wrangle 0.1.0 → 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,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "socket"
6
+
7
+ require_relative "desktop_dispatch"
8
+ require_relative "desktop_effect"
9
+ require_relative "desktop_session_autonomy"
10
+ require_relative "desktop_policy"
11
+ require_relative "desktop_progressive_observation"
12
+ require_relative "desktop_proposal"
13
+ require_relative "event_log"
14
+ require_relative "macos_driver"
15
+ require_relative "provider_factory"
16
+ require_relative "scope_registry"
17
+ require_relative "session_server"
18
+ require_relative "tart_guest_driver"
19
+
20
+ module Wrangle
21
+ # Holds one exact macOS root window across short-lived CLI invocations.
22
+ class DesktopSessionServer
23
+ include DesktopDispatch
24
+ include DesktopProgressiveObservation
25
+ include DesktopSessionAutonomy
26
+
27
+ PROPOSAL_TTL = DesktopProposal::TTL
28
+
29
+ def self.run(socket_path, options) = new(socket_path, options).run
30
+
31
+ def initialize(socket_path, options, driver: nil, scope: nil, policy: DesktopPolicy.new,
32
+ registry: nil, log: nil, provider: nil, guest_driver_class: TartGuestDriver)
33
+ @socket_path = socket_path
34
+ @options = options
35
+ @driver = driver
36
+ @scope = scope
37
+ @policy = policy
38
+ @registry = registry
39
+ @log = log
40
+ @provider = provider
41
+ @guest_driver_class = guest_driver_class
42
+ @autonomy = DesktopAutonomy.new(provider) if provider
43
+ @observation = nil
44
+ @view = nil
45
+ @full_observation = false
46
+ @proposals = {}
47
+ @runs = {}
48
+ @actions = 0
49
+ @poisoned = nil
50
+ end
51
+
52
+ def run
53
+ FileUtils.mkdir_p(File.dirname(@socket_path))
54
+ FileUtils.rm_f(@socket_path)
55
+ start_runtime(log_session: File.basename(@socket_path, ".sock"))
56
+ serve(UNIXServer.new(@socket_path))
57
+ ensure
58
+ shutdown
59
+ end
60
+
61
+ def run_task(request)
62
+ start_runtime(log_session: "task")
63
+ autonomous_task(request)
64
+ ensure
65
+ shutdown
66
+ end
67
+
68
+ private
69
+
70
+ def start_runtime(log_session:)
71
+ @driver ||= configured_driver
72
+ @scope ||= @driver.attach(window_id: @options.fetch("window_id"), app: @options.fetch("app"))
73
+ @registry ||= ScopeRegistry.new
74
+ @lease = @registry.acquire(@scope, mode: @options.fetch("concurrency", "exclusive"))
75
+ @provider ||= ProviderFactory.build(@options) if @options["provider"]
76
+ @autonomy ||= DesktopAutonomy.new(@provider) if @provider
77
+ @log ||= EventLog.new(session: log_session)
78
+ fields = {
79
+ "scope_id" => @scope.id, "driver" => @options.fetch("driver", "macos"),
80
+ "concurrency" => @lease.mode
81
+ }
82
+ @log.record("attach", fields)
83
+ end
84
+
85
+ def configured_driver
86
+ name = @options.fetch("driver", "macos")
87
+ return MacOSDriver.new if name == "macos"
88
+ raise ConfigurationError, "Unknown desktop driver" unless name == "tart_guest"
89
+
90
+ helper = @options.fetch("guest_helper", TartGuestDriver::DEFAULT_HELPER)
91
+ @guest_driver_class.new(vm_name: @options.fetch("vm"), guest_app: @options.fetch("app"), guest_helper: helper)
92
+ end
93
+
94
+ def serve(server)
95
+ File.chmod(0o600, @socket_path)
96
+ loop do
97
+ client = server.accept
98
+ line = client.gets
99
+ next client.close unless line
100
+
101
+ request = parse(line)
102
+ client.puts(JSON.generate(dispatch(request)))
103
+ client.close
104
+ break if request["op"] == "close"
105
+ end
106
+ ensure
107
+ server.close
108
+ end
109
+
110
+ def parse(line)
111
+ JSON.parse(line)
112
+ rescue JSON::ParserError
113
+ { "op" => "bad" }
114
+ end
115
+
116
+ def dispatch(request)
117
+ { "ok" => true, "value" => handle(request) }
118
+ rescue Wrangle::Error => e
119
+ terminal = e.is_a?(ScopeLost) || e.is_a?(DeliveryUnknown) || e.is_a?(DriverUnavailable) ||
120
+ (e.is_a?(DriverRefusal) && e.delivery_unknown?)
121
+ log_refusal(request, e)
122
+ refusal(e.class.name.split("::").last, e.message, terminal:)
123
+ rescue ArgumentError => e
124
+ log_refusal(request, e)
125
+ refusal("ArgumentError", e.message, terminal: false)
126
+ rescue StandardError => e
127
+ log_refusal(request, e)
128
+ refusal(e.class.name, "internal error: #{e.message} (#{e.backtrace&.first})", terminal: true)
129
+ end
130
+
131
+ def handle(request)
132
+ case request["op"]
133
+ when "status" then status
134
+ when "observe" then observe
135
+ when "drill" then drill(request)
136
+ when "inspect" then current
137
+ when "preview" then request["goal"] ? autonomous_preview(request) : preview(request)
138
+ when "execute" then execute(request)
139
+ when "continue" then continue_run(request)
140
+ when "close"
141
+ { "closing" => true, "root_preserved" => true,
142
+ "message" => "Wrangle released control; the application window remains open" }
143
+ else raise ArgumentError, "Unknown op #{request["op"].inspect}"
144
+ end
145
+ end
146
+
147
+ def status
148
+ {
149
+ "pid" => Process.pid, "driver" => @options.fetch("driver", "macos"), "scope_id" => @scope.id,
150
+ "root" => @scope.root, "app" => @scope.app, "owned" => false,
151
+ "read_only" => @options["driver"] == "tart_guest",
152
+ "observed" => !@observation.nil?, "actions_taken" => @actions,
153
+ "pending_proposals" => @proposals.length, "poisoned" => !@poisoned.nil?,
154
+ "concurrency" => @lease&.mode || @options.fetch("concurrency", "exclusive"),
155
+ "provider" => @autonomy&.status
156
+ }
157
+ end
158
+
159
+ def observe
160
+ ensure_usable!
161
+ started = monotonic
162
+ before = @observation
163
+ @observation = @driver.observe(@scope)
164
+ @view = nil
165
+ @full_observation = false
166
+ value = compact(@observation, before)
167
+ @log&.record(
168
+ "observe", "scope_id" => @scope.id, "revision" => @observation["revision"],
169
+ "complete" => @observation["complete"], "candidates" => @observation["candidates"].length,
170
+ "ambiguous_actions" => @observation.dig("coverage", "ambiguous_action_count"),
171
+ "changed" => !value["changed"].nil?, "elapsed_ms" => elapsed_ms(started)
172
+ )
173
+ value
174
+ end
175
+
176
+ def drill(request)
177
+ ensure_usable!
178
+ previous = current
179
+ target = candidate(request["ref"])
180
+ raise ArgumentError, "DRILL was not offered for this action" unless target["operations"].include?("DRILL")
181
+ unless DesktopObservation.action_unambiguous?(@observation["candidates"], target, "DRILL")
182
+ raise ArgumentError, "DRILL target matches multiple visible candidates"
183
+ end
184
+
185
+ started = monotonic
186
+ view = { ref: target["ref"], snapshot_id: previous.fetch("snapshot_id") }
187
+ @observation = @driver.drill(@scope, **view)
188
+ @view = view
189
+ @full_observation = false
190
+ @proposals.clear
191
+ value = compact(@observation, previous)
192
+ @log&.record(
193
+ "drill", "scope_id" => @scope.id, "revision" => @observation["revision"],
194
+ "complete" => @observation["complete"], "candidates" => @observation["candidates"].length,
195
+ "ambiguous_actions" => @observation.dig("coverage", "ambiguous_action_count"),
196
+ "elapsed_ms" => elapsed_ms(started)
197
+ )
198
+ value
199
+ end
200
+
201
+ def current
202
+ raise ArgumentError, "Observe before inspecting" unless @observation
203
+
204
+ @observation
205
+ end
206
+
207
+ def preview(request)
208
+ ensure_usable!
209
+ current
210
+ if @observation.dig("coverage", "truncated")
211
+ raise PartialObservation, "Observation has unresolved branches; DRILL before proposing an action"
212
+ end
213
+
214
+ proposal = DesktopProposal.build(
215
+ scope: @scope, observation: @observation, request:, policy: @policy, created_at: monotonic
216
+ )
217
+ @proposals[proposal["id"]] = proposal
218
+ log_preview(proposal)
219
+ DesktopProposal.compact(proposal)
220
+ end
221
+
222
+ def execute(request)
223
+ ensure_usable!
224
+ proposal = proposal_for(request["proposal_id"])
225
+ proposal["attempt_started"] = monotonic
226
+ expired = expire(proposal)
227
+ return record_run_receipt(proposal, expired) if expired
228
+
229
+ if proposal.dig("provider", "qualified") == false
230
+ @proposals.delete(proposal["id"])
231
+ denied = receipt(proposal, "refused", "not_applicable", "provider_not_qualified")
232
+ return record_run_receipt(proposal, denied)
233
+ end
234
+ if proposal.dig("policy", "consequential") && request["approve"] != true
235
+ denied = receipt(proposal, "refused", "not_applicable", "approval_required")
236
+ return record_run_receipt(proposal, denied)
237
+ end
238
+
239
+ fresh = fresh_observation
240
+ return record_run_receipt(proposal, refuse_stale(proposal, fresh)) if fresh["revision"] != proposal["revision"]
241
+
242
+ record_run_receipt(proposal, deliver_proposal(proposal, fresh))
243
+ end
244
+
245
+ def proposal_for(id)
246
+ proposal = @proposals[id]
247
+ raise ArgumentError, "Unknown or consumed proposal" unless proposal
248
+
249
+ raise ScopeLost, "The proposal belongs to another scope" unless proposal["scope_id"] == @scope.id
250
+
251
+ proposal
252
+ end
253
+
254
+ def expire(proposal)
255
+ return nil unless monotonic - proposal["created_at"] > PROPOSAL_TTL
256
+
257
+ @proposals.delete(proposal["id"])
258
+ receipt(proposal, "not_delivered", "not_applicable", "proposal_expired")
259
+ end
260
+
261
+ def refuse_stale(proposal, fresh)
262
+ @observation = fresh
263
+ @proposals.delete(proposal["id"])
264
+ receipt(proposal, "not_delivered", "not_applicable", "stale_observation")
265
+ end
266
+
267
+ def revalidated_candidate!(proposal, fresh)
268
+ action_candidate = fresh["candidates"][proposal["number"] - 1]
269
+ if same_candidate?(proposal["candidate"], action_candidate) &&
270
+ DesktopObservation.action_unambiguous?(fresh["candidates"], action_candidate, proposal["operation"])
271
+ return action_candidate
272
+ end
273
+
274
+ @proposals.delete(proposal["id"])
275
+ @poisoned = ScopeLost.new("The proposed desktop target became ambiguous during revalidation")
276
+ raise @poisoned
277
+ end
278
+
279
+ def same_candidate?(before, after)
280
+ return false unless after
281
+
282
+ before.except("ref") == after.except("ref")
283
+ end
284
+
285
+ def candidate(index) = DesktopProposal.candidate(@observation, index)
286
+
287
+ def log_preview(proposal)
288
+ @log&.record(
289
+ "preview", "proposal_id" => proposal["id"], "scope_id" => @scope.id,
290
+ "revision" => proposal["revision"], "operation" => proposal["operation"],
291
+ "classification" => proposal.dig("policy", "classification"),
292
+ "consequential" => proposal.dig("policy", "consequential")
293
+ )
294
+ end
295
+
296
+ def observe_after_dispatch
297
+ [fresh_observation, nil]
298
+ rescue Error => e
299
+ @poisoned = e if e.is_a?(ScopeLost)
300
+ [nil, "#{e.class.name.split("::").last}: #{e.message}"]
301
+ end
302
+
303
+ def receipt(proposal, dispatch, effect, reason = nil, after: nil, verification_error: nil, durable: false)
304
+ value = {
305
+ "schema" => "wrangle.receipt.v1", "proposal_id" => proposal["id"],
306
+ "dispatch" => dispatch, "effect" => effect, "reason" => reason,
307
+ "scope_id" => @scope.id, "before_revision" => proposal["revision"],
308
+ "after_revision" => after&.fetch("revision", nil), "verification_error" => verification_error,
309
+ "terminal" => dispatch == "delivery_unknown" || !@poisoned.nil?
310
+ }.compact
311
+ @log&.record(
312
+ "execute", value.slice("proposal_id", "scope_id", "dispatch", "effect", "reason",
313
+ "before_revision", "after_revision", "terminal")
314
+ .merge("elapsed_ms" => elapsed_ms(proposal["attempt_started"])),
315
+ durable:
316
+ )
317
+ value
318
+ end
319
+
320
+ def ensure_usable!
321
+ raise @poisoned if @poisoned
322
+ end
323
+
324
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
325
+ def elapsed_ms(started) = started && ((monotonic - started) * 1000).round(1)
326
+
327
+ def compact(observation, before)
328
+ candidates = observation["candidates"].each_with_index.map do |candidate, index|
329
+ candidate.slice("role", "label", "value", "states", "operations", "children_count")
330
+ .merge("ref" => index + 1)
331
+ end
332
+ {
333
+ "schema" => observation["schema"], "driver" => @options.fetch("driver", "macos"),
334
+ "read_only" => @options["driver"] == "tart_guest",
335
+ "scope" => observation["scope"].slice("id", "root", "app"),
336
+ "revision" => observation["revision"], "complete" => observation["complete"],
337
+ "coverage" => observation["coverage"], "candidates" => candidates,
338
+ "ambiguous_actions" => observation["ambiguous_actions"],
339
+ "changed" => changed(before, observation)
340
+ }
341
+ end
342
+
343
+ def changed(before, after)
344
+ return nil unless before
345
+
346
+ old = before["candidates"].map { |candidate| identity(candidate) }
347
+ new = after["candidates"].map { |candidate| identity(candidate) }
348
+ { "same" => before["revision"] == after["revision"],
349
+ "appeared" => (new - old).first(20), "disappeared" => (old - new).first(20) }
350
+ end
351
+
352
+ def identity(candidate) = "#{candidate["role"]}:#{candidate["label"]}"
353
+
354
+ def refusal(name, message, terminal:)
355
+ { "ok" => false, "class" => name, "error" => message, "terminal" => terminal,
356
+ "retryable" => !terminal,
357
+ "hint" => terminal ? "This scope cannot continue. Attach again." : "Observe again." }
358
+ end
359
+
360
+ def log_refusal(request, error)
361
+ @log&.record("refusal", "operation" => request["op"], "class" => error.class.name.split("::").last)
362
+ end
363
+
364
+ def shutdown
365
+ @log&.record("close", "scope_id" => @scope&.id, "actions_taken" => @actions)
366
+ @registry&.release(@lease)
367
+ @driver&.close
368
+ return unless @socket_path
369
+
370
+ FileUtils.rm_f(@socket_path)
371
+ FileUtils.rm_f("#{@socket_path}.pid")
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "desktop_session_server"
6
+ require_relative "event_log"
7
+ require_relative "macos_driver"
8
+ require_relative "provider_factory"
9
+ require_relative "scope_registry"
10
+
11
+ module Wrangle
12
+ # Selects one exact application window and owns one bounded AX → choice → policy → action loop.
13
+ # The outer agent supplies a natural goal once; low-level refs and proposals never cross this API.
14
+ class DesktopTask
15
+ DEFAULT_STEPS = 8
16
+ MAX_STEPS = 8
17
+
18
+ def initialize(driver: MacOSDriver.new, provider: nil, registry: nil, log: nil)
19
+ @driver = driver
20
+ @provider = provider
21
+ @registry = registry
22
+ @log = log
23
+ @driver_owned = true
24
+ end
25
+
26
+ def run(app:, goal:, literals: {}, steps: DEFAULT_STEPS, min_confidence: 0.5,
27
+ concurrency: "exclusive", provider_options: {})
28
+ app = app.to_s.strip
29
+ goal = goal.to_s.strip
30
+ raise ArgumentError, "A macOS application name is required" if app.empty?
31
+ raise ArgumentError, "A desktop task goal is required" if goal.empty?
32
+
33
+ budget = Integer(steps)
34
+ raise ArgumentError, "Desktop task budget must be between 1 and #{MAX_STEPS}" unless
35
+ budget.between?(1, MAX_STEPS)
36
+
37
+ provider = @provider || ProviderFactory.build(provider_options)
38
+ window = select_window(app)
39
+ scope = @driver.attach(window_id: window.fetch("id"), app: window.fetch("app_name"))
40
+ log = @log || EventLog.new(session: "task-#{SecureRandom.hex(6)}")
41
+ server = DesktopSessionServer.new(
42
+ nil, { "concurrency" => concurrency }, driver: @driver, scope:, registry: @registry,
43
+ log:, provider:
44
+ )
45
+ @driver_owned = false
46
+ server.run_task(
47
+ "goal" => goal, "literals" => literals, "steps" => budget,
48
+ "min_confidence" => min_confidence
49
+ )
50
+ ensure
51
+ @driver.close if @driver_owned
52
+ end
53
+
54
+ private
55
+
56
+ def select_window(app)
57
+ windows = @driver.windows(app:, titles: true)
58
+ raise ScopeLost, "No visible #{app} window is available" if windows.empty?
59
+ return windows.first if windows.one?
60
+
61
+ focused = windows.select { |window| window["focused"] == true }
62
+ return focused.first if focused.one?
63
+
64
+ raise ScopeLost, "More than one #{app} window is visible and none is uniquely focused"
65
+ end
66
+ end
67
+ end
@@ -27,6 +27,39 @@ module Wrangle
27
27
  def scope? = SCOPE_CODES.include?(code)
