@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.
Files changed (166) hide show
  1. package/dist/assets/{AgentAvatar-Q1SmFRx2.js → AgentAvatar-BBmQXYDq.js} +1 -1
  2. package/dist/assets/{AssistantView-CwXkiCAd.js → AssistantView-BG8hK4y9.js} +3 -3
  3. package/dist/assets/{DiagramView-BW92schO.js → DiagramView-CN0qWNU5.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-BZmwl-zE.js → FadeInOnReveal-w_BQ7K7K.js} +1 -1
  5. package/dist/assets/Icon-B9kZgYZn.js +1 -0
  6. package/dist/assets/{MarkdownRenderer-Bv05iDZZ.js → MarkdownRenderer-Ds61--LT.js} +2 -2
  7. package/dist/assets/{MarkdownRendererImpl-C81xqYib.js → MarkdownRendererImpl-DOMmiOH0.js} +1 -1
  8. package/dist/assets/{MarkstreamRendererImpl-CXUCQuKJ.js → MarkstreamRendererImpl-F7MGg5ER.js} +1 -1
  9. package/dist/assets/{MobileDetailNavigation-BMgIu7zc.js → MobileDetailNavigation-DcmVOjkw.js} +1 -1
  10. package/dist/assets/{MobileShareBridge-BOXGNHmC.js → MobileShareBridge-C1Fc4ErB.js} +2 -2
  11. package/dist/assets/MobileSurface-BA7eS5Yd.js +2 -0
  12. package/dist/assets/{MultiRunWindow-B5At2Z95.js → MultiRunWindow-BoTnH6Yp.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-bsPLgz4r.js → OnboardingScreen-BxItVnnO.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-4wp4migh.js → OverlayScrollbar-B4e0QD05.js} +1 -1
  15. package/dist/assets/SettingsView-CjesWNLw.js +25 -0
  16. package/dist/assets/{SettingsWindow-CMtX2bMl.js → SettingsWindow-CReMbg-w.js} +1 -1
  17. package/dist/assets/TerminalView-C64-QYOG.js +8 -0
  18. package/dist/assets/{ToolOutputDialog-uH1ZZDSS.js → ToolOutputDialog-CJu8DNrt.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-DK7q2juD.js → appThemeRegistry-CF4lIxNt.js} +1 -1
  20. package/dist/assets/{button-CcNJUxUd.js → button-CwXyux0a.js} +1 -1
  21. package/dist/assets/collapsible-BZ6_cYJd.js +1 -0
  22. package/dist/assets/context-menu-DHrRifPE.js +1 -0
  23. package/dist/assets/{desktopHosts-C6HrP0jq.js → desktopHosts-JGFqhlaM.js} +1 -1
  24. package/dist/assets/{desktopRelayRestore-DdVBp45U.js → desktopRelayRestore-CHEwrI93.js} +1 -1
  25. package/dist/assets/{diagram-CxTs9hCQ.js → diagram-BwnPNKWt.js} +1 -1
  26. package/dist/assets/{dialog-KMVL9QKF.js → dialog-Dp0lQv5K.js} +1 -1
  27. package/dist/assets/es-DicJ8lKD.js +15 -0
  28. package/dist/assets/{fileMentionTouchSelection-7RIwlpkB.js → fileMentionTouchSelection-DPZRxNa2.js} +3 -4
  29. package/dist/assets/{fileQueries-CjdBRy8y.js → fileQueries-DN6gbLhM.js} +1 -1
  30. package/dist/assets/fr-lhgVcBW_.js +13 -0
  31. package/dist/assets/{gitApi-yuWg9BN4.js → gitApi-D8R7ic-T.js} +1 -1
  32. package/dist/assets/{imageSource-9vQBq3zp.js → imageSource-BMztRu0c.js} +1 -1
  33. package/dist/assets/{input-CrIvk3HZ.js → input-Biw8aNzR.js} +1 -1
  34. package/dist/assets/insertionBoundaries-DdwCD4S2.js +1 -0
  35. package/dist/assets/ja-B9EZwOcw.js +15 -0
  36. package/dist/assets/ko-Dxk-uJRb.js +15 -0
  37. package/dist/assets/main-BhlwwJ2c.js +169 -0
  38. package/dist/assets/main-D2BzpnUt.css +1 -0
  39. package/dist/assets/main-DSPCkT74.js +2 -0
  40. package/dist/assets/{markdownHeightCache-BmXVY_gc.js → markdownHeightCache-BOOrGCuG.js} +1 -1
  41. package/dist/assets/markdownTheme-DM5h1i18.js +1 -0
  42. package/dist/assets/{mcpQueries-C7jknHV8.js → mcpQueries-CE9tk-Jz.js} +1 -1
  43. package/dist/assets/miniChat-Ckk9ABql.js +2 -0
  44. package/dist/assets/mobile-cLMRZqI9.js +2 -0
  45. package/dist/assets/multirun-DVnFRb1V.js +4 -0
  46. package/dist/assets/persistence-D0y7226-.js +3 -0
  47. package/dist/assets/pl-CFhwWLJj.js +15 -0
  48. package/dist/assets/platform-D_c-T5nO.js +6 -0
  49. package/dist/assets/projectMeta-CSQgHequ.js +3 -0
  50. package/dist/assets/pt-BR-Ba6GfZ6J.js +15 -0
  51. package/dist/assets/renderElectronMiniChatApp-gGFkwoL_.js +2 -0
  52. package/dist/assets/{renderMobileApp-CGMMqbsO.js → renderMobileApp-B2w50kAc.js} +7 -7
  53. package/dist/assets/{runtimeConfig-BEANkvOd.js → runtimeConfig-BHH1hJDd.js} +1 -1
  54. package/dist/assets/runtimeEndpointReset-5uJ4sJk0.js +149 -0
  55. package/dist/assets/{sessionChangeSummary-qOLrP9fw.js → sessionChangeSummary-spf_atO8.js} +2 -2
  56. package/dist/assets/{sessionLookup-34XFmP9C.js → sessionLookup-BGjjwiCq.js} +1 -1
  57. package/dist/assets/{sortable-tabs-strip-DyEV0Heg.js → sortable-tabs-strip-CTY_wG46.js} +1 -1
  58. package/dist/assets/src-BjEyU6if.css +2 -0
  59. package/dist/assets/store-DmowK-GO.js +15 -0
  60. package/dist/assets/{terminalApi-CXaBEoa_.js → terminalApi-Dhqqp4xi.js} +1 -1
  61. package/dist/assets/{textarea-BBOlRI9O.js → textarea-QxsmcBGj.js} +1 -1
  62. package/dist/assets/{tooltip-CPI4fr1a.js → tooltip-CpMLl5_k.js} +1 -1
  63. package/dist/assets/uk-ChchBzy6.js +15 -0
  64. package/dist/assets/{url-DRDQXjyX.js → url-CGbJbiJq.js} +1 -1
  65. package/dist/assets/{useAppFontEffects-CiDe42I5.js → useAppFontEffects-B1h7ab_J.js} +56 -51
  66. package/dist/assets/{useAssistantUIStore-asNG6K7n.js → useAssistantUIStore-Bz4tRflF.js} +1 -1
  67. package/dist/assets/useDesktopWindowControlsLayout-Cxsx84NO.js +1 -0
  68. package/dist/assets/{useEffectiveDirectory-C_CouL5v.js → useEffectiveDirectory-DsqhqsJR.js} +1 -1
  69. package/dist/assets/{useFontPreferences-B0oUZAiB.js → useFontPreferences-Xw1vSkmC.js} +1 -1
  70. package/dist/assets/useMobileNavigationStore-B1axS4g-.js +414 -0
  71. package/dist/assets/{useRuntimeAPIs-DwLtO0c_.js → useRuntimeAPIs-DSLXJybW.js} +1 -1
  72. package/dist/assets/useSessionAutoCleanup-B5FTG_qf.js +2 -0
  73. package/dist/assets/{useThemeSystem-BlNUWH47.js → useThemeSystem-DMzWVJaW.js} +1 -1
  74. package/dist/assets/useUIStore-syVmbuDA.js +1 -0
  75. package/dist/assets/{useWorkerHighlightedLines-CPNYz6Wf.js → useWorkerHighlightedLines-DC_Bc-RV.js} +1 -1
  76. package/dist/assets/{vendor-base-ui-CDjyofeD.js → vendor-base-ui-CnHfUmDN.js} +1 -1
  77. package/dist/assets/{vendor-cmdk-BFH0UpXX.js → vendor-cmdk-CeAGfrLo.js} +1 -1
  78. package/dist/assets/{vendor-dnd-kit-accessibility-BrR3AKvM.js → vendor-dnd-kit-accessibility-BY5LhhUB.js} +1 -1
  79. package/dist/assets/{vendor-dnd-kit-core-CNfdvk6H.js → vendor-dnd-kit-core-CvWxUPdb.js} +1 -1
  80. package/dist/assets/{vendor-dnd-kit-sortable-DQUaTeGj.js → vendor-dnd-kit-sortable-hHdP9lLb.js} +1 -1
  81. package/dist/assets/{vendor-framer-motion-4dogsUk3.js → vendor-framer-motion-DFy_BSVd.js} +1 -1
  82. package/dist/assets/{vendor-legendapp-list-BU7MdPZR.js → vendor-legendapp-list-WE32lwNk.js} +1 -1
  83. package/dist/assets/{vendor-markstream-react-qHC2FykU.js → vendor-markstream-react-DX55XLGO.js} +3 -3
  84. package/dist/assets/vendor-opencode-client-C12gfjag.js +12 -0
  85. package/dist/assets/{vendor-pierre-diffs-CUfqC003.js → vendor-pierre-diffs-DX7XTPG3.js} +1 -1
  86. package/dist/assets/{vendor-react-C94A0AHl.js → vendor-react-DLPElpoQ.js} +1 -1
  87. package/dist/assets/{vendor-react-drawio-FVlWqKVE.js → vendor-react-drawio-ECtEwvbc.js} +1 -1
  88. package/dist/assets/{vendor-reactuses-core-C2A3EjUi.js → vendor-reactuses-core-B8parU1Y.js} +1 -1
  89. package/dist/assets/{vendor-sonner-DNqHmUQs.js → vendor-sonner-DVzioF3d.js} +1 -1
  90. package/dist/assets/{vendor-stream-diffs-CwbKsmlv.js → vendor-stream-diffs-DWy3xSmp.js} +2 -2
  91. package/dist/assets/{vendor-tanstack-react-query-D0iFK57C.js → vendor-tanstack-react-query-D7pys_c9.js} +1 -1
  92. package/dist/assets/{vendor-tanstack-react-virtual-B2kV6cnT.js → vendor-tanstack-react-virtual-tlDgH6PG.js} +1 -1
  93. package/dist/assets/{vendor-zustand-BqLKf6ZY.js → vendor-zustand-B4dlGEM2.js} +1 -1
  94. package/dist/assets/zh-CN-Z_bkUQk5.js +15 -0
  95. package/dist/assets/zh-TW-CZeTm7kd.js +15 -0
  96. package/dist/index.html +13 -13
  97. package/dist/mini-chat.html +13 -13
  98. package/dist/mobile.html +13 -13
  99. package/package.json +3 -2
  100. package/server/index.js +38 -7
  101. package/server/lib/browser-provider/DOCUMENTATION.md +141 -0
  102. package/server/lib/browser-provider/contract.js +312 -0
  103. package/server/lib/browser-provider/host.js +421 -0
  104. package/server/lib/browser-provider/host.test.js +304 -0
  105. package/server/lib/browser-provider/routes.js +122 -0
  106. package/server/lib/browser-provider/routes.test.js +120 -0
  107. package/server/lib/browser-provider/selection-store.js +43 -0
  108. package/server/lib/browser-provider/selection-store.test.js +35 -0
  109. package/server/lib/notifications/DOCUMENTATION.md +3 -1
  110. package/server/lib/notifications/runtime.js +110 -26
  111. package/server/lib/notifications/runtime.test.js +92 -0
  112. package/server/lib/notifications/template-runtime.js +130 -38
  113. package/server/lib/notifications/template-runtime.test.js +96 -0
  114. package/server/lib/opencode/DOCUMENTATION.md +4 -0
  115. package/server/lib/opencode/ensure-cli.d.ts +8 -0
  116. package/server/lib/opencode/feature-routes-runtime.js +10 -0
  117. package/server/lib/opencode/feature-routes-runtime.test.js +7 -0
  118. package/server/lib/opencode/proxy.js +3 -1
  119. package/server/lib/opencode/proxy.test.js +3 -3
  120. package/server/lib/opencode/routes.js +30 -0
  121. package/server/lib/opencode/upgrade-screen.d.ts +30 -0
  122. package/server/lib/opencode/upgrade-screen.js +299 -0
  123. package/server/lib/opencode/upgrade-screen.test.js +196 -0
  124. package/server/lib/session-metadata/DOCUMENTATION.md +27 -17
  125. package/server/lib/session-metadata/opencode-session-record.d.ts +57 -0
  126. package/server/lib/session-metadata/opencode-session-record.js +316 -0
  127. package/server/lib/session-metadata/opencode-session-record.test.js +242 -0
  128. package/server/lib/session-metadata/routes.js +5 -1
  129. package/server/lib/session-metadata/session-archive.js +1 -1
  130. package/server/lib/session-metadata/session-metadata-store.d.ts +2 -0
  131. package/server/lib/session-metadata/session-metadata-store.js +63 -16
  132. package/server/lib/session-metadata/session-projection.js +26 -2
  133. package/dist/assets/Icon-AQPvxMiU.js +0 -1
  134. package/dist/assets/MobileSurface-xuc_z0_G.js +0 -2
  135. package/dist/assets/SettingsView-CJ6tcH5_.js +0 -25
  136. package/dist/assets/TerminalView-Dvr-ysTS.js +0 -8
  137. package/dist/assets/collapsible-ZmZ6rLWs.js +0 -3
  138. package/dist/assets/context-menu-B2LXJgXL.js +0 -1
  139. package/dist/assets/es-CN3E_zXj.js +0 -15
  140. package/dist/assets/fr-9zZvQZD2.js +0 -13
  141. package/dist/assets/insertionBoundaries-BiUCSkx-.js +0 -1
  142. package/dist/assets/ja-Dnjk_82N.js +0 -15
  143. package/dist/assets/ko-BxCEE99C.js +0 -15
  144. package/dist/assets/main-C9mIBo77.js +0 -2
  145. package/dist/assets/main-DiL6w6KF.js +0 -169
  146. package/dist/assets/main-qSeeJRW1.css +0 -1
  147. package/dist/assets/markdownTheme-CKac3wKC.js +0 -1
  148. package/dist/assets/miniChat-CwtUaElU.js +0 -2
  149. package/dist/assets/mobile-DB-6--ws.js +0 -2
  150. package/dist/assets/multirun-TAsxFI8e.js +0 -4
  151. package/dist/assets/persistence-56kq8Z4V.js +0 -3
  152. package/dist/assets/pl-CNNsWnPM.js +0 -15
  153. package/dist/assets/platform-C13fMVEs.js +0 -6
  154. package/dist/assets/pt-BR-BZB2DJMp.js +0 -15
  155. package/dist/assets/renderElectronMiniChatApp-CGj0MNRU.js +0 -2
  156. package/dist/assets/runtimeEndpointReset-A_ZBUJFS.js +0 -147
  157. package/dist/assets/src-ubCWXZhR.css +0 -2
  158. package/dist/assets/store-DLCiZaA6.js +0 -15
  159. package/dist/assets/uk-rwQrxeYM.js +0 -15
  160. package/dist/assets/useDesktopWindowControlsLayout-BlizAlOt.js +0 -1
  161. package/dist/assets/useMobileNavigationStore-DfPClaW-.js +0 -414
  162. package/dist/assets/useSessionAutoCleanup-DtrfbxAp.js +0 -2
  163. package/dist/assets/useUIStore-CQYKJnrg.js +0 -1
  164. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +0 -12
  165. package/dist/assets/zh-CN-BvmBDnkD.js +0 -15
  166. 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-owned per-session metadata for Host features that used to live on
4
- OpenCode `session.metadata`.
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 later other Host state under the
9
- `openchamber` namespace are OpenChamber product state. The Host owns their
10
- authority: it serializes conditional writes, publishes only persisted values,
11
- and protects archive fields from generic patches. They therefore live in this
12
- Host store and are folded back onto proxy session list/detail/SSE responses so
13
- clients still read `session.metadata` and `time.archived`.
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
- Upstream capability is tracked per version and does not change that ownership:
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-metadata-store.js` | Durable store: one JSON file per data dir, `{ [sessionID]: metadata }`, RFC 7386 merge patch, full RMW serialization, refuse-failed-read refuses write/read-as-empty |
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: OPENCHAMBER_DATA_DIR })` + initial `load()`
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 (not upstream `session.update`)
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. OpenCode create-time `metadata` is a hint only.
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
+ };