@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,2564 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Harness integration transaction.
|
|
3
|
+
|
|
4
|
+
Reentrant, journaled submit pipeline that never stashes, moves or overwrites
|
|
5
|
+
the primary worktree. All target-branch mutation happens inside a temporary
|
|
6
|
+
integration worktree; the journal is the single contract for step order,
|
|
7
|
+
identities and recovery.
|
|
8
|
+
|
|
9
|
+
Steps: preflight → prepare → merge → verify → push → cleanup.
|
|
10
|
+
Journal: ``.harness/state/integration/<transaction-id>/journal.json``.
|
|
11
|
+
|
|
12
|
+
Python 3.10+, stdlib only.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import datetime as dt
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import shlex
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
import uuid
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Callable, Sequence
|
|
31
|
+
|
|
32
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
33
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
34
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
35
|
+
|
|
36
|
+
import harness_change # noqa: E402
|
|
37
|
+
import harness_ledger as hl # noqa: E402
|
|
38
|
+
import harness_paths # noqa: E402
|
|
39
|
+
import harness_process # noqa: E402
|
|
40
|
+
import harness_service # noqa: E402
|
|
41
|
+
|
|
42
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
43
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
44
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
45
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
46
|
+
|
|
47
|
+
STEP_ORDER = ["preflight", "prepare", "merge", "verify", "push", "cleanup"]
|
|
48
|
+
DEFAULT_VERIFY_TIMEOUT_SECONDS = 1800
|
|
49
|
+
HEAVY_WORKTREE_ROOTS = (
|
|
50
|
+
"node_modules", ".venv", "venv", "build", "dist", "target", ".cache", "__pycache__"
|
|
51
|
+
)
|
|
52
|
+
_FOREIGN_PATTERNS = (
|
|
53
|
+
re.compile(r"^\.harness/changes/([^/]+)(/|$)"),
|
|
54
|
+
re.compile(r"^\.harness/state/changes/([^/]+)(/|$)"),
|
|
55
|
+
)
|
|
56
|
+
WORKTREE_LIFECYCLE_STATES = (
|
|
57
|
+
"REGISTERED",
|
|
58
|
+
"MATERIALIZED",
|
|
59
|
+
"AGENT_ATTACHED",
|
|
60
|
+
"ACTIVE",
|
|
61
|
+
"QUIESCING",
|
|
62
|
+
"AGENT_DETACHED",
|
|
63
|
+
"STATE_FINALIZED",
|
|
64
|
+
"REGISTRATION_REMOVED",
|
|
65
|
+
"RESIDUAL_CLEANED",
|
|
66
|
+
"RETIRED",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def now_iso() -> str:
|
|
71
|
+
return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _worktree_registrations(project_root: Path) -> dict[str, dict[str, Any]]:
|
|
75
|
+
proc = subprocess.run(
|
|
76
|
+
["git", "worktree", "list", "--porcelain"],
|
|
77
|
+
cwd=str(project_root),
|
|
78
|
+
capture_output=True,
|
|
79
|
+
text=True,
|
|
80
|
+
encoding="utf-8",
|
|
81
|
+
errors="replace",
|
|
82
|
+
check=False,
|
|
83
|
+
)
|
|
84
|
+
registrations: dict[str, dict[str, Any]] = {}
|
|
85
|
+
current: dict[str, Any] | None = None
|
|
86
|
+
for line in proc.stdout.splitlines() if proc.returncode == 0 else []:
|
|
87
|
+
if line.startswith("worktree "):
|
|
88
|
+
path = str(Path(line.removeprefix("worktree ")).resolve())
|
|
89
|
+
current = {"path": path, "branch": None, "head": None}
|
|
90
|
+
registrations[path] = current
|
|
91
|
+
elif current is not None and line.startswith("branch "):
|
|
92
|
+
current["branch"] = line.removeprefix("branch ")
|
|
93
|
+
elif current is not None and line.startswith("HEAD "):
|
|
94
|
+
current["head"] = line.removeprefix("HEAD ")
|
|
95
|
+
return registrations
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _active_capsules_for_root(
|
|
99
|
+
project_root: Path,
|
|
100
|
+
target_root: Path,
|
|
101
|
+
change_dir: Path | None,
|
|
102
|
+
) -> list[dict[str, Any]]:
|
|
103
|
+
candidates: list[Path] = []
|
|
104
|
+
change_dirs = [change_dir.resolve()] if change_dir is not None else []
|
|
105
|
+
if not change_dirs:
|
|
106
|
+
for root in (
|
|
107
|
+
project_root / ".harness" / "changes",
|
|
108
|
+
project_root / ".harness" / "state" / "changes",
|
|
109
|
+
):
|
|
110
|
+
if root.is_dir():
|
|
111
|
+
change_dirs.extend(path for path in root.iterdir() if path.is_dir())
|
|
112
|
+
for root in change_dirs:
|
|
113
|
+
for capsule_root in (
|
|
114
|
+
root / "runtime" / "phase-context",
|
|
115
|
+
root / "phase-context",
|
|
116
|
+
):
|
|
117
|
+
if capsule_root.is_dir():
|
|
118
|
+
candidates.extend(sorted(capsule_root.glob("*.json")))
|
|
119
|
+
active: list[dict[str, Any]] = []
|
|
120
|
+
for path in candidates:
|
|
121
|
+
try:
|
|
122
|
+
capsule = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
123
|
+
except (OSError, json.JSONDecodeError):
|
|
124
|
+
continue
|
|
125
|
+
if not isinstance(capsule, dict):
|
|
126
|
+
continue
|
|
127
|
+
raw_root = str(capsule.get("executionRoot") or "").strip()
|
|
128
|
+
if not raw_root:
|
|
129
|
+
continue
|
|
130
|
+
try:
|
|
131
|
+
same_root = Path(raw_root).resolve() == target_root
|
|
132
|
+
except OSError:
|
|
133
|
+
same_root = False
|
|
134
|
+
transaction = capsule.get("closeTransaction")
|
|
135
|
+
transaction_status = (
|
|
136
|
+
str(transaction.get("status") or "").upper()
|
|
137
|
+
if isinstance(transaction, dict)
|
|
138
|
+
else ""
|
|
139
|
+
)
|
|
140
|
+
closed = transaction_status == "CLOSED" or bool(
|
|
141
|
+
capsule.get("closedAt") and capsule.get("closeStatus")
|
|
142
|
+
)
|
|
143
|
+
if same_root and not closed:
|
|
144
|
+
active.append(
|
|
145
|
+
{
|
|
146
|
+
"path": str(path),
|
|
147
|
+
"phase": capsule.get("phase"),
|
|
148
|
+
"runId": capsule.get("runId"),
|
|
149
|
+
"transactionStatus": transaction_status or "OPEN",
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
return active
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def evaluate_worktree_retirement(
|
|
156
|
+
project_root: Path,
|
|
157
|
+
target_root: Path,
|
|
158
|
+
*,
|
|
159
|
+
change_dir: Path | None = None,
|
|
160
|
+
agent_root: Path | None = None,
|
|
161
|
+
junction_dependencies: list[dict[str, Any]] | None = None,
|
|
162
|
+
) -> dict[str, Any]:
|
|
163
|
+
"""Journal a unified, read-only preflight for safe worktree retirement."""
|
|
164
|
+
project_root = project_root.resolve()
|
|
165
|
+
target_root = target_root.resolve()
|
|
166
|
+
registrations = _worktree_registrations(project_root)
|
|
167
|
+
registration = registrations.get(str(target_root))
|
|
168
|
+
active_capsules = _active_capsules_for_root(
|
|
169
|
+
project_root,
|
|
170
|
+
target_root,
|
|
171
|
+
change_dir,
|
|
172
|
+
)
|
|
173
|
+
if junction_dependencies is None:
|
|
174
|
+
junction_path = (
|
|
175
|
+
project_root
|
|
176
|
+
/ ".harness"
|
|
177
|
+
/ "config"
|
|
178
|
+
/ "junction-dependencies.json"
|
|
179
|
+
)
|
|
180
|
+
try:
|
|
181
|
+
loaded = json.loads(junction_path.read_text(encoding="utf-8-sig"))
|
|
182
|
+
except (OSError, json.JSONDecodeError):
|
|
183
|
+
loaded = []
|
|
184
|
+
junction_dependencies = loaded if isinstance(loaded, list) else []
|
|
185
|
+
matching_junctions = []
|
|
186
|
+
for item in junction_dependencies:
|
|
187
|
+
if not isinstance(item, dict):
|
|
188
|
+
continue
|
|
189
|
+
try:
|
|
190
|
+
matches = Path(str(item.get("target") or "")).resolve() == target_root
|
|
191
|
+
except OSError:
|
|
192
|
+
matches = False
|
|
193
|
+
if matches:
|
|
194
|
+
matching_junctions.append(item)
|
|
195
|
+
attached = False
|
|
196
|
+
if agent_root is not None:
|
|
197
|
+
try:
|
|
198
|
+
resolved_agent = agent_root.resolve()
|
|
199
|
+
attached = (
|
|
200
|
+
resolved_agent == target_root
|
|
201
|
+
or target_root in resolved_agent.parents
|
|
202
|
+
)
|
|
203
|
+
except OSError:
|
|
204
|
+
attached = True
|
|
205
|
+
blockers: list[dict[str, Any]] = []
|
|
206
|
+
if active_capsules:
|
|
207
|
+
blockers.append(
|
|
208
|
+
{
|
|
209
|
+
"code": "ACTIVE_PHASE_CAPSULE",
|
|
210
|
+
"message": "phase capsules still reference the worktree",
|
|
211
|
+
"capsules": active_capsules,
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
if attached:
|
|
215
|
+
blockers.append(
|
|
216
|
+
{
|
|
217
|
+
"code": "AGENT_STILL_ATTACHED",
|
|
218
|
+
"message": "Agent root/cwd still points into the worktree",
|
|
219
|
+
"agentRoot": str(agent_root.resolve()) if agent_root else None,
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
if matching_junctions:
|
|
223
|
+
blockers.append(
|
|
224
|
+
{
|
|
225
|
+
"code": "JUNCTION_DEPENDENCY_ACTIVE",
|
|
226
|
+
"message": "junction dependencies must be removed or migrated",
|
|
227
|
+
"junctions": matching_junctions,
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
receipt = {
|
|
231
|
+
"schemaVersion": 1,
|
|
232
|
+
"lifecycleId": hashlib.sha256(
|
|
233
|
+
str(target_root).encode("utf-8")
|
|
234
|
+
).hexdigest()[:20],
|
|
235
|
+
"projectRoot": str(project_root),
|
|
236
|
+
"targetRoot": str(target_root),
|
|
237
|
+
"state": "ACTIVE" if blockers else "QUIESCING",
|
|
238
|
+
"allowedStates": list(WORKTREE_LIFECYCLE_STATES),
|
|
239
|
+
"registration": registration,
|
|
240
|
+
"diskPathPresent": target_root.exists(),
|
|
241
|
+
"agentAttached": attached,
|
|
242
|
+
"activeCapsules": active_capsules,
|
|
243
|
+
"junctionDependencies": matching_junctions,
|
|
244
|
+
"blockers": blockers,
|
|
245
|
+
"ok": not blockers,
|
|
246
|
+
"code": (
|
|
247
|
+
"WORKTREE_RETIREMENT_READY"
|
|
248
|
+
if not blockers
|
|
249
|
+
else "WORKTREE_RETIREMENT_BLOCKED"
|
|
250
|
+
),
|
|
251
|
+
"updatedAt": now_iso(),
|
|
252
|
+
}
|
|
253
|
+
receipt_path = (
|
|
254
|
+
project_root
|
|
255
|
+
/ ".harness"
|
|
256
|
+
/ "runtime"
|
|
257
|
+
/ "worktree-lifecycle"
|
|
258
|
+
/ f"{receipt['lifecycleId']}.json"
|
|
259
|
+
)
|
|
260
|
+
receipt_path.parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
temporary = receipt_path.with_name(
|
|
262
|
+
f".{receipt_path.name}.{os.getpid()}.tmp"
|
|
263
|
+
)
|
|
264
|
+
try:
|
|
265
|
+
temporary.write_text(
|
|
266
|
+
json.dumps(receipt, ensure_ascii=False, indent=2) + "\n",
|
|
267
|
+
encoding="utf-8",
|
|
268
|
+
newline="\n",
|
|
269
|
+
)
|
|
270
|
+
os.replace(temporary, receipt_path)
|
|
271
|
+
finally:
|
|
272
|
+
temporary.unlink(missing_ok=True)
|
|
273
|
+
receipt["receiptPath"] = str(receipt_path)
|
|
274
|
+
return receipt
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _split_verify_command(raw: str, *, platform: str = sys.platform) -> list[str]:
|
|
278
|
+
# Harness profile/CLI commands use one portable quoting contract. Keep
|
|
279
|
+
# POSIX tokenization on Windows too; executable normalization happens later.
|
|
280
|
+
return shlex.split(raw, posix=True)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _dedupe_verify_commands(
|
|
284
|
+
commands: Sequence[Sequence[str]],
|
|
285
|
+
) -> list[list[str]]:
|
|
286
|
+
"""Preserve order while preventing explicit/profile duplicate execution."""
|
|
287
|
+
result: list[list[str]] = []
|
|
288
|
+
seen: set[tuple[str, ...]] = set()
|
|
289
|
+
for command in commands:
|
|
290
|
+
normalized = tuple(str(item) for item in command)
|
|
291
|
+
if normalized in seen:
|
|
292
|
+
continue
|
|
293
|
+
seen.add(normalized)
|
|
294
|
+
result.append(list(normalized))
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
_HEAVY_VERIFICATIONS = frozenset({"unitTestFull", "apiTest", "browserTest", "package"})
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _merge_command_kind(item: Any) -> tuple[str, str | None]:
|
|
302
|
+
"""Classify typed merge commands; legacy strings remain smoke."""
|
|
303
|
+
if not isinstance(item, dict):
|
|
304
|
+
return "smoke", None
|
|
305
|
+
verification = str(item.get("verification") or item.get("target") or "").strip() or None
|
|
306
|
+
declared = str(item.get("kind") or item.get("class") or "").strip().lower()
|
|
307
|
+
if declared in {"integrity", "smoke", "heavyweight"}:
|
|
308
|
+
return declared, verification
|
|
309
|
+
return ("heavyweight" if verification in _HEAVY_VERIFICATIONS else "smoke"), verification
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _reusable_merge_evidence(
|
|
313
|
+
ledger_path: Path | None,
|
|
314
|
+
*,
|
|
315
|
+
verification: str | None,
|
|
316
|
+
command: Sequence[str],
|
|
317
|
+
product_tree_hash: str | None,
|
|
318
|
+
reuse_context: dict[str, str],
|
|
319
|
+
) -> dict[str, Any] | None:
|
|
320
|
+
"""Return feature-tip evidence when merge content and execution key match."""
|
|
321
|
+
if ledger_path is None or verification is None or not product_tree_hash:
|
|
322
|
+
return None
|
|
323
|
+
try:
|
|
324
|
+
ledger = json.loads(ledger_path.read_text(encoding="utf-8-sig"))
|
|
325
|
+
except (OSError, json.JSONDecodeError):
|
|
326
|
+
return None
|
|
327
|
+
raw = ledger.get("verificationTargets")
|
|
328
|
+
entries = raw.values() if isinstance(raw, dict) else raw if isinstance(raw, list) else []
|
|
329
|
+
command_hash = hl.command_set_hash(" ".join(str(part) for part in command))
|
|
330
|
+
for entry in reversed(list(entries)):
|
|
331
|
+
if not isinstance(entry, dict) or entry.get("verification") != verification:
|
|
332
|
+
continue
|
|
333
|
+
if entry.get("status") != "OK":
|
|
334
|
+
continue
|
|
335
|
+
if str(entry.get("productTreeHash") or "").removeprefix("sha256:") != product_tree_hash.removeprefix("sha256:"):
|
|
336
|
+
continue
|
|
337
|
+
if str(entry.get("commandSetHash") or "") != command_hash:
|
|
338
|
+
continue
|
|
339
|
+
# Context identity is fail-closed: an evidence field may only be
|
|
340
|
+
# reused when the merge policy supplies the same current value.
|
|
341
|
+
if any(
|
|
342
|
+
str(entry.get(field) or "").strip()
|
|
343
|
+
and str(entry.get(field) or "").strip()
|
|
344
|
+
!= str(reuse_context.get(field) or "").strip()
|
|
345
|
+
for field in ("environmentHash", "toolchainHash", "lockHash", "dbSchemaHash")
|
|
346
|
+
):
|
|
347
|
+
continue
|
|
348
|
+
return {
|
|
349
|
+
"reusedFrom": {
|
|
350
|
+
"targetId": entry.get("id"),
|
|
351
|
+
"sourceCommit": entry.get("productCommit") or entry.get("currentHead"),
|
|
352
|
+
"sourceCandidate": entry.get("candidateId"),
|
|
353
|
+
},
|
|
354
|
+
"reuseReason": "product tree and canonical execution key matched",
|
|
355
|
+
"reuseKey": hl.canonical_reuse_key({**entry, "verification": verification}),
|
|
356
|
+
}
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _normalize_verify_command(
|
|
361
|
+
command: Sequence[str],
|
|
362
|
+
*,
|
|
363
|
+
platform: str = sys.platform,
|
|
364
|
+
resolver: Callable[[str], str | None] = shutil.which,
|
|
365
|
+
) -> list[str]:
|
|
366
|
+
"""Resolve Windows package-manager wrappers without enabling shell mode."""
|
|
367
|
+
result = [str(item) for item in command]
|
|
368
|
+
if not result or not platform.lower().startswith("win"):
|
|
369
|
+
return result
|
|
370
|
+
executable = Path(result[0]).name.lower()
|
|
371
|
+
if Path(executable).suffix:
|
|
372
|
+
return result
|
|
373
|
+
if executable not in {"npm", "npx", "pnpm", "yarn", "corepack"}:
|
|
374
|
+
return result
|
|
375
|
+
resolved = resolver(executable + ".cmd") or resolver(executable)
|
|
376
|
+
if resolved:
|
|
377
|
+
result[0] = resolved
|
|
378
|
+
return result
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# 合并态验证的兜底优先级:先跑覆盖面最广的,再退到窄的。profile 里这些命令
|
|
382
|
+
# 本来就是 ledger 一直在用的规范命令,没有理由让调用方在合并时另行手敲一遍。
|
|
383
|
+
MERGE_VERIFICATION_FALLBACK_ORDER = ("unitTestFull", "unitTest", "apiTest", "compile")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def verify_plan_recovery_action() -> str:
|
|
387
|
+
"""把 VERIFY_PLAN_MISSING 补成一句能照做的话。
|
|
388
|
+
|
|
389
|
+
此前它只说 "requires at least one executable command"。于是调用方要么去读
|
|
390
|
+
实现,要么随手塞一条命令让门过去——两条路都走过,后者更糟:它让"验证通过"
|
|
391
|
+
这句话失去含义。
|
|
392
|
+
"""
|
|
393
|
+
return (
|
|
394
|
+
"合并态验证没有可执行计划。两条正规出路:"
|
|
395
|
+
"(1) 在 .harness/config/build-profile.json 增加 mergeVerification.requiredOnMerge "
|
|
396
|
+
"声明合并时必跑的命令(推荐,一次配置长期生效);"
|
|
397
|
+
"(2) 本次用 --command 传入。注意 --command 可重复,每次只接**一条**命令,"
|
|
398
|
+
"且按 argv 直接执行(不经过 shell)——`&&`、`|`、`cd x && y` 都不成立,"
|
|
399
|
+
"需要拆成多个 --command。"
|
|
400
|
+
"示例:--command \"npm test\" --command \"npm run lint\"。"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _fallback_merge_commands(profile: Any) -> list[dict[str, Any]]:
|
|
405
|
+
"""从 profile 已有的验证目标推导合并态计划。"""
|
|
406
|
+
commands = profile.get("commands") if isinstance(profile, dict) else None
|
|
407
|
+
if not isinstance(commands, dict):
|
|
408
|
+
return []
|
|
409
|
+
for name in MERGE_VERIFICATION_FALLBACK_ORDER:
|
|
410
|
+
entry = commands.get(name)
|
|
411
|
+
if not isinstance(entry, dict):
|
|
412
|
+
continue
|
|
413
|
+
argv = entry.get("argvTemplate")
|
|
414
|
+
if isinstance(argv, list) and argv and all(
|
|
415
|
+
isinstance(item, str) and item for item in argv
|
|
416
|
+
):
|
|
417
|
+
return [{
|
|
418
|
+
"command": [str(item) for item in argv],
|
|
419
|
+
"kind": "heavyweight",
|
|
420
|
+
"verification": name,
|
|
421
|
+
"source": "profile-fallback",
|
|
422
|
+
"reuseContext": {},
|
|
423
|
+
}]
|
|
424
|
+
raw = entry.get("command")
|
|
425
|
+
if isinstance(raw, str) and raw.strip():
|
|
426
|
+
return [{
|
|
427
|
+
"command": _split_verify_command(raw.strip()),
|
|
428
|
+
"kind": "heavyweight",
|
|
429
|
+
"verification": name,
|
|
430
|
+
"source": "profile-fallback",
|
|
431
|
+
"reuseContext": {},
|
|
432
|
+
}]
|
|
433
|
+
return []
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def merge_verification_plan(profile: Any) -> list[dict[str, Any]]:
|
|
437
|
+
"""声明优先,推导兜底;两者都没有才返回空计划。"""
|
|
438
|
+
merge = profile.get("mergeVerification") if isinstance(profile, dict) else None
|
|
439
|
+
required = merge.get("requiredOnMerge") if isinstance(merge, dict) else None
|
|
440
|
+
if isinstance(required, list) and required:
|
|
441
|
+
plan: list[dict[str, Any]] = []
|
|
442
|
+
for index, item in enumerate(required):
|
|
443
|
+
if isinstance(item, str) and item.strip():
|
|
444
|
+
plan.append({
|
|
445
|
+
"command": _split_verify_command(item.strip()),
|
|
446
|
+
"kind": "smoke",
|
|
447
|
+
"verification": None,
|
|
448
|
+
"source": "profile",
|
|
449
|
+
"reuseContext": {},
|
|
450
|
+
})
|
|
451
|
+
continue
|
|
452
|
+
if not isinstance(item, dict):
|
|
453
|
+
raise ValueError(f"requiredOnMerge[{index}] must be string or object")
|
|
454
|
+
raw = item.get("command")
|
|
455
|
+
if isinstance(raw, list) and raw and all(
|
|
456
|
+
isinstance(value, str) and value for value in raw
|
|
457
|
+
):
|
|
458
|
+
command = [str(value) for value in raw]
|
|
459
|
+
elif isinstance(raw, str) and raw.strip():
|
|
460
|
+
command = _split_verify_command(raw.strip())
|
|
461
|
+
else:
|
|
462
|
+
raise ValueError(f"requiredOnMerge[{index}].command missing")
|
|
463
|
+
kind = str(item.get("kind") or "smoke")
|
|
464
|
+
plan.append({
|
|
465
|
+
"command": command,
|
|
466
|
+
"kind": kind,
|
|
467
|
+
"verification": item.get("verification"),
|
|
468
|
+
"source": "profile",
|
|
469
|
+
"reuseContext": {
|
|
470
|
+
field: str(item.get(field)).strip()
|
|
471
|
+
for field in (
|
|
472
|
+
"environmentHash",
|
|
473
|
+
"toolchainHash",
|
|
474
|
+
"lockHash",
|
|
475
|
+
"dbSchemaHash",
|
|
476
|
+
)
|
|
477
|
+
if str(item.get(field) or "").strip()
|
|
478
|
+
},
|
|
479
|
+
})
|
|
480
|
+
return plan
|
|
481
|
+
return _fallback_merge_commands(profile)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def verification_depth(plan: Sequence[dict[str, Any]]) -> str:
|
|
485
|
+
"""一条临时探测命令通过,不该和跑完整条链读起来一样。
|
|
486
|
+
|
|
487
|
+
2026-08-19 那次合并,verify 只跑了一个测试文件就 DONE,报告里看不出任何差别。
|
|
488
|
+
真正的全链验证是人工在事务外补跑的——账面与事实脱节,正是这种静默造成的。
|
|
489
|
+
"""
|
|
490
|
+
entries = [item for item in plan if isinstance(item, dict)]
|
|
491
|
+
if len(entries) == 1 and entries[0].get("source") not in {"profile", "profile-fallback"}:
|
|
492
|
+
return "thin"
|
|
493
|
+
return "declared"
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class IntegrationError(Exception):
|
|
497
|
+
code = "INTEGRATION_ERROR"
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
class IntegrationLockHeldError(IntegrationError):
|
|
501
|
+
code = "INTEGRATION_LOCK_HELD"
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
class MergeFailedError(IntegrationError):
|
|
505
|
+
code = "MERGE_FAILED"
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class ForeignChangePathsError(IntegrationError):
|
|
509
|
+
code = "FOREIGN_CHANGE_PATHS"
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class VerificationFailedError(IntegrationError):
|
|
513
|
+
code = "VERIFICATION_FAILED"
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
class VerifyPlanMissingError(IntegrationError):
|
|
517
|
+
code = "VERIFY_PLAN_MISSING"
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class TargetMovedError(IntegrationError):
|
|
521
|
+
code = "TARGET_MOVED"
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
class RemoteProbeFailedError(IntegrationError):
|
|
525
|
+
"""Remote probe failed (network/auth/process error), not target moved (retro §5.28)."""
|
|
526
|
+
|
|
527
|
+
code = "REMOTE_PROBE_FAILED"
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
class LedgerSyncError(IntegrationError):
|
|
531
|
+
code = "LEDGER_SYNC_PENDING"
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
class EvidenceRebindRefusedError(IntegrationError):
|
|
535
|
+
code = "EVIDENCE_REBIND_REFUSED"
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
class CleanupRefusedError(IntegrationError):
|
|
539
|
+
code = "CLEANUP_REFUSED"
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
class CleanupResidualError(IntegrationError):
|
|
543
|
+
code = "CLEANUP_RESIDUAL"
|
|
544
|
+
|
|
545
|
+
def __init__(self, result: dict[str, Any]):
|
|
546
|
+
self.result = result
|
|
547
|
+
super().__init__(
|
|
548
|
+
"worktree cleanup retained paths: "
|
|
549
|
+
+ ", ".join(result.get("residualPaths") or ["<registration>"])
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
class AbandonRefusedError(IntegrationError):
|
|
554
|
+
code = "ABANDON_REFUSED"
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
class JournalConflictError(IntegrationError):
|
|
558
|
+
code = "JOURNAL_CONFLICT"
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
JOURNAL_LOCK_STALE_SECONDS = 300
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
class GitRunner:
|
|
565
|
+
"""Recordable git invocation wrapper (command spy for audits)."""
|
|
566
|
+
|
|
567
|
+
def __init__(self) -> None:
|
|
568
|
+
self.history: list[tuple[str, tuple[str, ...]]] = []
|
|
569
|
+
|
|
570
|
+
def run(self, cwd: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
|
|
571
|
+
self.history.append((str(cwd), tuple(args)))
|
|
572
|
+
proc = subprocess.run(
|
|
573
|
+
["git", *args],
|
|
574
|
+
cwd=str(cwd),
|
|
575
|
+
capture_output=True,
|
|
576
|
+
text=True,
|
|
577
|
+
encoding="utf-8",
|
|
578
|
+
errors="replace",
|
|
579
|
+
check=False,
|
|
580
|
+
)
|
|
581
|
+
if check and proc.returncode != 0:
|
|
582
|
+
raise IntegrationError(
|
|
583
|
+
f"git {' '.join(args)} failed ({proc.returncode}): {proc.stderr.strip()}"
|
|
584
|
+
)
|
|
585
|
+
return proc
|
|
586
|
+
|
|
587
|
+
def text(self, cwd: Path, *args: str) -> str | None:
|
|
588
|
+
proc = self.run(cwd, *args, check=False)
|
|
589
|
+
if proc.returncode != 0:
|
|
590
|
+
return None
|
|
591
|
+
return proc.stdout.strip()
|
|
592
|
+
|
|
593
|
+
def remote_probe(self, cwd: Path, *args: str) -> dict[str, Any]:
|
|
594
|
+
"""Typed remote probe result (retro §5.28).
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
{
|
|
598
|
+
"exitCode": int,
|
|
599
|
+
"stdoutHash": str | None, # first whitespace-delimited token
|
|
600
|
+
"redactedStderr": str, # stderr with credentials redacted
|
|
601
|
+
"category": "ok" | "target-moved" | "probe-failed" | "auth-failed",
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
`category` distinguishes:
|
|
605
|
+
- `ok`: exit=0, hash available
|
|
606
|
+
- `target-moved`: only meaningful when caller compares hash to expected
|
|
607
|
+
- `probe-failed`: exit!=0, network/process error (allow bounded retry)
|
|
608
|
+
- `auth-failed`: exit!=0, stderr contains auth/credential indicators
|
|
609
|
+
|
|
610
|
+
`None` must never enter the "found head" field; callers must branch on
|
|
611
|
+
`exitCode` before comparing hashes.
|
|
612
|
+
"""
|
|
613
|
+
proc = self.run(cwd, *args, check=False)
|
|
614
|
+
stderr = (proc.stderr or "").strip()
|
|
615
|
+
# Redact potential credentials in stderr (URLs with user:pass@, tokens)
|
|
616
|
+
redacted = re.sub(
|
|
617
|
+
r"(https?|ssh)://[^\s]+@[^\s]+", r"\1://***@***", stderr
|
|
618
|
+
)
|
|
619
|
+
redacted = re.sub(r"(token|password|secret|key)\s*[:=]\s*\S+", r"\1=***", redacted, flags=re.I)
|
|
620
|
+
stdout_hash: str | None = None
|
|
621
|
+
if proc.returncode == 0:
|
|
622
|
+
stdout = (proc.stdout or "").strip()
|
|
623
|
+
if stdout:
|
|
624
|
+
stdout_hash = stdout.split()[0]
|
|
625
|
+
# Classify category
|
|
626
|
+
if proc.returncode == 0:
|
|
627
|
+
category = "ok"
|
|
628
|
+
elif re.search(r"auth|credential|permission|forbidden|unauthorized", stderr, re.I):
|
|
629
|
+
category = "auth-failed"
|
|
630
|
+
else:
|
|
631
|
+
category = "probe-failed"
|
|
632
|
+
return {
|
|
633
|
+
"exitCode": proc.returncode,
|
|
634
|
+
"stdoutHash": stdout_hash,
|
|
635
|
+
"redactedStderr": redacted,
|
|
636
|
+
"category": category,
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _sanitize(value: str) -> str:
|
|
641
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-") or "txn"
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _compact_transaction_id(change_id: str, run_id: str, *, limit: int = 48) -> str:
|
|
645
|
+
readable = f"{_sanitize(change_id)}-{_sanitize(run_id)}"
|
|
646
|
+
if len(readable) <= limit:
|
|
647
|
+
return readable
|
|
648
|
+
digest = hashlib.sha256(readable.encode("utf-8")).hexdigest()[:12]
|
|
649
|
+
prefix = _sanitize(change_id)[: max(8, limit - len(digest) - 1)]
|
|
650
|
+
return f"{prefix}-{digest}"
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def default_temp_root(project_root: Path) -> Path:
|
|
654
|
+
"""Use a deliberately short Windows worktree root to protect MAX_PATH."""
|
|
655
|
+
configured = os.environ.get("HUNTER_HARNESS_INTEGRATION_ROOT")
|
|
656
|
+
if configured:
|
|
657
|
+
return Path(configured).expanduser().resolve()
|
|
658
|
+
resolved = Path(project_root).resolve()
|
|
659
|
+
if os.name == "nt" and resolved.drive:
|
|
660
|
+
return Path(resolved.drive + os.sep) / "hh-int"
|
|
661
|
+
return resolved / ".harness" / "runtime" / "i"
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def journal_dir(project_root: Path, transaction_id: str) -> Path:
|
|
665
|
+
return (
|
|
666
|
+
Path(project_root)
|
|
667
|
+
/ ".harness"
|
|
668
|
+
/ "state"
|
|
669
|
+
/ "integration"
|
|
670
|
+
/ transaction_id
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def journal_path(project_root: Path, transaction_id: str) -> Path:
|
|
675
|
+
return journal_dir(project_root, transaction_id) / "journal.json"
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _write_verify_log(
|
|
679
|
+
project_root: Path,
|
|
680
|
+
transaction_id: str,
|
|
681
|
+
index: int,
|
|
682
|
+
*,
|
|
683
|
+
command: Sequence[str],
|
|
684
|
+
resolved_command: Sequence[str],
|
|
685
|
+
started: dt.datetime,
|
|
686
|
+
finished: dt.datetime,
|
|
687
|
+
exit_code: int | None,
|
|
688
|
+
stdout: str | bytes | None = None,
|
|
689
|
+
stderr: str | bytes | None = None,
|
|
690
|
+
error: str | None = None,
|
|
691
|
+
) -> str:
|
|
692
|
+
def as_text(value: str | bytes | None) -> str:
|
|
693
|
+
if isinstance(value, bytes):
|
|
694
|
+
return value.decode("utf-8", errors="replace")
|
|
695
|
+
return value or ""
|
|
696
|
+
|
|
697
|
+
path = journal_dir(project_root, transaction_id) / "logs" / f"verify-{index + 1}.log"
|
|
698
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
699
|
+
lines = [
|
|
700
|
+
f"command: {json.dumps(list(command), ensure_ascii=False)}",
|
|
701
|
+
f"resolvedCommand: {json.dumps(list(resolved_command), ensure_ascii=False)}",
|
|
702
|
+
f"startedAt: {started.isoformat(timespec='milliseconds')}",
|
|
703
|
+
f"finishedAt: {finished.isoformat(timespec='milliseconds')}",
|
|
704
|
+
f"durationMs: {int((finished - started).total_seconds() * 1000)}",
|
|
705
|
+
f"exitCode: {exit_code if exit_code is not None else 'null'}",
|
|
706
|
+
]
|
|
707
|
+
if error:
|
|
708
|
+
lines.append(f"error: {error}")
|
|
709
|
+
lines.extend(["--- stdout ---", as_text(stdout), "--- stderr ---", as_text(stderr)])
|
|
710
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
|
|
711
|
+
return path.relative_to(project_root).as_posix()
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def load_journal(project_root: Path, transaction_id: str) -> dict[str, Any]:
|
|
715
|
+
path = journal_path(project_root, transaction_id)
|
|
716
|
+
if not path.is_file():
|
|
717
|
+
raise FileNotFoundError(f"journal not found: {path}")
|
|
718
|
+
return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _pid_alive(pid: int) -> bool:
|
|
722
|
+
return harness_process.is_pid_alive(pid)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _acquire_journal_lock(lock_path: Path, journal_path: Path) -> int:
|
|
726
|
+
for _attempt in range(2):
|
|
727
|
+
try:
|
|
728
|
+
descriptor = os.open(
|
|
729
|
+
str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600
|
|
730
|
+
)
|
|
731
|
+
payload = json.dumps(
|
|
732
|
+
{"pid": os.getpid(), "createdAtEpoch": time.time()},
|
|
733
|
+
separators=(",", ":"),
|
|
734
|
+
).encode("utf-8")
|
|
735
|
+
try:
|
|
736
|
+
os.write(descriptor, payload)
|
|
737
|
+
os.fsync(descriptor)
|
|
738
|
+
except BaseException:
|
|
739
|
+
os.close(descriptor)
|
|
740
|
+
lock_path.unlink(missing_ok=True)
|
|
741
|
+
raise
|
|
742
|
+
return descriptor
|
|
743
|
+
except FileExistsError as exc:
|
|
744
|
+
stale = False
|
|
745
|
+
try:
|
|
746
|
+
owner = json.loads(lock_path.read_text(encoding="utf-8-sig"))
|
|
747
|
+
owner_pid = int(owner.get("pid") or 0)
|
|
748
|
+
created = float(owner.get("createdAtEpoch") or 0)
|
|
749
|
+
stale = not _pid_alive(owner_pid) and (
|
|
750
|
+
created <= 0
|
|
751
|
+
or time.time() - created > JOURNAL_LOCK_STALE_SECONDS
|
|
752
|
+
)
|
|
753
|
+
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
|
754
|
+
try:
|
|
755
|
+
stale = (
|
|
756
|
+
time.time() - lock_path.stat().st_mtime
|
|
757
|
+
> JOURNAL_LOCK_STALE_SECONDS
|
|
758
|
+
)
|
|
759
|
+
except OSError:
|
|
760
|
+
stale = True
|
|
761
|
+
if stale:
|
|
762
|
+
try:
|
|
763
|
+
lock_path.unlink()
|
|
764
|
+
except FileNotFoundError:
|
|
765
|
+
pass
|
|
766
|
+
continue
|
|
767
|
+
raise JournalConflictError(
|
|
768
|
+
f"journal writer already active: {journal_path}"
|
|
769
|
+
) from exc
|
|
770
|
+
raise JournalConflictError(f"could not reclaim stale journal lock: {journal_path}")
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _write_journal(project_root: Path, journal: dict[str, Any]) -> None:
|
|
774
|
+
path = journal_path(project_root, journal["transactionId"])
|
|
775
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
776
|
+
lock_path = path.with_name(path.name + ".lock")
|
|
777
|
+
lock_fd = _acquire_journal_lock(lock_path, path)
|
|
778
|
+
os.close(lock_fd)
|
|
779
|
+
expected_revision = int(journal.get("revision") or 0)
|
|
780
|
+
try:
|
|
781
|
+
if path.is_file():
|
|
782
|
+
current = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
783
|
+
current_revision = int(current.get("revision") or 0)
|
|
784
|
+
if current_revision != expected_revision:
|
|
785
|
+
raise JournalConflictError(
|
|
786
|
+
f"stale journal revision: expected {expected_revision}, "
|
|
787
|
+
f"current {current_revision}"
|
|
788
|
+
)
|
|
789
|
+
elif expected_revision != 0:
|
|
790
|
+
raise JournalConflictError(
|
|
791
|
+
f"journal disappeared at revision {expected_revision}: {path}"
|
|
792
|
+
)
|
|
793
|
+
journal["revision"] = expected_revision + 1
|
|
794
|
+
text = json.dumps(journal, ensure_ascii=False, indent=2) + "\n"
|
|
795
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
|
796
|
+
try:
|
|
797
|
+
tmp.write_text(text, encoding="utf-8", newline="\n")
|
|
798
|
+
os.replace(tmp, path)
|
|
799
|
+
except BaseException:
|
|
800
|
+
tmp.unlink(missing_ok=True)
|
|
801
|
+
raise
|
|
802
|
+
finally:
|
|
803
|
+
lock_path.unlink(missing_ok=True)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
class IntegrationTransaction:
|
|
807
|
+
def __init__(
|
|
808
|
+
self,
|
|
809
|
+
*,
|
|
810
|
+
project_root: Path,
|
|
811
|
+
change_id: str,
|
|
812
|
+
run_id: str,
|
|
813
|
+
target_branch: str,
|
|
814
|
+
feature_branch: str,
|
|
815
|
+
temp_root: Path,
|
|
816
|
+
runner: GitRunner | None = None,
|
|
817
|
+
) -> None:
|
|
818
|
+
self.project_root = harness_paths.resolve_main_project_root(Path(project_root))
|
|
819
|
+
self.change_id = change_id
|
|
820
|
+
self.run_id = run_id
|
|
821
|
+
self.target_branch = target_branch
|
|
822
|
+
self.feature_branch = feature_branch
|
|
823
|
+
self.temp_root = Path(temp_root).resolve()
|
|
824
|
+
self.runner = runner or GitRunner()
|
|
825
|
+
self.transaction_id = _compact_transaction_id(change_id, run_id)
|
|
826
|
+
self.temp_branch = f"harness/integration/{self.transaction_id}"
|
|
827
|
+
|
|
828
|
+
# ------------------------------------------------------------- journal
|
|
829
|
+
|
|
830
|
+
def _new_journal(self, base: str, feature_head: str) -> dict[str, Any]:
|
|
831
|
+
return {
|
|
832
|
+
"schemaVersion": 1,
|
|
833
|
+
"transactionId": self.transaction_id,
|
|
834
|
+
"changeName": self.change_id,
|
|
835
|
+
"evidenceChangeName": self.change_id,
|
|
836
|
+
"runId": self.run_id,
|
|
837
|
+
"repositoryId": harness_paths.repository_identity(self.project_root),
|
|
838
|
+
"primaryRoot": str(self.project_root),
|
|
839
|
+
"targetBranch": self.target_branch,
|
|
840
|
+
"featureBranch": self.feature_branch,
|
|
841
|
+
"base": base,
|
|
842
|
+
"featureHead": feature_head,
|
|
843
|
+
"evidenceIdentity": self._evidence_identity(),
|
|
844
|
+
"mergeCommit": None,
|
|
845
|
+
"pushedHead": None,
|
|
846
|
+
"integrationRoot": str(self.temp_root / self.transaction_id),
|
|
847
|
+
"allowedCleanupRoot": str(self.temp_root / self.transaction_id),
|
|
848
|
+
"protectionRefs": {
|
|
849
|
+
"base": f"refs/harness/integration/{self.transaction_id}/base",
|
|
850
|
+
"head": f"refs/harness/integration/{self.transaction_id}/head",
|
|
851
|
+
},
|
|
852
|
+
"steps": [
|
|
853
|
+
{"name": name, "status": "PENDING", "attempt": 0}
|
|
854
|
+
for name in STEP_ORDER
|
|
855
|
+
],
|
|
856
|
+
"createdAt": now_iso(),
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
def _change_roots(self, change_id: str) -> tuple[Path, Path]:
|
|
860
|
+
if re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", change_id) is None:
|
|
861
|
+
raise EvidenceRebindRefusedError(
|
|
862
|
+
"evidence change must be a non-empty kebab-case path segment"
|
|
863
|
+
)
|
|
864
|
+
contract_base = (self.project_root / ".harness" / "changes").resolve()
|
|
865
|
+
state_base = (
|
|
866
|
+
self.project_root / ".harness" / "state" / "changes"
|
|
867
|
+
).resolve()
|
|
868
|
+
raw_change_dir = contract_base / change_id
|
|
869
|
+
raw_state_dir = state_base / change_id
|
|
870
|
+
change_dir = raw_change_dir.resolve()
|
|
871
|
+
state_dir = raw_state_dir.resolve()
|
|
872
|
+
direct_contract_path = os.path.normcase(str(raw_change_dir.absolute()))
|
|
873
|
+
direct_state_path = os.path.normcase(str(raw_state_dir.absolute()))
|
|
874
|
+
if (
|
|
875
|
+
change_dir.parent != contract_base
|
|
876
|
+
or state_dir.parent != state_base
|
|
877
|
+
or os.path.normcase(str(change_dir)) != direct_contract_path
|
|
878
|
+
or os.path.normcase(str(state_dir)) != direct_state_path
|
|
879
|
+
):
|
|
880
|
+
raise EvidenceRebindRefusedError(
|
|
881
|
+
"evidence change must remain inside the direct Change roots"
|
|
882
|
+
)
|
|
883
|
+
return change_dir, state_dir
|
|
884
|
+
|
|
885
|
+
def _bound_evidence_change(self, journal: dict[str, Any]) -> str:
|
|
886
|
+
if journal.get("changeName") != self.change_id:
|
|
887
|
+
raise JournalConflictError(
|
|
888
|
+
"transaction Change does not match the journal"
|
|
889
|
+
)
|
|
890
|
+
if "evidenceChangeName" not in journal:
|
|
891
|
+
return self.change_id
|
|
892
|
+
raw_change_id = journal.get("evidenceChangeName")
|
|
893
|
+
if not isinstance(raw_change_id, str) or not raw_change_id:
|
|
894
|
+
raise EvidenceRebindRefusedError(
|
|
895
|
+
"journal evidence Change binding is invalid"
|
|
896
|
+
)
|
|
897
|
+
change_id = raw_change_id
|
|
898
|
+
self._change_roots(change_id)
|
|
899
|
+
if change_id == self.change_id:
|
|
900
|
+
return change_id
|
|
901
|
+
rebind = journal.get("evidenceRebind") or {}
|
|
902
|
+
if (
|
|
903
|
+
rebind.get("status") != "DONE"
|
|
904
|
+
or rebind.get("from") != self.change_id
|
|
905
|
+
or rebind.get("to") != change_id
|
|
906
|
+
or rebind.get("reason")
|
|
907
|
+
!= "REMOTE_CONFIRMED_DEFAULT_LEDGER_MISSING"
|
|
908
|
+
or rebind.get("remoteHead") != journal.get("mergeCommit")
|
|
909
|
+
):
|
|
910
|
+
raise EvidenceRebindRefusedError(
|
|
911
|
+
"journal evidence Change override has no valid recovery receipt"
|
|
912
|
+
)
|
|
913
|
+
return change_id
|
|
914
|
+
|
|
915
|
+
def _evidence_identity(self, change_id: str | None = None) -> dict[str, Any]:
|
|
916
|
+
change_dir, state_dir = self._change_roots(change_id or self.change_id)
|
|
917
|
+
state_candidates = [
|
|
918
|
+
state_dir,
|
|
919
|
+
change_dir,
|
|
920
|
+
]
|
|
921
|
+
events_file = next(
|
|
922
|
+
(root / "events.ndjson" for root in state_candidates if (root / "events.ndjson").is_file()),
|
|
923
|
+
None,
|
|
924
|
+
)
|
|
925
|
+
event_count = 0
|
|
926
|
+
last_event_id = None
|
|
927
|
+
event_hash = None
|
|
928
|
+
if events_file is not None:
|
|
929
|
+
raw = events_file.read_bytes()
|
|
930
|
+
event_hash = "sha256:" + hashlib.sha256(raw).hexdigest()
|
|
931
|
+
for line in raw.decode("utf-8", "replace").splitlines():
|
|
932
|
+
if not line.strip():
|
|
933
|
+
continue
|
|
934
|
+
event_count += 1
|
|
935
|
+
try:
|
|
936
|
+
item = json.loads(line)
|
|
937
|
+
except json.JSONDecodeError:
|
|
938
|
+
continue
|
|
939
|
+
last_event_id = item.get("id") or item.get("eventId") or last_event_id
|
|
940
|
+
|
|
941
|
+
artifact_hash = None
|
|
942
|
+
ledger_identity: dict[str, Any] = {}
|
|
943
|
+
for root in state_candidates:
|
|
944
|
+
for rel in (
|
|
945
|
+
Path("evidence/artifact-manifest.json"),
|
|
946
|
+
Path("evidence/archive-manifest-before.json"),
|
|
947
|
+
Path("evidence/test-tracking.json"),
|
|
948
|
+
):
|
|
949
|
+
candidate = root / rel
|
|
950
|
+
if candidate.is_file():
|
|
951
|
+
artifact_hash = "sha256:" + hashlib.sha256(candidate.read_bytes()).hexdigest()
|
|
952
|
+
break
|
|
953
|
+
if artifact_hash:
|
|
954
|
+
break
|
|
955
|
+
ledger_path = self._ledger_path(change_id or self.change_id)
|
|
956
|
+
if ledger_path is not None:
|
|
957
|
+
try:
|
|
958
|
+
ledger = json.loads(ledger_path.read_text(encoding="utf-8-sig"))
|
|
959
|
+
except (OSError, json.JSONDecodeError):
|
|
960
|
+
ledger = {}
|
|
961
|
+
if isinstance(ledger, dict):
|
|
962
|
+
for key in (
|
|
963
|
+
"schemaVersion",
|
|
964
|
+
"repositoryId",
|
|
965
|
+
"changeName",
|
|
966
|
+
"baseCommit",
|
|
967
|
+
"currentHead",
|
|
968
|
+
"diffHash",
|
|
969
|
+
"ownershipHash",
|
|
970
|
+
):
|
|
971
|
+
ledger_identity[key] = ledger.get(key)
|
|
972
|
+
return {
|
|
973
|
+
"eventHighWater": {
|
|
974
|
+
"count": event_count,
|
|
975
|
+
"lastEventId": last_event_id,
|
|
976
|
+
"sha256": event_hash,
|
|
977
|
+
},
|
|
978
|
+
"artifactManifestHash": artifact_hash,
|
|
979
|
+
"ledgerIdentity": ledger_identity,
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
def _ledger_path(self, change_id: str) -> Path | None:
|
|
983
|
+
change_dir, state_dir = self._change_roots(change_id)
|
|
984
|
+
candidates = (
|
|
985
|
+
state_dir / "evidence" / "verification-ledger.json",
|
|
986
|
+
change_dir / "evidence" / "verification-ledger.json",
|
|
987
|
+
state_dir / "verification-ledger.json",
|
|
988
|
+
)
|
|
989
|
+
for path in candidates:
|
|
990
|
+
if not path.is_file():
|
|
991
|
+
continue
|
|
992
|
+
resolved = path.resolve()
|
|
993
|
+
if path.is_symlink() or not any(
|
|
994
|
+
resolved.is_relative_to(root)
|
|
995
|
+
for root in (state_dir, change_dir)
|
|
996
|
+
):
|
|
997
|
+
raise EvidenceRebindRefusedError(
|
|
998
|
+
"verification ledger must remain inside its direct Change root"
|
|
999
|
+
)
|
|
1000
|
+
return path
|
|
1001
|
+
return None
|
|
1002
|
+
|
|
1003
|
+
def _finalize_remote_push(
|
|
1004
|
+
self,
|
|
1005
|
+
journal: dict[str, Any],
|
|
1006
|
+
*,
|
|
1007
|
+
merge_commit: str,
|
|
1008
|
+
remote_head: str,
|
|
1009
|
+
) -> None:
|
|
1010
|
+
evidence_change = self._bound_evidence_change(journal)
|
|
1011
|
+
journal.update({
|
|
1012
|
+
"evidenceChangeName": evidence_change,
|
|
1013
|
+
"pushedHead": merge_commit,
|
|
1014
|
+
"mergeFinalHash": merge_commit,
|
|
1015
|
+
"ciExpectedHead": merge_commit,
|
|
1016
|
+
"remoteHead": remote_head,
|
|
1017
|
+
"pushIntent": {
|
|
1018
|
+
"status": "REMOTE_CONFIRMED",
|
|
1019
|
+
"expectedRemoteHead": journal.get("remoteTargetHead") or journal.get("base"),
|
|
1020
|
+
"mergeCommit": merge_commit,
|
|
1021
|
+
"observedRemoteHead": remote_head,
|
|
1022
|
+
"updatedAt": now_iso(),
|
|
1023
|
+
},
|
|
1024
|
+
"ledgerSync": {"status": "PENDING", "updatedAt": now_iso()},
|
|
1025
|
+
})
|
|
1026
|
+
self._save(journal)
|
|
1027
|
+
ledger_path = self._ledger_path(evidence_change)
|
|
1028
|
+
if ledger_path is None:
|
|
1029
|
+
result = {"ok": False, "code": "LEDGER_MISSING"}
|
|
1030
|
+
else:
|
|
1031
|
+
result = hl.record_integration_hashes(
|
|
1032
|
+
ledger_path,
|
|
1033
|
+
change_dir=(
|
|
1034
|
+
self.project_root / ".harness" / "changes" / evidence_change
|
|
1035
|
+
),
|
|
1036
|
+
repository_id=str(journal["repositoryId"]),
|
|
1037
|
+
merge_final_hash=merge_commit,
|
|
1038
|
+
ci_expected_head=merge_commit,
|
|
1039
|
+
remote_head=remote_head,
|
|
1040
|
+
)
|
|
1041
|
+
journal = self._load()
|
|
1042
|
+
journal["ledgerSync"] = {
|
|
1043
|
+
"status": "DONE" if result.get("ok") else "FAILED",
|
|
1044
|
+
"result": result,
|
|
1045
|
+
"updatedAt": now_iso(),
|
|
1046
|
+
}
|
|
1047
|
+
self._save(journal)
|
|
1048
|
+
if not result.get("ok"):
|
|
1049
|
+
raise LedgerSyncError(
|
|
1050
|
+
f"remote push is confirmed but ledger sync requires recovery: {result.get('code')}"
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
def _assert_cleanup_consistency(self, journal: dict[str, Any]) -> None:
|
|
1054
|
+
evidence_change = self._bound_evidence_change(journal)
|
|
1055
|
+
push_step = self._step(journal, "push")
|
|
1056
|
+
merge_commit = str(journal.get("mergeCommit") or "")
|
|
1057
|
+
remote_line = self.runner.text(
|
|
1058
|
+
self.project_root, "ls-remote", "origin", self.target_branch
|
|
1059
|
+
)
|
|
1060
|
+
observed_remote = (
|
|
1061
|
+
remote_line.split()[0] if remote_line and remote_line.split() else ""
|
|
1062
|
+
)
|
|
1063
|
+
remote_contains_merge = bool(merge_commit and observed_remote == merge_commit)
|
|
1064
|
+
if push_step.get("status") != "DONE" and not remote_contains_merge:
|
|
1065
|
+
if self._step(journal, "prepare").get("status") == "DONE":
|
|
1066
|
+
raise CleanupRefusedError(
|
|
1067
|
+
"push is not complete and remote does not contain the merge commit"
|
|
1068
|
+
)
|
|
1069
|
+
return
|
|
1070
|
+
final_values = [
|
|
1071
|
+
journal.get("pushedHead"),
|
|
1072
|
+
journal.get("mergeFinalHash"),
|
|
1073
|
+
journal.get("ciExpectedHead"),
|
|
1074
|
+
journal.get("remoteHead"),
|
|
1075
|
+
observed_remote,
|
|
1076
|
+
]
|
|
1077
|
+
if any(not isinstance(value, str) or not value for value in final_values) or len(
|
|
1078
|
+
set(final_values)
|
|
1079
|
+
) != 1:
|
|
1080
|
+
raise CleanupRefusedError(
|
|
1081
|
+
"remote/journal final hashes are missing or inconsistent"
|
|
1082
|
+
)
|
|
1083
|
+
ledger_path = self._ledger_path(evidence_change)
|
|
1084
|
+
if ledger_path is None:
|
|
1085
|
+
raise CleanupRefusedError("verification ledger missing before cleanup")
|
|
1086
|
+
try:
|
|
1087
|
+
ledger = json.loads(ledger_path.read_text(encoding="utf-8-sig"))
|
|
1088
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
1089
|
+
raise CleanupRefusedError(f"verification ledger unreadable: {exc}") from exc
|
|
1090
|
+
ledger_values = [
|
|
1091
|
+
ledger.get("mergeFinalHash"),
|
|
1092
|
+
ledger.get("ciExpectedHead"),
|
|
1093
|
+
ledger.get("remoteHead"),
|
|
1094
|
+
]
|
|
1095
|
+
if len(set(ledger_values + [observed_remote])) != 1:
|
|
1096
|
+
raise CleanupRefusedError(
|
|
1097
|
+
"remote/journal/ledger final hashes are inconsistent"
|
|
1098
|
+
)
|
|
1099
|
+
ancestor = self.runner.run(
|
|
1100
|
+
self.project_root,
|
|
1101
|
+
"merge-base",
|
|
1102
|
+
"--is-ancestor",
|
|
1103
|
+
str(journal.get("featureHead") or ""),
|
|
1104
|
+
observed_remote,
|
|
1105
|
+
check=False,
|
|
1106
|
+
)
|
|
1107
|
+
if ancestor.returncode != 0:
|
|
1108
|
+
raise CleanupRefusedError(
|
|
1109
|
+
"feature head is not an ancestor of the pushed target"
|
|
1110
|
+
)
|
|
1111
|
+
|
|
1112
|
+
def _load(self) -> dict[str, Any]:
|
|
1113
|
+
return load_journal(self.project_root, self.transaction_id)
|
|
1114
|
+
|
|
1115
|
+
def _save(self, journal: dict[str, Any]) -> dict[str, Any]:
|
|
1116
|
+
_write_journal(self.project_root, journal)
|
|
1117
|
+
return journal
|
|
1118
|
+
|
|
1119
|
+
def _step(self, journal: dict[str, Any], name: str) -> dict[str, Any]:
|
|
1120
|
+
return next(s for s in journal["steps"] if s["name"] == name)
|
|
1121
|
+
|
|
1122
|
+
def _run_step(
|
|
1123
|
+
self,
|
|
1124
|
+
name: str,
|
|
1125
|
+
action: Callable[[dict[str, Any]], None],
|
|
1126
|
+
) -> dict[str, Any]:
|
|
1127
|
+
journal = self._load()
|
|
1128
|
+
step = self._step(journal, name)
|
|
1129
|
+
if step["status"] == "DONE":
|
|
1130
|
+
step["reentry"] = "REUSED"
|
|
1131
|
+
return self._save(journal)
|
|
1132
|
+
step["status"] = "RUNNING"
|
|
1133
|
+
step["attempt"] = int(step.get("attempt", 0)) + 1
|
|
1134
|
+
step["startedAt"] = now_iso()
|
|
1135
|
+
step.pop("error", None)
|
|
1136
|
+
self._save(journal)
|
|
1137
|
+
try:
|
|
1138
|
+
action(journal)
|
|
1139
|
+
except IntegrationError as exc:
|
|
1140
|
+
journal = self._load()
|
|
1141
|
+
step = self._step(journal, name)
|
|
1142
|
+
step["status"] = "FAILED"
|
|
1143
|
+
step["error"] = f"{exc.code}: {exc}"
|
|
1144
|
+
step["finishedAt"] = now_iso()
|
|
1145
|
+
self._save(journal)
|
|
1146
|
+
raise
|
|
1147
|
+
except Exception as exc: # unexpected: still journal the failure
|
|
1148
|
+
journal = self._load()
|
|
1149
|
+
step = self._step(journal, name)
|
|
1150
|
+
step["status"] = "FAILED"
|
|
1151
|
+
step["error"] = f"UNEXPECTED: {exc}"
|
|
1152
|
+
step["finishedAt"] = now_iso()
|
|
1153
|
+
self._save(journal)
|
|
1154
|
+
raise
|
|
1155
|
+
journal = self._load()
|
|
1156
|
+
step = self._step(journal, name)
|
|
1157
|
+
step["status"] = "DONE"
|
|
1158
|
+
step["finishedAt"] = now_iso()
|
|
1159
|
+
return self._save(journal)
|
|
1160
|
+
|
|
1161
|
+
# -------------------------------------------------------------- steps
|
|
1162
|
+
|
|
1163
|
+
def preflight(self) -> dict[str, Any]:
|
|
1164
|
+
def action(journal: dict[str, Any]) -> None:
|
|
1165
|
+
self.runner.run(
|
|
1166
|
+
self.project_root,
|
|
1167
|
+
"fetch",
|
|
1168
|
+
"origin",
|
|
1169
|
+
self.target_branch,
|
|
1170
|
+
check=False,
|
|
1171
|
+
)
|
|
1172
|
+
local_target = self.runner.text(
|
|
1173
|
+
self.project_root, "rev-parse", self.target_branch
|
|
1174
|
+
)
|
|
1175
|
+
remote_line = self.runner.text(
|
|
1176
|
+
self.project_root, "ls-remote", "origin", self.target_branch
|
|
1177
|
+
)
|
|
1178
|
+
remote_target = (
|
|
1179
|
+
remote_line.split()[0] if remote_line and remote_line.split() else None
|
|
1180
|
+
)
|
|
1181
|
+
feature_head = self.runner.text(
|
|
1182
|
+
self.project_root, "rev-parse", self.feature_branch
|
|
1183
|
+
)
|
|
1184
|
+
if not local_target or not feature_head:
|
|
1185
|
+
raise IntegrationError("target or feature branch not found")
|
|
1186
|
+
reconciliation_required = False
|
|
1187
|
+
base = local_target
|
|
1188
|
+
base_source = "local"
|
|
1189
|
+
if remote_target:
|
|
1190
|
+
if remote_target == local_target:
|
|
1191
|
+
base = remote_target
|
|
1192
|
+
base_source = "remote"
|
|
1193
|
+
else:
|
|
1194
|
+
local_behind = self.runner.run(
|
|
1195
|
+
self.project_root,
|
|
1196
|
+
"merge-base",
|
|
1197
|
+
"--is-ancestor",
|
|
1198
|
+
local_target,
|
|
1199
|
+
remote_target,
|
|
1200
|
+
check=False,
|
|
1201
|
+
)
|
|
1202
|
+
remote_behind = self.runner.run(
|
|
1203
|
+
self.project_root,
|
|
1204
|
+
"merge-base",
|
|
1205
|
+
"--is-ancestor",
|
|
1206
|
+
remote_target,
|
|
1207
|
+
local_target,
|
|
1208
|
+
check=False,
|
|
1209
|
+
)
|
|
1210
|
+
if local_behind.returncode == 0:
|
|
1211
|
+
base = remote_target
|
|
1212
|
+
base_source = "remote-ahead"
|
|
1213
|
+
reconciliation_required = True
|
|
1214
|
+
elif remote_behind.returncode == 0:
|
|
1215
|
+
base = local_target
|
|
1216
|
+
base_source = "local-ahead"
|
|
1217
|
+
else:
|
|
1218
|
+
raise TargetMovedError(
|
|
1219
|
+
f"local and remote {self.target_branch} diverged: "
|
|
1220
|
+
f"local={local_target}, remote={remote_target}"
|
|
1221
|
+
)
|
|
1222
|
+
fresh = self._new_journal(base, feature_head)
|
|
1223
|
+
fresh["baseSource"] = base_source
|
|
1224
|
+
fresh["localTargetHead"] = local_target
|
|
1225
|
+
fresh["remoteTargetHead"] = remote_target
|
|
1226
|
+
fresh["primaryReconciliationRequired"] = reconciliation_required
|
|
1227
|
+
fresh["revision"] = int(journal.get("revision") or 0)
|
|
1228
|
+
journal.clear()
|
|
1229
|
+
journal.update(fresh)
|
|
1230
|
+
# Re-mark preflight RUNNING on the fresh journal.
|
|
1231
|
+
step = self._step(journal, "preflight")
|
|
1232
|
+
step["status"] = "RUNNING"
|
|
1233
|
+
step["attempt"] = 1
|
|
1234
|
+
step["startedAt"] = now_iso()
|
|
1235
|
+
refs = journal["protectionRefs"]
|
|
1236
|
+
self.runner.run(
|
|
1237
|
+
self.project_root, "update-ref", refs["base"], base
|
|
1238
|
+
)
|
|
1239
|
+
self.runner.run(
|
|
1240
|
+
self.project_root, "update-ref", refs["head"], feature_head
|
|
1241
|
+
)
|
|
1242
|
+
self._save(journal)
|
|
1243
|
+
|
|
1244
|
+
try:
|
|
1245
|
+
existing = self._load()
|
|
1246
|
+
step = self._step(existing, "preflight")
|
|
1247
|
+
if step["status"] == "DONE":
|
|
1248
|
+
step["reentry"] = "REUSED"
|
|
1249
|
+
return self._save(existing)
|
|
1250
|
+
except FileNotFoundError:
|
|
1251
|
+
pass
|
|
1252
|
+
|
|
1253
|
+
# Acquire the target lock BEFORE writing any state: a rejected
|
|
1254
|
+
# contender must leave the worktree and state files untouched.
|
|
1255
|
+
lock = harness_change.integration_lock_acquire(
|
|
1256
|
+
self.project_root, run_id=self.run_id
|
|
1257
|
+
)
|
|
1258
|
+
if not lock.get("ok"):
|
|
1259
|
+
raise IntegrationLockHeldError(
|
|
1260
|
+
f"integration lock held: {lock.get('holder')}"
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
# Bootstrap: write an initial journal so _run_step has something to load.
|
|
1264
|
+
initial = {
|
|
1265
|
+
"schemaVersion": 1,
|
|
1266
|
+
"transactionId": self.transaction_id,
|
|
1267
|
+
"changeName": self.change_id,
|
|
1268
|
+
"runId": self.run_id,
|
|
1269
|
+
"steps": [
|
|
1270
|
+
{"name": name, "status": "PENDING", "attempt": 0}
|
|
1271
|
+
for name in STEP_ORDER
|
|
1272
|
+
],
|
|
1273
|
+
}
|
|
1274
|
+
self._save(initial)
|
|
1275
|
+
return self._run_step("preflight", action)
|
|
1276
|
+
|
|
1277
|
+
def prepare(self) -> dict[str, Any]:
|
|
1278
|
+
def action(journal: dict[str, Any]) -> None:
|
|
1279
|
+
# Best-effort fetch; absence of a remote is tolerated.
|
|
1280
|
+
self.runner.run(
|
|
1281
|
+
self.project_root,
|
|
1282
|
+
"fetch",
|
|
1283
|
+
"origin",
|
|
1284
|
+
self.target_branch,
|
|
1285
|
+
check=False,
|
|
1286
|
+
)
|
|
1287
|
+
base = journal["base"]
|
|
1288
|
+
self.runner.run(
|
|
1289
|
+
self.project_root, "branch", "-f", self.temp_branch, base
|
|
1290
|
+
)
|
|
1291
|
+
intg_root = Path(journal["integrationRoot"])
|
|
1292
|
+
intg_root.parent.mkdir(parents=True, exist_ok=True)
|
|
1293
|
+
self.runner.run(
|
|
1294
|
+
self.project_root,
|
|
1295
|
+
"worktree",
|
|
1296
|
+
"add",
|
|
1297
|
+
str(intg_root),
|
|
1298
|
+
self.temp_branch,
|
|
1299
|
+
)
|
|
1300
|
+
# H-5: LF-stable hashing in the integration worktree.
|
|
1301
|
+
self.runner.run(intg_root, "config", "core.autocrlf", "false")
|
|
1302
|
+
|
|
1303
|
+
return self._run_step("prepare", action)
|
|
1304
|
+
|
|
1305
|
+
def _foreign_paths(self, journal: dict[str, Any]) -> list[str]:
|
|
1306
|
+
feature_base = self.runner.text(
|
|
1307
|
+
self.project_root,
|
|
1308
|
+
"merge-base",
|
|
1309
|
+
journal["base"],
|
|
1310
|
+
journal["featureHead"],
|
|
1311
|
+
) or journal["base"]
|
|
1312
|
+
diff = self.runner.text(
|
|
1313
|
+
self.project_root,
|
|
1314
|
+
"diff",
|
|
1315
|
+
"--name-only",
|
|
1316
|
+
feature_base,
|
|
1317
|
+
journal["featureHead"],
|
|
1318
|
+
)
|
|
1319
|
+
contract: dict[str, Any] = {}
|
|
1320
|
+
contract_path = (
|
|
1321
|
+
self.project_root
|
|
1322
|
+
/ ".harness"
|
|
1323
|
+
/ "changes"
|
|
1324
|
+
/ self.change_id
|
|
1325
|
+
/ "meta"
|
|
1326
|
+
/ "change-context.json"
|
|
1327
|
+
)
|
|
1328
|
+
if contract_path.is_file():
|
|
1329
|
+
try:
|
|
1330
|
+
contract = json.loads(contract_path.read_text(encoding="utf-8-sig"))
|
|
1331
|
+
except (OSError, json.JSONDecodeError):
|
|
1332
|
+
contract = {}
|
|
1333
|
+
if not contract:
|
|
1334
|
+
shown = self.runner.text(
|
|
1335
|
+
self.project_root,
|
|
1336
|
+
"show",
|
|
1337
|
+
f"{journal['featureHead']}:.harness/changes/{self.change_id}/meta/change-context.json",
|
|
1338
|
+
)
|
|
1339
|
+
if shown:
|
|
1340
|
+
try:
|
|
1341
|
+
contract = json.loads(shown)
|
|
1342
|
+
except json.JSONDecodeError:
|
|
1343
|
+
contract = {}
|
|
1344
|
+
ownership = contract.get("ownership") or {}
|
|
1345
|
+
scoped = bool(
|
|
1346
|
+
ownership.get("productPaths") or ownership.get("staticEvidencePaths")
|
|
1347
|
+
)
|
|
1348
|
+
foreign: list[str] = []
|
|
1349
|
+
for line in (diff or "").splitlines():
|
|
1350
|
+
line = line.strip()
|
|
1351
|
+
if not line:
|
|
1352
|
+
continue
|
|
1353
|
+
if scoped:
|
|
1354
|
+
verdict = hl._classify_ownership_path(line, self.change_id, ownership)
|
|
1355
|
+
if verdict == "foreign":
|
|
1356
|
+
foreign.append(line)
|
|
1357
|
+
continue
|
|
1358
|
+
for pattern in _FOREIGN_PATTERNS:
|
|
1359
|
+
match = pattern.match(line)
|
|
1360
|
+
if match and match.group(1) != self.change_id:
|
|
1361
|
+
foreign.append(line)
|
|
1362
|
+
break
|
|
1363
|
+
return foreign
|
|
1364
|
+
|
|
1365
|
+
def merge(self) -> dict[str, Any]:
|
|
1366
|
+
def action(journal: dict[str, Any]) -> None:
|
|
1367
|
+
current_target = self.runner.text(
|
|
1368
|
+
self.project_root, "rev-parse", self.target_branch
|
|
1369
|
+
)
|
|
1370
|
+
expected_local = journal.get("localTargetHead") or journal["base"]
|
|
1371
|
+
if current_target != expected_local:
|
|
1372
|
+
raise TargetMovedError(
|
|
1373
|
+
f"local {self.target_branch} moved: expected "
|
|
1374
|
+
f"{expected_local}, found {current_target}"
|
|
1375
|
+
)
|
|
1376
|
+
if journal.get("remoteTargetHead"):
|
|
1377
|
+
# Use typed remote probe (retro §5.28): distinguish probe failure
|
|
1378
|
+
# (network/auth/process) from genuine target movement. `None`
|
|
1379
|
+
# must never enter the "found head" field.
|
|
1380
|
+
probe = self.runner.remote_probe(
|
|
1381
|
+
self.project_root, "ls-remote", "origin", self.target_branch
|
|
1382
|
+
)
|
|
1383
|
+
expected_remote = journal.get("remoteTargetHead")
|
|
1384
|
+
if probe["exitCode"] != 0:
|
|
1385
|
+
# Probe failed: network/auth/process error, not target moved.
|
|
1386
|
+
raise RemoteProbeFailedError(
|
|
1387
|
+
f"remote probe failed (exit={probe['exitCode']}, "
|
|
1388
|
+
f"category={probe['category']}): {probe['redactedStderr']}"
|
|
1389
|
+
)
|
|
1390
|
+
current_remote = probe["stdoutHash"]
|
|
1391
|
+
if current_remote is not None and current_remote != expected_remote:
|
|
1392
|
+
raise TargetMovedError(
|
|
1393
|
+
f"remote {self.target_branch} moved: expected "
|
|
1394
|
+
f"{expected_remote}, found {current_remote}"
|
|
1395
|
+
)
|
|
1396
|
+
foreign = self._foreign_paths(journal)
|
|
1397
|
+
if foreign:
|
|
1398
|
+
raise ForeignChangePathsError(
|
|
1399
|
+
"merge diff touches other changes: " + ", ".join(sorted(foreign))
|
|
1400
|
+
)
|
|
1401
|
+
intg_root = Path(journal["integrationRoot"])
|
|
1402
|
+
proc = self.runner.run(
|
|
1403
|
+
intg_root,
|
|
1404
|
+
"merge",
|
|
1405
|
+
"--no-ff",
|
|
1406
|
+
"-m",
|
|
1407
|
+
f"harness: integrate {self.change_id} into {self.target_branch}",
|
|
1408
|
+
self.feature_branch,
|
|
1409
|
+
check=False,
|
|
1410
|
+
)
|
|
1411
|
+
if proc.returncode != 0:
|
|
1412
|
+
raise MergeFailedError(proc.stderr.strip() or "merge conflict")
|
|
1413
|
+
journal["mergeCommit"] = self.runner.text(intg_root, "rev-parse", "HEAD")
|
|
1414
|
+
self._save(journal)
|
|
1415
|
+
|
|
1416
|
+
return self._run_step("merge", action)
|
|
1417
|
+
|
|
1418
|
+
def _required_on_merge_commands(self) -> list[list[str]]:
|
|
1419
|
+
"""Load build-profile mergeVerification.requiredOnMerge (Wave-2 H-9)."""
|
|
1420
|
+
import harness_profile
|
|
1421
|
+
|
|
1422
|
+
profile = harness_profile.load_profile(self.project_root)
|
|
1423
|
+
if not isinstance(profile, dict):
|
|
1424
|
+
return []
|
|
1425
|
+
merge = profile.get("mergeVerification")
|
|
1426
|
+
if not isinstance(merge, dict):
|
|
1427
|
+
return []
|
|
1428
|
+
required = merge.get("requiredOnMerge")
|
|
1429
|
+
if not isinstance(required, list):
|
|
1430
|
+
return []
|
|
1431
|
+
commands: list[list[str]] = []
|
|
1432
|
+
for index, item in enumerate(required):
|
|
1433
|
+
if isinstance(item, str) and item.strip():
|
|
1434
|
+
commands.append(_split_verify_command(item.strip()))
|
|
1435
|
+
continue
|
|
1436
|
+
if not isinstance(item, dict):
|
|
1437
|
+
raise ValueError(f"requiredOnMerge[{index}] must be string or object")
|
|
1438
|
+
raw = item.get("command")
|
|
1439
|
+
if isinstance(raw, list) and raw and all(isinstance(x, str) and x for x in raw):
|
|
1440
|
+
commands.append([str(x) for x in raw])
|
|
1441
|
+
elif isinstance(raw, str) and raw.strip():
|
|
1442
|
+
commands.append(_split_verify_command(raw.strip()))
|
|
1443
|
+
else:
|
|
1444
|
+
raise ValueError(f"requiredOnMerge[{index}].command missing")
|
|
1445
|
+
return commands
|
|
1446
|
+
|
|
1447
|
+
def _required_on_merge_plan(self) -> list[dict[str, Any]]:
|
|
1448
|
+
"""合并态验证计划:profile 声明优先,缺声明时从既有验证目标推导。
|
|
1449
|
+
|
|
1450
|
+
此前只读 mergeVerification.requiredOnMerge,没配就是空计划 → VERIFY_PLAN_MISSING。
|
|
1451
|
+
但 profile 里本来就有 ledger 一直在用的规范命令;让调用方在合并时另行手敲一遍,
|
|
1452
|
+
换来的通常是一条随手写的探测命令。
|
|
1453
|
+
"""
|
|
1454
|
+
import harness_profile
|
|
1455
|
+
|
|
1456
|
+
profile = harness_profile.load_profile(self.project_root)
|
|
1457
|
+
return merge_verification_plan(profile)
|
|
1458
|
+
|
|
1459
|
+
def verify(
|
|
1460
|
+
self,
|
|
1461
|
+
commands: Sequence[Sequence[str]] | None = None,
|
|
1462
|
+
*,
|
|
1463
|
+
timeout_seconds: float = DEFAULT_VERIFY_TIMEOUT_SECONDS,
|
|
1464
|
+
) -> dict[str, Any]:
|
|
1465
|
+
commands = list(commands or [])
|
|
1466
|
+
try:
|
|
1467
|
+
profile_plan = self._required_on_merge_plan()
|
|
1468
|
+
except Exception as exc: # noqa: BLE001 — surface as verify plan error
|
|
1469
|
+
raise VerifyPlanMissingError(f"mergeVerification load failed: {exc}") from exc
|
|
1470
|
+
plan = [
|
|
1471
|
+
{
|
|
1472
|
+
"command": list(command),
|
|
1473
|
+
"kind": "smoke",
|
|
1474
|
+
"verification": None,
|
|
1475
|
+
"source": "cli",
|
|
1476
|
+
}
|
|
1477
|
+
for command in commands
|
|
1478
|
+
]
|
|
1479
|
+
plan.extend(profile_plan)
|
|
1480
|
+
commands = _dedupe_verify_commands([item["command"] for item in plan])
|
|
1481
|
+
if timeout_seconds <= 0:
|
|
1482
|
+
raise VerifyPlanMissingError("verification timeout must be greater than zero")
|
|
1483
|
+
|
|
1484
|
+
def action(journal: dict[str, Any]) -> None:
|
|
1485
|
+
if not commands or any(
|
|
1486
|
+
not isinstance(command, (list, tuple)) or not command or not command[0]
|
|
1487
|
+
for command in commands
|
|
1488
|
+
):
|
|
1489
|
+
raise VerifyPlanMissingError(
|
|
1490
|
+
"integration verification requires at least one executable command"
|
|
1491
|
+
" — " + verify_plan_recovery_action()
|
|
1492
|
+
)
|
|
1493
|
+
# 计划先落盘:verify 失败时也要看得见"打算跑什么",
|
|
1494
|
+
# 而不是只剩一个 FAILED 状态。
|
|
1495
|
+
journal["verifyPlan"] = [
|
|
1496
|
+
{
|
|
1497
|
+
"command": list(item["command"]),
|
|
1498
|
+
"kind": item.get("kind"),
|
|
1499
|
+
"verification": item.get("verification"),
|
|
1500
|
+
"source": item.get("source"),
|
|
1501
|
+
}
|
|
1502
|
+
for item in plan
|
|
1503
|
+
]
|
|
1504
|
+
journal["verificationDepth"] = verification_depth(plan)
|
|
1505
|
+
self._save(journal)
|
|
1506
|
+
evidence_change = self._bound_evidence_change(journal)
|
|
1507
|
+
intg_root = Path(journal["integrationRoot"]).resolve()
|
|
1508
|
+
results: list[dict[str, Any]] = []
|
|
1509
|
+
merge_tree = self.runner.text(intg_root, "rev-parse", "--verify", "HEAD^{tree}")
|
|
1510
|
+
merge_tree_hash = "sha256:" + merge_tree if merge_tree else None
|
|
1511
|
+
policies = {tuple(item["command"]): item for item in plan}
|
|
1512
|
+
for index, command in enumerate(commands):
|
|
1513
|
+
policy = policies.get(
|
|
1514
|
+
tuple(command), {"kind": "smoke", "verification": None}
|
|
1515
|
+
)
|
|
1516
|
+
reused = (
|
|
1517
|
+
_reusable_merge_evidence(
|
|
1518
|
+
self._ledger_path(evidence_change),
|
|
1519
|
+
verification=policy.get("verification"),
|
|
1520
|
+
command=command,
|
|
1521
|
+
product_tree_hash=merge_tree_hash,
|
|
1522
|
+
reuse_context=policy.get("reuseContext") or {},
|
|
1523
|
+
)
|
|
1524
|
+
if policy.get("kind") == "heavyweight"
|
|
1525
|
+
else None
|
|
1526
|
+
)
|
|
1527
|
+
if reused is not None:
|
|
1528
|
+
results.append(
|
|
1529
|
+
{
|
|
1530
|
+
"command": list(command),
|
|
1531
|
+
"exitCode": 0,
|
|
1532
|
+
"reused": True,
|
|
1533
|
+
"kind": "heavyweight",
|
|
1534
|
+
**reused,
|
|
1535
|
+
}
|
|
1536
|
+
)
|
|
1537
|
+
continue
|
|
1538
|
+
executable_command = _normalize_verify_command(command)
|
|
1539
|
+
started = dt.datetime.now().astimezone()
|
|
1540
|
+
try:
|
|
1541
|
+
proc = subprocess.run(
|
|
1542
|
+
executable_command,
|
|
1543
|
+
cwd=str(intg_root),
|
|
1544
|
+
capture_output=True,
|
|
1545
|
+
text=True,
|
|
1546
|
+
encoding="utf-8",
|
|
1547
|
+
errors="replace",
|
|
1548
|
+
check=False,
|
|
1549
|
+
timeout=timeout_seconds,
|
|
1550
|
+
)
|
|
1551
|
+
except subprocess.TimeoutExpired as exc:
|
|
1552
|
+
finished = dt.datetime.now().astimezone()
|
|
1553
|
+
log_path = _write_verify_log(
|
|
1554
|
+
self.project_root,
|
|
1555
|
+
self.transaction_id,
|
|
1556
|
+
index,
|
|
1557
|
+
command=command,
|
|
1558
|
+
resolved_command=executable_command,
|
|
1559
|
+
started=started,
|
|
1560
|
+
finished=finished,
|
|
1561
|
+
exit_code=None,
|
|
1562
|
+
stdout=exc.stdout,
|
|
1563
|
+
stderr=exc.stderr,
|
|
1564
|
+
error=f"timed out after {timeout_seconds}s",
|
|
1565
|
+
)
|
|
1566
|
+
results.append(
|
|
1567
|
+
{
|
|
1568
|
+
"command": list(command),
|
|
1569
|
+
"resolvedCommand": executable_command,
|
|
1570
|
+
"exitCode": None,
|
|
1571
|
+
"timedOut": True,
|
|
1572
|
+
"timeoutSeconds": timeout_seconds,
|
|
1573
|
+
"startedAt": started.isoformat(timespec="milliseconds"),
|
|
1574
|
+
"finishedAt": finished.isoformat(timespec="milliseconds"),
|
|
1575
|
+
"durationMs": int(
|
|
1576
|
+
(finished - started).total_seconds() * 1000
|
|
1577
|
+
),
|
|
1578
|
+
"logPath": log_path,
|
|
1579
|
+
}
|
|
1580
|
+
)
|
|
1581
|
+
journal["verifyResults"] = results
|
|
1582
|
+
self._save(journal)
|
|
1583
|
+
raise VerificationFailedError(
|
|
1584
|
+
f"command timed out after {timeout_seconds}s: {' '.join(command)}"
|
|
1585
|
+
)
|
|
1586
|
+
except FileNotFoundError as exc:
|
|
1587
|
+
finished = dt.datetime.now().astimezone()
|
|
1588
|
+
log_path = _write_verify_log(
|
|
1589
|
+
self.project_root,
|
|
1590
|
+
self.transaction_id,
|
|
1591
|
+
index,
|
|
1592
|
+
command=command,
|
|
1593
|
+
resolved_command=executable_command,
|
|
1594
|
+
started=started,
|
|
1595
|
+
finished=finished,
|
|
1596
|
+
exit_code=None,
|
|
1597
|
+
error=str(exc),
|
|
1598
|
+
)
|
|
1599
|
+
results.append(
|
|
1600
|
+
{
|
|
1601
|
+
"command": list(command),
|
|
1602
|
+
"resolvedCommand": executable_command,
|
|
1603
|
+
"exitCode": None,
|
|
1604
|
+
"error": str(exc),
|
|
1605
|
+
"startedAt": started.isoformat(timespec="milliseconds"),
|
|
1606
|
+
"finishedAt": finished.isoformat(timespec="milliseconds"),
|
|
1607
|
+
"durationMs": int(
|
|
1608
|
+
(finished - started).total_seconds() * 1000
|
|
1609
|
+
),
|
|
1610
|
+
"logPath": log_path,
|
|
1611
|
+
}
|
|
1612
|
+
)
|
|
1613
|
+
journal["verifyResults"] = results
|
|
1614
|
+
self._save(journal)
|
|
1615
|
+
raise VerifyPlanMissingError(
|
|
1616
|
+
f"verification executable not found: {executable_command[0]}"
|
|
1617
|
+
) from exc
|
|
1618
|
+
finished = dt.datetime.now().astimezone()
|
|
1619
|
+
log_path = _write_verify_log(
|
|
1620
|
+
self.project_root,
|
|
1621
|
+
self.transaction_id,
|
|
1622
|
+
index,
|
|
1623
|
+
command=command,
|
|
1624
|
+
resolved_command=executable_command,
|
|
1625
|
+
started=started,
|
|
1626
|
+
finished=finished,
|
|
1627
|
+
exit_code=proc.returncode,
|
|
1628
|
+
stdout=proc.stdout,
|
|
1629
|
+
stderr=proc.stderr,
|
|
1630
|
+
)
|
|
1631
|
+
results.append(
|
|
1632
|
+
{
|
|
1633
|
+
"command": list(command),
|
|
1634
|
+
"resolvedCommand": executable_command,
|
|
1635
|
+
"exitCode": proc.returncode,
|
|
1636
|
+
"timedOut": False,
|
|
1637
|
+
"startedAt": started.isoformat(timespec="milliseconds"),
|
|
1638
|
+
"finishedAt": finished.isoformat(timespec="milliseconds"),
|
|
1639
|
+
"durationMs": int(
|
|
1640
|
+
(finished - started).total_seconds() * 1000
|
|
1641
|
+
),
|
|
1642
|
+
"logPath": log_path,
|
|
1643
|
+
}
|
|
1644
|
+
)
|
|
1645
|
+
if proc.returncode != 0:
|
|
1646
|
+
journal["verifyResults"] = results
|
|
1647
|
+
self._save(journal)
|
|
1648
|
+
raise VerificationFailedError(
|
|
1649
|
+
f"command failed ({proc.returncode}): {' '.join(command)}"
|
|
1650
|
+
)
|
|
1651
|
+
journal["verifyResults"] = results
|
|
1652
|
+
journal["verifyCwd"] = str(intg_root)
|
|
1653
|
+
basis = json.dumps(
|
|
1654
|
+
{
|
|
1655
|
+
"mergeCommit": journal.get("mergeCommit"),
|
|
1656
|
+
"results": results,
|
|
1657
|
+
"evidenceIdentity": journal.get("evidenceIdentity"),
|
|
1658
|
+
},
|
|
1659
|
+
ensure_ascii=False,
|
|
1660
|
+
sort_keys=True,
|
|
1661
|
+
)
|
|
1662
|
+
journal["verificationIdentity"] = {
|
|
1663
|
+
"sha256": "sha256:" + hashlib.sha256(basis.encode("utf-8")).hexdigest(),
|
|
1664
|
+
"ledgerIdentity": (journal.get("evidenceIdentity") or {}).get("ledgerIdentity") or {},
|
|
1665
|
+
}
|
|
1666
|
+
self._save(journal)
|
|
1667
|
+
|
|
1668
|
+
return self._run_step("verify", action)
|
|
1669
|
+
|
|
1670
|
+
def push(self) -> dict[str, Any]:
|
|
1671
|
+
def action(journal: dict[str, Any]) -> None:
|
|
1672
|
+
self._bound_evidence_change(journal)
|
|
1673
|
+
verify_step = self._step(journal, "verify")
|
|
1674
|
+
verify_results = journal.get("verifyResults")
|
|
1675
|
+
if (
|
|
1676
|
+
verify_step.get("status") != "DONE"
|
|
1677
|
+
or not isinstance(verify_results, list)
|
|
1678
|
+
or not verify_results
|
|
1679
|
+
or any(
|
|
1680
|
+
not isinstance(item, dict)
|
|
1681
|
+
or item.get("exitCode") != 0
|
|
1682
|
+
or not isinstance(item.get("command"), list)
|
|
1683
|
+
or not item.get("command")
|
|
1684
|
+
for item in verify_results
|
|
1685
|
+
)
|
|
1686
|
+
or not isinstance(journal.get("verificationIdentity"), dict)
|
|
1687
|
+
):
|
|
1688
|
+
raise VerifyPlanMissingError(
|
|
1689
|
+
"push requires a completed, non-empty verification plan"
|
|
1690
|
+
)
|
|
1691
|
+
remote_head = self.runner.text(
|
|
1692
|
+
self.project_root, "ls-remote", "origin", self.target_branch
|
|
1693
|
+
)
|
|
1694
|
+
remote_head = (remote_head or "").split()[0] if remote_head else ""
|
|
1695
|
+
if not remote_head:
|
|
1696
|
+
raise IntegrationError(
|
|
1697
|
+
f"remote branch not found: origin/{self.target_branch}"
|
|
1698
|
+
)
|
|
1699
|
+
merge_commit = journal.get("mergeCommit")
|
|
1700
|
+
if not merge_commit:
|
|
1701
|
+
raise IntegrationError("merge step not completed")
|
|
1702
|
+
if remote_head == merge_commit:
|
|
1703
|
+
# The previous push may have reached the remote before the local
|
|
1704
|
+
# process could persist pushedHead/step=DONE. Reconcile that
|
|
1705
|
+
# successful outcome instead of misclassifying it as drift.
|
|
1706
|
+
self._finalize_remote_push(
|
|
1707
|
+
journal, merge_commit=merge_commit, remote_head=remote_head
|
|
1708
|
+
)
|
|
1709
|
+
return
|
|
1710
|
+
expected_remote = journal.get("remoteTargetHead") or journal["base"]
|
|
1711
|
+
if remote_head != expected_remote:
|
|
1712
|
+
raise TargetMovedError(
|
|
1713
|
+
f"remote {self.target_branch} moved: expected "
|
|
1714
|
+
f"{expected_remote}, found {remote_head}"
|
|
1715
|
+
)
|
|
1716
|
+
journal["pushIntent"] = {
|
|
1717
|
+
"status": "PENDING",
|
|
1718
|
+
"expectedRemoteHead": expected_remote,
|
|
1719
|
+
"mergeCommit": merge_commit,
|
|
1720
|
+
"updatedAt": now_iso(),
|
|
1721
|
+
}
|
|
1722
|
+
self._save(journal)
|
|
1723
|
+
self.runner.run(
|
|
1724
|
+
self.project_root,
|
|
1725
|
+
"push",
|
|
1726
|
+
"origin",
|
|
1727
|
+
f"{merge_commit}:refs/heads/{self.target_branch}",
|
|
1728
|
+
)
|
|
1729
|
+
remote_after_line = self.runner.text(
|
|
1730
|
+
self.project_root, "ls-remote", "origin", self.target_branch
|
|
1731
|
+
)
|
|
1732
|
+
remote_after = (
|
|
1733
|
+
remote_after_line.split()[0]
|
|
1734
|
+
if remote_after_line and remote_after_line.split()
|
|
1735
|
+
else ""
|
|
1736
|
+
)
|
|
1737
|
+
if remote_after != merge_commit:
|
|
1738
|
+
journal = self._load()
|
|
1739
|
+
journal["pushIntent"] = {
|
|
1740
|
+
"status": "REMOTE_UNCONFIRMED",
|
|
1741
|
+
"expectedRemoteHead": expected_remote,
|
|
1742
|
+
"mergeCommit": merge_commit,
|
|
1743
|
+
"observedRemoteHead": remote_after,
|
|
1744
|
+
"updatedAt": now_iso(),
|
|
1745
|
+
}
|
|
1746
|
+
self._save(journal)
|
|
1747
|
+
raise IntegrationError(
|
|
1748
|
+
"push returned but the remote target does not equal the merge commit"
|
|
1749
|
+
)
|
|
1750
|
+
journal = self._load()
|
|
1751
|
+
self._finalize_remote_push(
|
|
1752
|
+
journal, merge_commit=merge_commit, remote_head=remote_after
|
|
1753
|
+
)
|
|
1754
|
+
|
|
1755
|
+
return self._run_step("push", action)
|
|
1756
|
+
|
|
1757
|
+
# ------------------------------------------------------------ cleanup
|
|
1758
|
+
|
|
1759
|
+
def cleanup_target(self, target: Path) -> dict[str, Any]:
|
|
1760
|
+
"""Boundary-checked removal of this transaction's exact worktree."""
|
|
1761
|
+
resolved = harness_paths.assert_path_within(target, self.temp_root)
|
|
1762
|
+
try:
|
|
1763
|
+
journal = self._load()
|
|
1764
|
+
except FileNotFoundError as exc:
|
|
1765
|
+
raise CleanupRefusedError(
|
|
1766
|
+
f"no journal for transaction {self.transaction_id}; "
|
|
1767
|
+
f"refusing cleanup of {resolved}"
|
|
1768
|
+
) from exc
|
|
1769
|
+
allowed = journal.get("allowedCleanupRoot")
|
|
1770
|
+
if not allowed or resolved != Path(allowed).resolve():
|
|
1771
|
+
raise CleanupRefusedError(
|
|
1772
|
+
f"refusing to clean non-transaction path: {resolved}"
|
|
1773
|
+
)
|
|
1774
|
+
listed_before = self.runner.text(
|
|
1775
|
+
self.project_root, "worktree", "list", "--porcelain"
|
|
1776
|
+
)
|
|
1777
|
+
registered_before = {
|
|
1778
|
+
str(Path(line.removeprefix("worktree ")).resolve())
|
|
1779
|
+
for line in (listed_before or "").splitlines()
|
|
1780
|
+
if line.startswith("worktree ")
|
|
1781
|
+
}
|
|
1782
|
+
if resolved.exists() and str(resolved) not in registered_before:
|
|
1783
|
+
raise CleanupRefusedError(
|
|
1784
|
+
f"refusing to clean unregistered worktree path: {resolved}"
|
|
1785
|
+
)
|
|
1786
|
+
lifecycle = evaluate_worktree_retirement(
|
|
1787
|
+
self.project_root,
|
|
1788
|
+
resolved,
|
|
1789
|
+
agent_root=(
|
|
1790
|
+
Path(os.environ["HUNTER_HARNESS_AGENT_ROOT"])
|
|
1791
|
+
if os.environ.get("HUNTER_HARNESS_AGENT_ROOT")
|
|
1792
|
+
else None
|
|
1793
|
+
),
|
|
1794
|
+
)
|
|
1795
|
+
if not lifecycle.get("ok"):
|
|
1796
|
+
codes = ", ".join(
|
|
1797
|
+
str(item.get("code") or "UNKNOWN")
|
|
1798
|
+
for item in lifecycle.get("blockers") or []
|
|
1799
|
+
)
|
|
1800
|
+
raise CleanupRefusedError(
|
|
1801
|
+
f"worktree lifecycle blocks cleanup: {codes}"
|
|
1802
|
+
)
|
|
1803
|
+
|
|
1804
|
+
heavy_removed: list[str] = []
|
|
1805
|
+
heavy_failures: list[dict[str, str]] = []
|
|
1806
|
+
if resolved.is_dir():
|
|
1807
|
+
for name in HEAVY_WORKTREE_ROOTS:
|
|
1808
|
+
heavy = resolved / name
|
|
1809
|
+
if not heavy.exists():
|
|
1810
|
+
continue
|
|
1811
|
+
harness_paths.assert_path_within(heavy, resolved)
|
|
1812
|
+
try:
|
|
1813
|
+
if heavy.is_dir() and not heavy.is_symlink():
|
|
1814
|
+
shutil.rmtree(heavy)
|
|
1815
|
+
else:
|
|
1816
|
+
heavy.unlink()
|
|
1817
|
+
heavy_removed.append(name)
|
|
1818
|
+
except OSError as exc:
|
|
1819
|
+
heavy_failures.append({"path": name, "error": str(exc)})
|
|
1820
|
+
remove_result = self.runner.run(
|
|
1821
|
+
self.project_root, "worktree", "remove", "--force", str(resolved),
|
|
1822
|
+
check=False,
|
|
1823
|
+
)
|
|
1824
|
+
remove_returncode = getattr(remove_result, "returncode", 0)
|
|
1825
|
+
listed = self.runner.text(self.project_root, "worktree", "list", "--porcelain")
|
|
1826
|
+
registered_after = {
|
|
1827
|
+
str(Path(line.removeprefix("worktree ")).resolve())
|
|
1828
|
+
for line in (listed or "").splitlines()
|
|
1829
|
+
if line.startswith("worktree ")
|
|
1830
|
+
}
|
|
1831
|
+
still_registered = str(resolved) in registered_after
|
|
1832
|
+
residual_paths: list[str] = []
|
|
1833
|
+
disk_path_present = resolved.exists()
|
|
1834
|
+
if disk_path_present:
|
|
1835
|
+
if resolved.is_file():
|
|
1836
|
+
residual_paths.append(resolved.name)
|
|
1837
|
+
else:
|
|
1838
|
+
for root, dirs, files in os.walk(resolved):
|
|
1839
|
+
rel_root = Path(root).relative_to(resolved)
|
|
1840
|
+
for name in sorted(dirs + files):
|
|
1841
|
+
rel = (rel_root / name).as_posix()
|
|
1842
|
+
if rel not in residual_paths:
|
|
1843
|
+
residual_paths.append(rel)
|
|
1844
|
+
if len(residual_paths) >= 200:
|
|
1845
|
+
break
|
|
1846
|
+
if len(residual_paths) >= 200:
|
|
1847
|
+
break
|
|
1848
|
+
# §5.30: Windows half-success — git worktree remove returned non-zero
|
|
1849
|
+
# but registration is already deleted and disk path remains. Return a
|
|
1850
|
+
# structured status distinct from generic CLEANUP_RESIDUAL so callers
|
|
1851
|
+
# can apply the allowlisted residual cleaner.
|
|
1852
|
+
if (
|
|
1853
|
+
remove_returncode != 0
|
|
1854
|
+
and not still_registered
|
|
1855
|
+
and disk_path_present
|
|
1856
|
+
and residual_paths
|
|
1857
|
+
and not heavy_failures
|
|
1858
|
+
):
|
|
1859
|
+
return {
|
|
1860
|
+
"ok": False,
|
|
1861
|
+
"code": "REGISTRATION_REMOVED_RESIDUAL_PRESENT",
|
|
1862
|
+
"target": str(resolved),
|
|
1863
|
+
"heavyRootsRemoved": heavy_removed,
|
|
1864
|
+
"heavyRootFailures": heavy_failures,
|
|
1865
|
+
"worktreeRegistered": False,
|
|
1866
|
+
"diskPathPresent": True,
|
|
1867
|
+
"residualPaths": residual_paths,
|
|
1868
|
+
}
|
|
1869
|
+
ok = not heavy_failures and not still_registered and not residual_paths
|
|
1870
|
+
return {
|
|
1871
|
+
"ok": ok,
|
|
1872
|
+
"code": "CLEANUP_COMPLETE" if ok else "CLEANUP_RESIDUAL",
|
|
1873
|
+
"target": str(resolved),
|
|
1874
|
+
"heavyRootsRemoved": heavy_removed,
|
|
1875
|
+
"heavyRootFailures": heavy_failures,
|
|
1876
|
+
"worktreeRegistered": still_registered,
|
|
1877
|
+
"diskPathPresent": disk_path_present,
|
|
1878
|
+
"residualPaths": residual_paths,
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
def _persist_cleanup_snapshot(
|
|
1882
|
+
self, journal: dict[str, Any], execution_root: Path
|
|
1883
|
+
) -> dict[str, Any]:
|
|
1884
|
+
"""Persist all evidence needed to close cleanup after the root is gone."""
|
|
1885
|
+
snapshot = {
|
|
1886
|
+
"schemaVersion": 1,
|
|
1887
|
+
"createdAt": now_iso(),
|
|
1888
|
+
"changeId": self.change_id,
|
|
1889
|
+
"attemptId": self.run_id,
|
|
1890
|
+
"worktreeRoot": str(execution_root.resolve()),
|
|
1891
|
+
"executionRoot": str(execution_root.resolve()),
|
|
1892
|
+
"mergeEvidence": {
|
|
1893
|
+
key: journal.get(key)
|
|
1894
|
+
for key in (
|
|
1895
|
+
"mergeCommit",
|
|
1896
|
+
"pushedHead",
|
|
1897
|
+
"mergeFinalHash",
|
|
1898
|
+
"ciExpectedHead",
|
|
1899
|
+
"remoteHead",
|
|
1900
|
+
)
|
|
1901
|
+
},
|
|
1902
|
+
"evidenceIdentity": journal.get("evidenceIdentity"),
|
|
1903
|
+
}
|
|
1904
|
+
journal["cleanupSnapshot"] = snapshot
|
|
1905
|
+
self._save(journal)
|
|
1906
|
+
return snapshot
|
|
1907
|
+
|
|
1908
|
+
def _stop_owned_services(
|
|
1909
|
+
self, journal: dict[str, Any], execution_root: Path
|
|
1910
|
+
) -> dict[str, Any]:
|
|
1911
|
+
"""Stop only sessions explicitly owned by this transaction worktree."""
|
|
1912
|
+
result = harness_service.stop_owned_sessions(
|
|
1913
|
+
self.project_root,
|
|
1914
|
+
change_id=self.change_id,
|
|
1915
|
+
execution_root=execution_root,
|
|
1916
|
+
worktree_root=execution_root,
|
|
1917
|
+
)
|
|
1918
|
+
journal["serviceStop"] = result
|
|
1919
|
+
self._save(journal)
|
|
1920
|
+
if not result["ok"]:
|
|
1921
|
+
raise CleanupRefusedError(
|
|
1922
|
+
"owned service could not be identity-verified and stopped"
|
|
1923
|
+
)
|
|
1924
|
+
return result
|
|
1925
|
+
|
|
1926
|
+
def cleanup(self) -> dict[str, Any]:
|
|
1927
|
+
try:
|
|
1928
|
+
existing = self._load()
|
|
1929
|
+
step = self._step(existing, "cleanup")
|
|
1930
|
+
if step["status"] == "DONE":
|
|
1931
|
+
step["reentry"] = "REUSED"
|
|
1932
|
+
return self._save(existing)
|
|
1933
|
+
except FileNotFoundError:
|
|
1934
|
+
raise IntegrationError("cannot cleanup before preflight")
|
|
1935
|
+
|
|
1936
|
+
def action(journal: dict[str, Any]) -> None:
|
|
1937
|
+
self._assert_cleanup_consistency(journal)
|
|
1938
|
+
intg_root = Path(journal["integrationRoot"])
|
|
1939
|
+
self._persist_cleanup_snapshot(journal, intg_root)
|
|
1940
|
+
service_stop = self._stop_owned_services(journal, intg_root)
|
|
1941
|
+
cleanup_result: dict[str, Any] = {
|
|
1942
|
+
"ok": True,
|
|
1943
|
+
"code": "ALREADY_ABSENT",
|
|
1944
|
+
"target": str(intg_root.resolve()),
|
|
1945
|
+
"heavyRootsRemoved": [],
|
|
1946
|
+
"heavyRootFailures": [],
|
|
1947
|
+
"worktreeRegistered": False,
|
|
1948
|
+
"residualPaths": [],
|
|
1949
|
+
}
|
|
1950
|
+
if intg_root.exists():
|
|
1951
|
+
cleanup_result = self.cleanup_target(intg_root)
|
|
1952
|
+
journal["cleanupResult"] = cleanup_result
|
|
1953
|
+
self._save(journal)
|
|
1954
|
+
if not cleanup_result["ok"]:
|
|
1955
|
+
raise CleanupResidualError(cleanup_result)
|
|
1956
|
+
self.runner.run(
|
|
1957
|
+
self.project_root, "branch", "-D", self.temp_branch, check=False
|
|
1958
|
+
)
|
|
1959
|
+
if self._step(journal, "push")["status"] == "DONE":
|
|
1960
|
+
refs = journal["protectionRefs"]
|
|
1961
|
+
for ref in (refs["base"], refs["head"]):
|
|
1962
|
+
self.runner.run(
|
|
1963
|
+
self.project_root, "update-ref", "-d", ref, check=False
|
|
1964
|
+
)
|
|
1965
|
+
worktrees = self.runner.text(
|
|
1966
|
+
self.project_root, "worktree", "list", "--porcelain"
|
|
1967
|
+
) or ""
|
|
1968
|
+
feature_ref = f"refs/heads/{self.feature_branch}"
|
|
1969
|
+
feature_branch_retained = self.runner.run(
|
|
1970
|
+
self.project_root,
|
|
1971
|
+
"show-ref",
|
|
1972
|
+
"--verify",
|
|
1973
|
+
feature_ref,
|
|
1974
|
+
check=False,
|
|
1975
|
+
).returncode == 0
|
|
1976
|
+
local_target = self.runner.text(
|
|
1977
|
+
self.project_root, "rev-parse", self.target_branch
|
|
1978
|
+
)
|
|
1979
|
+
remote_line = self.runner.text(
|
|
1980
|
+
self.project_root, "ls-remote", "origin", self.target_branch
|
|
1981
|
+
)
|
|
1982
|
+
remote_target = (
|
|
1983
|
+
remote_line.split()[0] if remote_line and remote_line.split() else None
|
|
1984
|
+
)
|
|
1985
|
+
journal["cleanupSummary"] = {
|
|
1986
|
+
"transactionArtifactsRemoved": {
|
|
1987
|
+
"integrationWorktree": not intg_root.exists(),
|
|
1988
|
+
"temporaryBranch": self.runner.run(
|
|
1989
|
+
self.project_root,
|
|
1990
|
+
"show-ref",
|
|
1991
|
+
"--verify",
|
|
1992
|
+
f"refs/heads/{self.temp_branch}",
|
|
1993
|
+
check=False,
|
|
1994
|
+
).returncode != 0,
|
|
1995
|
+
"protectionRefs": self._step(journal, "push")["status"] == "DONE",
|
|
1996
|
+
"heavyRoots": cleanup_result.get("heavyRootsRemoved") or [],
|
|
1997
|
+
},
|
|
1998
|
+
"sourceWorktreeRetained": f"branch {feature_ref}" in worktrees,
|
|
1999
|
+
"featureBranchRetained": feature_branch_retained,
|
|
2000
|
+
"primaryWorktreeUpdated": bool(
|
|
2001
|
+
local_target and remote_target and local_target == remote_target
|
|
2002
|
+
),
|
|
2003
|
+
"primaryHead": local_target,
|
|
2004
|
+
"remoteHead": remote_target,
|
|
2005
|
+
"residualPaths": cleanup_result.get("residualPaths") or [],
|
|
2006
|
+
"serviceStop": service_stop,
|
|
2007
|
+
}
|
|
2008
|
+
journal["cleanupReceipt"] = {
|
|
2009
|
+
"schemaVersion": 1,
|
|
2010
|
+
"completedAt": now_iso(),
|
|
2011
|
+
"snapshot": journal.get("cleanupSnapshot"),
|
|
2012
|
+
"worktreeRemoval": cleanup_result,
|
|
2013
|
+
"serviceStop": service_stop,
|
|
2014
|
+
"worktreeGone": not intg_root.exists(),
|
|
2015
|
+
}
|
|
2016
|
+
self._save(journal)
|
|
2017
|
+
|
|
2018
|
+
journal = self._run_step("cleanup", action)
|
|
2019
|
+
harness_change.integration_lock_release(self.project_root, run_id=self.run_id)
|
|
2020
|
+
return journal
|
|
2021
|
+
|
|
2022
|
+
# ------------------------------------------------------------ abandon
|
|
2023
|
+
|
|
2024
|
+
def _remote_contains_merge(self, journal: dict[str, Any]) -> bool:
|
|
2025
|
+
merge_commit = str(journal.get("mergeCommit") or "")
|
|
2026
|
+
if not merge_commit:
|
|
2027
|
+
return False
|
|
2028
|
+
remote_line = self.runner.text(
|
|
2029
|
+
self.project_root, "ls-remote", "origin", self.target_branch
|
|
2030
|
+
)
|
|
2031
|
+
observed_remote = (
|
|
2032
|
+
remote_line.split()[0] if remote_line and remote_line.split() else ""
|
|
2033
|
+
)
|
|
2034
|
+
return bool(observed_remote and observed_remote == merge_commit)
|
|
2035
|
+
|
|
2036
|
+
def _force_remove_integration_root(
|
|
2037
|
+
self, journal: dict[str, Any], intg_root: Path
|
|
2038
|
+
) -> dict[str, Any]:
|
|
2039
|
+
"""Best-effort remove of the integration worktree for abandon only."""
|
|
2040
|
+
allowed = Path(str(journal.get("allowedCleanupRoot") or "")).resolve()
|
|
2041
|
+
resolved = harness_paths.assert_path_within(intg_root, self.temp_root)
|
|
2042
|
+
if not allowed or resolved != allowed:
|
|
2043
|
+
raise CleanupRefusedError(
|
|
2044
|
+
f"refusing to abandon non-transaction path: {resolved}"
|
|
2045
|
+
)
|
|
2046
|
+
self.runner.run(
|
|
2047
|
+
self.project_root,
|
|
2048
|
+
"worktree",
|
|
2049
|
+
"remove",
|
|
2050
|
+
"--force",
|
|
2051
|
+
str(resolved),
|
|
2052
|
+
check=False,
|
|
2053
|
+
)
|
|
2054
|
+
residual: list[str] = []
|
|
2055
|
+
if resolved.exists():
|
|
2056
|
+
for name in HEAVY_WORKTREE_ROOTS:
|
|
2057
|
+
heavy = resolved / name
|
|
2058
|
+
if heavy.exists():
|
|
2059
|
+
harness_paths.assert_path_within(heavy, resolved)
|
|
2060
|
+
try:
|
|
2061
|
+
if heavy.is_dir() and not heavy.is_symlink():
|
|
2062
|
+
shutil.rmtree(heavy)
|
|
2063
|
+
else:
|
|
2064
|
+
heavy.unlink()
|
|
2065
|
+
except OSError:
|
|
2066
|
+
residual.append(name)
|
|
2067
|
+
if resolved.exists():
|
|
2068
|
+
try:
|
|
2069
|
+
shutil.rmtree(resolved)
|
|
2070
|
+
except OSError:
|
|
2071
|
+
residual.append(str(resolved))
|
|
2072
|
+
listed = self.runner.text(self.project_root, "worktree", "list", "--porcelain")
|
|
2073
|
+
still_registered = str(resolved) in {
|
|
2074
|
+
str(Path(line.removeprefix("worktree ")).resolve())
|
|
2075
|
+
for line in (listed or "").splitlines()
|
|
2076
|
+
if line.startswith("worktree ")
|
|
2077
|
+
}
|
|
2078
|
+
ok = not still_registered and not residual and not resolved.exists()
|
|
2079
|
+
return {
|
|
2080
|
+
"ok": ok,
|
|
2081
|
+
"code": "CLEANUP_COMPLETE" if ok else "CLEANUP_RESIDUAL",
|
|
2082
|
+
"target": str(resolved),
|
|
2083
|
+
"worktreeRegistered": still_registered,
|
|
2084
|
+
"diskPathPresent": resolved.exists(),
|
|
2085
|
+
"residualPaths": residual,
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
def abandon(self) -> dict[str, Any]:
|
|
2089
|
+
"""Official recovery for a failed integration transaction (H-6).
|
|
2090
|
+
|
|
2091
|
+
Allowed only when push is not DONE and the remote does not already
|
|
2092
|
+
contain ``mergeCommit``. Removes the integration worktree, temp branch,
|
|
2093
|
+
protection refs, and integration lock. Never deletes the feature
|
|
2094
|
+
worktree or feature branch.
|
|
2095
|
+
"""
|
|
2096
|
+
try:
|
|
2097
|
+
journal = self._load()
|
|
2098
|
+
except FileNotFoundError as exc:
|
|
2099
|
+
raise AbandonRefusedError(
|
|
2100
|
+
f"no journal for transaction {self.transaction_id}"
|
|
2101
|
+
) from exc
|
|
2102
|
+
|
|
2103
|
+
push_status = str(self._step(journal, "push").get("status") or "")
|
|
2104
|
+
push_intent = journal.get("pushIntent") or {}
|
|
2105
|
+
push_confirmed = (
|
|
2106
|
+
push_status == "DONE"
|
|
2107
|
+
or (
|
|
2108
|
+
isinstance(push_intent, dict)
|
|
2109
|
+
and push_intent.get("status") == "REMOTE_CONFIRMED"
|
|
2110
|
+
)
|
|
2111
|
+
or bool(journal.get("pushedHead"))
|
|
2112
|
+
or bool(journal.get("mergeFinalHash"))
|
|
2113
|
+
)
|
|
2114
|
+
remote_has_merge = self._remote_contains_merge(journal)
|
|
2115
|
+
if push_confirmed or remote_has_merge:
|
|
2116
|
+
raise AbandonRefusedError(
|
|
2117
|
+
"refuse abandon: push succeeded or remote already contains "
|
|
2118
|
+
"the merge commit; use cleanup instead"
|
|
2119
|
+
)
|
|
2120
|
+
|
|
2121
|
+
intg_root = Path(str(journal.get("integrationRoot") or ""))
|
|
2122
|
+
cleanup_result: dict[str, Any] = {
|
|
2123
|
+
"ok": True,
|
|
2124
|
+
"code": "ALREADY_ABSENT",
|
|
2125
|
+
"target": str(intg_root) if intg_root else "",
|
|
2126
|
+
"worktreeRegistered": False,
|
|
2127
|
+
"diskPathPresent": False,
|
|
2128
|
+
"residualPaths": [],
|
|
2129
|
+
}
|
|
2130
|
+
prepare_done = self._step(journal, "prepare").get("status") == "DONE"
|
|
2131
|
+
if prepare_done and str(journal.get("integrationRoot") or "").strip():
|
|
2132
|
+
try:
|
|
2133
|
+
cleanup_result = self.cleanup_target(intg_root)
|
|
2134
|
+
except CleanupRefusedError:
|
|
2135
|
+
cleanup_result = self._force_remove_integration_root(journal, intg_root)
|
|
2136
|
+
|
|
2137
|
+
self.runner.run(
|
|
2138
|
+
self.project_root, "branch", "-D", self.temp_branch, check=False
|
|
2139
|
+
)
|
|
2140
|
+
refs = journal.get("protectionRefs") or {}
|
|
2141
|
+
if isinstance(refs, dict):
|
|
2142
|
+
for ref in (refs.get("base"), refs.get("head")):
|
|
2143
|
+
if ref:
|
|
2144
|
+
self.runner.run(
|
|
2145
|
+
self.project_root, "update-ref", "-d", str(ref), check=False
|
|
2146
|
+
)
|
|
2147
|
+
|
|
2148
|
+
harness_change.integration_lock_release(self.project_root, run_id=self.run_id)
|
|
2149
|
+
|
|
2150
|
+
journal = self._load()
|
|
2151
|
+
journal["abandonedAt"] = now_iso()
|
|
2152
|
+
journal["abandonResult"] = cleanup_result
|
|
2153
|
+
self._save(journal)
|
|
2154
|
+
|
|
2155
|
+
return {
|
|
2156
|
+
"ok": True,
|
|
2157
|
+
"code": "ABANDON_COMPLETE",
|
|
2158
|
+
"transactionId": self.transaction_id,
|
|
2159
|
+
"cleanup": cleanup_result,
|
|
2160
|
+
"featureBranchRetained": True,
|
|
2161
|
+
"featureWorktreeRetained": True,
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
# ------------------------------------------------------------ recover
|
|
2165
|
+
|
|
2166
|
+
def _rebind_evidence_change_for_recovery(
|
|
2167
|
+
self,
|
|
2168
|
+
journal: dict[str, Any],
|
|
2169
|
+
evidence_change_id: str,
|
|
2170
|
+
) -> dict[str, Any]:
|
|
2171
|
+
self._change_roots(evidence_change_id)
|
|
2172
|
+
current_change = self._bound_evidence_change(journal)
|
|
2173
|
+
if current_change == evidence_change_id:
|
|
2174
|
+
return journal
|
|
2175
|
+
if current_change != self.change_id:
|
|
2176
|
+
raise EvidenceRebindRefusedError(
|
|
2177
|
+
"transaction evidence is already bound to another Change"
|
|
2178
|
+
)
|
|
2179
|
+
|
|
2180
|
+
push_step = self._step(journal, "push")
|
|
2181
|
+
ledger_sync = journal.get("ledgerSync") or {}
|
|
2182
|
+
ledger_result = ledger_sync.get("result") or {}
|
|
2183
|
+
merge_commit = str(journal.get("mergeCommit") or "")
|
|
2184
|
+
push_intent = journal.get("pushIntent") or {}
|
|
2185
|
+
if (
|
|
2186
|
+
push_step.get("status") != "FAILED"
|
|
2187
|
+
or ledger_sync.get("status") != "FAILED"
|
|
2188
|
+
or ledger_result.get("code") != "LEDGER_MISSING"
|
|
2189
|
+
or push_intent.get("status") != "REMOTE_CONFIRMED"
|
|
2190
|
+
or push_intent.get("mergeCommit") != merge_commit
|
|
2191
|
+
or push_intent.get("observedRemoteHead") != merge_commit
|
|
2192
|
+
):
|
|
2193
|
+
raise EvidenceRebindRefusedError(
|
|
2194
|
+
"evidence rebind requires a remote-confirmed push blocked only by "
|
|
2195
|
+
"the default LEDGER_MISSING result"
|
|
2196
|
+
)
|
|
2197
|
+
|
|
2198
|
+
probe = self.runner.remote_probe(
|
|
2199
|
+
self.project_root,
|
|
2200
|
+
"ls-remote",
|
|
2201
|
+
"origin",
|
|
2202
|
+
self.target_branch,
|
|
2203
|
+
)
|
|
2204
|
+
if probe["exitCode"] != 0:
|
|
2205
|
+
raise RemoteProbeFailedError(
|
|
2206
|
+
"cannot confirm the remote target before evidence rebind: "
|
|
2207
|
+
f"{probe['category']}: {probe['redactedStderr']}"
|
|
2208
|
+
)
|
|
2209
|
+
if not merge_commit or probe["stdoutHash"] != merge_commit:
|
|
2210
|
+
raise EvidenceRebindRefusedError(
|
|
2211
|
+
"remote target no longer equals the transaction merge commit"
|
|
2212
|
+
)
|
|
2213
|
+
|
|
2214
|
+
ledger_path = self._ledger_path(evidence_change_id)
|
|
2215
|
+
if ledger_path is None:
|
|
2216
|
+
raise EvidenceRebindRefusedError(
|
|
2217
|
+
"requested evidence Change has no verification ledger"
|
|
2218
|
+
)
|
|
2219
|
+
try:
|
|
2220
|
+
ledger = json.loads(ledger_path.read_text(encoding="utf-8-sig"))
|
|
2221
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
2222
|
+
raise EvidenceRebindRefusedError(
|
|
2223
|
+
f"requested evidence ledger is unreadable: {exc}"
|
|
2224
|
+
) from exc
|
|
2225
|
+
missing = hl.validate_ledger_identity(ledger)
|
|
2226
|
+
if missing:
|
|
2227
|
+
raise EvidenceRebindRefusedError(
|
|
2228
|
+
"requested evidence ledger identity is invalid: "
|
|
2229
|
+
+ ", ".join(missing)
|
|
2230
|
+
)
|
|
2231
|
+
if ledger.get("changeName") != evidence_change_id:
|
|
2232
|
+
raise EvidenceRebindRefusedError(
|
|
2233
|
+
"requested evidence ledger Change identity does not match"
|
|
2234
|
+
)
|
|
2235
|
+
if ledger.get("repositoryId") != journal.get("repositoryId"):
|
|
2236
|
+
raise EvidenceRebindRefusedError(
|
|
2237
|
+
"requested evidence ledger repository identity does not match"
|
|
2238
|
+
)
|
|
2239
|
+
|
|
2240
|
+
expected_handoff = str(
|
|
2241
|
+
journal.get("remoteTargetHead") or journal.get("base") or ""
|
|
2242
|
+
)
|
|
2243
|
+
handoff_values = [
|
|
2244
|
+
ledger.get("mergeFinalHash"),
|
|
2245
|
+
ledger.get("ciExpectedHead"),
|
|
2246
|
+
ledger.get("remoteHead"),
|
|
2247
|
+
]
|
|
2248
|
+
handoff_valid = all(
|
|
2249
|
+
isinstance(value, str)
|
|
2250
|
+
and re.fullmatch(r"[0-9a-f]{40}", value) is not None
|
|
2251
|
+
for value in handoff_values
|
|
2252
|
+
)
|
|
2253
|
+
handoff_hashes = set(handoff_values) if handoff_valid else set()
|
|
2254
|
+
if (
|
|
2255
|
+
not handoff_valid
|
|
2256
|
+
or len(handoff_hashes) != 1
|
|
2257
|
+
or next(iter(handoff_hashes), None)
|
|
2258
|
+
not in {expected_handoff, merge_commit}
|
|
2259
|
+
):
|
|
2260
|
+
raise EvidenceRebindRefusedError(
|
|
2261
|
+
"requested evidence ledger has no matching prior integration handoff"
|
|
2262
|
+
)
|
|
2263
|
+
|
|
2264
|
+
current_head = str(ledger.get("currentHead") or "")
|
|
2265
|
+
feature_head = str(journal.get("featureHead") or "")
|
|
2266
|
+
current_in_feature = self.runner.run(
|
|
2267
|
+
self.project_root,
|
|
2268
|
+
"merge-base",
|
|
2269
|
+
"--is-ancestor",
|
|
2270
|
+
current_head,
|
|
2271
|
+
feature_head,
|
|
2272
|
+
check=False,
|
|
2273
|
+
).returncode == 0
|
|
2274
|
+
feature_in_merge = self.runner.run(
|
|
2275
|
+
self.project_root,
|
|
2276
|
+
"merge-base",
|
|
2277
|
+
"--is-ancestor",
|
|
2278
|
+
feature_head,
|
|
2279
|
+
merge_commit,
|
|
2280
|
+
check=False,
|
|
2281
|
+
).returncode == 0
|
|
2282
|
+
if not current_in_feature or not feature_in_merge:
|
|
2283
|
+
raise EvidenceRebindRefusedError(
|
|
2284
|
+
"requested evidence ledger head is not in the integrated feature lineage"
|
|
2285
|
+
)
|
|
2286
|
+
|
|
2287
|
+
previous_identity = journal.get("evidenceIdentity")
|
|
2288
|
+
previous_verification_identity = journal.get("verificationIdentity")
|
|
2289
|
+
target_identity = self._evidence_identity(evidence_change_id)
|
|
2290
|
+
journal["evidenceChangeName"] = evidence_change_id
|
|
2291
|
+
journal["evidenceIdentity"] = target_identity
|
|
2292
|
+
verification_basis = json.dumps(
|
|
2293
|
+
{
|
|
2294
|
+
"mergeCommit": journal.get("mergeCommit"),
|
|
2295
|
+
"results": journal.get("verifyResults"),
|
|
2296
|
+
"evidenceIdentity": target_identity,
|
|
2297
|
+
},
|
|
2298
|
+
ensure_ascii=False,
|
|
2299
|
+
sort_keys=True,
|
|
2300
|
+
)
|
|
2301
|
+
journal["verificationIdentity"] = {
|
|
2302
|
+
"sha256": (
|
|
2303
|
+
"sha256:"
|
|
2304
|
+
+ hashlib.sha256(verification_basis.encode("utf-8")).hexdigest()
|
|
2305
|
+
),
|
|
2306
|
+
"ledgerIdentity": target_identity.get("ledgerIdentity") or {},
|
|
2307
|
+
}
|
|
2308
|
+
journal["evidenceRebind"] = {
|
|
2309
|
+
"status": "DONE",
|
|
2310
|
+
"from": current_change,
|
|
2311
|
+
"to": evidence_change_id,
|
|
2312
|
+
"reason": "REMOTE_CONFIRMED_DEFAULT_LEDGER_MISSING",
|
|
2313
|
+
"remoteHead": merge_commit,
|
|
2314
|
+
"handoffHash": next(iter(handoff_hashes)),
|
|
2315
|
+
"previousEvidenceIdentity": previous_identity,
|
|
2316
|
+
"previousVerificationIdentity": previous_verification_identity,
|
|
2317
|
+
"targetLedgerIdentity": target_identity.get("ledgerIdentity") or {},
|
|
2318
|
+
"updatedAt": now_iso(),
|
|
2319
|
+
}
|
|
2320
|
+
return self._save(journal)
|
|
2321
|
+
|
|
2322
|
+
def recover(
|
|
2323
|
+
self,
|
|
2324
|
+
verify_commands: Sequence[Sequence[str]] | None = None,
|
|
2325
|
+
*,
|
|
2326
|
+
evidence_change_id: str | None = None,
|
|
2327
|
+
) -> dict[str, Any]:
|
|
2328
|
+
journal = self._load()
|
|
2329
|
+
if evidence_change_id is not None:
|
|
2330
|
+
journal = self._rebind_evidence_change_for_recovery(
|
|
2331
|
+
journal,
|
|
2332
|
+
evidence_change_id,
|
|
2333
|
+
)
|
|
2334
|
+
actions: dict[str, Callable[[], dict[str, Any]]] = {
|
|
2335
|
+
"preflight": self.preflight,
|
|
2336
|
+
"prepare": self.prepare,
|
|
2337
|
+
"merge": self.merge,
|
|
2338
|
+
"verify": lambda: self.verify(commands=verify_commands),
|
|
2339
|
+
"push": self.push,
|
|
2340
|
+
"cleanup": self.cleanup,
|
|
2341
|
+
}
|
|
2342
|
+
for name in STEP_ORDER:
|
|
2343
|
+
step = self._step(journal, name)
|
|
2344
|
+
if step["status"] == "DONE":
|
|
2345
|
+
step["reentry"] = "REUSED"
|
|
2346
|
+
self._save(journal)
|
|
2347
|
+
continue
|
|
2348
|
+
journal = actions[name]()
|
|
2349
|
+
return self._load()
|
|
2350
|
+
|
|
2351
|
+
def status(self) -> dict[str, Any]:
|
|
2352
|
+
journal = self._load()
|
|
2353
|
+
return {
|
|
2354
|
+
"ok": True,
|
|
2355
|
+
"transactionId": self.transaction_id,
|
|
2356
|
+
"steps": journal["steps"],
|
|
2357
|
+
"mergeCommit": journal.get("mergeCommit"),
|
|
2358
|
+
"pushedHead": journal.get("pushedHead"),
|
|
2359
|
+
# "verify = DONE" 单独一句什么也没说明。把计划、来源和退出码一起回显,
|
|
2360
|
+
# 调用方才看得出这次合并到底验证了什么。
|
|
2361
|
+
"verifyPlan": journal.get("verifyPlan") or [],
|
|
2362
|
+
"verificationDepth": journal.get("verificationDepth"),
|
|
2363
|
+
"verifyResults": [
|
|
2364
|
+
{
|
|
2365
|
+
"command": item.get("command"),
|
|
2366
|
+
"exitCode": item.get("exitCode"),
|
|
2367
|
+
"reused": bool(item.get("reused")),
|
|
2368
|
+
"durationMs": item.get("durationMs"),
|
|
2369
|
+
}
|
|
2370
|
+
for item in (journal.get("verifyResults") or [])
|
|
2371
|
+
if isinstance(item, dict)
|
|
2372
|
+
],
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
|
|
2376
|
+
# ------------------------------------------------------------------- CLI
|
|
2377
|
+
|
|
2378
|
+
|
|
2379
|
+
def _txn_from_args(args: argparse.Namespace) -> IntegrationTransaction:
|
|
2380
|
+
project_root = Path.cwd()
|
|
2381
|
+
return IntegrationTransaction(
|
|
2382
|
+
project_root=project_root,
|
|
2383
|
+
change_id=args.change,
|
|
2384
|
+
run_id=args.run_id,
|
|
2385
|
+
target_branch=args.target_branch,
|
|
2386
|
+
feature_branch=args.feature_branch,
|
|
2387
|
+
temp_root=(
|
|
2388
|
+
Path(args.temp_root)
|
|
2389
|
+
if getattr(args, "temp_root", None)
|
|
2390
|
+
else default_temp_root(project_root)
|
|
2391
|
+
),
|
|
2392
|
+
)
|
|
2393
|
+
|
|
2394
|
+
|
|
2395
|
+
def _emit(payload: Any) -> int:
|
|
2396
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
2397
|
+
return 0
|
|
2398
|
+
|
|
2399
|
+
|
|
2400
|
+
def cmd_preflight(args: argparse.Namespace) -> int:
|
|
2401
|
+
return _emit(_txn_from_args(args).preflight())
|
|
2402
|
+
|
|
2403
|
+
|
|
2404
|
+
def cmd_prepare(args: argparse.Namespace) -> int:
|
|
2405
|
+
return _emit(_txn_from_args(args).prepare())
|
|
2406
|
+
|
|
2407
|
+
|
|
2408
|
+
def cmd_merge(args: argparse.Namespace) -> int:
|
|
2409
|
+
return _emit(_txn_from_args(args).merge())
|
|
2410
|
+
|
|
2411
|
+
|
|
2412
|
+
def _parse_verify_commands(values: Sequence[str] | None) -> list[list[str]]:
|
|
2413
|
+
"""Split ``--command`` strings honoring quotes (shlex, POSIX mode).
|
|
2414
|
+
|
|
2415
|
+
Plain ``str.split(" ")`` breaks quoted arguments (e.g. ``-k 'a b'``).
|
|
2416
|
+
POSIX shlex semantics: single/double quotes group tokens, backslash
|
|
2417
|
+
escapes the next character (quote Windows paths).
|
|
2418
|
+
"""
|
|
2419
|
+
return [_split_verify_command(value) for value in (values or [])]
|
|
2420
|
+
|
|
2421
|
+
|
|
2422
|
+
def cmd_verify(args: argparse.Namespace) -> int:
|
|
2423
|
+
return _emit(
|
|
2424
|
+
_txn_from_args(args).verify(
|
|
2425
|
+
commands=_parse_verify_commands(args.command),
|
|
2426
|
+
timeout_seconds=float(args.timeout_seconds),
|
|
2427
|
+
)
|
|
2428
|
+
)
|
|
2429
|
+
|
|
2430
|
+
|
|
2431
|
+
def cmd_push(args: argparse.Namespace) -> int:
|
|
2432
|
+
return _emit(_txn_from_args(args).push())
|
|
2433
|
+
|
|
2434
|
+
|
|
2435
|
+
def cmd_cleanup(args: argparse.Namespace) -> int:
|
|
2436
|
+
return _emit(_txn_from_args(args).cleanup())
|
|
2437
|
+
|
|
2438
|
+
|
|
2439
|
+
def cmd_abandon(args: argparse.Namespace) -> int:
|
|
2440
|
+
return _emit(_txn_from_args(args).abandon())
|
|
2441
|
+
|
|
2442
|
+
|
|
2443
|
+
def cmd_recover(args: argparse.Namespace) -> int:
|
|
2444
|
+
return _emit(
|
|
2445
|
+
_txn_from_args(args).recover(
|
|
2446
|
+
evidence_change_id=getattr(args, "evidence_change", None)
|
|
2447
|
+
)
|
|
2448
|
+
)
|
|
2449
|
+
|
|
2450
|
+
|
|
2451
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
2452
|
+
return _emit(_txn_from_args(args).status())
|
|
2453
|
+
|
|
2454
|
+
|
|
2455
|
+
def cmd_journal(args: argparse.Namespace) -> int:
|
|
2456
|
+
"""C5: journal 子命令 — 默认 compact (transactionId/currentStep/status),--verbose 全量。"""
|
|
2457
|
+
txn = _txn_from_args(args)
|
|
2458
|
+
full = txn.status()
|
|
2459
|
+
if getattr(args, "verbose", False):
|
|
2460
|
+
return _emit(full)
|
|
2461
|
+
# Derive currentStep: first non-DONE step, or last step name if all DONE.
|
|
2462
|
+
current_step = None
|
|
2463
|
+
for step in full.get("steps", []):
|
|
2464
|
+
if step.get("status") != "DONE":
|
|
2465
|
+
current_step = step.get("name")
|
|
2466
|
+
break
|
|
2467
|
+
if current_step is None and full.get("steps"):
|
|
2468
|
+
current_step = full["steps"][-1].get("name")
|
|
2469
|
+
overall_status = "DONE" if all(
|
|
2470
|
+
step.get("status") == "DONE" for step in full.get("steps", [])
|
|
2471
|
+
) else "IN_PROGRESS"
|
|
2472
|
+
compact = {
|
|
2473
|
+
"transactionId": full.get("transactionId"),
|
|
2474
|
+
"currentStep": current_step,
|
|
2475
|
+
"status": overall_status,
|
|
2476
|
+
}
|
|
2477
|
+
return _emit(compact)
|
|
2478
|
+
|
|
2479
|
+
|
|
2480
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
2481
|
+
parser = argparse.ArgumentParser(prog="harness_integration.py")
|
|
2482
|
+
sub = parser.add_subparsers(dest="command_name", required=True)
|
|
2483
|
+
|
|
2484
|
+
def add(
|
|
2485
|
+
name: str,
|
|
2486
|
+
func: Callable[[argparse.Namespace], int],
|
|
2487
|
+
) -> argparse.ArgumentParser:
|
|
2488
|
+
p = sub.add_parser(name)
|
|
2489
|
+
p.add_argument("--change", required=True)
|
|
2490
|
+
p.add_argument("--run-id", required=True)
|
|
2491
|
+
p.add_argument("--target-branch", default="main")
|
|
2492
|
+
p.add_argument("--feature-branch", required=True)
|
|
2493
|
+
p.add_argument("--temp-root")
|
|
2494
|
+
p.set_defaults(func=func)
|
|
2495
|
+
return p
|
|
2496
|
+
|
|
2497
|
+
add("preflight", cmd_preflight)
|
|
2498
|
+
add("prepare", cmd_prepare)
|
|
2499
|
+
add("merge", cmd_merge)
|
|
2500
|
+
add("push", cmd_push)
|
|
2501
|
+
add("cleanup", cmd_cleanup)
|
|
2502
|
+
add("abandon", cmd_abandon)
|
|
2503
|
+
p_recover = add("recover", cmd_recover)
|
|
2504
|
+
p_recover.add_argument(
|
|
2505
|
+
"--evidence-change",
|
|
2506
|
+
help=(
|
|
2507
|
+
"one-time recovery binding after a remote-confirmed "
|
|
2508
|
+
"default LEDGER_MISSING failure"
|
|
2509
|
+
),
|
|
2510
|
+
)
|
|
2511
|
+
add("status", cmd_status)
|
|
2512
|
+
|
|
2513
|
+
p_journal = sub.add_parser("journal")
|
|
2514
|
+
p_journal.add_argument("--change", required=True)
|
|
2515
|
+
p_journal.add_argument("--run-id", required=True)
|
|
2516
|
+
p_journal.add_argument("--target-branch", default="main")
|
|
2517
|
+
p_journal.add_argument("--feature-branch", required=True)
|
|
2518
|
+
p_journal.add_argument("--temp-root")
|
|
2519
|
+
p_journal.add_argument(
|
|
2520
|
+
"--verbose",
|
|
2521
|
+
action="store_true",
|
|
2522
|
+
help="emit full journal payload (default: compact transactionId/currentStep/status)",
|
|
2523
|
+
)
|
|
2524
|
+
p_journal.set_defaults(func=cmd_journal)
|
|
2525
|
+
|
|
2526
|
+
p_verify = sub.add_parser("verify")
|
|
2527
|
+
p_verify.add_argument("--change", required=True)
|
|
2528
|
+
p_verify.add_argument("--run-id", required=True)
|
|
2529
|
+
p_verify.add_argument("--target-branch", default="main")
|
|
2530
|
+
p_verify.add_argument("--feature-branch", required=True)
|
|
2531
|
+
p_verify.add_argument("--temp-root")
|
|
2532
|
+
p_verify.add_argument("--command", action="append")
|
|
2533
|
+
p_verify.add_argument(
|
|
2534
|
+
"--timeout-seconds",
|
|
2535
|
+
type=float,
|
|
2536
|
+
default=DEFAULT_VERIFY_TIMEOUT_SECONDS,
|
|
2537
|
+
)
|
|
2538
|
+
p_verify.set_defaults(func=cmd_verify)
|
|
2539
|
+
|
|
2540
|
+
return parser
|
|
2541
|
+
|
|
2542
|
+
|
|
2543
|
+
def main(argv: list[str] | None = None) -> int:
|
|
2544
|
+
parser = build_parser()
|
|
2545
|
+
args = parser.parse_args(argv)
|
|
2546
|
+
try:
|
|
2547
|
+
return int(args.func(args))
|
|
2548
|
+
except IntegrationError as exc:
|
|
2549
|
+
extra = getattr(exc, "result", None)
|
|
2550
|
+
payload = {"ok": False, "code": exc.code, "message": str(exc)}
|
|
2551
|
+
if isinstance(extra, dict):
|
|
2552
|
+
payload["cleanup"] = extra
|
|
2553
|
+
sys.stderr.write(
|
|
2554
|
+
json.dumps(
|
|
2555
|
+
payload,
|
|
2556
|
+
ensure_ascii=False,
|
|
2557
|
+
)
|
|
2558
|
+
+ "\n"
|
|
2559
|
+
)
|
|
2560
|
+
return 1
|
|
2561
|
+
|
|
2562
|
+
|
|
2563
|
+
if __name__ == "__main__":
|
|
2564
|
+
raise SystemExit(main())
|