Run a whole project: project runs in manual and auto-confirm mode (RBT-WP-0008-T10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 295952@bnt-lap001 Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
This commit is contained in:
parent
1d1087fa7f
commit
eff75a303c
14 changed files with 1112 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -69,8 +69,12 @@ def validate(reg: Registry, doc: Doc) -> list[str]:
|
|||
# ---- attribute resolution --------------------------------------------------------------------------
|
||||
|
||||
def _member(d: dict, pack_id: str, target: str | None = None) -> dict:
|
||||
"""The member for this runbook and target; a member without a target matches target=None."""
|
||||
for m in d["runbooks"]:
|
||||
if m["pack"] == pack_id and (target is None or m.get("target") == target):
|
||||
if m["pack"] == pack_id and m.get("target") == target:
|
||||
return m
|
||||
for m in d["runbooks"]: # lenient: a runbook named without its target
|
||||
if m["pack"] == pack_id and target is None:
|
||||
return m
|
||||
raise KeyError(pack_id)
|
||||
|
||||
|
|
@ -189,8 +193,12 @@ def state(reg: Registry, doc: Doc) -> dict:
|
|||
|
||||
# ---- runs from a project ------------------------------------------------------------------------------------
|
||||
|
||||
def start_run(reg: Registry, doc: Doc, pack_id: str, operator: str, target: str | None = None, mode: str = "manual"):
|
||||
"""Create a run of a member runbook with the project's attributes as defaults and the project as context."""
|
||||
def start_run(reg: Registry, doc: Doc, pack_id: str, operator: str, target: str | None = None, mode: str = "manual",
|
||||
values: dict | None = None, extra_context: dict | None = None):
|
||||
"""Create a run of a member runbook with the project's attributes as defaults and the project as context.
|
||||
|
||||
`values` replaces the current attributes (a project run passes the snapshot it took at its start);
|
||||
`extra_context` adds to the run's context (for example the project run it belongs to)."""
|
||||
d = doc.data
|
||||
if d["spec"] != "project/v0.1":
|
||||
raise R.RunError("a template is not run; instantiate it into a project first")
|
||||
|
|
@ -199,8 +207,8 @@ def start_run(reg: Registry, doc: Doc, pack_id: str, operator: str, target: str
|
|||
raise R.RunError(f"project {doc.id} is invalid: {errs[0]}")
|
||||
member = _member(d, pack_id, target)
|
||||
pack = reg.packs[pack_id]
|
||||
ctx = {"project": doc.id, "environment": d["environment"], "target": member.get("target")}
|
||||
values = member_values(reg, d, member)
|
||||
ctx = {"project": doc.id, "environment": d["environment"], "target": member.get("target"), **(extra_context or {})}
|
||||
values = dict(values) if values is not None else member_values(reg, d, member)
|
||||
if pack.spec == "runbook-pack/v0.2":
|
||||
from . import run2
|
||||
return run2.create(pack, operator, values, mode=mode, context=ctx)
|
||||
|
|
|
|||
334
rtut/projectrun.py
Normal file
334
rtut/projectrun.py
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
"""A project run: one run of every member runbook of a project, in `after` order, in one mode.
|
||||
|
||||
The record lives next to the project (`.local/project-runs/`); its state is derived from the member runs, which carry it in
|
||||
their context. It snapshots the project's resolved attributes when it starts, so every member run uses the same values even if
|
||||
the project is edited meanwhile. Manual: the operator starts each member, one at a time. Auto-confirm: the engine starts the next
|
||||
ready member, ticks its run, closes it when it finishes cleanly and moves on, stopping wherever a person is needed.
|
||||
Closing locks and seals the record like a run.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from . import project as J
|
||||
from . import run as R
|
||||
from . import run2
|
||||
from .attrs import Doc, Registry
|
||||
from .run import RunError, _clean, _now, _read_json, _seal_of
|
||||
|
||||
CONTINUE = ("member-started", "member-finished", "started", "completed", "push-through")
|
||||
OUTCOMES = ("succeeded", "failed", "abandoned")
|
||||
|
||||
|
||||
def key_of(member: dict) -> str:
|
||||
return member["pack"] + (f"@{member['target']}" if member.get("target") else "")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectRun:
|
||||
path: Path
|
||||
data: dict
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return self.data["id"]
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
return bool(self.data.get("outcome"))
|
||||
|
||||
def save(self) -> None:
|
||||
disk = _read_json(self.path)
|
||||
if disk and disk.get("outcome"):
|
||||
changed = {k for k in set(disk) | set(self.data) if disk.get(k) != self.data.get(k)} - {"updated"}
|
||||
if changed:
|
||||
raise RunError(f"this project run is closed and locked ({disk['outcome']}); it cannot be changed")
|
||||
self.data["updated"] = _now()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if self.path.exists():
|
||||
self.path.chmod(0o644)
|
||||
self.path.write_text(json.dumps(self.data, indent=2, sort_keys=True) + "\n")
|
||||
if self.data.get("outcome"):
|
||||
self.path.chmod(0o444)
|
||||
|
||||
def event(self, text: str, who: str | None = None) -> None:
|
||||
self.data["events"].append({"at": _now(), "who": who or self.data["operator"], "text": text})
|
||||
|
||||
|
||||
def runs_dir(doc: Doc) -> Path:
|
||||
return Path(doc.path).resolve().parents[2] / ".local" / "project-runs"
|
||||
|
||||
|
||||
def load(reg: Registry, run_id: str) -> ProjectRun:
|
||||
for doc in reg.projects.values():
|
||||
f = runs_dir(doc) / f"{run_id}.json"
|
||||
if f.exists():
|
||||
return ProjectRun(f, json.loads(f.read_text()))
|
||||
raise RunError(f"no such project run: {run_id}")
|
||||
|
||||
|
||||
def list_runs(reg: Registry, project_id: str | None = None) -> list[ProjectRun]:
|
||||
out, seen = [], set()
|
||||
for doc in reg.projects.values():
|
||||
d = runs_dir(doc)
|
||||
if d in seen or not d.exists():
|
||||
continue
|
||||
seen.add(d)
|
||||
for f in sorted(d.glob("*.json")):
|
||||
data = _read_json(f)
|
||||
if data and (project_id is None or data.get("project") == project_id):
|
||||
out.append(ProjectRun(f, data))
|
||||
return sorted(out, key=lambda p: p.data["created"], reverse=True)
|
||||
|
||||
|
||||
def active_run(reg: Registry, project_id: str) -> ProjectRun | None:
|
||||
return next((p for p in list_runs(reg, project_id) if not p.closed), None)
|
||||
|
||||
|
||||
# ---- create --------------------------------------------------------------------------------------------------------
|
||||
|
||||
def create(reg: Registry, doc: Doc, operator: str, mode: str = "manual") -> ProjectRun:
|
||||
if doc.data.get("spec") != "project/v0.1":
|
||||
raise RunError("a template is not run; instantiate it into a project first")
|
||||
errs = J.validate(reg, doc)
|
||||
if errs:
|
||||
raise RunError(f"project {doc.id} is invalid: {errs[0]}")
|
||||
if mode not in ("manual", "auto-confirm"):
|
||||
raise RunError("mode must be manual or auto-confirm")
|
||||
needs = J.missing_required(reg, doc.data)
|
||||
if needs:
|
||||
raise RunError("attributes still needed: " + ", ".join(sorted({n["attribute"] for n in needs})))
|
||||
running = active_run(reg, doc.id)
|
||||
if running:
|
||||
raise RunError(f"project run {running.id} is still open; close it first")
|
||||
operator = _clean(operator.strip(), "operator")
|
||||
d = doc.data
|
||||
members, snapshot = [], {}
|
||||
for spec in d["runbooks"]:
|
||||
k = key_of(spec)
|
||||
members.append({"key": k, "pack": spec["pack"], "target": spec.get("target"), "after": list(spec.get("after", [])), "run_id": None})
|
||||
snapshot[k] = J.member_values(reg, d, spec)
|
||||
data = {"spec": "project-run/v0.1", "id": time.strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6], "project": doc.id,
|
||||
"title": d.get("title", doc.id), "environment": d["environment"], "operator": operator, "mode": mode,
|
||||
"created": _now(), "members": members, "snapshot": snapshot, "events": [], "deviations": [], "outcome": None}
|
||||
pr = ProjectRun(runs_dir(doc) / f"{data['id']}.json", data)
|
||||
pr.event(f"started the project run ({mode} mode)")
|
||||
pr.save()
|
||||
return pr
|
||||
|
||||
|
||||
# ---- derived view ---------------------------------------------------------------------------------------------------
|
||||
|
||||
def _member_run(reg: Registry, m: dict):
|
||||
if not m.get("run_id"):
|
||||
return None
|
||||
pack = reg.packs.get(m["pack"])
|
||||
try:
|
||||
return R.load(pack, m["run_id"]) if pack else None
|
||||
except RunError:
|
||||
return None
|
||||
|
||||
|
||||
def _done(status: str) -> bool:
|
||||
return status in ("succeeded", "pushed")
|
||||
|
||||
|
||||
def view(reg: Registry, pr: ProjectRun) -> dict:
|
||||
"""Members with their derived status, what is ready, and overall progress."""
|
||||
rows = []
|
||||
for m in pr.data["members"]:
|
||||
run = _member_run(reg, m)
|
||||
row = {**m, "status": "waiting", "progress": None, "deviations": 0, "detail": ""}
|
||||
if run is not None:
|
||||
d = run.data
|
||||
row["deviations"] = len(d.get("deviations", []))
|
||||
if d.get("outcome"):
|
||||
pushed = any(s["state"] == "pushed" for s in d["steps"].values()) or any(
|
||||
g["state"] == "pushed" for g in d.get("gates", {}).values())
|
||||
row["status"] = "pushed" if d["outcome"] == "failed" and pushed else d["outcome"]
|
||||
else:
|
||||
row["status"] = "running"
|
||||
if run.pack.spec == "runbook-pack/v0.2":
|
||||
ov = run2.overview(run)
|
||||
row["progress"] = {"done": ov["progress"]["done"] + ov["progress"]["pushed"], "total": ov["progress"]["total"]}
|
||||
ap = d["auto_confirm"]
|
||||
if row["status"] == "running" and ap.get("stopped"):
|
||||
row["detail"] = f"auto-confirm stopped: {ap['stopped']['reason']}"
|
||||
elif row["status"] == "running" and d["mode"] == "auto-confirm" and ap.get("last"):
|
||||
row["detail"] = f"{ap['last']['action']} {ap['last'].get('item') or ''} {ap['last'].get('detail', '')}".strip()
|
||||
else:
|
||||
steps = list(d["steps"].values())
|
||||
row["progress"] = {"done": sum(s["state"] in ("done", "skipped") for s in steps), "total": len(steps)}
|
||||
rows.append(row)
|
||||
by_pack: dict[str, list[dict]] = {}
|
||||
for r in rows:
|
||||
by_pack.setdefault(r["pack"], []).append(r)
|
||||
for r in rows:
|
||||
if r["status"] != "waiting":
|
||||
continue
|
||||
unmet = [a for a in r["after"] if not all(_done(x["status"]) for x in by_pack.get(a, []))]
|
||||
r["blocked_by"] = unmet
|
||||
r["status"] = "ready" if not unmet else "waiting"
|
||||
open_member = next((r for r in rows if r["status"] == "running"), None)
|
||||
total = len(rows)
|
||||
done = sum(r["status"] in ("succeeded", "pushed") for r in rows)
|
||||
return {"members": rows, "open": open_member["key"] if open_member else None, "done": done, "total": total,
|
||||
"ready": [r["key"] for r in rows if r["status"] == "ready"],
|
||||
"problems": [r["key"] for r in rows if r["status"] in ("failed", "abandoned", "pushed")]}
|
||||
|
||||
|
||||
def _writable(pr: ProjectRun) -> None:
|
||||
if pr.closed:
|
||||
raise RunError(f"this project run is closed and locked ({pr.data['outcome']})")
|
||||
|
||||
|
||||
# ---- starting members ------------------------------------------------------------------------------------------------------
|
||||
|
||||
def start_member(reg: Registry, doc: Doc, pr: ProjectRun, key: str, override_reason: str = "", acknowledge_risk: bool = False):
|
||||
"""Start the member's run with the snapshot values. One member is open at a time; out of order needs an override."""
|
||||
_writable(pr)
|
||||
v = view(reg, pr)
|
||||
row = next((r for r in v["members"] if r["key"] == key), None)
|
||||
if row is None:
|
||||
raise RunError(f"no such member: {key}")
|
||||
if row["status"] in ("running", "succeeded", "pushed", "failed", "abandoned") and row.get("run_id"):
|
||||
raise RunError(f"{key} already has a run ({row['status']})")
|
||||
if v["open"]:
|
||||
raise RunError(f"finish or abandon the open run of {v['open']} first")
|
||||
if row["status"] == "waiting":
|
||||
need = ", ".join(row["blocked_by"])
|
||||
if not override_reason.strip():
|
||||
raise RunError(f"{key} waits for {need}. To start it anyway give a reason and acknowledge the risk; this is recorded.")
|
||||
if not acknowledge_risk:
|
||||
raise RunError("an override needs the operator to acknowledge the risk")
|
||||
if pr.data["mode"] != "manual":
|
||||
raise RunError("overrides are made in manual mode")
|
||||
pr.data["deviations"].append({"kind": "override", "item": key, "unmet": [f"{a} is not done" for a in row["blocked_by"]],
|
||||
"reason": _clean(override_reason.strip(), "reason"), "who": pr.data["operator"], "at": _now()})
|
||||
spec = J._member(doc.data, row["pack"], row.get("target"))
|
||||
mode = pr.data["mode"] if reg.packs[row["pack"]].spec == "runbook-pack/v0.2" else "manual"
|
||||
run = J.start_run(reg, doc, row["pack"], pr.data["operator"], row.get("target"), mode,
|
||||
values=pr.data["snapshot"][key], extra_context={"project_run": pr.id})
|
||||
for m in pr.data["members"]:
|
||||
if m["key"] == key:
|
||||
m["run_id"] = run.data["run_id"]
|
||||
pr.event(f"started {key}" + (" (override)" if row["status"] == "waiting" else ""))
|
||||
pr.save()
|
||||
return run
|
||||
|
||||
|
||||
def attributes_changed(reg: Registry, doc: Doc, pr: ProjectRun) -> list[str]:
|
||||
"""Member keys whose current project attributes differ from the snapshot this project run started with."""
|
||||
out = []
|
||||
for spec in doc.data["runbooks"]:
|
||||
k = key_of(spec)
|
||||
if k in pr.data["snapshot"] and J.member_values(reg, doc.data, spec) != pr.data["snapshot"][k]:
|
||||
out.append(k)
|
||||
return out
|
||||
|
||||
|
||||
# ---- auto-confirm ----------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def tick(reg: Registry, doc: Doc, pr: ProjectRun) -> dict:
|
||||
"""One auto-confirm decision for the project run."""
|
||||
_writable(pr)
|
||||
if pr.data["mode"] != "auto-confirm":
|
||||
raise RunError("tick needs an auto-confirm project run")
|
||||
v = view(reg, pr)
|
||||
if v["open"]:
|
||||
row = next(r for r in v["members"] if r["key"] == v["open"])
|
||||
run = _member_run(reg, row)
|
||||
if run.pack.spec != "runbook-pack/v0.2" or run.data["mode"] != "auto-confirm":
|
||||
return {"action": "awaiting-operator", "member": row["key"], "detail": "this member is run by you, in manual mode"}
|
||||
res = run2.auto_confirm_tick(run)
|
||||
run.data["auto_confirm"]["last"] = res
|
||||
run.save()
|
||||
if res["action"] == "finished":
|
||||
try:
|
||||
R.finalize(run, "succeeded", "closed by auto-confirm")
|
||||
outcome = "succeeded"
|
||||
except RunError:
|
||||
R.finalize(run, "failed", "closed by auto-confirm: pushed through problems")
|
||||
outcome = "failed"
|
||||
pr.event(f"{row['key']} finished ({outcome})")
|
||||
pr.save()
|
||||
return {"action": "member-finished", "member": row["key"], "detail": outcome}
|
||||
return {**res, "member": row["key"]}
|
||||
if v["ready"]:
|
||||
start_member(reg, doc, pr, v["ready"][0])
|
||||
return {"action": "member-started", "member": v["ready"][0], "detail": ""}
|
||||
if v["done"] == v["total"]:
|
||||
return {"action": "finished", "member": None, "detail": "every member is done"}
|
||||
stuck = [r for r in v["members"] if r["status"] == "waiting"]
|
||||
why = "; ".join(f"{r['key']} waits for {', '.join(r['blocked_by'])}" for r in stuck) or "nothing can start"
|
||||
return {"action": "blocked", "member": None, "detail": why}
|
||||
|
||||
|
||||
def run_until_waits(reg: Registry, doc: Doc, pr: ProjectRun, max_ticks: int = 300) -> dict:
|
||||
"""Tick until something needs a person, everything is done, or the limit is reached. Returns the last result."""
|
||||
last = {"action": "idle", "member": None, "detail": ""}
|
||||
for _ in range(max_ticks):
|
||||
last = tick(reg, doc, pr)
|
||||
if last["action"] not in CONTINUE:
|
||||
break
|
||||
pr.data["last"] = last
|
||||
pr.save()
|
||||
return last
|
||||
|
||||
|
||||
def switch_mode(reg: Registry, pr: ProjectRun, mode: str) -> None:
|
||||
"""Take over or hand over the project run; open v0.2 member runs follow."""
|
||||
_writable(pr)
|
||||
if mode not in ("manual", "auto-confirm") or mode == pr.data["mode"]:
|
||||
raise RunError(f"the project run is already in {pr.data['mode']} mode" if mode == pr.data["mode"] else "mode must be manual or auto-confirm")
|
||||
for m in pr.data["members"]:
|
||||
run = _member_run(reg, m)
|
||||
if run is not None and not run.data.get("outcome") and run.pack.spec == "runbook-pack/v0.2" and run.data["mode"] != mode:
|
||||
run2.switch_mode(run, mode)
|
||||
pr.data["mode"] = mode
|
||||
pr.event(f"switched to {mode} mode")
|
||||
pr.save()
|
||||
|
||||
|
||||
# ---- closing -------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def finalize(reg: Registry, pr: ProjectRun, outcome: str, note: str = "") -> None:
|
||||
"""Close the project run, locked and sealed. Open member runs are abandoned with it."""
|
||||
_writable(pr)
|
||||
if outcome not in OUTCOMES:
|
||||
raise RunError(f"outcome must be one of {', '.join(OUTCOMES)}")
|
||||
v = view(reg, pr)
|
||||
if outcome == "succeeded" and not all(r["status"] == "succeeded" for r in v["members"]):
|
||||
raise RunError("a project run succeeds only when every member run succeeded")
|
||||
if outcome == "failed" and not v["problems"] and not v["open"]:
|
||||
raise RunError("a project run can be finished as failed only when a member failed, was abandoned or pushed through; otherwise abandon it")
|
||||
for r in v["members"]:
|
||||
if r["status"] == "running":
|
||||
run = _member_run(reg, r)
|
||||
R.finalize(run, "abandoned", f"project run closed as {outcome}")
|
||||
pr.data["outcome"] = outcome
|
||||
pr.data["finished"] = _now()
|
||||
if note.strip():
|
||||
pr.data["outcome_note"] = _clean(note.strip(), "note")
|
||||
pr.event(f"closed the project run as {outcome}")
|
||||
pr.data["seal"] = _seal_of(pr.data)
|
||||
pr.save()
|
||||
|
||||
|
||||
def seal_state(pr: ProjectRun) -> str:
|
||||
disk = _read_json(pr.path) or pr.data
|
||||
if not disk.get("outcome"):
|
||||
return "open"
|
||||
return "sealed" if disk.get("seal") == _seal_of(disk) else "broken"
|
||||
|
||||
|
||||
def times(pr: ProjectRun, now: datetime | None = None) -> dict:
|
||||
now = now or datetime.now(timezone.utc)
|
||||
start = R._ts(pr.data["created"])
|
||||
end = R._ts(pr.data["finished"]) if pr.data.get("finished") else now
|
||||
return {"started": pr.data["created"], "finished": pr.data.get("finished"), "elapsed": max(0.0, (end - start).total_seconds())}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
17
rtut/ui.py
17
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"<p class=note>Running as <b>{label}</b> · <a href='/user'>change</a></p>")
|
||||
|
||||
def activities_html(self, limit: int = 12, level: str = "h2", link: bool = False) -> str:
|
||||
events = ACT.latest(self.packs, limit)
|
||||
events = ACT.latest(self.packs, limit, PRUN.list_runs(self.registry()))
|
||||
if not events:
|
||||
return f"{heading(level, 'Latest activities', '/activity' if link else None)}<p class=note>Nothing has happened yet. Start a run or a project.</p>"
|
||||
rows = "".join(
|
||||
|
|
@ -379,7 +381,8 @@ class App:
|
|||
where = ""
|
||||
if ctx.get("project"):
|
||||
where = (f" · project <a href='/project/{e(ctx['project'])}'>{e(ctx['project'])}</a>"
|
||||
f"{' @' + e(ctx['target']) if ctx.get('target') else ''} · {e(ctx.get('environment', ''))}")
|
||||
f"{' @' + e(ctx['target']) if ctx.get('target') else ''} · {e(ctx.get('environment', ''))}"
|
||||
f"{' · part of <a href=' + chr(39) + '/project-run/' + e(ctx['project_run']) + chr(39) + '>project run ' + e(ctx['project_run']) + '</a>' if ctx.get('project_run') else ''}")
|
||||
out = [f"<h1>{e(d['title'])}</h1><p class=pill>{e(d['exercise_status'])}</p>"
|
||||
f"<p class=note>run {e(rid)} · operator {e(r.data['operator'])}{where}</p>"
|
||||
f"<p class=note>{UI2.times_line(r)}</p>"]
|
||||
|
|
@ -520,6 +523,10 @@ def make_handler(app: App, port_holder: list):
|
|||
return self._send_file(status, body, ctype, dl or ("artefact-" + parts[4] if want_dl and status == 200 else None))
|
||||
if parts[0] == "run":
|
||||
return self._send(app.run_page(int(parts[1]), parts[2], query=parse_qs(urlparse(self.path).query)))
|
||||
if parts[0] == "project-run" and len(parts) == 2:
|
||||
body = UPR.page_for(app, parts[1])
|
||||
if body:
|
||||
return self._send(body)
|
||||
if parts[0] == "project" and len(parts) == 2:
|
||||
body = UP.project_page(app, parts[1])
|
||||
if body:
|
||||
|
|
@ -569,6 +576,8 @@ def make_handler(app: App, port_holder: list):
|
|||
except M.ReviewError as ex:
|
||||
raise ValueError(str(ex))
|
||||
return self._redirect(f"/pack/{i}#maturity")
|
||||
if (len(parts) == 3 and parts[0] == "project" and parts[2] == "runall") or (len(parts) == 3 and parts[0] == "project-run"):
|
||||
return self._redirect(UPR.handle_post(app, parts, form))
|
||||
if len(parts) == 3 and parts[0] == "project" and parts[2] == "edit":
|
||||
from . import project as J
|
||||
doc = app.registry().projects[parts[1]]
|
||||
|
|
@ -623,6 +632,8 @@ def make_handler(app: App, port_holder: list):
|
|||
except (R.RunError, KeyError, ValueError, IndexError) as ex:
|
||||
msg = str(ex)
|
||||
try:
|
||||
if parts[0] == "project-run":
|
||||
return self._send(UPR.page_for(app, parts[1], msg) or page("Error", f"<p class=bad>{e(msg)}</p>"), 400)
|
||||
if parts[0] == "project":
|
||||
return self._send(UP.project_page(app, parts[1], msg) or page("Error", f"<p class=bad>{e(msg)}</p>"), 400)
|
||||
if parts[0] == "template":
|
||||
|
|
|
|||
208
rtut/ui_projectrun.py
Normal file
208
rtut/ui_projectrun.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"""UI for project runs: start one from a project, follow it member by member, in manual or auto-confirm mode."""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
|
||||
from . import projectrun as PR, run as R
|
||||
from .run import fmt_duration
|
||||
|
||||
MODE_HELP = {
|
||||
"manual": "You start each member yourself, one at a time, in the project's order. Every run inside it is manual.",
|
||||
"auto-confirm": ("The engine starts the next ready member, confirms what it can, closes each member run when it finishes cleanly, and moves on. "
|
||||
"It stops wherever a person is needed. It never runs commands, and v0.1 members are always run by you."),
|
||||
}
|
||||
|
||||
|
||||
def e(s) -> str:
|
||||
return html.escape(str(s))
|
||||
|
||||
|
||||
def pill(text: str) -> str:
|
||||
return f"<span class=pill data-s='{e(text)}'>{e(text)}</span>"
|
||||
|
||||
|
||||
def stamp(iso: str | None) -> str:
|
||||
return iso[:16].replace("T", " ") + " UTC" if iso else "-"
|
||||
|
||||
|
||||
# ---- on the project page ---------------------------------------------------------------------------------------------------
|
||||
|
||||
def project_section(app, reg, doc, needs: list[dict]) -> str:
|
||||
pid = doc.id
|
||||
active = PR.active_run(reg, pid)
|
||||
out = ["<h2>Run the whole project</h2>"]
|
||||
if active:
|
||||
out.append(f"<p>A project run is in progress ({pill(active.data['mode'])}): "
|
||||
f"<a href='/project-run/{e(active.id)}'>continue it</a>. Only one runs at a time.</p>")
|
||||
elif needs:
|
||||
out.append(f"<p class=note>Give the project the attributes it still needs first: {e(', '.join(sorted({n['attribute'] for n in needs})))}.</p>")
|
||||
else:
|
||||
opts = "".join(f"<label><input type=radio name=mode value='{m}'{' checked' if m == 'manual' else ''}> <b>{m}</b>: {e(t)}</label><br>"
|
||||
for m, t in MODE_HELP.items())
|
||||
out.append(f"<form method=post action='/project/{e(pid)}/runall'>{app.hidden()}<fieldset><legend>Mode</legend>{opts}</fieldset>"
|
||||
f"<p class=note>The project's attributes are snapshotted when it starts, so every member run uses the same values even if "
|
||||
f"you edit the project meanwhile.</p>{app.who()}<button>Start project run</button></form>")
|
||||
past = PR.list_runs(reg, pid)
|
||||
if past:
|
||||
rows = ""
|
||||
for p in past:
|
||||
v = PR.view(reg, p)
|
||||
t = PR.times(p)
|
||||
took = f"took {fmt_duration(t['elapsed'])}" if t["finished"] else f"running {fmt_duration(t['elapsed'])}"
|
||||
rows += (f"<tr><td><a href='/project-run/{e(p.id)}'>{e(p.id)}</a></td><td>{pill(p.data['mode'])}</td>"
|
||||
f"<td>{pill(p.data['outcome'] or 'in progress')}</td><td>{v['done']} of {v['total']} members</td>"
|
||||
f"<td class=note>{e(stamp(t['started']))} · {e(took)} · {e(p.data['operator'])}</td></tr>")
|
||||
out.append("<h3>Project runs</h3><table><tr><th>Run</th><th>Mode</th><th>Outcome</th><th>Progress</th><th>When</th></tr>" + rows + "</table>")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
# ---- the project run page ------------------------------------------------------------------------------------------------------
|
||||
|
||||
def page_for(app, prid: str, msg: str = "") -> bytes | None:
|
||||
from .ui import page
|
||||
reg = app.registry()
|
||||
try:
|
||||
pr = PR.load(reg, prid)
|
||||
except R.RunError:
|
||||
return None
|
||||
doc = reg.projects.get(pr.data["project"])
|
||||
d = pr.data
|
||||
closed = pr.closed
|
||||
v = PR.view(reg, pr)
|
||||
t = PR.times(pr)
|
||||
manual = d["mode"] == "manual"
|
||||
took = (f"finished {e(stamp(t['finished']))} · took <b>{e(fmt_duration(t['elapsed']))}</b>" if t["finished"]
|
||||
else f"running for <b>{e(fmt_duration(t['elapsed']))}</b>")
|
||||
out = [f"<h1>Project run: {e(d['title'])}</h1>",
|
||||
f"<p>{pill(d['mode'])} {pill(d['outcome']) if d['outcome'] else pill('in progress')} {pill(d['environment'])} "
|
||||
f"<span class=note>{e(d['id'])} · operator {e(d['operator'])} · <a href='/project/{e(d['project'])}'>project {e(d['project'])}</a></span></p>",
|
||||
f"<p class=note>Started {e(stamp(t['started']))} · {took}</p>",
|
||||
f"<p class=eyebrow>{v['done']} of {v['total']} members done</p><progress max={v['total']} value={v['done']} style='width:100%'></progress>"]
|
||||
if msg:
|
||||
out.append(f"<p class=bad>{e(msg)}</p>")
|
||||
if doc is not None and not closed:
|
||||
changed = PR.attributes_changed(reg, doc, pr)
|
||||
if changed:
|
||||
out.append("<p class=note>The project's attributes have changed since this project run started. It keeps the values it started with "
|
||||
f"({e(', '.join(changed))}); a new project run picks up the new ones.</p>")
|
||||
if d["deviations"]:
|
||||
out.append("<p class=note>⚑ Deviations: " + "; ".join(f"{e(x['kind'])} {e(x['item'])} ({e(x['reason'])})" for x in d["deviations"]) + "</p>")
|
||||
if not closed:
|
||||
out.append(_controls(app, pr, v))
|
||||
out.append(_members(app, reg, pr, v, manual and not closed))
|
||||
out.append("<details><summary><b>Log</b></summary><ul class=feed>" + "".join(
|
||||
f"<li><span class=note>{e(x['at'][:19].replace('T', ' '))}</span> · <b>{e(x['who'])}</b> {e(x['text'])}</li>" for x in reversed(d["events"])) + "</ul></details>")
|
||||
out.append(_close(app, reg, pr, v))
|
||||
return page(f"Project run {d['id']}", "".join(out))
|
||||
|
||||
|
||||
def _controls(app, pr, v: dict) -> str:
|
||||
d = pr.data
|
||||
base = f"/project-run/{e(pr.id)}"
|
||||
if d["mode"] == "manual":
|
||||
if v["open"]:
|
||||
row = next(r for r in v["members"] if r["key"] == v["open"])
|
||||
return (f"<div class=banner><b>Continue:</b> {e(row['key'])} <span class=note>(its run is open)</span></div>"
|
||||
f"<div class=panel><b>Manual mode.</b> <span class=note>You do the member run; when it is closed the next member becomes ready.</span>"
|
||||
f"<form method=post action='{base}/mode' class=row>{app.hidden()}<input type=hidden name=to value=auto-confirm>"
|
||||
"<button>Hand over to auto-confirm</button></form></div>")
|
||||
if v["ready"]:
|
||||
return (f"<div class=banner><b>Next:</b> {e(v['ready'][0])} <span class=note>(ready)</span></div>"
|
||||
f"<div class=panel><b>Manual mode.</b> <span class=note>Start the next member below.</span>"
|
||||
f"<form method=post action='{base}/mode' class=row>{app.hidden()}<input type=hidden name=to value=auto-confirm>"
|
||||
"<button>Hand over to auto-confirm</button></form></div>")
|
||||
if v["done"] == v["total"]:
|
||||
return "<div class=banner><b>Every member is done.</b> Close the project run below.</div>"
|
||||
return "<div class=banner><b>Nothing can start.</b> A member it depends on did not succeed. Close the project run, or start a member with an override.</div>"
|
||||
last = d.get("last")
|
||||
status = f"<p class=note>Last: {e(last['action'])} {e(last.get('member') or '')} {e(last.get('detail') or '')}</p>" if last else ""
|
||||
return (f"<div class=panel data-s='auto'><b>Auto-confirm mode.</b> <span class=note>{e(MODE_HELP['auto-confirm'])}</span>{status}"
|
||||
f"<form method=post action='{base}/tick' class=row>{app.hidden()}"
|
||||
"<button name=n value=1>Check once</button> <button name=n value=300>Run until it waits</button></form>"
|
||||
f"<form method=post action='{base}/mode' class=row>{app.hidden()}<input type=hidden name=to value=manual><button>Take over (manual)</button></form></div>")
|
||||
|
||||
|
||||
def _members(app, reg, pr, v: dict, manual: bool) -> str:
|
||||
base = f"/project-run/{e(pr.id)}"
|
||||
rows = ""
|
||||
for n, r in enumerate(v["members"], 1):
|
||||
pack = reg.packs.get(r["pack"])
|
||||
i = app.pack_index(r["pack"])
|
||||
title = e(pack.data["title"]) if pack else e(r["pack"])
|
||||
link = f"<a href='/pack/{i}'>{title}</a>" if i is not None else title
|
||||
prog = f"{r['progress']['done']}/{r['progress']['total']}" if r.get("progress") else "-"
|
||||
run = f" · <a href='/run/{i}/{e(r['run_id'])}'>open run</a>" if r.get("run_id") and i is not None else ""
|
||||
extra = ""
|
||||
if r["status"] == "waiting":
|
||||
extra = f"<br><span class=note>waiting for {e(', '.join(r['blocked_by']))}</span>"
|
||||
if r.get("detail"):
|
||||
extra += f"<br><span class=note>{e(r['detail'])}</span>"
|
||||
if r["deviations"]:
|
||||
extra += f"<br><span class=note>⚑ {r['deviations']} deviation(s)</span>"
|
||||
action = ""
|
||||
if manual and not r.get("run_id") and not v["open"]:
|
||||
key = e(r["key"])
|
||||
if r["status"] == "ready":
|
||||
action = f"<form method=post action='{base}/start'>{app.hidden()}<input type=hidden name=key value='{key}'><button>Start</button></form>"
|
||||
elif r["status"] == "waiting":
|
||||
action = (f"<details class=override><summary>Start anyway</summary><form method=post action='{base}/start'>{app.hidden()}"
|
||||
f"<input type=hidden name=key value='{key}'><p class=bad>It waits for {e(', '.join(r['blocked_by']))}; starting it now is recorded as a deviation.</p>"
|
||||
"<p><input name=override_reason placeholder='why (required)' required></p>"
|
||||
"<p><label><input type=checkbox name=acknowledge_risk value=1 required> I acknowledge the risk.</label></p>"
|
||||
"<button>Override and start</button></form></details>")
|
||||
rows += (f"<tr><td>{n}</td><td>{link}<br><span class=note>{e(r['key'])}</span>{extra}</td><td>{pill(r['status'])}</td>"
|
||||
f"<td>{prog}{run}</td><td>{action}</td></tr>")
|
||||
return ("<h2>Members</h2><table><tr><th>#</th><th>Runbook</th><th>Status</th><th>Progress</th><th></th></tr>" + rows + "</table>")
|
||||
|
||||
|
||||
def _close(app, reg, pr, v: dict) -> str:
|
||||
d = pr.data
|
||||
if pr.closed:
|
||||
state = PR.seal_state(pr)
|
||||
note = f"<p>Comment: {e(d['outcome_note'])}</p>" if d.get("outcome_note") else ""
|
||||
broken = ("<p class=bad>⚠ This project run's record was changed outside the app after it was sealed.</p>" if state == "broken" else
|
||||
"<p class=note>This project run is <b>closed and locked</b>: its record is sealed and cannot be changed. Start a new project run to try again.</p>")
|
||||
return f"<div id=close class=step><h2>Project run finished: {e(d['outcome'])}</h2>{note}{broken}</div>"
|
||||
btns = []
|
||||
if all(r["status"] == "succeeded" for r in v["members"]):
|
||||
btns.append("<button name=outcome value=succeeded>Finish: succeeded</button>")
|
||||
if v["problems"] or v["open"]:
|
||||
btns.append("<button name=outcome value=failed onclick=\"return confirm('Finish this project run as failed? An open member run is abandoned.')\">Finish: failed</button>")
|
||||
btns.append("<button name=outcome value=abandoned onclick=\"return confirm('Abandon this project run? An open member run is abandoned.')\">Abandon project run</button>")
|
||||
return (f"<div id=close class=step><h2>Close this project run</h2><p class=note>Closing records the outcome, abandons an open member run, and locks the record.</p>"
|
||||
f"<form method=post action='/project-run/{e(pr.id)}/finalize'>{app.hidden()}"
|
||||
f"<p><input name=note class=grow style='width:100%' placeholder='comment (optional)'></p><p class=row>{' '.join(btns)}</p></form></div>")
|
||||
|
||||
|
||||
# ---- actions ---------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def handle_post(app, parts: list[str], form: dict) -> str:
|
||||
"""Returns the URL to go to; raises RunError/ValueError/KeyError with the reason if the action is refused."""
|
||||
reg = app.registry()
|
||||
if len(parts) == 3 and parts[0] == "project" and parts[2] == "runall":
|
||||
doc = reg.projects[parts[1]]
|
||||
pr = PR.create(reg, doc, form.get("operator", ""), form.get("mode", "manual"))
|
||||
return f"/project-run/{pr.id}"
|
||||
if len(parts) == 3 and parts[0] == "project-run":
|
||||
pr = PR.load(reg, parts[1])
|
||||
doc = reg.projects[pr.data["project"]]
|
||||
action = parts[2]
|
||||
if action == "start":
|
||||
run = PR.start_member(reg, doc, pr, form.get("key", ""), form.get("override_reason", ""), bool(form.get("acknowledge_risk")))
|
||||
i = app.pack_index(run.pack.id)
|
||||
return f"/run/{i}/{run.data['run_id']}#current"
|
||||
if action == "tick":
|
||||
n = max(1, min(int(form.get("n", "1") or 1), 300))
|
||||
if n == 1:
|
||||
pr.data["last"] = PR.tick(reg, doc, pr)
|
||||
pr.save()
|
||||
else:
|
||||
PR.run_until_waits(reg, doc, pr, n)
|
||||
return f"/project-run/{pr.id}"
|
||||
if action == "mode":
|
||||
PR.switch_mode(reg, pr, form.get("to", ""))
|
||||
return f"/project-run/{pr.id}"
|
||||
if action == "finalize":
|
||||
PR.finalize(reg, pr, form.get("outcome", ""), form.get("note", ""))
|
||||
return f"/project-run/{pr.id}#close"
|
||||
raise ValueError("unknown action")
|
||||
|
|
@ -166,6 +166,8 @@ def project_page(app, pid: str, msg: str = "") -> bytes | None:
|
|||
out.append("<h2>Runbooks</h2>"
|
||||
f"<p class=note>Next: {e(', '.join(st['next']) or 'nothing to start')}</p>"
|
||||
"<table><tr><th>Runbook</th><th>Status</th><th>Maturity</th><th></th></tr>" + rows + "</table>")
|
||||
from . import ui_projectrun as UPR
|
||||
out.append(UPR.project_section(app, reg, doc, st["needs"]))
|
||||
# attributes per member
|
||||
out.append("<h2>Attributes</h2><p class=note>What each runbook receives from this project, and where each value comes from.</p>")
|
||||
for spec in d["runbooks"]:
|
||||
|
|
|
|||
|
|
@ -96,7 +96,8 @@ def run_page(app, i: int, rid: str, msg: str = "", query: dict | None = None) ->
|
|||
where = ""
|
||||
if ctx.get("project"):
|
||||
where = (f" · project <a href='/project/{e(ctx['project'])}'>{e(ctx['project'])}</a>"
|
||||
f"{' @' + e(ctx['target']) if ctx.get('target') else ''} · {e(ctx.get('environment', ''))}")
|
||||
f"{' @' + e(ctx['target']) if ctx.get('target') else ''} · {e(ctx.get('environment', ''))}"
|
||||
f"{' · part of <a href=' + chr(39) + '/project-run/' + e(ctx['project_run']) + chr(39) + '>project run ' + e(ctx['project_run']) + '</a>' if ctx.get('project_run') else ''}")
|
||||
ov = R.overview(run)
|
||||
prog = ov["progress"]
|
||||
out = [f"<h1>{e(pack.data['title'])}</h1>"
|
||||
|
|
|
|||
264
tests/test_projectrun.py
Normal file
264
tests/test_projectrun.py
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import copy
|
||||
import shutil
|
||||
import stat
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from rtut import project as J, projectrun as PR, run as R1, run2
|
||||
from rtut.attrs import Registry
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def v2_pack(pid, extra_step=None):
|
||||
steps = [{"id": "first", "title": "First step", "actor": "op", "owner": "t", "uses": ["site"], "command": "echo {{site}}",
|
||||
"verify": {"done_when": "you saw the site", "expect": "manual"}},
|
||||
{"id": "second", "title": "Second step", "actor": "op", "owner": "t", "verify": {"done_when": "second done", "expect": "manual"}}]
|
||||
if extra_step:
|
||||
steps.append(extra_step)
|
||||
return {"spec": "runbook-pack/v0.2", "id": pid, "title": f"Pack {pid}", "owner": "t", "exercise_status": "unexercised", "engine": "native",
|
||||
"framing": ["keep in mind"], "actors": [{"id": "op", "role": "Operator", "description": "does it"}],
|
||||
"datapoints": [{"id": "site", "label": "Site", "type": "string", "pattern": "[a-z]+", "default": "north", "ask_in": "main"}],
|
||||
"stages": [{"id": "main", "title": "Main", "goal": "do the things", "steps": steps}]}
|
||||
|
||||
|
||||
def write(path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(yaml.safe_dump(data, sort_keys=False))
|
||||
|
||||
|
||||
def make_repo(tmp_path, members=None, v1=False, alpha_extra=None, attrs=None):
|
||||
write(tmp_path / "runbooks/alpha/pack.yaml", v2_pack("t.alpha", alpha_extra))
|
||||
write(tmp_path / "runbooks/beta/pack.yaml", v2_pack("t.beta"))
|
||||
if v1:
|
||||
shutil.copy(ROOT / "runbooks/example-hello/pack.yaml", tmp_path / "runbooks/hello.yaml")
|
||||
(tmp_path / "runbooks/hello").mkdir()
|
||||
(tmp_path / "runbooks/hello.yaml").rename(tmp_path / "runbooks/hello/pack.yaml")
|
||||
members = members or [{"pack": "t.alpha"}, {"pack": "t.beta", "after": ["t.alpha"]}]
|
||||
write(tmp_path / "projects/demo/project.yaml", {"spec": "project/v0.1", "id": "demo", "title": "Demo project", "environment": "rehearsal",
|
||||
"attributes": attrs or {"site": "south"}, "runbooks": members})
|
||||
return Registry([tmp_path])
|
||||
|
||||
|
||||
def do_all_manual(run):
|
||||
for sid in run2.flow(run).steps:
|
||||
run2.start(run, sid); run2.verify(run, sid, manual_ok=True); run2.complete(run, sid)
|
||||
|
||||
|
||||
def finish_member(reg, doc, pr, key, outcome="succeeded"):
|
||||
row = next(m for m in pr.data["members"] if m["key"] == key)
|
||||
run = R1.load(reg.packs[row["pack"]], row["run_id"])
|
||||
do_all_manual(run)
|
||||
R1.finalize(run, outcome)
|
||||
|
||||
|
||||
# ---- create ------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_create_snapshots_attributes_records_events_and_refuses_bad_starts(tmp_path):
|
||||
reg = make_repo(tmp_path)
|
||||
doc = reg.projects["demo"]
|
||||
pr = PR.create(reg, doc, "bernd")
|
||||
assert pr.data["mode"] == "manual" and pr.data["snapshot"]["t.alpha"] == {"site": "south"} and pr.data["events"][0]["text"].startswith("started")
|
||||
assert pr.path.parent.name == "project-runs" and PR.active_run(reg, "demo").id == pr.id
|
||||
with pytest.raises(R1.RunError, match="still open"):
|
||||
PR.create(reg, doc, "bernd") # one active project run at a time
|
||||
with pytest.raises(R1.RunError, match="mode must be"):
|
||||
PR.create(make_repo(tmp_path / "x"), Registry([tmp_path / "x"]).projects["demo"], "bernd", "autopilot")
|
||||
with pytest.raises(R1.RunError, match="template is not run"):
|
||||
PR.create(reg, Registry([ROOT]).templates["lighthouse-watch"], "bernd")
|
||||
|
||||
|
||||
def test_missing_required_attributes_refuse_the_start(tmp_path):
|
||||
p = v2_pack("t.alpha"); p["datapoints"][0].pop("default")
|
||||
write(tmp_path / "runbooks/alpha/pack.yaml", p)
|
||||
write(tmp_path / "projects/demo/project.yaml", {"spec": "project/v0.1", "id": "demo", "title": "Demo project", "environment": "rehearsal",
|
||||
"runbooks": [{"pack": "t.alpha"}]})
|
||||
reg = Registry([tmp_path])
|
||||
with pytest.raises(R1.RunError, match="attributes still needed: site"):
|
||||
PR.create(reg, reg.projects["demo"], "bernd")
|
||||
|
||||
|
||||
# ---- manual mode ------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_manual_project_run_goes_member_by_member_in_after_order(tmp_path):
|
||||
reg = make_repo(tmp_path); doc = reg.projects["demo"]
|
||||
pr = PR.create(reg, doc, "bernd")
|
||||
v = PR.view(reg, pr)
|
||||
assert [r["status"] for r in v["members"]] == ["ready", "waiting"] and v["members"][1]["blocked_by"] == ["t.alpha"] and v["ready"] == ["t.alpha"]
|
||||
with pytest.raises(R1.RunError, match="waits for t.alpha"):
|
||||
PR.start_member(reg, doc, pr, "t.beta") # out of order: refused with the reason
|
||||
run = PR.start_member(reg, doc, pr, "t.alpha")
|
||||
assert run.data["context"] == {"project": "demo", "environment": "rehearsal", "project_run": pr.id}
|
||||
assert run.data["params"]["site"] == "south" and run.data["mode"] == "manual"
|
||||
with pytest.raises(R1.RunError, match="already has a run"):
|
||||
PR.start_member(reg, doc, pr, "t.alpha")
|
||||
with pytest.raises(R1.RunError, match="finish or abandon the open run"):
|
||||
PR.start_member(reg, doc, pr, "t.beta", override_reason="x", acknowledge_risk=True)
|
||||
finish_member(reg, doc, pr, "t.alpha")
|
||||
v = PR.view(reg, pr)
|
||||
assert [r["status"] for r in v["members"]] == ["succeeded", "ready"] and v["done"] == 1 and v["open"] is None
|
||||
PR.start_member(reg, doc, pr, "t.beta")
|
||||
finish_member(reg, doc, pr, "t.beta")
|
||||
assert PR.view(reg, pr)["done"] == 2
|
||||
PR.finalize(reg, pr, "succeeded", "all good")
|
||||
assert pr.data["outcome"] == "succeeded" and pr.data["events"][-1]["text"] == "closed the project run as succeeded"
|
||||
|
||||
|
||||
def test_starting_out_of_order_needs_a_reason_and_acknowledgement_and_is_recorded(tmp_path):
|
||||
reg = make_repo(tmp_path); doc = reg.projects["demo"]
|
||||
pr = PR.create(reg, doc, "bernd")
|
||||
with pytest.raises(R1.RunError, match="acknowledge"):
|
||||
PR.start_member(reg, doc, pr, "t.beta", override_reason="testing beta first")
|
||||
PR.start_member(reg, doc, pr, "t.beta", override_reason="testing beta first", acknowledge_risk=True)
|
||||
dev = pr.data["deviations"][0]
|
||||
assert dev["kind"] == "override" and dev["item"] == "t.beta" and dev["unmet"] == ["t.alpha is not done"] and dev["reason"] == "testing beta first"
|
||||
assert pr.data["events"][-1]["text"] == "started t.beta (override)"
|
||||
|
||||
|
||||
def test_snapshot_keeps_member_runs_consistent_when_the_project_is_edited_meanwhile(tmp_path):
|
||||
reg = make_repo(tmp_path); doc = reg.projects["demo"]
|
||||
pr = PR.create(reg, doc, "bernd")
|
||||
PR.start_member(reg, doc, pr, "t.alpha"); finish_member(reg, doc, pr, "t.alpha")
|
||||
J.set_attributes(reg, doc, {"site": "east"}) # the project changes while the project run is open
|
||||
reg = Registry([tmp_path]); doc = reg.projects["demo"]
|
||||
assert PR.attributes_changed(reg, doc, pr) == ["t.alpha", "t.beta"]
|
||||
run = PR.start_member(reg, doc, pr, "t.beta")
|
||||
assert run.data["params"]["site"] == "south" # the value the project run started with
|
||||
assert J.member_values(reg, doc.data, doc.data["runbooks"][1])["site"] == "east"
|
||||
|
||||
|
||||
# ---- auto-confirm --------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_auto_confirm_runs_the_whole_project_member_by_member_and_closes_each_run(tmp_path):
|
||||
reg = make_repo(tmp_path); doc = reg.projects["demo"]
|
||||
pr = PR.create(reg, doc, "bernd", "auto-confirm")
|
||||
last = PR.run_until_waits(reg, doc, pr)
|
||||
assert last["action"] == "finished"
|
||||
v = PR.view(reg, pr)
|
||||
assert [r["status"] for r in v["members"]] == ["succeeded", "succeeded"] and v["done"] == 2
|
||||
a = R1.load(reg.packs["t.alpha"], v["members"][0]["run_id"])
|
||||
assert a.data["mode"] == "auto-confirm" and a.data["outcome_note"] == "closed by auto-confirm" and R1.seal_state(a) == "sealed"
|
||||
assert [e["text"] for e in pr.data["events"] if "started" in e["text"] or "finished" in e["text"]][:3] == [
|
||||
"started the project run (auto-confirm mode)", "started t.alpha", "t.alpha finished (succeeded)"]
|
||||
PR.finalize(reg, pr, "succeeded")
|
||||
|
||||
|
||||
def test_auto_confirm_stops_at_a_manual_member_and_continues_after_the_operator_finishes_it(tmp_path):
|
||||
reg = make_repo(tmp_path, [{"pack": "t.alpha"}, {"pack": "rtut.example-hello", "after": ["t.alpha"]}, {"pack": "t.beta", "after": ["rtut.example-hello"]}], v1=True,
|
||||
attrs={"site": "south", "host": "localhost"})
|
||||
doc = reg.projects["demo"]
|
||||
pr = PR.create(reg, doc, "bernd", "auto-confirm")
|
||||
last = PR.run_until_waits(reg, doc, pr)
|
||||
assert last["action"] == "awaiting-operator" and last["member"] == "rtut.example-hello" and "manual mode" in last["detail"]
|
||||
hello = R1.load(reg.packs["rtut.example-hello"], next(m["run_id"] for m in pr.data["members"] if m["pack"] == "rtut.example-hello"))
|
||||
assert hello.pack.spec == "runbook-pack/v0.1" and "mode" not in hello.data # a v0.1 run is always manual: it cannot be auto-confirmed
|
||||
R1.start(hello, "ping"); R1.verify(hello, "ping", confirm=True); R1.complete(hello, "ping"); R1.finalize(hello, "succeeded")
|
||||
assert PR.run_until_waits(reg, doc, pr)["action"] == "finished"
|
||||
assert [r["status"] for r in PR.view(reg, pr)["members"]] == ["succeeded"] * 3
|
||||
|
||||
|
||||
def test_auto_confirm_stops_when_a_member_needs_a_person(tmp_path):
|
||||
step = {"id": "careful", "title": "Careful step", "actor": "op", "owner": "t", "auto_confirm": "manual-only",
|
||||
"verify": {"done_when": "you did it", "expect": "manual"}}
|
||||
reg = make_repo(tmp_path, alpha_extra=step); doc = reg.projects["demo"]
|
||||
pr = PR.create(reg, doc, "bernd", "auto-confirm")
|
||||
last = PR.run_until_waits(reg, doc, pr)
|
||||
assert last["action"] == "awaiting-operator" and last["member"] == "t.alpha" and "manual-only" in last["detail"]
|
||||
assert PR.view(reg, pr)["members"][0]["status"] == "running" and PR.view(reg, pr)["members"][1]["status"] == "waiting"
|
||||
|
||||
|
||||
def test_a_pushed_through_member_lets_dependents_proceed_but_the_project_cannot_succeed(tmp_path):
|
||||
step = {"id": "shaky", "title": "Shaky step", "actor": "op", "owner": "t", "timeout": {"seconds": 1, "on_timeout": "push-through"},
|
||||
"verify": {"done_when": "never true", "command": "false", "expect": "exit-0"}}
|
||||
reg = make_repo(tmp_path, alpha_extra=step); doc = reg.projects["demo"]
|
||||
pr = PR.create(reg, doc, "bernd", "auto-confirm")
|
||||
for _ in range(40):
|
||||
last = PR.tick(reg, doc, pr)
|
||||
if last["action"] == "waiting":
|
||||
run = R1.load(reg.packs["t.alpha"], pr.data["members"][0]["run_id"])
|
||||
run.data["steps"]["shaky"]["started"] = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat(); run.save()
|
||||
if last["action"] in ("finished", "blocked", "awaiting-operator", "stopped"):
|
||||
break
|
||||
v = PR.view(reg, pr)
|
||||
assert v["members"][0]["status"] == "pushed" and v["members"][0]["deviations"] >= 1 # closed as failed, but pushed through
|
||||
assert v["members"][1]["status"] in ("succeeded", "running", "ready") # beta was allowed to proceed
|
||||
assert v["problems"] == ["t.alpha"]
|
||||
with pytest.raises(R1.RunError, match="every member run succeeded"):
|
||||
PR.finalize(reg, pr, "succeeded")
|
||||
|
||||
|
||||
def test_a_failed_member_blocks_its_dependents(tmp_path):
|
||||
reg = make_repo(tmp_path); doc = reg.projects["demo"]
|
||||
pr = PR.create(reg, doc, "bernd")
|
||||
run = PR.start_member(reg, doc, pr, "t.alpha")
|
||||
run2.start(run, "first"); run2.fail(run, "first", "broke")
|
||||
R1.finalize(run, "failed")
|
||||
v = PR.view(reg, pr)
|
||||
assert v["members"][0]["status"] == "failed" and v["members"][1]["status"] == "waiting" and v["problems"] == ["t.alpha"] and v["ready"] == []
|
||||
pr2 = PR.create(make_repo(tmp_path / "y"), Registry([tmp_path / "y"]).projects["demo"], "bernd", "auto-confirm")
|
||||
assert PR.tick(Registry([tmp_path / "y"]), Registry([tmp_path / "y"]).projects["demo"], pr2)["action"] == "member-started"
|
||||
|
||||
|
||||
# ---- modes, closing, locking ------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_switching_mode_carries_the_open_member_run_along(tmp_path):
|
||||
reg = make_repo(tmp_path); doc = reg.projects["demo"]
|
||||
pr = PR.create(reg, doc, "bernd")
|
||||
run = PR.start_member(reg, doc, pr, "t.alpha")
|
||||
PR.switch_mode(reg, pr, "auto-confirm")
|
||||
assert pr.data["mode"] == "auto-confirm" and R1.load(reg.packs["t.alpha"], run.data["run_id"]).data["mode"] == "auto-confirm"
|
||||
PR.switch_mode(reg, pr, "manual")
|
||||
assert R1.load(reg.packs["t.alpha"], run.data["run_id"]).data["mode"] == "manual"
|
||||
with pytest.raises(R1.RunError, match="already in manual"):
|
||||
PR.switch_mode(reg, pr, "manual")
|
||||
|
||||
|
||||
def test_closing_abandons_the_open_member_run_locks_seals_and_refuses_further_work(tmp_path):
|
||||
reg = make_repo(tmp_path); doc = reg.projects["demo"]
|
||||
pr = PR.create(reg, doc, "bernd")
|
||||
run = PR.start_member(reg, doc, pr, "t.alpha")
|
||||
with pytest.raises(R1.RunError, match="succeeds only when"):
|
||||
PR.finalize(reg, pr, "succeeded")
|
||||
PR.finalize(reg, pr, "abandoned", "not now")
|
||||
closed = R1.load(reg.packs["t.alpha"], run.data["run_id"])
|
||||
assert closed.data["outcome"] == "abandoned" and "project run closed as abandoned" in closed.data["outcome_note"]
|
||||
assert pr.data["outcome"] == "abandoned" and PR.seal_state(pr) == "sealed" and not (pr.path.stat().st_mode & 0o222)
|
||||
assert PR.active_run(reg, "demo") is None
|
||||
for call in (lambda: PR.start_member(reg, doc, pr, "t.beta"), lambda: PR.finalize(reg, pr, "abandoned"), lambda: PR.switch_mode(reg, pr, "auto-confirm")):
|
||||
with pytest.raises(R1.RunError, match="closed and locked"):
|
||||
call()
|
||||
pr.data["operator"] = "forged"
|
||||
with pytest.raises(R1.RunError, match="closed and locked"):
|
||||
pr.save()
|
||||
fresh = PR.load(reg, pr.id)
|
||||
assert fresh.data["operator"] == "bernd"
|
||||
|
||||
|
||||
def test_failed_needs_a_problem_or_an_open_member_otherwise_abandon(tmp_path):
|
||||
reg = make_repo(tmp_path); doc = reg.projects["demo"]
|
||||
pr = PR.create(reg, doc, "bernd") # nothing started, nothing wrong
|
||||
with pytest.raises(R1.RunError, match="only when a member failed"):
|
||||
PR.finalize(reg, pr, "failed")
|
||||
PR.start_member(reg, doc, pr, "t.alpha") # an open member run may be given up as failed
|
||||
PR.finalize(reg, pr, "failed", "gave up")
|
||||
assert pr.data["outcome"] == "failed"
|
||||
|
||||
|
||||
def test_a_project_run_edited_on_disk_after_closing_is_reported_broken(tmp_path):
|
||||
import json
|
||||
reg = make_repo(tmp_path); doc = reg.projects["demo"]
|
||||
pr = PR.create(reg, doc, "bernd"); PR.finalize(reg, pr, "abandoned")
|
||||
pr.path.chmod(0o644)
|
||||
d = json.loads(pr.path.read_text()); d["operator"] = "forged"; pr.path.write_text(json.dumps(d))
|
||||
assert PR.seal_state(PR.load(reg, pr.id)) == "broken"
|
||||
|
||||
|
||||
def test_listing_and_times(tmp_path):
|
||||
reg = make_repo(tmp_path); doc = reg.projects["demo"]
|
||||
a = PR.create(reg, doc, "bernd"); PR.finalize(reg, a, "abandoned")
|
||||
b = PR.create(reg, doc, "bernd")
|
||||
assert [p.id for p in PR.list_runs(reg, "demo")] == [b.id, a.id] and PR.active_run(reg, "demo").id == b.id
|
||||
assert PR.list_runs(reg, "nope") == [] and PR.times(a)["finished"] and PR.times(b)["finished"] is None
|
||||
230
tests/test_ui_projectrun.py
Normal file
230
tests/test_ui_projectrun.py
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import http.client
|
||||
import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from rtut import project as J, projectrun as PR, run as R1, run2, ui
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def v2_pack(pid, manual_only_last=False):
|
||||
steps = [{"id": "first", "title": "First step", "actor": "op", "owner": "t", "uses": ["site"], "command": "echo {{site}}",
|
||||
"verify": {"done_when": "you saw the site", "expect": "manual"}},
|
||||
{"id": "second", "title": "Second step", "actor": "op", "owner": "t", "verify": {"done_when": "second done", "expect": "manual"}}]
|
||||
if manual_only_last:
|
||||
steps.append({"id": "careful", "title": "Careful step", "actor": "op", "owner": "t", "auto_confirm": "manual-only",
|
||||
"verify": {"done_when": "you did it", "expect": "manual"}})
|
||||
return {"spec": "runbook-pack/v0.2", "id": pid, "title": f"Pack {pid}", "owner": "t", "exercise_status": "unexercised", "engine": "native",
|
||||
"framing": ["keep in mind"], "actors": [{"id": "op", "role": "Operator", "description": "does it"}],
|
||||
"datapoints": [{"id": "site", "label": "Site", "type": "string", "pattern": "[a-z]+", "default": "north", "ask_in": "main"}],
|
||||
"stages": [{"id": "main", "title": "Main", "goal": "do the things", "steps": steps}]}
|
||||
|
||||
|
||||
def write(path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(yaml.safe_dump(data, sort_keys=False))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def srv(tmp_path):
|
||||
write(tmp_path / "runbooks/alpha/pack.yaml", v2_pack("t.alpha"))
|
||||
write(tmp_path / "runbooks/beta/pack.yaml", v2_pack("t.beta", manual_only_last=True))
|
||||
write(tmp_path / "projects/demo/project.yaml", {
|
||||
"spec": "project/v0.1", "id": "demo", "title": "Demo project", "environment": "rehearsal", "attributes": {"site": "south"},
|
||||
"runbooks": [{"pack": "t.alpha"}, {"pack": "t.beta", "after": ["t.alpha"]}]})
|
||||
s = ui.serve([tmp_path], 0)
|
||||
threading.Thread(target=s.serve_forever, daemon=True).start()
|
||||
s.repo = tmp_path
|
||||
yield s
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def req(s, method, path, form=None):
|
||||
c = http.client.HTTPConnection("127.0.0.1", s.server_address[1])
|
||||
body = urlencode(form) if form is not None else None
|
||||
c.request(method, path, body, {"Host": "127.0.0.1", "Content-Type": "application/x-www-form-urlencoded"})
|
||||
r = c.getresponse()
|
||||
return r.status, r.getheader("Location"), r.read().decode()
|
||||
|
||||
|
||||
def post(s, path, **form):
|
||||
return req(s, "POST", path, {"_t": s.app.token, **form})
|
||||
|
||||
|
||||
def start_pr(s, mode="manual"):
|
||||
st, loc, _ = post(s, "/project/demo/runall", mode=mode)
|
||||
assert st == 303 and loc.startswith("/project-run/")
|
||||
return loc
|
||||
|
||||
|
||||
def finish_run(s, run_url):
|
||||
"""Complete and close the member run behind a /run/<i>/<id> URL, as an operator would in that page."""
|
||||
parts = run_url.split("#")[0].split("/")
|
||||
i, rid = int(parts[2]), parts[3]
|
||||
run = R1.load(s.app.packs[i], rid)
|
||||
for sid in run2.flow(run).steps:
|
||||
assert post(s, f"/run/{i}/{rid}/{sid}/start")[0] == 303
|
||||
assert post(s, f"/run/{i}/{rid}/{sid}/verify", ok="1")[0] == 303
|
||||
assert post(s, f"/run/{i}/{rid}/{sid}/done")[0] == 303
|
||||
assert post(s, f"/run/{i}/{rid}/finalize", outcome="succeeded")[0] == 303
|
||||
|
||||
|
||||
# ---- starting -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_project_page_offers_the_whole_project_run_with_both_modes(srv):
|
||||
_, _, body = req(srv, "GET", "/project/demo")
|
||||
assert "<h2>Run the whole project</h2>" in body and "name=mode value='manual' checked" in body and "name=mode value='auto-confirm'" in body
|
||||
assert "<b>manual</b>: You start each member yourself" in body and "<b>auto-confirm</b>: The engine starts the next ready member" in body
|
||||
assert "Running as <b>tester-login</b>" in body and "Start project run" in body and "snapshotted" in body
|
||||
|
||||
|
||||
def test_missing_attributes_block_the_start_and_say_which(srv):
|
||||
p = v2_pack("t.alpha"); p["datapoints"][0].pop("default")
|
||||
write(srv.repo / "runbooks/alpha/pack.yaml", p)
|
||||
d = yaml.safe_load((srv.repo / "projects/demo/project.yaml").read_text()); d["attributes"] = {}
|
||||
write(srv.repo / "projects/demo/project.yaml", d)
|
||||
_, _, body = req(srv, "GET", "/project/demo")
|
||||
assert "Give the project the attributes it still needs first: site" in body and "Start project run" not in body
|
||||
st, _, err = post(srv, "/project/demo/runall", mode="manual")
|
||||
assert st == 400 and "attributes still needed: site" in err
|
||||
|
||||
|
||||
# ---- manual --------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_manual_project_run_page_shows_next_member_and_walks_them_in_order(srv):
|
||||
loc = start_pr(srv)
|
||||
_, _, body = req(srv, "GET", loc)
|
||||
assert "<h1>Project run: Demo project</h1>" in body and "0 of 2 members done" in body
|
||||
assert "<b>Next:</b> t.alpha" in body and "Manual mode." in body and "Hand over to auto-confirm" in body
|
||||
assert "waiting for t.alpha" in body and "Start anyway" in body and ">ready<" in body and ">waiting<" in body
|
||||
st, run_loc, _ = post(srv, f"{loc}/start", key="t.alpha")
|
||||
assert st == 303 and run_loc.startswith("/run/") and run_loc.endswith("#current")
|
||||
_, _, runpage = req(srv, "GET", run_loc.split("#")[0])
|
||||
assert f"part of <a href='{loc}'>project run" in runpage and "project <a href='/project/demo'>demo</a>" in runpage
|
||||
_, _, body = req(srv, "GET", loc)
|
||||
assert "<b>Continue:</b> t.alpha" in body and "open run" in body and ">running<" in body
|
||||
assert "<button>Start</button>" not in body # one member at a time
|
||||
finish_run(srv, run_loc)
|
||||
_, _, body = req(srv, "GET", loc)
|
||||
assert "1 of 2 members done" in body and "<b>Next:</b> t.beta" in body and ">succeeded<" in body
|
||||
|
||||
|
||||
def test_out_of_order_start_needs_reason_and_acknowledgement_and_shows_as_a_deviation(srv):
|
||||
loc = start_pr(srv)
|
||||
st, _, err = post(srv, f"{loc}/start", key="t.beta")
|
||||
assert st == 400 and "waits for t.alpha" in err
|
||||
st, _, err = post(srv, f"{loc}/start", key="t.beta", override_reason="beta first")
|
||||
assert st == 400 and "acknowledge" in err
|
||||
assert post(srv, f"{loc}/start", key="t.beta", override_reason="beta first", acknowledge_risk="1")[0] == 303
|
||||
_, _, body = req(srv, "GET", loc)
|
||||
assert "⚑ Deviations: override t.beta (beta first)" in body and "started t.beta (override)" in body
|
||||
|
||||
|
||||
def test_only_one_project_run_is_active_and_the_page_links_to_it(srv):
|
||||
loc = start_pr(srv)
|
||||
_, _, body = req(srv, "GET", "/project/demo")
|
||||
assert f"<a href='{loc}'>continue it</a>" in body and "Start project run" not in body
|
||||
st, _, err = post(srv, "/project/demo/runall", mode="manual")
|
||||
assert st == 400 and "still open" in err
|
||||
|
||||
|
||||
# ---- auto-confirm -----------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_auto_confirm_runs_members_until_a_person_is_needed_then_continues(srv):
|
||||
loc = start_pr(srv, "auto-confirm")
|
||||
_, _, body = req(srv, "GET", loc)
|
||||
assert "Auto-confirm mode." in body and "Run until it waits" in body and "Take over (manual)" in body and "never runs commands" in body
|
||||
assert "<button>Start</button>" not in body # the engine starts members, not you
|
||||
assert post(srv, f"{loc}/tick", n="300")[0] == 303
|
||||
_, _, body = req(srv, "GET", loc)
|
||||
assert "1 of 2 members done" in body and "Last: awaiting-operator t.beta" in body and "manual-only" in body
|
||||
pr = PR.load(srv.app.registry(), loc.rsplit("/", 1)[1])
|
||||
beta = R1.load(srv.app.registry().packs["t.beta"], next(m["run_id"] for m in pr.data["members"] if m["pack"] == "t.beta"))
|
||||
assert beta.data["mode"] == "auto-confirm" and beta.data["context"]["project_run"] == pr.id
|
||||
i = srv.app.pack_index("t.beta")
|
||||
assert post(srv, f"/run/{i}/{beta.data['run_id']}/careful/start")[0] == 303 # the person takes the careful step
|
||||
assert post(srv, f"/run/{i}/{beta.data['run_id']}/careful/verify", ok="1")[0] == 303
|
||||
assert post(srv, f"/run/{i}/{beta.data['run_id']}/careful/done")[0] == 303
|
||||
assert post(srv, f"{loc}/tick", n="300")[0] == 303
|
||||
_, _, body = req(srv, "GET", loc)
|
||||
assert "2 of 2 members done" in body and "Finish: succeeded" in body
|
||||
|
||||
|
||||
def test_take_over_and_hand_over(srv):
|
||||
loc = start_pr(srv, "auto-confirm")
|
||||
assert post(srv, f"{loc}/mode", to="manual")[0] == 303
|
||||
_, _, body = req(srv, "GET", loc)
|
||||
assert "Manual mode." in body and "Hand over to auto-confirm" in body and "switched to manual mode" in body
|
||||
st, _, err = post(srv, f"{loc}/mode", to="manual")
|
||||
assert st == 400 and "already in manual" in err
|
||||
|
||||
|
||||
# ---- closing and locking ------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_closing_the_project_run_locks_it_and_abandons_the_open_member_run(srv):
|
||||
loc = start_pr(srv)
|
||||
post(srv, f"{loc}/start", key="t.alpha")
|
||||
_, _, body = req(srv, "GET", loc)
|
||||
assert "Finish: succeeded" not in body and "Abandon project run" in body and "Finish: failed" in body
|
||||
st, _, err = post(srv, f"{loc}/finalize", outcome="succeeded")
|
||||
assert st == 400 and "succeeds only when" in err
|
||||
assert post(srv, f"{loc}/finalize", outcome="abandoned", note="not now")[0] == 303
|
||||
_, _, body = req(srv, "GET", loc)
|
||||
assert "Project run finished: abandoned" in body and "Comment: not now" in body and "closed and locked" in body
|
||||
assert "Close this project run" not in body and "<button>Start</button>" not in body and "Hand over" not in body
|
||||
reg = srv.app.registry()
|
||||
pr = PR.load(reg, loc.rsplit("/", 1)[1])
|
||||
member = R1.load(reg.packs["t.alpha"], pr.data["members"][0]["run_id"])
|
||||
assert member.data["outcome"] == "abandoned"
|
||||
for path, form in ((f"{loc}/start", {"key": "t.beta"}), (f"{loc}/tick", {"n": "1"}), (f"{loc}/mode", {"to": "auto-confirm"}), (f"{loc}/finalize", {"outcome": "abandoned"})):
|
||||
st, _, err = post(srv, path, **form)
|
||||
assert st == 400, path
|
||||
_, _, body = req(srv, "GET", "/project/demo")
|
||||
assert "Start project run" in body and f"<a href='{loc}'>" in body # a new one may start; the old one is in the history
|
||||
|
||||
|
||||
def test_a_tampered_closed_project_run_is_called_out(srv):
|
||||
import json
|
||||
loc = start_pr(srv)
|
||||
post(srv, f"{loc}/finalize", outcome="abandoned")
|
||||
pr = PR.load(srv.app.registry(), loc.rsplit("/", 1)[1])
|
||||
pr.path.chmod(0o644)
|
||||
d = json.loads(pr.path.read_text()); d["operator"] = "forged"; pr.path.write_text(json.dumps(d))
|
||||
assert "changed outside the app after it was sealed" in req(srv, "GET", loc)[2]
|
||||
|
||||
|
||||
# ---- history, activity, snapshot, safety --------------------------------------------------------------------------------------------
|
||||
|
||||
def test_history_table_and_activity_feed_include_the_project_run(srv):
|
||||
loc = start_pr(srv)
|
||||
post(srv, f"{loc}/start", key="t.alpha")
|
||||
_, _, body = req(srv, "GET", "/project/demo")
|
||||
assert "<h3>Project runs</h3>" in body and ">in progress<" in body and "0 of 2 members" in body
|
||||
_, _, act = req(srv, "GET", "/activity")
|
||||
assert "project run: started the project run (manual mode)" in act and "project run: started t.alpha" in act and f"href='{loc}'" in act
|
||||
|
||||
|
||||
def test_attribute_changes_after_the_start_are_shown_and_do_not_reach_later_members(srv):
|
||||
loc = start_pr(srv)
|
||||
post(srv, f"{loc}/start", key="t.alpha")
|
||||
reg = srv.app.registry()
|
||||
J.set_attributes(reg, reg.projects["demo"], {"site": "east"})
|
||||
_, _, body = req(srv, "GET", loc)
|
||||
assert "attributes have changed since this project run started" in body and "keeps the values it started with" in body
|
||||
pr = PR.load(srv.app.registry(), loc.rsplit("/", 1)[1])
|
||||
assert pr.data["snapshot"]["t.beta"] == {"site": "south"}
|
||||
|
||||
|
||||
def test_sidebar_marks_projects_on_a_project_run_page_and_csrf_and_unknown_ids(srv):
|
||||
loc = start_pr(srv)
|
||||
_, _, body = req(srv, "GET", loc)
|
||||
assert "'/project-run/']" in body # the alias list includes project runs
|
||||
assert req(srv, "POST", f"{loc}/tick", {"n": "1"})[0] == 403
|
||||
assert req(srv, "POST", "/project/demo/runall", {"mode": "manual"})[0] == 403
|
||||
assert req(srv, "GET", "/project-run/nope")[0] == 404
|
||||
assert post(srv, "/project-run/nope/tick", n="1")[0] == 400
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue