From 8d62c1fbe8dc4de5da6b6a5452da2eae4de2f027 Mon Sep 17 00:00:00 2001
From: tegwick
Date: Tue, 29 Sep 2026 15:50:56 +0200
Subject: [PATCH] Active user attribute set, latest activities, guarded changes
to used values, artefact links, durations and averages; irreversible showcase
step
Co-Authored-By: Claude Sonnet 5.5
Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
---
docs/maturity-and-projects.md | 9 +
docs/pack-spec-v0.2.md | 2 +-
docs/run-spec.md | 23 ++
rtut/activity.py | 59 +++++
rtut/artefacts.py | 63 +++++
rtut/attrs.py | 4 +-
rtut/run.py | 54 +++-
rtut/run2.py | 42 ++-
rtut/ui.py | 94 ++++++-
rtut/ui_projects.py | 6 +-
rtut/ui_v2.py | 93 ++++++-
rtut/user.py | 89 +++++++
runbooks/showcase-lighthouse-v2/REVIEWS.yaml | 5 +
runbooks/showcase-lighthouse-v2/pack.yaml | 3 +-
runbooks/showcase-lighthouse/pack.yaml | 8 +-
tests/conftest.py | 9 +
tests/test_activity_artefacts.py | 93 +++++++
tests/test_run2.py | 39 +++
tests/test_score.py | 36 +++
tests/test_ui_round3.py | 263 +++++++++++++++++++
tests/test_user.py | 51 ++++
workplans/RBT-WP-0004-local-runbook-ui.md | 15 ++
22 files changed, 1025 insertions(+), 35 deletions(-)
create mode 100644 rtut/activity.py
create mode 100644 rtut/artefacts.py
create mode 100644 rtut/user.py
create mode 100644 runbooks/showcase-lighthouse-v2/REVIEWS.yaml
create mode 100644 tests/conftest.py
create mode 100644 tests/test_activity_artefacts.py
create mode 100644 tests/test_ui_round3.py
create mode 100644 tests/test_user.py
diff --git a/docs/maturity-and-projects.md b/docs/maturity-and-projects.md
index e0e95e4..e667a68 100644
--- a/docs/maturity-and-projects.md
+++ b/docs/maturity-and-projects.md
@@ -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
diff --git a/docs/pack-spec-v0.2.md b/docs/pack-spec-v0.2.md
index f9cf8c0..58bb6b6 100644
--- a/docs/pack-spec-v0.2.md
+++ b/docs/pack-spec-v0.2.md
@@ -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
diff --git a/docs/run-spec.md b/docs/run-spec.md
index 1979669..46fc553 100644
--- a/docs/run-spec.md
+++ b/docs/run-spec.md
@@ -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///artefact/`, `?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,
diff --git a/rtut/activity.py b/rtut/activity.py
new file mode 100644
index 0000000..d9dffaa
--- /dev/null
+++ b/rtut/activity.py
@@ -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]
diff --git a/rtut/artefacts.py b/rtut/artefacts.py
new file mode 100644
index 0000000..2bb234b
--- /dev/null
+++ b/rtut/artefacts.py
@@ -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
diff --git a/rtut/attrs.py b/rtut/attrs.py
index 52584b8..aaead93 100644
--- a/rtut/attrs.py
+++ b/rtut/attrs.py
@@ -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]]:
diff --git a/rtut/run.py b/rtut/run.py
index 3e2e0eb..27d6b8d 100644
--- a/rtut/run.py
+++ b/rtut/run.py
@@ -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
diff --git a/rtut/run2.py b/rtut/run2.py
index 54c12b5..2206987 100644
--- a/rtut/run2.py
+++ b/rtut/run2.py
@@ -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)
diff --git a/rtut/ui.py b/rtut/ui.py
index 63af077..ff3861a 100644
--- a/rtut/ui.py
+++ b/rtut/ui.py
@@ -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""
+ f"
Runs, reviews and activity are recorded under {e(u['operator'])}, taken from {src}.
{env}"
+ "
These are the attributes of the app-level set user-attributes. Every project can reuse it "
+ "(attribute_sets: [user-attributes]). If the UI later sits behind a login, the login fills the same attribute.
"
+ f"")
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"
Questions for this stage(values used by its steps; never enter secrets)"
f"
")
+ f"{''.join(rows)}{_override_fields(c, locked)}")
-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 "
Values already in use by steps can only be changed in manual mode: take over first.
"
+ return ("Changing a value that steps already used (override)"
+ "
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.
"
+ ""
+ "")
+
+
+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""
err = f" {e(rec.get('error', ''))}" if rec["state"] == "err" else ""
- return f"{err}"
+ use = (f" in use by {e(', '.join(users))}"
+ if users and rec.get("value") is not None else "")
+ return f"{err}"
# ---- steps ------------------------------------------------------------------------------------------------------------------
@@ -262,6 +295,9 @@ def _step(c: Ctx, s: dict) -> str:
marks.append("irreversible")
if s.get("auto_confirm") == "manual-only":
marks.append("manual-only")
+ took = R1.run_times(run)["step_seconds"].get(sid)
+ if took is not None and state in DONE:
+ marks.append(f"took {e(R1.fmt_duration(took))}")
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"
Every datapoint of this run, independent of the flow. Tags (dimensions) come from the pack; click one to filter.
{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"")
arts = R.artefact_rows(run)
if arts:
- out.append("
Artefacts
Artefact
Where it lives
Produced by
Until
" + "".join(
+ out.append("
Artefacts
Artefact
Where it lives
Open
Produced by
Until
" + "".join(
f"
{e(a['title'])}
{e(a['location'] or '(not known yet)')}
"
+ f"
{_artefact_links(c, a)}
"
f"
{e(a['produced_by'])} {sp(a['produced_state'])}
{e(a['lives_until'] or '-')}
" for a in arts) + "
")
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"Open link"
+ path, why = A.check(loc)
+ if path is None:
+ return f"{e(why)}"
+ url = f"{c.base}/artefact/{e(a['id'])}"
+ return f"Open · Download"
+
+
+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))
diff --git a/rtut/user.py b/rtut/user.py
new file mode 100644
index 0000000..7647cc9
--- /dev/null
+++ b/rtut/user.py
@@ -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}, [])
diff --git a/runbooks/showcase-lighthouse-v2/REVIEWS.yaml b/runbooks/showcase-lighthouse-v2/REVIEWS.yaml
new file mode 100644
index 0000000..f63197f
--- /dev/null
+++ b/runbooks/showcase-lighthouse-v2/REVIEWS.yaml
@@ -0,0 +1,5 @@
+reviews:
+- by: Bernd
+ at: '2026-09-29T13:19:03+00:00'
+ digest: 18e2c938b35bf59c
+ note: This is my first try.
diff --git a/runbooks/showcase-lighthouse-v2/pack.yaml b/runbooks/showcase-lighthouse-v2/pack.yaml
index de88cf0..f446b35 100644
--- a/runbooks/showcase-lighthouse-v2/pack.yaml
+++ b/runbooks/showcase-lighthouse-v2/pack.yaml
@@ -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
diff --git a/runbooks/showcase-lighthouse/pack.yaml b/runbooks/showcase-lighthouse/pack.yaml
index 66878c9..cc9520b 100644
--- a/runbooks/showcase-lighthouse/pack.yaml
+++ b/runbooks/showcase-lighthouse/pack.yaml
@@ -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}}
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..624f8b9
--- /dev/null
+++ b/tests/conftest.py
@@ -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")
diff --git a/tests/test_activity_artefacts.py b/tests/test_activity_artefacts.py
new file mode 100644
index 0000000..7e3aeb9
--- /dev/null
+++ b/tests/test_activity_artefacts.py
@@ -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
diff --git a/tests/test_run2.py b/tests/test_run2.py
index eec3225..e8f248e 100644
--- a/tests/test_run2.py
+++ b/tests/test_run2.py
@@ -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)
diff --git a/tests/test_score.py b/tests/test_score.py
index cff0836..36977ee 100644
--- a/tests/test_score.py
+++ b/tests/test_score.py
@@ -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
diff --git a/tests/test_ui_round3.py b/tests/test_ui_round3.py
new file mode 100644
index 0000000..334cbcd
--- /dev/null
+++ b/tests/test_ui_round3.py
@@ -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 "bernd" 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 Bernd W. (bernd)" in body and "change" in body
+ assert "Your name (one word)" not in body and "yes" 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("")[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("
Runbooks
")[0]
+ assert "erin 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"open" 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"Open" in body and "?download=1'>Download" 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 " 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 " 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
diff --git a/tests/test_user.py b/tests/test_user.py
new file mode 100644
index 0000000..c6c5c86
--- /dev/null
+++ b/tests/test_user.py
@@ -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
diff --git a/workplans/RBT-WP-0004-local-runbook-ui.md b/workplans/RBT-WP-0004-local-runbook-ui.md
index 720041f..945f2a4 100644
--- a/workplans/RBT-WP-0004-local-runbook-ui.md
+++ b/workplans/RBT-WP-0004-local-runbook-ui.md
@@ -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.