Run a whole project: project runs in manual and auto-confirm mode (RBT-WP-0008-T10)
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s
Work Records / validate (push) Has been cancelled

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
This commit is contained in:
tegwick 2026-09-29 20:08:17 +02:00
parent 1d1087fa7f
commit eff75a303c
14 changed files with 1112 additions and 15 deletions

View file

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

View file

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

334
rtut/projectrun.py Normal file
View file

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

View file

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

View file

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

208
rtut/ui_projectrun.py Normal file
View file

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

View file

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

View file

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