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.
data/exe/wrangle CHANGED
@@ -23,7 +23,16 @@ module Wrangle
23
23
 
24
24
  One-shot:
25
25
  wrangle windows [--titles] list Safari windows
26
- wrangle displays list displays in AppleScript window coordinates
26
+ wrangle windows --app APP list scoped macOS window ids without titles
27
+ wrangle windows --vm VM --app APP list exact windows inside one running Tart guest
28
+ wrangle tart-observe --vm VM --app APP
29
+ read one exact Tart guest window without mutation
30
+ wrangle displays [--driver macos] list displays in top-left window coordinates
31
+ wrangle doctor check macOS driver capabilities and permissions
32
+ wrangle task --app APP --goal GOAL
33
+ complete one bounded native desktop task (Jev by default)
34
+ wrangle qualify --provider NAME run the provider conformance suite and emit a receipt
35
+ [--suite PATH] [--output PATH]
27
36
 
28
37
  Decide with Jev (needs JEV_API_KEY):
29
38
  wrangle decide --goal "..." propose one action; changes nothing
@@ -33,12 +42,28 @@ module Wrangle
33
42
  wrangle open <url> [--display N] open a window Wrangle owns
34
43
  [--side left] park it on half a display
35
44
  [--backend mcp] drive safaridriver's own tab instead of Apple Events
36
- wrangle attach <window_id> take over a window you already have open
45
+ wrangle attach <window_id> take over a Safari window you already have open
46
+ wrangle attach --app APP <w-id> scope one macOS application window
47
+ wrangle attach --vm VM --app APP [<w-id>]
48
+ open a read-only exact guest-window session
49
+ [--frontmost 3] take the front window after a countdown
50
+ [--pick] choose one listed application window
51
+ [--cooperative] permit another session on the same root
37
52
  wrangle observe [--match RE] look; lists numbered actions
38
- wrangle act <ref> [--text STR] do one action, and report what changed
53
+ wrangle drill <ref> expand one observed progressive branch
54
+ wrangle inspect pull the full latest machine observation
55
+ wrangle preview <ref> [--operation OP] [--text STR]
56
+ bind an action to the current revision
57
+ wrangle preview --goal GOAL [--literal NAME=VALUE]
58
+ let the explicit provider propose an action
59
+ wrangle execute <proposal_id> [--approve]
60
+ revalidate and dispatch the proposal once
61
+ wrangle continue <run_id> continue authorized safe actions within budget
62
+ wrangle act <ref> [--text STR] do one Safari action, and report what changed
39
63
  wrangle text [--match RE] print the page text
40
64
  wrangle status
41
- wrangle close
65
+ wrangle stop emergency-stop without application focus
66
+ wrangle close release control; attached app windows stay open
42
67
 
43
68
  Common options: --session NAME (default "default"), --json, --all, --settle SECONDS
44
69
 
@@ -53,18 +78,25 @@ module Wrangle
53
78
  command = argv.shift
54
79
  options = parse(argv)
55
80
  case command
56
- when "windows" then windows(options)
57
- when "displays" then displays(options)
81
+ when "windows", "tart-observe", "displays" then public_send(command.tr("-", "_"), options)
82
+ when "doctor", "qualify", "task" then public_send(command, options)
58
83
  when "open" then start({ "url" => argv.first }, options)
59
- when "attach" then start({ "window_id" => Integer(argv.first, exception: false) }, options)
84
+ when "attach" then attach(argv.first, options)
60
85
  when "observe" then observe(options)
86
+ when "drill" then drill(argv.first, options)
87
+ when "inspect" then inspect(options)
88
+ when "preview" then preview(argv.first, options)
89
+ when "execute" then execute(argv.first, options)
90
+ when "continue" then continue_run(argv.first, options)
61
91
  when "act" then act(argv.first, options)
62
92
  when "text" then text(options)
63
93
  when "decide" then propose(argv, options, execute: false)
64
94
  when "run" then propose(argv, options, execute: true)
65
95
  when "status" then status(options)
96
+ when "stop" then stop(options)
66
97
  when "close" then close(options)
67
98
  when "__serve" then serve(argv)
99
+ when "__serve_macos" then serve_macos(argv)
68
100
  when nil, "help", "-h", "--help" then puts(USAGE_TEXT) || OK
69
101
  when "version", "-v", "--version" then puts(Wrangle::VERSION) || OK
70
102
  else warn("unknown command: #{command}\n\n#{USAGE_TEXT}") || USAGE
@@ -76,35 +108,63 @@ module Wrangle
76
108
 
77
109
  def parse(argv)
78
110
  options = { session: "default", settle: nil, json: false, all: false, match: nil }
79
- OptionParser.new do |o|
80
- o.on("--session NAME") { |v| options[:session] = v }
81
- o.on("--display N", Integer) { |v| options[:display] = v }
82
- o.on("--side SIDE") { |v| options[:side] = v }
83
- o.on("--backend NAME") { |v| options[:backend] = v }
84
- o.on("--steady MS", Float) { |v| options[:steady] = v / 1000.0 }
85
- o.on("--plan GOAL") { |v| (options[:plan] ||= []) << v }
86
- o.on("--leg-steps N", Integer) { |v| options[:leg_steps] = v }
87
- o.on("--text STR") { |v| options[:text] = v }
88
- o.on("--match RE") { |v| options[:match] = v }
89
- o.on("--settle S", Float) { |v| options[:settle] = v }
90
- o.on("--goal STR") { |v| options[:goal] = v }
91
- o.on("--literal PAIR") { |v| (options[:literals] ||= {}).store(*v.split("=", 2)) }
92
- o.on("--steps N", Integer) { |v| options[:steps] = v }
93
- o.on("--min-confidence F", Float) { |v| options[:min_confidence] = v }
94
- o.on("--execute") { options[:execute] = true }
95
- o.on("--expect RE") { |v| options[:expect] = v }
96
- o.on("--titles") { options[:titles] = true }
97
- o.on("--all") { options[:all] = true }
98
- o.on("--json") { options[:json] = true }
99
- end.parse!(argv)
111
+ parser = OptionParser.new
112
+ scope_options(parser, options)
113
+ run_options(parser, options)
114
+ parser.parse!(argv)
100
115
  options
101
116
  end
102
117
 
118
+ def scope_options(parser, options)
119
+ parser.on("--session NAME") { |v| options[:session] = v }
120
+ parser.on("--display N", Integer) { |v| options[:display] = v }
121
+ parser.on("--side SIDE") { |v| options[:side] = v }
122
+ parser.on("--backend NAME") { |v| options[:backend] = v }
123
+ parser.on("--driver NAME") { |v| options[:driver] = v }
124
+ parser.on("--app APP") { |v| options[:app] = v }
125
+ parser.on("--vm NAME") { |v| options[:vm] = v }
126
+ parser.on("--guest-helper PATH") { |v| options[:guest_helper] = v }
127
+ parser.on("--frontmost [SECONDS]", Float) { |v| options[:frontmost] = v || 3.0 }
128
+ parser.on("--pick") { options[:pick] = true }
129
+ parser.on("--cooperative") { options[:cooperative] = true }
130
+ parser.on("--provider NAME") { |v| options[:provider] = v }
131
+ parser.on("--provider-model NAME") { |v| options[:provider_model] = v }
132
+ parser.on("--provider-endpoint URL") { |v| options[:provider_endpoint] = v }
133
+ parser.on("--provider-trace PATH") { |v| options[:provider_trace] = v }
134
+ parser.on("--provider-qualification PATH") { |v| options[:provider_qualification] = v }
135
+ parser.on("--provider-max-choices N", Integer) { |v| options[:provider_max_choices] = v }
136
+ parser.on("--titles") { options[:titles] = true }
137
+ parser.on("--all") { options[:all] = true }
138
+ parser.on("--json") { options[:json] = true }
139
+ end
140
+
141
+ def run_options(parser, options)
142
+ parser.on("--steady MS", Float) { |v| options[:steady] = v / 1000.0 }
143
+ parser.on("--plan GOAL") { |v| (options[:plan] ||= []) << v }
144
+ parser.on("--leg-steps N", Integer) { |v| options[:leg_steps] = v }
145
+ parser.on("--text STR") { |v| options[:text] = v }
146
+ parser.on("--operation OP") { |v| options[:operation] = v }
147
+ parser.on("--approve") { options[:approve] = true }
148
+ parser.on("--match RE") { |v| options[:match] = v }
149
+ parser.on("--settle S", Float) { |v| options[:settle] = v }
150
+ parser.on("--goal STR") { |v| options[:goal] = v }
151
+ parser.on("--literal PAIR") { |v| (options[:literals] ||= {}).store(*v.split("=", 2)) }
152
+ parser.on("--steps N", Integer) { |v| options[:steps] = v }
153
+ parser.on("--min-confidence F", Float) { |v| options[:min_confidence] = v }
154
+ parser.on("--execute") { options[:execute] = true }
155
+ parser.on("--expect RE") { |v| options[:expect] = v }
156
+ parser.on("--suite PATH") { |v| options[:suite] = v }
157
+ parser.on("--output PATH") { |v| options[:output] = v }
158
+ end
159
+
103
160
  def client(options) = SessionClient.new(SessionServer.socket_path(options[:session]))
