@vellumai/cli 0.4.55 → 0.4.56
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 +3 -70
- package/package.json +2 -3
- package/src/__tests__/random-name.test.ts +24 -5
- package/src/adapters/install.sh +1 -1
- package/src/adapters/openclaw.ts +6 -3
- package/src/commands/client.ts +2 -3
- package/src/commands/hatch.ts +78 -155
- package/src/commands/pair.ts +2 -2
- package/src/commands/retire.ts +31 -7
- package/src/commands/wake.ts +25 -6
- package/src/components/DefaultMainScreen.tsx +1 -1
- package/src/lib/assistant-config.ts +9 -2
- package/src/lib/aws.ts +11 -37
- package/src/lib/constants.ts +7 -0
- package/src/lib/docker.ts +634 -279
- package/src/lib/gcp.ts +15 -14
- package/src/lib/guardian-token.ts +174 -0
- package/src/lib/health-check.ts +6 -30
- package/src/lib/local.ts +150 -27
- package/src/lib/platform-client.ts +24 -0
- package/src/lib/process.ts +1 -1
- package/src/lib/random-name.ts +17 -1
- package/src/lib/jwt.ts +0 -62
- package/src/lib/policy.ts +0 -7
package/src/commands/hatch.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash, randomBytes, randomUUID } from "crypto";
|
|
2
1
|
import {
|
|
3
2
|
appendFileSync,
|
|
4
3
|
existsSync,
|
|
@@ -6,16 +5,14 @@ import {
|
|
|
6
5
|
mkdirSync,
|
|
7
6
|
readFileSync,
|
|
8
7
|
readlinkSync,
|
|
8
|
+
rmSync,
|
|
9
9
|
symlinkSync,
|
|
10
10
|
unlinkSync,
|
|
11
11
|
writeFileSync,
|
|
12
12
|
} from "fs";
|
|
13
|
-
import { homedir
|
|
13
|
+
import { homedir } from "os";
|
|
14
14
|
import { join } from "path";
|
|
15
15
|
|
|
16
|
-
import QRCode from "qrcode";
|
|
17
|
-
import qrcode from "qrcode-terminal";
|
|
18
|
-
|
|
19
16
|
// Direct import — bun embeds this at compile time so it works in compiled binaries.
|
|
20
17
|
import cliPkg from "../../package.json";
|
|
21
18
|
|
|
@@ -40,7 +37,6 @@ import {
|
|
|
40
37
|
} from "../lib/constants";
|
|
41
38
|
import type { RemoteHost, Species } from "../lib/constants";
|
|
42
39
|
import { hatchDocker } from "../lib/docker";
|
|
43
|
-
import { mintLocalBearerToken } from "../lib/jwt";
|
|
44
40
|
import { hatchGcp } from "../lib/gcp";
|
|
45
41
|
import type { PollResult, WatchHatchingResult } from "../lib/gcp";
|
|
46
42
|
import {
|
|
@@ -49,9 +45,10 @@ import {
|
|
|
49
45
|
stopLocalProcesses,
|
|
50
46
|
} from "../lib/local";
|
|
51
47
|
import { maybeStartNgrokTunnel } from "../lib/ngrok";
|
|
48
|
+
import { httpHealthCheck } from "../lib/http-client";
|
|
52
49
|
import { detectOrphanedProcesses } from "../lib/orphan-detection";
|
|
53
50
|
import { isProcessAlive, stopProcess } from "../lib/process";
|
|
54
|
-
import {
|
|
51
|
+
import { generateInstanceName } from "../lib/random-name";
|
|
55
52
|
import { validateAssistantName } from "../lib/retire-archive";
|
|
56
53
|
import { archiveLogFile, resetLogFile } from "../lib/xdg-log";
|
|
57
54
|
|
|
@@ -97,14 +94,12 @@ chown -R "$SSH_USER:$SSH_USER" "$SSH_USER_HOME" 2>/dev/null || true
|
|
|
97
94
|
|
|
98
95
|
export async function buildStartupScript(
|
|
99
96
|
species: Species,
|
|
100
|
-
bearerToken: string,
|
|
101
97
|
sshUser: string,
|
|
102
98
|
anthropicApiKey: string,
|
|
103
99
|
instanceName: string,
|
|
104
100
|
cloud: RemoteHost,
|
|
105
101
|
): Promise<string> {
|
|
106
|
-
const platformUrl =
|
|
107
|
-
process.env.VELLUM_PLATFORM_URL ?? "https://assistant.vellum.ai";
|
|
102
|
+
const platformUrl = process.env.VELLUM_PLATFORM_URL ?? "https://vellum.ai";
|
|
108
103
|
const logPath =
|
|
109
104
|
cloud === "custom"
|
|
110
105
|
? "/tmp/vellum-startup.log"
|
|
@@ -117,7 +112,6 @@ export async function buildStartupScript(
|
|
|
117
112
|
|
|
118
113
|
if (species === "openclaw") {
|
|
119
114
|
return await buildOpenclawStartupScript(
|
|
120
|
-
bearerToken,
|
|
121
115
|
sshUser,
|
|
122
116
|
anthropicApiKey,
|
|
123
117
|
timestampRedirect,
|
|
@@ -567,121 +561,6 @@ function installCLISymlink(): void {
|
|
|
567
561
|
);
|
|
568
562
|
}
|
|
569
563
|
|
|
570
|
-
async function waitForDaemonReady(
|
|
571
|
-
runtimeUrl: string,
|
|
572
|
-
bearerToken: string | undefined,
|
|
573
|
-
timeoutMs = 15000,
|
|
574
|
-
): Promise<boolean> {
|
|
575
|
-
const start = Date.now();
|
|
576
|
-
const pollInterval = 1000;
|
|
577
|
-
while (Date.now() - start < timeoutMs) {
|
|
578
|
-
try {
|
|
579
|
-
const res = await fetch(`${runtimeUrl}/v1/health`, {
|
|
580
|
-
method: "GET",
|
|
581
|
-
headers: bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {},
|
|
582
|
-
signal: AbortSignal.timeout(2000),
|
|
583
|
-
});
|
|
584
|
-
if (res.ok) return true;
|
|
585
|
-
} catch {
|
|
586
|
-
// Daemon not ready yet
|
|
587
|
-
}
|
|
588
|
-
await new Promise((r) => setTimeout(r, pollInterval));
|
|
589
|
-
}
|
|
590
|
-
return false;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
async function displayPairingQRCode(
|
|
594
|
-
runtimeUrl: string,
|
|
595
|
-
bearerToken: string | undefined,
|
|
596
|
-
/** External gateway URL for the QR payload. When omitted, runtimeUrl is used. */
|
|
597
|
-
externalGatewayUrl?: string,
|
|
598
|
-
): Promise<void> {
|
|
599
|
-
try {
|
|
600
|
-
const pairingRequestId = randomUUID();
|
|
601
|
-
const pairingSecret = randomBytes(32).toString("hex");
|
|
602
|
-
|
|
603
|
-
// The daemon's HTTP server may not be fully ready even though the gateway
|
|
604
|
-
// health check passed (the gateway is up, but the upstream daemon HTTP
|
|
605
|
-
// endpoint it proxies to may still be initializing). Poll the daemon's
|
|
606
|
-
// health endpoint through the gateway to ensure it's reachable.
|
|
607
|
-
const daemonReady = await waitForDaemonReady(runtimeUrl, bearerToken);
|
|
608
|
-
if (!daemonReady) {
|
|
609
|
-
console.warn(
|
|
610
|
-
"⚠ Assistant health check did not pass within 15s. Run `vellum pair` to try again.\n",
|
|
611
|
-
);
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
const registerRes = await fetch(`${runtimeUrl}/pairing/register`, {
|
|
616
|
-
method: "POST",
|
|
617
|
-
headers: {
|
|
618
|
-
"Content-Type": "application/json",
|
|
619
|
-
...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
|
|
620
|
-
},
|
|
621
|
-
body: JSON.stringify({
|
|
622
|
-
pairingRequestId,
|
|
623
|
-
pairingSecret,
|
|
624
|
-
gatewayUrl: externalGatewayUrl ?? runtimeUrl,
|
|
625
|
-
}),
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
if (!registerRes.ok) {
|
|
629
|
-
const body = await registerRes.text().catch(() => "");
|
|
630
|
-
console.warn(
|
|
631
|
-
`⚠ Could not register pairing request: ${registerRes.status} ${registerRes.statusText}${body ? ` — ${body}` : ""}. Run \`vellum pair\` to try again.\n`,
|
|
632
|
-
);
|
|
633
|
-
return;
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
const hostId = createHash("sha256")
|
|
637
|
-
.update(hostname() + userInfo().username)
|
|
638
|
-
.digest("hex");
|
|
639
|
-
const payload = JSON.stringify({
|
|
640
|
-
type: "vellum-daemon",
|
|
641
|
-
v: 4,
|
|
642
|
-
id: hostId,
|
|
643
|
-
g: externalGatewayUrl ?? runtimeUrl,
|
|
644
|
-
pairingRequestId,
|
|
645
|
-
pairingSecret,
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
const qrString = await new Promise<string>((resolve) => {
|
|
649
|
-
qrcode.generate(payload, { small: true }, (code: string) => {
|
|
650
|
-
resolve(code);
|
|
651
|
-
});
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
// Save QR code as PNG to a well-known location so it can be retrieved
|
|
655
|
-
// (e.g. via SCP) for pairing through the Desktop app.
|
|
656
|
-
const qrDir = join(homedir(), ".vellum", "pairing-qr");
|
|
657
|
-
mkdirSync(qrDir, { recursive: true });
|
|
658
|
-
const qrPngPath = join(qrDir, "initial.png");
|
|
659
|
-
try {
|
|
660
|
-
const pngBuffer = await QRCode.toBuffer(payload, {
|
|
661
|
-
type: "png",
|
|
662
|
-
width: 512,
|
|
663
|
-
});
|
|
664
|
-
writeFileSync(qrPngPath, pngBuffer);
|
|
665
|
-
console.log(`QR code PNG saved to ${qrPngPath}\n`);
|
|
666
|
-
} catch (pngErr) {
|
|
667
|
-
const pngReason =
|
|
668
|
-
pngErr instanceof Error ? pngErr.message : String(pngErr);
|
|
669
|
-
console.warn(`\u26A0 Could not save QR code PNG: ${pngReason}\n`);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
console.log("Scan this QR code with the Vellum iOS app to pair:\n");
|
|
673
|
-
console.log(qrString);
|
|
674
|
-
console.log("This pairing request expires in 5 minutes.");
|
|
675
|
-
console.log("Run `vellum pair` to generate a new one.\n");
|
|
676
|
-
} catch (err) {
|
|
677
|
-
// Non-fatal — pairing is optional
|
|
678
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
679
|
-
console.warn(
|
|
680
|
-
`⚠ Could not generate pairing QR code: ${reason}. Run \`vellum pair\` to try again.\n`,
|
|
681
|
-
);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
|
|
685
564
|
async function hatchLocal(
|
|
686
565
|
species: Species,
|
|
687
566
|
name: string | null,
|
|
@@ -697,13 +576,15 @@ async function hatchLocal(
|
|
|
697
576
|
process.exit(1);
|
|
698
577
|
}
|
|
699
578
|
|
|
700
|
-
const instanceName =
|
|
701
|
-
|
|
702
|
-
process.env.VELLUM_ASSISTANT_NAME
|
|
703
|
-
|
|
579
|
+
const instanceName = generateInstanceName(
|
|
580
|
+
species,
|
|
581
|
+
name ?? process.env.VELLUM_ASSISTANT_NAME,
|
|
582
|
+
);
|
|
704
583
|
|
|
705
584
|
// Clean up stale local state: if daemon/gateway processes are running but
|
|
706
|
-
// the lock file has no entries, stop them
|
|
585
|
+
// the lock file has no entries AND the daemon is not healthy, stop them
|
|
586
|
+
// before starting fresh. A healthy daemon should be reused, not killed —
|
|
587
|
+
// it may have been started intentionally via `vellum wake`.
|
|
707
588
|
const vellumDir = join(homedir(), ".vellum");
|
|
708
589
|
const existingAssistants = loadAllAssistants();
|
|
709
590
|
const localAssistants = existingAssistants.filter((a) => a.cloud === "local");
|
|
@@ -711,10 +592,16 @@ async function hatchLocal(
|
|
|
711
592
|
const daemonPid = isProcessAlive(join(vellumDir, "vellum.pid"));
|
|
712
593
|
const gatewayPid = isProcessAlive(join(vellumDir, "gateway.pid"));
|
|
713
594
|
if (daemonPid.alive || gatewayPid.alive) {
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
);
|
|
717
|
-
await
|
|
595
|
+
// Check if the daemon is actually healthy before killing it.
|
|
596
|
+
// Default port 7821 is used when there's no lockfile entry.
|
|
597
|
+
const defaultPort = parseInt(process.env.RUNTIME_HTTP_PORT || "7821", 10);
|
|
598
|
+
const healthy = await httpHealthCheck(defaultPort);
|
|
599
|
+
if (!healthy) {
|
|
600
|
+
console.log(
|
|
601
|
+
"🧹 Cleaning up stale local processes (no lock file entry)...\n",
|
|
602
|
+
);
|
|
603
|
+
await stopLocalProcesses();
|
|
604
|
+
}
|
|
718
605
|
}
|
|
719
606
|
}
|
|
720
607
|
|
|
@@ -722,17 +609,32 @@ async function hatchLocal(
|
|
|
722
609
|
// are not tracked by any PID file or lock file entry and kill them before
|
|
723
610
|
// starting new ones. This prevents resource leaks when the desktop app
|
|
724
611
|
// crashes or is force-quit without a clean shutdown.
|
|
612
|
+
//
|
|
613
|
+
// Skip orphan cleanup if the daemon is already healthy on the expected port
|
|
614
|
+
// — those processes are intentional (e.g. started via `vellum wake`) and
|
|
615
|
+
// startLocalDaemon() will reuse them.
|
|
725
616
|
if (IS_DESKTOP) {
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
617
|
+
const existingResources = findAssistantByName(instanceName);
|
|
618
|
+
const expectedPort =
|
|
619
|
+
existingResources?.cloud === "local" && existingResources.resources
|
|
620
|
+
? existingResources.resources.daemonPort
|
|
621
|
+
: undefined;
|
|
622
|
+
const daemonAlreadyHealthy = expectedPort
|
|
623
|
+
? await httpHealthCheck(expectedPort)
|
|
624
|
+
: false;
|
|
625
|
+
|
|
626
|
+
if (!daemonAlreadyHealthy) {
|
|
627
|
+
const orphans = await detectOrphanedProcesses();
|
|
628
|
+
if (orphans.length > 0) {
|
|
629
|
+
desktopLog(
|
|
630
|
+
`🧹 Found ${orphans.length} orphaned process${orphans.length === 1 ? "" : "es"} — cleaning up...`,
|
|
735
631
|
);
|
|
632
|
+
for (const orphan of orphans) {
|
|
633
|
+
await stopProcess(
|
|
634
|
+
parseInt(orphan.pid, 10),
|
|
635
|
+
`${orphan.name} (PID ${orphan.pid})`,
|
|
636
|
+
);
|
|
637
|
+
}
|
|
736
638
|
}
|
|
737
639
|
}
|
|
738
640
|
}
|
|
@@ -747,6 +649,38 @@ async function hatchLocal(
|
|
|
747
649
|
resources = await allocateLocalResources(instanceName);
|
|
748
650
|
}
|
|
749
651
|
|
|
652
|
+
// Clean up stale workspace data: if the workspace directory already exists for
|
|
653
|
+
// this instance but no local lockfile entry owns it, a previous retire failed
|
|
654
|
+
// to archive it (or a managed-only retire left local data behind). Remove the
|
|
655
|
+
// workspace subtree so the new assistant starts fresh — but preserve the rest
|
|
656
|
+
// of .vellum (e.g. protected/, credentials) which may be shared.
|
|
657
|
+
if (
|
|
658
|
+
!existingEntry ||
|
|
659
|
+
(existingEntry.cloud != null && existingEntry.cloud !== "local")
|
|
660
|
+
) {
|
|
661
|
+
const instanceWorkspaceDir = join(
|
|
662
|
+
resources.instanceDir,
|
|
663
|
+
".vellum",
|
|
664
|
+
"workspace",
|
|
665
|
+
);
|
|
666
|
+
if (existsSync(instanceWorkspaceDir)) {
|
|
667
|
+
const ownedByOther = loadAllAssistants().some((a) => {
|
|
668
|
+
if ((a.cloud != null && a.cloud !== "local") || !a.resources)
|
|
669
|
+
return false;
|
|
670
|
+
return (
|
|
671
|
+
join(a.resources.instanceDir, ".vellum", "workspace") ===
|
|
672
|
+
instanceWorkspaceDir
|
|
673
|
+
);
|
|
674
|
+
});
|
|
675
|
+
if (!ownedByOther) {
|
|
676
|
+
console.log(
|
|
677
|
+
`🧹 Removing stale workspace at ${instanceWorkspaceDir} (not owned by any assistant)...\n`,
|
|
678
|
+
);
|
|
679
|
+
rmSync(instanceWorkspaceDir, { recursive: true, force: true });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
750
684
|
const logsDir = join(
|
|
751
685
|
resources.instanceDir,
|
|
752
686
|
".vellum",
|
|
@@ -795,15 +729,10 @@ async function hatchLocal(
|
|
|
795
729
|
delete process.env.BASE_DATA_DIR;
|
|
796
730
|
}
|
|
797
731
|
|
|
798
|
-
// Mint a JWT from the signing key so the CLI can authenticate with the
|
|
799
|
-
// daemon/gateway (which requires auth by default).
|
|
800
|
-
const bearerToken = mintLocalBearerToken(resources.instanceDir);
|
|
801
|
-
|
|
802
732
|
const localEntry: AssistantEntry = {
|
|
803
733
|
assistantId: instanceName,
|
|
804
734
|
runtimeUrl,
|
|
805
735
|
localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
|
|
806
|
-
bearerToken,
|
|
807
736
|
cloud: "local",
|
|
808
737
|
species,
|
|
809
738
|
hatchedAt: new Date().toISOString(),
|
|
@@ -825,12 +754,6 @@ async function hatchLocal(
|
|
|
825
754
|
console.log(` Name: ${instanceName}`);
|
|
826
755
|
console.log(` Runtime: ${runtimeUrl}`);
|
|
827
756
|
console.log("");
|
|
828
|
-
|
|
829
|
-
// Use loopback for HTTP calls (health check + pairing register) since
|
|
830
|
-
// mDNS hostnames may not resolve on the local machine, but keep the
|
|
831
|
-
// external runtimeUrl in the QR payload so iOS devices can reach it.
|
|
832
|
-
const localGatewayUrl = `http://127.0.0.1:${resources.gatewayPort}`;
|
|
833
|
-
await displayPairingQRCode(localGatewayUrl, bearerToken, runtimeUrl);
|
|
834
757
|
}
|
|
835
758
|
|
|
836
759
|
if (keepAlive) {
|
package/src/commands/pair.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { PNG } from "pngjs";
|
|
|
7
7
|
import { saveAssistantEntry } from "../lib/assistant-config";
|
|
8
8
|
import type { AssistantEntry } from "../lib/assistant-config";
|
|
9
9
|
import type { Species } from "../lib/constants";
|
|
10
|
-
import {
|
|
10
|
+
import { generateInstanceName } from "../lib/random-name";
|
|
11
11
|
|
|
12
12
|
interface QRPairingPayload {
|
|
13
13
|
type: string;
|
|
@@ -119,7 +119,7 @@ export async function pair(): Promise<void> {
|
|
|
119
119
|
throw new Error("QR code does not contain valid Vellum pairing data.");
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
const instanceName =
|
|
122
|
+
const instanceName = generateInstanceName(species);
|
|
123
123
|
const runtimeUrl = payload.g;
|
|
124
124
|
const deviceId = getDeviceId();
|
|
125
125
|
const deviceName = hostname();
|
package/src/commands/retire.ts
CHANGED
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
removeAssistantEntry,
|
|
10
10
|
} from "../lib/assistant-config";
|
|
11
11
|
import type { AssistantEntry } from "../lib/assistant-config";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
fetchOrganizationId,
|
|
14
|
+
getPlatformUrl,
|
|
15
|
+
readPlatformToken,
|
|
16
|
+
} from "../lib/platform-client";
|
|
13
17
|
import { retireInstance as retireAwsInstance } from "../lib/aws";
|
|
14
18
|
import { retireDocker } from "../lib/docker";
|
|
15
19
|
import { retireInstance as retireGcpInstance } from "../lib/gcp";
|
|
@@ -84,6 +88,21 @@ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
|
|
|
84
88
|
const gatewayPidFile = join(vellumDir, "gateway.pid");
|
|
85
89
|
await stopProcessByPidFile(gatewayPidFile, "gateway", undefined, 7000);
|
|
86
90
|
|
|
91
|
+
// Stop Qdrant — the daemon's graceful shutdown tries to stop it via
|
|
92
|
+
// qdrantManager.stop(), but if the daemon was SIGKILL'd (after 2s timeout)
|
|
93
|
+
// Qdrant may still be running as an orphan. Check both the current PID file
|
|
94
|
+
// location and the legacy location.
|
|
95
|
+
const qdrantPidFile = join(
|
|
96
|
+
vellumDir,
|
|
97
|
+
"workspace",
|
|
98
|
+
"data",
|
|
99
|
+
"qdrant",
|
|
100
|
+
"qdrant.pid",
|
|
101
|
+
);
|
|
102
|
+
const qdrantLegacyPidFile = join(vellumDir, "qdrant.pid");
|
|
103
|
+
await stopProcessByPidFile(qdrantPidFile, "qdrant", undefined, 5000);
|
|
104
|
+
await stopProcessByPidFile(qdrantLegacyPidFile, "qdrant", undefined, 5000);
|
|
105
|
+
|
|
87
106
|
// If the PID file didn't track a running daemon, scan for orphaned
|
|
88
107
|
// daemon processes that may have been started without writing a PID.
|
|
89
108
|
if (!daemonStopped) {
|
|
@@ -116,12 +135,12 @@ async function retireLocal(name: string, entry: AssistantEntry): Promise<void> {
|
|
|
116
135
|
try {
|
|
117
136
|
renameSync(dirToArchive, stagingDir);
|
|
118
137
|
} catch (err) {
|
|
119
|
-
|
|
120
|
-
|
|
138
|
+
// Re-throw so the caller (and the desktop app) knows the archive failed.
|
|
139
|
+
// If the rename fails, old workspace data stays in place and a subsequent
|
|
140
|
+
// hatch would inherit stale SOUL.md, IDENTITY.md, and memories.
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Failed to archive ${dirToArchive}: ${err instanceof Error ? err.message : err}`,
|
|
121
143
|
);
|
|
122
|
-
console.warn("Skipping archive.");
|
|
123
|
-
console.log("\u2705 Local instance retired.");
|
|
124
|
-
return;
|
|
125
144
|
}
|
|
126
145
|
|
|
127
146
|
writeFileSync(metadataPath, JSON.stringify(entry, null, 2) + "\n");
|
|
@@ -189,10 +208,15 @@ async function retireVellum(assistantId: string): Promise<void> {
|
|
|
189
208
|
process.exit(1);
|
|
190
209
|
}
|
|
191
210
|
|
|
211
|
+
const orgId = await fetchOrganizationId(token);
|
|
212
|
+
|
|
192
213
|
const url = `${getPlatformUrl()}/v1/assistants/${encodeURIComponent(assistantId)}/retire/`;
|
|
193
214
|
const response = await fetch(url, {
|
|
194
215
|
method: "DELETE",
|
|
195
|
-
headers: {
|
|
216
|
+
headers: {
|
|
217
|
+
"X-Session-Token": token,
|
|
218
|
+
"Vellum-Organization-Id": orgId,
|
|
219
|
+
},
|
|
196
220
|
});
|
|
197
221
|
|
|
198
222
|
if (!response.ok) {
|
package/src/commands/wake.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { join } from "path";
|
|
|
4
4
|
import { resolveTargetAssistant } from "../lib/assistant-config.js";
|
|
5
5
|
import { isProcessAlive, stopProcessByPidFile } from "../lib/process";
|
|
6
6
|
import {
|
|
7
|
-
|
|
7
|
+
isAssistantWatchModeAvailable,
|
|
8
|
+
isGatewayWatchModeAvailable,
|
|
8
9
|
startLocalDaemon,
|
|
9
10
|
startGateway,
|
|
10
11
|
} from "../lib/local";
|
|
@@ -24,12 +25,16 @@ export async function wake(): Promise<void> {
|
|
|
24
25
|
console.log("");
|
|
25
26
|
console.log("Options:");
|
|
26
27
|
console.log(
|
|
27
|
-
" --watch
|
|
28
|
+
" --watch Run assistant and gateway in watch mode (hot reload on source changes)",
|
|
29
|
+
);
|
|
30
|
+
console.log(
|
|
31
|
+
" --foreground Run assistant in foreground with logs printed to terminal",
|
|
28
32
|
);
|
|
29
33
|
process.exit(0);
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
const watch = args.includes("--watch");
|
|
37
|
+
const foreground = args.includes("--foreground");
|
|
33
38
|
const nameArg = args.find((a) => !a.startsWith("-"));
|
|
34
39
|
const entry = resolveTargetAssistant(nameArg);
|
|
35
40
|
|
|
@@ -64,7 +69,7 @@ export async function wake(): Promise<void> {
|
|
|
64
69
|
// Watch mode requires bun --watch with .ts sources; packaged desktop
|
|
65
70
|
// builds only have a compiled binary. Stopping the daemon without a
|
|
66
71
|
// viable watch-mode path would leave the user with no running assistant.
|
|
67
|
-
if (!
|
|
72
|
+
if (!isAssistantWatchModeAvailable()) {
|
|
68
73
|
console.log(
|
|
69
74
|
`Assistant running (pid ${pid}) — watch mode not available (no source files). Keeping existing process.`,
|
|
70
75
|
);
|
|
@@ -85,7 +90,7 @@ export async function wake(): Promise<void> {
|
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
if (!daemonRunning) {
|
|
88
|
-
await startLocalDaemon(watch, resources);
|
|
93
|
+
await startLocalDaemon(watch, resources, { foreground });
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
// Start gateway
|
|
@@ -95,8 +100,8 @@ export async function wake(): Promise<void> {
|
|
|
95
100
|
const { alive, pid } = isProcessAlive(gatewayPidFile);
|
|
96
101
|
if (alive) {
|
|
97
102
|
if (watch) {
|
|
98
|
-
//
|
|
99
|
-
if (!
|
|
103
|
+
// Guard gateway restart separately: check gateway source availability.
|
|
104
|
+
if (!isGatewayWatchModeAvailable()) {
|
|
100
105
|
console.log(
|
|
101
106
|
`Gateway running (pid ${pid}) — watch mode not available (no source files). Keeping existing process.`,
|
|
102
107
|
);
|
|
@@ -131,4 +136,18 @@ export async function wake(): Promise<void> {
|
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
console.log("Wake complete.");
|
|
139
|
+
|
|
140
|
+
if (foreground) {
|
|
141
|
+
console.log("Running in foreground (Ctrl+C to stop)...\n");
|
|
142
|
+
// Block forever — the daemon is running with inherited stdio so its
|
|
143
|
+
// output streams to this terminal. When the user hits Ctrl+C, SIGINT
|
|
144
|
+
// propagates to the daemon child and both exit.
|
|
145
|
+
await new Promise<void>((resolve) => {
|
|
146
|
+
process.on("SIGINT", () => {
|
|
147
|
+
console.log("\nShutting down...");
|
|
148
|
+
resolve();
|
|
149
|
+
});
|
|
150
|
+
process.on("SIGTERM", () => resolve());
|
|
151
|
+
});
|
|
152
|
+
}
|
|
134
153
|
}
|
|
@@ -3,6 +3,7 @@ import { homedir } from "os";
|
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
+
DAEMON_INTERNAL_ASSISTANT_ID,
|
|
6
7
|
DEFAULT_DAEMON_PORT,
|
|
7
8
|
DEFAULT_GATEWAY_PORT,
|
|
8
9
|
DEFAULT_QDRANT_PORT,
|
|
@@ -49,6 +50,8 @@ export interface AssistantEntry {
|
|
|
49
50
|
sshUser?: string;
|
|
50
51
|
zone?: string;
|
|
51
52
|
hatchedAt?: string;
|
|
53
|
+
/** Name of the shared volume backing BASE_DATA_DIR for containerised instances. */
|
|
54
|
+
volume?: string;
|
|
52
55
|
/** Per-instance resource config. Present for local entries in multi-instance setups. */
|
|
53
56
|
resources?: LocalInstanceResources;
|
|
54
57
|
[key: string]: unknown;
|
|
@@ -152,7 +155,9 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
152
155
|
"share",
|
|
153
156
|
"vellum",
|
|
154
157
|
"assistants",
|
|
155
|
-
typeof raw.assistantId === "string"
|
|
158
|
+
typeof raw.assistantId === "string"
|
|
159
|
+
? raw.assistantId
|
|
160
|
+
: DAEMON_INTERNAL_ASSISTANT_ID,
|
|
156
161
|
);
|
|
157
162
|
raw.resources = {
|
|
158
163
|
instanceDir,
|
|
@@ -172,7 +177,9 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
172
177
|
"share",
|
|
173
178
|
"vellum",
|
|
174
179
|
"assistants",
|
|
175
|
-
typeof raw.assistantId === "string"
|
|
180
|
+
typeof raw.assistantId === "string"
|
|
181
|
+
? raw.assistantId
|
|
182
|
+
: DAEMON_INTERNAL_ASSISTANT_ID,
|
|
176
183
|
);
|
|
177
184
|
mutated = true;
|
|
178
185
|
}
|
package/src/lib/aws.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { randomBytes } from "crypto";
|
|
2
1
|
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "fs";
|
|
3
2
|
import { homedir, tmpdir, userInfo } from "os";
|
|
4
3
|
import { join } from "path";
|
|
@@ -9,7 +8,8 @@ import { saveAssistantEntry, setActiveAssistant } from "./assistant-config";
|
|
|
9
8
|
import type { AssistantEntry } from "./assistant-config";
|
|
10
9
|
import { GATEWAY_PORT } from "./constants";
|
|
11
10
|
import type { Species } from "./constants";
|
|
12
|
-
import {
|
|
11
|
+
import { leaseGuardianToken } from "./guardian-token";
|
|
12
|
+
import { generateInstanceName } from "./random-name";
|
|
13
13
|
import { exec, execOutput } from "./step-runner";
|
|
14
14
|
|
|
15
15
|
const KEY_PAIR_NAME = "vellum-assistant";
|
|
@@ -370,28 +370,6 @@ 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
|
-
|
|
395
373
|
export async function hatchAws(
|
|
396
374
|
species: Species,
|
|
397
375
|
detached: boolean,
|
|
@@ -405,12 +383,7 @@ export async function hatchAws(
|
|
|
405
383
|
(await getActiveRegion().catch(() => AWS_DEFAULT_REGION));
|
|
406
384
|
let instanceName: string;
|
|
407
385
|
|
|
408
|
-
|
|
409
|
-
instanceName = name;
|
|
410
|
-
} else {
|
|
411
|
-
const suffix = generateRandomSuffix();
|
|
412
|
-
instanceName = `${species}-${suffix}`;
|
|
413
|
-
}
|
|
386
|
+
instanceName = generateInstanceName(species, name);
|
|
414
387
|
|
|
415
388
|
console.log(`\u{1F95A} Creating new assistant: ${instanceName}`);
|
|
416
389
|
console.log(` Species: ${species}`);
|
|
@@ -431,13 +404,11 @@ export async function hatchAws(
|
|
|
431
404
|
console.log(
|
|
432
405
|
`\u26a0\ufe0f Instance name ${instanceName} already exists, generating a new name...`,
|
|
433
406
|
);
|
|
434
|
-
|
|
435
|
-
instanceName = `${species}-${suffix}`;
|
|
407
|
+
instanceName = generateInstanceName(species);
|
|
436
408
|
}
|
|
437
409
|
}
|
|
438
410
|
|
|
439
411
|
const sshUser = userInfo().username;
|
|
440
|
-
const bearerToken = randomBytes(32).toString("hex");
|
|
441
412
|
const hatchedBy = process.env.VELLUM_HATCHED_BY;
|
|
442
413
|
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
|
443
414
|
if (!anthropicApiKey) {
|
|
@@ -465,7 +436,6 @@ export async function hatchAws(
|
|
|
465
436
|
|
|
466
437
|
const startupScript = await buildStartupScript(
|
|
467
438
|
species,
|
|
468
|
-
bearerToken,
|
|
469
439
|
sshUser,
|
|
470
440
|
anthropicApiKey,
|
|
471
441
|
instanceName,
|
|
@@ -558,10 +528,14 @@ export async function hatchAws(
|
|
|
558
528
|
process.exit(1);
|
|
559
529
|
}
|
|
560
530
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
awsEntry.bearerToken =
|
|
531
|
+
try {
|
|
532
|
+
const tokenData = await leaseGuardianToken(runtimeUrl, instanceName);
|
|
533
|
+
awsEntry.bearerToken = tokenData.accessToken;
|
|
564
534
|
saveAssistantEntry(awsEntry);
|
|
535
|
+
} catch (err) {
|
|
536
|
+
console.warn(
|
|
537
|
+
`\u26a0\ufe0f Could not lease guardian token: ${err instanceof Error ? err.message : err}`,
|
|
538
|
+
);
|
|
565
539
|
}
|
|
566
540
|
} else {
|
|
567
541
|
console.log(
|
package/src/lib/constants.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical internal assistant ID used as the default/fallback across the CLI
|
|
3
|
+
* and daemon. Mirrors `DAEMON_INTERNAL_ASSISTANT_ID` from
|
|
4
|
+
* `assistant/src/runtime/assistant-scope.ts`.
|
|
5
|
+
*/
|
|
6
|
+
export const DAEMON_INTERNAL_ASSISTANT_ID = "self" as const;
|
|
7
|
+
|
|
1
8
|
export const FIREWALL_TAG = "vellum-assistant";
|
|
2
9
|
export const GATEWAY_PORT = process.env.GATEWAY_PORT
|
|
3
10
|
? Number(process.env.GATEWAY_PORT)
|