Evidence export of closed runs and rtut seal (RBT-WP-0009-T04)
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
e021fd979f
commit
6d70f4d97d
12 changed files with 149 additions and 19 deletions
|
|
@ -174,6 +174,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||
sp = sub.add_parser("ui", help="serve the local UI on 127.0.0.1")
|
||||
sp.add_argument("roots", nargs="*", default=["."])
|
||||
sp.add_argument("--port", type=int, default=8877)
|
||||
sp = sub.add_parser("seal", help="check the seal of an exported run or project-run record (JSON file)")
|
||||
sp.add_argument("record")
|
||||
sp = sub.add_parser("check", help="validate every pack, attribute set, project and template under a repository")
|
||||
sp.add_argument("root", nargs="?", default=".")
|
||||
sp = sub.add_parser("maturity", help="derived maturity of a pack at its current revision")
|
||||
|
|
@ -213,6 +215,15 @@ def main(argv: list[str] | None = None) -> int:
|
|||
except KeyboardInterrupt:
|
||||
pass
|
||||
return 0
|
||||
if a.cmd == "seal":
|
||||
import json
|
||||
from . import run as R
|
||||
try:
|
||||
state = R.record_seal_state(json.loads(Path(a.record).read_text()))
|
||||
except (OSError, ValueError) as e:
|
||||
print(f"error: {e}", file=sys.stderr); return 1
|
||||
print(state)
|
||||
return {"sealed": 0, "unsealed": 2, "open": 2, "broken": 1}[state]
|
||||
if a.cmd == "check":
|
||||
return _check(a.root)
|
||||
if a.cmd == "project":
|
||||
|
|
|
|||
|
|
@ -321,10 +321,7 @@ def finalize(reg: Registry, pr: ProjectRun, outcome: str, note: str = "") -> Non
|
|||
|
||||
|
||||
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"
|
||||
return R.record_seal_state(_read_json(pr.path) or pr.data)
|
||||
|
||||
|
||||
def times(pr: ProjectRun, now: datetime | None = None) -> dict:
|
||||
|
|
|
|||
17
rtut/run.py
17
rtut/run.py
|
|
@ -374,16 +374,21 @@ def _read_json(path) -> dict | None:
|
|||
return None
|
||||
|
||||
|
||||
def record_seal_state(data: dict) -> str:
|
||||
"""'open' | 'sealed' | 'broken' | 'unsealed' for a run or project-run record, judged from its content alone,
|
||||
so an exported record can be checked by anyone (`rtut seal <file>`)."""
|
||||
if not data.get("outcome"):
|
||||
return "open"
|
||||
if not data.get("seal"):
|
||||
return "unsealed"
|
||||
return "sealed" if data["seal"] == _seal_of(data) else "broken"
|
||||
|
||||
|
||||
def seal_state(run: Run) -> str:
|
||||
"""'open' | 'sealed' | 'broken' (changed after closing, outside the app) | 'unsealed' (closed before sealing existed).
|
||||
|
||||
Judged on what is on disk, not on the in-memory copy."""
|
||||
disk = _read_json(run.path) or run.data
|
||||
if not disk.get("outcome"):
|
||||
return "open"
|
||||
if not disk.get("seal"):
|
||||
return "unsealed"
|
||||
return "sealed" if disk["seal"] == _seal_of(disk) else "broken"
|
||||
return record_seal_state(_read_json(run.path) or run.data)
|
||||
|
||||
|
||||
def finalize(run: Run, outcome: str, note: str = "", rating: str | None = None, comment: str = "") -> None:
|
||||
|
|
|
|||
23
rtut/ui.py
23
rtut/ui.py
|
|
@ -301,7 +301,9 @@ class App:
|
|||
outcome = r.data.get("outcome")
|
||||
if outcome:
|
||||
note = f" - {e(r.data['outcome_note'])}" if r.data.get("outcome_note") else ""
|
||||
box = f"<p><b>Run finished: {e(outcome)}</b>{note}</p>{self.locked_note(r)}"
|
||||
box = (f"<p><b>Run finished: {e(outcome)}</b>{note}</p>{self.locked_note(r)}"
|
||||
f"<p><a href='/run/{i}/{e(rid)}/record.json'>Download the run record (JSON)</a> "
|
||||
"<span class=note>Check it anywhere with <code>rtut seal <file></code>.</span></p>")
|
||||
return f"<div id=close class=step><h2>Close this run</h2>{box}</div>"
|
||||
states = [v["state"] for v in r.data["steps"].values()]
|
||||
btns = []
|
||||
|
|
@ -480,6 +482,21 @@ def make_handler(app: App, port_holder: list):
|
|||
self.send_header("Content-Disposition", f"attachment; filename=\"{safe}\"")
|
||||
self.end_headers(); self.wfile.write(body)
|
||||
|
||||
def _send_record(self, path, filename: str):
|
||||
"""A closed record, exactly as stored, as a download. Its seal can be checked with `rtut seal <file>`."""
|
||||
import json
|
||||
data = json.loads(path.read_text())
|
||||
if not data.get("outcome"):
|
||||
return self._send_file(409, b"the record can be downloaded once the run is closed", "text/plain; charset=utf-8", None)
|
||||
state = R.record_seal_state(data)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Rtut-Seal", state)
|
||||
self.send_header("Content-Security-Policy", "default-src 'none'; sandbox")
|
||||
self.send_header("Content-Disposition", f"attachment; filename=\"{filename}\"")
|
||||
self.end_headers(); self.wfile.write(path.read_bytes())
|
||||
|
||||
def _host_ok(self) -> bool:
|
||||
return (self.headers.get("Host", "").split(":")[0] in ("127.0.0.1", "localhost"))
|
||||
|
||||
|
|
@ -506,6 +523,10 @@ def make_handler(app: App, port_holder: list):
|
|||
body = UA.set_page(app, parts[1])
|
||||
if body:
|
||||
return self._send(body)
|
||||
if parts[0] == "run" and len(parts) == 4 and parts[3] == "record.json":
|
||||
return self._send_record(R.load(app.packs[int(parts[1])], parts[2]).path, f"run-{parts[2]}.json")
|
||||
if parts[0] == "project-run" and len(parts) == 3 and parts[2] == "record.json":
|
||||
return self._send_record(PRUN.load(app.registry(), parts[1]).path, f"project-run-{parts[1]}.json")
|
||||
if parts[0] == "run" and len(parts) == 5 and parts[3] == "artefact":
|
||||
status, body, ctype, dl = UI2.serve_artefact(app, int(parts[1]), parts[2], parts[4])
|
||||
want_dl = bool(parse_qs(urlparse(self.path).query).get("download"))
|
||||
|
|
|
|||
|
|
@ -162,7 +162,9 @@ def _close(app, reg, pr, v: dict) -> str:
|
|||
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>"
|
||||
return (f"<div id=close class=step><h2>Project run finished: {e(d['outcome'])}</h2>{note}{broken}"
|
||||
f"<p><a href='/project-run/{e(pr.id)}/record.json'>Download the project run record (JSON)</a> "
|
||||
"<span class=note>Check it anywhere with <code>rtut seal <file></code>.</span></p></div>")
|
||||
btns = []
|
||||
if all(r["status"] == "succeeded" for r in v["members"]):
|
||||
btns.append("<button name=outcome value=succeeded>Finish: succeeded</button>")
|
||||
|
|
|
|||
|
|
@ -605,7 +605,9 @@ def _close(c: Ctx) -> str:
|
|||
if c.finished:
|
||||
note = f"<p>Comment: {e(run.data['outcome_note'])}</p>" if run.data.get("outcome_note") else ""
|
||||
return (f"<div id=close class=step><h2>Run finished: {e(run.data['outcome'])}</h2>{note}{c.app.locked_note(run)}"
|
||||
"<p class=note>The outcome is part of this runbook's score and maturity evidence at this revision.</p></div>")
|
||||
"<p class=note>The outcome is part of this runbook's score and maturity evidence at this revision.</p>"
|
||||
f"<p><a href='{c.base}/record.json'>Download the run record (JSON)</a> "
|
||||
"<span class=note>Check it anywhere with <code>rtut seal <file></code>.</span></p></div>")
|
||||
fl = c.fl
|
||||
steps = [run.data["steps"][s]["state"] for s in fl.steps]
|
||||
gates = [run.data["gates"][g]["state"] for g in fl.gates]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue