From e87f7fdd5d8f771a266b7bc8790a71bc37137a86 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 7 May 2026 16:01:33 +0200 Subject: [PATCH] retention and run history --- README.md | 1 + docs/ARCHITECTURE-BLUEPRINT.md | 1 + src/guide_board/cli.py | 15 ++++++ src/guide_board/execution.py | 7 +++ src/guide_board/retention.py | 99 ++++++++++++++++++++++++++++++++++ tests/test_core.py | 19 +++++++ 6 files changed, 142 insertions(+) create mode 100644 src/guide_board/retention.py diff --git a/README.md b/README.md index 019e65f..d302487 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docs/ARCHITECTURE-BLUEPRINT.md b/docs/ARCHITECTURE-BLUEPRINT.md index 88bc9fe..0e516f1 100644 --- a/docs/ARCHITECTURE-BLUEPRINT.md +++ b/docs/ARCHITECTURE-BLUEPRINT.md @@ -691,6 +691,7 @@ Each run should be reproducible from captured metadata where possible. ```text runs// run.json + retention-summary.json plan.json sources.lock.json target-profile.snapshot.json diff --git a/src/guide_board/cli.py b/src/guide_board/cli.py index d22baf1..62de7ae 100644 --- a/src/guide_board/cli.py +++ b/src/guide_board/cli.py @@ -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) diff --git a/src/guide_board/execution.py b/src/guide_board/execution.py index 0462b72..87e5620 100644 --- a/src/guide_board/execution.py +++ b/src/guide_board/execution.py @@ -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"]) diff --git a/src/guide_board/retention.py b/src/guide_board/retention.py new file mode 100644 index 0000000..729c5f1 --- /dev/null +++ b/src/guide_board/retention.py @@ -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": {}, + } diff --git a/tests/test_core.py b/tests/test_core.py index af4cd8b..90db5fe 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -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"]