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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +94 -3
- data/exe/wrangle +556 -33
- data/lib/wrangle/decision_provider.rb +128 -0
- data/lib/wrangle/desktop_autonomy.rb +61 -0
- data/lib/wrangle/desktop_decider.rb +263 -0
- data/lib/wrangle/desktop_dispatch.rb +67 -0
- data/lib/wrangle/desktop_effect.rb +198 -0
- data/lib/wrangle/desktop_observation.rb +252 -0
- data/lib/wrangle/desktop_policy.rb +47 -0
- data/lib/wrangle/desktop_progressive_observation.rb +34 -0
- data/lib/wrangle/desktop_proposal.rb +75 -0
- data/lib/wrangle/desktop_session_autonomy.rb +344 -0
- data/lib/wrangle/desktop_session_server.rb +374 -0
- data/lib/wrangle/desktop_task.rb +67 -0
- data/lib/wrangle/errors.rb +36 -0
- data/lib/wrangle/event_log.rb +51 -0
- data/lib/wrangle/jev.rb +4 -2
- data/lib/wrangle/macos/helper.swift +593 -0
- data/lib/wrangle/macos_driver.rb +200 -0
- data/lib/wrangle/macos_helper.rb +194 -0
- data/lib/wrangle/observation.rb +1 -1
- data/lib/wrangle/provider_conformance.jsonl +8 -0
- data/lib/wrangle/provider_factory.rb +106 -0
- data/lib/wrangle/provider_qualification.rb +73 -0
- data/lib/wrangle/run_loop.rb +4 -2
- data/lib/wrangle/safari.rb +7 -4
- data/lib/wrangle/scope_registry.rb +152 -0
- data/lib/wrangle/session_server.rb +15 -9
- data/lib/wrangle/tart_guest_driver.rb +251 -0
- data/lib/wrangle/timing.rb +12 -0
- data/lib/wrangle/version.rb +1 -1
- data/lib/wrangle.rb +20 -1
- data/skills/wrangle/SKILL.md +37 -4
- metadata +29 -7
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
|
|
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
|
|
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
|
|
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
|
|
57
|
-
when "
|
|
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
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|