chainlesschain 0.162.189 → 0.162.197
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -11
- package/bin/chainlesschain.js +7 -52
- package/completions/_cc +209 -0
- package/completions/cc.bash +18 -0
- package/completions/cc.fish +13 -0
- package/completions/cc.ps1 +226 -0
- package/package.json +10 -2
- package/src/assets/web-panel/assets/{AIOps-DbZft8w0.js → AIOps-D3Gu1a5d.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-DoOZpwfN.js → ActionButton-DrwAoKaz.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-B0J0KLTW.js → Analytics-C7Q-gd_3.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-D9eIR0qq.js → AppLayout-DgBtae-n.js} +4 -4
- package/src/assets/web-panel/assets/{Artifacts-BnZXEVeo.js → Artifacts-pQ6LcA-W.js} +1 -1
- package/src/assets/web-panel/assets/{Audit-X1ce4Bjn.js → Audit-DK61tocB.js} +1 -1
- package/src/assets/web-panel/assets/{BackgroundAgents-BvWR-y5v.js → BackgroundAgents-BOKgDFRh.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-BublSGEd.js → Backup-Bo9G4vTP.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-CR_77Hso.js → BaseInput-Do_dE9_v.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-DixW52tq.js → Chat-COVvZzOL.js} +6 -6
- package/src/assets/web-panel/assets/ChatBubbleRenderer-BUUyJ5QO.js +1 -0
- package/src/assets/web-panel/assets/{Checkbox-CCv1C87K.js → Checkbox-Z9MJMolt.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-B03Cc3dd.js → Codegen-BVzWKVbf.js} +1 -1
- package/src/assets/web-panel/assets/{Col-CA06-6pU.js → Col-C8MnYcbT.js} +1 -1
- package/src/assets/web-panel/assets/{Community-Bj881J52.js → Community-DQxqw15l.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-UZLTPoaL.js → Compact-BAUmrh3y.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-kGwzRb_0.js → Compliance-jPAkQ3zK.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-KundoYM8.js → Cowork-BBQyxbh8.js} +2 -2
- package/src/assets/web-panel/assets/{Cron-BYZyoqDe.js → Cron-W5koq8Z5.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-BhrrppBX.js → Crosschain-BQhnu5hS.js} +1 -1
- package/src/assets/web-panel/assets/{DID-CdC6EguZ.js → DID-DD7PlI18.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-CcQx3J4y.js → Dashboard-w_ILuwKI.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-BpijtEzQ.js → Dropdown-qiDUAyAo.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-DfZjeZTe.js → EmailListRenderer-PDeBcI5H.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-BFYSf69C.js → FamilyGuardDashboard-SuArscRM.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-Okdi3pJm.js → Federation-BCovZaUn.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-mH-HebKW.js → FormItemContext-DjlFdVFk.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-xivV69x7.js → GenericCardRenderer-DoEZ7fcS.js} +1 -1
- package/src/assets/web-panel/assets/{Git-1rh_YBwg.js → Git-CV0-6GPI.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-eAXkNxdY.js → Governance-B_SFN50w.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-CS6dMVTN.js → Inference-heI0Eo6k.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-Bq_TnkvD.js → KnowledgeGraph-Cz5INS5p.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-KchHtiHt.js → Logs-BSFxiHkg.js} +2 -2
- package/src/assets/web-panel/assets/MarkdownRenderer-1mNozZ8v.js +1 -0
- package/src/assets/web-panel/assets/{Marketplace-CsmGcX5X.js → Marketplace-0TWTpE7k.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-BaPFnnyz.js → McpTools-CV4-wBLm.js} +4 -4
- package/src/assets/web-panel/assets/{Memory-2m-sUUf7.js → Memory-x5bH5rw5.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-D5nVZBRu.js → MobileBridge-DaBmUu9G.js} +1 -1
- package/src/assets/web-panel/assets/{MobileProjects-Bo8MLZvT.js → MobileProjects-DqnoFPgR.js} +1 -1
- package/src/assets/web-panel/assets/{Mtc-3GDvamGa.js → Mtc-B_zKEBif.js} +4 -4
- package/src/assets/web-panel/assets/{MtcAudit-D2zGuZCH.js → MtcAudit-BCfKD2yg.js} +4 -4
- package/src/assets/web-panel/assets/{Multisig-BBVQ4t6t.js → Multisig-DgYfj6c_.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-BIOCyOGp.js → NLProgramming-Dz09lFUm.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-DVwBGpzc.js → Notes-Cuj6ugBE.js} +3 -3
- package/src/assets/web-panel/assets/{NotificationSettings-BDnHfIfX.js → NotificationSettings-DK2WXjhg.js} +1 -1
- package/src/assets/web-panel/assets/{OrderTableRenderer-C7Ek20ag.js → OrderTableRenderer-D1IAOQoo.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-D3SRKaa7.js → Organization-9fqnPvTw.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-C2B8ahyn.js → Overflow-n5xHkhow.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-2iwQzG65.js → P2P-B5AUWwH-.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-CoPiWqMN.js → PdhVaultBrowser-CoUDV6dy.js} +4 -4
- package/src/assets/web-panel/assets/{Permissions-yV5XNXo5.js → Permissions-DPRmQf_H.js} +4 -4
- package/src/assets/web-panel/assets/{PersonalDataHub-uCDJhMUF.js → PersonalDataHub-B37bsbEN.js} +4 -4
- package/src/assets/web-panel/assets/{Pipeline-hN7ucMOA.js → Pipeline-CCAuPqu9.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-CgU0ptN4.js → Privacy-B81msZdv.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-CfYPiT6j.js → ProjectInit-B-Py2NbF.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-B0A2C4nw.js → ProjectSettings-CYsneiRB.js} +2 -2
- package/src/assets/web-panel/assets/{Projects-BE5C0ajn.js → Projects-Y975Mh6R.js} +1 -1
- package/src/assets/web-panel/assets/{Providers-CstQPf8W.js → Providers-uO-10aP1.js} +1 -1
- package/src/assets/web-panel/assets/{QrScannerModal-Co3z_ynk.js → QrScannerModal-Bj_6gJ0l.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-DMTS6pQ6.js → QuickAsk-BgpBCMPE.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-ETw4odUa.js → Recommend-DhUW3YUA.js} +1 -1
- package/src/assets/web-panel/assets/RemoteSession-DdeyfbkA.js +5 -0
- package/src/assets/web-panel/assets/{Reputation-BTVqQgKq.js → Reputation-fxW421xd.js} +1 -1
- package/src/assets/web-panel/assets/{Row-CBKKwwtO.js → Row-De73w1og.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-BHUQ438I.js → RssFeed-3US_Q_lj.js} +2 -2
- package/src/assets/web-panel/assets/{Search-B4NqeUsF.js → Search-DbZ_DU74.js} +1 -1
- package/src/assets/web-panel/assets/{Security-BRjoYG5_.js → Security-DoIU1vMF.js} +4 -4
- package/src/assets/web-panel/assets/{Services-CxQzu3p3.js → Services-DB_RGqpc.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-BKzwwmWO.js → Skeleton-DnzLaayC.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-DrsFcgth.js → Skills-VzVXHENF.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-D21tmXCH.js → Sla-DMfvjCAI.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-CjyjLfp8.js → SpeechSettings-DQQOYWbF.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-Cu45-tnx.js → SyncSettings-Bmx3FG1P.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-oMcILYOj.js → Tasks-DOQyfb-2.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-Bh-Zcr5R.js → Templates-D6hTRT7c.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-68ns63aI.js → Tenant-CTPQZAzs.js} +1 -1
- package/src/assets/web-panel/assets/{Terminal-BLJkFykE.js → Terminal-B7vII3oH.js} +2 -2
- package/src/assets/web-panel/assets/{TimelineRenderer-xcrJLq-O.js → TimelineRenderer-BkiFJeXV.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-C6PpFJtf.js → Tokens-BPPdQMNo.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-BaKA7tdM.js → Trigger-BxG8JAEI.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-BzhA57Jc.js → Trust-DHD7nMpM.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-4_U3JkIR.js → UkeySign-B4BJpU47.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-162AANxX.js → VideoEditing-CRiGpFx2.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-B1viuql_.js → Wallet-BSDSNiem.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-ZVWazhCy.js → WebAuthn-DLTUh5Zv.js} +5 -5
- package/src/assets/web-panel/assets/WorkflowEditor-tW2MwjhD.js +1 -0
- package/src/assets/web-panel/assets/{chat-DmrHNuwq.js → chat-SgsLQ3FC.js} +1 -1
- package/src/assets/web-panel/assets/{colors-DAJ_M-PK.js → colors-BHjjLIKO.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-uIzmYwLs.js → compact-item-VceErdKq.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-D_ZmuLwb.js → createContext-BZ332MYl.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-DosMaL-k.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-CDt_shTL.js → hasIn-CBTjJOjn.js} +1 -1
- package/src/assets/web-panel/assets/index-0O5ePnUq.js +1 -0
- package/src/assets/web-panel/assets/{index-rXdwUeE3.js → index-1n7xjKDU.js} +1 -1
- package/src/assets/web-panel/assets/{index-BAvSmJXW.js → index-29wVzi-k.js} +1 -1
- package/src/assets/web-panel/assets/{index-C16fYKFD.js → index-B2u26AS3.js} +1 -1
- package/src/assets/web-panel/assets/{index-C5WcdJH2.js → index-B40dQn0G.js} +3 -3
- package/src/assets/web-panel/assets/{index-CaelyMgF.js → index-B5KAGxNo.js} +1 -1
- package/src/assets/web-panel/assets/{index-OzTxFIJP.js → index-BC1xuQgX.js} +1 -1
- package/src/assets/web-panel/assets/{index-CzXqvMuP.js → index-BETEjuNr.js} +1 -1
- package/src/assets/web-panel/assets/{index-clSX4PEc.js → index-BOtvPdHt.js} +1 -1
- package/src/assets/web-panel/assets/{index-saf3VNeP.js → index-BPXFLYdW.js} +1 -1
- package/src/assets/web-panel/assets/{index-DdUMcwRB.js → index-BQSmZGgs.js} +1 -1
- package/src/assets/web-panel/assets/{index-DfSSy9uy.js → index-BRWhLbWF.js} +1 -1
- package/src/assets/web-panel/assets/{index-GcKOHD_c.js → index-BTlc-Bve.js} +1 -1
- package/src/assets/web-panel/assets/{index-BHvlm0In.js → index-BY1wexYG.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cvak6Oe2.js → index-BcUenmCX.js} +1 -1
- package/src/assets/web-panel/assets/{index-CKyd472S.js → index-BeiqkxJG.js} +1 -1
- package/src/assets/web-panel/assets/{index-DvFhhe6r.js → index-BhXZEUAc.js} +1 -1
- package/src/assets/web-panel/assets/{index-DBFmcaCQ.js → index-By5jBtkV.js} +1 -1
- package/src/assets/web-panel/assets/{index-DqDSF2RW.js → index-CLc19alH.js} +1 -1
- package/src/assets/web-panel/assets/{index-DeGBoLQA.js → index-CMVvxIS8.js} +1 -1
- package/src/assets/web-panel/assets/{index-FyplHuNt.js → index-C_B9rFQE.js} +1 -1
- package/src/assets/web-panel/assets/{index-CeUpKjvi.js → index-CaIS_TNX.js} +1 -1
- package/src/assets/web-panel/assets/{index-BRa4kJ_S.js → index-CawpdGTW.js} +1 -1
- package/src/assets/web-panel/assets/index-ChAzPigF.js +1 -0
- package/src/assets/web-panel/assets/{index-QsgfSZrV.js → index-CpumS98o.js} +1 -1
- package/src/assets/web-panel/assets/{index-BDF6EQBs.js → index-Cr4rjlW5.js} +1 -1
- package/src/assets/web-panel/assets/{index-rS3gl_68.js → index-CrUgDQkf.js} +1 -1
- package/src/assets/web-panel/assets/{index-D4n1lGTB.js → index-CtjLcCNU.js} +1 -1
- package/src/assets/web-panel/assets/{index-ifxmu8SY.js → index-DUkVN794.js} +1 -1
- package/src/assets/web-panel/assets/{index-BTCXf1t-.js → index-D_SePDWj.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bbxjfstq.js → index-DaCCy6lq.js} +1 -1
- package/src/assets/web-panel/assets/{index-D9OE8B3d.js → index-DdefYBSw.js} +1 -1
- package/src/assets/web-panel/assets/{index-CG0lY4h3.js → index-DePOeMDM.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bx4d6c1k.js → index-DgPvuIn2.js} +1 -1
- package/src/assets/web-panel/assets/{index-Z8P4BexE.js → index-FJ3zC9LK.js} +1 -1
- package/src/assets/web-panel/assets/{index-ByzGvpR9.js → index-_PHrIO4O.js} +1 -1
- package/src/assets/web-panel/assets/{index-DwfvPe-j.js → index-fU7wNHDB.js} +1 -1
- package/src/assets/web-panel/assets/{index-CcZ9xJI9.js → index-iYQNU9za.js} +1 -1
- package/src/assets/web-panel/assets/{index-CbD_WW_6.js → index-vIXOpcjD.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-pmfaD-aK.js → initDefaultProps-D1oZNz0D.js} +1 -1
- package/src/assets/web-panel/assets/{motion-CaQ2CpYF.js → motion-C8k6doMO.js} +1 -1
- package/src/assets/web-panel/assets/{move-CkKTqNbI.js → move-DJ4qSbYt.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-Dd4xsgRI.js → mtc-parser-CdpJD85j.js} +1 -1
- package/src/assets/web-panel/assets/{omit-BFuA5YEs.js → omit-B0V4slKA.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-DDfxOAJc.js → pickAttrs-Di5NiW1F.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-Be595JCp.js → placementArrow-B5OMHgEM.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-7byljUM3.js → responsiveObserve-C6U9IWql.js} +1 -1
- package/src/assets/web-panel/assets/{slide-CTWfOTaY.js → slide-BMyTH3rN.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-CNZOuIxM.js → statusUtils-C7d6BG7Z.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-DwOcBV78.js → styleChecker-CR50QnQy.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-B6MNwAO9.js → useFlexGapSupport-DBEeoMb8.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-CzyQjH9r.js → useFs-3r099cjv.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-Dxh3KiVA.js → usePersonalDataHub-C5IYNdTK.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-DVfZiS5N.js → vnode-DmEOLRHf.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-BYme7fbY.js → zoom-DnpUiVCB.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/cli-help.js +181 -0
- package/src/command-help-index.json +181 -0
- package/src/command-manifest.json +1271 -175
- package/src/command-surface-policy.js +312 -0
- package/src/commands/agent.js +76 -33
- package/src/commands/artifacts.js +400 -2
- package/src/commands/background-session.js +41 -3
- package/src/commands/checkpoint-restore-recovery.js +1476 -0
- package/src/commands/checkpoint.js +947 -22
- package/src/commands/compact.js +101 -25
- package/src/commands/config.js +506 -20
- package/src/commands/doctor.js +4 -1
- package/src/commands/hub.js +1 -1
- package/src/commands/mcp.js +688 -24
- package/src/commands/pack.js +16 -2
- package/src/commands/plugin.js +20 -12
- package/src/commands/session-mcp-recovery.js +218 -0
- package/src/commands/session.js +470 -110
- package/src/commands/setup.js +36 -10
- package/src/commands/skill.js +181 -110
- package/src/commands/status.js +21 -5
- package/src/commands/update.js +68 -6
- package/src/constants.js +11 -0
- package/src/data/changelog.json +14 -1
- package/src/gateways/ws/action-protocol.js +10 -0
- package/src/gateways/ws/session-protocol.js +555 -27
- package/src/gateways/ws/ws-agent-handler.js +541 -14
- package/src/gateways/ws/ws-session-gateway.js +129 -22
- package/src/gateways/ws/ws-session-state.js +431 -0
- package/src/harness/background-task-manager.js +233 -36
- package/src/harness/jsonl-session-store.js +2551 -167
- package/src/harness/mcp-client.js +802 -61
- package/src/harness/prompt-compressor.js +227 -27
- package/src/harness/session-index.js +89 -38
- package/src/harness/session-list-index.js +254 -0
- package/src/harness/structured-handoff.js +410 -0
- package/src/lazy-dispatch.js +747 -84
- package/src/lib/__tests__/logger.test.js +50 -2
- package/src/lib/advisor-runtime.js +1047 -0
- package/src/lib/agent-core.js +1 -0
- package/src/lib/agent-sandbox.js +120 -26
- package/src/lib/agent-session-export.js +20 -0
- package/src/lib/agent-team/team-runner.js +230 -8
- package/src/lib/artifact-store.js +131 -8
- package/src/lib/background-agent-supervisor.js +185 -13
- package/src/lib/background-interaction-journal.js +37 -9
- package/src/lib/background-launch-profile.js +1058 -0
- package/src/lib/checkpoint-restore-already-completed-controller.js +902 -0
- package/src/lib/checkpoint-restore-orchestrator.js +987 -0
- package/src/lib/checkpoint-restore-partial-rollback-controller.js +1953 -0
- package/src/lib/checkpoint-restore-recovery-controller.js +689 -0
- package/src/lib/checkpoint-restore-recovery.js +884 -0
- package/src/lib/checkpoint-restore-retention.js +126 -0
- package/src/lib/checkpoint-restore-saga.js +5079 -0
- package/src/lib/checkpoint-restore-session-recovery.js +1178 -0
- package/src/lib/checkpoint-restore-workspace-target-verifier.js +270 -0
- package/src/lib/checkpoint-store.js +1688 -97
- package/src/lib/checkpoint-timeline-authority.js +522 -0
- package/src/lib/checkpoint-timeline.js +642 -0
- package/src/lib/config-keys.js +18 -119
- package/src/lib/config-manager.js +446 -27
- package/src/lib/config-redaction.js +57 -0
- package/src/lib/config-schema.js +853 -0
- package/src/lib/cowork-mcp-tools.js +5 -0
- package/src/lib/cowork-task-runner.js +458 -63
- package/src/lib/delivery-coordinator.js +1234 -0
- package/src/lib/delivery-evidence.js +450 -0
- package/src/lib/doctor-checkup.js +199 -14
- package/src/lib/executable-path.js +34 -0
- package/src/lib/fatal-handler.js +16 -0
- package/src/lib/file-checkpoint.js +4909 -186
- package/src/lib/file-lines.js +125 -0
- package/src/lib/headless-config-command.js +16 -5
- package/src/lib/hook-runner.cjs +139 -38
- package/src/lib/hooks-v2-workspace-context.js +10 -0
- package/src/lib/impacted-gate-selector.js +424 -0
- package/src/lib/interaction-adapter.js +62 -0
- package/src/lib/jsonl-session-store.js +1 -35
- package/src/lib/logger.js +66 -14
- package/src/lib/mcp-call-ledger-store.js +1069 -0
- package/src/lib/mcp-call-ledger.js +747 -0
- package/src/lib/mcp-client.js +5 -0
- package/src/lib/mcp-conflict-scheduler.js +468 -0
- package/src/lib/mcp-effect-contract.js +102 -0
- package/src/lib/mcp-headers-helper-trust.js +145 -0
- package/src/lib/mcp-headers-helper.js +616 -0
- package/src/lib/mcp-host-recovery-runtime.js +131 -0
- package/src/lib/mcp-ledger-recovery-admission.js +1065 -0
- package/src/lib/mcp-recovery-adjudication.js +443 -0
- package/src/lib/output-context.js +86 -0
- package/src/lib/packer/file-identity.js +170 -0
- package/src/lib/packer/index.js +3 -0
- package/src/lib/packer/native-update-state.js +277 -0
- package/src/lib/packer/pack-update-applier.js +1983 -136
- package/src/lib/packer/pack-update-checker.js +34 -0
- package/src/lib/packer/pack-update-downloader.js +875 -86
- package/src/lib/packer/pack-update-signature.js +86 -0
- package/src/lib/packer/pkg-config-generator.js +6 -0
- package/src/lib/paths.js +65 -10
- package/src/lib/plan-mode.js +831 -14
- package/src/lib/plan-persistence.js +540 -0
- package/src/lib/plugin-runtime/hooks.js +227 -69
- package/src/lib/plugin-runtime/plugin-options.js +189 -44
- package/src/lib/pr-automation-policy.js +146 -8
- package/src/lib/process-execution-broker/credential-agent.js +7 -0
- package/src/lib/process-execution-broker/index.js +53 -8
- package/src/lib/process-execution-broker/workspace-transaction.js +976 -73
- package/src/lib/project-instructions.js +165 -29
- package/src/lib/project-mcp-trust.js +67 -0
- package/src/lib/public-ide-capability-manifest.js +278 -0
- package/src/lib/public-ide-capability-validation.js +351 -0
- package/src/lib/secret-store.js +267 -43
- package/src/lib/secure-fs.js +1089 -0
- package/src/lib/security-audit.js +58 -0
- package/src/lib/service-manager.js +34 -13
- package/src/lib/session-budget-runtime.js +1452 -0
- package/src/lib/session-host-snapshot.js +388 -0
- package/src/lib/session-message-provenance.js +429 -0
- package/src/lib/session-projection.js +804 -0
- package/src/lib/session-resource-budget.js +1495 -0
- package/src/lib/settings-hooks.cjs +138 -22
- package/src/lib/settings-loader.cjs +25 -2
- package/src/lib/side-effect-ledger-store.js +45 -23
- package/src/lib/skill-execution-identity.js +217 -0
- package/src/lib/skill-loader.js +299 -18
- package/src/lib/sub-agent-context.js +236 -28
- package/src/lib/sub-agent-registry.js +5 -0
- package/src/lib/subagent-contract.js +94 -0
- package/src/lib/todo-manager.js +185 -18
- package/src/lib/todo-persistence.js +417 -0
- package/src/lib/turn-binding-store.js +41 -23
- package/src/lib/with-file-lock.js +25 -1
- package/src/lib/ws-chat-handler.js +21 -0
- package/src/program-base.js +18 -0
- package/src/repl/advisor-command.js +103 -0
- package/src/repl/agent-repl.js +2332 -154
- package/src/repl/btw-command.js +93 -10
- package/src/repl/clipboard-image.js +119 -0
- package/src/repl/config-summary.js +64 -108
- package/src/repl/prompt-editor.js +233 -0
- package/src/repl/prompt-interactions.js +404 -0
- package/src/repl/prompt-stash.js +354 -0
- package/src/repl/prompt-suggestions.js +312 -0
- package/src/repl/repl-keybindings.js +227 -0
- package/src/repl/session-recap.js +217 -0
- package/src/repl/slash-command-registry.js +25 -4
- package/src/repl/terminal-layout.js +88 -0
- package/src/runtime/agent-core.js +1264 -325
- package/src/runtime/coding-agent-contract-shared.cjs +13 -1
- package/src/runtime/coding-agent-managed-tool-policy.cjs +7 -2
- package/src/runtime/diagnostics.js +32 -13
- package/src/runtime/headless-runner.js +298 -118
- package/src/runtime/headless-stream.js +353 -95
- package/src/runtime/mcp-config.js +148 -14
- package/src/runtime/status-diagnostics-lite.js +137 -0
- package/src/assets/web-panel/assets/ChatBubbleRenderer-DDHDpBBA.js +0 -1
- package/src/assets/web-panel/assets/MarkdownRenderer-TabqHW9B.js +0 -1
- package/src/assets/web-panel/assets/RemoteSession-B0rkE09d.js +0 -5
- package/src/assets/web-panel/assets/WorkflowEditor-tzdyUk-1.js +0 -1
- package/src/assets/web-panel/assets/devWarning-DIrncrV1.js +0 -1
- package/src/assets/web-panel/assets/index-D-Swwqld.js +0 -1
- package/src/assets/web-panel/assets/index-FMZAMKy8.js +0 -1
|
@@ -3,28 +3,481 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
+
constants as fsConstants,
|
|
6
7
|
existsSync,
|
|
7
|
-
mkdirSync,
|
|
8
8
|
appendFileSync,
|
|
9
9
|
readFileSync,
|
|
10
10
|
readdirSync,
|
|
11
11
|
copyFileSync,
|
|
12
|
+
renameSync,
|
|
12
13
|
rmSync,
|
|
14
|
+
openSync,
|
|
15
|
+
closeSync,
|
|
16
|
+
fstatSync,
|
|
17
|
+
lstatSync,
|
|
18
|
+
readSync,
|
|
19
|
+
ftruncateSync,
|
|
13
20
|
} from "node:fs";
|
|
14
21
|
import { join, basename, resolve } from "node:path";
|
|
15
|
-
import { createHash } from "node:crypto";
|
|
22
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
23
|
+
import { TextDecoder } from "node:util";
|
|
16
24
|
import { getHomeDir } from "../lib/paths.js";
|
|
25
|
+
import { ensurePrivateDirectory, ensurePrivateFile } from "../lib/secure-fs.js";
|
|
17
26
|
import {
|
|
18
27
|
computeEventHash,
|
|
19
|
-
latestChainHash,
|
|
20
|
-
verifyTranscriptText,
|
|
21
28
|
TRANSCRIPT_CHAIN_STATUS,
|
|
22
29
|
} from "./transcript-integrity.js";
|
|
23
30
|
import { withFileLock } from "../lib/with-file-lock.js";
|
|
31
|
+
import {
|
|
32
|
+
decodeVerifiedPersistedMessage,
|
|
33
|
+
DURABLE_SYSTEM_MESSAGE_KINDS,
|
|
34
|
+
encodePersistedMessage,
|
|
35
|
+
markDurableSystemMessage,
|
|
36
|
+
projectCanonicalResumeMessages,
|
|
37
|
+
sanitizePersistedMessage,
|
|
38
|
+
SESSION_FORK_AUTHORITY_FIELD,
|
|
39
|
+
} from "../lib/session-message-provenance.js";
|
|
40
|
+
import {
|
|
41
|
+
iterateFileLinesSync,
|
|
42
|
+
iterateFileLinesReverseSync,
|
|
43
|
+
} from "../lib/file-lines.js";
|
|
44
|
+
import {
|
|
45
|
+
emptySessionMeta,
|
|
46
|
+
applyEventToSessionMeta,
|
|
47
|
+
listIndexedSessions,
|
|
48
|
+
publicSessionMeta,
|
|
49
|
+
readLatestSessionActivity,
|
|
50
|
+
readSessionMeta,
|
|
51
|
+
recordSessionDeleted,
|
|
52
|
+
recordSessionEvent,
|
|
53
|
+
recordSessionActivity,
|
|
54
|
+
replaceSessionMeta,
|
|
55
|
+
} from "./session-list-index.js";
|
|
56
|
+
|
|
57
|
+
let securedSessionsDir = null;
|
|
58
|
+
let securedSessionsDirIdentity = null;
|
|
59
|
+
|
|
60
|
+
// Deterministic process-crash injection for the independent session-scale
|
|
61
|
+
// gate. The hooks are inert unless the dedicated child process opts in; this
|
|
62
|
+
// keeps production behavior and ordinary tests on the exact same append path.
|
|
63
|
+
export const _sessionScaleFaultHooks = Object.seal({
|
|
64
|
+
afterTranscriptAppend: null,
|
|
65
|
+
afterForkCopy: null,
|
|
66
|
+
afterForkLineage: null,
|
|
67
|
+
afterForkPublish: null,
|
|
68
|
+
afterForkMeta: null,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const WS_TURN_EVENT = "ws_turn";
|
|
72
|
+
export const WS_TURN_CLAIM_EVENT = "ws_turn_claim";
|
|
73
|
+
export const WS_TURN_SCHEMA_VERSION = 1;
|
|
74
|
+
export const WS_TURN_REQUEST_ID_MAX_BYTES = 128;
|
|
75
|
+
const WS_TURN_CONTENT_MAX_BYTES = 4 * 1024 * 1024;
|
|
76
|
+
const WS_TURN_REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@-]*$/;
|
|
77
|
+
const WS_TURN_INPUT_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
78
|
+
const WS_TURN_CLAIM_ID_PATTERN = /^claim-[0-9a-f-]{36}$/;
|
|
79
|
+
const WS_TURN_FAILURE_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
80
|
+
|
|
81
|
+
function wsTurnError(message, code) {
|
|
82
|
+
const error = new Error(message);
|
|
83
|
+
error.code = code;
|
|
84
|
+
return error;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hasExactKeys(value, expectedKeys) {
|
|
88
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
89
|
+
const actual = Object.keys(value).sort();
|
|
90
|
+
const expected = [...expectedKeys].sort();
|
|
91
|
+
return (
|
|
92
|
+
actual.length === expected.length &&
|
|
93
|
+
actual.every((key, index) => key === expected[index])
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function normalizeWsTurnRequestId(value) {
|
|
98
|
+
if (typeof value !== "string") {
|
|
99
|
+
throw wsTurnError(
|
|
100
|
+
"Canonical WebSocket turns require a string request id",
|
|
101
|
+
"CC_WS_REQUEST_ID_REQUIRED",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const canonical = value.normalize("NFC");
|
|
105
|
+
if (
|
|
106
|
+
canonical !== value ||
|
|
107
|
+
!WS_TURN_REQUEST_ID_PATTERN.test(canonical) ||
|
|
108
|
+
Buffer.byteLength(canonical, "utf8") > WS_TURN_REQUEST_ID_MAX_BYTES
|
|
109
|
+
) {
|
|
110
|
+
throw wsTurnError(
|
|
111
|
+
"WebSocket request id is not bounded canonical ASCII",
|
|
112
|
+
"CC_WS_REQUEST_ID_INVALID",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return canonical;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeWsTurnContent(value, role) {
|
|
119
|
+
if (
|
|
120
|
+
typeof value !== "string" ||
|
|
121
|
+
(role === "assistant" && value.trim().length === 0)
|
|
122
|
+
) {
|
|
123
|
+
throw wsTurnError(
|
|
124
|
+
`Canonical WebSocket ${role} content is invalid`,
|
|
125
|
+
role === "assistant"
|
|
126
|
+
? "CC_WS_EMPTY_ASSISTANT_RESPONSE"
|
|
127
|
+
: "CC_WS_USER_MESSAGE_INVALID",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (Buffer.byteLength(value, "utf8") > WS_TURN_CONTENT_MAX_BYTES) {
|
|
131
|
+
throw wsTurnError(
|
|
132
|
+
`Canonical WebSocket ${role} content exceeds the durable event limit`,
|
|
133
|
+
"CC_WS_TURN_CONTENT_TOO_LARGE",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizeWsTurnInputDigest(value) {
|
|
140
|
+
if (typeof value !== "string" || !WS_TURN_INPUT_DIGEST_PATTERN.test(value)) {
|
|
141
|
+
throw wsTurnError(
|
|
142
|
+
"Canonical WebSocket turn input digest is invalid",
|
|
143
|
+
"CC_WS_TURN_INPUT_DIGEST_INVALID",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeWsTurnClaimId(value) {
|
|
150
|
+
if (typeof value !== "string" || !WS_TURN_CLAIM_ID_PATTERN.test(value)) {
|
|
151
|
+
throw wsTurnError(
|
|
152
|
+
"Canonical WebSocket turn claim id is invalid",
|
|
153
|
+
"CC_WS_TURN_CLAIM_ID_INVALID",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeWsTurnFailureCode(value) {
|
|
160
|
+
if (typeof value !== "string" || !WS_TURN_FAILURE_CODE_PATTERN.test(value)) {
|
|
161
|
+
throw wsTurnError(
|
|
162
|
+
"Canonical WebSocket turn failure code is invalid",
|
|
163
|
+
"CC_WS_TURN_FAILURE_CODE_INVALID",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function createWsTurnClaimId() {
|
|
170
|
+
return `claim-${randomUUID()}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function computeWsTurnInputDigest(user) {
|
|
174
|
+
const content = normalizeWsTurnContent(user, "user");
|
|
175
|
+
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function canonicalWsTurnClaimData({ requestId, inputDigest, opaqueClaimId }) {
|
|
179
|
+
return Object.freeze({
|
|
180
|
+
schemaVersion: WS_TURN_SCHEMA_VERSION,
|
|
181
|
+
requestId: normalizeWsTurnRequestId(requestId),
|
|
182
|
+
inputDigest: normalizeWsTurnInputDigest(inputDigest),
|
|
183
|
+
opaqueClaimId: normalizeWsTurnClaimId(opaqueClaimId),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function canonicalWsTurnCompletedData({
|
|
188
|
+
requestId,
|
|
189
|
+
inputDigest = null,
|
|
190
|
+
opaqueClaimId = null,
|
|
191
|
+
user,
|
|
192
|
+
assistant,
|
|
193
|
+
}) {
|
|
194
|
+
const normalizedUser = normalizeWsTurnContent(user, "user");
|
|
195
|
+
const hasClaimAuthority = inputDigest != null || opaqueClaimId != null;
|
|
196
|
+
const data = {
|
|
197
|
+
schemaVersion: WS_TURN_SCHEMA_VERSION,
|
|
198
|
+
requestId: normalizeWsTurnRequestId(requestId),
|
|
199
|
+
outcome: "completed",
|
|
200
|
+
user: Object.freeze({
|
|
201
|
+
role: "user",
|
|
202
|
+
content: normalizedUser,
|
|
203
|
+
}),
|
|
204
|
+
assistant: Object.freeze({
|
|
205
|
+
role: "assistant",
|
|
206
|
+
content: normalizeWsTurnContent(assistant, "assistant"),
|
|
207
|
+
}),
|
|
208
|
+
};
|
|
209
|
+
if (hasClaimAuthority) {
|
|
210
|
+
data.inputDigest = normalizeWsTurnInputDigest(inputDigest);
|
|
211
|
+
data.opaqueClaimId = normalizeWsTurnClaimId(opaqueClaimId);
|
|
212
|
+
if (data.inputDigest !== computeWsTurnInputDigest(normalizedUser)) {
|
|
213
|
+
throw wsTurnError(
|
|
214
|
+
"WebSocket turn settlement input does not match its durable claim",
|
|
215
|
+
"CC_WS_TURN_INPUT_DIGEST_MISMATCH",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return Object.freeze(data);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function canonicalWsTurnFailedData({
|
|
223
|
+
requestId,
|
|
224
|
+
inputDigest,
|
|
225
|
+
opaqueClaimId,
|
|
226
|
+
failureCode,
|
|
227
|
+
}) {
|
|
228
|
+
return Object.freeze({
|
|
229
|
+
schemaVersion: WS_TURN_SCHEMA_VERSION,
|
|
230
|
+
requestId: normalizeWsTurnRequestId(requestId),
|
|
231
|
+
inputDigest: normalizeWsTurnInputDigest(inputDigest),
|
|
232
|
+
opaqueClaimId: normalizeWsTurnClaimId(opaqueClaimId),
|
|
233
|
+
outcome: "failed",
|
|
234
|
+
failure: Object.freeze({ code: normalizeWsTurnFailureCode(failureCode) }),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function projectWsTurnClaim(event) {
|
|
239
|
+
if (event?.type !== WS_TURN_CLAIM_EVENT) return null;
|
|
240
|
+
try {
|
|
241
|
+
if (
|
|
242
|
+
!hasExactKeys(event.data, [
|
|
243
|
+
"schemaVersion",
|
|
244
|
+
"requestId",
|
|
245
|
+
"inputDigest",
|
|
246
|
+
"opaqueClaimId",
|
|
247
|
+
])
|
|
248
|
+
) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const data = canonicalWsTurnClaimData(event.data || {});
|
|
252
|
+
if (event.data?.schemaVersion !== WS_TURN_SCHEMA_VERSION) return null;
|
|
253
|
+
return Object.freeze({
|
|
254
|
+
...data,
|
|
255
|
+
eventHash: typeof event.hash === "string" ? event.hash : null,
|
|
256
|
+
});
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function projectWsTurnSettlement(event) {
|
|
263
|
+
if (event?.type !== WS_TURN_EVENT) return null;
|
|
264
|
+
try {
|
|
265
|
+
if (event.data?.outcome === "failed") {
|
|
266
|
+
if (
|
|
267
|
+
!hasExactKeys(event.data, [
|
|
268
|
+
"schemaVersion",
|
|
269
|
+
"requestId",
|
|
270
|
+
"inputDigest",
|
|
271
|
+
"opaqueClaimId",
|
|
272
|
+
"outcome",
|
|
273
|
+
"failure",
|
|
274
|
+
]) ||
|
|
275
|
+
!hasExactKeys(event.data?.failure, ["code"])
|
|
276
|
+
) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const data = canonicalWsTurnFailedData({
|
|
280
|
+
requestId: event.data?.requestId,
|
|
281
|
+
inputDigest: event.data?.inputDigest,
|
|
282
|
+
opaqueClaimId: event.data?.opaqueClaimId,
|
|
283
|
+
failureCode: event.data?.failure?.code,
|
|
284
|
+
});
|
|
285
|
+
if (event.data?.schemaVersion !== WS_TURN_SCHEMA_VERSION) return null;
|
|
286
|
+
return Object.freeze({
|
|
287
|
+
...data,
|
|
288
|
+
eventHash: typeof event.hash === "string" ? event.hash : null,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
const claimedSettlement =
|
|
292
|
+
event.data?.inputDigest != null || event.data?.opaqueClaimId != null;
|
|
293
|
+
if (
|
|
294
|
+
!hasExactKeys(
|
|
295
|
+
event.data,
|
|
296
|
+
claimedSettlement
|
|
297
|
+
? [
|
|
298
|
+
"schemaVersion",
|
|
299
|
+
"requestId",
|
|
300
|
+
"inputDigest",
|
|
301
|
+
"opaqueClaimId",
|
|
302
|
+
"outcome",
|
|
303
|
+
"user",
|
|
304
|
+
"assistant",
|
|
305
|
+
]
|
|
306
|
+
: ["schemaVersion", "requestId", "outcome", "user", "assistant"],
|
|
307
|
+
) ||
|
|
308
|
+
!hasExactKeys(event.data?.user, ["role", "content"]) ||
|
|
309
|
+
!hasExactKeys(event.data?.assistant, ["role", "content"])
|
|
310
|
+
) {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
const data = canonicalWsTurnCompletedData({
|
|
314
|
+
requestId: event.data?.requestId,
|
|
315
|
+
inputDigest: event.data?.inputDigest,
|
|
316
|
+
opaqueClaimId: event.data?.opaqueClaimId,
|
|
317
|
+
user: event.data?.user?.content,
|
|
318
|
+
assistant: event.data?.assistant?.content,
|
|
319
|
+
});
|
|
320
|
+
if (
|
|
321
|
+
event.data?.schemaVersion !== WS_TURN_SCHEMA_VERSION ||
|
|
322
|
+
event.data?.outcome !== "completed" ||
|
|
323
|
+
event.data?.user?.role !== "user" ||
|
|
324
|
+
event.data?.assistant?.role !== "assistant"
|
|
325
|
+
) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
return Object.freeze({
|
|
329
|
+
...data,
|
|
330
|
+
eventHash: typeof event.hash === "string" ? event.hash : null,
|
|
331
|
+
});
|
|
332
|
+
} catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function projectWsTurnMessages(event) {
|
|
338
|
+
const settlement = projectWsTurnSettlement(event);
|
|
339
|
+
return settlement?.outcome === "completed" ? settlement : null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function wsTurnAuthorityError(message, code = "CC_WS_TURN_AUTHORITY_INVALID") {
|
|
343
|
+
return wsTurnError(message, code);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function createWsTurnLifecycleReducer({
|
|
347
|
+
requestId,
|
|
348
|
+
inputDigest = null,
|
|
349
|
+
} = {}) {
|
|
350
|
+
const canonicalRequestId = normalizeWsTurnRequestId(requestId);
|
|
351
|
+
const expectedInputDigest =
|
|
352
|
+
inputDigest == null ? null : normalizeWsTurnInputDigest(inputDigest);
|
|
353
|
+
let claim = null;
|
|
354
|
+
let settlement = null;
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
accept(event) {
|
|
358
|
+
if (
|
|
359
|
+
event?.type !== WS_TURN_CLAIM_EVENT &&
|
|
360
|
+
event?.type !== WS_TURN_EVENT
|
|
361
|
+
) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (event?.data?.requestId !== canonicalRequestId) return;
|
|
365
|
+
|
|
366
|
+
if (event.type === WS_TURN_CLAIM_EVENT) {
|
|
367
|
+
const projected = projectWsTurnClaim(event);
|
|
368
|
+
if (!projected?.eventHash) {
|
|
369
|
+
throw wsTurnAuthorityError(
|
|
370
|
+
"WebSocket request claim is malformed or unchained",
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
if (claim || settlement) {
|
|
374
|
+
throw wsTurnAuthorityError(
|
|
375
|
+
"WebSocket request has duplicate or out-of-order claim authority",
|
|
376
|
+
"CC_WS_TURN_CLAIM_DUPLICATE",
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
claim = projected;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const projected = projectWsTurnSettlement(event);
|
|
384
|
+
if (!projected?.eventHash) {
|
|
385
|
+
throw wsTurnAuthorityError(
|
|
386
|
+
"WebSocket request settlement is malformed or unchained",
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (settlement) {
|
|
390
|
+
throw wsTurnAuthorityError(
|
|
391
|
+
"WebSocket request has multiple durable settlements",
|
|
392
|
+
"CC_WS_TURN_IDEMPOTENCY_DUPLICATE",
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
const claimedSettlement = projected.opaqueClaimId != null;
|
|
396
|
+
if (claimedSettlement) {
|
|
397
|
+
if (
|
|
398
|
+
!claim ||
|
|
399
|
+
claim.opaqueClaimId !== projected.opaqueClaimId ||
|
|
400
|
+
claim.inputDigest !== projected.inputDigest
|
|
401
|
+
) {
|
|
402
|
+
throw wsTurnAuthorityError(
|
|
403
|
+
"WebSocket request settlement does not match its preceding claim",
|
|
404
|
+
"CC_WS_TURN_CLAIM_MISMATCH",
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
} else if (claim) {
|
|
408
|
+
throw wsTurnAuthorityError(
|
|
409
|
+
"Claimed WebSocket request used a legacy unfenced settlement",
|
|
410
|
+
"CC_WS_TURN_CLAIM_MISMATCH",
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
settlement = projected;
|
|
414
|
+
},
|
|
415
|
+
finish(authority = {}) {
|
|
416
|
+
const durableInputDigest =
|
|
417
|
+
claim?.inputDigest ||
|
|
418
|
+
settlement?.inputDigest ||
|
|
419
|
+
(settlement?.outcome === "completed"
|
|
420
|
+
? computeWsTurnInputDigest(settlement.user.content)
|
|
421
|
+
: null);
|
|
422
|
+
if (
|
|
423
|
+
expectedInputDigest &&
|
|
424
|
+
durableInputDigest &&
|
|
425
|
+
expectedInputDigest !== durableInputDigest
|
|
426
|
+
) {
|
|
427
|
+
throw wsTurnAuthorityError(
|
|
428
|
+
"WebSocket request id was already claimed with different input",
|
|
429
|
+
"CC_WS_REQUEST_ID_CONFLICT",
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
const status = settlement?.outcome || (claim ? "pending" : "none");
|
|
433
|
+
return Object.freeze({
|
|
434
|
+
requestId: canonicalRequestId,
|
|
435
|
+
inputDigest: durableInputDigest || expectedInputDigest,
|
|
436
|
+
status,
|
|
437
|
+
claim,
|
|
438
|
+
settlement,
|
|
439
|
+
turn: settlement?.outcome === "completed" ? settlement : null,
|
|
440
|
+
headHash:
|
|
441
|
+
typeof authority.headHash === "string" ? authority.headHash : null,
|
|
442
|
+
eventCount: Number.isSafeInteger(authority.eventCount)
|
|
443
|
+
? authority.eventCount
|
|
444
|
+
: 0,
|
|
445
|
+
});
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function runSessionScaleFaultHook(name, payload) {
|
|
451
|
+
if (process.env.CC_SESSION_SCALE_FAULT_INJECTION !== "1") return;
|
|
452
|
+
const hook = _sessionScaleFaultHooks[name];
|
|
453
|
+
if (typeof hook === "function") hook(payload);
|
|
454
|
+
}
|
|
24
455
|
|
|
25
456
|
function getSessionsDir() {
|
|
26
457
|
const dir = join(getHomeDir(), "sessions");
|
|
27
|
-
|
|
458
|
+
let current = null;
|
|
459
|
+
try {
|
|
460
|
+
current = lstatSync(dir);
|
|
461
|
+
} catch {
|
|
462
|
+
// The owner-only helper below creates a missing directory and surfaces any
|
|
463
|
+
// other unsafe filesystem state with its canonical error.
|
|
464
|
+
}
|
|
465
|
+
const currentIdentity = current ? `${current.dev}:${current.ino}` : null;
|
|
466
|
+
const unsafePosixMode =
|
|
467
|
+
process.platform !== "win32" &&
|
|
468
|
+
current !== null &&
|
|
469
|
+
(current.mode & 0o777) !== 0o700;
|
|
470
|
+
if (
|
|
471
|
+
securedSessionsDir !== dir ||
|
|
472
|
+
currentIdentity === null ||
|
|
473
|
+
currentIdentity !== securedSessionsDirIdentity ||
|
|
474
|
+
unsafePosixMode
|
|
475
|
+
) {
|
|
476
|
+
ensurePrivateDirectory(dir);
|
|
477
|
+
securedSessionsDir = dir;
|
|
478
|
+
const secured = lstatSync(dir);
|
|
479
|
+
securedSessionsDirIdentity = `${secured.dev}:${secured.ino}`;
|
|
480
|
+
}
|
|
28
481
|
return dir;
|
|
29
482
|
}
|
|
30
483
|
|
|
@@ -61,42 +514,687 @@ export function appendTokenUsage(sessionId, usage) {
|
|
|
61
514
|
appendEvent(sessionId, "token_usage", usage || {});
|
|
62
515
|
}
|
|
63
516
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
517
|
+
function inspectPhysicalTail(filePath, { dryRun = false } = {}) {
|
|
518
|
+
const result = {
|
|
519
|
+
action: "none",
|
|
520
|
+
changed: false,
|
|
521
|
+
discardedBytes: 0,
|
|
522
|
+
discardedRecords: 0,
|
|
523
|
+
fileSize: 0,
|
|
524
|
+
};
|
|
525
|
+
if (!existsSync(filePath)) return result;
|
|
526
|
+
const fd = openSync(filePath, "r+");
|
|
527
|
+
let normalizeNewline = false;
|
|
528
|
+
try {
|
|
529
|
+
const size = fstatSync(fd).size;
|
|
530
|
+
result.fileSize = size;
|
|
531
|
+
if (size > 0) {
|
|
532
|
+
const lastByte = Buffer.allocUnsafe(1);
|
|
533
|
+
readSync(fd, lastByte, 0, 1, size - 1);
|
|
534
|
+
if (lastByte[0] !== 0x0a) {
|
|
535
|
+
// Locate the beginning of the physical tail using bounded reverse IO.
|
|
536
|
+
let cursor = size;
|
|
537
|
+
let tailStart = 0;
|
|
538
|
+
let found = false;
|
|
539
|
+
const chunkSize = 64 * 1024;
|
|
540
|
+
while (cursor > 0 && !found) {
|
|
541
|
+
const length = Math.min(chunkSize, cursor);
|
|
542
|
+
cursor -= length;
|
|
543
|
+
const chunk = Buffer.allocUnsafe(length);
|
|
544
|
+
readSync(fd, chunk, 0, length, cursor);
|
|
545
|
+
const newline = chunk.lastIndexOf(0x0a);
|
|
546
|
+
if (newline >= 0) {
|
|
547
|
+
tailStart = cursor + newline + 1;
|
|
548
|
+
found = true;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const tail = Buffer.allocUnsafe(size - tailStart);
|
|
552
|
+
if (tail.length > 0) readSync(fd, tail, 0, tail.length, tailStart);
|
|
553
|
+
try {
|
|
554
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(tail);
|
|
555
|
+
JSON.parse(text);
|
|
556
|
+
// A valid legacy/manual last record merely lacks its newline; retain
|
|
557
|
+
// it and normalize before appending the next chained event.
|
|
558
|
+
result.action = "normalize-newline";
|
|
559
|
+
result.changed = true;
|
|
560
|
+
normalizeNewline = !dryRun;
|
|
561
|
+
} catch {
|
|
562
|
+
// Crash tail: discard only the one incomplete physical record.
|
|
563
|
+
result.action = "discard-partial-record";
|
|
564
|
+
result.changed = true;
|
|
565
|
+
result.discardedBytes = size - tailStart;
|
|
566
|
+
result.discardedRecords = 1;
|
|
567
|
+
if (!dryRun) ftruncateSync(fd, tailStart);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
} finally {
|
|
572
|
+
closeSync(fd);
|
|
573
|
+
}
|
|
574
|
+
if (normalizeNewline) {
|
|
575
|
+
appendFileSync(filePath, "\n", { encoding: "utf8", mode: 0o600 });
|
|
576
|
+
}
|
|
577
|
+
if (!dryRun && result.changed) ensurePrivateFile(filePath);
|
|
578
|
+
return result;
|
|
579
|
+
}
|
|
69
580
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
581
|
+
/**
|
|
582
|
+
* Repair at most one crash-truncated physical tail and resolve the chain hash.
|
|
583
|
+
* This is called only while the transcript's cross-process lock is held, so a
|
|
584
|
+
* second writer can never reuse a stale in-process hash.
|
|
585
|
+
*/
|
|
586
|
+
function _resolveChainTail(filePath) {
|
|
587
|
+
if (!existsSync(filePath)) return { prevHash: null, recovery: null };
|
|
588
|
+
// Fast path: almost every append follows a newline-terminated record. Read
|
|
589
|
+
// that tail once and avoid opening the file a second time just to inspect its
|
|
590
|
+
// last byte. The slow repair path runs only for an unterminated crash tail.
|
|
591
|
+
const initial = iterateFileLinesReverseSync(filePath);
|
|
592
|
+
const first = initial.next();
|
|
593
|
+
if (!first.done && first.value.terminated) {
|
|
594
|
+
try {
|
|
595
|
+
let current = first;
|
|
596
|
+
while (!current.done) {
|
|
597
|
+
try {
|
|
598
|
+
const event = JSON.parse(current.value.line);
|
|
599
|
+
return {
|
|
600
|
+
prevHash: typeof event?.hash === "string" ? event.hash : null,
|
|
601
|
+
recovery: null,
|
|
602
|
+
};
|
|
603
|
+
} catch {
|
|
604
|
+
current = initial.next();
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return { prevHash: null, recovery: null };
|
|
608
|
+
} finally {
|
|
609
|
+
initial.return?.();
|
|
610
|
+
}
|
|
76
611
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
612
|
+
initial.return?.();
|
|
613
|
+
|
|
614
|
+
const recovery = inspectPhysicalTail(filePath);
|
|
615
|
+
for (const { line } of iterateFileLinesReverseSync(filePath)) {
|
|
616
|
+
try {
|
|
617
|
+
const event = JSON.parse(line);
|
|
618
|
+
return {
|
|
619
|
+
prevHash: typeof event?.hash === "string" ? event.hash : null,
|
|
620
|
+
recovery: recovery.changed ? recovery : null,
|
|
621
|
+
};
|
|
622
|
+
} catch {
|
|
623
|
+
// A malformed historical line is verified separately; keep searching so
|
|
624
|
+
// this append never chains from arbitrary bytes.
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return { prevHash: null, recovery: recovery.changed ? recovery : null };
|
|
82
628
|
}
|
|
83
629
|
|
|
84
|
-
|
|
630
|
+
function readVerifiedWsAuthorityLocked(
|
|
631
|
+
sessionId,
|
|
632
|
+
filePath,
|
|
633
|
+
requestId,
|
|
634
|
+
inputDigest,
|
|
635
|
+
) {
|
|
636
|
+
const reducer = createWsTurnLifecycleReducer({ requestId, inputDigest });
|
|
637
|
+
const verification = verifyTranscriptFile(filePath, {
|
|
638
|
+
onVerifiedEvent: (event) => reducer.accept(event),
|
|
639
|
+
});
|
|
640
|
+
assertVerifiedTranscriptAnchor(sessionId, verification);
|
|
641
|
+
const authority = Object.freeze({
|
|
642
|
+
headHash: verification.lastHash,
|
|
643
|
+
eventCount: verification.chainedEvents,
|
|
644
|
+
});
|
|
645
|
+
return Object.freeze({
|
|
646
|
+
authority,
|
|
647
|
+
state: reducer.finish(authority),
|
|
648
|
+
messages: Object.freeze([...rebuildVerifiedMessagesFromFile(filePath)]),
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function appendVerifiedWsAuthorityEventLocked(
|
|
653
|
+
sessionId,
|
|
654
|
+
filePath,
|
|
655
|
+
type,
|
|
656
|
+
data,
|
|
657
|
+
previousHeadHash,
|
|
658
|
+
{ verifySettlement = true } = {},
|
|
659
|
+
) {
|
|
660
|
+
const core = { type, timestamp: Date.now(), data };
|
|
661
|
+
const hash = computeEventHash(previousHeadHash, core);
|
|
662
|
+
const event = { ...core, prevHash: previousHeadHash, hash };
|
|
663
|
+
appendFileSync(filePath, `${JSON.stringify(event)}\n`, {
|
|
664
|
+
encoding: "utf8",
|
|
665
|
+
mode: 0o600,
|
|
666
|
+
});
|
|
667
|
+
ensurePrivateFile(filePath);
|
|
668
|
+
runSessionScaleFaultHook("afterTranscriptAppend", {
|
|
669
|
+
sessionId,
|
|
670
|
+
type,
|
|
671
|
+
event,
|
|
672
|
+
filePath,
|
|
673
|
+
});
|
|
674
|
+
try {
|
|
675
|
+
recordSessionEvent(getSessionsDir(), sessionId, event, hash);
|
|
676
|
+
} catch (cause) {
|
|
677
|
+
const error = new Error(
|
|
678
|
+
`Session authority anchor could not be persisted: ${sessionId}`,
|
|
679
|
+
{ cause },
|
|
680
|
+
);
|
|
681
|
+
error.code = "SESSION_INDEX_ANCHOR_FAILED";
|
|
682
|
+
error.sessionId = sessionId;
|
|
683
|
+
error.commitState = "unknown";
|
|
684
|
+
throw error;
|
|
685
|
+
}
|
|
686
|
+
if (verifySettlement) {
|
|
687
|
+
const verification = verifyTranscriptFile(filePath);
|
|
688
|
+
try {
|
|
689
|
+
assertVerifiedTranscriptAnchor(sessionId, verification);
|
|
690
|
+
} catch (cause) {
|
|
691
|
+
const error = new Error(
|
|
692
|
+
`Session authority settlement could not be verified: ${sessionId}`,
|
|
693
|
+
{ cause },
|
|
694
|
+
);
|
|
695
|
+
error.code = "SESSION_INDEX_ANCHOR_FAILED";
|
|
696
|
+
error.sessionId = sessionId;
|
|
697
|
+
error.commitState = "unknown";
|
|
698
|
+
throw error;
|
|
699
|
+
}
|
|
700
|
+
if (verification.lastHash !== hash || event.prevHash !== previousHeadHash) {
|
|
701
|
+
const error = new Error(
|
|
702
|
+
`Session authority settlement head is not durable: ${sessionId}`,
|
|
703
|
+
);
|
|
704
|
+
error.code = "SESSION_INDEX_ANCHOR_FAILED";
|
|
705
|
+
error.sessionId = sessionId;
|
|
706
|
+
error.commitState = "unknown";
|
|
707
|
+
throw error;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return Object.freeze({ hash, event });
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function withVerifiedWsTurnLock(sessionId, task) {
|
|
714
|
+
const filePath = sessionPath(sessionId);
|
|
715
|
+
return withFileLock(filePath, () => task(filePath), {
|
|
716
|
+
failIfUnavailable: true,
|
|
717
|
+
timeoutMs: 30_000,
|
|
718
|
+
retryMs: 1,
|
|
719
|
+
maxRetryMs: 8,
|
|
720
|
+
retryJitterMs: 4,
|
|
721
|
+
yieldAfterReleaseMs: 2,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function encodeEventMessageProvenance(type, data) {
|
|
726
|
+
if (
|
|
727
|
+
(type === "compact" || type === "checkpoint_timeline_commit") &&
|
|
728
|
+
data &&
|
|
729
|
+
typeof data === "object" &&
|
|
730
|
+
!Array.isArray(data) &&
|
|
731
|
+
Array.isArray(data.messages)
|
|
732
|
+
) {
|
|
733
|
+
return {
|
|
734
|
+
...data,
|
|
735
|
+
messages: data.messages.map(encodePersistedMessage),
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
if (type === "system") return encodePersistedMessage(data);
|
|
739
|
+
return data;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const SESSION_AUTHORITY_RECOVERY_TEXT_LIMITS = Object.freeze({
|
|
743
|
+
safetyId: 512,
|
|
744
|
+
safetyIdentity: 512,
|
|
745
|
+
safetyPlanIdentity: 1024,
|
|
746
|
+
safetyCoverage: 64,
|
|
747
|
+
restorePhase: 64,
|
|
748
|
+
branchSessionId: 512,
|
|
749
|
+
});
|
|
750
|
+
const SESSION_AUTHORITY_RECOVERY_CREATED_PATH_LIMIT = 256;
|
|
751
|
+
const SESSION_AUTHORITY_RECOVERY_PATH_LENGTH_LIMIT = 4096;
|
|
752
|
+
|
|
753
|
+
function sanitizeSessionAuthorityRecoveryEvidence(value) {
|
|
754
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
755
|
+
const evidence = {};
|
|
756
|
+
for (const [field, limit] of Object.entries(
|
|
757
|
+
SESSION_AUTHORITY_RECOVERY_TEXT_LIMITS,
|
|
758
|
+
)) {
|
|
759
|
+
if (typeof value[field] === "string" && value[field].length > 0) {
|
|
760
|
+
evidence[field] = value[field].slice(0, limit);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (Array.isArray(value.createdPaths)) {
|
|
764
|
+
evidence.createdPaths = Object.freeze(
|
|
765
|
+
value.createdPaths
|
|
766
|
+
.filter((item) => typeof item === "string")
|
|
767
|
+
.slice(0, SESSION_AUTHORITY_RECOVERY_CREATED_PATH_LIMIT)
|
|
768
|
+
.map((item) =>
|
|
769
|
+
item.slice(0, SESSION_AUTHORITY_RECOVERY_PATH_LENGTH_LIMIT),
|
|
770
|
+
),
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
return Object.keys(evidence).length > 0 ? Object.freeze(evidence) : null;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function appendEventLocked(
|
|
777
|
+
sessionId,
|
|
778
|
+
type,
|
|
779
|
+
data,
|
|
780
|
+
{ expectedHeadHash, compareHead = false, requireIndexAnchor = false } = {},
|
|
781
|
+
) {
|
|
85
782
|
const filePath = sessionPath(sessionId);
|
|
86
|
-
withFileLock(
|
|
783
|
+
return withFileLock(
|
|
87
784
|
filePath,
|
|
88
785
|
() => {
|
|
89
|
-
const
|
|
90
|
-
|
|
786
|
+
const existingMeta = readSessionMeta(getSessionsDir(), sessionId);
|
|
787
|
+
if (
|
|
788
|
+
!existsSync(filePath) &&
|
|
789
|
+
existingMeta?.deleted === true &&
|
|
790
|
+
type !== "session_start"
|
|
791
|
+
) {
|
|
792
|
+
const error = new Error(`Session was deleted: ${sessionId}`);
|
|
793
|
+
error.code = "SESSION_DELETED";
|
|
794
|
+
throw error;
|
|
795
|
+
}
|
|
796
|
+
const { prevHash, recovery } = _resolveChainTail(filePath);
|
|
797
|
+
if (compareHead && prevHash !== (expectedHeadHash || null)) {
|
|
798
|
+
const error = new Error(
|
|
799
|
+
`Session revision changed for ${sessionId}; refresh the checkpoint timeline`,
|
|
800
|
+
);
|
|
801
|
+
error.code = "SESSION_REVISION_STALE";
|
|
802
|
+
error.expectedHeadHash = expectedHeadHash || null;
|
|
803
|
+
error.actualHeadHash = prevHash;
|
|
804
|
+
throw error;
|
|
805
|
+
}
|
|
806
|
+
const persistedData = encodeEventMessageProvenance(type, data);
|
|
807
|
+
const core = { type, timestamp: Date.now(), data: persistedData };
|
|
91
808
|
const hash = computeEventHash(prevHash, core);
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
809
|
+
const event = { ...core, prevHash, hash };
|
|
810
|
+
const line = JSON.stringify(event) + "\n";
|
|
811
|
+
appendFileSync(filePath, line, { encoding: "utf8", mode: 0o600 });
|
|
812
|
+
ensurePrivateFile(filePath);
|
|
813
|
+
runSessionScaleFaultHook("afterTranscriptAppend", {
|
|
814
|
+
sessionId,
|
|
815
|
+
type,
|
|
816
|
+
event,
|
|
817
|
+
filePath,
|
|
818
|
+
});
|
|
819
|
+
try {
|
|
820
|
+
recordSessionEvent(getSessionsDir(), sessionId, event, hash);
|
|
821
|
+
} catch (cause) {
|
|
822
|
+
if (requireIndexAnchor) {
|
|
823
|
+
const error = new Error(
|
|
824
|
+
`Session authority anchor could not be persisted: ${sessionId}`,
|
|
825
|
+
{ cause },
|
|
826
|
+
);
|
|
827
|
+
error.code = "SESSION_INDEX_ANCHOR_FAILED";
|
|
828
|
+
error.sessionId = sessionId;
|
|
829
|
+
throw error;
|
|
830
|
+
}
|
|
831
|
+
// The metadata index is explicitly rebuildable. Never turn a committed
|
|
832
|
+
// transcript event into an apparent failure that a caller may retry.
|
|
833
|
+
}
|
|
834
|
+
if (requireIndexAnchor) {
|
|
835
|
+
const verification = verifyTranscriptFile(filePath);
|
|
836
|
+
const anchoredMeta = readSessionMeta(getSessionsDir(), sessionId);
|
|
837
|
+
const anchored =
|
|
838
|
+
verification.status === TRANSCRIPT_CHAIN_STATUS.VERIFIED &&
|
|
839
|
+
verification.malformedLines === 0 &&
|
|
840
|
+
!verification.truncatedTail &&
|
|
841
|
+
anchoredMeta?.deleted !== true &&
|
|
842
|
+
anchoredMeta?.last_hash === hash &&
|
|
843
|
+
Number(anchoredMeta?.event_count) === verification.chainedEvents;
|
|
844
|
+
if (!anchored) {
|
|
845
|
+
const error = new Error(
|
|
846
|
+
`Session authority anchor does not match the transcript: ${sessionId}`,
|
|
847
|
+
);
|
|
848
|
+
error.code = "SESSION_INDEX_ANCHOR_FAILED";
|
|
849
|
+
error.sessionId = sessionId;
|
|
850
|
+
error.verification = verification;
|
|
851
|
+
throw error;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return { hash, recovery };
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
failIfUnavailable: true,
|
|
858
|
+
timeoutMs: 30_000,
|
|
859
|
+
retryMs: 1,
|
|
860
|
+
maxRetryMs: 8,
|
|
861
|
+
retryJitterMs: 4,
|
|
862
|
+
yieldAfterReleaseMs: 2,
|
|
95
863
|
},
|
|
96
|
-
{ failIfUnavailable: true },
|
|
97
864
|
);
|
|
98
865
|
}
|
|
99
866
|
|
|
867
|
+
export function appendEvent(sessionId, type, data) {
|
|
868
|
+
return appendEventLocked(sessionId, type, data);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Append an authority-bearing event only when both the transcript and its
|
|
873
|
+
* crash/tail-truncation anchor are durably updated and still agree.
|
|
874
|
+
*/
|
|
875
|
+
export function appendAuthorityEvent(sessionId, type, data) {
|
|
876
|
+
return appendEventLocked(sessionId, type, data, {
|
|
877
|
+
requireIndexAnchor: true,
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Compare-and-append for revisioned IDE actions. The head check and append run
|
|
883
|
+
* under the transcript's canonical writer lock, so a stale preview can never
|
|
884
|
+
* commit after another session writer advances the chain.
|
|
885
|
+
*/
|
|
886
|
+
export function appendEventIfHead(
|
|
887
|
+
sessionId,
|
|
888
|
+
type,
|
|
889
|
+
data,
|
|
890
|
+
expectedHeadHash = null,
|
|
891
|
+
) {
|
|
892
|
+
return appendEventLocked(sessionId, type, data, {
|
|
893
|
+
expectedHeadHash,
|
|
894
|
+
compareHead: true,
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Authority-bearing compare-and-append with a mandatory index anchor. */
|
|
899
|
+
export function appendAuthorityEventIfHead(
|
|
900
|
+
sessionId,
|
|
901
|
+
type,
|
|
902
|
+
data,
|
|
903
|
+
expectedHeadHash = null,
|
|
904
|
+
) {
|
|
905
|
+
return appendEventLocked(sessionId, type, data, {
|
|
906
|
+
expectedHeadHash,
|
|
907
|
+
compareHead: true,
|
|
908
|
+
requireIndexAnchor: true,
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Run a synchronous authority transaction while holding the canonical
|
|
914
|
+
* transcript writer lock for its whole lifetime.
|
|
915
|
+
*
|
|
916
|
+
* A sequence of separate compare-and-appends leaves a gap between commits:
|
|
917
|
+
* callers may mutate an external resource after the first event, then lose the
|
|
918
|
+
* second CAS to another session writer. This primitive closes that gap. It
|
|
919
|
+
* validates the transcript + sidecar anchor and the caller's expected head
|
|
920
|
+
* before invoking `task`, then keeps every authority append on the same locked
|
|
921
|
+
* chain. The callback must be synchronous; retaining the writer beyond the
|
|
922
|
+
* lock lifetime would make the apparent transaction unsafe.
|
|
923
|
+
*
|
|
924
|
+
* External mutations may register bounded, recovery-only evidence through the
|
|
925
|
+
* writer. It is attached only when final settlement is unverifiable; the
|
|
926
|
+
* callback's arbitrary return value is never copied into an error.
|
|
927
|
+
*
|
|
928
|
+
* This is a transaction-scoped writer lease, not a general host/session lease
|
|
929
|
+
* and not a filesystem power-loss transaction. Callers that coordinate an
|
|
930
|
+
* external resource must still journal crash recovery for that resource.
|
|
931
|
+
*/
|
|
932
|
+
export function withSessionAuthorityTransaction(
|
|
933
|
+
sessionId,
|
|
934
|
+
expectedHeadHash,
|
|
935
|
+
task,
|
|
936
|
+
) {
|
|
937
|
+
if (typeof task !== "function") {
|
|
938
|
+
throw new TypeError("Session authority transaction callback is required");
|
|
939
|
+
}
|
|
940
|
+
if (task.constructor?.name === "AsyncFunction") {
|
|
941
|
+
throw new TypeError(
|
|
942
|
+
"Session authority transaction callback must be synchronous",
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const filePath = sessionPath(sessionId);
|
|
947
|
+
return withFileLock(
|
|
948
|
+
filePath,
|
|
949
|
+
() => {
|
|
950
|
+
const existingMeta = readSessionMeta(getSessionsDir(), sessionId);
|
|
951
|
+
if (!existsSync(filePath) && existingMeta?.deleted === true) {
|
|
952
|
+
const error = new Error(`Session was deleted: ${sessionId}`);
|
|
953
|
+
error.code = "SESSION_DELETED";
|
|
954
|
+
throw error;
|
|
955
|
+
}
|
|
956
|
+
if (!existsSync(filePath) && existingMeta !== null) {
|
|
957
|
+
throw unverifiedTranscriptError(sessionId, {
|
|
958
|
+
status: "missing",
|
|
959
|
+
reason: "session sidecar exists without its transcript",
|
|
960
|
+
lastHash: null,
|
|
961
|
+
chainedEvents: 0,
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
let currentHeadHash = null;
|
|
966
|
+
if (existsSync(filePath)) {
|
|
967
|
+
// Normalize or discard only an incomplete physical tail before the
|
|
968
|
+
// verified sample. The existing sidecar already anchors the last
|
|
969
|
+
// complete event, so a crash tail never becomes transaction authority.
|
|
970
|
+
_resolveChainTail(filePath);
|
|
971
|
+
const verification = verifyTranscriptFile(filePath);
|
|
972
|
+
assertVerifiedTranscriptAnchor(sessionId, verification);
|
|
973
|
+
currentHeadHash = verification.lastHash;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const expected = expectedHeadHash || null;
|
|
977
|
+
if (currentHeadHash !== expected) {
|
|
978
|
+
const error = new Error(
|
|
979
|
+
`Session revision changed for ${sessionId}; refresh the checkpoint timeline`,
|
|
980
|
+
);
|
|
981
|
+
error.code = "SESSION_REVISION_STALE";
|
|
982
|
+
error.expectedHeadHash = expected;
|
|
983
|
+
error.actualHeadHash = currentHeadHash;
|
|
984
|
+
throw error;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
let appendAttempts = 0;
|
|
988
|
+
let writerActive = true;
|
|
989
|
+
let writerPoisoned = false;
|
|
990
|
+
let transactionRecoveryEvidence = null;
|
|
991
|
+
const writer = Object.freeze({
|
|
992
|
+
initialHeadHash: currentHeadHash,
|
|
993
|
+
currentHeadHash: () => currentHeadHash,
|
|
994
|
+
retainRecoveryEvidence(evidence) {
|
|
995
|
+
if (!writerActive) {
|
|
996
|
+
const error = new Error(
|
|
997
|
+
`Session authority transaction is already closed: ${sessionId}`,
|
|
998
|
+
);
|
|
999
|
+
error.code = "SESSION_AUTHORITY_TRANSACTION_CLOSED";
|
|
1000
|
+
throw error;
|
|
1001
|
+
}
|
|
1002
|
+
const sanitized = sanitizeSessionAuthorityRecoveryEvidence(evidence);
|
|
1003
|
+
if (sanitized) {
|
|
1004
|
+
transactionRecoveryEvidence = Object.freeze({
|
|
1005
|
+
...(transactionRecoveryEvidence || {}),
|
|
1006
|
+
...sanitized,
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
return transactionRecoveryEvidence;
|
|
1010
|
+
},
|
|
1011
|
+
appendAuthorityEvent(type, data) {
|
|
1012
|
+
if (!writerActive) {
|
|
1013
|
+
const error = new Error(
|
|
1014
|
+
`Session authority transaction is already closed: ${sessionId}`,
|
|
1015
|
+
);
|
|
1016
|
+
error.code = "SESSION_AUTHORITY_TRANSACTION_CLOSED";
|
|
1017
|
+
throw error;
|
|
1018
|
+
}
|
|
1019
|
+
if (writerPoisoned) {
|
|
1020
|
+
const error = new Error(
|
|
1021
|
+
`Session authority transaction writer is poisoned: ${sessionId}`,
|
|
1022
|
+
);
|
|
1023
|
+
error.code = "SESSION_AUTHORITY_TRANSACTION_POISONED";
|
|
1024
|
+
error.commitState = "unknown";
|
|
1025
|
+
throw error;
|
|
1026
|
+
}
|
|
1027
|
+
const persistedData = encodeEventMessageProvenance(type, data);
|
|
1028
|
+
appendAttempts += 1;
|
|
1029
|
+
let appended;
|
|
1030
|
+
try {
|
|
1031
|
+
appended = appendVerifiedWsAuthorityEventLocked(
|
|
1032
|
+
sessionId,
|
|
1033
|
+
filePath,
|
|
1034
|
+
type,
|
|
1035
|
+
persistedData,
|
|
1036
|
+
currentHeadHash,
|
|
1037
|
+
{ verifySettlement: false },
|
|
1038
|
+
);
|
|
1039
|
+
} catch (error) {
|
|
1040
|
+
// Bytes may already have reached the transcript before a
|
|
1041
|
+
// sidecar/activity failure. The actual head is then unknown, so a
|
|
1042
|
+
// second append from this writer would risk forking the chain.
|
|
1043
|
+
writerPoisoned = true;
|
|
1044
|
+
if (error && !error.commitState) error.commitState = "unknown";
|
|
1045
|
+
throw error;
|
|
1046
|
+
}
|
|
1047
|
+
currentHeadHash = appended.hash;
|
|
1048
|
+
return appended;
|
|
1049
|
+
},
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
let result;
|
|
1053
|
+
let bodyError = null;
|
|
1054
|
+
let bodyThrew = false;
|
|
1055
|
+
try {
|
|
1056
|
+
result = task(writer);
|
|
1057
|
+
if (result && typeof result.then === "function") {
|
|
1058
|
+
const error = new TypeError(
|
|
1059
|
+
"Session authority transaction callback must be synchronous",
|
|
1060
|
+
);
|
|
1061
|
+
error.code = "SESSION_AUTHORITY_TRANSACTION_ASYNC";
|
|
1062
|
+
error.commitState = "unknown";
|
|
1063
|
+
throw error;
|
|
1064
|
+
}
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
bodyThrew = true;
|
|
1067
|
+
bodyError = error;
|
|
1068
|
+
} finally {
|
|
1069
|
+
// A callback can retain this object or return a Promise/thenable. Revoke
|
|
1070
|
+
// it before settlement and before releasing the lock so no later
|
|
1071
|
+
// microtask can append outside the transaction critical section.
|
|
1072
|
+
writerActive = false;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (appendAttempts > 0) {
|
|
1076
|
+
try {
|
|
1077
|
+
const verification = verifyTranscriptFile(filePath);
|
|
1078
|
+
assertVerifiedTranscriptAnchor(sessionId, verification);
|
|
1079
|
+
if (verification.lastHash !== currentHeadHash) {
|
|
1080
|
+
throw new Error(
|
|
1081
|
+
`Session authority settlement head is not durable: ${sessionId}`,
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
} catch (cause) {
|
|
1085
|
+
const error = new Error(
|
|
1086
|
+
`Session authority transaction could not be verified: ${sessionId}`,
|
|
1087
|
+
{ cause },
|
|
1088
|
+
);
|
|
1089
|
+
error.code = "SESSION_INDEX_ANCHOR_FAILED";
|
|
1090
|
+
error.sessionId = sessionId;
|
|
1091
|
+
error.commitState = "unknown";
|
|
1092
|
+
if (bodyThrew) error.transactionError = bodyError;
|
|
1093
|
+
if (transactionRecoveryEvidence) {
|
|
1094
|
+
// Never attach the arbitrary callback result. Callers must opt in
|
|
1095
|
+
// to this bounded recovery-only projection before settlement.
|
|
1096
|
+
error.transactionRecoveryEvidence = transactionRecoveryEvidence;
|
|
1097
|
+
}
|
|
1098
|
+
throw error;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
if (bodyThrew) throw bodyError;
|
|
1102
|
+
return result;
|
|
1103
|
+
},
|
|
1104
|
+
{
|
|
1105
|
+
failIfUnavailable: true,
|
|
1106
|
+
timeoutMs: 30_000,
|
|
1107
|
+
retryMs: 1,
|
|
1108
|
+
maxRetryMs: 8,
|
|
1109
|
+
retryJitterMs: 4,
|
|
1110
|
+
yieldAfterReleaseMs: 2,
|
|
1111
|
+
},
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function verifyTranscriptFile(filePath, options = {}) {
|
|
1116
|
+
const onVerifiedEvent =
|
|
1117
|
+
typeof options.onVerifiedEvent === "function"
|
|
1118
|
+
? options.onVerifiedEvent
|
|
1119
|
+
: null;
|
|
1120
|
+
const ioMetrics = options.ioMetrics ?? null;
|
|
1121
|
+
const result = {
|
|
1122
|
+
status: TRANSCRIPT_CHAIN_STATUS.EMPTY,
|
|
1123
|
+
chainedEvents: 0,
|
|
1124
|
+
legacyEvents: 0,
|
|
1125
|
+
malformedLines: 0,
|
|
1126
|
+
truncatedTail: false,
|
|
1127
|
+
firstInvalidLine: null,
|
|
1128
|
+
reason: null,
|
|
1129
|
+
lastHash: null,
|
|
1130
|
+
};
|
|
1131
|
+
let lastHash = null;
|
|
1132
|
+
let sawChain = false;
|
|
1133
|
+
const tampered = (lineNo, reason) => ({
|
|
1134
|
+
...result,
|
|
1135
|
+
status: TRANSCRIPT_CHAIN_STATUS.TAMPERED,
|
|
1136
|
+
firstInvalidLine: lineNo,
|
|
1137
|
+
reason,
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
for (const { line, lineNo, terminated } of iterateFileLinesSync(filePath, {
|
|
1141
|
+
ioMetrics,
|
|
1142
|
+
})) {
|
|
1143
|
+
let event;
|
|
1144
|
+
try {
|
|
1145
|
+
event = JSON.parse(line);
|
|
1146
|
+
} catch {
|
|
1147
|
+
result.malformedLines += 1;
|
|
1148
|
+
if (!terminated) {
|
|
1149
|
+
result.truncatedTail = true;
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
if (sawChain) {
|
|
1153
|
+
return tampered(lineNo, "malformed line inside hash chain");
|
|
1154
|
+
}
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
if (event && typeof event.hash === "string") {
|
|
1159
|
+
const expectedPrev = sawChain ? lastHash : null;
|
|
1160
|
+
if ((event.prevHash ?? null) !== expectedPrev) {
|
|
1161
|
+
return tampered(
|
|
1162
|
+
lineNo,
|
|
1163
|
+
sawChain
|
|
1164
|
+
? "hash chain linkage broken (record deleted, inserted, or reordered)"
|
|
1165
|
+
: "chain does not start at a genesis record (head records removed)",
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
if (computeEventHash(expectedPrev, event) !== event.hash) {
|
|
1169
|
+
return tampered(lineNo, "event content does not match its hash");
|
|
1170
|
+
}
|
|
1171
|
+
sawChain = true;
|
|
1172
|
+
lastHash = event.hash;
|
|
1173
|
+
result.lastHash = lastHash;
|
|
1174
|
+
result.chainedEvents += 1;
|
|
1175
|
+
onVerifiedEvent?.(event);
|
|
1176
|
+
} else {
|
|
1177
|
+
if (sawChain) {
|
|
1178
|
+
return tampered(
|
|
1179
|
+
lineNo,
|
|
1180
|
+
"unchained record after hash chain started (manual append or downgrade write)",
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
result.legacyEvents += 1;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
if (result.chainedEvents > 0) {
|
|
1188
|
+
result.status =
|
|
1189
|
+
result.legacyEvents > 0
|
|
1190
|
+
? TRANSCRIPT_CHAIN_STATUS.PARTIAL
|
|
1191
|
+
: TRANSCRIPT_CHAIN_STATUS.VERIFIED;
|
|
1192
|
+
} else if (result.legacyEvents > 0 || result.malformedLines > 0) {
|
|
1193
|
+
result.status = TRANSCRIPT_CHAIN_STATUS.LEGACY;
|
|
1194
|
+
}
|
|
1195
|
+
return result;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
100
1198
|
/**
|
|
101
1199
|
* Verify a session transcript's hash chain (tamper-evidence).
|
|
102
1200
|
* Statuses: verified | partial (legacy prefix + valid chain) | legacy
|
|
@@ -112,7 +1210,7 @@ export function verifySession(sessionId) {
|
|
|
112
1210
|
}
|
|
113
1211
|
return {
|
|
114
1212
|
sessionId,
|
|
115
|
-
...
|
|
1213
|
+
...verifyTranscriptFile(filePath),
|
|
116
1214
|
};
|
|
117
1215
|
}
|
|
118
1216
|
|
|
@@ -125,6 +1223,137 @@ export function verifyAllSessions(options = {}) {
|
|
|
125
1223
|
.map((file) => verifySession(basename(file, ".jsonl")));
|
|
126
1224
|
}
|
|
127
1225
|
|
|
1226
|
+
function indexedProjectionMatchesTranscript(meta, verification, validation) {
|
|
1227
|
+
return (
|
|
1228
|
+
verification.status === TRANSCRIPT_CHAIN_STATUS.VERIFIED &&
|
|
1229
|
+
validation.valid === true &&
|
|
1230
|
+
meta?.deleted !== true &&
|
|
1231
|
+
Number(meta?.event_count) === validation.eventCount &&
|
|
1232
|
+
(meta?.last_hash ?? null) === (verification.lastHash ?? null)
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Diagnose and repair the final physical transcript record plus its rebuildable
|
|
1238
|
+
* metadata projection. Interior corruption and hash-chain tampering are never
|
|
1239
|
+
* rewritten: repair may append a missing newline, discard one crash-partial
|
|
1240
|
+
* record, or rebuild a stale sidecar/activity index from a healthy transcript.
|
|
1241
|
+
*/
|
|
1242
|
+
export function repairSession(sessionId, options = {}) {
|
|
1243
|
+
const dryRun = options.dryRun === true;
|
|
1244
|
+
if (isUnsafeSessionId(sessionId)) {
|
|
1245
|
+
return {
|
|
1246
|
+
sessionId,
|
|
1247
|
+
dryRun,
|
|
1248
|
+
changed: false,
|
|
1249
|
+
healthy: false,
|
|
1250
|
+
status: "invalid-id",
|
|
1251
|
+
reason: "invalid session id",
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
const filePath = sessionPath(sessionId);
|
|
1255
|
+
if (!existsSync(filePath)) {
|
|
1256
|
+
return {
|
|
1257
|
+
sessionId,
|
|
1258
|
+
dryRun,
|
|
1259
|
+
changed: false,
|
|
1260
|
+
healthy: false,
|
|
1261
|
+
status: "not-found",
|
|
1262
|
+
reason: "session file not found",
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const result = withFileLock(
|
|
1267
|
+
filePath,
|
|
1268
|
+
() => {
|
|
1269
|
+
const dir = getSessionsDir();
|
|
1270
|
+
const before = verifyTranscriptFile(filePath);
|
|
1271
|
+
const beforeValidation = validateJsonlSession(sessionId);
|
|
1272
|
+
const repair = inspectPhysicalTail(filePath, { dryRun });
|
|
1273
|
+
const after = dryRun ? null : verifyTranscriptFile(filePath);
|
|
1274
|
+
const afterValidation = dryRun ? null : validateJsonlSession(sessionId);
|
|
1275
|
+
const effective = after || before;
|
|
1276
|
+
const effectiveValidation = afterValidation || beforeValidation;
|
|
1277
|
+
const transcriptHealthy =
|
|
1278
|
+
effective.status !== TRANSCRIPT_CHAIN_STATUS.TAMPERED &&
|
|
1279
|
+
!(dryRun
|
|
1280
|
+
? before.truncatedTail || repair.changed
|
|
1281
|
+
: after.truncatedTail) &&
|
|
1282
|
+
effectiveValidation.valid;
|
|
1283
|
+
const currentMeta = readSessionMeta(dir, sessionId);
|
|
1284
|
+
const currentActivity = readLatestSessionActivity(dir, sessionId);
|
|
1285
|
+
const indexRepairRequired =
|
|
1286
|
+
transcriptHealthy &&
|
|
1287
|
+
(!indexedProjectionMatchesTranscript(
|
|
1288
|
+
currentMeta,
|
|
1289
|
+
effective,
|
|
1290
|
+
effectiveValidation,
|
|
1291
|
+
) ||
|
|
1292
|
+
!indexedProjectionMatchesTranscript(
|
|
1293
|
+
currentActivity,
|
|
1294
|
+
effective,
|
|
1295
|
+
effectiveValidation,
|
|
1296
|
+
));
|
|
1297
|
+
let indexRebuilt = false;
|
|
1298
|
+
let indexRepairError = null;
|
|
1299
|
+
if (!dryRun && indexRepairRequired) {
|
|
1300
|
+
try {
|
|
1301
|
+
rebuildSessionMetaUnlocked(dir, sessionId, filePath);
|
|
1302
|
+
indexRebuilt = true;
|
|
1303
|
+
} catch (cause) {
|
|
1304
|
+
indexRepairError = {
|
|
1305
|
+
code: cause?.code || null,
|
|
1306
|
+
message: String(cause?.message || cause),
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
const healthy = dryRun
|
|
1311
|
+
? transcriptHealthy && !repair.changed && !indexRepairRequired
|
|
1312
|
+
: transcriptHealthy && (!indexRepairRequired || indexRebuilt);
|
|
1313
|
+
return {
|
|
1314
|
+
sessionId,
|
|
1315
|
+
dryRun,
|
|
1316
|
+
changed: repair.changed || indexRebuilt,
|
|
1317
|
+
physicalChanged: repair.changed,
|
|
1318
|
+
indexChanged: indexRebuilt,
|
|
1319
|
+
wouldChange: dryRun && (repair.changed || indexRepairRequired),
|
|
1320
|
+
action:
|
|
1321
|
+
repair.action === "none" && indexRepairRequired
|
|
1322
|
+
? "rebuild-index"
|
|
1323
|
+
: repair.action,
|
|
1324
|
+
physicalAction: repair.action,
|
|
1325
|
+
indexAction: indexRepairRequired ? "rebuild-index" : "none",
|
|
1326
|
+
indexRepairRequired,
|
|
1327
|
+
indexRebuilt,
|
|
1328
|
+
indexRepairError,
|
|
1329
|
+
discardedBytes: repair.discardedBytes,
|
|
1330
|
+
discardedRecords: repair.discardedRecords,
|
|
1331
|
+
healthy,
|
|
1332
|
+
transcriptHealthy,
|
|
1333
|
+
status: effective.status,
|
|
1334
|
+
before,
|
|
1335
|
+
after,
|
|
1336
|
+
beforeValidation,
|
|
1337
|
+
afterValidation,
|
|
1338
|
+
reason:
|
|
1339
|
+
effective.status === TRANSCRIPT_CHAIN_STATUS.TAMPERED
|
|
1340
|
+
? effective.reason || "transcript remains tampered"
|
|
1341
|
+
: !effectiveValidation.valid
|
|
1342
|
+
? effectiveValidation.reason ||
|
|
1343
|
+
"transcript remains structurally invalid"
|
|
1344
|
+
: indexRepairError
|
|
1345
|
+
? `session metadata index rebuild failed: ${indexRepairError.message}`
|
|
1346
|
+
: indexRepairRequired && dryRun
|
|
1347
|
+
? "session metadata index requires rebuild"
|
|
1348
|
+
: null,
|
|
1349
|
+
};
|
|
1350
|
+
},
|
|
1351
|
+
{ failIfUnavailable: true },
|
|
1352
|
+
);
|
|
1353
|
+
|
|
1354
|
+
return result;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
128
1357
|
/** All locally-stored session ids (the source of truth a mirror derives from). */
|
|
129
1358
|
export function listSessionIds() {
|
|
130
1359
|
const dir = getSessionsDir();
|
|
@@ -134,6 +1363,20 @@ export function listSessionIds() {
|
|
|
134
1363
|
.map((file) => basename(file, ".jsonl"));
|
|
135
1364
|
}
|
|
136
1365
|
|
|
1366
|
+
/** Resolve an exact id or one unambiguous prefix without reading transcripts. */
|
|
1367
|
+
export function resolveSessionId(input) {
|
|
1368
|
+
if (isUnsafeSessionId(input)) return null;
|
|
1369
|
+
if (sessionExists(input)) return input;
|
|
1370
|
+
const matches = listSessionIds().filter((id) => id.startsWith(input));
|
|
1371
|
+
if (matches.length > 1) {
|
|
1372
|
+
const error = new Error(`Ambiguous session id prefix: ${input}`);
|
|
1373
|
+
error.code = "AMBIGUOUS_SESSION_ID";
|
|
1374
|
+
error.matches = matches.slice(0, 20);
|
|
1375
|
+
throw error;
|
|
1376
|
+
}
|
|
1377
|
+
return matches[0] || null;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
137
1380
|
export { TRANSCRIPT_CHAIN_STATUS };
|
|
138
1381
|
|
|
139
1382
|
export function startSession(sessionId, meta = {}) {
|
|
@@ -141,21 +1384,248 @@ export function startSession(sessionId, meta = {}) {
|
|
|
141
1384
|
sessionId ||
|
|
142
1385
|
`session-${Date.now()}-${createHash("sha256").update(Math.random().toString()).digest("hex").slice(0, 6)}`;
|
|
143
1386
|
|
|
144
|
-
appendEvent(id, "session_start", {
|
|
145
|
-
title: meta.title || "Untitled",
|
|
146
|
-
provider: meta.provider || "",
|
|
147
|
-
model: meta.model || "",
|
|
1387
|
+
appendEvent(id, "session_start", {
|
|
1388
|
+
title: meta.title || "Untitled",
|
|
1389
|
+
provider: meta.provider || "",
|
|
1390
|
+
model: meta.model || "",
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
return id;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
export function appendUserMessage(sessionId, content) {
|
|
1397
|
+
appendEvent(sessionId, "user_message", { role: "user", content });
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
export function appendAssistantMessage(sessionId, content) {
|
|
1401
|
+
appendEvent(sessionId, "assistant_message", { role: "assistant", content });
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function wsTurnLifecycleResult(snapshot, overrides = {}) {
|
|
1405
|
+
return Object.freeze({
|
|
1406
|
+
...snapshot.state,
|
|
1407
|
+
messages: snapshot.messages,
|
|
1408
|
+
...overrides,
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Claim one canonical WebSocket request before any model/tool execution.
|
|
1414
|
+
* Full chain verification, metadata-anchor validation, expected-head CAS and
|
|
1415
|
+
* claim append all execute under the cross-process transcript writer lock.
|
|
1416
|
+
*/
|
|
1417
|
+
export function claimWsTurnIfHead(
|
|
1418
|
+
sessionId,
|
|
1419
|
+
{ requestId, user, inputDigest, opaqueClaimId },
|
|
1420
|
+
expectedHeadHash = null,
|
|
1421
|
+
) {
|
|
1422
|
+
const normalizedUser = normalizeWsTurnContent(user, "user");
|
|
1423
|
+
const computedDigest = computeWsTurnInputDigest(normalizedUser);
|
|
1424
|
+
const canonicalDigest = normalizeWsTurnInputDigest(
|
|
1425
|
+
inputDigest || computedDigest,
|
|
1426
|
+
);
|
|
1427
|
+
if (canonicalDigest !== computedDigest) {
|
|
1428
|
+
throw wsTurnAuthorityError(
|
|
1429
|
+
"WebSocket request input does not match its proposed claim digest",
|
|
1430
|
+
"CC_WS_TURN_INPUT_DIGEST_MISMATCH",
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
const data = canonicalWsTurnClaimData({
|
|
1434
|
+
requestId,
|
|
1435
|
+
inputDigest: canonicalDigest,
|
|
1436
|
+
opaqueClaimId,
|
|
148
1437
|
});
|
|
149
1438
|
|
|
150
|
-
return
|
|
1439
|
+
return withVerifiedWsTurnLock(sessionId, (filePath) => {
|
|
1440
|
+
const snapshot = readVerifiedWsAuthorityLocked(
|
|
1441
|
+
sessionId,
|
|
1442
|
+
filePath,
|
|
1443
|
+
data.requestId,
|
|
1444
|
+
data.inputDigest,
|
|
1445
|
+
);
|
|
1446
|
+
if (snapshot.state.status !== "none") {
|
|
1447
|
+
return wsTurnLifecycleResult(snapshot, {
|
|
1448
|
+
acquired: false,
|
|
1449
|
+
deduplicated: true,
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
if (snapshot.authority.headHash !== (expectedHeadHash || null)) {
|
|
1453
|
+
const error = new Error(
|
|
1454
|
+
`Session revision changed for ${sessionId}; refresh the canonical turn authority`,
|
|
1455
|
+
);
|
|
1456
|
+
error.code = "SESSION_REVISION_STALE";
|
|
1457
|
+
error.expectedHeadHash = expectedHeadHash || null;
|
|
1458
|
+
error.actualHeadHash = snapshot.authority.headHash;
|
|
1459
|
+
throw error;
|
|
1460
|
+
}
|
|
1461
|
+
const appended = appendVerifiedWsAuthorityEventLocked(
|
|
1462
|
+
sessionId,
|
|
1463
|
+
filePath,
|
|
1464
|
+
WS_TURN_CLAIM_EVENT,
|
|
1465
|
+
data,
|
|
1466
|
+
snapshot.authority.headHash,
|
|
1467
|
+
);
|
|
1468
|
+
const claim = projectWsTurnClaim(appended.event);
|
|
1469
|
+
return Object.freeze({
|
|
1470
|
+
...snapshot.state,
|
|
1471
|
+
status: "pending",
|
|
1472
|
+
claim,
|
|
1473
|
+
headHash: appended.hash,
|
|
1474
|
+
eventCount: snapshot.authority.eventCount + 1,
|
|
1475
|
+
messages: snapshot.messages,
|
|
1476
|
+
acquired: true,
|
|
1477
|
+
deduplicated: false,
|
|
1478
|
+
});
|
|
1479
|
+
});
|
|
151
1480
|
}
|
|
152
1481
|
|
|
153
|
-
|
|
154
|
-
|
|
1482
|
+
/** Settle only the exact durable claim that fenced model/tool execution. */
|
|
1483
|
+
export function settleWsTurnClaim(
|
|
1484
|
+
sessionId,
|
|
1485
|
+
{
|
|
1486
|
+
requestId,
|
|
1487
|
+
inputDigest,
|
|
1488
|
+
opaqueClaimId,
|
|
1489
|
+
outcome,
|
|
1490
|
+
user,
|
|
1491
|
+
assistant,
|
|
1492
|
+
failureCode = "CC_WS_TURN_FAILED",
|
|
1493
|
+
},
|
|
1494
|
+
) {
|
|
1495
|
+
const identity = canonicalWsTurnClaimData({
|
|
1496
|
+
requestId,
|
|
1497
|
+
inputDigest,
|
|
1498
|
+
opaqueClaimId,
|
|
1499
|
+
});
|
|
1500
|
+
const data =
|
|
1501
|
+
outcome === "completed"
|
|
1502
|
+
? canonicalWsTurnCompletedData({
|
|
1503
|
+
...identity,
|
|
1504
|
+
user,
|
|
1505
|
+
assistant,
|
|
1506
|
+
})
|
|
1507
|
+
: outcome === "failed"
|
|
1508
|
+
? canonicalWsTurnFailedData({
|
|
1509
|
+
...identity,
|
|
1510
|
+
failureCode,
|
|
1511
|
+
})
|
|
1512
|
+
: (() => {
|
|
1513
|
+
throw wsTurnAuthorityError(
|
|
1514
|
+
"WebSocket turn settlement outcome is invalid",
|
|
1515
|
+
"CC_WS_TURN_OUTCOME_INVALID",
|
|
1516
|
+
);
|
|
1517
|
+
})();
|
|
1518
|
+
|
|
1519
|
+
return withVerifiedWsTurnLock(sessionId, (filePath) => {
|
|
1520
|
+
const snapshot = readVerifiedWsAuthorityLocked(
|
|
1521
|
+
sessionId,
|
|
1522
|
+
filePath,
|
|
1523
|
+
identity.requestId,
|
|
1524
|
+
identity.inputDigest,
|
|
1525
|
+
);
|
|
1526
|
+
if (snapshot.state.settlement) {
|
|
1527
|
+
if (snapshot.state.settlement.opaqueClaimId !== identity.opaqueClaimId) {
|
|
1528
|
+
throw wsTurnAuthorityError(
|
|
1529
|
+
"WebSocket request is settled by a different durable claim",
|
|
1530
|
+
"CC_WS_TURN_CLAIM_NOT_OWNER",
|
|
1531
|
+
);
|
|
1532
|
+
}
|
|
1533
|
+
return wsTurnLifecycleResult(snapshot, {
|
|
1534
|
+
acquired: false,
|
|
1535
|
+
deduplicated: true,
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
if (
|
|
1539
|
+
snapshot.state.status !== "pending" ||
|
|
1540
|
+
snapshot.state.claim?.opaqueClaimId !== identity.opaqueClaimId
|
|
1541
|
+
) {
|
|
1542
|
+
throw wsTurnAuthorityError(
|
|
1543
|
+
"WebSocket request has no matching pending claim to settle",
|
|
1544
|
+
"CC_WS_TURN_CLAIM_NOT_OWNER",
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
const appended = appendVerifiedWsAuthorityEventLocked(
|
|
1548
|
+
sessionId,
|
|
1549
|
+
filePath,
|
|
1550
|
+
WS_TURN_EVENT,
|
|
1551
|
+
data,
|
|
1552
|
+
snapshot.authority.headHash,
|
|
1553
|
+
);
|
|
1554
|
+
const settlement = projectWsTurnSettlement(appended.event);
|
|
1555
|
+
return Object.freeze({
|
|
1556
|
+
...snapshot.state,
|
|
1557
|
+
status: settlement.outcome,
|
|
1558
|
+
settlement,
|
|
1559
|
+
turn: settlement.outcome === "completed" ? settlement : null,
|
|
1560
|
+
headHash: appended.hash,
|
|
1561
|
+
eventCount: snapshot.authority.eventCount + 1,
|
|
1562
|
+
messages:
|
|
1563
|
+
settlement.outcome === "completed"
|
|
1564
|
+
? Object.freeze([
|
|
1565
|
+
...snapshot.messages,
|
|
1566
|
+
settlement.user,
|
|
1567
|
+
settlement.assistant,
|
|
1568
|
+
])
|
|
1569
|
+
: snapshot.messages,
|
|
1570
|
+
acquired: false,
|
|
1571
|
+
deduplicated: false,
|
|
1572
|
+
});
|
|
1573
|
+
});
|
|
155
1574
|
}
|
|
156
1575
|
|
|
157
|
-
|
|
158
|
-
|
|
1576
|
+
/**
|
|
1577
|
+
* Compatibility-only atomic settlement for callers that already hold a full
|
|
1578
|
+
* response. Canonical WS handlers must use claimWsTurnIfHead() first.
|
|
1579
|
+
*/
|
|
1580
|
+
export function appendWsTurnIfHead(
|
|
1581
|
+
sessionId,
|
|
1582
|
+
{ requestId, user, assistant },
|
|
1583
|
+
expectedHeadHash = null,
|
|
1584
|
+
) {
|
|
1585
|
+
const data = canonicalWsTurnCompletedData({ requestId, user, assistant });
|
|
1586
|
+
const inputDigest = computeWsTurnInputDigest(data.user.content);
|
|
1587
|
+
return withVerifiedWsTurnLock(sessionId, (filePath) => {
|
|
1588
|
+
const snapshot = readVerifiedWsAuthorityLocked(
|
|
1589
|
+
sessionId,
|
|
1590
|
+
filePath,
|
|
1591
|
+
data.requestId,
|
|
1592
|
+
inputDigest,
|
|
1593
|
+
);
|
|
1594
|
+
if (snapshot.state.status === "completed") {
|
|
1595
|
+
return Object.freeze({
|
|
1596
|
+
...snapshot.state.turn,
|
|
1597
|
+
hash: snapshot.state.turn.eventHash,
|
|
1598
|
+
deduplicated: true,
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
if (snapshot.state.status !== "none") {
|
|
1602
|
+
throw wsTurnAuthorityError(
|
|
1603
|
+
"WebSocket request already has claimed lifecycle authority",
|
|
1604
|
+
"CC_WS_TURN_CLAIM_REQUIRED",
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
if (snapshot.authority.headHash !== (expectedHeadHash || null)) {
|
|
1608
|
+
const error = new Error(
|
|
1609
|
+
`Session revision changed for ${sessionId}; refresh the checkpoint timeline`,
|
|
1610
|
+
);
|
|
1611
|
+
error.code = "SESSION_REVISION_STALE";
|
|
1612
|
+
error.expectedHeadHash = expectedHeadHash || null;
|
|
1613
|
+
error.actualHeadHash = snapshot.authority.headHash;
|
|
1614
|
+
throw error;
|
|
1615
|
+
}
|
|
1616
|
+
const appended = appendVerifiedWsAuthorityEventLocked(
|
|
1617
|
+
sessionId,
|
|
1618
|
+
filePath,
|
|
1619
|
+
WS_TURN_EVENT,
|
|
1620
|
+
data,
|
|
1621
|
+
snapshot.authority.headHash,
|
|
1622
|
+
);
|
|
1623
|
+
return Object.freeze({
|
|
1624
|
+
...projectWsTurnMessages(appended.event),
|
|
1625
|
+
hash: appended.hash,
|
|
1626
|
+
deduplicated: false,
|
|
1627
|
+
});
|
|
1628
|
+
});
|
|
159
1629
|
}
|
|
160
1630
|
|
|
161
1631
|
export function appendToolCall(sessionId, toolName, args) {
|
|
@@ -242,19 +1712,59 @@ export function appendCompactEvent(sessionId, stats) {
|
|
|
242
1712
|
appendEvent(sessionId, "compact", stats);
|
|
243
1713
|
}
|
|
244
1714
|
|
|
1715
|
+
function canonicalReplayFingerprint(messages) {
|
|
1716
|
+
return JSON.stringify(
|
|
1717
|
+
projectCanonicalResumeMessages(messages, { strict: true }).map(
|
|
1718
|
+
encodePersistedMessage,
|
|
1719
|
+
),
|
|
1720
|
+
);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/**
|
|
1724
|
+
* Persist a compact checkpoint only while the verified active replay still
|
|
1725
|
+
* equals the caller's last known projection. Head-only CAS is insufficient for
|
|
1726
|
+
* a long-lived REPL: it could sample a newer head that already contains turns
|
|
1727
|
+
* absent from its local context and then accidentally hide those turns.
|
|
1728
|
+
*/
|
|
1729
|
+
export function appendCompactEventIfMessagesMatch(
|
|
1730
|
+
sessionId,
|
|
1731
|
+
stats,
|
|
1732
|
+
expectedMessages,
|
|
1733
|
+
) {
|
|
1734
|
+
const expectedFingerprint = canonicalReplayFingerprint(expectedMessages);
|
|
1735
|
+
const persistedData = encodeEventMessageProvenance("compact", stats);
|
|
1736
|
+
return withVerifiedWsTurnLock(sessionId, (filePath) => {
|
|
1737
|
+
const verification = verifyTranscriptFile(filePath);
|
|
1738
|
+
assertVerifiedTranscriptAnchor(sessionId, verification);
|
|
1739
|
+
const actualMessages = rebuildVerifiedMessagesFromFile(filePath);
|
|
1740
|
+
if (canonicalReplayFingerprint(actualMessages) !== expectedFingerprint) {
|
|
1741
|
+
const error = new Error(
|
|
1742
|
+
`Session messages changed for ${sessionId}; refresh before compacting`,
|
|
1743
|
+
);
|
|
1744
|
+
error.code = "SESSION_REVISION_STALE";
|
|
1745
|
+
error.actualHeadHash = verification.lastHash;
|
|
1746
|
+
throw error;
|
|
1747
|
+
}
|
|
1748
|
+
return appendVerifiedWsAuthorityEventLocked(
|
|
1749
|
+
sessionId,
|
|
1750
|
+
filePath,
|
|
1751
|
+
"compact",
|
|
1752
|
+
persistedData,
|
|
1753
|
+
verification.lastHash,
|
|
1754
|
+
);
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
|
|
245
1758
|
export function readEvents(sessionId) {
|
|
246
1759
|
if (isUnsafeSessionId(sessionId)) return []; // traversal id → treat as empty
|
|
247
1760
|
const filePath = sessionPath(sessionId);
|
|
248
1761
|
if (!existsSync(filePath)) return [];
|
|
249
1762
|
|
|
250
|
-
const content = readFileSync(filePath, "utf-8");
|
|
251
1763
|
const events = [];
|
|
252
|
-
|
|
253
|
-
for (const line of content.split("\n")) {
|
|
254
|
-
if (!line.trim()) continue;
|
|
1764
|
+
for (const { line } of iterateFileLinesSync(filePath)) {
|
|
255
1765
|
try {
|
|
256
1766
|
events.push(JSON.parse(line));
|
|
257
|
-
} catch
|
|
1767
|
+
} catch {
|
|
258
1768
|
// Skip malformed lines
|
|
259
1769
|
}
|
|
260
1770
|
}
|
|
@@ -262,6 +1772,216 @@ export function readEvents(sessionId) {
|
|
|
262
1772
|
return events;
|
|
263
1773
|
}
|
|
264
1774
|
|
|
1775
|
+
function unverifiedTranscriptError(sessionId, verification) {
|
|
1776
|
+
const error = new Error(
|
|
1777
|
+
`session transcript is not fully verified and anchored (${verification.status})`,
|
|
1778
|
+
);
|
|
1779
|
+
error.code = "SESSION_TRANSCRIPT_UNVERIFIED";
|
|
1780
|
+
error.sessionId = sessionId;
|
|
1781
|
+
error.verification = verification;
|
|
1782
|
+
return error;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
function assertVerifiedTranscriptAnchor(sessionId, verification) {
|
|
1786
|
+
const meta = readSessionMeta(getSessionsDir(), sessionId);
|
|
1787
|
+
const trustedStatus =
|
|
1788
|
+
verification.status === TRANSCRIPT_CHAIN_STATUS.VERIFIED;
|
|
1789
|
+
const anchoredHead =
|
|
1790
|
+
meta?.deleted !== true &&
|
|
1791
|
+
meta?.last_hash === verification.lastHash &&
|
|
1792
|
+
Number(meta?.event_count) === verification.chainedEvents;
|
|
1793
|
+
if (
|
|
1794
|
+
!trustedStatus ||
|
|
1795
|
+
!anchoredHead ||
|
|
1796
|
+
verification.malformedLines > 0 ||
|
|
1797
|
+
verification.truncatedTail
|
|
1798
|
+
) {
|
|
1799
|
+
throw unverifiedTranscriptError(sessionId, verification);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* Fold a fully verified transcript into a caller-owned projection.
|
|
1805
|
+
*
|
|
1806
|
+
* The projection factory and its `accept(event)` / `finish(authority)` methods
|
|
1807
|
+
* execute while the canonical writer lock is held. Hash-chain verification,
|
|
1808
|
+
* event reduction, and the independently persisted head/count check share one
|
|
1809
|
+
* forward transcript pass. `finish()` may call `authority.readMessages()` to
|
|
1810
|
+
* recover only the active context with bounded reverse IO (latest compact
|
|
1811
|
+
* checkpoint plus its suffix).
|
|
1812
|
+
*
|
|
1813
|
+
* This removes the mandatory all-event array from resume projections. It does
|
|
1814
|
+
* not make verification IO sublinear: authenticating a plain chained JSONL
|
|
1815
|
+
* transcript remains O(N), and the local sidecar is not an anti-rollback
|
|
1816
|
+
* anchor or a cross-process lease.
|
|
1817
|
+
*/
|
|
1818
|
+
export function readVerifiedProjection(
|
|
1819
|
+
sessionId,
|
|
1820
|
+
createProjection,
|
|
1821
|
+
options = {},
|
|
1822
|
+
) {
|
|
1823
|
+
if (typeof createProjection !== "function") {
|
|
1824
|
+
throw new TypeError("Verified transcript projection factory is required");
|
|
1825
|
+
}
|
|
1826
|
+
if (isUnsafeSessionId(sessionId)) {
|
|
1827
|
+
const error = new Error(
|
|
1828
|
+
`unsafe session id: ${String(sessionId).slice(0, 60)}`,
|
|
1829
|
+
);
|
|
1830
|
+
error.code = "SESSION_TRANSCRIPT_UNVERIFIED";
|
|
1831
|
+
error.sessionId = sessionId;
|
|
1832
|
+
throw error;
|
|
1833
|
+
}
|
|
1834
|
+
const filePath = sessionPath(sessionId);
|
|
1835
|
+
|
|
1836
|
+
return withFileLock(
|
|
1837
|
+
filePath,
|
|
1838
|
+
() => {
|
|
1839
|
+
const projection = createProjection();
|
|
1840
|
+
if (
|
|
1841
|
+
!projection ||
|
|
1842
|
+
typeof projection.accept !== "function" ||
|
|
1843
|
+
typeof projection.finish !== "function"
|
|
1844
|
+
) {
|
|
1845
|
+
throw new TypeError(
|
|
1846
|
+
"Verified transcript projection must provide accept() and finish()",
|
|
1847
|
+
);
|
|
1848
|
+
}
|
|
1849
|
+
if (!existsSync(filePath)) {
|
|
1850
|
+
return projection.finish(
|
|
1851
|
+
Object.freeze({
|
|
1852
|
+
headHash: null,
|
|
1853
|
+
eventCount: 0,
|
|
1854
|
+
readMessages: () => [],
|
|
1855
|
+
}),
|
|
1856
|
+
);
|
|
1857
|
+
}
|
|
1858
|
+
const verification = verifyTranscriptFile(filePath, {
|
|
1859
|
+
ioMetrics: options.ioMetrics,
|
|
1860
|
+
onVerifiedEvent: (event) => projection.accept(event),
|
|
1861
|
+
});
|
|
1862
|
+
assertVerifiedTranscriptAnchor(sessionId, verification);
|
|
1863
|
+
return projection.finish(
|
|
1864
|
+
Object.freeze({
|
|
1865
|
+
headHash: verification.lastHash,
|
|
1866
|
+
eventCount: verification.chainedEvents,
|
|
1867
|
+
readMessages: () =>
|
|
1868
|
+
rebuildVerifiedMessagesFromFile(filePath, {
|
|
1869
|
+
ioMetrics: options.messageIoMetrics,
|
|
1870
|
+
}),
|
|
1871
|
+
}),
|
|
1872
|
+
);
|
|
1873
|
+
},
|
|
1874
|
+
{
|
|
1875
|
+
failIfUnavailable: true,
|
|
1876
|
+
timeoutMs: 30_000,
|
|
1877
|
+
retryMs: 1,
|
|
1878
|
+
maxRetryMs: 8,
|
|
1879
|
+
retryJitterMs: 4,
|
|
1880
|
+
yieldAfterReleaseMs: 2,
|
|
1881
|
+
},
|
|
1882
|
+
);
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/**
|
|
1886
|
+
* Compatibility reader for consumers that still require the complete event
|
|
1887
|
+
* array. New resume projections should use `readVerifiedProjection()` so their
|
|
1888
|
+
* heap is bounded by active context plus reducer authority state.
|
|
1889
|
+
*/
|
|
1890
|
+
export function readVerifiedEvents(sessionId) {
|
|
1891
|
+
return readVerifiedProjection(sessionId, () => {
|
|
1892
|
+
const events = [];
|
|
1893
|
+
return {
|
|
1894
|
+
accept(event) {
|
|
1895
|
+
events.push(event);
|
|
1896
|
+
},
|
|
1897
|
+
finish() {
|
|
1898
|
+
return events;
|
|
1899
|
+
},
|
|
1900
|
+
};
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* Read the active context only after transcript and sidecar-anchor validation.
|
|
1906
|
+
* Unlike the compatibility `rebuildMessages()` reader, this is allowed to
|
|
1907
|
+
* restore hash-covered durable system-message provenance.
|
|
1908
|
+
*/
|
|
1909
|
+
export function readVerifiedMessages(sessionId, options = {}) {
|
|
1910
|
+
return readVerifiedProjection(
|
|
1911
|
+
sessionId,
|
|
1912
|
+
() => ({
|
|
1913
|
+
accept() {},
|
|
1914
|
+
finish(authority) {
|
|
1915
|
+
return Object.freeze([...authority.readMessages()]);
|
|
1916
|
+
},
|
|
1917
|
+
}),
|
|
1918
|
+
options,
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
/**
|
|
1923
|
+
* Read the verified head, active messages, and (when present) one durable
|
|
1924
|
+
* WebSocket request settlement from the same locked transcript sample.
|
|
1925
|
+
* Tracking only the requested id keeps idempotency lookup heap-bounded.
|
|
1926
|
+
*/
|
|
1927
|
+
export function readVerifiedWsTurnState(sessionId, requestId, options = {}) {
|
|
1928
|
+
const canonicalRequestId = normalizeWsTurnRequestId(requestId);
|
|
1929
|
+
const inputDigest =
|
|
1930
|
+
options.inputDigest == null
|
|
1931
|
+
? null
|
|
1932
|
+
: normalizeWsTurnInputDigest(options.inputDigest);
|
|
1933
|
+
return readVerifiedProjection(
|
|
1934
|
+
sessionId,
|
|
1935
|
+
() => {
|
|
1936
|
+
const reducer = createWsTurnLifecycleReducer({
|
|
1937
|
+
requestId: canonicalRequestId,
|
|
1938
|
+
inputDigest,
|
|
1939
|
+
});
|
|
1940
|
+
return {
|
|
1941
|
+
accept(event) {
|
|
1942
|
+
reducer.accept(event);
|
|
1943
|
+
},
|
|
1944
|
+
finish(authority) {
|
|
1945
|
+
const state = reducer.finish(authority);
|
|
1946
|
+
return Object.freeze({
|
|
1947
|
+
sessionId,
|
|
1948
|
+
...state,
|
|
1949
|
+
messages: Object.freeze([...authority.readMessages()]),
|
|
1950
|
+
});
|
|
1951
|
+
},
|
|
1952
|
+
};
|
|
1953
|
+
},
|
|
1954
|
+
options,
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
/**
|
|
1959
|
+
* Return the newest event matching a type/predicate using bounded reverse IO.
|
|
1960
|
+
* This is the recovery path for snapshots and ledgers: callers never need to
|
|
1961
|
+
* materialize a multi-gigabyte transcript merely to inspect its latest state.
|
|
1962
|
+
*/
|
|
1963
|
+
export function findLatestEvent(sessionId, type, predicate = null) {
|
|
1964
|
+
if (isUnsafeSessionId(sessionId)) return null;
|
|
1965
|
+
const filePath = sessionPath(sessionId);
|
|
1966
|
+
if (!existsSync(filePath)) return null;
|
|
1967
|
+
const wanted = Array.isArray(type) ? new Set(type) : null;
|
|
1968
|
+
for (const { line } of iterateFileLinesReverseSync(filePath)) {
|
|
1969
|
+
let event;
|
|
1970
|
+
try {
|
|
1971
|
+
event = JSON.parse(line);
|
|
1972
|
+
} catch {
|
|
1973
|
+
continue;
|
|
1974
|
+
}
|
|
1975
|
+
const typeMatches = wanted
|
|
1976
|
+
? wanted.has(event?.type)
|
|
1977
|
+
: type == null || event?.type === type;
|
|
1978
|
+
if (!typeMatches) continue;
|
|
1979
|
+
if (typeof predicate === "function" && !predicate(event)) continue;
|
|
1980
|
+
return event;
|
|
1981
|
+
}
|
|
1982
|
+
return null;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
265
1985
|
/**
|
|
266
1986
|
* A replayable chat message must be a `{ role, content }` object — guard
|
|
267
1987
|
* against a corrupt / partially-written / hand-edited event whose `data` is
|
|
@@ -272,32 +1992,35 @@ function isReplayableMessage(m) {
|
|
|
272
1992
|
return Boolean(m) && typeof m === "object" && typeof m.role === "string";
|
|
273
1993
|
}
|
|
274
1994
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
1995
|
+
function rebuildMessagesFromFile(
|
|
1996
|
+
filePath,
|
|
1997
|
+
options = {},
|
|
1998
|
+
decodeMessage = sanitizePersistedMessage,
|
|
1999
|
+
) {
|
|
2000
|
+
const ioMetrics = options?.ioMetrics ?? null;
|
|
2001
|
+
// Scan newest-first and stop at the latest valid compact checkpoint. Memory
|
|
2002
|
+
// is therefore proportional to the active context suffix, not transcript
|
|
2003
|
+
// size. Without a compact event the full conversation is necessarily the
|
|
2004
|
+
// replay state, but the file itself is still never loaded as one giant string.
|
|
2005
|
+
const suffix = [];
|
|
2006
|
+
let checkpoint = [];
|
|
2007
|
+
for (const { line } of iterateFileLinesReverseSync(filePath, { ioMetrics })) {
|
|
2008
|
+
let event;
|
|
2009
|
+
try {
|
|
2010
|
+
event = JSON.parse(line);
|
|
2011
|
+
} catch {
|
|
2012
|
+
continue;
|
|
288
2013
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
2014
|
+
if (
|
|
2015
|
+
(event?.type === "compact" ||
|
|
2016
|
+
event?.type === "checkpoint_timeline_commit") &&
|
|
2017
|
+
Array.isArray(event.data?.messages)
|
|
2018
|
+
) {
|
|
2019
|
+
checkpoint = event.data.messages
|
|
2020
|
+
.map(decodeMessage)
|
|
2021
|
+
.filter(isReplayableMessage);
|
|
2022
|
+
break;
|
|
294
2023
|
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const startIndex = lastCompactIndex >= 0 ? lastCompactIndex + 1 : 0;
|
|
298
|
-
|
|
299
|
-
for (let i = startIndex; i < events.length; i++) {
|
|
300
|
-
const event = events[i];
|
|
301
2024
|
if (
|
|
302
2025
|
event &&
|
|
303
2026
|
(event.type === "user_message" ||
|
|
@@ -305,11 +2028,33 @@ export function rebuildMessages(sessionId) {
|
|
|
305
2028
|
event.type === "system") &&
|
|
306
2029
|
isReplayableMessage(event.data)
|
|
307
2030
|
) {
|
|
308
|
-
|
|
2031
|
+
suffix.push(decodeMessage(event.data));
|
|
2032
|
+
} else if (event?.type === WS_TURN_EVENT) {
|
|
2033
|
+
const turn = projectWsTurnMessages(event);
|
|
2034
|
+
if (turn) {
|
|
2035
|
+
// The file is scanned newest-first. Push the pair in reverse so the
|
|
2036
|
+
// final suffix.reverse() restores user → assistant atomically.
|
|
2037
|
+
suffix.push(turn.assistant, turn.user);
|
|
2038
|
+
}
|
|
309
2039
|
}
|
|
310
2040
|
}
|
|
2041
|
+
suffix.reverse();
|
|
2042
|
+
return [...checkpoint, ...suffix];
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
function rebuildVerifiedMessagesFromFile(filePath, options = {}) {
|
|
2046
|
+
return rebuildMessagesFromFile(
|
|
2047
|
+
filePath,
|
|
2048
|
+
options,
|
|
2049
|
+
decodeVerifiedPersistedMessage,
|
|
2050
|
+
);
|
|
2051
|
+
}
|
|
311
2052
|
|
|
312
|
-
|
|
2053
|
+
export function rebuildMessages(sessionId, options = {}) {
|
|
2054
|
+
if (isUnsafeSessionId(sessionId)) return [];
|
|
2055
|
+
const filePath = sessionPath(sessionId);
|
|
2056
|
+
if (!existsSync(filePath)) return [];
|
|
2057
|
+
return rebuildMessagesFromFile(filePath, options);
|
|
313
2058
|
}
|
|
314
2059
|
|
|
315
2060
|
/** ISO string for a numeric ms timestamp, or "" when missing / non-finite /
|
|
@@ -325,51 +2070,79 @@ export function toIsoSafe(ts) {
|
|
|
325
2070
|
return Number.isNaN(d.getTime()) ? "" : d.toISOString();
|
|
326
2071
|
}
|
|
327
2072
|
|
|
2073
|
+
function rebuildSessionMetaUnlocked(dir, sessionId, filePath) {
|
|
2074
|
+
let meta = emptySessionMeta(sessionId);
|
|
2075
|
+
for (const { line } of iterateFileLinesSync(filePath)) {
|
|
2076
|
+
try {
|
|
2077
|
+
const event = JSON.parse(line);
|
|
2078
|
+
meta = applyEventToSessionMeta(meta, event, event?.hash);
|
|
2079
|
+
} catch {
|
|
2080
|
+
// The validator/repair path reports malformed records. The index remains
|
|
2081
|
+
// a best-effort projection over all intact events.
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
return replaceSessionMeta(dir, meta);
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
function rebuildSessionMeta(sessionId) {
|
|
2088
|
+
const dir = getSessionsDir();
|
|
2089
|
+
const filePath = sessionPath(sessionId);
|
|
2090
|
+
if (!existsSync(filePath)) return null;
|
|
2091
|
+
return withFileLock(
|
|
2092
|
+
filePath,
|
|
2093
|
+
() => rebuildSessionMetaUnlocked(dir, sessionId, filePath),
|
|
2094
|
+
{
|
|
2095
|
+
failIfUnavailable: true,
|
|
2096
|
+
timeoutMs: 30_000,
|
|
2097
|
+
retryMs: 1,
|
|
2098
|
+
maxRetryMs: 8,
|
|
2099
|
+
retryJitterMs: 4,
|
|
2100
|
+
},
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
/** Read canonical session metadata without loading the transcript body. */
|
|
2105
|
+
export function getJsonlSessionMetadata(sessionId) {
|
|
2106
|
+
if (isUnsafeSessionId(sessionId) || !sessionExists(sessionId)) return null;
|
|
2107
|
+
const dir = getSessionsDir();
|
|
2108
|
+
const meta = readSessionMeta(dir, sessionId) || rebuildSessionMeta(sessionId);
|
|
2109
|
+
return meta ? publicSessionMeta(meta) : null;
|
|
2110
|
+
}
|
|
2111
|
+
|
|
328
2112
|
export function listJsonlSessions(options = {}) {
|
|
329
2113
|
const dir = getSessionsDir();
|
|
330
2114
|
if (!existsSync(dir)) return [];
|
|
331
2115
|
|
|
332
2116
|
const limit = options.limit || 20;
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const startEvent = events.find((e) => e.type === "session_start");
|
|
339
|
-
const lastEvent = events[events.length - 1];
|
|
340
|
-
const messageCount = events.filter(
|
|
341
|
-
(e) => e.type === "user_message" || e.type === "assistant_message",
|
|
342
|
-
).length;
|
|
343
|
-
// cc session rename appends session_rename events; the LAST one wins.
|
|
344
|
-
let renamedTitle = null;
|
|
345
|
-
for (const e of events) {
|
|
346
|
-
if (e.type === "session_rename" && e.data?.title) {
|
|
347
|
-
renamedTitle = e.data.title;
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
return {
|
|
352
|
-
id,
|
|
353
|
-
title: renamedTitle || startEvent?.data?.title || "Untitled",
|
|
354
|
-
provider: startEvent?.data?.provider || "",
|
|
355
|
-
model: startEvent?.data?.model || "",
|
|
356
|
-
message_count: messageCount,
|
|
357
|
-
created_at: toIsoSafe(startEvent?.timestamp),
|
|
358
|
-
updated_at: toIsoSafe(lastEvent?.timestamp),
|
|
359
|
-
_lastTs: lastEvent?.timestamp || 0,
|
|
360
|
-
_eventCount: events.length,
|
|
361
|
-
};
|
|
362
|
-
})
|
|
363
|
-
.sort((a, b) => {
|
|
364
|
-
// Primary: numeric timestamp descending
|
|
365
|
-
if (b._lastTs !== a._lastTs) return b._lastTs - a._lastTs;
|
|
366
|
-
// Tiebreak when ms collides: more events = more recent activity
|
|
367
|
-
return b._eventCount - a._eventCount;
|
|
368
|
-
})
|
|
369
|
-
.slice(0, limit)
|
|
370
|
-
.map(({ _lastTs, _eventCount, ...rest }) => rest);
|
|
2117
|
+
const sessionIds = listSessionIds();
|
|
2118
|
+
let indexed = listIndexedSessions(dir, {
|
|
2119
|
+
limit,
|
|
2120
|
+
hasSession: (id) => sessionExists(id),
|
|
2121
|
+
});
|
|
371
2122
|
|
|
372
|
-
|
|
2123
|
+
// First use after upgrade (or a lost/corrupt rebuildable index): stream each
|
|
2124
|
+
// legacy transcript once to seed its small sidecar and activity record.
|
|
2125
|
+
const expected = Math.min(Math.max(0, Number(limit) || 0), sessionIds.length);
|
|
2126
|
+
if (indexed.length < expected) {
|
|
2127
|
+
const rebuilt = [];
|
|
2128
|
+
for (const id of sessionIds) {
|
|
2129
|
+
const meta = readSessionMeta(dir, id);
|
|
2130
|
+
rebuilt.push(meta || rebuildSessionMeta(id));
|
|
2131
|
+
}
|
|
2132
|
+
// Directory enumeration order is not activity order. Re-append the rebuilt
|
|
2133
|
+
// snapshots oldest-first so reverse journal reads preserve the transcript
|
|
2134
|
+
// timestamps even on the first post-upgrade listing.
|
|
2135
|
+
for (const meta of rebuilt
|
|
2136
|
+
.filter(Boolean)
|
|
2137
|
+
.sort((a, b) => (a.updated_at_ms || 0) - (b.updated_at_ms || 0))) {
|
|
2138
|
+
recordSessionActivity(dir, meta);
|
|
2139
|
+
}
|
|
2140
|
+
indexed = listIndexedSessions(dir, {
|
|
2141
|
+
limit,
|
|
2142
|
+
hasSession: (id) => sessionExists(id),
|
|
2143
|
+
});
|
|
2144
|
+
}
|
|
2145
|
+
return indexed;
|
|
373
2146
|
}
|
|
374
2147
|
|
|
375
2148
|
/**
|
|
@@ -387,6 +2160,41 @@ export function renameSession(sessionId, title) {
|
|
|
387
2160
|
return { id: sessionId, title: normalized.slice(0, 200) };
|
|
388
2161
|
}
|
|
389
2162
|
|
|
2163
|
+
/** Delete the canonical transcript under its writer lock and publish a
|
|
2164
|
+
* rebuildable tombstone so an already-waiting stale writer cannot resurrect
|
|
2165
|
+
* the session after the deletion commits. */
|
|
2166
|
+
export function deleteJsonlSession(sessionId) {
|
|
2167
|
+
if (isUnsafeSessionId(sessionId)) return false;
|
|
2168
|
+
const filePath = sessionPath(sessionId);
|
|
2169
|
+
if (!existsSync(filePath)) return false;
|
|
2170
|
+
return withFileLock(
|
|
2171
|
+
filePath,
|
|
2172
|
+
() => {
|
|
2173
|
+
if (!existsSync(filePath)) return false;
|
|
2174
|
+
rmSync(filePath, { force: true });
|
|
2175
|
+
try {
|
|
2176
|
+
recordSessionDeleted(getSessionsDir(), sessionId);
|
|
2177
|
+
} catch (error) {
|
|
2178
|
+
// writeMetaAtomic precedes the activity-journal append. If only the
|
|
2179
|
+
// rebuildable journal lock/release failed, the durable sidecar still
|
|
2180
|
+
// prevents a stale writer from resurrecting this deleted session.
|
|
2181
|
+
if (readSessionMeta(getSessionsDir(), sessionId)?.deleted !== true) {
|
|
2182
|
+
throw error;
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
return true;
|
|
2186
|
+
},
|
|
2187
|
+
{
|
|
2188
|
+
failIfUnavailable: true,
|
|
2189
|
+
timeoutMs: 30_000,
|
|
2190
|
+
retryMs: 1,
|
|
2191
|
+
maxRetryMs: 8,
|
|
2192
|
+
retryJitterMs: 4,
|
|
2193
|
+
yieldAfterReleaseMs: 2,
|
|
2194
|
+
},
|
|
2195
|
+
);
|
|
2196
|
+
}
|
|
2197
|
+
|
|
390
2198
|
/**
|
|
391
2199
|
* Prune old sessions (gap-analysis 2026-07-11 P1 "保留期限"): delete session
|
|
392
2200
|
* transcripts whose LAST activity is older than `olderThanDays`, always
|
|
@@ -419,9 +2227,7 @@ export function pruneJsonlSessions(options = {}) {
|
|
|
419
2227
|
continue;
|
|
420
2228
|
}
|
|
421
2229
|
try {
|
|
422
|
-
|
|
423
|
-
_chainTailCache.delete(s.id);
|
|
424
|
-
deleted.push(s.id);
|
|
2230
|
+
if (deleteJsonlSession(s.id)) deleted.push(s.id);
|
|
425
2231
|
} catch {
|
|
426
2232
|
/* per-file failures never abort the sweep */
|
|
427
2233
|
}
|
|
@@ -434,31 +2240,405 @@ export function pruneJsonlSessions(options = {}) {
|
|
|
434
2240
|
};
|
|
435
2241
|
}
|
|
436
2242
|
|
|
437
|
-
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
2243
|
+
function compareFilePrefix(candidatePath, fullPath) {
|
|
2244
|
+
const candidateFd = openSync(candidatePath, "r");
|
|
2245
|
+
const fullFd = openSync(fullPath, "r");
|
|
2246
|
+
try {
|
|
2247
|
+
const candidateSize = fstatSync(candidateFd).size;
|
|
2248
|
+
const fullSize = fstatSync(fullFd).size;
|
|
2249
|
+
if (candidateSize > fullSize) {
|
|
2250
|
+
return { matches: false, candidateSize, fullSize };
|
|
2251
|
+
}
|
|
2252
|
+
const candidateBuffer = Buffer.allocUnsafe(64 * 1024);
|
|
2253
|
+
const fullBuffer = Buffer.allocUnsafe(64 * 1024);
|
|
2254
|
+
let offset = 0;
|
|
2255
|
+
while (offset < candidateSize) {
|
|
2256
|
+
const length = Math.min(candidateBuffer.length, candidateSize - offset);
|
|
2257
|
+
const candidateRead = readSync(
|
|
2258
|
+
candidateFd,
|
|
2259
|
+
candidateBuffer,
|
|
2260
|
+
0,
|
|
2261
|
+
length,
|
|
2262
|
+
offset,
|
|
2263
|
+
);
|
|
2264
|
+
const fullRead = readSync(fullFd, fullBuffer, 0, length, offset);
|
|
2265
|
+
if (
|
|
2266
|
+
candidateRead !== length ||
|
|
2267
|
+
fullRead !== length ||
|
|
2268
|
+
!candidateBuffer
|
|
2269
|
+
.subarray(0, length)
|
|
2270
|
+
.equals(fullBuffer.subarray(0, length))
|
|
2271
|
+
) {
|
|
2272
|
+
return { matches: false, candidateSize, fullSize };
|
|
2273
|
+
}
|
|
2274
|
+
offset += length;
|
|
2275
|
+
}
|
|
2276
|
+
return { matches: true, candidateSize, fullSize };
|
|
2277
|
+
} finally {
|
|
2278
|
+
closeSync(candidateFd);
|
|
2279
|
+
closeSync(fullFd);
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
443
2282
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
2283
|
+
const SESSION_FORK_AUTHORITY_SCHEMA =
|
|
2284
|
+
"chainlesschain.session-fork-authority/v1";
|
|
2285
|
+
|
|
2286
|
+
function normalizeForkRequestId(options) {
|
|
2287
|
+
const requestId = options?.requestId ?? "default";
|
|
2288
|
+
if (
|
|
2289
|
+
typeof requestId !== "string" ||
|
|
2290
|
+
requestId.length === 0 ||
|
|
2291
|
+
Buffer.byteLength(requestId, "utf8") > 256
|
|
2292
|
+
) {
|
|
2293
|
+
throw new TypeError(
|
|
2294
|
+
"fork requestId must be a non-empty string <= 256 bytes",
|
|
2295
|
+
);
|
|
447
2296
|
}
|
|
2297
|
+
return requestId;
|
|
2298
|
+
}
|
|
448
2299
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
2300
|
+
/**
|
|
2301
|
+
* Copy one verified source revision into an idempotent successor. The target is
|
|
2302
|
+
* bound to (sourceId, requestId), while its lineage record commits the source
|
|
2303
|
+
* revision selected by the first completed attempt. Callers that intentionally
|
|
2304
|
+
* need separate forks must pass separate requestId values.
|
|
2305
|
+
*/
|
|
2306
|
+
export function forkSession(sourceId, options = {}) {
|
|
2307
|
+
if (isUnsafeSessionId(sourceId)) return null;
|
|
2308
|
+
const requestId = normalizeForkRequestId(options);
|
|
2309
|
+
const sourcePath = sessionPath(sourceId);
|
|
2310
|
+
if (!existsSync(sourcePath)) return null;
|
|
2311
|
+
|
|
2312
|
+
return withFileLock(
|
|
2313
|
+
sourcePath,
|
|
2314
|
+
() => {
|
|
2315
|
+
if (!existsSync(sourcePath)) return null;
|
|
2316
|
+
const sourceVerification = verifyTranscriptFile(sourcePath);
|
|
2317
|
+
assertVerifiedTranscriptAnchor(sourceId, sourceVerification);
|
|
2318
|
+
const forkIdentity = JSON.stringify({
|
|
2319
|
+
schemaVersion: 1,
|
|
2320
|
+
sourceSessionId: sourceId,
|
|
2321
|
+
requestId,
|
|
2322
|
+
});
|
|
2323
|
+
const forkDigest = createHash("sha256")
|
|
2324
|
+
.update(forkIdentity, "utf8")
|
|
2325
|
+
.digest("hex");
|
|
2326
|
+
const requestDigest = `sha256:${forkDigest}`;
|
|
2327
|
+
const newId = `session-fork-${forkDigest.slice(0, 32)}`;
|
|
2328
|
+
const filePath = sessionPath(newId);
|
|
2329
|
+
const sessionsDir = getSessionsDir();
|
|
2330
|
+
const pendingPath = join(sessionsDir, `${newId}.fork.pending`);
|
|
2331
|
+
const persistedData = encodeEventMessageProvenance(
|
|
2332
|
+
"system",
|
|
2333
|
+
markDurableSystemMessage(
|
|
2334
|
+
{
|
|
2335
|
+
role: "system",
|
|
2336
|
+
content: `[Forked from session ${sourceId}]`,
|
|
2337
|
+
},
|
|
2338
|
+
DURABLE_SYSTEM_MESSAGE_KINDS.FORK_LINEAGE,
|
|
2339
|
+
),
|
|
2340
|
+
);
|
|
453
2341
|
|
|
454
|
-
|
|
2342
|
+
return withFileLock(
|
|
2343
|
+
filePath,
|
|
2344
|
+
() => {
|
|
2345
|
+
let targetMeta = readSessionMeta(sessionsDir, newId);
|
|
2346
|
+
const finalExists = existsSync(filePath);
|
|
2347
|
+
const pendingExists = existsSync(pendingPath);
|
|
2348
|
+
if (finalExists && pendingExists) {
|
|
2349
|
+
throw unverifiedTranscriptError(
|
|
2350
|
+
newId,
|
|
2351
|
+
verifyTranscriptFile(filePath),
|
|
2352
|
+
);
|
|
2353
|
+
}
|
|
2354
|
+
if (!finalExists && targetMeta !== null) {
|
|
2355
|
+
throw unverifiedTranscriptError(newId, {
|
|
2356
|
+
status: "not-found",
|
|
2357
|
+
chainedEvents: 0,
|
|
2358
|
+
malformedLines: 0,
|
|
2359
|
+
truncatedTail: false,
|
|
2360
|
+
lastHash: null,
|
|
2361
|
+
});
|
|
2362
|
+
}
|
|
2363
|
+
let workingPath = finalExists ? filePath : pendingPath;
|
|
2364
|
+
if (!finalExists && !pendingExists) {
|
|
2365
|
+
if (targetMeta !== null) {
|
|
2366
|
+
throw unverifiedTranscriptError(newId, {
|
|
2367
|
+
status: "not-found",
|
|
2368
|
+
chainedEvents: 0,
|
|
2369
|
+
malformedLines: 0,
|
|
2370
|
+
truncatedTail: false,
|
|
2371
|
+
lastHash: null,
|
|
2372
|
+
});
|
|
2373
|
+
}
|
|
2374
|
+
copyFileSync(sourcePath, workingPath, fsConstants.COPYFILE_EXCL);
|
|
2375
|
+
ensurePrivateFile(workingPath);
|
|
2376
|
+
runSessionScaleFaultHook("afterForkCopy", {
|
|
2377
|
+
sourceId,
|
|
2378
|
+
sessionId: newId,
|
|
2379
|
+
filePath: workingPath,
|
|
2380
|
+
});
|
|
2381
|
+
} else {
|
|
2382
|
+
const prefix = compareFilePrefix(workingPath, sourcePath);
|
|
2383
|
+
if (prefix.matches && prefix.candidateSize < prefix.fullSize) {
|
|
2384
|
+
if (targetMeta !== null) {
|
|
2385
|
+
throw unverifiedTranscriptError(
|
|
2386
|
+
newId,
|
|
2387
|
+
verifyTranscriptFile(workingPath),
|
|
2388
|
+
);
|
|
2389
|
+
}
|
|
2390
|
+
// copyFileSync can leave an exact byte prefix after a hard exit.
|
|
2391
|
+
// The deterministic target lock and prefix proof make replacing
|
|
2392
|
+
// only that unpublished target safe and crash-resumable.
|
|
2393
|
+
rmSync(workingPath, { force: true });
|
|
2394
|
+
copyFileSync(sourcePath, workingPath, fsConstants.COPYFILE_EXCL);
|
|
2395
|
+
ensurePrivateFile(workingPath);
|
|
2396
|
+
runSessionScaleFaultHook("afterForkCopy", {
|
|
2397
|
+
sourceId,
|
|
2398
|
+
sessionId: newId,
|
|
2399
|
+
filePath: workingPath,
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
const inspectTarget = () => {
|
|
2405
|
+
let sourceBoundaryHash = null;
|
|
2406
|
+
let lineageEvent = null;
|
|
2407
|
+
let lineageEventIndex = null;
|
|
2408
|
+
let lineageAuthority = null;
|
|
2409
|
+
let matchingLineageEvents = 0;
|
|
2410
|
+
let eventIndex = 0;
|
|
2411
|
+
const verification = verifyTranscriptFile(workingPath, {
|
|
2412
|
+
onVerifiedEvent(event) {
|
|
2413
|
+
eventIndex += 1;
|
|
2414
|
+
if (eventIndex === sourceVerification.chainedEvents) {
|
|
2415
|
+
sourceBoundaryHash = event.hash;
|
|
2416
|
+
}
|
|
2417
|
+
const authority = event?.data?.[SESSION_FORK_AUTHORITY_FIELD];
|
|
2418
|
+
if (
|
|
2419
|
+
event?.type === "system" &&
|
|
2420
|
+
authority?.schema === SESSION_FORK_AUTHORITY_SCHEMA &&
|
|
2421
|
+
authority?.requestDigest === requestDigest &&
|
|
2422
|
+
authority?.sourceSessionId === sourceId
|
|
2423
|
+
) {
|
|
2424
|
+
matchingLineageEvents += 1;
|
|
2425
|
+
lineageEvent = event;
|
|
2426
|
+
lineageEventIndex = eventIndex;
|
|
2427
|
+
lineageAuthority = authority;
|
|
2428
|
+
}
|
|
2429
|
+
},
|
|
2430
|
+
});
|
|
2431
|
+
return {
|
|
2432
|
+
verification,
|
|
2433
|
+
sourceBoundaryHash,
|
|
2434
|
+
lineageEvent,
|
|
2435
|
+
lineageEventIndex,
|
|
2436
|
+
lineageAuthority,
|
|
2437
|
+
matchingLineageEvents,
|
|
2438
|
+
};
|
|
2439
|
+
};
|
|
2440
|
+
|
|
2441
|
+
let target = inspectTarget();
|
|
2442
|
+
if (
|
|
2443
|
+
target.sourceBoundaryHash === sourceVerification.lastHash &&
|
|
2444
|
+
target.verification.chainedEvents ===
|
|
2445
|
+
sourceVerification.chainedEvents &&
|
|
2446
|
+
target.verification.truncatedTail &&
|
|
2447
|
+
targetMeta === null
|
|
2448
|
+
) {
|
|
2449
|
+
// A hard exit can cut the one lineage append. Discard only that
|
|
2450
|
+
// unanchored physical tail, then prove the copied source again.
|
|
2451
|
+
inspectPhysicalTail(workingPath);
|
|
2452
|
+
target = inspectTarget();
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
const cleanTarget =
|
|
2456
|
+
target.verification.status === TRANSCRIPT_CHAIN_STATUS.VERIFIED &&
|
|
2457
|
+
target.verification.malformedLines === 0 &&
|
|
2458
|
+
!target.verification.truncatedTail;
|
|
2459
|
+
const exactSourceCopy =
|
|
2460
|
+
cleanTarget &&
|
|
2461
|
+
target.sourceBoundaryHash === sourceVerification.lastHash &&
|
|
2462
|
+
target.verification.chainedEvents ===
|
|
2463
|
+
sourceVerification.chainedEvents;
|
|
2464
|
+
|
|
2465
|
+
if (exactSourceCopy) {
|
|
2466
|
+
if (targetMeta !== null) {
|
|
2467
|
+
throw unverifiedTranscriptError(newId, target.verification);
|
|
2468
|
+
}
|
|
2469
|
+
const { prevHash } = _resolveChainTail(workingPath);
|
|
2470
|
+
if (prevHash !== sourceVerification.lastHash) {
|
|
2471
|
+
const error = new Error(
|
|
2472
|
+
`Fork source changed while copying: ${sourceId}`,
|
|
2473
|
+
);
|
|
2474
|
+
error.code = "SESSION_REVISION_STALE";
|
|
2475
|
+
throw error;
|
|
2476
|
+
}
|
|
2477
|
+
const forkAuthority = {
|
|
2478
|
+
schema: SESSION_FORK_AUTHORITY_SCHEMA,
|
|
2479
|
+
requestDigest,
|
|
2480
|
+
sourceSessionId: sourceId,
|
|
2481
|
+
sourceHeadHash: sourceVerification.lastHash,
|
|
2482
|
+
sourceEventCount: sourceVerification.chainedEvents,
|
|
2483
|
+
};
|
|
2484
|
+
const core = {
|
|
2485
|
+
type: "system",
|
|
2486
|
+
timestamp: Date.now(),
|
|
2487
|
+
data: {
|
|
2488
|
+
...persistedData,
|
|
2489
|
+
[SESSION_FORK_AUTHORITY_FIELD]: forkAuthority,
|
|
2490
|
+
},
|
|
2491
|
+
};
|
|
2492
|
+
const hash = computeEventHash(prevHash, core);
|
|
2493
|
+
appendFileSync(
|
|
2494
|
+
workingPath,
|
|
2495
|
+
`${JSON.stringify({ ...core, prevHash, hash })}\n`,
|
|
2496
|
+
{ encoding: "utf8", mode: 0o600 },
|
|
2497
|
+
);
|
|
2498
|
+
ensurePrivateFile(workingPath);
|
|
2499
|
+
runSessionScaleFaultHook("afterForkLineage", {
|
|
2500
|
+
sourceId,
|
|
2501
|
+
sessionId: newId,
|
|
2502
|
+
filePath: workingPath,
|
|
2503
|
+
event: { ...core, prevHash, hash },
|
|
2504
|
+
});
|
|
2505
|
+
target = inspectTarget();
|
|
2506
|
+
targetMeta = readSessionMeta(sessionsDir, newId);
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
const authority = target.lineageAuthority;
|
|
2510
|
+
const authorityEventCount = Number(authority?.sourceEventCount);
|
|
2511
|
+
const creationEventCount = authorityEventCount + 1;
|
|
2512
|
+
const expectedAuthority = {
|
|
2513
|
+
schema: SESSION_FORK_AUTHORITY_SCHEMA,
|
|
2514
|
+
requestDigest,
|
|
2515
|
+
sourceSessionId: sourceId,
|
|
2516
|
+
sourceHeadHash: authority?.sourceHeadHash,
|
|
2517
|
+
sourceEventCount: authorityEventCount,
|
|
2518
|
+
};
|
|
2519
|
+
const lineageMatches =
|
|
2520
|
+
target.matchingLineageEvents === 1 &&
|
|
2521
|
+
target.lineageEvent?.type === "system" &&
|
|
2522
|
+
Number.isSafeInteger(authorityEventCount) &&
|
|
2523
|
+
authorityEventCount > 0 &&
|
|
2524
|
+
typeof authority?.sourceHeadHash === "string" &&
|
|
2525
|
+
target.lineageEventIndex === creationEventCount &&
|
|
2526
|
+
target.lineageEvent?.prevHash === authority.sourceHeadHash &&
|
|
2527
|
+
JSON.stringify(target.lineageEvent?.data) ===
|
|
2528
|
+
JSON.stringify({
|
|
2529
|
+
...persistedData,
|
|
2530
|
+
[SESSION_FORK_AUTHORITY_FIELD]: expectedAuthority,
|
|
2531
|
+
});
|
|
2532
|
+
if (
|
|
2533
|
+
target.verification.status !== TRANSCRIPT_CHAIN_STATUS.VERIFIED ||
|
|
2534
|
+
target.verification.chainedEvents < creationEventCount ||
|
|
2535
|
+
!lineageMatches ||
|
|
2536
|
+
target.verification.malformedLines > 0 ||
|
|
2537
|
+
target.verification.truncatedTail
|
|
2538
|
+
) {
|
|
2539
|
+
throw unverifiedTranscriptError(newId, target.verification);
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
const anchorMatchesTarget =
|
|
2543
|
+
targetMeta?.deleted !== true &&
|
|
2544
|
+
targetMeta?.last_hash === target.verification.lastHash &&
|
|
2545
|
+
Number(targetMeta?.event_count) ===
|
|
2546
|
+
target.verification.chainedEvents;
|
|
2547
|
+
if (anchorMatchesTarget) {
|
|
2548
|
+
const activity = readLatestSessionActivity(sessionsDir, newId);
|
|
2549
|
+
if (
|
|
2550
|
+
activity?.deleted === true ||
|
|
2551
|
+
activity?.last_hash !== targetMeta.last_hash ||
|
|
2552
|
+
Number(activity?.event_count) !== Number(targetMeta.event_count)
|
|
2553
|
+
) {
|
|
2554
|
+
recordSessionActivity(sessionsDir, targetMeta);
|
|
2555
|
+
}
|
|
2556
|
+
return newId;
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
// A completed or progressed successor with a non-matching anchor is a
|
|
2560
|
+
// rollback/conflict, never a crash prefix that this request may bless.
|
|
2561
|
+
if (
|
|
2562
|
+
targetMeta !== null ||
|
|
2563
|
+
target.verification.chainedEvents > creationEventCount
|
|
2564
|
+
) {
|
|
2565
|
+
throw unverifiedTranscriptError(newId, target.verification);
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
let sourceAuthorityHash = null;
|
|
2569
|
+
if (authorityEventCount === sourceVerification.chainedEvents) {
|
|
2570
|
+
sourceAuthorityHash = sourceVerification.lastHash;
|
|
2571
|
+
} else if (authorityEventCount < sourceVerification.chainedEvents) {
|
|
2572
|
+
let index = 0;
|
|
2573
|
+
const authorityVerification = verifyTranscriptFile(sourcePath, {
|
|
2574
|
+
onVerifiedEvent(event) {
|
|
2575
|
+
index += 1;
|
|
2576
|
+
if (index === authorityEventCount) {
|
|
2577
|
+
sourceAuthorityHash = event.hash;
|
|
2578
|
+
}
|
|
2579
|
+
},
|
|
2580
|
+
});
|
|
2581
|
+
assertVerifiedTranscriptAnchor(sourceId, authorityVerification);
|
|
2582
|
+
}
|
|
2583
|
+
if (sourceAuthorityHash !== authority.sourceHeadHash) {
|
|
2584
|
+
const error = new Error(
|
|
2585
|
+
`Fork source revision no longer contains the claimed authority: ${sourceId}`,
|
|
2586
|
+
);
|
|
2587
|
+
error.code = "SESSION_REVISION_STALE";
|
|
2588
|
+
throw error;
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
if (workingPath === pendingPath) {
|
|
2592
|
+
if (existsSync(filePath)) {
|
|
2593
|
+
throw unverifiedTranscriptError(
|
|
2594
|
+
newId,
|
|
2595
|
+
verifyTranscriptFile(filePath),
|
|
2596
|
+
);
|
|
2597
|
+
}
|
|
2598
|
+
renameSync(pendingPath, filePath);
|
|
2599
|
+
workingPath = filePath;
|
|
2600
|
+
ensurePrivateFile(workingPath);
|
|
2601
|
+
runSessionScaleFaultHook("afterForkPublish", {
|
|
2602
|
+
sourceId,
|
|
2603
|
+
sessionId: newId,
|
|
2604
|
+
filePath: workingPath,
|
|
2605
|
+
});
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
rebuildSessionMetaUnlocked(sessionsDir, newId, filePath);
|
|
2609
|
+
runSessionScaleFaultHook("afterForkMeta", {
|
|
2610
|
+
sourceId,
|
|
2611
|
+
sessionId: newId,
|
|
2612
|
+
filePath,
|
|
2613
|
+
});
|
|
2614
|
+
assertVerifiedTranscriptAnchor(newId, target.verification);
|
|
2615
|
+
return newId;
|
|
2616
|
+
},
|
|
2617
|
+
{
|
|
2618
|
+
failIfUnavailable: true,
|
|
2619
|
+
timeoutMs: 30_000,
|
|
2620
|
+
retryMs: 1,
|
|
2621
|
+
maxRetryMs: 8,
|
|
2622
|
+
retryJitterMs: 4,
|
|
2623
|
+
},
|
|
2624
|
+
);
|
|
2625
|
+
},
|
|
2626
|
+
{
|
|
2627
|
+
failIfUnavailable: true,
|
|
2628
|
+
timeoutMs: 30_000,
|
|
2629
|
+
retryMs: 1,
|
|
2630
|
+
maxRetryMs: 8,
|
|
2631
|
+
retryJitterMs: 4,
|
|
2632
|
+
yieldAfterReleaseMs: 2,
|
|
2633
|
+
},
|
|
2634
|
+
);
|
|
455
2635
|
}
|
|
456
2636
|
|
|
457
2637
|
/**
|
|
458
2638
|
* Create an independent BRANCH session ("从这里分支" — P0-3's fourth restore
|
|
459
2639
|
* action) that keeps a parent's conversation up to a chosen turn and diverges
|
|
460
|
-
* from there. Unlike forkSession() (whole-session copy
|
|
461
|
-
* this writes ONLY the caller-supplied pre-branch messages
|
|
2640
|
+
* from there. Unlike forkSession() (a whole-session copy with revision-scoped
|
|
2641
|
+
* request identity), this writes ONLY the caller-supplied pre-branch messages
|
|
462
2642
|
* id (see deriveBranchSessionId) and records parent lineage — so the origin
|
|
463
2643
|
* session is never touched (preservesParent) and a replayed branch request
|
|
464
2644
|
* resolves to the SAME file instead of a duplicate (idempotent).
|
|
@@ -483,35 +2663,214 @@ export function createBranchSession({
|
|
|
483
2663
|
`unsafe branch session id: ${String(branchSessionId).slice(0, 60)}`,
|
|
484
2664
|
);
|
|
485
2665
|
}
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
startSession(branchSessionId, {
|
|
493
|
-
title: meta.title || `Branch of ${parentSessionId ?? "session"}`,
|
|
494
|
-
provider: meta.provider || "",
|
|
495
|
-
model: meta.model || "",
|
|
496
|
-
});
|
|
497
|
-
appendEvent(branchSessionId, "session_branch", {
|
|
498
|
-
parentSessionId: parentSessionId == null ? null : String(parentSessionId),
|
|
499
|
-
parentTurnId: parentTurnId == null ? null : String(parentTurnId),
|
|
2666
|
+
// Validate and clone the complete message projection before creating the
|
|
2667
|
+
// deterministic branch file. A validation failure must leave no partial
|
|
2668
|
+
// session that would make a later, valid retry look idempotently complete.
|
|
2669
|
+
const canonicalMessages = projectCanonicalResumeMessages(messages, {
|
|
2670
|
+
strict: true,
|
|
500
2671
|
});
|
|
501
|
-
|
|
2672
|
+
if (
|
|
2673
|
+
(parentSessionId !== null && typeof parentSessionId !== "string") ||
|
|
2674
|
+
(parentTurnId !== null && typeof parentTurnId !== "string")
|
|
2675
|
+
) {
|
|
2676
|
+
throw new TypeError("Branch lineage ids must be strings or null");
|
|
2677
|
+
}
|
|
2678
|
+
const branchParentSessionId = parentSessionId;
|
|
2679
|
+
const branchParentTurnId = parentTurnId;
|
|
2680
|
+
const cleanMeta = sanitizePersistedMessage(meta);
|
|
2681
|
+
if (!cleanMeta || typeof cleanMeta !== "object") {
|
|
2682
|
+
throw new TypeError("Branch metadata must contain JSON-safe data");
|
|
2683
|
+
}
|
|
2684
|
+
const startData = {
|
|
2685
|
+
title:
|
|
2686
|
+
typeof cleanMeta.title === "string" && cleanMeta.title
|
|
2687
|
+
? cleanMeta.title
|
|
2688
|
+
: `Branch of ${branchParentSessionId ?? "session"}`,
|
|
2689
|
+
provider: typeof cleanMeta.provider === "string" ? cleanMeta.provider : "",
|
|
2690
|
+
model: typeof cleanMeta.model === "string" ? cleanMeta.model : "",
|
|
2691
|
+
};
|
|
2692
|
+
const plannedEvents = [
|
|
2693
|
+
{ type: "session_start", data: startData },
|
|
2694
|
+
{
|
|
2695
|
+
type: "session_branch",
|
|
2696
|
+
data: {
|
|
2697
|
+
parentSessionId: branchParentSessionId,
|
|
2698
|
+
parentTurnId: branchParentTurnId,
|
|
2699
|
+
},
|
|
2700
|
+
},
|
|
2701
|
+
];
|
|
502
2702
|
let count = 0;
|
|
503
|
-
for (const m of
|
|
504
|
-
if (!m || m.role == null) continue;
|
|
2703
|
+
for (const m of canonicalMessages) {
|
|
505
2704
|
if (m.role === "user") {
|
|
506
|
-
|
|
2705
|
+
plannedEvents.push({
|
|
2706
|
+
type: "user_message",
|
|
2707
|
+
data: { role: "user", content: m.content },
|
|
2708
|
+
});
|
|
507
2709
|
count += 1;
|
|
508
2710
|
} else if (m.role === "assistant") {
|
|
509
|
-
|
|
2711
|
+
plannedEvents.push({
|
|
2712
|
+
type: "assistant_message",
|
|
2713
|
+
data: { role: "assistant", content: m.content },
|
|
2714
|
+
});
|
|
2715
|
+
count += 1;
|
|
2716
|
+
} else if (m.role === "system") {
|
|
2717
|
+
plannedEvents.push({
|
|
2718
|
+
type: "system",
|
|
2719
|
+
data: encodePersistedMessage(m),
|
|
2720
|
+
});
|
|
510
2721
|
count += 1;
|
|
511
2722
|
}
|
|
512
|
-
//
|
|
2723
|
+
// Host prompts and tool scaffolding are re-established on resume. The
|
|
2724
|
+
// canonical projection above retains only explicitly durable systems.
|
|
513
2725
|
}
|
|
514
|
-
|
|
2726
|
+
const inputDigest = `sha256:${createHash("sha256")
|
|
2727
|
+
.update(JSON.stringify(plannedEvents), "utf8")
|
|
2728
|
+
.digest("hex")}`;
|
|
2729
|
+
plannedEvents.push({
|
|
2730
|
+
type: "session_branch_complete",
|
|
2731
|
+
data: {
|
|
2732
|
+
schemaVersion: 1,
|
|
2733
|
+
inputDigest,
|
|
2734
|
+
messageCount: count,
|
|
2735
|
+
},
|
|
2736
|
+
});
|
|
2737
|
+
|
|
2738
|
+
const filePath = sessionPath(branchSessionId);
|
|
2739
|
+
const sessionsDir = getSessionsDir();
|
|
2740
|
+
return withFileLock(
|
|
2741
|
+
filePath,
|
|
2742
|
+
() => {
|
|
2743
|
+
if (existsSync(filePath)) inspectPhysicalTail(filePath);
|
|
2744
|
+
let verification = existsSync(filePath)
|
|
2745
|
+
? verifyTranscriptFile(filePath)
|
|
2746
|
+
: {
|
|
2747
|
+
status: TRANSCRIPT_CHAIN_STATUS.EMPTY,
|
|
2748
|
+
chainedEvents: 0,
|
|
2749
|
+
malformedLines: 0,
|
|
2750
|
+
truncatedTail: false,
|
|
2751
|
+
lastHash: null,
|
|
2752
|
+
};
|
|
2753
|
+
if (
|
|
2754
|
+
verification.status !== TRANSCRIPT_CHAIN_STATUS.EMPTY &&
|
|
2755
|
+
verification.status !== TRANSCRIPT_CHAIN_STATUS.VERIFIED
|
|
2756
|
+
) {
|
|
2757
|
+
throw unverifiedTranscriptError(branchSessionId, verification);
|
|
2758
|
+
}
|
|
2759
|
+
const existingEvents = existsSync(filePath)
|
|
2760
|
+
? readEvents(branchSessionId)
|
|
2761
|
+
: [];
|
|
2762
|
+
const prefixMatches = existingEvents
|
|
2763
|
+
.slice(0, Math.min(existingEvents.length, plannedEvents.length))
|
|
2764
|
+
.every(
|
|
2765
|
+
(event, index) =>
|
|
2766
|
+
event.type === plannedEvents[index].type &&
|
|
2767
|
+
JSON.stringify(event.data) ===
|
|
2768
|
+
JSON.stringify(plannedEvents[index].data),
|
|
2769
|
+
);
|
|
2770
|
+
if (!prefixMatches) {
|
|
2771
|
+
const error = new Error(
|
|
2772
|
+
`Branch session conflicts with its deterministic input: ${branchSessionId}`,
|
|
2773
|
+
);
|
|
2774
|
+
error.code = "SESSION_BRANCH_CONFLICT";
|
|
2775
|
+
throw error;
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
// Once the exact completion marker is anchored, later branch turns are
|
|
2779
|
+
// outside the idempotent creation transaction. A replay resolves to that
|
|
2780
|
+
// progressed branch, but an unanchored suffix is never re-blessed.
|
|
2781
|
+
if (existingEvents.length > plannedEvents.length) {
|
|
2782
|
+
assertVerifiedTranscriptAnchor(branchSessionId, verification);
|
|
2783
|
+
return { branchSessionId, created: false, messages: 0 };
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
// A crashed writer can leave a hash-valid planned prefix ahead of its
|
|
2787
|
+
// sidecar. Only advance an existing anchor along that exact prefix. Never
|
|
2788
|
+
// lower or replace a higher/different anchor: doing so would legitimize a
|
|
2789
|
+
// truncated deterministic branch and permanently discard later turns.
|
|
2790
|
+
const anchoredMeta = readSessionMeta(sessionsDir, branchSessionId);
|
|
2791
|
+
const anchoredCount = Number(anchoredMeta?.event_count);
|
|
2792
|
+
const anchorMatchesCurrent =
|
|
2793
|
+
anchoredMeta?.deleted !== true &&
|
|
2794
|
+
anchoredMeta?.last_hash === verification.lastHash &&
|
|
2795
|
+
anchoredCount === verification.chainedEvents;
|
|
2796
|
+
const anchorIsStrictPriorPrefix =
|
|
2797
|
+
anchoredMeta?.deleted !== true &&
|
|
2798
|
+
Number.isSafeInteger(anchoredCount) &&
|
|
2799
|
+
anchoredCount >= 0 &&
|
|
2800
|
+
anchoredCount < verification.chainedEvents &&
|
|
2801
|
+
verification.chainedEvents === existingEvents.length &&
|
|
2802
|
+
(anchoredCount === 0
|
|
2803
|
+
? anchoredMeta?.last_hash === null
|
|
2804
|
+
: existingEvents[anchoredCount - 1]?.hash ===
|
|
2805
|
+
anchoredMeta?.last_hash);
|
|
2806
|
+
if (
|
|
2807
|
+
anchoredMeta !== null &&
|
|
2808
|
+
!anchorMatchesCurrent &&
|
|
2809
|
+
!anchorIsStrictPriorPrefix
|
|
2810
|
+
) {
|
|
2811
|
+
throw unverifiedTranscriptError(branchSessionId, verification);
|
|
2812
|
+
}
|
|
2813
|
+
if (existsSync(filePath) && !anchorMatchesCurrent) {
|
|
2814
|
+
rebuildSessionMetaUnlocked(sessionsDir, branchSessionId, filePath);
|
|
2815
|
+
}
|
|
2816
|
+
if (existingEvents.length > 0) {
|
|
2817
|
+
assertVerifiedTranscriptAnchor(branchSessionId, verification);
|
|
2818
|
+
}
|
|
2819
|
+
if (existingEvents.length === plannedEvents.length) {
|
|
2820
|
+
return { branchSessionId, created: false, messages: 0 };
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
let prevHash = verification.lastHash;
|
|
2824
|
+
for (
|
|
2825
|
+
let index = existingEvents.length;
|
|
2826
|
+
index < plannedEvents.length;
|
|
2827
|
+
index += 1
|
|
2828
|
+
) {
|
|
2829
|
+
const planned = plannedEvents[index];
|
|
2830
|
+
const core = {
|
|
2831
|
+
type: planned.type,
|
|
2832
|
+
timestamp: Date.now(),
|
|
2833
|
+
data: planned.data,
|
|
2834
|
+
};
|
|
2835
|
+
const hash = computeEventHash(prevHash, core);
|
|
2836
|
+
const event = { ...core, prevHash, hash };
|
|
2837
|
+
appendFileSync(filePath, `${JSON.stringify(event)}\n`, {
|
|
2838
|
+
encoding: "utf8",
|
|
2839
|
+
mode: 0o600,
|
|
2840
|
+
});
|
|
2841
|
+
ensurePrivateFile(filePath);
|
|
2842
|
+
runSessionScaleFaultHook("afterTranscriptAppend", {
|
|
2843
|
+
sessionId: branchSessionId,
|
|
2844
|
+
type: planned.type,
|
|
2845
|
+
event,
|
|
2846
|
+
filePath,
|
|
2847
|
+
});
|
|
2848
|
+
prevHash = hash;
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
verification = verifyTranscriptFile(filePath);
|
|
2852
|
+
if (
|
|
2853
|
+
verification.status !== TRANSCRIPT_CHAIN_STATUS.VERIFIED ||
|
|
2854
|
+
verification.chainedEvents !== plannedEvents.length ||
|
|
2855
|
+
verification.lastHash !== prevHash ||
|
|
2856
|
+
verification.malformedLines > 0 ||
|
|
2857
|
+
verification.truncatedTail
|
|
2858
|
+
) {
|
|
2859
|
+
throw unverifiedTranscriptError(branchSessionId, verification);
|
|
2860
|
+
}
|
|
2861
|
+
rebuildSessionMetaUnlocked(sessionsDir, branchSessionId, filePath);
|
|
2862
|
+
assertVerifiedTranscriptAnchor(branchSessionId, verification);
|
|
2863
|
+
return { branchSessionId, created: true, messages: count };
|
|
2864
|
+
},
|
|
2865
|
+
{
|
|
2866
|
+
failIfUnavailable: true,
|
|
2867
|
+
timeoutMs: 30_000,
|
|
2868
|
+
retryMs: 1,
|
|
2869
|
+
maxRetryMs: 8,
|
|
2870
|
+
retryJitterMs: 4,
|
|
2871
|
+
yieldAfterReleaseMs: 2,
|
|
2872
|
+
},
|
|
2873
|
+
);
|
|
515
2874
|
}
|
|
516
2875
|
|
|
517
2876
|
export function sessionExists(sessionId) {
|
|
@@ -618,30 +2977,48 @@ export function validateJsonlSession(sessionId) {
|
|
|
618
2977
|
};
|
|
619
2978
|
}
|
|
620
2979
|
|
|
621
|
-
const lines = readFileSync(filePath, "utf-8").split("\n");
|
|
622
2980
|
let malformedLines = 0;
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
2981
|
+
let eventCount = 0;
|
|
2982
|
+
let messageCount = 0;
|
|
2983
|
+
let invalidWsTurns = 0;
|
|
2984
|
+
let invalidWsClaims = 0;
|
|
2985
|
+
let hasStartEvent = false;
|
|
2986
|
+
for (const { line } of iterateFileLinesSync(filePath)) {
|
|
627
2987
|
try {
|
|
628
|
-
|
|
2988
|
+
const event = JSON.parse(line);
|
|
2989
|
+
eventCount += 1;
|
|
2990
|
+
if (event?.type === "session_start") hasStartEvent = true;
|
|
2991
|
+
if (
|
|
2992
|
+
event?.type === "user_message" ||
|
|
2993
|
+
event?.type === "assistant_message"
|
|
2994
|
+
) {
|
|
2995
|
+
messageCount += 1;
|
|
2996
|
+
} else if (event?.type === WS_TURN_EVENT) {
|
|
2997
|
+
const settlement = projectWsTurnSettlement(event);
|
|
2998
|
+
if (!settlement) invalidWsTurns += 1;
|
|
2999
|
+
else if (settlement.outcome === "completed") messageCount += 2;
|
|
3000
|
+
} else if (
|
|
3001
|
+
event?.type === WS_TURN_CLAIM_EVENT &&
|
|
3002
|
+
!projectWsTurnClaim(event)
|
|
3003
|
+
) {
|
|
3004
|
+
invalidWsClaims += 1;
|
|
3005
|
+
}
|
|
629
3006
|
} catch {
|
|
630
3007
|
malformedLines++;
|
|
631
3008
|
}
|
|
632
3009
|
}
|
|
633
3010
|
|
|
634
|
-
const hasStartEvent = events.some((event) => event.type === "session_start");
|
|
635
|
-
const messageCount = events.filter(
|
|
636
|
-
(event) =>
|
|
637
|
-
event.type === "user_message" || event.type === "assistant_message",
|
|
638
|
-
).length;
|
|
639
|
-
|
|
640
3011
|
return {
|
|
641
3012
|
sessionId,
|
|
642
|
-
valid:
|
|
3013
|
+
valid:
|
|
3014
|
+
malformedLines === 0 &&
|
|
3015
|
+
invalidWsTurns === 0 &&
|
|
3016
|
+
invalidWsClaims === 0 &&
|
|
3017
|
+
hasStartEvent,
|
|
643
3018
|
malformedLines,
|
|
644
|
-
|
|
3019
|
+
invalidWsTurns,
|
|
3020
|
+
invalidWsClaims,
|
|
3021
|
+
eventCount,
|
|
645
3022
|
messageCount,
|
|
646
3023
|
hasStartEvent,
|
|
647
3024
|
};
|
|
@@ -709,10 +3086,17 @@ function performLegacySessionMigration(sourcePath, options) {
|
|
|
709
3086
|
appendLegacyMessage(sessionId, message);
|
|
710
3087
|
}
|
|
711
3088
|
if (legacy.summary) {
|
|
712
|
-
appendEvent(
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
3089
|
+
appendEvent(
|
|
3090
|
+
sessionId,
|
|
3091
|
+
"system",
|
|
3092
|
+
markDurableSystemMessage(
|
|
3093
|
+
{
|
|
3094
|
+
role: "system",
|
|
3095
|
+
content: `[Migrated Summary]\n${legacy.summary}`,
|
|
3096
|
+
},
|
|
3097
|
+
DURABLE_SYSTEM_MESSAGE_KINDS.MIGRATION_SUMMARY,
|
|
3098
|
+
),
|
|
3099
|
+
);
|
|
716
3100
|
}
|
|
717
3101
|
|
|
718
3102
|
const validation = validateJsonlSession(sessionId);
|