@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,7 +14,6 @@ import { join } from "node:path";
|
|
|
14
14
|
|
|
15
15
|
import * as assistantConfig from "../lib/assistant-config.js";
|
|
16
16
|
import * as docker from "../lib/docker.js";
|
|
17
|
-
import * as featureFlags from "../lib/feature-flags.js";
|
|
18
17
|
import * as guardianToken from "../lib/guardian-token.js";
|
|
19
18
|
import * as ingressConfig from "../lib/ingress-config.js";
|
|
20
19
|
import * as local from "../lib/local.js";
|
|
@@ -141,19 +140,9 @@ mock.module("../lib/ngrok", () => ({
|
|
|
141
140
|
maybeStartNgrokTunnel: maybeStartNgrokTunnelMock,
|
|
142
141
|
}));
|
|
143
142
|
|
|
144
|
-
const realFeatureFlags = { ...featureFlags };
|
|
145
143
|
const realIngressConfig = { ...ingressConfig };
|
|
146
144
|
const realNginxIngress = { ...nginxIngress };
|
|
147
145
|
|
|
148
|
-
const isAssistantFeatureFlagEnabledMock = mock<
|
|
149
|
-
typeof featureFlags.isAssistantFeatureFlagEnabled
|
|
150
|
-
>(async () => true);
|
|
151
|
-
|
|
152
|
-
mock.module("../lib/feature-flags.js", () => ({
|
|
153
|
-
...realFeatureFlags,
|
|
154
|
-
isAssistantFeatureFlagEnabled: isAssistantFeatureFlagEnabledMock,
|
|
155
|
-
}));
|
|
156
|
-
|
|
157
146
|
const loadRawConfigMock = mock<typeof ingressConfig.loadRawConfig>(() => ({}));
|
|
158
147
|
|
|
159
148
|
mock.module("../lib/ingress-config.js", () => ({
|
|
@@ -179,12 +168,12 @@ mock.module("../lib/nginx-ingress.js", () => ({
|
|
|
179
168
|
}));
|
|
180
169
|
|
|
181
170
|
const { wake } = await import("../commands/wake.js");
|
|
182
|
-
const { WEB_INGRESS_FLAG_RETRY } = await import("../lib/tunnel-edge.js");
|
|
183
171
|
|
|
184
172
|
let tempDir: string;
|
|
185
173
|
let originalArgv: string[];
|
|
186
174
|
let logSpy: ReturnType<typeof spyOn>;
|
|
187
175
|
let warnSpy: ReturnType<typeof spyOn>;
|
|
176
|
+
let localEntry: AssistantEntry;
|
|
188
177
|
|
|
189
178
|
function makeLocalEntry(): AssistantEntry {
|
|
190
179
|
tempDir = mkdtempSync(join(tmpdir(), "vellum-wake-test-"));
|
|
@@ -211,9 +200,9 @@ beforeEach(() => {
|
|
|
211
200
|
logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
212
201
|
warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
213
202
|
|
|
214
|
-
|
|
203
|
+
localEntry = makeLocalEntry();
|
|
215
204
|
resolveTargetAssistantMock.mockReset();
|
|
216
|
-
resolveTargetAssistantMock.mockReturnValue(
|
|
205
|
+
resolveTargetAssistantMock.mockReturnValue(localEntry);
|
|
217
206
|
saveAssistantEntryMock.mockReset();
|
|
218
207
|
getDaemonPidPathMock.mockReset();
|
|
219
208
|
getDaemonPidPathMock.mockImplementation((resources) =>
|
|
@@ -256,8 +245,6 @@ beforeEach(() => {
|
|
|
256
245
|
);
|
|
257
246
|
maybeStartNgrokTunnelMock.mockReset();
|
|
258
247
|
maybeStartNgrokTunnelMock.mockResolvedValue(null);
|
|
259
|
-
isAssistantFeatureFlagEnabledMock.mockReset();
|
|
260
|
-
isAssistantFeatureFlagEnabledMock.mockResolvedValue(true);
|
|
261
248
|
loadRawConfigMock.mockReset();
|
|
262
249
|
loadRawConfigMock.mockReturnValue({});
|
|
263
250
|
ensureTunnelEdgeMock.mockReset();
|
|
@@ -288,7 +275,6 @@ afterAll(() => {
|
|
|
288
275
|
mock.module("../lib/process", () => realProcessLib);
|
|
289
276
|
mock.module("../lib/local", () => realLocal);
|
|
290
277
|
mock.module("../lib/ngrok", () => realNgrok);
|
|
291
|
-
mock.module("../lib/feature-flags.js", () => realFeatureFlags);
|
|
292
278
|
mock.module("../lib/ingress-config.js", () => realIngressConfig);
|
|
293
279
|
mock.module("../lib/nginx-ingress.js", () => realNginxIngress);
|
|
294
280
|
});
|
|
@@ -402,8 +388,73 @@ describe("vellum wake", () => {
|
|
|
402
388
|
// startLocalDaemon should NOT be called (daemon already running).
|
|
403
389
|
expect(startLocalDaemonMock).not.toHaveBeenCalled();
|
|
404
390
|
});
|
|
405
|
-
});
|
|
406
391
|
|
|
392
|
+
test("does not duplicate an assistant already classified as stuck", async () => {
|
|
393
|
+
localEntry.guardianBootstrapSecret = "existing-bootstrap-secret";
|
|
394
|
+
resolveProcessStateMock.mockImplementation(
|
|
395
|
+
async (_pidFile, _port, label) =>
|
|
396
|
+
label === "Assistant"
|
|
397
|
+
? { status: "stuck", pid: 123 }
|
|
398
|
+
: { status: "healthy", pid: 456 },
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
await wake();
|
|
402
|
+
|
|
403
|
+
expect(startLocalDaemonMock).not.toHaveBeenCalled();
|
|
404
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
405
|
+
expect.stringContaining("could not be stopped"),
|
|
406
|
+
);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("does not duplicate an assistant when a watch restart cannot stop it", async () => {
|
|
410
|
+
localEntry.guardianBootstrapSecret = "existing-bootstrap-secret";
|
|
411
|
+
isAssistantWatchModeAvailableMock.mockReturnValue(true);
|
|
412
|
+
stopProcessByPidFileMock.mockResolvedValue(false);
|
|
413
|
+
isProcessAliveMock.mockReturnValue({ alive: true, pid: 123 });
|
|
414
|
+
|
|
415
|
+
await wake();
|
|
416
|
+
|
|
417
|
+
expect(startLocalDaemonMock).not.toHaveBeenCalled();
|
|
418
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
419
|
+
expect.stringContaining("could not be stopped for watch mode"),
|
|
420
|
+
);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("does not duplicate a gateway already classified as stuck", async () => {
|
|
424
|
+
localEntry.guardianBootstrapSecret = "existing-bootstrap-secret";
|
|
425
|
+
resolveProcessStateMock.mockImplementation(
|
|
426
|
+
async (_pidFile, _port, label) =>
|
|
427
|
+
label === "Gateway"
|
|
428
|
+
? { status: "stuck", pid: 456 }
|
|
429
|
+
: { status: "healthy", pid: 123 },
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
await wake();
|
|
433
|
+
|
|
434
|
+
expect(startGatewayMock).not.toHaveBeenCalled();
|
|
435
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
436
|
+
expect.stringContaining("Gateway running"),
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("does not duplicate a gateway when a watch restart cannot stop it", async () => {
|
|
441
|
+
localEntry.guardianBootstrapSecret = "existing-bootstrap-secret";
|
|
442
|
+
isGatewayWatchModeAvailableMock.mockReturnValue(true);
|
|
443
|
+
stopProcessByPidFileMock.mockResolvedValue(false);
|
|
444
|
+
isProcessAliveMock.mockImplementation((pidFile) =>
|
|
445
|
+
pidFile.endsWith("gateway.pid")
|
|
446
|
+
? { alive: true, pid: 456 }
|
|
447
|
+
: { alive: false, pid: null },
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
await wake();
|
|
451
|
+
|
|
452
|
+
expect(startGatewayMock).not.toHaveBeenCalled();
|
|
453
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
454
|
+
expect.stringContaining("Gateway running"),
|
|
455
|
+
);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
407
458
|
|
|
408
459
|
describe("vellum wake — tunnel edge restore", () => {
|
|
409
460
|
const webhookConfig = { telegram: { botUsername: "bot" } };
|
|
@@ -416,7 +467,7 @@ describe("vellum wake — tunnel edge restore", () => {
|
|
|
416
467
|
process.argv = ["bun", "vellum", "wake", "local-assistant"];
|
|
417
468
|
});
|
|
418
469
|
|
|
419
|
-
test("webhook-configured wake ensures the edge
|
|
470
|
+
test("webhook-configured wake ensures the edge and tunnels the edge port", async () => {
|
|
420
471
|
loadRawConfigMock.mockReturnValue(webhookConfig);
|
|
421
472
|
|
|
422
473
|
await wake();
|
|
@@ -425,7 +476,6 @@ describe("vellum wake — tunnel edge restore", () => {
|
|
|
425
476
|
assistantId: "local-assistant",
|
|
426
477
|
workspaceDir: workspaceDirOf(tempDir),
|
|
427
478
|
gatewayPort: 7830,
|
|
428
|
-
flagRetry: WEB_INGRESS_FLAG_RETRY,
|
|
429
479
|
});
|
|
430
480
|
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
431
481
|
7840,
|
|
@@ -443,7 +493,6 @@ describe("vellum wake — tunnel edge restore", () => {
|
|
|
443
493
|
assistantId: "local-assistant",
|
|
444
494
|
workspaceDir: workspaceDirOf(tempDir),
|
|
445
495
|
gatewayPort: 7830,
|
|
446
|
-
flagRetry: WEB_INGRESS_FLAG_RETRY,
|
|
447
496
|
});
|
|
448
497
|
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
449
498
|
7840,
|
|
@@ -455,7 +504,6 @@ describe("vellum wake — tunnel edge restore", () => {
|
|
|
455
504
|
test("skips the edge and tunnels the gateway port when nothing wants an edge", async () => {
|
|
456
505
|
await wake();
|
|
457
506
|
|
|
458
|
-
expect(isAssistantFeatureFlagEnabledMock).not.toHaveBeenCalled();
|
|
459
507
|
expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
|
|
460
508
|
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
461
509
|
7830,
|
|
@@ -483,7 +531,7 @@ describe("vellum wake — tunnel edge restore", () => {
|
|
|
483
531
|
expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
|
|
484
532
|
});
|
|
485
533
|
|
|
486
|
-
test("a running edge recorded against this gateway port is reused
|
|
534
|
+
test("a running edge recorded against this gateway port is reused without ensureTunnelEdge", async () => {
|
|
487
535
|
loadRawConfigMock.mockReturnValue(webhookConfig);
|
|
488
536
|
isIngressRunningMock.mockReturnValue(true);
|
|
489
537
|
readIngressStateMock.mockReturnValue({
|
|
@@ -494,7 +542,6 @@ describe("vellum wake — tunnel edge restore", () => {
|
|
|
494
542
|
|
|
495
543
|
await wake();
|
|
496
544
|
|
|
497
|
-
expect(isAssistantFeatureFlagEnabledMock).not.toHaveBeenCalled();
|
|
498
545
|
expect(ensureTunnelEdgeMock).not.toHaveBeenCalled();
|
|
499
546
|
expect(logSpy).toHaveBeenCalledWith(
|
|
500
547
|
" Tunnel edge already running on 127.0.0.1:7845 (remote web + webhooks).",
|
|
@@ -505,6 +552,26 @@ describe("vellum wake — tunnel edge restore", () => {
|
|
|
505
552
|
);
|
|
506
553
|
});
|
|
507
554
|
|
|
555
|
+
test("a running webhooks-only edge goes through ensureTunnelEdge to upgrade to the SPA edge", async () => {
|
|
556
|
+
loadRawConfigMock.mockReturnValue(webhookConfig);
|
|
557
|
+
isIngressRunningMock.mockReturnValue(true);
|
|
558
|
+
readIngressStateMock.mockReturnValue({
|
|
559
|
+
listenPort: 7845,
|
|
560
|
+
includeWebApp: false,
|
|
561
|
+
gatewayPort: 7830,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
await wake();
|
|
565
|
+
|
|
566
|
+
expect(ensureTunnelEdgeMock).toHaveBeenCalledWith(
|
|
567
|
+
expect.objectContaining({ gatewayPort: 7830 }),
|
|
568
|
+
);
|
|
569
|
+
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
570
|
+
7840,
|
|
571
|
+
workspaceDirOf(tempDir),
|
|
572
|
+
);
|
|
573
|
+
});
|
|
574
|
+
|
|
508
575
|
test("a running edge recorded against a different gateway port goes through ensureTunnelEdge", async () => {
|
|
509
576
|
loadRawConfigMock.mockReturnValue(webhookConfig);
|
|
510
577
|
isIngressRunningMock.mockReturnValue(true);
|
|
@@ -563,28 +630,6 @@ describe("vellum wake — tunnel edge restore", () => {
|
|
|
563
630
|
expect(logSpy).toHaveBeenCalledWith("Wake complete.");
|
|
564
631
|
});
|
|
565
632
|
|
|
566
|
-
test("flag-off + webhooks-on produces a webhooks-only edge fronting the tunnel", async () => {
|
|
567
|
-
loadRawConfigMock.mockReturnValue(webhookConfig);
|
|
568
|
-
ensureTunnelEdgeMock.mockResolvedValue({
|
|
569
|
-
port: 7840,
|
|
570
|
-
started: true,
|
|
571
|
-
includesWebApp: false,
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
await wake();
|
|
575
|
-
|
|
576
|
-
expect(ensureTunnelEdgeMock).toHaveBeenCalledWith(
|
|
577
|
-
expect.objectContaining({ assistantId: "local-assistant" }),
|
|
578
|
-
);
|
|
579
|
-
expect(logSpy).toHaveBeenCalledWith(
|
|
580
|
-
expect.stringContaining("webhooks only"),
|
|
581
|
-
);
|
|
582
|
-
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
583
|
-
7840,
|
|
584
|
-
workspaceDirOf(tempDir),
|
|
585
|
-
);
|
|
586
|
-
});
|
|
587
|
-
|
|
588
633
|
test("a reused edge still points the tunnel at its listen port", async () => {
|
|
589
634
|
loadRawConfigMock.mockReturnValue(enabledConfig);
|
|
590
635
|
ensureTunnelEdgeMock.mockResolvedValue({
|
|
@@ -600,27 +645,4 @@ describe("vellum wake — tunnel edge restore", () => {
|
|
|
600
645
|
workspaceDirOf(tempDir),
|
|
601
646
|
);
|
|
602
647
|
});
|
|
603
|
-
|
|
604
|
-
test("an exhausted flag lookup warns and falls back to the gateway-port tunnel", async () => {
|
|
605
|
-
loadRawConfigMock.mockReturnValue(webhookConfig);
|
|
606
|
-
ensureTunnelEdgeMock.mockRejectedValue(
|
|
607
|
-
new Error(
|
|
608
|
-
"Could not verify the `web-remote-ingress` feature flag before starting the edge. Is the assistant running? Try `vellum wake` and retry. gateway unreachable",
|
|
609
|
-
),
|
|
610
|
-
);
|
|
611
|
-
|
|
612
|
-
await wake();
|
|
613
|
-
|
|
614
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
615
|
-
expect.stringContaining("web-remote-ingress"),
|
|
616
|
-
);
|
|
617
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
618
|
-
expect.stringContaining("vellum nginx-ingress up"),
|
|
619
|
-
);
|
|
620
|
-
expect(maybeStartNgrokTunnelMock).toHaveBeenCalledWith(
|
|
621
|
-
7830,
|
|
622
|
-
workspaceDirOf(tempDir),
|
|
623
|
-
);
|
|
624
|
-
expect(logSpy).toHaveBeenCalledWith("Wake complete.");
|
|
625
|
-
});
|
|
626
648
|
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
executableName,
|
|
8
|
+
isProcessAlive,
|
|
9
|
+
isVellumCommandLine,
|
|
10
|
+
isVellumWindowsProcess,
|
|
11
|
+
pathListDelimiter,
|
|
12
|
+
parseTasklistCsv,
|
|
13
|
+
stopProcess,
|
|
14
|
+
stopProcessByPidFile,
|
|
15
|
+
windowsCommandLineLookupArgs,
|
|
16
|
+
} from "../lib/process";
|
|
17
|
+
import {
|
|
18
|
+
isInteractiveCliSession,
|
|
19
|
+
parseRemotePs,
|
|
20
|
+
processTableCommand,
|
|
21
|
+
} from "../lib/orphan-detection";
|
|
22
|
+
|
|
23
|
+
test("uses Windows executable suffixes without changing Unix names", () => {
|
|
24
|
+
expect(executableName("vellum-daemon", "win32")).toBe("vellum-daemon.exe");
|
|
25
|
+
expect(executableName("vellum.exe", "win32")).toBe("vellum.exe");
|
|
26
|
+
expect(executableName("vellum-daemon", "darwin")).toBe("vellum-daemon");
|
|
27
|
+
expect(pathListDelimiter("win32")).toBe(";");
|
|
28
|
+
expect(pathListDelimiter("linux")).toBe(":");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("parses tasklist rows and ignores informational output", () => {
|
|
32
|
+
const output =
|
|
33
|
+
'"vellum-daemon.exe","4812","Console","1","20,000 K"\r\nINFO: No tasks match.';
|
|
34
|
+
expect(parseTasklistCsv(output)).toEqual([
|
|
35
|
+
{ imageName: "vellum-daemon.exe", pid: 4812 },
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("passes the PID inside the PowerShell command", () => {
|
|
40
|
+
const args = windowsCommandLineLookupArgs(4812);
|
|
41
|
+
expect(args).toHaveLength(4);
|
|
42
|
+
expect(args[3]).toContain("ProcessId = 4812");
|
|
43
|
+
expect(args[3]).not.toContain("$args");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("selects a direct process-table command with fixed arguments", () => {
|
|
47
|
+
const windows = processTableCommand("win32");
|
|
48
|
+
expect(windows.command).toBe("powershell.exe");
|
|
49
|
+
expect(windows.args.join(" ")).toContain("Win32_Process");
|
|
50
|
+
expect(processTableCommand("linux")).toEqual({
|
|
51
|
+
command: "ps",
|
|
52
|
+
args: ["ax", "-o", "pid=,ppid=,args="],
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("preserves command lines containing spaces and Unicode usernames", () => {
|
|
57
|
+
const [parsed] = parseRemotePs(
|
|
58
|
+
"42 1 C:\\Users\\Example User 用户\\Vellum\\vellum-daemon.exe --port 3030",
|
|
59
|
+
);
|
|
60
|
+
expect(parsed.command).toContain(
|
|
61
|
+
"Example User 用户\\Vellum\\vellum-daemon.exe",
|
|
62
|
+
);
|
|
63
|
+
expect(
|
|
64
|
+
isInteractiveCliSession(
|
|
65
|
+
'"C:\\Users\\Example User 用户\\Vellum\\vellum.exe" exec -it --service vellum-gateway',
|
|
66
|
+
),
|
|
67
|
+
).toBeTrue();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("rejects a stale PID file", () => {
|
|
71
|
+
const dir = mkdtempSync(path.join(tmpdir(), "vellum stale pid "));
|
|
72
|
+
const pidFile = path.join(dir, "assistant.pid");
|
|
73
|
+
writeFileSync(pidFile, "2147483647\n", "utf8");
|
|
74
|
+
expect(isProcessAlive(pidFile)).toEqual({ alive: false, pid: null });
|
|
75
|
+
rmSync(dir, { recursive: true, force: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("recognizes only Vellum Bun-hosted process command lines", () => {
|
|
79
|
+
expect(
|
|
80
|
+
isVellumCommandLine(
|
|
81
|
+
"bun.exe C:\\Users\\Example User 用户\\.vellum\\runtime\\assistant\\src\\daemon\\main.ts",
|
|
82
|
+
),
|
|
83
|
+
).toBeTrue();
|
|
84
|
+
expect(
|
|
85
|
+
isVellumCommandLine("bun.exe C:\\work\\unrelated\\server.ts"),
|
|
86
|
+
).toBeFalse();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("verifies Windows Qdrant ownership from its command line", () => {
|
|
90
|
+
expect(
|
|
91
|
+
isVellumWindowsProcess(
|
|
92
|
+
"qdrant.exe",
|
|
93
|
+
"C:\\Users\\Example User\\.vellum\\workspace\\data\\qdrant\\bin\\qdrant.exe",
|
|
94
|
+
),
|
|
95
|
+
).toBeTrue();
|
|
96
|
+
expect(
|
|
97
|
+
isVellumWindowsProcess("qdrant.exe", "C:\\Databases\\qdrant.exe"),
|
|
98
|
+
).toBeFalse();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("reports a failed forced Windows process-tree termination", async () => {
|
|
102
|
+
const calls: string[][] = [];
|
|
103
|
+
const stopped = await stopProcess(
|
|
104
|
+
process.pid,
|
|
105
|
+
"test process",
|
|
106
|
+
0,
|
|
107
|
+
"win32",
|
|
108
|
+
(args) => {
|
|
109
|
+
calls.push(args);
|
|
110
|
+
throw new Error("access denied");
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(stopped).toBeFalse();
|
|
115
|
+
expect(calls).toEqual([
|
|
116
|
+
["/PID", String(process.pid), "/T"],
|
|
117
|
+
["/PID", String(process.pid), "/T", "/F"],
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("treats a Unix exit before SIGKILL as a successful stop", async () => {
|
|
122
|
+
const signals: Array<NodeJS.Signals | number | undefined> = [];
|
|
123
|
+
const originalKill = process.kill.bind(process);
|
|
124
|
+
process.kill = ((_pid: number, signal?: NodeJS.Signals | number) => {
|
|
125
|
+
signals.push(signal);
|
|
126
|
+
if (signal === "SIGKILL") {
|
|
127
|
+
throw Object.assign(new Error("no such process"), { code: "ESRCH" });
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}) as typeof process.kill;
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
expect(await stopProcess(4812, "test process", 0, "darwin")).toBeTrue();
|
|
134
|
+
expect(signals).toEqual([0, "SIGTERM", 0, "SIGKILL"]);
|
|
135
|
+
} finally {
|
|
136
|
+
process.kill = originalKill;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("preserves PID tracking when process termination fails", async () => {
|
|
141
|
+
const dir = mkdtempSync(path.join(tmpdir(), "vellum live pid "));
|
|
142
|
+
const pidFile = path.join(dir, "assistant.pid");
|
|
143
|
+
writeFileSync(pidFile, `${process.pid}\n`, "utf8");
|
|
144
|
+
|
|
145
|
+
expect(
|
|
146
|
+
await stopProcessByPidFile(
|
|
147
|
+
pidFile,
|
|
148
|
+
"test process",
|
|
149
|
+
undefined,
|
|
150
|
+
0,
|
|
151
|
+
async () => false,
|
|
152
|
+
() => true,
|
|
153
|
+
),
|
|
154
|
+
).toBeFalse();
|
|
155
|
+
expect(isProcessAlive(pidFile)).toEqual({ alive: true, pid: process.pid });
|
|
156
|
+
rmSync(dir, { recursive: true, force: true });
|
|
157
|
+
});
|
package/src/commands/client.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from "../lib/client-identity";
|
|
30
30
|
import {
|
|
31
31
|
getLockfileData,
|
|
32
|
+
renameLockfileAssistantIfPresent,
|
|
32
33
|
upsertRendererLockfileAssistant,
|
|
33
34
|
replacePlatformAssistants,
|
|
34
35
|
isActiveAssistant,
|
|
@@ -72,6 +73,7 @@ import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
|
|
|
72
73
|
import { probePort } from "../lib/port-probe.js";
|
|
73
74
|
import { openBrowser } from "../lib/open-browser";
|
|
74
75
|
import { isCompiledCli } from "../lib/local.js";
|
|
76
|
+
import { findWebDistDir } from "../lib/web-dist.js";
|
|
75
77
|
import { getLogDir, openLogFile, resetLogFile } from "../lib/xdg-log.js";
|
|
76
78
|
|
|
77
79
|
const SUPPORTED_INTERFACES = ["cli", "web"] as const;
|
|
@@ -403,37 +405,6 @@ async function maybeHydratePlatformAssistantName(
|
|
|
403
405
|
|
|
404
406
|
const SPA_BASE = "/assistant/";
|
|
405
407
|
|
|
406
|
-
/**
|
|
407
|
-
* Locate the pre-built @vellumai/web dist directory.
|
|
408
|
-
*
|
|
409
|
-
* Resolution order:
|
|
410
|
-
* 1. npm-installed package — require.resolve('@vellumai/web/package.json')
|
|
411
|
-
* 2. Source checkout — walk up from cli/ to find clients/web/dist/
|
|
412
|
-
*/
|
|
413
|
-
function findWebDistDir(): string | null {
|
|
414
|
-
try {
|
|
415
|
-
const pkgPath = require.resolve("@vellumai/web/package.json");
|
|
416
|
-
const distDir = path.join(path.dirname(pkgPath), "dist");
|
|
417
|
-
if (existsSync(path.join(distDir, "index.html"))) {
|
|
418
|
-
return distDir;
|
|
419
|
-
}
|
|
420
|
-
} catch {
|
|
421
|
-
// Package not installed; try source checkout.
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
let dir = import.meta.dir;
|
|
425
|
-
for (let depth = 0; depth < 8; depth++) {
|
|
426
|
-
const candidate = path.join(dir, "clients", "web", "dist", "index.html");
|
|
427
|
-
if (existsSync(candidate)) {
|
|
428
|
-
return path.dirname(candidate);
|
|
429
|
-
}
|
|
430
|
-
const parent = path.dirname(dir);
|
|
431
|
-
if (parent === dir) break;
|
|
432
|
-
dir = parent;
|
|
433
|
-
}
|
|
434
|
-
return null;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
408
|
/**
|
|
438
409
|
* Locate the clients/web source directory for running the Vite dev server.
|
|
439
410
|
* Only works from a source checkout (not npm-installed).
|
|
@@ -586,6 +557,13 @@ async function handleLocalEndpoints(
|
|
|
586
557
|
body.platformAssistants as Array<Record<string, unknown>>,
|
|
587
558
|
body.organizationId as string | undefined,
|
|
588
559
|
);
|
|
560
|
+
} else if (body.rename && typeof body.rename === "object") {
|
|
561
|
+
const rename = body.rename as Record<string, unknown>;
|
|
562
|
+
result = renameLockfileAssistantIfPresent(
|
|
563
|
+
lockfilePaths,
|
|
564
|
+
rename.assistantId as string,
|
|
565
|
+
rename.name as string,
|
|
566
|
+
);
|
|
589
567
|
} else {
|
|
590
568
|
result = upsertRendererLockfileAssistant(
|
|
591
569
|
lockfilePaths,
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
10
10
|
import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
|
|
11
11
|
import { GATEWAY_PORT } from "../lib/constants.js";
|
|
12
|
-
import { WEB_REMOTE_INGRESS_FLAG } from "../lib/feature-flags.js";
|
|
13
12
|
import {
|
|
14
13
|
DEFAULT_NGINX_INGRESS_PORT,
|
|
15
14
|
ensureTunnelEdge,
|
|
@@ -61,15 +60,6 @@ function printHelp(): void {
|
|
|
61
60
|
console.log(" $ vellum nginx-ingress status");
|
|
62
61
|
console.log(" $ vellum nginx-ingress up");
|
|
63
62
|
console.log(" $ vellum nginx-ingress down my-assistant");
|
|
64
|
-
console.log("");
|
|
65
|
-
console.log("Feature flags:");
|
|
66
|
-
console.log(
|
|
67
|
-
` ${WEB_REMOTE_INGRESS_FLAG} selects the edge mode: enabled serves the`,
|
|
68
|
-
);
|
|
69
|
-
console.log(
|
|
70
|
-
" remote web app alongside webhooks; disabled starts a webhooks-only",
|
|
71
|
-
);
|
|
72
|
-
console.log(" proxy.");
|
|
73
63
|
}
|
|
74
64
|
|
|
75
65
|
interface NginxIngressTarget {
|
|
@@ -177,11 +167,6 @@ export async function up(target: NginxIngressTarget): Promise<void> {
|
|
|
177
167
|
|
|
178
168
|
console.log("");
|
|
179
169
|
console.log(`nginx ingress running: http://127.0.0.1:${edge.port} (${mode})`);
|
|
180
|
-
if (!edge.includesWebApp) {
|
|
181
|
-
console.log(
|
|
182
|
-
`Enable the ${WEB_REMOTE_INGRESS_FLAG} feature flag to also serve the web app remotely.`,
|
|
183
|
-
);
|
|
184
|
-
}
|
|
185
170
|
console.log("");
|
|
186
171
|
console.log("Next steps:");
|
|
187
172
|
console.log(
|
package/src/commands/pair.ts
CHANGED
|
@@ -42,11 +42,6 @@ import {
|
|
|
42
42
|
} from "../lib/client-identity.js";
|
|
43
43
|
import { GATEWAY_PORT } from "../lib/constants.js";
|
|
44
44
|
import { getCurrentEnvironment } from "../lib/environments/resolve.js";
|
|
45
|
-
import {
|
|
46
|
-
formatFeatureFlagGateMessage,
|
|
47
|
-
isAssistantFeatureFlagEnabled,
|
|
48
|
-
WEB_REMOTE_INGRESS_FLAG,
|
|
49
|
-
} from "../lib/feature-flags.js";
|
|
50
45
|
import { getLocalLanIPv4 } from "../lib/local.js";
|
|
51
46
|
import { isLoopbackUrl, loopbackSafeFetch } from "../lib/loopback-fetch.js";
|
|
52
47
|
import { formatWebApproveFailure, parseGatewayErrorCode } from "../lib/pair.js";
|
|
@@ -265,34 +260,6 @@ async function approveRemoteWebPairing(
|
|
|
265
260
|
return (await response.json()) as RemoteWebPairingVerificationResponse;
|
|
266
261
|
}
|
|
267
262
|
|
|
268
|
-
async function assertWebRemoteIngressEnabled(
|
|
269
|
-
assistantId: string,
|
|
270
|
-
runtimeUrl: string,
|
|
271
|
-
): Promise<void> {
|
|
272
|
-
let enabled: boolean;
|
|
273
|
-
try {
|
|
274
|
-
enabled = await isAssistantFeatureFlagEnabled(
|
|
275
|
-
assistantId,
|
|
276
|
-
WEB_REMOTE_INGRESS_FLAG,
|
|
277
|
-
{ runtimeUrl },
|
|
278
|
-
);
|
|
279
|
-
} catch (err) {
|
|
280
|
-
console.error(
|
|
281
|
-
`Error: could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` feature flag. Is the assistant running? Try \`vellum wake\` and retry. ${
|
|
282
|
-
err instanceof Error ? err.message : String(err)
|
|
283
|
-
}`,
|
|
284
|
-
);
|
|
285
|
-
process.exit(1);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
if (!enabled) {
|
|
289
|
-
console.error(
|
|
290
|
-
`Error: ${formatFeatureFlagGateMessage(WEB_REMOTE_INGRESS_FLAG)}`,
|
|
291
|
-
);
|
|
292
|
-
process.exit(1);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
263
|
export async function pair(): Promise<void> {
|
|
297
264
|
const rawArgs = process.argv.slice(3);
|
|
298
265
|
|
|
@@ -440,8 +407,6 @@ export async function pair(): Promise<void> {
|
|
|
440
407
|
}
|
|
441
408
|
|
|
442
409
|
if (webApproveCode) {
|
|
443
|
-
await assertWebRemoteIngressEnabled(entry.assistantId, mintUrl);
|
|
444
|
-
|
|
445
410
|
// Rejections are diagnosed here rather than by exitOnHttpError: a
|
|
446
411
|
// rejected code must name the gateway that was asked, or an
|
|
447
412
|
// assistant/environment mismatch is indistinguishable from a typo.
|
|
@@ -472,8 +437,6 @@ export async function pair(): Promise<void> {
|
|
|
472
437
|
}
|
|
473
438
|
|
|
474
439
|
if (webPairing) {
|
|
475
|
-
await assertWebRemoteIngressEnabled(entry.assistantId, mintUrl);
|
|
476
|
-
|
|
477
440
|
let publicBaseUrl: string;
|
|
478
441
|
try {
|
|
479
442
|
publicBaseUrl = normalizePairingBaseUrl(advertisedUrl);
|
|
@@ -551,8 +514,6 @@ export async function pair(): Promise<void> {
|
|
|
551
514
|
}
|
|
552
515
|
const qrBaseUrl = qrResult.url;
|
|
553
516
|
|
|
554
|
-
await assertWebRemoteIngressEnabled(entry.assistantId, mintUrl);
|
|
555
|
-
|
|
556
517
|
// Mint a challenge and immediately approve it: running this CLI on the host
|
|
557
518
|
// IS the local-presence proof, so the scanning device completes pairing in
|
|
558
519
|
// one step. Reuses the `--web` + `--web-approve` code paths.
|
package/src/commands/wake.ts
CHANGED
|
@@ -134,11 +134,24 @@ export async function wake(): Promise<void> {
|
|
|
134
134
|
"readyz",
|
|
135
135
|
);
|
|
136
136
|
if (daemonState.status !== "needs_start") {
|
|
137
|
-
if (
|
|
137
|
+
if (daemonState.status === "stuck") {
|
|
138
|
+
daemonRunning = true;
|
|
139
|
+
daemonUnready = true;
|
|
140
|
+
console.log(
|
|
141
|
+
`Assistant running (pid ${daemonState.pid}) but is not responding and could not be stopped.`,
|
|
142
|
+
);
|
|
143
|
+
} else if (watch && isAssistantWatchModeAvailable()) {
|
|
138
144
|
console.log(
|
|
139
145
|
`Assistant running (pid ${daemonState.pid}) — restarting in watch mode...`,
|
|
140
146
|
);
|
|
141
|
-
await stopProcessByPidFile(pidFile, "assistant");
|
|
147
|
+
const stopped = await stopProcessByPidFile(pidFile, "assistant");
|
|
148
|
+
if (!stopped && isProcessAlive(pidFile).alive) {
|
|
149
|
+
daemonRunning = true;
|
|
150
|
+
daemonUnready = true;
|
|
151
|
+
console.log(
|
|
152
|
+
`Assistant running (pid ${daemonState.pid}) but could not be stopped for watch mode.`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
142
155
|
} else {
|
|
143
156
|
daemonRunning = true;
|
|
144
157
|
daemonUnready = daemonState.status !== "healthy";
|
|
@@ -249,8 +262,29 @@ export async function wake(): Promise<void> {
|
|
|
249
262
|
"Gateway",
|
|
250
263
|
);
|
|
251
264
|
const gatewayAlive = gatewayState.status === "healthy";
|
|
265
|
+
const gatewayStuck = gatewayState.status === "stuck";
|
|
266
|
+
const restartGateway = async (
|
|
267
|
+
restartWithWatch: boolean,
|
|
268
|
+
): Promise<boolean> => {
|
|
269
|
+
const stopped = await stopProcessByPidFile(gatewayPidFile, "gateway");
|
|
270
|
+
if (!stopped && isProcessAlive(gatewayPidFile).alive) {
|
|
271
|
+
console.log(
|
|
272
|
+
`Gateway running (pid ${gatewayState.pid}) but could not be stopped.`,
|
|
273
|
+
);
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
await startGateway(restartWithWatch, resources, {
|
|
277
|
+
signingKey,
|
|
278
|
+
bootstrapSecret,
|
|
279
|
+
});
|
|
280
|
+
return true;
|
|
281
|
+
};
|
|
252
282
|
const needsRestart = bootstrapSecretBackfilled && gatewayAlive;
|
|
253
|
-
if (
|
|
283
|
+
if (gatewayStuck) {
|
|
284
|
+
console.log(
|
|
285
|
+
`Gateway running (pid ${gatewayState.pid}) but is not responding and could not be stopped.`,
|
|
286
|
+
);
|
|
287
|
+
} else if (needsRestart) {
|
|
254
288
|
const restartWithWatch = watch && isGatewayWatchModeAvailable();
|
|
255
289
|
if (restartWithWatch) {
|
|
256
290
|
console.log(
|
|
@@ -261,20 +295,13 @@ export async function wake(): Promise<void> {
|
|
|
261
295
|
`Gateway running (pid ${gatewayState.pid}) — restarting without watch mode to apply bootstrap secret...`,
|
|
262
296
|
);
|
|
263
297
|
}
|
|
264
|
-
await
|
|
265
|
-
await startGateway(restartWithWatch, resources, {
|
|
266
|
-
signingKey,
|
|
267
|
-
bootstrapSecret,
|
|
268
|
-
});
|
|
269
|
-
gatewayStarted = true;
|
|
298
|
+
gatewayStarted = await restartGateway(restartWithWatch);
|
|
270
299
|
} else if (gatewayAlive) {
|
|
271
300
|
if (watch && isGatewayWatchModeAvailable()) {
|
|
272
301
|
console.log(
|
|
273
302
|
`Gateway running (pid ${gatewayState.pid}) — restarting in watch mode...`,
|
|
274
303
|
);
|
|
275
|
-
await
|
|
276
|
-
await startGateway(watch, resources, { signingKey, bootstrapSecret });
|
|
277
|
-
gatewayStarted = true;
|
|
304
|
+
gatewayStarted = await restartGateway(watch);
|
|
278
305
|
} else {
|
|
279
306
|
if (watch) {
|
|
280
307
|
console.log(
|