@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.
- package/README.md +15 -11
- package/bin/cli-opencode.test.js +4 -4
- package/bin/cli.js +1 -1
- package/bin/lib/cli-startup.js +1 -1
- package/bin/lib/cli-startup.test.js +18 -4
- package/dist/assets/{AgentAvatar-CLWWNu_Q.js → AgentAvatar-Q1SmFRx2.js} +1 -1
- package/dist/assets/AssistantView-CwXkiCAd.js +3 -0
- package/dist/assets/{DiagramView-xuSJTnv7.js → DiagramView-BW92schO.js} +1 -1
- package/dist/assets/{FadeInOnReveal-Ov0D96d0.js → FadeInOnReveal-BZmwl-zE.js} +1 -1
- package/dist/assets/{Icon-DD-BuMfZ.js → Icon-AQPvxMiU.js} +1 -1
- package/dist/assets/{MarkdownRenderer-bc_p1H7l.js → MarkdownRenderer-Bv05iDZZ.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-SKi4oG0c.js → MarkdownRendererImpl-C81xqYib.js} +3 -3
- package/dist/assets/MarkstreamRendererImpl-CXUCQuKJ.js +3 -0
- package/dist/assets/{MobileDetailNavigation-Bnx59U_J.js → MobileDetailNavigation-BMgIu7zc.js} +1 -1
- package/dist/assets/{MobileShareBridge-BQlP4J-F.js → MobileShareBridge-BOXGNHmC.js} +2 -2
- package/dist/assets/MobileSurface-xuc_z0_G.js +2 -0
- package/dist/assets/{MultiRunWindow-ChZGob0v.js → MultiRunWindow-B5At2Z95.js} +1 -1
- package/dist/assets/{OnboardingScreen-DtQJxZG-.js → OnboardingScreen-bsPLgz4r.js} +1 -1
- package/dist/assets/OverlayScrollbar-4wp4migh.js +2 -0
- package/dist/assets/SettingsView-CJ6tcH5_.js +25 -0
- package/dist/assets/{SettingsWindow-DfnsFa8y.js → SettingsWindow-CMtX2bMl.js} +1 -1
- package/dist/assets/{TerminalView-Dh9MWCpS.js → TerminalView-Dvr-ysTS.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BejmjrRk.js → ToolOutputDialog-uH1ZZDSS.js} +2 -2
- package/dist/assets/appThemeRegistry-DK7q2juD.js +1 -0
- package/dist/assets/{assistant-attachment-cache-C0aRnFkj.js → assistant-attachment-cache-DphJleHr.js} +1 -1
- package/dist/assets/{button-D-rATN7w.js → button-CcNJUxUd.js} +1 -1
- package/dist/assets/collapsible-ZmZ6rLWs.js +3 -0
- package/dist/assets/context-menu-B2LXJgXL.js +1 -0
- package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-C6HrP0jq.js} +1 -1
- package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-DdVBp45U.js} +1 -1
- package/dist/assets/{diagram-DQf7Y_uR.js → diagram-CxTs9hCQ.js} +1 -1
- package/dist/assets/{dialog-bfbvR4VH.js → dialog-KMVL9QKF.js} +1 -1
- package/dist/assets/es-CN3E_zXj.js +15 -0
- package/dist/assets/{fileMentionTouchSelection-BvrNCroG.js → fileMentionTouchSelection-7RIwlpkB.js} +1 -1
- package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-CjdBRy8y.js} +1 -1
- package/dist/assets/fr-9zZvQZD2.js +13 -0
- package/dist/assets/gitApi-yuWg9BN4.js +10 -0
- package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-9vQBq3zp.js} +1 -1
- package/dist/assets/{input-8r6F_Fza.js → input-CrIvk3HZ.js} +1 -1
- package/dist/assets/insertionBoundaries-BiUCSkx-.js +1 -0
- package/dist/assets/ja-Dnjk_82N.js +15 -0
- package/dist/assets/ko-BxCEE99C.js +15 -0
- package/dist/assets/{main-CCE_4y5F.js → main-C9mIBo77.js} +2 -2
- package/dist/assets/main-DiL6w6KF.js +169 -0
- package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
- package/dist/assets/{markdownHeightCache-1Qo6y6IJ.js → markdownHeightCache-BmXVY_gc.js} +1 -1
- package/dist/assets/{markdownTheme-DOfGg-A7.js → markdownTheme-CKac3wKC.js} +1 -1
- package/dist/assets/mcpQueries-C7jknHV8.js +1 -0
- package/dist/assets/miniChat-CwtUaElU.js +2 -0
- package/dist/assets/mobile-DB-6--ws.js +2 -0
- package/dist/assets/multirun-TAsxFI8e.js +4 -0
- package/dist/assets/{persistence-Ce3AnpEr.js → persistence-56kq8Z4V.js} +2 -2
- package/dist/assets/pl-CNNsWnPM.js +15 -0
- package/dist/assets/platform-C13fMVEs.js +6 -0
- package/dist/assets/pt-BR-BZB2DJMp.js +15 -0
- package/dist/assets/{relay-image-stream-DM7atqJs.js → relay-image-stream-DrdVj99c.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-CGj0MNRU.js +2 -0
- package/dist/assets/renderMobileApp-CGMMqbsO.js +28 -0
- package/dist/assets/runtime-fetch-Fs9ZV2V8.js +1 -0
- package/dist/assets/{runtimeConfig-BMvqa5we.js → runtimeConfig-BEANkvOd.js} +1 -1
- package/dist/assets/runtimeEndpointReset-A_ZBUJFS.js +147 -0
- package/dist/assets/sessionChangeSummary-qOLrP9fw.js +2 -0
- package/dist/assets/{sessionLookup-gh-hVRIc.js → sessionLookup-34XFmP9C.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-CudoSgV4.js → sortable-tabs-strip-DyEV0Heg.js} +1 -1
- package/dist/assets/src-ubCWXZhR.css +2 -0
- package/dist/assets/store-DLCiZaA6.js +15 -0
- package/dist/assets/{terminalApi-DiF6JmBP.js → terminalApi-CXaBEoa_.js} +1 -1
- package/dist/assets/textarea-BBOlRI9O.js +1 -0
- package/dist/assets/{tooltip-CQvpK-UV.js → tooltip-CPI4fr1a.js} +1 -1
- package/dist/assets/uk-rwQrxeYM.js +15 -0
- package/dist/assets/{url-_z7evzdK.js → url-DRDQXjyX.js} +1 -1
- package/dist/assets/{useAppFontEffects-Cpmpo7Wb.js → useAppFontEffects-CiDe42I5.js} +57 -43
- package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-asNG6K7n.js} +1 -1
- package/dist/assets/useDesktopWindowControlsLayout-BlizAlOt.js +1 -0
- package/dist/assets/useEffectiveDirectory-C_CouL5v.js +1 -0
- package/dist/assets/{useFontPreferences-DcbXQOkc.js → useFontPreferences-B0oUZAiB.js} +1 -1
- package/dist/assets/useMobileNavigationStore-DfPClaW-.js +414 -0
- package/dist/assets/{useRuntimeAPIs-DSLXJybW.js → useRuntimeAPIs-DwLtO0c_.js} +1 -1
- package/dist/assets/useSessionAutoCleanup-DtrfbxAp.js +2 -0
- package/dist/assets/{useThemeSystem-DMzWVJaW.js → useThemeSystem-BlNUWH47.js} +1 -1
- package/dist/assets/useUIStore-CQYKJnrg.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-Bqn4AiBL.js → useWorkerHighlightedLines-CPNYz6Wf.js} +2 -2
- package/dist/assets/{vendor-base-ui-CnHfUmDN.js → vendor-base-ui-CDjyofeD.js} +1 -1
- package/dist/assets/{vendor-cmdk-CeAGfrLo.js → vendor-cmdk-BFH0UpXX.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-accessibility-BY5LhhUB.js → vendor-dnd-kit-accessibility-BrR3AKvM.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-core-CvWxUPdb.js → vendor-dnd-kit-core-CNfdvk6H.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-sortable-hHdP9lLb.js → vendor-dnd-kit-sortable-DQUaTeGj.js} +1 -1
- package/dist/assets/{vendor-framer-motion-DFy_BSVd.js → vendor-framer-motion-4dogsUk3.js} +1 -1
- package/dist/assets/{vendor-legendapp-list-WE32lwNk.js → vendor-legendapp-list-BU7MdPZR.js} +1 -1
- package/dist/assets/{vendor-markstream-react-DX55XLGO.js → vendor-markstream-react-qHC2FykU.js} +3 -3
- package/dist/assets/{vendor-pierre-diffs-DX7XTPG3.js → vendor-pierre-diffs-CUfqC003.js} +1 -1
- package/dist/assets/{vendor-react-DLPElpoQ.js → vendor-react-C94A0AHl.js} +1 -1
- package/dist/assets/{vendor-react-drawio-ECtEwvbc.js → vendor-react-drawio-FVlWqKVE.js} +1 -1
- package/dist/assets/{vendor-reactuses-core-BnW6GxJt.js → vendor-reactuses-core-C2A3EjUi.js} +1 -1
- package/dist/assets/{vendor-sonner-DVzioF3d.js → vendor-sonner-DNqHmUQs.js} +1 -1
- package/dist/assets/{vendor-stream-diffs-DWy3xSmp.js → vendor-stream-diffs-CwbKsmlv.js} +2 -2
- package/dist/assets/{vendor-tanstack-react-query-D7pys_c9.js → vendor-tanstack-react-query-D0iFK57C.js} +1 -1
- package/dist/assets/{vendor-tanstack-react-virtual-tlDgH6PG.js → vendor-tanstack-react-virtual-B2kV6cnT.js} +1 -1
- package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-BqLKf6ZY.js} +1 -1
- package/dist/assets/zh-CN-BvmBDnkD.js +15 -0
- package/dist/assets/zh-TW-9wcHTX0y.js +15 -0
- package/dist/index.html +13 -13
- package/dist/mini-chat.html +13 -13
- package/dist/mobile.html +13 -13
- package/package.json +1 -1
- package/server/index.js +327 -62
- package/server/lib/assistants/DOCUMENTATION.md +14 -9
- package/server/lib/assistants/contracts.js +11 -1
- package/server/lib/assistants/projection-history.test.js +265 -18
- package/server/lib/assistants/routes.js +6 -0
- package/server/lib/assistants/service.js +417 -231
- package/server/lib/assistants/service.test.js +392 -148
- package/server/lib/conversations/DOCUMENTATION.md +1 -1
- package/server/lib/conversations/service.js +1 -0
- package/server/lib/conversations/service.test.js +16 -0
- package/server/lib/conversations/validation.js +11 -1
- package/server/lib/event-stream/DOCUMENTATION.md +3 -1
- package/server/lib/event-stream/directory-ws-bridge.js +13 -2
- package/server/lib/event-stream/global-hub.js +49 -4
- package/server/lib/event-stream/reasoning-projection.js +16 -1
- package/server/lib/event-stream/reasoning-projection.test.js +15 -0
- package/server/lib/event-stream/runtime.js +57 -20
- package/server/lib/event-stream/runtime.test.js +112 -0
- package/server/lib/llm/DOCUMENTATION.md +33 -9
- package/server/lib/llm/catalog.js +1 -1
- package/server/lib/llm/completions.js +5 -1
- package/server/lib/llm/completions.test.js +3 -3
- package/server/lib/llm/generate.js +237 -145
- package/server/lib/llm/generate.test.js +201 -46
- package/server/lib/llm/routes.js +2 -0
- package/server/lib/message-queue/runtime.js +15 -2
- package/server/lib/notifications/runtime.js +23 -5
- package/server/lib/notifications/runtime.test.js +11 -0
- package/server/lib/opencode/DOCUMENTATION.md +85 -17
- package/server/lib/opencode/agent-document.d.ts +28 -0
- package/server/lib/opencode/agent-document.js +280 -0
- package/server/lib/opencode/agent-document.test.js +137 -0
- package/server/lib/opencode/agents.js +96 -0
- package/server/lib/opencode/auth-state-runtime.js +5 -0
- package/server/lib/opencode/bootstrap-runtime.js +2 -0
- package/server/lib/opencode/config-entity-routes.js +42 -99
- package/server/lib/opencode/config-entity-routes.test.js +107 -0
- package/server/lib/opencode/config-hot-reload.integration.test.js +105 -0
- package/server/lib/opencode/ensure-cli.js +1 -2
- package/server/lib/opencode/env-runtime.js +40 -26
- package/server/lib/opencode/env-runtime.test.js +10 -17
- package/server/lib/opencode/feature-routes-runtime.js +43 -4
- package/server/lib/opencode/feature-routes-runtime.test.js +18 -0
- package/server/lib/opencode/http-server-connections.js +98 -0
- package/server/lib/opencode/http-server-connections.test.js +321 -0
- package/server/lib/opencode/lifecycle.js +349 -39
- package/server/lib/opencode/lifecycle.test.js +322 -0
- package/server/lib/opencode/managed-capabilities-runtime.js +15 -2
- package/server/lib/opencode/managed-capabilities-runtime.test.js +1 -0
- package/server/lib/opencode/openchamber-routes.js +7 -1
- package/server/lib/opencode/opencode2-pin.js +4 -4
- package/server/lib/opencode/opencode2-pin.test.js +1 -0
- package/server/lib/opencode/opencode2-sidecar.e2e.test.js +127 -4
- package/server/lib/opencode/owned-runtime-upgrade.js +431 -0
- package/server/lib/opencode/owned-runtime-upgrade.test.js +160 -0
- package/server/lib/opencode/plugin-routes.js +3 -18
- package/server/lib/opencode/plugin-routes.test.js +9 -9
- package/server/lib/opencode/provider-catalog.js +58 -12
- package/server/lib/opencode/provider-catalog.test.js +48 -0
- package/server/lib/opencode/proxy.js +252 -46
- package/server/lib/opencode/proxy.test.js +235 -2
- package/server/lib/opencode/routes.js +296 -62
- package/server/lib/opencode/routes.test.js +278 -12
- package/server/lib/opencode/runtime-contract.js +401 -0
- package/server/lib/opencode/runtime-contract.test.js +214 -0
- package/server/lib/opencode/server-opencode-fetch.js +242 -0
- package/server/lib/opencode/server-opencode-fetch.test.js +130 -0
- package/server/lib/opencode/server-utils-runtime.js +33 -0
- package/server/lib/opencode/shared-service.integration.test.js +66 -0
- package/server/lib/opencode/shared-service.js +147 -0
- package/server/lib/opencode/shared-service.test.js +93 -0
- package/server/lib/opencode/shared.js +3 -3
- package/server/lib/opencode/shutdown-runtime.js +15 -6
- package/server/lib/opencode/shutdown-runtime.test.js +49 -1
- package/server/lib/opencode/skill-routes.js +20 -33
- package/server/lib/opencode/skill-routes.test.js +35 -2
- package/server/lib/opencode/skills.test.js +15 -0
- package/server/lib/opencode/startup-pipeline-runtime.js +4 -0
- package/server/lib/opencode/static-routes-runtime.js +3 -1
- package/server/lib/opencode/static-routes-runtime.test.js +33 -0
- package/server/lib/opencode/v2-client.js +14 -3
- package/server/lib/opencode/v2-plugin-host-shim/index.mjs +155 -0
- package/server/lib/opencode/v2-plugin-host-shim/package.json +4 -0
- package/server/lib/permission-auto-accept/DOCUMENTATION.md +5 -5
- package/server/lib/permission-auto-accept/runtime.js +14 -7
- package/server/lib/permission-auto-accept/runtime.test.js +27 -12
- package/server/lib/relay/reasoning-projection.e2e.test.ts +22 -7
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -0
- package/server/lib/scheduled-tasks/runtime.js +52 -4
- package/server/lib/scheduled-tasks/runtime.test.js +80 -1
- package/server/lib/session-goal/DOCUMENTATION.md +113 -13
- package/server/lib/session-goal/routes.js +9 -0
- package/server/lib/session-goal/runtime.js +1579 -117
- package/server/lib/session-goal/runtime.test.js +1453 -84
- package/server/lib/session-index/DOCUMENTATION.md +18 -15
- package/server/lib/session-index/event-ingest.js +89 -16
- package/server/lib/session-index/event-ingest.test.js +73 -0
- package/server/lib/session-index/host-authority-snapshot.js +92 -0
- package/server/lib/session-index/routes.host-archive.test.js +201 -0
- package/server/lib/session-index/routes.js +63 -10
- package/server/lib/session-index/routes.test.js +8 -8
- package/server/lib/session-index/service.js +7 -1
- package/server/lib/session-index/sync-runtime.js +161 -34
- package/server/lib/session-index/sync-runtime.test.js +155 -1
- package/server/lib/session-metadata/DOCUMENTATION.md +61 -16
- package/server/lib/session-metadata/routes.js +108 -9
- package/server/lib/session-metadata/routes.test.js +47 -2
- package/server/lib/session-metadata/session-archive.d.ts +73 -0
- package/server/lib/session-metadata/session-archive.js +654 -0
- package/server/lib/session-metadata/session-archive.test.js +359 -0
- package/server/lib/session-metadata/session-metadata-store.d.ts +36 -0
- package/server/lib/session-metadata/session-metadata-store.js +212 -72
- package/server/lib/session-metadata/session-metadata-store.test.js +205 -10
- package/server/lib/session-metadata/session-projection.d.ts +41 -0
- package/server/lib/session-metadata/session-projection.js +255 -0
- package/server/lib/session-metadata/session-projection.test.js +64 -0
- package/server/lib/session-metadata/system-session.js +55 -0
- package/server/lib/session-metadata/system-session.test.js +55 -0
- package/server/lib/session-title/DOCUMENTATION.md +40 -7
- package/server/lib/session-title/native-runtime.test.js +167 -0
- package/server/lib/session-title/runtime.js +67 -42
- package/server/lib/session-title/session-access.js +46 -0
- package/server/lib/session-title/session-access.test.js +91 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +9 -8
- package/server/lib/session-turn-pages/routes.js +17 -102
- package/server/lib/session-turn-pages/routes.test.js +281 -0
- package/server/lib/session-turn-pages/session-message-projection.d.ts +13 -0
- package/server/lib/session-turn-pages/session-message-projection.js +281 -0
- package/server/lib/small-model/DOCUMENTATION.md +101 -147
- package/server/lib/small-model/catalog.js +31 -51
- package/server/lib/small-model/catalog.test.js +65 -10
- package/server/lib/small-model/custom-api.js +68 -0
- package/server/lib/small-model/custom-api.test.js +64 -0
- package/server/lib/small-model/index.js +124 -240
- package/server/lib/small-model/resolve.js +35 -120
- package/server/lib/small-model/resolve.test.js +20 -110
- package/server/lib/small-model/routes.js +1 -3
- package/server/lib/small-model/summary-settings.test.js +157 -253
- package/server/opencode-proxy.test.js +2 -0
- package/dist/assets/AssistantView-BTCkm8yk.js +0 -3
- package/dist/assets/MarkstreamRendererImpl-CnkodGJW.js +0 -3
- package/dist/assets/MobileSurface-BXLeiZ4d.js +0 -2
- package/dist/assets/OverlayScrollbar-pMwxaCca.js +0 -2
- package/dist/assets/SettingsView-B5I0KDTZ.js +0 -25
- package/dist/assets/appThemeRegistry-COGfxTFV.js +0 -1
- package/dist/assets/collapsible-BXRd5_Hr.js +0 -1
- package/dist/assets/context-menu-jGtH6fR9.js +0 -1
- package/dist/assets/es-c52LTHHe.js +0 -15
- package/dist/assets/fr-PisFbUE8.js +0 -13
- package/dist/assets/gitApi-ButwzQ0N.js +0 -9
- package/dist/assets/insertionBoundaries-DGs7S5zv.js +0 -1
- package/dist/assets/ja-C8Ikun3Z.js +0 -15
- package/dist/assets/ko-ClFys5o2.js +0 -15
- package/dist/assets/main-Dn9rpsgl.js +0 -169
- package/dist/assets/markdown-worker-CptoICmq.js +0 -2
- package/dist/assets/miniChat-BpjxVqgg.js +0 -2
- package/dist/assets/mobile-nMJCaNq1.js +0 -2
- package/dist/assets/multirun-BvSG94mW.js +0 -4
- package/dist/assets/pl-CnaKWN5F.js +0 -15
- package/dist/assets/platform-CWTIp02q.js +0 -4
- package/dist/assets/projectMeta-DOe49dM_.js +0 -3
- package/dist/assets/pt-BR-s8OpfRI6.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +0 -2
- package/dist/assets/renderMobileApp-y-S8y8v_.js +0 -28
- package/dist/assets/runtime-fetch-zIalUnbb.js +0 -1
- package/dist/assets/runtimeEndpointReset-B6BxhvR0.js +0 -147
- package/dist/assets/sessionChangeSummary-VVBlEXuh.js +0 -2
- package/dist/assets/src-BzX5UI-c.css +0 -2
- package/dist/assets/store-DJczmczv.js +0 -15
- package/dist/assets/textarea-3XR8sUfy.js +0 -1
- package/dist/assets/uk-CaX0iqkB.js +0 -15
- package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +0 -1
- package/dist/assets/useEffectiveDirectory-CN6JoCFw.js +0 -1
- package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +0 -414
- package/dist/assets/useSessionAutoCleanup-BLjymaFt.js +0 -2
- package/dist/assets/useUIStore-Bxv3f23A.js +0 -1
- package/dist/assets/zh-CN-20MYEjha.js +0 -15
- package/dist/assets/zh-TW-DiN04qTw.js +0 -15
- package/server/lib/small-model/call.js +0 -427
- package/server/lib/small-model/call.test.js +0 -382
- package/server/lib/small-model/opencode-session.js +0 -183
- 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
|
|
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
|
|
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
|
|
124
|
+
let loadFailureReason = null;
|
|
125
|
+
/** Full RMW + persist chain — concurrent writers cannot interleave merges. */
|
|
126
|
+
let mutationChain = Promise.resolve();
|
|
72
127
|
|
|
73
|
-
const
|
|
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
|
-
//
|
|
106
|
-
//
|
|
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
|
|
165
|
+
`[session-metadata] session metadata file is corrupt and will not be overwritten: ${error?.message ?? error}`,
|
|
112
166
|
);
|
|
113
|
-
return { ok:
|
|
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
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
173
|
-
*
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
277
|
+
return runExclusive(async () => {
|
|
278
|
+
await ensureReadable();
|
|
279
|
+
return commitMergedPatch(id, entries.get(id), patch, options);
|
|
280
|
+
});
|
|
281
|
+
};
|
|
184
282
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
-
|
|
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('
|
|
127
|
+
it('refuses corrupt JSON as empty success and does not overwrite the file', async () => {
|
|
128
128
|
const dataDir = makeDataDir();
|
|
129
|
-
|
|
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()).
|
|
134
|
-
await expect(store.setSessionMetadata('ses_1', { a: 1 })).
|
|
135
|
-
expect(
|
|
136
|
-
expect(
|
|
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(/
|
|
164
|
+
await expect(store.setSessionMetadata('ses_1', { a: 1 })).rejects.toThrow(/unavailable/);
|
|
163
165
|
expect(readFile(dataDir)).toBe('{}');
|
|
164
166
|
});
|
|
165
167
|
|
|
166
|
-
it('
|
|
168
|
+
it('does not publish uncommitted drafts while persist is blocked (set)', async () => {
|
|
167
169
|
const dataDir = makeDataDir();
|
|
168
|
-
|
|
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
|
|
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
|
});
|