@hunter-harness/workflow-harness 0.3.1 → 0.4.1
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/reference.md +30 -1
- package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +1 -0
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-execute/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-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +146 -8
- package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_knowledge_candidates.py +538 -4
- 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/reference.md +30 -1
- package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +1 -0
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- 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-pull/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +146 -8
- package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_knowledge_candidates.py +538 -4
- 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/reference.md +30 -1
- package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +1 -0
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
- 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-pull/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/scripts/harness_archive.py +146 -8
- package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/codex/scripts/harness_knowledge_candidates.py +538 -4
- 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/reference.md +30 -1
- package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +1 -0
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-execute/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- 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-pull/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/scripts/harness_archive.py +146 -8
- package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -1
- package/harness/bundles/general/cursor/scripts/harness_knowledge_candidates.py +538 -4
- package/harness/bundles/general/pi/.harness-build.json +6 -0
- package/harness/bundles/general/pi/CONTEXT.md +98 -0
- package/harness/bundles/general/pi/README.md +478 -0
- package/harness/bundles/general/pi/contracts/build-profile-v3.schema.json +302 -0
- package/harness/bundles/general/pi/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/general/pi/contracts/doctor-capability.schema.json +43 -0
- package/harness/bundles/general/pi/contracts/external-platform-observation.schema.json +62 -0
- package/harness/bundles/general/pi/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/general/pi/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/general/pi/contracts/run-session.schema.json +142 -0
- package/harness/bundles/general/pi/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/general/pi/contracts/service-session.schema.json +147 -0
- package/harness/bundles/general/pi/contracts/workflow-policy.json +574 -0
- package/harness/bundles/general/pi/harness-archive/SKILL.md +322 -0
- package/harness/bundles/general/pi/harness-archive/checklist.md +71 -0
- package/harness/bundles/general/pi/harness-archive/reference.md +150 -0
- package/harness/bundles/general/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
- package/harness/bundles/general/pi/harness-archive/templates/summary-data-template.json +218 -0
- package/harness/bundles/general/pi/harness-codebase-map/SKILL.md +116 -0
- package/harness/bundles/general/pi/harness-codebase-map/checklist.md +126 -0
- package/harness/bundles/general/pi/harness-codebase-map/reference.md +364 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/STACK.md +55 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/TESTING.md +64 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
- package/harness/bundles/general/pi/harness-codebase-map/templates/map-summary.md +64 -0
- package/harness/bundles/general/pi/harness-execute/SKILL.md +170 -0
- package/harness/bundles/general/pi/harness-knowledge-ingest/SKILL.md +103 -0
- package/harness/bundles/general/pi/harness-knowledge-query/SKILL.md +97 -0
- package/harness/bundles/general/pi/harness-plan/SKILL.md +158 -0
- package/harness/bundles/general/pi/harness-plan/checklist.md +243 -0
- package/harness/bundles/general/pi/harness-plan/protocols.md +133 -0
- package/harness/bundles/general/pi/harness-plan/reference.md +468 -0
- package/harness/bundles/general/pi/harness-pull/SKILL.md +55 -0
- package/harness/bundles/general/pi/harness-push/SKILL.md +83 -0
- package/harness/bundles/general/pi/harness-review/SKILL.md +175 -0
- package/harness/bundles/general/pi/harness-review/checklist.md +124 -0
- package/harness/bundles/general/pi/harness-review/protocols.md +69 -0
- package/harness/bundles/general/pi/harness-review/reference.md +114 -0
- package/harness/bundles/general/pi/harness-run/SKILL.md +188 -0
- package/harness/bundles/general/pi/harness-run/checklist.md +279 -0
- package/harness/bundles/general/pi/harness-run/protocols.md +141 -0
- package/harness/bundles/general/pi/harness-run/reference.md +886 -0
- package/harness/bundles/general/pi/harness-submit/SKILL.md +182 -0
- package/harness/bundles/general/pi/harness-submit/checklist.md +452 -0
- package/harness/bundles/general/pi/harness-submit/reference.md +152 -0
- package/harness/bundles/general/pi/harness-sync/SKILL.md +72 -0
- package/harness/bundles/general/pi/harness-sync/reference.md +176 -0
- package/harness/bundles/general/pi/harness-test/SKILL.md +250 -0
- package/harness/bundles/general/pi/harness-test/checklist.md +359 -0
- package/harness/bundles/general/pi/harness-test/pitfalls-java.md +14 -0
- package/harness/bundles/general/pi/harness-test/pitfalls.md +307 -0
- package/harness/bundles/general/pi/harness-test/reference.md +880 -0
- package/harness/bundles/general/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
- package/harness/bundles/general/pi/protocols/archive-report-protocol.md +196 -0
- package/harness/bundles/general/pi/protocols/ci-layering-protocol.md +22 -0
- package/harness/bundles/general/pi/protocols/evidence-based-reporting-protocol.md +134 -0
- package/harness/bundles/general/pi/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/general/pi/protocols/external-platform-protocol.md +27 -0
- package/harness/bundles/general/pi/protocols/ledger-protocol.md +365 -0
- package/harness/bundles/general/pi/protocols/memory-closeout-protocol.md +21 -0
- package/harness/bundles/general/pi/protocols/model-routing-protocol.md +22 -0
- package/harness/bundles/general/pi/protocols/powershell-protocol.md +238 -0
- package/harness/bundles/general/pi/protocols/registry-governance-protocol.md +31 -0
- package/harness/bundles/general/pi/protocols/report-pipeline-protocol.md +172 -0
- package/harness/bundles/general/pi/protocols/sensitive-info-protocol.md +110 -0
- package/harness/bundles/general/pi/protocols/state-layout-protocol.md +209 -0
- package/harness/bundles/general/pi/protocols/submit-protocol.md +78 -0
- package/harness/bundles/general/pi/protocols/verification-graph-protocol.md +32 -0
- package/harness/bundles/general/pi/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/general/pi/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/general/pi/scripts/harness_archive.py +11345 -0
- package/harness/bundles/general/pi/scripts/harness_change.py +1626 -0
- package/harness/bundles/general/pi/scripts/harness_check_gate.py +113 -0
- package/harness/bundles/general/pi/scripts/harness_context.py +1774 -0
- package/harness/bundles/general/pi/scripts/harness_deploy.py +968 -0
- package/harness/bundles/general/pi/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/general/pi/scripts/harness_environment.py +1690 -0
- package/harness/bundles/general/pi/scripts/harness_events.py +2234 -0
- package/harness/bundles/general/pi/scripts/harness_events_sync.py +1425 -0
- package/harness/bundles/general/pi/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/general/pi/scripts/harness_fixback.py +1975 -0
- package/harness/bundles/general/pi/scripts/harness_gate.py +3816 -0
- package/harness/bundles/general/pi/scripts/harness_headless.py +37 -0
- package/harness/bundles/general/pi/scripts/harness_integration.py +2564 -0
- package/harness/bundles/general/pi/scripts/harness_knowledge_candidates.py +796 -0
- package/harness/bundles/general/pi/scripts/harness_ledger.py +3556 -0
- package/harness/bundles/general/pi/scripts/harness_migration_head.py +220 -0
- package/harness/bundles/general/pi/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/general/pi/scripts/harness_paths.py +676 -0
- package/harness/bundles/general/pi/scripts/harness_phase.py +1726 -0
- package/harness/bundles/general/pi/scripts/harness_plan_aggregate.py +249 -0
- package/harness/bundles/general/pi/scripts/harness_plan_finalize.py +1016 -0
- package/harness/bundles/general/pi/scripts/harness_preflight.py +650 -0
- package/harness/bundles/general/pi/scripts/harness_process.py +1065 -0
- package/harness/bundles/general/pi/scripts/harness_profile.py +1817 -0
- package/harness/bundles/general/pi/scripts/harness_report_model.py +442 -0
- package/harness/bundles/general/pi/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/pi/scripts/harness_review.py +631 -0
- package/harness/bundles/general/pi/scripts/harness_runtime.py +2243 -0
- package/harness/bundles/general/pi/scripts/harness_service.py +3355 -0
- package/harness/bundles/general/pi/scripts/harness_state.py +365 -0
- package/harness/bundles/general/pi/scripts/harness_sync.py +407 -0
- package/harness/bundles/general/pi/scripts/harness_test_cleanup.py +175 -0
- package/harness/bundles/general/pi/scripts/harness_test_guard.py +1899 -0
- package/harness/bundles/general/pi/scripts/harness_test_runner.py +1586 -0
- package/harness/bundles/general/pi/scripts/harness_verification.py +593 -0
- package/harness/bundles/general/pi/scripts/harness_workflow_policy.py +266 -0
- 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/reference.md +30 -1
- package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +1 -0
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-execute/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-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-pull/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +146 -8
- package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_knowledge_candidates.py +538 -4
- 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/reference.md +30 -1
- package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +1 -0
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- 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-pull/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +146 -8
- package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_knowledge_candidates.py +538 -4
- 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/reference.md +30 -1
- package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +1 -0
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
- 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-pull/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/scripts/harness_archive.py +146 -8
- package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/codex/scripts/harness_knowledge_candidates.py +538 -4
- 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/reference.md +30 -1
- package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +1 -0
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-execute/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- 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-pull/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/scripts/harness_archive.py +146 -8
- package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -1
- package/harness/bundles/java/cursor/scripts/harness_knowledge_candidates.py +538 -4
- package/harness/bundles/java/pi/.harness-build.json +6 -0
- package/harness/bundles/java/pi/CONTEXT.md +98 -0
- package/harness/bundles/java/pi/README.md +478 -0
- package/harness/bundles/java/pi/contracts/build-profile-v3.schema.json +302 -0
- package/harness/bundles/java/pi/contracts/ci-metrics.schema.json +36 -0
- package/harness/bundles/java/pi/contracts/doctor-capability.schema.json +43 -0
- package/harness/bundles/java/pi/contracts/external-platform-observation.schema.json +62 -0
- package/harness/bundles/java/pi/contracts/fixtures/managed-execution.json +341 -0
- package/harness/bundles/java/pi/contracts/process-identity.schema.json +132 -0
- package/harness/bundles/java/pi/contracts/run-session.schema.json +142 -0
- package/harness/bundles/java/pi/contracts/service-retirement-receipt.schema.json +85 -0
- package/harness/bundles/java/pi/contracts/service-session.schema.json +147 -0
- package/harness/bundles/java/pi/contracts/workflow-policy.json +574 -0
- package/harness/bundles/java/pi/harness-apidoc/SKILL.md +97 -0
- package/harness/bundles/java/pi/harness-apidoc/checklist.md +142 -0
- package/harness/bundles/java/pi/harness-apidoc/reference.md +233 -0
- package/harness/bundles/java/pi/harness-archive/SKILL.md +322 -0
- package/harness/bundles/java/pi/harness-archive/checklist.md +71 -0
- package/harness/bundles/java/pi/harness-archive/reference.md +150 -0
- package/harness/bundles/java/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
- package/harness/bundles/java/pi/harness-archive/templates/summary-data-template.json +218 -0
- package/harness/bundles/java/pi/harness-codebase-map/SKILL.md +116 -0
- package/harness/bundles/java/pi/harness-codebase-map/checklist.md +126 -0
- package/harness/bundles/java/pi/harness-codebase-map/reference.md +364 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/STACK.md +55 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/TESTING.md +64 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
- package/harness/bundles/java/pi/harness-codebase-map/templates/map-summary.md +64 -0
- package/harness/bundles/java/pi/harness-execute/SKILL.md +170 -0
- package/harness/bundles/java/pi/harness-knowledge-ingest/SKILL.md +103 -0
- package/harness/bundles/java/pi/harness-knowledge-query/SKILL.md +97 -0
- package/harness/bundles/java/pi/harness-package/SKILL.md +94 -0
- package/harness/bundles/java/pi/harness-package/checklist.md +322 -0
- package/harness/bundles/java/pi/harness-package/reference.md +210 -0
- package/harness/bundles/java/pi/harness-plan/SKILL.md +158 -0
- package/harness/bundles/java/pi/harness-plan/checklist.md +243 -0
- package/harness/bundles/java/pi/harness-plan/protocols.md +133 -0
- package/harness/bundles/java/pi/harness-plan/reference.md +468 -0
- package/harness/bundles/java/pi/harness-pull/SKILL.md +55 -0
- package/harness/bundles/java/pi/harness-push/SKILL.md +83 -0
- package/harness/bundles/java/pi/harness-review/SKILL.md +175 -0
- package/harness/bundles/java/pi/harness-review/checklist.md +124 -0
- package/harness/bundles/java/pi/harness-review/protocols.md +69 -0
- package/harness/bundles/java/pi/harness-review/reference.md +114 -0
- package/harness/bundles/java/pi/harness-run/SKILL.md +206 -0
- package/harness/bundles/java/pi/harness-run/checklist.md +252 -0
- package/harness/bundles/java/pi/harness-run/protocols.md +141 -0
- package/harness/bundles/java/pi/harness-run/reference.md +820 -0
- package/harness/bundles/java/pi/harness-submit/SKILL.md +189 -0
- package/harness/bundles/java/pi/harness-submit/checklist.md +452 -0
- package/harness/bundles/java/pi/harness-submit/reference.md +152 -0
- package/harness/bundles/java/pi/harness-sync/SKILL.md +72 -0
- package/harness/bundles/java/pi/harness-sync/reference.md +176 -0
- package/harness/bundles/java/pi/harness-test/SKILL.md +264 -0
- package/harness/bundles/java/pi/harness-test/checklist.md +320 -0
- package/harness/bundles/java/pi/harness-test/pitfalls-java.md +16 -0
- package/harness/bundles/java/pi/harness-test/pitfalls.md +307 -0
- package/harness/bundles/java/pi/harness-test/reference.md +792 -0
- package/harness/bundles/java/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
- package/harness/bundles/java/pi/protocols/archive-report-protocol.md +196 -0
- package/harness/bundles/java/pi/protocols/ci-layering-protocol.md +22 -0
- package/harness/bundles/java/pi/protocols/evidence-based-reporting-protocol.md +134 -0
- package/harness/bundles/java/pi/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/java/pi/protocols/external-platform-protocol.md +27 -0
- package/harness/bundles/java/pi/protocols/ledger-protocol.md +365 -0
- package/harness/bundles/java/pi/protocols/memory-closeout-protocol.md +21 -0
- package/harness/bundles/java/pi/protocols/model-routing-protocol.md +22 -0
- package/harness/bundles/java/pi/protocols/powershell-protocol.md +238 -0
- package/harness/bundles/java/pi/protocols/registry-governance-protocol.md +31 -0
- package/harness/bundles/java/pi/protocols/report-pipeline-protocol.md +172 -0
- package/harness/bundles/java/pi/protocols/sensitive-info-protocol.md +110 -0
- package/harness/bundles/java/pi/protocols/state-layout-protocol.md +209 -0
- package/harness/bundles/java/pi/protocols/submit-protocol.md +78 -0
- package/harness/bundles/java/pi/protocols/verification-graph-protocol.md +32 -0
- package/harness/bundles/java/pi/scripts/harness_adoption_metrics.py +391 -0
- package/harness/bundles/java/pi/scripts/harness_apidoc.py +53 -0
- package/harness/bundles/java/pi/scripts/harness_archive.py +11345 -0
- package/harness/bundles/java/pi/scripts/harness_change.py +1626 -0
- package/harness/bundles/java/pi/scripts/harness_check_gate.py +113 -0
- package/harness/bundles/java/pi/scripts/harness_context.py +1774 -0
- package/harness/bundles/java/pi/scripts/harness_deploy.py +968 -0
- package/harness/bundles/java/pi/scripts/harness_efficiency.py +443 -0
- package/harness/bundles/java/pi/scripts/harness_environment.py +1690 -0
- package/harness/bundles/java/pi/scripts/harness_events.py +2234 -0
- package/harness/bundles/java/pi/scripts/harness_events_sync.py +1425 -0
- package/harness/bundles/java/pi/scripts/harness_execution_contracts.py +596 -0
- package/harness/bundles/java/pi/scripts/harness_fixback.py +1975 -0
- package/harness/bundles/java/pi/scripts/harness_gate.py +3816 -0
- package/harness/bundles/java/pi/scripts/harness_headless.py +37 -0
- package/harness/bundles/java/pi/scripts/harness_integration.py +2564 -0
- package/harness/bundles/java/pi/scripts/harness_knowledge_candidates.py +796 -0
- package/harness/bundles/java/pi/scripts/harness_ledger.py +3556 -0
- package/harness/bundles/java/pi/scripts/harness_migration_head.py +220 -0
- package/harness/bundles/java/pi/scripts/harness_orchestration.py +344 -0
- package/harness/bundles/java/pi/scripts/harness_paths.py +676 -0
- package/harness/bundles/java/pi/scripts/harness_phase.py +1726 -0
- package/harness/bundles/java/pi/scripts/harness_plan_aggregate.py +249 -0
- package/harness/bundles/java/pi/scripts/harness_plan_finalize.py +1016 -0
- package/harness/bundles/java/pi/scripts/harness_preflight.py +650 -0
- package/harness/bundles/java/pi/scripts/harness_process.py +1065 -0
- package/harness/bundles/java/pi/scripts/harness_profile.py +1817 -0
- package/harness/bundles/java/pi/scripts/harness_report_model.py +442 -0
- package/harness/bundles/java/pi/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/pi/scripts/harness_review.py +631 -0
- package/harness/bundles/java/pi/scripts/harness_runtime.py +2243 -0
- package/harness/bundles/java/pi/scripts/harness_service.py +3355 -0
- package/harness/bundles/java/pi/scripts/harness_state.py +365 -0
- package/harness/bundles/java/pi/scripts/harness_sync.py +407 -0
- package/harness/bundles/java/pi/scripts/harness_test_cleanup.py +175 -0
- package/harness/bundles/java/pi/scripts/harness_test_guard.py +1899 -0
- package/harness/bundles/java/pi/scripts/harness_test_runner.py +1586 -0
- package/harness/bundles/java/pi/scripts/harness_verification.py +593 -0
- package/harness/bundles/java/pi/scripts/harness_workflow_policy.py +266 -0
- package/harness/manifests/general/claude-code.json +19 -19
- package/harness/manifests/general/codebuddy.json +19 -19
- package/harness/manifests/general/codex.json +19 -19
- package/harness/manifests/general/cursor.json +19 -19
- package/harness/manifests/general/pi.json +475 -0
- package/harness/manifests/java/claude-code.json +21 -21
- package/harness/manifests/java/codebuddy.json +21 -21
- package/harness/manifests/java/codex.json +21 -21
- package/harness/manifests/java/cursor.json +21 -21
- package/harness/manifests/java/pi.json +499 -0
- package/hunter-workflow-family.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,1726 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Phase reconciliation, trace projection, and normalized CI metrics.
|
|
3
|
+
|
|
4
|
+
The reconciler is deliberately an evidence orchestrator: it decides REUSE,
|
|
5
|
+
RUN, or BLOCK for the required gate DAG. It never executes user test commands.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import datetime as dt
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import uuid
|
|
19
|
+
import xml.etree.ElementTree as ET
|
|
20
|
+
from collections import defaultdict
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
26
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
27
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
28
|
+
|
|
29
|
+
import harness_events as he # noqa: E402
|
|
30
|
+
import harness_ledger as hl # noqa: E402
|
|
31
|
+
import harness_paths as hpaths # noqa: E402
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
SCHEMA_VERSION = 1
|
|
35
|
+
CI_METRICS_SCHEMA_VERSION = 1
|
|
36
|
+
# 唯一权威清单在 harness_paths,harness_context 引的是同一个对象。此前这里另有
|
|
37
|
+
# 一份拷贝,少了 merge,worktree 变更走到 merge 就在 target_required_dag 里硬 raise。
|
|
38
|
+
PHASE_ORDER = hpaths.WORKFLOW_PHASES
|
|
39
|
+
VALIDATION_PHASES = {
|
|
40
|
+
"compile": "execute",
|
|
41
|
+
"unitTest": "execute",
|
|
42
|
+
"unitTestFull": "execute",
|
|
43
|
+
"apiTest": "execute",
|
|
44
|
+
"browserTest": "execute",
|
|
45
|
+
"dbCompatibility": "execute",
|
|
46
|
+
"package": "package",
|
|
47
|
+
}
|
|
48
|
+
FINAL_SEQUENCE_RECEIPTS_REL = (
|
|
49
|
+
Path("evidence") / "final-sequence-receipts.json"
|
|
50
|
+
)
|
|
51
|
+
ENVIRONMENT_EXECUTIONS_REL = (
|
|
52
|
+
Path("evidence") / "environment-executions.json"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
57
|
+
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
58
|
+
if not isinstance(value, dict):
|
|
59
|
+
raise ValueError(f"JSON object required: {path}")
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _write_json(path: Path, value: Any) -> None:
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
temp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
66
|
+
try:
|
|
67
|
+
temp.write_text(
|
|
68
|
+
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
|
|
69
|
+
encoding="utf-8",
|
|
70
|
+
newline="\n",
|
|
71
|
+
)
|
|
72
|
+
temp.replace(path)
|
|
73
|
+
except BaseException:
|
|
74
|
+
temp.unlink(missing_ok=True)
|
|
75
|
+
raise
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _final_sequence_contract(change_dir: Path) -> dict[str, Any] | None:
|
|
79
|
+
# 权威切换(2026-08):v2 发布快照优先,classify 工作副本回退。
|
|
80
|
+
loaded = hpaths.load_change_gate_policy(change_dir)
|
|
81
|
+
policy = loaded.get("policy")
|
|
82
|
+
if policy is None:
|
|
83
|
+
return None
|
|
84
|
+
declared = policy.get("finalSequence")
|
|
85
|
+
if isinstance(declared, dict) and isinstance(declared.get("nodeIds"), list):
|
|
86
|
+
return declared
|
|
87
|
+
dag = policy.get("requiredGateDag")
|
|
88
|
+
if not isinstance(dag, dict):
|
|
89
|
+
return None
|
|
90
|
+
sequence_nodes = [
|
|
91
|
+
node
|
|
92
|
+
for node in (dag.get("nodes") or [])
|
|
93
|
+
if isinstance(node, dict) and node.get("kind") == "sequence"
|
|
94
|
+
]
|
|
95
|
+
if not sequence_nodes:
|
|
96
|
+
return None
|
|
97
|
+
sequence_nodes.sort(key=lambda node: int(node.get("sequenceIndex") or 0))
|
|
98
|
+
node_ids = [str(node.get("id") or "") for node in sequence_nodes]
|
|
99
|
+
return {
|
|
100
|
+
"schemaVersion": 1,
|
|
101
|
+
"nodeIds": node_ids,
|
|
102
|
+
"freezeNodeId": (
|
|
103
|
+
"sequence:code-freeze"
|
|
104
|
+
if "sequence:code-freeze" in node_ids
|
|
105
|
+
else None
|
|
106
|
+
),
|
|
107
|
+
"terminalNodeId": node_ids[-1],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _load_sequence_receipts(change_dir: Path) -> dict[str, Any]:
|
|
112
|
+
path = change_dir / FINAL_SEQUENCE_RECEIPTS_REL
|
|
113
|
+
if not path.is_file():
|
|
114
|
+
return {
|
|
115
|
+
"schemaVersion": 1,
|
|
116
|
+
"changeId": change_dir.name,
|
|
117
|
+
"receipts": {},
|
|
118
|
+
"attempts": [],
|
|
119
|
+
}
|
|
120
|
+
value = _read_json(path)
|
|
121
|
+
if value.get("schemaVersion") != 1:
|
|
122
|
+
raise ValueError("unsupported final-sequence receipt schema")
|
|
123
|
+
receipts = value.get("receipts")
|
|
124
|
+
if not isinstance(receipts, dict):
|
|
125
|
+
raise ValueError("final-sequence receipts must be an object")
|
|
126
|
+
attempts = value.get("attempts")
|
|
127
|
+
if attempts is not None and not isinstance(attempts, list):
|
|
128
|
+
raise ValueError("final-sequence attempts must be an array")
|
|
129
|
+
value.setdefault("attempts", [])
|
|
130
|
+
return value
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _validated_sequence_subject(subject: dict[str, Any]) -> dict[str, str]:
|
|
134
|
+
required = ("productCommit", "productTreeHash", "environmentHash")
|
|
135
|
+
missing = [
|
|
136
|
+
field
|
|
137
|
+
for field in required
|
|
138
|
+
if not isinstance(subject.get(field), str)
|
|
139
|
+
or not str(subject[field]).strip()
|
|
140
|
+
]
|
|
141
|
+
if missing:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
"final-sequence subject missing: " + ", ".join(missing)
|
|
144
|
+
)
|
|
145
|
+
return {field: str(subject[field]).strip() for field in required}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _record_sequence_attempt(
|
|
149
|
+
change_dir: Path,
|
|
150
|
+
journal: dict[str, Any],
|
|
151
|
+
*,
|
|
152
|
+
node_id: str,
|
|
153
|
+
ok: bool,
|
|
154
|
+
code: str,
|
|
155
|
+
subject: dict[str, str],
|
|
156
|
+
) -> None:
|
|
157
|
+
attempts = journal.setdefault("attempts", [])
|
|
158
|
+
attempts.append(
|
|
159
|
+
{
|
|
160
|
+
"nodeId": node_id,
|
|
161
|
+
"ok": ok,
|
|
162
|
+
"code": code,
|
|
163
|
+
"subject": subject,
|
|
164
|
+
"recordedAt": he.now_iso(),
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
_write_json(change_dir / FINAL_SEQUENCE_RECEIPTS_REL, journal)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def record_sequence_receipt(
|
|
171
|
+
change_dir: Path,
|
|
172
|
+
node_id: str,
|
|
173
|
+
subject: dict[str, Any],
|
|
174
|
+
*,
|
|
175
|
+
status: str = "OK",
|
|
176
|
+
evidence: dict[str, Any] | None = None,
|
|
177
|
+
) -> dict[str, Any]:
|
|
178
|
+
"""Record one final-sequence transition after validating its predecessor."""
|
|
179
|
+
change_dir = change_dir.resolve()
|
|
180
|
+
contract = _final_sequence_contract(change_dir)
|
|
181
|
+
if contract is None:
|
|
182
|
+
return {
|
|
183
|
+
"ok": False,
|
|
184
|
+
"code": "FINAL_SEQUENCE_NOT_CONFIGURED",
|
|
185
|
+
"message": "gate policy does not declare finalSequence",
|
|
186
|
+
}
|
|
187
|
+
node_ids = [
|
|
188
|
+
str(item)
|
|
189
|
+
for item in contract.get("nodeIds") or []
|
|
190
|
+
if isinstance(item, str)
|
|
191
|
+
]
|
|
192
|
+
if node_id not in node_ids:
|
|
193
|
+
return {
|
|
194
|
+
"ok": False,
|
|
195
|
+
"code": "SEQUENCE_NODE_UNKNOWN",
|
|
196
|
+
"message": f"node is not in finalSequence: {node_id}",
|
|
197
|
+
}
|
|
198
|
+
try:
|
|
199
|
+
normalized_subject = _validated_sequence_subject(subject)
|
|
200
|
+
journal = _load_sequence_receipts(change_dir)
|
|
201
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
202
|
+
return {
|
|
203
|
+
"ok": False,
|
|
204
|
+
"code": "SEQUENCE_RECEIPT_INVALID",
|
|
205
|
+
"message": str(exc),
|
|
206
|
+
}
|
|
207
|
+
receipts = journal["receipts"]
|
|
208
|
+
index = node_ids.index(node_id)
|
|
209
|
+
predecessor_id = node_ids[index - 1] if index else None
|
|
210
|
+
if predecessor_id is not None:
|
|
211
|
+
predecessor = receipts.get(predecessor_id)
|
|
212
|
+
if (
|
|
213
|
+
not isinstance(predecessor, dict)
|
|
214
|
+
or str(predecessor.get("status") or "").upper() != "OK"
|
|
215
|
+
):
|
|
216
|
+
code = "SEQUENCE_PREDECESSOR_MISSING"
|
|
217
|
+
_record_sequence_attempt(
|
|
218
|
+
change_dir,
|
|
219
|
+
journal,
|
|
220
|
+
node_id=node_id,
|
|
221
|
+
ok=False,
|
|
222
|
+
code=code,
|
|
223
|
+
subject=normalized_subject,
|
|
224
|
+
)
|
|
225
|
+
return {
|
|
226
|
+
"ok": False,
|
|
227
|
+
"code": code,
|
|
228
|
+
"message": f"required predecessor is not complete: {predecessor_id}",
|
|
229
|
+
"predecessor": predecessor_id,
|
|
230
|
+
}
|
|
231
|
+
if predecessor.get("subject") != normalized_subject:
|
|
232
|
+
code = "SEQUENCE_SUBJECT_CHANGED"
|
|
233
|
+
_record_sequence_attempt(
|
|
234
|
+
change_dir,
|
|
235
|
+
journal,
|
|
236
|
+
node_id=node_id,
|
|
237
|
+
ok=False,
|
|
238
|
+
code=code,
|
|
239
|
+
subject=normalized_subject,
|
|
240
|
+
)
|
|
241
|
+
return {
|
|
242
|
+
"ok": False,
|
|
243
|
+
"code": code,
|
|
244
|
+
"message": "candidate subject differs from predecessor receipt",
|
|
245
|
+
"expected": predecessor.get("subject"),
|
|
246
|
+
"actual": normalized_subject,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
freeze_node_id = str(contract.get("freezeNodeId") or "")
|
|
250
|
+
freeze_receipt = receipts.get(freeze_node_id)
|
|
251
|
+
if (
|
|
252
|
+
freeze_node_id
|
|
253
|
+
and isinstance(freeze_receipt, dict)
|
|
254
|
+
and freeze_receipt.get("subject") != normalized_subject
|
|
255
|
+
):
|
|
256
|
+
code = "SEQUENCE_SUBJECT_CHANGED"
|
|
257
|
+
_record_sequence_attempt(
|
|
258
|
+
change_dir,
|
|
259
|
+
journal,
|
|
260
|
+
node_id=node_id,
|
|
261
|
+
ok=False,
|
|
262
|
+
code=code,
|
|
263
|
+
subject=normalized_subject,
|
|
264
|
+
)
|
|
265
|
+
return {
|
|
266
|
+
"ok": False,
|
|
267
|
+
"code": code,
|
|
268
|
+
"message": "candidate subject changed after code-freeze",
|
|
269
|
+
"expected": freeze_receipt.get("subject"),
|
|
270
|
+
"actual": normalized_subject,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
normalized_status = status.strip().upper()
|
|
274
|
+
if normalized_status not in {"OK", "WARN", "FAIL"}:
|
|
275
|
+
return {
|
|
276
|
+
"ok": False,
|
|
277
|
+
"code": "SEQUENCE_STATUS_INVALID",
|
|
278
|
+
"message": f"unsupported final-sequence status: {status}",
|
|
279
|
+
}
|
|
280
|
+
normalized_evidence = evidence if isinstance(evidence, dict) else {}
|
|
281
|
+
if node_id == "sequence:unit-test-full":
|
|
282
|
+
full_execution_id = str(
|
|
283
|
+
normalized_evidence.get("fullExecutionId") or ""
|
|
284
|
+
).strip()
|
|
285
|
+
try:
|
|
286
|
+
environment_journal = load_environment_execution_journal(
|
|
287
|
+
change_dir
|
|
288
|
+
)
|
|
289
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
290
|
+
environment_journal = {"fullExecutions": []}
|
|
291
|
+
environment_error = str(exc)
|
|
292
|
+
else:
|
|
293
|
+
environment_error = ""
|
|
294
|
+
full_execution = next(
|
|
295
|
+
(
|
|
296
|
+
item
|
|
297
|
+
for item in environment_journal.get("fullExecutions") or []
|
|
298
|
+
if isinstance(item, dict)
|
|
299
|
+
and item.get("fullExecutionId") == full_execution_id
|
|
300
|
+
),
|
|
301
|
+
None,
|
|
302
|
+
)
|
|
303
|
+
if (
|
|
304
|
+
not full_execution_id
|
|
305
|
+
or not isinstance(full_execution, dict)
|
|
306
|
+
or full_execution.get("subject") != normalized_subject
|
|
307
|
+
or full_execution.get("status") != "OK"
|
|
308
|
+
or full_execution.get("assertionBearing") is not True
|
|
309
|
+
):
|
|
310
|
+
code = "FULL_EXECUTION_EVIDENCE_REQUIRED"
|
|
311
|
+
_record_sequence_attempt(
|
|
312
|
+
change_dir,
|
|
313
|
+
journal,
|
|
314
|
+
node_id=node_id,
|
|
315
|
+
ok=False,
|
|
316
|
+
code=code,
|
|
317
|
+
subject=normalized_subject,
|
|
318
|
+
)
|
|
319
|
+
return {
|
|
320
|
+
"ok": False,
|
|
321
|
+
"code": code,
|
|
322
|
+
"message": (
|
|
323
|
+
"unit-test-full requires a successful assertion-bearing "
|
|
324
|
+
"Full execution bound to the frozen subject"
|
|
325
|
+
),
|
|
326
|
+
"fullExecutionId": full_execution_id or None,
|
|
327
|
+
"journalError": environment_error or None,
|
|
328
|
+
}
|
|
329
|
+
receipt = {
|
|
330
|
+
"schemaVersion": 1,
|
|
331
|
+
"nodeId": node_id,
|
|
332
|
+
"status": normalized_status,
|
|
333
|
+
"subject": normalized_subject,
|
|
334
|
+
"evidence": normalized_evidence,
|
|
335
|
+
"recordedAt": he.now_iso(),
|
|
336
|
+
}
|
|
337
|
+
existing = receipts.get(node_id)
|
|
338
|
+
reused = (
|
|
339
|
+
isinstance(existing, dict)
|
|
340
|
+
and existing.get("status") == normalized_status
|
|
341
|
+
and existing.get("subject") == normalized_subject
|
|
342
|
+
and existing.get("evidence") == receipt["evidence"]
|
|
343
|
+
)
|
|
344
|
+
if not reused:
|
|
345
|
+
receipts[node_id] = receipt
|
|
346
|
+
_record_sequence_attempt(
|
|
347
|
+
change_dir,
|
|
348
|
+
journal,
|
|
349
|
+
node_id=node_id,
|
|
350
|
+
ok=normalized_status == "OK",
|
|
351
|
+
code=(
|
|
352
|
+
"SEQUENCE_RECEIPT_REUSED"
|
|
353
|
+
if reused
|
|
354
|
+
else (
|
|
355
|
+
"SEQUENCE_STEP_RECORDED"
|
|
356
|
+
if normalized_status == "OK"
|
|
357
|
+
else "SEQUENCE_STEP_FAILED"
|
|
358
|
+
)
|
|
359
|
+
),
|
|
360
|
+
subject=normalized_subject,
|
|
361
|
+
)
|
|
362
|
+
ok = normalized_status == "OK"
|
|
363
|
+
return {
|
|
364
|
+
"ok": ok,
|
|
365
|
+
"code": (
|
|
366
|
+
"SEQUENCE_RECEIPT_REUSED"
|
|
367
|
+
if reused
|
|
368
|
+
else ("SEQUENCE_STEP_RECORDED" if ok else "SEQUENCE_STEP_FAILED")
|
|
369
|
+
),
|
|
370
|
+
"nodeId": node_id,
|
|
371
|
+
"receipt": receipts.get(node_id),
|
|
372
|
+
"reused": reused,
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def evaluate_final_sequence(
|
|
377
|
+
change_dir: Path,
|
|
378
|
+
subject: dict[str, Any],
|
|
379
|
+
*,
|
|
380
|
+
exclude_nodes: set[str] | None = None,
|
|
381
|
+
) -> dict[str, Any]:
|
|
382
|
+
"""Evaluate all required sequence receipts against one candidate subject."""
|
|
383
|
+
change_dir = change_dir.resolve()
|
|
384
|
+
try:
|
|
385
|
+
contract = _final_sequence_contract(change_dir)
|
|
386
|
+
if contract is None:
|
|
387
|
+
return {
|
|
388
|
+
"ok": True,
|
|
389
|
+
"code": "FINAL_SEQUENCE_NOT_CONFIGURED",
|
|
390
|
+
"skipped": True,
|
|
391
|
+
"missing": [],
|
|
392
|
+
"invalid": [],
|
|
393
|
+
}
|
|
394
|
+
normalized_subject = _validated_sequence_subject(subject)
|
|
395
|
+
journal = _load_sequence_receipts(change_dir)
|
|
396
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
397
|
+
return {
|
|
398
|
+
"ok": False,
|
|
399
|
+
"code": "FINAL_SEQUENCE_INVALID",
|
|
400
|
+
"message": str(exc),
|
|
401
|
+
}
|
|
402
|
+
excluded = exclude_nodes or set()
|
|
403
|
+
required = [
|
|
404
|
+
str(item)
|
|
405
|
+
for item in contract.get("nodeIds") or []
|
|
406
|
+
if isinstance(item, str) and item not in excluded
|
|
407
|
+
]
|
|
408
|
+
receipts = journal["receipts"]
|
|
409
|
+
missing = [node_id for node_id in required if node_id not in receipts]
|
|
410
|
+
invalid = []
|
|
411
|
+
for node_id in required:
|
|
412
|
+
receipt = receipts.get(node_id)
|
|
413
|
+
if not isinstance(receipt, dict):
|
|
414
|
+
continue
|
|
415
|
+
if (
|
|
416
|
+
str(receipt.get("status") or "").upper() != "OK"
|
|
417
|
+
or receipt.get("subject") != normalized_subject
|
|
418
|
+
):
|
|
419
|
+
invalid.append(
|
|
420
|
+
{
|
|
421
|
+
"nodeId": node_id,
|
|
422
|
+
"status": receipt.get("status"),
|
|
423
|
+
"subject": receipt.get("subject"),
|
|
424
|
+
}
|
|
425
|
+
)
|
|
426
|
+
ok = not missing and not invalid
|
|
427
|
+
return {
|
|
428
|
+
"ok": ok,
|
|
429
|
+
"code": (
|
|
430
|
+
"FINAL_SEQUENCE_COMPLETE"
|
|
431
|
+
if ok
|
|
432
|
+
else "FINAL_SEQUENCE_INCOMPLETE"
|
|
433
|
+
),
|
|
434
|
+
"message": (
|
|
435
|
+
"all required final-sequence receipts match the candidate"
|
|
436
|
+
if ok
|
|
437
|
+
else f"missing={len(missing)} invalid={len(invalid)}"
|
|
438
|
+
),
|
|
439
|
+
"subject": normalized_subject,
|
|
440
|
+
"required": required,
|
|
441
|
+
"missing": missing,
|
|
442
|
+
"invalid": invalid,
|
|
443
|
+
"receiptsPath": str(change_dir / FINAL_SEQUENCE_RECEIPTS_REL),
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def load_environment_execution_journal(
|
|
448
|
+
change_dir: Path,
|
|
449
|
+
) -> dict[str, Any]:
|
|
450
|
+
path = change_dir.resolve() / ENVIRONMENT_EXECUTIONS_REL
|
|
451
|
+
if not path.is_file():
|
|
452
|
+
return {
|
|
453
|
+
"schemaVersion": 1,
|
|
454
|
+
"changeId": change_dir.name,
|
|
455
|
+
"environmentAttempts": [],
|
|
456
|
+
"fullExecutions": [],
|
|
457
|
+
}
|
|
458
|
+
journal = _read_json(path)
|
|
459
|
+
if journal.get("schemaVersion") != 1:
|
|
460
|
+
raise ValueError("unsupported environment execution journal schema")
|
|
461
|
+
for field in ("environmentAttempts", "fullExecutions"):
|
|
462
|
+
if not isinstance(journal.get(field), list):
|
|
463
|
+
raise ValueError(f"environment journal {field} must be an array")
|
|
464
|
+
return journal
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def record_environment_attempt(
|
|
468
|
+
change_dir: Path,
|
|
469
|
+
subject: dict[str, Any],
|
|
470
|
+
*,
|
|
471
|
+
stage: str,
|
|
472
|
+
status: str,
|
|
473
|
+
duration_ms: int,
|
|
474
|
+
failure_signature: str | None = None,
|
|
475
|
+
) -> dict[str, Any]:
|
|
476
|
+
"""Record environment lifecycle work without manufacturing Full results."""
|
|
477
|
+
change_dir = change_dir.resolve()
|
|
478
|
+
if stage not in {"prepare", "verify", "release"}:
|
|
479
|
+
return {
|
|
480
|
+
"ok": False,
|
|
481
|
+
"code": "ENVIRONMENT_STAGE_INVALID",
|
|
482
|
+
"message": f"unsupported environment stage: {stage}",
|
|
483
|
+
}
|
|
484
|
+
normalized_status = status.strip().upper()
|
|
485
|
+
if normalized_status not in {
|
|
486
|
+
"OK",
|
|
487
|
+
"ENVIRONMENT_ERROR",
|
|
488
|
+
"INTERRUPTED",
|
|
489
|
+
"FAIL",
|
|
490
|
+
}:
|
|
491
|
+
return {
|
|
492
|
+
"ok": False,
|
|
493
|
+
"code": "ENVIRONMENT_STATUS_INVALID",
|
|
494
|
+
"message": f"unsupported environment status: {status}",
|
|
495
|
+
}
|
|
496
|
+
if not isinstance(duration_ms, int) or duration_ms < 0:
|
|
497
|
+
return {
|
|
498
|
+
"ok": False,
|
|
499
|
+
"code": "ENVIRONMENT_DURATION_INVALID",
|
|
500
|
+
"message": "duration_ms must be a non-negative integer",
|
|
501
|
+
}
|
|
502
|
+
try:
|
|
503
|
+
normalized_subject = _validated_sequence_subject(subject)
|
|
504
|
+
journal = load_environment_execution_journal(change_dir)
|
|
505
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
506
|
+
return {
|
|
507
|
+
"ok": False,
|
|
508
|
+
"code": "ENVIRONMENT_JOURNAL_INVALID",
|
|
509
|
+
"message": str(exc),
|
|
510
|
+
}
|
|
511
|
+
attempt = {
|
|
512
|
+
"attemptId": "env-" + uuid.uuid4().hex,
|
|
513
|
+
"stage": stage,
|
|
514
|
+
"status": normalized_status,
|
|
515
|
+
"subject": normalized_subject,
|
|
516
|
+
"durationMs": duration_ms,
|
|
517
|
+
"failureSignature": failure_signature,
|
|
518
|
+
"recordedAt": he.now_iso(),
|
|
519
|
+
}
|
|
520
|
+
journal["environmentAttempts"].append(attempt)
|
|
521
|
+
_write_json(change_dir / ENVIRONMENT_EXECUTIONS_REL, journal)
|
|
522
|
+
ok = normalized_status == "OK"
|
|
523
|
+
return {
|
|
524
|
+
"ok": ok,
|
|
525
|
+
"code": (
|
|
526
|
+
"ENVIRONMENT_STAGE_RECORDED"
|
|
527
|
+
if ok
|
|
528
|
+
else "ENVIRONMENT_ERROR_RECORDED"
|
|
529
|
+
),
|
|
530
|
+
"attempt": attempt,
|
|
531
|
+
"fullExecutionCreated": False,
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def record_full_test_execution(
|
|
536
|
+
change_dir: Path,
|
|
537
|
+
subject: dict[str, Any],
|
|
538
|
+
*,
|
|
539
|
+
status: str,
|
|
540
|
+
prepare_ms: int,
|
|
541
|
+
test_ms: int,
|
|
542
|
+
cleanup_ms: int,
|
|
543
|
+
result_digest: str,
|
|
544
|
+
supersedes: str | None = None,
|
|
545
|
+
) -> dict[str, Any]:
|
|
546
|
+
"""Record one assertion-bearing Full execution after environment success."""
|
|
547
|
+
change_dir = change_dir.resolve()
|
|
548
|
+
normalized_status = status.strip().upper()
|
|
549
|
+
if normalized_status not in {"OK", "FAIL"}:
|
|
550
|
+
return {
|
|
551
|
+
"ok": False,
|
|
552
|
+
"code": "FULL_STATUS_INVALID",
|
|
553
|
+
"message": "Full status must be OK or FAIL",
|
|
554
|
+
}
|
|
555
|
+
durations = (prepare_ms, test_ms, cleanup_ms)
|
|
556
|
+
if any(not isinstance(value, int) or value < 0 for value in durations):
|
|
557
|
+
return {
|
|
558
|
+
"ok": False,
|
|
559
|
+
"code": "FULL_DURATION_INVALID",
|
|
560
|
+
"message": "prepare/test/cleanup durations must be non-negative",
|
|
561
|
+
}
|
|
562
|
+
if not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", result_digest):
|
|
563
|
+
return {
|
|
564
|
+
"ok": False,
|
|
565
|
+
"code": "FULL_RESULT_DIGEST_INVALID",
|
|
566
|
+
"message": "result_digest must be a sha256 digest",
|
|
567
|
+
}
|
|
568
|
+
try:
|
|
569
|
+
normalized_subject = _validated_sequence_subject(subject)
|
|
570
|
+
journal = load_environment_execution_journal(change_dir)
|
|
571
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
572
|
+
return {
|
|
573
|
+
"ok": False,
|
|
574
|
+
"code": "ENVIRONMENT_JOURNAL_INVALID",
|
|
575
|
+
"message": str(exc),
|
|
576
|
+
}
|
|
577
|
+
successful_environment = {
|
|
578
|
+
str(item.get("stage") or ""): item
|
|
579
|
+
for item in journal["environmentAttempts"]
|
|
580
|
+
if isinstance(item, dict)
|
|
581
|
+
and item.get("subject") == normalized_subject
|
|
582
|
+
and str(item.get("status") or "").upper() == "OK"
|
|
583
|
+
}
|
|
584
|
+
missing = [
|
|
585
|
+
stage
|
|
586
|
+
for stage in ("prepare", "verify")
|
|
587
|
+
if stage not in successful_environment
|
|
588
|
+
]
|
|
589
|
+
if missing:
|
|
590
|
+
return {
|
|
591
|
+
"ok": False,
|
|
592
|
+
"code": "ENVIRONMENT_NOT_READY",
|
|
593
|
+
"message": (
|
|
594
|
+
"Full execution requires successful environment stages: "
|
|
595
|
+
+ ", ".join(missing)
|
|
596
|
+
),
|
|
597
|
+
"missing": missing,
|
|
598
|
+
}
|
|
599
|
+
prior_success = next(
|
|
600
|
+
(
|
|
601
|
+
item
|
|
602
|
+
for item in reversed(journal["fullExecutions"])
|
|
603
|
+
if isinstance(item, dict)
|
|
604
|
+
and item.get("subject") == normalized_subject
|
|
605
|
+
and item.get("status") == "OK"
|
|
606
|
+
),
|
|
607
|
+
None,
|
|
608
|
+
)
|
|
609
|
+
if prior_success is not None:
|
|
610
|
+
if prior_success.get("resultDigest") == result_digest:
|
|
611
|
+
sequence_receipt = record_sequence_receipt(
|
|
612
|
+
change_dir,
|
|
613
|
+
"sequence:unit-test-full",
|
|
614
|
+
normalized_subject,
|
|
615
|
+
evidence={
|
|
616
|
+
"fullExecutionId": prior_success.get("fullExecutionId"),
|
|
617
|
+
"resultDigest": result_digest,
|
|
618
|
+
"assertionBearing": True,
|
|
619
|
+
},
|
|
620
|
+
)
|
|
621
|
+
return {
|
|
622
|
+
"ok": True,
|
|
623
|
+
"code": "FULL_EXECUTION_REUSED",
|
|
624
|
+
"execution": prior_success,
|
|
625
|
+
"reused": True,
|
|
626
|
+
"sequenceReceipt": sequence_receipt,
|
|
627
|
+
}
|
|
628
|
+
return {
|
|
629
|
+
"ok": False,
|
|
630
|
+
"code": "FULL_ALREADY_COMPLETED",
|
|
631
|
+
"message": (
|
|
632
|
+
"a successful assertion-bearing Full already exists for this identity"
|
|
633
|
+
),
|
|
634
|
+
"existingExecutionId": prior_success.get("fullExecutionId"),
|
|
635
|
+
}
|
|
636
|
+
prior_failed_ids = {
|
|
637
|
+
str(item.get("fullExecutionId") or "")
|
|
638
|
+
for item in journal["fullExecutions"]
|
|
639
|
+
if isinstance(item, dict)
|
|
640
|
+
and item.get("subject") == normalized_subject
|
|
641
|
+
and item.get("status") == "FAIL"
|
|
642
|
+
}
|
|
643
|
+
if prior_failed_ids and supersedes not in prior_failed_ids:
|
|
644
|
+
return {
|
|
645
|
+
"ok": False,
|
|
646
|
+
"code": "FULL_SUPERSESSION_REQUIRED",
|
|
647
|
+
"message": "Full rerun after assertion failure must name supersedes",
|
|
648
|
+
"priorFailedExecutionIds": sorted(prior_failed_ids),
|
|
649
|
+
}
|
|
650
|
+
execution = {
|
|
651
|
+
"fullExecutionId": "full-" + uuid.uuid4().hex,
|
|
652
|
+
"status": normalized_status,
|
|
653
|
+
"subject": normalized_subject,
|
|
654
|
+
"environmentAttemptIds": [
|
|
655
|
+
successful_environment[stage]["attemptId"]
|
|
656
|
+
for stage in ("prepare", "verify")
|
|
657
|
+
],
|
|
658
|
+
"prepareMs": prepare_ms,
|
|
659
|
+
"testExecutionMs": test_ms,
|
|
660
|
+
"cleanupMs": cleanup_ms,
|
|
661
|
+
"totalMs": prepare_ms + test_ms + cleanup_ms,
|
|
662
|
+
"resultDigest": result_digest,
|
|
663
|
+
"supersedes": supersedes,
|
|
664
|
+
"assertionBearing": True,
|
|
665
|
+
"recordedAt": he.now_iso(),
|
|
666
|
+
}
|
|
667
|
+
journal["fullExecutions"].append(execution)
|
|
668
|
+
_write_json(change_dir / ENVIRONMENT_EXECUTIONS_REL, journal)
|
|
669
|
+
sequence_receipt = None
|
|
670
|
+
if normalized_status == "OK":
|
|
671
|
+
sequence_receipt = record_sequence_receipt(
|
|
672
|
+
change_dir,
|
|
673
|
+
"sequence:unit-test-full",
|
|
674
|
+
normalized_subject,
|
|
675
|
+
evidence={
|
|
676
|
+
"fullExecutionId": execution["fullExecutionId"],
|
|
677
|
+
"resultDigest": result_digest,
|
|
678
|
+
"assertionBearing": True,
|
|
679
|
+
},
|
|
680
|
+
)
|
|
681
|
+
return {
|
|
682
|
+
"ok": normalized_status == "OK",
|
|
683
|
+
"code": (
|
|
684
|
+
"FULL_EXECUTION_RECORDED"
|
|
685
|
+
if normalized_status == "OK"
|
|
686
|
+
else "FULL_ASSERTIONS_FAILED"
|
|
687
|
+
),
|
|
688
|
+
"execution": execution,
|
|
689
|
+
"reused": False,
|
|
690
|
+
"sequenceReceipt": sequence_receipt,
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _stable_hex(*parts: Any, length: int) -> str:
|
|
695
|
+
raw = "\x1f".join(str(part) for part in parts).encode("utf-8")
|
|
696
|
+
return hashlib.sha256(raw).hexdigest()[:length]
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def target_required_dag(policy: dict[str, Any], target_phase: str) -> dict[str, Any]:
|
|
700
|
+
"""Return only the required predecessors needed to close ``target_phase``.
|
|
701
|
+
|
|
702
|
+
Validations owned by the target phase are prerequisites to closing it;
|
|
703
|
+
stage nodes for the target itself are deliberately excluded because their
|
|
704
|
+
``phase.end`` is the result of close, not evidence required before close.
|
|
705
|
+
"""
|
|
706
|
+
# 未知阶段名先过读时映射:阶段改名后,历史 change 的 gate-policy.json 与
|
|
707
|
+
# plannedPhases 里仍然是旧名,靠 LEGACY_PHASE_ALIASES 继续可读,不必迁移已落盘
|
|
708
|
+
# 的 change(本仓库没有 change 级 schema 迁移机制,硬 raise 等于让在途变更报废)。
|
|
709
|
+
resolved_target = hpaths.resolve_phase_name(target_phase)
|
|
710
|
+
if resolved_target is None:
|
|
711
|
+
raise ValueError(
|
|
712
|
+
f"unsupported reconcile target phase: {target_phase}; "
|
|
713
|
+
f"已知阶段 {', '.join(PHASE_ORDER)}。"
|
|
714
|
+
"若这是改名前的旧阶段,在 harness_paths.LEGACY_PHASE_ALIASES 登记映射;"
|
|
715
|
+
"若这份 gate-policy.json 本身过期,重跑 harness_gate.py classify 重建"
|
|
716
|
+
)
|
|
717
|
+
target_phase = resolved_target
|
|
718
|
+
dag = policy.get("requiredGateDag")
|
|
719
|
+
if not isinstance(dag, dict) or not isinstance(dag.get("nodes"), list):
|
|
720
|
+
raise ValueError("gate policy missing requiredGateDag; run harness_gate.py classify")
|
|
721
|
+
target_rank = PHASE_ORDER.index(target_phase)
|
|
722
|
+
selected: list[dict[str, Any]] = []
|
|
723
|
+
for raw in dag["nodes"]:
|
|
724
|
+
if not isinstance(raw, dict):
|
|
725
|
+
raise ValueError("requiredGateDag node must be an object")
|
|
726
|
+
node = dict(raw)
|
|
727
|
+
node_id = str(node.get("id") or "")
|
|
728
|
+
node_kind, _, node_name = node_id.partition(":")
|
|
729
|
+
inferred_phase = (
|
|
730
|
+
VALIDATION_PHASES.get(node_name)
|
|
731
|
+
if node_kind == "validation"
|
|
732
|
+
else node_name
|
|
733
|
+
)
|
|
734
|
+
raw_phase = str(node.get("phase") or inferred_phase or "")
|
|
735
|
+
node_phase = hpaths.resolve_phase_name(raw_phase)
|
|
736
|
+
if node_phase is None:
|
|
737
|
+
raise ValueError(
|
|
738
|
+
f"requiredGateDag node has unsupported phase: {node_id} (phase={raw_phase!r});"
|
|
739
|
+
"旧阶段名在 harness_paths.LEGACY_PHASE_ALIASES 登记映射,"
|
|
740
|
+
"或重跑 harness_gate.py classify 重建这份 gate-policy.json"
|
|
741
|
+
)
|
|
742
|
+
node["phase"] = node_phase
|
|
743
|
+
node_rank = PHASE_ORDER.index(node_phase)
|
|
744
|
+
is_validation = node.get("kind") == "validation" or node_id.startswith(
|
|
745
|
+
"validation:"
|
|
746
|
+
)
|
|
747
|
+
if node_rank < target_rank or (is_validation and node_rank == target_rank):
|
|
748
|
+
selected.append(node)
|
|
749
|
+
selected_ids = {str(node.get("id")) for node in selected}
|
|
750
|
+
for node in selected:
|
|
751
|
+
excluded_dependencies = [
|
|
752
|
+
dependency
|
|
753
|
+
for dependency in (node.get("dependsOn") or [])
|
|
754
|
+
if dependency not in selected_ids
|
|
755
|
+
]
|
|
756
|
+
if excluded_dependencies:
|
|
757
|
+
raise ValueError(
|
|
758
|
+
f"target DAG for {target_phase} excludes dependencies of "
|
|
759
|
+
f"{node.get('id')}: {', '.join(excluded_dependencies)}"
|
|
760
|
+
)
|
|
761
|
+
return {
|
|
762
|
+
"schemaVersion": dag.get("schemaVersion", 1),
|
|
763
|
+
"nodes": selected,
|
|
764
|
+
"edges": [
|
|
765
|
+
edge
|
|
766
|
+
for edge in (dag.get("edges") or [])
|
|
767
|
+
if isinstance(edge, dict)
|
|
768
|
+
and edge.get("from") in selected_ids
|
|
769
|
+
and edge.get("to") in selected_ids
|
|
770
|
+
],
|
|
771
|
+
"targetPhase": target_phase,
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def select_phase_capsule(
|
|
776
|
+
change_dir: Path,
|
|
777
|
+
project_root: Path,
|
|
778
|
+
*,
|
|
779
|
+
phase: str,
|
|
780
|
+
run_id: str | None,
|
|
781
|
+
) -> dict[str, Any]:
|
|
782
|
+
"""Select a phase capsule by its exact phase/run identity, never by mtime."""
|
|
783
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", phase):
|
|
784
|
+
return {
|
|
785
|
+
"ok": False,
|
|
786
|
+
"code": "PHASE_INVALID",
|
|
787
|
+
"message": f"invalid phase selector: {phase}",
|
|
788
|
+
"remediation": "pass the phase name emitted by harness_gate.py begin",
|
|
789
|
+
}
|
|
790
|
+
# 在途 change 的 capsule 可能按旧名(run/test)写入:归一后按 canonical
|
|
791
|
+
# 优先、旧名兜底查找。新写一律 canonical。
|
|
792
|
+
phase = hpaths.resolve_phase_name(phase) or phase
|
|
793
|
+
phase_names = [phase] + [
|
|
794
|
+
legacy for legacy, canonical in hpaths.LEGACY_PHASE_ALIASES.items()
|
|
795
|
+
if canonical == phase
|
|
796
|
+
]
|
|
797
|
+
state_dir = hpaths.resolve_state_dir_for_contract(change_dir, project_root)
|
|
798
|
+
capsule_dir = state_dir / "runtime" / "phase-context"
|
|
799
|
+
candidates = (
|
|
800
|
+
[
|
|
801
|
+
path
|
|
802
|
+
for path in capsule_dir.iterdir()
|
|
803
|
+
if path.is_file()
|
|
804
|
+
and any(path.name.startswith(f"{name}-") for name in phase_names)
|
|
805
|
+
and path.suffix == ".json"
|
|
806
|
+
]
|
|
807
|
+
if capsule_dir.is_dir()
|
|
808
|
+
else []
|
|
809
|
+
)
|
|
810
|
+
if not run_id:
|
|
811
|
+
if not candidates:
|
|
812
|
+
return {
|
|
813
|
+
"ok": True,
|
|
814
|
+
"code": "PHASE_CAPSULE_ABSENT",
|
|
815
|
+
"capsule": None,
|
|
816
|
+
"path": None,
|
|
817
|
+
}
|
|
818
|
+
return {
|
|
819
|
+
"ok": False,
|
|
820
|
+
"code": "PHASE_RUN_ID_REQUIRED",
|
|
821
|
+
"message": f"{len(candidates)} capsule(s) exist for phase {phase}",
|
|
822
|
+
"remediation": f"rerun reconcile with --phase {phase} --run-id <run-id>",
|
|
823
|
+
}
|
|
824
|
+
path: Path | None = None
|
|
825
|
+
for name in phase_names:
|
|
826
|
+
key = hashlib.sha256(f"{name}\0{run_id}".encode("utf-8")).hexdigest()[:20]
|
|
827
|
+
candidate = capsule_dir / f"{name}-{key}.json"
|
|
828
|
+
if candidate.is_file():
|
|
829
|
+
path = candidate
|
|
830
|
+
break
|
|
831
|
+
if path is None:
|
|
832
|
+
key = hashlib.sha256(f"{phase}\0{run_id}".encode("utf-8")).hexdigest()[:20]
|
|
833
|
+
return {
|
|
834
|
+
"ok": False,
|
|
835
|
+
"code": "PHASE_CAPSULE_NOT_FOUND",
|
|
836
|
+
"message": f"no capsule for phase {phase} and run-id {run_id}",
|
|
837
|
+
"expectedPath": str(capsule_dir / f"{phase}-{key}.json"),
|
|
838
|
+
"remediation": f"begin/resume the phase or verify --phase {phase} --run-id {run_id}",
|
|
839
|
+
}
|
|
840
|
+
try:
|
|
841
|
+
capsule = _read_json(path)
|
|
842
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
843
|
+
return {
|
|
844
|
+
"ok": False,
|
|
845
|
+
"code": "PHASE_CAPSULE_INVALID",
|
|
846
|
+
"message": str(exc),
|
|
847
|
+
"path": str(path),
|
|
848
|
+
"remediation": f"repair or resume the capsule for --phase {phase} --run-id {run_id}",
|
|
849
|
+
}
|
|
850
|
+
if (
|
|
851
|
+
hpaths.resolve_phase_name(capsule.get("phase")) != phase
|
|
852
|
+
or capsule.get("runId") != run_id
|
|
853
|
+
):
|
|
854
|
+
return {
|
|
855
|
+
"ok": False,
|
|
856
|
+
"code": "PHASE_CAPSULE_IDENTITY_MISMATCH",
|
|
857
|
+
"message": "capsule content does not match the requested phase/run-id",
|
|
858
|
+
"path": str(path),
|
|
859
|
+
"remediation": f"resume the exact phase with --phase {phase} --run-id {run_id}",
|
|
860
|
+
}
|
|
861
|
+
return {
|
|
862
|
+
"ok": True,
|
|
863
|
+
"code": "PHASE_CAPSULE_SELECTED",
|
|
864
|
+
"capsule": capsule,
|
|
865
|
+
"path": str(path),
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def reconcile_dag(
|
|
870
|
+
dag: dict[str, Any],
|
|
871
|
+
evidence: dict[str, dict[str, Any]],
|
|
872
|
+
*,
|
|
873
|
+
identity: dict[str, Any],
|
|
874
|
+
) -> dict[str, Any]:
|
|
875
|
+
"""Reduce a required DAG to deterministic REUSE/RUN/BLOCK decisions."""
|
|
876
|
+
raw_nodes = dag.get("nodes")
|
|
877
|
+
if not isinstance(raw_nodes, list):
|
|
878
|
+
raise ValueError("requiredGateDag.nodes must be an array")
|
|
879
|
+
if not raw_nodes:
|
|
880
|
+
raise ValueError("requiredGateDag.nodes must not be empty")
|
|
881
|
+
nodes_by_id: dict[str, dict[str, Any]] = {}
|
|
882
|
+
for raw in raw_nodes:
|
|
883
|
+
if not isinstance(raw, dict) or not str(raw.get("id") or "").strip():
|
|
884
|
+
raise ValueError("requiredGateDag node requires a non-empty id")
|
|
885
|
+
node_id = str(raw["id"])
|
|
886
|
+
if node_id in nodes_by_id:
|
|
887
|
+
raise ValueError(f"duplicate DAG node: {node_id}")
|
|
888
|
+
depends = raw.get("dependsOn") or []
|
|
889
|
+
if not isinstance(depends, list) or any(not isinstance(item, str) for item in depends):
|
|
890
|
+
raise ValueError(f"invalid dependsOn for {node_id}")
|
|
891
|
+
nodes_by_id[node_id] = dict(raw)
|
|
892
|
+
for node_id, node in nodes_by_id.items():
|
|
893
|
+
missing = [dep for dep in node.get("dependsOn") or [] if dep not in nodes_by_id]
|
|
894
|
+
if missing:
|
|
895
|
+
raise ValueError(f"unknown dependency for {node_id}: {', '.join(missing)}")
|
|
896
|
+
|
|
897
|
+
if not identity.get("ok"):
|
|
898
|
+
blocker = {
|
|
899
|
+
"code": str(identity.get("code") or "IDENTITY_MISMATCH"),
|
|
900
|
+
"message": str(identity.get("message") or "identity validation failed"),
|
|
901
|
+
}
|
|
902
|
+
return {
|
|
903
|
+
"nodes": [
|
|
904
|
+
{
|
|
905
|
+
**node,
|
|
906
|
+
"decision": "BLOCK",
|
|
907
|
+
"reason": blocker["code"],
|
|
908
|
+
"evidence": evidence.get(node_id) or {},
|
|
909
|
+
}
|
|
910
|
+
for node_id, node in nodes_by_id.items()
|
|
911
|
+
],
|
|
912
|
+
"blockers": [blocker],
|
|
913
|
+
"canClose": False,
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
pending = set(nodes_by_id)
|
|
917
|
+
decided: dict[str, dict[str, Any]] = {}
|
|
918
|
+
while pending:
|
|
919
|
+
progressed = False
|
|
920
|
+
for node_id in list(pending):
|
|
921
|
+
node = nodes_by_id[node_id]
|
|
922
|
+
dependencies = list(node.get("dependsOn") or [])
|
|
923
|
+
if any(dep not in decided for dep in dependencies):
|
|
924
|
+
continue
|
|
925
|
+
facts = evidence.get(node_id) or {}
|
|
926
|
+
upstream = [decided[dep] for dep in dependencies]
|
|
927
|
+
if any(item["decision"] == "BLOCK" for item in upstream):
|
|
928
|
+
decision, reason = "BLOCK", "upstream-blocked"
|
|
929
|
+
elif any(item["decision"] != "REUSE" for item in upstream):
|
|
930
|
+
decision, reason = "RUN", "upstream-invalidated"
|
|
931
|
+
elif bool(facts.get("reusable")):
|
|
932
|
+
decision, reason = "REUSE", str(facts.get("reason") or "evidence-current")
|
|
933
|
+
elif str(facts.get("decision") or "").upper() == "BLOCK":
|
|
934
|
+
decision, reason = "BLOCK", str(facts.get("reason") or "evidence-blocked")
|
|
935
|
+
else:
|
|
936
|
+
decision, reason = "RUN", str(facts.get("reason") or "evidence-missing")
|
|
937
|
+
decided[node_id] = {
|
|
938
|
+
**node,
|
|
939
|
+
"decision": decision,
|
|
940
|
+
"reason": reason,
|
|
941
|
+
"evidence": facts,
|
|
942
|
+
}
|
|
943
|
+
pending.remove(node_id)
|
|
944
|
+
progressed = True
|
|
945
|
+
if not progressed:
|
|
946
|
+
raise ValueError("requiredGateDag contains a cycle")
|
|
947
|
+
|
|
948
|
+
ordered = [decided[str(raw["id"])] for raw in raw_nodes]
|
|
949
|
+
blockers = [
|
|
950
|
+
{"code": "NODE_BLOCKED", "nodeId": node["id"], "message": node["reason"]}
|
|
951
|
+
for node in ordered
|
|
952
|
+
if node["decision"] == "BLOCK"
|
|
953
|
+
]
|
|
954
|
+
return {
|
|
955
|
+
"nodes": ordered,
|
|
956
|
+
"blockers": blockers,
|
|
957
|
+
"canClose": bool(ordered) and all(node["decision"] == "REUSE" for node in ordered),
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def _event_groups(events: list[dict[str, Any]]) -> list[tuple[str, int, list[dict[str, Any]]]]:
|
|
962
|
+
order: list[tuple[str, int]] = []
|
|
963
|
+
groups: dict[tuple[str, int], list[dict[str, Any]]] = defaultdict(list)
|
|
964
|
+
for event in events:
|
|
965
|
+
key = (str(event.get("phase") or "unknown"), int(event.get("attempt") or 1))
|
|
966
|
+
if key not in groups:
|
|
967
|
+
order.append(key)
|
|
968
|
+
groups[key].append(event)
|
|
969
|
+
return [(phase, attempt, groups[(phase, attempt)]) for phase, attempt in order]
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def timing_dimensions(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
973
|
+
"""Keep runner, orchestration-active, wall-clock, and user wait distinct."""
|
|
974
|
+
output: list[dict[str, Any]] = []
|
|
975
|
+
for phase, attempt, grouped in _event_groups(events):
|
|
976
|
+
timing = he.canonical_phase_timing(grouped)
|
|
977
|
+
runner_values = [
|
|
978
|
+
event.get("duration_ms", event.get("durationMs"))
|
|
979
|
+
for event in grouped
|
|
980
|
+
if event.get("type") == "command"
|
|
981
|
+
and isinstance(event.get("duration_ms", event.get("durationMs")), int)
|
|
982
|
+
]
|
|
983
|
+
wait_values = [
|
|
984
|
+
event.get("user_wait_ms", event.get("userWaitMs"))
|
|
985
|
+
for event in grouped
|
|
986
|
+
if isinstance(event.get("user_wait_ms", event.get("userWaitMs")), int)
|
|
987
|
+
]
|
|
988
|
+
output.append(
|
|
989
|
+
{
|
|
990
|
+
"phase": phase,
|
|
991
|
+
"attempt": attempt,
|
|
992
|
+
"runnerMs": sum(runner_values) if runner_values else None,
|
|
993
|
+
"orchestrationActiveMs": timing.get("activeExecutionMs"),
|
|
994
|
+
"wallClockMs": timing.get("wallClockSpanMs"),
|
|
995
|
+
"userWaitMs": sum(wait_values) if wait_values else None,
|
|
996
|
+
"lateEventCount": timing.get("lateEventCount"),
|
|
997
|
+
}
|
|
998
|
+
)
|
|
999
|
+
return output
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def build_trace(events: list[dict[str, Any]], *, change_id: str) -> dict[str, Any]:
|
|
1003
|
+
"""Project legacy or trace-aware events into stable phase/attempt/tool spans."""
|
|
1004
|
+
event_ids = [str(event.get("id") or index) for index, event in enumerate(events)]
|
|
1005
|
+
explicit_trace = next(
|
|
1006
|
+
(
|
|
1007
|
+
str(event.get("traceId") or event.get("trace_id"))
|
|
1008
|
+
for event in events
|
|
1009
|
+
if event.get("traceId") or event.get("trace_id")
|
|
1010
|
+
),
|
|
1011
|
+
"",
|
|
1012
|
+
)
|
|
1013
|
+
trace_id = explicit_trace if len(explicit_trace) == 32 else _stable_hex(change_id, *event_ids, length=32)
|
|
1014
|
+
root_span = _stable_hex(trace_id, "root", length=16)
|
|
1015
|
+
spans: list[dict[str, Any]] = [
|
|
1016
|
+
{
|
|
1017
|
+
"traceId": trace_id,
|
|
1018
|
+
"spanId": root_span,
|
|
1019
|
+
"parentSpanId": None,
|
|
1020
|
+
"kind": "change",
|
|
1021
|
+
"changeId": change_id,
|
|
1022
|
+
}
|
|
1023
|
+
]
|
|
1024
|
+
for phase, attempt, grouped in _event_groups(events):
|
|
1025
|
+
phase_span = _stable_hex(trace_id, phase, attempt, length=16)
|
|
1026
|
+
spans.append(
|
|
1027
|
+
{
|
|
1028
|
+
"traceId": trace_id,
|
|
1029
|
+
"spanId": phase_span,
|
|
1030
|
+
"parentSpanId": root_span,
|
|
1031
|
+
"kind": "phase-attempt",
|
|
1032
|
+
"phase": phase,
|
|
1033
|
+
"attempt": attempt,
|
|
1034
|
+
}
|
|
1035
|
+
)
|
|
1036
|
+
tools: list[str] = []
|
|
1037
|
+
for event in grouped:
|
|
1038
|
+
tool = str(event.get("executor_tool") or event.get("executorTool") or "").strip()
|
|
1039
|
+
if tool and tool not in tools:
|
|
1040
|
+
tools.append(tool)
|
|
1041
|
+
for tool in tools:
|
|
1042
|
+
spans.append(
|
|
1043
|
+
{
|
|
1044
|
+
"traceId": trace_id,
|
|
1045
|
+
"spanId": _stable_hex(trace_id, phase, attempt, tool, length=16),
|
|
1046
|
+
"parentSpanId": phase_span,
|
|
1047
|
+
"kind": "tool",
|
|
1048
|
+
"phase": phase,
|
|
1049
|
+
"attempt": attempt,
|
|
1050
|
+
"tool": tool,
|
|
1051
|
+
}
|
|
1052
|
+
)
|
|
1053
|
+
return {"schemaVersion": 1, "traceId": trace_id, "spans": spans}
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _number(value: Any, *, integer: bool = True) -> int | float | None:
|
|
1057
|
+
if value is None or isinstance(value, bool):
|
|
1058
|
+
return None
|
|
1059
|
+
try:
|
|
1060
|
+
number = float(value)
|
|
1061
|
+
except (TypeError, ValueError):
|
|
1062
|
+
return None
|
|
1063
|
+
return int(number) if integer else number
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def _junit_metrics(text: str) -> dict[str, Any]:
|
|
1067
|
+
root = ET.fromstring(text)
|
|
1068
|
+
suites = [root] if root.tag.endswith("testsuite") else list(root.findall(".//testsuite"))
|
|
1069
|
+
aggregate = root if root.tag.endswith(("testsuite", "testsuites")) else None
|
|
1070
|
+
attrs = aggregate.attrib if aggregate is not None else {}
|
|
1071
|
+
def attribute_total(name: str, fallback: str | None = None) -> int | None:
|
|
1072
|
+
direct = _number(attrs.get(name, attrs.get(fallback) if fallback else None))
|
|
1073
|
+
if direct is not None:
|
|
1074
|
+
return int(direct)
|
|
1075
|
+
values = [
|
|
1076
|
+
_number(item.attrib.get(name, item.attrib.get(fallback) if fallback else None))
|
|
1077
|
+
for item in suites
|
|
1078
|
+
]
|
|
1079
|
+
return (
|
|
1080
|
+
sum(int(value) for value in values if value is not None)
|
|
1081
|
+
if any(value is not None for value in values)
|
|
1082
|
+
else None
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
total = attribute_total("tests")
|
|
1086
|
+
failures = attribute_total("failures")
|
|
1087
|
+
errors = attribute_total("errors")
|
|
1088
|
+
skipped = attribute_total("skipped", "disabled")
|
|
1089
|
+
duration_seconds = _number(attrs.get("time"), integer=False)
|
|
1090
|
+
if duration_seconds is None:
|
|
1091
|
+
durations = [_number(item.attrib.get("time"), integer=False) for item in suites]
|
|
1092
|
+
duration_seconds = (
|
|
1093
|
+
sum(value for value in durations if value is not None)
|
|
1094
|
+
if any(value is not None for value in durations)
|
|
1095
|
+
else None
|
|
1096
|
+
)
|
|
1097
|
+
passed = None
|
|
1098
|
+
if all(value is not None for value in (total, failures, errors, skipped)):
|
|
1099
|
+
passed = max(int(total) - int(failures) - int(errors) - int(skipped), 0)
|
|
1100
|
+
return {
|
|
1101
|
+
"suites": len(suites) if suites else None,
|
|
1102
|
+
"passed": passed,
|
|
1103
|
+
"failed": failures,
|
|
1104
|
+
"skipped": skipped,
|
|
1105
|
+
"setupErrors": errors,
|
|
1106
|
+
"durationMs": int(duration_seconds * 1000) if duration_seconds is not None else None,
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def normalize_ci_metrics(
|
|
1111
|
+
raw: str | dict[str, Any],
|
|
1112
|
+
*,
|
|
1113
|
+
runner: str,
|
|
1114
|
+
head_sha: str | None,
|
|
1115
|
+
source: str | None = None,
|
|
1116
|
+
) -> dict[str, Any]:
|
|
1117
|
+
"""Normalize JUnit, Vitest, or Playwright machine output without inventing zeros."""
|
|
1118
|
+
normalized_runner = runner.lower()
|
|
1119
|
+
if normalized_runner == "junit":
|
|
1120
|
+
if not isinstance(raw, str):
|
|
1121
|
+
raise ValueError("JUnit input must be XML text")
|
|
1122
|
+
metrics = _junit_metrics(raw)
|
|
1123
|
+
else:
|
|
1124
|
+
if isinstance(raw, str):
|
|
1125
|
+
value = json.loads(raw)
|
|
1126
|
+
else:
|
|
1127
|
+
value = raw
|
|
1128
|
+
if not isinstance(value, dict):
|
|
1129
|
+
raise ValueError("runner metrics must be a JSON object")
|
|
1130
|
+
if normalized_runner == "vitest":
|
|
1131
|
+
runtimes = [
|
|
1132
|
+
_number((item.get("perfStats") or {}).get("runtime"))
|
|
1133
|
+
for item in (value.get("testResults") or [])
|
|
1134
|
+
if isinstance(item, dict)
|
|
1135
|
+
]
|
|
1136
|
+
metrics = {
|
|
1137
|
+
"suites": _number(value.get("numTotalTestSuites")),
|
|
1138
|
+
"passed": _number(value.get("numPassedTests")),
|
|
1139
|
+
"failed": _number(value.get("numFailedTests")),
|
|
1140
|
+
"skipped": _number(value.get("numPendingTests")),
|
|
1141
|
+
"setupErrors": _number(value.get("numRuntimeErrorTestSuites")),
|
|
1142
|
+
"durationMs": sum(item for item in runtimes if item is not None)
|
|
1143
|
+
if any(item is not None for item in runtimes)
|
|
1144
|
+
else None,
|
|
1145
|
+
}
|
|
1146
|
+
elif normalized_runner == "playwright":
|
|
1147
|
+
stats = value.get("stats") if isinstance(value.get("stats"), dict) else {}
|
|
1148
|
+
suites = value.get("suites")
|
|
1149
|
+
error_counts = []
|
|
1150
|
+
for raw_errors in (value.get("errors"), stats.get("errors")):
|
|
1151
|
+
count = len(raw_errors) if isinstance(raw_errors, list) else _number(raw_errors)
|
|
1152
|
+
if count is not None:
|
|
1153
|
+
error_counts.append(count)
|
|
1154
|
+
setup_errors = max(error_counts) if error_counts else None
|
|
1155
|
+
metrics = {
|
|
1156
|
+
"suites": len(suites) if isinstance(suites, list) else None,
|
|
1157
|
+
"passed": _number(stats.get("expected")),
|
|
1158
|
+
"failed": _number(stats.get("unexpected")),
|
|
1159
|
+
"skipped": _number(stats.get("skipped")),
|
|
1160
|
+
"setupErrors": _number(setup_errors),
|
|
1161
|
+
"durationMs": _number(stats.get("duration")),
|
|
1162
|
+
}
|
|
1163
|
+
else:
|
|
1164
|
+
raise ValueError(f"unsupported runner: {runner}")
|
|
1165
|
+
result = {
|
|
1166
|
+
"schemaVersion": CI_METRICS_SCHEMA_VERSION,
|
|
1167
|
+
"runner": normalized_runner,
|
|
1168
|
+
"source": source,
|
|
1169
|
+
**metrics,
|
|
1170
|
+
"headSha": head_sha,
|
|
1171
|
+
}
|
|
1172
|
+
for field in ("suites", "passed", "failed", "skipped", "setupErrors", "durationMs"):
|
|
1173
|
+
value = result.get(field)
|
|
1174
|
+
if value is not None and (not isinstance(value, int) or value < 0):
|
|
1175
|
+
raise ValueError(f"invalid CI metric {field}: expected nonnegative integer or null")
|
|
1176
|
+
if head_sha is not None and not re.fullmatch(r"[0-9a-f]{40}(?:[0-9a-f]{24})?", head_sha):
|
|
1177
|
+
raise ValueError("headSha must be a lowercase 40- or 64-character hex commit id")
|
|
1178
|
+
return result
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _git_head(project_root: Path) -> str | None:
|
|
1182
|
+
try:
|
|
1183
|
+
proc = subprocess.run(
|
|
1184
|
+
["git", "rev-parse", "--verify", "HEAD"],
|
|
1185
|
+
cwd=str(project_root),
|
|
1186
|
+
capture_output=True,
|
|
1187
|
+
text=True,
|
|
1188
|
+
encoding="utf-8",
|
|
1189
|
+
errors="replace",
|
|
1190
|
+
check=False,
|
|
1191
|
+
)
|
|
1192
|
+
except OSError:
|
|
1193
|
+
return None
|
|
1194
|
+
return proc.stdout.strip() if proc.returncode == 0 and proc.stdout.strip() else None
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def assess_identity(
|
|
1198
|
+
change_dir: Path,
|
|
1199
|
+
project_root: Path,
|
|
1200
|
+
ledger: dict[str, Any] | None,
|
|
1201
|
+
*,
|
|
1202
|
+
target_phase: str | None = None,
|
|
1203
|
+
run_id: str | None = None,
|
|
1204
|
+
) -> dict[str, Any]:
|
|
1205
|
+
current_head = _git_head(project_root)
|
|
1206
|
+
capsule: dict[str, Any] | None = None
|
|
1207
|
+
capsule_selection: dict[str, Any] | None = None
|
|
1208
|
+
if target_phase is not None:
|
|
1209
|
+
capsule_selection = select_phase_capsule(
|
|
1210
|
+
change_dir,
|
|
1211
|
+
project_root,
|
|
1212
|
+
phase=target_phase,
|
|
1213
|
+
run_id=run_id,
|
|
1214
|
+
)
|
|
1215
|
+
if not capsule_selection.get("ok"):
|
|
1216
|
+
return {
|
|
1217
|
+
"ok": False,
|
|
1218
|
+
"code": capsule_selection.get("code"),
|
|
1219
|
+
"message": capsule_selection.get("message"),
|
|
1220
|
+
"remediation": capsule_selection.get("remediation"),
|
|
1221
|
+
"capsuleSelection": {
|
|
1222
|
+
key: value
|
|
1223
|
+
for key, value in capsule_selection.items()
|
|
1224
|
+
if key != "capsule"
|
|
1225
|
+
},
|
|
1226
|
+
}
|
|
1227
|
+
selected = capsule_selection.get("capsule")
|
|
1228
|
+
capsule = selected if isinstance(selected, dict) else None
|
|
1229
|
+
if capsule is not None:
|
|
1230
|
+
stored_head = capsule.get("currentHead")
|
|
1231
|
+
if stored_head and current_head and stored_head != current_head:
|
|
1232
|
+
return {
|
|
1233
|
+
"ok": False,
|
|
1234
|
+
"code": "HEAD_MISMATCH",
|
|
1235
|
+
"message": f"phase capsule head {stored_head} != current {current_head}",
|
|
1236
|
+
"storedHead": stored_head,
|
|
1237
|
+
"currentHead": current_head,
|
|
1238
|
+
"capsuleSelection": capsule_selection,
|
|
1239
|
+
}
|
|
1240
|
+
if not ledger or ledger.get("schemaVersion") != 3:
|
|
1241
|
+
return {
|
|
1242
|
+
"ok": True,
|
|
1243
|
+
"code": "IDENTITY_NOT_REUSABLE",
|
|
1244
|
+
"message": "no ledger v3 identity; validations must RUN unless their own evidence is current",
|
|
1245
|
+
"currentHead": current_head,
|
|
1246
|
+
"capsuleSelection": capsule_selection,
|
|
1247
|
+
}
|
|
1248
|
+
missing = hl.validate_ledger_identity(ledger)
|
|
1249
|
+
if missing:
|
|
1250
|
+
return {
|
|
1251
|
+
"ok": False,
|
|
1252
|
+
"code": "LEDGER_IDENTITY_INVALID",
|
|
1253
|
+
"message": "missing: " + ", ".join(missing),
|
|
1254
|
+
}
|
|
1255
|
+
if capsule is not None and capsule.get("baseCommit") not in {
|
|
1256
|
+
None,
|
|
1257
|
+
ledger.get("baseCommit"),
|
|
1258
|
+
}:
|
|
1259
|
+
return {
|
|
1260
|
+
"ok": False,
|
|
1261
|
+
"code": "BASE_MISMATCH",
|
|
1262
|
+
"message": "phase capsule and ledger base commits differ",
|
|
1263
|
+
"capsuleBase": capsule.get("baseCommit"),
|
|
1264
|
+
"ledgerBase": ledger.get("baseCommit"),
|
|
1265
|
+
}
|
|
1266
|
+
try:
|
|
1267
|
+
contract = hpaths.load_change_contract(change_dir)
|
|
1268
|
+
current_repository = hpaths.repository_identity(project_root)
|
|
1269
|
+
current_ownership = hl.ownership_hash(contract)
|
|
1270
|
+
current_diff = hl.compute_ownership_diff(
|
|
1271
|
+
project_root,
|
|
1272
|
+
base=str(ledger["baseCommit"]),
|
|
1273
|
+
change_dir=change_dir,
|
|
1274
|
+
)["diffHash"]
|
|
1275
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
1276
|
+
return {"ok": False, "code": "IDENTITY_RESOLUTION_FAILED", "message": str(exc)}
|
|
1277
|
+
identity_dimensions = {
|
|
1278
|
+
"repositoryId": (ledger.get("repositoryId"), current_repository),
|
|
1279
|
+
"ownershipHash": (ledger.get("ownershipHash"), current_ownership),
|
|
1280
|
+
"currentHead": (ledger.get("currentHead"), current_head),
|
|
1281
|
+
}
|
|
1282
|
+
changed = {
|
|
1283
|
+
key: {"stored": stored, "current": current}
|
|
1284
|
+
for key, (stored, current) in identity_dimensions.items()
|
|
1285
|
+
if stored != current
|
|
1286
|
+
}
|
|
1287
|
+
if changed:
|
|
1288
|
+
return {
|
|
1289
|
+
"ok": False,
|
|
1290
|
+
"code": "LEDGER_IDENTITY_MISMATCH",
|
|
1291
|
+
"message": "ledger identity does not match current change",
|
|
1292
|
+
"mismatches": changed,
|
|
1293
|
+
}
|
|
1294
|
+
if ledger.get("diffHash") != current_diff:
|
|
1295
|
+
return {
|
|
1296
|
+
"ok": True,
|
|
1297
|
+
"code": "DIFF_CHANGED",
|
|
1298
|
+
"message": "ownership diff changed; reuse is decided per node inputs",
|
|
1299
|
+
"storedDiffHash": ledger.get("diffHash"),
|
|
1300
|
+
"currentDiffHash": current_diff,
|
|
1301
|
+
"currentHead": current_head,
|
|
1302
|
+
}
|
|
1303
|
+
return {
|
|
1304
|
+
"ok": True,
|
|
1305
|
+
"code": "IDENTITY_OK",
|
|
1306
|
+
"currentHead": current_head,
|
|
1307
|
+
"capsuleSelection": capsule_selection,
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def _latest_phase_status(events: list[dict[str, Any]], phase: str) -> str | None:
|
|
1312
|
+
status = None
|
|
1313
|
+
for event in he.apply_event_corrections(events):
|
|
1314
|
+
if event.get("type") == "phase.end" and event.get("phase") == phase:
|
|
1315
|
+
status = str(event.get("status") or "").upper() or None
|
|
1316
|
+
return status
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def collect_node_evidence(
|
|
1320
|
+
dag: dict[str, Any],
|
|
1321
|
+
change_dir: Path,
|
|
1322
|
+
events: list[dict[str, Any]],
|
|
1323
|
+
ledger: dict[str, Any] | None,
|
|
1324
|
+
) -> dict[str, dict[str, Any]]:
|
|
1325
|
+
result: dict[str, dict[str, Any]] = {}
|
|
1326
|
+
validations = (ledger or {}).get("validations") or {}
|
|
1327
|
+
sequence_receipts = _load_sequence_receipts(change_dir).get("receipts") or {}
|
|
1328
|
+
for node in dag.get("nodes") or []:
|
|
1329
|
+
node_id = str(node.get("id") or "")
|
|
1330
|
+
if node.get("kind") == "sequence" or node_id.startswith("sequence:"):
|
|
1331
|
+
receipt = (
|
|
1332
|
+
sequence_receipts.get(node_id)
|
|
1333
|
+
if isinstance(sequence_receipts, dict)
|
|
1334
|
+
else None
|
|
1335
|
+
)
|
|
1336
|
+
status = (
|
|
1337
|
+
str(receipt.get("status") or "").upper()
|
|
1338
|
+
if isinstance(receipt, dict)
|
|
1339
|
+
else ""
|
|
1340
|
+
)
|
|
1341
|
+
result[node_id] = {
|
|
1342
|
+
"reusable": status == "OK",
|
|
1343
|
+
"reason": f"sequence-status:{status or 'missing'}",
|
|
1344
|
+
"receipt": receipt,
|
|
1345
|
+
}
|
|
1346
|
+
elif node.get("kind") == "validation" or node_id.startswith("validation:"):
|
|
1347
|
+
verification = node_id.split(":", 1)[1]
|
|
1348
|
+
entry = validations.get(verification) if isinstance(validations, dict) else None
|
|
1349
|
+
if not isinstance(entry, dict):
|
|
1350
|
+
result[node_id] = {"reusable": False, "reason": "validation-missing"}
|
|
1351
|
+
continue
|
|
1352
|
+
files = entry.get("inputsFiles") if isinstance(entry.get("inputsFiles"), list) else []
|
|
1353
|
+
if not files:
|
|
1354
|
+
result[node_id] = {"reusable": False, "reason": "inputs-missing"}
|
|
1355
|
+
continue
|
|
1356
|
+
reuse = hl.decide_can_reuse(
|
|
1357
|
+
change_dir=change_dir,
|
|
1358
|
+
verification=verification,
|
|
1359
|
+
files=[str(item) for item in files],
|
|
1360
|
+
requested_scope=entry.get("scope") if isinstance(entry.get("scope"), str) else None,
|
|
1361
|
+
requested_command=str(entry.get("command") or "") or None,
|
|
1362
|
+
)
|
|
1363
|
+
result[node_id] = {
|
|
1364
|
+
"reusable": bool(reuse.get("reuse")),
|
|
1365
|
+
"reason": str(reuse.get("code") or reuse.get("reason") or "not-reusable"),
|
|
1366
|
+
"reuse": reuse,
|
|
1367
|
+
}
|
|
1368
|
+
else:
|
|
1369
|
+
phase = str(node.get("phase") or node_id.split(":", 1)[-1])
|
|
1370
|
+
status = _latest_phase_status(events, phase)
|
|
1371
|
+
result[node_id] = {
|
|
1372
|
+
"reusable": status in {"OK", "WARN"},
|
|
1373
|
+
"reason": f"phase-status:{status or 'missing'}",
|
|
1374
|
+
"status": status,
|
|
1375
|
+
}
|
|
1376
|
+
return result
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def reconcile(
|
|
1380
|
+
change_dir: Path,
|
|
1381
|
+
project_root: Path,
|
|
1382
|
+
*,
|
|
1383
|
+
target_phase: str | None = None,
|
|
1384
|
+
run_id: str | None = None,
|
|
1385
|
+
) -> dict[str, Any]:
|
|
1386
|
+
change_dir = change_dir.resolve()
|
|
1387
|
+
project_root = project_root.resolve()
|
|
1388
|
+
# 权威切换(2026-08):v2 发布快照优先,classify 工作副本回退。
|
|
1389
|
+
loaded = hpaths.load_change_gate_policy(change_dir)
|
|
1390
|
+
policy = loaded.get("policy")
|
|
1391
|
+
if policy is None:
|
|
1392
|
+
raise ValueError(
|
|
1393
|
+
"gate policy missing; run harness_gate.py classify "
|
|
1394
|
+
"or publish via plan finalize"
|
|
1395
|
+
)
|
|
1396
|
+
dag = (
|
|
1397
|
+
target_required_dag(policy, target_phase)
|
|
1398
|
+
if target_phase is not None
|
|
1399
|
+
else policy.get("requiredGateDag")
|
|
1400
|
+
)
|
|
1401
|
+
if not isinstance(dag, dict):
|
|
1402
|
+
raise ValueError("gate policy missing requiredGateDag; run harness_gate.py classify")
|
|
1403
|
+
ledger, ledger_path = hl.load_ledger(change_dir)
|
|
1404
|
+
events_file = he.events_path(change_dir)
|
|
1405
|
+
events = he.load_events(events_file) if events_file.is_file() else []
|
|
1406
|
+
identity = assess_identity(
|
|
1407
|
+
change_dir,
|
|
1408
|
+
project_root,
|
|
1409
|
+
ledger,
|
|
1410
|
+
target_phase=target_phase,
|
|
1411
|
+
run_id=run_id,
|
|
1412
|
+
)
|
|
1413
|
+
evidence = collect_node_evidence(dag, change_dir, events, ledger)
|
|
1414
|
+
if identity.get("code") not in {"IDENTITY_OK", "DIFF_CHANGED"}:
|
|
1415
|
+
for facts in evidence.values():
|
|
1416
|
+
facts["reusable"] = False
|
|
1417
|
+
facts["reason"] = "identity-not-reusable"
|
|
1418
|
+
if dag.get("nodes"):
|
|
1419
|
+
decision = reconcile_dag(dag, evidence, identity=identity)
|
|
1420
|
+
elif identity.get("ok"):
|
|
1421
|
+
decision = {"nodes": [], "blockers": [], "canClose": True}
|
|
1422
|
+
else:
|
|
1423
|
+
decision = {
|
|
1424
|
+
"nodes": [],
|
|
1425
|
+
"blockers": [
|
|
1426
|
+
{
|
|
1427
|
+
"code": str(identity.get("code") or "IDENTITY_MISMATCH"),
|
|
1428
|
+
"message": str(identity.get("message") or "identity validation failed"),
|
|
1429
|
+
}
|
|
1430
|
+
],
|
|
1431
|
+
"canClose": False,
|
|
1432
|
+
}
|
|
1433
|
+
guard_path = change_dir / "evidence" / "test-guard-snapshot.json"
|
|
1434
|
+
guard = _read_json(guard_path) if guard_path.is_file() else None
|
|
1435
|
+
return {
|
|
1436
|
+
"ok": not decision["blockers"],
|
|
1437
|
+
"action": "reconcile",
|
|
1438
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
1439
|
+
"generatedAt": dt.datetime.now().astimezone().isoformat(timespec="milliseconds"),
|
|
1440
|
+
"changeId": change_dir.name,
|
|
1441
|
+
"projectRoot": str(project_root),
|
|
1442
|
+
"changeDir": str(change_dir),
|
|
1443
|
+
"targetPhase": target_phase,
|
|
1444
|
+
"runId": run_id,
|
|
1445
|
+
"identity": identity,
|
|
1446
|
+
**decision,
|
|
1447
|
+
"guard": guard,
|
|
1448
|
+
"ledgerPath": str(ledger_path) if ledger_path else None,
|
|
1449
|
+
"trace": build_trace(events, change_id=change_dir.name),
|
|
1450
|
+
"timing": timing_dimensions(events),
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def format_compact(result: dict[str, Any]) -> str:
|
|
1455
|
+
lines: list[str] = []
|
|
1456
|
+
for node in result.get("nodes") or []:
|
|
1457
|
+
decision = str(node.get("decision") or "")
|
|
1458
|
+
if decision == "REUSE":
|
|
1459
|
+
continue
|
|
1460
|
+
lines.append(f"{decision} {node.get('id')}: {node.get('reason')}")
|
|
1461
|
+
for blocker in result.get("blockers") or []:
|
|
1462
|
+
lines.append(f"BLOCK {blocker.get('code')}: {blocker.get('message')}")
|
|
1463
|
+
if not lines:
|
|
1464
|
+
nodes = result.get("nodes") or []
|
|
1465
|
+
if not result.get("ok") or not nodes:
|
|
1466
|
+
lines.append(
|
|
1467
|
+
f"BLOCK {result.get('code') or 'EMPTY_RECONCILIATION'}: "
|
|
1468
|
+
f"{result.get('error') or 'no required nodes were reconciled'}"
|
|
1469
|
+
)
|
|
1470
|
+
else:
|
|
1471
|
+
lines.append(f"REUSED {len(nodes)} required nodes; safe to close")
|
|
1472
|
+
return "\n".join(lines)
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
def _close_gate(args: argparse.Namespace) -> dict[str, Any]:
|
|
1476
|
+
command = [
|
|
1477
|
+
sys.executable,
|
|
1478
|
+
str(SCRIPTS_DIR / "harness_gate.py"),
|
|
1479
|
+
"close",
|
|
1480
|
+
"--json",
|
|
1481
|
+
"--phase",
|
|
1482
|
+
args.phase,
|
|
1483
|
+
"--change",
|
|
1484
|
+
Path(args.change_dir).name,
|
|
1485
|
+
"--project",
|
|
1486
|
+
str(Path(args.project).resolve()),
|
|
1487
|
+
"--status",
|
|
1488
|
+
"OK",
|
|
1489
|
+
]
|
|
1490
|
+
if args.run_id:
|
|
1491
|
+
command.extend(["--run-id", args.run_id])
|
|
1492
|
+
proc = subprocess.run(
|
|
1493
|
+
command,
|
|
1494
|
+
capture_output=True,
|
|
1495
|
+
text=True,
|
|
1496
|
+
encoding="utf-8",
|
|
1497
|
+
errors="replace",
|
|
1498
|
+
check=False,
|
|
1499
|
+
)
|
|
1500
|
+
try:
|
|
1501
|
+
payload = json.loads(proc.stdout)
|
|
1502
|
+
except json.JSONDecodeError:
|
|
1503
|
+
payload = {"ok": False, "code": "GATE_CLOSE_INVALID_OUTPUT", "stderr": proc.stderr}
|
|
1504
|
+
payload["exitCode"] = proc.returncode
|
|
1505
|
+
return payload
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def _cmd_reconcile(args: argparse.Namespace) -> int:
|
|
1509
|
+
try:
|
|
1510
|
+
result = reconcile(
|
|
1511
|
+
Path(args.change_dir),
|
|
1512
|
+
Path(args.project),
|
|
1513
|
+
target_phase=args.phase,
|
|
1514
|
+
run_id=args.run_id,
|
|
1515
|
+
)
|
|
1516
|
+
if args.close:
|
|
1517
|
+
if not result.get("canClose"):
|
|
1518
|
+
result["close"] = {
|
|
1519
|
+
"ok": False,
|
|
1520
|
+
"code": "RECONCILE_NOT_CLOSABLE",
|
|
1521
|
+
"message": "required nodes still need RUN or are BLOCKED",
|
|
1522
|
+
}
|
|
1523
|
+
result["ok"] = False
|
|
1524
|
+
result.setdefault("blockers", []).append(
|
|
1525
|
+
{
|
|
1526
|
+
"code": "RECONCILE_NOT_CLOSABLE",
|
|
1527
|
+
"message": "explicit close refused because required nodes are not reusable",
|
|
1528
|
+
}
|
|
1529
|
+
)
|
|
1530
|
+
else:
|
|
1531
|
+
result["close"] = _close_gate(args)
|
|
1532
|
+
if not result["close"].get("ok"):
|
|
1533
|
+
result["ok"] = False
|
|
1534
|
+
if args.output:
|
|
1535
|
+
_write_json(Path(args.output).resolve(), result)
|
|
1536
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
1537
|
+
result = {"ok": False, "action": "reconcile", "code": "RECONCILE_FAILED", "error": str(exc)}
|
|
1538
|
+
if args.json:
|
|
1539
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
1540
|
+
else:
|
|
1541
|
+
print(format_compact(result))
|
|
1542
|
+
return 0 if result.get("ok") else 2
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
def _cmd_metrics(args: argparse.Namespace) -> int:
|
|
1546
|
+
input_path = Path(args.input).resolve()
|
|
1547
|
+
try:
|
|
1548
|
+
text = input_path.read_text(encoding="utf-8-sig")
|
|
1549
|
+
runner = args.runner
|
|
1550
|
+
if runner == "auto":
|
|
1551
|
+
if input_path.suffix.lower() == ".xml":
|
|
1552
|
+
runner = "junit"
|
|
1553
|
+
raw: str | dict[str, Any] = text
|
|
1554
|
+
else:
|
|
1555
|
+
raw = json.loads(text)
|
|
1556
|
+
runner = (
|
|
1557
|
+
"playwright"
|
|
1558
|
+
if isinstance(raw, dict) and "stats" in raw and "suites" in raw
|
|
1559
|
+
else "vitest"
|
|
1560
|
+
)
|
|
1561
|
+
else:
|
|
1562
|
+
raw = text if runner == "junit" else json.loads(text)
|
|
1563
|
+
result = normalize_ci_metrics(
|
|
1564
|
+
raw,
|
|
1565
|
+
runner=runner,
|
|
1566
|
+
head_sha=args.head_sha,
|
|
1567
|
+
source=str(input_path),
|
|
1568
|
+
)
|
|
1569
|
+
if args.output:
|
|
1570
|
+
_write_json(Path(args.output).resolve(), result)
|
|
1571
|
+
except (OSError, ValueError, ET.ParseError, json.JSONDecodeError) as exc:
|
|
1572
|
+
result = {"ok": False, "code": "CI_METRICS_FAILED", "error": str(exc)}
|
|
1573
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
1574
|
+
return 2
|
|
1575
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
1576
|
+
return 0
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
def _cmd_record_sequence(args: argparse.Namespace) -> int:
|
|
1580
|
+
result = record_sequence_receipt(
|
|
1581
|
+
Path(args.change_dir),
|
|
1582
|
+
args.node,
|
|
1583
|
+
{
|
|
1584
|
+
"productCommit": args.product_commit,
|
|
1585
|
+
"productTreeHash": args.product_tree_hash,
|
|
1586
|
+
"environmentHash": args.environment_hash,
|
|
1587
|
+
},
|
|
1588
|
+
status=args.status,
|
|
1589
|
+
)
|
|
1590
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
1591
|
+
return 0 if result.get("ok") else 2
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
def _cmd_sequence_status(args: argparse.Namespace) -> int:
|
|
1595
|
+
result = evaluate_final_sequence(
|
|
1596
|
+
Path(args.change_dir),
|
|
1597
|
+
{
|
|
1598
|
+
"productCommit": args.product_commit,
|
|
1599
|
+
"productTreeHash": args.product_tree_hash,
|
|
1600
|
+
"environmentHash": args.environment_hash,
|
|
1601
|
+
},
|
|
1602
|
+
exclude_nodes={"sequence:archive"} if args.pre_archive else set(),
|
|
1603
|
+
)
|
|
1604
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
1605
|
+
return 0 if result.get("ok") else 2
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
def _subject_from_args(args: argparse.Namespace) -> dict[str, str]:
|
|
1609
|
+
return {
|
|
1610
|
+
"productCommit": args.product_commit,
|
|
1611
|
+
"productTreeHash": args.product_tree_hash,
|
|
1612
|
+
"environmentHash": args.environment_hash,
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
def _cmd_record_environment(args: argparse.Namespace) -> int:
|
|
1617
|
+
result = record_environment_attempt(
|
|
1618
|
+
Path(args.change_dir),
|
|
1619
|
+
_subject_from_args(args),
|
|
1620
|
+
stage=args.stage,
|
|
1621
|
+
status=args.status,
|
|
1622
|
+
duration_ms=args.duration_ms,
|
|
1623
|
+
failure_signature=args.failure_signature,
|
|
1624
|
+
)
|
|
1625
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
1626
|
+
return 0 if result.get("ok") else 2
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def _cmd_record_full(args: argparse.Namespace) -> int:
|
|
1630
|
+
result = record_full_test_execution(
|
|
1631
|
+
Path(args.change_dir),
|
|
1632
|
+
_subject_from_args(args),
|
|
1633
|
+
status=args.status,
|
|
1634
|
+
prepare_ms=args.prepare_ms,
|
|
1635
|
+
test_ms=args.test_ms,
|
|
1636
|
+
cleanup_ms=args.cleanup_ms,
|
|
1637
|
+
result_digest=args.result_digest,
|
|
1638
|
+
supersedes=args.supersedes,
|
|
1639
|
+
)
|
|
1640
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
1641
|
+
return 0 if result.get("ok") else 2
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def _add_subject_arguments(parser: argparse.ArgumentParser) -> None:
|
|
1645
|
+
parser.add_argument("--change-dir", required=True)
|
|
1646
|
+
parser.add_argument("--product-commit", required=True)
|
|
1647
|
+
parser.add_argument("--product-tree-hash", required=True)
|
|
1648
|
+
parser.add_argument("--environment-hash", required=True)
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1652
|
+
parser = argparse.ArgumentParser(prog="harness_phase.py")
|
|
1653
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1654
|
+
reconcile_parser = sub.add_parser("reconcile")
|
|
1655
|
+
reconcile_parser.add_argument("--change-dir", required=True)
|
|
1656
|
+
reconcile_parser.add_argument("--project", required=True)
|
|
1657
|
+
reconcile_parser.add_argument("--json", action="store_true")
|
|
1658
|
+
reconcile_parser.add_argument("--output")
|
|
1659
|
+
reconcile_parser.add_argument("--close", action="store_true")
|
|
1660
|
+
reconcile_parser.add_argument("--phase", default="execute")
|
|
1661
|
+
reconcile_parser.add_argument("--run-id")
|
|
1662
|
+
reconcile_parser.set_defaults(func=_cmd_reconcile)
|
|
1663
|
+
|
|
1664
|
+
metrics_parser = sub.add_parser("metrics")
|
|
1665
|
+
metrics_parser.add_argument("--input", required=True)
|
|
1666
|
+
metrics_parser.add_argument("--runner", choices=("auto", "junit", "vitest", "playwright"), default="auto")
|
|
1667
|
+
metrics_parser.add_argument("--head-sha")
|
|
1668
|
+
metrics_parser.add_argument("--output")
|
|
1669
|
+
metrics_parser.set_defaults(func=_cmd_metrics)
|
|
1670
|
+
|
|
1671
|
+
record_parser = sub.add_parser("record-sequence")
|
|
1672
|
+
record_parser.add_argument("--change-dir", required=True)
|
|
1673
|
+
record_parser.add_argument("--node", required=True)
|
|
1674
|
+
record_parser.add_argument("--product-commit", required=True)
|
|
1675
|
+
record_parser.add_argument("--product-tree-hash", required=True)
|
|
1676
|
+
record_parser.add_argument("--environment-hash", required=True)
|
|
1677
|
+
record_parser.add_argument(
|
|
1678
|
+
"--status",
|
|
1679
|
+
choices=("OK", "WARN", "FAIL"),
|
|
1680
|
+
default="OK",
|
|
1681
|
+
)
|
|
1682
|
+
record_parser.set_defaults(func=_cmd_record_sequence)
|
|
1683
|
+
|
|
1684
|
+
sequence_parser = sub.add_parser("sequence-status")
|
|
1685
|
+
sequence_parser.add_argument("--change-dir", required=True)
|
|
1686
|
+
sequence_parser.add_argument("--product-commit", required=True)
|
|
1687
|
+
sequence_parser.add_argument("--product-tree-hash", required=True)
|
|
1688
|
+
sequence_parser.add_argument("--environment-hash", required=True)
|
|
1689
|
+
sequence_parser.add_argument("--pre-archive", action="store_true")
|
|
1690
|
+
sequence_parser.set_defaults(func=_cmd_sequence_status)
|
|
1691
|
+
|
|
1692
|
+
environment_parser = sub.add_parser("record-environment")
|
|
1693
|
+
_add_subject_arguments(environment_parser)
|
|
1694
|
+
environment_parser.add_argument(
|
|
1695
|
+
"--stage",
|
|
1696
|
+
choices=("prepare", "verify", "release"),
|
|
1697
|
+
required=True,
|
|
1698
|
+
)
|
|
1699
|
+
environment_parser.add_argument(
|
|
1700
|
+
"--status",
|
|
1701
|
+
choices=("OK", "ENVIRONMENT_ERROR", "INTERRUPTED", "FAIL"),
|
|
1702
|
+
required=True,
|
|
1703
|
+
)
|
|
1704
|
+
environment_parser.add_argument("--duration-ms", type=int, required=True)
|
|
1705
|
+
environment_parser.add_argument("--failure-signature")
|
|
1706
|
+
environment_parser.set_defaults(func=_cmd_record_environment)
|
|
1707
|
+
|
|
1708
|
+
full_parser = sub.add_parser("record-full")
|
|
1709
|
+
_add_subject_arguments(full_parser)
|
|
1710
|
+
full_parser.add_argument("--status", choices=("OK", "FAIL"), required=True)
|
|
1711
|
+
full_parser.add_argument("--prepare-ms", type=int, required=True)
|
|
1712
|
+
full_parser.add_argument("--test-ms", type=int, required=True)
|
|
1713
|
+
full_parser.add_argument("--cleanup-ms", type=int, required=True)
|
|
1714
|
+
full_parser.add_argument("--result-digest", required=True)
|
|
1715
|
+
full_parser.add_argument("--supersedes")
|
|
1716
|
+
full_parser.set_defaults(func=_cmd_record_full)
|
|
1717
|
+
return parser
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1721
|
+
args = build_parser().parse_args(argv)
|
|
1722
|
+
return int(args.func(args))
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
if __name__ == "__main__":
|
|
1726
|
+
raise SystemExit(main())
|