28
28
  end
29
29
 
30
+ # A platform driver is absent, incompatible, or returned a malformed response.
31
+ class DriverError < Error; end
32
+
33
+ class DriverUnavailable < DriverError; end
34
+ class DriverTimeout < DriverError; end
35
+
36
+ # A platform driver refused an operation. Delivery and retry are separate because an action that
37
+ # may have landed is terminal even when the driver thinks another attempt might succeed.
38
+ class DriverRefusal < DriverError
39
+ attr_reader :code, :delivery, :retry_disposition, :suggestion, :details
40
+
41
+ def initialize(code, message, delivery: nil, retry_disposition: nil, suggestion: nil, details: nil)
42
+ super("#{code}: #{message}")
43
+ @code = code
44
+ @delivery = delivery
45
+ @retry_disposition = retry_disposition
46
+ @suggestion = suggestion
47
+ @details = details
48
+ end
49
+
50
+ def delivery_unknown? = delivery == "unknown"
51
+ def safe_to_retry? = delivery == "not_delivered" && retry_disposition == "safe"
52
+ end
53
+
54
+ # Another exclusive session already controls an overlapping root surface.
55
+ class ScopeBusy < Error; end
56
+
57
+ # A progressive observation has unresolved branches and cannot safely authorize mutation.
58
+ class PartialObservation < Error; end
59
+
60
+ # Deterministic policy denied an otherwise observed action.
61
+ class PolicyDenied < Error; end
62
+
30
63
  # The bound window, tab, or document is no longer the one this session was handed.
