@openchambery/web 2.0.0-beta.1 → 2.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (287) hide show
  1. package/README.md +15 -11
  2. package/bin/cli-opencode.test.js +4 -4
  3. package/bin/cli.js +1 -1
  4. package/bin/lib/cli-startup.js +1 -1
  5. package/bin/lib/cli-startup.test.js +18 -4
  6. package/dist/assets/{AgentAvatar-CLWWNu_Q.js → AgentAvatar-Q1SmFRx2.js} +1 -1
  7. package/dist/assets/AssistantView-CwXkiCAd.js +3 -0
  8. package/dist/assets/{DiagramView-xuSJTnv7.js → DiagramView-BW92schO.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-Ov0D96d0.js → FadeInOnReveal-BZmwl-zE.js} +1 -1
  10. package/dist/assets/{Icon-DD-BuMfZ.js → Icon-AQPvxMiU.js} +1 -1
  11. package/dist/assets/{MarkdownRenderer-bc_p1H7l.js → MarkdownRenderer-Bv05iDZZ.js} +2 -2
  12. package/dist/assets/{MarkdownRendererImpl-SKi4oG0c.js → MarkdownRendererImpl-C81xqYib.js} +3 -3
  13. package/dist/assets/MarkstreamRendererImpl-CXUCQuKJ.js +3 -0
  14. package/dist/assets/{MobileDetailNavigation-Bnx59U_J.js → MobileDetailNavigation-BMgIu7zc.js} +1 -1
  15. package/dist/assets/{MobileShareBridge-BQlP4J-F.js → MobileShareBridge-BOXGNHmC.js} +2 -2
  16. package/dist/assets/MobileSurface-xuc_z0_G.js +2 -0
  17. package/dist/assets/{MultiRunWindow-ChZGob0v.js → MultiRunWindow-B5At2Z95.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-DtQJxZG-.js → OnboardingScreen-bsPLgz4r.js} +1 -1
  19. package/dist/assets/OverlayScrollbar-4wp4migh.js +2 -0
  20. package/dist/assets/SettingsView-CJ6tcH5_.js +25 -0
  21. package/dist/assets/{SettingsWindow-DfnsFa8y.js → SettingsWindow-CMtX2bMl.js} +1 -1
  22. package/dist/assets/{TerminalView-Dh9MWCpS.js → TerminalView-Dvr-ysTS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-BejmjrRk.js → ToolOutputDialog-uH1ZZDSS.js} +2 -2
  24. package/dist/assets/appThemeRegistry-DK7q2juD.js +1 -0
  25. package/dist/assets/{assistant-attachment-cache-C0aRnFkj.js → assistant-attachment-cache-DphJleHr.js} +1 -1
  26. package/dist/assets/{button-D-rATN7w.js → button-CcNJUxUd.js} +1 -1
  27. package/dist/assets/collapsible-ZmZ6rLWs.js +3 -0
  28. package/dist/assets/context-menu-B2LXJgXL.js +1 -0
  29. package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-C6HrP0jq.js} +1 -1
  30. package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-DdVBp45U.js} +1 -1
  31. package/dist/assets/{diagram-DQf7Y_uR.js → diagram-CxTs9hCQ.js} +1 -1
  32. package/dist/assets/{dialog-bfbvR4VH.js → dialog-KMVL9QKF.js} +1 -1
  33. package/dist/assets/es-CN3E_zXj.js +15 -0
  34. package/dist/assets/{fileMentionTouchSelection-BvrNCroG.js → fileMentionTouchSelection-7RIwlpkB.js} +1 -1
  35. package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-CjdBRy8y.js} +1 -1
  36. package/dist/assets/fr-9zZvQZD2.js +13 -0
  37. package/dist/assets/gitApi-yuWg9BN4.js +10 -0
  38. package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-9vQBq3zp.js} +1 -1
  39. package/dist/assets/{input-8r6F_Fza.js → input-CrIvk3HZ.js} +1 -1
  40. package/dist/assets/insertionBoundaries-BiUCSkx-.js +1 -0
  41. package/dist/assets/ja-Dnjk_82N.js +15 -0
  42. package/dist/assets/ko-BxCEE99C.js +15 -0
  43. package/dist/assets/{main-CCE_4y5F.js → main-C9mIBo77.js} +2 -2
  44. package/dist/assets/main-DiL6w6KF.js +169 -0
  45. package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
  46. package/dist/assets/{markdownHeightCache-1Qo6y6IJ.js → markdownHeightCache-BmXVY_gc.js} +1 -1
  47. package/dist/assets/{markdownTheme-DOfGg-A7.js → markdownTheme-CKac3wKC.js} +1 -1
  48. package/dist/assets/mcpQueries-C7jknHV8.js +1 -0
  49. package/dist/assets/miniChat-CwtUaElU.js +2 -0
  50. package/dist/assets/mobile-DB-6--ws.js +2 -0
  51. package/dist/assets/multirun-TAsxFI8e.js +4 -0
  52. package/dist/assets/{persistence-Ce3AnpEr.js → persistence-56kq8Z4V.js} +2 -2
  53. package/dist/assets/pl-CNNsWnPM.js +15 -0
  54. package/dist/assets/platform-C13fMVEs.js +6 -0
  55. package/dist/assets/pt-BR-BZB2DJMp.js +15 -0
  56. package/dist/assets/{relay-image-stream-DM7atqJs.js → relay-image-stream-DrdVj99c.js} +1 -1
  57. package/dist/assets/renderElectronMiniChatApp-CGj0MNRU.js +2 -0
  58. package/dist/assets/renderMobileApp-CGMMqbsO.js +28 -0
  59. package/dist/assets/runtime-fetch-Fs9ZV2V8.js +1 -0
  60. package/dist/assets/{runtimeConfig-BMvqa5we.js → runtimeConfig-BEANkvOd.js} +1 -1
  61. package/dist/assets/runtimeEndpointReset-A_ZBUJFS.js +147 -0
  62. package/dist/assets/sessionChangeSummary-qOLrP9fw.js +2 -0
  63. package/dist/assets/{sessionLookup-gh-hVRIc.js → sessionLookup-34XFmP9C.js} +1 -1
  64. package/dist/assets/{sortable-tabs-strip-CudoSgV4.js → sortable-tabs-strip-DyEV0Heg.js} +1 -1
  65. package/dist/assets/src-ubCWXZhR.css +2 -0
  66. package/dist/assets/store-DLCiZaA6.js +15 -0
  67. package/dist/assets/{terminalApi-DiF6JmBP.js → terminalApi-CXaBEoa_.js} +1 -1
  68. package/dist/assets/textarea-BBOlRI9O.js +1 -0
  69. package/dist/assets/{tooltip-CQvpK-UV.js → tooltip-CPI4fr1a.js} +1 -1
  70. package/dist/assets/uk-rwQrxeYM.js +15 -0
  71. package/dist/assets/{url-_z7evzdK.js → url-DRDQXjyX.js} +1 -1
  72. package/dist/assets/{useAppFontEffects-Cpmpo7Wb.js → useAppFontEffects-CiDe42I5.js} +57 -43
  73. package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-asNG6K7n.js} +1 -1
  74. package/dist/assets/useDesktopWindowControlsLayout-BlizAlOt.js +1 -0
  75. package/dist/assets/useEffectiveDirectory-C_CouL5v.js +1 -0
  76. package/dist/assets/{useFontPreferences-DcbXQOkc.js → useFontPreferences-B0oUZAiB.js} +1 -1
  77. package/dist/assets/useMobileNavigationStore-DfPClaW-.js +414 -0
  78. package/dist/assets/{useRuntimeAPIs-DSLXJybW.js → useRuntimeAPIs-DwLtO0c_.js} +1 -1
  79. package/dist/assets/useSessionAutoCleanup-DtrfbxAp.js +2 -0
  80. package/dist/assets/{useThemeSystem-DMzWVJaW.js → useThemeSystem-BlNUWH47.js} +1 -1
  81. package/dist/assets/useUIStore-CQYKJnrg.js +1 -0
  82. package/dist/assets/{useWorkerHighlightedLines-Bqn4AiBL.js → useWorkerHighlightedLines-CPNYz6Wf.js} +2 -2
  83. package/dist/assets/{vendor-base-ui-CnHfUmDN.js → vendor-base-ui-CDjyofeD.js} +1 -1
  84. package/dist/assets/{vendor-cmdk-CeAGfrLo.js → vendor-cmdk-BFH0UpXX.js} +1 -1
  85. package/dist/assets/{vendor-dnd-kit-accessibility-BY5LhhUB.js → vendor-dnd-kit-accessibility-BrR3AKvM.js} +1 -1
  86. package/dist/assets/{vendor-dnd-kit-core-CvWxUPdb.js → vendor-dnd-kit-core-CNfdvk6H.js} +1 -1
  87. package/dist/assets/{vendor-dnd-kit-sortable-hHdP9lLb.js → vendor-dnd-kit-sortable-DQUaTeGj.js} +1 -1
  88. package/dist/assets/{vendor-framer-motion-DFy_BSVd.js → vendor-framer-motion-4dogsUk3.js} +1 -1
  89. package/dist/assets/{vendor-legendapp-list-WE32lwNk.js → vendor-legendapp-list-BU7MdPZR.js} +1 -1
  90. package/dist/assets/{vendor-markstream-react-DX55XLGO.js → vendor-markstream-react-qHC2FykU.js} +3 -3
  91. package/dist/assets/{vendor-pierre-diffs-DX7XTPG3.js → vendor-pierre-diffs-CUfqC003.js} +1 -1
  92. package/dist/assets/{vendor-react-DLPElpoQ.js → vendor-react-C94A0AHl.js} +1 -1
  93. package/dist/assets/{vendor-react-drawio-ECtEwvbc.js → vendor-react-drawio-FVlWqKVE.js} +1 -1
  94. package/dist/assets/{vendor-reactuses-core-BnW6GxJt.js → vendor-reactuses-core-C2A3EjUi.js} +1 -1
  95. package/dist/assets/{vendor-sonner-DVzioF3d.js → vendor-sonner-DNqHmUQs.js} +1 -1
  96. package/dist/assets/{vendor-stream-diffs-DWy3xSmp.js → vendor-stream-diffs-CwbKsmlv.js} +2 -2
  97. package/dist/assets/{vendor-tanstack-react-query-D7pys_c9.js → vendor-tanstack-react-query-D0iFK57C.js} +1 -1
  98. package/dist/assets/{vendor-tanstack-react-virtual-tlDgH6PG.js → vendor-tanstack-react-virtual-B2kV6cnT.js} +1 -1
  99. package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-BqLKf6ZY.js} +1 -1
  100. package/dist/assets/zh-CN-BvmBDnkD.js +15 -0
  101. package/dist/assets/zh-TW-9wcHTX0y.js +15 -0
  102. package/dist/index.html +13 -13
  103. package/dist/mini-chat.html +13 -13
  104. package/dist/mobile.html +13 -13
  105. package/package.json +1 -1
  106. package/server/index.js +327 -62
  107. package/server/lib/assistants/DOCUMENTATION.md +14 -9
  108. package/server/lib/assistants/contracts.js +11 -1
  109. package/server/lib/assistants/projection-history.test.js +265 -18
  110. package/server/lib/assistants/routes.js +6 -0
  111. package/server/lib/assistants/service.js +417 -231
  112. package/server/lib/assistants/service.test.js +392 -148
  113. package/server/lib/conversations/DOCUMENTATION.md +1 -1
  114. package/server/lib/conversations/service.js +1 -0
  115. package/server/lib/conversations/service.test.js +16 -0
  116. package/server/lib/conversations/validation.js +11 -1
  117. package/server/lib/event-stream/DOCUMENTATION.md +3 -1
  118. package/server/lib/event-stream/directory-ws-bridge.js +13 -2
  119. package/server/lib/event-stream/global-hub.js +49 -4
  120. package/server/lib/event-stream/reasoning-projection.js +16 -1
  121. package/server/lib/event-stream/reasoning-projection.test.js +15 -0
  122. package/server/lib/event-stream/runtime.js +57 -20
  123. package/server/lib/event-stream/runtime.test.js +112 -0
  124. package/server/lib/llm/DOCUMENTATION.md +33 -9
  125. package/server/lib/llm/catalog.js +1 -1
  126. package/server/lib/llm/completions.js +5 -1
  127. package/server/lib/llm/completions.test.js +3 -3
  128. package/server/lib/llm/generate.js +237 -145
  129. package/server/lib/llm/generate.test.js +201 -46
  130. package/server/lib/llm/routes.js +2 -0
  131. package/server/lib/message-queue/runtime.js +15 -2
  132. package/server/lib/notifications/runtime.js +23 -5
  133. package/server/lib/notifications/runtime.test.js +11 -0
  134. package/server/lib/opencode/DOCUMENTATION.md +85 -17
  135. package/server/lib/opencode/agent-document.d.ts +28 -0
  136. package/server/lib/opencode/agent-document.js +280 -0
  137. package/server/lib/opencode/agent-document.test.js +137 -0
  138. package/server/lib/opencode/agents.js +96 -0
  139. package/server/lib/opencode/auth-state-runtime.js +5 -0
  140. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  141. package/server/lib/opencode/config-entity-routes.js +42 -99
  142. package/server/lib/opencode/config-entity-routes.test.js +107 -0
  143. package/server/lib/opencode/config-hot-reload.integration.test.js +105 -0
  144. package/server/lib/opencode/ensure-cli.js +1 -2
  145. package/server/lib/opencode/env-runtime.js +40 -26
  146. package/server/lib/opencode/env-runtime.test.js +10 -17
  147. package/server/lib/opencode/feature-routes-runtime.js +43 -4
  148. package/server/lib/opencode/feature-routes-runtime.test.js +18 -0
  149. package/server/lib/opencode/http-server-connections.js +98 -0
  150. package/server/lib/opencode/http-server-connections.test.js +321 -0
  151. package/server/lib/opencode/lifecycle.js +349 -39
  152. package/server/lib/opencode/lifecycle.test.js +322 -0
  153. package/server/lib/opencode/managed-capabilities-runtime.js +15 -2
  154. package/server/lib/opencode/managed-capabilities-runtime.test.js +1 -0
  155. package/server/lib/opencode/openchamber-routes.js +7 -1
  156. package/server/lib/opencode/opencode2-pin.js +4 -4
  157. package/server/lib/opencode/opencode2-pin.test.js +1 -0
  158. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +127 -4
  159. package/server/lib/opencode/owned-runtime-upgrade.js +431 -0
  160. package/server/lib/opencode/owned-runtime-upgrade.test.js +160 -0
  161. package/server/lib/opencode/plugin-routes.js +3 -18
  162. package/server/lib/opencode/plugin-routes.test.js +9 -9
  163. package/server/lib/opencode/provider-catalog.js +58 -12
  164. package/server/lib/opencode/provider-catalog.test.js +48 -0
  165. package/server/lib/opencode/proxy.js +252 -46
  166. package/server/lib/opencode/proxy.test.js +235 -2
  167. package/server/lib/opencode/routes.js +296 -62
  168. package/server/lib/opencode/routes.test.js +278 -12
  169. package/server/lib/opencode/runtime-contract.js +401 -0
  170. package/server/lib/opencode/runtime-contract.test.js +214 -0
  171. package/server/lib/opencode/server-opencode-fetch.js +242 -0
  172. package/server/lib/opencode/server-opencode-fetch.test.js +130 -0
  173. package/server/lib/opencode/server-utils-runtime.js +33 -0
  174. package/server/lib/opencode/shared-service.integration.test.js +66 -0
  175. package/server/lib/opencode/shared-service.js +147 -0
  176. package/server/lib/opencode/shared-service.test.js +93 -0
  177. package/server/lib/opencode/shared.js +3 -3
  178. package/server/lib/opencode/shutdown-runtime.js +15 -6
  179. package/server/lib/opencode/shutdown-runtime.test.js +49 -1
  180. package/server/lib/opencode/skill-routes.js +20 -33
  181. package/server/lib/opencode/skill-routes.test.js +35 -2
  182. package/server/lib/opencode/skills.test.js +15 -0
  183. package/server/lib/opencode/startup-pipeline-runtime.js +4 -0
  184. package/server/lib/opencode/static-routes-runtime.js +3 -1
  185. package/server/lib/opencode/static-routes-runtime.test.js +33 -0
  186. package/server/lib/opencode/v2-client.js +14 -3
  187. package/server/lib/opencode/v2-plugin-host-shim/index.mjs +155 -0
  188. package/server/lib/opencode/v2-plugin-host-shim/package.json +4 -0
  189. package/server/lib/permission-auto-accept/DOCUMENTATION.md +5 -5
  190. package/server/lib/permission-auto-accept/runtime.js +14 -7
  191. package/server/lib/permission-auto-accept/runtime.test.js +27 -12
  192. package/server/lib/relay/reasoning-projection.e2e.test.ts +22 -7
  193. package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -0
  194. package/server/lib/scheduled-tasks/runtime.js +52 -4
  195. package/server/lib/scheduled-tasks/runtime.test.js +80 -1
  196. package/server/lib/session-goal/DOCUMENTATION.md +113 -13
  197. package/server/lib/session-goal/routes.js +9 -0
  198. package/server/lib/session-goal/runtime.js +1579 -117
  199. package/server/lib/session-goal/runtime.test.js +1453 -84
  200. package/server/lib/session-index/DOCUMENTATION.md +18 -15
  201. package/server/lib/session-index/event-ingest.js +89 -16
  202. package/server/lib/session-index/event-ingest.test.js +73 -0
  203. package/server/lib/session-index/host-authority-snapshot.js +92 -0
  204. package/server/lib/session-index/routes.host-archive.test.js +201 -0
  205. package/server/lib/session-index/routes.js +63 -10
  206. package/server/lib/session-index/routes.test.js +8 -8
  207. package/server/lib/session-index/service.js +7 -1
  208. package/server/lib/session-index/sync-runtime.js +161 -34
  209. package/server/lib/session-index/sync-runtime.test.js +155 -1
  210. package/server/lib/session-metadata/DOCUMENTATION.md +61 -16
  211. package/server/lib/session-metadata/routes.js +108 -9
  212. package/server/lib/session-metadata/routes.test.js +47 -2
  213. package/server/lib/session-metadata/session-archive.d.ts +73 -0
  214. package/server/lib/session-metadata/session-archive.js +654 -0
  215. package/server/lib/session-metadata/session-archive.test.js +359 -0
  216. package/server/lib/session-metadata/session-metadata-store.d.ts +36 -0
  217. package/server/lib/session-metadata/session-metadata-store.js +212 -72
  218. package/server/lib/session-metadata/session-metadata-store.test.js +205 -10
  219. package/server/lib/session-metadata/session-projection.d.ts +41 -0
  220. package/server/lib/session-metadata/session-projection.js +255 -0
  221. package/server/lib/session-metadata/session-projection.test.js +64 -0
  222. package/server/lib/session-metadata/system-session.js +55 -0
  223. package/server/lib/session-metadata/system-session.test.js +55 -0
  224. package/server/lib/session-title/DOCUMENTATION.md +40 -7
  225. package/server/lib/session-title/native-runtime.test.js +167 -0
  226. package/server/lib/session-title/runtime.js +67 -42
  227. package/server/lib/session-title/session-access.js +46 -0
  228. package/server/lib/session-title/session-access.test.js +91 -0
  229. package/server/lib/session-turn-pages/DOCUMENTATION.md +9 -8
  230. package/server/lib/session-turn-pages/routes.js +17 -102
  231. package/server/lib/session-turn-pages/routes.test.js +281 -0
  232. package/server/lib/session-turn-pages/session-message-projection.d.ts +13 -0
  233. package/server/lib/session-turn-pages/session-message-projection.js +281 -0
  234. package/server/lib/small-model/DOCUMENTATION.md +101 -147
  235. package/server/lib/small-model/catalog.js +31 -51
  236. package/server/lib/small-model/catalog.test.js +65 -10
  237. package/server/lib/small-model/custom-api.js +68 -0
  238. package/server/lib/small-model/custom-api.test.js +64 -0
  239. package/server/lib/small-model/index.js +124 -240
  240. package/server/lib/small-model/resolve.js +35 -120
  241. package/server/lib/small-model/resolve.test.js +20 -110
  242. package/server/lib/small-model/routes.js +1 -3
  243. package/server/lib/small-model/summary-settings.test.js +157 -253
  244. package/server/opencode-proxy.test.js +2 -0
  245. package/dist/assets/AssistantView-BTCkm8yk.js +0 -3
  246. package/dist/assets/MarkstreamRendererImpl-CnkodGJW.js +0 -3
  247. package/dist/assets/MobileSurface-BXLeiZ4d.js +0 -2
  248. package/dist/assets/OverlayScrollbar-pMwxaCca.js +0 -2
  249. package/dist/assets/SettingsView-B5I0KDTZ.js +0 -25
  250. package/dist/assets/appThemeRegistry-COGfxTFV.js +0 -1
  251. package/dist/assets/collapsible-BXRd5_Hr.js +0 -1
  252. package/dist/assets/context-menu-jGtH6fR9.js +0 -1
  253. package/dist/assets/es-c52LTHHe.js +0 -15
  254. package/dist/assets/fr-PisFbUE8.js +0 -13
  255. package/dist/assets/gitApi-ButwzQ0N.js +0 -9
  256. package/dist/assets/insertionBoundaries-DGs7S5zv.js +0 -1
  257. package/dist/assets/ja-C8Ikun3Z.js +0 -15
  258. package/dist/assets/ko-ClFys5o2.js +0 -15
  259. package/dist/assets/main-Dn9rpsgl.js +0 -169
  260. package/dist/assets/markdown-worker-CptoICmq.js +0 -2
  261. package/dist/assets/miniChat-BpjxVqgg.js +0 -2
  262. package/dist/assets/mobile-nMJCaNq1.js +0 -2
  263. package/dist/assets/multirun-BvSG94mW.js +0 -4
  264. package/dist/assets/pl-CnaKWN5F.js +0 -15
  265. package/dist/assets/platform-CWTIp02q.js +0 -4
  266. package/dist/assets/projectMeta-DOe49dM_.js +0 -3
  267. package/dist/assets/pt-BR-s8OpfRI6.js +0 -15
  268. package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +0 -2
  269. package/dist/assets/renderMobileApp-y-S8y8v_.js +0 -28
  270. package/dist/assets/runtime-fetch-zIalUnbb.js +0 -1
  271. package/dist/assets/runtimeEndpointReset-B6BxhvR0.js +0 -147
  272. package/dist/assets/sessionChangeSummary-VVBlEXuh.js +0 -2
  273. package/dist/assets/src-BzX5UI-c.css +0 -2
  274. package/dist/assets/store-DJczmczv.js +0 -15
  275. package/dist/assets/textarea-3XR8sUfy.js +0 -1
  276. package/dist/assets/uk-CaX0iqkB.js +0 -15
  277. package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +0 -1
  278. package/dist/assets/useEffectiveDirectory-CN6JoCFw.js +0 -1
  279. package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +0 -414
  280. package/dist/assets/useSessionAutoCleanup-BLjymaFt.js +0 -2
  281. package/dist/assets/useUIStore-Bxv3f23A.js +0 -1
  282. package/dist/assets/zh-CN-20MYEjha.js +0 -15
  283. package/dist/assets/zh-TW-DiN04qTw.js +0 -15
  284. package/server/lib/small-model/call.js +0 -427
  285. package/server/lib/small-model/call.test.js +0 -382
  286. package/server/lib/small-model/opencode-session.js +0 -183
  287. package/server/lib/small-model/opencode-session.test.js +0 -147
