@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.
Files changed (51) hide show
  1. package/bun.lock +49 -56
  2. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +224 -0
  3. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
  4. package/node_modules/@vellumai/local-mode/src/index.ts +8 -1
  5. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +0 -15
  6. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +8 -4
  7. package/node_modules/@vellumai/local-mode/src/sleep.ts +80 -0
  8. package/node_modules/@vellumai/local-mode/src/status.ts +342 -0
  9. package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
  10. package/package.json +3 -3
  11. package/src/__tests__/assistant-config.test.ts +1 -2
  12. package/src/__tests__/device-id.test.ts +6 -14
  13. package/src/__tests__/helpers/os-mock.ts +27 -0
  14. package/src/__tests__/login-loopback.test.ts +71 -0
  15. package/src/__tests__/multi-local.test.ts +2 -10
  16. package/src/__tests__/nginx-ingress-command.test.ts +69 -0
  17. package/src/__tests__/nginx-ingress.test.ts +401 -0
  18. package/src/__tests__/sleep.test.ts +4 -0
  19. package/src/__tests__/teleport.test.ts +6 -9
  20. package/src/__tests__/tunnel.test.ts +164 -0
  21. package/src/__tests__/wake.test.ts +15 -4
  22. package/src/__tests__/workos-pkce.test.ts +314 -0
  23. package/src/commands/flags.ts +1 -22
  24. package/src/commands/hatch.ts +90 -9
  25. package/src/commands/login.ts +123 -59
  26. package/src/commands/nginx-ingress.ts +291 -0
  27. package/src/commands/rollback.ts +0 -6
  28. package/src/commands/sleep.ts +17 -0
  29. package/src/commands/teleport.ts +23 -36
  30. package/src/commands/tunnel.ts +69 -11
  31. package/src/commands/upgrade.ts +0 -2
  32. package/src/commands/wake.ts +7 -5
  33. package/src/commands/workflows.ts +301 -0
  34. package/src/index.ts +8 -0
  35. package/src/lib/arg-utils.ts +48 -0
  36. package/src/lib/assistant-client.ts +2 -0
  37. package/src/lib/assistant-config.ts +0 -7
  38. package/src/lib/cloudflare-tunnel.ts +15 -2
  39. package/src/lib/docker.ts +103 -49
  40. package/src/lib/feature-flags.test.ts +157 -0
  41. package/src/lib/feature-flags.ts +38 -0
  42. package/src/lib/hatch-local.ts +0 -1
  43. package/src/lib/local.ts +5 -0
  44. package/src/lib/nginx-ingress.ts +574 -0
  45. package/src/lib/ngrok.ts +26 -4
  46. package/src/lib/platform-client.ts +0 -1
  47. package/src/lib/retire-local.ts +5 -0
  48. package/src/lib/statefulset.ts +73 -21
  49. package/src/lib/sync-cloud-assistants.ts +4 -17
  50. package/src/lib/upgrade-lifecycle.ts +1 -2
  51. package/src/lib/workos-pkce.ts +160 -0
