@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
|
@@ -22,7 +22,9 @@ the index excludes them from every snapshot and clears prior summaries when a
|
|
|
22
22
|
`metadata.openchamber.smallModel.purpose`, or
|
|
23
23
|
`metadata.openchamber.llm.purpose`. Title prefixes are human labels and
|
|
24
24
|
never participate in this filter. Metadata is the ownership/isolation signal;
|
|
25
|
-
`time.archived` is archive state
|
|
25
|
+
`time.archived` is archive state (Host authority via
|
|
26
|
+
`metadata.openchamber.archive.archivedAt` when present — see
|
|
27
|
+
`session-metadata/`); `time.pinned` is OpenChamber-owned pin state
|
|
26
28
|
overlaid from the independent `session_pin` table (also mirrored onto in-window
|
|
27
29
|
`session_summary.pinned_at` for older readers). Pins are not subject to the
|
|
28
30
|
newest-20 summary bound: evicting or rebuilding a directory keeps pin
|
|
@@ -30,7 +32,9 @@ membership, and `setPinned` can target a session that is not currently in the
|
|
|
30
32
|
index. Archive or `remove` still clears the pin. Live OpenCode upserts never
|
|
31
33
|
overwrite pin membership. Titles are for recognition. The index does
|
|
32
34
|
not cache system sessions, so cold-start and live upserts stay aligned with
|
|
33
|
-
ordinary sidebar lists.
|
|
35
|
+
ordinary sidebar lists. System session creators persist isolation through the
|
|
36
|
+
Host metadata store, then `onSystemSessionPersisted` upserts the projected
|
|
37
|
+
session so a create-before-persist race cannot leave a visible summary.
|
|
34
38
|
Pin and unpin use `POST` / `DELETE`
|
|
35
39
|
`/api/openchamber/session-index/session/:id/pin`, write `session_pin`, then
|
|
36
40
|
`publishChange()` so revision tips broadcast `openchamber:session-index-changed`.
|
|
@@ -49,19 +53,18 @@ events that remain outside the newest-20 bound follow the same no-op path.
|
|
|
49
53
|
|
|
50
54
|
`sync-runtime.js` owns cold-start synchronization. The renderer submits all
|
|
51
55
|
known project directories once to `POST /api/openchamber/session-index/sync`.
|
|
52
|
-
The runtime processes them sequentially
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
their matching loading state.
|
|
56
|
+
The runtime processes them sequentially. OpenCode 2.x list is cursor-only (no
|
|
57
|
+
`start` incremental); each directory fetches active-only pages (`limit=20`)
|
|
58
|
+
after optional Host `projectSessions` overlay. Consecutive archived rows may
|
|
59
|
+
consume further pages within a small page budget so the newest-20 **active**
|
|
60
|
+
window can still fill; exhausting the budget before the window is full is an
|
|
61
|
+
explicit partial result (`hasMore` stays true). `replaceDirectory` /
|
|
62
|
+
event-ingest upsert keep active-only rows (archived clears the summary and pin).
|
|
63
|
+
It publishes an in-memory revision after every externally observable index or
|
|
64
|
+
synchronization-state change. A successful empty directory refresh remains in
|
|
65
|
+
the snapshot so another client can use it as a worktree-topology recovery hint.
|
|
66
|
+
Stopping the runtime marks queued and in-flight directories as failed in the
|
|
67
|
+
published progress snapshot so clients can retire their matching loading state.
|
|
65
68
|
|
|
66
69
|
The renderer observes revisions through OpenChamber SSE tip events
|
|
67
70
|
(`openchamber:session-index-changed`). Each tip carries the new revision and
|
|
@@ -1,58 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
extractSessionInfoFromPayload,
|
|
3
|
+
isSessionLifecycleEventType,
|
|
4
|
+
normalizeSessionEventType,
|
|
5
|
+
resolveSessionDirectory,
|
|
6
|
+
} from '../session-metadata/session-projection.js';
|
|
7
|
+
|
|
1
8
|
const withDirectory = (session, directory) => {
|
|
2
9
|
if (!session || typeof session !== 'object') return null;
|
|
10
|
+
const resolved = resolveSessionDirectory(session);
|
|
11
|
+
if (resolved) return { ...session, directory: resolved };
|
|
3
12
|
if (session.directory || session.project?.worktree || !directory || directory === 'global') return session;
|
|
4
13
|
return { ...session, directory };
|
|
5
14
|
};
|
|
6
15
|
|
|
7
|
-
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} service
|
|
18
|
+
* @param {object} event
|
|
19
|
+
* @param {number} [observedAt]
|
|
20
|
+
* @param {object} [options]
|
|
21
|
+
* @param {(session: object) => object | null | undefined} [options.projectSession]
|
|
22
|
+
* Host authority projection before index write. Returning `null` means Host
|
|
23
|
+
* not ready / unavailable — skip the index write and keep prior rows.
|
|
24
|
+
* @param {(sessionID: string) => void | Promise<void>} [options.onSessionDeleted]
|
|
25
|
+
* @param {() => boolean} [options.isHostReady]
|
|
26
|
+
* When false, session lifecycle upserts are skipped until metadata is ready.
|
|
27
|
+
*/
|
|
28
|
+
export const applySessionIndexEvent = (service, event, observedAt = Date.now(), options = {}) => {
|
|
8
29
|
if (!service || !event || typeof event !== 'object') return false;
|
|
9
30
|
const payload = event.payload && typeof event.payload === 'object' ? event.payload : event;
|
|
10
31
|
const directory = typeof event.directory === 'string' ? event.directory : '';
|
|
11
32
|
const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
|
|
33
|
+
const projectSession = typeof options.projectSession === 'function' ? options.projectSession : null;
|
|
34
|
+
const onSessionDeleted = typeof options.onSessionDeleted === 'function' ? options.onSessionDeleted : null;
|
|
35
|
+
const isHostReady = typeof options.isHostReady === 'function' ? options.isHostReady : null;
|
|
36
|
+
|
|
37
|
+
const baseType = normalizeSessionEventType(payload.type);
|
|
38
|
+
|
|
39
|
+
if (baseType === 'session.created' || baseType === 'session.updated') {
|
|
40
|
+
// Until Host metadata is ready, do not write session lifecycle into the
|
|
41
|
+
// index (would bake unprojected upstream archive). Transcript paths use
|
|
42
|
+
// message.* and stay open.
|
|
43
|
+
if (isHostReady && !isHostReady()) return false;
|
|
12
44
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
45
|
+
let session = withDirectory(extractSessionInfoFromPayload(payload), directory);
|
|
46
|
+
if (!session) return false;
|
|
47
|
+
|
|
48
|
+
if (projectSession) {
|
|
49
|
+
try {
|
|
50
|
+
const projected = projectSession(session);
|
|
51
|
+
// null/undefined from projector = Host unavailable — keep old index data.
|
|
52
|
+
if (projected == null) return false;
|
|
53
|
+
session = projected;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.warn('[session-index] projectSession failed; keeping prior index rows:', error?.message ?? error);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return service.upsertAndReportChange(session, observedAt, { preserveActivity: true });
|
|
16
60
|
}
|
|
17
61
|
|
|
18
|
-
if (
|
|
62
|
+
if (baseType === 'session.deleted') {
|
|
19
63
|
const sessionID = typeof properties.sessionID === 'string'
|
|
20
64
|
? properties.sessionID
|
|
21
65
|
: typeof properties.info?.id === 'string'
|
|
22
66
|
? properties.info.id
|
|
23
|
-
: ''
|
|
24
|
-
|
|
67
|
+
: typeof payload.data?.sessionID === 'string'
|
|
68
|
+
? payload.data.sessionID
|
|
69
|
+
: typeof payload.data?.info?.id === 'string'
|
|
70
|
+
? payload.data.info.id
|
|
71
|
+
: '';
|
|
72
|
+
if (!sessionID) return false;
|
|
73
|
+
const removed = service.remove(sessionID);
|
|
74
|
+
if (onSessionDeleted) {
|
|
75
|
+
try {
|
|
76
|
+
const result = onSessionDeleted(sessionID);
|
|
77
|
+
if (result && typeof result.catch === 'function') {
|
|
78
|
+
result.catch((error) => {
|
|
79
|
+
console.warn('[session-index] onSessionDeleted failed (retryable):', error?.message ?? error);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.warn('[session-index] onSessionDeleted failed (retryable):', error?.message ?? error);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return removed;
|
|
25
87
|
}
|
|
26
88
|
|
|
27
|
-
if (payload.type === 'message.updated') {
|
|
28
|
-
const info = properties.info && typeof properties.info === 'object'
|
|
89
|
+
if (baseType === 'message.updated' || payload.type === 'message.updated') {
|
|
90
|
+
const info = properties.info && typeof properties.info === 'object'
|
|
91
|
+
? properties.info
|
|
92
|
+
: (payload.data?.info && typeof payload.data.info === 'object' ? payload.data.info : {});
|
|
29
93
|
if (info.role !== 'user' || typeof info.sessionID !== 'string') return false;
|
|
30
94
|
const activityAt = typeof info.time?.created === 'number' ? info.time.created : observedAt;
|
|
31
95
|
return service.touchActivity(info.sessionID, activityAt);
|
|
32
96
|
}
|
|
33
97
|
|
|
34
|
-
if (
|
|
35
|
-
const sessionID = typeof properties.sessionID === 'string'
|
|
98
|
+
if (baseType === 'session.status') {
|
|
99
|
+
const sessionID = typeof properties.sessionID === 'string'
|
|
100
|
+
? properties.sessionID
|
|
101
|
+
: (typeof payload.data?.sessionID === 'string' ? payload.data.sessionID : '');
|
|
36
102
|
const status = typeof properties.status?.type === 'string'
|
|
37
103
|
? properties.status.type
|
|
38
104
|
: typeof properties.info?.type === 'string'
|
|
39
105
|
? properties.info.type
|
|
40
|
-
: '';
|
|
106
|
+
: (typeof payload.data?.status?.type === 'string' ? payload.data.status.type : '');
|
|
41
107
|
return sessionID && status ? service.updateStatus(sessionID, status, observedAt) : false;
|
|
42
108
|
}
|
|
43
109
|
|
|
44
|
-
if (
|
|
45
|
-
const sessionID = typeof properties.sessionID === 'string'
|
|
110
|
+
if (baseType === 'session.idle') {
|
|
111
|
+
const sessionID = typeof properties.sessionID === 'string'
|
|
112
|
+
? properties.sessionID
|
|
113
|
+
: (typeof payload.data?.sessionID === 'string' ? payload.data.sessionID : '');
|
|
46
114
|
if (!sessionID) return false;
|
|
47
115
|
const activityChanged = service.touchActivity(sessionID, observedAt);
|
|
48
116
|
const statusChanged = service.updateStatus(sessionID, 'idle', observedAt);
|
|
49
117
|
return activityChanged || statusChanged;
|
|
50
118
|
}
|
|
51
119
|
|
|
52
|
-
if (
|
|
53
|
-
const sessionID = typeof properties.sessionID === 'string'
|
|
120
|
+
if (baseType === 'session.error') {
|
|
121
|
+
const sessionID = typeof properties.sessionID === 'string'
|
|
122
|
+
? properties.sessionID
|
|
123
|
+
: (typeof payload.data?.sessionID === 'string' ? payload.data.sessionID : '');
|
|
54
124
|
return sessionID ? service.updateStatus(sessionID, 'idle', observedAt) : false;
|
|
55
125
|
}
|
|
56
126
|
|
|
127
|
+
// Unknown lifecycle variants with version suffixes already normalized above.
|
|
128
|
+
if (isSessionLifecycleEventType(payload.type)) return false;
|
|
129
|
+
|
|
57
130
|
return false;
|
|
58
131
|
};
|
|
@@ -78,4 +78,77 @@ describe('session index event ingest', () => {
|
|
|
78
78
|
expect(service.touchActivity).toHaveBeenCalledWith('ses_1', 400);
|
|
79
79
|
expect(service.updateStatus).toHaveBeenCalledWith('ses_1', 'idle', 400);
|
|
80
80
|
});
|
|
81
|
+
|
|
82
|
+
it('projects Host archive onto session upserts and cleans metadata on delete', () => {
|
|
83
|
+
const service = {
|
|
84
|
+
upsertAndReportChange: vi.fn(() => true),
|
|
85
|
+
remove: vi.fn(() => true),
|
|
86
|
+
};
|
|
87
|
+
const onSessionDeleted = vi.fn();
|
|
88
|
+
const projectSession = (session) => ({
|
|
89
|
+
...session,
|
|
90
|
+
time: { ...session.time, archived: 77 },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
applySessionIndexEvent(service, {
|
|
94
|
+
directory: '/repo',
|
|
95
|
+
payload: {
|
|
96
|
+
type: 'session.updated',
|
|
97
|
+
properties: { info: { id: 'ses_1', title: 'S', time: { created: 1, updated: 1 } } },
|
|
98
|
+
},
|
|
99
|
+
}, 100, { projectSession, isHostReady: () => true });
|
|
100
|
+
|
|
101
|
+
expect(service.upsertAndReportChange).toHaveBeenCalledWith(
|
|
102
|
+
expect.objectContaining({ id: 'ses_1', time: { created: 1, updated: 1, archived: 77 } }),
|
|
103
|
+
100,
|
|
104
|
+
{ preserveActivity: true },
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
applySessionIndexEvent(service, {
|
|
108
|
+
payload: { type: 'session.deleted', properties: { sessionID: 'ses_1' } },
|
|
109
|
+
}, 200, { onSessionDeleted });
|
|
110
|
+
expect(service.remove).toHaveBeenCalledWith('ses_1');
|
|
111
|
+
expect(onSessionDeleted).toHaveBeenCalledWith('ses_1');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('supports v2 data.info and versioned type suffixes', () => {
|
|
115
|
+
const service = { upsertAndReportChange: vi.fn(() => true) };
|
|
116
|
+
applySessionIndexEvent(service, {
|
|
117
|
+
directory: '/repo',
|
|
118
|
+
payload: {
|
|
119
|
+
type: 'session.updated.v2',
|
|
120
|
+
data: { info: { id: 'ses_v2', title: 'V2', time: { created: 1, updated: 2 } } },
|
|
121
|
+
},
|
|
122
|
+
}, 50, { isHostReady: () => true });
|
|
123
|
+
expect(service.upsertAndReportChange).toHaveBeenCalledWith(
|
|
124
|
+
expect.objectContaining({ id: 'ses_v2', directory: '/repo' }),
|
|
125
|
+
50,
|
|
126
|
+
{ preserveActivity: true },
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('skips lifecycle index writes when Host is not ready (keeps prior rows)', () => {
|
|
131
|
+
const service = { upsertAndReportChange: vi.fn(() => true) };
|
|
132
|
+
expect(applySessionIndexEvent(service, {
|
|
133
|
+
payload: {
|
|
134
|
+
type: 'session.updated',
|
|
135
|
+
properties: { info: { id: 'ses_1', time: { created: 1, updated: 1 } } },
|
|
136
|
+
},
|
|
137
|
+
}, 1, { isHostReady: () => false })).toBe(false);
|
|
138
|
+
expect(service.upsertAndReportChange).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('skips index write when projectSession fails (not empty success)', () => {
|
|
142
|
+
const service = { upsertAndReportChange: vi.fn(() => true) };
|
|
143
|
+
expect(applySessionIndexEvent(service, {
|
|
144
|
+
payload: {
|
|
145
|
+
type: 'session.updated',
|
|
146
|
+
properties: { info: { id: 'ses_1', time: { created: 1, updated: 1 } } },
|
|
147
|
+
},
|
|
148
|
+
}, 1, {
|
|
149
|
+
isHostReady: () => true,
|
|
150
|
+
projectSession: () => { throw new Error('store down'); },
|
|
151
|
+
})).toBe(false);
|
|
152
|
+
expect(service.upsertAndReportChange).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
81
154
|
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply committed Host archive authority onto a session-index snapshot / lookup.
|
|
3
|
+
* Used by production GET routes so SQLite rows never surface Host-archived
|
|
4
|
+
* sessions (or pins) after a crash between metadata commit and index apply.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
isSessionArchived,
|
|
9
|
+
projectSessionWithHostMetadata,
|
|
10
|
+
readHostArchivedAt,
|
|
11
|
+
} from '../session-metadata/session-projection.js';
|
|
12
|
+
|
|
13
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {object | null | undefined} session summary-shaped session from index
|
|
17
|
+
* @param {Record<string, object> | null} storedById committed Host metadata map
|
|
18
|
+
*/
|
|
19
|
+
export const projectIndexSessionWithHost = (session, storedById) => {
|
|
20
|
+
if (!session || typeof session !== 'object' || typeof session.id !== 'string') return session;
|
|
21
|
+
const host = storedById && isPlainObject(storedById[session.id]) ? storedById[session.id] : null;
|
|
22
|
+
// Index summaries already carry time.*; fold Host archive the same way as proxy.
|
|
23
|
+
return projectSessionWithHostMetadata(session, host);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Filter a full index snapshot through Host archive authority.
|
|
28
|
+
* Archived sessions drop from directory lists; pins for Host-archived ids drop.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} snapshot `{ directories, pinnedSessionIds, ... }`
|
|
31
|
+
* @param {Record<string, object>} storedById
|
|
32
|
+
*/
|
|
33
|
+
export const applyHostArchiveToIndexSnapshot = (snapshot, storedById) => {
|
|
34
|
+
if (!snapshot || typeof snapshot !== 'object') return snapshot;
|
|
35
|
+
const stored = storedById && typeof storedById === 'object' ? storedById : {};
|
|
36
|
+
|
|
37
|
+
const directories = Array.isArray(snapshot.directories)
|
|
38
|
+
? snapshot.directories.map((directory) => {
|
|
39
|
+
if (!directory || typeof directory !== 'object') return directory;
|
|
40
|
+
const sessions = Array.isArray(directory.sessions)
|
|
41
|
+
? directory.sessions
|
|
42
|
+
.map((session) => projectIndexSessionWithHost(session, stored))
|
|
43
|
+
.filter((session) => session && !isSessionArchived(session))
|
|
44
|
+
: [];
|
|
45
|
+
return { ...directory, sessions };
|
|
46
|
+
})
|
|
47
|
+
: snapshot.directories;
|
|
48
|
+
|
|
49
|
+
const pinnedSessionIds = Array.isArray(snapshot.pinnedSessionIds)
|
|
50
|
+
? snapshot.pinnedSessionIds.filter((id) => {
|
|
51
|
+
if (typeof id !== 'string' || !id) return false;
|
|
52
|
+
const hostArchived = readHostArchivedAt(stored[id]);
|
|
53
|
+
// positive Host archive hides pin; 0 / missing keep pin membership.
|
|
54
|
+
return !(typeof hostArchived === 'number' && hostArchived > 0);
|
|
55
|
+
})
|
|
56
|
+
: snapshot.pinnedSessionIds;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
...snapshot,
|
|
60
|
+
directories,
|
|
61
|
+
pinnedSessionIds,
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Lookup hit: if Host-archived, treat as not found for active deep-links.
|
|
67
|
+
* @returns {{ session: object } | { hidden: true } | null}
|
|
68
|
+
*/
|
|
69
|
+
export const applyHostArchiveToLookupHit = (hit, storedById) => {
|
|
70
|
+
if (!hit || typeof hit !== 'object') return null;
|
|
71
|
+
const projected = projectIndexSessionWithHost(
|
|
72
|
+
{
|
|
73
|
+
...hit,
|
|
74
|
+
time: {
|
|
75
|
+
created: hit.createdAt,
|
|
76
|
+
updated: hit.updatedAt,
|
|
77
|
+
...(hit.archivedAt ? { archived: hit.archivedAt } : {}),
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
storedById,
|
|
81
|
+
);
|
|
82
|
+
if (isSessionArchived(projected)) return { hidden: true };
|
|
83
|
+
return {
|
|
84
|
+
session: {
|
|
85
|
+
...hit,
|
|
86
|
+
// Expose projected archive stamp for clients that read time from lookup.
|
|
87
|
+
archivedAt: projected?.time?.archived ?? 0,
|
|
88
|
+
time: projected?.time,
|
|
89
|
+
metadata: projected?.metadata,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { createSessionMetadataStore } from '../session-metadata/session-metadata-store.js';
|
|
7
|
+
import { createSessionArchiveService } from '../session-metadata/session-archive.js';
|
|
8
|
+
import { createSessionIndexService } from './service.js';
|
|
9
|
+
import { registerSessionIndexRoutes } from './routes.js';
|
|
10
|
+
|
|
11
|
+
const tempDirs = [];
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
while (tempDirs.length > 0) {
|
|
15
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
16
|
+
}
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const makeDir = () => {
|
|
21
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-index-host-'));
|
|
22
|
+
tempDirs.push(dir);
|
|
23
|
+
return dir;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const registry = () => {
|
|
27
|
+
const routes = new Map();
|
|
28
|
+
return {
|
|
29
|
+
app: {
|
|
30
|
+
get: (pathValue, handler) => routes.set(`GET ${pathValue}`, handler),
|
|
31
|
+
put: (pathValue, handler) => routes.set(`PUT ${pathValue}`, handler),
|
|
32
|
+
post: (pathValue, handler) => routes.set(`POST ${pathValue}`, handler),
|
|
33
|
+
delete: (pathValue, handler) => routes.set(`DELETE ${pathValue}`, handler),
|
|
34
|
+
},
|
|
35
|
+
route: (method, pathValue) => routes.get(`${method} ${pathValue}`),
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const response = () => ({
|
|
40
|
+
statusCode: 200,
|
|
41
|
+
body: undefined,
|
|
42
|
+
status(code) { this.statusCode = code; return this; },
|
|
43
|
+
json(body) { this.body = body; return this; },
|
|
44
|
+
end() { return this; },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('session-index routes + Host archive (production path)', () => {
|
|
48
|
+
it('snapshot/lookup filter Host-archived rows and pins; 503 when metadata unavailable', async () => {
|
|
49
|
+
const dataDir = makeDir();
|
|
50
|
+
const store = createSessionMetadataStore({ dataDir });
|
|
51
|
+
const index = createSessionIndexService({
|
|
52
|
+
dbPath: path.join(dataDir, 'index.sqlite'),
|
|
53
|
+
getRuntimeConfig: () => ({ apiBaseUrl: 'http://runtime-test' }),
|
|
54
|
+
});
|
|
55
|
+
index.replaceDirectory({
|
|
56
|
+
directory: '/repo',
|
|
57
|
+
sessions: [
|
|
58
|
+
{ id: 'ses_live', title: 'Live', directory: '/repo', time: { created: 1, updated: 10 } },
|
|
59
|
+
{ id: 'ses_arch', title: 'Arch', directory: '/repo', time: { created: 1, updated: 9 } },
|
|
60
|
+
],
|
|
61
|
+
cursor: null,
|
|
62
|
+
hasMore: false,
|
|
63
|
+
});
|
|
64
|
+
index.setPinned('ses_arch', 50);
|
|
65
|
+
index.setPinned('ses_live', 51);
|
|
66
|
+
|
|
67
|
+
await store.setSessionMetadata('ses_arch', {
|
|
68
|
+
openchamber: { archive: { archivedAt: 99 } },
|
|
69
|
+
}, { allowArchive: true });
|
|
70
|
+
|
|
71
|
+
const { app, route } = registry();
|
|
72
|
+
registerSessionIndexRoutes(app, {
|
|
73
|
+
sessionIndexService: index,
|
|
74
|
+
getCommittedHostMetadata: () => store.getAll(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const snapRes = response();
|
|
78
|
+
await route('GET', '/api/openchamber/session-index')({}, snapRes);
|
|
79
|
+
expect(snapRes.statusCode).toBe(200);
|
|
80
|
+
expect(snapRes.body.directories[0].sessions.map((s) => s.id)).toEqual(['ses_live']);
|
|
81
|
+
expect(snapRes.body.pinnedSessionIds).toEqual(['ses_live']);
|
|
82
|
+
|
|
83
|
+
const lookupArch = response();
|
|
84
|
+
await route('GET', '/api/openchamber/session-index/session/:sessionId')(
|
|
85
|
+
{ params: { sessionId: 'ses_arch' } },
|
|
86
|
+
lookupArch,
|
|
87
|
+
);
|
|
88
|
+
expect(lookupArch.statusCode).toBe(404);
|
|
89
|
+
|
|
90
|
+
const lookupLive = response();
|
|
91
|
+
await route('GET', '/api/openchamber/session-index/session/:sessionId')(
|
|
92
|
+
{ params: { sessionId: 'ses_live' } },
|
|
93
|
+
lookupLive,
|
|
94
|
+
);
|
|
95
|
+
expect(lookupLive.statusCode).toBe(200);
|
|
96
|
+
expect(lookupLive.body.session.id).toBe('ses_live');
|
|
97
|
+
|
|
98
|
+
// Metadata unavailable → 503 retryable (production gate).
|
|
99
|
+
const { app: app2, route: route2 } = registry();
|
|
100
|
+
registerSessionIndexRoutes(app2, {
|
|
101
|
+
sessionIndexService: index,
|
|
102
|
+
getCommittedHostMetadata: async () => {
|
|
103
|
+
throw new Error('session metadata is unavailable: EACCES');
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
const failRes = response();
|
|
107
|
+
await route2('GET', '/api/openchamber/session-index')({}, failRes);
|
|
108
|
+
expect(failRes.statusCode).toBe(503);
|
|
109
|
+
expect(failRes.body).toMatchObject({ retryable: true, code: 'session_metadata_unavailable' });
|
|
110
|
+
|
|
111
|
+
index.close();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('index write failure after archive schedules repair; rebuild re-projects from store', async () => {
|
|
115
|
+
const dataDir = makeDir();
|
|
116
|
+
const store = createSessionMetadataStore({ dataDir });
|
|
117
|
+
const timers = [];
|
|
118
|
+
const setTimer = (fn, ms) => {
|
|
119
|
+
const handle = { fn, ms, cleared: false };
|
|
120
|
+
timers.push(handle);
|
|
121
|
+
return handle;
|
|
122
|
+
};
|
|
123
|
+
const clearTimer = (handle) => { if (handle) handle.cleared = true; };
|
|
124
|
+
|
|
125
|
+
let upsertFails = true;
|
|
126
|
+
const upserts = [];
|
|
127
|
+
const archive = createSessionArchiveService({
|
|
128
|
+
sessionMetadataStore: store,
|
|
129
|
+
fetchUpstreamSession: async ({ sessionID }) => ({
|
|
130
|
+
id: sessionID,
|
|
131
|
+
directory: '/repo',
|
|
132
|
+
title: 'S',
|
|
133
|
+
time: { created: 1, updated: 2 },
|
|
134
|
+
}),
|
|
135
|
+
sessionIndexService: {
|
|
136
|
+
upsertAndReportChange: (session) => {
|
|
137
|
+
upserts.push(session);
|
|
138
|
+
if (upsertFails) throw new Error('sqlite locked');
|
|
139
|
+
return true;
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
setTimer,
|
|
143
|
+
clearTimer,
|
|
144
|
+
now: () => 1_000,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const result = await archive.setArchive({ sessionID: 'ses_1', archivedAt: 77 });
|
|
148
|
+
expect(result.index).toMatchObject({ ok: false, retryable: true });
|
|
149
|
+
await expect(store.get('ses_1')).resolves.toMatchObject({
|
|
150
|
+
openchamber: { archive: { archivedAt: 77 } },
|
|
151
|
+
});
|
|
152
|
+
expect(archive.getIndexRepairStatus('ses_1')).toMatchObject({ pending: true, retryable: true });
|
|
153
|
+
expect(timers).toHaveLength(1);
|
|
154
|
+
expect(timers[0].cleared).toBe(false);
|
|
155
|
+
|
|
156
|
+
// Repair run: re-project from committed store (not stale blob).
|
|
157
|
+
upsertFails = false;
|
|
158
|
+
await timers[0].fn();
|
|
159
|
+
expect(upserts.at(-1)?.time?.archived).toBe(77);
|
|
160
|
+
expect(archive.getIndexRepairStatus('ses_1')).toMatchObject({ ok: true, pending: false });
|
|
161
|
+
|
|
162
|
+
// stop() clears any remaining scheduled timers (none left after success).
|
|
163
|
+
archive.stop();
|
|
164
|
+
expect(archive.getIndexRepairStatus('ses_1').pending).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('restart: fresh store+index routes hide Host-archived without pending repair state', async () => {
|
|
168
|
+
const dataDir = makeDir();
|
|
169
|
+
const store1 = createSessionMetadataStore({ dataDir });
|
|
170
|
+
await store1.setSessionMetadata('ses_x', {
|
|
171
|
+
openchamber: { archive: { archivedAt: 5 } },
|
|
172
|
+
}, { allowArchive: true });
|
|
173
|
+
|
|
174
|
+
// Simulate crash after metadata commit: index still has the row.
|
|
175
|
+
const index = createSessionIndexService({
|
|
176
|
+
dbPath: path.join(dataDir, 'index.sqlite'),
|
|
177
|
+
getRuntimeConfig: () => ({ apiBaseUrl: 'http://runtime-restart' }),
|
|
178
|
+
});
|
|
179
|
+
index.replaceDirectory({
|
|
180
|
+
directory: '/repo',
|
|
181
|
+
sessions: [
|
|
182
|
+
{ id: 'ses_x', title: 'X', directory: '/repo', time: { created: 1, updated: 2 } },
|
|
183
|
+
{ id: 'ses_y', title: 'Y', directory: '/repo', time: { created: 1, updated: 3 } },
|
|
184
|
+
],
|
|
185
|
+
cursor: null,
|
|
186
|
+
hasMore: false,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Process restart: new store loads committed archive.
|
|
190
|
+
const store2 = createSessionMetadataStore({ dataDir });
|
|
191
|
+
const { app, route } = registry();
|
|
192
|
+
registerSessionIndexRoutes(app, {
|
|
193
|
+
sessionIndexService: index,
|
|
194
|
+
getCommittedHostMetadata: () => store2.getAll(),
|
|
195
|
+
});
|
|
196
|
+
const res = response();
|
|
197
|
+
await route('GET', '/api/openchamber/session-index')({}, res);
|
|
198
|
+
expect(res.body.directories[0].sessions.map((s) => s.id)).toEqual(['ses_y']);
|
|
199
|
+
index.close();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -1,12 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyHostArchiveToIndexSnapshot,
|
|
3
|
+
applyHostArchiveToLookupHit,
|
|
4
|
+
} from './host-authority-snapshot.js';
|
|
5
|
+
|
|
1
6
|
const unsupported = (res) => res.status(501).json({ error: 'Session index is unavailable for this runtime' });
|
|
2
7
|
|
|
3
|
-
|
|
4
|
-
|
|
8
|
+
/**
|
|
9
|
+
* @param {import('express').Express} app
|
|
10
|
+
* @param {object} deps
|
|
11
|
+
* @param {object | null} deps.sessionIndexService
|
|
12
|
+
* @param {object | null} [deps.sessionIndexSyncRuntime]
|
|
13
|
+
* @param {() => Promise<Record<string, object>> | Record<string, object> | null} [deps.getCommittedHostMetadata]
|
|
14
|
+
* Committed Host metadata map. Throws / returns null on unavailable → 503.
|
|
15
|
+
*/
|
|
16
|
+
export const registerSessionIndexRoutes = (app, {
|
|
17
|
+
sessionIndexService,
|
|
18
|
+
sessionIndexSyncRuntime,
|
|
19
|
+
getCommittedHostMetadata = null,
|
|
20
|
+
} = {}) => {
|
|
21
|
+
const hostStoreWired = typeof getCommittedHostMetadata === 'function';
|
|
22
|
+
|
|
23
|
+
const readHostMap = async () => {
|
|
24
|
+
if (!hostStoreWired) return { status: 'unconfigured', stored: {} };
|
|
25
|
+
try {
|
|
26
|
+
const stored = await getCommittedHostMetadata();
|
|
27
|
+
if (stored == null || typeof stored !== 'object') {
|
|
28
|
+
return { status: 'unavailable', error: 'session metadata is unavailable' };
|
|
29
|
+
}
|
|
30
|
+
return { status: 'ok', stored };
|
|
31
|
+
} catch (error) {
|
|
32
|
+
const message = error instanceof Error ? error.message : 'session metadata is unavailable';
|
|
33
|
+
return { status: 'unavailable', error: message };
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const metadataUnavailable = (res, error) => res.status(503).json({
|
|
38
|
+
error: error || 'session metadata is unavailable',
|
|
39
|
+
code: 'session_metadata_unavailable',
|
|
40
|
+
retryable: true,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
app.get('/api/openchamber/session-index', async (_req, res) => {
|
|
5
44
|
if (!sessionIndexService) return unsupported(res);
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
45
|
+
const base = sessionIndexSyncRuntime?.snapshot() ?? sessionIndexService.snapshot();
|
|
46
|
+
if (!hostStoreWired) {
|
|
47
|
+
return res.json({ available: true, ...base });
|
|
48
|
+
}
|
|
49
|
+
const host = await readHostMap();
|
|
50
|
+
if (host.status === 'unavailable') return metadataUnavailable(res, host.error);
|
|
51
|
+
const filtered = applyHostArchiveToIndexSnapshot(base, host.stored);
|
|
52
|
+
return res.json({ available: true, ...filtered });
|
|
10
53
|
});
|
|
11
54
|
|
|
12
55
|
app.post('/api/openchamber/session-index/sync', (req, res) => {
|
|
@@ -47,7 +90,7 @@ export const registerSessionIndexRoutes = (app, { sessionIndexService, sessionIn
|
|
|
47
90
|
});
|
|
48
91
|
|
|
49
92
|
// Deep-link resolution: GET by session id without requiring a directory.
|
|
50
|
-
app.get('/api/openchamber/session-index/session/:sessionId', (req, res) => {
|
|
93
|
+
app.get('/api/openchamber/session-index/session/:sessionId', async (req, res) => {
|
|
51
94
|
if (!sessionIndexService) return unsupported(res);
|
|
52
95
|
if (typeof sessionIndexService.findBySessionId !== 'function') {
|
|
53
96
|
return res.status(501).json({ error: 'Session lookup is unavailable' });
|
|
@@ -56,11 +99,21 @@ export const registerSessionIndexRoutes = (app, { sessionIndexService, sessionIn
|
|
|
56
99
|
if (typeof sessionId !== 'string' || !sessionId.trim()) {
|
|
57
100
|
return res.status(400).json({ error: 'sessionId is required' });
|
|
58
101
|
}
|
|
59
|
-
const
|
|
102
|
+
const id = sessionId.trim();
|
|
103
|
+
const hit = sessionIndexService.findBySessionId(id);
|
|
60
104
|
if (!hit) {
|
|
61
|
-
return res.status(404).json({ error: 'session_not_found', sessionId:
|
|
105
|
+
return res.status(404).json({ error: 'session_not_found', sessionId: id });
|
|
106
|
+
}
|
|
107
|
+
if (!hostStoreWired) {
|
|
108
|
+
return res.json({ available: true, session: hit });
|
|
109
|
+
}
|
|
110
|
+
const host = await readHostMap();
|
|
111
|
+
if (host.status === 'unavailable') return metadataUnavailable(res, host.error);
|
|
112
|
+
const projected = applyHostArchiveToLookupHit(hit, host.stored);
|
|
113
|
+
if (!projected || projected.hidden) {
|
|
114
|
+
return res.status(404).json({ error: 'session_not_found', sessionId: id });
|
|
62
115
|
}
|
|
63
|
-
res.json({ available: true, session:
|
|
116
|
+
return res.json({ available: true, session: projected.session });
|
|
64
117
|
});
|
|
65
118
|
|
|
66
119
|
app.post('/api/openchamber/session-index/session/:id/pin', (req, res) => {
|