Run a whole project: project runs in manual and auto-confirm mode (RBT-WP-0008-T10)
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 20:08:17 +02:00
parent 1d1087fa7f
commit eff75a303c
14 changed files with 1112 additions and 15 deletions

View file

@ -181,6 +181,22 @@ still need values. Instantiating a template creates a project file with **its ow
came from, and shows which required attributes are still needed. Nothing of the template's own title, goal or description is copied into the
project: a project is not a copy of its template, it only remembers where it came from. Templates make a set of runbooks reusable as a product.
### Project runs
A **project run** runs every member runbook of a project once, in `after` order, in one mode. The record lives next to the project
(`.local/project-runs/`); its state is derived from the member runs, which carry it in their context.
- **Snapshot.** When it starts, the project's resolved attributes are snapshotted per member. Every member run uses the snapshot, so editing the
project meanwhile cannot make member runs inconsistent; the page says when the project has changed since. A new project run picks up the new values.
- **Manual.** You start each member yourself, one at a time. Starting a member before what it waits for is an override: a reason and an
acknowledged risk, recorded on the project run.
- **Auto-confirm.** The engine starts the next ready member, ticks its run, closes it when it finishes cleanly and moves on (`Run until it waits`).
It stops wherever a person is needed: a v0.1 member (always manual), a manual-only or irreversible step, a check that does not pass. A member
that finished only by pushing through problems counts as done for its dependents, but the project run can then no longer succeed.
- **Member status**: `waiting`, `ready`, `running`, `succeeded`, `pushed`, `failed`, `abandoned`. A failed or abandoned member blocks its dependents.
- **Closing.** `succeeded` needs every member run to have succeeded; `failed` needs a failed, abandoned or pushed member (or an open member run);
`abandoned` any time. Closing abandons an open member run, locks the record and seals it like a run. Only one project run per project is open at a time.
### Modes
Any project run can use any mode: **manual** (operator confirms everything), **auto-confirm** (the engine confirms when

View file

@ -63,10 +63,17 @@ def events_for(pack, pack_index: int | None) -> list[dict]:
return out
def latest(packs: list, limit: int = 15) -> list[dict]:
def project_run_events(pr) -> list[dict]:
return [{"pack": pr.data["title"], "run": None, "link": f"/project-run/{pr.id}", "project": pr.data["project"],
"at": ev["at"], "who": ev["who"], "text": "project run: " + ev["text"]} for ev in pr.data["events"]]
def latest(packs: list, limit: int = 15, project_runs: list | None = None) -> 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)
for pr in project_runs or []:
events += project_run_events(pr)
events.sort(key=lambda x: x["at"], reverse=True)
return events[:limit]

View file

