@vellumai/cli 0.8.3 → 0.8.5
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 +29 -7
- package/package.json +1 -1
- package/src/__tests__/api-key-check.test.ts +78 -0
- 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__/retire.test.ts +241 -0
- 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/retire.ts +144 -34
- package/src/commands/roadmap.ts +449 -0
- package/src/commands/use.ts +24 -10
- package/src/components/DefaultMainScreen.tsx +27 -115
- package/src/index.ts +3 -0
- package/src/lib/__tests__/port-allocator.test.ts +117 -0
- package/src/lib/__tests__/step-runner.test.ts +85 -0
- package/src/lib/api-key-check.ts +40 -0
- package/src/lib/assistant-config.ts +84 -5
- package/src/lib/assistant-target-args.ts +21 -0
- package/src/lib/docker.ts +67 -16
- package/src/lib/hatch-local.ts +11 -0
- package/src/lib/host-image-loader.ts +138 -0
- package/src/lib/platform-releases.ts +12 -5
- package/src/lib/port-allocator.ts +93 -0
- package/src/lib/statefulset.ts +0 -10
- package/src/lib/step-runner.ts +40 -7
- package/src/shared/provider-env-vars.ts +1 -0
package/src/lib/docker.ts
CHANGED
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
setActiveAssistant,
|
|
13
13
|
} from "./assistant-config";
|
|
14
14
|
import type { AssistantEntry } from "./assistant-config";
|
|
15
|
-
import { buildHatchConfigValues, writeInitialConfig } from "./config-utils";
|
|
16
15
|
import { buildServiceRunArgs } from "./statefulset.js";
|
|
17
16
|
import type { Species } from "./constants";
|
|
18
17
|
import { getDefaultPorts } from "./environments/paths.js";
|
|
@@ -21,12 +20,21 @@ import { leaseGuardianToken } from "./guardian-token";
|
|
|
21
20
|
import { logHatchNextSteps } from "./hatch-next-steps.js";
|
|
22
21
|
import { isVellumProcess, stopProcess } from "./process";
|
|
23
22
|
import { generateInstanceName } from "./random-name";
|
|
24
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
HOST_IMAGE_LOADER_URL,
|
|
25
|
+
isLocalBuildRef,
|
|
26
|
+
loadImageViaHost,
|
|
27
|
+
} from "./host-image-loader.js";
|
|
28
|
+
import {
|
|
29
|
+
fetchLatestStableVersion,
|
|
30
|
+
resolveImageRefs,
|
|
31
|
+
} from "./platform-releases.js";
|
|
25
32
|
import {
|
|
26
33
|
configureHatchProviderApiKey,
|
|
27
34
|
formatProviderName,
|
|
28
35
|
resolveHatchProvider,
|
|
29
36
|
} from "./provider-secrets.js";
|
|
37
|
+
import { findOpenPort } from "./port-allocator.js";
|
|
30
38
|
import { exec, execOutput } from "./step-runner";
|
|
31
39
|
import {
|
|
32
40
|
closeLogFile,
|
|
@@ -637,7 +645,6 @@ export async function startContainers(
|
|
|
637
645
|
extraAssistantEnv?: Record<string, string>;
|
|
638
646
|
gatewayPort: number;
|
|
639
647
|
imageTags: Record<ServiceName, string>;
|
|
640
|
-
defaultWorkspaceConfigPath?: string;
|
|
641
648
|
instanceName: string;
|
|
642
649
|
res: ReturnType<typeof dockerResourceNames>;
|
|
643
650
|
},
|
|
@@ -973,7 +980,22 @@ export async function hatchDocker(
|
|
|
973
980
|
await ensureDockerInstalled();
|
|
974
981
|
|
|
975
982
|
const instanceName = generateInstanceName(species, name);
|
|
976
|
-
|
|
983
|
+
// Resolve the gateway's host port dynamically. The env-default
|
|
984
|
+
// (production 7830 / non-prod overrides) is just the *preferred*
|
|
985
|
+
// starting point — if it's taken by another local assistant, eval
|
|
986
|
+
// run, or unrelated process, we walk upward until we find a free
|
|
987
|
+
// port. This replaces the previous "first one in wins, everyone
|
|
988
|
+
// else gets a docker bind error" behavior and removes the need for
|
|
989
|
+
// an orphan-cleanup pre-flight in the evals harness.
|
|
990
|
+
const preferredGatewayPort = getDefaultPorts(
|
|
991
|
+
getCurrentEnvironment(),
|
|
992
|
+
).gateway;
|
|
993
|
+
const gatewayPort = await findOpenPort(preferredGatewayPort);
|
|
994
|
+
if (gatewayPort !== preferredGatewayPort) {
|
|
995
|
+
log(
|
|
996
|
+
`Preferred gateway port ${preferredGatewayPort} is in use; allocated ${gatewayPort} for this instance.`,
|
|
997
|
+
);
|
|
998
|
+
}
|
|
977
999
|
|
|
978
1000
|
const imageTags: Record<ServiceName, string> = {
|
|
979
1001
|
assistant: "",
|
|
@@ -1059,8 +1081,23 @@ export async function hatchDocker(
|
|
|
1059
1081
|
imageSource = "env override";
|
|
1060
1082
|
log("Using image overrides from environment variables");
|
|
1061
1083
|
} else {
|
|
1062
|
-
|
|
1063
|
-
|
|
1084
|
+
// Resolve image refs from a remote source that may have dev/local
|
|
1085
|
+
// builds. If resolution is unavailable, fall back to the CLI's own
|
|
1086
|
+
// version so a default tag can still be resolved.
|
|
1087
|
+
log("🔍 Fetching latest stable release...");
|
|
1088
|
+
const latestVersion = await fetchLatestStableVersion();
|
|
1089
|
+
let versionTag: string;
|
|
1090
|
+
if (latestVersion) {
|
|
1091
|
+
versionTag = latestVersion.startsWith("v")
|
|
1092
|
+
? latestVersion
|
|
1093
|
+
: `v${latestVersion}`;
|
|
1094
|
+
} else {
|
|
1095
|
+
const fallback = cliPkg.version;
|
|
1096
|
+
versionTag = fallback ? `v${fallback}` : "latest";
|
|
1097
|
+
log(
|
|
1098
|
+
`⚠️ Platform releases unavailable; falling back to CLI version ${versionTag}`,
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1064
1101
|
log("🔍 Resolving image references...");
|
|
1065
1102
|
const resolved = await resolveImageRefs(versionTag, log);
|
|
1066
1103
|
imageTags.assistant = resolved.imageTags.assistant;
|
|
@@ -1078,11 +1115,25 @@ export async function hatchDocker(
|
|
|
1078
1115
|
log(` credential-executor: ${imageTags["credential-executor"]}`);
|
|
1079
1116
|
log("");
|
|
1080
1117
|
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1118
|
+
// Per-ref branching: local-build refs need the image-loader; external
|
|
1119
|
+
// registry refs get a normal `docker pull`. The two transports compose
|
|
1120
|
+
// cleanly — a release can mix different sources for different images.
|
|
1121
|
+
log("📦 Acquiring Docker images...");
|
|
1122
|
+
for (const service of [
|
|
1123
|
+
"assistant",
|
|
1124
|
+
"gateway",
|
|
1125
|
+
"credential-executor",
|
|
1126
|
+
] as const) {
|
|
1127
|
+
const ref = imageTags[service];
|
|
1128
|
+
if (isLocalBuildRef(ref)) {
|
|
1129
|
+
log(` ↪ loading ${ref} via host image-loader`);
|
|
1130
|
+
await loadImageViaHost(HOST_IMAGE_LOADER_URL, ref, log);
|
|
1131
|
+
} else {
|
|
1132
|
+
log(` ↪ pulling ${ref}`);
|
|
1133
|
+
await exec("docker", ["pull", ref]);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
log("✅ Docker images acquired");
|
|
1086
1137
|
}
|
|
1087
1138
|
|
|
1088
1139
|
const res = dockerResourceNames(instanceName);
|
|
@@ -1113,10 +1164,11 @@ export async function hatchDocker(
|
|
|
1113
1164
|
"chown 1001:1001 /workspace /run/assistant-ipc /run/gateway-ipc",
|
|
1114
1165
|
]);
|
|
1115
1166
|
|
|
1116
|
-
//
|
|
1117
|
-
//
|
|
1118
|
-
|
|
1119
|
-
|
|
1167
|
+
// BYOK setup (API key, custom profiles, active-profile selection) is
|
|
1168
|
+
// driven post-boot by the CLI calling the Assistant's public APIs
|
|
1169
|
+
// (`POST /v1/secrets`, etc.) via `configureHatchProviderApiKey` below.
|
|
1170
|
+
// The Assistant container comes up clean — no overlay file, no
|
|
1171
|
+
// client-side workspace-config injection.
|
|
1120
1172
|
|
|
1121
1173
|
const cesServiceToken = randomBytes(32).toString("hex");
|
|
1122
1174
|
const signingKey = randomBytes(32).toString("hex");
|
|
@@ -1140,7 +1192,6 @@ export async function hatchDocker(
|
|
|
1140
1192
|
cesServiceToken,
|
|
1141
1193
|
gatewayPort,
|
|
1142
1194
|
imageTags,
|
|
1143
|
-
defaultWorkspaceConfigPath,
|
|
1144
1195
|
instanceName,
|
|
1145
1196
|
res,
|
|
1146
1197
|
},
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
resolveHatchProvider,
|
|
41
41
|
} from "./provider-secrets.js";
|
|
42
42
|
import { logHatchNextSteps } from "./hatch-next-steps.js";
|
|
43
|
+
import { checkProviderApiKey } from "./api-key-check.js";
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Attempts to place a symlink at the given path pointing to cliBinary.
|
|
@@ -178,6 +179,16 @@ export async function hatchLocal(
|
|
|
178
179
|
console.log(` Species: ${species}`);
|
|
179
180
|
console.log("");
|
|
180
181
|
|
|
182
|
+
const apiKeyCheck = checkProviderApiKey();
|
|
183
|
+
if (!apiKeyCheck.hasKey) {
|
|
184
|
+
console.warn(
|
|
185
|
+
"Warning: No LLM provider API key is configured. The assistant will fail when you try to send a message.",
|
|
186
|
+
);
|
|
187
|
+
console.warn(" To fix, export your key before running vellum hatch:");
|
|
188
|
+
console.warn(" export ANTHROPIC_API_KEY=<your-key>");
|
|
189
|
+
console.warn("");
|
|
190
|
+
}
|
|
191
|
+
|
|
181
192
|
if (!process.env.APP_VERSION) {
|
|
182
193
|
process.env.APP_VERSION = cliPkg.version;
|
|
183
194
|
}
|
|
@@ -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);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createServer } from "net";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Walks upward from `preferred` and returns the first host port that the
|
|
5
|
+
* kernel will let us bind to. Used by `hatchDocker` to pick the gateway's
|
|
6
|
+
* host-side port instead of always grabbing the env-default (e.g. 7830 /
|
|
7
|
+
* 20100), which collides with any other local assistant — eval-spawned or
|
|
8
|
+
* otherwise — already bound there.
|
|
9
|
+
*
|
|
10
|
+
* The previous design (`evals/src/lib/orphan-cleanup.ts`) tried to fix this
|
|
11
|
+
* by sweeping dead eval-run resources before the next hatch. That only
|
|
12
|
+
* helped when the conflict came from a prior eval run; an unrelated local
|
|
13
|
+
* `vellum hatch` holding the port wedged the whole flow. Discovering an
|
|
14
|
+
* open port at hatch time is the proper fix and lets us delete the cleanup
|
|
15
|
+
* pre-flight entirely.
|
|
16
|
+
*
|
|
17
|
+
* Walks linearly from `preferred` upward rather than asking the kernel for
|
|
18
|
+
* an arbitrary ephemeral port (`listen(0)`) so the resulting port stays
|
|
19
|
+
* legible to operators — three local assistants land on N, N+1, N+2
|
|
20
|
+
* instead of three random numbers in the 32768-60999 range.
|
|
21
|
+
*/
|
|
22
|
+
export async function findOpenPort(
|
|
23
|
+
preferred: number,
|
|
24
|
+
options: { maxAttempts?: number; host?: string } = {},
|
|
25
|
+
): Promise<number> {
|
|
26
|
+
const maxAttempts = options.maxAttempts ?? 50;
|
|
27
|
+
const host = options.host ?? "0.0.0.0";
|
|
28
|
+
|
|
29
|
+
if (!Number.isInteger(preferred) || preferred < 1 || preferred > 65535) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`findOpenPort: preferred port ${preferred} is not a valid TCP port`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`findOpenPort: maxAttempts ${maxAttempts} must be a positive integer`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let lastError: Error | null = null;
|
|
41
|
+
for (let offset = 0; offset < maxAttempts; offset++) {
|
|
42
|
+
const port = preferred + offset;
|
|
43
|
+
if (port > 65535) break;
|
|
44
|
+
try {
|
|
45
|
+
await probePort(port, host);
|
|
46
|
+
return port;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
lastError = err as Error;
|
|
49
|
+
if (!isPortInUseError(err)) {
|
|
50
|
+
// EACCES / EPERM / etc. are not "try the next port" signals — those
|
|
51
|
+
// are configuration problems an operator needs to see immediately.
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
throw new Error(
|
|
57
|
+
`findOpenPort: no open port in range [${preferred}, ${preferred + maxAttempts - 1}]` +
|
|
58
|
+
(lastError ? ` (last error: ${lastError.message})` : ""),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolves if `port` on `host` can be bound right now. Rejects with the
|
|
64
|
+
* server's `error` event (typically `EADDRINUSE`) otherwise. Always closes
|
|
65
|
+
* the probe server before resolving so we don't leak the port we just
|
|
66
|
+
* proved was free.
|
|
67
|
+
*/
|
|
68
|
+
function probePort(port: number, host: string): Promise<void> {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const server = createServer();
|
|
71
|
+
const cleanup = (cb: () => void): void => {
|
|
72
|
+
server.removeAllListeners();
|
|
73
|
+
server.close(() => cb());
|
|
74
|
+
};
|
|
75
|
+
server.once("error", (err) => {
|
|
76
|
+
// close() on a server that never listened is a no-op; calling it
|
|
77
|
+
// anyway keeps cleanup uniform.
|
|
78
|
+
cleanup(() => reject(err));
|
|
79
|
+
});
|
|
80
|
+
server.once("listening", () => {
|
|
81
|
+
cleanup(() => resolve());
|
|
82
|
+
});
|
|
83
|
+
server.listen(port, host);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isPortInUseError(err: unknown): boolean {
|
|
88
|
+
if (err instanceof Error && "code" in err) {
|
|
89
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
90
|
+
return code === "EADDRINUSE" || code === "EADDRNOTAVAIL";
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
package/src/lib/statefulset.ts
CHANGED
|
@@ -257,7 +257,6 @@ export interface BuildServiceRunArgsOpts extends DockerRunSecrets {
|
|
|
257
257
|
instanceName: string;
|
|
258
258
|
res: DockerResourceNames;
|
|
259
259
|
extraAssistantEnv?: Record<string, string>;
|
|
260
|
-
defaultWorkspaceConfigPath?: string;
|
|
261
260
|
/** Avatar device path, if available. Injected by `docker.ts` after resolving. */
|
|
262
261
|
avatarDevicePath?: string;
|
|
263
262
|
}
|
|
@@ -286,7 +285,6 @@ export function buildServiceRunArgs(
|
|
|
286
285
|
instanceName,
|
|
287
286
|
res,
|
|
288
287
|
extraAssistantEnv,
|
|
289
|
-
defaultWorkspaceConfigPath,
|
|
290
288
|
avatarDevicePath,
|
|
291
289
|
} = opts;
|
|
292
290
|
|
|
@@ -355,14 +353,6 @@ export function buildServiceRunArgs(
|
|
|
355
353
|
"-e", `GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
|
|
356
354
|
);
|
|
357
355
|
|
|
358
|
-
if (defaultWorkspaceConfigPath) {
|
|
359
|
-
const cPath = `/tmp/vellum-default-workspace-config-${Date.now()}.json`;
|
|
360
|
-
args.push(
|
|
361
|
-
"-v", `${defaultWorkspaceConfigPath}:${cPath}:ro`,
|
|
362
|
-
"-e", `VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH=${cPath}`,
|
|
363
|
-
);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
356
|
if (extraAssistantEnv) {
|
|
367
357
|
for (const [k, v] of Object.entries(extraAssistantEnv)) {
|
|
368
358
|
args.push("-e", `${k}=${v}`);
|
package/src/lib/step-runner.ts
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Build the error message for a failed child process. **Never include the
|
|
5
|
+
* argv** — `docker run ...` invocations carry `-e ANTHROPIC_API_KEY=…` /
|
|
6
|
+
* `-e OPENAI_API_KEY=…` style flags, and the resulting `Error.message`
|
|
7
|
+
* propagates all the way to:
|
|
8
|
+
*
|
|
9
|
+
* - the CLI's top-level catch (`console.error("Error:", err.message)`)
|
|
10
|
+
* which leaks them onto stderr,
|
|
11
|
+
* - `subprocess-*.log` files captured by the evals harness when it
|
|
12
|
+
* spawns `vellum hatch` (which then becomes the inlined log on the
|
|
13
|
+
* run-detail report page),
|
|
14
|
+
* - `run.json#error` and the last-N-lines tail in `progress.ndjson`
|
|
15
|
+
* that the evals harness emits for `SubprocessFailedError`.
|
|
16
|
+
*
|
|
17
|
+
* The diagnostic substring callers actually grep for ("no such container",
|
|
18
|
+
* "is not running", "port is already allocated", …) lives in the child's
|
|
19
|
+
* stderr/stdout, which we DO preserve below. Keep the command name only —
|
|
20
|
+
* it's enough to disambiguate which step failed without quoting secrets.
|
|
21
|
+
*
|
|
22
|
+
* Exported so the unit test can assert no `-e KEY=...` slips back in.
|
|
23
|
+
*/
|
|
24
|
+
export function buildExecErrorMessage(
|
|
25
|
+
command: string,
|
|
26
|
+
code: number | null,
|
|
27
|
+
stderr: string,
|
|
28
|
+
stdout: string,
|
|
29
|
+
): string {
|
|
30
|
+
const codeLabel = code === null ? "an unknown code" : `code ${code}`;
|
|
31
|
+
const header = `${command} exited with ${codeLabel}`;
|
|
32
|
+
const output = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
|
|
33
|
+
return output ? `${header}\n${output}` : header;
|
|
34
|
+
}
|
|
35
|
+
|
|
3
36
|
export function exec(
|
|
4
37
|
command: string,
|
|
5
38
|
args: string[],
|
|
@@ -25,11 +58,7 @@ export function exec(
|
|
|
25
58
|
if (code === 0) {
|
|
26
59
|
resolve();
|
|
27
60
|
} else {
|
|
28
|
-
|
|
29
|
-
const output = [stderr.trim(), stdout.trim()]
|
|
30
|
-
.filter(Boolean)
|
|
31
|
-
.join("\n");
|
|
32
|
-
reject(new Error(output ? `${msg}\n${output}` : msg));
|
|
61
|
+
reject(new Error(buildExecErrorMessage(command, code, stderr, stdout)));
|
|
33
62
|
}
|
|
34
63
|
});
|
|
35
64
|
child.on("error", reject);
|
|
@@ -61,8 +90,12 @@ export function execOutput(
|
|
|
61
90
|
if (code === 0) {
|
|
62
91
|
resolve(stdout.trim());
|
|
63
92
|
} else {
|
|
64
|
-
|
|
65
|
-
|
|
93
|
+
// execOutput intentionally drops stdout from the error message
|
|
94
|
+
// (callers that read stdout via the success path don't expect
|
|
95
|
+
// partial stdout to land in error.message). Stderr is enough
|
|
96
|
+
// for diagnostics, and the no-args-in-message guarantee from
|
|
97
|
+
// exec() still holds.
|
|
98
|
+
reject(new Error(buildExecErrorMessage(command, code, stderr, "")));
|
|
66
99
|
}
|
|
67
100
|
});
|
|
68
101
|
child.on("error", reject);
|
|
@@ -26,6 +26,7 @@ export const LLM_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
|
|
|
26
26
|
gemini: "GEMINI_API_KEY",
|
|
27
27
|
fireworks: "FIREWORKS_API_KEY",
|
|
28
28
|
openrouter: "OPENROUTER_API_KEY",
|
|
29
|
+
minimax: "MINIMAX_API_KEY",
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
/** Search-provider env var names. Mirrors `SEARCH_PROVIDER_CATALOG` BYOK entries. */
|