diff --git a/.changeset/workflow-executor-waiters.md b/.changeset/workflow-executor-waiters.md new file mode 100644 index 000000000..27490eb1e --- /dev/null +++ b/.changeset/workflow-executor-waiters.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": minor +--- + +Add durable workflow and executor waiters with timeout, retry, and JSON diagnostics. diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index cbdb7517c..f729e63fa 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -6,7 +6,7 @@ - [#1491](https://github.com/tailor-platform/sdk/pull/1491) [`be30383`](https://github.com/tailor-platform/sdk/commit/be30383e368b01f81f7e019fc509c9b61a33eb37) Thanks [@toiroakr](https://github.com/toiroakr)! - chore(deps): upgrade typescript to 6.0.3 - + Upgrade the workspace dev/build toolchain to TypeScript 6.0.3. Dev-dependency change only — no public API or runtime behavior change. diff --git a/packages/sdk/docs/cli-reference.md b/packages/sdk/docs/cli-reference.md index 3f28e6167..032b4024d 100644 --- a/packages/sdk/docs/cli-reference.md +++ b/packages/sdk/docs/cli-reference.md @@ -223,6 +223,7 @@ Commands for managing workflows and executions. | [workflow list](./cli/workflow.md#workflow-list) | List all workflows in the workspace. | | [workflow get](./cli/workflow.md#workflow-get) | Get workflow details. | | [workflow start](./cli/workflow.md#workflow-start) | Start a workflow execution. | +| [workflow wait](./cli/workflow.md#workflow-wait) | Wait for a workflow execution. | | [workflow executions](./cli/workflow.md#workflow-executions) | List or get workflow executions. | | [workflow resume](./cli/workflow.md#workflow-resume) | Resume a failed or pending workflow execution. | diff --git a/packages/sdk/docs/cli/executor.md b/packages/sdk/docs/cli/executor.md index 4f4f0119d..150fc9ffe 100644 --- a/packages/sdk/docs/cli/executor.md +++ b/packages/sdk/docs/cli/executor.md @@ -179,6 +179,7 @@ tailor-sdk executor jobs [options] [job-id] | `--attempts` | - | Show job attempts (only with job ID) (detail mode only) | No | `false` | - | | `--wait` | `-W` | Wait for job completion and downstream execution (workflow/function) if applicable (detail mode only) | No | `false` | - | | `--interval ` | `-i` | Polling interval when using --wait (e.g., '3s', '500ms', '1m') | No | `"3s"` | - | +| `--timeout ` | `-t` | Maximum time to wait when using --wait (e.g., '30s', '5m') | No | `"5m"` | - | | `--order ` | - | Sort order (asc or desc) | No | `"desc"` | - | | `--limit ` | - | Maximum number of jobs to list (0: unlimited, default: 50) (list mode only) | No | `50` | - | | `--logs` | `-l` | Display function execution logs after completion (requires --wait) | No | `false` | - | @@ -283,6 +284,7 @@ tailor-sdk executor trigger [options] | `--header
` | `-H` | Request header (format: 'Key: Value', can be specified multiple times) | No | - | - | | `--wait` | `-W` | Wait for job completion and downstream execution (workflow/function) if applicable | No | `false` | - | | `--interval ` | `-i` | Polling interval when using --wait (e.g., '3s', '500ms', '1m') | No | `"3s"` | - | +| `--timeout ` | `-t` | Maximum time to wait when using --wait (e.g., '30s', '5m') | No | `"5m"` | - | | `--logs` | `-l` | Display function execution logs after completion (requires --wait) | No | `false` | - | @@ -323,6 +325,57 @@ $ tailor-sdk executor trigger my-executor -W -l +**Shell automation** + +Trigger an executor and wait for the executor job plus any downstream workflow or +function execution: + +```bash +tailor-sdk executor trigger daily-workflow \ + --wait \ + --timeout 5m \ + --interval 5s \ + --json +``` + +Wait for an existing job when another process already captured the job ID: + +```bash +tailor-sdk executor jobs daily-workflow "$job_id" \ + --wait \ + --timeout 5m \ + --logs \ + --json +``` + +**Programmatic API** + +Import your executor definition and pass it to the typed API: + +```ts +import { triggerExecutor, watchExecutorJob } from "@tailor-platform/sdk/cli"; +import dailyWorkflow from "../executors/dailyWorkflow"; + +const { jobId } = await triggerExecutor({ + executor: dailyWorkflow, +}); + +if (!jobId) { + throw new Error("Executor trigger did not return a job ID"); +} + +const result = await watchExecutorJob({ + executor: dailyWorkflow, + jobId, + timeout: 5 * 60 * 1000, + interval: 5000, +}); + +if (result.timedOut) { + throw new Error(`Executor job ${result.job.id} timed out at ${result.job.status}`); +} +``` + **Notes** diff --git a/packages/sdk/docs/cli/workflow.md b/packages/sdk/docs/cli/workflow.md index c9e46c13f..530eb7468 100644 --- a/packages/sdk/docs/cli/workflow.md +++ b/packages/sdk/docs/cli/workflow.md @@ -33,6 +33,7 @@ tailor-sdk workflow [command] | [`workflow list`](#workflow-list) | List all workflows in the workspace. | | [`workflow get`](#workflow-get) | Get workflow details. | | [`workflow start`](#workflow-start) | Start a workflow execution. | +| [`workflow wait`](#workflow-wait) | Wait for a workflow execution. | | [`workflow executions`](#workflow-executions) | List or get workflow executions. | | [`workflow resume`](#workflow-resume) | Resume a failed or pending workflow execution. | @@ -175,8 +176,10 @@ tailor-sdk workflow start [options] | `--machine-user ` | `-m` | Machine user name. Falls back to the active profile's default machine user. | No | - | `TAILOR_PLATFORM_MACHINE_USER_NAME` | | `--arg ` | `-a` | Workflow argument (JSON string) | No | - | - | | `--wait` | `-W` | Wait for execution to complete | No | `false` | - | -| `--interval ` | `-i` | Polling interval when using --wait (e.g., '3s', '500ms', '1m') | No | `"3s"` | - | -| `--logs` | `-l` | Display job execution logs after completion (requires --wait) | No | `false` | - | +| `--interval ` | `-i` | Polling interval when waiting (e.g., '3s', '500ms', '1m') | No | `"3s"` | - | +| `--timeout ` | `-t` | Maximum time to wait (e.g., '30s', '10m') | No | `"10m"` | - | +| `--until ` | `-u` | Wait target (success, suspended, terminal) | No | `"terminal"` | - | +| `--logs` | `-l` | Display job execution logs after completion | No | `false` | - | @@ -185,6 +188,136 @@ tailor-sdk workflow start [options] See [Global Options](../cli-reference.md#global-options) for options available to all commands. + + +### workflow wait + + + + + +Wait for a workflow execution. + + + + + +**Usage** + +``` +tailor-sdk workflow wait [options] +``` + + + + + +**Arguments** + +| Argument | Description | Required | +| -------------- | ------------ | -------- | +| `execution-id` | Execution ID | Yes | + + + + + +**Options** + +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | --------------------------------------------------------- | -------- | ------------ | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--interval ` | `-i` | Polling interval when waiting (e.g., '3s', '500ms', '1m') | No | `"3s"` | - | +| `--timeout ` | `-t` | Maximum time to wait (e.g., '30s', '10m') | No | `"10m"` | - | +| `--until ` | `-u` | Wait target (success, suspended, terminal) | No | `"terminal"` | - | +| `--logs` | `-l` | Display job execution logs after completion | No | `false` | - | + + + + + +See [Global Options](../cli-reference.md#global-options) for options available to all commands. + + + + + +**Examples** + +**Wait for workflow success** + +```bash +$ tailor-sdk workflow wait execution-id --until success --timeout 10m --json +``` + +**Wait for a workflow wait point** + +```bash +$ tailor-sdk workflow wait execution-id --until suspended --timeout 6m --logs --json +``` + +**Wait for success, failure, or suspension** + +```bash +$ tailor-sdk workflow wait execution-id --until terminal +``` + + + +**Shell automation** + +Capture the execution ID from `workflow start` and wait for the same run from a +separate command: + +```bash +execution_id="$( + tailor-sdk workflow start order-workflow --json | jq -r '.executionId' +)" + +tailor-sdk workflow wait "$execution_id" \ + --until success \ + --timeout 10m \ + --interval 5s \ + --json +``` + +Wait until a workflow reaches a wait point, such as an approval step: + +```bash +tailor-sdk workflow wait "$execution_id" \ + --until suspended \ + --timeout 6m \ + --logs \ + --json +``` + +**Programmatic API** + +Use `waitWorkflowExecution` when a script already has an execution ID and needs +the same waiter behavior as the CLI: + +```ts +import { waitWorkflowExecution } from "@tailor-platform/sdk/cli"; + +const executionId = process.env.EXECUTION_ID; + +if (!executionId) { + throw new Error("EXECUTION_ID is required"); +} + +const result = await waitWorkflowExecution({ + executionId, + until: "success", + timeout: 10 * 60 * 1000, + interval: 5000, +}); + +if (result.timedOut) { + throw new Error(`Workflow ${result.id} timed out at ${result.status}`); +} +``` + ### workflow executions @@ -221,17 +354,19 @@ tailor-sdk workflow executions [options] [execution-id] **Options** -| Option | Alias | Description | Required | Default | Env | -| --------------------------------- | ----- | -------------------------------------------------------------- | -------- | -------- | ------------------------------ | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--order ` | - | Sort order (asc or desc) | No | `"desc"` | - | -| `--limit ` | `-l` | Maximum number of items to return (0: unlimited) | No | `50` | - | -| `--workflow-name ` | `-n` | Filter by workflow name (list mode only) | No | - | - | -| `--status ` | `-s` | Filter by status (list mode only) | No | - | - | -| `--wait` | `-W` | Wait for execution to complete | No | `false` | - | -| `--interval ` | `-i` | Polling interval when using --wait (e.g., '3s', '500ms', '1m') | No | `"3s"` | - | -| `--logs` | - | Display job execution logs (detail mode only) | No | `false` | - | +| Option | Alias | Description | Required | Default | Env | +| --------------------------------- | ----- | --------------------------------------------------------- | -------- | ------------ | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--order ` | - | Sort order (asc or desc) | No | `"desc"` | - | +| `--limit ` | `-l` | Maximum number of items to return (0: unlimited) | No | `50` | - | +| `--workflow-name ` | `-n` | Filter by workflow name (list mode only) | No | - | - | +| `--status ` | `-s` | Filter by status (list mode only) | No | - | - | +| `--wait` | `-W` | Wait for execution to complete | No | `false` | - | +| `--interval ` | `-i` | Polling interval when waiting (e.g., '3s', '500ms', '1m') | No | `"3s"` | - | +| `--timeout ` | `-t` | Maximum time to wait (e.g., '30s', '10m') | No | `"10m"` | - | +| `--until ` | `-u` | Wait target (success, suspended, terminal) | No | `"terminal"` | - | +| `--logs` | - | Display job execution logs (detail mode only) | No | `false` | - | @@ -276,13 +411,15 @@ tailor-sdk workflow resume [options] **Options** -| Option | Alias | Description | Required | Default | Env | -| ------------------------------- | ----- | -------------------------------------------------------------- | -------- | ------- | ------------------------------ | -| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | -| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | -| `--wait` | `-W` | Wait for execution to complete | No | `false` | - | -| `--interval ` | `-i` | Polling interval when using --wait (e.g., '3s', '500ms', '1m') | No | `"3s"` | - | -| `--logs` | `-l` | Display job execution logs after completion (requires --wait) | No | `false` | - | +| Option | Alias | Description | Required | Default | Env | +| ------------------------------- | ----- | --------------------------------------------------------- | -------- | ------------ | ------------------------------ | +| `--workspace-id ` | `-w` | Workspace ID | No | - | `TAILOR_PLATFORM_WORKSPACE_ID` | +| `--profile ` | `-p` | Workspace profile | No | - | `TAILOR_PLATFORM_PROFILE` | +| `--wait` | `-W` | Wait for execution to complete | No | `false` | - | +| `--interval ` | `-i` | Polling interval when waiting (e.g., '3s', '500ms', '1m') | No | `"3s"` | - | +| `--timeout ` | `-t` | Maximum time to wait (e.g., '30s', '10m') | No | `"10m"` | - | +| `--until ` | `-u` | Wait target (success, suspended, terminal) | No | `"terminal"` | - | +| `--logs` | `-l` | Display job execution logs after completion | No | `false` | - | diff --git a/packages/sdk/src/cli/commands/executor/jobs.test.ts b/packages/sdk/src/cli/commands/executor/jobs.test.ts new file mode 100644 index 000000000..501a65adc --- /dev/null +++ b/packages/sdk/src/cli/commands/executor/jobs.test.ts @@ -0,0 +1,174 @@ +import { Code, ConnectError } from "@connectrpc/connect"; +import { + ExecutorJobStatus, + ExecutorTargetType, +} from "@tailor-proto/tailor/v1/executor_resource_pb"; +import { WorkflowExecution_Status } from "@tailor-proto/tailor/v1/workflow_resource_pb"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { initOperatorClient } from "@/cli/shared/client"; +import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; +import { getExecutorWaitFailureMessage, watchExecutorJob } from "./jobs"; +import type { ExecutorJob } from "@tailor-proto/tailor/v1/executor_resource_pb"; +import type { WorkflowExecution } from "@tailor-proto/tailor/v1/workflow_resource_pb"; + +vi.mock("@/cli/shared/context", () => ({ + loadAccessToken: vi.fn(), + loadWorkspaceId: vi.fn(), +})); + +vi.mock("@/cli/shared/client", () => ({ + fetchAll: async ( + fn: (pageToken: string, maxPageSize: number) => Promise<[T[], string]>, + ): Promise => { + const [items] = await fn("", 1000); + return items; + }, + initOperatorClient: vi.fn(), +})); + +function executorJob(status: ExecutorJobStatus): ExecutorJob { + return { + id: "job-1", + executorName: "my-executor", + status, + } as ExecutorJob; +} + +describe("watchExecutorJob", () => { + let getExecutorJobMock: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(loadAccessToken).mockResolvedValue("mock-token"); + vi.mocked(loadWorkspaceId).mockResolvedValue("workspace-1"); + + getExecutorJobMock = vi + .fn() + .mockRejectedValueOnce(new ConnectError("temporarily unavailable", Code.Unavailable)) + .mockResolvedValueOnce({ + job: executorJob(ExecutorJobStatus.SUCCESS), + }); + + vi.mocked(initOperatorClient).mockResolvedValue({ + getExecutorExecutor: vi.fn().mockResolvedValue({ + executor: { + targetType: ExecutorTargetType.WEBHOOK, + }, + }), + getExecutorJob: getExecutorJobMock, + listExecutorJobAttempts: vi.fn().mockResolvedValue({ + attempts: [], + nextPageToken: "", + }), + } as unknown as Awaited>); + }); + + test("retries retryable job polling failures", async () => { + const result = await watchExecutorJob({ + executorName: "my-executor", + jobId: "job-1", + interval: 1, + timeout: 100, + showProgress: false, + }); + + expect(result).toMatchObject({ + targetType: "WEBHOOK", + attempts: 2, + timedOut: false, + lastError: null, + job: { + id: "job-1", + executorName: "my-executor", + status: "SUCCESS", + }, + }); + }); + + test("returns timeout diagnostics with the last observed job status", async () => { + getExecutorJobMock.mockReset().mockResolvedValue({ + job: executorJob(ExecutorJobStatus.PENDING), + }); + + const result = await watchExecutorJob({ + executorName: "my-executor", + jobId: "job-1", + interval: 1, + timeout: 5, + showProgress: false, + }); + + expect(result).toMatchObject({ + targetType: "WEBHOOK", + timedOut: true, + lastError: null, + job: { + id: "job-1", + executorName: "my-executor", + status: "PENDING", + }, + }); + expect(result.attempts).toBeGreaterThan(0); + }); + + test("preserves workflow failure status when optional log fetch fails", async () => { + const getWorkflowExecutionMock = vi + .fn() + .mockResolvedValueOnce({ + execution: { + id: "workflow-execution-1", + workflowName: "daily-workflow", + status: WorkflowExecution_Status.FAILED, + jobExecutions: [], + } as unknown as WorkflowExecution, + }) + .mockRejectedValueOnce(new Error("logs unavailable")); + + vi.mocked(initOperatorClient).mockResolvedValue({ + getExecutorExecutor: vi.fn().mockResolvedValue({ + executor: { + targetType: ExecutorTargetType.WORKFLOW, + }, + }), + getExecutorJob: vi.fn().mockResolvedValue({ + job: executorJob(ExecutorJobStatus.SUCCESS), + }), + listExecutorJobAttempts: vi.fn().mockResolvedValue({ + attempts: [ + { + id: "attempt-1", + jobId: "job-1", + status: ExecutorJobStatus.SUCCESS, + operationReference: "workflow-execution-1", + }, + ], + nextPageToken: "", + }), + getWorkflowExecution: getWorkflowExecutionMock, + } as unknown as Awaited>); + + const result = await watchExecutorJob({ + executorName: "my-executor", + jobId: "job-1", + interval: 1, + timeout: 100, + logs: true, + showProgress: false, + }); + + expect(result).toMatchObject({ + targetType: "WORKFLOW", + workflowExecutionId: "workflow-execution-1", + workflowStatus: "FAILED", + timedOut: false, + job: { + id: "job-1", + status: "SUCCESS", + }, + }); + expect(getExecutorWaitFailureMessage(result)).toBe( + "Workflow execution 'workflow-execution-1' failed.", + ); + }); +}); diff --git a/packages/sdk/src/cli/commands/executor/jobs.ts b/packages/sdk/src/cli/commands/executor/jobs.ts index e10dc9e2c..cf5825478 100644 --- a/packages/sdk/src/cli/commands/executor/jobs.ts +++ b/packages/sdk/src/cli/commands/executor/jobs.ts @@ -33,11 +33,11 @@ import { spinner } from "@/cli/shared/spinner"; import { getWorkflowExecution } from "../workflow/executions"; import { waitForExecution } from "../workflow/start"; import { + classifyExecutorJobStatus, colorizeExecutorJobStatus, colorizeFunctionExecutionStatus, executorTargetTypeToString, isFunctionExecutionTerminalStatus, - isExecutorJobTerminalStatus, parseExecutorJobStatus, } from "./status"; import { @@ -76,7 +76,9 @@ export type WatchExecutorJobTypedOptions workspaceId?: string; profile?: string; interval?: number; + timeout?: number; logs?: boolean; + showProgress?: boolean; }; /** @@ -111,7 +113,9 @@ export interface WatchExecutorJobOptions { workspaceId?: string; profile?: string; interval?: number; + timeout?: number; logs?: boolean; + showProgress?: boolean; } export interface ExecutorJobDetailInfo extends ExecutorJobInfo { @@ -127,6 +131,10 @@ export interface WorkflowJobLog { export interface WatchExecutorJobResult { job: ExecutorJobDetailInfo; targetType: string; + elapsedMs: number; + attempts: number; + timedOut: boolean; + lastError: string | null; workflowExecutionId?: string; workflowStatus?: string; workflowJobLogs?: WorkflowJobLog[]; @@ -139,6 +147,32 @@ function formatTime(date: Date): string { return date.toLocaleTimeString("en-US", { hour12: false }); } +function formatWaitError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRetryableWaitError(error: unknown): boolean { + if (!(error instanceof ConnectError)) { + return false; + } + return ( + error.code === Code.Aborted || + error.code === Code.ResourceExhausted || + error.code === Code.Unavailable + ); +} + +function createUnknownExecutorJob(executorName: string, jobId: string): ExecutorJobDetailInfo { + return { + id: jobId, + executorName, + status: "UNKNOWN", + scheduledAt: "N/A", + createdAt: "N/A", + updatedAt: "N/A", + }; +} + /** * List executor jobs for a given executor. * @@ -306,7 +340,48 @@ export async function watchExecutorJob( }); const interval = options.interval ?? 3000; - const sp = spinner().start("Waiting for executor job to complete..."); + const timeout = options.timeout; + const showProgress = options.showProgress ?? !logger.jsonMode; + const startedAt = Date.now(); + const sp = showProgress ? spinner().start("Waiting for executor job to complete...") : null; + + let attempts = 0; + let lastError: string | null = null; + + type WatchExecutorJobResultBase = Omit< + WatchExecutorJobResult, + "elapsedMs" | "attempts" | "timedOut" | "lastError" + >; + + const remainingTimeout = (): number | undefined => { + if (timeout === undefined) { + return undefined; + } + return timeout - (Date.now() - startedAt); + }; + + const withWaitMetadata = ( + result: WatchExecutorJobResultBase, + timedOut: boolean, + ): WatchExecutorJobResult => ({ + ...result, + elapsedMs: Date.now() - startedAt, + attempts, + timedOut, + lastError, + }); + + const timeoutResult = ( + targetType: string, + job: Awaited>["job"], + ): WatchExecutorJobResult => + withWaitMetadata( + { + job: job ? toExecutorJobInfo(job) : createUnknownExecutorJob(executorName, options.jobId), + targetType, + }, + true, + ); try { // Get executor details to determine target type @@ -327,47 +402,75 @@ export async function watchExecutorJob( // loop exits when the executor job reaches a terminal status // oxlint-disable-next-line typescript/no-unnecessary-condition while (true) { - const response = await client.getExecutorJob({ - workspaceId, - executorName, - jobId: options.jobId, - }); + const remainingMs = remainingTimeout(); + if (remainingMs !== undefined && remainingMs <= 0) { + sp?.fail("Executor job wait timed out."); + return timeoutResult(targetTypeStr, job); + } - job = response.job; - if (!job) { - throw new Error(`Job '${options.jobId}' not found.`); + try { + attempts += 1; + const response = await client.getExecutorJob({ + workspaceId, + executorName, + jobId: options.jobId, + }); + + job = response.job; + if (!job) { + throw new Error(`Job '${options.jobId}' not found.`); + } + lastError = null; + + if (classifyExecutorJobStatus(job.status) !== "transient") { + break; + } + } catch (error) { + if (!isRetryableWaitError(error)) { + throw error; + } + lastError = formatWaitError(error); + if (sp) { + sp.text = `Retrying executor job poll... (${formatTime(new Date())})`; + } } - if (isExecutorJobTerminalStatus(job.status)) { - break; + const nextRemainingMs = remainingTimeout(); + if (nextRemainingMs !== undefined && nextRemainingMs <= 0) { + sp?.fail("Executor job wait timed out."); + return timeoutResult(targetTypeStr, job); } - sp.text = `Waiting for executor job... (${formatTime(new Date())})`; - await setTimeout(interval); + if (sp) { + sp.text = `Waiting for executor job... (${formatTime(new Date())})`; + } + await setTimeout( + nextRemainingMs === undefined ? interval : Math.min(interval, nextRemainingMs), + ); } const jobInfo = toExecutorJobInfo(job); const coloredStatus = colorizeExecutorJobStatus(jobInfo.status); if (job.status === ExecutorJobStatus.SUCCESS) { - sp.succeed(`Executor job completed: ${coloredStatus}`); + sp?.succeed(`Executor job completed: ${coloredStatus}`); } else { - sp.fail(`Executor job completed: ${coloredStatus}`); + sp?.fail(`Executor job completed: ${coloredStatus}`); } // Get attempts to find operationReference - const attempts = await fetchAll(async (pageToken, maxPageSize) => { - const { attempts, nextPageToken } = await client.listExecutorJobAttempts({ + const attemptRecords = await fetchAll(async (pageToken, maxPageSize) => { + const { attempts: jobAttempts, nextPageToken } = await client.listExecutorJobAttempts({ workspaceId, jobId: options.jobId, pageToken, pageSize: maxPageSize, pageDirection: PageDirection.DESC, }); - return [attempts, nextPageToken]; + return [jobAttempts, nextPageToken]; }); - const attemptInfos = attempts.map(toExecutorJobAttemptInfo); + const attemptInfos = attemptRecords.map(toExecutorJobAttemptInfo); const jobDetail: ExecutorJobDetailInfo = { ...jobInfo, attempts: attemptInfos, @@ -381,55 +484,82 @@ export async function watchExecutorJob( switch (targetType) { case ExecutorTargetType.WORKFLOW: { // Wait for workflow execution with progress display - sp.stop(); + sp?.stop(); try { + const workflowTimeout = remainingTimeout(); + if (workflowTimeout !== undefined && workflowTimeout <= 0) { + return withWaitMetadata( + { + job: jobDetail, + targetType: targetTypeStr, + workflowExecutionId: operationReference, + }, + true, + ); + } + // Use waitForExecution with progress display (same as workflow start) const executionResult = await waitForExecution({ client, workspaceId, executionId: operationReference, interval, - showProgress: true, + timeout: workflowTimeout, + showProgress, trackJobs: true, }); + attempts += executionResult.attempts; + lastError = executionResult.lastError; // Fetch logs if requested let workflowJobLogs: WorkflowJobLog[] | undefined; if (options.logs) { - const { execution: execWithLogs } = await getWorkflowExecution({ - executionId: operationReference, - workspaceId: options.workspaceId, - profile: options.profile, - logs: true, - }); - if (execWithLogs.jobDetails) { - workflowJobLogs = execWithLogs.jobDetails - .filter((job) => job.logs || job.result) - .map((job) => ({ - jobName: job.stackedJobName || job.id, - logs: job.logs, - result: job.result, - })); + try { + const { execution: execWithLogs } = await getWorkflowExecution({ + executionId: operationReference, + workspaceId: options.workspaceId, + profile: options.profile, + logs: true, + }); + if (execWithLogs.jobDetails) { + workflowJobLogs = execWithLogs.jobDetails + .filter((job) => job.logs || job.result) + .map((job) => ({ + jobName: job.stackedJobName || job.id, + logs: job.logs, + result: job.result, + })); + } + } catch (error) { + logger.warn( + `Could not fetch workflow execution logs: ${error instanceof Error ? error.message : error}`, + ); } } - return { - job: jobDetail, - targetType: targetTypeStr, - workflowExecutionId: operationReference, - workflowStatus: executionResult.status, - workflowJobLogs, - }; + return withWaitMetadata( + { + job: jobDetail, + targetType: targetTypeStr, + workflowExecutionId: operationReference, + workflowStatus: executionResult.status, + workflowJobLogs, + }, + executionResult.timedOut, + ); } catch (error) { logger.warn( `Could not track workflow execution: ${error instanceof Error ? error.message : error}`, ); - return { - job: jobDetail, - targetType: targetTypeStr, - workflowExecutionId: operationReference, - }; + return withWaitMetadata( + { + job: jobDetail, + targetType: targetTypeStr, + workflowExecutionId: operationReference, + }, + false, + ); } } @@ -437,50 +567,103 @@ export async function watchExecutorJob( case ExecutorTargetType.JOB_FUNCTION: { // Wait for function execution - sp.start(`Waiting for function execution ${operationReference}...`); + sp?.start(`Waiting for function execution ${operationReference}...`); try { - // loop exits when the function execution reaches a terminal status + let functionStatus: string | undefined; // oxlint-disable-next-line typescript/no-unnecessary-condition while (true) { - const { execution } = await client.getFunctionExecution({ - workspaceId, - executionId: operationReference, - }); - - if (!execution) { - throw new Error(`Function execution '${operationReference}' not found.`); + const functionTimeout = remainingTimeout(); + if (functionTimeout !== undefined && functionTimeout <= 0) { + sp?.fail("Function execution wait timed out."); + return withWaitMetadata( + { + job: jobDetail, + targetType: targetTypeStr, + functionExecutionId: operationReference, + functionStatus, + }, + true, + ); } - if (isFunctionExecutionTerminalStatus(execution.status)) { - const statusStr = functionExecutionStatusToString(execution.status); - const coloredFnStatus = colorizeFunctionExecutionStatus(statusStr); - if (execution.status === FunctionExecution_Status.SUCCESS) { - sp.succeed(`Function execution completed: ${coloredFnStatus}`); - } else { - sp.fail(`Function execution completed: ${coloredFnStatus}`); + try { + attempts += 1; + const { execution } = await client.getFunctionExecution({ + workspaceId, + executionId: operationReference, + }); + + if (!execution) { + throw new Error(`Function execution '${operationReference}' not found.`); + } + + lastError = null; + functionStatus = functionExecutionStatusToString(execution.status); + + if (isFunctionExecutionTerminalStatus(execution.status)) { + const coloredFnStatus = colorizeFunctionExecutionStatus(functionStatus); + if (execution.status === FunctionExecution_Status.SUCCESS) { + sp?.succeed(`Function execution completed: ${coloredFnStatus}`); + } else { + sp?.fail(`Function execution completed: ${coloredFnStatus}`); + } + return withWaitMetadata( + { + job: jobDetail, + targetType: targetTypeStr, + functionExecutionId: operationReference, + functionStatus, + functionLogs: options.logs ? execution.logs || undefined : undefined, + }, + false, + ); + } + } catch (error) { + if (!isRetryableWaitError(error)) { + throw error; + } + lastError = formatWaitError(error); + if (sp) { + sp.text = `Retrying function execution poll... (${formatTime(new Date())})`; } - return { - job: jobDetail, - targetType: targetTypeStr, - functionExecutionId: operationReference, - functionStatus: statusStr, - functionLogs: options.logs ? execution.logs || undefined : undefined, - }; } - sp.text = `Waiting for function execution... (${formatTime(new Date())})`; - await setTimeout(interval); + const nextFunctionTimeout = remainingTimeout(); + if (nextFunctionTimeout !== undefined && nextFunctionTimeout <= 0) { + sp?.fail("Function execution wait timed out."); + return withWaitMetadata( + { + job: jobDetail, + targetType: targetTypeStr, + functionExecutionId: operationReference, + functionStatus, + }, + true, + ); + } + + if (sp) { + sp.text = `Waiting for function execution... (${formatTime(new Date())})`; + } + await setTimeout( + nextFunctionTimeout === undefined + ? interval + : Math.min(interval, nextFunctionTimeout), + ); } } catch (error) { - sp.warn( + sp?.warn( `Could not track function execution: ${error instanceof Error ? error.message : error}`, ); - return { - job: jobDetail, - targetType: targetTypeStr, - functionExecutionId: operationReference, - }; + return withWaitMetadata( + { + job: jobDetail, + targetType: targetTypeStr, + functionExecutionId: operationReference, + }, + false, + ); } } break; @@ -490,10 +673,31 @@ export async function watchExecutorJob( } } - return { job: jobDetail, targetType: targetTypeStr }; + return withWaitMetadata({ job: jobDetail, targetType: targetTypeStr }, false); } finally { - sp.stop(); + sp?.stop(); + } +} + +/** + * Build a user-facing failure message for an executor job wait result. + * @param result - Executor job wait result + * @returns Failure message, or undefined when the wait succeeded + */ +export function getExecutorWaitFailureMessage(result: WatchExecutorJobResult): string | undefined { + if (result.timedOut) { + return `Timed out waiting for executor job '${result.job.id}'. Last status: ${result.job.status}.`; + } + if (result.job.status === "FAILED" || result.job.status === "CANCELED") { + return `Executor job '${result.job.id}' completed with status ${result.job.status}.`; } + if (result.workflowStatus === "FAILED") { + return `Workflow execution '${result.workflowExecutionId}' failed.`; + } + if (result.functionStatus === "FAILED") { + return `Function execution '${result.functionExecutionId}' failed.`; + } + return undefined; } function printJobWithAttempts(job: ExecutorJobDetailInfo): void { @@ -577,6 +781,10 @@ export const jobsCommand = defineAppCommand({ alias: "i", description: "Polling interval when using --wait (e.g., '3s', '500ms', '1m')", }), + timeout: arg(durationArg.default("5m"), { + alias: "t", + description: "Maximum time to wait when using --wait (e.g., '30s', '5m')", + }), ...pagedLogArgs, limit: arg(nonNegativeIntArg.default(50), { description: "Maximum number of jobs to list (0: unlimited, default: 50) (list mode only)", @@ -588,6 +796,7 @@ export const jobsCommand = defineAppCommand({ }) .strict(), run: async (args) => { + const jsonOutput = logger.jsonMode || args.json; if (args.jobId) { if (args.wait) { const result = await watchExecutorJob({ @@ -596,11 +805,13 @@ export const jobsCommand = defineAppCommand({ workspaceId: args["workspace-id"], profile: args.profile, interval: parseDuration(args.interval), + timeout: parseDuration(args.timeout), logs: args.logs, + showProgress: !jsonOutput, }); // Print result - if (!args.json) { + if (!jsonOutput) { logger.log(styles.bold(`Target Type: ${result.targetType}\n`)); printJobWithAttempts(result.job); if (result.workflowExecutionId) { @@ -649,6 +860,10 @@ export const jobsCommand = defineAppCommand({ } else { logger.out(result); } + const failureMessage = getExecutorWaitFailureMessage(result); + if (failureMessage) { + throw new Error(failureMessage); + } return; } @@ -659,7 +874,7 @@ export const jobsCommand = defineAppCommand({ workspaceId: args["workspace-id"], profile: args.profile, }); - if (args.attempts && !args.json) { + if (args.attempts && !jsonOutput) { printJobWithAttempts(job); } else { logger.out(job); diff --git a/packages/sdk/src/cli/commands/executor/status.ts b/packages/sdk/src/cli/commands/executor/status.ts index ca86cbafb..ceba79d99 100644 --- a/packages/sdk/src/cli/commands/executor/status.ts +++ b/packages/sdk/src/cli/commands/executor/status.ts @@ -10,6 +10,8 @@ import { styles } from "@/cli/shared/logger"; // Executor Job Status // ============================================================================ +export type ExecutorJobStatusClass = "success" | "failure" | "transient"; + /** * Colorize executor job status string. * @param status - Executor job status string @@ -38,13 +40,58 @@ export function colorizeExecutorJobStatus(status: string): string { * @returns True if status is terminal */ export function isExecutorJobTerminalStatus(status: ExecutorJobStatus): boolean { + return isExecutorJobSuccessStatus(status) || isExecutorJobFailureStatus(status); +} + +/** + * Check if executor job status is successful. + * @param status - Executor job status enum value + * @returns True if status is success + */ +export function isExecutorJobSuccessStatus(status: ExecutorJobStatus): boolean { + return status === ExecutorJobStatus.SUCCESS; +} + +/** + * Check if executor job status is a terminal failure. + * @param status - Executor job status enum value + * @returns True if status is failure + */ +export function isExecutorJobFailureStatus(status: ExecutorJobStatus): boolean { + return status === ExecutorJobStatus.FAILED || status === ExecutorJobStatus.CANCELED; +} + +/** + * Check if executor job status can still progress. + * @param status - Executor job status enum value + * @returns True if status is transient + */ +export function isExecutorJobTransientStatus(status: ExecutorJobStatus): boolean { return ( - status === ExecutorJobStatus.SUCCESS || - status === ExecutorJobStatus.FAILED || - status === ExecutorJobStatus.CANCELED + status === ExecutorJobStatus.UNSPECIFIED || + status === ExecutorJobStatus.PENDING || + status === ExecutorJobStatus.RUNNING ); } +/** + * Classify executor job status for waiter decisions. + * @param status - Executor job status enum value + * @returns Classified executor job status + */ +export function classifyExecutorJobStatus(status: ExecutorJobStatus): ExecutorJobStatusClass { + if (isExecutorJobSuccessStatus(status)) { + return "success"; + } + if (isExecutorJobTerminalStatus(status)) { + return "failure"; + } + if (isExecutorJobTransientStatus(status)) { + return "transient"; + } + return "transient"; +} + /** * Parse executor job status string to enum. * @param status - Status string to parse diff --git a/packages/sdk/src/cli/commands/executor/trigger.test.ts b/packages/sdk/src/cli/commands/executor/trigger.test.ts index 6456ab6d5..0c5e0ff71 100644 --- a/packages/sdk/src/cli/commands/executor/trigger.test.ts +++ b/packages/sdk/src/cli/commands/executor/trigger.test.ts @@ -1,7 +1,16 @@ +import { + ExecutorJobStatus, + ExecutorTargetType, + ExecutorTriggerType, +} from "@tailor-proto/tailor/v1/executor_resource_pb"; +import { runCommand } from "politty"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { initOperatorClient } from "@/cli/shared/client"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; -import { triggerExecutor } from "./trigger"; +import { captureStderr, captureStdout } from "@/cli/shared/test-helpers/capture-output"; +import { jsonMode } from "@/cli/shared/test-helpers/json-mode"; +import { triggerCommand, triggerExecutor } from "./trigger"; +import type { ExecutorJob } from "@tailor-proto/tailor/v1/executor_resource_pb"; vi.mock("@/cli/shared/context", () => ({ loadAccessToken: vi.fn(), @@ -9,6 +18,12 @@ vi.mock("@/cli/shared/context", () => ({ })); vi.mock("@/cli/shared/client", () => ({ + fetchAll: async ( + fn: (pageToken: string, maxPageSize: number) => Promise<[T[], string]>, + ): Promise => { + const [items] = await fn("", 1000); + return items; + }, initOperatorClient: vi.fn(), })); @@ -60,4 +75,52 @@ describe("triggerExecutor runtime overload", () => { }, }); }); + + test("trigger command wait with jsonMode emits only parseable JSON to stdout", async () => { + using stdout = captureStdout(); + using _stderr = captureStderr(); + using _json = jsonMode(); + + vi.mocked(initOperatorClient).mockResolvedValue({ + getExecutorExecutor: vi.fn().mockResolvedValue({ + executor: { + triggerType: ExecutorTriggerType.INCOMING_WEBHOOK, + targetType: ExecutorTargetType.WEBHOOK, + }, + }), + triggerExecutor: triggerExecutorMock, + getExecutorJob: vi.fn().mockResolvedValue({ + job: { + id: "job-1", + executorName: "my-executor", + status: ExecutorJobStatus.SUCCESS, + } as ExecutorJob, + }), + listExecutorJobAttempts: vi.fn().mockResolvedValue({ + attempts: [], + nextPageToken: "", + }), + } as unknown as Awaited>); + + await runCommand(triggerCommand, [ + "my-executor", + "--wait", + "--timeout", + "1s", + "--interval", + "1ms", + ]); + + expect(JSON.parse(stdout.output)).toMatchObject({ + targetType: "WEBHOOK", + attempts: 1, + timedOut: false, + lastError: null, + job: { + id: "job-1", + executorName: "my-executor", + status: "SUCCESS", + }, + }); + }); }); diff --git a/packages/sdk/src/cli/commands/executor/trigger.ts b/packages/sdk/src/cli/commands/executor/trigger.ts index a5ccacc9c..e3e9f318c 100644 --- a/packages/sdk/src/cli/commands/executor/trigger.ts +++ b/packages/sdk/src/cli/commands/executor/trigger.ts @@ -8,7 +8,7 @@ import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; import { logger, styles } from "@/cli/shared/logger"; import { assertWritable } from "@/cli/shared/readonly-guard"; -import { watchExecutorJob } from "./jobs"; +import { getExecutorWaitFailureMessage, watchExecutorJob } from "./jobs"; import { executorTriggerTypeToString } from "./status"; import type { IncomingWebhookTrigger, ScheduleTriggerInput } from "@/types/executor.generated"; import type { JsonObject } from "@bufbuild/protobuf"; @@ -209,6 +209,10 @@ The \`--logs\` option displays logs from the downstream execution when available alias: "i", description: "Polling interval when using --wait (e.g., '3s', '500ms', '1m')", }), + timeout: arg(durationArg.default("5m"), { + alias: "t", + description: "Maximum time to wait when using --wait (e.g., '30s', '5m')", + }), logs: arg(z.boolean().default(false), { alias: "l", description: "Display function execution logs after completion (requires --wait)", @@ -216,6 +220,7 @@ The \`--logs\` option displays logs from the downstream execution when available }) .strict(), run: async (args) => { + const jsonOutput = logger.jsonMode || args.json; await assertWritable({ profile: args.profile }); // Validate trigger type before processing const accessToken = await loadAccessToken({ @@ -296,11 +301,13 @@ The \`--logs\` option displays logs from the downstream execution when available workspaceId: args["workspace-id"], profile: args.profile, interval: parseDuration(args.interval), + timeout: parseDuration(args.timeout), logs: args.logs, + showProgress: !jsonOutput, }); // Print result - if (!args.json) { + if (!jsonOutput) { logger.log(styles.bold(`\nTarget Type: ${watchResult.targetType}`)); logger.log(`Job Status: ${watchResult.job.status}`); @@ -350,6 +357,10 @@ The \`--logs\` option displays logs from the downstream execution when available } else { logger.out(watchResult); } + const failureMessage = getExecutorWaitFailureMessage(watchResult); + if (failureMessage) { + throw new Error(failureMessage); + } } }, }); diff --git a/packages/sdk/src/cli/commands/workflow/args.ts b/packages/sdk/src/cli/commands/workflow/args.ts index 1b459dabc..2113b0e4e 100644 --- a/packages/sdk/src/cli/commands/workflow/args.ts +++ b/packages/sdk/src/cli/commands/workflow/args.ts @@ -1,9 +1,16 @@ import { arg } from "politty"; import { z } from "zod"; import { durationArg } from "@/cli/shared/args"; +import type { WorkflowWaitUntil } from "./status"; type ArgsShape = Record; +export const workflowWaitUntilArg = z.enum([ + "success", + "suspended", + "terminal", +]) satisfies z.ZodType; + export const nameArgs = { name: arg(z.string(), { positional: true, @@ -11,17 +18,29 @@ export const nameArgs = { }), } satisfies ArgsShape; -export const waitArgs = { - wait: arg(z.boolean().default(false), { - alias: "W", - description: "Wait for execution to complete", - }), +export const workflowWaitControlArgs = { interval: arg(durationArg.default("3s"), { alias: "i", - description: "Polling interval when using --wait (e.g., '3s', '500ms', '1m')", + description: "Polling interval when waiting (e.g., '3s', '500ms', '1m')", + }), + timeout: arg(durationArg.default("10m"), { + alias: "t", + description: "Maximum time to wait (e.g., '30s', '10m')", + }), + until: arg(workflowWaitUntilArg.default("terminal"), { + alias: "u", + description: "Wait target (success, suspended, terminal)", }), logs: arg(z.boolean().default(false), { alias: "l", - description: "Display job execution logs after completion (requires --wait)", + description: "Display job execution logs after completion", + }), +} satisfies ArgsShape; + +export const waitArgs = { + wait: arg(z.boolean().default(false), { + alias: "W", + description: "Wait for execution to complete", }), + ...workflowWaitControlArgs, } satisfies ArgsShape; diff --git a/packages/sdk/src/cli/commands/workflow/executions.test.ts b/packages/sdk/src/cli/commands/workflow/executions.test.ts new file mode 100644 index 000000000..d3c6f65b7 --- /dev/null +++ b/packages/sdk/src/cli/commands/workflow/executions.test.ts @@ -0,0 +1,59 @@ +import { WorkflowExecution_Status } from "@tailor-proto/tailor/v1/workflow_resource_pb"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { initOperatorClient } from "@/cli/shared/client"; +import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; +import { getWorkflowExecution } from "./executions"; +import type { WorkflowExecution } from "@tailor-proto/tailor/v1/workflow_resource_pb"; + +vi.mock("@/cli/shared/context", () => ({ + loadAccessToken: vi.fn(), + loadWorkspaceId: vi.fn(), +})); + +vi.mock("@/cli/shared/client", () => ({ + initOperatorClient: vi.fn(), +})); + +function execution(status: WorkflowExecution_Status): WorkflowExecution { + return { + id: "execution-1", + workflowName: "my-workflow", + status, + jobExecutions: [], + } as unknown as WorkflowExecution; +} + +describe("getWorkflowExecution", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(loadAccessToken).mockResolvedValue("mock-token"); + vi.mocked(loadWorkspaceId).mockResolvedValue("workspace-1"); + }); + + test("returns wait diagnostics when waiting times out", async () => { + vi.mocked(initOperatorClient).mockResolvedValue({ + getWorkflowExecution: vi.fn().mockResolvedValue({ + execution: execution(WorkflowExecution_Status.PENDING), + }), + } as unknown as Awaited>); + + const { wait } = await getWorkflowExecution({ + executionId: "execution-1", + interval: 1, + timeout: 5, + until: "success", + }); + + const result = await wait(); + + expect(result).toMatchObject({ + id: "execution-1", + status: "PENDING", + statusClass: "transient", + timedOut: true, + lastError: null, + }); + expect(result.attempts).toBeGreaterThan(0); + }); +}); diff --git a/packages/sdk/src/cli/commands/workflow/executions.ts b/packages/sdk/src/cli/commands/workflow/executions.ts index 203fb4963..4286c85bb 100644 --- a/packages/sdk/src/cli/commands/workflow/executions.ts +++ b/packages/sdk/src/cli/commands/workflow/executions.ts @@ -19,15 +19,20 @@ import { defineAppCommand } from "@/cli/shared/command"; import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; import { formatKeyValueTable } from "@/cli/shared/format"; import { styles, logger } from "@/cli/shared/logger"; -import { spinner } from "@/cli/shared/spinner"; import { waitArgs } from "./args"; -import { isWorkflowExecutionTerminalStatus } from "./status"; +import { type WorkflowWaitUntil } from "./status"; import { type WorkflowExecutionInfo, type WorkflowJobExecutionInfo, toWorkflowExecutionInfo, toWorkflowJobExecutionInfo, } from "./transform"; +import { + getWorkflowWaitFailureMessage, + waitForWorkflowExecution, + waitForWorkflowExecutionById, + type WorkflowWaitResult, +} from "./waiter"; import type { FunctionExecution } from "@tailor-proto/tailor/v1/function_resource_pb"; type WorkflowLike = { @@ -60,6 +65,8 @@ export interface GetWorkflowExecutionOptions { workspaceId?: string; profile?: string; interval?: number; + timeout?: number; + until?: WorkflowWaitUntil; logs?: boolean; } @@ -70,35 +77,17 @@ export interface WorkflowExecutionDetailInfo extends WorkflowExecutionInfo { })[]; } -export interface GetWorkflowExecutionResult { - execution: WorkflowExecutionDetailInfo; - wait: () => Promise; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +export interface WorkflowExecutionWaitInfo extends WorkflowExecutionDetailInfo { + statusClass: WorkflowWaitResult["statusClass"]; + elapsedMs: number; + attempts: number; + timedOut: boolean; + lastError: string | null; } -function formatTime(date: Date): string { - return date.toLocaleTimeString("en-US", { hour12: false }); -} - -function colorizeStatus(status: WorkflowExecution_Status): string { - const statusText = WorkflowExecution_Status[status]; - switch (status) { - case WorkflowExecution_Status.PENDING: - return styles.dim(statusText); - case WorkflowExecution_Status.PENDING_RESUME: - return styles.warning(statusText); - case WorkflowExecution_Status.RUNNING: - return styles.info(statusText); - case WorkflowExecution_Status.SUCCESS: - return styles.success(statusText); - case WorkflowExecution_Status.FAILED: - return styles.error(statusText); - default: - return statusText; - } +export interface GetWorkflowExecutionResult { + execution: WorkflowExecutionDetailInfo; + wait: () => Promise; } function parseStatus(status: string): WorkflowExecution_Status { @@ -114,9 +103,15 @@ function parseStatus(status: string): WorkflowExecution_Status { return WorkflowExecution_Status.SUCCESS; case "FAILED": return WorkflowExecution_Status.FAILED; + case "PENDING_RETRY": + return WorkflowExecution_Status.PENDING_RETRY; + case "WAITING": + return WorkflowExecution_Status.WAITING; + case "UNSPECIFIED": + return WorkflowExecution_Status.UNSPECIFIED; default: throw new Error( - `Invalid status: ${status}. Valid values: PENDING, PENDING_RESUME, RUNNING, SUCCESS, FAILED`, + `Invalid status: ${status}. Valid values: UNSPECIFIED, PENDING, PENDING_RESUME, RUNNING, SUCCESS, FAILED, PENDING_RETRY, WAITING`, ); } } @@ -278,28 +273,25 @@ export async function getWorkflowExecution( return result; } - async function waitForCompletion(): Promise { + async function waitForCompletion(): Promise { const interval = options.interval ?? 3000; - - // loop exits when the workflow execution reaches a terminal status - // oxlint-disable-next-line typescript/no-unnecessary-condition - while (true) { - const { execution } = await client.getWorkflowExecution({ - workspaceId, - executionId: options.executionId, - }); - - if (!execution) { - throw new Error(`Execution '${options.executionId}' not found.`); - } - - // Terminal states (SUCCESS, FAILED, PENDING_RESUME) - if (isWorkflowExecutionTerminalStatus(execution.status)) { - return await fetchExecutionWithLogs(options.executionId, options.logs ?? false); - } - - await sleep(interval); - } + const waitResult = await waitForWorkflowExecution({ + client, + workspaceId, + executionId: options.executionId, + interval, + timeout: options.timeout, + until: options.until ?? "terminal", + }); + const execution = await fetchExecutionWithLogs(options.executionId, options.logs ?? false); + return { + ...execution, + statusClass: waitResult.statusClass, + elapsedMs: waitResult.elapsedMs, + attempts: waitResult.attempts, + timedOut: waitResult.timedOut, + lastError: waitResult.lastError, + }; } const execution = await fetchExecutionWithLogs(options.executionId, options.logs ?? false); @@ -310,37 +302,6 @@ export async function getWorkflowExecution( }; } -async function waitWithSpinner( - waitFn: () => Promise, - interval: number, - json: boolean, -): Promise { - const sp = !json ? spinner().start("Waiting for workflow to complete...") : null; - - const updateInterval = setInterval(() => { - if (sp) { - const now = formatTime(new Date()); - sp.text = `Waiting for workflow to complete... (${now})`; - } - }, interval); - - try { - const result = await waitFn(); - const coloredStatus = colorizeStatus( - WorkflowExecution_Status[result.status as keyof typeof WorkflowExecution_Status], - ); - if (result.status === "SUCCESS") { - sp?.succeed(`Completed: ${coloredStatus}`); - } else { - sp?.fail(`Completed: ${coloredStatus}`); - } - return result; - } finally { - clearInterval(updateInterval); - sp?.stop(); - } -} - /** * Print a workflow execution and its logs in a human-readable format. * @param execution - Workflow execution detail info @@ -425,9 +386,58 @@ export const executionsCommand = defineAppCommand({ }) .strict(), run: async (args) => { + const jsonOutput = logger.jsonMode || args.json; if (args.executionId) { const interval = parseDuration(args.interval); - const { execution, wait } = await getWorkflowExecution({ + + if (!jsonOutput) { + logger.info(`Execution ID: ${args.executionId}`, { mode: "stream" }); + } + + if (args.wait) { + const result = await waitForWorkflowExecutionById({ + executionId: args.executionId, + workspaceId: args["workspace-id"], + profile: args.profile, + interval, + timeout: parseDuration(args.timeout), + until: args.until, + showProgress: !jsonOutput, + trackJobs: true, + }); + + if (args.logs && !jsonOutput) { + const { execution } = await getWorkflowExecution({ + executionId: args.executionId, + workspaceId: args["workspace-id"], + profile: args.profile, + logs: true, + }); + printExecutionWithLogs(execution); + } else if (args.logs) { + const { execution } = await getWorkflowExecution({ + executionId: args.executionId, + workspaceId: args["workspace-id"], + profile: args.profile, + logs: true, + }); + const output: WorkflowWaitResult & Pick = { + ...result, + jobDetails: execution.jobDetails, + }; + logger.out(output); + } else { + logger.out(result); + } + + const failureMessage = getWorkflowWaitFailureMessage(result, args.until); + if (failureMessage) { + throw new Error(failureMessage); + } + return; + } + + const { execution } = await getWorkflowExecution({ executionId: args.executionId, workspaceId: args["workspace-id"], profile: args.profile, @@ -435,16 +445,10 @@ export const executionsCommand = defineAppCommand({ logs: args.logs, }); - if (!args.json) { - logger.info(`Execution ID: ${execution.id}`, { mode: "stream" }); - } - - const result = args.wait ? await waitWithSpinner(wait, interval, args.json) : execution; - - if (args.logs && !args.json) { - printExecutionWithLogs(result); + if (args.logs && !jsonOutput) { + printExecutionWithLogs(execution); } else { - logger.out(result); + logger.out(execution); } } else { const executions = await listWorkflowExecutions({ diff --git a/packages/sdk/src/cli/commands/workflow/index.ts b/packages/sdk/src/cli/commands/workflow/index.ts index 0c1766d0d..c1a6f3244 100644 --- a/packages/sdk/src/cli/commands/workflow/index.ts +++ b/packages/sdk/src/cli/commands/workflow/index.ts @@ -4,6 +4,7 @@ import { getCommand } from "./get"; import { listCommand } from "./list"; import { resumeCommand } from "./resume"; import { startCommand } from "./start"; +import { waitCommand } from "./wait"; export const workflowCommand = defineCommand({ name: "workflow", @@ -12,6 +13,7 @@ export const workflowCommand = defineCommand({ list: listCommand, get: getCommand, start: startCommand, + wait: waitCommand, executions: executionsCommand, resume: resumeCommand, }, diff --git a/packages/sdk/src/cli/commands/workflow/resume.ts b/packages/sdk/src/cli/commands/workflow/resume.ts index 64d417894..0348cbf60 100644 --- a/packages/sdk/src/cli/commands/workflow/resume.ts +++ b/packages/sdk/src/cli/commands/workflow/resume.ts @@ -9,7 +9,7 @@ import { logger } from "@/cli/shared/logger"; import { waitArgs } from "./args"; import { getWorkflowExecution, printExecutionWithLogs } from "./executions"; import { waitForExecution, type WaitOptions } from "./start"; -import { type WorkflowExecutionInfo } from "./transform"; +import { getWorkflowWaitFailureMessage, type WorkflowWaitResult } from "./waiter"; export interface ResumeWorkflowOptions { executionId: string; @@ -20,7 +20,7 @@ export interface ResumeWorkflowOptions { export interface ResumeWorkflowResultWithWait { executionId: string; - wait: (options?: WaitOptions) => Promise; + wait: (options?: WaitOptions) => Promise; } /** @@ -54,6 +54,8 @@ export async function resumeWorkflow( workspaceId, executionId, interval: options.interval ?? 3000, + timeout: waitOptions?.timeout, + until: waitOptions?.until, showProgress: waitOptions?.showProgress, }), }; @@ -86,6 +88,7 @@ export const resumeCommand = defineAppCommand({ }) .strict(), run: async (args) => { + const jsonOutput = logger.jsonMode || args.json; const { executionId, wait } = await resumeWorkflow({ executionId: args.executionId, workspaceId: args["workspace-id"], @@ -93,13 +96,17 @@ export const resumeCommand = defineAppCommand({ interval: parseDuration(args.interval), }); - if (!args.json) { + if (!jsonOutput) { logger.info(`Execution ID: ${executionId}`, { mode: "stream" }); } if (args.wait) { - const result = await wait({ showProgress: !args.json }); - if (args.logs && !args.json) { + const result = await wait({ + showProgress: !jsonOutput, + timeout: parseDuration(args.timeout), + until: args.until, + }); + if (args.logs && !jsonOutput) { const { execution } = await getWorkflowExecution({ executionId, workspaceId: args["workspace-id"], @@ -107,9 +114,21 @@ export const resumeCommand = defineAppCommand({ logs: true, }); printExecutionWithLogs(execution); + } else if (args.logs) { + const { execution } = await getWorkflowExecution({ + executionId, + workspaceId: args["workspace-id"], + profile: args.profile, + logs: true, + }); + logger.out({ ...result, jobDetails: execution.jobDetails }); } else { logger.out(result); } + const failureMessage = getWorkflowWaitFailureMessage(result, args.until); + if (failureMessage) { + throw new Error(failureMessage); + } } else { logger.out({ executionId }); } diff --git a/packages/sdk/src/cli/commands/workflow/start.test.ts b/packages/sdk/src/cli/commands/workflow/start.test.ts index 8df467489..972b35ac7 100644 --- a/packages/sdk/src/cli/commands/workflow/start.test.ts +++ b/packages/sdk/src/cli/commands/workflow/start.test.ts @@ -1,3 +1,4 @@ +import { WorkflowExecution_Status } from "@tailor-proto/tailor/v1/workflow_resource_pb"; import { runCommand } from "politty"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { initOperatorClient } from "@/cli/shared/client"; @@ -7,6 +8,7 @@ import { captureStderr, captureStdout } from "@/cli/shared/test-helpers/capture- import { jsonMode } from "@/cli/shared/test-helpers/json-mode"; import { resolveWorkflow } from "./get"; import { startCommand, startWorkflow } from "./start"; +import type { WorkflowExecution } from "@tailor-proto/tailor/v1/workflow_resource_pb"; vi.mock("@/cli/shared/context", () => ({ loadAccessToken: vi.fn(), @@ -28,6 +30,7 @@ vi.mock("./get", () => ({ describe("startWorkflow runtime overload", () => { let getApplicationMock: ReturnType; + let getWorkflowExecutionMock: ReturnType; let testStartWorkflowMock: ReturnType; beforeEach(() => { @@ -50,9 +53,18 @@ describe("startWorkflow runtime overload", () => { testStartWorkflowMock = vi.fn().mockResolvedValue({ executionId: "execution-1", }); + getWorkflowExecutionMock = vi.fn().mockResolvedValue({ + execution: { + id: "execution-1", + workflowName: "legacy-workflow", + status: WorkflowExecution_Status.SUCCESS, + jobExecutions: [], + } as unknown as WorkflowExecution, + }); vi.mocked(initOperatorClient).mockResolvedValue({ getApplication: getApplicationMock, + getWorkflowExecution: getWorkflowExecutionMock, testStartWorkflow: testStartWorkflowMock, } as unknown as Awaited>); @@ -105,6 +117,35 @@ describe("startWorkflow runtime overload", () => { expect(stderr.output).not.toBe(""); }); + test("start command wait with jsonMode emits only parseable JSON to stdout", async () => { + using stdout = captureStdout(); + using _stderr = captureStderr(); + using _json = jsonMode(); + + await runCommand(startCommand, [ + "legacy-workflow", + "--machine-user", + "legacy-user", + "--wait", + "--until", + "success", + "--timeout", + "1s", + "--interval", + "1ms", + ]); + + expect(JSON.parse(stdout.output)).toMatchObject({ + id: "execution-1", + workflowName: "legacy-workflow", + status: "SUCCESS", + statusClass: "success", + attempts: 1, + timedOut: false, + lastError: null, + }); + }); + test("uses machine user from profile default when --machine-user flag is absent", async () => { vi.mocked(loadMachineUserName).mockResolvedValue("profile-bot"); diff --git a/packages/sdk/src/cli/commands/workflow/start.ts b/packages/sdk/src/cli/commands/workflow/start.ts index df4f2f6d6..0f835a449 100644 --- a/packages/sdk/src/cli/commands/workflow/start.ts +++ b/packages/sdk/src/cli/commands/workflow/start.ts @@ -1,10 +1,6 @@ import { create } from "@bufbuild/protobuf"; import { Code, ConnectError } from "@connectrpc/connect"; import { AuthInvokerSchema } from "@tailor-proto/tailor/v1/auth_resource_pb"; -import { - WorkflowExecution_Status, - WorkflowJobExecution_Status, -} from "@tailor-proto/tailor/v1/workflow_resource_pb"; import { arg } from "politty"; import { z } from "zod"; import { deploymentArgs, parseDuration } from "@/cli/shared/args"; @@ -12,13 +8,16 @@ import { initOperatorClient } from "@/cli/shared/client"; import { defineAppCommand } from "@/cli/shared/command"; import { loadConfig } from "@/cli/shared/config-loader"; import { loadAccessToken, loadMachineUserName, loadWorkspaceId } from "@/cli/shared/context"; -import { logger, styles } from "@/cli/shared/logger"; -import { spinner } from "@/cli/shared/spinner"; +import { logger } from "@/cli/shared/logger"; import { nameArgs, waitArgs } from "./args"; import { getWorkflowExecution, printExecutionWithLogs } from "./executions"; import { resolveWorkflow } from "./get"; -import { type WorkflowExecutionInfo, toWorkflowExecutionInfo } from "./transform"; -import type { WorkflowExecution } from "@tailor-proto/tailor/v1/workflow_resource_pb"; +import { type WorkflowWaitUntil } from "./status"; +import { + getWorkflowWaitFailureMessage, + waitForWorkflowExecution, + type WorkflowWaitResult, +} from "./waiter"; import type { Jsonifiable } from "type-fest"; type WorkflowLike = { @@ -73,149 +72,17 @@ type StartWorkflowTypedBaseOptions = { export type StartWorkflowTypedOptions = W extends WorkflowLike ? StartWorkflowTypedBaseOptions & StartWorkflowArgOption : never; -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function formatTime(date: Date): string { - return date.toLocaleTimeString("en-US", { hour12: false }); -} - -function colorizeStatus(status: WorkflowExecution_Status): string { - const statusText = WorkflowExecution_Status[status]; - switch (status) { - case WorkflowExecution_Status.PENDING: - return styles.dim(statusText); - case WorkflowExecution_Status.PENDING_RESUME: - return styles.warning(statusText); - case WorkflowExecution_Status.RUNNING: - return styles.info(statusText); - case WorkflowExecution_Status.SUCCESS: - return styles.success(statusText); - case WorkflowExecution_Status.FAILED: - return styles.error(statusText); - default: - return statusText; - } -} - -export interface WaitForExecutionOptions { - client: Awaited>; - workspaceId: string; - executionId: string; - interval: number; - showProgress?: boolean; - trackJobs?: boolean; -} - -/** - * Wait for a workflow execution to reach a terminal state, optionally showing progress. - * @param options - Wait options - * @returns Final workflow execution info - */ -export async function waitForExecution( - options: WaitForExecutionOptions, -): Promise { - const { client, workspaceId, executionId, interval, showProgress, trackJobs } = options; - - let lastStatus: WorkflowExecution_Status | undefined; - let lastRunningJobs: string | undefined; - const sp = showProgress - ? spinner({ indent: 2 }).start("Waiting for workflow to complete...") - : null; - - try { - // loop exits when the workflow execution reaches a terminal status - // oxlint-disable-next-line typescript/no-unnecessary-condition - while (true) { - const { execution } = await client.getWorkflowExecution({ - workspaceId, - executionId, - }); - - if (!execution) { - sp?.fail(`Execution '${executionId}' not found.`); - throw new Error(`Execution '${executionId}' not found.`); - } - - const now = formatTime(new Date()); - const coloredStatus = colorizeStatus(execution.status); - - // Show workflow status change (persist previous line) - if (execution.status !== lastStatus) { - if (showProgress) { - sp?.stop(); - logger.info(`Status: ${coloredStatus}`, { - mode: "stream", - indent: 2, - }); - sp?.start(`Waiting for workflow to complete...`); - } - lastStatus = execution.status; - } - - // Show job execution details when running (optional) - if (trackJobs && execution.status === WorkflowExecution_Status.RUNNING) { - const runningJobs = getRunningJobs(execution); - if (runningJobs && runningJobs !== lastRunningJobs) { - if (showProgress) { - sp?.stop(); - logger.info(`Job | ${runningJobs}: ${coloredStatus}`, { - mode: "stream", - indent: 2, - }); - sp?.start(`Waiting for workflow to complete...`); - } - lastRunningJobs = runningJobs; - } - } - - if (sp) { - sp.text = `Waiting for workflow to complete... (${now})`; - } - - // Terminal states: SUCCESS, FAILED, or PENDING_RESUME - if (isTerminalStatus(execution.status)) { - if (execution.status === WorkflowExecution_Status.SUCCESS) { - sp?.succeed(`Completed: ${coloredStatus}`); - } else if (execution.status === WorkflowExecution_Status.FAILED) { - sp?.fail(`Completed: ${coloredStatus}`); - } else { - sp?.warn(`Completed: ${coloredStatus}`); - } - return toWorkflowExecutionInfo(execution); - } - - await sleep(interval); - } - } catch (error) { - sp?.stop(); - throw error; - } -} - -function getRunningJobs(execution: WorkflowExecution): string { - return execution.jobExecutions - .filter((job) => job.status === WorkflowJobExecution_Status.RUNNING) - .map((job) => job.stackedJobName) - .join(", "); -} - -function isTerminalStatus(status: WorkflowExecution_Status): boolean { - return ( - status === WorkflowExecution_Status.SUCCESS || - status === WorkflowExecution_Status.FAILED || - status === WorkflowExecution_Status.PENDING_RESUME - ); -} +export { waitForWorkflowExecution as waitForExecution }; export interface WaitOptions { showProgress?: boolean; + timeout?: number; + until?: WorkflowWaitUntil; } export interface StartWorkflowResultWithWait { executionId: string; - wait: (options?: WaitOptions) => Promise; + wait: (options?: WaitOptions) => Promise; } interface StartWorkflowCoreOptions { @@ -252,11 +119,13 @@ async function startWorkflowCore( return { executionId, wait: (waitOptions?: WaitOptions) => - waitForExecution({ + waitForWorkflowExecution({ client, workspaceId, executionId, interval: options.interval ?? 3000, + timeout: waitOptions?.timeout, + until: waitOptions?.until, showProgress: waitOptions?.showProgress, trackJobs: true, }), @@ -386,7 +255,11 @@ export const startCommand = defineAppCommand({ logger.info(`Execution ID: ${executionId}`, { mode: "stream" }); if (args.wait) { - const result = await wait({ showProgress: !jsonOutput }); + const result = await wait({ + showProgress: !jsonOutput, + timeout: parseDuration(args.timeout), + until: args.until, + }); if (args.logs && !jsonOutput) { const { execution } = await getWorkflowExecution({ executionId, @@ -395,9 +268,21 @@ export const startCommand = defineAppCommand({ logs: true, }); printExecutionWithLogs(execution); + } else if (args.logs) { + const { execution } = await getWorkflowExecution({ + executionId, + workspaceId: args["workspace-id"], + profile: args.profile, + logs: true, + }); + logger.out({ ...result, jobDetails: execution.jobDetails }); } else { logger.out(result); } + const failureMessage = getWorkflowWaitFailureMessage(result, args.until); + if (failureMessage) { + throw new Error(failureMessage); + } } else { logger.out({ executionId }); } diff --git a/packages/sdk/src/cli/commands/workflow/status.ts b/packages/sdk/src/cli/commands/workflow/status.ts index 34dbc09df..d45d0e285 100644 --- a/packages/sdk/src/cli/commands/workflow/status.ts +++ b/packages/sdk/src/cli/commands/workflow/status.ts @@ -1,4 +1,74 @@ -import { WorkflowExecution_Status } from "@tailor-proto/tailor/v1/workflow_resource_pb"; +import { + WorkflowExecution_Status, + WorkflowJobExecution_Status, +} from "@tailor-proto/tailor/v1/workflow_resource_pb"; +import type { WorkflowExecution } from "@tailor-proto/tailor/v1/workflow_resource_pb"; + +export type WorkflowWaitUntil = "success" | "suspended" | "terminal"; + +export type WorkflowExecutionStatusClass = "success" | "suspended" | "failure" | "transient"; + +export interface WorkflowExecutionStatusClassification { + statusClass: WorkflowExecutionStatusClass; + status: WorkflowExecution_Status; +} + +/** + * Check if workflow execution status is successful. + * @param status - Workflow execution status enum value + * @returns True if status is success + */ +export function isWorkflowExecutionSuccessStatus(status: WorkflowExecution_Status): boolean { + return status === WorkflowExecution_Status.SUCCESS; +} + +/** + * Check if workflow job execution status is suspended or waiting. + * @param status - Workflow job execution status enum value + * @returns True if status represents a wait point + */ +export function isWorkflowJobExecutionSuspendedStatus( + status: WorkflowJobExecution_Status, +): boolean { + return ( + status === WorkflowJobExecution_Status.SUSPEND || status === WorkflowJobExecution_Status.WAITING + ); +} + +/** + * Check if workflow execution status is suspended or waiting. + * @param status - Workflow execution status enum value + * @returns True if status represents a suspended execution + */ +export function isWorkflowExecutionSuspendedStatus(status: WorkflowExecution_Status): boolean { + return ( + status === WorkflowExecution_Status.PENDING_RESUME || + status === WorkflowExecution_Status.WAITING + ); +} + +/** + * Check if workflow execution status is a terminal failure. + * @param status - Workflow execution status enum value + * @returns True if status represents failure + */ +export function isWorkflowExecutionFailureStatus(status: WorkflowExecution_Status): boolean { + return status === WorkflowExecution_Status.FAILED; +} + +/** + * Check if workflow execution status can still progress without user action. + * @param status - Workflow execution status enum value + * @returns True if status is transient + */ +export function isWorkflowExecutionTransientStatus(status: WorkflowExecution_Status): boolean { + return ( + status === WorkflowExecution_Status.UNSPECIFIED || + status === WorkflowExecution_Status.PENDING || + status === WorkflowExecution_Status.RUNNING || + status === WorkflowExecution_Status.PENDING_RETRY + ); +} /** * Check if workflow execution status is terminal. @@ -7,8 +77,58 @@ import { WorkflowExecution_Status } from "@tailor-proto/tailor/v1/workflow_resou */ export function isWorkflowExecutionTerminalStatus(status: WorkflowExecution_Status): boolean { return ( - status === WorkflowExecution_Status.SUCCESS || - status === WorkflowExecution_Status.FAILED || - status === WorkflowExecution_Status.PENDING_RESUME + isWorkflowExecutionSuccessStatus(status) || + isWorkflowExecutionFailureStatus(status) || + isWorkflowExecutionSuspendedStatus(status) ); } + +/** + * Classify workflow execution status for waiter decisions. + * @param execution - Workflow execution to classify + * @returns Classified workflow execution status + */ +export function classifyWorkflowExecutionStatus( + execution: WorkflowExecution, +): WorkflowExecutionStatusClassification { + if (isWorkflowExecutionTerminalStatus(execution.status)) { + if (isWorkflowExecutionSuccessStatus(execution.status)) { + return { statusClass: "success", status: execution.status }; + } + if (isWorkflowExecutionFailureStatus(execution.status)) { + return { statusClass: "failure", status: execution.status }; + } + return { statusClass: "suspended", status: execution.status }; + } + if (execution.jobExecutions.some((job) => isWorkflowJobExecutionSuspendedStatus(job.status))) { + return { statusClass: "suspended", status: execution.status }; + } + if (isWorkflowExecutionTransientStatus(execution.status)) { + return { statusClass: "transient", status: execution.status }; + } + return { statusClass: "transient", status: execution.status }; +} + +/** + * Check if a classified workflow execution has reached the requested waiter target. + * @param classification - Workflow execution status classification + * @param until - Requested wait target + * @returns True if the wait target is reached + */ +export function hasReachedWorkflowWaitTarget( + classification: WorkflowExecutionStatusClassification, + until: WorkflowWaitUntil, +): boolean { + switch (until) { + case "success": + return classification.statusClass === "success"; + case "suspended": + return classification.statusClass === "suspended"; + case "terminal": + return ( + classification.statusClass === "success" || + classification.statusClass === "failure" || + classification.statusClass === "suspended" + ); + } +} diff --git a/packages/sdk/src/cli/commands/workflow/transform.ts b/packages/sdk/src/cli/commands/workflow/transform.ts index 408ab72cb..01a7c0d7a 100644 --- a/packages/sdk/src/cli/commands/workflow/transform.ts +++ b/packages/sdk/src/cli/commands/workflow/transform.ts @@ -60,6 +60,10 @@ function workflowExecutionStatusToString(status: WorkflowExecution_Status): stri return "SUCCESS"; case WorkflowExecution_Status.FAILED: return "FAILED"; + case WorkflowExecution_Status.PENDING_RETRY: + return "PENDING_RETRY"; + case WorkflowExecution_Status.WAITING: + return "WAITING"; default: return "UNSPECIFIED"; } @@ -80,6 +84,8 @@ function workflowJobExecutionStatusToString(status: WorkflowJobExecution_Status) return "SUCCESS"; case WorkflowJobExecution_Status.FAILED: return "FAILED"; + case WorkflowJobExecution_Status.WAITING: + return "WAITING"; default: return "UNSPECIFIED"; } diff --git a/packages/sdk/src/cli/commands/workflow/wait.test.ts b/packages/sdk/src/cli/commands/workflow/wait.test.ts new file mode 100644 index 000000000..8e447f033 --- /dev/null +++ b/packages/sdk/src/cli/commands/workflow/wait.test.ts @@ -0,0 +1,66 @@ +import { WorkflowExecution_Status } from "@tailor-proto/tailor/v1/workflow_resource_pb"; +import { runCommand } from "politty"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { initOperatorClient } from "@/cli/shared/client"; +import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; +import { captureStderr, captureStdout } from "@/cli/shared/test-helpers/capture-output"; +import { jsonMode } from "@/cli/shared/test-helpers/json-mode"; +import { waitCommand } from "./wait"; +import type { WorkflowExecution } from "@tailor-proto/tailor/v1/workflow_resource_pb"; + +vi.mock("@/cli/shared/context", () => ({ + loadAccessToken: vi.fn(), + loadWorkspaceId: vi.fn(), +})); + +vi.mock("@/cli/shared/client", () => ({ + initOperatorClient: vi.fn(), +})); + +function execution(status: WorkflowExecution_Status): WorkflowExecution { + return { + id: "execution-1", + workflowName: "my-workflow", + status, + jobExecutions: [], + } as unknown as WorkflowExecution; +} + +describe("workflow wait command", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(loadAccessToken).mockResolvedValue("mock-token"); + vi.mocked(loadWorkspaceId).mockResolvedValue("workspace-1"); + vi.mocked(initOperatorClient).mockResolvedValue({ + getWorkflowExecution: vi.fn().mockResolvedValue({ + execution: execution(WorkflowExecution_Status.SUCCESS), + }), + } as unknown as Awaited>); + }); + + test("emits one parseable JSON object in json mode", async () => { + using stdout = captureStdout(); + using _stderr = captureStderr(); + using _json = jsonMode(); + + await runCommand(waitCommand, [ + "execution-1", + "--until", + "success", + "--timeout", + "1s", + "--interval", + "1ms", + ]); + + expect(JSON.parse(stdout.output)).toMatchObject({ + id: "execution-1", + status: "SUCCESS", + statusClass: "success", + attempts: 1, + timedOut: false, + lastError: null, + }); + }); +}); diff --git a/packages/sdk/src/cli/commands/workflow/wait.ts b/packages/sdk/src/cli/commands/workflow/wait.ts new file mode 100644 index 000000000..50bd3f107 --- /dev/null +++ b/packages/sdk/src/cli/commands/workflow/wait.ts @@ -0,0 +1,150 @@ +import { arg } from "politty"; +import { z } from "zod"; +import { parseDuration, workspaceArgs } from "@/cli/shared/args"; +import { defineAppCommand } from "@/cli/shared/command"; +import { logger } from "@/cli/shared/logger"; +import { workflowWaitControlArgs } from "./args"; +import { getWorkflowExecution, printExecutionWithLogs } from "./executions"; +import { type WorkflowWaitUntil } from "./status"; +import { + getWorkflowWaitFailureMessage, + waitForWorkflowExecutionById, + type WaitWorkflowExecutionOptions, + type WorkflowWaitResult, +} from "./waiter"; + +export interface WorkflowWaitOutput extends WorkflowWaitResult { + jobDetails?: Awaited>["execution"]["jobDetails"]; +} + +/** + * Wait for an existing workflow execution by ID. + * @param options - Workflow wait options + * @returns Workflow wait result + */ +export async function waitWorkflowExecution( + options: WaitWorkflowExecutionOptions, +): Promise { + return await waitForWorkflowExecutionById({ + ...options, + showProgress: options.showProgress ?? !logger.jsonMode, + trackJobs: options.trackJobs ?? true, + }); +} + +/** + * Attach workflow job logs to a wait result when requested. + * @param result - Workflow wait result + * @param options - Workflow wait options + * @returns Workflow wait result with optional job details + */ +export async function addWorkflowLogsToWaitResult( + result: WorkflowWaitResult, + options: WaitWorkflowExecutionOptions, +): Promise { + const { execution } = await getWorkflowExecution({ + executionId: options.executionId, + workspaceId: options.workspaceId, + profile: options.profile, + logs: true, + }); + + return { + ...result, + jobDetails: execution.jobDetails, + }; +} + +/** + * Print or emit a workflow wait result and fail the command if needed. + * @param result - Workflow wait result + * @param output - Output object + * @param until - Requested wait target + */ +export function emitWorkflowWaitResult( + result: WorkflowWaitResult, + output: WorkflowWaitOutput, + until: WorkflowWaitUntil, +): void { + logger.out(output); + const failureMessage = getWorkflowWaitFailureMessage(result, until); + if (failureMessage) { + throw new Error(failureMessage); + } +} + +export const waitCommand = defineAppCommand({ + name: "wait", + description: "Wait for a workflow execution.", + examples: [ + { + cmd: "execution-id --until success --timeout 10m --json", + desc: "Wait for workflow success", + }, + { + cmd: "execution-id --until suspended --timeout 6m --logs --json", + desc: "Wait for a workflow wait point", + }, + { + cmd: "execution-id --until terminal", + desc: "Wait for success, failure, or suspension", + }, + ], + args: z + .object({ + ...workspaceArgs, + "execution-id": arg(z.string(), { + positional: true, + description: "Execution ID", + }), + ...workflowWaitControlArgs, + }) + .strict(), + run: async (args) => { + const jsonOutput = logger.jsonMode || args.json; + const result = await waitWorkflowExecution({ + executionId: args.executionId, + workspaceId: args["workspace-id"], + profile: args.profile, + interval: parseDuration(args.interval), + timeout: parseDuration(args.timeout), + until: args.until, + showProgress: !jsonOutput, + }); + + if (args.logs && !jsonOutput) { + const output = await addWorkflowLogsToWaitResult(result, { + executionId: args.executionId, + workspaceId: args["workspace-id"], + profile: args.profile, + }); + if (output.jobDetails) { + printExecutionWithLogs({ + id: output.id, + workflowName: output.workflowName, + status: output.status, + jobExecutions: output.jobExecutions, + startedAt: output.startedAt, + finishedAt: output.finishedAt, + jobDetails: output.jobDetails, + }); + } else { + logger.out(output); + } + const failureMessage = getWorkflowWaitFailureMessage(result, args.until); + if (failureMessage) { + throw new Error(failureMessage); + } + return; + } + + const output = args.logs + ? await addWorkflowLogsToWaitResult(result, { + executionId: args.executionId, + workspaceId: args["workspace-id"], + profile: args.profile, + }) + : result; + emitWorkflowWaitResult(result, output, args.until); + }, +}); diff --git a/packages/sdk/src/cli/commands/workflow/waiter.test.ts b/packages/sdk/src/cli/commands/workflow/waiter.test.ts new file mode 100644 index 000000000..d14704040 --- /dev/null +++ b/packages/sdk/src/cli/commands/workflow/waiter.test.ts @@ -0,0 +1,145 @@ +import { Code, ConnectError } from "@connectrpc/connect"; +import { + WorkflowExecution_Status, + WorkflowJobExecution_Status, +} from "@tailor-proto/tailor/v1/workflow_resource_pb"; +import { describe, expect, test, vi } from "vitest"; +import { + getWorkflowWaitFailureMessage, + waitForWorkflowExecution, + type WorkflowWaitResult, +} from "./waiter"; +import type { + WorkflowExecution, + WorkflowJobExecution, +} from "@tailor-proto/tailor/v1/workflow_resource_pb"; + +function workflowExecution( + status: WorkflowExecution_Status, + jobStatuses: WorkflowJobExecution_Status[] = [], +): WorkflowExecution { + return { + id: "execution-1", + workflowName: "my-workflow", + status, + jobExecutions: jobStatuses.map( + (jobStatus, index) => + ({ + id: `job-${index + 1}`, + stackedJobName: `job-${index + 1}`, + status: jobStatus, + executionId: `function-${index + 1}`, + }) as WorkflowJobExecution, + ), + } as WorkflowExecution; +} + +function workflowClient( + getWorkflowExecution: ReturnType, +): Parameters[0]["client"] { + return { + getWorkflowExecution, + } as unknown as Parameters[0]["client"]; +} + +describe("waitForWorkflowExecution", () => { + test("treats retry and pending states as transient until success", async () => { + const getWorkflowExecution = vi + .fn() + .mockResolvedValueOnce({ + execution: workflowExecution(WorkflowExecution_Status.UNSPECIFIED), + }) + .mockResolvedValueOnce({ + execution: workflowExecution(WorkflowExecution_Status.PENDING_RETRY), + }) + .mockResolvedValueOnce({ + execution: workflowExecution(WorkflowExecution_Status.SUCCESS), + }); + + const result = await waitForWorkflowExecution({ + client: workflowClient(getWorkflowExecution), + workspaceId: "workspace-1", + executionId: "execution-1", + interval: 1, + timeout: 100, + until: "success", + }); + + expect(result).toMatchObject({ + id: "execution-1", + status: "SUCCESS", + statusClass: "success", + attempts: 3, + timedOut: false, + lastError: null, + } satisfies Partial); + }); + + test("can wait for a job-level suspended state", async () => { + const getWorkflowExecution = vi.fn().mockResolvedValue({ + execution: workflowExecution(WorkflowExecution_Status.RUNNING, [ + WorkflowJobExecution_Status.WAITING, + ]), + }); + + const result = await waitForWorkflowExecution({ + client: workflowClient(getWorkflowExecution), + workspaceId: "workspace-1", + executionId: "execution-1", + interval: 1, + timeout: 100, + until: "suspended", + }); + + expect(result.status).toBe("RUNNING"); + expect(result.statusClass).toBe("suspended"); + expect(getWorkflowWaitFailureMessage(result, "suspended")).toBeUndefined(); + }); + + test("retries retryable poll failures", async () => { + const getWorkflowExecution = vi + .fn() + .mockRejectedValueOnce(new ConnectError("temporarily unavailable", Code.Unavailable)) + .mockResolvedValueOnce({ + execution: workflowExecution(WorkflowExecution_Status.SUCCESS), + }); + + const result = await waitForWorkflowExecution({ + client: workflowClient(getWorkflowExecution), + workspaceId: "workspace-1", + executionId: "execution-1", + interval: 1, + timeout: 100, + until: "success", + }); + + expect(result.status).toBe("SUCCESS"); + expect(result.attempts).toBe(2); + expect(result.lastError).toBeNull(); + }); + + test("returns timeout diagnostics with the last observed status", async () => { + const getWorkflowExecution = vi.fn().mockResolvedValue({ + execution: workflowExecution(WorkflowExecution_Status.PENDING), + }); + + const result = await waitForWorkflowExecution({ + client: workflowClient(getWorkflowExecution), + workspaceId: "workspace-1", + executionId: "execution-1", + interval: 1, + timeout: 5, + until: "success", + }); + + expect(result).toMatchObject({ + id: "execution-1", + status: "PENDING", + statusClass: "transient", + timedOut: true, + lastError: null, + } satisfies Partial); + expect(result.attempts).toBeGreaterThan(0); + expect(getWorkflowWaitFailureMessage(result, "success")).toContain("Timed out"); + }); +}); diff --git a/packages/sdk/src/cli/commands/workflow/waiter.ts b/packages/sdk/src/cli/commands/workflow/waiter.ts new file mode 100644 index 000000000..d4e221259 --- /dev/null +++ b/packages/sdk/src/cli/commands/workflow/waiter.ts @@ -0,0 +1,341 @@ +import { setTimeout } from "node:timers/promises"; +import { Code, ConnectError } from "@connectrpc/connect"; +import { + WorkflowExecution_Status, + WorkflowJobExecution_Status, +} from "@tailor-proto/tailor/v1/workflow_resource_pb"; +import { initOperatorClient } from "@/cli/shared/client"; +import { loadAccessToken, loadWorkspaceId } from "@/cli/shared/context"; +import { logger, styles } from "@/cli/shared/logger"; +import { spinner } from "@/cli/shared/spinner"; +import { + classifyWorkflowExecutionStatus, + hasReachedWorkflowWaitTarget, + isWorkflowExecutionFailureStatus, + isWorkflowExecutionSuspendedStatus, + type WorkflowExecutionStatusClass, + type WorkflowWaitUntil, +} from "./status"; +import { type WorkflowExecutionInfo, toWorkflowExecutionInfo } from "./transform"; +import type { WorkflowExecution } from "@tailor-proto/tailor/v1/workflow_resource_pb"; + +export const DEFAULT_WORKFLOW_WAIT_INTERVAL_MS = 3000; + +export interface WorkflowWaitOptions { + client: Awaited>; + workspaceId: string; + executionId: string; + interval?: number; + timeout?: number; + until?: WorkflowWaitUntil; + showProgress?: boolean; + trackJobs?: boolean; +} + +export interface WaitWorkflowExecutionOptions { + executionId: string; + workspaceId?: string; + profile?: string; + interval?: number; + timeout?: number; + until?: WorkflowWaitUntil; + showProgress?: boolean; + trackJobs?: boolean; +} + +export interface WorkflowWaitResult extends WorkflowExecutionInfo { + statusClass: WorkflowExecutionStatusClass | "unknown"; + elapsedMs: number; + attempts: number; + timedOut: boolean; + lastError: string | null; +} + +function formatTime(date: Date): string { + return date.toLocaleTimeString("en-US", { hour12: false }); +} + +function colorizeStatus(status: WorkflowExecution_Status): string { + const statusText = WorkflowExecution_Status[status]; + switch (status) { + case WorkflowExecution_Status.PENDING: + case WorkflowExecution_Status.UNSPECIFIED: + return styles.dim(statusText); + case WorkflowExecution_Status.PENDING_RESUME: + case WorkflowExecution_Status.PENDING_RETRY: + case WorkflowExecution_Status.WAITING: + return styles.warning(statusText); + case WorkflowExecution_Status.RUNNING: + return styles.info(statusText); + case WorkflowExecution_Status.SUCCESS: + return styles.success(statusText); + case WorkflowExecution_Status.FAILED: + return styles.error(statusText); + default: + return statusText; + } +} + +function getActiveJobs(execution: WorkflowExecution): string { + return execution.jobExecutions + .filter( + (job) => + job.status === WorkflowJobExecution_Status.RUNNING || + job.status === WorkflowJobExecution_Status.SUSPEND || + job.status === WorkflowJobExecution_Status.WAITING, + ) + .map((job) => job.stackedJobName) + .join(", "); +} + +function formatWaitError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isRetryableWaitError(error: unknown): boolean { + if (!(error instanceof ConnectError)) { + return false; + } + return ( + error.code === Code.Aborted || + error.code === Code.ResourceExhausted || + error.code === Code.Unavailable + ); +} + +interface CreateWorkflowWaitResultOptions { + executionId: string; + execution: WorkflowExecution | undefined; + startedAt: number; + attempts: number; + timedOut: boolean; + lastError: string | null; +} + +function createWorkflowWaitResult(options: CreateWorkflowWaitResultOptions): WorkflowWaitResult { + const elapsedMs = Date.now() - options.startedAt; + if (options.execution) { + const classification = classifyWorkflowExecutionStatus(options.execution); + return { + ...toWorkflowExecutionInfo(options.execution), + statusClass: classification.statusClass, + elapsedMs, + attempts: options.attempts, + timedOut: options.timedOut, + lastError: options.lastError, + }; + } + return { + id: options.executionId, + workflowName: "", + status: "UNKNOWN", + statusClass: "unknown", + jobExecutions: 0, + startedAt: null, + finishedAt: null, + elapsedMs, + attempts: options.attempts, + timedOut: options.timedOut, + lastError: options.lastError, + }; +} + +/** + * Wait for a workflow execution to reach the requested state. + * @param options - Workflow waiter options + * @returns Final or timed-out workflow wait result + */ +export async function waitForWorkflowExecution( + options: WorkflowWaitOptions, +): Promise { + const interval = options.interval ?? DEFAULT_WORKFLOW_WAIT_INTERVAL_MS; + const until = options.until ?? "terminal"; + const startedAt = Date.now(); + const sp = options.showProgress + ? spinner({ indent: 2 }).start("Waiting for workflow to complete...") + : null; + + let attempts = 0; + let lastExecution: WorkflowExecution | undefined; + let lastError: string | null = null; + let lastStatus: WorkflowExecution_Status | undefined; + let lastActiveJobs: string | undefined; + + try { + // oxlint-disable-next-line typescript/no-unnecessary-condition + while (true) { + const elapsedMs = Date.now() - startedAt; + const remainingMs = options.timeout === undefined ? undefined : options.timeout - elapsedMs; + if (remainingMs !== undefined && remainingMs <= 0) { + sp?.fail("Workflow wait timed out."); + return createWorkflowWaitResult({ + executionId: options.executionId, + execution: lastExecution, + startedAt, + attempts, + timedOut: true, + lastError, + }); + } + + try { + attempts += 1; + const { execution } = await options.client.getWorkflowExecution({ + workspaceId: options.workspaceId, + executionId: options.executionId, + }); + + if (!execution) { + sp?.fail(`Execution '${options.executionId}' not found.`); + throw new Error(`Execution '${options.executionId}' not found.`); + } + + lastExecution = execution; + lastError = null; + + const classification = classifyWorkflowExecutionStatus(execution); + const coloredStatus = colorizeStatus(execution.status); + + if (execution.status !== lastStatus) { + if (options.showProgress) { + sp?.stop(); + logger.info(`Status: ${coloredStatus}`, { + mode: "stream", + indent: 2, + }); + sp?.start("Waiting for workflow to complete..."); + } + lastStatus = execution.status; + } + + if (options.trackJobs) { + const activeJobs = getActiveJobs(execution); + if (activeJobs && activeJobs !== lastActiveJobs) { + if (options.showProgress) { + sp?.stop(); + logger.info(`Job | ${activeJobs}: ${coloredStatus}`, { + mode: "stream", + indent: 2, + }); + sp?.start("Waiting for workflow to complete..."); + } + lastActiveJobs = activeJobs; + } + } + + if (sp) { + sp.text = `Waiting for workflow to complete... (${formatTime(new Date())})`; + } + + if ( + hasReachedWorkflowWaitTarget(classification, until) || + classification.statusClass === "failure" || + (until === "suspended" && classification.statusClass === "success") + ) { + if (execution.status === WorkflowExecution_Status.SUCCESS) { + sp?.succeed(`Completed: ${coloredStatus}`); + } else if (isWorkflowExecutionFailureStatus(execution.status)) { + sp?.fail(`Completed: ${coloredStatus}`); + } else if (isWorkflowExecutionSuspendedStatus(execution.status)) { + sp?.warn(`Completed: ${coloredStatus}`); + } else { + sp?.succeed(`Completed: ${coloredStatus}`); + } + return createWorkflowWaitResult({ + executionId: options.executionId, + execution, + startedAt, + attempts, + timedOut: false, + lastError, + }); + } + } catch (error) { + if (!isRetryableWaitError(error)) { + throw error; + } + lastError = formatWaitError(error); + if (options.showProgress) { + if (sp) { + sp.text = `Retrying workflow status poll... (${formatTime(new Date())})`; + } + } + } + + const nextElapsedMs = Date.now() - startedAt; + const nextRemainingMs = + options.timeout === undefined ? undefined : options.timeout - nextElapsedMs; + if (nextRemainingMs !== undefined && nextRemainingMs <= 0) { + sp?.fail("Workflow wait timed out."); + return createWorkflowWaitResult({ + executionId: options.executionId, + execution: lastExecution, + startedAt, + attempts, + timedOut: true, + lastError, + }); + } + + await setTimeout( + nextRemainingMs === undefined ? interval : Math.min(interval, nextRemainingMs), + ); + } + } finally { + sp?.stop(); + } +} + +/** + * Wait for an existing workflow execution by ID. + * @param options - Workflow execution wait options + * @returns Workflow wait result + */ +export async function waitForWorkflowExecutionById( + options: WaitWorkflowExecutionOptions, +): Promise { + const accessToken = await loadAccessToken({ + profile: options.profile, + }); + const client = await initOperatorClient(accessToken); + const workspaceId = await loadWorkspaceId({ + workspaceId: options.workspaceId, + profile: options.profile, + }); + + return await waitForWorkflowExecution({ + client, + workspaceId, + executionId: options.executionId, + interval: options.interval, + timeout: options.timeout, + until: options.until, + showProgress: options.showProgress, + trackJobs: options.trackJobs, + }); +} + +/** + * Build a user-facing failure message for a workflow wait result. + * @param result - Workflow wait result + * @param until - Requested wait target + * @returns Failure message, or undefined when the wait succeeded + */ +export function getWorkflowWaitFailureMessage( + result: WorkflowWaitResult, + until: WorkflowWaitUntil, +): string | undefined { + if (result.timedOut) { + return `Timed out waiting for workflow execution '${result.id}' to reach ${until}. Last status: ${result.status}.`; + } + if (result.status === "FAILED") { + return `Workflow execution '${result.id}' failed.`; + } + if (until === "success" && result.statusClass !== "success") { + return `Workflow execution '${result.id}' reached ${result.status} before success.`; + } + if (until === "suspended" && result.statusClass !== "suspended") { + return `Workflow execution '${result.id}' reached ${result.status} before suspension.`; + } + return undefined; +} diff --git a/packages/sdk/src/cli/lib.ts b/packages/sdk/src/cli/lib.ts index 72596ce4f..58cd8089e 100644 --- a/packages/sdk/src/cli/lib.ts +++ b/packages/sdk/src/cli/lib.ts @@ -105,7 +105,10 @@ export { type ListWorkflowExecutionsTypedOptions, type GetWorkflowExecutionOptions, type GetWorkflowExecutionResult, + type WorkflowExecutionWaitInfo, } from "./commands/workflow/executions"; +export { waitWorkflowExecution, type WorkflowWaitOutput } from "./commands/workflow/wait"; +export type { WaitWorkflowExecutionOptions, WorkflowWaitResult } from "./commands/workflow/waiter"; export { resumeWorkflow, type ResumeWorkflowOptions, @@ -126,6 +129,7 @@ export { export { listExecutorJobs, getExecutorJob, + getExecutorWaitFailureMessage, watchExecutorJob, type ListExecutorJobsOptions, type ListExecutorJobsTypedOptions, diff --git a/packages/sdk/src/cli/shared/readonly-guard.test.ts b/packages/sdk/src/cli/shared/readonly-guard.test.ts index b77a52a18..2faeb919f 100644 --- a/packages/sdk/src/cli/shared/readonly-guard.test.ts +++ b/packages/sdk/src/cli/shared/readonly-guard.test.ts @@ -131,6 +131,7 @@ const READ_OR_LOCAL_COMMAND_PATHS = new Set([ "workflow/list.ts", "workflow/resume.ts", "workflow/start.ts", + "workflow/wait.ts", // Workspace (read-only branches) "workspace/index.ts", "workspace/get.ts", diff --git a/packages/tailor-proto/src/tailor/v1/aigateway_pb.d.ts b/packages/tailor-proto/src/tailor/v1/aigateway_pb.d.ts index 5a0cbc077..28015540c 100644 --- a/packages/tailor-proto/src/tailor/v1/aigateway_pb.d.ts +++ b/packages/tailor-proto/src/tailor/v1/aigateway_pb.d.ts @@ -238,4 +238,3 @@ export declare type ListAIGatewaysResponse = Message<"tailor.v1.ListAIGatewaysRe * Use `create(ListAIGatewaysResponseSchema)` to create a new message. */ export declare const ListAIGatewaysResponseSchema: GenMessage; - diff --git a/packages/tailor-proto/src/tailor/v1/aigateway_pb.js b/packages/tailor-proto/src/tailor/v1/aigateway_pb.js index 64574a613..2faff5b98 100644 --- a/packages/tailor-proto/src/tailor/v1/aigateway_pb.js +++ b/packages/tailor-proto/src/tailor/v1/aigateway_pb.js @@ -84,4 +84,3 @@ export const ListAIGatewaysRequestSchema = /*@__PURE__*/ */ export const ListAIGatewaysResponseSchema = /*@__PURE__*/ messageDesc(file_tailor_v1_aigateway, 9); - diff --git a/packages/tailor-proto/src/tailor/v1/aigateway_resource_pb.d.ts b/packages/tailor-proto/src/tailor/v1/aigateway_resource_pb.d.ts index 1a97222cb..b80cfe254 100644 --- a/packages/tailor-proto/src/tailor/v1/aigateway_resource_pb.d.ts +++ b/packages/tailor-proto/src/tailor/v1/aigateway_resource_pb.d.ts @@ -67,4 +67,3 @@ export declare type AIGateway = Message<"tailor.v1.AIGateway"> & { * Use `create(AIGatewaySchema)` to create a new message. */ export declare const AIGatewaySchema: GenMessage; - diff --git a/packages/tailor-proto/src/tailor/v1/aigateway_resource_pb.js b/packages/tailor-proto/src/tailor/v1/aigateway_resource_pb.js index a01c2678e..266bbe50d 100644 --- a/packages/tailor-proto/src/tailor/v1/aigateway_resource_pb.js +++ b/packages/tailor-proto/src/tailor/v1/aigateway_resource_pb.js @@ -18,4 +18,3 @@ export const file_tailor_v1_aigateway_resource = /*@__PURE__*/ */ export const AIGatewaySchema = /*@__PURE__*/ messageDesc(file_tailor_v1_aigateway_resource, 0); -