Rename open to stalled and autopilot to auto-confirm; add pushed state; declare idempotency; propose maturity and projects model (RBT-WP-0008)
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
f39d5cec3c
commit
f5619e1a52
19 changed files with 429 additions and 105 deletions
|
|
@ -114,7 +114,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
for p in _packs(a.root):
|
||||
if p.ok and p.data.get("engine") == "native" and p.spec == "runbook-pack/v0.1":
|
||||
c = R.scorecard(p)
|
||||
print(f"{p.id}\ttried {c['tried']}\tsucceeded {c['succeeded']}\tfailed {c['failed']}\tabandoned {c['abandoned']}\topen {c['open']}\t[{p.data['exercise_status']}]")
|
||||
print(f"{p.id}\ttried {c['tried']}\tsucceeded {c['succeeded']}\tfailed {c['failed']}\tabandoned {c['abandoned']}\tstalled {c['stalled']}\t[{p.data['exercise_status']}]")
|
||||
return 0
|
||||
if a.cmd in ("list", "validate"):
|
||||
packs = _packs(a.root)
|
||||
|
|
|
|||
14
rtut/flow.py
14
rtut/flow.py
|
|
@ -67,3 +67,17 @@ class Flow:
|
|||
for a in self.ancestors(id_):
|
||||
refs += self.stages[a].get("requires", [])
|
||||
return [r for r in (self.resolve(x) for x in refs) if r]
|
||||
|
||||
|
||||
def pack_idempotency(data: dict) -> str:
|
||||
"""Derived characteristic of the whole runbook: idempotent, non-idempotent, conditional or unknown."""
|
||||
fl = Flow(data)
|
||||
vals = []
|
||||
for s in fl.steps.values():
|
||||
parts = s.get("one_of") or [s]
|
||||
vals += [p.get("idempotent", s.get("idempotent", "unknown")) for p in parts]
|
||||
if not vals or "unknown" in vals:
|
||||
return "unknown"
|
||||
if "non-idempotent" in vals:
|
||||
return "non-idempotent"
|
||||
return "conditional" if "conditional" in vals else "idempotent"
|
||||
|
|
|
|||
13
rtut/run.py
13
rtut/run.py
|
|
@ -244,8 +244,6 @@ def exercise_update(run: Run, today: date | None = None) -> tuple[str, str]:
|
|||
raise RunError("every step must be done (not skipped) to exercise the pack")
|
||||
if run.data.get("deviations"):
|
||||
raise RunError("a run with deviations (overrides, waivers, push-through) cannot exercise the pack")
|
||||
if any(v.get("pushed_through") for v in run.data["steps"].values()):
|
||||
raise RunError("a run with pushed-through steps cannot exercise the pack")
|
||||
today = today or date.today()
|
||||
old = run.pack.path.read_text()
|
||||
new = STATUS_LINE.sub(f"exercise_status: exercised {today.isoformat()} by {run.data['operator']}",
|
||||
|
|
@ -346,8 +344,9 @@ def finalize(run: Run, outcome: str, note: str = "") -> None:
|
|||
raise RunError("a run succeeds only when every step is done (skipped steps do not count)")
|
||||
if outcome == "succeeded" and any(g["state"] not in ("passed", "waived") for g in run.data.get("gates", {}).values()):
|
||||
raise RunError("a run succeeds only when every gate has passed or been waived")
|
||||
if outcome == "failed" and "failed" not in states:
|
||||
raise RunError("a run can be finished as failed only when a step has failed; otherwise abandon it")
|
||||
gstates = [g["state"] for g in run.data.get("gates", {}).values()]
|
||||
if outcome == "failed" and not ({"failed", "pushed"} & set(states) or {"failed", "pushed"} & set(gstates)):
|
||||
raise RunError("a run can be finished as failed only when a step or gate has failed or was pushed through; otherwise abandon it")
|
||||
run.data["outcome"] = outcome
|
||||
run.data["finished"] = _now()
|
||||
if note.strip():
|
||||
|
|
@ -356,17 +355,17 @@ def finalize(run: Run, outcome: str, note: str = "") -> None:
|
|||
|
||||
|
||||
def effective_outcome(run: Run, now: datetime | None = None, stale_days: int = STALE_DAYS) -> str:
|
||||
"""finished outcome, or 'open', or 'abandoned' for an open run untouched for stale_days."""
|
||||
"""finished outcome, or 'stalled' (unfinished), or 'abandoned' for an unfinished run untouched for stale_days."""
|
||||
if run.data.get("outcome"):
|
||||
return run.data["outcome"]
|
||||
now = now or datetime.now(timezone.utc)
|
||||
last = datetime.fromisoformat(run.data.get("updated") or run.data["created"])
|
||||
return "abandoned" if (now - last).days >= stale_days else "open"
|
||||
return "abandoned" if (now - last).days >= stale_days else "stalled"
|
||||
|
||||
|
||||
def scorecard(pack: Pack, now: datetime | None = None, stale_days: int = STALE_DAYS) -> dict:
|
||||
"""How often a runbook was tried and how it went, derived from its run records."""
|
||||
card = {"tried": 0, "succeeded": 0, "failed": 0, "abandoned": 0, "open": 0,
|
||||
card = {"tried": 0, "succeeded": 0, "failed": 0, "abandoned": 0, "stalled": 0,
|
||||
"feedback": {"good": 0, "neutral": 0, "poor": 0}}
|
||||
for rid in list_runs(pack):
|
||||
r = load(pack, rid)
|
||||
|
|
|
|||
66
rtut/run2.py
66
rtut/run2.py
|
|
@ -1,7 +1,7 @@
|
|||
"""Runs of runbook-pack/v0.2 packs (record runbook-run/v0.2).
|
||||
|
||||
Adds to the v0.1 run: datapoint states (nil/set/err/ok), a dependency graph with a recommended
|
||||
next action, alternatives, QA gates with waivers, manual and autopilot modes with timeouts, and
|
||||
next action, alternatives, QA gates with waivers, manual and auto-confirm modes with timeouts, and
|
||||
a deviation record. Shared helpers (feedback, finalize, captures, score) live in run.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
@ -18,8 +18,10 @@ from .run import Run, RunError, _clean, _now, runs_dir # noqa: F401 (re-export
|
|||
|
||||
DEFAULT_PHRASE = "I understand this cannot be undone"
|
||||
VERIFY_TIMEOUT = 60
|
||||
DONE_STEP = ("done", "skipped")
|
||||
DONE_GATE = ("passed", "waived")
|
||||
# "pushed" is its own state: the item was passed over by auto-confirm push-through without being verified.
|
||||
# It lets dependents proceed but is neither done nor an error, and it is tracked separately.
|
||||
DONE_STEP = ("done", "skipped", "pushed")
|
||||
DONE_GATE = ("passed", "waived", "pushed")
|
||||
|
||||
|
||||
def flow(run: Run) -> Flow:
|
||||
|
|
@ -85,8 +87,8 @@ def create(pack: Pack, operator: str, values: dict[str, str] | None = None, mode
|
|||
preauthorize: list[str] | None = None) -> Run:
|
||||
if not pack.ok or pack.data.get("engine") != "native" or pack.spec != "runbook-pack/v0.2":
|
||||
raise RunError("only valid native runbook-pack/v0.2 packs can be run here")
|
||||
if mode not in ("manual", "autopilot"):
|
||||
raise RunError("mode must be manual or autopilot")
|
||||
if mode not in ("manual", "auto-confirm"):
|
||||
raise RunError("mode must be manual or auto-confirm")
|
||||
operator = _clean(operator.strip(), "operator")
|
||||
if not re.fullmatch(r"\S+", operator):
|
||||
raise RunError("operator must be one word (name or handle)")
|
||||
|
|
@ -102,7 +104,7 @@ def create(pack: Pack, operator: str, values: dict[str, str] | None = None, mode
|
|||
"spec": "runbook-run/v0.2", "run_id": time.strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6],
|
||||
"pack_id": pack.id, "pack_path": str(pack.path), "operator": operator, "created": _now(),
|
||||
"mode": mode, "params": {}, "deviations": [],
|
||||
"autopilot": {"preauthorized": pre, "authorized_by": operator if pre else None},
|
||||
"auto_confirm": {"preauthorized": pre, "authorized_by": operator if pre else None},
|
||||
"datapoints": {i: {"state": "nil"} for i in fl.datapoints},
|
||||
"steps": {i: {"state": "todo"} for i in fl.steps},
|
||||
"gates": {i: {"state": "open", "results": {}} for i in fl.gates},
|
||||
|
|
@ -188,7 +190,7 @@ def unmet(run: Run, id_: str) -> list[str]:
|
|||
if fl.kind(id_) == "step":
|
||||
for dp in fl.steps[id_].get("uses", []):
|
||||
st = run.data["datapoints"][dp]["state"]
|
||||
if st not in ("set", "ok"):
|
||||
if st not in ("set", "ok", "pushed"):
|
||||
out.append(f"datapoint {dp} is {st}")
|
||||
elif fl.kind(id_) == "gate":
|
||||
stage = fl.parent[id_]
|
||||
|
|
@ -266,9 +268,9 @@ def start(run: Run, step_id: str, alt: str | None = None, phrase: str | None = N
|
|||
raise RunError(f"step {step_id} has no alternatives")
|
||||
eff = effective_step(run.pack, fl, step_id, st.get("alternative"))
|
||||
if not eff.get("reversible", True): # irreversible: the typed phrase is enforced here
|
||||
pre = run.data["autopilot"]["preauthorized"]
|
||||
if step_id in pre and run.data["mode"] == "autopilot":
|
||||
st["authorized_by"] = run.data["autopilot"]["authorized_by"]
|
||||
pre = run.data["auto_confirm"]["preauthorized"]
|
||||
if step_id in pre and run.data["mode"] == "auto-confirm":
|
||||
st["authorized_by"] = run.data["auto_confirm"]["authorized_by"]
|
||||
elif (phrase or "") != phrase_for(run, step_id):
|
||||
raise RunError(f"step {step_id} is irreversible: type the confirmation phrase exactly: "
|
||||
f"{phrase_for(run, step_id)!r}")
|
||||
|
|
@ -298,7 +300,10 @@ def _check_result(run: Run, ck: dict, confirm: bool, manual_ok: bool | None, aut
|
|||
elif exp in ("exit-0", "output-contains"):
|
||||
if not confirm:
|
||||
raise RunError("running a check command needs operator confirmation")
|
||||
cmd = render(ck["command"], run.data["params"], run.pack)
|
||||
try:
|
||||
cmd = render(ck["command"], run.data["params"], run.pack)
|
||||
except (KeyError, ValueError) as e: # e.g. a datapoint that was pushed through has no value
|
||||
return {"passed": False, "kind": exp, "parts": {"command": False, "error": str(e)}, "at": _now()}
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
cp = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=VERIFY_TIMEOUT,
|
||||
|
|
@ -428,7 +433,7 @@ def waive(run: Run, gate_id: str, reason: str) -> None:
|
|||
run.save()
|
||||
|
||||
|
||||
# ---- autopilot ----------------------------------------------------------------------------------------------------------
|
||||
# ---- auto-confirm ----------------------------------------------------------------------------------------------------------
|
||||
|
||||
def _timeout_for(run: Run, id_: str) -> dict | None:
|
||||
return flow(run).node(id_).get("timeout") or run.pack.data.get("timeout")
|
||||
|
|
@ -439,18 +444,18 @@ def _elapsed(started: str, now: datetime) -> float:
|
|||
|
||||
|
||||
def _stop(run: Run, item: str, why: str) -> dict:
|
||||
run.data["autopilot"]["stopped"] = {"item": item, "reason": why, "at": _now()}
|
||||
run.data["auto_confirm"]["stopped"] = {"item": item, "reason": why, "at": _now()}
|
||||
run.save()
|
||||
return {"action": "stopped", "item": item, "detail": why}
|
||||
|
||||
|
||||
def autopilot_tick(run: Run, now: datetime | None = None) -> dict:
|
||||
"""One autopilot decision. Confirmation is applied when preconditions are met; a timeout stops
|
||||
def auto_confirm_tick(run: Run, now: datetime | None = None) -> dict:
|
||||
"""One auto-confirm decision. Confirmation is applied when preconditions are met; a timeout stops
|
||||
the run or pushes through, recording a deviation. The engine never runs a step's own command."""
|
||||
_writable(run)
|
||||
if run.data["mode"] != "autopilot":
|
||||
raise RunError("autopilot_tick needs an autopilot run")
|
||||
ap = run.data["autopilot"]
|
||||
if run.data["mode"] != "auto-confirm":
|
||||
raise RunError("auto_confirm_tick needs an auto-confirm run")
|
||||
ap = run.data["auto_confirm"]
|
||||
if ap.get("stopped"):
|
||||
return {"action": "stopped", "item": ap["stopped"]["item"], "detail": ap["stopped"]["reason"]}
|
||||
now = now or datetime.now(timezone.utc)
|
||||
|
|
@ -463,8 +468,8 @@ def autopilot_tick(run: Run, now: datetime | None = None) -> dict:
|
|||
first = na["blocked"][0]
|
||||
return _stop(run, first["id"], "nothing can advance: " + "; ".join(first["unmet"]))
|
||||
if fl.kind(item) == "step":
|
||||
return _autopilot_step(run, item, now)
|
||||
return _autopilot_gate(run, item, now)
|
||||
return _auto_confirm_step(run, item, now)
|
||||
return _auto_confirm_gate(run, item, now)
|
||||
|
||||
|
||||
def _on_timeout(run: Run, item: str, cfg: dict, detail: str) -> dict:
|
||||
|
|
@ -473,17 +478,17 @@ def _on_timeout(run: Run, item: str, cfg: dict, detail: str) -> dict:
|
|||
return {"action": "push-through", "item": item, "detail": detail}
|
||||
|
||||
|
||||
def _autopilot_step(run: Run, sid: str, now: datetime) -> dict:
|
||||
def _auto_confirm_step(run: Run, sid: str, now: datetime) -> dict:
|
||||
fl = flow(run)
|
||||
s = fl.steps[sid]
|
||||
st = run.data["steps"][sid]
|
||||
if s.get("autopilot") == "manual-only":
|
||||
if s.get("auto_confirm") == "manual-only":
|
||||
return {"action": "awaiting-operator", "item": sid, "detail": "step is manual-only"}
|
||||
if st["state"] == "failed":
|
||||
return {"action": "awaiting-operator", "item": sid, "detail": "step failed; retry or stop"}
|
||||
alt = st.get("alternative") or recommended_alt(run, sid)
|
||||
eff = effective_step(run.pack, fl, sid, alt)
|
||||
if not eff.get("reversible", True) and sid not in run.data["autopilot"]["preauthorized"]:
|
||||
if not eff.get("reversible", True) and sid not in run.data["auto_confirm"]["preauthorized"]:
|
||||
return {"action": "awaiting-operator", "item": sid, "detail": "irreversible and not pre-authorized"}
|
||||
if s.get("one_of") and not alt:
|
||||
return {"action": "awaiting-operator", "item": sid, "detail": "no alternative is recommended; the operator must choose"}
|
||||
|
|
@ -503,19 +508,20 @@ def _autopilot_step(run: Run, sid: str, now: datetime) -> dict:
|
|||
if cfg and _elapsed(st["started"], now) >= cfg["seconds"]:
|
||||
out = _on_timeout(run, sid, cfg, "verification did not pass")
|
||||
if out["action"] == "push-through":
|
||||
st.update(state="done", finished=_now(), pushed_through=True)
|
||||
st.update(state="pushed", finished=_now())
|
||||
_deviate(run, "push-through", sid, ["verification did not pass"],
|
||||
f"autopilot timeout after {cfg['seconds']}s", _risk_text(run, sid), False)
|
||||
f"auto-confirm timeout after {cfg['seconds']}s", _risk_text(run, sid), False)
|
||||
for dp in s.get("produces", []):
|
||||
rec = run.data["datapoints"].get(dp)
|
||||
if rec and rec["state"] == "nil":
|
||||
rec.update(state="err", error="pushed through without verification", at=_now())
|
||||
if rec and rec["state"] in ("nil", "err"):
|
||||
rec.pop("error", None)
|
||||
rec.update(state="pushed", how="push-through", at=_now())
|
||||
run.save()
|
||||
return out
|
||||
return {"action": "waiting", "item": sid, "detail": "verification did not pass yet"}
|
||||
|
||||
|
||||
def _autopilot_gate(run: Run, gid: str, now: datetime) -> dict:
|
||||
def _auto_confirm_gate(run: Run, gid: str, now: datetime) -> dict:
|
||||
g = flow(run).gates[gid]
|
||||
gs = run.data["gates"][gid]
|
||||
gs.setdefault("started", _now())
|
||||
|
|
@ -531,9 +537,9 @@ def _autopilot_gate(run: Run, gid: str, now: datetime) -> dict:
|
|||
out = _on_timeout(run, gid, cfg, "gate did not pass")
|
||||
if out["action"] == "push-through":
|
||||
failed = [f"check {i} not passed" for i, r in gs["results"].items() if not r["passed"]]
|
||||
gs.update(state="waived", waiver={"by_actor": "autopilot", "who": run.data["operator"],
|
||||
gs.update(state="pushed", pushed={"who": run.data["operator"],
|
||||
"reason": f"push-through after {cfg['seconds']}s", "at": _now()})
|
||||
_deviate(run, "push-through", gid, failed, f"autopilot timeout after {cfg['seconds']}s", _risk_text(run, gid), False)
|
||||
_deviate(run, "push-through", gid, failed, f"auto-confirm timeout after {cfg['seconds']}s", _risk_text(run, gid), False)
|
||||
run.save()
|
||||
return out
|
||||
return {"action": "waiting", "item": gid, "detail": "gate did not pass yet"}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ SMILEYS = (("good", "🙂", "Went well"), ("neutral", "😐", "So-so"), ("poor",
|
|||
|
||||
def score_line(c: dict) -> str:
|
||||
return (f"tried {c['tried']} · succeeded {c['succeeded']} · failed {c['failed']} · "
|
||||
f"abandoned {c['abandoned']} · open {c['open']}")
|
||||
f"abandoned {c['abandoned']} · stalled {c['stalled']}")
|
||||
|
||||
|
||||
class App:
|
||||
|
|
@ -147,7 +147,7 @@ class App:
|
|||
btns.append("<button name=outcome value=abandoned onclick=\"return confirm('Abandon this run?')\">Abandon run</button>")
|
||||
return (f"<div id=close class=step><h2>Close this run</h2>"
|
||||
"<p class=note>Finishing records the outcome in the pack's score. A finished run is read-only. "
|
||||
"Runs left open for a week count as abandoned.</p>"
|
||||
"Unfinished runs count as stalled, and as abandoned after a week.</p>"
|
||||
f"<form method=post action='/run/{i}/{e(rid)}/finalize'>{self.hidden()}"
|
||||
"<p><input name=note class=grow style='width:100%' placeholder='comment (optional)'></p>"
|
||||
f"<p class=row>{' '.join(btns)}</p></form></div>")
|
||||
|
|
|
|||
|
|
@ -128,6 +128,9 @@ def _check_refs(m: _Model, data: dict) -> None:
|
|||
for v in vals:
|
||||
if v not in dps[k]["values"]:
|
||||
m.errs.append(f"step {pid}: recommended_when {k}={v} not in values")
|
||||
for part, pid in parts:
|
||||
if part.get("idempotent", node.get("idempotent")) == "conditional" and not (part.get("idempotent_note") or node.get("idempotent_note")):
|
||||
m.errs.append(f"step {pid}: idempotent: conditional needs an idempotent_note saying when")
|
||||
if node.get("one_of") and node.get("command"):
|
||||
m.errs.append(f"step {id_}: a step with one_of must not also have its own command")
|
||||
elif kind == "gate":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue