@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,7 +2,6 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { getProviderUsage, resolveUsageProvider, selectModelWindows } from "./service";
|
|
4
4
|
const providerIds = [
|
|
5
|
-
"claude",
|
|
6
5
|
"codex",
|
|
7
6
|
"github-copilot",
|
|
8
7
|
"google",
|
|
@@ -24,12 +23,15 @@ test("registers every supported usage provider", () => {
|
|
|
24
23
|
}
|
|
25
24
|
});
|
|
26
25
|
test("maps OpenCode provider aliases to their usage provider", () => {
|
|
27
|
-
assert.equal(resolveUsageProvider("anthropic")?.id, "claude");
|
|
28
26
|
assert.equal(resolveUsageProvider("openai")?.id, "codex");
|
|
29
27
|
assert.equal(resolveUsageProvider("copilot")?.id, "github-copilot");
|
|
30
28
|
assert.equal(resolveUsageProvider("gemini")?.id, "google");
|
|
31
29
|
assert.equal(resolveUsageProvider("opencode")?.id, "opencode-go");
|
|
32
30
|
});
|
|
31
|
+
test("does not use Claude subscription OAuth credentials", () => {
|
|
32
|
+
assert.equal(resolveUsageProvider("anthropic"), null);
|
|
33
|
+
assert.equal(resolveUsageProvider("claude"), null);
|
|
34
|
+
});
|
|
33
35
|
test("selects model-specific windows by normalized model id", () => {
|
|
34
36
|
const result = {
|
|
35
37
|
providerId: "google",
|
|
@@ -42,7 +44,7 @@ test("selects model-specific windows by normalized model id", () => {
|
|
|
42
44
|
models: {
|
|
43
45
|
"gemini/gemini-2.5-flash": {
|
|
44
46
|
windows: {
|
|
45
|
-
daily: { usedPercent: 25, remainingPercent: 75, windowSeconds:
|
|
47
|
+
daily: { usedPercent: 25, remainingPercent: 75, windowSeconds: 86_400, resetAt: null },
|
|
46
48
|
},
|
|
47
49
|
},
|
|
48
50
|
},
|
package/dist/usage/shared.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import os from "os";
|
|
3
3
|
import path from "path";
|
|
4
|
-
const REQUEST_TIMEOUT_MS =
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
5
5
|
function authFileCandidates() {
|
|
6
6
|
const home = os.homedir();
|
|
7
7
|
const candidates = [
|
|
@@ -68,7 +68,7 @@ export function toNumber(value) {
|
|
|
68
68
|
}
|
|
69
69
|
export function toTimestamp(value) {
|
|
70
70
|
if (typeof value === "number" && Number.isFinite(value))
|
|
71
|
-
return value <
|
|
71
|
+
return value < 1_000_000_000_000 ? value * 1000 : value;
|
|
72
72
|
if (typeof value === "string" && value.trim()) {
|
|
73
73
|
const parsed = Date.parse(value);
|
|
74
74
|
return Number.isNaN(parsed) ? null : parsed;
|
|
@@ -88,8 +88,8 @@ export function toUsageWindow(input) {
|
|
|
88
88
|
export function resolveWindowLabel(windowSeconds) {
|
|
89
89
|
if (!windowSeconds)
|
|
90
90
|
return "usage";
|
|
91
|
-
if (windowSeconds %
|
|
92
|
-
const days = windowSeconds /
|
|
91
|
+
if (windowSeconds % 86_400 === 0) {
|
|
92
|
+
const days = windowSeconds / 86_400;
|
|
93
93
|
return days === 7 ? "weekly" : `${days}d`;
|
|
94
94
|
}
|
|
95
95
|
if (windowSeconds % 3600 === 0)
|
|
@@ -8,11 +8,11 @@ test("reads the explicit OpenCode auth file without exposing credentials through
|
|
|
8
8
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-usage-"));
|
|
9
9
|
const authFile = path.join(directory, "auth.json");
|
|
10
10
|
const previous = process.env.OPENCODE_AUTH_FILE;
|
|
11
|
-
fs.writeFileSync(authFile, JSON.stringify({
|
|
11
|
+
fs.writeFileSync(authFile, JSON.stringify({ openai: { type: "oauth", access: "secret-token" } }));
|
|
12
12
|
process.env.OPENCODE_AUTH_FILE = authFile;
|
|
13
13
|
try {
|
|
14
|
-
assert.equal(readOpenCodeAuth().
|
|
15
|
-
assert.equal(getCredential(["
|
|
14
|
+
assert.equal(readOpenCodeAuth().openai.access, "secret-token");
|
|
15
|
+
assert.equal(getCredential(["openai"], ["access"]), "secret-token");
|
|
16
16
|
}
|
|
17
17
|
finally {
|
|
18
18
|
if (previous === undefined)
|
|
@@ -29,5 +29,5 @@ test("normalizes percentages and timestamps", () => {
|
|
|
29
29
|
windowSeconds: null,
|
|
30
30
|
resetAt: null,
|
|
31
31
|
});
|
|
32
|
-
assert.equal(toTimestamp(
|
|
32
|
+
assert.equal(toTimestamp(1_700_000_000), 1_700_000_000_000);
|
|
33
33
|
});
|
|
@@ -41,7 +41,7 @@ describe("buildWindowsSpawnSpec", () => {
|
|
|
41
41
|
it("conservatively classifies bare commands as wrappers", () => {
|
|
42
42
|
assert.equal(buildWindowsSpawnSpec("opencode", []).processKind, "windows-wrapper");
|
|
43
43
|
});
|
|
44
|
-
it("resolves a bare cmd shim from a quoted PATH entry and wraps its absolute path", () => {
|
|
44
|
+
it("resolves a bare cmd shim from a quoted PATH entry and wraps its absolute path", { skip: process.platform !== "win32" }, () => {
|
|
45
45
|
const root = mkdtempSync(path.join(tmpdir(), "codenomad-spawn-"));
|
|
46
46
|
const cwd = path.join(root, "workspace");
|
|
47
47
|
const bin = path.join(root, "bin with spaces");
|
|
@@ -63,7 +63,7 @@ describe("buildWindowsSpawnSpec", () => {
|
|
|
63
63
|
rmSync(root, { recursive: true, force: true });
|
|
64
64
|
}
|
|
65
65
|
});
|
|
66
|
-
it("honors PATHEXT precedence when both native and shim files exist", () => {
|
|
66
|
+
it("honors PATHEXT precedence when both native and shim files exist", { skip: process.platform !== "win32" }, () => {
|
|
67
67
|
const root = mkdtempSync(path.join(tmpdir(), "codenomad-spawn-"));
|
|
68
68
|
writeFileSync(path.join(root, "opencode.cmd"), "@echo off\r\n");
|
|
69
69
|
writeFileSync(path.join(root, "opencode.exe"), "");
|
|
@@ -8,14 +8,16 @@ import { EventBus } from "../../events/bus";
|
|
|
8
8
|
import { WorkspaceManager } from "../manager";
|
|
9
9
|
import { normalizeWorkspaceIdentityPath, resolveWorkspaceIdentity } from "../workspace-identity";
|
|
10
10
|
const temporaryDirectories = [];
|
|
11
|
+
const runtimeResult = (pid = 123) => ({
|
|
12
|
+
pid,
|
|
13
|
+
port: 4321,
|
|
14
|
+
exitPromise: new Promise(() => undefined),
|
|
15
|
+
getLastOutput: () => "",
|
|
16
|
+
});
|
|
11
17
|
function deferred() {
|
|
12
18
|
let resolve;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
resolve = resolvePromise;
|
|
16
|
-
reject = rejectPromise;
|
|
17
|
-
});
|
|
18
|
-
return { promise, resolve, reject };
|
|
19
|
+
const promise = new Promise((resolvePromise) => { resolve = resolvePromise; });
|
|
20
|
+
return { promise, resolve };
|
|
19
21
|
}
|
|
20
22
|
afterEach(async () => {
|
|
21
23
|
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })));
|
|
@@ -34,167 +36,99 @@ function createManager(rootDir) {
|
|
|
34
36
|
const manager = new WorkspaceManager({
|
|
35
37
|
rootDir,
|
|
36
38
|
settings: { getOwner: () => ({ environmentVariables: {} }) },
|
|
37
|
-
binaryResolver: {
|
|
38
|
-
resolve: () => ({ path: process.execPath, label: "Node.js", version: process.version }),
|
|
39
|
-
},
|
|
39
|
+
binaryResolver: { resolve: () => ({ path: process.execPath, label: "Node.js", version: process.version }) },
|
|
40
40
|
eventBus: new EventBus(logger),
|
|
41
41
|
logger,
|
|
42
42
|
getServerBaseUrl: () => "http://127.0.0.1:3000",
|
|
43
43
|
});
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
port: 4321,
|
|
48
|
-
exitPromise: new Promise(() => { }),
|
|
49
|
-
cancellationPromise: new Promise(() => { }),
|
|
50
|
-
getLastOutput: () => "",
|
|
51
|
-
});
|
|
52
|
-
internal.waitForWorkspaceReadiness = async () => undefined;
|
|
44
|
+
manager.runtime.launch = async () => runtimeResult();
|
|
45
|
+
manager.runtime.stop = async () => undefined;
|
|
46
|
+
manager.waitForWorkspaceReadiness = async () => undefined;
|
|
53
47
|
return manager;
|
|
54
48
|
}
|
|
49
|
+
async function waitForOwners(manager, count) {
|
|
50
|
+
while ([...manager.pendingWorkspaceCreations.values()][0]?.ownership.size !== count) {
|
|
51
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function createSharedLaunch() {
|
|
55
|
+
const { root, target, link } = await createLinkedWorkspace();
|
|
56
|
+
const manager = createManager(root);
|
|
57
|
+
const launchGate = deferred();
|
|
58
|
+
let launches = 0;
|
|
59
|
+
manager.runtime.launch = async () => {
|
|
60
|
+
launches += 1;
|
|
61
|
+
await launchGate.promise;
|
|
62
|
+
return runtimeResult();
|
|
63
|
+
};
|
|
64
|
+
const leader = manager.create(target, undefined, { requestId: "leader" });
|
|
65
|
+
const follower = manager.create(link, undefined, { requestId: "follower" });
|
|
66
|
+
await waitForOwners(manager, 2);
|
|
67
|
+
return { manager, launchGate, leader, follower, launches: () => launches };
|
|
68
|
+
}
|
|
55
69
|
describe("workspace identity", () => {
|
|
56
|
-
it("normalizes Windows
|
|
70
|
+
it("normalizes Windows paths without affecting POSIX case", () => {
|
|
57
71
|
assert.equal(normalizeWorkspaceIdentityPath("C:\\Projects\\CodeNomad\\", "win32"), "c:\\projects\\codenomad\\");
|
|
58
72
|
assert.equal(normalizeWorkspaceIdentityPath(String.raw `\\Server\Share\Repo`, "win32"), String.raw `\\server\share\repo`);
|
|
59
73
|
assert.equal(normalizeWorkspaceIdentityPath("/Projects/CodeNomad/", "linux"), "/Projects/CodeNomad/");
|
|
60
74
|
});
|
|
61
|
-
it("
|
|
75
|
+
it("canonicalizes aliases and falls back to an absolute identity for missing paths", async () => {
|
|
62
76
|
const { root, target, link } = await createLinkedWorkspace();
|
|
63
|
-
const targetResult = await
|
|
64
|
-
|
|
77
|
+
const [targetResult, linkResult, missing] = await Promise.all([
|
|
78
|
+
resolveWorkspaceIdentity(target, root),
|
|
79
|
+
resolveWorkspaceIdentity(link, root),
|
|
80
|
+
resolveWorkspaceIdentity("missing", root),
|
|
81
|
+
]);
|
|
82
|
+
const expectedMissing = path.resolve(root, "missing");
|
|
65
83
|
assert.equal(linkResult.identityKey, targetResult.identityKey);
|
|
66
84
|
assert.equal(linkResult.workspacePath, targetResult.workspacePath);
|
|
67
|
-
assert.
|
|
68
|
-
|
|
69
|
-
it("falls back to a normalized absolute identity when realpath fails", async () => {
|
|
70
|
-
const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-workspace-missing-"));
|
|
71
|
-
temporaryDirectories.push(root);
|
|
72
|
-
const result = await resolveWorkspaceIdentity("missing", root);
|
|
73
|
-
const expected = path.resolve(root, "missing");
|
|
74
|
-
assert.equal(result.workspacePath, expected);
|
|
75
|
-
assert.equal(result.identityKey, normalizeWorkspaceIdentityPath(expected));
|
|
85
|
+
assert.equal(missing.workspacePath, expectedMissing);
|
|
86
|
+
assert.equal(missing.identityKey, normalizeWorkspaceIdentityPath(expectedMissing));
|
|
76
87
|
});
|
|
77
|
-
it("
|
|
88
|
+
it("deduplicates active canonical aliases", async () => {
|
|
78
89
|
const { root, target, link } = await createLinkedWorkspace();
|
|
79
90
|
const manager = createManager(root);
|
|
80
|
-
const [
|
|
81
|
-
assert.equal(Number(targetResult.created) + Number(linkResult.created), 1);
|
|
82
|
-
assert.equal(targetResult.workspace.id, linkResult.workspace.id);
|
|
83
|
-
assert.equal(manager.list().length, 1);
|
|
84
|
-
});
|
|
85
|
-
it("shares an in-flight startup between canonical aliases", async () => {
|
|
86
|
-
const { root, target, link } = await createLinkedWorkspace();
|
|
87
|
-
const manager = createManager(root);
|
|
88
|
-
const launchGate = deferred();
|
|
89
|
-
let launches = 0;
|
|
90
|
-
manager.runtime.launch = async () => {
|
|
91
|
-
launches += 1;
|
|
92
|
-
await launchGate.promise;
|
|
93
|
-
return {
|
|
94
|
-
pid: 123,
|
|
95
|
-
port: 4321,
|
|
96
|
-
exitPromise: new Promise(() => { }),
|
|
97
|
-
cancellationPromise: new Promise(() => { }),
|
|
98
|
-
getLastOutput: () => "",
|
|
99
|
-
};
|
|
100
|
-
};
|
|
101
|
-
const firstPromise = manager.create(target, undefined, { requestId: "restore-request" });
|
|
102
|
-
const secondPromise = manager.create(link);
|
|
103
|
-
while (![...manager.pendingWorkspaceCreations.values()][0]?.followerCount) {
|
|
104
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
105
|
-
}
|
|
106
|
-
launchGate.resolve();
|
|
107
|
-
const [first, second] = await Promise.all([firstPromise, secondPromise]);
|
|
108
|
-
assert.equal(launches, 1);
|
|
109
|
-
assert.equal(first.created, false);
|
|
110
|
-
assert.equal(first.workspace.requestId, undefined);
|
|
111
|
-
assert.equal(first.workspace.id, second.workspace.id);
|
|
112
|
-
assert.equal(first.workspace.status, "ready");
|
|
113
|
-
assert.equal(second.workspace.status, "ready");
|
|
114
|
-
});
|
|
115
|
-
it("keeps both cleanup owners when two restore requests share a canonical launch", async () => {
|
|
116
|
-
const { root, target, link } = await createLinkedWorkspace();
|
|
117
|
-
const manager = createManager(root);
|
|
118
|
-
const [first, second] = await Promise.all([
|
|
119
|
-
manager.create(target, undefined, { requestId: "restore-first" }),
|
|
120
|
-
manager.create(link, undefined, { requestId: "restore-second" }),
|
|
121
|
-
]);
|
|
91
|
+
const [first, second] = await Promise.all([manager.create(target), manager.create(link)]);
|
|
122
92
|
assert.equal(Number(first.created) + Number(second.created), 1);
|
|
123
93
|
assert.equal(first.workspace.id, second.workspace.id);
|
|
124
|
-
assert.equal(first.workspace.requestId, "restore-first");
|
|
125
|
-
assert.equal(second.workspace.requestId, "restore-second");
|
|
126
|
-
assert.equal(manager.releaseCreationRequest(first.workspace.id, "restore-first"), true);
|
|
127
|
-
assert.equal(manager.releaseCreationRequest(first.workspace.id, "restore-second"), true);
|
|
128
|
-
});
|
|
129
|
-
it("detaches a cancelled restore follower without stopping the leader launch", async () => {
|
|
130
|
-
const { root, target, link } = await createLinkedWorkspace();
|
|
131
|
-
const manager = createManager(root);
|
|
132
|
-
const launchGate = deferred();
|
|
133
|
-
let launches = 0;
|
|
134
|
-
manager.runtime.launch = async () => {
|
|
135
|
-
launches += 1;
|
|
136
|
-
await launchGate.promise;
|
|
137
|
-
return {
|
|
138
|
-
pid: 123,
|
|
139
|
-
port: 4321,
|
|
140
|
-
exitPromise: new Promise(() => { }),
|
|
141
|
-
cancellationPromise: new Promise(() => { }),
|
|
142
|
-
getLastOutput: () => "",
|
|
143
|
-
};
|
|
144
|
-
};
|
|
145
|
-
const leader = manager.create(target, undefined, { requestId: "restore-leader" });
|
|
146
|
-
const follower = manager.create(link, undefined, { requestId: "restore-follower" });
|
|
147
|
-
while (![...manager.pendingWorkspaceCreations.values()][0]?.followerCount) {
|
|
148
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
149
|
-
}
|
|
150
|
-
await manager.cancelCreationRequest("restore-follower");
|
|
151
|
-
launchGate.resolve();
|
|
152
|
-
const leaderResult = await leader;
|
|
153
|
-
await assert.rejects(follower, /creation request restore-follower was cancelled/);
|
|
154
|
-
assert.equal(launches, 1);
|
|
155
|
-
assert.equal(leaderResult.workspace.requestId, "restore-leader");
|
|
156
|
-
assert.equal(manager.list().length, 1);
|
|
157
|
-
});
|
|
158
|
-
it("detaches a cancelled restore leader without stopping a follower launch", async () => {
|
|
159
|
-
const { root, target, link } = await createLinkedWorkspace();
|
|
160
|
-
const manager = createManager(root);
|
|
161
|
-
const launchGate = deferred();
|
|
162
|
-
manager.runtime.launch = async () => {
|
|
163
|
-
await launchGate.promise;
|
|
164
|
-
return {
|
|
165
|
-
pid: 123,
|
|
166
|
-
port: 4321,
|
|
167
|
-
exitPromise: new Promise(() => { }),
|
|
168
|
-
cancellationPromise: new Promise(() => { }),
|
|
169
|
-
getLastOutput: () => "",
|
|
170
|
-
};
|
|
171
|
-
};
|
|
172
|
-
const leader = manager.create(target, undefined, { requestId: "restore-leader" });
|
|
173
|
-
const follower = manager.create(link, undefined, { requestId: "restore-follower" });
|
|
174
|
-
while (![...manager.pendingWorkspaceCreations.values()][0]?.followerCount) {
|
|
175
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
176
|
-
}
|
|
177
|
-
await manager.cancelCreationRequest("restore-leader");
|
|
178
|
-
launchGate.resolve();
|
|
179
|
-
await assert.rejects(leader, /creation request restore-leader was cancelled/);
|
|
180
|
-
const followerResult = await follower;
|
|
181
|
-
assert.equal(followerResult.workspace.requestId, "restore-follower");
|
|
182
94
|
assert.equal(manager.list().length, 1);
|
|
183
95
|
});
|
|
184
|
-
it("
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
const [
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
assert.equal(
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
96
|
+
it("shares one in-flight launch between canonical aliases", async () => {
|
|
97
|
+
const shared = await createSharedLaunch();
|
|
98
|
+
shared.launchGate.resolve();
|
|
99
|
+
const [leader, follower] = await Promise.all([shared.leader, shared.follower]);
|
|
100
|
+
assert.equal(shared.launches(), 1);
|
|
101
|
+
assert.equal(leader.workspace.id, follower.workspace.id);
|
|
102
|
+
assert.equal(Number(leader.created) + Number(follower.created), 1);
|
|
103
|
+
assert.equal(leader.workspace.status, "ready");
|
|
104
|
+
});
|
|
105
|
+
for (const cancelledRole of ["leader", "follower"]) {
|
|
106
|
+
it(`detaches a cancelled ${cancelledRole} without stopping its shared owner`, async () => {
|
|
107
|
+
const shared = await createSharedLaunch();
|
|
108
|
+
await shared.manager.cancelCreationRequest(cancelledRole);
|
|
109
|
+
shared.launchGate.resolve();
|
|
110
|
+
const cancelled = shared[cancelledRole];
|
|
111
|
+
const survivor = shared[cancelledRole === "leader" ? "follower" : "leader"];
|
|
112
|
+
await assert.rejects(cancelled, new RegExp(`creation request ${cancelledRole} was cancelled`));
|
|
113
|
+
const result = await survivor;
|
|
114
|
+
assert.equal(shared.launches(), 1);
|
|
115
|
+
assert.equal(result.workspace.requestId, cancelledRole === "leader" ? "follower" : "leader");
|
|
116
|
+
assert.equal(shared.manager.list().length, 1);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
for (const releasedRole of ["leader", "follower"]) {
|
|
120
|
+
it(`retains shared ownership when the ${releasedRole} releases and the other owner cancels`, async () => {
|
|
121
|
+
const shared = await createSharedLaunch();
|
|
122
|
+
shared.launchGate.resolve();
|
|
123
|
+
const [leader, follower] = await Promise.all([shared.leader, shared.follower]);
|
|
124
|
+
assert.equal(leader.workspace.id, follower.workspace.id);
|
|
125
|
+
assert.equal(shared.manager.releaseCreationRequest(leader.workspace.id, releasedRole), true);
|
|
126
|
+
await shared.manager.cancelCreationRequest(releasedRole === "leader" ? "follower" : "leader");
|
|
127
|
+
assert.equal(shared.manager.list().length, 1);
|
|
128
|
+
assert.equal(shared.manager.get(leader.workspace.id)?.requestId, undefined);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
it("releases a failed canonical reservation for retry", async () => {
|
|
198
132
|
const { root, target, link } = await createLinkedWorkspace();
|
|
199
133
|
const manager = createManager(root);
|
|
200
134
|
const launchGate = deferred();
|
|
@@ -204,152 +138,26 @@ describe("workspace identity", () => {
|
|
|
204
138
|
await launchGate.promise;
|
|
205
139
|
throw new Error("launch failed");
|
|
206
140
|
};
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
141
|
+
const failures = [
|
|
142
|
+
manager.create(target, undefined, { requestId: "first" }),
|
|
143
|
+
manager.create(link, undefined, { requestId: "second" }),
|
|
144
|
+
];
|
|
145
|
+
await waitForOwners(manager, 2);
|
|
212
146
|
launchGate.resolve();
|
|
213
|
-
|
|
214
|
-
assert.deepEqual(failed.map((result) => result.status), ["rejected", "rejected"]);
|
|
147
|
+
assert.deepEqual((await Promise.allSettled(failures)).map((result) => result.status), ["rejected", "rejected"]);
|
|
215
148
|
assert.equal(launches, 1);
|
|
216
|
-
manager.runtime.launch = async () => (
|
|
217
|
-
|
|
218
|
-
port: 5432,
|
|
219
|
-
exitPromise: new Promise(() => { }),
|
|
220
|
-
cancellationPromise: new Promise(() => { }),
|
|
221
|
-
getLastOutput: () => "",
|
|
222
|
-
});
|
|
223
|
-
const retry = await manager.create(target);
|
|
224
|
-
assert.equal(retry.created, true);
|
|
225
|
-
assert.equal(retry.workspace.status, "ready");
|
|
226
|
-
});
|
|
227
|
-
it("allows an explicit second workspace for the same canonical path", async () => {
|
|
228
|
-
const { root, target, link } = await createLinkedWorkspace();
|
|
229
|
-
const manager = createManager(root);
|
|
230
|
-
const [first, second] = await Promise.all([
|
|
231
|
-
manager.create(target, undefined, { forceNew: true }),
|
|
232
|
-
manager.create(link, undefined, { forceNew: true }),
|
|
233
|
-
]);
|
|
234
|
-
assert.equal(first.created, true);
|
|
235
|
-
assert.equal(second.created, true);
|
|
236
|
-
assert.notEqual(first.workspace.id, second.workspace.id);
|
|
237
|
-
assert.equal(manager.list().length, 2);
|
|
149
|
+
manager.runtime.launch = async () => runtimeResult(456);
|
|
150
|
+
assert.equal((await manager.create(target)).created, true);
|
|
238
151
|
});
|
|
239
|
-
it("
|
|
152
|
+
it("allows forced canonical duplicates without replacing the reusable workspace", async () => {
|
|
240
153
|
const { root, target, link } = await createLinkedWorkspace();
|
|
241
154
|
const manager = createManager(root);
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const concurrent = await manager.create(link);
|
|
247
|
-
assert.equal(concurrent.created, true);
|
|
248
|
-
assert.notEqual(concurrent.workspace.id, restore.workspace.id);
|
|
249
|
-
assert.equal(manager.releaseCreationRequest(restore.workspace.id, "wrong-request"), false);
|
|
250
|
-
assert.equal(manager.releaseCreationRequest(restore.workspace.id, "restore-request"), true);
|
|
251
|
-
assert.equal(manager.releaseCreationRequest(restore.workspace.id, "restore-request"), true);
|
|
252
|
-
await manager.delete(concurrent.workspace.id);
|
|
155
|
+
const normal = await manager.create(target);
|
|
156
|
+
const forced = await manager.create(link, undefined, { forceNew: true });
|
|
157
|
+
assert.notEqual(normal.workspace.id, forced.workspace.id);
|
|
158
|
+
await manager.delete(forced.workspace.id);
|
|
253
159
|
const reused = await manager.create(link);
|
|
254
160
|
assert.equal(reused.created, false);
|
|
255
|
-
assert.equal(reused.workspace.id,
|
|
256
|
-
});
|
|
257
|
-
it("keeps the canonical reservation when a forced duplicate is deleted", async () => {
|
|
258
|
-
const { root, target, link } = await createLinkedWorkspace();
|
|
259
|
-
const manager = createManager(root);
|
|
260
|
-
const normalLaunch = deferred();
|
|
261
|
-
const forcedLaunch = deferred();
|
|
262
|
-
let launches = 0;
|
|
263
|
-
const launchedWorkspaceIds = [];
|
|
264
|
-
manager.runtime.launch = (options) => {
|
|
265
|
-
launches += 1;
|
|
266
|
-
launchedWorkspaceIds.push(options.workspaceId);
|
|
267
|
-
return launches === 1 ? normalLaunch.promise : forcedLaunch.promise;
|
|
268
|
-
};
|
|
269
|
-
const first = manager.create(target);
|
|
270
|
-
while (launchedWorkspaceIds.length < 1) {
|
|
271
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
272
|
-
}
|
|
273
|
-
const forced = manager.create(link, undefined, { forceNew: true });
|
|
274
|
-
const forcedRejected = assert.rejects(forced, /launch was cancelled/);
|
|
275
|
-
while (launchedWorkspaceIds.length < 2) {
|
|
276
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
277
|
-
}
|
|
278
|
-
const forcedDeletion = manager.delete(launchedWorkspaceIds[1]);
|
|
279
|
-
const reused = manager.create(link);
|
|
280
|
-
normalLaunch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => { }), cancellationPromise: new Promise(() => { }), getLastOutput: () => "" });
|
|
281
|
-
forcedLaunch.resolve({ pid: 456, port: 5432, exitPromise: new Promise(() => { }), cancellationPromise: new Promise(() => { }), getLastOutput: () => "" });
|
|
282
|
-
await forcedDeletion;
|
|
283
|
-
const [firstResult, reusedResult] = await Promise.all([first, reused]);
|
|
284
|
-
await forcedRejected;
|
|
285
|
-
assert.equal(launches, 2);
|
|
286
|
-
assert.equal(firstResult.workspace.id, reusedResult.workspace.id);
|
|
287
|
-
assert.equal(reusedResult.created, false);
|
|
288
|
-
});
|
|
289
|
-
it("cancels an in-flight startup when its workspace is deleted", async () => {
|
|
290
|
-
const { root, target } = await createLinkedWorkspace();
|
|
291
|
-
const manager = createManager(root);
|
|
292
|
-
const launch = deferred();
|
|
293
|
-
let workspaceId = "";
|
|
294
|
-
manager.runtime.launch = (options) => {
|
|
295
|
-
workspaceId = options.workspaceId;
|
|
296
|
-
return launch.promise;
|
|
297
|
-
};
|
|
298
|
-
const events = [];
|
|
299
|
-
manager.options.eventBus.onEvent((event) => events.push(event));
|
|
300
|
-
const creation = manager.create(target);
|
|
301
|
-
const creationRejected = assert.rejects(creation, /launch was cancelled/);
|
|
302
|
-
while (!workspaceId) {
|
|
303
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
304
|
-
}
|
|
305
|
-
const deletion = manager.delete(workspaceId);
|
|
306
|
-
launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => { }), cancellationPromise: new Promise(() => { }), getLastOutput: () => "" });
|
|
307
|
-
await Promise.all([creationRejected, deletion]);
|
|
308
|
-
assert.equal(manager.get(workspaceId), undefined);
|
|
309
|
-
assert.equal(events.filter((event) => event.type === "workspace.stopped" && event.workspaceId === workspaceId).length, 1);
|
|
310
|
-
});
|
|
311
|
-
it("cancels an in-flight restore startup by request id", async () => {
|
|
312
|
-
const { root, target } = await createLinkedWorkspace();
|
|
313
|
-
const manager = createManager(root);
|
|
314
|
-
const launch = deferred();
|
|
315
|
-
let workspaceId = "";
|
|
316
|
-
manager.runtime.launch = (options) => {
|
|
317
|
-
workspaceId = options.workspaceId;
|
|
318
|
-
return launch.promise;
|
|
319
|
-
};
|
|
320
|
-
const creation = manager.create(target, undefined, { requestId: "cancel-restore" });
|
|
321
|
-
const creationRejected = assert.rejects(creation, /launch was cancelled/);
|
|
322
|
-
while (!workspaceId)
|
|
323
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
324
|
-
const cancellation = manager.cancelCreationRequest("cancel-restore");
|
|
325
|
-
launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => { }), cancellationPromise: new Promise(() => { }), getLastOutput: () => "" });
|
|
326
|
-
await Promise.all([creationRejected, cancellation]);
|
|
327
|
-
assert.equal(manager.get(workspaceId), undefined);
|
|
328
|
-
});
|
|
329
|
-
it("remembers cancellation that arrives before restore reservation", async () => {
|
|
330
|
-
const { root, target } = await createLinkedWorkspace();
|
|
331
|
-
const manager = createManager(root);
|
|
332
|
-
await manager.cancelCreationRequest("early-cancel");
|
|
333
|
-
await assert.rejects(manager.create(target, undefined, { requestId: "early-cancel" }), /creation request early-cancel was cancelled/);
|
|
334
|
-
assert.equal(manager.list().length, 0);
|
|
335
|
-
});
|
|
336
|
-
it("waits for and cancels forced creations during shutdown", async () => {
|
|
337
|
-
const { root, target } = await createLinkedWorkspace();
|
|
338
|
-
const manager = createManager(root);
|
|
339
|
-
const launch = deferred();
|
|
340
|
-
let workspaceId = "";
|
|
341
|
-
manager.runtime.launch = (options) => {
|
|
342
|
-
workspaceId = options.workspaceId;
|
|
343
|
-
return launch.promise;
|
|
344
|
-
};
|
|
345
|
-
const creation = manager.create(target, undefined, { forceNew: true });
|
|
346
|
-
const creationRejected = assert.rejects(creation, /launch was cancelled/);
|
|
347
|
-
while (!workspaceId) {
|
|
348
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
349
|
-
}
|
|
350
|
-
const shutdown = manager.shutdown();
|
|
351
|
-
launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => { }), cancellationPromise: new Promise(() => { }), getLastOutput: () => "" });
|
|
352
|
-
await Promise.all([creationRejected, shutdown]);
|
|
353
|
-
assert.equal(manager.list().length, 0);
|
|
161
|
+
assert.equal(reused.workspace.id, normal.workspace.id);
|
|
354
162
|
});
|
|
355
163
|
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { binaryPathsEqual } from "./manager";
|
|
4
|
+
test("matches regular Windows binary paths without case sensitivity", () => {
|
|
5
|
+
assert.equal(binaryPathsEqual(String.raw `C:\Tools\OpenCode.cmd`, String.raw `c:\tools\opencode.CMD`, "win32"), true);
|
|
6
|
+
});
|
|
7
|
+
test("matches WSL distro names without changing Linux path casing", () => {
|
|
8
|
+
assert.equal(binaryPathsEqual(String.raw `\\wsl.localhost\Ubuntu\home\dev\OpenCode`, String.raw `\\wsl$\ubuntu\home\dev\OpenCode`, "win32"), true);
|
|
9
|
+
assert.equal(binaryPathsEqual(String.raw `\\wsl.localhost\Ubuntu\home\dev\OpenCode`, String.raw `\\wsl$\ubuntu\home\dev\opencode`, "win32"), false);
|
|
10
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
|
2
2
|
const INSTANCE_HOST = "127.0.0.1";
|
|
3
|
-
const LOOPBACK_TIMEOUT_MS =
|
|
3
|
+
const LOOPBACK_TIMEOUT_MS = 10_000;
|
|
4
4
|
/**
|
|
5
5
|
* Creates an OpenCode SDK client for direct loopback communication with a
|
|
6
6
|
* running workspace instance.
|
|
@@ -18,7 +18,7 @@ const LOOPBACK_TIMEOUT_MS = 10000;
|
|
|
18
18
|
* call or cache per instance as needed. Returns `null` when the instance has
|
|
19
19
|
* no open port yet.
|
|
20
20
|
*/
|
|
21
|
-
export function createInstanceClient(workspaceManager, instanceId) {
|
|
21
|
+
export function createInstanceClient(workspaceManager, instanceId, options = {}) {
|
|
22
22
|
const port = workspaceManager.getInstancePort(instanceId);
|
|
23
23
|
if (!port)
|
|
24
24
|
return null;
|
|
@@ -28,12 +28,13 @@ export function createInstanceClient(workspaceManager, instanceId) {
|
|
|
28
28
|
headers.authorization = authorization;
|
|
29
29
|
}
|
|
30
30
|
const workspace = workspaceManager.get(instanceId);
|
|
31
|
+
const timeoutMs = options.timeoutMs ?? LOOPBACK_TIMEOUT_MS;
|
|
31
32
|
return createOpencodeClient({
|
|
32
33
|
baseUrl: `http://${INSTANCE_HOST}:${port}/`,
|
|
33
34
|
headers,
|
|
34
35
|
fetch: (url, init) => fetch(url, {
|
|
35
36
|
...init,
|
|
36
|
-
signal: init?.signal ?? AbortSignal.timeout(
|
|
37
|
+
signal: init?.signal ?? AbortSignal.timeout(timeoutMs),
|
|
37
38
|
}),
|
|
38
39
|
...(workspace?.path ? { directory: workspace.path } : {}),
|
|
39
40
|
});
|
|
@@ -1,33 +1,29 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
2
3
|
import { describe, it } from "node:test";
|
|
3
|
-
import { LAUNCH_CLEANUP_TOKEN_ENV, probeLaunchCleanupToken, signalLaunchCleanupToken
|
|
4
|
-
|
|
5
|
-
return { pid: 1, output: [null, stdout, stderr], stdout, stderr, status, signal: null };
|
|
6
|
-
}
|
|
4
|
+
import { LAUNCH_CLEANUP_TOKEN_ENV, probeLaunchCleanupToken, signalLaunchCleanupToken } from "./process-identity";
|
|
5
|
+
const result = (stdout) => ({ pid: 1, output: [null, stdout, ""], stdout, stderr: "", status: 0, signal: null });
|
|
7
6
|
describe("launch cleanup token adapter", () => {
|
|
8
|
-
it("passes the exact token to
|
|
9
|
-
const token = "a".repeat(64);
|
|
10
|
-
|
|
11
|
-
const probe = probeLaunchCleanupToken(
|
|
12
|
-
invocation = { command, args, timeout: options.timeout };
|
|
13
|
-
return result("5000|1|4242|150|boot-a|150\n");
|
|
14
|
-
}), token, 25);
|
|
7
|
+
it("passes the exact token to the bounded Linux environ probe", () => {
|
|
8
|
+
const token = "a".repeat(64), calls = [];
|
|
9
|
+
const run = ((command, args, options) => { calls.push(command, args, options); return result("CODENOMAD_PROCESS|5000|1|4242|150|boot-a|150\n"); });
|
|
10
|
+
const probe = probeLaunchCleanupToken(run, token, 25);
|
|
15
11
|
assert.equal(probe.ok && probe.processes.get(5000)?.startOrder, "150");
|
|
16
|
-
assert.equal(
|
|
17
|
-
assert.
|
|
18
|
-
assert.
|
|
19
|
-
assert.
|
|
20
|
-
assert.match(invocation?.args[1] ?? "", /\/proc\/\$1\/environ/);
|
|
12
|
+
assert.equal(calls[0], "sh");
|
|
13
|
+
assert.deepEqual([calls[2].timeout, calls[1].includes(LAUNCH_CLEANUP_TOKEN_ENV), calls[1].includes(token)], [25, true, true]);
|
|
14
|
+
assert.match(calls[1][1], /\/proc\/\$1\/environ/);
|
|
15
|
+
assert.doesNotMatch(calls[1][1], /\bseq\b/);
|
|
21
16
|
});
|
|
22
|
-
it("
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
"CODENOMAD_RESULT|1\n")), "b".repeat(64), "SIGKILL", 25);
|
|
26
|
-
assert.equal(cleanup.ok, true);
|
|
27
|
-
assert.deepEqual(cleanup.targets.map((target) => target.pid), [4242, 5000]);
|
|
17
|
+
it("executes a successful empty Linux token probe", { skip: process.platform !== "linux" }, () => {
|
|
18
|
+
const probe = probeLaunchCleanupToken(spawnSync, "f".repeat(64), 1_000);
|
|
19
|
+
assert.deepEqual(probe, { ok: true, processes: new Map() });
|
|
28
20
|
});
|
|
29
|
-
it("rejects malformed
|
|
30
|
-
const
|
|
31
|
-
|
|
21
|
+
it("signals every exact-token target and rejects malformed records", () => {
|
|
22
|
+
const rows = "CODENOMAD_TARGET|4242|1|4242|100|boot-a|100\nCODENOMAD_TARGET|5000|1|4242|150|boot-a|150\nCODENOMAD_RESULT|1\n";
|
|
23
|
+
const run = (() => result(rows));
|
|
24
|
+
const cleanup = signalLaunchCleanupToken(run, "b".repeat(64), "SIGKILL", 25);
|
|
25
|
+
assert.deepEqual([cleanup.ok, cleanup.targets.map(({ pid }) => pid)], [true, [4242, 5000]]);
|
|
26
|
+
const malformed = (() => result("5000|1|4242|150|boot-a|150|truncated\n"));
|
|
27
|
+
assert.equal(probeLaunchCleanupToken(malformed, "c".repeat(64), 25).ok, false);
|
|
32
28
|
});
|
|
33
29
|
});
|