Active user attribute set, latest activities, guarded changes to used values, artefact links, durations and averages; irreversible showcase step
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
6716cee921
commit
8d62c1fbe8
22 changed files with 1025 additions and 35 deletions
|
|
@ -11,7 +11,7 @@ from __future__ import annotations
|
|||
import html
|
||||
from urllib.parse import quote
|
||||
|
||||
from . import run as R1, run2 as R
|
||||
from . import artefacts as A, run as R1, run2 as R
|
||||
from .flow import Flow
|
||||
|
||||
DONE = ("done", "skipped", "pushed")
|
||||
|
|
@ -55,7 +55,7 @@ def start_form(app, i: int, pack) -> str:
|
|||
"<label><input type=radio name=mode value=manual checked> <b>Manual</b>: you confirm every check and gate.</label><br>"
|
||||
"<label><input type=radio name=mode value=auto-confirm> <b>Auto-confirm</b>: the engine confirms when preconditions hold. "
|
||||
"It does not run commands; you (or another tool) do the work.</label></fieldset>"
|
||||
f"{pre}<p><label>Your name (one word)<br><input name=operator required></label></p><button>Start run</button></form>")
|
||||
f"{pre}{app.who()}<button>Start run</button></form>")
|
||||
|
||||
|
||||
def create_run(app, i: int, form: dict):
|
||||
|
|
@ -102,6 +102,7 @@ def run_page(app, i: int, rid: str, msg: str = "", query: dict | None = None) ->
|
|||
out = [f"<h1>{e(pack.data['title'])}</h1>"
|
||||
f"<p>{sp(d['mode'])} {sp(d['outcome']) if d.get('outcome') else ''} "
|
||||
f"<span class=note>run {e(rid)} · operator {e(d['operator'])}{where}</span></p>"
|
||||
f"<p class=note>{times_line(run)}</p>"
|
||||
f"<p class=eyebrow>{prog['done']} of {prog['total']} done"
|
||||
f"{' · ' + str(prog['pushed']) + ' pushed through' if prog['pushed'] else ''}</p>"
|
||||
f"<progress max={prog['total']} value={prog['done'] + prog['pushed']} style='width:100%'></progress>"]
|
||||
|
|
@ -122,6 +123,20 @@ def run_page(app, i: int, rid: str, msg: str = "", query: dict | None = None) ->
|
|||
return page(pack.data["title"], "".join(out))
|
||||
|
||||
|
||||
def stamp(iso: str | None) -> str:
|
||||
return iso[:16].replace("T", " ") + " UTC" if iso else "-"
|
||||
|
||||
|
||||
def times_line(run) -> str:
|
||||
"""Started, finished and how long: the run's timestamps at a glance."""
|
||||
t = R1.run_times(run)
|
||||
if t["finished_run"]:
|
||||
return (f"Started {e(stamp(t['started']))} · finished {e(stamp(t['finished']))} · took <b>{e(R1.fmt_duration(t['elapsed']))}</b>"
|
||||
f"{' · working ' + e(R1.fmt_duration(t['working'])) if t['working'] else ''}")
|
||||
return (f"Started {e(stamp(t['started']))} · running for <b>{e(R1.fmt_duration(t['elapsed']))}</b>"
|
||||
f"{' · working ' + e(R1.fmt_duration(t['working'])) if t['working'] else ''}")
|
||||
|
||||
|
||||
# ---- flow view ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
def _flow_view(c: Ctx, ov: dict) -> str:
|
||||
|
|
@ -222,15 +237,31 @@ def _questions(c: Ctx, st: dict) -> str:
|
|||
if not dps or c.finished:
|
||||
return ""
|
||||
rows = []
|
||||
locked = False
|
||||
for d in dps:
|
||||
rec = c.run.data["datapoints"][d["id"]]
|
||||
rows.append(f"<p>{_input(d, rec)}<br><span class=note>{e(d.get('help', ''))}</span></p>")
|
||||
users = R.datapoint_users(c.run, d["id"])
|
||||
locked = locked or bool(users and rec.get("value") is not None)
|
||||
rows.append(f"<p>{_input(d, rec, users=users)}<br><span class=note>{e(d.get('help', ''))}</span></p>")
|
||||
return (f"<div class=questions><b>Questions for this stage</b> <span class=note>(values used by its steps; never enter secrets)</span>"
|
||||
f"<form method=post action='{c.base}/data'>{c.h()}<input type=hidden name=next value='{c.base}#stage-{e(st['id'])}'>"
|
||||
f"{''.join(rows)}<button>Save answers</button></form></div>")
|
||||
f"{''.join(rows)}{_override_fields(c, locked)}<button>Save answers</button></form></div>")
|
||||
|
||||
|
||||
def _input(d: dict, rec: dict, editable: bool = True) -> str:
|
||||
def _override_fields(c: Ctx, locked: bool) -> str:
|
||||
"""Only shown when some value is already in use by started steps: changing it is a recorded deviation."""
|
||||
if not locked:
|
||||
return ""
|
||||
if not c.manual:
|
||||
return "<p class=note>Values already in use by steps can only be changed in manual mode: take over first.</p>"
|
||||
return ("<details class=override><summary>Changing a value that steps already used (override)</summary>"
|
||||
"<p class=bad>Risk: steps that already ran used the previous value, so their results may no longer match the new one. "
|
||||
"The change is recorded as a deviation.</p>"
|
||||
"<p><input name=override_reason class=grow style='width:100%' placeholder='why you change it now (required for values in use)'></p>"
|
||||
"<p><label><input type=checkbox name=acknowledge_risk value=1> I acknowledge the risk.</label></p></details>")
|
||||
|
||||
|
||||
def _input(d: dict, rec: dict, editable: bool = True, users: list[str] | None = None) -> str:
|
||||
name = f"d_{d['id']}"
|
||||
val = rec.get("value", "")
|
||||
dis = "" if editable else " disabled"
|
||||
|
|
@ -242,7 +273,9 @@ def _input(d: dict, rec: dict, editable: bool = True) -> str:
|
|||
else:
|
||||
ctl = f"<input name='{name}' value='{e(val)}'{dis}>"
|
||||
err = f" <span class=bad>{e(rec.get('error', ''))}</span>" if rec["state"] == "err" else ""
|
||||
return f"<label>{e(d['label'])} {sp(rec['state'])}<br>{ctl}</label>{err}"
|
||||
use = (f" <span class=chip title='changing it is a recorded deviation'>in use by {e(', '.join(users))}</span>"
|
||||
if users and rec.get("value") is not None else "")
|
||||
return f"<label>{e(d['label'])} {sp(rec['state'])}{use}<br>{ctl}</label>{err}"
|
||||
|
||||
|
||||
# ---- steps ------------------------------------------------------------------------------------------------------------------
|
||||
|
|
@ -262,6 +295,9 @@ def _step(c: Ctx, s: dict) -> str:
|
|||
marks.append("<span class=chip data-s='irreversible'>irreversible</span>")
|
||||
if s.get("auto_confirm") == "manual-only":
|
||||
marks.append("<span class=chip>manual-only</span>")
|
||||
took = R1.run_times(run)["step_seconds"].get(sid)
|
||||
if took is not None and state in DONE:
|
||||
marks.append(f"<span class=note>took {e(R1.fmt_duration(took))}</span>")
|
||||
title = eff.get("title", s["title"]) if st.get("alternative") else s["title"]
|
||||
actor = fl.actors.get(s["actor"], {}).get("role", s["actor"])
|
||||
body = []
|
||||
|
|
@ -461,6 +497,8 @@ def _data_view(c: Ctx) -> str:
|
|||
groups = R.datapoint_rows(run, group=group, flt=flt)
|
||||
out = [f"<p class=note>Every datapoint of this run, independent of the flow. Tags (dimensions) come from the pack; click one to filter.</p>{ctl}"]
|
||||
editable = not c.finished
|
||||
any_locked = any(R.datapoint_users(run, x["id"]) and x["value"] is not None and x["editable"]
|
||||
for rows_ in groups.values() for x in rows_)
|
||||
out.append(f"<form method=post action='{c.base}/data'>{c.h()}<input type=hidden name=next value='{c.base}?view=data"
|
||||
f"{'&group=' + quote(group) if group else ''}{'&filter=' + quote(flt) if flt else ''}'>")
|
||||
for gname, rows in groups.items():
|
||||
|
|
@ -473,19 +511,20 @@ def _data_view(c: Ctx) -> str:
|
|||
dims = " ".join(link(group, f"{k}:{v}", f"{k}: {v}") for k, v in r["dimensions"].items())
|
||||
src = " · ".join(x for x in (f"asked in {e(r['ask_in'])}" if r["ask_in"] else "", f"produced by {e(r['produced_by'])}" if r["produced_by"] else "",
|
||||
f"confirmed by {e(r['confirmed_by'])}" if r["confirmed_by"] else "", f"how: {e(r['how'])}" if r["how"] else "") if x)
|
||||
ctl = _input(d, rec, editable and r["editable"]) if (editable and r["editable"]) else (
|
||||
ctl = _input(d, rec, editable and r["editable"], R.datapoint_users(run, r["id"])) if (editable and r["editable"]) else (
|
||||
f"{e(r['label'])} {sp(r['state'])}<br><code>{e(r['value'] if r['value'] is not None else '')}</code>")
|
||||
trs += f"<tr><td>{ctl}</td><td>{dims}</td><td class=note>{src}</td></tr>"
|
||||
out.append(f"<table class=data><tr><th>Datapoint</th><th>Dimensions</th><th>Provenance</th></tr>{trs}</table>")
|
||||
if not groups:
|
||||
out.append("<p class=note>No datapoints match.</p>")
|
||||
if editable:
|
||||
out.append("<button>Save changed values</button>")
|
||||
out.append(_override_fields(c, any_locked) + "<button>Save changed values</button>")
|
||||
out.append("</form>")
|
||||
arts = R.artefact_rows(run)
|
||||
if arts:
|
||||
out.append("<h2>Artefacts</h2><table><tr><th>Artefact</th><th>Where it lives</th><th>Produced by</th><th>Until</th></tr>" + "".join(
|
||||
out.append("<h2>Artefacts</h2><table><tr><th>Artefact</th><th>Where it lives</th><th>Open</th><th>Produced by</th><th>Until</th></tr>" + "".join(
|
||||
f"<tr><td>{e(a['title'])}</td><td><code>{e(a['location'] or '(not known yet)')}</code></td>"
|
||||
f"<td>{_artefact_links(c, a)}</td>"
|
||||
f"<td>{e(a['produced_by'])} {sp(a['produced_state'])}</td><td>{e(a['lives_until'] or '-')}</td></tr>" for a in arts) + "</table>")
|
||||
if fl.actors:
|
||||
counts: dict[str, int] = {}
|
||||
|
|
@ -497,6 +536,32 @@ def _data_view(c: Ctx) -> str:
|
|||
return "".join(out)
|
||||
|
||||
|
||||
def _artefact_links(c: Ctx, a: dict) -> str:
|
||||
loc = a["location"]
|
||||
if loc and A.is_url(loc):
|
||||
return f"<a href='{e(loc)}' target=_blank rel='noopener noreferrer'>Open link</a>"
|
||||
path, why = A.check(loc)
|
||||
if path is None:
|
||||
return f"<span class=note>{e(why)}</span>"
|
||||
url = f"{c.base}/artefact/{e(a['id'])}"
|
||||
return f"<a href='{url}' target=_blank rel=noopener>Open</a> · <a href='{url}?download=1'>Download</a>"
|
||||
|
||||
|
||||
def serve_artefact(app, i: int, rid: str, aid: str) -> tuple[int, bytes, str, str | None]:
|
||||
"""(status, body, content type, download filename or None). Refusals come back as short plain text."""
|
||||
run = R1.load(app.packs[i], rid)
|
||||
if run.pack.spec != "runbook-pack/v0.2":
|
||||
return 404, b"no artefacts", "text/plain; charset=utf-8", None
|
||||
row = next((a for a in R.artefact_rows(run) if a["id"] == aid), None)
|
||||
if row is None:
|
||||
return 404, b"no such artefact", "text/plain; charset=utf-8", None
|
||||
path, why = A.check(row["location"])
|
||||
if path is None:
|
||||
return 403, f"not served: {why}".encode(), "text/plain; charset=utf-8", None
|
||||
body, ctype, is_text = A.content(path)
|
||||
return 200, body, ctype, (None if is_text else path.name)
|
||||
|
||||
|
||||
# ---- overview ------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def _overview_view(c: Ctx, ov: dict) -> str:
|
||||
|
|
@ -590,6 +655,7 @@ def handle_post(app, i: int, rid: str, parts: list[str], form: dict) -> str:
|
|||
return f"{base}#current"
|
||||
if parts == ["data"]:
|
||||
bad = []
|
||||
changes = {}
|
||||
for k, v in form.items():
|
||||
if not k.startswith("d_"):
|
||||
continue
|
||||
|
|
@ -597,7 +663,14 @@ def handle_post(app, i: int, rid: str, parts: list[str], form: dict) -> str:
|
|||
cur = run.data["datapoints"].get(dp)
|
||||
if cur is None or v == "" or (cur["state"] in ("set", "ok") and cur.get("value") == v):
|
||||
continue # unchanged or left empty
|
||||
if R.set_datapoint(run, dp, v) == "err":
|
||||
changes[dp] = v
|
||||
need = R.changes_needing_override(run, changes) # all or nothing: check before applying any
|
||||
reason, ack = form.get("override_reason", ""), bool(form.get("acknowledge_risk"))
|
||||
if need and not (reason.strip() and ack):
|
||||
raise R1.RunError("already in use by started steps: " + "; ".join(f"{d} ({', '.join(u)})" for d, u in need.items())
|
||||
+ ". To change them give a reason and acknowledge the risk; each change is recorded as a deviation.")
|
||||
for dp, v in changes.items():
|
||||
if R.set_datapoint(run, dp, v, override_reason=reason, acknowledge_risk=ack) == "err":
|
||||
bad.append(f"{dp} ({run.data['datapoints'][dp]['error']})")
|
||||
if bad:
|
||||
raise ValueError("invalid value for: " + ", ".join(bad))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue