@openchambery/web 2.0.0-beta.4 → 2.0.0-beta.5
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/dist/assets/{AgentAvatar-Q1SmFRx2.js → AgentAvatar-BBmQXYDq.js} +1 -1
- package/dist/assets/{AssistantView-CwXkiCAd.js → AssistantView-BG8hK4y9.js} +3 -3
- package/dist/assets/{DiagramView-BW92schO.js → DiagramView-CN0qWNU5.js} +1 -1
- package/dist/assets/{FadeInOnReveal-BZmwl-zE.js → FadeInOnReveal-w_BQ7K7K.js} +1 -1
- package/dist/assets/Icon-B9kZgYZn.js +1 -0
- package/dist/assets/{MarkdownRenderer-Bv05iDZZ.js → MarkdownRenderer-Ds61--LT.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-C81xqYib.js → MarkdownRendererImpl-DOMmiOH0.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-CXUCQuKJ.js → MarkstreamRendererImpl-F7MGg5ER.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-BMgIu7zc.js → MobileDetailNavigation-DcmVOjkw.js} +1 -1
- package/dist/assets/{MobileShareBridge-BOXGNHmC.js → MobileShareBridge-C1Fc4ErB.js} +2 -2
- package/dist/assets/MobileSurface-BA7eS5Yd.js +2 -0
- package/dist/assets/{MultiRunWindow-B5At2Z95.js → MultiRunWindow-BoTnH6Yp.js} +1 -1
- package/dist/assets/{OnboardingScreen-bsPLgz4r.js → OnboardingScreen-BxItVnnO.js} +1 -1
- package/dist/assets/{OverlayScrollbar-4wp4migh.js → OverlayScrollbar-B4e0QD05.js} +1 -1
- package/dist/assets/SettingsView-CjesWNLw.js +25 -0
- package/dist/assets/{SettingsWindow-CMtX2bMl.js → SettingsWindow-CReMbg-w.js} +1 -1
- package/dist/assets/TerminalView-C64-QYOG.js +8 -0
- package/dist/assets/{ToolOutputDialog-uH1ZZDSS.js → ToolOutputDialog-CJu8DNrt.js} +1 -1
- package/dist/assets/{appThemeRegistry-DK7q2juD.js → appThemeRegistry-CF4lIxNt.js} +1 -1
- package/dist/assets/{button-CcNJUxUd.js → button-CwXyux0a.js} +1 -1
- package/dist/assets/collapsible-BZ6_cYJd.js +1 -0
- package/dist/assets/context-menu-DHrRifPE.js +1 -0
- package/dist/assets/{desktopHosts-C6HrP0jq.js → desktopHosts-JGFqhlaM.js} +1 -1
- package/dist/assets/{desktopRelayRestore-DdVBp45U.js → desktopRelayRestore-CHEwrI93.js} +1 -1
- package/dist/assets/{diagram-CxTs9hCQ.js → diagram-BwnPNKWt.js} +1 -1
- package/dist/assets/{dialog-KMVL9QKF.js → dialog-Dp0lQv5K.js} +1 -1
- package/dist/assets/es-DicJ8lKD.js +15 -0
- package/dist/assets/{fileMentionTouchSelection-7RIwlpkB.js → fileMentionTouchSelection-DPZRxNa2.js} +3 -4
- package/dist/assets/{fileQueries-CjdBRy8y.js → fileQueries-DN6gbLhM.js} +1 -1
- package/dist/assets/fr-lhgVcBW_.js +13 -0
- package/dist/assets/{gitApi-yuWg9BN4.js → gitApi-D8R7ic-T.js} +1 -1
- package/dist/assets/{imageSource-9vQBq3zp.js → imageSource-BMztRu0c.js} +1 -1
- package/dist/assets/{input-CrIvk3HZ.js → input-Biw8aNzR.js} +1 -1
- package/dist/assets/insertionBoundaries-DdwCD4S2.js +1 -0
- package/dist/assets/ja-B9EZwOcw.js +15 -0
- package/dist/assets/ko-Dxk-uJRb.js +15 -0
- package/dist/assets/main-BhlwwJ2c.js +169 -0
- package/dist/assets/main-D2BzpnUt.css +1 -0
- package/dist/assets/main-DSPCkT74.js +2 -0
- package/dist/assets/{markdownHeightCache-BmXVY_gc.js → markdownHeightCache-BOOrGCuG.js} +1 -1
- package/dist/assets/markdownTheme-DM5h1i18.js +1 -0
- package/dist/assets/{mcpQueries-C7jknHV8.js → mcpQueries-CE9tk-Jz.js} +1 -1
- package/dist/assets/miniChat-Ckk9ABql.js +2 -0
- package/dist/assets/mobile-cLMRZqI9.js +2 -0
- package/dist/assets/multirun-DVnFRb1V.js +4 -0
- package/dist/assets/persistence-D0y7226-.js +3 -0
- package/dist/assets/pl-CFhwWLJj.js +15 -0
- package/dist/assets/platform-D_c-T5nO.js +6 -0
- package/dist/assets/projectMeta-CSQgHequ.js +3 -0
- package/dist/assets/pt-BR-Ba6GfZ6J.js +15 -0
- package/dist/assets/renderElectronMiniChatApp-gGFkwoL_.js +2 -0
- package/dist/assets/{renderMobileApp-CGMMqbsO.js → renderMobileApp-B2w50kAc.js} +7 -7
- package/dist/assets/{runtimeConfig-BEANkvOd.js → runtimeConfig-BHH1hJDd.js} +1 -1
- package/dist/assets/runtimeEndpointReset-5uJ4sJk0.js +149 -0
- package/dist/assets/{sessionChangeSummary-qOLrP9fw.js → sessionChangeSummary-spf_atO8.js} +2 -2
- package/dist/assets/{sessionLookup-34XFmP9C.js → sessionLookup-BGjjwiCq.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-DyEV0Heg.js → sortable-tabs-strip-CTY_wG46.js} +1 -1
- package/dist/assets/src-BjEyU6if.css +2 -0
- package/dist/assets/store-DmowK-GO.js +15 -0
- package/dist/assets/{terminalApi-CXaBEoa_.js → terminalApi-Dhqqp4xi.js} +1 -1
- package/dist/assets/{textarea-BBOlRI9O.js → textarea-QxsmcBGj.js} +1 -1
- package/dist/assets/{tooltip-CPI4fr1a.js → tooltip-CpMLl5_k.js} +1 -1
- package/dist/assets/uk-ChchBzy6.js +15 -0
- package/dist/assets/{url-DRDQXjyX.js → url-CGbJbiJq.js} +1 -1
- package/dist/assets/{useAppFontEffects-CiDe42I5.js → useAppFontEffects-B1h7ab_J.js} +56 -51
- package/dist/assets/{useAssistantUIStore-asNG6K7n.js → useAssistantUIStore-Bz4tRflF.js} +1 -1
- package/dist/assets/useDesktopWindowControlsLayout-Cxsx84NO.js +1 -0
- package/dist/assets/{useEffectiveDirectory-C_CouL5v.js → useEffectiveDirectory-DsqhqsJR.js} +1 -1
- package/dist/assets/{useFontPreferences-B0oUZAiB.js → useFontPreferences-Xw1vSkmC.js} +1 -1
- package/dist/assets/useMobileNavigationStore-B1axS4g-.js +414 -0
- package/dist/assets/{useRuntimeAPIs-DwLtO0c_.js → useRuntimeAPIs-DSLXJybW.js} +1 -1
- package/dist/assets/useSessionAutoCleanup-B5FTG_qf.js +2 -0
- package/dist/assets/{useThemeSystem-BlNUWH47.js → useThemeSystem-DMzWVJaW.js} +1 -1
- package/dist/assets/useUIStore-syVmbuDA.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-CPNYz6Wf.js → useWorkerHighlightedLines-DC_Bc-RV.js} +1 -1
- package/dist/assets/{vendor-base-ui-CDjyofeD.js → vendor-base-ui-CnHfUmDN.js} +1 -1
- package/dist/assets/{vendor-cmdk-BFH0UpXX.js → vendor-cmdk-CeAGfrLo.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-accessibility-BrR3AKvM.js → vendor-dnd-kit-accessibility-BY5LhhUB.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-core-CNfdvk6H.js → vendor-dnd-kit-core-CvWxUPdb.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-sortable-DQUaTeGj.js → vendor-dnd-kit-sortable-hHdP9lLb.js} +1 -1
- package/dist/assets/{vendor-framer-motion-4dogsUk3.js → vendor-framer-motion-DFy_BSVd.js} +1 -1
- package/dist/assets/{vendor-legendapp-list-BU7MdPZR.js → vendor-legendapp-list-WE32lwNk.js} +1 -1
- package/dist/assets/{vendor-markstream-react-qHC2FykU.js → vendor-markstream-react-DX55XLGO.js} +3 -3
- package/dist/assets/vendor-opencode-client-C12gfjag.js +12 -0
- package/dist/assets/{vendor-pierre-diffs-CUfqC003.js → vendor-pierre-diffs-DX7XTPG3.js} +1 -1
- package/dist/assets/{vendor-react-C94A0AHl.js → vendor-react-DLPElpoQ.js} +1 -1
- package/dist/assets/{vendor-react-drawio-FVlWqKVE.js → vendor-react-drawio-ECtEwvbc.js} +1 -1
- package/dist/assets/{vendor-reactuses-core-C2A3EjUi.js → vendor-reactuses-core-B8parU1Y.js} +1 -1
- package/dist/assets/{vendor-sonner-DNqHmUQs.js → vendor-sonner-DVzioF3d.js} +1 -1
- package/dist/assets/{vendor-stream-diffs-CwbKsmlv.js → vendor-stream-diffs-DWy3xSmp.js} +2 -2
- package/dist/assets/{vendor-tanstack-react-query-D0iFK57C.js → vendor-tanstack-react-query-D7pys_c9.js} +1 -1
- package/dist/assets/{vendor-tanstack-react-virtual-B2kV6cnT.js → vendor-tanstack-react-virtual-tlDgH6PG.js} +1 -1
- package/dist/assets/{vendor-zustand-BqLKf6ZY.js → vendor-zustand-B4dlGEM2.js} +1 -1
- package/dist/assets/zh-CN-Z_bkUQk5.js +15 -0
- package/dist/assets/zh-TW-CZeTm7kd.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 +3 -2
- package/server/index.js +38 -7
- 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/notifications/DOCUMENTATION.md +3 -1
- package/server/lib/notifications/runtime.js +110 -26
- package/server/lib/notifications/runtime.test.js +92 -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 +4 -0
- package/server/lib/opencode/ensure-cli.d.ts +8 -0
- package/server/lib/opencode/feature-routes-runtime.js +10 -0
- package/server/lib/opencode/feature-routes-runtime.test.js +7 -0
- package/server/lib/opencode/proxy.js +3 -1
- package/server/lib/opencode/proxy.test.js +3 -3
- package/server/lib/opencode/routes.js +30 -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/session-metadata/DOCUMENTATION.md +27 -17
- package/server/lib/session-metadata/opencode-session-record.d.ts +57 -0
- package/server/lib/session-metadata/opencode-session-record.js +316 -0
- package/server/lib/session-metadata/opencode-session-record.test.js +242 -0
- package/server/lib/session-metadata/routes.js +5 -1
- package/server/lib/session-metadata/session-archive.js +1 -1
- package/server/lib/session-metadata/session-metadata-store.d.ts +2 -0
- package/server/lib/session-metadata/session-metadata-store.js +63 -16
- package/server/lib/session-metadata/session-projection.js +26 -2
- package/dist/assets/Icon-AQPvxMiU.js +0 -1
- package/dist/assets/MobileSurface-xuc_z0_G.js +0 -2
- package/dist/assets/SettingsView-CJ6tcH5_.js +0 -25
- package/dist/assets/TerminalView-Dvr-ysTS.js +0 -8
- package/dist/assets/collapsible-ZmZ6rLWs.js +0 -3
- package/dist/assets/context-menu-B2LXJgXL.js +0 -1
- package/dist/assets/es-CN3E_zXj.js +0 -15
- package/dist/assets/fr-9zZvQZD2.js +0 -13
- package/dist/assets/insertionBoundaries-BiUCSkx-.js +0 -1
- package/dist/assets/ja-Dnjk_82N.js +0 -15
- package/dist/assets/ko-BxCEE99C.js +0 -15
- package/dist/assets/main-C9mIBo77.js +0 -2
- package/dist/assets/main-DiL6w6KF.js +0 -169
- package/dist/assets/main-qSeeJRW1.css +0 -1
- package/dist/assets/markdownTheme-CKac3wKC.js +0 -1
- package/dist/assets/miniChat-CwtUaElU.js +0 -2
- package/dist/assets/mobile-DB-6--ws.js +0 -2
- package/dist/assets/multirun-TAsxFI8e.js +0 -4
- package/dist/assets/persistence-56kq8Z4V.js +0 -3
- package/dist/assets/pl-CNNsWnPM.js +0 -15
- package/dist/assets/platform-C13fMVEs.js +0 -6
- package/dist/assets/pt-BR-BZB2DJMp.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-CGj0MNRU.js +0 -2
- package/dist/assets/runtimeEndpointReset-A_ZBUJFS.js +0 -147
- package/dist/assets/src-ubCWXZhR.css +0 -2
- package/dist/assets/store-DLCiZaA6.js +0 -15
- package/dist/assets/uk-rwQrxeYM.js +0 -15
- package/dist/assets/useDesktopWindowControlsLayout-BlizAlOt.js +0 -1
- package/dist/assets/useMobileNavigationStore-DfPClaW-.js +0 -414
- package/dist/assets/useSessionAutoCleanup-DtrfbxAp.js +0 -2
- package/dist/assets/useUIStore-CQYKJnrg.js +0 -1
- package/dist/assets/vendor-opencode-client-BOe2qpQb.js +0 -12
- package/dist/assets/zh-CN-BvmBDnkD.js +0 -15
- package/dist/assets/zh-TW-9wcHTX0y.js +0 -15
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
REQUIRED_OPENCODE_VERSION,
|
|
6
|
+
describeUpgradeScreen,
|
|
7
|
+
evaluateUpgradeScreenInstallResult,
|
|
8
|
+
installRequiredOpenCode,
|
|
9
|
+
registerUpgradeScreenRoutes,
|
|
10
|
+
resolveUpgradeScreenTarget,
|
|
11
|
+
} from './upgrade-screen.js';
|
|
12
|
+
|
|
13
|
+
describe('upgrade screen version gate', () => {
|
|
14
|
+
it('uses 2.0.15 even when the runtime pin is older', () => {
|
|
15
|
+
expect(REQUIRED_OPENCODE_VERSION).toBe('2.0.15');
|
|
16
|
+
expect(resolveUpgradeScreenTarget('2.0.12')).toBe('2.0.15');
|
|
17
|
+
expect(resolveUpgradeScreenTarget('2.0.16')).toBe('2.0.16');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('does not open the gate at or above 2.0.15', () => {
|
|
21
|
+
expect(describeUpgradeScreen({
|
|
22
|
+
version: '2.0.15',
|
|
23
|
+
installation: 'managed',
|
|
24
|
+
platformCanInstall: true,
|
|
25
|
+
})).toMatchObject({ state: 'compatible', canInstall: false, minimumVersion: '2.0.15' });
|
|
26
|
+
expect(describeUpgradeScreen({
|
|
27
|
+
version: 'v2.0.16',
|
|
28
|
+
installation: 'managed',
|
|
29
|
+
platformCanInstall: true,
|
|
30
|
+
}).state).toBe('compatible');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('opens the gate below 2.0.15 and offers install only when this runtime can switch', () => {
|
|
34
|
+
expect(describeUpgradeScreen({
|
|
35
|
+
version: '2.0.14',
|
|
36
|
+
installation: 'managed',
|
|
37
|
+
platformCanInstall: true,
|
|
38
|
+
})).toMatchObject({ state: 'incompatible', canInstall: true, version: '2.0.14' });
|
|
39
|
+
expect(describeUpgradeScreen({
|
|
40
|
+
version: '1.18.30',
|
|
41
|
+
installation: 'managed',
|
|
42
|
+
platformCanInstall: true,
|
|
43
|
+
})).toMatchObject({ state: 'incompatible', canInstall: true });
|
|
44
|
+
expect(describeUpgradeScreen({
|
|
45
|
+
version: '2.0.14',
|
|
46
|
+
installation: 'external',
|
|
47
|
+
platformCanInstall: true,
|
|
48
|
+
}).canInstall).toBe(false);
|
|
49
|
+
expect(describeUpgradeScreen({
|
|
50
|
+
version: '2.0.14',
|
|
51
|
+
installation: 'bundled',
|
|
52
|
+
platformCanInstall: true,
|
|
53
|
+
})).toMatchObject({ state: 'incompatible', canInstall: true, installation: 'bundled' });
|
|
54
|
+
expect(describeUpgradeScreen({
|
|
55
|
+
version: '2.0.14',
|
|
56
|
+
installation: 'managed',
|
|
57
|
+
platformCanInstall: false,
|
|
58
|
+
}).canInstall).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('does not treat a missing version as an old install', () => {
|
|
62
|
+
expect(describeUpgradeScreen({
|
|
63
|
+
version: null,
|
|
64
|
+
installation: 'managed',
|
|
65
|
+
platformCanInstall: true,
|
|
66
|
+
}).state).toBe('unavailable');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('does not report an unverified install as upgraded', () => {
|
|
70
|
+
expect(evaluateUpgradeScreenInstallResult({
|
|
71
|
+
targetVersion: '2.0.15',
|
|
72
|
+
serveVersion: '2.0.14',
|
|
73
|
+
})).toMatchObject({ ok: false, upgraded: false, version: '2.0.14' });
|
|
74
|
+
expect(evaluateUpgradeScreenInstallResult({
|
|
75
|
+
targetVersion: '2.0.15',
|
|
76
|
+
serveVersion: null,
|
|
77
|
+
}).upgraded).toBe(false);
|
|
78
|
+
expect(evaluateUpgradeScreenInstallResult({
|
|
79
|
+
targetVersion: '2.0.15',
|
|
80
|
+
serveVersion: '2.0.15',
|
|
81
|
+
})).toMatchObject({ ok: true, upgraded: true, version: '2.0.15' });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('installRequiredOpenCode', () => {
|
|
86
|
+
const createDeps = (overrides = {}) => {
|
|
87
|
+
const calls = [];
|
|
88
|
+
const deps = {
|
|
89
|
+
resolveOwnership: async () => ({ ownership: 'global-cli', guidance: null }),
|
|
90
|
+
readServeVersion: async () => '2.0.14',
|
|
91
|
+
readCliVersion: () => '2.0.14',
|
|
92
|
+
platformCanInstall: true,
|
|
93
|
+
install: vi.fn(async () => {
|
|
94
|
+
calls.push('install');
|
|
95
|
+
return '/cache/opencode';
|
|
96
|
+
}),
|
|
97
|
+
readBinaryVersion: vi.fn(() => '2.0.15'),
|
|
98
|
+
persistBinary: vi.fn(async () => {
|
|
99
|
+
calls.push('persist');
|
|
100
|
+
}),
|
|
101
|
+
forceBinary: vi.fn(() => {
|
|
102
|
+
calls.push('force');
|
|
103
|
+
}),
|
|
104
|
+
restart: vi.fn(async () => {
|
|
105
|
+
calls.push('restart');
|
|
106
|
+
}),
|
|
107
|
+
waitReady: vi.fn(async () => {
|
|
108
|
+
calls.push('ready');
|
|
109
|
+
}),
|
|
110
|
+
readConfiguredBinary: async () => null,
|
|
111
|
+
isSharedService: () => false,
|
|
112
|
+
...overrides,
|
|
113
|
+
};
|
|
114
|
+
return { deps, calls };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
it('installs 2.0.15 and only succeeds after the running serve is verified', async () => {
|
|
118
|
+
const { deps, calls } = createDeps({
|
|
119
|
+
readServeVersion: vi.fn()
|
|
120
|
+
.mockResolvedValueOnce('2.0.14')
|
|
121
|
+
.mockResolvedValueOnce('2.0.15'),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await expect(installRequiredOpenCode(deps)).resolves.toMatchObject({
|
|
125
|
+
ok: true,
|
|
126
|
+
upgraded: true,
|
|
127
|
+
version: '2.0.15',
|
|
128
|
+
targetVersion: '2.0.15',
|
|
129
|
+
});
|
|
130
|
+
expect(deps.install).toHaveBeenCalledWith({ version: '2.0.15' });
|
|
131
|
+
expect(calls).toEqual(['install', 'persist', 'force', 'restart', 'ready']);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('returns the failure reason and does not claim an upgrade when verification fails', async () => {
|
|
135
|
+
const { deps } = createDeps({
|
|
136
|
+
readServeVersion: vi.fn()
|
|
137
|
+
.mockResolvedValueOnce('1.18.30')
|
|
138
|
+
.mockResolvedValueOnce('1.18.30'),
|
|
139
|
+
readCliVersion: () => '1.18.30',
|
|
140
|
+
readBinaryVersion: () => '2.0.15',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await expect(installRequiredOpenCode(deps)).rejects.toThrow(/still 1\.18\.30/);
|
|
144
|
+
expect(deps.persistBinary).toHaveBeenCalledWith('');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('does not install when the runtime cannot switch', async () => {
|
|
148
|
+
const { deps } = createDeps({
|
|
149
|
+
resolveOwnership: async () => ({
|
|
150
|
+
ownership: 'external-serve',
|
|
151
|
+
guidance: 'Restart that server yourself.',
|
|
152
|
+
}),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await expect(installRequiredOpenCode(deps)).rejects.toMatchObject({
|
|
156
|
+
status: 409,
|
|
157
|
+
message: 'Restart that server yourself.',
|
|
158
|
+
});
|
|
159
|
+
expect(deps.install).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('upgrade screen routes', () => {
|
|
164
|
+
it('hides the gate for 2.0.15 and reports install failure without upgraded:true', async () => {
|
|
165
|
+
const app = express();
|
|
166
|
+
app.use(express.json());
|
|
167
|
+
const readServeVersion = vi.fn()
|
|
168
|
+
.mockResolvedValueOnce('2.0.15')
|
|
169
|
+
.mockResolvedValueOnce('2.0.12')
|
|
170
|
+
.mockResolvedValueOnce('2.0.12');
|
|
171
|
+
registerUpgradeScreenRoutes(app, {
|
|
172
|
+
resolveOwnership: async () => ({ ownership: 'owned-cache', guidance: null }),
|
|
173
|
+
readServeVersion,
|
|
174
|
+
readCliVersion: () => '2.0.12',
|
|
175
|
+
platformCanInstall: true,
|
|
176
|
+
install: vi.fn(async () => {
|
|
177
|
+
throw new Error('registry unreachable');
|
|
178
|
+
}),
|
|
179
|
+
readBinaryVersion: () => '2.0.15',
|
|
180
|
+
persistBinary: vi.fn(),
|
|
181
|
+
forceBinary: vi.fn(),
|
|
182
|
+
restart: vi.fn(),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const compatible = await request(app).get('/api/opencode/compatibility').expect(200);
|
|
186
|
+
expect(compatible.body).toMatchObject({ state: 'compatible', version: '2.0.15', minimumVersion: '2.0.15' });
|
|
187
|
+
|
|
188
|
+
const failed = await request(app).post('/api/opencode/install-required').expect(500);
|
|
189
|
+
expect(failed.body).toMatchObject({
|
|
190
|
+
success: false,
|
|
191
|
+
upgraded: false,
|
|
192
|
+
error: 'registry unreachable',
|
|
193
|
+
});
|
|
194
|
+
expect(failed.body.upgraded).not.toBe(true);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -1,32 +1,42 @@
|
|
|
1
1
|
# Session metadata store
|
|
2
2
|
|
|
3
|
-
OpenChamber
|
|
4
|
-
|
|
3
|
+
OpenChamber session metadata whose durable copy lives on the OpenCode session
|
|
4
|
+
record. The side JSON file is the cache and the pre-migration source.
|
|
5
5
|
|
|
6
6
|
## Why
|
|
7
7
|
|
|
8
|
-
Session goal progress, Host archive stamps, and
|
|
9
|
-
`openchamber` namespace
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
Session goal progress, Host archive stamps, titles, and other Host state under
|
|
9
|
+
the `openchamber` namespace travel with the OpenCode session. `session.update`
|
|
10
|
+
(`PATCH /api/session/:id`, body `{ title?, metadata?, permissions? }`) replaces
|
|
11
|
+
`metadata` wholesale. Archive is `metadata.openchamber.archive.archivedAt` on
|
|
12
|
+
that record and is still projected onto `time.archived`. Title is the record's
|
|
13
|
+
`title` field and is not sent when only metadata changes.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
`opencode-session-record.js` is the only module that knows that call. A client
|
|
16
|
+
whose types omit `session.update` metadata still goes through that module;
|
|
17
|
+
a missing API throws and is never an empty write.
|
|
18
|
+
|
|
19
|
+
The side file remains until each row has been copied or OpenCode has confirmed
|
|
20
|
+
the session is gone. Until that load succeeds, the file is not an authoritative
|
|
21
|
+
empty catalog: a failed read stays unwritable and does not clear other sessions.
|
|
22
|
+
A session OpenCode still has is listed even when the side file has no row.
|
|
23
|
+
One failed copy does not delete the other rows or the other OpenCode sessions.
|
|
24
|
+
|
|
25
|
+
Fork-specific fields (`goal`, `assistant`, `scheduledTask`, `llm`, `smallModel`,
|
|
26
|
+
and the rest of `openchamber`) are merged onto the record. They are not dropped
|
|
27
|
+
to match a narrower upstream metadata shape.
|
|
16
28
|
|
|
17
29
|
| OpenCode client | Session metadata update |
|
|
18
30
|
|---|---|
|
|
19
31
|
| Earlier 2.x releases used during the migration | `metadata` accepted at create time only |
|
|
20
|
-
| 2.0.14 | `PATCH /api/session/:id` with body `{ title, metadata, permissions }` (`session.update`) |
|
|
21
|
-
|
|
22
|
-
Adopting the upstream update API for Host-owned fields requires a separate
|
|
23
|
-
decision about authority, conditional writes, and archive protection.
|
|
32
|
+
| 2.0.14+ / 2.0.15 | `PATCH /api/session/:id` with body `{ title, metadata, permissions }` (`session.update`) |
|
|
24
33
|
|
|
25
34
|
## Layout
|
|
26
35
|
|
|
27
36
|
| File | Role |
|
|
28
37
|
|---|---|
|
|
29
|
-
| `session-
|
|
38
|
+
| `opencode-session-record.js` | Read/write title and metadata on the OpenCode session record; migrate the side file without treating a failed load as empty or dropping other sessions |
|
|
39
|
+
| `session-metadata-store.js` | Side-file cache and migration source: `{ [sessionID]: metadata }`, RFC 7386 merge patch, full RMW serialization, refuse-failed-read refuses write/read-as-empty. When a record reader/writer is wired, commits the merged object onto the OpenCode session first |
|
|
30
40
|
| `session-projection.js` | Pure Host authority projection (metadata deep-merge + archive → `time.archived`) |
|
|
31
41
|
| `session-archive.js` | Domain archive/unarchive: validate upstream session + directory, persist, project, best-effort index/broadcast |
|
|
32
42
|
| `system-session.js` | Isolation patches for system session creators: `buildScheduledTaskMetadata`, `buildLlmSessionMetadata`, `buildAssistantSessionMetadata`. Title prefixes are labels and never ownership. |
|
|
@@ -59,19 +69,19 @@ decision about authority, conditional writes, and archive protection.
|
|
|
59
69
|
|
|
60
70
|
Wired from `packages/web/server/index.js` + `feature-routes-runtime.js`:
|
|
61
71
|
|
|
62
|
-
1. `createSessionMetadataStore({ dataDir
|
|
72
|
+
1. `createHttpSessionRecordClient` + `createSessionMetadataStore({ dataDir, recordReader, recordWriter })` + initial `load()`, then `startSideStoreMigration`
|
|
63
73
|
2. proxy `getStoredSessionMetadata` / `getStoredSessionMetadataSync` → list/get + direct SSE overlay
|
|
64
74
|
3. global hub `projectOutboundSessionPayload` → live + replay session lifecycle authority
|
|
65
75
|
4. session-index sync `projectSessions` + event-ingest `projectSession` / `onSessionDeleted`
|
|
66
76
|
5. `createSessionArchiveService` after session index exists
|
|
67
77
|
6. session-goal `readSessionMetadata` / `mutateSessionMetadata` / `persistSessionGoal`
|
|
68
78
|
7. `registerSessionMetadataRoutes` (metadata + archive)
|
|
69
|
-
8. assistants `archiveSessionHost` → shared Host archive
|
|
79
|
+
8. assistants `archiveSessionHost` → shared Host archive, which write-throughs `metadata` onto the OpenCode session record without clearing `title`
|
|
70
80
|
9. system session creators persist isolation through `persistSessionMetadata`
|
|
71
81
|
(`setSessionMetadata` + `openchamber:session-metadata` broadcast) and then
|
|
72
82
|
`onSystemSessionPersisted` (index upsert that removes the row). Wired from
|
|
73
83
|
`index.js` into scheduled-task runtime, LLM attachment generate, and
|
|
74
|
-
Assistant binding create.
|
|
84
|
+
Assistant binding create. Create-time `metadata` is merged onto the session record; it is not a second authority.
|
|
75
85
|
|
|
76
86
|
Capability is open (`supported: true`). Manual UI metadata writes, session-goal
|
|
77
87
|
routes, and scheduled-task goal create all persist `openchamber.goal` through
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export function mergeOwnedMetadata(
|
|
2
|
+
openCodeMetadata: unknown,
|
|
3
|
+
sideMetadata: unknown,
|
|
4
|
+
): Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
export function writeSessionRecordViaClient(
|
|
7
|
+
client: { session?: { update?: (input: unknown, requestOptions?: unknown) => Promise<unknown> } } | null | undefined,
|
|
8
|
+
input: {
|
|
9
|
+
sessionID: string;
|
|
10
|
+
metadata?: Record<string, unknown>;
|
|
11
|
+
title?: string;
|
|
12
|
+
requestOptions?: object;
|
|
13
|
+
},
|
|
14
|
+
): Promise<void>;
|
|
15
|
+
|
|
16
|
+
export function readSessionRecordViaClient(
|
|
17
|
+
client: { session?: { get?: (input: unknown, requestOptions?: unknown) => Promise<unknown> } } | null | undefined,
|
|
18
|
+
input: { sessionID: string; requestOptions?: object },
|
|
19
|
+
): Promise<Record<string, unknown> | null>;
|
|
20
|
+
|
|
21
|
+
export function createHttpSessionRecordClient(options: {
|
|
22
|
+
buildSessionUrl: (sessionID: string, directory?: string | null) => string;
|
|
23
|
+
getHeaders?: () => Record<string, string>;
|
|
24
|
+
fetchFn?: typeof fetch;
|
|
25
|
+
}): {
|
|
26
|
+
read: (sessionID: string, directory?: string | null) => Promise<Record<string, unknown> | null>;
|
|
27
|
+
write: (input: {
|
|
28
|
+
sessionID: string;
|
|
29
|
+
metadata?: Record<string, unknown>;
|
|
30
|
+
title?: string;
|
|
31
|
+
directory?: string | null;
|
|
32
|
+
}) => Promise<void>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function migrateLoadedSideStore(input: {
|
|
36
|
+
loadResult: { ok?: boolean; entries?: Record<string, object> | null } | null;
|
|
37
|
+
readSession: (sessionID: string) => Promise<object | null>;
|
|
38
|
+
writeMetadata: (sessionID: string, metadata: object, record: object) => Promise<void>;
|
|
39
|
+
}): Promise<{
|
|
40
|
+
status: 'unavailable' | 'partial' | 'complete';
|
|
41
|
+
migrated: string[];
|
|
42
|
+
failed: Array<{ id: string; error: string }>;
|
|
43
|
+
absent: string[];
|
|
44
|
+
}>;
|
|
45
|
+
|
|
46
|
+
export function retainOpenCodeSessions(
|
|
47
|
+
openCodeSessions: unknown,
|
|
48
|
+
sideById: Record<string, object> | null | undefined,
|
|
49
|
+
): unknown[];
|
|
50
|
+
|
|
51
|
+
export function startSideStoreMigration(options: {
|
|
52
|
+
load: () => Promise<{ ok?: boolean; entries?: Record<string, object> }>;
|
|
53
|
+
readSession: (sessionID: string) => Promise<object | null>;
|
|
54
|
+
writeMetadata: (sessionID: string, metadata: object, record: object) => Promise<void>;
|
|
55
|
+
setTimer?: typeof setTimeout;
|
|
56
|
+
clearTimer?: typeof clearTimeout;
|
|
57
|
+
}): () => void;
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode session-record ownership for OpenChamber metadata.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode 2.0.15 `PATCH /api/session/:id` (`session.update`) replaces
|
|
5
|
+
* `metadata` wholesale and can set `title`. Archive stays in
|
|
6
|
+
* `metadata.openchamber.archive` on that same record, then projects onto
|
|
7
|
+
* `time.archived`. This module is the only place that knows that call.
|
|
8
|
+
*
|
|
9
|
+
* A client whose types do not yet expose `session.update` metadata still goes
|
|
10
|
+
* through `writeSessionRecordViaClient` / `createHttpSessionRecordClient`.
|
|
11
|
+
* Missing the API throws — it is never an empty success.
|
|
12
|
+
*
|
|
13
|
+
* The side store is a migration source. A failed load is not an empty catalog.
|
|
14
|
+
* One session's migration failure does not delete other sessions or their rows.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mergeOwnedMetadata, projectSessionWithStoredMap } from './session-projection.js';
|
|
18
|
+
|
|
19
|
+
export { mergeOwnedMetadata };
|
|
20
|
+
|
|
21
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
22
|
+
|
|
23
|
+
const asNonEmptyString = (value) => {
|
|
24
|
+
if (typeof value !== 'string') return null;
|
|
25
|
+
const trimmed = value.trim();
|
|
26
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const isSessionNotFoundError = (error) => {
|
|
30
|
+
const status = Number(error?.status ?? error?.statusCode);
|
|
31
|
+
if (status === 404) return true;
|
|
32
|
+
const tag = error?._tag || error?.name;
|
|
33
|
+
return tag === 'SessionNotFoundError' || tag === 'Session.NotFoundError';
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const deepEqual = (left, right) => {
|
|
37
|
+
if (left === right) return true;
|
|
38
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
39
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
|
40
|
+
return left.every((item, index) => deepEqual(item, right[index]));
|
|
41
|
+
}
|
|
42
|
+
if (!isPlainObject(left) || !isPlainObject(right)) return false;
|
|
43
|
+
const leftKeys = Object.keys(left);
|
|
44
|
+
const rightKeys = Object.keys(right);
|
|
45
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
46
|
+
return leftKeys.every((key) => deepEqual(left[key], right[key]));
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const unwrapSession = (payload) => {
|
|
50
|
+
if (!isPlainObject(payload)) return null;
|
|
51
|
+
if (isPlainObject(payload.data) && typeof payload.data.id === 'string') return payload.data;
|
|
52
|
+
if (typeof payload.id === 'string') return payload;
|
|
53
|
+
return null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Structural adapter for `@opencode/client` session.update.
|
|
58
|
+
* Throws when the method is absent — never pretends the write landed.
|
|
59
|
+
*
|
|
60
|
+
* @param {{ session?: { update?: Function } } | null | undefined} client
|
|
61
|
+
* @param {{ sessionID: string, metadata?: object, title?: string, requestOptions?: object }} input
|
|
62
|
+
*/
|
|
63
|
+
export const writeSessionRecordViaClient = async (client, input) => {
|
|
64
|
+
const update = client?.session?.update;
|
|
65
|
+
if (typeof update !== 'function') {
|
|
66
|
+
const error = new Error('session.update is unavailable');
|
|
67
|
+
error.code = 'session_update_unavailable';
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
const sessionID = asNonEmptyString(input?.sessionID);
|
|
71
|
+
if (!sessionID) throw new Error('a session id is required to update the OpenCode session record');
|
|
72
|
+
const body = { sessionID };
|
|
73
|
+
if (input.metadata !== undefined) body.metadata = input.metadata;
|
|
74
|
+
if (input.title !== undefined) body.title = input.title;
|
|
75
|
+
await update(body, input.requestOptions);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {{ session?: { get?: Function } } | null | undefined} client
|
|
80
|
+
* @param {{ sessionID: string, requestOptions?: object }} input
|
|
81
|
+
* @returns {Promise<object | null>} null only when OpenCode confirms the session is gone
|
|
82
|
+
*/
|
|
83
|
+
export const readSessionRecordViaClient = async (client, input) => {
|
|
84
|
+
const get = client?.session?.get;
|
|
85
|
+
if (typeof get !== 'function') {
|
|
86
|
+
const error = new Error('session.get is unavailable');
|
|
87
|
+
error.code = 'session_get_unavailable';
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
const sessionID = asNonEmptyString(input?.sessionID);
|
|
91
|
+
if (!sessionID) throw new Error('a session id is required to read the OpenCode session record');
|
|
92
|
+
try {
|
|
93
|
+
const session = await get({ sessionID }, input.requestOptions);
|
|
94
|
+
return unwrapSession(session) || (isPlainObject(session) ? session : null);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (isSessionNotFoundError(error)) return null;
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* HTTP owner of `GET` / `PATCH /api/session/:id`. Callers supply the URL so
|
|
103
|
+
* web and VS Code keep their own prefix rules.
|
|
104
|
+
*
|
|
105
|
+
* @param {{
|
|
106
|
+
* buildSessionUrl: (sessionID: string, directory?: string | null) => string,
|
|
107
|
+
* getHeaders?: () => Record<string, string>,
|
|
108
|
+
* fetchFn?: typeof fetch,
|
|
109
|
+
* }} options
|
|
110
|
+
*/
|
|
111
|
+
export const createHttpSessionRecordClient = ({
|
|
112
|
+
buildSessionUrl,
|
|
113
|
+
getHeaders = () => ({}),
|
|
114
|
+
fetchFn = null,
|
|
115
|
+
}) => {
|
|
116
|
+
if (typeof buildSessionUrl !== 'function') {
|
|
117
|
+
throw new Error('createHttpSessionRecordClient requires buildSessionUrl');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const request = async (sessionID, { method, directory, body }) => {
|
|
121
|
+
const fetchImpl = fetchFn || globalThis.fetch;
|
|
122
|
+
const id = asNonEmptyString(sessionID);
|
|
123
|
+
if (!id) throw new Error('a session id is required');
|
|
124
|
+
const url = buildSessionUrl(id, directory);
|
|
125
|
+
const response = await fetchImpl(url, {
|
|
126
|
+
method,
|
|
127
|
+
headers: {
|
|
128
|
+
Accept: 'application/json',
|
|
129
|
+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
130
|
+
...(typeof getHeaders === 'function' ? getHeaders() : {}),
|
|
131
|
+
},
|
|
132
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
133
|
+
});
|
|
134
|
+
return response;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const read = async (sessionID, directory) => {
|
|
138
|
+
const response = await request(sessionID, { method: 'GET', directory });
|
|
139
|
+
if (response.status === 404) return null;
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
const error = new Error(`OpenCode session.get failed (${response.status})`);
|
|
142
|
+
error.status = response.status;
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
const payload = await response.json().catch(() => null);
|
|
146
|
+
const session = unwrapSession(payload);
|
|
147
|
+
if (!session) {
|
|
148
|
+
const error = new Error('OpenCode session.get returned an unreadable record');
|
|
149
|
+
error.status = 502;
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
return session;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const write = async ({ sessionID, directory, metadata, title } = {}) => {
|
|
156
|
+
const body = {};
|
|
157
|
+
if (metadata !== undefined) body.metadata = metadata;
|
|
158
|
+
if (title !== undefined) body.title = title;
|
|
159
|
+
if (Object.keys(body).length === 0) {
|
|
160
|
+
throw new Error('session record write requires metadata or title');
|
|
161
|
+
}
|
|
162
|
+
const response = await request(sessionID, { method: 'PATCH', directory, body });
|
|
163
|
+
if (response.status === 404) {
|
|
164
|
+
const error = new Error(`session ${sessionID} was not found`);
|
|
165
|
+
error.status = 404;
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
const error = new Error(`OpenCode session.update failed (${response.status})`);
|
|
170
|
+
error.status = response.status;
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return { read, write };
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Copy side-store rows onto OpenCode records. Refuses a failed load instead of
|
|
180
|
+
* treating it as an empty catalog. A failed session is reported and skipped;
|
|
181
|
+
* other sessions, including ones OpenCode has and the side store does not, are
|
|
182
|
+
* left in place.
|
|
183
|
+
*
|
|
184
|
+
* @param {{
|
|
185
|
+
* loadResult: { ok?: boolean, entries?: Record<string, object> | null } | null,
|
|
186
|
+
* readSession: (sessionID: string) => Promise<object | null>,
|
|
187
|
+
* writeMetadata: (sessionID: string, metadata: object, record: object) => Promise<void>,
|
|
188
|
+
* }} input
|
|
189
|
+
*/
|
|
190
|
+
export const migrateLoadedSideStore = async ({ loadResult, readSession, writeMetadata }) => {
|
|
191
|
+
if (!loadResult || loadResult.ok !== true || !isPlainObject(loadResult.entries)) {
|
|
192
|
+
return { status: 'unavailable', migrated: [], failed: [], absent: [] };
|
|
193
|
+
}
|
|
194
|
+
if (typeof readSession !== 'function' || typeof writeMetadata !== 'function') {
|
|
195
|
+
throw new Error('side-store migration requires readSession and writeMetadata');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const migrated = [];
|
|
199
|
+
const failed = [];
|
|
200
|
+
const absent = [];
|
|
201
|
+
|
|
202
|
+
for (const [sessionID, sideMetadata] of Object.entries(loadResult.entries)) {
|
|
203
|
+
if (!isPlainObject(sideMetadata)) continue;
|
|
204
|
+
try {
|
|
205
|
+
const record = await readSession(sessionID);
|
|
206
|
+
if (record == null) {
|
|
207
|
+
absent.push(sessionID);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const merged = mergeOwnedMetadata(record.metadata, sideMetadata);
|
|
211
|
+
if (!deepEqual(merged, isPlainObject(record.metadata) ? record.metadata : {})) {
|
|
212
|
+
await writeMetadata(sessionID, merged, record);
|
|
213
|
+
}
|
|
214
|
+
migrated.push(sessionID);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
failed.push({
|
|
217
|
+
id: sessionID,
|
|
218
|
+
error: error instanceof Error ? error.message : String(error),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
status: failed.length > 0 ? 'partial' : 'complete',
|
|
225
|
+
migrated,
|
|
226
|
+
failed,
|
|
227
|
+
absent,
|
|
228
|
+
};
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Membership comes from OpenCode. A missing side-store row does not drop the
|
|
233
|
+
* session. A null side map is an unread store, not an empty catalog.
|
|
234
|
+
*
|
|
235
|
+
* @param {unknown} openCodeSessions
|
|
236
|
+
* @param {Record<string, object> | null | undefined} sideById
|
|
237
|
+
*/
|
|
238
|
+
export const retainOpenCodeSessions = (openCodeSessions, sideById) => {
|
|
239
|
+
if (!Array.isArray(openCodeSessions)) {
|
|
240
|
+
throw new Error('OpenCode session list is unavailable');
|
|
241
|
+
}
|
|
242
|
+
if (sideById == null || typeof sideById !== 'object' || Array.isArray(sideById)) {
|
|
243
|
+
throw new Error('session metadata is unavailable');
|
|
244
|
+
}
|
|
245
|
+
return openCodeSessions.map((session) => projectSessionWithStoredMap(session, sideById));
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const MIGRATION_RETRY_MS = 2_000;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Retry until every loaded side-store row is copied or confirmed absent.
|
|
252
|
+
* A failed load schedules another attempt and does not write empty metadata.
|
|
253
|
+
*
|
|
254
|
+
* @param {{
|
|
255
|
+
* load: () => Promise<{ ok?: boolean, entries?: Record<string, object> }>,
|
|
256
|
+
* readSession: (sessionID: string) => Promise<object | null>,
|
|
257
|
+
* writeMetadata: (sessionID: string, metadata: object, record: object) => Promise<void>,
|
|
258
|
+
* setTimer?: typeof setTimeout,
|
|
259
|
+
* clearTimer?: typeof clearTimeout,
|
|
260
|
+
* }} options
|
|
261
|
+
* @returns {() => void} stop
|
|
262
|
+
*/
|
|
263
|
+
export const startSideStoreMigration = ({
|
|
264
|
+
load,
|
|
265
|
+
readSession,
|
|
266
|
+
writeMetadata,
|
|
267
|
+
setTimer = setTimeout,
|
|
268
|
+
clearTimer = clearTimeout,
|
|
269
|
+
}) => {
|
|
270
|
+
const finished = new Set();
|
|
271
|
+
let stopped = false;
|
|
272
|
+
let timer = null;
|
|
273
|
+
|
|
274
|
+
const schedule = () => {
|
|
275
|
+
if (stopped || timer) return;
|
|
276
|
+
timer = setTimer(() => {
|
|
277
|
+
timer = null;
|
|
278
|
+
void attempt();
|
|
279
|
+
}, MIGRATION_RETRY_MS);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const attempt = async () => {
|
|
283
|
+
if (stopped) return;
|
|
284
|
+
let loadResult;
|
|
285
|
+
try {
|
|
286
|
+
loadResult = await load();
|
|
287
|
+
} catch {
|
|
288
|
+
schedule();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (!loadResult || loadResult.ok !== true || !isPlainObject(loadResult.entries)) {
|
|
292
|
+
schedule();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const pending = {};
|
|
296
|
+
for (const [id, metadata] of Object.entries(loadResult.entries)) {
|
|
297
|
+
if (!finished.has(id)) pending[id] = metadata;
|
|
298
|
+
}
|
|
299
|
+
if (Object.keys(pending).length === 0) return;
|
|
300
|
+
const result = await migrateLoadedSideStore({
|
|
301
|
+
loadResult: { ok: true, entries: pending },
|
|
302
|
+
readSession,
|
|
303
|
+
writeMetadata,
|
|
304
|
+
});
|
|
305
|
+
for (const id of result.migrated) finished.add(id);
|
|
306
|
+
for (const id of result.absent) finished.add(id);
|
|
307
|
+
if (result.failed.length > 0) schedule();
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
void attempt();
|
|
311
|
+
return () => {
|
|
312
|
+
stopped = true;
|
|
313
|
+
if (timer) clearTimer(timer);
|
|
314
|
+
timer = null;
|
|
315
|
+
};
|
|
316
|
+
};
|