@vantran-se/openchamber-web 2.0.2 → 2.0.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 +20 -28
- package/bin/cli.js +3 -1
- package/bin/cli.test.js +13 -4
- package/bin/lib/DOCUMENTATION.md +4 -1
- package/bin/lib/commands-serve.js +9 -0
- package/dist/assets/{CodeMirrorCommentWidgets-B82Oye3V.js → CodeMirrorCommentWidgets-DZF8ntaq.js} +1 -1
- package/dist/assets/{DiffView-DJLmWn6Z.js → DiffView-D2p4Ar0H.js} +1 -1
- package/dist/assets/{FilesView-DjksmZRo.js → FilesView-BeTtbsip.js} +1 -1
- package/dist/assets/{GitView-DiUsUIgr.js → GitView-DwQwqDvu.js} +1 -1
- package/dist/assets/{GuestAttachDialog-CYwWYLr3.js → GuestAttachDialog-CoXM0gh_.js} +1 -1
- package/dist/assets/{GuestSurfacePane-CmctyRI4.js → GuestSurfacePane-DIJvkkM-.js} +1 -1
- package/dist/assets/{JsonTreeView-DOHHcbgJ.js → JsonTreeView-Cq_27v0y.js} +1 -1
- package/dist/assets/{LinearIssuesView-DaqnylcV.js → LinearIssuesView-CsSSRMhn.js} +1 -1
- package/dist/assets/{MarkdownImageGallery-HJNvWt7L.js → MarkdownImageGallery-CY0dWTaE.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-nDZRGg2I.js → MarkdownRendererImpl-ILdXzLrt.js} +1 -1
- package/dist/assets/{OnboardingScreen-C2B5Bsof.js → OnboardingScreen-ClatMQAX.js} +1 -1
- package/dist/assets/{PatchDiff-C4FLBjpt.js → PatchDiff-DOwlrIK9.js} +1 -1
- package/dist/assets/{PierreDiffViewer-C_1S9c9n.js → PierreDiffViewer-CrcGqqD_.js} +1 -1
- package/dist/assets/{PlanView-_eZvb-G5.js → PlanView-BGLsMgQk.js} +1 -1
- package/dist/assets/{PluginPane-sM8fcZIV.js → PluginPane-B39JEUL8.js} +1 -1
- package/dist/assets/{SettingsView-BRfr46hb.js → SettingsView-13B2_G-A.js} +1 -1
- package/dist/assets/{SettingsWindow-CcUDwQHg.js → SettingsWindow-B0BAy-f9.js} +1 -1
- package/dist/assets/{SubmoduleDiffSummary-CrN1ZGwl.js → SubmoduleDiffSummary-fcdDzCSW.js} +1 -1
- package/dist/assets/{ToolOutputDialog-Cij4jI09.js → ToolOutputDialog-B7xaUz2H.js} +1 -1
- package/dist/assets/{ToolPartDiffPreview-BXzwQ-JY.js → ToolPartDiffPreview-CkWCaUmZ.js} +1 -1
- package/dist/assets/{VirtualizedFileDiff-DSWTLJNH.js → VirtualizedFileDiff-DONC4eI2.js} +1 -1
- package/dist/assets/{WalkthroughView-CpUUgY8H.js → WalkthroughView-CXDImOXW.js} +1 -1
- package/dist/assets/{appThemeRegistry-B5tHTkR8.js → appThemeRegistry-Br2TLSlb.js} +1 -1
- package/dist/assets/{commitAndPush-D7D8qV8p.js → commitAndPush-CcI4ol7c.js} +1 -1
- package/dist/assets/{computeVirtualFileMetrics-cq9uQ30i.js → computeVirtualFileMetrics-_-20umCs.js} +1 -1
- package/dist/assets/{de-CUO1-KqF.js → de-BtYnUgII.js} +1 -1
- package/dist/assets/{es-BlbC6sGJ.js → es-crP5qPye.js} +1 -1
- package/dist/assets/{fr-D2nwe_0W.js → fr-CLSRuALk.js} +1 -1
- package/dist/assets/{index-BQOoYIOu.js → index-OKDYjcMF.js} +1 -1
- package/dist/assets/{isDiffPlainText-D0obJ2p8.js → isDiffPlainText-D77M6q1f.js} +1 -1
- package/dist/assets/{isFilePlainText-DB3_rjyW.js → isFilePlainText-C54B4Mns.js} +1 -1
- package/dist/assets/{ja-CA7vnDsZ.js → ja-SZ1-ucLV.js} +1 -1
- package/dist/assets/{ko-Chy6a9l-.js → ko-HthbT_Ve.js} +1 -1
- package/dist/assets/{main-B9JFK3vF.js → main-BJklC7Cl.js} +4 -4
- package/dist/assets/{main-Bhiqnrhd.js → main-CGEvLMd2.js} +2 -2
- package/dist/assets/{markdownCore-tfqOFaBd.js → markdownCore-BzQuETm0.js} +1 -1
- package/dist/assets/{miniChat-B0UOL5zA.js → miniChat-BSxkRQLj.js} +2 -2
- package/dist/assets/{mobile-BJppFIEo.js → mobile-C5KRTNOB.js} +2 -2
- package/dist/assets/{patchFileDiff-Db6KuwCP.js → patchFileDiff-DGw2KryM.js} +1 -1
- package/dist/assets/{pl-CTR5A_1P.js → pl-C8eYfjL1.js} +1 -1
- package/dist/assets/{pt-BR-BE1O1KEx.js → pt-BR-cYOrZdxS.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-Yz5sfoFh.js → renderElectronMiniChatApp-CnkAQV6o.js} +2 -2
- package/dist/assets/{renderMobileApp-QQ-yB8aY.js → renderMobileApp-CMxjwisj.js} +3 -3
- package/dist/assets/{runtimeEndpointReset-DKDdzRzm.js → runtimeEndpointReset-C45jrFq4.js} +3 -3
- package/dist/assets/{scheduledTasksApi-C5sCuNqf.js → scheduledTasksApi-CkK1F9s2.js} +1 -1
- package/dist/assets/{shikiHighlight-xURoo5t_.js → shikiHighlight-4O0ZM14D.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-BZLGuYTU.js → textMateThemeFromAppTheme-yNq7b0DZ.js} +1 -1
- package/dist/assets/{tr-Bh5vK6cW.js → tr-B4IQ5t-T.js} +1 -1
- package/dist/assets/{uk-CLDkZFy_.js → uk-DCoXG6ve.js} +1 -1
- package/dist/assets/{useAppFontEffects-BxrtCnfR.js → useAppFontEffects-Bzyszv2N.js} +48 -48
- package/dist/assets/{useInlineCommentController-CeXuR-SR.js → useInlineCommentController-zOf6ORAq.js} +1 -1
- package/dist/assets/{useRootScrollLock-DgWxezc1.js → useRootScrollLock-DCaZ4BWi.js} +2 -2
- package/dist/assets/{vendor-codemirror-language-data-DOuaQUZ0.js → vendor-codemirror-language-data-6cEFPNUR.js} +2 -2
- package/dist/assets/{web-components-63H1h21j.js → web-components-BEo4-ISu.js} +1 -1
- package/dist/assets/{zh-CN-DODgcmYj.js → zh-CN-BXBoxsFk.js} +1 -1
- package/dist/assets/{zh-TW-ByNfIwLA.js → zh-TW-C6oelpnL.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/mini-chat.html +1 -1
- package/dist/mobile.html +1 -1
- package/package.json +1 -1
- package/server/index.d.ts +2 -0
- package/server/index.js +114 -52
- package/server/lib/agent-tool/DOCUMENTATION.md +47 -127
- package/server/lib/agent-tool/runtime.js +66 -47
- package/server/lib/agent-tool/runtime.test.js +2 -0
- package/server/lib/event-stream/DOCUMENTATION.md +2 -2
- package/server/lib/event-stream/global-hub.js +22 -6
- package/server/lib/event-stream/global-hub.test.js +93 -2
- package/server/lib/event-stream/rebind.test.js +1 -0
- package/server/lib/event-stream/runtime.js +1 -2
- package/server/lib/opencode/DOCUMENTATION.md +23 -23
- package/server/lib/opencode/lifecycle.js +126 -0
- package/server/lib/opencode/lifecycle.test.js +228 -1
- package/server/lib/opencode/network-runtime.js +16 -7
- package/server/lib/opencode/network-runtime.test.js +29 -1
- package/server/lib/opencode/proxy.js +15 -14
- package/server/lib/opencode/server-startup-runtime.js +25 -0
- package/server/lib/opencode/server-startup-runtime.test.js +67 -1
- package/server/lib/opencode/shared-service-runtime.js +155 -0
- package/server/lib/opencode/shared-service-runtime.test.js +180 -0
- package/server/lib/opencode/shutdown-runtime.js +11 -5
- package/server/lib/opencode/shutdown-runtime.test.js +77 -2
- package/server/lib/opencode/startup-pipeline-runtime.js +5 -4
- package/server/lib/opencode/startup-pipeline-runtime.test.js +18 -7
- package/server/lib/opencode/watcher.test.js +54 -0
- package/server/opencode-proxy.test.js +158 -0
- package/server/proxy-headers.js +8 -2
- package/server/proxy-headers.test.js +20 -0
|
@@ -55,6 +55,132 @@ const WARMUP_REQUEST_TIMEOUT_MS = 30000;
|
|
|
55
55
|
const MANAGED_STDERR_TAIL_MAX_BYTES = 32 * 1024;
|
|
56
56
|
const HEALTH_FAILURE_DETAIL_MAX_LENGTH = 256;
|
|
57
57
|
|
|
58
|
+
export const resolveOpenCodeConnectionKind = ({ runtime, configuredHost, lifecycleMode }) => {
|
|
59
|
+
if (configuredHost) return 'explicit-external';
|
|
60
|
+
if (lifecycleMode === 'managed' || runtime === 'desktop') return 'managed-owned';
|
|
61
|
+
return 'shared-local';
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const createOpenCodeServerComposition = ({
|
|
65
|
+
runtime,
|
|
66
|
+
configuredHost,
|
|
67
|
+
lifecycleMode,
|
|
68
|
+
createSharedRuntime,
|
|
69
|
+
createManagedPluginRuntime = () => null,
|
|
70
|
+
getManagedBaseUrl = () => null,
|
|
71
|
+
getManagedAuthHeaders = () => ({}),
|
|
72
|
+
}) => {
|
|
73
|
+
const kind = resolveOpenCodeConnectionKind({ runtime, configuredHost, lifecycleMode });
|
|
74
|
+
if (kind === 'shared-local' && !createSharedRuntime) {
|
|
75
|
+
throw new Error('Shared OpenCode composition requires a shared runtime factory');
|
|
76
|
+
}
|
|
77
|
+
const sharedRuntime = kind === 'shared-local' ? createSharedRuntime() : null;
|
|
78
|
+
const managedPluginRuntime = kind === 'managed-owned' ? createManagedPluginRuntime() : null;
|
|
79
|
+
const connection = createOpenCodeConnectionAdapter({
|
|
80
|
+
kind,
|
|
81
|
+
sharedRuntime,
|
|
82
|
+
getManagedBaseUrl,
|
|
83
|
+
getManagedAuthHeaders,
|
|
84
|
+
});
|
|
85
|
+
return { kind, sharedRuntime, managedPluginRuntime, connection };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const createOpenCodeRecoveryCallbacks = ({
|
|
89
|
+
resetOpenCodeRuntimeProviders,
|
|
90
|
+
rebindUpstream,
|
|
91
|
+
interruptBusySessionsAfterRestart,
|
|
92
|
+
broadcastUiNotification,
|
|
93
|
+
}) => ({
|
|
94
|
+
onManagedRestarted() {
|
|
95
|
+
resetOpenCodeRuntimeProviders();
|
|
96
|
+
try {
|
|
97
|
+
rebindUpstream();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.warn('Failed to rebind message stream after OpenCode restart:', error?.message ?? error);
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const { sessionIds } = interruptBusySessionsAfterRestart();
|
|
103
|
+
if (sessionIds.length === 0) return;
|
|
104
|
+
const multiple = sessionIds.length > 1;
|
|
105
|
+
broadcastUiNotification({
|
|
106
|
+
title: multiple ? 'Chats interrupted' : 'Chat interrupted',
|
|
107
|
+
body: multiple
|
|
108
|
+
? 'OpenCode restarted during running responses. Send a message in each chat to continue.'
|
|
109
|
+
: 'OpenCode restarted during a running response. Send a message to continue.',
|
|
110
|
+
tag: 'opencode-restart-interrupted',
|
|
111
|
+
kind: 'opencode-restart-interrupted',
|
|
112
|
+
sessionId: sessionIds[0],
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.warn('Failed to reconcile sessions after OpenCode restart:', error?.message ?? error);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
onSharedRecovered() {
|
|
119
|
+
resetOpenCodeRuntimeProviders();
|
|
120
|
+
rebindUpstream();
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export const createOpenCodeLifecycleControls = ({
|
|
125
|
+
getKind,
|
|
126
|
+
restartLifecycle,
|
|
127
|
+
triggerLifecycleHealthCheck,
|
|
128
|
+
recoverShared,
|
|
129
|
+
onSharedRecovered,
|
|
130
|
+
}) => {
|
|
131
|
+
let sharedRecoveryPromise = null;
|
|
132
|
+
const recover = () => {
|
|
133
|
+
if (!recoverShared || !onSharedRecovered) {
|
|
134
|
+
return Promise.reject(new Error('Shared OpenCode recovery is not configured'));
|
|
135
|
+
}
|
|
136
|
+
if (!sharedRecoveryPromise) {
|
|
137
|
+
sharedRecoveryPromise = Promise.resolve()
|
|
138
|
+
.then(() => recoverShared())
|
|
139
|
+
.then(() => onSharedRecovered())
|
|
140
|
+
.finally(() => {
|
|
141
|
+
sharedRecoveryPromise = null;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return sharedRecoveryPromise;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
restart: (...args) => (
|
|
149
|
+
getKind() === 'shared-local' ? recover() : restartLifecycle(...args)
|
|
150
|
+
),
|
|
151
|
+
triggerHealthCheck: (...args) => {
|
|
152
|
+
if (getKind() !== 'shared-local') {
|
|
153
|
+
return triggerLifecycleHealthCheck(...args);
|
|
154
|
+
}
|
|
155
|
+
return recover().catch((error) => {
|
|
156
|
+
console.warn('Failed to recover shared OpenCode service:', error?.message ?? error);
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const createOpenCodeConnectionAdapter = ({
|
|
163
|
+
kind,
|
|
164
|
+
sharedRuntime = null,
|
|
165
|
+
getManagedBaseUrl = () => null,
|
|
166
|
+
getManagedAuthHeaders = () => ({}),
|
|
167
|
+
}) => {
|
|
168
|
+
if (kind === 'shared-local' && !sharedRuntime) {
|
|
169
|
+
throw new Error('Shared OpenCode connection requires a shared service runtime');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
getOpenCodeConnectionKind: () => kind,
|
|
174
|
+
getOpenCodeBaseUrl: () => (
|
|
175
|
+
kind === 'shared-local' ? sharedRuntime.getBaseUrl() : getManagedBaseUrl()
|
|
176
|
+
),
|
|
177
|
+
getOpenCodeAuthHeaders: () => (
|
|
178
|
+
kind === 'shared-local' ? sharedRuntime.getHeaders() : getManagedAuthHeaders()
|
|
179
|
+
),
|
|
180
|
+
ownsOpenCodeProcess: () => kind === 'managed-owned',
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
58
184
|
const getBoundedTextTail = (value, maxBytes) => {
|
|
59
185
|
const buffer = Buffer.from(String(value ?? ''));
|
|
60
186
|
if (buffer.byteLength <= maxBytes) return buffer.toString();
|
|
@@ -18,7 +18,14 @@ vi.mock('./startup-performance.js', () => ({
|
|
|
18
18
|
recordStartupPerformance: recordStartupPerformanceMock,
|
|
19
19
|
}));
|
|
20
20
|
|
|
21
|
-
const {
|
|
21
|
+
const {
|
|
22
|
+
createOpenCodeConnectionAdapter,
|
|
23
|
+
createOpenCodeLifecycleControls,
|
|
24
|
+
createOpenCodeLifecycleRuntime,
|
|
25
|
+
createOpenCodeRecoveryCallbacks,
|
|
26
|
+
createOpenCodeServerComposition,
|
|
27
|
+
resolveOpenCodeConnectionKind,
|
|
28
|
+
} = await import('./lifecycle.js');
|
|
22
29
|
|
|
23
30
|
const originalOpencodeBinary = process.env.OPENCODE_BINARY;
|
|
24
31
|
const originalPath = process.env.PATH;
|
|
@@ -128,6 +135,226 @@ const createRuntime = (overrides = {}, stateOverrides = {}, envOverrides = {}) =
|
|
|
128
135
|
return runtime;
|
|
129
136
|
};
|
|
130
137
|
|
|
138
|
+
describe('OpenCode connection composition', () => {
|
|
139
|
+
it('selects shared-local for Web without an explicit host', () => {
|
|
140
|
+
expect(resolveOpenCodeConnectionKind({ runtime: 'web', configuredHost: null })).toBe('shared-local');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('selects explicit-external when a host is configured', () => {
|
|
144
|
+
expect(resolveOpenCodeConnectionKind({
|
|
145
|
+
runtime: 'web',
|
|
146
|
+
configuredHost: { origin: 'https://opencode.example.com', port: 443 },
|
|
147
|
+
})).toBe('explicit-external');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('keeps Desktop on the managed-owned lifecycle', () => {
|
|
151
|
+
expect(resolveOpenCodeConnectionKind({ runtime: 'desktop', configuredHost: null })).toBe('managed-owned');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('gives an explicit host priority over the Desktop managed lifecycle', () => {
|
|
155
|
+
expect(resolveOpenCodeConnectionKind({
|
|
156
|
+
runtime: 'desktop',
|
|
157
|
+
configuredHost: { origin: 'https://opencode.example.com', port: 443 },
|
|
158
|
+
})).toBe('explicit-external');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('keeps an embedded managed override on the managed-owned lifecycle', () => {
|
|
162
|
+
expect(resolveOpenCodeConnectionKind({
|
|
163
|
+
runtime: 'web',
|
|
164
|
+
configuredHost: null,
|
|
165
|
+
lifecycleMode: 'managed',
|
|
166
|
+
})).toBe('managed-owned');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('requires a shared runtime factory for shared-local composition', () => {
|
|
170
|
+
expect(() => createOpenCodeServerComposition({
|
|
171
|
+
runtime: 'web',
|
|
172
|
+
configuredHost: null,
|
|
173
|
+
})).toThrow('Shared OpenCode composition requires a shared runtime factory');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('creates shared and managed-only runtimes only for their owning connection kind', () => {
|
|
177
|
+
const sharedRuntime = { getBaseUrl: () => null, getHeaders: () => ({}) };
|
|
178
|
+
const managedPluginRuntime = { buildManagedChildEnv: () => ({}) };
|
|
179
|
+
const createSharedRuntime = vi.fn(() => sharedRuntime);
|
|
180
|
+
const createManagedPluginRuntime = vi.fn(() => managedPluginRuntime);
|
|
181
|
+
|
|
182
|
+
const shared = createOpenCodeServerComposition({
|
|
183
|
+
runtime: 'web',
|
|
184
|
+
configuredHost: null,
|
|
185
|
+
createSharedRuntime,
|
|
186
|
+
createManagedPluginRuntime,
|
|
187
|
+
});
|
|
188
|
+
const external = createOpenCodeServerComposition({
|
|
189
|
+
runtime: 'web',
|
|
190
|
+
configuredHost: { origin: 'https://opencode.example.com', port: 443 },
|
|
191
|
+
createSharedRuntime,
|
|
192
|
+
createManagedPluginRuntime,
|
|
193
|
+
});
|
|
194
|
+
const desktop = createOpenCodeServerComposition({
|
|
195
|
+
runtime: 'desktop',
|
|
196
|
+
configuredHost: null,
|
|
197
|
+
createSharedRuntime,
|
|
198
|
+
createManagedPluginRuntime,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(shared.sharedRuntime).toBe(sharedRuntime);
|
|
202
|
+
expect(shared.managedPluginRuntime).toBeNull();
|
|
203
|
+
expect(external.sharedRuntime).toBeNull();
|
|
204
|
+
expect(external.managedPluginRuntime).toBeNull();
|
|
205
|
+
expect(desktop.sharedRuntime).toBeNull();
|
|
206
|
+
expect(desktop.managedPluginRuntime).toBe(managedPluginRuntime);
|
|
207
|
+
expect(createSharedRuntime).toHaveBeenCalledTimes(1);
|
|
208
|
+
expect(createManagedPluginRuntime).toHaveBeenCalledTimes(1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('recovers the shared service through the production callback without interrupting sessions', async () => {
|
|
212
|
+
const restartManaged = vi.fn(async () => {});
|
|
213
|
+
const triggerManagedHealthCheck = vi.fn(async () => {});
|
|
214
|
+
const recoverShared = vi.fn(async () => {});
|
|
215
|
+
const resetOpenCodeRuntimeProviders = vi.fn();
|
|
216
|
+
const rebindUpstream = vi.fn();
|
|
217
|
+
const interruptBusySessionsAfterRestart = vi.fn(() => ({ sessionIds: [] }));
|
|
218
|
+
const broadcastUiNotification = vi.fn();
|
|
219
|
+
const callbacks = createOpenCodeRecoveryCallbacks({
|
|
220
|
+
resetOpenCodeRuntimeProviders,
|
|
221
|
+
rebindUpstream,
|
|
222
|
+
interruptBusySessionsAfterRestart,
|
|
223
|
+
broadcastUiNotification,
|
|
224
|
+
});
|
|
225
|
+
const shared = createOpenCodeLifecycleControls({
|
|
226
|
+
getKind: () => 'shared-local',
|
|
227
|
+
restartLifecycle: restartManaged,
|
|
228
|
+
triggerLifecycleHealthCheck: triggerManagedHealthCheck,
|
|
229
|
+
recoverShared,
|
|
230
|
+
onSharedRecovered: callbacks.onSharedRecovered,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await shared.restart('manual');
|
|
234
|
+
|
|
235
|
+
expect(recoverShared).toHaveBeenCalledOnce();
|
|
236
|
+
expect(resetOpenCodeRuntimeProviders).toHaveBeenCalledOnce();
|
|
237
|
+
expect(rebindUpstream).toHaveBeenCalledOnce();
|
|
238
|
+
expect(recoverShared.mock.invocationCallOrder[0]).toBeLessThan(rebindUpstream.mock.invocationCallOrder[0]);
|
|
239
|
+
expect(interruptBusySessionsAfterRestart).not.toHaveBeenCalled();
|
|
240
|
+
expect(broadcastUiNotification).not.toHaveBeenCalled();
|
|
241
|
+
expect(restartManaged).not.toHaveBeenCalled();
|
|
242
|
+
expect(triggerManagedHealthCheck).not.toHaveBeenCalled();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('keeps managed restart interruption behavior in the production callback', () => {
|
|
246
|
+
const resetOpenCodeRuntimeProviders = vi.fn();
|
|
247
|
+
const rebindUpstream = vi.fn();
|
|
248
|
+
const interruptBusySessionsAfterRestart = vi.fn(() => ({ sessionIds: ['session-1'] }));
|
|
249
|
+
const broadcastUiNotification = vi.fn();
|
|
250
|
+
const callbacks = createOpenCodeRecoveryCallbacks({
|
|
251
|
+
resetOpenCodeRuntimeProviders,
|
|
252
|
+
rebindUpstream,
|
|
253
|
+
interruptBusySessionsAfterRestart,
|
|
254
|
+
broadcastUiNotification,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
callbacks.onManagedRestarted();
|
|
258
|
+
|
|
259
|
+
expect(resetOpenCodeRuntimeProviders).toHaveBeenCalledOnce();
|
|
260
|
+
expect(rebindUpstream).toHaveBeenCalledOnce();
|
|
261
|
+
expect(interruptBusySessionsAfterRestart).toHaveBeenCalledOnce();
|
|
262
|
+
expect(broadcastUiNotification).toHaveBeenCalledWith(expect.objectContaining({
|
|
263
|
+
kind: 'opencode-restart-interrupted',
|
|
264
|
+
sessionId: 'session-1',
|
|
265
|
+
}));
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('shares concurrent shared recovery attempts', async () => {
|
|
269
|
+
let finishRecovery;
|
|
270
|
+
const recoverShared = vi.fn(() => new Promise((resolve) => {
|
|
271
|
+
finishRecovery = resolve;
|
|
272
|
+
}));
|
|
273
|
+
const onSharedRecovered = vi.fn(async () => {});
|
|
274
|
+
const shared = createOpenCodeLifecycleControls({
|
|
275
|
+
getKind: () => 'shared-local',
|
|
276
|
+
restartLifecycle: vi.fn(),
|
|
277
|
+
triggerLifecycleHealthCheck: vi.fn(),
|
|
278
|
+
recoverShared,
|
|
279
|
+
onSharedRecovered,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const first = shared.triggerHealthCheck();
|
|
283
|
+
const second = shared.triggerHealthCheck();
|
|
284
|
+
await vi.waitFor(() => expect(recoverShared).toHaveBeenCalledOnce());
|
|
285
|
+
finishRecovery();
|
|
286
|
+
await Promise.all([first, second]);
|
|
287
|
+
|
|
288
|
+
expect(recoverShared).toHaveBeenCalledOnce();
|
|
289
|
+
expect(onSharedRecovered).toHaveBeenCalledOnce();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('contains shared health recovery failures and permits a later retry', async () => {
|
|
293
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
294
|
+
const recoverShared = vi.fn()
|
|
295
|
+
.mockRejectedValueOnce(new Error('service unavailable'))
|
|
296
|
+
.mockResolvedValueOnce(undefined);
|
|
297
|
+
const onSharedRecovered = vi.fn(async () => {});
|
|
298
|
+
const shared = createOpenCodeLifecycleControls({
|
|
299
|
+
getKind: () => 'shared-local',
|
|
300
|
+
restartLifecycle: vi.fn(),
|
|
301
|
+
triggerLifecycleHealthCheck: vi.fn(),
|
|
302
|
+
recoverShared,
|
|
303
|
+
onSharedRecovered,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await expect(shared.triggerHealthCheck()).resolves.toBeUndefined();
|
|
307
|
+
await expect(shared.restart()).resolves.toBeUndefined();
|
|
308
|
+
|
|
309
|
+
expect(recoverShared).toHaveBeenCalledTimes(2);
|
|
310
|
+
expect(onSharedRecovered).toHaveBeenCalledOnce();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('preserves managed lifecycle controls', async () => {
|
|
314
|
+
const restartManaged = vi.fn(async () => {});
|
|
315
|
+
const triggerManagedHealthCheck = vi.fn(async () => {});
|
|
316
|
+
const managed = createOpenCodeLifecycleControls({
|
|
317
|
+
getKind: () => 'managed-owned',
|
|
318
|
+
restartLifecycle: restartManaged,
|
|
319
|
+
triggerLifecycleHealthCheck: triggerManagedHealthCheck,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await managed.restart('manual');
|
|
323
|
+
await managed.triggerHealthCheck();
|
|
324
|
+
|
|
325
|
+
expect(restartManaged).toHaveBeenCalledWith('manual');
|
|
326
|
+
expect(triggerManagedHealthCheck).toHaveBeenCalledTimes(1);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('requires a shared runtime for shared-local composition', () => {
|
|
330
|
+
expect(() => createOpenCodeConnectionAdapter({ kind: 'shared-local' })).toThrow(
|
|
331
|
+
'Shared OpenCode connection requires a shared service runtime',
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('reads shared URL and auth from the shared runtime at call time without owning its process', () => {
|
|
336
|
+
let baseUrl = 'http://127.0.0.1:4096';
|
|
337
|
+
let headers = { authorization: 'Basic first' };
|
|
338
|
+
const adapter = createOpenCodeConnectionAdapter({
|
|
339
|
+
kind: 'shared-local',
|
|
340
|
+
sharedRuntime: {
|
|
341
|
+
getBaseUrl: () => baseUrl,
|
|
342
|
+
getHeaders: () => ({ ...headers }),
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(adapter.getOpenCodeConnectionKind()).toBe('shared-local');
|
|
347
|
+
expect(adapter.getOpenCodeBaseUrl()).toBe('http://127.0.0.1:4096');
|
|
348
|
+
expect(adapter.getOpenCodeAuthHeaders()).toEqual({ authorization: 'Basic first' });
|
|
349
|
+
expect(adapter.ownsOpenCodeProcess()).toBe(false);
|
|
350
|
+
|
|
351
|
+
baseUrl = 'http://127.0.0.1:5096';
|
|
352
|
+
headers = { authorization: 'Basic replacement' };
|
|
353
|
+
expect(adapter.getOpenCodeBaseUrl()).toBe('http://127.0.0.1:5096');
|
|
354
|
+
expect(adapter.getOpenCodeAuthHeaders()).toEqual({ authorization: 'Basic replacement' });
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
131
358
|
describe('OpenCode lifecycle', () => {
|
|
132
359
|
it('uses the resolved binary directly on startup and managed restart without an env override', async () => {
|
|
133
360
|
delete process.env.OPENCODE_BINARY;
|
|
@@ -2,10 +2,18 @@ import { readOpenCodeInfo, isSupportedOpenCodeVersion } from './compatibility.js
|
|
|
2
2
|
export const createOpenCodeNetworkRuntime = (deps) => {
|
|
3
3
|
const {
|
|
4
4
|
state,
|
|
5
|
-
|
|
5
|
+
getOpenCodeBaseUrl,
|
|
6
|
+
getOpenCodeAuthHeaders: readOpenCodeAuthHeaders,
|
|
6
7
|
configuredOpenCodeHostname = '127.0.0.1',
|
|
7
8
|
} = deps;
|
|
8
9
|
|
|
10
|
+
const getOpenCodeAuthHeaders = () => Object.fromEntries(
|
|
11
|
+
Object.entries(readOpenCodeAuthHeaders?.() ?? {}).map(([key, value]) => [
|
|
12
|
+
key.toLowerCase() === 'authorization' ? 'Authorization' : key,
|
|
13
|
+
value,
|
|
14
|
+
]),
|
|
15
|
+
);
|
|
16
|
+
|
|
9
17
|
const resolveConnectHostname = () => {
|
|
10
18
|
const raw = typeof configuredOpenCodeHostname === 'string' ? configuredOpenCodeHostname.trim() : '';
|
|
11
19
|
const hostname = raw || '127.0.0.1';
|
|
@@ -83,14 +91,14 @@ export const createOpenCodeNetworkRuntime = (deps) => {
|
|
|
83
91
|
};
|
|
84
92
|
|
|
85
93
|
const buildOpenCodeUrl = (path, prefixOverride) => {
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
const base = getOpenCodeBaseUrl?.() ?? state.openCodeBaseUrl;
|
|
95
|
+
if (!base && !state.openCodePort) {
|
|
96
|
+
throw new Error('OpenCode endpoint is not available');
|
|
88
97
|
}
|
|
89
98
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
90
|
-
const prefix = normalizeApiPrefix(prefixOverride
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
return `${base}${fullPath}`;
|
|
99
|
+
const prefix = normalizeApiPrefix(prefixOverride ?? '');
|
|
100
|
+
const fallback = `http://${resolveConnectHostname()}:${state.openCodePort}`;
|
|
101
|
+
return `${String(base ?? fallback).replace(/\/+$/, '')}${prefix}${normalizedPath}`;
|
|
94
102
|
};
|
|
95
103
|
|
|
96
104
|
const detectOpenCodeApiPrefix = () => {
|
|
@@ -107,6 +115,7 @@ export const createOpenCodeNetworkRuntime = (deps) => {
|
|
|
107
115
|
|
|
108
116
|
return {
|
|
109
117
|
waitForReady,
|
|
118
|
+
getOpenCodeAuthHeaders,
|
|
110
119
|
normalizeApiPrefix,
|
|
111
120
|
setDetectedOpenCodeApiPrefix,
|
|
112
121
|
buildOpenCodeUrl,
|
|
@@ -13,7 +13,8 @@ const createRuntime = (overrides = {}) => createOpenCodeNetworkRuntime({
|
|
|
13
13
|
openCodeApiDetectionTimer: null,
|
|
14
14
|
...overrides.state,
|
|
15
15
|
},
|
|
16
|
-
|
|
16
|
+
getOpenCodeBaseUrl: overrides.getOpenCodeBaseUrl,
|
|
17
|
+
getOpenCodeAuthHeaders: overrides.getOpenCodeAuthHeaders ?? (() => ({})),
|
|
17
18
|
configuredOpenCodeHostname: overrides.configuredOpenCodeHostname,
|
|
18
19
|
});
|
|
19
20
|
|
|
@@ -48,6 +49,33 @@ describe('OpenCode network runtime', () => {
|
|
|
48
49
|
expect(runtime.buildOpenCodeUrl('/provider')).toBe('http://remote.example:4096/provider');
|
|
49
50
|
});
|
|
50
51
|
|
|
52
|
+
it('builds each request from the current shared-service base URL', () => {
|
|
53
|
+
let baseUrl = 'http://127.0.0.1:4096';
|
|
54
|
+
const runtime = createRuntime({
|
|
55
|
+
state: { openCodePort: null },
|
|
56
|
+
getOpenCodeBaseUrl: () => baseUrl,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(runtime.buildOpenCodeUrl('/api/session')).toBe('http://127.0.0.1:4096/api/session');
|
|
60
|
+
baseUrl = 'http://127.0.0.1:5096/';
|
|
61
|
+
expect(runtime.buildOpenCodeUrl('/api/session')).toBe('http://127.0.0.1:5096/api/session');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('normalizes current service auth without dropping additional headers', () => {
|
|
65
|
+
let headers = { authorization: 'Basic first', 'x-service-header': 'one' };
|
|
66
|
+
const runtime = createRuntime({ getOpenCodeAuthHeaders: () => headers });
|
|
67
|
+
|
|
68
|
+
expect(runtime.getOpenCodeAuthHeaders()).toEqual({
|
|
69
|
+
Authorization: 'Basic first',
|
|
70
|
+
'x-service-header': 'one',
|
|
71
|
+
});
|
|
72
|
+
headers = { authorization: 'Basic replacement', 'x-service-header': 'two' };
|
|
73
|
+
expect(runtime.getOpenCodeAuthHeaders()).toEqual({
|
|
74
|
+
Authorization: 'Basic replacement',
|
|
75
|
+
'x-service-header': 'two',
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
51
79
|
it('normalizes wildcard and IPv6 OpenCode bind hosts for local connects', () => {
|
|
52
80
|
expect(createRuntime({ configuredOpenCodeHostname: '0.0.0.0' }).buildOpenCodeUrl('/provider'))
|
|
53
81
|
.toBe('http://127.0.0.1:4096/provider');
|
|
@@ -477,18 +477,15 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
477
477
|
const resolveProxyTarget = () => {
|
|
478
478
|
const runtimeState = getRuntime();
|
|
479
479
|
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
if (resolved) {
|
|
488
|
-
return resolved;
|
|
489
|
-
}
|
|
490
|
-
} catch {
|
|
480
|
+
// The shared-service endpoint can remain authoritative even when it has no
|
|
481
|
+
// explicit port (for example, HTTPS on its default port). Resolve it before
|
|
482
|
+
// consulting the legacy runtime snapshot.
|
|
483
|
+
try {
|
|
484
|
+
const resolved = normalizeProxyTarget(buildOpenCodeUrl('/', ''));
|
|
485
|
+
if (resolved) {
|
|
486
|
+
return resolved;
|
|
491
487
|
}
|
|
488
|
+
} catch {
|
|
492
489
|
}
|
|
493
490
|
|
|
494
491
|
const externalBase = normalizeProxyTarget(runtimeState.openCodeBaseUrl);
|
|
@@ -973,10 +970,14 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
973
970
|
router: () => resolveProxyTarget(),
|
|
974
971
|
on: {
|
|
975
972
|
proxyReq: (proxyReq, req) => {
|
|
976
|
-
//
|
|
973
|
+
// The proxy middleware starts with browser headers. Never let an
|
|
974
|
+
// OpenChamber client credential reach OpenCode, even when the active
|
|
975
|
+
// OpenCode connection itself has no authorization header.
|
|
976
|
+
proxyReq.removeHeader('authorization');
|
|
977
977
|
const authHeaders = getOpenCodeAuthHeaders();
|
|
978
|
-
|
|
979
|
-
proxyReq.
|
|
978
|
+
for (const [key, value] of Object.entries(authHeaders)) {
|
|
979
|
+
proxyReq.removeHeader(key);
|
|
980
|
+
proxyReq.setHeader(key, value);
|
|
980
981
|
}
|
|
981
982
|
|
|
982
983
|
if (req.headers?.['x-opencode-directory-encoding'] === 'uri') {
|
|
@@ -31,6 +31,7 @@ export const createServerStartupRuntime = (dependencies) => {
|
|
|
31
31
|
bindHost,
|
|
32
32
|
startupTunnelRequest,
|
|
33
33
|
onTunnelReady,
|
|
34
|
+
afterListening,
|
|
34
35
|
}) => {
|
|
35
36
|
let activePort = port;
|
|
36
37
|
|
|
@@ -45,6 +46,30 @@ export const createServerStartupRuntime = (dependencies) => {
|
|
|
45
46
|
try {
|
|
46
47
|
const addressInfo = server.address();
|
|
47
48
|
activePort = typeof addressInfo === 'object' && addressInfo ? addressInfo.port : port;
|
|
49
|
+
try {
|
|
50
|
+
await afterListening?.({ activePort, bindHost });
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (typeof process.send === 'function' && process.connected) {
|
|
53
|
+
const startupError = {
|
|
54
|
+
type: 'openchamber:error',
|
|
55
|
+
code: error?.code || 'WEB_STARTUP_ERROR',
|
|
56
|
+
message: error instanceof Error ? error.message : String(error),
|
|
57
|
+
};
|
|
58
|
+
if (error?.url) startupError.url = error.url;
|
|
59
|
+
await new Promise((resolveStartupError) => {
|
|
60
|
+
try {
|
|
61
|
+
process.send(startupError, () => resolveStartupError());
|
|
62
|
+
} catch {
|
|
63
|
+
resolveStartupError();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
await new Promise((resolveClose) => {
|
|
68
|
+
server.close(() => resolveClose());
|
|
69
|
+
server.closeAllConnections?.();
|
|
70
|
+
});
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
48
73
|
|
|
49
74
|
if (typeof process.send === 'function') {
|
|
50
75
|
if (!process.connected) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
1
|
+
import { describe, expect, test, mock } from 'bun:test';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
3
|
import { createServerStartupRuntime } from './server-startup-runtime.js';
|
|
4
4
|
|
|
@@ -7,6 +7,72 @@ import { createServerStartupRuntime } from './server-startup-runtime.js';
|
|
|
7
7
|
* on a single uncaught exception turned every stray socket error into "the
|
|
8
8
|
* instance is unreachable until restarted". Only a sustained storm shuts down.
|
|
9
9
|
*/
|
|
10
|
+
describe('server startup readiness', () => {
|
|
11
|
+
test('reports daemon ready only after listener-dependent startup finishes', async () => {
|
|
12
|
+
const order = [];
|
|
13
|
+
const fakeProcess = {
|
|
14
|
+
connected: true,
|
|
15
|
+
send: mock((message, callback) => {
|
|
16
|
+
order.push(message.type);
|
|
17
|
+
callback();
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
const server = new EventEmitter();
|
|
21
|
+
server.address = () => ({ port: 3901 });
|
|
22
|
+
server.listen = (_port, _host, callback) => callback();
|
|
23
|
+
const runtime = createServerStartupRuntime({
|
|
24
|
+
process: fakeProcess,
|
|
25
|
+
crypto: {},
|
|
26
|
+
server,
|
|
27
|
+
readSettingsFromDiskMigrated: async () => ({}),
|
|
28
|
+
tunnelAuthController: {},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await runtime.startListeningAndMaybeTunnel({
|
|
32
|
+
port: 0,
|
|
33
|
+
bindHost: '127.0.0.1',
|
|
34
|
+
afterListening: async () => order.push('prepared'),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(order).toEqual(['prepared', 'openchamber:ready']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('closes the listener and reports startup failure when listener-dependent startup fails', async () => {
|
|
41
|
+
const sent = [];
|
|
42
|
+
const fakeProcess = {
|
|
43
|
+
connected: true,
|
|
44
|
+
send: mock((message, callback) => {
|
|
45
|
+
sent.push(message);
|
|
46
|
+
callback();
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
const server = new EventEmitter();
|
|
50
|
+
server.address = () => ({ port: 3901 });
|
|
51
|
+
server.listen = (_port, _host, callback) => callback();
|
|
52
|
+
server.close = mock((callback) => callback());
|
|
53
|
+
server.closeAllConnections = mock(() => {});
|
|
54
|
+
const runtime = createServerStartupRuntime({ process: fakeProcess, crypto: {}, server });
|
|
55
|
+
const startupError = Object.assign(new Error('shared service rejected'), {
|
|
56
|
+
code: 'OPENCODE_INCOMPATIBLE',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await expect(runtime.startListeningAndMaybeTunnel({
|
|
60
|
+
port: 0,
|
|
61
|
+
bindHost: '127.0.0.1',
|
|
62
|
+
afterListening: async () => { throw startupError; },
|
|
63
|
+
})).rejects.toBe(startupError);
|
|
64
|
+
|
|
65
|
+
expect(server.close).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(server.closeAllConnections).toHaveBeenCalledTimes(1);
|
|
67
|
+
expect(sent).toEqual([expect.objectContaining({
|
|
68
|
+
type: 'openchamber:error',
|
|
69
|
+
code: 'OPENCODE_INCOMPATIBLE',
|
|
70
|
+
})]);
|
|
71
|
+
expect(sent).not.toContainEqual(expect.objectContaining({ type: 'openchamber:ready' }));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
});
|
|
75
|
+
|
|
10
76
|
describe('uncaught exception policy', () => {
|
|
11
77
|
const setup = () => {
|
|
12
78
|
const fakeProcess = new EventEmitter();
|