@neuralnomads/codenomad-dev 0.18.0-dev-20260716-40cee071 → 0.18.0-dev-20260720-4c50829d
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/dist/auth/manager.js +1 -1
- package/dist/filesystem/__tests__/search-cache.test.js +5 -5
- package/dist/filesystem/search-cache.js +1 -1
- package/dist/index.js +42 -0
- package/dist/index.test.js +40 -10
- package/dist/opencode-update/service.js +150 -0
- package/dist/opencode-update/service.test.js +107 -0
- package/dist/permissions/auto-accept-manager.js +165 -0
- package/dist/permissions/auto-accept-manager.test.js +208 -0
- package/dist/permissions/auto-accept-store.js +5 -2
- package/dist/permissions/opencode-yolo-metadata.js +81 -0
- package/dist/permissions/opencode-yolo-metadata.test.js +54 -0
- package/dist/server/__tests__/remote-proxy.test.js +64 -56
- package/dist/server/http-server.js +12 -15
- package/dist/server/remote-proxy.js +68 -98
- package/dist/server/routes/opencode-update.js +37 -0
- package/dist/server/routes/opencode-update.test.js +34 -0
- package/dist/server/routes/remote-servers.js +1 -1
- package/dist/server/routes/workspaces.test.js +38 -0
- package/dist/server/routes/worktrees.js +19 -0
- package/dist/server/routes/yolo.js +2 -1
- package/dist/server/tls.js +2 -2
- package/dist/settings/binaries.test.js +2 -7
- package/dist/shutdown.js +67 -59
- package/dist/shutdown.test.js +115 -78
- package/dist/ui/remote-ui.js +2 -2
- package/dist/usage/providers/api-key.js +3 -3
- package/dist/usage/providers/google.js +1 -1
- package/dist/usage/providers/oauth.js +1 -36
- package/dist/usage/providers/special.js +3 -3
- package/dist/usage/service.js +1 -1
- package/dist/usage/service.test.js +5 -3
- package/dist/usage/shared.js +4 -4
- package/dist/usage/shared.test.js +4 -4
- package/dist/workspaces/__tests__/spawn.test.js +2 -2
- package/dist/workspaces/__tests__/workspace-identity.test.js +94 -286
- package/dist/workspaces/binary-path.test.js +10 -0
- package/dist/workspaces/instance-client.js +4 -3
- package/dist/workspaces/launch-cleanup.test.js +21 -25
- package/dist/workspaces/manager.js +300 -351
- package/dist/workspaces/manager.test.js +209 -237
- package/dist/workspaces/process-identity.darwin.test.js +88 -0
- package/dist/workspaces/process-identity.js +172 -149
- package/dist/workspaces/process-identity.test.js +86 -140
- package/dist/workspaces/runtime.js +261 -373
- package/dist/workspaces/runtime.test.js +222 -547
- package/package.json +1 -1
- package/public/assets/{FilesTab-BjztpSdj.js → FilesTab-ChfE8-8N.js} +2 -2
- package/public/assets/{GitChangesTab-B9XraSoR.js → GitChangesTab-ntRtkxIF.js} +2 -2
- package/public/assets/{SplitFilePanel-Cu_5-Z-E.js → SplitFilePanel-Dav7VCqK.js} +1 -1
- package/public/assets/StatusTab-D3toOefp.js +1 -0
- package/public/assets/{align-justify-BKS9gyRk.js → align-justify-D91sAsLc.js} +1 -1
- package/public/assets/{bundle-full-CXGXXlzR.js → bundle-full-CWkY9-v4.js} +1 -1
- package/public/assets/{diff-viewer-CSNOYoEB.js → diff-viewer-Cn_41zpc.js} +1 -1
- package/public/assets/index-3dx-hhNQ.js +1 -0
- package/public/assets/index-BKwfZLzw.js +1 -0
- package/public/assets/index-C5IioEFT.js +1 -0
- package/public/assets/index-CZYc0VcU.js +2 -0
- package/public/assets/index-CdCoXYXP.js +1 -0
- package/public/assets/index-CwAVops2.js +1 -0
- package/public/assets/index-CzvCOwKc.js +1 -0
- package/public/assets/index-D-loOWPv.js +1 -0
- package/public/assets/{index-DVc4ls8c.js → index-DM1cpVNo.js} +1 -1
- package/public/assets/{index-sLe9hEfQ.js → index-DOprq6Gt.js} +1 -1
- package/public/assets/index-L7fX3W8k.css +1 -0
- package/public/assets/index-w2KdHEME.js +1 -0
- package/public/assets/{loading-CDmt9OJU.js → loading-BfoDcWcg.js} +1 -1
- package/public/assets/main-CaTCcck1.js +68 -0
- package/public/assets/{markdown-1ZLSxCFT.js → markdown-D33YI2g2.js} +3 -3
- package/public/assets/{monaco-viewer-D6JgH76W.js → monaco-viewer-DM35aobI.js} +4 -4
- package/public/assets/{tool-call-CyXb63LH.js → tool-call-tV85Jv2i.js} +3 -3
- package/public/assets/{unified-picker-DCCdSVwI.js → unified-picker-DSRT9GMh.js} +1 -1
- package/public/assets/{wrap-text-pNMdTIq3.js → wrap-text-Dmrn2DKo.js} +1 -1
- package/public/index.html +4 -4
- package/public/loading.html +4 -4
- package/public/sw.js +1 -1
- package/dist/workspaces/launch-cleanup.js +0 -132
- package/public/assets/StatusTab-D_IE79r-.js +0 -1
- package/public/assets/index-B3YjNCh4.js +0 -1
- package/public/assets/index-B3ZD2_SU.js +0 -1
- package/public/assets/index-Bh2ofaQo.js +0 -1
- package/public/assets/index-CM0c9xRZ.js +0 -1
- package/public/assets/index-CdDxouJl.js +0 -2
- package/public/assets/index-D06Y2cFq.js +0 -1
- package/public/assets/index-PmE51vPh.js +0 -1
- package/public/assets/index-Qj63IiSj.css +0 -1
- package/public/assets/index-VY_zwmsG.js +0 -1
- package/public/assets/index-qiiH7egV.js +0 -1
- package/public/assets/main-CTN6Wwz1.js +0 -68
|
@@ -2,6 +2,7 @@ import path from "path";
|
|
|
2
2
|
import { spawnSync } from "child_process";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { connect } from "net";
|
|
5
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
6
|
import { FileSystemBrowser } from "../filesystem/browser";
|
|
6
7
|
import { searchWorkspaceFiles } from "../filesystem/search";
|
|
7
8
|
import { clearWorkspaceSearchCache } from "../filesystem/search-cache";
|
|
@@ -9,79 +10,93 @@ import { WorkspaceRuntime } from "./runtime";
|
|
|
9
10
|
import { buildOpencodeConfigContent, getCodeNomadPluginUrl, resolveExistingOpencodeConfigContent, } from "../opencode-plugin.js";
|
|
10
11
|
import { OPENCODE_SERVER_BASE_URL_ENV, buildOpencodeBasicAuthHeader, OPENCODE_SERVER_PASSWORD_ENV, OPENCODE_SERVER_USERNAME_ENV, resolveOpencodeServerAuth, } from "./opencode-auth";
|
|
11
12
|
import { resolveWorkspaceIdentity } from "./workspace-identity";
|
|
13
|
+
import { parseWslUncPath } from "./spawn";
|
|
12
14
|
const STARTUP_STABILITY_DELAY_MS = 1500;
|
|
15
|
+
const DEFAULT_LAUNCH_TIMEOUT_MS = 30_000;
|
|
16
|
+
const ORDINARY_CREATION_OWNER = "";
|
|
17
|
+
const WORKSPACE_STATE = Symbol("workspaceState");
|
|
18
|
+
export function binaryPathsEqual(left, right, platform = process.platform) {
|
|
19
|
+
const leftWsl = parseWslUncPath(left);
|
|
20
|
+
const rightWsl = parseWslUncPath(right);
|
|
21
|
+
if (leftWsl || rightWsl) {
|
|
22
|
+
return Boolean(leftWsl
|
|
23
|
+
&& rightWsl
|
|
24
|
+
&& leftWsl.distro.toLowerCase() === rightWsl.distro.toLowerCase()
|
|
25
|
+
&& leftWsl.linuxPath === rightWsl.linuxPath);
|
|
26
|
+
}
|
|
27
|
+
return platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
28
|
+
}
|
|
13
29
|
export class WorkspaceLaunchCancelledError extends Error {
|
|
14
30
|
constructor(workspaceId) {
|
|
15
31
|
super(`Workspace ${workspaceId} launch was cancelled`);
|
|
16
32
|
this.name = "WorkspaceLaunchCancelledError";
|
|
17
33
|
}
|
|
18
34
|
}
|
|
19
|
-
export class
|
|
20
|
-
constructor(timeoutMs) {
|
|
21
|
-
super(`Workspace
|
|
22
|
-
this.code = "
|
|
35
|
+
export class WorkspaceLaunchTimeoutError extends Error {
|
|
36
|
+
constructor(workspaceId, timeoutMs) {
|
|
37
|
+
super(`${workspaceId ? `Workspace ${workspaceId}` : "Workspace"} did not finish launching within ${timeoutMs}ms`);
|
|
38
|
+
this.code = "WORKSPACE_LAUNCH_TIMEOUT";
|
|
23
39
|
this.retryable = true;
|
|
24
|
-
this.name = "
|
|
40
|
+
this.name = "WorkspaceLaunchTimeoutError";
|
|
25
41
|
}
|
|
26
42
|
}
|
|
27
|
-
export class
|
|
28
|
-
constructor(
|
|
29
|
-
super(`Workspace ${
|
|
30
|
-
this.code = "
|
|
43
|
+
export class WorkspaceCleanupTimeoutError extends Error {
|
|
44
|
+
constructor(operation, timeoutMs) {
|
|
45
|
+
super(`Workspace ${operation} did not finish within ${timeoutMs}ms; cleanup can be retried`);
|
|
46
|
+
this.code = "WORKSPACE_CLEANUP_TIMEOUT";
|
|
31
47
|
this.retryable = true;
|
|
32
|
-
this.name = "
|
|
48
|
+
this.name = "WorkspaceCleanupTimeoutError";
|
|
33
49
|
}
|
|
34
50
|
}
|
|
35
|
-
export class WorkspaceShutdownError extends
|
|
51
|
+
export class WorkspaceShutdownError extends AggregateError {
|
|
36
52
|
constructor(errors) {
|
|
37
|
-
super(`Failed to stop ${errors.length} workspace${errors.length === 1 ? "" : "s"} during shutdown; cleanup can be retried`);
|
|
53
|
+
super(errors, `Failed to stop ${errors.length} workspace${errors.length === 1 ? "" : "s"} during shutdown; cleanup can be retried`);
|
|
38
54
|
this.code = "WORKSPACE_SHUTDOWN_FAILED";
|
|
39
55
|
this.retryable = true;
|
|
40
56
|
this.name = "WorkspaceShutdownError";
|
|
41
|
-
this.errors = errors;
|
|
42
57
|
}
|
|
43
58
|
}
|
|
44
59
|
export class WorkspaceManager {
|
|
45
60
|
constructor(options) {
|
|
46
61
|
this.options = options;
|
|
47
62
|
this.workspaces = new Map();
|
|
48
|
-
this.workspaceIdentities = new Map();
|
|
49
63
|
this.pendingWorkspaceCreations = new Map();
|
|
50
|
-
this.pendingWorkspaceOwners = new Map();
|
|
51
64
|
this.cancelledCreationRequests = new Set();
|
|
52
65
|
this.shuttingDown = false;
|
|
53
66
|
this.opencodeAuth = new Map();
|
|
54
67
|
this.runtime = options.runtime ?? new WorkspaceRuntime(this.options.eventBus, this.options.logger);
|
|
55
68
|
this.codeNomadPluginUrl = getCodeNomadPluginUrl();
|
|
56
|
-
this.shutdownTimeoutMs = Math.max(1, options.shutdownTimeoutMs ?? 10000);
|
|
57
|
-
this.launchSettlementTimeoutMs = Math.max(1, options.launchSettlementTimeoutMs ?? 5000);
|
|
58
|
-
this.scheduleTimeout = options.setTimeout ?? setTimeout;
|
|
59
|
-
this.cancelTimeout = options.clearTimeout ?? clearTimeout;
|
|
60
69
|
}
|
|
61
70
|
list() {
|
|
62
71
|
return Array.from(this.workspaces.values())
|
|
63
|
-
.filter((record) => record.published)
|
|
64
|
-
.map((record) => record.descriptor);
|
|
72
|
+
.filter((record) => record[WORKSPACE_STATE].published);
|
|
65
73
|
}
|
|
66
74
|
get(id) {
|
|
67
75
|
const record = this.workspaces.get(id);
|
|
68
|
-
return record?.published ? record
|
|
76
|
+
return record?.[WORKSPACE_STATE].published ? record : undefined;
|
|
69
77
|
}
|
|
70
78
|
getInstancePort(id) {
|
|
71
79
|
const record = this.workspaces.get(id);
|
|
72
|
-
return record?.published ? record.
|
|
80
|
+
return record?.[WORKSPACE_STATE].published ? record.port : undefined;
|
|
73
81
|
}
|
|
74
82
|
getInstanceAuthorizationHeader(id) {
|
|
75
|
-
return this.workspaces.get(id)?.published ? this.opencodeAuth.get(id)?.authorization : undefined;
|
|
83
|
+
return this.workspaces.get(id)?.[WORKSPACE_STATE].published ? this.opencodeAuth.get(id)?.authorization : undefined;
|
|
84
|
+
}
|
|
85
|
+
findReadyInstanceIdByBinary(binaryPath) {
|
|
86
|
+
const resolvedPath = this.resolveBinaryPath(binaryPath);
|
|
87
|
+
return this.list().find((workspace) => {
|
|
88
|
+
return workspace.status === "ready" && binaryPathsEqual(workspace.binaryId, resolvedPath);
|
|
89
|
+
})?.id;
|
|
76
90
|
}
|
|
77
91
|
findReadyWorkspaceByIdentity(identityKey, includeRestoreOwned) {
|
|
78
|
-
for (const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
&&
|
|
82
|
-
&& record.
|
|
83
|
-
&&
|
|
84
|
-
|
|
92
|
+
for (const record of this.workspaces.values()) {
|
|
93
|
+
const state = record[WORKSPACE_STATE];
|
|
94
|
+
if (state.published
|
|
95
|
+
&& !state.abortController.signal.aborted
|
|
96
|
+
&& (includeRestoreOwned || !record.requestId)
|
|
97
|
+
&& record.status === "ready"
|
|
98
|
+
&& record.identityKey === identityKey) {
|
|
99
|
+
return record;
|
|
85
100
|
}
|
|
86
101
|
}
|
|
87
102
|
return undefined;
|
|
@@ -130,74 +145,70 @@ export class WorkspaceManager {
|
|
|
130
145
|
browser.writeFile(relativePath, contents);
|
|
131
146
|
}
|
|
132
147
|
async create(folder, name, options = {}) {
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
if (options.forceNew) {
|
|
141
|
-
const ownership = this.createOwnership(options.requestId);
|
|
142
|
-
const result = await this.createResolvedWorkspace(workspacePath, identityKey, name, options, ownership);
|
|
143
|
-
return this.resultForCreationOwner(result, options.requestId, ownership);
|
|
144
|
-
}
|
|
145
|
-
const existing = this.findReadyWorkspaceByIdentity(identityKey, Boolean(options.requestId));
|
|
146
|
-
if (existing) {
|
|
147
|
-
this.options.logger.info({ workspaceId: existing.id, folder: workspacePath }, "Reusing existing workspace");
|
|
148
|
-
const record = this.workspaces.get(existing.id);
|
|
149
|
-
if (options.requestId && record && record.creationOwnership.requestIds.size > 0) {
|
|
150
|
-
record.creationOwnership.requestIds.add(options.requestId);
|
|
151
|
-
this.syncCreationOwnership(record);
|
|
152
|
-
return this.resultForCreationOwner({ workspace: existing, created: false }, options.requestId, record.creationOwnership);
|
|
148
|
+
const launchTimeoutMs = Math.max(1, this.options.launchTimeoutMs ?? DEFAULT_LAUNCH_TIMEOUT_MS);
|
|
149
|
+
const launchDeadlineAt = Date.now() + launchTimeoutMs;
|
|
150
|
+
try {
|
|
151
|
+
const { workspacePath, identityKey } = await this.withLaunchDeadline(resolveWorkspaceIdentity(folder, this.options.rootDir), undefined, launchDeadlineAt, launchTimeoutMs);
|
|
152
|
+
if (options.requestId && this.cancelledCreationRequests.has(options.requestId)) {
|
|
153
|
+
throw new Error(`Workspace creation request ${options.requestId} was cancelled`);
|
|
153
154
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
155
|
+
if (this.shuttingDown) {
|
|
156
|
+
throw new Error("Workspace manager is shutting down");
|
|
157
|
+
}
|
|
158
|
+
if (options.forceNew) {
|
|
159
|
+
const ownership = this.createOwnership(options.requestId);
|
|
160
|
+
const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt);
|
|
161
|
+
const result = await this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs);
|
|
162
|
+
return this.finishCreation(result, options.requestId, ownership);
|
|
161
163
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
const existing = this.findReadyWorkspaceByIdentity(identityKey, Boolean(options.requestId));
|
|
165
|
+
if (existing) {
|
|
166
|
+
this.options.logger.info({ workspaceId: existing.id, folder: workspacePath }, "Reusing existing workspace");
|
|
167
|
+
const record = this.workspaces.get(existing.id);
|
|
168
|
+
if (options.requestId && record) {
|
|
169
|
+
if (!record.ownership.has(options.requestId))
|
|
170
|
+
record.ownership.set(options.requestId, "active");
|
|
171
|
+
this.syncOwnership(record);
|
|
172
|
+
return this.finishCreation({ workspace: existing, created: false }, options.requestId, record.ownership);
|
|
166
173
|
}
|
|
167
|
-
|
|
168
|
-
|
|
174
|
+
return { workspace: existing, created: false };
|
|
175
|
+
}
|
|
176
|
+
const pending = this.pendingWorkspaceCreations.get(identityKey);
|
|
177
|
+
if (pending) {
|
|
178
|
+
const state = pending[WORKSPACE_STATE];
|
|
179
|
+
const owner = options.requestId ?? ORDINARY_CREATION_OWNER;
|
|
180
|
+
if (!pending.ownership.has(owner))
|
|
181
|
+
pending.ownership.set(owner, "active");
|
|
182
|
+
this.syncOwnership(pending);
|
|
183
|
+
const result = await state.creation;
|
|
184
|
+
return this.finishCreation({ workspace: result.workspace, created: false }, options.requestId, pending.ownership);
|
|
185
|
+
}
|
|
186
|
+
const ownership = this.createOwnership(options.requestId);
|
|
187
|
+
const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt);
|
|
188
|
+
const creation = this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs);
|
|
189
|
+
this.pendingWorkspaceCreations.set(identityKey, record);
|
|
190
|
+
try {
|
|
191
|
+
return this.finishCreation(await creation, options.requestId, ownership);
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
if (this.pendingWorkspaceCreations.get(identityKey) === record) {
|
|
195
|
+
this.pendingWorkspaceCreations.delete(identityKey);
|
|
169
196
|
}
|
|
170
197
|
}
|
|
171
|
-
const result = await pending.promise;
|
|
172
|
-
return this.resultForCreationOwner({ workspace: result.workspace, created: false }, options.requestId, pending.ownership);
|
|
173
|
-
}
|
|
174
|
-
const ownership = this.createOwnership(options.requestId);
|
|
175
|
-
const creation = this.createResolvedWorkspace(workspacePath, identityKey, name, options, ownership, (workspaceId) => {
|
|
176
|
-
this.pendingWorkspaceOwners.set(identityKey, workspaceId);
|
|
177
|
-
});
|
|
178
|
-
const pendingCreation = { promise: creation, ownership, followerCount: 0 };
|
|
179
|
-
this.pendingWorkspaceCreations.set(identityKey, pendingCreation);
|
|
180
|
-
try {
|
|
181
|
-
const result = await creation;
|
|
182
|
-
return this.resultForCreationOwner(options.requestId && pendingCreation.ownership.sharedByNonRestoreCaller
|
|
183
|
-
? { workspace: result.workspace, created: false }
|
|
184
|
-
: result, options.requestId, pendingCreation.ownership);
|
|
185
198
|
}
|
|
186
199
|
finally {
|
|
187
|
-
if (
|
|
188
|
-
this.
|
|
189
|
-
this.pendingWorkspaceOwners.delete(identityKey);
|
|
190
|
-
}
|
|
200
|
+
if (options.requestId)
|
|
201
|
+
this.cancelledCreationRequests.delete(options.requestId);
|
|
191
202
|
}
|
|
192
203
|
}
|
|
193
|
-
|
|
204
|
+
reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt) {
|
|
194
205
|
const id = randomUUID();
|
|
195
206
|
const binary = this.options.binaryResolver.resolve(options.binaryPath);
|
|
196
|
-
const resolvedBinaryPath = this.resolveBinaryPath(binary.path);
|
|
207
|
+
const resolvedBinaryPath = this.resolveBinaryPath(binary.path, Math.max(1, launchDeadlineAt - Date.now()));
|
|
197
208
|
clearWorkspaceSearchCache(workspacePath);
|
|
198
209
|
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace");
|
|
199
210
|
const proxyPath = `/workspaces/${id}/instance`;
|
|
200
|
-
const
|
|
211
|
+
const record = {
|
|
201
212
|
id,
|
|
202
213
|
requestId: options.requestId,
|
|
203
214
|
path: workspacePath,
|
|
@@ -210,29 +221,60 @@ export class WorkspaceManager {
|
|
|
210
221
|
createdAt: new Date().toISOString(),
|
|
211
222
|
updatedAt: new Date().toISOString(),
|
|
212
223
|
};
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
224
|
+
Object.defineProperties(record, {
|
|
225
|
+
identityKey: { value: identityKey },
|
|
226
|
+
ownership: { value: ownership },
|
|
227
|
+
[WORKSPACE_STATE]: { value: { abortController: new AbortController(), published: false, stoppedPublished: false } },
|
|
216
228
|
});
|
|
217
|
-
const record = {
|
|
218
|
-
descriptor,
|
|
219
|
-
lifecycle: {
|
|
220
|
-
cancelled: false,
|
|
221
|
-
settled: false,
|
|
222
|
-
completion,
|
|
223
|
-
complete: completeLaunch,
|
|
224
|
-
stoppedEventPublished: false,
|
|
225
|
-
},
|
|
226
|
-
published: false,
|
|
227
|
-
creationOwnership,
|
|
228
|
-
releasedCreationRequestIds: new Set(),
|
|
229
|
-
};
|
|
230
229
|
this.workspaces.set(id, record);
|
|
231
|
-
this.
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
230
|
+
if (options.requestId && this.cancelledCreationRequests.has(options.requestId)) {
|
|
231
|
+
record[WORKSPACE_STATE].abortController.abort(new WorkspaceLaunchCancelledError(id));
|
|
232
|
+
}
|
|
233
|
+
return record;
|
|
234
|
+
}
|
|
235
|
+
startCreation(record, options, launchDeadlineAt, launchTimeoutMs) {
|
|
236
|
+
const creation = this.createWithDeadline(record, options, launchDeadlineAt, launchTimeoutMs);
|
|
237
|
+
record[WORKSPACE_STATE].creation = creation;
|
|
238
|
+
record[WORKSPACE_STATE].settlement = creation.then(() => undefined, () => undefined);
|
|
239
|
+
return creation;
|
|
240
|
+
}
|
|
241
|
+
async createWithDeadline(record, options, launchDeadlineAt, launchTimeoutMs) {
|
|
242
|
+
const timeoutMs = Math.max(1, launchDeadlineAt - Date.now());
|
|
243
|
+
const state = record[WORKSPACE_STATE];
|
|
244
|
+
let timeout = (this.options.setTimeout ?? setTimeout)(() => {
|
|
245
|
+
timeout = null;
|
|
246
|
+
if (!state.abortController.signal.aborted) {
|
|
247
|
+
state.abortController.abort(new WorkspaceLaunchTimeoutError(record.id, launchTimeoutMs));
|
|
248
|
+
}
|
|
249
|
+
}, timeoutMs);
|
|
250
|
+
try {
|
|
251
|
+
return await this.createResolvedWorkspace(record, options);
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
if (timeout)
|
|
255
|
+
(this.options.clearTimeout ?? clearTimeout)(timeout);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async withLaunchDeadline(operation, workspaceId, deadlineAt, launchTimeoutMs) {
|
|
259
|
+
const timeoutMs = Math.max(1, deadlineAt - Date.now());
|
|
260
|
+
let timeout = null;
|
|
261
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
262
|
+
timeout = (this.options.setTimeout ?? setTimeout)(() => {
|
|
263
|
+
timeout = null;
|
|
264
|
+
reject(new WorkspaceLaunchTimeoutError(workspaceId, launchTimeoutMs));
|
|
265
|
+
}, timeoutMs);
|
|
266
|
+
});
|
|
267
|
+
try {
|
|
268
|
+
return await Promise.race([operation, deadline]);
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
if (timeout)
|
|
272
|
+
(this.options.clearTimeout ?? clearTimeout)(timeout);
|
|
235
273
|
}
|
|
274
|
+
}
|
|
275
|
+
async createResolvedWorkspace(record, options) {
|
|
276
|
+
const state = record[WORKSPACE_STATE];
|
|
277
|
+
const { id, path: workspacePath, binaryId: resolvedBinaryPath, proxyPath } = record;
|
|
236
278
|
try {
|
|
237
279
|
this.throwIfCancelled(record);
|
|
238
280
|
const serverConfig = this.options.settings.getOwner("config", "server");
|
|
@@ -262,291 +304,231 @@ export class WorkspaceManager {
|
|
|
262
304
|
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
|
|
263
305
|
};
|
|
264
306
|
const logLevel = serverConfig?.logLevel;
|
|
265
|
-
const { pid, port, exitPromise,
|
|
307
|
+
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
|
|
266
308
|
workspaceId: id,
|
|
267
309
|
folder: workspacePath,
|
|
268
310
|
binaryPath: resolvedBinaryPath,
|
|
269
311
|
environment,
|
|
270
312
|
logLevel,
|
|
313
|
+
signal: state.abortController.signal,
|
|
271
314
|
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
|
272
315
|
});
|
|
273
|
-
|
|
274
|
-
|
|
316
|
+
record.pid = pid;
|
|
317
|
+
record.port = port;
|
|
275
318
|
this.throwIfCancelled(record);
|
|
276
|
-
|
|
277
|
-
this.options.eventBus.publish({ type: "workspace.created", workspace:
|
|
319
|
+
state.published = true;
|
|
320
|
+
this.options.eventBus.publish({ type: "workspace.created", workspace: record });
|
|
278
321
|
this.throwIfCancelled(record);
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
]);
|
|
322
|
+
const runtimeVersion = await this.waitForWorkspaceReadiness({
|
|
323
|
+
workspaceId: id,
|
|
324
|
+
port,
|
|
325
|
+
exitPromise,
|
|
326
|
+
getLastOutput,
|
|
327
|
+
signal: state.abortController.signal,
|
|
328
|
+
});
|
|
287
329
|
this.throwIfCancelled(record);
|
|
288
330
|
if (runtimeVersion) {
|
|
289
|
-
|
|
331
|
+
record.binaryVersion = runtimeVersion;
|
|
290
332
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
this.options.eventBus.publish({ type: "workspace.started", workspace:
|
|
333
|
+
record.status = "ready";
|
|
334
|
+
record.updatedAt = new Date().toISOString();
|
|
335
|
+
this.options.eventBus.publish({ type: "workspace.started", workspace: record });
|
|
294
336
|
this.options.logger.info({ workspaceId: id, port }, "Workspace ready");
|
|
295
|
-
return { workspace:
|
|
337
|
+
return { workspace: record, created: true };
|
|
296
338
|
}
|
|
297
339
|
catch (error) {
|
|
298
|
-
|
|
299
|
-
await this.stopRuntime(id);
|
|
300
|
-
throw error instanceof WorkspaceLaunchCancelledError ? error : new WorkspaceLaunchCancelledError(id);
|
|
301
|
-
}
|
|
302
|
-
if (!record.published) {
|
|
303
|
-
try {
|
|
304
|
-
await this.stopRuntime(id);
|
|
305
|
-
}
|
|
306
|
-
catch (cleanupError) {
|
|
307
|
-
this.options.logger.error({ workspaceId: id, err: cleanupError }, "Unpublished workspace cleanup remains pending");
|
|
308
|
-
throw cleanupError;
|
|
309
|
-
}
|
|
310
|
-
if (this.workspaces.get(id) === record) {
|
|
311
|
-
this.workspaces.delete(id);
|
|
312
|
-
this.workspaceIdentities.delete(id);
|
|
313
|
-
}
|
|
314
|
-
this.opencodeAuth.delete(id);
|
|
315
|
-
this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed before identity publication");
|
|
316
|
-
throw error;
|
|
317
|
-
}
|
|
340
|
+
const launchFailure = state.abortController.signal.aborted ? state.abortController.signal.reason : error;
|
|
318
341
|
let stopFailure;
|
|
319
|
-
await this.
|
|
342
|
+
await this.runtime.stop(id).catch((stopError) => {
|
|
320
343
|
stopFailure = stopError;
|
|
321
|
-
this.options.logger.warn({ workspaceId: id, err: stopError }, "Failed to stop workspace after startup error");
|
|
322
344
|
});
|
|
323
345
|
if (!stopFailure) {
|
|
324
|
-
this.
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
this.opencodeAuth.delete(id);
|
|
330
|
-
throw error;
|
|
346
|
+
this.removeRecord(id, record, state.published);
|
|
347
|
+
throw launchFailure;
|
|
348
|
+
}
|
|
349
|
+
if (!state.published) {
|
|
350
|
+
throw stopFailure;
|
|
331
351
|
}
|
|
332
|
-
|
|
333
|
-
|
|
352
|
+
record.status = "error";
|
|
353
|
+
record.error = stopFailure instanceof Error
|
|
334
354
|
? `Workspace startup failed and its process could not be stopped: ${stopFailure.message}`
|
|
335
|
-
:
|
|
336
|
-
|
|
337
|
-
if (this.workspaces.get(id) === record &&
|
|
338
|
-
this.options.eventBus.publish({ type: "workspace.error", workspace:
|
|
355
|
+
: launchFailure instanceof Error ? launchFailure.message : String(launchFailure);
|
|
356
|
+
record.updatedAt = new Date().toISOString();
|
|
357
|
+
if (this.workspaces.get(id) === record && state.published) {
|
|
358
|
+
this.options.eventBus.publish({ type: "workspace.error", workspace: record });
|
|
339
359
|
}
|
|
340
|
-
this.options.logger.error({ workspaceId: id, err:
|
|
341
|
-
throw
|
|
342
|
-
}
|
|
343
|
-
finally {
|
|
344
|
-
record.lifecycle.settled = true;
|
|
345
|
-
record.lifecycle.complete();
|
|
360
|
+
this.options.logger.error({ workspaceId: id, err: launchFailure }, "Workspace failed to start");
|
|
361
|
+
throw launchFailure;
|
|
346
362
|
}
|
|
347
363
|
}
|
|
348
364
|
delete(id) {
|
|
349
365
|
const record = this.workspaces.get(id);
|
|
350
366
|
if (!record)
|
|
351
367
|
return Promise.resolve(undefined);
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
368
|
+
const state = record[WORKSPACE_STATE];
|
|
369
|
+
if (!state.abortController.signal.aborted) {
|
|
370
|
+
state.abortController.abort(new WorkspaceLaunchCancelledError(id));
|
|
371
|
+
}
|
|
372
|
+
const pending = this.pendingWorkspaceCreations.get(record.identityKey);
|
|
373
|
+
if (pending === record) {
|
|
374
|
+
this.pendingWorkspaceCreations.delete(record.identityKey);
|
|
357
375
|
}
|
|
358
|
-
if (!
|
|
376
|
+
if (!state.deletePromise) {
|
|
359
377
|
let deletePromise;
|
|
360
378
|
deletePromise = this.cleanupDeletedWorkspace(id, record).catch((error) => {
|
|
361
|
-
if (
|
|
362
|
-
|
|
363
|
-
}
|
|
379
|
+
if (state.deletePromise === deletePromise)
|
|
380
|
+
state.deletePromise = undefined;
|
|
364
381
|
throw error;
|
|
365
382
|
});
|
|
366
|
-
|
|
383
|
+
state.deletePromise = deletePromise;
|
|
367
384
|
}
|
|
368
|
-
return
|
|
385
|
+
return state.deletePromise;
|
|
369
386
|
}
|
|
370
387
|
releaseCreationRequest(id, requestId) {
|
|
371
388
|
const record = this.workspaces.get(id);
|
|
372
|
-
if (!record?.published)
|
|
389
|
+
if (!record?.[WORKSPACE_STATE].published)
|
|
373
390
|
return false;
|
|
374
|
-
|
|
391
|
+
const ownership = record.ownership;
|
|
392
|
+
const state = ownership.get(requestId);
|
|
393
|
+
if (state === "released")
|
|
375
394
|
return true;
|
|
376
|
-
if (
|
|
395
|
+
if (state === "cancelled")
|
|
396
|
+
return false;
|
|
397
|
+
if (state !== "active")
|
|
377
398
|
return false;
|
|
378
|
-
|
|
399
|
+
ownership.set(requestId, "released");
|
|
400
|
+
this.syncOwnership(record);
|
|
379
401
|
return true;
|
|
380
402
|
}
|
|
381
403
|
async cancelCreationRequest(requestId) {
|
|
404
|
+
let matched = false;
|
|
382
405
|
for (const [workspaceId, record] of this.workspaces) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (
|
|
406
|
+
const ownership = record.ownership;
|
|
407
|
+
const state = ownership.get(requestId);
|
|
408
|
+
if (state === "released") {
|
|
409
|
+
matched = true;
|
|
386
410
|
continue;
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
411
|
+
}
|
|
412
|
+
if (state === "cancelled") {
|
|
413
|
+
matched = true;
|
|
414
|
+
if (this.hasActiveRequest(ownership) || this.isRetained(ownership))
|
|
415
|
+
continue;
|
|
416
|
+
await this.delete(workspaceId);
|
|
391
417
|
return;
|
|
392
418
|
}
|
|
419
|
+
if (state !== "active")
|
|
420
|
+
continue;
|
|
421
|
+
matched = true;
|
|
422
|
+
ownership.set(requestId, "cancelled");
|
|
423
|
+
this.syncOwnership(record);
|
|
424
|
+
if (this.hasActiveRequest(ownership) || this.isRetained(ownership))
|
|
425
|
+
return;
|
|
393
426
|
await this.delete(workspaceId);
|
|
394
427
|
return;
|
|
395
428
|
}
|
|
396
|
-
if (
|
|
397
|
-
|
|
398
|
-
if (oldestRequestId)
|
|
399
|
-
this.cancelledCreationRequests.delete(oldestRequestId);
|
|
400
|
-
}
|
|
401
|
-
this.cancelledCreationRequests.add(requestId);
|
|
429
|
+
if (!matched)
|
|
430
|
+
this.cancelledCreationRequests.add(requestId);
|
|
402
431
|
}
|
|
403
432
|
createOwnership(requestId) {
|
|
404
|
-
return
|
|
405
|
-
requestIds: new Set(requestId ? [requestId] : []),
|
|
406
|
-
cancelledRequestIds: new Set(),
|
|
407
|
-
sharedByNonRestoreCaller: !requestId,
|
|
408
|
-
};
|
|
433
|
+
return new Map([[requestId ?? ORDINARY_CREATION_OWNER, "active"]]);
|
|
409
434
|
}
|
|
410
|
-
|
|
411
|
-
if (requestId && ownership.
|
|
435
|
+
finishCreation(result, requestId, ownership) {
|
|
436
|
+
if (requestId && ownership.get(requestId) === "cancelled") {
|
|
412
437
|
throw new Error(`Workspace creation request ${requestId} was cancelled`);
|
|
413
438
|
}
|
|
439
|
+
const retained = this.isRetained(ownership);
|
|
414
440
|
return {
|
|
415
|
-
workspace: requestId
|
|
416
|
-
|
|
417
|
-
: result.workspace,
|
|
418
|
-
created: result.created,
|
|
441
|
+
workspace: requestId ? { ...result.workspace, requestId } : result.workspace,
|
|
442
|
+
created: result.created && !(requestId && retained),
|
|
419
443
|
};
|
|
420
444
|
}
|
|
421
|
-
|
|
445
|
+
syncOwnership(record) {
|
|
422
446
|
if (!record)
|
|
423
447
|
return;
|
|
424
|
-
|
|
448
|
+
const ownership = record.ownership;
|
|
449
|
+
record.requestId = this.isRetained(ownership)
|
|
425
450
|
? undefined
|
|
426
|
-
:
|
|
451
|
+
: Array.from(ownership).find(([, state]) => state === "active")?.[0];
|
|
427
452
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
434
|
-
record.creationOwnership.requestIds.clear();
|
|
435
|
-
record.creationOwnership.sharedByNonRestoreCaller = true;
|
|
436
|
-
this.syncCreationOwnership(record);
|
|
453
|
+
isRetained(ownership) {
|
|
454
|
+
return ownership.has(ORDINARY_CREATION_OWNER) || Array.from(ownership.values()).includes("released");
|
|
455
|
+
}
|
|
456
|
+
hasActiveRequest(ownership) {
|
|
457
|
+
return Array.from(ownership).some(([requestId, state]) => Boolean(requestId) && state === "active");
|
|
437
458
|
}
|
|
438
459
|
async shutdown() {
|
|
439
460
|
this.shuttingDown = true;
|
|
440
461
|
this.options.logger.info("Shutting down all workspaces");
|
|
441
462
|
const stopTasks = Array.from(this.workspaces.keys(), (id) => this.delete(id));
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
}
|
|
463
|
+
const results = stopTasks.length
|
|
464
|
+
? await this.withTimeout(Promise.allSettled(stopTasks), this.options.shutdownTimeoutMs ?? 10000, "shutdown")
|
|
465
|
+
: [];
|
|
466
|
+
const stopFailures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
447
467
|
if (this.workspaces.size === 0) {
|
|
448
|
-
this.workspaceIdentities.clear();
|
|
449
468
|
this.pendingWorkspaceCreations.clear();
|
|
450
|
-
this.pendingWorkspaceOwners.clear();
|
|
451
469
|
this.cancelledCreationRequests.clear();
|
|
452
|
-
this.options.logger.info("All workspaces cleared");
|
|
453
470
|
}
|
|
454
|
-
else
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
stopFailures.push(new Error(`Workspace cleanup remains incomplete for: ${Array.from(this.workspaces.keys()).join(", ")}`));
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
if (stopFailures.length > 0) {
|
|
471
|
+
else if (!stopFailures.length)
|
|
472
|
+
stopFailures.push(new Error(`Workspace cleanup remains incomplete for: ${Array.from(this.workspaces.keys()).join(", ")}`));
|
|
473
|
+
if (stopFailures.length)
|
|
461
474
|
throw new WorkspaceShutdownError(stopFailures);
|
|
462
|
-
}
|
|
463
475
|
}
|
|
464
|
-
async
|
|
476
|
+
async withTimeout(operation, timeoutMs, label) {
|
|
465
477
|
let timeout = null;
|
|
466
478
|
const deadline = new Promise((_resolve, reject) => {
|
|
467
|
-
timeout = this.
|
|
479
|
+
timeout = (this.options.setTimeout ?? setTimeout)(() => {
|
|
468
480
|
timeout = null;
|
|
469
|
-
reject(new
|
|
470
|
-
},
|
|
481
|
+
reject(new WorkspaceCleanupTimeoutError(label, timeoutMs));
|
|
482
|
+
}, timeoutMs);
|
|
471
483
|
});
|
|
472
484
|
try {
|
|
473
485
|
return await Promise.race([operation, deadline]);
|
|
474
486
|
}
|
|
475
487
|
finally {
|
|
476
|
-
if (timeout)
|
|
477
|
-
this.
|
|
478
|
-
}
|
|
488
|
+
if (timeout)
|
|
489
|
+
(this.options.clearTimeout ?? clearTimeout)(timeout);
|
|
479
490
|
}
|
|
480
491
|
}
|
|
481
492
|
requireWorkspace(id) {
|
|
482
493
|
const record = this.workspaces.get(id);
|
|
483
|
-
if (!record?.published)
|
|
494
|
+
if (!record?.[WORKSPACE_STATE].published)
|
|
484
495
|
throw new Error("Workspace not found");
|
|
485
|
-
|
|
486
|
-
return record.descriptor;
|
|
496
|
+
return record;
|
|
487
497
|
}
|
|
488
498
|
throwIfCancelled(record) {
|
|
489
|
-
|
|
490
|
-
throw new WorkspaceLaunchCancelledError(record.descriptor.id);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
async stopRuntime(workspaceId) {
|
|
494
|
-
try {
|
|
495
|
-
await this.runtime.stop(workspaceId);
|
|
496
|
-
}
|
|
497
|
-
catch (error) {
|
|
498
|
-
this.options.logger.warn({ workspaceId, err: error }, "Failed to stop workspace process cleanly");
|
|
499
|
-
throw error;
|
|
500
|
-
}
|
|
499
|
+
record[WORKSPACE_STATE].abortController.signal.throwIfAborted();
|
|
501
500
|
}
|
|
502
501
|
async cleanupDeletedWorkspace(id, record) {
|
|
503
|
-
this.options.logger.info({ workspaceId: id }, "Stopping workspace");
|
|
504
502
|
// Stop once immediately, then again after launch settlement to cover a child
|
|
505
503
|
// that became available while cancellation was propagating.
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
await this.withLaunchSettlementTimeout(id, record.lifecycle.completion);
|
|
509
|
-
}
|
|
510
|
-
try {
|
|
511
|
-
await this.runtime.stop(id);
|
|
512
|
-
}
|
|
513
|
-
catch (error) {
|
|
514
|
-
this.options.logger.warn({ workspaceId: id, err: error }, "Failed final workspace process cleanup");
|
|
515
|
-
throw error;
|
|
516
|
-
}
|
|
517
|
-
if (this.workspaces.get(id) === record) {
|
|
518
|
-
this.workspaces.delete(id);
|
|
519
|
-
this.workspaceIdentities.delete(id);
|
|
520
|
-
this.opencodeAuth.delete(id);
|
|
521
|
-
clearWorkspaceSearchCache(record.descriptor.path);
|
|
522
|
-
this.publishStopped(record);
|
|
523
|
-
}
|
|
524
|
-
return record.descriptor;
|
|
525
|
-
}
|
|
526
|
-
async withLaunchSettlementTimeout(workspaceId, completion) {
|
|
527
|
-
let timeout = null;
|
|
528
|
-
const deadline = new Promise((_resolve, reject) => {
|
|
529
|
-
timeout = this.scheduleTimeout(() => {
|
|
530
|
-
timeout = null;
|
|
531
|
-
reject(new WorkspaceLaunchSettlementTimeoutError(workspaceId, this.launchSettlementTimeoutMs));
|
|
532
|
-
}, this.launchSettlementTimeoutMs);
|
|
504
|
+
const immediateStop = this.runtime.stop(id).catch((error) => {
|
|
505
|
+
this.options.logger.warn({ workspaceId: id, err: error }, "Initial workspace process cleanup failed; retrying after launch settles");
|
|
533
506
|
});
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
}
|
|
543
|
-
publishStopped(record) {
|
|
544
|
-
if (record.lifecycle.stoppedEventPublished)
|
|
507
|
+
await this.withTimeout(record[WORKSPACE_STATE].settlement, this.options.launchSettlementTimeoutMs ?? 5000, `${id} launch cancellation`);
|
|
508
|
+
await immediateStop;
|
|
509
|
+
await this.runtime.stop(id);
|
|
510
|
+
this.removeRecord(id, record, true);
|
|
511
|
+
return record;
|
|
512
|
+
}
|
|
513
|
+
removeRecord(id, record, publishStopped) {
|
|
514
|
+
if (this.workspaces.get(id) !== record)
|
|
545
515
|
return;
|
|
546
|
-
|
|
547
|
-
this.
|
|
516
|
+
this.workspaces.delete(id);
|
|
517
|
+
this.opencodeAuth.delete(id);
|
|
518
|
+
clearWorkspaceSearchCache(record.path);
|
|
519
|
+
if (publishStopped)
|
|
520
|
+
this.publishStopped(record, "deleted");
|
|
521
|
+
}
|
|
522
|
+
publishStopped(record, reason = "stopped") {
|
|
523
|
+
const state = record[WORKSPACE_STATE];
|
|
524
|
+
if (!state.published || state.stoppedPublished)
|
|
525
|
+
return;
|
|
526
|
+
state.stoppedPublished = true;
|
|
527
|
+
record.status = "stopped";
|
|
528
|
+
record.error = undefined;
|
|
529
|
+
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: record.id, reason });
|
|
548
530
|
}
|
|
549
|
-
resolveBinaryPath(identifier) {
|
|
531
|
+
resolveBinaryPath(identifier, timeoutMs = DEFAULT_LAUNCH_TIMEOUT_MS) {
|
|
550
532
|
if (!identifier) {
|
|
551
533
|
return identifier;
|
|
552
534
|
}
|
|
@@ -556,7 +538,7 @@ export class WorkspaceManager {
|
|
|
556
538
|
}
|
|
557
539
|
const locator = process.platform === "win32" ? "where" : "which";
|
|
558
540
|
try {
|
|
559
|
-
const result = spawnSync(locator, [identifier], { encoding: "utf8" });
|
|
541
|
+
const result = spawnSync(locator, [identifier], { encoding: "utf8", timeout: Math.max(1, timeoutMs) });
|
|
560
542
|
if (result.status === 0 && result.stdout) {
|
|
561
543
|
const candidates = result.stdout
|
|
562
544
|
.split(/\r?\n/)
|
|
@@ -594,25 +576,19 @@ export class WorkspaceManager {
|
|
|
594
576
|
async waitForWorkspaceReadiness(params) {
|
|
595
577
|
await Promise.race([
|
|
596
578
|
this.waitForPortAvailability(params.port, 5000, params.signal),
|
|
597
|
-
|
|
598
|
-
throw this.buildStartupError(params.workspaceId, "exited before becoming ready", info, params.getLastOutput());
|
|
599
|
-
}),
|
|
579
|
+
this.exitDuringStartup(params, "exited before becoming ready"),
|
|
600
580
|
]);
|
|
601
581
|
const version = await this.waitForInstanceHealth(params);
|
|
602
582
|
await Promise.race([
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
throw this.buildStartupError(params.workspaceId, "exited shortly after start", info, params.getLastOutput());
|
|
606
|
-
}),
|
|
583
|
+
delay(STARTUP_STABILITY_DELAY_MS, undefined, { signal: params.signal }),
|
|
584
|
+
this.exitDuringStartup(params, "exited shortly after start"),
|
|
607
585
|
]);
|
|
608
586
|
return version;
|
|
609
587
|
}
|
|
610
588
|
async waitForInstanceHealth(params) {
|
|
611
589
|
const probeResult = await Promise.race([
|
|
612
590
|
this.probeInstance(params.workspaceId, params.port, params.signal),
|
|
613
|
-
|
|
614
|
-
throw this.buildStartupError(params.workspaceId, "exited during health checks", info, params.getLastOutput());
|
|
615
|
-
}),
|
|
591
|
+
this.exitDuringStartup(params, "exited during health checks"),
|
|
616
592
|
]);
|
|
617
593
|
if (probeResult.ok) {
|
|
618
594
|
return probeResult.version;
|
|
@@ -624,6 +600,11 @@ export class WorkspaceManager {
|
|
|
624
600
|
const reason = probeResult.reason ?? "Health check failed";
|
|
625
601
|
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`);
|
|
626
602
|
}
|
|
603
|
+
exitDuringStartup(params, phase) {
|
|
604
|
+
return params.exitPromise.then((info) => {
|
|
605
|
+
throw this.buildStartupError(params.workspaceId, phase, info, params.getLastOutput());
|
|
606
|
+
});
|
|
607
|
+
}
|
|
627
608
|
async probeInstance(workspaceId, port, signal) {
|
|
628
609
|
const url = `http://127.0.0.1:${port}/global/health`;
|
|
629
610
|
try {
|
|
@@ -665,34 +646,28 @@ export class WorkspaceManager {
|
|
|
665
646
|
const deadline = Date.now() + timeoutMs;
|
|
666
647
|
let settled = false;
|
|
667
648
|
let retryTimer = null;
|
|
668
|
-
let socket = null;
|
|
669
649
|
const cleanup = () => {
|
|
670
650
|
settled = true;
|
|
671
651
|
if (retryTimer) {
|
|
672
652
|
clearTimeout(retryTimer);
|
|
673
653
|
retryTimer = null;
|
|
674
654
|
}
|
|
675
|
-
signal?.removeEventListener("abort", onAbort);
|
|
676
|
-
};
|
|
677
|
-
const onAbort = () => {
|
|
678
|
-
if (settled)
|
|
679
|
-
return;
|
|
680
|
-
cleanup();
|
|
681
|
-
socket?.destroy();
|
|
682
|
-
reject(signal?.reason instanceof Error ? signal.reason : new Error("Workspace readiness was cancelled"));
|
|
683
655
|
};
|
|
684
656
|
const tryConnect = () => {
|
|
685
|
-
if (settled)
|
|
657
|
+
if (settled)
|
|
686
658
|
return;
|
|
687
|
-
}
|
|
688
|
-
socket = connect({ port, host: "127.0.0.1" }, () => {
|
|
659
|
+
const socket = connect({ port, host: "127.0.0.1", signal }, () => {
|
|
689
660
|
cleanup();
|
|
690
|
-
socket
|
|
661
|
+
socket.end();
|
|
691
662
|
resolve();
|
|
692
663
|
});
|
|
693
664
|
socket.once("error", () => {
|
|
694
|
-
socket
|
|
695
|
-
if (settled)
|
|
665
|
+
socket.destroy();
|
|
666
|
+
if (settled)
|
|
667
|
+
return;
|
|
668
|
+
if (signal?.aborted) {
|
|
669
|
+
cleanup();
|
|
670
|
+
reject(signal.reason);
|
|
696
671
|
return;
|
|
697
672
|
}
|
|
698
673
|
if (Date.now() >= deadline) {
|
|
@@ -707,35 +682,11 @@ export class WorkspaceManager {
|
|
|
707
682
|
}
|
|
708
683
|
});
|
|
709
684
|
};
|
|
710
|
-
if (signal?.aborted)
|
|
711
|
-
|
|
712
|
-
return;
|
|
713
|
-
}
|
|
714
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
685
|
+
if (signal?.aborted)
|
|
686
|
+
return reject(signal.reason);
|
|
715
687
|
tryConnect();
|
|
716
688
|
});
|
|
717
689
|
}
|
|
718
|
-
delay(durationMs, signal) {
|
|
719
|
-
if (durationMs <= 0) {
|
|
720
|
-
return Promise.resolve();
|
|
721
|
-
}
|
|
722
|
-
return new Promise((resolve, reject) => {
|
|
723
|
-
const timer = setTimeout(() => {
|
|
724
|
-
signal?.removeEventListener("abort", onAbort);
|
|
725
|
-
resolve();
|
|
726
|
-
}, durationMs);
|
|
727
|
-
const onAbort = () => {
|
|
728
|
-
clearTimeout(timer);
|
|
729
|
-
signal?.removeEventListener("abort", onAbort);
|
|
730
|
-
reject(signal?.reason instanceof Error ? signal.reason : new Error("Workspace readiness was cancelled"));
|
|
731
|
-
};
|
|
732
|
-
if (signal?.aborted) {
|
|
733
|
-
onAbort();
|
|
734
|
-
return;
|
|
735
|
-
}
|
|
736
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
737
|
-
});
|
|
738
|
-
}
|
|
739
690
|
describeExit(info) {
|
|
740
691
|
if (info.signal) {
|
|
741
692
|
return `signal ${info.signal}`;
|
|
@@ -749,15 +700,13 @@ export class WorkspaceManager {
|
|
|
749
700
|
const record = this.workspaces.get(workspaceId);
|
|
750
701
|
if (!record)
|
|
751
702
|
return;
|
|
752
|
-
const workspace = record
|
|
703
|
+
const workspace = record;
|
|
753
704
|
this.opencodeAuth.delete(workspaceId);
|
|
754
705
|
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited");
|
|
755
706
|
workspace.pid = undefined;
|
|
756
707
|
workspace.port = undefined;
|
|
757
708
|
workspace.updatedAt = new Date().toISOString();
|
|
758
|
-
if (record.
|
|
759
|
-
workspace.status = "stopped";
|
|
760
|
-
workspace.error = undefined;
|
|
709
|
+
if (record[WORKSPACE_STATE].abortController.signal.aborted || info.requested || info.code === 0) {
|
|
761
710
|
this.publishStopped(record);
|
|
762
711
|
}
|
|
763
712
|
else {
|