Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1564,7 +1564,7 @@ DESCRIPTION
Customize with --memory and --timeout flags.

USAGE
$ apify task run <taskId> [-b <value>] [-m <value>]
$ apify task run <taskId> [-b <value>] [--json] [-m <value>]
[-t <value>]

ARGUMENTS
Expand All @@ -1574,6 +1574,7 @@ ARGUMENTS
FLAGS
-b, --build=<value> Tag or number of the build to run
(e.g. "latest" or "1.2.34").
--json Format the command output as JSON
-m, --memory=<value> Amount of memory allocated for the
Task run, in megabytes.
-t, --timeout=<value> Timeout for the Task run in seconds.
Expand Down
39 changes: 14 additions & 25 deletions src/commands/actors/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ActorsCallCommand> {
static override name = 'call' as const;
Expand Down Expand Up @@ -146,9 +143,6 @@ export class ActorsCallCommand extends ApifyCommand<typeof ActorsCallCommand> {
let runStarted = false;
let run: ActorRun;

let url: string;
let datasetUrl: string;

const iterator = runActorOrTaskOnCloud(apifyClient, {
actorOrTaskData: {
id: actorId,
Expand All @@ -170,8 +164,7 @@ export class ActorsCallCommand extends ApifyCommand<typeof ActorsCallCommand> {

// 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)}`];

Expand Down Expand Up @@ -220,23 +213,19 @@ export class ActorsCallCommand extends ApifyCommand<typeof ActorsCallCommand> {
}
}

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;
Expand Down
23 changes: 8 additions & 15 deletions src/commands/task/run.ts
Original file line number Diff line number Diff line change
@@ -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<typeof TaskRunCommand> {
Expand All @@ -29,6 +28,8 @@ export class TaskRunCommand extends ApifyCommand<typeof TaskRunCommand> {

static override flags = SharedRunOnCloudFlags('Task');

static override enableJsonFlag = true;

static override args = {
taskId: Args.string({
required: true,
Expand Down Expand Up @@ -59,8 +60,7 @@ export class TaskRunCommand extends ApifyCommand<typeof TaskRunCommand> {
runOpts.memory = this.flags.memory;
}

let url: string;
let datasetUrl: string;
let run!: ActorRun;

const iterator = runActorOrTaskOnCloud(apifyClient, {
actorOrTaskData: {
Expand All @@ -70,22 +70,15 @@ export class TaskRunCommand extends ApifyCommand<typeof TaskRunCommand> {
},
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) {
Expand Down
20 changes: 3 additions & 17 deletions src/lib/commands/run-on-cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}

Expand Down
201 changes: 201 additions & 0 deletions src/lib/commands/run-result.ts
Original file line number Diff line number Diff line change
@@ -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<RunResultOperation, string> = {
'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<string[]> {
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 });
}
}
1 change: 1 addition & 0 deletions src/lib/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export enum CommandExitCodes {
MissingAuth = 1,

BuildTimedOut = 2,
RunTimedOut = 2,

BuildAborted = 3,
RunAborted = 3,
Expand Down
Loading
Loading