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,1345 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { getHomeDir } from "../paths.js";
|
|
6
|
+
import { ensurePrivateDirectory, ensurePrivateFile } from "../secure-fs.js";
|
|
7
|
+
import {
|
|
8
|
+
OCCURRENCE_STATUS,
|
|
9
|
+
SCHEDULER_SCHEMA_VERSION,
|
|
10
|
+
SchedulerKernelError,
|
|
11
|
+
canonicalJson,
|
|
12
|
+
deriveOccurrenceIdentity,
|
|
13
|
+
invalidArgument,
|
|
14
|
+
normalizeAuthorityEnvelope,
|
|
15
|
+
normalizeEpochMs,
|
|
16
|
+
normalizeHistoryLimit,
|
|
17
|
+
normalizeIdentifier,
|
|
18
|
+
normalizeJson,
|
|
19
|
+
normalizeMaxAttempts,
|
|
20
|
+
} from "./contract.js";
|
|
21
|
+
|
|
22
|
+
const requireCjs = createRequire(import.meta.url);
|
|
23
|
+
|
|
24
|
+
export const SCHEDULER_APPLICATION_ID = 0x4343534b; // "CCSK"
|
|
25
|
+
export const DEFAULT_BUSY_TIMEOUT_MS = 5_000;
|
|
26
|
+
export const MAX_LEASE_MS = 24 * 60 * 60 * 1_000;
|
|
27
|
+
|
|
28
|
+
const MIGRATION_NAME = "scheduler-kernel-v1";
|
|
29
|
+
const USER_TABLES = Object.freeze([
|
|
30
|
+
"events",
|
|
31
|
+
"jobs",
|
|
32
|
+
"migrations",
|
|
33
|
+
"occurrences",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const MIGRATION_V1_SQL = `
|
|
37
|
+
CREATE TABLE migrations (
|
|
38
|
+
version INTEGER PRIMARY KEY NOT NULL,
|
|
39
|
+
name TEXT NOT NULL UNIQUE,
|
|
40
|
+
checksum TEXT NOT NULL,
|
|
41
|
+
applied_at INTEGER NOT NULL CHECK (applied_at >= 0)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE jobs (
|
|
45
|
+
job_id TEXT PRIMARY KEY NOT NULL,
|
|
46
|
+
kind TEXT NOT NULL,
|
|
47
|
+
trigger_json TEXT NOT NULL CHECK (json_valid(trigger_json)),
|
|
48
|
+
payload_json TEXT NOT NULL CHECK (json_valid(payload_json)),
|
|
49
|
+
authority_json TEXT NOT NULL CHECK (json_valid(authority_json)),
|
|
50
|
+
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
|
|
51
|
+
revision INTEGER NOT NULL CHECK (revision >= 1),
|
|
52
|
+
max_attempts INTEGER NOT NULL CHECK (max_attempts BETWEEN 1 AND 32),
|
|
53
|
+
created_at INTEGER NOT NULL CHECK (created_at >= 0),
|
|
54
|
+
updated_at INTEGER NOT NULL CHECK (updated_at >= 0)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE occurrences (
|
|
58
|
+
occurrence_id TEXT PRIMARY KEY NOT NULL,
|
|
59
|
+
job_id TEXT NOT NULL REFERENCES jobs(job_id) ON DELETE RESTRICT,
|
|
60
|
+
job_revision INTEGER NOT NULL CHECK (job_revision >= 1),
|
|
61
|
+
idempotency_key TEXT NOT NULL,
|
|
62
|
+
trigger_key TEXT NOT NULL,
|
|
63
|
+
scheduled_for INTEGER NOT NULL CHECK (scheduled_for >= 0),
|
|
64
|
+
available_at INTEGER NOT NULL CHECK (available_at >= 0),
|
|
65
|
+
status TEXT NOT NULL CHECK (
|
|
66
|
+
status IN ('queued', 'running', 'retry_wait', 'succeeded', 'dead_letter')
|
|
67
|
+
),
|
|
68
|
+
attempt INTEGER NOT NULL DEFAULT 0 CHECK (attempt >= 0),
|
|
69
|
+
max_attempts INTEGER NOT NULL CHECK (max_attempts BETWEEN 1 AND 32),
|
|
70
|
+
fence INTEGER NOT NULL DEFAULT 0 CHECK (fence >= 0),
|
|
71
|
+
lease_owner TEXT,
|
|
72
|
+
lease_expires_at INTEGER,
|
|
73
|
+
authority_json TEXT NOT NULL CHECK (json_valid(authority_json)),
|
|
74
|
+
payload_json TEXT NOT NULL CHECK (json_valid(payload_json)),
|
|
75
|
+
last_error_json TEXT CHECK (
|
|
76
|
+
last_error_json IS NULL OR json_valid(last_error_json)
|
|
77
|
+
),
|
|
78
|
+
result_json TEXT CHECK (result_json IS NULL OR json_valid(result_json)),
|
|
79
|
+
created_at INTEGER NOT NULL CHECK (created_at >= 0),
|
|
80
|
+
updated_at INTEGER NOT NULL CHECK (updated_at >= 0),
|
|
81
|
+
settled_at INTEGER,
|
|
82
|
+
CHECK (attempt <= max_attempts),
|
|
83
|
+
CHECK (
|
|
84
|
+
(status = 'running' AND lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL)
|
|
85
|
+
OR
|
|
86
|
+
(status <> 'running' AND lease_owner IS NULL AND lease_expires_at IS NULL)
|
|
87
|
+
),
|
|
88
|
+
CHECK (
|
|
89
|
+
(status IN ('succeeded', 'dead_letter') AND settled_at IS NOT NULL)
|
|
90
|
+
OR
|
|
91
|
+
(status NOT IN ('succeeded', 'dead_letter') AND settled_at IS NULL)
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
CREATE UNIQUE INDEX scheduler_occurrences_idempotency
|
|
96
|
+
ON occurrences(idempotency_key);
|
|
97
|
+
CREATE INDEX scheduler_occurrences_claim
|
|
98
|
+
ON occurrences(status, available_at, lease_expires_at, scheduled_for, occurrence_id);
|
|
99
|
+
CREATE INDEX scheduler_occurrences_job
|
|
100
|
+
ON occurrences(job_id, created_at, occurrence_id);
|
|
101
|
+
|
|
102
|
+
CREATE TABLE events (
|
|
103
|
+
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
104
|
+
job_id TEXT NOT NULL REFERENCES jobs(job_id) ON DELETE RESTRICT,
|
|
105
|
+
occurrence_id TEXT REFERENCES occurrences(occurrence_id) ON DELETE RESTRICT,
|
|
106
|
+
event_type TEXT NOT NULL,
|
|
107
|
+
occurred_at INTEGER NOT NULL CHECK (occurred_at >= 0),
|
|
108
|
+
owner_id TEXT,
|
|
109
|
+
fence INTEGER,
|
|
110
|
+
data_json TEXT NOT NULL CHECK (json_valid(data_json))
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
CREATE INDEX scheduler_events_occurrence
|
|
114
|
+
ON events(occurrence_id, event_id DESC);
|
|
115
|
+
CREATE INDEX scheduler_events_job
|
|
116
|
+
ON events(job_id, event_id DESC);
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
export const MIGRATION_V1_CHECKSUM = createHash("sha256")
|
|
120
|
+
.update(MIGRATION_V1_SQL.trim().replace(/\r\n/g, "\n"), "utf8")
|
|
121
|
+
.digest("hex");
|
|
122
|
+
|
|
123
|
+
// Fingerprint of the normalized sqlite_master catalog produced by the v1 DDL.
|
|
124
|
+
// Unlike the migration-source checksum, this also detects added triggers/views
|
|
125
|
+
// and constraint/foreign-key changes that preserve the visible column list.
|
|
126
|
+
export const SCHEMA_V1_FINGERPRINT =
|
|
127
|
+
"cf244f675ac7430683f67046b57524eb6c14baa45d6463ed3f704be898fec887";
|
|
128
|
+
|
|
129
|
+
const EXPECTED_COLUMNS = Object.freeze({
|
|
130
|
+
migrations: [
|
|
131
|
+
["version", "INTEGER", 1, 1],
|
|
132
|
+
["name", "TEXT", 1, 0],
|
|
133
|
+
["checksum", "TEXT", 1, 0],
|
|
134
|
+
["applied_at", "INTEGER", 1, 0],
|
|
135
|
+
],
|
|
136
|
+
jobs: [
|
|
137
|
+
["job_id", "TEXT", 1, 1],
|
|
138
|
+
["kind", "TEXT", 1, 0],
|
|
139
|
+
["trigger_json", "TEXT", 1, 0],
|
|
140
|
+
["payload_json", "TEXT", 1, 0],
|
|
141
|
+
["authority_json", "TEXT", 1, 0],
|
|
142
|
+
["enabled", "INTEGER", 1, 0],
|
|
143
|
+
["revision", "INTEGER", 1, 0],
|
|
144
|
+
["max_attempts", "INTEGER", 1, 0],
|
|
145
|
+
["created_at", "INTEGER", 1, 0],
|
|
146
|
+
["updated_at", "INTEGER", 1, 0],
|
|
147
|
+
],
|
|
148
|
+
occurrences: [
|
|
149
|
+
["occurrence_id", "TEXT", 1, 1],
|
|
150
|
+
["job_id", "TEXT", 1, 0],
|
|
151
|
+
["job_revision", "INTEGER", 1, 0],
|
|
152
|
+
["idempotency_key", "TEXT", 1, 0],
|
|
153
|
+
["trigger_key", "TEXT", 1, 0],
|
|
154
|
+
["scheduled_for", "INTEGER", 1, 0],
|
|
155
|
+
["available_at", "INTEGER", 1, 0],
|
|
156
|
+
["status", "TEXT", 1, 0],
|
|
157
|
+
["attempt", "INTEGER", 1, 0],
|
|
158
|
+
["max_attempts", "INTEGER", 1, 0],
|
|
159
|
+
["fence", "INTEGER", 1, 0],
|
|
160
|
+
["lease_owner", "TEXT", 0, 0],
|
|
161
|
+
["lease_expires_at", "INTEGER", 0, 0],
|
|
162
|
+
["authority_json", "TEXT", 1, 0],
|
|
163
|
+
["payload_json", "TEXT", 1, 0],
|
|
164
|
+
["last_error_json", "TEXT", 0, 0],
|
|
165
|
+
["result_json", "TEXT", 0, 0],
|
|
166
|
+
["created_at", "INTEGER", 1, 0],
|
|
167
|
+
["updated_at", "INTEGER", 1, 0],
|
|
168
|
+
["settled_at", "INTEGER", 0, 0],
|
|
169
|
+
],
|
|
170
|
+
events: [
|
|
171
|
+
["event_id", "INTEGER", 0, 1],
|
|
172
|
+
["job_id", "TEXT", 1, 0],
|
|
173
|
+
["occurrence_id", "TEXT", 0, 0],
|
|
174
|
+
["event_type", "TEXT", 1, 0],
|
|
175
|
+
["occurred_at", "INTEGER", 1, 0],
|
|
176
|
+
["owner_id", "TEXT", 0, 0],
|
|
177
|
+
["fence", "INTEGER", 0, 0],
|
|
178
|
+
["data_json", "TEXT", 1, 0],
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const EXPECTED_INDEXES = Object.freeze([
|
|
183
|
+
"scheduler_events_job",
|
|
184
|
+
"scheduler_events_occurrence",
|
|
185
|
+
"scheduler_occurrences_claim",
|
|
186
|
+
"scheduler_occurrences_idempotency",
|
|
187
|
+
"scheduler_occurrences_job",
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
function schemaError(code, message, cause = undefined, details = undefined) {
|
|
191
|
+
return new SchedulerKernelError(
|
|
192
|
+
code,
|
|
193
|
+
message,
|
|
194
|
+
details,
|
|
195
|
+
cause ? { cause } : undefined,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function assertPositiveSafeInteger(value, field) {
|
|
200
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
201
|
+
throw invalidArgument(`${field} must be a positive integer`);
|
|
202
|
+
}
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function normalizeLeaseMs(value) {
|
|
207
|
+
const leaseMs = assertPositiveSafeInteger(value, "leaseMs");
|
|
208
|
+
if (leaseMs > MAX_LEASE_MS) {
|
|
209
|
+
throw invalidArgument(`leaseMs must not exceed ${MAX_LEASE_MS}`);
|
|
210
|
+
}
|
|
211
|
+
return leaseMs;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function normalizeBoolean(value, field) {
|
|
215
|
+
if (typeof value !== "boolean") {
|
|
216
|
+
throw invalidArgument(`${field} must be a boolean`);
|
|
217
|
+
}
|
|
218
|
+
return value;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function normalizeJobInput(input) {
|
|
222
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
223
|
+
throw invalidArgument("job must be an object");
|
|
224
|
+
}
|
|
225
|
+
const allowed = new Set([
|
|
226
|
+
"id",
|
|
227
|
+
"kind",
|
|
228
|
+
"trigger",
|
|
229
|
+
"payload",
|
|
230
|
+
"authority",
|
|
231
|
+
"enabled",
|
|
232
|
+
"maxAttempts",
|
|
233
|
+
]);
|
|
234
|
+
const unknown = Object.keys(input).filter((key) => !allowed.has(key));
|
|
235
|
+
if (unknown.length > 0) {
|
|
236
|
+
throw invalidArgument("job contains unknown fields", {
|
|
237
|
+
fields: unknown.sort(),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
id: normalizeIdentifier(input.id, "job.id"),
|
|
242
|
+
kind: normalizeIdentifier(input.kind, "job.kind", { maxLength: 128 }),
|
|
243
|
+
trigger: normalizeJson(input.trigger ?? {}, "job.trigger"),
|
|
244
|
+
payload: normalizeJson(input.payload ?? null, "job.payload"),
|
|
245
|
+
authority: normalizeAuthorityEnvelope(input.authority),
|
|
246
|
+
enabled:
|
|
247
|
+
input.enabled === undefined
|
|
248
|
+
? true
|
|
249
|
+
: normalizeBoolean(input.enabled, "job.enabled"),
|
|
250
|
+
maxAttempts: normalizeMaxAttempts(input.maxAttempts),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function normalizeJobPatch(patch) {
|
|
255
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
|
256
|
+
throw invalidArgument("job patch must be an object");
|
|
257
|
+
}
|
|
258
|
+
const allowed = new Set([
|
|
259
|
+
"kind",
|
|
260
|
+
"trigger",
|
|
261
|
+
"payload",
|
|
262
|
+
"authority",
|
|
263
|
+
"enabled",
|
|
264
|
+
"maxAttempts",
|
|
265
|
+
]);
|
|
266
|
+
const keys = Object.keys(patch);
|
|
267
|
+
const unknown = keys.filter((key) => !allowed.has(key));
|
|
268
|
+
if (unknown.length > 0) {
|
|
269
|
+
throw invalidArgument("job patch contains unknown fields", {
|
|
270
|
+
fields: unknown.sort(),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
if (keys.length === 0) throw invalidArgument("job patch must not be empty");
|
|
274
|
+
const normalized = {};
|
|
275
|
+
if (Object.hasOwn(patch, "kind")) {
|
|
276
|
+
normalized.kind = normalizeIdentifier(patch.kind, "job.kind", {
|
|
277
|
+
maxLength: 128,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
if (Object.hasOwn(patch, "trigger")) {
|
|
281
|
+
normalized.trigger = normalizeJson(patch.trigger, "job.trigger");
|
|
282
|
+
}
|
|
283
|
+
if (Object.hasOwn(patch, "payload")) {
|
|
284
|
+
normalized.payload = normalizeJson(patch.payload, "job.payload");
|
|
285
|
+
}
|
|
286
|
+
if (Object.hasOwn(patch, "authority")) {
|
|
287
|
+
normalized.authority = normalizeAuthorityEnvelope(patch.authority);
|
|
288
|
+
}
|
|
289
|
+
if (Object.hasOwn(patch, "enabled")) {
|
|
290
|
+
normalized.enabled = normalizeBoolean(patch.enabled, "job.enabled");
|
|
291
|
+
}
|
|
292
|
+
if (Object.hasOwn(patch, "maxAttempts")) {
|
|
293
|
+
normalized.maxAttempts = normalizeMaxAttempts(patch.maxAttempts);
|
|
294
|
+
}
|
|
295
|
+
return { normalized, changedFields: keys.sort() };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function readStoredJson(encoded, field) {
|
|
299
|
+
try {
|
|
300
|
+
return JSON.parse(encoded);
|
|
301
|
+
} catch (cause) {
|
|
302
|
+
throw schemaError(
|
|
303
|
+
"SCHEDULER_DATA_CORRUPT",
|
|
304
|
+
`Stored ${field} is not valid JSON`,
|
|
305
|
+
cause,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function readStoredAuthority(encoded, field) {
|
|
311
|
+
try {
|
|
312
|
+
return normalizeAuthorityEnvelope(readStoredJson(encoded, field));
|
|
313
|
+
} catch (cause) {
|
|
314
|
+
if (cause?.code === "SCHEDULER_DATA_CORRUPT") throw cause;
|
|
315
|
+
throw schemaError(
|
|
316
|
+
"SCHEDULER_DATA_CORRUPT",
|
|
317
|
+
`Stored ${field} does not match authority envelope v1`,
|
|
318
|
+
cause,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function mapJob(row) {
|
|
324
|
+
if (!row) return null;
|
|
325
|
+
return {
|
|
326
|
+
id: row.job_id,
|
|
327
|
+
kind: row.kind,
|
|
328
|
+
trigger: readStoredJson(row.trigger_json, "job trigger"),
|
|
329
|
+
payload: readStoredJson(row.payload_json, "job payload"),
|
|
330
|
+
authority: readStoredAuthority(row.authority_json, "job authority"),
|
|
331
|
+
enabled: row.enabled === 1,
|
|
332
|
+
revision: row.revision,
|
|
333
|
+
maxAttempts: row.max_attempts,
|
|
334
|
+
createdAt: row.created_at,
|
|
335
|
+
updatedAt: row.updated_at,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function mapOccurrence(row) {
|
|
340
|
+
if (!row) return null;
|
|
341
|
+
return {
|
|
342
|
+
id: row.occurrence_id,
|
|
343
|
+
jobId: row.job_id,
|
|
344
|
+
jobRevision: row.job_revision,
|
|
345
|
+
idempotencyKey: row.idempotency_key,
|
|
346
|
+
triggerKey: row.trigger_key,
|
|
347
|
+
scheduledFor: row.scheduled_for,
|
|
348
|
+
availableAt: row.available_at,
|
|
349
|
+
status: row.status,
|
|
350
|
+
attempt: row.attempt,
|
|
351
|
+
maxAttempts: row.max_attempts,
|
|
352
|
+
fence: row.fence,
|
|
353
|
+
leaseOwner: row.lease_owner,
|
|
354
|
+
leaseExpiresAt: row.lease_expires_at,
|
|
355
|
+
authority: readStoredAuthority(row.authority_json, "occurrence authority"),
|
|
356
|
+
payload: readStoredJson(row.payload_json, "occurrence payload"),
|
|
357
|
+
lastError:
|
|
358
|
+
row.last_error_json === null
|
|
359
|
+
? null
|
|
360
|
+
: readStoredJson(row.last_error_json, "occurrence error"),
|
|
361
|
+
result:
|
|
362
|
+
row.result_json === null
|
|
363
|
+
? null
|
|
364
|
+
: readStoredJson(row.result_json, "occurrence result"),
|
|
365
|
+
createdAt: row.created_at,
|
|
366
|
+
updatedAt: row.updated_at,
|
|
367
|
+
settledAt: row.settled_at,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function mapEvent(row) {
|
|
372
|
+
return {
|
|
373
|
+
eventId: row.event_id,
|
|
374
|
+
jobId: row.job_id,
|
|
375
|
+
occurrenceId: row.occurrence_id,
|
|
376
|
+
type: row.event_type,
|
|
377
|
+
occurredAt: row.occurred_at,
|
|
378
|
+
ownerId: row.owner_id,
|
|
379
|
+
fence: row.fence,
|
|
380
|
+
data: readStoredJson(row.data_json, "event data"),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function listUserTables(db) {
|
|
385
|
+
return db
|
|
386
|
+
.prepare(
|
|
387
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
|
388
|
+
)
|
|
389
|
+
.all()
|
|
390
|
+
.map((row) => row.name);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function assertDatabaseIntegrity(db) {
|
|
394
|
+
let rows;
|
|
395
|
+
try {
|
|
396
|
+
rows = db.pragma("quick_check(1)");
|
|
397
|
+
} catch (cause) {
|
|
398
|
+
throw schemaError(
|
|
399
|
+
"SCHEDULER_SCHEMA_CORRUPT",
|
|
400
|
+
"Scheduler database integrity check could not run",
|
|
401
|
+
cause,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
if (
|
|
405
|
+
!Array.isArray(rows) ||
|
|
406
|
+
rows.length !== 1 ||
|
|
407
|
+
rows[0]?.quick_check !== "ok"
|
|
408
|
+
) {
|
|
409
|
+
throw schemaError(
|
|
410
|
+
"SCHEDULER_SCHEMA_CORRUPT",
|
|
411
|
+
"Scheduler database failed SQLite quick_check",
|
|
412
|
+
undefined,
|
|
413
|
+
{ result: rows },
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function verifyTableShape(db, table) {
|
|
419
|
+
const actual = db
|
|
420
|
+
.pragma(`table_info(${JSON.stringify(table)})`)
|
|
421
|
+
.map((column) => [
|
|
422
|
+
column.name,
|
|
423
|
+
String(column.type || "").toUpperCase(),
|
|
424
|
+
column.notnull,
|
|
425
|
+
column.pk,
|
|
426
|
+
]);
|
|
427
|
+
const expected = EXPECTED_COLUMNS[table];
|
|
428
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
429
|
+
throw schemaError(
|
|
430
|
+
"SCHEDULER_SCHEMA_CORRUPT",
|
|
431
|
+
`Scheduler table shape does not match v1: ${table}`,
|
|
432
|
+
undefined,
|
|
433
|
+
{ expected, actual },
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function schemaFingerprint(db) {
|
|
439
|
+
const catalog = db
|
|
440
|
+
.prepare(
|
|
441
|
+
"SELECT type, name, tbl_name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name",
|
|
442
|
+
)
|
|
443
|
+
.all()
|
|
444
|
+
.map((row) => ({
|
|
445
|
+
type: row.type,
|
|
446
|
+
name: row.name,
|
|
447
|
+
tbl_name: row.tbl_name,
|
|
448
|
+
sql: String(row.sql || "")
|
|
449
|
+
.replace(/\s+/g, " ")
|
|
450
|
+
.trim(),
|
|
451
|
+
}));
|
|
452
|
+
return createHash("sha256")
|
|
453
|
+
.update(JSON.stringify(catalog), "utf8")
|
|
454
|
+
.digest("hex");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function verifySchema(db) {
|
|
458
|
+
assertDatabaseIntegrity(db);
|
|
459
|
+
const tables = listUserTables(db);
|
|
460
|
+
if (JSON.stringify(tables) !== JSON.stringify(USER_TABLES)) {
|
|
461
|
+
throw schemaError(
|
|
462
|
+
"SCHEDULER_SCHEMA_UNKNOWN",
|
|
463
|
+
"Scheduler database has unknown or missing tables",
|
|
464
|
+
undefined,
|
|
465
|
+
{ expected: USER_TABLES, actual: tables },
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let applicationId;
|
|
470
|
+
let userVersion;
|
|
471
|
+
try {
|
|
472
|
+
applicationId = db.pragma("application_id", { simple: true });
|
|
473
|
+
userVersion = db.pragma("user_version", { simple: true });
|
|
474
|
+
} catch (cause) {
|
|
475
|
+
throw schemaError(
|
|
476
|
+
"SCHEDULER_SCHEMA_CORRUPT",
|
|
477
|
+
"Scheduler schema metadata could not be read",
|
|
478
|
+
cause,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
if (
|
|
482
|
+
applicationId !== SCHEDULER_APPLICATION_ID ||
|
|
483
|
+
userVersion !== SCHEDULER_SCHEMA_VERSION
|
|
484
|
+
) {
|
|
485
|
+
throw schemaError(
|
|
486
|
+
"SCHEDULER_SCHEMA_UNKNOWN",
|
|
487
|
+
"Scheduler schema version or application identity is unknown",
|
|
488
|
+
undefined,
|
|
489
|
+
{ applicationId, userVersion },
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
for (const table of USER_TABLES) verifyTableShape(db, table);
|
|
494
|
+
|
|
495
|
+
const actualFingerprint = schemaFingerprint(db);
|
|
496
|
+
if (actualFingerprint !== SCHEMA_V1_FINGERPRINT) {
|
|
497
|
+
throw schemaError(
|
|
498
|
+
"SCHEDULER_SCHEMA_CORRUPT",
|
|
499
|
+
"Scheduler sqlite_master catalog does not match v1",
|
|
500
|
+
undefined,
|
|
501
|
+
{
|
|
502
|
+
expected: SCHEMA_V1_FINGERPRINT,
|
|
503
|
+
actual: actualFingerprint,
|
|
504
|
+
},
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let migrations;
|
|
509
|
+
try {
|
|
510
|
+
migrations = db
|
|
511
|
+
.prepare(
|
|
512
|
+
"SELECT version, name, checksum FROM migrations ORDER BY version",
|
|
513
|
+
)
|
|
514
|
+
.all();
|
|
515
|
+
} catch (cause) {
|
|
516
|
+
throw schemaError(
|
|
517
|
+
"SCHEDULER_SCHEMA_CORRUPT",
|
|
518
|
+
"Scheduler migration history could not be read",
|
|
519
|
+
cause,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
if (
|
|
523
|
+
migrations.length !== 1 ||
|
|
524
|
+
migrations[0].version !== SCHEDULER_SCHEMA_VERSION ||
|
|
525
|
+
migrations[0].name !== MIGRATION_NAME ||
|
|
526
|
+
migrations[0].checksum !== MIGRATION_V1_CHECKSUM
|
|
527
|
+
) {
|
|
528
|
+
throw schemaError(
|
|
529
|
+
"SCHEDULER_SCHEMA_UNKNOWN",
|
|
530
|
+
"Scheduler migration history is unknown",
|
|
531
|
+
undefined,
|
|
532
|
+
{ migrations },
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const indexes = db
|
|
537
|
+
.prepare(
|
|
538
|
+
"SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'scheduler_%' ORDER BY name",
|
|
539
|
+
)
|
|
540
|
+
.all()
|
|
541
|
+
.map((row) => row.name);
|
|
542
|
+
if (JSON.stringify(indexes) !== JSON.stringify(EXPECTED_INDEXES)) {
|
|
543
|
+
throw schemaError(
|
|
544
|
+
"SCHEDULER_SCHEMA_CORRUPT",
|
|
545
|
+
"Scheduler indexes do not match v1",
|
|
546
|
+
undefined,
|
|
547
|
+
{ expected: EXPECTED_INDEXES, actual: indexes },
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
const foreignKeyFailures = db.pragma("foreign_key_check");
|
|
551
|
+
if (foreignKeyFailures.length > 0) {
|
|
552
|
+
throw schemaError(
|
|
553
|
+
"SCHEDULER_SCHEMA_CORRUPT",
|
|
554
|
+
"Scheduler database contains broken foreign keys",
|
|
555
|
+
undefined,
|
|
556
|
+
{ count: foreignKeyFailures.length },
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function initializeOrVerifySchema(db, now) {
|
|
562
|
+
assertDatabaseIntegrity(db);
|
|
563
|
+
const tables = listUserTables(db);
|
|
564
|
+
if (tables.length === 0) {
|
|
565
|
+
const migrate = db.transaction(() => {
|
|
566
|
+
db.exec(MIGRATION_V1_SQL);
|
|
567
|
+
db.prepare(
|
|
568
|
+
"INSERT INTO migrations (version, name, checksum, applied_at) VALUES (?, ?, ?, ?)",
|
|
569
|
+
).run(
|
|
570
|
+
SCHEDULER_SCHEMA_VERSION,
|
|
571
|
+
MIGRATION_NAME,
|
|
572
|
+
MIGRATION_V1_CHECKSUM,
|
|
573
|
+
now,
|
|
574
|
+
);
|
|
575
|
+
db.pragma(`application_id = ${SCHEDULER_APPLICATION_ID}`);
|
|
576
|
+
db.pragma(`user_version = ${SCHEDULER_SCHEMA_VERSION}`);
|
|
577
|
+
});
|
|
578
|
+
migrate.immediate();
|
|
579
|
+
} else if (!tables.includes("migrations")) {
|
|
580
|
+
throw schemaError(
|
|
581
|
+
"SCHEDULER_SCHEMA_UNKNOWN",
|
|
582
|
+
"Refusing a non-empty database without scheduler migration history",
|
|
583
|
+
undefined,
|
|
584
|
+
{ tables },
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
verifySchema(db);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function defaultDatabasePath() {
|
|
591
|
+
return join(getHomeDir(), "scheduler", "kernel-v1.sqlite");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function loadDatabaseConstructor() {
|
|
595
|
+
try {
|
|
596
|
+
return requireCjs("better-sqlite3");
|
|
597
|
+
} catch (cause) {
|
|
598
|
+
throw schemaError(
|
|
599
|
+
"SCHEDULER_SQLITE_UNAVAILABLE",
|
|
600
|
+
"Scheduler kernel requires the existing optional better-sqlite3 dependency",
|
|
601
|
+
cause,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function sqliteStorageFiles(target) {
|
|
607
|
+
return [target, `${target}-journal`, `${target}-wal`, `${target}-shm`];
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function openDatabase({ file, Database, busyTimeoutMs, clock }) {
|
|
611
|
+
const target =
|
|
612
|
+
file === ":memory:" ? file : resolve(file || defaultDatabasePath());
|
|
613
|
+
const timeout = Number(busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS);
|
|
614
|
+
if (!Number.isSafeInteger(timeout) || timeout < 0 || timeout > 60_000) {
|
|
615
|
+
throw invalidArgument(
|
|
616
|
+
"busyTimeoutMs must be an integer between 0 and 60000",
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
const now = normalizeEpochMs(clock(), "clock result");
|
|
620
|
+
if (target !== ":memory:") {
|
|
621
|
+
ensurePrivateDirectory(dirname(target));
|
|
622
|
+
// Existing WAL/SHM files participate in the next SQLite open, so validate
|
|
623
|
+
// them before the native driver consumes either sidecar.
|
|
624
|
+
for (const candidate of sqliteStorageFiles(target)) {
|
|
625
|
+
if (existsSync(candidate)) ensurePrivateFile(candidate);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const DatabaseConstructor = Database || loadDatabaseConstructor();
|
|
630
|
+
let db;
|
|
631
|
+
try {
|
|
632
|
+
db = new DatabaseConstructor(target, { timeout });
|
|
633
|
+
db.pragma("foreign_keys = ON");
|
|
634
|
+
db.pragma("trusted_schema = OFF");
|
|
635
|
+
db.pragma(`busy_timeout = ${timeout}`);
|
|
636
|
+
initializeOrVerifySchema(db, now);
|
|
637
|
+
if (target !== ":memory:") db.pragma("journal_mode = WAL");
|
|
638
|
+
db.pragma("synchronous = FULL");
|
|
639
|
+
} catch (cause) {
|
|
640
|
+
try {
|
|
641
|
+
db?.close();
|
|
642
|
+
} catch {
|
|
643
|
+
// Preserve the authoritative open/schema failure.
|
|
644
|
+
}
|
|
645
|
+
if (cause instanceof SchedulerKernelError) throw cause;
|
|
646
|
+
throw schemaError(
|
|
647
|
+
"SCHEDULER_SCHEMA_CORRUPT",
|
|
648
|
+
"Scheduler database could not be opened or verified",
|
|
649
|
+
cause,
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (target !== ":memory:") {
|
|
654
|
+
// A quiet WAL database may not have materialized sidecars yet. On Windows,
|
|
655
|
+
// asking secure-fs to classify a missing path deliberately falls back to a
|
|
656
|
+
// native ACL preflight. Only inspect files that SQLite actually created;
|
|
657
|
+
// later opens repeat this check for any surviving sidecars.
|
|
658
|
+
for (const candidate of sqliteStorageFiles(target)) {
|
|
659
|
+
if (existsSync(candidate)) ensurePrivateFile(candidate);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return { db, target };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function leaseLost(occurrenceId) {
|
|
666
|
+
return new SchedulerKernelError(
|
|
667
|
+
"SCHEDULER_LEASE_LOST",
|
|
668
|
+
`Occurrence lease is absent, expired, or owned by another fence: ${occurrenceId}`,
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export class SchedulerStore {
|
|
673
|
+
constructor(db, { file, clock }) {
|
|
674
|
+
this.db = db;
|
|
675
|
+
this.file = file;
|
|
676
|
+
this.clock = clock;
|
|
677
|
+
this.closed = false;
|
|
678
|
+
this.statements = {
|
|
679
|
+
getJob: db.prepare("SELECT * FROM jobs WHERE job_id = ?"),
|
|
680
|
+
getOccurrence: db.prepare(
|
|
681
|
+
"SELECT * FROM occurrences WHERE occurrence_id = ?",
|
|
682
|
+
),
|
|
683
|
+
getOccurrenceByKey: db.prepare(
|
|
684
|
+
"SELECT * FROM occurrences WHERE idempotency_key = ?",
|
|
685
|
+
),
|
|
686
|
+
insertEvent: db.prepare(`
|
|
687
|
+
INSERT INTO events
|
|
688
|
+
(job_id, occurrence_id, event_type, occurred_at, owner_id, fence, data_json)
|
|
689
|
+
VALUES
|
|
690
|
+
(@jobId, @occurrenceId, @type, @occurredAt, @ownerId, @fence, @dataJson)
|
|
691
|
+
`),
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
_assertOpen() {
|
|
696
|
+
if (this.closed) {
|
|
697
|
+
throw new SchedulerKernelError(
|
|
698
|
+
"SCHEDULER_STORE_CLOSED",
|
|
699
|
+
"Scheduler store is closed",
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
_now() {
|
|
705
|
+
return normalizeEpochMs(this.clock(), "clock result");
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
_write(callback) {
|
|
709
|
+
this._assertOpen();
|
|
710
|
+
const transaction = this.db.transaction(callback);
|
|
711
|
+
return transaction.immediate();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
_appendEvent({
|
|
715
|
+
jobId,
|
|
716
|
+
occurrenceId = null,
|
|
717
|
+
type,
|
|
718
|
+
occurredAt,
|
|
719
|
+
ownerId = null,
|
|
720
|
+
fence = null,
|
|
721
|
+
data = {},
|
|
722
|
+
}) {
|
|
723
|
+
this.statements.insertEvent.run({
|
|
724
|
+
jobId,
|
|
725
|
+
occurrenceId,
|
|
726
|
+
type,
|
|
727
|
+
occurredAt,
|
|
728
|
+
ownerId,
|
|
729
|
+
fence,
|
|
730
|
+
dataJson: canonicalJson(data, "event.data"),
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
createJob(input) {
|
|
735
|
+
const job = normalizeJobInput(input);
|
|
736
|
+
const now = this._now();
|
|
737
|
+
return this._write(() => {
|
|
738
|
+
if (this.statements.getJob.get(job.id)) {
|
|
739
|
+
throw new SchedulerKernelError(
|
|
740
|
+
"SCHEDULER_CONFLICT",
|
|
741
|
+
`Scheduler job already exists: ${job.id}`,
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
this.db
|
|
745
|
+
.prepare(
|
|
746
|
+
`
|
|
747
|
+
INSERT INTO jobs
|
|
748
|
+
(job_id, kind, trigger_json, payload_json, authority_json,
|
|
749
|
+
enabled, revision, max_attempts, created_at, updated_at)
|
|
750
|
+
VALUES
|
|
751
|
+
(@id, @kind, @triggerJson, @payloadJson, @authorityJson,
|
|
752
|
+
@enabled, 1, @maxAttempts, @now, @now)
|
|
753
|
+
`,
|
|
754
|
+
)
|
|
755
|
+
.run({
|
|
756
|
+
id: job.id,
|
|
757
|
+
kind: job.kind,
|
|
758
|
+
triggerJson: canonicalJson(job.trigger, "job.trigger"),
|
|
759
|
+
payloadJson: canonicalJson(job.payload, "job.payload"),
|
|
760
|
+
authorityJson: canonicalJson(job.authority, "job.authority"),
|
|
761
|
+
enabled: job.enabled ? 1 : 0,
|
|
762
|
+
maxAttempts: job.maxAttempts,
|
|
763
|
+
now,
|
|
764
|
+
});
|
|
765
|
+
this._appendEvent({
|
|
766
|
+
jobId: job.id,
|
|
767
|
+
type: "job_created",
|
|
768
|
+
occurredAt: now,
|
|
769
|
+
data: { revision: 1 },
|
|
770
|
+
});
|
|
771
|
+
return mapJob(this.statements.getJob.get(job.id));
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
getJob(jobId) {
|
|
776
|
+
this._assertOpen();
|
|
777
|
+
return mapJob(
|
|
778
|
+
this.statements.getJob.get(normalizeIdentifier(jobId, "jobId")),
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
updateJob(jobId, expectedRevision, patch) {
|
|
783
|
+
const id = normalizeIdentifier(jobId, "jobId");
|
|
784
|
+
const revision = assertPositiveSafeInteger(
|
|
785
|
+
expectedRevision,
|
|
786
|
+
"expectedRevision",
|
|
787
|
+
);
|
|
788
|
+
const { normalized, changedFields } = normalizeJobPatch(patch);
|
|
789
|
+
const now = this._now();
|
|
790
|
+
return this._write(() => {
|
|
791
|
+
const current = mapJob(this.statements.getJob.get(id));
|
|
792
|
+
if (!current) {
|
|
793
|
+
throw new SchedulerKernelError(
|
|
794
|
+
"SCHEDULER_NOT_FOUND",
|
|
795
|
+
`Scheduler job does not exist: ${id}`,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
if (current.revision !== revision) {
|
|
799
|
+
throw new SchedulerKernelError(
|
|
800
|
+
"SCHEDULER_REVISION_CONFLICT",
|
|
801
|
+
`Scheduler job revision changed: ${id}`,
|
|
802
|
+
{ expectedRevision: revision, actualRevision: current.revision },
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
const next = {
|
|
806
|
+
...current,
|
|
807
|
+
...normalized,
|
|
808
|
+
revision: current.revision + 1,
|
|
809
|
+
updatedAt: now,
|
|
810
|
+
};
|
|
811
|
+
const result = this.db
|
|
812
|
+
.prepare(
|
|
813
|
+
`
|
|
814
|
+
UPDATE jobs
|
|
815
|
+
SET kind = @kind,
|
|
816
|
+
trigger_json = @triggerJson,
|
|
817
|
+
payload_json = @payloadJson,
|
|
818
|
+
authority_json = @authorityJson,
|
|
819
|
+
enabled = @enabled,
|
|
820
|
+
revision = @nextRevision,
|
|
821
|
+
max_attempts = @maxAttempts,
|
|
822
|
+
updated_at = @now
|
|
823
|
+
WHERE job_id = @id AND revision = @expectedRevision
|
|
824
|
+
`,
|
|
825
|
+
)
|
|
826
|
+
.run({
|
|
827
|
+
id,
|
|
828
|
+
kind: next.kind,
|
|
829
|
+
triggerJson: canonicalJson(next.trigger, "job.trigger"),
|
|
830
|
+
payloadJson: canonicalJson(next.payload, "job.payload"),
|
|
831
|
+
authorityJson: canonicalJson(next.authority, "job.authority"),
|
|
832
|
+
enabled: next.enabled ? 1 : 0,
|
|
833
|
+
nextRevision: next.revision,
|
|
834
|
+
maxAttempts: next.maxAttempts,
|
|
835
|
+
now,
|
|
836
|
+
expectedRevision: revision,
|
|
837
|
+
});
|
|
838
|
+
if (result.changes !== 1) {
|
|
839
|
+
throw new SchedulerKernelError(
|
|
840
|
+
"SCHEDULER_REVISION_CONFLICT",
|
|
841
|
+
`Scheduler job revision changed during update: ${id}`,
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
this._appendEvent({
|
|
845
|
+
jobId: id,
|
|
846
|
+
type: "job_updated",
|
|
847
|
+
occurredAt: now,
|
|
848
|
+
data: {
|
|
849
|
+
fromRevision: revision,
|
|
850
|
+
toRevision: next.revision,
|
|
851
|
+
changedFields,
|
|
852
|
+
},
|
|
853
|
+
});
|
|
854
|
+
return mapJob(this.statements.getJob.get(id));
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
enqueueOccurrence({ jobId, scheduledFor, triggerKey, availableAt } = {}) {
|
|
859
|
+
const id = normalizeIdentifier(jobId, "jobId");
|
|
860
|
+
const scheduled = normalizeEpochMs(scheduledFor, "scheduledFor");
|
|
861
|
+
const key = normalizeIdentifier(triggerKey, "triggerKey");
|
|
862
|
+
const available = normalizeEpochMs(availableAt ?? scheduled, "availableAt");
|
|
863
|
+
const now = this._now();
|
|
864
|
+
return this._write(() => {
|
|
865
|
+
const job = mapJob(this.statements.getJob.get(id));
|
|
866
|
+
if (!job) {
|
|
867
|
+
throw new SchedulerKernelError(
|
|
868
|
+
"SCHEDULER_NOT_FOUND",
|
|
869
|
+
`Scheduler job does not exist: ${id}`,
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
if (!job.enabled) {
|
|
873
|
+
throw new SchedulerKernelError(
|
|
874
|
+
"SCHEDULER_JOB_DISABLED",
|
|
875
|
+
`Scheduler job is disabled: ${id}`,
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
const identity = deriveOccurrenceIdentity({
|
|
879
|
+
jobId: id,
|
|
880
|
+
jobRevision: job.revision,
|
|
881
|
+
scheduledFor: scheduled,
|
|
882
|
+
triggerKey: key,
|
|
883
|
+
});
|
|
884
|
+
const result = this.db
|
|
885
|
+
.prepare(
|
|
886
|
+
`
|
|
887
|
+
INSERT INTO occurrences
|
|
888
|
+
(occurrence_id, job_id, job_revision, idempotency_key, trigger_key,
|
|
889
|
+
scheduled_for, available_at, status, attempt, max_attempts, fence,
|
|
890
|
+
lease_owner, lease_expires_at, authority_json, payload_json,
|
|
891
|
+
last_error_json, result_json, created_at, updated_at, settled_at)
|
|
892
|
+
VALUES
|
|
893
|
+
(@occurrenceId, @jobId, @jobRevision, @idempotencyKey, @triggerKey,
|
|
894
|
+
@scheduledFor, @availableAt, 'queued', 0, @maxAttempts, 0,
|
|
895
|
+
NULL, NULL, @authorityJson, @payloadJson,
|
|
896
|
+
NULL, NULL, @now, @now, NULL)
|
|
897
|
+
ON CONFLICT(idempotency_key) DO NOTHING
|
|
898
|
+
`,
|
|
899
|
+
)
|
|
900
|
+
.run({
|
|
901
|
+
...identity,
|
|
902
|
+
jobId: id,
|
|
903
|
+
jobRevision: job.revision,
|
|
904
|
+
triggerKey: key,
|
|
905
|
+
scheduledFor: scheduled,
|
|
906
|
+
availableAt: available,
|
|
907
|
+
maxAttempts: job.maxAttempts,
|
|
908
|
+
authorityJson: canonicalJson(job.authority, "occurrence.authority"),
|
|
909
|
+
payloadJson: canonicalJson(job.payload, "occurrence.payload"),
|
|
910
|
+
now,
|
|
911
|
+
});
|
|
912
|
+
const row = this.statements.getOccurrenceByKey.get(
|
|
913
|
+
identity.idempotencyKey,
|
|
914
|
+
);
|
|
915
|
+
if (!row || row.occurrence_id !== identity.occurrenceId) {
|
|
916
|
+
throw schemaError(
|
|
917
|
+
"SCHEDULER_DATA_CORRUPT",
|
|
918
|
+
"Occurrence idempotency identity does not match stored data",
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
if (result.changes === 1) {
|
|
922
|
+
this._appendEvent({
|
|
923
|
+
jobId: id,
|
|
924
|
+
occurrenceId: identity.occurrenceId,
|
|
925
|
+
type: "occurrence_enqueued",
|
|
926
|
+
occurredAt: now,
|
|
927
|
+
data: {
|
|
928
|
+
jobRevision: job.revision,
|
|
929
|
+
scheduledFor: scheduled,
|
|
930
|
+
triggerKey: key,
|
|
931
|
+
},
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
return { ...mapOccurrence(row), deduplicated: result.changes === 0 };
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
getOccurrence(occurrenceId) {
|
|
939
|
+
this._assertOpen();
|
|
940
|
+
return mapOccurrence(
|
|
941
|
+
this.statements.getOccurrence.get(
|
|
942
|
+
normalizeIdentifier(occurrenceId, "occurrenceId"),
|
|
943
|
+
),
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
_deadLetterExpiredLeases(now) {
|
|
948
|
+
const expired = this.db
|
|
949
|
+
.prepare(
|
|
950
|
+
`
|
|
951
|
+
SELECT occurrence_id, job_id, lease_owner, fence, attempt, max_attempts
|
|
952
|
+
FROM occurrences
|
|
953
|
+
WHERE status = 'running'
|
|
954
|
+
AND lease_expires_at <= ?
|
|
955
|
+
AND attempt >= max_attempts
|
|
956
|
+
ORDER BY lease_expires_at, occurrence_id
|
|
957
|
+
LIMIT 100
|
|
958
|
+
`,
|
|
959
|
+
)
|
|
960
|
+
.all(now);
|
|
961
|
+
const update = this.db.prepare(`
|
|
962
|
+
UPDATE occurrences
|
|
963
|
+
SET status = 'dead_letter',
|
|
964
|
+
lease_owner = NULL,
|
|
965
|
+
lease_expires_at = NULL,
|
|
966
|
+
last_error_json = @errorJson,
|
|
967
|
+
updated_at = @now,
|
|
968
|
+
settled_at = @now
|
|
969
|
+
WHERE occurrence_id = @occurrenceId
|
|
970
|
+
AND status = 'running'
|
|
971
|
+
AND lease_owner = @ownerId
|
|
972
|
+
AND fence = @fence
|
|
973
|
+
AND lease_expires_at <= @now
|
|
974
|
+
AND attempt >= max_attempts
|
|
975
|
+
`);
|
|
976
|
+
for (const row of expired) {
|
|
977
|
+
const error = {
|
|
978
|
+
code: "lease_expired",
|
|
979
|
+
message: "Execution lease expired after the final allowed attempt",
|
|
980
|
+
};
|
|
981
|
+
const result = update.run({
|
|
982
|
+
occurrenceId: row.occurrence_id,
|
|
983
|
+
ownerId: row.lease_owner,
|
|
984
|
+
fence: row.fence,
|
|
985
|
+
errorJson: canonicalJson(error, "occurrence.error"),
|
|
986
|
+
now,
|
|
987
|
+
});
|
|
988
|
+
if (result.changes === 1) {
|
|
989
|
+
this._appendEvent({
|
|
990
|
+
jobId: row.job_id,
|
|
991
|
+
occurrenceId: row.occurrence_id,
|
|
992
|
+
type: "occurrence_dead_lettered",
|
|
993
|
+
occurredAt: now,
|
|
994
|
+
ownerId: row.lease_owner,
|
|
995
|
+
fence: row.fence,
|
|
996
|
+
data: {
|
|
997
|
+
attempt: row.attempt,
|
|
998
|
+
maxAttempts: row.max_attempts,
|
|
999
|
+
reason: "lease_expired",
|
|
1000
|
+
},
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
claimNext({ ownerId, leaseMs } = {}) {
|
|
1007
|
+
const owner = normalizeIdentifier(ownerId, "ownerId");
|
|
1008
|
+
const lease = normalizeLeaseMs(leaseMs);
|
|
1009
|
+
const now = this._now();
|
|
1010
|
+
const leaseExpiresAt = now + lease;
|
|
1011
|
+
if (!Number.isSafeInteger(leaseExpiresAt)) {
|
|
1012
|
+
throw invalidArgument("lease expiry exceeds the safe integer range");
|
|
1013
|
+
}
|
|
1014
|
+
return this._write(() => {
|
|
1015
|
+
this._deadLetterExpiredLeases(now);
|
|
1016
|
+
const candidate = this.db
|
|
1017
|
+
.prepare(
|
|
1018
|
+
`
|
|
1019
|
+
SELECT o.*
|
|
1020
|
+
FROM occurrences o
|
|
1021
|
+
JOIN jobs j ON j.job_id = o.job_id
|
|
1022
|
+
WHERE j.enabled = 1
|
|
1023
|
+
AND o.attempt < o.max_attempts
|
|
1024
|
+
AND (
|
|
1025
|
+
(o.status IN ('queued', 'retry_wait') AND o.available_at <= @now)
|
|
1026
|
+
OR
|
|
1027
|
+
(o.status = 'running' AND o.lease_expires_at <= @now)
|
|
1028
|
+
)
|
|
1029
|
+
ORDER BY
|
|
1030
|
+
CASE WHEN o.status = 'running' THEN o.lease_expires_at ELSE o.available_at END,
|
|
1031
|
+
o.scheduled_for,
|
|
1032
|
+
o.occurrence_id
|
|
1033
|
+
LIMIT 1
|
|
1034
|
+
`,
|
|
1035
|
+
)
|
|
1036
|
+
.get({ now });
|
|
1037
|
+
if (!candidate) return null;
|
|
1038
|
+
const previousStatus = candidate.status;
|
|
1039
|
+
const previousOwner = candidate.lease_owner;
|
|
1040
|
+
const result = this.db
|
|
1041
|
+
.prepare(
|
|
1042
|
+
`
|
|
1043
|
+
UPDATE occurrences
|
|
1044
|
+
SET status = 'running',
|
|
1045
|
+
attempt = attempt + 1,
|
|
1046
|
+
fence = fence + 1,
|
|
1047
|
+
lease_owner = @ownerId,
|
|
1048
|
+
lease_expires_at = @leaseExpiresAt,
|
|
1049
|
+
updated_at = @now,
|
|
1050
|
+
settled_at = NULL
|
|
1051
|
+
WHERE occurrence_id = @occurrenceId
|
|
1052
|
+
AND fence = @expectedFence
|
|
1053
|
+
AND attempt < max_attempts
|
|
1054
|
+
AND (
|
|
1055
|
+
(status IN ('queued', 'retry_wait') AND available_at <= @now)
|
|
1056
|
+
OR
|
|
1057
|
+
(status = 'running' AND lease_expires_at <= @now)
|
|
1058
|
+
)
|
|
1059
|
+
`,
|
|
1060
|
+
)
|
|
1061
|
+
.run({
|
|
1062
|
+
occurrenceId: candidate.occurrence_id,
|
|
1063
|
+
expectedFence: candidate.fence,
|
|
1064
|
+
ownerId: owner,
|
|
1065
|
+
leaseExpiresAt,
|
|
1066
|
+
now,
|
|
1067
|
+
});
|
|
1068
|
+
if (result.changes !== 1) {
|
|
1069
|
+
throw new SchedulerKernelError(
|
|
1070
|
+
"SCHEDULER_CLAIM_CONFLICT",
|
|
1071
|
+
"Occurrence eligibility changed during claim",
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
const claimed = this.statements.getOccurrence.get(
|
|
1075
|
+
candidate.occurrence_id,
|
|
1076
|
+
);
|
|
1077
|
+
this._appendEvent({
|
|
1078
|
+
jobId: candidate.job_id,
|
|
1079
|
+
occurrenceId: candidate.occurrence_id,
|
|
1080
|
+
type:
|
|
1081
|
+
previousStatus === OCCURRENCE_STATUS.RUNNING
|
|
1082
|
+
? "occurrence_reclaimed"
|
|
1083
|
+
: "occurrence_claimed",
|
|
1084
|
+
occurredAt: now,
|
|
1085
|
+
ownerId: owner,
|
|
1086
|
+
fence: claimed.fence,
|
|
1087
|
+
data: {
|
|
1088
|
+
attempt: claimed.attempt,
|
|
1089
|
+
previousOwner,
|
|
1090
|
+
previousStatus,
|
|
1091
|
+
},
|
|
1092
|
+
});
|
|
1093
|
+
return mapOccurrence(claimed);
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
renew({ occurrenceId, ownerId, fence, leaseMs } = {}) {
|
|
1098
|
+
const id = normalizeIdentifier(occurrenceId, "occurrenceId");
|
|
1099
|
+
const owner = normalizeIdentifier(ownerId, "ownerId");
|
|
1100
|
+
const token = assertPositiveSafeInteger(fence, "fence");
|
|
1101
|
+
const lease = normalizeLeaseMs(leaseMs);
|
|
1102
|
+
const now = this._now();
|
|
1103
|
+
const leaseExpiresAt = now + lease;
|
|
1104
|
+
if (!Number.isSafeInteger(leaseExpiresAt)) {
|
|
1105
|
+
throw invalidArgument("lease expiry exceeds the safe integer range");
|
|
1106
|
+
}
|
|
1107
|
+
return this._write(() => {
|
|
1108
|
+
const result = this.db
|
|
1109
|
+
.prepare(
|
|
1110
|
+
`
|
|
1111
|
+
UPDATE occurrences
|
|
1112
|
+
SET lease_expires_at = MAX(lease_expires_at, @leaseExpiresAt),
|
|
1113
|
+
updated_at = @now
|
|
1114
|
+
WHERE occurrence_id = @occurrenceId
|
|
1115
|
+
AND status = 'running'
|
|
1116
|
+
AND lease_owner = @ownerId
|
|
1117
|
+
AND fence = @fence
|
|
1118
|
+
AND lease_expires_at > @now
|
|
1119
|
+
`,
|
|
1120
|
+
)
|
|
1121
|
+
.run({
|
|
1122
|
+
occurrenceId: id,
|
|
1123
|
+
ownerId: owner,
|
|
1124
|
+
fence: token,
|
|
1125
|
+
leaseExpiresAt,
|
|
1126
|
+
now,
|
|
1127
|
+
});
|
|
1128
|
+
if (result.changes !== 1) throw leaseLost(id);
|
|
1129
|
+
const row = this.statements.getOccurrence.get(id);
|
|
1130
|
+
this._appendEvent({
|
|
1131
|
+
jobId: row.job_id,
|
|
1132
|
+
occurrenceId: id,
|
|
1133
|
+
type: "occurrence_renewed",
|
|
1134
|
+
occurredAt: now,
|
|
1135
|
+
ownerId: owner,
|
|
1136
|
+
fence: token,
|
|
1137
|
+
data: { leaseExpiresAt: row.lease_expires_at },
|
|
1138
|
+
});
|
|
1139
|
+
return mapOccurrence(row);
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
settle({
|
|
1144
|
+
occurrenceId,
|
|
1145
|
+
ownerId,
|
|
1146
|
+
fence,
|
|
1147
|
+
outcome,
|
|
1148
|
+
result,
|
|
1149
|
+
error,
|
|
1150
|
+
retryable = true,
|
|
1151
|
+
retryAt,
|
|
1152
|
+
} = {}) {
|
|
1153
|
+
const id = normalizeIdentifier(occurrenceId, "occurrenceId");
|
|
1154
|
+
const owner = normalizeIdentifier(ownerId, "ownerId");
|
|
1155
|
+
const token = assertPositiveSafeInteger(fence, "fence");
|
|
1156
|
+
if (!["succeeded", "failed"].includes(outcome)) {
|
|
1157
|
+
throw invalidArgument("outcome must be succeeded or failed");
|
|
1158
|
+
}
|
|
1159
|
+
if (typeof retryable !== "boolean") {
|
|
1160
|
+
throw invalidArgument("retryable must be a boolean");
|
|
1161
|
+
}
|
|
1162
|
+
const normalizedResult =
|
|
1163
|
+
outcome === "succeeded" ? normalizeJson(result ?? null, "result") : null;
|
|
1164
|
+
const normalizedError =
|
|
1165
|
+
outcome === "failed"
|
|
1166
|
+
? normalizeJson(
|
|
1167
|
+
error ?? {
|
|
1168
|
+
code: "execution_failed",
|
|
1169
|
+
message: "Execution failed",
|
|
1170
|
+
},
|
|
1171
|
+
"error",
|
|
1172
|
+
)
|
|
1173
|
+
: null;
|
|
1174
|
+
const now = this._now();
|
|
1175
|
+
const availableAt =
|
|
1176
|
+
outcome !== "failed" || retryAt === undefined
|
|
1177
|
+
? now
|
|
1178
|
+
: Math.max(now, normalizeEpochMs(retryAt, "retryAt"));
|
|
1179
|
+
|
|
1180
|
+
return this._write(() => {
|
|
1181
|
+
const current = this.statements.getOccurrence.get(id);
|
|
1182
|
+
if (
|
|
1183
|
+
!current ||
|
|
1184
|
+
current.status !== OCCURRENCE_STATUS.RUNNING ||
|
|
1185
|
+
current.lease_owner !== owner ||
|
|
1186
|
+
current.fence !== token ||
|
|
1187
|
+
current.lease_expires_at <= now
|
|
1188
|
+
) {
|
|
1189
|
+
throw leaseLost(id);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
let nextStatus;
|
|
1193
|
+
let eventType;
|
|
1194
|
+
let settledAt = null;
|
|
1195
|
+
let nextAvailableAt = current.available_at;
|
|
1196
|
+
let lastErrorJson = null;
|
|
1197
|
+
let resultJson = null;
|
|
1198
|
+
if (outcome === "succeeded") {
|
|
1199
|
+
nextStatus = OCCURRENCE_STATUS.SUCCEEDED;
|
|
1200
|
+
eventType = "occurrence_succeeded";
|
|
1201
|
+
settledAt = now;
|
|
1202
|
+
resultJson = canonicalJson(normalizedResult, "result");
|
|
1203
|
+
} else if (retryable && current.attempt < current.max_attempts) {
|
|
1204
|
+
nextStatus = OCCURRENCE_STATUS.RETRY_WAIT;
|
|
1205
|
+
eventType = "occurrence_retry_scheduled";
|
|
1206
|
+
nextAvailableAt = availableAt;
|
|
1207
|
+
lastErrorJson = canonicalJson(normalizedError, "error");
|
|
1208
|
+
} else {
|
|
1209
|
+
nextStatus = OCCURRENCE_STATUS.DEAD_LETTER;
|
|
1210
|
+
eventType = "occurrence_dead_lettered";
|
|
1211
|
+
settledAt = now;
|
|
1212
|
+
lastErrorJson = canonicalJson(normalizedError, "error");
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const update = this.db
|
|
1216
|
+
.prepare(
|
|
1217
|
+
`
|
|
1218
|
+
UPDATE occurrences
|
|
1219
|
+
SET status = @status,
|
|
1220
|
+
available_at = @availableAt,
|
|
1221
|
+
lease_owner = NULL,
|
|
1222
|
+
lease_expires_at = NULL,
|
|
1223
|
+
last_error_json = @lastErrorJson,
|
|
1224
|
+
result_json = @resultJson,
|
|
1225
|
+
updated_at = @now,
|
|
1226
|
+
settled_at = @settledAt
|
|
1227
|
+
WHERE occurrence_id = @occurrenceId
|
|
1228
|
+
AND status = 'running'
|
|
1229
|
+
AND lease_owner = @ownerId
|
|
1230
|
+
AND fence = @fence
|
|
1231
|
+
AND lease_expires_at > @now
|
|
1232
|
+
`,
|
|
1233
|
+
)
|
|
1234
|
+
.run({
|
|
1235
|
+
occurrenceId: id,
|
|
1236
|
+
ownerId: owner,
|
|
1237
|
+
fence: token,
|
|
1238
|
+
status: nextStatus,
|
|
1239
|
+
availableAt: nextAvailableAt,
|
|
1240
|
+
lastErrorJson,
|
|
1241
|
+
resultJson,
|
|
1242
|
+
now,
|
|
1243
|
+
settledAt,
|
|
1244
|
+
});
|
|
1245
|
+
if (update.changes !== 1) throw leaseLost(id);
|
|
1246
|
+
this._appendEvent({
|
|
1247
|
+
jobId: current.job_id,
|
|
1248
|
+
occurrenceId: id,
|
|
1249
|
+
type: eventType,
|
|
1250
|
+
occurredAt: now,
|
|
1251
|
+
ownerId: owner,
|
|
1252
|
+
fence: token,
|
|
1253
|
+
data: {
|
|
1254
|
+
attempt: current.attempt,
|
|
1255
|
+
maxAttempts: current.max_attempts,
|
|
1256
|
+
...(nextStatus === OCCURRENCE_STATUS.RETRY_WAIT
|
|
1257
|
+
? { retryAt: nextAvailableAt }
|
|
1258
|
+
: {}),
|
|
1259
|
+
},
|
|
1260
|
+
});
|
|
1261
|
+
return mapOccurrence(this.statements.getOccurrence.get(id));
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
history({ occurrenceId, jobId, limit } = {}) {
|
|
1266
|
+
this._assertOpen();
|
|
1267
|
+
const boundedLimit = normalizeHistoryLimit(limit);
|
|
1268
|
+
const occurrence =
|
|
1269
|
+
occurrenceId === undefined
|
|
1270
|
+
? null
|
|
1271
|
+
: normalizeIdentifier(occurrenceId, "occurrenceId");
|
|
1272
|
+
const job =
|
|
1273
|
+
jobId === undefined ? null : normalizeIdentifier(jobId, "jobId");
|
|
1274
|
+
let sql = "SELECT * FROM events";
|
|
1275
|
+
const parameters = { limit: boundedLimit };
|
|
1276
|
+
if (occurrence !== null && job !== null) {
|
|
1277
|
+
sql += " WHERE occurrence_id = @occurrenceId AND job_id = @jobId";
|
|
1278
|
+
parameters.occurrenceId = occurrence;
|
|
1279
|
+
parameters.jobId = job;
|
|
1280
|
+
} else if (occurrence !== null) {
|
|
1281
|
+
sql += " WHERE occurrence_id = @occurrenceId";
|
|
1282
|
+
parameters.occurrenceId = occurrence;
|
|
1283
|
+
} else if (job !== null) {
|
|
1284
|
+
sql += " WHERE job_id = @jobId";
|
|
1285
|
+
parameters.jobId = job;
|
|
1286
|
+
}
|
|
1287
|
+
sql += " ORDER BY event_id DESC LIMIT @limit";
|
|
1288
|
+
return this.db.prepare(sql).all(parameters).map(mapEvent);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
listDeadLetters({ jobId, limit } = {}) {
|
|
1292
|
+
this._assertOpen();
|
|
1293
|
+
const boundedLimit = normalizeHistoryLimit(limit);
|
|
1294
|
+
if (jobId === undefined) {
|
|
1295
|
+
return this.db
|
|
1296
|
+
.prepare(
|
|
1297
|
+
"SELECT * FROM occurrences WHERE status = 'dead_letter' ORDER BY settled_at DESC, occurrence_id LIMIT ?",
|
|
1298
|
+
)
|
|
1299
|
+
.all(boundedLimit)
|
|
1300
|
+
.map(mapOccurrence);
|
|
1301
|
+
}
|
|
1302
|
+
const id = normalizeIdentifier(jobId, "jobId");
|
|
1303
|
+
return this.db
|
|
1304
|
+
.prepare(
|
|
1305
|
+
"SELECT * FROM occurrences WHERE status = 'dead_letter' AND job_id = ? ORDER BY settled_at DESC, occurrence_id LIMIT ?",
|
|
1306
|
+
)
|
|
1307
|
+
.all(id, boundedLimit)
|
|
1308
|
+
.map(mapOccurrence);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
schemaInfo() {
|
|
1312
|
+
this._assertOpen();
|
|
1313
|
+
return {
|
|
1314
|
+
applicationId: this.db.pragma("application_id", { simple: true }),
|
|
1315
|
+
schemaVersion: this.db.pragma("user_version", { simple: true }),
|
|
1316
|
+
migration: this.db
|
|
1317
|
+
.prepare(
|
|
1318
|
+
"SELECT version, name, checksum, applied_at AS appliedAt FROM migrations ORDER BY version DESC LIMIT 1",
|
|
1319
|
+
)
|
|
1320
|
+
.get(),
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
close() {
|
|
1325
|
+
if (this.closed) return;
|
|
1326
|
+
this.closed = true;
|
|
1327
|
+
this.db.close();
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
export function openSchedulerStore({
|
|
1332
|
+
file,
|
|
1333
|
+
Database,
|
|
1334
|
+
clock = Date.now,
|
|
1335
|
+
busyTimeoutMs = DEFAULT_BUSY_TIMEOUT_MS,
|
|
1336
|
+
} = {}) {
|
|
1337
|
+
if (typeof clock !== "function") {
|
|
1338
|
+
throw invalidArgument("clock must be a function");
|
|
1339
|
+
}
|
|
1340
|
+
const opened = openDatabase({ file, Database, busyTimeoutMs, clock });
|
|
1341
|
+
return new SchedulerStore(opened.db, {
|
|
1342
|
+
file: opened.target,
|
|
1343
|
+
clock,
|
|
1344
|
+
});
|
|
1345
|
+
}
|