v0.2 walk-through UI: guided flow, data view, overview, modes, overrides, typed phrase; retry confirmation; visual pass
Some checks failed
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
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 14:42:09 +02:00
parent f8ce4717a8
commit f070999aa2
12 changed files with 1285 additions and 53 deletions

View file

@ -370,11 +370,19 @@ def fail(run: Run, step_id: str, note: str = "") -> None:
run.save()
def retry(run: Run, step_id: str) -> None:
def retry(run: Run, step_id: str, acknowledge_partial: bool = False) -> None:
"""Retry a failed step. Unless it is declared idempotent, the operator must confirm that the earlier
attempt did not partly apply (a repeated non-idempotent step can double its effect)."""
_writable(run)
st = run.data["steps"][step_id]
if st["state"] != "failed":
raise RunError(f"step {step_id} has not failed")
idem = step_def(run, step_id).get("idempotent") or flow(run).steps[step_id].get("idempotent")
if idem != "idempotent":
if not acknowledge_partial:
raise RunError(f"step {step_id} is {idem or 'not declared idempotent'}: confirm that the earlier attempt did not "
"partly apply before retrying it")
st.setdefault("retries", []).append({"at": _now(), "acknowledged_partial": True})
for dp in flow(run).steps[step_id].get("produces", []):
rec = run.data["datapoints"].get(dp)
if rec and rec["state"] == "err" and rec.get("error", "").startswith("producing step"):
@ -544,3 +552,96 @@ def _auto_confirm_gate(run: Run, gid: str, now: datetime) -> dict:
run.save()
return out
return {"action": "waiting", "item": gid, "detail": "gate did not pass yet"}
# ---- views for renderers: overview, datapoint table with dimensions, artefacts, mode switch -----------------------------
def switch_mode(run: Run, mode: str) -> None:
"""Take over (auto-confirm to manual) or hand over (manual to auto-confirm). The change is recorded."""
_writable(run)
if mode not in ("manual", "auto-confirm"):
raise RunError("mode must be manual or auto-confirm")
if mode == run.data["mode"]:
raise RunError(f"the run is already in {mode} mode")
run.data.setdefault("mode_history", []).append({"from": run.data["mode"], "to": mode, "at": _now(), "who": run.data["operator"]})
run.data["mode"] = mode
if mode == "auto-confirm":
run.data["auto_confirm"].pop("stopped", None)
run.save()
def overview(run: Run) -> dict:
"""Progress per stage, datapoint states, deviations: the state of the run at a glance."""
fl = flow(run)
stages = []
def walk(items: list[dict], depth: int) -> None:
for st in items:
steps = fl.members(st["id"], include_gates=False)
gates = [g for g in fl.members(st["id"]) if fl.kind(g) == "gate"]
sstates = [run.data["steps"][s]["state"] for s in steps]
stages.append({
"id": st["id"], "title": st["title"], "goal": st["goal"], "depth": depth,
"steps_total": len(steps), "steps_done": sum(x in ("done", "skipped") for x in sstates),
"steps_pushed": sstates.count("pushed"), "steps_failed": sstates.count("failed"),
"steps_running": sstates.count("running"),
"gates": [{"id": g, "title": fl.gates[g]["title"], "state": run.data["gates"][g]["state"]} for g in gates],
"complete": is_complete(run, st["id"]),
"unmet": [] if is_complete(run, st["id"]) else unmet(run, st["id"]),
})
walk(st.get("stages", []), depth + 1)
walk(fl.data["stages"], 0)
dps: dict[str, int] = {}
for v in run.data["datapoints"].values():
dps[v["state"]] = dps.get(v["state"], 0) + 1
items = [i for i in fl.order]
done = sum(1 for i in items if is_complete(run, i) and not (fl.kind(i) == "step" and run.data["steps"][i]["state"] == "pushed")
and not (fl.kind(i) == "gate" and run.data["gates"][i]["state"] == "pushed"))
pushed = sum(1 for i in items if (fl.kind(i) == "step" and run.data["steps"][i]["state"] == "pushed")
or (fl.kind(i) == "gate" and run.data["gates"][i]["state"] == "pushed"))
return {"stages": stages, "datapoints": dps, "deviations": list(run.data["deviations"]),
"progress": {"done": done, "pushed": pushed, "total": len(items)}, "mode": run.data["mode"],
"mode_history": run.data.get("mode_history", []), "stopped": run.data["auto_confirm"].get("stopped")}
def dimension_keys(run: Run) -> list[str]:
keys: set[str] = set()
for d in flow(run).datapoints.values():
keys |= set((d.get("dimensions") or {}).keys())
return sorted(keys)
def datapoint_rows(run: Run, group: str | None = None, flt: str | None = None) -> dict[str, list[dict]]:
"""Datapoints with their state, optionally filtered by 'dimension:value' and grouped by a dimension.
Returns {group label: [rows]}; without grouping there is one group, ''.
"""
fl = flow(run)
fkey, _, fval = (flt or "").partition(":")
groups: dict[str, list[dict]] = {}
for d in fl.datapoints.values():
dims = d.get("dimensions") or {}
if flt and dims.get(fkey) != fval:
continue
rec = run.data["datapoints"][d["id"]]
row = {"id": d["id"], "label": d["label"], "type": d["type"], "values": d.get("values"), "state": rec["state"],
"value": rec.get("value"), "how": rec.get("how"), "error": rec.get("error"), "dimensions": dims,
"ask_in": d.get("ask_in"), "produced_by": d.get("produced_by"), "confirmed_by": d.get("confirmed_by"),
"editable": not d.get("produced_by")}
groups.setdefault(dims.get(group, "(none)") if group else "", []).append(row)
return dict(sorted(groups.items()))
def artefact_rows(run: Run) -> list[dict]:
"""What the run produced or will produce, and where it lives (a reference, never a secret)."""
fl = flow(run)
out = []
for a in run.pack.data.get("artefacts", []):
dp = run.data["datapoints"].get(a["location"]["datapoint"], {})
base = dp.get("value") if dp.get("state") in ("set", "ok") else None
loc = (base.rstrip("/") + "/" + a["location"]["path"]) if base and a["location"].get("path") else base
out.append({"id": a["id"], "title": a["title"], "location": loc, "produced_by": a["produced_by"],
"produced_state": run.data["steps"][a["produced_by"]]["state"] if a["produced_by"] in run.data["steps"] else "?",
"lives_until": a.get("lives_until")})
return out