@openchambery/web 2.0.0-beta.1 → 2.0.0-beta.11
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-pLaqWWmB.js} +1 -1
- package/dist/assets/AssistantView-C4GkABnI.js +3 -0
- package/dist/assets/{DiagramView-xuSJTnv7.js → DiagramView-ByYLIfWo.js} +1 -1
- package/dist/assets/{FadeInOnReveal-Ov0D96d0.js → FadeInOnReveal-MuJAzJq_.js} +1 -1
- package/dist/assets/Icon-Dz2kO9RN.js +1 -0
- package/dist/assets/MarkdownRenderer-CPU6kSHd.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-SKi4oG0c.js → MarkdownRendererImpl-2hw5cdqQ.js} +3 -3
- package/dist/assets/MarkstreamRendererImpl-DXZQkLTa.js +3 -0
- package/dist/assets/{MobileDetailNavigation-Bnx59U_J.js → MobileDetailNavigation-C9BzieQY.js} +1 -1
- package/dist/assets/{MobileShareBridge-BQlP4J-F.js → MobileShareBridge-2_iOfzqO.js} +2 -2
- package/dist/assets/MobileSurface-DNVA7K_1.js +2 -0
- package/dist/assets/{MultiRunWindow-ChZGob0v.js → MultiRunWindow-QA6pMrJ9.js} +1 -1
- package/dist/assets/{OnboardingScreen-DtQJxZG-.js → OnboardingScreen-XWkr7MjT.js} +1 -1
- package/dist/assets/OverlayScrollbar-DCWyGseO.js +2 -0
- package/dist/assets/SettingsView-dWuu0VIb.js +25 -0
- package/dist/assets/{SettingsWindow-DfnsFa8y.js → SettingsWindow-CetgiJMX.js} +1 -1
- package/dist/assets/TerminalView-BuXuo7gm.js +8 -0
- package/dist/assets/{ToolOutputDialog-BejmjrRk.js → ToolOutputDialog-DNSSOaCt.js} +2 -2
- package/dist/assets/appThemeRegistry-Cb7qFBsc.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-Doq9FI1I.js} +1 -1
- package/dist/assets/{collapsible-BXRd5_Hr.js → collapsible-CYUeu3_N.js} +1 -1
- package/dist/assets/{context-menu-jGtH6fR9.js → context-menu-C8kOMVtC.js} +1 -1
- package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-DbxnIQdS.js} +1 -1
- package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-dvxF4V2p.js} +1 -1
- package/dist/assets/{diagram-DQf7Y_uR.js → diagram-B1p9Yn1h.js} +1 -1
- package/dist/assets/{dialog-bfbvR4VH.js → dialog-D08-Lg-e.js} +1 -1
- package/dist/assets/es-Dsmc0A_3.js +15 -0
- package/dist/assets/fileMentionTouchSelection-BCAKb-NS.js +3 -0
- package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-X773xxvX.js} +1 -1
- package/dist/assets/fr-C_ZlZDI2.js +13 -0
- package/dist/assets/gitApi-3VBj3fbR.js +10 -0
- package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-BMztRu0c.js} +1 -1
- package/dist/assets/{input-8r6F_Fza.js → input-DZZuQ1IL.js} +1 -1
- package/dist/assets/insertionBoundaries-BMuCcvPy.js +1 -0
- package/dist/assets/ja-BqqTaWTi.js +15 -0
- package/dist/assets/ko-DP2XPM37.js +15 -0
- package/dist/assets/main-COEILjRF.js +169 -0
- package/dist/assets/main-D2BzpnUt.css +1 -0
- package/dist/assets/{main-CCE_4y5F.js → main-DsUewWPt.js} +2 -2
- package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
- package/dist/assets/{markdownHeightCache-1Qo6y6IJ.js → markdownHeightCache-DBgXGze8.js} +1 -1
- package/dist/assets/markdownTheme-DM5h1i18.js +1 -0
- package/dist/assets/mcpQueries-B05QR67V.js +1 -0
- package/dist/assets/miniChat-D7IPMm-k.js +2 -0
- package/dist/assets/mobile-BnCoDxnv.js +2 -0
- package/dist/assets/multirun-BDmFjX9N.js +4 -0
- package/dist/assets/persistence-DznVIKwz.js +3 -0
- package/dist/assets/pl-BCf5uLlK.js +15 -0
- package/dist/assets/platform-DSTtx-DI.js +6 -0
- package/dist/assets/projectMeta-C5gcAd5f.js +3 -0
- package/dist/assets/pt-BR-W09Q2pvn.js +15 -0
- package/dist/assets/{relay-image-stream-DM7atqJs.js → relay-image-stream-DrdVj99c.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-DsAaWCss.js +2 -0
- package/dist/assets/renderMobileApp-DOaoohlp.js +28 -0
- package/dist/assets/runtime-fetch-Fs9ZV2V8.js +1 -0
- package/dist/assets/{runtimeConfig-BMvqa5we.js → runtimeConfig-CzPK1uMj.js} +1 -1
- package/dist/assets/runtimeEndpointReset-DKjVJCdt.js +149 -0
- package/dist/assets/sessionChangeSummary-CktSWasa.js +2 -0
- package/dist/assets/{sessionLookup-gh-hVRIc.js → sessionLookup-CDA1aAcI.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-CudoSgV4.js → sortable-tabs-strip-Q3bb6MVj.js} +1 -1
- package/dist/assets/src-lS1keDVV.css +2 -0
- package/dist/assets/store-jaP79mqI.js +15 -0
- package/dist/assets/{terminalApi-DiF6JmBP.js → terminalApi-Da_DO_Uh.js} +1 -1
- package/dist/assets/textarea--CYehp07.js +1 -0
- package/dist/assets/{tooltip-CQvpK-UV.js → tooltip-CGLzBwEK.js} +1 -1
- package/dist/assets/uk-BgFzz8m2.js +15 -0
- package/dist/assets/{url-_z7evzdK.js → url-Byz3kd29.js} +1 -1
- package/dist/assets/{useAppFontEffects-Cpmpo7Wb.js → useAppFontEffects-2zzPMQF2.js} +64 -45
- package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-Bz4tRflF.js} +1 -1
- package/dist/assets/useDesktopWindowControlsLayout-B-2YooUd.js +1 -0
- package/dist/assets/useEffectiveDirectory-K3U4eL5F.js +1 -0
- package/dist/assets/{useFontPreferences-DcbXQOkc.js → useFontPreferences-D9OIZs1p.js} +1 -1
- package/dist/assets/useMobileNavigationStore-B0zSdCwP.js +414 -0
- package/dist/assets/useSessionAutoCleanup-BCXHAcbX.js +2 -0
- package/dist/assets/useUIStore-syVmbuDA.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-Bqn4AiBL.js → useWorkerHighlightedLines-CsoMQNik.js} +2 -2
- package/dist/assets/vendor-opencode-client-C12gfjag.js +12 -0
- package/dist/assets/{vendor-reactuses-core-BnW6GxJt.js → vendor-reactuses-core-Dq5ocTF8.js} +1 -1
- package/dist/assets/zh-CN-BhqSLgUY.js +15 -0
- package/dist/assets/zh-TW-BsaBgY-p.js +15 -0
- package/dist/index.html +12 -12
- package/dist/mini-chat.html +12 -12
- package/dist/mobile.html +12 -12
- package/package.json +3 -2
- package/server/index.js +359 -63
- 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/browser-provider/DOCUMENTATION.md +141 -0
- package/server/lib/browser-provider/contract.js +312 -0
- package/server/lib/browser-provider/host.js +421 -0
- package/server/lib/browser-provider/host.test.js +304 -0
- package/server/lib/browser-provider/routes.js +122 -0
- package/server/lib/browser-provider/routes.test.js +120 -0
- package/server/lib/browser-provider/selection-store.js +43 -0
- package/server/lib/browser-provider/selection-store.test.js +35 -0
- package/server/lib/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/DOCUMENTATION.md +3 -1
- package/server/lib/notifications/runtime.js +133 -31
- package/server/lib/notifications/runtime.test.js +103 -0
- package/server/lib/notifications/template-runtime.js +130 -38
- package/server/lib/notifications/template-runtime.test.js +96 -0
- package/server/lib/opencode/DOCUMENTATION.md +89 -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.d.ts +8 -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 +53 -4
- package/server/lib/opencode/feature-routes-runtime.test.js +25 -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 +254 -46
- package/server/lib/opencode/proxy.test.js +238 -5
- package/server/lib/opencode/routes.js +326 -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/upgrade-screen.d.ts +30 -0
- package/server/lib/opencode/upgrade-screen.js +299 -0
- package/server/lib/opencode/upgrade-screen.test.js +196 -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 +73 -18
- package/server/lib/session-metadata/opencode-session-record.d.ts +57 -0
- package/server/lib/session-metadata/opencode-session-record.js +316 -0
- package/server/lib/session-metadata/opencode-session-record.test.js +242 -0
- package/server/lib/session-metadata/routes.js +112 -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 +38 -0
- package/server/lib/session-metadata/session-metadata-store.js +270 -83
- 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 +279 -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 +69 -44
- 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/Icon-DD-BuMfZ.js +0 -1
- package/dist/assets/MarkdownRenderer-bc_p1H7l.js +0 -2
- 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/TerminalView-Dh9MWCpS.js +0 -8
- package/dist/assets/appThemeRegistry-COGfxTFV.js +0 -1
- package/dist/assets/es-c52LTHHe.js +0 -15
- package/dist/assets/fileMentionTouchSelection-BvrNCroG.js +0 -4
- 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/main-qSeeJRW1.css +0 -1
- package/dist/assets/markdown-worker-CptoICmq.js +0 -2
- package/dist/assets/markdownTheme-DOfGg-A7.js +0 -1
- 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/persistence-Ce3AnpEr.js +0 -3
- 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/vendor-opencode-client-BOe2qpQb.js +0 -12
- 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
|
@@ -124,16 +124,18 @@ describe('createSessionMetadataStore', () => {
|
|
|
124
124
|
expect(JSON.parse(readFile(dataDir))).toEqual({ ses_1: { a: 1 }, ses_2: { b: 2 } });
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
-
it('
|
|
127
|
+
it('refuses corrupt JSON as empty success and does not overwrite the file', async () => {
|
|
128
128
|
const dataDir = makeDataDir();
|
|
129
|
-
|
|
129
|
+
const corruptPath = path.join(dataDir, 'sessions-metadata.json');
|
|
130
|
+
fs.writeFileSync(corruptPath, '{ not json', 'utf8');
|
|
130
131
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
131
132
|
|
|
132
133
|
const store = createSessionMetadataStore({ dataDir });
|
|
133
|
-
await expect(store.getAll()).
|
|
134
|
-
await expect(store.setSessionMetadata('ses_1', { a: 1 })).
|
|
135
|
-
expect(
|
|
136
|
-
expect(
|
|
134
|
+
await expect(store.getAll()).rejects.toThrow(/unavailable|corrupt/i);
|
|
135
|
+
await expect(store.setSessionMetadata('ses_1', { a: 1 })).rejects.toThrow(/unavailable|corrupt/i);
|
|
136
|
+
expect(fs.readFileSync(corruptPath, 'utf8')).toBe('{ not json');
|
|
137
|
+
expect(store.isLoaded()).toBe(false);
|
|
138
|
+
expect(store.getSnapshotSync()).toBeNull();
|
|
137
139
|
});
|
|
138
140
|
|
|
139
141
|
it('drops entries that are not metadata objects', async () => {
|
|
@@ -159,19 +161,72 @@ describe('createSessionMetadataStore', () => {
|
|
|
159
161
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
160
162
|
|
|
161
163
|
const store = createSessionMetadataStore({ dataDir, fsPromises });
|
|
162
|
-
await expect(store.setSessionMetadata('ses_1', { a: 1 })).rejects.toThrow(/
|
|
164
|
+
await expect(store.setSessionMetadata('ses_1', { a: 1 })).rejects.toThrow(/unavailable/);
|
|
163
165
|
expect(readFile(dataDir)).toBe('{}');
|
|
164
166
|
});
|
|
165
167
|
|
|
166
|
-
it('
|
|
168
|
+
it('does not publish uncommitted drafts while persist is blocked (set)', async () => {
|
|
167
169
|
const dataDir = makeDataDir();
|
|
168
|
-
|
|
170
|
+
let releaseWrite;
|
|
171
|
+
const writeGate = new Promise((resolve) => { releaseWrite = resolve; });
|
|
172
|
+
const fsPromises = {
|
|
173
|
+
...fs.promises,
|
|
174
|
+
writeFile: async (target, payload, encoding) => {
|
|
175
|
+
await writeGate;
|
|
176
|
+
return fs.promises.writeFile(target, payload, encoding);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
const store = createSessionMetadataStore({ dataDir, fsPromises });
|
|
180
|
+
const pending = store.setSessionMetadata('ses_1', { a: 1 });
|
|
181
|
+
// Concurrent readers must still see committed empty — not the in-flight draft.
|
|
182
|
+
await expect(store.get('ses_1')).resolves.toEqual({});
|
|
183
|
+
expect(store.getSnapshotSync()).toEqual({});
|
|
184
|
+
releaseWrite();
|
|
185
|
+
await expect(pending).resolves.toEqual({ a: 1 });
|
|
186
|
+
await expect(store.get('ses_1')).resolves.toEqual({ a: 1 });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('does not publish uncommitted deletes while persist is blocked (remove)', async () => {
|
|
190
|
+
const dataDir = makeDataDir();
|
|
191
|
+
const storeWarm = createSessionMetadataStore({ dataDir });
|
|
192
|
+
await storeWarm.setSessionMetadata('ses_1', { a: 1 });
|
|
169
193
|
|
|
194
|
+
let releaseWrite = null;
|
|
195
|
+
const fsPromises = {
|
|
196
|
+
...fs.promises,
|
|
197
|
+
writeFile: async (target, payload, encoding) => {
|
|
198
|
+
if (releaseWrite) await new Promise((resolve) => { releaseWrite = resolve; });
|
|
199
|
+
return fs.promises.writeFile(target, payload, encoding);
|
|
200
|
+
},
|
|
201
|
+
};
|
|
170
202
|
const store = createSessionMetadataStore({ dataDir, fsPromises });
|
|
171
|
-
await
|
|
203
|
+
await store.load();
|
|
204
|
+
// Arm the gate only for the remove persist.
|
|
205
|
+
releaseWrite = () => {};
|
|
206
|
+
let unlock;
|
|
207
|
+
const gate = new Promise((resolve) => { unlock = resolve; });
|
|
208
|
+
fsPromises.writeFile = async (target, payload, encoding) => {
|
|
209
|
+
await gate;
|
|
210
|
+
return fs.promises.writeFile(target, payload, encoding);
|
|
211
|
+
};
|
|
212
|
+
const pending = store.removeSession('ses_1');
|
|
213
|
+
await expect(store.get('ses_1')).resolves.toEqual({ a: 1 });
|
|
214
|
+
unlock();
|
|
215
|
+
await expect(pending).resolves.toBe(true);
|
|
172
216
|
await expect(store.get('ses_1')).resolves.toEqual({});
|
|
173
217
|
});
|
|
174
218
|
|
|
219
|
+
it('keeps committed entries when persist fails (set never published)', async () => {
|
|
220
|
+
const dataDir = makeDataDir();
|
|
221
|
+
const storeOk = createSessionMetadataStore({ dataDir });
|
|
222
|
+
await storeOk.setSessionMetadata('ses_1', { a: 1 });
|
|
223
|
+
|
|
224
|
+
const fsPromises = { ...fs.promises, writeFile: async () => { throw new Error('disk full'); } };
|
|
225
|
+
const store = createSessionMetadataStore({ dataDir, fsPromises });
|
|
226
|
+
await expect(store.setSessionMetadata('ses_1', { b: 2 })).rejects.toThrow('disk full');
|
|
227
|
+
await expect(store.get('ses_1')).resolves.toEqual({ a: 1 });
|
|
228
|
+
});
|
|
229
|
+
|
|
175
230
|
it('forgets a session on request', async () => {
|
|
176
231
|
const dataDir = makeDataDir();
|
|
177
232
|
const store = createSessionMetadataStore({ dataDir });
|
|
@@ -181,4 +236,144 @@ describe('createSessionMetadataStore', () => {
|
|
|
181
236
|
await expect(store.removeSession('ses_1')).resolves.toBe(false);
|
|
182
237
|
expect(JSON.parse(readFile(dataDir))).toEqual({});
|
|
183
238
|
});
|
|
239
|
+
|
|
240
|
+
it('serializes concurrent writes so neighboring keys are not lost', async () => {
|
|
241
|
+
const store = createSessionMetadataStore({ dataDir: makeDataDir() });
|
|
242
|
+
await Promise.all([
|
|
243
|
+
store.setSessionMetadata('ses_1', { openchamber: { goal: { id: 'g1' } } }),
|
|
244
|
+
store.setSessionMetadata('ses_1', { openchamber: { assist: { recap: 'a' } } }),
|
|
245
|
+
]);
|
|
246
|
+
await expect(store.get('ses_1')).resolves.toEqual({
|
|
247
|
+
openchamber: { goal: { id: 'g1' }, assist: { recap: 'a' } },
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('protects Host archive from generic metadata null ancestor deletes', async () => {
|
|
252
|
+
const store = createSessionMetadataStore({ dataDir: makeDataDir() });
|
|
253
|
+
await store.setSessionMetadata('ses_1', { openchamber: { archive: { archivedAt: 9 } } }, { allowArchive: true });
|
|
254
|
+
await store.setSessionMetadata('ses_1', { openchamber: { goal: { id: 'g1' } } });
|
|
255
|
+
await expect(store.get('ses_1')).resolves.toEqual({
|
|
256
|
+
openchamber: { archive: { archivedAt: 9 }, goal: { id: 'g1' } },
|
|
257
|
+
});
|
|
258
|
+
await store.setSessionMetadata('ses_1', { openchamber: null });
|
|
259
|
+
await expect(store.get('ses_1')).resolves.toEqual({
|
|
260
|
+
openchamber: { archive: { archivedAt: 9 } },
|
|
261
|
+
});
|
|
262
|
+
await store.setSessionMetadata('ses_1', { openchamber: { archive: { archivedAt: 1 } } });
|
|
263
|
+
await expect(store.get('ses_1')).resolves.toEqual({
|
|
264
|
+
openchamber: { archive: { archivedAt: 9 } },
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('does not treat a failed load as empty success on get/getAll', async () => {
|
|
269
|
+
const dataDir = makeDataDir();
|
|
270
|
+
fs.writeFileSync(path.join(dataDir, 'sessions-metadata.json'), '{}', 'utf8');
|
|
271
|
+
const unreadable = Object.assign(new Error('EACCES'), { code: 'EACCES' });
|
|
272
|
+
const fsPromises = {
|
|
273
|
+
...fs.promises,
|
|
274
|
+
readFile: async () => { throw unreadable; },
|
|
275
|
+
};
|
|
276
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
277
|
+
const store = createSessionMetadataStore({ dataDir, fsPromises });
|
|
278
|
+
await expect(store.get('ses_1')).rejects.toThrow(/unavailable|could not be read|EACCES/i);
|
|
279
|
+
await expect(store.getAll()).rejects.toThrow(/unavailable|could not be read|EACCES/i);
|
|
280
|
+
expect(store.getSnapshotSync()).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('mutateSessionMetadata commits only when decide returns ok with a patch', async () => {
|
|
284
|
+
const store = createSessionMetadataStore({ dataDir: makeDataDir() });
|
|
285
|
+
await store.setSessionMetadata('ses_1', {
|
|
286
|
+
openchamber: { goal: { id: 'g1', status: 'active', executionGeneration: 2 }, assist: { recap: 'keep' } },
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const rejected = await store.mutateSessionMetadata('ses_1', (current) => {
|
|
290
|
+
const gen = current?.openchamber?.goal?.executionGeneration;
|
|
291
|
+
if (gen !== 2) return { ok: false, reason: 'generation_mismatch' };
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
reason: 'status_not_active',
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
expect(rejected).toEqual({
|
|
298
|
+
committed: false,
|
|
299
|
+
reason: 'status_not_active',
|
|
300
|
+
metadata: {
|
|
301
|
+
openchamber: { goal: { id: 'g1', status: 'active', executionGeneration: 2 }, assist: { recap: 'keep' } },
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
await expect(store.get('ses_1')).resolves.toEqual({
|
|
305
|
+
openchamber: { goal: { id: 'g1', status: 'active', executionGeneration: 2 }, assist: { recap: 'keep' } },
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const accepted = await store.mutateSessionMetadata('ses_1', (current) => {
|
|
309
|
+
if (current?.openchamber?.goal?.executionGeneration !== 2) {
|
|
310
|
+
return { ok: false, reason: 'generation_mismatch' };
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
ok: true,
|
|
314
|
+
patch: { openchamber: { goal: { status: 'paused', executionGeneration: 3 } } },
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
expect(accepted.committed).toBe(true);
|
|
318
|
+
expect(accepted.metadata.openchamber.goal).toEqual({
|
|
319
|
+
id: 'g1',
|
|
320
|
+
status: 'paused',
|
|
321
|
+
executionGeneration: 3,
|
|
322
|
+
});
|
|
323
|
+
expect(accepted.metadata.openchamber.assist).toEqual({ recap: 'keep' });
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('mutateSessionMetadata does not publish drafts while persist is blocked', async () => {
|
|
327
|
+
const dataDir = makeDataDir();
|
|
328
|
+
await createSessionMetadataStore({ dataDir }).setSessionMetadata('ses_1', {
|
|
329
|
+
openchamber: { goal: { id: 'g1', status: 'active', executionGeneration: 1 } },
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
let unlock;
|
|
333
|
+
const gate = new Promise((resolve) => { unlock = resolve; });
|
|
334
|
+
const fsPromises = {
|
|
335
|
+
...fs.promises,
|
|
336
|
+
writeFile: async (target, payload, encoding) => {
|
|
337
|
+
await gate;
|
|
338
|
+
return fs.promises.writeFile(target, payload, encoding);
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
const store = createSessionMetadataStore({ dataDir, fsPromises });
|
|
342
|
+
await store.load();
|
|
343
|
+
|
|
344
|
+
const pending = store.mutateSessionMetadata('ses_1', () => ({
|
|
345
|
+
ok: true,
|
|
346
|
+
patch: { openchamber: { goal: { status: 'paused', executionGeneration: 2 } } },
|
|
347
|
+
}));
|
|
348
|
+
await expect(store.get('ses_1')).resolves.toEqual({
|
|
349
|
+
openchamber: { goal: { id: 'g1', status: 'active', executionGeneration: 1 } },
|
|
350
|
+
});
|
|
351
|
+
expect(store.getSnapshotSync()?.ses_1?.openchamber?.goal?.status).toBe('active');
|
|
352
|
+
unlock();
|
|
353
|
+
await expect(pending).resolves.toMatchObject({
|
|
354
|
+
committed: true,
|
|
355
|
+
metadata: { openchamber: { goal: { status: 'paused', executionGeneration: 2 } } },
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('mutateSessionMetadata preserves Host archive and serializes with setSessionMetadata', async () => {
|
|
360
|
+
const store = createSessionMetadataStore({ dataDir: makeDataDir() });
|
|
361
|
+
await store.setSessionMetadata('ses_1', { openchamber: { archive: { archivedAt: 42 } } }, { allowArchive: true });
|
|
362
|
+
|
|
363
|
+
const [mutated] = await Promise.all([
|
|
364
|
+
store.mutateSessionMetadata('ses_1', () => ({
|
|
365
|
+
ok: true,
|
|
366
|
+
patch: { openchamber: { goal: { id: 'g1', status: 'active', executionGeneration: 0 } } },
|
|
367
|
+
})),
|
|
368
|
+
store.setSessionMetadata('ses_1', { openchamber: { assist: { recap: 'a' } } }),
|
|
369
|
+
]);
|
|
370
|
+
expect(mutated.committed).toBe(true);
|
|
371
|
+
await expect(store.get('ses_1')).resolves.toEqual({
|
|
372
|
+
openchamber: {
|
|
373
|
+
archive: { archivedAt: 42 },
|
|
374
|
+
goal: { id: 'g1', status: 'active', executionGeneration: 0 },
|
|
375
|
+
assist: { recap: 'a' },
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
});
|
|
184
379
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type surface for session-projection.js (consumed by VS Code Extension Host facades).
|
|
3
|
+
* Runtime implementation stays in the .js module.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const HOST_ARCHIVE_METADATA_KEY: string;
|
|
7
|
+
|
|
8
|
+
export function normalizeSessionEventType(type: unknown): string;
|
|
9
|
+
|
|
10
|
+
export function isSessionLifecycleEventType(type: unknown): boolean;
|
|
11
|
+
|
|
12
|
+
export function extractSessionInfoFromPayload(
|
|
13
|
+
payload: unknown,
|
|
14
|
+
): { id: string; [key: string]: unknown } | null;
|
|
15
|
+
|
|
16
|
+
export function resolveSessionDirectory(session: unknown): string | null;
|
|
17
|
+
|
|
18
|
+
export function readHostArchivedAt(metadata: unknown): number | null;
|
|
19
|
+
|
|
20
|
+
export function resolveProjectedArchivedAt(
|
|
21
|
+
upstreamArchived: unknown,
|
|
22
|
+
hostArchivedAt: unknown,
|
|
23
|
+
): number | null | undefined;
|
|
24
|
+
|
|
25
|
+
export function isSessionArchived(session: unknown): boolean;
|
|
26
|
+
|
|
27
|
+
export function projectSessionWithHostMetadata(
|
|
28
|
+
session: unknown,
|
|
29
|
+
hostMetadata: unknown,
|
|
30
|
+
): unknown;
|
|
31
|
+
|
|
32
|
+
export function projectSessionWithStoredMap(
|
|
33
|
+
session: unknown,
|
|
34
|
+
storedBySessionId: unknown,
|
|
35
|
+
): unknown;
|
|
36
|
+
|
|
37
|
+
export function projectSessionLifecyclePayload(
|
|
38
|
+
payload: unknown,
|
|
39
|
+
readHostMetadata: (sessionId: string) => unknown,
|
|
40
|
+
options?: { hostReady?: boolean },
|
|
41
|
+
): unknown;
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host authority projection for OpenCode sessions.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode 2.x has no durable archive write. OpenChamber stores archive state
|
|
5
|
+
* under `metadata.openchamber.archive.archivedAt` and folds it onto session
|
|
6
|
+
* list/detail/event payloads so clients keep reading `time.archived`.
|
|
7
|
+
*
|
|
8
|
+
* Authority:
|
|
9
|
+
* - positive archivedAt → Host archive wins
|
|
10
|
+
* - 0 → explicit unarchive (clears time.archived even if upstream still has history)
|
|
11
|
+
* - missing Host archive key → fall back to upstream time.archived
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
|
|
16
|
+
/** RFC 7386 merge (local copy — avoid circular import with the metadata store). */
|
|
17
|
+
const mergeMetadataPatch = (current, patch) => {
|
|
18
|
+
const base = isPlainObject(current) ? { ...current } : {};
|
|
19
|
+
if (!isPlainObject(patch)) return base;
|
|
20
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
21
|
+
if (value === null) {
|
|
22
|
+
delete base[key];
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
base[key] = isPlainObject(value) ? mergeMetadataPatch(base[key], value) : value;
|
|
26
|
+
}
|
|
27
|
+
return base;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const HOST_ARCHIVE_METADATA_KEY = 'archive';
|
|
31
|
+
|
|
32
|
+
const SESSION_LIFECYCLE_BASE = new Set([
|
|
33
|
+
'session.created',
|
|
34
|
+
'session.updated',
|
|
35
|
+
'session.deleted',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Strip optional version suffixes: `session.updated.v2` → `session.updated`.
|
|
40
|
+
* @param {unknown} type
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export const normalizeSessionEventType = (type) => {
|
|
44
|
+
if (typeof type !== 'string' || !type) return '';
|
|
45
|
+
const match = type.match(/^(session\.(?:created|updated|deleted|status|idle|error))(?:\.|$)/);
|
|
46
|
+
return match ? match[1] : type;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const isSessionLifecycleEventType = (type) => SESSION_LIFECYCLE_BASE.has(normalizeSessionEventType(type));
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve session info from legacy `properties.info`, native v2 `data.info`,
|
|
53
|
+
* or a bare session object under properties/data.
|
|
54
|
+
* @param {object | null | undefined} payload
|
|
55
|
+
*/
|
|
56
|
+
export const extractSessionInfoFromPayload = (payload) => {
|
|
57
|
+
if (!isPlainObject(payload)) return null;
|
|
58
|
+
const properties = isPlainObject(payload.properties) ? payload.properties : null;
|
|
59
|
+
const data = isPlainObject(payload.data) ? payload.data : null;
|
|
60
|
+
|
|
61
|
+
const candidates = [
|
|
62
|
+
properties?.info,
|
|
63
|
+
data?.info,
|
|
64
|
+
// Bare session on properties/data when shape is already a Session.
|
|
65
|
+
properties && typeof properties.id === 'string' ? properties : null,
|
|
66
|
+
data && typeof data.id === 'string' ? data : null,
|
|
67
|
+
];
|
|
68
|
+
for (const candidate of candidates) {
|
|
69
|
+
if (isPlainObject(candidate) && typeof candidate.id === 'string' && candidate.id) {
|
|
70
|
+
return candidate;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Authoritative worktree directory: v2 `location.directory`, then legacy fields.
|
|
78
|
+
* @param {object | null | undefined} session
|
|
79
|
+
*/
|
|
80
|
+
export const resolveSessionDirectory = (session) => {
|
|
81
|
+
if (!isPlainObject(session)) return null;
|
|
82
|
+
const candidates = [
|
|
83
|
+
session.location?.directory,
|
|
84
|
+
session.directory,
|
|
85
|
+
session.project?.worktree,
|
|
86
|
+
];
|
|
87
|
+
for (const value of candidates) {
|
|
88
|
+
if (typeof value !== 'string') continue;
|
|
89
|
+
const normalized = value.trim().replace(/\\/g, '/').replace(/\/+$/, '');
|
|
90
|
+
if (normalized) return normalized;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {unknown} value
|
|
97
|
+
* @returns {number | null} finite number including 0, else null when absent/invalid
|
|
98
|
+
*/
|
|
99
|
+
export const readHostArchivedAt = (metadata) => {
|
|
100
|
+
if (!isPlainObject(metadata)) return null;
|
|
101
|
+
const openchamber = metadata.openchamber;
|
|
102
|
+
if (!isPlainObject(openchamber)) return null;
|
|
103
|
+
if (!Object.prototype.hasOwnProperty.call(openchamber, HOST_ARCHIVE_METADATA_KEY)) return null;
|
|
104
|
+
const archive = openchamber[HOST_ARCHIVE_METADATA_KEY];
|
|
105
|
+
if (!isPlainObject(archive) || !Object.prototype.hasOwnProperty.call(archive, 'archivedAt')) return null;
|
|
106
|
+
const value = archive.archivedAt;
|
|
107
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
|
108
|
+
return value;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {unknown} upstreamArchived
|
|
113
|
+
* @param {number | null} hostArchivedAt
|
|
114
|
+
* @returns {number | undefined}
|
|
115
|
+
*/
|
|
116
|
+
export const resolveProjectedArchivedAt = (upstreamArchived, hostArchivedAt) => {
|
|
117
|
+
if (hostArchivedAt === 0) return undefined;
|
|
118
|
+
if (typeof hostArchivedAt === 'number' && hostArchivedAt > 0) return Math.trunc(hostArchivedAt);
|
|
119
|
+
if (typeof upstreamArchived === 'number' && Number.isFinite(upstreamArchived) && upstreamArchived > 0) {
|
|
120
|
+
return Math.trunc(upstreamArchived);
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const isSessionArchived = (session) => {
|
|
126
|
+
const archived = session?.time?.archived;
|
|
127
|
+
return typeof archived === 'number' && Number.isFinite(archived) && archived > 0;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Deep-merge Host metadata onto an upstream session and apply archive authority
|
|
132
|
+
* to `time.archived`. Does not mutate created/updated.
|
|
133
|
+
*
|
|
134
|
+
* @param {object | null | undefined} session
|
|
135
|
+
* @param {object | null | undefined} hostMetadata single session metadata object
|
|
136
|
+
* @returns {object | null | undefined}
|
|
137
|
+
*/
|
|
138
|
+
export const projectSessionWithHostMetadata = (session, hostMetadata) => {
|
|
139
|
+
if (!session || typeof session !== 'object' || Array.isArray(session)) return session;
|
|
140
|
+
if (typeof session.id !== 'string' || !session.id) return session;
|
|
141
|
+
|
|
142
|
+
const theirs = isPlainObject(session.metadata) ? session.metadata : {};
|
|
143
|
+
const ours = isPlainObject(hostMetadata) ? hostMetadata : null;
|
|
144
|
+
const metadata = ours ? mergeMetadataPatch(theirs, ours) : theirs;
|
|
145
|
+
|
|
146
|
+
const hostArchivedAt = readHostArchivedAt(ours ?? metadata);
|
|
147
|
+
const upstreamArchived = session.time && typeof session.time === 'object'
|
|
148
|
+
? session.time.archived
|
|
149
|
+
: undefined;
|
|
150
|
+
const projectedArchived = resolveProjectedArchivedAt(upstreamArchived, hostArchivedAt);
|
|
151
|
+
|
|
152
|
+
const timeBase = session.time && typeof session.time === 'object' && !Array.isArray(session.time)
|
|
153
|
+
? { ...session.time }
|
|
154
|
+
: {};
|
|
155
|
+
if (projectedArchived === undefined) delete timeBase.archived;
|
|
156
|
+
else timeBase.archived = projectedArchived;
|
|
157
|
+
|
|
158
|
+
const directory = resolveSessionDirectory(session);
|
|
159
|
+
const next = {
|
|
160
|
+
...session,
|
|
161
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : (session.metadata ?? metadata),
|
|
162
|
+
time: timeBase,
|
|
163
|
+
...(directory ? { directory } : {}),
|
|
164
|
+
};
|
|
165
|
+
if (Object.keys(metadata).length === 0 && session.metadata === undefined) {
|
|
166
|
+
delete next.metadata;
|
|
167
|
+
}
|
|
168
|
+
return next;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* OpenCode record keys win. Side-store keys the record does not have yet are
|
|
173
|
+
* filled in. A missing or empty side row does not wipe the record.
|
|
174
|
+
*
|
|
175
|
+
* @param {unknown} openCodeMetadata
|
|
176
|
+
* @param {unknown} sideMetadata
|
|
177
|
+
*/
|
|
178
|
+
export const mergeOwnedMetadata = (openCodeMetadata, sideMetadata) => {
|
|
179
|
+
const openCode = isPlainObject(openCodeMetadata) ? openCodeMetadata : {};
|
|
180
|
+
if (!isPlainObject(sideMetadata) || Object.keys(sideMetadata).length === 0) {
|
|
181
|
+
return { ...openCode };
|
|
182
|
+
}
|
|
183
|
+
return mergeMetadataPatch(sideMetadata, openCode);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const projectRecordMetadata = (session, sideMetadata) => {
|
|
187
|
+
const metadata = mergeOwnedMetadata(session.metadata, sideMetadata);
|
|
188
|
+
const hadMetadata = isPlainObject(session.metadata);
|
|
189
|
+
const withRecord = Object.keys(metadata).length === 0 && !hadMetadata
|
|
190
|
+
? session
|
|
191
|
+
: { ...session, metadata };
|
|
192
|
+
return projectSessionWithHostMetadata(withRecord, null);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {object | null | undefined} session
|
|
197
|
+
* @param {Record<string, object> | null | undefined} storedBySessionId
|
|
198
|
+
*/
|
|
199
|
+
export const projectSessionWithStoredMap = (session, storedBySessionId) => {
|
|
200
|
+
if (!session || typeof session !== 'object' || typeof session.id !== 'string') return session;
|
|
201
|
+
if (!storedBySessionId || typeof storedBySessionId !== 'object') {
|
|
202
|
+
return projectSessionWithHostMetadata(session, null);
|
|
203
|
+
}
|
|
204
|
+
const host = storedBySessionId[session.id];
|
|
205
|
+
return projectRecordMetadata(session, isPlainObject(host) ? host : null);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const projectLifecycleInner = (payload, readHostMetadata) => {
|
|
209
|
+
const baseType = normalizeSessionEventType(payload.type);
|
|
210
|
+
if (baseType !== 'session.created' && baseType !== 'session.updated') return payload;
|
|
211
|
+
|
|
212
|
+
const info = extractSessionInfoFromPayload(payload);
|
|
213
|
+
if (!info) return payload;
|
|
214
|
+
|
|
215
|
+
let host = null;
|
|
216
|
+
if (typeof readHostMetadata === 'function') {
|
|
217
|
+
try {
|
|
218
|
+
host = readHostMetadata(info.id);
|
|
219
|
+
} catch {
|
|
220
|
+
host = null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const projected = projectRecordMetadata(info, isPlainObject(host) ? host : null);
|
|
224
|
+
if (projected === info) return payload;
|
|
225
|
+
|
|
226
|
+
const next = { ...payload };
|
|
227
|
+
if (isPlainObject(payload.properties) && payload.properties.info === info) {
|
|
228
|
+
next.properties = { ...payload.properties, info: projected };
|
|
229
|
+
} else if (isPlainObject(payload.properties) && payload.properties === info) {
|
|
230
|
+
next.properties = projected;
|
|
231
|
+
}
|
|
232
|
+
if (isPlainObject(payload.data) && payload.data.info === info) {
|
|
233
|
+
next.data = { ...payload.data, info: projected };
|
|
234
|
+
} else if (isPlainObject(payload.data) && payload.data === info) {
|
|
235
|
+
next.data = projected;
|
|
236
|
+
}
|
|
237
|
+
// Keep original type string (including version suffix).
|
|
238
|
+
return next;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Project session.created / session.updated event payloads.
|
|
243
|
+
* Supports GlobalEvent wrap, versioned types, legacy properties.info, and
|
|
244
|
+
* native v2 data.info. Non-lifecycle payloads are returned unchanged.
|
|
245
|
+
* Envelope fields outside the inner payload are preserved.
|
|
246
|
+
*
|
|
247
|
+
* @param {unknown} payload
|
|
248
|
+
* @param {(sessionId: string) => object | null | undefined} readHostMetadata
|
|
249
|
+
* @param {{ hostReady?: boolean }} [options]
|
|
250
|
+
* When `hostReady === false`, lifecycle payloads return `null` (caller drops
|
|
251
|
+
* outbound publish). Transcript/message events are unaffected.
|
|
252
|
+
*/
|
|
253
|
+
export const projectSessionLifecyclePayload = (payload, readHostMetadata, options = {}) => {
|
|
254
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload;
|
|
255
|
+
|
|
256
|
+
// GlobalEvent wrap: { directory, payload: { type, ... } } — preserve envelope.
|
|
257
|
+
if (isPlainObject(payload.payload) && typeof payload.payload.type === 'string') {
|
|
258
|
+
const innerType = normalizeSessionEventType(payload.payload.type);
|
|
259
|
+
if (SESSION_LIFECYCLE_BASE.has(innerType) || innerType.startsWith('session.')) {
|
|
260
|
+
if (options.hostReady === false && isSessionLifecycleEventType(payload.payload.type)) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
const inner = projectSessionLifecyclePayload(payload.payload, readHostMetadata, options);
|
|
264
|
+
if (inner == null) return null;
|
|
265
|
+
return inner === payload.payload ? payload : { ...payload, payload: inner };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const type = typeof payload.type === 'string' ? payload.type : '';
|
|
270
|
+
if (options.hostReady === false && isSessionLifecycleEventType(type)) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
if (!isSessionLifecycleEventType(type) && normalizeSessionEventType(type) !== 'session.created'
|
|
274
|
+
&& normalizeSessionEventType(type) !== 'session.updated') {
|
|
275
|
+
return payload;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return projectLifecycleInner(payload, readHostMetadata);
|
|
279
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
extractSessionInfoFromPayload,
|
|
5
|
+
normalizeSessionEventType,
|
|
6
|
+
projectSessionLifecyclePayload,
|
|
7
|
+
resolveSessionDirectory,
|
|
8
|
+
} from './session-projection.js';
|
|
9
|
+
|
|
10
|
+
describe('session projection event shapes', () => {
|
|
11
|
+
it('normalizes version suffixes', () => {
|
|
12
|
+
expect(normalizeSessionEventType('session.updated.v2')).toBe('session.updated');
|
|
13
|
+
expect(normalizeSessionEventType('session.created')).toBe('session.created');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('extracts info from properties.info and data.info', () => {
|
|
17
|
+
expect(extractSessionInfoFromPayload({
|
|
18
|
+
type: 'session.updated',
|
|
19
|
+
properties: { info: { id: 'ses_a' } },
|
|
20
|
+
})?.id).toBe('ses_a');
|
|
21
|
+
expect(extractSessionInfoFromPayload({
|
|
22
|
+
type: 'session.updated.v2',
|
|
23
|
+
data: { info: { id: 'ses_b' } },
|
|
24
|
+
})?.id).toBe('ses_b');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('prefers location.directory', () => {
|
|
28
|
+
expect(resolveSessionDirectory({
|
|
29
|
+
location: { directory: '/loc' },
|
|
30
|
+
directory: '/legacy',
|
|
31
|
+
})).toBe('/loc');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('projects GlobalEvent wrap and preserves envelope directory', () => {
|
|
35
|
+
const projected = projectSessionLifecyclePayload({
|
|
36
|
+
directory: '/repo',
|
|
37
|
+
payload: {
|
|
38
|
+
type: 'session.updated.v2',
|
|
39
|
+
data: {
|
|
40
|
+
info: {
|
|
41
|
+
id: 'ses_1',
|
|
42
|
+
time: { created: 1, updated: 2, archived: 50 },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
}, () => ({ openchamber: { archive: { archivedAt: 99 } } }));
|
|
47
|
+
|
|
48
|
+
expect(projected.directory).toBe('/repo');
|
|
49
|
+
expect(projected.payload.type).toBe('session.updated.v2');
|
|
50
|
+
expect(projected.payload.data.info.time.archived).toBe(99);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns null for lifecycle when hostReady is false', () => {
|
|
54
|
+
expect(projectSessionLifecyclePayload({
|
|
55
|
+
type: 'session.updated',
|
|
56
|
+
properties: { info: { id: 'ses_1', time: {} } },
|
|
57
|
+
}, () => null, { hostReady: false })).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('leaves message events alone when hostReady is false', () => {
|
|
61
|
+
const msg = { type: 'message.updated', properties: { info: { role: 'user' } } };
|
|
62
|
+
expect(projectSessionLifecyclePayload(msg, () => null, { hostReady: false })).toBe(msg);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-owned isolation markers for system sessions.
|
|
3
|
+
*
|
|
4
|
+
* Sidebar / session-index / notifications hide a session only when these
|
|
5
|
+
* fields are present. Title prefixes are labels and never ownership.
|
|
6
|
+
*
|
|
7
|
+
* Every system session creator (scheduled tasks, LLM attachment gateway,
|
|
8
|
+
* Assistant bindings) must persist the matching patch through the Host
|
|
9
|
+
* session-metadata store. OpenCode create-time `metadata` is a hint only.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const LLM_SESSION_PURPOSE = 'chat-completions';
|
|
13
|
+
|
|
14
|
+
const nonEmpty = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);
|
|
15
|
+
|
|
16
|
+
export const buildScheduledTaskMetadata = ({ projectID, taskID, runID, name } = {}) => {
|
|
17
|
+
const id = nonEmpty(taskID);
|
|
18
|
+
if (!id) {
|
|
19
|
+
throw new Error('scheduled-task metadata requires taskID');
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
openchamber: {
|
|
23
|
+
scheduledTask: {
|
|
24
|
+
taskID: id,
|
|
25
|
+
...(nonEmpty(projectID) ? { projectID: nonEmpty(projectID) } : {}),
|
|
26
|
+
...(nonEmpty(runID) ? { runID: nonEmpty(runID) } : {}),
|
|
27
|
+
...(nonEmpty(name) ? { name: nonEmpty(name) } : {}),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const buildLlmSessionMetadata = (purpose = LLM_SESSION_PURPOSE) => {
|
|
34
|
+
const resolved = nonEmpty(purpose) || LLM_SESSION_PURPOSE;
|
|
35
|
+
return {
|
|
36
|
+
openchamber: {
|
|
37
|
+
llm: { purpose: resolved },
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const buildAssistantSessionMetadata = ({ assistantID, name } = {}) => {
|
|
43
|
+
const id = nonEmpty(assistantID);
|
|
44
|
+
if (!id) {
|
|
45
|
+
throw new Error('assistant session metadata requires assistantID');
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
openchamber: {
|
|
49
|
+
assistant: {
|
|
50
|
+
assistantID: id,
|
|
51
|
+
...(nonEmpty(name) ? { name: nonEmpty(name) } : {}),
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
};
|