class RubyLLM::AgUI::RunRecord

The authoritative server-side progress of one reserved run.

The record is immutable: every transition returns a new record that the store persists. That keeps a run’s identity, status and progress honest across processes, and it is what makes “a cancelled run is never reported as success” checkable rather than a comment: once a record reaches a terminal status, no transition can move it to a different one.

A record is host bookkeeping, not business truth. The host still owns the conversation, the messages and whatever the tool calls actually did.

Constants

CANCELLED

The run was stopped before it completed and did not fail.

FAILED

The run failed.

INTERRUPTED

The run paused for human input.

RESERVED

The statuses a run moves through: identity is claimed (RESERVED), the run streams (STARTED), and it ends in exactly one of the terminal statuses.

STARTED
STATUSES
SUCCEEDED

The run ended normally.

TERMINAL_STATUSES

The statuses a run can end in. A run has exactly one.

Attributes

created_at[R]
digest[R]

The digest of the input this run was reserved for. A retry that sends the same input attaches to this record instead of running twice; a different input under the same key is a conflict.

failure[R]

The sanitized failure a reconnect reports for a run that failed, as ‘{“message” =>, “code” =>}`, or nil. Never the exception itself: a reconnect must not re-leak what the original RUN_ERROR withheld.

interrupts[R]

The interrupts raised by this run, oldest first.

run_id[R]

Identifies this run. Unique within (scope, thread_id).

scope[R]

The host’s tenant/actor/thread scope. Part of every reservation key.

sequence[R]

The highest event sequence number handed out for this run. Snapshots on reconnect are ordered by it rather than by wall-clock time.

snapshot[R]

The host-owned run state kept for a reconnect snapshot, or nil.

status[R]

The run’s current status.

thread_id[R]

The AG-UI conversation this run belongs to.

updated_at[R]

Public Class Methods

from_storage(hash) click to toggle source

Rebuilds a record stored by to_storage.

# File lib/ruby_llm/ag_ui/run_record.rb, line 185
def self.from_storage(hash)
  new(
    scope: hash.fetch('scope'), thread_id: hash.fetch('threadId'), run_id: hash.fetch('runId'),
    digest: hash.fetch('digest'), status: hash.fetch('status').to_sym,
    sequence: hash.fetch('sequence'), snapshot: hash['snapshot'], failure: hash['failure'],
    interrupts: interrupts_from_storage(hash['interrupts']),
    created_at: Interrupt.parse_time(hash.fetch('createdAt')),
    updated_at: Interrupt.parse_time(hash.fetch('updatedAt'))
  )
end
interrupts_from_storage(list) click to toggle source
# File lib/ruby_llm/ag_ui/run_record.rb, line 196
def self.interrupts_from_storage(list)
  (list || []).map { |interrupt| Interrupt.from_storage(interrupt) }
end
new(scope:, thread_id:, run_id:, digest:, created_at:, status: RESERVED, sequence: 0, snapshot: nil, interrupts: [], failure: nil, updated_at: nil) click to toggle source

rubocop:disable-next Metrics/ParameterLists – one keyword per field the record owns

# File lib/ruby_llm/ag_ui/run_record.rb, line 69
def initialize(scope:, thread_id:, run_id:, digest:, created_at:, status: RESERVED, sequence: 0,
               snapshot: nil, interrupts: [], failure: nil, updated_at: nil)
  @scope = scope
  @thread_id = thread_id
  @run_id = run_id
  @digest = digest
  @status = assert_status!(status)
  @sequence = sequence
  @snapshot = snapshot
  @interrupts = interrupts.freeze
  @failure = failure
  @created_at = created_at
  @updated_at = updated_at || created_at
  freeze
end

Public Instance Methods

advance(now:) click to toggle source

Hands out the next event sequence number.

# File lib/ruby_llm/ag_ui/run_record.rb, line 121
def advance(now:)
  with(now: now, sequence: sequence + 1)
end
awaiting_approval?() click to toggle source

Whether the run is waiting on a human. Reported as an interrupt outcome; never reported as success.

# File lib/ruby_llm/ag_ui/run_record.rb, line 98
def awaiting_approval?
  interrupted? && interrupts.any?(&:pending?)
end
finish(status, now:, failure: nil) click to toggle source

Closes the run with status: :succeeded, :interrupted, :cancelled or :failed, and, for a failure, the sanitized error a reconnect reports. Re-applying the same terminal status is an idempotent no-op — so the first failure wins rather than being replaced by a second, possibly different, explanation — and any other terminal status is a protocol violation, because a run has exactly one outcome.

# File lib/ruby_llm/ag_ui/run_record.rb, line 131
def finish(status, now:, failure: nil)
  raise ConfigurationError, "cannot finish a run as #{status.inspect}" unless TERMINAL_STATUSES.include?(status)
  return self if terminal? && self.status == status
  raise StreamError, "run #{run_id} already ended as #{self.status}, not #{status}" if terminal?

  transition_to(status, now: now, failure: failure)
end
interrupt(id) click to toggle source

The interrupt with id, or nil.

# File lib/ruby_llm/ag_ui/run_record.rb, line 108
def interrupt(id)
  interrupts.find { |interrupt| interrupt.id == id }
end
interrupted(interrupts, now:) click to toggle source

Closes the run as interrupted and records the interrupts it raised.

# File lib/ruby_llm/ag_ui/run_record.rb, line 140
def interrupted(interrupts, now:)
  raise ConfigurationError, 'an interrupted run needs at least one interrupt' if interrupts.empty?

  pending = interrupts.reject(&:decided?)
  raise ConfigurationError, 'an interrupted run needs at least one unresolved interrupt' if pending.empty?

  transition_to(INTERRUPTED, now: now, interrupts: interrupts)
end
pending_interrupts() click to toggle source

The interrupts that still wait for an answer.

# File lib/ruby_llm/ag_ui/run_record.rb, line 103
def pending_interrupts
  interrupts.select(&:pending?)
end
replace_interrupt(decided, now:) click to toggle source

Replaces the interrupt with the same id, keeping its position.

This is the one transition that also applies to a terminal record: an interrupted run is closed, and answering its interrupt records the decision on that closed record.

# File lib/ruby_llm/ag_ui/run_record.rb, line 154
def replace_interrupt(decided, now:)
  raise ConfigurationError, "no interrupt #{decided.id} on run #{run_id}" unless interrupt(decided.id)

  replaced = interrupts.map { |existing| existing.id == decided.id ? decided : existing }
  with(now: now, interrupts: replaced)
end
start(now:, snapshot: nil) click to toggle source

Moves a reserved run to :started, remembering the host-owned state a reconnect serves. Idempotent for an already-started run.

# File lib/ruby_llm/ag_ui/run_record.rb, line 114
def start(now:, snapshot: nil)
  return self if started?

  transition_to(STARTED, now: now, snapshot: snapshot)
end
terminal?() click to toggle source

Whether the run has ended.

# File lib/ruby_llm/ag_ui/run_record.rb, line 92
def terminal?
  TERMINAL_STATUSES.include?(status)
end
to_h() click to toggle source
# File lib/ruby_llm/ag_ui/run_record.rb, line 161
def to_h
  {
    'scope' => scope.to_s,
    'threadId' => thread_id,
    'runId' => run_id,
    'digest' => digest,
    'status' => status.to_s,
    'sequence' => sequence,
    'snapshot' => snapshot,
    'failure' => failure,
    'interrupts' => interrupts.map(&:to_h),
    'createdAt' => iso8601(created_at),
    'updatedAt' => iso8601(updated_at)
  }
end
to_storage() click to toggle source

The record in the form a durable store keeps. to_h is the reporting shape; it leaves out what an interrupt needs to be re-verified, so a store round-trips this instead.

# File lib/ruby_llm/ag_ui/run_record.rb, line 180
def to_storage
  to_h.merge('interrupts' => interrupts.map(&:to_storage))
end