"""Nova Per-Run Manifest Writer (REQ-187). Emits nova.run.started, nova.run.completed, nova.run.failed events with (run_id, contractId, env, stages x durations, exit, confidence, HITL block count). Writes metrics/runs/.json. scripts/run_platform.sh invokes the writer at run start + run end. D-120: Nova-native (JSONL events + JSON manifest file, no Kafka). D-128: metrics/ at repo root. """ import datetime import json import os import sys import time import uuid _METRICS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "metrics") _RUNS_DIR = os.path.join(_METRICS_DIR, "runs") sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) from core.metrics.event_envelope import emit, make_event, append_event def _iso8601_now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def _run_id(): return f"run-{int(time.time())}-{uuid.uuid4().hex[:8]}" def start_run(contract_id, environment, stages=None): """Emit nova.run.started + return the run_id.""" run_id = _run_id() data = { "contract_id": contract_id, "environment": environment, "started_at": _iso8601_now(), "stages": stages or [], } emit("nova.run.started", run_id, environment, data, contract_id=contract_id) return run_id def complete_run(run_id, contract_id, environment, stages, exit_code, confidence=None, hitl=None, policy=None, cost_estimate_usd=None, decision_id=None): """Emit nova.run.completed + write the per-run manifest JSON. Args: run_id: the run identifier from start_run() contract_id: the contract UUID environment: dev|qa|prod|dr stages: list of {name, duration_ms, exit_code, error?} exit_code: the overall run exit code confidence: optional {score, band, perInput} hitl: optional {gate, result, block} policy: optional {passed, failed, skipped} cost_estimate_usd: optional float decision_id: optional string (links to the Decision Ledger) """ started_at = stages[0].get("started_at", _iso8601_now()) if stages else _iso8601_now() completed_at = _iso8601_now() outcome = "succeeded" if exit_code == 0 else "failed" manifest = { "run_id": run_id, "contract_id": contract_id, "environment": environment, "started_at": started_at, "completed_at": completed_at, "exit_code": exit_code, "stages": stages, "outcome": outcome, } if confidence: manifest["confidence"] = confidence if hitl: manifest["hitl"] = hitl if policy: manifest["policy"] = policy if cost_estimate_usd is not None: manifest["cost_estimate_usd"] = cost_estimate_usd if decision_id: manifest["decision_id"] = decision_id os.makedirs(_RUNS_DIR, exist_ok=True) manifest_path = os.path.join(_RUNS_DIR, f"{run_id}.json") with open(manifest_path, "w", encoding="utf-8") as fh: json.dump(manifest, fh, indent=2, sort_keys=True) event_type = "nova.run.completed" if exit_code == 0 else "nova.run.failed" emit(event_type, run_id, environment, manifest, contract_id=contract_id) return manifest def persist_run_artifacts(run_id, work_dir): """Copy ephemeral $WORK/*.json to metrics/runs// as durable artifacts. Args: run_id: the run identifier work_dir: the $WORK directory (e.g. /tmp/nova_platform_run) """ if not work_dir or not os.path.isdir(work_dir): return [] dest = os.path.join(_RUNS_DIR, run_id) os.makedirs(dest, exist_ok=True) copied = [] for fname in ("pcr.json", "signal.json", "event.json", "outbox_item.json", "stack.json", "checkov.json"): src = os.path.join(work_dir, fname) if os.path.isfile(src): import shutil shutil.copy2(src, os.path.join(dest, fname)) copied.append(fname) return copied if __name__ == "__main__": if len(sys.argv) < 4: print("usage: run_manifest.py [run_id] [work_dir]", file=sys.stderr) sys.exit(2) action = sys.argv[1] cid = sys.argv[2] env = sys.argv[3] if action == "start": rid = start_run(cid, env) print(rid) elif action == "complete": rid = sys.argv[4] if len(sys.argv) >= 5 else _run_id() m = complete_run(rid, cid, env, [], 0) print(json.dumps(m, indent=2)) elif action == "persist": rid = sys.argv[4] if len(sys.argv) >= 5 else "" wd = sys.argv[5] if len(sys.argv) >= 6 else "" copied = persist_run_artifacts(rid, wd) print(json.dumps({"copied": copied}))