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
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.
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.
The interrupts raised by this run, oldest first.
Identifies this run. Unique within (scope, thread_id).
The host’s tenant/actor/thread scope. Part of every reservation key.
The highest event sequence number handed out for this run. Snapshots on reconnect are ordered by it rather than by wall-clock time.
The host-owned run state kept for a reconnect snapshot, or nil.
The run’s current status.
The AG-UI conversation this run belongs to.
Public Class Methods
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
# 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
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
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
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
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
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
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
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
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
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
Whether the run has ended.
# File lib/ruby_llm/ag_ui/run_record.rb, line 92 def terminal? TERMINAL_STATUSES.include?(status) end
# 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
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