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)



166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/rubino/ui/api.rb', line 166

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



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

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

#blank_lineObject



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

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



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

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

#compression_started(at: nil) ⇒ Object



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

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.



119
120
121
122
123
124
125
126
127
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
# File 'lib/rubino/ui/api.rb', line 119

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



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

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

#info(message) ⇒ Object



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

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

#job_enqueued(type) ⇒ Object



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

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

#job_finished(type) ⇒ Object



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

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

#job_started(type) ⇒ Object



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

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

#mode_changed(name, previous: nil) ⇒ Object



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

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

#note(text) ⇒ Object



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

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

#reasoning_changed(mode, previous: nil) ⇒ Object



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

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

#reasoning_status(mode) ⇒ Object



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

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

#separatorObject



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

def separator = emit_event(:separator)

#status(message) ⇒ Object



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

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.



68
69
70
71
72
73
# File 'lib/rubino/ui/api.rb', line 68

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



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

def stream_end = emit_event(:stream_end)

#success(message) ⇒ Object



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

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

#table(headers:, rows:) ⇒ Object



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

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

#think_changed(effort, previous: nil) ⇒ Object



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

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

#think_status(effort) ⇒ Object



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

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

#thinking_startedObject



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

def thinking_started = emit_event(:thinking_started)

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



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

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

#tool_chunk(name, chunk) ⇒ Object



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

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

#tool_finished(name, result: nil) ⇒ Object



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

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

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



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

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

#warning(message) ⇒ Object



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

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