@vellumai/cli 0.10.5-dev.202607031934.26016be → 0.10.5-dev.202607032030.d15600c

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.10.5-dev.202607031934.26016be",
3
+ "version": "0.10.5-dev.202607032030.d15600c",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,157 @@
1
+ import {
2
+ afterAll,
3
+ beforeAll,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ mock,
8
+ spyOn,
9
+ test,
10
+ } from "bun:test";
11
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ import type { AssistantEntry } from "../lib/assistant-config.js";
16
+
17
+ const testDir = mkdtempSync(join(tmpdir(), "retire-local-test-"));
18
+ const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
19
+
20
+ const stopProcessByPidFileMock = mock(
21
+ async (_pidFile: string, _label: string): Promise<boolean> => true,
22
+ );
23
+ const stopOrphanedDaemonProcessesMock = mock(async (): Promise<boolean> => false);
24
+ const stopIngressNginxMock = mock(async (): Promise<boolean> => false);
25
+
26
+ mock.module("../lib/process.js", () => ({
27
+ stopProcessByPidFile: stopProcessByPidFileMock,
28
+ stopOrphanedDaemonProcesses: stopOrphanedDaemonProcessesMock,
29
+ }));
30
+
31
+ mock.module("../lib/nginx-ingress.js", () => ({
32
+ stopIngressNginx: stopIngressNginxMock,
33
+ }));
34
+
35
+ // Keep archive paths on the same filesystem as the test dir so rename doesn't
36
+ // hit EXDEV (cross-device link).
37
+ const retiredStagingDir = join(testDir, "retired");
38
+ mock.module("../lib/retire-archive.js", () => ({
39
+ getArchivePath: () => join(retiredStagingDir, "test-assistant.tar.gz"),
40
+ getMetadataPath: () => join(retiredStagingDir, "test-assistant.meta.json"),
41
+ }));
42
+
43
+ import { retireLocal } from "../lib/retire-local.js";
44
+
45
+ const instanceDir = join(testDir, "test-instance");
46
+ const vellumDir = join(instanceDir, ".vellum");
47
+
48
+ function makeEntry(assistantId: string): AssistantEntry {
49
+ return {
50
+ assistantId,
51
+ runtimeUrl: "http://127.0.0.1:7801",
52
+ cloud: "local",
53
+ resources: {
54
+ instanceDir,
55
+ daemonPort: 7801,
56
+ gatewayPort: 7831,
57
+ qdrantPort: 6334,
58
+ cesPort: 7790,
59
+ },
60
+ };
61
+ }
62
+
63
+ function writeLockfile(entries: AssistantEntry[]): void {
64
+ writeFileSync(
65
+ join(testDir, ".vellum.lock.json"),
66
+ JSON.stringify({ assistants: entries }, null, 2) + "\n",
67
+ );
68
+ }
69
+
70
+ describe("retireLocal — CES sibling stop", () => {
71
+ beforeAll(() => {
72
+ process.env.VELLUM_LOCKFILE_DIR = testDir;
73
+ });
74
+
75
+ beforeEach(() => {
76
+ stopProcessByPidFileMock.mockReset();
77
+ stopProcessByPidFileMock.mockResolvedValue(true);
78
+ stopOrphanedDaemonProcessesMock.mockReset();
79
+ stopOrphanedDaemonProcessesMock.mockResolvedValue(false);
80
+ stopIngressNginxMock.mockReset();
81
+ stopIngressNginxMock.mockResolvedValue(false);
82
+
83
+ rmSync(instanceDir, { recursive: true, force: true });
84
+ rmSync(join(testDir, "retired"), { recursive: true, force: true });
85
+ mkdirSync(vellumDir, { recursive: true });
86
+ writeLockfile([makeEntry("test-assistant")]);
87
+
88
+ // Suppress console output from the lifecycle reporter.
89
+ spyOn(console, "log").mockImplementation(() => {});
90
+ spyOn(console, "warn").mockImplementation(() => {});
91
+ });
92
+
93
+ afterAll(() => {
94
+ if (originalLockfileDir === undefined) {
95
+ delete process.env.VELLUM_LOCKFILE_DIR;
96
+ } else {
97
+ process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
98
+ }
99
+ rmSync(testDir, { recursive: true, force: true });
100
+ });
101
+
102
+ test("stops the CES sibling alongside daemon and gateway", async () => {
103
+ const entry = makeEntry("test-assistant");
104
+ await retireLocal("test-assistant", entry, {
105
+ progress: () => {},
106
+ log: () => {},
107
+ warn: () => {},
108
+ error: () => {},
109
+ });
110
+
111
+ // Verify CES PID file is among the stop calls.
112
+ const cesStopCall = stopProcessByPidFileMock.mock.calls.find(
113
+ ([pidFile, label]) =>
114
+ pidFile === join(vellumDir, "ces.pid") &&
115
+ label === "credential-executor",
116
+ );
117
+ expect(cesStopCall).toBeDefined();
118
+
119
+ // Also verify daemon and gateway are still stopped (sanity).
120
+ const daemonStopCall = stopProcessByPidFileMock.mock.calls.find(
121
+ ([, label]) => label === "daemon",
122
+ );
123
+ expect(daemonStopCall).toBeDefined();
124
+
125
+ const gatewayStopCall = stopProcessByPidFileMock.mock.calls.find(
126
+ ([, label]) => label === "gateway",
127
+ );
128
+ expect(gatewayStopCall).toBeDefined();
129
+ });
130
+
131
+ test("CES stop is a no-op when ces.pid is absent", async () => {
132
+ // stopProcessByPidFile returns false when the PID file doesn't exist.
133
+ // retireLocal should still complete successfully — the CES stop is best-effort.
134
+ stopProcessByPidFileMock.mockImplementation(
135
+ async (pidFile: string): Promise<boolean> => {
136
+ if (pidFile.includes("ces.pid")) return false;
137
+ return true;
138
+ },
139
+ );
140
+
141
+ const entry = makeEntry("test-assistant");
142
+ await retireLocal("test-assistant", entry, {
143
+ progress: () => {},
144
+ log: () => {},
145
+ warn: () => {},
146
+ error: () => {},
147
+ });
148
+
149
+ // The CES stop was attempted (PID file checked) but returned false.
150
+ const cesStopCall = stopProcessByPidFileMock.mock.calls.find(
151
+ ([pidFile, label]) =>
152
+ pidFile === join(vellumDir, "ces.pid") &&
153
+ label === "credential-executor",
154
+ );
155
+ expect(cesStopCall).toBeDefined();
156
+ });
157
+ });
package/src/lib/docker.ts CHANGED
@@ -1626,7 +1626,45 @@ async function waitForGatewayAndLease(opts: {
1626
1626
  log(` Container: ${containerName}`);
1627
1627
  log("");
1628
1628
  log(`Stop with: vellum retire ${instanceName}`);
1629
- return { ready: true };
1629
+
1630
+ // Lease a guardian token even in detached mode so that `vellum ps`,
1631
+ // `vellum exec`, and other CLI commands can authenticate to the
1632
+ // gateway. Skip the /readyz readiness poll (the caller asked to detach
1633
+ // and not block) but retry the lease itself since the gateway may need
1634
+ // a moment to accept connections after the container starts.
1635
+ const leaseStart = Date.now();
1636
+ const leaseDeadline = containersUpAt + DOCKER_READY_TIMEOUT_MS;
1637
+ let guardianAccessToken: string | undefined;
1638
+ while (Date.now() < leaseDeadline) {
1639
+ try {
1640
+ const tokenData = await leaseGuardianToken(
1641
+ runtimeUrl,
1642
+ instanceName,
1643
+ bootstrapSecret,
1644
+ );
1645
+ guardianAccessToken = tokenData.accessToken;
1646
+ const leaseElapsed = ((Date.now() - leaseStart) / 1000).toFixed(1);
1647
+ log(
1648
+ `Guardian token lease: success after ${leaseElapsed}s (principalId=${tokenData.guardianPrincipalId})`,
1649
+ );
1650
+ break;
1651
+ } catch (err) {
1652
+ const elapsed = ((Date.now() - leaseStart) / 1000).toFixed(0);
1653
+ const msg = err instanceof Error ? err.message : String(err);
1654
+ log(
1655
+ `Guardian token lease: attempt failed after ${elapsed}s (${msg.split("\n")[0]}), retrying...`,
1656
+ );
1657
+ await new Promise((r) => setTimeout(r, 2000));
1658
+ }
1659
+ }
1660
+ if (!guardianAccessToken) {
1661
+ log(
1662
+ `⚠️ Guardian token lease failed after ${DOCKER_READY_TIMEOUT_MS / 1000}s.\n` +
1663
+ ` The assistant is running but CLI commands (vellum ps, vellum exec) will not authenticate.\n` +
1664
+ ` Re-hatch or run \`vellum setup\` to recover.`,
1665
+ );
1666
+ }
1667
+ return { ready: true, guardianAccessToken };
1630
1668
  }
1631
1669
 
1632
1670
  log(` Container: ${containerName}`);
@@ -66,6 +66,18 @@ export async function retireLocal(
66
66
  const gatewayPidFile = join(vellumDir, "gateway.pid");
67
67
  await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
68
68
 
69
+ // Stop the CES sibling if one was launched (CES_STANDALONE). No-op when the
70
+ // PID file is absent — on the default topology the assistant owns CES as an
71
+ // stdio child and it exits with the daemon.
72
+ const cesPidFile = join(vellumDir, "ces.pid");
73
+ const cesStopped = await stopProcessByPidFile(
74
+ cesPidFile,
75
+ "credential-executor",
76
+ );
77
+ if (cesStopped) {
78
+ reporter.log("credential-executor stopped.");
79
+ }
80
+
69
81
  // Stop Qdrant — the daemon's graceful shutdown tries to stop it via
70
82
  // qdrantManager.stop(), but if the daemon was SIGKILL'd (after 2s timeout)
71
83
  // Qdrant may still be running as an orphan. Check both the current PID file