@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
|
@@ -24,24 +24,24 @@ const response = () => ({
|
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
describe('session index routes', () => {
|
|
27
|
-
it('returns deterministic unsupported state outside Electron', () => {
|
|
27
|
+
it('returns deterministic unsupported state outside Electron', async () => {
|
|
28
28
|
const { app, route } = registry();
|
|
29
29
|
registerSessionIndexRoutes(app, { sessionIndexService: null });
|
|
30
30
|
const res = response();
|
|
31
31
|
|
|
32
|
-
route('GET', '/api/openchamber/session-index')({}, res);
|
|
32
|
+
await route('GET', '/api/openchamber/session-index')({}, res);
|
|
33
33
|
|
|
34
34
|
expect(res.statusCode).toBe(501);
|
|
35
35
|
expect(res.body).toMatchObject({ error: expect.stringContaining('unavailable') });
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
it('returns an Electron snapshot through the OpenChamber route', () => {
|
|
38
|
+
it('returns an Electron snapshot through the OpenChamber route', async () => {
|
|
39
39
|
const { app, route } = registry();
|
|
40
40
|
const sessionIndexService = { snapshot: () => ({ directories: [{ directory: '/repo', sessions: [] }] }) };
|
|
41
41
|
registerSessionIndexRoutes(app, { sessionIndexService });
|
|
42
42
|
const res = response();
|
|
43
43
|
|
|
44
|
-
route('GET', '/api/openchamber/session-index')({}, res);
|
|
44
|
+
await route('GET', '/api/openchamber/session-index')({}, res);
|
|
45
45
|
|
|
46
46
|
expect(res.statusCode).toBe(200);
|
|
47
47
|
expect(res.body).toEqual({ available: true, directories: [{ directory: '/repo', sessions: [] }] });
|
|
@@ -91,7 +91,7 @@ describe('session index routes', () => {
|
|
|
91
91
|
expect(route('GET', '/api/openchamber/session-index/changes')).toBeUndefined();
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
-
it('looks up a session by id for deep links', () => {
|
|
94
|
+
it('looks up a session by id for deep links', async () => {
|
|
95
95
|
const { app, route } = registry();
|
|
96
96
|
const findBySessionId = vi.fn(() => ({
|
|
97
97
|
id: 'ses_abc',
|
|
@@ -106,7 +106,7 @@ describe('session index routes', () => {
|
|
|
106
106
|
});
|
|
107
107
|
const res = response();
|
|
108
108
|
|
|
109
|
-
route('GET', '/api/openchamber/session-index/session/:sessionId')(
|
|
109
|
+
await route('GET', '/api/openchamber/session-index/session/:sessionId')(
|
|
110
110
|
{ params: { sessionId: 'ses_abc' } },
|
|
111
111
|
res,
|
|
112
112
|
);
|
|
@@ -119,7 +119,7 @@ describe('session index routes', () => {
|
|
|
119
119
|
});
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
-
it('returns 404 when session id is unknown', () => {
|
|
122
|
+
it('returns 404 when session id is unknown', async () => {
|
|
123
123
|
const { app, route } = registry();
|
|
124
124
|
registerSessionIndexRoutes(app, {
|
|
125
125
|
sessionIndexService: {
|
|
@@ -129,7 +129,7 @@ describe('session index routes', () => {
|
|
|
129
129
|
});
|
|
130
130
|
const res = response();
|
|
131
131
|
|
|
132
|
-
route('GET', '/api/openchamber/session-index/session/:sessionId')(
|
|
132
|
+
await route('GET', '/api/openchamber/session-index/session/:sessionId')(
|
|
133
133
|
{ params: { sessionId: 'ses_missing' } },
|
|
134
134
|
res,
|
|
135
135
|
);
|
|
@@ -284,8 +284,14 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
284
284
|
const previousStateByID = new Map(
|
|
285
285
|
existingSummaryState.all(key, normalizedDirectory).map((row) => [row.session_id, row]),
|
|
286
286
|
);
|
|
287
|
+
// Active-only: archived sessions never enter the cold-start index. Host
|
|
288
|
+
// archive projection must set time.archived before replaceDirectory so a
|
|
289
|
+
// stale unarchived cache cannot resurrect them.
|
|
287
290
|
const summaries = Array.isArray(sessions)
|
|
288
|
-
? sessions
|
|
291
|
+
? sessions
|
|
292
|
+
.map((session) => toSummary(session, normalizedDirectory))
|
|
293
|
+
.filter((summary) => summary && !summary.archivedAt)
|
|
294
|
+
.slice(0, MAX_ROOT_SESSIONS)
|
|
289
295
|
: [];
|
|
290
296
|
touchDirectory.run({
|
|
291
297
|
runtimeKey: key,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
const SESSION_LIMIT = 20;
|
|
2
|
+
/** Max upstream list pages while skipping consecutive Host-archived rows. */
|
|
3
|
+
const ARCHIVE_SKIP_PAGE_BUDGET = 5;
|
|
2
4
|
const FULL_RECONCILE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
3
5
|
const FETCH_TIMEOUT_MS = 6_000;
|
|
4
6
|
const INTERACTIVE_YIELD_MS = 1_000;
|
|
@@ -15,12 +17,55 @@ const updatedAt = (session) => {
|
|
|
15
17
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
16
18
|
};
|
|
17
19
|
|
|
20
|
+
const isArchivedSession = (session) => {
|
|
21
|
+
const archived = session?.time?.archived;
|
|
22
|
+
return typeof archived === 'number' && Number.isFinite(archived) && archived > 0;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const nonEmptySystemID = (value) => typeof value === 'string' && value.length > 0;
|
|
26
|
+
|
|
27
|
+
/** Match session-index service: system sessions never consume the active-20 budget. */
|
|
28
|
+
const isSystemSession = (session) => {
|
|
29
|
+
const openchamber = session?.metadata?.openchamber;
|
|
30
|
+
if (!openchamber || typeof openchamber !== 'object') return false;
|
|
31
|
+
if (openchamber.assigned?.from === 'contact') return false;
|
|
32
|
+
if (nonEmptySystemID(openchamber.assistant?.assistantID)) return true;
|
|
33
|
+
if (nonEmptySystemID(openchamber.scheduledTask?.taskID)) return true;
|
|
34
|
+
if (nonEmptySystemID(openchamber.smallModel?.purpose)) return true;
|
|
35
|
+
if (nonEmptySystemID(openchamber.llm?.purpose)) return true;
|
|
36
|
+
return false;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const isRootActiveCandidate = (session) => {
|
|
40
|
+
if (!session?.id || isArchivedSession(session)) return false;
|
|
41
|
+
if (typeof session.parentID === 'string' && session.parentID) return false;
|
|
42
|
+
if (session.title === 'smartfetch-secondary') return false;
|
|
43
|
+
if (isSystemSession(session)) return false;
|
|
44
|
+
return true;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* v2 list cursor may be a string/number or `{ next }`. Never String(object).
|
|
49
|
+
* @returns {string | null}
|
|
50
|
+
*/
|
|
51
|
+
export const extractSessionListCursorToken = (cursor) => {
|
|
52
|
+
if (cursor == null || cursor === '') return null;
|
|
53
|
+
if (typeof cursor === 'string' || typeof cursor === 'number' || typeof cursor === 'bigint') {
|
|
54
|
+
const token = String(cursor).trim();
|
|
55
|
+
return token.length > 0 ? token : null;
|
|
56
|
+
}
|
|
57
|
+
if (typeof cursor === 'object' && cursor.next != null && cursor.next !== '') {
|
|
58
|
+
return extractSessionListCursorToken(cursor.next);
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
};
|
|
62
|
+
|
|
18
63
|
const mergeIncrementalSessions = (cached, changed) => {
|
|
19
64
|
const byID = new Map();
|
|
20
65
|
for (const session of cached ?? []) byID.set(session.id, session);
|
|
21
66
|
for (const session of changed ?? []) {
|
|
22
67
|
if (!session?.id) continue;
|
|
23
|
-
if (session
|
|
68
|
+
if (isArchivedSession(session)) byID.delete(session.id);
|
|
24
69
|
else byID.set(session.id, session);
|
|
25
70
|
}
|
|
26
71
|
return [...byID.values()]
|
|
@@ -35,6 +80,12 @@ export const createSessionIndexSyncRuntime = ({
|
|
|
35
80
|
waitForOpenCodeReady,
|
|
36
81
|
/** Optional tip sink: ({ revision, sync }) after each revision bump. */
|
|
37
82
|
onRevisionTip = null,
|
|
83
|
+
/**
|
|
84
|
+
* Optional Host authority projection applied to each upstream list page
|
|
85
|
+
* before active-only filtering (archive / metadata). Sync — no await.
|
|
86
|
+
* @type {((sessions: object[]) => object[]) | null}
|
|
87
|
+
*/
|
|
88
|
+
projectSessions = null,
|
|
38
89
|
fetchFn = globalThis.fetch,
|
|
39
90
|
now = Date.now,
|
|
40
91
|
setTimer = setTimeout,
|
|
@@ -116,49 +167,125 @@ export const createSessionIndexSyncRuntime = ({
|
|
|
116
167
|
.find((entry) => entry.directory === directory);
|
|
117
168
|
|
|
118
169
|
const fetchDirectory = async (task) => {
|
|
119
|
-
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
// Empty snapshots still store lastSyncedAt as a worktree topology hint.
|
|
124
|
-
const useIncremental = false;
|
|
125
|
-
const url = new URL(buildOpenCodeUrl('/session'));
|
|
126
|
-
url.searchParams.set('directory', task.directory);
|
|
127
|
-
url.searchParams.set('limit', String(SESSION_LIMIT));
|
|
128
|
-
|
|
170
|
+
// v2 `session.list` cursor pagination. Collect candidates across pages,
|
|
171
|
+
// then re-project once before commit so a Host archive that lands mid-sync
|
|
172
|
+
// cannot resurrect a row. Root/system filters run before the 20-slot budget.
|
|
173
|
+
// projectSessions failure aborts the refresh and keeps prior directory rows.
|
|
129
174
|
const controller = new AbortController();
|
|
130
175
|
currentController = controller;
|
|
131
176
|
currentWasPreempted = false;
|
|
132
177
|
const timeout = setTimer(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
133
178
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
179
|
+
/** @type {object[]} raw upstream sessions collected across pages */
|
|
180
|
+
const collected = [];
|
|
181
|
+
let pages = 0;
|
|
182
|
+
let cursorToken = null;
|
|
183
|
+
let upstreamHasMore = false;
|
|
184
|
+
let hitPageBudget = false;
|
|
185
|
+
let lastCursorToken = null;
|
|
186
|
+
let seenCursor = new Set();
|
|
187
|
+
|
|
188
|
+
while (collected.length < SESSION_LIMIT * 3 && pages < ARCHIVE_SKIP_PAGE_BUDGET) {
|
|
189
|
+
const url = new URL(buildOpenCodeUrl('/session'));
|
|
190
|
+
url.searchParams.set('directory', task.directory);
|
|
191
|
+
url.searchParams.set('limit', String(SESSION_LIMIT));
|
|
192
|
+
if (cursorToken) {
|
|
193
|
+
url.searchParams.set('cursor', cursorToken);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const response = await fetchFn(url, {
|
|
197
|
+
method: 'GET',
|
|
198
|
+
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
|
199
|
+
signal: controller.signal,
|
|
200
|
+
});
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
const error = new Error(`OpenCode session list failed (${response.status})`);
|
|
203
|
+
error.status = response.status;
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
const payload = await response.json();
|
|
207
|
+
const sessions = Array.isArray(payload) ? payload : payload?.data;
|
|
208
|
+
if (!Array.isArray(sessions)) throw new Error('Invalid OpenCode session list payload');
|
|
209
|
+
pages += 1;
|
|
210
|
+
|
|
211
|
+
for (const session of sessions) {
|
|
212
|
+
if (session?.id) collected.push(session);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// v2: exhaust by cursor.next when present. A short page with next is
|
|
216
|
+
// still more data (archived skips / filtered upstream). Bare arrays
|
|
217
|
+
// have no cursor — length < limit means end of list.
|
|
218
|
+
const isEnvelope = payload && typeof payload === 'object' && !Array.isArray(payload);
|
|
219
|
+
const nextToken = isEnvelope
|
|
220
|
+
? extractSessionListCursorToken(payload.cursor)
|
|
221
|
+
: null;
|
|
222
|
+
const pageFull = sessions.length === SESSION_LIMIT;
|
|
223
|
+
if (nextToken) lastCursorToken = nextToken;
|
|
224
|
+
|
|
225
|
+
if (nextToken) {
|
|
226
|
+
upstreamHasMore = true;
|
|
227
|
+
if (seenCursor.has(nextToken)) {
|
|
228
|
+
// Duplicate cursor → stop (broken upstream) rather than loop forever.
|
|
229
|
+
hitPageBudget = true;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
seenCursor.add(nextToken);
|
|
233
|
+
if (pages >= ARCHIVE_SKIP_PAGE_BUDGET) {
|
|
234
|
+
hitPageBudget = true;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
cursorToken = nextToken;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// No next token: bare-array / terminal page.
|
|
242
|
+
if (!pageFull) {
|
|
243
|
+
upstreamHasMore = false;
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
// Full page without cursor — cannot continue safely.
|
|
247
|
+
upstreamHasMore = true;
|
|
248
|
+
hitPageBudget = true;
|
|
249
|
+
break;
|
|
143
250
|
}
|
|
144
|
-
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
251
|
+
|
|
252
|
+
// Re-project the full collected set at commit time (current Host authority).
|
|
253
|
+
let projected = collected;
|
|
254
|
+
if (typeof projectSessions === 'function') {
|
|
255
|
+
try {
|
|
256
|
+
const next = projectSessions(collected);
|
|
257
|
+
if (!Array.isArray(next)) {
|
|
258
|
+
throw new Error('projectSessions must return an array');
|
|
259
|
+
}
|
|
260
|
+
projected = next;
|
|
261
|
+
} catch (error) {
|
|
262
|
+
// Failure is not empty success — keep prior directory rows.
|
|
263
|
+
console.warn('[session-index] projectSessions failed; keeping prior directory:', error?.message ?? error);
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Root + system filters before filling the active-20 capacity.
|
|
269
|
+
const active = [];
|
|
270
|
+
for (const session of projected) {
|
|
271
|
+
if (!isRootActiveCandidate(session)) continue;
|
|
272
|
+
if (active.length >= SESSION_LIMIT) break;
|
|
273
|
+
active.push(session);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const oldest = active[active.length - 1];
|
|
277
|
+
const cursorNumber = lastCursorToken && Number.isFinite(Number(lastCursorToken))
|
|
278
|
+
? Number(lastCursorToken)
|
|
279
|
+
: (updatedAt(oldest) || null);
|
|
153
280
|
sessionIndexService.replaceDirectory({
|
|
154
281
|
directory: task.directory,
|
|
155
|
-
sessions:
|
|
156
|
-
cursor:
|
|
157
|
-
hasMore:
|
|
158
|
-
fullSync:
|
|
282
|
+
sessions: active,
|
|
283
|
+
cursor: cursorNumber,
|
|
284
|
+
hasMore: upstreamHasMore || hitPageBudget || active.length === SESSION_LIMIT,
|
|
285
|
+
fullSync: true,
|
|
159
286
|
now: now(),
|
|
160
287
|
});
|
|
161
|
-
for (const session of
|
|
288
|
+
for (const session of active) {
|
|
162
289
|
if (!session?.id) continue;
|
|
163
290
|
const key = `${task.runtimeKey}\n${task.directory}\n${session.id}`;
|
|
164
291
|
if (queuedChildFlagKeys.has(key)) continue;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import { createSessionIndexSyncRuntime } from './sync-runtime.js';
|
|
3
|
+
import { createSessionIndexSyncRuntime, extractSessionListCursorToken } from './sync-runtime.js';
|
|
4
4
|
|
|
5
5
|
const session = (id, updated, directory = '/repo') => ({
|
|
6
6
|
id,
|
|
@@ -89,6 +89,160 @@ describe('session index background sync runtime', () => {
|
|
|
89
89
|
await expect(changed).resolves.toMatchObject({ revision: initialRevision + 1 });
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
+
it('extracts cursor.next without stringifying objects', () => {
|
|
93
|
+
expect(extractSessionListCursorToken({ next: 'abc' })).toBe('abc');
|
|
94
|
+
expect(extractSessionListCursorToken({ next: 42 })).toBe('42');
|
|
95
|
+
expect(extractSessionListCursorToken({ foo: 1 })).toBeNull();
|
|
96
|
+
expect(extractSessionListCursorToken('plain')).toBe('plain');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('continues paging when a short page still carries cursor.next', async () => {
|
|
100
|
+
const service = createService();
|
|
101
|
+
const cursors = [];
|
|
102
|
+
const runtime = createSessionIndexSyncRuntime({
|
|
103
|
+
sessionIndexService: service,
|
|
104
|
+
buildOpenCodeUrl: (route) => `http://opencode.test${route}`,
|
|
105
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
106
|
+
waitForOpenCodeReady: async () => true,
|
|
107
|
+
projectSessions: (sessions) => sessions,
|
|
108
|
+
fetchFn: async (url) => {
|
|
109
|
+
if (url.pathname !== '/session') {
|
|
110
|
+
return new Response(JSON.stringify([]), { status: 200 });
|
|
111
|
+
}
|
|
112
|
+
cursors.push(url.searchParams.get('cursor'));
|
|
113
|
+
if (!url.searchParams.get('cursor')) {
|
|
114
|
+
// Short page (not limit=20) but v2 still offers next — must continue.
|
|
115
|
+
return new Response(JSON.stringify({
|
|
116
|
+
data: [
|
|
117
|
+
{ ...session('ses_arch_a', 30), time: { created: 1, updated: 30, archived: 9 } },
|
|
118
|
+
{ ...session('ses_arch_b', 29), time: { created: 1, updated: 29, archived: 9 } },
|
|
119
|
+
],
|
|
120
|
+
cursor: { next: 'more-after-short' },
|
|
121
|
+
}), { status: 200 });
|
|
122
|
+
}
|
|
123
|
+
return new Response(JSON.stringify({
|
|
124
|
+
data: [session('ses_active', 10)],
|
|
125
|
+
cursor: null,
|
|
126
|
+
}), { status: 200 });
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
runtime.enqueue(['/repo']);
|
|
130
|
+
await waitUntil(() => isFullyIdle(runtime));
|
|
131
|
+
expect(cursors).toContain('more-after-short');
|
|
132
|
+
expect(service.replaceDirectory.mock.calls[0][0].sessions).toEqual([
|
|
133
|
+
expect.objectContaining({ id: 'ses_active' }),
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('uses cursor.next for paging and does not write [object Object]', async () => {
|
|
138
|
+
const service = createService();
|
|
139
|
+
const cursors = [];
|
|
140
|
+
const runtime = createSessionIndexSyncRuntime({
|
|
141
|
+
sessionIndexService: service,
|
|
142
|
+
buildOpenCodeUrl: (route) => `http://opencode.test${route}`,
|
|
143
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
144
|
+
waitForOpenCodeReady: async () => true,
|
|
145
|
+
projectSessions: (sessions) => sessions,
|
|
146
|
+
fetchFn: async (url) => {
|
|
147
|
+
if (url.pathname !== '/session') {
|
|
148
|
+
return new Response(JSON.stringify([]), { status: 200 });
|
|
149
|
+
}
|
|
150
|
+
cursors.push(url.searchParams.get('cursor'));
|
|
151
|
+
if (!url.searchParams.get('cursor')) {
|
|
152
|
+
const archivedPage = Array.from({ length: 20 }, (_, index) => ({
|
|
153
|
+
...session(`ses_arch_${index}`, 100 - index),
|
|
154
|
+
time: { created: 1, updated: 100 - index, archived: 9 },
|
|
155
|
+
}));
|
|
156
|
+
return new Response(JSON.stringify({
|
|
157
|
+
data: archivedPage,
|
|
158
|
+
cursor: { next: 'page-2-token' },
|
|
159
|
+
}), { status: 200 });
|
|
160
|
+
}
|
|
161
|
+
return new Response(JSON.stringify({
|
|
162
|
+
data: [session('ses_active', 10)],
|
|
163
|
+
cursor: { next: null },
|
|
164
|
+
}), { status: 200 });
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
runtime.enqueue(['/repo']);
|
|
169
|
+
await waitUntil(() => isFullyIdle(runtime));
|
|
170
|
+
expect(cursors[0]).toBeNull();
|
|
171
|
+
expect(cursors).toContain('page-2-token');
|
|
172
|
+
expect(cursors.some((value) => value === '[object Object]')).toBe(false);
|
|
173
|
+
expect(service.replaceDirectory.mock.calls[0][0].sessions).toEqual([
|
|
174
|
+
expect.objectContaining({ id: 'ses_active' }),
|
|
175
|
+
]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('keeps prior directory rows when projectSessions fails', async () => {
|
|
179
|
+
const service = createService([{
|
|
180
|
+
directory: '/repo',
|
|
181
|
+
sessions: [session('ses_old', 10)],
|
|
182
|
+
cursor: null,
|
|
183
|
+
hasMore: false,
|
|
184
|
+
}]);
|
|
185
|
+
const runtime = createSessionIndexSyncRuntime({
|
|
186
|
+
sessionIndexService: service,
|
|
187
|
+
buildOpenCodeUrl: (route) => `http://opencode.test${route}`,
|
|
188
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
189
|
+
waitForOpenCodeReady: async () => true,
|
|
190
|
+
projectSessions: () => {
|
|
191
|
+
throw new Error('metadata unavailable');
|
|
192
|
+
},
|
|
193
|
+
fetchFn: async () => new Response(JSON.stringify([session('ses_new', 20)]), { status: 200 }),
|
|
194
|
+
});
|
|
195
|
+
runtime.enqueue(['/repo']);
|
|
196
|
+
await waitUntil(() => isFullyIdle(runtime));
|
|
197
|
+
// replaceDirectory must not have replaced with empty/new on failure.
|
|
198
|
+
expect(service.replaceDirectory).not.toHaveBeenCalled();
|
|
199
|
+
expect(runtime.snapshot().sync.failedDirectories).toContain('/repo');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('skips Host-archived rows and pages within budget to fill active slots', async () => {
|
|
203
|
+
const service = createService();
|
|
204
|
+
const urls = [];
|
|
205
|
+
const runtime = createSessionIndexSyncRuntime({
|
|
206
|
+
sessionIndexService: service,
|
|
207
|
+
buildOpenCodeUrl: (route) => `http://opencode.test${route}`,
|
|
208
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
209
|
+
waitForOpenCodeReady: async () => true,
|
|
210
|
+
projectSessions: (sessions) => sessions.map((item) => (
|
|
211
|
+
String(item.id).startsWith('ses_arch')
|
|
212
|
+
? { ...item, time: { ...item.time, archived: 9 } }
|
|
213
|
+
: item
|
|
214
|
+
)),
|
|
215
|
+
fetchFn: async (url) => {
|
|
216
|
+
if (url.pathname !== '/session') {
|
|
217
|
+
return new Response(JSON.stringify([]), { status: 200 });
|
|
218
|
+
}
|
|
219
|
+
urls.push(url.searchParams.get('cursor'));
|
|
220
|
+
if (!url.searchParams.get('cursor')) {
|
|
221
|
+
// Full page of archived rows — sync must continue with cursor.
|
|
222
|
+
const archivedPage = Array.from({ length: 20 }, (_, index) => session(`ses_arch_${index}`, 100 - index));
|
|
223
|
+
return new Response(JSON.stringify({
|
|
224
|
+
data: archivedPage,
|
|
225
|
+
cursor: 'page-2',
|
|
226
|
+
}), { status: 200 });
|
|
227
|
+
}
|
|
228
|
+
return new Response(JSON.stringify({
|
|
229
|
+
data: [session('ses_active', 10)],
|
|
230
|
+
cursor: null,
|
|
231
|
+
}), { status: 200 });
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
runtime.enqueue(['/repo']);
|
|
236
|
+
await waitUntil(() => isFullyIdle(runtime));
|
|
237
|
+
|
|
238
|
+
expect(urls[0]).toBeNull();
|
|
239
|
+
expect(urls).toContain('page-2');
|
|
240
|
+
expect(service.replaceDirectory.mock.calls[0][0].sessions).toEqual([
|
|
241
|
+
expect.objectContaining({ id: 'ses_active' }),
|
|
242
|
+
]);
|
|
243
|
+
expect(service.replaceDirectory.mock.calls[0][0].sessions.some((item) => String(item.id).startsWith('ses_arch'))).toBe(false);
|
|
244
|
+
});
|
|
245
|
+
|
|
92
246
|
it('v2 session.list has no incremental start — always fetches a full page', async () => {
|
|
93
247
|
const service = createService([{
|
|
94
248
|
directory: '/repo',
|
|
@@ -5,37 +5,82 @@ OpenCode `session.metadata`.
|
|
|
5
5
|
|
|
6
6
|
## Why
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
`
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
Session goal progress, Host archive stamps, and later other Host state under the
|
|
9
|
+
`openchamber` namespace are OpenChamber product state. The Host owns their
|
|
10
|
+
authority: it serializes conditional writes, publishes only persisted values,
|
|
11
|
+
and protects archive fields from generic patches. They therefore live in this
|
|
12
|
+
Host store and are folded back onto proxy session list/detail/SSE responses so
|
|
13
|
+
clients still read `session.metadata` and `time.archived`.
|
|
14
|
+
|
|
15
|
+
Upstream capability is tracked per version and does not change that ownership:
|
|
16
|
+
|
|
17
|
+
| OpenCode client | Session metadata update |
|
|
18
|
+
|---|---|
|
|
19
|
+
| Earlier 2.x releases used during the migration | `metadata` accepted at create time only |
|
|
20
|
+
| 2.0.14 | `PATCH /api/session/:id` with body `{ title, metadata, permissions }` (`session.update`) |
|
|
21
|
+
|
|
22
|
+
Adopting the upstream update API for Host-owned fields requires a separate
|
|
23
|
+
decision about authority, conditional writes, and archive protection.
|
|
13
24
|
|
|
14
25
|
## Layout
|
|
15
26
|
|
|
16
27
|
| File | Role |
|
|
17
28
|
|---|---|
|
|
18
|
-
| `session-metadata-store.js` | Durable store: one JSON file per data dir, `{ [sessionID]: metadata }`, RFC 7386 merge patch, refuse-failed-read refuses write |
|
|
19
|
-
| `
|
|
20
|
-
|
|
|
29
|
+
| `session-metadata-store.js` | Durable store: one JSON file per data dir, `{ [sessionID]: metadata }`, RFC 7386 merge patch, full RMW serialization, refuse-failed-read refuses write/read-as-empty |
|
|
30
|
+
| `session-projection.js` | Pure Host authority projection (metadata deep-merge + archive → `time.archived`) |
|
|
31
|
+
| `session-archive.js` | Domain archive/unarchive: validate upstream session + directory, persist, project, best-effort index/broadcast |
|
|
32
|
+
| `system-session.js` | Isolation patches for system session creators: `buildScheduledTaskMetadata`, `buildLlmSessionMetadata`, `buildAssistantSessionMetadata`. Title prefixes are labels and never ownership. |
|
|
33
|
+
| `routes.js` | `PUT .../metadata`, `PUT .../archive`, re-exports store/archive/projection |
|
|
34
|
+
| `*.d.ts` | Sibling type surfaces for Extension Host (`packages/vscode` facades) — runtime stays in `.js` |
|
|
35
|
+
| `*.test.js` | Store + route + archive contracts |
|
|
21
36
|
|
|
22
37
|
## Contracts
|
|
23
38
|
|
|
24
39
|
- Writes are JSON Merge Patch (RFC 7386): nested objects merge key-by-key; `null` deletes a key.
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
40
|
+
- Mutations are fully serialized (read-merge-write + persist) so concurrent writers cannot lose neighbors.
|
|
41
|
+
- Committed `entries` update **only after** successful persist — concurrent readers never observe uncommitted drafts. Persist failure keeps the last committed snapshot and surfaces the error.
|
|
42
|
+
- `mutateSessionMetadata(sessionID, decide)` runs `decide(committedMetadata)` on the same exclusive lock. Return `{ ok: false, reason? }` to reject without writing, or `{ ok: true, patch }` to merge-persist-publish. Session-goal uses this for generation/status preconditions.
|
|
43
|
+
- Goal-shaped metadata PUT patches advance `openchamber.goal.executionGeneration` via `withGoalExecutionGeneration` inside the mutate lock (pause/resume/condition changes).
|
|
44
|
+
- A failed or **corrupt** file read is **not** empty success — the store stays unwritable, keeps the file, and throws; it does not overwrite unknown disk state with `{}`.
|
|
45
|
+
- Entries are keyed by `sessionID` only (not directory). Runtime isolation is the Host data dir. Multiple Hosts sharing one data file are outside the current contract.
|
|
46
|
+
- Generic metadata patches **cannot** wipe Host archive: `openchamber.archive` is stripped from the patch and restored after merge (including null ancestor deletes). Archive writes use `setSessionMetadata(..., { allowArchive: true })`.
|
|
47
|
+
- Host archive authority (`metadata.openchamber.archive.archivedAt`):
|
|
48
|
+
- positive number → archived (`time.archived` = that value)
|
|
49
|
+
- `0` → explicit unarchive (clears `time.archived` even if upstream still has history)
|
|
50
|
+
- missing → fall back to upstream `time.archived`
|
|
51
|
+
- `PUT /api/openchamber/sessions/:sessionId/archive` body `{ archivedAt: number, directory?: string }` returns `{ session, index? }`. Validates upstream id + directory (`location.directory` first). Does not mutate upstream `created`/`updated`. Metadata commit is authoritative; `index: { ok: false, retryable: true }` is observable when SQLite apply fails after commit (`retryIndexUpdate` / `getIndexRepairStatus`).
|
|
52
|
+
- When the Host store is wired, proxy list/get return **503 retryable** on metadata read failure (never silent un-overlaid upstream). Until the store is ready, session lifecycle SSE/hub frames are suppressed; transcript/message paths stay open. Hub `replayAfter` re-projects with **current** committed metadata.
|
|
53
|
+
- Successful proxy `DELETE /api/session/:id` and assistant SDK delete run idempotent `forgetSession`; `session.deleted` remains compensatory. Upstream delete failure keeps metadata.
|
|
54
|
+
- Successful metadata `PUT` may broadcast `openchamber:session-metadata` with the **full** merged metadata object.
|
|
55
|
+
- Successful archive broadcasts `session.updated` with projected `info` (global UI + openchamber SSE).
|
|
56
|
+
- `session.deleted` events best-effort `removeSession` metadata cleanup.
|
|
28
57
|
|
|
29
58
|
## Server wiring
|
|
30
59
|
|
|
31
|
-
Wired from `packages/web/server/index.js`:
|
|
60
|
+
Wired from `packages/web/server/index.js` + `feature-routes-runtime.js`:
|
|
32
61
|
|
|
33
|
-
1. `createSessionMetadataStore({ dataDir: OPENCHAMBER_DATA_DIR })`
|
|
34
|
-
2. proxy `getStoredSessionMetadata
|
|
35
|
-
3.
|
|
36
|
-
4. `
|
|
62
|
+
1. `createSessionMetadataStore({ dataDir: OPENCHAMBER_DATA_DIR })` + initial `load()`
|
|
63
|
+
2. proxy `getStoredSessionMetadata` / `getStoredSessionMetadataSync` → list/get + direct SSE overlay
|
|
64
|
+
3. global hub `projectOutboundSessionPayload` → live + replay session lifecycle authority
|
|
65
|
+
4. session-index sync `projectSessions` + event-ingest `projectSession` / `onSessionDeleted`
|
|
66
|
+
5. `createSessionArchiveService` after session index exists
|
|
67
|
+
6. session-goal `readSessionMetadata` / `mutateSessionMetadata` / `persistSessionGoal`
|
|
68
|
+
7. `registerSessionMetadataRoutes` (metadata + archive)
|
|
69
|
+
8. assistants `archiveSessionHost` → shared Host archive (not upstream `session.update`)
|
|
70
|
+
9. system session creators persist isolation through `persistSessionMetadata`
|
|
71
|
+
(`setSessionMetadata` + `openchamber:session-metadata` broadcast) and then
|
|
72
|
+
`onSystemSessionPersisted` (index upsert that removes the row). Wired from
|
|
73
|
+
`index.js` into scheduled-task runtime, LLM attachment generate, and
|
|
74
|
+
Assistant binding create. OpenCode create-time `metadata` is a hint only.
|
|
37
75
|
|
|
38
76
|
Capability is open (`supported: true`). Manual UI metadata writes, session-goal
|
|
39
77
|
routes, and scheduled-task goal create all persist `openchamber.goal` through
|
|
40
78
|
this store. After first create/resume persist, the server notifies the goal
|
|
41
79
|
runtime with a synthetic `session.updated` so the loop can arm.
|
|
80
|
+
|
|
81
|
+
Sidebar / session-index / notifications hide a session only when Host metadata
|
|
82
|
+
carries a non-empty `openchamber.assistant.assistantID` (unless
|
|
83
|
+
`assigned.from === 'contact'`), `openchamber.scheduledTask.taskID`,
|
|
84
|
+
`openchamber.smallModel.purpose`, or `openchamber.llm.purpose`. Title prefixes
|
|
85
|
+
are human labels and never participate. Every system session creator must
|
|
86
|
+
persist the matching patch through this store.
|