@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
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-owned session archive domain operations.
|
|
3
|
+
*
|
|
4
|
+
* Persists `metadata.openchamber.archive.archivedAt` in the session-metadata
|
|
5
|
+
* store, projects full Session records for HTTP/SSE consumers, and schedules
|
|
6
|
+
* bounded index repair after durable metadata commit when SQLite apply fails.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
isSessionArchived,
|
|
11
|
+
projectSessionWithHostMetadata,
|
|
12
|
+
readHostArchivedAt,
|
|
13
|
+
resolveSessionDirectory,
|
|
14
|
+
} from './session-projection.js';
|
|
15
|
+
|
|
16
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
|
|
18
|
+
const asNonEmptyString = (value) => {
|
|
19
|
+
if (typeof value !== 'string') return null;
|
|
20
|
+
const trimmed = value.trim();
|
|
21
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const normalizeDirectory = (value) => {
|
|
25
|
+
if (typeof value !== 'string') return null;
|
|
26
|
+
const normalized = value.trim().replace(/\\/g, '/').replace(/\/+$/, '');
|
|
27
|
+
return normalized || null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Bounded backoff for index repair and metadata cleanup (ms). */
|
|
31
|
+
const REPAIR_DELAYS_MS = [250, 750, 1_500, 3_000, 6_000];
|
|
32
|
+
const CLEANUP_DELAYS_MS = [200, 600, 1_200, 2_500, 5_000];
|
|
33
|
+
|
|
34
|
+
export class SessionArchiveError extends Error {
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} code
|
|
37
|
+
* @param {string} message
|
|
38
|
+
* @param {number} [status]
|
|
39
|
+
*/
|
|
40
|
+
constructor(code, message, status = 400) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'SessionArchiveError';
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.status = status;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {object} deps
|
|
50
|
+
* @param {{ setSessionMetadata: Function, get: Function, removeSession?: Function, getSnapshotSync?: Function }} deps.sessionMetadataStore
|
|
51
|
+
* @param {(input: { sessionID: string, directory?: string | null }) => Promise<object | null>} deps.fetchUpstreamSession
|
|
52
|
+
* @param {{ upsert?: Function, upsertAndReportChange?: Function, remove?: Function } | null} [deps.sessionIndexService]
|
|
53
|
+
* @param {(event: { type: string, properties: object }, options?: object) => void} [deps.broadcastSessionEvent]
|
|
54
|
+
* @param {(sessionID: string) => void} [deps.onIndexChanged]
|
|
55
|
+
* @param {() => number} [deps.now]
|
|
56
|
+
* @param {typeof setTimeout} [deps.setTimer]
|
|
57
|
+
* @param {typeof clearTimeout} [deps.clearTimer]
|
|
58
|
+
*/
|
|
59
|
+
export const createSessionArchiveService = (deps = {}) => {
|
|
60
|
+
const {
|
|
61
|
+
sessionMetadataStore = null,
|
|
62
|
+
fetchUpstreamSession = null,
|
|
63
|
+
sessionIndexService = null,
|
|
64
|
+
broadcastSessionEvent = null,
|
|
65
|
+
onIndexChanged = null,
|
|
66
|
+
now = Date.now,
|
|
67
|
+
setTimer = setTimeout,
|
|
68
|
+
clearTimer = clearTimeout,
|
|
69
|
+
} = deps;
|
|
70
|
+
|
|
71
|
+
if (!sessionMetadataStore || typeof sessionMetadataStore.setSessionMetadata !== 'function') {
|
|
72
|
+
throw new Error('createSessionArchiveService requires sessionMetadataStore');
|
|
73
|
+
}
|
|
74
|
+
if (typeof fetchUpstreamSession !== 'function') {
|
|
75
|
+
throw new Error('createSessionArchiveService requires fetchUpstreamSession');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Per-session operation generation. Bumped on every successful Host mutation
|
|
80
|
+
* (archive/unarchive), forget/delete, and stop. In-flight repairs capture the
|
|
81
|
+
* generation at start and abort after await if a newer operation won.
|
|
82
|
+
* @type {Map<string, number>}
|
|
83
|
+
*/
|
|
84
|
+
const operationGeneration = new Map();
|
|
85
|
+
/** @type {Map<string, { sessionID: string, directory: string | null, archivedAt: number, generation: number, attempts: number, error?: string, failedAt?: number, timer?: ReturnType<typeof setTimeout> }>} */
|
|
86
|
+
const pendingIndexRepair = new Map();
|
|
87
|
+
/** @type {Map<string, { sessionID: string, attempts: number, error?: string, failedAt?: number, timer?: ReturnType<typeof setTimeout> }>} */
|
|
88
|
+
const pendingCleanup = new Map();
|
|
89
|
+
let stopped = false;
|
|
90
|
+
|
|
91
|
+
const getOperationGeneration = (sessionID) => operationGeneration.get(sessionID) ?? 0;
|
|
92
|
+
|
|
93
|
+
const bumpOperationGeneration = (sessionID) => {
|
|
94
|
+
const next = getOperationGeneration(sessionID) + 1;
|
|
95
|
+
operationGeneration.set(sessionID, next);
|
|
96
|
+
return next;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const clearRepairTimer = (entry) => {
|
|
100
|
+
if (entry?.timer) {
|
|
101
|
+
clearTimer(entry.timer);
|
|
102
|
+
entry.timer = undefined;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Invalidate in-flight repair for this session: bump generation + clear timer.
|
|
108
|
+
* Does not delete a *newer* pending that already carries the bumped generation.
|
|
109
|
+
*/
|
|
110
|
+
const invalidateSessionRepairs = (sessionID, { clearPending = false } = {}) => {
|
|
111
|
+
const id = asNonEmptyString(sessionID);
|
|
112
|
+
if (!id) return 0;
|
|
113
|
+
const gen = bumpOperationGeneration(id);
|
|
114
|
+
const pending = pendingIndexRepair.get(id);
|
|
115
|
+
if (pending) {
|
|
116
|
+
clearRepairTimer(pending);
|
|
117
|
+
if (clearPending || pending.generation < gen) {
|
|
118
|
+
// Only drop pending when explicitly clearing or it belongs to an older op.
|
|
119
|
+
if (pending.generation < gen) pendingIndexRepair.delete(id);
|
|
120
|
+
else if (clearPending) pendingIndexRepair.delete(id);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return gen;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const readHostMetadataSync = (sessionID) => {
|
|
127
|
+
if (typeof sessionMetadataStore.getSnapshotSync === 'function') {
|
|
128
|
+
const snap = sessionMetadataStore.getSnapshotSync();
|
|
129
|
+
if (snap && isPlainObject(snap[sessionID])) return snap[sessionID];
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const projectSession = (session, hostMetadata = null) => {
|
|
136
|
+
const host = hostMetadata ?? (
|
|
137
|
+
session?.id && typeof sessionMetadataStore.getSnapshotSync === 'function'
|
|
138
|
+
? readHostMetadataSync(session.id)
|
|
139
|
+
: null
|
|
140
|
+
);
|
|
141
|
+
return projectSessionWithHostMetadata(session, host);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const applyIndexRows = (projected) => {
|
|
145
|
+
if (!sessionIndexService || !projected?.id) {
|
|
146
|
+
return { ok: true, skipped: true };
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
if (typeof sessionIndexService.upsertAndReportChange === 'function') {
|
|
150
|
+
const changed = sessionIndexService.upsertAndReportChange(projected);
|
|
151
|
+
if (changed && typeof onIndexChanged === 'function') onIndexChanged(projected.id);
|
|
152
|
+
} else if (typeof sessionIndexService.upsert === 'function') {
|
|
153
|
+
sessionIndexService.upsert(projected);
|
|
154
|
+
if (typeof onIndexChanged === 'function') onIndexChanged(projected.id);
|
|
155
|
+
}
|
|
156
|
+
return { ok: true };
|
|
157
|
+
} catch (error) {
|
|
158
|
+
const message = error?.message ?? String(error);
|
|
159
|
+
console.warn('[session-archive] session index update failed after commit:', message);
|
|
160
|
+
return { ok: false, error: message, retryable: true };
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const isRepairStillCurrent = (sessionID, generation) => {
|
|
165
|
+
if (stopped) return false;
|
|
166
|
+
return getOperationGeneration(sessionID) === generation;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Fetch upstream first (may hang), then read the *latest* committed Host
|
|
171
|
+
* snapshot and apply — never use metadata captured before the await.
|
|
172
|
+
* Aborts without writing if generation was superseded (unarchive/delete/stop).
|
|
173
|
+
*/
|
|
174
|
+
const rebuildAndApplyIndex = async ({ sessionID, directory, archivedAt, generation }) => {
|
|
175
|
+
const capturedGen = typeof generation === 'number' ? generation : getOperationGeneration(sessionID);
|
|
176
|
+
|
|
177
|
+
let upstream = null;
|
|
178
|
+
let fetchError = null;
|
|
179
|
+
try {
|
|
180
|
+
upstream = await fetchUpstreamSession({
|
|
181
|
+
sessionID,
|
|
182
|
+
directory: directory || undefined,
|
|
183
|
+
});
|
|
184
|
+
} catch (error) {
|
|
185
|
+
fetchError = error;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Generation check AFTER the hangable await — stale repairs stop here.
|
|
189
|
+
if (!isRepairStillCurrent(sessionID, capturedGen)) {
|
|
190
|
+
return { ok: true, stale: true, projected: null };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Latest committed Host authority — after upstream returns.
|
|
194
|
+
let metadata = {};
|
|
195
|
+
try {
|
|
196
|
+
metadata = await sessionMetadataStore.get(sessionID);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
return { ok: false, error: error?.message ?? String(error), retryable: true };
|
|
199
|
+
}
|
|
200
|
+
if (!isRepairStillCurrent(sessionID, capturedGen)) {
|
|
201
|
+
return { ok: true, stale: true, projected: null };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const hostArchived = readHostArchivedAt(metadata);
|
|
205
|
+
const effectiveArchived = typeof hostArchived === 'number' ? hostArchived : archivedAt;
|
|
206
|
+
|
|
207
|
+
if (fetchError) {
|
|
208
|
+
if (Number(fetchError?.status) === 404 || Number(fetchError?.statusCode) === 404) {
|
|
209
|
+
if (typeof effectiveArchived === 'number' && effectiveArchived > 0 && sessionIndexService?.remove) {
|
|
210
|
+
if (!isRepairStillCurrent(sessionID, capturedGen)) {
|
|
211
|
+
return { ok: true, stale: true, projected: null };
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
sessionIndexService.remove(sessionID);
|
|
215
|
+
if (typeof onIndexChanged === 'function') onIndexChanged(sessionID);
|
|
216
|
+
return { ok: true, projected: null };
|
|
217
|
+
} catch (removeError) {
|
|
218
|
+
return { ok: false, error: removeError?.message ?? String(removeError), retryable: true };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { ok: true, projected: null };
|
|
222
|
+
}
|
|
223
|
+
return { ok: false, error: fetchError?.message ?? String(fetchError), retryable: true };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!upstream || typeof upstream !== 'object' || upstream.id !== sessionID) {
|
|
227
|
+
return { ok: false, error: 'upstream session unavailable for index repair', retryable: true };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const projected = projectSessionWithHostMetadata(
|
|
231
|
+
{
|
|
232
|
+
...upstream,
|
|
233
|
+
directory: resolveSessionDirectory(upstream) || directory || upstream.directory,
|
|
234
|
+
},
|
|
235
|
+
metadata,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
if (!isRepairStillCurrent(sessionID, capturedGen)) {
|
|
239
|
+
return { ok: true, stale: true, projected: null };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const applied = applyIndexRows(projected);
|
|
243
|
+
if (!isRepairStillCurrent(sessionID, capturedGen)) {
|
|
244
|
+
return { ok: true, stale: true, projected: null };
|
|
245
|
+
}
|
|
246
|
+
return applied.ok
|
|
247
|
+
? { ok: true, projected }
|
|
248
|
+
: { ok: false, error: applied.error, retryable: true, projected };
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const scheduleIndexRepair = (sessionID, directory, archivedAt, priorError = null, generation = null) => {
|
|
252
|
+
if (stopped) return;
|
|
253
|
+
const id = asNonEmptyString(sessionID);
|
|
254
|
+
if (!id) return;
|
|
255
|
+
const gen = typeof generation === 'number' ? generation : getOperationGeneration(id);
|
|
256
|
+
// A newer mutation already owns this session — do not reschedule an old op.
|
|
257
|
+
if (gen !== getOperationGeneration(id)) return;
|
|
258
|
+
|
|
259
|
+
const existing = pendingIndexRepair.get(id);
|
|
260
|
+
if (existing && existing.generation > gen) {
|
|
261
|
+
// Newer repair already pending — leave it alone.
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (existing && existing.generation === gen) {
|
|
265
|
+
clearRepairTimer(existing);
|
|
266
|
+
} else if (existing) {
|
|
267
|
+
clearRepairTimer(existing);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const entry = {
|
|
271
|
+
sessionID: id,
|
|
272
|
+
directory: directory || existing?.directory || null,
|
|
273
|
+
archivedAt,
|
|
274
|
+
generation: gen,
|
|
275
|
+
attempts: existing?.generation === gen ? existing.attempts : 0,
|
|
276
|
+
error: priorError || existing?.error,
|
|
277
|
+
failedAt: now(),
|
|
278
|
+
};
|
|
279
|
+
pendingIndexRepair.set(id, entry);
|
|
280
|
+
|
|
281
|
+
if (entry.attempts >= REPAIR_DELAYS_MS.length) {
|
|
282
|
+
console.warn(`[session-archive] index repair exhausted for ${id}: ${entry.error}`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const delay = REPAIR_DELAYS_MS[entry.attempts];
|
|
287
|
+
entry.attempts += 1;
|
|
288
|
+
entry.timer = setTimer(() => {
|
|
289
|
+
entry.timer = undefined;
|
|
290
|
+
return (async () => {
|
|
291
|
+
if (!isRepairStillCurrent(id, gen)) {
|
|
292
|
+
// Only drop pending if it still matches this superseded generation.
|
|
293
|
+
const current = pendingIndexRepair.get(id);
|
|
294
|
+
if (current && current.generation === gen) pendingIndexRepair.delete(id);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const result = await rebuildAndApplyIndex(entry);
|
|
298
|
+
if (result.stale) {
|
|
299
|
+
const current = pendingIndexRepair.get(id);
|
|
300
|
+
if (current && current.generation === gen) pendingIndexRepair.delete(id);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (result.ok) {
|
|
304
|
+
const current = pendingIndexRepair.get(id);
|
|
305
|
+
if (current && current.generation === gen) pendingIndexRepair.delete(id);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (!isRepairStillCurrent(id, gen)) return;
|
|
309
|
+
entry.error = result.error;
|
|
310
|
+
entry.failedAt = now();
|
|
311
|
+
scheduleIndexRepair(id, entry.directory, entry.archivedAt, result.error, gen);
|
|
312
|
+
})();
|
|
313
|
+
}, delay);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const applyIndexAfterCommit = (projected, { directory, archivedAt, generation } = {}) => {
|
|
317
|
+
const gen = typeof generation === 'number' ? generation : getOperationGeneration(projected?.id);
|
|
318
|
+
const result = applyIndexRows(projected);
|
|
319
|
+
if (result.ok) {
|
|
320
|
+
if (projected?.id) {
|
|
321
|
+
const pending = pendingIndexRepair.get(projected.id);
|
|
322
|
+
// Clear only pending belonging to this operation — not a newer repair.
|
|
323
|
+
if (pending && pending.generation === gen) {
|
|
324
|
+
clearRepairTimer(pending);
|
|
325
|
+
pendingIndexRepair.delete(projected.id);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { ok: true };
|
|
329
|
+
}
|
|
330
|
+
if (projected?.id) {
|
|
331
|
+
scheduleIndexRepair(
|
|
332
|
+
projected.id,
|
|
333
|
+
directory ?? projected.directory ?? null,
|
|
334
|
+
archivedAt,
|
|
335
|
+
result.error,
|
|
336
|
+
gen,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
return { ok: false, error: result.error, retryable: true };
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const broadcastAfterCommit = (projected) => {
|
|
343
|
+
if (typeof broadcastSessionEvent !== 'function' || !projected?.id) return;
|
|
344
|
+
try {
|
|
345
|
+
broadcastSessionEvent({
|
|
346
|
+
type: 'session.updated',
|
|
347
|
+
properties: {
|
|
348
|
+
info: projected,
|
|
349
|
+
sessionID: projected.id,
|
|
350
|
+
},
|
|
351
|
+
}, {
|
|
352
|
+
directory: typeof projected.directory === 'string' ? projected.directory : undefined,
|
|
353
|
+
});
|
|
354
|
+
} catch (error) {
|
|
355
|
+
console.warn('[session-archive] broadcast failed after commit:', error?.message ?? error);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Manual / status retry: rebuild from latest authority (not stale pending blob).
|
|
361
|
+
*/
|
|
362
|
+
const retryIndexUpdate = async (sessionID) => {
|
|
363
|
+
const id = asNonEmptyString(sessionID);
|
|
364
|
+
if (!id) return { ok: false, error: 'session id required' };
|
|
365
|
+
const pending = pendingIndexRepair.get(id);
|
|
366
|
+
const directory = pending?.directory ?? null;
|
|
367
|
+
const archivedAt = pending?.archivedAt ?? 0;
|
|
368
|
+
const gen = pending?.generation ?? getOperationGeneration(id);
|
|
369
|
+
const result = await rebuildAndApplyIndex({
|
|
370
|
+
sessionID: id,
|
|
371
|
+
directory,
|
|
372
|
+
archivedAt,
|
|
373
|
+
generation: gen,
|
|
374
|
+
});
|
|
375
|
+
if (result.stale) return { ok: true, stale: true };
|
|
376
|
+
if (result.ok) {
|
|
377
|
+
const current = pendingIndexRepair.get(id);
|
|
378
|
+
if (current && current.generation === gen) pendingIndexRepair.delete(id);
|
|
379
|
+
return { ok: true };
|
|
380
|
+
}
|
|
381
|
+
scheduleIndexRepair(id, directory, archivedAt, result.error, gen);
|
|
382
|
+
return { ok: false, error: result.error, retryable: true };
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const getIndexRepairStatus = (sessionID) => {
|
|
386
|
+
const id = asNonEmptyString(sessionID);
|
|
387
|
+
if (!id) return null;
|
|
388
|
+
const pending = pendingIndexRepair.get(id);
|
|
389
|
+
if (!pending) return { ok: true, pending: false };
|
|
390
|
+
return {
|
|
391
|
+
ok: false,
|
|
392
|
+
pending: true,
|
|
393
|
+
retryable: pending.attempts < REPAIR_DELAYS_MS.length,
|
|
394
|
+
attempts: pending.attempts,
|
|
395
|
+
error: pending.error,
|
|
396
|
+
failedAt: pending.failedAt,
|
|
397
|
+
};
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const setArchive = async (input = {}) => {
|
|
401
|
+
const sessionID = asNonEmptyString(input.sessionID);
|
|
402
|
+
if (!sessionID) {
|
|
403
|
+
throw new SessionArchiveError('validation_error', 'a session id is required', 400);
|
|
404
|
+
}
|
|
405
|
+
if (typeof input.archivedAt !== 'number' || !Number.isFinite(input.archivedAt)) {
|
|
406
|
+
throw new SessionArchiveError('validation_error', 'archivedAt must be a finite number', 400);
|
|
407
|
+
}
|
|
408
|
+
if (input.archivedAt < 0) {
|
|
409
|
+
throw new SessionArchiveError('validation_error', 'archivedAt must be >= 0', 400);
|
|
410
|
+
}
|
|
411
|
+
const archivedAt = input.archivedAt > 0 ? Math.trunc(input.archivedAt) : 0;
|
|
412
|
+
const directoryHint = normalizeDirectory(input.directory);
|
|
413
|
+
|
|
414
|
+
let upstream;
|
|
415
|
+
try {
|
|
416
|
+
upstream = await fetchUpstreamSession({
|
|
417
|
+
sessionID,
|
|
418
|
+
directory: directoryHint,
|
|
419
|
+
});
|
|
420
|
+
} catch (error) {
|
|
421
|
+
const status = Number(error?.status) || Number(error?.statusCode) || 502;
|
|
422
|
+
if (status === 404) {
|
|
423
|
+
throw new SessionArchiveError('not_found', `session ${sessionID} was not found`, 404);
|
|
424
|
+
}
|
|
425
|
+
throw new SessionArchiveError(
|
|
426
|
+
'upstream_error',
|
|
427
|
+
error?.message || 'failed to read upstream session',
|
|
428
|
+
status >= 400 && status < 600 ? status : 502,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
if (!upstream || typeof upstream !== 'object' || typeof upstream.id !== 'string') {
|
|
432
|
+
throw new SessionArchiveError('not_found', `session ${sessionID} was not found`, 404);
|
|
433
|
+
}
|
|
434
|
+
if (upstream.id !== sessionID) {
|
|
435
|
+
throw new SessionArchiveError(
|
|
436
|
+
'id_mismatch',
|
|
437
|
+
'upstream session id does not match the requested session id',
|
|
438
|
+
502,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const upstreamDirectory = normalizeDirectory(resolveSessionDirectory(upstream));
|
|
443
|
+
if (directoryHint && upstreamDirectory && directoryHint !== upstreamDirectory) {
|
|
444
|
+
throw new SessionArchiveError(
|
|
445
|
+
'directory_mismatch',
|
|
446
|
+
'directory does not match the upstream session',
|
|
447
|
+
403,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const metadata = await sessionMetadataStore.setSessionMetadata(
|
|
452
|
+
sessionID,
|
|
453
|
+
{ openchamber: { archive: { archivedAt } } },
|
|
454
|
+
{ allowArchive: true },
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
// New Host mutation wins: bump generation and cancel older repair timers
|
|
458
|
+
// without wiping a pending that will be scheduled for *this* generation.
|
|
459
|
+
const gen = bumpOperationGeneration(sessionID);
|
|
460
|
+
const priorPending = pendingIndexRepair.get(sessionID);
|
|
461
|
+
if (priorPending && priorPending.generation < gen) {
|
|
462
|
+
clearRepairTimer(priorPending);
|
|
463
|
+
pendingIndexRepair.delete(sessionID);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const projected = projectSessionWithHostMetadata(
|
|
467
|
+
{
|
|
468
|
+
...upstream,
|
|
469
|
+
directory: upstreamDirectory || directoryHint || upstream.directory,
|
|
470
|
+
},
|
|
471
|
+
metadata,
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
const index = applyIndexAfterCommit(projected, {
|
|
475
|
+
directory: upstreamDirectory || directoryHint,
|
|
476
|
+
archivedAt,
|
|
477
|
+
generation: gen,
|
|
478
|
+
});
|
|
479
|
+
broadcastAfterCommit(projected);
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
session: projected,
|
|
483
|
+
metadata,
|
|
484
|
+
archivedAt,
|
|
485
|
+
index: index.ok
|
|
486
|
+
? { ok: true }
|
|
487
|
+
: { ok: false, error: index.error, retryable: true },
|
|
488
|
+
};
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const archiveSession = (sessionID, directory = null, archivedAt = now()) => setArchive({
|
|
492
|
+
sessionID,
|
|
493
|
+
directory: directory ?? undefined,
|
|
494
|
+
archivedAt: typeof archivedAt === 'number' && Number.isFinite(archivedAt) && archivedAt > 0
|
|
495
|
+
? archivedAt
|
|
496
|
+
: now(),
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const unarchiveSession = (sessionID, directory = null) => setArchive({
|
|
500
|
+
sessionID,
|
|
501
|
+
directory: directory ?? undefined,
|
|
502
|
+
archivedAt: 0,
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const scheduleCleanup = (sessionID, priorError = null) => {
|
|
506
|
+
if (stopped) return;
|
|
507
|
+
const id = asNonEmptyString(sessionID);
|
|
508
|
+
if (!id) return;
|
|
509
|
+
const existing = pendingCleanup.get(id) ?? { sessionID: id, attempts: 0 };
|
|
510
|
+
clearRepairTimer(existing);
|
|
511
|
+
existing.error = priorError || existing.error;
|
|
512
|
+
existing.failedAt = now();
|
|
513
|
+
pendingCleanup.set(id, existing);
|
|
514
|
+
|
|
515
|
+
if (existing.attempts >= CLEANUP_DELAYS_MS.length) {
|
|
516
|
+
console.warn(`[session-archive] metadata cleanup exhausted for ${id}: ${existing.error}`);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const delay = CLEANUP_DELAYS_MS[existing.attempts];
|
|
521
|
+
existing.attempts += 1;
|
|
522
|
+
existing.timer = setTimer(() => {
|
|
523
|
+
existing.timer = undefined;
|
|
524
|
+
return (async () => {
|
|
525
|
+
if (stopped) return;
|
|
526
|
+
const result = await runCleanupOnce(id);
|
|
527
|
+
if (result.ok) {
|
|
528
|
+
pendingCleanup.delete(id);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
existing.error = result.error;
|
|
532
|
+
existing.failedAt = now();
|
|
533
|
+
scheduleCleanup(id, result.error);
|
|
534
|
+
})();
|
|
535
|
+
}, delay);
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const runCleanupOnce = async (sessionID) => {
|
|
539
|
+
if (typeof sessionMetadataStore.removeSession !== 'function') {
|
|
540
|
+
return { ok: true, removed: false };
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
const removed = await sessionMetadataStore.removeSession(sessionID);
|
|
544
|
+
pendingIndexRepair.delete(sessionID);
|
|
545
|
+
// false = already absent → idempotent success
|
|
546
|
+
return { ok: true, removed: removed === true };
|
|
547
|
+
} catch (error) {
|
|
548
|
+
const message = error?.message ?? String(error);
|
|
549
|
+
console.warn('[session-archive] metadata cleanup write failed (retryable):', message);
|
|
550
|
+
return { ok: false, error: message, retryable: true };
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Idempotent Host metadata cleanup after authoritative session delete.
|
|
556
|
+
* Not-found is success. Write failures schedule bounded retry and return
|
|
557
|
+
* observable retryable status — HTTP delete success is not reversed.
|
|
558
|
+
*/
|
|
559
|
+
const forgetSession = async (sessionID) => {
|
|
560
|
+
const id = asNonEmptyString(sessionID);
|
|
561
|
+
if (!id) return { ok: true, removed: false };
|
|
562
|
+
// Supersede any in-flight index repair for this session (late GET must not write).
|
|
563
|
+
invalidateSessionRepairs(id, { clearPending: true });
|
|
564
|
+
const result = await runCleanupOnce(id);
|
|
565
|
+
if (result.ok) {
|
|
566
|
+
pendingCleanup.delete(id);
|
|
567
|
+
return result;
|
|
568
|
+
}
|
|
569
|
+
scheduleCleanup(id, result.error);
|
|
570
|
+
return { ok: false, removed: false, retryable: true, error: result.error };
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const getCleanupStatus = (sessionID) => {
|
|
574
|
+
const id = asNonEmptyString(sessionID);
|
|
575
|
+
if (!id) return null;
|
|
576
|
+
const pending = pendingCleanup.get(id);
|
|
577
|
+
if (!pending) return { ok: true, pending: false };
|
|
578
|
+
return {
|
|
579
|
+
ok: false,
|
|
580
|
+
pending: true,
|
|
581
|
+
retryable: pending.attempts < CLEANUP_DELAYS_MS.length,
|
|
582
|
+
attempts: pending.attempts,
|
|
583
|
+
error: pending.error,
|
|
584
|
+
failedAt: pending.failedAt,
|
|
585
|
+
};
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const stop = () => {
|
|
589
|
+
stopped = true;
|
|
590
|
+
// Bump every known session generation so late repair awaits refuse to write.
|
|
591
|
+
for (const id of new Set([
|
|
592
|
+
...operationGeneration.keys(),
|
|
593
|
+
...pendingIndexRepair.keys(),
|
|
594
|
+
...pendingCleanup.keys(),
|
|
595
|
+
])) {
|
|
596
|
+
bumpOperationGeneration(id);
|
|
597
|
+
}
|
|
598
|
+
for (const entry of pendingIndexRepair.values()) clearRepairTimer(entry);
|
|
599
|
+
for (const entry of pendingCleanup.values()) clearRepairTimer(entry);
|
|
600
|
+
pendingIndexRepair.clear();
|
|
601
|
+
pendingCleanup.clear();
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
return {
|
|
605
|
+
setArchive,
|
|
606
|
+
archiveSession,
|
|
607
|
+
unarchiveSession,
|
|
608
|
+
forgetSession,
|
|
609
|
+
retryIndexUpdate,
|
|
610
|
+
getIndexRepairStatus,
|
|
611
|
+
getCleanupStatus,
|
|
612
|
+
projectSession,
|
|
613
|
+
readHostArchivedAt: (metadata) => readHostArchivedAt(metadata),
|
|
614
|
+
isSessionArchived,
|
|
615
|
+
readHostMetadataSync,
|
|
616
|
+
stop,
|
|
617
|
+
};
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
export const createUpstreamSessionFetcher = ({
|
|
621
|
+
buildOpenCodeUrl,
|
|
622
|
+
getOpenCodeAuthHeaders,
|
|
623
|
+
fetchFn = globalThis.fetch,
|
|
624
|
+
}) => async ({ sessionID, directory = null }) => {
|
|
625
|
+
const id = asNonEmptyString(sessionID);
|
|
626
|
+
if (!id) return null;
|
|
627
|
+
const url = new URL(buildOpenCodeUrl(`/session/${encodeURIComponent(id)}`));
|
|
628
|
+
if (directory) url.searchParams.set('directory', directory);
|
|
629
|
+
const response = await fetchFn(url, {
|
|
630
|
+
method: 'GET',
|
|
631
|
+
headers: {
|
|
632
|
+
Accept: 'application/json',
|
|
633
|
+
...(typeof getOpenCodeAuthHeaders === 'function' ? getOpenCodeAuthHeaders() : {}),
|
|
634
|
+
},
|
|
635
|
+
});
|
|
636
|
+
if (response.status === 404) {
|
|
637
|
+
const error = new Error('session not found');
|
|
638
|
+
error.status = 404;
|
|
639
|
+
throw error;
|
|
640
|
+
}
|
|
641
|
+
if (!response.ok) {
|
|
642
|
+
const error = new Error(`OpenCode session.get failed (${response.status})`);
|
|
643
|
+
error.status = response.status;
|
|
644
|
+
throw error;
|
|
645
|
+
}
|
|
646
|
+
const payload = await response.json();
|
|
647
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
648
|
+
if (payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) {
|
|
649
|
+
return payload.data;
|
|
650
|
+
}
|
|
651
|
+
if (typeof payload.id === 'string') return payload;
|
|
652
|
+
}
|
|
653
|
+
return null;
|
|
654
|
+
};
|