@openchambery/web 2.0.0-beta.3 → 2.0.0-beta.30
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 +14 -0
- package/dist/assets/{AgentAvatar-B_JQoreg.js → AgentAvatar-O2x_1cFU.js} +1 -1
- package/dist/assets/AssistantView-pBywAsqy.js +3 -0
- package/dist/assets/{DiagramView-BNnda8qO.js → DiagramView-B984aE-R.js} +1 -1
- package/dist/assets/{FadeInOnReveal-VYQTSAkr.js → FadeInOnReveal-CMBd5BTd.js} +1 -1
- package/dist/assets/Icon-BN87CyS4.js +1 -0
- package/dist/assets/MarkdownRenderer-DHiNU8n3.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-DwrHFGUp.js → MarkdownRendererImpl-xJHMGHju.js} +3 -3
- package/dist/assets/MarkstreamRendererImpl-BZflns6x.js +3 -0
- package/dist/assets/{MobileDetailNavigation-BXr6AGLT.js → MobileDetailNavigation-DUvxsvrB.js} +1 -1
- package/dist/assets/{MobileShareBridge-D7OE9OP_.js → MobileShareBridge-DWo-J3Vc.js} +3 -3
- package/dist/assets/MobileSurface-B-Y6U5xm.js +2 -0
- package/dist/assets/{MultiRunWindow-DF3iIFCd.js → MultiRunWindow-rYiVSNKK.js} +1 -1
- package/dist/assets/{OnboardingScreen-BjHmlwlw.js → OnboardingScreen-BLjZIBOs.js} +1 -1
- package/dist/assets/{OverlayScrollbar-DLtWO_Gp.js → OverlayScrollbar-CoZ4wtau.js} +1 -1
- package/dist/assets/SettingsView-pjD0LrHE.js +25 -0
- package/dist/assets/{SettingsWindow-BI5swspb.js → SettingsWindow-CFOQRwjA.js} +1 -1
- package/dist/assets/TerminalView-Dw5jaYkc.js +8 -0
- package/dist/assets/{ToolOutputDialog-BEWhBcRd.js → ToolOutputDialog-B4v-ona2.js} +1 -1
- package/dist/assets/appThemeRegistry-BqDCGocy.js +1 -0
- package/dist/assets/{assistant-attachment-cache-C0aRnFkj.js → assistant-attachment-cache-sG8YQsVs.js} +1 -1
- package/dist/assets/{button-DKuz3L8l.js → button-Ck8ipzBm.js} +1 -1
- package/dist/assets/collapsible-nU99prkD.js +1 -0
- package/dist/assets/context-menu-DMzZN1oi.js +1 -0
- package/dist/assets/{desktopHosts-CoDkOnyS.js → desktopHosts-DuftEj8B.js} +1 -1
- package/dist/assets/desktopRelayRestore-g378BdZf.js +1 -0
- package/dist/assets/{diagram-D7T8HQgj.js → diagram-CvO4P8ds.js} +1 -1
- package/dist/assets/dialog-ByGUkS3c.js +1 -0
- package/dist/assets/dropdown-menu.styles-ayLWk2gf.js +1 -0
- package/dist/assets/es-DCjl24dV.js +15 -0
- package/dist/assets/fileMentionTouchSelection-Cgkxwzqb.js +2 -0
- package/dist/assets/{fileQueries-JrpcsOCs.js → fileQueries-DuF89nFO.js} +1 -1
- package/dist/assets/fr-DfJ5j0t5.js +13 -0
- package/dist/assets/{gitApi-CM42qUed.js → gitApi-Dwqkc2nZ.js} +3 -3
- package/dist/assets/{imageSource-CIl0FL10.js → imageSource-BpUsiwFd.js} +1 -1
- package/dist/assets/{input-CzQy8UhV.js → input-CXhFmHcq.js} +1 -1
- package/dist/assets/insertionBoundaries-BhWfAOPY.js +1 -0
- package/dist/assets/ja-B_9_w2Xf.js +15 -0
- package/dist/assets/ko-DlGbo3kV.js +15 -0
- package/dist/assets/main-CGMinhgQ.js +169 -0
- package/dist/assets/main-pMpcLJha.js +2 -0
- package/dist/assets/{markdownHeightCache-Cj1I5NDw.js → markdownHeightCache-g_UsWzbA.js} +1 -1
- package/dist/assets/markdownTheme-DM5h1i18.js +1 -0
- package/dist/assets/{mcpQueries-D5mQymgM.js → mcpQueries-DfrW3VHF.js} +1 -1
- package/dist/assets/miniChat-YOt30ECc.js +2 -0
- package/dist/assets/mobile-BK1bBCYD.js +2 -0
- package/dist/assets/multirun-Dua24A69.js +3 -0
- package/dist/assets/persistence-Dnj1QOdX.js +3 -0
- package/dist/assets/pl-DjUsS8Ei.js +15 -0
- package/dist/assets/platform-X3Y3GyM5.js +6 -0
- package/dist/assets/projectMeta-DXdoPmbz.js +1 -0
- package/dist/assets/pt-BR-B8sV7qo1.js +15 -0
- package/dist/assets/{relay-image-stream-DM7atqJs.js → relay-image-stream-D2XooXKZ.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-BOuEz_oT.js +2 -0
- package/dist/assets/renderMobileApp-C8Alpv-B.js +28 -0
- package/dist/assets/runtime-fetch-CiqQd__Y.js +1 -0
- package/dist/assets/runtime-switch-BJM2k_uH.js +1 -0
- package/dist/assets/runtimeConfig-DX_NP2Zm.js +2 -0
- package/dist/assets/runtimeEndpointReset-BSQU3e5U.js +148 -0
- package/dist/assets/runtimeEndpointReset-DsuP5FPT.css +1 -0
- package/dist/assets/sessionChangeSummary-8AIBkExf.js +2 -0
- package/dist/assets/{sessionLookup-myHe5ltu.js → sessionLookup-Bdwd1Fit.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-BQtmHx9r.js → sortable-tabs-strip-T-dk_L69.js} +1 -1
- package/dist/assets/src-OzbnJsph.css +2 -0
- package/dist/assets/store-Cuktref-.js +15 -0
- package/dist/assets/{terminalApi-BxcdiduX.js → terminalApi-C_4joKUG.js} +1 -1
- package/dist/assets/textarea-biAqiqnJ.js +1 -0
- package/dist/assets/{tooltip-Cx5gTbdY.js → tooltip-BKL7J5hx.js} +1 -1
- package/dist/assets/uk-Bh7HXyLp.js +15 -0
- package/dist/assets/{url-CDHnjlsm.js → url-DNRMq_0R.js} +1 -1
- package/dist/assets/{useAppFontEffects-CNeo0aZs.js → useAppFontEffects-bafOEpgK.js} +57 -52
- package/dist/assets/{useAssistantUIStore-asNG6K7n.js → useAssistantUIStore-DBwfg9nX.js} +1 -1
- package/dist/assets/useDesktopWindowControlsLayout-CkCVOkxa.js +1 -0
- package/dist/assets/{useEffectiveDirectory-B4LvR8Gt.js → useEffectiveDirectory-B7htpJfU.js} +1 -1
- package/dist/assets/{useFontPreferences-CUnQ4TVm.js → useFontPreferences-CMrbTA0r.js} +1 -1
- package/dist/assets/useMobileNavigationStore-D2tnhKYV.js +415 -0
- package/dist/assets/{useRuntimeAPIs-DwLtO0c_.js → useRuntimeAPIs-DSLXJybW.js} +1 -1
- package/dist/assets/useSessionAutoCleanup-BC5OOH3M.js +2 -0
- package/dist/assets/{useThemeSystem-BlNUWH47.js → useThemeSystem-DMzWVJaW.js} +1 -1
- package/dist/assets/useUIStore-Dfx6TKvi.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-CJZbJDW3.js → useWorkerHighlightedLines-BNQUAMEB.js} +1 -1
- package/dist/assets/{vendor-base-ui-CDjyofeD.js → vendor-base-ui-CnHfUmDN.js} +1 -1
- package/dist/assets/{vendor-cmdk-BFH0UpXX.js → vendor-cmdk-CeAGfrLo.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-accessibility-BrR3AKvM.js → vendor-dnd-kit-accessibility-BY5LhhUB.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-core-CNfdvk6H.js → vendor-dnd-kit-core-CvWxUPdb.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-sortable-DQUaTeGj.js → vendor-dnd-kit-sortable-hHdP9lLb.js} +1 -1
- package/dist/assets/{vendor-framer-motion-4dogsUk3.js → vendor-framer-motion-DFy_BSVd.js} +1 -1
- package/dist/assets/{vendor-legendapp-list-BU7MdPZR.js → vendor-legendapp-list-WE32lwNk.js} +1 -1
- package/dist/assets/{vendor-markstream-react-qHC2FykU.js → vendor-markstream-react-DX55XLGO.js} +3 -3
- package/dist/assets/vendor-opencode-client-MF2GBrFx.js +12 -0
- package/dist/assets/{vendor-pierre-diffs-CUfqC003.js → vendor-pierre-diffs-DX7XTPG3.js} +1 -1
- package/dist/assets/{vendor-react-C94A0AHl.js → vendor-react-DLPElpoQ.js} +1 -1
- package/dist/assets/{vendor-react-drawio-FVlWqKVE.js → vendor-react-drawio-ECtEwvbc.js} +1 -1
- package/dist/assets/{vendor-reactuses-core-C2A3EjUi.js → vendor-reactuses-core-Dq5ocTF8.js} +1 -1
- package/dist/assets/{vendor-sonner-DNqHmUQs.js → vendor-sonner-DVzioF3d.js} +1 -1
- package/dist/assets/{vendor-stream-diffs-CwbKsmlv.js → vendor-stream-diffs-DWy3xSmp.js} +2 -2
- package/dist/assets/{vendor-tanstack-react-query-D0iFK57C.js → vendor-tanstack-react-query-D7pys_c9.js} +1 -1
- package/dist/assets/{vendor-tanstack-react-virtual-B2kV6cnT.js → vendor-tanstack-react-virtual-tlDgH6PG.js} +1 -1
- package/dist/assets/{vendor-zustand-BqLKf6ZY.js → vendor-zustand-DzRqcymP.js} +2 -2
- package/dist/assets/zh-CN-Uzia0gk4.js +15 -0
- package/dist/assets/zh-TW-CiI7DLdB.js +15 -0
- package/dist/index.html +15 -15
- package/dist/mini-chat.html +15 -15
- package/dist/mobile.html +15 -15
- package/package.json +3 -2
- package/server/index.js +46 -27
- package/server/lib/browser-provider/DOCUMENTATION.md +141 -0
- package/server/lib/browser-provider/contract.js +312 -0
- package/server/lib/browser-provider/host.js +421 -0
- package/server/lib/browser-provider/host.test.js +304 -0
- package/server/lib/browser-provider/routes.js +122 -0
- package/server/lib/browser-provider/routes.test.js +120 -0
- package/server/lib/browser-provider/selection-store.js +43 -0
- package/server/lib/browser-provider/selection-store.test.js +35 -0
- package/server/lib/event-stream/DOCUMENTATION.md +3 -1
- package/server/lib/event-stream/reasoning-projection.js +16 -1
- package/server/lib/event-stream/reasoning-projection.test.js +15 -0
- package/server/lib/git/DOCUMENTATION.md +1 -0
- package/server/lib/git/routes.js +7 -4
- package/server/lib/git/routes.test.js +34 -0
- package/server/lib/git/service.js +3 -1
- package/server/lib/llm/DOCUMENTATION.md +6 -4
- package/server/lib/llm/generate.js +8 -9
- package/server/lib/llm/generate.test.js +33 -6
- package/server/lib/message-queue/service.js +1 -1
- package/server/lib/notifications/DOCUMENTATION.md +3 -1
- package/server/lib/notifications/runtime.js +128 -31
- package/server/lib/notifications/runtime.test.js +103 -0
- package/server/lib/notifications/template-runtime.js +130 -38
- package/server/lib/notifications/template-runtime.test.js +96 -0
- package/server/lib/opencode/DOCUMENTATION.md +36 -19
- package/server/lib/opencode/agents.js +13 -22
- package/server/lib/opencode/auth-state-runtime.js +3 -3
- package/server/lib/opencode/auth-state-runtime.test.js +59 -0
- package/server/lib/opencode/auth.d.ts +13 -0
- package/server/lib/opencode/auth.js +74 -64
- package/server/lib/opencode/auth.test.js +57 -0
- package/server/lib/opencode/commands.js +1 -1
- package/server/lib/opencode/config-entities-layering.test.js +72 -0
- package/server/lib/opencode/config-entity-routes.js +16 -124
- package/server/lib/opencode/config-entity-routes.test.js +48 -11
- package/server/lib/opencode/config-files.d.ts +17 -0
- package/server/lib/opencode/config-files.js +187 -0
- package/server/lib/opencode/config-files.test.js +201 -0
- package/server/lib/opencode/core-routes.js +0 -1
- package/server/lib/opencode/core-routes.test.js +0 -15
- package/server/lib/opencode/credential-boundary.d.ts +1 -0
- package/server/lib/opencode/credential-boundary.js +11 -0
- package/server/lib/opencode/ensure-cli.d.ts +8 -0
- package/server/lib/opencode/ensure-cli.js +4 -1
- package/server/lib/opencode/env-config.js +4 -1
- package/server/lib/opencode/env-config.test.js +28 -0
- package/server/lib/opencode/env-runtime.test.js +18 -1
- package/server/lib/opencode/feature-routes-runtime.js +19 -7
- package/server/lib/opencode/feature-routes-runtime.test.js +7 -0
- package/server/lib/opencode/hmr-state-runtime.js +4 -4
- package/server/lib/opencode/lifecycle.js +1 -49
- package/server/lib/opencode/lifecycle.test.js +30 -4
- package/server/lib/opencode/location-release.d.ts +8 -0
- package/server/lib/opencode/location-release.js +32 -0
- package/server/lib/opencode/location-release.test.js +43 -0
- package/server/lib/opencode/mcp.js +1 -1
- package/server/lib/opencode/opencode2-pin.js +2 -1
- package/server/lib/opencode/owned-runtime-upgrade.test.js +5 -5
- package/server/lib/opencode/plugin-routes.test.js +4 -2
- package/server/lib/opencode/plugins.js +12 -60
- package/server/lib/opencode/plugins.test.js +5 -3
- package/server/lib/opencode/providers.js +6 -3
- package/server/lib/opencode/proxy.js +13 -1
- package/server/lib/opencode/proxy.test.js +31 -3
- package/server/lib/opencode/remote-upgrade.integration.test.js +66 -0
- package/server/lib/opencode/routes.js +40 -8
- package/server/lib/opencode/routes.test.js +51 -10
- package/server/lib/opencode/runtime-contract.js +3 -2
- package/server/lib/opencode/runtime-contract.test.js +12 -12
- package/server/lib/opencode/shared.js +6 -175
- package/server/lib/opencode/update-discovery.js +39 -0
- package/server/lib/opencode/update-discovery.test.js +39 -0
- package/server/lib/opencode/upgrade-screen.d.ts +30 -0
- package/server/lib/opencode/upgrade-screen.js +292 -0
- package/server/lib/opencode/upgrade-screen.test.js +196 -0
- package/server/lib/opencode/version-alignment.test.js +40 -0
- package/server/lib/question-auto-delegate/DOCUMENTATION.md +21 -6
- package/server/lib/question-auto-delegate/core.d.ts +5 -1
- package/server/lib/question-auto-delegate/core.js +21 -13
- package/server/lib/question-auto-delegate/form-io.d.ts +6 -0
- package/server/lib/question-auto-delegate/form-io.js +39 -0
- package/server/lib/question-auto-delegate/runtime.js +4 -24
- package/server/lib/question-auto-delegate/runtime.test.js +138 -0
- package/server/lib/quota/DOCUMENTATION.md +9 -0
- package/server/lib/quota/provider-credentials.test.js +29 -0
- package/server/lib/quota/providers/claude.js +4 -4
- package/server/lib/quota/providers/codex.js +4 -4
- package/server/lib/quota/providers/codex.test.js +1 -1
- package/server/lib/quota/providers/copilot.js +5 -5
- package/server/lib/quota/providers/google/auth.js +3 -3
- package/server/lib/quota/providers/google/index.js +2 -2
- package/server/lib/quota/providers/index.js +3 -7
- package/server/lib/quota/providers/index.test.js +2 -2
- package/server/lib/quota/providers/kimi.js +4 -4
- package/server/lib/quota/providers/minimax-shared.js +4 -4
- package/server/lib/quota/providers/nanogpt.js +4 -4
- package/server/lib/quota/providers/openai.js +4 -4
- package/server/lib/quota/providers/openrouter.js +4 -4
- package/server/lib/quota/providers/wafer.js +4 -4
- package/server/lib/quota/providers/zai.js +4 -4
- package/server/lib/quota/providers/zhipuai-coding-plan.js +6 -20
- package/server/lib/quota/routes.js +1 -1
- package/server/lib/relay/DOCUMENTATION.md +3 -1
- package/server/lib/relay/service.js +1 -0
- package/server/lib/relay/service.test.js +13 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -1
- package/server/lib/scheduled-tasks/runtime.js +12 -4
- package/server/lib/scheduled-tasks/runtime.test.js +81 -6
- package/server/lib/session-goal/DOCUMENTATION.md +11 -0
- package/server/lib/session-goal/runtime.js +4 -4
- package/server/lib/session-goal/runtime.test.js +32 -3
- package/server/lib/session-index/DOCUMENTATION.md +7 -0
- package/server/lib/session-index/event-ingest.js +13 -0
- package/server/lib/session-index/event-ingest.test.js +7 -0
- package/server/lib/session-index/service.js +45 -0
- package/server/lib/session-index/service.test.js +47 -0
- package/server/lib/session-metadata/DOCUMENTATION.md +33 -17
- package/server/lib/session-metadata/opencode-session-record.d.ts +57 -0
- package/server/lib/session-metadata/opencode-session-record.js +316 -0
- package/server/lib/session-metadata/opencode-session-record.test.js +242 -0
- package/server/lib/session-metadata/routes.js +5 -1
- package/server/lib/session-metadata/session-archive.js +1 -1
- package/server/lib/session-metadata/session-metadata-store.d.ts +2 -0
- package/server/lib/session-metadata/session-metadata-store.js +63 -16
- package/server/lib/session-metadata/session-metadata-store.test.js +23 -0
- package/server/lib/session-metadata/session-projection.d.ts +1 -0
- package/server/lib/session-metadata/session-projection.js +49 -2
- package/server/lib/session-metadata/session-projection.test.js +35 -0
- package/server/lib/session-title/DOCUMENTATION.md +52 -14
- package/server/lib/session-title/native-runtime.test.js +59 -5
- package/server/lib/session-title/runtime.js +65 -19
- package/server/lib/session-title/session-access.js +108 -3
- package/server/lib/session-title/session-access.test.js +125 -7
- package/server/lib/tts/DOCUMENTATION.md +7 -5
- package/server/lib/tts/credential-source.test.js +23 -0
- package/server/lib/tts/routes.js +2 -2
- package/server/lib/tts/service.js +16 -37
- package/dist/assets/AssistantView-ClQAfGOT.js +0 -3
- package/dist/assets/Icon-BiEQEcih.js +0 -1
- package/dist/assets/MarkdownRenderer-CJHSsWin.js +0 -2
- package/dist/assets/MarkstreamRendererImpl-CSnbQMu3.js +0 -3
- package/dist/assets/MobileSurface-D2jUkX9l.js +0 -2
- package/dist/assets/SettingsView-BRxELjow.js +0 -25
- package/dist/assets/TerminalView-BU3x8-fr.js +0 -8
- package/dist/assets/appThemeRegistry-4MR0F7Jz.js +0 -1
- package/dist/assets/collapsible-DXbDynN4.js +0 -3
- package/dist/assets/context-menu-CqolsLCm.js +0 -1
- package/dist/assets/desktopRelayRestore-DDlVKyOE.js +0 -1
- package/dist/assets/dialog-DQYl3W_o.js +0 -1
- package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +0 -1
- package/dist/assets/es-CN3E_zXj.js +0 -15
- package/dist/assets/fileMentionTouchSelection-BZ6ovBYH.js +0 -4
- package/dist/assets/fr-9zZvQZD2.js +0 -13
- package/dist/assets/insertionBoundaries-CtkZ9720.js +0 -1
- package/dist/assets/ja-Dnjk_82N.js +0 -15
- package/dist/assets/ko-BxCEE99C.js +0 -15
- package/dist/assets/main-BILgBI3B.js +0 -169
- package/dist/assets/main-BeveiW6F.js +0 -2
- package/dist/assets/markdownTheme-CKac3wKC.js +0 -1
- package/dist/assets/miniChat-BgrzM3u7.js +0 -2
- package/dist/assets/mobile-DtYoQE8C.js +0 -2
- package/dist/assets/multirun-jy8dEI96.js +0 -4
- package/dist/assets/persistence-C9LQpiOS.js +0 -3
- package/dist/assets/pl-CNNsWnPM.js +0 -15
- package/dist/assets/platform-BQghHRfp.js +0 -6
- package/dist/assets/pt-BR-BZB2DJMp.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-BGrrmeME.js +0 -2
- package/dist/assets/renderMobileApp-u3c4GtFC.js +0 -28
- package/dist/assets/runtime-fetch-zIalUnbb.js +0 -1
- package/dist/assets/runtime-switch-LvWDU-SL.js +0 -1
- package/dist/assets/runtimeConfig-BIGBRGQI.js +0 -2
- package/dist/assets/runtimeEndpointReset-D2savkuw.js +0 -147
- package/dist/assets/sessionChangeSummary-eIbpSfrO.js +0 -2
- package/dist/assets/src-CLN7Z-zx.css +0 -2
- package/dist/assets/store-DLCiZaA6.js +0 -15
- package/dist/assets/textarea-CakKvqFw.js +0 -1
- package/dist/assets/uk-rwQrxeYM.js +0 -15
- package/dist/assets/useDesktopWindowControlsLayout-DBJ56qIo.js +0 -1
- package/dist/assets/useMobileNavigationStore-ZHJPxlHt.js +0 -414
- package/dist/assets/useSessionAutoCleanup-BIVmrnYU.js +0 -2
- package/dist/assets/useUIStore-CQYKJnrg.js +0 -1
- package/dist/assets/vendor-opencode-client-BOe2qpQb.js +0 -12
- package/dist/assets/zh-CN-BvmBDnkD.js +0 -15
- package/dist/assets/zh-TW-9wcHTX0y.js +0 -15
- package/server/lib/opencode/snippets.js +0 -242
- package/server/lib/opencode/snippets.test.js +0 -97
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createSessionArchiveService } from './session-archive.js';
|
|
4
|
+
import { createSessionMetadataStore } from './session-metadata-store.js';
|
|
5
|
+
import {
|
|
6
|
+
createHttpSessionRecordClient,
|
|
7
|
+
mergeOwnedMetadata,
|
|
8
|
+
migrateLoadedSideStore,
|
|
9
|
+
readSessionRecordViaClient,
|
|
10
|
+
retainOpenCodeSessions,
|
|
11
|
+
writeSessionRecordViaClient,
|
|
12
|
+
} from './opencode-session-record.js';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
const makeDataDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-record-'));
|
|
18
|
+
|
|
19
|
+
describe('OpenCode session record ownership', () => {
|
|
20
|
+
it('keeps title and archive on the OpenCode record across archive and restore', async () => {
|
|
21
|
+
const dataDir = makeDataDir();
|
|
22
|
+
const records = new Map([
|
|
23
|
+
['ses_1', {
|
|
24
|
+
id: 'ses_1',
|
|
25
|
+
title: 'Keep me',
|
|
26
|
+
metadata: { openchamber: { goal: { status: 'active' }, assistant: { assistantID: 'ast_1' } } },
|
|
27
|
+
time: { created: 1, updated: 2 },
|
|
28
|
+
}],
|
|
29
|
+
]);
|
|
30
|
+
const writes = [];
|
|
31
|
+
const store = createSessionMetadataStore({
|
|
32
|
+
dataDir,
|
|
33
|
+
recordReader: async (sessionID) => records.get(sessionID) ?? null,
|
|
34
|
+
recordWriter: async (sessionID, metadata) => {
|
|
35
|
+
const current = records.get(sessionID);
|
|
36
|
+
if (!current) {
|
|
37
|
+
const error = new Error('missing');
|
|
38
|
+
error.status = 404;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
writes.push({ sessionID, metadata, title: current.title });
|
|
42
|
+
records.set(sessionID, { ...current, metadata });
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
const archive = createSessionArchiveService({
|
|
46
|
+
sessionMetadataStore: store,
|
|
47
|
+
fetchUpstreamSession: async ({ sessionID }) => records.get(sessionID) ?? null,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const archived = await archive.setArchive({ sessionID: 'ses_1', archivedAt: 50, directory: '/repo' });
|
|
51
|
+
expect(archived.session.title).toBe('Keep me');
|
|
52
|
+
expect(archived.session.time.archived).toBe(50);
|
|
53
|
+
expect(records.get('ses_1').title).toBe('Keep me');
|
|
54
|
+
expect(records.get('ses_1').metadata.openchamber.archive.archivedAt).toBe(50);
|
|
55
|
+
expect(records.get('ses_1').metadata.openchamber.goal.status).toBe('active');
|
|
56
|
+
expect(records.get('ses_1').metadata.openchamber.assistant.assistantID).toBe('ast_1');
|
|
57
|
+
expect(writes.at(-1).metadata.openchamber.assistant.assistantID).toBe('ast_1');
|
|
58
|
+
|
|
59
|
+
const restored = await archive.setArchive({ sessionID: 'ses_1', archivedAt: 0 });
|
|
60
|
+
expect(restored.session.title).toBe('Keep me');
|
|
61
|
+
expect(restored.session.time.archived).toBeUndefined();
|
|
62
|
+
expect(records.get('ses_1').title).toBe('Keep me');
|
|
63
|
+
expect(records.get('ses_1').metadata.openchamber.archive.archivedAt).toBe(0);
|
|
64
|
+
expect(records.get('ses_1').metadata.openchamber.goal.status).toBe('active');
|
|
65
|
+
|
|
66
|
+
const restarted = createSessionMetadataStore({
|
|
67
|
+
dataDir: makeDataDir(),
|
|
68
|
+
recordReader: async (sessionID) => records.get(sessionID) ?? null,
|
|
69
|
+
recordWriter: async () => undefined,
|
|
70
|
+
});
|
|
71
|
+
await expect(restarted.get('ses_1')).resolves.toMatchObject({
|
|
72
|
+
openchamber: {
|
|
73
|
+
archive: { archivedAt: 0 },
|
|
74
|
+
goal: { status: 'active' },
|
|
75
|
+
assistant: { assistantID: 'ast_1' },
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const visible = retainOpenCodeSessions([records.get('ses_1')], {});
|
|
79
|
+
expect(visible).toHaveLength(1);
|
|
80
|
+
expect(visible[0].title).toBe('Keep me');
|
|
81
|
+
expect(visible[0].time.archived).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('does not drop an OpenCode session when the side store has no row', () => {
|
|
85
|
+
const visible = retainOpenCodeSessions([
|
|
86
|
+
{
|
|
87
|
+
id: 'ses_1',
|
|
88
|
+
title: 'Still here',
|
|
89
|
+
metadata: { openchamber: { archive: { archivedAt: 9 } } },
|
|
90
|
+
time: { created: 1, updated: 2 },
|
|
91
|
+
},
|
|
92
|
+
{ id: 'ses_2', title: 'Also here', time: { created: 1, updated: 2 } },
|
|
93
|
+
], {});
|
|
94
|
+
|
|
95
|
+
expect(visible.map((session) => session.id)).toEqual(['ses_1', 'ses_2']);
|
|
96
|
+
expect(visible[0].title).toBe('Still here');
|
|
97
|
+
expect(visible[0].time.archived).toBe(9);
|
|
98
|
+
expect(visible[1].title).toBe('Also here');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('does not treat an unread side store as an empty catalog', () => {
|
|
102
|
+
expect(() => retainOpenCodeSessions([{ id: 'ses_1', title: 'Stay' }], null)).toThrow(/unavailable/);
|
|
103
|
+
expect(() => retainOpenCodeSessions(null, {})).toThrow(/unavailable/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('does not treat a failed side-store load as an authoritative empty migration', async () => {
|
|
107
|
+
const writes = [];
|
|
108
|
+
const records = {
|
|
109
|
+
ses_1: { id: 'ses_1', title: 'Stay', metadata: { openchamber: { goal: { id: 'g1' } } } },
|
|
110
|
+
};
|
|
111
|
+
const result = await migrateLoadedSideStore({
|
|
112
|
+
loadResult: { ok: false, entries: {} },
|
|
113
|
+
readSession: async (id) => records[id] ?? null,
|
|
114
|
+
writeMetadata: async (id, metadata) => {
|
|
115
|
+
writes.push(id);
|
|
116
|
+
records[id] = { ...records[id], metadata };
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(result.status).toBe('unavailable');
|
|
121
|
+
expect(writes).toEqual([]);
|
|
122
|
+
expect(records.ses_1.title).toBe('Stay');
|
|
123
|
+
expect(records.ses_1.metadata.openchamber.goal.id).toBe('g1');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('keeps other sessions when one migration write fails', async () => {
|
|
127
|
+
const records = {
|
|
128
|
+
ses_ok: { id: 'ses_ok', title: 'Ok', metadata: {} },
|
|
129
|
+
ses_bad: { id: 'ses_bad', title: 'Bad', metadata: { openchamber: { llm: { purpose: 'keep' } } } },
|
|
130
|
+
ses_only_opencode: { id: 'ses_only_opencode', title: 'Stay', metadata: { fromOpenCode: true } },
|
|
131
|
+
};
|
|
132
|
+
const side = {
|
|
133
|
+
ses_ok: { openchamber: { goal: { id: 'g1' } } },
|
|
134
|
+
ses_bad: { openchamber: { assistant: { assistantID: 'ast_1' } } },
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const result = await migrateLoadedSideStore({
|
|
138
|
+
loadResult: { ok: true, entries: side },
|
|
139
|
+
readSession: async (id) => records[id] ?? null,
|
|
140
|
+
writeMetadata: async (id, metadata) => {
|
|
141
|
+
if (id === 'ses_bad') throw new Error('write failed');
|
|
142
|
+
records[id] = { ...records[id], metadata };
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(result.status).toBe('partial');
|
|
147
|
+
expect(result.migrated).toEqual(['ses_ok']);
|
|
148
|
+
expect(result.failed).toEqual([{ id: 'ses_bad', error: 'write failed' }]);
|
|
149
|
+
expect(records.ses_ok.metadata.openchamber.goal.id).toBe('g1');
|
|
150
|
+
expect(records.ses_ok.title).toBe('Ok');
|
|
151
|
+
expect(records.ses_bad.title).toBe('Bad');
|
|
152
|
+
expect(records.ses_bad.metadata.openchamber.llm.purpose).toBe('keep');
|
|
153
|
+
expect(records.ses_bad.metadata.openchamber.assistant).toBeUndefined();
|
|
154
|
+
expect(records.ses_only_opencode).toEqual({
|
|
155
|
+
id: 'ses_only_opencode',
|
|
156
|
+
title: 'Stay',
|
|
157
|
+
metadata: { fromOpenCode: true },
|
|
158
|
+
});
|
|
159
|
+
expect(side.ses_bad.openchamber.assistant.assistantID).toBe('ast_1');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('fills side-store gaps without letting an empty side row wipe OpenCode fields', () => {
|
|
163
|
+
expect(mergeOwnedMetadata(
|
|
164
|
+
{ openchamber: { goal: { status: 'paused' } }, fromOpenCode: true },
|
|
165
|
+
{ openchamber: { goal: { id: 'g1' }, archive: { archivedAt: 4 } } },
|
|
166
|
+
)).toEqual({
|
|
167
|
+
fromOpenCode: true,
|
|
168
|
+
openchamber: { goal: { id: 'g1', status: 'paused' }, archive: { archivedAt: 4 } },
|
|
169
|
+
});
|
|
170
|
+
expect(mergeOwnedMetadata({ keep: true }, {})).toEqual({ keep: true });
|
|
171
|
+
expect(mergeOwnedMetadata({ keep: true }, null)).toEqual({ keep: true });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('throws when the installed client has no session.update instead of writing nothing', async () => {
|
|
175
|
+
await expect(writeSessionRecordViaClient({ session: {} }, {
|
|
176
|
+
sessionID: 'ses_1',
|
|
177
|
+
metadata: { openchamber: { archive: { archivedAt: 1 } } },
|
|
178
|
+
})).rejects.toThrow(/session\.update is unavailable/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('writes metadata through session.update without sending title', async () => {
|
|
182
|
+
const update = vi.fn(async () => undefined);
|
|
183
|
+
await writeSessionRecordViaClient({ session: { update } }, {
|
|
184
|
+
sessionID: 'ses_1',
|
|
185
|
+
metadata: { openchamber: { archive: { archivedAt: 3 } } },
|
|
186
|
+
});
|
|
187
|
+
expect(update).toHaveBeenCalledWith({
|
|
188
|
+
sessionID: 'ses_1',
|
|
189
|
+
metadata: { openchamber: { archive: { archivedAt: 3 } } },
|
|
190
|
+
}, undefined);
|
|
191
|
+
expect(update.mock.calls[0][0].title).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('reads a missing session as not-found and a transport failure as failure', async () => {
|
|
195
|
+
const missing = Object.assign(new Error('gone'), { status: 404 });
|
|
196
|
+
await expect(readSessionRecordViaClient({
|
|
197
|
+
session: { get: async () => { throw missing; } },
|
|
198
|
+
}, { sessionID: 'ses_1' })).resolves.toBeNull();
|
|
199
|
+
|
|
200
|
+
const down = Object.assign(new Error('down'), { status: 503 });
|
|
201
|
+
await expect(readSessionRecordViaClient({
|
|
202
|
+
session: { get: async () => { throw down; } },
|
|
203
|
+
}, { sessionID: 'ses_1' })).rejects.toThrow(/down/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('patches the session record over HTTP and does not turn a failed read into an empty session', async () => {
|
|
207
|
+
const calls = [];
|
|
208
|
+
const fetchFn = vi.fn(async (url, init) => {
|
|
209
|
+
calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : undefined });
|
|
210
|
+
if (init.method === 'PATCH' && String(url).includes('ses_ok')) {
|
|
211
|
+
return { ok: true, status: 204, json: async () => null };
|
|
212
|
+
}
|
|
213
|
+
if (String(url).includes('ses_missing')) {
|
|
214
|
+
return { ok: false, status: 404, json: async () => ({}) };
|
|
215
|
+
}
|
|
216
|
+
if (String(url).includes('ses_down')) {
|
|
217
|
+
return { ok: false, status: 503, json: async () => ({}) };
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
ok: true,
|
|
221
|
+
status: 200,
|
|
222
|
+
json: async () => ({ id: 'ses_ok', title: 'Keep', metadata: { fromOpenCode: true } }),
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
const client = createHttpSessionRecordClient({
|
|
226
|
+
buildSessionUrl: (sessionID) => `http://opencode.local/api/session/${sessionID}`,
|
|
227
|
+
fetchFn,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await expect(client.read('ses_ok')).resolves.toMatchObject({ id: 'ses_ok', title: 'Keep' });
|
|
231
|
+
await expect(client.read('ses_missing')).resolves.toBeNull();
|
|
232
|
+
await expect(client.read('ses_down')).rejects.toThrow(/503/);
|
|
233
|
+
await client.write({
|
|
234
|
+
sessionID: 'ses_ok',
|
|
235
|
+
metadata: { openchamber: { archive: { archivedAt: 8 } } },
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const patch = calls.find((call) => call.method === 'PATCH');
|
|
239
|
+
expect(patch.body).toEqual({ metadata: { openchamber: { archive: { archivedAt: 8 } } } });
|
|
240
|
+
expect(patch.body.title).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -83,13 +83,17 @@ export const registerSessionMetadataRoutes = (app, deps = {}) => {
|
|
|
83
83
|
},
|
|
84
84
|
},
|
|
85
85
|
};
|
|
86
|
+
}, {
|
|
87
|
+
directory: asNonEmptyString(body.directory) || undefined,
|
|
86
88
|
});
|
|
87
89
|
if (!result?.committed) {
|
|
88
90
|
throw new Error(result?.reason || 'session metadata mutate was rejected');
|
|
89
91
|
}
|
|
90
92
|
metadata = result.metadata;
|
|
91
93
|
} else {
|
|
92
|
-
metadata = await sessionMetadataStore.setSessionMetadata(sessionId, patch
|
|
94
|
+
metadata = await sessionMetadataStore.setSessionMetadata(sessionId, patch, {
|
|
95
|
+
directory: asNonEmptyString(body.directory) || undefined,
|
|
96
|
+
});
|
|
93
97
|
}
|
|
94
98
|
const directory = asNonEmptyString(body.directory) || '';
|
|
95
99
|
if (typeof onMetadataWritten === 'function') {
|
|
@@ -451,7 +451,7 @@ export const createSessionArchiveService = (deps = {}) => {
|
|
|
451
451
|
const metadata = await sessionMetadataStore.setSessionMetadata(
|
|
452
452
|
sessionID,
|
|
453
453
|
{ openchamber: { archive: { archivedAt } } },
|
|
454
|
-
{ allowArchive: true },
|
|
454
|
+
{ allowArchive: true, directory: upstreamDirectory || directoryHint },
|
|
455
455
|
);
|
|
456
456
|
|
|
457
457
|
// New Host mutation wins: bump generation and cancel older repair timers
|
|
@@ -33,4 +33,6 @@ export function createSessionMetadataStore(options: {
|
|
|
33
33
|
fsPromises?: typeof import('node:fs').promises;
|
|
34
34
|
path?: typeof import('node:path');
|
|
35
35
|
now?: () => number;
|
|
36
|
+
recordReader?: (sessionID: string, directory?: string | null) => Promise<object | null>;
|
|
37
|
+
recordWriter?: (sessionID: string, metadata: object, directory?: string | null) => Promise<void>;
|
|
36
38
|
}): SessionMetadataStore;
|
|
@@ -1,26 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenChamber
|
|
2
|
+
* OpenChamber session metadata cache and migration source.
|
|
3
3
|
*
|
|
4
|
-
* OpenCode
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* The OpenCode session record is the durable authority for title, archive, and
|
|
5
|
+
* other OpenChamber metadata (`opencode-session-record.js`). This JSON file
|
|
6
|
+
* remains the cache and the pre-migration source. A failed or corrupt read is
|
|
7
|
+
* never empty success: the store stays unwritable and does not overwrite
|
|
8
|
+
* unknown disk state or the OpenCode record.
|
|
9
9
|
*
|
|
10
10
|
* Writes are a JSON Merge Patch (RFC 7386): nested objects merge key by key and
|
|
11
|
-
* a `null` deletes. That
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* a `null` deletes. That keeps two features writing into the same `openchamber`
|
|
12
|
+
* namespace from erasing each other — goal mode saving progress must not drop
|
|
13
|
+
* an assist recap. When a record reader and writer are wired, the merged object
|
|
14
|
+
* is written onto the OpenCode session before the local cache commits. A failed
|
|
15
|
+
* record read is not treated as empty metadata.
|
|
14
16
|
*
|
|
15
17
|
* Mutations are fully serialized. Committed `entries` update only after a
|
|
16
18
|
* 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.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import fsDefault from 'node:fs';
|
|
22
22
|
import pathDefault from 'node:path';
|
|
23
23
|
|
|
24
|
+
import { mergeOwnedMetadata } from './opencode-session-record.js';
|
|
25
|
+
|
|
24
26
|
const METADATA_FILE_NAME = 'sessions-metadata.json';
|
|
25
27
|
const HOST_ARCHIVE_KEY = 'archive';
|
|
26
28
|
|
|
@@ -103,13 +105,22 @@ const applyReservedArchiveProtection = (previous, patch, merged, { allowArchive
|
|
|
103
105
|
* @param {typeof fsDefault.promises} [options.fsPromises]
|
|
104
106
|
* @param {typeof pathDefault} [options.path]
|
|
105
107
|
* @param {() => number} [options.now]
|
|
108
|
+
* @param {(sessionID: string, directory?: string | null) => Promise<object | null>} [options.recordReader]
|
|
109
|
+
* OpenCode session read. Null means the session is gone. Any other failure throws.
|
|
110
|
+
* @param {(sessionID: string, metadata: object, directory?: string | null) => Promise<void>} [options.recordWriter]
|
|
111
|
+
* Writes the full merged metadata onto the OpenCode session record. Must not send title.
|
|
106
112
|
*/
|
|
107
113
|
export const createSessionMetadataStore = ({
|
|
108
114
|
dataDir,
|
|
109
115
|
fsPromises = fsDefault.promises,
|
|
110
116
|
path = pathDefault,
|
|
111
117
|
now = Date.now,
|
|
118
|
+
recordReader = null,
|
|
119
|
+
recordWriter = null,
|
|
112
120
|
}) => {
|
|
121
|
+
if (typeof recordWriter === 'function' && typeof recordReader !== 'function') {
|
|
122
|
+
throw new Error('session record writer requires a record reader');
|
|
123
|
+
}
|
|
113
124
|
const filePath = path.join(dataDir, METADATA_FILE_NAME);
|
|
114
125
|
|
|
115
126
|
/** sessionID → metadata. Committed only — never holds an in-flight draft. */
|
|
@@ -218,11 +229,27 @@ export const createSessionMetadataStore = ({
|
|
|
218
229
|
return next;
|
|
219
230
|
};
|
|
220
231
|
|
|
221
|
-
const
|
|
232
|
+
const readRecordMetadata = async (id, directory) => {
|
|
233
|
+
if (typeof recordReader !== 'function') return undefined;
|
|
234
|
+
try {
|
|
235
|
+
const record = await recordReader(id, directory);
|
|
236
|
+
if (record == null) return null;
|
|
237
|
+
return isPlainObject(record.metadata) ? record.metadata : {};
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (entries.has(id)) return undefined;
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const get = async (sessionID, options = {}) => {
|
|
222
245
|
const id = asNonEmptyString(sessionID);
|
|
223
246
|
if (!id) return {};
|
|
224
247
|
await ensureReadable();
|
|
225
|
-
|
|
248
|
+
const side = entries.get(id);
|
|
249
|
+
const fromRecord = await readRecordMetadata(id, options.directory);
|
|
250
|
+
if (fromRecord === undefined) return side ?? {};
|
|
251
|
+
if (fromRecord === null) return side ?? {};
|
|
252
|
+
return mergeOwnedMetadata(fromRecord, side);
|
|
226
253
|
};
|
|
227
254
|
|
|
228
255
|
const has = async (sessionID) => {
|
|
@@ -243,16 +270,36 @@ export const createSessionMetadataStore = ({
|
|
|
243
270
|
*/
|
|
244
271
|
const commitMergedPatch = async (id, previous, patch, options = {}) => {
|
|
245
272
|
const allowArchive = options.allowArchive === true;
|
|
273
|
+
let base = previous;
|
|
274
|
+
if (typeof recordReader === 'function') {
|
|
275
|
+
let record;
|
|
276
|
+
try {
|
|
277
|
+
record = await recordReader(id, options.directory);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
// A failed read is not an empty record. Do not replace metadata we could not see.
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
if (record == null) {
|
|
283
|
+
const error = new Error(`session ${id} was not found`);
|
|
284
|
+
error.status = 404;
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
base = mergeOwnedMetadata(record.metadata, previous);
|
|
288
|
+
}
|
|
246
289
|
const effectivePatch = allowArchive ? patch : stripArchiveFromMetadataPatch(patch);
|
|
247
290
|
|
|
248
|
-
let merged = mergeMetadataPatch(
|
|
249
|
-
merged = applyReservedArchiveProtection(
|
|
291
|
+
let merged = mergeMetadataPatch(base, effectivePatch);
|
|
292
|
+
merged = applyReservedArchiveProtection(base, patch, merged, { allowArchive });
|
|
293
|
+
|
|
294
|
+
if (typeof recordWriter === 'function') {
|
|
295
|
+
await recordWriter(id, merged, options.directory);
|
|
296
|
+
}
|
|
250
297
|
|
|
251
298
|
const nextMap = new Map(entries);
|
|
252
299
|
if (Object.keys(merged).length === 0) nextMap.delete(id);
|
|
253
300
|
else nextMap.set(id, merged);
|
|
254
301
|
|
|
255
|
-
//
|
|
302
|
+
// OpenCode write already landed. Publish the local cache only after its persist succeeds.
|
|
256
303
|
await persistMap(nextMap);
|
|
257
304
|
|
|
258
305
|
if (Object.keys(merged).length === 0) entries.delete(id);
|
|
@@ -53,6 +53,29 @@ describe('mergeMetadataPatch', () => {
|
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
describe('createSessionMetadataStore', () => {
|
|
56
|
+
it('round-trips title authority through the OpenCode record and restart without losing neighboring metadata', async () => {
|
|
57
|
+
const dataDir = makeDataDir();
|
|
58
|
+
const record = { id: 'ses_1', title: 'Native', metadata: { plugin: { retained: true } } };
|
|
59
|
+
const authority = { sessionID: 'ses_1', title: 'Chosen', revision: 'r1', source: 'summary' };
|
|
60
|
+
const deps = {
|
|
61
|
+
dataDir,
|
|
62
|
+
recordReader: async () => structuredClone(record),
|
|
63
|
+
recordWriter: async (_id, metadata) => { record.metadata = structuredClone(metadata); },
|
|
64
|
+
};
|
|
65
|
+
const store = createSessionMetadataStore(deps);
|
|
66
|
+
await store.mutateSessionMetadata('ses_1', () => ({ ok: true, patch: {
|
|
67
|
+
openchamber: { titleAuthority: authority },
|
|
68
|
+
} }));
|
|
69
|
+
expect(record.metadata).toEqual({ plugin: { retained: true }, openchamber: { titleAuthority: authority } });
|
|
70
|
+
const reopened = createSessionMetadataStore(deps);
|
|
71
|
+
expect((await reopened.get('ses_1')).openchamber.titleAuthority).toEqual(authority);
|
|
72
|
+
expect((await reopened.getAll()).ses_1.openchamber.titleAuthority).toEqual(authority);
|
|
73
|
+
const stale = await reopened.mutateSessionMetadata('ses_1', (metadata) => ({
|
|
74
|
+
ok: metadata.openchamber.titleAuthority.revision === 'old', patch: { openchamber: { titleAuthority: null } },
|
|
75
|
+
}));
|
|
76
|
+
expect(stale.committed).toBe(false);
|
|
77
|
+
expect(record.metadata.openchamber.titleAuthority).toEqual(authority);
|
|
78
|
+
});
|
|
56
79
|
it('starts empty when the file does not exist', async () => {
|
|
57
80
|
const store = createSessionMetadataStore({ dataDir: makeDataDir() });
|
|
58
81
|
await expect(store.getAll()).resolves.toEqual({});
|
|
@@ -39,3 +39,4 @@ export function projectSessionLifecyclePayload(
|
|
|
39
39
|
readHostMetadata: (sessionId: string) => unknown,
|
|
40
40
|
options?: { hostReady?: boolean },
|
|
41
41
|
): unknown;
|
|
42
|
+
export function readSessionTitleAuthority(sessionID: string, metadata: unknown): { sessionID: string; title: string; revision: string; source?: string } | null;
|
|
@@ -13,6 +13,14 @@
|
|
|
13
13
|
|
|
14
14
|
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
15
15
|
|
|
16
|
+
export const readSessionTitleAuthority = (sessionID, metadata) => {
|
|
17
|
+
const authority = metadata?.openchamber?.titleAuthority;
|
|
18
|
+
return typeof sessionID === 'string' && sessionID.length > 0 && authority?.sessionID === sessionID
|
|
19
|
+
&& typeof authority.title === 'string' && authority.title.trim()
|
|
20
|
+
&& typeof authority.revision === 'string' && authority.revision
|
|
21
|
+
? authority : null;
|
|
22
|
+
};
|
|
23
|
+
|
|
16
24
|
/** RFC 7386 merge (local copy — avoid circular import with the metadata store). */
|
|
17
25
|
const mergeMetadataPatch = (current, patch) => {
|
|
18
26
|
const base = isPlainObject(current) ? { ...current } : {};
|
|
@@ -33,6 +41,7 @@ const SESSION_LIFECYCLE_BASE = new Set([
|
|
|
33
41
|
'session.created',
|
|
34
42
|
'session.updated',
|
|
35
43
|
'session.deleted',
|
|
44
|
+
'session.renamed',
|
|
36
45
|
]);
|
|
37
46
|
|
|
38
47
|
/**
|
|
@@ -156,8 +165,10 @@ export const projectSessionWithHostMetadata = (session, hostMetadata) => {
|
|
|
156
165
|
else timeBase.archived = projectedArchived;
|
|
157
166
|
|
|
158
167
|
const directory = resolveSessionDirectory(session);
|
|
168
|
+
const titleAuthority = readSessionTitleAuthority(session.id, metadata);
|
|
159
169
|
const next = {
|
|
160
170
|
...session,
|
|
171
|
+
...(titleAuthority ? { title: titleAuthority.title } : {}),
|
|
161
172
|
metadata: Object.keys(metadata).length > 0 ? metadata : (session.metadata ?? metadata),
|
|
162
173
|
time: timeBase,
|
|
163
174
|
...(directory ? { directory } : {}),
|
|
@@ -168,6 +179,30 @@ export const projectSessionWithHostMetadata = (session, hostMetadata) => {
|
|
|
168
179
|
return next;
|
|
169
180
|
};
|
|
170
181
|
|
|
182
|
+
/**
|
|
183
|
+
* OpenCode record keys win. Side-store keys the record does not have yet are
|
|
184
|
+
* filled in. A missing or empty side row does not wipe the record.
|
|
185
|
+
*
|
|
186
|
+
* @param {unknown} openCodeMetadata
|
|
187
|
+
* @param {unknown} sideMetadata
|
|
188
|
+
*/
|
|
189
|
+
export const mergeOwnedMetadata = (openCodeMetadata, sideMetadata) => {
|
|
190
|
+
const openCode = isPlainObject(openCodeMetadata) ? openCodeMetadata : {};
|
|
191
|
+
if (!isPlainObject(sideMetadata) || Object.keys(sideMetadata).length === 0) {
|
|
192
|
+
return { ...openCode };
|
|
193
|
+
}
|
|
194
|
+
return mergeMetadataPatch(sideMetadata, openCode);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const projectRecordMetadata = (session, sideMetadata) => {
|
|
198
|
+
const metadata = mergeOwnedMetadata(session.metadata, sideMetadata);
|
|
199
|
+
const hadMetadata = isPlainObject(session.metadata);
|
|
200
|
+
const withRecord = Object.keys(metadata).length === 0 && !hadMetadata
|
|
201
|
+
? session
|
|
202
|
+
: { ...session, metadata };
|
|
203
|
+
return projectSessionWithHostMetadata(withRecord, null);
|
|
204
|
+
};
|
|
205
|
+
|
|
171
206
|
/**
|
|
172
207
|
* @param {object | null | undefined} session
|
|
173
208
|
* @param {Record<string, object> | null | undefined} storedBySessionId
|
|
@@ -178,11 +213,20 @@ export const projectSessionWithStoredMap = (session, storedBySessionId) => {
|
|
|
178
213
|
return projectSessionWithHostMetadata(session, null);
|
|
179
214
|
}
|
|
180
215
|
const host = storedBySessionId[session.id];
|
|
181
|
-
return
|
|
216
|
+
return projectRecordMetadata(session, isPlainObject(host) ? host : null);
|
|
182
217
|
};
|
|
183
218
|
|
|
184
219
|
const projectLifecycleInner = (payload, readHostMetadata) => {
|
|
185
220
|
const baseType = normalizeSessionEventType(payload.type);
|
|
221
|
+
if (baseType === 'session.renamed') {
|
|
222
|
+
const data = payload.data ?? payload.properties;
|
|
223
|
+
if (typeof data?.sessionID !== 'string') return payload;
|
|
224
|
+
const authority = readSessionTitleAuthority(data?.sessionID, readHostMetadata?.(data?.sessionID));
|
|
225
|
+
if (!authority || data.title === authority.title) return payload;
|
|
226
|
+
return payload.data
|
|
227
|
+
? { ...payload, data: { ...data, title: authority.title } }
|
|
228
|
+
: { ...payload, properties: { ...data, title: authority.title } };
|
|
229
|
+
}
|
|
186
230
|
if (baseType !== 'session.created' && baseType !== 'session.updated') return payload;
|
|
187
231
|
|
|
188
232
|
const info = extractSessionInfoFromPayload(payload);
|
|
@@ -196,7 +240,7 @@ const projectLifecycleInner = (payload, readHostMetadata) => {
|
|
|
196
240
|
host = null;
|
|
197
241
|
}
|
|
198
242
|
}
|
|
199
|
-
const projected =
|
|
243
|
+
const projected = projectRecordMetadata(info, isPlainObject(host) ? host : null);
|
|
200
244
|
if (projected === info) return payload;
|
|
201
245
|
|
|
202
246
|
const next = { ...payload };
|
|
@@ -243,6 +287,9 @@ export const projectSessionLifecyclePayload = (payload, readHostMetadata, option
|
|
|
243
287
|
}
|
|
244
288
|
|
|
245
289
|
const type = typeof payload.type === 'string' ? payload.type : '';
|
|
290
|
+
if (type === 'session.renamed') {
|
|
291
|
+
return options.hostReady === false ? null : projectLifecycleInner(payload, readHostMetadata);
|
|
292
|
+
}
|
|
246
293
|
if (options.hostReady === false && isSessionLifecycleEventType(type)) {
|
|
247
294
|
return null;
|
|
248
295
|
}
|
|
@@ -5,9 +5,44 @@ import {
|
|
|
5
5
|
normalizeSessionEventType,
|
|
6
6
|
projectSessionLifecyclePayload,
|
|
7
7
|
resolveSessionDirectory,
|
|
8
|
+
projectSessionWithHostMetadata,
|
|
9
|
+
readSessionTitleAuthority,
|
|
8
10
|
} from './session-projection.js';
|
|
9
11
|
|
|
10
12
|
describe('session projection event shapes', () => {
|
|
13
|
+
const titleMetadata = { openchamber: { titleAuthority: {
|
|
14
|
+
sessionID: 'ses_owned', title: 'Chosen title', revision: 'r1', source: 'summary',
|
|
15
|
+
} } };
|
|
16
|
+
|
|
17
|
+
it('keeps the owned title in snapshots and native rename events regardless of upstream clocks', () => {
|
|
18
|
+
const upstream = { id: 'ses_owned', title: 'Native title', time: { updated: 20 } };
|
|
19
|
+
expect(projectSessionWithHostMetadata(upstream, titleMetadata).title).toBe('Chosen title');
|
|
20
|
+
for (const created of [1, 20, 10000]) {
|
|
21
|
+
const event = { type: 'session.renamed', created, data: { sessionID: 'ses_owned', title: 'Native title' } };
|
|
22
|
+
expect(projectSessionLifecyclePayload(event, () => titleMetadata)).toEqual({
|
|
23
|
+
...event, data: { ...event.data, title: 'Chosen title' },
|
|
24
|
+
});
|
|
25
|
+
expect(event.data.title).toBe('Native title');
|
|
26
|
+
}
|
|
27
|
+
expect(projectSessionWithHostMetadata({ ...upstream, id: 'ses_fork' }, titleMetadata).title).toBe('Native title');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('suppresses native rename while metadata is unknown, including wrapped replay', () => {
|
|
31
|
+
const payload = { directory: '/repo', payload: {
|
|
32
|
+
type: 'session.renamed', data: { sessionID: 'ses_owned', title: 'Native' },
|
|
33
|
+
} };
|
|
34
|
+
expect(projectSessionLifecyclePayload(payload, () => null, { hostReady: false })).toBeNull();
|
|
35
|
+
expect(projectSessionLifecyclePayload(payload, () => titleMetadata).payload.data.title).toBe('Chosen title');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('ignores malformed or absent authority and rename identities', () => {
|
|
39
|
+
for (const metadata of [undefined, {}, { openchamber: { titleAuthority: {} } }]) {
|
|
40
|
+
expect(readSessionTitleAuthority(undefined, metadata)).toBeNull();
|
|
41
|
+
expect(readSessionTitleAuthority('ses_owned', metadata)).toBeNull();
|
|
42
|
+
}
|
|
43
|
+
const event = { type: 'session.renamed', data: {} };
|
|
44
|
+
expect(projectSessionLifecyclePayload(event, () => titleMetadata)).toBe(event);
|
|
45
|
+
});
|
|
11
46
|
it('normalizes version suffixes', () => {
|
|
12
47
|
expect(normalizeSessionEventType('session.updated.v2')).toBe('session.updated');
|
|
13
48
|
expect(normalizeSessionEventType('session.created')).toBe('session.created');
|