Skip to main content

Understanding Runs and Actions

The Run–Action object model

When you retrieve a remote execution, Run is not a collection containing every action spawned during that execution. A remote.Run wraps a protobuf Run and requires that protobuf to contain one action field. During construction, Run.__post_init__ creates that field's lightweight Action wrapper as run.action; if the field is absent, construction raises RuntimeError("Run does not have an action").

The relationship is therefore:

Run
└── action: Action # the root action
└── details(): ActionDetails # full details for that action

Action.listall(for_run_name=...)
└── Action, Action, ... # all actions associated with the run

Use the public exports rather than the private remote._action module:

from flyte.remote import Action, ActionDetails, Run, RunDetails

remote/__init__.py re-exports these four classes. The protobuf-backed implementations for Action and ActionDetails are in remote/_action.py; Run and RunDetails are implemented in remote/_run.py.

Root action versus all actions

A run-level lookup gives you the run's root action. For example, Run.get(name) obtains RunDetails, then reconstructs a Run whose action contains the root action's identifier, metadata, and status:

run = Run.get(name="my_run")
print(run.name)
print(run.action.name)
print(run.phase)

Run.name, Run.phase, and Run.raw_phase all read through run.action. Run-level wait, show_logs, done, and monitoring are similarly views of that root action. RunDetails has the same shape at the detail level: its __post_init__ constructs exactly one action_details = ActionDetails(self.pb2.action).

To inspect every action associated with a run, query actions explicitly:

for action in Action.listall(for_run_name="my_run"):
print(action.name, action.task_name, action.phase)

The CLI makes this distinction explicit. flyte get run my_run uses RunDetails.get(name=name) and documents that only the root action is shown. flyte get action my_run uses Action.listall(for_run_name=run_name); adding an action name changes the lookup to Action.get(run_name=run_name, name=action_name):

import flyte.remote as remote

cfg.init(project=project, domain=domain)

if action_name:
obj = remote.Action.get(run_name=run_name, name=action_name)
else:
obj = remote.Action.listall(for_run_name=run_name)

Here cfg.init is the CLI's setup path. In application code, initialize Flyte before using the RPC-backed remote methods. ensure_client() is called by the list and get implementations.

Action.listall calls the run service's ListActions RPC with a RunIdentifier built from the configured organization, project, domain, and supplied run name. It follows response tokens until the server returns no token and yields lightweight Action objects. Its default sort is ("created_at", "asc") and each request asks for 100 actions. Run.listall uses the corresponding ListRuns RPC, defaults to limit=100, and caps each request at 100 while stopping after the requested total.

Both list methods expose a filters argument, but the current request construction does not place that argument into the protobuf request. Do not assume that passing filters changes the server-side listing until that implementation changes.

Lightweight handles and full details

Use the lightweight class when you need an identifier, name, phase, or a lifecycle operation. Use its detail class when you need metadata, status fields, errors, attempts, or data.

Lightweight handleFull-detail objectHow the detail object is obtained
RunRunDetailsrun.details() lazily calls RunDetails.get_details; Run.get(name) stores the fetched details immediately
ActionActionDetailsaction.details() lazily calls ActionDetails.get_details; Action.get(...) stores the fetched details immediately

For a named run, you can request details directly:

run_details = RunDetails.get(name="my_run")
print(run_details.name)
print(run_details.action_id)
print(run_details.task_name)

For a named action, provide both names in the current implementation:

action_details = ActionDetails.get(run_name="my_run", name="my_action")
print(action_details.name)
print(action_details.run_name)
print(action_details.task_name)

RunDetails.get builds a RunIdentifier using the initialized organization, project, domain, and the supplied name, then calls GetRunDetails. ActionDetails.get similarly builds an ActionIdentifier and calls GetActionDetails through get_details. The detail object retains the protobuf in pb2; ActionDetails exposes it through metadata and status properties.

The uri-shaped parameters on Action.get and ActionDetails.get should not be mistaken for implemented URI resolution. ActionDetails.get checks that either uri or both run_name and name are present, but then constructs the identifier from run_name and name; it does not parse the URI. Action.get likewise fetches using the explicit name fields. Use explicit run_name and name with the current implementation.

Monitoring the root or a selected action

Inspect the current phase with either the string property or the raw protobuf enum:

run = Run.get(name="my_run")

print(run.phase) # delegates to run.action.phase
print(run.raw_phase) # delegates to run.action.raw_phase
print(run.done()) # delegates to run.action.done()

Action and ActionDetails expose the same phase-related concepts. ActionDetails.is_running is true only when the status phase is PHASE_RUNNING. The terminal-state helper used by Action.done(), ActionDetails.done(), and consequently Run.done() recognizes FAILED, SUCCEEDED, ABORTED, and TIMED_OUT.