104
161
 
105
162
  # --- one-shot ------------------------------------------------------------------------------
106
163
 
107
164
  def windows(options)
165
+ return tart_guest_windows(options) if options[:vm]
166
+ return macos_windows(options) if options[:app]
167
+
108
168
  listed = Wrangle::Safari.windows(titles: options[:titles])
109
169
  return emit(listed) if options[:json]
110
170
 
@@ -117,8 +177,86 @@ module Wrangle
117
177
  OK
118
178
  end
119
179
 
180
+ def macos_windows(options)
181
+ listed = Wrangle::MacOSHelper.new.windows(app: options[:app], titles: options[:titles])
182
+ show_macos_windows(listed, options)
183
+ end
184
+
185
+ def tart_guest_windows(options)
186
+ return warn("Tart guest windows need --vm VM and --app APP") || USAGE if options[:app].to_s.empty?
187
+
188
+ driver = tart_guest_driver(options)
189
+ listed = driver.windows(app: options[:app], titles: options[:titles])
190
+ show_macos_windows(listed, options)
191
+ ensure
192
+ driver&.close
193
+ end
194
+
195
+ def tart_observe(options)
196
+ if options[:vm].to_s.empty? || options[:app].to_s.empty?
197
+ return warn("tart-observe needs --vm VM and --app APP") || USAGE
198
+ end
199
+
200
+ driver = tart_guest_driver(options)
201
+ doctor = driver.doctor
202
+ unless doctor["ready"]
203
+ reason = doctor["session_locked"] ? "The Tart guest session is locked" : "The Tart guest driver is not ready"
204
+ raise DriverUnavailable, reason
205
+ end
206
+
207
+ window = exact_guest_window(driver.windows(app: options[:app], titles: false), options[:app])
208
+ scope = driver.attach(window_id: window.fetch("id"), app: window.fetch("app_name"))
209
+ observation = driver.observe(scope)
210
+ if options[:json]
211
+ return emit(
212
+ "schema" => "wrangle.tart-guest-observation.v1", "vm" => options[:vm],
213
+ "app" => options[:app], "read_only" => true, "observation" => observation
214
+ )
215
+ end
216
+
217
+ puts "guest #{options[:vm]} (read only)"
218
+ desktop_report(observation, options)
219
+ ensure
220
+ driver&.close
221
+ end
222
+
223
+ def tart_guest_driver(options)
224
+ Wrangle::TartGuestDriver.new(
225
+ vm_name: options[:vm], guest_app: options[:app],
226
+ guest_helper: options[:guest_helper] || Wrangle::TartGuestDriver::DEFAULT_HELPER
227
+ )
228
+ end
229
+
230
+ def exact_guest_window(windows, app)
231
+ raise ScopeLost, "No visible #{app} window is available in the Tart guest" if windows.empty?
232
+ return windows.first if windows.one?
233
+
234
+ focused = windows.select { |window| window["focused"] == true }
235
+ return focused.first if focused.one?
236
+
237
+ raise ScopeLost, "More than one #{app} window is visible in the Tart guest and none is uniquely focused"
238
+ end
239
+
240
+ def show_macos_windows(listed, options)
241
+ return emit(listed) if options[:json]
242
+
243
+ listed.each do |window|
244
+ bounds = window.fetch("bounds")
245
+ mark = window["focused"] ? "front" : ""
246
+ line = format("%-12s %-18s %5d,%5d %4dx%-4d %s", window["id"], window["app_name"],
247
+ bounds["x"], bounds["y"], bounds["width"], bounds["height"], mark)
248
+ line += " #{window["title"]}" if options[:titles]
249
+ puts line.rstrip
250
+ end
251
+ OK
252
+ end
253
+
120
254
  def displays(options)
121
- listed = Wrangle::Safari.displays
255
+ listed = if options[:driver] == "macos"
256
+ Wrangle::MacOSHelper.new.displays.map { |display| display.fetch("bounds") }
257
+ else
258
+ Wrangle::Safari.displays
259
+ end
122
260
  return emit(listed) if options[:json]