@ -69,8 +69,12 @@ def validate(reg: Registry, doc: Doc) -> list[str]:
# ---- attribute resolution --------------------------------------------------------------------------
def _member(d: dict, pack_id: str, target: str | None = None) -> dict:
"""The member for this runbook and target; a member without a target matches target=None."""
for m in d["runbooks"]:
if m["pack"] == pack_id and (target is None or m.get("target") == target):
if m["pack"] == pack_id and m.get("target") == target:
return m
for m in d["runbooks"]: # lenient: a runbook named without its target
if m["pack"] == pack_id and target is None:
return m
raise KeyError(pack_id)
@ -189,8 +193,12 @@ def state(reg: Registry, doc: Doc) -> dict:
# ---- runs from a project ------------------------------------------------------------------------------------
def start_run(reg: Registry, doc: Doc, pack_id: str, operator: str, target: str | None = None, mode: str = "manual"):
"""Create a run of a member runbook with the project's attributes as defaults and the project as context."""
def start_run(reg: Registry, doc: Doc, pack_id: str, operator: str, target: str | None = None, mode: str = "manual",
values: dict | None = None, extra_context: dict | None = None):
"""Create a run of a member runbook with the project's attributes as defaults and the project as context.
`values` replaces the current attributes (a project run passes the snapshot it took at its start);
`extra_context` adds to the run's context (for example the project run it belongs to)."""
d = doc.data
if d["spec"] != "project/v0.1":
raise R.RunError("a template is not run; instantiate it into a project first")
@ -199,8 +207,8 @@ def start_run(reg: Registry, doc: Doc, pack_id: str, operator: str, target: str
raise R.RunError(f"project {doc.id} is invalid: {errs[0]}")
member = _member(d, pack_id, target)
pack = reg.packs[pack_id]
ctx = {"project": doc.id, "environment": d["environment"], "target": member.get("target")}
values = member_values(reg, d, member)
ctx = {"project": doc.id, "environment": d["environment"], "target": member.get("target"), **(extra_context or {})}
values = dict(values) if values is not None else member_values(reg, d, member)
if pack.spec == "runbook-pack/v0.2":
from . import run2
return run2.create(pack, operator, values, mode=mode, context=ctx)

334
rtut/projectrun.py Normal file
View file

@ -0,0 +1,334 @@
"""A project run: one run of every member runbook of a project, in `after` order, in one mode.
The record lives next to the project (`.local/project-runs/`); its state is derived from the member runs, which carry it in
their context. It snapshots the project's resolved attributes when it starts, so every member run uses the same values even if
the project is edited meanwhile. Manual: the operator starts each member, one at a time. Auto-confirm: the engine starts the next
ready member, ticks its run, closes it when it finishes cleanly and moves on, stopping wherever a person is needed.
Closing locks and seals the record like a run.
"""
from __future__ import annotations
import json
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from . import project as J
from . import run as R
from . import run2
from .attrs import Doc, Registry
from .run import RunError, _clean, _now, _read_json, _seal_of
CONTINUE = ("member-started", "member-finished", "started", "completed", "push-through")
OUTCOMES = ("succeeded", "failed", "abandoned")
def key_of(member: dict) -> str:
return member["pack"] + (f"@{member['target']}" if member.get("target") else "")
@dataclass
class ProjectRun:
path: Path
data: dict
@property
def id(self) -> str:
return self.data["id"]
@property
def closed(self) -> bool:
return bool(self.data.get("outcome"))
def save(self) -> None:
disk = _read_json(self.path)
if disk and disk.get("outcome"):
changed = {k for k in set(disk) | set(self.data) if disk.get(k) != self.data.get(k)} - {"updated"}
if changed:
raise RunError(f"this project run is closed and locked ({disk['outcome']}); it cannot be changed")
self.data["updated"] = _now()
self.path.parent.mkdir(parents=True, exist_ok=True)
if self.path.exists():
self.path.chmod(0o644)
self.path.write_text(json.dumps(self.data, indent=2, sort_keys=True) + "\n")
if self.data.get("outcome"):
self.path.chmod(0o444)
def event(self, text: str, who: str | None = None) -> None:
self.data["events"].append({"at": _now(), "who": who or self.data["operator"], "text": text})
def runs_dir(doc: Doc) -> Path:
return Path(doc.path).resolve().parents[2] / ".local" / "project-runs"
def load(reg: Registry, run_id: str) -> ProjectRun:
for doc in reg.projects.values():
f = runs_dir(doc) / f"{run_id}.json"
if f.exists():
return ProjectRun(f, json.loads(f.read_text()))
raise RunError(f"no such project run: {run_id}")
def list_runs(reg: Registry, project_id: str | None = None) -> list[ProjectRun]:
out, seen = [], set()
for doc in reg.projects.values():
d = runs_dir(doc)
if d in seen or not d.exists():
continue
seen.add(d)
for f in sorted(d.glob("*.json")):
data = _read_json(f)
if data and (project_id is None or data.get("project") == project_id):
out.append(ProjectRun(f, data))
return sorted(out, key=lambda p: p.data["created"], reverse=True)
def active_run(reg: Registry, project_id: str) -> ProjectRun | None:
return next((p for p in list_runs(reg, project_id) if not p.closed), None)
# ---- create --------------------------------------------------------------------------------------------------------
def create(reg: Registry, doc: Doc, operator: str, mode: str = "manual") -> ProjectRun:
if doc.data.get("spec") != "project/v0.1":
raise RunError("a template is not run; instantiate it into a project first")
errs = J.validate(reg, doc)
if errs:
raise RunError(f"project {doc.id} is invalid: {errs[0]}")
if mode not in ("manual", "auto-confirm"):
raise RunError("mode must be manual or auto-confirm")
needs = J.missing_required(reg, doc.data)
if needs:
raise RunError("attributes still needed: " + ", ".join(sorted({n["attribute"] for n in needs})))
running = active_run(reg, doc.id)
if running:
raise RunError(f"project run {running.id} is still open; close it first")
operator = _clean(operator.strip(), "operator")
d = doc.data
members, snapshot = [], {}
for spec in d["runbooks"]:
k = key_of(spec)
members.append({"key": k, "pack": spec["pack"], "target": spec.get("target"), "after": list(spec.get("after", [])), "run_id": None})
snapshot[k] = J.member_values(reg, d, spec)
data = {"spec": "project-run/v0.1", "id": time.strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6], "project": doc.id,
"title": d.get("title", doc.id), "environment": d["environment"], "operator": operator, "mode": mode,
"created": _now(), "members": members, "snapshot": snapshot, "events": [], "deviations": [], "outcome": None}
pr = ProjectRun(runs_dir(doc) / f"{data['id']}.json", data)
pr.event(f"started the project run ({mode} mode)")
pr.save()
return pr
# ---- derived view ---------------------------------------------------------------------------------------------------
def _member_run(reg: Registry, m: dict):
if not m.get("run_id"):
return None
pack = reg.packs.get(m["pack"])
try:
return R.load(pack, m["run_id"]) if pack else None
except RunError:
return None
def _done(status: str) -> bool:
return status in ("succeeded", "pushed")
def view(reg: Registry, pr: ProjectRun) -> dict:
"""Members with their derived status, what is ready, and overall progress."""
rows = []
for m in pr.data["members"]:
run = _member_run(reg, m)
row = {**m, "status": "waiting", "progress": None, "deviations": 0, "detail": ""}
if run is not None:
d = run.data
row["deviations"] = len(d.get("deviations", []))
if d.get("outcome"):
pushed = any(s["state"] == "pushed" for s in d["steps"].values()) or any(
g["state"] == "pushed" for g in d.get("gates", {}).values())
row["status"] = "pushed" if d["outcome"] == "failed" and pushed else d["outcome"]
else:
row["status"] = "running"
if run.pack.spec == "runbook-pack/v0.2":
ov = run2.overview(run)
row["progress"] = {"done": ov["progress"]["done"] + ov["progress"]["pushed"], "total": ov["progress"]["total"]}
ap = d["auto_confirm"]
if row["status"] == "running" and ap.get("stopped"):
row["detail"] = f"auto-confirm stopped: {ap['stopped']['reason']}"
elif row["status"] == "running" and d["mode"] == "auto-confirm" and ap.get("last"):
row["detail"] = f"{ap['last']['action']} {ap['last'].get('item') or ''} {ap['last'].get('detail', '')}".strip()
else:
steps = list(d["steps"].values())
row["progress"] = {"done": sum(s["state"] in ("done", "skipped") for s in steps), "total": len(steps)}
rows.append(row)
by_pack: dict[str, list[dict]] = {}
for r in rows:
by_pack.setdefault(r["pack"], []).append(r)
for r in rows:
if r["status"] != "waiting":
continue
unmet = [a for a in r["after"] if not all(_done(x["status"]) for x in by_pack.get(a, []))]
r["blocked_by"] = unmet
r["status"] = "ready" if not unmet else "waiting"
open_member = next((r for r in rows if r["status"] == "running"), None)
total = len(rows)
done = sum(r["status"] in ("succeeded", "pushed") for r in rows)
return {"members": rows, "open": open_member["key"] if open_member else None, "done": done, "total": total,
"ready": [r["key"] for r in rows if r["status"] == "ready"],
"problems": [r["key"] for r in rows if r["status"] in ("failed", "abandoned", "pushed")]}
def _writable(pr: ProjectRun) -> None:
if pr.closed:
raise RunError(f"this project run is closed and locked ({pr.data['outcome']})")
# ---- starting members ------------------------------------------------------------------------------------------------------
def start_member(reg: Registry, doc: Doc, pr: ProjectRun, key: str, override_reason: str = "", acknowledge_risk: bool = False):
"""Start the member's run with the snapshot values. One member is open at a time; out of order needs an override."""
_writable(pr)
v = view(reg, pr)
row = next((r for r in v["members"] if r["key"] == key), None)
if row is None:
raise RunError(f"no such member: {key}")
if row["status"] in ("running", "succeeded", "pushed", "failed", "abandoned") and row.get("run_id"):
raise RunError(f"{key} already has a run ({row['status']})")
if v["open"]:
raise RunError(f"finish or abandon the open run of {v['open']} first")
if row["status"] == "waiting":
need = ", ".join(row["blocked_by"])
if not override_reason.strip():
raise RunError(f"{key} waits for {need}. To start it anyway give a reason and acknowledge the risk; this is recorded.")
if not acknowledge_risk:
raise RunError("an override needs the operator to acknowledge the risk")
if pr.data["mode"] != "manual":
raise RunError("overrides are made in manual mode")
pr.data["deviations"].append({"kind": "override", "item": key, "unmet": [f"{a} is not done" for a in row["blocked_by"]],
"reason": _clean(override_reason.strip(), "reason"), "who": pr.data["operator"], "at": _now()})
spec = J._member(doc.data, row["pack"], row.get("target"))
mode = pr.data["mode"] if reg.packs[row["pack"]].spec == "runbook-pack/v0.2" else "manual"
run = J.start_run(reg, doc, row["pack"], pr.data["operator"], row.get("target"), mode,
values=pr.data["snapshot"][key], extra_context={"project_run": pr.id})
for m in pr.data["members"]:
if m["key"] == key:
m["run_id"] = run.data["run_id"]
pr.event(f"started {key}" + (" (override)" if row["status"] == "waiting" else ""))
pr.save()
return run
def attributes_changed(reg: Registry, doc: Doc, pr: ProjectRun) -> list[str]:
"""Member keys whose current project attributes differ from the snapshot this project run started with."""
out = []
for spec in doc.data["runbooks"]:
k = key_of(spec)
if k in pr.data["snapshot"] and J.member_values(reg, doc.data, spec) != pr.data["snapshot"][k]:
out.append(k)
return out
# ---- auto-confirm ----------------------------------------------------------------------------------------------------------------
def tick(reg: Registry, doc: Doc, pr: ProjectRun) -> dict:
"""One auto-confirm decision for the project run."""
_writable(pr)
if pr.data["mode"] != "auto-confirm":
raise RunError("tick needs an auto-confirm project run")
v = view(reg, pr)
if v["open"]:
row = next(r for r in v["members"] if r["key"] == v["open"])
run = _member_run(reg, row)
if run.pack.spec != "runbook-pack/v0.2" or run.data["mode"] != "auto-confirm":
return {"action": "awaiting-operator", "member": row["key"], "detail": "this member is run by you, in manual mode"}
res = run2.auto_confirm_tick(run)
run.data["auto_confirm"]["last"] = res
run.save()
if res["action"] == "finished":
try:
R.finalize(run, "succeeded", "closed by auto-confirm")
outcome = "succeeded"
except RunError:
R.finalize(run, "failed", "closed by auto-confirm: pushed through problems")
outcome = "failed"
pr.event(f"{row['key']} finished ({outcome})")
pr.save()
return {"action": "member-finished", "member": row["key"], "detail": outcome}
return {**res, "member": row["key"]}
if v["ready"]:
start_member(reg, doc, pr, v["ready"][0])
return {"action": "member-started", "member": v["ready"][0], "detail": ""}
if v["done"] == v["total"]:
return {"action": "finished", "member": None, "detail": "every member is done"}
stuck = [r for r in v["members"] if r["status"] == "waiting"]
why = "; ".join(f"{r['key']} waits for {', '.join(r['blocked_by'])}" for r in stuck) or "nothing can start"
return {"action": "blocked", "member": None, "detail": why}
def run_until_waits(reg: Registry, doc: Doc, pr: ProjectRun, max_ticks: int = 300) -> dict:
"""Tick until something needs a person, everything is done, or the limit is reached. Returns the last result."""
last = {"action": "idle", "member": None, "detail": ""}
for _ in range(max_ticks):
last = tick(reg, doc, pr)
if last["action"] not in CONTINUE:
break
pr.data["last"] = last
pr.save()
return last
def switch_mode(reg: Registry, pr: ProjectRun, mode: str) -> None:
"""Take over or hand over the project run; open v0.2 member runs follow."""
_writable(pr)
if mode not in ("manual", "auto-confirm") or mode == pr.data["mode"]:
raise RunError(f"the project run is already in {pr.data['mode']} mode" if mode == pr.data["mode"] else "mode must be manual or auto-confirm")
for m in pr.data["members"]:
run = _member_run(reg, m)
if run is not None and not run.data.get("outcome") and run.pack.spec == "runbook-pack/v0.2" and run.data["mode"] != mode:
run2.switch_mode(run, mode)
pr.data["mode"] = mode
pr.event(f"switched to {mode} mode")
pr.save()
# ---- closing -------------------------------------------------------------------------------------------------------------------------
def finalize(reg: Registry, pr: ProjectRun, outcome: str, note: str = "") -> None:
"""Close the project run, locked and sealed. Open member runs are abandoned with it."""
_writable(pr)
if outcome not in OUTCOMES:
raise RunError(f"outcome must be one of {', '.join(OUTCOMES)}")
v = view(reg, pr)
if outcome == "succeeded" and not all(r["status"] == "succeeded" for r in v["members"]):
raise RunError("a project run succeeds only when every member run succeeded")
if outcome == "failed" and not v["problems"] and not v["open"]:
raise RunError("a project run can be finished as failed only when a member failed, was abandoned or pushed through; otherwise abandon it")
for r in v["members"]:
if r["status"] == "running":
run = _member_run(reg, r)
R.finalize(run, "abandoned", f"project run closed as {outcome}")
pr.data["outcome"] = outcome
pr.data["finished"] = _now()
if note.strip():
pr.data["outcome_note"] = _clean(note.strip(), "note")
pr.event(f"closed the project run as {outcome}")
pr.data["seal"] = _seal_of(pr.data)
pr.save()
def seal_state(pr: ProjectRun) -> str:
disk = _read_json(pr.path) or pr.data
if not disk.get("outcome"):
return "open"
return "sealed" if disk.get("seal") == _seal_of(disk) else "broken"
def times(pr: ProjectRun, now: datetime | None = None) -> dict:
now = now or datetime.now(timezone.utc)
start = R._ts(pr.data["created"])
end = R._ts(pr.data["finished"]) if pr.data.get("finished") else now
return {"started": pr.data["created"], "finished": pr.data.get("finished"), "elapsed": max(0.0, (end - start).total_seconds())}

