f8616b806e
P1 (Wave 1, feat) — REQ-187, REQ-188, REQ-205 (emitter), REQ-206 (emitter) New components: - core/metrics/event_envelope.py — CloudEvents 1.0 envelope + platform.* conventions - core/metrics/run_manifest.py — per-run manifest writer (nova.run.started/completed/failed) - core/metrics/decision_ledger.py — SQLite append-only hash-chain (ai.decision.made + attestation.recorded) - core/metrics/infracost_adapter.py — Infracost post-processor (degraded mode when CLI absent, A6) - schemas/metrics_event.schema.json — CloudEvents envelope schema - schemas/metrics_run_manifest.schema.json — per-run manifest schema - metrics/README.md — backup/restore doc (REQ-201) - tests/test_metrics_emitters.py — 16 tests (all pass) Modified components: - core/confidence_signal.py — emits nova.confidence.computed + nova.ai.decision.made (D-122) - core/hitl_gates.py — emits nova.attestation.recorded on qa/prod/dr gates (D-132) - adapters/terraform/policy/checkov_adapter.py — emits nova.policy.evaluated - pyproject.toml — addopts gains --junitxml + --json-report + --cov (REQ-206) - .gitignore — metrics runtime artifacts ignored D-120: Nova-native (JSONL + SQLite, no Kafka/OTel) D-121: Decision Ledger = outbox_writer extension → SQLite hash-chain D-122: AI decision = confidence_signal + HITL gate (not LLM) D-128: metrics/ at repo root D-132: Attestation instrumentation ---ci--- project: acdl phase: 1 milestone: v1.17 status: execute ---/ci---
137 lines
4.7 KiB
Python
137 lines
4.7 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 _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/<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})) |