chainlesschain 0.162.197 → 0.162.200
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/bin/chainlesschain.js +14 -6
- package/package.json +4 -1
- package/scripts/command-lifecycle-report.mjs +94 -0
- package/src/assets/web-panel/assets/{AIOps-D3Gu1a5d.js → AIOps-DcQ0bWKy.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-DrwAoKaz.js → ActionButton-GecwfFPt.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-C7Q-gd_3.js → Analytics-CD6iSt9v.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-DgBtae-n.js → AppLayout-CDEtuEYf.js} +5 -5
- package/src/assets/web-panel/assets/{Artifacts-pQ6LcA-W.js → Artifacts-DS3jAjeW.js} +1 -1
- package/src/assets/web-panel/assets/{Audit-DK61tocB.js → Audit-CcSJA048.js} +1 -1
- package/src/assets/web-panel/assets/{BackgroundAgents-BOKgDFRh.js → BackgroundAgents-DRo9Morb.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-Bo9G4vTP.js → Backup-Vw5MrWft.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-Do_dE9_v.js → BaseInput-DNHIu823.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-COVvZzOL.js → Chat-DvAsO8-b.js} +4 -4
- package/src/assets/web-panel/assets/ChatBubbleRenderer-9aDuObay.js +1 -0
- package/src/assets/web-panel/assets/{Checkbox-Z9MJMolt.js → Checkbox-CKCa7tvn.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-BVzWKVbf.js → Codegen-CIFvZdaT.js} +1 -1
- package/src/assets/web-panel/assets/{Col-C8MnYcbT.js → Col-C79yz7bK.js} +1 -1
- package/src/assets/web-panel/assets/{Community-DQxqw15l.js → Community-DVvZskLJ.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-BAUmrh3y.js → Compact-oaew61hV.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-jPAkQ3zK.js → Compliance-DqA7wmRx.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-BBQyxbh8.js → Cowork-B-qGadxa.js} +3 -3
- package/src/assets/web-panel/assets/{Cron-W5koq8Z5.js → Cron-CUlCl4sS.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-BQhnu5hS.js → Crosschain-CNOlr9DU.js} +1 -1
- package/src/assets/web-panel/assets/{DID-DD7PlI18.js → DID-DJqFCT_3.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-w_ILuwKI.js → Dashboard-CznGpphf.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-qiDUAyAo.js → Dropdown-_cJw218E.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-PDeBcI5H.js → EmailListRenderer-Dr-1BZ9C.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-SuArscRM.js → FamilyGuardDashboard-Dxf4xtSk.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-BCovZaUn.js → Federation-DOiG3i_1.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-DjlFdVFk.js → FormItemContext-BzbMcFB9.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-DoEZ7fcS.js → GenericCardRenderer-VbBrYttP.js} +1 -1
- package/src/assets/web-panel/assets/{Git-CV0-6GPI.js → Git-CLG8E6ED.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-B_SFN50w.js → Governance-LGJQfk_q.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-heI0Eo6k.js → Inference-DlQKx9lF.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-Cz5INS5p.js → KnowledgeGraph-DU8vEouV.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-BSFxiHkg.js → Logs-FZIQOdln.js} +2 -2
- package/src/assets/web-panel/assets/MarkdownRenderer-BJNWIrbw.js +1 -0
- package/src/assets/web-panel/assets/{Marketplace-0TWTpE7k.js → Marketplace-C_xd-zZI.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-CV4-wBLm.js → McpTools-q-YktphS.js} +5 -5
- package/src/assets/web-panel/assets/{Memory-x5bH5rw5.js → Memory-Cq9OhDin.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-DaBmUu9G.js → MobileBridge-B03_R98T.js} +1 -1
- package/src/assets/web-panel/assets/MobileProjects-DZrk-qmF.js +1 -0
- package/src/assets/web-panel/assets/{Mtc-B_zKEBif.js → Mtc-BidCKW28.js} +5 -5
- package/src/assets/web-panel/assets/{MtcAudit-BCfKD2yg.js → MtcAudit-r-iaMVM7.js} +2 -2
- package/src/assets/web-panel/assets/{Multisig-DgYfj6c_.js → Multisig-CgTUFvff.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-Dz09lFUm.js → NLProgramming-B7NvzfAV.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-Cuj6ugBE.js → Notes-80CWNqJI.js} +3 -3
- package/src/assets/web-panel/assets/{NotificationSettings-DK2WXjhg.js → NotificationSettings-0H6xdiY3.js} +1 -1
- package/src/assets/web-panel/assets/{OrderTableRenderer-D1IAOQoo.js → OrderTableRenderer-CKP4AFWy.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-9fqnPvTw.js → Organization-B8sxCV_h.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-n5xHkhow.js → Overflow-CW9JBBhQ.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-B5AUWwH-.js → P2P-bdSQV7oZ.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-CoUDV6dy.js → PdhVaultBrowser-DQad42nW.js} +4 -4
- package/src/assets/web-panel/assets/{Permissions-DPRmQf_H.js → Permissions-CueexxTR.js} +4 -4
- package/src/assets/web-panel/assets/{PersonalDataHub-B37bsbEN.js → PersonalDataHub-fqxflhR8.js} +6 -6
- package/src/assets/web-panel/assets/{Pipeline-CCAuPqu9.js → Pipeline-KFwwnIMC.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-B81msZdv.js → Privacy-ClnD9buY.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-B-Py2NbF.js → ProjectInit-CZEz6sm3.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-CYsneiRB.js → ProjectSettings-B6NzpmOI.js} +2 -2
- package/src/assets/web-panel/assets/{Projects-Y975Mh6R.js → Projects-DYyuwPCs.js} +1 -1
- package/src/assets/web-panel/assets/Providers-C3tz_jdh.js +1 -0
- package/src/assets/web-panel/assets/{QrScannerModal-Bj_6gJ0l.js → QrScannerModal-BzuOBbqB.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-BgpBCMPE.js → QuickAsk-CmNO9qSj.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-DhUW3YUA.js → Recommend-VJNj3_NF.js} +1 -1
- package/src/assets/web-panel/assets/{RemoteSession-DdeyfbkA.js → RemoteSession-CrbdbSBF.js} +2 -2
- package/src/assets/web-panel/assets/{Reputation-fxW421xd.js → Reputation-DTyMd45j.js} +1 -1
- package/src/assets/web-panel/assets/{Row-De73w1og.js → Row-DHrQ-Icz.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-3US_Q_lj.js → RssFeed-DqWxY9c5.js} +2 -2
- package/src/assets/web-panel/assets/{Search-DbZ_DU74.js → Search-ctF1Ks3J.js} +1 -1
- package/src/assets/web-panel/assets/{Security-DoIU1vMF.js → Security-BrQz8uo5.js} +4 -4
- package/src/assets/web-panel/assets/{Services-DB_RGqpc.js → Services-D4scVFar.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-DnzLaayC.js → Skeleton-oUScaOjv.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-VzVXHENF.js → Skills-CL-y0eLt.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-DMfvjCAI.js → Sla-DQExhwXj.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-DQQOYWbF.js → SpeechSettings-iNp_I7kN.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-Bmx3FG1P.js → SyncSettings-CTDlMo0A.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-DOQyfb-2.js → Tasks-4kb8l3sc.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-D6hTRT7c.js → Templates-BXcL9oXW.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-CTPQZAzs.js → Tenant-DLE8eS0D.js} +1 -1
- package/src/assets/web-panel/assets/{Terminal-B7vII3oH.js → Terminal-DGpUZPPK.js} +2 -2
- package/src/assets/web-panel/assets/{TimelineRenderer-BkiFJeXV.js → TimelineRenderer-C1pWXGPC.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-BPPdQMNo.js → Tokens-BYfbfqhZ.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-BxG8JAEI.js → Trigger-K9YfLOCw.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-DHD7nMpM.js → Trust-Ctkq77Hy.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-B4BJpU47.js → UkeySign-C6_CSe9J.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-CRiGpFx2.js → VideoEditing-Be3rpOWs.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-BSDSNiem.js → Wallet-D0UIplxk.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-DLTUh5Zv.js → WebAuthn-CF9bM9I3.js} +5 -5
- package/src/assets/web-panel/assets/{WorkflowEditor-tW2MwjhD.js → WorkflowEditor-D0H1XVPY.js} +1 -1
- package/src/assets/web-panel/assets/{chat-SgsLQ3FC.js → chat-Cgzzbe0o.js} +1 -1
- package/src/assets/web-panel/assets/{colors-BHjjLIKO.js → colors-BwNxEVS0.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-VceErdKq.js → compact-item-D60Z4Hhs.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-BZ332MYl.js → createContext-MFk58XB0.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-BJiw369i.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-CBTjJOjn.js → hasIn-CRj2SvGO.js} +1 -1
- package/src/assets/web-panel/assets/{index-DaCCy6lq.js → index--s45_1cb.js} +1 -1
- package/src/assets/web-panel/assets/{index-CMVvxIS8.js → index--tquQ2ZG.js} +1 -1
- package/src/assets/web-panel/assets/{index-BhXZEUAc.js → index-1T1rhokn.js} +1 -1
- package/src/assets/web-panel/assets/{index-BQSmZGgs.js → index-B-M82_rx.js} +1 -1
- package/src/assets/web-panel/assets/{index-BeiqkxJG.js → index-BE_el1Zs.js} +1 -1
- package/src/assets/web-panel/assets/{index-29wVzi-k.js → index-BhRkR7IO.js} +1 -1
- package/src/assets/web-panel/assets/{index-iYQNU9za.js → index-Bo0y96-K.js} +1 -1
- package/src/assets/web-panel/assets/{index-BRWhLbWF.js → index-CJgu5JUK.js} +1 -1
- package/src/assets/web-panel/assets/{index-BPXFLYdW.js → index-CLWwdebO.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cr4rjlW5.js → index-CNAK5QG_.js} +1 -1
- package/src/assets/web-panel/assets/{index-CaIS_TNX.js → index-CQjJjPRD.js} +1 -1
- package/src/assets/web-panel/assets/{index-1n7xjKDU.js → index-CTDbAfae.js} +1 -1
- package/src/assets/web-panel/assets/{index-BETEjuNr.js → index-CVI_9sRK.js} +1 -1
- package/src/assets/web-panel/assets/{index-By5jBtkV.js → index-CY14oMQK.js} +1 -1
- package/src/assets/web-panel/assets/{index-DgPvuIn2.js → index-CbiTJkZf.js} +1 -1
- package/src/assets/web-panel/assets/index-CiMouosn.js +1 -0
- package/src/assets/web-panel/assets/{index-CawpdGTW.js → index-CqWpxMqx.js} +1 -1
- package/src/assets/web-panel/assets/{index-_PHrIO4O.js → index-CvB8lYUR.js} +1 -1
- package/src/assets/web-panel/assets/{index-BOtvPdHt.js → index-D51XwtCY.js} +1 -1
- package/src/assets/web-panel/assets/{index-CrUgDQkf.js → index-D6HvK_1O.js} +1 -1
- package/src/assets/web-panel/assets/{index-BY1wexYG.js → index-D9jVIwLJ.js} +1 -1
- package/src/assets/web-panel/assets/{index-C_B9rFQE.js → index-DA4tE98u.js} +1 -1
- package/src/assets/web-panel/assets/{index-B5KAGxNo.js → index-DBwOtXw0.js} +1 -1
- package/src/assets/web-panel/assets/{index-BC1xuQgX.js → index-DKdNzTIb.js} +1 -1
- package/src/assets/web-panel/assets/{index-B2u26AS3.js → index-DRySdmvN.js} +1 -1
- package/src/assets/web-panel/assets/{index-DePOeMDM.js → index-DWzyTev7.js} +1 -1
- package/src/assets/web-panel/assets/index-DXcKlwy7.js +1 -0
- package/src/assets/web-panel/assets/{index-BTlc-Bve.js → index-DYjs7jAP.js} +1 -1
- package/src/assets/web-panel/assets/{index-CLc19alH.js → index-DbROzMBp.js} +1 -1
- package/src/assets/web-panel/assets/{index-fU7wNHDB.js → index-DhFtk5PR.js} +1 -1
- package/src/assets/web-panel/assets/{index-D_SePDWj.js → index-Di2AbMLg.js} +1 -1
- package/src/assets/web-panel/assets/{index-vIXOpcjD.js → index-DvmocQsM.js} +1 -1
- package/src/assets/web-panel/assets/{index-BcUenmCX.js → index-Dw-o0zEF.js} +1 -1
- package/src/assets/web-panel/assets/{index-DUkVN794.js → index-DysPoP38.js} +1 -1
- package/src/assets/web-panel/assets/{index-CtjLcCNU.js → index-LX9pJxm_.js} +1 -1
- package/src/assets/web-panel/assets/{index-B40dQn0G.js → index-OI_fXWx0.js} +3 -3
- package/src/assets/web-panel/assets/{index-FJ3zC9LK.js → index-eDTOX8Xj.js} +1 -1
- package/src/assets/web-panel/assets/{index-DdefYBSw.js → index-gWKDsTzF.js} +1 -1
- package/src/assets/web-panel/assets/{index-CpumS98o.js → index-q_GDUVAA.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-D1oZNz0D.js → initDefaultProps-C61kZqEd.js} +1 -1
- package/src/assets/web-panel/assets/{motion-C8k6doMO.js → motion-DZnT5U1L.js} +1 -1
- package/src/assets/web-panel/assets/{move-DJ4qSbYt.js → move-L8wzwbel.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-CdpJD85j.js → mtc-parser-DLdYSejc.js} +1 -1
- package/src/assets/web-panel/assets/{omit-B0V4slKA.js → omit-D53cbS59.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-Di5NiW1F.js → pickAttrs-Bbc5HPtu.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-B5OMHgEM.js → placementArrow-BkvmO860.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-C6U9IWql.js → responsiveObserve-u2y1ryS1.js} +1 -1
- package/src/assets/web-panel/assets/{slide-BMyTH3rN.js → slide-BBqj7svU.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-C7d6BG7Z.js → statusUtils-GN0mqu2d.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-CR50QnQy.js → styleChecker-BvwPKoBT.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-DBEeoMb8.js → useFlexGapSupport-CCt8tnfY.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-3r099cjv.js → useFs-DXAxl2SV.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-C5IYNdTK.js → usePersonalDataHub-DihlR2dj.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-DmEOLRHf.js → vnode-D7BOX2gi.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-DnpUiVCB.js → zoom-CVbO-nL7.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/command-help-index.json +4 -4
- package/src/commands/agent.js +3 -3
- package/src/commands/background-session.js +145 -0
- package/src/commands/mcp.js +244 -7
- package/src/commands/serve.js +1 -1
- package/src/commands/session-show.js +129 -0
- package/src/commands/session.js +97 -145
- package/src/commands/skill.js +87 -50
- package/src/data/changelog.json +40 -1
- package/src/gateways/ws/session-protocol.js +14 -4
- package/src/gateways/ws/ws-agent-handler.js +39 -1
- package/src/harness/jsonl-session-store.js +1547 -127
- package/src/harness/mcp-client.js +2690 -409
- package/src/harness/session-index.js +80 -80
- package/src/harness/session-list-index.js +210 -7
- package/src/lazy-dispatch.js +76 -8
- package/src/lib/abort-utils.js +68 -0
- package/src/lib/agent-core.js +1 -59
- package/src/lib/background-interaction-journal.js +2 -2
- package/src/lib/chat-core.js +24 -6
- package/src/lib/command-lifecycle-report.js +516 -0
- package/src/lib/cowork-mcp-tools.js +47 -2
- package/src/lib/cowork-task-runner.js +11 -3
- package/src/lib/doctor-checkup.js +9 -7
- package/src/lib/learning/skill-improver.js +21 -2
- package/src/lib/mcp-call-ledger-store.js +116 -3
- package/src/lib/mcp-call-ledger.js +59 -14
- package/src/lib/mcp-client.js +1 -0
- package/src/lib/mcp-stdio-environment.js +48 -0
- package/src/lib/mcp-stdio-executable-identity.js +1133 -0
- package/src/lib/mcp-stdio-execution-authority.js +363 -0
- package/src/lib/mcp-stdio-package-materialization.js +1424 -0
- package/src/lib/mcp-tool-metadata.js +436 -0
- package/src/lib/mcp-tool-result.js +367 -0
- package/src/lib/observability/index.js +71 -0
- package/src/lib/packer/native-update-state.js +979 -1
- package/src/lib/packer/pack-update-applier.js +1050 -125
- package/src/lib/packer/pkg-config-generator.js +1 -0
- package/src/lib/paths.js +53 -0
- package/src/lib/pr-link-ledger.js +8 -27
- package/src/lib/pr-link-store.js +26 -0
- package/src/lib/process-execution-broker/index.js +192 -35
- package/src/lib/process-execution-broker/platform-sandbox.js +291 -24
- package/src/lib/process-tree-termination.js +253 -0
- package/src/lib/recent-session.js +17 -17
- package/src/lib/secure-file-identity.js +47 -0
- package/src/lib/secure-fs.js +21 -2
- package/src/lib/session-anti-rollback-anchor.js +766 -0
- package/src/lib/session-host-lease.js +492 -0
- package/src/lib/session-host-snapshot.js +9 -5
- package/src/lib/session-persistence-failure.js +109 -0
- package/src/lib/session-picker.js +26 -3
- package/src/lib/session-projection.js +5 -1
- package/src/lib/session-search.js +25 -11
- package/src/lib/session-tail.js +31 -1
- package/src/lib/side-effect-ledger-store.js +2 -2
- package/src/lib/skill-budget.js +222 -0
- package/src/lib/skill-execution-authority.js +385 -0
- package/src/lib/skill-execution-identity.js +39 -2
- package/src/lib/skill-loader.js +322 -86
- package/src/lib/skill-mcp.js +43 -1
- package/src/lib/turn-binding-store.js +2 -2
- package/src/repl/agent-repl.js +483 -77
- package/src/repl/bg-dashboard.js +81 -28
- package/src/repl/chat-repl.js +77 -12
- package/src/runtime/agent-core.js +428 -113
- package/src/runtime/agent-runtime.js +26 -3
- package/src/runtime/cleanup-deadline.js +143 -0
- package/src/runtime/headless-runner.js +494 -149
- package/src/runtime/headless-stream.js +504 -75
- package/src/runtime/mcp-config.js +157 -10
- package/src/runtime/mcp-tool-search.js +0 -0
- package/src/runtime/output-backpressure.js +419 -0
- package/src/runtime/pipe-safety.js +56 -21
- package/src/assets/web-panel/assets/ChatBubbleRenderer-BUUyJ5QO.js +0 -1
- package/src/assets/web-panel/assets/MarkdownRenderer-1mNozZ8v.js +0 -1
- package/src/assets/web-panel/assets/MobileProjects-DqnoFPgR.js +0 -1
- package/src/assets/web-panel/assets/Providers-uO-10aP1.js +0 -1
- package/src/assets/web-panel/assets/devWarning-DosMaL-k.js +0 -1
- package/src/assets/web-panel/assets/index-0O5ePnUq.js +0 -1
- package/src/assets/web-panel/assets/index-ChAzPigF.js +0 -1
- package/src/lib/skill-process-broker.js +0 -85
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* JSONL Session Store — append-only session persistence.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import fs, {
|
|
6
6
|
constants as fsConstants,
|
|
7
7
|
existsSync,
|
|
8
8
|
appendFileSync,
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
fstatSync,
|
|
17
17
|
lstatSync,
|
|
18
18
|
readSync,
|
|
19
|
+
writeSync,
|
|
19
20
|
ftruncateSync,
|
|
20
21
|
} from "node:fs";
|
|
21
22
|
import { join, basename, resolve } from "node:path";
|
|
@@ -23,9 +24,16 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
23
24
|
import { TextDecoder } from "node:util";
|
|
24
25
|
import { getHomeDir } from "../lib/paths.js";
|
|
25
26
|
import { ensurePrivateDirectory, ensurePrivateFile } from "../lib/secure-fs.js";
|
|
27
|
+
import {
|
|
28
|
+
isAffectedWindowsZeroDeviceStatRuntime,
|
|
29
|
+
samePathHandleStableFileIdentity,
|
|
30
|
+
withTrustedFileParentSync,
|
|
31
|
+
} from "../lib/secure-file-identity.js";
|
|
26
32
|
import {
|
|
27
33
|
computeEventHash,
|
|
34
|
+
latestChainHash,
|
|
28
35
|
TRANSCRIPT_CHAIN_STATUS,
|
|
36
|
+
verifyTranscriptText,
|
|
29
37
|
} from "./transcript-integrity.js";
|
|
30
38
|
import { withFileLock } from "../lib/with-file-lock.js";
|
|
31
39
|
import {
|
|
@@ -48,11 +56,24 @@ import {
|
|
|
48
56
|
publicSessionMeta,
|
|
49
57
|
readLatestSessionActivity,
|
|
50
58
|
readSessionMeta,
|
|
59
|
+
readSessionTombstoneMarker,
|
|
51
60
|
recordSessionDeleted,
|
|
52
61
|
recordSessionEvent,
|
|
53
62
|
recordSessionActivity,
|
|
54
63
|
replaceSessionMeta,
|
|
64
|
+
normalizeSessionGenerationAuthority,
|
|
65
|
+
SESSION_GENERATION_AUTHORITY_FIELD,
|
|
66
|
+
SESSION_GENERATION_AUTHORITY_SCHEMA,
|
|
67
|
+
SESSION_TOMBSTONE_MARKER_SUFFIX,
|
|
55
68
|
} from "./session-list-index.js";
|
|
69
|
+
import { createSessionPersistenceFailure } from "../lib/session-persistence-failure.js";
|
|
70
|
+
import { withSessionHostWriteAuthority } from "../lib/session-host-lease.js";
|
|
71
|
+
import {
|
|
72
|
+
listSessionAntiRollbackIds,
|
|
73
|
+
publishSessionAntiRollbackAnchor,
|
|
74
|
+
readSessionAntiRollbackAnchor,
|
|
75
|
+
sessionAntiRollbackPredecessorWitness,
|
|
76
|
+
} from "../lib/session-anti-rollback-anchor.js";
|
|
56
77
|
|
|
57
78
|
let securedSessionsDir = null;
|
|
58
79
|
let securedSessionsDirIdentity = null;
|
|
@@ -61,6 +82,7 @@ let securedSessionsDirIdentity = null;
|
|
|
61
82
|
// gate. The hooks are inert unless the dedicated child process opts in; this
|
|
62
83
|
// keeps production behavior and ordinary tests on the exact same append path.
|
|
63
84
|
export const _sessionScaleFaultHooks = Object.seal({
|
|
85
|
+
beforeTranscriptAppend: null,
|
|
64
86
|
afterTranscriptAppend: null,
|
|
65
87
|
afterForkCopy: null,
|
|
66
88
|
afterForkLineage: null,
|
|
@@ -78,6 +100,11 @@ const WS_TURN_INPUT_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
|
78
100
|
const WS_TURN_CLAIM_ID_PATTERN = /^claim-[0-9a-f-]{36}$/;
|
|
79
101
|
const WS_TURN_FAILURE_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
80
102
|
|
|
103
|
+
export {
|
|
104
|
+
SESSION_GENERATION_AUTHORITY_FIELD,
|
|
105
|
+
SESSION_GENERATION_AUTHORITY_SCHEMA,
|
|
106
|
+
};
|
|
107
|
+
|
|
81
108
|
function wsTurnError(message, code) {
|
|
82
109
|
const error = new Error(message);
|
|
83
110
|
error.code = code;
|
|
@@ -510,6 +537,100 @@ export function sessionPath(sessionId) {
|
|
|
510
537
|
return join(getSessionsDir(), `${sessionId}.jsonl`);
|
|
511
538
|
}
|
|
512
539
|
|
|
540
|
+
function physicalTranscriptStateFromStats(stats) {
|
|
541
|
+
const exact = (value) =>
|
|
542
|
+
typeof value === "bigint" ? String(value) : undefined;
|
|
543
|
+
const legacyNumber = (value) => Number(value);
|
|
544
|
+
const timeMs = (name) => {
|
|
545
|
+
const nanoseconds = stats[`${name}Ns`];
|
|
546
|
+
return typeof nanoseconds === "bigint"
|
|
547
|
+
? Number(nanoseconds / 1_000_000n) +
|
|
548
|
+
Number(nanoseconds % 1_000_000n) / 1_000_000
|
|
549
|
+
: Number(stats[`${name}Ms`]);
|
|
550
|
+
};
|
|
551
|
+
return Object.freeze({
|
|
552
|
+
// Keep the rounded legacy fields so metadata produced by older releases
|
|
553
|
+
// remains readable. BigIntStats supplies the exact witness used by new
|
|
554
|
+
// writers; this avoids Node 22.12 Windows fstat/lstat precision drift.
|
|
555
|
+
dev: String(legacyNumber(stats.dev)),
|
|
556
|
+
ino: String(legacyNumber(stats.ino)),
|
|
557
|
+
size: legacyNumber(stats.size),
|
|
558
|
+
mtimeMs: timeMs("mtime"),
|
|
559
|
+
ctimeMs: timeMs("ctime"),
|
|
560
|
+
devExact: exact(stats.dev),
|
|
561
|
+
inoExact: exact(stats.ino),
|
|
562
|
+
sizeExact: exact(stats.size),
|
|
563
|
+
mtimeNs: exact(stats.mtimeNs),
|
|
564
|
+
ctimeNs: exact(stats.ctimeNs),
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function readPhysicalTranscriptStats(filePath) {
|
|
569
|
+
const stats = lstatSync(filePath, { bigint: true });
|
|
570
|
+
if (stats.isSymbolicLink() || !stats.isFile()) {
|
|
571
|
+
const error = new Error("Session transcript path is not a regular file");
|
|
572
|
+
error.code = "SESSION_TRANSCRIPT_IDENTITY_CHANGED";
|
|
573
|
+
error.commitState = "not-committed";
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
576
|
+
return stats;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function readPhysicalTranscriptState(filePath) {
|
|
580
|
+
return physicalTranscriptStateFromStats(
|
|
581
|
+
readPhysicalTranscriptStats(filePath),
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function samePhysicalTranscriptState(left, right, { content = true } = {}) {
|
|
586
|
+
if (!left || !right) return left === right;
|
|
587
|
+
const sameField = (exactField, legacyField, { numeric = false } = {}) => {
|
|
588
|
+
if (left[exactField] != null && right[exactField] != null) {
|
|
589
|
+
return String(left[exactField]) === String(right[exactField]);
|
|
590
|
+
}
|
|
591
|
+
return numeric
|
|
592
|
+
? Number(left[legacyField]) === Number(right[legacyField])
|
|
593
|
+
: String(left[legacyField]) === String(right[legacyField]);
|
|
594
|
+
};
|
|
595
|
+
return (
|
|
596
|
+
sameField("devExact", "dev") &&
|
|
597
|
+
sameField("inoExact", "ino") &&
|
|
598
|
+
(!content ||
|
|
599
|
+
(sameField("sizeExact", "size", { numeric: true }) &&
|
|
600
|
+
sameField("mtimeNs", "mtimeMs", { numeric: true }) &&
|
|
601
|
+
sameField("ctimeNs", "ctimeMs", { numeric: true })))
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function samePhysicalTranscriptContentState(left, right) {
|
|
606
|
+
if (!left || !right) return left === right;
|
|
607
|
+
const sameField = (exactField, legacyField) => {
|
|
608
|
+
if (left[exactField] != null && right[exactField] != null) {
|
|
609
|
+
return String(left[exactField]) === String(right[exactField]);
|
|
610
|
+
}
|
|
611
|
+
return Number(left[legacyField]) === Number(right[legacyField]);
|
|
612
|
+
};
|
|
613
|
+
return (
|
|
614
|
+
sameField("sizeExact", "size") &&
|
|
615
|
+
sameField("mtimeNs", "mtimeMs") &&
|
|
616
|
+
sameField("ctimeNs", "ctimeMs")
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function withTranscriptFileParent(filePath, task) {
|
|
621
|
+
if (!isAffectedWindowsZeroDeviceStatRuntime()) {
|
|
622
|
+
return task({ canonicalPath: filePath, parentDevice: null });
|
|
623
|
+
}
|
|
624
|
+
return withTrustedFileParentSync(fs, filePath, task);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function transcriptIdentityError(commitState, reason) {
|
|
628
|
+
const error = new Error(`Session transcript identity changed: ${reason}`);
|
|
629
|
+
error.code = "SESSION_TRANSCRIPT_IDENTITY_CHANGED";
|
|
630
|
+
error.commitState = commitState;
|
|
631
|
+
return error;
|
|
632
|
+
}
|
|
633
|
+
|
|
513
634
|
export function appendTokenUsage(sessionId, usage) {
|
|
514
635
|
appendEvent(sessionId, "token_usage", usage || {});
|
|
515
636
|
}
|
|
@@ -660,19 +781,26 @@ function appendVerifiedWsAuthorityEventLocked(
|
|
|
660
781
|
const core = { type, timestamp: Date.now(), data };
|
|
661
782
|
const hash = computeEventHash(previousHeadHash, core);
|
|
662
783
|
const event = { ...core, prevHash: previousHeadHash, hash };
|
|
663
|
-
appendFileSync(filePath, `${JSON.stringify(event)}\n`, {
|
|
664
|
-
encoding: "utf8",
|
|
665
|
-
mode: 0o600,
|
|
666
|
-
});
|
|
667
784
|
ensurePrivateFile(filePath);
|
|
668
|
-
|
|
785
|
+
const expectedState = readPhysicalTranscriptState(filePath);
|
|
786
|
+
const transcriptState = appendTranscriptEvent(
|
|
669
787
|
sessionId,
|
|
670
788
|
type,
|
|
671
789
|
event,
|
|
672
790
|
filePath,
|
|
673
|
-
|
|
791
|
+
expectedState,
|
|
792
|
+
);
|
|
674
793
|
try {
|
|
675
|
-
|
|
794
|
+
const nextMeta = recordSessionEvent(
|
|
795
|
+
getSessionsDir(),
|
|
796
|
+
sessionId,
|
|
797
|
+
event,
|
|
798
|
+
hash,
|
|
799
|
+
{
|
|
800
|
+
transcriptState,
|
|
801
|
+
},
|
|
802
|
+
);
|
|
803
|
+
publishSessionAntiRollbackWitness(sessionId, nextMeta, filePath, "live");
|
|
676
804
|
} catch (cause) {
|
|
677
805
|
const error = new Error(
|
|
678
806
|
`Session authority anchor could not be persisted: ${sessionId}`,
|
|
@@ -710,9 +838,17 @@ function appendVerifiedWsAuthorityEventLocked(
|
|
|
710
838
|
return Object.freeze({ hash, event });
|
|
711
839
|
}
|
|
712
840
|
|
|
841
|
+
function withSessionHostWriterLock(sessionId, filePath, task, options) {
|
|
842
|
+
return withFileLock(
|
|
843
|
+
filePath,
|
|
844
|
+
() => withSessionHostWriteAuthority(sessionId, task),
|
|
845
|
+
options,
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
|
|
713
849
|
function withVerifiedWsTurnLock(sessionId, task) {
|
|
714
850
|
const filePath = sessionPath(sessionId);
|
|
715
|
-
return
|
|
851
|
+
return withSessionHostWriterLock(sessionId, filePath, () => task(filePath), {
|
|
716
852
|
failIfUnavailable: true,
|
|
717
853
|
timeoutMs: 30_000,
|
|
718
854
|
retryMs: 1,
|
|
@@ -739,6 +875,153 @@ function encodeEventMessageProvenance(type, data) {
|
|
|
739
875
|
return data;
|
|
740
876
|
}
|
|
741
877
|
|
|
878
|
+
function appendTranscriptEvent(
|
|
879
|
+
sessionId,
|
|
880
|
+
type,
|
|
881
|
+
event,
|
|
882
|
+
filePath,
|
|
883
|
+
expectedState,
|
|
884
|
+
) {
|
|
885
|
+
const payload = { sessionId, type, event, filePath };
|
|
886
|
+
try {
|
|
887
|
+
runSessionScaleFaultHook("beforeTranscriptAppend", payload);
|
|
888
|
+
} catch (cause) {
|
|
889
|
+
throw createSessionPersistenceFailure(cause, {
|
|
890
|
+
sessionId,
|
|
891
|
+
operation: "transcript-append",
|
|
892
|
+
commitState: "not-committed",
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
try {
|
|
896
|
+
return withTranscriptFileParent(
|
|
897
|
+
filePath,
|
|
898
|
+
({ canonicalPath, parentDevice }) =>
|
|
899
|
+
appendTranscriptEventAtPath({
|
|
900
|
+
sessionId,
|
|
901
|
+
event,
|
|
902
|
+
payload,
|
|
903
|
+
filePath: canonicalPath,
|
|
904
|
+
expectedState,
|
|
905
|
+
parentDevice,
|
|
906
|
+
}),
|
|
907
|
+
);
|
|
908
|
+
} catch (cause) {
|
|
909
|
+
if (
|
|
910
|
+
cause?.code === "SESSION_TRANSCRIPT_IDENTITY_CHANGED" ||
|
|
911
|
+
cause?.operation === "transcript-append" ||
|
|
912
|
+
cause?.operation === "transcript-settlement"
|
|
913
|
+
) {
|
|
914
|
+
throw cause;
|
|
915
|
+
}
|
|
916
|
+
throw createSessionPersistenceFailure(cause, {
|
|
917
|
+
sessionId,
|
|
918
|
+
operation: "transcript-settlement",
|
|
919
|
+
commitState: "unknown",
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function appendTranscriptEventAtPath({
|
|
925
|
+
sessionId,
|
|
926
|
+
event,
|
|
927
|
+
payload,
|
|
928
|
+
filePath,
|
|
929
|
+
expectedState,
|
|
930
|
+
parentDevice,
|
|
931
|
+
}) {
|
|
932
|
+
let fd = null;
|
|
933
|
+
let wroteBytes = false;
|
|
934
|
+
let appendCompleted = false;
|
|
935
|
+
try {
|
|
936
|
+
let flags = fsConstants.O_WRONLY | fsConstants.O_APPEND;
|
|
937
|
+
if (expectedState === null) {
|
|
938
|
+
flags |= fsConstants.O_CREAT | fsConstants.O_EXCL;
|
|
939
|
+
}
|
|
940
|
+
if (typeof fsConstants.O_NOFOLLOW === "number") {
|
|
941
|
+
flags |= fsConstants.O_NOFOLLOW;
|
|
942
|
+
}
|
|
943
|
+
fd = openSync(filePath, flags, 0o600);
|
|
944
|
+
const openedStats = fstatSync(fd, { bigint: true });
|
|
945
|
+
const openedState = physicalTranscriptStateFromStats(openedStats);
|
|
946
|
+
if (expectedState !== null) {
|
|
947
|
+
if (parentDevice === null) {
|
|
948
|
+
if (!samePhysicalTranscriptState(expectedState, openedState)) {
|
|
949
|
+
throw transcriptIdentityError(
|
|
950
|
+
"not-committed",
|
|
951
|
+
"the append descriptor does not match the verified path",
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
} else {
|
|
955
|
+
const publishedBeforeStats = readPhysicalTranscriptStats(filePath);
|
|
956
|
+
const publishedBeforeState =
|
|
957
|
+
physicalTranscriptStateFromStats(publishedBeforeStats);
|
|
958
|
+
if (
|
|
959
|
+
!samePhysicalTranscriptState(expectedState, publishedBeforeState) ||
|
|
960
|
+
!samePathHandleStableFileIdentity(
|
|
961
|
+
publishedBeforeStats,
|
|
962
|
+
openedStats,
|
|
963
|
+
parentDevice,
|
|
964
|
+
)
|
|
965
|
+
) {
|
|
966
|
+
throw transcriptIdentityError(
|
|
967
|
+
"not-committed",
|
|
968
|
+
"the append descriptor does not match the verified path",
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
const bytes = Buffer.from(`${JSON.stringify(event)}\n`, "utf8");
|
|
974
|
+
let offset = 0;
|
|
975
|
+
while (offset < bytes.length) {
|
|
976
|
+
const written = writeSync(fd, bytes, offset, bytes.length - offset);
|
|
977
|
+
if (!Number.isSafeInteger(written) || written <= 0) {
|
|
978
|
+
throw new Error("Session transcript append made no forward progress");
|
|
979
|
+
}
|
|
980
|
+
offset += written;
|
|
981
|
+
wroteBytes = true;
|
|
982
|
+
}
|
|
983
|
+
appendCompleted = true;
|
|
984
|
+
runSessionScaleFaultHook("afterTranscriptAppend", payload);
|
|
985
|
+
const descriptorStats = fstatSync(fd, { bigint: true });
|
|
986
|
+
const descriptorState = physicalTranscriptStateFromStats(descriptorStats);
|
|
987
|
+
const pathStats = readPhysicalTranscriptStats(filePath);
|
|
988
|
+
const pathState = physicalTranscriptStateFromStats(pathStats);
|
|
989
|
+
const publishedIdentityMatches =
|
|
990
|
+
parentDevice === null
|
|
991
|
+
? samePhysicalTranscriptState(descriptorState, pathState, {
|
|
992
|
+
content: false,
|
|
993
|
+
})
|
|
994
|
+
: samePathHandleStableFileIdentity(
|
|
995
|
+
pathStats,
|
|
996
|
+
descriptorStats,
|
|
997
|
+
parentDevice,
|
|
998
|
+
);
|
|
999
|
+
if (!publishedIdentityMatches) {
|
|
1000
|
+
throw transcriptIdentityError(
|
|
1001
|
+
"unknown",
|
|
1002
|
+
"the published path no longer names the appended descriptor",
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
return pathState;
|
|
1006
|
+
} catch (cause) {
|
|
1007
|
+
if (cause?.code === "SESSION_TRANSCRIPT_IDENTITY_CHANGED") throw cause;
|
|
1008
|
+
throw createSessionPersistenceFailure(cause, {
|
|
1009
|
+
sessionId,
|
|
1010
|
+
operation: appendCompleted
|
|
1011
|
+
? "transcript-settlement"
|
|
1012
|
+
: "transcript-append",
|
|
1013
|
+
// ENOSPC can follow a short write. EROFS fails before the append can
|
|
1014
|
+
// mutate the target.
|
|
1015
|
+
commitState:
|
|
1016
|
+
!appendCompleted && (cause?.code === "EROFS" || !wroteBytes)
|
|
1017
|
+
? "not-committed"
|
|
1018
|
+
: "unknown",
|
|
1019
|
+
});
|
|
1020
|
+
} finally {
|
|
1021
|
+
if (fd !== null) closeSync(fd);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
742
1025
|
const SESSION_AUTHORITY_RECOVERY_TEXT_LIMITS = Object.freeze({
|
|
743
1026
|
safetyId: 512,
|
|
744
1027
|
safetyIdentity: 512,
|
|
@@ -773,6 +1056,174 @@ function sanitizeSessionAuthorityRecoveryEvidence(value) {
|
|
|
773
1056
|
return Object.keys(evidence).length > 0 ? Object.freeze(evidence) : null;
|
|
774
1057
|
}
|
|
775
1058
|
|
|
1059
|
+
function sessionDeletedError(sessionId) {
|
|
1060
|
+
const error = new Error(`Session was deleted: ${sessionId}`);
|
|
1061
|
+
error.code = "SESSION_DELETED";
|
|
1062
|
+
return error;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function createSessionGenerationAuthority(
|
|
1066
|
+
sessionId,
|
|
1067
|
+
predecessorWitness = null,
|
|
1068
|
+
generationId = `generation-${randomUUID()}`,
|
|
1069
|
+
) {
|
|
1070
|
+
const previous = normalizeSessionGenerationAuthority(
|
|
1071
|
+
predecessorWitness?.generation,
|
|
1072
|
+
);
|
|
1073
|
+
const isReplacement = predecessorWitness !== null;
|
|
1074
|
+
return Object.freeze({
|
|
1075
|
+
schema: SESSION_GENERATION_AUTHORITY_SCHEMA,
|
|
1076
|
+
sessionId,
|
|
1077
|
+
generationId,
|
|
1078
|
+
ordinal:
|
|
1079
|
+
previous?.sessionId === sessionId
|
|
1080
|
+
? previous.ordinal + 1
|
|
1081
|
+
: isReplacement
|
|
1082
|
+
? 2
|
|
1083
|
+
: 1,
|
|
1084
|
+
predecessor: isReplacement
|
|
1085
|
+
? Object.freeze({
|
|
1086
|
+
kind:
|
|
1087
|
+
previous?.sessionId === sessionId
|
|
1088
|
+
? "tombstone"
|
|
1089
|
+
: "legacy-tombstone",
|
|
1090
|
+
generationId:
|
|
1091
|
+
previous?.sessionId === sessionId ? previous.generationId : null,
|
|
1092
|
+
headHash:
|
|
1093
|
+
typeof predecessorWitness?.last_hash === "string" &&
|
|
1094
|
+
/^[0-9a-f]{64}$/.test(predecessorWitness.last_hash)
|
|
1095
|
+
? predecessorWitness.last_hash
|
|
1096
|
+
: null,
|
|
1097
|
+
eventCount: Math.max(0, Number(predecessorWitness?.event_count) || 0),
|
|
1098
|
+
tombstonedAtMs: Number.isSafeInteger(
|
|
1099
|
+
Number(predecessorWitness?.deleted_at_ms),
|
|
1100
|
+
)
|
|
1101
|
+
? Math.max(0, Number(predecessorWitness.deleted_at_ms))
|
|
1102
|
+
: null,
|
|
1103
|
+
})
|
|
1104
|
+
: null,
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function encodeSessionGenerationData(data, authority = null) {
|
|
1109
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
1110
|
+
return authority
|
|
1111
|
+
? { [SESSION_GENERATION_AUTHORITY_FIELD]: authority }
|
|
1112
|
+
: data;
|
|
1113
|
+
}
|
|
1114
|
+
const clean = { ...data };
|
|
1115
|
+
// The namespace generation is store-owned authority. Callers may not forge
|
|
1116
|
+
// it on arbitrary events or choose a replacement predecessor.
|
|
1117
|
+
delete clean[SESSION_GENERATION_AUTHORITY_FIELD];
|
|
1118
|
+
if (authority) clean[SESSION_GENERATION_AUTHORITY_FIELD] = authority;
|
|
1119
|
+
return clean;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function sessionAntiRollbackCandidateFromWitness(witness, status = "live") {
|
|
1123
|
+
if (!witness || typeof witness !== "object") return null;
|
|
1124
|
+
const eventCount = Math.max(0, Number(witness.event_count) || 0);
|
|
1125
|
+
const headHash =
|
|
1126
|
+
typeof witness.last_hash === "string" &&
|
|
1127
|
+
/^[0-9a-f]{64}$/.test(witness.last_hash)
|
|
1128
|
+
? witness.last_hash
|
|
1129
|
+
: null;
|
|
1130
|
+
const generation = normalizeSessionGenerationAuthority(witness.generation);
|
|
1131
|
+
const legacyDeletion =
|
|
1132
|
+
status === "deleted" && generation === null && headHash === null;
|
|
1133
|
+
if (!legacyDeletion && (eventCount === 0) !== (headHash === null))
|
|
1134
|
+
return null;
|
|
1135
|
+
return Object.freeze({
|
|
1136
|
+
status,
|
|
1137
|
+
generation,
|
|
1138
|
+
headHash,
|
|
1139
|
+
eventCount,
|
|
1140
|
+
deletedAtMs:
|
|
1141
|
+
status === "deleted"
|
|
1142
|
+
? Math.max(
|
|
1143
|
+
0,
|
|
1144
|
+
Number(witness.deleted_at_ms || witness.updated_at_ms) || 0,
|
|
1145
|
+
)
|
|
1146
|
+
: null,
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function antiRollbackAnchorMatchesCandidate(anchor, candidate) {
|
|
1151
|
+
return (
|
|
1152
|
+
anchor !== null &&
|
|
1153
|
+
candidate !== null &&
|
|
1154
|
+
anchor.status === candidate.status &&
|
|
1155
|
+
JSON.stringify(anchor.generation) ===
|
|
1156
|
+
JSON.stringify(candidate.generation) &&
|
|
1157
|
+
anchor.headHash === candidate.headHash &&
|
|
1158
|
+
anchor.eventCount === candidate.eventCount &&
|
|
1159
|
+
anchor.deletedAtMs === candidate.deletedAtMs
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function transcriptHashAtEventCount(filePath, eventCount) {
|
|
1164
|
+
if (eventCount === 0) return null;
|
|
1165
|
+
let count = 0;
|
|
1166
|
+
for (const { line } of iterateFileLinesSync(filePath)) {
|
|
1167
|
+
let event;
|
|
1168
|
+
try {
|
|
1169
|
+
event = JSON.parse(line);
|
|
1170
|
+
} catch {
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
if (typeof event?.hash !== "string") continue;
|
|
1174
|
+
count += 1;
|
|
1175
|
+
if (count === eventCount) return event.hash;
|
|
1176
|
+
}
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function publishSessionAntiRollbackWitness(
|
|
1181
|
+
sessionId,
|
|
1182
|
+
witness,
|
|
1183
|
+
filePath,
|
|
1184
|
+
status = "live",
|
|
1185
|
+
) {
|
|
1186
|
+
const candidate = sessionAntiRollbackCandidateFromWitness(witness, status);
|
|
1187
|
+
if (candidate === null) {
|
|
1188
|
+
const error = new Error(
|
|
1189
|
+
`Session anti-rollback candidate is incomplete: ${sessionId}`,
|
|
1190
|
+
);
|
|
1191
|
+
error.code = "CC_SESSION_ANTI_ROLLBACK_UNAVAILABLE";
|
|
1192
|
+
throw error;
|
|
1193
|
+
}
|
|
1194
|
+
return publishSessionAntiRollbackAnchor(sessionId, candidate, {
|
|
1195
|
+
provePrefix: (current) =>
|
|
1196
|
+
current.status === "live" &&
|
|
1197
|
+
existsSync(filePath) &&
|
|
1198
|
+
transcriptHashAtEventCount(filePath, current.eventCount) ===
|
|
1199
|
+
current.headHash,
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function assertSessionAntiRollbackBeforeWrite(
|
|
1204
|
+
sessionId,
|
|
1205
|
+
witness,
|
|
1206
|
+
filePath,
|
|
1207
|
+
status = "live",
|
|
1208
|
+
) {
|
|
1209
|
+
const current = readSessionAntiRollbackAnchor(sessionId);
|
|
1210
|
+
if (current === null && witness === null) return null;
|
|
1211
|
+
const candidate = sessionAntiRollbackCandidateFromWitness(witness, status);
|
|
1212
|
+
if (candidate === null) return current;
|
|
1213
|
+
if (antiRollbackAnchorMatchesCandidate(current, candidate)) return current;
|
|
1214
|
+
|
|
1215
|
+
if (status === "live" && existsSync(filePath)) {
|
|
1216
|
+
const verification = verifyTranscriptFile(filePath);
|
|
1217
|
+
assertLocalVerifiedTranscriptAnchor(sessionId, verification);
|
|
1218
|
+
}
|
|
1219
|
+
return publishSessionAntiRollbackWitness(
|
|
1220
|
+
sessionId,
|
|
1221
|
+
witness,
|
|
1222
|
+
filePath,
|
|
1223
|
+
status,
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
776
1227
|
function appendEventLocked(
|
|
777
1228
|
sessionId,
|
|
778
1229
|
type,
|
|
@@ -780,20 +1231,117 @@ function appendEventLocked(
|
|
|
780
1231
|
{ expectedHeadHash, compareHead = false, requireIndexAnchor = false } = {},
|
|
781
1232
|
) {
|
|
782
1233
|
const filePath = sessionPath(sessionId);
|
|
783
|
-
return
|
|
1234
|
+
return withSessionHostWriterLock(
|
|
1235
|
+
sessionId,
|
|
784
1236
|
filePath,
|
|
785
1237
|
() => {
|
|
786
|
-
|
|
1238
|
+
let existingMeta = readSessionMeta(getSessionsDir(), sessionId);
|
|
1239
|
+
const marker = readSessionTombstoneMarker(getSessionsDir(), sessionId);
|
|
1240
|
+
const externalAnchor = readSessionAntiRollbackAnchor(sessionId);
|
|
1241
|
+
const presence = getSessionPresence(sessionId);
|
|
1242
|
+
const transcriptExists = existsSync(filePath);
|
|
1243
|
+
const startsNewGeneration =
|
|
1244
|
+
presence === SESSION_PRESENCE.TOMBSTONED && type === "session_start";
|
|
1245
|
+
if (presence === SESSION_PRESENCE.CONFLICT) {
|
|
1246
|
+
if (marker !== null || existingMeta?.deleted === true) {
|
|
1247
|
+
throw sessionDeletedError(sessionId);
|
|
1248
|
+
}
|
|
1249
|
+
throw unverifiedTranscriptError(
|
|
1250
|
+
sessionId,
|
|
1251
|
+
transcriptAnchorMismatchVerification(),
|
|
1252
|
+
);
|
|
1253
|
+
}
|
|
787
1254
|
if (
|
|
788
|
-
|
|
789
|
-
existingMeta?.deleted === true &&
|
|
1255
|
+
presence === SESSION_PRESENCE.TOMBSTONED &&
|
|
790
1256
|
type !== "session_start"
|
|
791
1257
|
) {
|
|
792
|
-
|
|
793
|
-
error.code = "SESSION_DELETED";
|
|
794
|
-
throw error;
|
|
1258
|
+
throw sessionDeletedError(sessionId);
|
|
795
1259
|
}
|
|
1260
|
+
if (
|
|
1261
|
+
presence === SESSION_PRESENCE.MISSING_TRANSCRIPT ||
|
|
1262
|
+
(!transcriptExists &&
|
|
1263
|
+
existingMeta !== null &&
|
|
1264
|
+
existingMeta?.deleted !== true)
|
|
1265
|
+
) {
|
|
1266
|
+
throw unverifiedTranscriptError(
|
|
1267
|
+
sessionId,
|
|
1268
|
+
missingTranscriptVerification(),
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
const duplicateLiveStart =
|
|
1272
|
+
presence === SESSION_PRESENCE.PRESENT && type === "session_start";
|
|
796
1273
|
const { prevHash, recovery } = _resolveChainTail(filePath);
|
|
1274
|
+
let expectedTranscriptState = transcriptExists
|
|
1275
|
+
? readPhysicalTranscriptState(filePath)
|
|
1276
|
+
: null;
|
|
1277
|
+
if (
|
|
1278
|
+
transcriptExists &&
|
|
1279
|
+
existingMeta?.transcript &&
|
|
1280
|
+
(!samePhysicalTranscriptState(
|
|
1281
|
+
existingMeta.transcript,
|
|
1282
|
+
expectedTranscriptState,
|
|
1283
|
+
{ content: recovery === null },
|
|
1284
|
+
) ||
|
|
1285
|
+
Number(existingMeta.transcript.size) !==
|
|
1286
|
+
Number(expectedTranscriptState.size))
|
|
1287
|
+
) {
|
|
1288
|
+
throw transcriptIdentityError(
|
|
1289
|
+
"not-committed",
|
|
1290
|
+
"the transcript no longer matches its persisted physical witness",
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
if (transcriptExists) {
|
|
1294
|
+
ensurePrivateFile(filePath);
|
|
1295
|
+
expectedTranscriptState = readPhysicalTranscriptState(filePath);
|
|
1296
|
+
}
|
|
1297
|
+
if (
|
|
1298
|
+
transcriptExists &&
|
|
1299
|
+
existingMeta !== null &&
|
|
1300
|
+
existingMeta?.deleted !== true &&
|
|
1301
|
+
(existingMeta.last_hash ?? null) !== prevHash
|
|
1302
|
+
) {
|
|
1303
|
+
throw unverifiedTranscriptError(
|
|
1304
|
+
sessionId,
|
|
1305
|
+
transcriptAnchorMismatchVerification(prevHash),
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
if (transcriptExists && existingMeta === null) {
|
|
1309
|
+
const verification = verifyTranscriptFile(filePath);
|
|
1310
|
+
if (
|
|
1311
|
+
verification.status === TRANSCRIPT_CHAIN_STATUS.TAMPERED ||
|
|
1312
|
+
verification.malformedLines > 0 ||
|
|
1313
|
+
verification.truncatedTail
|
|
1314
|
+
) {
|
|
1315
|
+
throw unverifiedTranscriptError(sessionId, verification);
|
|
1316
|
+
}
|
|
1317
|
+
// A sidecar is rebuildable, but appending before rebuilding it would
|
|
1318
|
+
// publish a count of one for a multi-event transcript. Restore the
|
|
1319
|
+
// projection under the same writer lock before advancing the chain.
|
|
1320
|
+
rebuildSessionMetaUnlocked(getSessionsDir(), sessionId, filePath);
|
|
1321
|
+
existingMeta = readSessionMeta(getSessionsDir(), sessionId);
|
|
1322
|
+
}
|
|
1323
|
+
if (transcriptExists) {
|
|
1324
|
+
assertSessionAntiRollbackBeforeWrite(
|
|
1325
|
+
sessionId,
|
|
1326
|
+
existingMeta,
|
|
1327
|
+
filePath,
|
|
1328
|
+
"live",
|
|
1329
|
+
);
|
|
1330
|
+
} else if (presence === SESSION_PRESENCE.TOMBSTONED) {
|
|
1331
|
+
assertSessionAntiRollbackBeforeWrite(
|
|
1332
|
+
sessionId,
|
|
1333
|
+
existingMeta ||
|
|
1334
|
+
marker ||
|
|
1335
|
+
sessionAntiRollbackPredecessorWitness(externalAnchor),
|
|
1336
|
+
filePath,
|
|
1337
|
+
"deleted",
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
if (duplicateLiveStart) {
|
|
1341
|
+
const error = new Error(`Session already exists: ${sessionId}`);
|
|
1342
|
+
error.code = "SESSION_ALREADY_EXISTS";
|
|
1343
|
+
throw error;
|
|
1344
|
+
}
|
|
797
1345
|
if (compareHead && prevHash !== (expectedHeadHash || null)) {
|
|
798
1346
|
const error = new Error(
|
|
799
1347
|
`Session revision changed for ${sessionId}; refresh the checkpoint timeline`,
|
|
@@ -803,33 +1351,60 @@ function appendEventLocked(
|
|
|
803
1351
|
error.actualHeadHash = prevHash;
|
|
804
1352
|
throw error;
|
|
805
1353
|
}
|
|
806
|
-
const
|
|
1354
|
+
const generationAuthority =
|
|
1355
|
+
type === "session_start" &&
|
|
1356
|
+
[SESSION_PRESENCE.ABSENT, SESSION_PRESENCE.TOMBSTONED].includes(
|
|
1357
|
+
presence,
|
|
1358
|
+
)
|
|
1359
|
+
? createSessionGenerationAuthority(
|
|
1360
|
+
sessionId,
|
|
1361
|
+
startsNewGeneration
|
|
1362
|
+
? existingMeta ||
|
|
1363
|
+
marker ||
|
|
1364
|
+
sessionAntiRollbackPredecessorWitness(externalAnchor)
|
|
1365
|
+
: null,
|
|
1366
|
+
)
|
|
1367
|
+
: null;
|
|
1368
|
+
const persistedData = encodeSessionGenerationData(
|
|
1369
|
+
encodeEventMessageProvenance(type, data),
|
|
1370
|
+
generationAuthority,
|
|
1371
|
+
);
|
|
807
1372
|
const core = { type, timestamp: Date.now(), data: persistedData };
|
|
808
1373
|
const hash = computeEventHash(prevHash, core);
|
|
809
1374
|
const event = { ...core, prevHash, hash };
|
|
810
|
-
const
|
|
811
|
-
appendFileSync(filePath, line, { encoding: "utf8", mode: 0o600 });
|
|
812
|
-
ensurePrivateFile(filePath);
|
|
813
|
-
runSessionScaleFaultHook("afterTranscriptAppend", {
|
|
1375
|
+
const transcriptState = appendTranscriptEvent(
|
|
814
1376
|
sessionId,
|
|
815
1377
|
type,
|
|
816
1378
|
event,
|
|
817
1379
|
filePath,
|
|
818
|
-
|
|
1380
|
+
expectedTranscriptState,
|
|
1381
|
+
);
|
|
819
1382
|
try {
|
|
820
|
-
|
|
1383
|
+
const nextMeta = recordSessionEvent(
|
|
1384
|
+
getSessionsDir(),
|
|
1385
|
+
sessionId,
|
|
1386
|
+
event,
|
|
1387
|
+
hash,
|
|
1388
|
+
{
|
|
1389
|
+
resetGeneration: startsNewGeneration,
|
|
1390
|
+
transcriptState,
|
|
1391
|
+
},
|
|
1392
|
+
);
|
|
1393
|
+
publishSessionAntiRollbackWitness(
|
|
1394
|
+
sessionId,
|
|
1395
|
+
nextMeta,
|
|
1396
|
+
filePath,
|
|
1397
|
+
"live",
|
|
1398
|
+
);
|
|
821
1399
|
} catch (cause) {
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
}
|
|
831
|
-
// The metadata index is explicitly rebuildable. Never turn a committed
|
|
832
|
-
// transcript event into an apparent failure that a caller may retry.
|
|
1400
|
+
const error = new Error(
|
|
1401
|
+
`Session authority anchor could not be persisted: ${sessionId}`,
|
|
1402
|
+
{ cause },
|
|
1403
|
+
);
|
|
1404
|
+
error.code = "SESSION_INDEX_ANCHOR_FAILED";
|
|
1405
|
+
error.sessionId = sessionId;
|
|
1406
|
+
error.commitState = "unknown";
|
|
1407
|
+
throw error;
|
|
833
1408
|
}
|
|
834
1409
|
if (requireIndexAnchor) {
|
|
835
1410
|
const verification = verifyTranscriptFile(filePath);
|
|
@@ -848,10 +1423,11 @@ function appendEventLocked(
|
|
|
848
1423
|
error.code = "SESSION_INDEX_ANCHOR_FAILED";
|
|
849
1424
|
error.sessionId = sessionId;
|
|
850
1425
|
error.verification = verification;
|
|
1426
|
+
error.commitState = "unknown";
|
|
851
1427
|
throw error;
|
|
852
1428
|
}
|
|
853
1429
|
}
|
|
854
|
-
return { hash, recovery };
|
|
1430
|
+
return { hash, recovery, commitState: "committed" };
|
|
855
1431
|
},
|
|
856
1432
|
{
|
|
857
1433
|
failIfUnavailable: true,
|
|
@@ -864,8 +1440,24 @@ function appendEventLocked(
|
|
|
864
1440
|
);
|
|
865
1441
|
}
|
|
866
1442
|
|
|
1443
|
+
function runClassifiedAppend(sessionId, operation, append) {
|
|
1444
|
+
try {
|
|
1445
|
+
return append();
|
|
1446
|
+
} catch (cause) {
|
|
1447
|
+
throw createSessionPersistenceFailure(cause, {
|
|
1448
|
+
sessionId,
|
|
1449
|
+
operation,
|
|
1450
|
+
// Preserve an inner append/settlement classification. Without one,
|
|
1451
|
+
// EROFS defaults to not-committed and ENOSPC remains unknown.
|
|
1452
|
+
commitState: cause?.commitState,
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
867
1457
|
export function appendEvent(sessionId, type, data) {
|
|
868
|
-
return
|
|
1458
|
+
return runClassifiedAppend(sessionId, "append-event", () =>
|
|
1459
|
+
appendEventLocked(sessionId, type, data),
|
|
1460
|
+
);
|
|
869
1461
|
}
|
|
870
1462
|
|
|
871
1463
|
/**
|
|
@@ -873,9 +1465,11 @@ export function appendEvent(sessionId, type, data) {
|
|
|
873
1465
|
* crash/tail-truncation anchor are durably updated and still agree.
|
|
874
1466
|
*/
|
|
875
1467
|
export function appendAuthorityEvent(sessionId, type, data) {
|
|
876
|
-
return
|
|
877
|
-
|
|
878
|
-
|
|
1468
|
+
return runClassifiedAppend(sessionId, "append-authority-event", () =>
|
|
1469
|
+
appendEventLocked(sessionId, type, data, {
|
|
1470
|
+
requireIndexAnchor: true,
|
|
1471
|
+
}),
|
|
1472
|
+
);
|
|
879
1473
|
}
|
|
880
1474
|
|
|
881
1475
|
/**
|
|
@@ -889,10 +1483,12 @@ export function appendEventIfHead(
|
|
|
889
1483
|
data,
|
|
890
1484
|
expectedHeadHash = null,
|
|
891
1485
|
) {
|
|
892
|
-
return
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1486
|
+
return runClassifiedAppend(sessionId, "compare-and-append-event", () =>
|
|
1487
|
+
appendEventLocked(sessionId, type, data, {
|
|
1488
|
+
expectedHeadHash,
|
|
1489
|
+
compareHead: true,
|
|
1490
|
+
}),
|
|
1491
|
+
);
|
|
896
1492
|
}
|
|
897
1493
|
|
|
898
1494
|
/** Authority-bearing compare-and-append with a mandatory index anchor. */
|
|
@@ -902,11 +1498,123 @@ export function appendAuthorityEventIfHead(
|
|
|
902
1498
|
data,
|
|
903
1499
|
expectedHeadHash = null,
|
|
904
1500
|
) {
|
|
905
|
-
return
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1501
|
+
return runClassifiedAppend(
|
|
1502
|
+
sessionId,
|
|
1503
|
+
"compare-and-append-authority-event",
|
|
1504
|
+
() =>
|
|
1505
|
+
appendEventLocked(sessionId, type, data, {
|
|
1506
|
+
expectedHeadHash,
|
|
1507
|
+
compareHead: true,
|
|
1508
|
+
requireIndexAnchor: true,
|
|
1509
|
+
}),
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Validate a caller-owned projection and append one authority event while the
|
|
1515
|
+
* same transcript writer lock remains held. This closes the read/validate to
|
|
1516
|
+
* append race for authorities (such as MCP host fencing) that are derived from
|
|
1517
|
+
* the complete verified transcript rather than from the head hash alone.
|
|
1518
|
+
*
|
|
1519
|
+
* Both callbacks must be synchronous. The projection remains caller-owned and
|
|
1520
|
+
* only its validation result controls whether the event is appended.
|
|
1521
|
+
*/
|
|
1522
|
+
export function appendAuthorityEventWithVerifiedProjection(
|
|
1523
|
+
sessionId,
|
|
1524
|
+
type,
|
|
1525
|
+
data,
|
|
1526
|
+
{ createProjection, validateProjection } = {},
|
|
1527
|
+
) {
|
|
1528
|
+
if (
|
|
1529
|
+
typeof createProjection !== "function" ||
|
|
1530
|
+
typeof validateProjection !== "function"
|
|
1531
|
+
) {
|
|
1532
|
+
throw new TypeError(
|
|
1533
|
+
"Authority projection append requires projection and validation callbacks",
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
if (
|
|
1537
|
+
createProjection.constructor?.name === "AsyncFunction" ||
|
|
1538
|
+
validateProjection.constructor?.name === "AsyncFunction"
|
|
1539
|
+
) {
|
|
1540
|
+
throw new TypeError(
|
|
1541
|
+
"Authority projection append callbacks must be synchronous",
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
const filePath = sessionPath(sessionId);
|
|
1545
|
+
return withSessionHostWriterLock(
|
|
1546
|
+
sessionId,
|
|
1547
|
+
filePath,
|
|
1548
|
+
() => {
|
|
1549
|
+
const projection = createProjection();
|
|
1550
|
+
if (
|
|
1551
|
+
!projection ||
|
|
1552
|
+
typeof projection.accept !== "function" ||
|
|
1553
|
+
typeof projection.finish !== "function"
|
|
1554
|
+
) {
|
|
1555
|
+
throw new TypeError(
|
|
1556
|
+
"Authority projection must provide accept() and finish()",
|
|
1557
|
+
);
|
|
1558
|
+
}
|
|
1559
|
+
if (!existsSync(filePath)) {
|
|
1560
|
+
throw unverifiedTranscriptError(sessionId, {
|
|
1561
|
+
status: "missing",
|
|
1562
|
+
reason: "authority transcript does not exist",
|
|
1563
|
+
lastHash: null,
|
|
1564
|
+
chainedEvents: 0,
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
_resolveChainTail(filePath);
|
|
1568
|
+
const verification = verifyTranscriptFile(filePath, {
|
|
1569
|
+
onVerifiedEvent: (event) => {
|
|
1570
|
+
const accepted = projection.accept(event);
|
|
1571
|
+
if (accepted && typeof accepted.then === "function") {
|
|
1572
|
+
throw new TypeError(
|
|
1573
|
+
"Authority projection accept() must be synchronous",
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
1576
|
+
},
|
|
1577
|
+
});
|
|
1578
|
+
assertVerifiedTranscriptAnchor(sessionId, verification);
|
|
1579
|
+
const authority = Object.freeze({
|
|
1580
|
+
headHash: verification.lastHash,
|
|
1581
|
+
eventCount: verification.chainedEvents,
|
|
1582
|
+
readMessages: () => rebuildVerifiedMessagesFromFile(filePath),
|
|
1583
|
+
});
|
|
1584
|
+
const projected = projection.finish(authority);
|
|
1585
|
+
if (projected && typeof projected.then === "function") {
|
|
1586
|
+
throw new TypeError(
|
|
1587
|
+
"Authority projection finish() must be synchronous",
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
const validation = validateProjection(projected, authority);
|
|
1591
|
+
if (validation && typeof validation.then === "function") {
|
|
1592
|
+
throw new TypeError(
|
|
1593
|
+
"Authority projection validation must be synchronous",
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
if (validation === false) {
|
|
1597
|
+
throw new Error("Authority projection validation rejected the append");
|
|
1598
|
+
}
|
|
1599
|
+
const persistedData = encodeEventMessageProvenance(type, data);
|
|
1600
|
+
const appended = appendVerifiedWsAuthorityEventLocked(
|
|
1601
|
+
sessionId,
|
|
1602
|
+
filePath,
|
|
1603
|
+
type,
|
|
1604
|
+
persistedData,
|
|
1605
|
+
authority.headHash,
|
|
1606
|
+
);
|
|
1607
|
+
return Object.freeze({ hash: appended.hash });
|
|
1608
|
+
},
|
|
1609
|
+
{
|
|
1610
|
+
failIfUnavailable: true,
|
|
1611
|
+
timeoutMs: 30_000,
|
|
1612
|
+
retryMs: 1,
|
|
1613
|
+
maxRetryMs: 8,
|
|
1614
|
+
retryJitterMs: 4,
|
|
1615
|
+
yieldAfterReleaseMs: 2,
|
|
1616
|
+
},
|
|
1617
|
+
);
|
|
910
1618
|
}
|
|
911
1619
|
|
|
912
1620
|
/**
|
|
@@ -944,16 +1652,18 @@ export function withSessionAuthorityTransaction(
|
|
|
944
1652
|
}
|
|
945
1653
|
|
|
946
1654
|
const filePath = sessionPath(sessionId);
|
|
947
|
-
return
|
|
1655
|
+
return withSessionHostWriterLock(
|
|
1656
|
+
sessionId,
|
|
948
1657
|
filePath,
|
|
949
1658
|
() => {
|
|
950
1659
|
const existingMeta = readSessionMeta(getSessionsDir(), sessionId);
|
|
951
|
-
|
|
1660
|
+
const persistedWitness = existingMeta;
|
|
1661
|
+
if (persistedWitness?.deleted === true) {
|
|
952
1662
|
const error = new Error(`Session was deleted: ${sessionId}`);
|
|
953
1663
|
error.code = "SESSION_DELETED";
|
|
954
1664
|
throw error;
|
|
955
1665
|
}
|
|
956
|
-
if (!existsSync(filePath) &&
|
|
1666
|
+
if (!existsSync(filePath) && persistedWitness !== null) {
|
|
957
1667
|
throw unverifiedTranscriptError(sessionId, {
|
|
958
1668
|
status: "missing",
|
|
959
1669
|
reason: "session sidecar exists without its transcript",
|
|
@@ -1204,8 +1914,19 @@ export function verifySession(sessionId) {
|
|
|
1204
1914
|
if (isUnsafeSessionId(sessionId)) {
|
|
1205
1915
|
return { sessionId, status: "invalid-id", reason: "invalid session id" };
|
|
1206
1916
|
}
|
|
1207
|
-
const filePath = sessionPath(sessionId);
|
|
1917
|
+
const filePath = sessionPath(sessionId);
|
|
1918
|
+
const presence = getSessionPresence(sessionId);
|
|
1919
|
+
if (presence === SESSION_PRESENCE.CONFLICT) {
|
|
1920
|
+
return {
|
|
1921
|
+
sessionId,
|
|
1922
|
+
status: "conflict",
|
|
1923
|
+
reason: "tombstoned session has a restored transcript path",
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1208
1926
|
if (!existsSync(filePath)) {
|
|
1927
|
+
if (presence === SESSION_PRESENCE.MISSING_TRANSCRIPT) {
|
|
1928
|
+
return { sessionId, ...missingTranscriptVerification() };
|
|
1929
|
+
}
|
|
1209
1930
|
return { sessionId, status: "not-found", reason: "session file not found" };
|
|
1210
1931
|
}
|
|
1211
1932
|
return {
|
|
@@ -1217,10 +1938,20 @@ export function verifySession(sessionId) {
|
|
|
1217
1938
|
export function verifyAllSessions(options = {}) {
|
|
1218
1939
|
const dir = getSessionsDir();
|
|
1219
1940
|
if (!existsSync(dir)) return [];
|
|
1220
|
-
|
|
1221
|
-
|
|
1941
|
+
const sessionIds = new Set(listSessionIds());
|
|
1942
|
+
for (const file of readdirSync(dir)) {
|
|
1943
|
+
if (!file.endsWith(".meta.json")) continue;
|
|
1944
|
+
const sessionId = file.slice(0, -".meta.json".length);
|
|
1945
|
+
if (
|
|
1946
|
+
!isUnsafeSessionId(sessionId) &&
|
|
1947
|
+
getSessionPresence(sessionId) === SESSION_PRESENCE.MISSING_TRANSCRIPT
|
|
1948
|
+
) {
|
|
1949
|
+
sessionIds.add(sessionId);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
return [...sessionIds]
|
|
1222
1953
|
.slice(0, options.limit || 1000)
|
|
1223
|
-
.map((
|
|
1954
|
+
.map((sessionId) => verifySession(sessionId));
|
|
1224
1955
|
}
|
|
1225
1956
|
|
|
1226
1957
|
function indexedProjectionMatchesTranscript(meta, verification, validation) {
|
|
@@ -1267,6 +1998,32 @@ export function repairSession(sessionId, options = {}) {
|
|
|
1267
1998
|
filePath,
|
|
1268
1999
|
() => {
|
|
1269
2000
|
const dir = getSessionsDir();
|
|
2001
|
+
if (getSessionPresence(sessionId) === SESSION_PRESENCE.CONFLICT) {
|
|
2002
|
+
return {
|
|
2003
|
+
sessionId,
|
|
2004
|
+
dryRun,
|
|
2005
|
+
changed: false,
|
|
2006
|
+
physicalChanged: false,
|
|
2007
|
+
indexChanged: false,
|
|
2008
|
+
wouldChange: false,
|
|
2009
|
+
action: "none",
|
|
2010
|
+
physicalAction: "none",
|
|
2011
|
+
indexAction: "none",
|
|
2012
|
+
indexRepairRequired: false,
|
|
2013
|
+
indexRebuilt: false,
|
|
2014
|
+
indexRepairError: null,
|
|
2015
|
+
discardedBytes: 0,
|
|
2016
|
+
discardedRecords: 0,
|
|
2017
|
+
healthy: false,
|
|
2018
|
+
transcriptHealthy: false,
|
|
2019
|
+
status: "conflict",
|
|
2020
|
+
before: null,
|
|
2021
|
+
after: null,
|
|
2022
|
+
beforeValidation: null,
|
|
2023
|
+
afterValidation: null,
|
|
2024
|
+
reason: "tombstoned session has a restored transcript path",
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
1270
2027
|
const before = verifyTranscriptFile(filePath);
|
|
1271
2028
|
const beforeValidation = validateJsonlSession(sessionId);
|
|
1272
2029
|
const repair = inspectPhysicalTail(filePath, { dryRun });
|
|
@@ -1363,18 +2120,77 @@ export function listSessionIds() {
|
|
|
1363
2120
|
.map((file) => basename(file, ".jsonl"));
|
|
1364
2121
|
}
|
|
1365
2122
|
|
|
1366
|
-
/**
|
|
1367
|
-
|
|
2123
|
+
/**
|
|
2124
|
+
* Enumerate every durable id occupying the canonical JSONL namespace.
|
|
2125
|
+
*
|
|
2126
|
+
* A transcript is not the only authority witness: a live/missing sidecar and
|
|
2127
|
+
* a tombstone marker must continue to fence legacy stores even when the JSONL
|
|
2128
|
+
* path itself is absent. Keeping this enumeration content-free also lets
|
|
2129
|
+
* prefix resolution detect ambiguity without opening transcript bodies.
|
|
2130
|
+
*/
|
|
2131
|
+
export function listSessionEvidenceIds() {
|
|
2132
|
+
const dir = getSessionsDir();
|
|
2133
|
+
const ids = new Set(listSessionAntiRollbackIds());
|
|
2134
|
+
if (!existsSync(dir)) return [...ids].sort();
|
|
2135
|
+
for (const file of readdirSync(dir)) {
|
|
2136
|
+
let id = null;
|
|
2137
|
+
if (file.endsWith(".jsonl")) {
|
|
2138
|
+
id = basename(file, ".jsonl");
|
|
2139
|
+
} else if (file.endsWith(".meta.json")) {
|
|
2140
|
+
id = file.slice(0, -".meta.json".length);
|
|
2141
|
+
} else if (file.endsWith(SESSION_TOMBSTONE_MARKER_SUFFIX)) {
|
|
2142
|
+
id = file.slice(0, -SESSION_TOMBSTONE_MARKER_SUFFIX.length);
|
|
2143
|
+
}
|
|
2144
|
+
if (id && !isUnsafeSessionId(id)) ids.add(id);
|
|
2145
|
+
}
|
|
2146
|
+
return [...ids].sort();
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
/**
|
|
2150
|
+
* Resolve an exact id or unambiguous prefix across the complete persisted
|
|
2151
|
+
* namespace, including damaged and tombstoned generations.
|
|
2152
|
+
*
|
|
2153
|
+
* Returning the presence separately from the id prevents callers from using
|
|
2154
|
+
* "not readable" as "not canonical" and then silently consulting a legacy
|
|
2155
|
+
* store. Ambiguity is fail-closed even when only one candidate is currently
|
|
2156
|
+
* readable.
|
|
2157
|
+
*/
|
|
2158
|
+
export function resolveSessionAuthority(input) {
|
|
1368
2159
|
if (isUnsafeSessionId(input)) return null;
|
|
1369
|
-
|
|
1370
|
-
|
|
2160
|
+
const exactPresence = getSessionPresence(input);
|
|
2161
|
+
if (exactPresence !== SESSION_PRESENCE.ABSENT) {
|
|
2162
|
+
return Object.freeze({
|
|
2163
|
+
id: input,
|
|
2164
|
+
match: "exact",
|
|
2165
|
+
presence: exactPresence,
|
|
2166
|
+
readable: exactPresence === SESSION_PRESENCE.PRESENT,
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
const matches = listSessionEvidenceIds()
|
|
2170
|
+
.filter((id) => id.startsWith(input))
|
|
2171
|
+
.map((id) => ({ id, presence: getSessionPresence(id) }))
|
|
2172
|
+
.filter(({ presence }) => presence !== SESSION_PRESENCE.ABSENT);
|
|
1371
2173
|
if (matches.length > 1) {
|
|
1372
2174
|
const error = new Error(`Ambiguous session id prefix: ${input}`);
|
|
1373
2175
|
error.code = "AMBIGUOUS_SESSION_ID";
|
|
1374
|
-
error.matches = matches.slice(0, 20);
|
|
2176
|
+
error.matches = matches.slice(0, 20).map(({ id }) => id);
|
|
2177
|
+
error.authorities = matches.slice(0, 20).map((item) => ({ ...item }));
|
|
1375
2178
|
throw error;
|
|
1376
2179
|
}
|
|
1377
|
-
|
|
2180
|
+
if (matches.length === 0) return null;
|
|
2181
|
+
const [{ id, presence }] = matches;
|
|
2182
|
+
return Object.freeze({
|
|
2183
|
+
id,
|
|
2184
|
+
match: "prefix",
|
|
2185
|
+
presence,
|
|
2186
|
+
readable: presence === SESSION_PRESENCE.PRESENT,
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
/** Resolve an exact id or one unambiguous prefix without reading transcripts. */
|
|
2191
|
+
export function resolveSessionId(input) {
|
|
2192
|
+
const authority = resolveSessionAuthority(input);
|
|
2193
|
+
return authority?.readable ? authority.id : null;
|
|
1378
2194
|
}
|
|
1379
2195
|
|
|
1380
2196
|
export { TRANSCRIPT_CHAIN_STATUS };
|
|
@@ -1394,11 +2210,17 @@ export function startSession(sessionId, meta = {}) {
|
|
|
1394
2210
|
}
|
|
1395
2211
|
|
|
1396
2212
|
export function appendUserMessage(sessionId, content) {
|
|
1397
|
-
appendEvent(sessionId, "user_message", {
|
|
2213
|
+
return appendEvent(sessionId, "user_message", {
|
|
2214
|
+
role: "user",
|
|
2215
|
+
content,
|
|
2216
|
+
});
|
|
1398
2217
|
}
|
|
1399
2218
|
|
|
1400
2219
|
export function appendAssistantMessage(sessionId, content) {
|
|
1401
|
-
appendEvent(sessionId, "assistant_message", {
|
|
2220
|
+
return appendEvent(sessionId, "assistant_message", {
|
|
2221
|
+
role: "assistant",
|
|
2222
|
+
content,
|
|
2223
|
+
});
|
|
1402
2224
|
}
|
|
1403
2225
|
|
|
1404
2226
|
function wsTurnLifecycleResult(snapshot, overrides = {}) {
|
|
@@ -1757,6 +2579,16 @@ export function appendCompactEventIfMessagesMatch(
|
|
|
1757
2579
|
|
|
1758
2580
|
export function readEvents(sessionId) {
|
|
1759
2581
|
if (isUnsafeSessionId(sessionId)) return []; // traversal id → treat as empty
|
|
2582
|
+
const presence = getSessionPresence(sessionId);
|
|
2583
|
+
if (presence === SESSION_PRESENCE.MISSING_TRANSCRIPT) {
|
|
2584
|
+
throw unverifiedTranscriptError(sessionId, missingTranscriptVerification());
|
|
2585
|
+
}
|
|
2586
|
+
if (presence === SESSION_PRESENCE.CONFLICT) {
|
|
2587
|
+
throw unverifiedTranscriptError(
|
|
2588
|
+
sessionId,
|
|
2589
|
+
transcriptAnchorMismatchVerification(),
|
|
2590
|
+
);
|
|
2591
|
+
}
|
|
1760
2592
|
const filePath = sessionPath(sessionId);
|
|
1761
2593
|
if (!existsSync(filePath)) return [];
|
|
1762
2594
|
|
|
@@ -1782,22 +2614,110 @@ function unverifiedTranscriptError(sessionId, verification) {
|
|
|
1782
2614
|
return error;
|
|
1783
2615
|
}
|
|
1784
2616
|
|
|
1785
|
-
function
|
|
2617
|
+
function missingTranscriptVerification() {
|
|
2618
|
+
return Object.freeze({
|
|
2619
|
+
status: "missing",
|
|
2620
|
+
reason: "persisted session witness exists without its transcript",
|
|
2621
|
+
lastHash: null,
|
|
2622
|
+
chainedEvents: 0,
|
|
2623
|
+
malformedLines: 0,
|
|
2624
|
+
truncatedTail: false,
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
function transcriptAnchorMismatchVerification(lastHash = null) {
|
|
2629
|
+
return Object.freeze({
|
|
2630
|
+
status: "anchor-mismatch",
|
|
2631
|
+
reason: "persisted session witness does not match its transcript",
|
|
2632
|
+
lastHash,
|
|
2633
|
+
chainedEvents: 0,
|
|
2634
|
+
malformedLines: 0,
|
|
2635
|
+
truncatedTail: false,
|
|
2636
|
+
});
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
function readSessionPersistenceWitness(sessionId) {
|
|
2640
|
+
return readSessionMeta(getSessionsDir(), sessionId);
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
function hasLiveSessionWitness(sessionId) {
|
|
2644
|
+
const witness = readSessionPersistenceWitness(sessionId);
|
|
2645
|
+
if (witness !== null && witness?.deleted !== true) return true;
|
|
2646
|
+
return readSessionAntiRollbackAnchor(sessionId)?.status === "live";
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
function assertLocalVerifiedTranscriptAnchor(
|
|
2650
|
+
sessionId,
|
|
2651
|
+
verification,
|
|
2652
|
+
physicalState = null,
|
|
2653
|
+
) {
|
|
1786
2654
|
const meta = readSessionMeta(getSessionsDir(), sessionId);
|
|
2655
|
+
let physicalAnchorMatches = true;
|
|
2656
|
+
let currentPhysicalState = physicalState;
|
|
2657
|
+
if (meta?.transcript) {
|
|
2658
|
+
try {
|
|
2659
|
+
currentPhysicalState ||= readPhysicalTranscriptState(
|
|
2660
|
+
sessionPath(sessionId),
|
|
2661
|
+
);
|
|
2662
|
+
physicalAnchorMatches = samePhysicalTranscriptState(
|
|
2663
|
+
meta.transcript,
|
|
2664
|
+
currentPhysicalState,
|
|
2665
|
+
);
|
|
2666
|
+
} catch {
|
|
2667
|
+
physicalAnchorMatches = false;
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
1787
2670
|
const trustedStatus =
|
|
1788
2671
|
verification.status === TRANSCRIPT_CHAIN_STATUS.VERIFIED;
|
|
2672
|
+
const headMatches = meta?.last_hash === verification.lastHash;
|
|
2673
|
+
const eventCountMatches =
|
|
2674
|
+
Number(meta?.event_count) === verification.chainedEvents;
|
|
1789
2675
|
const anchoredHead =
|
|
1790
2676
|
meta?.deleted !== true &&
|
|
1791
|
-
|
|
1792
|
-
|
|
2677
|
+
headMatches &&
|
|
2678
|
+
eventCountMatches &&
|
|
2679
|
+
physicalAnchorMatches;
|
|
1793
2680
|
if (
|
|
1794
2681
|
!trustedStatus ||
|
|
1795
2682
|
!anchoredHead ||
|
|
1796
2683
|
verification.malformedLines > 0 ||
|
|
1797
2684
|
verification.truncatedTail
|
|
1798
2685
|
) {
|
|
1799
|
-
|
|
2686
|
+
const error = unverifiedTranscriptError(sessionId, verification);
|
|
2687
|
+
error.anchor = Object.freeze({
|
|
2688
|
+
metadataPresent: meta !== null,
|
|
2689
|
+
metadataDeleted: meta?.deleted === true,
|
|
2690
|
+
headMatches,
|
|
2691
|
+
eventCountMatches,
|
|
2692
|
+
physicalAnchorMatches,
|
|
2693
|
+
metadataHeadHash: meta?.last_hash ?? null,
|
|
2694
|
+
metadataEventCount: Number.isSafeInteger(Number(meta?.event_count))
|
|
2695
|
+
? Number(meta.event_count)
|
|
2696
|
+
: null,
|
|
2697
|
+
metadataTranscript: meta?.transcript ?? null,
|
|
2698
|
+
currentTranscript: currentPhysicalState ?? null,
|
|
2699
|
+
});
|
|
2700
|
+
throw error;
|
|
1800
2701
|
}
|
|
2702
|
+
return meta;
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
function assertVerifiedTranscriptAnchor(
|
|
2706
|
+
sessionId,
|
|
2707
|
+
verification,
|
|
2708
|
+
physicalState = null,
|
|
2709
|
+
) {
|
|
2710
|
+
const meta = assertLocalVerifiedTranscriptAnchor(
|
|
2711
|
+
sessionId,
|
|
2712
|
+
verification,
|
|
2713
|
+
physicalState,
|
|
2714
|
+
);
|
|
2715
|
+
publishSessionAntiRollbackWitness(
|
|
2716
|
+
sessionId,
|
|
2717
|
+
meta,
|
|
2718
|
+
sessionPath(sessionId),
|
|
2719
|
+
"live",
|
|
2720
|
+
);
|
|
1801
2721
|
}
|
|
1802
2722
|
|
|
1803
2723
|
/**
|
|
@@ -1812,8 +2732,8 @@ function assertVerifiedTranscriptAnchor(sessionId, verification) {
|
|
|
1812
2732
|
*
|
|
1813
2733
|
* This removes the mandatory all-event array from resume projections. It does
|
|
1814
2734
|
* not make verification IO sublinear: authenticating a plain chained JSONL
|
|
1815
|
-
* transcript remains O(N)
|
|
1816
|
-
*
|
|
2735
|
+
* transcript remains O(N). The local sidecar is checked against an independent
|
|
2736
|
+
* machine-local anti-rollback witness; it is not itself that witness.
|
|
1817
2737
|
*/
|
|
1818
2738
|
export function readVerifiedProjection(
|
|
1819
2739
|
sessionId,
|
|
@@ -1847,6 +2767,16 @@ export function readVerifiedProjection(
|
|
|
1847
2767
|
);
|
|
1848
2768
|
}
|
|
1849
2769
|
if (!existsSync(filePath)) {
|
|
2770
|
+
const presence = getSessionPresence(sessionId);
|
|
2771
|
+
if (presence === SESSION_PRESENCE.TOMBSTONED) {
|
|
2772
|
+
throw sessionDeletedError(sessionId);
|
|
2773
|
+
}
|
|
2774
|
+
if (presence === SESSION_PRESENCE.MISSING_TRANSCRIPT) {
|
|
2775
|
+
throw unverifiedTranscriptError(
|
|
2776
|
+
sessionId,
|
|
2777
|
+
missingTranscriptVerification(),
|
|
2778
|
+
);
|
|
2779
|
+
}
|
|
1850
2780
|
return projection.finish(
|
|
1851
2781
|
Object.freeze({
|
|
1852
2782
|
headHash: null,
|
|
@@ -1901,6 +2831,112 @@ export function readVerifiedEvents(sessionId) {
|
|
|
1901
2831
|
});
|
|
1902
2832
|
}
|
|
1903
2833
|
|
|
2834
|
+
/**
|
|
2835
|
+
* Return the exact JSONL bytes only after the transcript and its persisted
|
|
2836
|
+
* anchor verify under the canonical writer lock. This is the export/mirror
|
|
2837
|
+
* boundary; derived consumers must never copy a merely parseable raw path.
|
|
2838
|
+
*/
|
|
2839
|
+
export function readVerifiedTranscriptBytes(sessionId) {
|
|
2840
|
+
if (isUnsafeSessionId(sessionId)) {
|
|
2841
|
+
throw unverifiedTranscriptError(
|
|
2842
|
+
sessionId,
|
|
2843
|
+
transcriptAnchorMismatchVerification(),
|
|
2844
|
+
);
|
|
2845
|
+
}
|
|
2846
|
+
const filePath = sessionPath(sessionId);
|
|
2847
|
+
return withFileLock(
|
|
2848
|
+
filePath,
|
|
2849
|
+
() => {
|
|
2850
|
+
const presence = getSessionPresence(sessionId);
|
|
2851
|
+
if (presence === SESSION_PRESENCE.MISSING_TRANSCRIPT) {
|
|
2852
|
+
throw unverifiedTranscriptError(
|
|
2853
|
+
sessionId,
|
|
2854
|
+
missingTranscriptVerification(),
|
|
2855
|
+
);
|
|
2856
|
+
}
|
|
2857
|
+
if (presence !== SESSION_PRESENCE.PRESENT) {
|
|
2858
|
+
throw unverifiedTranscriptError(
|
|
2859
|
+
sessionId,
|
|
2860
|
+
transcriptAnchorMismatchVerification(),
|
|
2861
|
+
);
|
|
2862
|
+
}
|
|
2863
|
+
return withTranscriptFileParent(
|
|
2864
|
+
filePath,
|
|
2865
|
+
({ canonicalPath, parentDevice }) =>
|
|
2866
|
+
readVerifiedTranscriptBytesAtPath(
|
|
2867
|
+
sessionId,
|
|
2868
|
+
canonicalPath,
|
|
2869
|
+
parentDevice,
|
|
2870
|
+
),
|
|
2871
|
+
);
|
|
2872
|
+
},
|
|
2873
|
+
{
|
|
2874
|
+
failIfUnavailable: true,
|
|
2875
|
+
timeoutMs: 30_000,
|
|
2876
|
+
retryMs: 1,
|
|
2877
|
+
maxRetryMs: 8,
|
|
2878
|
+
retryJitterMs: 4,
|
|
2879
|
+
yieldAfterReleaseMs: 2,
|
|
2880
|
+
},
|
|
2881
|
+
);
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
function readVerifiedTranscriptBytesAtPath(sessionId, filePath, parentDevice) {
|
|
2885
|
+
let flags = fsConstants.O_RDONLY;
|
|
2886
|
+
if (typeof fsConstants.O_NOFOLLOW === "number") {
|
|
2887
|
+
flags |= fsConstants.O_NOFOLLOW;
|
|
2888
|
+
}
|
|
2889
|
+
const fd = openSync(filePath, flags);
|
|
2890
|
+
try {
|
|
2891
|
+
const beforeStats = fstatSync(fd, { bigint: true });
|
|
2892
|
+
const before = physicalTranscriptStateFromStats(beforeStats);
|
|
2893
|
+
const publishedBeforeStats = readPhysicalTranscriptStats(filePath);
|
|
2894
|
+
const publishedBefore =
|
|
2895
|
+
physicalTranscriptStateFromStats(publishedBeforeStats);
|
|
2896
|
+
const descriptorMatchesPublished =
|
|
2897
|
+
parentDevice === null
|
|
2898
|
+
? samePhysicalTranscriptState(before, publishedBefore)
|
|
2899
|
+
: samePathHandleStableFileIdentity(
|
|
2900
|
+
publishedBeforeStats,
|
|
2901
|
+
beforeStats,
|
|
2902
|
+
parentDevice,
|
|
2903
|
+
) && samePhysicalTranscriptContentState(before, publishedBefore);
|
|
2904
|
+
if (!descriptorMatchesPublished) {
|
|
2905
|
+
throw transcriptIdentityError(
|
|
2906
|
+
"not-committed",
|
|
2907
|
+
"the read descriptor does not match the canonical path",
|
|
2908
|
+
);
|
|
2909
|
+
}
|
|
2910
|
+
const text = readFileSync(fd, "utf8");
|
|
2911
|
+
const afterStats = fstatSync(fd, { bigint: true });
|
|
2912
|
+
const after = physicalTranscriptStateFromStats(afterStats);
|
|
2913
|
+
const publishedStats = readPhysicalTranscriptStats(filePath);
|
|
2914
|
+
const published = physicalTranscriptStateFromStats(publishedStats);
|
|
2915
|
+
const publishedStillMatches =
|
|
2916
|
+
parentDevice === null
|
|
2917
|
+
? samePhysicalTranscriptState(after, published)
|
|
2918
|
+
: samePathHandleStableFileIdentity(
|
|
2919
|
+
publishedStats,
|
|
2920
|
+
afterStats,
|
|
2921
|
+
parentDevice,
|
|
2922
|
+
) && samePhysicalTranscriptContentState(after, published);
|
|
2923
|
+
if (!samePhysicalTranscriptState(before, after) || !publishedStillMatches) {
|
|
2924
|
+
throw transcriptIdentityError(
|
|
2925
|
+
"unknown",
|
|
2926
|
+
"the transcript changed while verified bytes were read",
|
|
2927
|
+
);
|
|
2928
|
+
}
|
|
2929
|
+
const verification = {
|
|
2930
|
+
...verifyTranscriptText(text),
|
|
2931
|
+
lastHash: latestChainHash(text),
|
|
2932
|
+
};
|
|
2933
|
+
assertVerifiedTranscriptAnchor(sessionId, verification, published);
|
|
2934
|
+
return text;
|
|
2935
|
+
} finally {
|
|
2936
|
+
closeSync(fd);
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
|
|
1904
2940
|
/**
|
|
1905
2941
|
* Read the active context only after transcript and sidecar-anchor validation.
|
|
1906
2942
|
* Unlike the compatibility `rebuildMessages()` reader, this is allowed to
|
|
@@ -1956,30 +2992,29 @@ export function readVerifiedWsTurnState(sessionId, requestId, options = {}) {
|
|
|
1956
2992
|
}
|
|
1957
2993
|
|
|
1958
2994
|
/**
|
|
1959
|
-
* Return the newest event matching a type/predicate
|
|
1960
|
-
*
|
|
1961
|
-
*
|
|
2995
|
+
* Return the newest event matching a type/predicate after authenticating the
|
|
2996
|
+
* complete transcript and sidecar anchor. Heap remains bounded to one event;
|
|
2997
|
+
* the forward integrity pass is intentionally O(N).
|
|
1962
2998
|
*/
|
|
1963
2999
|
export function findLatestEvent(sessionId, type, predicate = null) {
|
|
1964
3000
|
if (isUnsafeSessionId(sessionId)) return null;
|
|
1965
|
-
const filePath = sessionPath(sessionId);
|
|
1966
|
-
if (!existsSync(filePath)) return null;
|
|
1967
3001
|
const wanted = Array.isArray(type) ? new Set(type) : null;
|
|
1968
|
-
|
|
1969
|
-
let
|
|
1970
|
-
|
|
1971
|
-
event
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
3002
|
+
return readVerifiedProjection(sessionId, () => {
|
|
3003
|
+
let latest = null;
|
|
3004
|
+
return {
|
|
3005
|
+
accept(event) {
|
|
3006
|
+
const typeMatches = wanted
|
|
3007
|
+
? wanted.has(event?.type)
|
|
3008
|
+
: type == null || event?.type === type;
|
|
3009
|
+
if (!typeMatches) return;
|
|
3010
|
+
if (typeof predicate === "function" && !predicate(event)) return;
|
|
3011
|
+
latest = event;
|
|
3012
|
+
},
|
|
3013
|
+
finish() {
|
|
3014
|
+
return latest;
|
|
3015
|
+
},
|
|
3016
|
+
};
|
|
3017
|
+
});
|
|
1983
3018
|
}
|
|
1984
3019
|
|
|
1985
3020
|
/**
|
|
@@ -2052,6 +3087,16 @@ function rebuildVerifiedMessagesFromFile(filePath, options = {}) {
|
|
|
2052
3087
|
|
|
2053
3088
|
export function rebuildMessages(sessionId, options = {}) {
|
|
2054
3089
|
if (isUnsafeSessionId(sessionId)) return [];
|
|
3090
|
+
const presence = getSessionPresence(sessionId);
|
|
3091
|
+
if (presence === SESSION_PRESENCE.MISSING_TRANSCRIPT) {
|
|
3092
|
+
throw unverifiedTranscriptError(sessionId, missingTranscriptVerification());
|
|
3093
|
+
}
|
|
3094
|
+
if (presence === SESSION_PRESENCE.CONFLICT) {
|
|
3095
|
+
throw unverifiedTranscriptError(
|
|
3096
|
+
sessionId,
|
|
3097
|
+
transcriptAnchorMismatchVerification(),
|
|
3098
|
+
);
|
|
3099
|
+
}
|
|
2055
3100
|
const filePath = sessionPath(sessionId);
|
|
2056
3101
|
if (!existsSync(filePath)) return [];
|
|
2057
3102
|
return rebuildMessagesFromFile(filePath, options);
|
|
@@ -2081,6 +3126,7 @@ function rebuildSessionMetaUnlocked(dir, sessionId, filePath) {
|
|
|
2081
3126
|
// a best-effort projection over all intact events.
|
|
2082
3127
|
}
|
|
2083
3128
|
}
|
|
3129
|
+
meta.transcript = readPhysicalTranscriptState(filePath);
|
|
2084
3130
|
return replaceSessionMeta(dir, meta);
|
|
2085
3131
|
}
|
|
2086
3132
|
|
|
@@ -2090,7 +3136,30 @@ function rebuildSessionMeta(sessionId) {
|
|
|
2090
3136
|
if (!existsSync(filePath)) return null;
|
|
2091
3137
|
return withFileLock(
|
|
2092
3138
|
filePath,
|
|
2093
|
-
() =>
|
|
3139
|
+
() => {
|
|
3140
|
+
const verification = verifyTranscriptFile(filePath);
|
|
3141
|
+
const externalAnchor = readSessionAntiRollbackAnchor(sessionId);
|
|
3142
|
+
if (
|
|
3143
|
+
externalAnchor === null &&
|
|
3144
|
+
[
|
|
3145
|
+
TRANSCRIPT_CHAIN_STATUS.LEGACY,
|
|
3146
|
+
TRANSCRIPT_CHAIN_STATUS.PARTIAL,
|
|
3147
|
+
].includes(verification.status) &&
|
|
3148
|
+
!verification.truncatedTail
|
|
3149
|
+
) {
|
|
3150
|
+
return rebuildSessionMetaUnlocked(dir, sessionId, filePath);
|
|
3151
|
+
}
|
|
3152
|
+
if (
|
|
3153
|
+
verification.status !== TRANSCRIPT_CHAIN_STATUS.VERIFIED ||
|
|
3154
|
+
verification.malformedLines > 0 ||
|
|
3155
|
+
verification.truncatedTail
|
|
3156
|
+
) {
|
|
3157
|
+
throw unverifiedTranscriptError(sessionId, verification);
|
|
3158
|
+
}
|
|
3159
|
+
const meta = rebuildSessionMetaUnlocked(dir, sessionId, filePath);
|
|
3160
|
+
assertVerifiedTranscriptAnchor(sessionId, verification);
|
|
3161
|
+
return meta;
|
|
3162
|
+
},
|
|
2094
3163
|
{
|
|
2095
3164
|
failIfUnavailable: true,
|
|
2096
3165
|
timeoutMs: 30_000,
|
|
@@ -2115,9 +3184,13 @@ export function listJsonlSessions(options = {}) {
|
|
|
2115
3184
|
|
|
2116
3185
|
const limit = options.limit || 20;
|
|
2117
3186
|
const sessionIds = listSessionIds();
|
|
3187
|
+
let indexScan = null;
|
|
2118
3188
|
let indexed = listIndexedSessions(dir, {
|
|
2119
3189
|
limit,
|
|
2120
3190
|
hasSession: (id) => sessionExists(id),
|
|
3191
|
+
onScanComplete: (scan) => {
|
|
3192
|
+
indexScan = scan;
|
|
3193
|
+
},
|
|
2121
3194
|
});
|
|
2122
3195
|
|
|
2123
3196
|
// First use after upgrade (or a lost/corrupt rebuildable index): stream each
|
|
@@ -2126,8 +3199,22 @@ export function listJsonlSessions(options = {}) {
|
|
|
2126
3199
|
if (indexed.length < expected) {
|
|
2127
3200
|
const rebuilt = [];
|
|
2128
3201
|
for (const id of sessionIds) {
|
|
2129
|
-
|
|
2130
|
-
|
|
3202
|
+
// A physical path restored behind a tombstone is a conflict, not a live
|
|
3203
|
+
// list entry. Never re-emit its deleted snapshot as list activity.
|
|
3204
|
+
if (!sessionExists(id)) continue;
|
|
3205
|
+
const meta = readSessionMeta(dir, id) || rebuildSessionMeta(id);
|
|
3206
|
+
if (!meta) continue;
|
|
3207
|
+
const indexedMeta = indexScan?.latestBySessionId?.get(id) || null;
|
|
3208
|
+
if (
|
|
3209
|
+
indexedMeta?.deleted !== true &&
|
|
3210
|
+
indexedMeta?.last_hash === meta.last_hash &&
|
|
3211
|
+
Number(indexedMeta?.updated_at_ms || 0) ===
|
|
3212
|
+
Number(meta.updated_at_ms || 0) &&
|
|
3213
|
+
Number(indexedMeta?.event_count || 0) === Number(meta.event_count || 0)
|
|
3214
|
+
) {
|
|
3215
|
+
continue;
|
|
3216
|
+
}
|
|
3217
|
+
rebuilt.push(meta);
|
|
2131
3218
|
}
|
|
2132
3219
|
// Directory enumeration order is not activity order. Re-append the rebuilt
|
|
2133
3220
|
// snapshots oldest-first so reverse journal reads preserve the transcript
|
|
@@ -2145,6 +3232,58 @@ export function listJsonlSessions(options = {}) {
|
|
|
2145
3232
|
return indexed;
|
|
2146
3233
|
}
|
|
2147
3234
|
|
|
3235
|
+
/**
|
|
3236
|
+
* List canonical namespace occupants for recent-session and picker consumers.
|
|
3237
|
+
* Damaged generations are deliberately represented as blocked rows so a
|
|
3238
|
+
* newer missing/conflicting canonical session cannot disappear and cause an
|
|
3239
|
+
* older legacy conversation to be resumed instead. Tombstones fence duplicate
|
|
3240
|
+
* legacy ids but are not resumable choices unless explicitly requested.
|
|
3241
|
+
*/
|
|
3242
|
+
export function listSessionAuthoritySummaries(options = {}) {
|
|
3243
|
+
const limit = Math.max(1, Number(options.limit) || 20);
|
|
3244
|
+
const evidenceIds = listSessionEvidenceIds();
|
|
3245
|
+
const readable = new Map(
|
|
3246
|
+
listJsonlSessions({ limit: Math.max(limit, evidenceIds.length) }).map(
|
|
3247
|
+
(item) => [item.id, item],
|
|
3248
|
+
),
|
|
3249
|
+
);
|
|
3250
|
+
const rows = [];
|
|
3251
|
+
for (const id of evidenceIds) {
|
|
3252
|
+
const presence = getSessionPresence(id);
|
|
3253
|
+
if (
|
|
3254
|
+
presence === SESSION_PRESENCE.ABSENT ||
|
|
3255
|
+
(presence === SESSION_PRESENCE.TOMBSTONED &&
|
|
3256
|
+
options.includeTombstoned !== true)
|
|
3257
|
+
) {
|
|
3258
|
+
continue;
|
|
3259
|
+
}
|
|
3260
|
+
const item = readable.get(id);
|
|
3261
|
+
const meta = item ? null : readSessionMeta(getSessionsDir(), id);
|
|
3262
|
+
rows.push({
|
|
3263
|
+
...(item ||
|
|
3264
|
+
publicSessionMeta({
|
|
3265
|
+
...emptySessionMeta(id),
|
|
3266
|
+
...(meta || {}),
|
|
3267
|
+
id,
|
|
3268
|
+
})),
|
|
3269
|
+
id,
|
|
3270
|
+
_store: "jsonl",
|
|
3271
|
+
_presence: presence,
|
|
3272
|
+
_blocked: presence !== SESSION_PRESENCE.PRESENT,
|
|
3273
|
+
});
|
|
3274
|
+
}
|
|
3275
|
+
return rows
|
|
3276
|
+
.sort((a, b) => {
|
|
3277
|
+
const delta =
|
|
3278
|
+
(Date.parse(b.updated_at || "") || 0) -
|
|
3279
|
+
(Date.parse(a.updated_at || "") || 0);
|
|
3280
|
+
if (delta !== 0) return delta;
|
|
3281
|
+
if (a._blocked !== b._blocked) return a._blocked ? -1 : 1;
|
|
3282
|
+
return String(b.id).localeCompare(String(a.id));
|
|
3283
|
+
})
|
|
3284
|
+
.slice(0, limit);
|
|
3285
|
+
}
|
|
3286
|
+
|
|
2148
3287
|
/**
|
|
2149
3288
|
* Rename a session (gap-analysis 2026-07-11 P1 "命名会话"): appends a
|
|
2150
3289
|
* `session_rename` event so the hash chain stays intact (no rewrite). The
|
|
@@ -2160,25 +3299,90 @@ export function renameSession(sessionId, title) {
|
|
|
2160
3299
|
return { id: sessionId, title: normalized.slice(0, 200) };
|
|
2161
3300
|
}
|
|
2162
3301
|
|
|
2163
|
-
/** Delete the canonical transcript under its writer lock and publish
|
|
2164
|
-
*
|
|
2165
|
-
*
|
|
3302
|
+
/** Delete the canonical transcript under its writer lock and publish durable
|
|
3303
|
+
* tombstone witnesses so an already-waiting stale writer cannot resurrect the
|
|
3304
|
+
* session after the deletion commits. */
|
|
2166
3305
|
export function deleteJsonlSession(sessionId) {
|
|
2167
3306
|
if (isUnsafeSessionId(sessionId)) return false;
|
|
2168
3307
|
const filePath = sessionPath(sessionId);
|
|
2169
|
-
if (!existsSync(filePath)) return false;
|
|
2170
|
-
return
|
|
3308
|
+
if (!existsSync(filePath) && !hasLiveSessionWitness(sessionId)) return false;
|
|
3309
|
+
return withSessionHostWriterLock(
|
|
3310
|
+
sessionId,
|
|
2171
3311
|
filePath,
|
|
2172
3312
|
() => {
|
|
2173
|
-
|
|
2174
|
-
|
|
3313
|
+
const transcriptExists = existsSync(filePath);
|
|
3314
|
+
let existingMeta = readSessionPersistenceWitness(sessionId);
|
|
3315
|
+
const marker = readSessionTombstoneMarker(getSessionsDir(), sessionId);
|
|
3316
|
+
const externalAnchor = readSessionAntiRollbackAnchor(sessionId);
|
|
3317
|
+
if (
|
|
3318
|
+
!transcriptExists &&
|
|
3319
|
+
(existingMeta === null || existingMeta?.deleted === true)
|
|
3320
|
+
) {
|
|
3321
|
+
return false;
|
|
3322
|
+
}
|
|
3323
|
+
const deletionWitness =
|
|
3324
|
+
existingMeta?.deleted === true
|
|
3325
|
+
? existingMeta
|
|
3326
|
+
: sessionAntiRollbackPredecessorWitness(externalAnchor) || marker;
|
|
3327
|
+
if (
|
|
3328
|
+
transcriptExists &&
|
|
3329
|
+
existingMeta === null &&
|
|
3330
|
+
deletionWitness === null
|
|
3331
|
+
) {
|
|
3332
|
+
const verification = verifyTranscriptFile(filePath);
|
|
3333
|
+
if (
|
|
3334
|
+
verification.status !== TRANSCRIPT_CHAIN_STATUS.VERIFIED ||
|
|
3335
|
+
verification.malformedLines > 0 ||
|
|
3336
|
+
verification.truncatedTail
|
|
3337
|
+
) {
|
|
3338
|
+
throw unverifiedTranscriptError(sessionId, verification);
|
|
3339
|
+
}
|
|
3340
|
+
rebuildSessionMetaUnlocked(getSessionsDir(), sessionId, filePath);
|
|
3341
|
+
existingMeta = readSessionPersistenceWitness(sessionId);
|
|
3342
|
+
}
|
|
3343
|
+
if (deletionWitness === null) {
|
|
3344
|
+
assertSessionAntiRollbackBeforeWrite(
|
|
3345
|
+
sessionId,
|
|
3346
|
+
existingMeta,
|
|
3347
|
+
filePath,
|
|
3348
|
+
"live",
|
|
3349
|
+
);
|
|
3350
|
+
} else if (existingMeta?.deleted !== true) {
|
|
3351
|
+
existingMeta = replaceSessionMeta(getSessionsDir(), {
|
|
3352
|
+
...emptySessionMeta(sessionId),
|
|
3353
|
+
id: sessionId,
|
|
3354
|
+
generation: deletionWitness.generation || null,
|
|
3355
|
+
last_hash: deletionWitness.last_hash || null,
|
|
3356
|
+
event_count: Math.max(0, Number(deletionWitness.event_count) || 0),
|
|
3357
|
+
updated_at_ms: Math.max(
|
|
3358
|
+
0,
|
|
3359
|
+
Number(deletionWitness.deleted_at_ms) || Date.now(),
|
|
3360
|
+
),
|
|
3361
|
+
deleted_at_ms: Math.max(
|
|
3362
|
+
0,
|
|
3363
|
+
Number(deletionWitness.deleted_at_ms) || Date.now(),
|
|
3364
|
+
),
|
|
3365
|
+
deleted: true,
|
|
3366
|
+
transcript: null,
|
|
3367
|
+
});
|
|
3368
|
+
}
|
|
3369
|
+
if (transcriptExists) rmSync(filePath, { force: true });
|
|
2175
3370
|
try {
|
|
2176
|
-
recordSessionDeleted(getSessionsDir(), sessionId);
|
|
3371
|
+
const tombstone = recordSessionDeleted(getSessionsDir(), sessionId);
|
|
3372
|
+
publishSessionAntiRollbackWitness(
|
|
3373
|
+
sessionId,
|
|
3374
|
+
tombstone,
|
|
3375
|
+
filePath,
|
|
3376
|
+
"deleted",
|
|
3377
|
+
);
|
|
2177
3378
|
} catch (error) {
|
|
2178
3379
|
// writeMetaAtomic precedes the activity-journal append. If only the
|
|
2179
3380
|
// rebuildable journal lock/release failed, the durable sidecar still
|
|
2180
3381
|
// prevents a stale writer from resurrecting this deleted session.
|
|
2181
|
-
if (
|
|
3382
|
+
if (
|
|
3383
|
+
readSessionMeta(getSessionsDir(), sessionId)?.deleted !== true ||
|
|
3384
|
+
readSessionAntiRollbackAnchor(sessionId)?.status !== "deleted"
|
|
3385
|
+
) {
|
|
2182
3386
|
throw error;
|
|
2183
3387
|
}
|
|
2184
3388
|
}
|
|
@@ -2481,12 +3685,22 @@ export function forkSession(sourceId, options = {}) {
|
|
|
2481
3685
|
sourceHeadHash: sourceVerification.lastHash,
|
|
2482
3686
|
sourceEventCount: sourceVerification.chainedEvents,
|
|
2483
3687
|
};
|
|
3688
|
+
const forkGenerationId = `generation-${createHash("sha256")
|
|
3689
|
+
.update(`fork-generation\0${newId}`, "utf8")
|
|
3690
|
+
.digest("hex")
|
|
3691
|
+
.slice(0, 32)}`;
|
|
2484
3692
|
const core = {
|
|
2485
3693
|
type: "system",
|
|
2486
3694
|
timestamp: Date.now(),
|
|
2487
3695
|
data: {
|
|
2488
3696
|
...persistedData,
|
|
2489
3697
|
[SESSION_FORK_AUTHORITY_FIELD]: forkAuthority,
|
|
3698
|
+
[SESSION_GENERATION_AUTHORITY_FIELD]:
|
|
3699
|
+
createSessionGenerationAuthority(
|
|
3700
|
+
newId,
|
|
3701
|
+
null,
|
|
3702
|
+
forkGenerationId,
|
|
3703
|
+
),
|
|
2490
3704
|
},
|
|
2491
3705
|
};
|
|
2492
3706
|
const hash = computeEventHash(prevHash, core);
|
|
@@ -2516,6 +3730,17 @@ export function forkSession(sourceId, options = {}) {
|
|
|
2516
3730
|
sourceHeadHash: authority?.sourceHeadHash,
|
|
2517
3731
|
sourceEventCount: authorityEventCount,
|
|
2518
3732
|
};
|
|
3733
|
+
const generationAuthority = normalizeSessionGenerationAuthority(
|
|
3734
|
+
target.lineageEvent?.data?.[SESSION_GENERATION_AUTHORITY_FIELD],
|
|
3735
|
+
);
|
|
3736
|
+
const hasGenerationAuthority =
|
|
3737
|
+
target.lineageEvent?.data?.[SESSION_GENERATION_AUTHORITY_FIELD] !==
|
|
3738
|
+
undefined;
|
|
3739
|
+
const generationMatches =
|
|
3740
|
+
!hasGenerationAuthority ||
|
|
3741
|
+
(generationAuthority?.sessionId === newId &&
|
|
3742
|
+
generationAuthority.ordinal === 1 &&
|
|
3743
|
+
generationAuthority.predecessor === null);
|
|
2519
3744
|
const lineageMatches =
|
|
2520
3745
|
target.matchingLineageEvents === 1 &&
|
|
2521
3746
|
target.lineageEvent?.type === "system" &&
|
|
@@ -2524,10 +3749,16 @@ export function forkSession(sourceId, options = {}) {
|
|
|
2524
3749
|
typeof authority?.sourceHeadHash === "string" &&
|
|
2525
3750
|
target.lineageEventIndex === creationEventCount &&
|
|
2526
3751
|
target.lineageEvent?.prevHash === authority.sourceHeadHash &&
|
|
3752
|
+
generationMatches &&
|
|
2527
3753
|
JSON.stringify(target.lineageEvent?.data) ===
|
|
2528
3754
|
JSON.stringify({
|
|
2529
3755
|
...persistedData,
|
|
2530
3756
|
[SESSION_FORK_AUTHORITY_FIELD]: expectedAuthority,
|
|
3757
|
+
...(hasGenerationAuthority
|
|
3758
|
+
? {
|
|
3759
|
+
[SESSION_GENERATION_AUTHORITY_FIELD]: generationAuthority,
|
|
3760
|
+
}
|
|
3761
|
+
: {}),
|
|
2531
3762
|
});
|
|
2532
3763
|
if (
|
|
2533
3764
|
target.verification.status !== TRANSCRIPT_CHAIN_STATUS.VERIFIED ||
|
|
@@ -2689,7 +3920,7 @@ export function createBranchSession({
|
|
|
2689
3920
|
provider: typeof cleanMeta.provider === "string" ? cleanMeta.provider : "",
|
|
2690
3921
|
model: typeof cleanMeta.model === "string" ? cleanMeta.model : "",
|
|
2691
3922
|
};
|
|
2692
|
-
const
|
|
3923
|
+
const digestEvents = [
|
|
2693
3924
|
{ type: "session_start", data: startData },
|
|
2694
3925
|
{
|
|
2695
3926
|
type: "session_branch",
|
|
@@ -2702,19 +3933,19 @@ export function createBranchSession({
|
|
|
2702
3933
|
let count = 0;
|
|
2703
3934
|
for (const m of canonicalMessages) {
|
|
2704
3935
|
if (m.role === "user") {
|
|
2705
|
-
|
|
3936
|
+
digestEvents.push({
|
|
2706
3937
|
type: "user_message",
|
|
2707
3938
|
data: { role: "user", content: m.content },
|
|
2708
3939
|
});
|
|
2709
3940
|
count += 1;
|
|
2710
3941
|
} else if (m.role === "assistant") {
|
|
2711
|
-
|
|
3942
|
+
digestEvents.push({
|
|
2712
3943
|
type: "assistant_message",
|
|
2713
3944
|
data: { role: "assistant", content: m.content },
|
|
2714
3945
|
});
|
|
2715
3946
|
count += 1;
|
|
2716
3947
|
} else if (m.role === "system") {
|
|
2717
|
-
|
|
3948
|
+
digestEvents.push({
|
|
2718
3949
|
type: "system",
|
|
2719
3950
|
data: encodePersistedMessage(m),
|
|
2720
3951
|
});
|
|
@@ -2724,8 +3955,27 @@ export function createBranchSession({
|
|
|
2724
3955
|
// canonical projection above retains only explicitly durable systems.
|
|
2725
3956
|
}
|
|
2726
3957
|
const inputDigest = `sha256:${createHash("sha256")
|
|
2727
|
-
.update(JSON.stringify(
|
|
3958
|
+
.update(JSON.stringify(digestEvents), "utf8")
|
|
2728
3959
|
.digest("hex")}`;
|
|
3960
|
+
const branchGenerationId = `generation-${createHash("sha256")
|
|
3961
|
+
.update(`branch-generation\0${branchSessionId}`, "utf8")
|
|
3962
|
+
.digest("hex")
|
|
3963
|
+
.slice(0, 32)}`;
|
|
3964
|
+
const plannedEvents = digestEvents.map((event, index) =>
|
|
3965
|
+
index === 0
|
|
3966
|
+
? {
|
|
3967
|
+
...event,
|
|
3968
|
+
data: encodeSessionGenerationData(
|
|
3969
|
+
event.data,
|
|
3970
|
+
createSessionGenerationAuthority(
|
|
3971
|
+
branchSessionId,
|
|
3972
|
+
null,
|
|
3973
|
+
branchGenerationId,
|
|
3974
|
+
),
|
|
3975
|
+
),
|
|
3976
|
+
}
|
|
3977
|
+
: event,
|
|
3978
|
+
);
|
|
2729
3979
|
plannedEvents.push({
|
|
2730
3980
|
type: "session_branch_complete",
|
|
2731
3981
|
data: {
|
|
@@ -2740,6 +3990,23 @@ export function createBranchSession({
|
|
|
2740
3990
|
return withFileLock(
|
|
2741
3991
|
filePath,
|
|
2742
3992
|
() => {
|
|
3993
|
+
const presence = getSessionPresence(branchSessionId);
|
|
3994
|
+
if (
|
|
3995
|
+
presence === SESSION_PRESENCE.TOMBSTONED ||
|
|
3996
|
+
(presence === SESSION_PRESENCE.CONFLICT &&
|
|
3997
|
+
readSessionTombstoneMarker(sessionsDir, branchSessionId) !== null)
|
|
3998
|
+
) {
|
|
3999
|
+
throw sessionDeletedError(branchSessionId);
|
|
4000
|
+
}
|
|
4001
|
+
if (
|
|
4002
|
+
presence === SESSION_PRESENCE.MISSING_TRANSCRIPT ||
|
|
4003
|
+
presence === SESSION_PRESENCE.CONFLICT
|
|
4004
|
+
) {
|
|
4005
|
+
throw unverifiedTranscriptError(
|
|
4006
|
+
branchSessionId,
|
|
4007
|
+
transcriptAnchorMismatchVerification(),
|
|
4008
|
+
);
|
|
4009
|
+
}
|
|
2743
4010
|
if (existsSync(filePath)) inspectPhysicalTail(filePath);
|
|
2744
4011
|
let verification = existsSync(filePath)
|
|
2745
4012
|
? verifyTranscriptFile(filePath)
|
|
@@ -2761,12 +4028,22 @@ export function createBranchSession({
|
|
|
2761
4028
|
: [];
|
|
2762
4029
|
const prefixMatches = existingEvents
|
|
2763
4030
|
.slice(0, Math.min(existingEvents.length, plannedEvents.length))
|
|
2764
|
-
.every(
|
|
2765
|
-
|
|
4031
|
+
.every((event, index) => {
|
|
4032
|
+
let plannedData = plannedEvents[index].data;
|
|
4033
|
+
// Existing v1 branches predate generation authority. They remain
|
|
4034
|
+
// idempotently readable; only newly created branches publish it.
|
|
4035
|
+
if (
|
|
4036
|
+
index === 0 &&
|
|
4037
|
+
event?.data?.[SESSION_GENERATION_AUTHORITY_FIELD] === undefined
|
|
4038
|
+
) {
|
|
4039
|
+
plannedData = { ...plannedData };
|
|
4040
|
+
delete plannedData[SESSION_GENERATION_AUTHORITY_FIELD];
|
|
4041
|
+
}
|
|
4042
|
+
return (
|
|
2766
4043
|
event.type === plannedEvents[index].type &&
|
|
2767
|
-
JSON.stringify(event.data) ===
|
|
2768
|
-
|
|
2769
|
-
);
|
|
4044
|
+
JSON.stringify(event.data) === JSON.stringify(plannedData)
|
|
4045
|
+
);
|
|
4046
|
+
});
|
|
2770
4047
|
if (!prefixMatches) {
|
|
2771
4048
|
const error = new Error(
|
|
2772
4049
|
`Branch session conflicts with its deterministic input: ${branchSessionId}`,
|
|
@@ -2821,6 +4098,9 @@ export function createBranchSession({
|
|
|
2821
4098
|
}
|
|
2822
4099
|
|
|
2823
4100
|
let prevHash = verification.lastHash;
|
|
4101
|
+
let expectedTranscriptState = existsSync(filePath)
|
|
4102
|
+
? readPhysicalTranscriptState(filePath)
|
|
4103
|
+
: null;
|
|
2824
4104
|
for (
|
|
2825
4105
|
let index = existingEvents.length;
|
|
2826
4106
|
index < plannedEvents.length;
|
|
@@ -2834,17 +4114,13 @@ export function createBranchSession({
|
|
|
2834
4114
|
};
|
|
2835
4115
|
const hash = computeEventHash(prevHash, core);
|
|
2836
4116
|
const event = { ...core, prevHash, hash };
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
});
|
|
2841
|
-
ensurePrivateFile(filePath);
|
|
2842
|
-
runSessionScaleFaultHook("afterTranscriptAppend", {
|
|
2843
|
-
sessionId: branchSessionId,
|
|
2844
|
-
type: planned.type,
|
|
4117
|
+
expectedTranscriptState = appendTranscriptEvent(
|
|
4118
|
+
branchSessionId,
|
|
4119
|
+
planned.type,
|
|
2845
4120
|
event,
|
|
2846
4121
|
filePath,
|
|
2847
|
-
|
|
4122
|
+
expectedTranscriptState,
|
|
4123
|
+
);
|
|
2848
4124
|
prevHash = hash;
|
|
2849
4125
|
}
|
|
2850
4126
|
|
|
@@ -2873,14 +4149,148 @@ export function createBranchSession({
|
|
|
2873
4149
|
);
|
|
2874
4150
|
}
|
|
2875
4151
|
|
|
4152
|
+
export const SESSION_PRESENCE = Object.freeze({
|
|
4153
|
+
ABSENT: "absent",
|
|
4154
|
+
PRESENT: "present",
|
|
4155
|
+
MISSING_TRANSCRIPT: "missing-transcript",
|
|
4156
|
+
TOMBSTONED: "tombstoned",
|
|
4157
|
+
CONFLICT: "conflict",
|
|
4158
|
+
});
|
|
4159
|
+
|
|
4160
|
+
export function getSessionPresence(sessionId) {
|
|
4161
|
+
if (isUnsafeSessionId(sessionId)) return SESSION_PRESENCE.ABSENT;
|
|
4162
|
+
const dir = getSessionsDir();
|
|
4163
|
+
const transcriptExists = existsSync(sessionPath(sessionId));
|
|
4164
|
+
const tombstoneMarkerExists = existsSync(
|
|
4165
|
+
join(dir, `${sessionId}${SESSION_TOMBSTONE_MARKER_SUFFIX}`),
|
|
4166
|
+
);
|
|
4167
|
+
const witness = readSessionPersistenceWitness(sessionId);
|
|
4168
|
+
const externalAnchor = readSessionAntiRollbackAnchor(sessionId);
|
|
4169
|
+
if (
|
|
4170
|
+
transcriptExists &&
|
|
4171
|
+
(witness?.deleted === true ||
|
|
4172
|
+
tombstoneMarkerExists ||
|
|
4173
|
+
externalAnchor?.status === "deleted")
|
|
4174
|
+
) {
|
|
4175
|
+
return SESSION_PRESENCE.CONFLICT;
|
|
4176
|
+
}
|
|
4177
|
+
if (transcriptExists) {
|
|
4178
|
+
if (witness !== null && witness?.deleted !== true) {
|
|
4179
|
+
assertSessionAntiRollbackBeforeWrite(
|
|
4180
|
+
sessionId,
|
|
4181
|
+
witness,
|
|
4182
|
+
sessionPath(sessionId),
|
|
4183
|
+
"live",
|
|
4184
|
+
);
|
|
4185
|
+
}
|
|
4186
|
+
return SESSION_PRESENCE.PRESENT;
|
|
4187
|
+
}
|
|
4188
|
+
if (
|
|
4189
|
+
witness?.deleted === true ||
|
|
4190
|
+
tombstoneMarkerExists ||
|
|
4191
|
+
externalAnchor?.status === "deleted"
|
|
4192
|
+
) {
|
|
4193
|
+
return SESSION_PRESENCE.TOMBSTONED;
|
|
4194
|
+
}
|
|
4195
|
+
if (witness !== null || externalAnchor?.status === "live") {
|
|
4196
|
+
return SESSION_PRESENCE.MISSING_TRANSCRIPT;
|
|
4197
|
+
}
|
|
4198
|
+
return SESSION_PRESENCE.ABSENT;
|
|
4199
|
+
}
|
|
4200
|
+
|
|
4201
|
+
/** True when a host must treat this id as canonical instead of falling back. */
|
|
4202
|
+
export function sessionHasPersistedEvidence(sessionId) {
|
|
4203
|
+
return [
|
|
4204
|
+
SESSION_PRESENCE.PRESENT,
|
|
4205
|
+
SESSION_PRESENCE.MISSING_TRANSCRIPT,
|
|
4206
|
+
SESSION_PRESENCE.TOMBSTONED,
|
|
4207
|
+
SESSION_PRESENCE.CONFLICT,
|
|
4208
|
+
].includes(getSessionPresence(sessionId));
|
|
4209
|
+
}
|
|
4210
|
+
|
|
4211
|
+
/** True only for a physical, non-tombstoned transcript path. */
|
|
2876
4212
|
export function sessionExists(sessionId) {
|
|
2877
|
-
|
|
2878
|
-
return existsSync(sessionPath(sessionId));
|
|
4213
|
+
return getSessionPresence(sessionId) === SESSION_PRESENCE.PRESENT;
|
|
2879
4214
|
}
|
|
2880
4215
|
|
|
2881
4216
|
export function getLastSessionId() {
|
|
2882
|
-
|
|
2883
|
-
|
|
4217
|
+
// Seed/rebuild missing indexes for physical transcripts first, then include
|
|
4218
|
+
// a live missing-transcript witness so --continue fails closed on the newest
|
|
4219
|
+
// canonical identity instead of silently selecting an older healthy chat.
|
|
4220
|
+
const dir = getSessionsDir();
|
|
4221
|
+
listJsonlSessions({ limit: 1 });
|
|
4222
|
+
const sessions = listIndexedSessions(dir, {
|
|
4223
|
+
limit: 1,
|
|
4224
|
+
hasSession: (id) =>
|
|
4225
|
+
[
|
|
4226
|
+
SESSION_PRESENCE.PRESENT,
|
|
4227
|
+
SESSION_PRESENCE.MISSING_TRANSCRIPT,
|
|
4228
|
+
SESSION_PRESENCE.CONFLICT,
|
|
4229
|
+
].includes(getSessionPresence(id)),
|
|
4230
|
+
includeDeleted: (id) =>
|
|
4231
|
+
getSessionPresence(id) === SESSION_PRESENCE.CONFLICT,
|
|
4232
|
+
});
|
|
4233
|
+
const candidateRisk = (presence) =>
|
|
4234
|
+
[SESSION_PRESENCE.MISSING_TRANSCRIPT, SESSION_PRESENCE.CONFLICT].includes(
|
|
4235
|
+
presence,
|
|
4236
|
+
)
|
|
4237
|
+
? 1
|
|
4238
|
+
: 0;
|
|
4239
|
+
let latest =
|
|
4240
|
+
sessions.length > 0
|
|
4241
|
+
? {
|
|
4242
|
+
id: sessions[0].id,
|
|
4243
|
+
updatedAt: Date.parse(sessions[0].updated_at) || 0,
|
|
4244
|
+
risk: candidateRisk(getSessionPresence(sessions[0].id)),
|
|
4245
|
+
}
|
|
4246
|
+
: null;
|
|
4247
|
+
|
|
4248
|
+
// A stale/corrupt disposable activity journal cannot rediscover a sidecar-
|
|
4249
|
+
// only missing witness. Directory enumeration already occurs in the list
|
|
4250
|
+
// path; normally read only metadata files that have no transcript
|
|
4251
|
+
// counterpart. Durable tombstone namespace witnesses are enumerable, so
|
|
4252
|
+
// restored transcript conflicts are checked without opening every live
|
|
4253
|
+
// sidecar. This remains correct when the disposable activity journal is
|
|
4254
|
+
// absent, corrupt, or a parseable stale prefix.
|
|
4255
|
+
if (existsSync(dir)) {
|
|
4256
|
+
const files = readdirSync(dir);
|
|
4257
|
+
const transcriptIds = new Set(
|
|
4258
|
+
files
|
|
4259
|
+
.filter((file) => file.endsWith(".jsonl"))
|
|
4260
|
+
.map((file) => basename(file, ".jsonl")),
|
|
4261
|
+
);
|
|
4262
|
+
const tombstoneIds = new Set(
|
|
4263
|
+
files
|
|
4264
|
+
.filter((file) => file.endsWith(SESSION_TOMBSTONE_MARKER_SUFFIX))
|
|
4265
|
+
.map((file) => file.slice(0, -SESSION_TOMBSTONE_MARKER_SUFFIX.length)),
|
|
4266
|
+
);
|
|
4267
|
+
for (const file of files) {
|
|
4268
|
+
if (!file.endsWith(".meta.json")) continue;
|
|
4269
|
+
const id = file.slice(0, -".meta.json".length);
|
|
4270
|
+
const hasTranscript = transcriptIds.has(id);
|
|
4271
|
+
if (isUnsafeSessionId(id) || (hasTranscript && !tombstoneIds.has(id))) {
|
|
4272
|
+
continue;
|
|
4273
|
+
}
|
|
4274
|
+
const meta = readSessionMeta(dir, id);
|
|
4275
|
+
const presence = getSessionPresence(id);
|
|
4276
|
+
const isMissing = presence === SESSION_PRESENCE.MISSING_TRANSCRIPT;
|
|
4277
|
+
const isRecoveredConflict = presence === SESSION_PRESENCE.CONFLICT;
|
|
4278
|
+
if (!meta || (!isMissing && !isRecoveredConflict)) continue;
|
|
4279
|
+
const updatedAt = Math.max(0, Number(meta?.updated_at_ms) || 0);
|
|
4280
|
+
const risk = candidateRisk(presence);
|
|
4281
|
+
if (
|
|
4282
|
+
latest === null ||
|
|
4283
|
+
updatedAt > latest.updatedAt ||
|
|
4284
|
+
(updatedAt === latest.updatedAt && risk > latest.risk) ||
|
|
4285
|
+
(updatedAt === latest.updatedAt &&
|
|
4286
|
+
risk === latest.risk &&
|
|
4287
|
+
id > latest.id)
|
|
4288
|
+
) {
|
|
4289
|
+
latest = { id, updatedAt, risk };
|
|
4290
|
+
}
|
|
4291
|
+
}
|
|
4292
|
+
}
|
|
4293
|
+
return latest?.id || null;
|
|
2884
4294
|
}
|
|
2885
4295
|
|
|
2886
4296
|
export function migrateLegacySessions(sourceDir, options = {}) {
|
|
@@ -3068,7 +4478,8 @@ function performLegacySessionMigration(sourcePath, options) {
|
|
|
3068
4478
|
};
|
|
3069
4479
|
}
|
|
3070
4480
|
|
|
3071
|
-
|
|
4481
|
+
const existingPresence = getSessionPresence(sessionId);
|
|
4482
|
+
if (!options.force && existingPresence !== SESSION_PRESENCE.ABSENT) {
|
|
3072
4483
|
return {
|
|
3073
4484
|
file: sourcePath,
|
|
3074
4485
|
sessionId,
|
|
@@ -3078,8 +4489,17 @@ function performLegacySessionMigration(sourcePath, options) {
|
|
|
3078
4489
|
}
|
|
3079
4490
|
|
|
3080
4491
|
if (!options.dryRun) {
|
|
3081
|
-
if (
|
|
3082
|
-
|
|
4492
|
+
if (
|
|
4493
|
+
options.force &&
|
|
4494
|
+
![SESSION_PRESENCE.ABSENT, SESSION_PRESENCE.TOMBSTONED].includes(
|
|
4495
|
+
existingPresence,
|
|
4496
|
+
)
|
|
4497
|
+
) {
|
|
4498
|
+
if (!deleteJsonlSession(sessionId)) {
|
|
4499
|
+
throw new Error(
|
|
4500
|
+
`could not tombstone existing JSONL session before forced migration: ${sessionId}`,
|
|
4501
|
+
);
|
|
4502
|
+
}
|
|
3083
4503
|
}
|
|
3084
4504
|
startSession(sessionId, legacy.meta);
|
|
3085
4505
|
for (const message of legacy.messages) {
|