@vellumai/cli 0.8.12-staging.2 → 0.9.0-staging.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bun.lock +49 -56
- package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +224 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +8 -1
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +0 -15
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +8 -4
- package/node_modules/@vellumai/local-mode/src/sleep.ts +80 -0
- package/node_modules/@vellumai/local-mode/src/status.ts +342 -0
- package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
- package/package.json +3 -3
- package/src/__tests__/assistant-config.test.ts +1 -2
- package/src/__tests__/device-id.test.ts +6 -14
- package/src/__tests__/helpers/os-mock.ts +27 -0
- package/src/__tests__/login-loopback.test.ts +71 -0
- package/src/__tests__/multi-local.test.ts +2 -10
- package/src/__tests__/nginx-ingress-command.test.ts +69 -0
- package/src/__tests__/nginx-ingress.test.ts +401 -0
- package/src/__tests__/sleep.test.ts +4 -0
- package/src/__tests__/teleport.test.ts +6 -9
- package/src/__tests__/tunnel.test.ts +164 -0
- package/src/__tests__/wake.test.ts +15 -4
- package/src/__tests__/workos-pkce.test.ts +314 -0
- package/src/commands/flags.ts +1 -22
- package/src/commands/hatch.ts +90 -9
- package/src/commands/login.ts +123 -59
- package/src/commands/nginx-ingress.ts +291 -0
- package/src/commands/rollback.ts +0 -6
- package/src/commands/sleep.ts +17 -0
- package/src/commands/teleport.ts +23 -36
- package/src/commands/tunnel.ts +69 -11
- package/src/commands/upgrade.ts +0 -2
- package/src/commands/wake.ts +7 -5
- package/src/commands/workflows.ts +301 -0
- package/src/index.ts +8 -0
- package/src/lib/arg-utils.ts +48 -0
- package/src/lib/assistant-client.ts +2 -0
- package/src/lib/assistant-config.ts +0 -7
- package/src/lib/cloudflare-tunnel.ts +15 -2
- package/src/lib/docker.ts +103 -49
- package/src/lib/feature-flags.test.ts +157 -0
- package/src/lib/feature-flags.ts +38 -0
- package/src/lib/hatch-local.ts +0 -1
- package/src/lib/local.ts +5 -0
- package/src/lib/nginx-ingress.ts +574 -0
- package/src/lib/ngrok.ts +26 -4
- package/src/lib/platform-client.ts +0 -1
- package/src/lib/retire-local.ts +5 -0
- package/src/lib/statefulset.ts +73 -21
- package/src/lib/sync-cloud-assistants.ts +4 -17
- package/src/lib/upgrade-lifecycle.ts +1 -2
- package/src/lib/workos-pkce.ts +160 -0
|
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
|
|
6
6
|
import { GATEWAY_PORT } from "./constants.js";
|
|
7
|
+
import { resolveTunnelTargetPort } from "./nginx-ingress.js";
|
|
7
8
|
|
|
8
9
|
// ── Workspace config helpers (mirrors the pattern in ngrok.ts) ───────────────
|
|
9
10
|
|
|
@@ -112,7 +113,7 @@ export function waitForCloudflareTunnelUrl(
|
|
|
112
113
|
reject(
|
|
113
114
|
new Error(
|
|
114
115
|
`cloudflared tunnel URL did not appear within ${timeoutMs / 1000}s. ` +
|
|
115
|
-
`Ensure cloudflared is working: try running 'cloudflared tunnel --url http://localhost:
|
|
116
|
+
`Ensure cloudflared is working: try running 'cloudflared tunnel --url http://localhost:7840' manually.`,
|
|
116
117
|
),
|
|
117
118
|
);
|
|
118
119
|
}, timeoutMs);
|
|
@@ -175,6 +176,8 @@ export interface RunCloudflareTunnelOptions {
|
|
|
175
176
|
port?: number;
|
|
176
177
|
/** Workspace directory for config read/write. Defaults to ~/.vellum/workspace. */
|
|
177
178
|
workspaceDir?: string;
|
|
179
|
+
/** Prefer nginx ingress over the gateway port when it is running. */
|
|
180
|
+
preferNginxIngress?: boolean;
|
|
178
181
|
}
|
|
179
182
|
|
|
180
183
|
export async function runCloudflareTunnel(
|
|
@@ -197,8 +200,18 @@ export async function runCloudflareTunnel(
|
|
|
197
200
|
|
|
198
201
|
console.log(`Using ${version}`);
|
|
199
202
|
|
|
200
|
-
const port = opts.port ?? GATEWAY_PORT;
|
|
201
203
|
const workspaceDir = opts.workspaceDir ?? getDefaultWorkspaceDir();
|
|
204
|
+
const gatewayPort = opts.port ?? GATEWAY_PORT;
|
|
205
|
+
const { port, viaIngress } = resolveTunnelTargetPort(
|
|
206
|
+
workspaceDir,
|
|
207
|
+
gatewayPort,
|
|
208
|
+
{ preferNginxIngress: opts.preferNginxIngress === true },
|
|
209
|
+
);
|
|
210
|
+
if (viaIngress) {
|
|
211
|
+
console.log(
|
|
212
|
+
`nginx ingress detected — tunneling to it on 127.0.0.1:${port}.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
202
215
|
|
|
203
216
|
console.log(`Starting cloudflared quick tunnel to localhost:${port}...`);
|
|
204
217
|
console.log("No Cloudflare account required — quick tunnels are free.");
|
package/src/lib/docker.ts
CHANGED
|
@@ -15,7 +15,6 @@ import cliPkg from "../../package.json";
|
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
findAssistantByName,
|
|
18
|
-
normalizeVersion,
|
|
19
18
|
saveAssistantEntry,
|
|
20
19
|
setActiveAssistant,
|
|
21
20
|
} from "./assistant-config";
|
|
@@ -275,8 +274,53 @@ function ensureLocalBinOnPath(): void {
|
|
|
275
274
|
}
|
|
276
275
|
}
|
|
277
276
|
|
|
278
|
-
export interface
|
|
277
|
+
export interface HatchDockerParams {
|
|
278
|
+
/** Assistant species to hatch (e.g. `"vellum"`). */
|
|
279
|
+
species: Species;
|
|
280
|
+
/** Run detached without attaching to logs or interactive setup. */
|
|
281
|
+
detached?: boolean;
|
|
282
|
+
/** Instance display name. Defaults to an auto-generated name. */
|
|
283
|
+
name?: string | null;
|
|
284
|
+
/** Build from a local source tree and hot-reload on change. */
|
|
285
|
+
watch?: boolean;
|
|
286
|
+
/** Hatch-time config values (key → value). */
|
|
287
|
+
configValues?: Record<string, string>;
|
|
288
|
+
/** Extra env vars forwarded into the assistant container. */
|
|
289
|
+
flagEnvVars?: Record<string, string>;
|
|
279
290
|
setupProviderCredentials?: boolean;
|
|
291
|
+
/**
|
|
292
|
+
* Path to a local source tree to build images from before hatching. When
|
|
293
|
+
* provided, this path is used directly as the repo root and no file
|
|
294
|
+
* watcher is started — useful for callers (e.g. evals) that want each
|
|
295
|
+
* run to pick up local CLI changes without keeping a long-lived watcher
|
|
296
|
+
* process around. `--watch` independently auto-detects the repo root and
|
|
297
|
+
* also enables hot-reload.
|
|
298
|
+
*/
|
|
299
|
+
sourcePath?: string | null;
|
|
300
|
+
analyze?: boolean;
|
|
301
|
+
/**
|
|
302
|
+
* Name of an existing container whose network namespace the assistant,
|
|
303
|
+
* gateway, and credential-executor join (`--network=container:<name>`),
|
|
304
|
+
* instead of the assistant owning a freshly-created per-instance network.
|
|
305
|
+
* When set, hatch creates no Docker network and publishes no host ports —
|
|
306
|
+
* the namespace owner is responsible for publishing the gateway port — so
|
|
307
|
+
* `gatewayPort` must also be supplied (the owner had to publish it before
|
|
308
|
+
* hatch ran).
|
|
309
|
+
*/
|
|
310
|
+
netnsContainer?: string;
|
|
311
|
+
/**
|
|
312
|
+
* Explicit host port to record as the gateway's `runtimeUrl` instead of
|
|
313
|
+
* auto-allocating a free port. Required alongside `netnsContainer`, where
|
|
314
|
+
* the namespace owner — not hatch — owns port allocation and publishing.
|
|
315
|
+
*/
|
|
316
|
+
gatewayPort?: number;
|
|
317
|
+
/**
|
|
318
|
+
* Host path to a PEM CA bundle bind-mounted into the assistant container
|
|
319
|
+
* and trusted at process start via `NODE_EXTRA_CA_CERTS`. Lets the daemon
|
|
320
|
+
* trust a TLS-terminating egress proxy from its very first outbound
|
|
321
|
+
* connection.
|
|
322
|
+
*/
|
|
323
|
+
assistantCaCertPath?: string;
|
|
280
324
|
}
|
|
281
325
|
|
|
282
326
|
export type DockerProviderCredentialSetupAction =
|
|
@@ -670,6 +714,8 @@ export async function startContainers(
|
|
|
670
714
|
imageTags: Record<ServiceName, string>;
|
|
671
715
|
instanceName: string;
|
|
672
716
|
res: ReturnType<typeof dockerResourceNames>;
|
|
717
|
+
netnsContainer?: string;
|
|
718
|
+
assistantCaCertPath?: string;
|
|
673
719
|
},
|
|
674
720
|
log: (msg: string) => void,
|
|
675
721
|
): Promise<void> {
|
|
@@ -892,6 +938,8 @@ function startFileWatcher(opts: {
|
|
|
892
938
|
instanceName: string;
|
|
893
939
|
repoRoot: string;
|
|
894
940
|
res: ReturnType<typeof dockerResourceNames>;
|
|
941
|
+
netnsContainer?: string;
|
|
942
|
+
assistantCaCertPath?: string;
|
|
895
943
|
}): () => void {
|
|
896
944
|
const { gatewayPort, imageTags, instanceName, repoRoot, res } = opts;
|
|
897
945
|
|
|
@@ -913,6 +961,8 @@ function startFileWatcher(opts: {
|
|
|
913
961
|
instanceName,
|
|
914
962
|
res,
|
|
915
963
|
avatarDevicePath: resolveAvatarDevicePath(),
|
|
964
|
+
netnsContainer: opts.netnsContainer,
|
|
965
|
+
assistantCaCertPath: opts.assistantCaCertPath,
|
|
916
966
|
});
|
|
917
967
|
const containerForService: Record<ServiceName, string> = {
|
|
918
968
|
assistant: res.assistantContainer,
|
|
@@ -1031,31 +1081,19 @@ function startFileWatcher(opts: {
|
|
|
1031
1081
|
};
|
|
1032
1082
|
}
|
|
1033
1083
|
|
|
1034
|
-
export
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
sourcePath?: string | null;
|
|
1044
|
-
analyze?: boolean;
|
|
1045
|
-
}
|
|
1084
|
+
export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
1085
|
+
const {
|
|
1086
|
+
species,
|
|
1087
|
+
detached = false,
|
|
1088
|
+
name = null,
|
|
1089
|
+
configValues = {},
|
|
1090
|
+
flagEnvVars = {},
|
|
1091
|
+
} = params;
|
|
1092
|
+
let watch = params.watch ?? false;
|
|
1046
1093
|
|
|
1047
|
-
export async function hatchDocker(
|
|
1048
|
-
species: Species,
|
|
1049
|
-
detached: boolean,
|
|
1050
|
-
name: string | null,
|
|
1051
|
-
watch: boolean = false,
|
|
1052
|
-
configValues: Record<string, string> = {},
|
|
1053
|
-
flagEnvVars: Record<string, string> = {},
|
|
1054
|
-
options: HatchDockerOptions = {},
|
|
1055
|
-
): Promise<void> {
|
|
1056
1094
|
resetLogFile("hatch.log");
|
|
1057
1095
|
const provider =
|
|
1058
|
-
|
|
1096
|
+
params.setupProviderCredentials === false
|
|
1059
1097
|
? undefined
|
|
1060
1098
|
: resolveHatchProvider(configValues);
|
|
1061
1099
|
|
|
@@ -1070,21 +1108,32 @@ export async function hatchDocker(
|
|
|
1070
1108
|
await ensureDockerInstalled();
|
|
1071
1109
|
|
|
1072
1110
|
const instanceName = generateInstanceName(species, name);
|
|
1073
|
-
// Resolve the gateway's host port
|
|
1111
|
+
// Resolve the gateway's host port. When joining an externally-owned
|
|
1112
|
+
// network namespace, the owner has already published the gateway port,
|
|
1113
|
+
// so the caller — not hatch — owns port allocation; use the supplied
|
|
1114
|
+
// port verbatim. Otherwise resolve it dynamically: the env-default
|
|
1074
1115
|
// (production 7830 / non-prod overrides) is just the *preferred*
|
|
1075
|
-
// starting point — if it's taken by another local assistant, eval
|
|
1076
|
-
//
|
|
1077
|
-
//
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1116
|
+
// starting point — if it's taken by another local assistant, eval run,
|
|
1117
|
+
// or unrelated process, we walk upward until we find a free port, so
|
|
1118
|
+
// concurrent instances don't collide on a docker bind error.
|
|
1119
|
+
let gatewayPort: number;
|
|
1120
|
+
if (params.netnsContainer) {
|
|
1121
|
+
if (params.gatewayPort === undefined) {
|
|
1122
|
+
throw new Error(
|
|
1123
|
+
"hatchDocker: gatewayPort is required when netnsContainer is set (the namespace owner publishes the port before hatch runs)",
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
gatewayPort = params.gatewayPort;
|
|
1127
|
+
} else {
|
|
1128
|
+
const preferredGatewayPort = getDefaultPorts(
|
|
1129
|
+
getCurrentEnvironment(),
|
|
1130
|
+
).gateway;
|
|
1131
|
+
gatewayPort = await findOpenPort(preferredGatewayPort);
|
|
1132
|
+
if (gatewayPort !== preferredGatewayPort) {
|
|
1133
|
+
log(
|
|
1134
|
+
`Preferred gateway port ${preferredGatewayPort} is in use; allocated ${gatewayPort} for this instance.`,
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1088
1137
|
}
|
|
1089
1138
|
|
|
1090
1139
|
const imageTags: Record<ServiceName, string> = {
|
|
@@ -1094,8 +1143,8 @@ export async function hatchDocker(
|
|
|
1094
1143
|
};
|
|
1095
1144
|
|
|
1096
1145
|
const sourcePath =
|
|
1097
|
-
typeof
|
|
1098
|
-
?
|
|
1146
|
+
typeof params.sourcePath === "string" && params.sourcePath.length > 0
|
|
1147
|
+
? params.sourcePath
|
|
1099
1148
|
: null;
|
|
1100
1149
|
const buildFromSource = sourcePath !== null;
|
|
1101
1150
|
let repoRoot: string | undefined;
|
|
@@ -1152,8 +1201,6 @@ export async function hatchDocker(
|
|
|
1152
1201
|
log("✅ Docker images built");
|
|
1153
1202
|
}
|
|
1154
1203
|
|
|
1155
|
-
let releaseVersion: string | undefined;
|
|
1156
|
-
|
|
1157
1204
|
if (!mode.build || !repoRoot) {
|
|
1158
1205
|
emitProgress(2, 6, "Pulling images...");
|
|
1159
1206
|
|
|
@@ -1190,9 +1237,6 @@ export async function hatchDocker(
|
|
|
1190
1237
|
`⚠️ Platform releases unavailable; falling back to CLI version ${versionTag}`,
|
|
1191
1238
|
);
|
|
1192
1239
|
}
|
|
1193
|
-
if (versionTag !== "latest") {
|
|
1194
|
-
releaseVersion = normalizeVersion(versionTag);
|
|
1195
|
-
}
|
|
1196
1240
|
log("🔍 Resolving image references...");
|
|
1197
1241
|
const resolved = await resolveImageRefs(versionTag, log);
|
|
1198
1242
|
imageTags.assistant = resolved.imageTags.assistant;
|
|
@@ -1247,8 +1291,15 @@ export async function hatchDocker(
|
|
|
1247
1291
|
const res = dockerResourceNames(instanceName);
|
|
1248
1292
|
|
|
1249
1293
|
emitProgress(3, 6, "Creating volumes...");
|
|
1250
|
-
|
|
1251
|
-
|
|
1294
|
+
// When joining an externally-owned network namespace, the owner already
|
|
1295
|
+
// provides the network stack — creating a per-instance network here would
|
|
1296
|
+
// be unused and leak on teardown.
|
|
1297
|
+
if (params.netnsContainer) {
|
|
1298
|
+
log("📁 Joining existing network namespace; creating volumes...");
|
|
1299
|
+
} else {
|
|
1300
|
+
log("📁 Creating network and volumes...");
|
|
1301
|
+
await exec("docker", ["network", "create", res.network]);
|
|
1302
|
+
}
|
|
1252
1303
|
await exec("docker", ["volume", "create", res.socketVolume]);
|
|
1253
1304
|
await exec("docker", ["volume", "create", res.assistantIpcVolume]);
|
|
1254
1305
|
await exec("docker", ["volume", "create", res.gatewayIpcVolume]);
|
|
@@ -1356,6 +1407,8 @@ export async function hatchDocker(
|
|
|
1356
1407
|
imageTags,
|
|
1357
1408
|
instanceName,
|
|
1358
1409
|
res,
|
|
1410
|
+
netnsContainer: params.netnsContainer,
|
|
1411
|
+
assistantCaCertPath: params.assistantCaCertPath,
|
|
1359
1412
|
},
|
|
1360
1413
|
log,
|
|
1361
1414
|
);
|
|
@@ -1370,7 +1423,6 @@ export async function hatchDocker(
|
|
|
1370
1423
|
cloud: "docker",
|
|
1371
1424
|
species,
|
|
1372
1425
|
hatchedAt: new Date().toISOString(),
|
|
1373
|
-
version: releaseVersion,
|
|
1374
1426
|
guardianBootstrapSecret: ownSecret,
|
|
1375
1427
|
containerInfo: {
|
|
1376
1428
|
assistantImage: imageTags.assistant,
|
|
@@ -1396,7 +1448,7 @@ export async function hatchDocker(
|
|
|
1396
1448
|
logFd,
|
|
1397
1449
|
runtimeUrl,
|
|
1398
1450
|
containersUpAt,
|
|
1399
|
-
analyze:
|
|
1451
|
+
analyze: params.analyze ?? false,
|
|
1400
1452
|
});
|
|
1401
1453
|
|
|
1402
1454
|
if (!ready && !(watch && repoRoot)) {
|
|
@@ -1454,6 +1506,8 @@ export async function hatchDocker(
|
|
|
1454
1506
|
instanceName,
|
|
1455
1507
|
repoRoot,
|
|
1456
1508
|
res,
|
|
1509
|
+
netnsContainer: params.netnsContainer,
|
|
1510
|
+
assistantCaCertPath: params.assistantCaCertPath,
|
|
1457
1511
|
});
|
|
1458
1512
|
|
|
1459
1513
|
await new Promise<void>((resolve) => {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
test,
|
|
8
|
+
} from "bun:test";
|
|
9
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
isAssistantFeatureFlagEnabled,
|
|
15
|
+
WEB_REMOTE_INGRESS_FLAG,
|
|
16
|
+
} from "./feature-flags.js";
|
|
17
|
+
|
|
18
|
+
const testDir = mkdtempSync(join(tmpdir(), "cli-feature-flags-test-"));
|
|
19
|
+
const originalFetch = globalThis.fetch;
|
|
20
|
+
const originalLockfileDir = process.env.VELLUM_LOCKFILE_DIR;
|
|
21
|
+
|
|
22
|
+
function writeLockfile(): void {
|
|
23
|
+
mkdirSync(testDir, { recursive: true });
|
|
24
|
+
writeFileSync(
|
|
25
|
+
join(testDir, ".vellum.lock.json"),
|
|
26
|
+
JSON.stringify(
|
|
27
|
+
{
|
|
28
|
+
activeAssistant: "assistant-1",
|
|
29
|
+
assistants: [
|
|
30
|
+
{
|
|
31
|
+
assistantId: "assistant-1",
|
|
32
|
+
runtimeUrl: "http://127.0.0.1:7830",
|
|
33
|
+
cloud: "local",
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
null,
|
|
38
|
+
2,
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
44
|
+
return new Response(JSON.stringify(body), {
|
|
45
|
+
status,
|
|
46
|
+
headers: { "content-type": "application/json" },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function mockFetch(response: Response): void {
|
|
51
|
+
const fetchMock = async () => response;
|
|
52
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function mockFetchWithUrls(response: Response): string[] {
|
|
56
|
+
const urls: string[] = [];
|
|
57
|
+
const fetchMock = async (input: RequestInfo | URL) => {
|
|
58
|
+
urls.push(String(input));
|
|
59
|
+
return response;
|
|
60
|
+
};
|
|
61
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
62
|
+
return urls;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("isAssistantFeatureFlagEnabled", () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
68
|
+
rmSync(join(testDir, ".vellum.lock.json"), { force: true });
|
|
69
|
+
writeLockfile();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
globalThis.fetch = originalFetch;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterAll(() => {
|
|
77
|
+
if (originalLockfileDir === undefined) {
|
|
78
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
79
|
+
} else {
|
|
80
|
+
process.env.VELLUM_LOCKFILE_DIR = originalLockfileDir;
|
|
81
|
+
}
|
|
82
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("returns true when the assistant flag is enabled", async () => {
|
|
86
|
+
mockFetch(
|
|
87
|
+
jsonResponse({
|
|
88
|
+
flags: [{ key: WEB_REMOTE_INGRESS_FLAG, enabled: true }],
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
await expect(
|
|
93
|
+
isAssistantFeatureFlagEnabled("assistant-1", WEB_REMOTE_INGRESS_FLAG),
|
|
94
|
+
).resolves.toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("uses the supplied runtime URL instead of a stale lockfile runtimeUrl", async () => {
|
|
98
|
+
writeFileSync(
|
|
99
|
+
join(testDir, ".vellum.lock.json"),
|
|
100
|
+
JSON.stringify(
|
|
101
|
+
{
|
|
102
|
+
activeAssistant: "assistant-1",
|
|
103
|
+
assistants: [
|
|
104
|
+
{
|
|
105
|
+
assistantId: "assistant-1",
|
|
106
|
+
runtimeUrl: "https://stale-tunnel.ngrok-free.dev",
|
|
107
|
+
cloud: "local",
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
null,
|
|
112
|
+
2,
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
const urls = mockFetchWithUrls(
|
|
116
|
+
jsonResponse({
|
|
117
|
+
flags: [{ key: WEB_REMOTE_INGRESS_FLAG, enabled: true }],
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
await expect(
|
|
122
|
+
isAssistantFeatureFlagEnabled("assistant-1", WEB_REMOTE_INGRESS_FLAG, {
|
|
123
|
+
runtimeUrl: "http://127.0.0.1:9123",
|
|
124
|
+
}),
|
|
125
|
+
).resolves.toBe(true);
|
|
126
|
+
|
|
127
|
+
expect(urls).toEqual([
|
|
128
|
+
"http://127.0.0.1:9123/v1/assistants/assistant-1/feature-flags",
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("returns false when the assistant flag is disabled or missing", async () => {
|
|
133
|
+
mockFetch(
|
|
134
|
+
jsonResponse({
|
|
135
|
+
flags: [{ key: WEB_REMOTE_INGRESS_FLAG, enabled: false }],
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
await expect(
|
|
140
|
+
isAssistantFeatureFlagEnabled("assistant-1", WEB_REMOTE_INGRESS_FLAG),
|
|
141
|
+
).resolves.toBe(false);
|
|
142
|
+
|
|
143
|
+
mockFetch(jsonResponse({ flags: [] }));
|
|
144
|
+
|
|
145
|
+
await expect(
|
|
146
|
+
isAssistantFeatureFlagEnabled("assistant-1", WEB_REMOTE_INGRESS_FLAG),
|
|
147
|
+
).resolves.toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("throws when the gateway rejects the flag request", async () => {
|
|
151
|
+
mockFetch(jsonResponse({ error: "nope" }, 500));
|
|
152
|
+
|
|
153
|
+
await expect(
|
|
154
|
+
isAssistantFeatureFlagEnabled("assistant-1", WEB_REMOTE_INGRESS_FLAG),
|
|
155
|
+
).rejects.toThrow("Failed to fetch feature flags");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AssistantClient } from "./assistant-client.js";
|
|
2
|
+
|
|
3
|
+
export const WEB_REMOTE_INGRESS_FLAG = "web-remote-ingress";
|
|
4
|
+
|
|
5
|
+
type FeatureFlagEntry = {
|
|
6
|
+
key?: unknown;
|
|
7
|
+
enabled?: unknown;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type FeatureFlagsResponse = {
|
|
11
|
+
flags?: FeatureFlagEntry[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function isAssistantFeatureFlagEnabled(
|
|
15
|
+
assistantId: string,
|
|
16
|
+
key: string,
|
|
17
|
+
opts: { runtimeUrl?: string } = {},
|
|
18
|
+
): Promise<boolean> {
|
|
19
|
+
const client = new AssistantClient({
|
|
20
|
+
assistantId,
|
|
21
|
+
runtimeUrl: opts.runtimeUrl,
|
|
22
|
+
});
|
|
23
|
+
const res = await client.get("/feature-flags");
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
const body = await res.text().catch(() => "");
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Failed to fetch feature flags: HTTP ${res.status} ${body}`.trim(),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const data = (await res.json()) as FeatureFlagsResponse;
|
|
32
|
+
const flag = data.flags?.find((entry) => entry.key === key);
|
|
33
|
+
return flag?.enabled === true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatFeatureFlagGateMessage(flagKey: string): string {
|
|
37
|
+
return `This command is behind the \`${flagKey}\` feature flag. Enable it with \`vellum flags set ${flagKey} true\` and try again.`;
|
|
38
|
+
}
|
package/src/lib/hatch-local.ts
CHANGED
package/src/lib/local.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "./assistant-config.js";
|
|
18
18
|
import { GATEWAY_PORT } from "./constants.js";
|
|
19
19
|
import { httpHealthCheck, waitForDaemonReady } from "./http-client.js";
|
|
20
|
+
import { stopIngressNginx } from "./nginx-ingress.js";
|
|
20
21
|
import {
|
|
21
22
|
resolveProcessState,
|
|
22
23
|
stopProcess,
|
|
@@ -1240,4 +1241,8 @@ export async function stopLocalProcesses(
|
|
|
1240
1241
|
unlinkSync(ngrokPidFile);
|
|
1241
1242
|
} catch {}
|
|
1242
1243
|
}
|
|
1244
|
+
|
|
1245
|
+
// Stop the nginx ingress if one is fronting this gateway (it guards against
|
|
1246
|
+
// PID reuse itself, mirroring the ngrok handling above).
|
|
1247
|
+
await stopIngressNginx(join(vellumDir, "workspace"));
|
|
1243
1248
|
}
|