@remnic/core 10.0.60 → 10.0.62

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.
Files changed (289) hide show
  1. package/dist/access-admin-ops-surface.js +25 -24
  2. package/dist/access-authorization-probe.js +26 -25
  3. package/dist/access-boundary.js +25 -24
  4. package/dist/access-cli.js +73 -72
  5. package/dist/access-cli.js.map +1 -1
  6. package/dist/access-extraction-force-flush.js +25 -24
  7. package/dist/access-http-briefing.js +26 -25
  8. package/dist/access-http-query.js +27 -26
  9. package/dist/access-http.js +44 -43
  10. package/dist/access-identity-continuity-surface.js +25 -24
  11. package/dist/access-lcm-surface.js +25 -24
  12. package/dist/access-mcp.js +36 -35
  13. package/dist/access-namespace-preflight.js +25 -24
  14. package/dist/access-observe-write-category.js +4 -4
  15. package/dist/access-observe-write-surface.js +25 -24
  16. package/dist/access-operations-batch.js +27 -26
  17. package/dist/access-operations.js +34 -33
  18. package/dist/access-recall-concurrency.js +25 -24
  19. package/dist/access-recall-diagnostics.js +2 -2
  20. package/dist/access-recall-response.js +25 -24
  21. package/dist/access-recall-surface.js +25 -24
  22. package/dist/access-schema.js +3 -3
  23. package/dist/access-service.js +25 -24
  24. package/dist/access-surface-catalog.js +27 -26
  25. package/dist/access-surface-catalog.js.map +1 -1
  26. package/dist/active-recall.js +8 -6
  27. package/dist/active-recall.js.map +1 -1
  28. package/dist/briefing.js +8 -6
  29. package/dist/buffer-persistence.d.ts +14 -3
  30. package/dist/buffer-persistence.d.ts.map +1 -1
  31. package/dist/buffer-persistence.js +1 -1
  32. package/dist/buffer-turn-helpers.d.ts +7 -0
  33. package/dist/buffer-turn-helpers.d.ts.map +1 -1
  34. package/dist/buffer-turn-helpers.js +5 -3
  35. package/dist/buffer.d.ts +5 -1
  36. package/dist/buffer.d.ts.map +1 -1
  37. package/dist/buffer.js +3 -3
  38. package/dist/{capsule-crypto-7FJQINUR.js → capsule-crypto-GWVG7LGC.js} +2 -2
  39. package/dist/causal-consolidation.js +9 -7
  40. package/dist/causal-consolidation.js.map +1 -1
  41. package/dist/{chunk-VHBGJAYC.js → chunk-25QK3LR2.js} +2 -2
  42. package/dist/{chunk-X2TZWVCC.js → chunk-3HLWLYRD.js} +2 -2
  43. package/dist/{chunk-TIS6T6UP.js → chunk-3VNRDRBM.js} +2 -2
  44. package/dist/{chunk-4O4VAEWH.js → chunk-4NLHEE2Z.js} +2 -2
  45. package/dist/{chunk-3DG2YAAX.js → chunk-6EZPYZT3.js} +62 -35
  46. package/dist/chunk-6EZPYZT3.js.map +1 -0
  47. package/dist/{chunk-I6UTOUBK.js → chunk-6VBKU3FY.js} +150 -345
  48. package/dist/chunk-6VBKU3FY.js.map +1 -0
  49. package/dist/{chunk-66CC6L6R.js → chunk-76LKZ42K.js} +2 -2
  50. package/dist/{chunk-LR7RW4T6.js → chunk-7ETWIBTO.js} +3 -3
  51. package/dist/{chunk-JM2WJBP7.js → chunk-7JFXXA57.js} +2 -2
  52. package/dist/{chunk-PIVSUPF2.js → chunk-A6JV3WHI.js} +2 -2
  53. package/dist/{chunk-MOB4JHG6.js → chunk-BDNPW3KM.js} +2 -2
  54. package/dist/{chunk-IJFX4JRI.js → chunk-BQOOUKEN.js} +7 -7
  55. package/dist/{chunk-PRSF7DWT.js → chunk-BU4W2ISH.js} +2 -2
  56. package/dist/{chunk-KQAFEZQX.js → chunk-BUKK5SWA.js} +2 -2
  57. package/dist/{chunk-JRY55I6Z.js → chunk-C2AYUXEF.js} +2 -2
  58. package/dist/{chunk-DF4SYU4Z.js → chunk-C5OW2FFB.js} +2 -2
  59. package/dist/{chunk-74Z2BYEK.js → chunk-CYEEVWQ6.js} +2 -2
  60. package/dist/{chunk-M7KAJBO2.js → chunk-EADUOZWH.js} +3 -3
  61. package/dist/{chunk-N5H22V75.js → chunk-ECT5ME6X.js} +2 -2
  62. package/dist/{chunk-M3GOWG2P.js → chunk-FMOUUWQN.js} +2 -2
  63. package/dist/{chunk-2EJAPJ5G.js → chunk-FYPKSR6Z.js} +2 -2
  64. package/dist/{chunk-L5RMURVI.js → chunk-GDCKOER4.js} +41 -13
  65. package/dist/chunk-GDCKOER4.js.map +1 -0
  66. package/dist/{chunk-LGLLCWA2.js → chunk-GNEB3WXR.js} +2 -2
  67. package/dist/{chunk-Z4EH5TSO.js → chunk-HNZH57JK.js} +2 -2
  68. package/dist/{chunk-PTJWUN4H.js → chunk-IJKBIOWL.js} +30 -30
  69. package/dist/{chunk-C5GTIRIO.js → chunk-IMNJJKHW.js} +22 -22
  70. package/dist/{chunk-ADK5TMQL.js → chunk-IVJI5FDA.js} +2 -2
  71. package/dist/{chunk-F3GX76LV.js → chunk-IWAB7JVH.js} +2 -2
  72. package/dist/{chunk-JFRO5HJY.js → chunk-J62O2UW5.js} +2 -2
  73. package/dist/chunk-JCL7RO2G.js +674 -0
  74. package/dist/chunk-JCL7RO2G.js.map +1 -0
  75. package/dist/{chunk-A3UFV4K3.js → chunk-JIYJIH5N.js} +2 -2
  76. package/dist/{chunk-UIX4JLVV.js → chunk-JVFYP2LD.js} +2 -2
  77. package/dist/{chunk-KJ3MDX4V.js → chunk-KXY4HW2Y.js} +2 -2
  78. package/dist/{chunk-VQ6H4DL6.js → chunk-L334PSLW.js} +4 -4
  79. package/dist/{chunk-IA3XZG4V.js → chunk-L74VVEJA.js} +2 -2
  80. package/dist/{chunk-UY67YP6Q.js → chunk-LIUI46BX.js} +2 -2
  81. package/dist/{chunk-4OFXLQJD.js → chunk-MPDLLLOG.js} +8 -3
  82. package/dist/chunk-MPDLLLOG.js.map +1 -0
  83. package/dist/{chunk-UP2Q7U4G.js → chunk-MTVC5S5N.js} +2 -2
  84. package/dist/{chunk-QWURVT5Z.js → chunk-N7CATBYH.js} +2 -2
  85. package/dist/{chunk-JEDS2QYV.js → chunk-OH55F76C.js} +2 -2
  86. package/dist/{chunk-2NLLXCJG.js → chunk-OWHERGF2.js} +2 -2
  87. package/dist/{chunk-G6GMZJQ4.js → chunk-PA6ZNFB7.js} +2 -2
  88. package/dist/{chunk-6BKSUA6P.js → chunk-PWEIZCPS.js} +2 -2
  89. package/dist/{chunk-T4SMMTGY.js → chunk-Q3DAWUAV.js} +2 -2
  90. package/dist/{chunk-AWKADEMO.js → chunk-QCAJMIAE.js} +3 -3
  91. package/dist/{chunk-ZRDEOC4X.js → chunk-QI7LLSWX.js} +2 -2
  92. package/dist/{chunk-7DHNBABU.js → chunk-QJ6IR35B.js} +3 -3
  93. package/dist/{chunk-J53IBVMF.js → chunk-R25JMO5O.js} +2 -2
  94. package/dist/{chunk-PARM6OYL.js → chunk-SEA56G6S.js} +1 -1
  95. package/dist/chunk-SEA56G6S.js.map +1 -0
  96. package/dist/{chunk-HL643H2F.js → chunk-TMWSCE2L.js} +8 -8
  97. package/dist/{chunk-FLDXEJFX.js → chunk-TPCRDPUY.js} +2 -2
  98. package/dist/{chunk-KZMRMKC7.js → chunk-U7MIMS2Y.js} +11 -1
  99. package/dist/chunk-U7MIMS2Y.js.map +1 -0
  100. package/dist/{chunk-WJC5KGYC.js → chunk-UFQB5VQ5.js} +6 -6
  101. package/dist/{chunk-64WHWIPL.js → chunk-UVHXLVFJ.js} +2 -2
  102. package/dist/{chunk-X7Y7WX73.js → chunk-UVYI6VIX.js} +1 -1
  103. package/dist/{chunk-5XY46Z2W.js → chunk-UXRCORZX.js} +3 -3
  104. package/dist/{chunk-WUL6LDJ4.js → chunk-VPEA63FC.js} +2 -2
  105. package/dist/{chunk-MHSUEA32.js → chunk-WA6XRNWE.js} +3 -3
  106. package/dist/{chunk-GGHX4YHM.js → chunk-WB3OFPM2.js} +2 -2
  107. package/dist/{chunk-RSYFSCKL.js → chunk-WZEDFDJ6.js} +14 -14
  108. package/dist/{chunk-2HVCTFKG.js → chunk-X2ZLHUEO.js} +2 -2
  109. package/dist/{chunk-PJLJN23N.js → chunk-XICJL56Z.js} +2 -2
  110. package/dist/{chunk-ZXEPLZUQ.js → chunk-YGM47S7M.js} +7 -7
  111. package/dist/{chunk-XWXBXBOX.js → chunk-YLB7TW2B.js} +2 -1
  112. package/dist/chunk-YLB7TW2B.js.map +1 -0
  113. package/dist/{chunk-KXDUSGPM.js → chunk-YRL4PGPP.js} +2 -2
  114. package/dist/{chunk-GJF337YM.js → chunk-Z45353ML.js} +2 -2
  115. package/dist/{chunk-NJZY5LOC.js → chunk-ZARHK7PT.js} +2 -2
  116. package/dist/{chunk-NF5EHQTJ.js → chunk-ZFU2B2GM.js} +5 -5
  117. package/dist/{chunk-W37MS6SX.js → chunk-ZRSXXWIX.js} +2 -2
  118. package/dist/cli.js +60 -59
  119. package/dist/coding/export-okf-codegraph.js +7 -5
  120. package/dist/coding/export-okf-codegraph.js.map +1 -1
  121. package/dist/compounding/engine.js +9 -7
  122. package/dist/config.d.ts.map +1 -1
  123. package/dist/config.js +8 -6
  124. package/dist/connectors/codex-materialize-runner.js +8 -6
  125. package/dist/connectors/index.js +8 -6
  126. package/dist/convergence-refresh.js +10 -8
  127. package/dist/corpus-watermark.js +10 -8
  128. package/dist/deep-recall-http-glue.js +4 -4
  129. package/dist/entity-retrieval.js +8 -6
  130. package/dist/external-wiki-access.js +26 -25
  131. package/dist/extraction.js +8 -6
  132. package/dist/harmonic-retrieval.js +8 -6
  133. package/dist/index.d.ts +1 -1
  134. package/dist/index.d.ts.map +1 -1
  135. package/dist/index.js +75 -72
  136. package/dist/location/index.js +10 -8
  137. package/dist/maintenance/memory-governance.js +8 -6
  138. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +8 -6
  139. package/dist/maintenance/rebuild-memory-projection-runner.js +10 -8
  140. package/dist/maintenance/rebuild-memory-projection.js +9 -7
  141. package/dist/memory-browse-operations.js +26 -25
  142. package/dist/namespaces/migrate.js +9 -7
  143. package/dist/namespaces/search.js +3 -3
  144. package/dist/namespaces/storage.js +8 -6
  145. package/dist/operator-doctor-corpus.js +11 -9
  146. package/dist/operator-toolkit.js +17 -15
  147. package/dist/orchestration/buffer-drain-gate.d.ts +21 -0
  148. package/dist/orchestration/buffer-drain-gate.d.ts.map +1 -0
  149. package/dist/orchestration/buffer-drain-runner.d.ts +79 -0
  150. package/dist/orchestration/buffer-drain-runner.d.ts.map +1 -0
  151. package/dist/orchestration/buffer-drain-runner.js +780 -0
  152. package/dist/orchestration/buffer-drain-runner.js.map +1 -0
  153. package/dist/orchestration/buffer-swap.d.ts +12 -0
  154. package/dist/orchestration/buffer-swap.d.ts.map +1 -0
  155. package/dist/orchestration/maintenance.js +18 -16
  156. package/dist/orchestration/turn-ingestion.d.ts.map +1 -1
  157. package/dist/orchestrator.d.ts +4 -1
  158. package/dist/orchestrator.d.ts.map +1 -1
  159. package/dist/orchestrator.js +73 -72
  160. package/dist/recall-explain-renderer.js +3 -3
  161. package/dist/recall-navigate-access.js +2 -2
  162. package/dist/recall-navigate-disclosure.js +2 -2
  163. package/dist/recall-navigate.js +2 -2
  164. package/dist/recall-navigation-operations.js +26 -25
  165. package/dist/recall-navigation.js +25 -24
  166. package/dist/recall-xray-cli.js +4 -4
  167. package/dist/recall-xray-renderer.js +3 -3
  168. package/dist/recall-xray.js +2 -2
  169. package/dist/resume-bundles.js +9 -7
  170. package/dist/search/factory.js +2 -2
  171. package/dist/search/index.js +4 -4
  172. package/dist/semantic-consolidation.js +9 -7
  173. package/dist/semantic-rule-promotion.js +8 -6
  174. package/dist/semantic-rule-verifier.js +8 -6
  175. package/dist/source-agent-qualifier.js +8 -6
  176. package/dist/standup/build.js +8 -6
  177. package/dist/storage/buffer-drain-claims.d.ts +159 -0
  178. package/dist/storage/buffer-drain-claims.d.ts.map +1 -0
  179. package/dist/storage/buffer-drain-claims.js +18 -0
  180. package/dist/storage/buffer-drain-claims.js.map +1 -0
  181. package/dist/storage/buffer-session-files.d.ts +14 -2
  182. package/dist/storage/buffer-session-files.d.ts.map +1 -1
  183. package/dist/storage/buffer-session-files.js +22 -0
  184. package/dist/storage/buffer-session-files.js.map +1 -0
  185. package/dist/storage.d.ts +6 -1
  186. package/dist/storage.d.ts.map +1 -1
  187. package/dist/storage.js +7 -5
  188. package/dist/support-passport/index.js +28 -27
  189. package/dist/transfer/backup.js +2 -2
  190. package/dist/transfer/capsule-export.js +2 -2
  191. package/dist/transfer/capsule-import.js +2 -2
  192. package/dist/transfer/export-okf.js +8 -6
  193. package/dist/types.d.ts +2 -0
  194. package/dist/types.d.ts.map +1 -1
  195. package/dist/types.js +1 -1
  196. package/dist/verified-recall.js +8 -6
  197. package/dist/who-knows-cli.js +9 -7
  198. package/dist/who-knows.js +8 -6
  199. package/package.json +32 -2
  200. package/src/access-observe-replay-durability.test.ts +1 -0
  201. package/src/buffer-drain-handover.test.ts +307 -0
  202. package/src/buffer-incremental-persistence.test.ts +19 -1
  203. package/src/buffer-persistence.ts +27 -5
  204. package/src/buffer-session.test.ts +3 -3
  205. package/src/buffer-turn-helpers.ts +16 -0
  206. package/src/buffer.ts +50 -10
  207. package/src/config.ts +1 -0
  208. package/src/index.ts +1 -1
  209. package/src/orchestration/buffer-drain-gate.test.ts +40 -0
  210. package/src/orchestration/buffer-drain-gate.ts +38 -0
  211. package/src/orchestration/buffer-drain-runner.ts +312 -0
  212. package/src/orchestration/buffer-swap.ts +27 -0
  213. package/src/orchestration/turn-ingestion.ts +2 -0
  214. package/src/orchestrator.ts +9 -6
  215. package/src/storage/buffer-drain-claims.test.ts +448 -0
  216. package/src/storage/buffer-drain-claims.ts +593 -0
  217. package/src/storage/buffer-session-files.ts +50 -3
  218. package/src/storage.ts +8 -11
  219. package/src/testing/subjects/extraction-lifecycle.test.ts +34 -0
  220. package/src/types.ts +2 -0
  221. package/dist/chunk-3DG2YAAX.js.map +0 -1
  222. package/dist/chunk-4OFXLQJD.js.map +0 -1
  223. package/dist/chunk-I6UTOUBK.js.map +0 -1
  224. package/dist/chunk-KZMRMKC7.js.map +0 -1
  225. package/dist/chunk-L5RMURVI.js.map +0 -1
  226. package/dist/chunk-PARM6OYL.js.map +0 -1
  227. package/dist/chunk-XWXBXBOX.js.map +0 -1
  228. /package/dist/{capsule-crypto-7FJQINUR.js.map → capsule-crypto-GWVG7LGC.js.map} +0 -0
  229. /package/dist/{chunk-VHBGJAYC.js.map → chunk-25QK3LR2.js.map} +0 -0
  230. /package/dist/{chunk-X2TZWVCC.js.map → chunk-3HLWLYRD.js.map} +0 -0
  231. /package/dist/{chunk-TIS6T6UP.js.map → chunk-3VNRDRBM.js.map} +0 -0
  232. /package/dist/{chunk-4O4VAEWH.js.map → chunk-4NLHEE2Z.js.map} +0 -0
  233. /package/dist/{chunk-66CC6L6R.js.map → chunk-76LKZ42K.js.map} +0 -0
  234. /package/dist/{chunk-LR7RW4T6.js.map → chunk-7ETWIBTO.js.map} +0 -0
  235. /package/dist/{chunk-JM2WJBP7.js.map → chunk-7JFXXA57.js.map} +0 -0
  236. /package/dist/{chunk-PIVSUPF2.js.map → chunk-A6JV3WHI.js.map} +0 -0
  237. /package/dist/{chunk-MOB4JHG6.js.map → chunk-BDNPW3KM.js.map} +0 -0
  238. /package/dist/{chunk-IJFX4JRI.js.map → chunk-BQOOUKEN.js.map} +0 -0
  239. /package/dist/{chunk-PRSF7DWT.js.map → chunk-BU4W2ISH.js.map} +0 -0
  240. /package/dist/{chunk-KQAFEZQX.js.map → chunk-BUKK5SWA.js.map} +0 -0
  241. /package/dist/{chunk-JRY55I6Z.js.map → chunk-C2AYUXEF.js.map} +0 -0
  242. /package/dist/{chunk-DF4SYU4Z.js.map → chunk-C5OW2FFB.js.map} +0 -0
  243. /package/dist/{chunk-74Z2BYEK.js.map → chunk-CYEEVWQ6.js.map} +0 -0
  244. /package/dist/{chunk-M7KAJBO2.js.map → chunk-EADUOZWH.js.map} +0 -0
  245. /package/dist/{chunk-N5H22V75.js.map → chunk-ECT5ME6X.js.map} +0 -0
  246. /package/dist/{chunk-M3GOWG2P.js.map → chunk-FMOUUWQN.js.map} +0 -0
  247. /package/dist/{chunk-2EJAPJ5G.js.map → chunk-FYPKSR6Z.js.map} +0 -0
  248. /package/dist/{chunk-LGLLCWA2.js.map → chunk-GNEB3WXR.js.map} +0 -0
  249. /package/dist/{chunk-Z4EH5TSO.js.map → chunk-HNZH57JK.js.map} +0 -0
  250. /package/dist/{chunk-PTJWUN4H.js.map → chunk-IJKBIOWL.js.map} +0 -0
  251. /package/dist/{chunk-C5GTIRIO.js.map → chunk-IMNJJKHW.js.map} +0 -0
  252. /package/dist/{chunk-ADK5TMQL.js.map → chunk-IVJI5FDA.js.map} +0 -0
  253. /package/dist/{chunk-F3GX76LV.js.map → chunk-IWAB7JVH.js.map} +0 -0
  254. /package/dist/{chunk-JFRO5HJY.js.map → chunk-J62O2UW5.js.map} +0 -0
  255. /package/dist/{chunk-A3UFV4K3.js.map → chunk-JIYJIH5N.js.map} +0 -0
  256. /package/dist/{chunk-UIX4JLVV.js.map → chunk-JVFYP2LD.js.map} +0 -0
  257. /package/dist/{chunk-KJ3MDX4V.js.map → chunk-KXY4HW2Y.js.map} +0 -0
  258. /package/dist/{chunk-VQ6H4DL6.js.map → chunk-L334PSLW.js.map} +0 -0
  259. /package/dist/{chunk-IA3XZG4V.js.map → chunk-L74VVEJA.js.map} +0 -0
  260. /package/dist/{chunk-UY67YP6Q.js.map → chunk-LIUI46BX.js.map} +0 -0
  261. /package/dist/{chunk-UP2Q7U4G.js.map → chunk-MTVC5S5N.js.map} +0 -0
  262. /package/dist/{chunk-QWURVT5Z.js.map → chunk-N7CATBYH.js.map} +0 -0
  263. /package/dist/{chunk-JEDS2QYV.js.map → chunk-OH55F76C.js.map} +0 -0
  264. /package/dist/{chunk-2NLLXCJG.js.map → chunk-OWHERGF2.js.map} +0 -0
  265. /package/dist/{chunk-G6GMZJQ4.js.map → chunk-PA6ZNFB7.js.map} +0 -0
  266. /package/dist/{chunk-6BKSUA6P.js.map → chunk-PWEIZCPS.js.map} +0 -0
  267. /package/dist/{chunk-T4SMMTGY.js.map → chunk-Q3DAWUAV.js.map} +0 -0
  268. /package/dist/{chunk-AWKADEMO.js.map → chunk-QCAJMIAE.js.map} +0 -0
  269. /package/dist/{chunk-ZRDEOC4X.js.map → chunk-QI7LLSWX.js.map} +0 -0
  270. /package/dist/{chunk-7DHNBABU.js.map → chunk-QJ6IR35B.js.map} +0 -0
  271. /package/dist/{chunk-J53IBVMF.js.map → chunk-R25JMO5O.js.map} +0 -0
  272. /package/dist/{chunk-HL643H2F.js.map → chunk-TMWSCE2L.js.map} +0 -0
  273. /package/dist/{chunk-FLDXEJFX.js.map → chunk-TPCRDPUY.js.map} +0 -0
  274. /package/dist/{chunk-WJC5KGYC.js.map → chunk-UFQB5VQ5.js.map} +0 -0
  275. /package/dist/{chunk-64WHWIPL.js.map → chunk-UVHXLVFJ.js.map} +0 -0
  276. /package/dist/{chunk-X7Y7WX73.js.map → chunk-UVYI6VIX.js.map} +0 -0
  277. /package/dist/{chunk-5XY46Z2W.js.map → chunk-UXRCORZX.js.map} +0 -0
  278. /package/dist/{chunk-WUL6LDJ4.js.map → chunk-VPEA63FC.js.map} +0 -0
  279. /package/dist/{chunk-MHSUEA32.js.map → chunk-WA6XRNWE.js.map} +0 -0
  280. /package/dist/{chunk-GGHX4YHM.js.map → chunk-WB3OFPM2.js.map} +0 -0
  281. /package/dist/{chunk-RSYFSCKL.js.map → chunk-WZEDFDJ6.js.map} +0 -0
  282. /package/dist/{chunk-2HVCTFKG.js.map → chunk-X2ZLHUEO.js.map} +0 -0
  283. /package/dist/{chunk-PJLJN23N.js.map → chunk-XICJL56Z.js.map} +0 -0
  284. /package/dist/{chunk-ZXEPLZUQ.js.map → chunk-YGM47S7M.js.map} +0 -0
  285. /package/dist/{chunk-KXDUSGPM.js.map → chunk-YRL4PGPP.js.map} +0 -0
  286. /package/dist/{chunk-GJF337YM.js.map → chunk-Z45353ML.js.map} +0 -0
  287. /package/dist/{chunk-NJZY5LOC.js.map → chunk-ZARHK7PT.js.map} +0 -0
  288. /package/dist/{chunk-NF5EHQTJ.js.map → chunk-ZFU2B2GM.js.map} +0 -0
  289. /package/dist/{chunk-W37MS6SX.js.map → chunk-ZRSXXWIX.js.map} +0 -0
