@vellumai/cli 0.5.15 โ 0.6.0
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/bun.lock +46 -52
- package/package.json +1 -1
- package/src/__tests__/teleport.test.ts +1005 -391
- package/src/commands/hatch.ts +17 -388
- package/src/commands/retire.ts +2 -120
- package/src/commands/rollback.ts +6 -0
- package/src/commands/teleport.ts +757 -198
- package/src/commands/upgrade.ts +7 -0
- package/src/lib/aws.ts +2 -1
- package/src/lib/constants.ts +0 -11
- package/src/lib/docker.ts +27 -13
- package/src/lib/gcp.ts +2 -5
- package/src/lib/hatch-local.ts +403 -0
- package/src/lib/local.ts +9 -120
- package/src/lib/platform-client.ts +142 -8
- package/src/lib/retire-local.ts +124 -0
- package/src/lib/upgrade-lifecycle.ts +8 -0
- package/src/shared/provider-env-vars.ts +19 -0
package/src/commands/upgrade.ts
CHANGED
|
@@ -291,6 +291,11 @@ async function upgradeDocker(
|
|
|
291
291
|
` Captured ${Object.keys(capturedEnv).length} env var(s) from ${res.assistantContainer}\n`,
|
|
292
292
|
);
|
|
293
293
|
|
|
294
|
+
// Capture GUARDIAN_BOOTSTRAP_SECRET from the gateway container (it is only
|
|
295
|
+
// set on gateway, not assistant) so it persists across container restarts.
|
|
296
|
+
const gatewayEnv = await captureContainerEnv(res.gatewayContainer);
|
|
297
|
+
const bootstrapSecret = gatewayEnv["GUARDIAN_BOOTSTRAP_SECRET"];
|
|
298
|
+
|
|
294
299
|
// Notify connected clients that an upgrade is about to begin.
|
|
295
300
|
// This must fire BEFORE any progress broadcasts so the UI sets
|
|
296
301
|
// isUpdateInProgress = true and starts displaying status messages.
|
|
@@ -419,6 +424,7 @@ async function upgradeDocker(
|
|
|
419
424
|
await startContainers(
|
|
420
425
|
{
|
|
421
426
|
signingKey,
|
|
427
|
+
bootstrapSecret,
|
|
422
428
|
cesServiceToken,
|
|
423
429
|
extraAssistantEnv,
|
|
424
430
|
gatewayPort,
|
|
@@ -517,6 +523,7 @@ async function upgradeDocker(
|
|
|
517
523
|
await startContainers(
|
|
518
524
|
{
|
|
519
525
|
signingKey,
|
|
526
|
+
bootstrapSecret,
|
|
520
527
|
cesServiceToken,
|
|
521
528
|
extraAssistantEnv,
|
|
522
529
|
gatewayPort,
|
package/src/lib/aws.ts
CHANGED
|
@@ -6,7 +6,8 @@ import { buildStartupScript, watchHatching } from "../commands/hatch";
|
|
|
6
6
|
import type { PollResult } from "../commands/hatch";
|
|
7
7
|
import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
|
|
8
8
|
import type { AssistantEntry } from "./assistant-config";
|
|
9
|
-
import { GATEWAY_PORT
|
|
9
|
+
import { GATEWAY_PORT } from "./constants";
|
|
10
|
+
import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
|
|
10
11
|
import type { Species } from "./constants";
|
|
11
12
|
import { leaseGuardianToken } from "./guardian-token";
|
|
12
13
|
import { generateInstanceName } from "./random-name";
|
package/src/lib/constants.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import providerEnvVarsRegistry from "../../../meta/provider-env-vars.json";
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Canonical internal assistant ID used as the default/fallback across the CLI
|
|
5
3
|
* and daemon. Mirrors `DAEMON_INTERNAL_ASSISTANT_ID` from
|
|
@@ -28,15 +26,6 @@ export const LOCKFILE_NAMES = [
|
|
|
28
26
|
".vellum.lockfile.json",
|
|
29
27
|
] as const;
|
|
30
28
|
|
|
31
|
-
/**
|
|
32
|
-
* Environment variable names for provider API keys, keyed by provider ID.
|
|
33
|
-
* Loaded from the shared registry at `meta/provider-env-vars.json` โ the
|
|
34
|
-
* single source of truth also consumed by the assistant runtime and the
|
|
35
|
-
* macOS client.
|
|
36
|
-
*/
|
|
37
|
-
export const PROVIDER_ENV_VAR_NAMES: Record<string, string> =
|
|
38
|
-
providerEnvVarsRegistry.providers;
|
|
39
|
-
|
|
40
29
|
export const VALID_REMOTE_HOSTS = [
|
|
41
30
|
"local",
|
|
42
31
|
"gcp",
|
package/src/lib/docker.ts
CHANGED
|
@@ -13,7 +13,8 @@ import {
|
|
|
13
13
|
} from "./assistant-config";
|
|
14
14
|
import type { AssistantEntry } from "./assistant-config";
|
|
15
15
|
import { writeInitialConfig } from "./config-utils";
|
|
16
|
-
import { DEFAULT_GATEWAY_PORT
|
|
16
|
+
import { DEFAULT_GATEWAY_PORT } from "./constants";
|
|
17
|
+
import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
|
|
17
18
|
import type { Species } from "./constants";
|
|
18
19
|
import { leaseGuardianToken } from "./guardian-token";
|
|
19
20
|
import { isVellumProcess, stopProcess } from "./process";
|
|
@@ -479,8 +480,9 @@ async function buildAllImages(
|
|
|
479
480
|
|
|
480
481
|
/**
|
|
481
482
|
* Returns a function that builds the `docker run` arguments for a given
|
|
482
|
-
* service.
|
|
483
|
-
*
|
|
483
|
+
* service. All three containers share a network namespace via
|
|
484
|
+
* `--network=container:` so inter-service traffic is over localhost,
|
|
485
|
+
* matching the platform's Kubernetes pod topology.
|
|
484
486
|
*/
|
|
485
487
|
export function serviceDockerRunArgs(opts: {
|
|
486
488
|
signingKey?: string;
|
|
@@ -511,12 +513,14 @@ export function serviceDockerRunArgs(opts: {
|
|
|
511
513
|
"--name",
|
|
512
514
|
res.assistantContainer,
|
|
513
515
|
`--network=${res.network}`,
|
|
516
|
+
"-p",
|
|
517
|
+
`${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
|
|
514
518
|
"-v",
|
|
515
519
|
`${res.workspaceVolume}:/workspace`,
|
|
516
520
|
"-v",
|
|
517
521
|
`${res.socketVolume}:/run/ces-bootstrap`,
|
|
518
522
|
"-e",
|
|
519
|
-
"IS_CONTAINERIZED=
|
|
523
|
+
"IS_CONTAINERIZED=true",
|
|
520
524
|
"-e",
|
|
521
525
|
`VELLUM_ASSISTANT_NAME=${instanceName}`,
|
|
522
526
|
"-e",
|
|
@@ -526,9 +530,9 @@ export function serviceDockerRunArgs(opts: {
|
|
|
526
530
|
"-e",
|
|
527
531
|
"VELLUM_WORKSPACE_DIR=/workspace",
|
|
528
532
|
"-e",
|
|
529
|
-
|
|
533
|
+
"CES_CREDENTIAL_URL=http://localhost:8090",
|
|
530
534
|
"-e",
|
|
531
|
-
`GATEWAY_INTERNAL_URL=http
|
|
535
|
+
`GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
|
|
532
536
|
];
|
|
533
537
|
if (defaultWorkspaceConfigPath) {
|
|
534
538
|
const containerPath = `/tmp/vellum-default-workspace-config-${Date.now()}.json`;
|
|
@@ -567,9 +571,7 @@ export function serviceDockerRunArgs(opts: {
|
|
|
567
571
|
"-d",
|
|
568
572
|
"--name",
|
|
569
573
|
res.gatewayContainer,
|
|
570
|
-
`--network
|
|
571
|
-
"-p",
|
|
572
|
-
`${gatewayPort}:${GATEWAY_INTERNAL_PORT}`,
|
|
574
|
+
`--network=container:${res.assistantContainer}`,
|
|
573
575
|
"-v",
|
|
574
576
|
`${res.workspaceVolume}:/workspace`,
|
|
575
577
|
"-v",
|
|
@@ -581,13 +583,13 @@ export function serviceDockerRunArgs(opts: {
|
|
|
581
583
|
"-e",
|
|
582
584
|
`GATEWAY_PORT=${GATEWAY_INTERNAL_PORT}`,
|
|
583
585
|
"-e",
|
|
584
|
-
|
|
586
|
+
"ASSISTANT_HOST=localhost",
|
|
585
587
|
"-e",
|
|
586
588
|
`RUNTIME_HTTP_PORT=${ASSISTANT_INTERNAL_PORT}`,
|
|
587
589
|
"-e",
|
|
588
590
|
"RUNTIME_PROXY_ENABLED=true",
|
|
589
591
|
"-e",
|
|
590
|
-
|
|
592
|
+
"CES_CREDENTIAL_URL=http://localhost:8090",
|
|
591
593
|
...(cesServiceToken
|
|
592
594
|
? ["-e", `CES_SERVICE_TOKEN=${cesServiceToken}`]
|
|
593
595
|
: []),
|
|
@@ -605,7 +607,7 @@ export function serviceDockerRunArgs(opts: {
|
|
|
605
607
|
"-d",
|
|
606
608
|
"--name",
|
|
607
609
|
res.cesContainer,
|
|
608
|
-
`--network
|
|
610
|
+
`--network=container:${res.assistantContainer}`,
|
|
609
611
|
"-v",
|
|
610
612
|
`${res.socketVolume}:/run/ces-bootstrap`,
|
|
611
613
|
"-v",
|
|
@@ -842,6 +844,15 @@ function startFileWatcher(opts: {
|
|
|
842
844
|
const services = pendingServices;
|
|
843
845
|
pendingServices = new Set();
|
|
844
846
|
|
|
847
|
+
// Gateway and CES share the assistant's network namespace. If the
|
|
848
|
+
// assistant container is removed and recreated, the shared namespace
|
|
849
|
+
// is destroyed and the other two lose connectivity. Cascade the
|
|
850
|
+
// restart to all three services in that case.
|
|
851
|
+
if (services.has("assistant")) {
|
|
852
|
+
services.add("gateway");
|
|
853
|
+
services.add("credential-executor");
|
|
854
|
+
}
|
|
855
|
+
|
|
845
856
|
const serviceNames = [...services].join(", ");
|
|
846
857
|
console.log(`\n๐ Changes detected โ rebuilding: ${serviceNames}`);
|
|
847
858
|
|
|
@@ -854,7 +865,10 @@ function startFileWatcher(opts: {
|
|
|
854
865
|
}),
|
|
855
866
|
);
|
|
856
867
|
|
|
857
|
-
|
|
868
|
+
// Restart in dependency order (assistant first) so the network
|
|
869
|
+
// namespace owner is up before dependents try to attach.
|
|
870
|
+
for (const service of SERVICE_START_ORDER) {
|
|
871
|
+
if (!services.has(service)) continue;
|
|
858
872
|
const container = containerForService[service];
|
|
859
873
|
console.log(`๐ Restarting ${container}...`);
|
|
860
874
|
await removeContainer(container);
|
package/src/lib/gcp.ts
CHANGED
|
@@ -4,11 +4,8 @@ import { join } from "path";
|
|
|
4
4
|
|
|
5
5
|
import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
|
|
6
6
|
import type { AssistantEntry } from "./assistant-config";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
GATEWAY_PORT,
|
|
10
|
-
PROVIDER_ENV_VAR_NAMES,
|
|
11
|
-
} from "./constants";
|
|
7
|
+
import { FIREWALL_TAG, GATEWAY_PORT } from "./constants";
|
|
8
|
+
import { PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
|
|
12
9
|
import type { Species } from "./constants";
|
|
13
10
|
import { leaseGuardianToken } from "./guardian-token";
|
|
14
11
|
import { getPlatformUrl } from "./platform-client";
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readlinkSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
symlinkSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
appendFileSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
} from "fs";
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
|
|
16
|
+
// Direct import โ bun embeds this at compile time so it works in compiled binaries.
|
|
17
|
+
import cliPkg from "../../package.json";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
allocateLocalResources,
|
|
21
|
+
findAssistantByName,
|
|
22
|
+
loadAllAssistants,
|
|
23
|
+
saveAssistantEntry,
|
|
24
|
+
setActiveAssistant,
|
|
25
|
+
syncConfigToLockfile,
|
|
26
|
+
} from "./assistant-config.js";
|
|
27
|
+
import type {
|
|
28
|
+
AssistantEntry,
|
|
29
|
+
LocalInstanceResources,
|
|
30
|
+
} from "./assistant-config.js";
|
|
31
|
+
import type { Species } from "./constants.js";
|
|
32
|
+
import { writeInitialConfig } from "./config-utils.js";
|
|
33
|
+
import {
|
|
34
|
+
generateLocalSigningKey,
|
|
35
|
+
startLocalDaemon,
|
|
36
|
+
startGateway,
|
|
37
|
+
stopLocalProcesses,
|
|
38
|
+
} from "./local.js";
|
|
39
|
+
import { maybeStartNgrokTunnel } from "./ngrok.js";
|
|
40
|
+
import { httpHealthCheck } from "./http-client.js";
|
|
41
|
+
import { detectOrphanedProcesses } from "./orphan-detection.js";
|
|
42
|
+
import { isProcessAlive, stopProcess } from "./process.js";
|
|
43
|
+
import { generateInstanceName } from "./random-name.js";
|
|
44
|
+
import { leaseGuardianToken } from "./guardian-token.js";
|
|
45
|
+
import { archiveLogFile, resetLogFile } from "./xdg-log.js";
|
|
46
|
+
import { emitProgress } from "./desktop-progress.js";
|
|
47
|
+
|
|
48
|
+
const IS_DESKTOP = !!process.env.VELLUM_DESKTOP_APP;
|
|
49
|
+
|
|
50
|
+
function desktopLog(msg: string): void {
|
|
51
|
+
process.stdout.write(msg + "\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Attempts to place a symlink at the given path pointing to cliBinary.
|
|
56
|
+
* Returns true if the symlink was created (or already correct), false on failure.
|
|
57
|
+
*/
|
|
58
|
+
function trySymlink(cliBinary: string, symlinkPath: string): boolean {
|
|
59
|
+
try {
|
|
60
|
+
// Use lstatSync (not existsSync) to detect dangling symlinks โ
|
|
61
|
+
// existsSync follows symlinks and returns false for broken links.
|
|
62
|
+
try {
|
|
63
|
+
const stats = lstatSync(symlinkPath);
|
|
64
|
+
if (!stats.isSymbolicLink()) {
|
|
65
|
+
// Real file โ don't overwrite (developer's local install)
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
// Already a symlink โ skip if it already points to our binary
|
|
69
|
+
const dest = readlinkSync(symlinkPath);
|
|
70
|
+
if (dest === cliBinary) return true;
|
|
71
|
+
// Stale or dangling symlink โ remove before creating new one
|
|
72
|
+
unlinkSync(symlinkPath);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if ((e as NodeJS.ErrnoException)?.code !== "ENOENT") return false;
|
|
75
|
+
// Path doesn't exist โ proceed to create symlink
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const dir = join(symlinkPath, "..");
|
|
79
|
+
if (!existsSync(dir)) {
|
|
80
|
+
mkdirSync(dir, { recursive: true });
|
|
81
|
+
}
|
|
82
|
+
symlinkSync(cliBinary, symlinkPath);
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Ensures ~/.local/bin is present in the user's shell profile so that
|
|
91
|
+
* symlinks placed there are on PATH in new terminal sessions.
|
|
92
|
+
*/
|
|
93
|
+
function ensureLocalBinInShellProfile(localBinDir: string): void {
|
|
94
|
+
const shell = process.env.SHELL ?? "";
|
|
95
|
+
const home = homedir();
|
|
96
|
+
// Determine the appropriate shell profile to modify
|
|
97
|
+
const profilePath = shell.endsWith("/zsh")
|
|
98
|
+
? join(home, ".zshrc")
|
|
99
|
+
: shell.endsWith("/bash")
|
|
100
|
+
? join(home, ".bash_profile")
|
|
101
|
+
: null;
|
|
102
|
+
if (!profilePath) return;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const contents = existsSync(profilePath)
|
|
106
|
+
? readFileSync(profilePath, "utf-8")
|
|
107
|
+
: "";
|
|
108
|
+
// Check if ~/.local/bin is already referenced in PATH exports
|
|
109
|
+
if (contents.includes(localBinDir)) return;
|
|
110
|
+
const line = `\nexport PATH="${localBinDir}:\$PATH"\n`;
|
|
111
|
+
appendFileSync(profilePath, line);
|
|
112
|
+
console.log(` Added ${localBinDir} to ${profilePath}`);
|
|
113
|
+
} catch {
|
|
114
|
+
// Not critical โ user can add it manually
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function installCLISymlink(): void {
|
|
119
|
+
const cliBinary = process.execPath;
|
|
120
|
+
if (!cliBinary || !existsSync(cliBinary)) return;
|
|
121
|
+
|
|
122
|
+
// Preferred location โ works on most Macs where /usr/local/bin exists
|
|
123
|
+
const preferredPath = "/usr/local/bin/vellum";
|
|
124
|
+
if (trySymlink(cliBinary, preferredPath)) {
|
|
125
|
+
console.log(` Symlinked ${preferredPath} โ ${cliBinary}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Fallback โ use ~/.local/bin which is user-writable and doesn't need root.
|
|
130
|
+
// On some Macs /usr/local doesn't exist and creating it requires admin privileges.
|
|
131
|
+
const localBinDir = join(homedir(), ".local", "bin");
|
|
132
|
+
const fallbackPath = join(localBinDir, "vellum");
|
|
133
|
+
if (trySymlink(cliBinary, fallbackPath)) {
|
|
134
|
+
console.log(` Symlinked ${fallbackPath} โ ${cliBinary}`);
|
|
135
|
+
ensureLocalBinInShellProfile(localBinDir);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log(
|
|
140
|
+
` โ Could not create symlink for vellum CLI (tried ${preferredPath} and ${fallbackPath})`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function hatchLocal(
|
|
145
|
+
species: Species,
|
|
146
|
+
name: string | null,
|
|
147
|
+
restart: boolean = false,
|
|
148
|
+
watch: boolean = false,
|
|
149
|
+
keepAlive: boolean = false,
|
|
150
|
+
configValues: Record<string, string> = {},
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
if (restart && !name && !process.env.VELLUM_ASSISTANT_NAME) {
|
|
153
|
+
console.error(
|
|
154
|
+
"Error: Cannot restart without a known assistant ID. Provide --name or ensure VELLUM_ASSISTANT_NAME is set.",
|
|
155
|
+
);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const instanceName = generateInstanceName(
|
|
160
|
+
species,
|
|
161
|
+
name ?? process.env.VELLUM_ASSISTANT_NAME,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
emitProgress(1, 7, "Preparing workspace...");
|
|
165
|
+
|
|
166
|
+
// Clean up stale local state: if daemon/gateway processes are running but
|
|
167
|
+
// the lock file has no entries AND the daemon is not healthy, stop them
|
|
168
|
+
// before starting fresh. A healthy daemon should be reused, not killed โ
|
|
169
|
+
// it may have been started intentionally via `vellum wake`.
|
|
170
|
+
const vellumDir = join(homedir(), ".vellum");
|
|
171
|
+
const existingAssistants = loadAllAssistants();
|
|
172
|
+
const localAssistants = existingAssistants.filter((a) => a.cloud === "local");
|
|
173
|
+
if (localAssistants.length === 0) {
|
|
174
|
+
const daemonPid = isProcessAlive(join(vellumDir, "vellum.pid"));
|
|
175
|
+
const gatewayPid = isProcessAlive(join(vellumDir, "gateway.pid"));
|
|
176
|
+
if (daemonPid.alive || gatewayPid.alive) {
|
|
177
|
+
// Check if the daemon is actually healthy before killing it.
|
|
178
|
+
// Default port 7821 is used when there's no lockfile entry.
|
|
179
|
+
const defaultPort = parseInt(process.env.RUNTIME_HTTP_PORT || "7821", 10);
|
|
180
|
+
const healthy = await httpHealthCheck(defaultPort);
|
|
181
|
+
if (!healthy) {
|
|
182
|
+
console.log(
|
|
183
|
+
"๐งน Cleaning up stale local processes (no lock file entry)...\n",
|
|
184
|
+
);
|
|
185
|
+
await stopLocalProcesses();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// On desktop, scan the process table for orphaned vellum processes that
|
|
191
|
+
// are not tracked by any PID file or lock file entry and kill them before
|
|
192
|
+
// starting new ones. This prevents resource leaks when the desktop app
|
|
193
|
+
// crashes or is force-quit without a clean shutdown.
|
|
194
|
+
//
|
|
195
|
+
// Skip orphan cleanup if the daemon is already healthy on the expected port
|
|
196
|
+
// โ those processes are intentional (e.g. started via `vellum wake`) and
|
|
197
|
+
// startLocalDaemon() will reuse them.
|
|
198
|
+
if (IS_DESKTOP) {
|
|
199
|
+
const existingResources = findAssistantByName(instanceName);
|
|
200
|
+
const expectedPort =
|
|
201
|
+
existingResources?.cloud === "local" && existingResources.resources
|
|
202
|
+
? existingResources.resources.daemonPort
|
|
203
|
+
: undefined;
|
|
204
|
+
const daemonAlreadyHealthy = expectedPort
|
|
205
|
+
? await httpHealthCheck(expectedPort)
|
|
206
|
+
: false;
|
|
207
|
+
|
|
208
|
+
if (!daemonAlreadyHealthy) {
|
|
209
|
+
const orphans = await detectOrphanedProcesses();
|
|
210
|
+
if (orphans.length > 0) {
|
|
211
|
+
desktopLog(
|
|
212
|
+
`๐งน Found ${orphans.length} orphaned process${orphans.length === 1 ? "" : "es"} โ cleaning up...`,
|
|
213
|
+
);
|
|
214
|
+
for (const orphan of orphans) {
|
|
215
|
+
await stopProcess(
|
|
216
|
+
parseInt(orphan.pid, 10),
|
|
217
|
+
`${orphan.name} (PID ${orphan.pid})`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
emitProgress(2, 7, "Allocating resources...");
|
|
225
|
+
|
|
226
|
+
// Reuse existing resources if re-hatching with --name that matches a known
|
|
227
|
+
// local assistant, otherwise allocate fresh per-instance ports and directories.
|
|
228
|
+
let resources: LocalInstanceResources;
|
|
229
|
+
const existingEntry = findAssistantByName(instanceName);
|
|
230
|
+
if (existingEntry?.cloud === "local" && existingEntry.resources) {
|
|
231
|
+
resources = existingEntry.resources;
|
|
232
|
+
} else {
|
|
233
|
+
resources = await allocateLocalResources(instanceName);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Clean up stale workspace data: if the workspace directory already exists for
|
|
237
|
+
// this instance but no local lockfile entry owns it, a previous retire failed
|
|
238
|
+
// to archive it (or a managed-only retire left local data behind). Remove the
|
|
239
|
+
// workspace subtree so the new assistant starts fresh โ but preserve the rest
|
|
240
|
+
// of .vellum (e.g. protected/, credentials) which may be shared.
|
|
241
|
+
if (
|
|
242
|
+
!existingEntry ||
|
|
243
|
+
(existingEntry.cloud != null && existingEntry.cloud !== "local")
|
|
244
|
+
) {
|
|
245
|
+
const instanceWorkspaceDir = join(
|
|
246
|
+
resources.instanceDir,
|
|
247
|
+
".vellum",
|
|
248
|
+
"workspace",
|
|
249
|
+
);
|
|
250
|
+
if (existsSync(instanceWorkspaceDir)) {
|
|
251
|
+
const ownedByOther = loadAllAssistants().some((a) => {
|
|
252
|
+
if ((a.cloud != null && a.cloud !== "local") || !a.resources)
|
|
253
|
+
return false;
|
|
254
|
+
return (
|
|
255
|
+
join(a.resources.instanceDir, ".vellum", "workspace") ===
|
|
256
|
+
instanceWorkspaceDir
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
if (!ownedByOther) {
|
|
260
|
+
console.log(
|
|
261
|
+
`๐งน Removing stale workspace at ${instanceWorkspaceDir} (not owned by any assistant)...\n`,
|
|
262
|
+
);
|
|
263
|
+
rmSync(instanceWorkspaceDir, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const logsDir = join(
|
|
269
|
+
resources.instanceDir,
|
|
270
|
+
".vellum",
|
|
271
|
+
"workspace",
|
|
272
|
+
"data",
|
|
273
|
+
"logs",
|
|
274
|
+
);
|
|
275
|
+
archiveLogFile("hatch.log", logsDir);
|
|
276
|
+
resetLogFile("hatch.log");
|
|
277
|
+
|
|
278
|
+
console.log(`๐ฅ Hatching local assistant: ${instanceName}`);
|
|
279
|
+
console.log(` Species: ${species}`);
|
|
280
|
+
console.log("");
|
|
281
|
+
|
|
282
|
+
if (!process.env.APP_VERSION) {
|
|
283
|
+
process.env.APP_VERSION = cliPkg.version;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
emitProgress(3, 7, "Writing configuration...");
|
|
287
|
+
const defaultWorkspaceConfigPath = writeInitialConfig(configValues);
|
|
288
|
+
|
|
289
|
+
emitProgress(4, 7, "Starting assistant...");
|
|
290
|
+
const signingKey = generateLocalSigningKey();
|
|
291
|
+
await startLocalDaemon(watch, resources, {
|
|
292
|
+
defaultWorkspaceConfigPath,
|
|
293
|
+
signingKey,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
emitProgress(5, 7, "Starting gateway...");
|
|
297
|
+
let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
|
298
|
+
try {
|
|
299
|
+
runtimeUrl = await startGateway(watch, resources, { signingKey });
|
|
300
|
+
} catch (error) {
|
|
301
|
+
// Gateway failed โ stop the daemon we just started so we don't leave
|
|
302
|
+
// orphaned processes with no lock file entry.
|
|
303
|
+
console.error(
|
|
304
|
+
`\nโ Gateway startup failed โ stopping assistant to avoid orphaned processes.`,
|
|
305
|
+
);
|
|
306
|
+
await stopLocalProcesses(resources);
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Lease a guardian token so the desktop app can import it on first launch
|
|
311
|
+
// instead of hitting /v1/guardian/init itself.
|
|
312
|
+
emitProgress(6, 7, "Securing connection...");
|
|
313
|
+
try {
|
|
314
|
+
await leaseGuardianToken(runtimeUrl, instanceName);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
console.error(`โ ๏ธ Guardian token lease failed: ${err}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
|
|
320
|
+
// Set BASE_DATA_DIR so ngrok reads the correct instance config.
|
|
321
|
+
const prevBaseDataDir = process.env.BASE_DATA_DIR;
|
|
322
|
+
process.env.BASE_DATA_DIR = resources.instanceDir;
|
|
323
|
+
const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
|
|
324
|
+
if (ngrokChild?.pid) {
|
|
325
|
+
const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
|
|
326
|
+
writeFileSync(ngrokPidFile, String(ngrokChild.pid));
|
|
327
|
+
}
|
|
328
|
+
if (prevBaseDataDir !== undefined) {
|
|
329
|
+
process.env.BASE_DATA_DIR = prevBaseDataDir;
|
|
330
|
+
} else {
|
|
331
|
+
delete process.env.BASE_DATA_DIR;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const localEntry: AssistantEntry = {
|
|
335
|
+
assistantId: instanceName,
|
|
336
|
+
runtimeUrl,
|
|
337
|
+
localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
|
|
338
|
+
cloud: "local",
|
|
339
|
+
species,
|
|
340
|
+
hatchedAt: new Date().toISOString(),
|
|
341
|
+
serviceGroupVersion: cliPkg.version ? `v${cliPkg.version}` : undefined,
|
|
342
|
+
resources: { ...resources, signingKey },
|
|
343
|
+
};
|
|
344
|
+
emitProgress(7, 7, "Saving configuration...");
|
|
345
|
+
if (!restart) {
|
|
346
|
+
saveAssistantEntry(localEntry);
|
|
347
|
+
setActiveAssistant(instanceName);
|
|
348
|
+
syncConfigToLockfile();
|
|
349
|
+
|
|
350
|
+
if (process.env.VELLUM_DESKTOP_APP) {
|
|
351
|
+
installCLISymlink();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
console.log("");
|
|
355
|
+
console.log(`โ
Local assistant hatched!`);
|
|
356
|
+
console.log("");
|
|
357
|
+
console.log("Instance details:");
|
|
358
|
+
console.log(` Name: ${instanceName}`);
|
|
359
|
+
console.log(` Runtime: ${runtimeUrl}`);
|
|
360
|
+
console.log("");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (keepAlive) {
|
|
364
|
+
const healthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
|
|
365
|
+
const healthTarget = "Gateway";
|
|
366
|
+
const POLL_INTERVAL_MS = 5000;
|
|
367
|
+
const MAX_FAILURES = 3;
|
|
368
|
+
let consecutiveFailures = 0;
|
|
369
|
+
|
|
370
|
+
const shutdown = async (): Promise<void> => {
|
|
371
|
+
console.log("\nShutting down local processes...");
|
|
372
|
+
await stopLocalProcesses(resources);
|
|
373
|
+
process.exit(0);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
process.on("SIGTERM", () => void shutdown());
|
|
377
|
+
process.on("SIGINT", () => void shutdown());
|
|
378
|
+
|
|
379
|
+
// Poll the health endpoint until it stops responding.
|
|
380
|
+
while (true) {
|
|
381
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
382
|
+
try {
|
|
383
|
+
const res = await fetch(healthUrl, {
|
|
384
|
+
signal: AbortSignal.timeout(3000),
|
|
385
|
+
});
|
|
386
|
+
if (res.ok) {
|
|
387
|
+
consecutiveFailures = 0;
|
|
388
|
+
} else {
|
|
389
|
+
consecutiveFailures++;
|
|
390
|
+
}
|
|
391
|
+
} catch {
|
|
392
|
+
consecutiveFailures++;
|
|
393
|
+
}
|
|
394
|
+
if (consecutiveFailures >= MAX_FAILURES) {
|
|
395
|
+
console.log(
|
|
396
|
+
`\nโ ๏ธ ${healthTarget} stopped responding โ shutting down.`,
|
|
397
|
+
);
|
|
398
|
+
await stopLocalProcesses(resources);
|
|
399
|
+
process.exit(1);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|