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
|
|
@ -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>"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue