@vellumai/cli 0.5.5 → 0.5.7
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/knip.json +4 -1
- package/package.json +1 -1
- package/src/__tests__/health-check.test.ts +26 -1
- package/src/commands/backup.ts +28 -13
- package/src/commands/hatch.ts +96 -60
- package/src/commands/ps.ts +6 -1
- package/src/commands/restore.ts +50 -30
- package/src/commands/retire.ts +5 -5
- package/src/commands/rollback.ts +443 -0
- package/src/commands/upgrade.ts +586 -120
- package/src/commands/wake.ts +8 -0
- package/src/index.ts +5 -0
- package/src/lib/assistant-config.ts +62 -7
- package/src/lib/aws.ts +2 -0
- package/src/lib/backup-ops.ts +213 -0
- package/src/lib/cli-error.ts +91 -0
- package/src/lib/config-utils.ts +59 -0
- package/src/lib/docker.ts +82 -10
- package/src/lib/doctor-client.ts +11 -1
- package/src/lib/gcp.ts +5 -1
- package/src/lib/guardian-token.ts +46 -1
- package/src/lib/health-check.ts +4 -0
- package/src/lib/local.ts +29 -9
- package/src/lib/platform-client.ts +19 -4
- package/src/lib/platform-releases.ts +112 -0
- package/src/lib/upgrade-lifecycle.ts +237 -0
- package/src/lib/version-compat.ts +45 -0
- package/src/lib/workspace-git.ts +39 -0
package/src/lib/docker.ts
CHANGED
|
@@ -12,11 +12,13 @@ import {
|
|
|
12
12
|
setActiveAssistant,
|
|
13
13
|
} from "./assistant-config";
|
|
14
14
|
import type { AssistantEntry } from "./assistant-config";
|
|
15
|
+
import { writeInitialConfig } from "./config-utils";
|
|
15
16
|
import { DEFAULT_GATEWAY_PORT, PROVIDER_ENV_VAR_NAMES } from "./constants";
|
|
16
17
|
import type { Species } from "./constants";
|
|
17
|
-
import { leaseGuardianToken } from "./guardian-token";
|
|
18
|
+
import { leaseGuardianToken, saveBootstrapSecret } from "./guardian-token";
|
|
18
19
|
import { isVellumProcess, stopProcess } from "./process";
|
|
19
20
|
import { generateInstanceName } from "./random-name";
|
|
21
|
+
import { resolveImageRefs } from "./platform-releases.js";
|
|
20
22
|
import { exec, execOutput } from "./step-runner";
|
|
21
23
|
import {
|
|
22
24
|
closeLogFile,
|
|
@@ -464,15 +466,19 @@ async function buildAllImages(
|
|
|
464
466
|
* can be restarted independently.
|
|
465
467
|
*/
|
|
466
468
|
export function serviceDockerRunArgs(opts: {
|
|
469
|
+
signingKey?: string;
|
|
470
|
+
bootstrapSecret?: string;
|
|
467
471
|
cesServiceToken?: string;
|
|
468
472
|
extraAssistantEnv?: Record<string, string>;
|
|
469
473
|
gatewayPort: number;
|
|
470
474
|
imageTags: Record<ServiceName, string>;
|
|
475
|
+
defaultWorkspaceConfigPath?: string;
|
|
471
476
|
instanceName: string;
|
|
472
477
|
res: ReturnType<typeof dockerResourceNames>;
|
|
473
478
|
}): Record<ServiceName, () => string[]> {
|
|
474
479
|
const {
|
|
475
480
|
cesServiceToken,
|
|
481
|
+
defaultWorkspaceConfigPath,
|
|
476
482
|
extraAssistantEnv,
|
|
477
483
|
gatewayPort,
|
|
478
484
|
imageTags,
|
|
@@ -495,6 +501,8 @@ export function serviceDockerRunArgs(opts: {
|
|
|
495
501
|
"-e",
|
|
496
502
|
`VELLUM_ASSISTANT_NAME=${instanceName}`,
|
|
497
503
|
"-e",
|
|
504
|
+
"VELLUM_CLOUD=docker",
|
|
505
|
+
"-e",
|
|
498
506
|
"RUNTIME_HTTP_HOST=0.0.0.0",
|
|
499
507
|
"-e",
|
|
500
508
|
"WORKSPACE_DIR=/workspace",
|
|
@@ -503,9 +511,21 @@ export function serviceDockerRunArgs(opts: {
|
|
|
503
511
|
"-e",
|
|
504
512
|
`GATEWAY_INTERNAL_URL=http://${res.gatewayContainer}:${GATEWAY_INTERNAL_PORT}`,
|
|
505
513
|
];
|
|
514
|
+
if (defaultWorkspaceConfigPath) {
|
|
515
|
+
const containerPath = `/tmp/vellum-default-workspace-config-${Date.now()}.json`;
|
|
516
|
+
args.push(
|
|
517
|
+
"-v",
|
|
518
|
+
`${defaultWorkspaceConfigPath}:${containerPath}:ro`,
|
|
519
|
+
"-e",
|
|
520
|
+
`VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH=${containerPath}`,
|
|
521
|
+
);
|
|
522
|
+
}
|
|
506
523
|
if (cesServiceToken) {
|
|
507
524
|
args.push("-e", `CES_SERVICE_TOKEN=${cesServiceToken}`);
|
|
508
525
|
}
|
|
526
|
+
if (opts.signingKey) {
|
|
527
|
+
args.push("-e", `ACTOR_TOKEN_SIGNING_KEY=${opts.signingKey}`);
|
|
528
|
+
}
|
|
509
529
|
for (const envVar of [
|
|
510
530
|
...Object.values(PROVIDER_ENV_VAR_NAMES),
|
|
511
531
|
"VELLUM_PLATFORM_URL",
|
|
@@ -552,6 +572,12 @@ export function serviceDockerRunArgs(opts: {
|
|
|
552
572
|
...(cesServiceToken
|
|
553
573
|
? ["-e", `CES_SERVICE_TOKEN=${cesServiceToken}`]
|
|
554
574
|
: []),
|
|
575
|
+
...(opts.signingKey
|
|
576
|
+
? ["-e", `ACTOR_TOKEN_SIGNING_KEY=${opts.signingKey}`]
|
|
577
|
+
: []),
|
|
578
|
+
...(opts.bootstrapSecret
|
|
579
|
+
? ["-e", `GUARDIAN_BOOTSTRAP_SECRET=${opts.bootstrapSecret}`]
|
|
580
|
+
: []),
|
|
555
581
|
imageTags.gateway,
|
|
556
582
|
],
|
|
557
583
|
"credential-executor": () => [
|
|
@@ -735,10 +761,13 @@ export const SERVICE_START_ORDER: ServiceName[] = [
|
|
|
735
761
|
/** Start all three containers in dependency order. */
|
|
736
762
|
export async function startContainers(
|
|
737
763
|
opts: {
|
|
764
|
+
signingKey?: string;
|
|
765
|
+
bootstrapSecret?: string;
|
|
738
766
|
cesServiceToken?: string;
|
|
739
767
|
extraAssistantEnv?: Record<string, string>;
|
|
740
768
|
gatewayPort: number;
|
|
741
769
|
imageTags: Record<ServiceName, string>;
|
|
770
|
+
defaultWorkspaceConfigPath?: string;
|
|
742
771
|
instanceName: string;
|
|
743
772
|
res: ReturnType<typeof dockerResourceNames>;
|
|
744
773
|
},
|
|
@@ -760,6 +789,7 @@ export async function stopContainers(
|
|
|
760
789
|
await removeContainer(res.assistantContainer);
|
|
761
790
|
}
|
|
762
791
|
|
|
792
|
+
|
|
763
793
|
/** Stop containers without removing them (preserves state for `docker start`). */
|
|
764
794
|
export async function sleepContainers(
|
|
765
795
|
res: ReturnType<typeof dockerResourceNames>,
|
|
@@ -771,8 +801,14 @@ export async function sleepContainers(
|
|
|
771
801
|
]) {
|
|
772
802
|
try {
|
|
773
803
|
await exec("docker", ["stop", container]);
|
|
774
|
-
} catch {
|
|
775
|
-
|
|
804
|
+
} catch (err) {
|
|
805
|
+
const msg =
|
|
806
|
+
err instanceof Error ? err.message.toLowerCase() : String(err);
|
|
807
|
+
if (msg.includes("no such container") || msg.includes("is not running")) {
|
|
808
|
+
// container doesn't exist or already stopped — expected, skip
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
throw err;
|
|
776
812
|
}
|
|
777
813
|
}
|
|
778
814
|
}
|
|
@@ -996,6 +1032,7 @@ export async function hatchDocker(
|
|
|
996
1032
|
detached: boolean,
|
|
997
1033
|
name: string | null,
|
|
998
1034
|
watch: boolean = false,
|
|
1035
|
+
configValues: Record<string, string> = {},
|
|
999
1036
|
): Promise<void> {
|
|
1000
1037
|
resetLogFile("hatch.log");
|
|
1001
1038
|
|
|
@@ -1042,14 +1079,16 @@ export async function hatchDocker(
|
|
|
1042
1079
|
} else {
|
|
1043
1080
|
const version = cliPkg.version;
|
|
1044
1081
|
const versionTag = version ? `v${version}` : "latest";
|
|
1045
|
-
|
|
1046
|
-
|
|
1082
|
+
log("🔍 Resolving image references...");
|
|
1083
|
+
const resolved = await resolveImageRefs(versionTag, log);
|
|
1084
|
+
imageTags.assistant = resolved.imageTags.assistant;
|
|
1085
|
+
imageTags.gateway = resolved.imageTags.gateway;
|
|
1047
1086
|
imageTags["credential-executor"] =
|
|
1048
|
-
|
|
1087
|
+
resolved.imageTags["credential-executor"];
|
|
1049
1088
|
|
|
1050
1089
|
log(`🥚 Hatching Docker assistant: ${instanceName}`);
|
|
1051
1090
|
log(` Species: ${species}`);
|
|
1052
|
-
log(` Images:`);
|
|
1091
|
+
log(` Images (${resolved.source}):`);
|
|
1053
1092
|
log(` assistant: ${imageTags.assistant}`);
|
|
1054
1093
|
log(` gateway: ${imageTags.gateway}`);
|
|
1055
1094
|
log(` credential-executor: ${imageTags["credential-executor"]}`);
|
|
@@ -1071,9 +1110,37 @@ export async function hatchDocker(
|
|
|
1071
1110
|
await exec("docker", ["volume", "create", res.cesSecurityVolume]);
|
|
1072
1111
|
await exec("docker", ["volume", "create", res.gatewaySecurityVolume]);
|
|
1073
1112
|
|
|
1113
|
+
// Set workspace volume ownership so non-root containers (UID 1001) can write.
|
|
1114
|
+
await exec("docker", [
|
|
1115
|
+
"run",
|
|
1116
|
+
"--rm",
|
|
1117
|
+
"-v",
|
|
1118
|
+
`${res.workspaceVolume}:/workspace`,
|
|
1119
|
+
"busybox",
|
|
1120
|
+
"chown",
|
|
1121
|
+
"1001:1001",
|
|
1122
|
+
"/workspace",
|
|
1123
|
+
]);
|
|
1124
|
+
|
|
1125
|
+
// Write --config key=value pairs to a temp file that gets bind-mounted
|
|
1126
|
+
// into the assistant container and read via VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH.
|
|
1127
|
+
const defaultWorkspaceConfigPath = writeInitialConfig(configValues);
|
|
1128
|
+
|
|
1074
1129
|
const cesServiceToken = randomBytes(32).toString("hex");
|
|
1130
|
+
const signingKey = randomBytes(32).toString("hex");
|
|
1131
|
+
const bootstrapSecret = randomBytes(32).toString("hex");
|
|
1132
|
+
saveBootstrapSecret(instanceName, bootstrapSecret);
|
|
1075
1133
|
await startContainers(
|
|
1076
|
-
{
|
|
1134
|
+
{
|
|
1135
|
+
signingKey,
|
|
1136
|
+
bootstrapSecret,
|
|
1137
|
+
cesServiceToken,
|
|
1138
|
+
gatewayPort,
|
|
1139
|
+
imageTags,
|
|
1140
|
+
defaultWorkspaceConfigPath,
|
|
1141
|
+
instanceName,
|
|
1142
|
+
res,
|
|
1143
|
+
},
|
|
1077
1144
|
log,
|
|
1078
1145
|
);
|
|
1079
1146
|
|
|
@@ -1252,7 +1319,9 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1252
1319
|
// Log periodically so the user knows we're still trying
|
|
1253
1320
|
const elapsed = ((Date.now() - leaseStart) / 1000).toFixed(0);
|
|
1254
1321
|
log(
|
|
1255
|
-
`Guardian token lease: attempt failed after ${elapsed}s (${
|
|
1322
|
+
`Guardian token lease: attempt failed after ${elapsed}s (${
|
|
1323
|
+
lastLeaseError.split("\n")[0]
|
|
1324
|
+
}), retrying...`,
|
|
1256
1325
|
);
|
|
1257
1326
|
}
|
|
1258
1327
|
await new Promise((r) => setTimeout(r, 2000));
|
|
@@ -1260,7 +1329,10 @@ async function waitForGatewayAndLease(opts: {
|
|
|
1260
1329
|
|
|
1261
1330
|
if (!leaseSuccess) {
|
|
1262
1331
|
log(
|
|
1263
|
-
`\u26a0\ufe0f Guardian token lease: FAILED after ${(
|
|
1332
|
+
`\u26a0\ufe0f Guardian token lease: FAILED after ${(
|
|
1333
|
+
(Date.now() - leaseStart) /
|
|
1334
|
+
1000
|
|
1335
|
+
).toFixed(1)}s — ${lastLeaseError ?? "unknown error"}`,
|
|
1264
1336
|
);
|
|
1265
1337
|
}
|
|
1266
1338
|
|
package/src/lib/doctor-client.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const DOCTOR_URL =
|
|
1
|
+
const DOCTOR_URL = process.env.DOCTOR_SERVICE_URL?.trim() || "";
|
|
2
2
|
|
|
3
3
|
export type ProgressPhase =
|
|
4
4
|
| "invoking_prompt"
|
|
@@ -107,6 +107,16 @@ async function callDoctorDaemon(
|
|
|
107
107
|
chatContext?: ChatLogEntry[],
|
|
108
108
|
onLog?: DoctorLogCallback,
|
|
109
109
|
): Promise<DoctorResult> {
|
|
110
|
+
if (!DOCTOR_URL) {
|
|
111
|
+
onLog?.("Doctor service not configured (DOCTOR_SERVICE_URL is not set)");
|
|
112
|
+
return {
|
|
113
|
+
assistantId,
|
|
114
|
+
diagnostics: null,
|
|
115
|
+
recommendation: null,
|
|
116
|
+
error: "Doctor service not configured",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
110
120
|
const MAX_RETRIES = 2;
|
|
111
121
|
let lastError: unknown;
|
|
112
122
|
|
package/src/lib/gcp.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "./constants";
|
|
12
12
|
import type { Species } from "./constants";
|
|
13
13
|
import { leaseGuardianToken } from "./guardian-token";
|
|
14
|
+
import { getPlatformUrl } from "./platform-client";
|
|
14
15
|
import { generateInstanceName } from "./random-name";
|
|
15
16
|
import { exec, execOutput } from "./step-runner";
|
|
16
17
|
|
|
@@ -455,6 +456,7 @@ export async function hatchGcp(
|
|
|
455
456
|
providerApiKeys: Record<string, string>,
|
|
456
457
|
instanceName: string,
|
|
457
458
|
cloud: "gcp",
|
|
459
|
+
configValues?: Record<string, string>,
|
|
458
460
|
) => Promise<string>,
|
|
459
461
|
watchHatching: (
|
|
460
462
|
pollFn: () => Promise<PollResult>,
|
|
@@ -462,6 +464,7 @@ export async function hatchGcp(
|
|
|
462
464
|
startTime: number,
|
|
463
465
|
species: Species,
|
|
464
466
|
) => Promise<WatchHatchingResult>,
|
|
467
|
+
configValues: Record<string, string> = {},
|
|
465
468
|
): Promise<void> {
|
|
466
469
|
const startTime = Date.now();
|
|
467
470
|
const account = process.env.GCP_ACCOUNT_EMAIL;
|
|
@@ -525,6 +528,7 @@ export async function hatchGcp(
|
|
|
525
528
|
providerApiKeys,
|
|
526
529
|
instanceName,
|
|
527
530
|
"gcp",
|
|
531
|
+
configValues,
|
|
528
532
|
);
|
|
529
533
|
const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
|
|
530
534
|
writeFileSync(startupScriptPath, startupScript);
|
|
@@ -640,7 +644,7 @@ export async function hatchGcp(
|
|
|
640
644
|
species === "vellum" &&
|
|
641
645
|
(await checkCurlFailure(instanceName, project, zone, account))
|
|
642
646
|
) {
|
|
643
|
-
const installScriptUrl = `${
|
|
647
|
+
const installScriptUrl = `${getPlatformUrl()}/install.sh`;
|
|
644
648
|
console.log(
|
|
645
649
|
`\ud83d\udd04 Detected install script curl failure for ${installScriptUrl}, attempting recovery...`,
|
|
646
650
|
);
|
|
@@ -42,6 +42,46 @@ function getPersistedDeviceIdPath(): string {
|
|
|
42
42
|
return join(getXdgConfigHome(), "vellum", "device-id");
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function getBootstrapSecretPath(assistantId: string): string {
|
|
46
|
+
return join(
|
|
47
|
+
getXdgConfigHome(),
|
|
48
|
+
"vellum",
|
|
49
|
+
"assistants",
|
|
50
|
+
assistantId,
|
|
51
|
+
"bootstrap-secret",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Load a previously saved bootstrap secret for the given assistant.
|
|
57
|
+
* Returns null if the file does not exist or is unreadable.
|
|
58
|
+
*/
|
|
59
|
+
export function loadBootstrapSecret(assistantId: string): string | null {
|
|
60
|
+
try {
|
|
61
|
+
const raw = readFileSync(getBootstrapSecretPath(assistantId), "utf-8").trim();
|
|
62
|
+
return raw.length > 0 ? raw : null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Persist a bootstrap secret for the given assistant so that the desktop
|
|
70
|
+
* client and upgrade/rollback paths can retrieve it later.
|
|
71
|
+
*/
|
|
72
|
+
export function saveBootstrapSecret(
|
|
73
|
+
assistantId: string,
|
|
74
|
+
secret: string,
|
|
75
|
+
): void {
|
|
76
|
+
const path = getBootstrapSecretPath(assistantId);
|
|
77
|
+
const dir = dirname(path);
|
|
78
|
+
if (!existsSync(dir)) {
|
|
79
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
80
|
+
}
|
|
81
|
+
writeFileSync(path, secret + "\n", { mode: 0o600 });
|
|
82
|
+
chmodSync(path, 0o600);
|
|
83
|
+
}
|
|
84
|
+
|
|
45
85
|
function hashWithSalt(input: string): string {
|
|
46
86
|
return createHash("sha256")
|
|
47
87
|
.update(input + DEVICE_ID_SALT)
|
|
@@ -168,9 +208,14 @@ export async function leaseGuardianToken(
|
|
|
168
208
|
assistantId: string,
|
|
169
209
|
): Promise<GuardianTokenData> {
|
|
170
210
|
const deviceId = computeDeviceId();
|
|
211
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
212
|
+
const bootstrapSecret = loadBootstrapSecret(assistantId);
|
|
213
|
+
if (bootstrapSecret) {
|
|
214
|
+
headers["x-bootstrap-secret"] = bootstrapSecret;
|
|
215
|
+
}
|
|
171
216
|
const response = await fetch(`${gatewayUrl}/v1/guardian/init`, {
|
|
172
217
|
method: "POST",
|
|
173
|
-
headers
|
|
218
|
+
headers,
|
|
174
219
|
body: JSON.stringify({ platform: "cli", deviceId }),
|
|
175
220
|
});
|
|
176
221
|
|
package/src/lib/health-check.ts
CHANGED
|
@@ -3,11 +3,13 @@ export const HEALTH_CHECK_TIMEOUT_MS = 1500;
|
|
|
3
3
|
interface HealthResponse {
|
|
4
4
|
status: string;
|
|
5
5
|
message?: string;
|
|
6
|
+
version?: string;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
export interface HealthCheckResult {
|
|
9
10
|
status: string;
|
|
10
11
|
detail: string | null;
|
|
12
|
+
version?: string;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export async function checkManagedHealth(
|
|
@@ -63,6 +65,7 @@ export async function checkManagedHealth(
|
|
|
63
65
|
return {
|
|
64
66
|
status,
|
|
65
67
|
detail: status !== "healthy" ? (data.message ?? null) : null,
|
|
68
|
+
version: data.version,
|
|
66
69
|
};
|
|
67
70
|
} catch (error) {
|
|
68
71
|
const status =
|
|
@@ -108,6 +111,7 @@ export async function checkHealth(
|
|
|
108
111
|
return {
|
|
109
112
|
status,
|
|
110
113
|
detail: status !== "healthy" ? (data.message ?? null) : null,
|
|
114
|
+
version: data.version,
|
|
111
115
|
};
|
|
112
116
|
} catch (error) {
|
|
113
117
|
const status =
|
package/src/lib/local.ts
CHANGED
|
@@ -192,10 +192,15 @@ function resolveDaemonMainPath(assistantIndex: string): string {
|
|
|
192
192
|
return join(dirname(assistantIndex), "daemon", "main.ts");
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
type DaemonStartOptions = {
|
|
196
|
+
foreground?: boolean;
|
|
197
|
+
defaultWorkspaceConfigPath?: string;
|
|
198
|
+
};
|
|
199
|
+
|
|
195
200
|
async function startDaemonFromSource(
|
|
196
201
|
assistantIndex: string,
|
|
197
202
|
resources: LocalInstanceResources,
|
|
198
|
-
options?:
|
|
203
|
+
options?: DaemonStartOptions,
|
|
199
204
|
): Promise<void> {
|
|
200
205
|
const foreground = options?.foreground ?? false;
|
|
201
206
|
const daemonMainPath = resolveDaemonMainPath(assistantIndex);
|
|
@@ -260,6 +265,7 @@ async function startDaemonFromSource(
|
|
|
260
265
|
const env: Record<string, string | undefined> = {
|
|
261
266
|
...process.env,
|
|
262
267
|
RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
|
|
268
|
+
VELLUM_CLOUD: "local",
|
|
263
269
|
};
|
|
264
270
|
if (resources) {
|
|
265
271
|
env.BASE_DATA_DIR = resources.instanceDir;
|
|
@@ -268,6 +274,10 @@ async function startDaemonFromSource(
|
|
|
268
274
|
env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
|
|
269
275
|
delete env.QDRANT_URL;
|
|
270
276
|
}
|
|
277
|
+
if (options?.defaultWorkspaceConfigPath) {
|
|
278
|
+
env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
|
|
279
|
+
options.defaultWorkspaceConfigPath;
|
|
280
|
+
}
|
|
271
281
|
|
|
272
282
|
// Write a sentinel PID file before spawning so concurrent hatch() calls
|
|
273
283
|
// detect the in-progress spawn and wait instead of racing.
|
|
@@ -306,6 +316,7 @@ async function startDaemonFromSource(
|
|
|
306
316
|
async function startDaemonWatchFromSource(
|
|
307
317
|
assistantIndex: string,
|
|
308
318
|
resources: LocalInstanceResources,
|
|
319
|
+
options?: DaemonStartOptions,
|
|
309
320
|
): Promise<void> {
|
|
310
321
|
const mainPath = resolveDaemonMainPath(assistantIndex);
|
|
311
322
|
if (!existsSync(mainPath)) {
|
|
@@ -381,6 +392,10 @@ async function startDaemonWatchFromSource(
|
|
|
381
392
|
env.QDRANT_HTTP_PORT = String(resources.qdrantPort);
|
|
382
393
|
delete env.QDRANT_URL;
|
|
383
394
|
}
|
|
395
|
+
if (options?.defaultWorkspaceConfigPath) {
|
|
396
|
+
env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
|
|
397
|
+
options.defaultWorkspaceConfigPath;
|
|
398
|
+
}
|
|
384
399
|
|
|
385
400
|
// Write a sentinel PID file before spawning so concurrent hatch() calls
|
|
386
401
|
// detect the in-progress spawn and wait instead of racing.
|
|
@@ -819,7 +834,7 @@ export function isGatewayWatchModeAvailable(): boolean {
|
|
|
819
834
|
export async function startLocalDaemon(
|
|
820
835
|
watch: boolean = false,
|
|
821
836
|
resources: LocalInstanceResources,
|
|
822
|
-
options?:
|
|
837
|
+
options?: DaemonStartOptions,
|
|
823
838
|
): Promise<void> {
|
|
824
839
|
const foreground = options?.foreground ?? false;
|
|
825
840
|
// Check for a compiled daemon binary adjacent to the CLI executable.
|
|
@@ -905,7 +920,7 @@ export async function startLocalDaemon(
|
|
|
905
920
|
|
|
906
921
|
// Build a minimal environment for the daemon. When launched from the
|
|
907
922
|
// macOS app the CLI inherits a huge environment (XPC_SERVICE_NAME,
|
|
908
|
-
// __CFBundleIdentifier,
|
|
923
|
+
// __CFBundleIdentifier, etc.) that can cause
|
|
909
924
|
// the daemon to take 50+ seconds to start instead of ~1s.
|
|
910
925
|
const home = homedir();
|
|
911
926
|
const bunBinDir = join(home, ".bun", "bin");
|
|
@@ -924,20 +939,25 @@ export async function startLocalDaemon(
|
|
|
924
939
|
"ANTHROPIC_API_KEY",
|
|
925
940
|
"APP_VERSION",
|
|
926
941
|
"BASE_DATA_DIR",
|
|
927
|
-
"
|
|
942
|
+
"VELLUM_PLATFORM_URL",
|
|
928
943
|
"QDRANT_HTTP_PORT",
|
|
929
944
|
"QDRANT_URL",
|
|
930
945
|
"RUNTIME_HTTP_PORT",
|
|
931
|
-
"
|
|
946
|
+
"SENTRY_DSN_ASSISTANT",
|
|
932
947
|
"TMPDIR",
|
|
933
948
|
"USER",
|
|
934
949
|
"LANG",
|
|
935
950
|
"VELLUM_DEBUG",
|
|
951
|
+
"VELLUM_DESKTOP_APP",
|
|
936
952
|
]) {
|
|
937
953
|
if (process.env[key]) {
|
|
938
954
|
daemonEnv[key] = process.env[key]!;
|
|
939
955
|
}
|
|
940
956
|
}
|
|
957
|
+
if (options?.defaultWorkspaceConfigPath) {
|
|
958
|
+
daemonEnv.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
|
|
959
|
+
options.defaultWorkspaceConfigPath;
|
|
960
|
+
}
|
|
941
961
|
// When running a named instance, override env so the daemon resolves
|
|
942
962
|
// all paths under the instance directory and listens on its own port.
|
|
943
963
|
if (resources) {
|
|
@@ -1005,9 +1025,9 @@ export async function startLocalDaemon(
|
|
|
1005
1025
|
// Kill the bundled daemon to avoid two processes competing for the same port
|
|
1006
1026
|
await stopProcessByPidFile(pidFile, "bundled daemon");
|
|
1007
1027
|
if (watch) {
|
|
1008
|
-
await startDaemonWatchFromSource(assistantIndex, resources);
|
|
1028
|
+
await startDaemonWatchFromSource(assistantIndex, resources, options);
|
|
1009
1029
|
} else {
|
|
1010
|
-
await startDaemonFromSource(assistantIndex, resources);
|
|
1030
|
+
await startDaemonFromSource(assistantIndex, resources, options);
|
|
1011
1031
|
}
|
|
1012
1032
|
daemonReady = await waitForDaemonReady(resources.daemonPort, 60000);
|
|
1013
1033
|
}
|
|
@@ -1031,7 +1051,7 @@ export async function startLocalDaemon(
|
|
|
1031
1051
|
);
|
|
1032
1052
|
}
|
|
1033
1053
|
if (watch) {
|
|
1034
|
-
await startDaemonWatchFromSource(assistantIndex, resources);
|
|
1054
|
+
await startDaemonWatchFromSource(assistantIndex, resources, options);
|
|
1035
1055
|
|
|
1036
1056
|
const daemonReady = await waitForDaemonReady(resources.daemonPort, 60000);
|
|
1037
1057
|
if (daemonReady) {
|
|
@@ -1042,7 +1062,7 @@ export async function startLocalDaemon(
|
|
|
1042
1062
|
);
|
|
1043
1063
|
}
|
|
1044
1064
|
} else {
|
|
1045
|
-
await startDaemonFromSource(assistantIndex, resources,
|
|
1065
|
+
await startDaemonFromSource(assistantIndex, resources, options);
|
|
1046
1066
|
|
|
1047
1067
|
const daemonReady = await waitForDaemonReady(resources.daemonPort, 60000);
|
|
1048
1068
|
if (daemonReady) {
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import { homedir } from "os";
|
|
10
10
|
import { join, dirname } from "path";
|
|
11
11
|
|
|
12
|
-
const DEFAULT_PLATFORM_URL = "
|
|
12
|
+
const DEFAULT_PLATFORM_URL = "";
|
|
13
13
|
|
|
14
14
|
function getXdgConfigHome(): string {
|
|
15
15
|
return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
@@ -23,6 +23,20 @@ export function getPlatformUrl(): string {
|
|
|
23
23
|
return process.env.VELLUM_PLATFORM_URL ?? DEFAULT_PLATFORM_URL;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Returns the platform URL, throwing a clear error if it is not configured.
|
|
28
|
+
* Use this in functions that need a valid URL to make HTTP requests.
|
|
29
|
+
*/
|
|
30
|
+
function requirePlatformUrl(): string {
|
|
31
|
+
const url = getPlatformUrl();
|
|
32
|
+
if (!url) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"VELLUM_PLATFORM_URL is not configured. Set it in your environment or .env file.",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return url;
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
export function readPlatformToken(): string | null {
|
|
27
41
|
try {
|
|
28
42
|
return readFileSync(getPlatformTokenPath(), "utf-8").trim();
|
|
@@ -60,14 +74,15 @@ interface OrganizationListResponse {
|
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
export async function fetchOrganizationId(token: string): Promise<string> {
|
|
63
|
-
const
|
|
77
|
+
const platformUrl = requirePlatformUrl();
|
|
78
|
+
const url = `${platformUrl}/v1/organizations/`;
|
|
64
79
|
const response = await fetch(url, {
|
|
65
80
|
headers: { "X-Session-Token": token },
|
|
66
81
|
});
|
|
67
82
|
|
|
68
83
|
if (!response.ok) {
|
|
69
84
|
throw new Error(
|
|
70
|
-
`Failed to fetch organizations (${response.status}). Try logging in again.`,
|
|
85
|
+
`Failed to fetch organizations from ${platformUrl} (${response.status}). Try logging in again.`,
|
|
71
86
|
);
|
|
72
87
|
}
|
|
73
88
|
|
|
@@ -91,7 +106,7 @@ interface AllauthSessionResponse {
|
|
|
91
106
|
}
|
|
92
107
|
|
|
93
108
|
export async function fetchCurrentUser(token: string): Promise<PlatformUser> {
|
|
94
|
-
const url = `${
|
|
109
|
+
const url = `${requirePlatformUrl()}/_allauth/app/v1/auth/session`;
|
|
95
110
|
const response = await fetch(url, {
|
|
96
111
|
headers: { "X-Session-Token": token },
|
|
97
112
|
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { getPlatformUrl } from "./platform-client.js";
|
|
2
|
+
import { DOCKERHUB_IMAGES } from "./docker.js";
|
|
3
|
+
import type { ServiceName } from "./docker.js";
|
|
4
|
+
|
|
5
|
+
export interface ResolvedImageRefs {
|
|
6
|
+
imageTags: Record<ServiceName, string>;
|
|
7
|
+
source: "platform" | "dockerhub";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve image references for a given version.
|
|
12
|
+
*
|
|
13
|
+
* Tries the platform API first (returns GCR digest-based refs when available),
|
|
14
|
+
* then falls back to DockerHub tag-based refs when the platform is unreachable
|
|
15
|
+
* or the version is not found.
|
|
16
|
+
*/
|
|
17
|
+
export async function resolveImageRefs(
|
|
18
|
+
version: string,
|
|
19
|
+
log?: (msg: string) => void,
|
|
20
|
+
): Promise<ResolvedImageRefs> {
|
|
21
|
+
log?.("Resolving image references...");
|
|
22
|
+
|
|
23
|
+
const platformRefs = await fetchPlatformImageRefs(version, log);
|
|
24
|
+
if (platformRefs) {
|
|
25
|
+
log?.("Resolved image refs from platform API");
|
|
26
|
+
return { imageTags: platformRefs, source: "platform" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
log?.("Falling back to DockerHub tags");
|
|
30
|
+
const imageTags: Record<ServiceName, string> = {
|
|
31
|
+
assistant: `${DOCKERHUB_IMAGES.assistant}:${version}`,
|
|
32
|
+
"credential-executor": `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`,
|
|
33
|
+
gateway: `${DOCKERHUB_IMAGES.gateway}:${version}`,
|
|
34
|
+
};
|
|
35
|
+
return { imageTags, source: "dockerhub" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fetch image references from the platform releases API.
|
|
40
|
+
*
|
|
41
|
+
* Returns a record of service name to image ref (GCR digest-based) for the
|
|
42
|
+
* given version, or null if the platform is unreachable, the version is not
|
|
43
|
+
* found, or any error occurs.
|
|
44
|
+
*/
|
|
45
|
+
async function fetchPlatformImageRefs(
|
|
46
|
+
version: string,
|
|
47
|
+
log?: (msg: string) => void,
|
|
48
|
+
): Promise<Record<ServiceName, string> | null> {
|
|
49
|
+
try {
|
|
50
|
+
const platformUrl = getPlatformUrl();
|
|
51
|
+
const url = `${platformUrl}/v1/releases/?stable=true`;
|
|
52
|
+
|
|
53
|
+
log?.(`Fetching releases from ${url}`);
|
|
54
|
+
|
|
55
|
+
const response = await fetch(url, {
|
|
56
|
+
signal: AbortSignal.timeout(10_000),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
log?.(`Platform API returned ${response.status}`);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const releases = (await response.json()) as Array<{
|
|
65
|
+
version?: string;
|
|
66
|
+
assistant_image_ref?: string | null;
|
|
67
|
+
gateway_image_ref?: string | null;
|
|
68
|
+
credential_executor_image_ref?: string | null;
|
|
69
|
+
}>;
|
|
70
|
+
|
|
71
|
+
// Strip leading "v" from the requested version for matching
|
|
72
|
+
const normalizedVersion = version.replace(/^v/, "");
|
|
73
|
+
|
|
74
|
+
const release = releases.find((r) => {
|
|
75
|
+
const releaseVersion = (r.version ?? "").replace(/^v/, "");
|
|
76
|
+
return releaseVersion === normalizedVersion;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (!release) {
|
|
80
|
+
log?.(`Version ${version} not found in platform releases`);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const assistantImage = release.assistant_image_ref;
|
|
85
|
+
const gatewayImage = release.gateway_image_ref;
|
|
86
|
+
let credentialExecutorImage = release.credential_executor_image_ref;
|
|
87
|
+
|
|
88
|
+
// Assistant and gateway images are required; credential-executor falls back to DockerHub
|
|
89
|
+
if (!assistantImage || !gatewayImage) {
|
|
90
|
+
log?.("Platform release missing required image refs");
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Fall back to DockerHub for credential-executor if its image ref is null
|
|
95
|
+
if (!credentialExecutorImage) {
|
|
96
|
+
credentialExecutorImage = `${DOCKERHUB_IMAGES["credential-executor"]}:${version}`;
|
|
97
|
+
log?.(
|
|
98
|
+
"credential-executor image not in platform release, using DockerHub fallback",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
assistant: assistantImage,
|
|
104
|
+
"credential-executor": credentialExecutorImage,
|
|
105
|
+
gateway: gatewayImage,
|
|
106
|
+
};
|
|
107
|
+
} catch (err) {
|
|
108
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
109
|
+
log?.(`Platform image ref resolution failed: ${message}`);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|