diff --git a/docs/maturity-and-projects.md b/docs/maturity-and-projects.md
index 235ad08..ee9f408 100644
--- a/docs/maturity-and-projects.md
+++ b/docs/maturity-and-projects.md
@@ -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
diff --git a/rtut/activity.py b/rtut/activity.py
index 9752ca8..a68972e 100644
--- a/rtut/activity.py
+++ b/rtut/activity.py
@@ -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]
diff --git a/rtut/project.py b/rtut/project.py
index 61aad2e..6a6b1aa 100644
--- a/rtut/project.py
+++ b/rtut/project.py
@@ -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)
diff --git a/rtut/projectrun.py b/rtut/projectrun.py
new file mode 100644
index 0000000..bb042c7
--- /dev/null
+++ b/rtut/projectrun.py
@@ -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())}
diff --git a/rtut/run.py b/rtut/run.py
index c9ac4ef..7f20534 100644
--- a/rtut/run.py
+++ b/rtut/run.py
@@ -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:
diff --git a/rtut/ui.py b/rtut/ui.py
index e82ba04..f604487 100644
--- a/rtut/ui.py
+++ b/rtut/ui.py
@@ -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"
")
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)}
Nothing has happened yet. Start a run or a project.
"
rows = "".join(
@@ -379,7 +381,8 @@ class App:
where = ""
if ctx.get("project"):
where = (f" · project {e(ctx['project'])}"
- 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 project run ' + e(ctx['project_run']) + '' if ctx.get('project_run') else ''}")
out = [f"
{e(d['title'])}
{e(d['exercise_status'])}
"
f"
run {e(rid)} · operator {e(r.data['operator'])}{where}
"
f"
{UI2.times_line(r)}
"]
@@ -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"
{e(msg)}
"), 400)
if parts[0] == "project":
return self._send(UP.project_page(app, parts[1], msg) or page("Error", f"
{e(msg)}
"), 400)
if parts[0] == "template":
diff --git a/rtut/ui_projectrun.py b/rtut/ui_projectrun.py
new file mode 100644
index 0000000..1d89f82
--- /dev/null
+++ b/rtut/ui_projectrun.py
@@ -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"{e(text)}"
+
+
+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 = ["
Run the whole project
"]
+ if active:
+ out.append(f"
A project run is in progress ({pill(active.data['mode'])}): "
+ f"continue it. Only one runs at a time.
")
+ elif needs:
+ out.append(f"
Give the project the attributes it still needs first: {e(', '.join(sorted({n['attribute'] for n in needs})))}.
")
+ else:
+ opts = "".join(f" "
+ for m, t in MODE_HELP.items())
+ out.append(f"")
+ 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"
")
+ if doc is not None and not closed:
+ changed = PR.attributes_changed(reg, doc, pr)
+ if changed:
+ out.append("
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.
")
+ if d["deviations"]:
+ out.append("
⚑ Deviations: " + "; ".join(f"{e(x['kind'])} {e(x['item'])} ({e(x['reason'])})" for x in d["deviations"]) + "
")
+ if not closed:
+ out.append(_controls(app, pr, v))
+ out.append(_members(app, reg, pr, v, manual and not closed))
+ out.append("Log
")
+
+
+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"{title}" if i is not None else title
+ prog = f"{r['progress']['done']}/{r['progress']['total']}" if r.get("progress") else "-"
+ run = f" · open run" if r.get("run_id") and i is not None else ""
+ extra = ""
+ if r["status"] == "waiting":
+ extra = f" waiting for {e(', '.join(r['blocked_by']))}"
+ if r.get("detail"):
+ extra += f" {e(r['detail'])}"
+ if r["deviations"]:
+ extra += f" ⚑ {r['deviations']} deviation(s)"
+ action = ""
+ if manual and not r.get("run_id") and not v["open"]:
+ key = e(r["key"])
+ if r["status"] == "ready":
+ action = f""
+ elif r["status"] == "waiting":
+ action = (f"Start anyway")
+ rows += (f"
{n}
{link} {e(r['key'])}{extra}
{pill(r['status'])}
"
+ f"
{prog}{run}
{action}
")
+ return ("
Members
#
Runbook
Status
Progress
" + rows + "
")
+
+
+def _close(app, reg, pr, v: dict) -> str:
+ d = pr.data
+ if pr.closed:
+ state = PR.seal_state(pr)
+ note = f"
Comment: {e(d['outcome_note'])}
" if d.get("outcome_note") else ""
+ broken = ("
⚠ This project run's record was changed outside the app after it was sealed.
" if state == "broken" else
+ "
This project run is closed and locked: its record is sealed and cannot be changed. Start a new project run to try again.
")
+ return f"
Project run finished: {e(d['outcome'])}
{note}{broken}
"
+ btns = []
+ if all(r["status"] == "succeeded" for r in v["members"]):
+ btns.append("")
+ if v["problems"] or v["open"]:
+ btns.append("")
+ btns.append("")
+ return (f"
Close this project run
Closing records the outcome, abandons an open member run, and locks the record.
"
+ f"
")
+
+
+# ---- 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")
diff --git a/rtut/ui_projects.py b/rtut/ui_projects.py
index 8834261..db8b3a8 100644
--- a/rtut/ui_projects.py
+++ b/rtut/ui_projects.py
@@ -166,6 +166,8 @@ def project_page(app, pid: str, msg: str = "") -> bytes | None:
out.append("
Runbooks
"
f"
Next: {e(', '.join(st['next']) or 'nothing to start')}
"
"
Runbook
Status
Maturity
" + rows + "
")
+ from . import ui_projectrun as UPR
+ out.append(UPR.project_section(app, reg, doc, st["needs"]))
# attributes per member
out.append("
Attributes
What each runbook receives from this project, and where each value comes from.
")
for spec in d["runbooks"]:
diff --git a/rtut/ui_v2.py b/rtut/ui_v2.py
index 66d9fd7..7b555d7 100644
--- a/rtut/ui_v2.py
+++ b/rtut/ui_v2.py
@@ -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 {e(ctx['project'])}"
- 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 project run ' + e(ctx['project_run']) + '' if ctx.get('project_run') else ''}")
ov = R.overview(run)
prog = ov["progress"]
out = [f"
{e(pack.data['title'])}
"
diff --git a/tests/test_projectrun.py b/tests/test_projectrun.py
new file mode 100644
index 0000000..55cb348
--- /dev/null
+++ b/tests/test_projectrun.py
@@ -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
diff --git a/tests/test_ui_projectrun.py b/tests/test_ui_projectrun.py
new file mode 100644
index 0000000..c2a949d
--- /dev/null
+++ b/tests/test_ui_projectrun.py
@@ -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// 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 "
Run the whole project
" in body and "name=mode value='manual' checked" in body and "name=mode value='auto-confirm'" in body
+ assert "manual: You start each member yourself" in body and "auto-confirm: The engine starts the next ready member" in body
+ assert "Running as tester-login" 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 "
Project run: Demo project
" in body and "0 of 2 members done" in body
+ assert "Next: 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 project run" in runpage and "project demo" in runpage
+ _, _, body = req(srv, "GET", loc)
+ assert "Continue: t.alpha" in body and "open run" in body and ">running<" in body
+ assert "" 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 "Next: 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"continue it" 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 "" 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 "" 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"" 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 "