ruby_llm-ag_ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +48 -0
  3. data/CONTRIBUTING.md +56 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +146 -0
  6. data/SECURITY.md +30 -0
  7. data/docs/_advanced/rails-integration.md +199 -0
  8. data/docs/_advanced/security-and-deployment.md +204 -0
  9. data/docs/_core_features/approval-and-resume.md +62 -0
  10. data/docs/_core_features/input-validation.md +64 -0
  11. data/docs/_core_features/reconnects.md +42 -0
  12. data/docs/_core_features/streaming-runs.md +49 -0
  13. data/docs/_getting_started/configuration.md +57 -0
  14. data/docs/_getting_started/getting-started.md +52 -0
  15. data/docs/_getting_started/overview.md +189 -0
  16. data/docs/_getting_started/whats-new-in-0-1.md +41 -0
  17. data/docs/_reference/api-reference.md +26 -0
  18. data/docs/_reference/compatibility.md +130 -0
  19. data/docs/_reference/protocol-coverage.md +175 -0
  20. data/docs/_reference/release-history.md +22 -0
  21. data/docs/_reference/releasing.md +54 -0
  22. data/docs/_reference/third-party-notices.md +82 -0
  23. data/docs/_reference/upgrading.md +42 -0
  24. data/docs/index.md +53 -0
  25. data/lib/generators/ruby_llm/ag_ui/install_generator.rb +37 -0
  26. data/lib/generators/ruby_llm/ag_ui/templates/create_ruby_llm_ag_ui_runs.rb.tt +24 -0
  27. data/lib/generators/ruby_llm/ag_ui/templates/initializer.rb.tt +24 -0
  28. data/lib/ruby_llm/ag_ui/canonical.rb +40 -0
  29. data/lib/ruby_llm/ag_ui/configuration.rb +113 -0
  30. data/lib/ruby_llm/ag_ui/coverage.rb +163 -0
  31. data/lib/ruby_llm/ag_ui/emitter.rb +119 -0
  32. data/lib/ruby_llm/ag_ui/error.rb +69 -0
  33. data/lib/ruby_llm/ag_ui/error_sanitizer.rb +69 -0
  34. data/lib/ruby_llm/ag_ui/input.rb +299 -0
  35. data/lib/ruby_llm/ag_ui/interrupt.rb +207 -0
  36. data/lib/ruby_llm/ag_ui/mapper.rb +333 -0
  37. data/lib/ruby_llm/ag_ui/message_parser.rb +127 -0
  38. data/lib/ruby_llm/ag_ui/protocol.rb +130 -0
  39. data/lib/ruby_llm/ag_ui/rails/active_record_store.rb +119 -0
  40. data/lib/ruby_llm/ag_ui/rails/railtie.rb +29 -0
  41. data/lib/ruby_llm/ag_ui/rails/record.rb +51 -0
  42. data/lib/ruby_llm/ag_ui/rails/streaming.rb +166 -0
  43. data/lib/ruby_llm/ag_ui/reconnect.rb +86 -0
  44. data/lib/ruby_llm/ag_ui/redaction.rb +58 -0
  45. data/lib/ruby_llm/ag_ui/redemption.rb +62 -0
  46. data/lib/ruby_llm/ag_ui/reservation.rb +56 -0
  47. data/lib/ruby_llm/ag_ui/resume.rb +108 -0
  48. data/lib/ruby_llm/ag_ui/resume_entry.rb +49 -0
  49. data/lib/ruby_llm/ag_ui/run.rb +177 -0
  50. data/lib/ruby_llm/ag_ui/run_record.rb +229 -0
  51. data/lib/ruby_llm/ag_ui/sse_writer.rb +119 -0
  52. data/lib/ruby_llm/ag_ui/store/base.rb +123 -0
  53. data/lib/ruby_llm/ag_ui/store/memory_store.rb +123 -0
  54. data/lib/ruby_llm/ag_ui/validation.rb +63 -0
  55. data/lib/ruby_llm/ag_ui/version.rb +8 -0
  56. data/lib/ruby_llm/ag_ui.rb +65 -0
  57. metadata +223 -0
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # The result of redeeming an interrupt.
6
+ #
7
+ # A caller cannot tell a fresh decision from a replay by the interrupt
8
+ # alone: both come back approved. The distinction is exactly what decides
9
+ # whether the tool may run, so the store returns it explicitly. Only the
10
+ # call that recorded the decision may execute the tool; a replay reports the
11
+ # answer already given and must not run anything again.
12
+ class Redemption
13
+ # The stored interrupt, with its decision.
14
+ attr_reader :interrupt
15
+
16
+ def initialize(interrupt:, replayed:)
17
+ @interrupt = interrupt
18
+ @replayed = replayed
19
+ freeze
20
+ end
21
+
22
+ # Whether this call recorded the decision.
23
+ def recorded?
24
+ !@replayed
25
+ end
26
+
27
+ # Whether the decision was already recorded by an earlier call.
28
+ def replayed?
29
+ @replayed
30
+ end
31
+
32
+ # The interrupt's status: :approved, :denied or :cancelled.
33
+ def status
34
+ interrupt.status
35
+ end
36
+
37
+ # Whether the stored decision is an approval.
38
+ def approved?
39
+ interrupt.approved?
40
+ end
41
+
42
+ # Whether the stored decision is a refusal.
43
+ def denied?
44
+ interrupt.denied?
45
+ end
46
+
47
+ # Whether the stored decision abandons the interrupt.
48
+ def cancelled?
49
+ interrupt.cancelled?
50
+ end
51
+
52
+ # The tool call the decision concerns.
53
+ def tool_call_id
54
+ interrupt.tool_call_id
55
+ end
56
+
57
+ def to_h
58
+ { 'interruptId' => interrupt.id, 'status' => status.to_s, 'replayed' => replayed? }
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # The answer to a run reservation: the record that owns the identity, and
6
+ # how the reservation was obtained.
7
+ #
8
+ # A retried request whose input digest matches an existing reservation gets
9
+ # ATTACHED, so the host streams or reports the run already in progress
10
+ # instead of executing it a second time.
11
+ class Reservation
12
+ # The identity was free and is now claimed by this call.
13
+ CREATED = :created
14
+ # The identity was already claimed by an identical request.
15
+ ATTACHED = :attached
16
+
17
+ # The record that owns the run identity.
18
+ attr_reader :record
19
+
20
+ # :created or :attached.
21
+ attr_reader :status
22
+
23
+ def initialize(record:, status:)
24
+ @record = record
25
+ @status = status
26
+ freeze
27
+ end
28
+
29
+ # Whether this call claimed the identity.
30
+ def created?
31
+ status == CREATED
32
+ end
33
+
34
+ # Whether the identity was already claimed by an identical request.
35
+ def attached?
36
+ status == ATTACHED
37
+ end
38
+
39
+ def thread_id
40
+ record.thread_id
41
+ end
42
+
43
+ def run_id
44
+ record.run_id
45
+ end
46
+
47
+ def digest
48
+ record.digest
49
+ end
50
+
51
+ def to_h
52
+ { 'status' => status.to_s, 'threadId' => thread_id, 'runId' => run_id, 'digest' => digest }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # The answers a run's input gives to a previous run's interrupts (FR-06).
6
+ #
7
+ # A resume is a request, never a permission grant. Before anything is
8
+ # written, every entry is checked against the live state: it must name an
9
+ # interrupt of the *most recent* interrupted run on the same thread, the
10
+ # tool call it concerns must still be pending on the host's chat, and that
11
+ # call's arguments must still digest to what the interrupt recorded, so a
12
+ # resume cannot change what it approves. The decision is then recorded once;
13
+ # a repeated resume is refused rather than executed again, because the tool
14
+ # may already have run.
15
+ #
16
+ # An approval answers an interrupt. The host still re-authorizes the actor
17
+ # before the tool executes.
18
+ class Resume
19
+ # One entry's effect: the interrupt it answers and the decision to record.
20
+ Decision = Struct.new(:interrupt, :decision, keyword_init: true)
21
+
22
+ def initialize(input:, store:, scope:, chat:)
23
+ @input = input
24
+ @store = store
25
+ @scope = scope
26
+ @chat = chat
27
+ @plan = []
28
+ end
29
+
30
+ # Whether the input answers anything at all.
31
+ def needed?
32
+ @input.resume?
33
+ end
34
+
35
+ # Checks every entry and builds the plan. Writes nothing, so a rejected
36
+ # resume is rejected before the run opens a stream.
37
+ def plan(now:)
38
+ source = interrupted_run
39
+ @plan = @input.resume.map { |entry| plan_entry(source, entry, now) }
40
+ self
41
+ end
42
+
43
+ # Records each planned decision once and tells the chat about it, so the
44
+ # next completion runs an approved tool or refuses a denied one.
45
+ def apply(now:)
46
+ @plan.each { |planned| redeem(planned, now: now) }
47
+ self
48
+ end
49
+
50
+ private
51
+
52
+ # Only the most recent interrupted run on the thread is resumable.
53
+ # Anything older is stale, and answering it would apply a decision to a
54
+ # conversation that has already moved on.
55
+ def interrupted_run
56
+ record = @store.latest_interrupted(scope: @scope, thread_id: @input.thread_id)
57
+ raise ResumeRejectedError, "thread #{@input.thread_id} has no interrupted run to resume" if record.nil?
58
+
59
+ record
60
+ end
61
+
62
+ def plan_entry(source, entry, now)
63
+ interrupt = source.interrupt(entry.interrupt_id)
64
+ raise ResumeRejectedError, "interrupt #{entry.interrupt_id} is not the latest run's" if interrupt.nil?
65
+ raise ResumeRejectedError, "interrupt #{interrupt.id} has already been answered" unless interrupt.pending?
66
+ raise ResumeRejectedError, "interrupt #{interrupt.id} has expired" if interrupt.expired?(now)
67
+
68
+ ensure_unchanged!(interrupt)
69
+ Decision.new(interrupt: interrupt, decision: decision_for(entry))
70
+ end
71
+
72
+ # The call an interrupt approves must still be pending on the host's chat
73
+ # with the same arguments. A resume answers the question that was asked; it
74
+ # cannot change it.
75
+ def ensure_unchanged!(interrupt)
76
+ tool_call = pending_approval(interrupt.tool_call_id)
77
+ raise ResumeRejectedError, "tool call #{interrupt.tool_call_id} is not pending on this chat" unless tool_call
78
+
79
+ return if Canonical.digest(tool_call.arguments) == interrupt.arguments_digest
80
+
81
+ raise ResumeRejectedError, "arguments for tool call #{interrupt.tool_call_id} changed since the interrupt"
82
+ end
83
+
84
+ # The store's redemption is atomic: the first answer record wins, and a
85
+ # repeat comes back as a replay that must not execute anything.
86
+ def redeem(planned, now:)
87
+ redemption = @store.redeem_interrupt(
88
+ scope: @scope, thread_id: @input.thread_id,
89
+ interrupt_id: planned.interrupt.id, decision: planned.decision, now: now
90
+ )
91
+ raise ResumeRejectedError, "interrupt #{planned.interrupt.id} was already answered" if redemption.replayed?
92
+
93
+ redemption.approved? ? @chat.approve(redemption.tool_call_id) : @chat.deny(redemption.tool_call_id)
94
+ end
95
+
96
+ # The protocol's resume vocabulary is resolved or cancelled; the interrupt
97
+ # records the truthful decision, and a cancelled answer denies the call
98
+ # rather than approving it.
99
+ def decision_for(entry)
100
+ entry.resolved? ? Interrupt::APPROVED : Interrupt::CANCELLED
101
+ end
102
+
103
+ def pending_approval(tool_call_id)
104
+ @chat.pending_approvals.find { |tool_call| tool_call.id == tool_call_id }
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # One answer to one interrupt, as the client sends it on the run that
6
+ # continues from an interrupted one.
7
+ #
8
+ # A resume entry is a request, never a permission grant: the run still
9
+ # checks that the interrupt exists, belongs to the most recent interrupted
10
+ # run on this thread, has not been redeemed, and has not expired, and the
11
+ # host still re-authorizes before any tool runs.
12
+ class ResumeEntry
13
+ # The protocol's two statuses.
14
+ RESOLVED = 'resolved'
15
+ CANCELLED = 'cancelled'
16
+ STATUSES = [RESOLVED, CANCELLED].freeze
17
+
18
+ # Identifies the interrupt this entry answers.
19
+ attr_reader :interrupt_id
20
+
21
+ # "resolved" or "cancelled".
22
+ attr_reader :status
23
+
24
+ # The answer the client supplies, for an interrupt that asked for one.
25
+ attr_reader :payload
26
+
27
+ def initialize(interrupt_id:, status:, payload: nil)
28
+ @interrupt_id = interrupt_id
29
+ @status = status
30
+ @payload = payload
31
+ freeze
32
+ end
33
+
34
+ # Whether the client answered the interrupt.
35
+ def resolved?
36
+ status == RESOLVED
37
+ end
38
+
39
+ # Whether the client abandoned the interrupt.
40
+ def cancelled?
41
+ status == CANCELLED
42
+ end
43
+
44
+ def to_h
45
+ { 'interruptId' => interrupt_id, 'status' => status, 'payload' => payload }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # Runs one RubyLLM 2.x chat turn as one AG-UI 1.0 run.
6
+ #
7
+ # input = RubyLLM::AgUI::Input.parse(request_body)
8
+ # chat = host_chat_for(actor, input.thread_id) # host-owned RubyLLM::Chat
9
+ # RubyLLM::AgUI::Run.new(chat:, input:, scope: "tenant:#{actor.id}").stream(to: response.stream)
10
+ #
11
+ # The host owns authentication, authorization, the chat, the tools a run may
12
+ # use and the transport. This class owns the run's identity (reserved before
13
+ # any provider work), the ordering of the events, and the outcome: a
14
+ # cancelled run is never reported as success, a provider failure is a
15
+ # sanitized RUN_ERROR, a paused run is an interrupt rather than a success,
16
+ # and an identity that is already reserved is never executed a second time.
17
+ #
18
+ # +scope+ is the host's tenant/actor boundary, built from authenticated
19
+ # identity. The gem takes it from the host rather than deriving it from the
20
+ # input, because a scope derived from anything the client sent would let one
21
+ # client attach to another's run.
22
+ class Run
23
+ # The run's authoritative record, once it has been reserved.
24
+ attr_reader :record
25
+
26
+ # rubocop:disable-next Metrics/ParameterLists -- one keyword per host-supplied or overridable value
27
+ def initialize(chat:, input:, scope:, store: nil, logger: nil, redact: nil, clock: nil, expires_in: nil,
28
+ state: nil)
29
+ @chat = chat
30
+ @input = input
31
+ @scope = scope
32
+ @store = store || RubyLLM::AgUI.config.run_store!
33
+ @logger = logger || RubyLLM::AgUI.config.logger
34
+ @redact = redact
35
+ @clock = clock || RubyLLM::AgUI.config.clock
36
+ @expires_in = expires_in || RubyLLM::AgUI.config.interrupt_ttl
37
+ @mutex = Mutex.new
38
+ validate!
39
+ @state = state.nil? ? @input.state : state
40
+ end
41
+
42
+ # Reserves the run and streams it to +to+, an IO-like stream. Returns the
43
+ # run's record, which carries the status the run ended with.
44
+ #
45
+ # The reservation happens before any provider work: a request whose input
46
+ # digest matches an identity already reserved attaches instead of
47
+ # executing, and a changed input under the same key is refused by the
48
+ # store. A reserved run that already ended is served as a reconnect — its
49
+ # outcome and state snapshot — never as a second execution.
50
+ def stream(to:)
51
+ resume.plan(now: @clock.call) if resume.needed?
52
+ reservation = reserve
53
+ return execute(reservation.record, to) if reservation.created?
54
+
55
+ reconnect(reservation.record, to)
56
+ end
57
+
58
+ private
59
+
60
+ def resume
61
+ @resume ||= Resume.new(input: @input, store: @store, scope: @scope, chat: @chat)
62
+ end
63
+
64
+ def reserve
65
+ @store.reserve(
66
+ scope: @scope, thread_id: @input.thread_id, run_id: @input.run_id, digest: @input.digest
67
+ )
68
+ end
69
+
70
+ # An identity that is already reserved is never executed a second time.
71
+ # A run that ended is served as a reconnect: its outcome and state
72
+ # snapshot, without replaying the deltas or running anything again.
73
+ #
74
+ # A run that has not ended cannot be served here. A second stream cannot
75
+ # join a live one without a relay the gem does not own, and pretending it
76
+ # did would either hang or duplicate the work, so it fails closed.
77
+ def reconnect(record, to)
78
+ unless Reconnect.serves?(record)
79
+ raise StreamError, "run #{record.run_id} on thread #{record.thread_id} is still #{record.status}; " \
80
+ 'a live run cannot be joined by a second stream'
81
+ end
82
+
83
+ build_emitter(record, to).replay(Reconnect.new(record: record, input: @input).events)
84
+ end
85
+
86
+ def execute(record, to)
87
+ @mapper = build_mapper(record)
88
+ @emitter = build_emitter(record, to, mapper: @mapper)
89
+ @emitter.start(run_started_event(record), state: @state)
90
+ resume.apply(now: @clock.call) if resume.needed?
91
+ drive
92
+ @record = finish
93
+ rescue RubyLLM::CancelledError
94
+ @record = @emitter.cancel
95
+ rescue StandardError => e
96
+ @record = @emitter.fail(e)
97
+ end
98
+
99
+ def build_emitter(record, to, mapper: build_mapper(record))
100
+ Emitter.new(
101
+ writer: SSEWriter.new(to, logger: @logger), mapper: mapper,
102
+ store: @store, record: record, clock: @clock, logger: @logger
103
+ )
104
+ end
105
+
106
+ # Drives RubyLLM and projects what it reports.
107
+ #
108
+ # RubyLLM runs a host's local tools concurrently when asked to
109
+ # (`tool_concurrency`), so `before_tool_call` can arrive from several
110
+ # threads at once. The mapper is per-run state, so mapping and writing
111
+ # happen under one lock: two callbacks never interleave inside it, and the
112
+ # run's sequence stays a single ordered list.
113
+ def drive
114
+ @chat.before_tool_call { |tool_call| project { @mapper.before_tool_call(tool_call) } }
115
+ @chat.after_message { |message| project { @mapper.message(message) } }
116
+ @chat.complete { |chunk| project { @mapper.chunk(chunk) } }
117
+ end
118
+
119
+ def project
120
+ @mutex.synchronize { @emitter.write(yield) }
121
+ end
122
+
123
+ # A run that stopped for a human is an interrupt, never a success. Any
124
+ # other pause is something this producer cannot express on the wire, so it
125
+ # is a failure with a fixed, safe message rather than a run reported as
126
+ # finished.
127
+ def finish
128
+ return @emitter.succeed unless @chat.waiting?
129
+ raise UnsupportedRunStateError unless @chat.awaiting_approval?
130
+
131
+ @emitter.interrupt(build_interrupts)
132
+ end
133
+
134
+ # One interrupt per pending approval, carrying the tool call, the digest of
135
+ # the arguments RubyLLM asked to run, and a host-owned expiry. The host
136
+ # still re-authorizes before the tool executes: an approval answers an
137
+ # interrupt, it never grants a permission.
138
+ def build_interrupts
139
+ expires_at = @clock.call + @expires_in
140
+ @chat.pending_approvals.map do |tool_call|
141
+ Interrupt.create(
142
+ tool_call_id: tool_call.id,
143
+ arguments_digest: Canonical.digest(tool_call.arguments),
144
+ expires_at: expires_at,
145
+ message: "Approve the #{tool_call.name} tool call?"
146
+ )
147
+ end
148
+ end
149
+
150
+ def build_mapper(record)
151
+ Mapper.new(thread_id: record.thread_id, run_id: record.run_id, redact: @redact, logger: @logger)
152
+ end
153
+
154
+ def run_started_event(record)
155
+ Protocol.run_started(
156
+ thread_id: record.thread_id, run_id: record.run_id,
157
+ parent_run_id: @input.parent_run_id, input: @input.to_h
158
+ )
159
+ end
160
+
161
+ def validate!
162
+ unless @chat.respond_to?(:complete)
163
+ raise ConfigurationError, 'chat must be a RubyLLM::Chat or act like one (it does not respond to #complete)'
164
+ end
165
+ raise ConfigurationError, 'input must be a RubyLLM::AgUI::Input' unless @input.is_a?(Input)
166
+
167
+ validate_scope!
168
+ end
169
+
170
+ def validate_scope!
171
+ return if @scope.is_a?(String) && !@scope.empty?
172
+
173
+ raise ConfigurationError, 'scope must be a non-empty String built from authenticated identity'
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AgUI
5
+ # The authoritative server-side progress of one reserved run.
6
+ #
7
+ # The record is immutable: every transition returns a new record that the
8
+ # store persists. That keeps a run's identity, status and progress honest
9
+ # across processes, and it is what makes "a cancelled run is never reported
10
+ # as success" checkable rather than a comment: once a record reaches a
11
+ # terminal status, no transition can move it to a different one.
12
+ #
13
+ # A record is host bookkeeping, not business truth. The host still owns the
14
+ # conversation, the messages and whatever the tool calls actually did.
15
+ class RunRecord
16
+ # The statuses a run moves through: identity is claimed (RESERVED), the run
17
+ # streams (STARTED), and it ends in exactly one of the terminal statuses.
18
+ RESERVED = :reserved
19
+ STARTED = :started
20
+ # The run ended normally.
21
+ SUCCEEDED = :succeeded
22
+ # The run paused for human input.
23
+ INTERRUPTED = :interrupted
24
+ # The run was stopped before it completed and did not fail.
25
+ CANCELLED = :cancelled
26
+ # The run failed.
27
+ FAILED = :failed
28
+
29
+ STATUSES = [RESERVED, STARTED, SUCCEEDED, INTERRUPTED, CANCELLED, FAILED].freeze
30
+
31
+ # The statuses a run can end in. A run has exactly one.
32
+ TERMINAL_STATUSES = [SUCCEEDED, INTERRUPTED, CANCELLED, FAILED].freeze
33
+
34
+ # The host's tenant/actor/thread scope. Part of every reservation key.
35
+ attr_reader :scope
36
+
37
+ # The AG-UI conversation this run belongs to.
38
+ attr_reader :thread_id
39
+
40
+ # Identifies this run. Unique within (scope, thread_id).
41
+ attr_reader :run_id
42
+
43
+ # The digest of the input this run was reserved for. A retry that sends
44
+ # the same input attaches to this record instead of running twice; a
45
+ # different input under the same key is a conflict.
46
+ attr_reader :digest
47
+
48
+ # The run's current status.
49
+ attr_reader :status
50
+
51
+ # The highest event sequence number handed out for this run. Snapshots on
52
+ # reconnect are ordered by it rather than by wall-clock time.
53
+ attr_reader :sequence
54
+
55
+ # The host-owned run state kept for a reconnect snapshot, or nil.
56
+ attr_reader :snapshot
57
+
58
+ # The sanitized failure a reconnect reports for a run that failed, as
59
+ # `{"message" =>, "code" =>}`, or nil. Never the exception itself: a
60
+ # reconnect must not re-leak what the original RUN_ERROR withheld.
61
+ attr_reader :failure
62
+
63
+ # The interrupts raised by this run, oldest first.
64
+ attr_reader :interrupts
65
+
66
+ attr_reader :created_at, :updated_at
67
+
68
+ # rubocop:disable-next Metrics/ParameterLists -- one keyword per field the record owns
69
+ def initialize(scope:, thread_id:, run_id:, digest:, created_at:, status: RESERVED, sequence: 0,
70
+ snapshot: nil, interrupts: [], failure: nil, updated_at: nil)
71
+ @scope = scope
72
+ @thread_id = thread_id
73
+ @run_id = run_id
74
+ @digest = digest
75
+ @status = assert_status!(status)
76
+ @sequence = sequence
77
+ @snapshot = snapshot
78
+ @interrupts = interrupts.freeze
79
+ @failure = failure
80
+ @created_at = created_at
81
+ @updated_at = updated_at || created_at
82
+ freeze
83
+ end
84
+
85
+ # One predicate per status, so callers read `record.interrupted?` rather
86
+ # than comparing symbols.
87
+ STATUSES.each do |status|
88
+ define_method(:"#{status}?") { self.status == status }
89
+ end
90
+
91
+ # Whether the run has ended.
92
+ def terminal?
93
+ TERMINAL_STATUSES.include?(status)
94
+ end
95
+
96
+ # Whether the run is waiting on a human. Reported as an interrupt outcome;
97
+ # never reported as success.
98
+ def awaiting_approval?
99
+ interrupted? && interrupts.any?(&:pending?)
100
+ end
101
+
102
+ # The interrupts that still wait for an answer.
103
+ def pending_interrupts
104
+ interrupts.select(&:pending?)
105
+ end
106
+
107
+ # The interrupt with +id+, or nil.
108
+ def interrupt(id)
109
+ interrupts.find { |interrupt| interrupt.id == id }
110
+ end
111
+
112
+ # Moves a reserved run to :started, remembering the host-owned state a
113
+ # reconnect serves. Idempotent for an already-started run.
114
+ def start(now:, snapshot: nil)
115
+ return self if started?
116
+
117
+ transition_to(STARTED, now: now, snapshot: snapshot)
118
+ end
119
+
120
+ # Hands out the next event sequence number.
121
+ def advance(now:)
122
+ with(now: now, sequence: sequence + 1)
123
+ end
124
+
125
+ # Closes the run with +status+: :succeeded, :interrupted, :cancelled or
126
+ # :failed, and, for a failure, the sanitized error a reconnect reports.
127
+ # Re-applying the same terminal status is an idempotent no-op — so the
128
+ # first failure wins rather than being replaced by a second, possibly
129
+ # different, explanation — and any other terminal status is a protocol
130
+ # violation, because a run has exactly one outcome.
131
+ def finish(status, now:, failure: nil)
132
+ raise ConfigurationError, "cannot finish a run as #{status.inspect}" unless TERMINAL_STATUSES.include?(status)
133
+ return self if terminal? && self.status == status
134
+ raise StreamError, "run #{run_id} already ended as #{self.status}, not #{status}" if terminal?
135
+
136
+ transition_to(status, now: now, failure: failure)
137
+ end
138
+
139
+ # Closes the run as interrupted and records the interrupts it raised.
140
+ def interrupted(interrupts, now:)
141
+ raise ConfigurationError, 'an interrupted run needs at least one interrupt' if interrupts.empty?
142
+
143
+ pending = interrupts.reject(&:decided?)
144
+ raise ConfigurationError, 'an interrupted run needs at least one unresolved interrupt' if pending.empty?
145
+
146
+ transition_to(INTERRUPTED, now: now, interrupts: interrupts)
147
+ end
148
+
149
+ # Replaces the interrupt with the same id, keeping its position.
150
+ #
151
+ # This is the one transition that also applies to a terminal record: an
152
+ # interrupted run is closed, and answering its interrupt records the
153
+ # decision on that closed record.
154
+ def replace_interrupt(decided, now:)
155
+ raise ConfigurationError, "no interrupt #{decided.id} on run #{run_id}" unless interrupt(decided.id)
156
+
157
+ replaced = interrupts.map { |existing| existing.id == decided.id ? decided : existing }
158
+ with(now: now, interrupts: replaced)
159
+ end
160
+
161
+ def to_h
162
+ {
163
+ 'scope' => scope.to_s,
164
+ 'threadId' => thread_id,
165
+ 'runId' => run_id,
166
+ 'digest' => digest,
167
+ 'status' => status.to_s,
168
+ 'sequence' => sequence,
169
+ 'snapshot' => snapshot,
170
+ 'failure' => failure,
171
+ 'interrupts' => interrupts.map(&:to_h),
172
+ 'createdAt' => iso8601(created_at),
173
+ 'updatedAt' => iso8601(updated_at)
174
+ }
175
+ end
176
+
177
+ # The record in the form a durable store keeps. #to_h is the reporting
178
+ # shape; it leaves out what an interrupt needs to be re-verified, so a
179
+ # store round-trips this instead.
180
+ def to_storage
181
+ to_h.merge('interrupts' => interrupts.map(&:to_storage))
182
+ end
183
+
184
+ # Rebuilds a record stored by #to_storage.
185
+ def self.from_storage(hash)
186
+ new(
187
+ scope: hash.fetch('scope'), thread_id: hash.fetch('threadId'), run_id: hash.fetch('runId'),
188
+ digest: hash.fetch('digest'), status: hash.fetch('status').to_sym,
189
+ sequence: hash.fetch('sequence'), snapshot: hash['snapshot'], failure: hash['failure'],
190
+ interrupts: interrupts_from_storage(hash['interrupts']),
191
+ created_at: Interrupt.parse_time(hash.fetch('createdAt')),
192
+ updated_at: Interrupt.parse_time(hash.fetch('updatedAt'))
193
+ )
194
+ end
195
+
196
+ def self.interrupts_from_storage(list)
197
+ (list || []).map { |interrupt| Interrupt.from_storage(interrupt) }
198
+ end
199
+
200
+ private
201
+
202
+ # rubocop:disable-next Metrics/ParameterLists -- one keyword per field, each defaulted to the record's own
203
+ def with(now:, status: self.status, sequence: self.sequence, snapshot: self.snapshot,
204
+ interrupts: self.interrupts, failure: self.failure)
205
+ self.class.new(
206
+ scope: scope, thread_id: thread_id, run_id: run_id, digest: digest, status: status,
207
+ sequence: sequence, snapshot: snapshot, interrupts: interrupts, failure: failure,
208
+ created_at: created_at, updated_at: now
209
+ )
210
+ end
211
+
212
+ def transition_to(status, now:, **attributes)
213
+ raise StreamError, "run #{run_id} already ended as #{self.status}" if terminal?
214
+
215
+ with(now: now, status: status, **attributes)
216
+ end
217
+
218
+ def iso8601(time)
219
+ time&.utc&.iso8601
220
+ end
221
+
222
+ def assert_status!(value)
223
+ return value if STATUSES.include?(value)
224
+
225
+ raise ConfigurationError, "status must be one of #{STATUSES.join(', ')}"
226
+ end
227
+ end
228
+ end
229
+ end