From 52a1164a0e252aa7e49233fd6d94dab6f002cf7e Mon Sep 17 00:00:00 2001 From: Andrew Jensen Date: Wed, 29 Apr 2026 16:54:25 -0600 Subject: [PATCH 01/22] WIP: define command for triggering workflow run --- src/cli.ts | 2 + src/commands/workflowRuns.ts | 13 + src/commands/workflowRuns/trigger.test.ts | 404 ++++++++++++++++++++++ src/commands/workflowRuns/trigger.ts | 400 +++++++++++++++++++++ 4 files changed, 819 insertions(+) create mode 100644 src/commands/workflowRuns.ts create mode 100644 src/commands/workflowRuns/trigger.test.ts create mode 100644 src/commands/workflowRuns/trigger.ts diff --git a/src/cli.ts b/src/cli.ts index 7d5b646..c013e8b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,7 @@ import { initCommand } from "./commands/init.js"; import { scorecardsCommand } from "./commands/scorecards.js"; import { studioCommand } from "./commands/studio.js"; import { teamsCommand } from "./commands/teams.js"; +import { workflowRunsCommand } from "./commands/workflowRuns.js"; import { workflowsCommand } from "./commands/workflows.js"; import { handleError } from "./commandHelpers.js"; @@ -49,6 +50,7 @@ function createProgram(): Command { program.addCommand(scorecardsCommand()); program.addCommand(studioCommand()); program.addCommand(teamsCommand()); + program.addCommand(workflowRunsCommand()); program.addCommand(workflowsCommand()); applyExitOverride(program); diff --git a/src/commands/workflowRuns.ts b/src/commands/workflowRuns.ts new file mode 100644 index 0000000..1bf4c86 --- /dev/null +++ b/src/commands/workflowRuns.ts @@ -0,0 +1,13 @@ +import { Command } from "commander"; + +import { triggerCommand } from "./workflowRuns/trigger.js"; + +export function workflowRunsCommand(): Command { + const workflowRuns = new Command() + .name("workflowRuns") + .description("Trigger and monitor Self-service workflow runs"); + + workflowRuns.addCommand(triggerCommand()); + + return workflowRuns; +} diff --git a/src/commands/workflowRuns/trigger.test.ts b/src/commands/workflowRuns/trigger.test.ts new file mode 100644 index 0000000..39e7681 --- /dev/null +++ b/src/commands/workflowRuns/trigger.test.ts @@ -0,0 +1,404 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { EXIT_CODES } from "../../errors.js"; + +const setToken = vi.fn(); +const deleteToken = vi.fn(); +const getToken = vi.fn(); + +vi.mock("../../secrets.js", () => ({ + setToken, + deleteToken, + getToken, +})); + +const originalEnv = { ...process.env }; +const stdoutWrites: string[] = []; +const stderrWrites: string[] = []; + +beforeEach(() => { + process.env = { ...originalEnv }; + getToken.mockReset(); + setToken.mockReset(); + deleteToken.mockReset(); + vi.restoreAllMocks(); + stdoutWrites.length = 0; + stderrWrites.length = 0; + vi.spyOn(process.stdout, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + stdoutWrites.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + vi.spyOn(process.stderr, "write").mockImplementation((( + chunk: string | Uint8Array, + ) => { + stderrWrites.push(String(chunk)); + return true; + }) as typeof process.stderr.write); + vi.spyOn(globalThis, "setTimeout").mockImplementation((( + callback: TimerHandler, + ) => { + if (typeof callback === "function") { + callback(); + } + return 0; + }) as typeof globalThis.setTimeout); +}); + +afterEach(() => { + process.env = { ...originalEnv }; + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("workflowRuns trigger command", () => { + const runId = "wr-test-1"; + + const pendingRun = { + id: runId, + status: "PENDING_RUN", + started_at: "2026-04-29T12:00:00.000Z", + completed_at: null, + events: [], + }; + + const succeededRun = { + ...pendingRun, + status: "SUCCEEDED", + completed_at: "2026-04-29T12:00:05.000Z", + workflow: { + identifier: "wf-one", + name: "Workflow One", + description: null, + scope: "GLOBAL", + }, + }; + + it("triggers then polls until succeeded and prints a summary", async () => { + process.env.DX_BASE_URL = "https://api.example.com"; + getToken.mockReturnValue("token-123"); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ ok: true, workflow_run: { id: runId } }), + { + status: 200, + }, + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true, workflow_run: pendingRun }), { + status: 200, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true, workflow_run: succeededRun }), { + status: 200, + }), + ); + + vi.stubGlobal("fetch", fetchMock); + + const { run } = await import("../../cli.js"); + await run(["node", "dx", "workflowRuns", "trigger", "wf-one"]); + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.example.com/workflowRuns.trigger", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ workflow_identifier: "wf-one" }), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + `https://api.example.com/workflowRuns.info?id=${runId}`, + expect.objectContaining({ method: "GET" }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 3, + `https://api.example.com/workflowRuns.info?id=${runId}`, + expect.objectContaining({ method: "GET" }), + ); + + const out = stdoutWrites.join(""); + expect(out).toContain("Workflow run"); + expect(out).toContain(runId); + expect(out).toContain("SUCCEEDED"); + expect(out).toContain("Workflow One"); + }); + + it("prints new POST_MESSAGE events to stderr while polling", async () => { + process.env.DX_BASE_URL = "https://api.example.com"; + getToken.mockReturnValue("token-123"); + + const pendingWithMsg = { + ...pendingRun, + events: [ + { + id: "e1", + type: "POST_MESSAGE", + occurred_at: "2026-04-29T12:00:01Z", + message: "Step one", + data: {}, + }, + ], + }; + const pendingWithMore = { + ...pendingRun, + events: [ + ...pendingWithMsg.events, + { + id: "e2", + type: "POST_MESSAGE", + occurred_at: "2026-04-29T12:00:02Z", + message: "Step two", + data: {}, + }, + ], + }; + + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ ok: true, workflow_run: { id: runId } }), + { + status: 200, + }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ ok: true, workflow_run: pendingWithMsg }), + { + status: 200, + }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ ok: true, workflow_run: pendingWithMore }), + { + status: 200, + }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ ok: true, workflow_run: succeededRun }), + { + status: 200, + }, + ), + ), + ); + + const { run } = await import("../../cli.js"); + await run(["node", "dx", "workflowRuns", "trigger", "wf-one"]); + + const err = stderrWrites.join(""); + expect(err).toContain("Step two"); + expect(err).not.toContain("Step one"); + }); + + it("outputs the final workflow run as JSON with --json", async () => { + process.env.DX_BASE_URL = "https://api.example.com"; + getToken.mockReturnValue("token-123"); + + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ ok: true, workflow_run: { id: runId } }), + { + status: 200, + }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ ok: true, workflow_run: succeededRun }), + { + status: 200, + }, + ), + ), + ); + + const { run } = await import("../../cli.js"); + await run(["node", "dx", "--json", "workflowRuns", "trigger", "wf-one"]); + + expect(JSON.parse(stdoutWrites.join(""))).toEqual({ + ok: true, + workflow_run: succeededRun, + }); + }); + + it("sends entity and coerced param data in the trigger body", async () => { + process.env.DX_BASE_URL = "https://api.example.com"; + getToken.mockReturnValue("token-123"); + + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ ok: true, workflow_run: { id: runId } }), + { + status: 200, + }, + ), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ ok: true, workflow_run: succeededRun }), + { + status: 200, + }, + ), + ), + ); + + const { run } = await import("../../cli.js"); + await run([ + "node", + "dx", + "workflowRuns", + "trigger", + "wf-one", + "--entity", + "svc-a", + "--param", + "count=42", + "--param", + "enabled=true", + ]); + + expect(fetch).toHaveBeenNthCalledWith( + 1, + "https://api.example.com/workflowRuns.trigger", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + workflow_identifier: "wf-one", + entity_identifier: "svc-a", + data: { count: 42, enabled: true }, + }), + }), + ); + }); + + it("exits with an error when the run ends in FAILED", async () => { + process.env.DX_BASE_URL = "https://api.example.com"; + getToken.mockReturnValue("token-123"); + + const failedRun = { + ...pendingRun, + status: "FAILED", + completed_at: "2026-04-29T12:00:03.000Z", + events: [ + { + id: "e1", + type: "POST_MESSAGE", + occurred_at: "2026-04-29T12:00:02Z", + message: "Something broke", + data: {}, + }, + ], + }; + + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ ok: true, workflow_run: { id: runId } }), + { + status: 200, + }, + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true, workflow_run: failedRun }), { + status: 200, + }), + ), + ); + + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + + const { run } = await import("../../cli.js"); + await run(["node", "dx", "workflowRuns", "trigger", "wf-one"]); + + expect(stderrWrites.join("")).toContain("Something broke"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("errors on invalid --param format", async () => { + getToken.mockReturnValue("token-123"); + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + + const { run } = await import("../../cli.js"); + await run([ + "node", + "dx", + "workflowRuns", + "trigger", + "wf-one", + "--param", + "nocomma", + ]); + + expect(stderrWrites.join("")).toContain("Invalid --param format"); + expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.ARGUMENT_ERROR); + }); + + it("retries info after HTTP 429 while polling", async () => { + process.env.DX_BASE_URL = "https://api.example.com"; + getToken.mockReturnValue("token-123"); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ ok: true, workflow_run: { id: runId } }), + { + status: 200, + }, + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: "slow_down" }), { + status: 429, + headers: { "Retry-After": "0" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true, workflow_run: succeededRun }), { + status: 200, + }), + ); + + vi.stubGlobal("fetch", fetchMock); + + const { run } = await import("../../cli.js"); + await run(["node", "dx", "workflowRuns", "trigger", "wf-one"]); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(stdoutWrites.join("")).toContain("SUCCEEDED"); + }); +}); diff --git a/src/commands/workflowRuns/trigger.ts b/src/commands/workflowRuns/trigger.ts new file mode 100644 index 0000000..d1359dd --- /dev/null +++ b/src/commands/workflowRuns/trigger.ts @@ -0,0 +1,400 @@ +import { Command } from "commander"; + +import { + createExampleText, + getContext, + wrapAction, +} from "../../commandHelpers.js"; +import { CliError, EXIT_CODES, HttpError } from "../../errors.js"; +import { request } from "../../http.js"; +import { + AsyncProgressReporter, + renderJson, + renderRichText, +} from "../../renderers.js"; +import { buildRuntime } from "../../runtime.js"; +import type { Runtime } from "../../types.js"; +import * as ui from "../../ui.js"; + +const DEFAULT_RETRY_AFTER_MS = 1000; + +const PENDING_WORKFLOW_RUN_STATUSES = new Set([ + "PENDING_RUN", + "IN_PROGRESS", + "WAITING_FOR_APPROVAL", + "WAITING_FOR_EVENTS", +]); + +const TERMINAL_SUCCESS = new Set(["SUCCEEDED"]); +const TERMINAL_FAILURE = new Set([ + "FAILED", + "REJECTED", + "TIMEOUT", + "CANCELLED", +]); + +export function triggerCommand() { + return new Command() + .name("trigger") + .description( + "Trigger a Self-service workflow run and wait until it succeeds or fails", + ) + .argument( + "", + "Workflow identifier (from dx workflows list)", + ) + .option( + "--entity ", + "Catalog entity identifier (required for entity-scoped workflows)", + ) + .option( + "--param ", + "Workflow parameter value (repeatable). Value after the first '=' is kept verbatim", + (val: string, prev: string[]) => [...prev, val], + [] as string[], + ) + .addHelpText( + "afterAll", + createExampleText([ + { + label: "Trigger a global workflow and wait for completion", + command: "dx workflowRuns trigger my-global-workflow", + }, + { + label: "Trigger with an entity and parameters", + command: + 'dx workflowRuns trigger provision-db --entity acme-api --param size=small --param "greeting=hello world"', + }, + { + label: "Trigger with machine-readable output", + command: + "dx workflowRuns trigger my-workflow --json --entity svc-1 --param count=3", + }, + ]), + ) + .action( + wrapAction(async (workflowIdentifier: string, options, command) => { + const context = getContext(command); + const runtime = buildRuntime(context); + const progress = new AsyncProgressReporter(); + const params = parseWorkflowParams(options.param as string[]); + const entityIdentifier = parseOptionalTrimmed(options.entity); + + const body: Record = { + workflow_identifier: workflowIdentifier.trim(), + }; + if (entityIdentifier !== undefined) { + body.entity_identifier = entityIdentifier; + } + if (params !== undefined && Object.keys(params).length > 0) { + body.data = params; + } + + try { + progress.start(ui.bold("Triggering workflow")); + const triggerResponse = await triggerWorkflowRun(runtime, body); + const runId = triggerResponse.body.workflow_run.id; + await waitForRetryAfter(triggerResponse.retryAfterMs); + + const finalRun = await waitForWorkflowRun( + runtime, + progress, + runId, + context.json, + ); + + if (runtime.context.json) { + progress.stop(); + renderJson({ ok: true, workflow_run: finalRun }); + } else { + progress.stop( + `${ui.success(ui.GLYPHS.CHECK)} Workflow run ${ui.code(finalRun.id)} completed with status ${ui.bold(finalRun.status)}.`, + ); + renderRichText(renderWorkflowRunSummary(finalRun)); + } + } catch (error) { + progress.stop(`${ui.error(ui.GLYPHS.ERROR)} Workflow run failed.`); + throw error; + } + }), + ); +} + +function parseOptionalTrimmed(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const t = value.trim(); + return t.length > 0 ? t : undefined; +} + +function parseWorkflowParams( + pairs: string[], +): Record | undefined { + if (pairs.length === 0) { + return undefined; + } + + const result: Record = {}; + for (const pair of pairs) { + const eqIdx = pair.indexOf("="); + if (eqIdx === -1) { + throw new CliError( + `Invalid --param format: "${pair}". Expected key=value.`, + EXIT_CODES.ARGUMENT_ERROR, + ); + } + const key = pair.slice(0, eqIdx).trim(); + if (!key) { + throw new CliError( + `Invalid --param format: "${pair}". Parameter name cannot be empty.`, + EXIT_CODES.ARGUMENT_ERROR, + ); + } + const raw = pair.slice(eqIdx + 1); + result[key] = coerceParamValue(raw); + } + return result; +} + +function coerceParamValue(raw: string): unknown { + const trimmed = raw.trim(); + if (trimmed === "true") { + return true; + } + if (trimmed === "false") { + return false; + } + if (/^-?\d+$/.test(trimmed)) { + return Number(trimmed); + } + if (/^-?\d+\.\d+$/.test(trimmed)) { + return Number(trimmed); + } + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return JSON.parse(trimmed) as unknown; + } catch { + throw new CliError( + `Invalid JSON in --param value: ${trimmed.slice(0, 80)}${trimmed.length > 80 ? "…" : ""}`, + EXIT_CODES.ARGUMENT_ERROR, + ); + } + } + return raw; +} + +type WorkflowRunEvent = { + id: string; + type: string; + occurred_at: string; + message: string | null; + data?: Record; +}; + +type WorkflowRunDetail = { + id: string; + status: string; + started_at?: string; + completed_at?: string | null; + data?: Record; + links?: Array<{ url: string; icon?: string; label?: string }>; + workflow?: { + identifier: string; + name: string; + description?: string | null; + scope?: string; + }; + entity?: { identifier: string; name: string }; + events?: WorkflowRunEvent[]; +}; + +type TriggerWorkflowRunResponse = { + ok: true; + workflow_run: { id: string }; +}; + +type InfoWorkflowRunResponse = { + ok: true; + workflow_run: WorkflowRunDetail; +}; + +async function triggerWorkflowRun( + runtime: Runtime, + body: Record, +): Promise<{ body: TriggerWorkflowRunResponse; retryAfterMs?: number }> { + return request(runtime, "/workflowRuns.trigger", { + method: "POST", + body, + }); +} + +async function getWorkflowRun( + runtime: Runtime, + id: string, +): Promise<{ body: InfoWorkflowRunResponse; retryAfterMs?: number }> { + return request(runtime, "/workflowRuns.info", { + method: "GET", + query: { id }, + }); +} + +async function waitForWorkflowRun( + runtime: Runtime, + progress: AsyncProgressReporter, + workflowRunId: string, + jsonMode: boolean, +): Promise { + const seenEventIds = new Set(); + let eventStreamPrimed = false; + + while (true) { + let retryAfterMs: number | undefined; + try { + const infoResponse = await getWorkflowRun(runtime, workflowRunId); + const workflowRun = infoResponse.body.workflow_run; + retryAfterMs = infoResponse.retryAfterMs; + + if (!jsonMode) { + if (!eventStreamPrimed) { + primeEventIds(workflowRun.events, seenEventIds); + eventStreamPrimed = true; + } else { + emitNewPostMessages(workflowRun.events, seenEventIds); + } + } + + if (TERMINAL_SUCCESS.has(workflowRun.status)) { + return workflowRun; + } + + if (TERMINAL_FAILURE.has(workflowRun.status)) { + throw buildTerminalStatusError(workflowRun); + } + + if (!PENDING_WORKFLOW_RUN_STATUSES.has(workflowRun.status)) { + throw new CliError( + `Unexpected workflow run status: ${workflowRun.status}`, + 1, + ); + } + + progress.update( + `${ui.bold("Workflow running")} ${ui.dim(`(${workflowRunId})`)} — ${workflowRun.status}`, + ); + + await waitForRetryAfter(retryAfterMs); + } catch (error) { + if (error instanceof HttpError && error.status === 429) { + progress.update( + `${ui.warning(ui.GLYPHS.WARNING)} Rate limited while polling ${ui.dim(`(${workflowRunId})`)}; retrying`, + ); + await waitForRetryAfter(retryAfterMs); + continue; + } + throw error; + } + } +} + +function primeEventIds( + events: WorkflowRunEvent[] | undefined, + seenEventIds: Set, +): void { + if (!events?.length) { + return; + } + for (const event of events) { + seenEventIds.add(event.id); + } +} + +function emitNewPostMessages( + events: WorkflowRunEvent[] | undefined, + seenEventIds: Set, +): void { + if (!events?.length) { + return; + } + + for (const event of events) { + if (seenEventIds.has(event.id)) { + continue; + } + seenEventIds.add(event.id); + if (event.type === "POST_MESSAGE" && event.message) { + renderRichText([ui.p(event.message)], { useStderr: true }); + } + } +} + +function buildTerminalStatusError(run: WorkflowRunDetail): CliError { + if (run.status === "FAILED") { + return new CliError( + `Workflow run ${run.id} failed${formatStatusSuffix(run)}.`, + 1, + ); + } + return new CliError( + `Workflow run ${run.id} ended with status ${run.status}${formatStatusSuffix(run)}.`, + 1, + ); +} + +function formatStatusSuffix(run: WorkflowRunDetail): string { + const lastMessage = findLastPostMessage(run.events); + return lastMessage ? `: ${lastMessage}` : ""; +} + +function findLastPostMessage( + events: WorkflowRunEvent[] | undefined, +): string | undefined { + if (!events?.length) { + return undefined; + } + for (let i = events.length - 1; i >= 0; i--) { + const ev = events[i]; + if (ev.type === "POST_MESSAGE" && ev.message) { + return ev.message; + } + } + return undefined; +} + +function renderWorkflowRunSummary(run: WorkflowRunDetail) { + const items = [ui.dli("Run ID", run.id), ui.dli("Status", run.status)]; + if (run.workflow) { + items.push( + ui.dli("Workflow", `${run.workflow.name} (${run.workflow.identifier})`), + ); + } + if (run.entity) { + items.push( + ui.dli("Entity", `${run.entity.name} (${run.entity.identifier})`), + ); + } + if (run.started_at) { + items.push(ui.dli("Started", ui.timestampSummary(run.started_at))); + } + if (run.completed_at) { + items.push(ui.dli("Completed", ui.timestampSummary(run.completed_at))); + } + if (run.links?.length) { + const linkLines = run.links.map((l) => + l.label ? `${l.label}: ${ui.link(l.url)}` : ui.link(l.url), + ); + items.push(ui.dli("Links", linkLines.join("\n"))); + } + return [ + ui.h2("Workflow run"), + ui.dl(items, { termWidth: 14 }), + ui.blankLine(), + ]; +} + +async function waitForRetryAfter(retryAfterMs?: number): Promise { + const delayMs = retryAfterMs ?? DEFAULT_RETRY_AFTER_MS; + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); +} From e91f64ef70956bcb56d7f59b5b6bdb413ea35b40 Mon Sep 17 00:00:00 2001 From: Andrew Jensen Date: Mon, 4 May 2026 15:33:00 -0600 Subject: [PATCH 02/22] Add web link, color some items --- src/commands/workflowRuns/trigger.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/commands/workflowRuns/trigger.ts b/src/commands/workflowRuns/trigger.ts index d1359dd..66936ea 100644 --- a/src/commands/workflowRuns/trigger.ts +++ b/src/commands/workflowRuns/trigger.ts @@ -110,7 +110,7 @@ export function triggerCommand() { progress.stop( `${ui.success(ui.GLYPHS.CHECK)} Workflow run ${ui.code(finalRun.id)} completed with status ${ui.bold(finalRun.status)}.`, ); - renderRichText(renderWorkflowRunSummary(finalRun)); + renderRichText(renderWorkflowRunSummary(finalRun, runtime)); } } catch (error) { progress.stop(`${ui.error(ui.GLYPHS.ERROR)} Workflow run failed.`); @@ -361,11 +361,23 @@ function findLastPostMessage( return undefined; } -function renderWorkflowRunSummary(run: WorkflowRunDetail) { - const items = [ui.dli("Run ID", run.id), ui.dli("Status", run.status)]; +function renderWorkflowRunSummary(run: WorkflowRunDetail, runtime: Runtime) { + const items = [ + ui.dli("Run ID", ui.code(run.id)), + ui.dli("Status", run.status), + ]; if (run.workflow) { items.push( - ui.dli("Workflow", `${run.workflow.name} (${run.workflow.identifier})`), + ui.dli( + "Workflow", + `${run.workflow.name} (${ui.code(run.workflow.identifier)})`, + ), + ); + items.push( + ui.dli( + "Web link", + ui.link(ui.webLink(`/self-service/workflow-runs/${run.id}`, runtime)), + ), ); } if (run.entity) { From 71e312dfb384e71e9488199db67561cad296638b Mon Sep 17 00:00:00 2001 From: Andrew Jensen Date: Mon, 4 May 2026 16:07:44 -0600 Subject: [PATCH 03/22] WIP: Collect parameter data --- src/commands/workflowRuns/trigger.ts | 149 +++++++++++++++++++++++++-- src/commands/workflows.ts | 4 +- 2 files changed, 144 insertions(+), 9 deletions(-) diff --git a/src/commands/workflowRuns/trigger.ts b/src/commands/workflowRuns/trigger.ts index 66936ea..0a9677c 100644 --- a/src/commands/workflowRuns/trigger.ts +++ b/src/commands/workflowRuns/trigger.ts @@ -1,4 +1,5 @@ import { Command } from "commander"; +import { input, select } from "@inquirer/prompts"; import { createExampleText, @@ -15,6 +16,7 @@ import { import { buildRuntime } from "../../runtime.js"; import type { Runtime } from "../../types.js"; import * as ui from "../../ui.js"; +import { listWorkflows, WorkflowParameter } from "../workflows.js"; const DEFAULT_RETRY_AFTER_MS = 1000; @@ -76,9 +78,35 @@ export function triggerCommand() { wrapAction(async (workflowIdentifier: string, options, command) => { const context = getContext(command); const runtime = buildRuntime(context); + const progress = new AsyncProgressReporter(); - const params = parseWorkflowParams(options.param as string[]); + + // Fetch list of all workflows, find the one that applies + const workflowsResponse = await listWorkflows(runtime, {}); + const workflow = workflowsResponse.workflows.find( + (w) => w.identifier === workflowIdentifier, + ); + + if (!workflow) { + throw new CliError( + `Workflow \`${workflowIdentifier}\` not found`, + EXIT_CODES.ARGUMENT_ERROR, + ); + } + + // Collect options const entityIdentifier = parseOptionalTrimmed(options.entity); + const parameterData = parseParameterData(options.param as string[]); + if (isInteractive()) { + for (const param of workflow.parameters) { + if (parameterData[param.identifier] !== undefined) { + continue; + } + + parameterData[param.identifier] = + await promptForParameterValue(param); + } + } const body: Record = { workflow_identifier: workflowIdentifier.trim(), @@ -86,8 +114,8 @@ export function triggerCommand() { if (entityIdentifier !== undefined) { body.entity_identifier = entityIdentifier; } - if (params !== undefined && Object.keys(params).length > 0) { - body.data = params; + if (Object.keys(parameterData).length > 0) { + body.data = parameterData; } try { @@ -120,6 +148,115 @@ export function triggerCommand() { ); } +// TODO: move somewhere central +function isInteractive(): boolean { + return process.stdin.isTTY && process.stderr.isTTY; +} + +async function promptForParameterValue( + param: WorkflowParameter, +): Promise { + const message = param.description + ? `${param.name}: ${param.description}` + : param.name; + + switch (param.type) { + case "STRING": { + const rawValue = await input({ + message, + default: param.default_value + ? (param.default_value as string) + : undefined, + validate: (value) => { + if (value === "" && !param.is_required) { + return true; + } else if (value === "") { + return "Value is required"; + } + return true; + }, + }); + + return rawValue === "" ? undefined : rawValue; + } + case "INTEGER": { + const rawValue = await input({ + message, + default: param.default_value + ? (param.default_value as string) + : undefined, + validate: (value) => { + if (value === "" && !param.is_required) { + return true; + } else if (value === "") { + return "Value is required"; + } + + const num = Number(value); + if (isNaN(num)) { + return "Value must be a number"; + } else if (num % 1 !== 0) { + return "Value must be an integer"; + } else { + return true; + } + }, + }); + + return rawValue === "" ? undefined : Number(rawValue); + } + case "FLOAT": { + const rawValue = await input({ + message, + default: param.default_value + ? (param.default_value as string) + : undefined, + validate: (value) => { + if (value === "" && !param.is_required) { + return true; + } else if (value === "") { + return "Value is required"; + } + + const num = Number(value); + if (isNaN(num)) { + return "Value must be a number"; + } else { + return true; + } + }, + }); + + return rawValue === "" ? undefined : Number(rawValue); + } + case "BOOLEAN": + throw new CliError("FIXME: implement"); + case "SELECT": { + return await select({ + message, + default: param.default_value + ? (param.default_value as string) + : undefined, + choices: param.definition!.options.map((option) => ({ + name: option, + value: option, + })), + }); + } + case "USER": + throw new CliError("FIXME: implement"); + case "TEAM": + throw new CliError("FIXME: implement"); + case "EMAIL": + throw new CliError("FIXME: implement"); + default: + throw new CliError( + `Unknown parameter type: ${param.type}`, + EXIT_CODES.ARGUMENT_ERROR, + ); + } +} + function parseOptionalTrimmed(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; @@ -128,11 +265,9 @@ function parseOptionalTrimmed(value: unknown): string | undefined { return t.length > 0 ? t : undefined; } -function parseWorkflowParams( - pairs: string[], -): Record | undefined { +function parseParameterData(pairs: string[]): Record { if (pairs.length === 0) { - return undefined; + return {}; } const result: Record = {}; diff --git a/src/commands/workflows.ts b/src/commands/workflows.ts index bf2e862..3693580 100644 --- a/src/commands/workflows.ts +++ b/src/commands/workflows.ts @@ -119,7 +119,7 @@ export type WorkflowParameter = { default_value: unknown; is_required: boolean; type: WorkflowParameterType; - definition: unknown; + definition: null | { options: string[] }; }; export type WorkflowSummary = { @@ -149,7 +149,7 @@ type ListWorkflowsResponse = { // --- API --- -async function listWorkflows( +export async function listWorkflows( runtime: Runtime, params: ListWorkflowsParams, ): Promise { From 03e79629825f15229d77b7ba35b49d192202e156 Mon Sep 17 00:00:00 2001 From: Andrew Jensen Date: Tue, 5 May 2026 14:23:45 -0600 Subject: [PATCH 04/22] Implement more param prompts, show clearer error on unimplemented user type --- src/commands/teams.ts | 2 +- src/commands/workflowRuns/trigger.ts | 77 ++++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/commands/teams.ts b/src/commands/teams.ts index 9846f32..07505d8 100644 --- a/src/commands/teams.ts +++ b/src/commands/teams.ts @@ -214,7 +214,7 @@ async function getTeamInfo( return response.body; } -async function listTeams(runtime: Runtime): Promise { +export async function listTeams(runtime: Runtime): Promise { const response = await request(runtime, "/teams.list", { method: "GET", }); diff --git a/src/commands/workflowRuns/trigger.ts b/src/commands/workflowRuns/trigger.ts index 0a9677c..6e104d7 100644 --- a/src/commands/workflowRuns/trigger.ts +++ b/src/commands/workflowRuns/trigger.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { input, select } from "@inquirer/prompts"; +import { input, select, confirm, search } from "@inquirer/prompts"; import { createExampleText, @@ -17,6 +17,7 @@ import { buildRuntime } from "../../runtime.js"; import type { Runtime } from "../../types.js"; import * as ui from "../../ui.js"; import { listWorkflows, WorkflowParameter } from "../workflows.js"; +import { listTeams } from "../teams.js"; const DEFAULT_RETRY_AFTER_MS = 1000; @@ -103,8 +104,10 @@ export function triggerCommand() { continue; } - parameterData[param.identifier] = - await promptForParameterValue(param); + parameterData[param.identifier] = await promptForParameterValue( + runtime, + param, + ); } } @@ -154,6 +157,7 @@ function isInteractive(): boolean { } async function promptForParameterValue( + runtime: Runtime, param: WorkflowParameter, ): Promise { const message = param.description @@ -230,7 +234,10 @@ async function promptForParameterValue( return rawValue === "" ? undefined : Number(rawValue); } case "BOOLEAN": - throw new CliError("FIXME: implement"); + return await confirm({ + message, + default: false, + }); case "SELECT": { return await select({ message, @@ -244,11 +251,55 @@ async function promptForParameterValue( }); } case "USER": - throw new CliError("FIXME: implement"); - case "TEAM": - throw new CliError("FIXME: implement"); - case "EMAIL": - throw new CliError("FIXME: implement"); + throw new CliError( + "User-based parameters are not yet supported in the CLI", + EXIT_CODES.ARGUMENT_ERROR, + ); + case "TEAM": { + renderRichText([ui.p("Fetching teams...")]); + const teamsResponse = await listTeams(runtime); + const teams = teamsResponse.teams; + + const teamId = await search({ + message, + source: (term: string | undefined) => + teams + .filter( + (team) => + term === undefined || + team.name.toLowerCase().includes(term.toLowerCase()), + ) + .map((team) => ({ + name: team.name, + value: team.id, + })), + }); + + // TODO: change the trigger endpoint to accept encoded IDs too + const decodedTeamId = decodeBase64(teamId); + + return decodedTeamId; + } + case "EMAIL": { + const rawValue = await input({ + message, + default: param.default_value + ? (param.default_value as string) + : undefined, + validate: (value) => { + if (value === "" && !param.is_required) { + return true; + } else if (value === "") { + return "Value is required"; + } else if (!isValidEmail(value)) { + return "Value must be a valid email address"; + } + return true; + }, + }); + + return rawValue === "" ? undefined : rawValue; + } default: throw new CliError( `Unknown parameter type: ${param.type}`, @@ -257,6 +308,14 @@ async function promptForParameterValue( } } +function decodeBase64(value: string): string { + return Buffer.from(value, "base64").toString("utf-8"); +} + +function isValidEmail(value: string): boolean { + return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value); +} + function parseOptionalTrimmed(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; From b617b0fe9d5415169e3bdd773553970c9614c35f Mon Sep 17 00:00:00 2001 From: Andrew Jensen Date: Tue, 5 May 2026 15:59:40 -0600 Subject: [PATCH 05/22] Extract module for parameters, improve output --- src/commands/workflowRuns/parameters.ts | 167 ++++++++++++++++++ src/commands/workflowRuns/trigger.ts | 219 +++++------------------- 2 files changed, 214 insertions(+), 172 deletions(-) create mode 100644 src/commands/workflowRuns/parameters.ts diff --git a/src/commands/workflowRuns/parameters.ts b/src/commands/workflowRuns/parameters.ts new file mode 100644 index 0000000..3269612 --- /dev/null +++ b/src/commands/workflowRuns/parameters.ts @@ -0,0 +1,167 @@ +import { Runtime } from "../../types.js"; +import { WorkflowParameter } from "../workflows.js"; +import { input, select, confirm, search } from "@inquirer/prompts"; +import { listTeams } from "../teams.js"; +import { CliError, EXIT_CODES } from "../../errors.js"; +import { renderRichText } from "../../renderers.js"; +import * as ui from "../../ui.js"; + +export async function promptForParameterValue( + runtime: Runtime, + param: WorkflowParameter, +): Promise { + const message = param.description + ? `${param.name}: ${param.description}` + : param.name; + + switch (param.type) { + case "STRING": { + const rawValue = await input({ + message, + default: param.default_value + ? (param.default_value as string) + : undefined, + validate: (value) => { + if (value === "" && !param.is_required) { + return true; + } else if (value === "") { + return "Value is required"; + } + return true; + }, + }); + + return rawValue === "" ? undefined : rawValue; + } + case "INTEGER": { + const rawValue = await input({ + message, + default: param.default_value + ? (param.default_value as string) + : undefined, + validate: (value) => { + if (value === "" && !param.is_required) { + return true; + } else if (value === "") { + return "Value is required"; + } + + const num = Number(value); + if (isNaN(num)) { + return "Value must be a number"; + } else if (num % 1 !== 0) { + return "Value must be an integer"; + } else { + return true; + } + }, + }); + + return rawValue === "" ? undefined : Number(rawValue); + } + case "FLOAT": { + const rawValue = await input({ + message, + default: param.default_value + ? (param.default_value as string) + : undefined, + validate: (value) => { + if (value === "" && !param.is_required) { + return true; + } else if (value === "") { + return "Value is required"; + } + + const num = Number(value); + if (isNaN(num)) { + return "Value must be a number"; + } else { + return true; + } + }, + }); + + return rawValue === "" ? undefined : Number(rawValue); + } + case "BOOLEAN": + return await confirm({ + message, + default: false, + }); + case "SELECT": { + return await select({ + message, + default: param.default_value + ? (param.default_value as string) + : undefined, + choices: param.definition!.options.map((option) => ({ + name: option, + value: option, + })), + }); + } + case "USER": + throw new CliError( + "User-based parameters are not yet supported in the CLI", + EXIT_CODES.ARGUMENT_ERROR, + ); + case "TEAM": { + renderRichText([ui.p("Fetching teams...")]); + const teamsResponse = await listTeams(runtime); + const teams = teamsResponse.teams; + + const teamId = await search({ + message, + source: (term: string | undefined) => + teams + .filter( + (team) => + term === undefined || + team.name.toLowerCase().includes(term.toLowerCase()), + ) + .map((team) => ({ + name: team.name, + value: team.id, + })), + }); + + // TODO: change the trigger endpoint to accept encoded IDs too + const decodedTeamId = decodeBase64(teamId); + + return decodedTeamId; + } + case "EMAIL": { + const rawValue = await input({ + message, + default: param.default_value + ? (param.default_value as string) + : undefined, + validate: (value) => { + if (value === "" && !param.is_required) { + return true; + } else if (value === "") { + return "Value is required"; + } else if (!isValidEmail(value)) { + return "Value must be a valid email address"; + } + return true; + }, + }); + + return rawValue === "" ? undefined : rawValue; + } + default: + throw new CliError( + `Unknown parameter type: ${param.type}`, + EXIT_CODES.ARGUMENT_ERROR, + ); + } +} + +function decodeBase64(value: string): string { + return Buffer.from(value, "base64").toString("utf-8"); +} + +function isValidEmail(value: string): boolean { + return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value); +} diff --git a/src/commands/workflowRuns/trigger.ts b/src/commands/workflowRuns/trigger.ts index 6e104d7..0c5db16 100644 --- a/src/commands/workflowRuns/trigger.ts +++ b/src/commands/workflowRuns/trigger.ts @@ -16,8 +16,8 @@ import { import { buildRuntime } from "../../runtime.js"; import type { Runtime } from "../../types.js"; import * as ui from "../../ui.js"; -import { listWorkflows, WorkflowParameter } from "../workflows.js"; -import { listTeams } from "../teams.js"; +import { listWorkflows } from "../workflows.js"; +import { promptForParameterValue } from "./parameters.js"; const DEFAULT_RETRY_AFTER_MS = 1000; @@ -80,8 +80,6 @@ export function triggerCommand() { const context = getContext(command); const runtime = buildRuntime(context); - const progress = new AsyncProgressReporter(); - // Fetch list of all workflows, find the one that applies const workflowsResponse = await listWorkflows(runtime, {}); const workflow = workflowsResponse.workflows.find( @@ -95,8 +93,15 @@ export function triggerCommand() { ); } - // Collect options const entityIdentifier = parseOptionalTrimmed(options.entity); + if (entityIdentifier === undefined && workflow.scope === "ENTITY") { + throw new CliError( + "--entity is required for entity-scoped workflows", + EXIT_CODES.ARGUMENT_ERROR, + ); + } + + // Collect parameter data const parameterData = parseParameterData(options.param as string[]); if (isInteractive()) { for (const param of workflow.parameters) { @@ -111,21 +116,51 @@ export function triggerCommand() { } } - const body: Record = { + // Trigger the workflow + const triggerRequestBody: Record = { workflow_identifier: workflowIdentifier.trim(), }; if (entityIdentifier !== undefined) { - body.entity_identifier = entityIdentifier; + triggerRequestBody.entity_identifier = entityIdentifier; } if (Object.keys(parameterData).length > 0) { - body.data = parameterData; + triggerRequestBody.data = parameterData; + } + + let runId: string; + try { + const triggerResponse = await triggerWorkflowRun( + runtime, + triggerRequestBody, + ); + runId = triggerResponse.body.workflow_run.id; + } catch (error) { + renderRichText( + [ + ui.p( + `${ui.error(ui.GLYPHS.ERROR)} Failed to trigger workflow run.`, + ), + ], + { useStderr: true }, + ); + throw error; } + renderRichText( + [ + ui.p(`Workflow run ${ui.code(runId)} triggered.`, false), + ui.p( + `Web link: ${ui.link(ui.webLink(`/self-service/workflow-runs/${runId}`, runtime))}`, + ), + ], + { useStderr: true }, + ); + + // Poll for updates + const progress = new AsyncProgressReporter(); try { - progress.start(ui.bold("Triggering workflow")); - const triggerResponse = await triggerWorkflowRun(runtime, body); - const runId = triggerResponse.body.workflow_run.id; - await waitForRetryAfter(triggerResponse.retryAfterMs); + progress.start(ui.bold("Waiting for completion...")); + await waitForRetryAfter(); const finalRun = await waitForWorkflowRun( runtime, @@ -156,166 +191,6 @@ function isInteractive(): boolean { return process.stdin.isTTY && process.stderr.isTTY; } -async function promptForParameterValue( - runtime: Runtime, - param: WorkflowParameter, -): Promise { - const message = param.description - ? `${param.name}: ${param.description}` - : param.name; - - switch (param.type) { - case "STRING": { - const rawValue = await input({ - message, - default: param.default_value - ? (param.default_value as string) - : undefined, - validate: (value) => { - if (value === "" && !param.is_required) { - return true; - } else if (value === "") { - return "Value is required"; - } - return true; - }, - }); - - return rawValue === "" ? undefined : rawValue; - } - case "INTEGER": { - const rawValue = await input({ - message, - default: param.default_value - ? (param.default_value as string) - : undefined, - validate: (value) => { - if (value === "" && !param.is_required) { - return true; - } else if (value === "") { - return "Value is required"; - } - - const num = Number(value); - if (isNaN(num)) { - return "Value must be a number"; - } else if (num % 1 !== 0) { - return "Value must be an integer"; - } else { - return true; - } - }, - }); - - return rawValue === "" ? undefined : Number(rawValue); - } - case "FLOAT": { - const rawValue = await input({ - message, - default: param.default_value - ? (param.default_value as string) - : undefined, - validate: (value) => { - if (value === "" && !param.is_required) { - return true; - } else if (value === "") { - return "Value is required"; - } - - const num = Number(value); - if (isNaN(num)) { - return "Value must be a number"; - } else { - return true; - } - }, - }); - - return rawValue === "" ? undefined : Number(rawValue); - } - case "BOOLEAN": - return await confirm({ - message, - default: false, - }); - case "SELECT": { - return await select({ - message, - default: param.default_value - ? (param.default_value as string) - : undefined, - choices: param.definition!.options.map((option) => ({ - name: option, - value: option, - })), - }); - } - case "USER": - throw new CliError( - "User-based parameters are not yet supported in the CLI", - EXIT_CODES.ARGUMENT_ERROR, - ); - case "TEAM": { - renderRichText([ui.p("Fetching teams...")]); - const teamsResponse = await listTeams(runtime); - const teams = teamsResponse.teams; - - const teamId = await search({ - message, - source: (term: string | undefined) => - teams - .filter( - (team) => - term === undefined || - team.name.toLowerCase().includes(term.toLowerCase()), - ) - .map((team) => ({ - name: team.name, - value: team.id, - })), - }); - - // TODO: change the trigger endpoint to accept encoded IDs too - const decodedTeamId = decodeBase64(teamId); - - return decodedTeamId; - } - case "EMAIL": { - const rawValue = await input({ - message, - default: param.default_value - ? (param.default_value as string) - : undefined, - validate: (value) => { - if (value === "" && !param.is_required) { - return true; - } else if (value === "") { - return "Value is required"; - } else if (!isValidEmail(value)) { - return "Value must be a valid email address"; - } - return true; - }, - }); - - return rawValue === "" ? undefined : rawValue; - } - default: - throw new CliError( - `Unknown parameter type: ${param.type}`, - EXIT_CODES.ARGUMENT_ERROR, - ); - } -} - -function decodeBase64(value: string): string { - return Buffer.from(value, "base64").toString("utf-8"); -} - -function isValidEmail(value: string): boolean { - return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value); -} - function parseOptionalTrimmed(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; From 6ffcdb27bcd07e7cf0f8f8a990e6c8888796bdcd Mon Sep 17 00:00:00 2001 From: Andrew Jensen Date: Tue, 5 May 2026 16:14:28 -0600 Subject: [PATCH 06/22] Define info command, move things into its module --- src/commands/workflowRuns.ts | 2 + src/commands/workflowRuns/info.ts | 130 +++++++++++++++++++++++++++ src/commands/workflowRuns/trigger.ts | 98 +++----------------- 3 files changed, 142 insertions(+), 88 deletions(-) create mode 100644 src/commands/workflowRuns/info.ts diff --git a/src/commands/workflowRuns.ts b/src/commands/workflowRuns.ts index 1bf4c86..2b0307a 100644 --- a/src/commands/workflowRuns.ts +++ b/src/commands/workflowRuns.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; +import { infoCommand } from "./workflowRuns/info.js"; import { triggerCommand } from "./workflowRuns/trigger.js"; export function workflowRunsCommand(): Command { @@ -7,6 +8,7 @@ export function workflowRunsCommand(): Command { .name("workflowRuns") .description("Trigger and monitor Self-service workflow runs"); + workflowRuns.addCommand(infoCommand()); workflowRuns.addCommand(triggerCommand()); return workflowRuns; diff --git a/src/commands/workflowRuns/info.ts b/src/commands/workflowRuns/info.ts new file mode 100644 index 0000000..5e42c2f --- /dev/null +++ b/src/commands/workflowRuns/info.ts @@ -0,0 +1,130 @@ +import { Command } from "commander"; + +import { + createExampleText, + getContext, + wrapAction, +} from "../../commandHelpers.js"; +import { buildRuntime } from "../../runtime.js"; +import { renderJson, renderRichText } from "../../renderers.js"; +import * as ui from "../../ui.js"; +import { request } from "../../http.js"; +import { Runtime } from "../../types.js"; + +export function infoCommand() { + return new Command() + .name("info") + .description("Get info for a workflow run") + .argument("", "The ID of the workflow run") + .addHelpText( + "afterAll", + createExampleText([ + { + label: "Get info for a workflow run", + command: "dx workflowRuns info hvserjgz5lo7", + }, + ]), + ) + .action( + wrapAction(async (workflowRunId: string, options, command) => { + const context = getContext(command); + const runtime = buildRuntime(context); + + const infoResponse = await getWorkflowRun(runtime, workflowRunId); + const workflowRun = infoResponse.body.workflow_run; + + if (runtime.context.json) { + renderJson({ ok: true, workflow_run: workflowRun }); + } else { + renderWorkflowRunSummary(workflowRun, runtime); + } + }), + ); +} + +export function renderWorkflowRunSummary( + run: WorkflowRunDetail, + runtime: Runtime, +) { + const items = [ + ui.dli("Run ID", ui.code(run.id)), + ui.dli("Status", run.status), + ]; + if (run.workflow) { + items.push( + ui.dli( + "Workflow", + `${run.workflow.name} (${ui.code(run.workflow.identifier)})`, + ), + ); + items.push( + ui.dli( + "Web link", + ui.link(ui.webLink(`/self-service/workflow-runs/${run.id}`, runtime)), + ), + ); + } + if (run.entity) { + items.push( + ui.dli("Entity", `${run.entity.name} (${run.entity.identifier})`), + ); + } + if (run.started_at) { + items.push(ui.dli("Started", ui.timestampSummary(run.started_at))); + } + if (run.completed_at) { + items.push(ui.dli("Completed", ui.timestampSummary(run.completed_at))); + } + if (run.links?.length) { + const linkLines = run.links.map((l) => + l.label ? `${l.label}: ${ui.link(l.url)}` : ui.link(l.url), + ); + items.push(ui.dli("Links", linkLines.join("\n"))); + } + + renderRichText([ + ui.h2("Workflow run"), + ui.dl(items, { termWidth: 14 }), + ui.blankLine(), + ]); +} + +export type WorkflowRunEvent = { + id: string; + type: string; + occurred_at: string; + message: string | null; + data?: Record; +}; + +export type WorkflowRunDetail = { + id: string; + status: string; + started_at?: string; + completed_at?: string | null; + data?: Record; + links?: Array<{ url: string; icon?: string; label?: string }>; + workflow?: { + identifier: string; + name: string; + description?: string | null; + scope?: string; + }; + entity?: { identifier: string; name: string }; + events?: WorkflowRunEvent[]; +}; + +export type InfoWorkflowRunResponse = { + ok: true; + workflow_run: WorkflowRunDetail; +}; + +export async function getWorkflowRun( + runtime: Runtime, + id: string, +): Promise<{ body: InfoWorkflowRunResponse; retryAfterMs?: number }> { + return request(runtime, "/workflowRuns.info", { + method: "GET", + query: { id }, + }); +} diff --git a/src/commands/workflowRuns/trigger.ts b/src/commands/workflowRuns/trigger.ts index 0c5db16..fdda126 100644 --- a/src/commands/workflowRuns/trigger.ts +++ b/src/commands/workflowRuns/trigger.ts @@ -1,5 +1,4 @@ import { Command } from "commander"; -import { input, select, confirm, search } from "@inquirer/prompts"; import { createExampleText, @@ -18,6 +17,12 @@ import type { Runtime } from "../../types.js"; import * as ui from "../../ui.js"; import { listWorkflows } from "../workflows.js"; import { promptForParameterValue } from "./parameters.js"; +import { + getWorkflowRun, + renderWorkflowRunSummary, + WorkflowRunDetail, + WorkflowRunEvent, +} from "./info.js"; const DEFAULT_RETRY_AFTER_MS = 1000; @@ -162,7 +167,7 @@ export function triggerCommand() { progress.start(ui.bold("Waiting for completion...")); await waitForRetryAfter(); - const finalRun = await waitForWorkflowRun( + const finalDetail = await waitForWorkflowRun( runtime, progress, runId, @@ -171,12 +176,12 @@ export function triggerCommand() { if (runtime.context.json) { progress.stop(); - renderJson({ ok: true, workflow_run: finalRun }); + renderJson({ ok: true, workflow_run: finalDetail }); } else { progress.stop( - `${ui.success(ui.GLYPHS.CHECK)} Workflow run ${ui.code(finalRun.id)} completed with status ${ui.bold(finalRun.status)}.`, + `${ui.success(ui.GLYPHS.CHECK)} Workflow run ${ui.code(finalDetail.id)} completed with status ${ui.bold(finalDetail.status)}.`, ); - renderRichText(renderWorkflowRunSummary(finalRun, runtime)); + renderWorkflowRunSummary(finalDetail, runtime); } } catch (error) { progress.stop(`${ui.error(ui.GLYPHS.ERROR)} Workflow run failed.`); @@ -253,41 +258,11 @@ function coerceParamValue(raw: string): unknown { return raw; } -type WorkflowRunEvent = { - id: string; - type: string; - occurred_at: string; - message: string | null; - data?: Record; -}; - -type WorkflowRunDetail = { - id: string; - status: string; - started_at?: string; - completed_at?: string | null; - data?: Record; - links?: Array<{ url: string; icon?: string; label?: string }>; - workflow?: { - identifier: string; - name: string; - description?: string | null; - scope?: string; - }; - entity?: { identifier: string; name: string }; - events?: WorkflowRunEvent[]; -}; - type TriggerWorkflowRunResponse = { ok: true; workflow_run: { id: string }; }; -type InfoWorkflowRunResponse = { - ok: true; - workflow_run: WorkflowRunDetail; -}; - async function triggerWorkflowRun( runtime: Runtime, body: Record, @@ -298,16 +273,6 @@ async function triggerWorkflowRun( }); } -async function getWorkflowRun( - runtime: Runtime, - id: string, -): Promise<{ body: InfoWorkflowRunResponse; retryAfterMs?: number }> { - return request(runtime, "/workflowRuns.info", { - method: "GET", - query: { id }, - }); -} - async function waitForWorkflowRun( runtime: Runtime, progress: AsyncProgressReporter, @@ -430,49 +395,6 @@ function findLastPostMessage( return undefined; } -function renderWorkflowRunSummary(run: WorkflowRunDetail, runtime: Runtime) { - const items = [ - ui.dli("Run ID", ui.code(run.id)), - ui.dli("Status", run.status), - ]; - if (run.workflow) { - items.push( - ui.dli( - "Workflow", - `${run.workflow.name} (${ui.code(run.workflow.identifier)})`, - ), - ); - items.push( - ui.dli( - "Web link", - ui.link(ui.webLink(`/self-service/workflow-runs/${run.id}`, runtime)), - ), - ); - } - if (run.entity) { - items.push( - ui.dli("Entity", `${run.entity.name} (${run.entity.identifier})`), - ); - } - if (run.started_at) { - items.push(ui.dli("Started", ui.timestampSummary(run.started_at))); - } - if (run.completed_at) { - items.push(ui.dli("Completed", ui.timestampSummary(run.completed_at))); - } - if (run.links?.length) { - const linkLines = run.links.map((l) => - l.label ? `${l.label}: ${ui.link(l.url)}` : ui.link(l.url), - ); - items.push(ui.dli("Links", linkLines.join("\n"))); - } - return [ - ui.h2("Workflow run"), - ui.dl(items, { termWidth: 14 }), - ui.blankLine(), - ]; -} - async function waitForRetryAfter(retryAfterMs?: number): Promise { const delayMs = retryAfterMs ?? DEFAULT_RETRY_AFTER_MS; await new Promise((resolve) => { From 686d14f8e420a4f1aeab7ba913d5f2143e1d84cf Mon Sep 17 00:00:00 2001 From: Andrew Jensen Date: Tue, 5 May 2026 16:31:23 -0600 Subject: [PATCH 07/22] Start handling events --- src/commands/workflowRuns/trigger.ts | 129 +++++++++------------------ 1 file changed, 41 insertions(+), 88 deletions(-) diff --git a/src/commands/workflowRuns/trigger.ts b/src/commands/workflowRuns/trigger.ts index fdda126..c137cc6 100644 --- a/src/commands/workflowRuns/trigger.ts +++ b/src/commands/workflowRuns/trigger.ts @@ -157,34 +157,37 @@ export function triggerCommand() { ui.p( `Web link: ${ui.link(ui.webLink(`/self-service/workflow-runs/${runId}`, runtime))}`, ), + ui.blankLine(), ], { useStderr: true }, ); // Poll for updates - const progress = new AsyncProgressReporter(); try { - progress.start(ui.bold("Waiting for completion...")); await waitForRetryAfter(); - const finalDetail = await waitForWorkflowRun( - runtime, - progress, - runId, - context.json, - ); + const finalDetail = await pollForWorkflowRunInfo(runtime, runId); if (runtime.context.json) { - progress.stop(); renderJson({ ok: true, workflow_run: finalDetail }); } else { - progress.stop( - `${ui.success(ui.GLYPHS.CHECK)} Workflow run ${ui.code(finalDetail.id)} completed with status ${ui.bold(finalDetail.status)}.`, - ); + renderRichText([ + ui.blankLine(), + ui.p( + `${ui.success(ui.GLYPHS.CHECK)} Workflow run ${ui.code(finalDetail.id)} completed with status ${ui.bold(finalDetail.status)}.`, + ), + ui.blankLine(), + ]); renderWorkflowRunSummary(finalDetail, runtime); } } catch (error) { - progress.stop(`${ui.error(ui.GLYPHS.ERROR)} Workflow run failed.`); + renderRichText( + [ + ui.blankLine(), + ui.p(`${ui.error(ui.GLYPHS.ERROR)} Workflow run failed.`), + ], + { useStderr: true }, + ); throw error; } }), @@ -273,28 +276,25 @@ async function triggerWorkflowRun( }); } -async function waitForWorkflowRun( +const POLL_INTERVAL_MS = 1000; + +async function pollForWorkflowRunInfo( runtime: Runtime, - progress: AsyncProgressReporter, workflowRunId: string, - jsonMode: boolean, ): Promise { const seenEventIds = new Set(); - let eventStreamPrimed = false; while (true) { - let retryAfterMs: number | undefined; try { const infoResponse = await getWorkflowRun(runtime, workflowRunId); const workflowRun = infoResponse.body.workflow_run; - retryAfterMs = infoResponse.retryAfterMs; - - if (!jsonMode) { - if (!eventStreamPrimed) { - primeEventIds(workflowRun.events, seenEventIds); - eventStreamPrimed = true; - } else { - emitNewPostMessages(workflowRun.events, seenEventIds); + + if (workflowRun.events) { + for (const event of workflowRun.events) { + if (!seenEventIds.has(event.id)) { + emitWorkflowRunEvent(event, runtime); + seenEventIds.add(event.id); + } } } @@ -313,17 +313,17 @@ async function waitForWorkflowRun( ); } - progress.update( - `${ui.bold("Workflow running")} ${ui.dim(`(${workflowRunId})`)} — ${workflowRun.status}`, - ); + // progress.update( + // `${ui.bold("Workflow running")} ${ui.dim(`(${workflowRunId})`)} — ${workflowRun.status}`, + // ); - await waitForRetryAfter(retryAfterMs); + await waitForRetryAfter(POLL_INTERVAL_MS); } catch (error) { if (error instanceof HttpError && error.status === 429) { - progress.update( - `${ui.warning(ui.GLYPHS.WARNING)} Rate limited while polling ${ui.dim(`(${workflowRunId})`)}; retrying`, - ); - await waitForRetryAfter(retryAfterMs); + // progress.update( + // `${ui.warning(ui.GLYPHS.WARNING)} Rate limited while polling ${ui.dim(`(${workflowRunId})`)}; retrying`, + // ); + await waitForRetryAfter(POLL_INTERVAL_MS * 5); continue; } throw error; @@ -331,70 +331,23 @@ async function waitForWorkflowRun( } } -function primeEventIds( - events: WorkflowRunEvent[] | undefined, - seenEventIds: Set, -): void { - if (!events?.length) { - return; - } - for (const event of events) { - seenEventIds.add(event.id); - } -} - -function emitNewPostMessages( - events: WorkflowRunEvent[] | undefined, - seenEventIds: Set, -): void { - if (!events?.length) { - return; - } - - for (const event of events) { - if (seenEventIds.has(event.id)) { - continue; - } - seenEventIds.add(event.id); - if (event.type === "POST_MESSAGE" && event.message) { - renderRichText([ui.p(event.message)], { useStderr: true }); - } - } +function emitWorkflowRunEvent(event: WorkflowRunEvent, runtime: Runtime): void { + renderRichText( + [ui.p(`${ui.dim(event.occurred_at)} Received event: ${event.type}`)], + { useStderr: true }, + ); } function buildTerminalStatusError(run: WorkflowRunDetail): CliError { if (run.status === "FAILED") { - return new CliError( - `Workflow run ${run.id} failed${formatStatusSuffix(run)}.`, - 1, - ); + return new CliError(`Workflow run ${run.id} failed.`, 1); } return new CliError( - `Workflow run ${run.id} ended with status ${run.status}${formatStatusSuffix(run)}.`, + `Workflow run ${run.id} ended with status ${run.status}.`, 1, ); } -function formatStatusSuffix(run: WorkflowRunDetail): string { - const lastMessage = findLastPostMessage(run.events); - return lastMessage ? `: ${lastMessage}` : ""; -} - -function findLastPostMessage( - events: WorkflowRunEvent[] | undefined, -): string | undefined { - if (!events?.length) { - return undefined; - } - for (let i = events.length - 1; i >= 0; i--) { - const ev = events[i]; - if (ev.type === "POST_MESSAGE" && ev.message) { - return ev.message; - } - } - return undefined; -} - async function waitForRetryAfter(retryAfterMs?: number): Promise { const delayMs = retryAfterMs ?? DEFAULT_RETRY_AFTER_MS; await new Promise((resolve) => { From 7c1b01606e404d7290c27ba08e652a086c3c7d9a Mon Sep 17 00:00:00 2001 From: Andrew Jensen Date: Tue, 5 May 2026 17:05:42 -0600 Subject: [PATCH 08/22] Define commands for posting events --- src/commands/workflowRuns.ts | 6 ++ src/commands/workflowRuns/addLink.ts | 66 ++++++++++++++++++ src/commands/workflowRuns/changeStatus.ts | 83 +++++++++++++++++++++++ src/commands/workflowRuns/postMessage.ts | 58 ++++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 src/commands/workflowRuns/addLink.ts create mode 100644 src/commands/workflowRuns/changeStatus.ts create mode 100644 src/commands/workflowRuns/postMessage.ts diff --git a/src/commands/workflowRuns.ts b/src/commands/workflowRuns.ts index 2b0307a..031ef2c 100644 --- a/src/commands/workflowRuns.ts +++ b/src/commands/workflowRuns.ts @@ -1,6 +1,9 @@ import { Command } from "commander"; +import { addLinkCommand } from "./workflowRuns/addLink.js"; +import { changeStatusCommand } from "./workflowRuns/changeStatus.js"; import { infoCommand } from "./workflowRuns/info.js"; +import { postMessageCommand } from "./workflowRuns/postMessage.js"; import { triggerCommand } from "./workflowRuns/trigger.js"; export function workflowRunsCommand(): Command { @@ -8,7 +11,10 @@ export function workflowRunsCommand(): Command { .name("workflowRuns") .description("Trigger and monitor Self-service workflow runs"); + workflowRuns.addCommand(addLinkCommand()); + workflowRuns.addCommand(changeStatusCommand()); workflowRuns.addCommand(infoCommand()); + workflowRuns.addCommand(postMessageCommand()); workflowRuns.addCommand(triggerCommand()); return workflowRuns; diff --git a/src/commands/workflowRuns/addLink.ts b/src/commands/workflowRuns/addLink.ts new file mode 100644 index 0000000..db66773 --- /dev/null +++ b/src/commands/workflowRuns/addLink.ts @@ -0,0 +1,66 @@ +import { Command } from "commander"; + +import { wrapAction } from "../../commandHelpers.js"; +import { getContext } from "../../commandHelpers.js"; +import { buildRuntime } from "../../runtime.js"; +import { renderJson, renderRichText } from "../../renderers.js"; +import * as ui from "../../ui.js"; +import { request } from "../../http.js"; +import { Runtime } from "../../types.js"; + +export function addLinkCommand() { + return new Command() + .name("addLink") + .description("Add a link to a workflow run") + .argument("", "The ID of the workflow run") + .requiredOption("--url ", "The URL of the link") + .requiredOption("--label