@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
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { Service } from '@opencode/client/service';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
import { isSupportedOpenCodeVersion, readOpenCodeInfo } from './compatibility.js';
|
|
9
|
+
|
|
10
|
+
const endpointSchema = z.object({
|
|
11
|
+
url: z.string(),
|
|
12
|
+
auth: z.object({
|
|
13
|
+
type: z.literal('basic'),
|
|
14
|
+
username: z.string(),
|
|
15
|
+
password: z.string(),
|
|
16
|
+
}).optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const createConnectionError = (code, message, details = {}, cause) => {
|
|
20
|
+
const error = new Error(message, cause === undefined ? undefined : { cause });
|
|
21
|
+
error.code = code;
|
|
22
|
+
Object.assign(error, details);
|
|
23
|
+
return error;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const unavailableError = (message, cause) => createConnectionError(
|
|
27
|
+
'OPENCODE_SERVICE_UNAVAILABLE',
|
|
28
|
+
message,
|
|
29
|
+
{},
|
|
30
|
+
cause,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const normalizeEndpoint = (endpoint) => {
|
|
34
|
+
const parsed = endpointSchema.safeParse(endpoint);
|
|
35
|
+
if (!parsed.success) {
|
|
36
|
+
throw unavailableError('OpenCode shared service did not return an endpoint');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let url;
|
|
40
|
+
try {
|
|
41
|
+
url = new URL(parsed.data.url);
|
|
42
|
+
} catch (cause) {
|
|
43
|
+
throw unavailableError('OpenCode shared service returned an invalid URL', cause);
|
|
44
|
+
}
|
|
45
|
+
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname) {
|
|
46
|
+
throw unavailableError('OpenCode shared service returned an unsupported URL');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { ...parsed.data, url: url.origin };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const defaultRegistrationFile = () => join(
|
|
53
|
+
process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'),
|
|
54
|
+
'opencode',
|
|
55
|
+
'service.json',
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const registrationExists = async (options = {}) => {
|
|
59
|
+
try {
|
|
60
|
+
await access(options.file ?? defaultRegistrationFile());
|
|
61
|
+
return true;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error?.code === 'ENOENT') return false;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const createSharedOpenCodeServiceRuntime = ({
|
|
69
|
+
service = Service,
|
|
70
|
+
fetchImpl = fetch,
|
|
71
|
+
ensureOptions,
|
|
72
|
+
readInfo = readOpenCodeInfo,
|
|
73
|
+
isSupportedVersion = isSupportedOpenCodeVersion,
|
|
74
|
+
hasRegistration = registrationExists,
|
|
75
|
+
}) => {
|
|
76
|
+
let activeConnection = null;
|
|
77
|
+
|
|
78
|
+
const resolveEndpoint = async () => {
|
|
79
|
+
let discovered;
|
|
80
|
+
try {
|
|
81
|
+
discovered = await service.discover(ensureOptions);
|
|
82
|
+
} catch (cause) {
|
|
83
|
+
throw unavailableError('Could not discover the OpenCode shared service', cause);
|
|
84
|
+
}
|
|
85
|
+
if (discovered) return normalizeEndpoint(discovered);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
if (await hasRegistration(ensureOptions)) {
|
|
89
|
+
throw unavailableError(
|
|
90
|
+
'An incompatible or unhealthy OpenCode shared service is already registered',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
} catch (cause) {
|
|
94
|
+
if (cause?.code === 'OPENCODE_SERVICE_UNAVAILABLE') throw cause;
|
|
95
|
+
throw unavailableError('Could not inspect the OpenCode shared service registration', cause);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return normalizeEndpoint(await service.ensure(ensureOptions));
|
|
99
|
+
} catch (cause) {
|
|
100
|
+
if (cause?.code === 'OPENCODE_SERVICE_UNAVAILABLE') throw cause;
|
|
101
|
+
throw unavailableError('Could not start the OpenCode shared service', cause);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const probe = async (endpoint) => {
|
|
106
|
+
let headers;
|
|
107
|
+
try {
|
|
108
|
+
headers = { ...(service.headers(endpoint) ?? {}) };
|
|
109
|
+
} catch (cause) {
|
|
110
|
+
throw unavailableError('Could not authenticate with the OpenCode shared service', cause);
|
|
111
|
+
}
|
|
112
|
+
let info;
|
|
113
|
+
try {
|
|
114
|
+
const response = await fetchImpl(new URL('/api/info', endpoint.url), {
|
|
115
|
+
headers: { Accept: 'application/json', ...headers },
|
|
116
|
+
});
|
|
117
|
+
info = await readInfo(response);
|
|
118
|
+
} catch (cause) {
|
|
119
|
+
throw unavailableError('Could not reach the OpenCode shared service', cause);
|
|
120
|
+
}
|
|
121
|
+
if (!info) {
|
|
122
|
+
throw unavailableError('OpenCode shared service did not return valid service information');
|
|
123
|
+
}
|
|
124
|
+
if (!isSupportedVersion(info.version)) {
|
|
125
|
+
throw createConnectionError(
|
|
126
|
+
'OPENCODE_INCOMPATIBLE',
|
|
127
|
+
`OpenCode shared service version ${info.version} is incompatible`,
|
|
128
|
+
{ foundVersion: info.version },
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
kind: 'shared-local',
|
|
134
|
+
endpoint,
|
|
135
|
+
version: info.version,
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const establishConnection = async () => {
|
|
140
|
+
const replacement = await probe(await resolveEndpoint());
|
|
141
|
+
activeConnection = replacement;
|
|
142
|
+
return replacement;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
connect: establishConnection,
|
|
147
|
+
recover: establishConnection,
|
|
148
|
+
getConnection: () => activeConnection,
|
|
149
|
+
getBaseUrl: () => activeConnection?.endpoint.url ?? null,
|
|
150
|
+
getHeaders: () => activeConnection ? { ...(service.headers(activeConnection.endpoint) ?? {}) } : {},
|
|
151
|
+
dispose: async () => {
|
|
152
|
+
activeConnection = null;
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createSharedOpenCodeServiceRuntime } from './shared-service-runtime.js';
|
|
4
|
+
|
|
5
|
+
const endpointA = {
|
|
6
|
+
url: 'http://127.0.0.1:4096',
|
|
7
|
+
auth: { type: 'basic', username: 'opencode', password: 'original' },
|
|
8
|
+
};
|
|
9
|
+
const endpointB = {
|
|
10
|
+
url: 'http://127.0.0.1:5096',
|
|
11
|
+
auth: { type: 'basic', username: 'opencode', password: 'replacement' },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const createRuntime = ({
|
|
15
|
+
discover = vi.fn().mockResolvedValue(endpointA),
|
|
16
|
+
ensure = vi.fn(),
|
|
17
|
+
stop = vi.fn(),
|
|
18
|
+
headers = vi.fn((endpoint) => endpoint.auth
|
|
19
|
+
? { authorization: `Basic ${endpoint.auth.password}` }
|
|
20
|
+
: undefined),
|
|
21
|
+
fetchImpl = vi.fn().mockResolvedValue(Response.json({ version: '2.0.15' })),
|
|
22
|
+
infoVersion,
|
|
23
|
+
readInfo = async (response) => infoVersion === undefined
|
|
24
|
+
? response.json()
|
|
25
|
+
: { version: infoVersion },
|
|
26
|
+
isSupportedVersion = (version) => version === '2.0.15',
|
|
27
|
+
ensureOptions = { command: ['opencode', 'serve', '--service'] },
|
|
28
|
+
hasRegistration = vi.fn().mockResolvedValue(false),
|
|
29
|
+
} = {}) => createSharedOpenCodeServiceRuntime({
|
|
30
|
+
service: { discover, ensure, stop, headers },
|
|
31
|
+
fetchImpl,
|
|
32
|
+
ensureOptions,
|
|
33
|
+
readInfo,
|
|
34
|
+
isSupportedVersion,
|
|
35
|
+
hasRegistration,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('shared OpenCode service runtime', () => {
|
|
39
|
+
it('uses a compatible discovered service without ensuring another one', async () => {
|
|
40
|
+
const discover = vi.fn().mockResolvedValue(endpointA);
|
|
41
|
+
const ensure = vi.fn();
|
|
42
|
+
const runtime = createRuntime({ discover, ensure, infoVersion: '2.0.15' });
|
|
43
|
+
|
|
44
|
+
await expect(runtime.connect()).resolves.toMatchObject({
|
|
45
|
+
kind: 'shared-local',
|
|
46
|
+
endpoint: endpointA,
|
|
47
|
+
version: '2.0.15',
|
|
48
|
+
});
|
|
49
|
+
expect(ensure).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects an incompatible discovered service without replacing it', async () => {
|
|
53
|
+
const discover = vi.fn().mockResolvedValue(endpointA);
|
|
54
|
+
const ensure = vi.fn();
|
|
55
|
+
const runtime = createRuntime({ discover, ensure, infoVersion: '1.99.0' });
|
|
56
|
+
|
|
57
|
+
await expect(runtime.connect()).rejects.toMatchObject({
|
|
58
|
+
code: 'OPENCODE_INCOMPATIBLE',
|
|
59
|
+
foundVersion: '1.99.0',
|
|
60
|
+
});
|
|
61
|
+
expect(ensure).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('refuses to ensure when discovery hides an existing incompatible or unhealthy registration', async () => {
|
|
65
|
+
const discover = vi.fn().mockResolvedValue(undefined);
|
|
66
|
+
const ensure = vi.fn();
|
|
67
|
+
const runtime = createRuntime({
|
|
68
|
+
discover,
|
|
69
|
+
ensure,
|
|
70
|
+
hasRegistration: vi.fn().mockResolvedValue(true),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await expect(runtime.connect()).rejects.toMatchObject({
|
|
74
|
+
code: 'OPENCODE_SERVICE_UNAVAILABLE',
|
|
75
|
+
});
|
|
76
|
+
expect(ensure).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('ensures a service only when discovery returns no endpoint', async () => {
|
|
80
|
+
const discover = vi.fn().mockResolvedValue(undefined);
|
|
81
|
+
const ensure = vi.fn().mockResolvedValue(endpointA);
|
|
82
|
+
const runtime = createRuntime({ discover, ensure, infoVersion: '2.0.15' });
|
|
83
|
+
|
|
84
|
+
await expect(runtime.connect()).resolves.toMatchObject({ endpoint: endpointA });
|
|
85
|
+
expect(ensure).toHaveBeenCalledTimes(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('refreshes URL and auth when recovery discovers a replacement endpoint', async () => {
|
|
89
|
+
const discover = vi.fn()
|
|
90
|
+
.mockResolvedValueOnce(endpointA)
|
|
91
|
+
.mockResolvedValueOnce(endpointB);
|
|
92
|
+
const runtime = createRuntime({ discover, infoVersion: '2.0.15' });
|
|
93
|
+
|
|
94
|
+
await runtime.connect();
|
|
95
|
+
await runtime.recover();
|
|
96
|
+
|
|
97
|
+
expect(runtime.getBaseUrl()).toBe(endpointB.url);
|
|
98
|
+
expect(runtime.getHeaders()).toEqual({ authorization: 'Basic replacement' });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('keeps the active connection when a replacement is incompatible', async () => {
|
|
102
|
+
const discover = vi.fn()
|
|
103
|
+
.mockResolvedValueOnce(endpointA)
|
|
104
|
+
.mockResolvedValueOnce(endpointB);
|
|
105
|
+
const readInfo = vi.fn()
|
|
106
|
+
.mockResolvedValueOnce({ version: '2.0.15' })
|
|
107
|
+
.mockResolvedValueOnce({ version: '1.99.0' });
|
|
108
|
+
const runtime = createRuntime({ discover, readInfo });
|
|
109
|
+
|
|
110
|
+
await runtime.connect();
|
|
111
|
+
await expect(runtime.recover()).rejects.toMatchObject({ code: 'OPENCODE_INCOMPATIBLE' });
|
|
112
|
+
|
|
113
|
+
expect(runtime.getBaseUrl()).toBe(endpointA.url);
|
|
114
|
+
expect(runtime.getHeaders()).toEqual({ authorization: 'Basic original' });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('dispose does not stop the shared service', async () => {
|
|
118
|
+
const stop = vi.fn();
|
|
119
|
+
const runtime = createRuntime({ stop, infoVersion: '2.0.15' });
|
|
120
|
+
await runtime.connect();
|
|
121
|
+
await runtime.dispose();
|
|
122
|
+
expect(stop).not.toHaveBeenCalled();
|
|
123
|
+
expect(runtime.getConnection()).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it.each([
|
|
127
|
+
[undefined],
|
|
128
|
+
[{}],
|
|
129
|
+
[{ url: '' }],
|
|
130
|
+
[{ url: 'file:///tmp/opencode.sock' }],
|
|
131
|
+
[{ url: 'not a url' }],
|
|
132
|
+
])('rejects a malformed endpoint (%j)', async (endpoint) => {
|
|
133
|
+
const runtime = createRuntime({ discover: vi.fn().mockResolvedValue(endpoint) });
|
|
134
|
+
|
|
135
|
+
await expect(runtime.connect()).rejects.toMatchObject({ code: 'OPENCODE_SERVICE_UNAVAILABLE' });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('reports an authenticated service as unavailable when /api/info returns 401', async () => {
|
|
139
|
+
const runtime = createRuntime({
|
|
140
|
+
fetchImpl: vi.fn().mockResolvedValue(new Response(null, { status: 401 })),
|
|
141
|
+
readInfo: async () => null,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await expect(runtime.connect()).rejects.toMatchObject({ code: 'OPENCODE_SERVICE_UNAVAILABLE' });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('reports discovery failures as unavailable without ensuring a replacement', async () => {
|
|
148
|
+
const ensure = vi.fn();
|
|
149
|
+
const runtime = createRuntime({
|
|
150
|
+
discover: vi.fn().mockRejectedValue(new Error('registration unavailable')),
|
|
151
|
+
ensure,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await expect(runtime.connect()).rejects.toMatchObject({ code: 'OPENCODE_SERVICE_UNAVAILABLE' });
|
|
155
|
+
expect(ensure).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('reports an unreachable discovered endpoint as unavailable without ensuring a replacement', async () => {
|
|
159
|
+
const ensure = vi.fn();
|
|
160
|
+
const runtime = createRuntime({
|
|
161
|
+
ensure,
|
|
162
|
+
fetchImpl: vi.fn().mockRejectedValue(new Error('connection refused')),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await expect(runtime.connect()).rejects.toMatchObject({ code: 'OPENCODE_SERVICE_UNAVAILABLE' });
|
|
166
|
+
expect(ensure).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('uses empty headers when Service.headers returns undefined', async () => {
|
|
170
|
+
const fetchImpl = vi.fn().mockResolvedValue(Response.json({ version: '2.0.15' }));
|
|
171
|
+
const runtime = createRuntime({ headers: vi.fn().mockReturnValue(undefined), fetchImpl });
|
|
172
|
+
|
|
173
|
+
await runtime.connect();
|
|
174
|
+
|
|
175
|
+
expect(runtime.getHeaders()).toEqual({});
|
|
176
|
+
expect(fetchImpl).toHaveBeenCalledWith(new URL(`${endpointA.url}/api/info`), {
|
|
177
|
+
headers: { Accept: 'application/json' },
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -13,18 +13,20 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
|
|
13
13
|
contextObligatoryRuntime,
|
|
14
14
|
messageQueueRuntime,
|
|
15
15
|
scheduledTasksRuntime,
|
|
16
|
+
globalEventHub,
|
|
16
17
|
getHealthCheckInterval,
|
|
17
18
|
clearHealthCheckInterval,
|
|
18
19
|
getTerminalRuntime,
|
|
19
20
|
setTerminalRuntime,
|
|
20
21
|
getMessageStreamRuntime,
|
|
21
22
|
setMessageStreamRuntime,
|
|
22
|
-
|
|
23
|
+
getOpenCodeConnectionKind,
|
|
23
24
|
getOpenCodePort,
|
|
24
25
|
getOpenCodeProcess,
|
|
25
26
|
setOpenCodeProcess,
|
|
26
27
|
killProcessOnPort,
|
|
27
28
|
waitForPortRelease,
|
|
29
|
+
disposeSharedOpenCodeService,
|
|
28
30
|
getServer,
|
|
29
31
|
getUiAuthController,
|
|
30
32
|
setUiAuthController,
|
|
@@ -66,7 +68,9 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
|
|
66
68
|
beginGuestServiceShutdown();
|
|
67
69
|
syncToHmrState();
|
|
68
70
|
console.log('Starting graceful shutdown...');
|
|
69
|
-
const exitProcess =
|
|
71
|
+
const exitProcess = options.exitProcess === true || options.exitProcess === false
|
|
72
|
+
? options.exitProcess
|
|
73
|
+
: getExitOnShutdown();
|
|
70
74
|
|
|
71
75
|
// Both embedded stop() and daemon exits use this sequence. Close admission
|
|
72
76
|
// synchronously above, then stop viewers before draining their services.
|
|
@@ -77,6 +81,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
|
|
77
81
|
() => getRelayService()?.stop(),
|
|
78
82
|
() => getDictationRuntime()?.stop(),
|
|
79
83
|
() => openCodeWatcherRuntime.stop(),
|
|
84
|
+
() => globalEventHub.stop(),
|
|
80
85
|
() => sessionRuntime.dispose(),
|
|
81
86
|
() => sessionAssistRuntime?.stop?.(),
|
|
82
87
|
() => sessionGoalRuntime?.stop?.(),
|
|
@@ -118,7 +123,8 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
|
|
118
123
|
}
|
|
119
124
|
}
|
|
120
125
|
|
|
121
|
-
|
|
126
|
+
const connectionKind = getOpenCodeConnectionKind();
|
|
127
|
+
if (connectionKind === 'managed-owned') {
|
|
122
128
|
const portToKill = getOpenCodePort();
|
|
123
129
|
const openCodeProcess = getOpenCodeProcess();
|
|
124
130
|
|
|
@@ -136,8 +142,8 @@ export const createGracefulShutdownRuntime = (dependencies) => {
|
|
|
136
142
|
if (!(await waitForPortRelease(portToKill, 5000))) {
|
|
137
143
|
console.warn(`Timed out waiting for OpenCode port ${portToKill} to be released during shutdown`);
|
|
138
144
|
}
|
|
139
|
-
} else {
|
|
140
|
-
|
|
145
|
+
} else if (connectionKind === 'shared-local') {
|
|
146
|
+
await disposeSharedOpenCodeService();
|
|
141
147
|
}
|
|
142
148
|
|
|
143
149
|
const server = getServer();
|
|
@@ -16,18 +16,20 @@ const createRuntime = (server, overrides = {}) => {
|
|
|
16
16
|
openCodeWatcherRuntime: { stop: vi.fn() },
|
|
17
17
|
sessionRuntime: { dispose: vi.fn() },
|
|
18
18
|
scheduledTasksRuntime: { stop: vi.fn() },
|
|
19
|
+
globalEventHub: { stop: vi.fn() },
|
|
19
20
|
getHealthCheckInterval: () => null,
|
|
20
21
|
clearHealthCheckInterval: vi.fn(),
|
|
21
22
|
getTerminalRuntime: () => null,
|
|
22
23
|
setTerminalRuntime: vi.fn(),
|
|
23
24
|
getMessageStreamRuntime: () => null,
|
|
24
25
|
setMessageStreamRuntime: vi.fn(),
|
|
25
|
-
|
|
26
|
+
getOpenCodeConnectionKind: () => 'explicit-external',
|
|
26
27
|
getOpenCodePort: () => null,
|
|
27
28
|
getOpenCodeProcess: () => null,
|
|
28
29
|
setOpenCodeProcess: vi.fn(),
|
|
29
30
|
killProcessOnPort: vi.fn(),
|
|
30
31
|
waitForPortRelease: vi.fn(async () => true),
|
|
32
|
+
disposeSharedOpenCodeService: vi.fn(async () => {}),
|
|
31
33
|
getServer: () => server,
|
|
32
34
|
getUiAuthController: () => null,
|
|
33
35
|
setUiAuthController: vi.fn(),
|
|
@@ -151,7 +153,7 @@ describe('graceful shutdown runtime', () => {
|
|
|
151
153
|
throw new Error('fixture cleanup failure');
|
|
152
154
|
},
|
|
153
155
|
getTerminalRuntime: () => ({ shutdown: terminalShutdown }),
|
|
154
|
-
|
|
156
|
+
getOpenCodeConnectionKind: () => 'managed-owned',
|
|
155
157
|
getOpenCodeProcess: () => ({ close: processClose }),
|
|
156
158
|
});
|
|
157
159
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
@@ -187,6 +189,70 @@ describe('graceful shutdown runtime', () => {
|
|
|
187
189
|
}
|
|
188
190
|
});
|
|
189
191
|
|
|
192
|
+
it('only managed-owned closes and kills OpenCode before waiting for its port', async () => {
|
|
193
|
+
const close = vi.fn(async () => {});
|
|
194
|
+
const killProcessOnPort = vi.fn();
|
|
195
|
+
const waitForPortRelease = vi.fn(async () => true);
|
|
196
|
+
const disposeSharedOpenCodeService = vi.fn(async () => {});
|
|
197
|
+
const runtime = createRuntime(null, {
|
|
198
|
+
getOpenCodeConnectionKind: () => 'managed-owned',
|
|
199
|
+
getOpenCodePort: () => 4096,
|
|
200
|
+
getOpenCodeProcess: () => ({ close }),
|
|
201
|
+
killProcessOnPort,
|
|
202
|
+
waitForPortRelease,
|
|
203
|
+
disposeSharedOpenCodeService,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await runtime.gracefulShutdown({ exitProcess: false });
|
|
207
|
+
|
|
208
|
+
expect(close).toHaveBeenCalledOnce();
|
|
209
|
+
expect(killProcessOnPort).toHaveBeenCalledWith(4096);
|
|
210
|
+
expect(waitForPortRelease).toHaveBeenCalledWith(4096, 5000);
|
|
211
|
+
expect(disposeSharedOpenCodeService).not.toHaveBeenCalled();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('shared-local disposes connection state without touching the shared service process', async () => {
|
|
215
|
+
const close = vi.fn();
|
|
216
|
+
const killProcessOnPort = vi.fn();
|
|
217
|
+
const waitForPortRelease = vi.fn();
|
|
218
|
+
const disposeSharedOpenCodeService = vi.fn(async () => {});
|
|
219
|
+
const runtime = createRuntime(null, {
|
|
220
|
+
getOpenCodeConnectionKind: () => 'shared-local',
|
|
221
|
+
getOpenCodeProcess: () => ({ close }),
|
|
222
|
+
killProcessOnPort,
|
|
223
|
+
waitForPortRelease,
|
|
224
|
+
disposeSharedOpenCodeService,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await runtime.gracefulShutdown({ exitProcess: false });
|
|
228
|
+
|
|
229
|
+
expect(disposeSharedOpenCodeService).toHaveBeenCalledOnce();
|
|
230
|
+
expect(close).not.toHaveBeenCalled();
|
|
231
|
+
expect(killProcessOnPort).not.toHaveBeenCalled();
|
|
232
|
+
expect(waitForPortRelease).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('explicit-external leaves OpenCode and shared state untouched', async () => {
|
|
236
|
+
const close = vi.fn();
|
|
237
|
+
const killProcessOnPort = vi.fn();
|
|
238
|
+
const waitForPortRelease = vi.fn();
|
|
239
|
+
const disposeSharedOpenCodeService = vi.fn();
|
|
240
|
+
const runtime = createRuntime(null, {
|
|
241
|
+
getOpenCodeConnectionKind: () => 'explicit-external',
|
|
242
|
+
getOpenCodeProcess: () => ({ close }),
|
|
243
|
+
killProcessOnPort,
|
|
244
|
+
waitForPortRelease,
|
|
245
|
+
disposeSharedOpenCodeService,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await runtime.gracefulShutdown({ exitProcess: false });
|
|
249
|
+
|
|
250
|
+
expect(close).not.toHaveBeenCalled();
|
|
251
|
+
expect(killProcessOnPort).not.toHaveBeenCalled();
|
|
252
|
+
expect(waitForPortRelease).not.toHaveBeenCalled();
|
|
253
|
+
expect(disposeSharedOpenCodeService).not.toHaveBeenCalled();
|
|
254
|
+
});
|
|
255
|
+
|
|
190
256
|
it('stops guest services during shutdown', async () => {
|
|
191
257
|
const server = {
|
|
192
258
|
close: vi.fn((callback) => {
|
|
@@ -255,6 +321,15 @@ describe('graceful shutdown runtime', () => {
|
|
|
255
321
|
expect(vi.getTimerCount()).toBe(0);
|
|
256
322
|
});
|
|
257
323
|
|
|
324
|
+
it('stops the application-owned global event hub during embedded shutdown', async () => {
|
|
325
|
+
const globalEventHub = { stop: vi.fn() };
|
|
326
|
+
const runtime = createRuntime(null, { globalEventHub });
|
|
327
|
+
|
|
328
|
+
await runtime.gracefulShutdown({ exitProcess: false });
|
|
329
|
+
|
|
330
|
+
expect(globalEventHub.stop).toHaveBeenCalledOnce();
|
|
331
|
+
});
|
|
332
|
+
|
|
258
333
|
it('isolates failed viewer and relay cleanup and still drains guests and exits', async () => {
|
|
259
334
|
const stop = vi.fn(() => { throw new Error('stop failed'); });
|
|
260
335
|
const stopAllGuestServices = vi.fn(async () => {});
|
|
@@ -34,7 +34,6 @@ export const createStartupPipelineRuntime = (dependencies) => {
|
|
|
34
34
|
terminalRebindWindowMs,
|
|
35
35
|
terminalMaxRebindsPerWindow,
|
|
36
36
|
setupProxy,
|
|
37
|
-
scheduleOpenCodeApiDetection,
|
|
38
37
|
bootstrapOpenCodeAtStartup,
|
|
39
38
|
staticRoutesRuntime,
|
|
40
39
|
process,
|
|
@@ -132,13 +131,15 @@ export const createStartupPipelineRuntime = (dependencies) => {
|
|
|
132
131
|
bindHost,
|
|
133
132
|
startupTunnelRequest,
|
|
134
133
|
onTunnelReady,
|
|
134
|
+
afterListening: async ({ activePort }) => {
|
|
135
|
+
tunnelRuntimeContext.setActivePort(activePort);
|
|
136
|
+
await bootstrapOpenCodeAtStartup();
|
|
137
|
+
},
|
|
135
138
|
});
|
|
136
139
|
recordStartupPerformance('web.listener.ready', {
|
|
137
140
|
durationMs: performance.now() - pipelineStartedAt,
|
|
138
141
|
});
|
|
139
|
-
|
|
140
|
-
scheduleOpenCodeApiDetection();
|
|
141
|
-
void bootstrapOpenCodeAtStartup();
|
|
142
|
+
|
|
142
143
|
|
|
143
144
|
serverStartupRuntime.attachProcessHandlers({ attachSignals });
|
|
144
145
|
|
|
@@ -3,23 +3,27 @@ import { describe, expect, it, vi } from 'vitest';
|
|
|
3
3
|
import { createStartupPipelineRuntime } from './startup-pipeline-runtime.js';
|
|
4
4
|
|
|
5
5
|
describe('startup pipeline runtime', () => {
|
|
6
|
-
it('publishes the listening port before
|
|
6
|
+
it('publishes the listening port before awaiting OpenCode bootstrap', async () => {
|
|
7
7
|
const order = [];
|
|
8
|
+
let releaseBootstrap;
|
|
9
|
+
const bootstrapGate = new Promise((resolve) => { releaseBootstrap = resolve; });
|
|
8
10
|
const runtime = createStartupPipelineRuntime({
|
|
9
11
|
createTerminalRuntime: () => ({}),
|
|
10
12
|
createDictationRuntime: () => ({}),
|
|
11
13
|
createMessageStreamWsRuntime: () => ({}),
|
|
12
14
|
createServerStartupRuntime: () => ({
|
|
13
15
|
resolveBindHost: () => '127.0.0.1',
|
|
14
|
-
startListeningAndMaybeTunnel: async () => {
|
|
16
|
+
startListeningAndMaybeTunnel: async ({ afterListening }) => {
|
|
15
17
|
order.push('listen');
|
|
18
|
+
await afterListening({ activePort: 3901, bindHost: '127.0.0.1' });
|
|
19
|
+
order.push('ready');
|
|
16
20
|
return { activePort: 3901 };
|
|
17
21
|
},
|
|
18
22
|
attachProcessHandlers: vi.fn(),
|
|
19
23
|
}),
|
|
20
24
|
});
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
const run = runtime.run({
|
|
23
27
|
app: {},
|
|
24
28
|
setupProxy: vi.fn(),
|
|
25
29
|
staticRoutesRuntime: { registerStaticRoutes: vi.fn() },
|
|
@@ -27,14 +31,21 @@ describe('startup pipeline runtime', () => {
|
|
|
27
31
|
tunnelRuntimeContext: {
|
|
28
32
|
setActivePort: (port) => order.push(`port:${port}`),
|
|
29
33
|
},
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
bootstrapOpenCodeAtStartup: async () => {
|
|
35
|
+
order.push('bootstrap');
|
|
36
|
+
await bootstrapGate;
|
|
37
|
+
},
|
|
32
38
|
process: {},
|
|
33
39
|
crypto: {},
|
|
34
|
-
server: {},
|
|
40
|
+
server: { close: (callback) => callback() },
|
|
35
41
|
attachSignals: false,
|
|
36
42
|
});
|
|
37
43
|
|
|
38
|
-
|
|
44
|
+
await Promise.resolve();
|
|
45
|
+
expect(order).toEqual(['listen', 'port:3901', 'bootstrap']);
|
|
46
|
+
releaseBootstrap();
|
|
47
|
+
await run;
|
|
48
|
+
|
|
49
|
+
expect(order).toEqual(['listen', 'port:3901', 'bootstrap', 'ready']);
|
|
39
50
|
});
|
|
40
51
|
});
|
|
@@ -3,6 +3,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
3
3
|
import { createGlobalMessageStreamHub } from '../event-stream/global-hub.js';
|
|
4
4
|
import { createOpenCodeWatcherRuntime } from './watcher.js';
|
|
5
5
|
|
|
6
|
+
async function waitForAssertion(assertion) {
|
|
7
|
+
const deadline = Date.now() + 1000;
|
|
8
|
+
let lastError;
|
|
9
|
+
while (Date.now() < deadline) {
|
|
10
|
+
try {
|
|
11
|
+
assertion();
|
|
12
|
+
return;
|
|
13
|
+
} catch (error) {
|
|
14
|
+
lastError = error;
|
|
15
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
throw lastError;
|
|
19
|
+
}
|
|
20
|
+
|
|
6
21
|
function createSseResponse({ blocks = [], signal, holdOpen = false }) {
|
|
7
22
|
const encoder = new TextEncoder();
|
|
8
23
|
let index = 0;
|
|
@@ -189,6 +204,45 @@ describe('createOpenCodeWatcherRuntime', () => {
|
|
|
189
204
|
});
|
|
190
205
|
});
|
|
191
206
|
|
|
207
|
+
it('keeps its shared subscription across a hub rebind', async () => {
|
|
208
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
209
|
+
let generation = 0;
|
|
210
|
+
const payloads = [];
|
|
211
|
+
const globalEventHub = createGlobalMessageStreamHub({
|
|
212
|
+
buildOpenCodeUrl: () => `http://127.0.0.1:${generation === 0 ? 4096 : 5096}/api/event`,
|
|
213
|
+
getOpenCodeAuthHeaders: () => ({ Authorization: generation === 0 ? 'Basic first' : 'Basic second' }),
|
|
214
|
+
upstreamReconnectDelayMs: 60_000,
|
|
215
|
+
fetchImpl: async (_url, options) => createSseResponse({
|
|
216
|
+
signal: options.signal,
|
|
217
|
+
holdOpen: true,
|
|
218
|
+
blocks: [generation === 0
|
|
219
|
+
? 'data: {"id":"evt-1","type":"server.connected","data":{}}\n\n'
|
|
220
|
+
: 'data: {"id":"evt-2","type":"session.renamed","location":{"directory":"/tmp/project"},"data":{"sessionID":"ses_1","title":"Recovered"}}\n\n'],
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
223
|
+
const watcher = createOpenCodeWatcherRuntime({
|
|
224
|
+
waitForOpenCodePort: async () => {},
|
|
225
|
+
buildOpenCodeUrl: () => '',
|
|
226
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
227
|
+
globalEventHub,
|
|
228
|
+
onPayload: (payload) => payloads.push(payload),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
await watcher.start();
|
|
233
|
+
await waitForAssertion(() => expect(payloads.map((payload) => payload.type)).toContain('server.connected'));
|
|
234
|
+
generation = 1;
|
|
235
|
+
globalEventHub.rebind();
|
|
236
|
+
await waitForAssertion(() => expect(payloads).toContainEqual(expect.objectContaining({
|
|
237
|
+
type: 'session.updated',
|
|
238
|
+
properties: expect.objectContaining({ sessionID: 'ses_1' }),
|
|
239
|
+
})));
|
|
240
|
+
} finally {
|
|
241
|
+
watcher.stop();
|
|
242
|
+
globalEventHub.stop();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
192
246
|
it('does not stop a shared global event hub when the watcher stops', async () => {
|
|
193
247
|
const events = new Set();
|
|
194
248
|
const statuses = new Set();
|