@vellumai/cli 0.4.42 ā 0.4.44
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 +1 -1
- package/src/__tests__/assistant-config.test.ts +282 -3
- package/src/__tests__/multi-local.test.ts +13 -21
- package/src/__tests__/sleep.test.ts +172 -0
- package/src/commands/client.ts +72 -10
- package/src/commands/hatch.ts +65 -14
- package/src/commands/ps.ts +25 -8
- package/src/commands/recover.ts +17 -8
- package/src/commands/retire.ts +14 -23
- package/src/commands/sleep.ts +88 -16
- package/src/commands/wake.ts +9 -7
- package/src/components/DefaultMainScreen.tsx +19 -85
- package/src/index.ts +0 -3
- package/src/lib/assistant-config.ts +154 -61
- package/src/lib/aws.ts +30 -1
- package/src/lib/docker.ts +321 -0
- package/src/lib/gcp.ts +53 -1
- package/src/lib/http-client.ts +114 -0
- package/src/lib/local.ts +117 -167
- package/src/lib/step-runner.ts +9 -1
- package/src/lib/xdg-log.ts +47 -3
- package/src/__tests__/skills-uninstall.test.ts +0 -203
- package/src/commands/skills.ts +0 -514
|
@@ -16,13 +16,10 @@ import { probePort } from "./port-probe.js";
|
|
|
16
16
|
*/
|
|
17
17
|
export interface LocalInstanceResources {
|
|
18
18
|
/**
|
|
19
|
-
* Instance-specific data root. The first local assistant uses `~` (
|
|
20
|
-
*
|
|
21
|
-
* `~/.local/share/vellum/assistants/<name
|
|
22
|
-
*
|
|
23
|
-
* The daemon's `.vellum/` directory lives inside it. Equivalent to
|
|
24
|
-
* `AssistantEntry.baseDataDir` minus the trailing `/.vellum` suffix ā
|
|
25
|
-
* `baseDataDir` is kept on the flat entry for legacy lockfile compat.
|
|
19
|
+
* Instance-specific data root. The first local assistant uses `~` (home
|
|
20
|
+
* directory) with default ports. Subsequent instances are placed under
|
|
21
|
+
* `~/.local/share/vellum/assistants/<name>/`.
|
|
22
|
+
* The daemon's `.vellum/` directory lives inside it.
|
|
26
23
|
*/
|
|
27
24
|
instanceDir: string;
|
|
28
25
|
/** HTTP port for the daemon runtime server */
|
|
@@ -31,10 +28,9 @@ export interface LocalInstanceResources {
|
|
|
31
28
|
gatewayPort: number;
|
|
32
29
|
/** HTTP port for the Qdrant vector store */
|
|
33
30
|
qdrantPort: number;
|
|
34
|
-
/** Absolute path to the Unix domain socket for IPC */
|
|
35
|
-
socketPath: string;
|
|
36
31
|
/** Absolute path to the daemon PID file */
|
|
37
32
|
pidFile: string;
|
|
33
|
+
[key: string]: unknown;
|
|
38
34
|
}
|
|
39
35
|
|
|
40
36
|
export interface AssistantEntry {
|
|
@@ -43,8 +39,6 @@ export interface AssistantEntry {
|
|
|
43
39
|
/** Loopback URL for same-machine health checks (e.g. `http://127.0.0.1:7831`).
|
|
44
40
|
* Avoids mDNS resolution issues when the machine checks its own gateway. */
|
|
45
41
|
localUrl?: string;
|
|
46
|
-
/** @deprecated Use `resources.instanceDir` for multi-instance entries. Legacy equivalent of `join(instanceDir, ".vellum")`. */
|
|
47
|
-
baseDataDir?: string;
|
|
48
42
|
bearerToken?: string;
|
|
49
43
|
cloud: string;
|
|
50
44
|
instanceId?: string;
|
|
@@ -57,10 +51,11 @@ export interface AssistantEntry {
|
|
|
57
51
|
hatchedAt?: string;
|
|
58
52
|
/** Per-instance resource config. Present for local entries in multi-instance setups. */
|
|
59
53
|
resources?: LocalInstanceResources;
|
|
54
|
+
[key: string]: unknown;
|
|
60
55
|
}
|
|
61
56
|
|
|
62
57
|
interface LockfileData {
|
|
63
|
-
assistants?:
|
|
58
|
+
assistants?: Record<string, unknown>[];
|
|
64
59
|
activeAssistant?: string;
|
|
65
60
|
platformBaseUrl?: string;
|
|
66
61
|
[key: string]: unknown;
|
|
@@ -70,8 +65,13 @@ function getBaseDir(): string {
|
|
|
70
65
|
return process.env.BASE_DATA_DIR?.trim() || homedir();
|
|
71
66
|
}
|
|
72
67
|
|
|
68
|
+
/** The lockfile always lives under the home directory. */
|
|
69
|
+
function getLockfileDir(): string {
|
|
70
|
+
return process.env.VELLUM_LOCKFILE_DIR?.trim() || homedir();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
73
|
function readLockfile(): LockfileData {
|
|
74
|
-
const base =
|
|
74
|
+
const base = getLockfileDir();
|
|
75
75
|
const candidates = [
|
|
76
76
|
join(base, ".vellum.lock.json"),
|
|
77
77
|
join(base, ".vellum.lockfile.json"),
|
|
@@ -92,18 +92,136 @@ function readLockfile(): LockfileData {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
function writeLockfile(data: LockfileData): void {
|
|
95
|
-
const lockfilePath = join(
|
|
95
|
+
const lockfilePath = join(getLockfileDir(), ".vellum.lock.json");
|
|
96
96
|
writeFileSync(lockfilePath, JSON.stringify(data, null, 2) + "\n");
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Try to extract a port number from a URL string (e.g. `http://localhost:7830`).
|
|
101
|
+
* Returns undefined if the URL is malformed or has no explicit port.
|
|
102
|
+
*/
|
|
103
|
+
function parsePortFromUrl(url: unknown): number | undefined {
|
|
104
|
+
if (typeof url !== "string") return undefined;
|
|
105
|
+
try {
|
|
106
|
+
const parsed = new URL(url);
|
|
107
|
+
const port = parseInt(parsed.port, 10);
|
|
108
|
+
return isNaN(port) ? undefined : port;
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Detect and migrate legacy lockfile entries to the current format.
|
|
116
|
+
*
|
|
117
|
+
* Legacy entries stored `baseDataDir` as a top-level field. The current
|
|
118
|
+
* format nests this under `resources.instanceDir`. This function also
|
|
119
|
+
* synthesises a full `resources` object when one is missing by inferring
|
|
120
|
+
* ports from the entry's `runtimeUrl` and falling back to defaults.
|
|
121
|
+
*
|
|
122
|
+
* Returns `true` if the entry was mutated (so the caller can persist).
|
|
123
|
+
*/
|
|
124
|
+
export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
125
|
+
if (typeof raw.cloud === "string" && raw.cloud !== "local") {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let mutated = false;
|
|
130
|
+
|
|
131
|
+
// Migrate top-level `baseDataDir` ā `resources.instanceDir`
|
|
132
|
+
if (typeof raw.baseDataDir === "string" && raw.baseDataDir) {
|
|
133
|
+
if (!raw.resources || typeof raw.resources !== "object") {
|
|
134
|
+
raw.resources = {};
|
|
135
|
+
}
|
|
136
|
+
const res = raw.resources as Record<string, unknown>;
|
|
137
|
+
if (!res.instanceDir) {
|
|
138
|
+
res.instanceDir = raw.baseDataDir;
|
|
139
|
+
mutated = true;
|
|
140
|
+
}
|
|
141
|
+
delete raw.baseDataDir;
|
|
142
|
+
mutated = true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Synthesise missing `resources` for local entries
|
|
146
|
+
if (!raw.resources || typeof raw.resources !== "object") {
|
|
147
|
+
const gatewayPort =
|
|
148
|
+
parsePortFromUrl(raw.runtimeUrl) ?? DEFAULT_GATEWAY_PORT;
|
|
149
|
+
const instanceDir = join(
|
|
150
|
+
homedir(),
|
|
151
|
+
".local",
|
|
152
|
+
"share",
|
|
153
|
+
"vellum",
|
|
154
|
+
"assistants",
|
|
155
|
+
typeof raw.assistantId === "string" ? raw.assistantId : "default",
|
|
156
|
+
);
|
|
157
|
+
raw.resources = {
|
|
158
|
+
instanceDir,
|
|
159
|
+
daemonPort: DEFAULT_DAEMON_PORT,
|
|
160
|
+
gatewayPort,
|
|
161
|
+
qdrantPort: DEFAULT_QDRANT_PORT,
|
|
162
|
+
pidFile: join(instanceDir, ".vellum", "vellum.pid"),
|
|
163
|
+
};
|
|
164
|
+
mutated = true;
|
|
165
|
+
} else {
|
|
166
|
+
// Backfill any missing fields on an existing partial `resources` object
|
|
167
|
+
const res = raw.resources as Record<string, unknown>;
|
|
168
|
+
if (!res.instanceDir) {
|
|
169
|
+
res.instanceDir = join(
|
|
170
|
+
homedir(),
|
|
171
|
+
".local",
|
|
172
|
+
"share",
|
|
173
|
+
"vellum",
|
|
174
|
+
"assistants",
|
|
175
|
+
typeof raw.assistantId === "string" ? raw.assistantId : "default",
|
|
176
|
+
);
|
|
177
|
+
mutated = true;
|
|
178
|
+
}
|
|
179
|
+
if (typeof res.daemonPort !== "number") {
|
|
180
|
+
res.daemonPort = DEFAULT_DAEMON_PORT;
|
|
181
|
+
mutated = true;
|
|
182
|
+
}
|
|
183
|
+
if (typeof res.gatewayPort !== "number") {
|
|
184
|
+
res.gatewayPort =
|
|
185
|
+
parsePortFromUrl(raw.runtimeUrl) ?? DEFAULT_GATEWAY_PORT;
|
|
186
|
+
mutated = true;
|
|
187
|
+
}
|
|
188
|
+
if (typeof res.qdrantPort !== "number") {
|
|
189
|
+
res.qdrantPort = DEFAULT_QDRANT_PORT;
|
|
190
|
+
mutated = true;
|
|
191
|
+
}
|
|
192
|
+
if (typeof res.pidFile !== "string") {
|
|
193
|
+
res.pidFile = join(
|
|
194
|
+
res.instanceDir as string,
|
|
195
|
+
".vellum",
|
|
196
|
+
"vellum.pid",
|
|
197
|
+
);
|
|
198
|
+
mutated = true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return mutated;
|
|
203
|
+
}
|
|
204
|
+
|
|
99
205
|
function readAssistants(): AssistantEntry[] {
|
|
100
206
|
const data = readLockfile();
|
|
101
207
|
const entries = data.assistants;
|
|
102
208
|
if (!Array.isArray(entries)) {
|
|
103
209
|
return [];
|
|
104
210
|
}
|
|
211
|
+
|
|
212
|
+
let migrated = false;
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
if (migrateLegacyEntry(entry)) {
|
|
215
|
+
migrated = true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (migrated) {
|
|
220
|
+
writeLockfile(data);
|
|
221
|
+
}
|
|
222
|
+
|
|
105
223
|
return entries.filter(
|
|
106
|
-
(e) =>
|
|
224
|
+
(e): e is AssistantEntry =>
|
|
107
225
|
typeof e.assistantId === "string" && typeof e.runtimeUrl === "string",
|
|
108
226
|
);
|
|
109
227
|
}
|
|
@@ -135,12 +253,17 @@ export function findAssistantByName(name: string): AssistantEntry | null {
|
|
|
135
253
|
export function removeAssistantEntry(assistantId: string): void {
|
|
136
254
|
const data = readLockfile();
|
|
137
255
|
const entries = (data.assistants ?? []).filter(
|
|
138
|
-
(e
|
|
256
|
+
(e) => e.assistantId !== assistantId,
|
|
139
257
|
);
|
|
140
258
|
data.assistants = entries;
|
|
141
|
-
//
|
|
259
|
+
// Reassign active assistant if it matches the removed entry
|
|
142
260
|
if (data.activeAssistant === assistantId) {
|
|
143
|
-
|
|
261
|
+
const remaining = entries[0];
|
|
262
|
+
if (remaining) {
|
|
263
|
+
data.activeAssistant = String(remaining.assistantId);
|
|
264
|
+
} else {
|
|
265
|
+
delete data.activeAssistant;
|
|
266
|
+
}
|
|
144
267
|
}
|
|
145
268
|
writeLockfile(data);
|
|
146
269
|
}
|
|
@@ -228,17 +351,26 @@ async function findAvailablePort(
|
|
|
228
351
|
|
|
229
352
|
/**
|
|
230
353
|
* Allocate an isolated set of resources for a named local instance.
|
|
231
|
-
* The first local assistant
|
|
232
|
-
*
|
|
354
|
+
* The first local assistant uses the home directory with default ports.
|
|
355
|
+
* Subsequent assistants are placed under
|
|
233
356
|
* `~/.local/share/vellum/assistants/<name>/` with scanned ports.
|
|
234
357
|
*/
|
|
235
358
|
export async function allocateLocalResources(
|
|
236
359
|
instanceName: string,
|
|
237
360
|
): Promise<LocalInstanceResources> {
|
|
238
|
-
// First local assistant gets the home directory
|
|
361
|
+
// First local assistant gets the home directory with default ports.
|
|
239
362
|
const existingLocals = loadAllAssistants().filter((e) => e.cloud === "local");
|
|
240
363
|
if (existingLocals.length === 0) {
|
|
241
|
-
|
|
364
|
+
const home = homedir();
|
|
365
|
+
const vellumDir = join(home, ".vellum");
|
|
366
|
+
mkdirSync(vellumDir, { recursive: true });
|
|
367
|
+
return {
|
|
368
|
+
instanceDir: home,
|
|
369
|
+
daemonPort: DEFAULT_DAEMON_PORT,
|
|
370
|
+
gatewayPort: DEFAULT_GATEWAY_PORT,
|
|
371
|
+
qdrantPort: DEFAULT_QDRANT_PORT,
|
|
372
|
+
pidFile: join(vellumDir, "vellum.pid"),
|
|
373
|
+
};
|
|
242
374
|
}
|
|
243
375
|
|
|
244
376
|
const instanceDir = join(
|
|
@@ -263,13 +395,6 @@ export async function allocateLocalResources(
|
|
|
263
395
|
entry.resources.gatewayPort,
|
|
264
396
|
entry.resources.qdrantPort,
|
|
265
397
|
);
|
|
266
|
-
} else {
|
|
267
|
-
// Legacy entries without resources use the default ports
|
|
268
|
-
reservedPorts.push(
|
|
269
|
-
DEFAULT_DAEMON_PORT,
|
|
270
|
-
DEFAULT_GATEWAY_PORT,
|
|
271
|
-
DEFAULT_QDRANT_PORT,
|
|
272
|
-
);
|
|
273
398
|
}
|
|
274
399
|
}
|
|
275
400
|
|
|
@@ -294,42 +419,10 @@ export async function allocateLocalResources(
|
|
|
294
419
|
daemonPort,
|
|
295
420
|
gatewayPort,
|
|
296
421
|
qdrantPort,
|
|
297
|
-
socketPath: join(instanceDir, ".vellum", "vellum.sock"),
|
|
298
422
|
pidFile: join(instanceDir, ".vellum", "vellum.pid"),
|
|
299
423
|
};
|
|
300
424
|
}
|
|
301
425
|
|
|
302
|
-
/**
|
|
303
|
-
* Return default resources representing the legacy single-instance layout.
|
|
304
|
-
* Used to normalize existing lockfile entries so callers can treat all local
|
|
305
|
-
* entries uniformly.
|
|
306
|
-
*/
|
|
307
|
-
export function defaultLocalResources(): LocalInstanceResources {
|
|
308
|
-
const vellumDir = join(homedir(), ".vellum");
|
|
309
|
-
return {
|
|
310
|
-
instanceDir: homedir(),
|
|
311
|
-
daemonPort: DEFAULT_DAEMON_PORT,
|
|
312
|
-
gatewayPort: DEFAULT_GATEWAY_PORT,
|
|
313
|
-
qdrantPort: DEFAULT_QDRANT_PORT,
|
|
314
|
-
socketPath: join(vellumDir, "vellum.sock"),
|
|
315
|
-
pidFile: join(vellumDir, "vellum.pid"),
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Normalize existing lockfile entries so local entries include resource fields.
|
|
321
|
-
* Remote entries are left untouched. Returns a new array (does not mutate input).
|
|
322
|
-
*/
|
|
323
|
-
export function normalizeExistingEntryResources(
|
|
324
|
-
entries: AssistantEntry[],
|
|
325
|
-
): AssistantEntry[] {
|
|
326
|
-
return entries.map((entry) => {
|
|
327
|
-
if (entry.cloud !== "local") return entry;
|
|
328
|
-
if (entry.resources) return entry;
|
|
329
|
-
return { ...entry, resources: defaultLocalResources() };
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
|
|
333
426
|
/**
|
|
334
427
|
* Read the assistant config file and sync client-relevant values to the
|
|
335
428
|
* lockfile. This lets external tools (e.g. vel) discover the platform URL
|
package/src/lib/aws.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { join } from "path";
|
|
|
5
5
|
|
|
6
6
|
import { buildStartupScript, watchHatching } from "../commands/hatch";
|
|
7
7
|
import type { PollResult } from "../commands/hatch";
|
|
8
|
-
import { saveAssistantEntry } from "./assistant-config";
|
|
8
|
+
import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
|
|
9
9
|
import type { AssistantEntry } from "./assistant-config";
|
|
10
10
|
import { GATEWAY_PORT } from "./constants";
|
|
11
11
|
import type { Species } from "./constants";
|
|
@@ -370,6 +370,28 @@ async function pollAwsInstance(
|
|
|
370
370
|
}
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
async function fetchRemoteBearerToken(
|
|
374
|
+
ip: string,
|
|
375
|
+
keyPath: string,
|
|
376
|
+
): Promise<string | null> {
|
|
377
|
+
try {
|
|
378
|
+
const remoteCmd =
|
|
379
|
+
'cat ~/.vellum.lock.json 2>/dev/null || cat ~/.vellum.lockfile.json 2>/dev/null || echo "{}"';
|
|
380
|
+
const output = await awsSshExec(ip, keyPath, remoteCmd);
|
|
381
|
+
const data = JSON.parse(output.trim());
|
|
382
|
+
const assistants = data.assistants;
|
|
383
|
+
if (Array.isArray(assistants) && assistants.length > 0) {
|
|
384
|
+
const token = assistants[0].bearerToken;
|
|
385
|
+
if (typeof token === "string" && token) {
|
|
386
|
+
return token;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
390
|
+
} catch {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
373
395
|
export async function hatchAws(
|
|
374
396
|
species: Species,
|
|
375
397
|
detached: boolean,
|
|
@@ -500,6 +522,7 @@ export async function hatchAws(
|
|
|
500
522
|
hatchedAt: new Date().toISOString(),
|
|
501
523
|
};
|
|
502
524
|
saveAssistantEntry(awsEntry);
|
|
525
|
+
setActiveAssistant(instanceName);
|
|
503
526
|
|
|
504
527
|
if (detached) {
|
|
505
528
|
console.log("\u{1F680} Startup script is running on the instance...");
|
|
@@ -535,6 +558,12 @@ export async function hatchAws(
|
|
|
535
558
|
}
|
|
536
559
|
process.exit(1);
|
|
537
560
|
}
|
|
561
|
+
|
|
562
|
+
const remoteBearerToken = await fetchRemoteBearerToken(ip, keyPath);
|
|
563
|
+
if (remoteBearerToken) {
|
|
564
|
+
awsEntry.bearerToken = remoteBearerToken;
|
|
565
|
+
saveAssistantEntry(awsEntry);
|
|
566
|
+
}
|
|
538
567
|
} else {
|
|
539
568
|
console.log(
|
|
540
569
|
"\u26a0\ufe0f No external IP available for monitoring. Instance is still running.",
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "child_process";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
|
|
6
|
+
import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
|
|
7
|
+
import type { AssistantEntry } from "./assistant-config";
|
|
8
|
+
import { DEFAULT_GATEWAY_PORT } from "./constants";
|
|
9
|
+
import type { Species } from "./constants";
|
|
10
|
+
import { discoverPublicUrl } from "./local";
|
|
11
|
+
import { generateRandomSuffix } from "./random-name";
|
|
12
|
+
import { exec, execOutput } from "./step-runner";
|
|
13
|
+
import { closeLogFile, openLogFile, resetLogFile, writeToLogFile } from "./xdg-log";
|
|
14
|
+
|
|
15
|
+
const _require = createRequire(import.meta.url);
|
|
16
|
+
|
|
17
|
+
interface DockerRoot {
|
|
18
|
+
/** Directory to use as the Docker build context */
|
|
19
|
+
root: string;
|
|
20
|
+
/** Relative path from root to the directory containing the Dockerfiles */
|
|
21
|
+
dockerfileDir: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Locate the directory containing the Dockerfile. In the source tree the
|
|
26
|
+
* Dockerfiles live under `meta/`, but when installed as an npm package they
|
|
27
|
+
* are at the package root.
|
|
28
|
+
*/
|
|
29
|
+
function findDockerRoot(): DockerRoot {
|
|
30
|
+
// Source tree: cli/src/lib/ -> repo root (Dockerfiles in meta/)
|
|
31
|
+
const sourceTreeRoot = join(import.meta.dir, "..", "..", "..");
|
|
32
|
+
if (existsSync(join(sourceTreeRoot, "meta", "Dockerfile"))) {
|
|
33
|
+
return { root: sourceTreeRoot, dockerfileDir: "meta" };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// bunx layout: @vellumai/cli/src/lib/ -> ../../../.. -> node_modules -> vellum/
|
|
37
|
+
const bunxRoot = join(import.meta.dir, "..", "..", "..", "..", "vellum");
|
|
38
|
+
if (existsSync(join(bunxRoot, "Dockerfile"))) {
|
|
39
|
+
return { root: bunxRoot, dockerfileDir: "." };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Walk up from cwd looking for meta/Dockerfile (source checkout)
|
|
43
|
+
let dir = process.cwd();
|
|
44
|
+
while (true) {
|
|
45
|
+
if (existsSync(join(dir, "meta", "Dockerfile"))) {
|
|
46
|
+
return { root: dir, dockerfileDir: "meta" };
|
|
47
|
+
}
|
|
48
|
+
const parent = dirname(dir);
|
|
49
|
+
if (parent === dir) break;
|
|
50
|
+
dir = parent;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Fall back to Node module resolution for the `vellum` package
|
|
54
|
+
try {
|
|
55
|
+
const vellumPkgPath = _require.resolve("vellum/package.json");
|
|
56
|
+
const vellumDir = dirname(vellumPkgPath);
|
|
57
|
+
if (existsSync(join(vellumDir, "Dockerfile"))) {
|
|
58
|
+
return { root: vellumDir, dockerfileDir: "." };
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// resolution failed
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
throw new Error(
|
|
65
|
+
"Could not find Dockerfile. Run this command from within the " +
|
|
66
|
+
"vellum-assistant repository, or ensure the vellum package is installed.",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Creates a line-buffered output prefixer that prepends `[docker]` to each
|
|
72
|
+
* line from the container's stdout/stderr. Calls `onLine` for each complete
|
|
73
|
+
* line so the caller can detect sentinel output (e.g. hatch completion).
|
|
74
|
+
*/
|
|
75
|
+
function createLinePrefixer(
|
|
76
|
+
stream: NodeJS.WritableStream,
|
|
77
|
+
onLine?: (line: string) => void,
|
|
78
|
+
): { write(data: Buffer): void; flush(): void } {
|
|
79
|
+
let remainder = "";
|
|
80
|
+
return {
|
|
81
|
+
write(data: Buffer) {
|
|
82
|
+
const text = remainder + data.toString();
|
|
83
|
+
const lines = text.split("\n");
|
|
84
|
+
remainder = lines.pop() ?? "";
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
stream.write(` [docker] ${line}\n`);
|
|
87
|
+
onLine?.(line);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
flush() {
|
|
91
|
+
if (remainder) {
|
|
92
|
+
stream.write(` [docker] ${remainder}\n`);
|
|
93
|
+
onLine?.(remainder);
|
|
94
|
+
remainder = "";
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function fetchRemoteBearerToken(
|
|
101
|
+
containerName: string,
|
|
102
|
+
): Promise<string | null> {
|
|
103
|
+
try {
|
|
104
|
+
const remoteCmd =
|
|
105
|
+
'cat ~/.vellum.lock.json 2>/dev/null || cat ~/.vellum.lockfile.json 2>/dev/null || echo "{}"';
|
|
106
|
+
const output = await execOutput("docker", [
|
|
107
|
+
"exec",
|
|
108
|
+
containerName,
|
|
109
|
+
"sh",
|
|
110
|
+
"-c",
|
|
111
|
+
remoteCmd,
|
|
112
|
+
]);
|
|
113
|
+
const data = JSON.parse(output.trim());
|
|
114
|
+
const assistants = data.assistants;
|
|
115
|
+
if (Array.isArray(assistants) && assistants.length > 0) {
|
|
116
|
+
const token = assistants[0].bearerToken;
|
|
117
|
+
if (typeof token === "string" && token) {
|
|
118
|
+
return token;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function retireDocker(name: string): Promise<void> {
|
|
128
|
+
console.log(`\u{1F5D1}\ufe0f Stopping Docker container '${name}'...\n`);
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await exec("docker", ["stop", name]);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.warn(
|
|
134
|
+
`\u26a0\ufe0f Failed to stop container: ${error instanceof Error ? error.message : error}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await exec("docker", ["rm", name]);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.warn(
|
|
142
|
+
`\u26a0\ufe0f Failed to remove container: ${error instanceof Error ? error.message : error}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log(`\u2705 Docker instance retired.`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function hatchDocker(
|
|
150
|
+
species: Species,
|
|
151
|
+
detached: boolean,
|
|
152
|
+
name: string | null,
|
|
153
|
+
watch: boolean,
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
const { root: repoRoot, dockerfileDir } = findDockerRoot();
|
|
156
|
+
const instanceName = name ?? `${species}-${generateRandomSuffix()}`;
|
|
157
|
+
const dockerfileName = watch ? "Dockerfile.development" : "Dockerfile";
|
|
158
|
+
const dockerfile = join(dockerfileDir, dockerfileName);
|
|
159
|
+
const dockerfilePath = join(repoRoot, dockerfile);
|
|
160
|
+
|
|
161
|
+
if (!existsSync(dockerfilePath)) {
|
|
162
|
+
console.error(`Error: ${dockerfile} not found at ${dockerfilePath}`);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
resetLogFile("hatch.log");
|
|
167
|
+
|
|
168
|
+
console.log(`š„ Hatching Docker assistant: ${instanceName}`);
|
|
169
|
+
console.log(` Species: ${species}`);
|
|
170
|
+
console.log(` Dockerfile: ${dockerfile}`);
|
|
171
|
+
if (watch) {
|
|
172
|
+
console.log(` Mode: development (watch)`);
|
|
173
|
+
}
|
|
174
|
+
console.log("");
|
|
175
|
+
|
|
176
|
+
const imageTag = `vellum-assistant:${instanceName}`;
|
|
177
|
+
const logFd = openLogFile("hatch.log");
|
|
178
|
+
console.log("šØ Building Docker image...");
|
|
179
|
+
try {
|
|
180
|
+
await exec("docker", ["build", "-f", dockerfile, "-t", imageTag, "."], {
|
|
181
|
+
cwd: repoRoot,
|
|
182
|
+
});
|
|
183
|
+
} catch (err) {
|
|
184
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
185
|
+
writeToLogFile(logFd, `[docker-build] ${new Date().toISOString()} ERROR\n${message}\n`);
|
|
186
|
+
closeLogFile(logFd);
|
|
187
|
+
throw err;
|
|
188
|
+
}
|
|
189
|
+
closeLogFile(logFd);
|
|
190
|
+
console.log("ā
Docker image built\n");
|
|
191
|
+
|
|
192
|
+
const gatewayPort = DEFAULT_GATEWAY_PORT;
|
|
193
|
+
const runArgs: string[] = [
|
|
194
|
+
"run",
|
|
195
|
+
"--init",
|
|
196
|
+
"--name",
|
|
197
|
+
instanceName,
|
|
198
|
+
"-p",
|
|
199
|
+
`${gatewayPort}:${gatewayPort}`,
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
// Pass through environment variables the assistant needs
|
|
203
|
+
for (const envVar of [
|
|
204
|
+
"ANTHROPIC_API_KEY",
|
|
205
|
+
"GATEWAY_RUNTIME_PROXY_ENABLED",
|
|
206
|
+
"RUNTIME_PROXY_BEARER_TOKEN",
|
|
207
|
+
"VELLUM_ASSISTANT_PLATFORM_URL",
|
|
208
|
+
]) {
|
|
209
|
+
if (process.env[envVar]) {
|
|
210
|
+
runArgs.push("-e", `${envVar}=${process.env[envVar]}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Pass the instance name so the inner hatch uses the same assistant ID
|
|
215
|
+
// instead of generating a new random one.
|
|
216
|
+
runArgs.push("-e", `VELLUM_ASSISTANT_NAME=${instanceName}`);
|
|
217
|
+
|
|
218
|
+
// Mount source volumes in watch mode for hot reloading
|
|
219
|
+
if (watch) {
|
|
220
|
+
runArgs.push(
|
|
221
|
+
"-v",
|
|
222
|
+
`${join(repoRoot, "assistant", "src")}:/app/assistant/src`,
|
|
223
|
+
"-v",
|
|
224
|
+
`${join(repoRoot, "gateway", "src")}:/app/gateway/src`,
|
|
225
|
+
"-v",
|
|
226
|
+
`${join(repoRoot, "cli", "src")}:/app/cli/src`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const publicUrl = await discoverPublicUrl(gatewayPort);
|
|
231
|
+
const runtimeUrl = publicUrl || `http://localhost:${gatewayPort}`;
|
|
232
|
+
const dockerEntry: AssistantEntry = {
|
|
233
|
+
assistantId: instanceName,
|
|
234
|
+
runtimeUrl,
|
|
235
|
+
cloud: "docker",
|
|
236
|
+
species,
|
|
237
|
+
hatchedAt: new Date().toISOString(),
|
|
238
|
+
};
|
|
239
|
+
saveAssistantEntry(dockerEntry);
|
|
240
|
+
setActiveAssistant(instanceName);
|
|
241
|
+
|
|
242
|
+
// The Dockerfiles already define a CMD that runs `vellum hatch --keep-alive`.
|
|
243
|
+
// Only override CMD when a non-default species is specified, since that
|
|
244
|
+
// requires an extra argument the Dockerfile doesn't include.
|
|
245
|
+
const containerCmd: string[] =
|
|
246
|
+
species !== "vellum"
|
|
247
|
+
? ["vellum", "hatch", species, ...(watch ? ["--watch"] : []), "--keep-alive"]
|
|
248
|
+
: [];
|
|
249
|
+
|
|
250
|
+
// Always start the container detached so it keeps running after the CLI exits.
|
|
251
|
+
runArgs.push("-d");
|
|
252
|
+
console.log("š Starting Docker container...");
|
|
253
|
+
await exec("docker", [...runArgs, imageTag, ...containerCmd], { cwd: repoRoot });
|
|
254
|
+
|
|
255
|
+
if (detached) {
|
|
256
|
+
console.log("\nā
Docker assistant hatched!\n");
|
|
257
|
+
console.log("Instance details:");
|
|
258
|
+
console.log(` Name: ${instanceName}`);
|
|
259
|
+
console.log(` Runtime: ${runtimeUrl}`);
|
|
260
|
+
console.log(` Container: ${instanceName}`);
|
|
261
|
+
console.log("");
|
|
262
|
+
console.log(`Stop with: docker stop ${instanceName}`);
|
|
263
|
+
} else {
|
|
264
|
+
console.log(` Container: ${instanceName}`);
|
|
265
|
+
console.log(` Runtime: ${runtimeUrl}`);
|
|
266
|
+
console.log("");
|
|
267
|
+
|
|
268
|
+
// Tail container logs until the inner hatch completes, then exit and
|
|
269
|
+
// leave the container running in the background.
|
|
270
|
+
await new Promise<void>((resolve, reject) => {
|
|
271
|
+
const child = nodeSpawn("docker", ["logs", "-f", instanceName], {
|
|
272
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const handleLine = (line: string): void => {
|
|
276
|
+
if (line.includes("Local assistant hatched!")) {
|
|
277
|
+
process.nextTick(async () => {
|
|
278
|
+
const remoteBearerToken =
|
|
279
|
+
await fetchRemoteBearerToken(instanceName);
|
|
280
|
+
if (remoteBearerToken) {
|
|
281
|
+
dockerEntry.bearerToken = remoteBearerToken;
|
|
282
|
+
saveAssistantEntry(dockerEntry);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
console.log("");
|
|
286
|
+
console.log(`\u2705 Docker container is up and running!`);
|
|
287
|
+
console.log(` Name: ${instanceName}`);
|
|
288
|
+
console.log(` Runtime: ${runtimeUrl}`);
|
|
289
|
+
console.log("");
|
|
290
|
+
child.kill();
|
|
291
|
+
resolve();
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const stdoutPrefixer = createLinePrefixer(process.stdout, handleLine);
|
|
297
|
+
const stderrPrefixer = createLinePrefixer(process.stderr, handleLine);
|
|
298
|
+
|
|
299
|
+
child.stdout?.on("data", (data: Buffer) => stdoutPrefixer.write(data));
|
|
300
|
+
child.stderr?.on("data", (data: Buffer) => stderrPrefixer.write(data));
|
|
301
|
+
child.stdout?.on("end", () => stdoutPrefixer.flush());
|
|
302
|
+
child.stderr?.on("end", () => stderrPrefixer.flush());
|
|
303
|
+
|
|
304
|
+
child.on("close", (code) => {
|
|
305
|
+
// The log tail may exit if the container stops before the sentinel
|
|
306
|
+
// is seen, or we killed it after detecting the sentinel.
|
|
307
|
+
if (code === 0 || code === null || code === 130 || code === 137 || code === 143) {
|
|
308
|
+
resolve();
|
|
309
|
+
} else {
|
|
310
|
+
reject(new Error(`Docker container exited with code ${code}`));
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
child.on("error", reject);
|
|
314
|
+
|
|
315
|
+
process.on("SIGINT", () => {
|
|
316
|
+
child.kill();
|
|
317
|
+
resolve();
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|