@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.
Files changed (81) hide show
  1. package/README.md +12 -0
  2. package/THIRD_PARTY_NOTICES.md +26 -0
  3. package/dist/events/bus.js +13 -0
  4. package/dist/events/bus.test.js +37 -0
  5. package/dist/index.js +33 -56
  6. package/dist/index.test.js +32 -0
  7. package/dist/server/__tests__/remote-proxy.test.js +102 -6
  8. package/dist/server/http-server.js +2 -0
  9. package/dist/server/remote-proxy.js +96 -9
  10. package/dist/server/routes/usage.js +18 -0
  11. package/dist/server/routes/usage.test.js +26 -0
  12. package/dist/server/routes/workspaces.js +32 -1
  13. package/dist/server/routes/workspaces.test.js +75 -0
  14. package/dist/settings/binaries.js +4 -1
  15. package/dist/settings/binaries.test.js +24 -0
  16. package/dist/shutdown.js +75 -0
  17. package/dist/shutdown.test.js +111 -0
  18. package/dist/usage/providers/api-key.js +171 -0
  19. package/dist/usage/providers/google.js +171 -0
  20. package/dist/usage/providers/minimax.js +109 -0
  21. package/dist/usage/providers/oauth.js +111 -0
  22. package/dist/usage/providers/special.js +148 -0
  23. package/dist/usage/service.js +83 -0
  24. package/dist/usage/service.test.js +59 -0
  25. package/dist/usage/shared.js +130 -0
  26. package/dist/usage/shared.test.js +33 -0
  27. package/dist/usage/types.js +1 -0
  28. package/dist/workspaces/__tests__/spawn.test.js +64 -7
  29. package/dist/workspaces/__tests__/workspace-identity.test.js +177 -25
  30. package/dist/workspaces/launch-cleanup.js +132 -0
  31. package/dist/workspaces/launch-cleanup.test.js +33 -0
  32. package/dist/workspaces/manager.js +393 -135
  33. package/dist/workspaces/manager.test.js +318 -0
  34. package/dist/workspaces/process-identity.js +408 -0
  35. package/dist/workspaces/process-identity.test.js +165 -0
  36. package/dist/workspaces/runtime.js +567 -122
  37. package/dist/workspaces/runtime.test.js +603 -0
  38. package/dist/workspaces/spawn.js +86 -15
  39. package/package.json +1 -1
  40. package/public/assets/{FilesTab-EPvldvn1.js → FilesTab-BjztpSdj.js} +2 -2
  41. package/public/assets/{GitChangesTab-ofjovmv2.js → GitChangesTab-B9XraSoR.js} +2 -2
  42. package/public/assets/{SplitFilePanel-CyoPkcLd.js → SplitFilePanel-Cu_5-Z-E.js} +1 -1
  43. package/public/assets/StatusTab-D_IE79r-.js +1 -0
  44. package/public/assets/{align-justify-Bcf1-ZOl.js → align-justify-BKS9gyRk.js} +1 -1
  45. package/public/assets/{bundle-full-D5dzvToA.js → bundle-full-CXGXXlzR.js} +1 -1
  46. package/public/assets/{diff-viewer-DViXCI-4.js → diff-viewer-CSNOYoEB.js} +1 -1
  47. package/public/assets/{git-diff-vendor-CSgooKT_.js → git-diff-vendor-CYS3ApzA.js} +1 -1
  48. package/public/assets/index-B3YjNCh4.js +1 -0
  49. package/public/assets/index-B3ZD2_SU.js +1 -0
  50. package/public/assets/index-Bh2ofaQo.js +1 -0
  51. package/public/assets/index-CM0c9xRZ.js +1 -0
  52. package/public/assets/index-CdDxouJl.js +2 -0
  53. package/public/assets/index-D06Y2cFq.js +1 -0
  54. package/public/assets/{index-lf4nUOU1.js → index-DVc4ls8c.js} +1 -1
  55. package/public/assets/index-PmE51vPh.js +1 -0
  56. package/public/assets/{index-BMvFMsuy.css → index-Qj63IiSj.css} +1 -1
  57. package/public/assets/index-VY_zwmsG.js +1 -0
  58. package/public/assets/index-qiiH7egV.js +1 -0
  59. package/public/assets/{index-UbL8tz_V.js → index-sLe9hEfQ.js} +1 -1
  60. package/public/assets/{loading-CZ_tt3h0.js → loading-CDmt9OJU.js} +1 -1
  61. package/public/assets/main-CTN6Wwz1.js +68 -0
  62. package/public/assets/{markdown-G11aN6hz.js → markdown-1ZLSxCFT.js} +55 -44
  63. package/public/assets/monaco-viewer-D6JgH76W.js +26 -0
  64. package/public/assets/{tool-call-BsSXWErm.js → tool-call-CyXb63LH.js} +32 -32
  65. package/public/assets/{unified-picker-BRNI3UvE.js → unified-picker-DCCdSVwI.js} +1 -1
  66. package/public/assets/{wrap-text-B7up-uv7.js → wrap-text-pNMdTIq3.js} +1 -1
  67. package/public/index.html +5 -5
  68. package/public/loading.html +5 -5
  69. package/public/sw.js +1 -1
  70. package/public/assets/StatusTab-Bgu1Fh24.js +0 -1
  71. package/public/assets/index-3KsjbWGP.js +0 -1
  72. package/public/assets/index-B0WK8-cF.js +0 -1
  73. package/public/assets/index-BDv_iVtj.js +0 -1
  74. package/public/assets/index-BjxZvyb9.js +0 -2
  75. package/public/assets/index-BleKf9Nj.js +0 -1
  76. package/public/assets/index-CiIQ43NW.js +0 -1
  77. package/public/assets/index-DBquqL6Q.js +0 -1
  78. package/public/assets/index-yf9zolv4.js +0 -1
  79. package/public/assets/index-ypaL7i_s.js +0 -1
  80. package/public/assets/main-CAkg_fp_.js +0 -68
  81. 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, buildWslSignalSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn";
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
- resolveDefault: () => ({ path: process.execPath, label: "Node.js", version: process.version }),
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 new Promise((resolve) => setTimeout(resolve, 20));
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 [first, second] = await Promise.all([manager.create(target), manager.create(link)]);
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(Number(first.created) + Number(second.created), 1);
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 new Promise((resolve) => setTimeout(resolve, 20));
204
+ await launchGate.promise;
112
205
  throw new Error("launch failed");
113
206
  };
114
- const failed = await Promise.allSettled([manager.create(target), manager.create(link)]);
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
- manager.runtime.launch = () => {
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 (manager.list().length < 1) {
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, /Workspace creation cancelled/);
155
- while (manager.list().length < 2) {
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 forcedWorkspace = manager.list().find((workspace) => workspace.id !== manager.list()[0].id);
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
- manager.runtime.launch = () => launch.promise;
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, /Workspace creation cancelled/);
178
- while (manager.list().length === 0) {
301
+ const creationRejected = assert.rejects(creation, /launch was cancelled/);
302
+ while (!workspaceId) {
179
303
  await new Promise((resolve) => setImmediate(resolve));
180
304
  }
181
- const workspaceId = manager.list()[0].id;
182
- await manager.delete(workspaceId);
183
- launch.resolve({ pid: 123, port: 4321, exitPromise: new Promise(() => { }), getLastOutput: () => "" });
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
- manager.runtime.launch = () => launch.promise;
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, /Workspace creation cancelled/);
195
- while (manager.list().length === 0) {
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
  });