pinecall 0.0.1

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 (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +167 -0
  3. data/LICENSE +202 -0
  4. data/README.md +140 -0
  5. data/lib/pinecall/agent/author.rb +31 -0
  6. data/lib/pinecall/agent/config.rb +124 -0
  7. data/lib/pinecall/agent/doc.rb +73 -0
  8. data/lib/pinecall/agent/knowledge.rb +18 -0
  9. data/lib/pinecall/agent/searching.rb +33 -0
  10. data/lib/pinecall/agent/spec.rb +133 -0
  11. data/lib/pinecall/agent/state.rb +185 -0
  12. data/lib/pinecall/agent/tools.rb +119 -0
  13. data/lib/pinecall/agent.rb +160 -0
  14. data/lib/pinecall/blocks.rb +152 -0
  15. data/lib/pinecall/bridge.rb +239 -0
  16. data/lib/pinecall/call_world/answers.rb +61 -0
  17. data/lib/pinecall/call_world/room.rb +25 -0
  18. data/lib/pinecall/call_world.rb +175 -0
  19. data/lib/pinecall/client/agent.rb +179 -0
  20. data/lib/pinecall/client/call.rb +140 -0
  21. data/lib/pinecall/client/connection.rb +190 -0
  22. data/lib/pinecall/client/endpoints.rb +30 -0
  23. data/lib/pinecall/client/listeners.rb +42 -0
  24. data/lib/pinecall/client/observe.rb +109 -0
  25. data/lib/pinecall/client/rest.rb +61 -0
  26. data/lib/pinecall/client.rb +212 -0
  27. data/lib/pinecall/errors.rb +53 -0
  28. data/lib/pinecall/panel.rb +138 -0
  29. data/lib/pinecall/reading.rb +37 -0
  30. data/lib/pinecall/rules.rb +38 -0
  31. data/lib/pinecall/serve/held.rb +40 -0
  32. data/lib/pinecall/serve/loading.rb +102 -0
  33. data/lib/pinecall/serve/viewing.rb +40 -0
  34. data/lib/pinecall/serve.rb +169 -0
  35. data/lib/pinecall/testing.rb +190 -0
  36. data/lib/pinecall/version.rb +6 -0
  37. data/lib/pinecall/view.rb +70 -0
  38. data/lib/pinecall/wire/codec.rb +75 -0
  39. data/lib/pinecall/wire/enums.rb +104 -0
  40. data/lib/pinecall/wire/errors.rb +12 -0
  41. data/lib/pinecall/wire/reduce.rb +249 -0
  42. data/lib/pinecall/wire/registry.rb +168 -0
  43. data/lib/pinecall/wire/shapes.rb +33 -0
  44. data/lib/pinecall/wire/shapes_call_events.rb +137 -0
  45. data/lib/pinecall/wire/shapes_commands.rb +114 -0
  46. data/lib/pinecall/wire/shapes_config.rb +70 -0
  47. data/lib/pinecall/wire/shapes_doors.rb +96 -0
  48. data/lib/pinecall/wire/shapes_events.rb +358 -0
  49. data/lib/pinecall/wire/shapes_metrics.rb +109 -0
  50. data/lib/pinecall/wire/shapes_parts.rb +340 -0
  51. data/lib/pinecall/wire/state.rb +56 -0
  52. data/lib/pinecall/wire/validate.rb +154 -0
  53. data/lib/pinecall/wire.rb +43 -0
  54. data/lib/pinecall.rb +45 -0
  55. data/sig/pinecall.rbs +453 -0
  56. metadata +119 -0
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "call_world/room"
4
+ require_relative "call_world/answers"
5
+
6
+ module Pinecall
7
+ # The call as an agent sees it: room, turns, and the commands it can send.
8
+ #
9
+ # State is reduced from received entries; each verb is one wire command. No LiveKit access by
10
+ # design: a missing capability should become a new command of the wire.
11
+ class CallWorld
12
+ attr_reader :id, :contact, :from, :channel, :medium, :room, :turns, :today, :claimed
13
+
14
+ # `send` puts one command on the wire for this call; `searching` asks the gateway to search for
15
+ # it. Both are supplied by the bridge. `today` is the day the call opened, YYYY-MM-DD. `medium` is
16
+ # spoken (`voice`) or written (`text`); a gateway that does not say gets it from the channel.
17
+ def initialize(id:, contact: nil, from: nil, channel: nil, medium: nil, today: Time.now.strftime("%Y-%m-%d"),
18
+ claimed: nil, searching: nil, &send)
19
+ @id = id
20
+ @contact = contact
21
+ @from = from
22
+ @channel = channel
23
+ @medium = (medium || (channel.to_s == "whatsapp" ? "text" : "voice")).to_s
24
+ @today = today
25
+ @claimed = claimed
26
+ @searching = searching
27
+ @send = send
28
+ @room = { participants: [], caller: nil }
29
+ @turns = []
30
+ @waiting = Waiting.new
31
+ @cause = nil
32
+ @events = 0
33
+ end
34
+
35
+ # The external event being handled, if any; logged as the cause of state changes.
36
+ attr_accessor :cause
37
+
38
+ # Next per-call event sequence number (not the wire's seq).
39
+ def numbered = @events += 1
40
+
41
+ # ── commands ─────────────────────────────────────────────────────────────
42
+
43
+ # Speak `text` verbatim now. Returns true once the turn arrives, false after `LANDS_WITHIN_S`.
44
+ def say(text, **options)
45
+ @waiting.wait(:spoken, LANDS_WITHIN_S, false) { @send.call("agent.say", { text: }.merge(options)) }
46
+ end
47
+
48
+ # Make the model speak now, guided by instructions the caller does not hear.
49
+ def reply(instructions, **options)
50
+ @waiting.wait(:spoken, LANDS_WITHIN_S, false) { @send.call("agent.reply", { instructions: }.merge(options)) }
51
+ end
52
+
53
+ # Search the knowledge bases attached to this agent; the gateway runs it and logs the results.
54
+ # `k` defaults to each base's own setting.
55
+ def search(query, k: nil)
56
+ raise Error, NO_GATEWAY_TO_SEARCH if @searching.nil?
57
+
58
+ @searching.call(query, k).map { |chunk| Found.new(path: chunk[:path], heading: chunk[:heading], text: chunk[:text]) }
59
+ end
60
+
61
+ # Transfer the caller to a number: sent on (cold) on a phone call, dialled into the call (warm)
62
+ # in a browser; `mode:` forces one. `ok: false` means the caller is still with the agent.
63
+ def transfer(to, mode: nil)
64
+ wanted = mode.nil? ? { to: } : { to:, mode: mode.to_s }
65
+ lapsed = Transferred.new(to:, mode: nil, ok: false, error: NO_ANSWER)
66
+ @waiting.wait(:transfers, TRANSFER_WITHIN_S, lapsed) { @send.call("call.transfer", wanted) }
67
+ end
68
+
69
+ # Ask for a supervisor; the caller waits until someone takes the line or `wait_s` passes.
70
+ # `reason` is shown to the supervisor. The calling tool runs the whole time.
71
+ def attention(reason, wait_s:)
72
+ lapsed = Attended.new(ok: false, by: nil, error: NO_ANSWER)
73
+ @waiting.wait(:asks, wait_s + A_MOMENT_S, lapsed) { @send.call("call.attention", { reason:, wait_s: }) }
74
+ end
75
+
76
+ # Put the caller on hold: they hear hold music and the agent neither speaks nor listens.
77
+ def hold = @send.call("call.hold", {})
78
+
79
+ # Take the caller off hold.
80
+ def unhold = @send.call("call.unhold", {})
81
+
82
+ # Send DTMF tones: `0-9`, `*`, `#`, and `,` for a pause.
83
+ def dtmf(digits) = @send.call("call.dtmf", { digits: })
84
+
85
+ # Bind this call to the four-digit code shown on the caller's page; `claimed` is set when the
86
+ # log says it took. A code that is not four digits never reaches the wire.
87
+ def claim(code) = @send.call("call.claim", { code: })
88
+
89
+ # Record a callback request in the call's log for the backend to dial. `at:` is the wire's `when`.
90
+ def callback(number, at: nil, note: nil)
91
+ @send.call("call.callback", { number:, when: at, note: }.compact)
92
+ end
93
+
94
+ # Send a payload to browsers in the room. The log records its size, not its content.
95
+ def send_to(topic, data, to: nil)
96
+ payload = { topic:, data: }
97
+ payload[:to] = Array(to) unless to.nil?
98
+ @send.call("room.send", payload)
99
+ end
100
+
101
+ # A participant handle by identity.
102
+ def participant(identity) = Seat.new(identity, @send)
103
+
104
+ # Invite a second SIP leg or a person.
105
+ def invite(to, kind: nil)
106
+ wanted = { to: }
107
+ wanted[:kind] = kind.to_s unless kind.nil?
108
+ @send.call("room.invite", wanted)
109
+ end
110
+
111
+ # Append an application entry to the call's log.
112
+ def log(name, data = {})
113
+ @send.call("call.log", { name: name.to_s, data: data.is_a?(Hash) ? data : { value: data } })
114
+ end
115
+
116
+ # The caller asked never to be called again: their number joins the org's do-not-call list, and no
117
+ # call of the org reaches it until a consent is recorded. Tell them it is done.
118
+ def opt_out(note = nil)
119
+ @send.call("call.opt_out", note.nil? ? {} : { note: })
120
+ end
121
+
122
+ # End the call; `call.ended` follows with reason `agent_hung_up`.
123
+ def hangup(reason = nil)
124
+ @send.call("call.hangup", reason.nil? ? {} : { reason: })
125
+ end
126
+
127
+ # ── entries ──────────────────────────────────────────────────────────────
128
+
129
+ # Apply one log entry to the room, the turns and the verbs waiting on it.
130
+ def take(type, data, at)
131
+ case type
132
+ when "participant.joined" then joined(data, at)
133
+ when "participant.left" then @room[:participants].reject! { |one| one.identity == data[:identity] }
134
+ when "participant.speaking" then speaking(data)
135
+ when "turn.user" then remember("user", data, at)
136
+ when "turn.agent" then remember("agent", data, at).tap { @waiting.settle(:spoken, true) }
137
+ when "call.claimed" then @claimed = data[:code].to_s
138
+ when "call.transferred" then @waiting.settle(:transfers, transferred(data))
139
+ when "attention.answered" then @waiting.settle(:asks, Attended.new(ok: data[:ok] == true, by: data[:by], error: data[:error]))
140
+ when "call.ended" then @waiting.end_all
141
+ end
142
+ end
143
+
144
+ def participants = @room[:participants].dup
145
+
146
+ # The caller's participant, or nil.
147
+ def caller_seat = @room[:participants].find { |one| one.kind == "caller" }
148
+
149
+ def last_turn = @turns.last
150
+
151
+ private
152
+
153
+ def joined(data, at)
154
+ @room[:participants] << Participant.new(
155
+ identity: data[:identity], kind: data[:kind], name: data[:name], joined_at: at, speaking: false
156
+ )
157
+ end
158
+
159
+ def speaking(data)
160
+ who = @room[:participants].find { |one| one.identity == data[:identity] }
161
+ who.speaking = data[:speaking] unless who.nil?
162
+ end
163
+
164
+ def remember(who, data, at)
165
+ turn = Turn.new(who:, text: data[:text], speech_id: data[:speech_id],
166
+ interrupted: data[:interrupted] || false, at:)
167
+ @turns << turn
168
+ turn
169
+ end
170
+
171
+ def transferred(data)
172
+ Transferred.new(to: data[:to].to_s, mode: data[:mode], ok: data[:ok] == true, error: data[:error])
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinecall
4
+ class Client
5
+ # The socket-level handle for one agent slug: its declaration, tool runners and listeners.
6
+ # (`Pinecall::Agent` is the class a developer writes; `Bridge` connects the two.)
7
+ #
8
+ # The gateway's registry is in memory, so the declaration is re-sent on every reconnect.
9
+ # Several sockets may hold one slug; unclaimed calls go to the newest that `takes_unclaimed`,
10
+ # so a rolling deploy serves immediately and a console never gets calls it did not open.
11
+ class Agent
12
+ ANSWERS_WITHIN_S = 10
13
+
14
+ NO_DEV_HANDLER = "this process answers no dev verbs: it is not a `pinecall start` in the agent's directory"
15
+
16
+ attr_reader :slug, :calls, :config, :app
17
+
18
+ def initialize(slug, options, client)
19
+ @slug = slug
20
+ @client = client
21
+ @routes = options[:routes] || []
22
+ @takes_unclaimed = options.fetch(:takes_unclaimed, true)
23
+ @tools = {}
24
+ @waiting = []
25
+ @config = options.reject { |key, _| %i[routes tools takes_unclaimed].include?(key) }
26
+ @app = nil
27
+ @running = []
28
+ @dev = nil
29
+ @lock = Mutex.new
30
+ declare(options[:tools] || [])
31
+ @calls = CallBook.new(self)
32
+ @listeners = Listeners.new { |error| on_error(error) }
33
+ end
34
+
35
+ # Listen for one event type across this agent's calls.
36
+ def on(type, &listener) = @listeners.on(type, &listener)
37
+
38
+ # Listen for every event of this agent.
39
+ def on_any(&listener) = @listeners.on_any(&listener)
40
+
41
+ # Answer the console's `dev.request` asks: the block takes the verb and its data and returns the
42
+ # answer, or raises `DevRefused` to refuse with a status. Without one every ask is a 501.
43
+ def on_dev(&handler)
44
+ @dev = handler
45
+ end
46
+
47
+ # Replace the tools; sent with the next `configure`.
48
+ #
49
+ # A tool is `{ **spec, run: ->(arguments, call) { … } }`.
50
+ def declare(tools)
51
+ @tools = tools.to_h { |tool| [tool[:name].to_s, tool] }
52
+ @config = @config.merge(tools: tools.map { |tool| tool.reject { |key, _| key == :run } })
53
+ end
54
+
55
+ # Update the agent config. Only the given fields change; live calls keep their session.
56
+ def configure(changes = {})
57
+ @config = @config.merge(changes)
58
+ ask("agent.configure", lands_as: "agent.configured", data: { config: @config })
59
+ end
60
+
61
+ # Register the slug and send the config. Runs on every reconnect.
62
+ def open
63
+ registered = ask("agent.register", lands_as: "agent.registered", data: {
64
+ routes: @routes, sdk: @client.sdk, host: Socket.gethostname,
65
+ takes_unclaimed: @takes_unclaimed
66
+ })
67
+ # Each connection gets a new app id.
68
+ @app = registered[:app]
69
+ configure
70
+ self
71
+ end
72
+
73
+ # ── called by the client ───────────────────────────────────────────────
74
+
75
+ # Apply an entry to its call and notify listeners.
76
+ def take(entry)
77
+ event = Wire.event_of(entry)
78
+ call = entry.call.nil? ? nil : @calls.of(entry.call, entry.ts)
79
+ settle(event)
80
+ call&.take(event)
81
+ @listeners.emit(event, call)
82
+ @client.seen(event, call)
83
+ return answer_the_console(event.data) if event.type == "dev.request"
84
+ return if call.nil?
85
+
86
+ run_tool(event, call) if event.type == "tool.call"
87
+ @calls.forget(call)
88
+ end
89
+
90
+ def command(type, call, data)
91
+ @client.send_command(type:, agent: @slug, call:, data:, id: "#{@slug}:#{type}")
92
+ end
93
+
94
+ def on_error(error) = @client.on_error(error)
95
+
96
+ # Agent-scoped heartbeat; answered with `pong`.
97
+ def ping = command("ping", nil, {})
98
+
99
+ # Stop taking new calls and hand the live ones to another holder, or park them for the next.
100
+ # Running tools still answer. Returns where the calls went: `{ handed:, parked: }`.
101
+ def drain(answer_s: ANSWERS_WITHIN_S) = ask("agent.drain", lands_as: "agent.draining", data: {}, within_s: answer_s)
102
+
103
+ # How many tool runs have not sent their `tool.result` yet.
104
+ def in_flight = @lock.synchronize { @running.count(&:alive?) }
105
+
106
+ # Wait until every running tool has answered, or `within_s` passed.
107
+ def settled(within_s: nil)
108
+ deadline = within_s && (now + within_s)
109
+ @lock.synchronize { @running.dup }.each { |thread| thread.join(deadline && [deadline - now, 0].max) }
110
+ nil
111
+ end
112
+
113
+ private
114
+
115
+ # Always answer with exactly one `tool.result`: without one the turn waits forever.
116
+ # Runs on its own thread so a slow tool does not block the socket reader.
117
+ def run_tool(event, call)
118
+ call_id, name, arguments = event.data.values_at(:call_id, :name, :arguments)
119
+ tool = @tools[name.to_s]
120
+ return call.tool_result({ call_id:, name:, error: "this app declares no tool called #{name}" }) if tool.nil?
121
+
122
+ running = Thread.new do
123
+ started = now
124
+ begin
125
+ output = tool[:run].call(arguments || {}, call)
126
+ call.tool_result({ call_id:, name:, output:, duration_s: now - started })
127
+ rescue StandardError => e
128
+ # Report the exception to the model as the tool's `error`.
129
+ call.tool_result({ call_id:, name:, error: e.message, duration_s: now - started })
130
+ end
131
+ end
132
+ @lock.synchronize { (@running << running).select!(&:alive?) }
133
+ end
134
+
135
+ # Every ask needs one answer, or the console's request hangs until the gateway gives up. On a
136
+ # thread of its own: a panel reads the tenant's systems, and this is the socket's reader.
137
+ def answer_the_console(asked)
138
+ id, verb, data = asked.values_at(:id, :verb, :data)
139
+ Thread.new do
140
+ raise DevRefused.new(501, NO_DEV_HANDLER) if @dev.nil?
141
+
142
+ command("dev.answer", nil, { id:, result: @dev.call(verb, data || {}) })
143
+ rescue DevRefused => e
144
+ command("dev.answer", nil, { id:, refused: { status: e.status, detail: e.detail } })
145
+ rescue StandardError => e
146
+ command("dev.answer", nil, { id:, refused: { status: 500, detail: e.message } })
147
+ end
148
+ end
149
+
150
+ # Await the reply event, or an `error` carrying our id. Only register/configure are awaited;
151
+ # other commands are fire-and-forget.
152
+ def ask(type, lands_as:, data:, within_s: ANSWERS_WITHIN_S)
153
+ id = "#{@slug}:#{type}"
154
+ answer = Thread::Queue.new
155
+ @waiting << { type: lands_as, id:, answer: }
156
+ @client.send_command(type:, agent: @slug, call: nil, data:, id:)
157
+ settled = answer.pop(timeout: within_s)
158
+ raise NotConnected, "#{type}: the gateway did not answer in #{within_s}s" if settled.nil?
159
+ raise settled if settled.is_a?(Exception)
160
+
161
+ settled
162
+ ensure
163
+ @waiting.reject! { |one| one[:answer].equal?(answer) }
164
+ end
165
+
166
+ def settle(event)
167
+ @waiting.dup.each do |waiting|
168
+ if event.type == waiting[:type]
169
+ waiting[:answer].push(event.data)
170
+ elsif event.type == "error" && event.data[:id] == waiting[:id]
171
+ waiting[:answer].push(Refused.new(event.data))
172
+ end
173
+ end
174
+ end
175
+
176
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinecall
4
+ class Client
5
+ # A call being served: line details read from the log, and the commands it can send.
6
+ #
7
+ # Commands are not awaited; their effects arrive later as log entries.
8
+ class Call
9
+ attr_reader :id, :today, :claimed, :run, :medium
10
+ attr_accessor :status, :channel, :from, :to, :contact, :state
11
+
12
+ def initialize(id, agent, opened_at)
13
+ @id = id
14
+ @agent = agent
15
+ @today = Time.at(opened_at).strftime("%Y-%m-%d")
16
+ @status = "ringing"
17
+ @channel = nil
18
+ # Spoken or written, as `call.started` says; nil before it, and from a gateway that does not say.
19
+ @medium = nil
20
+ @from = nil
21
+ @to = nil
22
+ @contact = nil
23
+ @claimed = nil
24
+ @run = nil
25
+ @state = {}
26
+ @listeners = Listeners.new { |error| agent.on_error(error) }
27
+ end
28
+
29
+ def agent_slug = @agent.slug
30
+
31
+ # Listen for one event type on this call; the returned lambda unsubscribes.
32
+ def on(type, &listener) = @listeners.on(type, &listener)
33
+
34
+ # Listen for every event on this call.
35
+ def on_any(&listener) = @listeners.on_any(&listener)
36
+
37
+ # ── commands ───────────────────────────────────────────────────────────
38
+
39
+ # Speak `text` verbatim now, as a `turn.agent`.
40
+ def say(text, **options) = command("agent.say", { text: }.merge(options))
41
+
42
+ # Make the model speak now, guided by instructions the caller does not hear.
43
+ def reply(instructions, **options) = command("agent.reply", { instructions: }.merge(options))
44
+
45
+ # Replace one prompt block by name.
46
+ def set_prompt(name, text) = command("prompt.set", { name: name.to_s, text: })
47
+
48
+ # Set the tools currently visible to the model (a subset of the declared ones).
49
+ def set_tools(tools) = command("tools.set", { tools: })
50
+
51
+ # Send the full state; answered with `state.changed`.
52
+ def set_state(state, changed = nil)
53
+ @state = state
54
+ wire = { state: }
55
+ wire[:changed] = Array(changed).map(&:to_s) unless changed.nil?
56
+ command("state.set", wire)
57
+ end
58
+
59
+ def tool_result(result) = command("tool.result", result)
60
+
61
+ # Send an event from the application's backend; the agent must declare `name`.
62
+ def event(name, data) = command("call.event", { name: name.to_s, data: })
63
+
64
+ # End the call; `call.ended` follows with reason `agent_hung_up`.
65
+ def hangup(reason = nil) = command("call.hangup", reason.nil? ? {} : { reason: })
66
+
67
+ # Append an application entry to the call's log, as a `custom` entry.
68
+ def log(name, data = {}) = command("call.log", { name: name.to_s, data: })
69
+
70
+ # Send any call-scoped command of the wire.
71
+ def command(type, data) = @agent.command(type, @id, data)
72
+
73
+ # ── entries ────────────────────────────────────────────────────────────
74
+
75
+ # Apply an event to the call, then notify listeners.
76
+ def take(event)
77
+ learn(event)
78
+ @listeners.emit(event, self)
79
+ end
80
+
81
+ private
82
+
83
+ def learn(event)
84
+ case event.type
85
+ when "call.ringing" then the_line(event.data, "ringing")
86
+ when "call.dialing" then the_line(event.data, "dialing")
87
+ when "call.started" then started(event.data)
88
+ when "call.attached" then attached(event.data)
89
+ when "call.ended" then @status = "ended"
90
+ when "state.changed" then @state = event.data[:state].dup
91
+ when "call.claimed" then @claimed = event.data[:code]
92
+ end
93
+ end
94
+
95
+ # Handed over mid-conversation: the call's own start, the state it is in, the code it claimed.
96
+ def attached(data)
97
+ line = data[:started]
98
+ started(line)
99
+ @state = data[:state].dup
100
+ @today = Time.at(line[:started_at]).strftime("%Y-%m-%d")
101
+ @claimed = data[:claimed]
102
+ end
103
+
104
+ def started(line)
105
+ the_line(line, "active")
106
+ @medium = line[:medium]
107
+ end
108
+
109
+ def the_line(line, status)
110
+ @status = status
111
+ @channel = line[:channel]
112
+ @from = line[:from]
113
+ @to = line[:to]
114
+ @contact = line[:caller]
115
+ @run = line[:run]
116
+ end
117
+ end
118
+
119
+ # Live calls by id; a call is dropped once it has ended.
120
+ class CallBook
121
+ def initialize(agent)
122
+ @agent = agent
123
+ @live = {}
124
+ @lock = Mutex.new
125
+ end
126
+
127
+ def live = @lock.synchronize { @live.values.dup }
128
+
129
+ # Find or create the call for an entry.
130
+ def of(id, at)
131
+ @lock.synchronize { @live[id] ||= Call.new(id, @agent, at) }
132
+ end
133
+
134
+ # Drop an ended call; `call.ended` listeners have already run.
135
+ def forget(call)
136
+ @lock.synchronize { @live.delete(call.id) } if call.status == "ended"
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinecall
4
+ class Client
5
+ # The gateway websocket: auth, reconnect with backoff, heartbeat, and one reader thread.
6
+ #
7
+ # The key goes in the `Authorization` header, never the URL (URLs end up in access logs).
8
+ # A bad key closes with 1008 and no body. `websocket-driver` handles framing over a plain
9
+ # TCP/TLS socket, so no event loop is needed.
10
+ class Connection
11
+ FIRST_WAIT_S = 0.5
12
+ LONGEST_WAIT_S = 30.0
13
+ GROWS_BY = 2
14
+ PINGS_EVERY_S = 30
15
+
16
+ Handlers = Data.define(:on_open, :on_entry, :on_error, :on_heartbeat)
17
+
18
+ attr_reader :url
19
+
20
+ def initialize(url:, api_key:, handlers:, env: nil, ping_every: PINGS_EVERY_S, backoff: {})
21
+ @url = Endpoints.apps(url)
22
+ @api_key = api_key
23
+ @env = env
24
+ @handlers = handlers
25
+ @ping_every = ping_every
26
+ @backoff = { first: FIRST_WAIT_S, longest: LONGEST_WAIT_S, grows_by: GROWS_BY }.merge(backoff)
27
+ @lock = Mutex.new
28
+ @open = false
29
+ @closed = false
30
+ @attempt = 0
31
+ end
32
+
33
+ def open? = @open
34
+
35
+ # Connect and return once `on_open` has run; reconnects are handled internally afterwards.
36
+ def start
37
+ @closed = false
38
+ @opened = Thread::Queue.new
39
+ @reader = Thread.new { dial_until_closed }
40
+ @reader.abort_on_exception = false
41
+ answer = @opened.pop(timeout: 30)
42
+ # Later failures go to `on_error`, not to this caller.
43
+ @opened = nil
44
+ raise NotConnected, "the gateway did not answer in 30s: #{@url}" if answer.nil?
45
+ raise answer if answer.is_a?(Exception)
46
+
47
+ self
48
+ end
49
+
50
+ # Raises when disconnected instead of queueing: a queued command could land after its call ended.
51
+ def send_frame(command)
52
+ @lock.synchronize do
53
+ raise NotConnected, "#{command.type}: the gateway is not connected" unless @open
54
+
55
+ @driver.text(Wire.encode(command))
56
+ end
57
+ end
58
+
59
+ # A drain was asked: the gateway closing the socket from now on is not dialled back.
60
+ def leaving! = @leaving = true
61
+
62
+ # Close without reconnecting.
63
+ def close
64
+ @closed = true
65
+ @open = false
66
+ @heartbeat&.kill
67
+ @socket&.close
68
+ # A stop is read on the reader thread itself, which cannot wait for its own end.
69
+ @reader&.join(1) unless Thread.current.equal?(@reader)
70
+ nil
71
+ end
72
+
73
+ # Called by websocket-driver.
74
+ def write(bytes)
75
+ @socket.write(bytes)
76
+ rescue IOError, SystemCallError => e
77
+ @handlers.on_error.call(e)
78
+ end
79
+
80
+ private
81
+
82
+ def dial_until_closed
83
+ until @closed
84
+ begin
85
+ dial
86
+ rescue StandardError => e
87
+ fail_the_first_dial(e)
88
+ end
89
+ break if @closed || @leaving
90
+
91
+ sleep(wait_s)
92
+ end
93
+ end
94
+
95
+ def dial
96
+ @socket = connect_to(URI.parse(@url))
97
+ @driver = WebSocket::Driver.client(self)
98
+ @driver.set_header("Authorization", "Bearer #{@api_key}")
99
+ @driver.set_header(Rest::ENV_HEADER, @env) unless @env.nil?
100
+ @driver.on(:open) { opened }
101
+ @driver.on(:message) { |event| took(event.data) }
102
+ @driver.on(:close) { |event| shut(event) }
103
+ @driver.start
104
+ read_until_closed
105
+ end
106
+
107
+ def connect_to(url)
108
+ port = url.port || (url.scheme == "wss" ? 443 : 80)
109
+ socket = TCPSocket.new(url.host, port)
110
+ return socket unless url.scheme == "wss"
111
+
112
+ context = OpenSSL::SSL::SSLContext.new
113
+ context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
114
+ tls = OpenSSL::SSL::SSLSocket.new(socket, context)
115
+ tls.hostname = url.host
116
+ tls.sync_close = true
117
+ tls.connect
118
+ tls
119
+ end
120
+
121
+ def read_until_closed
122
+ loop do
123
+ bytes = @socket.readpartial(4096)
124
+ @driver.parse(bytes)
125
+ end
126
+ rescue EOFError, IOError, SystemCallError, OpenSSL::SSL::SSLError
127
+ @open = false
128
+ end
129
+
130
+ # Registration runs on its own thread: this is the reader thread, and `agent.register` is
131
+ # answered by an entry it has to read.
132
+ def opened
133
+ @open = true
134
+ @attempt = 0
135
+ @heartbeat = Thread.new { beat }
136
+ Thread.new { declare }
137
+ end
138
+
139
+ def declare
140
+ @handlers.on_open.call
141
+ @opened&.push(:open)
142
+ rescue StandardError => e
143
+ # First connection: raise from `start`. Reconnect: report via `on_error`.
144
+ @opened.nil? || @opened.closed? ? @handlers.on_error.call(e) : @opened.push(e)
145
+ end
146
+
147
+ def took(raw)
148
+ @handlers.on_entry.call(Wire.decode_entry(JSON.parse(raw, symbolize_names: true)))
149
+ rescue StandardError => e
150
+ @handlers.on_error.call(e)
151
+ end
152
+
153
+ def shut(event)
154
+ @open = false
155
+ @heartbeat&.kill
156
+ return if @closed
157
+
158
+ # Only a failed first connection is reported to `start`; later closes just retry.
159
+ return unless @opened && !@opened.closed? && @attempt.zero?
160
+
161
+ @opened.push(NotConnected.new("the gateway refused the socket: closed with #{event.code}"))
162
+ end
163
+
164
+ def fail_the_first_dial(error)
165
+ @open = false
166
+ return @handlers.on_error.call(error) unless @opened && !@opened.closed? && @attempt.zero?
167
+
168
+ @opened.push(NotConnected.new("#{@url}: #{error.message}"))
169
+ end
170
+
171
+ def beat
172
+ loop do
173
+ sleep(@ping_every)
174
+ break unless @open
175
+
176
+ @handlers.on_heartbeat.call
177
+ end
178
+ rescue StandardError => e
179
+ @handlers.on_error.call(e)
180
+ end
181
+
182
+ # Full jitter, so clients do not reconnect in lockstep.
183
+ def wait_s
184
+ window = [@backoff[:longest], @backoff[:first] * (@backoff[:grows_by]**@attempt)].min
185
+ @attempt += 1
186
+ rand * window
187
+ end
188
+ end
189
+ end
190
+ end