From 10f311b3918e149bf773e5a2b33bc9c5dbfefba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Fri, 29 May 2026 13:08:56 +0200 Subject: [PATCH 1/2] feat(cli): add wait commands and enhance agentic output for builds and runs Introduces dedicated `wait` commands and unifies output for several build/run commands for better CI/CD integration. --- docs/reference.md | 80 +++++++++++++-- scripts/generate-cli-docs.ts | 2 + src/commands/actors/call.ts | 88 +++++++++++++--- src/commands/actors/push.ts | 112 +++++++++++++++------ src/commands/actors/start.ts | 51 +++++++++- src/commands/builds/_index.ts | 2 + src/commands/builds/create.ts | 168 ++++++++++++++++++++++++++----- src/commands/builds/wait.ts | 132 ++++++++++++++++++++++++ src/commands/runs/_index.ts | 2 + src/commands/runs/wait.ts | 143 ++++++++++++++++++++++++++ src/commands/task/run.ts | 102 ++++++++++++++++--- src/lib/commands/agent-output.ts | 141 ++++++++++++++++++++++++++ src/lib/commands/run-on-cloud.ts | 17 +++- src/lib/consts.ts | 1 + 14 files changed, 937 insertions(+), 104 deletions(-) create mode 100644 src/commands/builds/wait.ts create mode 100644 src/commands/runs/wait.ts create mode 100644 src/lib/commands/agent-output.ts diff --git a/docs/reference.md b/docs/reference.md index 80d1da98f..4fc6d418c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -69,10 +69,11 @@ ARGUMENTS FLAGS -d, --body= The request body (JSON string). Use "-" to read from stdin. - --describe= Describe an endpoint: print every HTTP - method on a path, its summary, and path parameters. - Accepts a path like "actor-runs/{runId}" or - "/v2/actor-runs/{runId}". + --describe= Print a reference for an endpoint + path: its HTTP methods, summary, and path parameters. + Leading slashes and a version prefix in the path are + optional. For example, "actor-runs/{runId}" and + "/v2/actor-runs/{runId}" are both accepted. -H, --header= Additional HTTP header(s). Pass a single "key:value" string, or a JSON object like '{"X-Foo": "bar", "X-Baz": "qux"}' to send multiple @@ -85,9 +86,11 @@ FLAGS -p, --params= Query parameters as a JSON object, e.g. '{"limit": 1, "desc": true}'. - -s, --search= Filter --list-endpoints by a - space-separated query. Each token must appear - (case-insensitive) in method, path, or summary. + -s, --search= Filter results returned by + --list-endpoints. The query is case-insensitive and split + into tokens by spaces. For an endpoint to be returned, + every token must appear in that endpoint's method, path, + or summary. ``` ##### `apify telemetry` @@ -881,6 +884,8 @@ SUBCOMMANDS builds log Prints the log of a specific build. builds info Prints information about a specific build. builds create Creates a new build of the Actor. + builds wait Waits for an Actor build to reach a + terminal status (SUCCEEDED, FAILED, ABORTED, TIMED-OUT). ``` ##### `apify builds add-tag` @@ -905,7 +910,7 @@ DESCRIPTION USAGE $ apify builds create [actorId] [--json] [--log] - [--tag ] [--version ] + [--tag ] [--version ] [--wait] ARGUMENTS actorId Optional Actor ID or Name to trigger a build for. By default, @@ -920,6 +925,8 @@ FLAGS --version= Optional Actor Version to build. By default, this will be inferred from the tag, but this flag is required when multiple versions have the same tag. + --wait Wait for the build to reach a terminal + status. Returns exit code 0 only when the build SUCCEEDED. ``` ##### `apify builds info` @@ -1005,6 +1012,32 @@ FLAGS -y, --yes Automatic yes to prompts; assume "yes" as answer to all prompts. ``` + +##### `apify builds wait` + +```sh +DESCRIPTION + Waits for an Actor build to reach a terminal status (SUCCEEDED, FAILED, + ABORTED, TIMED-OUT). + Returns exit code 0 only when the build SUCCEEDED. Designed for CI and agentic + workflows. + +USAGE + $ apify builds wait [--json] + [--poll-interval ] [-t ] + +ARGUMENTS + buildId The build ID to wait for. + +FLAGS + --json Format the command output as + JSON + --poll-interval= How often to poll the + platform, in seconds. Defaults to 2. + -t, --timeout= Maximum seconds to wait + before giving up. Without this flag the command waits + indefinitely. +``` @@ -1029,6 +1062,8 @@ SUBCOMMANDS runs ls Lists all runs of the Actor. runs resurrect Resurrects an aborted or finished Actor Run. runs rm Deletes an Actor Run. + runs wait Waits for an Actor run to reach a terminal + status (SUCCEEDED, FAILED, ABORTED, TIMED-OUT). ``` ##### `apify runs abort` @@ -1134,6 +1169,32 @@ FLAGS -y, --yes Automatic yes to prompts; assume "yes" as answer to all prompts. ``` + +##### `apify runs wait` + +```sh +DESCRIPTION + Waits for an Actor run to reach a terminal status (SUCCEEDED, FAILED, ABORTED, + TIMED-OUT). + Returns exit code 0 only when the run SUCCEEDED. Designed for CI and agentic + workflows. + +USAGE + $ apify runs wait [--json] [--poll-interval ] + [-t ] + +ARGUMENTS + runId The run ID to wait for. + +FLAGS + --json Format the command output as + JSON + --poll-interval= How often to poll the + platform, in seconds. Defaults to 2. + -t, --timeout= Maximum seconds to wait + before giving up. Without this flag the command waits + indefinitely. +``` @@ -1556,7 +1617,7 @@ DESCRIPTION Customize with --memory and --timeout flags. USAGE - $ apify task run [-b ] [-m ] + $ apify task run [-b ] [--json] [-m ] [-t ] ARGUMENTS @@ -1566,6 +1627,7 @@ ARGUMENTS FLAGS -b, --build= Tag or number of the build to run (e.g. "latest" or "1.2.34"). + --json Format the command output as JSON -m, --memory= Amount of memory allocated for the Task run, in megabytes. -t, --timeout= Timeout for the Task run in seconds. diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index a2fe14b89..f47a40a71 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -60,6 +60,7 @@ const categories: Record = { { command: Commands.buildsLs }, { command: Commands.buildsRemoveTag }, { command: Commands.buildsRm }, + { command: Commands.buildsWait }, ], 'actor-run': [ // @@ -70,6 +71,7 @@ const categories: Record = { { command: Commands.runsLs }, { command: Commands.runsResurrect }, { command: Commands.runsRm }, + { command: Commands.runsWait }, ], 'general': [ // diff --git a/src/commands/actors/call.ts b/src/commands/actors/call.ts index 451a1ab11..0f8e77193 100644 --- a/src/commands/actors/call.ts +++ b/src/commands/actors/call.ts @@ -13,6 +13,13 @@ import chalk from 'chalk'; import { ApifyCommand, StdinMode } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; +import { + consoleDatasetUrl, + consoleRunUrl, + exitCodeForJobStatus, + fetchLogTail, + formatResultSummary, +} from '../../lib/commands/agent-output.js'; import { getInputOverride } from '../../lib/commands/resolve-input.js'; import { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands/run-on-cloud.js'; import { CommandExitCodes, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; @@ -144,10 +151,7 @@ export class ActorsCallCommand extends ApifyCommand { } let runStarted = false; - let run: ActorRun; - - let url: string; - let datasetUrl: string; + let run: ActorRun | undefined; const iterator = runActorOrTaskOnCloud(apifyClient, { actorOrTaskData: { @@ -160,6 +164,7 @@ export class ActorsCallCommand extends ApifyCommand { silent: this.flags.silent, waitForRunToFinish: true, printRunLogs: true, + suppressFinalStatus: true, }); for await (const yieldedRun of iterator) { @@ -170,8 +175,7 @@ export class ActorsCallCommand extends ApifyCommand { // A *lot* is copied from `runs info` if (!this.flags.silent) { - url = `https://console.apify.com/actors/${actorId}/runs/${yieldedRun.id}`; - datasetUrl = `https://console.apify.com/storage/datasets/${yieldedRun.defaultDatasetId}`; + const startedUrl = `https://console.apify.com/actors/${actorId}/runs/${yieldedRun.id}`; const message: string[] = [`${chalk.yellow('Started')}: ${TimestampFormatter.display(yieldedRun.startedAt)}`]; @@ -213,29 +217,87 @@ export class ActorsCallCommand extends ApifyCommand { message.push(`${chalk.yellow('Memory')}: ${run.options.memoryMbytes} MB`); // url - message.push(`${chalk.blue('View on Apify Console')}: ${url}`, ''); + message.push(`${chalk.blue('View on Apify Console')}: ${startedUrl}`, ''); simpleLog({ message: message.join('\n'), stdout: !this.flags.json }); } } } + if (!run) { + error({ message: 'Actor run did not start.' }); + process.exitCode = CommandExitCodes.RunFailed; + return; + } + const finalRun = run; + const finalUrl = consoleRunUrl(actorId, finalRun.id); + const finalDatasetUrl = consoleDatasetUrl(finalRun.defaultDatasetId); + const ok = finalRun.status === 'SUCCEEDED'; + const exitCode = exitCodeForJobStatus(finalRun.status, 'run'); + const logTail = ok ? [] : await fetchLogTail(apifyClient, finalRun.id); + if (this.flags.json) { - printJsonToStdout(run!); + printJsonToStdout({ + ok, + operation: 'call', + actor: { + id: actorId, + url: `https://console.apify.com/actors/${actorId}`, + }, + run: { + id: finalRun.id, + status: finalRun.status, + exitCode: finalRun.exitCode ?? null, + url: finalUrl, + }, + storage: { + defaultDatasetId: finalRun.defaultDatasetId, + defaultKeyValueStoreId: finalRun.defaultKeyValueStoreId, + datasetUrl: finalDatasetUrl, + }, + ...(ok + ? {} + : { + error: { + phase: 'run', + message: 'Actor run did not succeed', + logTail, + }, + }), + exitCode, + }); + process.exitCode = exitCode; return; } if (!this.flags.silent) { simpleLog({ - message: [ - '', - `${chalk.blue('Export results')}: ${datasetUrl!}`, - `${chalk.blue('View on Apify Console')}: ${url!}`, - ].join('\n'), + message: formatResultSummary({ + resultLabel: 'Apify call result', + overallStatus: finalRun.status as never, + lines: [ + { label: 'Run', value: finalRun.status as string }, + { label: 'Actor ID', value: actorId }, + { label: 'Run ID', value: finalRun.id }, + { label: 'Build number', value: finalRun.buildNumber }, + ...(typeof finalRun.exitCode === 'number' + ? [{ label: 'Exit code', value: String(finalRun.exitCode) }] + : []), + { label: 'Dataset ID', value: finalRun.defaultDatasetId }, + { label: 'Key-value store ID', value: finalRun.defaultKeyValueStoreId }, + ], + links: [ + { label: 'Run URL', url: finalUrl }, + { label: 'Dataset URL', url: finalDatasetUrl }, + ], + errorReason: ok ? undefined : logTail, + }), stdout: true, }); } + process.exitCode = exitCode; + if (this.flags.outputDataset) { const datasetId = run!.defaultDatasetId; diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 980bd85ed..45566dfb5 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -12,11 +12,12 @@ import { createHmacSignature } from '@apify/utilities'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; +import { exitCodeForJobStatus, fetchLogTail, formatResultSummary } from '../../lib/commands/agent-output.js'; import { CommandExitCodes, DEPRECATED_LOCAL_CONFIG_NAME, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; import { sumFilesSizeInBytes } from '../../lib/files.js'; import { useAbortJobOnSignal } from '../../lib/hooks/useAbortJobOnSignal.js'; import { useActorConfig } from '../../lib/hooks/useActorConfig.js'; -import { error, info, link, run, success, warning } from '../../lib/outputs.js'; +import { error, info, run, simpleLog, warning } from '../../lib/outputs.js'; import { transformEnvToEnvVars } from '../../lib/secrets.js'; import { createActZip, @@ -382,46 +383,91 @@ Skipping push. Use --force to override.`, console.error(err); } - build = (await apifyClient.build(build.id).get())!; + const refreshedBuild = await apifyClient.build(build.id).get(); + if (!refreshedBuild) { + error({ message: `Could not fetch build with ID "${build.id}" after deployment.` }); + process.exitCode = CommandExitCodes.BuildFailed; + return; + } + build = refreshedBuild; + + const actorUrl = `https://console.apify.com${redirectUrlPart}/actors/${build.actId}`; + const buildUrl = `https://console.apify.com${redirectUrlPart}/actors/${build.actId}#/builds/${build.buildNumber}`; + + if (this.flags.open) { + await open(actorUrl); + } + + const buildStatus = build.status as string; + const isTerminal = + buildStatus === ACTOR_JOB_STATUSES.SUCCEEDED || + buildStatus === ACTOR_JOB_STATUSES.FAILED || + buildStatus === ACTOR_JOB_STATUSES.ABORTED || + buildStatus === ACTOR_JOB_STATUSES.TIMED_OUT; + + const ok = build.status === ACTOR_JOB_STATUSES.SUCCEEDED; + const exitCode = exitCodeForJobStatus(build.status, 'build'); + const logTail = ok ? [] : await fetchLogTail(apifyClient, build.id); + + const buildStatusLabel = isTerminal ? (build.status as string) : 'RUNNING'; + const overallStatus = ok ? 'SUCCEEDED' : (buildStatusLabel as never); if (this.flags.json) { - printJsonToStdout(build); + printJsonToStdout({ + ok, + operation: 'push', + actor: { + id: build.actId, + url: actorUrl, + }, + build: { + id: build.id, + number: build.buildNumber, + status: build.status, + url: buildUrl, + }, + ...(ok + ? {} + : { + error: { + phase: 'build', + message: isTerminal ? 'Actor build did not succeed' : 'Actor build did not reach a terminal status', + logTail, + }, + }), + exitCode, + }); + process.exitCode = exitCode; return; } - link({ - message: 'Actor build detail', - url: `https://console.apify.com${redirectUrlPart}/actors/${build.actId}#/builds/${build.buildNumber}`, - }); - - link({ - message: 'Actor detail', - url: `https://console.apify.com${redirectUrlPart}/actors/${build.actId}`, + simpleLog({ + message: formatResultSummary({ + resultLabel: 'Apify push result', + overallStatus, + lines: [ + { label: 'Upload', value: 'SUCCEEDED' }, + { label: 'Build', value: build.status as string }, + { label: 'Actor ID', value: build.actId }, + { label: 'Build ID', value: build.id }, + { label: 'Build number', value: build.buildNumber }, + { label: 'Exit code', value: String(exitCode) }, + ], + links: [ + { label: 'Actor URL', url: actorUrl }, + { label: 'Build URL', url: buildUrl }, + ], + errorReason: ok ? undefined : logTail, + }), + stdout: true, }); - if (this.flags.open) { - await open(`https://console.apify.com${redirectUrlPart}/actors/${build.actId}`); + if (!isTerminal) { + warning({ message: `Build did not finish; current status is ${build.status}.` }); + } else if (!ok) { + error({ message: `Build ended with status ${build.status}.` }); } - if (build.status === ACTOR_JOB_STATUSES.SUCCEEDED) { - success({ message: 'Actor was deployed to Apify cloud and built there.' }); - // @ts-expect-error FIX THESE TYPES 😢 - } else if (build.status === ACTOR_JOB_STATUSES.READY) { - warning({ message: 'Build is waiting for allocation.' }); - // @ts-expect-error FIX THESE TYPES 😢 - } else if (build.status === ACTOR_JOB_STATUSES.RUNNING) { - warning({ message: 'Build is still running.' }); - // @ts-expect-error FIX THESE TYPES 😢 - } else if (build.status === ACTOR_JOB_STATUSES.ABORTED || build.status === ACTOR_JOB_STATUSES.ABORTING) { - warning({ message: 'Build was aborted!' }); - process.exitCode = CommandExitCodes.BuildAborted; - // @ts-expect-error FIX THESE TYPES 😢 - } else if (build.status === ACTOR_JOB_STATUSES.TIMED_OUT || build.status === ACTOR_JOB_STATUSES.TIMING_OUT) { - warning({ message: 'Build timed out!' }); - process.exitCode = CommandExitCodes.BuildTimedOut; - } else { - error({ message: 'Build failed!' }); - process.exitCode = CommandExitCodes.BuildFailed; - } + process.exitCode = exitCode; } } diff --git a/src/commands/actors/start.ts b/src/commands/actors/start.ts index 37ce1a47e..7eedb530f 100644 --- a/src/commands/actors/start.ts +++ b/src/commands/actors/start.ts @@ -1,9 +1,12 @@ +import process from 'node:process'; + import type { ActorRun, ActorStartOptions, ActorTaggedBuild } from 'apify-client'; import chalk from 'chalk'; import { ApifyCommand, StdinMode } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; +import { consoleRunUrl } from '../../lib/commands/agent-output.js'; import { getInputOverride } from '../../lib/commands/resolve-input.js'; import { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands/run-on-cloud.js'; import { LOCAL_CONFIG_PATH } from '../../lib/consts.js'; @@ -123,23 +126,50 @@ export class ActorsStartCommand extends ApifyCommand printRunLogs: false, }); - let run!: ActorRun; + let run: ActorRun | undefined; for await (const yieldedRun of iterator) { run = yieldedRun; } - if (this.flags.json) { - printJsonToStdout(run); + if (!run) { + simpleLog({ message: 'Actor run did not start.', stdout: false }); + process.exitCode = 1; return; } - const url = `https://console.apify.com/actors/${actorId}/runs/${run.id}`; + const url = consoleRunUrl(actorId, run.id); const datasetUrl = `https://console.apify.com/storage/datasets/${run.defaultDatasetId}`; + if (this.flags.json) { + printJsonToStdout({ + ok: true, + operation: 'actors.start', + waited: false, + actor: { + id: actorId, + name: userFriendlyId, + }, + run: { + id: run.id, + status: run.status, + url, + }, + next: { + wait: `apify runs wait ${run.id} --json`, + log: `apify runs log ${run.id}`, + info: `apify runs info ${run.id} --json`, + }, + exitCode: 0, + }); + return; + } + const message: string[] = [ `${chalk.gray('Run:')} Calling Actor ${userFriendlyId} (${chalk.gray(actorId)})`, '', + `${chalk.yellow('Run ID')}: ${run.id}`, + `${chalk.yellow('Status')}: ${run.status}`, `${chalk.yellow('Started')}: ${TimestampFormatter.display(run.startedAt)}`, ]; @@ -183,8 +213,19 @@ export class ActorsStartCommand extends ApifyCommand // url message.push( '', - `${chalk.blue('Export results')}: ${datasetUrl!}`, + `${chalk.blue('Export results')}: ${datasetUrl}`, `${chalk.blue('View on Apify Console')}: ${url}`, + '', + chalk.gray('This command does not wait for the run to finish.'), + '', + 'To wait for the final status:', + ` apify runs wait ${run.id} --json`, + '', + 'To stream or inspect logs:', + ` apify runs log ${run.id}`, + '', + 'To inspect run metadata:', + ` apify runs info ${run.id} --json`, ); simpleLog({ diff --git a/src/commands/builds/_index.ts b/src/commands/builds/_index.ts index 1e9fb51b9..080290fe7 100644 --- a/src/commands/builds/_index.ts +++ b/src/commands/builds/_index.ts @@ -6,6 +6,7 @@ import { BuildsLogCommand } from './log.js'; import { BuildsLsCommand } from './ls.js'; import { BuildsRemoveTagCommand } from './remove-tag.js'; import { BuildsRmCommand } from './rm.js'; +import { BuildsWaitCommand } from './wait.js'; export class BuildsIndexCommand extends ApifyCommand { static override name = 'builds' as const; @@ -25,6 +26,7 @@ export class BuildsIndexCommand extends ApifyCommand BuildsLogCommand, BuildsInfoCommand, BuildsCreateCommand, + BuildsWaitCommand, ]; async run() { diff --git a/src/commands/builds/create.ts b/src/commands/builds/create.ts index 9931fcc56..febda28c8 100644 --- a/src/commands/builds/create.ts +++ b/src/commands/builds/create.ts @@ -1,8 +1,17 @@ +import process from 'node:process'; + import chalk from 'chalk'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; +import { + consoleBuildUrl, + exitCodeForJobStatus, + fetchLogTail, + formatResultSummary, + waitForTerminalStatus, +} from '../../lib/commands/agent-output.js'; import { resolveActorContext } from '../../lib/commands/resolve-actor-context.js'; import { useAbortJobOnSignal } from '../../lib/hooks/useAbortJobOnSignal.js'; import { error, simpleLog } from '../../lib/outputs.js'; @@ -44,6 +53,10 @@ export class BuildsCreateCommand extends ApifyCommand { + static override name = 'wait' as const; + + static override description = + 'Waits for an Actor build to reach a terminal status (SUCCEEDED, FAILED, ABORTED, TIMED-OUT).\n' + + 'Returns exit code 0 only when the build SUCCEEDED. Designed for CI and agentic workflows.'; + + static override examples = [ + { + description: 'Wait for a build to finish and return a non-zero exit code on failure.', + command: 'apify builds wait ', + }, + { + description: 'Wait for a build and emit a structured JSON result.', + command: 'apify builds wait --json', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-builds-wait'; + + static override enableJsonFlag = true; + + static override args = { + buildId: Args.string({ + required: true, + description: 'The build ID to wait for.', + }), + }; + + static override flags = { + timeout: Flags.integer({ + char: 't', + description: 'Maximum seconds to wait before giving up. Without this flag the command waits indefinitely.', + required: false, + }), + 'poll-interval': Flags.integer({ + description: 'How often to poll the platform, in seconds. Defaults to 2.', + required: false, + }), + }; + + async run() { + const { buildId } = this.args; + const { timeout, pollInterval, json } = this.flags; + + const apifyClient = await getLoggedClientOrThrow(); + + const build = (await waitForTerminalStatus({ + apifyClient, + jobId: buildId, + kind: 'build', + maxWaitMillis: timeout ? timeout * 1000 : undefined, + pollIntervalMillis: pollInterval ? pollInterval * 1000 : undefined, + })) as Build; + + const url = consoleBuildUrl(build.actId, build.buildNumber); + const exitCode = exitCodeForJobStatus(build.status, 'build'); + const ok = build.status === 'SUCCEEDED'; + + let logTail: string[] = []; + if (!ok) { + logTail = await fetchLogTail(apifyClient, build.id); + } + + if (json) { + printJsonToStdout({ + ok, + operation: 'builds.wait', + build: { + id: build.id, + status: build.status, + number: build.buildNumber, + url, + }, + ...(ok + ? {} + : { + error: { + phase: 'build', + message: 'Actor build did not succeed', + logTail, + }, + }), + exitCode, + }); + process.exitCode = exitCode; + return; + } + + const lines: { label: string; value: string }[] = [ + { label: 'Build', value: build.status }, + { label: 'Build ID', value: build.id }, + { label: 'Build number', value: build.buildNumber }, + { label: 'Actor ID', value: build.actId }, + ]; + + const links = [{ label: 'Build URL', url }]; + + simpleLog({ + message: formatResultSummary({ + resultLabel: 'Apify build result', + overallStatus: build.status as never, + lines, + links, + errorReason: ok ? undefined : logTail, + }), + stdout: true, + }); + + if (!ok) { + error({ message: `Build ended with status ${build.status}.` }); + } + process.exitCode = exitCode; + } +} diff --git a/src/commands/runs/_index.ts b/src/commands/runs/_index.ts index 685a951cc..0624a1704 100644 --- a/src/commands/runs/_index.ts +++ b/src/commands/runs/_index.ts @@ -5,6 +5,7 @@ import { RunsLogCommand } from './log.js'; import { RunsLsCommand } from './ls.js'; import { RunsResurrectCommand } from './resurrect.js'; import { RunsRmCommand } from './rm.js'; +import { RunsWaitCommand } from './wait.js'; export class RunsIndexCommand extends ApifyCommand { static override name = 'runs' as const; @@ -24,6 +25,7 @@ export class RunsIndexCommand extends ApifyCommand { RunsLsCommand, RunsResurrectCommand, RunsRmCommand, + RunsWaitCommand, ]; async run() { diff --git a/src/commands/runs/wait.ts b/src/commands/runs/wait.ts new file mode 100644 index 000000000..3c9ae02c8 --- /dev/null +++ b/src/commands/runs/wait.ts @@ -0,0 +1,143 @@ +import process from 'node:process'; + +import type { ActorRun } from 'apify-client'; + +import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Args } from '../../lib/command-framework/args.js'; +import { Flags } from '../../lib/command-framework/flags.js'; +import { + consoleRunUrl, + exitCodeForJobStatus, + fetchLogTail, + formatResultSummary, + waitForTerminalStatus, +} from '../../lib/commands/agent-output.js'; +import { error, simpleLog } from '../../lib/outputs.js'; +import { getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; + +export class RunsWaitCommand extends ApifyCommand { + static override name = 'wait' as const; + + static override description = + 'Waits for an Actor run to reach a terminal status (SUCCEEDED, FAILED, ABORTED, TIMED-OUT).\n' + + 'Returns exit code 0 only when the run SUCCEEDED. Designed for CI and agentic workflows.'; + + static override examples = [ + { + description: 'Wait for a run to finish and return a non-zero exit code on failure.', + command: 'apify runs wait ', + }, + { + description: 'Wait for a run and emit a structured JSON result.', + command: 'apify runs wait --json', + }, + { + description: 'Give up waiting after 5 minutes (still returns current status).', + command: 'apify runs wait --timeout 300', + }, + ]; + + static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-runs-wait'; + + static override enableJsonFlag = true; + + static override args = { + runId: Args.string({ + required: true, + description: 'The run ID to wait for.', + }), + }; + + static override flags = { + timeout: Flags.integer({ + char: 't', + description: 'Maximum seconds to wait before giving up. Without this flag the command waits indefinitely.', + required: false, + }), + 'poll-interval': Flags.integer({ + description: 'How often to poll the platform, in seconds. Defaults to 2.', + required: false, + }), + }; + + async run() { + const { runId } = this.args; + const { timeout, pollInterval, json } = this.flags; + + const apifyClient = await getLoggedClientOrThrow(); + + const run = (await waitForTerminalStatus({ + apifyClient, + jobId: runId, + kind: 'run', + maxWaitMillis: timeout ? timeout * 1000 : undefined, + pollIntervalMillis: pollInterval ? pollInterval * 1000 : undefined, + })) as ActorRun; + + const url = consoleRunUrl(run.actId, run.id); + const exitCode = exitCodeForJobStatus(run.status, 'run'); + const ok = run.status === 'SUCCEEDED'; + + let logTail: string[] = []; + if (!ok) { + logTail = await fetchLogTail(apifyClient, run.id); + } + + if (json) { + printJsonToStdout({ + ok, + operation: 'runs.wait', + run: { + id: run.id, + status: run.status, + exitCode: run.exitCode ?? null, + url, + }, + ...(ok + ? {} + : { + error: { + phase: 'run', + message: 'Actor run did not succeed', + logTail, + }, + }), + exitCode, + }); + process.exitCode = exitCode; + return; + } + + const lines: { label: string; value: string }[] = [ + { label: 'Run', value: run.status }, + { label: 'Run ID', value: run.id }, + { label: 'Actor ID', value: run.actId }, + ]; + + if (run.buildNumber) { + lines.push({ label: 'Build number', value: run.buildNumber }); + } + + if (typeof run.exitCode === 'number') { + lines.push({ label: 'Exit code', value: String(run.exitCode) }); + } + + const links = [{ label: 'Run URL', url }]; + + simpleLog({ + message: formatResultSummary({ + resultLabel: 'Apify run result', + overallStatus: run.status as never, + lines, + links, + errorReason: ok ? undefined : logTail, + }), + stdout: true, + }); + + if (!ok) { + error({ message: `Run ended with status ${run.status}.` }); + } + process.exitCode = exitCode; + } +} diff --git a/src/commands/task/run.ts b/src/commands/task/run.ts index 5f3acdad4..ee7288ca1 100644 --- a/src/commands/task/run.ts +++ b/src/commands/task/run.ts @@ -1,11 +1,19 @@ -import type { ApifyClient, TaskStartOptions } from 'apify-client'; -import chalk from 'chalk'; +import process from 'node:process'; + +import type { ActorRun, ApifyClient, TaskStartOptions } from 'apify-client'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; +import { + consoleDatasetUrl, + consoleRunUrl, + exitCodeForJobStatus, + fetchLogTail, + formatResultSummary, +} from '../../lib/commands/agent-output.js'; import { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands/run-on-cloud.js'; import { simpleLog } from '../../lib/outputs.js'; -import { getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; +import { getLocalUserInfo, getLoggedClientOrThrow, printJsonToStdout } from '../../lib/utils.js'; export class TaskRunCommand extends ApifyCommand { static override name = 'run' as const; @@ -29,6 +37,8 @@ export class TaskRunCommand extends ApifyCommand { static override flags = SharedRunOnCloudFlags('Task'); + static override enableJsonFlag = true; + static override args = { taskId: Args.string({ required: true, @@ -59,8 +69,7 @@ export class TaskRunCommand extends ApifyCommand { runOpts.memory = this.flags.memory; } - let url: string; - let datasetUrl: string; + let run: ActorRun | undefined; const iterator = runActorOrTaskOnCloud(apifyClient, { actorOrTaskData: { @@ -71,21 +80,90 @@ export class TaskRunCommand extends ApifyCommand { runOptions: runOpts, type: 'Task', printRunLogs: true, + waitForRunToFinish: true, + silent: this.flags.json, + suppressFinalStatus: true, }); for await (const yieldedRun of iterator) { - url = `https://console.apify.com/actors/${yieldedRun.actId}/runs/${yieldedRun.id}`; - datasetUrl = `https://console.apify.com/storage/datasets/${yieldedRun.defaultDatasetId}`; + run = yieldedRun; + } + + if (!run) { + simpleLog({ message: 'Task run did not start.', stdout: false }); + process.exitCode = 1; + return; + } + const finalRun = run; + const finalUrl = consoleRunUrl(finalRun.actId, finalRun.id); + const finalDatasetUrl = consoleDatasetUrl(finalRun.defaultDatasetId); + const ok = finalRun.status === 'SUCCEEDED'; + const exitCode = exitCodeForJobStatus(finalRun.status, 'run'); + const logTail = ok ? [] : await fetchLogTail(apifyClient, finalRun.id); + + if (this.flags.json) { + printJsonToStdout({ + ok, + operation: 'task.run', + task: { + id: taskId, + name: userFriendlyId, + title, + }, + actor: { + id: finalRun.actId, + url: `https://console.apify.com/actors/${finalRun.actId}`, + }, + run: { + id: finalRun.id, + status: finalRun.status, + exitCode: finalRun.exitCode ?? null, + url: finalUrl, + }, + storage: { + defaultDatasetId: finalRun.defaultDatasetId, + defaultKeyValueStoreId: finalRun.defaultKeyValueStoreId, + datasetUrl: finalDatasetUrl, + }, + ...(ok + ? {} + : { + error: { + phase: 'run', + message: 'Task run did not succeed', + logTail, + }, + }), + exitCode, + }); + process.exitCode = exitCode; + return; } simpleLog({ - message: [ - '', - `${chalk.blue('Export results')}: ${datasetUrl!}`, - `${chalk.blue('View on Apify Console')}: ${url!}`, - ].join('\n'), + message: formatResultSummary({ + resultLabel: 'Apify task run result', + overallStatus: finalRun.status as never, + lines: [ + { label: 'Run', value: finalRun.status as string }, + { label: 'Task ID', value: taskId }, + { label: 'Actor ID', value: finalRun.actId }, + { label: 'Run ID', value: finalRun.id }, + { label: 'Build number', value: finalRun.buildNumber }, + ...(typeof finalRun.exitCode === 'number' ? [{ label: 'Exit code', value: String(finalRun.exitCode) }] : []), + { label: 'Dataset ID', value: finalRun.defaultDatasetId }, + { label: 'Key-value store ID', value: finalRun.defaultKeyValueStoreId }, + ], + links: [ + { label: 'Run URL', url: finalUrl }, + { label: 'Dataset URL', url: finalDatasetUrl }, + ], + errorReason: ok ? undefined : logTail, + }), stdout: true, }); + + process.exitCode = exitCode; } private async resolveTaskId(client: ApifyClient, usernameOrId: string) { diff --git a/src/lib/commands/agent-output.ts b/src/lib/commands/agent-output.ts new file mode 100644 index 000000000..e2346e56c --- /dev/null +++ b/src/lib/commands/agent-output.ts @@ -0,0 +1,141 @@ +import type { ActorRun, ApifyClient, Build } from 'apify-client'; +import chalk from 'chalk'; + +import { ACTOR_JOB_TERMINAL_STATUSES } from '@apify/consts'; + +import { CommandExitCodes } from '../consts.js'; + +export type TerminalStatus = 'SUCCEEDED' | 'FAILED' | 'ABORTED' | 'TIMED-OUT'; + +const TERMINAL_STATUSES = new Set(ACTOR_JOB_TERMINAL_STATUSES as readonly string[]); + +export function isTerminalStatus(status: string | undefined): status is TerminalStatus { + return !!status && TERMINAL_STATUSES.has(status); +} + +export function exitCodeForJobStatus(status: string | undefined, kind: 'build' | 'run'): number { + switch (status) { + case 'SUCCEEDED': + return 0; + case 'TIMED-OUT': + case 'TIMING-OUT': + return kind === 'build' ? CommandExitCodes.BuildTimedOut : CommandExitCodes.RunTimedOut; + case 'ABORTED': + case 'ABORTING': + return kind === 'build' ? CommandExitCodes.BuildAborted : CommandExitCodes.RunAborted; + default: + return kind === 'build' ? CommandExitCodes.BuildFailed : CommandExitCodes.RunFailed; + } +} + +export interface WaitForJobOptions { + apifyClient: ApifyClient; + jobId: string; + kind: 'build' | 'run'; + /** Poll interval in milliseconds. Defaults to 2000. */ + pollIntervalMillis?: number; + /** Maximum time to wait before giving up. Defaults to no limit. */ + maxWaitMillis?: number; +} + +export async function waitForTerminalStatus(options: WaitForJobOptions): Promise { + const { apifyClient, jobId, kind, pollIntervalMillis = 2000, maxWaitMillis } = options; + const startedAt = Date.now(); + + while (true) { + const job = + kind === 'build' + ? ((await apifyClient.build(jobId).get()) as Build | undefined) + : ((await apifyClient.run(jobId).get()) as ActorRun | undefined); + + if (!job) { + throw new Error(`${kind === 'build' ? 'Build' : 'Run'} with ID "${jobId}" was not found.`); + } + + if (isTerminalStatus(job.status)) { + return job; + } + + if (maxWaitMillis && Date.now() - startedAt >= maxWaitMillis) { + return { ...job, status: 'TIMED-OUT' } as typeof job; + } + + const sleepMillis = maxWaitMillis + ? Math.min(pollIntervalMillis, maxWaitMillis - (Date.now() - startedAt)) + : pollIntervalMillis; + await new Promise((resolve) => setTimeout(resolve, Math.max(0, sleepMillis))); + } +} + +export async function fetchLogTail(apifyClient: ApifyClient, jobId: string, maxLines = 20): Promise { + try { + const log = await apifyClient.log(jobId).get(); + if (!log) return []; + + const lines = log.split('\n').filter((line) => line.length > 0); + return lines.slice(-maxLines); + } catch { + return []; + } +} + +export function consoleActorUrl(actorId: string): string { + return `https://console.apify.com/actors/${actorId}`; +} + +export function consoleRunUrl(actorId: string, runId: string): string { + return `https://console.apify.com/actors/${actorId}/runs/${runId}`; +} + +export function consoleBuildUrl(actorId: string, buildNumber: string): string { + return `https://console.apify.com/actors/${actorId}#/builds/${buildNumber}`; +} + +export function consoleDatasetUrl(datasetId: string): string { + return `https://console.apify.com/storage/datasets/${datasetId}`; +} + +export function consoleKeyValueStoreUrl(storeId: string): string { + return `https://console.apify.com/storage/key-value-stores/${storeId}`; +} + +function statusColor(status: string): string { + if (status === 'SUCCEEDED') return chalk.green(status); + if (status === 'RUNNING' || status === 'READY') return chalk.blue(status); + return chalk.red(status); +} + +export interface ResultSummaryOptions { + resultLabel: string; // e.g. "Apify push result" + overallStatus: 'SUCCEEDED' | 'FAILED' | 'ABORTED' | 'TIMED-OUT' | 'RUNNING'; + lines: { label: string; value: string }[]; + links?: { label: string; url: string }[]; + errorReason?: string[]; +} + +export function formatResultSummary(options: ResultSummaryOptions): string { + const out: string[] = []; + out.push(`${chalk.bold(options.resultLabel)}: ${statusColor(options.overallStatus)}`); + out.push(''); + + for (const line of options.lines) { + out.push(`${line.label}: ${line.value}`); + } + + if (options.links?.length) { + out.push(''); + for (const link of options.links) { + out.push(`${link.label}: ${chalk.blue(link.url)}`); + } + } + + if (options.errorReason?.length) { + out.push(''); + out.push(chalk.red('Reason:')); + for (const line of options.errorReason) { + out.push(line); + } + } + + return out.join('\n'); +} diff --git a/src/lib/commands/run-on-cloud.ts b/src/lib/commands/run-on-cloud.ts index 0f2455cb5..8c421cb99 100644 --- a/src/lib/commands/run-on-cloud.ts +++ b/src/lib/commands/run-on-cloud.ts @@ -32,6 +32,12 @@ export interface RunOnCloudOptions { silent?: boolean; waitForRunToFinish?: boolean; printRunLogs?: boolean; + /** + * When true, suppresses the final "Actor finished/failed" status line and the + * implicit `process.exitCode` write at the end of the generator. Use this when + * the caller renders its own final result summary and owns the exit code. + */ + suppressFinalStatus?: boolean; } export async function* runActorOrTaskOnCloud(apifyClient: ApifyClient, options: RunOnCloudOptions) { @@ -45,6 +51,7 @@ export async function* runActorOrTaskOnCloud(apifyClient: ApifyClient, options: silent, waitForRunToFinish, printRunLogs, + suppressFinalStatus, } = options; const clientMethod = type === 'Actor' ? 'actor' : 'task'; @@ -151,16 +158,16 @@ export async function* runActorOrTaskOnCloud(apifyClient: ApifyClient, options: } } - if (!silent) { + if (!suppressFinalStatus) { if (run.status === ACTOR_JOB_STATUSES.SUCCEEDED) { - success({ message: `${type} finished.` }); + if (!silent) success({ message: `${type} finished.` }); } else if (run.status === ACTOR_JOB_STATUSES.RUNNING) { - warning({ message: `${type} is still running!` }); + if (!silent) warning({ message: `${type} is still running!` }); } else if (run.status === ACTOR_JOB_STATUSES.ABORTED || run.status === ACTOR_JOB_STATUSES.ABORTING) { - warning({ message: `${type} was aborted!` }); + if (!silent) warning({ message: `${type} was aborted!` }); process.exitCode = CommandExitCodes.RunAborted; } else { - error({ message: `${type} failed!` }); + if (!silent) error({ message: `${type} failed!` }); process.exitCode = CommandExitCodes.RunFailed; } } diff --git a/src/lib/consts.ts b/src/lib/consts.ts index e0fb869ef..0f09a72e4 100644 --- a/src/lib/consts.ts +++ b/src/lib/consts.ts @@ -77,6 +77,7 @@ export enum CommandExitCodes { MissingAuth = 1, BuildTimedOut = 2, + RunTimedOut = 2, BuildAborted = 3, RunAborted = 3, From 46099be09752493bad6883e9e5676b81d74f5911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Fri, 29 May 2026 12:33:33 +0000 Subject: [PATCH 2/2] fix(push): exit 0 when build is still RUNNING after wait window The earlier change made `apify push` exit non-zero whenever the build had not reached SUCCEEDED, including non-terminal RUNNING/READY states. That broke the E2E lifecycle tests and any CI that treats a successful upload + in-progress build as a successful push. Only treat a terminal, non-SUCCEEDED status as a push failure. --- src/commands/actors/push.ts | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 45566dfb5..0d8f281e3 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -12,7 +12,12 @@ import { createHmacSignature } from '@apify/utilities'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; -import { exitCodeForJobStatus, fetchLogTail, formatResultSummary } from '../../lib/commands/agent-output.js'; +import { + exitCodeForJobStatus, + fetchLogTail, + formatResultSummary, + isTerminalStatus, +} from '../../lib/commands/agent-output.js'; import { CommandExitCodes, DEPRECATED_LOCAL_CONFIG_NAME, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; import { sumFilesSizeInBytes } from '../../lib/files.js'; import { useAbortJobOnSignal } from '../../lib/hooks/useAbortJobOnSignal.js'; @@ -399,18 +404,15 @@ Skipping push. Use --force to override.`, } const buildStatus = build.status as string; - const isTerminal = - buildStatus === ACTOR_JOB_STATUSES.SUCCEEDED || - buildStatus === ACTOR_JOB_STATUSES.FAILED || - buildStatus === ACTOR_JOB_STATUSES.ABORTED || - buildStatus === ACTOR_JOB_STATUSES.TIMED_OUT; - - const ok = build.status === ACTOR_JOB_STATUSES.SUCCEEDED; - const exitCode = exitCodeForJobStatus(build.status, 'build'); + const isTerminal = isTerminalStatus(buildStatus); + const buildSucceeded = buildStatus === ACTOR_JOB_STATUSES.SUCCEEDED; + // A non-terminal status (RUNNING/READY) means the push succeeded — only a terminal-but-not-SUCCEEDED status is a failure. + const ok = buildSucceeded || !isTerminal; + const exitCode = ok ? 0 : exitCodeForJobStatus(build.status, 'build'); const logTail = ok ? [] : await fetchLogTail(apifyClient, build.id); - const buildStatusLabel = isTerminal ? (build.status as string) : 'RUNNING'; - const overallStatus = ok ? 'SUCCEEDED' : (buildStatusLabel as never); + const buildStatusLabel = isTerminal ? buildStatus : 'RUNNING'; + const overallStatus = buildSucceeded ? 'SUCCEEDED' : (buildStatusLabel as never); if (this.flags.json) { printJsonToStdout({ @@ -431,7 +433,7 @@ Skipping push. Use --force to override.`, : { error: { phase: 'build', - message: isTerminal ? 'Actor build did not succeed' : 'Actor build did not reach a terminal status', + message: 'Actor build did not succeed', logTail, }, }),