@vellumai/cli 0.8.3 → 0.8.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 +18 -6
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +108 -0
- package/src/__tests__/assistant-target-args.test.ts +30 -0
- package/src/__tests__/host-image-loader.test.ts +206 -0
- package/src/__tests__/ps-platform-status.test.ts +100 -22
- package/src/__tests__/use.test.ts +144 -0
- package/src/commands/client.ts +27 -24
- package/src/commands/ps.ts +107 -105
- package/src/commands/use.ts +24 -10
- package/src/components/DefaultMainScreen.tsx +27 -115
- package/src/lib/assistant-config.ts +84 -5
- package/src/lib/assistant-target-args.ts +21 -0
- package/src/lib/docker.ts +45 -8
- package/src/lib/host-image-loader.ts +138 -0
- package/src/lib/platform-releases.ts +12 -5
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn } from "child_process";
|
|
2
1
|
import { basename } from "path";
|
|
3
2
|
import {
|
|
4
3
|
useCallback,
|
|
@@ -10,8 +9,6 @@ import {
|
|
|
10
9
|
} from "react";
|
|
11
10
|
import { Box, render as inkRender, Text, useInput, useStdout } from "ink";
|
|
12
11
|
|
|
13
|
-
import { removeAssistantEntry } from "../lib/assistant-config";
|
|
14
|
-
|
|
15
12
|
import { SPECIES_CONFIG, type Species } from "../lib/constants";
|
|
16
13
|
import { checkHealth } from "../lib/health-check";
|
|
17
14
|
import { appendHistory, loadHistory } from "../lib/input-history";
|
|
@@ -43,9 +40,27 @@ export const SLASH_COMMANDS = [
|
|
|
43
40
|
"/help",
|
|
44
41
|
"/q",
|
|
45
42
|
"/quit",
|
|
46
|
-
"/retire",
|
|
47
43
|
];
|
|
48
44
|
|
|
45
|
+
const HELP_COMMANDS = [
|
|
46
|
+
{
|
|
47
|
+
command: "/btw <question>",
|
|
48
|
+
description: "Ask a side question while the assistant is working",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
command: "/quit, /exit, /q",
|
|
52
|
+
description: "Disconnect and exit",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
command: "/clear",
|
|
56
|
+
description: "Clear the screen",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
command: "/help, ?",
|
|
60
|
+
description: "Show this help",
|
|
61
|
+
},
|
|
62
|
+
] as const;
|
|
63
|
+
|
|
49
64
|
const SEND_TIMEOUT_MS = 5000;
|
|
50
65
|
|
|
51
66
|
// ── Layout constants ──────────────────────────────────────
|
|
@@ -100,12 +115,11 @@ const MIN_FEED_ROWS = 3;
|
|
|
100
115
|
// Feed item height estimation
|
|
101
116
|
const TOOL_CALL_CHROME_LINES = 2; // header (┌) + footer (└)
|
|
102
117
|
const MESSAGE_SPACING = 1;
|
|
103
|
-
const HELP_DISPLAY_HEIGHT =
|
|
118
|
+
const HELP_DISPLAY_HEIGHT = HELP_COMMANDS.length + 1;
|
|
104
119
|
|
|
105
120
|
interface ListMessagesResponse {
|
|
106
121
|
messages: RuntimeMessage[];
|
|
107
122
|
nextCursor?: string;
|
|
108
|
-
interfaces?: string[];
|
|
109
123
|
}
|
|
110
124
|
|
|
111
125
|
interface SendMessageResponse {
|
|
@@ -742,26 +756,12 @@ function HelpDisplay(): ReactElement {
|
|
|
742
756
|
return (
|
|
743
757
|
<Box flexDirection="column">
|
|
744
758
|
<Text bold>Commands:</Text>
|
|
745
|
-
|
|
746
|
-
{
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
<Text dimColor>Retire the remote instance and exit</Text>
|
|
752
|
-
</Text>
|
|
753
|
-
<Text>
|
|
754
|
-
{" /quit, /exit, /q "}
|
|
755
|
-
<Text dimColor>Disconnect and exit</Text>
|
|
756
|
-
</Text>
|
|
757
|
-
<Text>
|
|
758
|
-
{" /clear "}
|
|
759
|
-
<Text dimColor>Clear the screen</Text>
|
|
760
|
-
</Text>
|
|
761
|
-
<Text>
|
|
762
|
-
{" /help, ? "}
|
|
763
|
-
<Text dimColor>Show this help</Text>
|
|
764
|
-
</Text>
|
|
759
|
+
{HELP_COMMANDS.map((entry) => (
|
|
760
|
+
<Text key={entry.command}>
|
|
761
|
+
{` ${entry.command.padEnd(17)} `}
|
|
762
|
+
<Text dimColor>{entry.description}</Text>
|
|
763
|
+
</Text>
|
|
764
|
+
))}
|
|
765
765
|
</Box>
|
|
766
766
|
);
|
|
767
767
|
}
|
|
@@ -1391,8 +1391,6 @@ interface ChatAppProps {
|
|
|
1391
1391
|
/** Pre-built auth headers (e.g. { Authorization: "Bearer ..." } for local,
|
|
1392
1392
|
* { "X-Session-Token": "...", "Vellum-Organization-Id": "..." } for platform). */
|
|
1393
1393
|
auth?: Record<string, string>;
|
|
1394
|
-
project?: string;
|
|
1395
|
-
zone?: string;
|
|
1396
1394
|
onExit: () => void;
|
|
1397
1395
|
handleRef: (handle: ChatAppHandle) => void;
|
|
1398
1396
|
}
|
|
@@ -1403,8 +1401,6 @@ function ChatApp({
|
|
|
1403
1401
|
assistantName,
|
|
1404
1402
|
species,
|
|
1405
1403
|
auth,
|
|
1406
|
-
project,
|
|
1407
|
-
zone,
|
|
1408
1404
|
onExit,
|
|
1409
1405
|
handleRef,
|
|
1410
1406
|
}: ChatAppProps): ReactElement {
|
|
@@ -1954,86 +1950,6 @@ function ChatApp({
|
|
|
1954
1950
|
return;
|
|
1955
1951
|
}
|
|
1956
1952
|
|
|
1957
|
-
if (trimmed === "/retire") {
|
|
1958
|
-
if (!project || !zone) {
|
|
1959
|
-
h.showError(
|
|
1960
|
-
"No instance info available. Connect to a hatched instance first.",
|
|
1961
|
-
);
|
|
1962
|
-
return;
|
|
1963
|
-
}
|
|
1964
|
-
|
|
1965
|
-
const confirmIndex = await h.showSelection(`Retire ${assistantId}?`, [
|
|
1966
|
-
"Yes, retire",
|
|
1967
|
-
"Cancel",
|
|
1968
|
-
]);
|
|
1969
|
-
if (confirmIndex !== 0) {
|
|
1970
|
-
h.addStatus("Cancelled.");
|
|
1971
|
-
return;
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
h.showSpinner(`Retiring instance ${assistantId}...`);
|
|
1975
|
-
|
|
1976
|
-
try {
|
|
1977
|
-
const labelChild = spawn(
|
|
1978
|
-
"gcloud",
|
|
1979
|
-
[
|
|
1980
|
-
"compute",
|
|
1981
|
-
"instances",
|
|
1982
|
-
"add-labels",
|
|
1983
|
-
assistantId,
|
|
1984
|
-
`--project=${project}`,
|
|
1985
|
-
`--zone=${zone}`,
|
|
1986
|
-
"--labels=retired-by=vel",
|
|
1987
|
-
],
|
|
1988
|
-
{ stdio: "pipe" },
|
|
1989
|
-
);
|
|
1990
|
-
await new Promise<void>((resolve) => {
|
|
1991
|
-
labelChild.on("close", () => resolve());
|
|
1992
|
-
labelChild.on("error", () => resolve());
|
|
1993
|
-
});
|
|
1994
|
-
} catch {
|
|
1995
|
-
// Best-effort labeling before deletion
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
|
-
const child = spawn(
|
|
1999
|
-
"gcloud",
|
|
2000
|
-
[
|
|
2001
|
-
"compute",
|
|
2002
|
-
"instances",
|
|
2003
|
-
"delete",
|
|
2004
|
-
assistantId,
|
|
2005
|
-
`--project=${project}`,
|
|
2006
|
-
`--zone=${zone}`,
|
|
2007
|
-
"--quiet",
|
|
2008
|
-
],
|
|
2009
|
-
{ stdio: "pipe" },
|
|
2010
|
-
);
|
|
2011
|
-
|
|
2012
|
-
child.on("close", (code) => {
|
|
2013
|
-
handleRef_.current?.hideSpinner();
|
|
2014
|
-
if (code === 0) {
|
|
2015
|
-
removeAssistantEntry(assistantId);
|
|
2016
|
-
handleRef_.current?.addStatus(
|
|
2017
|
-
`Removed ${assistantId} from lockfile.json`,
|
|
2018
|
-
);
|
|
2019
|
-
} else {
|
|
2020
|
-
handleRef_.current?.showError(
|
|
2021
|
-
`Failed to delete instance (exit code ${code})`,
|
|
2022
|
-
);
|
|
2023
|
-
}
|
|
2024
|
-
cleanup();
|
|
2025
|
-
process.exit(code === 0 ? 0 : 1);
|
|
2026
|
-
});
|
|
2027
|
-
|
|
2028
|
-
child.on("error", (err) => {
|
|
2029
|
-
handleRef_.current?.hideSpinner();
|
|
2030
|
-
handleRef_.current?.showError(
|
|
2031
|
-
`Failed to retire instance: ${err.message}`,
|
|
2032
|
-
);
|
|
2033
|
-
});
|
|
2034
|
-
return;
|
|
2035
|
-
}
|
|
2036
|
-
|
|
2037
1953
|
// If a connection attempt is already in progress, don't silently drop input
|
|
2038
1954
|
if (connectingRef.current) {
|
|
2039
1955
|
h.addStatus(
|
|
@@ -2218,7 +2134,7 @@ function ChatApp({
|
|
|
2218
2134
|
// racing with SSE events that may arrive during the sendMessage await.
|
|
2219
2135
|
h.showSpinner("Working...");
|
|
2220
2136
|
},
|
|
2221
|
-
[runtimeUrl, assistantId, auth,
|
|
2137
|
+
[runtimeUrl, assistantId, auth, cleanup, ensureConnected],
|
|
2222
2138
|
);
|
|
2223
2139
|
|
|
2224
2140
|
const handleSubmit = useCallback(
|
|
@@ -2530,8 +2446,6 @@ export function renderChatApp(
|
|
|
2530
2446
|
onExit: () => void,
|
|
2531
2447
|
options?: {
|
|
2532
2448
|
auth?: Record<string, string>;
|
|
2533
|
-
project?: string;
|
|
2534
|
-
zone?: string;
|
|
2535
2449
|
assistantName?: string;
|
|
2536
2450
|
},
|
|
2537
2451
|
): ChatAppInstance {
|
|
@@ -2544,8 +2458,6 @@ export function renderChatApp(
|
|
|
2544
2458
|
assistantName={options?.assistantName}
|
|
2545
2459
|
species={species}
|
|
2546
2460
|
auth={options?.auth}
|
|
2547
|
-
project={options?.project}
|
|
2548
|
-
zone={options?.zone}
|
|
2549
2461
|
onExit={onExit}
|
|
2550
2462
|
handleRef={(h) => {
|
|
2551
2463
|
chatHandle = h;
|
|
@@ -109,6 +109,11 @@ export interface AssistantEntry {
|
|
|
109
109
|
[key: string]: unknown;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
export type AssistantLookupResult =
|
|
113
|
+
| { status: "found"; entry: AssistantEntry }
|
|
114
|
+
| { status: "not_found" }
|
|
115
|
+
| { status: "ambiguous"; matches: AssistantEntry[] };
|
|
116
|
+
|
|
112
117
|
interface LockfileData {
|
|
113
118
|
assistants?: Record<string, unknown>[];
|
|
114
119
|
activeAssistant?: string;
|
|
@@ -309,8 +314,70 @@ export function loadLatestAssistant(): AssistantEntry | null {
|
|
|
309
314
|
}
|
|
310
315
|
|
|
311
316
|
export function findAssistantByName(name: string): AssistantEntry | null {
|
|
317
|
+
return readAssistants().find((entry) => entry.assistantId === name) ?? null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function getAssistantDisplayName(entry: AssistantEntry): string {
|
|
321
|
+
const primary = entry.name?.trim();
|
|
322
|
+
if (primary) return primary;
|
|
323
|
+
|
|
324
|
+
const legacy = entry.assistantName?.trim();
|
|
325
|
+
if (legacy) return legacy;
|
|
326
|
+
|
|
327
|
+
return entry.assistantId;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function formatAssistantReference(entry: AssistantEntry): string {
|
|
331
|
+
const displayName = getAssistantDisplayName(entry);
|
|
332
|
+
return displayName === entry.assistantId
|
|
333
|
+
? entry.assistantId
|
|
334
|
+
: `${displayName} (${entry.assistantId})`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function getAssistantDisplayNameCandidates(entry: AssistantEntry): string[] {
|
|
338
|
+
return Array.from(
|
|
339
|
+
new Set(
|
|
340
|
+
[entry.name?.trim(), entry.assistantName?.trim()].filter(
|
|
341
|
+
(value): value is string => typeof value === "string" && value !== "",
|
|
342
|
+
),
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function lookupAssistantByIdentifier(
|
|
348
|
+
identifier: string,
|
|
349
|
+
): AssistantLookupResult {
|
|
312
350
|
const entries = readAssistants();
|
|
313
|
-
|
|
351
|
+
const exactId = entries.find((entry) => entry.assistantId === identifier);
|
|
352
|
+
if (exactId) {
|
|
353
|
+
return { status: "found", entry: exactId };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const displayMatches = entries.filter((entry) =>
|
|
357
|
+
getAssistantDisplayNameCandidates(entry).includes(identifier),
|
|
358
|
+
);
|
|
359
|
+
if (displayMatches.length === 1) {
|
|
360
|
+
return { status: "found", entry: displayMatches[0] };
|
|
361
|
+
}
|
|
362
|
+
if (displayMatches.length > 1) {
|
|
363
|
+
return { status: "ambiguous", matches: displayMatches };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return { status: "not_found" };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function formatAssistantLookupError(
|
|
370
|
+
identifier: string,
|
|
371
|
+
result: AssistantLookupResult = lookupAssistantByIdentifier(identifier),
|
|
372
|
+
): string {
|
|
373
|
+
if (result.status === "ambiguous") {
|
|
374
|
+
const matches = result.matches
|
|
375
|
+
.map((entry) => formatAssistantReference(entry))
|
|
376
|
+
.join(", ");
|
|
377
|
+
return `Multiple assistants match '${identifier}': ${matches}. Use the assistant ID to disambiguate.`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return `No assistant found with name or ID '${identifier}'.`;
|
|
314
381
|
}
|
|
315
382
|
|
|
316
383
|
export function removeAssistantEntry(assistantId: string): void {
|
|
@@ -446,13 +513,25 @@ export function resolveAssistant(nameArg?: string): AssistantEntry | null {
|
|
|
446
513
|
* 3. Sole lockfile entry (any cloud)
|
|
447
514
|
*/
|
|
448
515
|
export function resolveTargetAssistant(nameArg?: string): AssistantEntry {
|
|
449
|
-
const entry = resolveAssistant(nameArg);
|
|
450
|
-
if (entry) return entry;
|
|
451
|
-
|
|
452
516
|
if (nameArg) {
|
|
453
|
-
|
|
517
|
+
const result = lookupAssistantByIdentifier(nameArg);
|
|
518
|
+
if (result.status === "found") return result.entry;
|
|
519
|
+
console.error(formatAssistantLookupError(nameArg, result));
|
|
454
520
|
} else {
|
|
521
|
+
const active = getActiveAssistant();
|
|
522
|
+
if (active) {
|
|
523
|
+
const result = lookupAssistantByIdentifier(active);
|
|
524
|
+
if (result.status === "found") return result.entry;
|
|
525
|
+
if (result.status === "ambiguous") {
|
|
526
|
+
console.error(formatAssistantLookupError(active, result));
|
|
527
|
+
process.exit(1);
|
|
528
|
+
}
|
|
529
|
+
// Active assistant no longer exists in lockfile — fall through.
|
|
530
|
+
}
|
|
531
|
+
|
|
455
532
|
const all = readAssistants();
|
|
533
|
+
if (all.length === 1) return all[0];
|
|
534
|
+
|
|
456
535
|
if (all.length === 0) {
|
|
457
536
|
console.error("No assistant found. Run 'vellum hatch' first.");
|
|
458
537
|
} else {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function parseAssistantTargetArg(
|
|
2
|
+
args: string[],
|
|
3
|
+
flagsWithValues: readonly string[] = [],
|
|
4
|
+
): string | undefined {
|
|
5
|
+
const flagsWithValuesSet = new Set(flagsWithValues);
|
|
6
|
+
const parts: string[] = [];
|
|
7
|
+
|
|
8
|
+
for (let i = 0; i < args.length; i++) {
|
|
9
|
+
const arg = args[i];
|
|
10
|
+
if (flagsWithValuesSet.has(arg)) {
|
|
11
|
+
i++;
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (arg.startsWith("-")) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
parts.push(arg);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
21
|
+
}
|
package/src/lib/docker.ts
CHANGED
|
@@ -21,7 +21,15 @@ import { leaseGuardianToken } from "./guardian-token";
|
|
|
21
21
|
import { logHatchNextSteps } from "./hatch-next-steps.js";
|
|
22
22
|
import { isVellumProcess, stopProcess } from "./process";
|
|
23
23
|
import { generateInstanceName } from "./random-name";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
HOST_IMAGE_LOADER_URL,
|
|
26
|
+
isLocalBuildRef,
|
|
27
|
+
loadImageViaHost,
|
|
28
|
+
} from "./host-image-loader.js";
|
|
29
|
+
import {
|
|
30
|
+
fetchLatestStableVersion,
|
|
31
|
+
resolveImageRefs,
|
|
32
|
+
} from "./platform-releases.js";
|
|
25
33
|
import {
|
|
26
34
|
configureHatchProviderApiKey,
|
|
27
35
|
formatProviderName,
|
|
@@ -1059,8 +1067,23 @@ export async function hatchDocker(
|
|
|
1059
1067
|
imageSource = "env override";
|
|
1060
1068
|
log("Using image overrides from environment variables");
|
|
1061
1069
|
} else {
|
|
1062
|
-
|
|
1063
|
-
|
|
1070
|
+
// Resolve image refs from a remote source that may have dev/local
|
|
1071
|
+
// builds. If resolution is unavailable, fall back to the CLI's own
|
|
1072
|
+
// version so a default tag can still be resolved.
|
|
1073
|
+
log("🔍 Fetching latest stable release...");
|
|
1074
|
+
const latestVersion = await fetchLatestStableVersion();
|
|
1075
|
+
let versionTag: string;
|
|
1076
|
+
if (latestVersion) {
|
|
1077
|
+
versionTag = latestVersion.startsWith("v")
|
|
1078
|
+
? latestVersion
|
|
1079
|
+
: `v${latestVersion}`;
|
|
1080
|
+
} else {
|
|
1081
|
+
const fallback = cliPkg.version;
|
|
1082
|
+
versionTag = fallback ? `v${fallback}` : "latest";
|
|
1083
|
+
log(
|
|
1084
|
+
`⚠️ Platform releases unavailable; falling back to CLI version ${versionTag}`,
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1064
1087
|
log("🔍 Resolving image references...");
|
|
1065
1088
|
const resolved = await resolveImageRefs(versionTag, log);
|
|
1066
1089
|
imageTags.assistant = resolved.imageTags.assistant;
|
|
@@ -1078,11 +1101,25 @@ export async function hatchDocker(
|
|
|
1078
1101
|
log(` credential-executor: ${imageTags["credential-executor"]}`);
|
|
1079
1102
|
log("");
|
|
1080
1103
|
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1104
|
+
// Per-ref branching: local-build refs need the image-loader; external
|
|
1105
|
+
// registry refs get a normal `docker pull`. The two transports compose
|
|
1106
|
+
// cleanly — a release can mix different sources for different images.
|
|
1107
|
+
log("📦 Acquiring Docker images...");
|
|
1108
|
+
for (const service of [
|
|
1109
|
+
"assistant",
|
|
1110
|
+
"gateway",
|
|
1111
|
+
"credential-executor",
|
|
1112
|
+
] as const) {
|
|
1113
|
+
const ref = imageTags[service];
|
|
1114
|
+
if (isLocalBuildRef(ref)) {
|
|
1115
|
+
log(` ↪ loading ${ref} via host image-loader`);
|
|
1116
|
+
await loadImageViaHost(HOST_IMAGE_LOADER_URL, ref, log);
|
|
1117
|
+
} else {
|
|
1118
|
+
log(` ↪ pulling ${ref}`);
|
|
1119
|
+
await exec("docker", ["pull", ref]);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
log("✅ Docker images acquired");
|
|
1086
1123
|
}
|
|
1087
1124
|
|
|
1088
1125
|
const res = dockerResourceNames(instanceName);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the host-side image-loader endpoint. Used to acquire image refs
|
|
3
|
+
* that aren't pullable from any external registry.
|
|
4
|
+
*
|
|
5
|
+
* The endpoint URL is a well-known convention — port 5500 on 127.0.0.1.
|
|
6
|
+
* The CLI calls in whenever it sees a ref that starts with `vellum-local/`,
|
|
7
|
+
* which are image refs that only exist in a local docker daemon and can't be
|
|
8
|
+
* `docker pull`'d from any external registry.
|
|
9
|
+
*
|
|
10
|
+
* The endpoint contract is intentionally minimal — POST a ref as JSON, get
|
|
11
|
+
* back a 200 once the image is in the host docker daemon, or a non-2xx
|
|
12
|
+
* with a descriptive error message. The client doesn't know (or care) what
|
|
13
|
+
* transport the server uses to put the image there.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Well-known URL of the host-side image-loader server.
|
|
18
|
+
*/
|
|
19
|
+
export const HOST_IMAGE_LOADER_URL = "http://127.0.0.1:5500/v1/images/load";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Prefix for image refs that only exist in a local docker daemon.
|
|
23
|
+
* These cannot be `docker pull`'d from any external registry; the CLI must
|
|
24
|
+
* route them through the host image-loader instead.
|
|
25
|
+
*/
|
|
26
|
+
const LOCAL_BUILD_REF_PREFIX = "vellum-local/";
|
|
27
|
+
|
|
28
|
+
/** Whether `ref` points at a local-build image that requires the host loader. */
|
|
29
|
+
export function isLocalBuildRef(ref: string): boolean {
|
|
30
|
+
return ref.startsWith(LOCAL_BUILD_REF_PREFIX);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Default timeout for image-load requests. Large `docker save | docker load`
|
|
34
|
+
* pipelines for full assistant images can run for a minute or two on cold
|
|
35
|
+
* caches, so we give plenty of headroom. */
|
|
36
|
+
const LOAD_TIMEOUT_MS = 120_000;
|
|
37
|
+
|
|
38
|
+
export interface HostImageLoaderResponse {
|
|
39
|
+
loaded?: boolean;
|
|
40
|
+
ref?: string;
|
|
41
|
+
error?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class HostImageLoaderError extends Error {
|
|
45
|
+
readonly url: string;
|
|
46
|
+
readonly ref: string;
|
|
47
|
+
readonly status?: number;
|
|
48
|
+
|
|
49
|
+
constructor(message: string, url: string, ref: string, status?: number) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "HostImageLoaderError";
|
|
52
|
+
this.url = url;
|
|
53
|
+
this.ref = ref;
|
|
54
|
+
this.status = status;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isConnectionRefused(err: unknown): boolean {
|
|
59
|
+
if (!err || typeof err !== "object") return false;
|
|
60
|
+
const e = err as { cause?: { code?: string }; code?: string };
|
|
61
|
+
return e.cause?.code === "ECONNREFUSED" || e.code === "ECONNREFUSED";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Ask the host-side loader to acquire `ref` into the host docker daemon.
|
|
66
|
+
*
|
|
67
|
+
* Resolves when the server returns 200; throws a {@link HostImageLoaderError}
|
|
68
|
+
* with a user-actionable message on any failure (network, timeout, non-2xx).
|
|
69
|
+
*
|
|
70
|
+
* The `log` callback receives one-line status updates; pass the same logger
|
|
71
|
+
* the surrounding command uses.
|
|
72
|
+
*/
|
|
73
|
+
/** Minimal fetch signature accepted for test injection. */
|
|
74
|
+
export type FetchLike = (
|
|
75
|
+
input: string | URL,
|
|
76
|
+
init?: {
|
|
77
|
+
method?: string;
|
|
78
|
+
headers?: Record<string, string>;
|
|
79
|
+
body?: string;
|
|
80
|
+
signal?: AbortSignal;
|
|
81
|
+
},
|
|
82
|
+
) => Promise<Response>;
|
|
83
|
+
|
|
84
|
+
export async function loadImageViaHost(
|
|
85
|
+
url: string,
|
|
86
|
+
ref: string,
|
|
87
|
+
log: (msg: string) => void,
|
|
88
|
+
options: { timeoutMs?: number; fetchImpl?: FetchLike } = {},
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
const timeoutMs = options.timeoutMs ?? LOAD_TIMEOUT_MS;
|
|
91
|
+
const fetchImpl: FetchLike =
|
|
92
|
+
options.fetchImpl ?? (fetch as unknown as FetchLike);
|
|
93
|
+
|
|
94
|
+
log(` ↪ ${ref}`);
|
|
95
|
+
|
|
96
|
+
let response: Response;
|
|
97
|
+
try {
|
|
98
|
+
response = await fetchImpl(url, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { "Content-Type": "application/json" },
|
|
101
|
+
body: JSON.stringify({ ref }),
|
|
102
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
103
|
+
});
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (isConnectionRefused(err)) {
|
|
106
|
+
throw new HostImageLoaderError(
|
|
107
|
+
`Could not reach image-loader at ${url}. The ref \`${ref}\` is a ` +
|
|
108
|
+
`local-build image that requires the loader. Is the loader running? ` +
|
|
109
|
+
`Start it, or set VELLUM_ASSISTANT_IMAGE / VELLUM_GATEWAY_IMAGE / ` +
|
|
110
|
+
`VELLUM_CREDENTIAL_EXECUTOR_IMAGE to bypass image resolution.`,
|
|
111
|
+
url,
|
|
112
|
+
ref,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
116
|
+
throw new HostImageLoaderError(
|
|
117
|
+
`Image-loader request for ${ref} failed: ${message}`,
|
|
118
|
+
url,
|
|
119
|
+
ref,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
let body: HostImageLoaderResponse | null = null;
|
|
125
|
+
try {
|
|
126
|
+
body = (await response.json()) as HostImageLoaderResponse;
|
|
127
|
+
} catch {
|
|
128
|
+
// Server returned non-JSON; fall through with status-only error.
|
|
129
|
+
}
|
|
130
|
+
const detail = body?.error ? `: ${body.error}` : "";
|
|
131
|
+
throw new HostImageLoaderError(
|
|
132
|
+
`Image-loader returned HTTP ${response.status} for ${ref}${detail}`,
|
|
133
|
+
url,
|
|
134
|
+
ref,
|
|
135
|
+
response.status,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -46,7 +46,10 @@ export async function resolveImageRefs(
|
|
|
46
46
|
const platformRefs = await fetchPlatformImageRefs(version, log);
|
|
47
47
|
if (platformRefs) {
|
|
48
48
|
log?.("Resolved image refs from platform API");
|
|
49
|
-
return {
|
|
49
|
+
return {
|
|
50
|
+
imageTags: platformRefs.imageTags,
|
|
51
|
+
source: "platform",
|
|
52
|
+
};
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
log?.("Falling back to DockerHub tags");
|
|
@@ -68,7 +71,9 @@ export async function resolveImageRefs(
|
|
|
68
71
|
async function fetchPlatformImageRefs(
|
|
69
72
|
version: string,
|
|
70
73
|
log?: (msg: string) => void,
|
|
71
|
-
): Promise<
|
|
74
|
+
): Promise<{
|
|
75
|
+
imageTags: Record<ServiceName, string>;
|
|
76
|
+
} | null> {
|
|
72
77
|
try {
|
|
73
78
|
const platformUrl = getPlatformUrl();
|
|
74
79
|
const url = `${platformUrl}/v1/releases/?stable=true`;
|
|
@@ -123,9 +128,11 @@ async function fetchPlatformImageRefs(
|
|
|
123
128
|
}
|
|
124
129
|
|
|
125
130
|
return {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
imageTags: {
|
|
132
|
+
assistant: assistantImage,
|
|
133
|
+
"credential-executor": credentialExecutorImage,
|
|
134
|
+
gateway: gatewayImage,
|
|
135
|
+
},
|
|
129
136
|
};
|
|
130
137
|
} catch (err) {
|
|
131
138
|
const message = err instanceof Error ? err.message : String(err);
|