51b886f3f6
REQ-317: core/metrics/outcome_backfill.py backfills fact_decision.outcome pending -> succeeded/failed after run.completed/run.failed; idempotent + terminal (does not overwrite a non-pending outcome); wired into the collector. The Post-Pilot AI Decision Accuracy denominator is now grounded (fact_decision.outcome is not stuck pending). REQ-318: ai.decision.made on a block band carries escalation_reason: 'confidence' (the only value in v1.26 — a block is always confidence- driven; future milestones may add 'policy'). Persisted into fact_run by the collector. The Post-Pilot Human Escalation Frequency denominator is now grounded. ---ci--- project: acdl phase: 3 milestone: v1.26 status: execute wave: W2 ---
172 lines
6.4 KiB
Python
172 lines
6.4 KiB
Python
"""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/<run_id>.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 _backfill_outcome(decision_id, outcome):
|
|
"""Transition fact_decision.outcome pending -> outcome (REQ-317).
|
|
|
|
Best-effort: logs a warning and skips if decision_id is missing or the
|
|
backfill raises. Never raises — the run is already completing/failing
|
|
and the manifest write is the source of truth for the run outcome.
|
|
"""
|
|
if not decision_id:
|
|
# A run that failed before ai.decision.made was emitted has no
|
|
# decision to backfill (e.g. a schema-validation failure). Skip
|
|
# silently rather than pollute stderr on every clean run.
|
|
return None
|
|
try:
|
|
from core.metrics import outcome_backfill
|
|
return outcome_backfill.backfill(decision_id, outcome)
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
print(f"[run_manifest] outcome backfill skipped for {decision_id}: {exc}",
|
|
file=sys.stderr)
|
|
return None
|
|
|
|
|
|
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, escalation_reason=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)
|
|
escalation_reason: optional string (REQ-318) — "confidence" when
|
|
the ai.decision.made band was block; absent/None otherwise.
|
|
Persisted into the manifest so the collector can write it
|
|
into fact_run (Post-Pilot Human Escalation Frequency denom).
|
|
"""
|
|
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
|
|
if escalation_reason:
|
|
manifest["escalation_reason"] = escalation_reason
|
|
|
|
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)
|
|
|
|
# REQ-317: backfill fact_decision.outcome pending -> succeeded/failed
|
|
# after the run completes. The decision_id links the run to the
|
|
# Decision Ledger entry written by ai.decision.made. Best-effort: a
|
|
# run that failed before ai.decision.made was emitted has no
|
|
# decision_id and the backfill is a no-op (the run outcome is still
|
|
# captured in the manifest above).
|
|
backfill_result = _backfill_outcome(decision_id, outcome)
|
|
|
|
return manifest
|
|
|
|
|
|
def persist_run_artifacts(run_id, work_dir):
|
|
"""Copy ephemeral $WORK/*.json to metrics/runs/<run_id>/ 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 <start|complete|persist> <contract_id> <environment> [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})) |