acp_sdk_async 0.2.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.
@@ -0,0 +1,440 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ACP
6
+ module Schema
7
+ class ValidationError < StandardError
8
+ attr_reader :path
9
+
10
+ def initialize(message, path = [])
11
+ @path = path
12
+ super(path.empty? ? message : "#{path.join('.')}: #{message}")
13
+ end
14
+ end
15
+
16
+ def self.to_camel(snake)
17
+ parts = snake.to_s.split("_")
18
+ parts[0] + parts[1..].map(&:capitalize).join
19
+ end
20
+
21
+ def self.to_snake(camel)
22
+ camel.to_s.gsub(/([A-Z])/) { "_#{$1.downcase}" }.sub(/\A_/, "")
23
+ end
24
+
25
+ def self.serialize(value)
26
+ case value
27
+ when Base then value.to_h
28
+ when Array then value.map { |v| serialize(v) }
29
+ when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = serialize(v) }
30
+ else value
31
+ end
32
+ end
33
+
34
+ module Types
35
+ def self.resolve(spec)
36
+ case spec
37
+ when Symbol then Scalar.for(spec)
38
+ when String then Ref.new(spec)
39
+ when Array then List.new(resolve(spec.first))
40
+ else
41
+ raise ArgumentError, "Unsupported type spec: #{spec.inspect}" unless spec.respond_to?(:coerce)
42
+
43
+ spec
44
+ end
45
+ end
46
+
47
+ class Scalar
48
+ KINDS = %i[string integer number boolean object any].freeze
49
+
50
+ def self.for(kind)
51
+ @instances ||= KINDS.to_h { |k| [k, new(k)] }
52
+ @instances.fetch(kind) { raise ArgumentError, "Unknown scalar type: #{kind.inspect}" }
53
+ end
54
+
55
+ attr_reader :kind
56
+
57
+ def initialize(kind)
58
+ @kind = kind
59
+ end
60
+
61
+ def coerce(value, path = [])
62
+ case @kind
63
+ when :any then value
64
+ when :string
65
+ return value if value.is_a?(String)
66
+
67
+ fail!(value, path)
68
+ when :integer
69
+ return value if value.is_a?(Integer)
70
+ return value.to_i if value.is_a?(Float) && value.finite? && value == value.floor
71
+
72
+ fail!(value, path)
73
+ when :number
74
+ return value if value.is_a?(Numeric)
75
+
76
+ fail!(value, path)
77
+ when :boolean
78
+ return value if value == true || value == false
79
+
80
+ fail!(value, path)
81
+ when :object
82
+ return value if value.is_a?(Hash)
83
+
84
+ fail!(value, path)
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def fail!(value, path)
91
+ raise ValidationError.new("expected #{@kind}, got #{value.class}", path)
92
+ end
93
+ end
94
+
95
+ STRING = Scalar.for(:string)
96
+ INTEGER = Scalar.for(:integer)
97
+ NUMBER = Scalar.for(:number)
98
+ BOOLEAN = Scalar.for(:boolean)
99
+ OBJECT = Scalar.for(:object)
100
+ ANY = Scalar.for(:any)
101
+
102
+ class ProtocolVersion
103
+ def coerce(value, _path = [])
104
+ return value if value.is_a?(Integer)
105
+
106
+ Integer(value)
107
+ rescue ArgumentError, TypeError
108
+ 1
109
+ end
110
+ end
111
+
112
+ PROTOCOL_VERSION = ProtocolVersion.new
113
+
114
+ class Ref
115
+ attr_reader :name
116
+
117
+ def initialize(name)
118
+ @name = name
119
+ @target = nil
120
+ end
121
+
122
+ def target
123
+ @target ||= Schema.const_get(@name)
124
+ end
125
+
126
+ def coerce(value, path = [])
127
+ target.coerce(value, path)
128
+ end
129
+ end
130
+
131
+ class List
132
+ attr_reader :item
133
+
134
+ def initialize(item, skip_invalid: false)
135
+ @item = Types.resolve(item)
136
+ @skip_invalid = skip_invalid
137
+ end
138
+
139
+ def coerce(value, path = [])
140
+ raise ValidationError.new("expected array, got #{value.class}", path) unless value.is_a?(Array)
141
+
142
+ result = []
143
+ value.each_with_index do |element, index|
144
+ begin
145
+ result << @item.coerce(element, path + [index])
146
+ rescue ValidationError
147
+ raise unless @skip_invalid
148
+ end
149
+ end
150
+ result
151
+ end
152
+ end
153
+
154
+ class Map
155
+ def initialize(value_type)
156
+ @value_type = Types.resolve(value_type)
157
+ end
158
+
159
+ def coerce(value, path = [])
160
+ raise ValidationError.new("expected object, got #{value.class}", path) unless value.is_a?(Hash)
161
+
162
+ value.each_with_object({}) do |(key, element), result|
163
+ result[key.to_s] = @value_type.coerce(element, path + [key.to_s])
164
+ end
165
+ end
166
+ end
167
+
168
+ class Enum
169
+ attr_reader :values
170
+
171
+ def initialize(values, open: false)
172
+ @values = values.freeze
173
+ @open = open
174
+ end
175
+
176
+ def coerce(value, path = [])
177
+ raise ValidationError.new("expected string, got #{value.class}", path) unless value.is_a?(String)
178
+ return value if @open || @values.include?(value)
179
+
180
+ raise ValidationError.new("unexpected value #{value.inspect}, allowed: #{@values.join(', ')}", path)
181
+ end
182
+ end
183
+
184
+ class Union
185
+ attr_reader :tag, :tagged, :untagged
186
+
187
+ def initialize(tag: nil, tagged: {}, untagged: [])
188
+ @tag = tag
189
+ @tagged = tagged.transform_values { |list| Array(list).map { |t| Types.resolve(t) } }
190
+ @untagged = Array(untagged).map { |t| Types.resolve(t) }
191
+ end
192
+
193
+ def variants
194
+ (@tagged.values.flatten + @untagged).map { |t| t.is_a?(Ref) ? t.target : t }
195
+ end
196
+
197
+ def coerce(value, path = [])
198
+ if value.is_a?(Base)
199
+ return value if variant_classes.any? { |variant| value.is_a?(variant) }
200
+
201
+ return coerce(value.to_h, path)
202
+ end
203
+
204
+ if @tag && value.is_a?(Hash)
205
+ tag_value = read_tag(value)
206
+ candidates = @tagged[tag_value] if tag_value
207
+ if candidates && !candidates.empty?
208
+ return try_variants(candidates, value, path) { |error| raise error }
209
+ end
210
+ end
211
+
212
+ try_variants(@untagged, value, path) do
213
+ raise ValidationError.new(union_error_message(value), path)
214
+ end
215
+ end
216
+
217
+ private
218
+
219
+ def variant_classes
220
+ @variant_classes ||= variants.select { |variant| variant.is_a?(Class) }
221
+ end
222
+
223
+ def read_tag(hash)
224
+ snake = Schema.to_snake(@tag)
225
+ [@tag, @tag.to_sym, snake, snake.to_sym].each do |key|
226
+ return hash[key] if hash.key?(key)
227
+ end
228
+ nil
229
+ end
230
+
231
+ def try_variants(candidates, value, path)
232
+ last_error = nil
233
+ candidates.each do |candidate|
234
+ return candidate.coerce(value, path)
235
+ rescue ValidationError => e
236
+ last_error = e
237
+ end
238
+ yield(last_error || ValidationError.new(union_error_message(value), path))
239
+ end
240
+
241
+ def union_error_message(value)
242
+ if @tag && value.is_a?(Hash)
243
+ "no variant matches #{@tag}=#{read_tag(value).inspect}"
244
+ else
245
+ "no variant matches value of type #{value.class}"
246
+ end
247
+ end
248
+ end
249
+ end
250
+
251
+ class Base
252
+ FieldDef = Struct.new(
253
+ :name, :key, :type, :required, :default, :const, :default_on_error,
254
+ keyword_init: true
255
+ )
256
+
257
+ class << self
258
+ def fields
259
+ @fields ||= superclass.respond_to?(:fields) ? superclass.fields.dup : []
260
+ end
261
+
262
+ def field_names
263
+ fields.map(&:name)
264
+ end
265
+
266
+ def field(name, type = :any, key: nil, required: false, default: nil, const: nil, default_on_error: false)
267
+ name = name.to_sym
268
+ fields.reject! { |f| f.name == name }
269
+ fields << FieldDef.new(
270
+ name: name,
271
+ key: key || Schema.to_camel(name),
272
+ type: Types.resolve(type),
273
+ required: required,
274
+ default: default,
275
+ const: const,
276
+ default_on_error: default_on_error
277
+ )
278
+ attr_reader name
279
+ define_method(:"#{name}=") do |value|
280
+ instance_variable_set(:"@#{name}", value)
281
+ @set_fields << name
282
+ end
283
+ end
284
+
285
+ def coerce(value, path = [])
286
+ return value if value.is_a?(self)
287
+ raise ValidationError.new("expected object, got #{value.class}", path) unless value.is_a?(Hash)
288
+
289
+ instance = allocate
290
+ instance.send(:load_from_hash, value, path)
291
+ instance
292
+ end
293
+
294
+ def from_hash(hash)
295
+ return nil if hash.nil?
296
+
297
+ coerce(hash)
298
+ end
299
+
300
+ alias parse coerce
301
+ end
302
+
303
+ attr_reader :field_meta
304
+
305
+ def initialize(**kwargs)
306
+ @set_fields = []
307
+ @field_meta = kwargs.delete(:field_meta) || kwargs.delete(:_meta)
308
+ unknown = kwargs.keys - self.class.field_names
309
+ raise ArgumentError, "Unknown fields for #{self.class.name}: #{unknown.join(', ')}" unless unknown.empty?
310
+
311
+ self.class.fields.each do |f|
312
+ if kwargs.key?(f.name)
313
+ value = kwargs[f.name]
314
+ value = f.type.coerce(value, [f.key]) unless value.nil?
315
+ assign(f, value, set: !value.nil?)
316
+ elsif f.const
317
+ assign(f, f.const, set: true)
318
+ else
319
+ assign(f, default_for(f), set: false)
320
+ end
321
+ end
322
+ end
323
+
324
+ def field_meta=(value)
325
+ @field_meta = value
326
+ end
327
+
328
+ def set?(name)
329
+ @set_fields.include?(name.to_sym)
330
+ end
331
+
332
+ def [](name)
333
+ public_send(name)
334
+ end
335
+
336
+ def to_h
337
+ result = {}
338
+ ordered_fields.each do |f|
339
+ next unless f.const || @set_fields.include?(f.name)
340
+
341
+ value = instance_variable_get(:"@#{f.name}")
342
+ next if value.nil?
343
+
344
+ result[f.key] = Schema.serialize(value)
345
+ end
346
+ result["_meta"] = Schema.serialize(@field_meta) if @field_meta
347
+ result
348
+ end
349
+
350
+ def to_json(*args)
351
+ to_h.to_json(*args)
352
+ end
353
+
354
+ def ==(other)
355
+ other.class == self.class && other.to_h == to_h
356
+ end
357
+
358
+ alias eql? ==
359
+
360
+ def hash
361
+ [self.class, to_h].hash
362
+ end
363
+
364
+ def inspect
365
+ "#<#{self.class.name} #{to_h.inspect}>"
366
+ end
367
+
368
+ private
369
+
370
+ def ordered_fields
371
+ fields = self.class.fields
372
+ fields.select(&:const) + fields.reject(&:const)
373
+ end
374
+
375
+ def assign(field, value, set:)
376
+ instance_variable_set(:"@#{field.name}", value)
377
+ @set_fields << field.name if set
378
+ end
379
+
380
+ def default_for(field)
381
+ default = field.default
382
+ return nil if default.nil?
383
+
384
+ default = deep_dup(default)
385
+ begin
386
+ field.type.coerce(default, [field.key])
387
+ rescue ValidationError
388
+ default
389
+ end
390
+ end
391
+
392
+ def deep_dup(value)
393
+ case value
394
+ when Hash then value.transform_values { |v| deep_dup(v) }
395
+ when Array then value.map { |v| deep_dup(v) }
396
+ when String then value.dup
397
+ else value
398
+ end
399
+ end
400
+
401
+ def load_from_hash(hash, path)
402
+ @set_fields = []
403
+ self.class.fields.each do |f|
404
+ present, raw = lookup(hash, f)
405
+ if present && !raw.nil?
406
+ load_present(f, raw, path)
407
+ elsif f.const
408
+ assign(f, f.const, set: true)
409
+ elsif f.required && !(present && f.default_on_error)
410
+ raise ValidationError.new("missing required field #{f.key}", path)
411
+ else
412
+ assign(f, default_for(f), set: false)
413
+ end
414
+ end
415
+ meta = hash.key?("_meta") ? hash["_meta"] : hash[:_meta]
416
+ @field_meta = meta.is_a?(Hash) ? meta : nil
417
+ end
418
+
419
+ def load_present(field, raw, path)
420
+ value = field.type.coerce(raw, path + [field.key])
421
+ if field.const && value != field.const
422
+ raise ValidationError.new("expected #{field.const.inspect}, got #{value.inspect}", path + [field.key])
423
+ end
424
+
425
+ assign(field, value, set: true)
426
+ rescue ValidationError
427
+ raise unless field.default_on_error
428
+
429
+ assign(field, default_for(field), set: false)
430
+ end
431
+
432
+ def lookup(hash, field)
433
+ [field.key, field.key.to_sym, field.name.to_s, field.name].each do |candidate|
434
+ return [true, hash[candidate]] if hash.key?(candidate)
435
+ end
436
+ [false, nil]
437
+ end
438
+ end
439
+ end
440
+ end
data/lib/acp/stdio.rb ADDED
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "transport"
4
+ require_relative "client"
5
+ require_relative "wait"
6
+
7
+ module ACP
8
+ module Stdio
9
+ DEFAULT_INHERITED_ENV = %w[HOME LOGNAME PATH SHELL TERM USER].freeze
10
+
11
+ def self.default_environment
12
+ env = {}
13
+ DEFAULT_INHERITED_ENV.each do |key|
14
+ value = ENV.fetch(key, nil)
15
+ next if value.nil? || value.start_with?("()")
16
+
17
+ env[key] = value
18
+ end
19
+ env
20
+ end
21
+
22
+ def self.stdio_streams(input = $stdin, output = $stdout)
23
+ input.binmode if input.respond_to?(:binmode)
24
+ output.binmode if output.respond_to?(:binmode)
25
+ input.set_encoding("UTF-8") if input.respond_to?(:set_encoding)
26
+ output.set_encoding("UTF-8") if output.respond_to?(:set_encoding)
27
+ output.sync = true if output.respond_to?(:sync=)
28
+ [input, output]
29
+ end
30
+
31
+ def self.spawn_agent(command, *args, env: nil, cwd: nil, stderr: :log, receive_timeout: nil)
32
+ merged = default_environment
33
+ merged.merge!(env.transform_keys(&:to_s)) if env
34
+
35
+ reader, writer = IO.pipe
36
+ child_reader, child_writer = IO.pipe
37
+ stderr_reader, stderr_writer = stderr == :log ? IO.pipe : [nil, nil]
38
+
39
+ options = { in: child_reader, out: writer }
40
+ options[:chdir] = cwd.to_s if cwd
41
+ options[:err] = case stderr
42
+ when :log then stderr_writer
43
+ when :inherit then $stderr
44
+ when :discard then File::NULL
45
+ when IO then stderr
46
+ else raise ArgumentError, "Unsupported stderr option: #{stderr.inspect}"
47
+ end
48
+
49
+ pid = Process.spawn(merged, command, *args, **options)
50
+ child_reader.close
51
+ writer.close
52
+ stderr_writer&.close
53
+
54
+ reader.set_encoding("UTF-8")
55
+ child_writer.set_encoding("UTF-8")
56
+ child_writer.sync = true
57
+
58
+ drain = stderr_reader ? start_stderr_drain(stderr_reader, "#{File.basename(command)}[#{pid}]") : nil
59
+
60
+ transport = NdjsonTransport.new(reader, child_writer, receive_timeout: receive_timeout)
61
+ AgentProcess.new(pid: pid, transport: transport, stdin: child_writer, stdout: reader, stderr_drain: drain)
62
+ end
63
+
64
+ def self.start_stderr_drain(io, label)
65
+ io.set_encoding("UTF-8")
66
+ Wait.spawn("acp-stderr-#{label}") do
67
+ io.each_line do |line|
68
+ line = line.scrub unless line.valid_encoding?
69
+ ACP.logger.info("acp[#{label}] stderr: #{line.chomp}")
70
+ end
71
+ rescue IOError, Errno::EBADF, ::Async::Cancel
72
+ nil
73
+ ensure
74
+ io.close unless io.closed?
75
+ end
76
+ end
77
+
78
+ class AgentProcess
79
+ attr_reader :pid, :transport
80
+
81
+ def initialize(pid:, transport:, stdin:, stdout:, stderr_drain: nil)
82
+ @pid = pid
83
+ @transport = transport
84
+ @stdin = stdin
85
+ @stdout = stdout
86
+ @stderr_drain = stderr_drain
87
+ @status = nil
88
+ end
89
+
90
+ def connect(client_handler, start_listening: true, **options)
91
+ connection = Client::Connection.new(client_handler, @transport, **options)
92
+ connection.start if start_listening
93
+ connection
94
+ end
95
+
96
+ def alive?
97
+ return false if @status
98
+
99
+ Process.kill(0, @pid)
100
+ true
101
+ rescue Errno::ESRCH, Errno::EPERM
102
+ false
103
+ end
104
+
105
+ def wait
106
+ return @status if @status
107
+
108
+ _, status = Process.wait2(@pid)
109
+ @status = status
110
+ rescue Errno::ECHILD
111
+ @status
112
+ end
113
+
114
+ def kill(signal = "TERM")
115
+ Process.kill(signal, @pid)
116
+ rescue Errno::ESRCH
117
+ nil
118
+ end
119
+
120
+ def close(timeout: 2.0)
121
+ @stdin.close unless @stdin.closed?
122
+ @transport.close
123
+ return finish if wait_with_timeout(timeout)
124
+
125
+ kill
126
+ return finish if wait_with_timeout(timeout)
127
+
128
+ kill("KILL")
129
+ wait
130
+ finish
131
+ rescue StandardError
132
+ nil
133
+ end
134
+
135
+ private
136
+
137
+ def finish
138
+ Wait.join(@stderr_drain, 1.0)
139
+ @status
140
+ end
141
+
142
+ def wait_with_timeout(timeout)
143
+ return true if @status
144
+
145
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
146
+ loop do
147
+ _, status = Process.wait2(@pid, Process::WNOHANG)
148
+ if status
149
+ @status = status
150
+ return true
151
+ end
152
+
153
+ return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
154
+
155
+ sleep 0.02
156
+ end
157
+ rescue Errno::ECHILD
158
+ true
159
+ end
160
+ end
161
+ end
162
+ end