123
261
 
124
262
  listed.each_with_index do |display, index|
@@ -128,15 +266,194 @@ module Wrangle
128
266
  OK
129
267
  end
130
268
 
269
+ def doctor(options)
270
+ driver = Wrangle::MacOSDriver.new
271
+ report = driver.doctor
272
+ return emit(report) if options[:json]
273
+
274
+ puts "macOS #{report["platform"] || "unavailable"}"
275
+ puts "ready #{report["ready"]}"
276
+ if report["driver"]
277
+ puts "driver #{report["driver"]}"
278
+ puts "display source #{report.dig("capabilities", "display_inventory")}"
279
+ else
280
+ puts "error #{report["error"]}"
281
+ end
282
+ report["ready"] ? OK : UNAVAILABLE
283
+ ensure
284
+ driver&.close
285
+ end
286
+
287
+ DEFAULT_TASK_PROVIDER = "jev"
288
+
289
+ TASK_PROVIDER_ENV = {
290
+ "provider_model" => "WRANGLE_DESKTOP_PROVIDER_MODEL",
291
+ "provider_endpoint" => "WRANGLE_DESKTOP_PROVIDER_ENDPOINT",
292
+ "provider_trace" => "WRANGLE_DESKTOP_PROVIDER_TRACE",
293
+ "provider_qualification" => "WRANGLE_DESKTOP_PROVIDER_QUALIFICATION"
294
+ }.freeze
295
+
296
+ def task(options)
297
+ return warn("task needs --app APP and --goal GOAL") || USAGE if
298
+ options[:app].to_s.strip.empty? || options[:goal].to_s.strip.empty?
299
+
300
+ value = DesktopTask.new.run(
301
+ app: options[:app], goal: options[:goal], literals: options[:literals] || {},
302
+ steps: options[:steps] || DesktopTask::DEFAULT_STEPS,
303
+ min_confidence: options[:min_confidence] || 0.5,
304
+ concurrency: options[:cooperative] ? "cooperative" : "exclusive",
305
+ provider_options: task_provider_options(options)
306
+ )
307
+ return emit({ "ok" => true, "value" => value }) if options[:json]
308
+
309
+ puts value.fetch("message")
310
+ puts "status #{value.fetch("status")}"
311
+ puts "actions #{value.fetch("actions_taken")}"
312
+ OK
313
+ rescue Wrangle::Error, ArgumentError => e
314
+ task_failure(e, options)
315
+ end
316
+
317
+ def task_provider_options(options)
318
+ values = PROVIDER_OPTIONS.to_h { |key| [key.to_s, options[key]] }.compact
319
+ values["provider"] ||= ENV.fetch("WRANGLE_DESKTOP_PROVIDER", DEFAULT_TASK_PROVIDER)
320
+ raise ConfigurationError, "WRANGLE_DESKTOP_PROVIDER cannot be empty" if values["provider"].to_s.empty?
321
+
322
+ TASK_PROVIDER_ENV.each do |key, environment|
323
+ configured = ENV.fetch(environment, nil)
324
+ values[key] ||= configured unless configured.to_s.empty?
325
+ end
326
+ values
327
+ end
328
+
329
+ def task_failure(error, options)
330
+ reply = { "ok" => false, "class" => error.class.name.split("::").last, "error" => error.message,
331
+ "terminal" => true, "retryable" => false }
332
+ return emit(reply) if options[:json]
333
+
334
+ warn "#{reply["class"]}: #{reply["error"]}"
335
+ UNAVAILABLE
336
+ end
337
+
338
+ def qualify(options)
339
+ return warn("qualify needs --provider replay or jev") || USAGE if options[:provider].to_s.empty?
340
+
341
+ suite = options[:suite] || ProviderQualification::DEFAULT_SUITE
342
+ provider_options = PROVIDER_OPTIONS.to_h { |key| [key.to_s, options[key]] }.compact
343
+ provider_options["provider_trace"] ||= suite if options[:provider] == "replay"
344
+ provider = ProviderFactory.build(provider_options)
345
+ cases = ProviderQualification.load(suite)
346
+ report = ProviderQualification.run(
347
+ provider, cases, endpoint: ProviderFactory.qualification_endpoint(provider_options)
348
+ )
349
+ write_qualification(options[:output], report) if options[:output]
350
+ return emit(report) if options[:json]
351
+
352
+ puts "provider #{report["provider"]}/#{report["model"]}"
353
+ puts "suite #{report["suite_sha256"]}"
354
+ puts "passed #{report["passed"]}/#{report["cases"]}"
355
+ puts "qualified #{report["qualified"]}"
356
+ puts "receipt #{options[:output]}" if options[:output]
357
+ report["qualified"] ? OK : TERMINAL
358
+ end
359
+
360
+ def write_qualification(path, report)
361
+ directory = File.dirname(File.expand_path(path))
362
+ FileUtils.mkdir_p(directory, mode: 0o700)
363
+ temporary = File.join(directory, ".#{File.basename(path)}.#{Process.pid}.tmp")
364
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
365
+ file.write(JSON.pretty_generate(report))
366
+ file.write("\n")
367
+ file.flush
368
+ file.fsync
369
+ end
370
+ File.rename(temporary, path)
371
+ ensure
372
+ FileUtils.rm_f(temporary) if defined?(temporary)
373
+ end
374
+
131
375
  # --- session -------------------------------------------------------------------------------
132
376
 
377
+ def attach(identifier, options)
378
+ if options[:vm]
379
+ chosen = choose_tart_guest_window(identifier, options)
380
+ target = {
381
+ "driver" => "tart_guest", "vm" => options[:vm], "window_id" => chosen["id"],
382
+ "app" => options[:app], "guest_helper" => options[:guest_helper] || TartGuestDriver::DEFAULT_HELPER
383
+ }
384
+ start(target, options)
385
+ elsif options[:app] || options[:frontmost] || options[:pick]
386
+ chosen = choose_macos_window(identifier, options)
387
+ start({ "driver" => "macos", "window_id" => chosen["id"], "app" => chosen["app_name"] }, options)
388
+ else
389
+ start({ "window_id" => Integer(identifier, exception: false) }, options)
390
+ end
391
+ end
392
+
393
+ def choose_tart_guest_window(identifier, options)
394
+ raise ArgumentError, "Tart guest attachment needs --app" if options[:app].to_s.empty?
395
+ if options[:frontmost] || options[:pick]
396
+ raise ArgumentError, "Tart guest attachment does not accept --frontmost or --pick"
397
+ end
398
+ return { "id" => identifier, "app_name" => options[:app] } unless identifier.to_s.empty?
399
+
400
+ driver = tart_guest_driver(options)
401
+ exact_guest_window(driver.windows(app: options[:app], titles: false), options[:app])
402
+ ensure
403
+ driver&.close
404
+ end
405
+
406
+ def choose_macos_window(identifier, options)
407
+ helper = Wrangle::MacOSHelper.new
408
+ if options[:frontmost]
409
+ warn "Select the window to hand over; capturing frontmost in #{options[:frontmost]}s"
410
+ sleep options[:frontmost]
411
+ chosen = helper.frontmost
412
+ if options[:app] && chosen["app_name"].to_s.casecmp?(options[:app]) == false
413
+ raise ScopeLost, "Frontmost window belongs to #{chosen["app_name"]}, not #{options[:app]}"
414
+ end
415
+
416
+ return chosen
417
+ end
418
+ return pick_macos_window(helper, options[:app]) if options[:pick]
419
+ raise ArgumentError, "macOS attach needs --app" unless options[:app]
420
+
421
+ { "id" => identifier, "app_name" => options[:app] }
422
+ end
423
+
424
+ def pick_macos_window(helper, app)
425
+ raise ArgumentError, "--pick requires --app" if app.to_s.empty?
426
+
427
+ windows = helper.windows(app:, titles: true)
428
+ raise ScopeLost, "No visible #{app} windows" if windows.empty?
429
+
430
+ windows.each_with_index do |window, index|
431
+ bounds = window.fetch("bounds")
432
+ puts format("%2d %-12s %5d,%5d %4dx%-4d %s", index + 1, window["id"], bounds["x"], bounds["y"],
433
+ bounds["width"], bounds["height"], window["title"].to_s[0, 60])
434
+ end
435
+ print "Window: "
436
+ selected = Integer($stdin.gets, exception: false)
437
+ unless selected&.between?(1, windows.length)
438
+ raise ArgumentError, "Choose a window number from 1 to #{windows.length}"
439
+ end
440
+
441
+ windows[selected - 1]
442
+ end
443
+
444
+ DESKTOP_DRIVERS = %w[macos tart_guest].freeze
445
+
133
446
  def start(target, options)