View file

@ -92,7 +92,7 @@ def _context(ctx: dict | None) -> dict:
ctx = dict(ctx or {})
if ctx.get("environment") and ctx["environment"] not in ENVIRONMENTS:
raise RunError(f"environment must be one of {', '.join(ENVIRONMENTS)}")
return {k: _clean(str(v), f"context {k}") for k, v in ctx.items() if v and k in ("project", "target", "environment")}
return {k: _clean(str(v), f"context {k}") for k, v in ctx.items() if v and k in ("project", "target", "environment", "project_run")}
def create(pack: Pack, operator: str, params: dict[str, str] | None = None, context: dict | None = None) -> Run:

View file

@ -15,6 +15,8 @@ from . import ui_projects as UP
from . import ui_v2 as UI2
from . import activity as ACT, user as USER
from . import ui_attrs as UA
from . import ui_projectrun as UPR
from . import projectrun as PRUN
from .attrs import Registry
LIGHT = "--fg:#141414;--bg:#fbfbf9;--mut:#6b6b66;--line:#d9d9d2;--card:#fff;--hi:#f2e35a;--ok:#1a7f37;--warn:#9a6700;--bad:#c62828;--side:#f3f3ef"
@ -127,7 +129,7 @@ def page(title: str, body: str) -> bytes:
"const f=b.closest('form'),n=f.querySelector('[name=note]'),w=f.querySelector('[data-warn]');"
"if(n&&!n.value.trim()&&!b.dataset.warned){ev.preventDefault();b.dataset.warned='1';w.hidden=false;n.focus();}}));"
# sidebar: mark where we are; theme switch cycles system -> light -> dark
"const p=location.pathname;const alias={'/projects':['/project/','/template/'],'/runbooks':['/pack/','/run/'],'/attribute-sets':['/attribute-set/']};"
"const p=location.pathname;const alias={'/projects':['/project/','/template/','/project-run/'],'/runbooks':['/pack/','/run/'],'/attribute-sets':['/attribute-set/']};"
"document.querySelectorAll('a[data-nav]').forEach(a=>{const h=a.getAttribute('href');"
"if(h==='/'?p==='/':p===h||p.startsWith(h+'/')||(alias[h]||[]).some(x=>p.startsWith(x)))a.setAttribute('aria-current','page')});"
"const tb=document.querySelector('[data-theme-toggle]');const cur=()=>{try{return localStorage.getItem('rtut-theme')||'system'}catch(x){return 'system'}};"
@ -186,7 +188,7 @@ class App:
f"<p class=note>Running as <b>{label}</b> · <a href='/user'>change</a></p>")
def activities_html(self, limit: int = 12, level: str = "h2", link: bool = False) -> str:
events = ACT.latest(self.packs, limit)
events = ACT.latest(self.packs, limit, PRUN.list_runs(self.registry()))
if not events:
return f"{heading(level, 'Latest activities', '/activity' if link else None)}<p class=note>Nothing has happened yet. Start a run or a project.</p>"
rows = "".join(
@ -379,7 +381,8 @@ class App:
where = ""
if ctx.get("project"):
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', ''))}")
f"{' @' + e(ctx['target']) if ctx.get('target') else ''} · {e(ctx.get('environment', ''))}"
f"{' · part of <a href=' + chr(39) + '/project-run/' + e(ctx['project_run']) + chr(39) + '>project run ' + e(ctx['project_run']) + '</a>' if ctx.get('project_run') else ''}")
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>{UI2.times_line(r)}</p>"]
@ -520,6 +523,10 @@ def make_handler(app: App, port_holder: list):
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-run" and len(parts) == 2:
body = UPR.page_for(app, parts[1])
if body:
return self._send(body)
if parts[0] == "project" and len(parts) == 2:
body = UP.project_page(app, parts[1])
if body:
@ -569,6 +576,8 @@ def make_handler(app: App, port_holder: list):
except M.ReviewError as ex:
raise ValueError(str(ex))
return self._redirect(f"/pack/{i}#maturity")
if (len(parts) == 3 and parts[0] == "project" and parts[2] == "runall") or (len(parts) == 3 and parts[0] == "project-run"):
return self._redirect(UPR.handle_post(app, parts, form))
if len(parts) == 3 and parts[0] == "project" and parts[2] == "edit":
from . import project as J
doc = app.registry().projects[parts[1]]
@ -623,6 +632,8 @@ def make_handler(app: App, port_holder: list):
except (R.RunError, KeyError, ValueError, IndexError) as ex:
msg = str(ex)
try:
if parts[0] == "project-run":
return self._send(UPR.page_for(app, parts[1], msg) or page("Error", f"<p class=bad>{e(msg)}</p>"), 400)
if parts[0] == "project":
return self._send(UP.project_page(app, parts[1], msg) or page("Error", f"<p class=bad>{e(msg)}</p>"), 400)
if parts[0] == "template":

208
rtut/ui_projectrun.py Normal file
View file