31
64
  # A new explicit handoff is required; the session will not look for a substitute.
32
65
  class ScopeLost < Error; end
@@ -39,6 +72,9 @@ module Wrangle
39
72
 
40
73
  # Raised when the decision service is unusable: unreachable, slow, or answering with something it
41
74
  # was never offered. A bad answer is a refusal, not a fallback to guessing.
75
+ # A vendor-neutral decision provider failed negotiation or returned an invalid choice.
76
+ class ProviderError < Error; end
77
+
42
78
  class JevError < Error
43
79
  attr_reader :code
44
80
 
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Wrangle
8
+ # Compact, owner-only JSONL audit events. Callers pass metadata, never pixels or literal values.
9
+ class EventLog
10
+ RETENTION = 7 * 24 * 60 * 60
11
+
12
+ def initialize(session:, root: nil, now: Time.now)
13
+ home = ENV["WRANGLE_HOME"] || File.join(Dir.home, ".wrangle")
14
+ @root = root || File.join(home, "logs")
15
+ FileUtils.mkdir_p(@root, mode: 0o700)
16
+ File.chmod(0o700, @root)
17
+ prune(now)
18
+ safe_session = session.to_s.gsub(/[^a-zA-Z0-9_.-]/, "_")[0, 80]
19
+ @path = File.join(@root, "#{now.utc.strftime("%Y%m%d")}-#{safe_session}-#{Process.pid}.jsonl")
20
+ end
21
+
22
+ attr_reader :path
23
+
24
+ def record(type, fields = nil, durable: false, **implicit_fields)
25
+ fields = (fields || {}).merge(implicit_fields)
26
+ event = { "at" => Time.now.utc.iso8601(3), "event" => type }.merge(fields.compact)
27
+ File.open(@path, File::WRONLY | File::CREAT | File::APPEND, 0o600) do |file|
28
+ file.flock(File::LOCK_EX)
29
+ file.puts(JSON.generate(event))
30
+ file.flush
31
+ file.fsync if durable
32
+ end
33
+ File.open(@root, File::RDONLY, &:fsync) if durable
34
+ event
35
+ end
36
+
37
+ private
38
+
39
+ def prune(now)
40
+ Dir.each_child(@root) do |name|
41
+ next if name.end_with?(".pinned")
42
+
43
+ path = File.join(@root, name)
44
+ next unless File.file?(path)
45
+ next unless now - File.mtime(path) > RETENTION
46
+
47
+ File.unlink(path)
48
+ end
49
+ end
50
+ end
51
+ end
data/lib/wrangle/jev.rb CHANGED
@@ -5,6 +5,7 @@ require "net/http"
5
5
  require "uri"
6
6
 
7
7
  require_relative "errors"
8
+ require_relative "timing"
8
9
 
9
10
  module Wrangle
10
11
  # Asks Jev to pick one observed control.
@@ -29,11 +30,12 @@ module Wrangle
29
30
 
30
31
  attr_reader :model, :endpoint
31
32
 
32
- def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, model: DEFAULT_MODEL, timeout: 20)
33
+ def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, model: DEFAULT_MODEL, timeout: 20, timing: Timing)
33
34
  @api_key = api_key
34
35
  @endpoint = URI.parse(endpoint)
35
36
  @model = model
36
37
  @timeout = timeout
38
+ @timing = timing
37
39
  end
38
40
 
39
41
  def ask(state:, questions:)
@@ -60,7 +62,7 @@ module Wrangle
60
62
  response = post(body)
61
63
  break unless RETRYABLE.include?(response.code.to_i) && attempt < 2
62
64
 
63
- sleep(0.25 * (2**attempt))
65
+ @timing.sleep(0.25 * (2**attempt))
64
66
  end
65
67
  response
66
68
  end