@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,1425 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""P4: sync local events.ndjson to the platform Run monitoring API.
|
|
3
|
+
|
|
4
|
+
Reads `.harness/credentials.local.yaml` + project_id, whitelists event fields,
|
|
5
|
+
POSTs batches with ACK cursor, and sends heartbeats. Offline-safe: cursor only
|
|
6
|
+
advances on accepted/duplicate_accepted responses.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import errno
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
import uuid
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
28
|
+
if str(SCRIPTS_DIR) not in sys.path:
|
|
29
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
30
|
+
|
|
31
|
+
import harness_paths # noqa: E402
|
|
32
|
+
|
|
33
|
+
WHITELIST = {
|
|
34
|
+
"id",
|
|
35
|
+
"timestamp",
|
|
36
|
+
"schema_version",
|
|
37
|
+
# Legacy aliases remain readable while new writers use the canonical names.
|
|
38
|
+
"ts",
|
|
39
|
+
"type",
|
|
40
|
+
"phase",
|
|
41
|
+
"status",
|
|
42
|
+
"note",
|
|
43
|
+
"message",
|
|
44
|
+
"attempt",
|
|
45
|
+
"name",
|
|
46
|
+
"code",
|
|
47
|
+
"severity",
|
|
48
|
+
"decision",
|
|
49
|
+
"reason",
|
|
50
|
+
"exit_code",
|
|
51
|
+
"duration_ms",
|
|
52
|
+
"schemaVersion",
|
|
53
|
+
"summary",
|
|
54
|
+
"executor_tool",
|
|
55
|
+
"executor_agent",
|
|
56
|
+
"executor_model",
|
|
57
|
+
"execution_mode",
|
|
58
|
+
"decision_reason_code",
|
|
59
|
+
"fallback_reason_code",
|
|
60
|
+
"trigger",
|
|
61
|
+
"from_phase",
|
|
62
|
+
"result_status",
|
|
63
|
+
"planned_phases",
|
|
64
|
+
"skipped_phases",
|
|
65
|
+
"next_phase",
|
|
66
|
+
"phase_plan_source",
|
|
67
|
+
"closure_disposition",
|
|
68
|
+
"closure_reason",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
MAX_BATCH_SIZE = 100
|
|
72
|
+
ACCEPTED_STATUSES = {"accepted", "duplicate_accepted"}
|
|
73
|
+
QUARANTINE_STATUSES = {"id_conflict", "rejected", "rejected_schema"}
|
|
74
|
+
DISPLAY_TITLE_MAX_LENGTH = 200
|
|
75
|
+
EVENT_SUMMARY_MAX_LENGTH = 500
|
|
76
|
+
|
|
77
|
+
PHASE_LABELS = {
|
|
78
|
+
"plan": "计划",
|
|
79
|
+
"execute": "执行",
|
|
80
|
+
"run": "编码",
|
|
81
|
+
"test": "测试",
|
|
82
|
+
"review": "评审",
|
|
83
|
+
"package": "打包",
|
|
84
|
+
"apidoc": "接口文档",
|
|
85
|
+
"submit": "提交",
|
|
86
|
+
"archive": "归档",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
REVIEW_REASON_SUMMARIES = {
|
|
90
|
+
"REVIEW_DELEGATED": "已使用独立评审,主会话负责核验结果。",
|
|
91
|
+
"REVIEW_INLINE_UNAVAILABLE": "当前环境没有可用的隔离评审能力,已由主会话完成评审。",
|
|
92
|
+
"REVIEW_INLINE_SPAWN_FAILED": "独立评审启动失败,已由主会话完成评审。",
|
|
93
|
+
"REVIEW_INLINE_INVALID_RESULT": "独立评审未返回有效结果,已由主会话重新完成评审。",
|
|
94
|
+
"INLINE_BY_ADAPTER": "当前适配器未提供隔离评审,已由主会话完成评审。",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _yaml_scalar(text: str, key: str) -> str | None:
|
|
99
|
+
match = re.search(rf"(?m)^[ \t]*{re.escape(key)}:\s*['\"]?([^'\"\n#]+)", text)
|
|
100
|
+
if match is None:
|
|
101
|
+
return None
|
|
102
|
+
value = match.group(1).strip()
|
|
103
|
+
return value or None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_endpoint(project: Path) -> dict[str, str] | None:
|
|
107
|
+
creds = project / ".harness" / "credentials.local.yaml"
|
|
108
|
+
if not creds.is_file():
|
|
109
|
+
return None
|
|
110
|
+
text = creds.read_text(encoding="utf-8", errors="ignore")
|
|
111
|
+
server_url = _yaml_scalar(text, "server_url")
|
|
112
|
+
token = _yaml_scalar(text, "token")
|
|
113
|
+
project_id = _yaml_scalar(text, "project_id")
|
|
114
|
+
if not project_id:
|
|
115
|
+
py = project / ".harness" / "project.yaml"
|
|
116
|
+
if py.is_file():
|
|
117
|
+
project_id = _yaml_scalar(py.read_text(encoding="utf-8", errors="ignore"), "project_id")
|
|
118
|
+
if not server_url or not token or not project_id:
|
|
119
|
+
return None
|
|
120
|
+
if project_id.lower() in {"null", "~", "none"}:
|
|
121
|
+
return None
|
|
122
|
+
return {
|
|
123
|
+
"server_url": server_url.rstrip("/"),
|
|
124
|
+
"token": token,
|
|
125
|
+
"project_id": project_id,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _summary_text(value: Any) -> str:
|
|
130
|
+
if not isinstance(value, str):
|
|
131
|
+
return ""
|
|
132
|
+
text = re.sub(r"\s+", " ", value).strip()
|
|
133
|
+
text = "".join(character for character in text if ord(character) >= 32 and ord(character) != 127)
|
|
134
|
+
return text[:EVENT_SUMMARY_MAX_LENGTH].rstrip()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
_MACHINE_DETAIL_RE = re.compile(
|
|
138
|
+
r"(?:\s*[;;,,]?\s*)"
|
|
139
|
+
r"(?:artifactsHash|artifactHash|receiptHash|content_sha256|contentSha256)"
|
|
140
|
+
r"\s*[:=]\s*(?:sha256:)?[0-9a-f]{32,64}",
|
|
141
|
+
re.IGNORECASE,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _readable_summary_text(value: Any) -> str:
|
|
146
|
+
text = _summary_text(value)
|
|
147
|
+
if not text:
|
|
148
|
+
return ""
|
|
149
|
+
text = _MACHINE_DETAIL_RE.sub("", text)
|
|
150
|
+
text = re.sub(
|
|
151
|
+
r"\bfinalize\s+ok\b",
|
|
152
|
+
"规划产物已校验并发布",
|
|
153
|
+
text,
|
|
154
|
+
flags=re.IGNORECASE,
|
|
155
|
+
)
|
|
156
|
+
text = re.sub(r"\s*([;;,,])\s*([;;,,])", r"\1", text)
|
|
157
|
+
return _summary_text(text.strip(" ;;,,"))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def build_event_summary(event: dict[str, Any]) -> str:
|
|
161
|
+
reason_code = _summary_text(
|
|
162
|
+
event.get("fallback_reason_code") or event.get("decision_reason_code")
|
|
163
|
+
)
|
|
164
|
+
reason_summary = REVIEW_REASON_SUMMARIES.get(reason_code)
|
|
165
|
+
explicit = _readable_summary_text(event.get("summary"))
|
|
166
|
+
if reason_summary and (
|
|
167
|
+
not explicit
|
|
168
|
+
or explicit == reason_code
|
|
169
|
+
or re.search(r"REVIEW_[A-Z0-9_]+|INLINE_BY_ADAPTER", explicit)
|
|
170
|
+
):
|
|
171
|
+
return reason_summary
|
|
172
|
+
if explicit and not re.search(
|
|
173
|
+
r"\b[A-Z][A-Z0-9_]{3,}\b|\b[a-z][\w.-]+\s*=\s*(?:true|false)\b",
|
|
174
|
+
explicit,
|
|
175
|
+
):
|
|
176
|
+
return explicit
|
|
177
|
+
|
|
178
|
+
event_type = _summary_text(event.get("type"))
|
|
179
|
+
phase = _summary_text(event.get("phase"))
|
|
180
|
+
phase_label = PHASE_LABELS.get(phase, phase or "当前")
|
|
181
|
+
note = _readable_summary_text(event.get("note"))
|
|
182
|
+
message = _readable_summary_text(event.get("message"))
|
|
183
|
+
decision = _readable_summary_text(event.get("decision"))
|
|
184
|
+
reason = _readable_summary_text(event.get("reason"))
|
|
185
|
+
name = _readable_summary_text(event.get("name"))
|
|
186
|
+
code = _readable_summary_text(event.get("code"))
|
|
187
|
+
status = _summary_text(event.get("status"))
|
|
188
|
+
|
|
189
|
+
if event_type == "decision" and decision:
|
|
190
|
+
return _summary_text(f"{decision};原因:{reason}" if reason else decision)
|
|
191
|
+
if event_type == "issue":
|
|
192
|
+
detail = message or note or code
|
|
193
|
+
if detail and reason and reason != detail:
|
|
194
|
+
return _summary_text(f"{detail};原因:{reason}")
|
|
195
|
+
return detail or "发现一项需要处理的问题。"
|
|
196
|
+
if event_type == "issue.resolve":
|
|
197
|
+
detail = message or note or code
|
|
198
|
+
return _summary_text(f"问题已解决:{detail}" if detail else "已解决此前记录的问题。")
|
|
199
|
+
if event_type == "phase.start":
|
|
200
|
+
return _summary_text(
|
|
201
|
+
f"开始执行{phase_label}阶段:{note}" if note else f"开始执行{phase_label}阶段。"
|
|
202
|
+
)
|
|
203
|
+
if event_type in {"phase.end", "phase.auto_sealed"}:
|
|
204
|
+
detail = note or status
|
|
205
|
+
return _summary_text(
|
|
206
|
+
f"{phase_label}阶段已结束:{detail}" if detail else f"{phase_label}阶段已结束。"
|
|
207
|
+
)
|
|
208
|
+
if event_type == "gate.blocked":
|
|
209
|
+
return note or message or "阶段门禁暂未通过,现场已保留,可按提示恢复。"
|
|
210
|
+
if event_type == "gate.recovered":
|
|
211
|
+
return note or message or "阶段门禁已恢复,流程可以继续。"
|
|
212
|
+
if event_type == "command":
|
|
213
|
+
detail = name or note
|
|
214
|
+
return _summary_text(f"执行步骤:{detail}" if detail else "执行了一项工作步骤。")
|
|
215
|
+
if event_type == "verification":
|
|
216
|
+
detail = name or note or status
|
|
217
|
+
return _summary_text(f"完成验证:{detail}" if detail else "完成了一项结果验证。")
|
|
218
|
+
if event_type == "artifact":
|
|
219
|
+
detail = name or note
|
|
220
|
+
return _summary_text(f"生成或更新产物:{detail}" if detail else "生成或更新了一项交付内容。")
|
|
221
|
+
if event_type == "correction":
|
|
222
|
+
detail = note or reason or message
|
|
223
|
+
return _summary_text(f"调整执行方式:{detail}" if detail else "根据当前结果调整了执行方式。")
|
|
224
|
+
if event_type == "change.rename":
|
|
225
|
+
return note or message or "更新了变更名称。"
|
|
226
|
+
if event_type in {"recovery", "phase.recovery", "attempt.recovery"}:
|
|
227
|
+
return note or reason or message or "恢复了中断的运行状态。"
|
|
228
|
+
if event_type == "heartbeat":
|
|
229
|
+
return "客户端保持在线,并同步了最新运行状态。"
|
|
230
|
+
|
|
231
|
+
return note or message or name or reason or code or "记录了一项运行事件。"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def sanitize(event: dict[str, Any]) -> dict[str, Any]:
|
|
235
|
+
payload = {key: event[key] for key in WHITELIST if key in event}
|
|
236
|
+
payload["summary"] = build_event_summary(event)
|
|
237
|
+
return payload
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _workflow_plan(change_dir: Path) -> dict[str, Any] | None:
|
|
241
|
+
policy_path = change_dir / "meta" / "gate-policy.json"
|
|
242
|
+
if not policy_path.is_file():
|
|
243
|
+
return None
|
|
244
|
+
try:
|
|
245
|
+
policy = json.loads(policy_path.read_text(encoding="utf-8-sig"))
|
|
246
|
+
except (OSError, json.JSONDecodeError):
|
|
247
|
+
return None
|
|
248
|
+
if not isinstance(policy, dict):
|
|
249
|
+
return None
|
|
250
|
+
raw = policy.get("plannedPhases")
|
|
251
|
+
source = "change"
|
|
252
|
+
if not isinstance(raw, list) or not raw:
|
|
253
|
+
raw = policy.get("defaultPhases")
|
|
254
|
+
source = "policy-default"
|
|
255
|
+
if not isinstance(raw, list):
|
|
256
|
+
return None
|
|
257
|
+
phases = [str(item).strip() for item in raw if str(item).strip()]
|
|
258
|
+
if not phases:
|
|
259
|
+
return None
|
|
260
|
+
skipped = policy.get("skippedPhases")
|
|
261
|
+
return {
|
|
262
|
+
"planned_phases": list(dict.fromkeys(phases)),
|
|
263
|
+
"skipped_phases": skipped if isinstance(skipped, list) else [],
|
|
264
|
+
"phase_plan_source": source,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _event_payload(event: dict[str, Any], phase_plan: dict[str, Any] | None) -> dict[str, Any]:
|
|
269
|
+
payload = sanitize(event)
|
|
270
|
+
if phase_plan is None:
|
|
271
|
+
return payload
|
|
272
|
+
payload.update(phase_plan)
|
|
273
|
+
if str(event.get("type") or "") in {"phase.end", "phase.auto_sealed"}:
|
|
274
|
+
phase = str(event.get("phase") or "").strip()
|
|
275
|
+
planned = phase_plan["planned_phases"]
|
|
276
|
+
if phase in planned:
|
|
277
|
+
index = planned.index(phase)
|
|
278
|
+
payload["next_phase"] = planned[index + 1] if index + 1 < len(planned) else None
|
|
279
|
+
return payload
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _valid_display_title(value: Any) -> str | None:
|
|
283
|
+
if not isinstance(value, str):
|
|
284
|
+
return None
|
|
285
|
+
title = value.strip()
|
|
286
|
+
if (
|
|
287
|
+
not title
|
|
288
|
+
or len(title) > DISPLAY_TITLE_MAX_LENGTH
|
|
289
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in title)
|
|
290
|
+
):
|
|
291
|
+
return None
|
|
292
|
+
return title
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _safe_file_within(change_dir: Path, path: Path) -> Path | None:
|
|
296
|
+
try:
|
|
297
|
+
root = change_dir.resolve()
|
|
298
|
+
resolved = path.resolve()
|
|
299
|
+
except OSError:
|
|
300
|
+
return None
|
|
301
|
+
if path.is_symlink() or not resolved.is_relative_to(root) or not resolved.is_file():
|
|
302
|
+
return None
|
|
303
|
+
return resolved
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def load_display_title(change_dir: Path, change_key: str) -> str:
|
|
307
|
+
metadata_path = _safe_file_within(
|
|
308
|
+
change_dir, change_dir / "meta" / "change-title.json"
|
|
309
|
+
)
|
|
310
|
+
if metadata_path is not None:
|
|
311
|
+
try:
|
|
312
|
+
payload = json.loads(metadata_path.read_text(encoding="utf-8-sig"))
|
|
313
|
+
except (OSError, json.JSONDecodeError):
|
|
314
|
+
payload = None
|
|
315
|
+
if isinstance(payload, dict):
|
|
316
|
+
title = _valid_display_title(payload.get("displayTitle"))
|
|
317
|
+
if title is not None:
|
|
318
|
+
return title
|
|
319
|
+
|
|
320
|
+
# Legacy migration: older Plan runs already wrote a human title into the
|
|
321
|
+
# approved design H1 but had no structured title metadata.
|
|
322
|
+
design_path = _safe_file_within(
|
|
323
|
+
change_dir, change_dir / "spec" / f"{change_key}-design.md"
|
|
324
|
+
)
|
|
325
|
+
if design_path is not None:
|
|
326
|
+
try:
|
|
327
|
+
with design_path.open("r", encoding="utf-8-sig") as stream:
|
|
328
|
+
for _ in range(40):
|
|
329
|
+
line = stream.readline()
|
|
330
|
+
if not line:
|
|
331
|
+
break
|
|
332
|
+
if not line.startswith("# "):
|
|
333
|
+
continue
|
|
334
|
+
candidate = re.sub(
|
|
335
|
+
r"\s+(?:设计文档|设计方案|设计)$", "", line[2:].strip()
|
|
336
|
+
)
|
|
337
|
+
title = _valid_display_title(candidate)
|
|
338
|
+
if title is not None and title != change_key:
|
|
339
|
+
return title
|
|
340
|
+
break
|
|
341
|
+
except OSError:
|
|
342
|
+
pass
|
|
343
|
+
return change_key
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def cursor_path(change_dir: Path) -> Path:
|
|
347
|
+
return _state_dir(change_dir) / "meta" / "events-sync-cursor.json"
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _state_dir(change_dir: Path) -> Path:
|
|
351
|
+
return Path(harness_paths.resolve_state_dir_for_contract(change_dir))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _cursor_values(path: Path) -> tuple[int, int, bool]:
|
|
355
|
+
if not path.is_file():
|
|
356
|
+
return 0, 0, True
|
|
357
|
+
try:
|
|
358
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
359
|
+
except (OSError, json.JSONDecodeError):
|
|
360
|
+
return 0, 0, False
|
|
361
|
+
raw_acked = data.get("acked_lines") if isinstance(data, dict) else None
|
|
362
|
+
raw_base = data.get("producer_seq_base", 0) if isinstance(data, dict) else None
|
|
363
|
+
acked_valid = (
|
|
364
|
+
not isinstance(raw_acked, bool)
|
|
365
|
+
and isinstance(raw_acked, int)
|
|
366
|
+
and raw_acked >= 0
|
|
367
|
+
)
|
|
368
|
+
base_valid = (
|
|
369
|
+
not isinstance(raw_base, bool)
|
|
370
|
+
and isinstance(raw_base, int)
|
|
371
|
+
and raw_base >= 0
|
|
372
|
+
)
|
|
373
|
+
return (
|
|
374
|
+
raw_acked if acked_valid else 0,
|
|
375
|
+
raw_base if base_valid else 0,
|
|
376
|
+
acked_valid and base_valid,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _complete_line_count(path: Path) -> int:
|
|
381
|
+
if not path.is_file():
|
|
382
|
+
return 0
|
|
383
|
+
try:
|
|
384
|
+
data = path.read_bytes()
|
|
385
|
+
except OSError:
|
|
386
|
+
return 0
|
|
387
|
+
if not data:
|
|
388
|
+
return 0
|
|
389
|
+
return data.count(b"\n")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _initial_producer_seq_base(change_dir: Path) -> int:
|
|
393
|
+
"""Keep producer sequence monotonic when a legacy stream becomes split-v1."""
|
|
394
|
+
state_dir = _state_dir(change_dir).resolve()
|
|
395
|
+
contract_dir = change_dir.resolve()
|
|
396
|
+
if state_dir == contract_dir:
|
|
397
|
+
return 0
|
|
398
|
+
legacy_acked, legacy_base, _ = _cursor_values(
|
|
399
|
+
contract_dir / "meta" / "events-sync-cursor.json"
|
|
400
|
+
)
|
|
401
|
+
legacy_lines = _complete_line_count(contract_dir / "events.ndjson")
|
|
402
|
+
return legacy_base + max(legacy_acked, legacy_lines)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def load_cursor_state(
|
|
406
|
+
change_dir: Path,
|
|
407
|
+
*,
|
|
408
|
+
maximum_lines: int | None = None,
|
|
409
|
+
repair: bool = False,
|
|
410
|
+
) -> dict[str, int]:
|
|
411
|
+
path = cursor_path(change_dir)
|
|
412
|
+
exists = path.is_file()
|
|
413
|
+
acked, producer_seq_base, valid = _cursor_values(path)
|
|
414
|
+
if not exists:
|
|
415
|
+
producer_seq_base = _initial_producer_seq_base(change_dir)
|
|
416
|
+
if maximum_lines is not None:
|
|
417
|
+
if maximum_lines < 0:
|
|
418
|
+
raise ValueError("maximum_lines must be non-negative")
|
|
419
|
+
if acked > maximum_lines:
|
|
420
|
+
acked = 0
|
|
421
|
+
valid = False
|
|
422
|
+
if (not valid or not exists) and repair:
|
|
423
|
+
save_cursor(
|
|
424
|
+
change_dir,
|
|
425
|
+
acked,
|
|
426
|
+
producer_seq_base=producer_seq_base,
|
|
427
|
+
)
|
|
428
|
+
return {
|
|
429
|
+
"acked_lines": acked,
|
|
430
|
+
"producer_seq_base": producer_seq_base,
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def load_cursor(
|
|
435
|
+
change_dir: Path,
|
|
436
|
+
*,
|
|
437
|
+
maximum_lines: int | None = None,
|
|
438
|
+
repair: bool = False,
|
|
439
|
+
) -> int:
|
|
440
|
+
return load_cursor_state(
|
|
441
|
+
change_dir,
|
|
442
|
+
maximum_lines=maximum_lines,
|
|
443
|
+
repair=repair,
|
|
444
|
+
)["acked_lines"]
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _fsync_parent(path: Path) -> None:
|
|
448
|
+
if os.name == "nt":
|
|
449
|
+
return
|
|
450
|
+
descriptor = os.open(str(path.parent), os.O_RDONLY)
|
|
451
|
+
try:
|
|
452
|
+
os.fsync(descriptor)
|
|
453
|
+
finally:
|
|
454
|
+
os.close(descriptor)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def save_cursor(
|
|
458
|
+
change_dir: Path,
|
|
459
|
+
acked_lines: int,
|
|
460
|
+
*,
|
|
461
|
+
producer_seq_base: int | None = None,
|
|
462
|
+
) -> None:
|
|
463
|
+
if isinstance(acked_lines, bool) or not isinstance(acked_lines, int) or acked_lines < 0:
|
|
464
|
+
raise ValueError("acked_lines must be a non-negative integer")
|
|
465
|
+
path = cursor_path(change_dir)
|
|
466
|
+
if producer_seq_base is None:
|
|
467
|
+
_, producer_seq_base, _ = _cursor_values(path)
|
|
468
|
+
if (
|
|
469
|
+
isinstance(producer_seq_base, bool)
|
|
470
|
+
or not isinstance(producer_seq_base, int)
|
|
471
|
+
or producer_seq_base < 0
|
|
472
|
+
):
|
|
473
|
+
raise ValueError("producer_seq_base must be a non-negative integer")
|
|
474
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
475
|
+
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
476
|
+
encoded = (
|
|
477
|
+
json.dumps({
|
|
478
|
+
"schemaVersion": 2,
|
|
479
|
+
"acked_lines": acked_lines,
|
|
480
|
+
"producer_seq_base": producer_seq_base,
|
|
481
|
+
}, indent=2) + "\n"
|
|
482
|
+
).encode("utf-8")
|
|
483
|
+
descriptor = os.open(
|
|
484
|
+
str(temporary), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600
|
|
485
|
+
)
|
|
486
|
+
try:
|
|
487
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
488
|
+
descriptor = -1
|
|
489
|
+
stream.write(encoded)
|
|
490
|
+
stream.flush()
|
|
491
|
+
os.fsync(stream.fileno())
|
|
492
|
+
os.replace(temporary, path)
|
|
493
|
+
_fsync_parent(path)
|
|
494
|
+
finally:
|
|
495
|
+
if descriptor >= 0:
|
|
496
|
+
os.close(descriptor)
|
|
497
|
+
try:
|
|
498
|
+
temporary.unlink()
|
|
499
|
+
except FileNotFoundError:
|
|
500
|
+
pass
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def quarantine_path(change_dir: Path) -> Path:
|
|
504
|
+
return _state_dir(change_dir) / "meta" / "events-sync-quarantine.ndjson"
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def append_quarantine(
|
|
508
|
+
change_dir: Path,
|
|
509
|
+
*,
|
|
510
|
+
event_id: str,
|
|
511
|
+
line_number: int,
|
|
512
|
+
error_code: str,
|
|
513
|
+
status: str,
|
|
514
|
+
) -> None:
|
|
515
|
+
path = quarantine_path(change_dir)
|
|
516
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
517
|
+
record = {
|
|
518
|
+
"schema_version": 1,
|
|
519
|
+
"event_id": event_id,
|
|
520
|
+
"line_number": line_number,
|
|
521
|
+
"status": status,
|
|
522
|
+
"error_code": error_code,
|
|
523
|
+
"quarantined_at": datetime.now(timezone.utc).isoformat(),
|
|
524
|
+
}
|
|
525
|
+
with path.open("a", encoding="utf-8", newline="\n") as stream:
|
|
526
|
+
stream.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
527
|
+
stream.flush()
|
|
528
|
+
os.fsync(stream.fileno())
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def run_id_for(change_dir: Path) -> str:
|
|
532
|
+
# Stable opaque id from absolute path — no hostname/username leakage.
|
|
533
|
+
digest = hashlib.sha256(str(change_dir.resolve()).encode("utf-8")).hexdigest()[:24]
|
|
534
|
+
return "run_" + digest
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
PLAN_EVENTS_STREAM_NAME = "plan-events.ndjson"
|
|
538
|
+
PLAN_EVENTS_CURSOR_NAME = "plan-events-sync-cursor.json"
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _plan_events_path(change_dir: Path) -> Path:
|
|
542
|
+
return _state_dir(change_dir) / "meta" / PLAN_EVENTS_STREAM_NAME
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _plan_events_cursor_path(change_dir: Path) -> Path:
|
|
546
|
+
return _state_dir(change_dir) / "meta" / PLAN_EVENTS_CURSOR_NAME
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _load_plan_cursor(change_dir: Path, *, initial_base: int) -> tuple[int, int]:
|
|
550
|
+
path = _plan_events_cursor_path(change_dir)
|
|
551
|
+
if not path.is_file():
|
|
552
|
+
return 0, initial_base
|
|
553
|
+
try:
|
|
554
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
555
|
+
except (OSError, json.JSONDecodeError):
|
|
556
|
+
return 0, initial_base
|
|
557
|
+
acked = data.get("acked_lines") if isinstance(data, dict) else None
|
|
558
|
+
base = data.get("producer_seq_base") if isinstance(data, dict) else None
|
|
559
|
+
if not isinstance(acked, int) or isinstance(acked, bool) or acked < 0:
|
|
560
|
+
acked = 0
|
|
561
|
+
if not isinstance(base, int) or isinstance(base, bool) or base < 0:
|
|
562
|
+
base = initial_base
|
|
563
|
+
return acked, base
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _save_plan_cursor(change_dir: Path, *, acked: int, base: int) -> None:
|
|
567
|
+
path = _plan_events_cursor_path(change_dir)
|
|
568
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
569
|
+
payload = {
|
|
570
|
+
"schema_version": 1,
|
|
571
|
+
"acked_lines": acked,
|
|
572
|
+
"producer_seq_base": base,
|
|
573
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
574
|
+
}
|
|
575
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
576
|
+
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
577
|
+
os.replace(tmp, path)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _sync_plan_events_stream(
|
|
581
|
+
change_dir: Path,
|
|
582
|
+
*,
|
|
583
|
+
endpoint: dict[str, str],
|
|
584
|
+
rid: str,
|
|
585
|
+
resolved_key: str,
|
|
586
|
+
display_title: str,
|
|
587
|
+
main_stream_total_lines: int,
|
|
588
|
+
) -> dict[str, Any]:
|
|
589
|
+
"""Upload the TS PlanEvent stream (meta/plan-events.ndjson) with its own ACK cursor.
|
|
590
|
+
|
|
591
|
+
producer_seq base starts after the Python stream's total line count, so the two
|
|
592
|
+
streams never collide in (run_id, producer_seq) space. 事件语义与转换边界见
|
|
593
|
+
12-M3 文档:TS 事件按 hunter-progress-sync/v1 线上格式投影,event_type 保留
|
|
594
|
+
TS 原值(artifact_published 等)。
|
|
595
|
+
"""
|
|
596
|
+
project_id = endpoint["project_id"]
|
|
597
|
+
plan_file = _plan_events_path(change_dir)
|
|
598
|
+
if not plan_file.is_file():
|
|
599
|
+
return {"ok": True, "uploaded": 0, "quarantined": 0, "reason": "no plan-events.ndjson"}
|
|
600
|
+
lines = _event_lines_snapshot(plan_file)
|
|
601
|
+
acked, base = _load_plan_cursor(change_dir, initial_base=main_stream_total_lines)
|
|
602
|
+
if acked > len(lines):
|
|
603
|
+
# 文件被截断重建:从头重放(event_id 服务端幂等去重)
|
|
604
|
+
acked = 0
|
|
605
|
+
pending = lines[acked:]
|
|
606
|
+
uploaded = 0
|
|
607
|
+
quarantined = 0
|
|
608
|
+
client_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
609
|
+
for chunk_start in range(0, len(pending), MAX_BATCH_SIZE):
|
|
610
|
+
raw_chunk = pending[chunk_start:chunk_start + MAX_BATCH_SIZE]
|
|
611
|
+
batch: list[dict[str, Any]] = []
|
|
612
|
+
line_by_event_id: dict[str, int] = {}
|
|
613
|
+
local_invalid: list[tuple[str, int, str]] = []
|
|
614
|
+
for offset, raw_line in enumerate(raw_chunk, start=1):
|
|
615
|
+
line_number = acked + chunk_start + offset
|
|
616
|
+
line = raw_line.strip()
|
|
617
|
+
if not line:
|
|
618
|
+
local_invalid.append((f"{resolved_key}:plan:{line_number}", line_number, "EMPTY_LINE"))
|
|
619
|
+
continue
|
|
620
|
+
try:
|
|
621
|
+
event = json.loads(line)
|
|
622
|
+
except json.JSONDecodeError:
|
|
623
|
+
local_invalid.append((f"{resolved_key}:plan:{line_number}", line_number, "INVALID_JSON"))
|
|
624
|
+
continue
|
|
625
|
+
if not isinstance(event, dict):
|
|
626
|
+
local_invalid.append((f"{resolved_key}:plan:{line_number}", line_number, "INVALID_EVENT"))
|
|
627
|
+
continue
|
|
628
|
+
event_id = str(event.get("event_id") or f"{resolved_key}:plan:{line_number}")
|
|
629
|
+
item: dict[str, Any] = {
|
|
630
|
+
"event_id": event_id,
|
|
631
|
+
"producer_seq": base + line_number,
|
|
632
|
+
"event_type": str(event.get("type") or "unknown"),
|
|
633
|
+
"occurred_at": str(event.get("occurred_at") or client_time),
|
|
634
|
+
"payload": sanitize(event),
|
|
635
|
+
}
|
|
636
|
+
if event.get("phase") is not None:
|
|
637
|
+
item["phase"] = event.get("phase")
|
|
638
|
+
batch.append(item)
|
|
639
|
+
line_by_event_id[event_id] = line_number
|
|
640
|
+
|
|
641
|
+
if batch:
|
|
642
|
+
try:
|
|
643
|
+
result = post_json(
|
|
644
|
+
endpoint,
|
|
645
|
+
f"/api/v1/projects/{project_id}/runs/events:batch",
|
|
646
|
+
{
|
|
647
|
+
"protocol_version": "hunter-progress-sync/v1",
|
|
648
|
+
"run_id": rid,
|
|
649
|
+
"change_key": resolved_key,
|
|
650
|
+
"title": display_title,
|
|
651
|
+
"events": batch,
|
|
652
|
+
},
|
|
653
|
+
)
|
|
654
|
+
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
|
655
|
+
return {
|
|
656
|
+
"ok": False,
|
|
657
|
+
"error": f"plan-events batch failed: {exc}",
|
|
658
|
+
"uploaded": uploaded,
|
|
659
|
+
"quarantined": quarantined,
|
|
660
|
+
}
|
|
661
|
+
items = result.get("items") if isinstance(result, dict) else None
|
|
662
|
+
if not isinstance(items, list) or len(items) != len(batch):
|
|
663
|
+
return {
|
|
664
|
+
"ok": False,
|
|
665
|
+
"error": "plan-events batch response did not acknowledge every event",
|
|
666
|
+
"uploaded": uploaded,
|
|
667
|
+
"quarantined": quarantined,
|
|
668
|
+
}
|
|
669
|
+
response_by_id = {
|
|
670
|
+
str(item.get("id") or item.get("event_id") or ""): item
|
|
671
|
+
for item in items if isinstance(item, dict)
|
|
672
|
+
}
|
|
673
|
+
for event in batch:
|
|
674
|
+
event_id = str(event["event_id"])
|
|
675
|
+
item = response_by_id.get(event_id)
|
|
676
|
+
status = str((item or {}).get("status") or "")
|
|
677
|
+
if status in ACCEPTED_STATUSES:
|
|
678
|
+
uploaded += 1
|
|
679
|
+
continue
|
|
680
|
+
if status in QUARANTINE_STATUSES:
|
|
681
|
+
append_quarantine(
|
|
682
|
+
change_dir,
|
|
683
|
+
event_id=event_id,
|
|
684
|
+
line_number=line_by_event_id[event_id],
|
|
685
|
+
error_code=str((item or {}).get("error_code") or status.upper()),
|
|
686
|
+
status=status,
|
|
687
|
+
)
|
|
688
|
+
quarantined += 1
|
|
689
|
+
continue
|
|
690
|
+
return {
|
|
691
|
+
"ok": False,
|
|
692
|
+
"error": f"plan event {event_id} was not durably acknowledged",
|
|
693
|
+
"uploaded": uploaded,
|
|
694
|
+
"quarantined": quarantined,
|
|
695
|
+
"server": result,
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
for event_id, line_number, error_code in local_invalid:
|
|
699
|
+
append_quarantine(
|
|
700
|
+
change_dir,
|
|
701
|
+
event_id=event_id,
|
|
702
|
+
line_number=line_number,
|
|
703
|
+
error_code=error_code,
|
|
704
|
+
status="local_invalid",
|
|
705
|
+
)
|
|
706
|
+
quarantined += 1
|
|
707
|
+
_save_plan_cursor(change_dir, acked=acked + chunk_start + len(raw_chunk), base=base)
|
|
708
|
+
|
|
709
|
+
return {
|
|
710
|
+
"ok": True,
|
|
711
|
+
"uploaded": uploaded,
|
|
712
|
+
"quarantined": quarantined,
|
|
713
|
+
"acked_lines": len(lines),
|
|
714
|
+
"producer_seq_base": base,
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def post_json(endpoint: dict[str, str], path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
719
|
+
url = endpoint["server_url"] + path
|
|
720
|
+
data = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
721
|
+
request = urllib.request.Request(
|
|
722
|
+
url,
|
|
723
|
+
data=data,
|
|
724
|
+
method="POST",
|
|
725
|
+
headers={
|
|
726
|
+
"Accept": "application/json",
|
|
727
|
+
"Authorization": f"Bearer {endpoint['token']}",
|
|
728
|
+
"Content-Type": "application/json",
|
|
729
|
+
},
|
|
730
|
+
)
|
|
731
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
732
|
+
return json.loads(response.read().decode("utf-8"))
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _event_lines_snapshot(events_file: Path) -> list[str]:
|
|
736
|
+
"""Read a complete-line snapshot under the writer's events.ndjson lock."""
|
|
737
|
+
lock = _CrossProcessLock(events_file.with_name(events_file.name + ".lock"))
|
|
738
|
+
lock.acquire(blocking=True)
|
|
739
|
+
try:
|
|
740
|
+
data = events_file.read_bytes()
|
|
741
|
+
finally:
|
|
742
|
+
lock.release()
|
|
743
|
+
# A non-cooperating/external writer may still leave a partial tail. Never
|
|
744
|
+
# quarantine or acknowledge that tail; the next nudge will retry it once a
|
|
745
|
+
# terminating newline makes the physical record durable.
|
|
746
|
+
if data and not data.endswith(b"\n"):
|
|
747
|
+
last_newline = data.rfind(b"\n")
|
|
748
|
+
data = b"" if last_newline < 0 else data[:last_newline + 1]
|
|
749
|
+
return data.decode("utf-8-sig").splitlines()
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _complete_stream_bytes(path: Path) -> bytes:
|
|
753
|
+
data = path.read_bytes() if path.is_file() else b""
|
|
754
|
+
if data and not data.endswith(b"\n"):
|
|
755
|
+
last_newline = data.rfind(b"\n")
|
|
756
|
+
data = b"" if last_newline < 0 else data[:last_newline + 1]
|
|
757
|
+
return data
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _atomic_replace_bytes(path: Path, data: bytes) -> None:
|
|
761
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
762
|
+
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
763
|
+
descriptor = os.open(
|
|
764
|
+
str(temporary), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600
|
|
765
|
+
)
|
|
766
|
+
try:
|
|
767
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
768
|
+
descriptor = -1
|
|
769
|
+
stream.write(data)
|
|
770
|
+
stream.flush()
|
|
771
|
+
os.fsync(stream.fileno())
|
|
772
|
+
os.replace(temporary, path)
|
|
773
|
+
_fsync_parent(path)
|
|
774
|
+
finally:
|
|
775
|
+
if descriptor >= 0:
|
|
776
|
+
os.close(descriptor)
|
|
777
|
+
try:
|
|
778
|
+
temporary.unlink()
|
|
779
|
+
except FileNotFoundError:
|
|
780
|
+
pass
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _migrate_pending_legacy_stream(change_dir: Path) -> None:
|
|
784
|
+
"""Prefix unsent colocated events before the first split-v1 state upload."""
|
|
785
|
+
contract_dir = change_dir.resolve()
|
|
786
|
+
state_dir = _state_dir(change_dir).resolve()
|
|
787
|
+
legacy_events = contract_dir / "events.ndjson"
|
|
788
|
+
target_events = state_dir / "events.ndjson"
|
|
789
|
+
target_cursor = state_dir / "meta" / "events-sync-cursor.json"
|
|
790
|
+
if state_dir == contract_dir or not legacy_events.is_file() or target_cursor.exists():
|
|
791
|
+
return
|
|
792
|
+
legacy_cursor = contract_dir / "meta" / "events-sync-cursor.json"
|
|
793
|
+
legacy_acked, _, _ = _cursor_values(legacy_cursor)
|
|
794
|
+
legacy_line_count = _complete_line_count(legacy_events)
|
|
795
|
+
if legacy_acked >= legacy_line_count:
|
|
796
|
+
return
|
|
797
|
+
|
|
798
|
+
locks = [
|
|
799
|
+
_CrossProcessLock(path.with_name(path.name + ".lock"))
|
|
800
|
+
for path in sorted((legacy_events, target_events), key=lambda item: str(item).casefold())
|
|
801
|
+
]
|
|
802
|
+
for lock in locks:
|
|
803
|
+
lock.acquire(blocking=True)
|
|
804
|
+
try:
|
|
805
|
+
if target_cursor.exists() or not legacy_events.is_file():
|
|
806
|
+
return
|
|
807
|
+
legacy_data = _complete_stream_bytes(legacy_events)
|
|
808
|
+
target_data = _complete_stream_bytes(target_events)
|
|
809
|
+
if target_data.startswith(legacy_data):
|
|
810
|
+
merged = target_data
|
|
811
|
+
else:
|
|
812
|
+
merged = legacy_data + target_data
|
|
813
|
+
_atomic_replace_bytes(target_events, merged)
|
|
814
|
+
|
|
815
|
+
target_cursor.parent.mkdir(parents=True, exist_ok=True)
|
|
816
|
+
if legacy_cursor.is_file():
|
|
817
|
+
os.replace(legacy_cursor, target_cursor)
|
|
818
|
+
else:
|
|
819
|
+
save_cursor(change_dir, 0, producer_seq_base=0)
|
|
820
|
+
|
|
821
|
+
legacy_quarantine = contract_dir / "meta" / "events-sync-quarantine.ndjson"
|
|
822
|
+
target_quarantine = state_dir / "meta" / "events-sync-quarantine.ndjson"
|
|
823
|
+
if legacy_quarantine.is_file() and not target_quarantine.exists():
|
|
824
|
+
os.replace(legacy_quarantine, target_quarantine)
|
|
825
|
+
legacy_events.unlink()
|
|
826
|
+
finally:
|
|
827
|
+
for lock in reversed(locks):
|
|
828
|
+
lock.release()
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def sync_change(
|
|
832
|
+
project: Path,
|
|
833
|
+
change_dir: Path,
|
|
834
|
+
*,
|
|
835
|
+
heartbeat_only: bool = False,
|
|
836
|
+
run_id: str | None = None,
|
|
837
|
+
change_key: str | None = None,
|
|
838
|
+
) -> dict[str, Any]:
|
|
839
|
+
endpoint = load_endpoint(project)
|
|
840
|
+
if endpoint is None:
|
|
841
|
+
return {"ok": True, "skipped": True, "reason": "remote credentials not configured"}
|
|
842
|
+
resolved_key = change_key or change_dir.name
|
|
843
|
+
rid = run_id or run_id_for(change_dir)
|
|
844
|
+
project_id = endpoint["project_id"]
|
|
845
|
+
display_title = load_display_title(change_dir, resolved_key)
|
|
846
|
+
|
|
847
|
+
# Always heartbeat so the console can separate connection vs run status.
|
|
848
|
+
try:
|
|
849
|
+
client_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
850
|
+
post_json(
|
|
851
|
+
endpoint,
|
|
852
|
+
f"/api/v1/projects/{project_id}/runs/heartbeats",
|
|
853
|
+
{
|
|
854
|
+
"protocol_version": "hunter-progress-sync/v1",
|
|
855
|
+
"run_id": rid,
|
|
856
|
+
"change_key": resolved_key,
|
|
857
|
+
"client_time": client_time,
|
|
858
|
+
"title": display_title,
|
|
859
|
+
},
|
|
860
|
+
)
|
|
861
|
+
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
|
862
|
+
return {"ok": False, "error": f"heartbeat failed: {exc}", "run_id": rid}
|
|
863
|
+
|
|
864
|
+
if heartbeat_only:
|
|
865
|
+
return {"ok": True, "heartbeat": True, "run_id": rid, "change_key": resolved_key}
|
|
866
|
+
|
|
867
|
+
_migrate_pending_legacy_stream(change_dir)
|
|
868
|
+
events_file = _state_dir(change_dir) / "events.ndjson"
|
|
869
|
+
if not events_file.is_file():
|
|
870
|
+
plan_result = _sync_plan_events_stream(
|
|
871
|
+
change_dir,
|
|
872
|
+
endpoint=endpoint,
|
|
873
|
+
rid=rid,
|
|
874
|
+
resolved_key=resolved_key,
|
|
875
|
+
display_title=display_title,
|
|
876
|
+
main_stream_total_lines=0,
|
|
877
|
+
)
|
|
878
|
+
result: dict[str, Any] = {
|
|
879
|
+
"ok": bool(plan_result.get("ok")),
|
|
880
|
+
"run_id": rid,
|
|
881
|
+
"change_key": resolved_key,
|
|
882
|
+
"uploaded": int(plan_result.get("uploaded") or 0),
|
|
883
|
+
"quarantined": int(plan_result.get("quarantined") or 0),
|
|
884
|
+
"reason": "no events.ndjson",
|
|
885
|
+
"plan_events": {key: value for key, value in plan_result.items() if key != "ok"},
|
|
886
|
+
}
|
|
887
|
+
if not plan_result.get("ok"):
|
|
888
|
+
result["error"] = plan_result.get("error")
|
|
889
|
+
return result
|
|
890
|
+
|
|
891
|
+
lines = _event_lines_snapshot(events_file)
|
|
892
|
+
cursor = load_cursor_state(
|
|
893
|
+
change_dir,
|
|
894
|
+
maximum_lines=len(lines),
|
|
895
|
+
repair=True,
|
|
896
|
+
)
|
|
897
|
+
acked = cursor["acked_lines"]
|
|
898
|
+
producer_seq_base = cursor["producer_seq_base"]
|
|
899
|
+
pending_raw = lines[acked:]
|
|
900
|
+
phase_plan = _workflow_plan(change_dir)
|
|
901
|
+
uploaded = 0
|
|
902
|
+
quarantined = 0
|
|
903
|
+
last_result: dict[str, Any] | None = None
|
|
904
|
+
|
|
905
|
+
# Chunk physical lines, not only valid events, so a malformed line can never
|
|
906
|
+
# strand all later valid events behind the durable cursor.
|
|
907
|
+
for chunk_start in range(0, len(pending_raw), MAX_BATCH_SIZE):
|
|
908
|
+
raw_chunk = pending_raw[chunk_start:chunk_start + MAX_BATCH_SIZE]
|
|
909
|
+
batch: list[dict[str, Any]] = []
|
|
910
|
+
line_by_event_id: dict[str, int] = {}
|
|
911
|
+
local_invalid: list[tuple[str, int, str]] = []
|
|
912
|
+
for offset, raw_line in enumerate(raw_chunk, start=1):
|
|
913
|
+
line_number = acked + chunk_start + offset
|
|
914
|
+
line = raw_line.strip()
|
|
915
|
+
if not line:
|
|
916
|
+
local_invalid.append((f"{resolved_key}:{line_number}", line_number, "EMPTY_LINE"))
|
|
917
|
+
continue
|
|
918
|
+
try:
|
|
919
|
+
event = json.loads(line)
|
|
920
|
+
except json.JSONDecodeError:
|
|
921
|
+
local_invalid.append((f"{resolved_key}:{line_number}", line_number, "INVALID_JSON"))
|
|
922
|
+
continue
|
|
923
|
+
if not isinstance(event, dict):
|
|
924
|
+
local_invalid.append((f"{resolved_key}:{line_number}", line_number, "INVALID_EVENT"))
|
|
925
|
+
continue
|
|
926
|
+
event_id = str(event.get("id") or f"{resolved_key}:{line_number}")
|
|
927
|
+
item: dict[str, Any] = {
|
|
928
|
+
"event_id": event_id,
|
|
929
|
+
"producer_seq": producer_seq_base + line_number,
|
|
930
|
+
"event_type": str(event.get("type") or "unknown"),
|
|
931
|
+
"occurred_at": str(event.get("timestamp") or event.get("ts") or client_time),
|
|
932
|
+
"payload": _event_payload(event, phase_plan),
|
|
933
|
+
}
|
|
934
|
+
if event.get("phase") is not None:
|
|
935
|
+
item["phase"] = event.get("phase")
|
|
936
|
+
batch.append(item)
|
|
937
|
+
line_by_event_id[event_id] = line_number
|
|
938
|
+
|
|
939
|
+
if batch:
|
|
940
|
+
try:
|
|
941
|
+
result = post_json(
|
|
942
|
+
endpoint,
|
|
943
|
+
f"/api/v1/projects/{project_id}/runs/events:batch",
|
|
944
|
+
{
|
|
945
|
+
"protocol_version": "hunter-progress-sync/v1",
|
|
946
|
+
"run_id": rid,
|
|
947
|
+
"change_key": resolved_key,
|
|
948
|
+
"title": display_title,
|
|
949
|
+
"events": batch,
|
|
950
|
+
},
|
|
951
|
+
)
|
|
952
|
+
except urllib.error.HTTPError as exc:
|
|
953
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
954
|
+
return {
|
|
955
|
+
"ok": False,
|
|
956
|
+
"error": f"batch HTTP {exc.code}: {detail}",
|
|
957
|
+
"run_id": rid,
|
|
958
|
+
"uploaded": uploaded,
|
|
959
|
+
"quarantined": quarantined,
|
|
960
|
+
"acked_lines": load_cursor(change_dir),
|
|
961
|
+
}
|
|
962
|
+
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
|
963
|
+
return {
|
|
964
|
+
"ok": False,
|
|
965
|
+
"error": f"batch failed: {exc}",
|
|
966
|
+
"run_id": rid,
|
|
967
|
+
"uploaded": uploaded,
|
|
968
|
+
"quarantined": quarantined,
|
|
969
|
+
"acked_lines": load_cursor(change_dir),
|
|
970
|
+
}
|
|
971
|
+
last_result = result
|
|
972
|
+
items = result.get("items") if isinstance(result, dict) else None
|
|
973
|
+
if not isinstance(items, list) or len(items) != len(batch):
|
|
974
|
+
return {
|
|
975
|
+
"ok": False,
|
|
976
|
+
"error": "batch response did not acknowledge every event",
|
|
977
|
+
"run_id": rid,
|
|
978
|
+
"uploaded": uploaded,
|
|
979
|
+
"quarantined": quarantined,
|
|
980
|
+
"acked_lines": load_cursor(change_dir),
|
|
981
|
+
}
|
|
982
|
+
response_by_id = {
|
|
983
|
+
str(item.get("id") or item.get("event_id") or ""): item
|
|
984
|
+
for item in items if isinstance(item, dict)
|
|
985
|
+
}
|
|
986
|
+
for event in batch:
|
|
987
|
+
event_id = str(event["event_id"])
|
|
988
|
+
item = response_by_id.get(event_id)
|
|
989
|
+
status = str((item or {}).get("status") or "")
|
|
990
|
+
if status in ACCEPTED_STATUSES:
|
|
991
|
+
uploaded += 1
|
|
992
|
+
continue
|
|
993
|
+
if status in QUARANTINE_STATUSES:
|
|
994
|
+
append_quarantine(
|
|
995
|
+
change_dir,
|
|
996
|
+
event_id=event_id,
|
|
997
|
+
line_number=line_by_event_id[event_id],
|
|
998
|
+
error_code=str((item or {}).get("error_code") or status.upper()),
|
|
999
|
+
status=status,
|
|
1000
|
+
)
|
|
1001
|
+
quarantined += 1
|
|
1002
|
+
continue
|
|
1003
|
+
return {
|
|
1004
|
+
"ok": False,
|
|
1005
|
+
"error": f"event {event_id} was not durably acknowledged",
|
|
1006
|
+
"run_id": rid,
|
|
1007
|
+
"uploaded": uploaded,
|
|
1008
|
+
"quarantined": quarantined,
|
|
1009
|
+
"acked_lines": load_cursor(change_dir),
|
|
1010
|
+
"server": result,
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
for event_id, line_number, error_code in local_invalid:
|
|
1014
|
+
append_quarantine(
|
|
1015
|
+
change_dir,
|
|
1016
|
+
event_id=event_id,
|
|
1017
|
+
line_number=line_number,
|
|
1018
|
+
error_code=error_code,
|
|
1019
|
+
status="local_invalid",
|
|
1020
|
+
)
|
|
1021
|
+
quarantined += 1
|
|
1022
|
+
save_cursor(
|
|
1023
|
+
change_dir,
|
|
1024
|
+
acked + chunk_start + len(raw_chunk),
|
|
1025
|
+
producer_seq_base=producer_seq_base,
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
main_result: dict[str, Any] = {
|
|
1029
|
+
"ok": True,
|
|
1030
|
+
"run_id": rid,
|
|
1031
|
+
"change_key": resolved_key,
|
|
1032
|
+
"uploaded": uploaded,
|
|
1033
|
+
"quarantined": quarantined,
|
|
1034
|
+
"batch_size": min(MAX_BATCH_SIZE, len(pending_raw)),
|
|
1035
|
+
"acked_lines": len(lines),
|
|
1036
|
+
"producer_seq_base": producer_seq_base,
|
|
1037
|
+
"server": last_result,
|
|
1038
|
+
}
|
|
1039
|
+
plan_result = _sync_plan_events_stream(
|
|
1040
|
+
change_dir,
|
|
1041
|
+
endpoint=endpoint,
|
|
1042
|
+
rid=rid,
|
|
1043
|
+
resolved_key=resolved_key,
|
|
1044
|
+
display_title=display_title,
|
|
1045
|
+
main_stream_total_lines=len(lines),
|
|
1046
|
+
)
|
|
1047
|
+
main_result["uploaded"] += int(plan_result.get("uploaded") or 0)
|
|
1048
|
+
main_result["quarantined"] += int(plan_result.get("quarantined") or 0)
|
|
1049
|
+
main_result["plan_events"] = {key: value for key, value in plan_result.items() if key != "ok"}
|
|
1050
|
+
if not plan_result.get("ok"):
|
|
1051
|
+
main_result["ok"] = False
|
|
1052
|
+
main_result["error"] = plan_result.get("error")
|
|
1053
|
+
return main_result
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def auto_events_sync(
|
|
1057
|
+
project_root: Path,
|
|
1058
|
+
change_dir: Path,
|
|
1059
|
+
*,
|
|
1060
|
+
heartbeat_only: bool = False,
|
|
1061
|
+
run_id: str | None = None,
|
|
1062
|
+
change_key: str | None = None,
|
|
1063
|
+
) -> dict[str, Any]:
|
|
1064
|
+
"""Best-effort platform events sync (same style as auto_push_archive_core).
|
|
1065
|
+
|
|
1066
|
+
Failures become warnings so the caller never rolls back the main workflow.
|
|
1067
|
+
"""
|
|
1068
|
+
project_root = project_root.resolve()
|
|
1069
|
+
change_dir = change_dir.resolve()
|
|
1070
|
+
if load_endpoint(project_root) is None:
|
|
1071
|
+
return {"skipped": True, "reason": "no remote credentials"}
|
|
1072
|
+
try:
|
|
1073
|
+
result = sync_change(
|
|
1074
|
+
project_root,
|
|
1075
|
+
change_dir,
|
|
1076
|
+
heartbeat_only=heartbeat_only,
|
|
1077
|
+
run_id=run_id,
|
|
1078
|
+
change_key=change_key,
|
|
1079
|
+
)
|
|
1080
|
+
except Exception as exc: # noqa: BLE001 — best-effort never blocks caller
|
|
1081
|
+
return {
|
|
1082
|
+
"skipped": False,
|
|
1083
|
+
"ok": False,
|
|
1084
|
+
"warning": f"events-sync deferred: {exc}",
|
|
1085
|
+
}
|
|
1086
|
+
if result.get("skipped"):
|
|
1087
|
+
return {"skipped": True, "reason": result.get("reason") or "skipped"}
|
|
1088
|
+
if result.get("ok"):
|
|
1089
|
+
return {"skipped": False, "ok": True, **{
|
|
1090
|
+
key: result[key]
|
|
1091
|
+
for key in ("run_id", "change_key", "uploaded", "acked_lines", "heartbeat")
|
|
1092
|
+
if key in result
|
|
1093
|
+
}}
|
|
1094
|
+
return {
|
|
1095
|
+
"skipped": False,
|
|
1096
|
+
"ok": False,
|
|
1097
|
+
"warning": str(result.get("error") or "events-sync failed"),
|
|
1098
|
+
**{
|
|
1099
|
+
key: result[key]
|
|
1100
|
+
for key in ("run_id", "change_key")
|
|
1101
|
+
if key in result
|
|
1102
|
+
},
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def _nudge_path(project_root: Path) -> Path:
|
|
1107
|
+
return project_root / ".harness" / "state" / "local" / "events-sync" / "nudge.json"
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _agent_lock_path(project_root: Path) -> Path:
|
|
1111
|
+
return project_root / ".harness" / "state" / "local" / "events-sync" / "agent.lock"
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _agent_waiter_lock_path(project_root: Path) -> Path:
|
|
1115
|
+
return project_root / ".harness" / "state" / "local" / "events-sync" / "agent-waiter.lock"
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
class _CrossProcessLock:
|
|
1119
|
+
"""Cross-platform advisory file lock held by an open descriptor.
|
|
1120
|
+
|
|
1121
|
+
Lock files are deliberately persistent: ownership belongs to the kernel lock,
|
|
1122
|
+
not mtime or file existence, so a slow live worker can never be stolen.
|
|
1123
|
+
"""
|
|
1124
|
+
|
|
1125
|
+
def __init__(self, path: Path) -> None:
|
|
1126
|
+
self.path = path
|
|
1127
|
+
self._stream: Any | None = None
|
|
1128
|
+
|
|
1129
|
+
def acquire(self, *, blocking: bool) -> bool:
|
|
1130
|
+
if self._stream is not None:
|
|
1131
|
+
return True
|
|
1132
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
1133
|
+
stream = self.path.open("a+b", buffering=0)
|
|
1134
|
+
if stream.seek(0, os.SEEK_END) == 0:
|
|
1135
|
+
stream.write(b"\0")
|
|
1136
|
+
stream.flush()
|
|
1137
|
+
os.fsync(stream.fileno())
|
|
1138
|
+
while True:
|
|
1139
|
+
stream.seek(0)
|
|
1140
|
+
try:
|
|
1141
|
+
if os.name == "nt":
|
|
1142
|
+
import msvcrt
|
|
1143
|
+
|
|
1144
|
+
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
|
|
1145
|
+
else:
|
|
1146
|
+
import fcntl
|
|
1147
|
+
|
|
1148
|
+
fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
1149
|
+
self._stream = stream
|
|
1150
|
+
return True
|
|
1151
|
+
except OSError as exc:
|
|
1152
|
+
if exc.errno not in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}:
|
|
1153
|
+
stream.close()
|
|
1154
|
+
raise
|
|
1155
|
+
if not blocking:
|
|
1156
|
+
stream.close()
|
|
1157
|
+
return False
|
|
1158
|
+
time.sleep(0.05)
|
|
1159
|
+
|
|
1160
|
+
def release(self) -> None:
|
|
1161
|
+
stream = self._stream
|
|
1162
|
+
if stream is None:
|
|
1163
|
+
return
|
|
1164
|
+
try:
|
|
1165
|
+
stream.seek(0)
|
|
1166
|
+
if os.name == "nt":
|
|
1167
|
+
import msvcrt
|
|
1168
|
+
|
|
1169
|
+
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
|
|
1170
|
+
else:
|
|
1171
|
+
import fcntl
|
|
1172
|
+
|
|
1173
|
+
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
|
|
1174
|
+
finally:
|
|
1175
|
+
stream.close()
|
|
1176
|
+
self._stream = None
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _windowless_python_executable(
|
|
1180
|
+
executable: str, base_executable: str | None
|
|
1181
|
+
) -> str:
|
|
1182
|
+
"""Return the real Windows interpreter, bypassing venv forwarding launchers.
|
|
1183
|
+
|
|
1184
|
+
Virtual-environment ``python.exe`` and ``pythonw.exe`` files may both be
|
|
1185
|
+
forwarding launchers. The forwarding hop can start the real console
|
|
1186
|
+
interpreter without preserving ``CREATE_NO_WINDOW`` and make Windows
|
|
1187
|
+
Terminal appear. Prefer the base runtime directly; its ``pythonw.exe`` is
|
|
1188
|
+
ideal, while its console executable remains windowless under our explicit
|
|
1189
|
+
process creation flags.
|
|
1190
|
+
"""
|
|
1191
|
+
executable_path = Path(executable)
|
|
1192
|
+
if base_executable:
|
|
1193
|
+
base_path = Path(base_executable)
|
|
1194
|
+
base_windowless = base_path.with_name("pythonw.exe")
|
|
1195
|
+
if base_windowless.is_file():
|
|
1196
|
+
return str(base_windowless)
|
|
1197
|
+
if base_path.is_file():
|
|
1198
|
+
return str(base_path)
|
|
1199
|
+
windowless = executable_path.with_name("pythonw.exe")
|
|
1200
|
+
if windowless.is_file():
|
|
1201
|
+
return str(windowless)
|
|
1202
|
+
return str(executable_path)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def schedule_events_sync(project_root: Path, change_dir: Path) -> bool:
|
|
1206
|
+
"""Wake a finite, project-local sync worker after an event is durable.
|
|
1207
|
+
|
|
1208
|
+
The wake-up record contains no credential or event payload. Multiple spawns are
|
|
1209
|
+
harmless: a project-local lock lets only one worker perform network requests.
|
|
1210
|
+
"""
|
|
1211
|
+
project_root = project_root.resolve()
|
|
1212
|
+
change_dir = change_dir.resolve()
|
|
1213
|
+
if load_endpoint(project_root) is None:
|
|
1214
|
+
return False
|
|
1215
|
+
nudge = _nudge_path(project_root)
|
|
1216
|
+
nudge.parent.mkdir(parents=True, exist_ok=True)
|
|
1217
|
+
payload = {
|
|
1218
|
+
"schema_version": 1,
|
|
1219
|
+
"generation": uuid.uuid4().hex,
|
|
1220
|
+
"change": change_dir.name,
|
|
1221
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
1222
|
+
}
|
|
1223
|
+
temporary = nudge.with_name(f".{nudge.name}.{uuid.uuid4().hex}.tmp")
|
|
1224
|
+
temporary.write_text(json.dumps(payload, separators=(",", ":")) + "\n", encoding="utf-8")
|
|
1225
|
+
os.replace(temporary, nudge)
|
|
1226
|
+
interpreter = sys.executable
|
|
1227
|
+
if os.name == "nt":
|
|
1228
|
+
interpreter = _windowless_python_executable(
|
|
1229
|
+
sys.executable,
|
|
1230
|
+
getattr(sys, "_base_executable", None),
|
|
1231
|
+
)
|
|
1232
|
+
command = [
|
|
1233
|
+
interpreter,
|
|
1234
|
+
str(Path(__file__).resolve()),
|
|
1235
|
+
"--project",
|
|
1236
|
+
str(project_root),
|
|
1237
|
+
"--agent",
|
|
1238
|
+
"--json",
|
|
1239
|
+
]
|
|
1240
|
+
kwargs: dict[str, Any] = {
|
|
1241
|
+
"stdin": subprocess.DEVNULL,
|
|
1242
|
+
"stdout": subprocess.DEVNULL,
|
|
1243
|
+
"stderr": subprocess.DEVNULL,
|
|
1244
|
+
"close_fds": True,
|
|
1245
|
+
}
|
|
1246
|
+
if os.name == "nt":
|
|
1247
|
+
kwargs["creationflags"] = (
|
|
1248
|
+
getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
1249
|
+
| getattr(subprocess, "DETACHED_PROCESS", 0)
|
|
1250
|
+
)
|
|
1251
|
+
else:
|
|
1252
|
+
kwargs["start_new_session"] = True
|
|
1253
|
+
subprocess.Popen(command, **kwargs) # noqa: S603 — fixed local interpreter/script
|
|
1254
|
+
return True
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def _read_nudge_generation(project_root: Path) -> str:
|
|
1258
|
+
try:
|
|
1259
|
+
value = json.loads(_nudge_path(project_root).read_text(encoding="utf-8"))
|
|
1260
|
+
return str(value.get("generation") or "") if isinstance(value, dict) else ""
|
|
1261
|
+
except (OSError, json.JSONDecodeError):
|
|
1262
|
+
return ""
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def _discover_changes(project_root: Path) -> list[Path]:
|
|
1266
|
+
root = project_root / ".harness" / "changes"
|
|
1267
|
+
return sorted(path for path in root.glob("*") if path.is_dir()) if root.is_dir() else []
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def _change_has_open_phase(change_dir: Path) -> bool:
|
|
1271
|
+
"""Project the append-only stream into open/closed phase state."""
|
|
1272
|
+
try:
|
|
1273
|
+
path = Path(harness_paths.resolve_state_dir_for_contract(change_dir)) / "events.ndjson"
|
|
1274
|
+
except (OSError, ValueError):
|
|
1275
|
+
return False
|
|
1276
|
+
if not path.is_file():
|
|
1277
|
+
return False
|
|
1278
|
+
open_phases: dict[str, bool] = {}
|
|
1279
|
+
try:
|
|
1280
|
+
for line in path.read_text(encoding="utf-8-sig").splitlines():
|
|
1281
|
+
if not line.strip():
|
|
1282
|
+
continue
|
|
1283
|
+
event = json.loads(line)
|
|
1284
|
+
if not isinstance(event, dict):
|
|
1285
|
+
continue
|
|
1286
|
+
phase = event.get("phase")
|
|
1287
|
+
event_type = event.get("type")
|
|
1288
|
+
if not isinstance(phase, str) or not phase:
|
|
1289
|
+
continue
|
|
1290
|
+
if event_type == "phase.start":
|
|
1291
|
+
open_phases[phase] = True
|
|
1292
|
+
elif event_type in {"phase.end", "phase.auto_sealed", "workflow.end", "run.end"}:
|
|
1293
|
+
open_phases[phase] = False
|
|
1294
|
+
except (OSError, json.JSONDecodeError):
|
|
1295
|
+
return False
|
|
1296
|
+
return any(open_phases.values())
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
def _project_has_open_phase(project_root: Path) -> bool:
|
|
1300
|
+
return any(_change_has_open_phase(path) for path in _discover_changes(project_root))
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def _run_agent_session(project_root: Path) -> dict[str, Any]:
|
|
1304
|
+
attempts = 0
|
|
1305
|
+
uploaded = 0
|
|
1306
|
+
quarantined = 0
|
|
1307
|
+
while attempts < 6:
|
|
1308
|
+
generation = _read_nudge_generation(project_root)
|
|
1309
|
+
results = [sync_change(project_root, path) for path in _discover_changes(project_root)]
|
|
1310
|
+
uploaded += sum(int(item.get("uploaded") or 0) for item in results)
|
|
1311
|
+
quarantined += sum(int(item.get("quarantined") or 0) for item in results)
|
|
1312
|
+
if all(item.get("ok") for item in results):
|
|
1313
|
+
time.sleep(0.6)
|
|
1314
|
+
if generation == _read_nudge_generation(project_root):
|
|
1315
|
+
if not _project_has_open_phase(project_root):
|
|
1316
|
+
return {
|
|
1317
|
+
"ok": True,
|
|
1318
|
+
"uploaded": uploaded,
|
|
1319
|
+
"quarantined": quarantined,
|
|
1320
|
+
"attempts": attempts + 1,
|
|
1321
|
+
}
|
|
1322
|
+
# Keep a finite workflow lease alive while a phase is open.
|
|
1323
|
+
# A phase.end nudge is observed on the next pass and releases
|
|
1324
|
+
# the worker naturally; no terminal window is created.
|
|
1325
|
+
time.sleep(10.0)
|
|
1326
|
+
heartbeat_results = [
|
|
1327
|
+
sync_change(project_root, path, heartbeat_only=True)
|
|
1328
|
+
for path in _discover_changes(project_root)
|
|
1329
|
+
]
|
|
1330
|
+
if all(item.get("ok") for item in heartbeat_results):
|
|
1331
|
+
attempts = 0
|
|
1332
|
+
continue
|
|
1333
|
+
results = heartbeat_results
|
|
1334
|
+
attempts = 0
|
|
1335
|
+
if generation != _read_nudge_generation(project_root):
|
|
1336
|
+
continue
|
|
1337
|
+
time.sleep(min(16.0, float(2 ** attempts)))
|
|
1338
|
+
attempts += 1
|
|
1339
|
+
return {
|
|
1340
|
+
"ok": False,
|
|
1341
|
+
"uploaded": uploaded,
|
|
1342
|
+
"quarantined": quarantined,
|
|
1343
|
+
"attempts": attempts,
|
|
1344
|
+
"error": "remote sync remains unavailable after bounded retries",
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def run_agent(project_root: Path) -> dict[str, Any]:
|
|
1349
|
+
"""Run a bounded retry session with a lossless one-worker handoff."""
|
|
1350
|
+
primary = _CrossProcessLock(_agent_lock_path(project_root))
|
|
1351
|
+
waiter: _CrossProcessLock | None = None
|
|
1352
|
+
if not primary.acquire(blocking=False):
|
|
1353
|
+
# At most one follower waits at the release boundary. Any later nudge is
|
|
1354
|
+
# represented by the same atomic generation record and that follower sees it.
|
|
1355
|
+
waiter = _CrossProcessLock(_agent_waiter_lock_path(project_root))
|
|
1356
|
+
if not waiter.acquire(blocking=False):
|
|
1357
|
+
return {"ok": True, "already_running": True}
|
|
1358
|
+
try:
|
|
1359
|
+
primary.acquire(blocking=True)
|
|
1360
|
+
finally:
|
|
1361
|
+
waiter.release()
|
|
1362
|
+
try:
|
|
1363
|
+
return _run_agent_session(project_root)
|
|
1364
|
+
finally:
|
|
1365
|
+
primary.release()
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1369
|
+
parser = argparse.ArgumentParser(description="Sync events.ndjson to platform Run monitoring")
|
|
1370
|
+
parser.add_argument("--project", default=".", help="Project root")
|
|
1371
|
+
parser.add_argument("--change-dir", help="Specific change directory")
|
|
1372
|
+
parser.add_argument("--run-id", help="Override stable run_id (archive finalize continuity)")
|
|
1373
|
+
parser.add_argument("--change-key", help="Override change_key sent to the platform")
|
|
1374
|
+
parser.add_argument("--heartbeat-only", action="store_true")
|
|
1375
|
+
parser.add_argument("--agent", action="store_true", help=argparse.SUPPRESS)
|
|
1376
|
+
parser.add_argument("--json", action="store_true")
|
|
1377
|
+
args = parser.parse_args(argv)
|
|
1378
|
+
project = Path(args.project).resolve()
|
|
1379
|
+
if args.agent:
|
|
1380
|
+
result = run_agent(project)
|
|
1381
|
+
payload = {
|
|
1382
|
+
"schema_version": 1,
|
|
1383
|
+
"ok": bool(result.get("ok")),
|
|
1384
|
+
"command": "events.sync.agent",
|
|
1385
|
+
"exit_code": 0 if result.get("ok") else 1,
|
|
1386
|
+
"result": result,
|
|
1387
|
+
}
|
|
1388
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
1389
|
+
return int(payload["exit_code"])
|
|
1390
|
+
if args.change_dir:
|
|
1391
|
+
changes = [Path(args.change_dir).resolve()]
|
|
1392
|
+
else:
|
|
1393
|
+
root = project / ".harness" / "changes"
|
|
1394
|
+
changes = sorted(path for path in root.glob("*") if path.is_dir()) if root.is_dir() else []
|
|
1395
|
+
results = [
|
|
1396
|
+
sync_change(
|
|
1397
|
+
project,
|
|
1398
|
+
change_dir,
|
|
1399
|
+
heartbeat_only=args.heartbeat_only,
|
|
1400
|
+
run_id=args.run_id,
|
|
1401
|
+
change_key=args.change_key,
|
|
1402
|
+
)
|
|
1403
|
+
for change_dir in changes
|
|
1404
|
+
]
|
|
1405
|
+
ok = all(item.get("ok") for item in results) if results else True
|
|
1406
|
+
# P5 headless envelope — stable for orchestrators.
|
|
1407
|
+
payload = {
|
|
1408
|
+
"schema_version": 1,
|
|
1409
|
+
"ok": ok,
|
|
1410
|
+
"command": "events.sync",
|
|
1411
|
+
"change": None,
|
|
1412
|
+
"phase": None,
|
|
1413
|
+
"exit_code": 0 if ok else 1,
|
|
1414
|
+
"warnings": [],
|
|
1415
|
+
"errors": [] if ok else [{"message": "one or more change syncs failed"}],
|
|
1416
|
+
"result": {"results": results},
|
|
1417
|
+
# Backward-compatible aliases used by early callers / tests.
|
|
1418
|
+
"results": results,
|
|
1419
|
+
}
|
|
1420
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
1421
|
+
return 0 if ok else 1
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
if __name__ == "__main__":
|
|
1425
|
+
raise SystemExit(main())
|