@neuralnomads/codenomad-dev 0.18.0-dev-20260715-07e8ce68 → 0.18.0-dev-20260716-40cee071
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/THIRD_PARTY_NOTICES.md +26 -0
- package/dist/events/bus.js +13 -0
- package/dist/events/bus.test.js +37 -0
- package/dist/index.js +33 -56
- package/dist/index.test.js +32 -0
- package/dist/server/__tests__/remote-proxy.test.js +102 -6
- package/dist/server/http-server.js +2 -0
- package/dist/server/remote-proxy.js +96 -9
- package/dist/server/routes/usage.js +18 -0
- package/dist/server/routes/usage.test.js +26 -0
- package/dist/server/routes/workspaces.js +32 -1
- package/dist/server/routes/workspaces.test.js +75 -0
- package/dist/settings/binaries.js +4 -1
- package/dist/settings/binaries.test.js +24 -0
- package/dist/shutdown.js +75 -0
- package/dist/shutdown.test.js +111 -0
- package/dist/usage/providers/api-key.js +171 -0
- package/dist/usage/providers/google.js +171 -0
- package/dist/usage/providers/minimax.js +109 -0
- package/dist/usage/providers/oauth.js +111 -0
- package/dist/usage/providers/special.js +148 -0
- package/dist/usage/service.js +83 -0
- package/dist/usage/service.test.js +59 -0
- package/dist/usage/shared.js +130 -0
- package/dist/usage/shared.test.js +33 -0
- package/dist/usage/types.js +1 -0
- package/dist/workspaces/__tests__/spawn.test.js +64 -7
- package/dist/workspaces/__tests__/workspace-identity.test.js +177 -25
- package/dist/workspaces/launch-cleanup.js +132 -0
- package/dist/workspaces/launch-cleanup.test.js +33 -0
- package/dist/workspaces/manager.js +393 -135
- package/dist/workspaces/manager.test.js +318 -0
- package/dist/workspaces/process-identity.js +408 -0
- package/dist/workspaces/process-identity.test.js +165 -0
- package/dist/workspaces/runtime.js +567 -122
- package/dist/workspaces/runtime.test.js +603 -0
- package/dist/workspaces/spawn.js +86 -15
- package/package.json +1 -1
- package/public/assets/{FilesTab-EPvldvn1.js → FilesTab-BjztpSdj.js} +2 -2
- package/public/assets/{GitChangesTab-ofjovmv2.js → GitChangesTab-B9XraSoR.js} +2 -2
- package/public/assets/{SplitFilePanel-CyoPkcLd.js → SplitFilePanel-Cu_5-Z-E.js} +1 -1
- package/public/assets/StatusTab-D_IE79r-.js +1 -0
- package/public/assets/{align-justify-Bcf1-ZOl.js → align-justify-BKS9gyRk.js} +1 -1
- package/public/assets/{bundle-full-D5dzvToA.js → bundle-full-CXGXXlzR.js} +1 -1
- package/public/assets/{diff-viewer-DViXCI-4.js → diff-viewer-CSNOYoEB.js} +1 -1
- package/public/assets/{git-diff-vendor-CSgooKT_.js → git-diff-vendor-CYS3ApzA.js} +1 -1
- package/public/assets/index-B3YjNCh4.js +1 -0
- package/public/assets/index-B3ZD2_SU.js +1 -0
- package/public/assets/index-Bh2ofaQo.js +1 -0
- package/public/assets/index-CM0c9xRZ.js +1 -0
- package/public/assets/index-CdDxouJl.js +2 -0
- package/public/assets/index-D06Y2cFq.js +1 -0
- package/public/assets/{index-lf4nUOU1.js → index-DVc4ls8c.js} +1 -1
- package/public/assets/index-PmE51vPh.js +1 -0
- package/public/assets/{index-BMvFMsuy.css → index-Qj63IiSj.css} +1 -1
- package/public/assets/index-VY_zwmsG.js +1 -0
- package/public/assets/index-qiiH7egV.js +1 -0
- package/public/assets/{index-UbL8tz_V.js → index-sLe9hEfQ.js} +1 -1
- package/public/assets/{loading-CZ_tt3h0.js → loading-CDmt9OJU.js} +1 -1
- package/public/assets/main-CTN6Wwz1.js +68 -0
- package/public/assets/{markdown-G11aN6hz.js → markdown-1ZLSxCFT.js} +55 -44
- package/public/assets/monaco-viewer-D6JgH76W.js +26 -0
- package/public/assets/{tool-call-BsSXWErm.js → tool-call-CyXb63LH.js} +32 -32
- package/public/assets/{unified-picker-BRNI3UvE.js → unified-picker-DCCdSVwI.js} +1 -1
- package/public/assets/{wrap-text-B7up-uv7.js → wrap-text-pNMdTIq3.js} +1 -1
- package/public/index.html +5 -5
- package/public/loading.html +5 -5
- package/public/sw.js +1 -1
- package/public/assets/StatusTab-Bgu1Fh24.js +0 -1
- package/public/assets/index-3KsjbWGP.js +0 -1
- package/public/assets/index-B0WK8-cF.js +0 -1
- package/public/assets/index-BDv_iVtj.js +0 -1
- package/public/assets/index-BjxZvyb9.js +0 -2
- package/public/assets/index-BleKf9Nj.js +0 -1
- package/public/assets/index-CiIQ43NW.js +0 -1
- package/public/assets/index-DBquqL6Q.js +0 -1
- package/public/assets/index-yf9zolv4.js +0 -1
- package/public/assets/index-ypaL7i_s.js +0 -1
- package/public/assets/main-CAkg_fp_.js +0 -68
- package/public/assets/monaco-viewer-CZefBfct.js +0 -26
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 15000;
|
|
5
|
+
function authFileCandidates() {
|
|
6
|
+
const home = os.homedir();
|
|
7
|
+
const candidates = [
|
|
8
|
+
process.env.OPENCODE_AUTH_FILE,
|
|
9
|
+
process.env.OPENCODE_DATA_DIR ? path.join(process.env.OPENCODE_DATA_DIR, "auth.json") : undefined,
|
|
10
|
+
process.env.XDG_DATA_HOME ? path.join(process.env.XDG_DATA_HOME, "opencode", "auth.json") : undefined,
|
|
11
|
+
path.join(home, ".local", "share", "opencode", "auth.json"),
|
|
12
|
+
process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, "opencode", "Data", "auth.json") : undefined,
|
|
13
|
+
process.env.APPDATA ? path.join(process.env.APPDATA, "opencode", "auth.json") : undefined,
|
|
14
|
+
path.join(home, "Library", "Application Support", "opencode", "auth.json"),
|
|
15
|
+
];
|
|
16
|
+
return Array.from(new Set(candidates.filter((candidate) => Boolean(candidate))));
|
|
17
|
+
}
|
|
18
|
+
export function readOpenCodeAuth() {
|
|
19
|
+
for (const candidate of authFileCandidates()) {
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(fs.readFileSync(candidate, "utf8"));
|
|
22
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code !== "ENOENT")
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
export function getAuthEntry(aliases) {
|
|
33
|
+
const auth = readOpenCodeAuth();
|
|
34
|
+
for (const alias of aliases) {
|
|
35
|
+
const value = auth[alias];
|
|
36
|
+
if (typeof value === "string" && value.trim())
|
|
37
|
+
return { token: value.trim() };
|
|
38
|
+
if (value && typeof value === "object" && !Array.isArray(value))
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
export function getString(value) {
|
|
44
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
45
|
+
}
|
|
46
|
+
export function getCredential(aliases, fields) {
|
|
47
|
+
const entry = getAuthEntry(aliases);
|
|
48
|
+
if (!entry)
|
|
49
|
+
return null;
|
|
50
|
+
for (const field of fields) {
|
|
51
|
+
const value = getString(entry[field]);
|
|
52
|
+
if (value)
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
export function asObject(value) {
|
|
58
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
59
|
+
}
|
|
60
|
+
export function toNumber(value) {
|
|
61
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
62
|
+
return value;
|
|
63
|
+
if (typeof value === "string" && value.trim()) {
|
|
64
|
+
const parsed = Number(value);
|
|
65
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
export function toTimestamp(value) {
|
|
70
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
71
|
+
return value < 1000000000000 ? value * 1000 : value;
|
|
72
|
+
if (typeof value === "string" && value.trim()) {
|
|
73
|
+
const parsed = Date.parse(value);
|
|
74
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
export function toUsageWindow(input) {
|
|
79
|
+
const usedPercent = input.usedPercent === null ? null : Math.max(0, Math.min(100, input.usedPercent));
|
|
80
|
+
return {
|
|
81
|
+
usedPercent,
|
|
82
|
+
remainingPercent: usedPercent === null ? null : 100 - usedPercent,
|
|
83
|
+
windowSeconds: input.windowSeconds ?? null,
|
|
84
|
+
resetAt: input.resetAt ?? null,
|
|
85
|
+
...(input.valueLabel ? { valueLabel: input.valueLabel } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function resolveWindowLabel(windowSeconds) {
|
|
89
|
+
if (!windowSeconds)
|
|
90
|
+
return "usage";
|
|
91
|
+
if (windowSeconds % 86400 === 0) {
|
|
92
|
+
const days = windowSeconds / 86400;
|
|
93
|
+
return days === 7 ? "weekly" : `${days}d`;
|
|
94
|
+
}
|
|
95
|
+
if (windowSeconds % 3600 === 0)
|
|
96
|
+
return `${windowSeconds / 3600}h`;
|
|
97
|
+
return `${windowSeconds}s`;
|
|
98
|
+
}
|
|
99
|
+
export function result(providerId, providerName, input) {
|
|
100
|
+
return {
|
|
101
|
+
providerId,
|
|
102
|
+
providerName,
|
|
103
|
+
ok: input.ok,
|
|
104
|
+
configured: input.configured,
|
|
105
|
+
usage: input.usage ?? null,
|
|
106
|
+
fetchedAt: Date.now(),
|
|
107
|
+
...(input.error ? { error: input.error } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export function notConfigured(providerId, providerName) {
|
|
111
|
+
return result(providerId, providerName, { ok: false, configured: false, error: "Not configured" });
|
|
112
|
+
}
|
|
113
|
+
export async function fetchJson(url, init = {}) {
|
|
114
|
+
const response = await fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
115
|
+
if (!response.ok)
|
|
116
|
+
throw new Error(`Provider returned HTTP ${response.status}`);
|
|
117
|
+
return response.json();
|
|
118
|
+
}
|
|
119
|
+
export async function safeFetch(providerId, providerName, operation) {
|
|
120
|
+
try {
|
|
121
|
+
return result(providerId, providerName, { ok: true, configured: true, usage: await operation() });
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return result(providerId, providerName, {
|
|
125
|
+
ok: false,
|
|
126
|
+
configured: true,
|
|
127
|
+
error: error instanceof Error ? error.message : "Request failed",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { getCredential, readOpenCodeAuth, toTimestamp, toUsageWindow } from "./shared";
|
|
7
|
+
test("reads the explicit OpenCode auth file without exposing credentials through the API", () => {
|
|
8
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-usage-"));
|
|
9
|
+
const authFile = path.join(directory, "auth.json");
|
|
10
|
+
const previous = process.env.OPENCODE_AUTH_FILE;
|
|
11
|
+
fs.writeFileSync(authFile, JSON.stringify({ anthropic: { type: "oauth", access: "secret-token" } }));
|
|
12
|
+
process.env.OPENCODE_AUTH_FILE = authFile;
|
|
13
|
+
try {
|
|
14
|
+
assert.equal(readOpenCodeAuth().anthropic.access, "secret-token");
|
|
15
|
+
assert.equal(getCredential(["anthropic"], ["access"]), "secret-token");
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
if (previous === undefined)
|
|
19
|
+
delete process.env.OPENCODE_AUTH_FILE;
|
|
20
|
+
else
|
|
21
|
+
process.env.OPENCODE_AUTH_FILE = previous;
|
|
22
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
test("normalizes percentages and timestamps", () => {
|
|
26
|
+
assert.deepEqual(toUsageWindow({ usedPercent: 120, resetAt: null }), {
|
|
27
|
+
usedPercent: 100,
|
|
28
|
+
remainingPercent: 0,
|
|
29
|
+
windowSeconds: null,
|
|
30
|
+
resetAt: null,
|
|
31
|
+
});
|
|
32
|
+
assert.equal(toTimestamp(1700000000), 1700000000000);
|
|
33
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
2
5
|
import { describe, it } from "node:test";
|
|
3
|
-
import { buildWindowsSpawnSpec,
|
|
6
|
+
import { buildWindowsSpawnSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn";
|
|
4
7
|
describe("parseWslUncPath", () => {
|
|
5
8
|
it("parses WSL UNC paths into distro and linux path", () => {
|
|
6
9
|
assert.deepEqual(parseWslUncPath(String.raw `\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`), {
|
|
@@ -30,6 +33,62 @@ describe("resolveWslWorkingDirectory", () => {
|
|
|
30
33
|
});
|
|
31
34
|
});
|
|
32
35
|
describe("buildWindowsSpawnSpec", () => {
|
|
36
|
+
it("classifies native executables separately from script and shell wrappers", () => {
|
|
37
|
+
assert.equal(buildWindowsSpawnSpec("opencode.exe", []).processKind, "windows-direct");
|
|
38
|
+
assert.equal(buildWindowsSpawnSpec("opencode.cmd", []).processKind, "windows-wrapper");
|
|
39
|
+
assert.equal(buildWindowsSpawnSpec("powershell.exe", []).processKind, "windows-wrapper");
|
|
40
|
+
});
|
|
41
|
+
it("conservatively classifies bare commands as wrappers", () => {
|
|
42
|
+
assert.equal(buildWindowsSpawnSpec("opencode", []).processKind, "windows-wrapper");
|
|
43
|
+
});
|
|
44
|
+
it("resolves a bare cmd shim from a quoted PATH entry and wraps its absolute path", () => {
|
|
45
|
+
const root = mkdtempSync(path.join(tmpdir(), "codenomad-spawn-"));
|
|
46
|
+
const cwd = path.join(root, "workspace");
|
|
47
|
+
const bin = path.join(root, "bin with spaces");
|
|
48
|
+
mkdirSync(cwd);
|
|
49
|
+
mkdirSync(bin);
|
|
50
|
+
const shim = path.join(bin, "opencode.cmd");
|
|
51
|
+
writeFileSync(shim, "@echo off\r\n");
|
|
52
|
+
try {
|
|
53
|
+
const spec = buildWindowsSpawnSpec("opencode", ["serve"], {
|
|
54
|
+
cwd,
|
|
55
|
+
env: { Path: `"${bin}"`, PathExt: ".CMD;.EXE", ComSpec: "test-cmd.exe" },
|
|
56
|
+
});
|
|
57
|
+
assert.equal(spec.command, "test-cmd.exe");
|
|
58
|
+
assert.equal(spec.processKind, "windows-wrapper");
|
|
59
|
+
assert.equal(spec.options.windowsVerbatimArguments, true);
|
|
60
|
+
assert.match(spec.args[3] ?? "", new RegExp(escapeRegex(path.win32.resolve(shim)), "i"));
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
rmSync(root, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
it("honors PATHEXT precedence when both native and shim files exist", () => {
|
|
67
|
+
const root = mkdtempSync(path.join(tmpdir(), "codenomad-spawn-"));
|
|
68
|
+
writeFileSync(path.join(root, "opencode.cmd"), "@echo off\r\n");
|
|
69
|
+
writeFileSync(path.join(root, "opencode.exe"), "");
|
|
70
|
+
try {
|
|
71
|
+
const spec = buildWindowsSpawnSpec("opencode", [], {
|
|
72
|
+
cwd: root,
|
|
73
|
+
env: { PATH: "", PATHEXT: ".EXE;.CMD" },
|
|
74
|
+
});
|
|
75
|
+
assert.equal(spec.command.toLowerCase(), path.win32.resolve(root, "opencode.exe").toLowerCase());
|
|
76
|
+
assert.equal(spec.processKind, "windows-direct");
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
rmSync(root, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
it("leaves an unresolved bare command unchanged without injecting a shell", () => {
|
|
83
|
+
const spec = buildWindowsSpawnSpec("missing-opencode", ["serve"], {
|
|
84
|
+
cwd: String.raw `C:\missing-workspace`,
|
|
85
|
+
env: { PATH: "", PATHEXT: ".CMD;.EXE", ComSpec: "must-not-run.exe" },
|
|
86
|
+
});
|
|
87
|
+
assert.equal(spec.command, "missing-opencode");
|
|
88
|
+
assert.deepEqual(spec.args, ["serve"]);
|
|
89
|
+
assert.equal(spec.processKind, "windows-wrapper");
|
|
90
|
+
assert.equal(spec.options.windowsVerbatimArguments, undefined);
|
|
91
|
+
});
|
|
33
92
|
it("wraps WSL binaries with wsl.exe and propagates required env vars", () => {
|
|
34
93
|
const spec = buildWindowsSpawnSpec(String.raw `\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, ["serve", "--port", "0"], {
|
|
35
94
|
cwd: String.raw `\\wsl.localhost\Ubuntu\home\dev\workspace`,
|
|
@@ -142,7 +201,7 @@ describe("buildWindowsSpawnSpec", () => {
|
|
|
142
201
|
"--exec",
|
|
143
202
|
"sh",
|
|
144
203
|
"-lc",
|
|
145
|
-
`printf '%s%s\\n' '__CODENOMAD_WSL_PID__:' "$$" && cd "$1" && shift && exec "$@"`,
|
|
204
|
+
`codenomad_pgid=$(ps -o pgid= -p "$$" 2>/dev/null | tr -d '[:space:]'); codenomad_start=$(awk '{print $22}' "/proc/$$/stat" 2>/dev/null); codenomad_boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null); test -n "$codenomad_pgid" && test -n "$codenomad_start" && test -n "$codenomad_boot" && printf '%s%s:%s:%s:%s\\n' '__CODENOMAD_WSL_PID__:' "$$" "$codenomad_pgid" "$codenomad_start" "$codenomad_boot" && cd "$1" && shift && exec "$@"`,
|
|
146
205
|
"codenomad-wsl-launch",
|
|
147
206
|
"/home/dev/workspace",
|
|
148
207
|
"/home/dev/.opencode/bin/opencode",
|
|
@@ -150,9 +209,7 @@ describe("buildWindowsSpawnSpec", () => {
|
|
|
150
209
|
]);
|
|
151
210
|
assert.equal(spec.wsl?.pidMarker, "__CODENOMAD_WSL_PID__:");
|
|
152
211
|
});
|
|
153
|
-
it("builds the WSL kill command for tracked Linux PIDs", () => {
|
|
154
|
-
const spec = buildWslSignalSpec("Ubuntu", 4321, "SIGTERM");
|
|
155
|
-
assert.equal(spec.command, "wsl.exe");
|
|
156
|
-
assert.deepEqual(spec.args, ["--distribution", "Ubuntu", "--exec", "kill", "-TERM", "4321"]);
|
|
157
|
-
});
|
|
158
212
|
});
|
|
213
|
+
function escapeRegex(value) {
|
|
214
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
215
|
+
}
|
|
@@ -35,7 +35,7 @@ function createManager(rootDir) {
|
|
|
35
35
|
rootDir,
|
|
36
36
|
settings: { getOwner: () => ({ environmentVariables: {} }) },
|
|
37
37
|
binaryResolver: {
|
|
38
|
-
|
|
38
|
+
resolve: () => ({ path: process.execPath, label: "Node.js", version: process.version }),
|
|
39
39
|
},
|
|
40
40
|
eventBus: new EventBus(logger),
|
|
41
41
|
logger,
|
|
@@ -46,6 +46,7 @@ function createManager(rootDir) {
|
|
|
46
46
|
pid: 123,
|
|
47
47
|
port: 4321,
|
|
48
48
|
exitPromise: new Promise(() => { }),
|
|
49
|
+
cancellationPromise: new Promise(() => { }),
|
|
49
50
|
getLastOutput: () => "",
|
|
50
51
|
});
|
|
51
52
|
internal.waitForWorkspaceReadiness = async () => undefined;
|
|
@@ -84,40 +85,139 @@ describe("workspace identity", () => {
|
|
|
84
85
|
it("shares an in-flight startup between canonical aliases", async () => {
|
|
85
86
|
const { root, target, link } = await createLinkedWorkspace();
|
|
86
87
|
const manager = createManager(root);
|
|
88
|
+
const launchGate = deferred();
|
|
87
89
|
let launches = 0;
|
|
88
90
|
manager.runtime.launch = async () => {
|
|
89
91
|
launches += 1;
|
|
90
|
-
await
|
|
92
|
+
await launchGate.promise;
|
|
91
93
|
return {
|
|
92
94
|
pid: 123,
|
|
93
95
|
port: 4321,
|
|
94
96
|
exitPromise: new Promise(() => { }),
|
|
97
|
+
cancellationPromise: new Promise(() => { }),
|
|
95
98
|
getLastOutput: () => "",
|
|
96
99
|
};
|
|
97
100
|
};
|
|
98
|
-
const
|
|
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]);
|
|
99
108
|
assert.equal(launches, 1);
|
|
100
|
-
assert.equal(
|
|
109
|
+
assert.equal(first.created, false);
|
|
110
|
+
assert.equal(first.workspace.requestId, undefined);
|
|
101
111
|
assert.equal(first.workspace.id, second.workspace.id);
|
|
102
112
|
assert.equal(first.workspace.status, "ready");
|
|
103
113
|
assert.equal(second.workspace.status, "ready");
|
|
104
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
|
+
]);
|
|
122
|
+
assert.equal(Number(first.created) + Number(second.created), 1);
|
|
123
|
+
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
|
+
assert.equal(manager.list().length, 1);
|
|
183
|
+
});
|
|
184
|
+
it("retains a shared workspace after one restore owner releases and another cancels", async () => {
|
|
185
|
+
const { root, target, link } = await createLinkedWorkspace();
|
|
186
|
+
const manager = createManager(root);
|
|
187
|
+
const [first, second] = await Promise.all([
|
|
188
|
+
manager.create(target, undefined, { requestId: "restore-first" }),
|
|
189
|
+
manager.create(link, undefined, { requestId: "restore-second" }),
|
|
190
|
+
]);
|
|
191
|
+
assert.equal(manager.releaseCreationRequest(first.workspace.id, "restore-first"), true);
|
|
192
|
+
await manager.cancelCreationRequest("restore-second");
|
|
193
|
+
assert.equal(manager.list().length, 1);
|
|
194
|
+
assert.equal(manager.get(second.workspace.id)?.requestId, undefined);
|
|
195
|
+
assert.equal(manager.releaseCreationRequest(second.workspace.id, "restore-second"), true);
|
|
196
|
+
});
|
|
105
197
|
it("releases a failed identity reservation so creation can be retried", async () => {
|
|
106
198
|
const { root, target, link } = await createLinkedWorkspace();
|
|
107
199
|
const manager = createManager(root);
|
|
200
|
+
const launchGate = deferred();
|
|
108
201
|
let launches = 0;
|
|
109
202
|
manager.runtime.launch = async () => {
|
|
110
203
|
launches += 1;
|
|
111
|
-
await
|
|
204
|
+
await launchGate.promise;
|
|
112
205
|
throw new Error("launch failed");
|
|
113
206
|
};
|
|
114
|
-
const
|
|
207
|
+
const firstFailure = manager.create(target);
|
|
208
|
+
const secondFailure = manager.create(link);
|
|
209
|
+
while (![...manager.pendingWorkspaceCreations.values()][0]?.followerCount) {
|
|
210
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
211
|
+
}
|
|
212
|
+
launchGate.resolve();
|
|
213
|
+
const failed = await Promise.allSettled([firstFailure, secondFailure]);
|
|
115
214
|
assert.deepEqual(failed.map((result) => result.status), ["rejected", "rejected"]);
|
|
116
215
|
assert.equal(launches, 1);
|
|
117
216
|
manager.runtime.launch = async () => ({
|
|
118
217
|
pid: 456,
|
|
119
218
|
port: 5432,
|
|
120
219
|
exitPromise: new Promise(() => { }),
|
|
220
|
+
cancellationPromise: new Promise(() => { }),
|
|
121
221
|
getLastOutput: () => "",
|
|
122
222
|
});
|
|
123
223
|
const retry = await manager.create(target);
|
|
@@ -136,30 +236,50 @@ describe("workspace identity", () => {
|
|
|
136
236
|
assert.notEqual(first.workspace.id, second.workspace.id);
|
|
137
237
|
assert.equal(manager.list().length, 2);
|
|
138
238
|
});
|
|
239
|
+
it("does not reuse restore-owned workspaces until hydration releases ownership", async () => {
|
|
240
|
+
const { root, target, link } = await createLinkedWorkspace();
|
|
241
|
+
const manager = createManager(root);
|
|
242
|
+
const restore = await manager.create(target, undefined, {
|
|
243
|
+
forceNew: true,
|
|
244
|
+
requestId: "restore-request",
|
|
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);
|
|
253
|
+
const reused = await manager.create(link);
|
|
254
|
+
assert.equal(reused.created, false);
|
|
255
|
+
assert.equal(reused.workspace.id, restore.workspace.id);
|
|
256
|
+
});
|
|
139
257
|
it("keeps the canonical reservation when a forced duplicate is deleted", async () => {
|
|
140
258
|
const { root, target, link } = await createLinkedWorkspace();
|
|
141
259
|
const manager = createManager(root);
|
|
142
260
|
const normalLaunch = deferred();
|
|
143
261
|
const forcedLaunch = deferred();
|
|
144
262
|
let launches = 0;
|
|
145
|
-
|
|
263
|
+
const launchedWorkspaceIds = [];
|
|
264
|
+
manager.runtime.launch = (options) => {
|
|
146
265
|
launches += 1;
|
|
266
|
+
launchedWorkspaceIds.push(options.workspaceId);
|
|
147
267
|
return launches === 1 ? normalLaunch.promise : forcedLaunch.promise;
|
|
148
268
|
};
|
|
149
269
|
const first = manager.create(target);
|
|
150
|
-
while (
|
|
270
|
+
while (launchedWorkspaceIds.length < 1) {
|
|
151
271
|
await new Promise((resolve) => setImmediate(resolve));
|
|
152
272
|
}
|
|
153
273
|
const forced = manager.create(link, undefined, { forceNew: true });
|
|
154
|
-
const forcedRejected = assert.rejects(forced, /
|
|
155
|
-
while (
|
|
274
|
+
const forcedRejected = assert.rejects(forced, /launch was cancelled/);
|
|
275
|
+
while (launchedWorkspaceIds.length < 2) {
|
|
156
276
|
await new Promise((resolve) => setImmediate(resolve));
|
|
157
277
|
}
|
|
158
|
-
const
|
|
159
|
-
await manager.delete(forcedWorkspace.id);
|
|
278
|
+
const forcedDeletion = manager.delete(launchedWorkspaceIds[1]);
|
|
160
279
|
const reused = manager.create(link);
|
|
161
|
-
normalLaunch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => { }), getLastOutput: () => "" });
|
|
162
|
-
forcedLaunch.resolve({ pid: 456, port: 5432, exitPromise: new Promise(() => { }), getLastOutput: () => "" });
|
|
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;
|
|
163
283
|
const [firstResult, reusedResult] = await Promise.all([first, reused]);
|
|
164
284
|
await forcedRejected;
|
|
165
285
|
assert.equal(launches, 2);
|
|
@@ -170,33 +290,65 @@ describe("workspace identity", () => {
|
|
|
170
290
|
const { root, target } = await createLinkedWorkspace();
|
|
171
291
|
const manager = createManager(root);
|
|
172
292
|
const launch = deferred();
|
|
173
|
-
|
|
293
|
+
let workspaceId = "";
|
|
294
|
+
manager.runtime.launch = (options) => {
|
|
295
|
+
workspaceId = options.workspaceId;
|
|
296
|
+
return launch.promise;
|
|
297
|
+
};
|
|
174
298
|
const events = [];
|
|
175
299
|
manager.options.eventBus.onEvent((event) => events.push(event));
|
|
176
300
|
const creation = manager.create(target);
|
|
177
|
-
const creationRejected = assert.rejects(creation, /
|
|
178
|
-
while (
|
|
301
|
+
const creationRejected = assert.rejects(creation, /launch was cancelled/);
|
|
302
|
+
while (!workspaceId) {
|
|
179
303
|
await new Promise((resolve) => setImmediate(resolve));
|
|
180
304
|
}
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
await creationRejected;
|
|
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]);
|
|
185
308
|
assert.equal(manager.get(workspaceId), undefined);
|
|
186
309
|
assert.equal(events.filter((event) => event.type === "workspace.stopped" && event.workspaceId === workspaceId).length, 1);
|
|
187
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
|
+
});
|
|
188
336
|
it("waits for and cancels forced creations during shutdown", async () => {
|
|
189
337
|
const { root, target } = await createLinkedWorkspace();
|
|
190
338
|
const manager = createManager(root);
|
|
191
339
|
const launch = deferred();
|
|
192
|
-
|
|
340
|
+
let workspaceId = "";
|
|
341
|
+
manager.runtime.launch = (options) => {
|
|
342
|
+
workspaceId = options.workspaceId;
|
|
343
|
+
return launch.promise;
|
|
344
|
+
};
|
|
193
345
|
const creation = manager.create(target, undefined, { forceNew: true });
|
|
194
|
-
const creationRejected = assert.rejects(creation, /
|
|
195
|
-
while (
|
|
346
|
+
const creationRejected = assert.rejects(creation, /launch was cancelled/);
|
|
347
|
+
while (!workspaceId) {
|
|
196
348
|
await new Promise((resolve) => setImmediate(resolve));
|
|
197
349
|
}
|
|
198
350
|
const shutdown = manager.shutdown();
|
|
199
|
-
launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => { }), getLastOutput: () => "" });
|
|
351
|
+
launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => { }), cancellationPromise: new Promise(() => { }), getLastOutput: () => "" });
|
|
200
352
|
await Promise.all([creationRejected, shutdown]);
|
|
201
353
|
assert.equal(manager.list().length, 0);
|
|
202
354
|
});
|