@openchambery/web 1.19.20 → 2.0.0-beta.1
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 +13 -11
- package/bin/cli-opencode.test.js +208 -0
- package/bin/cli.js +62 -9
- package/bin/lib/cli-startup.js +105 -2
- package/bin/lib/cli-startup.test.js +165 -0
- package/dist/assets/{AgentAvatar-NT957MuI.js → AgentAvatar-CLWWNu_Q.js} +1 -1
- package/dist/assets/AssistantView-BTCkm8yk.js +3 -0
- package/dist/assets/{DiagramView-C_JfbxNl.js → DiagramView-xuSJTnv7.js} +1 -1
- package/dist/assets/{FadeInOnReveal-CilRl4gc.js → FadeInOnReveal-Ov0D96d0.js} +1 -1
- package/dist/assets/{Icon-i5GiQRLq.js → Icon-DD-BuMfZ.js} +1 -1
- package/dist/assets/MarkdownRenderer-bc_p1H7l.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-B1iUwzOz.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-BPtMj3-X.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
- package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
- package/dist/assets/{MobileShareBridge-CKClHsWN.js → MobileShareBridge-BQlP4J-F.js} +1 -1
- package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
- package/dist/assets/{MultiRunWindow-DiA24bnN.js → MultiRunWindow-ChZGob0v.js} +1 -1
- package/dist/assets/{OnboardingScreen-BGRLk4Wi.js → OnboardingScreen-DtQJxZG-.js} +1 -1
- package/dist/assets/{OverlayScrollbar-NuQI58XD.js → OverlayScrollbar-pMwxaCca.js} +1 -1
- package/dist/assets/SettingsView-B5I0KDTZ.js +25 -0
- package/dist/assets/{SettingsWindow-XlzRSS7E.js → SettingsWindow-DfnsFa8y.js} +1 -1
- package/dist/assets/{TerminalView-DdIaaLba.js → TerminalView-Dh9MWCpS.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BbwuLdXU.js → ToolOutputDialog-BejmjrRk.js} +1 -1
- package/dist/assets/{appThemeRegistry-ChzfD7mc.js → appThemeRegistry-COGfxTFV.js} +1 -1
- package/dist/assets/{button-DtzFDvij.js → button-D-rATN7w.js} +1 -1
- package/dist/assets/{collapsible-D-nzhcyF.js → collapsible-BXRd5_Hr.js} +1 -1
- package/dist/assets/{context-menu-BzVSzHLG.js → context-menu-jGtH6fR9.js} +1 -1
- package/dist/assets/{diagram-2YWalSb5.js → diagram-DQf7Y_uR.js} +1 -1
- package/dist/assets/{dialog-DGV7DsZC.js → dialog-bfbvR4VH.js} +1 -1
- package/dist/assets/es-c52LTHHe.js +15 -0
- package/dist/assets/{fileMentionTouchSelection-DyjmxUEN.js → fileMentionTouchSelection-BvrNCroG.js} +1 -1
- package/dist/assets/{fr-DsBZeN8Y.js → fr-PisFbUE8.js} +3 -3
- package/dist/assets/gitApi-ButwzQ0N.js +9 -0
- package/dist/assets/{input-CbB2HdLg.js → input-8r6F_Fza.js} +1 -1
- package/dist/assets/insertionBoundaries-DGs7S5zv.js +1 -0
- package/dist/assets/ja-C8Ikun3Z.js +15 -0
- package/dist/assets/ko-ClFys5o2.js +15 -0
- package/dist/assets/main-CCE_4y5F.js +2 -0
- package/dist/assets/main-Dn9rpsgl.js +169 -0
- package/dist/assets/{markdownHeightCache-C9ym9Tsb.js → markdownHeightCache-1Qo6y6IJ.js} +1 -1
- package/dist/assets/miniChat-BpjxVqgg.js +2 -0
- package/dist/assets/mobile-nMJCaNq1.js +2 -0
- package/dist/assets/{multirun-8fkF0Ji_.js → multirun-BvSG94mW.js} +1 -1
- package/dist/assets/pl-CnaKWN5F.js +15 -0
- package/dist/assets/platform-CWTIp02q.js +4 -0
- package/dist/assets/{projectMeta--y6tyjrv.js → projectMeta-DOe49dM_.js} +2 -2
- package/dist/assets/pt-BR-s8OpfRI6.js +15 -0
- package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +2 -0
- package/dist/assets/renderMobileApp-y-S8y8v_.js +28 -0
- package/dist/assets/{runtimeConfig-DYWtBrkW.js → runtimeConfig-BMvqa5we.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-BSWg99jc.js → runtimeEndpointReset-B6BxhvR0.js} +5 -5
- package/dist/assets/{sessionChangeSummary-BwupJF-Q.js → sessionChangeSummary-VVBlEXuh.js} +1 -1
- package/dist/assets/{sessionLookup-DP2X1qRR.js → sessionLookup-gh-hVRIc.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-CfgEJfB0.js → sortable-tabs-strip-CudoSgV4.js} +1 -1
- package/dist/assets/{src-DZ60kHBP.css → src-BzX5UI-c.css} +1 -1
- package/dist/assets/{store-B6dImdlY.js → store-DJczmczv.js} +4 -4
- package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-DiF6JmBP.js} +1 -1
- package/dist/assets/{textarea-BnD799LW.js → textarea-3XR8sUfy.js} +1 -1
- package/dist/assets/{tooltip-Djn7MSOX.js → tooltip-CQvpK-UV.js} +1 -1
- package/dist/assets/uk-CaX0iqkB.js +15 -0
- package/dist/assets/{useAppFontEffects-DWLxpWlS.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
- package/dist/assets/{useDesktopWindowControlsLayout-BGPR_rq6.js → useDesktopWindowControlsLayout-B1GX59-L.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-CbV9_r1s.js → useEffectiveDirectory-CN6JoCFw.js} +1 -1
- package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-DcbXQOkc.js} +1 -1
- package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +414 -0
- package/dist/assets/{useSessionAutoCleanup-Dnors7hB.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-DQqqrOBU.js → useWorkerHighlightedLines-Bqn4AiBL.js} +1 -1
- package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
- package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
- package/dist/assets/zh-CN-20MYEjha.js +15 -0
- package/dist/assets/{zh-TW-BsOQbrwT.js → zh-TW-DiN04qTw.js} +3 -3
- package/dist/index.html +6 -6
- package/dist/mini-chat.html +6 -6
- package/dist/mobile.html +6 -6
- package/package.json +2 -2
- package/server/index.js +107 -0
- package/server/lib/assistants/DOCUMENTATION.md +18 -18
- package/server/lib/assistants/assign.js +2 -1
- package/server/lib/assistants/contact-store.js +37 -19
- package/server/lib/assistants/projection-history.test.js +207 -0
- package/server/lib/assistants/routes.js +5 -5
- package/server/lib/assistants/service.js +493 -123
- package/server/lib/assistants/service.test.js +203 -133
- package/server/lib/conversations/DOCUMENTATION.md +4 -4
- package/server/lib/conversations/service.js +86 -57
- package/server/lib/conversations/service.test.js +116 -100
- package/server/lib/event-stream/global-hub.js +2 -1
- package/server/lib/llm/DOCUMENTATION.md +71 -84
- package/server/lib/llm/catalog.js +111 -45
- package/server/lib/llm/catalog.test.js +121 -59
- package/server/lib/llm/completions.js +37 -12
- package/server/lib/llm/completions.test.js +45 -4
- package/server/lib/llm/generate.js +421 -450
- package/server/lib/llm/generate.test.js +553 -487
- package/server/lib/llm/routes.js +6 -4
- package/server/lib/message-queue/DOCUMENTATION.md +5 -5
- package/server/lib/message-queue/opencode-adapter.js +191 -104
- package/server/lib/message-queue/opencode-adapter.test.js +328 -58
- package/server/lib/message-queue/runtime.test.js +6 -6
- package/server/lib/message-queue/worker.js +7 -1
- package/server/lib/opencode/DOCUMENTATION.md +28 -5
- package/server/lib/opencode/config-entity-routes.js +84 -13
- package/server/lib/opencode/config-entity-routes.test.js +41 -10
- package/server/lib/opencode/ensure-cli.js +239 -0
- package/server/lib/opencode/ensure-cli.test.js +119 -0
- package/server/lib/opencode/env-runtime.js +193 -110
- package/server/lib/opencode/env-runtime.test.js +221 -17
- package/server/lib/opencode/feature-routes-runtime.js +37 -1
- package/server/lib/opencode/lifecycle.js +204 -70
- package/server/lib/opencode/lifecycle.test.js +376 -6
- package/server/lib/opencode/network-runtime.js +28 -16
- package/server/lib/opencode/network-runtime.test.js +68 -4
- package/server/lib/opencode/opencode2-pin.js +139 -0
- package/server/lib/opencode/opencode2-pin.test.js +87 -0
- package/server/lib/opencode/opencode2-sidecar.e2e.test.js +203 -0
- package/server/lib/opencode/plugin-compatibility.js +15 -0
- package/server/lib/opencode/plugin-compatibility.test.js +21 -0
- package/server/lib/opencode/plugins.js +4 -3
- package/server/lib/opencode/plugins.test.js +12 -0
- package/server/lib/opencode/proxy.js +153 -16
- package/server/lib/opencode/proxy.test.js +120 -0
- package/server/lib/opencode/routes.js +25 -32
- package/server/lib/opencode/routes.test.js +51 -52
- package/server/lib/opencode/server-utils-runtime.js +2 -0
- package/server/lib/opencode/skill-routes.js +6 -7
- package/server/lib/opencode/skill-routes.test.js +36 -5
- package/server/lib/opencode/v1-migration-gate.js +157 -0
- package/server/lib/opencode/v1-migration-gate.test.js +111 -0
- package/server/lib/opencode/v2-client.js +49 -0
- package/server/lib/opencode/watcher.js +2 -1
- package/server/lib/opencode/watcher.test.js +1 -1
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +71 -39
- package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
- package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
- package/server/lib/scheduled-tasks/routes.js +27 -0
- package/server/lib/scheduled-tasks/routes.test.js +94 -0
- package/server/lib/scheduled-tasks/runtime.js +331 -308
- package/server/lib/scheduled-tasks/runtime.test.js +530 -318
- package/server/lib/session-goal/DOCUMENTATION.md +65 -14
- package/server/lib/session-goal/capability.js +57 -0
- package/server/lib/session-goal/capability.test.js +33 -0
- package/server/lib/session-goal/routes.js +231 -2
- package/server/lib/session-goal/routes.test.js +211 -0
- package/server/lib/session-goal/runtime.js +46 -4
- package/server/lib/session-goal/runtime.test.js +62 -1
- package/server/lib/session-index/sync-runtime.js +20 -17
- package/server/lib/session-index/sync-runtime.test.js +9 -8
- package/server/lib/session-metadata/DOCUMENTATION.md +41 -0
- package/server/lib/session-metadata/routes.js +94 -0
- package/server/lib/session-metadata/routes.test.js +111 -0
- package/server/lib/session-metadata/session-metadata-store.js +235 -0
- package/server/lib/session-metadata/session-metadata-store.test.js +184 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +3 -3
- package/server/lib/session-turn-pages/reconcile.service.js +1 -1
- package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
- package/server/lib/session-turn-pages/routes.js +204 -71
- package/server/lib/session-turn-pages/routes.test.js +285 -0
- package/server/lib/session-turn-pages/service.js +6 -2
- package/server/lib/session-turn-pages/service.test.js +34 -0
- package/server/lib/small-model/DOCUMENTATION.md +5 -4
- package/server/lib/small-model/catalog.js +56 -6
- package/server/lib/small-model/catalog.test.js +47 -0
- package/server/lib/small-model/index.js +3 -3
- package/server/lib/small-model/opencode-session.js +50 -206
- package/server/lib/small-model/opencode-session.test.js +58 -88
- package/server/opencode-proxy.test.js +61 -12
- package/dist/assets/AssistantView-BjvfkSRS.js +0 -3
- package/dist/assets/MarkdownRenderer-Cg82L3fN.js +0 -2
- package/dist/assets/MobileDetailNavigation-s_vvUD_F.js +0 -1
- package/dist/assets/MobileSurface-DHpXHbJp.js +0 -2
- package/dist/assets/SettingsView-l0J7pfYo.js +0 -25
- package/dist/assets/es-rqMiRP7-.js +0 -15
- package/dist/assets/gitApi-CHpibtno.js +0 -7
- package/dist/assets/insertionBoundaries-Yd1Lorjw.js +0 -1
- package/dist/assets/ja-Bgr8-dOg.js +0 -15
- package/dist/assets/ko-B_Gxh-0l.js +0 -15
- package/dist/assets/main-BoRXxmB3.js +0 -2
- package/dist/assets/main-DCDcdV5o.js +0 -169
- package/dist/assets/miniChat-12fxk5KR.js +0 -2
- package/dist/assets/mobile-CkZem9Ad.js +0 -2
- package/dist/assets/pl-DW2XJGC4.js +0 -15
- package/dist/assets/platform-0w_Q9_ji.js +0 -2
- package/dist/assets/pt-BR-CIsanf4T.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-COFc0ZDJ.js +0 -2
- package/dist/assets/renderMobileApp-BKzx7JL3.js +0 -28
- package/dist/assets/uk-BPmPEUYJ.js +0 -15
- package/dist/assets/useMobileNavigationStore-DrB_awgk.js +0 -413
- package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
- package/dist/assets/zh-CN-R8-mDcIp.js +0 -15
|
@@ -21,6 +21,20 @@ import {
|
|
|
21
21
|
stripIncludeReasoningParam,
|
|
22
22
|
} from '../event-stream/reasoning-projection.js';
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* v2 protocol mounts every JSON/SSE route under `/api`
|
|
26
|
+
* (`@opencode-ai/protocol` groups). Host traffic is already `/api/*`.
|
|
27
|
+
* Express `app.use('/api', …)` strips that prefix from `req.url`; put it back
|
|
28
|
+
* before talking to opencode2. Passing a root `/session` path to v2 returns
|
|
29
|
+
* the bundled Web UI HTML (`text/html`) and the SDK throws UnsupportedContentType.
|
|
30
|
+
*/
|
|
31
|
+
export const toUpstreamOpenCodeApiPath = (requestUrl) => {
|
|
32
|
+
const raw = typeof requestUrl === 'string' ? requestUrl : '';
|
|
33
|
+
if (!raw || raw === '/') return '/api';
|
|
34
|
+
if (raw.startsWith('/api/') || raw === '/api' || raw.startsWith('/api?')) return raw;
|
|
35
|
+
return raw.startsWith('/') ? `/api${raw}` : `/api/${raw}`;
|
|
36
|
+
};
|
|
37
|
+
|
|
24
38
|
export const createDirectoryQueryCanonicalizer = ({ realpath, ...cacheOptions } = {}) => {
|
|
25
39
|
const realpathCache = createRealpathCache({ fallbackOnError: true, realpath, ...cacheOptions });
|
|
26
40
|
|
|
@@ -183,10 +197,19 @@ const sanitizeSessionListItem = (session) => {
|
|
|
183
197
|
};
|
|
184
198
|
|
|
185
199
|
const sanitizeSessionListPayload = (payload) => {
|
|
186
|
-
if (
|
|
187
|
-
return payload;
|
|
200
|
+
if (Array.isArray(payload)) {
|
|
201
|
+
return payload.map((session) => sanitizeSessionListItem(session));
|
|
188
202
|
}
|
|
189
|
-
|
|
203
|
+
if (payload && typeof payload === 'object' && Array.isArray(payload.data)) {
|
|
204
|
+
return { ...payload, data: payload.data.map((session) => sanitizeSessionListItem(session)) };
|
|
205
|
+
}
|
|
206
|
+
return payload;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const sessionListRecords = (payload) => {
|
|
210
|
+
if (Array.isArray(payload)) return payload;
|
|
211
|
+
if (payload && typeof payload === 'object' && Array.isArray(payload.data)) return payload.data;
|
|
212
|
+
return null;
|
|
190
213
|
};
|
|
191
214
|
|
|
192
215
|
export const isInteractiveSessionRequest = (method, requestUrl) => {
|
|
@@ -226,8 +249,56 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
226
249
|
ensureOpenCodeApiPrefix,
|
|
227
250
|
onInteractiveSessionRequest,
|
|
228
251
|
onSessionTurnAdmission,
|
|
252
|
+
// OpenCode 2.x has no session-metadata update route. When the Host store is
|
|
253
|
+
// wired, fold its per-session metadata onto list/detail responses so
|
|
254
|
+
// clients keep reading `session.metadata` where they always did.
|
|
255
|
+
getStoredSessionMetadata = null,
|
|
229
256
|
} = deps;
|
|
230
257
|
|
|
258
|
+
/**
|
|
259
|
+
* `{ [sessionID]: metadata }` for the current instance, or `null` when the
|
|
260
|
+
* store cannot answer. `null` means "unknown", and an unknown answer leaves
|
|
261
|
+
* the upstream record untouched — never rewrite a session as empty metadata.
|
|
262
|
+
*/
|
|
263
|
+
const readStoredSessionMetadata = async () => {
|
|
264
|
+
if (typeof getStoredSessionMetadata !== 'function') return null;
|
|
265
|
+
try {
|
|
266
|
+
const stored = await getStoredSessionMetadata();
|
|
267
|
+
return stored && typeof stored === 'object' ? stored : null;
|
|
268
|
+
} catch (error) {
|
|
269
|
+
console.warn('[proxy] session metadata unavailable:', error?.message ?? error);
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* OpenChamber's stored metadata wins per key: OpenCode only ever saw what was
|
|
276
|
+
* set at create time, and everything written since lives on our side.
|
|
277
|
+
*/
|
|
278
|
+
const withStoredMetadata = (session, stored) => {
|
|
279
|
+
if (!session || typeof session !== 'object' || typeof session.id !== 'string') return session;
|
|
280
|
+
const ours = stored[session.id];
|
|
281
|
+
if (!ours || typeof ours !== 'object' || Array.isArray(ours)) return session;
|
|
282
|
+
const theirs = session.metadata && typeof session.metadata === 'object' && !Array.isArray(session.metadata)
|
|
283
|
+
? session.metadata
|
|
284
|
+
: {};
|
|
285
|
+
return { ...session, metadata: { ...theirs, ...ours } };
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const overlaySession = (session, stored) => {
|
|
289
|
+
if (!stored) return session;
|
|
290
|
+
return withStoredMetadata(session, stored);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const overlayOwnedStateOnList = async (payload) => {
|
|
294
|
+
const records = sessionListRecords(payload);
|
|
295
|
+
if (!records) return payload;
|
|
296
|
+
const stored = await readStoredSessionMetadata();
|
|
297
|
+
if (!stored) return payload;
|
|
298
|
+
const overlaid = records.map((session) => overlaySession(session, stored));
|
|
299
|
+
return Array.isArray(payload) ? overlaid : { ...payload, data: overlaid };
|
|
300
|
+
};
|
|
301
|
+
|
|
231
302
|
if (app.get('opencodeProxyConfigured')) {
|
|
232
303
|
return;
|
|
233
304
|
}
|
|
@@ -419,7 +490,7 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
419
490
|
try {
|
|
420
491
|
// Strip includeReasoning before building the upstream OpenCode path.
|
|
421
492
|
const strippedUrl = stripIncludeReasoningParam(requestUrl);
|
|
422
|
-
const upstreamPath =
|
|
493
|
+
const upstreamPath = toUpstreamOpenCodeApiPath(strippedUrl);
|
|
423
494
|
const headers = normalizeForwardedDirectoryHeaders(
|
|
424
495
|
collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders())
|
|
425
496
|
);
|
|
@@ -625,8 +696,7 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
625
696
|
const requestUrl = typeof req.originalUrl === 'string' && req.originalUrl.length > 0
|
|
626
697
|
? req.originalUrl
|
|
627
698
|
: (typeof req.url === 'string' ? req.url : '');
|
|
628
|
-
|
|
629
|
-
return canonicalizeDirectoryQuery(upstreamPathRaw);
|
|
699
|
+
return canonicalizeDirectoryQuery(toUpstreamOpenCodeApiPath(requestUrl));
|
|
630
700
|
};
|
|
631
701
|
|
|
632
702
|
const forwardSanitizedSessionListRequest = async (req, res, next, logLabel) => {
|
|
@@ -643,14 +713,14 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
643
713
|
return;
|
|
644
714
|
}
|
|
645
715
|
|
|
646
|
-
if (result.parseError || !
|
|
716
|
+
if (result.parseError || !sessionListRecords(result.payload)) {
|
|
647
717
|
res.setHeader('content-type', result.contentType);
|
|
648
718
|
res.end(result.bodyText);
|
|
649
719
|
return;
|
|
650
720
|
}
|
|
651
721
|
|
|
652
722
|
res.setHeader('content-type', result.contentType);
|
|
653
|
-
res.json(sanitizeSessionListPayload(result.payload));
|
|
723
|
+
res.json(await overlayOwnedStateOnList(sanitizeSessionListPayload(result.payload)));
|
|
654
724
|
} catch (error) {
|
|
655
725
|
if (isAbortError(error)) {
|
|
656
726
|
return;
|
|
@@ -735,7 +805,7 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
735
805
|
const READINESS_HOLD_POLL_MS = 75;
|
|
736
806
|
const READINESS_HOLD_MAX_MS = 6000;
|
|
737
807
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
738
|
-
const
|
|
808
|
+
const isProcessWarmingUp = (runtimeState) => {
|
|
739
809
|
const waitElapsed = runtimeState.openCodeNotReadySince === 0 ? 0 : Date.now() - runtimeState.openCodeNotReadySince;
|
|
740
810
|
return (
|
|
741
811
|
(!runtimeState.isOpenCodeReady && (runtimeState.openCodeNotReadySince === 0 || waitElapsed < OPEN_CODE_READY_GRACE_MS)) ||
|
|
@@ -744,6 +814,18 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
744
814
|
);
|
|
745
815
|
};
|
|
746
816
|
|
|
817
|
+
// Process warmup keeps the existing grace window. V1 migration is separate:
|
|
818
|
+
// admitTranscript=false holds with no grace until the gate admits or the
|
|
819
|
+
// client cancels. After grace, a still-running migration must not next()
|
|
820
|
+
// an empty session list through as an authoritative success.
|
|
821
|
+
const isMigrationBlockingTranscript = (runtimeState) => (
|
|
822
|
+
runtimeState?.v1Migration != null && runtimeState.v1Migration.admitTranscript !== true
|
|
823
|
+
);
|
|
824
|
+
|
|
825
|
+
const shouldHoldProxiedRequest = (runtimeState) => (
|
|
826
|
+
isProcessWarmingUp(runtimeState) || isMigrationBlockingTranscript(runtimeState)
|
|
827
|
+
);
|
|
828
|
+
|
|
747
829
|
app.use('/api', async (req, res, next) => {
|
|
748
830
|
if (
|
|
749
831
|
req.path.startsWith('/themes/custom') ||
|
|
@@ -758,18 +840,22 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
758
840
|
return next();
|
|
759
841
|
}
|
|
760
842
|
|
|
761
|
-
if (!
|
|
843
|
+
if (!shouldHoldProxiedRequest(getRuntime())) {
|
|
762
844
|
return next();
|
|
763
845
|
}
|
|
764
846
|
|
|
765
|
-
const
|
|
766
|
-
while (
|
|
847
|
+
const processDeadline = Date.now() + Math.min(OPEN_CODE_READY_GRACE_MS, READINESS_HOLD_MAX_MS);
|
|
848
|
+
while (true) {
|
|
767
849
|
// Client gave up (closed/aborted) — stop holding.
|
|
768
850
|
if (res.writableEnded || req.aborted) return;
|
|
769
|
-
|
|
770
|
-
if (!
|
|
851
|
+
const runtimeState = getRuntime();
|
|
852
|
+
if (!shouldHoldProxiedRequest(runtimeState)) {
|
|
771
853
|
return next();
|
|
772
854
|
}
|
|
855
|
+
if (!isMigrationBlockingTranscript(runtimeState) && Date.now() >= processDeadline) {
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
await sleep(READINESS_HOLD_POLL_MS);
|
|
773
859
|
}
|
|
774
860
|
|
|
775
861
|
if (!res.headersSent) {
|
|
@@ -852,7 +938,7 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
852
938
|
return bTime - aTime;
|
|
853
939
|
});
|
|
854
940
|
console.log(`[SessionMerge] ${globalSessions?.length || 0} global + ${extraSessions.length} extra = ${merged.length} total`);
|
|
855
|
-
return res.json(sanitizeSessionListPayload(merged));
|
|
941
|
+
return res.json(await overlayOwnedStateOnList(sanitizeSessionListPayload(merged)));
|
|
856
942
|
} catch (error) {
|
|
857
943
|
console.log(`[SessionMerge] Error: ${error.message}`);
|
|
858
944
|
return res.status(500).json({ error: error.message || 'Failed to merge Windows sessions' });
|
|
@@ -864,6 +950,55 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
864
950
|
return forwardSanitizedSessionListRequest(req, res, next, 'session.list');
|
|
865
951
|
});
|
|
866
952
|
|
|
953
|
+
// One session: the same metadata overlay as the list, so a detail read agrees
|
|
954
|
+
// with the list it came from. Everything else about the record is forwarded
|
|
955
|
+
// untouched. Without a store, fall through to the generic proxy.
|
|
956
|
+
app.get('/api/session/:sessionID', async (req, res, next) => {
|
|
957
|
+
if (typeof getStoredSessionMetadata !== 'function') return next();
|
|
958
|
+
// Nested routes (message, children, …) are registered separately; Express
|
|
959
|
+
// still matches this pattern for bare session ids only when no further
|
|
960
|
+
// segment is present — but be defensive if a child path slips through.
|
|
961
|
+
if (req.path.includes('/message') || String(req.params.sessionID || '').includes('/')) {
|
|
962
|
+
return next();
|
|
963
|
+
}
|
|
964
|
+
try {
|
|
965
|
+
const upstreamPath = await getRequestUpstreamPath(req);
|
|
966
|
+
const result = await fetchSessionListPayload(upstreamPath, { req });
|
|
967
|
+
|
|
968
|
+
res.status(result.upstream.status);
|
|
969
|
+
applyForwardProxyResponseHeaders(result.upstream.headers, res);
|
|
970
|
+
res.setHeader('content-type', result.contentType);
|
|
971
|
+
|
|
972
|
+
const record = result.isJson && !result.parseError ? result.payload : null;
|
|
973
|
+
const session = record && typeof record === 'object' && !Array.isArray(record)
|
|
974
|
+
? (record.data && typeof record.data === 'object' && !Array.isArray(record.data) ? record.data : record)
|
|
975
|
+
: null;
|
|
976
|
+
if (!session || typeof session.id !== 'string') {
|
|
977
|
+
res.end(result.bodyText);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const stored = await readStoredSessionMetadata();
|
|
982
|
+
if (!stored) {
|
|
983
|
+
res.end(result.bodyText);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const overlaid = overlaySession(session, stored);
|
|
988
|
+
res.json(record.data && typeof record.data === 'object' && !Array.isArray(record.data)
|
|
989
|
+
? { ...record, data: overlaid }
|
|
990
|
+
: overlaid);
|
|
991
|
+
} catch (error) {
|
|
992
|
+
if (isAbortError(error)) return;
|
|
993
|
+
console.error('[proxy] OpenCode session.get proxy error:', error?.message ?? error);
|
|
994
|
+
if (!res.headersSent) {
|
|
995
|
+
res.status(503).json({ error: 'OpenCode service unavailable' });
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
next(error);
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
|
|
867
1002
|
// Official session.messages list (not exact message — that is owned by
|
|
868
1003
|
// session-turn-pages and registered before this proxy).
|
|
869
1004
|
app.get('/api/session/:sessionID/message', (req, res, next) => {
|
|
@@ -881,7 +1016,9 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
881
1016
|
const apiProxy = createProxyMiddleware({
|
|
882
1017
|
target: resolveProxyTarget(),
|
|
883
1018
|
changeOrigin: true,
|
|
884
|
-
|
|
1019
|
+
// Express already stripped the `/api` mount. Restore it so v2 receives
|
|
1020
|
+
// `/api/agent` rather than `/agent` (which serves the Web UI HTML).
|
|
1021
|
+
pathRewrite: (pathValue) => toUpstreamOpenCodeApiPath(pathValue),
|
|
885
1022
|
timeout: PROXY_REQUEST_TIMEOUT_MS,
|
|
886
1023
|
proxyTimeout: PROXY_REQUEST_TIMEOUT_MS,
|
|
887
1024
|
// Dynamic target — port can change after restart
|
|
@@ -8,8 +8,21 @@ import {
|
|
|
8
8
|
normalizeForwardedDirectoryHeaders,
|
|
9
9
|
registerOpenCodeProxy,
|
|
10
10
|
resolveSessionTurnAdmissionRequest,
|
|
11
|
+
toUpstreamOpenCodeApiPath,
|
|
11
12
|
} from './proxy.js';
|
|
12
13
|
|
|
14
|
+
describe('toUpstreamOpenCodeApiPath', () => {
|
|
15
|
+
it('keeps v2 /api paths and restores the prefix Express strips at the /api mount', () => {
|
|
16
|
+
expect(toUpstreamOpenCodeApiPath('/api/agent')).toBe('/api/agent');
|
|
17
|
+
expect(toUpstreamOpenCodeApiPath('/api/session?directory=/repo')).toBe('/api/session?directory=/repo');
|
|
18
|
+
expect(toUpstreamOpenCodeApiPath('/api')).toBe('/api');
|
|
19
|
+
expect(toUpstreamOpenCodeApiPath('/agent')).toBe('/api/agent');
|
|
20
|
+
expect(toUpstreamOpenCodeApiPath('/session/ses_1/message')).toBe('/api/session/ses_1/message');
|
|
21
|
+
expect(toUpstreamOpenCodeApiPath('/')).toBe('/api');
|
|
22
|
+
expect(toUpstreamOpenCodeApiPath('')).toBe('/api');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
13
26
|
const originalFetch = globalThis.fetch;
|
|
14
27
|
|
|
15
28
|
afterEach(() => {
|
|
@@ -300,3 +313,110 @@ describe('registerOpenCodeProxy reasoning projection routes', () => {
|
|
|
300
313
|
expect(res.body[0].parts[0].text).toBe('keep-me');
|
|
301
314
|
});
|
|
302
315
|
});
|
|
316
|
+
|
|
317
|
+
describe('registerOpenCodeProxy session metadata overlay', () => {
|
|
318
|
+
const mountProxyWithMetadata = (getStoredSessionMetadata) => {
|
|
319
|
+
const app = express();
|
|
320
|
+
registerOpenCodeProxy(app, {
|
|
321
|
+
fs: { promises: { realpath: async (v) => v } },
|
|
322
|
+
os: {},
|
|
323
|
+
path: {},
|
|
324
|
+
OPEN_CODE_READY_GRACE_MS: 1000,
|
|
325
|
+
LONG_REQUEST_TIMEOUT_MS: 60_000,
|
|
326
|
+
SSE_UPSTREAM_CONNECT_TIMEOUT_MS: 5_000,
|
|
327
|
+
SSE_UPSTREAM_STALL_TIMEOUT_MS: 5_000,
|
|
328
|
+
getRuntime: readyRuntime,
|
|
329
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
330
|
+
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
|
|
331
|
+
ensureOpenCodeApiPrefix: () => {},
|
|
332
|
+
getStoredSessionMetadata,
|
|
333
|
+
});
|
|
334
|
+
return app;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
it('merges Host store metadata onto session list and detail (store keys win)', async () => {
|
|
338
|
+
globalThis.fetch = vi.fn(async (url) => {
|
|
339
|
+
const path = String(url).split('?')[0];
|
|
340
|
+
if (path.endsWith('/api/session') || path.endsWith('/session')) {
|
|
341
|
+
return {
|
|
342
|
+
ok: true,
|
|
343
|
+
status: 200,
|
|
344
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
345
|
+
text: async () => JSON.stringify([
|
|
346
|
+
{
|
|
347
|
+
id: 'ses_1',
|
|
348
|
+
title: 'Alpha',
|
|
349
|
+
metadata: { fromOpenCode: true, shared: 'theirs' },
|
|
350
|
+
},
|
|
351
|
+
{ id: 'ses_2', title: 'Beta' },
|
|
352
|
+
]),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
if (path.includes('/session/ses_1')) {
|
|
356
|
+
return {
|
|
357
|
+
ok: true,
|
|
358
|
+
status: 200,
|
|
359
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
360
|
+
text: async () => JSON.stringify({
|
|
361
|
+
id: 'ses_1',
|
|
362
|
+
title: 'Alpha',
|
|
363
|
+
metadata: { fromOpenCode: true, shared: 'theirs' },
|
|
364
|
+
}),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
ok: false,
|
|
369
|
+
status: 404,
|
|
370
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
371
|
+
text: async () => JSON.stringify({ error: 'not found' }),
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const app = mountProxyWithMetadata(async () => ({
|
|
376
|
+
ses_1: { openchamber: { goal: { status: 'active' } }, shared: 'ours' },
|
|
377
|
+
}));
|
|
378
|
+
|
|
379
|
+
const list = await request(app).get('/api/session');
|
|
380
|
+
expect(list.status).toBe(200);
|
|
381
|
+
expect(list.body[0]).toMatchObject({
|
|
382
|
+
id: 'ses_1',
|
|
383
|
+
metadata: {
|
|
384
|
+
fromOpenCode: true,
|
|
385
|
+
shared: 'ours',
|
|
386
|
+
openchamber: { goal: { status: 'active' } },
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
expect(list.body[1].metadata).toBeUndefined();
|
|
390
|
+
|
|
391
|
+
const detail = await request(app).get('/api/session/ses_1');
|
|
392
|
+
expect(detail.status).toBe(200);
|
|
393
|
+
expect(detail.body).toMatchObject({
|
|
394
|
+
id: 'ses_1',
|
|
395
|
+
metadata: {
|
|
396
|
+
fromOpenCode: true,
|
|
397
|
+
shared: 'ours',
|
|
398
|
+
openchamber: { goal: { status: 'active' } },
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('leaves upstream records untouched when the store throws', async () => {
|
|
404
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
405
|
+
globalThis.fetch = vi.fn(async () => ({
|
|
406
|
+
ok: true,
|
|
407
|
+
status: 200,
|
|
408
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
409
|
+
text: async () => JSON.stringify([
|
|
410
|
+
{ id: 'ses_1', title: 'Alpha', metadata: { fromOpenCode: true } },
|
|
411
|
+
]),
|
|
412
|
+
}));
|
|
413
|
+
|
|
414
|
+
const app = mountProxyWithMetadata(async () => {
|
|
415
|
+
throw new Error('store down');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const list = await request(app).get('/api/session');
|
|
419
|
+
expect(list.status).toBe(200);
|
|
420
|
+
expect(list.body[0]).toEqual({ id: 'ses_1', title: 'Alpha', metadata: { fromOpenCode: true } });
|
|
421
|
+
});
|
|
422
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createProjectIdFromPath } from '../projects/project-id.js';
|
|
2
2
|
import { projectBootstrapSettingsResponse } from './settings-helpers.js';
|
|
3
|
+
import { PINNED_OPENCODE2_VERSION, isOpenCode1xVersion, resolveOpenCode2UpgradeTarget } from './opencode2-pin.js';
|
|
3
4
|
import fs from 'fs';
|
|
4
5
|
import os from 'os';
|
|
5
6
|
import path from 'path';
|
|
@@ -22,6 +23,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|
|
22
23
|
buildOpenCodeUrl,
|
|
23
24
|
getOpenCodeAuthHeaders,
|
|
24
25
|
onSettingsPersisted,
|
|
26
|
+
getIsExternalOpenCode = () => false,
|
|
25
27
|
} = dependencies;
|
|
26
28
|
|
|
27
29
|
let authLibrary = null;
|
|
@@ -50,7 +52,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|
|
50
52
|
};
|
|
51
53
|
|
|
52
54
|
const readOpenCodeCurrentVersion = async () => {
|
|
53
|
-
const healthResponse = await fetch(buildOpenCodeUrl('/
|
|
55
|
+
const healthResponse = await fetch(buildOpenCodeUrl('/api/info', ''), {
|
|
54
56
|
method: 'GET',
|
|
55
57
|
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
|
56
58
|
});
|
|
@@ -183,37 +185,30 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|
|
183
185
|
|
|
184
186
|
app.post('/api/opencode/upgrade', async (req, res) => {
|
|
185
187
|
try {
|
|
186
|
-
if (
|
|
188
|
+
if (getIsExternalOpenCode()) {
|
|
187
189
|
return res.status(409).json({
|
|
188
190
|
success: false,
|
|
189
|
-
error: '
|
|
191
|
+
error: 'This is your own OpenCode serve. OpenChamber will not upgrade or restart it.',
|
|
190
192
|
});
|
|
191
193
|
}
|
|
192
194
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return res.status(400).json({
|
|
195
|
+
if (await isBundledOpenCodeBinaryActive()) {
|
|
196
|
+
return res.status(409).json({
|
|
196
197
|
success: false,
|
|
197
|
-
error: 'OpenCode
|
|
198
|
+
error: 'OpenCode is bundled with OpenChamber Desktop and cannot be upgraded separately.',
|
|
198
199
|
});
|
|
199
200
|
}
|
|
200
201
|
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
...getOpenCodeAuthHeaders(),
|
|
207
|
-
},
|
|
208
|
-
body: JSON.stringify({ target }),
|
|
209
|
-
});
|
|
210
|
-
const payload = await response.json().catch(() => null);
|
|
211
|
-
if (!response.ok) {
|
|
212
|
-
return res.status(response.status).json({
|
|
202
|
+
const rawTarget = typeof req.body?.target === 'string' && req.body.target.trim().length > 0
|
|
203
|
+
? req.body.target.trim()
|
|
204
|
+
: undefined;
|
|
205
|
+
if (isOpenCode1xVersion(rawTarget)) {
|
|
206
|
+
return res.status(400).json({
|
|
213
207
|
success: false,
|
|
214
|
-
error:
|
|
208
|
+
error: 'OpenCode upgrade refuses 1.x targets. Only pinned opencode2 is allowed.',
|
|
215
209
|
});
|
|
216
210
|
}
|
|
211
|
+
const target = resolveOpenCode2UpgradeTarget(rawTarget);
|
|
217
212
|
|
|
218
213
|
try {
|
|
219
214
|
await refreshOpenCodeAfterConfigChange('OpenCode upgrade');
|
|
@@ -227,7 +222,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|
|
227
222
|
});
|
|
228
223
|
}
|
|
229
224
|
|
|
230
|
-
return res.json({
|
|
225
|
+
return res.json({ success: true, restarted: true, version: target, pinned: true });
|
|
231
226
|
} catch (error) {
|
|
232
227
|
console.error('Failed to upgrade OpenCode:', error);
|
|
233
228
|
return res.status(500).json({
|
|
@@ -249,13 +244,10 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|
|
249
244
|
});
|
|
250
245
|
}
|
|
251
246
|
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}),
|
|
257
|
-
fetchLatestOpenCodeVersion(),
|
|
258
|
-
]);
|
|
247
|
+
const healthResponse = await fetch(buildOpenCodeUrl('/api/info', ''), {
|
|
248
|
+
method: 'GET',
|
|
249
|
+
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
|
250
|
+
});
|
|
259
251
|
const health = await healthResponse.json().catch(() => null);
|
|
260
252
|
if (!healthResponse.ok) {
|
|
261
253
|
return res.status(healthResponse.status).json({
|
|
@@ -264,10 +256,11 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|
|
264
256
|
});
|
|
265
257
|
}
|
|
266
258
|
const currentVersion = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null;
|
|
259
|
+
const latestVersion = isOpenCode1xVersion(PINNED_OPENCODE2_VERSION) ? null : PINNED_OPENCODE2_VERSION;
|
|
267
260
|
if (!currentVersion || !latestVersion) {
|
|
268
|
-
return res.json({ available: null, currentVersion, latestVersion
|
|
261
|
+
return res.json({ available: null, currentVersion, latestVersion });
|
|
269
262
|
}
|
|
270
|
-
const available = compareVersions(latestVersion, currentVersion) > 0;
|
|
263
|
+
const available = !isOpenCode1xVersion(currentVersion) && compareVersions(latestVersion, currentVersion) > 0;
|
|
271
264
|
return res.json({
|
|
272
265
|
available,
|
|
273
266
|
currentVersion,
|
|
@@ -283,7 +276,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|
|
283
276
|
|
|
284
277
|
app.get('/api/opencode/health', async (_req, res) => {
|
|
285
278
|
try {
|
|
286
|
-
const healthResponse = await fetch(buildOpenCodeUrl('/
|
|
279
|
+
const healthResponse = await fetch(buildOpenCodeUrl('/api/info', ''), {
|
|
287
280
|
method: 'GET',
|
|
288
281
|
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
|
289
282
|
signal: AbortSignal.timeout(4_000),
|
|
@@ -306,7 +299,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
|
|
306
299
|
|
|
307
300
|
app.get('/api/opencode/version', async (_req, res) => {
|
|
308
301
|
try {
|
|
309
|
-
const healthResponse = await fetch(buildOpenCodeUrl('/
|
|
302
|
+
const healthResponse = await fetch(buildOpenCodeUrl('/api/info', ''), {
|
|
310
303
|
method: 'GET',
|
|
311
304
|
headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
|
312
305
|
});
|
|
@@ -4,6 +4,7 @@ import request from 'supertest';
|
|
|
4
4
|
import fs from 'fs';
|
|
5
5
|
|
|
6
6
|
import { registerOpenCodeRoutes } from './routes.js';
|
|
7
|
+
import { isOpenCode1xVersion } from './opencode2-pin.js';
|
|
7
8
|
|
|
8
9
|
const createDependencies = ({ formatSettingsResponse }) => ({
|
|
9
10
|
crypto: {},
|
|
@@ -126,79 +127,77 @@ describe('behavior AGENTS.md route', () => {
|
|
|
126
127
|
});
|
|
127
128
|
});
|
|
128
129
|
|
|
129
|
-
describe('
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
afterEach(() => {
|
|
133
|
-
globalThis.fetch = originalFetch;
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
const jsonResponse = (body, status = 200, statusText = '') =>
|
|
137
|
-
new Response(JSON.stringify(body), {
|
|
138
|
-
status,
|
|
139
|
-
statusText,
|
|
140
|
-
headers: { 'Content-Type': 'application/json' },
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
const createUpgradeApp = () => {
|
|
130
|
+
describe('opencode2 upgrade pin (ticket 12)', () => {
|
|
131
|
+
const createUpgradeApp = (overrides = {}) => {
|
|
144
132
|
const app = express();
|
|
145
133
|
app.use(express.json());
|
|
146
|
-
const deps =
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
134
|
+
const deps = {
|
|
135
|
+
...createDependencies({ formatSettingsResponse: vi.fn(() => ({})) }),
|
|
136
|
+
getOpenCodeResolutionSnapshot: vi.fn(async () => ({ source: 'path' })),
|
|
137
|
+
buildOpenCodeUrl: vi.fn((pathname) => `http://opencode.test${pathname}`),
|
|
138
|
+
getOpenCodeAuthHeaders: vi.fn(() => ({ Authorization: 'Basic secret' })),
|
|
139
|
+
refreshOpenCodeAfterConfigChange: vi.fn(async () => undefined),
|
|
140
|
+
...overrides,
|
|
141
|
+
};
|
|
150
142
|
registerOpenCodeRoutes(app, deps);
|
|
151
143
|
return { app, deps };
|
|
152
144
|
};
|
|
153
145
|
|
|
154
|
-
it('
|
|
155
|
-
const fetchMock = vi.fn(
|
|
156
|
-
|
|
157
|
-
expect(JSON.parse(init.body)).toEqual({ target: '1.18.26' });
|
|
158
|
-
return jsonResponse({ success: true, version: '1.18.26' });
|
|
159
|
-
});
|
|
160
|
-
globalThis.fetch = fetchMock;
|
|
146
|
+
it('rejects 1.x upgrade targets and never calls /global/upgrade', async () => {
|
|
147
|
+
const fetchMock = vi.fn();
|
|
148
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
161
149
|
const { app } = createUpgradeApp();
|
|
162
150
|
|
|
163
151
|
const response = await request(app)
|
|
164
152
|
.post('/api/opencode/upgrade')
|
|
165
|
-
.send({ target: '
|
|
166
|
-
.expect(
|
|
153
|
+
.send({ target: '1.18.4' })
|
|
154
|
+
.expect(400);
|
|
167
155
|
|
|
168
|
-
expect(response.body).
|
|
169
|
-
expect(
|
|
156
|
+
expect(response.body.success).toBe(false);
|
|
157
|
+
expect(response.body.error).toMatch(/1\.x/);
|
|
158
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
159
|
+
vi.unstubAllGlobals();
|
|
170
160
|
});
|
|
171
161
|
|
|
172
|
-
it('
|
|
173
|
-
const fetchMock = vi.fn()
|
|
174
|
-
|
|
162
|
+
it('does not treat a 1.x latest string as an available upgrade', async () => {
|
|
163
|
+
const fetchMock = vi.fn(async (url) => {
|
|
164
|
+
const href = String(url);
|
|
165
|
+
if (href.includes('/api/info')) {
|
|
166
|
+
return {
|
|
167
|
+
ok: true,
|
|
168
|
+
json: async () => ({ version: '2.0.12', healthy: true }),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (href.includes('registry.npmjs.org/opencode-ai') || href.includes('github.com/repos/anomalyco/opencode')) {
|
|
172
|
+
return { ok: true, json: async () => ({ version: '1.18.18', tag_name: 'v1.18.18' }) };
|
|
173
|
+
}
|
|
174
|
+
throw new Error(`unexpected fetch ${href}`);
|
|
175
|
+
});
|
|
176
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
175
177
|
const { app } = createUpgradeApp();
|
|
176
178
|
|
|
177
|
-
const response = await request(app).
|
|
179
|
+
const response = await request(app).get('/api/opencode/upgrade-status').expect(200);
|
|
178
180
|
|
|
179
|
-
expect(response.body).
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
181
|
+
expect(response.body.latestVersion).not.toMatch(/^v?1\./);
|
|
182
|
+
expect(isOpenCode1xVersion(response.body.latestVersion)).toBe(false);
|
|
183
|
+
expect(String(response.body.latestVersion || '')).not.toContain('1.18');
|
|
184
|
+
vi.unstubAllGlobals();
|
|
184
185
|
});
|
|
185
186
|
|
|
186
|
-
it('
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
),
|
|
193
|
-
);
|
|
194
|
-
globalThis.fetch = fetchMock;
|
|
195
|
-
const { app } = createUpgradeApp();
|
|
187
|
+
it('returns 409 for external OpenCode and does not refresh', async () => {
|
|
188
|
+
const refreshOpenCodeAfterConfigChange = vi.fn(async () => undefined);
|
|
189
|
+
const { app, deps } = createUpgradeApp({
|
|
190
|
+
getIsExternalOpenCode: () => true,
|
|
191
|
+
refreshOpenCodeAfterConfigChange,
|
|
192
|
+
});
|
|
196
193
|
|
|
197
194
|
const response = await request(app)
|
|
198
195
|
.post('/api/opencode/upgrade')
|
|
199
|
-
.send({
|
|
200
|
-
.expect(
|
|
196
|
+
.send({})
|
|
197
|
+
.expect(409);
|
|
201
198
|
|
|
202
|
-
expect(response.body).
|
|
199
|
+
expect(response.body.success).toBe(false);
|
|
200
|
+
expect(response.body.error).toMatch(/your own OpenCode serve/i);
|
|
201
|
+
expect(deps.refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
|
|
203
202
|
});
|
|
204
203
|
});
|