@hunter-harness/workflow-harness 0.2.27 → 0.2.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/contracts/workflow-policy.json +34 -2
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +26 -2
- package/harness/bundles/general/claude-code/harness-test/checklist.md +15 -0
- package/harness/bundles/general/claude-code/harness-test/reference.md +28 -4
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +2061 -187
- package/harness/bundles/general/claude-code/scripts/harness_events.py +43 -9
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +491 -7
- package/harness/bundles/general/claude-code/scripts/harness_integration.py +232 -0
- package/harness/bundles/general/claude-code/scripts/harness_phase.py +758 -1
- package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/general/claude-code/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +974 -0
- package/harness/bundles/general/claude-code/scripts/harness_workflow_policy.py +87 -0
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +34 -2
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +26 -2
- package/harness/bundles/general/codebuddy/harness-test/checklist.md +15 -0
- package/harness/bundles/general/codebuddy/harness-test/reference.md +28 -4
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +2061 -187
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +43 -9
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +491 -7
- package/harness/bundles/general/codebuddy/scripts/harness_integration.py +232 -0
- package/harness/bundles/general/codebuddy/scripts/harness_phase.py +758 -1
- package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/general/codebuddy/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +974 -0
- package/harness/bundles/general/codebuddy/scripts/harness_workflow_policy.py +87 -0
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/contracts/workflow-policy.json +34 -2
- package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +26 -2
- package/harness/bundles/general/codex/harness-test/checklist.md +15 -0
- package/harness/bundles/general/codex/harness-test/reference.md +28 -4
- package/harness/bundles/general/codex/scripts/harness_archive.py +2061 -187
- package/harness/bundles/general/codex/scripts/harness_events.py +43 -9
- package/harness/bundles/general/codex/scripts/harness_gate.py +491 -7
- package/harness/bundles/general/codex/scripts/harness_integration.py +232 -0
- package/harness/bundles/general/codex/scripts/harness_phase.py +758 -1
- package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/general/codex/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/codex/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/general/codex/scripts/harness_test_runner.py +974 -0
- package/harness/bundles/general/codex/scripts/harness_workflow_policy.py +87 -0
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/contracts/workflow-policy.json +34 -2
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +26 -2
- package/harness/bundles/general/cursor/harness-test/checklist.md +15 -0
- package/harness/bundles/general/cursor/harness-test/reference.md +28 -4
- package/harness/bundles/general/cursor/scripts/harness_archive.py +2061 -187
- package/harness/bundles/general/cursor/scripts/harness_events.py +43 -9
- package/harness/bundles/general/cursor/scripts/harness_gate.py +491 -7
- package/harness/bundles/general/cursor/scripts/harness_integration.py +232 -0
- package/harness/bundles/general/cursor/scripts/harness_phase.py +758 -1
- package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/general/cursor/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/cursor/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/general/cursor/scripts/harness_test_runner.py +974 -0
- package/harness/bundles/general/cursor/scripts/harness_workflow_policy.py +87 -0
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/contracts/workflow-policy.json +34 -2
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +26 -2
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +2061 -187
- package/harness/bundles/java/claude-code/scripts/harness_events.py +43 -9
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +491 -7
- package/harness/bundles/java/claude-code/scripts/harness_integration.py +232 -0
- package/harness/bundles/java/claude-code/scripts/harness_phase.py +758 -1
- package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/java/claude-code/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +974 -0
- package/harness/bundles/java/claude-code/scripts/harness_workflow_policy.py +87 -0
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +34 -2
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +26 -2
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +2061 -187
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +43 -9
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +491 -7
- package/harness/bundles/java/codebuddy/scripts/harness_integration.py +232 -0
- package/harness/bundles/java/codebuddy/scripts/harness_phase.py +758 -1
- package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/java/codebuddy/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +974 -0
- package/harness/bundles/java/codebuddy/scripts/harness_workflow_policy.py +87 -0
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/contracts/workflow-policy.json +34 -2
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +26 -2
- package/harness/bundles/java/codex/scripts/harness_archive.py +2061 -187
- package/harness/bundles/java/codex/scripts/harness_events.py +43 -9
- package/harness/bundles/java/codex/scripts/harness_gate.py +491 -7
- package/harness/bundles/java/codex/scripts/harness_integration.py +232 -0
- package/harness/bundles/java/codex/scripts/harness_phase.py +758 -1
- package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/java/codex/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/codex/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/java/codex/scripts/harness_test_runner.py +974 -0
- package/harness/bundles/java/codex/scripts/harness_workflow_policy.py +87 -0
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/contracts/workflow-policy.json +34 -2
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +26 -2
- package/harness/bundles/java/cursor/scripts/harness_archive.py +2061 -187
- package/harness/bundles/java/cursor/scripts/harness_events.py +43 -9
- package/harness/bundles/java/cursor/scripts/harness_gate.py +491 -7
- package/harness/bundles/java/cursor/scripts/harness_integration.py +232 -0
- package/harness/bundles/java/cursor/scripts/harness_phase.py +758 -1
- package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/java/cursor/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/cursor/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/java/cursor/scripts/harness_test_runner.py +974 -0
- package/harness/bundles/java/cursor/scripts/harness_workflow_policy.py +87 -0
- package/harness/manifests/general/claude-code.json +34 -26
- package/harness/manifests/general/codebuddy.json +34 -26
- package/harness/manifests/general/codex.json +34 -26
- package/harness/manifests/general/cursor.json +34 -26
- package/harness/manifests/java/claude-code.json +34 -26
- package/harness/manifests/java/codebuddy.json +34 -26
- package/harness/manifests/java/codex.json +34 -26
- package/harness/manifests/java/cursor.json +34 -26
- package/hunter-workflow-family.json +2 -2
- package/package.json +1 -1
|
@@ -16,12 +16,14 @@ from __future__ import annotations
|
|
|
16
16
|
import argparse
|
|
17
17
|
import datetime as dt
|
|
18
18
|
import hashlib
|
|
19
|
+
import io
|
|
19
20
|
import json
|
|
20
21
|
import os
|
|
21
22
|
import re
|
|
22
23
|
import shutil
|
|
23
24
|
import subprocess
|
|
24
25
|
import sys
|
|
26
|
+
import tarfile
|
|
25
27
|
import tempfile
|
|
26
28
|
import uuid
|
|
27
29
|
from pathlib import Path
|
|
@@ -68,8 +70,10 @@ if str(SCRIPTS_DIR) not in sys.path:
|
|
|
68
70
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
69
71
|
|
|
70
72
|
import harness_events as he # noqa: E402
|
|
73
|
+
import harness_gate as hgate # noqa: E402
|
|
71
74
|
import harness_ledger as hl # noqa: E402
|
|
72
75
|
import harness_paths as hp # noqa: E402
|
|
76
|
+
import harness_phase as hphase # noqa: E402
|
|
73
77
|
import harness_review as hr # noqa: E402
|
|
74
78
|
|
|
75
79
|
|
|
@@ -331,6 +335,80 @@ def load_ci_metrics(change_dir: Path) -> tuple[dict[str, Any] | None, str | None
|
|
|
331
335
|
return None, None
|
|
332
336
|
|
|
333
337
|
|
|
338
|
+
def build_remote_cost_summary(metrics: dict[str, Any] | None) -> dict[str, Any]:
|
|
339
|
+
"""Aggregate typed remote runner/storage costs by candidate identity."""
|
|
340
|
+
value = metrics if isinstance(metrics, dict) else {}
|
|
341
|
+
raw_runs = value.get("runs")
|
|
342
|
+
if isinstance(raw_runs, list):
|
|
343
|
+
runs = [item for item in raw_runs if isinstance(item, dict)]
|
|
344
|
+
elif any(
|
|
345
|
+
field in value
|
|
346
|
+
for field in (
|
|
347
|
+
"runnerMinutes",
|
|
348
|
+
"queueWaitMs",
|
|
349
|
+
"artifactBytes",
|
|
350
|
+
"duplicateRunReason",
|
|
351
|
+
)
|
|
352
|
+
):
|
|
353
|
+
runs = [value]
|
|
354
|
+
else:
|
|
355
|
+
runs = []
|
|
356
|
+
|
|
357
|
+
candidates: dict[str, dict[str, Any]] = {}
|
|
358
|
+
for run in runs:
|
|
359
|
+
subject = run.get("subject")
|
|
360
|
+
subject = subject if isinstance(subject, dict) else {}
|
|
361
|
+
candidate_id = str(
|
|
362
|
+
run.get("candidateId")
|
|
363
|
+
or subject.get("candidateId")
|
|
364
|
+
or subject.get("productCommit")
|
|
365
|
+
or "unattributed"
|
|
366
|
+
).strip()
|
|
367
|
+
row = candidates.setdefault(
|
|
368
|
+
candidate_id,
|
|
369
|
+
{
|
|
370
|
+
"candidateId": candidate_id,
|
|
371
|
+
"runCount": 0,
|
|
372
|
+
"runnerMinutes": 0.0,
|
|
373
|
+
"queueWaitMs": 0,
|
|
374
|
+
"artifactBytes": 0,
|
|
375
|
+
"duplicateRunCount": 0,
|
|
376
|
+
"duplicateRunReasons": [],
|
|
377
|
+
},
|
|
378
|
+
)
|
|
379
|
+
row["runCount"] += 1
|
|
380
|
+
row["runnerMinutes"] += max(0.0, float(run.get("runnerMinutes") or 0))
|
|
381
|
+
row["queueWaitMs"] += max(0, int(run.get("queueWaitMs") or 0))
|
|
382
|
+
row["artifactBytes"] += max(0, int(run.get("artifactBytes") or 0))
|
|
383
|
+
reason = str(run.get("duplicateRunReason") or "").strip()
|
|
384
|
+
if reason:
|
|
385
|
+
row["duplicateRunCount"] += 1
|
|
386
|
+
if reason not in row["duplicateRunReasons"]:
|
|
387
|
+
row["duplicateRunReasons"].append(reason)
|
|
388
|
+
|
|
389
|
+
items = sorted(candidates.values(), key=lambda item: item["candidateId"])
|
|
390
|
+
for item in items:
|
|
391
|
+
item["runnerMinutes"] = round(float(item["runnerMinutes"]), 3)
|
|
392
|
+
totals = {
|
|
393
|
+
"runCount": sum(int(item["runCount"]) for item in items),
|
|
394
|
+
"runnerMinutes": round(
|
|
395
|
+
sum(float(item["runnerMinutes"]) for item in items),
|
|
396
|
+
3,
|
|
397
|
+
),
|
|
398
|
+
"queueWaitMs": sum(int(item["queueWaitMs"]) for item in items),
|
|
399
|
+
"artifactBytes": sum(int(item["artifactBytes"]) for item in items),
|
|
400
|
+
"duplicateRunCount": sum(
|
|
401
|
+
int(item["duplicateRunCount"]) for item in items
|
|
402
|
+
),
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
"schemaVersion": 1,
|
|
406
|
+
"available": bool(runs),
|
|
407
|
+
"candidates": items,
|
|
408
|
+
"totals": totals,
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
|
|
334
412
|
def _legacy_candidate_path(change_dir: Path) -> Path | None:
|
|
335
413
|
for relative in (
|
|
336
414
|
"evidence/product-candidate-ci.json",
|
|
@@ -401,7 +479,14 @@ def load_product_candidate_ci(change_dir: Path) -> dict[str, Any] | None:
|
|
|
401
479
|
|
|
402
480
|
|
|
403
481
|
def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
|
|
404
|
-
"""Evaluate
|
|
482
|
+
"""Evaluate whether candidate evidence is release-capable.
|
|
483
|
+
|
|
484
|
+
Evidence validity and release authority are deliberately separate. A
|
|
485
|
+
``remote-claimed`` receipt remains useful for audit/migration, but can
|
|
486
|
+
never authorize release. Local evidence requires an explicit project
|
|
487
|
+
opt-in; remote-required projects require a subject/environment-bound,
|
|
488
|
+
provider-verified immutable run.
|
|
489
|
+
"""
|
|
405
490
|
evidence = load_product_candidate_ci(change_dir)
|
|
406
491
|
if evidence is None:
|
|
407
492
|
return {
|
|
@@ -412,6 +497,8 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
|
|
|
412
497
|
"(need evidence/product-candidate-ci.json with conclusion=success, "
|
|
413
498
|
"runUrl, commit)"
|
|
414
499
|
),
|
|
500
|
+
"releaseCapable": False,
|
|
501
|
+
"evidenceValid": False,
|
|
415
502
|
"evidence": None,
|
|
416
503
|
}
|
|
417
504
|
|
|
@@ -425,6 +512,7 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
|
|
|
425
512
|
verification = verification if isinstance(verification, dict) else {}
|
|
426
513
|
product_commit = str(subject.get("productCommit") or "").strip()
|
|
427
514
|
product_tree_hash = str(subject.get("productTreeHash") or "").strip()
|
|
515
|
+
environment_hash = str(subject.get("environmentHash") or "").strip()
|
|
428
516
|
command_set_hash = str(verification.get("commandSetHash") or "").strip()
|
|
429
517
|
ledger_hash = str(verification.get("ledgerHash") or "").strip()
|
|
430
518
|
common_required_fields = {
|
|
@@ -433,11 +521,6 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
|
|
|
433
521
|
"subject.productCommit": product_commit,
|
|
434
522
|
"subject.productTreeHash": product_tree_hash,
|
|
435
523
|
}
|
|
436
|
-
allowed_assurance = {
|
|
437
|
-
"local-reproducible": 1,
|
|
438
|
-
"remote-claimed": 2,
|
|
439
|
-
"remote-attested": 3,
|
|
440
|
-
}
|
|
441
524
|
policy = load_gate_policy(change_dir) or {}
|
|
442
525
|
candidate_policy = policy.get("candidateVerification")
|
|
443
526
|
candidate_policy = (
|
|
@@ -446,8 +529,16 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
|
|
|
446
529
|
minimum_assurance = str(
|
|
447
530
|
candidate_policy.get("minimumAssurance") or "local-reproducible"
|
|
448
531
|
).strip()
|
|
449
|
-
|
|
450
|
-
minimum_assurance
|
|
532
|
+
remote_required = bool(candidate_policy.get("remoteRequired")) or (
|
|
533
|
+
minimum_assurance == "remote-attested"
|
|
534
|
+
)
|
|
535
|
+
allow_local_release = bool(candidate_policy.get("allowLocalRelease"))
|
|
536
|
+
declared_capabilities = {
|
|
537
|
+
str(item).strip()
|
|
538
|
+
for item in (evidence.get("capabilities") or [])
|
|
539
|
+
if isinstance(item, str) and item.strip()
|
|
540
|
+
}
|
|
541
|
+
capabilities = set(declared_capabilities)
|
|
451
542
|
if (
|
|
452
543
|
provider == "local-harness"
|
|
453
544
|
and _remote_ci_history_present(change_dir)
|
|
@@ -461,37 +552,11 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
|
|
|
461
552
|
"candidateVerification.allowLocalFallback=true"
|
|
462
553
|
),
|
|
463
554
|
"assurance": assurance,
|
|
555
|
+
"releaseCapable": False,
|
|
556
|
+
"evidenceValid": False,
|
|
464
557
|
"evidence": evidence,
|
|
465
558
|
}
|
|
466
|
-
|
|
467
|
-
local_required = {
|
|
468
|
-
"verification.commandSetHash": command_set_hash,
|
|
469
|
-
"verification.ledgerHash": ledger_hash,
|
|
470
|
-
"verification.toolchainHashes": verification.get("toolchainHashes"),
|
|
471
|
-
"verification.environmentHashes": verification.get("environmentHashes"),
|
|
472
|
-
"verification.dependencyHashes": verification.get("dependencyHashes"),
|
|
473
|
-
"verification.logHashes": verification.get("logHashes"),
|
|
474
|
-
}
|
|
475
|
-
required_fields = {**common_required_fields, **local_required}
|
|
476
|
-
elif provider == "remote-ci":
|
|
477
|
-
attestation = evidence.get("attestation")
|
|
478
|
-
attestation = attestation if isinstance(attestation, dict) else {}
|
|
479
|
-
if assurance == "remote-attested":
|
|
480
|
-
remote_identity_name = "verification.attestationDigest"
|
|
481
|
-
remote_identity = verification.get("attestationDigest")
|
|
482
|
-
else:
|
|
483
|
-
remote_identity_name = "verification.remoteIdentity"
|
|
484
|
-
remote_identity = (
|
|
485
|
-
verification.get("legacyEvidenceHash") or ledger_hash
|
|
486
|
-
)
|
|
487
|
-
required_fields = {
|
|
488
|
-
**common_required_fields,
|
|
489
|
-
"attestation.url": attestation.get("url"),
|
|
490
|
-
remote_identity_name: remote_identity,
|
|
491
|
-
}
|
|
492
|
-
else:
|
|
493
|
-
required_fields = common_required_fields
|
|
494
|
-
missing = [name for name, value in required_fields.items() if not value]
|
|
559
|
+
|
|
495
560
|
if conclusion not in {
|
|
496
561
|
"success",
|
|
497
562
|
"successful",
|
|
@@ -508,36 +573,225 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
|
|
|
508
573
|
f"provider={provider or 'unknown'}"
|
|
509
574
|
),
|
|
510
575
|
"assurance": assurance,
|
|
576
|
+
"releaseCapable": False,
|
|
577
|
+
"evidenceValid": False,
|
|
511
578
|
"evidence": evidence,
|
|
512
579
|
}
|
|
513
|
-
|
|
514
|
-
|
|
580
|
+
|
|
581
|
+
if assurance == "remote-claimed":
|
|
582
|
+
attestation = evidence.get("attestation")
|
|
583
|
+
attestation = attestation if isinstance(attestation, dict) else {}
|
|
584
|
+
required_fields = {
|
|
585
|
+
**common_required_fields,
|
|
586
|
+
"attestation.url": attestation.get("url"),
|
|
587
|
+
"verification.legacyEvidenceHash": (
|
|
588
|
+
verification.get("legacyEvidenceHash") or ledger_hash
|
|
589
|
+
),
|
|
590
|
+
}
|
|
591
|
+
missing = [
|
|
592
|
+
name for name, value in required_fields.items() if not value
|
|
593
|
+
]
|
|
594
|
+
if missing:
|
|
595
|
+
return {
|
|
596
|
+
"ok": False,
|
|
597
|
+
"code": "PRODUCT_CANDIDATE_NOT_VERIFIED",
|
|
598
|
+
"message": (
|
|
599
|
+
"remote claim is incomplete: " + ", ".join(missing)
|
|
600
|
+
),
|
|
601
|
+
"assurance": assurance,
|
|
602
|
+
"releaseCapable": False,
|
|
603
|
+
"evidenceValid": False,
|
|
604
|
+
"capabilities": sorted(capabilities),
|
|
605
|
+
"evidence": evidence,
|
|
606
|
+
}
|
|
515
607
|
return {
|
|
516
608
|
"ok": False,
|
|
517
|
-
"code": "
|
|
518
|
-
"message":
|
|
609
|
+
"code": "PRODUCT_CANDIDATE_RECORD_ONLY",
|
|
610
|
+
"message": (
|
|
611
|
+
"remote-claimed evidence is audit-only and cannot authorize release"
|
|
612
|
+
),
|
|
613
|
+
"assurance": assurance,
|
|
614
|
+
"releaseCapable": False,
|
|
615
|
+
"evidenceValid": True,
|
|
616
|
+
"recordOnly": True,
|
|
617
|
+
"capabilities": sorted(capabilities),
|
|
618
|
+
"evidence": evidence,
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if assurance == "local-reproducible" and provider == "local-harness":
|
|
622
|
+
local_required = {
|
|
623
|
+
"verification.commandSetHash": command_set_hash,
|
|
624
|
+
"verification.ledgerHash": ledger_hash,
|
|
625
|
+
"verification.toolchainHashes": verification.get("toolchainHashes"),
|
|
626
|
+
"verification.environmentHashes": verification.get("environmentHashes"),
|
|
627
|
+
"verification.dependencyHashes": verification.get("dependencyHashes"),
|
|
628
|
+
"verification.logHashes": verification.get("logHashes"),
|
|
629
|
+
}
|
|
630
|
+
required_fields = {**common_required_fields, **local_required}
|
|
631
|
+
missing = [
|
|
632
|
+
name for name, value in required_fields.items() if not value
|
|
633
|
+
]
|
|
634
|
+
if missing:
|
|
635
|
+
return {
|
|
636
|
+
"ok": False,
|
|
637
|
+
"code": "PRODUCT_CANDIDATE_NOT_VERIFIED",
|
|
638
|
+
"message": (
|
|
639
|
+
"candidate evidence missing or invalid: "
|
|
640
|
+
+ ", ".join(missing)
|
|
641
|
+
),
|
|
642
|
+
"assurance": assurance,
|
|
643
|
+
"releaseCapable": False,
|
|
644
|
+
"evidenceValid": False,
|
|
645
|
+
"evidence": evidence,
|
|
646
|
+
}
|
|
647
|
+
capabilities.update(
|
|
648
|
+
{
|
|
649
|
+
"subject-bound",
|
|
650
|
+
"locally-reproducible",
|
|
651
|
+
"environment-bound",
|
|
652
|
+
}
|
|
653
|
+
)
|
|
654
|
+
if remote_required:
|
|
655
|
+
return {
|
|
656
|
+
"ok": False,
|
|
657
|
+
"code": "PROJECT_RELEASE_POLICY_BLOCKED",
|
|
658
|
+
"message": (
|
|
659
|
+
"project requires remote-attested candidate evidence"
|
|
660
|
+
),
|
|
661
|
+
"assurance": assurance,
|
|
662
|
+
"releaseCapable": False,
|
|
663
|
+
"evidenceValid": True,
|
|
664
|
+
"capabilities": sorted(capabilities),
|
|
665
|
+
"evidence": evidence,
|
|
666
|
+
}
|
|
667
|
+
if not allow_local_release:
|
|
668
|
+
return {
|
|
669
|
+
"ok": False,
|
|
670
|
+
"code": "PROJECT_RELEASE_POLICY_BLOCKED",
|
|
671
|
+
"message": (
|
|
672
|
+
"local-reproducible evidence requires explicit "
|
|
673
|
+
"candidateVerification.allowLocalRelease=true"
|
|
674
|
+
),
|
|
675
|
+
"assurance": assurance,
|
|
676
|
+
"releaseCapable": False,
|
|
677
|
+
"evidenceValid": True,
|
|
678
|
+
"capabilities": sorted(capabilities),
|
|
679
|
+
"evidence": evidence,
|
|
680
|
+
}
|
|
681
|
+
return {
|
|
682
|
+
"ok": True,
|
|
683
|
+
"code": "PRODUCT_CANDIDATE_VERIFIED",
|
|
684
|
+
"message": (
|
|
685
|
+
"local reproducible candidate explicitly authorized by project policy "
|
|
686
|
+
f"commit={product_commit}"
|
|
687
|
+
),
|
|
519
688
|
"assurance": assurance,
|
|
689
|
+
"releaseCapable": True,
|
|
690
|
+
"evidenceValid": True,
|
|
691
|
+
"capabilities": sorted(capabilities),
|
|
520
692
|
"evidence": evidence,
|
|
521
693
|
}
|
|
522
|
-
|
|
694
|
+
|
|
695
|
+
if assurance == "remote-attested" and provider == "remote-ci":
|
|
696
|
+
attestation = evidence.get("attestation")
|
|
697
|
+
attestation = attestation if isinstance(attestation, dict) else {}
|
|
698
|
+
required_fields = {
|
|
699
|
+
**common_required_fields,
|
|
700
|
+
"subject.environmentHash": environment_hash,
|
|
701
|
+
"attestation.url": attestation.get("url"),
|
|
702
|
+
"attestation.providerRunDigest": attestation.get(
|
|
703
|
+
"providerRunDigest"
|
|
704
|
+
),
|
|
705
|
+
"verification.attestationDigest": verification.get(
|
|
706
|
+
"attestationDigest"
|
|
707
|
+
),
|
|
708
|
+
}
|
|
709
|
+
missing = [
|
|
710
|
+
name for name, value in required_fields.items() if not value
|
|
711
|
+
]
|
|
712
|
+
if missing:
|
|
713
|
+
return {
|
|
714
|
+
"ok": False,
|
|
715
|
+
"code": "PRODUCT_CANDIDATE_NOT_VERIFIED",
|
|
716
|
+
"message": (
|
|
717
|
+
"candidate evidence missing or invalid: "
|
|
718
|
+
+ ", ".join(missing)
|
|
719
|
+
),
|
|
720
|
+
"assurance": assurance,
|
|
721
|
+
"releaseCapable": False,
|
|
722
|
+
"evidenceValid": False,
|
|
723
|
+
"capabilities": sorted(capabilities),
|
|
724
|
+
"evidence": evidence,
|
|
725
|
+
}
|
|
726
|
+
capabilities.update(
|
|
727
|
+
{
|
|
728
|
+
"subject-bound",
|
|
729
|
+
"provider-verified",
|
|
730
|
+
"immutable-run",
|
|
731
|
+
"environment-bound",
|
|
732
|
+
}
|
|
733
|
+
)
|
|
734
|
+
required_capabilities = candidate_policy.get(
|
|
735
|
+
"requiredCapabilities"
|
|
736
|
+
)
|
|
737
|
+
if not isinstance(required_capabilities, list):
|
|
738
|
+
required_capabilities = (
|
|
739
|
+
[
|
|
740
|
+
"subject-bound",
|
|
741
|
+
"provider-verified",
|
|
742
|
+
"immutable-run",
|
|
743
|
+
"environment-bound",
|
|
744
|
+
]
|
|
745
|
+
if remote_required
|
|
746
|
+
else []
|
|
747
|
+
)
|
|
748
|
+
missing_capabilities = sorted(
|
|
749
|
+
{
|
|
750
|
+
str(item).strip()
|
|
751
|
+
for item in required_capabilities
|
|
752
|
+
if isinstance(item, str) and item.strip()
|
|
753
|
+
}
|
|
754
|
+
- capabilities
|
|
755
|
+
)
|
|
756
|
+
if missing_capabilities:
|
|
757
|
+
return {
|
|
758
|
+
"ok": False,
|
|
759
|
+
"code": "PRODUCT_CANDIDATE_NOT_VERIFIED",
|
|
760
|
+
"message": (
|
|
761
|
+
"candidate capabilities missing: "
|
|
762
|
+
+ ", ".join(missing_capabilities)
|
|
763
|
+
),
|
|
764
|
+
"assurance": assurance,
|
|
765
|
+
"releaseCapable": False,
|
|
766
|
+
"evidenceValid": False,
|
|
767
|
+
"capabilities": sorted(capabilities),
|
|
768
|
+
"evidence": evidence,
|
|
769
|
+
}
|
|
523
770
|
return {
|
|
524
|
-
"ok":
|
|
525
|
-
"code": "
|
|
771
|
+
"ok": True,
|
|
772
|
+
"code": "PRODUCT_CANDIDATE_VERIFIED",
|
|
526
773
|
"message": (
|
|
527
|
-
|
|
528
|
-
f"
|
|
774
|
+
"remote candidate attested "
|
|
775
|
+
f"provider={provider} commit={product_commit}"
|
|
529
776
|
),
|
|
530
777
|
"assurance": assurance,
|
|
778
|
+
"releaseCapable": True,
|
|
779
|
+
"evidenceValid": True,
|
|
780
|
+
"capabilities": sorted(capabilities),
|
|
531
781
|
"evidence": evidence,
|
|
532
782
|
}
|
|
783
|
+
|
|
533
784
|
return {
|
|
534
|
-
"ok":
|
|
535
|
-
"code": "
|
|
785
|
+
"ok": False,
|
|
786
|
+
"code": "PRODUCT_CANDIDATE_NOT_VERIFIED",
|
|
536
787
|
"message": (
|
|
537
|
-
|
|
538
|
-
f"
|
|
788
|
+
"candidate provider/assurance combination is invalid: "
|
|
789
|
+
f"provider={provider or 'unknown'} assurance={assurance or 'unknown'}"
|
|
539
790
|
),
|
|
540
791
|
"assurance": assurance,
|
|
792
|
+
"releaseCapable": False,
|
|
793
|
+
"evidenceValid": False,
|
|
794
|
+
"capabilities": sorted(capabilities),
|
|
541
795
|
"evidence": evidence,
|
|
542
796
|
}
|
|
543
797
|
|
|
@@ -556,12 +810,21 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
|
|
|
556
810
|
"product candidate CI conclusion is success but missing "
|
|
557
811
|
f"runUrl={run_url or 'empty'} commit={commit or 'empty'}"
|
|
558
812
|
),
|
|
813
|
+
"releaseCapable": False,
|
|
814
|
+
"evidenceValid": False,
|
|
559
815
|
"evidence": evidence,
|
|
560
816
|
}
|
|
561
817
|
return {
|
|
562
|
-
"ok":
|
|
563
|
-
"code": "
|
|
564
|
-
"message":
|
|
818
|
+
"ok": False,
|
|
819
|
+
"code": "PRODUCT_CANDIDATE_RECORD_ONLY",
|
|
820
|
+
"message": (
|
|
821
|
+
"legacy CI success is a remote claim only; migrate/attest before release "
|
|
822
|
+
f"commit={commit} url={run_url}"
|
|
823
|
+
),
|
|
824
|
+
"releaseCapable": False,
|
|
825
|
+
"evidenceValid": True,
|
|
826
|
+
"recordOnly": True,
|
|
827
|
+
"assurance": "remote-claimed",
|
|
565
828
|
"evidence": evidence,
|
|
566
829
|
}
|
|
567
830
|
return {
|
|
@@ -571,6 +834,8 @@ def evaluate_product_ci_gate(change_dir: Path) -> dict[str, Any]:
|
|
|
571
834
|
f"product candidate CI conclusion={conclusion or 'unknown'} "
|
|
572
835
|
f"commit={commit or 'unknown'} runUrl={run_url or 'unknown'}"
|
|
573
836
|
),
|
|
837
|
+
"releaseCapable": False,
|
|
838
|
+
"evidenceValid": False,
|
|
574
839
|
"evidence": evidence,
|
|
575
840
|
}
|
|
576
841
|
|
|
@@ -764,7 +1029,9 @@ def certify_local_candidate(
|
|
|
764
1029
|
if not product_commit:
|
|
765
1030
|
raise ValueError("product commit unavailable")
|
|
766
1031
|
|
|
767
|
-
detail =
|
|
1032
|
+
detail = compute_product_tree_hash_for_commit(
|
|
1033
|
+
project_root, product_commit
|
|
1034
|
+
)
|
|
768
1035
|
if detail.get("truncated"):
|
|
769
1036
|
raise ValueError(
|
|
770
1037
|
"product tree hash truncated; narrow the project or raise the explicit limit"
|
|
@@ -817,16 +1084,8 @@ def certify_local_candidate(
|
|
|
817
1084
|
|
|
818
1085
|
|
|
819
1086
|
PRODUCT_TREE_HASH_FILE_LIMIT = 20_000
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
def compute_product_tree_hash_detail(
|
|
823
|
-
project: Path,
|
|
824
|
-
*,
|
|
825
|
-
file_limit: int = PRODUCT_TREE_HASH_FILE_LIMIT,
|
|
826
|
-
) -> dict[str, Any]:
|
|
827
|
-
"""Hash product tree excluding .harness/**; report truncation metadata (Y3)."""
|
|
828
|
-
project = project.resolve()
|
|
829
|
-
skip_dirs = {
|
|
1087
|
+
PRODUCT_TREE_EXCLUDED_PARTS = frozenset(
|
|
1088
|
+
{
|
|
830
1089
|
".harness",
|
|
831
1090
|
".git",
|
|
832
1091
|
".worktrees",
|
|
@@ -838,17 +1097,36 @@ def compute_product_tree_hash_detail(
|
|
|
838
1097
|
".venv",
|
|
839
1098
|
"venv",
|
|
840
1099
|
}
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _product_path_included(relative_path: str) -> bool:
|
|
1104
|
+
parts = {part for part in relative_path.replace("\\", "/").split("/") if part}
|
|
1105
|
+
return not bool(parts & PRODUCT_TREE_EXCLUDED_PARTS)
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def compute_product_tree_hash_detail(
|
|
1109
|
+
project: Path,
|
|
1110
|
+
*,
|
|
1111
|
+
file_limit: int = PRODUCT_TREE_HASH_FILE_LIMIT,
|
|
1112
|
+
) -> dict[str, Any]:
|
|
1113
|
+
"""Hash product tree excluding .harness/**; report truncation metadata (Y3)."""
|
|
1114
|
+
project = project.resolve()
|
|
841
1115
|
limit = max(1, int(file_limit))
|
|
842
1116
|
lines: list[str] = []
|
|
843
1117
|
truncated = False
|
|
844
1118
|
for root, dirs, files in os.walk(project, onerror=lambda _exc: None):
|
|
845
|
-
dirs[:] =
|
|
1119
|
+
dirs[:] = sorted(
|
|
1120
|
+
name for name in dirs if name not in PRODUCT_TREE_EXCLUDED_PARTS
|
|
1121
|
+
)
|
|
846
1122
|
for name in sorted(files):
|
|
847
1123
|
path = Path(root) / name
|
|
848
1124
|
try:
|
|
849
1125
|
rel = path.relative_to(project).as_posix()
|
|
850
1126
|
except ValueError:
|
|
851
1127
|
continue
|
|
1128
|
+
if not _product_path_included(rel):
|
|
1129
|
+
continue
|
|
852
1130
|
try:
|
|
853
1131
|
digest = sha256_file(path)
|
|
854
1132
|
except OSError:
|
|
@@ -859,12 +1137,15 @@ def compute_product_tree_hash_detail(
|
|
|
859
1137
|
break
|
|
860
1138
|
if truncated:
|
|
861
1139
|
break
|
|
1140
|
+
lines.sort()
|
|
862
1141
|
digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
|
|
863
1142
|
return {
|
|
864
1143
|
"hash": digest,
|
|
865
1144
|
"truncated": truncated,
|
|
866
1145
|
"fileCount": len(lines),
|
|
867
1146
|
"limit": limit,
|
|
1147
|
+
"source": "working-tree",
|
|
1148
|
+
"algorithm": "sha256-path-content-v1",
|
|
868
1149
|
}
|
|
869
1150
|
|
|
870
1151
|
|
|
@@ -877,6 +1158,82 @@ def compute_product_tree_hash(
|
|
|
877
1158
|
return str(compute_product_tree_hash_detail(project, file_limit=file_limit)["hash"])
|
|
878
1159
|
|
|
879
1160
|
|
|
1161
|
+
def compute_product_tree_hash_for_commit(
|
|
1162
|
+
project: Path,
|
|
1163
|
+
product_commit: str,
|
|
1164
|
+
*,
|
|
1165
|
+
file_limit: int = PRODUCT_TREE_HASH_FILE_LIMIT,
|
|
1166
|
+
) -> dict[str, Any]:
|
|
1167
|
+
"""Hash immutable Git commit contents using the product exclusion rules."""
|
|
1168
|
+
project = project.resolve()
|
|
1169
|
+
commit = str(product_commit or "").strip()
|
|
1170
|
+
if not commit:
|
|
1171
|
+
raise ValueError("product commit is required")
|
|
1172
|
+
exists, resolved_commit, error = git_run(
|
|
1173
|
+
project, "rev-parse", "--verify", f"{commit}^{{commit}}"
|
|
1174
|
+
)
|
|
1175
|
+
if exists != 0 or not resolved_commit:
|
|
1176
|
+
raise ValueError(
|
|
1177
|
+
f"product commit not found: {commit} ({error or 'git rev-parse failed'})"
|
|
1178
|
+
)
|
|
1179
|
+
try:
|
|
1180
|
+
archive = subprocess.run(
|
|
1181
|
+
[
|
|
1182
|
+
"git",
|
|
1183
|
+
"-C",
|
|
1184
|
+
str(project),
|
|
1185
|
+
"archive",
|
|
1186
|
+
"--format=tar",
|
|
1187
|
+
resolved_commit,
|
|
1188
|
+
],
|
|
1189
|
+
capture_output=True,
|
|
1190
|
+
timeout=120,
|
|
1191
|
+
check=False,
|
|
1192
|
+
)
|
|
1193
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
1194
|
+
raise ValueError(f"could not read product commit tree: {exc}") from exc
|
|
1195
|
+
if archive.returncode != 0:
|
|
1196
|
+
detail = archive.stderr.decode("utf-8", errors="replace").strip()
|
|
1197
|
+
raise ValueError(
|
|
1198
|
+
f"could not read product commit tree {resolved_commit}: {detail}"
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
limit = max(1, int(file_limit))
|
|
1202
|
+
lines: list[str] = []
|
|
1203
|
+
truncated = False
|
|
1204
|
+
with tarfile.open(fileobj=io.BytesIO(archive.stdout), mode="r:") as stream:
|
|
1205
|
+
members = sorted(stream.getmembers(), key=lambda item: item.name)
|
|
1206
|
+
for member in members:
|
|
1207
|
+
relative = member.name.rstrip("/")
|
|
1208
|
+
if not relative or not _product_path_included(relative):
|
|
1209
|
+
continue
|
|
1210
|
+
if member.isfile():
|
|
1211
|
+
extracted = stream.extractfile(member)
|
|
1212
|
+
if extracted is None:
|
|
1213
|
+
continue
|
|
1214
|
+
content = extracted.read()
|
|
1215
|
+
elif member.issym() or member.islnk():
|
|
1216
|
+
content = member.linkname.encode("utf-8")
|
|
1217
|
+
else:
|
|
1218
|
+
continue
|
|
1219
|
+
lines.append(
|
|
1220
|
+
f"{relative}:{hashlib.sha256(content).hexdigest()}"
|
|
1221
|
+
)
|
|
1222
|
+
if len(lines) >= limit:
|
|
1223
|
+
truncated = True
|
|
1224
|
+
break
|
|
1225
|
+
digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
|
|
1226
|
+
return {
|
|
1227
|
+
"hash": digest,
|
|
1228
|
+
"truncated": truncated,
|
|
1229
|
+
"fileCount": len(lines),
|
|
1230
|
+
"limit": limit,
|
|
1231
|
+
"source": "git-commit-tree",
|
|
1232
|
+
"algorithm": "sha256-path-content-v1",
|
|
1233
|
+
"productCommit": resolved_commit,
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
|
|
880
1237
|
def resolve_product_archive_identity(
|
|
881
1238
|
change_dir: Path,
|
|
882
1239
|
*,
|
|
@@ -918,7 +1275,16 @@ def resolve_product_archive_identity(
|
|
|
918
1275
|
or release_tip
|
|
919
1276
|
or product
|
|
920
1277
|
)
|
|
921
|
-
tree = str(
|
|
1278
|
+
tree = str(
|
|
1279
|
+
ledger.get("productTreeHash")
|
|
1280
|
+
or candidate_subject.get("productTreeHash")
|
|
1281
|
+
or ""
|
|
1282
|
+
)
|
|
1283
|
+
environment_hash = str(
|
|
1284
|
+
candidate_subject.get("environmentHash")
|
|
1285
|
+
or ledger.get("environmentHash")
|
|
1286
|
+
or ""
|
|
1287
|
+
).strip()
|
|
922
1288
|
under_harness = False
|
|
923
1289
|
try:
|
|
924
1290
|
change_dir.relative_to(project_root / ".harness")
|
|
@@ -933,9 +1299,11 @@ def resolve_product_archive_identity(
|
|
|
933
1299
|
if not tree:
|
|
934
1300
|
if under_harness:
|
|
935
1301
|
try:
|
|
936
|
-
tree_meta =
|
|
937
|
-
|
|
938
|
-
|
|
1302
|
+
tree_meta = compute_product_tree_hash_for_commit(
|
|
1303
|
+
project_root, str(product)
|
|
1304
|
+
)
|
|
1305
|
+
tree = f"sha256:{tree_meta['hash']}"
|
|
1306
|
+
except (OSError, ValueError):
|
|
939
1307
|
tree = hashlib.sha256(b"").hexdigest()
|
|
940
1308
|
else:
|
|
941
1309
|
# Bare temp fixtures are not real project roots — avoid scanning up-tree.
|
|
@@ -946,24 +1314,16 @@ def resolve_product_archive_identity(
|
|
|
946
1314
|
"releaseTipHash": release_tip,
|
|
947
1315
|
"productTreeHash": str(tree),
|
|
948
1316
|
"archiveCommit": str(archive),
|
|
1317
|
+
"environmentHash": environment_hash,
|
|
949
1318
|
"productTreeHashTruncated": bool(tree_meta.get("truncated")),
|
|
950
1319
|
"productTreeHashFileCount": int(tree_meta.get("fileCount") or 0),
|
|
951
1320
|
}
|
|
952
|
-
expected_tree = tree
|
|
953
|
-
if under_harness:
|
|
954
|
-
try:
|
|
955
|
-
expected_meta = compute_product_tree_hash_detail(project_root)
|
|
956
|
-
expected_tree = str(expected_meta["hash"])
|
|
957
|
-
identity["productTreeHashTruncated"] = bool(expected_meta.get("truncated"))
|
|
958
|
-
identity["productTreeHashFileCount"] = int(expected_meta.get("fileCount") or 0)
|
|
959
|
-
except OSError:
|
|
960
|
-
expected_tree = tree
|
|
961
1321
|
identity["validation"] = validate_product_identity(
|
|
962
1322
|
product_commit=identity["productCommit"],
|
|
963
1323
|
product_tree_hash=identity["productTreeHash"],
|
|
964
1324
|
archive_commit=identity["archiveCommit"] or "unknown",
|
|
965
1325
|
project=project_root if under_harness else None,
|
|
966
|
-
expected_tree_hash=
|
|
1326
|
+
expected_tree_hash=tree if not under_harness else None,
|
|
967
1327
|
)
|
|
968
1328
|
return identity
|
|
969
1329
|
|
|
@@ -984,9 +1344,50 @@ def validate_product_identity(
|
|
|
984
1344
|
"message": "productCommit, productTreeHash, and archiveCommit are required",
|
|
985
1345
|
}
|
|
986
1346
|
expected = expected_tree_hash
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
if
|
|
1347
|
+
source = "provided-expected"
|
|
1348
|
+
resolved_product_commit = product_commit
|
|
1349
|
+
if project is not None and expected_tree_hash is None:
|
|
1350
|
+
code, resolved, error = git_run(
|
|
1351
|
+
project, "rev-parse", "--verify", f"{product_commit}^{{commit}}"
|
|
1352
|
+
)
|
|
1353
|
+
if code != 0 or not resolved:
|
|
1354
|
+
return {
|
|
1355
|
+
"ok": False,
|
|
1356
|
+
"code": "PRODUCT_COMMIT_NOT_FOUND",
|
|
1357
|
+
"message": (
|
|
1358
|
+
f"productCommit={product_commit} is not a reachable Git commit: "
|
|
1359
|
+
f"{error or 'not found'}"
|
|
1360
|
+
),
|
|
1361
|
+
"productCommit": product_commit,
|
|
1362
|
+
}
|
|
1363
|
+
resolved_product_commit = resolved
|
|
1364
|
+
try:
|
|
1365
|
+
detail = compute_product_tree_hash_for_commit(
|
|
1366
|
+
project, resolved_product_commit
|
|
1367
|
+
)
|
|
1368
|
+
except ValueError as exc:
|
|
1369
|
+
return {
|
|
1370
|
+
"ok": False,
|
|
1371
|
+
"code": "PRODUCT_TREE_UNAVAILABLE",
|
|
1372
|
+
"message": str(exc),
|
|
1373
|
+
"productCommit": resolved_product_commit,
|
|
1374
|
+
}
|
|
1375
|
+
if detail.get("truncated"):
|
|
1376
|
+
return {
|
|
1377
|
+
"ok": False,
|
|
1378
|
+
"code": "PRODUCT_TREE_HASH_TRUNCATED",
|
|
1379
|
+
"message": (
|
|
1380
|
+
f"product tree exceeds limit={detail.get('limit')} "
|
|
1381
|
+
f"for productCommit={resolved_product_commit}"
|
|
1382
|
+
),
|
|
1383
|
+
}
|
|
1384
|
+
expected = f"sha256:{detail['hash']}"
|
|
1385
|
+
source = "git-commit-tree"
|
|
1386
|
+
|
|
1387
|
+
normalize_hash = lambda value: str(value or "").strip().removeprefix("sha256:")
|
|
1388
|
+
if expected is not None and normalize_hash(product_tree_hash) != normalize_hash(
|
|
1389
|
+
expected
|
|
1390
|
+
):
|
|
990
1391
|
return {
|
|
991
1392
|
"ok": False,
|
|
992
1393
|
"code": "PRODUCT_TREE_HASH_MISMATCH",
|
|
@@ -997,12 +1398,48 @@ def validate_product_identity(
|
|
|
997
1398
|
"expected": expected,
|
|
998
1399
|
"actual": product_tree_hash,
|
|
999
1400
|
}
|
|
1401
|
+
|
|
1402
|
+
if project is not None:
|
|
1403
|
+
code, resolved_archive, error = git_run(
|
|
1404
|
+
project, "rev-parse", "--verify", f"{archive_commit}^{{commit}}"
|
|
1405
|
+
)
|
|
1406
|
+
if code != 0 or not resolved_archive:
|
|
1407
|
+
return {
|
|
1408
|
+
"ok": False,
|
|
1409
|
+
"code": "ARCHIVE_COMMIT_NOT_FOUND",
|
|
1410
|
+
"message": (
|
|
1411
|
+
f"archiveCommit={archive_commit} is not a Git commit: "
|
|
1412
|
+
f"{error or 'not found'}"
|
|
1413
|
+
),
|
|
1414
|
+
"archiveCommit": archive_commit,
|
|
1415
|
+
}
|
|
1416
|
+
ancestor, _out, ancestry_error = git_run(
|
|
1417
|
+
project,
|
|
1418
|
+
"merge-base",
|
|
1419
|
+
"--is-ancestor",
|
|
1420
|
+
resolved_product_commit,
|
|
1421
|
+
resolved_archive,
|
|
1422
|
+
)
|
|
1423
|
+
if ancestor != 0:
|
|
1424
|
+
return {
|
|
1425
|
+
"ok": False,
|
|
1426
|
+
"code": "ARCHIVE_COMMIT_UNRELATED",
|
|
1427
|
+
"message": (
|
|
1428
|
+
f"archiveCommit={resolved_archive} does not descend from "
|
|
1429
|
+
f"productCommit={resolved_product_commit}: "
|
|
1430
|
+
f"{ancestry_error or 'unrelated history'}"
|
|
1431
|
+
),
|
|
1432
|
+
"productCommit": resolved_product_commit,
|
|
1433
|
+
"archiveCommit": resolved_archive,
|
|
1434
|
+
}
|
|
1435
|
+
archive_commit = resolved_archive
|
|
1000
1436
|
return {
|
|
1001
1437
|
"ok": True,
|
|
1002
1438
|
"code": "PRODUCT_IDENTITY_OK",
|
|
1003
|
-
"productCommit":
|
|
1439
|
+
"productCommit": resolved_product_commit,
|
|
1004
1440
|
"productTreeHash": product_tree_hash,
|
|
1005
1441
|
"archiveCommit": archive_commit,
|
|
1442
|
+
"source": source,
|
|
1006
1443
|
}
|
|
1007
1444
|
|
|
1008
1445
|
|
|
@@ -1042,48 +1479,204 @@ def build_workflow_timing(
|
|
|
1042
1479
|
*,
|
|
1043
1480
|
report_cutoff_at: str | None = None,
|
|
1044
1481
|
) -> dict[str, Any]:
|
|
1045
|
-
"""
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1482
|
+
"""Build typed attempts, sessions, and a wall-clock-conserving breakdown."""
|
|
1483
|
+
parsed_stamps = [
|
|
1484
|
+
he.parse_timestamp(event.get("timestamp"))
|
|
1485
|
+
for event in events
|
|
1486
|
+
if event.get("timestamp")
|
|
1487
|
+
]
|
|
1488
|
+
parsed_stamps = [stamp for stamp in parsed_stamps if stamp is not None]
|
|
1489
|
+
started_dt = min(parsed_stamps) if parsed_stamps else None
|
|
1490
|
+
cutoff_text = report_cutoff_at or (
|
|
1491
|
+
max(parsed_stamps).isoformat() if parsed_stamps else None
|
|
1492
|
+
)
|
|
1493
|
+
cutoff_dt = he.parse_timestamp(cutoff_text) if cutoff_text else None
|
|
1494
|
+
started = started_dt.isoformat() if started_dt else None
|
|
1495
|
+
cutoff = cutoff_dt.isoformat() if cutoff_dt else cutoff_text
|
|
1496
|
+
|
|
1497
|
+
def clipped_interval(
|
|
1498
|
+
start_value: Any,
|
|
1499
|
+
end_value: Any,
|
|
1500
|
+
) -> tuple[Any, Any] | None:
|
|
1501
|
+
start = (
|
|
1502
|
+
start_value
|
|
1503
|
+
if hasattr(start_value, "tzinfo")
|
|
1504
|
+
else he.parse_timestamp(start_value)
|
|
1505
|
+
)
|
|
1506
|
+
end = (
|
|
1507
|
+
end_value
|
|
1508
|
+
if hasattr(end_value, "tzinfo")
|
|
1509
|
+
else he.parse_timestamp(end_value)
|
|
1510
|
+
)
|
|
1511
|
+
if start is None or end is None or end <= start:
|
|
1512
|
+
return None
|
|
1513
|
+
if started_dt is not None:
|
|
1514
|
+
start = max(start, started_dt)
|
|
1515
|
+
if cutoff_dt is not None:
|
|
1516
|
+
end = min(end, cutoff_dt)
|
|
1517
|
+
return (start, end) if end > start else None
|
|
1518
|
+
|
|
1519
|
+
def union_ms(intervals: list[tuple[Any, Any]]) -> int:
|
|
1520
|
+
merged: list[list[Any]] = []
|
|
1521
|
+
for interval_start, interval_end in sorted(intervals):
|
|
1522
|
+
if not merged or interval_start > merged[-1][1]:
|
|
1523
|
+
merged.append([interval_start, interval_end])
|
|
1524
|
+
else:
|
|
1525
|
+
merged[-1][1] = max(merged[-1][1], interval_end)
|
|
1526
|
+
return sum(
|
|
1527
|
+
max(
|
|
1528
|
+
0,
|
|
1529
|
+
int((interval_end - interval_start).total_seconds() * 1000),
|
|
1530
|
+
)
|
|
1531
|
+
for interval_start, interval_end in merged
|
|
1532
|
+
)
|
|
1533
|
+
|
|
1534
|
+
active_intervals: list[tuple[Any, Any]] = []
|
|
1535
|
+
attempts: list[dict[str, Any]] = []
|
|
1049
1536
|
stage_active = 0
|
|
1050
|
-
stage_intervals: list[tuple[Any, Any]] = []
|
|
1051
1537
|
unclosed = 0
|
|
1052
|
-
for
|
|
1538
|
+
for phase, phase_events in he.group_events_by_phase(events):
|
|
1539
|
+
if not any(
|
|
1540
|
+
event.get("type") in {"phase.start", "phase.end"}
|
|
1541
|
+
for event in phase_events
|
|
1542
|
+
):
|
|
1543
|
+
continue
|
|
1053
1544
|
timing = he.canonical_phase_timing(phase_events, cutoff_ts=cutoff)
|
|
1054
1545
|
stage_active += int(timing.get("activeExecutionMs") or 0)
|
|
1055
1546
|
unclosed += int(timing.get("unclosedAttemptCount") or 0)
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
if
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1547
|
+
for invocation in timing.get("attempts") or []:
|
|
1548
|
+
item = {"phase": phase, **invocation}
|
|
1549
|
+
attempts.append(item)
|
|
1550
|
+
if invocation.get("activeEligible"):
|
|
1551
|
+
interval = clipped_interval(
|
|
1552
|
+
invocation.get("startedAt"),
|
|
1553
|
+
invocation.get("endedAt"),
|
|
1554
|
+
)
|
|
1555
|
+
if interval is not None:
|
|
1556
|
+
active_intervals.append(interval)
|
|
1557
|
+
|
|
1558
|
+
external_intervals: list[tuple[Any, Any]] = []
|
|
1559
|
+
pause_intervals: list[tuple[Any, Any]] = []
|
|
1560
|
+
pause_start = None
|
|
1561
|
+
for event in sorted(
|
|
1562
|
+
events,
|
|
1563
|
+
key=lambda item: str(item.get("timestamp") or ""),
|
|
1564
|
+
):
|
|
1565
|
+
event_type = str(event.get("type") or "").lower()
|
|
1566
|
+
timestamp = he.parse_timestamp(event.get("timestamp"))
|
|
1567
|
+
if event_type in {
|
|
1568
|
+
"external.wait",
|
|
1569
|
+
"ci.wait",
|
|
1570
|
+
"environment.wait",
|
|
1571
|
+
"env.wait",
|
|
1572
|
+
}:
|
|
1573
|
+
ended = he.parse_timestamp(event.get("endedAt"))
|
|
1574
|
+
duration = event.get("duration_ms", event.get("durationMs"))
|
|
1575
|
+
if ended is None and timestamp is not None and isinstance(duration, int):
|
|
1576
|
+
ended = timestamp + dt.timedelta(milliseconds=max(0, duration))
|
|
1577
|
+
interval = clipped_interval(timestamp, ended)
|
|
1578
|
+
if interval is not None:
|
|
1579
|
+
external_intervals.append(interval)
|
|
1580
|
+
if event_type in {"workflow.pause", "session.pause", "pause"}:
|
|
1581
|
+
pause_start = timestamp or pause_start
|
|
1582
|
+
elif event_type in {
|
|
1583
|
+
"workflow.resume",
|
|
1584
|
+
"session.resume",
|
|
1585
|
+
"resume",
|
|
1586
|
+
} and pause_start is not None:
|
|
1587
|
+
interval = clipped_interval(pause_start, timestamp)
|
|
1588
|
+
if interval is not None:
|
|
1589
|
+
pause_intervals.append(interval)
|
|
1590
|
+
pause_start = None
|
|
1591
|
+
if pause_start is not None and cutoff_dt is not None:
|
|
1592
|
+
interval = clipped_interval(pause_start, cutoff_dt)
|
|
1593
|
+
if interval is not None:
|
|
1594
|
+
pause_intervals.append(interval)
|
|
1595
|
+
|
|
1596
|
+
boundaries = sorted(
|
|
1597
|
+
{
|
|
1598
|
+
point
|
|
1599
|
+
for interval in (
|
|
1600
|
+
active_intervals + external_intervals + pause_intervals
|
|
1601
|
+
)
|
|
1602
|
+
for point in interval
|
|
1603
|
+
}
|
|
1604
|
+
| (
|
|
1605
|
+
{started_dt, cutoff_dt}
|
|
1606
|
+
if started_dt is not None and cutoff_dt is not None
|
|
1607
|
+
else set()
|
|
1608
|
+
)
|
|
1609
|
+
)
|
|
1610
|
+
categories = {
|
|
1611
|
+
"attributed": 0,
|
|
1612
|
+
"external": 0,
|
|
1613
|
+
"paused": 0,
|
|
1614
|
+
"unattributed": 0,
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
def covered(
|
|
1618
|
+
interval_start: Any,
|
|
1619
|
+
interval_end: Any,
|
|
1620
|
+
intervals: list[tuple[Any, Any]],
|
|
1621
|
+
) -> bool:
|
|
1622
|
+
return any(
|
|
1623
|
+
start <= interval_start and end >= interval_end
|
|
1624
|
+
for start, end in intervals
|
|
1625
|
+
)
|
|
1626
|
+
|
|
1627
|
+
for segment_start, segment_end in zip(boundaries, boundaries[1:]):
|
|
1628
|
+
milliseconds = max(
|
|
1629
|
+
0,
|
|
1630
|
+
int((segment_end - segment_start).total_seconds() * 1000),
|
|
1631
|
+
)
|
|
1632
|
+
if covered(segment_start, segment_end, active_intervals):
|
|
1633
|
+
categories["attributed"] += milliseconds
|
|
1634
|
+
elif covered(segment_start, segment_end, external_intervals):
|
|
1635
|
+
categories["external"] += milliseconds
|
|
1636
|
+
elif covered(segment_start, segment_end, pause_intervals):
|
|
1637
|
+
categories["paused"] += milliseconds
|
|
1068
1638
|
else:
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1639
|
+
categories["unattributed"] += milliseconds
|
|
1640
|
+
|
|
1641
|
+
workflow_wall = (
|
|
1642
|
+
max(0, int((cutoff_dt - started_dt).total_seconds() * 1000))
|
|
1643
|
+
if started_dt is not None and cutoff_dt is not None
|
|
1644
|
+
else 0
|
|
1073
1645
|
)
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
if
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1646
|
+
stage_wall = union_ms(active_intervals)
|
|
1647
|
+
sessions: list[dict[str, Any]] = []
|
|
1648
|
+
session_start = started_dt
|
|
1649
|
+
for pause_start_dt, pause_end_dt in sorted(pause_intervals):
|
|
1650
|
+
if session_start is not None and pause_start_dt > session_start:
|
|
1651
|
+
sessions.append(
|
|
1652
|
+
{
|
|
1653
|
+
"session": len(sessions) + 1,
|
|
1654
|
+
"startedAt": session_start.isoformat(),
|
|
1655
|
+
"endedAt": pause_start_dt.isoformat(),
|
|
1656
|
+
"wallClockMs": int(
|
|
1657
|
+
(pause_start_dt - session_start).total_seconds() * 1000
|
|
1658
|
+
),
|
|
1659
|
+
"terminalStatus": "PAUSED",
|
|
1660
|
+
}
|
|
1661
|
+
)
|
|
1662
|
+
session_start = pause_end_dt
|
|
1663
|
+
if (
|
|
1664
|
+
session_start is not None
|
|
1665
|
+
and cutoff_dt is not None
|
|
1666
|
+
and cutoff_dt > session_start
|
|
1667
|
+
):
|
|
1668
|
+
sessions.append(
|
|
1669
|
+
{
|
|
1670
|
+
"session": len(sessions) + 1,
|
|
1671
|
+
"startedAt": session_start.isoformat(),
|
|
1672
|
+
"endedAt": cutoff_dt.isoformat(),
|
|
1673
|
+
"wallClockMs": int(
|
|
1674
|
+
(cutoff_dt - session_start).total_seconds() * 1000
|
|
1675
|
+
),
|
|
1676
|
+
"terminalStatus": "CUTOFF",
|
|
1677
|
+
}
|
|
1678
|
+
)
|
|
1679
|
+
conservation_total = sum(categories.values())
|
|
1087
1680
|
post_archive_excluded = 0
|
|
1088
1681
|
if cutoff:
|
|
1089
1682
|
for event in events:
|
|
@@ -1102,11 +1695,19 @@ def build_workflow_timing(
|
|
|
1102
1695
|
"workflowWallClockMs": int(workflow_wall or 0),
|
|
1103
1696
|
"stageActiveExecutionMs": int(stage_active),
|
|
1104
1697
|
"stageWallClockSpanMs": int(stage_wall),
|
|
1105
|
-
"
|
|
1106
|
-
"
|
|
1698
|
+
"attributedStageUnionMs": int(categories["attributed"]),
|
|
1699
|
+
"externalWaitMs": int(categories["external"]),
|
|
1700
|
+
"pausedMs": int(categories["paused"]),
|
|
1701
|
+
"agentOrToolUnattributedMs": int(categories["unattributed"]),
|
|
1702
|
+
"conservationDeltaMs": int(workflow_wall - conservation_total),
|
|
1107
1703
|
"unclosedAttemptCount": int(unclosed),
|
|
1704
|
+
"attempts": attempts,
|
|
1705
|
+
"sessions": sessions,
|
|
1706
|
+
"sessionWallClockMs": sum(
|
|
1707
|
+
int(item["wallClockMs"]) for item in sessions
|
|
1708
|
+
),
|
|
1108
1709
|
"postArchiveEventsExcluded": int(post_archive_excluded),
|
|
1109
|
-
"totalMinutesSemantics": "active-
|
|
1710
|
+
"totalMinutesSemantics": "workflow-wall-primary; active-is-separate",
|
|
1110
1711
|
}
|
|
1111
1712
|
|
|
1112
1713
|
|
|
@@ -1630,17 +2231,60 @@ def check_status(
|
|
|
1630
2231
|
candidate_codes = {
|
|
1631
2232
|
"PRODUCT_CI_NOT_GREEN",
|
|
1632
2233
|
"PRODUCT_CANDIDATE_NOT_VERIFIED",
|
|
2234
|
+
"PRODUCT_CANDIDATE_RECORD_ONLY",
|
|
2235
|
+
"PROJECT_RELEASE_POLICY_BLOCKED",
|
|
2236
|
+
"REMOTE_CI_DOWNGRADE_REFUSED",
|
|
1633
2237
|
}
|
|
1634
2238
|
archive_integrity_ok = not any(
|
|
1635
2239
|
item.get("code") not in candidate_codes for item in blockers
|
|
1636
2240
|
)
|
|
2241
|
+
archive_integrity = {
|
|
2242
|
+
"ok": archive_integrity_ok,
|
|
2243
|
+
"code": (
|
|
2244
|
+
"ARCHIVE_INTEGRITY_OK"
|
|
2245
|
+
if archive_integrity_ok
|
|
2246
|
+
else "ARCHIVE_INTEGRITY_FAILED"
|
|
2247
|
+
),
|
|
2248
|
+
"message": (
|
|
2249
|
+
"archive preconditions are complete"
|
|
2250
|
+
if archive_integrity_ok
|
|
2251
|
+
else "archive preconditions contain non-candidate blockers"
|
|
2252
|
+
),
|
|
2253
|
+
}
|
|
2254
|
+
try:
|
|
2255
|
+
release_summary = collect_summary_data(
|
|
2256
|
+
change_dir, write=False, for_replay=False
|
|
2257
|
+
)
|
|
2258
|
+
release_summary["archiveIntent"] = archive_intent
|
|
2259
|
+
report_adequacy = validate_report_adequacy(release_summary)
|
|
2260
|
+
release_decision = evaluate_release_eligibility(
|
|
2261
|
+
change_dir,
|
|
2262
|
+
release_summary,
|
|
2263
|
+
archive_integrity=archive_integrity,
|
|
2264
|
+
report_adequacy=report_adequacy,
|
|
2265
|
+
)
|
|
2266
|
+
except Exception as exc: # noqa: BLE001 — status must remain read-only
|
|
2267
|
+
release_decision = compose_release_decision(
|
|
2268
|
+
{
|
|
2269
|
+
"archiveIntegrity": archive_integrity,
|
|
2270
|
+
"reportAdequacy": {
|
|
2271
|
+
"ok": False,
|
|
2272
|
+
"code": "REPORT_ADEQUACY_UNAVAILABLE",
|
|
2273
|
+
"message": str(exc),
|
|
2274
|
+
},
|
|
2275
|
+
"candidateVerification": ci_gate,
|
|
2276
|
+
}
|
|
2277
|
+
)
|
|
1637
2278
|
return {
|
|
1638
2279
|
"ok": True,
|
|
1639
2280
|
"archivable": archivable,
|
|
1640
2281
|
"archiveIntent": archive_intent,
|
|
1641
|
-
"archiveIntegrity":
|
|
1642
|
-
"candidateVerification":
|
|
1643
|
-
|
|
2282
|
+
"archiveIntegrity": archive_integrity,
|
|
2283
|
+
"candidateVerification": release_decision["checks"][
|
|
2284
|
+
"candidateVerification"
|
|
2285
|
+
],
|
|
2286
|
+
"releaseDecision": release_decision,
|
|
2287
|
+
"releaseEligible": release_decision["releaseEligible"],
|
|
1644
2288
|
"change_dir": str(change_dir),
|
|
1645
2289
|
"change_name": infer_change_name(change_dir),
|
|
1646
2290
|
"blockers": blockers,
|
|
@@ -2888,6 +3532,7 @@ def collect_summary_data(
|
|
|
2888
3532
|
ci_metrics, ci_metrics_source = load_ci_metrics(change_dir)
|
|
2889
3533
|
if ci_metrics_source:
|
|
2890
3534
|
sources.append(ci_metrics_source)
|
|
3535
|
+
remote_cost = build_remote_cost_summary(ci_metrics)
|
|
2891
3536
|
|
|
2892
3537
|
log_text = load_execution_log(change_dir)
|
|
2893
3538
|
if log_text:
|
|
@@ -2908,6 +3553,7 @@ def collect_summary_data(
|
|
|
2908
3553
|
"diffStat",
|
|
2909
3554
|
"stageStatus",
|
|
2910
3555
|
"durations",
|
|
3556
|
+
"timing",
|
|
2911
3557
|
"skillCalls",
|
|
2912
3558
|
"verification",
|
|
2913
3559
|
"timeline",
|
|
@@ -2922,10 +3568,24 @@ def collect_summary_data(
|
|
|
2922
3568
|
"archiveIntent",
|
|
2923
3569
|
"archiveIntegrity",
|
|
2924
3570
|
"candidateVerification",
|
|
3571
|
+
"releaseDecision",
|
|
2925
3572
|
"releaseEligible",
|
|
3573
|
+
"productCommit",
|
|
3574
|
+
"featureMergeHash",
|
|
3575
|
+
"releaseTipHash",
|
|
3576
|
+
"productTreeHash",
|
|
3577
|
+
"archiveCommit",
|
|
3578
|
+
"environmentHash",
|
|
3579
|
+
"changeIdentity",
|
|
3580
|
+
"artifactStorage",
|
|
3581
|
+
"retention",
|
|
3582
|
+
"remoteCost",
|
|
3583
|
+
"projection",
|
|
2926
3584
|
):
|
|
2927
3585
|
if key in existing:
|
|
2928
3586
|
data[key] = _deepcopy_json(existing[key])
|
|
3587
|
+
if not for_replay or not isinstance(data.get("remoteCost"), dict):
|
|
3588
|
+
data["remoteCost"] = remote_cost
|
|
2929
3589
|
|
|
2930
3590
|
event_summary = he.build_summary(change_dir, events) if events else {
|
|
2931
3591
|
"ok": True,
|
|
@@ -3037,7 +3697,6 @@ def collect_summary_data(
|
|
|
3037
3697
|
)
|
|
3038
3698
|
data["archiveIntegrity"] = {"ok": True}
|
|
3039
3699
|
data["candidateVerification"] = candidate_gate
|
|
3040
|
-
data["releaseEligible"] = bool(candidate_gate.get("ok"))
|
|
3041
3700
|
|
|
3042
3701
|
# durations / skillCalls
|
|
3043
3702
|
if events:
|
|
@@ -3098,6 +3757,7 @@ def collect_summary_data(
|
|
|
3098
3757
|
data["releaseTipHash"] = identity.get("releaseTipHash") or data.get("archiveCommit")
|
|
3099
3758
|
data["productTreeHash"] = identity.get("productTreeHash")
|
|
3100
3759
|
data["archiveCommit"] = identity.get("archiveCommit") or data.get("finalCommit")
|
|
3760
|
+
data["environmentHash"] = identity.get("environmentHash") or NOT_AVAILABLE
|
|
3101
3761
|
data["changeIdentity"] = identity
|
|
3102
3762
|
|
|
3103
3763
|
# diffStat / changedFiles
|
|
@@ -3188,6 +3848,37 @@ def collect_summary_data(
|
|
|
3188
3848
|
data["artifacts"] = _artifacts_from_events(
|
|
3189
3849
|
projected_events, change_dir=change_dir
|
|
3190
3850
|
)
|
|
3851
|
+
storage_path = change_dir / "evidence" / "artifact-storage.json"
|
|
3852
|
+
if storage_path.is_file():
|
|
3853
|
+
storage = read_json(storage_path)
|
|
3854
|
+
data["artifactStorage"] = (
|
|
3855
|
+
storage if isinstance(storage, dict) else {}
|
|
3856
|
+
)
|
|
3857
|
+
sources.append("evidence/artifact-storage.json")
|
|
3858
|
+
else:
|
|
3859
|
+
data["artifactStorage"] = {
|
|
3860
|
+
"schemaVersion": 1,
|
|
3861
|
+
"artifactCount": 0,
|
|
3862
|
+
"bytesAdded": 0,
|
|
3863
|
+
"bytesReused": 0,
|
|
3864
|
+
"bytesPruned": 0,
|
|
3865
|
+
"largestItems": [],
|
|
3866
|
+
}
|
|
3867
|
+
retention = build_retention_audit(change_dir)
|
|
3868
|
+
data["retention"] = retention
|
|
3869
|
+
if write:
|
|
3870
|
+
write_json(
|
|
3871
|
+
change_dir / "evidence" / "retention-audit.json",
|
|
3872
|
+
retention,
|
|
3873
|
+
)
|
|
3874
|
+
data["projection"] = hgate.evaluate_projection_gate(
|
|
3875
|
+
project,
|
|
3876
|
+
"archive",
|
|
3877
|
+
)
|
|
3878
|
+
else:
|
|
3879
|
+
data.setdefault("artifactStorage", {})
|
|
3880
|
+
data.setdefault("retention", {})
|
|
3881
|
+
data.setdefault("projection", {})
|
|
3191
3882
|
|
|
3192
3883
|
data["reviewSummary"] = _review_summary(
|
|
3193
3884
|
change_dir,
|
|
@@ -3287,6 +3978,35 @@ def collect_summary_data(
|
|
|
3287
3978
|
"validationIssues": [],
|
|
3288
3979
|
}
|
|
3289
3980
|
|
|
3981
|
+
report_adequacy = validate_report_adequacy(data)
|
|
3982
|
+
checksum_status = str(
|
|
3983
|
+
(data.get("archiveManifest") or {}).get("checksumStatus") or ""
|
|
3984
|
+
)
|
|
3985
|
+
archive_integrity = {
|
|
3986
|
+
"ok": checksum_status in {"OK", "OK_WITH_EXCLUSIONS"},
|
|
3987
|
+
"code": (
|
|
3988
|
+
"ARCHIVE_INTEGRITY_OK"
|
|
3989
|
+
if checksum_status in {"OK", "OK_WITH_EXCLUSIONS"}
|
|
3990
|
+
else "ARCHIVE_INTEGRITY_FAILED"
|
|
3991
|
+
),
|
|
3992
|
+
"message": f"checksumStatus={checksum_status or 'missing'}",
|
|
3993
|
+
"checksumStatus": checksum_status,
|
|
3994
|
+
}
|
|
3995
|
+
release_decision = evaluate_release_eligibility(
|
|
3996
|
+
change_dir,
|
|
3997
|
+
data,
|
|
3998
|
+
archive_integrity=archive_integrity,
|
|
3999
|
+
report_adequacy=report_adequacy,
|
|
4000
|
+
)
|
|
4001
|
+
data["archiveIntegrity"] = archive_integrity
|
|
4002
|
+
data["candidateVerification"] = release_decision["checks"][
|
|
4003
|
+
"candidateVerification"
|
|
4004
|
+
]
|
|
4005
|
+
data["releaseDecision"] = release_decision
|
|
4006
|
+
data["releaseEligible"] = release_decision["releaseEligible"]
|
|
4007
|
+
data["reportPipeline"]["reportAdequacy"] = report_adequacy
|
|
4008
|
+
data["reportPipeline"]["releaseDecision"] = release_decision
|
|
4009
|
+
|
|
3290
4010
|
# Fill any remaining template placeholders that look like instructions
|
|
3291
4011
|
for key in list(data.keys()):
|
|
3292
4012
|
val = data[key]
|
|
@@ -3345,6 +4065,54 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
|
|
|
3345
4065
|
parts.append("</ul></div>")
|
|
3346
4066
|
if summary.get("riskTier"):
|
|
3347
4067
|
parts.append(f"<p><strong>riskTier</strong>: {esc(summary.get('riskTier'))}</p>")
|
|
4068
|
+
parts.append("<h3>Current Outcome</h3>")
|
|
4069
|
+
parts.append(
|
|
4070
|
+
f"<p>finalStatus={esc(summary.get('finalStatus'))}</p>"
|
|
4071
|
+
)
|
|
4072
|
+
|
|
4073
|
+
candidate = (
|
|
4074
|
+
summary.get("candidateVerification")
|
|
4075
|
+
if isinstance(summary.get("candidateVerification"), dict)
|
|
4076
|
+
else {}
|
|
4077
|
+
)
|
|
4078
|
+
parts.append("<h3>Candidate Claim / Attestation</h3>")
|
|
4079
|
+
parts.append(
|
|
4080
|
+
f"<p>assurance={esc(candidate.get('assurance'))} "
|
|
4081
|
+
f"code={esc(candidate.get('code'))} "
|
|
4082
|
+
f"provider={esc(candidate.get('provider'))}</p>"
|
|
4083
|
+
)
|
|
4084
|
+
|
|
4085
|
+
integrity = (
|
|
4086
|
+
summary.get("archiveIntegrity")
|
|
4087
|
+
if isinstance(summary.get("archiveIntegrity"), dict)
|
|
4088
|
+
else {}
|
|
4089
|
+
)
|
|
4090
|
+
parts.append("<h3>Archive Integrity</h3>")
|
|
4091
|
+
parts.append(
|
|
4092
|
+
f"<p>ok={esc(integrity.get('ok'))} "
|
|
4093
|
+
f"code={esc(integrity.get('code'))} "
|
|
4094
|
+
f"checksumStatus={esc(integrity.get('checksumStatus'))}</p>"
|
|
4095
|
+
)
|
|
4096
|
+
|
|
4097
|
+
decision = (
|
|
4098
|
+
summary.get("releaseDecision")
|
|
4099
|
+
if isinstance(summary.get("releaseDecision"), dict)
|
|
4100
|
+
else {}
|
|
4101
|
+
)
|
|
4102
|
+
parts.append("<h3>Release Eligibility</h3>")
|
|
4103
|
+
parts.append(
|
|
4104
|
+
f"<p>releaseEligible={esc(decision.get('releaseEligible'))} "
|
|
4105
|
+
f"code={esc(decision.get('code'))}</p><ul>"
|
|
4106
|
+
)
|
|
4107
|
+
checks = decision.get("checks")
|
|
4108
|
+
checks = checks if isinstance(checks, dict) else {}
|
|
4109
|
+
for name, check in checks.items():
|
|
4110
|
+
check = check if isinstance(check, dict) else {}
|
|
4111
|
+
parts.append(
|
|
4112
|
+
f"<li>{esc(name)}: ok={esc(check.get('ok'))} "
|
|
4113
|
+
f"code={esc(check.get('code'))}</li>"
|
|
4114
|
+
)
|
|
4115
|
+
parts.append("</ul>")
|
|
3348
4116
|
|
|
3349
4117
|
timing = summary.get("timing") if isinstance(summary.get("timing"), dict) else {}
|
|
3350
4118
|
if timing:
|
|
@@ -3356,6 +4124,16 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
|
|
|
3356
4124
|
f"workflowWallClock={esc(timing.get('workflowWallClockMs'))}"
|
|
3357
4125
|
"</p>"
|
|
3358
4126
|
)
|
|
4127
|
+
parts.append(
|
|
4128
|
+
"<p id=\"timingConservation\">"
|
|
4129
|
+
f"attributedStageUnionMs={esc(timing.get('attributedStageUnionMs'))} · "
|
|
4130
|
+
f"externalWaitMs={esc(timing.get('externalWaitMs'))} · "
|
|
4131
|
+
f"pausedMs={esc(timing.get('pausedMs'))} · "
|
|
4132
|
+
"agentOrToolUnattributedMs="
|
|
4133
|
+
f"{esc(timing.get('agentOrToolUnattributedMs'))} · "
|
|
4134
|
+
f"conservationDeltaMs={esc(timing.get('conservationDeltaMs'))}"
|
|
4135
|
+
"</p>"
|
|
4136
|
+
)
|
|
3359
4137
|
parts.append(
|
|
3360
4138
|
f"<p><strong>reportCutoffAt</strong>: "
|
|
3361
4139
|
f"<span id=\"reportCutoffAt\">{esc(timing.get('reportCutoffAt'))}</span></p>"
|
|
@@ -3364,6 +4142,59 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
|
|
|
3364
4142
|
"<p><small>durations.totalMinutes is active-only; "
|
|
3365
4143
|
"do not treat it as workflow wall clock.</small></p>"
|
|
3366
4144
|
)
|
|
4145
|
+
parts.append("<h3>History Quality</h3><ul>")
|
|
4146
|
+
attempts = timing.get("attempts")
|
|
4147
|
+
attempts = attempts if isinstance(attempts, list) else []
|
|
4148
|
+
for attempt in attempts:
|
|
4149
|
+
attempt = attempt if isinstance(attempt, dict) else {}
|
|
4150
|
+
parts.append(
|
|
4151
|
+
f"<li>{esc(attempt.get('phase'))}: "
|
|
4152
|
+
f"{esc(attempt.get('terminalStatus') or attempt.get('status'))} "
|
|
4153
|
+
f"durationMs={esc(attempt.get('durationMs'))}</li>"
|
|
4154
|
+
)
|
|
4155
|
+
if not attempts:
|
|
4156
|
+
parts.append("<li>no typed attempts recorded</li>")
|
|
4157
|
+
parts.append("</ul>")
|
|
4158
|
+
|
|
4159
|
+
remote_cost = (
|
|
4160
|
+
summary.get("remoteCost")
|
|
4161
|
+
if isinstance(summary.get("remoteCost"), dict)
|
|
4162
|
+
else {}
|
|
4163
|
+
)
|
|
4164
|
+
remote_totals = remote_cost.get("totals")
|
|
4165
|
+
remote_totals = remote_totals if isinstance(remote_totals, dict) else {}
|
|
4166
|
+
parts.append("<h3>Remote Cost</h3>")
|
|
4167
|
+
parts.append(
|
|
4168
|
+
f"<p>runnerMinutes={esc(remote_totals.get('runnerMinutes'))} "
|
|
4169
|
+
f"queueWaitMs={esc(remote_totals.get('queueWaitMs'))} "
|
|
4170
|
+
f"artifactBytes={esc(remote_totals.get('artifactBytes'))} "
|
|
4171
|
+
f"duplicateRunCount={esc(remote_totals.get('duplicateRunCount'))}</p>"
|
|
4172
|
+
)
|
|
4173
|
+
|
|
4174
|
+
storage = (
|
|
4175
|
+
summary.get("artifactStorage")
|
|
4176
|
+
if isinstance(summary.get("artifactStorage"), dict)
|
|
4177
|
+
else {}
|
|
4178
|
+
)
|
|
4179
|
+
parts.append("<h3>Artifact Storage</h3>")
|
|
4180
|
+
parts.append(
|
|
4181
|
+
f"<p>artifactCount={esc(storage.get('artifactCount'))} "
|
|
4182
|
+
f"bytesAdded={esc(storage.get('bytesAdded'))} "
|
|
4183
|
+
f"bytesReused={esc(storage.get('bytesReused'))} "
|
|
4184
|
+
f"bytesPruned={esc(storage.get('bytesPruned'))}</p>"
|
|
4185
|
+
)
|
|
4186
|
+
|
|
4187
|
+
projection = (
|
|
4188
|
+
summary.get("projection")
|
|
4189
|
+
if isinstance(summary.get("projection"), dict)
|
|
4190
|
+
else {}
|
|
4191
|
+
)
|
|
4192
|
+
parts.append("<h3>Projection / Fallback</h3>")
|
|
4193
|
+
parts.append(
|
|
4194
|
+
f"<p>mode={esc(projection.get('mode'))} "
|
|
4195
|
+
f"code={esc(projection.get('code'))} "
|
|
4196
|
+
f"remediation={esc(projection.get('remediation'))}</p>"
|
|
4197
|
+
)
|
|
3367
4198
|
|
|
3368
4199
|
if summary.get("productCommit") or summary.get("productTreeHash"):
|
|
3369
4200
|
parts.append("<h3>Identity</h3>")
|
|
@@ -3611,7 +4442,7 @@ def _freeze_evidence_cutoff(work_dir: Path) -> dict[str, Any]:
|
|
|
3611
4442
|
|
|
3612
4443
|
def validate_artifact_immutability(entries: list[dict[str, Any]]) -> dict[str, Any]:
|
|
3613
4444
|
"""Same artifact path with two different hashes -> conflict (UT-003/RET-07)."""
|
|
3614
|
-
issues: list[dict[str,
|
|
4445
|
+
issues: list[dict[str, Any]] = []
|
|
3615
4446
|
seen: dict[str, str] = {}
|
|
3616
4447
|
for item in entries:
|
|
3617
4448
|
if not isinstance(item, dict):
|
|
@@ -4055,67 +4886,740 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
|
|
|
4055
4886
|
except (TypeError, ValueError):
|
|
4056
4887
|
files_changed = 0
|
|
4057
4888
|
|
|
4058
|
-
base_missing = not base or base == NOT_AVAILABLE
|
|
4059
|
-
final_present = bool(
|
|
4060
|
-
(final and final != NOT_AVAILABLE) or (merge_final and merge_final != NOT_AVAILABLE)
|
|
4889
|
+
base_missing = not base or base == NOT_AVAILABLE
|
|
4890
|
+
final_present = bool(
|
|
4891
|
+
(final and final != NOT_AVAILABLE) or (merge_final and merge_final != NOT_AVAILABLE)
|
|
4892
|
+
)
|
|
4893
|
+
if final_present and base_missing:
|
|
4894
|
+
issues.append({
|
|
4895
|
+
"code": "IDENTITY_BASE_MISSING",
|
|
4896
|
+
"severity": "error",
|
|
4897
|
+
"message": (
|
|
4898
|
+
"final/merge identity present but baseCommit is empty; "
|
|
4899
|
+
"refuse CONDITIONAL_OK+ without a resolved base"
|
|
4900
|
+
),
|
|
4901
|
+
})
|
|
4902
|
+
|
|
4903
|
+
if (
|
|
4904
|
+
base
|
|
4905
|
+
and final
|
|
4906
|
+
and base != NOT_AVAILABLE
|
|
4907
|
+
and final != NOT_AVAILABLE
|
|
4908
|
+
and base != final
|
|
4909
|
+
and files_changed == 0
|
|
4910
|
+
):
|
|
4911
|
+
issues.append({
|
|
4912
|
+
"code": "DIFF_ZERO_WITH_NONEMPTY_COMMIT",
|
|
4913
|
+
"severity": "error",
|
|
4914
|
+
"message": f"final commit {final} differs from base {base} but filesChanged=0",
|
|
4915
|
+
})
|
|
4916
|
+
|
|
4917
|
+
# Typed metrics missing despite test report artifacts present.
|
|
4918
|
+
verification = summary.get("verification") or {}
|
|
4919
|
+
if isinstance(verification, dict):
|
|
4920
|
+
unit = verification.get("unitTests") or {}
|
|
4921
|
+
api = verification.get("apiTests") or {}
|
|
4922
|
+
artifacts = summary.get("artifacts") or []
|
|
4923
|
+
has_test_report = any(
|
|
4924
|
+
isinstance(a, dict) and "test" in str(a.get("path") or "").lower()
|
|
4925
|
+
for a in artifacts
|
|
4926
|
+
) if isinstance(artifacts, list) else False
|
|
4927
|
+
unit_pass = int(unit.get("passed") or 0) if isinstance(unit, dict) else 0
|
|
4928
|
+
api_status = str(api.get("status") or "") if isinstance(api, dict) else ""
|
|
4929
|
+
if has_test_report and unit_pass == 0 and api_status in {"", "not_available"}:
|
|
4930
|
+
issues.append({
|
|
4931
|
+
"code": "TYPED_METRICS_MISSING",
|
|
4932
|
+
"severity": "error",
|
|
4933
|
+
"message": "test report artifacts present but unitTests/apiTests typed metrics are empty",
|
|
4934
|
+
})
|
|
4935
|
+
|
|
4936
|
+
# stageStatus contradicts event reducer.
|
|
4937
|
+
stage = summary.get("stageStatus") or {}
|
|
4938
|
+
stage_from_events = summary.get("stageStatusFromEvents") or {}
|
|
4939
|
+
if isinstance(stage, dict) and isinstance(stage_from_events, dict):
|
|
4940
|
+
for phase, status in stage.items():
|
|
4941
|
+
event_status = stage_from_events.get(phase)
|
|
4942
|
+
if event_status is not None and str(status) != str(event_status):
|
|
4943
|
+
issues.append({
|
|
4944
|
+
"code": "STAGE_STATUS_CONTRADICTION",
|
|
4945
|
+
"severity": "error",
|
|
4946
|
+
"message": f"stageStatus.{phase}={status} but event reducer says {event_status}",
|
|
4947
|
+
})
|
|
4948
|
+
|
|
4949
|
+
# Candidate receipt and summary must describe the same immutable subject.
|
|
4950
|
+
candidate_gate = summary.get("candidateVerification")
|
|
4951
|
+
candidate_gate = candidate_gate if isinstance(candidate_gate, dict) else {}
|
|
4952
|
+
candidate = candidate_gate.get("evidence")
|
|
4953
|
+
candidate = candidate if isinstance(candidate, dict) else {}
|
|
4954
|
+
subject = candidate.get("subject")
|
|
4955
|
+
subject = subject if isinstance(subject, dict) else {}
|
|
4956
|
+
|
|
4957
|
+
summary_commit = str(summary.get("productCommit") or "").strip()
|
|
4958
|
+
candidate_commit = str(subject.get("productCommit") or "").strip()
|
|
4959
|
+
if (
|
|
4960
|
+
summary_commit
|
|
4961
|
+
and candidate_commit
|
|
4962
|
+
and summary_commit != candidate_commit
|
|
4963
|
+
):
|
|
4964
|
+
issues.append(
|
|
4965
|
+
{
|
|
4966
|
+
"code": "CANDIDATE_SUMMARY_COMMIT_MISMATCH",
|
|
4967
|
+
"severity": "error",
|
|
4968
|
+
"message": (
|
|
4969
|
+
"candidate productCommit does not match summary productCommit"
|
|
4970
|
+
),
|
|
4971
|
+
"expected": summary_commit,
|
|
4972
|
+
"actual": candidate_commit,
|
|
4973
|
+
}
|
|
4974
|
+
)
|
|
4975
|
+
|
|
4976
|
+
normalize_hash = lambda value: str(value or "").strip().removeprefix("sha256:")
|
|
4977
|
+
summary_tree = str(summary.get("productTreeHash") or "").strip()
|
|
4978
|
+
candidate_tree = str(subject.get("productTreeHash") or "").strip()
|
|
4979
|
+
if (
|
|
4980
|
+
summary_tree
|
|
4981
|
+
and candidate_tree
|
|
4982
|
+
and normalize_hash(summary_tree) != normalize_hash(candidate_tree)
|
|
4983
|
+
):
|
|
4984
|
+
issues.append(
|
|
4985
|
+
{
|
|
4986
|
+
"code": "CANDIDATE_SUMMARY_TREE_MISMATCH",
|
|
4987
|
+
"severity": "error",
|
|
4988
|
+
"message": (
|
|
4989
|
+
"candidate productTreeHash does not match summary productTreeHash"
|
|
4990
|
+
),
|
|
4991
|
+
"expected": summary_tree,
|
|
4992
|
+
"actual": candidate_tree,
|
|
4993
|
+
}
|
|
4994
|
+
)
|
|
4995
|
+
|
|
4996
|
+
summary_environment = str(summary.get("environmentHash") or "").strip()
|
|
4997
|
+
candidate_environment = str(subject.get("environmentHash") or "").strip()
|
|
4998
|
+
if (
|
|
4999
|
+
summary_environment
|
|
5000
|
+
and summary_environment != NOT_AVAILABLE
|
|
5001
|
+
and candidate_environment
|
|
5002
|
+
and normalize_hash(summary_environment)
|
|
5003
|
+
!= normalize_hash(candidate_environment)
|
|
5004
|
+
):
|
|
5005
|
+
issues.append(
|
|
5006
|
+
{
|
|
5007
|
+
"code": "CANDIDATE_SUMMARY_ENVIRONMENT_MISMATCH",
|
|
5008
|
+
"severity": "error",
|
|
5009
|
+
"message": (
|
|
5010
|
+
"candidate environmentHash does not match summary environmentHash"
|
|
5011
|
+
),
|
|
5012
|
+
"expected": summary_environment,
|
|
5013
|
+
"actual": candidate_environment,
|
|
5014
|
+
}
|
|
5015
|
+
)
|
|
5016
|
+
|
|
5017
|
+
return {"ok": not issues, "issues": issues}
|
|
5018
|
+
|
|
5019
|
+
|
|
5020
|
+
RELEASE_CHECKS = (
|
|
5021
|
+
"archiveIntegrity",
|
|
5022
|
+
"reportAdequacy",
|
|
5023
|
+
"candidateVerification",
|
|
5024
|
+
"candidateIdentity",
|
|
5025
|
+
"projectReleasePolicy",
|
|
5026
|
+
"terminalAttempts",
|
|
5027
|
+
"finalStatus",
|
|
5028
|
+
)
|
|
5029
|
+
|
|
5030
|
+
|
|
5031
|
+
def validate_parallel_isolation_contract(
|
|
5032
|
+
child_change_ids: list[str],
|
|
5033
|
+
isolation: Any,
|
|
5034
|
+
) -> dict[str, Any]:
|
|
5035
|
+
"""Require unique worktree, port, DB, temp root, and writer lease per child."""
|
|
5036
|
+
required = (
|
|
5037
|
+
"worktreeRoot",
|
|
5038
|
+
"port",
|
|
5039
|
+
"database",
|
|
5040
|
+
"tempRoot",
|
|
5041
|
+
"writerLease",
|
|
5042
|
+
)
|
|
5043
|
+
mapping = isolation if isinstance(isolation, dict) else {}
|
|
5044
|
+
issues: list[dict[str, Any]] = []
|
|
5045
|
+
expected = set(child_change_ids)
|
|
5046
|
+
actual = {str(key) for key in mapping}
|
|
5047
|
+
if expected != actual:
|
|
5048
|
+
issues.append(
|
|
5049
|
+
{
|
|
5050
|
+
"code": "PARALLEL_ISOLATION_MEMBERSHIP_MISMATCH",
|
|
5051
|
+
"message": "childIsolation must describe every covered child exactly once",
|
|
5052
|
+
"missing": sorted(expected - actual),
|
|
5053
|
+
"unexpected": sorted(actual - expected),
|
|
5054
|
+
}
|
|
5055
|
+
)
|
|
5056
|
+
|
|
5057
|
+
def _identity(field: str, value: Any) -> str:
|
|
5058
|
+
text = str(value).strip()
|
|
5059
|
+
if field in {"worktreeRoot", "tempRoot"}:
|
|
5060
|
+
return text.replace("\\", "/").rstrip("/").casefold()
|
|
5061
|
+
return text.casefold()
|
|
5062
|
+
|
|
5063
|
+
for field in required:
|
|
5064
|
+
owners: dict[str, list[str]] = {}
|
|
5065
|
+
for child_id in sorted(expected):
|
|
5066
|
+
row = mapping.get(child_id)
|
|
5067
|
+
row = row if isinstance(row, dict) else {}
|
|
5068
|
+
value = row.get(field)
|
|
5069
|
+
identity = _identity(field, value)
|
|
5070
|
+
if not identity:
|
|
5071
|
+
issues.append(
|
|
5072
|
+
{
|
|
5073
|
+
"code": "PARALLEL_ISOLATION_FIELD_MISSING",
|
|
5074
|
+
"message": f"{child_id} is missing isolation field {field}",
|
|
5075
|
+
"childChangeId": child_id,
|
|
5076
|
+
"field": field,
|
|
5077
|
+
}
|
|
5078
|
+
)
|
|
5079
|
+
continue
|
|
5080
|
+
owners.setdefault(identity, []).append(child_id)
|
|
5081
|
+
for value, children in owners.items():
|
|
5082
|
+
if len(children) > 1:
|
|
5083
|
+
issues.append(
|
|
5084
|
+
{
|
|
5085
|
+
"code": "PARALLEL_ISOLATION_COLLISION",
|
|
5086
|
+
"message": (
|
|
5087
|
+
f"parallel children share {field}; cross-write risk detected"
|
|
5088
|
+
),
|
|
5089
|
+
"field": field,
|
|
5090
|
+
"value": value,
|
|
5091
|
+
"children": children,
|
|
5092
|
+
}
|
|
5093
|
+
)
|
|
5094
|
+
return {
|
|
5095
|
+
"ok": not issues,
|
|
5096
|
+
"code": (
|
|
5097
|
+
"PARALLEL_ISOLATION_OK"
|
|
5098
|
+
if not issues
|
|
5099
|
+
else "PARALLEL_ISOLATION_INVALID"
|
|
5100
|
+
),
|
|
5101
|
+
"issues": issues,
|
|
5102
|
+
"children": sorted(expected),
|
|
5103
|
+
}
|
|
5104
|
+
|
|
5105
|
+
|
|
5106
|
+
def validate_aggregate_candidate_contract(
|
|
5107
|
+
contract: dict[str, Any],
|
|
5108
|
+
*,
|
|
5109
|
+
child_change_id: str | None = None,
|
|
5110
|
+
candidate_subject: dict[str, Any] | None = None,
|
|
5111
|
+
) -> dict[str, Any]:
|
|
5112
|
+
"""Validate aggregate/child/integration membership as one identity contract."""
|
|
5113
|
+
issues: list[dict[str, Any]] = []
|
|
5114
|
+
if contract.get("schemaVersion") != 1:
|
|
5115
|
+
issues.append(
|
|
5116
|
+
{
|
|
5117
|
+
"code": "AGGREGATE_SCHEMA_INVALID",
|
|
5118
|
+
"message": "aggregate candidate schemaVersion must be 1",
|
|
5119
|
+
}
|
|
5120
|
+
)
|
|
5121
|
+
if contract.get("candidateScope") != "aggregate":
|
|
5122
|
+
issues.append(
|
|
5123
|
+
{
|
|
5124
|
+
"code": "AGGREGATE_SCOPE_INVALID",
|
|
5125
|
+
"message": "candidateScope must be aggregate",
|
|
5126
|
+
}
|
|
5127
|
+
)
|
|
5128
|
+
aggregate_change_id = str(contract.get("aggregateChangeId") or "").strip()
|
|
5129
|
+
if not aggregate_change_id:
|
|
5130
|
+
issues.append(
|
|
5131
|
+
{
|
|
5132
|
+
"code": "AGGREGATE_CHANGE_ID_MISSING",
|
|
5133
|
+
"message": "aggregateChangeId is required",
|
|
5134
|
+
}
|
|
5135
|
+
)
|
|
5136
|
+
|
|
5137
|
+
raw_children = contract.get("coveredChildChanges")
|
|
5138
|
+
children = (
|
|
5139
|
+
[str(item).strip() for item in raw_children]
|
|
5140
|
+
if isinstance(raw_children, list)
|
|
5141
|
+
else []
|
|
5142
|
+
)
|
|
5143
|
+
if (
|
|
5144
|
+
not children
|
|
5145
|
+
or any(not item for item in children)
|
|
5146
|
+
or len(set(children)) != len(children)
|
|
5147
|
+
):
|
|
5148
|
+
issues.append(
|
|
5149
|
+
{
|
|
5150
|
+
"code": "AGGREGATE_CHILDREN_INVALID",
|
|
5151
|
+
"message": "coveredChildChanges must be non-empty and unique",
|
|
5152
|
+
}
|
|
5153
|
+
)
|
|
5154
|
+
commits = contract.get("childProductCommits")
|
|
5155
|
+
commits = commits if isinstance(commits, dict) else {}
|
|
5156
|
+
child_set = set(children)
|
|
5157
|
+
commit_set = {str(key) for key in commits}
|
|
5158
|
+
if child_set != commit_set or any(
|
|
5159
|
+
not isinstance(value, str) or not value.strip()
|
|
5160
|
+
for value in commits.values()
|
|
5161
|
+
):
|
|
5162
|
+
issues.append(
|
|
5163
|
+
{
|
|
5164
|
+
"code": "AGGREGATE_CHILD_MEMBERSHIP_MISMATCH",
|
|
5165
|
+
"message": (
|
|
5166
|
+
"childProductCommits must map every covered child exactly once"
|
|
5167
|
+
),
|
|
5168
|
+
"missing": sorted(child_set - commit_set),
|
|
5169
|
+
"unexpected": sorted(commit_set - child_set),
|
|
5170
|
+
}
|
|
5171
|
+
)
|
|
5172
|
+
if child_change_id is not None and child_change_id not in child_set:
|
|
5173
|
+
issues.append(
|
|
5174
|
+
{
|
|
5175
|
+
"code": "AGGREGATE_CHILD_NOT_COVERED",
|
|
5176
|
+
"message": f"child change is not covered: {child_change_id}",
|
|
5177
|
+
}
|
|
5178
|
+
)
|
|
5179
|
+
|
|
5180
|
+
integration_commit = str(
|
|
5181
|
+
contract.get("integrationProductCommit") or ""
|
|
5182
|
+
).strip()
|
|
5183
|
+
subject_commit = str(
|
|
5184
|
+
(candidate_subject or {}).get("productCommit") or ""
|
|
5185
|
+
).strip()
|
|
5186
|
+
if not integration_commit:
|
|
5187
|
+
issues.append(
|
|
5188
|
+
{
|
|
5189
|
+
"code": "AGGREGATE_INTEGRATION_COMMIT_MISSING",
|
|
5190
|
+
"message": "integrationProductCommit is required",
|
|
5191
|
+
}
|
|
5192
|
+
)
|
|
5193
|
+
elif subject_commit and integration_commit != subject_commit:
|
|
5194
|
+
issues.append(
|
|
5195
|
+
{
|
|
5196
|
+
"code": "AGGREGATE_INTEGRATION_COMMIT_MISMATCH",
|
|
5197
|
+
"message": (
|
|
5198
|
+
"integrationProductCommit does not match candidate subject"
|
|
5199
|
+
),
|
|
5200
|
+
"expected": subject_commit,
|
|
5201
|
+
"actual": integration_commit,
|
|
5202
|
+
}
|
|
5203
|
+
)
|
|
5204
|
+
|
|
5205
|
+
coverage = contract.get("coverageProof")
|
|
5206
|
+
coverage = coverage if isinstance(coverage, dict) else {}
|
|
5207
|
+
digest = str(coverage.get("digest") or "")
|
|
5208
|
+
if not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest):
|
|
5209
|
+
issues.append(
|
|
5210
|
+
{
|
|
5211
|
+
"code": "AGGREGATE_COVERAGE_PROOF_INVALID",
|
|
5212
|
+
"message": "coverageProof.digest must be a sha256 digest",
|
|
5213
|
+
}
|
|
5214
|
+
)
|
|
5215
|
+
if not str(coverage.get("source") or "").strip():
|
|
5216
|
+
issues.append(
|
|
5217
|
+
{
|
|
5218
|
+
"code": "AGGREGATE_COVERAGE_SOURCE_MISSING",
|
|
5219
|
+
"message": "coverageProof.source is required",
|
|
5220
|
+
}
|
|
5221
|
+
)
|
|
5222
|
+
parallel_isolation = None
|
|
5223
|
+
if (
|
|
5224
|
+
contract.get("concurrencyMode") == "isolated-multi-active"
|
|
5225
|
+
or "childIsolation" in contract
|
|
5226
|
+
):
|
|
5227
|
+
parallel_isolation = validate_parallel_isolation_contract(
|
|
5228
|
+
children,
|
|
5229
|
+
contract.get("childIsolation"),
|
|
5230
|
+
)
|
|
5231
|
+
issues.extend(parallel_isolation.get("issues") or [])
|
|
5232
|
+
|
|
5233
|
+
primary_code = (
|
|
5234
|
+
"AGGREGATE_CANDIDATE_OK"
|
|
5235
|
+
if not issues
|
|
5236
|
+
else (
|
|
5237
|
+
"AGGREGATE_CHILD_MEMBERSHIP_MISMATCH"
|
|
5238
|
+
if any(
|
|
5239
|
+
issue["code"] == "AGGREGATE_CHILD_MEMBERSHIP_MISMATCH"
|
|
5240
|
+
for issue in issues
|
|
5241
|
+
)
|
|
5242
|
+
else "AGGREGATE_CANDIDATE_INVALID"
|
|
5243
|
+
)
|
|
5244
|
+
)
|
|
5245
|
+
return {
|
|
5246
|
+
"ok": not issues,
|
|
5247
|
+
"code": primary_code,
|
|
5248
|
+
"message": (
|
|
5249
|
+
"aggregate candidate covers every child and matches integration identity"
|
|
5250
|
+
if not issues
|
|
5251
|
+
else f"aggregate candidate has {len(issues)} issue(s)"
|
|
5252
|
+
),
|
|
5253
|
+
"aggregateChangeId": aggregate_change_id,
|
|
5254
|
+
"coveredChildChanges": children,
|
|
5255
|
+
"parallelIsolation": parallel_isolation,
|
|
5256
|
+
"issues": issues,
|
|
5257
|
+
}
|
|
5258
|
+
|
|
5259
|
+
|
|
5260
|
+
def compose_release_decision(
|
|
5261
|
+
checks: dict[str, Any],
|
|
5262
|
+
) -> dict[str, Any]:
|
|
5263
|
+
"""Compose the only release-eligibility formula used by the archive."""
|
|
5264
|
+
normalized: dict[str, dict[str, Any]] = {}
|
|
5265
|
+
issues: list[dict[str, Any]] = []
|
|
5266
|
+
for name in RELEASE_CHECKS:
|
|
5267
|
+
value = checks.get(name)
|
|
5268
|
+
item = dict(value) if isinstance(value, dict) else {
|
|
5269
|
+
"ok": False,
|
|
5270
|
+
"code": "RELEASE_CHECK_MISSING",
|
|
5271
|
+
"message": f"required release check missing: {name}",
|
|
5272
|
+
}
|
|
5273
|
+
item["ok"] = bool(item.get("ok"))
|
|
5274
|
+
normalized[name] = item
|
|
5275
|
+
if not item["ok"]:
|
|
5276
|
+
issues.append(
|
|
5277
|
+
{
|
|
5278
|
+
"check": name,
|
|
5279
|
+
"code": str(
|
|
5280
|
+
item.get("code")
|
|
5281
|
+
or f"{name.upper()}_BLOCKED"
|
|
5282
|
+
),
|
|
5283
|
+
"message": str(
|
|
5284
|
+
item.get("message")
|
|
5285
|
+
or f"release check failed: {name}"
|
|
5286
|
+
),
|
|
5287
|
+
}
|
|
5288
|
+
)
|
|
5289
|
+
eligible = all(normalized[name]["ok"] for name in RELEASE_CHECKS)
|
|
5290
|
+
return {
|
|
5291
|
+
"ok": eligible,
|
|
5292
|
+
"releaseEligible": eligible,
|
|
5293
|
+
"code": (
|
|
5294
|
+
"RELEASE_ELIGIBLE"
|
|
5295
|
+
if eligible
|
|
5296
|
+
else "RELEASE_NOT_ELIGIBLE"
|
|
5297
|
+
),
|
|
5298
|
+
"checks": normalized,
|
|
5299
|
+
"issues": issues,
|
|
5300
|
+
}
|
|
5301
|
+
|
|
5302
|
+
|
|
5303
|
+
def _candidate_identity_gate(
|
|
5304
|
+
change_dir: Path,
|
|
5305
|
+
summary: dict[str, Any],
|
|
5306
|
+
candidate_gate: dict[str, Any],
|
|
5307
|
+
) -> dict[str, Any]:
|
|
5308
|
+
evidence = candidate_gate.get("evidence")
|
|
5309
|
+
evidence = evidence if isinstance(evidence, dict) else {}
|
|
5310
|
+
subject = evidence.get("subject")
|
|
5311
|
+
subject = subject if isinstance(subject, dict) else {}
|
|
5312
|
+
issues: list[dict[str, Any]] = []
|
|
5313
|
+
|
|
5314
|
+
pairs = (
|
|
5315
|
+
(
|
|
5316
|
+
"productCommit",
|
|
5317
|
+
str(summary.get("productCommit") or "").strip(),
|
|
5318
|
+
str(subject.get("productCommit") or "").strip(),
|
|
5319
|
+
False,
|
|
5320
|
+
),
|
|
5321
|
+
(
|
|
5322
|
+
"productTreeHash",
|
|
5323
|
+
str(summary.get("productTreeHash") or "").strip(),
|
|
5324
|
+
str(subject.get("productTreeHash") or "").strip(),
|
|
5325
|
+
True,
|
|
5326
|
+
),
|
|
5327
|
+
(
|
|
5328
|
+
"environmentHash",
|
|
5329
|
+
str(summary.get("environmentHash") or "").strip(),
|
|
5330
|
+
str(subject.get("environmentHash") or "").strip(),
|
|
5331
|
+
True,
|
|
5332
|
+
),
|
|
5333
|
+
)
|
|
5334
|
+
for field, expected, actual, is_hash in pairs:
|
|
5335
|
+
if not expected or expected == NOT_AVAILABLE or not actual:
|
|
5336
|
+
issues.append(
|
|
5337
|
+
{
|
|
5338
|
+
"code": f"CANDIDATE_{field.upper()}_MISSING",
|
|
5339
|
+
"message": (
|
|
5340
|
+
f"candidate/summary identity field missing: {field}"
|
|
5341
|
+
),
|
|
5342
|
+
"expected": expected,
|
|
5343
|
+
"actual": actual,
|
|
5344
|
+
}
|
|
5345
|
+
)
|
|
5346
|
+
continue
|
|
5347
|
+
left = expected.removeprefix("sha256:") if is_hash else expected
|
|
5348
|
+
right = actual.removeprefix("sha256:") if is_hash else actual
|
|
5349
|
+
if left != right:
|
|
5350
|
+
issues.append(
|
|
5351
|
+
{
|
|
5352
|
+
"code": f"CANDIDATE_{field.upper()}_MISMATCH",
|
|
5353
|
+
"message": (
|
|
5354
|
+
f"candidate {field} does not match summary {field}"
|
|
5355
|
+
),
|
|
5356
|
+
"expected": expected,
|
|
5357
|
+
"actual": actual,
|
|
5358
|
+
}
|
|
5359
|
+
)
|
|
5360
|
+
|
|
5361
|
+
product_identity = validate_product_identity(
|
|
5362
|
+
product_commit=str(summary.get("productCommit") or ""),
|
|
5363
|
+
product_tree_hash=str(summary.get("productTreeHash") or ""),
|
|
5364
|
+
archive_commit=str(summary.get("archiveCommit") or ""),
|
|
5365
|
+
project=find_project_root(change_dir),
|
|
5366
|
+
)
|
|
5367
|
+
if not product_identity.get("ok"):
|
|
5368
|
+
issues.append(
|
|
5369
|
+
{
|
|
5370
|
+
"code": str(
|
|
5371
|
+
product_identity.get("code")
|
|
5372
|
+
or "PRODUCT_IDENTITY_INVALID"
|
|
5373
|
+
),
|
|
5374
|
+
"message": str(
|
|
5375
|
+
product_identity.get("message")
|
|
5376
|
+
or "product/archive Git identity is invalid"
|
|
5377
|
+
),
|
|
5378
|
+
"detail": product_identity,
|
|
5379
|
+
}
|
|
5380
|
+
)
|
|
5381
|
+
aggregate_contract = evidence.get("aggregateCandidate")
|
|
5382
|
+
if not isinstance(aggregate_contract, dict):
|
|
5383
|
+
aggregate_contract = evidence.get("aggregate")
|
|
5384
|
+
if not isinstance(aggregate_contract, dict) and (
|
|
5385
|
+
evidence.get("candidateScope") == "aggregate"
|
|
5386
|
+
):
|
|
5387
|
+
aggregate_contract = evidence
|
|
5388
|
+
aggregate_identity = None
|
|
5389
|
+
if isinstance(aggregate_contract, dict):
|
|
5390
|
+
aggregate_identity = validate_aggregate_candidate_contract(
|
|
5391
|
+
aggregate_contract,
|
|
5392
|
+
candidate_subject=subject,
|
|
5393
|
+
)
|
|
5394
|
+
if not aggregate_identity.get("ok"):
|
|
5395
|
+
issues.append(
|
|
5396
|
+
{
|
|
5397
|
+
"code": str(
|
|
5398
|
+
aggregate_identity.get("code")
|
|
5399
|
+
or "AGGREGATE_CANDIDATE_INVALID"
|
|
5400
|
+
),
|
|
5401
|
+
"message": str(
|
|
5402
|
+
aggregate_identity.get("message")
|
|
5403
|
+
or "aggregate candidate identity is invalid"
|
|
5404
|
+
),
|
|
5405
|
+
"detail": aggregate_identity,
|
|
5406
|
+
}
|
|
5407
|
+
)
|
|
5408
|
+
return {
|
|
5409
|
+
"ok": not issues,
|
|
5410
|
+
"code": (
|
|
5411
|
+
"CANDIDATE_IDENTITY_OK"
|
|
5412
|
+
if not issues
|
|
5413
|
+
else "CANDIDATE_IDENTITY_INVALID"
|
|
5414
|
+
),
|
|
5415
|
+
"message": (
|
|
5416
|
+
"candidate, summary, environment, and Git identities agree"
|
|
5417
|
+
if not issues
|
|
5418
|
+
else f"candidate identity has {len(issues)} issue(s)"
|
|
5419
|
+
),
|
|
5420
|
+
"issues": issues,
|
|
5421
|
+
"productIdentity": product_identity,
|
|
5422
|
+
"aggregateIdentity": aggregate_identity,
|
|
5423
|
+
}
|
|
5424
|
+
|
|
5425
|
+
|
|
5426
|
+
def _project_release_policy_gate(
|
|
5427
|
+
change_dir: Path,
|
|
5428
|
+
summary: dict[str, Any],
|
|
5429
|
+
candidate_gate: dict[str, Any],
|
|
5430
|
+
) -> dict[str, Any]:
|
|
5431
|
+
policy = load_gate_policy(change_dir) or {}
|
|
5432
|
+
candidate_policy = policy.get("candidateVerification")
|
|
5433
|
+
candidate_policy = (
|
|
5434
|
+
candidate_policy if isinstance(candidate_policy, dict) else {}
|
|
4061
5435
|
)
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
5436
|
+
release_policy = policy.get("releasePolicy")
|
|
5437
|
+
release_policy = (
|
|
5438
|
+
release_policy if isinstance(release_policy, dict) else {}
|
|
5439
|
+
)
|
|
5440
|
+
projection = hgate.evaluate_projection_gate(
|
|
5441
|
+
find_project_root(change_dir),
|
|
5442
|
+
"archive",
|
|
5443
|
+
)
|
|
5444
|
+
if not projection.get("ok"):
|
|
5445
|
+
return {
|
|
5446
|
+
"ok": False,
|
|
5447
|
+
"code": str(
|
|
5448
|
+
projection.get("code") or "PROJECTION_DEGRADED_BLOCKED"
|
|
5449
|
+
),
|
|
5450
|
+
"message": str(
|
|
5451
|
+
projection.get("message")
|
|
5452
|
+
or "projection receipt blocks release"
|
|
5453
|
+
),
|
|
5454
|
+
"projection": projection,
|
|
5455
|
+
}
|
|
5456
|
+
if str(summary.get("archiveIntent") or "") == "record-only":
|
|
5457
|
+
return {
|
|
5458
|
+
"ok": False,
|
|
5459
|
+
"code": "RECORD_ONLY_ARCHIVE",
|
|
5460
|
+
"message": "record-only archive cannot authorize release",
|
|
5461
|
+
"remoteRequired": bool(candidate_policy.get("remoteRequired")),
|
|
5462
|
+
}
|
|
5463
|
+
minimum = str(
|
|
5464
|
+
candidate_policy.get("minimumAssurance")
|
|
5465
|
+
or "local-reproducible"
|
|
5466
|
+
).strip()
|
|
5467
|
+
remote_required = bool(candidate_policy.get("remoteRequired")) or (
|
|
5468
|
+
minimum == "remote-attested"
|
|
5469
|
+
)
|
|
5470
|
+
assurance = str(candidate_gate.get("assurance") or "").strip()
|
|
5471
|
+
if remote_required and assurance != "remote-attested":
|
|
5472
|
+
return {
|
|
5473
|
+
"ok": False,
|
|
5474
|
+
"code": "REMOTE_ATTESTATION_REQUIRED",
|
|
4066
5475
|
"message": (
|
|
4067
|
-
"
|
|
4068
|
-
"refuse CONDITIONAL_OK+ without a resolved base"
|
|
5476
|
+
f"project requires remote-attested evidence; got {assurance or 'none'}"
|
|
4069
5477
|
),
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
if (
|
|
4073
|
-
|
|
4074
|
-
and final
|
|
4075
|
-
and base != NOT_AVAILABLE
|
|
4076
|
-
and final != NOT_AVAILABLE
|
|
4077
|
-
and base != final
|
|
4078
|
-
and files_changed == 0
|
|
5478
|
+
"remoteRequired": True,
|
|
5479
|
+
}
|
|
5480
|
+
if assurance == "local-reproducible" and not bool(
|
|
5481
|
+
candidate_policy.get("allowLocalRelease")
|
|
4079
5482
|
):
|
|
4080
|
-
|
|
4081
|
-
"
|
|
4082
|
-
"
|
|
4083
|
-
"message":
|
|
4084
|
-
|
|
5483
|
+
return {
|
|
5484
|
+
"ok": False,
|
|
5485
|
+
"code": "LOCAL_RELEASE_NOT_AUTHORIZED",
|
|
5486
|
+
"message": (
|
|
5487
|
+
"local release requires "
|
|
5488
|
+
"candidateVerification.allowLocalRelease=true"
|
|
5489
|
+
),
|
|
5490
|
+
"remoteRequired": remote_required,
|
|
5491
|
+
}
|
|
5492
|
+
evidence = candidate_gate.get("evidence")
|
|
5493
|
+
evidence = evidence if isinstance(evidence, dict) else {}
|
|
5494
|
+
subject = evidence.get("subject")
|
|
5495
|
+
subject = subject if isinstance(subject, dict) else {}
|
|
5496
|
+
final_sequence = hphase.evaluate_final_sequence(
|
|
5497
|
+
change_dir,
|
|
5498
|
+
{
|
|
5499
|
+
"productCommit": str(subject.get("productCommit") or ""),
|
|
5500
|
+
"productTreeHash": str(subject.get("productTreeHash") or ""),
|
|
5501
|
+
"environmentHash": str(subject.get("environmentHash") or ""),
|
|
5502
|
+
},
|
|
5503
|
+
exclude_nodes={"sequence:archive"},
|
|
5504
|
+
)
|
|
5505
|
+
if not final_sequence.get("ok"):
|
|
5506
|
+
return {
|
|
5507
|
+
"ok": False,
|
|
5508
|
+
"code": "FINAL_SEQUENCE_INCOMPLETE",
|
|
5509
|
+
"message": str(
|
|
5510
|
+
final_sequence.get("message")
|
|
5511
|
+
or "required final sequence is incomplete"
|
|
5512
|
+
),
|
|
5513
|
+
"remoteRequired": remote_required,
|
|
5514
|
+
"finalSequence": final_sequence,
|
|
5515
|
+
}
|
|
5516
|
+
return {
|
|
5517
|
+
"ok": True,
|
|
5518
|
+
"code": "PROJECT_RELEASE_POLICY_OK",
|
|
5519
|
+
"message": "candidate evidence satisfies project release policy",
|
|
5520
|
+
"remoteRequired": remote_required,
|
|
5521
|
+
"minimumAssurance": minimum,
|
|
5522
|
+
"allowedFinalStatuses": release_policy.get(
|
|
5523
|
+
"allowedFinalStatuses", ["OK"]
|
|
5524
|
+
),
|
|
5525
|
+
"finalSequence": final_sequence,
|
|
5526
|
+
"projection": projection,
|
|
5527
|
+
}
|
|
4085
5528
|
|
|
4086
|
-
# Typed metrics missing despite test report artifacts present.
|
|
4087
|
-
verification = summary.get("verification") or {}
|
|
4088
|
-
if isinstance(verification, dict):
|
|
4089
|
-
unit = verification.get("unitTests") or {}
|
|
4090
|
-
api = verification.get("apiTests") or {}
|
|
4091
|
-
artifacts = summary.get("artifacts") or []
|
|
4092
|
-
has_test_report = any(
|
|
4093
|
-
isinstance(a, dict) and "test" in str(a.get("path") or "").lower()
|
|
4094
|
-
for a in artifacts
|
|
4095
|
-
) if isinstance(artifacts, list) else False
|
|
4096
|
-
unit_pass = int(unit.get("passed") or 0) if isinstance(unit, dict) else 0
|
|
4097
|
-
api_status = str(api.get("status") or "") if isinstance(api, dict) else ""
|
|
4098
|
-
if has_test_report and unit_pass == 0 and api_status in {"", "not_available"}:
|
|
4099
|
-
issues.append({
|
|
4100
|
-
"code": "TYPED_METRICS_MISSING",
|
|
4101
|
-
"severity": "error",
|
|
4102
|
-
"message": "test report artifacts present but unitTests/apiTests typed metrics are empty",
|
|
4103
|
-
})
|
|
4104
5529
|
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
5530
|
+
def _terminal_attempts_gate(summary: dict[str, Any]) -> dict[str, Any]:
|
|
5531
|
+
timing = summary.get("timing")
|
|
5532
|
+
timing = timing if isinstance(timing, dict) else {}
|
|
5533
|
+
unclosed = int(timing.get("unclosedAttemptCount") or 0)
|
|
5534
|
+
attempts = timing.get("attempts")
|
|
5535
|
+
attempts = attempts if isinstance(attempts, list) else []
|
|
5536
|
+
blocking_statuses = {
|
|
5537
|
+
"INCOMPLETE",
|
|
5538
|
+
"INCOMPLETE_AT_CUTOFF",
|
|
5539
|
+
"ORPHANED",
|
|
5540
|
+
"INTERRUPTED",
|
|
5541
|
+
}
|
|
5542
|
+
blocking = [
|
|
5543
|
+
item
|
|
5544
|
+
for item in attempts
|
|
5545
|
+
if isinstance(item, dict)
|
|
5546
|
+
and str(item.get("terminalStatus") or item.get("status") or "").upper()
|
|
5547
|
+
in blocking_statuses
|
|
5548
|
+
]
|
|
5549
|
+
ok = unclosed == 0 and not blocking
|
|
5550
|
+
return {
|
|
5551
|
+
"ok": ok,
|
|
5552
|
+
"code": (
|
|
5553
|
+
"TERMINAL_ATTEMPTS_OK"
|
|
5554
|
+
if ok
|
|
5555
|
+
else "TERMINAL_ATTEMPTS_INCOMPLETE"
|
|
5556
|
+
),
|
|
5557
|
+
"message": (
|
|
5558
|
+
"all release-critical attempts are terminal"
|
|
5559
|
+
if ok
|
|
5560
|
+
else (
|
|
5561
|
+
f"unclosedAttemptCount={unclosed} "
|
|
5562
|
+
f"blockingAttempts={len(blocking)}"
|
|
5563
|
+
)
|
|
5564
|
+
),
|
|
5565
|
+
"unclosedAttemptCount": unclosed,
|
|
5566
|
+
"blockingAttempts": blocking,
|
|
5567
|
+
}
|
|
4117
5568
|
|
|
4118
|
-
|
|
5569
|
+
|
|
5570
|
+
def evaluate_release_eligibility(
|
|
5571
|
+
change_dir: Path,
|
|
5572
|
+
summary: dict[str, Any],
|
|
5573
|
+
*,
|
|
5574
|
+
archive_integrity: dict[str, Any],
|
|
5575
|
+
report_adequacy: dict[str, Any],
|
|
5576
|
+
) -> dict[str, Any]:
|
|
5577
|
+
"""Evaluate the complete fail-closed release decision."""
|
|
5578
|
+
change_dir = change_dir.resolve()
|
|
5579
|
+
candidate_gate = evaluate_product_ci_gate(change_dir)
|
|
5580
|
+
candidate_identity = _candidate_identity_gate(
|
|
5581
|
+
change_dir, summary, candidate_gate
|
|
5582
|
+
)
|
|
5583
|
+
project_policy = _project_release_policy_gate(
|
|
5584
|
+
change_dir, summary, candidate_gate
|
|
5585
|
+
)
|
|
5586
|
+
gate_policy = load_gate_policy(change_dir) or {}
|
|
5587
|
+
release_policy = gate_policy.get("releasePolicy")
|
|
5588
|
+
release_policy = (
|
|
5589
|
+
release_policy if isinstance(release_policy, dict) else {}
|
|
5590
|
+
)
|
|
5591
|
+
allowed_statuses = release_policy.get("allowedFinalStatuses")
|
|
5592
|
+
if not isinstance(allowed_statuses, list) or not allowed_statuses:
|
|
5593
|
+
allowed_statuses = ["OK"]
|
|
5594
|
+
allowed_statuses = [
|
|
5595
|
+
str(item).upper() for item in allowed_statuses if str(item).strip()
|
|
5596
|
+
]
|
|
5597
|
+
final_status = str(summary.get("finalStatus") or "").upper()
|
|
5598
|
+
final_status_gate = {
|
|
5599
|
+
"ok": final_status in allowed_statuses,
|
|
5600
|
+
"code": (
|
|
5601
|
+
"FINAL_STATUS_ALLOWED"
|
|
5602
|
+
if final_status in allowed_statuses
|
|
5603
|
+
else "FINAL_STATUS_BLOCKED"
|
|
5604
|
+
),
|
|
5605
|
+
"message": (
|
|
5606
|
+
f"finalStatus={final_status or 'missing'} "
|
|
5607
|
+
f"allowed={allowed_statuses}"
|
|
5608
|
+
),
|
|
5609
|
+
"actual": final_status,
|
|
5610
|
+
"allowed": allowed_statuses,
|
|
5611
|
+
}
|
|
5612
|
+
return compose_release_decision(
|
|
5613
|
+
{
|
|
5614
|
+
"archiveIntegrity": archive_integrity,
|
|
5615
|
+
"reportAdequacy": report_adequacy,
|
|
5616
|
+
"candidateVerification": candidate_gate,
|
|
5617
|
+
"candidateIdentity": candidate_identity,
|
|
5618
|
+
"projectReleasePolicy": project_policy,
|
|
5619
|
+
"terminalAttempts": _terminal_attempts_gate(summary),
|
|
5620
|
+
"finalStatus": final_status_gate,
|
|
5621
|
+
}
|
|
5622
|
+
)
|
|
4119
5623
|
|
|
4120
5624
|
|
|
4121
5625
|
def artifact_preflight(change_dir: Path) -> dict[str, Any]:
|
|
@@ -4210,12 +5714,202 @@ def artifact_preflight(change_dir: Path) -> dict[str, Any]:
|
|
|
4210
5714
|
return {"ok": not blocking, "items": items, "blocking": blocking}
|
|
4211
5715
|
|
|
4212
5716
|
|
|
5717
|
+
def _artifact_policy(project_root: Path) -> dict[str, Any]:
|
|
5718
|
+
path = (
|
|
5719
|
+
project_root
|
|
5720
|
+
/ ".harness"
|
|
5721
|
+
/ "config"
|
|
5722
|
+
/ "artifact-policy.json"
|
|
5723
|
+
)
|
|
5724
|
+
defaults = {
|
|
5725
|
+
"schemaVersion": 1,
|
|
5726
|
+
"maxTotalBytes": 512 * 1024 * 1024,
|
|
5727
|
+
"maxFileBytes": 128 * 1024 * 1024,
|
|
5728
|
+
"contentAddressedMinBytes": 0,
|
|
5729
|
+
"runtimeTtlDays": 7,
|
|
5730
|
+
"stagingTtlDays": 2,
|
|
5731
|
+
}
|
|
5732
|
+
if not path.is_file():
|
|
5733
|
+
return {**defaults, "source": "defaults", "path": str(path)}
|
|
5734
|
+
value = read_json(path)
|
|
5735
|
+
if value.get("schemaVersion") != 1:
|
|
5736
|
+
raise ValueError("artifact policy schemaVersion must be 1")
|
|
5737
|
+
result = {**defaults, **value, "source": "project", "path": str(path)}
|
|
5738
|
+
for field in (
|
|
5739
|
+
"maxTotalBytes",
|
|
5740
|
+
"maxFileBytes",
|
|
5741
|
+
"contentAddressedMinBytes",
|
|
5742
|
+
"runtimeTtlDays",
|
|
5743
|
+
"stagingTtlDays",
|
|
5744
|
+
):
|
|
5745
|
+
if not isinstance(result.get(field), int) or int(result[field]) < 0:
|
|
5746
|
+
raise ValueError(f"artifact policy {field} must be non-negative")
|
|
5747
|
+
return result
|
|
5748
|
+
|
|
5749
|
+
|
|
5750
|
+
def build_retention_audit(change_dir: Path) -> dict[str, Any]:
|
|
5751
|
+
"""Inventory expirable runtime/staging trees without deleting user data."""
|
|
5752
|
+
change_dir = change_dir.resolve()
|
|
5753
|
+
project_root = find_project_root(change_dir)
|
|
5754
|
+
policy = _artifact_policy(project_root)
|
|
5755
|
+
now = dt.datetime.now(dt.timezone.utc)
|
|
5756
|
+
roots = (
|
|
5757
|
+
(
|
|
5758
|
+
"runtime",
|
|
5759
|
+
change_dir / "runtime",
|
|
5760
|
+
int(policy["runtimeTtlDays"]),
|
|
5761
|
+
),
|
|
5762
|
+
(
|
|
5763
|
+
"archive-operation-staging",
|
|
5764
|
+
project_root / ".harness" / "archive-operations" / "staging",
|
|
5765
|
+
int(policy["stagingTtlDays"]),
|
|
5766
|
+
),
|
|
5767
|
+
)
|
|
5768
|
+
items: list[dict[str, Any]] = []
|
|
5769
|
+
for category, root, ttl_days in roots:
|
|
5770
|
+
if not root.is_dir():
|
|
5771
|
+
continue
|
|
5772
|
+
for path in sorted(root.iterdir(), key=lambda item: item.name):
|
|
5773
|
+
try:
|
|
5774
|
+
modified = dt.datetime.fromtimestamp(
|
|
5775
|
+
path.stat().st_mtime,
|
|
5776
|
+
tz=dt.timezone.utc,
|
|
5777
|
+
)
|
|
5778
|
+
bytes_on_disk = sum(
|
|
5779
|
+
item.stat().st_size
|
|
5780
|
+
for item in ([path] if path.is_file() else path.rglob("*"))
|
|
5781
|
+
if item.is_file()
|
|
5782
|
+
)
|
|
5783
|
+
except OSError:
|
|
5784
|
+
continue
|
|
5785
|
+
age_seconds = max(0.0, (now - modified).total_seconds())
|
|
5786
|
+
expired = age_seconds >= ttl_days * 24 * 60 * 60
|
|
5787
|
+
try:
|
|
5788
|
+
relative = path.relative_to(project_root).as_posix()
|
|
5789
|
+
except ValueError:
|
|
5790
|
+
relative = str(path)
|
|
5791
|
+
items.append(
|
|
5792
|
+
{
|
|
5793
|
+
"category": category,
|
|
5794
|
+
"path": relative,
|
|
5795
|
+
"modifiedAt": modified.isoformat().replace("+00:00", "Z"),
|
|
5796
|
+
"ageDays": round(age_seconds / (24 * 60 * 60), 3),
|
|
5797
|
+
"ttlDays": ttl_days,
|
|
5798
|
+
"bytes": bytes_on_disk,
|
|
5799
|
+
"status": "EXPIRED" if expired else "RETAINED",
|
|
5800
|
+
}
|
|
5801
|
+
)
|
|
5802
|
+
expired_items = [item for item in items if item["status"] == "EXPIRED"]
|
|
5803
|
+
return {
|
|
5804
|
+
"schemaVersion": 1,
|
|
5805
|
+
"generatedAt": now.isoformat().replace("+00:00", "Z"),
|
|
5806
|
+
"mode": "audit-only",
|
|
5807
|
+
"policy": {
|
|
5808
|
+
"runtimeTtlDays": int(policy["runtimeTtlDays"]),
|
|
5809
|
+
"stagingTtlDays": int(policy["stagingTtlDays"]),
|
|
5810
|
+
"source": policy["source"],
|
|
5811
|
+
},
|
|
5812
|
+
"items": items,
|
|
5813
|
+
"expiredCount": len(expired_items),
|
|
5814
|
+
"bytesPrunable": sum(int(item["bytes"]) for item in expired_items),
|
|
5815
|
+
"remediation": (
|
|
5816
|
+
"remove only expired entries after confirming no active capsule, "
|
|
5817
|
+
"lease, worktree, or archive operation references them"
|
|
5818
|
+
),
|
|
5819
|
+
}
|
|
5820
|
+
|
|
5821
|
+
|
|
5822
|
+
def evaluate_artifact_budget(change_dir: Path) -> dict[str, Any]:
|
|
5823
|
+
"""Fail closed on archive size before the staging copy is created."""
|
|
5824
|
+
change_dir = change_dir.resolve()
|
|
5825
|
+
project_root = find_project_root(change_dir)
|
|
5826
|
+
try:
|
|
5827
|
+
policy = _artifact_policy(project_root)
|
|
5828
|
+
preflight = artifact_preflight(change_dir)
|
|
5829
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
5830
|
+
return {
|
|
5831
|
+
"ok": False,
|
|
5832
|
+
"code": "ARTIFACT_POLICY_INVALID",
|
|
5833
|
+
"message": str(exc),
|
|
5834
|
+
}
|
|
5835
|
+
excluded_parts = {
|
|
5836
|
+
"node_modules",
|
|
5837
|
+
".venv",
|
|
5838
|
+
"venv",
|
|
5839
|
+
"__pycache__",
|
|
5840
|
+
".cache",
|
|
5841
|
+
"integration-temp",
|
|
5842
|
+
}
|
|
5843
|
+
files: dict[str, Path] = {}
|
|
5844
|
+
for path in sorted(change_dir.rglob("*")):
|
|
5845
|
+
if (
|
|
5846
|
+
not path.is_file()
|
|
5847
|
+
or any(part in excluded_parts for part in path.parts)
|
|
5848
|
+
or path.name.endswith(".lock")
|
|
5849
|
+
):
|
|
5850
|
+
continue
|
|
5851
|
+
files[path.relative_to(change_dir).as_posix()] = path
|
|
5852
|
+
for item in preflight.get("items") or []:
|
|
5853
|
+
if item.get("category") != "repository-file":
|
|
5854
|
+
continue
|
|
5855
|
+
source = (project_root / str(item["path"])).resolve()
|
|
5856
|
+
if source.is_file() and source.is_relative_to(project_root):
|
|
5857
|
+
files[f"artifacts/product/{item['path']}"] = source
|
|
5858
|
+
entries = [
|
|
5859
|
+
{
|
|
5860
|
+
"path": relative,
|
|
5861
|
+
"bytes": path.stat().st_size,
|
|
5862
|
+
}
|
|
5863
|
+
for relative, path in files.items()
|
|
5864
|
+
]
|
|
5865
|
+
entries.sort(key=lambda item: (-int(item["bytes"]), str(item["path"])))
|
|
5866
|
+
total = sum(int(item["bytes"]) for item in entries)
|
|
5867
|
+
max_total = int(policy["maxTotalBytes"])
|
|
5868
|
+
max_file = int(policy["maxFileBytes"])
|
|
5869
|
+
oversized = [
|
|
5870
|
+
item for item in entries if int(item["bytes"]) > max_file
|
|
5871
|
+
]
|
|
5872
|
+
ok = total <= max_total and not oversized and bool(preflight.get("ok"))
|
|
5873
|
+
return {
|
|
5874
|
+
"ok": ok,
|
|
5875
|
+
"code": (
|
|
5876
|
+
"ARTIFACT_BUDGET_OK"
|
|
5877
|
+
if ok
|
|
5878
|
+
else "ARTIFACT_BUDGET_EXCEEDED"
|
|
5879
|
+
),
|
|
5880
|
+
"message": (
|
|
5881
|
+
"archive artifacts fit the configured budget"
|
|
5882
|
+
if ok
|
|
5883
|
+
else "archive artifact budget exceeded before staging"
|
|
5884
|
+
),
|
|
5885
|
+
"artifactCount": len(entries),
|
|
5886
|
+
"totalBytes": total,
|
|
5887
|
+
"maxTotalBytes": max_total,
|
|
5888
|
+
"maxFileBytes": max_file,
|
|
5889
|
+
"oversized": oversized,
|
|
5890
|
+
"largestItems": entries[:10],
|
|
5891
|
+
"policy": policy,
|
|
5892
|
+
"preflightBlocking": preflight.get("blocking") or [],
|
|
5893
|
+
}
|
|
5894
|
+
|
|
5895
|
+
|
|
4213
5896
|
def materialize_repository_artifacts(change_dir: Path) -> dict[str, Any]:
|
|
4214
|
-
"""
|
|
5897
|
+
"""Materialize repository artifacts through a content-addressed object store."""
|
|
4215
5898
|
change_dir = change_dir.resolve()
|
|
4216
5899
|
project_root = find_project_root(change_dir)
|
|
4217
5900
|
preflight = artifact_preflight(change_dir)
|
|
4218
5901
|
copied: list[str] = []
|
|
5902
|
+
objects: list[dict[str, Any]] = []
|
|
5903
|
+
bytes_added = 0
|
|
5904
|
+
bytes_reused = 0
|
|
5905
|
+
object_root = (
|
|
5906
|
+
project_root
|
|
5907
|
+
/ ".harness"
|
|
5908
|
+
/ "cache"
|
|
5909
|
+
/ "artifacts"
|
|
5910
|
+
/ "objects"
|
|
5911
|
+
/ "sha256"
|
|
5912
|
+
)
|
|
4219
5913
|
for item in preflight.get("items") or []:
|
|
4220
5914
|
if item.get("category") != "repository-file":
|
|
4221
5915
|
continue
|
|
@@ -4223,10 +5917,72 @@ def materialize_repository_artifacts(change_dir: Path) -> dict[str, Any]:
|
|
|
4223
5917
|
target = (change_dir / str(item["archivePath"])).resolve()
|
|
4224
5918
|
if not source.is_relative_to(project_root) or not target.is_relative_to(change_dir):
|
|
4225
5919
|
raise ValueError(f"artifact materialization escaped boundary: {item['path']}")
|
|
5920
|
+
digest = sha256_file(source)
|
|
5921
|
+
size = source.stat().st_size
|
|
5922
|
+
blob = object_root / digest[:2] / digest
|
|
5923
|
+
reused = blob.is_file()
|
|
5924
|
+
if reused:
|
|
5925
|
+
if blob.stat().st_size != size or sha256_file(blob) != digest:
|
|
5926
|
+
raise ValueError(f"content-addressed blob corrupted: {blob}")
|
|
5927
|
+
bytes_reused += size
|
|
5928
|
+
else:
|
|
5929
|
+
blob.parent.mkdir(parents=True, exist_ok=True)
|
|
5930
|
+
temporary = blob.with_name(
|
|
5931
|
+
f".{blob.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp"
|
|
5932
|
+
)
|
|
5933
|
+
try:
|
|
5934
|
+
shutil.copy2(source, temporary)
|
|
5935
|
+
if sha256_file(temporary) != digest:
|
|
5936
|
+
raise ValueError(
|
|
5937
|
+
f"content-addressed copy hash mismatch: {source}"
|
|
5938
|
+
)
|
|
5939
|
+
os.replace(temporary, blob)
|
|
5940
|
+
finally:
|
|
5941
|
+
temporary.unlink(missing_ok=True)
|
|
5942
|
+
bytes_added += size
|
|
4226
5943
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
4227
|
-
|
|
5944
|
+
target_preexisting = target.exists()
|
|
5945
|
+
materialization = "existing"
|
|
5946
|
+
if target_preexisting:
|
|
5947
|
+
if target.stat().st_size != size or sha256_file(target) != digest:
|
|
5948
|
+
raise ValueError(f"artifact target conflicts: {target}")
|
|
5949
|
+
else:
|
|
5950
|
+
try:
|
|
5951
|
+
os.link(blob, target)
|
|
5952
|
+
materialization = "hardlink"
|
|
5953
|
+
except OSError:
|
|
5954
|
+
shutil.copy2(blob, target)
|
|
5955
|
+
materialization = "copy"
|
|
4228
5956
|
copied.append(target.relative_to(change_dir).as_posix())
|
|
4229
|
-
|
|
5957
|
+
objects.append(
|
|
5958
|
+
{
|
|
5959
|
+
"path": target.relative_to(change_dir).as_posix(),
|
|
5960
|
+
"sha256": "sha256:" + digest,
|
|
5961
|
+
"bytes": size,
|
|
5962
|
+
"blob": blob.relative_to(project_root).as_posix(),
|
|
5963
|
+
"reused": reused,
|
|
5964
|
+
"materialization": materialization,
|
|
5965
|
+
}
|
|
5966
|
+
)
|
|
5967
|
+
result = {
|
|
5968
|
+
"ok": True,
|
|
5969
|
+
"artifactCount": len(objects),
|
|
5970
|
+
"bytesAdded": bytes_added,
|
|
5971
|
+
"bytesReused": bytes_reused,
|
|
5972
|
+
"bytesPruned": 0,
|
|
5973
|
+
"largestItems": sorted(
|
|
5974
|
+
objects,
|
|
5975
|
+
key=lambda entry: (-int(entry["bytes"]), str(entry["path"])),
|
|
5976
|
+
)[:10],
|
|
5977
|
+
"objects": objects,
|
|
5978
|
+
"copied": sorted(copied),
|
|
5979
|
+
}
|
|
5980
|
+
if objects:
|
|
5981
|
+
write_json(
|
|
5982
|
+
change_dir / "evidence" / "artifact-storage.json",
|
|
5983
|
+
{"schemaVersion": 1, **result, "recordedAt": now_iso()},
|
|
5984
|
+
)
|
|
5985
|
+
return result
|
|
4230
5986
|
|
|
4231
5987
|
|
|
4232
5988
|
def run_knowledge_poststeps(project_root: Path) -> dict[str, Any]:
|
|
@@ -4383,7 +6139,7 @@ def cmd_finalize(
|
|
|
4383
6139
|
)
|
|
4384
6140
|
operation_id = f"a-{uuid.uuid4().hex[:12]}"
|
|
4385
6141
|
operation_root = project_root / ".harness" / "archive-operations"
|
|
4386
|
-
operation_temp_dir = operation_root / "staging" / operation_id /
|
|
6142
|
+
operation_temp_dir = operation_root / "staging" / operation_id / change_dir.name
|
|
4387
6143
|
operation_record = operation_root / f"{operation_id}.json"
|
|
4388
6144
|
|
|
4389
6145
|
def _restore_finalize_failure() -> None:
|
|
@@ -4442,6 +6198,34 @@ def cmd_finalize(
|
|
|
4442
6198
|
if not exact_byte["ok"]:
|
|
4443
6199
|
warnings.append(str(exact_byte["remediation"]))
|
|
4444
6200
|
|
|
6201
|
+
artifact_budget = evaluate_artifact_budget(original_change_dir)
|
|
6202
|
+
payload["steps"]["artifact_budget"] = artifact_budget
|
|
6203
|
+
if not artifact_budget.get("ok"):
|
|
6204
|
+
payload["error"] = "artifact budget exceeded before staging"
|
|
6205
|
+
payload["issues"] = [
|
|
6206
|
+
{
|
|
6207
|
+
"code": str(
|
|
6208
|
+
artifact_budget.get("code")
|
|
6209
|
+
or "ARTIFACT_BUDGET_EXCEEDED"
|
|
6210
|
+
),
|
|
6211
|
+
"severity": "error",
|
|
6212
|
+
"message": str(
|
|
6213
|
+
artifact_budget.get("message")
|
|
6214
|
+
or "artifact budget exceeded"
|
|
6215
|
+
),
|
|
6216
|
+
}
|
|
6217
|
+
]
|
|
6218
|
+
payload["original_preserved"] = original_change_dir.is_dir()
|
|
6219
|
+
payload["finalStatus"] = "FAIL"
|
|
6220
|
+
_append_finalize_failure_terminal(
|
|
6221
|
+
split_state_dir
|
|
6222
|
+
if split_state_dir is not None and split_state_dir.is_dir()
|
|
6223
|
+
else original_change_dir,
|
|
6224
|
+
payload["error"],
|
|
6225
|
+
operation_id=operation_id,
|
|
6226
|
+
)
|
|
6227
|
+
return 1, payload
|
|
6228
|
+
|
|
4445
6229
|
payload["steps"]["service_stop_before_staging"] = run_service_stop(
|
|
4446
6230
|
original_change_dir
|
|
4447
6231
|
)
|
|
@@ -4635,7 +6419,7 @@ def cmd_finalize(
|
|
|
4635
6419
|
"message": ci_gate.get("message"),
|
|
4636
6420
|
}
|
|
4637
6421
|
payload["candidateVerification"] = ci_gate
|
|
4638
|
-
payload["releaseEligible"] =
|
|
6422
|
+
payload["releaseEligible"] = False
|
|
4639
6423
|
if not ci_gate.get("ok") and archive_intent == "release-candidate":
|
|
4640
6424
|
payload["error"] = str(ci_gate.get("message") or "PRODUCT_CI_NOT_GREEN")
|
|
4641
6425
|
payload["issues"] = [
|
|
@@ -4660,7 +6444,8 @@ def cmd_finalize(
|
|
|
4660
6444
|
"schemaVersion": 1,
|
|
4661
6445
|
"intent": archive_intent,
|
|
4662
6446
|
"candidateVerificationCode": ci_gate.get("code"),
|
|
4663
|
-
"releaseEligible":
|
|
6447
|
+
"releaseEligible": False,
|
|
6448
|
+
"releaseDecisionCode": "PENDING_IDENTITY_AND_REPORT_GATES",
|
|
4664
6449
|
"recordedAt": now_iso(),
|
|
4665
6450
|
},
|
|
4666
6451
|
)
|
|
@@ -4741,6 +6526,28 @@ def cmd_finalize(
|
|
|
4741
6526
|
write_json(summary_path, summary)
|
|
4742
6527
|
except OSError as exc:
|
|
4743
6528
|
warnings.append(f"could not write reportAdequacy: {exc}")
|
|
6529
|
+
release_decision = evaluate_release_eligibility(
|
|
6530
|
+
work_dir,
|
|
6531
|
+
summary,
|
|
6532
|
+
archive_integrity={
|
|
6533
|
+
"ok": True,
|
|
6534
|
+
"code": "ARCHIVE_INTEGRITY_PENDING_MANIFEST",
|
|
6535
|
+
"message": "pre-copy archive checks passed; checksum pending",
|
|
6536
|
+
},
|
|
6537
|
+
report_adequacy=adequacy_result,
|
|
6538
|
+
)
|
|
6539
|
+
summary["releaseDecision"] = release_decision
|
|
6540
|
+
summary["candidateVerification"] = release_decision["checks"][
|
|
6541
|
+
"candidateVerification"
|
|
6542
|
+
]
|
|
6543
|
+
summary["releaseEligible"] = release_decision["releaseEligible"]
|
|
6544
|
+
payload["releaseDecision"] = release_decision
|
|
6545
|
+
payload["candidateVerification"] = summary["candidateVerification"]
|
|
6546
|
+
payload["releaseEligible"] = summary["releaseEligible"]
|
|
6547
|
+
try:
|
|
6548
|
+
write_json(summary_path, summary)
|
|
6549
|
+
except OSError as exc:
|
|
6550
|
+
warnings.append(f"could not write releaseDecision: {exc}")
|
|
4744
6551
|
if not adequacy_result.get("ok"):
|
|
4745
6552
|
payload["issues"] = adequacy_result.get("issues") or []
|
|
4746
6553
|
payload["error"] = "report adequacy failed; staged operation discarded"
|
|
@@ -4748,6 +6555,17 @@ def cmd_finalize(
|
|
|
4748
6555
|
payload["warnings"] = warnings
|
|
4749
6556
|
payload["ok"] = False
|
|
4750
6557
|
return 1, payload
|
|
6558
|
+
if archive_intent == "release-candidate" and not release_decision[
|
|
6559
|
+
"releaseEligible"
|
|
6560
|
+
]:
|
|
6561
|
+
payload["issues"] = release_decision.get("issues") or []
|
|
6562
|
+
payload["error"] = (
|
|
6563
|
+
"release eligibility failed; staged operation discarded"
|
|
6564
|
+
)
|
|
6565
|
+
_restore_finalize_failure()
|
|
6566
|
+
payload["warnings"] = warnings
|
|
6567
|
+
payload["ok"] = False
|
|
6568
|
+
return 1, payload
|
|
4751
6569
|
|
|
4752
6570
|
# --- 6. render (Node, else Python fallback) ---
|
|
4753
6571
|
render_result = render_final_summary(work_dir, summary_path)
|
|
@@ -4870,6 +6688,50 @@ def cmd_finalize(
|
|
|
4870
6688
|
"exclusionReasons"
|
|
4871
6689
|
) or summary["archiveManifest"]["exclusionReasons"]
|
|
4872
6690
|
summary["archiveManifest"]["coverageChecked"] = coverage.get("checked")
|
|
6691
|
+
final_archive_integrity = {
|
|
6692
|
+
"ok": bool(coverage.get("ok"))
|
|
6693
|
+
and str(coverage.get("checksumStatus") or "")
|
|
6694
|
+
in {"OK", "OK_WITH_EXCLUSIONS"},
|
|
6695
|
+
"code": (
|
|
6696
|
+
"ARCHIVE_INTEGRITY_OK"
|
|
6697
|
+
if bool(coverage.get("ok"))
|
|
6698
|
+
and str(coverage.get("checksumStatus") or "")
|
|
6699
|
+
in {"OK", "OK_WITH_EXCLUSIONS"}
|
|
6700
|
+
else "ARCHIVE_INTEGRITY_FAILED"
|
|
6701
|
+
),
|
|
6702
|
+
"message": (
|
|
6703
|
+
"final archive manifest byte coverage verified"
|
|
6704
|
+
if bool(coverage.get("ok"))
|
|
6705
|
+
else "final archive manifest byte coverage failed"
|
|
6706
|
+
),
|
|
6707
|
+
"checksumStatus": coverage.get("checksumStatus"),
|
|
6708
|
+
}
|
|
6709
|
+
final_adequacy = validate_report_adequacy(summary)
|
|
6710
|
+
final_release_decision = evaluate_release_eligibility(
|
|
6711
|
+
work_dir,
|
|
6712
|
+
summary,
|
|
6713
|
+
archive_integrity=final_archive_integrity,
|
|
6714
|
+
report_adequacy=final_adequacy,
|
|
6715
|
+
)
|
|
6716
|
+
summary["archiveIntegrity"] = final_archive_integrity
|
|
6717
|
+
summary["candidateVerification"] = final_release_decision["checks"][
|
|
6718
|
+
"candidateVerification"
|
|
6719
|
+
]
|
|
6720
|
+
summary["releaseDecision"] = final_release_decision
|
|
6721
|
+
summary["releaseEligible"] = final_release_decision[
|
|
6722
|
+
"releaseEligible"
|
|
6723
|
+
]
|
|
6724
|
+
summary.setdefault("reportPipeline", {})[
|
|
6725
|
+
"reportAdequacy"
|
|
6726
|
+
] = final_adequacy
|
|
6727
|
+
summary["reportPipeline"][
|
|
6728
|
+
"releaseDecision"
|
|
6729
|
+
] = final_release_decision
|
|
6730
|
+
payload["releaseDecision"] = final_release_decision
|
|
6731
|
+
payload["candidateVerification"] = summary[
|
|
6732
|
+
"candidateVerification"
|
|
6733
|
+
]
|
|
6734
|
+
payload["releaseEligible"] = summary["releaseEligible"]
|
|
4873
6735
|
write_json(summary_path, summary)
|
|
4874
6736
|
# Re-render once more for checksumStatus display; remain excluded.
|
|
4875
6737
|
render_final_summary(work_dir, summary_path)
|
|
@@ -4906,7 +6768,15 @@ def cmd_finalize(
|
|
|
4906
6768
|
# Honest checksum: FAIL blocks; OK and OK_WITH_EXCLUSIONS (with reasons) pass.
|
|
4907
6769
|
checksum = str(compare_result.get("checksumStatus") or "")
|
|
4908
6770
|
checksum_ok = checksum in {"OK", "OK_WITH_EXCLUSIONS"}
|
|
4909
|
-
|
|
6771
|
+
release_ok = bool(
|
|
6772
|
+
(payload.get("releaseDecision") or {}).get("releaseEligible")
|
|
6773
|
+
)
|
|
6774
|
+
can_close = (
|
|
6775
|
+
validate_ok
|
|
6776
|
+
and manifest_ok
|
|
6777
|
+
and checksum_ok
|
|
6778
|
+
and (archive_intent == "record-only" or release_ok)
|
|
6779
|
+
)
|
|
4910
6780
|
|
|
4911
6781
|
if not can_close:
|
|
4912
6782
|
issues_out = list(validate_result.get("issues") or [])
|
|
@@ -4922,6 +6792,10 @@ def cmd_finalize(
|
|
|
4922
6792
|
),
|
|
4923
6793
|
}
|
|
4924
6794
|
)
|
|
6795
|
+
if archive_intent == "release-candidate" and not release_ok:
|
|
6796
|
+
issues_out.extend(
|
|
6797
|
+
(payload.get("releaseDecision") or {}).get("issues") or []
|
|
6798
|
+
)
|
|
4925
6799
|
payload["issues"] = issues_out
|
|
4926
6800
|
payload["error"] = "validate or manifest check failed; staged operation discarded"
|
|
4927
6801
|
payload["steps"]["delete_original"] = {"ok": False, "deleted": False}
|