chainlesschain 0.163.2 → 0.163.4
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/package.json +3 -2
- package/scripts/command-lifecycle-report.mjs +330 -38
- package/src/assets/web-panel/assets/{AIOps-DnGsmsqJ.js → AIOps-DB5X7qqV.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-CrO6fcQ7.js → ActionButton-DxHRtKLk.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-DsBdOaP2.js → Analytics-DIH5QrxT.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-DmQslKUB.js → AppLayout-uu0oxVz7.js} +5 -5
- package/src/assets/web-panel/assets/{Artifacts-BvyrT_QQ.js → Artifacts-BNSMiW9Y.js} +1 -1
- package/src/assets/web-panel/assets/{Audit-prndoLiC.js → Audit-DcAzB4_x.js} +1 -1
- package/src/assets/web-panel/assets/{BackgroundAgents-DOTFyA55.js → BackgroundAgents-ClSsvjIc.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-9zOnPp_8.js → Backup-BMciY0js.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-C4PC6sAG.js → BaseInput-Hn3b57Sh.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-BBAF-W9v.js → Chat-CoSlC53V.js} +6 -6
- package/src/assets/web-panel/assets/{ChatBubbleRenderer-CWhvvloO.js → ChatBubbleRenderer-CFevJ3So.js} +1 -1
- package/src/assets/web-panel/assets/{Checkbox-BI9iculd.js → Checkbox-B4L4Eu4w.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-3DP9BCKb.js → Codegen-C77FgRxF.js} +1 -1
- package/src/assets/web-panel/assets/{Col-df-2TnJX.js → Col-BrLtJLH3.js} +1 -1
- package/src/assets/web-panel/assets/{Community-CHs8gG_A.js → Community-Be7pXXhA.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-CDfF6q7V.js → Compact-DuGPecjo.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-CthrDnO7.js → Compliance-CzS66mjP.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-HjIqsWJw.js → Cowork-CEhjCoyK.js} +2 -2
- package/src/assets/web-panel/assets/{Cron-BKc196fb.js → Cron-D7ir8Vja.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-CJnjdc8W.js → Crosschain-BcXmuLxf.js} +1 -1
- package/src/assets/web-panel/assets/{DID-BHUy-8Q2.js → DID-Cto1dtBy.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-QDB7sKeY.js → Dashboard-B7KjgwOX.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-DStcVK11.js → Dropdown-COMqxijg.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-BwSUskMJ.js → EmailListRenderer-BZrya-nf.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-DgF_kurT.js → FamilyGuardDashboard-AmVeMq0J.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-k8wvv-wN.js → Federation-kZU8c-27.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-C11qoq2r.js → FormItemContext-sOV22v2-.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-BIIpr9kg.js → GenericCardRenderer-BUuLtGCz.js} +1 -1
- package/src/assets/web-panel/assets/{Git-BlA1exTe.js → Git-K-ZxsLsH.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-CYa1ZYM4.js → Governance-D3OEnawI.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-6_kKVIt6.js → Inference-Bll0gRmP.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-CB4AwxYy.js → KnowledgeGraph-DBXwjr53.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-xf7nHFAe.js → Logs-CEzPwEfP.js} +2 -2
- package/src/assets/web-panel/assets/{MarkdownRenderer-Bf3DEqP6.js → MarkdownRenderer-CLmCbHx6.js} +1 -1
- package/src/assets/web-panel/assets/{Marketplace-aPg8e4Jt.js → Marketplace-BYyRyOPz.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-C-TNlVUf.js → McpTools-DVu8w_rM.js} +5 -5
- package/src/assets/web-panel/assets/{Memory-DMTPIpGa.js → Memory-tenuZ_hO.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-BxyvveRq.js → MobileBridge-Bxgy4ozS.js} +1 -1
- package/src/assets/web-panel/assets/{MobileProjects-DyPCrAyQ.js → MobileProjects-Dgx1D6sc.js} +1 -1
- package/src/assets/web-panel/assets/{Mtc-BdhqNXp6.js → Mtc-CRfJ8r8j.js} +5 -5
- package/src/assets/web-panel/assets/{MtcAudit-Cz-HFxzW.js → MtcAudit-BBJq4Aiz.js} +5 -5
- package/src/assets/web-panel/assets/{Multisig-CIUaFNsx.js → Multisig-B3ii9lai.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-CWe6-J17.js → NLProgramming-FIi-Cubv.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-vYAKeviU.js → Notes-DIq8MPf1.js} +3 -3
- package/src/assets/web-panel/assets/{NotificationSettings-DQL9-2d7.js → NotificationSettings-BFOD9eGd.js} +1 -1
- package/src/assets/web-panel/assets/{OrderTableRenderer-DnXj1jBn.js → OrderTableRenderer--41u7vre.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-B3spU8AN.js → Organization-B_0AWztW.js} +2 -2
- package/src/assets/web-panel/assets/{Overflow-DgEwo7vm.js → Overflow-B6I7TuXv.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-hujMRH8X.js → P2P-Cdf6qens.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-C_qhdg9h.js → PdhVaultBrowser-B_0FcQJY.js} +3 -3
- package/src/assets/web-panel/assets/{Permissions-vp7LMcSf.js → Permissions-6nFBjsQp.js} +4 -4
- package/src/assets/web-panel/assets/{PersonalDataHub-Dxr5Hgnq.js → PersonalDataHub-nQYV7xpn.js} +3 -3
- package/src/assets/web-panel/assets/{Pipeline-DEPZqqH4.js → Pipeline-CBVod5jm.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-DTpDiKPe.js → Privacy-D52jW1Nt.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-jDJsRWmF.js → ProjectInit-Bdx3BYhf.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-Dhor5RWH.js → ProjectSettings-b7xr4DUa.js} +2 -2
- package/src/assets/web-panel/assets/Projects-DhfifCKx.js +1 -0
- package/src/assets/web-panel/assets/Providers-Dy_PgDMr.js +1 -0
- package/src/assets/web-panel/assets/{QrScannerModal-CjIw-wIb.js → QrScannerModal-FQKSmc3e.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-DX6jcqoi.js → QuickAsk-CSTrhzcF.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-DopXkhar.js → Recommend-CxMABOUh.js} +1 -1
- package/src/assets/web-panel/assets/{RemoteSession-BLvqJE9c.js → RemoteSession-Cv2T8yRa.js} +2 -2
- package/src/assets/web-panel/assets/{Reputation-DhRj2Me7.js → Reputation-CE1k4Vvu.js} +1 -1
- package/src/assets/web-panel/assets/{Row-DQ53vuPq.js → Row-BNgNcAV_.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-BslvwiBD.js → RssFeed-5ZiBZ3ph.js} +3 -3
- package/src/assets/web-panel/assets/{Search-DJKBDNKZ.js → Search-mDOIF1UV.js} +1 -1
- package/src/assets/web-panel/assets/{Security-COhpxHER.js → Security-CtuCTC-e.js} +3 -3
- package/src/assets/web-panel/assets/{Services-dbulD6cI.js → Services-DxFO2Zg7.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-ciIVNfhC.js → Skeleton-CKmKkH9y.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-AiRTkTCk.js → Skills-Bmzl9qkk.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-DDMcWYBc.js → Sla-9RqaWHOC.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-BPxWbpXO.js → SpeechSettings-CGdzFIYw.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-BoEOXCyg.js → SyncSettings-CvNuCeBT.js} +2 -2
- package/src/assets/web-panel/assets/Tasks-B_sLdcnw.js +1 -0
- package/src/assets/web-panel/assets/{Templates-UowY8Ixe.js → Templates-D9_YijdK.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-DauSLQXN.js → Tenant-Cuc7BUSP.js} +1 -1
- package/src/assets/web-panel/assets/{Terminal-DBj7Jneq.js → Terminal-6VYRnJgd.js} +2 -2
- package/src/assets/web-panel/assets/{TimelineRenderer-UMrPwoZC.js → TimelineRenderer-GtK8xd8Y.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-4ea96-bn.js → Tokens-BZ94Lt5W.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger--ISCqt4b.js → Trigger-DEcZgDaX.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-D4GVimRJ.js → Trust-CC3z9THe.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-Ci-nXRQc.js → UkeySign-Cvtw8cV3.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-Cjk7mKPw.js → VideoEditing-CL4e6Bn3.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-BgOLOO3Z.js → Wallet-ycEemP27.js} +2 -2
- package/src/assets/web-panel/assets/{WebAuthn-CCX0K29i.js → WebAuthn-DgZ4X_aw.js} +5 -5
- package/src/assets/web-panel/assets/{WorkflowEditor-Ch3zgIsE.js → WorkflowEditor-D0qMhSv7.js} +1 -1
- package/src/assets/web-panel/assets/{chat-DKOU_2Vb.js → chat-DsAg01Zn.js} +1 -1
- package/src/assets/web-panel/assets/{colors-6OVo4u3C.js → colors-B0oqAhrQ.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-BH78Hlsq.js → compact-item-CmX0Rabz.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-CFRpSbbC.js → createContext-Be4cn1Ub.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-CP2qzG6D.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-DNQhgcVi.js → hasIn-D5v12kVq.js} +1 -1
- package/src/assets/web-panel/assets/{index-BkN3iTTp.js → index-0eWLkQKT.js} +1 -1
- package/src/assets/web-panel/assets/{index-CkNEMANR.js → index-301ogenv.js} +1 -1
- package/src/assets/web-panel/assets/{index-D3RtTT4K.js → index-5LGY0JbG.js} +1 -1
- package/src/assets/web-panel/assets/{index-dDkoL4Pe.js → index-7VKDn-t1.js} +1 -1
- package/src/assets/web-panel/assets/{index-BHa4WyTZ.js → index-8WvYGnON.js} +1 -1
- package/src/assets/web-panel/assets/{index-CaYqY35Z.js → index-B1YGjOWC.js} +1 -1
- package/src/assets/web-panel/assets/{index-bKm3TUDp.js → index-B3mUHuVc.js} +1 -1
- package/src/assets/web-panel/assets/{index--rq90rL9.js → index-BA4oulaP.js} +1 -1
- package/src/assets/web-panel/assets/{index-DaDgdJYj.js → index-BBREnn1V.js} +1 -1
- package/src/assets/web-panel/assets/{index-B_Z-YoxX.js → index-BBvyHguy.js} +1 -1
- package/src/assets/web-panel/assets/{index-CkqHgG-b.js → index-BKmsS2jj.js} +1 -1
- package/src/assets/web-panel/assets/{index-CrcLHN2F.js → index-BRIqLy1m.js} +1 -1
- package/src/assets/web-panel/assets/{index-BaVE6E8y.js → index-BXGSbelg.js} +3 -3
- package/src/assets/web-panel/assets/{index-BYrnm5Ob.js → index-BaE0Ui07.js} +1 -1
- package/src/assets/web-panel/assets/{index-BDSR7ecX.js → index-BoAIAZ5C.js} +1 -1
- package/src/assets/web-panel/assets/{index-CNuCHHQT.js → index-BqltWojQ.js} +1 -1
- package/src/assets/web-panel/assets/{index-Eag8e_mA.js → index-CGiIyTZw.js} +1 -1
- package/src/assets/web-panel/assets/index-CKB0vbJU.js +1 -0
- package/src/assets/web-panel/assets/{index-BepBTpa8.js → index-CZ2wUEgr.js} +1 -1
- package/src/assets/web-panel/assets/{index-HNGGVF6o.js → index-CZpfzcuy.js} +1 -1
- package/src/assets/web-panel/assets/{index-AuBhLqOm.js → index-Ckrk3pIV.js} +1 -1
- package/src/assets/web-panel/assets/{index-7TC3S8SN.js → index-CqoCZ5vR.js} +1 -1
- package/src/assets/web-panel/assets/{index-CFPJ8yzF.js → index-CyWBir7N.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bb6kDrUx.js → index-CyyGTMKd.js} +1 -1
- package/src/assets/web-panel/assets/{index-DKN2noIF.js → index-D0BSkxYQ.js} +1 -1
- package/src/assets/web-panel/assets/{index-Du0rnksC.js → index-D5YVopyr.js} +1 -1
- package/src/assets/web-panel/assets/{index-BxRy5LPR.js → index-DKUBj36_.js} +1 -1
- package/src/assets/web-panel/assets/{index-sm7W8AXy.js → index-DM724NgU.js} +1 -1
- package/src/assets/web-panel/assets/{index-BujsOZfc.js → index-DQRsQen0.js} +1 -1
- package/src/assets/web-panel/assets/index-DgoXmwqB.js +1 -0
- package/src/assets/web-panel/assets/{index-Y9MJv28e.js → index-DikJ0Qqe.js} +1 -1
- package/src/assets/web-panel/assets/{index-caye7AOE.js → index-Dn2dSdXx.js} +1 -1
- package/src/assets/web-panel/assets/{index-cdf7fd7d.js → index-DyUaFWEk.js} +1 -1
- package/src/assets/web-panel/assets/{index-BnnXQot_.js → index-Hfe_dEGx.js} +1 -1
- package/src/assets/web-panel/assets/{index-DvHGtNnF.js → index-_JjRLSD0.js} +1 -1
- package/src/assets/web-panel/assets/{index-nbfHl1oV.js → index-frSojpHj.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cu3L4r5l.js → index-jK3ax6Z6.js} +1 -1
- package/src/assets/web-panel/assets/{index-D84id2Y4.js → index-qMuZ-NRR.js} +1 -1
- package/src/assets/web-panel/assets/{index-hPY0fFrd.js → index-q_fQ94SE.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-BZ9w6jPL.js → initDefaultProps-D4VdhJ9_.js} +1 -1
- package/src/assets/web-panel/assets/{motion-BnwUiOho.js → motion-CZJA0MQW.js} +1 -1
- package/src/assets/web-panel/assets/{move-ByUKJnNm.js → move-BqEs7Sb1.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-V7yK1KEl.js → mtc-parser-BpHEtsu1.js} +1 -1
- package/src/assets/web-panel/assets/{omit-BEXBBt4j.js → omit-BW94tY9W.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-qDm_46Gi.js → pickAttrs-BnBjPWP9.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-BIbhEo_0.js → placementArrow-uUkjQ_hu.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-NbPbDgA_.js → responsiveObserve-B-klUqXK.js} +1 -1
- package/src/assets/web-panel/assets/{slide-BnUWglZ0.js → slide-CvY1cNAs.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-BX8_wZI5.js → statusUtils-Ix5sEDT2.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-BV4OycQX.js → styleChecker-C8qcXOne.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-Od_H5llY.js → useFlexGapSupport-KNF6Kj8f.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-BWI-PyiX.js → useFs-DGJr345n.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-BqMuhG3g.js → usePersonalDataHub-BboN99rI.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-DNd23z6f.js → vnode-Bgt8Vgte.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-Cjaxnm7l.js → zoom-BhJtErgt.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/command-help-index.json +2 -2
- package/src/commands/agenda.js +6 -5
- package/src/commands/agent.js +237 -75
- package/src/commands/agents.js +6 -0
- package/src/commands/artifacts.js +98 -0
- package/src/commands/background-session.js +11 -1
- package/src/commands/mcp.js +218 -31
- package/src/commands/review.js +221 -22
- package/src/commands/session-mcp-recovery.js +5 -5
- package/src/data/changelog.json +27 -1
- package/src/gateways/ws/ws-agent-handler.js +492 -14
- package/src/harness/jsonl-session-store.js +39 -1
- package/src/harness/mcp-client.js +414 -71
- package/src/harness/prompt-compressor.js +24 -0
- package/src/harness/provider-backed-compaction.js +205 -0
- package/src/harness/worktree-isolator.js +26 -3
- package/src/lib/agent-worktree.js +41 -2
- package/src/lib/app-preview.js +3 -0
- package/src/lib/background-agent-supervisor.js +790 -186
- package/src/lib/background-command-argv.js +318 -0
- package/src/lib/background-launch-profile.js +1 -0
- package/src/lib/background-phase-reporter.js +22 -2
- package/src/lib/background-worktree-policy.js +109 -0
- package/src/lib/command-lifecycle-report.js +1340 -145
- package/src/lib/cowork-mcp-tools.js +51 -6
- package/src/lib/cowork-task-runner.js +1 -0
- package/src/lib/delivery-coordinator.js +30 -5
- package/src/lib/delivery-production-adapter.js +1777 -0
- package/src/lib/delivery-production-runner.js +240 -0
- package/src/lib/durable-security-store.js +29 -17
- package/src/lib/execution-trace/trace-context.cjs +179 -0
- package/src/lib/execution-trace/trace-context.js +8 -175
- package/src/lib/mcp-call-ledger-store.js +58 -6
- package/src/lib/mcp-headers-helper.js +76 -8
- package/src/lib/mcp-host-recovery-runtime.js +2 -0
- package/src/lib/mcp-ledger-recovery-admission.js +39 -3
- package/src/lib/mcp-recovery-adjudication.js +256 -19
- package/src/lib/mcp-sandbox-policy.js +184 -0
- package/src/lib/mcp-stdio-capsule-builder-worker.cjs +495 -0
- package/src/lib/mcp-stdio-executable-identity.js +33 -2
- package/src/lib/mcp-stdio-execution-authority.js +163 -0
- package/src/lib/mcp-stdio-immutable-vfs-resolver.cjs +830 -0
- package/src/lib/mcp-stdio-package-materialization.js +721 -333
- package/src/lib/mcp-stdio-workspace-authority.js +252 -0
- package/src/lib/observability/index.js +2 -0
- package/src/lib/observability/otlp-exporter.js +2 -2
- package/src/lib/packer/pack-update-applier.js +8 -1
- package/src/lib/packer/pkg-config-generator.js +34 -0
- package/src/lib/plugin-runtime/sandbox-policy.js +42 -2
- package/src/lib/process-execution-broker/index.js +213 -109
- package/src/lib/process-execution-broker/platform-sandbox.js +1024 -169
- package/src/lib/process-tree-termination.js +17 -1
- package/src/lib/scheduler-kernel/contract.js +315 -0
- package/src/lib/scheduler-kernel/store.js +1345 -0
- package/src/lib/session-host-lease.js +403 -30
- package/src/lib/session-persistence-failure.js +4 -1
- package/src/lib/skill-mcp.js +65 -4
- package/src/lib/sub-agent-context.js +8 -0
- package/src/lib/web-fetch.js +4 -4
- package/src/repl/agent-repl.js +285 -38
- package/src/runtime/agent-core.js +1044 -90
- package/src/runtime/agent-runtime.js +61 -8
- package/src/runtime/headless-runner.js +260 -166
- package/src/runtime/headless-stream.js +254 -25
- package/src/runtime/mcp-config.js +235 -28
- package/src/workers/background-agent-worker.js +492 -71
- package/src/assets/web-panel/assets/Projects-08xUDZGt.js +0 -1
- package/src/assets/web-panel/assets/Providers-BGs47s1a.js +0 -1
- package/src/assets/web-panel/assets/Tasks-CEYFJwmr.js +0 -1
- package/src/assets/web-panel/assets/devWarning-DEMblAIm.js +0 -1
- package/src/assets/web-panel/assets/index-BTvq-36D.js +0 -1
- package/src/assets/web-panel/assets/index-hUpvk6Jl.js +0 -1
|
@@ -0,0 +1,1777 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production DeliveryCoordinator adapter for a local git worktree + GitHub.
|
|
3
|
+
*
|
|
4
|
+
* Every process is launched with an argv array through ProcessExecutionBroker;
|
|
5
|
+
* no shell command strings are accepted. Provider mutations are correlated to
|
|
6
|
+
* the coordinator effect id, and any ambiguous process/provider failure is
|
|
7
|
+
* thrown so the crash-safe runner leaves the effect pending for reconciliation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { ArtifactStore, publicArtifactMetadata } from "./artifact-store.js";
|
|
15
|
+
import { captureState } from "./chrome-connector.js";
|
|
16
|
+
import {
|
|
17
|
+
assessDeliveryEvidence,
|
|
18
|
+
canonicalDeliveryJson,
|
|
19
|
+
verifyDeliveryEvidenceRecord,
|
|
20
|
+
} from "./delivery-evidence.js";
|
|
21
|
+
import { executionBroker } from "./process-execution-broker/index.js";
|
|
22
|
+
import { containsSecret, redactSecrets } from "./secret-scan.js";
|
|
23
|
+
import { runReview as runCliReview } from "../commands/review.js";
|
|
24
|
+
|
|
25
|
+
const EXACT_COMMIT_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
|
|
26
|
+
const SHA256_RE = /^sha256:[0-9a-f]{64}$/i;
|
|
27
|
+
const MERGE_METHODS = new Set(["squash", "merge", "rebase"]);
|
|
28
|
+
const MAX_OUTPUT_CHARS = 16_000;
|
|
29
|
+
|
|
30
|
+
// Production delivery fixes are intentionally file-tool-only. This immutable
|
|
31
|
+
// ceiling is paired with `exactToolNames: true`, so shell/code/git and any
|
|
32
|
+
// dynamically registered MCP or external tools are unavailable to the fixer.
|
|
33
|
+
export const DELIVERY_FIXER_ALLOWED_TOOLS = Object.freeze([
|
|
34
|
+
"read_file",
|
|
35
|
+
"list_dir",
|
|
36
|
+
"write_file",
|
|
37
|
+
"edit_file",
|
|
38
|
+
"edit_file_hashed",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const WINDOWS_RESERVED_PATH_SEGMENT =
|
|
42
|
+
/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/iu;
|
|
43
|
+
|
|
44
|
+
function exactPortableRepoFilePath(value) {
|
|
45
|
+
if (
|
|
46
|
+
typeof value !== "string" ||
|
|
47
|
+
value.length === 0 ||
|
|
48
|
+
value.trim() !== value
|
|
49
|
+
) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const segments = value.split("/");
|
|
53
|
+
if (
|
|
54
|
+
value.includes("\0") ||
|
|
55
|
+
value.includes("\\") ||
|
|
56
|
+
path.posix.isAbsolute(value) ||
|
|
57
|
+
/^[A-Za-z]:/u.test(value) ||
|
|
58
|
+
path.posix.normalize(value) !== value ||
|
|
59
|
+
value === "." ||
|
|
60
|
+
value === ".." ||
|
|
61
|
+
value.startsWith("../") ||
|
|
62
|
+
segments.some(
|
|
63
|
+
(segment) =>
|
|
64
|
+
segment === "" ||
|
|
65
|
+
segment === "." ||
|
|
66
|
+
segment === ".." ||
|
|
67
|
+
/[<>:"|?*]/u.test(segment) ||
|
|
68
|
+
[...segment].some((character) => character.charCodeAt(0) <= 0x1f) ||
|
|
69
|
+
/[ .]$/u.test(segment) ||
|
|
70
|
+
WINDOWS_RESERVED_PATH_SEGMENT.test(segment),
|
|
71
|
+
)
|
|
72
|
+
) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sameCanonicalPath(left, right) {
|
|
79
|
+
return path.relative(left, right) === "" && path.relative(right, left) === "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function defaultRunProcess(file, args, options) {
|
|
83
|
+
return executionBroker.spawnSync(file, args, options);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function defaultReviewRunner(options) {
|
|
87
|
+
return runCliReview(options, {
|
|
88
|
+
writeOut: () => {},
|
|
89
|
+
writeErr: () => {},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function jsonClone(value) {
|
|
94
|
+
return JSON.parse(JSON.stringify(value ?? null));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function boundedText(value, maximum = MAX_OUTPUT_CHARS) {
|
|
98
|
+
return redactSecrets(String(value || "").slice(0, maximum));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseJson(value, label) {
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(String(value || ""));
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new Error(`${label} returned invalid JSON: ${error.message}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sha256(value) {
|
|
110
|
+
return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function uniqueStrings(values) {
|
|
114
|
+
return [...new Set((Array.isArray(values) ? values : []).map(String))];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizedRepoPath(value) {
|
|
118
|
+
return String(value || "")
|
|
119
|
+
.replaceAll("\\", "/")
|
|
120
|
+
.replace(/^\.\//u, "")
|
|
121
|
+
.trim();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizedPathSet(values) {
|
|
125
|
+
return uniqueStrings(values).map(normalizedRepoPath).filter(Boolean).sort();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sameStringArray(left, right) {
|
|
129
|
+
const a = normalizedPathSet(left);
|
|
130
|
+
const b = normalizedPathSet(right);
|
|
131
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Validate the complete fail-closed policy used by the production adapter. */
|
|
135
|
+
export function validateGitHubDeliveryProductionConfig(config) {
|
|
136
|
+
const unmet = [];
|
|
137
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
138
|
+
return {
|
|
139
|
+
valid: false,
|
|
140
|
+
reason: "provider-config-invalid",
|
|
141
|
+
unmet: ["provider-config-invalid"],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (containsSecret(JSON.stringify(config))) unmet.push("raw-secret-detected");
|
|
145
|
+
|
|
146
|
+
const gates = Array.isArray(config.gates) ? config.gates : [];
|
|
147
|
+
const gateIds = gates.map((gate) => String(gate?.id || "").trim());
|
|
148
|
+
if (gates.length === 0) unmet.push("gate-config-missing");
|
|
149
|
+
if (gateIds.some((id) => !id)) unmet.push("gate-id-invalid");
|
|
150
|
+
if (new Set(gateIds).size !== gateIds.length) {
|
|
151
|
+
unmet.push("gate-id-duplicate");
|
|
152
|
+
}
|
|
153
|
+
gates.forEach((gate, gateIndex) => {
|
|
154
|
+
const executions = Array.isArray(gate?.executions) ? gate.executions : [];
|
|
155
|
+
const executionIds = executions.map((item) =>
|
|
156
|
+
String(item?.id || "").trim(),
|
|
157
|
+
);
|
|
158
|
+
if (executions.length === 0) {
|
|
159
|
+
unmet.push(`gate-executions-missing:${gateIds[gateIndex] || gateIndex}`);
|
|
160
|
+
}
|
|
161
|
+
if (
|
|
162
|
+
executionIds.some((id) => !id) ||
|
|
163
|
+
new Set(executionIds).size !== executionIds.length
|
|
164
|
+
) {
|
|
165
|
+
unmet.push(
|
|
166
|
+
`gate-execution-id-invalid:${gateIds[gateIndex] || gateIndex}`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (executions.some((item) => !commandSpec(item))) {
|
|
170
|
+
unmet.push(`gate-command-invalid:${gateIds[gateIndex] || gateIndex}`);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (
|
|
175
|
+
!config.preview ||
|
|
176
|
+
typeof config.preview !== "object" ||
|
|
177
|
+
Array.isArray(config.preview)
|
|
178
|
+
) {
|
|
179
|
+
unmet.push("preview-config-missing");
|
|
180
|
+
}
|
|
181
|
+
if (
|
|
182
|
+
!config.review ||
|
|
183
|
+
typeof config.review !== "object" ||
|
|
184
|
+
Array.isArray(config.review)
|
|
185
|
+
) {
|
|
186
|
+
unmet.push("review-config-missing");
|
|
187
|
+
}
|
|
188
|
+
const allowedPaths = Array.isArray(config.fix?.allowedPaths)
|
|
189
|
+
? config.fix.allowedPaths.map(exactPortableRepoFilePath)
|
|
190
|
+
: [];
|
|
191
|
+
if (
|
|
192
|
+
allowedPaths.length === 0 ||
|
|
193
|
+
new Set(allowedPaths).size !== allowedPaths.length ||
|
|
194
|
+
allowedPaths.some((item) => !item)
|
|
195
|
+
) {
|
|
196
|
+
unmet.push("fix-allowed-paths-invalid");
|
|
197
|
+
}
|
|
198
|
+
if (!EXACT_COMMIT_RE.test(String(config.fix?.baseCommitSha || ""))) {
|
|
199
|
+
unmet.push("fix-base-commit-invalid");
|
|
200
|
+
}
|
|
201
|
+
if (
|
|
202
|
+
!String(config.pullRequest?.base || "").trim() ||
|
|
203
|
+
!String(config.pullRequest?.title || "").trim()
|
|
204
|
+
) {
|
|
205
|
+
unmet.push("pull-request-config-invalid");
|
|
206
|
+
}
|
|
207
|
+
if (
|
|
208
|
+
!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9_.-]{1,100}$/u.test(
|
|
209
|
+
String(config.github?.repo || ""),
|
|
210
|
+
)
|
|
211
|
+
) {
|
|
212
|
+
unmet.push("github-repo-invalid");
|
|
213
|
+
}
|
|
214
|
+
const requiredChecks = Array.isArray(config.ci?.requiredChecks)
|
|
215
|
+
? config.ci.requiredChecks.map((item) => String(item).trim())
|
|
216
|
+
: [];
|
|
217
|
+
if (
|
|
218
|
+
requiredChecks.length === 0 ||
|
|
219
|
+
requiredChecks.some((item) => !item) ||
|
|
220
|
+
new Set(requiredChecks).size !== requiredChecks.length
|
|
221
|
+
) {
|
|
222
|
+
unmet.push("ci-required-checks-invalid");
|
|
223
|
+
}
|
|
224
|
+
if (
|
|
225
|
+
!config.merge ||
|
|
226
|
+
typeof config.merge.enabled !== "boolean" ||
|
|
227
|
+
!MERGE_METHODS.has(String(config.merge.method || "squash").toLowerCase())
|
|
228
|
+
) {
|
|
229
|
+
unmet.push("merge-config-invalid");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const uniqueUnmet = [...new Set(unmet)];
|
|
233
|
+
return {
|
|
234
|
+
valid: uniqueUnmet.length === 0,
|
|
235
|
+
reason: uniqueUnmet[0] || "ok",
|
|
236
|
+
unmet: uniqueUnmet,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function commandSpec(value) {
|
|
241
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
242
|
+
const file = String(value.file || value.command || "").trim();
|
|
243
|
+
const args = Array.isArray(value.args) ? value.args.map(String) : null;
|
|
244
|
+
if (!file || !args) return null;
|
|
245
|
+
const timeout = Number(value.timeoutMs);
|
|
246
|
+
return {
|
|
247
|
+
file,
|
|
248
|
+
args,
|
|
249
|
+
timeoutMs: Number.isFinite(timeout)
|
|
250
|
+
? Math.min(60 * 60 * 1000, Math.max(1000, timeout))
|
|
251
|
+
: 15 * 60 * 1000,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function definitiveFailure(error, extras = {}) {
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
error: String(error || "delivery provider configuration is invalid"),
|
|
259
|
+
sideEffects: [],
|
|
260
|
+
...extras,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function committedEffect(id, kind, details = {}) {
|
|
265
|
+
return {
|
|
266
|
+
id,
|
|
267
|
+
kind,
|
|
268
|
+
status: "committed",
|
|
269
|
+
...jsonClone(details),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function noEffect(id, kind, details = {}) {
|
|
274
|
+
return {
|
|
275
|
+
id,
|
|
276
|
+
kind,
|
|
277
|
+
status: "no_effect",
|
|
278
|
+
...jsonClone(details),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function failedEffect(id, kind, details = {}) {
|
|
283
|
+
return {
|
|
284
|
+
id,
|
|
285
|
+
kind,
|
|
286
|
+
status: "failed",
|
|
287
|
+
...jsonClone(details),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function unknownEffect(id, kind, details = {}) {
|
|
292
|
+
return {
|
|
293
|
+
id,
|
|
294
|
+
kind,
|
|
295
|
+
status: "unknown",
|
|
296
|
+
...jsonClone(details),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function boundEffectId(context) {
|
|
301
|
+
const id = String(context?.effect?.id || "");
|
|
302
|
+
if (!SHA256_RE.test(id)) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
"production delivery provider requires an exact effect binding",
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return id;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function findingFromReport(finding = {}) {
|
|
311
|
+
return {
|
|
312
|
+
path: String(finding.path || finding.file || ""),
|
|
313
|
+
line: Number(finding.line) || null,
|
|
314
|
+
title: boundedText(finding.title || finding.failure_scenario || "finding"),
|
|
315
|
+
severity: String(finding.severity || "Note"),
|
|
316
|
+
confidence: Number.isFinite(Number(finding.confidence))
|
|
317
|
+
? Number(finding.confidence)
|
|
318
|
+
: 1,
|
|
319
|
+
category: String(finding.category || "correctness"),
|
|
320
|
+
failureScenario: boundedText(
|
|
321
|
+
finding.failure_scenario ||
|
|
322
|
+
finding.failureScenario ||
|
|
323
|
+
finding.title ||
|
|
324
|
+
finding.body ||
|
|
325
|
+
"finding",
|
|
326
|
+
),
|
|
327
|
+
evidence: boundedText(
|
|
328
|
+
finding.evidence || finding.body || finding.title || "",
|
|
329
|
+
),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function checkState(value) {
|
|
334
|
+
const raw = String(value || "").toUpperCase();
|
|
335
|
+
if (["SUCCESS", "PASSED"].includes(raw)) return "success";
|
|
336
|
+
if (["FAILURE", "FAILED", "ERROR", "TIMED_OUT", "CANCELLED"].includes(raw)) {
|
|
337
|
+
return "failure";
|
|
338
|
+
}
|
|
339
|
+
if (["NEUTRAL", "SKIPPED"].includes(raw)) return "neutral";
|
|
340
|
+
return "pending";
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function statusPaths(output) {
|
|
344
|
+
return uniqueStrings(
|
|
345
|
+
String(output || "")
|
|
346
|
+
.split("\0")
|
|
347
|
+
.map((item) => item.trim())
|
|
348
|
+
.filter(Boolean),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** GitHub-backed implementation of all nine DeliveryCoordinator effects. */
|
|
353
|
+
export class GitHubDeliveryProductionAdapter {
|
|
354
|
+
constructor(options = {}, deps = {}) {
|
|
355
|
+
this.cwd = path.resolve(options.cwd || process.cwd());
|
|
356
|
+
this.config =
|
|
357
|
+
options.config && typeof options.config === "object"
|
|
358
|
+
? jsonClone(options.config)
|
|
359
|
+
: {};
|
|
360
|
+
const validation = validateGitHubDeliveryProductionConfig(this.config);
|
|
361
|
+
if (!validation.valid) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`invalid production delivery provider config: ${validation.unmet.join(", ")}`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
this._runProcess = deps.runProcess || defaultRunProcess;
|
|
367
|
+
this._capturePreview = deps.capturePreview || captureState;
|
|
368
|
+
this._runReview = deps.runReview || defaultReviewRunner;
|
|
369
|
+
this._runFix = deps.runFix || defaultReviewRunner;
|
|
370
|
+
this._artifactStore = deps.artifactStore || new ArtifactStore();
|
|
371
|
+
this._readFileSync = deps.readFileSync || fs.readFileSync;
|
|
372
|
+
this._tmpdir = deps.tmpdir || os.tmpdir;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
_process(file, args, { timeoutMs = 30_000, origin, input } = {}) {
|
|
376
|
+
return this._runProcess(String(file), args.map(String), {
|
|
377
|
+
cwd: this.cwd,
|
|
378
|
+
encoding: "utf8",
|
|
379
|
+
input,
|
|
380
|
+
timeout: timeoutMs,
|
|
381
|
+
windowsHide: true,
|
|
382
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
383
|
+
origin: origin || "delivery:production",
|
|
384
|
+
policy: "allow",
|
|
385
|
+
scope: "delivery",
|
|
386
|
+
shell: false,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
_read(file, args, label) {
|
|
391
|
+
const result = this._process(file, args, {
|
|
392
|
+
origin: `delivery:read:${label}`,
|
|
393
|
+
});
|
|
394
|
+
if (result?.error) throw result.error;
|
|
395
|
+
if (Number(result?.status) !== 0) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
boundedText(result?.stderr || result?.stdout) ||
|
|
398
|
+
`${label} failed with exit ${result?.status}`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
return String(result?.stdout || "");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
_mutate(file, args, label, options = {}) {
|
|
405
|
+
const result = this._process(file, args, {
|
|
406
|
+
...options,
|
|
407
|
+
origin: `delivery:mutation:${label}`,
|
|
408
|
+
});
|
|
409
|
+
// A transport error or non-zero mutation can be ambiguous: the remote may
|
|
410
|
+
// have accepted it before the client lost its response. Throwing preserves
|
|
411
|
+
// the pending effect instead of authorizing a blind retry.
|
|
412
|
+
if (result?.error) throw result.error;
|
|
413
|
+
if (Number(result?.status) !== 0) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
boundedText(result?.stderr || result?.stdout) ||
|
|
416
|
+
`${label} failed with exit ${result?.status}`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
return String(result?.stdout || "");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
_git(args, label = args[0]) {
|
|
423
|
+
return this._read("git", args, `git-${label}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
_gh(args, label = args[0]) {
|
|
427
|
+
const repo = String(this.config.github?.repo || "").trim();
|
|
428
|
+
const fullArgs =
|
|
429
|
+
repo && !args.includes("--repo") ? [...args, "--repo", repo] : args;
|
|
430
|
+
return this._read("gh", fullArgs, `gh-${label}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
_ghMutate(args, label, options = {}) {
|
|
434
|
+
const repo = String(this.config.github?.repo || "").trim();
|
|
435
|
+
const fullArgs =
|
|
436
|
+
repo && !args.includes("--repo") ? [...args, "--repo", repo] : args;
|
|
437
|
+
return this._mutate("gh", fullArgs, `gh-${label}`, options);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
_head() {
|
|
441
|
+
return this._git(["rev-parse", "HEAD"], "head").trim();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
_worktreeIdentity() {
|
|
445
|
+
return {
|
|
446
|
+
root: path.resolve(
|
|
447
|
+
this._git(["rev-parse", "--show-toplevel"], "worktree-root").trim(),
|
|
448
|
+
),
|
|
449
|
+
branch: this._git(["branch", "--show-current"], "branch").trim(),
|
|
450
|
+
head: this._head(),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
_sameWorktreeIdentity(expected) {
|
|
455
|
+
const actual = this._worktreeIdentity();
|
|
456
|
+
return {
|
|
457
|
+
ok:
|
|
458
|
+
actual.root === expected.root &&
|
|
459
|
+
actual.branch === expected.branch &&
|
|
460
|
+
actual.head === expected.head,
|
|
461
|
+
expected,
|
|
462
|
+
actual,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
_verifyAllowedPathScope(allowedPaths, worktreeRoot) {
|
|
467
|
+
try {
|
|
468
|
+
const realpath = fs.realpathSync.native || fs.realpathSync;
|
|
469
|
+
const root = path.resolve(realpath(worktreeRoot));
|
|
470
|
+
if (!sameCanonicalPath(root, path.resolve(worktreeRoot))) {
|
|
471
|
+
return {
|
|
472
|
+
ok: false,
|
|
473
|
+
error: "worktree root must be its canonical physical path",
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
for (const relativePath of allowedPaths) {
|
|
477
|
+
const exactPath = exactPortableRepoFilePath(relativePath);
|
|
478
|
+
if (!exactPath) {
|
|
479
|
+
return {
|
|
480
|
+
ok: false,
|
|
481
|
+
error: `allowed path is not portable and canonical: ${relativePath}`,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
const target = path.resolve(root, ...exactPath.split("/"));
|
|
485
|
+
const lexical = path.relative(root, target);
|
|
486
|
+
if (
|
|
487
|
+
!lexical ||
|
|
488
|
+
lexical === ".." ||
|
|
489
|
+
lexical.startsWith(`..${path.sep}`) ||
|
|
490
|
+
path.isAbsolute(lexical)
|
|
491
|
+
) {
|
|
492
|
+
return {
|
|
493
|
+
ok: false,
|
|
494
|
+
error: `allowed path escapes worktree: ${relativePath}`,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
if (!fs.existsSync(target)) {
|
|
498
|
+
return {
|
|
499
|
+
ok: false,
|
|
500
|
+
error: `allowed path must already exist: ${relativePath}`,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
const linkStats = fs.lstatSync(target, { bigint: true });
|
|
504
|
+
if (linkStats.isSymbolicLink() || !linkStats.isFile()) {
|
|
505
|
+
return {
|
|
506
|
+
ok: false,
|
|
507
|
+
error: `allowed path must be a non-link regular file: ${relativePath}`,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
if (linkStats.nlink !== 1n) {
|
|
511
|
+
return {
|
|
512
|
+
ok: false,
|
|
513
|
+
error: `allowed path must not have hard links: ${relativePath}`,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
const physicalTarget = path.resolve(realpath(target));
|
|
517
|
+
if (!sameCanonicalPath(physicalTarget, target)) {
|
|
518
|
+
return {
|
|
519
|
+
ok: false,
|
|
520
|
+
error: `allowed path traverses a filesystem alias: ${relativePath}`,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return { ok: true, worktreeRoot: root };
|
|
525
|
+
} catch (error) {
|
|
526
|
+
return { ok: false, error: boundedText(error.message) };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
_freshness(expected) {
|
|
531
|
+
const wanted = String(expected || "").trim();
|
|
532
|
+
if (!EXACT_COMMIT_RE.test(wanted)) {
|
|
533
|
+
return { ok: false, expected: wanted || null, actual: null };
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
const actual = this._head();
|
|
537
|
+
return { ok: actual === wanted, expected: wanted, actual };
|
|
538
|
+
} catch (error) {
|
|
539
|
+
return {
|
|
540
|
+
ok: false,
|
|
541
|
+
expected: wanted,
|
|
542
|
+
actual: null,
|
|
543
|
+
error: error.message,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
_cleanFreshness(expected) {
|
|
549
|
+
const freshness = this._freshness(expected);
|
|
550
|
+
if (!freshness.ok) return freshness;
|
|
551
|
+
try {
|
|
552
|
+
const status = this._git(["status", "--porcelain"], "clean-status");
|
|
553
|
+
if (status.trim()) {
|
|
554
|
+
return {
|
|
555
|
+
...freshness,
|
|
556
|
+
ok: false,
|
|
557
|
+
error: "worktree is not clean at the exact delivery head",
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
return freshness;
|
|
561
|
+
} catch (error) {
|
|
562
|
+
return { ...freshness, ok: false, error: error.message };
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
_freshnessFailure(freshness, phase, extras = {}) {
|
|
567
|
+
return definitiveFailure(
|
|
568
|
+
freshness.error ||
|
|
569
|
+
`${phase} exact-head mismatch: expected ${freshness.expected || "unverified"}, found ${freshness.actual || "unverified"}`,
|
|
570
|
+
{ commitSha: freshness.actual, ...extras },
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
_verifyBoundDiff(payload, phase) {
|
|
575
|
+
const baseCommitSha = String(payload?.baseCommitSha || "").trim();
|
|
576
|
+
const commitSha = String(payload?.commitSha || "").trim();
|
|
577
|
+
const expectedDigest = String(payload?.diffDigest || "").trim();
|
|
578
|
+
const expectedFiles = payload?.changedFiles;
|
|
579
|
+
if (
|
|
580
|
+
!EXACT_COMMIT_RE.test(baseCommitSha) ||
|
|
581
|
+
!EXACT_COMMIT_RE.test(commitSha) ||
|
|
582
|
+
!SHA256_RE.test(expectedDigest) ||
|
|
583
|
+
!Array.isArray(expectedFiles)
|
|
584
|
+
) {
|
|
585
|
+
return {
|
|
586
|
+
ok: false,
|
|
587
|
+
error: `${phase} requires an exact coordinator-owned diff binding`,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
const resolvedBase = this._git(
|
|
592
|
+
["rev-parse", "--verify", `${baseCommitSha}^{commit}`],
|
|
593
|
+
`${phase}-base`,
|
|
594
|
+
).trim();
|
|
595
|
+
if (resolvedBase !== baseCommitSha) {
|
|
596
|
+
return {
|
|
597
|
+
ok: false,
|
|
598
|
+
error: `${phase} base commit did not resolve exactly`,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
const range = `${baseCommitSha}...${commitSha}`;
|
|
602
|
+
const diff = this._git(["diff", "--binary", range], `${phase}-diff`);
|
|
603
|
+
const changedFiles = statusPaths(
|
|
604
|
+
this._git(
|
|
605
|
+
["diff", "--name-only", "-z", range],
|
|
606
|
+
`${phase}-changed-files`,
|
|
607
|
+
),
|
|
608
|
+
);
|
|
609
|
+
const actualDigest = sha256(diff);
|
|
610
|
+
if (
|
|
611
|
+
actualDigest !== expectedDigest ||
|
|
612
|
+
!sameStringArray(changedFiles, expectedFiles)
|
|
613
|
+
) {
|
|
614
|
+
return {
|
|
615
|
+
ok: false,
|
|
616
|
+
error: `${phase} diff digest or changed-file set no longer matches the exact base/head range`,
|
|
617
|
+
actualDigest,
|
|
618
|
+
changedFiles,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
ok: true,
|
|
623
|
+
baseCommitSha,
|
|
624
|
+
commitSha,
|
|
625
|
+
diff,
|
|
626
|
+
diffDigest: actualDigest,
|
|
627
|
+
changedFiles: normalizedPathSet(changedFiles),
|
|
628
|
+
};
|
|
629
|
+
} catch (error) {
|
|
630
|
+
return { ok: false, error: boundedText(error.message) };
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
_gateConfiguration(id) {
|
|
635
|
+
const gates = Array.isArray(this.config.gates) ? this.config.gates : [];
|
|
636
|
+
return gates.filter((gate) => String(gate?.id || "") === id);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async runGates(payload, context = {}) {
|
|
640
|
+
const freshness = this._cleanFreshness(payload.commitSha);
|
|
641
|
+
if (!freshness.ok) return this._freshnessFailure(freshness, "gates");
|
|
642
|
+
const effectId = boundEffectId(context);
|
|
643
|
+
const diffBinding = this._verifyBoundDiff(payload, "gates");
|
|
644
|
+
if (!diffBinding.ok) {
|
|
645
|
+
return definitiveFailure(diffBinding.error, {
|
|
646
|
+
commitSha: payload.commitSha,
|
|
647
|
+
actualDiffDigest: diffBinding.actualDigest || null,
|
|
648
|
+
actualChangedFiles: diffBinding.changedFiles || [],
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const selected = uniqueStrings(payload.gateSelection?.selectedGateIds);
|
|
653
|
+
const definitions = new Map(
|
|
654
|
+
(Array.isArray(payload.requiredGates) ? payload.requiredGates : []).map(
|
|
655
|
+
(gate) => [String(gate?.id || ""), gate],
|
|
656
|
+
),
|
|
657
|
+
);
|
|
658
|
+
if (selected.length === 0) {
|
|
659
|
+
return definitiveFailure("selected gate set is empty", {
|
|
660
|
+
commitSha: payload.commitSha,
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const plan = [];
|
|
665
|
+
for (const gateId of selected) {
|
|
666
|
+
const matches = this._gateConfiguration(gateId);
|
|
667
|
+
const requiredMatrix = Array.isArray(definitions.get(gateId)?.matrix)
|
|
668
|
+
? definitions.get(gateId).matrix.map(String)
|
|
669
|
+
: null;
|
|
670
|
+
if (matches.length !== 1 || !requiredMatrix) {
|
|
671
|
+
return definitiveFailure(
|
|
672
|
+
`production gate configuration is missing or ambiguous: ${gateId}`,
|
|
673
|
+
{ commitSha: payload.commitSha },
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
const executions = Array.isArray(matches[0].executions)
|
|
677
|
+
? matches[0].executions
|
|
678
|
+
: [];
|
|
679
|
+
const cells = [];
|
|
680
|
+
for (const cellId of requiredMatrix) {
|
|
681
|
+
const cellMatches = executions.filter(
|
|
682
|
+
(item) => String(item?.id || "") === cellId,
|
|
683
|
+
);
|
|
684
|
+
const spec =
|
|
685
|
+
cellMatches.length === 1 ? commandSpec(cellMatches[0]) : null;
|
|
686
|
+
if (!spec) {
|
|
687
|
+
return definitiveFailure(
|
|
688
|
+
`production gate execution is missing or ambiguous: ${gateId}/${cellId}`,
|
|
689
|
+
{ commitSha: payload.commitSha },
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
cells.push({ id: cellId, spec });
|
|
693
|
+
}
|
|
694
|
+
plan.push({ id: gateId, cells });
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const results = [];
|
|
698
|
+
const failures = [];
|
|
699
|
+
const sideEffects = [];
|
|
700
|
+
for (const gate of plan) {
|
|
701
|
+
const cells = [];
|
|
702
|
+
for (const cell of gate.cells) {
|
|
703
|
+
const gateId = gate.id;
|
|
704
|
+
const cellId = cell.id;
|
|
705
|
+
const spec = cell.spec;
|
|
706
|
+
const before = this._cleanFreshness(payload.commitSha);
|
|
707
|
+
if (!before.ok) {
|
|
708
|
+
return this._freshnessFailure(before, "gates", { sideEffects });
|
|
709
|
+
}
|
|
710
|
+
const processResult = this._process(spec.file, spec.args, {
|
|
711
|
+
timeoutMs: spec.timeoutMs,
|
|
712
|
+
origin: `delivery:gate:${gateId}:${cellId}`,
|
|
713
|
+
});
|
|
714
|
+
const after = this._cleanFreshness(payload.commitSha);
|
|
715
|
+
const passed =
|
|
716
|
+
!processResult?.error &&
|
|
717
|
+
Number(processResult?.status) === 0 &&
|
|
718
|
+
after.ok;
|
|
719
|
+
const message = boundedText(
|
|
720
|
+
processResult?.error?.message ||
|
|
721
|
+
processResult?.stderr ||
|
|
722
|
+
after.error ||
|
|
723
|
+
processResult?.stdout,
|
|
724
|
+
);
|
|
725
|
+
cells.push({
|
|
726
|
+
id: cellId,
|
|
727
|
+
status: passed ? "passed" : "failed",
|
|
728
|
+
commitSha: after.actual || payload.commitSha,
|
|
729
|
+
...(message ? { message } : {}),
|
|
730
|
+
});
|
|
731
|
+
if (!passed) {
|
|
732
|
+
failures.push({
|
|
733
|
+
id: `${gateId}:${cellId}`,
|
|
734
|
+
gateId,
|
|
735
|
+
test: cellId,
|
|
736
|
+
message: message || "gate process failed or changed HEAD",
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
sideEffects.push(
|
|
740
|
+
(passed ? committedEffect : failedEffect)(
|
|
741
|
+
`${effectId}:gate:${gateId}:${cellId}`,
|
|
742
|
+
"gate-process",
|
|
743
|
+
{ gateId, cellId, exitCode: processResult?.status ?? null },
|
|
744
|
+
),
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
results.push({
|
|
748
|
+
id: gate.id,
|
|
749
|
+
status: cells.every((cell) => cell.status === "passed")
|
|
750
|
+
? "passed"
|
|
751
|
+
: "failed",
|
|
752
|
+
commitSha: payload.commitSha,
|
|
753
|
+
matrix: cells,
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
const after = this._cleanFreshness(payload.commitSha);
|
|
757
|
+
if (!after.ok) {
|
|
758
|
+
return this._freshnessFailure(after, "gates", { sideEffects });
|
|
759
|
+
}
|
|
760
|
+
return {
|
|
761
|
+
commitSha: after.actual,
|
|
762
|
+
results,
|
|
763
|
+
failures,
|
|
764
|
+
sideEffects,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async runPreview(payload, context = {}) {
|
|
769
|
+
const freshness = this._cleanFreshness(payload.commitSha);
|
|
770
|
+
if (!freshness.ok) return this._freshnessFailure(freshness, "preview");
|
|
771
|
+
const preview = this.config.preview;
|
|
772
|
+
if (!preview || typeof preview !== "object") {
|
|
773
|
+
return definitiveFailure("production preview configuration is missing", {
|
|
774
|
+
commitSha: payload.commitSha,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
const effectId = boundEffectId(context);
|
|
778
|
+
const captureScreenshot = preview.screenshot !== false;
|
|
779
|
+
const requestedName = path.basename(
|
|
780
|
+
String(preview.screenshotPath || "preview.png"),
|
|
781
|
+
);
|
|
782
|
+
const screenshotName = path.extname(requestedName)
|
|
783
|
+
? requestedName
|
|
784
|
+
: `${requestedName}.png`;
|
|
785
|
+
// Browser output never lands in the git worktree. Its bytes are published
|
|
786
|
+
// below and the evidence keeps the content digest, not a mutable local path.
|
|
787
|
+
const screenshotPath = captureScreenshot
|
|
788
|
+
? path.join(
|
|
789
|
+
path.resolve(this._tmpdir()),
|
|
790
|
+
`cc-delivery-${effectId.slice("sha256:".length, 18)}-${screenshotName}`,
|
|
791
|
+
)
|
|
792
|
+
: null;
|
|
793
|
+
const captured = await this._capturePreview({
|
|
794
|
+
port: preview.port,
|
|
795
|
+
tab: preview.tab,
|
|
796
|
+
watchMs: preview.watchMs,
|
|
797
|
+
domCap: preview.domCap,
|
|
798
|
+
includeDom: preview.includeDom !== false,
|
|
799
|
+
screenshotPath,
|
|
800
|
+
reload: preview.reload === true,
|
|
801
|
+
});
|
|
802
|
+
const captureEffect =
|
|
803
|
+
captured?.ok === true
|
|
804
|
+
? committedEffect(`${effectId}:capture`, "preview-capture", {
|
|
805
|
+
reloaded: preview.reload === true,
|
|
806
|
+
screenshotRef:
|
|
807
|
+
captured.screenshotRef ||
|
|
808
|
+
(captured.screenshotPath
|
|
809
|
+
? path.basename(captured.screenshotPath)
|
|
810
|
+
: null),
|
|
811
|
+
})
|
|
812
|
+
: unknownEffect(`${effectId}:capture`, "preview-capture");
|
|
813
|
+
const sideEffects = [captureEffect];
|
|
814
|
+
let screenshotArtifact = null;
|
|
815
|
+
if (captured?.ok === true && screenshotPath && !captured.screenshotError) {
|
|
816
|
+
const returnedPath = captured.screenshotPath
|
|
817
|
+
? path.resolve(String(captured.screenshotPath))
|
|
818
|
+
: null;
|
|
819
|
+
if (returnedPath !== path.resolve(screenshotPath)) {
|
|
820
|
+
return definitiveFailure(
|
|
821
|
+
"preview did not return the exact out-of-worktree screenshot path",
|
|
822
|
+
{ commitSha: payload.commitSha, sideEffects },
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
const entry = this._artifactStore.publishData({
|
|
826
|
+
data: this._readFileSync(returnedPath),
|
|
827
|
+
fileName: screenshotName,
|
|
828
|
+
title: captured.title || "Delivery preview screenshot",
|
|
829
|
+
kind: "screenshot",
|
|
830
|
+
mime: "image/png",
|
|
831
|
+
immutable: true,
|
|
832
|
+
});
|
|
833
|
+
const integrity = this._artifactStore.verifyIntegrity(entry);
|
|
834
|
+
if (integrity.ok !== true) {
|
|
835
|
+
throw new Error(
|
|
836
|
+
"published preview screenshot failed integrity readback",
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
screenshotArtifact = publicArtifactMetadata(entry);
|
|
840
|
+
sideEffects.push(
|
|
841
|
+
committedEffect(`${effectId}:screenshot`, "immutable-artifact", {
|
|
842
|
+
artifactId: entry.id,
|
|
843
|
+
sha256: entry.sha256,
|
|
844
|
+
}),
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
const after = this._cleanFreshness(payload.commitSha);
|
|
848
|
+
if (!after.ok) {
|
|
849
|
+
return definitiveFailure(
|
|
850
|
+
after.error || "preview exact-head freshness was lost",
|
|
851
|
+
{ commitSha: after.actual, sideEffects },
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
if (captured?.ok !== true) {
|
|
855
|
+
return definitiveFailure(captured?.error || "preview capture failed", {
|
|
856
|
+
commitSha: payload.commitSha,
|
|
857
|
+
sideEffects,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
const consoleFailures = (captured.console || []).filter((entry) =>
|
|
861
|
+
["error", "assert"].includes(String(entry?.type || "").toLowerCase()),
|
|
862
|
+
);
|
|
863
|
+
const networkFailures = Array.isArray(captured.network)
|
|
864
|
+
? captured.network
|
|
865
|
+
: [];
|
|
866
|
+
const failures = [
|
|
867
|
+
...consoleFailures.map((entry, index) => ({
|
|
868
|
+
id: `preview-console-${index + 1}`,
|
|
869
|
+
message: entry.text || "browser console error",
|
|
870
|
+
})),
|
|
871
|
+
...networkFailures.map((entry, index) => ({
|
|
872
|
+
id: `preview-network-${index + 1}`,
|
|
873
|
+
message:
|
|
874
|
+
entry.error ||
|
|
875
|
+
`${entry.kind || "network"} ${entry.status || "failure"}: ${entry.url || "unknown URL"}`,
|
|
876
|
+
})),
|
|
877
|
+
];
|
|
878
|
+
if (captured.screenshotError) {
|
|
879
|
+
failures.push({
|
|
880
|
+
id: "preview-screenshot",
|
|
881
|
+
message: captured.screenshotError,
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
if (screenshotPath && !screenshotArtifact && !captured.screenshotError) {
|
|
885
|
+
failures.push({
|
|
886
|
+
id: "preview-screenshot",
|
|
887
|
+
message: "preview screenshot bytes were not published",
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
const artifacts = [
|
|
891
|
+
{
|
|
892
|
+
kind: "dom-summary",
|
|
893
|
+
data: {
|
|
894
|
+
tier: "dom-assert",
|
|
895
|
+
label: captured.title || "Preview DOM",
|
|
896
|
+
summary: JSON.stringify({
|
|
897
|
+
url: captured.url,
|
|
898
|
+
title: captured.title,
|
|
899
|
+
html: captured.html,
|
|
900
|
+
htmlTruncated: captured.htmlTruncated === true,
|
|
901
|
+
console: captured.console || [],
|
|
902
|
+
network: captured.network || [],
|
|
903
|
+
}),
|
|
904
|
+
},
|
|
905
|
+
},
|
|
906
|
+
{
|
|
907
|
+
kind: "test-result",
|
|
908
|
+
data: {
|
|
909
|
+
tier: "dom-assert",
|
|
910
|
+
label: "Preview console/network assertions",
|
|
911
|
+
passed: failures.length === 0,
|
|
912
|
+
total: consoleFailures.length + networkFailures.length,
|
|
913
|
+
failed: failures.length,
|
|
914
|
+
output: failures.map((item) => item.message).join("\n") || "clean",
|
|
915
|
+
},
|
|
916
|
+
},
|
|
917
|
+
];
|
|
918
|
+
if (screenshotArtifact) {
|
|
919
|
+
artifacts.push({
|
|
920
|
+
kind: "screenshot",
|
|
921
|
+
data: {
|
|
922
|
+
tier: "visual-screenshot",
|
|
923
|
+
label: captured.title || "Preview screenshot",
|
|
924
|
+
ref: screenshotArtifact.id,
|
|
925
|
+
digest: `sha256:${screenshotArtifact.sha256}`,
|
|
926
|
+
},
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
return {
|
|
930
|
+
commitSha: payload.commitSha,
|
|
931
|
+
passed: failures.length === 0,
|
|
932
|
+
failures,
|
|
933
|
+
artifacts,
|
|
934
|
+
sideEffects,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async runReview(payload, context = {}) {
|
|
939
|
+
const freshness = this._cleanFreshness(payload.commitSha);
|
|
940
|
+
if (!freshness.ok) return this._freshnessFailure(freshness, "review");
|
|
941
|
+
const effectId = boundEffectId(context);
|
|
942
|
+
const diffBinding = this._verifyBoundDiff(payload, "review");
|
|
943
|
+
if (!diffBinding.ok) {
|
|
944
|
+
return definitiveFailure(diffBinding.error, {
|
|
945
|
+
commitSha: payload.commitSha,
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
const review = this.config.review || {};
|
|
949
|
+
const result = await this._runReview({
|
|
950
|
+
cwd: this.cwd,
|
|
951
|
+
base: payload.baseCommitSha,
|
|
952
|
+
effort: review.effort || "high",
|
|
953
|
+
multi: true,
|
|
954
|
+
verify: review.verify !== false,
|
|
955
|
+
outputFormat: "json",
|
|
956
|
+
maxTurns: review.maxTurns,
|
|
957
|
+
model: review.model,
|
|
958
|
+
provider: review.provider,
|
|
959
|
+
});
|
|
960
|
+
const reviewEffect = committedEffect(
|
|
961
|
+
`${effectId}:review`,
|
|
962
|
+
"structured-review",
|
|
963
|
+
);
|
|
964
|
+
const after = this._cleanFreshness(payload.commitSha);
|
|
965
|
+
if (!after.ok) {
|
|
966
|
+
return this._freshnessFailure(after, "review", {
|
|
967
|
+
sideEffects: [reviewEffect],
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
if (
|
|
971
|
+
result?.isError !== false ||
|
|
972
|
+
!result?.report ||
|
|
973
|
+
!Array.isArray(result.report.findings)
|
|
974
|
+
) {
|
|
975
|
+
return definitiveFailure("structured production review failed", {
|
|
976
|
+
commitSha: payload.commitSha,
|
|
977
|
+
sideEffects: [failedEffect(`${effectId}:review`, "structured-review")],
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
return {
|
|
981
|
+
commitSha: payload.commitSha,
|
|
982
|
+
rawFindings: result.report.findings.map(findingFromReport),
|
|
983
|
+
sideEffects: [reviewEffect],
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async applyFix(payload, context = {}) {
|
|
988
|
+
const freshness = this._cleanFreshness(payload.commitSha);
|
|
989
|
+
if (!freshness.ok) return this._freshnessFailure(freshness, "fix");
|
|
990
|
+
const effectId = boundEffectId(context);
|
|
991
|
+
const fix = this.config.fix;
|
|
992
|
+
const allowedPaths = uniqueStrings(fix?.allowedPaths).map(
|
|
993
|
+
normalizedRepoPath,
|
|
994
|
+
);
|
|
995
|
+
const baseCommitSha = String(fix?.baseCommitSha || "").trim();
|
|
996
|
+
if (
|
|
997
|
+
!fix ||
|
|
998
|
+
allowedPaths.length === 0 ||
|
|
999
|
+
!EXACT_COMMIT_RE.test(baseCommitSha)
|
|
1000
|
+
) {
|
|
1001
|
+
return definitiveFailure(
|
|
1002
|
+
"fix requires explicit allowedPaths and an exact baseCommitSha",
|
|
1003
|
+
{ commitSha: payload.commitSha },
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
if (baseCommitSha !== String(payload.baseCommitSha || "")) {
|
|
1007
|
+
return definitiveFailure(
|
|
1008
|
+
"fix config baseCommitSha does not match the coordinator diff base",
|
|
1009
|
+
{
|
|
1010
|
+
commitSha: payload.commitSha,
|
|
1011
|
+
},
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
const diffBinding = this._verifyBoundDiff(payload, "fix");
|
|
1015
|
+
if (!diffBinding.ok) {
|
|
1016
|
+
return definitiveFailure(diffBinding.error, {
|
|
1017
|
+
commitSha: payload.commitSha,
|
|
1018
|
+
actualDiffDigest: diffBinding.actualDigest || null,
|
|
1019
|
+
actualChangedFiles: diffBinding.changedFiles || [],
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
const beforeIdentity = this._worktreeIdentity();
|
|
1023
|
+
if (!beforeIdentity.branch) {
|
|
1024
|
+
return definitiveFailure("fix requires an attached worktree branch", {
|
|
1025
|
+
commitSha: payload.commitSha,
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
const allowedScope = this._verifyAllowedPathScope(
|
|
1029
|
+
allowedPaths,
|
|
1030
|
+
beforeIdentity.root,
|
|
1031
|
+
);
|
|
1032
|
+
if (!allowedScope.ok) {
|
|
1033
|
+
return definitiveFailure(allowedScope.error, {
|
|
1034
|
+
commitSha: payload.commitSha,
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
const exactAllowedPaths = Object.freeze([...allowedPaths]);
|
|
1038
|
+
const fileMutationScope = Object.freeze({
|
|
1039
|
+
exact: true,
|
|
1040
|
+
worktreeRoot: allowedScope.worktreeRoot,
|
|
1041
|
+
allowedPaths: exactAllowedPaths,
|
|
1042
|
+
});
|
|
1043
|
+
const outcome = await this._runFix({
|
|
1044
|
+
cwd: allowedScope.worktreeRoot,
|
|
1045
|
+
base: baseCommitSha,
|
|
1046
|
+
effort: fix.effort || "high",
|
|
1047
|
+
fix: true,
|
|
1048
|
+
single: true,
|
|
1049
|
+
checkpoint: true,
|
|
1050
|
+
paths: exactAllowedPaths,
|
|
1051
|
+
allowedPaths: exactAllowedPaths,
|
|
1052
|
+
allowedTools: DELIVERY_FIXER_ALLOWED_TOOLS,
|
|
1053
|
+
exactToolNames: true,
|
|
1054
|
+
fileMutationScope,
|
|
1055
|
+
hermeticExecution: true,
|
|
1056
|
+
// The fixer has no MCP tools in its exact capability set. Suppress
|
|
1057
|
+
// registered/plugin/IDE discovery as defense in depth; headless also
|
|
1058
|
+
// skips MCP startup entirely for this exact built-in-only ceiling.
|
|
1059
|
+
useRegisteredMcp: false,
|
|
1060
|
+
strictMcpConfig: true,
|
|
1061
|
+
ide: false,
|
|
1062
|
+
pdh: false,
|
|
1063
|
+
jetbrains: false,
|
|
1064
|
+
failureContext: jsonClone(payload.failures || []),
|
|
1065
|
+
reviewContext: jsonClone(payload.review || null),
|
|
1066
|
+
maxTurns: fix.maxTurns,
|
|
1067
|
+
model: fix.model,
|
|
1068
|
+
provider: fix.provider,
|
|
1069
|
+
});
|
|
1070
|
+
if (
|
|
1071
|
+
outcome?.isError !== false ||
|
|
1072
|
+
(Number.isFinite(Number(outcome?.exitCode)) &&
|
|
1073
|
+
Number(outcome.exitCode) !== 0)
|
|
1074
|
+
) {
|
|
1075
|
+
throw new Error(
|
|
1076
|
+
"review fixer failed after it may have changed the worktree",
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
if (!this._sameWorktreeIdentity(beforeIdentity).ok) {
|
|
1080
|
+
throw new Error(
|
|
1081
|
+
"fixer changed worktree, branch, or HEAD identity outside the delivery commit transaction",
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
const afterAllowedScope = this._verifyAllowedPathScope(
|
|
1085
|
+
allowedPaths,
|
|
1086
|
+
beforeIdentity.root,
|
|
1087
|
+
);
|
|
1088
|
+
if (!afterAllowedScope.ok) {
|
|
1089
|
+
throw new Error(
|
|
1090
|
+
`fixer violated the physical allowed-path boundary: ${afterAllowedScope.error}`,
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
const fixerEffect = committedEffect(`${effectId}:fixer`, "review-fixer");
|
|
1094
|
+
const unstaged = statusPaths(
|
|
1095
|
+
this._git(["diff", "--name-only", "-z"], "changed-files"),
|
|
1096
|
+
);
|
|
1097
|
+
const staged = statusPaths(
|
|
1098
|
+
this._git(
|
|
1099
|
+
["diff", "--cached", "--name-only", "-z"],
|
|
1100
|
+
"staged-changed-files",
|
|
1101
|
+
),
|
|
1102
|
+
);
|
|
1103
|
+
const untracked = statusPaths(
|
|
1104
|
+
this._git(
|
|
1105
|
+
["ls-files", "--others", "--exclude-standard", "-z"],
|
|
1106
|
+
"untracked-files",
|
|
1107
|
+
),
|
|
1108
|
+
);
|
|
1109
|
+
const changed = uniqueStrings([...unstaged, ...staged, ...untracked]);
|
|
1110
|
+
if (changed.length === 0) {
|
|
1111
|
+
const unchanged = this._cleanFreshness(payload.commitSha);
|
|
1112
|
+
if (!unchanged.ok) {
|
|
1113
|
+
throw new Error(
|
|
1114
|
+
unchanged.error ||
|
|
1115
|
+
"fixer changed worktree identity without an allowed edit",
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
return {
|
|
1119
|
+
changed: false,
|
|
1120
|
+
commitSha: payload.commitSha,
|
|
1121
|
+
diffDigest: payload.diffDigest,
|
|
1122
|
+
changedFiles: [],
|
|
1123
|
+
sideEffects: [fixerEffect],
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
const outside = changed.filter(
|
|
1127
|
+
(item) => !allowedPaths.includes(normalizedRepoPath(item)),
|
|
1128
|
+
);
|
|
1129
|
+
if (outside.length > 0) {
|
|
1130
|
+
throw new Error(
|
|
1131
|
+
`fix changed paths outside its explicit allowlist: ${outside.join(", ")}`,
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
if (!this._sameWorktreeIdentity(beforeIdentity).ok) {
|
|
1135
|
+
throw new Error(
|
|
1136
|
+
"worktree identity changed before the fix could be staged",
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
this._mutate("git", ["add", "--", ...changed], "git-stage-fix");
|
|
1140
|
+
if (!this._sameWorktreeIdentity(beforeIdentity).ok) {
|
|
1141
|
+
throw new Error(
|
|
1142
|
+
"worktree identity changed while the allowed fix was staged",
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
this._mutate(
|
|
1146
|
+
"git",
|
|
1147
|
+
[
|
|
1148
|
+
"commit",
|
|
1149
|
+
"-m",
|
|
1150
|
+
String(fix.commitMessage || "fix(delivery): apply reviewed fixes"),
|
|
1151
|
+
],
|
|
1152
|
+
"git-commit-fix",
|
|
1153
|
+
);
|
|
1154
|
+
const nextCommit = this._head();
|
|
1155
|
+
if (!EXACT_COMMIT_RE.test(nextCommit) || nextCommit === payload.commitSha) {
|
|
1156
|
+
throw new Error("fix commit did not produce a new exact HEAD");
|
|
1157
|
+
}
|
|
1158
|
+
const parentCommit = this._git(
|
|
1159
|
+
["rev-parse", "--verify", `${nextCommit}^`],
|
|
1160
|
+
"fix-parent",
|
|
1161
|
+
).trim();
|
|
1162
|
+
if (parentCommit !== payload.commitSha) {
|
|
1163
|
+
throw new Error(
|
|
1164
|
+
"fix commit parent is not the exact pre-fix delivery head",
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
const committedFixFiles = statusPaths(
|
|
1168
|
+
this._git(
|
|
1169
|
+
["diff", "--name-only", "-z", `${payload.commitSha}...${nextCommit}`],
|
|
1170
|
+
"fix-commit-changed-files",
|
|
1171
|
+
),
|
|
1172
|
+
);
|
|
1173
|
+
const committedOutside = committedFixFiles.filter(
|
|
1174
|
+
(item) => !allowedPaths.includes(normalizedRepoPath(item)),
|
|
1175
|
+
);
|
|
1176
|
+
if (committedFixFiles.length === 0 || committedOutside.length > 0) {
|
|
1177
|
+
throw new Error(
|
|
1178
|
+
committedOutside.length > 0
|
|
1179
|
+
? `fix commit contains paths outside its explicit allowlist: ${committedOutside.join(", ")}`
|
|
1180
|
+
: "fix commit does not contain an attributable allowed-path change",
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
if (this._git(["status", "--porcelain"], "post-fix-status").trim()) {
|
|
1184
|
+
throw new Error("fix commit left uncommitted worktree changes");
|
|
1185
|
+
}
|
|
1186
|
+
const diff = this._git(
|
|
1187
|
+
["diff", "--binary", `${baseCommitSha}...${nextCommit}`],
|
|
1188
|
+
"fixed-diff",
|
|
1189
|
+
);
|
|
1190
|
+
const changedFiles = statusPaths(
|
|
1191
|
+
this._git(
|
|
1192
|
+
["diff", "--name-only", "-z", `${baseCommitSha}...${nextCommit}`],
|
|
1193
|
+
"fixed-changed-files",
|
|
1194
|
+
),
|
|
1195
|
+
);
|
|
1196
|
+
const sideEffects = [
|
|
1197
|
+
fixerEffect,
|
|
1198
|
+
committedEffect(`${effectId}:commit`, "git-commit", {
|
|
1199
|
+
commitSha: nextCommit,
|
|
1200
|
+
}),
|
|
1201
|
+
];
|
|
1202
|
+
if (context.state?.pr?.number) {
|
|
1203
|
+
const branch = this._git(["branch", "--show-current"], "branch").trim();
|
|
1204
|
+
if (!branch) throw new Error("cannot push a fix from detached HEAD");
|
|
1205
|
+
this._mutate(
|
|
1206
|
+
"git",
|
|
1207
|
+
["push", "origin", `${nextCommit}:refs/heads/${branch}`],
|
|
1208
|
+
"git-push-fix",
|
|
1209
|
+
);
|
|
1210
|
+
const pr = this._readPr(context.state.pr.number);
|
|
1211
|
+
if (
|
|
1212
|
+
String(pr.state || "").toUpperCase() !== "OPEN" ||
|
|
1213
|
+
String(pr.headRefOid || "") !== nextCommit
|
|
1214
|
+
) {
|
|
1215
|
+
throw new Error("pushed fix did not become the PR's exact remote head");
|
|
1216
|
+
}
|
|
1217
|
+
sideEffects.push(
|
|
1218
|
+
committedEffect(`${effectId}:push`, "git-push", {
|
|
1219
|
+
commitSha: nextCommit,
|
|
1220
|
+
prNumber: context.state.pr.number,
|
|
1221
|
+
}),
|
|
1222
|
+
);
|
|
1223
|
+
}
|
|
1224
|
+
return {
|
|
1225
|
+
changed: true,
|
|
1226
|
+
commitSha: nextCommit,
|
|
1227
|
+
diffDigest: sha256(diff),
|
|
1228
|
+
changedFiles,
|
|
1229
|
+
progressDigest: sha256(`${nextCommit}\0${diff}`),
|
|
1230
|
+
sideEffects,
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
_readPr(target) {
|
|
1235
|
+
return parseJson(
|
|
1236
|
+
this._gh(
|
|
1237
|
+
[
|
|
1238
|
+
"pr",
|
|
1239
|
+
"view",
|
|
1240
|
+
String(target),
|
|
1241
|
+
"--json",
|
|
1242
|
+
"number,state,isDraft,baseRefName,headRefName,headRefOid,url,reviewDecision,reviewRequests,mergeStateStatus,statusCheckRollup,mergeCommit",
|
|
1243
|
+
],
|
|
1244
|
+
"pr-view",
|
|
1245
|
+
),
|
|
1246
|
+
"gh pr view",
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
_readBranchProtection(baseRefName) {
|
|
1251
|
+
const repo = String(this.config.github?.repo || "").trim();
|
|
1252
|
+
const branch = String(baseRefName || "").trim();
|
|
1253
|
+
if (!repo || !branch) {
|
|
1254
|
+
throw new Error(
|
|
1255
|
+
"branch protection lookup requires an exact repository and base branch",
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
const protection = parseJson(
|
|
1259
|
+
this._read(
|
|
1260
|
+
"gh",
|
|
1261
|
+
[
|
|
1262
|
+
"api",
|
|
1263
|
+
`repos/${repo}/branches/${encodeURIComponent(branch)}/protection`,
|
|
1264
|
+
],
|
|
1265
|
+
"gh-branch-protection",
|
|
1266
|
+
),
|
|
1267
|
+
"GitHub branch protection",
|
|
1268
|
+
);
|
|
1269
|
+
const statusRule = protection?.required_status_checks;
|
|
1270
|
+
const protectedChecks = uniqueStrings([
|
|
1271
|
+
...(Array.isArray(statusRule?.contexts) ? statusRule.contexts : []),
|
|
1272
|
+
...(Array.isArray(statusRule?.checks)
|
|
1273
|
+
? statusRule.checks.map((item) => item?.context)
|
|
1274
|
+
: []),
|
|
1275
|
+
]);
|
|
1276
|
+
const requiredChecks = uniqueStrings(this.config.ci?.requiredChecks);
|
|
1277
|
+
return {
|
|
1278
|
+
protected: protection && typeof protection === "object",
|
|
1279
|
+
requiredChecksProtected: requiredChecks.every((name) =>
|
|
1280
|
+
protectedChecks.includes(name),
|
|
1281
|
+
),
|
|
1282
|
+
reviewRequired:
|
|
1283
|
+
protection?.required_pull_request_reviews != null &&
|
|
1284
|
+
typeof protection.required_pull_request_reviews === "object",
|
|
1285
|
+
enforcedForAdmins: protection?.enforce_admins?.enabled === true,
|
|
1286
|
+
protectedChecks,
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
_ciSnapshot(pr, commitSha, protection) {
|
|
1291
|
+
const requiredChecks = uniqueStrings(this.config.ci?.requiredChecks);
|
|
1292
|
+
const rollup = Array.isArray(pr?.statusCheckRollup)
|
|
1293
|
+
? pr.statusCheckRollup
|
|
1294
|
+
: [];
|
|
1295
|
+
const checks = rollup.map((check) => ({
|
|
1296
|
+
name: String(check.name || check.context || ""),
|
|
1297
|
+
state: checkState(check.conclusion || check.state || check.status),
|
|
1298
|
+
commitSha,
|
|
1299
|
+
url: check.detailsUrl || check.targetUrl || null,
|
|
1300
|
+
}));
|
|
1301
|
+
const requiredMatrixComplete = requiredChecks.every(
|
|
1302
|
+
(name) => checks.filter((check) => check.name === name).length === 1,
|
|
1303
|
+
);
|
|
1304
|
+
const requiredChecksSuccessful =
|
|
1305
|
+
requiredMatrixComplete &&
|
|
1306
|
+
requiredChecks.every(
|
|
1307
|
+
(name) =>
|
|
1308
|
+
checks.filter(
|
|
1309
|
+
(check) => check.name === name && check.state === "success",
|
|
1310
|
+
).length === 1,
|
|
1311
|
+
);
|
|
1312
|
+
const reviewApproved =
|
|
1313
|
+
String(pr?.reviewDecision || "").toUpperCase() === "APPROVED";
|
|
1314
|
+
const pendingApprovals = Array.isArray(pr?.reviewRequests)
|
|
1315
|
+
? pr.reviewRequests.length
|
|
1316
|
+
: null;
|
|
1317
|
+
const branchProtectionSatisfied =
|
|
1318
|
+
protection?.protected === true &&
|
|
1319
|
+
protection.requiredChecksProtected === true &&
|
|
1320
|
+
protection.reviewRequired === true &&
|
|
1321
|
+
protection.enforcedForAdmins === true &&
|
|
1322
|
+
String(pr?.mergeStateStatus || "").toUpperCase() === "CLEAN";
|
|
1323
|
+
return {
|
|
1324
|
+
requiredChecks,
|
|
1325
|
+
checks,
|
|
1326
|
+
requiredMatrixComplete,
|
|
1327
|
+
requiredChecksSuccessful,
|
|
1328
|
+
reviewApproved,
|
|
1329
|
+
pendingApprovals,
|
|
1330
|
+
branchProtectionSatisfied,
|
|
1331
|
+
protectedChecks: protection?.protectedChecks || [],
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
_verifyPublishedEvidence(evidence, commitSha, diffDigest) {
|
|
1336
|
+
const recordDigest = String(evidence?.recordDigest || "");
|
|
1337
|
+
const supplied = evidence?.artifact;
|
|
1338
|
+
if (
|
|
1339
|
+
!SHA256_RE.test(recordDigest) ||
|
|
1340
|
+
!supplied?.id ||
|
|
1341
|
+
supplied.immutable !== true ||
|
|
1342
|
+
supplied.recordDigest !== recordDigest ||
|
|
1343
|
+
typeof this._artifactStore.get !== "function" ||
|
|
1344
|
+
typeof this._artifactStore.storedPath !== "function"
|
|
1345
|
+
) {
|
|
1346
|
+
return { ok: false, error: "published evidence binding is incomplete" };
|
|
1347
|
+
}
|
|
1348
|
+
const canonical = this._artifactStore.get(supplied.id);
|
|
1349
|
+
if (
|
|
1350
|
+
!canonical ||
|
|
1351
|
+
canonical.id !== supplied.id ||
|
|
1352
|
+
canonical.file !== supplied.file ||
|
|
1353
|
+
canonical.sha256 !== supplied.sha256 ||
|
|
1354
|
+
canonical.immutable !== true ||
|
|
1355
|
+
canonical.recordDigest !== recordDigest
|
|
1356
|
+
) {
|
|
1357
|
+
return {
|
|
1358
|
+
ok: false,
|
|
1359
|
+
error:
|
|
1360
|
+
"published evidence metadata no longer matches the artifact store",
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
const integrity = this._artifactStore.verifyIntegrity(canonical);
|
|
1364
|
+
if (integrity?.ok !== true) {
|
|
1365
|
+
return {
|
|
1366
|
+
ok: false,
|
|
1367
|
+
error: `published evidence integrity failed: ${integrity?.reason || "unknown"}`,
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
try {
|
|
1371
|
+
const body = this._readFileSync(
|
|
1372
|
+
this._artifactStore.storedPath(canonical),
|
|
1373
|
+
);
|
|
1374
|
+
const actualSha256 = crypto
|
|
1375
|
+
.createHash("sha256")
|
|
1376
|
+
.update(body)
|
|
1377
|
+
.digest("hex");
|
|
1378
|
+
if (actualSha256 !== canonical.sha256) {
|
|
1379
|
+
return {
|
|
1380
|
+
ok: false,
|
|
1381
|
+
error: "published evidence bytes changed during merge verification",
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
const record = parseJson(body, "published evidence artifact");
|
|
1385
|
+
const verification = verifyDeliveryEvidenceRecord(record);
|
|
1386
|
+
if (
|
|
1387
|
+
!verification.valid ||
|
|
1388
|
+
record.recordDigest !== recordDigest ||
|
|
1389
|
+
String(record.commit?.sha || "") !== commitSha ||
|
|
1390
|
+
String(record.diff?.digest || "") !== diffDigest
|
|
1391
|
+
) {
|
|
1392
|
+
return {
|
|
1393
|
+
ok: false,
|
|
1394
|
+
error:
|
|
1395
|
+
"published evidence record digest or exact commit/diff binding is invalid",
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
return { ok: true, integrity };
|
|
1399
|
+
} catch (error) {
|
|
1400
|
+
return { ok: false, error: boundedText(error.message) };
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
async createPr(payload, context = {}) {
|
|
1405
|
+
const freshness = this._cleanFreshness(payload.commitSha);
|
|
1406
|
+
if (!freshness.ok) return this._freshnessFailure(freshness, "PR creation");
|
|
1407
|
+
const effectId = boundEffectId(context);
|
|
1408
|
+
const prConfig = this.config.pullRequest;
|
|
1409
|
+
if (!prConfig?.base || !prConfig?.title) {
|
|
1410
|
+
return definitiveFailure("PR creation requires explicit base and title", {
|
|
1411
|
+
commitSha: payload.commitSha,
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
const branch = this._git(["branch", "--show-current"], "branch").trim();
|
|
1415
|
+
if (!branch || branch === prConfig.base) {
|
|
1416
|
+
return definitiveFailure("PR creation requires a non-base branch", {
|
|
1417
|
+
commitSha: payload.commitSha,
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
const existing = parseJson(
|
|
1421
|
+
this._gh(
|
|
1422
|
+
[
|
|
1423
|
+
"pr",
|
|
1424
|
+
"list",
|
|
1425
|
+
"--head",
|
|
1426
|
+
branch,
|
|
1427
|
+
"--state",
|
|
1428
|
+
"open",
|
|
1429
|
+
"--json",
|
|
1430
|
+
"number,headRefOid,url",
|
|
1431
|
+
],
|
|
1432
|
+
"pr-list",
|
|
1433
|
+
),
|
|
1434
|
+
"gh pr list",
|
|
1435
|
+
);
|
|
1436
|
+
if (!Array.isArray(existing) || existing.length > 1) {
|
|
1437
|
+
return definitiveFailure("open PR lookup is ambiguous", {
|
|
1438
|
+
commitSha: payload.commitSha,
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
this._mutate(
|
|
1442
|
+
"git",
|
|
1443
|
+
["push", "origin", `${payload.commitSha}:refs/heads/${branch}`],
|
|
1444
|
+
"git-push-pr",
|
|
1445
|
+
);
|
|
1446
|
+
const sideEffects = [
|
|
1447
|
+
committedEffect(`${effectId}:push`, "git-push", {
|
|
1448
|
+
commitSha: payload.commitSha,
|
|
1449
|
+
branch,
|
|
1450
|
+
}),
|
|
1451
|
+
];
|
|
1452
|
+
let pr;
|
|
1453
|
+
if (existing.length === 1) {
|
|
1454
|
+
pr = this._readPr(existing[0].number);
|
|
1455
|
+
sideEffects.push(
|
|
1456
|
+
noEffect(`${effectId}:pr`, "github-pr-existing", {
|
|
1457
|
+
prNumber: existing[0].number,
|
|
1458
|
+
}),
|
|
1459
|
+
);
|
|
1460
|
+
} else {
|
|
1461
|
+
const marker = `<!-- chainlesschain-delivery-effect:${effectId} -->`;
|
|
1462
|
+
const body = `${String(prConfig.body || "").trim()}\n\n${marker}`.trim();
|
|
1463
|
+
this._ghMutate(
|
|
1464
|
+
[
|
|
1465
|
+
"pr",
|
|
1466
|
+
"create",
|
|
1467
|
+
"--base",
|
|
1468
|
+
String(prConfig.base),
|
|
1469
|
+
"--head",
|
|
1470
|
+
branch,
|
|
1471
|
+
"--title",
|
|
1472
|
+
String(prConfig.title),
|
|
1473
|
+
"--body",
|
|
1474
|
+
body,
|
|
1475
|
+
...(prConfig.draft === true ? ["--draft"] : []),
|
|
1476
|
+
],
|
|
1477
|
+
"pr-create",
|
|
1478
|
+
);
|
|
1479
|
+
pr = this._readPr(branch);
|
|
1480
|
+
sideEffects.push(
|
|
1481
|
+
committedEffect(`${effectId}:pr`, "github-pr-create", {
|
|
1482
|
+
prNumber: pr.number,
|
|
1483
|
+
}),
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
const after = this._freshness(payload.commitSha);
|
|
1487
|
+
if (
|
|
1488
|
+
!after.ok ||
|
|
1489
|
+
String(pr.state || "").toUpperCase() !== "OPEN" ||
|
|
1490
|
+
String(pr.headRefOid || "") !== payload.commitSha ||
|
|
1491
|
+
String(pr.baseRefName || "") !== String(prConfig.base) ||
|
|
1492
|
+
!Number.isInteger(Number(pr.number))
|
|
1493
|
+
) {
|
|
1494
|
+
throw new Error(
|
|
1495
|
+
"created/reused PR is not open at the exact delivery head",
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
return {
|
|
1499
|
+
number: Number(pr.number),
|
|
1500
|
+
hasOpenPr: true,
|
|
1501
|
+
headCommitSha: payload.commitSha,
|
|
1502
|
+
url: pr.url,
|
|
1503
|
+
headRefName: pr.headRefName || branch,
|
|
1504
|
+
sideEffects,
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
async refreshCi(payload, context = {}) {
|
|
1509
|
+
const freshness = this._cleanFreshness(payload.commitSha);
|
|
1510
|
+
if (!freshness.ok) return this._freshnessFailure(freshness, "CI refresh");
|
|
1511
|
+
boundEffectId(context);
|
|
1512
|
+
const number = Number(context.state?.pr?.number);
|
|
1513
|
+
const requiredChecks = uniqueStrings(this.config.ci?.requiredChecks);
|
|
1514
|
+
if (
|
|
1515
|
+
!Number.isInteger(number) ||
|
|
1516
|
+
number <= 0 ||
|
|
1517
|
+
requiredChecks.length === 0
|
|
1518
|
+
) {
|
|
1519
|
+
return definitiveFailure(
|
|
1520
|
+
"CI refresh requires an exact PR number and authoritative requiredChecks",
|
|
1521
|
+
{ commitSha: payload.commitSha },
|
|
1522
|
+
);
|
|
1523
|
+
}
|
|
1524
|
+
const pr = this._readPr(number);
|
|
1525
|
+
const after = this._cleanFreshness(payload.commitSha);
|
|
1526
|
+
if (!after.ok) return this._freshnessFailure(after, "CI refresh");
|
|
1527
|
+
const headCommitSha = String(pr.headRefOid || "");
|
|
1528
|
+
if (
|
|
1529
|
+
String(pr.state || "").toUpperCase() !== "OPEN" ||
|
|
1530
|
+
headCommitSha !== payload.commitSha ||
|
|
1531
|
+
String(pr.baseRefName || "") !== String(this.config.pullRequest?.base)
|
|
1532
|
+
) {
|
|
1533
|
+
return definitiveFailure("CI is not bound to the open exact-head PR", {
|
|
1534
|
+
commitSha: payload.commitSha,
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
let protection;
|
|
1538
|
+
try {
|
|
1539
|
+
protection = this._readBranchProtection(pr.baseRefName);
|
|
1540
|
+
} catch (error) {
|
|
1541
|
+
return definitiveFailure(
|
|
1542
|
+
`branch protection could not be verified: ${boundedText(error.message)}`,
|
|
1543
|
+
{ commitSha: payload.commitSha },
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
const snapshot = this._ciSnapshot(pr, headCommitSha, protection);
|
|
1547
|
+
return {
|
|
1548
|
+
hasOpenPr: String(pr.state || "").toUpperCase() === "OPEN",
|
|
1549
|
+
headCommitSha,
|
|
1550
|
+
ciCommitSha: headCommitSha,
|
|
1551
|
+
...snapshot,
|
|
1552
|
+
sideEffects: [],
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
async publishEvidence(payload, context = {}) {
|
|
1557
|
+
const freshness = this._cleanFreshness(payload.commitSha);
|
|
1558
|
+
if (!freshness.ok) {
|
|
1559
|
+
return this._freshnessFailure(freshness, "evidence publication");
|
|
1560
|
+
}
|
|
1561
|
+
const effectId = boundEffectId(context);
|
|
1562
|
+
const diffBinding = this._verifyBoundDiff(payload, "evidence");
|
|
1563
|
+
if (!diffBinding.ok) {
|
|
1564
|
+
return definitiveFailure(diffBinding.error, {
|
|
1565
|
+
commitSha: payload.commitSha,
|
|
1566
|
+
actualDiffDigest: diffBinding.actualDigest || null,
|
|
1567
|
+
actualChangedFiles: diffBinding.changedFiles || [],
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
if (
|
|
1571
|
+
!payload.record ||
|
|
1572
|
+
!SHA256_RE.test(String(payload.record.recordDigest || "")) ||
|
|
1573
|
+
String(payload.record.commit?.sha || "") !== payload.commitSha
|
|
1574
|
+
) {
|
|
1575
|
+
return definitiveFailure(
|
|
1576
|
+
"evidence record is not bound to the exact head",
|
|
1577
|
+
{
|
|
1578
|
+
commitSha: payload.commitSha,
|
|
1579
|
+
},
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
const verification = verifyDeliveryEvidenceRecord(payload.record);
|
|
1583
|
+
const readiness = assessDeliveryEvidence(payload.record);
|
|
1584
|
+
const containsRawSecret = containsSecret(
|
|
1585
|
+
canonicalDeliveryJson(payload.record),
|
|
1586
|
+
);
|
|
1587
|
+
const readinessMatches =
|
|
1588
|
+
canonicalDeliveryJson(readiness) ===
|
|
1589
|
+
canonicalDeliveryJson(payload.readiness || null);
|
|
1590
|
+
if (!verification.valid || containsRawSecret || !readinessMatches) {
|
|
1591
|
+
const reason = !verification.valid
|
|
1592
|
+
? verification.reason
|
|
1593
|
+
: containsRawSecret
|
|
1594
|
+
? "record-secret-detected"
|
|
1595
|
+
: "readiness-mismatch";
|
|
1596
|
+
return definitiveFailure(
|
|
1597
|
+
`evidence record failed authoritative verification: ${reason}`,
|
|
1598
|
+
{ commitSha: payload.commitSha },
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
const suffix = payload.record.recordDigest.slice("sha256:".length, 16);
|
|
1602
|
+
const entry = this._artifactStore.publishData({
|
|
1603
|
+
data: `${JSON.stringify(payload.record, null, 2)}\n`,
|
|
1604
|
+
fileName: `delivery-evidence-v1-${suffix}.json`,
|
|
1605
|
+
title: `Delivery evidence ${suffix}`,
|
|
1606
|
+
kind: "data",
|
|
1607
|
+
mime: "application/json",
|
|
1608
|
+
immutable: true,
|
|
1609
|
+
recordDigest: payload.record.recordDigest,
|
|
1610
|
+
});
|
|
1611
|
+
const integrity = this._artifactStore.verifyIntegrity(entry);
|
|
1612
|
+
if (integrity.ok !== true) {
|
|
1613
|
+
throw new Error("published evidence failed immediate integrity readback");
|
|
1614
|
+
}
|
|
1615
|
+
const after = this._cleanFreshness(payload.commitSha);
|
|
1616
|
+
if (!after.ok) {
|
|
1617
|
+
throw new Error("HEAD changed while immutable evidence was published");
|
|
1618
|
+
}
|
|
1619
|
+
return {
|
|
1620
|
+
artifact: publicArtifactMetadata(entry),
|
|
1621
|
+
artifactIntegrity: integrity,
|
|
1622
|
+
sideEffects: [
|
|
1623
|
+
committedEffect(`${effectId}:artifact`, "immutable-artifact", {
|
|
1624
|
+
artifactId: entry.id,
|
|
1625
|
+
recordDigest: payload.record.recordDigest,
|
|
1626
|
+
}),
|
|
1627
|
+
],
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
async merge(payload, context = {}) {
|
|
1632
|
+
const freshness = this._cleanFreshness(payload.commitSha);
|
|
1633
|
+
if (!freshness.ok) return this._freshnessFailure(freshness, "merge");
|
|
1634
|
+
const effectId = boundEffectId(context);
|
|
1635
|
+
const merge = this.config.merge;
|
|
1636
|
+
const method = String(merge?.method || "squash").toLowerCase();
|
|
1637
|
+
const number = Number(payload.pr?.number);
|
|
1638
|
+
if (
|
|
1639
|
+
merge?.enabled !== true ||
|
|
1640
|
+
!MERGE_METHODS.has(method) ||
|
|
1641
|
+
context.state?.pr?.mergeAllowed !== true ||
|
|
1642
|
+
Number(context.state?.pr?.number) !== number ||
|
|
1643
|
+
String(payload.pr?.headCommitSha || "") !== payload.commitSha ||
|
|
1644
|
+
String(payload.pr?.ciCommitSha || "") !== payload.commitSha ||
|
|
1645
|
+
!Number.isInteger(number) ||
|
|
1646
|
+
number <= 0
|
|
1647
|
+
) {
|
|
1648
|
+
return definitiveFailure(
|
|
1649
|
+
"merge is not explicitly enabled, authorized, and bound to a PR",
|
|
1650
|
+
{ commitSha: payload.commitSha },
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
const diffBinding = this._verifyBoundDiff(payload, "merge");
|
|
1654
|
+
if (!diffBinding.ok) {
|
|
1655
|
+
return definitiveFailure(diffBinding.error, {
|
|
1656
|
+
commitSha: payload.commitSha,
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
const before = this._readPr(number);
|
|
1660
|
+
if (
|
|
1661
|
+
String(before.state || "").toUpperCase() !== "OPEN" ||
|
|
1662
|
+
before.isDraft === true ||
|
|
1663
|
+
String(before.headRefOid || "") !== payload.commitSha ||
|
|
1664
|
+
String(before.baseRefName || "") !== String(this.config.pullRequest?.base)
|
|
1665
|
+
) {
|
|
1666
|
+
return definitiveFailure("PR is no longer mergeable at the exact head", {
|
|
1667
|
+
commitSha: payload.commitSha,
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
let protection;
|
|
1671
|
+
try {
|
|
1672
|
+
protection = this._readBranchProtection(before.baseRefName);
|
|
1673
|
+
} catch (error) {
|
|
1674
|
+
return definitiveFailure(
|
|
1675
|
+
`branch protection could not be re-verified before merge: ${boundedText(error.message)}`,
|
|
1676
|
+
{ commitSha: payload.commitSha },
|
|
1677
|
+
);
|
|
1678
|
+
}
|
|
1679
|
+
const ci = this._ciSnapshot(before, payload.commitSha, protection);
|
|
1680
|
+
if (
|
|
1681
|
+
ci.requiredMatrixComplete !== true ||
|
|
1682
|
+
ci.requiredChecksSuccessful !== true ||
|
|
1683
|
+
ci.reviewApproved !== true ||
|
|
1684
|
+
ci.pendingApprovals !== 0 ||
|
|
1685
|
+
ci.branchProtectionSatisfied !== true
|
|
1686
|
+
) {
|
|
1687
|
+
return definitiveFailure(
|
|
1688
|
+
"PR checks, review approval, or branch protection changed before merge",
|
|
1689
|
+
{ commitSha: payload.commitSha },
|
|
1690
|
+
);
|
|
1691
|
+
}
|
|
1692
|
+
const evidence = this._verifyPublishedEvidence(
|
|
1693
|
+
payload.evidence,
|
|
1694
|
+
payload.commitSha,
|
|
1695
|
+
payload.diffDigest,
|
|
1696
|
+
);
|
|
1697
|
+
if (!evidence.ok) {
|
|
1698
|
+
return definitiveFailure(
|
|
1699
|
+
`published evidence could not be re-verified before merge: ${evidence.error}`,
|
|
1700
|
+
{ commitSha: payload.commitSha },
|
|
1701
|
+
);
|
|
1702
|
+
}
|
|
1703
|
+
const beforeMutation = this._cleanFreshness(payload.commitSha);
|
|
1704
|
+
if (!beforeMutation.ok) {
|
|
1705
|
+
return this._freshnessFailure(beforeMutation, "merge");
|
|
1706
|
+
}
|
|
1707
|
+
this._ghMutate(
|
|
1708
|
+
[
|
|
1709
|
+
"pr",
|
|
1710
|
+
"merge",
|
|
1711
|
+
String(number),
|
|
1712
|
+
`--${method}`,
|
|
1713
|
+
"--match-head-commit",
|
|
1714
|
+
payload.commitSha,
|
|
1715
|
+
],
|
|
1716
|
+
"pr-merge",
|
|
1717
|
+
);
|
|
1718
|
+
const after = this._readPr(number);
|
|
1719
|
+
const localAfter = this._cleanFreshness(payload.commitSha);
|
|
1720
|
+
const mergeCommitSha = String(
|
|
1721
|
+
after.mergeCommit?.oid || after.mergeCommit || "",
|
|
1722
|
+
);
|
|
1723
|
+
if (
|
|
1724
|
+
!localAfter.ok ||
|
|
1725
|
+
String(after.state || "").toUpperCase() !== "MERGED" ||
|
|
1726
|
+
String(after.headRefOid || "") !== payload.commitSha ||
|
|
1727
|
+
!EXACT_COMMIT_RE.test(mergeCommitSha)
|
|
1728
|
+
) {
|
|
1729
|
+
throw new Error("merge provider did not prove a merged exact-head PR");
|
|
1730
|
+
}
|
|
1731
|
+
return {
|
|
1732
|
+
merged: true,
|
|
1733
|
+
headCommitSha: payload.commitSha,
|
|
1734
|
+
mergeCommitSha,
|
|
1735
|
+
method,
|
|
1736
|
+
sideEffects: [
|
|
1737
|
+
committedEffect(`${effectId}:merge`, "github-pr-merge", {
|
|
1738
|
+
prNumber: number,
|
|
1739
|
+
headCommitSha: payload.commitSha,
|
|
1740
|
+
mergeCommitSha,
|
|
1741
|
+
}),
|
|
1742
|
+
],
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
async archive(payload, context = {}) {
|
|
1747
|
+
const freshness = this._freshness(payload.commitSha);
|
|
1748
|
+
if (!freshness.ok) return this._freshnessFailure(freshness, "archive");
|
|
1749
|
+
const effectId = boundEffectId(context);
|
|
1750
|
+
// Production archive is intentionally non-destructive. The coordinator's
|
|
1751
|
+
// final snapshot is the archive record; retaining the worktree proves that
|
|
1752
|
+
// uncommitted or unpushed user work cannot be deleted by this action.
|
|
1753
|
+
const status = this._git(["status", "--porcelain"], "archive-status");
|
|
1754
|
+
const after = this._freshness(payload.commitSha);
|
|
1755
|
+
if (!after.ok) return this._freshnessFailure(after, "archive");
|
|
1756
|
+
const uncommitted = status
|
|
1757
|
+
.split(/\r?\n/u)
|
|
1758
|
+
.filter(Boolean)
|
|
1759
|
+
.filter((line) => !line.startsWith("??")).length;
|
|
1760
|
+
const untracked = status
|
|
1761
|
+
.split(/\r?\n/u)
|
|
1762
|
+
.filter((line) => line.startsWith("??")).length;
|
|
1763
|
+
return {
|
|
1764
|
+
archived: true,
|
|
1765
|
+
preservedUncommitted: true,
|
|
1766
|
+
preservedUnpushed: true,
|
|
1767
|
+
workspaceDisposition: "retained",
|
|
1768
|
+
observedUncommittedEntries: uncommitted,
|
|
1769
|
+
observedUntrackedEntries: untracked,
|
|
1770
|
+
sideEffects: [noEffect(`${effectId}:archive`, "worktree-retained")],
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
export function createGitHubDeliveryProductionAdapter(options = {}, deps = {}) {
|
|
1776
|
+
return new GitHubDeliveryProductionAdapter(options, deps);
|
|
1777
|
+
}
|