ai-delivery-workflow 0.2.2 → 0.4.0
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/README.md +45 -14
- package/bin/ai-delivery.mjs +181 -6
- package/bin/benchmark-autonomy.mjs +217 -0
- package/bin/release-check.mjs +382 -0
- package/docs/CLI-PARAMETER-REFERENCE.zh-CN.md +346 -0
- package/docs/CODEGRAPH-INTEGRATION.zh-CN.md +89 -0
- package/docs/DUAL-REPOSITORY-WORKSPACE.zh-CN.md +2 -2
- package/docs/EVOLUTION-MAINTENANCE.zh-CN.md +7 -7
- package/docs/EVOLUTION-USER-GUIDE.zh-CN.md +2 -2
- package/docs/FILE-REFERENCE.zh-CN.md +120 -39
- package/docs/MAINTENANCE-VERIFICATION.zh-CN.md +51 -0
- package/docs/MOCK-SYSTEM.zh-CN.md +141 -0
- package/docs/NPM-RELEASE-MANAGEMENT.zh-CN.md +106 -0
- package/docs/PROJECT-MANUAL.zh-CN.md +154 -95
- package/docs/PROTOTYPE-MIGRATION.zh-CN.md +164 -0
- package/docs/STATE-CLI-MAINTENANCE.zh-CN.md +35 -17
- package/docs/STATE-CLI-USER-GUIDE.zh-CN.md +115 -39
- package/docs/VIEWER-MAINTENANCE.zh-CN.md +101 -324
- package/docs/VIEWER-USER-GUIDE.zh-CN.md +68 -190
- package/docs/adr/0001-prototype-first-ui-governance.md +3 -0
- package/docs/adr/0002-microcks-as-default-mock-system.md +3 -0
- package/docs/adr/0003-workflow-manager-governed-control-surface.md +46 -0
- package/docs/adr/0004-operational-carbon-visual-standard.md +9 -0
- package/docs/adr/0005-prototype-management-as-routed-pages.md +7 -0
- package/docs/agents/domain.md +12 -0
- package/docs/agents/issue-tracker.md +27 -0
- package/docs/agents/triage-labels.md +11 -0
- package/docs/agents/workflow-manager-acceptance-matrix.md +93 -0
- package/docs/agents/workflow-manager-agent-interface.md +145 -0
- package/docs/agents/workflow-manager-current-state.md +205 -0
- package/lib/artifact-files.mjs +47 -0
- package/lib/codegraph.mjs +405 -0
- package/lib/delivery-state.mjs +3079 -198
- package/lib/frontend-guard.mjs +288 -0
- package/lib/fs-utils.mjs +53 -0
- package/lib/history-anchor.mjs +98 -0
- package/lib/mock-scenario-pack.mjs +687 -0
- package/lib/mock-system.mjs +897 -0
- package/lib/npm-package-result.mjs +11 -0
- package/lib/png-evidence.mjs +149 -0
- package/lib/project-bootstrap.mjs +96 -55
- package/lib/project-installer.mjs +1484 -164
- package/lib/project-migration.mjs +1193 -0
- package/lib/prototype-migration.mjs +713 -0
- package/lib/prototype-page-review.mjs +384 -0
- package/lib/prototype-review-target.mjs +334 -0
- package/lib/prototype-revision.mjs +812 -0
- package/lib/stable-value.mjs +15 -0
- package/lib/toml-hooks.mjs +69 -0
- package/lib/ui-acceptance.mjs +565 -0
- package/lib/visual-standard.mjs +232 -0
- package/lib/workflow-manager-contracts.mjs +367 -0
- package/lib/workflow-manager-facts.mjs +778 -0
- package/lib/workflow-manager-governance.mjs +514 -0
- package/lib/workflow-manager-mock.mjs +183 -0
- package/lib/workflow-manager-prototypes.mjs +1847 -0
- package/lib/workflow-manager-public/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/lib/workflow-manager-public/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/lib/workflow-manager-public/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/lib/workflow-manager-public/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/lib/workflow-manager-public/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/lib/workflow-manager-public/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/lib/workflow-manager-public/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/lib/workflow-manager-public/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/lib/workflow-manager-public/assets/index-Chlg6r8f.css +1 -0
- package/lib/workflow-manager-public/assets/index-E-hGB5ZD.js +13 -0
- package/lib/workflow-manager-public/fonts/OFL-1.1.txt +90 -0
- package/lib/workflow-manager-public/index.html +14 -0
- package/lib/workflow-manager-systems.mjs +519 -0
- package/lib/workflow-manager-transaction.mjs +397 -0
- package/lib/workflow-manager.mjs +745 -0
- package/package.json +8 -2
- package/skills/ai-delivery-assemble-release/agents/openai.yaml +3 -3
- package/skills/ai-delivery-assemble-release/assets/release-material-index.csv +1 -1
- package/skills/ai-delivery-assemble-release/assets/version-inclusion.csv +1 -1
- package/skills/ai-delivery-bootstrap/SKILL.md +4 -4
- package/skills/ai-delivery-bootstrap/agents/openai.yaml +3 -3
- package/skills/ai-delivery-bootstrap/references/bootstrap-contract.md +4 -2
- package/skills/ai-delivery-checkpoint-task/SKILL.md +17 -3
- package/skills/ai-delivery-checkpoint-task/agents/openai.yaml +3 -3
- package/skills/ai-delivery-checkpoint-task/assets/runtime-template/task.json +7 -1
- package/skills/ai-delivery-checkpoint-task/references/checkpoint-contract.md +22 -8
- package/skills/ai-delivery-checkpoint-task/scripts/hook-event.mjs +91 -0
- package/skills/ai-delivery-checkpoint-task/scripts/task-state.mjs +1086 -51
- package/skills/ai-delivery-close-version/SKILL.md +9 -8
- package/skills/ai-delivery-close-version/agents/openai.yaml +3 -3
- package/skills/ai-delivery-close-version/assets/version-lineage.yaml +1 -1
- package/skills/ai-delivery-close-version/references/version-closeout-contract.md +2 -2
- package/skills/ai-delivery-define-product/SKILL.md +8 -4
- package/skills/ai-delivery-define-product/agents/openai.yaml +4 -4
- package/skills/ai-delivery-define-product/references/product-contract.md +2 -2
- package/skills/ai-delivery-deploy-production/agents/openai.yaml +3 -3
- package/skills/ai-delivery-design-architecture/SKILL.md +9 -5
- package/skills/ai-delivery-design-architecture/assets/architecture-template/prototype-architecture-validation.yaml +16 -16
- package/skills/ai-delivery-design-architecture/references/architecture-contract.md +1 -1
- package/skills/ai-delivery-design-experience/SKILL.md +36 -30
- package/skills/ai-delivery-design-experience/agents/openai.yaml +4 -4
- package/skills/ai-delivery-design-experience/assets/experience-template/LEGACY-COMPATIBILITY.md +16 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/design-reference-selection.yaml +32 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/experience-baseline.yaml +2 -2
- package/skills/ai-delivery-design-experience/assets/experience-template/formal-ui-confirmation.yaml +16 -3
- package/skills/ai-delivery-design-experience/assets/experience-template/frontend-change-scope.yaml +30 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/interaction-contract.csv +1 -1
- package/skills/ai-delivery-design-experience/assets/experience-template/mock-admin-export.yaml +8 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/mock-scenario-mappings.csv +1 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/mock-scenario-pack-request.yaml +24 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/product-prototype-reconciliation.yaml +2 -1
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-adjustment-log.csv +1 -1
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-component-bindings.csv +1 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-component-candidates.csv +1 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-components.csv +1 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-confirmation.yaml +18 -18
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-data-contracts.csv +1 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-impact-assessment.yaml +5 -5
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-implementation-bindings.csv +1 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-manifest.yaml +2 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-pages.csv +1 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-product-coverage.csv +1 -1
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-review-comments.csv +1 -1
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-review-decision.yaml +13 -12
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-review-scenes.csv +1 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-review-sessions.csv +1 -1
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-review-unit-pages.csv +1 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-review-units.csv +1 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-revision.yaml +88 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-routes.csv +1 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-set.yaml +2 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-source-inspection-snapshots.csv +1 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-terminals.csv +1 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-tool-candidates.csv +1 -1
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-tool-selection.yaml +2 -3
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-traceability.csv +1 -1
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype-trigger-conditions.csv +1 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/prototype.yaml +6 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/ui-framework-selection.yaml +17 -1
- package/skills/ai-delivery-design-experience/assets/experience-template/ui-handoff.yaml +6 -5
- package/skills/ai-delivery-design-experience/assets/experience-template/ui-mapping.csv +1 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/visual-direction.yaml +4 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/visual-reference-manifest.yaml +23 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/visual-reference.html +163 -0
- package/skills/ai-delivery-design-experience/assets/experience-template/visual-system-confirmation.yaml +11 -0
- package/skills/ai-delivery-design-experience/references/experience-contract.md +26 -27
- package/skills/ai-delivery-design-experience/references/prototype-management-contract.md +42 -21
- package/skills/ai-delivery-design-tests/SKILL.md +13 -7
- package/skills/ai-delivery-design-tests/assets/test-design-template/prototype-test-scope.csv +1 -1
- package/skills/ai-delivery-design-tests/assets/test-design-template/prototype-test-validation.yaml +17 -16
- package/skills/ai-delivery-design-tests/references/test-design-contract.md +7 -5
- package/skills/ai-delivery-develop-iteration/SKILL.md +18 -11
- package/skills/ai-delivery-develop-iteration/agents/openai.yaml +3 -3
- package/skills/ai-delivery-develop-iteration/assets/development-template/candidate-manifest.yaml +36 -0
- package/skills/ai-delivery-develop-iteration/assets/development-template/task-prototype-traceability.csv +1 -1
- package/skills/ai-delivery-develop-iteration/references/development-contract.md +5 -3
- package/skills/ai-delivery-evolve-workflow/agents/openai.yaml +3 -3
- package/skills/ai-delivery-execute-test/SKILL.md +6 -2
- package/skills/ai-delivery-execute-test/agents/openai.yaml +4 -4
- package/skills/ai-delivery-execute-work-package/SKILL.md +10 -3
- package/skills/ai-delivery-execute-work-package/agents/openai.yaml +4 -4
- package/skills/ai-delivery-execute-work-package/references/work-package-contract.md +1 -1
- package/skills/ai-delivery-manage-git/agents/openai.yaml +3 -3
- package/skills/ai-delivery-manage-standards/agents/openai.yaml +4 -4
- package/skills/ai-delivery-orchestrate/SKILL.md +47 -29
- package/skills/ai-delivery-orchestrate/agents/openai.yaml +3 -3
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/control/migrations/.gitkeep +0 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/control/repairs/.gitkeep +0 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/delivery/project.yaml +1 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/delivery/workflow-state.yaml +2 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/frontend-guard/frontend-guard.mjs +50 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/hooks/hook-event.mjs +91 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/manager/manager.mjs +64 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/manager/start.cmd +4 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/manager/start.ps1 +2 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/{viewer → manager}/start.sh +1 -1
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/mock/mock.mjs +50 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/ui-acceptance/ui-acceptance.mjs +37 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/AGENTS.md +32 -6
- package/skills/ai-delivery-orchestrate/assets/project-template/gitignore.template +1 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/CONTRIBUTING.md +25 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/LICENSE +21 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/README.md +250 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/UPSTREAM.yaml +19 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/airbnb/DESIGN.md +545 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/airbnb/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/airtable/DESIGN.md +554 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/airtable/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/apple/DESIGN.md +562 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/apple/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/binance/DESIGN.md +634 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/binance/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/bmw/DESIGN.md +544 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/bmw/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/bmw-m/DESIGN.md +503 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/bmw-m/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/bugatti/DESIGN.md +454 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/bugatti/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/cal/DESIGN.md +542 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/cal/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/claude/DESIGN.md +589 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/claude/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/clay/DESIGN.md +541 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/clay/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/clickhouse/DESIGN.md +544 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/clickhouse/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/cohere/DESIGN.md +451 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/cohere/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/coinbase/DESIGN.md +570 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/coinbase/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/composio/DESIGN.md +506 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/composio/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/cursor/DESIGN.md +537 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/cursor/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/dell-1996/DESIGN.md +632 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/dell-1996/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/elevenlabs/DESIGN.md +504 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/elevenlabs/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/expo/DESIGN.md +526 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/expo/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/ferrari/DESIGN.md +531 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/ferrari/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/figma/DESIGN.md +578 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/figma/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/framer/DESIGN.md +544 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/framer/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/hashicorp/DESIGN.md +575 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/hashicorp/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/hp/DESIGN.md +670 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/hp/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/ibm/DESIGN.md +550 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/ibm/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/intercom/DESIGN.md +546 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/intercom/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/kraken/DESIGN.md +125 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/kraken/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/lamborghini/DESIGN.md +288 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/lamborghini/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/linear.app/DESIGN.md +548 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/linear.app/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/lovable/DESIGN.md +298 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/lovable/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/mastercard/DESIGN.md +365 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/mastercard/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/meta/DESIGN.md +683 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/meta/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/minimax/DESIGN.md +746 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/minimax/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/mintlify/DESIGN.md +852 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/mintlify/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/miro/DESIGN.md +825 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/miro/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/mistral.ai/DESIGN.md +773 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/mistral.ai/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/mongodb/DESIGN.md +767 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/mongodb/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/nike/DESIGN.md +575 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/nike/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/nintendo-2001/DESIGN.md +649 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/nintendo-2001/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/notion/DESIGN.md +821 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/notion/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/nvidia/DESIGN.md +640 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/nvidia/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/ollama/DESIGN.md +539 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/ollama/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/opencode.ai/DESIGN.md +521 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/opencode.ai/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/pinterest/DESIGN.md +597 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/pinterest/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/playstation/DESIGN.md +661 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/playstation/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/posthog/DESIGN.md +690 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/posthog/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/raycast/DESIGN.md +669 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/raycast/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/renault/DESIGN.md +589 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/renault/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/replicate/DESIGN.md +616 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/replicate/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/resend/DESIGN.md +585 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/resend/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/revolut/DESIGN.md +636 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/revolut/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/runwayml/DESIGN.md +244 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/runwayml/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/sanity/DESIGN.md +357 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/sanity/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/sentry/DESIGN.md +551 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/sentry/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/shopify/DESIGN.md +516 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/shopify/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/slack/DESIGN.md +482 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/spacex/DESIGN.md +363 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/spacex/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/spotify/DESIGN.md +246 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/spotify/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/starbucks/DESIGN.md +580 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/starbucks/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/stripe/DESIGN.md +487 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/stripe/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/supabase/DESIGN.md +462 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/supabase/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/superhuman/DESIGN.md +448 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/superhuman/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/tesla/DESIGN.md +286 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/tesla/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/theverge/DESIGN.md +339 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/theverge/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/together.ai/DESIGN.md +633 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/together.ai/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/uber/DESIGN.md +636 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/uber/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/vercel/DESIGN.md +736 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/vercel/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/vodafone/DESIGN.md +538 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/vodafone/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/voltagent/DESIGN.md +521 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/voltagent/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/warp/DESIGN.md +526 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/warp/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/webflow/DESIGN.md +588 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/webflow/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/wired/DESIGN.md +497 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/wired/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/wise/DESIGN.md +544 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/wise/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/x.ai/DESIGN.md +465 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/x.ai/README.md +5 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/zapier/DESIGN.md +537 -0
- package/skills/ai-delivery-orchestrate/assets/project-template/ref/ui/design-md/zapier/README.md +5 -0
- package/skills/ai-delivery-orchestrate/references/formal-state-contract.md +65 -20
- package/skills/ai-delivery-orchestrate/references/workflow-model.md +7 -5
- package/skills/ai-delivery-orchestrate-release/SKILL.md +2 -2
- package/skills/ai-delivery-orchestrate-release/agents/openai.yaml +4 -4
- package/skills/ai-delivery-orchestrate-release/references/release-workflow-contract.md +1 -1
- package/skills/ai-delivery-plan-iteration/SKILL.md +11 -4
- package/skills/ai-delivery-plan-iteration/agents/openai.yaml +4 -4
- package/skills/ai-delivery-plan-iteration/assets/planning-template/scope-manifest.yaml +17 -0
- package/skills/ai-delivery-plan-iteration/assets/planning-template/slice-prototype-traceability.csv +1 -1
- package/skills/ai-delivery-plan-iteration/references/planning-contract.md +8 -2
- package/skills/ai-delivery-prepare-platform/SKILL.md +5 -1
- package/skills/ai-delivery-prepare-platform/agents/openai.yaml +4 -4
- package/skills/ai-delivery-prepare-release/agents/openai.yaml +4 -4
- package/skills/ai-delivery-review-change/SKILL.md +14 -4
- package/skills/ai-delivery-review-change/agents/openai.yaml +4 -4
- package/skills/ai-delivery-review-change/references/review-contract.md +6 -0
- package/skills/ai-delivery-triage-failure/SKILL.md +5 -1
- package/skills/ai-delivery-triage-failure/agents/openai.yaml +4 -4
- package/skills/ai-delivery-validate-artifacts/SKILL.md +6 -3
- package/skills/ai-delivery-validate-artifacts/agents/openai.yaml +4 -4
- package/skills/ai-delivery-validate-artifacts/references/artifact-contract.md +13 -13
- package/skills/ai-delivery-verify-candidate/SKILL.md +13 -6
- package/skills/ai-delivery-verify-candidate/agents/openai.yaml +3 -3
- package/skills/ai-delivery-verify-candidate/assets/verification-template/ui-acceptance-request.yaml +19 -0
- package/skills/ai-delivery-verify-candidate/assets/verification-template/ui-acceptance-results.yaml +53 -0
- package/skills/ai-delivery-verify-candidate/references/candidate-verification-contract.md +10 -2
- package/skills/ai-delivery-verify-production/agents/openai.yaml +3 -3
- package/skills/ai-delivery-design-experience/assets/experience-template/low-fidelity-confirmation.yaml +0 -32
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/viewer/public/app.js +0 -546
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/viewer/public/index.html +0 -199
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/viewer/public/styles.css +0 -529
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/viewer/server.mjs +0 -780
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/viewer/start.cmd +0 -4
- package/skills/ai-delivery-orchestrate/assets/project-template/.workflow/tools/viewer/start.ps1 +0 -2
package/lib/delivery-state.mjs
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import zlib from "node:zlib";
|
|
5
|
+
import {
|
|
6
|
+
historyAnchorId,
|
|
7
|
+
readVerifiedHistoryAnchor,
|
|
8
|
+
structuredDocumentChecksum,
|
|
9
|
+
} from "./history-anchor.mjs";
|
|
10
|
+
import { assertPathInside, atomicWrite } from "./fs-utils.mjs";
|
|
4
11
|
|
|
5
|
-
const
|
|
12
|
+
const SEVEN_NODE_DEVELOPMENT_IDS = [
|
|
6
13
|
"00-bootstrap",
|
|
14
|
+
"01-product-shaping",
|
|
15
|
+
"02-solution-design",
|
|
16
|
+
"03-delivery-readiness",
|
|
17
|
+
"04-implementation",
|
|
18
|
+
"05-candidate-assurance",
|
|
19
|
+
"06-version-closeout",
|
|
20
|
+
];
|
|
21
|
+
const LEGACY_DEVELOPMENT_IDS = [
|
|
7
22
|
"01-product-discovery",
|
|
8
23
|
"02-product-definition",
|
|
9
24
|
"03-architecture-initial",
|
|
@@ -16,11 +31,15 @@ const NODE_IDS = new Set([
|
|
|
16
31
|
"10-change-review",
|
|
17
32
|
"11-candidate-verification",
|
|
18
33
|
"12-version-closeout",
|
|
34
|
+
];
|
|
35
|
+
const LEGACY_PRODUCTION_IDS = [
|
|
19
36
|
"12-release-preparation",
|
|
20
37
|
"13-production-approval",
|
|
21
38
|
"14-production-deployment",
|
|
22
39
|
"15-production-verification",
|
|
23
40
|
"16-version-archive",
|
|
41
|
+
];
|
|
42
|
+
const RELEASE_NODE_IDS = [
|
|
24
43
|
"R00-release-request",
|
|
25
44
|
"R01-version-selection",
|
|
26
45
|
"R02-release-eligibility",
|
|
@@ -32,10 +51,21 @@ const NODE_IDS = new Set([
|
|
|
32
51
|
"R08-production-deployment",
|
|
33
52
|
"R09-production-verification",
|
|
34
53
|
"R10-release-archive",
|
|
54
|
+
];
|
|
55
|
+
const NODE_IDS = new Set([
|
|
56
|
+
...SEVEN_NODE_DEVELOPMENT_IDS,
|
|
57
|
+
...LEGACY_DEVELOPMENT_IDS,
|
|
58
|
+
...LEGACY_PRODUCTION_IDS,
|
|
59
|
+
...RELEASE_NODE_IDS,
|
|
35
60
|
]);
|
|
61
|
+
const SEVEN_NODE_DEVELOPMENT_ID_SET = new Set(SEVEN_NODE_DEVELOPMENT_IDS);
|
|
62
|
+
const LEGACY_DEVELOPMENT_ID_SET = new Set(LEGACY_DEVELOPMENT_IDS);
|
|
63
|
+
const RELEASE_NODE_ID_SET = new Set(RELEASE_NODE_IDS);
|
|
36
64
|
const ARTIFACT_STATUSES = new Set([
|
|
37
65
|
"draft",
|
|
38
66
|
"review-draft",
|
|
67
|
+
"submitted",
|
|
68
|
+
"rejected",
|
|
39
69
|
"pending-approval",
|
|
40
70
|
"approved",
|
|
41
71
|
"valid",
|
|
@@ -54,14 +84,24 @@ const ARTIFACT_STATUSES = new Set([
|
|
|
54
84
|
"rolled-back",
|
|
55
85
|
"archived",
|
|
56
86
|
]);
|
|
87
|
+
const WORKING_DRAFT_STATUSES = new Set(["draft", "review-draft"]);
|
|
57
88
|
const GATE_DECISIONS = new Set(["approved", "rejected", "blocked", "not-required"]);
|
|
58
89
|
const UX_GATE_IDS = new Set(["UX-LF", "UX-VS", "UX-UI"]);
|
|
90
|
+
const RETIRED_GATE_IDS = new Set(["UX-LF"]);
|
|
91
|
+
const UX_GATE_NODES = new Set(["02-solution-design", "04-experience-design"]);
|
|
92
|
+
const PRODUCT_TERMINAL_NODES = new Set(["01-product-shaping", "02-product-definition"]);
|
|
59
93
|
const SUCCESSFUL_ARTIFACT_STATUSES = new Set([
|
|
60
94
|
"approved", "valid", "frozen", "immutable", "passed", "deployed", "released",
|
|
61
95
|
"release-ready", "included-in-release", "archived",
|
|
62
96
|
]);
|
|
63
97
|
const NODE_RULES = new Map([
|
|
64
98
|
["00-bootstrap", { scope: "development", prerequisites: [], gates: [] }],
|
|
99
|
+
["01-product-shaping", { scope: "development", prerequisites: ["00-bootstrap"], gates: ["GATE-A", "GATE-B"] }],
|
|
100
|
+
["02-solution-design", { scope: "development", prerequisites: ["01-product-shaping"], gates: ["UX-VS", "UX-UI"] }],
|
|
101
|
+
["03-delivery-readiness", { scope: "development", prerequisites: ["02-solution-design"], gates: [] }],
|
|
102
|
+
["04-implementation", { scope: "development", prerequisites: ["03-delivery-readiness"], gates: [] }],
|
|
103
|
+
["05-candidate-assurance", { scope: "development", prerequisites: ["04-implementation"], gates: [] }],
|
|
104
|
+
["06-version-closeout", { scope: "development", prerequisites: ["05-candidate-assurance"], gates: [] }],
|
|
65
105
|
["01-product-discovery", { scope: "development", prerequisites: ["00-bootstrap"], gates: ["GATE-A"] }],
|
|
66
106
|
["02-product-definition", { scope: "development", prerequisites: ["01-product-discovery"], gates: ["GATE-B"] }],
|
|
67
107
|
["03-architecture-initial", { scope: "development", prerequisites: ["02-product-definition"], gates: [] }],
|
|
@@ -86,7 +126,27 @@ const NODE_RULES = new Map([
|
|
|
86
126
|
["R09-production-verification", { scope: "release", prerequisites: ["R08-production-deployment"], gates: [] }],
|
|
87
127
|
["R10-release-archive", { scope: "release", prerequisites: ["R09-production-verification"], gates: [] }],
|
|
88
128
|
]);
|
|
89
|
-
const WRITABLE_NODE_IDS = new Set(
|
|
129
|
+
const WRITABLE_NODE_IDS = new Set([...SEVEN_NODE_DEVELOPMENT_IDS, ...RELEASE_NODE_IDS]);
|
|
130
|
+
const SCOPED_COMPLETION_NODES = new Set([
|
|
131
|
+
"03-delivery-readiness", "04-implementation", "05-candidate-assurance", "06-version-closeout",
|
|
132
|
+
"07-iteration-planning", "08-test-design", "09-development",
|
|
133
|
+
"10-change-review", "11-candidate-verification", "12-version-closeout",
|
|
134
|
+
]);
|
|
135
|
+
const CANDIDATE_REQUIRED_NODES = new Set([
|
|
136
|
+
"04-implementation", "05-candidate-assurance", "06-version-closeout",
|
|
137
|
+
"11-candidate-verification", "12-version-closeout",
|
|
138
|
+
]);
|
|
139
|
+
const CANDIDATE_BOUND_NODES = new Set(["05-candidate-assurance", "06-version-closeout"]);
|
|
140
|
+
const SCOPE_FREEZE_NODES = new Set(["03-delivery-readiness", "07-iteration-planning"]);
|
|
141
|
+
const SLICE_BUDGET_LIMITS = new Map([
|
|
142
|
+
["logical-tasks", 8],
|
|
143
|
+
["active-hours", 8],
|
|
144
|
+
["wall-clock-hours", 24],
|
|
145
|
+
["blocking-repair-rounds", 2],
|
|
146
|
+
]);
|
|
147
|
+
const LOCK_WAIT_CELL = new Int32Array(new SharedArrayBuffer(4));
|
|
148
|
+
const ACTIVE_LOCK_WAIT_TIMEOUT_MS = 30_000;
|
|
149
|
+
const LOCK_METADATA_INITIALIZATION_TIMEOUT_MS = 1_000;
|
|
90
150
|
|
|
91
151
|
function parseArgs(argv) {
|
|
92
152
|
const positional = [];
|
|
@@ -120,6 +180,13 @@ function lifecycleDescriptor(command, positional, flags) {
|
|
|
120
180
|
if (command === "gate" && new Set(["request", "decide"]).has(positional[1])) {
|
|
121
181
|
return { kind: "gate", action: `gate-${positional[1]}`, subject_id: optionalFlag(flags, "--gate-id") };
|
|
122
182
|
}
|
|
183
|
+
if (command === "scope" && new Set(["freeze", "amend", "warn"]).has(positional[1])) {
|
|
184
|
+
return {
|
|
185
|
+
kind: "scope",
|
|
186
|
+
action: `scope-${positional[1]}`,
|
|
187
|
+
subject_id: optionalFlag(flags, "--manifest-id") || optionalFlag(flags, "--iteration-id"),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
123
190
|
if (command !== "transition") return null;
|
|
124
191
|
const transition = optionalFlag(flags, "--event") || "transition";
|
|
125
192
|
if (transition.startsWith("node-")) {
|
|
@@ -135,9 +202,62 @@ function lifecycleDescriptor(command, positional, flags) {
|
|
|
135
202
|
};
|
|
136
203
|
}
|
|
137
204
|
|
|
205
|
+
function assertNotLegacyDevelopmentNode(node) {
|
|
206
|
+
if (LEGACY_DEVELOPMENT_ID_SET.has(node)) {
|
|
207
|
+
throw new Error(`Legacy workflow node is read-only: ${node}`);
|
|
208
|
+
}
|
|
209
|
+
return node;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function assertGateContractWritable(gateId) {
|
|
213
|
+
if (RETIRED_GATE_IDS.has(gateId)) {
|
|
214
|
+
throw new Error(`${gateId} Gate contract is retired; historical records remain read-only.`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function assertRetiredGateContract(gateId) {
|
|
219
|
+
if (!RETIRED_GATE_IDS.has(gateId)) {
|
|
220
|
+
throw new Error(`Gate contract is not retired: ${gateId}.`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function assertNoLegacyDevelopmentWrite(files, command, positional, flags, options) {
|
|
225
|
+
let node = null;
|
|
226
|
+
if ((command === "artifact" && positional[1] === "register")
|
|
227
|
+
|| (command === "gate" && positional[1] === "request")) {
|
|
228
|
+
node = optionalFlag(flags, "--node");
|
|
229
|
+
} else if (command === "gate" && positional[1] === "decide") {
|
|
230
|
+
const gateId = optionalFlag(flags, "--gate-id");
|
|
231
|
+
if (gateId) {
|
|
232
|
+
const state = normalizeWorkflowState(readYaml(files.workflowState, options.yaml, "workflow-state.yaml"));
|
|
233
|
+
const pending = [state.development_state, state.release_state]
|
|
234
|
+
.flatMap((scope) => scope.pending_gates)
|
|
235
|
+
.map((gate) => typeof gate === "string" ? { gate_id: gate, node: null } : gate)
|
|
236
|
+
.find((gate) => gate?.gate_id === gateId);
|
|
237
|
+
node = pending?.node || null;
|
|
238
|
+
}
|
|
239
|
+
} else if (command === "transition") {
|
|
240
|
+
node = optionalFlag(flags, "--node");
|
|
241
|
+
const event = optionalFlag(flags, "--event");
|
|
242
|
+
if (!node && new Set(["candidate-selected", "version-closed"]).has(event)) {
|
|
243
|
+
const artifactId = optionalFlag(flags, "--artifact-id");
|
|
244
|
+
if (artifactId) {
|
|
245
|
+
const registry = normalizeArtifactRegistry(
|
|
246
|
+
readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml"),
|
|
247
|
+
);
|
|
248
|
+
node = registry.artifacts.find((artifact) =>
|
|
249
|
+
(artifact.artifact_id || artifact.id) === artifactId)?.node || null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
assertNotLegacyDevelopmentNode(node);
|
|
254
|
+
}
|
|
255
|
+
|
|
138
256
|
function dispatchLifecycle(options, files, descriptor, phase, flags, result = null, error = null) {
|
|
139
257
|
if (!descriptor || typeof options.onLifecycleEvent !== "function") return { decision: "pass" };
|
|
140
|
-
const revision = optionalFlag(flags, "--expected-revision")
|
|
258
|
+
const revision = optionalFlag(flags, "--expected-scope-revision")
|
|
259
|
+
|| optionalFlag(flags, "--expected-revision")
|
|
260
|
+
|| "none";
|
|
141
261
|
const identity = descriptor.subject_id || "unspecified";
|
|
142
262
|
const idempotencyKey = `formal-state-${descriptor.kind}-${phase}-${descriptor.action}-${identity}-r${revision}`;
|
|
143
263
|
const event = {
|
|
@@ -179,13 +299,6 @@ function readYaml(file, yaml, label) {
|
|
|
179
299
|
return value;
|
|
180
300
|
}
|
|
181
301
|
|
|
182
|
-
function atomicWrite(file, content) {
|
|
183
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
184
|
-
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
185
|
-
fs.writeFileSync(temporary, content, "utf8");
|
|
186
|
-
fs.renameSync(temporary, file);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
302
|
function isInside(candidate, root) {
|
|
190
303
|
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
|
191
304
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
@@ -195,6 +308,12 @@ function sha256(value) {
|
|
|
195
308
|
return crypto.createHash("sha256").update(value).digest("hex");
|
|
196
309
|
}
|
|
197
310
|
|
|
311
|
+
export function stableValue(value) {
|
|
312
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
313
|
+
if (!value || typeof value !== "object") return value;
|
|
314
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
|
|
315
|
+
}
|
|
316
|
+
|
|
198
317
|
function requiredFlag(flags, name) {
|
|
199
318
|
const raw = flags.get(name);
|
|
200
319
|
const value = Array.isArray(raw) ? raw.at(-1) : raw;
|
|
@@ -222,30 +341,84 @@ function artifactStatus(flags) {
|
|
|
222
341
|
return status;
|
|
223
342
|
}
|
|
224
343
|
|
|
225
|
-
function
|
|
226
|
-
const raw = requiredFlag(flags,
|
|
344
|
+
function nonNegativeRevisionFlag(flags, name) {
|
|
345
|
+
const raw = requiredFlag(flags, name);
|
|
227
346
|
const value = Number(raw);
|
|
228
347
|
if (!Number.isInteger(value) || value < 0) {
|
|
229
|
-
throw new Error(
|
|
348
|
+
throw new Error(`${name} must be a non-negative integer.`);
|
|
230
349
|
}
|
|
231
350
|
return value;
|
|
232
351
|
}
|
|
233
352
|
|
|
234
|
-
function
|
|
353
|
+
function expectedRevision(flags) {
|
|
354
|
+
return nonNegativeRevisionFlag(flags, "--expected-revision");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function expectedScopeRevision(flags) {
|
|
358
|
+
if (!flags.has("--expected-scope-revision")) return null;
|
|
359
|
+
return nonNegativeRevisionFlag(flags, "--expected-scope-revision");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function assertWorkflowRevision(flags, state, scopeName = null) {
|
|
363
|
+
const scopedExpected = expectedScopeRevision(flags);
|
|
364
|
+
if (scopedExpected !== null) {
|
|
365
|
+
if (flags.has("--expected-revision")) {
|
|
366
|
+
throw new Error("Use either --expected-scope-revision or --expected-revision, not both.");
|
|
367
|
+
}
|
|
368
|
+
if (!scopeName) throw new Error("A workflow scope is required for --expected-scope-revision.");
|
|
369
|
+
const actual = scopeState(state, scopeName).revision;
|
|
370
|
+
if (actual !== scopedExpected) {
|
|
371
|
+
throw new Error(`${scopeName} scope revision conflict: expected ${scopedExpected}, actual ${actual}.`);
|
|
372
|
+
}
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const expected = expectedRevision(flags);
|
|
376
|
+
if (state.revision !== expected) {
|
|
377
|
+
throw new Error(`Workflow state revision conflict: expected ${expected}, actual ${state.revision}.`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function pauseForLockRetry() {
|
|
382
|
+
Atomics.wait(LOCK_WAIT_CELL, 0, 0, 10);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function withLock(files, stream, action, lockOptions = {}) {
|
|
386
|
+
const recover = lockOptions.recover !== false;
|
|
387
|
+
const waitForActive = lockOptions.waitForActive === true;
|
|
388
|
+
const maximumAttempts = waitForActive ? Number.POSITIVE_INFINITY : 3;
|
|
389
|
+
const waitDeadline = waitForActive
|
|
390
|
+
? Date.now() + (lockOptions.waitTimeoutMs ?? ACTIVE_LOCK_WAIT_TIMEOUT_MS)
|
|
391
|
+
: null;
|
|
392
|
+
// Read-only callers check both sides of lock acquisition so a stale journal is actionable
|
|
393
|
+
// and a writer that wins the race cannot expose a partial transaction.
|
|
394
|
+
if (!recover) assertNoPendingTransaction(files);
|
|
235
395
|
fs.mkdirSync(files.lockRoot, { recursive: true });
|
|
236
396
|
const lockFile = path.join(files.lockRoot, "formal-state.lock");
|
|
237
397
|
let descriptor;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
398
|
+
for (let attempt = 0; attempt < maximumAttempts && descriptor === undefined; attempt += 1) {
|
|
399
|
+
try {
|
|
400
|
+
descriptor = fs.openSync(lockFile, "wx");
|
|
401
|
+
fs.writeFileSync(descriptor, `${JSON.stringify({
|
|
402
|
+
pid: process.pid,
|
|
403
|
+
stream,
|
|
404
|
+
created_at: new Date().toISOString(),
|
|
405
|
+
})}\n`);
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (error.code !== "EEXIST") throw error;
|
|
408
|
+
if (recover && removeAbandonedLock(lockFile)) continue;
|
|
409
|
+
if (waitForActive && isWaitableFormalLock(lockFile) && Date.now() < waitDeadline) {
|
|
410
|
+
pauseForLockRetry();
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
throw new Error(`Formal state is locked while updating: ${stream}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (descriptor === undefined) {
|
|
417
|
+
throw new Error(`Formal state lock could not be acquired: ${stream}`);
|
|
247
418
|
}
|
|
248
419
|
try {
|
|
420
|
+
if (recover) recoverPendingTransaction(files);
|
|
421
|
+
else assertNoPendingTransaction(files);
|
|
249
422
|
return action();
|
|
250
423
|
} finally {
|
|
251
424
|
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
@@ -253,6 +426,50 @@ function withLock(files, stream, action) {
|
|
|
253
426
|
}
|
|
254
427
|
}
|
|
255
428
|
|
|
429
|
+
function isWaitableFormalLock(lockFile) {
|
|
430
|
+
try {
|
|
431
|
+
const content = fs.readFileSync(lockFile, "utf8");
|
|
432
|
+
try {
|
|
433
|
+
const owner = JSON.parse(content);
|
|
434
|
+
return typeof owner.stream === "string" && owner.stream.length > 0;
|
|
435
|
+
} catch {
|
|
436
|
+
const ageMs = Date.now() - fs.statSync(lockFile).mtimeMs;
|
|
437
|
+
return ageMs >= 0 && ageMs < LOCK_METADATA_INITIALIZATION_TIMEOUT_MS;
|
|
438
|
+
}
|
|
439
|
+
} catch (error) {
|
|
440
|
+
// The owner may release the lock between our failed open and metadata read.
|
|
441
|
+
return error.code === "ENOENT";
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function isAbandonedLock(lockFile) {
|
|
446
|
+
try {
|
|
447
|
+
const owner = JSON.parse(fs.readFileSync(lockFile, "utf8"));
|
|
448
|
+
if (!Number.isInteger(owner.pid) || owner.pid <= 0) return false;
|
|
449
|
+
try {
|
|
450
|
+
process.kill(owner.pid, 0);
|
|
451
|
+
return false;
|
|
452
|
+
} catch (error) {
|
|
453
|
+
return error.code === "ESRCH";
|
|
454
|
+
}
|
|
455
|
+
} catch {
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function removeAbandonedLock(lockFile) {
|
|
461
|
+
if (!isAbandonedLock(lockFile)) return false;
|
|
462
|
+
const abandoned = `${lockFile}.${process.pid}.${crypto.randomUUID()}.stale`;
|
|
463
|
+
try {
|
|
464
|
+
fs.renameSync(lockFile, abandoned);
|
|
465
|
+
} catch (error) {
|
|
466
|
+
if (error.code === "ENOENT") return true;
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
fs.rmSync(abandoned, { force: true });
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
|
|
256
473
|
function statePaths(projectRoot) {
|
|
257
474
|
const root = path.resolve(projectRoot);
|
|
258
475
|
const workflow = path.join(root, ".workflow");
|
|
@@ -266,7 +483,13 @@ function statePaths(projectRoot) {
|
|
|
266
483
|
artifactRegistry: path.join(delivery, "artifact-registry.yaml"),
|
|
267
484
|
events: path.join(delivery, "state-events.jsonl"),
|
|
268
485
|
eventShards: path.join(workflow, "control", "events"),
|
|
486
|
+
historySnapshots: path.join(workflow, "control", "snapshots", "formal-state"),
|
|
487
|
+
artifactHistoryArchives: path.join(workflow, "control", "archives", "history", "artifact-registry"),
|
|
488
|
+
artifactRetention: path.join(workflow, "control", "retention", "artifact-registry"),
|
|
489
|
+
compactRuntime: path.join(delivery, "runtime", "formal-state-compact"),
|
|
490
|
+
pruneRuntime: path.join(delivery, "runtime", "formal-state-prune"),
|
|
269
491
|
lockRoot: path.join(delivery, "runtime", "formal-state-locks"),
|
|
492
|
+
transactionJournal: path.join(delivery, "runtime", "formal-state-locks", "formal-state-transaction.json"),
|
|
270
493
|
};
|
|
271
494
|
}
|
|
272
495
|
|
|
@@ -282,6 +505,21 @@ function normalizeScope(value, scopeId = null) {
|
|
|
282
505
|
node_attempts: value?.node_attempts && typeof value.node_attempts === "object"
|
|
283
506
|
? value.node_attempts
|
|
284
507
|
: {},
|
|
508
|
+
node_candidate_bindings: value?.node_candidate_bindings
|
|
509
|
+
&& typeof value.node_candidate_bindings === "object"
|
|
510
|
+
&& !Array.isArray(value.node_candidate_bindings)
|
|
511
|
+
? value.node_candidate_bindings
|
|
512
|
+
: {},
|
|
513
|
+
scope_manifests: Array.isArray(value?.scope_manifests) ? value.scope_manifests : [],
|
|
514
|
+
current_scope_manifest_id: value?.current_scope_manifest_id ?? null,
|
|
515
|
+
scope_contract_version: value?.scope_contract_version ?? null,
|
|
516
|
+
scope_warnings: Array.isArray(value?.scope_warnings) ? value.scope_warnings : [],
|
|
517
|
+
node_completion_receipts: Array.isArray(value?.node_completion_receipts)
|
|
518
|
+
? value.node_completion_receipts
|
|
519
|
+
: [],
|
|
520
|
+
contract_retirement_receipts: Array.isArray(value?.contract_retirement_receipts)
|
|
521
|
+
? value.contract_retirement_receipts
|
|
522
|
+
: [],
|
|
285
523
|
latest_candidate_id: value?.latest_candidate_id ?? null,
|
|
286
524
|
latest_release_id: value?.latest_release_id ?? null,
|
|
287
525
|
updated_at: value?.updated_at ?? "",
|
|
@@ -289,6 +527,24 @@ function normalizeScope(value, scopeId = null) {
|
|
|
289
527
|
};
|
|
290
528
|
}
|
|
291
529
|
|
|
530
|
+
export function findIncompatibleNodeStates(scope) {
|
|
531
|
+
const memberships = new Map();
|
|
532
|
+
for (const [stateName, nodes] of [
|
|
533
|
+
["active_nodes", scope?.active_nodes],
|
|
534
|
+
["blocked_nodes", scope?.blocked_nodes],
|
|
535
|
+
["completed_nodes", scope?.completed_nodes],
|
|
536
|
+
]) {
|
|
537
|
+
for (const node of Array.isArray(nodes) ? nodes : []) {
|
|
538
|
+
const states = memberships.get(node) || new Set();
|
|
539
|
+
states.add(stateName);
|
|
540
|
+
memberships.set(node, states);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return [...memberships]
|
|
544
|
+
.filter(([, states]) => states.size > 1)
|
|
545
|
+
.map(([node, states]) => ({ node, states: [...states] }));
|
|
546
|
+
}
|
|
547
|
+
|
|
292
548
|
function exposeLegacyScopeAliases(state) {
|
|
293
549
|
const preferred = state.last_transition?.scope === "release"
|
|
294
550
|
? state.release_state
|
|
@@ -369,14 +625,597 @@ function normalizeArtifactRegistry(value) {
|
|
|
369
625
|
};
|
|
370
626
|
}
|
|
371
627
|
|
|
372
|
-
function
|
|
628
|
+
function uniqueSortedValues(values, label) {
|
|
629
|
+
const normalized = [...new Set(values.map((value) => String(value).trim()).filter(Boolean))].sort();
|
|
630
|
+
if (!normalized.length) throw new Error(`${label} requires at least one value.`);
|
|
631
|
+
return normalized;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function scopedReferences(flags, flag, idField) {
|
|
635
|
+
return uniqueSortedValues(repeatedFlag(flags, flag), flag).map((value) => {
|
|
636
|
+
const separator = value.indexOf(":");
|
|
637
|
+
if (separator <= 0 || separator === value.length - 1) {
|
|
638
|
+
throw new Error(`${flag} must use <node-id>:<id>.`);
|
|
639
|
+
}
|
|
640
|
+
const node = value.slice(0, separator);
|
|
641
|
+
const id = value.slice(separator + 1);
|
|
642
|
+
if (!WRITABLE_NODE_IDS.has(node) || scopeNameForNode(node) !== "development") {
|
|
643
|
+
throw new Error(`${flag} references an invalid development node: ${node}.`);
|
|
644
|
+
}
|
|
645
|
+
return { node, [idField]: id };
|
|
646
|
+
}).sort((left, right) => `${left.node}:${left[idField]}`.localeCompare(`${right.node}:${right[idField]}`));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function checksumWithoutField(record, field) {
|
|
650
|
+
const unsigned = { ...record };
|
|
651
|
+
delete unsigned[field];
|
|
652
|
+
return sha256(JSON.stringify(stableValue(unsigned)));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function scopeManifestChecksum(manifest) {
|
|
656
|
+
return checksumWithoutField(manifest, "manifest_checksum");
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function completionReceiptChecksum(receipt) {
|
|
660
|
+
return checksumWithoutField(receipt, "receipt_checksum");
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function contractRetirementReceiptChecksum(receipt) {
|
|
664
|
+
return checksumWithoutField(receipt, "receipt_checksum");
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function currentScopeManifest(scope) {
|
|
668
|
+
if (!scope.current_scope_manifest_id) return null;
|
|
669
|
+
return scope.scope_manifests.find((manifest) => manifest?.manifest_id === scope.current_scope_manifest_id) || null;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function currentTaskFindings(task) {
|
|
673
|
+
const current = new Map();
|
|
674
|
+
for (const attempt of Array.isArray(task?.attempts) ? task.attempts : []) {
|
|
675
|
+
for (const phase of Array.isArray(attempt?.phases) ? attempt.phases : []) {
|
|
676
|
+
for (const finding of Array.isArray(phase?.findings) ? phase.findings : []) {
|
|
677
|
+
if (finding?.finding_id) current.set(finding.finding_id, finding);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return [...current.values()];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function readScopeTasks(files, scopeId, options) {
|
|
685
|
+
const taskRoot = path.join(files.workflow, "control", "tasks");
|
|
686
|
+
if (!fs.existsSync(taskRoot)) return new Map();
|
|
687
|
+
if (!fs.statSync(taskRoot).isDirectory()) throw new Error(`Task fact root must be a directory: ${taskRoot}`);
|
|
688
|
+
const tasks = new Map();
|
|
689
|
+
for (const name of fs.readdirSync(taskRoot).filter((item) => /\.ya?ml$|\.json$/i.test(item)).sort()) {
|
|
690
|
+
const task = readYaml(path.join(taskRoot, name), options.yaml, `task fact ${name}`);
|
|
691
|
+
if (task.iteration_id !== scopeId) continue;
|
|
692
|
+
if (!task.task_id) throw new Error(`Task fact is missing task_id: ${name}`);
|
|
693
|
+
if (tasks.has(task.task_id)) throw new Error(`Duplicate task fact ID: ${task.task_id}.`);
|
|
694
|
+
tasks.set(task.task_id, task);
|
|
695
|
+
}
|
|
696
|
+
return tasks;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function artifactFileIntegrity(files, artifact) {
|
|
700
|
+
const artifactPath = typeof artifact?.path === "string" ? artifact.path.replaceAll("\\", "/") : "";
|
|
701
|
+
if (!artifactPath.startsWith(".workflow/")) {
|
|
702
|
+
return { valid: false, code: "invalid-path", artifact_path: artifactPath, actual_checksum: null };
|
|
703
|
+
}
|
|
704
|
+
const absolute = path.resolve(files.root, artifactPath);
|
|
705
|
+
try {
|
|
706
|
+
if (!isInside(absolute, files.workflow) || !fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) {
|
|
707
|
+
return { valid: false, code: "missing-or-outside", artifact_path: artifactPath, actual_checksum: null };
|
|
708
|
+
}
|
|
709
|
+
if (!isInside(fs.realpathSync(absolute), fs.realpathSync(files.workflow))) {
|
|
710
|
+
return { valid: false, code: "resolved-outside", artifact_path: artifactPath, actual_checksum: null };
|
|
711
|
+
}
|
|
712
|
+
const actualChecksum = sha256(fs.readFileSync(absolute));
|
|
713
|
+
if (artifact.checksum_algorithm !== "sha256" || artifact.checksum !== actualChecksum) {
|
|
714
|
+
return { valid: false, code: "checksum-mismatch", artifact_path: artifactPath, actual_checksum: actualChecksum };
|
|
715
|
+
}
|
|
716
|
+
return { valid: true, code: null, artifact_path: artifactPath, actual_checksum: actualChecksum };
|
|
717
|
+
} catch {
|
|
718
|
+
return { valid: false, code: "unreadable", artifact_path: artifactPath, actual_checksum: null };
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function latestGateDecisions(scope, node) {
|
|
723
|
+
const attempt = Number(scope.node_attempts[node] || 1);
|
|
724
|
+
const decisions = new Map();
|
|
725
|
+
for (const decision of scope.gate_decisions) {
|
|
726
|
+
if (decision?.node === node
|
|
727
|
+
&& (!decision.iteration_id || decision.iteration_id === scope.scope_id)
|
|
728
|
+
&& Number(decision.node_attempt || attempt) === attempt
|
|
729
|
+
&& decision.gate_id) decisions.set(decision.gate_id, decision);
|
|
730
|
+
}
|
|
731
|
+
return decisions;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function artifactSuperseder(registry, artifact) {
|
|
735
|
+
const artifactId = artifact?.artifact_id || artifact?.id;
|
|
736
|
+
if (!artifactId) return null;
|
|
737
|
+
return registry.artifacts.find((candidate) => candidate?.supersedes === artifactId) || null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function projectedArtifactStatus(registry, artifact) {
|
|
741
|
+
return artifactSuperseder(registry, artifact) ? "superseded" : artifact?.status;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function deriveNodeStates(files, state, registry, options) {
|
|
745
|
+
const projections = {};
|
|
746
|
+
const artifacts = new Map(registry.artifacts.map((artifact) => [artifact.artifact_id || artifact.id, artifact]));
|
|
747
|
+
for (const [node, rule] of NODE_RULES) {
|
|
748
|
+
const scope = scopeState(state, rule.scope);
|
|
749
|
+
const attempt = Number(scope.node_attempts[node] || 1);
|
|
750
|
+
const active = scope.active_nodes.includes(node);
|
|
751
|
+
const blocked = scope.blocked_nodes.includes(node);
|
|
752
|
+
const completed = scope.completed_nodes.includes(node);
|
|
753
|
+
const manifest = rule.scope === "development" ? currentScopeManifest(scope) : null;
|
|
754
|
+
const tasks = rule.scope === "development" && scope.scope_id
|
|
755
|
+
? readScopeTasks(files, scope.scope_id, options)
|
|
756
|
+
: new Map();
|
|
757
|
+
const missing = [];
|
|
758
|
+
const blockingReasons = [];
|
|
759
|
+
const blockingDefectIds = [];
|
|
760
|
+
const taskFacts = [];
|
|
761
|
+
const artifactFacts = [];
|
|
762
|
+
const scopedNode = rule.scope === "development" && SCOPED_COMPLETION_NODES.has(node);
|
|
763
|
+
const taskReferences = manifest?.required_tasks?.filter((reference) => reference.node === node) || [];
|
|
764
|
+
const allDefectReferences = CANDIDATE_REQUIRED_NODES.has(node)
|
|
765
|
+
? manifest?.required_tasks || []
|
|
766
|
+
: taskReferences;
|
|
767
|
+
|
|
768
|
+
const governedScope = scope.scope_contract_version === 1 || Boolean(manifest);
|
|
769
|
+
if (scopedNode && governedScope && !manifest) missing.push("scope-manifest:frozen");
|
|
770
|
+
for (const reference of taskReferences) {
|
|
771
|
+
const task = tasks.get(reference.task_id);
|
|
772
|
+
taskFacts.push({ task_id: reference.task_id, status: task?.status || "missing" });
|
|
773
|
+
if (!task) {
|
|
774
|
+
missing.push(`task:${reference.task_id}:missing`);
|
|
775
|
+
} else if (task.status !== "completed") {
|
|
776
|
+
missing.push(`task:${reference.task_id}:${task.status || "unknown"}`);
|
|
777
|
+
if (new Set(["failed", "abandoned"]).has(task.status)) {
|
|
778
|
+
blockingReasons.push(`task:${reference.task_id}:${task.status}`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
for (const reference of allDefectReferences) {
|
|
783
|
+
const task = tasks.get(reference.task_id);
|
|
784
|
+
for (const finding of currentTaskFindings(task)) {
|
|
785
|
+
if (finding.severity === "blocking" && finding.status === "open") {
|
|
786
|
+
blockingDefectIds.push(finding.finding_id);
|
|
787
|
+
blockingReasons.push(`defect:${finding.finding_id}:open`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
for (const reference of manifest?.required_artifacts?.filter((item) => item.node === node) || []) {
|
|
792
|
+
const artifact = artifacts.get(reference.artifact_id);
|
|
793
|
+
const integrity = artifact ? artifactFileIntegrity(files, artifact) : null;
|
|
794
|
+
const artifactStatus = artifact ? projectedArtifactStatus(registry, artifact) : "missing";
|
|
795
|
+
artifactFacts.push({
|
|
796
|
+
artifact_id: reference.artifact_id,
|
|
797
|
+
status: artifactStatus,
|
|
798
|
+
checksum: artifact?.checksum || null,
|
|
799
|
+
checksum_valid: integrity?.valid || false,
|
|
800
|
+
});
|
|
801
|
+
if (!artifact) {
|
|
802
|
+
missing.push(`artifact:${reference.artifact_id}:missing`);
|
|
803
|
+
} else if (!integrity.valid) {
|
|
804
|
+
missing.push(`artifact:${reference.artifact_id}:${integrity.code}`);
|
|
805
|
+
blockingReasons.push(`artifact:${reference.artifact_id}:${integrity.code}`);
|
|
806
|
+
} else if (!allowedCompletionStatuses(node).has(artifactStatus)) {
|
|
807
|
+
missing.push(`artifact:${reference.artifact_id}:${artifactStatus || "unknown"}`);
|
|
808
|
+
if (new Set(["rejected", "failed", "invalid", "blocked", "stale", "superseded"]).has(artifactStatus)) {
|
|
809
|
+
blockingReasons.push(`artifact:${reference.artifact_id}:${artifactStatus}`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const decisions = latestGateDecisions(scope, node);
|
|
815
|
+
const gateFacts = [];
|
|
816
|
+
for (const gateId of rule.gates || []) {
|
|
817
|
+
const decision = decisions.get(gateId);
|
|
818
|
+
gateFacts.push({ gate_id: gateId, decision: decision?.decision || "missing", actor: decision?.actor || null });
|
|
819
|
+
if (!satisfiesMandatoryGate(node, decision)) missing.push(`gate:${gateId}:approved`);
|
|
820
|
+
if (decision && !new Set(["approved", "not-required"]).has(decision.decision)) {
|
|
821
|
+
blockingReasons.push(`gate:${gateId}:${decision.decision}`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const pendingForNode = scope.pending_gates.filter((gate) =>
|
|
825
|
+
typeof gate === "string" || gate?.node === node);
|
|
826
|
+
for (const pending of pendingForNode) {
|
|
827
|
+
const gateId = typeof pending === "string" ? pending : pending.gate_id;
|
|
828
|
+
if (gateId && !missing.includes(`gate:${gateId}:pending`)) missing.push(`gate:${gateId}:pending`);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
let candidateId = null;
|
|
832
|
+
if (rule.scope === "development" && CANDIDATE_REQUIRED_NODES.has(node)) {
|
|
833
|
+
const binding = scope.node_candidate_bindings[node];
|
|
834
|
+
const requiresAttemptBinding = CANDIDATE_BOUND_NODES.has(node) && (active || blocked || completed);
|
|
835
|
+
candidateId = requiresAttemptBinding
|
|
836
|
+
? (Number(binding?.node_attempt) === attempt ? binding.candidate_id : null)
|
|
837
|
+
: scope.latest_candidate_id;
|
|
838
|
+
const candidate = candidateId ? artifacts.get(candidateId) : null;
|
|
839
|
+
if (!candidate || projectedArtifactStatus(registry, candidate) !== "immutable") {
|
|
840
|
+
missing.push(requiresAttemptBinding ? "candidate:attempt-binding" : "candidate:selected");
|
|
841
|
+
} else {
|
|
842
|
+
const integrity = artifactFileIntegrity(files, candidate);
|
|
843
|
+
if (!integrity.valid) {
|
|
844
|
+
missing.push(`candidate:${candidateId}:${integrity.code}`);
|
|
845
|
+
blockingReasons.push(`candidate:${candidateId}:${integrity.code}`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const receipt = [...scope.node_completion_receipts].reverse().find((item) =>
|
|
851
|
+
item?.node === node
|
|
852
|
+
&& Number(item.node_attempt || attempt) === attempt
|
|
853
|
+
&& (!manifest || (item.scope_manifest_id === manifest.manifest_id
|
|
854
|
+
&& item.scope_manifest_checksum === manifest.manifest_checksum)));
|
|
855
|
+
if (completed && scopedNode && manifest && !receipt) missing.push("completion-receipt:current-scope");
|
|
856
|
+
|
|
857
|
+
const missingConditions = [...new Set(missing)].sort();
|
|
858
|
+
const blockConditions = [...new Set(blockingReasons)].sort();
|
|
859
|
+
const defects = [...new Set(blockingDefectIds)].sort();
|
|
860
|
+
let derivedState = "pending";
|
|
861
|
+
if (blocked || blockConditions.length) derivedState = "blocked";
|
|
862
|
+
else if (completed) derivedState = missingConditions.length ? "blocked" : "completed";
|
|
863
|
+
else if (active && missingConditions.length) derivedState = "waiting";
|
|
864
|
+
else if (active && scopedNode) derivedState = "ready-to-complete";
|
|
865
|
+
else if (active) derivedState = "active";
|
|
866
|
+
|
|
867
|
+
projections[node] = stableValue({
|
|
868
|
+
node,
|
|
869
|
+
state: derivedState,
|
|
870
|
+
completion_ready: derivedState === "ready-to-complete",
|
|
871
|
+
scope_manifest_id: manifest?.manifest_id || null,
|
|
872
|
+
scope_manifest_checksum: manifest?.manifest_checksum || null,
|
|
873
|
+
node_attempt: attempt,
|
|
874
|
+
required_task_ids: taskReferences.map((reference) => reference.task_id).sort(),
|
|
875
|
+
required_artifact_ids: (manifest?.required_artifacts || [])
|
|
876
|
+
.filter((reference) => reference.node === node)
|
|
877
|
+
.map((reference) => reference.artifact_id)
|
|
878
|
+
.sort(),
|
|
879
|
+
task_facts: taskFacts,
|
|
880
|
+
artifact_facts: artifactFacts,
|
|
881
|
+
gate_facts: gateFacts,
|
|
882
|
+
candidate_id: candidateId,
|
|
883
|
+
blocking_defect_ids: defects,
|
|
884
|
+
blocking_conditions: blockConditions,
|
|
885
|
+
missing_conditions: missingConditions,
|
|
886
|
+
completion_receipt_id: receipt?.receipt_id || null,
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
return projections;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const FORMAL_STREAMS = new Map([
|
|
893
|
+
["workflow-state", {
|
|
894
|
+
snapshotFile: "workflow-state.yaml",
|
|
895
|
+
viewFile: (files) => files.workflowState,
|
|
896
|
+
normalize: normalizeWorkflowState,
|
|
897
|
+
applyOperations: applyWorkflowStateOperations,
|
|
898
|
+
allowedSchemas: new Set([1, 2]),
|
|
899
|
+
}],
|
|
900
|
+
["artifact-registry", {
|
|
901
|
+
snapshotFile: "artifact-registry.yaml",
|
|
902
|
+
viewFile: (files) => files.artifactRegistry,
|
|
903
|
+
normalize: normalizeArtifactRegistry,
|
|
904
|
+
applyOperations: applyArtifactRegistryOperations,
|
|
905
|
+
allowedSchemas: new Set([1]),
|
|
906
|
+
}],
|
|
907
|
+
]);
|
|
908
|
+
|
|
909
|
+
function formalStreamViews(files, rawValues) {
|
|
910
|
+
return new Map([...FORMAL_STREAMS].map(([stream, descriptor]) => [stream, {
|
|
911
|
+
...descriptor,
|
|
912
|
+
file: descriptor.viewFile(files),
|
|
913
|
+
raw: rawValues[stream],
|
|
914
|
+
value: descriptor.normalize(rawValues[stream]),
|
|
915
|
+
}]));
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function durableWrite(file, content) {
|
|
919
|
+
const descriptor = fs.openSync(file, "w");
|
|
920
|
+
try {
|
|
921
|
+
fs.writeFileSync(descriptor, content, "utf8");
|
|
922
|
+
fs.fsyncSync(descriptor);
|
|
923
|
+
} finally {
|
|
924
|
+
fs.closeSync(descriptor);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function writeTransactionJournal(files, journal) {
|
|
929
|
+
atomicWrite(files.transactionJournal, `${JSON.stringify(journal, null, 2)}\n`);
|
|
930
|
+
const descriptor = fs.openSync(files.transactionJournal, "r+");
|
|
931
|
+
try {
|
|
932
|
+
fs.fsyncSync(descriptor);
|
|
933
|
+
} finally {
|
|
934
|
+
fs.closeSync(descriptor);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function fileChecksum(file) {
|
|
939
|
+
return sha256(fs.readFileSync(file));
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function validateTransactionJournal(files, journal) {
|
|
943
|
+
if (journal?.schema_version !== 1 || !journal.transaction_id
|
|
944
|
+
|| !new Set(["prepared", "committed"]).has(journal.phase)
|
|
945
|
+
|| !Array.isArray(journal.entries) || !journal.entries.length) {
|
|
946
|
+
throw new Error("Formal state transaction journal is invalid.");
|
|
947
|
+
}
|
|
948
|
+
const managerEvents = path.join(
|
|
949
|
+
files.root, ".workflow", "control", "events", "workflow-manager-events.jsonl",
|
|
950
|
+
);
|
|
951
|
+
const managerReceipts = path.join(files.root, ".workflow", "control", "receipts");
|
|
952
|
+
const allowedFiles = new Set(
|
|
953
|
+
[files.project, files.workflowState, files.artifactRegistry, files.events, managerEvents]
|
|
954
|
+
.map((file) => path.resolve(file)),
|
|
955
|
+
);
|
|
956
|
+
for (const [index, entry] of journal.entries.entries()) {
|
|
957
|
+
const target = path.resolve(String(entry.file || ""));
|
|
958
|
+
const expectedTemporary = `${target}.${journal.transaction_id}.${index}.tmp`;
|
|
959
|
+
const expectedBackup = `${target}.${journal.transaction_id}.${index}.bak`;
|
|
960
|
+
if ((!allowedFiles.has(target)
|
|
961
|
+
&& !isInside(target, files.eventShards)
|
|
962
|
+
&& !isInside(target, files.historySnapshots)
|
|
963
|
+
&& !isInside(target, path.join(files.delivery, "reviews", "prototype-pages"))
|
|
964
|
+
&& !isInside(target, managerReceipts))
|
|
965
|
+
|| path.resolve(String(entry.temporary || "")) !== path.resolve(expectedTemporary)
|
|
966
|
+
|| path.resolve(String(entry.backup || "")) !== path.resolve(expectedBackup)
|
|
967
|
+
|| typeof entry.had_original !== "boolean"
|
|
968
|
+
|| !/^[a-f0-9]{64}$/.test(entry.new_checksum || "")
|
|
969
|
+
|| (entry.had_original && !/^[a-f0-9]{64}$/.test(entry.original_checksum || ""))) {
|
|
970
|
+
throw new Error("Formal state transaction journal contains an invalid file entry.");
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return journal;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function removeFileWithChecksum(file, expectedChecksum, label) {
|
|
977
|
+
if (!fs.existsSync(file)) return;
|
|
978
|
+
if (!fs.statSync(file).isFile() || fileChecksum(file) !== expectedChecksum) {
|
|
979
|
+
throw new Error(`Formal state transaction ${label} checksum mismatch: ${file}`);
|
|
980
|
+
}
|
|
981
|
+
fs.rmSync(file, { force: true });
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function rollbackPreparedTransaction(files, journal) {
|
|
985
|
+
for (const entry of [...journal.entries].reverse()) {
|
|
986
|
+
if (fs.existsSync(entry.backup)) {
|
|
987
|
+
if (!fs.statSync(entry.backup).isFile() || fileChecksum(entry.backup) !== entry.original_checksum) {
|
|
988
|
+
throw new Error(`Formal state transaction backup checksum mismatch: ${entry.backup}`);
|
|
989
|
+
}
|
|
990
|
+
if (fs.existsSync(entry.file)) {
|
|
991
|
+
removeFileWithChecksum(entry.file, entry.new_checksum, "new target");
|
|
992
|
+
}
|
|
993
|
+
fs.renameSync(entry.backup, entry.file);
|
|
994
|
+
} else if (entry.had_original) {
|
|
995
|
+
if (!fs.existsSync(entry.file) || !fs.statSync(entry.file).isFile()
|
|
996
|
+
|| fileChecksum(entry.file) !== entry.original_checksum) {
|
|
997
|
+
throw new Error(`Formal state transaction original target is unavailable: ${entry.file}`);
|
|
998
|
+
}
|
|
999
|
+
} else {
|
|
1000
|
+
removeFileWithChecksum(entry.file, entry.new_checksum, "new target");
|
|
1001
|
+
}
|
|
1002
|
+
removeFileWithChecksum(entry.temporary, entry.new_checksum, "temporary file");
|
|
1003
|
+
}
|
|
1004
|
+
fs.rmSync(files.transactionJournal, { force: true });
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function finalizeCommittedTransaction(files, journal) {
|
|
1008
|
+
for (const entry of journal.entries) {
|
|
1009
|
+
if (!fs.existsSync(entry.file) || !fs.statSync(entry.file).isFile()
|
|
1010
|
+
|| fileChecksum(entry.file) !== entry.new_checksum) {
|
|
1011
|
+
throw new Error(`Committed formal state transaction target is invalid: ${entry.file}`);
|
|
1012
|
+
}
|
|
1013
|
+
removeFileWithChecksum(entry.temporary, entry.new_checksum, "temporary file");
|
|
1014
|
+
if (entry.had_original) {
|
|
1015
|
+
removeFileWithChecksum(entry.backup, entry.original_checksum, "backup file");
|
|
1016
|
+
} else if (fs.existsSync(entry.backup)) {
|
|
1017
|
+
throw new Error(`Unexpected formal state transaction backup: ${entry.backup}`);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
fs.rmSync(files.transactionJournal, { force: true });
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function recoverPendingTransaction(files) {
|
|
1024
|
+
if (!fs.existsSync(files.transactionJournal)) return;
|
|
1025
|
+
const journal = validateTransactionJournal(
|
|
1026
|
+
files,
|
|
1027
|
+
JSON.parse(fs.readFileSync(files.transactionJournal, "utf8")),
|
|
1028
|
+
);
|
|
1029
|
+
if (journal.phase === "committed") finalizeCommittedTransaction(files, journal);
|
|
1030
|
+
else rollbackPreparedTransaction(files, journal);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function assertNoPendingTransaction(files) {
|
|
1034
|
+
if (fs.existsSync(files.transactionJournal)) {
|
|
1035
|
+
throw new Error("Formal state transaction is incomplete; run rebuild or retry the mutation to recover it.");
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function withReadableFormalState(files, action) {
|
|
1040
|
+
return withLock(files, "formal-state-read", action, { recover: false });
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function withZeroWriteFormalState(files, action) {
|
|
1044
|
+
const assertStable = () => {
|
|
1045
|
+
assertNoPendingTransaction(files);
|
|
1046
|
+
const lockFile = path.join(files.lockRoot, "formal-state.lock");
|
|
1047
|
+
if (fs.existsSync(lockFile)) {
|
|
1048
|
+
throw new Error("Formal state is locked; zero-write inspection cannot continue.");
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
assertStable();
|
|
1052
|
+
try {
|
|
1053
|
+
return action();
|
|
1054
|
+
} finally {
|
|
1055
|
+
assertStable();
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
export function withFormalStateReadLock(projectRoot, action) {
|
|
1060
|
+
if (typeof action !== "function") throw new Error("Formal state read lock requires an action.");
|
|
1061
|
+
return withReadableFormalState(statePaths(projectRoot), action);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function atomicWriteSet(files, entries, options = {}, transactionId = null) {
|
|
1065
|
+
transactionId = transactionId || `${process.pid}.${Date.now()}.${crypto.randomUUID()}`;
|
|
1066
|
+
const targets = entries.map(({ file }) => path.resolve(file));
|
|
1067
|
+
if (new Set(targets).size !== targets.length) {
|
|
1068
|
+
throw new Error("Formal state transaction cannot write the same target more than once.");
|
|
1069
|
+
}
|
|
1070
|
+
const staged = entries.map(({ file, content }, index) => ({
|
|
1071
|
+
file: path.resolve(file),
|
|
1072
|
+
content,
|
|
1073
|
+
temporary: `${path.resolve(file)}.${transactionId}.${index}.tmp`,
|
|
1074
|
+
backup: `${path.resolve(file)}.${transactionId}.${index}.bak`,
|
|
1075
|
+
had_original: false,
|
|
1076
|
+
original_checksum: null,
|
|
1077
|
+
new_checksum: sha256(content),
|
|
1078
|
+
}));
|
|
1079
|
+
for (const entry of staged) {
|
|
1080
|
+
fs.mkdirSync(path.dirname(entry.file), { recursive: true });
|
|
1081
|
+
if (fs.existsSync(entry.file) && !fs.statSync(entry.file).isFile()) {
|
|
1082
|
+
throw new Error(`Transactional write target must be a file: ${entry.file}`);
|
|
1083
|
+
}
|
|
1084
|
+
entry.had_original = fs.existsSync(entry.file);
|
|
1085
|
+
entry.original_checksum = entry.had_original ? fileChecksum(entry.file) : null;
|
|
1086
|
+
}
|
|
1087
|
+
const journal = {
|
|
1088
|
+
schema_version: 1,
|
|
1089
|
+
transaction_id: transactionId,
|
|
1090
|
+
phase: "prepared",
|
|
1091
|
+
entries: staged.map(({ content: ignored, ...entry }) => entry),
|
|
1092
|
+
};
|
|
1093
|
+
try {
|
|
1094
|
+
for (const entry of staged) durableWrite(entry.temporary, entry.content);
|
|
1095
|
+
writeTransactionJournal(files, journal);
|
|
1096
|
+
for (const [index, entry] of staged.entries()) {
|
|
1097
|
+
if (entry.had_original) fs.renameSync(entry.file, entry.backup);
|
|
1098
|
+
fs.renameSync(entry.temporary, entry.file);
|
|
1099
|
+
if (typeof options.onTransactionCommit === "function") {
|
|
1100
|
+
options.onTransactionCommit({ index, file: entry.file, transaction_id: transactionId });
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
journal.phase = "committed";
|
|
1104
|
+
writeTransactionJournal(files, journal);
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
if (fs.existsSync(files.transactionJournal)) {
|
|
1107
|
+
try {
|
|
1108
|
+
recoverPendingTransaction(files);
|
|
1109
|
+
} catch (recoveryError) {
|
|
1110
|
+
throw new AggregateError([error, recoveryError], "Formal state transaction failed and requires recovery.");
|
|
1111
|
+
}
|
|
1112
|
+
} else {
|
|
1113
|
+
for (const entry of staged) fs.rmSync(entry.temporary, { force: true });
|
|
1114
|
+
}
|
|
1115
|
+
throw error;
|
|
1116
|
+
}
|
|
1117
|
+
finalizeCommittedTransaction(files, journal);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function eventLogContent(files, events) {
|
|
1121
|
+
if (fs.existsSync(files.events) && !fs.statSync(files.events).isFile()) {
|
|
1122
|
+
throw new Error(`Transactional write target must be a file: ${files.events}`);
|
|
1123
|
+
}
|
|
373
1124
|
const existing = fs.existsSync(files.events) ? fs.readFileSync(files.events, "utf8") : "";
|
|
374
1125
|
const prefix = existing && !existing.endsWith("\n") ? `${existing}\n` : existing;
|
|
375
|
-
|
|
1126
|
+
return `${prefix}${events.map((event) => JSON.stringify(event)).join("\n")}\n`;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function unsignedRecordChecksum(record, yaml) {
|
|
1130
|
+
const { record_checksum: ignored, ...unsigned } = record;
|
|
1131
|
+
return sha256(yaml.stringify(unsigned));
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function previousStreamChecksum(files, stream, beforeRevision, options) {
|
|
1135
|
+
const directory = path.join(files.eventShards, stream);
|
|
1136
|
+
const checksumFromAnchor = () => {
|
|
1137
|
+
if (beforeRevision === 0) return null;
|
|
1138
|
+
const trusted = readLatestHistoryAnchor(files, options);
|
|
1139
|
+
const metadata = trusted?.anchor.streams?.[stream];
|
|
1140
|
+
if (!metadata || metadata.revision !== beforeRevision) {
|
|
1141
|
+
throw new Error(
|
|
1142
|
+
`Formal event chain head is unavailable for ${stream} revision ${beforeRevision}.`,
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
return metadata.record_checksum;
|
|
1146
|
+
};
|
|
1147
|
+
if (!fs.existsSync(directory)) return checksumFromAnchor();
|
|
1148
|
+
if (!fs.statSync(directory).isDirectory()) {
|
|
1149
|
+
throw new Error(`Formal event stream must be a directory: ${stream}`);
|
|
1150
|
+
}
|
|
1151
|
+
const names = fs.readdirSync(directory).filter((name) => /\.ya?ml$/i.test(name)).sort();
|
|
1152
|
+
if (!names.length) return checksumFromAnchor();
|
|
1153
|
+
const previous = readYaml(path.join(directory, names.at(-1)), options.yaml, `formal event shard ${stream}/${names.at(-1)}`);
|
|
1154
|
+
if (previous.revision !== beforeRevision) {
|
|
1155
|
+
throw new Error(`Formal event chain revision mismatch for ${stream}: expected ${beforeRevision}, actual ${previous.revision}.`);
|
|
1156
|
+
}
|
|
1157
|
+
return previous.record_checksum || unsignedRecordChecksum(previous, options.yaml);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function artifactRegistryOperations(registry, artifacts, handoffs = []) {
|
|
1161
|
+
const {
|
|
1162
|
+
artifacts: ignoredArtifacts,
|
|
1163
|
+
handoffs: ignoredHandoffs,
|
|
1164
|
+
...metadata
|
|
1165
|
+
} = stableValue(registry);
|
|
1166
|
+
return [
|
|
1167
|
+
{ action: "set-registry-metadata", fields: metadata },
|
|
1168
|
+
...artifacts.map((artifact) => ({
|
|
1169
|
+
action: "upsert-artifact",
|
|
1170
|
+
artifact_id: artifact.artifact_id || artifact.id,
|
|
1171
|
+
artifact: stableValue(artifact),
|
|
1172
|
+
})),
|
|
1173
|
+
...handoffs.map((handoff) => ({
|
|
1174
|
+
action: "append-artifact-handoff",
|
|
1175
|
+
handoff: stableValue(handoff),
|
|
1176
|
+
})),
|
|
1177
|
+
];
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function artifactRegistryTransaction(event, registry, beforeRevision, artifacts, handoffs = []) {
|
|
1181
|
+
const subject = event.artifact_id || (event.artifact_ids || []).join(",") || event.type;
|
|
1182
|
+
return {
|
|
1183
|
+
schema_version: 2,
|
|
1184
|
+
before_revision: beforeRevision,
|
|
1185
|
+
after_revision: registry.revision,
|
|
1186
|
+
idempotency_key: `artifact-registry:${event.type}:${subject}:r${beforeRevision}-${registry.revision}`,
|
|
1187
|
+
operations: artifactRegistryOperations(registry, artifacts, handoffs),
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function workflowStateOperations(state, scopeName, beforeRevision) {
|
|
1192
|
+
const { development_state: ignoredDevelopment, release_state: ignoredRelease, ...metadata } = stableValue(state);
|
|
1193
|
+
const scopes = beforeRevision === 0 ? ["development", "release"] : [scopeName];
|
|
1194
|
+
return [
|
|
1195
|
+
{ action: "set-workflow-metadata", fields: metadata },
|
|
1196
|
+
...scopes.map((name) => ({
|
|
1197
|
+
action: "replace-scope",
|
|
1198
|
+
scope: name,
|
|
1199
|
+
value: stableValue(scopeState(state, name)),
|
|
1200
|
+
})),
|
|
1201
|
+
];
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function workflowStateTransaction(event, state, scopeName, beforeRevision) {
|
|
1205
|
+
const subject = event.gate_id || event.node || event.artifact_id || event.manifest_id
|
|
1206
|
+
|| event.warning_id || event.iteration_id || event.type;
|
|
1207
|
+
return {
|
|
1208
|
+
schema_version: 2,
|
|
1209
|
+
before_revision: beforeRevision,
|
|
1210
|
+
after_revision: state.revision,
|
|
1211
|
+
idempotency_key: `workflow-state:${event.type}:${scopeName}:${subject}:r${beforeRevision}-${state.revision}`,
|
|
1212
|
+
operations: workflowStateOperations(state, scopeName, beforeRevision),
|
|
1213
|
+
};
|
|
376
1214
|
}
|
|
377
1215
|
|
|
378
|
-
function
|
|
379
|
-
const
|
|
1216
|
+
function prepareStreamMutation(files, mutation, options, transactionId) {
|
|
1217
|
+
const { stream, file, value, event, transaction = null } = mutation;
|
|
1218
|
+
const serialized = options.yaml.stringify(transaction ? stableValue(value) : value);
|
|
380
1219
|
const eventId = crypto.randomUUID();
|
|
381
1220
|
const eventRecord = {
|
|
382
1221
|
event_id: eventId,
|
|
@@ -386,51 +1225,565 @@ function writeStream(files, stream, file, value, event, options) {
|
|
|
386
1225
|
...event,
|
|
387
1226
|
after_checksum: sha256(serialized),
|
|
388
1227
|
};
|
|
1228
|
+
const shardRecord = transaction
|
|
1229
|
+
? {
|
|
1230
|
+
...eventRecord,
|
|
1231
|
+
transaction_id: transactionId,
|
|
1232
|
+
...transaction,
|
|
1233
|
+
previous_checksum: previousStreamChecksum(files, stream, transaction.before_revision, options),
|
|
1234
|
+
}
|
|
1235
|
+
: { ...eventRecord, after_value: value };
|
|
1236
|
+
const record = {
|
|
1237
|
+
...shardRecord,
|
|
1238
|
+
record_checksum: unsignedRecordChecksum(shardRecord, options.yaml),
|
|
1239
|
+
};
|
|
389
1240
|
const shardFile = path.join(files.eventShards, stream, `${String(value.revision).padStart(6, "0")}-${eventId}.yaml`);
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
1241
|
+
const indexedEvent = transaction ? record : eventRecord;
|
|
1242
|
+
return {
|
|
1243
|
+
indexedEvent,
|
|
1244
|
+
writes: [
|
|
1245
|
+
{ file: shardFile, content: options.yaml.stringify(record) },
|
|
1246
|
+
{ file, content: serialized },
|
|
1247
|
+
],
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function commitFormalTransaction(files, mutations, extraWrites, options) {
|
|
1252
|
+
if (!Array.isArray(mutations) || !mutations.length) {
|
|
1253
|
+
throw new Error("Formal state transaction requires at least one stream mutation.");
|
|
1254
|
+
}
|
|
1255
|
+
const transactionId = `TXN-${crypto.randomUUID()}`;
|
|
1256
|
+
const prepared = mutations.map((mutation) =>
|
|
1257
|
+
prepareStreamMutation(files, mutation, options, transactionId));
|
|
1258
|
+
const managerWrites = typeof options.managerTransactionWriteFactory === "function"
|
|
1259
|
+
? options.managerTransactionWriteFactory({
|
|
1260
|
+
transaction_id: transactionId,
|
|
1261
|
+
mutations: mutations.map((mutation) => ({
|
|
1262
|
+
stream: mutation.stream,
|
|
1263
|
+
event: mutation.event,
|
|
1264
|
+
value: mutation.value,
|
|
1265
|
+
})),
|
|
1266
|
+
})
|
|
1267
|
+
: [];
|
|
1268
|
+
if (!Array.isArray(managerWrites)) {
|
|
1269
|
+
throw new Error("Manager transaction write factory must return an array.");
|
|
1270
|
+
}
|
|
1271
|
+
const managerEventsFile = path.resolve(
|
|
1272
|
+
files.root, ".workflow", "control", "events", "workflow-manager-events.jsonl",
|
|
1273
|
+
);
|
|
1274
|
+
const managerReceiptsRoot = path.resolve(files.root, ".workflow", "control", "receipts");
|
|
1275
|
+
for (const write of managerWrites) {
|
|
1276
|
+
const target = path.resolve(String(write?.file || ""));
|
|
1277
|
+
const receiptRelative = path.relative(managerReceiptsRoot, target);
|
|
1278
|
+
const isReceipt = Boolean(receiptRelative)
|
|
1279
|
+
&& !receiptRelative.startsWith("..")
|
|
1280
|
+
&& !path.isAbsolute(receiptRelative)
|
|
1281
|
+
&& path.extname(target) === ".json";
|
|
1282
|
+
if (target !== managerEventsFile && !isReceipt) {
|
|
1283
|
+
throw new Error("Manager transaction writes are limited to its Receipt and Event stores.");
|
|
1284
|
+
}
|
|
1285
|
+
if (typeof write.content !== "string" && !Buffer.isBuffer(write.content)) {
|
|
1286
|
+
throw new Error("Manager transaction write content is invalid.");
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
atomicWriteSet(files, [
|
|
1290
|
+
...prepared.flatMap(({ writes }) => writes.slice(0, 1)),
|
|
1291
|
+
{ file: files.events, content: eventLogContent(files, prepared.map(({ indexedEvent }) => indexedEvent)) },
|
|
1292
|
+
...prepared.flatMap(({ writes }) => writes.slice(1)),
|
|
1293
|
+
...extraWrites,
|
|
1294
|
+
...managerWrites,
|
|
1295
|
+
], options, transactionId);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function applyArtifactRegistryOperations(value, record) {
|
|
1299
|
+
const registry = normalizeArtifactRegistry(structuredClone(value || { artifacts: [] }));
|
|
1300
|
+
for (const operation of record.operations || []) {
|
|
1301
|
+
if (operation.action === "set-registry-metadata") {
|
|
1302
|
+
if (!operation.fields || typeof operation.fields !== "object" || Array.isArray(operation.fields)) {
|
|
1303
|
+
throw new Error(`Artifact registry metadata operation is invalid at revision ${record.revision}.`);
|
|
1304
|
+
}
|
|
1305
|
+
Object.assign(registry, operation.fields);
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
if (operation.action === "upsert-artifact") {
|
|
1309
|
+
if (!operation.artifact_id || !operation.artifact || typeof operation.artifact !== "object") {
|
|
1310
|
+
throw new Error(`Artifact registry upsert operation is invalid at revision ${record.revision}.`);
|
|
1311
|
+
}
|
|
1312
|
+
const index = registry.artifacts.findIndex((item) => (item.artifact_id || item.id) === operation.artifact_id);
|
|
1313
|
+
if (index === -1) registry.artifacts.push(operation.artifact);
|
|
1314
|
+
else registry.artifacts[index] = operation.artifact;
|
|
1315
|
+
continue;
|
|
1316
|
+
}
|
|
1317
|
+
if (operation.action === "append-artifact-handoff") {
|
|
1318
|
+
if (!operation.handoff || typeof operation.handoff !== "object"
|
|
1319
|
+
|| !Array.isArray(operation.handoff.artifact_ids)
|
|
1320
|
+
|| !operation.handoff.artifact_ids.length) {
|
|
1321
|
+
throw new Error(`Artifact handoff operation is invalid at revision ${record.revision}.`);
|
|
1322
|
+
}
|
|
1323
|
+
registry.handoffs = [
|
|
1324
|
+
...(Array.isArray(registry.handoffs) ? registry.handoffs : []),
|
|
1325
|
+
stableValue(operation.handoff),
|
|
1326
|
+
];
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
throw new Error(`Unsupported artifact registry operation at revision ${record.revision}: ${operation.action || "missing"}.`);
|
|
1330
|
+
}
|
|
1331
|
+
registry.revision = record.after_revision;
|
|
1332
|
+
return stableValue(registry);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function applyWorkflowStateOperations(value, record) {
|
|
1336
|
+
const state = structuredClone(value || {});
|
|
1337
|
+
for (const operation of record.operations || []) {
|
|
1338
|
+
if (operation.action === "set-workflow-metadata") {
|
|
1339
|
+
if (!operation.fields || typeof operation.fields !== "object" || Array.isArray(operation.fields)) {
|
|
1340
|
+
throw new Error(`Workflow state metadata operation is invalid at revision ${record.revision}.`);
|
|
1341
|
+
}
|
|
1342
|
+
Object.assign(state, operation.fields);
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1345
|
+
if (operation.action === "replace-scope") {
|
|
1346
|
+
if (!new Set(["development", "release"]).has(operation.scope)
|
|
1347
|
+
|| !operation.value || typeof operation.value !== "object" || Array.isArray(operation.value)) {
|
|
1348
|
+
throw new Error(`Workflow state scope operation is invalid at revision ${record.revision}.`);
|
|
1349
|
+
}
|
|
1350
|
+
state[operation.scope === "release" ? "release_state" : "development_state"] = stableValue(operation.value);
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
throw new Error(`Unsupported workflow state operation at revision ${record.revision}: ${operation.action || "missing"}.`);
|
|
1354
|
+
}
|
|
1355
|
+
state.revision = record.after_revision;
|
|
1356
|
+
return stableValue(state);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function replayStreamRecords(stream, records, options, base = {}) {
|
|
1360
|
+
const ordered = [...records].sort((left, right) => left.revision - right.revision);
|
|
1361
|
+
let value = base.value ? structuredClone(base.value) : null;
|
|
1362
|
+
let previousChecksum = base.recordChecksum ?? null;
|
|
1363
|
+
let previousRevision = base.revision ?? 0;
|
|
1364
|
+
for (const record of ordered) {
|
|
1365
|
+
if (record.revision !== previousRevision + 1) {
|
|
1366
|
+
throw new Error(`Formal event shard revision gap: ${stream}/${record.revision}; expected ${previousRevision + 1}.`);
|
|
1367
|
+
}
|
|
1368
|
+
if (record.after_value) {
|
|
1369
|
+
value = record.after_value;
|
|
1370
|
+
} else if (record.schema_version === 2 && Array.isArray(record.operations)) {
|
|
1371
|
+
if (record.before_revision !== record.revision - 1 || record.after_revision !== record.revision) {
|
|
1372
|
+
throw new Error(`Formal event shard revision metadata mismatch: ${stream}/${record.revision}.`);
|
|
1373
|
+
}
|
|
1374
|
+
if (record.previous_checksum !== previousChecksum) {
|
|
1375
|
+
throw new Error(`Formal event shard checksum chain mismatch: ${stream}/${record.revision}.`);
|
|
1376
|
+
}
|
|
1377
|
+
const applyOperations = FORMAL_STREAMS.get(stream)?.applyOperations;
|
|
1378
|
+
if (!applyOperations) throw new Error(`Unsupported formal event stream: ${stream}.`);
|
|
1379
|
+
value = applyOperations(value, record);
|
|
1380
|
+
} else {
|
|
1381
|
+
throw new Error(`Formal event shard has no replayable value: ${stream}/${record.revision}.`);
|
|
1382
|
+
}
|
|
1383
|
+
const replayedChecksum = sha256(options.yaml.stringify(value));
|
|
1384
|
+
if (replayedChecksum !== record.after_checksum) {
|
|
1385
|
+
throw new Error(
|
|
1386
|
+
`Formal event shard checksum mismatch: ${stream}/${record.revision}; expected ${record.after_checksum}, replayed ${replayedChecksum}.`,
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
if (record.record_checksum && unsignedRecordChecksum(record, options.yaml) !== record.record_checksum) {
|
|
1390
|
+
throw new Error(`Formal event shard record checksum mismatch: ${stream}/${record.revision}.`);
|
|
1391
|
+
}
|
|
1392
|
+
previousChecksum = record.record_checksum || unsignedRecordChecksum(record, options.yaml);
|
|
1393
|
+
previousRevision = record.revision;
|
|
1394
|
+
}
|
|
1395
|
+
return value;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function validateEventShardRecord(record, stream, name) {
|
|
1399
|
+
const expectedName = `${String(record.revision).padStart(6, "0")}-${record.event_id}.yaml`;
|
|
1400
|
+
if (record.stream !== stream || !Number.isInteger(record.revision) || !record.event_id
|
|
1401
|
+
|| name !== expectedName || (!record.after_value && !Array.isArray(record.operations))) {
|
|
1402
|
+
throw new Error(`Formal event shard is invalid: ${stream}/${name}`);
|
|
1403
|
+
}
|
|
1404
|
+
return record;
|
|
393
1405
|
}
|
|
394
1406
|
|
|
395
|
-
function
|
|
1407
|
+
function readEventShardRecord(files, options, stream, name) {
|
|
1408
|
+
return validateEventShardRecord(readYaml(
|
|
1409
|
+
path.join(files.eventShards, stream, name),
|
|
1410
|
+
options.yaml,
|
|
1411
|
+
`formal event shard ${stream}/${name}`,
|
|
1412
|
+
), stream, name);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function readEventShards(files, options, afterRevisions = null, indexedEvents = []) {
|
|
396
1416
|
if (!fs.existsSync(files.eventShards)) return [];
|
|
397
1417
|
const records = [];
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
1418
|
+
if (afterRevisions !== null) {
|
|
1419
|
+
for (const event of indexedEvents) {
|
|
1420
|
+
if (!Object.hasOwn(afterRevisions, event.stream)
|
|
1421
|
+
|| !Number.isInteger(event.revision)
|
|
1422
|
+
|| event.revision <= Number(afterRevisions[event.stream] || 0)) {
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
const eventId = String(event.event_id || "");
|
|
1426
|
+
if (!eventId || eventId.includes("/") || eventId.includes("\\")) {
|
|
1427
|
+
throw new Error(`Formal audit event ID is invalid: ${event.stream}/${event.revision}.`);
|
|
1428
|
+
}
|
|
1429
|
+
const name = `${String(event.revision).padStart(6, "0")}-${eventId}.yaml`;
|
|
1430
|
+
records.push(readEventShardRecord(files, options, event.stream, name));
|
|
1431
|
+
}
|
|
1432
|
+
} else {
|
|
1433
|
+
for (const stream of fs.readdirSync(files.eventShards).sort()) {
|
|
1434
|
+
const directory = path.join(files.eventShards, stream);
|
|
1435
|
+
if (!fs.statSync(directory).isDirectory()) continue;
|
|
1436
|
+
for (const name of fs.readdirSync(directory).filter((item) => /\.ya?ml$/i.test(item)).sort()) {
|
|
1437
|
+
records.push(readEventShardRecord(files, options, stream, name));
|
|
405
1438
|
}
|
|
406
|
-
const checksum = sha256(options.yaml.stringify(record.after_value));
|
|
407
|
-
if (checksum !== record.after_checksum) throw new Error(`Formal event shard checksum mismatch: ${stream}/${name}`);
|
|
408
|
-
records.push(record);
|
|
409
1439
|
}
|
|
410
1440
|
}
|
|
411
|
-
|
|
1441
|
+
const sorted = records.sort((left, right) =>
|
|
412
1442
|
String(left.occurred_at).localeCompare(String(right.occurred_at))
|
|
413
1443
|
|| left.stream.localeCompare(right.stream)
|
|
414
1444
|
|| left.revision - right.revision);
|
|
1445
|
+
for (const stream of new Set(sorted.map((record) => record.stream))) {
|
|
1446
|
+
if (afterRevisions === null) {
|
|
1447
|
+
replayStreamRecords(stream, sorted.filter((record) => record.stream === stream), options);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
return sorted;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function readLatestHistoryAnchor(files, options) {
|
|
1454
|
+
const verified = readVerifiedHistoryAnchor({
|
|
1455
|
+
historySnapshots: files.historySnapshots,
|
|
1456
|
+
events: files.events,
|
|
1457
|
+
streams: FORMAL_STREAMS,
|
|
1458
|
+
readStructured: (file, label) => readYaml(file, options.yaml, label),
|
|
1459
|
+
stringify: options.yaml.stringify,
|
|
1460
|
+
});
|
|
1461
|
+
return verified ? { anchor: verified.anchor, snapshots: verified.snapshots } : null;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
function portableReference(files, target) {
|
|
1465
|
+
const relative = path.relative(files.root, target);
|
|
1466
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
1467
|
+
throw new Error(`Version archive reference is outside the project: ${target}.`);
|
|
1468
|
+
}
|
|
1469
|
+
return relative.replaceAll("\\", "/");
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function versionArchiveReferences(files, flags, options) {
|
|
1473
|
+
const iterationId = requiredFlag(flags, "--iteration-id");
|
|
1474
|
+
const requestedCandidateId = optionalFlag(flags, "--candidate-id");
|
|
1475
|
+
return withReadableFormalState(files, () => {
|
|
1476
|
+
const trusted = readLatestHistoryAnchor(files, options);
|
|
1477
|
+
if (!trusted) throw new Error(`Version archive requires a trusted history anchor for ${iterationId}.`);
|
|
1478
|
+
const scope = trusted.snapshots["workflow-state"]?.development_state;
|
|
1479
|
+
if (scope?.scope_id !== iterationId) {
|
|
1480
|
+
throw new Error(`Latest trusted history anchor does not contain version ${iterationId}.`);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
const manifests = Array.isArray(scope.scope_manifests) ? scope.scope_manifests : [];
|
|
1484
|
+
const manifestIndex = manifests.findIndex((item) => item?.manifest_id === scope.current_scope_manifest_id);
|
|
1485
|
+
if (manifestIndex < 0) {
|
|
1486
|
+
throw new Error(`Version archive requires the anchored scope manifest for ${iterationId}.`);
|
|
1487
|
+
}
|
|
1488
|
+
const manifest = manifests[manifestIndex];
|
|
1489
|
+
const anchoredRegistry = trusted.snapshots["artifact-registry"] || { artifacts: [] };
|
|
1490
|
+
const artifacts = anchoredRegistry.artifacts || [];
|
|
1491
|
+
const candidateId = scope.latest_candidate_id || null;
|
|
1492
|
+
if (!candidateId) {
|
|
1493
|
+
throw new Error(`Version archive requires an anchored candidate for ${iterationId}.`);
|
|
1494
|
+
}
|
|
1495
|
+
if (requestedCandidateId && requestedCandidateId !== candidateId) {
|
|
1496
|
+
throw new Error(
|
|
1497
|
+
`Requested candidate ${requestedCandidateId} does not match anchored selected candidate ${candidateId}.`,
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
1500
|
+
const candidateIndex = artifacts.findIndex((item) =>
|
|
1501
|
+
(item.artifact_id || item.id) === candidateId);
|
|
1502
|
+
if (candidateIndex < 0) {
|
|
1503
|
+
throw new Error(`Version archive candidate is not present in the trusted anchor: ${candidateId}.`);
|
|
1504
|
+
}
|
|
1505
|
+
const candidate = artifacts[candidateIndex];
|
|
1506
|
+
if (projectedArtifactStatus(anchoredRegistry, candidate) !== "immutable") {
|
|
1507
|
+
throw new Error(`Anchored selected candidate is not immutable: ${candidateId}.`);
|
|
1508
|
+
}
|
|
1509
|
+
const anchorDirectory = path.join(files.historySnapshots, trusted.anchor.anchor_id);
|
|
1510
|
+
const workflowSnapshotFile = path.join(anchorDirectory, FORMAL_STREAMS.get("workflow-state").snapshotFile);
|
|
1511
|
+
const registrySnapshotFile = path.join(anchorDirectory, FORMAL_STREAMS.get("artifact-registry").snapshotFile);
|
|
1512
|
+
const workflowRef = portableReference(files, workflowSnapshotFile);
|
|
1513
|
+
const registryRef = portableReference(files, registrySnapshotFile);
|
|
1514
|
+
const gateDecisions = Array.isArray(scope.gate_decisions) ? scope.gate_decisions : [];
|
|
1515
|
+
return {
|
|
1516
|
+
command: "snapshot references",
|
|
1517
|
+
references: {
|
|
1518
|
+
history_anchor: {
|
|
1519
|
+
anchor_id: trusted.anchor.anchor_id,
|
|
1520
|
+
anchor_checksum: trusted.anchor.anchor_checksum,
|
|
1521
|
+
ref: portableReference(files, path.join(anchorDirectory, "anchor.yaml")),
|
|
1522
|
+
workflow_state_checksum: trusted.anchor.streams["workflow-state"].view_checksum,
|
|
1523
|
+
artifact_registry_checksum: trusted.anchor.streams["artifact-registry"].view_checksum,
|
|
1524
|
+
},
|
|
1525
|
+
scope: {
|
|
1526
|
+
manifest_id: manifest.manifest_id,
|
|
1527
|
+
manifest_checksum: manifest.manifest_checksum,
|
|
1528
|
+
ref: `${workflowRef}#/development_state/scope_manifests/${manifestIndex}`,
|
|
1529
|
+
},
|
|
1530
|
+
candidate: {
|
|
1531
|
+
artifact_id: candidate.artifact_id || candidate.id,
|
|
1532
|
+
checksum_algorithm: candidate.checksum_algorithm || "sha256",
|
|
1533
|
+
checksum: candidate.checksum,
|
|
1534
|
+
ref: `${registryRef}#/artifacts/${candidateIndex}`,
|
|
1535
|
+
},
|
|
1536
|
+
gates: gateDecisions.map((decision, index) => ({
|
|
1537
|
+
gate_id: decision.gate_id,
|
|
1538
|
+
decision: decision.decision,
|
|
1539
|
+
actor: decision.actor,
|
|
1540
|
+
decided_at: decision.decided_at,
|
|
1541
|
+
checksum_algorithm: "sha256",
|
|
1542
|
+
checksum: sha256(JSON.stringify(stableValue(decision))),
|
|
1543
|
+
ref: `${workflowRef}#/development_state/gate_decisions/${index}`,
|
|
1544
|
+
})),
|
|
1545
|
+
},
|
|
1546
|
+
};
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function readArtifactRetentionReceipts(files, options) {
|
|
1551
|
+
if (!fs.existsSync(files.artifactRetention)) return [];
|
|
1552
|
+
return fs.readdirSync(files.artifactRetention)
|
|
1553
|
+
.filter((name) => /\.ya?ml$/i.test(name))
|
|
1554
|
+
.sort()
|
|
1555
|
+
.map((name) => {
|
|
1556
|
+
const receipt = readYaml(
|
|
1557
|
+
path.join(files.artifactRetention, name),
|
|
1558
|
+
options.yaml,
|
|
1559
|
+
`artifact retention receipt ${name}`,
|
|
1560
|
+
);
|
|
1561
|
+
const expectedName = `${historyAnchorId(receipt.anchor_id)}.yaml`;
|
|
1562
|
+
if (name !== expectedName
|
|
1563
|
+
|| receipt.schema_version !== 1
|
|
1564
|
+
|| receipt.stream !== "artifact-registry"
|
|
1565
|
+
|| !/^[a-f0-9]{64}$/.test(receipt.anchor_checksum || "")
|
|
1566
|
+
|| !Number.isInteger(receipt.pruned_through_revision)
|
|
1567
|
+
|| receipt.pruned_through_revision < 0
|
|
1568
|
+
|| receipt.retained_from_revision !== receipt.pruned_through_revision + 1
|
|
1569
|
+
|| !Number.isInteger(receipt.deleted_shard_count)
|
|
1570
|
+
|| receipt.deleted_shard_count < 1
|
|
1571
|
+
|| !/^[a-f0-9]{64}$/.test(receipt.deleted_shards_checksum || "")
|
|
1572
|
+
|| structuredDocumentChecksum(receipt, "receipt_checksum", options.yaml.stringify)
|
|
1573
|
+
!== receipt.receipt_checksum) {
|
|
1574
|
+
throw new Error(`Artifact retention receipt is invalid: ${name}.`);
|
|
1575
|
+
}
|
|
1576
|
+
const anchorFile = path.join(files.historySnapshots, receipt.anchor_id, "anchor.yaml");
|
|
1577
|
+
const anchor = readYaml(anchorFile, options.yaml, `retention anchor ${receipt.anchor_id}`);
|
|
1578
|
+
if (anchor.anchor_checksum !== receipt.anchor_checksum
|
|
1579
|
+
|| structuredDocumentChecksum(anchor, "anchor_checksum", options.yaml.stringify)
|
|
1580
|
+
!== anchor.anchor_checksum
|
|
1581
|
+
|| anchor.streams?.["artifact-registry"]?.revision !== receipt.pruned_through_revision) {
|
|
1582
|
+
throw new Error(`Artifact retention receipt does not match its trusted anchor: ${name}.`);
|
|
1583
|
+
}
|
|
1584
|
+
return receipt;
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
function createFormalSnapshot(files, flags, options) {
|
|
1589
|
+
return withLock(files, "formal-state-snapshot", () => {
|
|
1590
|
+
const project = readYaml(files.project, options.yaml, "project.yaml");
|
|
1591
|
+
const rawState = readYaml(files.workflowState, options.yaml, "workflow-state.yaml");
|
|
1592
|
+
const rawRegistry = readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml");
|
|
1593
|
+
const views = formalStreamViews(files, {
|
|
1594
|
+
"workflow-state": rawState,
|
|
1595
|
+
"artifact-registry": rawRegistry,
|
|
1596
|
+
});
|
|
1597
|
+
const state = views.get("workflow-state").value;
|
|
1598
|
+
const registry = views.get("artifact-registry").value;
|
|
1599
|
+
const streamRevisions = stableValue(Object.fromEntries(
|
|
1600
|
+
[...views].map(([stream, view]) => [stream, view.value.revision]),
|
|
1601
|
+
));
|
|
1602
|
+
const reason = requiredFlag(flags, "--reason");
|
|
1603
|
+
if (!new Set(["version-boundary", "configured-interval"]).has(reason)) {
|
|
1604
|
+
throw new Error(`Unsupported history snapshot reason: ${reason}.`);
|
|
1605
|
+
}
|
|
1606
|
+
const eventCount = Object.values(streamRevisions).reduce((total, revision) => total + revision, 0);
|
|
1607
|
+
const latest = readLatestHistoryAnchor(files, options);
|
|
1608
|
+
if (reason === "configured-interval") {
|
|
1609
|
+
const interval = Number(project.formal_state_snapshot_interval);
|
|
1610
|
+
if (!Number.isInteger(interval) || interval <= 0) {
|
|
1611
|
+
verifyFormalState(files, options);
|
|
1612
|
+
return {
|
|
1613
|
+
command: "snapshot create",
|
|
1614
|
+
created: false,
|
|
1615
|
+
reason: "snapshot interval is not configured",
|
|
1616
|
+
event_count: eventCount,
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
const anchoredEventCount = latest?.anchor.event_index.event_count || 0;
|
|
1620
|
+
if (eventCount - anchoredEventCount < interval) {
|
|
1621
|
+
verifyFormalState(files, options);
|
|
1622
|
+
return {
|
|
1623
|
+
command: "snapshot create",
|
|
1624
|
+
created: false,
|
|
1625
|
+
reason: "snapshot interval has not been reached",
|
|
1626
|
+
event_count: eventCount,
|
|
1627
|
+
events_until_snapshot: interval - (eventCount - anchoredEventCount),
|
|
1628
|
+
anchor_id: latest?.anchor.anchor_id || null,
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
const anchorId = historyAnchorId(
|
|
1633
|
+
optionalFlag(flags, "--id")
|
|
1634
|
+
|| (reason === "configured-interval" ? `ANCHOR-W${state.revision}-A${registry.revision}` : ""),
|
|
1635
|
+
);
|
|
1636
|
+
const directory = path.join(files.historySnapshots, anchorId);
|
|
1637
|
+
if (fs.existsSync(directory)
|
|
1638
|
+
&& latest?.anchor.anchor_id !== anchorId
|
|
1639
|
+
&& fs.lstatSync(directory).isDirectory()
|
|
1640
|
+
&& !fs.lstatSync(directory).isSymbolicLink()
|
|
1641
|
+
&& fs.readdirSync(directory).length === 0) {
|
|
1642
|
+
fs.rmdirSync(directory);
|
|
1643
|
+
}
|
|
1644
|
+
if (fs.existsSync(directory)) {
|
|
1645
|
+
const existing = latest?.anchor;
|
|
1646
|
+
const unchanged = existing?.anchor_id === anchorId
|
|
1647
|
+
&& existing.reason === reason
|
|
1648
|
+
&& [...views].every(([stream, view]) =>
|
|
1649
|
+
existing.streams[stream].revision === view.value.revision
|
|
1650
|
+
&& existing.streams[stream].view_checksum === fileChecksum(view.file));
|
|
1651
|
+
if (!unchanged) throw new Error(`History anchor already exists: ${anchorId}.`);
|
|
1652
|
+
verifyFormalState(files, options, { history: true, sinceAnchor: true });
|
|
1653
|
+
return {
|
|
1654
|
+
command: "snapshot create",
|
|
1655
|
+
created: false,
|
|
1656
|
+
deduplicated: true,
|
|
1657
|
+
anchor_id: anchorId,
|
|
1658
|
+
reason,
|
|
1659
|
+
anchor_checksum: existing.anchor_checksum,
|
|
1660
|
+
stream_revisions: streamRevisions,
|
|
1661
|
+
event_count: eventCount,
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
verifyFormalState(files, options, {
|
|
1665
|
+
history: true,
|
|
1666
|
+
sinceAnchor: Boolean(latest),
|
|
1667
|
+
});
|
|
1668
|
+
const snapshotWrites = [...views].map(([, view]) => ({
|
|
1669
|
+
file: path.join(directory, view.snapshotFile),
|
|
1670
|
+
content: fs.readFileSync(view.file, "utf8"),
|
|
1671
|
+
}));
|
|
1672
|
+
const timestamp = (options.now || (() => new Date().toISOString()))();
|
|
1673
|
+
const anchorBase = stableValue({
|
|
1674
|
+
schema_version: 1,
|
|
1675
|
+
anchor_id: anchorId,
|
|
1676
|
+
project_id: project.project_id || state.project_id || null,
|
|
1677
|
+
reason,
|
|
1678
|
+
created_at: timestamp,
|
|
1679
|
+
event_index: {
|
|
1680
|
+
offset_bytes: fs.existsSync(files.events) ? fs.statSync(files.events).size : 0,
|
|
1681
|
+
event_count: eventCount,
|
|
1682
|
+
},
|
|
1683
|
+
streams: Object.fromEntries([...views].map(([stream, view]) => [stream, {
|
|
1684
|
+
revision: view.value.revision,
|
|
1685
|
+
record_checksum: previousStreamChecksum(files, stream, view.value.revision, options),
|
|
1686
|
+
view_checksum: fileChecksum(view.file),
|
|
1687
|
+
}])),
|
|
1688
|
+
});
|
|
1689
|
+
const anchor = {
|
|
1690
|
+
...anchorBase,
|
|
1691
|
+
anchor_checksum: sha256(options.yaml.stringify(anchorBase)),
|
|
1692
|
+
};
|
|
1693
|
+
const pointerBase = stableValue({
|
|
1694
|
+
schema_version: 1,
|
|
1695
|
+
anchor_id: anchorId,
|
|
1696
|
+
anchor_checksum: anchor.anchor_checksum,
|
|
1697
|
+
updated_at: timestamp,
|
|
1698
|
+
});
|
|
1699
|
+
const pointer = {
|
|
1700
|
+
...pointerBase,
|
|
1701
|
+
pointer_checksum: sha256(options.yaml.stringify(pointerBase)),
|
|
1702
|
+
};
|
|
1703
|
+
atomicWriteSet(files, [
|
|
1704
|
+
...snapshotWrites,
|
|
1705
|
+
{ file: path.join(directory, "anchor.yaml"), content: options.yaml.stringify(stableValue(anchor)) },
|
|
1706
|
+
{ file: path.join(files.historySnapshots, "latest.yaml"), content: options.yaml.stringify(stableValue(pointer)) },
|
|
1707
|
+
], options);
|
|
1708
|
+
return {
|
|
1709
|
+
command: "snapshot create",
|
|
1710
|
+
created: true,
|
|
1711
|
+
anchor_id: anchorId,
|
|
1712
|
+
reason,
|
|
1713
|
+
anchor_checksum: anchor.anchor_checksum,
|
|
1714
|
+
stream_revisions: streamRevisions,
|
|
1715
|
+
event_count: eventCount,
|
|
1716
|
+
};
|
|
1717
|
+
}, { waitForActive: true });
|
|
415
1718
|
}
|
|
416
1719
|
|
|
417
1720
|
function rebuildFormalViews(files, options) {
|
|
418
1721
|
return withLock(files, "formal-state-rebuild", () => {
|
|
1722
|
+
const retentionReceipts = readArtifactRetentionReceipts(files, options);
|
|
1723
|
+
const historyArchives = readArtifactHistoryArchives(files, options);
|
|
1724
|
+
if (retentionReceipts.length || historyArchives.length) {
|
|
1725
|
+
const trusted = readLatestHistoryAnchor(files, options);
|
|
1726
|
+
if (!trusted) throw new Error("Retained history rebuild requires a trusted history anchor.");
|
|
1727
|
+
const errors = [];
|
|
1728
|
+
const events = readEvents(files, errors, {
|
|
1729
|
+
offsetBytes: trusted.anchor.event_index.offset_bytes,
|
|
1730
|
+
lineOffset: trusted.anchor.event_index.event_count,
|
|
1731
|
+
});
|
|
1732
|
+
if (errors.length) {
|
|
1733
|
+
throw new Error(`Retained history rebuild failed:\n- ${errors.join("\n- ")}`);
|
|
1734
|
+
}
|
|
1735
|
+
const baseRevisions = Object.fromEntries(
|
|
1736
|
+
Object.entries(trusted.anchor.streams).map(([stream, metadata]) => [stream, metadata.revision]),
|
|
1737
|
+
);
|
|
1738
|
+
const records = readEventShards(files, options, baseRevisions, events);
|
|
1739
|
+
const rebuilt = Object.fromEntries([...FORMAL_STREAMS].map(([stream]) => [stream, replayStreamRecords(
|
|
1740
|
+
stream,
|
|
1741
|
+
records.filter((record) => record.stream === stream),
|
|
1742
|
+
options,
|
|
1743
|
+
{
|
|
1744
|
+
value: trusted.snapshots[stream],
|
|
1745
|
+
recordChecksum: trusted.anchor.streams[stream].record_checksum,
|
|
1746
|
+
revision: trusted.anchor.streams[stream].revision,
|
|
1747
|
+
},
|
|
1748
|
+
)]));
|
|
1749
|
+
atomicWriteSet(files, [...FORMAL_STREAMS].map(([stream, descriptor]) => {
|
|
1750
|
+
const streamRecords = records.filter((record) => record.stream === stream);
|
|
1751
|
+
return {
|
|
1752
|
+
file: descriptor.viewFile(files),
|
|
1753
|
+
content: streamRecords.length
|
|
1754
|
+
? options.yaml.stringify(rebuilt[stream])
|
|
1755
|
+
: fs.readFileSync(
|
|
1756
|
+
path.join(files.historySnapshots, trusted.anchor.anchor_id, descriptor.snapshotFile),
|
|
1757
|
+
"utf8",
|
|
1758
|
+
),
|
|
1759
|
+
};
|
|
1760
|
+
}), options);
|
|
1761
|
+
return {
|
|
1762
|
+
command: "rebuild",
|
|
1763
|
+
rebuilt: true,
|
|
1764
|
+
history_base_anchor_id: trusted.anchor.anchor_id,
|
|
1765
|
+
event_shard_count: records.length,
|
|
1766
|
+
workflow_revision: rebuilt["workflow-state"]?.revision ?? 0,
|
|
1767
|
+
artifact_registry_revision: rebuilt["artifact-registry"]?.revision ?? 0,
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
419
1770
|
const records = readEventShards(files, options);
|
|
420
1771
|
if (!records.length) {
|
|
421
1772
|
return { command: "rebuild", rebuilt: false, event_shard_count: 0, message: "No formal event shards exist; legacy views were left unchanged." };
|
|
422
1773
|
}
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const
|
|
433
|
-
|
|
1774
|
+
const workflowRecords = records.filter((record) => record.stream === "workflow-state");
|
|
1775
|
+
const registryRecords = records.filter((record) => record.stream === "artifact-registry");
|
|
1776
|
+
const workflow = workflowRecords.length ? replayStreamRecords("workflow-state", workflowRecords, options) : null;
|
|
1777
|
+
const registry = registryRecords.length ? replayStreamRecords("artifact-registry", registryRecords, options) : null;
|
|
1778
|
+
const index = records.map((record) => {
|
|
1779
|
+
const { after_value: ignored, ...indexed } = record;
|
|
1780
|
+
if (record.after_value) delete indexed.record_checksum;
|
|
1781
|
+
return JSON.stringify(indexed);
|
|
1782
|
+
}).join("\n");
|
|
1783
|
+
const writes = [{ file: files.events, content: `${index}\n` }];
|
|
1784
|
+
if (workflow) writes.unshift({ file: files.workflowState, content: options.yaml.stringify(workflow) });
|
|
1785
|
+
if (registry) writes.unshift({ file: files.artifactRegistry, content: options.yaml.stringify(registry) });
|
|
1786
|
+
atomicWriteSet(files, writes, options);
|
|
434
1787
|
return {
|
|
435
1788
|
command: "rebuild",
|
|
436
1789
|
rebuilt: true,
|
|
@@ -460,6 +1813,10 @@ function registerArtifact(files, flags, options) {
|
|
|
460
1813
|
throw new Error(`Artifact ID is already registered: ${artifactId}`);
|
|
461
1814
|
}
|
|
462
1815
|
const artifactPath = requiredFlag(flags, "--path").replaceAll("\\", "/");
|
|
1816
|
+
const status = artifactStatus(flags);
|
|
1817
|
+
if (WORKING_DRAFT_STATUSES.has(status)) {
|
|
1818
|
+
throw new Error("Working drafts are mutable and must not be registered in the formal artifact registry.");
|
|
1819
|
+
}
|
|
463
1820
|
if (!artifactPath.startsWith(".workflow/")) {
|
|
464
1821
|
throw new Error("Artifact path must be project-relative and start with .workflow/.");
|
|
465
1822
|
}
|
|
@@ -482,14 +1839,37 @@ function registerArtifact(files, flags, options) {
|
|
|
482
1839
|
if (supersedes) {
|
|
483
1840
|
predecessor = registry.artifacts.find((item) => (item.artifact_id || item.id) === supersedes);
|
|
484
1841
|
if (!predecessor) throw new Error(`Superseded artifact is not registered: ${supersedes}`);
|
|
485
|
-
|
|
486
|
-
|
|
1842
|
+
const successor = artifactSuperseder(registry, predecessor);
|
|
1843
|
+
if (predecessor.status === "superseded" || successor) {
|
|
1844
|
+
const successorId = successor?.artifact_id || successor?.id;
|
|
1845
|
+
throw new Error(
|
|
1846
|
+
`Artifact is already superseded: ${supersedes}${successorId ? ` by ${successorId}` : ""}`,
|
|
1847
|
+
);
|
|
487
1848
|
}
|
|
488
1849
|
}
|
|
489
1850
|
const node = requiredFlag(flags, "--node");
|
|
1851
|
+
assertNotLegacyDevelopmentNode(node);
|
|
490
1852
|
if (!NODE_IDS.has(node)) throw new Error(`Unknown workflow node: ${node}`);
|
|
491
1853
|
const artifactScope = scopeNameForNode(node);
|
|
492
1854
|
const artifactScopeState = scopeState(workflowState, artifactScope);
|
|
1855
|
+
const candidateId = optionalFlag(flags, "--candidate-id");
|
|
1856
|
+
if (node === "05-candidate-assurance") {
|
|
1857
|
+
if (!candidateId) {
|
|
1858
|
+
throw new Error("05-candidate-assurance artifacts require --candidate-id.");
|
|
1859
|
+
}
|
|
1860
|
+
const attempt = Number(artifactScopeState.node_attempts[node] || 0);
|
|
1861
|
+
const binding = artifactScopeState.node_candidate_bindings[node];
|
|
1862
|
+
if (!artifactScopeState.active_nodes.includes(node)
|
|
1863
|
+
|| Number(binding?.node_attempt) !== attempt
|
|
1864
|
+
|| binding?.candidate_id !== candidateId) {
|
|
1865
|
+
throw new Error(`Assurance artifact candidate ${candidateId} does not match the active node attempt.`);
|
|
1866
|
+
}
|
|
1867
|
+
const candidate = registry.artifacts.find((item) =>
|
|
1868
|
+
(item.artifact_id || item.id) === candidateId);
|
|
1869
|
+
if (!candidate || projectedArtifactStatus(registry, candidate) !== "immutable") {
|
|
1870
|
+
throw new Error(`Assurance artifact candidate is not selected and immutable: ${candidateId}.`);
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
493
1873
|
const artifact = {
|
|
494
1874
|
artifact_id: artifactId,
|
|
495
1875
|
artifact_type: optionalFlag(flags, "--artifact-type"),
|
|
@@ -497,40 +1877,39 @@ function registerArtifact(files, flags, options) {
|
|
|
497
1877
|
node,
|
|
498
1878
|
scope: artifactScope,
|
|
499
1879
|
iteration_id: artifactScopeState.scope_id || null,
|
|
500
|
-
status
|
|
1880
|
+
status,
|
|
501
1881
|
version: optionalFlag(flags, "--version"),
|
|
502
1882
|
producer_skill: optionalFlag(flags, "--producer-skill"),
|
|
1883
|
+
candidate_id: candidateId,
|
|
503
1884
|
supersedes,
|
|
504
1885
|
checksum_algorithm: "sha256",
|
|
505
1886
|
checksum: sha256(content),
|
|
506
1887
|
registered_at: timestamp,
|
|
507
1888
|
};
|
|
508
|
-
if (predecessor) {
|
|
509
|
-
const previous = predecessor.status || "unknown";
|
|
510
|
-
predecessor.status = "superseded";
|
|
511
|
-
predecessor.superseded_by = artifactId;
|
|
512
|
-
predecessor.status_history = [
|
|
513
|
-
...(Array.isArray(predecessor.status_history) ? predecessor.status_history : []),
|
|
514
|
-
{
|
|
515
|
-
from: previous,
|
|
516
|
-
to: "superseded",
|
|
517
|
-
evidence: [artifactId],
|
|
518
|
-
changed_at: timestamp,
|
|
519
|
-
},
|
|
520
|
-
];
|
|
521
|
-
}
|
|
522
1889
|
registry.schema_version = 1;
|
|
523
1890
|
registry.project_id = registry.project_id || project.project_id || workflowState.project_id || null;
|
|
524
1891
|
registry.iteration_id = workflowState.development_state.scope_id || registry.iteration_id || null;
|
|
525
1892
|
registry.revision += 1;
|
|
526
1893
|
registry.updated_at = timestamp;
|
|
527
1894
|
registry.artifacts = [...registry.artifacts, artifact];
|
|
528
|
-
|
|
1895
|
+
const event = {
|
|
529
1896
|
type: "artifact-registered",
|
|
530
1897
|
occurred_at: timestamp,
|
|
531
1898
|
artifact_id: artifactId,
|
|
532
1899
|
path: artifactPath,
|
|
533
|
-
}
|
|
1900
|
+
};
|
|
1901
|
+
commitFormalTransaction(files, [{
|
|
1902
|
+
stream: "artifact-registry",
|
|
1903
|
+
file: files.artifactRegistry,
|
|
1904
|
+
value: registry,
|
|
1905
|
+
event,
|
|
1906
|
+
transaction: artifactRegistryTransaction(
|
|
1907
|
+
event,
|
|
1908
|
+
registry,
|
|
1909
|
+
expected,
|
|
1910
|
+
[artifact],
|
|
1911
|
+
),
|
|
1912
|
+
}], [], options);
|
|
534
1913
|
return {
|
|
535
1914
|
command: "artifact register",
|
|
536
1915
|
project_id: registry.project_id,
|
|
@@ -539,7 +1918,7 @@ function registerArtifact(files, flags, options) {
|
|
|
539
1918
|
artifact,
|
|
540
1919
|
artifact_registry: registry,
|
|
541
1920
|
};
|
|
542
|
-
});
|
|
1921
|
+
}, { waitForActive: true });
|
|
543
1922
|
}
|
|
544
1923
|
|
|
545
1924
|
function changeArtifactStatus(files, flags, options) {
|
|
@@ -558,8 +1937,11 @@ function changeArtifactStatus(files, flags, options) {
|
|
|
558
1937
|
const evidence = repeatedFlag(flags, "--evidence");
|
|
559
1938
|
if (!evidence.length) throw new Error("artifact status requires at least one --evidence reference.");
|
|
560
1939
|
if (artifact.status === status) throw new Error(`Artifact already has status ${status}: ${artifactId}`);
|
|
561
|
-
if (
|
|
562
|
-
throw new Error(`
|
|
1940
|
+
if (!WORKING_DRAFT_STATUSES.has(artifact.status)) {
|
|
1941
|
+
throw new Error(`Immutable artifact revision cannot change status: ${artifact.status}`);
|
|
1942
|
+
}
|
|
1943
|
+
if (WORKING_DRAFT_STATUSES.has(status)) {
|
|
1944
|
+
throw new Error("Legacy draft migration must target an immutable semantic status.");
|
|
563
1945
|
}
|
|
564
1946
|
|
|
565
1947
|
const timestamp = (options.now || (() => new Date().toISOString()))();
|
|
@@ -572,43 +1954,763 @@ function changeArtifactStatus(files, flags, options) {
|
|
|
572
1954
|
registry.schema_version = 1;
|
|
573
1955
|
registry.revision += 1;
|
|
574
1956
|
registry.updated_at = timestamp;
|
|
575
|
-
|
|
1957
|
+
const event = {
|
|
576
1958
|
type: "artifact-status-changed",
|
|
577
1959
|
occurred_at: timestamp,
|
|
578
1960
|
artifact_id: artifactId,
|
|
579
1961
|
from: previous,
|
|
580
1962
|
to: status,
|
|
581
1963
|
evidence,
|
|
582
|
-
}
|
|
1964
|
+
};
|
|
1965
|
+
commitFormalTransaction(files, [{
|
|
1966
|
+
stream: "artifact-registry",
|
|
1967
|
+
file: files.artifactRegistry,
|
|
1968
|
+
value: registry,
|
|
1969
|
+
event,
|
|
1970
|
+
transaction: artifactRegistryTransaction(event, registry, expected, [artifact]),
|
|
1971
|
+
}], [], options);
|
|
1972
|
+
return {
|
|
1973
|
+
command: "artifact status",
|
|
1974
|
+
revision: registry.revision,
|
|
1975
|
+
artifact,
|
|
1976
|
+
artifact_registry: registry,
|
|
1977
|
+
};
|
|
1978
|
+
}, { waitForActive: true });
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
function recoverArtifactPrune(files, apply, options) {
|
|
1982
|
+
if (!fs.existsSync(files.pruneRuntime)) return 0;
|
|
1983
|
+
const pending = fs.readdirSync(files.pruneRuntime, { withFileTypes: true })
|
|
1984
|
+
.filter((entry) => entry.isDirectory())
|
|
1985
|
+
.map((entry) => path.join(files.pruneRuntime, entry.name));
|
|
1986
|
+
if (pending.length && !apply) {
|
|
1987
|
+
throw new Error("An interrupted artifact prune requires explicit --apply recovery.");
|
|
1988
|
+
}
|
|
1989
|
+
let recovered = 0;
|
|
1990
|
+
for (const directory of pending) {
|
|
1991
|
+
const planFile = path.join(directory, "plan.json");
|
|
1992
|
+
if (!fs.existsSync(planFile)) throw new Error(`Artifact prune recovery plan is missing: ${directory}.`);
|
|
1993
|
+
let plan;
|
|
1994
|
+
try {
|
|
1995
|
+
plan = JSON.parse(fs.readFileSync(planFile, "utf8"));
|
|
1996
|
+
} catch {
|
|
1997
|
+
throw new Error(`Artifact prune recovery plan is invalid: ${directory}.`);
|
|
1998
|
+
}
|
|
1999
|
+
if (plan?.schema_version !== 1 || !plan.anchor_id || !Array.isArray(plan.shards)
|
|
2000
|
+
|| plan.shards.some((item) => !/^[^/\\]+\.ya?ml$/i.test(item?.name || "")
|
|
2001
|
+
|| !/^[a-f0-9]{64}$/.test(item?.checksum || ""))) {
|
|
2002
|
+
throw new Error(`Artifact prune recovery plan is invalid: ${directory}.`);
|
|
2003
|
+
}
|
|
2004
|
+
const receiptFile = path.join(files.artifactRetention, `${historyAnchorId(plan.anchor_id)}.yaml`);
|
|
2005
|
+
if (fs.existsSync(receiptFile)) {
|
|
2006
|
+
const receipt = readArtifactRetentionReceipts(files, options)
|
|
2007
|
+
.find((item) => item.anchor_id === plan.anchor_id);
|
|
2008
|
+
const plannedShardsChecksum = sha256(JSON.stringify(stableValue(plan.shards)));
|
|
2009
|
+
if (!receipt
|
|
2010
|
+
|| receipt.deleted_shard_count !== plan.shards.length
|
|
2011
|
+
|| receipt.deleted_shards_checksum !== plannedShardsChecksum) {
|
|
2012
|
+
throw new Error(`Artifact prune receipt does not match its recovery plan: ${plan.anchor_id}.`);
|
|
2013
|
+
}
|
|
2014
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
2015
|
+
recovered += 1;
|
|
2016
|
+
continue;
|
|
2017
|
+
}
|
|
2018
|
+
const shardDirectory = path.join(files.eventShards, "artifact-registry");
|
|
2019
|
+
fs.mkdirSync(shardDirectory, { recursive: true });
|
|
2020
|
+
for (const { name, checksum } of plan.shards) {
|
|
2021
|
+
const staged = path.join(directory, name);
|
|
2022
|
+
if (!fs.existsSync(staged)) continue;
|
|
2023
|
+
if (fileChecksum(staged) !== checksum) {
|
|
2024
|
+
throw new Error(`Artifact prune staged shard checksum mismatch: ${name}.`);
|
|
2025
|
+
}
|
|
2026
|
+
const destination = path.join(shardDirectory, name);
|
|
2027
|
+
if (fs.existsSync(destination)) {
|
|
2028
|
+
if (fileChecksum(destination) !== checksum) {
|
|
2029
|
+
throw new Error(`Artifact prune recovery destination conflicts: ${name}.`);
|
|
2030
|
+
}
|
|
2031
|
+
fs.rmSync(staged, { force: true });
|
|
2032
|
+
} else {
|
|
2033
|
+
fs.renameSync(staged, destination);
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
2037
|
+
recovered += 1;
|
|
2038
|
+
}
|
|
2039
|
+
return recovered;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
function legacyArtifactHistoryArchive(files, trusted, options) {
|
|
2043
|
+
verifyFormalState(files, options, { history: true });
|
|
2044
|
+
const anchoredRevision = trusted.anchor.streams["artifact-registry"].revision;
|
|
2045
|
+
const shardDirectory = path.join(files.eventShards, "artifact-registry");
|
|
2046
|
+
const legacyShards = fs.existsSync(shardDirectory)
|
|
2047
|
+
? fs.readdirSync(shardDirectory)
|
|
2048
|
+
.filter((name) => /\.ya?ml$/i.test(name))
|
|
2049
|
+
.map((name) => ({
|
|
2050
|
+
name,
|
|
2051
|
+
file: path.join(shardDirectory, name),
|
|
2052
|
+
record: readEventShardRecord(files, options, "artifact-registry", name),
|
|
2053
|
+
}))
|
|
2054
|
+
.filter(({ record }) => record.after_value && record.revision <= anchoredRevision)
|
|
2055
|
+
.sort((left, right) => left.record.revision - right.record.revision)
|
|
2056
|
+
: [];
|
|
2057
|
+
if (!legacyShards.length) {
|
|
2058
|
+
throw new Error("Legacy artifact history compact requires anchored full-snapshot shards.");
|
|
2059
|
+
}
|
|
2060
|
+
const replayed = replayStreamRecords(
|
|
2061
|
+
"artifact-registry",
|
|
2062
|
+
legacyShards.map(({ record }) => record),
|
|
2063
|
+
options,
|
|
2064
|
+
);
|
|
2065
|
+
const anchoredRegistry = trusted.snapshots["artifact-registry"];
|
|
2066
|
+
if (JSON.stringify(stableValue(normalizeArtifactRegistry(replayed)))
|
|
2067
|
+
!== JSON.stringify(stableValue(normalizeArtifactRegistry(anchoredRegistry)))) {
|
|
2068
|
+
throw new Error("Legacy artifact history replay does not reproduce the anchored registry.");
|
|
2069
|
+
}
|
|
2070
|
+
const finalRecord = legacyShards.at(-1).record;
|
|
2071
|
+
const anchoredChecksum = trusted.anchor.streams["artifact-registry"].view_checksum;
|
|
2072
|
+
if (finalRecord.revision !== anchoredRevision || finalRecord.after_checksum !== anchoredChecksum) {
|
|
2073
|
+
throw new Error("Legacy artifact history replay does not reproduce the anchored registry checksum.");
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
const digest = crypto.createHash("sha256");
|
|
2077
|
+
const entries = legacyShards.map(({ name, file, record }) => {
|
|
2078
|
+
const content = fs.readFileSync(file);
|
|
2079
|
+
digest.update(name);
|
|
2080
|
+
digest.update("\0");
|
|
2081
|
+
digest.update(content);
|
|
2082
|
+
digest.update("\0");
|
|
2083
|
+
return {
|
|
2084
|
+
path: name,
|
|
2085
|
+
revision: record.revision,
|
|
2086
|
+
size_bytes: content.length,
|
|
2087
|
+
sha256: sha256(content),
|
|
2088
|
+
content_base64: content.toString("base64"),
|
|
2089
|
+
};
|
|
2090
|
+
});
|
|
2091
|
+
const sourceHistoryRootDigest = digest.digest("hex");
|
|
2092
|
+
const coverage = {
|
|
2093
|
+
first_revision: legacyShards[0].record.revision,
|
|
2094
|
+
last_revision: finalRecord.revision,
|
|
2095
|
+
event_count: legacyShards.length,
|
|
2096
|
+
};
|
|
2097
|
+
const archiveId = trusted.anchor.anchor_id;
|
|
2098
|
+
const bundle = stableValue({
|
|
2099
|
+
schema_version: 1,
|
|
2100
|
+
archive_id: archiveId,
|
|
2101
|
+
stream: "artifact-registry",
|
|
2102
|
+
source_root: portableReference(files, shardDirectory),
|
|
2103
|
+
source_history_root_digest: sourceHistoryRootDigest,
|
|
2104
|
+
source_history_root_digest_algorithm: "sha256-path-null-bytes-null-v1",
|
|
2105
|
+
coverage,
|
|
2106
|
+
anchor: {
|
|
2107
|
+
anchor_id: trusted.anchor.anchor_id,
|
|
2108
|
+
anchor_checksum: trusted.anchor.anchor_checksum,
|
|
2109
|
+
registry_revision: anchoredRevision,
|
|
2110
|
+
registry_checksum: anchoredChecksum,
|
|
2111
|
+
},
|
|
2112
|
+
entries,
|
|
2113
|
+
});
|
|
2114
|
+
const archiveBytes = zlib.gzipSync(Buffer.from(`${JSON.stringify(bundle, null, 2)}\n`), {
|
|
2115
|
+
level: 9,
|
|
2116
|
+
mtime: 0,
|
|
2117
|
+
});
|
|
2118
|
+
const archiveFile = path.join(files.artifactHistoryArchives, `${archiveId}.json.gz`);
|
|
2119
|
+
const manifestFile = path.join(files.artifactHistoryArchives, `${archiveId}.yaml`);
|
|
2120
|
+
const looseBytes = entries.reduce((total, entry) => total + entry.size_bytes, 0);
|
|
2121
|
+
const compactedAt = (options.now || (() => new Date().toISOString()))();
|
|
2122
|
+
const manifestBase = stableValue({
|
|
2123
|
+
schema_version: 1,
|
|
2124
|
+
archive_id: archiveId,
|
|
2125
|
+
stream: "artifact-registry",
|
|
2126
|
+
anchor_id: trusted.anchor.anchor_id,
|
|
2127
|
+
anchor_checksum: trusted.anchor.anchor_checksum,
|
|
2128
|
+
registry_revision: anchoredRevision,
|
|
2129
|
+
registry_checksum: anchoredChecksum,
|
|
2130
|
+
source_root: bundle.source_root,
|
|
2131
|
+
source_history_root_digest: sourceHistoryRootDigest,
|
|
2132
|
+
source_history_root_digest_algorithm: bundle.source_history_root_digest_algorithm,
|
|
2133
|
+
coverage,
|
|
2134
|
+
loose_file_count: entries.length,
|
|
2135
|
+
loose_bytes: looseBytes,
|
|
2136
|
+
archive_format: "gzip-json-v1",
|
|
2137
|
+
archive_path: portableReference(files, archiveFile),
|
|
2138
|
+
archive_checksum: sha256(archiveBytes),
|
|
2139
|
+
compressed_bundle_bytes: archiveBytes.length,
|
|
2140
|
+
compacted_at: compactedAt,
|
|
2141
|
+
});
|
|
2142
|
+
const manifest = {
|
|
2143
|
+
...manifestBase,
|
|
2144
|
+
manifest_checksum: sha256(options.yaml.stringify(manifestBase)),
|
|
2145
|
+
};
|
|
2146
|
+
const manifestContent = options.yaml.stringify(stableValue(manifest));
|
|
2147
|
+
const archiveStorageBytes = archiveBytes.length + Buffer.byteLength(manifestContent);
|
|
2148
|
+
return {
|
|
2149
|
+
bundle,
|
|
2150
|
+
archiveBytes,
|
|
2151
|
+
archiveFile,
|
|
2152
|
+
entries,
|
|
2153
|
+
manifest,
|
|
2154
|
+
manifestContent,
|
|
2155
|
+
manifestFile,
|
|
2156
|
+
report: {
|
|
2157
|
+
command: "compact",
|
|
2158
|
+
anchor_id: trusted.anchor.anchor_id,
|
|
2159
|
+
anchor_checksum: trusted.anchor.anchor_checksum,
|
|
2160
|
+
source_history_root_digest: sourceHistoryRootDigest,
|
|
2161
|
+
coverage,
|
|
2162
|
+
loose_file_count: entries.length,
|
|
2163
|
+
loose_bytes: looseBytes,
|
|
2164
|
+
compressed_bundle_bytes: archiveBytes.length,
|
|
2165
|
+
manifest_bytes: Buffer.byteLength(manifestContent),
|
|
2166
|
+
archive_bytes: archiveStorageBytes,
|
|
2167
|
+
expected_freed_bytes: looseBytes - archiveStorageBytes,
|
|
2168
|
+
archive_path: portableReference(files, archiveFile),
|
|
2169
|
+
manifest_path: portableReference(files, manifestFile),
|
|
2170
|
+
},
|
|
2171
|
+
};
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
function readArtifactHistoryArchives(files, options) {
|
|
2175
|
+
assertPathInside(files.root, files.artifactHistoryArchives);
|
|
2176
|
+
if (!fs.existsSync(files.artifactHistoryArchives)) return [];
|
|
2177
|
+
return fs.readdirSync(files.artifactHistoryArchives)
|
|
2178
|
+
.filter((name) => /\.ya?ml$/i.test(name))
|
|
2179
|
+
.sort()
|
|
2180
|
+
.map((name) => {
|
|
2181
|
+
const manifestFile = path.join(files.artifactHistoryArchives, name);
|
|
2182
|
+
const manifest = readYaml(manifestFile, options.yaml, `artifact history archive manifest ${name}`);
|
|
2183
|
+
const archiveId = historyAnchorId(manifest.archive_id);
|
|
2184
|
+
const expectedManifestName = `${archiveId}.yaml`;
|
|
2185
|
+
const archiveFile = path.join(files.artifactHistoryArchives, `${archiveId}.json.gz`);
|
|
2186
|
+
const coverage = manifest.coverage;
|
|
2187
|
+
if (name !== expectedManifestName
|
|
2188
|
+
|| manifest.schema_version !== 1
|
|
2189
|
+
|| manifest.stream !== "artifact-registry"
|
|
2190
|
+
|| manifest.anchor_id !== archiveId
|
|
2191
|
+
|| manifest.archive_format !== "gzip-json-v1"
|
|
2192
|
+
|| manifest.archive_path !== portableReference(files, archiveFile)
|
|
2193
|
+
|| !/^[a-f0-9]{64}$/.test(manifest.anchor_checksum || "")
|
|
2194
|
+
|| !/^[a-f0-9]{64}$/.test(manifest.registry_checksum || "")
|
|
2195
|
+
|| !/^[a-f0-9]{64}$/.test(manifest.source_history_root_digest || "")
|
|
2196
|
+
|| manifest.source_history_root_digest_algorithm !== "sha256-path-null-bytes-null-v1"
|
|
2197
|
+
|| !/^[a-f0-9]{64}$/.test(manifest.archive_checksum || "")
|
|
2198
|
+
|| !Number.isSafeInteger(manifest.registry_revision)
|
|
2199
|
+
|| manifest.registry_revision < 1
|
|
2200
|
+
|| !Number.isSafeInteger(coverage?.first_revision)
|
|
2201
|
+
|| !Number.isSafeInteger(coverage?.last_revision)
|
|
2202
|
+
|| !Number.isSafeInteger(coverage?.event_count)
|
|
2203
|
+
|| coverage.first_revision < 1
|
|
2204
|
+
|| coverage.last_revision < coverage.first_revision
|
|
2205
|
+
|| coverage.event_count !== coverage.last_revision - coverage.first_revision + 1
|
|
2206
|
+
|| coverage.last_revision !== manifest.registry_revision
|
|
2207
|
+
|| !Number.isSafeInteger(manifest.loose_file_count)
|
|
2208
|
+
|| manifest.loose_file_count !== coverage.event_count
|
|
2209
|
+
|| !Number.isSafeInteger(manifest.loose_bytes)
|
|
2210
|
+
|| manifest.loose_bytes < 0
|
|
2211
|
+
|| !Number.isSafeInteger(manifest.compressed_bundle_bytes)
|
|
2212
|
+
|| manifest.compressed_bundle_bytes < 1
|
|
2213
|
+
|| structuredDocumentChecksum(manifest, "manifest_checksum", options.yaml.stringify)
|
|
2214
|
+
!== manifest.manifest_checksum
|
|
2215
|
+
|| !fs.existsSync(archiveFile)
|
|
2216
|
+
|| fs.statSync(archiveFile).size !== manifest.compressed_bundle_bytes
|
|
2217
|
+
|| sha256(fs.readFileSync(archiveFile)) !== manifest.archive_checksum) {
|
|
2218
|
+
throw new Error(`Artifact history archive manifest is invalid: ${name}.`);
|
|
2219
|
+
}
|
|
2220
|
+
let bundle;
|
|
2221
|
+
try {
|
|
2222
|
+
bundle = JSON.parse(zlib.gunzipSync(fs.readFileSync(archiveFile)).toString("utf8"));
|
|
2223
|
+
} catch {
|
|
2224
|
+
throw new Error(`Artifact history archive bundle is invalid: ${archiveId}.`);
|
|
2225
|
+
}
|
|
2226
|
+
if (bundle?.schema_version !== 1
|
|
2227
|
+
|| bundle.archive_id !== archiveId
|
|
2228
|
+
|| bundle.stream !== "artifact-registry"
|
|
2229
|
+
|| bundle.source_root !== manifest.source_root
|
|
2230
|
+
|| bundle.source_history_root_digest !== manifest.source_history_root_digest
|
|
2231
|
+
|| bundle.source_history_root_digest_algorithm !== manifest.source_history_root_digest_algorithm
|
|
2232
|
+
|| JSON.stringify(bundle.coverage) !== JSON.stringify(manifest.coverage)
|
|
2233
|
+
|| bundle.anchor?.anchor_id !== manifest.anchor_id
|
|
2234
|
+
|| bundle.anchor?.anchor_checksum !== manifest.anchor_checksum
|
|
2235
|
+
|| bundle.anchor?.registry_revision !== manifest.registry_revision
|
|
2236
|
+
|| bundle.anchor?.registry_checksum !== manifest.registry_checksum
|
|
2237
|
+
|| !Array.isArray(bundle.entries)
|
|
2238
|
+
|| bundle.entries.length !== manifest.loose_file_count) {
|
|
2239
|
+
throw new Error(`Artifact history archive bundle metadata is invalid: ${archiveId}.`);
|
|
2240
|
+
}
|
|
2241
|
+
const digest = crypto.createHash("sha256");
|
|
2242
|
+
const names = new Set();
|
|
2243
|
+
let entryBytes = 0;
|
|
2244
|
+
const records = bundle.entries.map((entry, index) => {
|
|
2245
|
+
if (!/^[^/\\]+\.ya?ml$/i.test(entry?.path || "") || names.has(entry.path)) {
|
|
2246
|
+
throw new Error(`Artifact history archive entry path is invalid: ${archiveId}.`);
|
|
2247
|
+
}
|
|
2248
|
+
names.add(entry.path);
|
|
2249
|
+
if (!Number.isSafeInteger(entry.size_bytes) || entry.size_bytes < 0
|
|
2250
|
+
|| !Number.isSafeInteger(entry.revision)
|
|
2251
|
+
|| entry.revision !== coverage.first_revision + index) {
|
|
2252
|
+
throw new Error(`Artifact history archive entry metadata is invalid: ${archiveId}/${entry.path}.`);
|
|
2253
|
+
}
|
|
2254
|
+
const content = Buffer.from(String(entry.content_base64 || ""), "base64");
|
|
2255
|
+
if (content.length !== entry.size_bytes || sha256(content) !== entry.sha256) {
|
|
2256
|
+
throw new Error(`Artifact history archive entry checksum is invalid: ${archiveId}/${entry.path}.`);
|
|
2257
|
+
}
|
|
2258
|
+
entryBytes += entry.size_bytes;
|
|
2259
|
+
digest.update(entry.path);
|
|
2260
|
+
digest.update("\0");
|
|
2261
|
+
digest.update(content);
|
|
2262
|
+
digest.update("\0");
|
|
2263
|
+
let record;
|
|
2264
|
+
try {
|
|
2265
|
+
record = options.yaml.parse(content.toString("utf8"));
|
|
2266
|
+
} catch {
|
|
2267
|
+
throw new Error(`Artifact history archive entry is unreadable: ${archiveId}/${entry.path}.`);
|
|
2268
|
+
}
|
|
2269
|
+
const validated = validateEventShardRecord(record, "artifact-registry", entry.path);
|
|
2270
|
+
if (validated.revision !== entry.revision) {
|
|
2271
|
+
throw new Error(`Artifact history archive entry revision is invalid: ${archiveId}/${entry.path}.`);
|
|
2272
|
+
}
|
|
2273
|
+
return validated;
|
|
2274
|
+
});
|
|
2275
|
+
if (digest.digest("hex") !== manifest.source_history_root_digest
|
|
2276
|
+
|| entryBytes !== manifest.loose_bytes
|
|
2277
|
+
|| records.length !== manifest.coverage?.event_count
|
|
2278
|
+
|| records[0]?.revision !== manifest.coverage?.first_revision
|
|
2279
|
+
|| records.at(-1)?.revision !== manifest.coverage?.last_revision
|
|
2280
|
+
|| records.some((record, index) => record.revision !== coverage.first_revision + index)) {
|
|
2281
|
+
throw new Error(`Artifact history archive coverage is invalid: ${archiveId}.`);
|
|
2282
|
+
}
|
|
2283
|
+
const replayed = replayStreamRecords("artifact-registry", records, options);
|
|
2284
|
+
const anchorFile = path.join(files.historySnapshots, archiveId, "anchor.yaml");
|
|
2285
|
+
const snapshotFile = path.join(files.historySnapshots, archiveId, "artifact-registry.yaml");
|
|
2286
|
+
const anchor = readYaml(anchorFile, options.yaml, `artifact history archive anchor ${archiveId}`);
|
|
2287
|
+
const snapshot = normalizeArtifactRegistry(readYaml(
|
|
2288
|
+
snapshotFile,
|
|
2289
|
+
options.yaml,
|
|
2290
|
+
`artifact history archive snapshot ${archiveId}`,
|
|
2291
|
+
));
|
|
2292
|
+
if (anchor.anchor_checksum !== manifest.anchor_checksum
|
|
2293
|
+
|| structuredDocumentChecksum(anchor, "anchor_checksum", options.yaml.stringify)
|
|
2294
|
+
!== anchor.anchor_checksum
|
|
2295
|
+
|| anchor.streams?.["artifact-registry"]?.revision !== manifest.registry_revision
|
|
2296
|
+
|| anchor.streams?.["artifact-registry"]?.view_checksum !== manifest.registry_checksum
|
|
2297
|
+
|| sha256(fs.readFileSync(snapshotFile)) !== manifest.registry_checksum
|
|
2298
|
+
|| JSON.stringify(stableValue(normalizeArtifactRegistry(replayed)))
|
|
2299
|
+
!== JSON.stringify(stableValue(snapshot))) {
|
|
2300
|
+
throw new Error(`Artifact history archive replay does not match its anchor: ${archiveId}.`);
|
|
2301
|
+
}
|
|
2302
|
+
return { manifest, manifestFile, archiveFile, bundle };
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
function restoreCompactionStaging(files, directory, plan) {
|
|
2307
|
+
const shardDirectory = path.join(files.eventShards, "artifact-registry");
|
|
2308
|
+
fs.mkdirSync(shardDirectory, { recursive: true });
|
|
2309
|
+
for (const entry of plan.entries) {
|
|
2310
|
+
const staged = path.join(directory, entry.path);
|
|
2311
|
+
if (!fs.existsSync(staged)) continue;
|
|
2312
|
+
if (sha256(fs.readFileSync(staged)) !== entry.sha256) {
|
|
2313
|
+
throw new Error(`Artifact history compact staged shard checksum mismatch: ${entry.path}.`);
|
|
2314
|
+
}
|
|
2315
|
+
const destination = path.join(shardDirectory, entry.path);
|
|
2316
|
+
if (fs.existsSync(destination)) {
|
|
2317
|
+
if (sha256(fs.readFileSync(destination)) !== entry.sha256) {
|
|
2318
|
+
throw new Error(`Artifact history compact recovery destination conflicts: ${entry.path}.`);
|
|
2319
|
+
}
|
|
2320
|
+
fs.rmSync(staged, { force: true });
|
|
2321
|
+
} else {
|
|
2322
|
+
fs.renameSync(staged, destination);
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
function recoverArtifactHistoryCompaction(files, apply, options) {
|
|
2328
|
+
if (!fs.existsSync(files.compactRuntime)) return 0;
|
|
2329
|
+
const pending = fs.readdirSync(files.compactRuntime, { withFileTypes: true })
|
|
2330
|
+
.filter((entry) => entry.isDirectory())
|
|
2331
|
+
.map((entry) => path.join(files.compactRuntime, entry.name));
|
|
2332
|
+
if (pending.length && !apply) {
|
|
2333
|
+
throw new Error("An interrupted artifact history compact requires explicit apply recovery.");
|
|
2334
|
+
}
|
|
2335
|
+
let recovered = 0;
|
|
2336
|
+
for (const directory of pending) {
|
|
2337
|
+
const planFile = path.join(directory, "plan.json");
|
|
2338
|
+
if (!fs.existsSync(planFile)) {
|
|
2339
|
+
const interruptedPlanWrites = fs.readdirSync(directory, { withFileTypes: true });
|
|
2340
|
+
if (interruptedPlanWrites.every((entry) =>
|
|
2341
|
+
entry.isFile() && /^plan\.json\.\d+\.\d+\.tmp$/.test(entry.name))) {
|
|
2342
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
2343
|
+
recovered += 1;
|
|
2344
|
+
continue;
|
|
2345
|
+
}
|
|
2346
|
+
throw new Error(`Artifact history compact recovery plan is invalid: ${directory}.`);
|
|
2347
|
+
}
|
|
2348
|
+
let plan;
|
|
2349
|
+
try {
|
|
2350
|
+
plan = JSON.parse(fs.readFileSync(planFile, "utf8"));
|
|
2351
|
+
} catch {
|
|
2352
|
+
throw new Error(`Artifact history compact recovery plan is invalid: ${directory}.`);
|
|
2353
|
+
}
|
|
2354
|
+
if (plan?.schema_version !== 1 || !plan.archive_id
|
|
2355
|
+
|| !/^[a-f0-9]{64}$/.test(plan.archive_checksum || "")
|
|
2356
|
+
|| !Array.isArray(plan.entries) || !plan.entries.length
|
|
2357
|
+
|| plan.entries.some((entry) => !/^[^/\\]+\.ya?ml$/i.test(entry?.path || "")
|
|
2358
|
+
|| !/^[a-f0-9]{64}$/.test(entry?.sha256 || ""))) {
|
|
2359
|
+
throw new Error(`Artifact history compact recovery plan is invalid: ${directory}.`);
|
|
2360
|
+
}
|
|
2361
|
+
const plannedEntries = new Map(plan.entries.map((entry) => [entry.path, entry.sha256]));
|
|
2362
|
+
const stagedEntries = fs.readdirSync(directory, { withFileTypes: true })
|
|
2363
|
+
.filter((entry) => entry.name !== "plan.json");
|
|
2364
|
+
if (plannedEntries.size !== plan.entries.length
|
|
2365
|
+
|| stagedEntries.some((entry) => !entry.isFile() || !plannedEntries.has(entry.name))) {
|
|
2366
|
+
throw new Error(`Artifact history compact recovery plan is invalid: ${directory}.`);
|
|
2367
|
+
}
|
|
2368
|
+
const changedStagedEntry = stagedEntries.find((entry) =>
|
|
2369
|
+
sha256(fs.readFileSync(path.join(directory, entry.name))) !== plannedEntries.get(entry.name));
|
|
2370
|
+
if (changedStagedEntry) {
|
|
2371
|
+
throw new Error(`Artifact history compact staged shard checksum mismatch: ${changedStagedEntry.name}.`);
|
|
2372
|
+
}
|
|
2373
|
+
let archive = null;
|
|
2374
|
+
try {
|
|
2375
|
+
archive = readArtifactHistoryArchives(files, options)
|
|
2376
|
+
.find(({ manifest }) => manifest.archive_id === plan.archive_id) || null;
|
|
2377
|
+
} catch {
|
|
2378
|
+
archive = null;
|
|
2379
|
+
}
|
|
2380
|
+
const archivedEntries = new Map(
|
|
2381
|
+
(archive?.bundle.entries || []).map((entry) => [entry.path, entry.sha256]),
|
|
2382
|
+
);
|
|
2383
|
+
const archiveMatches = archive?.manifest.archive_checksum === plan.archive_checksum
|
|
2384
|
+
&& plan.entries.every((entry) => archivedEntries.get(entry.path) === entry.sha256);
|
|
2385
|
+
if (!archiveMatches) {
|
|
2386
|
+
restoreCompactionStaging(files, directory, plan);
|
|
2387
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
2388
|
+
throw new Error(`Artifact history compact recovery plan does not match its verified archive: ${plan.archive_id}.`);
|
|
2389
|
+
}
|
|
2390
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
2391
|
+
recovered += 1;
|
|
2392
|
+
}
|
|
2393
|
+
return recovered;
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
function removeCompactedLooseEntries(files, archiveId, archiveChecksum, entries) {
|
|
2397
|
+
if (!entries.length) return;
|
|
2398
|
+
const stagingDirectory = path.join(files.compactRuntime, crypto.randomUUID());
|
|
2399
|
+
fs.mkdirSync(stagingDirectory, { recursive: true });
|
|
2400
|
+
atomicWrite(path.join(stagingDirectory, "plan.json"), `${JSON.stringify({
|
|
2401
|
+
schema_version: 1,
|
|
2402
|
+
archive_id: archiveId,
|
|
2403
|
+
archive_checksum: archiveChecksum,
|
|
2404
|
+
entries: entries.map((entry) => ({ path: entry.path, sha256: entry.sha256 })),
|
|
2405
|
+
}, null, 2)}\n`);
|
|
2406
|
+
const shardDirectory = path.join(files.eventShards, "artifact-registry");
|
|
2407
|
+
try {
|
|
2408
|
+
for (const entry of entries) {
|
|
2409
|
+
const source = path.join(shardDirectory, entry.path);
|
|
2410
|
+
if (sha256(fs.readFileSync(source)) !== entry.sha256) {
|
|
2411
|
+
throw new Error(`Artifact history shard changed during compact: ${entry.path}.`);
|
|
2412
|
+
}
|
|
2413
|
+
fs.renameSync(source, path.join(stagingDirectory, entry.path));
|
|
2414
|
+
}
|
|
2415
|
+
fs.rmSync(stagingDirectory, { recursive: true, force: true });
|
|
2416
|
+
} catch (error) {
|
|
2417
|
+
restoreCompactionStaging(files, stagingDirectory, { entries });
|
|
2418
|
+
fs.rmSync(stagingDirectory, { recursive: true, force: true });
|
|
2419
|
+
throw error;
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
function compactArtifactHistory(files, flags, options) {
|
|
2424
|
+
const apply = flags.has("--apply");
|
|
2425
|
+
const action = () => {
|
|
2426
|
+
assertPathInside(files.root, files.artifactHistoryArchives);
|
|
2427
|
+
assertPathInside(files.root, files.compactRuntime);
|
|
2428
|
+
assertPathInside(files.root, path.join(files.eventShards, "artifact-registry"));
|
|
2429
|
+
const recoveredCompactionCount = recoverArtifactHistoryCompaction(files, apply, options);
|
|
2430
|
+
const trusted = readLatestHistoryAnchor(files, options);
|
|
2431
|
+
if (!trusted) throw new Error("Legacy artifact history compact requires a trusted history anchor.");
|
|
2432
|
+
const existingArchive = readArtifactHistoryArchives(files, options)
|
|
2433
|
+
.find(({ manifest }) => manifest.archive_id === trusted.anchor.anchor_id);
|
|
2434
|
+
if (existingArchive) {
|
|
2435
|
+
verifyFormalState(files, options, { history: true });
|
|
2436
|
+
const shardDirectory = path.join(files.eventShards, "artifact-registry");
|
|
2437
|
+
const looseEntries = existingArchive.bundle.entries.filter((entry) => {
|
|
2438
|
+
const looseFile = path.join(shardDirectory, entry.path);
|
|
2439
|
+
if (!fs.existsSync(looseFile)) return false;
|
|
2440
|
+
if (sha256(fs.readFileSync(looseFile)) !== entry.sha256) {
|
|
2441
|
+
throw new Error(`Artifact history loose shard conflicts with verified archive: ${entry.path}.`);
|
|
2442
|
+
}
|
|
2443
|
+
return true;
|
|
2444
|
+
});
|
|
2445
|
+
const looseBytes = looseEntries.reduce((total, entry) => total + entry.size_bytes, 0);
|
|
2446
|
+
const manifestBytes = fs.statSync(existingArchive.manifestFile).size;
|
|
2447
|
+
const result = {
|
|
2448
|
+
command: "compact",
|
|
2449
|
+
anchor_id: existingArchive.manifest.anchor_id,
|
|
2450
|
+
anchor_checksum: existingArchive.manifest.anchor_checksum,
|
|
2451
|
+
source_history_root_digest: existingArchive.manifest.source_history_root_digest,
|
|
2452
|
+
coverage: existingArchive.manifest.coverage,
|
|
2453
|
+
loose_file_count: looseEntries.length,
|
|
2454
|
+
loose_bytes: looseBytes,
|
|
2455
|
+
compressed_bundle_bytes: existingArchive.manifest.compressed_bundle_bytes,
|
|
2456
|
+
manifest_bytes: manifestBytes,
|
|
2457
|
+
archive_bytes: existingArchive.manifest.compressed_bundle_bytes + manifestBytes,
|
|
2458
|
+
expected_freed_bytes: looseBytes,
|
|
2459
|
+
archive_path: existingArchive.manifest.archive_path,
|
|
2460
|
+
manifest_path: portableReference(files, existingArchive.manifestFile),
|
|
2461
|
+
mode: apply ? "apply" : "dry-run",
|
|
2462
|
+
applied: false,
|
|
2463
|
+
deduplicated: true,
|
|
2464
|
+
recovered_compaction_count: recoveredCompactionCount,
|
|
2465
|
+
};
|
|
2466
|
+
if (!apply || !looseEntries.length) return result;
|
|
2467
|
+
removeCompactedLooseEntries(
|
|
2468
|
+
files,
|
|
2469
|
+
trusted.anchor.anchor_id,
|
|
2470
|
+
existingArchive.manifest.archive_checksum,
|
|
2471
|
+
looseEntries,
|
|
2472
|
+
);
|
|
2473
|
+
return { ...result, applied: true };
|
|
2474
|
+
}
|
|
2475
|
+
const plan = legacyArtifactHistoryArchive(files, trusted, options);
|
|
2476
|
+
const result = {
|
|
2477
|
+
...plan.report,
|
|
2478
|
+
mode: apply ? "apply" : "dry-run",
|
|
2479
|
+
applied: false,
|
|
2480
|
+
deduplicated: false,
|
|
2481
|
+
recovered_compaction_count: recoveredCompactionCount,
|
|
2482
|
+
};
|
|
2483
|
+
if (!apply) return result;
|
|
2484
|
+
if (fs.existsSync(plan.manifestFile)) {
|
|
2485
|
+
throw new Error(`Artifact history archive already exists: ${trusted.anchor.anchor_id}.`);
|
|
2486
|
+
}
|
|
2487
|
+
fs.mkdirSync(files.artifactHistoryArchives, { recursive: true });
|
|
2488
|
+
const orphanArchiveExists = fs.existsSync(plan.archiveFile);
|
|
2489
|
+
if (orphanArchiveExists
|
|
2490
|
+
&& (fs.statSync(plan.archiveFile).size !== plan.archiveBytes.length
|
|
2491
|
+
|| sha256(fs.readFileSync(plan.archiveFile)) !== plan.manifest.archive_checksum)) {
|
|
2492
|
+
throw new Error(`Artifact history archive conflicts with the compact plan: ${trusted.anchor.anchor_id}.`);
|
|
2493
|
+
}
|
|
2494
|
+
let archiveWritten = false;
|
|
2495
|
+
let manifestWritten = false;
|
|
2496
|
+
try {
|
|
2497
|
+
if (!orphanArchiveExists) {
|
|
2498
|
+
atomicWrite(plan.archiveFile, plan.archiveBytes);
|
|
2499
|
+
archiveWritten = true;
|
|
2500
|
+
}
|
|
2501
|
+
atomicWrite(plan.manifestFile, plan.manifestContent);
|
|
2502
|
+
manifestWritten = true;
|
|
2503
|
+
const verified = readArtifactHistoryArchives(files, options);
|
|
2504
|
+
if (!verified.some(({ manifest }) => manifest.archive_id === trusted.anchor.anchor_id)) {
|
|
2505
|
+
throw new Error(`Artifact history archive verification failed: ${trusted.anchor.anchor_id}.`);
|
|
2506
|
+
}
|
|
2507
|
+
} catch (error) {
|
|
2508
|
+
if (manifestWritten) fs.rmSync(plan.manifestFile, { force: true });
|
|
2509
|
+
if (archiveWritten) fs.rmSync(plan.archiveFile, { force: true });
|
|
2510
|
+
throw error;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
removeCompactedLooseEntries(
|
|
2514
|
+
files,
|
|
2515
|
+
trusted.anchor.anchor_id,
|
|
2516
|
+
plan.manifest.archive_checksum,
|
|
2517
|
+
plan.entries,
|
|
2518
|
+
);
|
|
2519
|
+
return { ...result, applied: true };
|
|
2520
|
+
};
|
|
2521
|
+
return apply
|
|
2522
|
+
? withLock(files, "artifact-history-compact", action)
|
|
2523
|
+
: withZeroWriteFormalState(files, action);
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
function pruneArtifactHistory(files, flags, options) {
|
|
2527
|
+
const apply = flags.has("--apply");
|
|
2528
|
+
const action = () => {
|
|
2529
|
+
const recovered_prune_count = recoverArtifactPrune(files, apply, options);
|
|
2530
|
+
const trusted = readLatestHistoryAnchor(files, options);
|
|
2531
|
+
if (!trusted) throw new Error("Artifact prune requires a trusted history anchor.");
|
|
2532
|
+
verifyFormalState(files, options, { history: true });
|
|
2533
|
+
const anchoredRevision = trusted.anchor.streams["artifact-registry"].revision;
|
|
2534
|
+
const shardDirectory = path.join(files.eventShards, "artifact-registry");
|
|
2535
|
+
const eligibleShards = fs.existsSync(shardDirectory)
|
|
2536
|
+
? fs.readdirSync(shardDirectory)
|
|
2537
|
+
.filter((name) => /\.ya?ml$/i.test(name))
|
|
2538
|
+
.map((name) => ({ name, record: readEventShardRecord(files, options, "artifact-registry", name) }))
|
|
2539
|
+
.filter(({ record }) => record.revision <= anchoredRevision)
|
|
2540
|
+
.sort((left, right) => left.record.revision - right.record.revision)
|
|
2541
|
+
: [];
|
|
2542
|
+
const result = {
|
|
2543
|
+
command: "artifact prune",
|
|
2544
|
+
mode: apply ? "apply" : "dry-run",
|
|
2545
|
+
applied: false,
|
|
2546
|
+
recovered_prune_count,
|
|
2547
|
+
anchor_id: trusted.anchor.anchor_id,
|
|
2548
|
+
anchor_checksum: trusted.anchor.anchor_checksum,
|
|
2549
|
+
pruned_through_revision: anchoredRevision,
|
|
2550
|
+
retained_from_revision: anchoredRevision + 1,
|
|
2551
|
+
eligible_shard_count: eligibleShards.length,
|
|
2552
|
+
eligible_shards: eligibleShards.map(({ name }) =>
|
|
2553
|
+
path.relative(files.root, path.join(shardDirectory, name)).replaceAll("\\", "/")),
|
|
2554
|
+
};
|
|
2555
|
+
if (!apply || !eligibleShards.length) return result;
|
|
2556
|
+
|
|
2557
|
+
const deletedShards = eligibleShards.map(({ name }) => ({
|
|
2558
|
+
name,
|
|
2559
|
+
checksum: fileChecksum(path.join(shardDirectory, name)),
|
|
2560
|
+
}));
|
|
2561
|
+
const timestamp = (options.now || (() => new Date().toISOString()))();
|
|
2562
|
+
const receiptBase = stableValue({
|
|
2563
|
+
schema_version: 1,
|
|
2564
|
+
stream: "artifact-registry",
|
|
2565
|
+
anchor_id: trusted.anchor.anchor_id,
|
|
2566
|
+
anchor_checksum: trusted.anchor.anchor_checksum,
|
|
2567
|
+
pruned_through_revision: anchoredRevision,
|
|
2568
|
+
retained_from_revision: anchoredRevision + 1,
|
|
2569
|
+
deleted_shard_count: deletedShards.length,
|
|
2570
|
+
deleted_shards_checksum: sha256(JSON.stringify(stableValue(deletedShards))),
|
|
2571
|
+
applied_at: timestamp,
|
|
2572
|
+
});
|
|
2573
|
+
const receipt = {
|
|
2574
|
+
...receiptBase,
|
|
2575
|
+
receipt_checksum: sha256(options.yaml.stringify(receiptBase)),
|
|
2576
|
+
};
|
|
2577
|
+
const receiptFile = path.join(files.artifactRetention, `${trusted.anchor.anchor_id}.yaml`);
|
|
2578
|
+
if (fs.existsSync(receiptFile)) {
|
|
2579
|
+
throw new Error(`Artifact prune receipt already exists: ${trusted.anchor.anchor_id}.`);
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
const stagingDirectory = path.join(files.pruneRuntime, crypto.randomUUID());
|
|
2583
|
+
fs.mkdirSync(stagingDirectory, { recursive: true });
|
|
2584
|
+
atomicWrite(path.join(stagingDirectory, "plan.json"), `${JSON.stringify({
|
|
2585
|
+
schema_version: 1,
|
|
2586
|
+
anchor_id: trusted.anchor.anchor_id,
|
|
2587
|
+
shards: deletedShards,
|
|
2588
|
+
}, null, 2)}\n`);
|
|
2589
|
+
let receiptWritten = false;
|
|
2590
|
+
try {
|
|
2591
|
+
for (const { name, checksum } of deletedShards) {
|
|
2592
|
+
const source = path.join(shardDirectory, name);
|
|
2593
|
+
if (fileChecksum(source) !== checksum) throw new Error(`Artifact shard changed during prune: ${name}.`);
|
|
2594
|
+
fs.renameSync(source, path.join(stagingDirectory, name));
|
|
2595
|
+
}
|
|
2596
|
+
atomicWrite(receiptFile, options.yaml.stringify(stableValue(receipt)));
|
|
2597
|
+
receiptWritten = true;
|
|
2598
|
+
fs.rmSync(stagingDirectory, { recursive: true, force: true });
|
|
2599
|
+
} catch (error) {
|
|
2600
|
+
if (!receiptWritten) {
|
|
2601
|
+
for (const { name } of deletedShards) {
|
|
2602
|
+
const staged = path.join(stagingDirectory, name);
|
|
2603
|
+
if (fs.existsSync(staged)) fs.renameSync(staged, path.join(shardDirectory, name));
|
|
2604
|
+
}
|
|
2605
|
+
fs.rmSync(stagingDirectory, { recursive: true, force: true });
|
|
2606
|
+
}
|
|
2607
|
+
throw error;
|
|
2608
|
+
}
|
|
2609
|
+
verifyFormalState(files, options, { history: true, sinceAnchor: true });
|
|
583
2610
|
return {
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
2611
|
+
...result,
|
|
2612
|
+
applied: true,
|
|
2613
|
+
deleted_shard_count: deletedShards.length,
|
|
2614
|
+
receipt_path: path.relative(files.root, receiptFile).replaceAll("\\", "/"),
|
|
2615
|
+
receipt_checksum: receipt.receipt_checksum,
|
|
588
2616
|
};
|
|
589
|
-
}
|
|
2617
|
+
};
|
|
2618
|
+
return apply
|
|
2619
|
+
? withLock(files, "artifact-prune", action)
|
|
2620
|
+
: withZeroWriteFormalState(files, action);
|
|
590
2621
|
}
|
|
591
2622
|
|
|
592
2623
|
function allowedCompletionStatuses(node) {
|
|
593
|
-
if (node === "
|
|
2624
|
+
if (node === "06-version-closeout" || node === "12-version-closeout") {
|
|
2625
|
+
return new Set(["release-ready"]);
|
|
2626
|
+
}
|
|
594
2627
|
if (node === "R08-production-deployment") return new Set(["deployed"]);
|
|
595
2628
|
if (node === "R09-production-verification") return new Set(["passed"]);
|
|
596
2629
|
if (node === "R10-release-archive") return new Set(["released", "archived"]);
|
|
597
2630
|
return SUCCESSFUL_ARTIFACT_STATUSES;
|
|
598
2631
|
}
|
|
599
2632
|
|
|
2633
|
+
function nodeModel(node) {
|
|
2634
|
+
if (SEVEN_NODE_DEVELOPMENT_ID_SET.has(node)) return "development-seven-node";
|
|
2635
|
+
if (LEGACY_DEVELOPMENT_ID_SET.has(node)) return "development-legacy-13";
|
|
2636
|
+
if (RELEASE_NODE_ID_SET.has(node)) return "release-r00-r10";
|
|
2637
|
+
return "legacy-production-read-only";
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
function nodeContractCatalog() {
|
|
2641
|
+
return Object.fromEntries([...NODE_RULES].map(([node, rule]) => [node, {
|
|
2642
|
+
node,
|
|
2643
|
+
model: nodeModel(node),
|
|
2644
|
+
scope: rule.scope,
|
|
2645
|
+
prerequisites: [...rule.prerequisites],
|
|
2646
|
+
internal_gates: [...rule.gates],
|
|
2647
|
+
completion: {
|
|
2648
|
+
requires_scope_manifest: rule.scope === "development" && SCOPED_COMPLETION_NODES.has(node),
|
|
2649
|
+
requires_candidate: rule.scope === "development" && CANDIDATE_REQUIRED_NODES.has(node),
|
|
2650
|
+
binds_candidate_on_start: CANDIDATE_BOUND_NODES.has(node),
|
|
2651
|
+
requires_candidate_assurance: node === "06-version-closeout",
|
|
2652
|
+
allowed_evidence_statuses: [...allowedCompletionStatuses(node)].sort(),
|
|
2653
|
+
},
|
|
2654
|
+
}]));
|
|
2655
|
+
}
|
|
2656
|
+
|
|
600
2657
|
function satisfiesMandatoryGate(node, decision) {
|
|
601
2658
|
return decision?.decision === "approved"
|
|
602
|
-
|| (node
|
|
2659
|
+
|| (UX_GATE_NODES.has(node) && decision?.decision === "not-required");
|
|
603
2660
|
}
|
|
604
2661
|
|
|
605
2662
|
function isProductTerminalEvidence(artifact) {
|
|
606
|
-
return artifact?.node
|
|
2663
|
+
return PRODUCT_TERMINAL_NODES.has(artifact?.node)
|
|
607
2664
|
&& /terminal/i.test(artifact.artifact_type || "")
|
|
608
2665
|
&& SUCCESSFUL_ARTIFACT_STATUSES.has(artifact.status);
|
|
609
2666
|
}
|
|
610
2667
|
|
|
611
|
-
function
|
|
2668
|
+
function artifactHandoffMutation(files, transition, timestamp, options) {
|
|
2669
|
+
const artifactIds = [...new Set(transition.evidence || [])];
|
|
2670
|
+
if (!artifactIds.length) return null;
|
|
2671
|
+
const registry = normalizeArtifactRegistry(
|
|
2672
|
+
readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml"),
|
|
2673
|
+
);
|
|
2674
|
+
for (const artifactId of artifactIds) {
|
|
2675
|
+
const artifact = registry.artifacts.find((item) => (item.artifact_id || item.id) === artifactId);
|
|
2676
|
+
if (!artifact) throw new Error(`Handoff artifact is not registered: ${artifactId}`);
|
|
2677
|
+
}
|
|
2678
|
+
const handoff = stableValue({
|
|
2679
|
+
workflow_event: transition.event,
|
|
2680
|
+
scope: transition.scope,
|
|
2681
|
+
iteration_id: transition.iteration_id,
|
|
2682
|
+
artifact_ids: artifactIds,
|
|
2683
|
+
...(transition.node ? { node: transition.node } : {}),
|
|
2684
|
+
...(transition.gate_id ? { gate_id: transition.gate_id } : {}),
|
|
2685
|
+
occurred_at: timestamp,
|
|
2686
|
+
});
|
|
2687
|
+
registry.handoffs = [
|
|
2688
|
+
...(Array.isArray(registry.handoffs) ? registry.handoffs : []),
|
|
2689
|
+
handoff,
|
|
2690
|
+
];
|
|
2691
|
+
const beforeRevision = registry.revision;
|
|
2692
|
+
registry.revision += 1;
|
|
2693
|
+
registry.updated_at = timestamp;
|
|
2694
|
+
const event = {
|
|
2695
|
+
type: "artifact-handoff-recorded",
|
|
2696
|
+
occurred_at: timestamp,
|
|
2697
|
+
workflow_event: transition.event,
|
|
2698
|
+
scope: transition.scope,
|
|
2699
|
+
iteration_id: transition.iteration_id,
|
|
2700
|
+
artifact_ids: artifactIds,
|
|
2701
|
+
};
|
|
2702
|
+
return {
|
|
2703
|
+
stream: "artifact-registry",
|
|
2704
|
+
file: files.artifactRegistry,
|
|
2705
|
+
value: registry,
|
|
2706
|
+
event,
|
|
2707
|
+
transaction: artifactRegistryTransaction(event, registry, beforeRevision, [], [handoff]),
|
|
2708
|
+
};
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
function persistScopeMutation(files, context, options) {
|
|
2712
|
+
const { state, scope, timestamp, transition, extraWrites = [] } = context;
|
|
2713
|
+
const beforeRevision = state.revision;
|
|
612
2714
|
state.schema_version = 2;
|
|
613
2715
|
state.revision += 1;
|
|
614
2716
|
scope.revision += 1;
|
|
@@ -616,23 +2718,199 @@ function persistScopeMutation(files, state, scope, timestamp, transition, eventR
|
|
|
616
2718
|
state.updated_at = timestamp;
|
|
617
2719
|
scope.last_transition = transition;
|
|
618
2720
|
state.last_transition = transition;
|
|
619
|
-
|
|
2721
|
+
const { event: type, ...eventDetails } = transition;
|
|
2722
|
+
const eventRecord = { type, ...eventDetails };
|
|
2723
|
+
const mutations = [{
|
|
2724
|
+
stream: "workflow-state",
|
|
2725
|
+
file: files.workflowState,
|
|
2726
|
+
value: state,
|
|
2727
|
+
event: eventRecord,
|
|
2728
|
+
transaction: workflowStateTransaction(eventRecord, state, transition.scope, beforeRevision),
|
|
2729
|
+
}];
|
|
2730
|
+
const handoffMutation = artifactHandoffMutation(files, transition, timestamp, options);
|
|
2731
|
+
if (handoffMutation) mutations.push(handoffMutation);
|
|
2732
|
+
commitFormalTransaction(files, mutations, extraWrites, options);
|
|
620
2733
|
exposeLegacyScopeAliases(state);
|
|
621
2734
|
}
|
|
622
2735
|
|
|
623
|
-
function
|
|
624
|
-
const
|
|
2736
|
+
function scopeManifestFromFlags(flags, scope, timestamp, amendment) {
|
|
2737
|
+
const manifestId = requiredFlag(flags, "--manifest-id");
|
|
2738
|
+
if (scope.scope_manifests.some((manifest) => manifest?.manifest_id === manifestId)) {
|
|
2739
|
+
throw new Error(`Scope manifest ID already exists: ${manifestId}.`);
|
|
2740
|
+
}
|
|
2741
|
+
const current = currentScopeManifest(scope);
|
|
2742
|
+
if (amendment) {
|
|
2743
|
+
if (!current) throw new Error("scope amend requires an existing frozen scope manifest.");
|
|
2744
|
+
const supersedes = requiredFlag(flags, "--supersedes");
|
|
2745
|
+
if (supersedes !== current.manifest_id) {
|
|
2746
|
+
throw new Error(`scope amend must supersede current manifest ${current.manifest_id}.`);
|
|
2747
|
+
}
|
|
2748
|
+
} else if (current) {
|
|
2749
|
+
throw new Error(`Scope is already frozen by ${current.manifest_id}; use scope amend.`);
|
|
2750
|
+
}
|
|
2751
|
+
const approvedBy = requiredFlag(flags, "--approved-by");
|
|
2752
|
+
const reason = requiredFlag(flags, "--reason");
|
|
2753
|
+
const manifest = {
|
|
2754
|
+
manifest_id: manifestId,
|
|
2755
|
+
iteration_id: scope.scope_id,
|
|
2756
|
+
revision: Number(current?.revision || 0) + 1,
|
|
2757
|
+
status: "frozen",
|
|
2758
|
+
supersedes: amendment ? current.manifest_id : null,
|
|
2759
|
+
slices: uniqueSortedValues(repeatedFlag(flags, "--slice"), "--slice"),
|
|
2760
|
+
requirements: uniqueSortedValues(repeatedFlag(flags, "--requirement"), "--requirement"),
|
|
2761
|
+
acceptance: uniqueSortedValues(repeatedFlag(flags, "--acceptance"), "--acceptance"),
|
|
2762
|
+
required_tasks: scopedReferences(flags, "--required-task", "task_id"),
|
|
2763
|
+
required_artifacts: scopedReferences(flags, "--required-artifact", "artifact_id"),
|
|
2764
|
+
approved_by: approvedBy,
|
|
2765
|
+
approval_reason: reason,
|
|
2766
|
+
frozen_at: timestamp,
|
|
2767
|
+
};
|
|
2768
|
+
manifest.manifest_checksum = scopeManifestChecksum(manifest);
|
|
2769
|
+
return manifest;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
function mutateScopeManifest(files, flags, options, amendment = false) {
|
|
2773
|
+
return withLock(files, "workflow-state", () => {
|
|
2774
|
+
const state = normalizeWorkflowState(readYaml(files.workflowState, options.yaml, "workflow-state.yaml"));
|
|
2775
|
+
assertWorkflowRevision(flags, state, "development");
|
|
2776
|
+
const scope = state.development_state;
|
|
2777
|
+
const iterationId = requiredFlag(flags, "--iteration-id");
|
|
2778
|
+
if (scope.scope_id !== iterationId) {
|
|
2779
|
+
throw new Error(`Iteration mismatch for development scope: state is ${scope.scope_id}, command is ${iterationId}.`);
|
|
2780
|
+
}
|
|
2781
|
+
if (!amendment
|
|
2782
|
+
&& ![...SCOPE_FREEZE_NODES].some((node) =>
|
|
2783
|
+
scope.active_nodes.includes(node) || scope.completed_nodes.includes(node))) {
|
|
2784
|
+
throw new Error(
|
|
2785
|
+
"scope freeze requires Delivery Readiness (03-delivery-readiness or legacy 07-iteration-planning) to be active or completed.",
|
|
2786
|
+
);
|
|
2787
|
+
}
|
|
2788
|
+
const timestamp = (options.now || (() => new Date().toISOString()))();
|
|
2789
|
+
const manifest = scopeManifestFromFlags(flags, scope, timestamp, amendment);
|
|
2790
|
+
scope.scope_contract_version = 1;
|
|
2791
|
+
scope.scope_manifests = [...scope.scope_manifests, manifest];
|
|
2792
|
+
scope.current_scope_manifest_id = manifest.manifest_id;
|
|
2793
|
+
const transition = {
|
|
2794
|
+
event: amendment ? "scope-amended" : "scope-frozen",
|
|
2795
|
+
scope: "development",
|
|
2796
|
+
iteration_id: iterationId,
|
|
2797
|
+
manifest_id: manifest.manifest_id,
|
|
2798
|
+
supersedes: manifest.supersedes,
|
|
2799
|
+
approved_by: manifest.approved_by,
|
|
2800
|
+
reason: manifest.approval_reason,
|
|
2801
|
+
occurred_at: timestamp,
|
|
2802
|
+
};
|
|
2803
|
+
persistScopeMutation(files, { state, scope, timestamp, transition }, options);
|
|
2804
|
+
return {
|
|
2805
|
+
command: amendment ? "scope amend" : "scope freeze",
|
|
2806
|
+
revision: state.revision,
|
|
2807
|
+
scope_revision: scope.revision,
|
|
2808
|
+
scope_manifest: manifest,
|
|
2809
|
+
workflow_state: state,
|
|
2810
|
+
};
|
|
2811
|
+
}, { waitForActive: flags.has("--expected-scope-revision") });
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
function recordScopeWarning(files, flags, options) {
|
|
625
2815
|
return withLock(files, "workflow-state", () => {
|
|
626
2816
|
const state = normalizeWorkflowState(readYaml(files.workflowState, options.yaml, "workflow-state.yaml"));
|
|
627
|
-
|
|
628
|
-
|
|
2817
|
+
assertWorkflowRevision(flags, state, "development");
|
|
2818
|
+
const scope = state.development_state;
|
|
2819
|
+
const iterationId = requiredFlag(flags, "--iteration-id");
|
|
2820
|
+
if (scope.scope_id !== iterationId) {
|
|
2821
|
+
throw new Error(`Iteration mismatch for development scope: state is ${scope.scope_id}, command is ${iterationId}.`);
|
|
2822
|
+
}
|
|
2823
|
+
const manifest = currentScopeManifest(scope);
|
|
2824
|
+
if (!manifest) throw new Error("scope warn requires a frozen scope manifest.");
|
|
2825
|
+
const metric = requiredFlag(flags, "--metric").toLowerCase();
|
|
2826
|
+
if (!SLICE_BUDGET_LIMITS.has(metric)) {
|
|
2827
|
+
throw new Error(`Unsupported Slice budget metric: ${metric}.`);
|
|
629
2828
|
}
|
|
2829
|
+
const observed = Number(requiredFlag(flags, "--observed"));
|
|
2830
|
+
const requestedLimit = optionalFlag(flags, "--limit");
|
|
2831
|
+
const limit = requestedLimit === null ? SLICE_BUDGET_LIMITS.get(metric) : Number(requestedLimit);
|
|
2832
|
+
if (!Number.isFinite(observed) || observed < 0 || !Number.isFinite(limit) || limit < 0) {
|
|
2833
|
+
throw new Error("Slice budget observed and limit values must be non-negative numbers.");
|
|
2834
|
+
}
|
|
2835
|
+
if (observed <= limit) throw new Error(`Slice budget is not exceeded for ${metric}: ${observed} <= ${limit}.`);
|
|
2836
|
+
const reason = requiredFlag(flags, "--reason");
|
|
2837
|
+
const timestamp = (options.now || (() => new Date().toISOString()))();
|
|
2838
|
+
const warning = stableValue({
|
|
2839
|
+
warning_id: optionalFlag(flags, "--warning-id")
|
|
2840
|
+
|| `WARN-${sha256(`${iterationId}:${metric}:${observed}:${limit}:${scope.scope_warnings.length + 1}`).slice(0, 20)}`,
|
|
2841
|
+
iteration_id: iterationId,
|
|
2842
|
+
scope_manifest_id: manifest.manifest_id,
|
|
2843
|
+
scope_manifest_checksum: manifest.manifest_checksum,
|
|
2844
|
+
metric,
|
|
2845
|
+
observed,
|
|
2846
|
+
limit,
|
|
2847
|
+
exceeded: true,
|
|
2848
|
+
action: "warn-only",
|
|
2849
|
+
reslice_performed: false,
|
|
2850
|
+
reason,
|
|
2851
|
+
recorded_at: timestamp,
|
|
2852
|
+
});
|
|
2853
|
+
if (scope.scope_warnings.some((item) => item?.warning_id === warning.warning_id)) {
|
|
2854
|
+
throw new Error(`Scope warning ID already exists: ${warning.warning_id}.`);
|
|
2855
|
+
}
|
|
2856
|
+
scope.scope_warnings = [...scope.scope_warnings, warning];
|
|
2857
|
+
const transition = {
|
|
2858
|
+
event: "scope-budget-warning",
|
|
2859
|
+
scope: "development",
|
|
2860
|
+
iteration_id: iterationId,
|
|
2861
|
+
manifest_id: manifest.manifest_id,
|
|
2862
|
+
warning_id: warning.warning_id,
|
|
2863
|
+
metric,
|
|
2864
|
+
observed,
|
|
2865
|
+
limit,
|
|
2866
|
+
action: warning.action,
|
|
2867
|
+
occurred_at: timestamp,
|
|
2868
|
+
};
|
|
2869
|
+
persistScopeMutation(files, { state, scope, timestamp, transition }, options);
|
|
2870
|
+
return {
|
|
2871
|
+
command: "scope warn",
|
|
2872
|
+
revision: state.revision,
|
|
2873
|
+
scope_revision: scope.revision,
|
|
2874
|
+
warning,
|
|
2875
|
+
scope_manifest: manifest,
|
|
2876
|
+
workflow_state: state,
|
|
2877
|
+
};
|
|
2878
|
+
}, { waitForActive: flags.has("--expected-scope-revision") });
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
function completionReceiptFromProjection(scope, node, projection, evidence, timestamp) {
|
|
2882
|
+
const manifest = currentScopeManifest(scope);
|
|
2883
|
+
const receipt = stableValue({
|
|
2884
|
+
receipt_id: `RECEIPT-${sha256(`${scope.scope_id}:${node}:${projection.node_attempt}:${manifest?.manifest_id || "legacy"}`).slice(0, 24)}`,
|
|
2885
|
+
iteration_id: scope.scope_id,
|
|
2886
|
+
node,
|
|
2887
|
+
node_attempt: projection.node_attempt,
|
|
2888
|
+
scope_manifest_id: manifest?.manifest_id || null,
|
|
2889
|
+
scope_manifest_revision: manifest?.revision || null,
|
|
2890
|
+
scope_manifest_checksum: manifest?.manifest_checksum || null,
|
|
2891
|
+
required_task_ids: projection.required_task_ids,
|
|
2892
|
+
required_artifact_ids: projection.required_artifact_ids,
|
|
2893
|
+
task_facts: projection.task_facts,
|
|
2894
|
+
artifact_facts: projection.artifact_facts,
|
|
2895
|
+
gate_facts: projection.gate_facts,
|
|
2896
|
+
candidate_id: projection.candidate_id,
|
|
2897
|
+
blocking_defect_ids: projection.blocking_defect_ids,
|
|
2898
|
+
completion_evidence: [...new Set(evidence)].sort(),
|
|
2899
|
+
completed_at: timestamp,
|
|
2900
|
+
});
|
|
2901
|
+
receipt.receipt_checksum = completionReceiptChecksum(receipt);
|
|
2902
|
+
return receipt;
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
function transitionWorkflow(files, flags, options) {
|
|
2906
|
+
return withLock(files, "workflow-state", () => {
|
|
2907
|
+
const state = normalizeWorkflowState(readYaml(files.workflowState, options.yaml, "workflow-state.yaml"));
|
|
630
2908
|
const event = requiredFlag(flags, "--event");
|
|
631
2909
|
const iterationId = requiredFlag(flags, "--iteration-id");
|
|
632
2910
|
const evidence = repeatedFlag(flags, "--evidence");
|
|
633
2911
|
const timestamp = (options.now || (() => new Date().toISOString()))();
|
|
634
2912
|
|
|
635
|
-
const persist = (scopeName, details = {}) => {
|
|
2913
|
+
const persist = (scopeName, details = {}, extraWrites = []) => {
|
|
636
2914
|
const scope = scopeState(state, scopeName);
|
|
637
2915
|
const transition = {
|
|
638
2916
|
event,
|
|
@@ -642,19 +2920,19 @@ function transitionWorkflow(files, flags, options) {
|
|
|
642
2920
|
...details,
|
|
643
2921
|
occurred_at: timestamp,
|
|
644
2922
|
};
|
|
645
|
-
persistScopeMutation(files,
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
...details,
|
|
2923
|
+
persistScopeMutation(files, {
|
|
2924
|
+
state,
|
|
2925
|
+
scope,
|
|
2926
|
+
timestamp,
|
|
2927
|
+
transition,
|
|
2928
|
+
extraWrites,
|
|
652
2929
|
}, options);
|
|
653
2930
|
return { command: "transition", event, revision: state.revision, scope_revision: scope.revision, workflow_state: state };
|
|
654
2931
|
};
|
|
655
2932
|
|
|
656
2933
|
if (event === "iteration-started" || event === "release-started") {
|
|
657
2934
|
const scopeName = event === "release-started" ? "release" : "development";
|
|
2935
|
+
assertWorkflowRevision(flags, state, scopeName);
|
|
658
2936
|
const current = scopeState(state, scopeName);
|
|
659
2937
|
if (current.scope_id === iterationId) throw new Error(`${scopeName} scope is already current: ${iterationId}`);
|
|
660
2938
|
if (current.active_nodes.length || current.blocked_nodes.length || current.pending_gates.length) {
|
|
@@ -665,12 +2943,14 @@ function transitionWorkflow(files, flags, options) {
|
|
|
665
2943
|
state[scopeName === "release" ? "release_state" : "development_state"] = normalizeScope({
|
|
666
2944
|
scope_id: iterationId,
|
|
667
2945
|
revision: nextRevision,
|
|
2946
|
+
...(scopeName === "development" ? { scope_contract_version: 1 } : {}),
|
|
668
2947
|
});
|
|
669
2948
|
const project = readYaml(files.project, options.yaml, "project.yaml");
|
|
670
2949
|
if (scopeName === "development") project.current_iteration_id = iterationId;
|
|
671
2950
|
else project.current_release_id = iterationId;
|
|
672
|
-
|
|
673
|
-
|
|
2951
|
+
return persist(scopeName, {}, [
|
|
2952
|
+
{ file: files.project, content: options.yaml.stringify(project) },
|
|
2953
|
+
]);
|
|
674
2954
|
}
|
|
675
2955
|
|
|
676
2956
|
if (event === "candidate-selected" || event === "release-selected") {
|
|
@@ -678,13 +2958,18 @@ function transitionWorkflow(files, flags, options) {
|
|
|
678
2958
|
const registry = normalizeArtifactRegistry(readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml"));
|
|
679
2959
|
const artifact = registry.artifacts.find((item) => (item.artifact_id || item.id) === artifactId);
|
|
680
2960
|
if (!artifact) throw new Error(`Selected artifact is not registered: ${artifactId}`);
|
|
2961
|
+
assertNotLegacyDevelopmentNode(artifact.node);
|
|
2962
|
+
if (artifactSuperseder(registry, artifact)) {
|
|
2963
|
+
throw new Error(`Selected artifact is superseded: ${artifactId}`);
|
|
2964
|
+
}
|
|
681
2965
|
const expectedNodes = event === "candidate-selected"
|
|
682
|
-
? new Set(["09-development", "R04-release-candidate"])
|
|
2966
|
+
? new Set(["04-implementation", "09-development", "R04-release-candidate"])
|
|
683
2967
|
: new Set(["12-release-preparation", "R06-release-preparation"]);
|
|
684
2968
|
if (!expectedNodes.has(artifact.node)) {
|
|
685
2969
|
throw new Error(`Selected artifact ${artifactId} belongs to ${artifact.node}, not ${[...expectedNodes].join(" or ")}.`);
|
|
686
2970
|
}
|
|
687
2971
|
const scopeName = event === "release-selected" ? "release" : scopeNameForNode(artifact.node);
|
|
2972
|
+
assertWorkflowRevision(flags, state, scopeName);
|
|
688
2973
|
const scope = scopeState(state, scopeName);
|
|
689
2974
|
if (!scope.scope_id) throw new Error(`${scopeName} scope must be started before ${event}.`);
|
|
690
2975
|
if (scope.scope_id !== iterationId) {
|
|
@@ -703,6 +2988,7 @@ function transitionWorkflow(files, flags, options) {
|
|
|
703
2988
|
|
|
704
2989
|
if (event === "version-closed" || event === "release-archived") {
|
|
705
2990
|
const scopeName = event === "release-archived" ? "release" : "development";
|
|
2991
|
+
assertWorkflowRevision(flags, state, scopeName);
|
|
706
2992
|
const scope = scopeState(state, scopeName);
|
|
707
2993
|
if (scope.scope_id !== iterationId) {
|
|
708
2994
|
throw new Error(`Iteration mismatch for ${scopeName} scope: state is ${scope.scope_id}, command is ${iterationId}.`);
|
|
@@ -711,10 +2997,20 @@ function transitionWorkflow(files, flags, options) {
|
|
|
711
2997
|
const registry = normalizeArtifactRegistry(readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml"));
|
|
712
2998
|
const artifact = registry.artifacts.find((item) => (item.artifact_id || item.id) === artifactId);
|
|
713
2999
|
if (!artifact) throw new Error(`Milestone artifact is not registered: ${artifactId}`);
|
|
714
|
-
|
|
3000
|
+
assertNotLegacyDevelopmentNode(artifact.node);
|
|
3001
|
+
const expectedNodes = event === "version-closed"
|
|
3002
|
+
? ["06-version-closeout"]
|
|
3003
|
+
: ["R10-release-archive"];
|
|
715
3004
|
const expectedStatus = event === "version-closed" ? "release-ready" : "released";
|
|
716
|
-
if (!scope.completed_nodes.includes(
|
|
717
|
-
|
|
3005
|
+
if (!expectedNodes.some((node) => scope.completed_nodes.includes(node))) {
|
|
3006
|
+
throw new Error(`${event} requires ${expectedNodes.join(" or ")} to be completed.`);
|
|
3007
|
+
}
|
|
3008
|
+
if (!expectedNodes.includes(artifact.node)) {
|
|
3009
|
+
throw new Error(`Milestone artifact ${artifactId} belongs to ${artifact.node}, not ${expectedNodes.join(" or ")}.`);
|
|
3010
|
+
}
|
|
3011
|
+
if (!scope.completed_nodes.includes(artifact.node)) {
|
|
3012
|
+
throw new Error(`${event} requires its artifact node ${artifact.node} to be completed.`);
|
|
3013
|
+
}
|
|
718
3014
|
if (artifact.status !== expectedStatus) {
|
|
719
3015
|
throw new Error(`Milestone artifact ${artifactId} must have status ${expectedStatus}, not ${artifact.status || "missing"}.`);
|
|
720
3016
|
}
|
|
@@ -742,12 +3038,15 @@ function transitionWorkflow(files, flags, options) {
|
|
|
742
3038
|
throw new Error(`Unknown workflow node: ${node}`);
|
|
743
3039
|
}
|
|
744
3040
|
const scopeName = scopeNameForNode(node);
|
|
3041
|
+
assertWorkflowRevision(flags, state, scopeName);
|
|
745
3042
|
const scope = scopeState(state, scopeName);
|
|
746
3043
|
if (!scope.scope_id) throw new Error(`${scopeName} scope must be started before node ${node}.`);
|
|
747
3044
|
if (scope.scope_id !== iterationId) {
|
|
748
3045
|
throw new Error(`Iteration mismatch for ${scopeName} scope: state is ${scope.scope_id}, command is ${iterationId}.`);
|
|
749
3046
|
}
|
|
750
3047
|
let reason = null;
|
|
3048
|
+
let completionReceipt = null;
|
|
3049
|
+
let boundCandidateId = null;
|
|
751
3050
|
|
|
752
3051
|
if (event === "node-started") {
|
|
753
3052
|
if (scope.blocked_nodes.includes(node)) throw new Error(`Blocked node must be unblocked before start: ${node}`);
|
|
@@ -772,8 +3071,40 @@ function transitionWorkflow(files, flags, options) {
|
|
|
772
3071
|
&& !evidenceArtifacts.some((artifact) => artifact.node === prerequisite
|
|
773
3072
|
&& SUCCESSFUL_ARTIFACT_STATUSES.has(artifact.status)));
|
|
774
3073
|
if (missing.length) throw new Error(`Node prerequisite is not satisfied: ${missing.join(", ")}.`);
|
|
3074
|
+
const nextAttempt = Number(scope.node_attempts[node] || 0) + 1;
|
|
3075
|
+
if (CANDIDATE_BOUND_NODES.has(node)) {
|
|
3076
|
+
const registry = normalizeArtifactRegistry(
|
|
3077
|
+
readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml"),
|
|
3078
|
+
);
|
|
3079
|
+
const candidate = registry.artifacts.find((artifact) =>
|
|
3080
|
+
(artifact.artifact_id || artifact.id) === scope.latest_candidate_id);
|
|
3081
|
+
if (!candidate || projectedArtifactStatus(registry, candidate) !== "immutable") {
|
|
3082
|
+
throw new Error(`Node ${node} requires a selected immutable candidate before start.`);
|
|
3083
|
+
}
|
|
3084
|
+
const integrity = artifactFileIntegrity(files, candidate);
|
|
3085
|
+
if (!integrity.valid) {
|
|
3086
|
+
throw new Error(`Selected candidate ${scope.latest_candidate_id} failed integrity: ${integrity.code}.`);
|
|
3087
|
+
}
|
|
3088
|
+
boundCandidateId = scope.latest_candidate_id;
|
|
3089
|
+
if (node === "06-version-closeout") {
|
|
3090
|
+
const manifest = currentScopeManifest(scope);
|
|
3091
|
+
const assuranceReceipt = [...scope.node_completion_receipts].reverse().find((receipt) =>
|
|
3092
|
+
receipt?.node === "05-candidate-assurance"
|
|
3093
|
+
&& receipt.candidate_id === boundCandidateId
|
|
3094
|
+
&& (!manifest || (receipt.scope_manifest_id === manifest.manifest_id
|
|
3095
|
+
&& receipt.scope_manifest_checksum === manifest.manifest_checksum)));
|
|
3096
|
+
if (!assuranceReceipt) {
|
|
3097
|
+
throw new Error(`Candidate Assurance has not completed for selected candidate ${boundCandidateId}.`);
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
scope.node_candidate_bindings[node] = {
|
|
3101
|
+
node_attempt: nextAttempt,
|
|
3102
|
+
candidate_id: boundCandidateId,
|
|
3103
|
+
bound_at: timestamp,
|
|
3104
|
+
};
|
|
3105
|
+
}
|
|
775
3106
|
scope.active_nodes = [...scope.active_nodes, node];
|
|
776
|
-
scope.node_attempts[node] =
|
|
3107
|
+
scope.node_attempts[node] = nextAttempt;
|
|
777
3108
|
} else if (event === "node-completed") {
|
|
778
3109
|
if (!scope.active_nodes.includes(node)) throw new Error(`Node is not active: ${node}`);
|
|
779
3110
|
if (!evidence.length) throw new Error("node-completed requires at least one --evidence artifact ID.");
|
|
@@ -783,27 +3114,54 @@ function transitionWorkflow(files, flags, options) {
|
|
|
783
3114
|
if (unsuccessful) {
|
|
784
3115
|
throw new Error(`Completion evidence ${unsuccessful.artifact_id || unsuccessful.id} has unsuccessful status ${unsuccessful.status}.`);
|
|
785
3116
|
}
|
|
3117
|
+
if (node === "04-implementation") {
|
|
3118
|
+
const selectedCandidate = artifacts.find((artifact) =>
|
|
3119
|
+
(artifact.artifact_id || artifact.id) === scope.latest_candidate_id
|
|
3120
|
+
&& artifact.status === "immutable");
|
|
3121
|
+
if (!selectedCandidate) {
|
|
3122
|
+
throw new Error("Node completion conditions are not satisfied for 04-implementation: candidate:selected.");
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
if (node === "05-candidate-assurance") {
|
|
3126
|
+
const attempt = Number(scope.node_attempts[node] || 1);
|
|
3127
|
+
const binding = scope.node_candidate_bindings[node];
|
|
3128
|
+
const mismatched = artifacts.find((artifact) =>
|
|
3129
|
+
Number(binding?.node_attempt) !== attempt
|
|
3130
|
+
|| artifact.candidate_id !== binding?.candidate_id);
|
|
3131
|
+
if (mismatched) {
|
|
3132
|
+
throw new Error(
|
|
3133
|
+
`Assurance evidence ${mismatched.artifact_id || mismatched.id} does not bind candidate ${binding?.candidate_id || "missing"}.`,
|
|
3134
|
+
);
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
786
3137
|
const attempt = Number(scope.node_attempts[node] || 1);
|
|
787
3138
|
const pendingForNode = scope.pending_gates.filter((gate) =>
|
|
788
3139
|
typeof gate === "string" || (gate?.node === node && Number(gate.node_attempt || attempt) === attempt));
|
|
789
3140
|
if (pendingForNode.length) throw new Error(`Node has pending gates: ${node}`);
|
|
790
|
-
const
|
|
791
|
-
|
|
792
|
-
if (decision?.node === node
|
|
793
|
-
&& (!decision.iteration_id || decision.iteration_id === iterationId)
|
|
794
|
-
&& Number(decision.node_attempt || attempt) === attempt
|
|
795
|
-
&& decision.gate_id) latestGateDecisions.set(decision.gate_id, decision);
|
|
796
|
-
}
|
|
797
|
-
const nonApproving = [...latestGateDecisions.values()].find((decision) =>
|
|
3141
|
+
const gateDecisions = latestGateDecisions(scope, node);
|
|
3142
|
+
const nonApproving = [...gateDecisions.values()].find((decision) =>
|
|
798
3143
|
!new Set(["approved", "not-required"]).has(decision.decision));
|
|
799
3144
|
if (nonApproving) {
|
|
800
3145
|
throw new Error(`Node has a non-approving Gate decision ${nonApproving.gate_id}: ${nonApproving.decision}.`);
|
|
801
3146
|
}
|
|
802
3147
|
const missingGates = (NODE_RULES.get(node)?.gates || []).filter((gateId) => {
|
|
803
|
-
const decision =
|
|
3148
|
+
const decision = gateDecisions.get(gateId);
|
|
804
3149
|
return !satisfiesMandatoryGate(node, decision);
|
|
805
3150
|
});
|
|
806
3151
|
if (missingGates.length) throw new Error(`Node is missing mandatory Gate decisions: ${missingGates.join(", ")}.`);
|
|
3152
|
+
if (scopeName === "development" && scope.scope_contract_version === 1
|
|
3153
|
+
&& SCOPED_COMPLETION_NODES.has(node)) {
|
|
3154
|
+
const registry = normalizeArtifactRegistry(
|
|
3155
|
+
readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml"),
|
|
3156
|
+
);
|
|
3157
|
+
const projection = deriveNodeStates(files, state, registry, options)[node];
|
|
3158
|
+
if (projection.state !== "ready-to-complete") {
|
|
3159
|
+
const conditions = [...projection.missing_conditions, ...projection.blocking_conditions];
|
|
3160
|
+
throw new Error(`Node completion conditions are not satisfied for ${node}: ${conditions.join(", ") || projection.state}.`);
|
|
3161
|
+
}
|
|
3162
|
+
completionReceipt = completionReceiptFromProjection(scope, node, projection, evidence, timestamp);
|
|
3163
|
+
scope.node_completion_receipts = [...scope.node_completion_receipts, completionReceipt];
|
|
3164
|
+
}
|
|
807
3165
|
scope.active_nodes = scope.active_nodes.filter((item) => item !== node);
|
|
808
3166
|
scope.completed_nodes = [...new Set([...scope.completed_nodes, node])];
|
|
809
3167
|
} else if (event === "node-blocked") {
|
|
@@ -822,8 +3180,14 @@ function transitionWorkflow(files, flags, options) {
|
|
|
822
3180
|
+ "node-blocked, node-unblocked, candidate-selected, release-selected, version-closed, or release-archived.",
|
|
823
3181
|
);
|
|
824
3182
|
}
|
|
825
|
-
|
|
826
|
-
|
|
3183
|
+
const result = persist(scopeName, {
|
|
3184
|
+
node,
|
|
3185
|
+
...(boundCandidateId ? { candidate_id: boundCandidateId } : {}),
|
|
3186
|
+
...(reason ? { reason } : {}),
|
|
3187
|
+
});
|
|
3188
|
+
if (completionReceipt) result.completion_receipt = completionReceipt;
|
|
3189
|
+
return result;
|
|
3190
|
+
}, { waitForActive: flags.has("--expected-scope-revision") });
|
|
827
3191
|
}
|
|
828
3192
|
|
|
829
3193
|
function registeredEvidence(files, evidence, options, node = null) {
|
|
@@ -834,6 +3198,9 @@ function registeredEvidence(files, evidence, options, node = null) {
|
|
|
834
3198
|
return evidence.map((artifactId) => {
|
|
835
3199
|
const artifact = registry.artifacts.find((item) => (item.artifact_id || item.id) === artifactId);
|
|
836
3200
|
if (!artifact) throw new Error(`Evidence artifact is not registered: ${artifactId}`);
|
|
3201
|
+
if (artifactSuperseder(registry, artifact)) {
|
|
3202
|
+
throw new Error(`Evidence artifact is superseded: ${artifactId}`);
|
|
3203
|
+
}
|
|
837
3204
|
if (node && artifact.node && artifact.node !== node) {
|
|
838
3205
|
throw new Error(`Evidence ${artifactId} belongs to ${artifact.node}, not ${node}.`);
|
|
839
3206
|
}
|
|
@@ -841,16 +3208,160 @@ function registeredEvidence(files, evidence, options, node = null) {
|
|
|
841
3208
|
});
|
|
842
3209
|
}
|
|
843
3210
|
|
|
844
|
-
function
|
|
845
|
-
|
|
3211
|
+
function retiredGateContractPlan(state, gateId) {
|
|
3212
|
+
assertRetiredGateContract(gateId);
|
|
3213
|
+
const affectedIterations = [];
|
|
3214
|
+
const expectedScopeRevisions = {};
|
|
3215
|
+
for (const [scopeName, scope] of [
|
|
3216
|
+
["development", state.development_state],
|
|
3217
|
+
["release", state.release_state],
|
|
3218
|
+
]) {
|
|
3219
|
+
const requests = scope.pending_gates
|
|
3220
|
+
.map((gate) => typeof gate === "string" ? { gate_id: gate, node: null } : gate)
|
|
3221
|
+
.filter((gate) => gate?.gate_id === gateId);
|
|
3222
|
+
if (!requests.length) continue;
|
|
3223
|
+
expectedScopeRevisions[scopeName] = scope.revision;
|
|
3224
|
+
for (const request of requests) {
|
|
3225
|
+
affectedIterations.push({
|
|
3226
|
+
iteration_id: request.iteration_id || scope.scope_id,
|
|
3227
|
+
scope: scopeName,
|
|
3228
|
+
node: request.node || null,
|
|
3229
|
+
node_attempt: Number(request.node_attempt || scope.node_attempts[request.node] || 1),
|
|
3230
|
+
scope_revision: scope.revision,
|
|
3231
|
+
});
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
return {
|
|
3235
|
+
command: "contract retire",
|
|
3236
|
+
dry_run: true,
|
|
3237
|
+
applied: false,
|
|
3238
|
+
gate_id: gateId,
|
|
3239
|
+
replacement_gate_id: gateId === "UX-LF" ? "UX-UI" : null,
|
|
3240
|
+
fresh_approval_required: gateId === "UX-LF",
|
|
3241
|
+
affected_iterations: affectedIterations,
|
|
3242
|
+
expected_scope_revisions: expectedScopeRevisions,
|
|
3243
|
+
};
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
function retireGateContract(files, flags, options) {
|
|
3247
|
+
const gateId = requiredFlag(flags, "--gate-id");
|
|
3248
|
+
if (flags.has("--dry-run")) return withReadableFormalState(files, () => {
|
|
3249
|
+
const state = normalizeWorkflowState(
|
|
3250
|
+
readYaml(files.workflowState, options.yaml, "workflow-state.yaml"),
|
|
3251
|
+
);
|
|
3252
|
+
return retiredGateContractPlan(state, gateId);
|
|
3253
|
+
});
|
|
846
3254
|
return withLock(files, "workflow-state", () => {
|
|
847
3255
|
const state = normalizeWorkflowState(
|
|
848
3256
|
readYaml(files.workflowState, options.yaml, "workflow-state.yaml"),
|
|
849
3257
|
);
|
|
850
|
-
|
|
851
|
-
|
|
3258
|
+
const plan = retiredGateContractPlan(state, gateId);
|
|
3259
|
+
if (!plan.affected_iterations.length) {
|
|
3260
|
+
return { ...plan, dry_run: false, applied: false, receipts: [], workflow_state: state };
|
|
3261
|
+
}
|
|
3262
|
+
const affectedScopes = Object.keys(plan.expected_scope_revisions);
|
|
3263
|
+
if (affectedScopes.length !== 1) {
|
|
3264
|
+
throw new Error(`${gateId} retirement must affect exactly one formal scope per transaction.`);
|
|
3265
|
+
}
|
|
3266
|
+
const scopeName = affectedScopes[0];
|
|
3267
|
+
assertWorkflowRevision(flags, state, scopeName);
|
|
3268
|
+
const actor = requiredFlag(flags, "--actor");
|
|
3269
|
+
if (actor !== "system:package-upgrade") {
|
|
3270
|
+
throw new Error("Contract retirement must be executed by system:package-upgrade.");
|
|
852
3271
|
}
|
|
3272
|
+
const reason = requiredFlag(flags, "--reason");
|
|
3273
|
+
const contractVersion = requiredFlag(flags, "--contract-version");
|
|
3274
|
+
const scope = scopeState(state, scopeName);
|
|
3275
|
+
const pending = scope.pending_gates
|
|
3276
|
+
.map((gate) => typeof gate === "string" ? { gate_id: gate, node: null } : gate);
|
|
3277
|
+
const retiredRequests = pending.filter((gate) => gate?.gate_id === gateId);
|
|
3278
|
+
const timestamp = (options.now || (() => new Date().toISOString()))();
|
|
3279
|
+
const receipts = retiredRequests.map((request) => {
|
|
3280
|
+
const requestChecksum = sha256(JSON.stringify(stableValue(request)));
|
|
3281
|
+
const receipt = stableValue({
|
|
3282
|
+
receipt_id: `RETIRE-${gateId}-${requestChecksum.slice(0, 20).toUpperCase()}`,
|
|
3283
|
+
gate_id: gateId,
|
|
3284
|
+
iteration_id: request.iteration_id || scope.scope_id,
|
|
3285
|
+
scope: scopeName,
|
|
3286
|
+
node: request.node || null,
|
|
3287
|
+
node_attempt: Number(request.node_attempt || scope.node_attempts[request.node] || 1),
|
|
3288
|
+
request_checksum: requestChecksum,
|
|
3289
|
+
request_evidence: [...new Set(request.evidence || [])].sort(),
|
|
3290
|
+
contract_version: contractVersion,
|
|
3291
|
+
disposition: "contract-retired",
|
|
3292
|
+
human_approval_created: false,
|
|
3293
|
+
renewed_gate_id: gateId === "UX-LF" ? "UX-UI" : null,
|
|
3294
|
+
retired_by: actor,
|
|
3295
|
+
reason,
|
|
3296
|
+
retired_at: timestamp,
|
|
3297
|
+
});
|
|
3298
|
+
receipt.receipt_checksum = contractRetirementReceiptChecksum(receipt);
|
|
3299
|
+
return receipt;
|
|
3300
|
+
});
|
|
3301
|
+
const remaining = pending.filter((gate) => gate?.gate_id !== gateId);
|
|
3302
|
+
const existingUxUiRequests = remaining.filter((gate) => gate?.gate_id === "UX-UI");
|
|
3303
|
+
const renewalSources = existingUxUiRequests.length ? existingUxUiRequests : retiredRequests;
|
|
3304
|
+
const renewedRequests = gateId === "UX-LF"
|
|
3305
|
+
? renewalSources.map((request, index) => {
|
|
3306
|
+
const retiredIndex = Math.max(0, retiredRequests.findIndex((candidate) =>
|
|
3307
|
+
(candidate.iteration_id || scope.scope_id) === (request.iteration_id || scope.scope_id)));
|
|
3308
|
+
const retiredRequest = retiredRequests[retiredIndex] || retiredRequests[index] || request;
|
|
3309
|
+
const receipt = receipts[retiredIndex] || receipts[index];
|
|
3310
|
+
return {
|
|
3311
|
+
...request,
|
|
3312
|
+
gate_id: "UX-UI",
|
|
3313
|
+
renewed_from_gate_id: gateId,
|
|
3314
|
+
iteration_id: request.iteration_id || scope.scope_id,
|
|
3315
|
+
scope: scopeName,
|
|
3316
|
+
node: "02-solution-design",
|
|
3317
|
+
node_attempt: Number(retiredRequest.node_attempt || request.node_attempt
|
|
3318
|
+
|| scope.node_attempts[retiredRequest.node] || 1),
|
|
3319
|
+
evidence: [],
|
|
3320
|
+
prototype_revision_required: true,
|
|
3321
|
+
required_evidence_type: "prototype-revision",
|
|
3322
|
+
retirement_receipt_checksum: receipt.receipt_checksum,
|
|
3323
|
+
requested_at: request.requested_at || timestamp,
|
|
3324
|
+
};
|
|
3325
|
+
})
|
|
3326
|
+
: [];
|
|
3327
|
+
scope.pending_gates = [
|
|
3328
|
+
...remaining.filter((gate) => gate?.gate_id !== "UX-UI"),
|
|
3329
|
+
...renewedRequests,
|
|
3330
|
+
];
|
|
3331
|
+
scope.contract_retirement_receipts = [
|
|
3332
|
+
...scope.contract_retirement_receipts,
|
|
3333
|
+
...receipts,
|
|
3334
|
+
];
|
|
3335
|
+
const transition = {
|
|
3336
|
+
event: "gate-contract-retired",
|
|
3337
|
+
scope: scopeName,
|
|
3338
|
+
gate_id: gateId,
|
|
3339
|
+
iteration_id: scope.scope_id,
|
|
3340
|
+
retirement_receipt_ids: receipts.map((receipt) => receipt.receipt_id),
|
|
3341
|
+
renewed_gate_ids: renewedRequests.map((request) => request.gate_id),
|
|
3342
|
+
occurred_at: timestamp,
|
|
3343
|
+
};
|
|
3344
|
+
persistScopeMutation(files, { state, scope, timestamp, transition }, options);
|
|
3345
|
+
return {
|
|
3346
|
+
...plan,
|
|
3347
|
+
dry_run: false,
|
|
3348
|
+
applied: true,
|
|
3349
|
+
receipts,
|
|
3350
|
+
renewed_requests: renewedRequests,
|
|
3351
|
+
revision: state.revision,
|
|
3352
|
+
scope_revision: scope.revision,
|
|
3353
|
+
workflow_state: state,
|
|
3354
|
+
};
|
|
3355
|
+
}, { waitForActive: flags.has("--expected-scope-revision") });
|
|
3356
|
+
}
|
|
3357
|
+
|
|
3358
|
+
function requestGate(files, flags, options) {
|
|
3359
|
+
return withLock(files, "workflow-state", () => {
|
|
3360
|
+
const state = normalizeWorkflowState(
|
|
3361
|
+
readYaml(files.workflowState, options.yaml, "workflow-state.yaml"),
|
|
3362
|
+
);
|
|
853
3363
|
const gateId = requiredFlag(flags, "--gate-id");
|
|
3364
|
+
assertGateContractWritable(gateId);
|
|
854
3365
|
const iterationId = requiredFlag(flags, "--iteration-id");
|
|
855
3366
|
const node = requiredFlag(flags, "--node");
|
|
856
3367
|
if (!WRITABLE_NODE_IDS.has(node)) {
|
|
@@ -858,6 +3369,7 @@ function requestGate(files, flags, options) {
|
|
|
858
3369
|
throw new Error(`Unknown workflow node: ${node}`);
|
|
859
3370
|
}
|
|
860
3371
|
const scopeName = scopeNameForNode(node);
|
|
3372
|
+
assertWorkflowRevision(flags, state, scopeName);
|
|
861
3373
|
const scope = scopeState(state, scopeName);
|
|
862
3374
|
if (scope.scope_id !== iterationId) {
|
|
863
3375
|
throw new Error(`Iteration mismatch for ${scopeName} scope: state is ${scope.scope_id}, command is ${iterationId}.`);
|
|
@@ -889,34 +3401,24 @@ function requestGate(files, flags, options) {
|
|
|
889
3401
|
evidence,
|
|
890
3402
|
occurred_at: timestamp,
|
|
891
3403
|
};
|
|
892
|
-
persistScopeMutation(files, state, scope, timestamp, transition,
|
|
893
|
-
type: "gate-requested",
|
|
894
|
-
occurred_at: timestamp,
|
|
895
|
-
scope: scopeName,
|
|
896
|
-
gate_id: gateId,
|
|
897
|
-
node,
|
|
898
|
-
iteration_id: iterationId,
|
|
899
|
-
evidence,
|
|
900
|
-
}, options);
|
|
3404
|
+
persistScopeMutation(files, { state, scope, timestamp, transition }, options);
|
|
901
3405
|
return {
|
|
902
3406
|
command: "gate request",
|
|
903
3407
|
revision: state.revision,
|
|
3408
|
+
scope_revision: scope.revision,
|
|
904
3409
|
gate: request,
|
|
905
3410
|
workflow_state: state,
|
|
906
3411
|
};
|
|
907
|
-
});
|
|
3412
|
+
}, { waitForActive: flags.has("--expected-scope-revision") });
|
|
908
3413
|
}
|
|
909
3414
|
|
|
910
3415
|
function decideGate(files, flags, options) {
|
|
911
|
-
const expected = expectedRevision(flags);
|
|
912
3416
|
return withLock(files, "workflow-state", () => {
|
|
913
3417
|
const state = normalizeWorkflowState(
|
|
914
3418
|
readYaml(files.workflowState, options.yaml, "workflow-state.yaml"),
|
|
915
3419
|
);
|
|
916
|
-
if (state.revision !== expected) {
|
|
917
|
-
throw new Error(`Workflow state revision conflict: expected ${expected}, actual ${state.revision}.`);
|
|
918
|
-
}
|
|
919
3420
|
const gateId = requiredFlag(flags, "--gate-id");
|
|
3421
|
+
assertGateContractWritable(gateId);
|
|
920
3422
|
const scopes = [state.development_state, state.release_state];
|
|
921
3423
|
let owningScope = null;
|
|
922
3424
|
let pending = [];
|
|
@@ -933,7 +3435,9 @@ function decideGate(files, flags, options) {
|
|
|
933
3435
|
}
|
|
934
3436
|
}
|
|
935
3437
|
if (!request) throw new Error(`Gate is not pending: ${gateId}`);
|
|
3438
|
+
assertNotLegacyDevelopmentNode(request.node);
|
|
936
3439
|
const scopeName = request.scope || scopeNameForNode(request.node);
|
|
3440
|
+
assertWorkflowRevision(flags, state, scopeName);
|
|
937
3441
|
const decision = requiredFlag(flags, "--decision").toLowerCase().replaceAll("_", "-");
|
|
938
3442
|
if (!GATE_DECISIONS.has(decision)) throw new Error(`Unsupported gate decision: ${decision}`);
|
|
939
3443
|
const actor = requiredFlag(flags, "--actor");
|
|
@@ -941,8 +3445,15 @@ function decideGate(files, flags, options) {
|
|
|
941
3445
|
const evidence = repeatedFlag(flags, "--evidence");
|
|
942
3446
|
const evidenceArtifacts = registeredEvidence(files, evidence, options);
|
|
943
3447
|
const crossNodeEvidence = evidenceArtifacts.filter((artifact) => artifact.node !== request.node);
|
|
3448
|
+
const freshPrototypeRevisionRequired = request.prototype_revision_required === true;
|
|
3449
|
+
if (freshPrototypeRevisionRequired && decision === "approved" && !options.pageReviewReceiptWrite) {
|
|
3450
|
+
throw new Error("Renewed UX-UI approval requires a fresh Prototype revision through Workflow Manager prototype review.");
|
|
3451
|
+
}
|
|
3452
|
+
if (freshPrototypeRevisionRequired && decision === "not-required") {
|
|
3453
|
+
throw new Error("Renewed UX-UI cannot be not-required; a fresh Prototype revision and Workflow Manager approval are required.");
|
|
3454
|
+
}
|
|
944
3455
|
if (decision === "not-required") {
|
|
945
|
-
if (request.node
|
|
3456
|
+
if (!UX_GATE_NODES.has(request.node) || !UX_GATE_IDS.has(gateId)) {
|
|
946
3457
|
throw new Error(`Gate ${gateId} cannot use not-required.`);
|
|
947
3458
|
}
|
|
948
3459
|
if (!evidenceArtifacts.some(isProductTerminalEvidence)) {
|
|
@@ -973,6 +3484,68 @@ function decideGate(files, flags, options) {
|
|
|
973
3484
|
evidence,
|
|
974
3485
|
decided_at: timestamp,
|
|
975
3486
|
};
|
|
3487
|
+
const extraWrites = [];
|
|
3488
|
+
if (options.pageReviewReceiptWrite) {
|
|
3489
|
+
if (gateId !== "UX-UI" || decision !== "approved") {
|
|
3490
|
+
throw new Error("A Prototype Page review receipt can only complete an approved UX-UI decision.");
|
|
3491
|
+
}
|
|
3492
|
+
const receiptWrite = options.pageReviewReceiptWrite;
|
|
3493
|
+
const receiptRoot = path.resolve(files.delivery, "reviews", "prototype-pages");
|
|
3494
|
+
const receiptFile = path.resolve(String(receiptWrite.file || ""));
|
|
3495
|
+
const relativeReceipt = path.relative(receiptRoot, receiptFile);
|
|
3496
|
+
if (!relativeReceipt || relativeReceipt.startsWith("..") || path.isAbsolute(relativeReceipt)) {
|
|
3497
|
+
throw new Error("Prototype Page review receipt must stay within the formal review receipt directory.");
|
|
3498
|
+
}
|
|
3499
|
+
if (fs.existsSync(receiptFile)) throw new Error("Prototype Page review receipt is immutable and already exists.");
|
|
3500
|
+
const receipt = options.yaml.parse(String(receiptWrite.content || ""));
|
|
3501
|
+
if (!receipt || receipt.artifact_type !== "prototype-page-review-receipt"
|
|
3502
|
+
|| receipt.decision !== "approved" || receipt.actor !== actor
|
|
3503
|
+
|| receipt.iteration_id !== record.iteration_id) {
|
|
3504
|
+
throw new Error("Prototype Page review receipt does not match the UX-UI decision.");
|
|
3505
|
+
}
|
|
3506
|
+
const { receipt_checksum: ignored, ...unsignedReceipt } = receipt;
|
|
3507
|
+
if (receipt.receipt_checksum !== sha256(JSON.stringify(stableValue(unsignedReceipt)))) {
|
|
3508
|
+
throw new Error("Prototype Page review receipt checksum is invalid.");
|
|
3509
|
+
}
|
|
3510
|
+
const expectedReceiptFile = path.resolve(
|
|
3511
|
+
receiptRoot,
|
|
3512
|
+
String(receipt.prototype_revision_id || ""),
|
|
3513
|
+
`${String(receipt.receipt_id || "")}.yaml`,
|
|
3514
|
+
);
|
|
3515
|
+
if (receiptFile !== expectedReceiptFile) {
|
|
3516
|
+
throw new Error("Prototype Page review receipt path does not match its identities.");
|
|
3517
|
+
}
|
|
3518
|
+
const requestedEvidenceIds = new Set(request.evidence || []);
|
|
3519
|
+
const revisionEvidence = evidenceArtifacts.find((artifact) =>
|
|
3520
|
+
(freshPrototypeRevisionRequired || requestedEvidenceIds.has(artifact.artifact_id || artifact.id))
|
|
3521
|
+
&& artifact.artifact_type === "prototype-revision"
|
|
3522
|
+
&& artifact.iteration_id === receipt.iteration_id
|
|
3523
|
+
&& artifact.checksum_algorithm === "sha256"
|
|
3524
|
+
&& artifact.checksum === receipt.prototype_revision_checksum);
|
|
3525
|
+
if (!revisionEvidence || !artifactFileIntegrity(files, revisionEvidence).valid) {
|
|
3526
|
+
throw new Error("UX-UI Gate does not bind the exact Prototype revision evidence in the Page receipt.");
|
|
3527
|
+
}
|
|
3528
|
+
const revision = readYaml(
|
|
3529
|
+
path.resolve(files.root, revisionEvidence.path),
|
|
3530
|
+
options.yaml,
|
|
3531
|
+
"Prototype revision Gate evidence",
|
|
3532
|
+
);
|
|
3533
|
+
if (revision.artifact_type !== "prototype-revision"
|
|
3534
|
+
|| revision.artifact_id !== receipt.prototype_revision_id
|
|
3535
|
+
|| revision.prototype_id !== receipt.prototype_id
|
|
3536
|
+
|| revision.iteration_id !== receipt.iteration_id) {
|
|
3537
|
+
throw new Error("UX-UI Gate does not bind the exact Prototype revision evidence in the Page receipt.");
|
|
3538
|
+
}
|
|
3539
|
+
record.prototype_revision_id = receipt.prototype_revision_id;
|
|
3540
|
+
record.prototype_revision_checksum = receipt.prototype_revision_checksum;
|
|
3541
|
+
record.page_review_receipt = {
|
|
3542
|
+
receipt_id: receipt.receipt_id,
|
|
3543
|
+
page_id: receipt.page_id,
|
|
3544
|
+
ref: path.relative(files.root, receiptFile).replaceAll("\\", "/"),
|
|
3545
|
+
checksum: receipt.receipt_checksum,
|
|
3546
|
+
};
|
|
3547
|
+
extraWrites.push({ file: receiptFile, content: receiptWrite.content });
|
|
3548
|
+
}
|
|
976
3549
|
owningScope.pending_gates = pending.filter((gate) => gate?.gate_id !== gateId);
|
|
977
3550
|
owningScope.gate_decisions = [...owningScope.gate_decisions, record];
|
|
978
3551
|
const transition = {
|
|
@@ -981,42 +3554,82 @@ function decideGate(files, flags, options) {
|
|
|
981
3554
|
...record,
|
|
982
3555
|
occurred_at: timestamp,
|
|
983
3556
|
};
|
|
984
|
-
persistScopeMutation(files,
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
3557
|
+
persistScopeMutation(files, {
|
|
3558
|
+
state,
|
|
3559
|
+
scope: owningScope,
|
|
3560
|
+
timestamp,
|
|
3561
|
+
transition,
|
|
3562
|
+
extraWrites,
|
|
989
3563
|
}, options);
|
|
990
3564
|
return {
|
|
991
3565
|
command: "gate decide",
|
|
992
3566
|
revision: state.revision,
|
|
3567
|
+
scope_revision: owningScope.revision,
|
|
993
3568
|
gate: record,
|
|
994
3569
|
workflow_state: state,
|
|
995
3570
|
};
|
|
996
|
-
});
|
|
3571
|
+
}, { waitForActive: flags.has("--expected-scope-revision") });
|
|
997
3572
|
}
|
|
998
3573
|
|
|
999
|
-
function readEvents(files, errors) {
|
|
3574
|
+
function readEvents(files, errors, readOptions = {}) {
|
|
1000
3575
|
if (!fs.existsSync(files.events)) return [];
|
|
1001
3576
|
const events = [];
|
|
1002
|
-
const
|
|
3577
|
+
const offsetBytes = Number(readOptions.offsetBytes || 0);
|
|
3578
|
+
const lineOffset = Number(readOptions.lineOffset || 0);
|
|
3579
|
+
const descriptor = fs.openSync(files.events, "r");
|
|
3580
|
+
let content;
|
|
3581
|
+
try {
|
|
3582
|
+
const size = fs.fstatSync(descriptor).size;
|
|
3583
|
+
if (!Number.isInteger(offsetBytes) || offsetBytes < 0 || offsetBytes > size) {
|
|
3584
|
+
errors.push(`Audit event byte offset is invalid: ${offsetBytes}.`);
|
|
3585
|
+
return events;
|
|
3586
|
+
}
|
|
3587
|
+
content = Buffer.alloc(size - offsetBytes);
|
|
3588
|
+
let bytesRead = 0;
|
|
3589
|
+
while (bytesRead < content.length) {
|
|
3590
|
+
const count = fs.readSync(
|
|
3591
|
+
descriptor,
|
|
3592
|
+
content,
|
|
3593
|
+
bytesRead,
|
|
3594
|
+
content.length - bytesRead,
|
|
3595
|
+
offsetBytes + bytesRead,
|
|
3596
|
+
);
|
|
3597
|
+
if (count === 0) break;
|
|
3598
|
+
bytesRead += count;
|
|
3599
|
+
}
|
|
3600
|
+
if (bytesRead !== content.length) {
|
|
3601
|
+
errors.push(`Audit event suffix could not be read completely from byte offset ${offsetBytes}.`);
|
|
3602
|
+
return events;
|
|
3603
|
+
}
|
|
3604
|
+
} finally {
|
|
3605
|
+
fs.closeSync(descriptor);
|
|
3606
|
+
}
|
|
3607
|
+
const lines = content.toString("utf8").split(/\r?\n/);
|
|
1003
3608
|
for (let index = 0; index < lines.length; index += 1) {
|
|
1004
3609
|
if (!lines[index].trim()) continue;
|
|
1005
3610
|
try {
|
|
1006
3611
|
const event = JSON.parse(lines[index]);
|
|
1007
3612
|
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
|
1008
|
-
errors.push(`Audit event at line ${index + 1} must be a JSON object.`);
|
|
3613
|
+
errors.push(`Audit event at line ${lineOffset + index + 1} must be a JSON object.`);
|
|
1009
3614
|
} else {
|
|
1010
3615
|
events.push(event);
|
|
1011
3616
|
}
|
|
1012
3617
|
} catch {
|
|
1013
|
-
errors.push(`Audit event at line ${index + 1} is not valid JSON.`);
|
|
3618
|
+
errors.push(`Audit event at line ${lineOffset + index + 1} is not valid JSON.`);
|
|
1014
3619
|
}
|
|
1015
3620
|
}
|
|
1016
3621
|
return events;
|
|
1017
3622
|
}
|
|
1018
3623
|
|
|
1019
|
-
function verifyStream(
|
|
3624
|
+
function verifyStream(
|
|
3625
|
+
file,
|
|
3626
|
+
value,
|
|
3627
|
+
stream,
|
|
3628
|
+
events,
|
|
3629
|
+
errors,
|
|
3630
|
+
allowedSchemas = new Set([1]),
|
|
3631
|
+
base = {},
|
|
3632
|
+
) {
|
|
1020
3633
|
if (!allowedSchemas.has(value.schema_version)) {
|
|
1021
3634
|
errors.push(`${stream} schema_version must be one of ${[...allowedSchemas].join(", ")}.`);
|
|
1022
3635
|
}
|
|
@@ -1024,18 +3637,27 @@ function verifyStream(file, value, stream, events, errors, allowedSchemas = new
|
|
|
1024
3637
|
errors.push(`${stream} revision must be a non-negative integer.`);
|
|
1025
3638
|
return;
|
|
1026
3639
|
}
|
|
3640
|
+
const baseRevision = Number(base.revision || 0);
|
|
3641
|
+
if (!Number.isInteger(baseRevision) || baseRevision < 0 || value.revision < baseRevision) {
|
|
3642
|
+
errors.push(`${stream} revision predates the trusted history anchor.`);
|
|
3643
|
+
return;
|
|
3644
|
+
}
|
|
1027
3645
|
const streamEvents = events.filter((event) => event.stream === stream);
|
|
1028
|
-
|
|
1029
|
-
|
|
3646
|
+
const expectedEventCount = value.revision - baseRevision;
|
|
3647
|
+
if (expectedEventCount === 0) {
|
|
3648
|
+
if (streamEvents.length) errors.push(`${stream} has audit events after its current revision.`);
|
|
3649
|
+
if (base.viewChecksum && fileChecksum(file) !== base.viewChecksum) {
|
|
3650
|
+
errors.push(`Audit checksum mismatch for ${stream}.`);
|
|
3651
|
+
}
|
|
1030
3652
|
return;
|
|
1031
3653
|
}
|
|
1032
|
-
if (streamEvents.length !==
|
|
3654
|
+
if (streamEvents.length !== expectedEventCount) {
|
|
1033
3655
|
errors.push(
|
|
1034
|
-
`Audit event count mismatch for ${stream}: expected ${
|
|
3656
|
+
`Audit event count mismatch for ${stream}: expected ${expectedEventCount}, actual ${streamEvents.length}.`,
|
|
1035
3657
|
);
|
|
1036
3658
|
}
|
|
1037
3659
|
const revisions = streamEvents.map((event) => event.revision);
|
|
1038
|
-
for (let revision = 1; revision <= value.revision; revision += 1) {
|
|
3660
|
+
for (let revision = baseRevision + 1; revision <= value.revision; revision += 1) {
|
|
1039
3661
|
if (revisions.filter((item) => item === revision).length !== 1) {
|
|
1040
3662
|
errors.push(`${stream} audit revision ${revision} must occur exactly once.`);
|
|
1041
3663
|
}
|
|
@@ -1048,30 +3670,254 @@ function verifyStream(file, value, stream, events, errors, allowedSchemas = new
|
|
|
1048
3670
|
}
|
|
1049
3671
|
}
|
|
1050
3672
|
|
|
1051
|
-
function
|
|
3673
|
+
function verifyScopeContracts(scope, scopeName, errors) {
|
|
3674
|
+
if (scope.scope_contract_version !== null && scope.scope_contract_version !== 1) {
|
|
3675
|
+
errors.push(`Workflow ${scopeName} scope_contract_version must be 1 when present.`);
|
|
3676
|
+
}
|
|
3677
|
+
const manifests = Array.isArray(scope.scope_manifests) ? scope.scope_manifests : [];
|
|
3678
|
+
const manifestIds = new Set();
|
|
3679
|
+
for (const [index, manifest] of manifests.entries()) {
|
|
3680
|
+
if (!manifest?.manifest_id || manifestIds.has(manifest.manifest_id)) {
|
|
3681
|
+
errors.push(`Workflow ${scopeName} scope manifest ID is missing or duplicated.`);
|
|
3682
|
+
continue;
|
|
3683
|
+
}
|
|
3684
|
+
manifestIds.add(manifest.manifest_id);
|
|
3685
|
+
if (manifest.iteration_id !== scope.scope_id) {
|
|
3686
|
+
errors.push(`Scope manifest ${manifest.manifest_id} belongs to ${manifest.iteration_id}, not ${scope.scope_id}.`);
|
|
3687
|
+
}
|
|
3688
|
+
if (manifest.revision !== index + 1) {
|
|
3689
|
+
errors.push(`Scope manifest ${manifest.manifest_id} revision must be ${index + 1}.`);
|
|
3690
|
+
}
|
|
3691
|
+
const expectedSupersedes = index === 0 ? null : manifests[index - 1]?.manifest_id;
|
|
3692
|
+
if ((manifest.supersedes || null) !== expectedSupersedes) {
|
|
3693
|
+
errors.push(`Scope manifest ${manifest.manifest_id} must supersede ${expectedSupersedes || "nothing"}.`);
|
|
3694
|
+
}
|
|
3695
|
+
if (!manifest.approved_by || !manifest.approval_reason || manifest.status !== "frozen") {
|
|
3696
|
+
errors.push(`Scope manifest ${manifest.manifest_id} is not explicitly approved and frozen.`);
|
|
3697
|
+
}
|
|
3698
|
+
if (manifest.manifest_checksum !== scopeManifestChecksum(manifest)) {
|
|
3699
|
+
errors.push(`Scope manifest checksum mismatch: ${manifest.manifest_id}.`);
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
if (scope.scope_contract_version === 1) {
|
|
3703
|
+
const requiresManifest = scopeName === "development"
|
|
3704
|
+
&& (scope.completed_nodes.some((node) => SCOPED_COMPLETION_NODES.has(node))
|
|
3705
|
+
|| [...scope.active_nodes, ...scope.blocked_nodes]
|
|
3706
|
+
.some((node) => SCOPED_COMPLETION_NODES.has(node) && !SCOPE_FREEZE_NODES.has(node)));
|
|
3707
|
+
if ((scope.current_scope_manifest_id && !manifestIds.has(scope.current_scope_manifest_id))
|
|
3708
|
+
|| (!scope.current_scope_manifest_id && requiresManifest)) {
|
|
3709
|
+
errors.push(`Workflow ${scopeName} scope requires a valid current scope manifest.`);
|
|
3710
|
+
}
|
|
3711
|
+
} else if (scope.current_scope_manifest_id && !manifestIds.has(scope.current_scope_manifest_id)) {
|
|
3712
|
+
errors.push(`Workflow ${scopeName} current scope manifest is missing: ${scope.current_scope_manifest_id}.`);
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3715
|
+
const manifestsById = new Map(manifests.map((manifest) => [manifest.manifest_id, manifest]));
|
|
3716
|
+
const warningIds = new Set();
|
|
3717
|
+
for (const warning of scope.scope_warnings) {
|
|
3718
|
+
const manifest = manifestsById.get(warning?.scope_manifest_id);
|
|
3719
|
+
if (!warning?.warning_id || warningIds.has(warning.warning_id)) {
|
|
3720
|
+
errors.push(`Workflow ${scopeName} scope warning ID is missing or duplicated.`);
|
|
3721
|
+
continue;
|
|
3722
|
+
}
|
|
3723
|
+
warningIds.add(warning.warning_id);
|
|
3724
|
+
if (!manifest || warning.scope_manifest_checksum !== manifest.manifest_checksum) {
|
|
3725
|
+
errors.push(`Scope warning ${warning.warning_id} does not bind a valid manifest.`);
|
|
3726
|
+
}
|
|
3727
|
+
if (warning.action !== "warn-only" || warning.reslice_performed !== false) {
|
|
3728
|
+
errors.push(`Scope warning ${warning.warning_id} must not reslice or change scope.`);
|
|
3729
|
+
}
|
|
3730
|
+
}
|
|
3731
|
+
|
|
3732
|
+
const receiptIds = new Set();
|
|
3733
|
+
for (const receipt of scope.node_completion_receipts) {
|
|
3734
|
+
if (!receipt?.receipt_id || receiptIds.has(receipt.receipt_id)) {
|
|
3735
|
+
errors.push(`Workflow ${scopeName} completion receipt ID is missing or duplicated.`);
|
|
3736
|
+
continue;
|
|
3737
|
+
}
|
|
3738
|
+
receiptIds.add(receipt.receipt_id);
|
|
3739
|
+
if (receipt.receipt_checksum !== completionReceiptChecksum(receipt)) {
|
|
3740
|
+
errors.push(`Node completion receipt checksum mismatch: ${receipt.receipt_id}.`);
|
|
3741
|
+
}
|
|
3742
|
+
if (receipt.scope_manifest_id) {
|
|
3743
|
+
const manifest = manifestsById.get(receipt.scope_manifest_id);
|
|
3744
|
+
if (!manifest || receipt.scope_manifest_checksum !== manifest.manifest_checksum) {
|
|
3745
|
+
errors.push(`Node completion receipt ${receipt.receipt_id} does not bind a valid scope manifest.`);
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
if (Array.isArray(receipt.blocking_defect_ids) && receipt.blocking_defect_ids.length) {
|
|
3749
|
+
errors.push(`Node completion receipt ${receipt.receipt_id} retains blocking defects.`);
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3752
|
+
const retirementReceiptIds = new Set();
|
|
3753
|
+
for (const receipt of scope.contract_retirement_receipts) {
|
|
3754
|
+
if (!receipt?.receipt_id || retirementReceiptIds.has(receipt.receipt_id)) {
|
|
3755
|
+
errors.push(`Workflow ${scopeName} contract retirement receipt ID is missing or duplicated.`);
|
|
3756
|
+
continue;
|
|
3757
|
+
}
|
|
3758
|
+
retirementReceiptIds.add(receipt.receipt_id);
|
|
3759
|
+
if (receipt.receipt_checksum !== contractRetirementReceiptChecksum(receipt)) {
|
|
3760
|
+
errors.push(`Contract retirement receipt checksum mismatch: ${receipt.receipt_id}.`);
|
|
3761
|
+
}
|
|
3762
|
+
if (!RETIRED_GATE_IDS.has(receipt.gate_id)
|
|
3763
|
+
|| receipt.iteration_id !== scope.scope_id
|
|
3764
|
+
|| receipt.scope !== scopeName
|
|
3765
|
+
|| receipt.disposition !== "contract-retired"
|
|
3766
|
+
|| receipt.human_approval_created !== false
|
|
3767
|
+
|| receipt.retired_by !== "system:package-upgrade"
|
|
3768
|
+
|| !receipt.contract_version
|
|
3769
|
+
|| !/^[a-f0-9]{64}$/.test(receipt.request_checksum || "")) {
|
|
3770
|
+
errors.push(`Contract retirement receipt is invalid: ${receipt.receipt_id}.`);
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
if (scope.scope_contract_version === 1 && scope.current_scope_manifest_id) {
|
|
3774
|
+
const current = manifestsById.get(scope.current_scope_manifest_id);
|
|
3775
|
+
for (const node of scope.completed_nodes.filter((item) => SCOPED_COMPLETION_NODES.has(item))) {
|
|
3776
|
+
const attempt = Number(scope.node_attempts[node] || 1);
|
|
3777
|
+
const receipt = scope.node_completion_receipts.find((item) =>
|
|
3778
|
+
item?.node === node
|
|
3779
|
+
&& Number(item.node_attempt || attempt) === attempt
|
|
3780
|
+
&& item.scope_manifest_id === current?.manifest_id
|
|
3781
|
+
&& item.scope_manifest_checksum === current?.manifest_checksum);
|
|
3782
|
+
if (!receipt) errors.push(`Completed node ${node} lacks a receipt for the current scope manifest.`);
|
|
3783
|
+
}
|
|
3784
|
+
}
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3787
|
+
function verifyFormalState(files, options, verification = {}) {
|
|
3788
|
+
const verifyHistory = verification.history === true;
|
|
3789
|
+
const sinceAnchor = verification.sinceAnchor === true;
|
|
3790
|
+
if (sinceAnchor && !verifyHistory) {
|
|
3791
|
+
throw new Error("--since-anchor requires --history.");
|
|
3792
|
+
}
|
|
1052
3793
|
const project = readYaml(files.project, options.yaml, "project.yaml");
|
|
1053
3794
|
const rawState = readYaml(files.workflowState, options.yaml, "workflow-state.yaml");
|
|
1054
3795
|
const rawRegistry = readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml");
|
|
1055
|
-
const
|
|
1056
|
-
|
|
3796
|
+
const views = formalStreamViews(files, {
|
|
3797
|
+
"workflow-state": rawState,
|
|
3798
|
+
"artifact-registry": rawRegistry,
|
|
3799
|
+
});
|
|
3800
|
+
const state = views.get("workflow-state").value;
|
|
3801
|
+
const registry = views.get("artifact-registry").value;
|
|
1057
3802
|
const errors = [];
|
|
1058
|
-
|
|
1059
|
-
let
|
|
3803
|
+
let retentionReceipts = [];
|
|
3804
|
+
let historyArchives = [];
|
|
1060
3805
|
try {
|
|
1061
|
-
|
|
3806
|
+
retentionReceipts = readArtifactRetentionReceipts(files, options);
|
|
1062
3807
|
} catch (error) {
|
|
1063
3808
|
errors.push(error.message);
|
|
1064
3809
|
}
|
|
3810
|
+
if (verifyHistory) {
|
|
3811
|
+
try {
|
|
3812
|
+
historyArchives = readArtifactHistoryArchives(files, options);
|
|
3813
|
+
} catch (error) {
|
|
3814
|
+
errors.push(error.message);
|
|
3815
|
+
}
|
|
3816
|
+
}
|
|
3817
|
+
const compactedHistory = verifyHistory && !sinceAnchor
|
|
3818
|
+
&& historyArchives.length > 0 && retentionReceipts.length === 0;
|
|
3819
|
+
const retainedHistory = verifyHistory && !sinceAnchor
|
|
3820
|
+
&& (retentionReceipts.length > 0 || compactedHistory);
|
|
3821
|
+
const verificationMode = verifyHistory
|
|
3822
|
+
? (sinceAnchor
|
|
3823
|
+
? "incremental-history"
|
|
3824
|
+
: compactedHistory
|
|
3825
|
+
? "compacted-history"
|
|
3826
|
+
: retainedHistory
|
|
3827
|
+
? "retained-history"
|
|
3828
|
+
: "full-history")
|
|
3829
|
+
: "routine";
|
|
3830
|
+
let trustedAnchor = null;
|
|
3831
|
+
if (sinceAnchor || !verifyHistory || retainedHistory) {
|
|
3832
|
+
try {
|
|
3833
|
+
trustedAnchor = readLatestHistoryAnchor(files, options);
|
|
3834
|
+
if (sinceAnchor && !trustedAnchor) {
|
|
3835
|
+
errors.push("Incremental history verification requires a trusted history anchor.");
|
|
3836
|
+
} else if (retainedHistory && !trustedAnchor) {
|
|
3837
|
+
errors.push("Retained history verification requires a trusted history anchor.");
|
|
3838
|
+
}
|
|
3839
|
+
} catch (error) {
|
|
3840
|
+
errors.push(error.message);
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
const anchor = trustedAnchor?.anchor || null;
|
|
3844
|
+
const baseRevisions = anchor
|
|
3845
|
+
? Object.fromEntries(Object.entries(anchor.streams).map(([stream, metadata]) => [stream, metadata.revision]))
|
|
3846
|
+
: {};
|
|
3847
|
+
const events = readEvents(files, errors, anchor ? {
|
|
3848
|
+
offsetBytes: anchor.event_index.offset_bytes,
|
|
3849
|
+
lineOffset: anchor.event_index.event_count,
|
|
3850
|
+
} : {});
|
|
3851
|
+
let shards = [];
|
|
3852
|
+
if (verifyHistory && (!sinceAnchor || trustedAnchor)) {
|
|
3853
|
+
try {
|
|
3854
|
+
shards = readEventShards(
|
|
3855
|
+
files,
|
|
3856
|
+
options,
|
|
3857
|
+
anchor ? baseRevisions : null,
|
|
3858
|
+
anchor ? events : [],
|
|
3859
|
+
);
|
|
3860
|
+
} catch (error) {
|
|
3861
|
+
errors.push(error.message);
|
|
3862
|
+
}
|
|
3863
|
+
}
|
|
1065
3864
|
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
3865
|
+
for (const [stream, view] of views) {
|
|
3866
|
+
verifyStream(
|
|
3867
|
+
view.file,
|
|
3868
|
+
view.raw,
|
|
3869
|
+
stream,
|
|
3870
|
+
events,
|
|
3871
|
+
errors,
|
|
3872
|
+
view.allowedSchemas,
|
|
3873
|
+
anchor ? {
|
|
3874
|
+
revision: anchor.streams[stream].revision,
|
|
3875
|
+
viewChecksum: anchor.streams[stream].view_checksum,
|
|
3876
|
+
} : {},
|
|
3877
|
+
);
|
|
3878
|
+
}
|
|
3879
|
+
if (verifyHistory) {
|
|
3880
|
+
for (const [stream, view] of views) {
|
|
3881
|
+
const value = view.value;
|
|
3882
|
+
const streamShards = shards.filter((record) => record.stream === stream);
|
|
3883
|
+
const streamEvents = events.filter((event) => event.stream === stream);
|
|
3884
|
+
const baseRevision = Number(baseRevisions[stream] || 0);
|
|
3885
|
+
const expectedShardCount = value.revision - baseRevision;
|
|
3886
|
+
if (expectedShardCount < 0) {
|
|
3887
|
+
errors.push(`${stream} revision predates the trusted history anchor.`);
|
|
3888
|
+
continue;
|
|
3889
|
+
}
|
|
3890
|
+
if (streamShards.length !== expectedShardCount) {
|
|
3891
|
+
errors.push(`Event shard count mismatch for ${stream}: expected ${expectedShardCount}, actual ${streamShards.length}.`);
|
|
3892
|
+
}
|
|
3893
|
+
for (let revision = baseRevision + 1; revision <= value.revision; revision += 1) {
|
|
3894
|
+
const revisionShards = streamShards.filter((record) => record.revision === revision);
|
|
3895
|
+
if (revisionShards.length !== 1) {
|
|
3896
|
+
errors.push(`${stream} event shard revision ${revision} must occur exactly once.`);
|
|
3897
|
+
continue;
|
|
3898
|
+
}
|
|
3899
|
+
const indexedEvent = streamEvents.find((event) => event.revision === revision);
|
|
3900
|
+
const { after_value: ignored, ...shardEvent } = revisionShards[0];
|
|
3901
|
+
if (!indexedEvent?.record_checksum) delete shardEvent.record_checksum;
|
|
3902
|
+
if (!indexedEvent
|
|
3903
|
+
|| JSON.stringify(stableValue(shardEvent)) !== JSON.stringify(stableValue(indexedEvent))) {
|
|
3904
|
+
errors.push(`Event shard metadata mismatch for ${stream} revision ${revision}.`);
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
try {
|
|
3908
|
+
const replayed = replayStreamRecords(stream, streamShards, options, trustedAnchor ? {
|
|
3909
|
+
value: trustedAnchor.snapshots[stream],
|
|
3910
|
+
recordChecksum: anchor.streams[stream].record_checksum,
|
|
3911
|
+
revision: baseRevision,
|
|
3912
|
+
} : {});
|
|
3913
|
+
if (value.revision > 0) {
|
|
3914
|
+
const normalizedReplayed = FORMAL_STREAMS.get(stream).normalize(replayed);
|
|
3915
|
+
if (JSON.stringify(stableValue(normalizedReplayed)) !== JSON.stringify(stableValue(value))) {
|
|
3916
|
+
errors.push(`Derived view does not match latest authoritative shard for ${stream}.`);
|
|
3917
|
+
}
|
|
3918
|
+
}
|
|
3919
|
+
} catch (error) {
|
|
3920
|
+
errors.push(error.message);
|
|
1075
3921
|
}
|
|
1076
3922
|
}
|
|
1077
3923
|
}
|
|
@@ -1101,13 +3947,8 @@ function verifyFormalState(files, options) {
|
|
|
1101
3947
|
if (hasScopedState && !scope.scope_id) {
|
|
1102
3948
|
errors.push(`Workflow ${scopeName} scope has state but no scope_id.`);
|
|
1103
3949
|
}
|
|
1104
|
-
for (const
|
|
1105
|
-
|
|
1106
|
-
errors.push(`Workflow node appears in incompatible states in ${scopeName}: ${node}.`);
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
for (const node of blocked) {
|
|
1110
|
-
if (completed.has(node)) errors.push(`Workflow node appears in incompatible states in ${scopeName}: ${node}.`);
|
|
3950
|
+
for (const conflict of findIncompatibleNodeStates(scope)) {
|
|
3951
|
+
errors.push(`Workflow node appears in incompatible states in ${scopeName}: ${conflict.node}.`);
|
|
1111
3952
|
}
|
|
1112
3953
|
for (const node of [...active, ...blocked, ...completed]) {
|
|
1113
3954
|
if (!NODE_IDS.has(node)) errors.push(`Unknown workflow node in formal state: ${node}.`);
|
|
@@ -1115,6 +3956,7 @@ function verifyFormalState(files, options) {
|
|
|
1115
3956
|
errors.push(`Workflow node is stored in the wrong ${scopeName} scope: ${node}.`);
|
|
1116
3957
|
}
|
|
1117
3958
|
}
|
|
3959
|
+
verifyScopeContracts(scope, scopeName, errors);
|
|
1118
3960
|
}
|
|
1119
3961
|
|
|
1120
3962
|
const artifactIds = new Set();
|
|
@@ -1128,25 +3970,24 @@ function verifyFormalState(files, options) {
|
|
|
1128
3970
|
if (artifactIds.has(artifactId)) errors.push(`Duplicate artifact ID: ${artifactId}.`);
|
|
1129
3971
|
artifactIds.add(artifactId);
|
|
1130
3972
|
|
|
1131
|
-
const
|
|
1132
|
-
|
|
3973
|
+
const integrity = artifactFileIntegrity(files, artifact);
|
|
3974
|
+
const artifactPath = integrity.artifact_path;
|
|
3975
|
+
if (integrity.code === "invalid-path") {
|
|
1133
3976
|
errors.push(`Artifact path must start with .workflow/: ${artifactId}.`);
|
|
1134
3977
|
continue;
|
|
1135
3978
|
}
|
|
1136
3979
|
if (artifactPaths.has(artifactPath)) errors.push(`Duplicate artifact path: ${artifactPath}.`);
|
|
1137
3980
|
artifactPaths.add(artifactPath);
|
|
1138
3981
|
|
|
1139
|
-
|
|
1140
|
-
if (!isInside(absolute, files.workflow) || !fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) {
|
|
3982
|
+
if (integrity.code === "missing-or-outside" || integrity.code === "unreadable") {
|
|
1141
3983
|
errors.push(`Artifact file is missing or outside .workflow/: ${artifactId}.`);
|
|
1142
3984
|
continue;
|
|
1143
3985
|
}
|
|
1144
|
-
if (
|
|
3986
|
+
if (integrity.code === "resolved-outside") {
|
|
1145
3987
|
errors.push(`Artifact path resolves outside .workflow/: ${artifactId}.`);
|
|
1146
3988
|
continue;
|
|
1147
3989
|
}
|
|
1148
|
-
|
|
1149
|
-
if (artifact.checksum_algorithm !== "sha256" || artifact.checksum !== checksum) {
|
|
3990
|
+
if (integrity.code === "checksum-mismatch") {
|
|
1150
3991
|
errors.push(`Checksum mismatch for artifact ${artifactId}.`);
|
|
1151
3992
|
}
|
|
1152
3993
|
}
|
|
@@ -1156,11 +3997,17 @@ function verifyFormalState(files, options) {
|
|
|
1156
3997
|
command: "verify",
|
|
1157
3998
|
valid: true,
|
|
1158
3999
|
errors: [],
|
|
4000
|
+
verification_mode: verificationMode,
|
|
4001
|
+
anchor_id: anchor?.anchor_id || null,
|
|
4002
|
+
post_anchor_event_count: anchor ? events.length : null,
|
|
1159
4003
|
workflow_revision: state.revision,
|
|
1160
4004
|
artifact_registry_revision: registry.revision,
|
|
1161
4005
|
artifact_count: registry.artifacts.length,
|
|
1162
|
-
audit_event_count: events.length,
|
|
4006
|
+
audit_event_count: (anchor?.event_index.event_count || 0) + events.length,
|
|
1163
4007
|
event_shard_count: shards.length,
|
|
4008
|
+
history_verified: verifyHistory,
|
|
4009
|
+
retention_receipt_count: retentionReceipts.length,
|
|
4010
|
+
history_archive_count: historyArchives.length,
|
|
1164
4011
|
};
|
|
1165
4012
|
}
|
|
1166
4013
|
|
|
@@ -1170,6 +4017,10 @@ export function executeDeliveryStateCommand(argv, options = {}) {
|
|
|
1170
4017
|
const command = positional[0];
|
|
1171
4018
|
const projectRoot = path.resolve(String(flags.get("--project") || options.projectRoot || process.cwd()));
|
|
1172
4019
|
const files = statePaths(projectRoot);
|
|
4020
|
+
assertNoLegacyDevelopmentWrite(files, command, positional, flags, options);
|
|
4021
|
+
if (command === "gate" && ["request", "decide"].includes(positional[1])) {
|
|
4022
|
+
assertGateContractWritable(requiredFlag(flags, "--gate-id"));
|
|
4023
|
+
}
|
|
1173
4024
|
const descriptor = lifecycleDescriptor(command, positional, flags);
|
|
1174
4025
|
const before = dispatchLifecycle(options, files, descriptor, "before", flags);
|
|
1175
4026
|
if (before.decision === "block") {
|
|
@@ -1183,33 +4034,63 @@ export function executeDeliveryStateCommand(argv, options = {}) {
|
|
|
1183
4034
|
result = registerArtifact(files, flags, options);
|
|
1184
4035
|
} else if (command === "artifact" && positional[1] === "status") {
|
|
1185
4036
|
result = changeArtifactStatus(files, flags, options);
|
|
4037
|
+
} else if (command === "artifact" && positional[1] === "prune") {
|
|
4038
|
+
result = pruneArtifactHistory(files, flags, options);
|
|
4039
|
+
} else if (command === "artifact" && positional[1] === "compact") {
|
|
4040
|
+
result = compactArtifactHistory(files, flags, options);
|
|
1186
4041
|
} else if (command === "gate" && positional[1] === "request") {
|
|
1187
4042
|
result = requestGate(files, flags, options);
|
|
1188
4043
|
} else if (command === "gate" && positional[1] === "decide") {
|
|
1189
4044
|
result = decideGate(files, flags, options);
|
|
4045
|
+
} else if (command === "contract" && positional[1] === "retire") {
|
|
4046
|
+
result = retireGateContract(files, flags, options);
|
|
4047
|
+
} else if (command === "scope" && positional[1] === "freeze") {
|
|
4048
|
+
result = mutateScopeManifest(files, flags, options, false);
|
|
4049
|
+
} else if (command === "scope" && positional[1] === "amend") {
|
|
4050
|
+
result = mutateScopeManifest(files, flags, options, true);
|
|
4051
|
+
} else if (command === "scope" && positional[1] === "warn") {
|
|
4052
|
+
result = recordScopeWarning(files, flags, options);
|
|
1190
4053
|
} else if (command === "transition") {
|
|
1191
4054
|
result = transitionWorkflow(files, flags, options);
|
|
1192
4055
|
} else if (command === "verify") {
|
|
1193
|
-
result =
|
|
4056
|
+
result = withReadableFormalState(
|
|
4057
|
+
files,
|
|
4058
|
+
() => verifyFormalState(files, options, {
|
|
4059
|
+
history: flags.has("--history"),
|
|
4060
|
+
sinceAnchor: flags.has("--since-anchor"),
|
|
4061
|
+
}),
|
|
4062
|
+
);
|
|
4063
|
+
} else if (command === "snapshot" && positional[1] === "create") {
|
|
4064
|
+
result = createFormalSnapshot(files, flags, options);
|
|
4065
|
+
} else if (command === "snapshot" && positional[1] === "references") {
|
|
4066
|
+
result = versionArchiveReferences(files, flags, options);
|
|
1194
4067
|
} else if (command === "rebuild") {
|
|
1195
4068
|
result = rebuildFormalViews(files, options);
|
|
1196
4069
|
} else if (command === "inspect") {
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
4070
|
+
result = withReadableFormalState(files, () => {
|
|
4071
|
+
const project = readYaml(files.project, options.yaml, "project.yaml");
|
|
4072
|
+
const workflowState = normalizeWorkflowState(
|
|
4073
|
+
readYaml(files.workflowState, options.yaml, "workflow-state.yaml"),
|
|
4074
|
+
);
|
|
4075
|
+
const artifactRegistry = normalizeArtifactRegistry(
|
|
4076
|
+
readYaml(files.artifactRegistry, options.yaml, "artifact-registry.yaml"),
|
|
4077
|
+
);
|
|
4078
|
+
return {
|
|
4079
|
+
command,
|
|
4080
|
+
project_id: project.project_id || workflowState.project_id || path.basename(projectRoot),
|
|
4081
|
+
project,
|
|
4082
|
+
workflow_state: workflowState,
|
|
4083
|
+
artifact_registry: artifactRegistry,
|
|
4084
|
+
node_contracts: nodeContractCatalog(),
|
|
4085
|
+
scope_manifest: currentScopeManifest(workflowState.development_state),
|
|
4086
|
+
derived_node_states: deriveNodeStates(files, workflowState, artifactRegistry, options),
|
|
4087
|
+
};
|
|
4088
|
+
});
|
|
1211
4089
|
} else {
|
|
1212
|
-
throw new Error(
|
|
4090
|
+
throw new Error(
|
|
4091
|
+
"Use inspect, verify, rebuild, snapshot create, artifact register/status/prune/compact, scope freeze/amend/warn, "
|
|
4092
|
+
+ "transition, gate request/decide, or contract retire.",
|
|
4093
|
+
);
|
|
1213
4094
|
}
|
|
1214
4095
|
dispatchLifecycle(options, files, descriptor, "after", flags, result);
|
|
1215
4096
|
return result;
|