diff --git a/docs/reference.md b/docs/reference.md index 622341de6..74477da92 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1564,7 +1564,7 @@ DESCRIPTION Customize with --memory and --timeout flags. USAGE - $ apify task run [-b ] [-m ] + $ apify task run [-b ] [--json] [-m ] [-t ] ARGUMENTS @@ -1574,6 +1574,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/src/commands/actors/call.ts b/src/commands/actors/call.ts index 451a1ab11..6cf20132f 100644 --- a/src/commands/actors/call.ts +++ b/src/commands/actors/call.ts @@ -10,20 +10,17 @@ import { } from 'apify-client'; import chalk from 'chalk'; +import { ACTOR_JOB_STATUSES } from '@apify/consts'; + 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 { getInputOverride } from '../../lib/commands/resolve-input.js'; import { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands/run-on-cloud.js'; +import { finalizeRun, runUrl } from '../../lib/commands/run-result.js'; import { CommandExitCodes, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; import { error, simpleLog } from '../../lib/outputs.js'; -import { - getLocalConfig, - getLocalUserInfo, - getLoggedClientOrThrow, - printJsonToStdout, - TimestampFormatter, -} from '../../lib/utils.js'; +import { getLocalConfig, getLocalUserInfo, getLoggedClientOrThrow, TimestampFormatter } from '../../lib/utils.js'; export class ActorsCallCommand extends ApifyCommand { static override name = 'call' as const; @@ -146,9 +143,6 @@ export class ActorsCallCommand extends ApifyCommand { let runStarted = false; let run: ActorRun; - let url: string; - let datasetUrl: string; - const iterator = runActorOrTaskOnCloud(apifyClient, { actorOrTaskData: { id: actorId, @@ -170,8 +164,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 url = runUrl(actorId, yieldedRun.id); const message: string[] = [`${chalk.yellow('Started')}: ${TimestampFormatter.display(yieldedRun.startedAt)}`]; @@ -220,23 +213,19 @@ export class ActorsCallCommand extends ApifyCommand { } } + await finalizeRun({ + apifyClient, + run: run!, + operation: 'call', + json: this.flags.json, + silent: this.flags.silent, + }); + if (this.flags.json) { - printJsonToStdout(run!); return; } - if (!this.flags.silent) { - simpleLog({ - message: [ - '', - `${chalk.blue('Export results')}: ${datasetUrl!}`, - `${chalk.blue('View on Apify Console')}: ${url!}`, - ].join('\n'), - stdout: true, - }); - } - - if (this.flags.outputDataset) { + if (this.flags.outputDataset && run!.status === ACTOR_JOB_STATUSES.SUCCEEDED) { const datasetId = run!.defaultDatasetId; let info: Dataset; diff --git a/src/commands/task/run.ts b/src/commands/task/run.ts index 5f3acdad4..abd81315f 100644 --- a/src/commands/task/run.ts +++ b/src/commands/task/run.ts @@ -1,10 +1,9 @@ -import type { ApifyClient, TaskStartOptions } from 'apify-client'; -import chalk from 'chalk'; +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 { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands/run-on-cloud.js'; -import { simpleLog } from '../../lib/outputs.js'; +import { finalizeRun } from '../../lib/commands/run-result.js'; import { getLocalUserInfo, getLoggedClientOrThrow } from '../../lib/utils.js'; export class TaskRunCommand extends ApifyCommand { @@ -29,6 +28,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 +60,7 @@ export class TaskRunCommand extends ApifyCommand { runOpts.memory = this.flags.memory; } - let url: string; - let datasetUrl: string; + let run!: ActorRun; const iterator = runActorOrTaskOnCloud(apifyClient, { actorOrTaskData: { @@ -70,22 +70,15 @@ export class TaskRunCommand extends ApifyCommand { }, runOptions: runOpts, type: 'Task', + waitForRunToFinish: true, printRunLogs: 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; } - simpleLog({ - message: [ - '', - `${chalk.blue('Export results')}: ${datasetUrl!}`, - `${chalk.blue('View on Apify Console')}: ${url!}`, - ].join('\n'), - stdout: true, - }); + await finalizeRun({ apifyClient, run, operation: 'task-run', json: this.flags.json }); } private async resolveTaskId(client: ApifyClient, usernameOrId: string) { diff --git a/src/lib/commands/run-on-cloud.ts b/src/lib/commands/run-on-cloud.ts index 0f2455cb5..6127cd25a 100644 --- a/src/lib/commands/run-on-cloud.ts +++ b/src/lib/commands/run-on-cloud.ts @@ -6,9 +6,8 @@ import chalk from 'chalk'; import { ACTOR_JOB_STATUSES } from '@apify/consts'; import { Flags } from '../command-framework/flags.js'; -import { CommandExitCodes } from '../consts.js'; import { useAbortJobOnSignal } from '../hooks/useAbortJobOnSignal.js'; -import { error, run as runLog, success, warning } from '../outputs.js'; +import { run as runLog, warning } from '../outputs.js'; import { outputJobLog } from '../utils.js'; import { resolveInput } from './resolve-input.js'; @@ -151,21 +150,8 @@ export async function* runActorOrTaskOnCloud(apifyClient: ApifyClient, options: } } - if (!silent) { - if (run.status === ACTOR_JOB_STATUSES.SUCCEEDED) { - success({ message: `${type} finished.` }); - } else if (run.status === ACTOR_JOB_STATUSES.RUNNING) { - 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!` }); - process.exitCode = CommandExitCodes.RunAborted; - } else { - error({ message: `${type} failed!` }); - process.exitCode = CommandExitCodes.RunFailed; - } - } - - // Return the finished run + // Return the finished run. Presenting the final status and setting the exit code is the + // caller's responsibility. yield run; } diff --git a/src/lib/commands/run-result.ts b/src/lib/commands/run-result.ts new file mode 100644 index 000000000..c041f4493 --- /dev/null +++ b/src/lib/commands/run-result.ts @@ -0,0 +1,201 @@ +import process from 'node:process'; + +import type { ActorRun, ApifyClient } from 'apify-client'; +import chalk from 'chalk'; + +import { ACTOR_JOB_STATUSES } from '@apify/consts'; + +import { CommandExitCodes } from '../consts.js'; +import { simpleLog } from '../outputs.js'; +import { printJsonToStdout } from '../utils.js'; + +/** Which command produced the run, used for labels and the JSON `operation` field. */ +export type RunResultOperation = 'call' | 'task-run'; + +const OPERATION_LABELS: Record = { + 'call': 'Apify call', + 'task-run': 'Apify task run', +}; + +/** How many trailing log lines to surface as the failure reason. */ +const LOG_TAIL_LINES = 10; + +const CONSOLE_BASE_URL = 'https://console.apify.com'; + +function actorUrl(actorId: string) { + return `${CONSOLE_BASE_URL}/actors/${actorId}`; +} + +export function runUrl(actorId: string, runId: string) { + return `${CONSOLE_BASE_URL}/actors/${actorId}/runs/${runId}`; +} + +function datasetUrl(datasetId: string) { + return `${CONSOLE_BASE_URL}/storage/datasets/${datasetId}`; +} + +function isSucceeded(run: ActorRun): boolean { + return run.status === ACTOR_JOB_STATUSES.SUCCEEDED; +} + +/** + * Maps a terminal run status to the process exit code the CLI should report so that + * callers (CI, shell chains, agents) can tell a started run from a successful one. + * Failed runs propagate the Actor's own exit code when present (mirroring `apify run`). + */ +export function getRunExitCode(run: ActorRun): number { + switch (run.status) { + case ACTOR_JOB_STATUSES.SUCCEEDED: + return 0; + case ACTOR_JOB_STATUSES.ABORTED: + case ACTOR_JOB_STATUSES.ABORTING: + return CommandExitCodes.RunAborted; + case ACTOR_JOB_STATUSES.TIMED_OUT: + case ACTOR_JOB_STATUSES.TIMING_OUT: + return CommandExitCodes.RunTimedOut; + default: + return run.exitCode && run.exitCode !== 0 ? run.exitCode : CommandExitCodes.RunFailed; + } +} + +/** A generic, status-specific reason used when the platform did not provide a status message. */ +function genericReason(run: ActorRun): string { + switch (run.status) { + case ACTOR_JOB_STATUSES.ABORTED: + case ACTOR_JOB_STATUSES.ABORTING: + return 'Actor run was aborted'; + case ACTOR_JOB_STATUSES.TIMED_OUT: + case ACTOR_JOB_STATUSES.TIMING_OUT: + return 'Actor run timed out'; + default: + return 'Actor run failed'; + } +} + +/** + * Fetches the last few log lines of a finished run to explain a failure. Returns an empty + * array on success or when the log cannot be retrieved (best-effort, never throws). + */ +export async function fetchRunLogTail(apifyClient: ApifyClient, run: ActorRun): Promise { + if (isSucceeded(run)) { + return []; + } + + let log: string | undefined; + + try { + log = await apifyClient.log(run.id).get(); + } catch { + return []; + } + + if (!log) { + return []; + } + + return log + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0) + .slice(-LOG_TAIL_LINES); +} + +export interface RunResultOptions { + run: ActorRun; + operation: RunResultOperation; + logTail: string[]; +} + +/** Builds the structured `--json` payload so agents can reliably branch on the final status. */ +export function buildRunResultJson({ run, operation, logTail }: RunResultOptions) { + const ok = isSucceeded(run); + + return { + ok, + operation, + actor: { + id: run.actId, + url: actorUrl(run.actId), + }, + run: { + id: run.id, + status: run.status, + exitCode: run.exitCode ?? null, + url: runUrl(run.actId, run.id), + }, + storage: { + defaultDatasetId: run.defaultDatasetId, + defaultKeyValueStoreId: run.defaultKeyValueStoreId, + datasetUrl: datasetUrl(run.defaultDatasetId), + }, + error: ok + ? null + : { + phase: 'run', + message: run.statusMessage || genericReason(run), + logTail, + }, + exitCode: getRunExitCode(run), + }; +} + +/** Prints the human-readable final result summary to stdout. */ +export function printRunResultSummary({ run, operation, logTail }: RunResultOptions) { + const ok = isSucceeded(run); + const statusColor = ok ? chalk.green : chalk.red; + + const message: string[] = [ + `${OPERATION_LABELS[operation]} result: ${statusColor(run.status)}`, + '', + `${chalk.yellow('Run')}: ${statusColor(run.status)}`, + `${chalk.yellow('Actor ID')}: ${run.actId}`, + `${chalk.yellow('Run ID')}: ${run.id}`, + `${chalk.yellow('Build number')}: ${run.buildNumber}`, + ]; + + if (!ok && run.exitCode != null) { + message.push(`${chalk.yellow('Exit code')}: ${run.exitCode}`); + } + + message.push( + `${chalk.yellow('Dataset ID')}: ${run.defaultDatasetId}`, + `${chalk.yellow('Key-value store ID')}: ${run.defaultKeyValueStoreId}`, + '', + `${chalk.blue('Run URL')}: ${runUrl(run.actId, run.id)}`, + `${chalk.blue('Dataset URL')}: ${datasetUrl(run.defaultDatasetId)}`, + ); + + if (!ok) { + message.push('', `${chalk.yellow('Reason')}:`, run.statusMessage || genericReason(run), ...logTail); + } + + simpleLog({ message: message.join('\n'), stdout: true }); +} + +export interface FinalizeRunOptions { + apifyClient: ApifyClient; + run: ActorRun; + operation: RunResultOperation; + json: boolean; + silent?: boolean; +} + +/** + * Sets the process exit code from the run's final status and prints the outcome — either the + * structured `--json` payload or the human-readable summary. Every caller that waits for a run + * to finish should funnel through this so the final-status contract stays consistent. + */ +export async function finalizeRun({ apifyClient, run, operation, json, silent }: FinalizeRunOptions) { + process.exitCode = getRunExitCode(run); + + if (json) { + const logTail = await fetchRunLogTail(apifyClient, run); + printJsonToStdout(buildRunResultJson({ run, operation, logTail })); + return; + } + + if (!silent) { + const logTail = await fetchRunLogTail(apifyClient, run); + printRunResultSummary({ run, operation, logTail }); + } +} 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, diff --git a/test/e2e/commands/runs/lifecycle.test.ts b/test/e2e/commands/runs/lifecycle.test.ts index 9b7c50b86..be507a14f 100644 --- a/test/e2e/commands/runs/lifecycle.test.ts +++ b/test/e2e/commands/runs/lifecycle.test.ts @@ -151,7 +151,10 @@ describe('[e2e][api] runs lifecycle', () => { expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); const data = JSON.parse(result.stdout); - expect(data.status).toBe('SUCCEEDED'); + expect(data.ok).toBe(true); + expect(data.operation).toBe('call'); + expect(data.run.status).toBe('SUCCEEDED'); + expect(data.exitCode).toBe(0); }, 120_000); it('pull — downloads actor source', async () => { diff --git a/test/local/lib/run-result.test.ts b/test/local/lib/run-result.test.ts new file mode 100644 index 000000000..1414cccfd --- /dev/null +++ b/test/local/lib/run-result.test.ts @@ -0,0 +1,195 @@ +import type { ActorRun, ApifyClient } from 'apify-client'; + +import { + buildRunResultJson, + fetchRunLogTail, + finalizeRun, + getRunExitCode, + printRunResultSummary, +} from '../../../src/lib/commands/run-result.js'; + +const makeRun = (overrides: Partial = {}): ActorRun => + ({ + id: 'run123', + actId: 'actor456', + status: 'SUCCEEDED', + buildNumber: '0.0.1', + defaultDatasetId: 'dataset789', + defaultKeyValueStoreId: 'kvs012', + ...overrides, + }) as ActorRun; + +const fakeClientWithLog = (log: string | undefined) => + ({ + log: () => ({ + get: async () => log, + }), + }) as unknown as ApifyClient; + +const fakeClientThatThrows = () => + ({ + log: () => ({ + get: async () => { + throw new Error('boom'); + }, + }), + }) as unknown as ApifyClient; + +// eslint-disable-next-line no-control-regex +const stripAnsi = (value: string) => value.replace(/\[[0-9;]*m/g, ''); + +describe('getRunExitCode', () => { + it('returns 0 for a succeeded run', () => { + expect(getRunExitCode(makeRun({ status: 'SUCCEEDED' }))).toBe(0); + }); + + it('returns 3 for an aborted run', () => { + expect(getRunExitCode(makeRun({ status: 'ABORTED' }))).toBe(3); + }); + + it('returns 2 for a timed-out run', () => { + expect(getRunExitCode(makeRun({ status: 'TIMED-OUT' }))).toBe(2); + }); + + it('propagates the Actor exit code for a failed run', () => { + expect(getRunExitCode(makeRun({ status: 'FAILED', exitCode: 10 }))).toBe(10); + }); + + it('falls back to 1 for a failed run without an exit code', () => { + expect(getRunExitCode(makeRun({ status: 'FAILED' }))).toBe(1); + }); +}); + +describe('buildRunResultJson', () => { + it('marks a succeeded run as ok with no error', () => { + const json = buildRunResultJson({ run: makeRun(), operation: 'call', logTail: [] }); + + expect(json).toMatchObject({ + ok: true, + operation: 'call', + actor: { id: 'actor456', url: 'https://console.apify.com/actors/actor456' }, + run: { id: 'run123', status: 'SUCCEEDED', url: 'https://console.apify.com/actors/actor456/runs/run123' }, + storage: { defaultDatasetId: 'dataset789', defaultKeyValueStoreId: 'kvs012' }, + error: null, + exitCode: 0, + }); + }); + + it('reports a failed run with the error block and log tail', () => { + const json = buildRunResultJson({ + run: makeRun({ status: 'FAILED', exitCode: 1, statusMessage: 'Actor process exited with code 1' }), + operation: 'task-run', + logTail: ['last log line'], + }); + + expect(json.ok).toBe(false); + expect(json.operation).toBe('task-run'); + expect(json.run.status).toBe('FAILED'); + expect(json.exitCode).toBe(1); + expect(json.error).toEqual({ + phase: 'run', + message: 'Actor process exited with code 1', + logTail: ['last log line'], + }); + }); +}); + +describe('fetchRunLogTail', () => { + it('returns an empty array for a succeeded run without fetching', async () => { + const tail = await fetchRunLogTail(fakeClientThatThrows(), makeRun({ status: 'SUCCEEDED' })); + expect(tail).toEqual([]); + }); + + it('returns the last lines for a failed run', async () => { + const log = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`).join('\n'); + const tail = await fetchRunLogTail(fakeClientWithLog(log), makeRun({ status: 'FAILED' })); + + expect(tail).toHaveLength(10); + expect(tail[0]).toBe('line 6'); + expect(tail.at(-1)).toBe('line 15'); + }); + + it('returns an empty array when the log cannot be fetched', async () => { + const tail = await fetchRunLogTail(fakeClientThatThrows(), makeRun({ status: 'FAILED' })); + expect(tail).toEqual([]); + }); +}); + +describe('printRunResultSummary', () => { + it('prints a failed summary including exit code and reason', () => { + const lines: string[] = []; + const spy = vi.spyOn(console, 'log').mockImplementation((msg) => lines.push(String(msg))); + + printRunResultSummary({ + run: makeRun({ status: 'FAILED', exitCode: 1, statusMessage: 'Actor process exited with code 1' }), + operation: 'call', + logTail: ['some failing log'], + }); + + spy.mockRestore(); + + const output = stripAnsi(lines.join('\n')); + expect(output).toContain('Apify call result: FAILED'); + expect(output).toContain('Exit code: 1'); + expect(output).toContain('Reason:'); + expect(output).toContain('Actor process exited with code 1'); + expect(output).toContain('some failing log'); + }); + + it('omits the exit code and reason for a succeeded run', () => { + const lines: string[] = []; + const spy = vi.spyOn(console, 'log').mockImplementation((msg) => lines.push(String(msg))); + + printRunResultSummary({ run: makeRun(), operation: 'call', logTail: [] }); + + spy.mockRestore(); + + const output = stripAnsi(lines.join('\n')); + expect(output).toContain('Apify call result: SUCCEEDED'); + expect(output).not.toContain('Exit code:'); + expect(output).not.toContain('Reason:'); + }); +}); + +describe('finalizeRun', () => { + const originalExitCode = process.exitCode; + + afterEach(() => { + process.exitCode = originalExitCode; + }); + + it('sets the exit code and prints the JSON payload in JSON mode', async () => { + const lines: string[] = []; + const spy = vi.spyOn(console, 'log').mockImplementation((msg) => lines.push(String(msg))); + + await finalizeRun({ + apifyClient: fakeClientWithLog(undefined), + run: makeRun({ status: 'FAILED', exitCode: 1 }), + operation: 'call', + json: true, + }); + + spy.mockRestore(); + + expect(process.exitCode).toBe(1); + const parsed = JSON.parse(lines.join('\n')); + expect(parsed).toMatchObject({ ok: false, operation: 'call', run: { status: 'FAILED' }, exitCode: 1 }); + }); + + it('prints nothing but still sets the exit code in silent mode', async () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await finalizeRun({ + apifyClient: fakeClientWithLog(undefined), + run: makeRun({ status: 'ABORTED' }), + operation: 'call', + json: false, + silent: true, + }); + + spy.mockRestore(); + + expect(process.exitCode).toBe(3); + expect(spy).not.toHaveBeenCalled(); + }); +});