ALLM.Pipeline.PipelineRun (allm_pipeline v0.1.0)

Copy Markdown View Source

Represents a single execution of a pipeline.

Groups related step logs together for tracking and resumption. Each pipeline run has a name, status, timing information, and can hold arbitrary metadata about the execution.

Terminating a run is an ownership capability

create/3 mints an opaque completion token onto the returned struct's virtual :completion_token field. All three terminal writers — complete/2, fail/2 and cancel/1 — refuse to run without it. The set is deliberate: each writes completed_at plus a terminal status, so each inflicts the same damage (a run reported finished while it is still executing), and guarding only one would be the "rule enforced in more than one shape" trap root CLAUDE.md names. owner?/1 is the single predicate all three consult.

Three consequences, all deliberate:

  • The creator of a run is its owner, and terminating it is not something a caller can perform by merely holding a %PipelineRun{}. Taking over a run you did not create is possible, but only by NAME — see the mint section below.
  • A run loaded from the database (get/1, get_with_steps/1) carries no token, so a read path can never stamp a run terminal.
  • A borrowed run — an umbrella lending its run to an inner pipeline via the :pipeline_run opt — is passed through borrow/1 at the receiving boundary (Executor.borrowed_run/1), which strips the token. An inner complete/2 (or fail/2) is then a detectable {:error, :not_run_owner} instead of silently stamping the run terminal mid-loop and clobbering the umbrella's aggregate metadata with the last item's.

As of 2026-08-13 the guard is inert on every live path: all 28 fail/fail_pipeline_run and 17 complete sites hold a handle that came straight from create/3. It is a membership guard against the next call site, not a fix for a current one.

One mint implementation, two deliberate entry points

There is exactly one implementation of the mint (the private mint_token/1), reached by exactly two public functions:

  • create/3 — the run's creator becomes its owner. The overwhelmingly common path.
  • assume_ownership/1 — an explicit, greppable take-over of a token-less run, for a caller that legitimately means to finish a run it did not create (see Executor.resume/2, and the orphaned-run sweeper still open in .work/HANDOFF.md).

Do not add a third: a re-mint hidden inside a function whose name does not say "I am taking ownership" (resume/2 was the near miss — user decision, 2026-08-13) turns the ownership story back into a convention. borrow/1 is the inverse and the only other writer of the field.

The token is data, not a lock: it detects the borrowed-run mistake, which is the one that actually happens. It does not (and cannot) detect an orchestrator process that dies without terminating the run at all — that leaves a run at status = running, and needs a watchdog or sweeper, not a token. That sweeper takes over stranded runs via assume_ownership/1 rather than relying on fail/2 being open by omission — which it no longer is.

Summary

Functions

Take ownership of a token-less run: returns the same run carrying a fresh completion token, so the caller may terminate it.

Return a non-owning handle on pipeline_run — the same run, minus the completion token.

Mark a pipeline run as cancelled.

Create a changeset for a pipeline run.

Mark a pipeline run as successfully completed.

Count pipeline runs matching the same filters list/1 accepts (:limit and :offset are ignored) — the total a paginated UI needs to size its pager.

Create a new pipeline run with pending status.

Mark a pipeline run as failed with error information.

Get a pipeline run by ID.

Get a pipeline run by ID with step logs preloaded.

List pipeline runs with optional filters.

Whether this handle on the run is the one allowed to complete/2 it.

Mark a pipeline run as running with a start timestamp.

Types

status()

@type status() :: :pending | :running | :success | :failed | :cancelled

t()

@type t() :: %ALLM.Pipeline.PipelineRun{
  __meta__: term(),
  completed_at: DateTime.t() | nil,
  completion_token: binary() | nil,
  id: Ecto.UUID.t() | nil,
  inserted_at: DateTime.t() | nil,
  metadata: map(),
  name: String.t() | nil,
  parent_run_id: Ecto.UUID.t() | nil,
  started_at: DateTime.t() | nil,
  status: status() | nil,
  step_logs: [ALLM.Pipeline.StepLog.t()] | Ecto.Association.NotLoaded.t(),
  trigger: String.t() | nil,
  updated_at: DateTime.t() | nil
}

Functions

assume_ownership(pipeline_run)

@spec assume_ownership(t()) :: t()

Take ownership of a token-less run: returns the same run carrying a fresh completion token, so the caller may terminate it.

The explicit counterpart to create/3's implicit mint, and the sanctioned way to finish a run you did not create. Two callers need it:

  • a driver of Executor.resume/2, whose handle is loaded from the database and therefore never an owner;
  • the orphaned-run sweeper (still open in .work/HANDOFF.md), which cannot use fail/2 as an escape hatch now that fail/2 and cancel/1 are ownership-guarded too.

Named rather than inlined on purpose: a re-mint is a real transfer of the right to stamp a run terminal, so it should be greppable and appear in a diff. Do not call it to silence {:error, :not_run_owner} — on a borrowed umbrella handle it re-creates precisely the mid-loop clobber the token exists to detect. Ask first whether this caller really is the one that should finish the run.

borrow(pipeline_run)

@spec borrow(t()) :: t()

Return a non-owning handle on pipeline_run — the same run, minus the completion token.

Called at the borrowed-run boundary (Executor.borrowed_run/1) so an inner pipeline handed an umbrella's run can log steps under it but cannot complete it. See the moduledoc.

cancel(pipeline_run)

@spec cancel(t()) ::
  {:ok, t()} | {:error, Ecto.Changeset.t()} | {:error, :not_run_owner}

Mark a pipeline run as cancelled.

Requires an owning handle for the same reason complete/2 and fail/2 do — see the moduledoc.

changeset(pipeline_run, attrs)

@spec changeset(t(), map()) :: Ecto.Changeset.t()

Create a changeset for a pipeline run.

complete(pipeline_run, result_metadata \\ %{})

@spec complete(t(), map()) ::
  {:ok, t()} | {:error, Ecto.Changeset.t()} | {:error, :not_run_owner}

Mark a pipeline run as successfully completed.

Requires an owning handle (see the moduledoc). A handle with no completion token — a borrowed umbrella run, or a run re-loaded from the database — returns {:error, :not_run_owner} and writes nothing.

It logs at :error as well as returning, because almost every call site today discards this function's return value (PipelineRun.complete(run, stats) as a statement); the log line, not the tuple, is what surfaces the mistake in a real run.

Deliberately NOT a raise: the borrowed-run idiom is live in production (VideoSummaryPipeline lends its umbrella run to MeetingSummaryPipeline), and turning a wrong-but-working path into a crash would trade a metadata bug for an outage. Deliberately not a silent no-op either: that is the "first-write-wins" idempotency fix the design doc rejects (§2.4), which only swaps which pipeline's metadata is lost.

count(opts \\ [])

@spec count(keyword()) :: non_neg_integer()

Count pipeline runs matching the same filters list/1 accepts (:limit and :offset are ignored) — the total a paginated UI needs to size its pager.

create(name, metadata \\ %{}, attrs \\ [])

@spec create(String.t(), map(), keyword()) ::
  {:ok, t()} | {:error, Ecto.Changeset.t()}

Create a new pipeline run with pending status.

attrs carries top-level COLUMN values (:trigger, :parent_run_id) — these are plain scalars set directly on the changeset, NOT routed through the metadata JSONB (so they bypass Encodable.encode/1 and stay SQL/GraphQL-filterable).

This is the primary of the completion token's two mint entry points (see the moduledoc — the other is the explicit assume_ownership/1): the returned struct is the only handle that can complete/2 this run until someone deliberately takes it over.

fail(pipeline_run, error)

@spec fail(t(), term()) ::
  {:ok, t()} | {:error, Ecto.Changeset.t()} | {:error, :not_run_owner}

Mark a pipeline run as failed with error information.

Requires an owning handle, exactly as complete/2 does — fail/2 writes the same completed_at + terminal status, so a borrowed or re-loaded handle must not reach it either. Returns {:error, :not_run_owner} and writes nothing for a handle with no completion token.

The error is normalized by normalize_error/1 and then passed through Encodable.encode/1 like every other metadata write on this schema — an exception message echoing OCR'd or LLM-produced text can carry a NUL byte, which fails the jsonb write with ERROR 22P05 and loses the failure record along with the run.

get(id)

@spec get(Ecto.UUID.t()) :: t() | nil

Get a pipeline run by ID.

get_with_steps(id)

@spec get_with_steps(Ecto.UUID.t()) :: t() | nil

Get a pipeline run by ID with step logs preloaded.

list(opts \\ [])

@spec list(keyword()) :: [t()]

List pipeline runs with optional filters.

Options

  • :status / :trigger - exact match
  • :name - exact match on the pipeline slug ("video_summary"). Callers that assert on a specific pipeline rely on this NOT matching siblings such as "video_summary_single".
  • :name_contains - case-insensitive substring match, for the review UI's free-text search box ("video" matches video_listing, video_summary, …)
  • :limit / :offset - pagination

Ordered newest-first. inserted_at alone is not a total order (a batch can stamp several runs in the same microsecond), so id breaks ties — without it a row can repeat on one page and vanish from the next.

owner?(pipeline_run)

@spec owner?(t()) :: boolean()

Whether this handle on the run is the one allowed to complete/2 it.

start(pipeline_run)

@spec start(t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}

Mark a pipeline run as running with a start timestamp.