@vellumai/cli 0.8.12-staging.2 → 0.9.0-dev.202606162156.4bad3e5

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 (52) hide show
  1. package/README.md +1 -1
  2. package/bun.lock +49 -56
  3. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +224 -0
  4. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +19 -0
  5. package/node_modules/@vellumai/local-mode/src/index.ts +8 -1
  6. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +0 -15
  7. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +8 -4
  8. package/node_modules/@vellumai/local-mode/src/sleep.ts +80 -0
  9. package/node_modules/@vellumai/local-mode/src/status.ts +342 -0
  10. package/node_modules/@vellumai/local-mode/src/wake.ts +12 -1
  11. package/package.json +3 -3
  12. package/src/__tests__/assistant-config.test.ts +1 -2
  13. package/src/__tests__/device-id.test.ts +6 -14
  14. package/src/__tests__/helpers/os-mock.ts +27 -0
  15. package/src/__tests__/login-loopback.test.ts +71 -0
  16. package/src/__tests__/multi-local.test.ts +2 -10
  17. package/src/__tests__/nginx-ingress-command.test.ts +69 -0
  18. package/src/__tests__/nginx-ingress.test.ts +403 -0
  19. package/src/__tests__/sleep.test.ts +4 -0
  20. package/src/__tests__/teleport.test.ts +6 -9
  21. package/src/__tests__/tunnel.test.ts +164 -0
  22. package/src/__tests__/wake.test.ts +15 -4
  23. package/src/__tests__/workos-pkce.test.ts +314 -0
  24. package/src/commands/flags.ts +1 -22
  25. package/src/commands/hatch.ts +90 -9
  26. package/src/commands/login.ts +123 -59
  27. package/src/commands/nginx-ingress.ts +291 -0
  28. package/src/commands/rollback.ts +0 -6
  29. package/src/commands/sleep.ts +17 -0
  30. package/src/commands/teleport.ts +23 -36
  31. package/src/commands/tunnel.ts +69 -11
  32. package/src/commands/upgrade.ts +0 -2
  33. package/src/commands/wake.ts +7 -5
  34. package/src/commands/workflows.ts +301 -0
  35. package/src/index.ts +8 -0
  36. package/src/lib/arg-utils.ts +48 -0
  37. package/src/lib/assistant-client.ts +2 -0
  38. package/src/lib/assistant-config.ts +0 -7
  39. package/src/lib/cloudflare-tunnel.ts +15 -2
  40. package/src/lib/docker.ts +103 -49
  41. package/src/lib/feature-flags.test.ts +157 -0
  42. package/src/lib/feature-flags.ts +38 -0
  43. package/src/lib/hatch-local.ts +0 -1
  44. package/src/lib/local.ts +5 -0
  45. package/src/lib/nginx-ingress.ts +576 -0
  46. package/src/lib/ngrok.ts +26 -4
  47. package/src/lib/platform-client.ts +0 -1
  48. package/src/lib/retire-local.ts +5 -0
  49. package/src/lib/statefulset.ts +73 -21
  50. package/src/lib/sync-cloud-assistants.ts +4 -17
  51. package/src/lib/upgrade-lifecycle.ts +1 -2
  52. package/src/lib/workos-pkce.ts +160 -0
@@ -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,
@@ -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:8080' manually.`,
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.");