retention and run history

This commit is contained in:
2026-05-07 16:01:33 +02:00
parent 18299b03aa
commit e87f7fdd5d
6 changed files with 142 additions and 0 deletions

View File

@@ -22,6 +22,7 @@ PYTHONPATH=src python3 -m guide_board plan \
PYTHONPATH=src python3 -m guide_board run \
--target profiles/targets/sample-repository.json \
--assessment profiles/assessments/sample-noop.json
PYTHONPATH=src python3 -m guide_board runs list
PYTHONPATH=src python3 -m unittest discover -s tests
```

View File

@@ -691,6 +691,7 @@ Each run should be reproducible from captured metadata where possible.
```text
runs/<run-id>/
run.json
retention-summary.json
plan.json
sources.lock.json
target-profile.snapshot.json

View File

@@ -17,6 +17,7 @@ from guide_board.planning import (
validate_assessment_profile,
validate_target_profile,
)
from guide_board.retention import list_retained_runs
from guide_board.schema import assert_valid
@@ -74,6 +75,12 @@ def build_parser() -> argparse.ArgumentParser:
run.add_argument("--output-dir", type=Path)
run.set_defaults(func=cmd_run)
runs = subcommands.add_parser("runs", help="run history operations")
runs_commands = runs.add_subparsers(required=True)
list_runs = runs_commands.add_parser("list", help="list retained run summaries")
list_runs.add_argument("--runs-dir", type=Path)
list_runs.set_defaults(func=cmd_runs_list)
schema = subcommands.add_parser("schema", help="schema validation")
schema.add_argument("schema_name")
schema.add_argument("path", type=Path)
@@ -128,6 +135,14 @@ def cmd_run(args: argparse.Namespace) -> dict[str, Any]:
return run_assessment(args.root, args.target, args.assessment, args.output_dir)
def cmd_runs_list(args: argparse.Namespace) -> dict[str, Any]:
runs_dir = args.runs_dir or args.root / "runs"
return {
"runs_dir": str(runs_dir),
"runs": list_retained_runs(runs_dir),
}
def cmd_schema_validate(args: argparse.Namespace) -> dict[str, Any]:
document = load_json(args.path)
assert_valid(document, args.schema_name)

View File

