@vellumai/cli 0.6.3 → 0.6.5
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/AGENTS.md +12 -2
- package/README.md +3 -3
- package/bun.lock +17 -17
- package/bunfig.toml +6 -0
- package/package.json +18 -18
- package/src/__tests__/assistant-config.test.ts +124 -0
- package/src/__tests__/env-drift.test.ts +87 -0
- package/src/__tests__/guardian-token.test.ts +225 -0
- package/src/__tests__/llm-provider-env-var-parity.test.ts +64 -0
- package/src/__tests__/multi-local.test.ts +90 -13
- package/src/__tests__/orphan-detection.test.ts +214 -0
- package/src/__tests__/platform-client.test.ts +204 -0
- package/src/__tests__/preload.ts +27 -0
- package/src/__tests__/ssh-user-guard.test.ts +28 -0
- package/src/__tests__/teleport.test.ts +1073 -56
- package/src/commands/backup.ts +8 -0
- package/src/commands/exec.ts +186 -0
- package/src/commands/hatch.ts +1 -1
- package/src/commands/login.ts +209 -9
- package/src/commands/logs.ts +652 -0
- package/src/commands/pair.ts +9 -1
- package/src/commands/ps.ts +37 -7
- package/src/commands/recover.ts +8 -4
- package/src/commands/restore.ts +8 -0
- package/src/commands/retire.ts +16 -9
- package/src/commands/rollback.ts +32 -33
- package/src/commands/ssh.ts +7 -0
- package/src/commands/teleport.ts +253 -1
- package/src/commands/upgrade.ts +43 -52
- package/src/commands/wake.ts +25 -10
- package/src/components/DefaultMainScreen.tsx +7 -1
- package/src/index.ts +6 -0
- package/src/lib/__tests__/docker.test.ts +168 -0
- package/src/lib/assistant-config.ts +82 -108
- package/src/lib/aws.ts +12 -1
- package/src/lib/config-utils.ts +4 -4
- package/src/lib/constants.ts +0 -10
- package/src/lib/docker.ts +158 -8
- package/src/lib/environments/__tests__/paths.test.ts +228 -0
- package/src/lib/environments/__tests__/resolve.test.ts +226 -0
- package/src/lib/environments/__tests__/seeds.test.ts +72 -0
- package/src/lib/environments/paths.ts +109 -0
- package/src/lib/environments/resolve.ts +96 -0
- package/src/lib/environments/seeds.ts +74 -0
- package/src/lib/environments/types.ts +60 -0
- package/src/lib/exec-apple-container.ts +122 -0
- package/src/lib/gcp.ts +12 -1
- package/src/lib/guardian-token.ts +71 -10
- package/src/lib/hatch-local.ts +44 -23
- package/src/lib/local.ts +47 -5
- package/src/lib/orphan-detection.ts +28 -12
- package/src/lib/platform-client.ts +354 -24
- package/src/lib/retire-apple-container.ts +102 -0
- package/src/lib/ssh-apple-container.ts +166 -0
- package/src/lib/upgrade-lifecycle.ts +101 -28
- package/src/shared/provider-env-vars.ts +30 -6
package/src/index.ts
CHANGED
|
@@ -5,8 +5,10 @@ import { backup } from "./commands/backup";
|
|
|
5
5
|
import { clean } from "./commands/clean";
|
|
6
6
|
import { client } from "./commands/client";
|
|
7
7
|
import { events } from "./commands/events";
|
|
8
|
+
import { exec } from "./commands/exec";
|
|
8
9
|
import { hatch } from "./commands/hatch";
|
|
9
10
|
import { login, logout, whoami } from "./commands/login";
|
|
11
|
+
import { logs } from "./commands/logs";
|
|
10
12
|
import { message } from "./commands/message";
|
|
11
13
|
import { pair } from "./commands/pair";
|
|
12
14
|
import { ps } from "./commands/ps";
|
|
@@ -36,9 +38,11 @@ const commands = {
|
|
|
36
38
|
clean,
|
|
37
39
|
client,
|
|
38
40
|
events,
|
|
41
|
+
exec,
|
|
39
42
|
hatch,
|
|
40
43
|
login,
|
|
41
44
|
logout,
|
|
45
|
+
logs,
|
|
42
46
|
message,
|
|
43
47
|
pair,
|
|
44
48
|
ps,
|
|
@@ -67,7 +71,9 @@ function printHelp(): void {
|
|
|
67
71
|
console.log(" clean Kill orphaned vellum processes");
|
|
68
72
|
console.log(" client Connect to a hatched assistant");
|
|
69
73
|
console.log(" events Stream events from a running assistant");
|
|
74
|
+
console.log(" exec Execute a command inside an assistant's container");
|
|
70
75
|
console.log(" hatch Create a new assistant instance");
|
|
76
|
+
console.log(" logs View logs from an assistant instance");
|
|
71
77
|
console.log(" login Log in to the Vellum platform");
|
|
72
78
|
console.log(" logout Log out of the Vellum platform");
|
|
73
79
|
console.log(" message Send a message to a running assistant");
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
ASSISTANT_INTERNAL_PORT,
|
|
4
|
+
DEFAULT_MEET_AVATAR_DEVICE_PATH,
|
|
5
|
+
dockerResourceNames,
|
|
6
|
+
MEET_AVATAR_DEVICE_ENV_VAR,
|
|
7
|
+
MEET_AVATAR_ENV_VAR,
|
|
8
|
+
resolveMeetAvatarDevicePath,
|
|
9
|
+
serviceDockerRunArgs,
|
|
10
|
+
type ServiceName,
|
|
11
|
+
} from "../docker.js";
|
|
12
|
+
|
|
13
|
+
const instanceName = "test-instance";
|
|
14
|
+
const imageTags: Record<ServiceName, string> = {
|
|
15
|
+
assistant: "vellumai/vellum-assistant:test",
|
|
16
|
+
"credential-executor": "vellumai/vellum-credential-executor:test",
|
|
17
|
+
gateway: "vellumai/vellum-gateway:test",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function buildAssistantArgs(): string[] {
|
|
21
|
+
const res = dockerResourceNames(instanceName);
|
|
22
|
+
const builders = serviceDockerRunArgs({
|
|
23
|
+
gatewayPort: 7830,
|
|
24
|
+
imageTags,
|
|
25
|
+
instanceName,
|
|
26
|
+
res,
|
|
27
|
+
});
|
|
28
|
+
return builders.assistant();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("serviceDockerRunArgs — assistant", () => {
|
|
32
|
+
test("runs privileged so the inner dockerd can manage cgroups/iptables/overlayfs", () => {
|
|
33
|
+
const args = buildAssistantArgs();
|
|
34
|
+
expect(args).toContain("--privileged");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("mounts a dedicated named volume at /var/lib/docker for the inner dockerd data store", () => {
|
|
38
|
+
const args = buildAssistantArgs();
|
|
39
|
+
const spec = `${instanceName}-dockerd-data:/var/lib/docker`;
|
|
40
|
+
const mountIndex = args.indexOf(spec);
|
|
41
|
+
expect(mountIndex).toBeGreaterThan(0);
|
|
42
|
+
expect(args[mountIndex - 1]).toBe("-v");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("does NOT bind-mount the host Docker socket (DinD replaces host-socket access)", () => {
|
|
46
|
+
const args = buildAssistantArgs();
|
|
47
|
+
expect(args).not.toContain("/var/run/docker.sock:/var/run/docker.sock");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("does NOT set VELLUM_WORKSPACE_VOLUME_NAME (legacy Phase 1.8 hint, no longer needed in DinD)", () => {
|
|
51
|
+
const args = buildAssistantArgs();
|
|
52
|
+
expect(
|
|
53
|
+
args.some((a) => a.startsWith("VELLUM_WORKSPACE_VOLUME_NAME=")),
|
|
54
|
+
).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("keeps existing workspace and socket volume mounts intact", () => {
|
|
58
|
+
const args = buildAssistantArgs();
|
|
59
|
+
expect(args).toContain(`${instanceName}-workspace:/workspace`);
|
|
60
|
+
expect(args).toContain(`${instanceName}-socket:/run/ces-bootstrap`);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("preserves existing required env vars", () => {
|
|
64
|
+
const args = buildAssistantArgs();
|
|
65
|
+
expect(args).toContain("IS_CONTAINERIZED=true");
|
|
66
|
+
expect(args).toContain("VELLUM_WORKSPACE_DIR=/workspace");
|
|
67
|
+
expect(args).toContain(`VELLUM_ASSISTANT_NAME=${instanceName}`);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("publishes the assistant HTTP port on all host interfaces so sibling bot containers can reach the daemon via host.docker.internal on both Docker Desktop and Linux", () => {
|
|
71
|
+
const args = buildAssistantArgs();
|
|
72
|
+
// The port mapping is expressed as two adjacent args: "-p" then the spec.
|
|
73
|
+
// Bound to all interfaces (no `127.0.0.1:` prefix) because on vanilla
|
|
74
|
+
// Linux Docker, host.docker.internal:host-gateway resolves to the Docker
|
|
75
|
+
// bridge gateway IP — packets arrive at the bridge interface, not
|
|
76
|
+
// loopback, so a 127.0.0.1 DNAT rule would not match.
|
|
77
|
+
const portSpec = `${ASSISTANT_INTERNAL_PORT}:${ASSISTANT_INTERNAL_PORT}`;
|
|
78
|
+
const portIndex = args.indexOf(portSpec);
|
|
79
|
+
expect(portIndex).toBeGreaterThan(0);
|
|
80
|
+
expect(args[portIndex - 1]).toBe("-p");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("Meet avatar device passthrough (VELLUM_MEET_AVATAR opt-in)", () => {
|
|
85
|
+
// Snapshot + restore the process env so tests can flip the env-var
|
|
86
|
+
// without leaking state to later suites or other CLI tests.
|
|
87
|
+
const originalEnv: Record<string, string | undefined> = {};
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
for (const key of [MEET_AVATAR_ENV_VAR, MEET_AVATAR_DEVICE_ENV_VAR]) {
|
|
91
|
+
originalEnv[key] = process.env[key];
|
|
92
|
+
delete process.env[key];
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
for (const [key, value] of Object.entries(originalEnv)) {
|
|
98
|
+
if (value === undefined) delete process.env[key];
|
|
99
|
+
else process.env[key] = value;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("resolveMeetAvatarDevicePath returns null when the env var is unset", () => {
|
|
104
|
+
expect(resolveMeetAvatarDevicePath({})).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("resolveMeetAvatarDevicePath treats 0/false/no as disabled", () => {
|
|
108
|
+
for (const value of ["", "0", "false", "FALSE", "no", " NO "]) {
|
|
109
|
+
expect(resolveMeetAvatarDevicePath({ [MEET_AVATAR_ENV_VAR]: value })).toBe(
|
|
110
|
+
null,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("resolveMeetAvatarDevicePath returns the default device path when enabled with a truthy value", () => {
|
|
116
|
+
for (const value of ["1", "true", "YES"]) {
|
|
117
|
+
expect(resolveMeetAvatarDevicePath({ [MEET_AVATAR_ENV_VAR]: value })).toBe(
|
|
118
|
+
DEFAULT_MEET_AVATAR_DEVICE_PATH,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("resolveMeetAvatarDevicePath honors the VELLUM_MEET_AVATAR_DEVICE override", () => {
|
|
124
|
+
expect(
|
|
125
|
+
resolveMeetAvatarDevicePath({
|
|
126
|
+
[MEET_AVATAR_ENV_VAR]: "1",
|
|
127
|
+
[MEET_AVATAR_DEVICE_ENV_VAR]: "/dev/video11",
|
|
128
|
+
}),
|
|
129
|
+
).toBe("/dev/video11");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("assistant args omit --device and the avatar env vars when VELLUM_MEET_AVATAR is unset", () => {
|
|
133
|
+
const args = buildAssistantArgs();
|
|
134
|
+
expect(args).not.toContain("--device");
|
|
135
|
+
expect(
|
|
136
|
+
args.some((a) => a.startsWith(`${MEET_AVATAR_ENV_VAR}=`)),
|
|
137
|
+
).toBe(false);
|
|
138
|
+
expect(
|
|
139
|
+
args.some((a) => a.startsWith(`${MEET_AVATAR_DEVICE_ENV_VAR}=`)),
|
|
140
|
+
).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("assistant args include --device=/dev/video10:/dev/video10 when VELLUM_MEET_AVATAR=1", () => {
|
|
144
|
+
process.env[MEET_AVATAR_ENV_VAR] = "1";
|
|
145
|
+
const args = buildAssistantArgs();
|
|
146
|
+
const deviceIdx = args.indexOf("--device");
|
|
147
|
+
expect(deviceIdx).toBeGreaterThan(0);
|
|
148
|
+
expect(args[deviceIdx + 1]).toBe(
|
|
149
|
+
`${DEFAULT_MEET_AVATAR_DEVICE_PATH}:${DEFAULT_MEET_AVATAR_DEVICE_PATH}`,
|
|
150
|
+
);
|
|
151
|
+
// The env var must also be propagated into the container so the daemon
|
|
152
|
+
// knows to turn on avatar passthrough when spawning the bot.
|
|
153
|
+
expect(args).toContain(`${MEET_AVATAR_ENV_VAR}=1`);
|
|
154
|
+
expect(args).toContain(
|
|
155
|
+
`${MEET_AVATAR_DEVICE_ENV_VAR}=${DEFAULT_MEET_AVATAR_DEVICE_PATH}`,
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("assistant args honor a custom device path from VELLUM_MEET_AVATAR_DEVICE", () => {
|
|
160
|
+
process.env[MEET_AVATAR_ENV_VAR] = "1";
|
|
161
|
+
process.env[MEET_AVATAR_DEVICE_ENV_VAR] = "/dev/video11";
|
|
162
|
+
const args = buildAssistantArgs();
|
|
163
|
+
const deviceIdx = args.indexOf("--device");
|
|
164
|
+
expect(deviceIdx).toBeGreaterThan(0);
|
|
165
|
+
expect(args[deviceIdx + 1]).toBe("/dev/video11:/dev/video11");
|
|
166
|
+
expect(args).toContain(`${MEET_AVATAR_DEVICE_ENV_VAR}=/dev/video11`);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -8,16 +8,16 @@ import {
|
|
|
8
8
|
writeFileSync,
|
|
9
9
|
} from "fs";
|
|
10
10
|
import { homedir } from "os";
|
|
11
|
-
import { join } from "path";
|
|
11
|
+
import { dirname, join } from "path";
|
|
12
12
|
|
|
13
|
+
import { DAEMON_INTERNAL_ASSISTANT_ID } from "./constants.js";
|
|
13
14
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
} from "./constants.js";
|
|
15
|
+
getDefaultPorts,
|
|
16
|
+
getLockfilePath,
|
|
17
|
+
getLockfilePaths,
|
|
18
|
+
getMultiInstanceDir,
|
|
19
|
+
} from "./environments/paths.js";
|
|
20
|
+
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
21
21
|
import { probePort } from "./port-probe.js";
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -27,10 +27,11 @@ import { probePort } from "./port-probe.js";
|
|
|
27
27
|
*/
|
|
28
28
|
export interface LocalInstanceResources {
|
|
29
29
|
/**
|
|
30
|
-
* Instance-specific data root.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* The daemon's `.vellum/` directory
|
|
30
|
+
* Instance-specific data root. New local assistants are placed under
|
|
31
|
+
* `$XDG_DATA_HOME/vellum{-env}/assistants/<name>/`. Legacy entries
|
|
32
|
+
* (pre env-data-layout) may still point at `~` — the read path honors
|
|
33
|
+
* whatever `instanceDir` is stored. The daemon's `.vellum/` directory
|
|
34
|
+
* lives inside it.
|
|
34
35
|
*/
|
|
35
36
|
instanceDir: string;
|
|
36
37
|
/** HTTP port for the daemon runtime server */
|
|
@@ -84,18 +85,17 @@ export interface AssistantEntry {
|
|
|
84
85
|
resources?: LocalInstanceResources;
|
|
85
86
|
/** PID of the file watcher process for docker instances hatched with --watch. */
|
|
86
87
|
watcherPid?: number;
|
|
87
|
-
/** Last-known version of the service group, populated at hatch and updated by health checks. */
|
|
88
|
-
serviceGroupVersion?: string;
|
|
89
88
|
/** Docker image metadata for rollback. Only present for docker topology entries. */
|
|
90
89
|
containerInfo?: ContainerInfo;
|
|
91
|
-
/** The service group version that was running before the last upgrade. */
|
|
92
|
-
previousServiceGroupVersion?: string;
|
|
93
90
|
/** Docker image metadata from before the last upgrade. Enables rollback to the prior version. */
|
|
94
91
|
previousContainerInfo?: ContainerInfo;
|
|
95
92
|
/** Path to the .vbundle backup created for the most recent upgrade. Used by rollback to restore
|
|
96
93
|
* only the backup from the specific upgrade being rolled back — never a stale backup from a
|
|
97
94
|
* previous upgrade cycle. */
|
|
98
95
|
preUpgradeBackupPath?: string;
|
|
96
|
+
/** Running version of the service group at the time of the last upgrade, as reported by
|
|
97
|
+
* the health endpoint. Used by saved-state rollback for logging / broadcast events. */
|
|
98
|
+
previousVersion?: string;
|
|
99
99
|
/** Pre-upgrade DB migration version — used by rollback to know how far back to revert. */
|
|
100
100
|
previousDbMigrationVersion?: number;
|
|
101
101
|
/** Pre-upgrade workspace migration ID — used by rollback to know how far back to revert. */
|
|
@@ -114,15 +114,8 @@ export function getBaseDir(): string {
|
|
|
114
114
|
return process.env.BASE_DATA_DIR?.trim() || homedir();
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
/** The lockfile always lives under the home directory. */
|
|
118
|
-
function getLockfileDir(): string {
|
|
119
|
-
return process.env.VELLUM_LOCKFILE_DIR?.trim() || homedir();
|
|
120
|
-
}
|
|
121
|
-
|
|
122
117
|
function readLockfile(): LockfileData {
|
|
123
|
-
const
|
|
124
|
-
const candidates = LOCKFILE_NAMES.map((name) => join(base, name));
|
|
125
|
-
for (const lockfilePath of candidates) {
|
|
118
|
+
for (const lockfilePath of getLockfilePaths(getCurrentEnvironment())) {
|
|
126
119
|
if (!existsSync(lockfilePath)) continue;
|
|
127
120
|
try {
|
|
128
121
|
const raw = readFileSync(lockfilePath, "utf-8");
|
|
@@ -138,7 +131,8 @@ function readLockfile(): LockfileData {
|
|
|
138
131
|
}
|
|
139
132
|
|
|
140
133
|
function writeLockfile(data: LockfileData): void {
|
|
141
|
-
const lockfilePath =
|
|
134
|
+
const lockfilePath = getLockfilePath(getCurrentEnvironment());
|
|
135
|
+
mkdirSync(dirname(lockfilePath), { recursive: true });
|
|
142
136
|
const tmpPath = `${lockfilePath}.${randomBytes(4).toString("hex")}.tmp`;
|
|
143
137
|
try {
|
|
144
138
|
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
|
|
@@ -187,6 +181,8 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
187
181
|
return false;
|
|
188
182
|
}
|
|
189
183
|
|
|
184
|
+
const env = getCurrentEnvironment();
|
|
185
|
+
const defaultPorts = getDefaultPorts(env);
|
|
190
186
|
let mutated = false;
|
|
191
187
|
|
|
192
188
|
// Migrate top-level `baseDataDir` → `resources.instanceDir`
|
|
@@ -206,23 +202,19 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
206
202
|
// Synthesise missing `resources` for local entries
|
|
207
203
|
if (!raw.resources || typeof raw.resources !== "object") {
|
|
208
204
|
const gatewayPort =
|
|
209
|
-
parsePortFromUrl(raw.runtimeUrl) ??
|
|
205
|
+
parsePortFromUrl(raw.runtimeUrl) ?? defaultPorts.gateway;
|
|
210
206
|
const instanceDir = join(
|
|
211
|
-
|
|
212
|
-
".local",
|
|
213
|
-
"share",
|
|
214
|
-
"vellum",
|
|
215
|
-
"assistants",
|
|
207
|
+
getMultiInstanceDir(env),
|
|
216
208
|
typeof raw.assistantId === "string"
|
|
217
209
|
? raw.assistantId
|
|
218
210
|
: DAEMON_INTERNAL_ASSISTANT_ID,
|
|
219
211
|
);
|
|
220
212
|
raw.resources = {
|
|
221
213
|
instanceDir,
|
|
222
|
-
daemonPort:
|
|
214
|
+
daemonPort: defaultPorts.daemon,
|
|
223
215
|
gatewayPort,
|
|
224
|
-
qdrantPort:
|
|
225
|
-
cesPort:
|
|
216
|
+
qdrantPort: defaultPorts.qdrant,
|
|
217
|
+
cesPort: defaultPorts.ces,
|
|
226
218
|
pidFile: join(instanceDir, ".vellum", "vellum.pid"),
|
|
227
219
|
};
|
|
228
220
|
mutated = true;
|
|
@@ -231,11 +223,7 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
231
223
|
const res = raw.resources as Record<string, unknown>;
|
|
232
224
|
if (!res.instanceDir) {
|
|
233
225
|
res.instanceDir = join(
|
|
234
|
-
|
|
235
|
-
".local",
|
|
236
|
-
"share",
|
|
237
|
-
"vellum",
|
|
238
|
-
"assistants",
|
|
226
|
+
getMultiInstanceDir(env),
|
|
239
227
|
typeof raw.assistantId === "string"
|
|
240
228
|
? raw.assistantId
|
|
241
229
|
: DAEMON_INTERNAL_ASSISTANT_ID,
|
|
@@ -243,20 +231,20 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
243
231
|
mutated = true;
|
|
244
232
|
}
|
|
245
233
|
if (typeof res.daemonPort !== "number") {
|
|
246
|
-
res.daemonPort =
|
|
234
|
+
res.daemonPort = defaultPorts.daemon;
|
|
247
235
|
mutated = true;
|
|
248
236
|
}
|
|
249
237
|
if (typeof res.gatewayPort !== "number") {
|
|
250
238
|
res.gatewayPort =
|
|
251
|
-
parsePortFromUrl(raw.runtimeUrl) ??
|
|
239
|
+
parsePortFromUrl(raw.runtimeUrl) ?? defaultPorts.gateway;
|
|
252
240
|
mutated = true;
|
|
253
241
|
}
|
|
254
242
|
if (typeof res.qdrantPort !== "number") {
|
|
255
|
-
res.qdrantPort =
|
|
243
|
+
res.qdrantPort = defaultPorts.qdrant;
|
|
256
244
|
mutated = true;
|
|
257
245
|
}
|
|
258
246
|
if (typeof res.cesPort !== "number") {
|
|
259
|
-
res.cesPort =
|
|
247
|
+
res.cesPort = defaultPorts.ces;
|
|
260
248
|
mutated = true;
|
|
261
249
|
}
|
|
262
250
|
if (typeof res.pidFile !== "string") {
|
|
@@ -386,6 +374,22 @@ export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
|
|
|
386
374
|
process.exit(1);
|
|
387
375
|
}
|
|
388
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Determine which cloud topology an assistant entry is running on.
|
|
379
|
+
*/
|
|
380
|
+
export function resolveCloud(entry: AssistantEntry): string {
|
|
381
|
+
if (entry.cloud) {
|
|
382
|
+
return entry.cloud;
|
|
383
|
+
}
|
|
384
|
+
if (entry.project) {
|
|
385
|
+
return "gcp";
|
|
386
|
+
}
|
|
387
|
+
if (entry.sshUser) {
|
|
388
|
+
return "custom";
|
|
389
|
+
}
|
|
390
|
+
return "local";
|
|
391
|
+
}
|
|
392
|
+
|
|
389
393
|
export function saveAssistantEntry(entry: AssistantEntry): void {
|
|
390
394
|
const entries = readAssistants().filter(
|
|
391
395
|
(e) => e.assistantId !== entry.assistantId,
|
|
@@ -394,23 +398,6 @@ export function saveAssistantEntry(entry: AssistantEntry): void {
|
|
|
394
398
|
writeAssistants(entries);
|
|
395
399
|
}
|
|
396
400
|
|
|
397
|
-
/**
|
|
398
|
-
* Update just the serviceGroupVersion field on a lockfile entry.
|
|
399
|
-
* Reads the current entry, updates the version if changed, and writes back.
|
|
400
|
-
* No-op if the entry doesn't exist or the version hasn't changed.
|
|
401
|
-
*/
|
|
402
|
-
export function updateServiceGroupVersion(
|
|
403
|
-
assistantId: string,
|
|
404
|
-
version: string,
|
|
405
|
-
): void {
|
|
406
|
-
const entries = readAssistants();
|
|
407
|
-
const entry = entries.find((e) => e.assistantId === assistantId);
|
|
408
|
-
if (!entry) return;
|
|
409
|
-
if (entry.serviceGroupVersion === version) return;
|
|
410
|
-
entry.serviceGroupVersion = version;
|
|
411
|
-
writeAssistants(entries);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
401
|
/**
|
|
415
402
|
* Scan upward from `basePort` to find an available port. A port is considered
|
|
416
403
|
* available when `probePort()` returns false (nothing listening). Scans up to
|
|
@@ -434,72 +421,47 @@ async function findAvailablePort(
|
|
|
434
421
|
|
|
435
422
|
/**
|
|
436
423
|
* Allocate an isolated set of resources for a named local instance.
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
424
|
+
* Every new local assistant is allocated under
|
|
425
|
+
* `$XDG_DATA_HOME/vellum{-env}/assistants/<name>/`. The legacy `~/.vellum/`
|
|
426
|
+
* path is only reached via existing lockfile entries from before this change
|
|
427
|
+
* — the read path honors whatever `resources.instanceDir` is stored, so
|
|
428
|
+
* production users' existing first-local assistants keep their `~/.vellum/`
|
|
429
|
+
* roots unchanged.
|
|
440
430
|
*/
|
|
441
431
|
export async function allocateLocalResources(
|
|
442
432
|
instanceName: string,
|
|
443
433
|
): Promise<LocalInstanceResources> {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
// gateway, and credential store all resolve paths under the same root.
|
|
447
|
-
const existingLocals = loadAllAssistants().filter((e) => e.cloud === "local");
|
|
448
|
-
if (existingLocals.length === 0) {
|
|
449
|
-
const baseDir = getBaseDir();
|
|
450
|
-
const vellumDir = join(baseDir, ".vellum");
|
|
451
|
-
return {
|
|
452
|
-
instanceDir: baseDir,
|
|
453
|
-
daemonPort: DEFAULT_DAEMON_PORT,
|
|
454
|
-
gatewayPort: DEFAULT_GATEWAY_PORT,
|
|
455
|
-
qdrantPort: DEFAULT_QDRANT_PORT,
|
|
456
|
-
cesPort: DEFAULT_CES_PORT,
|
|
457
|
-
pidFile: join(vellumDir, "vellum.pid"),
|
|
458
|
-
};
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
const instanceDir = join(
|
|
462
|
-
homedir(),
|
|
463
|
-
".local",
|
|
464
|
-
"share",
|
|
465
|
-
"vellum",
|
|
466
|
-
"assistants",
|
|
467
|
-
instanceName,
|
|
468
|
-
);
|
|
434
|
+
const env = getCurrentEnvironment();
|
|
435
|
+
const instanceDir = join(getMultiInstanceDir(env), instanceName);
|
|
469
436
|
mkdirSync(instanceDir, { recursive: true });
|
|
470
437
|
|
|
471
438
|
// Collect ports already assigned to other local instances in the lockfile.
|
|
472
|
-
// Even if those instances are stopped, we must avoid reusing their ports
|
|
473
|
-
// to prevent binding collisions when both are woken.
|
|
474
439
|
const reservedPorts: number[] = [];
|
|
475
440
|
for (const entry of loadAllAssistants()) {
|
|
476
|
-
if (entry.cloud !== "local") continue;
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
);
|
|
484
|
-
}
|
|
441
|
+
if (entry.cloud !== "local" || !entry.resources) continue;
|
|
442
|
+
reservedPorts.push(
|
|
443
|
+
entry.resources.daemonPort,
|
|
444
|
+
entry.resources.gatewayPort,
|
|
445
|
+
entry.resources.qdrantPort,
|
|
446
|
+
entry.resources.cesPort,
|
|
447
|
+
);
|
|
485
448
|
}
|
|
486
449
|
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
const gatewayPort = await findAvailablePort(DEFAULT_GATEWAY_PORT, [
|
|
450
|
+
// Env-aware bases: non-prod envs sit in their own 1000-port window so
|
|
451
|
+
// running prod and staging assistants side-by-side doesn't collide. See
|
|
452
|
+
// `environments/seeds.ts:portBlock` for the layout.
|
|
453
|
+
const basePorts = getDefaultPorts(env);
|
|
454
|
+
const daemonPort = await findAvailablePort(basePorts.daemon, reservedPorts);
|
|
455
|
+
const gatewayPort = await findAvailablePort(basePorts.gateway, [
|
|
494
456
|
...reservedPorts,
|
|
495
457
|
daemonPort,
|
|
496
458
|
]);
|
|
497
|
-
const qdrantPort = await findAvailablePort(
|
|
459
|
+
const qdrantPort = await findAvailablePort(basePorts.qdrant, [
|
|
498
460
|
...reservedPorts,
|
|
499
461
|
daemonPort,
|
|
500
462
|
gatewayPort,
|
|
501
463
|
]);
|
|
502
|
-
const cesPort = await findAvailablePort(
|
|
464
|
+
const cesPort = await findAvailablePort(basePorts.ces, [
|
|
503
465
|
...reservedPorts,
|
|
504
466
|
daemonPort,
|
|
505
467
|
gatewayPort,
|
|
@@ -516,6 +478,18 @@ export async function allocateLocalResources(
|
|
|
516
478
|
};
|
|
517
479
|
}
|
|
518
480
|
|
|
481
|
+
/**
|
|
482
|
+
* Return `platformBaseUrl` from the lockfile, if set. This is the value
|
|
483
|
+
* persisted by {@link syncConfigToLockfile} the last time the active
|
|
484
|
+
* assistant was hatched/waked, and is the source of truth for "which
|
|
485
|
+
* platform does the currently-active assistant target".
|
|
486
|
+
*/
|
|
487
|
+
export function getLockfilePlatformBaseUrl(): string | undefined {
|
|
488
|
+
const url = readLockfile().platformBaseUrl;
|
|
489
|
+
if (typeof url === "string" && url.trim()) return url.trim();
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
|
|
519
493
|
/**
|
|
520
494
|
* Read the assistant config file and sync client-relevant values to the
|
|
521
495
|
* lockfile. This lets external tools (e.g. vel) discover the platform URL
|
package/src/lib/aws.ts
CHANGED
|
@@ -411,7 +411,18 @@ export async function hatchAws(
|
|
|
411
411
|
}
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
-
|
|
414
|
+
let sshUser: string;
|
|
415
|
+
try {
|
|
416
|
+
sshUser = userInfo().username;
|
|
417
|
+
} catch {
|
|
418
|
+
sshUser = process.env.USER ?? "";
|
|
419
|
+
}
|
|
420
|
+
if (!sshUser) {
|
|
421
|
+
console.error(
|
|
422
|
+
"Error: Could not determine SSH username. Set the USER environment variable and try again.",
|
|
423
|
+
);
|
|
424
|
+
process.exit(1);
|
|
425
|
+
}
|
|
415
426
|
const hatchedBy = process.env.VELLUM_HATCHED_BY;
|
|
416
427
|
const providerApiKeys: Record<string, string> = {};
|
|
417
428
|
for (const [, envVar] of Object.entries(PROVIDER_ENV_VAR_NAMES)) {
|
package/src/lib/config-utils.ts
CHANGED
|
@@ -5,8 +5,8 @@ import { join } from "path";
|
|
|
5
5
|
/**
|
|
6
6
|
* Convert flat dot-notation key=value pairs into a nested config object.
|
|
7
7
|
*
|
|
8
|
-
* e.g. {"
|
|
9
|
-
* → {
|
|
8
|
+
* e.g. {"llm.default.provider": "anthropic", "llm.default.model": "claude-opus-4-6"}
|
|
9
|
+
* → {llm: {default: {provider: "anthropic", model: "claude-opus-4-6"}}}
|
|
10
10
|
*/
|
|
11
11
|
export function buildNestedConfig(
|
|
12
12
|
configValues: Record<string, string>,
|
|
@@ -39,8 +39,8 @@ export function buildNestedConfig(
|
|
|
39
39
|
* values into its workspace config on first boot.
|
|
40
40
|
*
|
|
41
41
|
* Keys use dot-notation to address nested fields. For example:
|
|
42
|
-
* "
|
|
43
|
-
* "
|
|
42
|
+
* "llm.default.provider" → {llm: {default: {provider: ...}}}
|
|
43
|
+
* "llm.default.model" → {llm: {default: {model: ...}}}
|
|
44
44
|
*
|
|
45
45
|
* Returns undefined when configValues is empty (nothing to write).
|
|
46
46
|
*/
|
package/src/lib/constants.ts
CHANGED
|
@@ -16,16 +16,6 @@ export const DEFAULT_GATEWAY_PORT = 7830;
|
|
|
16
16
|
export const DEFAULT_QDRANT_PORT = 6333;
|
|
17
17
|
export const DEFAULT_CES_PORT = 8090;
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
* Lockfile candidate filenames, checked in priority order.
|
|
21
|
-
* `.vellum.lock.json` is the current name; `.vellum.lockfile.json` is the
|
|
22
|
-
* legacy name kept for backwards compatibility with older installs.
|
|
23
|
-
*/
|
|
24
|
-
export const LOCKFILE_NAMES = [
|
|
25
|
-
".vellum.lock.json",
|
|
26
|
-
".vellum.lockfile.json",
|
|
27
|
-
] as const;
|
|
28
|
-
|
|
29
19
|
export const VALID_REMOTE_HOSTS = [
|
|
30
20
|
"local",
|
|
31
21
|
"gcp",
|