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_runopt — is passed throughborrow/1at the receiving boundary (Executor.borrowed_run/1), which strips the token. An innercomplete/2(orfail/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 (seeExecutor.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
@type status() :: :pending | :running | :success | :failed | :cancelled
@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
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 usefail/2as an escape hatch now thatfail/2andcancel/1are 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.
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.
@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.
@spec changeset(t(), map()) :: Ecto.Changeset.t()
Create a changeset for a pipeline run.
@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.
@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.
@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.
@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.
@spec get(Ecto.UUID.t()) :: t() | nil
Get a pipeline run by ID.
@spec get_with_steps(Ecto.UUID.t()) :: t() | nil
Get a pipeline run by ID with step logs preloaded.
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"matchesvideo_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.
Whether this handle on the run is the one allowed to complete/2 it.
@spec start(t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
Mark a pipeline run as running with a start timestamp.