@hunter-harness/workflow-harness 0.2.26 → 0.2.28
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/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 +8 -4
- package/harness/bundles/general/claude-code/harness-sync/reference.md +28 -0
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +2147 -189
- package/harness/bundles/general/claude-code/scripts/harness_change.py +8 -3
- package/harness/bundles/general/claude-code/scripts/harness_events.py +57 -9
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +530 -8
- package/harness/bundles/general/claude-code/scripts/harness_integration.py +263 -6
- 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/codebuddy/.harness-build.json +1 -1
- 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 +8 -4
- package/harness/bundles/general/codebuddy/harness-sync/reference.md +28 -0
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +2147 -189
- package/harness/bundles/general/codebuddy/scripts/harness_change.py +8 -3
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +57 -9
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +530 -8
- package/harness/bundles/general/codebuddy/scripts/harness_integration.py +263 -6
- 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/codex/.harness-build.json +1 -1
- 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 +8 -4
- package/harness/bundles/general/codex/harness-sync/reference.md +28 -0
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/scripts/harness_archive.py +2147 -189
- package/harness/bundles/general/codex/scripts/harness_change.py +8 -3
- package/harness/bundles/general/codex/scripts/harness_events.py +57 -9
- package/harness/bundles/general/codex/scripts/harness_gate.py +530 -8
- package/harness/bundles/general/codex/scripts/harness_integration.py +263 -6
- 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/cursor/.harness-build.json +1 -1
- 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 +8 -4
- package/harness/bundles/general/cursor/harness-sync/reference.md +28 -0
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/scripts/harness_archive.py +2147 -189
- package/harness/bundles/general/cursor/scripts/harness_change.py +8 -3
- package/harness/bundles/general/cursor/scripts/harness_events.py +57 -9
- package/harness/bundles/general/cursor/scripts/harness_gate.py +530 -8
- package/harness/bundles/general/cursor/scripts/harness_integration.py +263 -6
- 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/java/claude-code/.harness-build.json +1 -1
- 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 +8 -4
- package/harness/bundles/java/claude-code/harness-sync/reference.md +28 -0
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +2147 -189
- package/harness/bundles/java/claude-code/scripts/harness_change.py +8 -3
- package/harness/bundles/java/claude-code/scripts/harness_events.py +57 -9
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +530 -8
- package/harness/bundles/java/claude-code/scripts/harness_integration.py +263 -6
- 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/codebuddy/.harness-build.json +1 -1
- 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 +8 -4
- package/harness/bundles/java/codebuddy/harness-sync/reference.md +28 -0
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +2147 -189
- package/harness/bundles/java/codebuddy/scripts/harness_change.py +8 -3
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +57 -9
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +530 -8
- package/harness/bundles/java/codebuddy/scripts/harness_integration.py +263 -6
- 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/codex/.harness-build.json +1 -1
- 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 +8 -4
- package/harness/bundles/java/codex/harness-sync/reference.md +28 -0
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/scripts/harness_archive.py +2147 -189
- package/harness/bundles/java/codex/scripts/harness_change.py +8 -3
- package/harness/bundles/java/codex/scripts/harness_events.py +57 -9
- package/harness/bundles/java/codex/scripts/harness_gate.py +530 -8
- package/harness/bundles/java/codex/scripts/harness_integration.py +263 -6
- 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/cursor/.harness-build.json +1 -1
- 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 +8 -4
- package/harness/bundles/java/cursor/harness-sync/reference.md +28 -0
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/scripts/harness_archive.py +2147 -189
- package/harness/bundles/java/cursor/scripts/harness_change.py +8 -3
- package/harness/bundles/java/cursor/scripts/harness_events.py +57 -9
- package/harness/bundles/java/cursor/scripts/harness_gate.py +530 -8
- package/harness/bundles/java/cursor/scripts/harness_integration.py +263 -6
- 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/manifests/general/claude-code.json +28 -24
- package/harness/manifests/general/codebuddy.json +28 -24
- package/harness/manifests/general/codex.json +28 -24
- package/harness/manifests/general/cursor.json +28 -24
- package/harness/manifests/java/claude-code.json +30 -26
- package/harness/manifests/java/codebuddy.json +30 -26
- package/harness/manifests/java/codex.json +30 -26
- package/harness/manifests/java/cursor.json +30 -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,42 +1479,215 @@ 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_wall = 0
|
|
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
|
-
stage_wall += int(timing.get("wallClockSpanMs") or 0)
|
|
1056
1546
|
unclosed += int(timing.get("unclosedAttemptCount") or 0)
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
|
1638
|
+
else:
|
|
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
|
|
1645
|
+
)
|
|
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())
|
|
1680
|
+
post_archive_excluded = 0
|
|
1681
|
+
if cutoff:
|
|
1682
|
+
for event in events:
|
|
1683
|
+
if str(event.get("phase") or "").lower() != "archive":
|
|
1684
|
+
continue
|
|
1685
|
+
ts = event.get("timestamp")
|
|
1686
|
+
if ts and he.duration_ms_between(cutoff, ts) and he.duration_ms_between(cutoff, ts) > 0:
|
|
1687
|
+
# timestamp after cutoff
|
|
1688
|
+
start_dt = he.parse_timestamp(cutoff)
|
|
1689
|
+
end_dt = he.parse_timestamp(ts)
|
|
1690
|
+
if start_dt and end_dt and end_dt > start_dt:
|
|
1081
1691
|
post_archive_excluded += 1
|
|
1082
1692
|
return {
|
|
1083
1693
|
"workflowStartedAt": started or NOT_AVAILABLE,
|
|
@@ -1085,11 +1695,19 @@ def build_workflow_timing(
|
|
|
1085
1695
|
"workflowWallClockMs": int(workflow_wall or 0),
|
|
1086
1696
|
"stageActiveExecutionMs": int(stage_active),
|
|
1087
1697
|
"stageWallClockSpanMs": int(stage_wall),
|
|
1088
|
-
"
|
|
1089
|
-
"
|
|
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),
|
|
1090
1703
|
"unclosedAttemptCount": int(unclosed),
|
|
1704
|
+
"attempts": attempts,
|
|
1705
|
+
"sessions": sessions,
|
|
1706
|
+
"sessionWallClockMs": sum(
|
|
1707
|
+
int(item["wallClockMs"]) for item in sessions
|
|
1708
|
+
),
|
|
1091
1709
|
"postArchiveEventsExcluded": int(post_archive_excluded),
|
|
1092
|
-
"totalMinutesSemantics": "active-
|
|
1710
|
+
"totalMinutesSemantics": "workflow-wall-primary; active-is-separate",
|
|
1093
1711
|
}
|
|
1094
1712
|
|
|
1095
1713
|
|
|
@@ -1613,17 +2231,60 @@ def check_status(
|
|
|
1613
2231
|
candidate_codes = {
|
|
1614
2232
|
"PRODUCT_CI_NOT_GREEN",
|
|
1615
2233
|
"PRODUCT_CANDIDATE_NOT_VERIFIED",
|
|
2234
|
+
"PRODUCT_CANDIDATE_RECORD_ONLY",
|
|
2235
|
+
"PROJECT_RELEASE_POLICY_BLOCKED",
|
|
2236
|
+
"REMOTE_CI_DOWNGRADE_REFUSED",
|
|
1616
2237
|
}
|
|
1617
2238
|
archive_integrity_ok = not any(
|
|
1618
2239
|
item.get("code") not in candidate_codes for item in blockers
|
|
1619
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
|
+
)
|
|
1620
2278
|
return {
|
|
1621
2279
|
"ok": True,
|
|
1622
2280
|
"archivable": archivable,
|
|
1623
2281
|
"archiveIntent": archive_intent,
|
|
1624
|
-
"archiveIntegrity":
|
|
1625
|
-
"candidateVerification":
|
|
1626
|
-
|
|
2282
|
+
"archiveIntegrity": archive_integrity,
|
|
2283
|
+
"candidateVerification": release_decision["checks"][
|
|
2284
|
+
"candidateVerification"
|
|
2285
|
+
],
|
|
2286
|
+
"releaseDecision": release_decision,
|
|
2287
|
+
"releaseEligible": release_decision["releaseEligible"],
|
|
1627
2288
|
"change_dir": str(change_dir),
|
|
1628
2289
|
"change_name": infer_change_name(change_dir),
|
|
1629
2290
|
"blockers": blockers,
|
|
@@ -1817,14 +2478,26 @@ def _ledger_api_tests(
|
|
|
1817
2478
|
counted = False
|
|
1818
2479
|
|
|
1819
2480
|
metrics = api.get("metrics")
|
|
1820
|
-
|
|
1821
|
-
|
|
2481
|
+
normalized_metrics = (
|
|
2482
|
+
{str(key).lower(): value for key, value in metrics.items()}
|
|
2483
|
+
if isinstance(metrics, dict)
|
|
2484
|
+
else {}
|
|
2485
|
+
)
|
|
2486
|
+
if any(
|
|
2487
|
+
k in normalized_metrics
|
|
2488
|
+
for k in ("total", "passed", "pass", "failed", "fail", "blocked")
|
|
1822
2489
|
):
|
|
1823
|
-
passed = int(
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
2490
|
+
passed = int(
|
|
2491
|
+
normalized_metrics.get("passed", normalized_metrics.get("pass", 0)) or 0
|
|
2492
|
+
)
|
|
2493
|
+
failed = int(
|
|
2494
|
+
normalized_metrics.get("failed", normalized_metrics.get("fail", 0)) or 0
|
|
2495
|
+
)
|
|
2496
|
+
blocked = int(normalized_metrics.get("blocked", 0) or 0)
|
|
2497
|
+
total = int(
|
|
2498
|
+
normalized_metrics.get("total", passed + failed + blocked) or 0
|
|
2499
|
+
)
|
|
2500
|
+
pass_rate = normalized_metrics.get("passrate")
|
|
1828
2501
|
source = "committed"
|
|
1829
2502
|
counted = total > 0 or passed > 0 or failed > 0 or blocked > 0
|
|
1830
2503
|
|
|
@@ -2191,8 +2864,12 @@ def _artifacts_from_events(
|
|
|
2191
2864
|
raw_path = str(e.get("path") or "")
|
|
2192
2865
|
archived_path = raw_path
|
|
2193
2866
|
if change_dir is not None and raw_path:
|
|
2867
|
+
normalized = raw_path.replace("\\", "/")
|
|
2868
|
+
same_change_prefix = f".harness/changes/{change_dir.name}/"
|
|
2869
|
+
if normalized.startswith(same_change_prefix):
|
|
2870
|
+
archived_path = normalized[len(same_change_prefix):]
|
|
2194
2871
|
product_copy = change_dir / "artifacts" / "product" / raw_path
|
|
2195
|
-
if not (change_dir / raw_path).is_file() and product_copy.is_file():
|
|
2872
|
+
if archived_path == raw_path and not (change_dir / raw_path).is_file() and product_copy.is_file():
|
|
2196
2873
|
archived_path = product_copy.relative_to(change_dir).as_posix()
|
|
2197
2874
|
out.append(
|
|
2198
2875
|
{
|
|
@@ -2618,6 +3295,37 @@ def _review_summary(
|
|
|
2618
3295
|
merged = dict(base)
|
|
2619
3296
|
merged.update(existing["reviewSummary"])
|
|
2620
3297
|
return merged
|
|
3298
|
+
reports = find_review_reports(change_dir)
|
|
3299
|
+
if reports:
|
|
3300
|
+
labels: set[tuple[str, str]] = set()
|
|
3301
|
+
pattern = re.compile(
|
|
3302
|
+
r"(?im)^\s{0,3}(?:#{1,6}\s*)?(?:[-*]\s*)?(?:\*\*)?"
|
|
3303
|
+
r"(RED|YELLOW)[-_\s]?(\d+)\b"
|
|
3304
|
+
)
|
|
3305
|
+
for report in reports:
|
|
3306
|
+
try:
|
|
3307
|
+
text = report.read_text(encoding="utf-8-sig")
|
|
3308
|
+
except OSError:
|
|
3309
|
+
continue
|
|
3310
|
+
labels.update(
|
|
3311
|
+
(match.group(1).upper(), match.group(2))
|
|
3312
|
+
for match in pattern.finditer(text)
|
|
3313
|
+
)
|
|
3314
|
+
red = sum(1 for severity, _ in labels if severity == "RED")
|
|
3315
|
+
yellow = sum(1 for severity, _ in labels if severity == "YELLOW")
|
|
3316
|
+
base.update(
|
|
3317
|
+
{
|
|
3318
|
+
"status": "ADVISORY_UNSTRUCTURED",
|
|
3319
|
+
"red": red,
|
|
3320
|
+
"yellow": yellow,
|
|
3321
|
+
"redConfirmed": red,
|
|
3322
|
+
"summary": (
|
|
3323
|
+
"structured review findings missing; counts inferred from "
|
|
3324
|
+
f"{len(reports)} markdown report(s)"
|
|
3325
|
+
),
|
|
3326
|
+
}
|
|
3327
|
+
)
|
|
3328
|
+
return base
|
|
2621
3329
|
if not review_evidence_present(change_dir, events):
|
|
2622
3330
|
base["status"] = "ADVISORY_NOT_RUN"
|
|
2623
3331
|
return base
|
|
@@ -2824,6 +3532,7 @@ def collect_summary_data(
|
|
|
2824
3532
|
ci_metrics, ci_metrics_source = load_ci_metrics(change_dir)
|
|
2825
3533
|
if ci_metrics_source:
|
|
2826
3534
|
sources.append(ci_metrics_source)
|
|
3535
|
+
remote_cost = build_remote_cost_summary(ci_metrics)
|
|
2827
3536
|
|
|
2828
3537
|
log_text = load_execution_log(change_dir)
|
|
2829
3538
|
if log_text:
|
|
@@ -2844,6 +3553,7 @@ def collect_summary_data(
|
|
|
2844
3553
|
"diffStat",
|
|
2845
3554
|
"stageStatus",
|
|
2846
3555
|
"durations",
|
|
3556
|
+
"timing",
|
|
2847
3557
|
"skillCalls",
|
|
2848
3558
|
"verification",
|
|
2849
3559
|
"timeline",
|
|
@@ -2858,10 +3568,24 @@ def collect_summary_data(
|
|
|
2858
3568
|
"archiveIntent",
|
|
2859
3569
|
"archiveIntegrity",
|
|
2860
3570
|
"candidateVerification",
|
|
3571
|
+
"releaseDecision",
|
|
2861
3572
|
"releaseEligible",
|
|
3573
|
+
"productCommit",
|
|
3574
|
+
"featureMergeHash",
|
|
3575
|
+
"releaseTipHash",
|
|
3576
|
+
"productTreeHash",
|
|
3577
|
+
"archiveCommit",
|
|
3578
|
+
"environmentHash",
|
|
3579
|
+
"changeIdentity",
|
|
3580
|
+
"artifactStorage",
|
|
3581
|
+
"retention",
|
|
3582
|
+
"remoteCost",
|
|
3583
|
+
"projection",
|
|
2862
3584
|
):
|
|
2863
3585
|
if key in existing:
|
|
2864
3586
|
data[key] = _deepcopy_json(existing[key])
|
|
3587
|
+
if not for_replay or not isinstance(data.get("remoteCost"), dict):
|
|
3588
|
+
data["remoteCost"] = remote_cost
|
|
2865
3589
|
|
|
2866
3590
|
event_summary = he.build_summary(change_dir, events) if events else {
|
|
2867
3591
|
"ok": True,
|
|
@@ -2973,7 +3697,6 @@ def collect_summary_data(
|
|
|
2973
3697
|
)
|
|
2974
3698
|
data["archiveIntegrity"] = {"ok": True}
|
|
2975
3699
|
data["candidateVerification"] = candidate_gate
|
|
2976
|
-
data["releaseEligible"] = bool(candidate_gate.get("ok"))
|
|
2977
3700
|
|
|
2978
3701
|
# durations / skillCalls
|
|
2979
3702
|
if events:
|
|
@@ -3034,6 +3757,7 @@ def collect_summary_data(
|
|
|
3034
3757
|
data["releaseTipHash"] = identity.get("releaseTipHash") or data.get("archiveCommit")
|
|
3035
3758
|
data["productTreeHash"] = identity.get("productTreeHash")
|
|
3036
3759
|
data["archiveCommit"] = identity.get("archiveCommit") or data.get("finalCommit")
|
|
3760
|
+
data["environmentHash"] = identity.get("environmentHash") or NOT_AVAILABLE
|
|
3037
3761
|
data["changeIdentity"] = identity
|
|
3038
3762
|
|
|
3039
3763
|
# diffStat / changedFiles
|
|
@@ -3124,6 +3848,37 @@ def collect_summary_data(
|
|
|
3124
3848
|
data["artifacts"] = _artifacts_from_events(
|
|
3125
3849
|
projected_events, change_dir=change_dir
|
|
3126
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", {})
|
|
3127
3882
|
|
|
3128
3883
|
data["reviewSummary"] = _review_summary(
|
|
3129
3884
|
change_dir,
|
|
@@ -3223,6 +3978,35 @@ def collect_summary_data(
|
|
|
3223
3978
|
"validationIssues": [],
|
|
3224
3979
|
}
|
|
3225
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
|
+
|
|
3226
4010
|
# Fill any remaining template placeholders that look like instructions
|
|
3227
4011
|
for key in list(data.keys()):
|
|
3228
4012
|
val = data[key]
|
|
@@ -3281,6 +4065,54 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
|
|
|
3281
4065
|
parts.append("</ul></div>")
|
|
3282
4066
|
if summary.get("riskTier"):
|
|
3283
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>")
|
|
3284
4116
|
|
|
3285
4117
|
timing = summary.get("timing") if isinstance(summary.get("timing"), dict) else {}
|
|
3286
4118
|
if timing:
|
|
@@ -3292,6 +4124,16 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
|
|
|
3292
4124
|
f"workflowWallClock={esc(timing.get('workflowWallClockMs'))}"
|
|
3293
4125
|
"</p>"
|
|
3294
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
|
+
)
|
|
3295
4137
|
parts.append(
|
|
3296
4138
|
f"<p><strong>reportCutoffAt</strong>: "
|
|
3297
4139
|
f"<span id=\"reportCutoffAt\">{esc(timing.get('reportCutoffAt'))}</span></p>"
|
|
@@ -3300,6 +4142,59 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
|
|
|
3300
4142
|
"<p><small>durations.totalMinutes is active-only; "
|
|
3301
4143
|
"do not treat it as workflow wall clock.</small></p>"
|
|
3302
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
|
+
)
|
|
3303
4198
|
|
|
3304
4199
|
if summary.get("productCommit") or summary.get("productTreeHash"):
|
|
3305
4200
|
parts.append("<h3>Identity</h3>")
|
|
@@ -3547,7 +4442,7 @@ def _freeze_evidence_cutoff(work_dir: Path) -> dict[str, Any]:
|
|
|
3547
4442
|
|
|
3548
4443
|
def validate_artifact_immutability(entries: list[dict[str, Any]]) -> dict[str, Any]:
|
|
3549
4444
|
"""Same artifact path with two different hashes -> conflict (UT-003/RET-07)."""
|
|
3550
|
-
issues: list[dict[str,
|
|
4445
|
+
issues: list[dict[str, Any]] = []
|
|
3551
4446
|
seen: dict[str, str] = {}
|
|
3552
4447
|
for item in entries:
|
|
3553
4448
|
if not isinstance(item, dict):
|
|
@@ -3995,63 +4890,736 @@ def validate_report_adequacy(summary: dict[str, Any]) -> dict[str, Any]:
|
|
|
3995
4890
|
final_present = bool(
|
|
3996
4891
|
(final and final != NOT_AVAILABLE) or (merge_final and merge_final != NOT_AVAILABLE)
|
|
3997
4892
|
)
|
|
3998
|
-
if final_present and base_missing:
|
|
3999
|
-
issues.append({
|
|
4000
|
-
"code": "IDENTITY_BASE_MISSING",
|
|
4001
|
-
"severity": "error",
|
|
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 {}
|
|
5435
|
+
)
|
|
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",
|
|
4002
5475
|
"message": (
|
|
4003
|
-
"
|
|
4004
|
-
"refuse CONDITIONAL_OK+ without a resolved base"
|
|
5476
|
+
f"project requires remote-attested evidence; got {assurance or 'none'}"
|
|
4005
5477
|
),
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
if (
|
|
4009
|
-
|
|
4010
|
-
and final
|
|
4011
|
-
and base != NOT_AVAILABLE
|
|
4012
|
-
and final != NOT_AVAILABLE
|
|
4013
|
-
and base != final
|
|
4014
|
-
and files_changed == 0
|
|
5478
|
+
"remoteRequired": True,
|
|
5479
|
+
}
|
|
5480
|
+
if assurance == "local-reproducible" and not bool(
|
|
5481
|
+
candidate_policy.get("allowLocalRelease")
|
|
4015
5482
|
):
|
|
4016
|
-
|
|
4017
|
-
"
|
|
4018
|
-
"
|
|
4019
|
-
"message":
|
|
4020
|
-
|
|
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
|
+
}
|
|
4021
5528
|
|
|
4022
|
-
# Typed metrics missing despite test report artifacts present.
|
|
4023
|
-
verification = summary.get("verification") or {}
|
|
4024
|
-
if isinstance(verification, dict):
|
|
4025
|
-
unit = verification.get("unitTests") or {}
|
|
4026
|
-
api = verification.get("apiTests") or {}
|
|
4027
|
-
artifacts = summary.get("artifacts") or []
|
|
4028
|
-
has_test_report = any(
|
|
4029
|
-
isinstance(a, dict) and "test" in str(a.get("path") or "").lower()
|
|
4030
|
-
for a in artifacts
|
|
4031
|
-
) if isinstance(artifacts, list) else False
|
|
4032
|
-
unit_pass = int(unit.get("passed") or 0) if isinstance(unit, dict) else 0
|
|
4033
|
-
api_status = str(api.get("status") or "") if isinstance(api, dict) else ""
|
|
4034
|
-
if has_test_report and unit_pass == 0 and api_status in {"", "not_available"}:
|
|
4035
|
-
issues.append({
|
|
4036
|
-
"code": "TYPED_METRICS_MISSING",
|
|
4037
|
-
"severity": "error",
|
|
4038
|
-
"message": "test report artifacts present but unitTests/apiTests typed metrics are empty",
|
|
4039
|
-
})
|
|
4040
5529
|
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
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
|
+
}
|
|
4053
5568
|
|
|
4054
|
-
|
|
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
|
+
)
|
|
4055
5623
|
|
|
4056
5624
|
|
|
4057
5625
|
def artifact_preflight(change_dir: Path) -> dict[str, Any]:
|
|
@@ -4146,12 +5714,202 @@ def artifact_preflight(change_dir: Path) -> dict[str, Any]:
|
|
|
4146
5714
|
return {"ok": not blocking, "items": items, "blocking": blocking}
|
|
4147
5715
|
|
|
4148
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
|
+
|
|
4149
5896
|
def materialize_repository_artifacts(change_dir: Path) -> dict[str, Any]:
|
|
4150
|
-
"""
|
|
5897
|
+
"""Materialize repository artifacts through a content-addressed object store."""
|
|
4151
5898
|
change_dir = change_dir.resolve()
|
|
4152
5899
|
project_root = find_project_root(change_dir)
|
|
4153
5900
|
preflight = artifact_preflight(change_dir)
|
|
4154
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
|
+
)
|
|
4155
5913
|
for item in preflight.get("items") or []:
|
|
4156
5914
|
if item.get("category") != "repository-file":
|
|
4157
5915
|
continue
|
|
@@ -4159,10 +5917,72 @@ def materialize_repository_artifacts(change_dir: Path) -> dict[str, Any]:
|
|
|
4159
5917
|
target = (change_dir / str(item["archivePath"])).resolve()
|
|
4160
5918
|
if not source.is_relative_to(project_root) or not target.is_relative_to(change_dir):
|
|
4161
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
|
|
4162
5943
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
4163
|
-
|
|
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"
|
|
4164
5956
|
copied.append(target.relative_to(change_dir).as_posix())
|
|
4165
|
-
|
|
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
|
|
4166
5986
|
|
|
4167
5987
|
|
|
4168
5988
|
def run_knowledge_poststeps(project_root: Path) -> dict[str, Any]:
|
|
@@ -4317,9 +6137,9 @@ def cmd_finalize(
|
|
|
4317
6137
|
if resolved_state_dir.resolve() != original_change_dir.resolve()
|
|
4318
6138
|
else None
|
|
4319
6139
|
)
|
|
4320
|
-
operation_id = f"
|
|
6140
|
+
operation_id = f"a-{uuid.uuid4().hex[:12]}"
|
|
4321
6141
|
operation_root = project_root / ".harness" / "archive-operations"
|
|
4322
|
-
operation_temp_dir = operation_root / "staging" / operation_id /
|
|
6142
|
+
operation_temp_dir = operation_root / "staging" / operation_id / change_dir.name
|
|
4323
6143
|
operation_record = operation_root / f"{operation_id}.json"
|
|
4324
6144
|
|
|
4325
6145
|
def _restore_finalize_failure() -> None:
|
|
@@ -4378,12 +6198,60 @@ def cmd_finalize(
|
|
|
4378
6198
|
if not exact_byte["ok"]:
|
|
4379
6199
|
warnings.append(str(exact_byte["remediation"]))
|
|
4380
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
|
+
|
|
6229
|
+
payload["steps"]["service_stop_before_staging"] = run_service_stop(
|
|
6230
|
+
original_change_dir
|
|
6231
|
+
)
|
|
6232
|
+
transient_names = {
|
|
6233
|
+
"node_modules",
|
|
6234
|
+
".venv",
|
|
6235
|
+
"venv",
|
|
6236
|
+
"__pycache__",
|
|
6237
|
+
".cache",
|
|
6238
|
+
"integration-temp",
|
|
6239
|
+
}
|
|
6240
|
+
|
|
6241
|
+
def _ignore_archive_transients(_directory: str, names: list[str]) -> set[str]:
|
|
6242
|
+
return {
|
|
6243
|
+
name
|
|
6244
|
+
for name in names
|
|
6245
|
+
if name in transient_names or name.endswith(".lock")
|
|
6246
|
+
}
|
|
6247
|
+
|
|
4381
6248
|
try:
|
|
4382
6249
|
operation_temp_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
4383
6250
|
shutil.copytree(
|
|
4384
6251
|
original_change_dir,
|
|
4385
6252
|
operation_temp_dir,
|
|
4386
6253
|
copy_function=shutil.copy2,
|
|
6254
|
+
ignore=_ignore_archive_transients,
|
|
4387
6255
|
)
|
|
4388
6256
|
write_json(
|
|
4389
6257
|
operation_record,
|
|
@@ -4551,7 +6419,7 @@ def cmd_finalize(
|
|
|
4551
6419
|
"message": ci_gate.get("message"),
|
|
4552
6420
|
}
|
|
4553
6421
|
payload["candidateVerification"] = ci_gate
|
|
4554
|
-
payload["releaseEligible"] =
|
|
6422
|
+
payload["releaseEligible"] = False
|
|
4555
6423
|
if not ci_gate.get("ok") and archive_intent == "release-candidate":
|
|
4556
6424
|
payload["error"] = str(ci_gate.get("message") or "PRODUCT_CI_NOT_GREEN")
|
|
4557
6425
|
payload["issues"] = [
|
|
@@ -4576,7 +6444,8 @@ def cmd_finalize(
|
|
|
4576
6444
|
"schemaVersion": 1,
|
|
4577
6445
|
"intent": archive_intent,
|
|
4578
6446
|
"candidateVerificationCode": ci_gate.get("code"),
|
|
4579
|
-
"releaseEligible":
|
|
6447
|
+
"releaseEligible": False,
|
|
6448
|
+
"releaseDecisionCode": "PENDING_IDENTITY_AND_REPORT_GATES",
|
|
4580
6449
|
"recordedAt": now_iso(),
|
|
4581
6450
|
},
|
|
4582
6451
|
)
|
|
@@ -4657,6 +6526,28 @@ def cmd_finalize(
|
|
|
4657
6526
|
write_json(summary_path, summary)
|
|
4658
6527
|
except OSError as exc:
|
|
4659
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}")
|
|
4660
6551
|
if not adequacy_result.get("ok"):
|
|
4661
6552
|
payload["issues"] = adequacy_result.get("issues") or []
|
|
4662
6553
|
payload["error"] = "report adequacy failed; staged operation discarded"
|
|
@@ -4664,6 +6555,17 @@ def cmd_finalize(
|
|
|
4664
6555
|
payload["warnings"] = warnings
|
|
4665
6556
|
payload["ok"] = False
|
|
4666
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
|
|
4667
6569
|
|
|
4668
6570
|
# --- 6. render (Node, else Python fallback) ---
|
|
4669
6571
|
render_result = render_final_summary(work_dir, summary_path)
|
|
@@ -4786,6 +6688,50 @@ def cmd_finalize(
|
|
|
4786
6688
|
"exclusionReasons"
|
|
4787
6689
|
) or summary["archiveManifest"]["exclusionReasons"]
|
|
4788
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"]
|
|
4789
6735
|
write_json(summary_path, summary)
|
|
4790
6736
|
# Re-render once more for checksumStatus display; remain excluded.
|
|
4791
6737
|
render_final_summary(work_dir, summary_path)
|
|
@@ -4822,7 +6768,15 @@ def cmd_finalize(
|
|
|
4822
6768
|
# Honest checksum: FAIL blocks; OK and OK_WITH_EXCLUSIONS (with reasons) pass.
|
|
4823
6769
|
checksum = str(compare_result.get("checksumStatus") or "")
|
|
4824
6770
|
checksum_ok = checksum in {"OK", "OK_WITH_EXCLUSIONS"}
|
|
4825
|
-
|
|
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
|
+
)
|
|
4826
6780
|
|
|
4827
6781
|
if not can_close:
|
|
4828
6782
|
issues_out = list(validate_result.get("issues") or [])
|
|
@@ -4838,6 +6792,10 @@ def cmd_finalize(
|
|
|
4838
6792
|
),
|
|
4839
6793
|
}
|
|
4840
6794
|
)
|
|
6795
|
+
if archive_intent == "release-candidate" and not release_ok:
|
|
6796
|
+
issues_out.extend(
|
|
6797
|
+
(payload.get("releaseDecision") or {}).get("issues") or []
|
|
6798
|
+
)
|
|
4841
6799
|
payload["issues"] = issues_out
|
|
4842
6800
|
payload["error"] = "validate or manifest check failed; staged operation discarded"
|
|
4843
6801
|
payload["steps"]["delete_original"] = {"ok": False, "deleted": False}
|