134
- return warn("open needs a url; attach needs a numeric window id") || USAGE if target.values.first.nil?
447
+ desktop = DESKTOP_DRIVERS.include?(target["driver"])
448
+ required = desktop ? target.values_at("window_id", "app") : [target.values.first]
449
+ return warn("open needs a url; attach needs a window id and --app for macOS") || USAGE if required.any?(&:nil?)
135
450
 
136
451
  socket = SessionServer.socket_path(options[:session])
137
452
  return warn("A session named #{options[:session].inspect} is already running.") || USAGE if
138
453
  SessionClient.new(socket).running?
139
454
 
455
+ return spawn_desktop(socket, target, options) if desktop
456
+
140
457
  backend = (options[:backend] || "jxa").to_s
141
458
  unless %w[jxa mcp].include?(backend)
142
459
  return warn("Unknown backend #{backend.inspect}. Choose jxa (Apple Events) or mcp (safaridriver).") || USAGE
@@ -152,6 +469,43 @@ module Wrangle
152
469
  observe(options)
153
470
  end
154
471
 
472
+ PROVIDER_OPTIONS = %i[
473
+ provider provider_model provider_endpoint provider_trace provider_qualification provider_max_choices
474
+ ].freeze
475
+
476
+ def spawn_desktop(socket, target, options)
477
+ if options[:backend] || options[:display] || options[:side]
478
+ return warn("Desktop attachment does not accept Safari backend or window-placement options") || USAGE
479
+ end
480
+
481
+ target["concurrency"] = options[:cooperative] ? "cooperative" : "exclusive"
482
+ PROVIDER_OPTIONS.each do |key|
483
+ target[key.to_s] = options[key] if options[key]
484
+ end
485
+ spawn_server(socket, target)
486
+ result = observe(options)
487
+ close_failed_desktop_session(options) unless result == OK
488
+ result
489
+ rescue StandardError
490
+ close_failed_desktop_session(options) if File.socket?(socket)
491
+ raise
492
+ end
493
+
494
+ def close_failed_desktop_session(options)
495
+ client(options).call("close")
496
+ await_session_shutdown(SessionServer.socket_path(options[:session]), 1)
497
+ rescue Error, SystemCallError
498
+ nil
499
+ end
500
+
501
+ def await_session_shutdown(socket, timeout)
502
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
503
+ while (File.socket?(socket) || File.exist?("#{socket}.pid")) &&
504
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
505
+ sleep 0.01
506
+ end
507
+ end
508
+
155
509
  # Half a screen is the useful case: a window big enough to render a real page, parked where it is
156
510
  # not in the user's way. Resolved here rather than in the session so the geometry is visible in
157
511
  # the command that asked for it.
@@ -176,9 +530,11 @@ module Wrangle
176
530
  def spawn_server(socket, target)
177
531
  log = File.join(File.dirname(socket), "#{File.basename(socket, ".sock")}.log")
178
532
  FileUtils.mkdir_p(File.dirname(socket))
