pinesandbox 0.0.1 → 0.3.18
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.
- checksums.yaml +4 -4
- data/Gemfile +3 -0
- data/README.md +668 -4
- data/Rakefile +5 -0
- data/lib/pinesandbox/adapters/coordinator_adapter.rb +1374 -0
- data/lib/pinesandbox/adapters/snapshot_adapter.rb +37 -0
- data/lib/pinesandbox/agent_events.rb +213 -0
- data/lib/pinesandbox/agent_mode.rb +252 -0
- data/lib/pinesandbox/agent_turn.rb +75 -0
- data/lib/pinesandbox/attach_credentials_source.rb +392 -0
- data/lib/pinesandbox/author_terminal_translator.rb +96 -0
- data/lib/pinesandbox/bind_hpke.rb +54 -0
- data/lib/pinesandbox/capture_keypair.rb +43 -0
- data/lib/pinesandbox/client.rb +446 -0
- data/lib/pinesandbox/clock.rb +51 -0
- data/lib/pinesandbox/computer.rb +1075 -0
- data/lib/pinesandbox/computer_binding.rb +47 -0
- data/lib/pinesandbox/computer_credentials.rb +175 -0
- data/lib/pinesandbox/connection.rb +26 -0
- data/lib/pinesandbox/control_events.rb +156 -0
- data/lib/pinesandbox/control_plane.rb +341 -0
- data/lib/pinesandbox/control_state.rb +31 -0
- data/lib/pinesandbox/control_token_source.rb +232 -0
- data/lib/pinesandbox/delegated_connection.rb +46 -0
- data/lib/pinesandbox/diagnostics.rb +83 -0
- data/lib/pinesandbox/drive_mode.rb +67 -0
- data/lib/pinesandbox/error_taxonomy.rb +104 -0
- data/lib/pinesandbox/errors.rb +525 -0
- data/lib/pinesandbox/exec_stream_parser.rb +90 -0
- data/lib/pinesandbox/models/artifact.rb +51 -0
- data/lib/pinesandbox/models/computer_session.rb +57 -0
- data/lib/pinesandbox/models/computer_tab.rb +30 -0
- data/lib/pinesandbox/models/file_entry.rb +38 -0
- data/lib/pinesandbox/operations.rb +134 -0
- data/lib/pinesandbox/response_handler.rb +88 -0
- data/lib/pinesandbox/runtime_details.rb +25 -0
- data/lib/pinesandbox/runtime_handle.rb +252 -0
- data/lib/pinesandbox/session.rb +627 -0
- data/lib/pinesandbox/skills.rb +142 -0
- data/lib/pinesandbox/spec_version_middleware.rb +34 -0
- data/lib/pinesandbox/sse_frame_parser.rb +95 -0
- data/lib/pinesandbox/state_hpke.rb +57 -0
- data/lib/pinesandbox/version.rb +3 -0
- data/lib/pinesandbox/zone.rb +176 -0
- data/lib/pinesandbox.rb +40 -2
- data/pinesandbox.gemspec +47 -0
- metadata +155 -10
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module PineSandbox
|
|
4
|
+
class Computer
|
|
5
|
+
# A saved connection to one runtime. Store with Credentials in encrypted
|
|
6
|
+
# application storage. Importing it neither contacts nor replaces the runtime.
|
|
7
|
+
class Binding < Struct.new(:computer_id, :runtime_id, :computer_token,
|
|
8
|
+
:binding_revision, keyword_init: true)
|
|
9
|
+
SCHEMA = 1
|
|
10
|
+
|
|
11
|
+
def self.from_json(json)
|
|
12
|
+
data = JSON.parse(json)
|
|
13
|
+
unless data.is_a?(Hash) && data["schema"].is_a?(Integer) && data["schema"] == SCHEMA
|
|
14
|
+
raise ArgumentError, "unsupported Computer binding schema"
|
|
15
|
+
end
|
|
16
|
+
new(computer_id: data["computer_id"], runtime_id: data["runtime_id"],
|
|
17
|
+
computer_token: data["computer_token"], binding_revision: data["binding_revision"]).validate!
|
|
18
|
+
rescue JSON::ParserError
|
|
19
|
+
raise ArgumentError, "Computer binding must be valid JSON"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def validate!
|
|
23
|
+
unless computer_id.is_a?(String) && computer_id.match?(Computer::UUID_V7)
|
|
24
|
+
raise ArgumentError, "binding computer_id must be a lowercase UUIDv7"
|
|
25
|
+
end
|
|
26
|
+
unless runtime_id.is_a?(String) && runtime_id.length <= 63 && runtime_id.match?(Zone::RUNTIME_ID_RE)
|
|
27
|
+
raise ArgumentError, "binding runtime_id must be a runtime identifier"
|
|
28
|
+
end
|
|
29
|
+
unless computer_token.is_a?(String) && computer_token.match?(/\Act_[!-~]+\z/)
|
|
30
|
+
raise ArgumentError, "binding computer_token must be a Computer token"
|
|
31
|
+
end
|
|
32
|
+
unless binding_revision.is_a?(Integer) && binding_revision.between?(0, (2**63) - 1)
|
|
33
|
+
raise ArgumentError, "binding revision must be a non-negative integer"
|
|
34
|
+
end
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_json(*args)
|
|
39
|
+
validate!
|
|
40
|
+
{ schema: SCHEMA, computer_id:, runtime_id:, computer_token:, binding_revision: }.to_json(*args)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def inspect = "#<PineSandbox::Computer::Binding computer_id=#{computer_id} runtime_id=#{runtime_id} token=[redacted]>"
|
|
44
|
+
alias_method :to_s, :inspect
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module PineSandbox
|
|
5
|
+
class Computer
|
|
6
|
+
# Caller-owned secrets for starting a Computer. JSON contains secret bytes
|
|
7
|
+
# encoded as Base64; store it in your application's encrypted storage.
|
|
8
|
+
# Keep Struct readers, writers and raw to_h values; the member set expands.
|
|
9
|
+
class Credentials < Struct.new(:id, :key, :capture_keypair, :prior_capture_keypairs,
|
|
10
|
+
:prior_keys, :ephemeral, keyword_init: true)
|
|
11
|
+
SCHEMA = 1
|
|
12
|
+
|
|
13
|
+
def self.from_json(json)
|
|
14
|
+
from_serialized_hash(JSON.parse(json))
|
|
15
|
+
rescue JSON::ParserError
|
|
16
|
+
raise ArgumentError, "credentials must be valid JSON"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.from_serialized_hash(hash)
|
|
20
|
+
raise ArgumentError, "computer credentials must be a Hash" unless hash.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
data = hash.transform_keys(&:to_s)
|
|
23
|
+
# Released Go credentials used encoding/json's exported field names.
|
|
24
|
+
# Accept those saved records, but always write the shared schema.
|
|
25
|
+
if !data.key?("schema") && data.key?("ID")
|
|
26
|
+
pair = data["CaptureKeypair"]
|
|
27
|
+
raise ArgumentError, "capture keypair must be an object" unless pair.is_a?(Hash)
|
|
28
|
+
data = {"schema" => SCHEMA, "id" => data["ID"], "key" => data["Key"],
|
|
29
|
+
"capture_keypair" => {"generation" => pair["Generation"], "pk" => pair["PK"], "sk" => pair["SK"]}}
|
|
30
|
+
end
|
|
31
|
+
schema = data["schema"]
|
|
32
|
+
unless schema.is_a?(Integer) && schema == SCHEMA
|
|
33
|
+
raise ArgumentError, "unsupported computer credentials schema (expected #{SCHEMA})"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
prior_capture_keypairs = data.fetch("prior_capture_keypairs", [])
|
|
37
|
+
unless prior_capture_keypairs.is_a?(Array)
|
|
38
|
+
raise ArgumentError, "prior_capture_keypairs must be an Array"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
new(
|
|
42
|
+
id: fetch_string(data, "id"),
|
|
43
|
+
key: decode(fetch_string(data, "key"), "key"),
|
|
44
|
+
ephemeral: data.fetch("ephemeral", false),
|
|
45
|
+
capture_keypair: data["capture_keypair"] && keypair_from_h(data["capture_keypair"]),
|
|
46
|
+
prior_capture_keypairs: prior_capture_keypairs.map { |entry| keypair_from_h(entry) },
|
|
47
|
+
prior_keys: prior_keys_from(data.fetch("prior_keys", []))
|
|
48
|
+
).validate!
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.prior_keys_from(entries)
|
|
52
|
+
raise ArgumentError, "prior_keys must be an Array" unless entries.is_a?(Array)
|
|
53
|
+
|
|
54
|
+
pairs = entries.map do |entry|
|
|
55
|
+
raise ArgumentError, "prior_keys entries must be Hashes" unless entry.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
entry = entry.transform_keys(&:to_s)
|
|
58
|
+
[ entry.fetch("version") { raise ArgumentError, "prior key is missing version" },
|
|
59
|
+
decode(fetch_string(entry, "key"), "prior key") ]
|
|
60
|
+
end
|
|
61
|
+
raise ArgumentError, "prior_keys versions must be distinct" if pairs.map(&:first).uniq.length != pairs.length
|
|
62
|
+
|
|
63
|
+
pairs.to_h
|
|
64
|
+
end
|
|
65
|
+
private_class_method :prior_keys_from
|
|
66
|
+
|
|
67
|
+
def initialize(id: nil, key: nil, ephemeral: false, capture_keypair: nil,
|
|
68
|
+
prior_capture_keypairs: [], prior_keys: {})
|
|
69
|
+
super(id: id&.dup, key: key&.dup, capture_keypair: capture_keypair,
|
|
70
|
+
prior_capture_keypairs: prior_capture_keypairs, prior_keys: prior_keys,
|
|
71
|
+
ephemeral: ephemeral)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate!
|
|
75
|
+
raise ArgumentError, "id must be a lowercase UUIDv7" unless id.is_a?(String) && id.match?(Computer::UUID_V7)
|
|
76
|
+
raise ArgumentError, "key must be 32 raw bytes" unless key.is_a?(String) && key.bytesize == 32
|
|
77
|
+
raise ArgumentError, "ephemeral must be true or false" unless [ true, false ].include?(ephemeral)
|
|
78
|
+
unless capture_keypair.nil? || capture_keypair.is_a?(CaptureKeypair)
|
|
79
|
+
raise ArgumentError, "capture_keypair must be a PineSandbox::CaptureKeypair"
|
|
80
|
+
end
|
|
81
|
+
if !ephemeral && capture_keypair.nil?
|
|
82
|
+
raise ArgumentError, "persistent Computer credentials require capture_keypair"
|
|
83
|
+
end
|
|
84
|
+
unless prior_capture_keypairs.is_a?(Array) && prior_capture_keypairs.all? { |keypair| keypair.is_a?(CaptureKeypair) }
|
|
85
|
+
raise ArgumentError, "prior_capture_keypairs must contain only PineSandbox::CaptureKeypair values"
|
|
86
|
+
end
|
|
87
|
+
prior_generations = prior_capture_keypairs.map(&:generation)
|
|
88
|
+
if prior_generations.any? && capture_keypair.nil?
|
|
89
|
+
raise ArgumentError, "prior_capture_keypairs require capture_keypair"
|
|
90
|
+
end
|
|
91
|
+
if prior_generations.uniq.length != prior_generations.length ||
|
|
92
|
+
prior_generations.any? { |generation| generation >= capture_keypair.generation }
|
|
93
|
+
raise ArgumentError, "prior capture keypair generations must be distinct and below the current generation"
|
|
94
|
+
end
|
|
95
|
+
unless prior_keys.is_a?(Hash) && prior_keys.all? { |version, bytes| version.is_a?(Integer) && bytes.is_a?(String) && bytes.bytesize == 32 }
|
|
96
|
+
raise ArgumentError, "prior_keys must map Integer versions to 32 raw bytes"
|
|
97
|
+
end
|
|
98
|
+
if prior_keys.key?(Computer::CURRENT_KEY_VERSION)
|
|
99
|
+
raise ArgumentError, "prior_keys must not include the current key version #{Computer::CURRENT_KEY_VERSION}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
self
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def ephemeral?
|
|
106
|
+
ephemeral
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# JSON contains secrets; ordinary Struct#to_h retains raw Ruby values.
|
|
110
|
+
def serialized_hash
|
|
111
|
+
validate!
|
|
112
|
+
{
|
|
113
|
+
"schema" => SCHEMA,
|
|
114
|
+
"id" => id.dup,
|
|
115
|
+
"key" => Base64.strict_encode64(key),
|
|
116
|
+
"ephemeral" => ephemeral?,
|
|
117
|
+
"capture_keypair" => capture_keypair && self.class.keypair_to_h(capture_keypair),
|
|
118
|
+
"prior_capture_keypairs" => prior_capture_keypairs.sort_by(&:generation).map { |keypair| self.class.keypair_to_h(keypair) },
|
|
119
|
+
"prior_keys" => prior_keys.sort.map { |version, bytes| { "version" => version, "key" => Base64.strict_encode64(bytes) } }
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def to_json(*args)
|
|
124
|
+
serialized_hash.to_json(*args)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def inspect
|
|
128
|
+
"#<PineSandbox::Computer::Credentials id=#{id.inspect} " \
|
|
129
|
+
"ephemeral=#{ephemeral?} key=[redacted] capture_keypair=[redacted]>"
|
|
130
|
+
end
|
|
131
|
+
alias_method :to_s, :inspect
|
|
132
|
+
|
|
133
|
+
private :serialized_hash
|
|
134
|
+
private_class_method :from_serialized_hash
|
|
135
|
+
|
|
136
|
+
class << self
|
|
137
|
+
def keypair_to_h(keypair)
|
|
138
|
+
{
|
|
139
|
+
"generation" => keypair.generation,
|
|
140
|
+
"pk" => Base64.strict_encode64(keypair.pk),
|
|
141
|
+
"sk" => Base64.strict_encode64(keypair.sk)
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def keypair_from_h(entry)
|
|
148
|
+
raise ArgumentError, "capture keypair entries must be Hashes" unless entry.is_a?(Hash)
|
|
149
|
+
|
|
150
|
+
entry = entry.transform_keys(&:to_s)
|
|
151
|
+
CaptureKeypair.new(
|
|
152
|
+
generation: entry.fetch("generation") { raise ArgumentError, "capture keypair is missing generation" },
|
|
153
|
+
pk: decode(fetch_string(entry, "pk"), "capture keypair pk"),
|
|
154
|
+
sk: decode(fetch_string(entry, "sk"), "capture keypair sk")
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def fetch_string(data, name)
|
|
159
|
+
value = data[name]
|
|
160
|
+
raise ArgumentError, "computer credentials field #{name} must be a String" unless value.is_a?(String)
|
|
161
|
+
|
|
162
|
+
value
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def decode(value, label)
|
|
166
|
+
decoded = Base64.strict_decode64(value)
|
|
167
|
+
raise ArgumentError unless Base64.strict_encode64(decoded) == value
|
|
168
|
+
decoded
|
|
169
|
+
rescue ArgumentError
|
|
170
|
+
raise ArgumentError, "computer credentials field #{label} is not strict Base64"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require_relative "zone"
|
|
2
|
+
require_relative "clock"
|
|
3
|
+
|
|
4
|
+
module PineSandbox
|
|
5
|
+
# Internal value object bundling everything Computer#attach needs against the
|
|
6
|
+
# Computer gateway: the `attach_provider` (mints per-attach {bind_token,
|
|
7
|
+
# broker_grant} — the pk_-backed portal source or an in-process issuer), the
|
|
8
|
+
# `zone` (derives the control + data hosts), and the `control_plane`
|
|
9
|
+
# (project-JWS create/inspect/destroy at api.<zone>). Built by
|
|
10
|
+
# PineSandbox::Client — not a public constructor.
|
|
11
|
+
class Connection
|
|
12
|
+
attr_reader :zone, :control_plane, :attach_provider, :clock
|
|
13
|
+
|
|
14
|
+
# `clock` paces every wait the Computer and its data-plane client make.
|
|
15
|
+
def initialize(zone:, control_plane:, attach_provider:, clock: PineSandbox::SYSTEM_CLOCK)
|
|
16
|
+
raise ArgumentError, "attach_provider required (mints the per-attach bind_token + broker_grant)" unless attach_provider
|
|
17
|
+
raise ArgumentError, "zone required (a PineSandbox::Zone)" unless zone.is_a?(PineSandbox::Zone)
|
|
18
|
+
raise ArgumentError, "control_plane required" unless control_plane
|
|
19
|
+
|
|
20
|
+
@attach_provider = attach_provider
|
|
21
|
+
@zone = zone
|
|
22
|
+
@control_plane = control_plane
|
|
23
|
+
@clock = clock
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module PineSandbox
|
|
5
|
+
# Typed events delivered by `Session#control_events(&block)`. Each
|
|
6
|
+
# struct corresponds to one of the four canonical v1 SSE event types:
|
|
7
|
+
#
|
|
8
|
+
# - controller_changed → ControllerChanged
|
|
9
|
+
# - idle_changed → IdleChanged
|
|
10
|
+
# - handoff_completed → HandoffCompleted
|
|
11
|
+
# - handoff_failed → HandoffFailed
|
|
12
|
+
#
|
|
13
|
+
# `idle_warning` (IdleWarning) is decoded too, should the Computer emit it.
|
|
14
|
+
#
|
|
15
|
+
# The block sees ONE typed struct per event. Unknown event types are
|
|
16
|
+
# dropped silently — clients should add new types via this catalog
|
|
17
|
+
# before stable consumers exist (SSE protocol contract).
|
|
18
|
+
#
|
|
19
|
+
# All structs carry `id` (the SSE `id:` field, monotonic uint64 per
|
|
20
|
+
# session) so callers can stash the last-seen ID for reconnect, and `type`
|
|
21
|
+
# (the SSE event name).
|
|
22
|
+
module ControlEvents
|
|
23
|
+
ControllerChanged = Struct.new(:id, :session_name, :controller, :epoch, :trigger,
|
|
24
|
+
:current_human_session, keyword_init: true) do
|
|
25
|
+
def type = "controller_changed"
|
|
26
|
+
end
|
|
27
|
+
# The person's idle deadline or pause changed. `idle_deadline` is a Time,
|
|
28
|
+
# or nil while the idle timer is paused.
|
|
29
|
+
IdleChanged = Struct.new(:id, :session_name, :epoch, :idle_deadline, :idle_paused,
|
|
30
|
+
:sub_operations, keyword_init: true) do
|
|
31
|
+
def type = "idle_changed"
|
|
32
|
+
end
|
|
33
|
+
IdleWarning = Struct.new(:id, :session_name, :expires_in_seconds, keyword_init: true) do
|
|
34
|
+
def type = "idle_warning"
|
|
35
|
+
end
|
|
36
|
+
HandoffCompleted = Struct.new(:id, :session_name, :handoff_id, keyword_init: true) do
|
|
37
|
+
def type = "handoff_completed"
|
|
38
|
+
end
|
|
39
|
+
HandoffFailed = Struct.new(:id, :session_name, :handoff_id, :reason, :problem,
|
|
40
|
+
keyword_init: true) do
|
|
41
|
+
def type = "handoff_failed"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# decode_event parses one SSE frame's (event_type, id, data_json)
|
|
45
|
+
# tuple into the matching typed struct. Returns nil on unknown event
|
|
46
|
+
# type or invalid JSON — the caller advances to the next frame.
|
|
47
|
+
def self.decode_event(event_type, id, data_json)
|
|
48
|
+
data =
|
|
49
|
+
begin
|
|
50
|
+
data_json.is_a?(Hash) ? data_json : JSON.parse(data_json.to_s)
|
|
51
|
+
rescue JSON::ParserError
|
|
52
|
+
return nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
case event_type
|
|
56
|
+
when "controller_changed"
|
|
57
|
+
ControllerChanged.new(
|
|
58
|
+
id: id,
|
|
59
|
+
session_name: data["session_name"],
|
|
60
|
+
controller: data["controller"],
|
|
61
|
+
epoch: data["epoch"],
|
|
62
|
+
trigger: data["trigger"],
|
|
63
|
+
current_human_session: data["current_human_session"]
|
|
64
|
+
)
|
|
65
|
+
when "idle_changed"
|
|
66
|
+
IdleChanged.new(
|
|
67
|
+
id: id,
|
|
68
|
+
session_name: data["session_name"],
|
|
69
|
+
epoch: data["epoch"],
|
|
70
|
+
idle_deadline: parse_time(data["idle_deadline"]),
|
|
71
|
+
idle_paused: data["idle_paused"] == true,
|
|
72
|
+
sub_operations: Array(data["sub_operations"])
|
|
73
|
+
)
|
|
74
|
+
when "idle_warning"
|
|
75
|
+
IdleWarning.new(
|
|
76
|
+
id: id,
|
|
77
|
+
session_name: data["session_name"],
|
|
78
|
+
expires_in_seconds: data["expires_in_seconds"]
|
|
79
|
+
)
|
|
80
|
+
when "handoff_completed"
|
|
81
|
+
HandoffCompleted.new(
|
|
82
|
+
id: id,
|
|
83
|
+
session_name: data["session_name"],
|
|
84
|
+
handoff_id: data["handoff_id"]
|
|
85
|
+
)
|
|
86
|
+
when "handoff_failed"
|
|
87
|
+
HandoffFailed.new(
|
|
88
|
+
id: id,
|
|
89
|
+
session_name: data["session_name"],
|
|
90
|
+
handoff_id: data["handoff_id"],
|
|
91
|
+
reason: data["reason"],
|
|
92
|
+
problem: data["problem"]
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.parse_time(value)
|
|
98
|
+
return nil unless value.is_a?(String) && !value.empty?
|
|
99
|
+
|
|
100
|
+
Time.iso8601(value)
|
|
101
|
+
rescue ArgumentError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
private_class_method :parse_time
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# ControlEventStreamParser parses the v1 SSE byte stream
|
|
108
|
+
# (`id:\nevent:\ndata:\n\n`) into typed ControlEvents structs. Unlike
|
|
109
|
+
# Services::EventStreamParser, this parser is focused on the v1
|
|
110
|
+
# control surface: every emitted event has an `event:` discriminator,
|
|
111
|
+
# the JSON `data` payload has NO `type` field (the SSE `event:` IS
|
|
112
|
+
# the type), and ID is decimal-encoded uint64.
|
|
113
|
+
#
|
|
114
|
+
# Use:
|
|
115
|
+
# parser = ControlEventStreamParser.new
|
|
116
|
+
# parser.feed(chunk) { |evt| ... } # multiple calls as chunks arrive
|
|
117
|
+
# parser.finish # end of stream: drop a cut-off block
|
|
118
|
+
#
|
|
119
|
+
# The parser is stateful per-stream; instantiate a fresh one on
|
|
120
|
+
# reconnect.
|
|
121
|
+
class ControlEventStreamParser
|
|
122
|
+
def initialize
|
|
123
|
+
@frames = SseFrameParser.new
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Feed a chunk; yields a typed ControlEvents::* struct per decoded frame.
|
|
127
|
+
# Framing is delegated to the single SseFrameParser; this class owns only
|
|
128
|
+
# the control-event DECODING (event-name + id + payload → typed struct).
|
|
129
|
+
def feed(chunk, &block)
|
|
130
|
+
@frames.feed(chunk) { |frame| emit(frame, &block) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The end of the stream: a trailing block missing its blank-line
|
|
134
|
+
# terminator was cut off and is dropped.
|
|
135
|
+
def finish(&_block)
|
|
136
|
+
@frames.finish
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
# Decode one framed event into a typed struct and hand it to the caller.
|
|
142
|
+
# Unknown event types decode to nil (forward-compat) and are dropped; a
|
|
143
|
+
# non-integer id decodes to a nil id.
|
|
144
|
+
def emit(frame, &block)
|
|
145
|
+
typed = ControlEvents.decode_event(frame.event, parse_id(frame.id), frame.data)
|
|
146
|
+
block.call(typed) if typed && block
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def parse_id(s)
|
|
150
|
+
return nil if s.nil?
|
|
151
|
+
Integer(s, 10)
|
|
152
|
+
rescue ArgumentError
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|