agl 1.0.17 → 21.0.2
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/.smartconfig.json +55 -0
- package/changelog.md +1026 -0
- package/cli.js +14 -0
- package/dist_serve/bundle.js +44778 -0
- package/dist_serve/index.html +13 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/classes.authmodels.d.ts +126 -0
- package/dist_ts/classes.authmodels.js +1422 -0
- package/dist_ts/classes.authstore.d.ts +160 -0
- package/dist_ts/classes.authstore.js +2483 -0
- package/dist_ts/classes.browserresourcehost.d.ts +85 -0
- package/dist_ts/classes.browserresourcehost.js +912 -0
- package/dist_ts/classes.cli.d.ts +21 -0
- package/dist_ts/classes.cli.js +1082 -0
- package/dist_ts/classes.config.d.ts +23 -0
- package/dist_ts/classes.config.js +267 -0
- package/dist_ts/classes.controller.d.ts +523 -0
- package/dist_ts/classes.controller.js +10611 -0
- package/dist_ts/classes.controllermanagement.d.ts +32 -0
- package/dist_ts/classes.controllermanagement.js +222 -0
- package/dist_ts/classes.draftmanager.d.ts +30 -0
- package/dist_ts/classes.draftmanager.js +179 -0
- package/dist_ts/classes.embeddeddb.d.ts +30 -0
- package/dist_ts/classes.embeddeddb.js +159 -0
- package/dist_ts/classes.flexframedtransport.d.ts +56 -0
- package/dist_ts/classes.flexframedtransport.js +275 -0
- package/dist_ts/classes.flexmodels.private.d.ts +231 -0
- package/dist_ts/classes.flexmodels.private.js +985 -0
- package/dist_ts/classes.flexmodels.public.d.ts +207 -0
- package/dist_ts/classes.flexmodels.public.js +885 -0
- package/dist_ts/classes.flexprojectionreader.d.ts +89 -0
- package/dist_ts/classes.flexprojectionreader.js +580 -0
- package/dist_ts/classes.flexservice.d.ts +196 -0
- package/dist_ts/classes.flexservice.js +3704 -0
- package/dist_ts/classes.flexstore.d.ts +174 -0
- package/dist_ts/classes.flexstore.js +3400 -0
- package/dist_ts/classes.flexsupervisor.d.ts +105 -0
- package/dist_ts/classes.flexsupervisor.js +1191 -0
- package/dist_ts/classes.gitreversion.d.ts +431 -0
- package/dist_ts/classes.gitreversion.js +10592 -0
- package/dist_ts/classes.harnessoperationmanager.d.ts +113 -0
- package/dist_ts/classes.harnessoperationmanager.js +602 -0
- package/dist_ts/classes.opencodeclient.d.ts +146 -0
- package/dist_ts/classes.opencodeclient.js +3003 -0
- package/dist_ts/classes.opencodesupervisor.d.ts +60 -0
- package/dist_ts/classes.opencodesupervisor.js +1003 -0
- package/dist_ts/classes.passkeymanager.d.ts +34 -0
- package/dist_ts/classes.passkeymanager.js +456 -0
- package/dist_ts/classes.processinspection.d.ts +50 -0
- package/dist_ts/classes.processinspection.js +484 -0
- package/dist_ts/classes.resourcecoordinator.d.ts +64 -0
- package/dist_ts/classes.resourcecoordinator.js +294 -0
- package/dist_ts/classes.sessionintelligencereader.d.ts +13 -0
- package/dist_ts/classes.sessionintelligencereader.js +40 -0
- package/dist_ts/classes.systemmetricssampler.d.ts +53 -0
- package/dist_ts/classes.systemmetricssampler.js +168 -0
- package/dist_ts/classes.terminalmanager.d.ts +95 -0
- package/dist_ts/classes.terminalmanager.js +981 -0
- package/dist_ts/classes.terminalresourcehost.d.ts +20 -0
- package/dist_ts/classes.terminalresourcehost.js +84 -0
- package/dist_ts/classes.upgradecoordinator.d.ts +244 -0
- package/dist_ts/classes.upgradecoordinator.js +2155 -0
- package/dist_ts/classes.upgradetransaction.d.ts +92 -0
- package/dist_ts/classes.upgradetransaction.js +2125 -0
- package/dist_ts/classes.uploadmanager.d.ts +24 -0
- package/dist_ts/classes.uploadmanager.js +210 -0
- package/dist_ts/classes.validation.d.ts +91 -0
- package/dist_ts/classes.validation.js +1087 -0
- package/dist_ts/flexharness.child.d.ts +1 -0
- package/dist_ts/flexharness.child.js +28 -0
- package/dist_ts/functions.canonicaldirectory.d.ts +21 -0
- package/dist_ts/functions.canonicaldirectory.js +144 -0
- package/dist_ts/functions.controllerdataroot.d.ts +15 -0
- package/dist_ts/functions.controllerdataroot.js +76 -0
- package/dist_ts/functions.embeddeddb.d.ts +2 -0
- package/dist_ts/functions.embeddeddb.js +21 -0
- package/dist_ts/functions.flexcredentialstore.d.ts +16 -0
- package/dist_ts/functions.flexcredentialstore.js +93 -0
- package/dist_ts/functions.opencodeauth.d.ts +3 -0
- package/dist_ts/functions.opencodeauth.js +31 -0
- package/dist_ts/functions.runtimeenvironment.d.ts +2 -0
- package/dist_ts/functions.runtimeenvironment.js +79 -0
- package/dist_ts/functions.upgradefollower.d.ts +3 -0
- package/dist_ts/functions.upgradefollower.js +95 -0
- package/dist_ts/index.d.ts +24 -0
- package/dist_ts/index.js +25 -0
- package/dist_ts/interfaces.auth.d.ts +181 -0
- package/dist_ts/interfaces.auth.js +82 -0
- package/dist_ts/interfaces.config.d.ts +34 -0
- package/dist_ts/interfaces.config.js +2 -0
- package/dist_ts/interfaces.flexipc.d.ts +939 -0
- package/dist_ts/interfaces.flexipc.js +1492 -0
- package/dist_ts/interfaces.gitreversion.d.ts +246 -0
- package/dist_ts/interfaces.gitreversion.js +2 -0
- package/dist_ts/interfaces.lifecycle.d.ts +37 -0
- package/dist_ts/interfaces.lifecycle.js +2 -0
- package/dist_ts/interfaces.opencode.d.ts +101 -0
- package/dist_ts/interfaces.opencode.js +3 -0
- package/dist_ts/interfaces.projects.d.ts +156 -0
- package/dist_ts/interfaces.projects.js +5 -0
- package/dist_ts/plugins.d.ts +36 -0
- package/dist_ts/plugins.js +40 -0
- package/dist_ts_interfaces/index.d.ts +2 -0
- package/dist_ts_interfaces/index.js +3 -0
- package/dist_ts_interfaces/interfaces.d.ts +747 -0
- package/dist_ts_interfaces/interfaces.js +47 -0
- package/dist_ts_interfaces/plugins.d.ts +4 -0
- package/dist_ts_interfaces/plugins.js +3 -0
- package/dist_ts_interfaces/requests.d.ts +806 -0
- package/dist_ts_interfaces/requests.js +2 -0
- package/dist_ts_migration/classes.documentmigrationrunner.d.ts +8 -0
- package/dist_ts_migration/classes.documentmigrationrunner.js +24 -0
- package/dist_ts_migration/classes.flexmigrationrunner.d.ts +11 -0
- package/dist_ts_migration/classes.flexmigrationrunner.js +25 -0
- package/dist_ts_migration/classes.flexpostharnessmigrationrunner.d.ts +10 -0
- package/dist_ts_migration/classes.flexpostharnessmigrationrunner.js +15 -0
- package/dist_ts_migration/classes.migrationrunner.d.ts +8 -0
- package/dist_ts_migration/classes.migrationrunner.js +34 -0
- package/dist_ts_migration/index.d.ts +12 -0
- package/dist_ts_migration/index.js +13 -0
- package/dist_ts_migration/v10_flexharnessv3migration.d.ts +11 -0
- package/dist_ts_migration/v10_flexharnessv3migration.js +23 -0
- package/dist_ts_migration/v13_flexprojectionorder.d.ts +11 -0
- package/dist_ts_migration/v13_flexprojectionorder.js +23 -0
- package/dist_ts_migration/v16_flexprovidercredentials.d.ts +22 -0
- package/dist_ts_migration/v16_flexprovidercredentials.js +149 -0
- package/dist_ts_migration/v18_resourcelayout.d.ts +22 -0
- package/dist_ts_migration/v18_resourcelayout.js +23 -0
- package/dist_ts_migration/v18_resourcelayoutmigration.d.ts +6 -0
- package/dist_ts_migration/v18_resourcelayoutmigration.js +61 -0
- package/dist_ts_migration/v19_flexprojectmanagement.d.ts +11 -0
- package/dist_ts_migration/v19_flexprojectmanagement.js +21 -0
- package/dist_ts_migration/v1_embeddeddatabaselocation.d.ts +28 -0
- package/dist_ts_migration/v1_embeddeddatabaselocation.js +415 -0
- package/dist_ts_migration/v22_projectfilesystemidentitymigration.d.ts +7 -0
- package/dist_ts_migration/v22_projectfilesystemidentitymigration.js +105 -0
- package/dist_ts_migration/v2_controllerdataroot.d.ts +130 -0
- package/dist_ts_migration/v2_controllerdataroot.js +1887 -0
- package/dist_ts_migration/v6_protocoldocumentmigration.d.ts +7 -0
- package/dist_ts_migration/v6_protocoldocumentmigration.js +100 -0
- package/dist_ts_migration/v6_protocoldocuments.d.ts +7 -0
- package/dist_ts_migration/v6_protocoldocuments.js +138 -0
- package/html/index.html +13 -0
- package/{LICENSE → license.md} +2 -2
- package/package.json +86 -48
- package/readme.md +480 -0
- package/readme.plan.md +188 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/classes.authmodels.ts +1245 -0
- package/ts/classes.authstore.ts +3274 -0
- package/ts/classes.browserresourcehost.ts +1205 -0
- package/ts/classes.cli.ts +1254 -0
- package/ts/classes.config.ts +351 -0
- package/ts/classes.controller.ts +14426 -0
- package/ts/classes.controllermanagement.ts +330 -0
- package/ts/classes.draftmanager.ts +232 -0
- package/ts/classes.embeddeddb.ts +166 -0
- package/ts/classes.flexframedtransport.ts +283 -0
- package/ts/classes.flexmodels.private.ts +823 -0
- package/ts/classes.flexmodels.public.ts +732 -0
- package/ts/classes.flexprojectionreader.ts +816 -0
- package/ts/classes.flexservice.ts +4302 -0
- package/ts/classes.flexstore.ts +4565 -0
- package/ts/classes.flexsupervisor.ts +1453 -0
- package/ts/classes.gitreversion.ts +13353 -0
- package/ts/classes.harnessoperationmanager.ts +870 -0
- package/ts/classes.opencodeclient.ts +4110 -0
- package/ts/classes.opencodesupervisor.ts +1280 -0
- package/ts/classes.passkeymanager.ts +570 -0
- package/ts/classes.processinspection.ts +623 -0
- package/ts/classes.resourcecoordinator.ts +436 -0
- package/ts/classes.sessionintelligencereader.ts +52 -0
- package/ts/classes.systemmetricssampler.ts +215 -0
- package/ts/classes.terminalmanager.ts +1207 -0
- package/ts/classes.terminalresourcehost.ts +122 -0
- package/ts/classes.upgradecoordinator.ts +2675 -0
- package/ts/classes.upgradetransaction.ts +2900 -0
- package/ts/classes.uploadmanager.ts +240 -0
- package/ts/classes.validation.ts +1409 -0
- package/ts/flexharness.child.ts +35 -0
- package/ts/functions.canonicaldirectory.ts +213 -0
- package/ts/functions.controllerdataroot.ts +107 -0
- package/ts/functions.embeddeddb.ts +22 -0
- package/ts/functions.flexcredentialstore.ts +128 -0
- package/ts/functions.opencodeauth.ts +34 -0
- package/ts/functions.runtimeenvironment.ts +90 -0
- package/ts/functions.upgradefollower.ts +115 -0
- package/ts/index.ts +36 -6
- package/ts/interfaces.auth.ts +316 -0
- package/ts/interfaces.config.ts +37 -0
- package/ts/interfaces.flexipc.ts +2586 -0
- package/ts/interfaces.gitreversion.ts +336 -0
- package/ts/interfaces.lifecycle.ts +46 -0
- package/ts/interfaces.opencode.ts +153 -0
- package/ts/interfaces.projects.ts +182 -0
- package/ts/plugins.ts +64 -0
- package/ts_interfaces/index.ts +2 -0
- package/ts_interfaces/interfaces.ts +827 -0
- package/ts_interfaces/plugins.ts +15 -0
- package/ts_interfaces/requests.ts +1186 -0
- package/ts_migration/classes.documentmigrationrunner.ts +32 -0
- package/ts_migration/classes.flexmigrationrunner.ts +33 -0
- package/ts_migration/classes.flexpostharnessmigrationrunner.ts +22 -0
- package/ts_migration/classes.migrationrunner.ts +40 -0
- package/ts_migration/index.ts +12 -0
- package/ts_migration/v10_flexharnessv3migration.ts +35 -0
- package/ts_migration/v13_flexprojectionorder.ts +33 -0
- package/ts_migration/v16_flexprovidercredentials.ts +196 -0
- package/ts_migration/v18_resourcelayout.ts +55 -0
- package/ts_migration/v18_resourcelayoutmigration.ts +68 -0
- package/ts_migration/v19_flexprojectmanagement.ts +28 -0
- package/ts_migration/v1_embeddeddatabaselocation.ts +560 -0
- package/ts_migration/v22_projectfilesystemidentitymigration.ts +121 -0
- package/ts_migration/v2_controllerdataroot.ts +2349 -0
- package/ts_migration/v6_protocoldocumentmigration.ts +144 -0
- package/ts_migration/v6_protocoldocuments.ts +198 -0
- package/ts_web/00_commitinfo_data.ts +8 -0
- package/ts_web/classes.controllersocketclient.ts +517 -0
- package/ts_web/classes.sessiondraftsync.ts +390 -0
- package/ts_web/elements.harnesscontrollerapp.ts +11494 -0
- package/ts_web/index.ts +2 -0
- package/ts_web/plugins.ts +31 -0
- package/.gitlab-ci.yml +0 -71
- package/.npmignore +0 -5
- package/README.md +0 -40
- package/dist/cli.js +0 -2
- package/dist/index.d.ts +0 -0
- package/dist/index.js +0 -9
- package/dist/mod_bundle/index.d.ts +0 -1
- package/dist/mod_bundle/index.js +0 -18
- package/dist/mod_bundle/mod.plugins.d.ts +0 -4
- package/dist/mod_bundle/mod.plugins.js +0 -11
- package/dist/mod_install/index.d.ts +0 -4
- package/dist/mod_install/index.js +0 -21
- package/dist/mod_install/mod.plugins.d.ts +0 -1
- package/dist/mod_install/mod.plugins.js +0 -7
- package/dist/mod_standard/index.d.ts +0 -4
- package/dist/mod_standard/index.js +0 -21
- package/dist/mod_standard/mod.browser.d.ts +0 -6
- package/dist/mod_standard/mod.browser.js +0 -14
- package/dist/mod_standard/mod.clean.d.ts +0 -4
- package/dist/mod_standard/mod.clean.js +0 -12
- package/dist/mod_standard/mod.compile.d.ts +0 -4
- package/dist/mod_standard/mod.compile.js +0 -21
- package/dist/mod_standard/mod.plugins.d.ts +0 -8
- package/dist/mod_standard/mod.plugins.js +0 -19
- package/dist/mod_standard/mod.test.d.ts +0 -4
- package/dist/mod_standard/mod.test.js +0 -12
- package/dist/plr.cli.d.ts +0 -3
- package/dist/plr.cli.js +0 -57
- package/dist/plr.config.d.ts +0 -11
- package/dist/plr.config.js +0 -26
- package/dist/plr.mods.d.ts +0 -17
- package/dist/plr.mods.js +0 -17
- package/dist/plr.paths.d.ts +0 -19
- package/dist/plr.paths.js +0 -26
- package/dist/plr.plugins.d.ts +0 -8
- package/dist/plr.plugins.js +0 -16
- package/npmextra.json +0 -10
- package/ts/mod_bundle/index.ts +0 -20
- package/ts/mod_bundle/mod.plugins.ts +0 -9
- package/ts/mod_install/index.ts +0 -20
- package/ts/mod_install/mod.plugins.ts +0 -1
- package/ts/mod_standard/index.ts +0 -23
- package/ts/mod_standard/mod.browser.ts +0 -17
- package/ts/mod_standard/mod.clean.ts +0 -13
- package/ts/mod_standard/mod.compile.ts +0 -23
- package/ts/mod_standard/mod.plugins.ts +0 -17
- package/ts/mod_standard/mod.test.ts +0 -13
- package/ts/plr.cli.ts +0 -60
- package/ts/plr.config.ts +0 -36
- package/ts/plr.mods.ts +0 -25
- package/ts/plr.paths.ts +0 -29
- package/ts/plr.plugins.ts +0 -17
- package/tslint.json +0 -3
- package/yarn.lock +0 -3464
|
@@ -0,0 +1,1280 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import {
|
|
3
|
+
openCodeExpectedVersion,
|
|
4
|
+
openCodeUsername,
|
|
5
|
+
type IOpenCodeConnectionConfig,
|
|
6
|
+
type IOpenCodeHealth,
|
|
7
|
+
type IOpenCodeLogEntry,
|
|
8
|
+
type IOpenCodeProcessGroupController,
|
|
9
|
+
type IOpenCodeSupervisorOptions,
|
|
10
|
+
type IOpenCodeSupervisorStatus,
|
|
11
|
+
} from './interfaces.opencode.js';
|
|
12
|
+
import { createSanitizedRuntimeEnvironment } from './functions.runtimeenvironment.js';
|
|
13
|
+
|
|
14
|
+
const defaultStartupTimeoutMs = 20_000;
|
|
15
|
+
const defaultHealthRequestTimeoutMs = 1_500;
|
|
16
|
+
const defaultHealthPollIntervalMs = 150;
|
|
17
|
+
const defaultStopTimeoutMs = 5_000;
|
|
18
|
+
const defaultFinalStopTimeoutMs = 2_000;
|
|
19
|
+
const defaultLogEntryLimit = 200;
|
|
20
|
+
const defaultLogLineByteLimit = 8 * 1024;
|
|
21
|
+
const maxHealthResponseBytes = 32 * 1024;
|
|
22
|
+
const logTruncationSuffix = '… [truncated]';
|
|
23
|
+
const macOsLsofPath = '/usr/sbin/lsof';
|
|
24
|
+
|
|
25
|
+
interface IChildLifecycleOutcome {
|
|
26
|
+
type: 'exit' | 'error';
|
|
27
|
+
code?: number | null;
|
|
28
|
+
signal?: NodeJS.Signals | null;
|
|
29
|
+
error?: Error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ILogAccumulator {
|
|
33
|
+
decoder: TextDecoder;
|
|
34
|
+
partial: string;
|
|
35
|
+
droppingUntilNewline: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type TStartupOperationOutcome<T> =
|
|
39
|
+
| { type: 'value'; value: T }
|
|
40
|
+
| { type: 'operationError'; error: unknown }
|
|
41
|
+
| { type: 'lifecycle'; outcome: IChildLifecycleOutcome }
|
|
42
|
+
| { type: 'abort'; reason: unknown };
|
|
43
|
+
|
|
44
|
+
class OpenCodeVersionMismatchError extends Error {}
|
|
45
|
+
class OpenCodeListenerOwnershipError extends Error {}
|
|
46
|
+
|
|
47
|
+
const validateIntegerOption = (
|
|
48
|
+
value: number | undefined,
|
|
49
|
+
fallback: number,
|
|
50
|
+
name: string,
|
|
51
|
+
minimum: number,
|
|
52
|
+
maximum: number
|
|
53
|
+
): number => {
|
|
54
|
+
const selected = value ?? fallback;
|
|
55
|
+
if (!Number.isInteger(selected) || selected < minimum || selected > maximum) {
|
|
56
|
+
throw new Error(`${name} must be an integer between ${minimum} and ${maximum}.`);
|
|
57
|
+
}
|
|
58
|
+
return selected;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const waitForPauseOrLifecycle = async (
|
|
62
|
+
milliseconds: number,
|
|
63
|
+
lifecycle: Promise<IChildLifecycleOutcome>,
|
|
64
|
+
signal?: AbortSignal
|
|
65
|
+
): Promise<IChildLifecycleOutcome | { type: 'abort'; reason: unknown } | undefined> => {
|
|
66
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
67
|
+
let abortListener: (() => void) | undefined;
|
|
68
|
+
try {
|
|
69
|
+
const outcomes: Array<
|
|
70
|
+
Promise<IChildLifecycleOutcome | { type: 'abort'; reason: unknown } | undefined>
|
|
71
|
+
> = [
|
|
72
|
+
new Promise<undefined>((resolve) => {
|
|
73
|
+
timeout = setTimeout(() => resolve(undefined), milliseconds);
|
|
74
|
+
}),
|
|
75
|
+
lifecycle,
|
|
76
|
+
];
|
|
77
|
+
if (signal) {
|
|
78
|
+
outcomes.push(
|
|
79
|
+
new Promise<{ type: 'abort'; reason: unknown }>((resolve) => {
|
|
80
|
+
if (signal.aborted) {
|
|
81
|
+
resolve({ type: 'abort', reason: signal.reason });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
abortListener = () => resolve({ type: 'abort', reason: signal.reason });
|
|
85
|
+
signal.addEventListener('abort', abortListener, { once: true });
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return await Promise.race(outcomes);
|
|
90
|
+
} finally {
|
|
91
|
+
if (timeout) {
|
|
92
|
+
clearTimeout(timeout);
|
|
93
|
+
}
|
|
94
|
+
if (signal && abortListener) {
|
|
95
|
+
signal.removeEventListener('abort', abortListener);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const waitForStartupOperation = async <T>(
|
|
101
|
+
operation: Promise<T>,
|
|
102
|
+
lifecycle: Promise<IChildLifecycleOutcome>,
|
|
103
|
+
signal?: AbortSignal
|
|
104
|
+
): Promise<TStartupOperationOutcome<T>> => {
|
|
105
|
+
let abortListener: (() => void) | undefined;
|
|
106
|
+
try {
|
|
107
|
+
const outcomes: Array<Promise<TStartupOperationOutcome<T>>> = [
|
|
108
|
+
operation.then(
|
|
109
|
+
(value) => ({ type: 'value' as const, value }),
|
|
110
|
+
(error: unknown) => ({ type: 'operationError' as const, error })
|
|
111
|
+
),
|
|
112
|
+
lifecycle.then((outcome) => ({ type: 'lifecycle' as const, outcome })),
|
|
113
|
+
];
|
|
114
|
+
if (signal) {
|
|
115
|
+
outcomes.push(
|
|
116
|
+
new Promise<{ type: 'abort'; reason: unknown }>((resolve) => {
|
|
117
|
+
if (signal.aborted) {
|
|
118
|
+
resolve({ type: 'abort', reason: signal.reason });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
abortListener = () => resolve({ type: 'abort', reason: signal.reason });
|
|
122
|
+
signal.addEventListener('abort', abortListener, { once: true });
|
|
123
|
+
})
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return await Promise.race(outcomes);
|
|
127
|
+
} finally {
|
|
128
|
+
if (signal && abortListener) {
|
|
129
|
+
signal.removeEventListener('abort', abortListener);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const startupAbortError = (reason: unknown): Error =>
|
|
135
|
+
reason instanceof Error
|
|
136
|
+
? reason
|
|
137
|
+
: new Error('OpenCode startup was aborted.', { cause: reason });
|
|
138
|
+
|
|
139
|
+
const waitBounded = async (promise: Promise<unknown>, timeoutMs: number): Promise<boolean> => {
|
|
140
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
141
|
+
try {
|
|
142
|
+
return await Promise.race([
|
|
143
|
+
promise.then(() => true),
|
|
144
|
+
new Promise<false>((resolve) => {
|
|
145
|
+
timeout = setTimeout(() => resolve(false), timeoutMs);
|
|
146
|
+
}),
|
|
147
|
+
]);
|
|
148
|
+
} finally {
|
|
149
|
+
if (timeout) {
|
|
150
|
+
clearTimeout(timeout);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const isMissingProcessError = (errorArg: unknown): boolean =>
|
|
156
|
+
typeof errorArg === 'object'
|
|
157
|
+
&& errorArg !== null
|
|
158
|
+
&& 'code' in errorArg
|
|
159
|
+
&& errorArg.code === 'ESRCH';
|
|
160
|
+
|
|
161
|
+
const defaultProcessGroupController: IOpenCodeProcessGroupController = {
|
|
162
|
+
isAlive: (processGroupIdArg) => {
|
|
163
|
+
try {
|
|
164
|
+
process.kill(-processGroupIdArg, 0);
|
|
165
|
+
return true;
|
|
166
|
+
} catch (errorArg) {
|
|
167
|
+
if (isMissingProcessError(errorArg)) return false;
|
|
168
|
+
throw errorArg;
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
signal: (processGroupIdArg, signalArg) => {
|
|
172
|
+
try {
|
|
173
|
+
process.kill(-processGroupIdArg, signalArg);
|
|
174
|
+
} catch (errorArg) {
|
|
175
|
+
if (!isMissingProcessError(errorArg)) throw errorArg;
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const waitForProcessGroupExit = async (
|
|
181
|
+
processGroupControllerArg: IOpenCodeProcessGroupController,
|
|
182
|
+
processGroupIdArg: number,
|
|
183
|
+
timeoutMsArg: number,
|
|
184
|
+
): Promise<boolean> => {
|
|
185
|
+
const deadline = Date.now() + timeoutMsArg;
|
|
186
|
+
while (await processGroupControllerArg.isAlive(processGroupIdArg)) {
|
|
187
|
+
const remainingMs = deadline - Date.now();
|
|
188
|
+
if (remainingMs <= 0) return false;
|
|
189
|
+
await new Promise<void>((resolve) => {
|
|
190
|
+
setTimeout(resolve, Math.min(20, remainingMs));
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return true;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const truncateLogLine = (value: string, byteLimit: number): { line: string; truncated: boolean } => {
|
|
197
|
+
if (Buffer.byteLength(value, 'utf8') <= byteLimit) {
|
|
198
|
+
return { line: value, truncated: false };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const suffixBytes = Buffer.byteLength(logTruncationSuffix, 'utf8');
|
|
202
|
+
const contentBytes = Math.max(0, byteLimit - suffixBytes);
|
|
203
|
+
let line = Buffer.from(value, 'utf8').subarray(0, contentBytes).toString('utf8');
|
|
204
|
+
line = line.replace(/\uFFFD$/u, '');
|
|
205
|
+
return {
|
|
206
|
+
line: `${line}${logTruncationSuffix}`,
|
|
207
|
+
truncated: true,
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const readBoundedResponseText = async (response: Response): Promise<string> => {
|
|
212
|
+
if (!response.body) {
|
|
213
|
+
return '';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const reader = response.body.getReader();
|
|
217
|
+
const chunks: Uint8Array[] = [];
|
|
218
|
+
let byteLength = 0;
|
|
219
|
+
try {
|
|
220
|
+
while (true) {
|
|
221
|
+
const { done, value } = await reader.read();
|
|
222
|
+
if (done) {
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
byteLength += value.byteLength;
|
|
226
|
+
if (byteLength > maxHealthResponseBytes) {
|
|
227
|
+
await reader.cancel('OpenCode health response exceeded its size limit.');
|
|
228
|
+
throw new Error('OpenCode health response exceeded its size limit.');
|
|
229
|
+
}
|
|
230
|
+
chunks.push(value);
|
|
231
|
+
}
|
|
232
|
+
} finally {
|
|
233
|
+
reader.releaseLock();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const body = new Uint8Array(byteLength);
|
|
237
|
+
let offset = 0;
|
|
238
|
+
for (const chunk of chunks) {
|
|
239
|
+
body.set(chunk, offset);
|
|
240
|
+
offset += chunk.byteLength;
|
|
241
|
+
}
|
|
242
|
+
return new TextDecoder().decode(body);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const createOpenCodeEnvironment = (password: string): NodeJS.ProcessEnv => {
|
|
246
|
+
const environment = createSanitizedRuntimeEnvironment();
|
|
247
|
+
environment.OPENCODE_SERVER_USERNAME = openCodeUsername;
|
|
248
|
+
environment.OPENCODE_SERVER_PASSWORD = password;
|
|
249
|
+
return environment;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
253
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
254
|
+
|
|
255
|
+
const detectLinuxLibc = (): 'glibc' | 'musl' => {
|
|
256
|
+
const report = process.report?.getReport();
|
|
257
|
+
if (isRecord(report)) {
|
|
258
|
+
const header = isRecord(report.header) ? report.header : undefined;
|
|
259
|
+
if (
|
|
260
|
+
typeof header?.glibcVersionRuntime === 'string' &&
|
|
261
|
+
header.glibcVersionRuntime.length > 0
|
|
262
|
+
) {
|
|
263
|
+
return 'glibc';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (
|
|
267
|
+
Array.isArray(report.sharedObjects) &&
|
|
268
|
+
report.sharedObjects.some(
|
|
269
|
+
(entry) =>
|
|
270
|
+
typeof entry === 'string' &&
|
|
271
|
+
(entry.includes('/ld-musl-') || entry.includes('/libc.musl-'))
|
|
272
|
+
)
|
|
273
|
+
) {
|
|
274
|
+
return 'musl';
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (plugins.fs.existsSync('/etc/alpine-release')) {
|
|
279
|
+
return 'musl';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
throw new Error('Unable to determine whether this Linux runtime uses glibc or musl.');
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const resolvePlatformPackage = (): { packageName: string; binaryName: string } => {
|
|
286
|
+
if (process.platform === 'linux') {
|
|
287
|
+
const libcSuffix = detectLinuxLibc() === 'musl' ? '-musl' : '';
|
|
288
|
+
if (process.arch === 'x64') {
|
|
289
|
+
return {
|
|
290
|
+
packageName: `opencode-linux-x64-baseline${libcSuffix}`,
|
|
291
|
+
binaryName: 'opencode',
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (process.arch === 'arm64') {
|
|
295
|
+
return {
|
|
296
|
+
packageName: `opencode-linux-arm64${libcSuffix}`,
|
|
297
|
+
binaryName: 'opencode',
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
} else if (process.platform === 'darwin') {
|
|
301
|
+
if (process.arch === 'x64') {
|
|
302
|
+
return {
|
|
303
|
+
packageName: 'opencode-darwin-x64-baseline',
|
|
304
|
+
binaryName: 'opencode',
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
if (process.arch === 'arm64') {
|
|
308
|
+
return {
|
|
309
|
+
packageName: 'opencode-darwin-arm64',
|
|
310
|
+
binaryName: 'opencode',
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
} else if (process.platform === 'win32') {
|
|
314
|
+
if (process.arch === 'x64') {
|
|
315
|
+
return {
|
|
316
|
+
packageName: 'opencode-windows-x64-baseline',
|
|
317
|
+
binaryName: 'opencode.exe',
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (process.arch === 'arm64') {
|
|
321
|
+
return {
|
|
322
|
+
packageName: 'opencode-windows-arm64',
|
|
323
|
+
binaryName: 'opencode.exe',
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
throw new Error(
|
|
329
|
+
`OpenCode does not provide a supported package for ${process.platform}/${process.arch}.`
|
|
330
|
+
);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const collectLinuxLoopbackListenerInodes = (port: number): Set<string> => {
|
|
334
|
+
const portHex = port.toString(16).toUpperCase().padStart(4, '0');
|
|
335
|
+
const expectedAddress = `0100007F:${portHex}`;
|
|
336
|
+
const inodes = new Set<string>();
|
|
337
|
+
const table = plugins.fs.readFileSync('/proc/net/tcp', 'utf8');
|
|
338
|
+
for (const line of table.split('\n').slice(1)) {
|
|
339
|
+
const fields = line.trim().split(/\s+/u);
|
|
340
|
+
if (fields.length >= 10 && fields[1] === expectedAddress && fields[3] === '0A') {
|
|
341
|
+
inodes.add(fields[9]);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return inodes;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const verifyLinuxListenerOwnership = (processId: number, port: number): boolean => {
|
|
348
|
+
const listenerInodes = collectLinuxLoopbackListenerInodes(port);
|
|
349
|
+
if (listenerInodes.size === 0) {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const descriptorDirectory = `/proc/${processId}/fd`;
|
|
354
|
+
let descriptorNames: string[];
|
|
355
|
+
try {
|
|
356
|
+
descriptorNames = plugins.fs.readdirSync(descriptorDirectory);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
throw new Error(`Unable to inspect OpenCode process ${processId} file descriptors.`, {
|
|
362
|
+
cause: error,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (const descriptorName of descriptorNames) {
|
|
367
|
+
let target: string;
|
|
368
|
+
try {
|
|
369
|
+
target = plugins.fs.readlinkSync(
|
|
370
|
+
plugins.path.join(descriptorDirectory, descriptorName)
|
|
371
|
+
);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
throw new Error(`Unable to inspect OpenCode process ${processId} socket ownership.`, {
|
|
377
|
+
cause: error,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
const match = /^socket:\[(\d+)\]$/u.exec(target);
|
|
381
|
+
if (match?.[1] && listenerInodes.has(match[1])) {
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return false;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const verifyMacOsListenerOwnership = (processId: number, port: number): boolean => {
|
|
389
|
+
const result = plugins.childProcess.spawnSync(
|
|
390
|
+
macOsLsofPath,
|
|
391
|
+
[
|
|
392
|
+
'-nP',
|
|
393
|
+
'-a',
|
|
394
|
+
'-p',
|
|
395
|
+
String(processId),
|
|
396
|
+
`-iTCP@127.0.0.1:${port}`,
|
|
397
|
+
'-sTCP:LISTEN',
|
|
398
|
+
'-Fpn',
|
|
399
|
+
],
|
|
400
|
+
{
|
|
401
|
+
encoding: 'utf8',
|
|
402
|
+
maxBuffer: 64 * 1024,
|
|
403
|
+
timeout: 1_500,
|
|
404
|
+
windowsHide: true,
|
|
405
|
+
}
|
|
406
|
+
);
|
|
407
|
+
if (result.error) {
|
|
408
|
+
throw new Error('Unable to inspect OpenCode listener ownership with lsof.', {
|
|
409
|
+
cause: result.error,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
if (result.status === 1) {
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
if (result.status !== 0) {
|
|
416
|
+
throw new Error(`lsof failed while inspecting OpenCode with status ${result.status}.`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const lines = result.stdout.split(/\r?\n/u);
|
|
420
|
+
return (
|
|
421
|
+
lines.includes(`p${processId}`) &&
|
|
422
|
+
lines.some((line) => line === `n127.0.0.1:${port}`)
|
|
423
|
+
);
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const verifyWindowsListenerOwnership = (processId: number, port: number): boolean => {
|
|
427
|
+
const systemRoot = process.env.SystemRoot;
|
|
428
|
+
if (!systemRoot || !plugins.path.isAbsolute(systemRoot)) {
|
|
429
|
+
throw new Error('SystemRoot is unavailable for Windows listener ownership verification.');
|
|
430
|
+
}
|
|
431
|
+
const resolvedSystemRoot = plugins.fs.realpathSync.native(systemRoot);
|
|
432
|
+
const netstatPath = plugins.fs.realpathSync.native(
|
|
433
|
+
plugins.path.join(resolvedSystemRoot, 'System32', 'netstat.exe')
|
|
434
|
+
);
|
|
435
|
+
const relativeNetstatPath = plugins.path.relative(resolvedSystemRoot, netstatPath);
|
|
436
|
+
if (
|
|
437
|
+
relativeNetstatPath.startsWith(`..${plugins.path.sep}`) ||
|
|
438
|
+
relativeNetstatPath === '..' ||
|
|
439
|
+
plugins.path.isAbsolute(relativeNetstatPath)
|
|
440
|
+
) {
|
|
441
|
+
throw new Error('The Windows netstat executable escaped SystemRoot.');
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const result = plugins.childProcess.spawnSync(netstatPath, ['-ano', '-p', 'tcp'], {
|
|
445
|
+
encoding: 'utf8',
|
|
446
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
447
|
+
timeout: 1_500,
|
|
448
|
+
windowsHide: true,
|
|
449
|
+
});
|
|
450
|
+
if (result.error) {
|
|
451
|
+
throw new Error('Unable to inspect OpenCode listener ownership with netstat.', {
|
|
452
|
+
cause: result.error,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
if (result.status !== 0) {
|
|
456
|
+
throw new Error(`netstat failed while inspecting OpenCode with status ${result.status}.`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const expectedEndpoint = `127.0.0.1:${port}`;
|
|
460
|
+
return result.stdout.split(/\r?\n/u).some((line) => {
|
|
461
|
+
const fields = line.trim().split(/\s+/u);
|
|
462
|
+
return (
|
|
463
|
+
fields.length >= 5 &&
|
|
464
|
+
fields[0]?.toUpperCase() === 'TCP' &&
|
|
465
|
+
fields[1] === expectedEndpoint &&
|
|
466
|
+
fields[3]?.toUpperCase() === 'LISTENING' &&
|
|
467
|
+
fields[4] === String(processId)
|
|
468
|
+
);
|
|
469
|
+
});
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
export const verifyOpenCodeListenerOwnership = (
|
|
473
|
+
processId: number,
|
|
474
|
+
port: number
|
|
475
|
+
): boolean => {
|
|
476
|
+
if (!Number.isSafeInteger(processId) || processId <= 0) {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
if (process.platform === 'linux') {
|
|
480
|
+
return verifyLinuxListenerOwnership(processId, port);
|
|
481
|
+
}
|
|
482
|
+
if (process.platform === 'darwin') {
|
|
483
|
+
return verifyMacOsListenerOwnership(processId, port);
|
|
484
|
+
}
|
|
485
|
+
if (process.platform === 'win32') {
|
|
486
|
+
return verifyWindowsListenerOwnership(processId, port);
|
|
487
|
+
}
|
|
488
|
+
throw new Error(`Listener ownership verification is unsupported on ${process.platform}.`);
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
export class OpenCodeSupervisor {
|
|
492
|
+
public readonly directory: string;
|
|
493
|
+
public readonly port: number;
|
|
494
|
+
public readonly baseUrl: string;
|
|
495
|
+
|
|
496
|
+
private readonly executablePath: string;
|
|
497
|
+
private readonly password: string;
|
|
498
|
+
private readonly startupTimeoutMs: number;
|
|
499
|
+
private readonly healthRequestTimeoutMs: number;
|
|
500
|
+
private readonly healthPollIntervalMs: number;
|
|
501
|
+
private readonly stopTimeoutMs: number;
|
|
502
|
+
private readonly finalStopTimeoutMs: number;
|
|
503
|
+
private readonly logEntryLimit: number;
|
|
504
|
+
private readonly logLineByteLimit: number;
|
|
505
|
+
private readonly spawnFactory: typeof plugins.childProcess.spawn;
|
|
506
|
+
private readonly fetchImplementation: typeof globalThis.fetch;
|
|
507
|
+
private readonly listenerOwnershipVerifier: NonNullable<
|
|
508
|
+
IOpenCodeSupervisorOptions['listenerOwnershipVerifier']
|
|
509
|
+
>;
|
|
510
|
+
private readonly processGroupController?: IOpenCodeProcessGroupController;
|
|
511
|
+
private readonly onChildExit?: () => void;
|
|
512
|
+
|
|
513
|
+
private state: IOpenCodeSupervisorStatus['state'] = 'stopped';
|
|
514
|
+
private healthy = false;
|
|
515
|
+
private version?: string;
|
|
516
|
+
private startedAt?: number;
|
|
517
|
+
private child?: plugins.childProcess.ChildProcess;
|
|
518
|
+
private childLifecycle?: Promise<IChildLifecycleOutcome>;
|
|
519
|
+
private ownedProcessGroupId?: number;
|
|
520
|
+
private orphanedProcessGroupId?: number;
|
|
521
|
+
private orphanReapTask?: Promise<void>;
|
|
522
|
+
private childExitCleanupPending = false;
|
|
523
|
+
private startPromise?: Promise<IOpenCodeSupervisorStatus>;
|
|
524
|
+
private stopPromise?: Promise<void>;
|
|
525
|
+
private readonly logs: IOpenCodeLogEntry[] = [];
|
|
526
|
+
private readonly stdoutAccumulator: ILogAccumulator = {
|
|
527
|
+
decoder: new TextDecoder(),
|
|
528
|
+
partial: '',
|
|
529
|
+
droppingUntilNewline: false,
|
|
530
|
+
};
|
|
531
|
+
private readonly stderrAccumulator: ILogAccumulator = {
|
|
532
|
+
decoder: new TextDecoder(),
|
|
533
|
+
partial: '',
|
|
534
|
+
droppingUntilNewline: false,
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
constructor(options: IOpenCodeSupervisorOptions) {
|
|
538
|
+
this.directory = plugins.fs.realpathSync.native(options.directory);
|
|
539
|
+
const directoryStat = plugins.fs.statSync(this.directory);
|
|
540
|
+
if (!directoryStat.isDirectory()) {
|
|
541
|
+
throw new Error('The configured OpenCode working directory is not a directory.');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
this.port = validateIntegerOption(options.port, options.port, 'OpenCode port', 1, 65_535);
|
|
545
|
+
this.baseUrl = `http://127.0.0.1:${this.port}`;
|
|
546
|
+
this.startupTimeoutMs = validateIntegerOption(
|
|
547
|
+
options.startupTimeoutMs,
|
|
548
|
+
defaultStartupTimeoutMs,
|
|
549
|
+
'OpenCode startup timeout',
|
|
550
|
+
1,
|
|
551
|
+
300_000
|
|
552
|
+
);
|
|
553
|
+
this.healthRequestTimeoutMs = validateIntegerOption(
|
|
554
|
+
options.healthRequestTimeoutMs,
|
|
555
|
+
defaultHealthRequestTimeoutMs,
|
|
556
|
+
'OpenCode health request timeout',
|
|
557
|
+
1,
|
|
558
|
+
60_000
|
|
559
|
+
);
|
|
560
|
+
this.healthPollIntervalMs = validateIntegerOption(
|
|
561
|
+
options.healthPollIntervalMs,
|
|
562
|
+
defaultHealthPollIntervalMs,
|
|
563
|
+
'OpenCode health poll interval',
|
|
564
|
+
1,
|
|
565
|
+
60_000
|
|
566
|
+
);
|
|
567
|
+
this.stopTimeoutMs = validateIntegerOption(
|
|
568
|
+
options.stopTimeoutMs,
|
|
569
|
+
defaultStopTimeoutMs,
|
|
570
|
+
'OpenCode stop timeout',
|
|
571
|
+
1,
|
|
572
|
+
300_000
|
|
573
|
+
);
|
|
574
|
+
this.finalStopTimeoutMs = validateIntegerOption(
|
|
575
|
+
options.finalStopTimeoutMs,
|
|
576
|
+
defaultFinalStopTimeoutMs,
|
|
577
|
+
'OpenCode final stop timeout',
|
|
578
|
+
1,
|
|
579
|
+
60_000
|
|
580
|
+
);
|
|
581
|
+
this.logEntryLimit = validateIntegerOption(
|
|
582
|
+
options.logEntryLimit,
|
|
583
|
+
defaultLogEntryLimit,
|
|
584
|
+
'OpenCode log entry limit',
|
|
585
|
+
1,
|
|
586
|
+
10_000
|
|
587
|
+
);
|
|
588
|
+
this.logLineByteLimit = validateIntegerOption(
|
|
589
|
+
options.logLineByteLimit,
|
|
590
|
+
defaultLogLineByteLimit,
|
|
591
|
+
'OpenCode log line byte limit',
|
|
592
|
+
16,
|
|
593
|
+
1024 * 1024
|
|
594
|
+
);
|
|
595
|
+
this.spawnFactory = options.spawnFactory ?? plugins.childProcess.spawn;
|
|
596
|
+
this.fetchImplementation = options.fetchImplementation ?? globalThis.fetch;
|
|
597
|
+
this.listenerOwnershipVerifier =
|
|
598
|
+
options.listenerOwnershipVerifier ?? verifyOpenCodeListenerOwnership;
|
|
599
|
+
this.processGroupController = process.platform !== 'win32'
|
|
600
|
+
&& (options.spawnFactory === undefined || options.processGroupController !== undefined)
|
|
601
|
+
? (options.processGroupController ?? defaultProcessGroupController)
|
|
602
|
+
: undefined;
|
|
603
|
+
this.onChildExit = options.onChildExit;
|
|
604
|
+
this.password = plugins.crypto.randomBytes(32).toString('base64url');
|
|
605
|
+
this.executablePath = this.resolveExecutablePath();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
public getStatus(): IOpenCodeSupervisorStatus {
|
|
609
|
+
return {
|
|
610
|
+
state: this.state,
|
|
611
|
+
healthy: this.healthy,
|
|
612
|
+
...(this.child?.pid ? { pid: this.child.pid } : {}),
|
|
613
|
+
...(this.version ? { version: this.version } : {}),
|
|
614
|
+
...(this.startedAt ? { startedAt: this.startedAt } : {}),
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
public getConnectionConfig(): IOpenCodeConnectionConfig {
|
|
619
|
+
return {
|
|
620
|
+
baseUrl: this.baseUrl,
|
|
621
|
+
directory: this.directory,
|
|
622
|
+
username: openCodeUsername,
|
|
623
|
+
password: this.password,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
public getLogSnapshot(): IOpenCodeLogEntry[] {
|
|
628
|
+
return this.logs.map((entry) => ({ ...entry }));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
public async start(signal?: AbortSignal): Promise<IOpenCodeSupervisorStatus> {
|
|
632
|
+
if (this.state === 'ready' && this.child) {
|
|
633
|
+
return this.getStatus();
|
|
634
|
+
}
|
|
635
|
+
if (this.startPromise) {
|
|
636
|
+
return this.startPromise;
|
|
637
|
+
}
|
|
638
|
+
if (this.state === 'stopping') {
|
|
639
|
+
throw new Error('OpenCode cannot start while it is stopping.');
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const startPromise = this.performStart(signal);
|
|
643
|
+
this.startPromise = startPromise;
|
|
644
|
+
try {
|
|
645
|
+
return await startPromise;
|
|
646
|
+
} finally {
|
|
647
|
+
if (this.startPromise === startPromise) {
|
|
648
|
+
this.startPromise = undefined;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
public async checkHealth(): Promise<IOpenCodeHealth> {
|
|
654
|
+
try {
|
|
655
|
+
const health = await this.fetchHealth();
|
|
656
|
+
this.healthy = true;
|
|
657
|
+
this.version = health.version;
|
|
658
|
+
return health;
|
|
659
|
+
} catch (error) {
|
|
660
|
+
this.healthy = false;
|
|
661
|
+
throw error;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
public async stop(): Promise<void> {
|
|
666
|
+
if (this.stopPromise) {
|
|
667
|
+
return this.stopPromise;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const child = this.child;
|
|
671
|
+
const lifecycle = this.childLifecycle;
|
|
672
|
+
if (!child || !lifecycle) {
|
|
673
|
+
if (this.ownedProcessGroupId !== undefined) {
|
|
674
|
+
throw new Error('OpenCode process-group ownership outlived child lifecycle tracking.');
|
|
675
|
+
}
|
|
676
|
+
await this.drainOrphanedProcessGroup();
|
|
677
|
+
this.child = undefined;
|
|
678
|
+
this.childLifecycle = undefined;
|
|
679
|
+
this.healthy = false;
|
|
680
|
+
this.state = 'stopped';
|
|
681
|
+
this.startedAt = undefined;
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const stopPromise = this.performStop(child, lifecycle);
|
|
686
|
+
this.stopPromise = stopPromise;
|
|
687
|
+
try {
|
|
688
|
+
await stopPromise;
|
|
689
|
+
} finally {
|
|
690
|
+
if (this.stopPromise === stopPromise) {
|
|
691
|
+
this.stopPromise = undefined;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private resolveExecutablePath(): string {
|
|
697
|
+
const require = plugins.createRequire(import.meta.url);
|
|
698
|
+
const platformPackage = resolvePlatformPackage();
|
|
699
|
+
const platformPackageJsonPath = plugins.fs.realpathSync.native(
|
|
700
|
+
require.resolve(`${platformPackage.packageName}/package.json`)
|
|
701
|
+
);
|
|
702
|
+
const installedPackageJson = JSON.parse(
|
|
703
|
+
plugins.fs.readFileSync(platformPackageJsonPath, 'utf8')
|
|
704
|
+
) as unknown;
|
|
705
|
+
if (
|
|
706
|
+
!isRecord(installedPackageJson) ||
|
|
707
|
+
installedPackageJson.name !== platformPackage.packageName ||
|
|
708
|
+
installedPackageJson.version !== openCodeExpectedVersion
|
|
709
|
+
) {
|
|
710
|
+
throw new Error(
|
|
711
|
+
`Expected ${platformPackage.packageName} ${openCodeExpectedVersion}.`
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const packageDirectory = plugins.fs.realpathSync.native(
|
|
716
|
+
plugins.path.dirname(platformPackageJsonPath)
|
|
717
|
+
);
|
|
718
|
+
const executablePath = plugins.fs.realpathSync.native(
|
|
719
|
+
plugins.path.join(packageDirectory, 'bin', platformPackage.binaryName)
|
|
720
|
+
);
|
|
721
|
+
const relativeExecutablePath = plugins.path.relative(packageDirectory, executablePath);
|
|
722
|
+
if (
|
|
723
|
+
relativeExecutablePath.startsWith(`..${plugins.path.sep}`) ||
|
|
724
|
+
relativeExecutablePath === '..' ||
|
|
725
|
+
plugins.path.isAbsolute(relativeExecutablePath)
|
|
726
|
+
) {
|
|
727
|
+
throw new Error('The package-owned OpenCode executable escaped its package directory.');
|
|
728
|
+
}
|
|
729
|
+
const executableStat = plugins.fs.statSync(executablePath);
|
|
730
|
+
if (
|
|
731
|
+
!executableStat.isFile() ||
|
|
732
|
+
(process.platform !== 'win32' && (executableStat.mode & 0o111) === 0)
|
|
733
|
+
) {
|
|
734
|
+
throw new Error('The package-owned OpenCode executable is not a file.');
|
|
735
|
+
}
|
|
736
|
+
return executablePath;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
private async performStart(signal?: AbortSignal): Promise<IOpenCodeSupervisorStatus> {
|
|
740
|
+
this.state = 'starting';
|
|
741
|
+
this.healthy = false;
|
|
742
|
+
this.version = undefined;
|
|
743
|
+
this.startedAt = undefined;
|
|
744
|
+
|
|
745
|
+
try {
|
|
746
|
+
signal?.throwIfAborted();
|
|
747
|
+
const child = this.spawnFactory(
|
|
748
|
+
this.executablePath,
|
|
749
|
+
[
|
|
750
|
+
'serve',
|
|
751
|
+
'--hostname',
|
|
752
|
+
'127.0.0.1',
|
|
753
|
+
'--port',
|
|
754
|
+
String(this.port),
|
|
755
|
+
'--mdns=false',
|
|
756
|
+
'--print-logs',
|
|
757
|
+
],
|
|
758
|
+
{
|
|
759
|
+
cwd: this.directory,
|
|
760
|
+
detached: this.processGroupController !== undefined,
|
|
761
|
+
shell: false,
|
|
762
|
+
windowsHide: true,
|
|
763
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
764
|
+
env: createOpenCodeEnvironment(this.password),
|
|
765
|
+
}
|
|
766
|
+
);
|
|
767
|
+
this.child = child;
|
|
768
|
+
this.childLifecycle = this.observeChild(child);
|
|
769
|
+
|
|
770
|
+
if (!child.pid) {
|
|
771
|
+
throw new Error('OpenCode did not provide a process ID after spawn.');
|
|
772
|
+
}
|
|
773
|
+
if (this.processGroupController) {
|
|
774
|
+
this.ownedProcessGroupId = child.pid;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const deadline = Date.now() + this.startupTimeoutMs;
|
|
778
|
+
let lastHealthError: unknown;
|
|
779
|
+
while (Date.now() < deadline) {
|
|
780
|
+
const lifecycle = this.childLifecycle;
|
|
781
|
+
if (!lifecycle) {
|
|
782
|
+
throw new Error('OpenCode child lifecycle tracking was lost.');
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const ownershipOutcome = await waitForStartupOperation(
|
|
786
|
+
Promise.resolve().then(() => this.listenerOwnershipVerifier(child.pid!, this.port)),
|
|
787
|
+
lifecycle,
|
|
788
|
+
signal
|
|
789
|
+
);
|
|
790
|
+
if (ownershipOutcome.type === 'abort') {
|
|
791
|
+
throw startupAbortError(ownershipOutcome.reason);
|
|
792
|
+
}
|
|
793
|
+
if (ownershipOutcome.type === 'lifecycle') {
|
|
794
|
+
throw this.lifecycleError(
|
|
795
|
+
ownershipOutcome.outcome,
|
|
796
|
+
'OpenCode exited during startup.'
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
if (ownershipOutcome.type === 'operationError') {
|
|
800
|
+
throw new OpenCodeListenerOwnershipError(
|
|
801
|
+
'OpenCode listener ownership could not be verified.',
|
|
802
|
+
{ cause: ownershipOutcome.error }
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
const listenerOwned = ownershipOutcome.value;
|
|
806
|
+
|
|
807
|
+
if (!listenerOwned) {
|
|
808
|
+
lastHealthError = new OpenCodeListenerOwnershipError(
|
|
809
|
+
'The loopback listener is not owned by the spawned OpenCode process.'
|
|
810
|
+
);
|
|
811
|
+
const remainingMs = deadline - Date.now();
|
|
812
|
+
if (remainingMs <= 0) {
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
const pauseOutcome = await waitForPauseOrLifecycle(
|
|
816
|
+
Math.min(this.healthPollIntervalMs, remainingMs),
|
|
817
|
+
lifecycle,
|
|
818
|
+
signal
|
|
819
|
+
);
|
|
820
|
+
if (pauseOutcome) {
|
|
821
|
+
if (pauseOutcome.type === 'abort') {
|
|
822
|
+
throw startupAbortError(pauseOutcome.reason);
|
|
823
|
+
}
|
|
824
|
+
throw this.lifecycleError(pauseOutcome, 'OpenCode exited during startup.');
|
|
825
|
+
}
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const healthOutcome = await waitForStartupOperation(
|
|
830
|
+
this.fetchHealth(signal),
|
|
831
|
+
lifecycle,
|
|
832
|
+
signal
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
if (healthOutcome.type === 'abort') {
|
|
836
|
+
throw startupAbortError(healthOutcome.reason);
|
|
837
|
+
}
|
|
838
|
+
if (healthOutcome.type === 'lifecycle') {
|
|
839
|
+
throw this.lifecycleError(healthOutcome.outcome, 'OpenCode exited during startup.');
|
|
840
|
+
}
|
|
841
|
+
if (healthOutcome.type === 'value') {
|
|
842
|
+
const secondOwnershipOutcome = await waitForStartupOperation(
|
|
843
|
+
Promise.resolve().then(() =>
|
|
844
|
+
this.listenerOwnershipVerifier(child.pid!, this.port)
|
|
845
|
+
),
|
|
846
|
+
lifecycle,
|
|
847
|
+
signal
|
|
848
|
+
);
|
|
849
|
+
if (secondOwnershipOutcome.type === 'abort') {
|
|
850
|
+
throw startupAbortError(secondOwnershipOutcome.reason);
|
|
851
|
+
}
|
|
852
|
+
if (secondOwnershipOutcome.type === 'lifecycle') {
|
|
853
|
+
throw this.lifecycleError(
|
|
854
|
+
secondOwnershipOutcome.outcome,
|
|
855
|
+
'OpenCode exited during startup.'
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
if (secondOwnershipOutcome.type === 'operationError') {
|
|
859
|
+
throw new OpenCodeListenerOwnershipError(
|
|
860
|
+
'OpenCode listener ownership could not be reverified.',
|
|
861
|
+
{ cause: secondOwnershipOutcome.error }
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
const stillOwned = secondOwnershipOutcome.value;
|
|
865
|
+
if (!stillOwned) {
|
|
866
|
+
throw new OpenCodeListenerOwnershipError(
|
|
867
|
+
'OpenCode lost ownership of its loopback listener during startup.'
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
if (child.exitCode !== null || child.signalCode !== null || this.child !== child) {
|
|
871
|
+
throw new Error('OpenCode exited while completing its health check.');
|
|
872
|
+
}
|
|
873
|
+
this.healthy = true;
|
|
874
|
+
this.version = healthOutcome.value.version;
|
|
875
|
+
this.startedAt = Date.now();
|
|
876
|
+
this.state = 'ready';
|
|
877
|
+
return this.getStatus();
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
lastHealthError = healthOutcome.error;
|
|
881
|
+
if (lastHealthError instanceof OpenCodeVersionMismatchError) {
|
|
882
|
+
throw lastHealthError;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const remainingMs = deadline - Date.now();
|
|
886
|
+
if (remainingMs <= 0) {
|
|
887
|
+
break;
|
|
888
|
+
}
|
|
889
|
+
const pauseOutcome = await waitForPauseOrLifecycle(
|
|
890
|
+
Math.min(this.healthPollIntervalMs, remainingMs),
|
|
891
|
+
lifecycle,
|
|
892
|
+
signal
|
|
893
|
+
);
|
|
894
|
+
if (pauseOutcome) {
|
|
895
|
+
if (pauseOutcome.type === 'abort') {
|
|
896
|
+
throw startupAbortError(pauseOutcome.reason);
|
|
897
|
+
}
|
|
898
|
+
throw this.lifecycleError(pauseOutcome, 'OpenCode exited during startup.');
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
throw new Error(`OpenCode did not become healthy within ${this.startupTimeoutMs}ms.`, {
|
|
903
|
+
cause: lastHealthError,
|
|
904
|
+
});
|
|
905
|
+
} catch (error) {
|
|
906
|
+
let cleanupError: unknown;
|
|
907
|
+
try {
|
|
908
|
+
await this.stop();
|
|
909
|
+
} catch (caughtCleanupError) {
|
|
910
|
+
cleanupError = caughtCleanupError;
|
|
911
|
+
}
|
|
912
|
+
this.state = 'failed';
|
|
913
|
+
this.healthy = false;
|
|
914
|
+
this.startedAt = undefined;
|
|
915
|
+
|
|
916
|
+
if (cleanupError) {
|
|
917
|
+
throw new AggregateError(
|
|
918
|
+
[error, cleanupError],
|
|
919
|
+
'OpenCode startup failed and its child process could not be cleaned up.'
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
throw new Error('OpenCode startup failed.', { cause: error });
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* best-effort reaping of group members that survived their leader's exit.
|
|
928
|
+
* Runs at exit time because that is when the pgid is still provably ours:
|
|
929
|
+
* living members keep it reserved, and an already-empty group answers ESRCH.
|
|
930
|
+
*/
|
|
931
|
+
private async reapOrphanedGroup(processGroupIdArg: number): Promise<void> {
|
|
932
|
+
if (!this.processGroupController) return;
|
|
933
|
+
if (!(await this.processGroupController.isAlive(processGroupIdArg))) return;
|
|
934
|
+
await this.processGroupController.signal(processGroupIdArg, 'SIGTERM');
|
|
935
|
+
let exited = await waitForProcessGroupExit(
|
|
936
|
+
this.processGroupController,
|
|
937
|
+
processGroupIdArg,
|
|
938
|
+
this.stopTimeoutMs,
|
|
939
|
+
);
|
|
940
|
+
if (!exited) {
|
|
941
|
+
await this.processGroupController.signal(processGroupIdArg, 'SIGKILL');
|
|
942
|
+
exited = await waitForProcessGroupExit(
|
|
943
|
+
this.processGroupController,
|
|
944
|
+
processGroupIdArg,
|
|
945
|
+
this.finalStopTimeoutMs,
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
if (!exited) throw new Error('An owned OpenCode process-group member survived SIGKILL.');
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
private notifyChildExitCleanup(): void {
|
|
952
|
+
if (!this.childExitCleanupPending) return;
|
|
953
|
+
this.childExitCleanupPending = false;
|
|
954
|
+
try {
|
|
955
|
+
this.onChildExit?.();
|
|
956
|
+
} catch {
|
|
957
|
+
// Child lifecycle cleanup must not prevent lifecycle settlement.
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
private async drainOrphanedProcessGroup(): Promise<void> {
|
|
962
|
+
const orphanReapTask = this.orphanReapTask;
|
|
963
|
+
if (orphanReapTask) await orphanReapTask;
|
|
964
|
+
const processGroupId = this.orphanedProcessGroupId;
|
|
965
|
+
if (processGroupId !== undefined) {
|
|
966
|
+
await this.reapOrphanedGroup(processGroupId);
|
|
967
|
+
if (this.orphanedProcessGroupId === processGroupId) {
|
|
968
|
+
this.orphanedProcessGroupId = undefined;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
this.notifyChildExitCleanup();
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
private observeChild(child: plugins.childProcess.ChildProcess): Promise<IChildLifecycleOutcome> {
|
|
975
|
+
child.stdout?.on('data', (chunk: unknown) => {
|
|
976
|
+
this.consumeLogChunk('stdout', chunk, this.stdoutAccumulator);
|
|
977
|
+
});
|
|
978
|
+
child.stderr?.on('data', (chunk: unknown) => {
|
|
979
|
+
this.consumeLogChunk('stderr', chunk, this.stderrAccumulator);
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
return new Promise((resolve) => {
|
|
983
|
+
let settled = false;
|
|
984
|
+
const finish = (outcome: IChildLifecycleOutcome): void => {
|
|
985
|
+
if (settled) {
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
settled = true;
|
|
989
|
+
this.flushLogAccumulator('stdout', this.stdoutAccumulator);
|
|
990
|
+
this.flushLogAccumulator('stderr', this.stderrAccumulator);
|
|
991
|
+
this.healthy = false;
|
|
992
|
+
// once the group leader is gone the kernel may recycle its pid, so a
|
|
993
|
+
// LATER stop() must never signal this group — but surviving group
|
|
994
|
+
// descendants must still be reaped NOW, while living members keep the
|
|
995
|
+
// pgid reserved and therefore safely targetable
|
|
996
|
+
const orphanedGroupId = this.ownedProcessGroupId;
|
|
997
|
+
this.ownedProcessGroupId = undefined;
|
|
998
|
+
this.childExitCleanupPending = true;
|
|
999
|
+
if (orphanedGroupId !== undefined && this.processGroupController) {
|
|
1000
|
+
this.orphanedProcessGroupId = orphanedGroupId;
|
|
1001
|
+
let reap!: Promise<void>;
|
|
1002
|
+
reap = this.reapOrphanedGroup(orphanedGroupId).then(() => {
|
|
1003
|
+
if (this.orphanedProcessGroupId === orphanedGroupId) {
|
|
1004
|
+
this.orphanedProcessGroupId = undefined;
|
|
1005
|
+
}
|
|
1006
|
+
this.notifyChildExitCleanup();
|
|
1007
|
+
}).finally(() => {
|
|
1008
|
+
if (this.orphanReapTask === reap) this.orphanReapTask = undefined;
|
|
1009
|
+
});
|
|
1010
|
+
this.orphanReapTask = reap;
|
|
1011
|
+
void reap.catch(() => undefined);
|
|
1012
|
+
} else {
|
|
1013
|
+
this.notifyChildExitCleanup();
|
|
1014
|
+
}
|
|
1015
|
+
if (this.state === 'stopping') {
|
|
1016
|
+
this.state = 'stopped';
|
|
1017
|
+
} else if (this.state === 'starting' || this.state === 'ready') {
|
|
1018
|
+
this.state = 'failed';
|
|
1019
|
+
}
|
|
1020
|
+
resolve(outcome);
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
child.once('exit', (code, signal) => {
|
|
1024
|
+
finish({ type: 'exit', code, signal });
|
|
1025
|
+
});
|
|
1026
|
+
child.once('error', (error) => {
|
|
1027
|
+
this.addLogEntry('stderr', `OpenCode process error: ${error.message}`, false);
|
|
1028
|
+
if (!child.pid) {
|
|
1029
|
+
finish({ type: 'error', error });
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
private async fetchHealth(callerSignal?: AbortSignal): Promise<IOpenCodeHealth> {
|
|
1036
|
+
callerSignal?.throwIfAborted();
|
|
1037
|
+
const requestAbortController = new AbortController();
|
|
1038
|
+
const abortFromCaller = (): void => {
|
|
1039
|
+
requestAbortController.abort(callerSignal?.reason);
|
|
1040
|
+
};
|
|
1041
|
+
callerSignal?.addEventListener('abort', abortFromCaller, { once: true });
|
|
1042
|
+
if (callerSignal?.aborted) {
|
|
1043
|
+
abortFromCaller();
|
|
1044
|
+
}
|
|
1045
|
+
const timeout = setTimeout(() => {
|
|
1046
|
+
requestAbortController.abort(
|
|
1047
|
+
new Error(`OpenCode health request exceeded ${this.healthRequestTimeoutMs}ms.`)
|
|
1048
|
+
);
|
|
1049
|
+
}, this.healthRequestTimeoutMs);
|
|
1050
|
+
timeout.unref();
|
|
1051
|
+
|
|
1052
|
+
const authorization = Buffer.from(`${openCodeUsername}:${this.password}`, 'utf8').toString(
|
|
1053
|
+
'base64'
|
|
1054
|
+
);
|
|
1055
|
+
try {
|
|
1056
|
+
const response = await this.fetchImplementation(`${this.baseUrl}/global/health`, {
|
|
1057
|
+
method: 'GET',
|
|
1058
|
+
headers: {
|
|
1059
|
+
Accept: 'application/json',
|
|
1060
|
+
Authorization: `Basic ${authorization}`,
|
|
1061
|
+
},
|
|
1062
|
+
redirect: 'error',
|
|
1063
|
+
signal: requestAbortController.signal,
|
|
1064
|
+
});
|
|
1065
|
+
if (!response.ok) {
|
|
1066
|
+
throw new Error(`OpenCode health check returned HTTP ${response.status}.`);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const body = await readBoundedResponseText(response);
|
|
1070
|
+
let parsed: unknown;
|
|
1071
|
+
try {
|
|
1072
|
+
parsed = JSON.parse(body);
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
throw new Error('OpenCode health check returned invalid JSON.', { cause: error });
|
|
1075
|
+
}
|
|
1076
|
+
if (
|
|
1077
|
+
typeof parsed !== 'object' ||
|
|
1078
|
+
parsed === null ||
|
|
1079
|
+
!('healthy' in parsed) ||
|
|
1080
|
+
!('version' in parsed) ||
|
|
1081
|
+
parsed.healthy !== true ||
|
|
1082
|
+
typeof parsed.version !== 'string'
|
|
1083
|
+
) {
|
|
1084
|
+
throw new Error('OpenCode health check returned an invalid payload.');
|
|
1085
|
+
}
|
|
1086
|
+
if (parsed.version !== openCodeExpectedVersion) {
|
|
1087
|
+
throw new OpenCodeVersionMismatchError(
|
|
1088
|
+
`OpenCode reported version ${parsed.version}; expected ${openCodeExpectedVersion}.`
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
return {
|
|
1093
|
+
healthy: true,
|
|
1094
|
+
version: openCodeExpectedVersion,
|
|
1095
|
+
};
|
|
1096
|
+
} finally {
|
|
1097
|
+
clearTimeout(timeout);
|
|
1098
|
+
callerSignal?.removeEventListener('abort', abortFromCaller);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
private async performStop(
|
|
1103
|
+
child: plugins.childProcess.ChildProcess,
|
|
1104
|
+
lifecycle: Promise<IChildLifecycleOutcome>
|
|
1105
|
+
): Promise<void> {
|
|
1106
|
+
this.state = 'stopping';
|
|
1107
|
+
this.healthy = false;
|
|
1108
|
+
|
|
1109
|
+
// a leader that already exited cleared ownedProcessGroupId in observeChild
|
|
1110
|
+
// and handed surviving members to reapOrphanedGroup, so this branch only
|
|
1111
|
+
// ever signals a group whose leader is still ours
|
|
1112
|
+
const ownedProcessGroupId = this.ownedProcessGroupId;
|
|
1113
|
+
if (this.processGroupController && ownedProcessGroupId !== undefined) {
|
|
1114
|
+
let exited = !(await this.processGroupController.isAlive(ownedProcessGroupId));
|
|
1115
|
+
if (!exited) {
|
|
1116
|
+
await this.processGroupController.signal(ownedProcessGroupId, 'SIGTERM');
|
|
1117
|
+
exited = await waitForProcessGroupExit(
|
|
1118
|
+
this.processGroupController,
|
|
1119
|
+
ownedProcessGroupId,
|
|
1120
|
+
this.stopTimeoutMs,
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
if (!exited) {
|
|
1124
|
+
await this.processGroupController.signal(ownedProcessGroupId, 'SIGKILL');
|
|
1125
|
+
exited = await waitForProcessGroupExit(
|
|
1126
|
+
this.processGroupController,
|
|
1127
|
+
ownedProcessGroupId,
|
|
1128
|
+
this.finalStopTimeoutMs,
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
if (!exited) {
|
|
1132
|
+
this.state = 'failed';
|
|
1133
|
+
throw new Error('The owned OpenCode process group did not exit after SIGKILL.');
|
|
1134
|
+
}
|
|
1135
|
+
const lifecycleSettled = await waitBounded(lifecycle, this.finalStopTimeoutMs);
|
|
1136
|
+
if (!lifecycleSettled) {
|
|
1137
|
+
this.state = 'failed';
|
|
1138
|
+
throw new Error('OpenCode process-group exit was not confirmed by the child lifecycle.');
|
|
1139
|
+
}
|
|
1140
|
+
this.ownedProcessGroupId = undefined;
|
|
1141
|
+
} else {
|
|
1142
|
+
|
|
1143
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
1144
|
+
try {
|
|
1145
|
+
child.kill('SIGTERM');
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
1148
|
+
throw new Error('Failed to send SIGTERM to OpenCode.', { cause: error });
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
let exited = await waitBounded(lifecycle, this.stopTimeoutMs);
|
|
1154
|
+
if (!exited && child.exitCode === null && child.signalCode === null) {
|
|
1155
|
+
try {
|
|
1156
|
+
child.kill('SIGKILL');
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
1159
|
+
throw new Error('Failed to send SIGKILL to OpenCode.', { cause: error });
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
exited = await waitBounded(lifecycle, this.finalStopTimeoutMs);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
if (!exited && child.exitCode === null && child.signalCode === null) {
|
|
1166
|
+
this.state = 'failed';
|
|
1167
|
+
throw new Error('OpenCode did not exit after SIGKILL.');
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// a leader that exited on its own may have left a reap task running; stop()
|
|
1172
|
+
// must not return while owned group members are still being signalled
|
|
1173
|
+
await this.drainOrphanedProcessGroup();
|
|
1174
|
+
|
|
1175
|
+
if (this.child === child) {
|
|
1176
|
+
this.child = undefined;
|
|
1177
|
+
}
|
|
1178
|
+
if (this.childLifecycle === lifecycle) {
|
|
1179
|
+
this.childLifecycle = undefined;
|
|
1180
|
+
}
|
|
1181
|
+
this.state = 'stopped';
|
|
1182
|
+
this.healthy = false;
|
|
1183
|
+
this.startedAt = undefined;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
private lifecycleError(outcome: IChildLifecycleOutcome, message: string): Error {
|
|
1187
|
+
if (outcome.type === 'error') {
|
|
1188
|
+
return new Error(message, { cause: outcome.error });
|
|
1189
|
+
}
|
|
1190
|
+
const detail = outcome.signal
|
|
1191
|
+
? `signal ${outcome.signal}`
|
|
1192
|
+
: `exit code ${String(outcome.code)}`;
|
|
1193
|
+
return new Error(`${message} (${detail})`);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
private consumeLogChunk(
|
|
1197
|
+
stream: IOpenCodeLogEntry['stream'],
|
|
1198
|
+
chunk: unknown,
|
|
1199
|
+
accumulator: ILogAccumulator
|
|
1200
|
+
): void {
|
|
1201
|
+
let bytes: Uint8Array;
|
|
1202
|
+
if (typeof chunk === 'string') {
|
|
1203
|
+
bytes = Buffer.from(chunk, 'utf8');
|
|
1204
|
+
} else if (chunk instanceof Uint8Array) {
|
|
1205
|
+
bytes = chunk;
|
|
1206
|
+
} else {
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
let text = accumulator.decoder.decode(bytes, { stream: true });
|
|
1211
|
+
while (text.length > 0) {
|
|
1212
|
+
if (accumulator.droppingUntilNewline) {
|
|
1213
|
+
const newlineIndex = text.indexOf('\n');
|
|
1214
|
+
if (newlineIndex === -1) {
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
accumulator.droppingUntilNewline = false;
|
|
1218
|
+
text = text.slice(newlineIndex + 1);
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const newlineIndex = text.indexOf('\n');
|
|
1223
|
+
if (newlineIndex !== -1) {
|
|
1224
|
+
const completeLine = `${accumulator.partial}${text.slice(0, newlineIndex)}`.replace(
|
|
1225
|
+
/\r$/u,
|
|
1226
|
+
''
|
|
1227
|
+
);
|
|
1228
|
+
accumulator.partial = '';
|
|
1229
|
+
const bounded = truncateLogLine(completeLine, this.logLineByteLimit);
|
|
1230
|
+
this.addLogEntry(stream, bounded.line, bounded.truncated);
|
|
1231
|
+
text = text.slice(newlineIndex + 1);
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const combined = `${accumulator.partial}${text}`;
|
|
1236
|
+
if (Buffer.byteLength(combined, 'utf8') > this.logLineByteLimit) {
|
|
1237
|
+
const bounded = truncateLogLine(combined, this.logLineByteLimit);
|
|
1238
|
+
this.addLogEntry(stream, bounded.line, true);
|
|
1239
|
+
accumulator.partial = '';
|
|
1240
|
+
accumulator.droppingUntilNewline = true;
|
|
1241
|
+
} else {
|
|
1242
|
+
accumulator.partial = combined;
|
|
1243
|
+
}
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
private flushLogAccumulator(
|
|
1249
|
+
stream: IOpenCodeLogEntry['stream'],
|
|
1250
|
+
accumulator: ILogAccumulator
|
|
1251
|
+
): void {
|
|
1252
|
+
const finalText = accumulator.decoder.decode();
|
|
1253
|
+
if (finalText) {
|
|
1254
|
+
this.consumeLogChunk(stream, finalText, accumulator);
|
|
1255
|
+
}
|
|
1256
|
+
if (accumulator.partial && !accumulator.droppingUntilNewline) {
|
|
1257
|
+
const bounded = truncateLogLine(accumulator.partial, this.logLineByteLimit);
|
|
1258
|
+
this.addLogEntry(stream, bounded.line, bounded.truncated);
|
|
1259
|
+
}
|
|
1260
|
+
accumulator.partial = '';
|
|
1261
|
+
accumulator.droppingUntilNewline = false;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
private addLogEntry(
|
|
1265
|
+
stream: IOpenCodeLogEntry['stream'],
|
|
1266
|
+
line: string,
|
|
1267
|
+
truncated: boolean
|
|
1268
|
+
): void {
|
|
1269
|
+
this.logs.push({
|
|
1270
|
+
timestamp: Date.now(),
|
|
1271
|
+
stream,
|
|
1272
|
+
line,
|
|
1273
|
+
truncated,
|
|
1274
|
+
});
|
|
1275
|
+
const excessEntries = this.logs.length - this.logEntryLimit;
|
|
1276
|
+
if (excessEntries > 0) {
|
|
1277
|
+
this.logs.splice(0, excessEntries);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
}
|