@@ -0,0 +1,593 @@
1
+ /**
2
+ * Durable drain claims for the extraction buffer (`remnic buffer drain`).
3
+ *
4
+ * A claim is the atomic handover of one session's buffered turns from the
5
+ * live buffer (daemon or CLI process) to a drain worker, so extraction can
6
+ * run out of process without a live buffer owner resurrecting or losing the
7
+ * turns. Files under `<stateDir>/buffer/drain/`:
8
+ *
9
+ * s-<sha256(key)>.json claim content: { key, entry, claimedAt }
10
+ * — the turns NOT yet extracted. Renamed into
11
+ * place from the session file (atomic on one
12
+ * filesystem), rewritten atomically as chunks
13
+ * drain.
14
+ * s-<sha256(key)>.lease.json { ownerId, pid, acquiredAt, heartbeatAt,
15
+ * leaseMs } — created with exclusive-create so
16
+ * exactly one drainer holds a key.
17
+ *
18
+ * Invariants (see the buffer-drain PR for the full protocol):
19
+ * - At every instant the claim content file holds exactly the turns whose
20
+ * extraction is not durably finished, so a kill at any point loses
21
+ * nothing and the next run resumes.
22
+ * - A live buffer owner that must persist a claimed session hands its
23
+ * in-memory entry INTO the claim (merge, identity-deduped) instead of
24
+ * writing a session file — it can never resurrect claimed turns.
25
+ * - Claims are idempotent: re-claiming, double-draining, and two
26
+ * concurrent drainers are all safe (per-key mutation lock + exclusive
27
+ * lease; the lock also fences lease takeover and progress writes).
28
+ *
29
+ * All content reads/writes go through the storage's secure file I/O
30
+ * (`BufferFileIo`); only lease/existence checks touch raw paths, and claim
31
+ * rewrites serialize on a cross-process held file lock.
32
+ */
33
+
34
+ import { hostname } from "node:os";
35
+ import { z } from "zod";
36
+ import { existsSync } from "node:fs";
37
+ import { mkdir, readdir, rename, unlink, writeFile } from "node:fs/promises";
38
+ import path from "node:path";
39
+
40
+ import { bufferSessionFileName, type BufferFileIo } from "./buffer-session-files.js";
41
+ import { bufferTurnsEqual, subtractBufferTurns } from "../buffer-turn-helpers.js";
42
+ import { log } from "../logger.js";
43
+ import { withHeldFileLock } from "../utils/serialize-mutations.js";
44
+ import type { BufferEntryState, BufferTurn } from "../types.js";
45
+
46
+ /** Result of persisting one session's entry. */
47
+ export interface BufferSessionSaveOutcome {
48
+ /** "saved": the session file holds the entry. "handed_over": the entry moved into a drain claim. */
49
+ status: "saved" | "handed_over";
50
+ /** Turns a drain already extracted (claim tombstone): the owner subtracts them from its copy. */
51
+ drainedTurns?: BufferTurn[];
52
+ }
53
+
54
+ export interface DrainLease {
55
+ ownerId: string;
56
+ pid: number;
57
+ /** Host the owner runs on: a takeover may only judge liveness on that host. */
58
+ host: string;
59
+ acquiredAt: string;
60
+ heartbeatAt: string;
61
+ leaseMs: number;
62
+ }
63
+
64
+ export interface DrainClaimFile {
65
+ key: string;
66
+ entry: BufferEntryState;
67
+ claimedAt: string;
68
+ /**
69
+ * Turns the drain already extracted. Non-empty means the session was
70
+ * drained and the claim is a TOMBSTONE: a live buffer owner subtracts
71
+ * these from its (possibly stale) copy instead of writing them back, so
72
+ * drained turns can never be resurrected.
73
+ */
74
+ drained?: BufferTurn[];
75
+ /** Same tombstone for turns the drain extracted from `retainedTurns`. */
76
+ drainedRetained?: BufferTurn[];
77
+ }
78
+
79
+ export type ClaimFromSessionOutcome = "claimed" | "empty" | "already_claimed";
80
+ export type AdoptOutcome = "adopted" | "busy";
81
+ export type AckOutcome = "acked" | "not_empty" | "busy";
82
+
83
+ export interface DrainClaimListing {
84
+ key: string;
85
+ turnCount: number;
86
+ leased: boolean;
87
+ leaseExpired: boolean;
88
+ }
89
+
90
+ /** Lock while a claim is read-modify-written; released on settle. */
91
+ const CLAIM_LOCK_STALE_MS = 120_000;
92
+ const CLAIM_LOCK_MAX_WAIT_MS = 120_000;
93
+
94
+ function emptyEntry(): BufferEntryState {
95
+ return { turns: [], lastExtractionAt: null, extractionCount: 0 };
96
+ }
97
+
98
+ /** Identity-dedupe `incoming` turns into `existing` (they are the same turns a
99
+ * live owner still held after claiming; only genuinely NEW turns append). */
100
+ function mergeTurns<T>(existing: T[], incoming: readonly T[], isEqual: (a: T, b: T) => boolean): T[] {
101
+ const merged = [...existing];
102
+ for (const turn of incoming) {
103
+ if (merged.some((kept) => isEqual(kept, turn))) continue;
104
+ merged.push(turn);
105
+ }
106
+ return merged;
107
+ }
108
+
109
+ function mergeEntries(claim: DrainClaimFile, incoming: BufferEntryState): DrainClaimFile {
110
+ const entry = emptyEntry();
111
+ // Turns already extracted stay out of the pending entry even when the
112
+ // owner's incoming copy is stale and still carries them (either tombstone
113
+ // list counts — a turn in either one left the claim for good).
114
+ const gone = [...(claim.drained ?? []), ...(claim.drainedRetained ?? [])];
115
+ entry.turns = mergeTurns(
116
+ subtractBufferTurns(claim.entry.turns, gone),
117
+ subtractBufferTurns(incoming.turns, gone),
118
+ bufferTurnsEqual,
119
+ );
120
+ const retained = [
121
+ ...subtractBufferTurns(claim.entry.retainedTurns ?? [], gone),
122
+ ...subtractBufferTurns(incoming.retainedTurns ?? [], gone),
123
+ ];
124
+ // SAFETY: an empty accumulator of the same element type.
125
+ const dedupedRetained = mergeTurns([] as typeof retained, retained, bufferTurnsEqual);
126
+ if (dedupedRetained.length > 0) entry.retainedTurns = dedupedRetained;
127
+ const stamps = [claim.entry.lastExtractionAt, incoming.lastExtractionAt].filter(
128
+ (value): value is string => value !== null && value !== undefined,
129
+ );
130
+ entry.lastExtractionAt = stamps.sort().at(-1) ?? null;
131
+ entry.extractionCount = Math.max(claim.entry.extractionCount, incoming.extractionCount);
132
+ return { ...claim, entry };
133
+ }
134
+
135
+ /**
136
+ * Is the lease owner still running? Only decidable on the owner's host; a
137
+ * lease from another host counts as alive (its expiry decides).
138
+ */
139
+ function isOwnerAlive(lease: DrainLease): boolean {
140
+ if (lease.host !== hostname()) return true;
141
+ if (!Number.isInteger(lease.pid) || lease.pid <= 0) return true;
142
+ try {
143
+ process.kill(lease.pid, 0);
144
+ return true;
145
+ } catch (err) {
146
+ // EPERM: the process exists but belongs to another user.
147
+ // SAFETY: the value comes from this module's own write path; the shape is checked above.
148
+ return (err as NodeJS.ErrnoException)?.code === "EPERM";
149
+ }
150
+ }
151
+
152
+ function isLeaseExpired(lease: DrainLease, now = Date.now()): boolean {
153
+ const heartbeat = Date.parse(lease.heartbeatAt);
154
+ return !Number.isFinite(heartbeat) || now - heartbeat > lease.leaseMs;
155
+ }
156
+
157
+ const claimBodySchema = z.object({
158
+ key: z.string().min(1),
159
+ entry: z.object({
160
+ turns: z.array(z.unknown()),
161
+ retainedTurns: z.array(z.unknown()).optional(),
162
+ lastExtractionAt: z.string().nullable().optional(),
163
+ extractionCount: z.number().optional(),
164
+ }).optional(),
165
+ turns: z.array(z.unknown()).optional(),
166
+ retainedTurns: z.array(z.unknown()).optional(),
167
+ lastExtractionAt: z.string().nullable().optional(),
168
+ extractionCount: z.number().optional(),
169
+ claimedAt: z.string().optional(),
170
+ drained: z.array(z.unknown()).optional(),
171
+ drainedRetained: z.array(z.unknown()).optional(),
172
+ });
173
+
174
+ function parseBody(raw: string): DrainClaimFile | null {
175
+ let body: z.infer<typeof claimBodySchema>;
176
+ try {
177
+ body = claimBodySchema.parse(JSON.parse(raw));
178
+ } catch {
179
+ return null;
180
+ }
181
+ // Two on-disk shapes, one format: a claim born from rename keeps the flat
182
+ // session-file shape ({key, turns, ...}) until its first rewrite, which
183
+ // nests under `entry`. Accept both.
184
+ const source = body.entry && Array.isArray(body.entry.turns) ? body.entry : body;
185
+ if (!Array.isArray(source.turns)) return null;
186
+ const entry = emptyEntry();
187
+ // SAFETY: the schema parsed these as an array of unknown; the claim file
188
+ // is written only by this module (BufferSessionFiles.save), so the entries
189
+ // are BufferTurn values.
190
+ // SAFETY: the claim file is written only by this module, so the parsed
191
+ // turn arrays hold BufferTurn values.
192
+ // SAFETY: the value comes from this module's own write path; the shape is checked above.
193
+ entry.turns = source.turns as BufferTurn[];
194
+ if (source.retainedTurns) {
195
+ // SAFETY: same boundary as the turns above (this module writes the file).
196
+ entry.retainedTurns = source.retainedTurns as BufferTurn[];
197
+ }
198
+ entry.lastExtractionAt = source.lastExtractionAt ?? null;
199
+ entry.extractionCount = Number.isFinite(source.extractionCount) ? Number(source.extractionCount) : 0;
200
+ const claim: DrainClaimFile = {
201
+ key: body.key,
202
+ entry,
203
+ claimedAt: body.claimedAt ?? new Date().toISOString(),
204
+ };
205
+ if (Array.isArray(body.drained)) {
206
+ // SAFETY: same boundary as the turns above (this module writes the file).
207
+ claim.drained = body.drained as BufferTurn[];
208
+ }
209
+ if (Array.isArray(body.drainedRetained)) {
210
+ // SAFETY: same boundary as the turns above (this module writes the file).
211
+ claim.drainedRetained = body.drainedRetained as BufferTurn[];
212
+ }
213
+ return claim;
214
+ }
215
+
216
+ const leaseSchema = z.object({
217
+ ownerId: z.string().min(1),
218
+ pid: z.number().optional(),
219
+ host: z.string().optional(),
220
+ acquiredAt: z.string().optional(),
221
+ heartbeatAt: z.string(),
222
+ leaseMs: z.number(),
223
+ });
224
+
225
+ function parseLease(raw: string): DrainLease | null {
226
+ let lease: z.infer<typeof leaseSchema>;
227
+ try {
228
+ lease = leaseSchema.parse(JSON.parse(raw));
229
+ } catch {
230
+ return null;
231
+ }
232
+ return {
233
+ ownerId: lease.ownerId,
234
+ pid: lease.pid ?? 0,
235
+ host: lease.host ?? "",
236
+ acquiredAt: lease.acquiredAt ?? lease.heartbeatAt,
237
+ heartbeatAt: lease.heartbeatAt,
238
+ leaseMs: lease.leaseMs,
239
+ };
240
+ }
241
+
242
+ export class BufferDrainClaims {
243
+ constructor(
244
+ private readonly stateDir: string,
245
+ private readonly io: BufferFileIo,
246
+ ) {}
247
+
248
+ private get dir(): string {
249
+ return path.join(this.stateDir, "buffer", "drain");
250
+ }
251
+
252
+ private fileFor(key: string): string {
253
+ return path.join(this.dir, bufferSessionFileName(key));
254
+ }
255
+
256
+ private leaseFileFor(key: string): string {
257
+ return `${this.fileFor(key)}.lease.json`;
258
+ }
259
+
260
+ private lockFileFor(key: string): string {
261
+ return `${this.fileFor(key)}.lock`;
262
+ }
263
+
264
+ /** Sync existence check for hot-path gates (`SmartBuffer.isSessionClaimed`). */
265
+ isClaimed(key: string): boolean {
266
+ return existsSync(this.fileFor(key));
267
+ }
268
+
269
+ /** Test + CLI seam: the lease file path for direct inspection. */
270
+ leasePathForTest(key: string): string {
271
+ return this.leaseFileFor(key);
272
+ }
273
+
274
+ private async readClaimBody(key: string): Promise<DrainClaimFile | null> {
275
+ try {
276
+ return parseBody(await this.io.read(this.fileFor(key)));
277
+ } catch {
278
+ return null;
279
+ }
280
+ }
281
+
282
+ /** The claim content for `key`, or null when the key is not claimed. */
283
+ load(key: string): Promise<DrainClaimFile | null> {
284
+ return this.readClaimBody(key);
285
+ }
286
+
287
+ private async writeClaimBody(key: string, claim: DrainClaimFile): Promise<void> {
288
+ await mkdir(this.dir, { recursive: true });
289
+ await this.io.write(this.fileFor(key), JSON.stringify({ ...claim, key }));
290
+ }
291
+
292
+ private async readLease(key: string): Promise<DrainLease | null> {
293
+ try {
294
+ return parseLease(await this.io.read(this.leaseFileFor(key)));
295
+ } catch {
296
+ return null;
297
+ }
298
+ }
299
+
300
+ private async writeLease(key: string, lease: DrainLease): Promise<void> {
301
+ await mkdir(this.dir, { recursive: true });
302
+ await this.io.write(this.leaseFileFor(key), JSON.stringify(lease));
303
+ }
304
+
305
+ /**
306
+ * Atomically move a session's persisted file into a claim. The rename is
307
+ * the handover: after it settles the turns exist ONLY as a claim, so no
308
+ * crash window can lose them or leave them in both places. Callers that
309
+ * lose the race (`already_claimed`) simply skip the key.
310
+ *
311
+ * The whole handover runs under the claim's mutation lock, so a claim
312
+ * create can never land on top of an existing claim (POSIX rename would
313
+ * silently replace it) and the lease write cannot race an adopter.
314
+ */
315
+ async claimFromSessionFile(key: string, lease: DrainLease): Promise<ClaimFromSessionOutcome> {
316
+ await mkdir(this.dir, { recursive: true });
317
+ const sessionPath = path.join(this.stateDir, "buffer", bufferSessionFileName(key));
318
+ if (existsSync(sessionPath)) {
319
+ // Advisory pre-read: an empty live session belongs to its owner, not
320
+ // the drain. A turn landing between this read and the rename is fine —
321
+ // the rename moves whatever is there and the claim drains it.
322
+ try {
323
+ const seeded = parseBody(await this.io.read(sessionPath));
324
+ if (seeded && seeded.entry.turns.length === 0 && (seeded.entry.retainedTurns?.length ?? 0) === 0) {
325
+ return "empty";
326
+ }
327
+ } catch {
328
+ // Unreadable live file: proceed with the rename and let the claim
329
+ // path report it loudly, like the loader skips corrupt files.
330
+ }
331
+ }
332
+ return await withHeldFileLock(
333
+ this.lockFileFor(key),
334
+ { staleMs: CLAIM_LOCK_STALE_MS, maxWaitMs: CLAIM_LOCK_MAX_WAIT_MS },
335
+ async (acquired) => {
336
+ if (!acquired) return "already_claimed";
337
+ // Under the lock the claim check is authoritative: an existing claim
338
+ // keeps its content and its lease; the session file stays with its
339
+ // owner (their next save merges into the claim).
340
+ if (this.isClaimed(key)) return "already_claimed";
341
+ try {
342
+ await rename(sessionPath, this.fileFor(key));
343
+ } catch (err) {
344
+ // The session file is gone: either it never existed, or another
345
+ // drainer claimed it first.
346
+ if (!existsSync(sessionPath)) {
347
+ if (this.isClaimed(key)) return "already_claimed";
348
+ return "empty";
349
+ }
350
+ throw err;
351
+ }
352
+ const claim = await this.readClaimBody(key);
353
+ if (!claim || (claim.entry.turns.length === 0 && (claim.entry.retainedTurns?.length ?? 0) === 0)) {
354
+ // Empty content: nothing to drain. Delete the moved file so the
355
+ // empty entry keeps living with its owner, not as a stale claim.
356
+ await unlink(this.fileFor(key)).catch(() => {});
357
+ return "empty";
358
+ }
359
+ await this.writeLease(key, lease);
360
+ return "claimed";
361
+ },
362
+ );
363
+ }
364
+
365
+ /**
366
+ * Take over a claim whose lease is missing, expired, or whose owner died.
367
+ * The whole judgement runs under the claim's mutation lock and re-reads
368
+ * the lease inside it, so exactly one concurrent adopter wins: the loser
369
+ * re-reads the winner's fresh lease and reports "busy" instead of
370
+ * unlinking it. A lease whose owner died is taken over in the same call.
371
+ */
372
+ async adopt(key: string, lease: DrainLease): Promise<AdoptOutcome> {
373
+ if (!this.isClaimed(key)) return "busy";
374
+ return await withHeldFileLock(
375
+ this.lockFileFor(key),
376
+ { staleMs: CLAIM_LOCK_STALE_MS, maxWaitMs: CLAIM_LOCK_MAX_WAIT_MS },
377
+ async (acquired) => {
378
+ if (!acquired) return "busy";
379
+ const existing = await this.readLease(key);
380
+ if (existing && !isLeaseExpired(existing)) {
381
+ if (existing.ownerId === lease.ownerId) {
382
+ // The same drainer already holds it: renew the lease.
383
+ await this.writeLease(key, { ...existing, heartbeatAt: new Date().toISOString(), leaseMs: lease.leaseMs });
384
+ return "adopted";
385
+ }
386
+ if (isOwnerAlive(existing)) return "busy";
387
+ // The owner died without releasing the claim (killed worker):
388
+ // take it over now instead of waiting for the lease to expire.
389
+ await unlink(this.leaseFileFor(key)).catch(() => {});
390
+ } else if (existing) {
391
+ // Expired lease: clear it before the exclusive create. We hold the
392
+ // lock, so no concurrent adopter can interleave its own
393
+ // unlink/create between these steps.
394
+ await unlink(this.leaseFileFor(key)).catch(() => {});
395
+ }
396
+ try {
397
+ await writeFile(this.leaseFileFor(key), JSON.stringify(lease), { flag: "wx" });
398
+ } catch (err) {
399
+ // The lease file exists: another drainer took it first.
400
+ if (existsSync(this.leaseFileFor(key))) return "busy";
401
+ throw err;
402
+ }
403
+ return "adopted";
404
+ },
405
+ );
406
+ }
407
+
408
+ /**
409
+ * Merge a live owner's in-memory entry INTO the claim (the handover path a
410
+ * buffer owner takes instead of persisting a claimed session). Serialized
411
+ * against drainer progress writes on the same lock.
412
+ */
413
+ async mergeIntoClaim(key: string, incoming: BufferEntryState): Promise<void> {
414
+ await withHeldFileLock(this.lockFileFor(key), { staleMs: CLAIM_LOCK_STALE_MS, maxWaitMs: CLAIM_LOCK_MAX_WAIT_MS }, async (acquired) => {
415
+ if (!acquired) {
416
+ // Never fall back to a session-file write behind a held claim lock:
417
+ // that would resurrect claimed turns. Fail so the owner's save stays
418
+ // pending and retries (SmartBuffer keeps pending state on failure).
419
+ throw new Error(`buffer drain claim for ${key} is busy; retrying the save later`);
420
+ }
421
+ const claim = await this.readClaimBody(key);
422
+ if (!claim) {
423
+ // The claim vanished (acked between the isClaimed check and now):
424
+ // nothing to hand over; the drain already owns everything.
425
+ return;
426
+ }
427
+ await this.writeClaimBody(key, mergeEntries(claim, incoming));
428
+ });
429
+ }
430
+
431
+ /** Drainer progress: replace the claim content with what is left to do.
432
+ *
433
+ * Fenced: `ownerId` must still match the claim's lease (checked under the
434
+ * same lock), so a drainer that lost its lease cannot write progress that
435
+ * would restore turns the new owner already extracted.
436
+ *
437
+ * `seen` is the entry the drainer LOADED (its snapshot of the claim). Only
438
+ * turns that were in that snapshot and are absent from BOTH of the
439
+ * drainer's lists were actually extracted and move to the tombstones — a
440
+ * turn the drainer moved into `entry.retainedTurns` is retained for retry,
441
+ * not extracted. Turns a live owner merged into the claim afterwards stay
442
+ * pending. Without `seen` the pre-existing behavior is kept (every claim
443
+ * turn missing from `entry` counts as extracted).
444
+ */
445
+ async writeEntry(key: string, ownerId: string, entry: BufferEntryState, seen?: BufferEntryState): Promise<void> {
446
+ await withHeldFileLock(this.lockFileFor(key), { staleMs: CLAIM_LOCK_STALE_MS, maxWaitMs: CLAIM_LOCK_MAX_WAIT_MS }, async (acquired) => {
447
+ if (!acquired) throw new Error(`buffer drain claim for ${key} is busy; progress write skipped`);
448
+ const lease = await this.readLease(key);
449
+ if (!lease || lease.ownerId !== ownerId) {
450
+ throw new Error(`buffer drain claim for ${key} is no longer owned by ${ownerId}; progress write skipped`);
451
+ }
452
+ const claim = await this.readClaimBody(key);
453
+ if (!claim) return;
454
+ const seenTurns = seen?.turns ?? claim.entry.turns;
455
+ const seenRetained = seen?.retainedTurns ?? claim.entry.retainedTurns ?? [];
456
+ const entryRetained = entry.retainedTurns ?? [];
457
+ // Still held by the drainer: in its remaining turns OR retained for retry.
458
+ const kept = (turn: BufferTurn): boolean =>
459
+ entry.turns.some((held) => bufferTurnsEqual(held, turn)) || entryRetained.some((held) => bufferTurnsEqual(held, turn));
460
+ // Progress: the snapshot turns that vanished from the entry were extracted.
461
+ const drained = [...(claim.drained ?? [])];
462
+ for (const turn of claim.entry.turns) {
463
+ const extracted =
464
+ seenTurns.some((snapshot) => bufferTurnsEqual(snapshot, turn)) && !kept(turn);
465
+ if (!extracted) continue;
466
+ if (drained.some((gone) => bufferTurnsEqual(gone, turn))) continue;
467
+ drained.push(turn);
468
+ }
469
+ const drainedRetained = [...(claim.drainedRetained ?? [])];
470
+ for (const turn of claim.entry.retainedTurns ?? []) {
471
+ const extracted =
472
+ seenRetained.some((snapshot) => bufferTurnsEqual(snapshot, turn)) && !kept(turn);
473
+ if (!extracted) continue;
474
+ if (drained.some((gone) => bufferTurnsEqual(gone, turn)) || drainedRetained.some((gone) => bufferTurnsEqual(gone, turn))) continue;
475
+ drainedRetained.push(turn);
476
+ }
477
+ // Turns merged in after the drainer loaded stay pending, in extraction
478
+ // order behind the drainer's own remainder.
479
+ const late = claim.entry.turns.filter(
480
+ (turn) =>
481
+ !seenTurns.some((snapshot) => bufferTurnsEqual(snapshot, turn)) && !kept(turn),
482
+ );
483
+ const next: BufferEntryState = { ...entry, turns: [...entry.turns, ...late] };
484
+ const lateRetained = (claim.entry.retainedTurns ?? []).filter(
485
+ (turn) =>
486
+ !seenRetained.some((snapshot) => bufferTurnsEqual(snapshot, turn)) && !kept(turn),
487
+ );
488
+ if (lateRetained.length > 0) next.retainedTurns = mergeTurns(entryRetained, lateRetained, bufferTurnsEqual);
489
+ await this.writeClaimBody(key, { ...claim, entry: next, drained, drainedRetained });
490
+ });
491
+ }
492
+
493
+ /** Extend the lease. "lost" means another drainer took the claim over.
494
+ * Serialized on the claim lock so a stale heartbeat can never overwrite a
495
+ * new owner's lease mid-takeover; a contended lock just skips this tick
496
+ * (usually the drainer's own progress write holds it). */
497
+ async heartbeat(key: string, ownerId: string, leaseMs: number): Promise<"ok" | "lost"> {
498
+ return await withHeldFileLock(this.lockFileFor(key), { staleMs: CLAIM_LOCK_STALE_MS, maxWaitMs: CLAIM_LOCK_MAX_WAIT_MS }, async (acquired) => {
499
+ if (!acquired) return "ok";
500
+ const lease = await this.readLease(key);
501
+ if (!lease || lease.ownerId !== ownerId) return "lost";
502
+ await this.writeLease(key, { ...lease, heartbeatAt: new Date().toISOString(), leaseMs });
503
+ return "ok";
504
+ });
505
+ }
506
+
507
+ /** Give the key back (content stays; the next run resumes it). */
508
+ async releaseLease(key: string, ownerId: string): Promise<void> {
509
+ const lease = await this.readLease(key);
510
+ if (!lease || lease.ownerId !== ownerId) return;
511
+ await unlink(this.leaseFileFor(key)).catch(() => {});
512
+ }
513
+
514
+ /**
515
+ * Finish a claim: only its owner may ack, and only when nothing is left to
516
+ * extract. Non-empty claims stay (deferred content is never dropped here).
517
+ */
518
+ async ack(key: string, ownerId: string): Promise<AckOutcome> {
519
+ const lease = await this.readLease(key);
520
+ if (!lease || lease.ownerId !== ownerId) return "busy";
521
+ const claim = await this.readClaimBody(key);
522
+ if (!claim) {
523
+ await unlink(this.leaseFileFor(key)).catch(() => {});
524
+ return "acked";
525
+ }
526
+ if (claim.entry.turns.length > 0 || (claim.entry.retainedTurns?.length ?? 0) > 0) return "not_empty";
527
+ // Keep the (empty) claim file as a TOMBSTONE: a live buffer owner that
528
+ // still holds a stale copy subtracts the drained turns instead of
529
+ // writing them back. The owner consumes the tombstone on its next save.
530
+ await unlink(this.leaseFileFor(key)).catch(() => {});
531
+ return "acked";
532
+ }
533
+
534
+ /** The turns a drain already extracted for `key` (tombstone content). */
535
+ async drainedTurns(key: string): Promise<BufferTurn[]> {
536
+ const claim = await this.readClaimBody(key);
537
+ return claim?.drained ?? [];
538
+ }
539
+
540
+ /**
541
+ * Is the drain for `key` FINISHED (acked): the claim holds extracted turns
542
+ * and nothing pending, and the drainer dropped its lease. A claim with
543
+ * partial progress or a live lease is still being drained — its owner must
544
+ * merge into it, never release it.
545
+ */
546
+ async isDrained(key: string): Promise<boolean> {
547
+ const claim = await this.readClaimBody(key);
548
+ if (!claim) return false;
549
+ if ((claim.drained?.length ?? 0) === 0 && (claim.drainedRetained?.length ?? 0) === 0) return false;
550
+ if (claim.entry.turns.length > 0 || (claim.entry.retainedTurns?.length ?? 0) > 0) return false;
551
+ return (await this.readLease(key)) === null;
552
+ }
553
+
554
+ /** Drop the claim file entirely (its owner consumed the tombstone). */
555
+ async release(key: string): Promise<void> {
556
+ await unlink(this.fileFor(key)).catch(() => {});
557
+ await unlink(this.leaseFileFor(key)).catch(() => {});
558
+ }
559
+
560
+ /** Every claim with its lease state (CLI scan + summaries). */
561
+ async list(): Promise<DrainClaimListing[]> {
562
+ let names: string[];
563
+ try {
564
+ names = (await readdir(this.dir)).filter((name) => name.endsWith(".json") && !name.endsWith(".lease.json"));
565
+ } catch (err) {
566
+ // SAFETY: the value comes from this module's own write path; the shape is checked above.
567
+ if ((err as NodeJS.ErrnoException)?.code === "ENOENT") return [];
568
+ throw err;
569
+ }
570
+ const out: DrainClaimListing[] = [];
571
+ for (const name of names) {
572
+ const claim = await this.readClaimBodyByKey(name);
573
+ if (!claim) continue;
574
+ const lease = await this.readLease(claim.key);
575
+ out.push({
576
+ key: claim.key,
577
+ turnCount: claim.entry.turns.length + (claim.entry.retainedTurns?.length ?? 0),
578
+ leased: lease !== null,
579
+ leaseExpired: lease !== null ? isLeaseExpired(lease) : true,
580
+ });
581
+ }
582
+ return out;
583
+ }
584
+
585
+ private async readClaimBodyByKey(name: string): Promise<DrainClaimFile | null> {
586
+ try {
587
+ return parseBody(await this.io.read(path.join(this.dir, name)));
588
+ } catch (err) {
589
+ log.debug(`buffer drain: skipping unreadable claim file ${name}: ${err instanceof Error ? err.message : String(err)}`);
590
+ return null;
591
+ }
592
+ }
593
+ }
@@ -1,7 +1,9 @@
1
+ import { BufferDrainClaims, type BufferSessionSaveOutcome } from "./buffer-drain-claims.js";
1
2
  import { createHash } from "node:crypto";
