chainlesschain 0.163.7 → 0.164.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/package.json +5 -1
- package/scripts/macos-mcp-launcher-build.mjs +317 -0
- package/scripts/macos-mcp-launcher-postinstall.sh +54 -0
- package/scripts/macos-mcp-launcher-preinstall.sh +61 -0
- package/src/assets/web-panel/assets/{AIOps-axqQfwv8.js → AIOps-D2MEn7fI.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-DV-Ro0By.js → ActionButton-lCW_4Gf0.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-Bt72WEVu.js → Analytics-Bs7i-nsS.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-BtepG55E.js → AppLayout-CK7B6S_j.js} +5 -5
- package/src/assets/web-panel/assets/{Artifacts-B7KaJ7S_.js → Artifacts-C8ILjpyo.js} +1 -1
- package/src/assets/web-panel/assets/{Audit-FNFaUg7A.js → Audit-Bb9cNaWc.js} +1 -1
- package/src/assets/web-panel/assets/{BackgroundAgents-DFb6J2wQ.js → BackgroundAgents-sC9ooXIf.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-CC7YBhyj.js → Backup-D-DrsplT.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-RkgTNVtj.js → BaseInput-uCNZpwSK.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-hHEmGZy-.js → Chat-CsDK1uE9.js} +6 -6
- package/src/assets/web-panel/assets/{ChatBubbleRenderer-CAP9YhHv.js → ChatBubbleRenderer-OqSDt50w.js} +1 -1
- package/src/assets/web-panel/assets/{Checkbox-z5wXOQP1.js → Checkbox-JhTXM1-K.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-DVaDJnxy.js → Codegen-BbzZLnyD.js} +1 -1
- package/src/assets/web-panel/assets/{Col-CJmaRDR5.js → Col-D4VW4m4j.js} +1 -1
- package/src/assets/web-panel/assets/{Community-DF29_2bI.js → Community-BXrYawxV.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-DtkQkBzn.js → Compact-DCihGy1u.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-xoHDxp7I.js → Compliance-DlyZ-lk8.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-BUm-R7ht.js → Cowork-DvGZrhbO.js} +2 -2
- package/src/assets/web-panel/assets/{Cron-Cgl14ZkB.js → Cron-DUncZi8h.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-BNcbmoqh.js → Crosschain-C_hLuUcm.js} +1 -1
- package/src/assets/web-panel/assets/{DID-Bwja0Zo9.js → DID-b6YSUqxr.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-Bk5XjEdJ.js → Dashboard-uvM15Cfb.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-Ci36Oitq.js → Dropdown-D1-Nd4qY.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer--U04ZN4q.js → EmailListRenderer-8ApvL6Zc.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-B2gNPR2s.js → FamilyGuardDashboard-LYJt8811.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-C4KghYn7.js → Federation-BbI3HGpv.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-QL7x_Q_0.js → FormItemContext-DSoEKuFu.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-F3Ryltwm.js → GenericCardRenderer-B0S9OWd5.js} +1 -1
- package/src/assets/web-panel/assets/{Git-2HassK5k.js → Git-DIs9Zhf3.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-DoBIp7oe.js → Governance-jzZXrxcl.js} +1 -1
- package/src/assets/web-panel/assets/{Inference--W01QhZ-.js → Inference-JUPjf2ZI.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-DmvwqGpT.js → KnowledgeGraph-DxnfJvlP.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-BBJ4yAel.js → Logs-iXiz8BEE.js} +2 -2
- package/src/assets/web-panel/assets/{MarkdownRenderer-BLCZMzhJ.js → MarkdownRenderer-GmB6DOr6.js} +1 -1
- package/src/assets/web-panel/assets/{Marketplace-D5lNngSI.js → Marketplace-Y3Xch312.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-CkEVOMKH.js → McpTools-D7kGGbO7.js} +4 -4
- package/src/assets/web-panel/assets/{Memory-B1Ow1xVE.js → Memory-Do_hVxzh.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-SgYbUfb6.js → MobileBridge-CtrR0Tas.js} +1 -1
- package/src/assets/web-panel/assets/MobileProjects-BL0Czcoj.js +1 -0
- package/src/assets/web-panel/assets/{Mtc-DcTXSSma.js → Mtc-qo5QCTGd.js} +2 -2
- package/src/assets/web-panel/assets/{MtcAudit-Dt3WG4oj.js → MtcAudit-cq5mnjYj.js} +2 -2
- package/src/assets/web-panel/assets/{Multisig-B9B38UZx.js → Multisig-Bq0MAQHw.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-B0S4GGvG.js → NLProgramming-DdWE6Vi2.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-DZ5U58Ng.js → Notes-BxMBFcIp.js} +3 -3
- package/src/assets/web-panel/assets/{NotificationSettings-PjL5ZW34.js → NotificationSettings-RTrAcbUe.js} +1 -1
- package/src/assets/web-panel/assets/{OrderTableRenderer-DrAX2ESt.js → OrderTableRenderer-DJbpGqzT.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-Cv1GYs-c.js → Organization-SQzIPdqC.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-Bbkp_PL3.js → Overflow-02yECh22.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-BD8QUgoZ.js → P2P-BNg4pNCM.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-BO-2PeaB.js → PdhVaultBrowser-BxPJZsD3.js} +3 -3
- package/src/assets/web-panel/assets/{Permissions-D_OPVa02.js → Permissions-CLnDnPAz.js} +4 -4
- package/src/assets/web-panel/assets/{PersonalDataHub-mh-pEmaB.js → PersonalDataHub-C5m0AjGf.js} +4 -4
- package/src/assets/web-panel/assets/{Pipeline-6JC2EDdb.js → Pipeline-VwOf5Dhv.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-CUshzUAG.js → Privacy-CC0W2hZd.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-Cs2Aalri.js → ProjectInit-Dz893Bob.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-1ak49tcj.js → ProjectSettings-CxQkvl0k.js} +2 -2
- package/src/assets/web-panel/assets/Projects-DDAjkUkT.js +1 -0
- package/src/assets/web-panel/assets/{Providers-CXXxvafH.js → Providers-DeB9IkxK.js} +1 -1
- package/src/assets/web-panel/assets/{QrScannerModal-PTcDsF58.js → QrScannerModal-OCeoaswn.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-CDwucrJw.js → QuickAsk-DenotaAj.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-CJlLVRXR.js → Recommend-Cies_jjI.js} +1 -1
- package/src/assets/web-panel/assets/{RemoteSession-DlN-ETsO.js → RemoteSession-Dxzl5n2T.js} +3 -3
- package/src/assets/web-panel/assets/{Reputation-DI23LR6m.js → Reputation-CW0mvxgZ.js} +1 -1
- package/src/assets/web-panel/assets/{Row-Cl7UQMrM.js → Row-SEd32qZU.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-CexHNfh8.js → RssFeed-BSwnJ3Lm.js} +3 -3
- package/src/assets/web-panel/assets/{Search-BneBWvRN.js → Search-xe2m8bkC.js} +1 -1
- package/src/assets/web-panel/assets/{Security-DX7Phgrp.js → Security-Mq2F9Nro.js} +4 -4
- package/src/assets/web-panel/assets/{Services-BWXO8Mte.js → Services-BKGw2V3P.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-DCyOjKPJ.js → Skeleton-DW95-Rxf.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-CXqERsTB.js → Skills-CHgChCv_.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-bhbrbz8E.js → Sla-tKh6k3R7.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-3IPU0HBZ.js → SpeechSettings-D6UL3dA5.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-DBNTQLbM.js → SyncSettings-D0Fw8fVL.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-quP0bkN-.js → Tasks-20x1GB3D.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-BouWmgho.js → Templates-DJ-lts-a.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-qv0D9s7z.js → Tenant-BFdGpbs7.js} +1 -1
- package/src/assets/web-panel/assets/{Terminal-BxvbE8BB.js → Terminal-Xpx61vHL.js} +2 -2
- package/src/assets/web-panel/assets/{TimelineRenderer-D-IeLnJ2.js → TimelineRenderer-C6UIvyZQ.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-CFMKKyVG.js → Tokens-Dellq08f.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-L8as3UuJ.js → Trigger-VKw8d0J-.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-MxmA4VZG.js → Trust-DNftdKT5.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-C0VvJYRi.js → UkeySign-DX9VAKJI.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-Bf5QZj8N.js → VideoEditing-B2h3jO8q.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-BIJ9riRz.js → Wallet-CjGkof7f.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-B8p9Mhkw.js → WebAuthn-2Zfcu55P.js} +4 -4
- package/src/assets/web-panel/assets/{WorkflowEditor-BVsuaPen.js → WorkflowEditor-B9eZ8UxZ.js} +1 -1
- package/src/assets/web-panel/assets/{chat-BfkjoKpV.js → chat-CQNfD-YO.js} +1 -1
- package/src/assets/web-panel/assets/{colors-DeAlsWK8.js → colors-D4usyFT2.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-Cs97bz5j.js → compact-item-s4TpjvjX.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-BqbG60LH.js → createContext-BRCSvYxj.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-DKJjSWRn.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-i_RY0b1X.js → hasIn-AdkUbkV-.js} +1 -1
- package/src/assets/web-panel/assets/{index-BOVkf1K3.js → index--00U6vXZ.js} +1 -1
- package/src/assets/web-panel/assets/{index-B-3tEpTc.js → index-B2V-eKfK.js} +1 -1
- package/src/assets/web-panel/assets/{index-D2B4VPyP.js → index-B4JJCfQ2.js} +1 -1
- package/src/assets/web-panel/assets/{index-BkBi5TmC.js → index-BIoG5yYf.js} +1 -1
- package/src/assets/web-panel/assets/{index-79V_zv1s.js → index-BMcimU-2.js} +1 -1
- package/src/assets/web-panel/assets/{index-U4f5izHz.js → index-BPhkFZ1I.js} +1 -1
- package/src/assets/web-panel/assets/{index-DiSkx4UB.js → index-BQ3DXBtD.js} +3 -3
- package/src/assets/web-panel/assets/{index-dIHr4B_u.js → index-Bad4VNvz.js} +1 -1
- package/src/assets/web-panel/assets/{index-CIG8fQ2_.js → index-BgiXR_-_.js} +1 -1
- package/src/assets/web-panel/assets/{index-DqBgBpgp.js → index-BtobShro.js} +1 -1
- package/src/assets/web-panel/assets/{index-ZxO_-E8a.js → index-Bw2MEnUk.js} +1 -1
- package/src/assets/web-panel/assets/{index-DUUrv141.js → index-C1mJtE6q.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bm7lLR4c.js → index-C484EJiS.js} +1 -1
- package/src/assets/web-panel/assets/{index-COdGA-8n.js → index-C5KAQFPQ.js} +1 -1
- package/src/assets/web-panel/assets/index-C61cnl3K.js +1 -0
- package/src/assets/web-panel/assets/{index-CED2PL_y.js → index-C7Y8Aluc.js} +1 -1
- package/src/assets/web-panel/assets/{index-YXgLng0K.js → index-CCsM_Ybx.js} +1 -1
- package/src/assets/web-panel/assets/{index-0-agYlWL.js → index-CJCFe2Qf.js} +1 -1
- package/src/assets/web-panel/assets/{index-B5UkyA3c.js → index-CJVEOa4i.js} +1 -1
- package/src/assets/web-panel/assets/{index-B1CbOH1i.js → index-CJdQrZth.js} +1 -1
- package/src/assets/web-panel/assets/{index-s1opAL-M.js → index-CKC1t3Ic.js} +1 -1
- package/src/assets/web-panel/assets/{index-yObj1O4n.js → index-CQhAVU5t.js} +1 -1
- package/src/assets/web-panel/assets/{index-CgsqdnRV.js → index-CYdoQsa1.js} +1 -1
- package/src/assets/web-panel/assets/{index-Dk7-v4hP.js → index-Cduu2Hhr.js} +1 -1
- package/src/assets/web-panel/assets/{index-B-JVTrpr.js → index-CkwiSLhU.js} +1 -1
- package/src/assets/web-panel/assets/{index-CeWoNHau.js → index-Cvyt-zhm.js} +1 -1
- package/src/assets/web-panel/assets/{index-CE2VtTlp.js → index-D2GjI2oe.js} +1 -1
- package/src/assets/web-panel/assets/index-D4XFHxBr.js +1 -0
- package/src/assets/web-panel/assets/{index-DmbfhJUP.js → index-DARsAyUp.js} +1 -1
- package/src/assets/web-panel/assets/{index-BESLFt4q.js → index-DVKfQ0Ux.js} +1 -1
- package/src/assets/web-panel/assets/{index-PT_5sBMA.js → index-DZT36ZYq.js} +1 -1
- package/src/assets/web-panel/assets/{index-gyFpvL7l.js → index-D_XBHopw.js} +1 -1
- package/src/assets/web-panel/assets/{index-BYUVuwKt.js → index-DdCB70SS.js} +1 -1
- package/src/assets/web-panel/assets/{index-BcUXAB2s.js → index-DzRjHxEv.js} +1 -1
- package/src/assets/web-panel/assets/{index-Dk2qouAz.js → index-MMN9V6JF.js} +1 -1
- package/src/assets/web-panel/assets/{index-D9evtvg2.js → index-RfMr79MW.js} +1 -1
- package/src/assets/web-panel/assets/{index-BuLQobzW.js → index-_IRVsV9X.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cf3WCLmy.js → index-nXyk8GZ9.js} +1 -1
- package/src/assets/web-panel/assets/{index-CG3PRyAL.js → index-s_buFMEG.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-C9h9yKL3.js → initDefaultProps-Blmpk7HL.js} +1 -1
- package/src/assets/web-panel/assets/{motion-BvmLsPdu.js → motion-DPBJMv1n.js} +1 -1
- package/src/assets/web-panel/assets/{move-CoL4ekU1.js → move-ButdzCM7.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-C4pMy6xV.js → mtc-parser-cyRC_QCe.js} +1 -1
- package/src/assets/web-panel/assets/{omit-DPuJfNxx.js → omit-1epSV7hz.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-D0KEeaK2.js → pickAttrs-DuPY8xJs.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-BpkyINxt.js → placementArrow-6XZqarhe.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-DQI_wOq8.js → responsiveObserve-B40pXBWM.js} +1 -1
- package/src/assets/web-panel/assets/{slide-DYfdDiRg.js → slide-jbZ9Wu4n.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-B5dtvb0D.js → statusUtils-BKQQcVy4.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-CF310mSj.js → styleChecker-NiKfWjUT.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-D1Qp4BLp.js → useFlexGapSupport-bQUCvPeb.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-C7_iyvJK.js → useFs-CUGji7ph.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-DZUt_fIm.js → usePersonalDataHub-4veyN2N2.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-D3dsi0lZ.js → vnode-1-nLug-Z.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-WdbOlvdQ.js → zoom-dhCeRDMo.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/command-help-index.json +6 -6
- package/src/commands/automation.js +103 -1
- package/src/commands/cowork.js +174 -19
- package/src/commands/permissions.js +240 -16
- package/src/commands/plugin.js +1293 -56
- package/src/commands/session-location.js +276 -0
- package/src/commands/session.js +2 -0
- package/src/commands/team-merge-review.js +937 -0
- package/src/commands/team.js +2 -0
- package/src/data/changelog.json +27 -1
- package/src/gateways/ws/action-protocol.js +35 -10
- package/src/gateways/ws/remote-session-protocol.js +24 -1
- package/src/gateways/ws/ws-agent-handler.js +12 -0
- package/src/gateways/ws/ws-server.js +48 -12
- package/src/harness/jsonl-session-store.js +341 -24
- package/src/harness/mcp-client.js +8 -1
- package/src/harness/remote-session-audit.js +1 -0
- package/src/harness/remote-session-crypto.js +210 -2
- package/src/harness/remote-session-registry.js +159 -6
- package/src/harness/session-list-index.js +233 -14
- package/src/lib/agent-authority.js +6 -0
- package/src/lib/agent-schedule-store.js +139 -28
- package/src/lib/agent-team/team-merge-review-store.js +765 -0
- package/src/lib/agent-team/team-merge-review-transaction.js +1223 -0
- package/src/lib/agent-team/team-merge-review.js +1847 -0
- package/src/lib/automation-center-incidents.js +314 -0
- package/src/lib/automation-center-runtime.js +448 -0
- package/src/lib/automation-center.js +182 -38
- package/src/lib/automation-engine.js +41 -0
- package/src/lib/automation-execution-authority.js +241 -12
- package/src/lib/automation-execution-incident.js +737 -0
- package/src/lib/background-agent-keeper-client.js +248 -0
- package/src/lib/background-agent-keeper-protocol.js +147 -0
- package/src/lib/background-agent-supervisor.js +520 -40
- package/src/lib/background-interaction-resolver.js +12 -0
- package/src/lib/background-turn-bootstrap-protocol.js +126 -0
- package/src/lib/cowork-cron.js +85 -14
- package/src/lib/cowork-workflow.js +336 -16
- package/src/lib/dynamic-workflow-facade.js +648 -0
- package/src/lib/execution-location-contract.js +551 -0
- package/src/lib/execution-location-runtime.js +130 -0
- package/src/lib/ide-context.js +94 -6
- package/src/lib/mcp-stdio-package-materialization.js +166 -14
- package/src/lib/packer/pkg-config-generator.js +2 -0
- package/src/lib/permission-authority.js +77 -0
- package/src/lib/permission-side-effect-center.js +495 -0
- package/src/lib/plugin-runtime/install.js +514 -13
- package/src/lib/plugin-runtime/marketplace-artifact-readback.js +900 -0
- package/src/lib/plugin-runtime/marketplace-catalog.js +1181 -0
- package/src/lib/plugin-runtime/marketplace-impact.js +408 -0
- package/src/lib/plugin-runtime/marketplace-remote-artifacts.js +1567 -0
- package/src/lib/plugin-security.js +60 -3
- package/src/lib/process-execution-broker/index.js +1095 -57
- package/src/lib/process-execution-broker/linux-bwrap-descriptor-launch.js +375 -0
- package/src/lib/process-execution-broker/linux-generic-bwrap-runtime.js +90 -16
- package/src/lib/process-execution-broker/linux-generic-bwrap.js +159 -18
- package/src/lib/process-execution-broker/macos-mcp-launcher-contract.js +278 -0
- package/src/lib/process-execution-broker/macos-mcp-launcher-protocol.json +23 -0
- package/src/lib/process-execution-broker/macos-mcp-launcher.c +1497 -0
- package/src/lib/process-execution-broker/mcp-fd-entry-bootstrap.js +37 -0
- package/src/lib/process-execution-broker/platform-sandbox.js +2330 -279
- package/src/lib/process-execution-broker/windows-sandbox-helper.dll +0 -0
- package/src/lib/process-execution-broker/windows-sandbox-helper.exe +0 -0
- package/src/lib/process-execution-broker/windows-sandbox.cs +50 -4
- package/src/lib/public-ide-capability-manifest.js +3 -0
- package/src/lib/remote-approval-bridge.js +123 -17
- package/src/lib/remote-membership-authority.js +632 -0
- package/src/lib/remote-membership-coordinator.js +2409 -0
- package/src/lib/remote-membership-host-store.js +1750 -0
- package/src/lib/scheduler-kernel/agenda-adapter.js +17 -7
- package/src/lib/scheduler-kernel/automation-adapter.js +105 -11
- package/src/lib/scheduler-kernel/automation-event-adapter.js +87 -11
- package/src/lib/scheduler-kernel/contract.js +91 -0
- package/src/lib/scheduler-kernel/cowork-cron-adapter.js +12 -1
- package/src/lib/scheduler-kernel/loop-adapter.js +7 -1
- package/src/lib/scheduler-kernel/routine-adapter.js +7 -1
- package/src/lib/scheduler-kernel/runtime-control-capabilities.js +31 -0
- package/src/lib/scheduler-kernel/runtime.js +176 -3
- package/src/lib/scheduler-kernel/store.js +1253 -45
- package/src/lib/scoped-permission-store.js +369 -0
- package/src/lib/session-anti-rollback-anchor.js +262 -5
- package/src/lib/sub-agent-context.js +4 -0
- package/src/lib/workflow-definition-contract.js +149 -0
- package/src/repl/agent-repl.js +41 -7
- package/src/repl/chat-repl.js +2 -0
- package/src/repl/clipboard-image.js +735 -16
- package/src/repl/ide-status.js +1 -1
- package/src/repl/prompt-interactions.js +59 -6
- package/src/runtime/agent-core.js +93 -3
- package/src/runtime/headless-runner.js +30 -25
- package/src/runtime/headless-stream.js +32 -25
- package/src/runtime/mcp-config.js +81 -10
- package/src/workers/background-agent-keeper.js +498 -0
- package/src/workers/background-agent-worker.js +358 -13
- package/src/workers/background-turn-bootstrap.js +143 -0
- package/src/assets/web-panel/assets/MobileProjects-BWdOvQAj.js +0 -1
- package/src/assets/web-panel/assets/Projects-BV4oApsm.js +0 -1
- package/src/assets/web-panel/assets/devWarning-DPty0mGN.js +0 -1
- package/src/assets/web-panel/assets/index-Bt7g0Vq_.js +0 -1
- package/src/assets/web-panel/assets/index-ChVT0p9u.js +0 -1
|
@@ -0,0 +1,1847 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical, content-minimized contract for reviewing a batch of Agent Team
|
|
3
|
+
* worktree branches before publishing them to the base branch.
|
|
4
|
+
*
|
|
5
|
+
* This module is intentionally pure. Git discovery/application and durable I/O
|
|
6
|
+
* live in sibling adapters. Persisted records contain identities, ranges and
|
|
7
|
+
* digests only; raw patches and file contents never enter the contract.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { redactSecrets } from "../secret-scan.js";
|
|
12
|
+
|
|
13
|
+
export const TEAM_MERGE_REVIEW_SCHEMA = "chainlesschain.team-merge-review/v1";
|
|
14
|
+
export const TEAM_MERGE_REVIEW_SCHEMA_VERSION = 1;
|
|
15
|
+
|
|
16
|
+
export const TEAM_MERGE_REVIEW_STATE = Object.freeze({
|
|
17
|
+
PLANNED: "planned",
|
|
18
|
+
PREPARED: "prepared",
|
|
19
|
+
PUBLISHING: "publishing",
|
|
20
|
+
PUBLISHED: "published",
|
|
21
|
+
CONFLICTED: "conflicted",
|
|
22
|
+
ROLLBACK_REQUIRED: "rollback_required",
|
|
23
|
+
ROLLED_BACK: "rolled_back",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const TEAM_MERGE_REVIEW_ERROR = Object.freeze({
|
|
27
|
+
INVALID: "TEAM_MERGE_REVIEW_INVALID",
|
|
28
|
+
STALE: "TEAM_MERGE_REVIEW_STALE",
|
|
29
|
+
TRANSITION: "TEAM_MERGE_REVIEW_TRANSITION_INVALID",
|
|
30
|
+
BINDING: "TEAM_MERGE_REVIEW_BINDING_MISMATCH",
|
|
31
|
+
LIMIT: "TEAM_MERGE_REVIEW_LIMIT",
|
|
32
|
+
SECRET: "TEAM_MERGE_REVIEW_SECRET_REJECTED",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const TEAM_MERGE_REVIEW_LIMITS = Object.freeze({
|
|
36
|
+
candidates: 256,
|
|
37
|
+
files: 4096,
|
|
38
|
+
hunks: 16384,
|
|
39
|
+
selectionIds: 100,
|
|
40
|
+
patchBytes: 512 * 1024,
|
|
41
|
+
fullPatchBytes: 8 * 1024 * 1024,
|
|
42
|
+
totalPatchBytes: 32 * 1024 * 1024,
|
|
43
|
+
keyBytes: 512,
|
|
44
|
+
branchBytes: 4096,
|
|
45
|
+
pathBytes: 4096,
|
|
46
|
+
actorBytes: 256,
|
|
47
|
+
hostBytes: 256,
|
|
48
|
+
reasonBytes: 2048,
|
|
49
|
+
conflicts: 256,
|
|
50
|
+
conflictTypeBytes: 64,
|
|
51
|
+
conflictTextBytes: 2048,
|
|
52
|
+
totalConflictTextBytes: 256 * 1024,
|
|
53
|
+
conflictHunkRefs: 4096,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const REVIEW_KEYS = new Set([
|
|
57
|
+
"schema",
|
|
58
|
+
"schemaVersion",
|
|
59
|
+
"reviewId",
|
|
60
|
+
"revision",
|
|
61
|
+
"state",
|
|
62
|
+
"base",
|
|
63
|
+
"candidates",
|
|
64
|
+
"files",
|
|
65
|
+
"decision",
|
|
66
|
+
"conflicts",
|
|
67
|
+
"planDigest",
|
|
68
|
+
"evidenceDigest",
|
|
69
|
+
"settlement",
|
|
70
|
+
"createdAt",
|
|
71
|
+
"updatedAt",
|
|
72
|
+
]);
|
|
73
|
+
const BASE_KEYS = new Set(["branch", "commitOid"]);
|
|
74
|
+
const CANDIDATE_KEYS = new Set(["key", "branch", "commitOid"]);
|
|
75
|
+
const FILE_KEYS = new Set([
|
|
76
|
+
"id",
|
|
77
|
+
"candidateKey",
|
|
78
|
+
"path",
|
|
79
|
+
"oldPath",
|
|
80
|
+
"status",
|
|
81
|
+
"oldBlobOid",
|
|
82
|
+
"newBlobOid",
|
|
83
|
+
"binary",
|
|
84
|
+
"patchDigest",
|
|
85
|
+
"hunks",
|
|
86
|
+
]);
|
|
87
|
+
const HUNK_KEYS = new Set([
|
|
88
|
+
"id",
|
|
89
|
+
"oldStart",
|
|
90
|
+
"oldLines",
|
|
91
|
+
"newStart",
|
|
92
|
+
"newLines",
|
|
93
|
+
"digest",
|
|
94
|
+
]);
|
|
95
|
+
const DECISION_KEYS = new Set([
|
|
96
|
+
"actor",
|
|
97
|
+
"host",
|
|
98
|
+
"reason",
|
|
99
|
+
"decidedAt",
|
|
100
|
+
"selectedFileIds",
|
|
101
|
+
"selectedHunkIds",
|
|
102
|
+
"digest",
|
|
103
|
+
]);
|
|
104
|
+
const CONFLICT_KEYS = new Set([
|
|
105
|
+
"candidateKey",
|
|
106
|
+
"path",
|
|
107
|
+
"type",
|
|
108
|
+
"explanation",
|
|
109
|
+
"suggestion",
|
|
110
|
+
"hunkIds",
|
|
111
|
+
]);
|
|
112
|
+
const SETTLEMENT_KEYS = new Set([
|
|
113
|
+
"preparedOid",
|
|
114
|
+
"publishedOid",
|
|
115
|
+
"rollbackOid",
|
|
116
|
+
"conflictDigest",
|
|
117
|
+
"transitionEvidenceDigest",
|
|
118
|
+
]);
|
|
119
|
+
const GIT_OID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
120
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
121
|
+
const REVIEW_ID = /^tmr_[a-f0-9]{32}$/;
|
|
122
|
+
const FILE_ID = /^tmrf_[a-f0-9]{32}$/;
|
|
123
|
+
const HUNK_ID = /^tmrh_[a-f0-9]{32}$/;
|
|
124
|
+
const ID_HEX_LENGTH = 32;
|
|
125
|
+
const CONTROL_CHARACTER = /\p{Cc}/u;
|
|
126
|
+
const FILE_STATUSES = new Set([
|
|
127
|
+
"added",
|
|
128
|
+
"modified",
|
|
129
|
+
"deleted",
|
|
130
|
+
"renamed",
|
|
131
|
+
"copied",
|
|
132
|
+
"type_changed",
|
|
133
|
+
"unmerged",
|
|
134
|
+
]);
|
|
135
|
+
const STATES = new Set(Object.values(TEAM_MERGE_REVIEW_STATE));
|
|
136
|
+
const TRANSITIONS = new Map([
|
|
137
|
+
["planned", new Set(["prepared", "conflicted"])],
|
|
138
|
+
["prepared", new Set(["publishing", "conflicted", "rollback_required"])],
|
|
139
|
+
["publishing", new Set(["published", "rollback_required"])],
|
|
140
|
+
["published", new Set(["rollback_required"])],
|
|
141
|
+
["conflicted", new Set(["rolled_back"])],
|
|
142
|
+
["rollback_required", new Set(["rolled_back"])],
|
|
143
|
+
["rolled_back", new Set()],
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
export class TeamMergeReviewError extends Error {
|
|
147
|
+
constructor(code, message, details = {}, cause = null) {
|
|
148
|
+
super(message, cause ? { cause } : undefined);
|
|
149
|
+
this.name = "TeamMergeReviewError";
|
|
150
|
+
this.code = code;
|
|
151
|
+
for (const [key, value] of Object.entries(details)) {
|
|
152
|
+
if (value !== undefined && value !== null) this[key] = value;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function fail(code, message, details = {}, cause = null) {
|
|
158
|
+
throw new TeamMergeReviewError(code, message, details, cause);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function assertPlainObject(value, label) {
|
|
162
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
163
|
+
fail(TEAM_MERGE_REVIEW_ERROR.INVALID, `${label} must be an object`);
|
|
164
|
+
}
|
|
165
|
+
const prototype = Object.getPrototypeOf(value);
|
|
166
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
167
|
+
fail(TEAM_MERGE_REVIEW_ERROR.INVALID, `${label} must be a plain object`);
|
|
168
|
+
}
|
|
169
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
170
|
+
if (
|
|
171
|
+
Reflect.ownKeys(value).some((key) => typeof key !== "string") ||
|
|
172
|
+
Object.values(descriptors).some(
|
|
173
|
+
(descriptor) =>
|
|
174
|
+
descriptor.get || descriptor.set || descriptor.enumerable !== true,
|
|
175
|
+
)
|
|
176
|
+
) {
|
|
177
|
+
fail(
|
|
178
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
179
|
+
`${label} must not contain symbols or accessors`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function assertExactKeys(value, keys, label) {
|
|
186
|
+
assertPlainObject(value, label);
|
|
187
|
+
const actual = Reflect.ownKeys(value);
|
|
188
|
+
if (actual.length !== keys.size || actual.some((key) => !keys.has(key))) {
|
|
189
|
+
fail(
|
|
190
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
191
|
+
`${label} has unexpected or missing fields`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function assertDenseArray(value, label) {
|
|
197
|
+
if (!Array.isArray(value)) {
|
|
198
|
+
fail(TEAM_MERGE_REVIEW_ERROR.INVALID, `${label} must be an array`);
|
|
199
|
+
}
|
|
200
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
201
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
202
|
+
const descriptor = descriptors[String(index)];
|
|
203
|
+
if (
|
|
204
|
+
!descriptor ||
|
|
205
|
+
descriptor.get ||
|
|
206
|
+
descriptor.set ||
|
|
207
|
+
descriptor.enumerable !== true
|
|
208
|
+
) {
|
|
209
|
+
fail(
|
|
210
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
211
|
+
`${label} must be dense and accessor-free`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const key of Reflect.ownKeys(descriptors)) {
|
|
216
|
+
if (key === "length") continue;
|
|
217
|
+
const index = typeof key === "string" ? Number(key) : Number.NaN;
|
|
218
|
+
if (
|
|
219
|
+
typeof key !== "string" ||
|
|
220
|
+
!Number.isSafeInteger(index) ||
|
|
221
|
+
index < 0 ||
|
|
222
|
+
index >= value.length ||
|
|
223
|
+
String(index) !== key
|
|
224
|
+
) {
|
|
225
|
+
fail(
|
|
226
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
227
|
+
`${label} contains a non-index property`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return value;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Stable JSON encoding used by all plan, decision, evidence and event hashes. */
|
|
235
|
+
export function canonicalMergeReviewJson(value, seen = new Set()) {
|
|
236
|
+
if (value === null) return "null";
|
|
237
|
+
if (typeof value === "string" || typeof value === "boolean") {
|
|
238
|
+
return JSON.stringify(value);
|
|
239
|
+
}
|
|
240
|
+
if (typeof value === "number") {
|
|
241
|
+
if (!Number.isFinite(value)) {
|
|
242
|
+
fail(
|
|
243
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
244
|
+
"merge-review evidence contains a non-finite number",
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return JSON.stringify(Object.is(value, -0) ? 0 : value);
|
|
248
|
+
}
|
|
249
|
+
if (!value || typeof value !== "object") {
|
|
250
|
+
fail(
|
|
251
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
252
|
+
"merge-review evidence must be JSON-compatible",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (seen.has(value)) {
|
|
256
|
+
fail(
|
|
257
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
258
|
+
"merge-review evidence must not contain cycles",
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
seen.add(value);
|
|
262
|
+
let encoded;
|
|
263
|
+
if (Array.isArray(value)) {
|
|
264
|
+
assertDenseArray(value, "merge-review evidence array");
|
|
265
|
+
encoded = `[${value
|
|
266
|
+
.map((item) => {
|
|
267
|
+
if (item === undefined || typeof item === "function") {
|
|
268
|
+
fail(
|
|
269
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
270
|
+
"merge-review arrays contain an unsupported value",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return canonicalMergeReviewJson(item, seen);
|
|
274
|
+
})
|
|
275
|
+
.join(",")}]`;
|
|
276
|
+
} else {
|
|
277
|
+
assertPlainObject(value, "merge-review evidence object");
|
|
278
|
+
encoded = `{${Object.keys(value)
|
|
279
|
+
.sort()
|
|
280
|
+
.map((key) => {
|
|
281
|
+
const item = value[key];
|
|
282
|
+
if (item === undefined || typeof item === "function") {
|
|
283
|
+
fail(
|
|
284
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
285
|
+
"merge-review objects contain an unsupported value",
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
return `${JSON.stringify(key)}:${canonicalMergeReviewJson(item, seen)}`;
|
|
289
|
+
})
|
|
290
|
+
.join(",")}}`;
|
|
291
|
+
}
|
|
292
|
+
seen.delete(value);
|
|
293
|
+
return encoded;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function digestMergeReview(domain, value) {
|
|
297
|
+
return `sha256:${createHash("sha256")
|
|
298
|
+
.update(domain, "utf8")
|
|
299
|
+
.update("\0", "utf8")
|
|
300
|
+
.update(canonicalMergeReviewJson(value), "utf8")
|
|
301
|
+
.digest("hex")}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function normalizeDigest(
|
|
305
|
+
value,
|
|
306
|
+
label,
|
|
307
|
+
{ nullable = false, strict = false } = {},
|
|
308
|
+
) {
|
|
309
|
+
if (nullable && value === null) return null;
|
|
310
|
+
if (!strict && nullable && value == null) return null;
|
|
311
|
+
if (typeof value !== "string" || !DIGEST.test(value)) {
|
|
312
|
+
fail(TEAM_MERGE_REVIEW_ERROR.INVALID, `${label} must be a sha256 digest`);
|
|
313
|
+
}
|
|
314
|
+
return value;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function normalizeOid(value, label, { nullable = false, strict = false } = {}) {
|
|
318
|
+
if (nullable && value === null) return null;
|
|
319
|
+
if (strict && typeof value !== "string") {
|
|
320
|
+
fail(TEAM_MERGE_REVIEW_ERROR.INVALID, `${label} must be a Git object id`);
|
|
321
|
+
}
|
|
322
|
+
if (!strict && nullable && value == null) return null;
|
|
323
|
+
const oid = String(value || "").toLowerCase();
|
|
324
|
+
if (!GIT_OID.test(oid) || (strict && oid !== value)) {
|
|
325
|
+
fail(TEAM_MERGE_REVIEW_ERROR.INVALID, `${label} must be a Git object id`);
|
|
326
|
+
}
|
|
327
|
+
return oid;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function byteLength(value) {
|
|
331
|
+
return Buffer.byteLength(value, "utf8");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function boundedIdentity(value, label, maxBytes) {
|
|
335
|
+
if (
|
|
336
|
+
typeof value !== "string" ||
|
|
337
|
+
value.length === 0 ||
|
|
338
|
+
value !== value.trim() ||
|
|
339
|
+
CONTROL_CHARACTER.test(value) ||
|
|
340
|
+
byteLength(value) > maxBytes
|
|
341
|
+
) {
|
|
342
|
+
fail(
|
|
343
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
344
|
+
`${label} must be a stable 1..${maxBytes} byte string`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
if (redactSecrets(value) !== value) {
|
|
348
|
+
fail(
|
|
349
|
+
TEAM_MERGE_REVIEW_ERROR.SECRET,
|
|
350
|
+
`${label} resembles secret material and cannot enter merge-review evidence`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
return value;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function boundedDecisionText(value, label, maxBytes, { strict = false } = {}) {
|
|
357
|
+
if (typeof value !== "string") {
|
|
358
|
+
fail(TEAM_MERGE_REVIEW_ERROR.INVALID, `${label} must be a string`);
|
|
359
|
+
}
|
|
360
|
+
const secretRedacted = redactSecrets(value);
|
|
361
|
+
if (strict && secretRedacted !== value) {
|
|
362
|
+
fail(
|
|
363
|
+
TEAM_MERGE_REVIEW_ERROR.SECRET,
|
|
364
|
+
`${label} contains secret material in persisted evidence`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
const redacted = secretRedacted.replace(/\p{Cc}/gu, " ").trim();
|
|
368
|
+
if (strict && redacted !== value) {
|
|
369
|
+
fail(
|
|
370
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
371
|
+
`${label} is not in canonical persisted form`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if (!redacted || byteLength(redacted) > maxBytes) {
|
|
375
|
+
fail(
|
|
376
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
377
|
+
`${label} must be a non-empty string of at most ${maxBytes} bytes`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
return redacted;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function normalizeBranch(value, label) {
|
|
384
|
+
const branch = boundedIdentity(
|
|
385
|
+
value,
|
|
386
|
+
label,
|
|
387
|
+
TEAM_MERGE_REVIEW_LIMITS.branchBytes,
|
|
388
|
+
);
|
|
389
|
+
if (
|
|
390
|
+
branch === "@" ||
|
|
391
|
+
branch.startsWith("-") ||
|
|
392
|
+
branch.startsWith("/") ||
|
|
393
|
+
branch.endsWith("/") ||
|
|
394
|
+
branch.endsWith(".") ||
|
|
395
|
+
branch.includes("..") ||
|
|
396
|
+
branch.includes("@{") ||
|
|
397
|
+
branch.includes("//") ||
|
|
398
|
+
/[\s~^:?*[\]\\]/u.test(branch) ||
|
|
399
|
+
branch
|
|
400
|
+
.split("/")
|
|
401
|
+
.some(
|
|
402
|
+
(part) =>
|
|
403
|
+
!part ||
|
|
404
|
+
part.startsWith(".") ||
|
|
405
|
+
part.startsWith("-") ||
|
|
406
|
+
part.endsWith(".") ||
|
|
407
|
+
part.endsWith(".lock"),
|
|
408
|
+
)
|
|
409
|
+
) {
|
|
410
|
+
fail(TEAM_MERGE_REVIEW_ERROR.INVALID, `${label} is not a safe Git ref`);
|
|
411
|
+
}
|
|
412
|
+
return branch;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function normalizePath(value, label = "file.path", { strict = false } = {}) {
|
|
416
|
+
const filePath = boundedIdentity(
|
|
417
|
+
value,
|
|
418
|
+
label,
|
|
419
|
+
TEAM_MERGE_REVIEW_LIMITS.pathBytes,
|
|
420
|
+
).replace(/\\/g, "/");
|
|
421
|
+
if (
|
|
422
|
+
(strict && filePath !== value) ||
|
|
423
|
+
filePath.startsWith("/") ||
|
|
424
|
+
/^[a-z]:\//iu.test(filePath) ||
|
|
425
|
+
filePath.endsWith("/") ||
|
|
426
|
+
filePath.split("/").some((part) => !part || part === "." || part === "..")
|
|
427
|
+
) {
|
|
428
|
+
fail(
|
|
429
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
430
|
+
`${label} must be a repository-relative path`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
return filePath;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function timestamp(value, label, { strict = false } = {}) {
|
|
437
|
+
if (strict && typeof value !== "string") {
|
|
438
|
+
fail(
|
|
439
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
440
|
+
`${label} must be a timestamp string`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
444
|
+
if (!Number.isFinite(date.getTime())) {
|
|
445
|
+
fail(TEAM_MERGE_REVIEW_ERROR.INVALID, `${label} must be a valid timestamp`);
|
|
446
|
+
}
|
|
447
|
+
const normalized = date.toISOString();
|
|
448
|
+
if (strict && normalized !== value) {
|
|
449
|
+
fail(
|
|
450
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
451
|
+
`${label} must be a canonical ISO timestamp`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
return normalized;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function nonNegativeInteger(value, label, { strict = false } = {}) {
|
|
458
|
+
if (strict && typeof value !== "number") {
|
|
459
|
+
fail(
|
|
460
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
461
|
+
`${label} must be a non-negative integer`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
const number = Number(value);
|
|
465
|
+
if (!Number.isSafeInteger(number) || number < 0) {
|
|
466
|
+
fail(
|
|
467
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
468
|
+
`${label} must be a non-negative integer`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
return number;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function normalizeBase(value, { strict = false } = {}) {
|
|
475
|
+
if (strict) assertExactKeys(value, BASE_KEYS, "merge-review base");
|
|
476
|
+
else assertPlainObject(value, "merge-review base");
|
|
477
|
+
return {
|
|
478
|
+
branch: normalizeBranch(value.branch, "base.branch"),
|
|
479
|
+
commitOid: normalizeOid(value.commitOid, "base.commitOid", { strict }),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function normalizeCandidates(values, { strict = false } = {}) {
|
|
484
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
485
|
+
fail(
|
|
486
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
487
|
+
"merge-review requires at least one candidate",
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
assertDenseArray(values, "merge-review candidates");
|
|
491
|
+
if (values.length > TEAM_MERGE_REVIEW_LIMITS.candidates) {
|
|
492
|
+
fail(
|
|
493
|
+
TEAM_MERGE_REVIEW_ERROR.LIMIT,
|
|
494
|
+
`merge-review cannot exceed ${TEAM_MERGE_REVIEW_LIMITS.candidates} candidates`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
const keys = new Set();
|
|
498
|
+
const branches = new Set();
|
|
499
|
+
return values.map((value, index) => {
|
|
500
|
+
if (strict) {
|
|
501
|
+
assertExactKeys(value, CANDIDATE_KEYS, `candidate[${index}]`);
|
|
502
|
+
} else {
|
|
503
|
+
assertPlainObject(value, `candidate[${index}]`);
|
|
504
|
+
}
|
|
505
|
+
const candidate = {
|
|
506
|
+
key: boundedIdentity(
|
|
507
|
+
value.key,
|
|
508
|
+
`candidate[${index}].key`,
|
|
509
|
+
TEAM_MERGE_REVIEW_LIMITS.keyBytes,
|
|
510
|
+
),
|
|
511
|
+
branch: normalizeBranch(value.branch, `candidate[${index}].branch`),
|
|
512
|
+
commitOid: normalizeOid(
|
|
513
|
+
value.commitOid,
|
|
514
|
+
`candidate[${index}].commitOid`,
|
|
515
|
+
{
|
|
516
|
+
strict,
|
|
517
|
+
},
|
|
518
|
+
),
|
|
519
|
+
};
|
|
520
|
+
if (keys.has(candidate.key) || branches.has(candidate.branch)) {
|
|
521
|
+
fail(
|
|
522
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
523
|
+
"merge-review candidate keys and branches must be unique",
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
keys.add(candidate.key);
|
|
527
|
+
branches.add(candidate.branch);
|
|
528
|
+
return candidate;
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export function computeMergeReviewHunkDigest(patch) {
|
|
533
|
+
if (typeof patch !== "string" || patch.length === 0) {
|
|
534
|
+
fail(
|
|
535
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
536
|
+
"full hunk text is required to compute its digest",
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
if (byteLength(patch) > TEAM_MERGE_REVIEW_LIMITS.patchBytes) {
|
|
540
|
+
fail(
|
|
541
|
+
TEAM_MERGE_REVIEW_ERROR.LIMIT,
|
|
542
|
+
`one hunk cannot exceed ${TEAM_MERGE_REVIEW_LIMITS.patchBytes} bytes`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
return digestMergeReview("cc-team-merge-review-full-hunk-v1", patch);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export function computeMergeReviewPatchDigest(patch) {
|
|
549
|
+
if (typeof patch !== "string" || patch.length === 0) {
|
|
550
|
+
fail(
|
|
551
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
552
|
+
"full file patch is required to compute its digest",
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
if (byteLength(patch) > TEAM_MERGE_REVIEW_LIMITS.fullPatchBytes) {
|
|
556
|
+
fail(
|
|
557
|
+
TEAM_MERGE_REVIEW_ERROR.LIMIT,
|
|
558
|
+
`one file patch cannot exceed ${TEAM_MERGE_REVIEW_LIMITS.fullPatchBytes} bytes`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
return `sha256:${createHash("sha256").update(patch, "utf8").digest("hex")}`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function hunkBinding(base, candidate, file, hunk) {
|
|
565
|
+
return {
|
|
566
|
+
baseOid: base.commitOid,
|
|
567
|
+
candidateKey: candidate.key,
|
|
568
|
+
candidateOid: candidate.commitOid,
|
|
569
|
+
path: file.path,
|
|
570
|
+
oldPath: file.oldPath,
|
|
571
|
+
oldBlobOid: file.oldBlobOid,
|
|
572
|
+
newBlobOid: file.newBlobOid,
|
|
573
|
+
oldStart: hunk.oldStart,
|
|
574
|
+
oldLines: hunk.oldLines,
|
|
575
|
+
newStart: hunk.newStart,
|
|
576
|
+
newLines: hunk.newLines,
|
|
577
|
+
digest: hunk.digest,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export function computeMergeReviewHunkId(base, candidate, file, hunk) {
|
|
582
|
+
const digest = digestMergeReview(
|
|
583
|
+
"cc-team-merge-review-hunk-id-v1",
|
|
584
|
+
hunkBinding(base, candidate, file, hunk),
|
|
585
|
+
);
|
|
586
|
+
return `tmrh_${digest.slice("sha256:".length, "sha256:".length + ID_HEX_LENGTH)}`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function assertChangedHunk(hunk, label) {
|
|
590
|
+
if (hunk.oldLines === 0 && hunk.newLines === 0) {
|
|
591
|
+
fail(
|
|
592
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
593
|
+
`${label} must describe at least one changed line`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function normalizeHunkInput(value, context, index) {
|
|
599
|
+
assertPlainObject(value, `hunk[${index}]`);
|
|
600
|
+
const hunk = {
|
|
601
|
+
oldStart: nonNegativeInteger(value.oldStart, `hunk[${index}].oldStart`),
|
|
602
|
+
oldLines: nonNegativeInteger(value.oldLines, `hunk[${index}].oldLines`),
|
|
603
|
+
newStart: nonNegativeInteger(value.newStart, `hunk[${index}].newStart`),
|
|
604
|
+
newLines: nonNegativeInteger(value.newLines, `hunk[${index}].newLines`),
|
|
605
|
+
digest:
|
|
606
|
+
typeof value.patch === "string"
|
|
607
|
+
? computeMergeReviewHunkDigest(value.patch)
|
|
608
|
+
: normalizeDigest(value.digest, `hunk[${index}].digest`),
|
|
609
|
+
};
|
|
610
|
+
assertChangedHunk(hunk, `hunk[${index}]`);
|
|
611
|
+
hunk.id = computeMergeReviewHunkId(
|
|
612
|
+
context.base,
|
|
613
|
+
context.candidate,
|
|
614
|
+
context.file,
|
|
615
|
+
hunk,
|
|
616
|
+
);
|
|
617
|
+
return hunk;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function validateHunk(value, context, index) {
|
|
621
|
+
assertExactKeys(value, HUNK_KEYS, `hunk[${index}]`);
|
|
622
|
+
const hunk = {
|
|
623
|
+
id: value.id,
|
|
624
|
+
oldStart: nonNegativeInteger(value.oldStart, `hunk[${index}].oldStart`, {
|
|
625
|
+
strict: true,
|
|
626
|
+
}),
|
|
627
|
+
oldLines: nonNegativeInteger(value.oldLines, `hunk[${index}].oldLines`, {
|
|
628
|
+
strict: true,
|
|
629
|
+
}),
|
|
630
|
+
newStart: nonNegativeInteger(value.newStart, `hunk[${index}].newStart`, {
|
|
631
|
+
strict: true,
|
|
632
|
+
}),
|
|
633
|
+
newLines: nonNegativeInteger(value.newLines, `hunk[${index}].newLines`, {
|
|
634
|
+
strict: true,
|
|
635
|
+
}),
|
|
636
|
+
digest: normalizeDigest(value.digest, `hunk[${index}].digest`),
|
|
637
|
+
};
|
|
638
|
+
if (typeof hunk.id !== "string" || !HUNK_ID.test(hunk.id)) {
|
|
639
|
+
fail(TEAM_MERGE_REVIEW_ERROR.INVALID, `hunk[${index}].id is invalid`);
|
|
640
|
+
}
|
|
641
|
+
assertChangedHunk(hunk, `hunk[${index}]`);
|
|
642
|
+
const expected = computeMergeReviewHunkId(
|
|
643
|
+
context.base,
|
|
644
|
+
context.candidate,
|
|
645
|
+
context.file,
|
|
646
|
+
hunk,
|
|
647
|
+
);
|
|
648
|
+
if (hunk.id !== expected) {
|
|
649
|
+
fail(
|
|
650
|
+
TEAM_MERGE_REVIEW_ERROR.BINDING,
|
|
651
|
+
`hunk[${index}] no longer binds its exact blobs, ranges and digest`,
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
return hunk;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function compareHunks(left, right) {
|
|
658
|
+
return (
|
|
659
|
+
left.oldStart - right.oldStart ||
|
|
660
|
+
left.newStart - right.newStart ||
|
|
661
|
+
left.id.localeCompare(right.id)
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function fileBinding(base, candidate, file) {
|
|
666
|
+
return {
|
|
667
|
+
baseOid: base.commitOid,
|
|
668
|
+
candidateKey: candidate.key,
|
|
669
|
+
candidateBranch: candidate.branch,
|
|
670
|
+
candidateOid: candidate.commitOid,
|
|
671
|
+
path: file.path,
|
|
672
|
+
oldPath: file.oldPath,
|
|
673
|
+
status: file.status,
|
|
674
|
+
oldBlobOid: file.oldBlobOid,
|
|
675
|
+
newBlobOid: file.newBlobOid,
|
|
676
|
+
binary: file.binary,
|
|
677
|
+
patchDigest: file.patchDigest,
|
|
678
|
+
hunks: file.hunks.map((hunk) => ({ id: hunk.id, digest: hunk.digest })),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function computeMergeReviewFileId(base, candidate, file) {
|
|
683
|
+
const digest = digestMergeReview(
|
|
684
|
+
"cc-team-merge-review-file-id-v1",
|
|
685
|
+
fileBinding(base, candidate, file),
|
|
686
|
+
);
|
|
687
|
+
return `tmrf_${digest.slice("sha256:".length, "sha256:".length + ID_HEX_LENGTH)}`;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function validateBlobState(file, label) {
|
|
691
|
+
if (file.status === "added" && (file.oldBlobOid || !file.newBlobOid)) {
|
|
692
|
+
fail(
|
|
693
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
694
|
+
`${label} added files require only newBlobOid`,
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
if (file.status === "deleted" && (!file.oldBlobOid || file.newBlobOid)) {
|
|
698
|
+
fail(
|
|
699
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
700
|
+
`${label} deleted files require only oldBlobOid`,
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
if (
|
|
704
|
+
!["added", "deleted"].includes(file.status) &&
|
|
705
|
+
(!file.oldBlobOid || !file.newBlobOid)
|
|
706
|
+
) {
|
|
707
|
+
fail(
|
|
708
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
709
|
+
`${label} requires both oldBlobOid and newBlobOid`,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
if (file.binary && file.hunks.length > 0) {
|
|
713
|
+
fail(
|
|
714
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
715
|
+
`${label} binary files support file-level selection only`,
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
if (file.status === "added" && file.oldPath !== null) {
|
|
719
|
+
fail(
|
|
720
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
721
|
+
`${label} added files require null oldPath`,
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
if (
|
|
725
|
+
["renamed", "copied"].includes(file.status) &&
|
|
726
|
+
(!file.oldPath || file.oldPath === file.path)
|
|
727
|
+
) {
|
|
728
|
+
fail(
|
|
729
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
730
|
+
`${label} ${file.status} files require a distinct oldPath`,
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
if (
|
|
734
|
+
!["added", "renamed", "copied"].includes(file.status) &&
|
|
735
|
+
file.oldPath !== file.path
|
|
736
|
+
) {
|
|
737
|
+
fail(
|
|
738
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
739
|
+
`${label} ${file.status} files require oldPath to equal path`,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function normalizeFileInput(value, base, candidatesByKey, index) {
|
|
745
|
+
assertPlainObject(value, `file[${index}]`);
|
|
746
|
+
const candidateKey = boundedIdentity(
|
|
747
|
+
value.candidateKey,
|
|
748
|
+
`file[${index}].candidateKey`,
|
|
749
|
+
TEAM_MERGE_REVIEW_LIMITS.keyBytes,
|
|
750
|
+
);
|
|
751
|
+
const candidate = candidatesByKey.get(candidateKey);
|
|
752
|
+
if (!candidate) {
|
|
753
|
+
fail(
|
|
754
|
+
TEAM_MERGE_REVIEW_ERROR.BINDING,
|
|
755
|
+
`file[${index}] refers to an unknown candidate`,
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
const status = String(value.status || "");
|
|
759
|
+
if (!FILE_STATUSES.has(status)) {
|
|
760
|
+
fail(TEAM_MERGE_REVIEW_ERROR.INVALID, `file[${index}].status is invalid`);
|
|
761
|
+
}
|
|
762
|
+
const file = {
|
|
763
|
+
candidateKey,
|
|
764
|
+
path: normalizePath(value.path, `file[${index}].path`),
|
|
765
|
+
oldPath:
|
|
766
|
+
value.oldPath == null
|
|
767
|
+
? null
|
|
768
|
+
: normalizePath(value.oldPath, `file[${index}].oldPath`),
|
|
769
|
+
status,
|
|
770
|
+
oldBlobOid: normalizeOid(value.oldBlobOid, `file[${index}].oldBlobOid`, {
|
|
771
|
+
nullable: true,
|
|
772
|
+
}),
|
|
773
|
+
newBlobOid: normalizeOid(value.newBlobOid, `file[${index}].newBlobOid`, {
|
|
774
|
+
nullable: true,
|
|
775
|
+
}),
|
|
776
|
+
binary: value.binary === true,
|
|
777
|
+
patchDigest: normalizeDigest(
|
|
778
|
+
value.patchDigest,
|
|
779
|
+
`file[${index}].patchDigest`,
|
|
780
|
+
),
|
|
781
|
+
hunks: [],
|
|
782
|
+
};
|
|
783
|
+
if (!Array.isArray(value.hunks)) {
|
|
784
|
+
fail(
|
|
785
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
786
|
+
`file[${index}].hunks must be an array`,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
assertDenseArray(value.hunks, `file[${index}].hunks`);
|
|
790
|
+
file.hunks = value.hunks
|
|
791
|
+
.map((hunk, hunkIndex) =>
|
|
792
|
+
normalizeHunkInput(hunk, { base, candidate, file }, hunkIndex),
|
|
793
|
+
)
|
|
794
|
+
.sort(compareHunks);
|
|
795
|
+
validateBlobState(file, `file[${index}]`);
|
|
796
|
+
file.id = computeMergeReviewFileId(base, candidate, file);
|
|
797
|
+
return file;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function validateFile(value, base, candidatesByKey, index) {
|
|
801
|
+
assertExactKeys(value, FILE_KEYS, `file[${index}]`);
|
|
802
|
+
const candidateKey = boundedIdentity(
|
|
803
|
+
value.candidateKey,
|
|
804
|
+
`file[${index}].candidateKey`,
|
|
805
|
+
TEAM_MERGE_REVIEW_LIMITS.keyBytes,
|
|
806
|
+
);
|
|
807
|
+
const candidate = candidatesByKey.get(candidateKey);
|
|
808
|
+
if (!candidate) {
|
|
809
|
+
fail(
|
|
810
|
+
TEAM_MERGE_REVIEW_ERROR.BINDING,
|
|
811
|
+
`file[${index}] refers to an unknown candidate`,
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
const status = value.status;
|
|
815
|
+
if (typeof status !== "string" || !FILE_STATUSES.has(status)) {
|
|
816
|
+
fail(TEAM_MERGE_REVIEW_ERROR.INVALID, `file[${index}].status is invalid`);
|
|
817
|
+
}
|
|
818
|
+
const file = {
|
|
819
|
+
id: value.id,
|
|
820
|
+
candidateKey,
|
|
821
|
+
path: normalizePath(value.path, `file[${index}].path`, { strict: true }),
|
|
822
|
+
oldPath:
|
|
823
|
+
value.oldPath === null
|
|
824
|
+
? null
|
|
825
|
+
: normalizePath(value.oldPath, `file[${index}].oldPath`, {
|
|
826
|
+
strict: true,
|
|
827
|
+
}),
|
|
828
|
+
status,
|
|
829
|
+
oldBlobOid: normalizeOid(value.oldBlobOid, `file[${index}].oldBlobOid`, {
|
|
830
|
+
nullable: true,
|
|
831
|
+
strict: true,
|
|
832
|
+
}),
|
|
833
|
+
newBlobOid: normalizeOid(value.newBlobOid, `file[${index}].newBlobOid`, {
|
|
834
|
+
nullable: true,
|
|
835
|
+
strict: true,
|
|
836
|
+
}),
|
|
837
|
+
binary: value.binary,
|
|
838
|
+
patchDigest: normalizeDigest(
|
|
839
|
+
value.patchDigest,
|
|
840
|
+
`file[${index}].patchDigest`,
|
|
841
|
+
),
|
|
842
|
+
hunks: [],
|
|
843
|
+
};
|
|
844
|
+
if (
|
|
845
|
+
typeof file.id !== "string" ||
|
|
846
|
+
!FILE_ID.test(file.id) ||
|
|
847
|
+
typeof file.binary !== "boolean" ||
|
|
848
|
+
!Array.isArray(value.hunks)
|
|
849
|
+
) {
|
|
850
|
+
fail(TEAM_MERGE_REVIEW_ERROR.INVALID, `file[${index}] identity is invalid`);
|
|
851
|
+
}
|
|
852
|
+
assertDenseArray(value.hunks, `file[${index}].hunks`);
|
|
853
|
+
file.hunks = value.hunks.map((hunk, hunkIndex) =>
|
|
854
|
+
validateHunk(hunk, { base, candidate, file }, hunkIndex),
|
|
855
|
+
);
|
|
856
|
+
if (
|
|
857
|
+
file.hunks.some(
|
|
858
|
+
(hunk, hunkIndex) =>
|
|
859
|
+
hunkIndex > 0 && compareHunks(file.hunks[hunkIndex - 1], hunk) >= 0,
|
|
860
|
+
)
|
|
861
|
+
) {
|
|
862
|
+
fail(
|
|
863
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
864
|
+
`file[${index}] hunks are not in stable order`,
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
validateBlobState(file, `file[${index}]`);
|
|
868
|
+
const expected = computeMergeReviewFileId(base, candidate, file);
|
|
869
|
+
if (file.id !== expected) {
|
|
870
|
+
fail(
|
|
871
|
+
TEAM_MERGE_REVIEW_ERROR.BINDING,
|
|
872
|
+
`file[${index}] no longer binds its exact candidate, blobs and hunks`,
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
return file;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function compareFiles(candidateOrder, left, right) {
|
|
879
|
+
return (
|
|
880
|
+
candidateOrder.get(left.candidateKey) -
|
|
881
|
+
candidateOrder.get(right.candidateKey) ||
|
|
882
|
+
left.path.localeCompare(right.path) ||
|
|
883
|
+
left.id.localeCompare(right.id)
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function normalizeFiles(values, base, candidates, { strict = false } = {}) {
|
|
888
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
889
|
+
fail(
|
|
890
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
891
|
+
"merge-review requires at least one changed file",
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
assertDenseArray(values, "merge-review files");
|
|
895
|
+
if (values.length > TEAM_MERGE_REVIEW_LIMITS.files) {
|
|
896
|
+
fail(
|
|
897
|
+
TEAM_MERGE_REVIEW_ERROR.LIMIT,
|
|
898
|
+
`merge-review cannot exceed ${TEAM_MERGE_REVIEW_LIMITS.files} files`,
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
let hunkCount = 0;
|
|
902
|
+
let totalPatchBytes = 0;
|
|
903
|
+
values.forEach((value, fileIndex) => {
|
|
904
|
+
assertPlainObject(value, `file[${fileIndex}]`);
|
|
905
|
+
if (!Array.isArray(value.hunks)) return;
|
|
906
|
+
hunkCount += value.hunks.length;
|
|
907
|
+
if (hunkCount > TEAM_MERGE_REVIEW_LIMITS.hunks) {
|
|
908
|
+
fail(
|
|
909
|
+
TEAM_MERGE_REVIEW_ERROR.LIMIT,
|
|
910
|
+
`merge-review cannot exceed ${TEAM_MERGE_REVIEW_LIMITS.hunks} hunks`,
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
value.hunks.forEach((hunk, hunkIndex) => {
|
|
914
|
+
assertPlainObject(hunk, `file[${fileIndex}].hunk[${hunkIndex}]`);
|
|
915
|
+
if (typeof hunk.patch !== "string") return;
|
|
916
|
+
totalPatchBytes += byteLength(hunk.patch);
|
|
917
|
+
if (totalPatchBytes > TEAM_MERGE_REVIEW_LIMITS.totalPatchBytes) {
|
|
918
|
+
fail(
|
|
919
|
+
TEAM_MERGE_REVIEW_ERROR.LIMIT,
|
|
920
|
+
`merge-review patches cannot exceed ${TEAM_MERGE_REVIEW_LIMITS.totalPatchBytes} total bytes`,
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
const candidatesByKey = new Map(
|
|
926
|
+
candidates.map((candidate) => [candidate.key, candidate]),
|
|
927
|
+
);
|
|
928
|
+
const candidateOrder = new Map(
|
|
929
|
+
candidates.map((candidate, index) => [candidate.key, index]),
|
|
930
|
+
);
|
|
931
|
+
const files = values.map((value, index) =>
|
|
932
|
+
strict
|
|
933
|
+
? validateFile(value, base, candidatesByKey, index)
|
|
934
|
+
: normalizeFileInput(value, base, candidatesByKey, index),
|
|
935
|
+
);
|
|
936
|
+
const seenPaths = new Set();
|
|
937
|
+
const seenIds = new Set();
|
|
938
|
+
const seenHunks = new Set();
|
|
939
|
+
for (const file of files) {
|
|
940
|
+
const pathBinding = `${file.candidateKey}\0${file.path}`;
|
|
941
|
+
if (seenPaths.has(pathBinding) || seenIds.has(file.id)) {
|
|
942
|
+
fail(
|
|
943
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
944
|
+
"merge-review contains duplicate file identities",
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
seenPaths.add(pathBinding);
|
|
948
|
+
seenIds.add(file.id);
|
|
949
|
+
for (const hunk of file.hunks) {
|
|
950
|
+
if (seenHunks.has(hunk.id)) {
|
|
951
|
+
fail(
|
|
952
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
953
|
+
"merge-review contains duplicate hunk identities",
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
seenHunks.add(hunk.id);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
if (strict) {
|
|
960
|
+
if (
|
|
961
|
+
files.some(
|
|
962
|
+
(file, index) =>
|
|
963
|
+
index > 0 &&
|
|
964
|
+
compareFiles(candidateOrder, files[index - 1], file) >= 0,
|
|
965
|
+
)
|
|
966
|
+
) {
|
|
967
|
+
fail(
|
|
968
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
969
|
+
"merge-review files are not in stable candidate/path order",
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
return files;
|
|
973
|
+
}
|
|
974
|
+
return files.sort((left, right) => compareFiles(candidateOrder, left, right));
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function planMaterial(review) {
|
|
978
|
+
return {
|
|
979
|
+
schema: TEAM_MERGE_REVIEW_SCHEMA,
|
|
980
|
+
schemaVersion: TEAM_MERGE_REVIEW_SCHEMA_VERSION,
|
|
981
|
+
base: review.base,
|
|
982
|
+
candidates: review.candidates,
|
|
983
|
+
files: review.files,
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
export function computeMergeReviewPlanDigest(review) {
|
|
988
|
+
return digestMergeReview(
|
|
989
|
+
"cc-team-merge-review-plan-v1",
|
|
990
|
+
planMaterial(review),
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
export function computeMergeReviewId(planDigest, createdAt) {
|
|
995
|
+
const digest = digestMergeReview("cc-team-merge-review-id-v1", {
|
|
996
|
+
planDigest: normalizeDigest(planDigest, "planDigest"),
|
|
997
|
+
createdAt: timestamp(createdAt, "createdAt"),
|
|
998
|
+
});
|
|
999
|
+
return `tmr_${digest.slice(
|
|
1000
|
+
"sha256:".length,
|
|
1001
|
+
"sha256:".length + ID_HEX_LENGTH,
|
|
1002
|
+
)}`;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function decisionMaterial(planDigest, decision) {
|
|
1006
|
+
return {
|
|
1007
|
+
planDigest,
|
|
1008
|
+
actor: decision.actor,
|
|
1009
|
+
host: decision.host,
|
|
1010
|
+
reason: decision.reason,
|
|
1011
|
+
decidedAt: decision.decidedAt,
|
|
1012
|
+
selectedFileIds: decision.selectedFileIds,
|
|
1013
|
+
selectedHunkIds: decision.selectedHunkIds,
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
export function computeMergeReviewDecisionDigest(planDigest, decision) {
|
|
1018
|
+
return digestMergeReview(
|
|
1019
|
+
"cc-team-merge-review-decision-v1",
|
|
1020
|
+
decisionMaterial(planDigest, decision),
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function selectionOrder(review) {
|
|
1025
|
+
const fileOrder = new Map();
|
|
1026
|
+
const hunkOrder = new Map();
|
|
1027
|
+
let hunkIndex = 0;
|
|
1028
|
+
review.files.forEach((file, index) => {
|
|
1029
|
+
fileOrder.set(file.id, index);
|
|
1030
|
+
for (const hunk of file.hunks) hunkOrder.set(hunk.id, hunkIndex++);
|
|
1031
|
+
});
|
|
1032
|
+
return { fileOrder, hunkOrder };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function normalizeSelectionIds(values, label, pattern, order) {
|
|
1036
|
+
assertDenseArray(values, label);
|
|
1037
|
+
const seen = new Set();
|
|
1038
|
+
const normalized = values.map((value) => {
|
|
1039
|
+
if (
|
|
1040
|
+
typeof value !== "string" ||
|
|
1041
|
+
!pattern.test(value) ||
|
|
1042
|
+
!order.has(value)
|
|
1043
|
+
) {
|
|
1044
|
+
fail(
|
|
1045
|
+
TEAM_MERGE_REVIEW_ERROR.BINDING,
|
|
1046
|
+
`${label} contains an ID outside the exact review plan`,
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
if (seen.has(value)) {
|
|
1050
|
+
fail(TEAM_MERGE_REVIEW_ERROR.INVALID, `${label} contains a duplicate ID`);
|
|
1051
|
+
}
|
|
1052
|
+
seen.add(value);
|
|
1053
|
+
return value;
|
|
1054
|
+
});
|
|
1055
|
+
normalized.sort((left, right) => order.get(left) - order.get(right));
|
|
1056
|
+
return normalized;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function normalizeDecision(
|
|
1060
|
+
value,
|
|
1061
|
+
review,
|
|
1062
|
+
{ strict = false, decidedAt = undefined } = {},
|
|
1063
|
+
) {
|
|
1064
|
+
if (value == null) return null;
|
|
1065
|
+
if (strict) assertExactKeys(value, DECISION_KEYS, "merge-review decision");
|
|
1066
|
+
else assertPlainObject(value, "merge-review decision");
|
|
1067
|
+
const { fileOrder, hunkOrder } = selectionOrder(review);
|
|
1068
|
+
const selectedFileIds = normalizeSelectionIds(
|
|
1069
|
+
value.selectedFileIds || [],
|
|
1070
|
+
"decision.selectedFileIds",
|
|
1071
|
+
FILE_ID,
|
|
1072
|
+
fileOrder,
|
|
1073
|
+
);
|
|
1074
|
+
const selectedHunkIds = normalizeSelectionIds(
|
|
1075
|
+
value.selectedHunkIds || [],
|
|
1076
|
+
"decision.selectedHunkIds",
|
|
1077
|
+
HUNK_ID,
|
|
1078
|
+
hunkOrder,
|
|
1079
|
+
);
|
|
1080
|
+
if (
|
|
1081
|
+
strict &&
|
|
1082
|
+
(canonicalMergeReviewJson(value.selectedFileIds) !==
|
|
1083
|
+
canonicalMergeReviewJson(selectedFileIds) ||
|
|
1084
|
+
canonicalMergeReviewJson(value.selectedHunkIds) !==
|
|
1085
|
+
canonicalMergeReviewJson(selectedHunkIds))
|
|
1086
|
+
) {
|
|
1087
|
+
fail(
|
|
1088
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1089
|
+
"merge-review decision selections are not in stable plan order",
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
if (selectedFileIds.length + selectedHunkIds.length === 0) {
|
|
1093
|
+
fail(
|
|
1094
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1095
|
+
"merge-review decision must select at least one file or hunk",
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
if (
|
|
1099
|
+
selectedFileIds.length + selectedHunkIds.length >
|
|
1100
|
+
TEAM_MERGE_REVIEW_LIMITS.selectionIds
|
|
1101
|
+
) {
|
|
1102
|
+
fail(
|
|
1103
|
+
TEAM_MERGE_REVIEW_ERROR.LIMIT,
|
|
1104
|
+
`merge-review decision exceeds ${TEAM_MERGE_REVIEW_LIMITS.selectionIds} selected IDs`,
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
const wholeFiles = new Set(selectedFileIds);
|
|
1108
|
+
for (const file of review.files) {
|
|
1109
|
+
if (
|
|
1110
|
+
wholeFiles.has(file.id) &&
|
|
1111
|
+
file.hunks.some((hunk) => selectedHunkIds.includes(hunk.id))
|
|
1112
|
+
) {
|
|
1113
|
+
fail(
|
|
1114
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1115
|
+
`decision selects both whole file and hunk for ${file.path}`,
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
const decision = {
|
|
1120
|
+
actor: boundedDecisionText(
|
|
1121
|
+
value.actor,
|
|
1122
|
+
"decision.actor",
|
|
1123
|
+
TEAM_MERGE_REVIEW_LIMITS.actorBytes,
|
|
1124
|
+
{ strict },
|
|
1125
|
+
),
|
|
1126
|
+
host: boundedIdentity(
|
|
1127
|
+
value.host,
|
|
1128
|
+
"decision.host",
|
|
1129
|
+
TEAM_MERGE_REVIEW_LIMITS.hostBytes,
|
|
1130
|
+
),
|
|
1131
|
+
reason: boundedDecisionText(
|
|
1132
|
+
value.reason,
|
|
1133
|
+
"decision.reason",
|
|
1134
|
+
TEAM_MERGE_REVIEW_LIMITS.reasonBytes,
|
|
1135
|
+
{ strict },
|
|
1136
|
+
),
|
|
1137
|
+
decidedAt: timestamp(
|
|
1138
|
+
strict ? value.decidedAt : (decidedAt ?? value.decidedAt),
|
|
1139
|
+
"decision.decidedAt",
|
|
1140
|
+
{ strict },
|
|
1141
|
+
),
|
|
1142
|
+
selectedFileIds,
|
|
1143
|
+
selectedHunkIds,
|
|
1144
|
+
};
|
|
1145
|
+
decision.digest = computeMergeReviewDecisionDigest(
|
|
1146
|
+
review.planDigest,
|
|
1147
|
+
decision,
|
|
1148
|
+
);
|
|
1149
|
+
if (strict && decision.digest !== value.digest) {
|
|
1150
|
+
fail(
|
|
1151
|
+
TEAM_MERGE_REVIEW_ERROR.BINDING,
|
|
1152
|
+
"merge-review decision digest does not bind the current plan and selection",
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
return decision;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
export function computeMergeReviewConflictsDigest(conflicts) {
|
|
1159
|
+
return digestMergeReview("cc-team-merge-review-conflicts-v1", conflicts);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function compareConflicts(fileOrder, left, right) {
|
|
1163
|
+
return (
|
|
1164
|
+
fileOrder.get(`${left.candidateKey}\0${left.path}`) -
|
|
1165
|
+
fileOrder.get(`${right.candidateKey}\0${right.path}`) ||
|
|
1166
|
+
left.type.localeCompare(right.type) ||
|
|
1167
|
+
left.hunkIds.join("\0").localeCompare(right.hunkIds.join("\0"))
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function normalizeConflicts(values, review, { strict = false } = {}) {
|
|
1172
|
+
assertDenseArray(values, "merge-review conflicts");
|
|
1173
|
+
if (values.length > TEAM_MERGE_REVIEW_LIMITS.conflicts) {
|
|
1174
|
+
fail(
|
|
1175
|
+
TEAM_MERGE_REVIEW_ERROR.LIMIT,
|
|
1176
|
+
`merge-review cannot exceed ${TEAM_MERGE_REVIEW_LIMITS.conflicts} conflicts`,
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
const candidates = new Set(
|
|
1180
|
+
review.candidates.map((candidate) => candidate.key),
|
|
1181
|
+
);
|
|
1182
|
+
const files = new Map(
|
|
1183
|
+
review.files.map((file) => [`${file.candidateKey}\0${file.path}`, file]),
|
|
1184
|
+
);
|
|
1185
|
+
const fileOrder = new Map(
|
|
1186
|
+
review.files.map((file, index) => [
|
|
1187
|
+
`${file.candidateKey}\0${file.path}`,
|
|
1188
|
+
index,
|
|
1189
|
+
]),
|
|
1190
|
+
);
|
|
1191
|
+
const selectedFiles = new Set(review.decision?.selectedFileIds || []);
|
|
1192
|
+
const selectedHunks = new Set(review.decision?.selectedHunkIds || []);
|
|
1193
|
+
let totalTextBytes = 0;
|
|
1194
|
+
let totalHunkRefs = 0;
|
|
1195
|
+
const seen = new Set();
|
|
1196
|
+
const conflicts = values.map((value, index) => {
|
|
1197
|
+
if (strict) assertExactKeys(value, CONFLICT_KEYS, `conflict[${index}]`);
|
|
1198
|
+
else assertPlainObject(value, `conflict[${index}]`);
|
|
1199
|
+
const candidateKey = boundedIdentity(
|
|
1200
|
+
value.candidateKey,
|
|
1201
|
+
`conflict[${index}].candidateKey`,
|
|
1202
|
+
TEAM_MERGE_REVIEW_LIMITS.keyBytes,
|
|
1203
|
+
);
|
|
1204
|
+
if (!candidates.has(candidateKey)) {
|
|
1205
|
+
fail(
|
|
1206
|
+
TEAM_MERGE_REVIEW_ERROR.BINDING,
|
|
1207
|
+
`conflict[${index}] refers to an unknown candidate`,
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
const conflictPath = normalizePath(value.path, `conflict[${index}].path`, {
|
|
1211
|
+
strict,
|
|
1212
|
+
});
|
|
1213
|
+
const file = files.get(`${candidateKey}\0${conflictPath}`);
|
|
1214
|
+
if (!file) {
|
|
1215
|
+
fail(
|
|
1216
|
+
TEAM_MERGE_REVIEW_ERROR.BINDING,
|
|
1217
|
+
`conflict[${index}] refers to a file outside the exact review plan`,
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
const type = boundedIdentity(
|
|
1221
|
+
value.type,
|
|
1222
|
+
`conflict[${index}].type`,
|
|
1223
|
+
TEAM_MERGE_REVIEW_LIMITS.conflictTypeBytes,
|
|
1224
|
+
);
|
|
1225
|
+
if (!/^[a-z][a-z0-9_-]*$/u.test(type)) {
|
|
1226
|
+
fail(
|
|
1227
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1228
|
+
`conflict[${index}].type is not a stable identifier`,
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
const explanation = boundedDecisionText(
|
|
1232
|
+
value.explanation,
|
|
1233
|
+
`conflict[${index}].explanation`,
|
|
1234
|
+
TEAM_MERGE_REVIEW_LIMITS.conflictTextBytes,
|
|
1235
|
+
{ strict },
|
|
1236
|
+
);
|
|
1237
|
+
const suggestion = boundedDecisionText(
|
|
1238
|
+
value.suggestion,
|
|
1239
|
+
`conflict[${index}].suggestion`,
|
|
1240
|
+
TEAM_MERGE_REVIEW_LIMITS.conflictTextBytes,
|
|
1241
|
+
{ strict },
|
|
1242
|
+
);
|
|
1243
|
+
totalTextBytes += byteLength(explanation) + byteLength(suggestion);
|
|
1244
|
+
if (totalTextBytes > TEAM_MERGE_REVIEW_LIMITS.totalConflictTextBytes) {
|
|
1245
|
+
fail(
|
|
1246
|
+
TEAM_MERGE_REVIEW_ERROR.LIMIT,
|
|
1247
|
+
"merge-review conflict text exceeds its total byte budget",
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
const hunkOrder = new Map(
|
|
1251
|
+
file.hunks.map((hunk, hunkIndex) => [hunk.id, hunkIndex]),
|
|
1252
|
+
);
|
|
1253
|
+
const hunkIds = normalizeSelectionIds(
|
|
1254
|
+
value.hunkIds,
|
|
1255
|
+
`conflict[${index}].hunkIds`,
|
|
1256
|
+
HUNK_ID,
|
|
1257
|
+
hunkOrder,
|
|
1258
|
+
);
|
|
1259
|
+
if (
|
|
1260
|
+
strict &&
|
|
1261
|
+
canonicalMergeReviewJson(value.hunkIds) !==
|
|
1262
|
+
canonicalMergeReviewJson(hunkIds)
|
|
1263
|
+
) {
|
|
1264
|
+
fail(
|
|
1265
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1266
|
+
`conflict[${index}].hunkIds are not in stable file order`,
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
totalHunkRefs += hunkIds.length;
|
|
1270
|
+
if (totalHunkRefs > TEAM_MERGE_REVIEW_LIMITS.conflictHunkRefs) {
|
|
1271
|
+
fail(
|
|
1272
|
+
TEAM_MERGE_REVIEW_ERROR.LIMIT,
|
|
1273
|
+
"merge-review conflicts exceed their hunk-reference budget",
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
if (
|
|
1277
|
+
(hunkIds.length === 0 && !selectedFiles.has(file.id)) ||
|
|
1278
|
+
hunkIds.some((hunkId) => !selectedHunks.has(hunkId))
|
|
1279
|
+
) {
|
|
1280
|
+
fail(
|
|
1281
|
+
TEAM_MERGE_REVIEW_ERROR.BINDING,
|
|
1282
|
+
`conflict[${index}] is outside the persisted selection decision`,
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
const identity = `${candidateKey}\0${conflictPath}\0${type}\0${hunkIds.join("\0")}`;
|
|
1286
|
+
if (seen.has(identity)) {
|
|
1287
|
+
fail(
|
|
1288
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1289
|
+
"merge-review contains duplicate conflict identities",
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
seen.add(identity);
|
|
1293
|
+
return {
|
|
1294
|
+
candidateKey,
|
|
1295
|
+
path: conflictPath,
|
|
1296
|
+
type,
|
|
1297
|
+
explanation,
|
|
1298
|
+
suggestion,
|
|
1299
|
+
hunkIds,
|
|
1300
|
+
};
|
|
1301
|
+
});
|
|
1302
|
+
conflicts.sort((left, right) => compareConflicts(fileOrder, left, right));
|
|
1303
|
+
if (
|
|
1304
|
+
strict &&
|
|
1305
|
+
canonicalMergeReviewJson(values) !== canonicalMergeReviewJson(conflicts)
|
|
1306
|
+
) {
|
|
1307
|
+
fail(
|
|
1308
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1309
|
+
"merge-review conflicts are not in stable plan order",
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
return conflicts;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/** Normalize readable conflict evidence against an already persisted decision. */
|
|
1316
|
+
export function canonicalizeMergeReviewConflicts(reviewValue, conflicts) {
|
|
1317
|
+
const review = validateMergeReview(reviewValue);
|
|
1318
|
+
return normalizeConflicts(conflicts, review);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function emptySettlement() {
|
|
1322
|
+
return {
|
|
1323
|
+
preparedOid: null,
|
|
1324
|
+
publishedOid: null,
|
|
1325
|
+
rollbackOid: null,
|
|
1326
|
+
conflictDigest: null,
|
|
1327
|
+
transitionEvidenceDigest: null,
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function normalizeSettlement(value, state, { strict = false } = {}) {
|
|
1332
|
+
if (strict) {
|
|
1333
|
+
assertExactKeys(value, SETTLEMENT_KEYS, "merge-review settlement");
|
|
1334
|
+
} else {
|
|
1335
|
+
assertPlainObject(value, "merge-review settlement");
|
|
1336
|
+
}
|
|
1337
|
+
const settlement = {
|
|
1338
|
+
preparedOid: normalizeOid(value.preparedOid, "settlement.preparedOid", {
|
|
1339
|
+
nullable: true,
|
|
1340
|
+
strict,
|
|
1341
|
+
}),
|
|
1342
|
+
publishedOid: normalizeOid(value.publishedOid, "settlement.publishedOid", {
|
|
1343
|
+
nullable: true,
|
|
1344
|
+
strict,
|
|
1345
|
+
}),
|
|
1346
|
+
rollbackOid: normalizeOid(value.rollbackOid, "settlement.rollbackOid", {
|
|
1347
|
+
nullable: true,
|
|
1348
|
+
strict,
|
|
1349
|
+
}),
|
|
1350
|
+
conflictDigest: normalizeDigest(
|
|
1351
|
+
value.conflictDigest,
|
|
1352
|
+
"settlement.conflictDigest",
|
|
1353
|
+
{ nullable: true, strict },
|
|
1354
|
+
),
|
|
1355
|
+
transitionEvidenceDigest: normalizeDigest(
|
|
1356
|
+
value.transitionEvidenceDigest,
|
|
1357
|
+
"settlement.transitionEvidenceDigest",
|
|
1358
|
+
{ nullable: true, strict },
|
|
1359
|
+
),
|
|
1360
|
+
};
|
|
1361
|
+
if (
|
|
1362
|
+
state === "planned" &&
|
|
1363
|
+
canonicalMergeReviewJson(settlement) !==
|
|
1364
|
+
canonicalMergeReviewJson(emptySettlement())
|
|
1365
|
+
) {
|
|
1366
|
+
fail(
|
|
1367
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1368
|
+
"planned merge-review cannot carry settlement evidence",
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
if (state !== "rolled_back" && settlement.rollbackOid) {
|
|
1372
|
+
fail(
|
|
1373
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1374
|
+
`${state} merge-review cannot carry rollbackOid`,
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
if (
|
|
1378
|
+
settlement.publishedOid &&
|
|
1379
|
+
!["published", "rollback_required", "rolled_back"].includes(state)
|
|
1380
|
+
) {
|
|
1381
|
+
fail(
|
|
1382
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1383
|
+
`${state} merge-review cannot carry publishedOid`,
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
if (
|
|
1387
|
+
settlement.conflictDigest &&
|
|
1388
|
+
!["conflicted", "rolled_back"].includes(state)
|
|
1389
|
+
) {
|
|
1390
|
+
fail(
|
|
1391
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1392
|
+
`${state} merge-review cannot carry conflictDigest`,
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
if (settlement.conflictDigest && settlement.publishedOid) {
|
|
1396
|
+
fail(
|
|
1397
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1398
|
+
"merge-review cannot be both conflicted and published",
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
if (
|
|
1402
|
+
settlement.publishedOid &&
|
|
1403
|
+
settlement.preparedOid &&
|
|
1404
|
+
settlement.publishedOid !== settlement.preparedOid
|
|
1405
|
+
) {
|
|
1406
|
+
fail(
|
|
1407
|
+
TEAM_MERGE_REVIEW_ERROR.BINDING,
|
|
1408
|
+
"publishedOid must be the exact preparedOid",
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
if (
|
|
1412
|
+
["prepared", "publishing", "published"].includes(state) &&
|
|
1413
|
+
!settlement.preparedOid
|
|
1414
|
+
) {
|
|
1415
|
+
fail(
|
|
1416
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1417
|
+
`${state} merge-review requires preparedOid`,
|
|
1418
|
+
);
|
|
1419
|
+
}
|
|
1420
|
+
if (state === "published" && !settlement.publishedOid) {
|
|
1421
|
+
fail(
|
|
1422
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1423
|
+
"published merge-review requires publishedOid",
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
if (state === "conflicted" && !settlement.conflictDigest) {
|
|
1427
|
+
fail(
|
|
1428
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1429
|
+
"conflicted merge-review requires conflictDigest",
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
if (state === "rolled_back" && !settlement.rollbackOid) {
|
|
1433
|
+
fail(
|
|
1434
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1435
|
+
"rolled_back merge-review requires rollbackOid",
|
|
1436
|
+
);
|
|
1437
|
+
}
|
|
1438
|
+
if (
|
|
1439
|
+
["rollback_required", "rolled_back"].includes(state) &&
|
|
1440
|
+
!settlement.conflictDigest &&
|
|
1441
|
+
!settlement.preparedOid
|
|
1442
|
+
) {
|
|
1443
|
+
fail(
|
|
1444
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1445
|
+
`${state} merge-review requires prepared or conflict evidence`,
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
if (state !== "planned" && !settlement.transitionEvidenceDigest) {
|
|
1449
|
+
fail(
|
|
1450
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1451
|
+
`${state} merge-review requires transition evidence`,
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
return settlement;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
function reviewEvidenceMaterial(review) {
|
|
1458
|
+
const material = { ...review };
|
|
1459
|
+
delete material.evidenceDigest;
|
|
1460
|
+
return material;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
export function computeMergeReviewEvidenceDigest(review) {
|
|
1464
|
+
return digestMergeReview(
|
|
1465
|
+
"cc-team-merge-review-evidence-v1",
|
|
1466
|
+
reviewEvidenceMaterial(review),
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function finalizeEvidence(review) {
|
|
1471
|
+
review.evidenceDigest = computeMergeReviewEvidenceDigest(review);
|
|
1472
|
+
return review;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/** Build a canonical planned review. Candidate array order is merge order. */
|
|
1476
|
+
export function buildMergeReview(input = {}) {
|
|
1477
|
+
assertPlainObject(input, "merge-review input");
|
|
1478
|
+
if (Object.hasOwn(input, "decision")) {
|
|
1479
|
+
fail(
|
|
1480
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1481
|
+
"persist merge-review selections with applyMergeReviewDecision",
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
const base = normalizeBase(input.base);
|
|
1485
|
+
const candidates = normalizeCandidates(input.candidates);
|
|
1486
|
+
const files = normalizeFiles(input.files, base, candidates);
|
|
1487
|
+
const createdAt = timestamp(input.createdAt || new Date(), "createdAt");
|
|
1488
|
+
const review = {
|
|
1489
|
+
schema: TEAM_MERGE_REVIEW_SCHEMA,
|
|
1490
|
+
schemaVersion: TEAM_MERGE_REVIEW_SCHEMA_VERSION,
|
|
1491
|
+
reviewId: "",
|
|
1492
|
+
revision: 0,
|
|
1493
|
+
state: TEAM_MERGE_REVIEW_STATE.PLANNED,
|
|
1494
|
+
base,
|
|
1495
|
+
candidates,
|
|
1496
|
+
files,
|
|
1497
|
+
decision: null,
|
|
1498
|
+
conflicts: [],
|
|
1499
|
+
planDigest: "",
|
|
1500
|
+
evidenceDigest: "",
|
|
1501
|
+
settlement: emptySettlement(),
|
|
1502
|
+
createdAt,
|
|
1503
|
+
updatedAt: createdAt,
|
|
1504
|
+
};
|
|
1505
|
+
review.planDigest = computeMergeReviewPlanDigest(review);
|
|
1506
|
+
review.reviewId = computeMergeReviewId(review.planDigest, review.createdAt);
|
|
1507
|
+
return finalizeEvidence(review);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/** Strictly validate and detach a persisted/public review record. */
|
|
1511
|
+
export function validateMergeReview(value) {
|
|
1512
|
+
assertExactKeys(value, REVIEW_KEYS, "merge-review record");
|
|
1513
|
+
if (
|
|
1514
|
+
value.schema !== TEAM_MERGE_REVIEW_SCHEMA ||
|
|
1515
|
+
value.schemaVersion !== TEAM_MERGE_REVIEW_SCHEMA_VERSION
|
|
1516
|
+
) {
|
|
1517
|
+
fail(
|
|
1518
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1519
|
+
"unsupported merge-review schema/version pair",
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
if (typeof value.reviewId !== "string" || !REVIEW_ID.test(value.reviewId)) {
|
|
1523
|
+
fail(TEAM_MERGE_REVIEW_ERROR.INVALID, "merge-review reviewId is invalid");
|
|
1524
|
+
}
|
|
1525
|
+
const revision = nonNegativeInteger(value.revision, "revision", {
|
|
1526
|
+
strict: true,
|
|
1527
|
+
});
|
|
1528
|
+
const state = value.state;
|
|
1529
|
+
if (typeof state !== "string" || !STATES.has(state)) {
|
|
1530
|
+
fail(TEAM_MERGE_REVIEW_ERROR.INVALID, "merge-review state is invalid");
|
|
1531
|
+
}
|
|
1532
|
+
const base = normalizeBase(value.base, { strict: true });
|
|
1533
|
+
const candidates = normalizeCandidates(value.candidates, { strict: true });
|
|
1534
|
+
const files = normalizeFiles(value.files, base, candidates, { strict: true });
|
|
1535
|
+
const createdAt = timestamp(value.createdAt, "createdAt", { strict: true });
|
|
1536
|
+
const updatedAt = timestamp(value.updatedAt, "updatedAt", { strict: true });
|
|
1537
|
+
if (Date.parse(updatedAt) < Date.parse(createdAt)) {
|
|
1538
|
+
fail(
|
|
1539
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1540
|
+
"merge-review updatedAt precedes createdAt",
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
const review = {
|
|
1544
|
+
schema: TEAM_MERGE_REVIEW_SCHEMA,
|
|
1545
|
+
schemaVersion: TEAM_MERGE_REVIEW_SCHEMA_VERSION,
|
|
1546
|
+
reviewId: value.reviewId,
|
|
1547
|
+
revision,
|
|
1548
|
+
state,
|
|
1549
|
+
base,
|
|
1550
|
+
candidates,
|
|
1551
|
+
files,
|
|
1552
|
+
decision: null,
|
|
1553
|
+
conflicts: [],
|
|
1554
|
+
planDigest: normalizeDigest(value.planDigest, "planDigest"),
|
|
1555
|
+
evidenceDigest: normalizeDigest(value.evidenceDigest, "evidenceDigest"),
|
|
1556
|
+
settlement: normalizeSettlement(value.settlement, state, { strict: true }),
|
|
1557
|
+
createdAt,
|
|
1558
|
+
updatedAt,
|
|
1559
|
+
};
|
|
1560
|
+
const expectedPlan = computeMergeReviewPlanDigest(review);
|
|
1561
|
+
if (review.planDigest !== expectedPlan) {
|
|
1562
|
+
fail(
|
|
1563
|
+
TEAM_MERGE_REVIEW_ERROR.BINDING,
|
|
1564
|
+
"merge-review plan digest does not bind its exact inputs",
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
const expectedId = computeMergeReviewId(expectedPlan, review.createdAt);
|
|
1568
|
+
if (review.reviewId !== expectedId) {
|
|
1569
|
+
fail(
|
|
1570
|
+
TEAM_MERGE_REVIEW_ERROR.BINDING,
|
|
1571
|
+
"merge-review reviewId does not bind its plan",
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
review.decision = normalizeDecision(value.decision, review, { strict: true });
|
|
1575
|
+
review.conflicts = normalizeConflicts(value.conflicts, review, {
|
|
1576
|
+
strict: true,
|
|
1577
|
+
});
|
|
1578
|
+
if (
|
|
1579
|
+
review.decision &&
|
|
1580
|
+
(Date.parse(review.decision.decidedAt) < Date.parse(createdAt) ||
|
|
1581
|
+
Date.parse(review.decision.decidedAt) > Date.parse(updatedAt) ||
|
|
1582
|
+
(state === "planned" && review.decision.decidedAt !== updatedAt))
|
|
1583
|
+
) {
|
|
1584
|
+
fail(
|
|
1585
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1586
|
+
"merge-review decision timestamp is outside its revision history",
|
|
1587
|
+
);
|
|
1588
|
+
}
|
|
1589
|
+
if (
|
|
1590
|
+
state === "planned" &&
|
|
1591
|
+
((!review.decision && revision !== 0) ||
|
|
1592
|
+
(review.decision && revision !== 1))
|
|
1593
|
+
) {
|
|
1594
|
+
fail(
|
|
1595
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1596
|
+
"planned merge-review revision does not match its decision state",
|
|
1597
|
+
);
|
|
1598
|
+
}
|
|
1599
|
+
if (
|
|
1600
|
+
["prepared", "publishing", "published", "conflicted"].includes(state) &&
|
|
1601
|
+
!review.decision
|
|
1602
|
+
) {
|
|
1603
|
+
fail(
|
|
1604
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1605
|
+
`${state} merge-review requires a selection decision`,
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
if (review.settlement.preparedOid && !review.decision) {
|
|
1609
|
+
fail(
|
|
1610
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1611
|
+
"prepared merge-review evidence requires a selection decision",
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
if (
|
|
1615
|
+
(state === "conflicted" && review.conflicts.length === 0) ||
|
|
1616
|
+
(review.conflicts.length > 0 &&
|
|
1617
|
+
!["conflicted", "rolled_back"].includes(state)) ||
|
|
1618
|
+
(review.conflicts.length > 0 && !review.decision)
|
|
1619
|
+
) {
|
|
1620
|
+
fail(
|
|
1621
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1622
|
+
"merge-review conflict evidence does not match its state/decision",
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
const expectedConflictDigest =
|
|
1626
|
+
review.conflicts.length > 0
|
|
1627
|
+
? computeMergeReviewConflictsDigest(review.conflicts)
|
|
1628
|
+
: null;
|
|
1629
|
+
if (review.settlement.conflictDigest !== expectedConflictDigest) {
|
|
1630
|
+
fail(
|
|
1631
|
+
TEAM_MERGE_REVIEW_ERROR.BINDING,
|
|
1632
|
+
"merge-review conflictDigest does not bind its canonical conflicts",
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
const expectedEvidence = computeMergeReviewEvidenceDigest(review);
|
|
1636
|
+
if (review.evidenceDigest !== expectedEvidence) {
|
|
1637
|
+
fail(
|
|
1638
|
+
TEAM_MERGE_REVIEW_ERROR.BINDING,
|
|
1639
|
+
"merge-review evidence digest does not bind its current revision",
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
return review;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function requireRevision(review, expectedRevision) {
|
|
1646
|
+
const expected = nonNegativeInteger(expectedRevision, "expectedRevision");
|
|
1647
|
+
if (review.revision !== expected) {
|
|
1648
|
+
fail(
|
|
1649
|
+
TEAM_MERGE_REVIEW_ERROR.STALE,
|
|
1650
|
+
`merge-review revision ${expected} is stale; current revision is ${review.revision}`,
|
|
1651
|
+
{
|
|
1652
|
+
reviewId: review.reviewId,
|
|
1653
|
+
expectedRevision: expected,
|
|
1654
|
+
revision: review.revision,
|
|
1655
|
+
},
|
|
1656
|
+
);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/** Persist the human selection before any staging mutation begins. */
|
|
1661
|
+
export function applyMergeReviewDecision(value, request = {}) {
|
|
1662
|
+
const review = validateMergeReview(value);
|
|
1663
|
+
requireRevision(review, request.expectedRevision);
|
|
1664
|
+
if (review.state !== TEAM_MERGE_REVIEW_STATE.PLANNED) {
|
|
1665
|
+
fail(
|
|
1666
|
+
TEAM_MERGE_REVIEW_ERROR.TRANSITION,
|
|
1667
|
+
`cannot decide a merge-review in state ${review.state}`,
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1670
|
+
if (review.decision) {
|
|
1671
|
+
const decision = normalizeDecision(request, review, {
|
|
1672
|
+
decidedAt: review.decision.decidedAt,
|
|
1673
|
+
});
|
|
1674
|
+
if (review.decision.digest === decision.digest) return review;
|
|
1675
|
+
fail(
|
|
1676
|
+
TEAM_MERGE_REVIEW_ERROR.TRANSITION,
|
|
1677
|
+
"merge-review already has a different immutable decision",
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
const updatedAt = timestamp(request.at || new Date(), "decision.at");
|
|
1681
|
+
if (Date.parse(updatedAt) < Date.parse(review.updatedAt)) {
|
|
1682
|
+
fail(
|
|
1683
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1684
|
+
"merge-review decision timestamp moves backwards",
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
const decision = normalizeDecision(request, review, { decidedAt: updatedAt });
|
|
1688
|
+
return finalizeEvidence({
|
|
1689
|
+
...review,
|
|
1690
|
+
revision: review.revision + 1,
|
|
1691
|
+
decision,
|
|
1692
|
+
updatedAt,
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function transitionSettlement(review, to, request) {
|
|
1697
|
+
const next = { ...review.settlement };
|
|
1698
|
+
const assignments = [
|
|
1699
|
+
["preparedOid", "prepared", normalizeOid],
|
|
1700
|
+
["publishedOid", "published", normalizeOid],
|
|
1701
|
+
["rollbackOid", "rolled_back", normalizeOid],
|
|
1702
|
+
["conflictDigest", "conflicted", normalizeDigest],
|
|
1703
|
+
];
|
|
1704
|
+
for (const [field, targetState, normalize] of assignments) {
|
|
1705
|
+
if (request[field] === undefined) continue;
|
|
1706
|
+
if (to !== targetState) {
|
|
1707
|
+
fail(
|
|
1708
|
+
TEAM_MERGE_REVIEW_ERROR.TRANSITION,
|
|
1709
|
+
`${field} may only be recorded while entering ${targetState}`,
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
const value = normalize(request[field], field, { nullable: true });
|
|
1713
|
+
if (next[field] !== null && next[field] !== value) {
|
|
1714
|
+
fail(
|
|
1715
|
+
TEAM_MERGE_REVIEW_ERROR.TRANSITION,
|
|
1716
|
+
`${field} is immutable once recorded`,
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
next[field] = value;
|
|
1720
|
+
}
|
|
1721
|
+
next.transitionEvidenceDigest = normalizeDigest(
|
|
1722
|
+
request.transitionEvidenceDigest,
|
|
1723
|
+
"transitionEvidenceDigest",
|
|
1724
|
+
);
|
|
1725
|
+
return normalizeSettlement(next, to);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/** Advance one exact-CAS state transition and bind its settlement evidence. */
|
|
1729
|
+
export function transitionMergeReview(value, request = {}) {
|
|
1730
|
+
const review = validateMergeReview(value);
|
|
1731
|
+
requireRevision(review, request.expectedRevision);
|
|
1732
|
+
const to = String(request.to || "");
|
|
1733
|
+
if (!STATES.has(to) || !TRANSITIONS.get(review.state)?.has(to)) {
|
|
1734
|
+
fail(
|
|
1735
|
+
TEAM_MERGE_REVIEW_ERROR.TRANSITION,
|
|
1736
|
+
`illegal merge-review transition ${review.state} -> ${to || "(empty)"}`,
|
|
1737
|
+
);
|
|
1738
|
+
}
|
|
1739
|
+
if (
|
|
1740
|
+
["prepared", "publishing", "published", "conflicted"].includes(to) &&
|
|
1741
|
+
!review.decision
|
|
1742
|
+
) {
|
|
1743
|
+
fail(
|
|
1744
|
+
TEAM_MERGE_REVIEW_ERROR.TRANSITION,
|
|
1745
|
+
`merge-review must persist a decision before ${to}`,
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
let conflicts = review.conflicts;
|
|
1749
|
+
if (to === "conflicted") {
|
|
1750
|
+
if (!Object.hasOwn(request, "conflicts")) {
|
|
1751
|
+
fail(
|
|
1752
|
+
TEAM_MERGE_REVIEW_ERROR.TRANSITION,
|
|
1753
|
+
"entering conflicted requires durable conflict evidence",
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
conflicts = normalizeConflicts(request.conflicts, review);
|
|
1757
|
+
if (conflicts.length === 0) {
|
|
1758
|
+
fail(
|
|
1759
|
+
TEAM_MERGE_REVIEW_ERROR.TRANSITION,
|
|
1760
|
+
"entering conflicted requires at least one conflict",
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
} else if (Object.hasOwn(request, "conflicts")) {
|
|
1764
|
+
fail(
|
|
1765
|
+
TEAM_MERGE_REVIEW_ERROR.TRANSITION,
|
|
1766
|
+
"conflicts may only be recorded while entering conflicted",
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
const updatedAt = timestamp(request.at || new Date(), "transition.at");
|
|
1770
|
+
if (Date.parse(updatedAt) < Date.parse(review.updatedAt)) {
|
|
1771
|
+
fail(
|
|
1772
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1773
|
+
"merge-review transition timestamp moves backwards",
|
|
1774
|
+
);
|
|
1775
|
+
}
|
|
1776
|
+
const settlement = transitionSettlement(review, to, request);
|
|
1777
|
+
const expectedConflictDigest =
|
|
1778
|
+
conflicts.length > 0 ? computeMergeReviewConflictsDigest(conflicts) : null;
|
|
1779
|
+
if (settlement.conflictDigest !== expectedConflictDigest) {
|
|
1780
|
+
fail(
|
|
1781
|
+
TEAM_MERGE_REVIEW_ERROR.BINDING,
|
|
1782
|
+
"conflictDigest must match the canonical durable conflicts",
|
|
1783
|
+
);
|
|
1784
|
+
}
|
|
1785
|
+
return finalizeEvidence({
|
|
1786
|
+
...review,
|
|
1787
|
+
revision: review.revision + 1,
|
|
1788
|
+
state: to,
|
|
1789
|
+
conflicts,
|
|
1790
|
+
settlement,
|
|
1791
|
+
updatedAt,
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/** Verify that a replayed store event is the sole legal successor. */
|
|
1796
|
+
export function assertMergeReviewSuccessor(previousValue, nextValue, type) {
|
|
1797
|
+
const previous = validateMergeReview(previousValue);
|
|
1798
|
+
const next = validateMergeReview(nextValue);
|
|
1799
|
+
let expected;
|
|
1800
|
+
if (type === "review.decided") {
|
|
1801
|
+
if (!next.decision) {
|
|
1802
|
+
fail(
|
|
1803
|
+
TEAM_MERGE_REVIEW_ERROR.TRANSITION,
|
|
1804
|
+
"invalid merge-review decision successor",
|
|
1805
|
+
);
|
|
1806
|
+
}
|
|
1807
|
+
expected = applyMergeReviewDecision(previous, {
|
|
1808
|
+
expectedRevision: previous.revision,
|
|
1809
|
+
actor: next.decision.actor,
|
|
1810
|
+
host: next.decision.host,
|
|
1811
|
+
reason: next.decision.reason,
|
|
1812
|
+
selectedFileIds: next.decision.selectedFileIds,
|
|
1813
|
+
selectedHunkIds: next.decision.selectedHunkIds,
|
|
1814
|
+
at: next.updatedAt,
|
|
1815
|
+
});
|
|
1816
|
+
} else if (type === "review.transitioned") {
|
|
1817
|
+
const request = {
|
|
1818
|
+
expectedRevision: previous.revision,
|
|
1819
|
+
to: next.state,
|
|
1820
|
+
at: next.updatedAt,
|
|
1821
|
+
transitionEvidenceDigest: next.settlement.transitionEvidenceDigest,
|
|
1822
|
+
};
|
|
1823
|
+
const targetField = {
|
|
1824
|
+
prepared: "preparedOid",
|
|
1825
|
+
published: "publishedOid",
|
|
1826
|
+
conflicted: "conflictDigest",
|
|
1827
|
+
rolled_back: "rollbackOid",
|
|
1828
|
+
}[next.state];
|
|
1829
|
+
if (targetField) {
|
|
1830
|
+
request[targetField] = next.settlement[targetField];
|
|
1831
|
+
}
|
|
1832
|
+
if (next.state === "conflicted") request.conflicts = next.conflicts;
|
|
1833
|
+
expected = transitionMergeReview(previous, request);
|
|
1834
|
+
} else {
|
|
1835
|
+
fail(
|
|
1836
|
+
TEAM_MERGE_REVIEW_ERROR.INVALID,
|
|
1837
|
+
`unsupported merge-review successor event ${String(type)}`,
|
|
1838
|
+
);
|
|
1839
|
+
}
|
|
1840
|
+
if (canonicalMergeReviewJson(expected) !== canonicalMergeReviewJson(next)) {
|
|
1841
|
+
fail(
|
|
1842
|
+
TEAM_MERGE_REVIEW_ERROR.BINDING,
|
|
1843
|
+
"merge-review successor is not the exact legal CAS result",
|
|
1844
|
+
);
|
|
1845
|
+
}
|
|
1846
|
+
return next;
|
|
1847
|
+
}
|