179
- pid = Process.spawn(RbConfig.ruby, __FILE__, "__serve", socket, JSON.generate(target),
533
+ command = DESKTOP_DRIVERS.include?(target["driver"]) ? "__serve_macos" : "__serve"
534
+ pid = Process.spawn(RbConfig.ruby, __FILE__, command, socket, JSON.generate(target),
180
535
  out: log, err: log, pgroup: true)
181
536
  Process.detach(pid)
537
+ File.write("#{socket}.pid", "#{pid}\n", mode: "w", perm: 0o600)
182
538
  await(socket, log)
183
539
  end
184
540
 
@@ -201,12 +557,107 @@ module Wrangle
201
557
  OK
202
558
  end
203
559
 
560
+ def serve_macos(argv)
561
+ DesktopSessionServer.run(argv[0], JSON.parse(argv[1]))
562
+ OK
563
+ end
564
+
204
565
  def observe(options)
205
566
  request(options, "observe", settle: options[:settle] || 0.0)
206
567
  end
207
568
 
569
+ def drill(ref, options)
570
+ return warn("drill needs an action number from the last observation") || USAGE unless ref&.match?(/\A\d+\z/)
571
+
572
+ request(options, "drill", ref: Integer(ref))
573
+ end
574
+
575
+ def inspect(options)
576
+ reply = client(options).call("inspect")
577
+ return emit(reply) if options[:json]
578
+ return failed(reply, options) unless reply["ok"]
579
+
580
+ puts JSON.pretty_generate(reply["value"])
581
+ OK
582
+ end
583
+
584
+ def preview(ref, options)
585
+ if options[:goal]
586
+ reply = client(options).call("preview", goal: options[:goal], literals: options[:literals] || {},
587
+ min_confidence: options[:min_confidence] || 0.5,
588
+ steps: options[:steps] || 8)
589
+ return show_desktop_decision(reply, options)
590
+ end
591
+ return warn("preview needs an action number or --goal") || USAGE unless ref&.match?(/\A\d+\z/)
592
+
593
+ reply = client(options).call("preview", ref: Integer(ref), operation: options[:operation], text: options[:text])
594
+ show_desktop_proposal(reply, options)
595
+ end
596
+
597
+ def show_desktop_decision(reply, options)
598
+ return emit(reply) if options[:json]
599
+ return failed(reply, options) unless reply["ok"]
600
+
601
+ decision = reply["value"]
602
+ puts "decision #{decision["operation"]} (#{(decision["confidence"] * 100).round}% sure)"
603
+ puts "provider #{decision["provider"]}/#{decision["model"]}"
604
+ puts "paused #{decision["paused"]}" if decision["paused"]
605
+ return OK unless decision["proposal"]
606
+
607
+ show_proposal(decision["proposal"])
608
+ OK
609
+ end
610
+
611
+ def show_desktop_proposal(reply, options)
612
+ return emit(reply) if options[:json]
613
+ return failed(reply, options) unless reply["ok"]
614
+
615
+ show_proposal(reply["value"])
616
+ OK
617
+ end
618
+
619
+ def show_proposal(proposal)
620
+ operation = desktop_operation(proposal.dig("action", "operation"))
621
+ puts "ready #{operation} #{proposal.dig("action", "label").inspect}"
622
+ puts "text #{proposal.dig("action", "text", "characters")} exact characters" if
623
+ proposal.dig("action", "text")
624
+ puts "changed nothing — preview only"
625
+ puts "next wrangle execute #{proposal["proposal_id"]}"
626
+ puts "approval required before this consequential action" if proposal.dig("policy", "consequential")
627
+ end
628
+
629
+ def execute(proposal_id, options)
630
+ return warn("execute needs a proposal id") || USAGE if proposal_id.to_s.empty?
631
+
632
+ reply = client(options).call("execute", proposal_id:, approve: options[:approve] || false)
633
+ return emit(reply) if options[:json]
634
+ return failed(reply, options) unless reply["ok"]
635
+
636
+ receipt = reply["value"]
637
+ puts receipt_summary(receipt)
638
+ puts "continue wrangle continue #{receipt["run_id"]}" if receipt["can_continue"]
639
+ return TERMINAL if receipt["terminal"]
640
+ return STALE if receipt["dispatch"] == "not_delivered"
641
+
642
+ receipt["dispatch"] == "refused" ? USAGE : OK
643
+ end
644
+
645
+ def continue_run(run_id, options)
646
+ return warn("continue needs a run id") || USAGE if run_id.to_s.empty?
647
+
648
+ reply = client(options).call("continue", run_id:)
649
+ return emit(reply) if options[:json]
650
+ return failed(reply, options) unless reply["ok"]
651
+
652
+ result = reply["value"]
653
+ puts "run #{result["status"]} (#{result["remaining"]} actions remain)"
654
+ Array(result["receipts"]).each { |receipt| puts receipt_summary(receipt) }
655
+ show_proposal(result["proposal"]) if result["proposal"]
656
+ result["status"] == "terminal" ? TERMINAL : OK
657
+ end
658
+
208
659
  def act(ref, options)
209
- return warn("act needs an action number from the last observation") || USAGE unless ref =~ /\A\d+\z/
660
+ return warn("act needs an action number from the last observation") || USAGE unless ref&.match?(/\A\d+\z/)
210
661
 
211
662
  request(options, "act", ref: Integer(ref), text: options[:text], settle: options[:settle] || 3.0)
212
663
  end
@@ -220,11 +671,34 @@ module Wrangle
220
671
  OK
221
672
  end
222
673
 
674
+ def stop(options)
675
+ socket = SessionServer.socket_path(options[:session])
676
+ pid_file = "#{socket}.pid"
677
+ pid = Integer(File.read(pid_file), exception: false) if File.file?(pid_file)
678
+ unless pid&.positive?
679
+ return warn("No stoppable wrangle session named #{options[:session].inspect}.") || UNAVAILABLE
680
+ end
681
+
682
+ Process.kill("INT", pid) # One signal. An in-flight mutation is never repeated.
683
+ await_session_shutdown(socket, 3)
684
+ puts "session stopped; any in-flight delivery must be treated as unknown"
685
+ OK
686
+ rescue Errno::ESRCH
687
+ FileUtils.rm_f(pid_file)
688
+ FileUtils.rm_f(socket)
689
+ warn("The session had already exited.") || UNAVAILABLE
690
+ end
691
+
223
692
  def close(options)
224
693
  reply = client(options).call("close")
694
+ await_session_shutdown(SessionServer.socket_path(options[:session]), 1)
225
695
  return emit(reply) if options[:json]
226
696
 
227
- puts reply["ok"] ? "session closed" : reply["error"]
697
+ if reply.dig("value", "root_preserved")
698
+ puts "control released; the application window remains open"
699
+ else
700
+ puts reply["ok"] ? "session closed" : reply["error"]
701
+ end
228
702
  OK
229
703
  end
230
704
 
@@ -311,6 +785,8 @@ module Wrangle
311
785
  # --- output --------------------------------------------------------------------------------
312
786
 
313
787
  def report(value, options)
788
+ return desktop_report(value, options) if value["schema"] == "wrangle.observation.v1"
789
+
314
790
  puts "acted #{value["kind"]} #{value["executed"].inspect}" if value["executed"]
315
791
  puts "page #{value["title"].to_s[0, 70].inspect} <#{value["url"]}>"
316
792
  puts format("state %s scroll %d/%d %d chars %d actions", value["fingerprint"],
@@ -320,6 +796,53 @@ module Wrangle
320
796
  actions(value["actions"], options)
321
797
  end
322
798
 
799
+ def desktop_report(value, options)
800
+ puts "window #{value.dig("scope", "app")}"
801
+ coverage = value["coverage"]
802
+ if value["complete"]
803
+ count = coverage["candidate_count"]
804
+ puts "ready found #{count} possible #{count == 1 ? "action" : "actions"}"
805
+ else
806
+ puts "more part of the window needs a closer look before Wrangle can act"
807
+ end
808
+ if value["changed"]
809
+ puts value.dig("changed", "same") ? "changed nothing" : "changed the available actions"
810
+ show_labels(" appeared", value.dig("changed", "appeared"))
811
+ show_labels(" gone", value.dig("changed", "disappeared"))
812
+ end
813
+ desktop_actions(value["candidates"], options)
814
+ end
815
+
816
+ def desktop_actions(list, options)
817
+ shown = options[:match] ? list.select { |item| item["label"] =~ /#{Regexp.escape(options[:match])}/i } : list
818
+ limit = options[:all] || options[:match] ? shown.size : 25
819
+ shown.first(limit).each do |item|
820
+ operations = item["operations"].map { |operation| desktop_operation(operation) }.join("/")
821
+ puts format(" %3d %-20s %-14s %s", item["ref"], operations, item["role"], item["label"].to_s[0, 70])
822
+ end
823
+ puts " ... #{shown.size - limit} more (--all, or --match RE to filter)" if shown.size > limit
824
+ OK
825
+ end
826
+
827
+ def desktop_operation(operation)
828
+ {
829
+ "DRILL" => "look closer", "PRESS" => "press", "SET_TEXT" => "type",
830
+ "CLEAR" => "clear", "TOGGLE" => "toggle", "EXPAND" => "expand", "COLLAPSE" => "collapse"
831
+ }.fetch(operation, operation.to_s.downcase.tr("_", " "))
832
+ end
833
+
834
+ def receipt_summary(receipt)
835
+ dispatch = receipt["dispatch"]
836
+ effect = receipt["effect"]
837
+ return "done the action succeeded and Wrangle verified the result" if
838
+ dispatch == "delivered" && effect == "verified"
839
+ return "done the app received the action; Wrangle could not verify the result" if dispatch == "delivered"
840
+ return "stopped the window changed before Wrangle could act; nothing was sent" if dispatch == "not_delivered"
841
+ return "stopped the app refused the action; nothing changed" if dispatch == "refused"
842
+
843
+ "uncertain the action may or may not have happened; do not repeat it"
844
+ end
845
+
323
846
  def changes(changed)
324
847
  return unless changed
325
848