@@ -12,6 +12,7 @@ from guide_board.io import write_json
from guide_board.mapping import build_mapping_records, summarize_mappings
from guide_board.planning import build_run_plan
from guide_board.policy import apply_policy
from guide_board.retention import build_retention_summary
from guide_board.runners import run_step
from guide_board.schema import assert_valid
@@ -61,6 +62,8 @@ def run_assessment(
"target_profile_ref": plan["target_profile_snapshot"]["id"],
"assessment_profile_ref": plan["assessment_profile_snapshot"]["id"],
}
retention_summary = build_retention_summary(run_metadata, plan, assessment_package)
assert_valid(retention_summary, "retention-summary")
_write_run_directory(
run_dir,
@@ -70,6 +73,7 @@ def run_assessment(
findings,
mapping_records,
assessment_package,
retention_summary,
)
return {
"status": run_metadata["status"],
@@ -77,6 +81,7 @@ def run_assessment(
"run_dir": str(run_dir),
"assessment_package": str(run_dir / "reports" / "assessment-package.json"),
"report": str(run_dir / "reports" / "report.md"),
"retention_summary": str(run_dir / "retention-summary.json"),
}
@@ -285,8 +290,10 @@ def _write_run_directory(
findings: list[dict[str, Any]],
mapping_records: list[dict[str, Any]],
assessment_package: dict[str, Any],
retention_summary: dict[str, Any],
) -> None:
write_json(run_dir / "run.json", run_metadata)
write_json(run_dir / "retention-summary.json", retention_summary)
write_json(run_dir / "plan.json", plan)
write_json(run_dir / "sources.lock.json", plan["source_lock"])
write_json(run_dir / "target-profile.snapshot.json", plan["target_profile_snapshot"])

View File

@@ -0,0 +1,99 @@
"""Retention summaries and run history helpers."""
from __future__ import annotations
from collections import Counter
from pathlib import Path
from typing import Any
from guide_board.io import load_json
def build_retention_summary(
run_metadata: dict[str, Any],
plan: dict[str, Any],
assessment_package: dict[str, Any],
) -> dict[str, Any]:
artifact_manifest = assessment_package.get("artifact_manifest", [])
retention_class_counts = Counter(
artifact.get("retention_class", "unknown")
for artifact in artifact_manifest
if isinstance(artifact, dict)
)
policy_summary = assessment_package.get("policy_summary", {})
findings = assessment_package.get("findings", [])
return {
"id": f"retention-summary:{run_metadata['id']}",
"run_id": run_metadata["id"],
"target_profile_ref": run_metadata["target_profile_ref"],
"assessment_profile_ref": run_metadata["assessment_profile_ref"],
"created_at": run_metadata["created_at"],
"summary": {
"status": run_metadata["status"],
"evidence_results": assessment_package.get("summary", {}),
"finding_count": len(findings),
"unexpected_findings": policy_summary.get("unexpected_findings", 0),
"expected_findings": sum(1 for finding in findings if finding.get("expected")),
"waived_findings": sum(1 for finding in findings if finding.get("waiver_ref")),
"mapping_target_count": len(
assessment_package.get("mapping_summary", {}).get("targets", [])
),
"artifact_count": len(artifact_manifest),
},
"report_refs": [
"reports/assessment-package.json",
"reports/report.md",
],
"artifact_retention": {
"policy": plan["assessment_profile_snapshot"].get("retention_policy", {}),
"output_artifact_retention": plan["assessment_profile_snapshot"]
.get("output_policy", {})
.get("artifact_retention"),
"retention_class_counts": dict(sorted(retention_class_counts.items())),
"raw_artifact_count": retention_class_counts.get("raw", 0),
},
}
def list_retained_runs(runs_dir: Path) -> list[dict[str, Any]]:
if not runs_dir.exists():
return []
summaries = []
for run_dir in sorted(path for path in runs_dir.iterdir() if path.is_dir()):
try:
summary = _summary_for_run_dir(run_dir)
except OSError:
continue
if summary is not None:
summaries.append(summary)
return sorted(summaries, key=lambda item: item.get("created_at", ""), reverse=True)
def _summary_for_run_dir(run_dir: Path) -> dict[str, Any] | None:
summary_path = run_dir / "retention-summary.json"
if summary_path.exists():
summary = load_json(summary_path)
summary["run_dir"] = str(run_dir)
return summary
metadata_path = run_dir / "run.json"
if not metadata_path.exists():
return None
metadata = load_json(metadata_path)
return {
"id": f"retention-summary:{metadata.get('id', run_dir.name)}",
"run_id": metadata.get("id", run_dir.name),
"run_dir": str(run_dir),
"target_profile_ref": metadata.get("target_profile_ref"),
"assessment_profile_ref": metadata.get("assessment_profile_ref"),
"created_at": metadata.get("created_at"),
"summary": {
"status": metadata.get("status", "unknown"),
},
"report_refs": [],
"artifact_retention": {},
}

View File

@@ -14,6 +14,7 @@ from guide_board.planning import (
validate_assessment_profile,
validate_target_profile,
)
from guide_board.retention import list_retained_runs
ROOT = Path(__file__).resolve().parents[1]
@@ -78,9 +79,27 @@ class CoreArchitectureTests(unittest.TestCase):
run_dir = Path(result["run_dir"])
self.assertEqual(result["status"], "completed")
self.assertTrue((run_dir / "run.json").exists())
self.assertTrue((run_dir / "retention-summary.json").exists())
self.assertTrue((run_dir / "normalized" / "evidence.json").exists())
self.assertTrue((run_dir / "reports" / "assessment-package.json").exists())
self.assertTrue((run_dir / "reports" / "report.md").exists())
retention = json.loads(
(run_dir / "retention-summary.json").read_text(encoding="utf-8")
)
self.assertEqual(
result["retention_summary"],
str(run_dir / "retention-summary.json"),
)
self.assertEqual(retention["summary"]["status"], "completed")
self.assertEqual(retention["summary"]["artifact_count"], 0)
self.assertEqual(
retention["artifact_retention"]["policy"],
{"raw_artifact_days": 0, "summary_days": 365},
)
self.assertEqual(
[run["run_id"] for run in list_retained_runs(Path(temporary_directory))],
[result["run_id"]],
)
mappings = json.loads(
(run_dir / "normalized" / "mappings.json").read_text(encoding="utf-8")
)["mappings"]