@vellumai/cli 0.10.11-dev.202607232324.61067fa → 0.10.11-dev.202607240043.b26a41c
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/package.json +1 -1
- package/src/__tests__/wake.test.ts +178 -0
- package/src/commands/nginx-ingress.ts +57 -71
- package/src/commands/wake.ts +118 -0
- package/src/lib/nginx-ingress.ts +90 -0
package/package.json
CHANGED
|
@@ -14,8 +14,11 @@ import { join } from "node:path";
|
|
|
14
14
|
|
|
15
15
|
import * as assistantConfig from "../lib/assistant-config.js";
|
|
16
16
|
import * as docker from "../lib/docker.js";
|
|
17
|
+
import * as featureFlags from "../lib/feature-flags.js";
|
|
17
18
|
import * as guardianToken from "../lib/guardian-token.js";
|
|
19
|
+
import * as ingressConfig from "../lib/ingress-config.js";
|
|
18
20
|
import * as local from "../lib/local.js";
|
|
21
|
+
import * as nginxIngress from "../lib/nginx-ingress.js";
|
|
19
22
|
import * as ngrok from "../lib/ngrok.js";
|
|
20
23
|
import * as processLib from "../lib/process.js";
|
|
21
24
|
import type { AssistantEntry } from "../lib/assistant-config.js";
|
|
@@ -138,11 +141,50 @@ mock.module("../lib/ngrok", () => ({
|
|
|
138
141
|
maybeStartNgrokTunnel: maybeStartNgrokTunnelMock,
|
|
139
142
|
}));
|
|
140
143
|
|
|
144
|
+
const realFeatureFlags = { ...featureFlags };
|
|
145
|
+
const realIngressConfig = { ...ingressConfig };
|
|
146
|
+
const realNginxIngress = { ...nginxIngress };
|
|
147
|
+
|
|
148
|
+
const isAssistantFeatureFlagEnabledMock = mock<
|
|
149
|
+
typeof featureFlags.isAssistantFeatureFlagEnabled
|
|
150
|
+
>(async () => true);
|
|
151
|
+
|
|
152
|
+
mock.module("../lib/feature-flags.js", () => ({
|
|
153
|
+
...realFeatureFlags,
|
|
154
|
+
isAssistantFeatureFlagEnabled: isAssistantFeatureFlagEnabledMock,
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
const loadRawConfigMock = mock<typeof ingressConfig.loadRawConfig>(() => ({}));
|
|
158
|
+
|
|
159
|
+
mock.module("../lib/ingress-config.js", () => ({
|
|
160
|
+
...realIngressConfig,
|
|
161
|
+
loadRawConfig: loadRawConfigMock,
|
|
162
|
+
}));
|
|
163
|
+
|
|
164
|
+
const isIngressRunningMock = mock<typeof nginxIngress.isIngressRunning>(
|
|
165
|
+
() => false,
|
|
166
|
+
);
|
|
167
|
+
const startRemoteWebIngressMock = mock<
|
|
168
|
+
typeof nginxIngress.startRemoteWebIngress
|
|
169
|
+
>(async () => ({
|
|
170
|
+
status: "started",
|
|
171
|
+
listenPort: 7840,
|
|
172
|
+
webDistDir: "/tmp/web/dist",
|
|
173
|
+
version: "nginx/1.25.3",
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
mock.module("../lib/nginx-ingress.js", () => ({
|
|
177
|
+
...realNginxIngress,
|
|
178
|
+
isIngressRunning: isIngressRunningMock,
|
|
179
|
+
startRemoteWebIngress: startRemoteWebIngressMock,
|
|
180
|
+
}));
|
|
181
|
+
|
|
141
182
|
const { wake } = await import("../commands/wake.js");
|
|
142
183
|
|
|
143
184
|
let tempDir: string;
|
|
144
185
|
let originalArgv: string[];
|
|
145
186
|
let logSpy: ReturnType<typeof spyOn>;
|
|
187
|
+
let warnSpy: ReturnType<typeof spyOn>;
|
|
146
188
|
|
|
147
189
|
function makeLocalEntry(): AssistantEntry {
|
|
148
190
|
tempDir = mkdtempSync(join(tmpdir(), "vellum-wake-test-"));
|
|
@@ -167,6 +209,7 @@ beforeEach(() => {
|
|
|
167
209
|
tempDir = "";
|
|
168
210
|
process.argv = ["bun", "vellum", "wake", "--watch", "local-assistant"];
|
|
169
211
|
logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
212
|
+
warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
170
213
|
|
|
171
214
|
const entry = makeLocalEntry();
|
|
172
215
|
resolveTargetAssistantMock.mockReset();
|
|
@@ -213,11 +256,25 @@ beforeEach(() => {
|
|
|
213
256
|
);
|
|
214
257
|
maybeStartNgrokTunnelMock.mockReset();
|
|
215
258
|
maybeStartNgrokTunnelMock.mockResolvedValue(null);
|
|
259
|
+
isAssistantFeatureFlagEnabledMock.mockReset();
|
|
260
|
+
isAssistantFeatureFlagEnabledMock.mockResolvedValue(true);
|
|
261
|
+
loadRawConfigMock.mockReset();
|
|
262
|
+
loadRawConfigMock.mockReturnValue({});
|
|
263
|
+
isIngressRunningMock.mockReset();
|
|
264
|
+
isIngressRunningMock.mockReturnValue(false);
|
|
265
|
+
startRemoteWebIngressMock.mockReset();
|
|
266
|
+
startRemoteWebIngressMock.mockResolvedValue({
|
|
267
|
+
status: "started",
|
|
268
|
+
listenPort: 7840,
|
|
269
|
+
webDistDir: "/tmp/web/dist",
|
|
270
|
+
version: "nginx/1.25.3",
|
|
271
|
+
});
|
|
216
272
|
});
|
|
217
273
|
|
|
218
274
|
afterEach(() => {
|
|
219
275
|
process.argv = originalArgv;
|
|
220
276
|
logSpy.mockRestore();
|
|
277
|
+
warnSpy.mockRestore();
|
|
221
278
|
if (tempDir) {
|
|
222
279
|
rmSync(tempDir, { recursive: true, force: true });
|
|
223
280
|
}
|
|
@@ -230,6 +287,9 @@ afterAll(() => {
|
|
|
230
287
|
mock.module("../lib/process", () => realProcessLib);
|
|
231
288
|
mock.module("../lib/local", () => realLocal);
|
|
232
289
|
mock.module("../lib/ngrok", () => realNgrok);
|
|
290
|
+
mock.module("../lib/feature-flags.js", () => realFeatureFlags);
|
|
291
|
+
mock.module("../lib/ingress-config.js", () => realIngressConfig);
|
|
292
|
+
mock.module("../lib/nginx-ingress.js", () => realNginxIngress);
|
|
233
293
|
});
|
|
234
294
|
|
|
235
295
|
describe("vellum wake", () => {
|
|
@@ -342,3 +402,121 @@ describe("vellum wake", () => {
|
|
|
342
402
|
expect(startLocalDaemonMock).not.toHaveBeenCalled();
|
|
343
403
|
});
|
|
344
404
|
});
|
|
405
|
+
|
|
406
|
+
describe("vellum wake — web ingress restore", () => {
|
|
407
|
+
const enabledConfig = {
|
|
408
|
+
ingress: { enabled: true, publicBaseUrl: "https://assistant.example.com" },
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
beforeEach(() => {
|
|
412
|
+
process.argv = ["bun", "vellum", "wake", "local-assistant"];
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("restores the edge when ingress is enabled, the flag is on, and it is not already running", async () => {
|
|
416
|
+
loadRawConfigMock.mockReturnValue(enabledConfig);
|
|
417
|
+
|
|
418
|
+
await wake();
|
|
419
|
+
|
|
420
|
+
expect(isAssistantFeatureFlagEnabledMock).toHaveBeenCalledWith(
|
|
421
|
+
"local-assistant",
|
|
422
|
+
"web-remote-ingress",
|
|
423
|
+
{ runtimeUrl: "http://127.0.0.1:7830" },
|
|
424
|
+
);
|
|
425
|
+
expect(startRemoteWebIngressMock).toHaveBeenCalledWith(
|
|
426
|
+
expect.objectContaining({
|
|
427
|
+
workspaceDir: join(tempDir, ".vellum", "workspace"),
|
|
428
|
+
gatewayPort: 7830,
|
|
429
|
+
}),
|
|
430
|
+
);
|
|
431
|
+
expect(logSpy).toHaveBeenCalledWith("Wake complete.");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("does not attempt a restore when ingress is disabled", async () => {
|
|
435
|
+
loadRawConfigMock.mockReturnValue({ ingress: { enabled: false } });
|
|
436
|
+
|
|
437
|
+
await wake();
|
|
438
|
+
|
|
439
|
+
expect(isAssistantFeatureFlagEnabledMock).not.toHaveBeenCalled();
|
|
440
|
+
expect(startRemoteWebIngressMock).not.toHaveBeenCalled();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("does not attempt a restore when ingress config is absent", async () => {
|
|
444
|
+
loadRawConfigMock.mockReturnValue({});
|
|
445
|
+
|
|
446
|
+
await wake();
|
|
447
|
+
|
|
448
|
+
expect(isAssistantFeatureFlagEnabledMock).not.toHaveBeenCalled();
|
|
449
|
+
expect(startRemoteWebIngressMock).not.toHaveBeenCalled();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("does not attempt a restore when enabled but the public URL is missing", async () => {
|
|
453
|
+
loadRawConfigMock.mockReturnValue({ ingress: { enabled: true } });
|
|
454
|
+
|
|
455
|
+
await wake();
|
|
456
|
+
|
|
457
|
+
expect(isAssistantFeatureFlagEnabledMock).not.toHaveBeenCalled();
|
|
458
|
+
expect(startRemoteWebIngressMock).not.toHaveBeenCalled();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("does not start a second edge when one is already running", async () => {
|
|
462
|
+
loadRawConfigMock.mockReturnValue(enabledConfig);
|
|
463
|
+
isIngressRunningMock.mockReturnValue(true);
|
|
464
|
+
|
|
465
|
+
await wake();
|
|
466
|
+
|
|
467
|
+
// Already up — skip the flag probe and the start entirely.
|
|
468
|
+
expect(isAssistantFeatureFlagEnabledMock).not.toHaveBeenCalled();
|
|
469
|
+
expect(startRemoteWebIngressMock).not.toHaveBeenCalled();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("skips the restore quietly with a hint when the flag is off", async () => {
|
|
473
|
+
loadRawConfigMock.mockReturnValue(enabledConfig);
|
|
474
|
+
isAssistantFeatureFlagEnabledMock.mockResolvedValue(false);
|
|
475
|
+
|
|
476
|
+
await wake();
|
|
477
|
+
|
|
478
|
+
expect(startRemoteWebIngressMock).not.toHaveBeenCalled();
|
|
479
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
480
|
+
expect.stringContaining("web-remote-ingress"),
|
|
481
|
+
);
|
|
482
|
+
expect(logSpy).toHaveBeenCalledWith("Wake complete.");
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("a thrown restore failure warns but does not fail the wake", async () => {
|
|
486
|
+
loadRawConfigMock.mockReturnValue(enabledConfig);
|
|
487
|
+
startRemoteWebIngressMock.mockRejectedValue(new Error("nginx boom"));
|
|
488
|
+
|
|
489
|
+
await wake();
|
|
490
|
+
|
|
491
|
+
expect(startRemoteWebIngressMock).toHaveBeenCalled();
|
|
492
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
493
|
+
expect(logSpy).toHaveBeenCalledWith("Wake complete.");
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("an unreachable edge warns but does not fail the wake", async () => {
|
|
497
|
+
loadRawConfigMock.mockReturnValue(enabledConfig);
|
|
498
|
+
startRemoteWebIngressMock.mockResolvedValue({
|
|
499
|
+
status: "unreachable",
|
|
500
|
+
listenPort: 7840,
|
|
501
|
+
logPath: "/tmp/nginx-ingress.log",
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
await wake();
|
|
505
|
+
|
|
506
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
507
|
+
expect(logSpy).toHaveBeenCalledWith("Wake complete.");
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("a failed flag probe warns and leaves the edge down without failing the wake", async () => {
|
|
511
|
+
loadRawConfigMock.mockReturnValue(enabledConfig);
|
|
512
|
+
isAssistantFeatureFlagEnabledMock.mockRejectedValue(
|
|
513
|
+
new Error("gateway unreachable"),
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
await wake();
|
|
517
|
+
|
|
518
|
+
expect(startRemoteWebIngressMock).not.toHaveBeenCalled();
|
|
519
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
520
|
+
expect(logSpy).toHaveBeenCalledWith("Wake complete.");
|
|
521
|
+
});
|
|
522
|
+
});
|
|
@@ -14,22 +14,16 @@ import {
|
|
|
14
14
|
isAssistantFeatureFlagEnabled,
|
|
15
15
|
WEB_REMOTE_INGRESS_FLAG,
|
|
16
16
|
} from "../lib/feature-flags.js";
|
|
17
|
-
import { waitForDaemonReady } from "../lib/http-client.js";
|
|
18
17
|
import {
|
|
19
18
|
DEFAULT_NGINX_INGRESS_PORT,
|
|
20
|
-
findWebDistDir,
|
|
21
19
|
getIngressPaths,
|
|
22
20
|
getIngressPid,
|
|
23
|
-
getNginxIngressPort,
|
|
24
|
-
getNginxVersion,
|
|
25
21
|
isIngressRunning,
|
|
26
22
|
resolveTunnelTargetPort,
|
|
27
|
-
|
|
23
|
+
startRemoteWebIngress,
|
|
28
24
|
stopIngressNginx,
|
|
29
25
|
} from "../lib/nginx-ingress.js";
|
|
30
26
|
|
|
31
|
-
const READY_TIMEOUT_MS = 5_000;
|
|
32
|
-
|
|
33
27
|
function printHelp(): void {
|
|
34
28
|
console.log("Usage: vellum nginx-ingress <subcommand> [<name>] [options]");
|
|
35
29
|
console.log("");
|
|
@@ -164,79 +158,71 @@ async function assertWebRemoteIngressEnabled(
|
|
|
164
158
|
|
|
165
159
|
async function up(target: NginxIngressTarget): Promise<void> {
|
|
166
160
|
const { workspaceDir, gatewayPort } = target;
|
|
167
|
-
const listenPort = getNginxIngressPort();
|
|
168
161
|
|
|
169
162
|
await assertWebRemoteIngressEnabled(target);
|
|
170
163
|
|
|
171
|
-
const
|
|
172
|
-
if (!version) {
|
|
173
|
-
console.error("Error: nginx is not installed.");
|
|
174
|
-
console.error("");
|
|
175
|
-
console.error("Install nginx:");
|
|
176
|
-
console.error(" macOS: brew install nginx");
|
|
177
|
-
console.error(" Linux: sudo apt install nginx");
|
|
178
|
-
console.error("");
|
|
179
|
-
console.error(
|
|
180
|
-
"Or point NGINX_BIN at an existing binary: NGINX_BIN=/path/to/nginx",
|
|
181
|
-
);
|
|
182
|
-
process.exit(1);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (isIngressRunning(workspaceDir)) {
|
|
186
|
-
console.log("nginx ingress is already running.");
|
|
187
|
-
await status(target);
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const webDistDir = findWebDistDir();
|
|
192
|
-
if (!webDistDir) {
|
|
193
|
-
console.error(
|
|
194
|
-
"Error: unable to locate built web assets for remote web ingress.",
|
|
195
|
-
);
|
|
196
|
-
console.error("");
|
|
197
|
-
console.error("Build the SPA first:");
|
|
198
|
-
console.error(" cd clients/web && VITE_PLATFORM_MODE=false bun run build");
|
|
199
|
-
console.error("");
|
|
200
|
-
console.error(
|
|
201
|
-
"Or install @vellumai/web so its packaged dist directory is available.",
|
|
202
|
-
);
|
|
203
|
-
process.exit(1);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
console.log(`Using ${version}`);
|
|
207
|
-
console.log(
|
|
208
|
-
`Starting nginx ingress on 127.0.0.1:${listenPort} → web ${webDistDir} + gateway 127.0.0.1:${gatewayPort}...`,
|
|
209
|
-
);
|
|
210
|
-
|
|
211
|
-
const child = startIngressNginx({
|
|
164
|
+
const result = await startRemoteWebIngress({
|
|
212
165
|
workspaceDir,
|
|
213
166
|
gatewayPort,
|
|
214
|
-
listenPort
|
|
215
|
-
|
|
167
|
+
onStarting: ({ version, webDistDir, listenPort }) => {
|
|
168
|
+
console.log(`Using ${version}`);
|
|
169
|
+
console.log(
|
|
170
|
+
`Starting nginx ingress on 127.0.0.1:${listenPort} → web ${webDistDir} + gateway 127.0.0.1:${gatewayPort}...`,
|
|
171
|
+
);
|
|
172
|
+
},
|
|
216
173
|
});
|
|
217
|
-
child.unref();
|
|
218
174
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
175
|
+
switch (result.status) {
|
|
176
|
+
case "already-running":
|
|
177
|
+
console.log("nginx ingress is already running.");
|
|
178
|
+
await status(target);
|
|
179
|
+
return;
|
|
180
|
+
case "started":
|
|
181
|
+
console.log("");
|
|
182
|
+
console.log(
|
|
183
|
+
`nginx ingress running: http://127.0.0.1:${result.listenPort}`,
|
|
184
|
+
);
|
|
185
|
+
console.log("");
|
|
186
|
+
console.log("Next steps:");
|
|
187
|
+
console.log(
|
|
188
|
+
" vellum tunnel --provider ngrok # tunnel now targets nginx ingress",
|
|
189
|
+
);
|
|
190
|
+
console.log(" vellum nginx-ingress down # stop the proxy");
|
|
191
|
+
return;
|
|
192
|
+
case "nginx-missing":
|
|
193
|
+
console.error("Error: nginx is not installed.");
|
|
194
|
+
console.error("");
|
|
195
|
+
console.error("Install nginx:");
|
|
196
|
+
console.error(" macOS: brew install nginx");
|
|
197
|
+
console.error(" Linux: sudo apt install nginx");
|
|
198
|
+
console.error("");
|
|
199
|
+
console.error(
|
|
200
|
+
"Or point NGINX_BIN at an existing binary: NGINX_BIN=/path/to/nginx",
|
|
201
|
+
);
|
|
202
|
+
break;
|
|
203
|
+
case "web-dist-missing":
|
|
204
|
+
console.error(
|
|
205
|
+
"Error: unable to locate built web assets for remote web ingress.",
|
|
206
|
+
);
|
|
207
|
+
console.error("");
|
|
208
|
+
console.error("Build the SPA first:");
|
|
209
|
+
console.error(
|
|
210
|
+
" cd clients/web && VITE_PLATFORM_MODE=false bun run build",
|
|
211
|
+
);
|
|
212
|
+
console.error("");
|
|
213
|
+
console.error(
|
|
214
|
+
"Or install @vellumai/web so its packaged dist directory is available.",
|
|
215
|
+
);
|
|
216
|
+
break;
|
|
217
|
+
case "unreachable":
|
|
218
|
+
console.error(
|
|
219
|
+
`Error: nginx ingress did not become reachable on 127.0.0.1:${result.listenPort}.`,
|
|
220
|
+
);
|
|
221
|
+
console.error(`Check the nginx log: ${result.logPath}`);
|
|
222
|
+
break;
|
|
230
223
|
}
|
|
231
224
|
|
|
232
|
-
|
|
233
|
-
console.log(`nginx ingress running: http://127.0.0.1:${listenPort}`);
|
|
234
|
-
console.log("");
|
|
235
|
-
console.log("Next steps:");
|
|
236
|
-
console.log(
|
|
237
|
-
" vellum tunnel --provider ngrok # tunnel now targets nginx ingress",
|
|
238
|
-
);
|
|
239
|
-
console.log(" vellum nginx-ingress down # stop the proxy");
|
|
225
|
+
process.exit(1);
|
|
240
226
|
}
|
|
241
227
|
|
|
242
228
|
async function down(target: NginxIngressTarget): Promise<void> {
|
package/src/commands/wake.ts
CHANGED
|
@@ -30,6 +30,15 @@ import {
|
|
|
30
30
|
startGateway,
|
|
31
31
|
} from "../lib/local";
|
|
32
32
|
import { maybeStartNgrokTunnel } from "../lib/ngrok";
|
|
33
|
+
import {
|
|
34
|
+
isAssistantFeatureFlagEnabled,
|
|
35
|
+
WEB_REMOTE_INGRESS_FLAG,
|
|
36
|
+
} from "../lib/feature-flags.js";
|
|
37
|
+
import { loadRawConfig } from "../lib/ingress-config.js";
|
|
38
|
+
import {
|
|
39
|
+
isIngressRunning,
|
|
40
|
+
startRemoteWebIngress,
|
|
41
|
+
} from "../lib/nginx-ingress.js";
|
|
33
42
|
|
|
34
43
|
export async function wake(): Promise<void> {
|
|
35
44
|
const args = process.argv.slice(3);
|
|
@@ -379,6 +388,17 @@ export async function wake(): Promise<void> {
|
|
|
379
388
|
writeFileSync(ngrokPidFile, String(ngrokChild.pid));
|
|
380
389
|
}
|
|
381
390
|
|
|
391
|
+
// Restore the nginx web ingress edge when the workspace config still wants
|
|
392
|
+
// it. A TLS-terminating front (tailscale serve / tunnel) persists across
|
|
393
|
+
// restarts and keeps proxying to the edge's loopback port, but the edge has
|
|
394
|
+
// a manual lifecycle — so a routine restart otherwise leaves the self-hosted
|
|
395
|
+
// remote-web path dead (502 / blank page) until someone runs it back up.
|
|
396
|
+
await restoreWebIngressIfEnabled(
|
|
397
|
+
entry.assistantId,
|
|
398
|
+
resources.gatewayPort,
|
|
399
|
+
workspaceDir,
|
|
400
|
+
);
|
|
401
|
+
|
|
382
402
|
if (daemonMigrationsFailed) {
|
|
383
403
|
console.log(
|
|
384
404
|
"Assistant database migrations FAILED — DB-backed routes will return 503 until the assistant is restarted. Check the daemon logs.",
|
|
@@ -404,3 +424,101 @@ export async function wake(): Promise<void> {
|
|
|
404
424
|
});
|
|
405
425
|
}
|
|
406
426
|
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Bring the nginx web ingress edge back up after a wake when the workspace
|
|
430
|
+
* config still wants it. Only restores when ingress is explicitly enabled with
|
|
431
|
+
* a saved public URL and the `web-remote-ingress` flag is on — the edge is
|
|
432
|
+
* pointless without the flag, so a disabled flag skips quietly with a hint.
|
|
433
|
+
*
|
|
434
|
+
* Reads the same workspace config the edge serves. Any failure to restore
|
|
435
|
+
* warns with the manual `vellum nginx-ingress up` command and never fails the
|
|
436
|
+
* wake — a down edge is a degraded remote-web path, not a broken assistant.
|
|
437
|
+
*/
|
|
438
|
+
async function restoreWebIngressIfEnabled(
|
|
439
|
+
assistantId: string,
|
|
440
|
+
gatewayPort: number,
|
|
441
|
+
workspaceDir: string,
|
|
442
|
+
): Promise<void> {
|
|
443
|
+
const config = loadRawConfig(workspaceDir);
|
|
444
|
+
const ingress = config.ingress as
|
|
445
|
+
| { enabled?: unknown; publicBaseUrl?: unknown }
|
|
446
|
+
| undefined;
|
|
447
|
+
const enabled = ingress?.enabled === true;
|
|
448
|
+
const publicBaseUrl =
|
|
449
|
+
typeof ingress?.publicBaseUrl === "string"
|
|
450
|
+
? ingress.publicBaseUrl.trim()
|
|
451
|
+
: "";
|
|
452
|
+
if (!enabled || !publicBaseUrl) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// The edge already survived (or was manually brought back) — nothing to do.
|
|
457
|
+
if (isIngressRunning(workspaceDir)) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
let flagEnabled: boolean;
|
|
462
|
+
try {
|
|
463
|
+
flagEnabled = await isAssistantFeatureFlagEnabled(
|
|
464
|
+
assistantId,
|
|
465
|
+
WEB_REMOTE_INGRESS_FLAG,
|
|
466
|
+
{ runtimeUrl: `http://127.0.0.1:${gatewayPort}` },
|
|
467
|
+
);
|
|
468
|
+
} catch (err) {
|
|
469
|
+
console.warn(
|
|
470
|
+
` Could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` flag to restore the web ingress edge; leaving it down. Bring it up manually with \`vellum nginx-ingress up\`. ${
|
|
471
|
+
err instanceof Error ? err.message : String(err)
|
|
472
|
+
}`,
|
|
473
|
+
);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (!flagEnabled) {
|
|
477
|
+
console.log(
|
|
478
|
+
` Web ingress edge not restored: the \`${WEB_REMOTE_INGRESS_FLAG}\` flag is off. Enable it and run \`vellum nginx-ingress up\` to serve remote web access.`,
|
|
479
|
+
);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
const result = await startRemoteWebIngress({
|
|
485
|
+
workspaceDir,
|
|
486
|
+
gatewayPort,
|
|
487
|
+
onStarting: ({ listenPort }) => {
|
|
488
|
+
console.log(
|
|
489
|
+
`Restoring web ingress edge on 127.0.0.1:${listenPort} (ingress.enabled)...`,
|
|
490
|
+
);
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
switch (result.status) {
|
|
494
|
+
case "started":
|
|
495
|
+
console.log(
|
|
496
|
+
` Web ingress edge running: http://127.0.0.1:${result.listenPort}`,
|
|
497
|
+
);
|
|
498
|
+
break;
|
|
499
|
+
case "already-running":
|
|
500
|
+
break;
|
|
501
|
+
case "nginx-missing":
|
|
502
|
+
console.warn(
|
|
503
|
+
" Could not restore the web ingress edge: nginx is not installed. Bring it up manually with `vellum nginx-ingress up`.",
|
|
504
|
+
);
|
|
505
|
+
break;
|
|
506
|
+
case "web-dist-missing":
|
|
507
|
+
console.warn(
|
|
508
|
+
" Could not restore the web ingress edge: built web assets were not found. Bring it up manually with `vellum nginx-ingress up`.",
|
|
509
|
+
);
|
|
510
|
+
break;
|
|
511
|
+
case "unreachable":
|
|
512
|
+
console.warn(
|
|
513
|
+
` Web ingress edge did not become reachable on 127.0.0.1:${result.listenPort}; check ${result.logPath}. Bring it up manually with \`vellum nginx-ingress up\`.`,
|
|
514
|
+
);
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
} catch (err) {
|
|
518
|
+
console.warn(
|
|
519
|
+
` Failed to restore the web ingress edge: ${
|
|
520
|
+
err instanceof Error ? err.message : String(err)
|
|
521
|
+
}. Bring it up manually with \`vellum nginx-ingress up\`.`,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
}
|
package/src/lib/nginx-ingress.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { createRequire } from "node:module";
|
|
|
17
17
|
import { dirname, join } from "node:path";
|
|
18
18
|
|
|
19
19
|
import { GATEWAY_PORT } from "./constants.js";
|
|
20
|
+
import { waitForDaemonReady } from "./http-client.js";
|
|
20
21
|
import { loadRawConfig, saveRawConfig } from "./ingress-config.js";
|
|
21
22
|
|
|
22
23
|
/**
|
|
@@ -540,6 +541,95 @@ export async function stopIngressNginx(workspaceDir: string): Promise<boolean> {
|
|
|
540
541
|
return true;
|
|
541
542
|
}
|
|
542
543
|
|
|
544
|
+
/** Probe budget for confirming the freshly-spawned edge answers /healthz. */
|
|
545
|
+
export const INGRESS_READY_TIMEOUT_MS = 5_000;
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Outcome of an attempt to bring up the remote-web nginx ingress edge. Callers
|
|
549
|
+
* render their own messaging per variant: `nginx-ingress up` prints
|
|
550
|
+
* install/build guidance and exits non-zero, while the wake restore path warns
|
|
551
|
+
* and continues.
|
|
552
|
+
*/
|
|
553
|
+
export type StartRemoteWebIngressResult =
|
|
554
|
+
| {
|
|
555
|
+
status: "started";
|
|
556
|
+
listenPort: number;
|
|
557
|
+
webDistDir: string;
|
|
558
|
+
version: string;
|
|
559
|
+
}
|
|
560
|
+
| { status: "already-running"; listenPort: number }
|
|
561
|
+
| { status: "nginx-missing" }
|
|
562
|
+
| { status: "web-dist-missing" }
|
|
563
|
+
| { status: "unreachable"; listenPort: number; logPath: string };
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Generate the nginx config and start the remote-web ingress edge, then probe
|
|
567
|
+
* /healthz through it to prove the ingress → gateway path is live. A spawned
|
|
568
|
+
* but unreachable nginx is rolled back so a failed attempt leaves no half-up
|
|
569
|
+
* edge behind.
|
|
570
|
+
*
|
|
571
|
+
* Pure mechanism: it performs no console output and never exits the process, so
|
|
572
|
+
* both the `nginx-ingress up` command and the wake restore path can share one
|
|
573
|
+
* implementation and map the returned result to their own UX.
|
|
574
|
+
*/
|
|
575
|
+
export async function startRemoteWebIngress(opts: {
|
|
576
|
+
workspaceDir: string;
|
|
577
|
+
gatewayPort: number;
|
|
578
|
+
listenPort?: number;
|
|
579
|
+
readyTimeoutMs?: number;
|
|
580
|
+
/**
|
|
581
|
+
* Invoked once, after every preflight check passes and immediately before
|
|
582
|
+
* nginx is spawned, so callers can emit their own "starting" progress line
|
|
583
|
+
* with the resolved version/dist/port. Never fires on a preflight bail-out
|
|
584
|
+
* (nginx-missing, already-running, web-dist-missing).
|
|
585
|
+
*/
|
|
586
|
+
onStarting?: (info: {
|
|
587
|
+
version: string;
|
|
588
|
+
webDistDir: string;
|
|
589
|
+
listenPort: number;
|
|
590
|
+
}) => void;
|
|
591
|
+
}): Promise<StartRemoteWebIngressResult> {
|
|
592
|
+
const listenPort = opts.listenPort ?? getNginxIngressPort();
|
|
593
|
+
|
|
594
|
+
const version = getNginxVersion();
|
|
595
|
+
if (!version) {
|
|
596
|
+
return { status: "nginx-missing" };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (isIngressRunning(opts.workspaceDir)) {
|
|
600
|
+
return { status: "already-running", listenPort };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const webDistDir = findWebDistDir();
|
|
604
|
+
if (!webDistDir) {
|
|
605
|
+
return { status: "web-dist-missing" };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
opts.onStarting?.({ version, webDistDir, listenPort });
|
|
609
|
+
|
|
610
|
+
const child = startIngressNginx({
|
|
611
|
+
workspaceDir: opts.workspaceDir,
|
|
612
|
+
gatewayPort: opts.gatewayPort,
|
|
613
|
+
listenPort,
|
|
614
|
+
remoteWebIngress: { webDistDir },
|
|
615
|
+
});
|
|
616
|
+
child.unref();
|
|
617
|
+
|
|
618
|
+
// /healthz proxies through nginx to the gateway, so a 200 proves the whole
|
|
619
|
+
// ingress → gateway path works.
|
|
620
|
+
const ready = await waitForDaemonReady(
|
|
621
|
+
listenPort,
|
|
622
|
+
opts.readyTimeoutMs ?? INGRESS_READY_TIMEOUT_MS,
|
|
623
|
+
);
|
|
624
|
+
if (!ready) {
|
|
625
|
+
const { logPath } = getIngressPaths(opts.workspaceDir);
|
|
626
|
+
await stopIngressNginx(opts.workspaceDir);
|
|
627
|
+
return { status: "unreachable", listenPort, logPath };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return { status: "started", listenPort, webDistDir, version };
|
|
631
|
+
}
|
|
632
|
+
|
|
543
633
|
/**
|
|
544
634
|
* Resolve the local port a tunnel should target: the nginx ingress when it is
|
|
545
635
|
* recorded AND its process is alive, otherwise the gateway port directly
|