@@ -11,12 +11,18 @@
11
11
  * a `null` deletes. That is what the old PATCH did, and it is what keeps two
12
12
  * features writing into the same `openchamber` namespace from erasing each
13
13
  * other — goal mode saving progress must not drop an assist recap.
14
+ *
15
+ * Mutations are fully serialized. Committed `entries` update only after a
16
+ * successful persist so concurrent readers never observe uncommitted drafts.
17
+ * A failed or corrupt file read is never empty success: the store stays
18
+ * unwritable and reports the error instead of overwriting unknown disk state.
14
19
  */
15
20
 
16
21
  import fsDefault from 'node:fs';
17
22
  import pathDefault from 'node:path';
18
23
 
19
24
  const METADATA_FILE_NAME = 'sessions-metadata.json';
25
+ const HOST_ARCHIVE_KEY = 'archive';
20
26
 
21
27
  const asNonEmptyString = (value) => {
22
28
  if (typeof value !== 'string') return null;
@@ -43,6 +49,54 @@ export const mergeMetadataPatch = (current, patch) => {
43
49
  return base;
44
50
  };
45
51
 
52
+ const readPriorArchivedAt = (metadata) => {
53
+ if (!isPlainObject(metadata?.openchamber)) return null;
54
+ if (!Object.prototype.hasOwnProperty.call(metadata.openchamber, HOST_ARCHIVE_KEY)) return null;
55
+ const archive = metadata.openchamber[HOST_ARCHIVE_KEY];
56
+ if (!isPlainObject(archive) || !Object.prototype.hasOwnProperty.call(archive, 'archivedAt')) return null;
57
+ const value = archive.archivedAt;
58
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
59
+ };
60
+
61
+ /** Strip Host archive writes from generic metadata patches. */
62
+ const stripArchiveFromMetadataPatch = (patch) => {
63
+ if (!isPlainObject(patch) || !isPlainObject(patch.openchamber)) return patch;
64
+ if (!Object.prototype.hasOwnProperty.call(patch.openchamber, HOST_ARCHIVE_KEY)) return patch;
65
+ const openchamber = { ...patch.openchamber };
66
+ delete openchamber[HOST_ARCHIVE_KEY];
67
+ return { ...patch, openchamber };
68
+ };
69
+
70
+ /**
71
+ * Restore reserved archive after a generic merge so null ancestor deletes
72
+ * (`openchamber: null` / `archive: null`) cannot wipe Host archive state.
73
+ */
74
+ const applyReservedArchiveProtection = (previous, patch, merged, { allowArchive = false } = {}) => {
75
+ if (allowArchive) return merged;
76
+ const priorArchive = readPriorArchivedAt(previous);
77
+ const result = isPlainObject(merged) ? { ...merged } : {};
78
+ const openchamber = isPlainObject(result.openchamber) ? { ...result.openchamber } : {};
79
+
80
+ if (isPlainObject(patch?.openchamber) && Object.prototype.hasOwnProperty.call(patch.openchamber, HOST_ARCHIVE_KEY)) {
81
+ delete openchamber[HOST_ARCHIVE_KEY];
82
+ }
83
+
84
+ if (priorArchive !== null) {
85
+ openchamber[HOST_ARCHIVE_KEY] = {
86
+ ...(isPlainObject(previous?.openchamber?.[HOST_ARCHIVE_KEY])
87
+ ? previous.openchamber[HOST_ARCHIVE_KEY]
88
+ : {}),
89
+ archivedAt: priorArchive,
90
+ };
91
+ } else {
92
+ delete openchamber[HOST_ARCHIVE_KEY];
93
+ }
94
+
95
+ if (Object.keys(openchamber).length > 0) result.openchamber = openchamber;
96
+ else delete result.openchamber;
97
+ return result;
98
+ };
99
+
46
100
  /**
47
101
  * @param {object} options
48
102
  * @param {string} options.dataDir OpenChamber data directory for this instance.
@@ -58,19 +112,26 @@ export const createSessionMetadataStore = ({
58
112
  }) => {
59
113
  const filePath = path.join(dataDir, METADATA_FILE_NAME);
60
114
 
61
- /** sessionID → metadata object. Authoritative once `loaded` is true. */
115
+ /** sessionID → metadata. Committed only — never holds an in-flight draft. */
62
116
  const entries = new Map();
63
117
  let loaded = false;
64
118
  let loadPromise = null;
65
119
  /**
66
120
  * Writes stay disabled until one load has told us what is already on disk.
67
- * A failed read is not evidence that nothing is stored, and writing over a
68
- * file we could not read would drop exactly the state we are protecting.
121
+ * A failed/corrupt read is not evidence that nothing is stored.
69
122
  */
70
123
  let writable = false;
71
- let writeChain = Promise.resolve();
124
+ let loadFailureReason = null;
125
+ /** Full RMW + persist chain — concurrent writers cannot interleave merges. */
126
+ let mutationChain = Promise.resolve();
72
127
 
73
- const snapshot = () => Object.fromEntries(entries);
128
+ const snapshotFrom = (map) => Object.fromEntries(map);
129
+ const snapshot = () => snapshotFrom(entries);
130
+
131
+ const unavailableError = (detail = loadFailureReason) => {
132
+ const suffix = detail ? `: ${detail}` : ': its file could not be read';
133
+ return new Error(`session metadata is unavailable${suffix.startsWith(':') ? suffix : `: ${suffix}`}`);
134
+ };
74
135
 
75
136
  const parseStored = (raw) => {
76
137
  const parsed = JSON.parse(raw);
@@ -80,8 +141,6 @@ export const createSessionMetadataStore = ({
80
141
  const result = new Map();
81
142
  for (const [sessionID, value] of Object.entries(parsed)) {
82
143
  const id = asNonEmptyString(sessionID);
83
- // A non-object entry is not metadata; dropping it is better than handing
84
- // a consumer something it will read fields off.
85
144
  if (id && isPlainObject(value)) result.set(id, value);
86
145
  }
87
146
  return result;
@@ -92,25 +151,20 @@ export const createSessionMetadataStore = ({
92
151
  try {
93
152
  raw = await fsPromises.readFile(filePath, 'utf8');
94
153
  } catch (error) {
95
- // No file yet is the normal first run: nothing is stored, and writing is
96
- // safe because there is no state to lose.
97
154
  if (error?.code === 'ENOENT') return { ok: true, stored: new Map() };
98
155
  console.warn('[session-metadata] could not read the session metadata file:', error?.message ?? error);
99
- return { ok: false, stored: null };
156
+ return { ok: false, stored: null, reason: error?.message || 'read failed' };
100
157
  }
101
158
 
102
159
  try {
103
160
  return { ok: true, stored: parseStored(raw) };
104
161
  } catch (error) {
105
- // Malformed bytes are kept for the user instead of being overwritten on
106
- // the next write, and whatever this process already knows stays in memory
107
- // rather than collapsing to "no metadata".
108
- const backup = `${filePath}.corrupt-${now()}`;
109
- await fsPromises.rename(filePath, backup).catch(() => undefined);
162
+ // Corrupt bytes must not become empty success. Keep the file, stay
163
+ // unwritable, and surface the error so callers can report/retry.
110
164
  console.warn(
111
- `[session-metadata] session metadata file was unreadable and was moved to ${backup}: ${error?.message ?? error}`,
165
+ `[session-metadata] session metadata file is corrupt and will not be overwritten: ${error?.message ?? error}`,
112
166
  );
113
- return { ok: true, stored: new Map() };
167
+ return { ok: false, stored: null, reason: error?.message || 'corrupt metadata file' };
114
168
  }
115
169
  };
116
170
 
@@ -118,117 +172,203 @@ export const createSessionMetadataStore = ({
118
172
  if (!loadPromise) {
119
173
  loadPromise = readFile().then((result) => {
120
174
  if (result.ok) {
121
- // Merge rather than replace: a write that happened while the first
122
- // load was still running must survive it.
123
175
  for (const [id, metadata] of result.stored) {
124
176
  if (!entries.has(id)) entries.set(id, metadata);
125
177
  }
126
178
  loaded = true;
127
179
  writable = true;
180
+ loadFailureReason = null;
128
181
  } else {
129
- // Let a later call retry; until one succeeds the store answers reads
130
- // from memory and refuses writes.
131
182
  loadPromise = null;
183
+ loaded = false;
184
+ writable = false;
185
+ loadFailureReason = result.reason || 'its file could not be read';
132
186
  }
133
- return { ok: result.ok, entries: snapshot() };
187
+ return { ok: result.ok, entries: snapshot(), reason: result.reason || null };
134
188
  });
135
189
  }
136
190
  return loadPromise;
137
191
  };
138
192
 
139
- const persist = () => {
140
- const payload = JSON.stringify(snapshot());
141
- const write = async () => {
142
- await fsPromises.mkdir(dataDir, { recursive: true });
143
- // Temp file in the same directory so the rename is atomic on one device:
144
- // a reader sees either the previous file or the complete new one.
145
- const tmpPath = `${filePath}.${process.pid}.tmp`;
146
- await fsPromises.writeFile(tmpPath, payload, 'utf8');
147
- await fsPromises.rename(tmpPath, filePath);
148
- };
149
- // Serialized so two overlapping writes cannot interleave their renames.
150
- const next = writeChain.then(write, write);
151
- writeChain = next.catch(() => undefined);
193
+ const ensureReadable = async () => {
194
+ const result = await load();
195
+ if (!result.ok || !writable) throw unavailableError(result.reason);
196
+ return result;
197
+ };
198
+
199
+ const persistMap = async (map) => {
200
+ const payload = JSON.stringify(snapshotFrom(map));
201
+ await fsPromises.mkdir(dataDir, { recursive: true });
202
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
203
+ await fsPromises.writeFile(tmpPath, payload, 'utf8');
204
+ await fsPromises.rename(tmpPath, filePath);
205
+ };
206
+
207
+ /**
208
+ * @template T
209
+ * @param {() => Promise<T>} work
210
+ * @returns {Promise<T>}
211
+ */
212
+ const runExclusive = (work) => {
213
+ const next = mutationChain.then(work, work);
214
+ mutationChain = next.then(
215
+ () => undefined,
216
+ () => undefined,
217
+ );
152
218
  return next;
153
219
  };
154
220
 
155
221
  const get = async (sessionID) => {
156
222
  const id = asNonEmptyString(sessionID);
157
223
  if (!id) return {};
158
- await load();
224
+ await ensureReadable();
159
225
  return entries.get(id) ?? {};
160
226
  };
161
227
 
162
- /** Whether the store holds anything for the session (an empty object counts as nothing). */
163
228
  const has = async (sessionID) => {
164
229
  const id = asNonEmptyString(sessionID);
165
230
  if (!id) return false;
166
- await load();
231
+ await ensureReadable();
167
232
  return entries.has(id);
168
233
  };
169
234
 
235
+ /**
236
+ * Shared exclusive commit path: merge → persist draft → publish committed.
237
+ * Readers never observe the draft; persist failure leaves prior committed state.
238
+ *
239
+ * @param {string} id
240
+ * @param {object | undefined} previous
241
+ * @param {object} patch
242
+ * @param {{ allowArchive?: boolean }} options
243
+ */
244
+ const commitMergedPatch = async (id, previous, patch, options = {}) => {
245
+ const allowArchive = options.allowArchive === true;
246
+ const effectivePatch = allowArchive ? patch : stripArchiveFromMetadataPatch(patch);
247
+
248
+ let merged = mergeMetadataPatch(previous, effectivePatch);
249
+ merged = applyReservedArchiveProtection(previous, patch, merged, { allowArchive });
250
+
251
+ const nextMap = new Map(entries);
252
+ if (Object.keys(merged).length === 0) nextMap.delete(id);
253
+ else nextMap.set(id, merged);
254
+
255
+ // Persist draft snapshot first; publish to committed entries only on success.
256
+ await persistMap(nextMap);
257
+
258
+ if (Object.keys(merged).length === 0) entries.delete(id);
259
+ else entries.set(id, merged);
260
+
261
+ return merged;
262
+ };
263
+
170
264
  /**
171
265
  * Applies a merge patch and returns the session's full metadata afterwards.
172
- * A failed write rolls the memory back and throws, so a caller never believes
173
- * it saved something it did not.
266
+ * Committed map updates only after persist succeeds — readers never see drafts.
267
+ *
268
+ * @param {string} sessionID
269
+ * @param {object} patch
270
+ * @param {{ allowArchive?: boolean }} [options]
174
271
  */
175
- const setSessionMetadata = async (sessionID, patch) => {
272
+ const setSessionMetadata = async (sessionID, patch, options = {}) => {
176
273
  const id = asNonEmptyString(sessionID);
177
274
  if (!id) throw new Error('a session id is required to store session metadata');
178
275
  if (!isPlainObject(patch)) throw new Error('a session metadata patch must be an object');
179
276
 
180
- const loadResult = await load();
181
- if (!loadResult.ok || !writable) {
182
- throw new Error('session metadata is unavailable: its file could not be read');
183
- }
277
+ return runExclusive(async () => {
278
+ await ensureReadable();
279
+ return commitMergedPatch(id, entries.get(id), patch, options);
280
+ });
281
+ };
184
282
 
185
- const had = entries.has(id);
186
- const previous = entries.get(id);
187
- const merged = mergeMetadataPatch(previous, patch);
188
- if (Object.keys(merged).length === 0) entries.delete(id);
189
- else entries.set(id, merged);
283
+ /**
284
+ * Conditional metadata mutation on the same exclusive serial boundary as
285
+ * setSessionMetadata. `decide(committedMetadata)` runs while the lock is held
286
+ * and must return synchronously:
287
+ * - `{ ok: false, reason? }` — no persist; readers keep prior committed state
288
+ * - `{ ok: true, patch }` — merge-patch, persist, then publish
289
+ *
290
+ * Use this for goal generation / status preconditions so a stale auditor
291
+ * cannot commit after a user pause that already landed.
292
+ *
293
+ * @param {string} sessionID
294
+ * @param {(current: object) => { ok: false, reason?: string } | { ok: true, patch: object }} decide
295
+ * @param {{ allowArchive?: boolean }} [options]
296
+ * @returns {Promise<{ committed: boolean, reason?: string, metadata: object }>}
297
+ */
298
+ const mutateSessionMetadata = async (sessionID, decide, options = {}) => {
299
+ const id = asNonEmptyString(sessionID);
300
+ if (!id) throw new Error('a session id is required to store session metadata');
301
+ if (typeof decide !== 'function') throw new Error('a decide function is required to mutate session metadata');
190
302
 
191
- try {
192
- await persist();
193
- } catch (error) {
194
- if (had) entries.set(id, previous);
195
- else entries.delete(id);
196
- throw error;
197
- }
303
+ return runExclusive(async () => {
304
+ await ensureReadable();
198
305
 
199
- return merged;
306
+ const previous = entries.get(id);
307
+ const current = previous ?? {};
308
+ const decision = decide(current);
309
+ if (!decision || decision.ok !== true) {
310
+ return {
311
+ committed: false,
312
+ reason: typeof decision?.reason === 'string' && decision.reason
313
+ ? decision.reason
314
+ : 'rejected',
315
+ metadata: current,
316
+ };
317
+ }
318
+ if (!isPlainObject(decision.patch)) {
319
+ throw new Error('a successful mutate decision must include an object patch');
320
+ }
321
+
322
+ const merged = await commitMergedPatch(id, previous, decision.patch, options);
323
+ return { committed: true, metadata: merged };
324
+ });
200
325
  };
201
326
 
327
+ /**
328
+ * Drop a session's metadata.
329
+ * @returns {Promise<boolean>} true if a row was removed; false if already absent
330
+ * (idempotent). Disk/write failures **throw** so callers can retry — never
331
+ * conflate not-found with write failure.
332
+ */
202
333
  const removeSession = async (sessionID) => {
203
334
  const id = asNonEmptyString(sessionID);
204
335
  if (!id) return false;
205
- await load();
206
- if (!writable || !entries.has(id)) return false;
207
- const previous = entries.get(id);
208
- entries.delete(id);
209
- try {
210
- await persist();
211
- } catch (error) {
212
- entries.set(id, previous);
213
- console.warn('[session-metadata] failed to drop session metadata:', error?.message ?? error);
214
- return false;
215
- }
216
- return true;
336
+
337
+ return runExclusive(async () => {
338
+ await ensureReadable();
339
+ if (!entries.has(id)) return false;
340
+
341
+ const nextMap = new Map(entries);
342
+ nextMap.delete(id);
343
+ // Write failures propagate — do not report as "already gone".
344
+ await persistMap(nextMap);
345
+ entries.delete(id);
346
+ return true;
347
+ });
217
348
  };
218
349
 
219
350
  const getAll = async () => {
220
- await load();
351
+ await ensureReadable();
221
352
  return snapshot();
222
353
  };
223
354
 
355
+ /**
356
+ * Synchronous committed snapshot for event hot paths.
357
+ * Returns `null` when not successfully loaded (unknown — never empty).
358
+ */
359
+ const getSnapshotSync = () => (loaded && writable ? snapshot() : null);
360
+
224
361
  return {
225
362
  load,
226
363
  get,
227
364
  getAll,
228
365
  list: getAll,
229
- isLoaded: () => loaded,
366
+ getSnapshotSync,
367
+ isLoaded: () => loaded && writable,
368
+ getLoadFailureReason: () => loadFailureReason,
230
369
  has,
231
370
  setSessionMetadata,
371
+ mutateSessionMetadata,
232
372
  removeSession,
233
373
  filePath,
234
374
  };
@@ -124,16 +124,18 @@ describe('createSessionMetadataStore', () => {
124
124
  expect(JSON.parse(readFile(dataDir))).toEqual({ ses_1: { a: 1 }, ses_2: { b: 2 } });
125
125
  });
126
126
 
127
- it('treats a malformed file as empty, keeps a backup, and still accepts writes', async () => {
127
+ it('refuses corrupt JSON as empty success and does not overwrite the file', async () => {
128
128
  const dataDir = makeDataDir();
129
- fs.writeFileSync(path.join(dataDir, 'sessions-metadata.json'), '{ not json', 'utf8');
129
+ const corruptPath = path.join(dataDir, 'sessions-metadata.json');
130
+ fs.writeFileSync(corruptPath, '{ not json', 'utf8');
130
131
  vi.spyOn(console, 'warn').mockImplementation(() => {});
131
132
 
132
133
  const store = createSessionMetadataStore({ dataDir });
133
- await expect(store.getAll()).resolves.toEqual({});
134
- await expect(store.setSessionMetadata('ses_1', { a: 1 })).resolves.toEqual({ a: 1 });
135
- expect(JSON.parse(readFile(dataDir))).toEqual({ ses_1: { a: 1 } });
136
- expect(fs.readdirSync(dataDir).some((name) => name.includes('sessions-metadata.json.'))).toBe(true);
134
+ await expect(store.getAll()).rejects.toThrow(/unavailable|corrupt/i);
135
+ await expect(store.setSessionMetadata('ses_1', { a: 1 })).rejects.toThrow(/unavailable|corrupt/i);
136
+ expect(fs.readFileSync(corruptPath, 'utf8')).toBe('{ not json');
137
+ expect(store.isLoaded()).toBe(false);
138
+ expect(store.getSnapshotSync()).toBeNull();
137
139
  });
138
140
 
139
141
  it('drops entries that are not metadata objects', async () => {
@@ -159,19 +161,72 @@ describe('createSessionMetadataStore', () => {
159
161
  vi.spyOn(console, 'warn').mockImplementation(() => {});
160
162
 
161
163
  const store = createSessionMetadataStore({ dataDir, fsPromises });
162
- await expect(store.setSessionMetadata('ses_1', { a: 1 })).rejects.toThrow(/could not be read/);
164
+ await expect(store.setSessionMetadata('ses_1', { a: 1 })).rejects.toThrow(/unavailable/);
163
165
  expect(readFile(dataDir)).toBe('{}');
164
166
  });
165
167
 
166
- it('rolls memory back and reports failure when persisting fails', async () => {
168
+ it('does not publish uncommitted drafts while persist is blocked (set)', async () => {
167
169
  const dataDir = makeDataDir();
168
- const fsPromises = { ...fs.promises, writeFile: async () => { throw new Error('disk full'); } };
170
+ let releaseWrite;
171
+ const writeGate = new Promise((resolve) => { releaseWrite = resolve; });
172
+ const fsPromises = {
173
+ ...fs.promises,
174
+ writeFile: async (target, payload, encoding) => {
175
+ await writeGate;
176
+ return fs.promises.writeFile(target, payload, encoding);
177
+ },
178
+ };
179
+ const store = createSessionMetadataStore({ dataDir, fsPromises });
180
+ const pending = store.setSessionMetadata('ses_1', { a: 1 });
181
+ // Concurrent readers must still see committed empty — not the in-flight draft.
182
+ await expect(store.get('ses_1')).resolves.toEqual({});
183
+ expect(store.getSnapshotSync()).toEqual({});
184
+ releaseWrite();
185
+ await expect(pending).resolves.toEqual({ a: 1 });
186
+ await expect(store.get('ses_1')).resolves.toEqual({ a: 1 });
187
+ });
188
+
189
+ it('does not publish uncommitted deletes while persist is blocked (remove)', async () => {
190
+ const dataDir = makeDataDir();
191
+ const storeWarm = createSessionMetadataStore({ dataDir });
192
+ await storeWarm.setSessionMetadata('ses_1', { a: 1 });
169
193
 
194
+ let releaseWrite = null;
195
+ const fsPromises = {
196
+ ...fs.promises,
197
+ writeFile: async (target, payload, encoding) => {
198
+ if (releaseWrite) await new Promise((resolve) => { releaseWrite = resolve; });
199
+ return fs.promises.writeFile(target, payload, encoding);
200
+ },
201
+ };
170
202
  const store = createSessionMetadataStore({ dataDir, fsPromises });
171
- await expect(store.setSessionMetadata('ses_1', { a: 1 })).rejects.toThrow('disk full');
203
+ await store.load();
204
+ // Arm the gate only for the remove persist.
205
+ releaseWrite = () => {};
206
+ let unlock;
207
+ const gate = new Promise((resolve) => { unlock = resolve; });
208
+ fsPromises.writeFile = async (target, payload, encoding) => {
209
+ await gate;
210
+ return fs.promises.writeFile(target, payload, encoding);
211
+ };
212
+ const pending = store.removeSession('ses_1');
213
+ await expect(store.get('ses_1')).resolves.toEqual({ a: 1 });
214
+ unlock();
215
+ await expect(pending).resolves.toBe(true);
172
216
  await expect(store.get('ses_1')).resolves.toEqual({});
173
217
  });
174
218
 
219
+ it('keeps committed entries when persist fails (set never published)', async () => {
220
+ const dataDir = makeDataDir();
221
+ const storeOk = createSessionMetadataStore({ dataDir });
222
+ await storeOk.setSessionMetadata('ses_1', { a: 1 });
223
+
224
+ const fsPromises = { ...fs.promises, writeFile: async () => { throw new Error('disk full'); } };
225
+ const store = createSessionMetadataStore({ dataDir, fsPromises });
226
+ await expect(store.setSessionMetadata('ses_1', { b: 2 })).rejects.toThrow('disk full');
227
+ await expect(store.get('ses_1')).resolves.toEqual({ a: 1 });
228
+ });
229
+
175
230
  it('forgets a session on request', async () => {
176
231
  const dataDir = makeDataDir();
177
232
  const store = createSessionMetadataStore({ dataDir });
@@ -181,4 +236,144 @@ describe('createSessionMetadataStore', () => {
181
236
  await expect(store.removeSession('ses_1')).resolves.toBe(false);
182
237
  expect(JSON.parse(readFile(dataDir))).toEqual({});
183
238
  });
239
+
240
+ it('serializes concurrent writes so neighboring keys are not lost', async () => {
241
+ const store = createSessionMetadataStore({ dataDir: makeDataDir() });
242
+ await Promise.all([
243
+ store.setSessionMetadata('ses_1', { openchamber: { goal: { id: 'g1' } } }),
244
+ store.setSessionMetadata('ses_1', { openchamber: { assist: { recap: 'a' } } }),
245
+ ]);
246
+ await expect(store.get('ses_1')).resolves.toEqual({
247
+ openchamber: { goal: { id: 'g1' }, assist: { recap: 'a' } },
248
+ });
249
+ });
250
+
251
+ it('protects Host archive from generic metadata null ancestor deletes', async () => {
252
+ const store = createSessionMetadataStore({ dataDir: makeDataDir() });
253
+ await store.setSessionMetadata('ses_1', { openchamber: { archive: { archivedAt: 9 } } }, { allowArchive: true });
254
+ await store.setSessionMetadata('ses_1', { openchamber: { goal: { id: 'g1' } } });
255
+ await expect(store.get('ses_1')).resolves.toEqual({
256
+ openchamber: { archive: { archivedAt: 9 }, goal: { id: 'g1' } },
257
+ });
258
+ await store.setSessionMetadata('ses_1', { openchamber: null });
259
+ await expect(store.get('ses_1')).resolves.toEqual({
260
+ openchamber: { archive: { archivedAt: 9 } },
261
+ });
262
+ await store.setSessionMetadata('ses_1', { openchamber: { archive: { archivedAt: 1 } } });
263
+ await expect(store.get('ses_1')).resolves.toEqual({
264
+ openchamber: { archive: { archivedAt: 9 } },
265
+ });
266
+ });
267
+
268
+ it('does not treat a failed load as empty success on get/getAll', async () => {
269
+ const dataDir = makeDataDir();
270
+ fs.writeFileSync(path.join(dataDir, 'sessions-metadata.json'), '{}', 'utf8');
271
+ const unreadable = Object.assign(new Error('EACCES'), { code: 'EACCES' });
272
+ const fsPromises = {
273
+ ...fs.promises,
274
+ readFile: async () => { throw unreadable; },
275
+ };
276
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
277
+ const store = createSessionMetadataStore({ dataDir, fsPromises });
278
+ await expect(store.get('ses_1')).rejects.toThrow(/unavailable|could not be read|EACCES/i);
279
+ await expect(store.getAll()).rejects.toThrow(/unavailable|could not be read|EACCES/i);
280
+ expect(store.getSnapshotSync()).toBeNull();
281
+ });
282
+
283
+ it('mutateSessionMetadata commits only when decide returns ok with a patch', async () => {
284
+ const store = createSessionMetadataStore({ dataDir: makeDataDir() });
285
+ await store.setSessionMetadata('ses_1', {
286
+ openchamber: { goal: { id: 'g1', status: 'active', executionGeneration: 2 }, assist: { recap: 'keep' } },
287
+ });
288
+
289
+ const rejected = await store.mutateSessionMetadata('ses_1', (current) => {
290
+ const gen = current?.openchamber?.goal?.executionGeneration;
291
+ if (gen !== 2) return { ok: false, reason: 'generation_mismatch' };
292
+ return {
293
+ ok: false,
294
+ reason: 'status_not_active',
295
+ };
296
+ });
297
+ expect(rejected).toEqual({
298
+ committed: false,
299
+ reason: 'status_not_active',
300
+ metadata: {
301
+ openchamber: { goal: { id: 'g1', status: 'active', executionGeneration: 2 }, assist: { recap: 'keep' } },
302
+ },
303
+ });
304
+ await expect(store.get('ses_1')).resolves.toEqual({
305
+ openchamber: { goal: { id: 'g1', status: 'active', executionGeneration: 2 }, assist: { recap: 'keep' } },
306
+ });
307
+
308
+ const accepted = await store.mutateSessionMetadata('ses_1', (current) => {
309
+ if (current?.openchamber?.goal?.executionGeneration !== 2) {
310
+ return { ok: false, reason: 'generation_mismatch' };
311
+ }
312
+ return {
313
+ ok: true,
314
+ patch: { openchamber: { goal: { status: 'paused', executionGeneration: 3 } } },
315
+ };
316
+ });
317
+ expect(accepted.committed).toBe(true);
318
+ expect(accepted.metadata.openchamber.goal).toEqual({
319
+ id: 'g1',
320
+ status: 'paused',
321
+ executionGeneration: 3,
322
+ });
323
+ expect(accepted.metadata.openchamber.assist).toEqual({ recap: 'keep' });
324
+ });
325
+
326
+ it('mutateSessionMetadata does not publish drafts while persist is blocked', async () => {
327
+ const dataDir = makeDataDir();
328
+ await createSessionMetadataStore({ dataDir }).setSessionMetadata('ses_1', {
329
+ openchamber: { goal: { id: 'g1', status: 'active', executionGeneration: 1 } },
330
+ });
331
+
332
+ let unlock;
333
+ const gate = new Promise((resolve) => { unlock = resolve; });
334
+ const fsPromises = {
335
+ ...fs.promises,
336
+ writeFile: async (target, payload, encoding) => {
337
+ await gate;
338
+ return fs.promises.writeFile(target, payload, encoding);
339
+ },
340
+ };
341
+ const store = createSessionMetadataStore({ dataDir, fsPromises });
342
+ await store.load();
343
+
344
+ const pending = store.mutateSessionMetadata('ses_1', () => ({
345
+ ok: true,
346
+ patch: { openchamber: { goal: { status: 'paused', executionGeneration: 2 } } },
347
+ }));
348
+ await expect(store.get('ses_1')).resolves.toEqual({
349
+ openchamber: { goal: { id: 'g1', status: 'active', executionGeneration: 1 } },
350
+ });
351
+ expect(store.getSnapshotSync()?.ses_1?.openchamber?.goal?.status).toBe('active');
352
+ unlock();
353
+ await expect(pending).resolves.toMatchObject({
354
+ committed: true,
355
+ metadata: { openchamber: { goal: { status: 'paused', executionGeneration: 2 } } },
356
+ });
357
+ });
358
+
359
+ it('mutateSessionMetadata preserves Host archive and serializes with setSessionMetadata', async () => {
360
+ const store = createSessionMetadataStore({ dataDir: makeDataDir() });
361
+ await store.setSessionMetadata('ses_1', { openchamber: { archive: { archivedAt: 42 } } }, { allowArchive: true });
362
+
363
+ const [mutated] = await Promise.all([
364
+ store.mutateSessionMetadata('ses_1', () => ({
365
+ ok: true,
366
+ patch: { openchamber: { goal: { id: 'g1', status: 'active', executionGeneration: 0 } } },
367
+ })),
368
+ store.setSessionMetadata('ses_1', { openchamber: { assist: { recap: 'a' } } }),
369
+ ]);
370
+ expect(mutated.committed).toBe(true);
371
+ await expect(store.get('ses_1')).resolves.toEqual({
372
+ openchamber: {
373
+ archive: { archivedAt: 42 },
374
+ goal: { id: 'g1', status: 'active', executionGeneration: 0 },
375
+ assist: { recap: 'a' },
376
+ },
377
+ });
378
+ });
184
379
  });