2
3
  import type { Stats } from "node:fs";
3
4
  import { lstat, readdir, unlink } from "node:fs/promises";
4
5
  import path from "node:path";
6
+ import { subtractBufferTurns } from "../buffer-turn-helpers.js";
5
7
  import { log } from "../logger.js";
6
8
  import { SecureStoreDecryptError, SecureStoreLockedError } from "../secure-store/secure-fs.js";
7
9
  import type { BufferEntryState, BufferState, BufferTurn } from "../types.js";
@@ -75,14 +77,32 @@ function entryFromBody(body: SessionFileBody): { key: string; entry: BufferEntry
75
77
  * One file per buffer session under `<stateDir>/buffer/`, so the observe hot
76
78
  * path persists only the session that changed. All writes happen under
77
79
  * SmartBuffer's serialized mutation chain (one writer per process).
80
+ *
81
+ * Sessions claimed by `remnic buffer drain` (see buffer-drain-claims.ts) are
82
+ * owned by the drain: `save` hands the in-memory entry INTO the claim
83
+ * (identity-deduped merge) instead of writing a session file, so a live
84
+ * buffer owner can never resurrect claimed turns.
78
85
  */
79
86
  export class BufferSessionFiles {
80
87
  private dirVerified = false;
88
+ private readonly claimsStore: BufferDrainClaims;
81
89
 
82
90
  constructor(
83
91
  private readonly stateDir: string,
84
92
  private readonly io: BufferFileIo,
85
- ) {}
93
+ ) {
94
+ this.claimsStore = new BufferDrainClaims(stateDir, io);
95
+ }
96
+
97
+ /** The drain-claim store for this state dir (CLI scan + claim ops). */
98
+ get claims(): BufferDrainClaims {
99
+ return this.claimsStore;
100
+ }
101
+
102
+ /** Sync gate check: is this session claimed by a buffer drain? */
103
+ isClaimed(key: string): boolean {
104
+ return this.claimsStore.isClaimed(key);
105
+ }
86
106
 
87
107
  private get dir(): string {
88
108
  return path.join(this.stateDir, "buffer");
@@ -113,10 +133,37 @@ export class BufferSessionFiles {
113
133
  }
114
134
 
115
135
  /** Persist ONE session atomically. Compact JSON: machine-read state, and
116
- * pretty-printing a large buffer costs CPU and bytes on every observe. */
117
- async save(key: string, entry: BufferEntryState): Promise<void> {
136
+ * pretty-printing a large buffer costs CPU and bytes on every observe.
137
+ * A drain-claimed session is handed over instead (see class doc). */
138
+ async save(key: string, entry: BufferEntryState): Promise<BufferSessionSaveOutcome> {
139
+ if (this.isClaimed(key)) {
140
+ // Consume the tombstone ONLY when the drain finished (acked: extracted
141
+ // turns recorded, nothing pending, no lease). Partial `drained` just
142
+ // means chunks completed — the drainer may still be running, and
143
+ // releasing here would unlink its claim and turn its later writes into
144
+ // no-ops.
145
+ if (await this.claimsStore.isDrained(key)) {
146
+ const drained = await this.claimsStore.drainedTurns(key);
147
+ // The drain finished this session: the claim is a tombstone. Write
148
+ // the owner's fresh remainder back as a session file (the drained
149
+ // turns are subtracted here too, so a caller that did not subtract
150
+ // them cannot resurrect them) and release the tombstone.
151
+ const fresh: BufferEntryState = { ...entry, turns: subtractBufferTurns(entry.turns, drained) };
152
+ if (entry.retainedTurns && entry.retainedTurns.length > 0) {
153
+ fresh.retainedTurns = subtractBufferTurns(entry.retainedTurns, drained);
154
+ if (fresh.retainedTurns.length === 0) delete fresh.retainedTurns;
155
+ }
156
+ await this.assertDirSafe();
157
+ await this.io.write(this.fileFor(key), JSON.stringify({ key, ...fresh }));
158
+ await this.claimsStore.release(key);
159
+ return { status: "saved", drainedTurns: drained };
160
+ }
161
+ await this.claimsStore.mergeIntoClaim(key, entry);
162
+ return { status: "handed_over" };
163
+ }
118
164
  await this.assertDirSafe();
119
165
  await this.io.write(this.fileFor(key), JSON.stringify({ key, ...entry }));
166
+ return { status: "saved" };
120
167
  }
121
168
 
122
169
  /** Remove one session's file. Idempotent: an absent file is fine. */