For a blocking wait, choose either the run facade or a specific action. The run method delegates directly to its root action:

run = Run.get(name="my_run")
run.wait(quiet=True)

# A selected action can be monitored independently.
action = Action.get(run_name="my_run", name="my_action")
action.wait(quiet=True, wait_for="terminal")

The syncified APIs also have asynchronous facades. The CLI uses the generated .aio method when it streams logs:

async def _run_log_view(obj):
task = asyncio.create_task(
obj.show_logs.aio(
max_lines=lines,
show_ts=show_ts,
raw=not pretty,
attempt=attempt,
filter_system=filter_system,
)
)
await task

Action.watch streams ActionDetails updates through ActionDetails.watch. It updates the action's _details cache for every event and stops at the first terminal update. Its wait_for parameter can stop earlier at "running" or "logs-ready"; the default is "terminal". ActionDetails.watch(action_id) performs the WatchActionDetails RPC, yields a new detail object for each response, and returns after yielding a terminal detail. A gRPC CANCELLED error is suppressed by that method.

At run level, Run.watch returns the root action's watch operation, so it monitors the root action rather than enumerating child actions. To watch a particular child action, call Action.watch on the selected action instead. Run.wait has only the run-level "terminal" and "running" choices in its declared signature; Action.wait additionally accepts "logs-ready".

Inspecting status, errors, attempts, and logs

Once you have ActionDetails, status inspection does not require reading the protobuf directly:

details = ActionDetails.get(run_name="my_run", name="my_action")

print(details.phase)
print(details.is_running)
print(details.runtime)
print(details.attempts)
print(details.error_info)
print(details.abort_info)

runtime computes the interval from status.start_time to status.end_time when an end time exists; otherwise it computes the interval from the start time to the current UTC time. attempts is the status attempt count. logs_available(attempt=None) checks the latest attempt by default, indexes the protobuf attempts with one-based attempt numbers, and returns False for an unavailable attempt.

show_logs waits for logs-ready when the action is neither running nor done, chooses the latest attempt when attempt is omitted, and passes the action identifier and display options to Logs.create_viewer. Run-level show_logs invokes the same operation on run.action. Consequently, these two CLI branches target different things:

if action_name:
obj = remote.Action.get(run_name=run_name, name=action_name)
else:
obj = remote.Run.get(run_name)

asyncio.run(_run_log_view(obj))

The first branch shows logs for the named action; the second shows logs for the run's root action. In Action.show_logs, attempt=0 is also treated as omitted because the implementation uses if not attempt.

Inputs and outputs belong to the selected action

ActionDetails.inputs() and ActionDetails.outputs() retrieve action data lazily. _cache_data calls GetActionData, uses the task or trace interface when present to convert protobuf literals into native Python values, and wraps the results as ActionInputs and tuple-like ActionOutputs. RunDetails.inputs() and RunDetails.outputs() simply delegate to its single action_details, so run-level I/O is root-action I/O.

async def read_io(details: RunDetails | ActionDetails):
inputs = await details.inputs()
try:
outputs = await details.outputs()
except Exception:
outputs = "not yet available"
return inputs, outputs

Inputs may be available while an action is still running. Outputs are different: if they are not already cached and the action is not terminal, ActionDetails.outputs() raises:

RuntimeError: Action is not in a terminal state, outputs are not available. Please wait for the action to complete.

The CLI's get io command uses this distinction: it always reads inputs, attempts outputs, and displays "not yet available" if output retrieval fails while still allowing inputs to be shown. An internal image-builder consumer follows the same root-action model by waiting on a Run, fetching run.details(), checking run_details.action_details.raw_phase, and then reading await run_details.outputs() after success.

Client context and lifecycle limitations

Initialize the remote client before calling Run.get, Run.listall, Action.get, Action.listall, detail lookups, watches, or aborts. These methods call ensure_client() and use the configured organization, project, and domain to construct identifiers. Run.url additionally uses the initialized client's endpoint and insecure setting to build a console URL for the run.

A few current behaviors matter when building longer-lived monitors:

  • Run.sync() and Action.sync() currently return self; they do not refresh the protobuf. Call details() or use watch() to obtain server state.
  • Run.abort() calls AbortRun and ignores a gRPC NOT_FOUND, while other gRPC failures are re-raised.
  • Action.watch and ActionDetails.watch stop after a terminal update rather than remaining open indefinitely.
  • Action.task_name is optional. It returns None when the metadata has no task ID, including trace actions.
  • RunDetails is a root-action detail view, not a full action collection. Use Action.listall(for_run_name=...) when you need child actions.