@ -0,0 +1,208 @@
"""UI for project runs: start one from a project, follow it member by member, in manual or auto-confirm mode."""
from __future__ import annotations
import html
from . import projectrun as PR, run as R
from .run import fmt_duration
MODE_HELP = {
"manual": "You start each member yourself, one at a time, in the project's order. Every run inside it is manual.",
"auto-confirm": ("The engine starts the next ready member, confirms what it can, closes each member run when it finishes cleanly, and moves on. "
"It stops wherever a person is needed. It never runs commands, and v0.1 members are always run by you."),
}
def e(s) -> str:
return html.escape(str(s))
def pill(text: str) -> str:
return f"<span class=pill data-s='{e(text)}'>{e(text)}</span>"
def stamp(iso: str | None) -> str:
return iso[:16].replace("T", " ") + " UTC" if iso else "-"
# ---- on the project page ---------------------------------------------------------------------------------------------------
def project_section(app, reg, doc, needs: list[dict]) -> str:
pid = doc.id
active = PR.active_run(reg, pid)
out = ["<h2>Run the whole project</h2>"]
if active:
out.append(f"<p>A project run is in progress ({pill(active.data['mode'])}): "
f"<a href='/project-run/{e(active.id)}'>continue it</a>. Only one runs at a time.</p>")
elif needs:
out.append(f"<p class=note>Give the project the attributes it still needs first: {e(', '.join(sorted({n['attribute'] for n in needs})))}.</p>")
else:
opts = "".join(f"<label><input type=radio name=mode value='{m}'{' checked' if m == 'manual' else ''}> <b>{m}</b>: {e(t)}</label><br>"
for m, t in MODE_HELP.items())
out.append(f"<form method=post action='/project/{e(pid)}/runall'>{app.hidden()}<fieldset><legend>Mode</legend>{opts}</fieldset>"
f"<p class=note>The project's attributes are snapshotted when it starts, so every member run uses the same values even if "
f"you edit the project meanwhile.</p>{app.who()}<button>Start project run</button></form>")
past = PR.list_runs(reg, pid)
if past:
rows = ""
for p in past:
v = PR.view(reg, p)
t = PR.times(p)
took = f"took {fmt_duration(t['elapsed'])}" if t["finished"] else f"running {fmt_duration(t['elapsed'])}"
rows += (f"<tr><td><a href='/project-run/{e(p.id)}'>{e(p.id)}</a></td><td>{pill(p.data['mode'])}</td>"
f"<td>{pill(p.data['outcome'] or 'in progress')}</td><td>{v['done']} of {v['total']} members</td>"
f"<td class=note>{e(stamp(t['started']))} · {e(took)} · {e(p.data['operator'])}</td></tr>")
out.append("<h3>Project runs</h3><table><tr><th>Run</th><th>Mode</th><th>Outcome</th><th>Progress</th><th>When</th></tr>" + rows + "</table>")
return "".join(out)
# ---- the project run page ------------------------------------------------------------------------------------------------------
def page_for(app, prid: str, msg: str = "") -> bytes | None:
from .ui import page
reg = app.registry()
try:
pr = PR.load(reg, prid)
except R.RunError:
return None
doc = reg.projects.get(pr.data["project"])
d = pr.data
closed = pr.closed
v = PR.view(reg, pr)
t = PR.times(pr)
manual = d["mode"] == "manual"
took = (f"finished {e(stamp(t['finished']))} · took <b>{e(fmt_duration(t['elapsed']))}</b>" if t["finished"]
else f"running for <b>{e(fmt_duration(t['elapsed']))}</b>")
out = [f"<h1>Project run: {e(d['title'])}</h1>",
f"<p>{pill(d['mode'])} {pill(d['outcome']) if d['outcome'] else pill('in progress')} {pill(d['environment'])} "
f"<span class=note>{e(d['id'])} · operator {e(d['operator'])} · <a href='/project/{e(d['project'])}'>project {e(d['project'])}</a></span></p>",
f"<p class=note>Started {e(stamp(t['started']))} · {took}</p>",
f"<p class=eyebrow>{v['done']} of {v['total']} members done</p><progress max={v['total']} value={v['done']} style='width:100%'></progress>"]
if msg:
out.append(f"<p class=bad>{e(msg)}</p>")
if doc is not None and not closed:
changed = PR.attributes_changed(reg, doc, pr)
if changed:
out.append("<p class=note>The project's attributes have changed since this project run started. It keeps the values it started with "
f"({e(', '.join(changed))}); a new project run picks up the new ones.</p>")
if d["deviations"]:
out.append("<p class=note>⚑ Deviations: " + "; ".join(f"{e(x['kind'])} {e(x['item'])} ({e(x['reason'])})" for x in d["deviations"]) + "</p>")
if not closed:
out.append(_controls(app, pr, v))
out.append(_members(app, reg, pr, v, manual and not closed))
out.append("<details><summary><b>Log</b></summary><ul class=feed>" + "".join(
f"<li><span class=note>{e(x['at'][:19].replace('T', ' '))}</span> · <b>{e(x['who'])}</b> {e(x['text'])}</li>" for x in reversed(d["events"])) + "</ul></details>")
out.append(_close(app, reg, pr, v))
return page(f"Project run {d['id']}", "".join(out))
def _controls(app, pr, v: dict) -> str:
d = pr.data
base = f"/project-run/{e(pr.id)}"
if d["mode"] == "manual":
if v["open"]:
row = next(r for r in v["members"] if r["key"] == v["open"])
return (f"<div class=banner><b>Continue:</b> {e(row['key'])} <span class=note>(its run is open)</span></div>"
f"<div class=panel><b>Manual mode.</b> <span class=note>You do the member run; when it is closed the next member becomes ready.</span>"
f"<form method=post action='{base}/mode' class=row>{app.hidden()}<input type=hidden name=to value=auto-confirm>"
"<button>Hand over to auto-confirm</button></form></div>")
if v["ready"]:
return (f"<div class=banner><b>Next:</b> {e(v['ready'][0])} <span class=note>(ready)</span></div>"
f"<div class=panel><b>Manual mode.</b> <span class=note>Start the next member below.</span>"
f"<form method=post action='{base}/mode' class=row>{app.hidden()}<input type=hidden name=to value=auto-confirm>"
"<button>Hand over to auto-confirm</button></form></div>")
if v["done"] == v["total"]:
return "<div class=banner><b>Every member is done.</b> Close the project run below.</div>"
return "<div class=banner><b>Nothing can start.</b> A member it depends on did not succeed. Close the project run, or start a member with an override.</div>"
last = d.get("last")
status = f"<p class=note>Last: {e(last['action'])} {e(last.get('member') or '')} {e(last.get('detail') or '')}</p>" if last else ""
return (f"<div class=panel data-s='auto'><b>Auto-confirm mode.</b> <span class=note>{e(MODE_HELP['auto-confirm'])}</span>{status}"
f"<form method=post action='{base}/tick' class=row>{app.hidden()}"
"<button name=n value=1>Check once</button> <button name=n value=300>Run until it waits</button></form>"
f"<form method=post action='{base}/mode' class=row>{app.hidden()}<input type=hidden name=to value=manual><button>Take over (manual)</button></form></div>")
def _members(app, reg, pr, v: dict, manual: bool) -> str:
base = f"/project-run/{e(pr.id)}"
rows = ""
for n, r in enumerate(v["members"], 1):
pack = reg.packs.get(r["pack"])
i = app.pack_index(r["pack"])
title = e(pack.data["title"]) if pack else e(r["pack"])
link = f"<a href='/pack/{i}'>{title}</a>" if i is not None else title
prog = f"{r['progress']['done']}/{r['progress']['total']}" if r.get("progress") else "-"
run = f" · <a href='/run/{i}/{e(r['run_id'])}'>open run</a>" if r.get("run_id") and i is not None else ""
extra = ""
if r["status"] == "waiting":
extra = f"<br><span class=note>waiting for {e(', '.join(r['blocked_by']))}</span>"
if r.get("detail"):
extra += f"<br><span class=note>{e(r['detail'])}</span>"
if r["deviations"]:
extra += f"<br><span class=note>⚑ {r['deviations']} deviation(s)</span>"
action = ""
if manual and not r.get("run_id") and not v["open"]:
key = e(r["key"])
if r["status"] == "ready":
action = f"<form method=post action='{base}/start'>{app.hidden()}<input type=hidden name=key value='{key}'><button>Start</button></form>"
elif r["status"] == "waiting":
action = (f"<details class=override><summary>Start anyway</summary><form method=post action='{base}/start'>{app.hidden()}"
f"<input type=hidden name=key value='{key}'><p class=bad>It waits for {e(', '.join(r['blocked_by']))}; starting it now is recorded as a deviation.</p>"
"<p><input name=override_reason placeholder='why (required)' required></p>"
"<p><label><input type=checkbox name=acknowledge_risk value=1 required> I acknowledge the risk.</label></p>"
"<button>Override and start</button></form></details>")
rows += (f"<tr><td>{n}</td><td>{link}<br><span class=note>{e(r['key'])}</span>{extra}</td><td>{pill(r['status'])}</td>"
f"<td>{prog}{run}</td><td>{action}</td></tr>")
return ("<h2>Members</h2><table><tr><th>#</th><th>Runbook</th><th>Status</th><th>Progress</th><th></th></tr>" + rows + "</table>")
def _close(app, reg, pr, v: dict) -> str:
d = pr.data
if pr.closed:
state = PR.seal_state(pr)
note = f"<p>Comment: {e(d['outcome_note'])}</p>" if d.get("outcome_note") else ""
broken = ("<p class=bad>⚠ This project run's record was changed outside the app after it was sealed.</p>" if state == "broken" else
"<p class=note>This project run is <b>closed and locked</b>: its record is sealed and cannot be changed. Start a new project run to try again.</p>")
return f"<div id=close class=step><h2>Project run finished: {e(d['outcome'])}</h2>{note}{broken}</div>"
btns = []
if all(r["status"] == "succeeded" for r in v["members"]):
btns.append("<button name=outcome value=succeeded>Finish: succeeded</button>")
if v["problems"] or v["open"]:
btns.append("<button name=outcome value=failed onclick=\"return confirm('Finish this project run as failed? An open member run is abandoned.')\">Finish: failed</button>")
btns.append("<button name=outcome value=abandoned onclick=\"return confirm('Abandon this project run? An open member run is abandoned.')\">Abandon project run</button>")
return (f"<div id=close class=step><h2>Close this project run</h2><p class=note>Closing records the outcome, abandons an open member run, and locks the record.</p>"
f"<form method=post action='/project-run/{e(pr.id)}/finalize'>{app.hidden()}"
f"<p><input name=note class=grow style='width:100%' placeholder='comment (optional)'></p><p class=row>{' '.join(btns)}</p></form></div>")
# ---- actions ---------------------------------------------------------------------------------------------------------------------------
def handle_post(app, parts: list[str], form: dict) -> str:
"""Returns the URL to go to; raises RunError/ValueError/KeyError with the reason if the action is refused."""
reg = app.registry()
if len(parts) == 3 and parts[0] == "project" and parts[2] == "runall":
doc = reg.projects[parts[1]]
pr = PR.create(reg, doc, form.get("operator", ""), form.get("mode", "manual"))
return f"/project-run/{pr.id}"
if len(parts) == 3 and parts[0] == "project-run":
pr = PR.load(reg, parts[1])
doc = reg.projects[pr.data["project"]]
action = parts[2]
if action == "start":
run = PR.start_member(reg, doc, pr, form.get("key", ""), form.get("override_reason", ""), bool(form.get("acknowledge_risk")))
i = app.pack_index(run.pack.id)
return f"/run/{i}/{run.data['run_id']}#current"
if action == "tick":
n = max(1, min(int(form.get("n", "1") or 1), 300))
if n == 1:
pr.data["last"] = PR.tick(reg, doc, pr)
pr.save()
else:
PR.run_until_waits(reg, doc, pr, n)
return f"/project-run/{pr.id}"
if action == "mode":
PR.switch_mode(reg, pr, form.get("to", ""))
return f"/project-run/{pr.id}"
if action == "finalize":
PR.finalize(reg, pr, form.get("outcome", ""), form.get("note", ""))
return f"/project-run/{pr.id}#close"
raise ValueError("unknown action")

