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
|
@@ -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
|
data/lib/wrangle/errors.rb
CHANGED
|
@@ -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
|