@@ -391,9 +391,8 @@ async function exportFromAssistant(
391
391
  // daemon, the CLI is updated separately).
392
392
  let sourceRuntimeVersion: string;
393
393
  try {
394
- const identity = await callRuntimeWithAuthRetry(
395
- entry,
396
- async (token) => localRuntimeIdentity(entry, token),
394
+ const identity = await callRuntimeWithAuthRetry(entry, async (token) =>
395
+ localRuntimeIdentity(entry, token),
397
396
  );
398
397
  sourceRuntimeVersion = identity.version;
399
398
  } catch (err) {
@@ -427,16 +426,13 @@ async function exportFromAssistant(
427
426
  let jobId: string;
428
427
  let accessToken: string;
429
428
  try {
430
- const result = await callRuntimeWithAuthRetry(
431
- entry,
432
- async (token) => {
433
- const r = await localRuntimeExportToGcs(entry, token, {
434
- uploadUrl,
435
- description: "teleport export",
436
- });
437
- return { jobId: r.jobId, token };
438
- },
439
- );
429
+ const result = await callRuntimeWithAuthRetry(entry, async (token) => {
430
+ const r = await localRuntimeExportToGcs(entry, token, {
431
+ uploadUrl,
432
+ description: "teleport export",
433
+ });
434
+ return { jobId: r.jobId, token };
435
+ });
440
436
  jobId = result.jobId;
441
437
  accessToken = result.token;
442
438
  } catch (err) {
@@ -734,9 +730,8 @@ async function importToAssistant(
734
730
  // target can't actually load) whenever the two drift apart.
735
731
  let targetRuntimeVersion: string;
736
732
  try {
737
- const identity = await callRuntimeWithAuthRetry(
738
- entry,
739
- (token) => localRuntimeIdentity(entry, token),
733
+ const identity = await callRuntimeWithAuthRetry(entry, (token) =>
734
+ localRuntimeIdentity(entry, token),
740
735
  );
741
736
  targetRuntimeVersion = identity.version;
742
737
  } catch (err) {
@@ -779,15 +774,12 @@ async function importToAssistant(
779
774
  let jobId: string;
780
775
  let accessToken: string;
781
776
  try {
782
- const result = await callRuntimeWithAuthRetry(
783
- entry,
784
- async (token) => {
785
- const r = await localRuntimeImportFromGcs(entry, token, {
786
- bundleUrl,
787
- });
788
- return { jobId: r.jobId, token };
789
- },
790
- );
777
+ const result = await callRuntimeWithAuthRetry(entry, async (token) => {
778
+ const r = await localRuntimeImportFromGcs(entry, token, {
779
+ bundleUrl,
780
+ });
781
+ return { jobId: r.jobId, token };
782
+ });
791
783
  jobId = result.jobId;
792
784
  accessToken = result.token;
793
785
  } catch (err) {
@@ -910,17 +902,12 @@ export async function resolveOrHatchTarget(
910
902
 
911
903
  if (targetEnv === "docker") {
912
904
  const beforeIds = new Set(loadAllAssistants().map((e) => e.assistantId));
913
- await hatchDocker(
914
- "vellum",
915
- false,
916
- targetName ?? null,
917
- false,
918
- {},
919
- {},
920
- {
921
- setupProviderCredentials: false,
922
- },
923
- );
905
+ await hatchDocker({
906
+ species: "vellum",
907
+ detached: false,
908
+ name: targetName ?? null,
909
+ setupProviderCredentials: false,
910
+ });
924
911
  const entry = targetName
925
912
  ? findAssistantByName(targetName)
926
913
  : (loadAllAssistants().find((e) => !beforeIds.has(e.assistantId)) ??
@@ -1,7 +1,12 @@
1
1
  import { join } from "path";
2
2
 
3
- import { resolveAssistant } from "../lib/assistant-config";
3
+ import { resolveAssistant, type AssistantEntry } from "../lib/assistant-config";
4
4
  import { runCloudflareTunnel } from "../lib/cloudflare-tunnel.js";
5
+ import { GATEWAY_PORT } from "../lib/constants.js";
6
+ import {
7
+ isAssistantFeatureFlagEnabled,
8
+ WEB_REMOTE_INGRESS_FLAG,
9
+ } from "../lib/feature-flags.js";
5
10
  import { runNgrokTunnel } from "../lib/ngrok";
6
11
 
7
12
  const VALID_PROVIDERS = ["vellum", "ngrok", "cloudflare", "tailscale"] as const;
@@ -88,6 +93,46 @@ function parseArgs(): TunnelArgs {
88
93
  return { assistantName, provider };
89
94
  }
90
95
 
96
+ function parsePortFromUrl(url: unknown): number | undefined {
97
+ if (typeof url !== "string" || !url.trim()) return undefined;
98
+ try {
99
+ const port = Number(new URL(url).port);
100
+ return Number.isInteger(port) && port > 0 && port <= 65535
101
+ ? port
102
+ : undefined;
103
+ } catch {
104
+ return undefined;
105
+ }
106
+ }
107
+
108
+ function resolveEntryGatewayPort(entry: AssistantEntry): number {
109
+ return (
110
+ entry.resources?.gatewayPort ??
111
+ parsePortFromUrl(entry.localUrl) ??
112
+ parsePortFromUrl(entry.runtimeUrl) ??
113
+ GATEWAY_PORT
114
+ );
115
+ }
116
+
117
+ async function shouldPreferNginxIngress(
118
+ assistantId: string,
119
+ gatewayPort: number,
120
+ ): Promise<boolean> {
121
+ try {
122
+ return await isAssistantFeatureFlagEnabled(
123
+ assistantId,
124
+ WEB_REMOTE_INGRESS_FLAG,
125
+ { runtimeUrl: `http://127.0.0.1:${gatewayPort}` },
126
+ );
127
+ } catch (err) {
128
+ throw new Error(
129
+ `Could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` feature flag before starting the tunnel. Is the assistant running? Try \`vellum wake\` and retry. ${
130
+ err instanceof Error ? err.message : String(err)
131
+ }`,
132
+ );
133
+ }
134
+ }
135
+
91
136
  export async function tunnel(): Promise<void> {
92
137
  const { assistantName, provider } = parseArgs();
93
138
 
@@ -104,21 +149,34 @@ export async function tunnel(): Promise<void> {
104
149
  process.exit(1);
105
150
  }
106
151
 
152
+ const resources = entry.resources;
153
+ const gatewayPort = resolveEntryGatewayPort(entry);
154
+ const baseTunnelOpts = {
155
+ port: gatewayPort,
156
+ ...(resources
157
+ ? { workspaceDir: join(resources.instanceDir, ".vellum", "workspace") }
158
+ : {}),
159
+ };
160
+
107
161
  if (provider === "ngrok") {
108
- await runNgrokTunnel();
162
+ await runNgrokTunnel({
163
+ ...baseTunnelOpts,
164
+ preferNginxIngress: await shouldPreferNginxIngress(
165
+ entry.assistantId,
166
+ gatewayPort,
167
+ ),
168
+ });
109
169
  return;
110
170
  }
111
171
 
112
172
  if (provider === "cloudflare") {
113
- const resources = entry.resources;
114
- await runCloudflareTunnel(
115
- resources
116
- ? {
117
- port: resources.gatewayPort,
118
- workspaceDir: join(resources.instanceDir, ".vellum", "workspace"),
119
- }
120
- : {},
121
- );
173
+ await runCloudflareTunnel({
174
+ ...baseTunnelOpts,
175
+ preferNginxIngress: await shouldPreferNginxIngress(
176
+ entry.assistantId,
177
+ gatewayPort,
178
+ ),
179
+ });
122
180
  return;
123
181
  }
124
182
 
@@ -6,7 +6,6 @@ import {
6
6
  findAssistantByName,
7
7
  getActiveAssistant,
8
8
  loadAllAssistants,
9
- normalizeVersion,
10
9
  resolveCloud,
11
10
  saveAssistantEntry,
12
11
  type AssistantEntry,
@@ -496,7 +495,6 @@ async function upgradeDocker(
496
495
  previousContainerInfo: entry.containerInfo,
497
496
  previousDbMigrationVersion: preMigrationState.dbVersion,
498
497
  previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
499
- version: normalizeVersion(versionTag),
500
498
  // Preserve the backup path so `vellum rollback` can restore it later
501
499
  preUpgradeBackupPath: backupPath ?? undefined,
502
500
  };
@@ -9,7 +9,6 @@ import {
9
9
  import { dockerResourceNames, wakeContainers } from "../lib/docker.js";
10
10
  import {
11
11
  leaseGuardianToken,
12
- loadGuardianToken,
13
12
  resetGuardianBootstrap,
14
13
  seedGuardianTokenFromSiblingEnv,
15
14
  } from "../lib/guardian-token.js";
@@ -43,7 +42,7 @@ export async function wake(): Promise<void> {
43
42
  " --foreground Run assistant in foreground with logs printed to terminal",
44
43
  );
45
44
  console.log(
46
- " --repair-guardian Re-provision the guardian token if missing (resets the\n" +
45
+ " --repair-guardian Force-re-provision the guardian token (resets the\n" +
47
46
  " gateway bootstrap and re-leases — REVOKES other device-bound\n" +
48
47
  " tokens, so only use deliberately, never from auto-repair)",
49
48
  );
@@ -238,8 +237,11 @@ export async function wake(): Promise<void> {
238
237
  console.log(" Seeded guardian token from sibling environment.");
239
238
  }
240
239
 
241
- // Last-resort recovery (explicit `--repair-guardian` only): if no guardian
242
- // token exists for this env even after sibling seeding, re-provision one. The
240
+ // Last-resort recovery (explicit `--repair-guardian` only): force a
241
+ // re-provision. Token health can't be judged locally a connect can 401
242
+ // off a token whose local expiry looks fine (revoked, mis-seeded, wrong
243
+ // principal) — and the user explicitly confirmed the destructive repair,
244
+ // so guessing "looks healthy, skip" just recreates the no-op loop. The
243
245
  // single-use bootstrap secret may already be spent — a prior connect can
244
246
  // lease a token that's then lost, or the gateway marks the secret consumed
245
247
  // before the client persists it — which otherwise bricks connect into a
@@ -248,7 +250,7 @@ export async function wake(): Promise<void> {
248
250
  // by the lockfile secret — mirrors the macOS client's forceReBootstrap), then
249
251
  // re-lease. Gated behind the flag because the re-lease revokes other
250
252
  // device-bound tokens; it must never run from the automatic repair path.
251
- if (repairGuardian && !loadGuardianToken(entry.assistantId)) {
253
+ if (repairGuardian) {
252
254
  const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
253
255
  const maxAttempts = 3;
254
256
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -0,0 +1,301 @@
1
+ import { extractAssistantFlag, extractValueFlag } from "../lib/arg-utils.js";
2
+ import { AssistantClient } from "../lib/assistant-client.js";
3
+ import {
4
+ formatAssistantLookupError,
5
+ lookupAssistantByIdentifier,
6
+ } from "../lib/assistant-config.js";
7
+
8
+ /**
9
+ * Client-side mirror of the server's wire-run projection
10
+ * (`WorkflowRunWire` from `assistant/src/runtime/routes/workflow-routes.ts`).
11
+ * The CLI is an independent build unit and deliberately does NOT import from
12
+ * `assistant/` (see `cli/src/shared/provider-env-vars.ts`), so the shape is
13
+ * mirrored here. Only the fields the CLI renders are declared — the server may
14
+ * send a superset. Keep in sync with `workflowRunSchema`.
15
+ */
16
+ type WorkflowRun = {
17
+ id: string;
18
+ name: string | null;
19
+ status: string;
20
+ agentsSpawned: number;
21
+ inputTokens: number;
22
+ outputTokens: number;
23
+ error: string | null;
24
+ createdAt: number | null;
25
+ finishedAt: number | null;
26
+ };
27
+
28
+ type SavedWorkflow = {
29
+ name: string;
30
+ description: string;
31
+ path: string;
32
+ };
33
+
34
+ function pad(s: string, w: number): string {
35
+ return s + " ".repeat(Math.max(0, w - s.length));
36
+ }
37
+
38
+ function fmtTime(ms: number | null): string {
39
+ return ms == null ? "-" : new Date(ms).toISOString();
40
+ }
41
+
42
+ function printRunsTable(runs: WorkflowRun[]): void {
43
+ const headers = {
44
+ id: "ID",
45
+ name: "NAME",
46
+ status: "STATUS",
47
+ agents: "AGENTS",
48
+ created: "CREATED",
49
+ };
50
+ const rows = runs.map((r) => ({
51
+ id: r.id,
52
+ name: r.name ?? "-",
53
+ status: r.status,
54
+ agents: String(r.agentsSpawned),
55
+ created: fmtTime(r.createdAt),
56
+ }));
57
+ const all = [headers, ...rows];
58
+ const w = {
59
+ id: Math.max(...all.map((r) => r.id.length)),
60
+ name: Math.max(...all.map((r) => r.name.length)),
61
+ status: Math.max(...all.map((r) => r.status.length)),
62
+ agents: Math.max(...all.map((r) => r.agents.length)),
63
+ created: Math.max(...all.map((r) => r.created.length)),
64
+ };
65
+ const formatRow = (r: typeof headers) =>
66
+ `${pad(r.id, w.id)} ${pad(r.name, w.name)} ${pad(r.status, w.status)} ${pad(r.agents, w.agents)} ${r.created}`;
67
+ console.log(formatRow(headers));
68
+ console.log(
69
+ `${"-".repeat(w.id)} ${"-".repeat(w.name)} ${"-".repeat(w.status)} ${"-".repeat(w.agents)} ${"-".repeat(w.created)}`,
70
+ );
71
+ for (const row of rows) console.log(formatRow(row));
72
+ }
73
+
74
+ function printSavedTable(workflows: SavedWorkflow[]): void {
75
+ const headers = { name: "NAME", description: "DESCRIPTION" };
76
+ const rows = workflows.map((w) => ({
77
+ name: w.name,
78
+ description: w.description,
79
+ }));
80
+ const all = [headers, ...rows];
81
+ const w = {
82
+ name: Math.max(...all.map((r) => r.name.length)),
83
+ description: Math.max(...all.map((r) => r.description.length)),
84
+ };
85
+ const formatRow = (r: typeof headers) =>
86
+ `${pad(r.name, w.name)} ${r.description}`;
87
+ console.log(formatRow(headers));
88
+ console.log(`${"-".repeat(w.name)} ${"-".repeat(w.description)}`);
89
+ for (const row of rows) console.log(formatRow(row));
90
+ }
91
+
92
+ function printHelp(): void {
93
+ console.log("Usage: vellum workflows <subcommand> [options]");
94
+ console.log("");
95
+ console.log("Inspect and control workflow runs on the active assistant.");
96
+ console.log("");
97
+ console.log("Subcommands:");
98
+ console.log(" list List saved (named) workflows");
99
+ console.log(" runs List recent workflow runs");
100
+ console.log(" show <run-id> Show details for a single run");
101
+ console.log(" abort <run-id> Abort an in-flight run");
102
+ console.log(
103
+ " resume <run-id> Resume an interrupted run (orphaned by a restart)",
104
+ );
105
+ console.log("");
106
+ console.log("Options:");
107
+ console.log(
108
+ " --assistant <name> Target a specific assistant (display name or ID)",
109
+ );
110
+ console.log(" --limit <n> (runs) Max runs to list");
111
+ console.log(" --status <status> (runs) Filter by run status");
112
+ console.log(" --help, -h Show this help");
113
+ }
114
+
115
+ function createClient(assistantName?: string): AssistantClient {
116
+ let assistantId: string | undefined;
117
+ if (assistantName) {
118
+ const result = lookupAssistantByIdentifier(assistantName);
119
+ if (result.status !== "found") {
120
+ throw new Error(formatAssistantLookupError(assistantName, result));
121
+ }
122
+ assistantId = result.entry.assistantId;
123
+ }
124
+ try {
125
+ return new AssistantClient(assistantId ? { assistantId } : undefined);
126
+ } catch {
127
+ throw new Error(
128
+ assistantName
129
+ ? `No assistant found matching '${assistantName}'.`
130
+ : "No assistant found. Hatch one with 'vellum hatch' first.",
131
+ );
132
+ }
133
+ }
134
+
135
+ function rethrowFetchError(err: unknown): never {
136
+ if (
137
+ err instanceof TypeError &&
138
+ (err.message.includes("fetch") || err.message.includes("connect"))
139
+ ) {
140
+ throw new Error(
141
+ "Could not reach the assistant. Is it running? Try 'vellum wake'.",
142
+ );
143
+ }
144
+ throw err;
145
+ }
146
+
147
+ async function requestJson<T>(
148
+ client: AssistantClient,
149
+ method: "get" | "post",
150
+ path: string,
151
+ query?: Record<string, string>,
152
+ ): Promise<T> {
153
+ let res: Response;
154
+ try {
155
+ res =
156
+ method === "get"
157
+ ? await client.get(path, query ? { query } : undefined)
158
+ : await client.post(path, undefined);
159
+ } catch (err) {
160
+ rethrowFetchError(err);
161
+ }
162
+ if (!res.ok) {
163
+ const body = await res.text().catch(() => "");
164
+ throw new Error(`Request failed: HTTP ${res.status} ${body}`.trim());
165
+ }
166
+ return (await res.json()) as T;
167
+ }
168
+
169
+ async function listSaved(assistantName?: string): Promise<void> {
170
+ const client = createClient(assistantName);
171
+ const data = await requestJson<{ workflows: SavedWorkflow[] }>(
172
+ client,
173
+ "get",
174
+ "/workflows",
175
+ );
176
+ if (data.workflows.length === 0) {
177
+ console.log("No saved workflows found.");
178
+ return;
179
+ }
180
+ printSavedTable(data.workflows);
181
+ }
182
+
183
+ async function listRuns(
184
+ opts: { limit?: string; status?: string },
185
+ assistantName?: string,
186
+ ): Promise<void> {
187
+ const client = createClient(assistantName);
188
+ const query: Record<string, string> = {};
189
+ if (opts.limit) query.limit = opts.limit;
190
+ if (opts.status) query.status = opts.status;
191
+ const data = await requestJson<{ runs: WorkflowRun[] }>(
192
+ client,
193
+ "get",
194
+ "/workflows/runs",
195
+ Object.keys(query).length ? query : undefined,
196
+ );
197
+ if (data.runs.length === 0) {
198
+ console.log("No workflow runs found.");
199
+ return;
200
+ }
201
+ printRunsTable(data.runs);
202
+ }
203
+
204
+ async function showRun(runId: string, assistantName?: string): Promise<void> {
205
+ const client = createClient(assistantName);
206
+ const run = await requestJson<WorkflowRun>(
207
+ client,
208
+ "get",
209
+ `/workflows/runs/${runId}`,
210
+ );
211
+ console.log(`ID: ${run.id}`);
212
+ console.log(`Name: ${run.name ?? "(unnamed)"}`);
213
+ console.log(`Status: ${run.status}`);
214
+ console.log(`Agents spawned: ${run.agentsSpawned}`);
215
+ console.log(
216
+ `Tokens: ${run.inputTokens} in / ${run.outputTokens} out`,
217
+ );
218
+ console.log(`Created: ${fmtTime(run.createdAt)}`);
219
+ console.log(`Finished: ${fmtTime(run.finishedAt)}`);
220
+ if (run.error) console.log(`Error: ${run.error}`);
221
+ }
222
+
223
+ async function abortRun(runId: string, assistantName?: string): Promise<void> {
224
+ const client = createClient(assistantName);
225
+ await requestJson<{ ok: boolean; runId: string }>(
226
+ client,
227
+ "post",
228
+ `/workflows/runs/${runId}/abort`,
229
+ );
230
+ console.log(`Abort signalled for workflow run ${runId}.`);
231
+ }
232
+
233
+ async function resumeRun(runId: string, assistantName?: string): Promise<void> {
234
+ const client = createClient(assistantName);
235
+ await requestJson<{ ok: boolean; runId: string }>(
236
+ client,
237
+ "post",
238
+ `/workflows/runs/${runId}/resume`,
239
+ );
240
+ console.log(
241
+ `Resumed workflow run ${runId}. It replays its completed steps and continues from where it was interrupted.`,
242
+ );
243
+ }
244
+
245
+ export async function workflows(): Promise<void> {
246
+ const args = process.argv.slice(3);
247
+
248
+ if (args.includes("--help") || args.includes("-h")) {
249
+ printHelp();
250
+ return;
251
+ }
252
+
253
+ const assistantName = extractAssistantFlag(args);
254
+ const limit = extractValueFlag(args, "limit");
255
+ const status = extractValueFlag(args, "status");
256
+ const subcommand = args[0];
257
+
258
+ if (!subcommand || subcommand === "list") {
259
+ await listSaved(assistantName);
260
+ return;
261
+ }
262
+
263
+ if (subcommand === "runs") {
264
+ await listRuns({ limit, status }, assistantName);
265
+ return;
266
+ }
267
+
268
+ if (subcommand === "show") {
269
+ const runId = args[1];
270
+ if (!runId) {
271
+ console.error("Usage: vellum workflows show <run-id>");
272
+ process.exit(1);
273
+ }
274
+ await showRun(runId, assistantName);
275
+ return;
276
+ }
277
+
278
+ if (subcommand === "abort") {
279
+ const runId = args[1];
280
+ if (!runId) {
281
+ console.error("Usage: vellum workflows abort <run-id>");
282
+ process.exit(1);
283
+ }
284
+ await abortRun(runId, assistantName);
285
+ return;
286
+ }
287
+
288
+ if (subcommand === "resume") {
289
+ const runId = args[1];
290
+ if (!runId) {
291
+ console.error("Usage: vellum workflows resume <run-id>");
292
+ process.exit(1);
293
+ }
294
+ await resumeRun(runId, assistantName);
295
+ return;
296
+ }
297
+
298
+ console.error(`Unknown subcommand: ${subcommand}`);
299
+ printHelp();
300
+ process.exit(1);
301
+ }
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ import { hatch } from "./commands/hatch";
16
16
  import { login, logout, whoami } from "./commands/login";
17
17
  import { logs } from "./commands/logs";
18
18
  import { message } from "./commands/message";
19
+ import { nginxIngress } from "./commands/nginx-ingress";
19
20
  import { pair } from "./commands/pair";
20
21
  import { ps } from "./commands/ps";
21
22
  import { recover } from "./commands/recover";
@@ -33,6 +34,7 @@ import { unpair } from "./commands/unpair";
33
34
  import { upgrade } from "./commands/upgrade";
34
35
  import { use } from "./commands/use";
35
36
  import { wake } from "./commands/wake";
37
+ import { workflows } from "./commands/workflows";
36
38
  import { resolveAssistant, setActiveAssistant } from "./lib/assistant-config";
37
39
  import { loadGuardianToken } from "./lib/guardian-token";
38
40
  import { checkHealth } from "./lib/health-check";
@@ -54,6 +56,7 @@ const commands = {
54
56
  logout,
55
57
  logs,
56
58
  message,
59
+ "nginx-ingress": nginxIngress,
57
60
  pair,
58
61
  ps,
59
62
  recover,
@@ -72,6 +75,7 @@ const commands = {
72
75
  use,
73
76
  wake,
74
77
  whoami,
78
+ workflows,
75
79
  } as const;
76
80
 
77
81
  type CommandName = keyof typeof commands;
@@ -96,6 +100,9 @@ function printHelp(): void {
96
100
  console.log(" flags Show and toggle feature flags");
97
101
  console.log(" gateway Gateway management commands");
98
102
  console.log(" hatch Create a new assistant instance");
103
+ console.log(
104
+ " nginx-ingress Manage the nginx proxy fronting the gateway for web access [beta]",
105
+ );
99
106
  console.log(" logs View logs from an assistant instance");
100
107
  console.log(" login Log in to the Vellum platform");
101
108
  console.log(" logout Log out of the Vellum platform");
@@ -126,6 +133,7 @@ function printHelp(): void {
126
133
  console.log(" use Set the active assistant for commands");
127
134
  console.log(" wake Start the assistant and gateway");
128
135
  console.log(" whoami Show current logged-in user");
136
+ console.log(" workflows Inspect and control workflow runs");
129
137
  console.log("");
130
138
  console.log("Options:");
131
139
  console.log(
@@ -11,3 +11,51 @@ export function extractFlag(
11
11
  const remaining = [...args.slice(0, idx), ...args.slice(idx + 2)];
12
12
  return [value, remaining];
13
13
  }
14
+
15
+ /**
16
+ * Strip `--<name> <value>` from argv and return the captured value.
17
+ *
18
+ * Mutates the input array so positional parsing downstream sees a clean shape.
19
+ * Returns `undefined` if the flag is absent. Error-reports a missing value (and
20
+ * exits) so the user gets a clear message rather than the flag being silently
21
+ * swallowed as a positional.
22
+ */
23
+ export function extractValueFlag(
24
+ args: string[],
25
+ name: string,
26
+ ): string | undefined {
27
+ for (let i = 0; i < args.length; i++) {
28
+ if (args[i] !== `--${name}`) continue;
29
+ const value = args[i + 1];
30
+ if (!value || value.startsWith("-")) {
31
+ console.error(`Missing value for --${name} <value>`);
32
+ process.exit(1);
33
+ }
34
+ args.splice(i, 2);
35
+ return value;
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ /**
41
+ * Strip `--assistant <name>` from argv and return the captured value.
42
+ *
43
+ * Mutates the input array so positional parsing downstream sees a clean shape
44
+ * (subcommand + key + value). Returns `undefined` if the flag is absent.
45
+ * Error-reports a missing value so the user gets a clear message rather than
46
+ * the flag being silently swallowed as a positional. (Kept distinct from
47
+ * {@link extractValueFlag} only for its `<name>` wording in the error string.)
48
+ */
49
+ export function extractAssistantFlag(args: string[]): string | undefined {
50
+ for (let i = 0; i < args.length; i++) {
51
+ if (args[i] !== "--assistant") continue;
52
+ const value = args[i + 1];
53
+ if (!value || value.startsWith("-")) {
54
+ console.error("Missing value for --assistant <name>");
55
+ process.exit(1);
56
+ }
57
+ args.splice(i, 2);
58
+ return value;
59
+ }
60
+ return undefined;
61
+ }
@@ -26,6 +26,7 @@ const FALLBACK_RUNTIME_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
26
26
 
27
27
  export interface AssistantClientOpts {
28
28
  assistantId?: string;
29
+ runtimeUrl?: string;
29
30
  /**
30
31
  * When provided alongside `orgId`, the client authenticates with a
31
32
  * session token instead of a guardian token. The session token is
@@ -73,6 +74,7 @@ export class AssistantClient {
73
74
  }
74
75
 
75
76
  this.runtimeUrl = (
77
+ opts?.runtimeUrl ||
76
78
  entry.localUrl ||
77
79
  entry.runtimeUrl ||
78
80
  FALLBACK_RUNTIME_URL
@@ -97,8 +97,6 @@ export interface AssistantEntry {
97
97
  sshUser?: string;
98
98
  zone?: string;
99
99
  hatchedAt?: string;
100
- /** Installed service-group release version (no `v` prefix), written at hatch/upgrade/rollback. */
101
- version?: string;
102
100
  /** Per-instance resource config. Present for local entries in multi-instance setups. */
103
101
  resources?: LocalInstanceResources;
104
102
  /** PID of the file watcher process for docker instances hatched with --watch. */
@@ -586,11 +584,6 @@ export function extractHostFromUrl(url: string): string {
586
584
  }
587
585
  }
588
586
 
589
- /** Strip a leading `v` so stored versions match the healthz `version` format. */
590
- export function normalizeVersion(version: string): string {
591
- return version.replace(/^v/, "");
592
- }
593
-
594
587
  export function saveAssistantEntry(entry: AssistantEntry): void {
595
588
  const entries = readAssistants().filter(
596
589
  (e) => e.assistantId !== entry.assistantId,