Class: Rubino::UI::API

Inherits:
Base
  • Object
show all
Defined in:
lib/rubino/ui/api.rb

Overview

Bridge between Agent::Runner and the HTTP API.

Streaming output (info/success/stream/…) is appended to an in-memory event buffer that the API server drains over SSE.

Interactive prompts cross threads through an ApprovalGate:

  • #confirm emits ‘approval.required` on the recorder and blocks on the gate until an HTTP client posts a decision.

  • #ask emits ‘clarify.required` and blocks the same way.

When no gate/recorder is wired (CLI or test contexts), both calls fall back to auto-approve (#confirm -> true, #ask -> nil).

APPROVE_DECISIONS lists the decision strings that count as approve; anything else yields a false from #confirm. The two deny forms differ only in persistence: “deny” denies this call ONCE (nothing remembered, re-prompts next session); “deny_always” additionally persists a permissions:deny rule so ApprovalPolicy#decide auto-denies the pattern across sessions. The set is kept in sync with Schemas::DecideApproval so every value the HTTP boundary accepts is either an approve or an explicit deny — no unreachable values, no silent denies from typos. ‘always` is a back-compat alias for `always_command` (existing web clients post it).

Constant Summary collapse

APPROVE_DECISIONS =
%w[once session always always_prefix always_command].freeze
ALWAYS_ALIAS =

‘always` from older web clients means the narrow “always this command” form (== always_command); normalized away before decision handling.

{ "always" => "always_command" }.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods inherited from Base

#body, #box_close, #box_open, #confirm_destructive, #hint_row, #input_injected, #panel_line, #queued, #replay_user_input, #select, #turn_interrupted

Constructor Details

#initialize(gate: nil, recorder: nil, session_id: nil, approval_cache: nil) ⇒ API

Returns a new instance of API.



39
40
41
42
43
44
45
# File 'lib/rubino/ui/api.rb', line 39

def initialize(gate: nil, recorder: nil, session_id: nil, approval_cache: nil)
  @gate = gate
  @recorder = recorder
  @session_id = session_id
  @approval_cache = approval_cache || Rubino::Run::SessionApprovalCache.instance
  @events = []
end

Instance Attribute Details

#eventsObject (readonly)

Returns the value of attribute events.



37
38
39
# File 'lib/rubino/ui/api.rb', line 37

def events
  @events
end

Instance Method Details

#ask(prompt) ⇒ String?

Emits ‘clarify.required` and blocks on the ApprovalGate until an HTTP client posts a clarification response for the generated clarify_id. Returns nil when no gate/recorder is wired.

Parameters:

  • prompt (String)

    question to ask the user

Returns:

  • (String, nil)

    the response text, or nil in non-API contexts or when the wait deadline elapsed with no answer (abandoned run)



175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/rubino/ui/api.rb', line 175

def ask(prompt)
  return nil unless @gate && @recorder

  clarify_id = SecureRandom.uuid
  @gate.register(clarify_id, recorder: @recorder)
  @recorder.emit("clarify.required", { clarify_id: clarify_id, question: prompt.to_s })
  answer = @gate.await(clarify_id)
  # Deadline elapsed with no answer: the gate emitted approval.expired;
  # treat an abandoned clarification as "no response".
  return nil if answer.equal?(Run::ApprovalGate::EXPIRED)

  answer
end

#assistant_text(text) ⇒ Object



71
# File 'lib/rubino/ui/api.rb', line 71

def assistant_text(text) = emit_event(:assistant_text, text: text)

#blank_lineObject



105
# File 'lib/rubino/ui/api.rb', line 105

def blank_line = emit_event(:blank_line)

#blocking_human_input?Boolean

The API adapter parks the run on the ApprovalGate for approvals (#confirm) and clarifications (#ask) — but only when a gate AND recorder are actually wired. Without them both calls auto-resolve and never block, so the loop can keep streaming. Drives Loop#interactive_turn?.

Returns:

  • (Boolean)


51
52
53
# File 'lib/rubino/ui/api.rb', line 51

def blocking_human_input?
  !@gate.nil? && !@recorder.nil?
end

#compression_finished(metadata, at: nil) ⇒ Object



97
98
99
# File 'lib/rubino/ui/api.rb', line 97

def compression_finished(, at: nil)
  emit_event(:compression_finished, metadata: , at: at)
end

#compression_started(at: nil) ⇒ Object



95
# File 'lib/rubino/ui/api.rb', line 95

def compression_started(at: nil) = emit_event(:compression_started, at: at)

#confirm(question, scope: nil, tool: nil, command: nil, pattern_key: nil, description: nil) ⇒ Boolean

Emits ‘approval.required` and blocks on the ApprovalGate until an HTTP client posts a decision for the generated approval_id. Auto-approves (returns true) when no gate/recorder is wired.

Parameters:

  • question (String)

    human-readable approval prompt

  • scope (String, nil) (defaults to: nil)

    cache key for “session”/“always” decisions; pass ‘“<tool>:<args>”` so a second call with the same shape bypasses the user prompt entirely. Nil opts out.

  • tool (String, nil) (defaults to: nil)

    tool name, for the enriched event.

  • command (String, nil) (defaults to: nil)

    literal command/args, for the event + prefix derivation when a decision persists.

  • pattern_key (String, nil) (defaults to: nil)

    matched dangerous-pattern key, if any.

  • description (String, nil) (defaults to: nil)

    dangerous-pattern description, if any.

Returns:

  • (Boolean)

    true when the decision is in APPROVE_DECISIONS; false on an explicit deny OR when the gate’s wait deadline elapses with no answer (abandoned run) — the safe auto-DENY default.



128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/rubino/ui/api.rb', line 128

def confirm(question, scope: nil, tool: nil, command: nil, pattern_key: nil, description: nil)
  return true unless @gate && @recorder

  # Session-scope short-circuit: a prior "session" / "always_*"
  # decision (or a persisted prefix) for this scope means we must NOT
  # prompt again in the same session.
  return true if scope && @session_id && @approval_cache.allowed?(@session_id, scope)

  rule = derive_rule(tool, command, pattern_key)

  approval_id = SecureRandom.uuid
  # Register before emitting: a fast HTTP client could POST a decision
  # the moment it sees approval.required, racing past #await; the gate
  # must already know the id is valid by then.
  @gate.register(approval_id, recorder: @recorder)
  @recorder.emit(
    "approval.required",
    approval_payload(approval_id, question, tool: tool, command: command,
                                            pattern_key: pattern_key, description: description, rule: rule)
  )
  decision = @gate.await(approval_id)
  # Wait deadline elapsed with no human answer (abandoned run): the gate
  # already emitted approval.expired. Resolve to a safe DENY — NEVER an
  # auto-approve — so the gated command does not run.
  return false if decision.equal?(Run::ApprovalGate::EXPIRED)

  normalized = normalize_decision(decision)
  approved = APPROVE_DECISIONS.include?(normalized)

  if approved
    apply_decision(normalized, scope: scope, command: command, rule: rule)
  elsif normalized == "deny_always"
    # Not an approve, but PERSIST the deny so ApprovalPolicy#decide
    # auto-denies this pattern across sessions (it checks permissions:deny
    # first). Plain "deny" stays a one-off — nothing persisted, re-prompts.
    persist_deny(tool, command, rule)
  end
  approved
end

#error(message) ⇒ Object



68
# File 'lib/rubino/ui/api.rb', line 68

def error(message) = emit_event(:error, message: message)

#info(message) ⇒ Object



64
# File 'lib/rubino/ui/api.rb', line 64

def info(message) = emit_event(:info, message: message)

#interactive?Boolean

ToolExecutor reads this to decide whether a tool needing approval can be put in front of a human (#260). The API adapter CAN — via the HTTP ApprovalGate — but only when a gate + recorder are wired; a gate-less embed/test run has no one to answer, so it fails closed too instead of silently auto-approving a write/shell command.

Returns:

  • (Boolean)


60
61
62
# File 'lib/rubino/ui/api.rb', line 60

def interactive?
  blocking_human_input?
end

#job_enqueued(type) ⇒ Object



101
# File 'lib/rubino/ui/api.rb', line 101

def job_enqueued(type) = emit_event(:job_enqueued, type: type)

#job_finished(type) ⇒ Object



103
# File 'lib/rubino/ui/api.rb', line 103

def job_finished(type) = emit_event(:job_finished, type: type)

#job_started(type) ⇒ Object



102
# File 'lib/rubino/ui/api.rb', line 102

def job_started(type) = emit_event(:job_started, type: type)

#mode_changed(name, previous: nil) ⇒ Object



106
# File 'lib/rubino/ui/api.rb', line 106

def mode_changed(name, previous: nil) = emit_event(:mode_changed, mode: name, previous: previous)

#note(text) ⇒ Object



70
# File 'lib/rubino/ui/api.rb', line 70

def note(text) = emit_event(:note, text: text)

#reasoning_changed(mode, previous: nil) ⇒ Object



108
# File 'lib/rubino/ui/api.rb', line 108

def reasoning_changed(mode, previous: nil) = emit_event(:reasoning_changed, mode: mode, previous: previous)

#reasoning_status(mode) ⇒ Object



107
# File 'lib/rubino/ui/api.rb', line 107

def reasoning_status(mode) = emit_event(:reasoning_status, mode: mode)

#separatorObject



104
# File 'lib/rubino/ui/api.rb', line 104

def separator = emit_event(:separator)

#status(message) ⇒ Object



69
# File 'lib/rubino/ui/api.rb', line 69

def status(message) = emit_event(:status, message: message)

#stream(chunk) ⇒ Object

The adapter no longer drops :thinking deltas in hidden mode (the CLI retains them unrendered for the Ctrl-O reveal, #76); the HTTP wire keeps the old contract — hidden means no reasoning deltas reach API consumers, so the gate lives here now.



77
78
79
80
81
82
# File 'lib/rubino/ui/api.rb', line 77

def stream(chunk)
  return if chunk.is_a?(Hash) && chunk[:type] == :thinking &&
            Config::ReasoningPrefs.mode(Rubino.configuration) == :hidden

  emit_event(:stream, chunk: chunk)
end

#stream_endObject



84
# File 'lib/rubino/ui/api.rb', line 84

def stream_end = emit_event(:stream_end)

#success(message) ⇒ Object



66
# File 'lib/rubino/ui/api.rb', line 66

def success(message) = emit_event(:success, message: message)

#table(headers:, rows:) ⇒ Object



86
# File 'lib/rubino/ui/api.rb', line 86

def table(headers:, rows:) = emit_event(:table, headers: headers, rows: rows)

#think_changed(effort, previous: nil) ⇒ Object



110
# File 'lib/rubino/ui/api.rb', line 110

def think_changed(effort, previous: nil) = emit_event(:think_changed, effort: effort, previous: previous)

#think_status(effort) ⇒ Object



109
# File 'lib/rubino/ui/api.rb', line 109

def think_status(effort) = emit_event(:think_status, effort: effort)

#thinking_startedObject



85
# File 'lib/rubino/ui/api.rb', line 85

def thinking_started = emit_event(:thinking_started)

#tool_body(text, kind: :plain) ⇒ Object



92
# File 'lib/rubino/ui/api.rb', line 92

def tool_body(text, kind: :plain) = emit_event(:tool_body, text: text, kind: kind)

#tool_chunk(name, chunk, kind: :plain) ⇒ Object



93
# File 'lib/rubino/ui/api.rb', line 93

def tool_chunk(name, chunk, kind: :plain) = emit_event(:tool_chunk, name: name, chunk: chunk, kind: kind)

#tool_finished(name, result: nil) ⇒ Object



94
# File 'lib/rubino/ui/api.rb', line 94

def tool_finished(name, result: nil) = emit_event(:tool_finished, name: name)

#tool_started(name, arguments: nil, at: nil) ⇒ Object



88
89
90
# File 'lib/rubino/ui/api.rb', line 88

def tool_started(name, arguments: nil, at: nil)
  emit_event(:tool_started, name: name, arguments: arguments, at: at)
end

#warning(message) ⇒ Object



67
# File 'lib/rubino/ui/api.rb', line 67

def warning(message) = emit_event(:warning, message: message)