View file

@ -166,6 +166,8 @@ def project_page(app, pid: str, msg: str = "") -> bytes | None:
out.append("<h2>Runbooks</h2>"
f"<p class=note>Next: {e(', '.join(st['next']) or 'nothing to start')}</p>"
"<table><tr><th>Runbook</th><th>Status</th><th>Maturity</th><th></th></tr>" + rows + "</table>")
from . import ui_projectrun as UPR
out.append(UPR.project_section(app, reg, doc, st["needs"]))
# attributes per member
out.append("<h2>Attributes</h2><p class=note>What each runbook receives from this project, and where each value comes from.</p>")
for spec in d["runbooks"]:

View file

@ -96,7 +96,8 @@ def run_page(app, i: int, rid: str, msg: str = "", query: dict | None = None) ->
where = ""
if ctx.get("project"):
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', ''))}")
f"{' @' + e(ctx['target']) if ctx.get('target') else ''} · {e(ctx.get('environment', ''))}"
f"{' · part of <a href=' + chr(39) + '/project-run/' + e(ctx['project_run']) + chr(39) + '>project run ' + e(ctx['project_run']) + '</a>' if ctx.get('project_run') else ''}")
ov = R.overview(run)
prog = ov["progress"]
out = [f"<h1>{e(pack.data['title'])}</h1>"

264
tests/test_projectrun.py Normal file
View file

@ -0,0 +1,264 @@
import copy
import shutil
import stat
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
import yaml
from rtut import project as J, projectrun as PR, run as R1, run2
from rtut.attrs import Registry
ROOT = Path(__file__).resolve().parent.parent
def v2_pack(pid, extra_step=None):
steps = [{"id": "first", "title": "First step", "actor": "op", "owner": "t", "uses": ["site"], "command": "echo {{site}}",
"verify": {"done_when": "you saw the site", "expect": "manual"}},
{"id": "second", "title": "Second step", "actor": "op", "owner": "t", "verify": {"done_when": "second done", "expect": "manual"}}]
if extra_step:
steps.append(extra_step)
return {"spec": "runbook-pack/v0.2", "id": pid, "title": f"Pack {pid}", "owner": "t", "exercise_status": "unexercised", "engine": "native",
"framing": ["keep in mind"], "actors": [{"id": "op", "role": "Operator", "description": "does it"}],
"datapoints": [{"id": "site", "label": "Site", "type": "string", "pattern": "[a-z]+", "default": "north", "ask_in": "main"}],
"stages": [{"id": "main", "title": "Main", "goal": "do the things", "steps": steps}]}
def write(path, data):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.safe_dump(data, sort_keys=False))
def make_repo(tmp_path, members=None, v1=False, alpha_extra=None, attrs=None):
write(tmp_path / "runbooks/alpha/pack.yaml", v2_pack("t.alpha", alpha_extra))
write(tmp_path / "runbooks/beta/pack.yaml", v2_pack("t.beta"))
if v1:
shutil.copy(ROOT / "runbooks/example-hello/pack.yaml", tmp_path / "runbooks/hello.yaml")
(tmp_path / "runbooks/hello").mkdir()
(tmp_path / "runbooks/hello.yaml").rename(tmp_path / "runbooks/hello/pack.yaml")
members = members or [{"pack": "t.alpha"}, {"pack": "t.beta", "after": ["t.alpha"]}]
write(tmp_path / "projects/demo/project.yaml", {"spec": "project/v0.1", "id": "demo", "title": "Demo project", "environment": "rehearsal",
"attributes": attrs or {"site": "south"}, "runbooks": members})
return Registry([tmp_path])
def do_all_manual(run):
for sid in run2.flow(run).steps:
run2.start(run, sid); run2.verify(run, sid, manual_ok=True); run2.complete(run, sid)
def finish_member(reg, doc, pr, key, outcome="succeeded"):
row = next(m for m in pr.data["members"] if m["key"] == key)
run = R1.load(reg.packs[row["pack"]], row["run_id"])
do_all_manual(run)
R1.finalize(run, outcome)
# ---- create ------------------------------------------------------------------------------------------------------------
def test_create_snapshots_attributes_records_events_and_refuses_bad_starts(tmp_path):
reg = make_repo(tmp_path)
doc = reg.projects["demo"]
pr = PR.create(reg, doc, "bernd")
assert pr.data["mode"] == "manual" and pr.data["snapshot"]["t.alpha"] == {"site": "south"} and pr.data["events"][0]["text"].startswith("started")
assert pr.path.parent.name == "project-runs" and PR.active_run(reg, "demo").id == pr.id
with pytest.raises(R1.RunError, match="still open"):
PR.create(reg, doc, "bernd") # one active project run at a time
with pytest.raises(R1.RunError, match="mode must be"):
PR.create(make_repo(tmp_path / "x"), Registry([tmp_path / "x"]).projects["demo"], "bernd", "autopilot")
with pytest.raises(R1.RunError, match="template is not run"):
PR.create(reg, Registry([ROOT]).templates["lighthouse-watch"], "bernd")
def test_missing_required_attributes_refuse_the_start(tmp_path):
p = v2_pack("t.alpha"); p["datapoints"][0].pop("default")
write(tmp_path / "runbooks/alpha/pack.yaml", p)
write(tmp_path / "projects/demo/project.yaml", {"spec": "project/v0.1", "id": "demo", "title": "Demo project", "environment": "rehearsal",
"runbooks": [{"pack": "t.alpha"}]})
reg = Registry([tmp_path])
with pytest.raises(R1.RunError, match="attributes still needed: site"):
PR.create(reg, reg.projects["demo"], "bernd")
# ---- manual mode ------------------------------------------------------------------------------------------------------------
def test_manual_project_run_goes_member_by_member_in_after_order(tmp_path):
reg = make_repo(tmp_path); doc = reg.projects["demo"]
pr = PR.create(reg, doc, "bernd")
v = PR.view(reg, pr)
assert [r["status"] for r in v["members"]] == ["ready", "waiting"] and v["members"][1]["blocked_by"] == ["t.alpha"] and v["ready"] == ["t.alpha"]
with pytest.raises(R1.RunError, match="waits for t.alpha"):
PR.start_member(reg, doc, pr, "t.beta") # out of order: refused with the reason
run = PR.start_member(reg, doc, pr, "t.alpha")
assert run.data["context"] == {"project": "demo", "environment": "rehearsal", "project_run": pr.id}
assert run.data["params"]["site"] == "south" and run.data["mode"] == "manual"
with pytest.raises(R1.RunError, match="already has a run"):
PR.start_member(reg, doc, pr, "t.alpha")
with pytest.raises(R1.RunError, match="finish or abandon the open run"):
PR.start_member(reg, doc, pr, "t.beta", override_reason="x", acknowledge_risk=True)
finish_member(reg, doc, pr, "t.alpha")
v = PR.view(reg, pr)
assert [r["status"] for r in v["members"]] == ["succeeded", "ready"] and v["done"] == 1 and v["open"] is None
PR.start_member(reg, doc, pr, "t.beta")
finish_member(reg, doc, pr, "t.beta")
assert PR.view(reg, pr)["done"] == 2
PR.finalize(reg, pr, "succeeded", "all good")
assert pr.data["outcome"] == "succeeded" and pr.data["events"][-1]["text"] == "closed the project run as succeeded"
def test_starting_out_of_order_needs_a_reason_and_acknowledgement_and_is_recorded(tmp_path):
reg = make_repo(tmp_path); doc = reg.projects["demo"]
pr = PR.create(reg, doc, "bernd")
with pytest.raises(R1.RunError, match="acknowledge"):
PR.start_member(reg, doc, pr, "t.beta", override_reason="testing beta first")
PR.start_member(reg, doc, pr, "t.beta", override_reason="testing beta first", acknowledge_risk=True)
dev = pr.data["deviations"][0]
assert dev["kind"] == "override" and dev["item"] == "t.beta" and dev["unmet"] == ["t.alpha is not done"] and dev["reason"] == "testing beta first"
assert pr.data["events"][-1]["text"] == "started t.beta (override)"
def test_snapshot_keeps_member_runs_consistent_when_the_project_is_edited_meanwhile(tmp_path):
reg = make_repo(tmp_path); doc = reg.projects["demo"]
pr = PR.create(reg, doc, "bernd")
PR.start_member(reg, doc, pr, "t.alpha"); finish_member(reg, doc, pr, "t.alpha")
J.set_attributes(reg, doc, {"site": "east"}) # the project changes while the project run is open
reg = Registry([tmp_path]); doc = reg.projects["demo"]
assert PR.attributes_changed(reg, doc, pr) == ["t.alpha", "t.beta"]
run = PR.start_member(reg, doc, pr, "t.beta")
assert run.data["params"]["site"] == "south" # the value the project run started with
assert J.member_values(reg, doc.data, doc.data["runbooks"][1])["site"] == "east"
# ---- auto-confirm --------------------------------------------------------------------------------------------------------------
def test_auto_confirm_runs_the_whole_project_member_by_member_and_closes_each_run(tmp_path):
reg = make_repo(tmp_path); doc = reg.projects["demo"]
pr = PR.create(reg, doc, "bernd", "auto-confirm")
last = PR.run_until_waits(reg, doc, pr)
assert last["action"] == "finished"
v = PR.view(reg, pr)
assert [r["status"] for r in v["members"]] == ["succeeded", "succeeded"] and v["done"] == 2
a = R1.load(reg.packs["t.alpha"], v["members"][0]["run_id"])
assert a.data["mode"] == "auto-confirm" and a.data["outcome_note"] == "closed by auto-confirm" and R1.seal_state(a) == "sealed"
assert [e["text"] for e in pr.data["events"] if "started" in e["text"] or "finished" in e["text"]][:3] == [
"started the project run (auto-confirm mode)", "started t.alpha", "t.alpha finished (succeeded)"]
PR.finalize(reg, pr, "succeeded")
def test_auto_confirm_stops_at_a_manual_member_and_continues_after_the_operator_finishes_it(tmp_path):
reg = make_repo(tmp_path, [{"pack": "t.alpha"}, {"pack": "rtut.example-hello", "after": ["t.alpha"]}, {"pack": "t.beta", "after": ["rtut.example-hello"]}], v1=True,
attrs={"site": "south", "host": "localhost"})
doc = reg.projects["demo"]
pr = PR.create(reg, doc, "bernd", "auto-confirm")
last = PR.run_until_waits(reg, doc, pr)
assert last["action"] == "awaiting-operator" and last["member"] == "rtut.example-hello" and "manual mode" in last["detail"]
hello = R1.load(reg.packs["rtut.example-hello"], next(m["run_id"] for m in pr.data["members"] if m["pack"] == "rtut.example-hello"))
assert hello.pack.spec == "runbook-pack/v0.1" and "mode" not in hello.data # a v0.1 run is always manual: it cannot be auto-confirmed
R1.start(hello, "ping"); R1.verify(hello, "ping", confirm=True); R1.complete(hello, "ping"); R1.finalize(hello, "succeeded")
assert PR.run_until_waits(reg, doc, pr)["action"] == "finished"
assert [r["status"] for r in PR.view(reg, pr)["members"]] == ["succeeded"] * 3
def test_auto_confirm_stops_when_a_member_needs_a_person(tmp_path):
step = {"id": "careful", "title": "Careful step", "actor": "op", "owner": "t", "auto_confirm": "manual-only",
"verify": {"done_when": "you did it", "expect": "manual"}}
reg = make_repo(tmp_path, alpha_extra=step); doc = reg.projects["demo"]
pr = PR.create(reg, doc, "bernd", "auto-confirm")
last = PR.run_until_waits(reg, doc, pr)
assert last["action"] == "awaiting-operator" and last["member"] == "t.alpha" and "manual-only" in last["detail"]
assert PR.view(reg, pr)["members"][0]["status"] == "running" and PR.view(reg, pr)["members"][1]["status"] == "waiting"
def test_a_pushed_through_member_lets_dependents_proceed_but_the_project_cannot_succeed(tmp_path):
step = {"id": "shaky", "title": "Shaky step", "actor": "op", "owner": "t", "timeout": {"seconds": 1, "on_timeout": "push-through"},
"verify": {"done_when": "never true", "command": "false", "expect": "exit-0"}}
reg = make_repo(tmp_path, alpha_extra=step); doc = reg.projects["demo"]
pr = PR.create(reg, doc, "bernd", "auto-confirm")
for _ in range(40):
last = PR.tick(reg, doc, pr)
if last["action"] == "waiting":
run = R1.load(reg.packs["t.alpha"], pr.data["members"][0]["run_id"])
run.data["steps"]["shaky"]["started"] = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat(); run.save()
if last["action"] in ("finished", "blocked", "awaiting-operator", "stopped"):
break
v = PR.view(reg, pr)
assert v["members"][0]["status"] == "pushed" and v["members"][0]["deviations"] >= 1 # closed as failed, but pushed through
assert v["members"][1]["status"] in ("succeeded", "running", "ready") # beta was allowed to proceed
assert v["problems"] == ["t.alpha"]
with pytest.raises(R1.RunError, match="every member run succeeded"):
PR.finalize(reg, pr, "succeeded")
def test_a_failed_member_blocks_its_dependents(tmp_path):
reg = make_repo(tmp_path); doc = reg.projects["demo"]
pr = PR.create(reg, doc, "bernd")
run = PR.start_member(reg, doc, pr, "t.alpha")
run2.start(run, "first"); run2.fail(run, "first", "broke")
R1.finalize(run, "failed")
v = PR.view(reg, pr)
assert v["members"][0]["status"] == "failed" and v["members"][1]["status"] == "waiting" and v["problems"] == ["t.alpha"] and v["ready"] == []
pr2 = PR.create(make_repo(tmp_path / "y"), Registry([tmp_path / "y"]).projects["demo"], "bernd", "auto-confirm")
assert PR.tick(Registry([tmp_path / "y"]), Registry([tmp_path / "y"]).projects["demo"], pr2)["action"] == "member-started"
# ---- modes, closing, locking ------------------------------------------------------------------------------------------------------------
def test_switching_mode_carries_the_open_member_run_along(tmp_path):
reg = make_repo(tmp_path); doc = reg.projects["demo"]
pr = PR.create(reg, doc, "bernd")
run = PR.start_member(reg, doc, pr, "t.alpha")
PR.switch_mode(reg, pr, "auto-confirm")
assert pr.data["mode"] == "auto-confirm" and R1.load(reg.packs["t.alpha"], run.data["run_id"]).data["mode"] == "auto-confirm"
PR.switch_mode(reg, pr, "manual")
assert R1.load(reg.packs["t.alpha"], run.data["run_id"]).data["mode"] == "manual"
with pytest.raises(R1.RunError, match="already in manual"):
PR.switch_mode(reg, pr, "manual")
def test_closing_abandons_the_open_member_run_locks_seals_and_refuses_further_work(tmp_path):
reg = make_repo(tmp_path); doc = reg.projects["demo"]
pr = PR.create(reg, doc, "bernd")
run = PR.start_member(reg, doc, pr, "t.alpha")
with pytest.raises(R1.RunError, match="succeeds only when"):
PR.finalize(reg, pr, "succeeded")
PR.finalize(reg, pr, "abandoned", "not now")
closed = R1.load(reg.packs["t.alpha"], run.data["run_id"])
assert closed.data["outcome"] == "abandoned" and "project run closed as abandoned" in closed.data["outcome_note"]
assert pr.data["outcome"] == "abandoned" and PR.seal_state(pr) == "sealed" and not (pr.path.stat().st_mode & 0o222)
assert PR.active_run(reg, "demo") is None
for call in (lambda: PR.start_member(reg, doc, pr, "t.beta"), lambda: PR.finalize(reg, pr, "abandoned"), lambda: PR.switch_mode(reg, pr, "auto-confirm")):
with pytest.raises(R1.RunError, match="closed and locked"):
call()
pr.data["operator"] = "forged"
with pytest.raises(R1.RunError, match="closed and locked"):
pr.save()
fresh = PR.load(reg, pr.id)
assert fresh.data["operator"] == "bernd"
def test_failed_needs_a_problem_or_an_open_member_otherwise_abandon(tmp_path):
reg = make_repo(tmp_path); doc = reg.projects["demo"]
pr = PR.create(reg, doc, "bernd") # nothing started, nothing wrong
with pytest.raises(R1.RunError, match="only when a member failed"):
PR.finalize(reg, pr, "failed")
PR.start_member(reg, doc, pr, "t.alpha") # an open member run may be given up as failed
PR.finalize(reg, pr, "failed", "gave up")
assert pr.data["outcome"] == "failed"
def test_a_project_run_edited_on_disk_after_closing_is_reported_broken(tmp_path):
import json
reg = make_repo(tmp_path); doc = reg.projects["demo"]
pr = PR.create(reg, doc, "bernd"); PR.finalize(reg, pr, "abandoned")
pr.path.chmod(0o644)
d = json.loads(pr.path.read_text()); d["operator"] = "forged"; pr.path.write_text(json.dumps(d))
assert PR.seal_state(PR.load(reg, pr.id)) == "broken"
def test_listing_and_times(tmp_path):
reg = make_repo(tmp_path); doc = reg.projects["demo"]
a = PR.create(reg, doc, "bernd"); PR.finalize(reg, a, "abandoned")
b = PR.create(reg, doc, "bernd")
assert [p.id for p in PR.list_runs(reg, "demo")] == [b.id, a.id] and PR.active_run(reg, "demo").id == b.id
assert PR.list_runs(reg, "nope") == [] and PR.times(a)["finished"] and PR.times(b)["finished"] is None

