@vellumai/cli 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +12 -2
- package/README.md +3 -3
- package/bunfig.toml +6 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +124 -0
- package/src/__tests__/env-drift.test.ts +87 -0
- package/src/__tests__/guardian-token.test.ts +172 -0
- package/src/__tests__/multi-local.test.ts +61 -14
- package/src/__tests__/orphan-detection.test.ts +214 -0
- package/src/__tests__/platform-client.test.ts +204 -0
- package/src/__tests__/preload.ts +27 -0
- package/src/__tests__/ssh-user-guard.test.ts +28 -0
- package/src/__tests__/teleport.test.ts +1073 -57
- package/src/commands/backup.ts +8 -0
- package/src/commands/hatch.ts +5 -28
- package/src/commands/login.ts +178 -9
- package/src/commands/logs.ts +652 -0
- package/src/commands/pair.ts +9 -1
- package/src/commands/ps.ts +37 -7
- package/src/commands/recover.ts +8 -4
- package/src/commands/restore.ts +124 -12
- package/src/commands/retire.ts +17 -3
- package/src/commands/rollback.ts +32 -33
- package/src/commands/sleep.ts +7 -0
- package/src/commands/ssh-apple-container.ts +162 -0
- package/src/commands/ssh.ts +7 -0
- package/src/commands/teleport.ts +307 -3
- package/src/commands/upgrade.ts +43 -52
- package/src/commands/wake.ts +21 -10
- package/src/components/DefaultMainScreen.tsx +7 -1
- package/src/index.ts +3 -0
- package/src/lib/__tests__/docker.test.ts +78 -0
- package/src/lib/assistant-config.ts +54 -87
- package/src/lib/aws.ts +12 -1
- package/src/lib/constants.ts +0 -10
- package/src/lib/docker.ts +73 -4
- package/src/lib/environments/__tests__/paths.test.ts +234 -0
- package/src/lib/environments/__tests__/resolve.test.ts +226 -0
- package/src/lib/environments/paths.ts +110 -0
- package/src/lib/environments/resolve.ts +96 -0
- package/src/lib/environments/seeds.ts +46 -0
- package/src/lib/environments/types.ts +60 -0
- package/src/lib/gcp.ts +12 -1
- package/src/lib/guardian-token.ts +8 -10
- package/src/lib/hatch-local.ts +30 -35
- package/src/lib/local.ts +46 -5
- package/src/lib/orphan-detection.ts +28 -12
- package/src/lib/platform-client.ts +261 -25
- package/src/lib/retire-apple-container.ts +102 -0
- package/src/lib/upgrade-lifecycle.ts +101 -28
package/src/commands/upgrade.ts
CHANGED
|
@@ -189,18 +189,9 @@ async function upgradeDocker(
|
|
|
189
189
|
const versionTag =
|
|
190
190
|
version ?? (cliPkg.version ? `v${cliPkg.version}` : "latest");
|
|
191
191
|
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
if (currentVersion && versionTag) {
|
|
196
|
-
const cmp = compareVersions(versionTag, currentVersion);
|
|
197
|
-
if (cmp !== null && cmp < 0) {
|
|
198
|
-
const msg = `Cannot upgrade to an older version (${versionTag} < ${currentVersion}). Use \`vellum rollback --version ${versionTag}\` instead.`;
|
|
199
|
-
console.error(msg);
|
|
200
|
-
emitCliError("VERSION_DIRECTION", msg);
|
|
201
|
-
process.exit(1);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
192
|
+
// Fetch the current running version from the health endpoint.
|
|
193
|
+
// This is used for logging, commit messages, and version-direction guards.
|
|
194
|
+
let currentVersion: string | undefined;
|
|
204
195
|
|
|
205
196
|
console.log("🔍 Resolving image references...");
|
|
206
197
|
const { imageTags } = await resolveImageRefs(versionTag);
|
|
@@ -225,7 +216,7 @@ async function upgradeDocker(
|
|
|
225
216
|
);
|
|
226
217
|
}
|
|
227
218
|
|
|
228
|
-
// Capture current migration state for rollback targeting.
|
|
219
|
+
// Capture current migration state and running version for rollback targeting.
|
|
229
220
|
// Must happen while daemon is still running (before containers are stopped).
|
|
230
221
|
let preMigrationState: {
|
|
231
222
|
dbVersion?: number;
|
|
@@ -240,26 +231,47 @@ async function upgradeDocker(
|
|
|
240
231
|
);
|
|
241
232
|
if (healthResp.ok) {
|
|
242
233
|
const health = (await healthResp.json()) as {
|
|
234
|
+
version?: string;
|
|
243
235
|
migrations?: { dbVersion?: number; lastWorkspaceMigrationId?: string };
|
|
244
236
|
};
|
|
245
237
|
preMigrationState = health.migrations ?? {};
|
|
238
|
+
currentVersion = health.version;
|
|
246
239
|
}
|
|
247
240
|
} catch {
|
|
248
241
|
// Best-effort — if we can't get migration state, rollback will skip migration reversal
|
|
249
242
|
}
|
|
250
243
|
|
|
244
|
+
// Reject downgrades — `vellum upgrade` only handles forward version changes.
|
|
245
|
+
// Users should use `vellum rollback --version <version>` for downgrades.
|
|
246
|
+
if (!currentVersion && versionTag) {
|
|
247
|
+
console.warn(
|
|
248
|
+
"⚠️ Could not determine current version from health endpoint — skipping version-direction check.\n",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (currentVersion && versionTag) {
|
|
252
|
+
const cmp = compareVersions(versionTag, currentVersion);
|
|
253
|
+
if (cmp !== null && cmp < 0) {
|
|
254
|
+
const msg = `Cannot upgrade to an older version (${versionTag} < ${currentVersion}). Use \`vellum rollback --version ${versionTag}\` instead.`;
|
|
255
|
+
console.error(msg);
|
|
256
|
+
emitCliError("VERSION_DIRECTION", msg);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
251
261
|
// Persist rollback state to lockfile BEFORE any destructive changes.
|
|
252
262
|
// This enables the `vellum rollback` command to restore the previous version.
|
|
253
|
-
if (entry.
|
|
263
|
+
if (entry.containerInfo) {
|
|
254
264
|
const rollbackEntry: AssistantEntry = {
|
|
255
265
|
...entry,
|
|
256
|
-
previousServiceGroupVersion: entry.serviceGroupVersion,
|
|
257
266
|
previousContainerInfo: { ...entry.containerInfo },
|
|
267
|
+
previousVersion: currentVersion,
|
|
258
268
|
previousDbMigrationVersion: preMigrationState.dbVersion,
|
|
259
269
|
previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
|
|
260
270
|
};
|
|
261
271
|
saveAssistantEntry(rollbackEntry);
|
|
262
|
-
|
|
272
|
+
if (currentVersion) {
|
|
273
|
+
console.log(` Saved rollback state: ${currentVersion}\n`);
|
|
274
|
+
}
|
|
263
275
|
}
|
|
264
276
|
|
|
265
277
|
// Record version transition start in workspace git history
|
|
@@ -269,7 +281,7 @@ async function upgradeDocker(
|
|
|
269
281
|
buildUpgradeCommitMessage({
|
|
270
282
|
action: "upgrade",
|
|
271
283
|
phase: "starting",
|
|
272
|
-
from:
|
|
284
|
+
from: currentVersion ?? "unknown",
|
|
273
285
|
to: versionTag,
|
|
274
286
|
topology: "docker",
|
|
275
287
|
assistantId: entry.assistantId,
|
|
@@ -321,7 +333,7 @@ async function upgradeDocker(
|
|
|
321
333
|
await broadcastUpgradeEvent(
|
|
322
334
|
entry.runtimeUrl,
|
|
323
335
|
entry.assistantId,
|
|
324
|
-
buildCompleteEvent(
|
|
336
|
+
buildCompleteEvent(currentVersion ?? "unknown", false),
|
|
325
337
|
);
|
|
326
338
|
emitCliError("IMAGE_PULL_FAILED", "Failed to pull Docker images", detail);
|
|
327
339
|
process.exit(1);
|
|
@@ -361,7 +373,7 @@ async function upgradeDocker(
|
|
|
361
373
|
console.log("📦 Creating pre-upgrade backup...");
|
|
362
374
|
const backupPath = await createBackup(entry.runtimeUrl, entry.assistantId, {
|
|
363
375
|
prefix: `${entry.assistantId}-pre-upgrade`,
|
|
364
|
-
description: `Pre-upgrade snapshot before ${
|
|
376
|
+
description: `Pre-upgrade snapshot before ${currentVersion ?? "unknown"} → ${versionTag}`,
|
|
365
377
|
});
|
|
366
378
|
if (backupPath) {
|
|
367
379
|
console.log(` Backup saved: ${backupPath}\n`);
|
|
@@ -434,7 +446,6 @@ async function upgradeDocker(
|
|
|
434
446
|
const newDigests = await captureImageRefs(res);
|
|
435
447
|
const updatedEntry: AssistantEntry = {
|
|
436
448
|
...entry,
|
|
437
|
-
serviceGroupVersion: versionTag,
|
|
438
449
|
containerInfo: {
|
|
439
450
|
assistantImage: imageTags.assistant,
|
|
440
451
|
gatewayImage: imageTags.gateway,
|
|
@@ -444,7 +455,6 @@ async function upgradeDocker(
|
|
|
444
455
|
cesDigest: newDigests?.["credential-executor"],
|
|
445
456
|
networkName: res.network,
|
|
446
457
|
},
|
|
447
|
-
previousServiceGroupVersion: entry.serviceGroupVersion,
|
|
448
458
|
previousContainerInfo: entry.containerInfo,
|
|
449
459
|
previousDbMigrationVersion: preMigrationState.dbVersion,
|
|
450
460
|
previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
|
|
@@ -467,7 +477,7 @@ async function upgradeDocker(
|
|
|
467
477
|
buildUpgradeCommitMessage({
|
|
468
478
|
action: "upgrade",
|
|
469
479
|
phase: "complete",
|
|
470
|
-
from:
|
|
480
|
+
from: currentVersion ?? "unknown",
|
|
471
481
|
to: versionTag,
|
|
472
482
|
topology: "docker",
|
|
473
483
|
assistantId: entry.assistantId,
|
|
@@ -584,7 +594,6 @@ async function upgradeDocker(
|
|
|
584
594
|
previousImageRefs["credential-executor"],
|
|
585
595
|
networkName: res.network,
|
|
586
596
|
},
|
|
587
|
-
previousServiceGroupVersion: undefined,
|
|
588
597
|
previousContainerInfo: undefined,
|
|
589
598
|
previousDbMigrationVersion: undefined,
|
|
590
599
|
previousWorkspaceMigrationId: undefined,
|
|
@@ -598,9 +607,9 @@ async function upgradeDocker(
|
|
|
598
607
|
entry.runtimeUrl,
|
|
599
608
|
entry.assistantId,
|
|
600
609
|
buildCompleteEvent(
|
|
601
|
-
|
|
610
|
+
currentVersion ?? "unknown",
|
|
602
611
|
false,
|
|
603
|
-
|
|
612
|
+
currentVersion,
|
|
604
613
|
),
|
|
605
614
|
);
|
|
606
615
|
|
|
@@ -621,7 +630,7 @@ async function upgradeDocker(
|
|
|
621
630
|
await broadcastUpgradeEvent(
|
|
622
631
|
entry.runtimeUrl,
|
|
623
632
|
entry.assistantId,
|
|
624
|
-
buildCompleteEvent(
|
|
633
|
+
buildCompleteEvent(currentVersion ?? "unknown", false),
|
|
625
634
|
);
|
|
626
635
|
emitCliError(
|
|
627
636
|
"ROLLBACK_FAILED",
|
|
@@ -641,7 +650,7 @@ async function upgradeDocker(
|
|
|
641
650
|
await broadcastUpgradeEvent(
|
|
642
651
|
entry.runtimeUrl,
|
|
643
652
|
entry.assistantId,
|
|
644
|
-
buildCompleteEvent(
|
|
653
|
+
buildCompleteEvent(currentVersion ?? "unknown", false),
|
|
645
654
|
);
|
|
646
655
|
emitCliError(
|
|
647
656
|
"ROLLBACK_FAILED",
|
|
@@ -657,7 +666,7 @@ async function upgradeDocker(
|
|
|
657
666
|
await broadcastUpgradeEvent(
|
|
658
667
|
entry.runtimeUrl,
|
|
659
668
|
entry.assistantId,
|
|
660
|
-
buildCompleteEvent(
|
|
669
|
+
buildCompleteEvent(currentVersion ?? "unknown", false),
|
|
661
670
|
);
|
|
662
671
|
emitCliError(
|
|
663
672
|
"ROLLBACK_NO_STATE",
|
|
@@ -678,22 +687,6 @@ async function upgradePlatform(
|
|
|
678
687
|
entry: AssistantEntry,
|
|
679
688
|
version: string | null,
|
|
680
689
|
): Promise<void> {
|
|
681
|
-
// Reject downgrades — `vellum upgrade` only handles forward version changes.
|
|
682
|
-
// Users should use `vellum rollback --version <version>` for downgrades.
|
|
683
|
-
// Only enforce this guard when the user explicitly passed `--version`.
|
|
684
|
-
// When version is null the platform API decides the actual target, so
|
|
685
|
-
// we must not block the request based on the local CLI version.
|
|
686
|
-
const currentVersion = entry.serviceGroupVersion;
|
|
687
|
-
if (version && currentVersion) {
|
|
688
|
-
const cmp = compareVersions(version, currentVersion);
|
|
689
|
-
if (cmp !== null && cmp < 0) {
|
|
690
|
-
const msg = `Cannot upgrade to an older version (${version} < ${currentVersion}). Use \`vellum rollback --version ${version}\` instead.`;
|
|
691
|
-
console.error(msg);
|
|
692
|
-
emitCliError("VERSION_DIRECTION", msg);
|
|
693
|
-
process.exit(1);
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
690
|
console.log(
|
|
698
691
|
`🔄 Upgrading platform-hosted assistant '${entry.assistantId}'...\n`,
|
|
699
692
|
);
|
|
@@ -733,7 +726,7 @@ async function upgradePlatform(
|
|
|
733
726
|
await broadcastUpgradeEvent(
|
|
734
727
|
entry.runtimeUrl,
|
|
735
728
|
entry.assistantId,
|
|
736
|
-
buildCompleteEvent(
|
|
729
|
+
buildCompleteEvent("unknown", false),
|
|
737
730
|
);
|
|
738
731
|
} catch {
|
|
739
732
|
// Best-effort — broadcast may fail if the assistant is unreachable
|
|
@@ -755,7 +748,7 @@ async function upgradePlatform(
|
|
|
755
748
|
await broadcastUpgradeEvent(
|
|
756
749
|
entry.runtimeUrl,
|
|
757
750
|
entry.assistantId,
|
|
758
|
-
buildCompleteEvent(
|
|
751
|
+
buildCompleteEvent("unknown", false),
|
|
759
752
|
);
|
|
760
753
|
} catch {
|
|
761
754
|
// Best-effort — broadcast may fail if the assistant is unreachable
|
|
@@ -788,8 +781,8 @@ async function upgradePrepare(
|
|
|
788
781
|
entry: AssistantEntry,
|
|
789
782
|
version: string | null,
|
|
790
783
|
): Promise<void> {
|
|
791
|
-
const targetVersion = version ??
|
|
792
|
-
const currentVersion =
|
|
784
|
+
const targetVersion = version ?? "unknown";
|
|
785
|
+
const currentVersion = "unknown";
|
|
793
786
|
|
|
794
787
|
// 1. Broadcast "starting" so the UI shows the progress spinner
|
|
795
788
|
await broadcastUpgradeEvent(
|
|
@@ -857,9 +850,7 @@ async function upgradeFinalize(
|
|
|
857
850
|
}
|
|
858
851
|
|
|
859
852
|
const fromVersion = version;
|
|
860
|
-
const currentVersion = cliPkg.version
|
|
861
|
-
? `v${cliPkg.version}`
|
|
862
|
-
: (entry.serviceGroupVersion ?? "unknown");
|
|
853
|
+
const currentVersion = cliPkg.version ? `v${cliPkg.version}` : "unknown";
|
|
863
854
|
|
|
864
855
|
// 1. Broadcast "complete" so the UI clears the progress spinner
|
|
865
856
|
await broadcastUpgradeEvent(
|
|
@@ -911,7 +902,7 @@ export async function upgrade(): Promise<void> {
|
|
|
911
902
|
await broadcastUpgradeEvent(
|
|
912
903
|
entry.runtimeUrl,
|
|
913
904
|
entry.assistantId,
|
|
914
|
-
buildCompleteEvent(
|
|
905
|
+
buildCompleteEvent("unknown", false),
|
|
915
906
|
);
|
|
916
907
|
emitCliError(categorizeUpgradeError(err), "Upgrade failed", detail);
|
|
917
908
|
process.exit(1);
|
package/src/commands/wake.ts
CHANGED
|
@@ -59,6 +59,13 @@ export async function wake(): Promise<void> {
|
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
if (entry.cloud === "apple-container") {
|
|
63
|
+
console.error(
|
|
64
|
+
`Error: '${entry.assistantId}' uses the Apple Containers runtime. Its lifecycle is managed by the macOS app — use the app to start it.`,
|
|
65
|
+
);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
62
69
|
if (entry.cloud && entry.cloud !== "local") {
|
|
63
70
|
console.error(
|
|
64
71
|
`Error: 'vellum wake' only works with local and docker assistants. '${entry.assistantId}' is a ${entry.cloud} instance.`,
|
|
@@ -176,18 +183,22 @@ export async function wake(): Promise<void> {
|
|
|
176
183
|
}
|
|
177
184
|
|
|
178
185
|
// Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
|
|
179
|
-
//
|
|
186
|
+
// Scope BASE_DATA_DIR to the woken instance so ngrok reads the correct
|
|
187
|
+
// instance config, then restore on any exit path.
|
|
180
188
|
const prevBaseDataDir = process.env.BASE_DATA_DIR;
|
|
181
189
|
process.env.BASE_DATA_DIR = resources.instanceDir;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
190
|
+
try {
|
|
191
|
+
const ngrokChild = await maybeStartNgrokTunnel(resources.gatewayPort);
|
|
192
|
+
if (ngrokChild?.pid) {
|
|
193
|
+
const ngrokPidFile = join(resources.instanceDir, ".vellum", "ngrok.pid");
|
|
194
|
+
writeFileSync(ngrokPidFile, String(ngrokChild.pid));
|
|
195
|
+
}
|
|
196
|
+
} finally {
|
|
197
|
+
if (prevBaseDataDir !== undefined) {
|
|
198
|
+
process.env.BASE_DATA_DIR = prevBaseDataDir;
|
|
199
|
+
} else {
|
|
200
|
+
delete process.env.BASE_DATA_DIR;
|
|
201
|
+
}
|
|
191
202
|
}
|
|
192
203
|
|
|
193
204
|
console.log("Wake complete.");
|
|
@@ -1939,8 +1939,14 @@ function ChatApp({
|
|
|
1939
1939
|
);
|
|
1940
1940
|
}
|
|
1941
1941
|
|
|
1942
|
+
let username: string;
|
|
1943
|
+
try {
|
|
1944
|
+
username = userInfo().username;
|
|
1945
|
+
} catch {
|
|
1946
|
+
username = "";
|
|
1947
|
+
}
|
|
1942
1948
|
const hostId = createHash("sha256")
|
|
1943
|
-
.update(hostname() +
|
|
1949
|
+
.update(hostname() + username)
|
|
1944
1950
|
.digest("hex");
|
|
1945
1951
|
const payload = JSON.stringify({
|
|
1946
1952
|
type: "vellum-assistant",
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { client } from "./commands/client";
|
|
|
7
7
|
import { events } from "./commands/events";
|
|
8
8
|
import { hatch } from "./commands/hatch";
|
|
9
9
|
import { login, logout, whoami } from "./commands/login";
|
|
10
|
+
import { logs } from "./commands/logs";
|
|
10
11
|
import { message } from "./commands/message";
|
|
11
12
|
import { pair } from "./commands/pair";
|
|
12
13
|
import { ps } from "./commands/ps";
|
|
@@ -39,6 +40,7 @@ const commands = {
|
|
|
39
40
|
hatch,
|
|
40
41
|
login,
|
|
41
42
|
logout,
|
|
43
|
+
logs,
|
|
42
44
|
message,
|
|
43
45
|
pair,
|
|
44
46
|
ps,
|
|
@@ -68,6 +70,7 @@ function printHelp(): void {
|
|
|
68
70
|
console.log(" client Connect to a hatched assistant");
|
|
69
71
|
console.log(" events Stream events from a running assistant");
|
|
70
72
|
console.log(" hatch Create a new assistant instance");
|
|
73
|
+
console.log(" logs View logs from an assistant instance");
|
|
71
74
|
console.log(" login Log in to the Vellum platform");
|
|
72
75
|
console.log(" logout Log out of the Vellum platform");
|
|
73
76
|
console.log(" message Send a message to a running assistant");
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
ASSISTANT_INTERNAL_PORT,
|
|
4
|
+
dockerResourceNames,
|
|
5
|
+
serviceDockerRunArgs,
|
|
6
|
+
type ServiceName,
|
|
7
|
+
} from "../docker.js";
|
|
8
|
+
|
|
9
|
+
const instanceName = "test-instance";
|
|
10
|
+
const imageTags: Record<ServiceName, string> = {
|
|
11
|
+
assistant: "vellumai/vellum-assistant:test",
|
|
12
|
+
"credential-executor": "vellumai/vellum-credential-executor:test",
|
|
13
|
+
gateway: "vellumai/vellum-gateway:test",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function buildAssistantArgs(): string[] {
|
|
17
|
+
const res = dockerResourceNames(instanceName);
|
|
18
|
+
const builders = serviceDockerRunArgs({
|
|
19
|
+
gatewayPort: 7830,
|
|
20
|
+
imageTags,
|
|
21
|
+
instanceName,
|
|
22
|
+
res,
|
|
23
|
+
});
|
|
24
|
+
return builders.assistant();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("serviceDockerRunArgs — assistant", () => {
|
|
28
|
+
test("runs privileged so the inner dockerd can manage cgroups/iptables/overlayfs", () => {
|
|
29
|
+
const args = buildAssistantArgs();
|
|
30
|
+
expect(args).toContain("--privileged");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("mounts a dedicated named volume at /var/lib/docker for the inner dockerd data store", () => {
|
|
34
|
+
const args = buildAssistantArgs();
|
|
35
|
+
const spec = `${instanceName}-dockerd-data:/var/lib/docker`;
|
|
36
|
+
const mountIndex = args.indexOf(spec);
|
|
37
|
+
expect(mountIndex).toBeGreaterThan(0);
|
|
38
|
+
expect(args[mountIndex - 1]).toBe("-v");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("does NOT bind-mount the host Docker socket (DinD replaces host-socket access)", () => {
|
|
42
|
+
const args = buildAssistantArgs();
|
|
43
|
+
expect(args).not.toContain("/var/run/docker.sock:/var/run/docker.sock");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("does NOT set VELLUM_WORKSPACE_VOLUME_NAME (legacy Phase 1.8 hint, no longer needed in DinD)", () => {
|
|
47
|
+
const args = buildAssistantArgs();
|
|
48
|
+
expect(
|
|
49
|
+
args.some((a) => a.startsWith("VELLUM_WORKSPACE_VOLUME_NAME=")),
|
|
50
|
+
).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("keeps existing workspace and socket volume mounts intact", () => {
|
|
54
|
+
const args = buildAssistantArgs();
|
|
55
|
+
expect(args).toContain(`${instanceName}-workspace:/workspace`);
|
|
56
|
+
expect(args).toContain(`${instanceName}-socket:/run/ces-bootstrap`);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("preserves existing required env vars", () => {
|
|
60
|
+
const args = buildAssistantArgs();
|
|
61
|
+
expect(args).toContain("IS_CONTAINERIZED=true");
|
|
62
|
+
expect(args).toContain("VELLUM_WORKSPACE_DIR=/workspace");
|
|
63
|
+
expect(args).toContain(`VELLUM_ASSISTANT_NAME=${instanceName}`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("publishes the assistant HTTP port on all host interfaces so sibling bot containers can reach the daemon via host.docker.internal on both Docker Desktop and Linux", () => {
|
|
67
|
+
const args = buildAssistantArgs();
|
|
68
|
+
// The port mapping is expressed as two adjacent args: "-p" then the spec.
|
|
69
|
+
// Bound to all interfaces (no `127.0.0.1:` prefix) because on vanilla
|
|
70
|
+
// Linux Docker, host.docker.internal:host-gateway resolves to the Docker
|
|
71
|
+
// bridge gateway IP — packets arrive at the bridge interface, not
|
|
72
|
+
// loopback, so a 127.0.0.1 DNAT rule would not match.
|
|
73
|
+
const portSpec = `${ASSISTANT_INTERNAL_PORT}:${ASSISTANT_INTERNAL_PORT}`;
|
|
74
|
+
const portIndex = args.indexOf(portSpec);
|
|
75
|
+
expect(portIndex).toBeGreaterThan(0);
|
|
76
|
+
expect(args[portIndex - 1]).toBe("-p");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
writeFileSync,
|
|
9
9
|
} from "fs";
|
|
10
10
|
import { homedir } from "os";
|
|
11
|
-
import { join } from "path";
|
|
11
|
+
import { dirname, join } from "path";
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
14
|
DAEMON_INTERNAL_ASSISTANT_ID,
|
|
@@ -16,8 +16,13 @@ import {
|
|
|
16
16
|
DEFAULT_DAEMON_PORT,
|
|
17
17
|
DEFAULT_GATEWAY_PORT,
|
|
18
18
|
DEFAULT_QDRANT_PORT,
|
|
19
|
-
LOCKFILE_NAMES,
|
|
20
19
|
} from "./constants.js";
|
|
20
|
+
import {
|
|
21
|
+
getLockfilePath,
|
|
22
|
+
getLockfilePaths,
|
|
23
|
+
getMultiInstanceDir,
|
|
24
|
+
} from "./environments/paths.js";
|
|
25
|
+
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
21
26
|
import { probePort } from "./port-probe.js";
|
|
22
27
|
|
|
23
28
|
/**
|
|
@@ -27,10 +32,11 @@ import { probePort } from "./port-probe.js";
|
|
|
27
32
|
*/
|
|
28
33
|
export interface LocalInstanceResources {
|
|
29
34
|
/**
|
|
30
|
-
* Instance-specific data root.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* The daemon's `.vellum/` directory
|
|
35
|
+
* Instance-specific data root. New local assistants are placed under
|
|
36
|
+
* `$XDG_DATA_HOME/vellum{-env}/assistants/<name>/`. Legacy entries
|
|
37
|
+
* (pre env-data-layout) may still point at `~` — the read path honors
|
|
38
|
+
* whatever `instanceDir` is stored. The daemon's `.vellum/` directory
|
|
39
|
+
* lives inside it.
|
|
34
40
|
*/
|
|
35
41
|
instanceDir: string;
|
|
36
42
|
/** HTTP port for the daemon runtime server */
|
|
@@ -84,18 +90,17 @@ export interface AssistantEntry {
|
|
|
84
90
|
resources?: LocalInstanceResources;
|
|
85
91
|
/** PID of the file watcher process for docker instances hatched with --watch. */
|
|
86
92
|
watcherPid?: number;
|
|
87
|
-
/** Last-known version of the service group, populated at hatch and updated by health checks. */
|
|
88
|
-
serviceGroupVersion?: string;
|
|
89
93
|
/** Docker image metadata for rollback. Only present for docker topology entries. */
|
|
90
94
|
containerInfo?: ContainerInfo;
|
|
91
|
-
/** The service group version that was running before the last upgrade. */
|
|
92
|
-
previousServiceGroupVersion?: string;
|
|
93
95
|
/** Docker image metadata from before the last upgrade. Enables rollback to the prior version. */
|
|
94
96
|
previousContainerInfo?: ContainerInfo;
|
|
95
97
|
/** Path to the .vbundle backup created for the most recent upgrade. Used by rollback to restore
|
|
96
98
|
* only the backup from the specific upgrade being rolled back — never a stale backup from a
|
|
97
99
|
* previous upgrade cycle. */
|
|
98
100
|
preUpgradeBackupPath?: string;
|
|
101
|
+
/** Running version of the service group at the time of the last upgrade, as reported by
|
|
102
|
+
* the health endpoint. Used by saved-state rollback for logging / broadcast events. */
|
|
103
|
+
previousVersion?: string;
|
|
99
104
|
/** Pre-upgrade DB migration version — used by rollback to know how far back to revert. */
|
|
100
105
|
previousDbMigrationVersion?: number;
|
|
101
106
|
/** Pre-upgrade workspace migration ID — used by rollback to know how far back to revert. */
|
|
@@ -114,15 +119,8 @@ export function getBaseDir(): string {
|
|
|
114
119
|
return process.env.BASE_DATA_DIR?.trim() || homedir();
|
|
115
120
|
}
|
|
116
121
|
|
|
117
|
-
/** The lockfile always lives under the home directory. */
|
|
118
|
-
function getLockfileDir(): string {
|
|
119
|
-
return process.env.VELLUM_LOCKFILE_DIR?.trim() || homedir();
|
|
120
|
-
}
|
|
121
|
-
|
|
122
122
|
function readLockfile(): LockfileData {
|
|
123
|
-
const
|
|
124
|
-
const candidates = LOCKFILE_NAMES.map((name) => join(base, name));
|
|
125
|
-
for (const lockfilePath of candidates) {
|
|
123
|
+
for (const lockfilePath of getLockfilePaths(getCurrentEnvironment())) {
|
|
126
124
|
if (!existsSync(lockfilePath)) continue;
|
|
127
125
|
try {
|
|
128
126
|
const raw = readFileSync(lockfilePath, "utf-8");
|
|
@@ -138,7 +136,8 @@ function readLockfile(): LockfileData {
|
|
|
138
136
|
}
|
|
139
137
|
|
|
140
138
|
function writeLockfile(data: LockfileData): void {
|
|
141
|
-
const lockfilePath =
|
|
139
|
+
const lockfilePath = getLockfilePath(getCurrentEnvironment());
|
|
140
|
+
mkdirSync(dirname(lockfilePath), { recursive: true });
|
|
142
141
|
const tmpPath = `${lockfilePath}.${randomBytes(4).toString("hex")}.tmp`;
|
|
143
142
|
try {
|
|
144
143
|
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
|
|
@@ -181,6 +180,13 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
181
180
|
return false;
|
|
182
181
|
}
|
|
183
182
|
|
|
183
|
+
// Apple-containers entries are fully managed by the macOS app.
|
|
184
|
+
// Skip legacy migration to avoid corrupting their fields.
|
|
185
|
+
if (raw.cloud === "apple-container") {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const env = getCurrentEnvironment();
|
|
184
190
|
let mutated = false;
|
|
185
191
|
|
|
186
192
|
// Migrate top-level `baseDataDir` → `resources.instanceDir`
|
|
@@ -202,11 +208,7 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
202
208
|
const gatewayPort =
|
|
203
209
|
parsePortFromUrl(raw.runtimeUrl) ?? DEFAULT_GATEWAY_PORT;
|
|
204
210
|
const instanceDir = join(
|
|
205
|
-
|
|
206
|
-
".local",
|
|
207
|
-
"share",
|
|
208
|
-
"vellum",
|
|
209
|
-
"assistants",
|
|
211
|
+
getMultiInstanceDir(env),
|
|
210
212
|
typeof raw.assistantId === "string"
|
|
211
213
|
? raw.assistantId
|
|
212
214
|
: DAEMON_INTERNAL_ASSISTANT_ID,
|
|
@@ -225,11 +227,7 @@ export function migrateLegacyEntry(raw: Record<string, unknown>): boolean {
|
|
|
225
227
|
const res = raw.resources as Record<string, unknown>;
|
|
226
228
|
if (!res.instanceDir) {
|
|
227
229
|
res.instanceDir = join(
|
|
228
|
-
|
|
229
|
-
".local",
|
|
230
|
-
"share",
|
|
231
|
-
"vellum",
|
|
232
|
-
"assistants",
|
|
230
|
+
getMultiInstanceDir(env),
|
|
233
231
|
typeof raw.assistantId === "string"
|
|
234
232
|
? raw.assistantId
|
|
235
233
|
: DAEMON_INTERNAL_ASSISTANT_ID,
|
|
@@ -388,23 +386,6 @@ export function saveAssistantEntry(entry: AssistantEntry): void {
|
|
|
388
386
|
writeAssistants(entries);
|
|
389
387
|
}
|
|
390
388
|
|
|
391
|
-
/**
|
|
392
|
-
* Update just the serviceGroupVersion field on a lockfile entry.
|
|
393
|
-
* Reads the current entry, updates the version if changed, and writes back.
|
|
394
|
-
* No-op if the entry doesn't exist or the version hasn't changed.
|
|
395
|
-
*/
|
|
396
|
-
export function updateServiceGroupVersion(
|
|
397
|
-
assistantId: string,
|
|
398
|
-
version: string,
|
|
399
|
-
): void {
|
|
400
|
-
const entries = readAssistants();
|
|
401
|
-
const entry = entries.find((e) => e.assistantId === assistantId);
|
|
402
|
-
if (!entry) return;
|
|
403
|
-
if (entry.serviceGroupVersion === version) return;
|
|
404
|
-
entry.serviceGroupVersion = version;
|
|
405
|
-
writeAssistants(entries);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
389
|
/**
|
|
409
390
|
* Scan upward from `basePort` to find an available port. A port is considered
|
|
410
391
|
* available when `probePort()` returns false (nothing listening). Scans up to
|
|
@@ -428,58 +409,32 @@ async function findAvailablePort(
|
|
|
428
409
|
|
|
429
410
|
/**
|
|
430
411
|
* Allocate an isolated set of resources for a named local instance.
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
412
|
+
* Every new local assistant is allocated under
|
|
413
|
+
* `$XDG_DATA_HOME/vellum{-env}/assistants/<name>/`. The legacy `~/.vellum/`
|
|
414
|
+
* path is only reached via existing lockfile entries from before this change
|
|
415
|
+
* — the read path honors whatever `resources.instanceDir` is stored, so
|
|
416
|
+
* production users' existing first-local assistants keep their `~/.vellum/`
|
|
417
|
+
* roots unchanged.
|
|
434
418
|
*/
|
|
435
419
|
export async function allocateLocalResources(
|
|
436
420
|
instanceName: string,
|
|
437
421
|
): Promise<LocalInstanceResources> {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
// gateway, and credential store all resolve paths under the same root.
|
|
441
|
-
const existingLocals = loadAllAssistants().filter((e) => e.cloud === "local");
|
|
442
|
-
if (existingLocals.length === 0) {
|
|
443
|
-
const baseDir = getBaseDir();
|
|
444
|
-
const vellumDir = join(baseDir, ".vellum");
|
|
445
|
-
return {
|
|
446
|
-
instanceDir: baseDir,
|
|
447
|
-
daemonPort: DEFAULT_DAEMON_PORT,
|
|
448
|
-
gatewayPort: DEFAULT_GATEWAY_PORT,
|
|
449
|
-
qdrantPort: DEFAULT_QDRANT_PORT,
|
|
450
|
-
cesPort: DEFAULT_CES_PORT,
|
|
451
|
-
pidFile: join(vellumDir, "vellum.pid"),
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
const instanceDir = join(
|
|
456
|
-
homedir(),
|
|
457
|
-
".local",
|
|
458
|
-
"share",
|
|
459
|
-
"vellum",
|
|
460
|
-
"assistants",
|
|
461
|
-
instanceName,
|
|
462
|
-
);
|
|
422
|
+
const env = getCurrentEnvironment();
|
|
423
|
+
const instanceDir = join(getMultiInstanceDir(env), instanceName);
|
|
463
424
|
mkdirSync(instanceDir, { recursive: true });
|
|
464
425
|
|
|
465
426
|
// Collect ports already assigned to other local instances in the lockfile.
|
|
466
|
-
// Even if those instances are stopped, we must avoid reusing their ports
|
|
467
|
-
// to prevent binding collisions when both are woken.
|
|
468
427
|
const reservedPorts: number[] = [];
|
|
469
428
|
for (const entry of loadAllAssistants()) {
|
|
470
|
-
if (entry.cloud !== "local") continue;
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
);
|
|
478
|
-
}
|
|
429
|
+
if (entry.cloud !== "local" || !entry.resources) continue;
|
|
430
|
+
reservedPorts.push(
|
|
431
|
+
entry.resources.daemonPort,
|
|
432
|
+
entry.resources.gatewayPort,
|
|
433
|
+
entry.resources.qdrantPort,
|
|
434
|
+
entry.resources.cesPort,
|
|
435
|
+
);
|
|
479
436
|
}
|
|
480
437
|
|
|
481
|
-
// Allocate ports sequentially to avoid overlapping ranges assigning the
|
|
482
|
-
// same port to multiple services (e.g. daemon 7821-7920 overlaps gateway 7830-7929).
|
|
483
438
|
const daemonPort = await findAvailablePort(
|
|
484
439
|
DEFAULT_DAEMON_PORT,
|
|
485
440
|
reservedPorts,
|
|
@@ -510,6 +465,18 @@ export async function allocateLocalResources(
|
|
|
510
465
|
};
|
|
511
466
|
}
|
|
512
467
|
|
|
468
|
+
/**
|
|
469
|
+
* Return `platformBaseUrl` from the lockfile, if set. This is the value
|
|
470
|
+
* persisted by {@link syncConfigToLockfile} the last time the active
|
|
471
|
+
* assistant was hatched/waked, and is the source of truth for "which
|
|
472
|
+
* platform does the currently-active assistant target".
|
|
473
|
+
*/
|
|
474
|
+
export function getLockfilePlatformBaseUrl(): string | undefined {
|
|
475
|
+
const url = readLockfile().platformBaseUrl;
|
|
476
|
+
if (typeof url === "string" && url.trim()) return url.trim();
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
|
|
513
480
|
/**
|
|
514
481
|
* Read the assistant config file and sync client-relevant values to the
|
|
515
482
|
* lockfile. This lets external tools (e.g. vel) discover the platform URL
|
package/src/lib/aws.ts
CHANGED
|
@@ -411,7 +411,18 @@ export async function hatchAws(
|
|
|
411
411
|
}
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
-
|
|
414
|
+
let sshUser: string;
|
|
415
|
+
try {
|
|
416
|
+
sshUser = userInfo().username;
|
|
417
|
+
} catch {
|
|
418
|
+
sshUser = process.env.USER ?? "";
|
|
419
|
+
}
|
|
420
|
+
if (!sshUser) {
|
|
421
|
+
console.error(
|
|
422
|
+
"Error: Could not determine SSH username. Set the USER environment variable and try again.",
|
|
423
|
+
);
|
|
424
|
+
process.exit(1);
|
|
425
|
+
}
|
|
415
426
|
const hatchedBy = process.env.VELLUM_HATCHED_BY;
|
|
416
427
|
const providerApiKeys: Record<string, string> = {};
|
|
417
428
|
for (const [, envVar] of Object.entries(PROVIDER_ENV_VAR_NAMES)) {
|