@vellumai/cli 0.11.3 → 0.11.4-dev.202608190019.b94dbf2
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/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +33 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +3 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-lock.test.ts +165 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-lock.ts +156 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +249 -8
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +186 -68
- package/node_modules/@vellumai/local-mode/src/unpair.ts +18 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
- package/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +35 -0
- package/src/__tests__/nginx-ingress-command.test.ts +4 -23
- package/src/__tests__/nginx-ingress.test.ts +59 -215
- package/src/__tests__/pair.test.ts +11 -197
- package/src/__tests__/retire-archive.test.ts +13 -1
- package/src/__tests__/retire-local.test.ts +58 -4
- package/src/__tests__/tunnel.test.ts +0 -28
- package/src/__tests__/wake.test.ts +91 -69
- package/src/__tests__/windows-lifecycle.test.ts +157 -0
- package/src/commands/client.ts +9 -31
- package/src/commands/nginx-ingress.ts +0 -15
- package/src/commands/pair.ts +0 -39
- package/src/commands/wake.ts +39 -12
- package/src/lib/__tests__/web-dist.test.ts +86 -0
- package/src/lib/assistant-config.ts +64 -27
- package/src/lib/local.ts +60 -20
- package/src/lib/nginx-ingress.ts +35 -108
- package/src/lib/orphan-detection.test.ts +3 -0
- package/src/lib/orphan-detection.ts +33 -11
- package/src/lib/pgrep.ts +20 -2
- package/src/lib/process.ts +191 -18
- package/src/lib/retire-archive.ts +38 -8
- package/src/lib/retire-local.ts +75 -9
- package/src/lib/tunnel-edge.ts +14 -22
- package/src/lib/web-dist.ts +48 -0
- package/src/lib/feature-flags.test.ts +0 -157
- package/src/lib/feature-flags.ts +0 -38
|
@@ -14,6 +14,8 @@ export interface RemoteProcess {
|
|
|
14
14
|
command: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
const VELLUM_PROCESS_MARKER = /vellum|qdrant|openclaw/;
|
|
18
|
+
|
|
17
19
|
export function classifyProcess(command: string): string {
|
|
18
20
|
if (/qdrant/.test(command)) return "qdrant";
|
|
19
21
|
if (/vellum-gateway/.test(command)) return "gateway";
|
|
@@ -23,9 +25,9 @@ export function classifyProcess(command: string): string {
|
|
|
23
25
|
)
|
|
24
26
|
)
|
|
25
27
|
return "openclaw-adapter";
|
|
26
|
-
if (/vellum-daemon/.test(command)) return "assistant";
|
|
28
|
+
if (/vellum-daemon|[\\/]daemon[\\/]main/.test(command)) return "assistant";
|
|
27
29
|
if (/daemon\s+(start|restart)/.test(command)) return "assistant";
|
|
28
|
-
if (/vellum-cli/.test(command)) return "vellum";
|
|
30
|
+
if (/vellum-cli|[\\/]vellum(?:-cli)?\.exe/.test(command)) return "vellum";
|
|
29
31
|
// Exclude macOS desktop app processes — their path contains .app/Contents/MacOS/
|
|
30
32
|
// but they are not background service processes.
|
|
31
33
|
if (/\.app\/Contents\/MacOS\//.test(command)) return "unknown";
|
|
@@ -56,7 +58,8 @@ export function classifyProcess(command: string): string {
|
|
|
56
58
|
* logs in the terminal).
|
|
57
59
|
*/
|
|
58
60
|
export function isInteractiveCliSession(command: string): boolean {
|
|
59
|
-
const vellumToken =
|
|
61
|
+
const vellumToken =
|
|
62
|
+
/(?:^|[\\/])vellum(?:-cli)?(?:\.exe)?["']?(?:\s+--(?:no-color|plain))*/;
|
|
60
63
|
const interactiveSubcommand = new RegExp(
|
|
61
64
|
vellumToken.source +
|
|
62
65
|
String.raw`\s+(?:tunnel|events|logs|client|terminal|ssh|exec|message|workflows)\b`,
|
|
@@ -154,6 +157,28 @@ export interface DetectOrphansOptions {
|
|
|
154
157
|
* avoid touching the real on-host lockfiles.
|
|
155
158
|
*/
|
|
156
159
|
excludePids?: Set<string>;
|
|
160
|
+
platform?: NodeJS.Platform;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const WINDOWS_PROCESS_LIST_SCRIPT =
|
|
164
|
+
'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $($_.CommandLine)" }';
|
|
165
|
+
|
|
166
|
+
export function processTableCommand(hostPlatform: NodeJS.Platform): {
|
|
167
|
+
command: string;
|
|
168
|
+
args: string[];
|
|
169
|
+
} {
|
|
170
|
+
if (hostPlatform === "win32") {
|
|
171
|
+
return {
|
|
172
|
+
command: "powershell.exe",
|
|
173
|
+
args: [
|
|
174
|
+
"-NoProfile",
|
|
175
|
+
"-NonInteractive",
|
|
176
|
+
"-Command",
|
|
177
|
+
WINDOWS_PROCESS_LIST_SCRIPT,
|
|
178
|
+
],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return { command: "ps", args: ["ax", "-o", "pid=,ppid=,args="] };
|
|
157
182
|
}
|
|
158
183
|
|
|
159
184
|
export async function detectOrphanedProcesses(
|
|
@@ -173,20 +198,17 @@ export async function detectOrphanedProcesses(
|
|
|
173
198
|
// Process table scan — discover orphaned processes by scanning the OS
|
|
174
199
|
// process table rather than reading PID files from the workspace.
|
|
175
200
|
try {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
"ps ax -o pid=,ppid=,args= | grep -E 'vellum|qdrant|openclaw' | grep -v grep",
|
|
181
|
-
],
|
|
182
|
-
{ timeoutMs: 5_000 },
|
|
183
|
-
);
|
|
201
|
+
const table = processTableCommand(options.platform ?? process.platform);
|
|
202
|
+
const output = await execOutput(table.command, table.args, {
|
|
203
|
+
timeoutMs: 5_000,
|
|
204
|
+
});
|
|
184
205
|
const procs = parseRemotePs(output);
|
|
185
206
|
const ownPid = String(process.pid);
|
|
186
207
|
|
|
187
208
|
for (const p of procs) {
|
|
188
209
|
if (p.pid === ownPid || seenPids.has(p.pid)) continue;
|
|
189
210
|
if (knownPids.has(p.pid)) continue;
|
|
211
|
+
if (!VELLUM_PROCESS_MARKER.test(p.command)) continue;
|
|
190
212
|
// Live interactive sessions are spared before classification so that
|
|
191
213
|
// service substrings in their argv cannot mark them as orphans.
|
|
192
214
|
if (isInteractiveCliSession(p.command)) {
|
package/src/lib/pgrep.ts
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
import { execOutput } from "./step-runner";
|
|
2
|
+
import { executableName, parseTasklistCsv } from "./process.js";
|
|
2
3
|
|
|
3
4
|
const PGREP_TIMEOUT_MS = 5_000;
|
|
4
5
|
|
|
5
|
-
export async function pgrepExact(
|
|
6
|
+
export async function pgrepExact(
|
|
7
|
+
name: string,
|
|
8
|
+
hostPlatform: NodeJS.Platform = process.platform,
|
|
9
|
+
): Promise<string[]> {
|
|
6
10
|
try {
|
|
7
|
-
const
|
|
11
|
+
const command = hostPlatform === "win32" ? "tasklist.exe" : "pgrep";
|
|
12
|
+
const args =
|
|
13
|
+
hostPlatform === "win32"
|
|
14
|
+
? [
|
|
15
|
+
"/FI",
|
|
16
|
+
`IMAGENAME eq ${executableName(name, hostPlatform)}`,
|
|
17
|
+
"/FO",
|
|
18
|
+
"CSV",
|
|
19
|
+
"/NH",
|
|
20
|
+
]
|
|
21
|
+
: ["-x", name];
|
|
22
|
+
const output = await execOutput(command, args, {
|
|
8
23
|
timeoutMs: PGREP_TIMEOUT_MS,
|
|
9
24
|
});
|
|
25
|
+
if (hostPlatform === "win32") {
|
|
26
|
+
return parseTasklistCsv(output).map(({ pid }) => String(pid));
|
|
27
|
+
}
|
|
10
28
|
return output.trim().split("\n").filter(Boolean);
|
|
11
29
|
} catch {
|
|
12
30
|
return [];
|
package/src/lib/process.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "child_process";
|
|
2
2
|
import { existsSync, readFileSync, unlinkSync } from "fs";
|
|
3
|
+
import { platform } from "os";
|
|
3
4
|
|
|
4
5
|
import {
|
|
5
6
|
httpHealthCheck,
|
|
@@ -8,21 +9,115 @@ import {
|
|
|
8
9
|
waitForDaemonReady,
|
|
9
10
|
} from "./http-client.js";
|
|
10
11
|
|
|
12
|
+
const VELLUM_COMMAND_PATTERN =
|
|
13
|
+
/vellum-daemon|vellum-cli|vellum-gateway|credential-executor|@vellumai|[\\/]\.?vellum[\\/]|[\\/]daemon[\\/]main|[\\/]\.vellum[\\/].*qdrant[\\/]bin[\\/]qdrant/;
|
|
14
|
+
|
|
15
|
+
export const isVellumCommandLine = (command: string): boolean =>
|
|
16
|
+
VELLUM_COMMAND_PATTERN.test(command);
|
|
17
|
+
|
|
11
18
|
/**
|
|
12
19
|
* Verify that a PID belongs to a vellum-related process by inspecting its
|
|
13
20
|
* command line via `ps`. Prevents killing unrelated processes when a PID file
|
|
14
21
|
* is stale and the OS has reused the PID.
|
|
15
22
|
*/
|
|
16
|
-
export function
|
|
23
|
+
export function executableName(
|
|
24
|
+
name: string,
|
|
25
|
+
hostPlatform: NodeJS.Platform = platform(),
|
|
26
|
+
): string {
|
|
27
|
+
return hostPlatform === "win32" && !name.endsWith(".exe")
|
|
28
|
+
? `${name}.exe`
|
|
29
|
+
: name;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function pathListDelimiter(
|
|
33
|
+
hostPlatform: NodeJS.Platform = platform(),
|
|
34
|
+
): string {
|
|
35
|
+
return hostPlatform === "win32" ? ";" : ":";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TasklistProcess {
|
|
39
|
+
imageName: string;
|
|
40
|
+
pid: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseTasklistCsv(output: string): TasklistProcess[] {
|
|
44
|
+
const processes: TasklistProcess[] = [];
|
|
45
|
+
for (const line of output.split(/\r?\n/)) {
|
|
46
|
+
const match = line.match(/^"([^"]+)","(\d+)"/);
|
|
47
|
+
if (match) {
|
|
48
|
+
processes.push({ imageName: match[1], pid: Number(match[2]) });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return processes;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readWindowsProcesses(pid?: number): TasklistProcess[] {
|
|
55
|
+
const args = pid
|
|
56
|
+
? ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"]
|
|
57
|
+
: ["/FO", "CSV", "/NH"];
|
|
58
|
+
const output = execFileSync("tasklist.exe", args, {
|
|
59
|
+
encoding: "utf-8",
|
|
60
|
+
timeout: 5000,
|
|
61
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
62
|
+
});
|
|
63
|
+
return parseTasklistCsv(output);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function windowsCommandLineLookupArgs(pid: number): string[] {
|
|
67
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
|
68
|
+
throw new Error(`Invalid process ID: ${pid}`);
|
|
69
|
+
}
|
|
70
|
+
return [
|
|
71
|
+
"-NoProfile",
|
|
72
|
+
"-NonInteractive",
|
|
73
|
+
"-Command",
|
|
74
|
+
`(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine`,
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readWindowsCommandLine(pid: number): string {
|
|
79
|
+
return execFileSync("powershell.exe", windowsCommandLineLookupArgs(pid), {
|
|
80
|
+
encoding: "utf8",
|
|
81
|
+
timeout: 3000,
|
|
82
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isVellumWindowsProcess(
|
|
87
|
+
imageName: string,
|
|
88
|
+
commandLine = "",
|
|
89
|
+
): boolean {
|
|
90
|
+
if (/^qdrant\.exe$/i.test(imageName)) {
|
|
91
|
+
return isVellumCommandLine(commandLine);
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
/^(?:vellum|vellum-cli|vellum-daemon|vellum-gateway|credential-executor)\.exe$/i.test(
|
|
95
|
+
imageName,
|
|
96
|
+
)
|
|
97
|
+
) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return /^bun\.exe$/i.test(imageName) && isVellumCommandLine(commandLine);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function isVellumProcess(
|
|
104
|
+
pid: number,
|
|
105
|
+
hostPlatform: NodeJS.Platform = platform(),
|
|
106
|
+
): boolean {
|
|
17
107
|
try {
|
|
108
|
+
if (hostPlatform === "win32") {
|
|
109
|
+
const imageName = readWindowsProcesses(pid)[0]?.imageName ?? "";
|
|
110
|
+
const commandLine = /^(?:bun|qdrant)\.exe$/i.test(imageName)
|
|
111
|
+
? readWindowsCommandLine(pid)
|
|
112
|
+
: "";
|
|
113
|
+
return isVellumWindowsProcess(imageName, commandLine);
|
|
114
|
+
}
|
|
18
115
|
const output = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
|
|
19
116
|
encoding: "utf-8",
|
|
20
117
|
timeout: 3000,
|
|
21
118
|
stdio: ["ignore", "pipe", "ignore"],
|
|
22
119
|
}).trim();
|
|
23
|
-
return
|
|
24
|
-
output,
|
|
25
|
-
);
|
|
120
|
+
return isVellumCommandLine(output);
|
|
26
121
|
} catch {
|
|
27
122
|
return false;
|
|
28
123
|
}
|
|
@@ -89,6 +184,7 @@ export async function isProcessHealthy(
|
|
|
89
184
|
* - `"migration_failed"` — process is alive and healthy, but its DB migrations
|
|
90
185
|
* failed: a terminal state that never recovers without a restart. The
|
|
91
186
|
* process is kept alive (same keep-alive rule as `"unready"`).
|
|
187
|
+
* - `"stuck"`: process is unresponsive and could not be terminated.
|
|
92
188
|
* - `"needs_start"` — process was dead, hung (and killed), or a stale PID
|
|
93
189
|
* was cleaned up. Caller should start a fresh process.
|
|
94
190
|
*/
|
|
@@ -96,6 +192,7 @@ export type ProcessState =
|
|
|
96
192
|
| { status: "healthy"; pid: number }
|
|
97
193
|
| { status: "unready"; pid: number }
|
|
98
194
|
| { status: "migration_failed"; pid: number }
|
|
195
|
+
| { status: "stuck"; pid: number }
|
|
99
196
|
| { status: "needs_start"; pid: number | null };
|
|
100
197
|
|
|
101
198
|
/**
|
|
@@ -140,7 +237,10 @@ export async function resolveProcessState(
|
|
|
140
237
|
console.log(
|
|
141
238
|
`${label} process alive (pid ${result.pid}) but not responding — killing and restarting...`,
|
|
142
239
|
);
|
|
143
|
-
await stopProcess(result.pid, label);
|
|
240
|
+
const stopped = await stopProcess(result.pid, label);
|
|
241
|
+
if (!stopped && isProcessAlive(pidFile).alive) {
|
|
242
|
+
return { status: "stuck", pid: result.pid };
|
|
243
|
+
}
|
|
144
244
|
} else {
|
|
145
245
|
console.log(
|
|
146
246
|
`Stale PID file (pid ${result.pid} is not a Vellum process) — cleaning up...`,
|
|
@@ -177,12 +277,20 @@ export async function resolveProcessState(
|
|
|
177
277
|
|
|
178
278
|
/**
|
|
179
279
|
* Stop a process by PID: SIGTERM, wait up to `timeoutMs`, then SIGKILL if still alive.
|
|
180
|
-
* Returns true if the process was stopped, false if it wasn't alive
|
|
280
|
+
* Returns true if the process was stopped, false if it wasn't alive or
|
|
281
|
+
* termination failed.
|
|
181
282
|
*/
|
|
182
283
|
export async function stopProcess(
|
|
183
284
|
pid: number,
|
|
184
285
|
label: string,
|
|
185
286
|
timeoutMs: number = 2000,
|
|
287
|
+
hostPlatform: NodeJS.Platform = platform(),
|
|
288
|
+
runTaskkill: (args: string[], timeout: number) => void = (args, timeout) => {
|
|
289
|
+
execFileSync("taskkill.exe", args, {
|
|
290
|
+
timeout,
|
|
291
|
+
stdio: "ignore",
|
|
292
|
+
});
|
|
293
|
+
},
|
|
186
294
|
): Promise<boolean> {
|
|
187
295
|
try {
|
|
188
296
|
process.kill(pid, 0);
|
|
@@ -191,10 +299,19 @@ export async function stopProcess(
|
|
|
191
299
|
}
|
|
192
300
|
|
|
193
301
|
console.log(`Stopping ${label} (pid ${pid})...`);
|
|
194
|
-
|
|
302
|
+
let waitForGracefulExit = true;
|
|
303
|
+
if (hostPlatform === "win32") {
|
|
304
|
+
try {
|
|
305
|
+
runTaskkill(["/PID", String(pid), "/T"], timeoutMs);
|
|
306
|
+
} catch {
|
|
307
|
+
waitForGracefulExit = false;
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
process.kill(pid, "SIGTERM");
|
|
311
|
+
}
|
|
195
312
|
|
|
196
313
|
const deadline = Date.now() + timeoutMs;
|
|
197
|
-
while (Date.now() < deadline) {
|
|
314
|
+
while (waitForGracefulExit && Date.now() < deadline) {
|
|
198
315
|
try {
|
|
199
316
|
process.kill(pid, 0);
|
|
200
317
|
await new Promise((r) => setTimeout(r, 100));
|
|
@@ -205,13 +322,33 @@ export async function stopProcess(
|
|
|
205
322
|
|
|
206
323
|
try {
|
|
207
324
|
process.kill(pid, 0);
|
|
208
|
-
console.log(`${label} did not exit after SIGTERM, sending SIGKILL...`);
|
|
209
|
-
process.kill(pid, "SIGKILL");
|
|
210
325
|
} catch {
|
|
211
|
-
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
if (hostPlatform === "win32") {
|
|
329
|
+
console.log(`${label} did not exit, terminating its process tree...`);
|
|
330
|
+
try {
|
|
331
|
+
runTaskkill(["/PID", String(pid), "/T", "/F"], timeoutMs);
|
|
332
|
+
} catch {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
process.kill(pid, 0);
|
|
337
|
+
return false;
|
|
338
|
+
} catch {
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
console.log(`${label} did not exit after SIGTERM, sending SIGKILL...`);
|
|
343
|
+
try {
|
|
344
|
+
process.kill(pid, "SIGKILL");
|
|
345
|
+
return true;
|
|
346
|
+
} catch (error) {
|
|
347
|
+
return (
|
|
348
|
+
error instanceof Error &&
|
|
349
|
+
(error as NodeJS.ErrnoException).code === "ESRCH"
|
|
350
|
+
);
|
|
212
351
|
}
|
|
213
|
-
|
|
214
|
-
return true;
|
|
215
352
|
}
|
|
216
353
|
|
|
217
354
|
/** Remove one or more files, ignoring missing-file errors. */
|
|
@@ -235,6 +372,12 @@ export async function stopProcessByPidFile(
|
|
|
235
372
|
label: string,
|
|
236
373
|
extraCleanupFiles?: string[],
|
|
237
374
|
timeoutMs?: number,
|
|
375
|
+
stop: (
|
|
376
|
+
pid: number,
|
|
377
|
+
label: string,
|
|
378
|
+
timeoutMs?: number,
|
|
379
|
+
) => Promise<boolean> = stopProcess,
|
|
380
|
+
ownsProcess: (pid: number) => boolean = isVellumProcess,
|
|
238
381
|
): Promise<boolean> {
|
|
239
382
|
const { alive, pid } = isProcessAlive(pidFile);
|
|
240
383
|
|
|
@@ -246,7 +389,7 @@ export async function stopProcessByPidFile(
|
|
|
246
389
|
// Verify the PID actually belongs to a vellum process before killing.
|
|
247
390
|
// If the PID file is stale and the OS reused the PID, skip the kill
|
|
248
391
|
// and clean up the stale files instead.
|
|
249
|
-
if (!
|
|
392
|
+
if (!ownsProcess(pid)) {
|
|
250
393
|
console.log(
|
|
251
394
|
`PID ${pid} is not a vellum process — cleaning up stale ${label} PID file.`,
|
|
252
395
|
);
|
|
@@ -254,8 +397,10 @@ export async function stopProcessByPidFile(
|
|
|
254
397
|
return false;
|
|
255
398
|
}
|
|
256
399
|
|
|
257
|
-
const stopped = await
|
|
258
|
-
|
|
400
|
+
const stopped = await stop(pid, label, timeoutMs);
|
|
401
|
+
if (stopped || !isProcessAlive(pidFile).alive) {
|
|
402
|
+
removeFiles(pidFile, extraCleanupFiles);
|
|
403
|
+
}
|
|
259
404
|
return stopped;
|
|
260
405
|
}
|
|
261
406
|
|
|
@@ -265,7 +410,33 @@ export async function stopProcessByPidFile(
|
|
|
265
410
|
*
|
|
266
411
|
* Returns true if at least one process was stopped.
|
|
267
412
|
*/
|
|
268
|
-
export async function stopOrphanedDaemonProcesses(
|
|
413
|
+
export async function stopOrphanedDaemonProcesses(
|
|
414
|
+
excludePids: ReadonlySet<string> = new Set(),
|
|
415
|
+
hostPlatform: NodeJS.Platform = platform(),
|
|
416
|
+
): Promise<boolean> {
|
|
417
|
+
if (hostPlatform === "win32") {
|
|
418
|
+
try {
|
|
419
|
+
const results = await Promise.all(
|
|
420
|
+
readWindowsProcesses()
|
|
421
|
+
.filter(
|
|
422
|
+
({ imageName, pid }) =>
|
|
423
|
+
pid !== process.pid &&
|
|
424
|
+
!excludePids.has(String(pid)) &&
|
|
425
|
+
(/^vellum-daemon\.exe$/i.test(imageName) ||
|
|
426
|
+
(/^bun\.exe$/i.test(imageName) &&
|
|
427
|
+
/vellum-daemon|[\\/]assistant[\\/]src[\\/](?:index|daemon[\\/]main)\.ts/i.test(
|
|
428
|
+
readWindowsCommandLine(pid),
|
|
429
|
+
))),
|
|
430
|
+
)
|
|
431
|
+
.map(({ pid }) =>
|
|
432
|
+
stopProcess(pid, "orphaned assistant", 2000, hostPlatform),
|
|
433
|
+
),
|
|
434
|
+
);
|
|
435
|
+
return results.some(Boolean);
|
|
436
|
+
} catch {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
269
440
|
let output: string;
|
|
270
441
|
try {
|
|
271
442
|
output = execFileSync("ps", ["-axww", "-o", "pid=,command="], {
|
|
@@ -284,7 +455,9 @@ export async function stopOrphanedDaemonProcesses(): Promise<boolean> {
|
|
|
284
455
|
const spaceIdx = trimmed.indexOf(" ");
|
|
285
456
|
if (spaceIdx === -1) continue;
|
|
286
457
|
const pid = parseInt(trimmed.slice(0, spaceIdx), 10);
|
|
287
|
-
if (isNaN(pid) || pid === process.pid)
|
|
458
|
+
if (isNaN(pid) || pid === process.pid || excludePids.has(String(pid))) {
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
288
461
|
const cmd = trimmed.slice(spaceIdx + 1);
|
|
289
462
|
|
|
290
463
|
if (cmd.includes("vellum-daemon")) {
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { mkdirSync } from "fs";
|
|
2
2
|
import { homedir } from "os";
|
|
3
|
-
import {
|
|
3
|
+
import { join, posix, win32 } from "path";
|
|
4
|
+
|
|
5
|
+
function isPathInside(
|
|
6
|
+
parent: string,
|
|
7
|
+
candidate: string,
|
|
8
|
+
pathApi: typeof posix,
|
|
9
|
+
): boolean {
|
|
10
|
+
const relativePath = pathApi.relative(parent, candidate);
|
|
11
|
+
return (
|
|
12
|
+
relativePath !== "" &&
|
|
13
|
+
relativePath !== ".." &&
|
|
14
|
+
!relativePath.startsWith(`..${pathApi.sep}`) &&
|
|
15
|
+
!pathApi.isAbsolute(relativePath)
|
|
16
|
+
);
|
|
17
|
+
}
|
|
4
18
|
|
|
5
19
|
export function getRetiredDir(): string {
|
|
6
20
|
const xdgData =
|
|
@@ -23,21 +37,37 @@ export function validateAssistantName(name: string): void {
|
|
|
23
37
|
}
|
|
24
38
|
}
|
|
25
39
|
|
|
26
|
-
function safeName(
|
|
40
|
+
function safeName(
|
|
41
|
+
assistantId: string,
|
|
42
|
+
retiredDir: string,
|
|
43
|
+
pathApi: typeof posix,
|
|
44
|
+
): string {
|
|
27
45
|
validateAssistantName(assistantId);
|
|
28
46
|
// Canonicalize and verify the result stays inside the retired directory
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
if (!candidate.startsWith(retiredDir + "/")) {
|
|
47
|
+
const candidate = pathApi.resolve(retiredDir, pathApi.basename(assistantId));
|
|
48
|
+
if (!isPathInside(retiredDir, candidate, pathApi)) {
|
|
32
49
|
throw new Error(`Invalid assistant name: '${assistantId}'`);
|
|
33
50
|
}
|
|
34
|
-
return basename(assistantId);
|
|
51
|
+
return pathApi.basename(assistantId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveRetiredFilePath(
|
|
55
|
+
assistantId: string,
|
|
56
|
+
extension: "tar.gz" | "json",
|
|
57
|
+
retiredDir: string,
|
|
58
|
+
hostPlatform: NodeJS.Platform = process.platform,
|
|
59
|
+
): string {
|
|
60
|
+
const pathApi = hostPlatform === "win32" ? win32 : posix;
|
|
61
|
+
return pathApi.join(
|
|
62
|
+
retiredDir,
|
|
63
|
+
`${safeName(assistantId, retiredDir, pathApi)}.${extension}`,
|
|
64
|
+
);
|
|
35
65
|
}
|
|
36
66
|
|
|
37
67
|
export function getArchivePath(assistantId: string): string {
|
|
38
|
-
return
|
|
68
|
+
return resolveRetiredFilePath(assistantId, "tar.gz", getRetiredDir());
|
|
39
69
|
}
|
|
40
70
|
|
|
41
71
|
export function getMetadataPath(assistantId: string): string {
|
|
42
|
-
return
|
|
72
|
+
return resolveRetiredFilePath(assistantId, "json", getRetiredDir());
|
|
43
73
|
}
|
package/src/lib/retire-local.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { existsSync, mkdirSync, renameSync, writeFileSync } from "fs";
|
|
4
|
-
import { basename, dirname, join } from "path";
|
|
4
|
+
import { basename, dirname, join, win32 } from "path";
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
getDaemonPidPath,
|
|
8
|
+
loadAllAssistants,
|
|
9
|
+
loadAllAssistantsAcrossEnvs,
|
|
10
|
+
} from "./assistant-config.js";
|
|
7
11
|
import type { AssistantEntry } from "./assistant-config.js";
|
|
8
12
|
import { stopIngressNginx } from "./nginx-ingress.js";
|
|
13
|
+
import { getKnownPidsFromAssistants } from "./orphan-detection.js";
|
|
9
14
|
import {
|
|
10
15
|
stopOrphanedDaemonProcesses,
|
|
11
16
|
stopProcessByPidFile,
|
|
@@ -26,6 +31,61 @@ export interface RetireLocalResult {
|
|
|
26
31
|
sharedDataDir?: boolean;
|
|
27
32
|
}
|
|
28
33
|
|
|
34
|
+
interface RetireArchiveCommand {
|
|
35
|
+
command: string;
|
|
36
|
+
args: string[];
|
|
37
|
+
env?: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const WINDOWS_RETIRE_ARCHIVE_SCRIPT = [
|
|
41
|
+
"$ErrorActionPreference = 'Stop'",
|
|
42
|
+
"& tar.exe -czf $env:VELLUM_RETIRE_ARCHIVE_PATH -C $env:VELLUM_RETIRE_ARCHIVE_PARENT $env:VELLUM_RETIRE_STAGING_NAME",
|
|
43
|
+
"if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }",
|
|
44
|
+
"Remove-Item -LiteralPath $env:VELLUM_RETIRE_STAGING_DIR -Recurse -Force",
|
|
45
|
+
].join("; ");
|
|
46
|
+
|
|
47
|
+
export function getRetireArchiveCommand(
|
|
48
|
+
archivePath: string,
|
|
49
|
+
stagingDir: string,
|
|
50
|
+
hostPlatform: NodeJS.Platform = process.platform,
|
|
51
|
+
): RetireArchiveCommand {
|
|
52
|
+
const archiveParent =
|
|
53
|
+
hostPlatform === "win32" ? win32.dirname(stagingDir) : dirname(stagingDir);
|
|
54
|
+
const stagingName =
|
|
55
|
+
hostPlatform === "win32"
|
|
56
|
+
? win32.basename(stagingDir)
|
|
57
|
+
: basename(stagingDir);
|
|
58
|
+
if (hostPlatform === "win32") {
|
|
59
|
+
return {
|
|
60
|
+
command: "powershell.exe",
|
|
61
|
+
args: [
|
|
62
|
+
"-NoProfile",
|
|
63
|
+
"-NonInteractive",
|
|
64
|
+
"-Command",
|
|
65
|
+
WINDOWS_RETIRE_ARCHIVE_SCRIPT,
|
|
66
|
+
],
|
|
67
|
+
env: {
|
|
68
|
+
VELLUM_RETIRE_ARCHIVE_PATH: archivePath,
|
|
69
|
+
VELLUM_RETIRE_ARCHIVE_PARENT: archiveParent,
|
|
70
|
+
VELLUM_RETIRE_STAGING_NAME: stagingName,
|
|
71
|
+
VELLUM_RETIRE_STAGING_DIR: stagingDir,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
command: "sh",
|
|
77
|
+
args: [
|
|
78
|
+
"-c",
|
|
79
|
+
'tar czf "$1" -C "$2" "$3" && rm -rf "$4"',
|
|
80
|
+
"vellum-retire",
|
|
81
|
+
archivePath,
|
|
82
|
+
archiveParent,
|
|
83
|
+
stagingName,
|
|
84
|
+
stagingDir,
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
29
89
|
export async function retireLocal(
|
|
30
90
|
name: string,
|
|
31
91
|
entry: AssistantEntry,
|
|
@@ -99,7 +159,14 @@ export async function retireLocal(
|
|
|
99
159
|
// If the PID file didn't track a running daemon, scan for orphaned
|
|
100
160
|
// daemon processes that may have been started without writing a PID.
|
|
101
161
|
if (!daemonStopped) {
|
|
102
|
-
|
|
162
|
+
const otherAssistantPids = getKnownPidsFromAssistants(
|
|
163
|
+
[...loadAllAssistantsAcrossEnvs(), ...loadAllAssistants()].filter(
|
|
164
|
+
(other) =>
|
|
165
|
+
other.assistantId !== name ||
|
|
166
|
+
other.resources?.instanceDir !== resources.instanceDir,
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
await stopOrphanedDaemonProcesses(otherAssistantPids);
|
|
103
170
|
}
|
|
104
171
|
|
|
105
172
|
// For named instances (instanceDir differs from the base directory),
|
|
@@ -140,14 +207,13 @@ export async function retireLocal(
|
|
|
140
207
|
|
|
141
208
|
// Spawn tar + cleanup in the background and detach so the CLI can exit
|
|
142
209
|
// immediately. The staging directory is removed once the archive is written.
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
`rm -rf ${JSON.stringify(stagingDir)}`,
|
|
146
|
-
].join(" && ");
|
|
147
|
-
|
|
148
|
-
const child = spawn("sh", ["-c", tarCmd], {
|
|
210
|
+
const archiveCommand = getRetireArchiveCommand(archivePath, stagingDir);
|
|
211
|
+
const child = spawn(archiveCommand.command, archiveCommand.args, {
|
|
149
212
|
stdio: "ignore",
|
|
150
213
|
detached: true,
|
|
214
|
+
...(archiveCommand.env
|
|
215
|
+
? { env: { ...process.env, ...archiveCommand.env } }
|
|
216
|
+
: {}),
|
|
151
217
|
});
|
|
152
218
|
child.unref();
|
|
153
219
|
|
package/src/lib/tunnel-edge.ts
CHANGED
|
@@ -10,17 +10,6 @@ import {
|
|
|
10
10
|
} from "./nginx-ingress.js";
|
|
11
11
|
import { hasWebhookIntegrations, maybeStartNgrokTunnel } from "./ngrok.js";
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
* Retry policy for the flag lookup inside the tunnel-edge restore. The gateway
|
|
15
|
-
* has typically been up for milliseconds at this point and answers
|
|
16
|
-
* `503 {"status":"starting"}` (or refuses connections) until its startup
|
|
17
|
-
* completes, so a single lookup races it.
|
|
18
|
-
*/
|
|
19
|
-
export const WEB_INGRESS_FLAG_RETRY = {
|
|
20
|
-
attempts: 15,
|
|
21
|
-
intervalMs: 2_000,
|
|
22
|
-
};
|
|
23
|
-
|
|
24
13
|
/**
|
|
25
14
|
* Whether the workspace ingress config wants the remote-web edge: explicitly
|
|
26
15
|
* enabled with a saved public URL.
|
|
@@ -53,14 +42,14 @@ function wantsTunnelEdge(workspaceDir: string): boolean {
|
|
|
53
42
|
* Bring the nginx edge back up after a wake or local upgrade and point the
|
|
54
43
|
* webhook auto-tunnel at it. The edge is wanted when webhook integrations are
|
|
55
44
|
* configured or the workspace ingress config is enabled with a saved public
|
|
56
|
-
* URL
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
45
|
+
* URL. A healthy SPA edge whose recorded state already targets the requested
|
|
46
|
+
* gateway port is reused without the `remoteWebConfigHash` comparison
|
|
47
|
+
* `startRemoteWebIngress` performs; injected-config drift (a renamed
|
|
48
|
+
* assistant, a changed hub URL) is repaired by the next explicit
|
|
49
|
+
* `vellum tunnel` or `vellum nginx-ingress up`, not by background wakes.
|
|
50
|
+
* A recorded webhooks-only edge is never reused: it goes through
|
|
51
|
+
* `ensureTunnelEdge` so the wake upgrades it to the SPA edge.
|
|
52
|
+
* Edge failures warn
|
|
64
53
|
* (with the error's install or diagnostic text) and fall back to tunneling the
|
|
65
54
|
* gateway port directly, which `maybeStartNgrokTunnel` only does when webhook
|
|
66
55
|
* integrations are configured, so webhook channels on nginx-less machines
|
|
@@ -79,11 +68,15 @@ export async function restoreTunnelEdgeAndAutoTunnel(
|
|
|
79
68
|
? readIngressState(workspaceDir)
|
|
80
69
|
: null;
|
|
81
70
|
let edge: TunnelEdge | null = null;
|
|
82
|
-
if (
|
|
71
|
+
if (
|
|
72
|
+
recorded !== null &&
|
|
73
|
+
recorded.gatewayPort === gatewayPort &&
|
|
74
|
+
recorded.includeWebApp
|
|
75
|
+
) {
|
|
83
76
|
edge = {
|
|
84
77
|
port: recorded.listenPort,
|
|
85
78
|
started: false,
|
|
86
|
-
includesWebApp:
|
|
79
|
+
includesWebApp: true,
|
|
87
80
|
};
|
|
88
81
|
} else {
|
|
89
82
|
try {
|
|
@@ -91,7 +84,6 @@ export async function restoreTunnelEdgeAndAutoTunnel(
|
|
|
91
84
|
assistantId,
|
|
92
85
|
workspaceDir,
|
|
93
86
|
gatewayPort,
|
|
94
|
-
flagRetry: WEB_INGRESS_FLAG_RETRY,
|
|
95
87
|
});
|
|
96
88
|
} catch (err) {
|
|
97
89
|
console.warn(
|