230
tests/test_ui_projectrun.py Normal file
View file

@ -0,0 +1,230 @@
import http.client
import shutil
import threading
from pathlib import Path
from urllib.parse import urlencode
import pytest
import yaml
from rtut import project as J, projectrun as PR, run as R1, run2, ui
ROOT = Path(__file__).resolve().parent.parent
def v2_pack(pid, manual_only_last=False):
steps = [{"id": "first", "title": "First step", "actor": "op", "owner": "t", "uses": ["site"], "command": "echo {{site}}",
"verify": {"done_when": "you saw the site", "expect": "manual"}},
{"id": "second", "title": "Second step", "actor": "op", "owner": "t", "verify": {"done_when": "second done", "expect": "manual"}}]
if manual_only_last:
steps.append({"id": "careful", "title": "Careful step", "actor": "op", "owner": "t", "auto_confirm": "manual-only",
"verify": {"done_when": "you did it", "expect": "manual"}})
return {"spec": "runbook-pack/v0.2", "id": pid, "title": f"Pack {pid}", "owner": "t", "exercise_status": "unexercised", "engine": "native",
"framing": ["keep in mind"], "actors": [{"id": "op", "role": "Operator", "description": "does it"}],
"datapoints": [{"id": "site", "label": "Site", "type": "string", "pattern": "[a-z]+", "default": "north", "ask_in": "main"}],
"stages": [{"id": "main", "title": "Main", "goal": "do the things", "steps": steps}]}
def write(path, data):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.safe_dump(data, sort_keys=False))
@pytest.fixture
def srv(tmp_path):
write(tmp_path / "runbooks/alpha/pack.yaml", v2_pack("t.alpha"))
write(tmp_path / "runbooks/beta/pack.yaml", v2_pack("t.beta", manual_only_last=True))
write(tmp_path / "projects/demo/project.yaml", {
"spec": "project/v0.1", "id": "demo", "title": "Demo project", "environment": "rehearsal", "attributes": {"site": "south"},
"runbooks": [{"pack": "t.alpha"}, {"pack": "t.beta", "after": ["t.alpha"]}]})
s = ui.serve([tmp_path], 0)
threading.Thread(target=s.serve_forever, daemon=True).start()
s.repo = tmp_path
yield s
s.shutdown()
def req(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()
def post(s, path, **form):
return req(s, "POST", path, {"_t": s.app.token, **form})
def start_pr(s, mode="manual"):
st, loc, _ = post(s, "/project/demo/runall", mode=mode)
assert st == 303 and loc.startswith("/project-run/")
return loc
def finish_run(s, run_url):
"""Complete and close the member run behind a /run/<i>/<id> URL, as an operator would in that page."""
parts = run_url.split("#")[0].split("/")
i, rid = int(parts[2]), parts[3]
run = R1.load(s.app.packs[i], rid)
for sid in run2.flow(run).steps:
assert post(s, f"/run/{i}/{rid}/{sid}/start")[0] == 303
assert post(s, f"/run/{i}/{rid}/{sid}/verify", ok="1")[0] == 303
assert post(s, f"/run/{i}/{rid}/{sid}/done")[0] == 303
assert post(s, f"/run/{i}/{rid}/finalize", outcome="succeeded")[0] == 303
# ---- starting -------------------------------------------------------------------------------------------------------------
def test_project_page_offers_the_whole_project_run_with_both_modes(srv):
_, _, body = req(srv, "GET", "/project/demo")
assert "<h2>Run the whole project</h2>" in body and "name=mode value='manual' checked" in body and "name=mode value='auto-confirm'" in body
assert "<b>manual</b>: You start each member yourself" in body and "<b>auto-confirm</b>: The engine starts the next ready member" in body
assert "Running as <b>tester-login</b>" in body and "Start project run" in body and "snapshotted" in body
def test_missing_attributes_block_the_start_and_say_which(srv):
p = v2_pack("t.alpha"); p["datapoints"][0].pop("default")
write(srv.repo / "runbooks/alpha/pack.yaml", p)
d = yaml.safe_load((srv.repo / "projects/demo/project.yaml").read_text()); d["attributes"] = {}
write(srv.repo / "projects/demo/project.yaml", d)
_, _, body = req(srv, "GET", "/project/demo")
assert "Give the project the attributes it still needs first: site" in body and "Start project run" not in body
st, _, err = post(srv, "/project/demo/runall", mode="manual")
assert st == 400 and "attributes still needed: site" in err
# ---- manual --------------------------------------------------------------------------------------------------------------------
def test_manual_project_run_page_shows_next_member_and_walks_them_in_order(srv):
loc = start_pr(srv)
_, _, body = req(srv, "GET", loc)
assert "<h1>Project run: Demo project</h1>" in body and "0 of 2 members done" in body
assert "<b>Next:</b> t.alpha" in body and "Manual mode." in body and "Hand over to auto-confirm" in body
assert "waiting for t.alpha" in body and "Start anyway" in body and ">ready<" in body and ">waiting<" in body
st, run_loc, _ = post(srv, f"{loc}/start", key="t.alpha")
assert st == 303 and run_loc.startswith("/run/") and run_loc.endswith("#current")
_, _, runpage = req(srv, "GET", run_loc.split("#")[0])
assert f"part of <a href='{loc}'>project run" in runpage and "project <a href='/project/demo'>demo</a>" in runpage
_, _, body = req(srv, "GET", loc)
assert "<b>Continue:</b> t.alpha" in body and "open run" in body and ">running<" in body
assert "<button>Start</button>" not in body # one member at a time
finish_run(srv, run_loc)
_, _, body = req(srv, "GET", loc)
assert "1 of 2 members done" in body and "<b>Next:</b> t.beta" in body and ">succeeded<" in body
def test_out_of_order_start_needs_reason_and_acknowledgement_and_shows_as_a_deviation(srv):
loc = start_pr(srv)
st, _, err = post(srv, f"{loc}/start", key="t.beta")
assert st == 400 and "waits for t.alpha" in err
st, _, err = post(srv, f"{loc}/start", key="t.beta", override_reason="beta first")
assert st == 400 and "acknowledge" in err
assert post(srv, f"{loc}/start", key="t.beta", override_reason="beta first", acknowledge_risk="1")[0] == 303
_, _, body = req(srv, "GET", loc)
assert "⚑ Deviations: override t.beta (beta first)" in body and "started t.beta (override)" in body
def test_only_one_project_run_is_active_and_the_page_links_to_it(srv):
loc = start_pr(srv)
_, _, body = req(srv, "GET", "/project/demo")
assert f"<a href='{loc}'>continue it</a>" in body and "Start project run" not in body
st, _, err = post(srv, "/project/demo/runall", mode="manual")
assert st == 400 and "still open" in err
# ---- auto-confirm -----------------------------------------------------------------------------------------------------------------
def test_auto_confirm_runs_members_until_a_person_is_needed_then_continues(srv):
loc = start_pr(srv, "auto-confirm")
_, _, body = req(srv, "GET", loc)
assert "Auto-confirm mode." in body and "Run until it waits" in body and "Take over (manual)" in body and "never runs commands" in body
assert "<button>Start</button>" not in body # the engine starts members, not you
assert post(srv, f"{loc}/tick", n="300")[0] == 303
_, _, body = req(srv, "GET", loc)
assert "1 of 2 members done" in body and "Last: awaiting-operator t.beta" in body and "manual-only" in body
pr = PR.load(srv.app.registry(), loc.rsplit("/", 1)[1])
beta = R1.load(srv.app.registry().packs["t.beta"], next(m["run_id"] for m in pr.data["members"] if m["pack"] == "t.beta"))
assert beta.data["mode"] == "auto-confirm" and beta.data["context"]["project_run"] == pr.id
i = srv.app.pack_index("t.beta")
assert post(srv, f"/run/{i}/{beta.data['run_id']}/careful/start")[0] == 303 # the person takes the careful step
assert post(srv, f"/run/{i}/{beta.data['run_id']}/careful/verify", ok="1")[0] == 303
assert post(srv, f"/run/{i}/{beta.data['run_id']}/careful/done")[0] == 303
assert post(srv, f"{loc}/tick", n="300")[0] == 303
_, _, body = req(srv, "GET", loc)
assert "2 of 2 members done" in body and "Finish: succeeded" in body
def test_take_over_and_hand_over(srv):
loc = start_pr(srv, "auto-confirm")
assert post(srv, f"{loc}/mode", to="manual")[0] == 303
_, _, body = req(srv, "GET", loc)
assert "Manual mode." in body and "Hand over to auto-confirm" in body and "switched to manual mode" in body
st, _, err = post(srv, f"{loc}/mode", to="manual")
assert st == 400 and "already in manual" in err
# ---- closing and locking ------------------------------------------------------------------------------------------------------------
def test_closing_the_project_run_locks_it_and_abandons_the_open_member_run(srv):
loc = start_pr(srv)
post(srv, f"{loc}/start", key="t.alpha")
_, _, body = req(srv, "GET", loc)
assert "Finish: succeeded" not in body and "Abandon project run" in body and "Finish: failed" in body
st, _, err = post(srv, f"{loc}/finalize", outcome="succeeded")
assert st == 400 and "succeeds only when" in err
assert post(srv, f"{loc}/finalize", outcome="abandoned", note="not now")[0] == 303
_, _, body = req(srv, "GET", loc)
assert "Project run finished: abandoned" in body and "Comment: not now" in body and "closed and locked" in body
assert "Close this project run" not in body and "<button>Start</button>" not in body and "Hand over" not in body
reg = srv.app.registry()
pr = PR.load(reg, loc.rsplit("/", 1)[1])
member = R1.load(reg.packs["t.alpha"], pr.data["members"][0]["run_id"])
assert member.data["outcome"] == "abandoned"
for path, form in ((f"{loc}/start", {"key": "t.beta"}), (f"{loc}/tick", {"n": "1"}), (f"{loc}/mode", {"to": "auto-confirm"}), (f"{loc}/finalize", {"outcome": "abandoned"})):
st, _, err = post(srv, path, **form)
assert st == 400, path
_, _, body = req(srv, "GET", "/project/demo")
assert "Start project run" in body and f"<a href='{loc}'>" in body # a new one may start; the old one is in the history
def test_a_tampered_closed_project_run_is_called_out(srv):
import json
loc = start_pr(srv)
post(srv, f"{loc}/finalize", outcome="abandoned")
pr = PR.load(srv.app.registry(), loc.rsplit("/", 1)[1])
pr.path.chmod(0o644)
d = json.loads(pr.path.read_text()); d["operator"] = "forged"; pr.path.write_text(json.dumps(d))
assert "changed outside the app after it was sealed" in req(srv, "GET", loc)[2]
# ---- history, activity, snapshot, safety --------------------------------------------------------------------------------------------
def test_history_table_and_activity_feed_include_the_project_run(srv):
loc = start_pr(srv)
post(srv, f"{loc}/start", key="t.alpha")
_, _, body = req(srv, "GET", "/project/demo")
assert "<h3>Project runs</h3>" in body and ">in progress<" in body and "0 of 2 members" in body
_, _, act = req(srv, "GET", "/activity")
assert "project run: started the project run (manual mode)" in act and "project run: started t.alpha" in act and f"href='{loc}'" in act
def test_attribute_changes_after_the_start_are_shown_and_do_not_reach_later_members(srv):
loc = start_pr(srv)
post(srv, f"{loc}/start", key="t.alpha")
reg = srv.app.registry()
J.set_attributes(reg, reg.projects["demo"], {"site": "east"})
_, _, body = req(srv, "GET", loc)
assert "attributes have changed since this project run started" in body and "keeps the values it started with" in body
pr = PR.load(srv.app.registry(), loc.rsplit("/", 1)[1])
assert pr.data["snapshot"]["t.beta"] == {"site": "south"}
def test_sidebar_marks_projects_on_a_project_run_page_and_csrf_and_unknown_ids(srv):
loc = start_pr(srv)
_, _, body = req(srv, "GET", loc)
assert "'/project-run/']" in body # the alias list includes project runs
assert req(srv, "POST", f"{loc}/tick", {"n": "1"})[0] == 403
assert req(srv, "POST", "/project/demo/runall", {"mode": "manual"})[0] == 403
assert req(srv, "GET", "/project-run/nope")[0] == 404
assert post(srv, "/project-run/nope/tick", n="1")[0] == 400

View file

@ -71,7 +71,7 @@ def test_the_theme_can_be_chosen_and_is_remembered_and_dark_styles_exist(srv):
assert "localStorage.getItem('rtut-theme')" in head and "URLSearchParams" in head # applied before painting; ?theme=dark also works
assert "system:'light',light:'dark',dark:'system'" in body # the button cycles system, light, dark
assert "aria-current" in body # the current section is marked
assert "'/attribute-sets':['/attribute-set/']" in body and "'/projects':['/project/','/template/']" in body # detail pages mark their section too
assert "'/attribute-sets':['/attribute-set/']" in body and "'/projects':['/project/','/template/','/project-run/']" in body # detail pages mark their section too
def test_the_sidebar_shows_the_saved_name(srv):

View file

@ -260,6 +260,19 @@ locked (engine refuses changes, feedback included), sealed with a hash and made
evidence; the closing form takes optional feedback so abandoned runs are not silent; a closed run shows its feedback read-only (no "Change my
feedback"), or no feedback section at all. See `docs/run-spec.md`.
## Project runs in the UI
```task
id: RBT-WP-0004-T17
status: done
priority: high
```
Done 2026-09-29: the project page has "Run the whole project" (mode choice, history of project runs); a project run page shows progress, the
next or open member, the member table with status, progress and links, an override for out-of-order starts, auto-confirm controls (check once,
run until it waits, take over or hand over), a log, and the closing form. Member run pages link back to their project run; the activity
feed includes project run events. Tested over HTTP; visual check pending.
## Acceptance Criteria
- Bernd can complete the SSH tutorial pack in the UI without pasting outputs to an agent.

View file

@ -141,13 +141,16 @@ Replace the "Record pack as exercised" click and the `exercise_status` field wit
```task
id: RBT-WP-0008-T10
status: wait
status: done
priority: medium
state_hub_task_id: "917961d5-1571-52a5-b138-a15163176ed4"
```
Run a whole project in manual or auto-confirm mode, member by member in `after` order, with the attributes coming from the
project; auto-pilot with execution follows RBT-WP-0006. Attribute states to explore in use: needed, optional, modified,
Done 2026-09-29: `rtut/projectrun.py` and `rtut/ui_projectrun.py`. A project run snapshots the resolved attributes, then runs the members
member by member in `after` order. Manual: you start each member (out of order needs a recorded override). Auto-confirm: the engine starts
the next ready member, ticks its run, closes it when it finishes cleanly, continues through push-through, and stops wherever a person is needed
(v0.1 member, manual-only or irreversible step, failed check). Closing locks and seals it, and abandons an open member run. Auto-pilot with
execution remains RBT-WP-0006. Attribute states to explore in use: needed, optional, modified,
checked; add what real use shows.
## Acceptance Criteria