@vellumai/cli 0.10.12-dev.202607250047.75b9ec6 → 0.10.12-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.
@@ -46,7 +46,6 @@ import {
46
46
  import { logHatchNextSteps } from "./hatch-next-steps.js";
47
47
  import { checkProviderApiKey } from "./api-key-check.js";
48
48
  import { loopbackSafeFetch } from "./loopback-fetch.js";
49
- import { withLocalHatchLock } from "./local-hatch-lock.js";
50
49
 
51
50
  /**
52
51
  * Attempts to place a symlink at the given path pointing to cliBinary.
@@ -180,116 +179,89 @@ export async function hatchLocal(
180
179
  name ?? process.env.VELLUM_ASSISTANT_NAME,
181
180
  );
182
181
 
183
- const { resources, runtimeUrl, loopbackUrl, bootstrapSecret } =
184
- await withLocalHatchLock(async () => {
185
- reporter.progress(1, 6, "Allocating resources...");
182
+ reporter.progress(1, 6, "Allocating resources...");
186
183
 
187
- const existing = findAssistantByName(instanceName);
188
- if (existing && (!existing.cloud || existing.cloud === "local")) {
189
- throw new Error(
190
- `An assistant named "${instanceName}" is already hatched.\n` +
191
- `Run \`vellum wake\` to restart it, or \`vellum retire ${instanceName}\` to remove it first.`,
192
- );
193
- }
194
-
195
- const resources = await allocateLocalResources(instanceName);
196
-
197
- const logsDir = join(
198
- resources.instanceDir,
199
- ".vellum",
200
- "workspace",
201
- "data",
202
- "logs",
203
- );
204
- archiveLogFile("hatch.log", logsDir);
205
- resetLogFile("hatch.log");
206
-
207
- reporter.log(`🥚 Hatching local assistant: ${instanceName}`);
208
- reporter.log(` Species: ${species}`);
209
- reporter.log("");
184
+ const existing = findAssistantByName(instanceName);
185
+ if (existing && (!existing.cloud || existing.cloud === "local")) {
186
+ throw new Error(
187
+ `An assistant named "${instanceName}" is already hatched.\n` +
188
+ `Run \`vellum wake\` to restart it, or \`vellum retire ${instanceName}\` to remove it first.`,
189
+ );
190
+ }
210
191
 
211
- const apiKeyCheck = checkProviderApiKey();
212
- if (!apiKeyCheck.hasKey) {
213
- reporter.warn(
214
- "Warning: No LLM provider API key is configured. The assistant will fail when you try to send a message.",
215
- );
216
- reporter.warn(" To fix, export your key before running vellum hatch:");
217
- reporter.warn(" export ANTHROPIC_API_KEY=<your-key>");
218
- reporter.warn("");
219
- }
192
+ const resources = await allocateLocalResources(instanceName);
220
193
 
221
- if (!process.env.APP_VERSION) {
222
- process.env.APP_VERSION = cliPkg.version;
223
- }
194
+ const logsDir = join(
195
+ resources.instanceDir,
196
+ ".vellum",
197
+ "workspace",
198
+ "data",
199
+ "logs",
200
+ );
201
+ archiveLogFile("hatch.log", logsDir);
202
+ resetLogFile("hatch.log");
224
203
 
225
- reporter.progress(2, 6, "Writing configuration...");
226
- const hatchConfigValues = buildHatchConfigValues(configValues, provider);
227
- const defaultWorkspaceConfigPath = writeInitialConfig(hatchConfigValues);
204
+ reporter.log(`🥚 Hatching local assistant: ${instanceName}`);
205
+ reporter.log(` Species: ${species}`);
206
+ reporter.log("");
228
207
 
229
- reporter.progress(3, 6, "Starting assistant...");
230
- const signingKey = generateLocalSigningKey();
231
- const bootstrapSecret = generateLocalSigningKey();
232
- let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
233
- try {
234
- // Launch the CES sibling alongside the daemon, in parallel — matching the
235
- // Docker topology. The assistant does not spawn its own CES, so a freshly
236
- // hatched instance would otherwise come up with CES unavailable.
237
- // startCes always launches the CES sibling.
238
- const startupResults = await Promise.allSettled([
239
- startCes(watch, resources),
240
- startLocalDaemon(watch, resources, {
241
- defaultWorkspaceConfigPath,
242
- requireReady: true,
243
- signingKey,
244
- }),
245
- ]);
246
- const startupFailure = startupResults.find(
247
- (result): result is PromiseRejectedResult =>
248
- result.status === "rejected",
249
- );
250
- if (startupFailure) {
251
- throw startupFailure.reason;
252
- }
208
+ const apiKeyCheck = checkProviderApiKey();
209
+ if (!apiKeyCheck.hasKey) {
210
+ reporter.warn(
211
+ "Warning: No LLM provider API key is configured. The assistant will fail when you try to send a message.",
212
+ );
213
+ reporter.warn(" To fix, export your key before running vellum hatch:");
214
+ reporter.warn(" export ANTHROPIC_API_KEY=<your-key>");
215
+ reporter.warn("");
216
+ }
253
217
 
254
- reporter.progress(4, 6, "Starting gateway...");
255
- runtimeUrl = await startGateway(watch, resources, {
256
- signingKey,
257
- bootstrapSecret,
258
- envOverrides: flagEnvVars,
259
- requireReady: true,
260
- });
261
- } catch (error) {
262
- reporter.error(
263
- `\n❌ Local assistant startup failed — stopping partial processes.`,
264
- );
265
- await stopLocalProcesses(resources);
266
- throw error;
267
- }
218
+ if (!process.env.APP_VERSION) {
219
+ process.env.APP_VERSION = cliPkg.version;
220
+ }
268
221
 
269
- const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
270
- const localEntry: AssistantEntry = {
271
- assistantId: instanceName,
272
- runtimeUrl,
273
- localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
274
- cloud: "local",
275
- species,
276
- hatchedAt: new Date().toISOString(),
277
- resources: { ...resources, signingKey },
278
- guardianBootstrapSecret: bootstrapSecret,
279
- };
280
-
281
- reporter.progress(5, 6, "Saving configuration...");
282
- saveAssistantEntry(localEntry);
283
- setActiveAssistant(instanceName);
284
-
285
- return { resources, runtimeUrl, loopbackUrl, bootstrapSecret };
222
+ reporter.progress(2, 6, "Writing configuration...");
223
+ const hatchConfigValues = buildHatchConfigValues(configValues, provider);
224
+ const defaultWorkspaceConfigPath = writeInitialConfig(hatchConfigValues);
225
+
226
+ reporter.progress(3, 6, "Starting assistant...");
227
+ const signingKey = generateLocalSigningKey();
228
+ const bootstrapSecret = generateLocalSigningKey();
229
+ // Launch the CES sibling alongside the daemon, in parallel — matching the
230
+ // Docker topology. The assistant does not spawn its own CES, so a freshly
231
+ // hatched instance would otherwise come up with CES unavailable.
232
+ // startCes always launches the CES sibling.
233
+ await Promise.all([
234
+ startCes(watch, resources),
235
+ startLocalDaemon(watch, resources, {
236
+ defaultWorkspaceConfigPath,
237
+ signingKey,
238
+ }),
239
+ ]);
240
+
241
+ reporter.progress(4, 6, "Starting gateway...");
242
+ let runtimeUrl = `http://127.0.0.1:${resources.gatewayPort}`;
243
+ try {
244
+ runtimeUrl = await startGateway(watch, resources, {
245
+ signingKey,
246
+ bootstrapSecret,
247
+ envOverrides: flagEnvVars,
286
248
  });
249
+ } catch (error) {
250
+ // Gateway failed — stop the daemon we just started so we don't leave
251
+ // orphaned processes with no lock file entry.
252
+ reporter.error(
253
+ `\n❌ Gateway startup failed — stopping assistant to avoid orphaned processes.`,
254
+ );
255
+ await stopLocalProcesses(resources);
256
+ throw error;
257
+ }
287
258
 
288
259
  // Lease a guardian token so the desktop app can import it on first launch
289
260
  // instead of hitting /v1/guardian/init itself. Use loopback to satisfy
290
261
  // the daemon's local-only check — the mDNS runtimeUrl resolves to a LAN
291
262
  // IP which the daemon rejects as non-loopback.
292
- reporter.progress(6, 6, "Securing connection...");
263
+ reporter.progress(5, 6, "Securing connection...");
264
+ const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
293
265
  const maxLeaseAttempts = 3;
294
266
  let guardianAccessToken: string | undefined;
295
267
  for (let attempt = 1; attempt <= maxLeaseAttempts; attempt++) {
@@ -318,6 +290,22 @@ export async function hatchLocal(
318
290
  }
319
291
  }
320
292
 
293
+ // Auto-start ngrok if webhook integrations (e.g. Telegram, Twilio) are configured.
294
+ const localEntry: AssistantEntry = {
295
+ assistantId: instanceName,
296
+ runtimeUrl,
297
+ localUrl: `http://127.0.0.1:${resources.gatewayPort}`,
298
+ cloud: "local",
299
+ species,
300
+ hatchedAt: new Date().toISOString(),
301
+ resources: { ...resources, signingKey },
302
+ guardianBootstrapSecret: bootstrapSecret,
303
+ };
304
+
305
+ reporter.progress(6, 6, "Saving configuration...");
306
+ saveAssistantEntry(localEntry);
307
+ setActiveAssistant(instanceName);
308
+
321
309
  if (process.env.VELLUM_DESKTOP_APP) {
322
310
  installCLISymlink();
323
311
  }
package/src/lib/local.ts CHANGED
@@ -548,7 +548,6 @@ export function generateLocalSigningKey(): string {
548
548
  type DaemonStartOptions = {
549
549
  foreground?: boolean;
550
550
  defaultWorkspaceConfigPath?: string;
551
- requireReady?: boolean;
552
551
  signingKey?: string;
553
552
  };
554
553
 
@@ -598,10 +597,7 @@ function applyDaemonEnvOverrides(
598
597
  applyIpcSocketDirOverride(env);
599
598
  }
600
599
 
601
- function logDaemonReadiness(
602
- readiness: DaemonReadiness,
603
- requireReady = false,
604
- ): void {
600
+ function logDaemonReadiness(readiness: DaemonReadiness): void {
605
601
  switch (readiness) {
606
602
  case "ready":
607
603
  console.log(" Assistant ready\n");
@@ -617,11 +613,6 @@ function logDaemonReadiness(
617
613
  );
618
614
  break;
619
615
  default:
620
- if (requireReady) {
621
- throw new Error(
622
- "Assistant did not bind its local port within 60 seconds.",
623
- );
624
- }
625
616
  console.log(
626
617
  " ⚠️ Assistant did not become ready within 60s — continuing anyway\n",
627
618
  );
@@ -1329,7 +1320,6 @@ export async function startLocalDaemon(
1329
1320
  resources.daemonPort,
1330
1321
  Date.now() + 60000,
1331
1322
  ),
1332
- options?.requireReady,
1333
1323
  );
1334
1324
  }
1335
1325
  return;
@@ -1372,7 +1362,6 @@ export async function startLocalDaemon(
1372
1362
  // classifies it without blocking on an in-flight migration.
1373
1363
  logDaemonReadiness(
1374
1364
  await probeDaemonReadinessWithRetry(resources.daemonPort),
1375
- options?.requireReady,
1376
1365
  );
1377
1366
  return;
1378
1367
  }
@@ -1524,7 +1513,7 @@ export async function startLocalDaemon(
1524
1513
  readiness = await probeDaemonReadiness(resources.daemonPort);
1525
1514
  }
1526
1515
 
1527
- logDaemonReadiness(readiness, options?.requireReady);
1516
+ logDaemonReadiness(readiness);
1528
1517
  }
1529
1518
  } else {
1530
1519
  console.log("🔨 Starting local assistant...");
@@ -1546,7 +1535,6 @@ export async function startLocalDaemon(
1546
1535
  resources.daemonPort,
1547
1536
  Date.now() + 60000,
1548
1537
  ),
1549
- options?.requireReady,
1550
1538
  );
1551
1539
  }
1552
1540
  }
@@ -1559,7 +1547,6 @@ export async function startGateway(
1559
1547
  signingKey?: string;
1560
1548
  bootstrapSecret?: string;
1561
1549
  envOverrides?: Record<string, string>;
1562
- requireReady?: boolean;
1563
1550
  },
1564
1551
  ): Promise<string> {
1565
1552
  const effectiveGatewayPort = resources?.gatewayPort ?? GATEWAY_PORT;
@@ -1689,11 +1676,6 @@ export async function startGateway(
1689
1676
  // connection-refused errors.
1690
1677
  const ready = await waitForDaemonReady(effectiveGatewayPort, 30000);
1691
1678
  if (!ready) {
1692
- if (options?.requireReady) {
1693
- throw new Error(
1694
- "Assistant gateway did not bind its local port within 30 seconds.",
1695
- );
1696
- }
1697
1679
  console.warn(
1698
1680
  "⚠ Gateway started but health check did not respond within 30s",
1699
1681
  );
@@ -17,7 +17,6 @@ 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";
21
20
  import { loadRawConfig, saveRawConfig } from "./ingress-config.js";
22
21
 
23
22
  /**
@@ -541,95 +540,6 @@ export async function stopIngressNginx(workspaceDir: string): Promise<boolean> {
541
540
  return true;
542
541
  }
543
542
 
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
-
633
543
  /**
634
544
  * Resolve the local port a tunnel should target: the nginx ingress when it is
635
545
  * recorded AND its process is alive, otherwise the gateway port directly