From eff75a303c725da20d5201a932112a34ddd60144 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 29 Sep 2026 20:08:17 +0200 Subject: [PATCH] Run a whole project: project runs in manual and auto-confirm mode (RBT-WP-0008-T10) Co-Authored-By: Claude Sonnet 5.5 Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 295952@bnt-lap001 Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477 --- docs/maturity-and-projects.md | 16 + rtut/activity.py | 9 +- rtut/project.py | 18 +- rtut/projectrun.py | 334 ++++++++++++++++++ rtut/run.py | 2 +- rtut/ui.py | 17 +- rtut/ui_projectrun.py | 208 +++++++++++ rtut/ui_projects.py | 2 + rtut/ui_v2.py | 3 +- tests/test_projectrun.py | 264 ++++++++++++++ tests/test_ui_projectrun.py | 230 ++++++++++++ tests/test_ui_shell.py | 2 +- workplans/RBT-WP-0004-local-runbook-ui.md | 13 + ...nbook-maturity-projects-and-idempotency.md | 9 +- 14 files changed, 1112 insertions(+), 15 deletions(-) create mode 100644 rtut/projectrun.py create mode 100644 rtut/ui_projectrun.py create mode 100644 tests/test_projectrun.py create mode 100644 tests/test_ui_projectrun.py 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"

Running as {label} · change

") 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"
{app.hidden()}
Mode{opts}
" + f"

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.

{app.who()}
") + 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"{e(p.id)}{pill(p.data['mode'])}" + f"{pill(p.data['outcome'] or 'in progress')}{v['done']} of {v['total']} members" + f"{e(stamp(t['started']))} · {e(took)} · {e(p.data['operator'])}") + out.append("

Project runs

" + rows + "
RunModeOutcomeProgressWhen
") + 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 {e(fmt_duration(t['elapsed']))}" if t["finished"] + else f"running for {e(fmt_duration(t['elapsed']))}") + out = [f"

Project run: {e(d['title'])}

", + f"

{pill(d['mode'])} {pill(d['outcome']) if d['outcome'] else pill('in progress')} {pill(d['environment'])} " + f"{e(d['id'])} · operator {e(d['operator'])} · project {e(d['project'])}

", + f"

Started {e(stamp(t['started']))} · {took}

", + f"

{v['done']} of {v['total']} members done

"] + if msg: + out.append(f"

{e(msg)}

") + 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
    " + "".join( + f"
  • {e(x['at'][:19].replace('T', ' '))} · {e(x['who'])} {e(x['text'])}
  • " for x in reversed(d["events"])) + "
") + 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"" + f"
Manual mode. You do the member run; when it is closed the next member becomes ready." + f"
{app.hidden()}" + "
") + if v["ready"]: + return (f"" + f"
Manual mode. Start the next member below." + f"
{app.hidden()}" + "
") + if v["done"] == v["total"]: + return "" + return "" + last = d.get("last") + status = f"

Last: {e(last['action'])} {e(last.get('member') or '')} {e(last.get('detail') or '')}

" if last else "" + return (f"
Auto-confirm mode. {e(MODE_HELP['auto-confirm'])}{status}" + f"
{app.hidden()}" + "
" + f"
{app.hidden()}
") + + +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"
{app.hidden()}
" + elif r["status"] == "waiting": + action = (f"
Start anyway
{app.hidden()}" + f"

It waits for {e(', '.join(r['blocked_by']))}; starting it now is recorded as a deviation.

" + "

" + "

" + "
") + rows += (f"{n}{link}
{e(r['key'])}{extra}{pill(r['status'])}" + f"{prog}{run}{action}") + return ("

Members

" + rows + "
#RunbookStatusProgress
") + + +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"
{app.hidden()}" + f"

{' '.join(btns)}

") + + +# ---- 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')}

" "" + rows + "
RunbookStatusMaturity
") + 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 "

Project runs

" 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 diff --git a/tests/test_ui_shell.py b/tests/test_ui_shell.py index ee3cfc4..21a6487 100644 --- a/tests/test_ui_shell.py +++ b/tests/test_ui_shell.py @@ -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): diff --git a/workplans/RBT-WP-0004-local-runbook-ui.md b/workplans/RBT-WP-0004-local-runbook-ui.md index 7596dc1..7890c0a 100644 --- a/workplans/RBT-WP-0004-local-runbook-ui.md +++ b/workplans/RBT-WP-0004-local-runbook-ui.md @@ -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. diff --git a/workplans/RBT-WP-0008-runbook-maturity-projects-and-idempotency.md b/workplans/RBT-WP-0008-runbook-maturity-projects-and-idempotency.md index e8904a7..65847a8 100644 --- a/workplans/RBT-WP-0008-runbook-maturity-projects-and-idempotency.md +++ b/workplans/RBT-WP-0008-runbook-maturity-projects-and-idempotency.md @@ -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