dexkit 0.9.0 → 0.11.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 +57 -1
- data/README.md +63 -254
- data/gemfiles/mongoid_no_ar.gemfile.lock +2 -2
- data/guides/llm/EVENT.md +25 -26
- data/guides/llm/FORM.md +200 -59
- data/guides/llm/OPERATION.md +115 -57
- data/guides/llm/QUERY.md +56 -0
- data/guides/llm/TOOL.md +308 -0
- data/lib/dex/context_dsl.rb +56 -0
- data/lib/dex/context_setup.rb +2 -33
- data/lib/dex/event/bus.rb +79 -11
- data/lib/dex/event/handler.rb +18 -1
- data/lib/dex/event/metadata.rb +15 -20
- data/lib/dex/event/processor.rb +2 -16
- data/lib/dex/event/test_helpers.rb +1 -1
- data/lib/dex/event.rb +3 -10
- data/lib/dex/form/context.rb +27 -0
- data/lib/dex/form/export.rb +128 -0
- data/lib/dex/form/nesting.rb +2 -0
- data/lib/dex/form.rb +119 -3
- data/lib/dex/id.rb +125 -0
- data/lib/dex/operation/async_proxy.rb +22 -4
- data/lib/dex/operation/guard_wrapper.rb +1 -1
- data/lib/dex/operation/jobs.rb +5 -4
- data/lib/dex/operation/once_wrapper.rb +1 -0
- data/lib/dex/operation/outcome.rb +14 -0
- data/lib/dex/operation/record_backend.rb +2 -1
- data/lib/dex/operation/record_wrapper.rb +14 -4
- data/lib/dex/operation/result_wrapper.rb +0 -12
- data/lib/dex/operation/test_helpers/assertions.rb +0 -88
- data/lib/dex/operation/test_helpers.rb +11 -1
- data/lib/dex/operation/ticket.rb +268 -0
- data/lib/dex/operation/trace_wrapper.rb +20 -0
- data/lib/dex/operation.rb +3 -0
- data/lib/dex/operation_failed.rb +14 -0
- data/lib/dex/query/export.rb +64 -0
- data/lib/dex/query.rb +41 -0
- data/lib/dex/test_log.rb +62 -4
- data/lib/dex/timeout.rb +14 -0
- data/lib/dex/tool.rb +388 -5
- data/lib/dex/trace.rb +291 -0
- data/lib/dex/version.rb +1 -1
- data/lib/dexkit.rb +22 -3
- metadata +12 -3
- data/lib/dex/event/trace.rb +0 -56
- data/lib/dex/event_test_helpers.rb +0 -3
data/lib/dex/operation/jobs.rb
CHANGED
|
@@ -9,29 +9,30 @@ module Dex
|
|
|
9
9
|
case name
|
|
10
10
|
when :DirectJob
|
|
11
11
|
const_set(:DirectJob, Class.new(ActiveJob::Base) do
|
|
12
|
-
def perform(class_name:, params:, once_key: nil, once_bypass: false)
|
|
12
|
+
def perform(class_name:, params:, trace: nil, once_key: nil, once_bypass: false)
|
|
13
13
|
klass = class_name.constantize
|
|
14
14
|
op = klass.new(**klass.send(:_coerce_serialized_hash, params))
|
|
15
15
|
op.once(once_key) if once_key
|
|
16
16
|
op.once(nil) if once_bypass
|
|
17
|
-
op.call
|
|
17
|
+
Dex::Trace.restore(trace) { op.call }
|
|
18
18
|
end
|
|
19
19
|
end)
|
|
20
20
|
when :RecordJob
|
|
21
21
|
const_set(:RecordJob, Class.new(ActiveJob::Base) do
|
|
22
|
-
def perform(class_name:, record_id:, once_key: nil, once_bypass: false)
|
|
22
|
+
def perform(class_name:, record_id:, trace: nil, once_key: nil, once_bypass: false)
|
|
23
23
|
klass = class_name.constantize
|
|
24
24
|
record = Dex.record_backend.find_record(record_id)
|
|
25
25
|
params = klass.send(:_coerce_serialized_hash, record.params || {})
|
|
26
26
|
|
|
27
27
|
op = klass.new(**params)
|
|
28
28
|
op.instance_variable_set(:@_dex_record_id, record_id)
|
|
29
|
+
op.instance_variable_set(:@_dex_execution_id, record_id)
|
|
29
30
|
op.once(once_key) if once_key
|
|
30
31
|
op.once(nil) if once_bypass
|
|
31
32
|
|
|
32
33
|
update_status(record_id, status: "running")
|
|
33
34
|
pipeline_started = true
|
|
34
|
-
op.call
|
|
35
|
+
Dex::Trace.restore(trace) { op.call }
|
|
35
36
|
rescue => e
|
|
36
37
|
# RecordWrapper handles failures during op.call via its own rescue.
|
|
37
38
|
# This catches pre-pipeline failures (find_record, deserialization, etc.)
|
|
@@ -26,6 +26,10 @@ module Dex
|
|
|
26
26
|
@value.respond_to?(method, include_private) || super
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
def deconstruct
|
|
30
|
+
[@value]
|
|
31
|
+
end
|
|
32
|
+
|
|
29
33
|
def deconstruct_keys(keys)
|
|
30
34
|
return { value: @value } unless @value.respond_to?(:deconstruct_keys)
|
|
31
35
|
@value.deconstruct_keys(keys)
|
|
@@ -49,6 +53,10 @@ module Dex
|
|
|
49
53
|
def message = @error.message
|
|
50
54
|
def details = @error.details
|
|
51
55
|
|
|
56
|
+
def deconstruct
|
|
57
|
+
[@error]
|
|
58
|
+
end
|
|
59
|
+
|
|
52
60
|
def deconstruct_keys(keys)
|
|
53
61
|
{ code: @error.code, message: @error.message, details: @error.details }
|
|
54
62
|
end
|
|
@@ -65,6 +73,12 @@ module Dex
|
|
|
65
73
|
rescue Dex::Error => e
|
|
66
74
|
Operation::Err.new(e)
|
|
67
75
|
end
|
|
76
|
+
|
|
77
|
+
def async(*)
|
|
78
|
+
raise NoMethodError,
|
|
79
|
+
"safe and async are alternative execution strategies. " \
|
|
80
|
+
"For async outcome reconstruction, use wait/wait! on the ticket."
|
|
81
|
+
end
|
|
68
82
|
end
|
|
69
83
|
end
|
|
70
84
|
end
|
|
@@ -156,7 +156,8 @@ module Dex
|
|
|
156
156
|
end
|
|
157
157
|
|
|
158
158
|
def has_field?(field_name)
|
|
159
|
-
|
|
159
|
+
field_name = field_name.to_s
|
|
160
|
+
record_class.fields.key?(field_name) || (field_name == "id" && record_class.fields.key?("_id"))
|
|
160
161
|
end
|
|
161
162
|
end
|
|
162
163
|
end
|
|
@@ -88,7 +88,7 @@ module Dex
|
|
|
88
88
|
else
|
|
89
89
|
_record_success_attrs(interceptor.result)
|
|
90
90
|
end
|
|
91
|
-
Dex.record_backend.update_record(@_dex_record_id, attrs)
|
|
91
|
+
Dex.record_backend.update_record(@_dex_record_id, _record_base_attrs(include_id: false).merge(attrs))
|
|
92
92
|
rescue => e
|
|
93
93
|
_record_handle_error(e)
|
|
94
94
|
end
|
|
@@ -108,7 +108,7 @@ module Dex
|
|
|
108
108
|
attrs[:once_key] = nil if defined?(@_once_key) || self.class.settings_for(:once).fetch(:defined, false)
|
|
109
109
|
|
|
110
110
|
if _record_has_pending_record?
|
|
111
|
-
Dex.record_backend.update_record(@_dex_record_id, attrs)
|
|
111
|
+
Dex.record_backend.update_record(@_dex_record_id, _record_base_attrs(include_id: false).merge(attrs))
|
|
112
112
|
else
|
|
113
113
|
Dex.record_backend.create_record(_record_base_attrs.merge(attrs))
|
|
114
114
|
end
|
|
@@ -116,8 +116,18 @@ module Dex
|
|
|
116
116
|
_record_handle_error(e)
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
-
def _record_base_attrs
|
|
120
|
-
|
|
119
|
+
def _record_base_attrs(include_id: true)
|
|
120
|
+
trace_snapshot = Dex::Trace.current
|
|
121
|
+
actor_frame = Dex::Trace.actor
|
|
122
|
+
|
|
123
|
+
attrs = {
|
|
124
|
+
name: self.class.name,
|
|
125
|
+
trace_id: Dex::Trace.trace_id,
|
|
126
|
+
trace: trace_snapshot,
|
|
127
|
+
actor_type: actor_frame&.dig(:actor_type),
|
|
128
|
+
actor_id: actor_frame&.dig(:id)&.to_s
|
|
129
|
+
}
|
|
130
|
+
attrs[:id] = @_dex_execution_id if include_id && defined?(@_dex_execution_id) && @_dex_execution_id
|
|
121
131
|
attrs[:params] = _record_params? ? _record_params : nil
|
|
122
132
|
attrs
|
|
123
133
|
end
|
|
@@ -57,18 +57,6 @@ module Dex
|
|
|
57
57
|
throw(:_dex_halt, Operation::Halt.new(type: :success, value: attrs.empty? ? value : attrs))
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
def assert!(*args, &block)
|
|
61
|
-
if block
|
|
62
|
-
code = args[0]
|
|
63
|
-
value = yield
|
|
64
|
-
else
|
|
65
|
-
value, code = args
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
error!(code) unless value
|
|
69
|
-
value
|
|
70
|
-
end
|
|
71
|
-
|
|
72
60
|
private
|
|
73
61
|
|
|
74
62
|
def _result_validate_success_type!(value)
|
|
@@ -52,39 +52,6 @@ module Dex
|
|
|
52
52
|
result
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
# --- One-liner assertions ---
|
|
56
|
-
|
|
57
|
-
def assert_operation(*args, returns: :_not_given, **params)
|
|
58
|
-
klass = _dex_resolve_subject(args)
|
|
59
|
-
result = klass.new(**params).safe.call
|
|
60
|
-
assert result.ok?, "Expected operation to succeed, got Err:\n#{_dex_format_err(result)}"
|
|
61
|
-
if returns != :_not_given
|
|
62
|
-
assert_equal returns, result.value, "Return value mismatch"
|
|
63
|
-
end
|
|
64
|
-
result
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def assert_operation_error(*args, message: nil, details: nil, **params)
|
|
68
|
-
klass, code = _dex_resolve_subject_and_code(args)
|
|
69
|
-
result = klass.new(**params).safe.call
|
|
70
|
-
assert result.error?, "Expected operation to fail, got Ok:\n#{_dex_format_ok(result)}"
|
|
71
|
-
if code
|
|
72
|
-
assert_equal code, result.code, "Error code mismatch.\n#{_dex_format_err(result)}"
|
|
73
|
-
end
|
|
74
|
-
if message
|
|
75
|
-
case message
|
|
76
|
-
when Regexp
|
|
77
|
-
assert_match message, result.message
|
|
78
|
-
else
|
|
79
|
-
assert_equal message, result.message
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
details&.each do |key, val|
|
|
83
|
-
assert_equal val, result.details&.dig(key)
|
|
84
|
-
end
|
|
85
|
-
result
|
|
86
|
-
end
|
|
87
|
-
|
|
88
55
|
# --- Contract assertions ---
|
|
89
56
|
|
|
90
57
|
def assert_params(*args)
|
|
@@ -238,49 +205,6 @@ module Dex
|
|
|
238
205
|
result
|
|
239
206
|
end
|
|
240
207
|
|
|
241
|
-
# --- Batch assertions ---
|
|
242
|
-
|
|
243
|
-
def assert_all_succeed(*args, params_list:)
|
|
244
|
-
klass = _dex_resolve_subject(args)
|
|
245
|
-
results = params_list.map { |p| klass.new(**p).safe.call }
|
|
246
|
-
failures = results.each_with_index.reject { |r, _| r.ok? }
|
|
247
|
-
if failures.any?
|
|
248
|
-
msgs = failures.map { |r, i| " [#{i}] #{params_list[i].inspect} => #{_dex_format_err(r)}" }
|
|
249
|
-
flunk "Expected all #{results.size} calls to succeed, but #{failures.size} failed:\n#{msgs.join("\n")}"
|
|
250
|
-
end
|
|
251
|
-
results
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
def assert_all_fail(*args, code:, params_list:, message: nil, details: nil)
|
|
255
|
-
klass = _dex_resolve_subject(args)
|
|
256
|
-
results = params_list.map { |p| klass.new(**p).safe.call }
|
|
257
|
-
failures = results.each_with_index.reject { |r, _| r.error? && r.code == code }
|
|
258
|
-
if failures.any?
|
|
259
|
-
msgs = failures.map { |r, i|
|
|
260
|
-
status = r.ok? ? "Ok(#{r.value.inspect})" : "Err(#{r.code})"
|
|
261
|
-
" [#{i}] #{params_list[i].inspect} => #{status}"
|
|
262
|
-
}
|
|
263
|
-
flunk "Expected all #{results.size} calls to fail with #{code.inspect}, but #{failures.size} didn't:\n#{msgs.join("\n")}"
|
|
264
|
-
end
|
|
265
|
-
results.each_with_index do |r, i|
|
|
266
|
-
if message
|
|
267
|
-
case message
|
|
268
|
-
when Regexp
|
|
269
|
-
assert_match message, r.message,
|
|
270
|
-
"Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
|
|
271
|
-
else
|
|
272
|
-
assert_equal message, r.message,
|
|
273
|
-
"Error message mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
|
|
274
|
-
end
|
|
275
|
-
end
|
|
276
|
-
details&.each do |key, val|
|
|
277
|
-
assert_equal val, r.details&.dig(key),
|
|
278
|
-
"Error details[:#{key}] mismatch at [#{i}] #{params_list[i].inspect}.\n#{_dex_format_err(r)}"
|
|
279
|
-
end
|
|
280
|
-
end
|
|
281
|
-
results
|
|
282
|
-
end
|
|
283
|
-
|
|
284
208
|
private
|
|
285
209
|
|
|
286
210
|
def _dex_format_err(result)
|
|
@@ -298,18 +222,6 @@ module Dex
|
|
|
298
222
|
" value: #{result.value.inspect}"
|
|
299
223
|
end
|
|
300
224
|
|
|
301
|
-
def _dex_resolve_subject_and_code(args)
|
|
302
|
-
if args.first.is_a?(Class) && args.first < Dex::Operation
|
|
303
|
-
klass = args.shift
|
|
304
|
-
code = args.shift
|
|
305
|
-
[klass, code]
|
|
306
|
-
elsif args.first.is_a?(Symbol)
|
|
307
|
-
[_dex_resolve_subject([]), args.shift]
|
|
308
|
-
else
|
|
309
|
-
[_dex_resolve_subject([]), nil]
|
|
310
|
-
end
|
|
311
|
-
end
|
|
312
|
-
|
|
313
225
|
def _dex_split_class_and_symbols(args)
|
|
314
226
|
if args.first.is_a?(Class) && args.first < Dex::Operation
|
|
315
227
|
[args[0..0], args[1..]]
|
|
@@ -97,6 +97,12 @@ module Dex
|
|
|
97
97
|
Dex::Operation::Ok.new(result)
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
+
trace = Dex::Trace.current + [{
|
|
101
|
+
type: :operation,
|
|
102
|
+
id: @_dex_execution_id,
|
|
103
|
+
class: self.class.name || self.class.to_s
|
|
104
|
+
}]
|
|
105
|
+
|
|
100
106
|
entry = Dex::TestLog::Entry.new(
|
|
101
107
|
type: "Operation",
|
|
102
108
|
name: self.class.name || self.class.to_s,
|
|
@@ -104,7 +110,10 @@ module Dex
|
|
|
104
110
|
params: _test_safe_params,
|
|
105
111
|
result: safe_result,
|
|
106
112
|
duration: duration,
|
|
107
|
-
caller_location: caller_locations(4, 1)&.first
|
|
113
|
+
caller_location: caller_locations(4, 1)&.first,
|
|
114
|
+
execution_id: @_dex_execution_id,
|
|
115
|
+
trace_id: @_dex_trace_id,
|
|
116
|
+
trace: trace
|
|
108
117
|
)
|
|
109
118
|
Dex::TestLog.record(entry)
|
|
110
119
|
end
|
|
@@ -120,6 +129,7 @@ module Dex
|
|
|
120
129
|
|
|
121
130
|
def setup
|
|
122
131
|
super
|
|
132
|
+
Dex::Trace.clear!
|
|
123
133
|
Dex::TestLog.clear!
|
|
124
134
|
Dex::Operation::TestWrapper.clear_all_stubs!
|
|
125
135
|
end
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Operation
|
|
5
|
+
class Ticket
|
|
6
|
+
WAIT_MAX_RECOMMENDED = 10
|
|
7
|
+
|
|
8
|
+
attr_reader :record, :job
|
|
9
|
+
|
|
10
|
+
def initialize(record:, job:)
|
|
11
|
+
@record = record
|
|
12
|
+
@job = job
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.from_record(record)
|
|
16
|
+
raise ArgumentError, "from_record requires a record, got nil" unless record
|
|
17
|
+
|
|
18
|
+
new(record: record, job: nil)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# --- Delegated accessors ---
|
|
22
|
+
|
|
23
|
+
def id
|
|
24
|
+
_require_record!("id")
|
|
25
|
+
record.id
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def operation_name
|
|
29
|
+
_require_record!("operation_name")
|
|
30
|
+
record.name
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def status
|
|
34
|
+
_require_record!("status")
|
|
35
|
+
record.status
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def error_code
|
|
39
|
+
_require_record!("error_code")
|
|
40
|
+
record.error_code
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def error_message
|
|
44
|
+
_require_record!("error_message")
|
|
45
|
+
record.error_message
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def error_details
|
|
49
|
+
_require_record!("error_details")
|
|
50
|
+
record.error_details
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# --- Predicates ---
|
|
54
|
+
|
|
55
|
+
def completed?
|
|
56
|
+
status == "completed"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def error?
|
|
60
|
+
status == "error"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def failed?
|
|
64
|
+
status == "failed"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def pending?
|
|
68
|
+
status == "pending"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def running?
|
|
72
|
+
status == "running"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def terminal?
|
|
76
|
+
completed? || error? || failed?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def recorded?
|
|
80
|
+
!record.nil?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# --- Reload ---
|
|
84
|
+
|
|
85
|
+
def reload
|
|
86
|
+
_require_record!("reload")
|
|
87
|
+
@record = Dex.record_backend.find_record(record.id)
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# --- Outcome reconstruction ---
|
|
92
|
+
|
|
93
|
+
def outcome
|
|
94
|
+
_require_record!("outcome")
|
|
95
|
+
|
|
96
|
+
case status
|
|
97
|
+
when "completed"
|
|
98
|
+
value = _unwrap_result(record.result)
|
|
99
|
+
value = _coerce_typed_result(value)
|
|
100
|
+
Ok.new(_symbolize_keys(value))
|
|
101
|
+
when "error"
|
|
102
|
+
code = record.error_code&.to_sym
|
|
103
|
+
message = record.error_message
|
|
104
|
+
details = record.error_details
|
|
105
|
+
details = _symbolize_keys(details) if details.is_a?(Hash)
|
|
106
|
+
Err.new(Dex::Error.new(code, message, details: details))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# --- Wait ---
|
|
111
|
+
|
|
112
|
+
def wait(timeout, interval: 0.2)
|
|
113
|
+
_validate_wait_args!(timeout, interval)
|
|
114
|
+
|
|
115
|
+
if timeout.to_f > WAIT_MAX_RECOMMENDED
|
|
116
|
+
Dex.warn(
|
|
117
|
+
"Ticket#wait called with #{timeout}s timeout. " \
|
|
118
|
+
"Speculative sync is designed for short waits (under #{WAIT_MAX_RECOMMENDED}s). " \
|
|
119
|
+
"Consider client-side polling for long-running operations."
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
interval_fn = interval.respond_to?(:call) ? interval : ->(_) { interval }
|
|
124
|
+
deadline = _monotonic_now + timeout.to_f
|
|
125
|
+
attempt = 0
|
|
126
|
+
|
|
127
|
+
loop do
|
|
128
|
+
if terminal?
|
|
129
|
+
result = outcome
|
|
130
|
+
raise _build_operation_failed unless result
|
|
131
|
+
return result
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
remaining = deadline - _monotonic_now
|
|
135
|
+
return nil if remaining <= 0
|
|
136
|
+
|
|
137
|
+
pause = [interval_fn.call(attempt).to_f, 0.01].max
|
|
138
|
+
sleep [pause, remaining].min
|
|
139
|
+
attempt += 1
|
|
140
|
+
reload
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def wait!(timeout, **opts)
|
|
145
|
+
result = wait(timeout, **opts)
|
|
146
|
+
raise Dex::Timeout.new(timeout: timeout, ticket_id: id, operation_name: operation_name) unless result
|
|
147
|
+
return result.value if result.ok?
|
|
148
|
+
|
|
149
|
+
raise result.error
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# --- to_param ---
|
|
153
|
+
|
|
154
|
+
def to_param
|
|
155
|
+
id.to_s
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# --- as_json ---
|
|
159
|
+
|
|
160
|
+
def as_json(*)
|
|
161
|
+
_require_record!("as_json")
|
|
162
|
+
|
|
163
|
+
data = { "id" => id.to_s, "name" => operation_name, "status" => status }
|
|
164
|
+
|
|
165
|
+
case status
|
|
166
|
+
when "completed"
|
|
167
|
+
result = _unwrap_result(record.result)
|
|
168
|
+
data["result"] = result unless result.nil?
|
|
169
|
+
when "error"
|
|
170
|
+
data["error"] = {
|
|
171
|
+
"code" => record.error_code,
|
|
172
|
+
"message" => record.error_message,
|
|
173
|
+
"details" => record.error_details
|
|
174
|
+
}.compact
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
data
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# --- inspect ---
|
|
181
|
+
|
|
182
|
+
def inspect
|
|
183
|
+
if record
|
|
184
|
+
"#<Dex::Operation::Ticket #{operation_name} id=#{id.inspect} status=#{status.inspect}>"
|
|
185
|
+
else
|
|
186
|
+
"#<Dex::Operation::Ticket (unrecorded) job=#{job&.class&.name}>"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def _require_record!(method)
|
|
193
|
+
return if record
|
|
194
|
+
|
|
195
|
+
raise ArgumentError,
|
|
196
|
+
"#{method} requires a recorded operation. " \
|
|
197
|
+
"Enable recording with `record true` in your operation, or use `Ticket.from_record` with an existing record."
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def _validate_wait_args!(timeout, interval)
|
|
201
|
+
unless record
|
|
202
|
+
raise ArgumentError,
|
|
203
|
+
"wait requires a recorded operation. Possible causes: " \
|
|
204
|
+
"(1) recording is not enabled — add `record true` to your operation; " \
|
|
205
|
+
"(2) `record params: false` is set — async operations store params in the record to reconstruct " \
|
|
206
|
+
"the operation in the background job, so params: false forces the direct (non-recorded) strategy. " \
|
|
207
|
+
"If you need to avoid storing params (e.g., PII), consider encrypting params at the model level instead."
|
|
208
|
+
end
|
|
209
|
+
unless _valid_duration?(timeout) && timeout.to_f > 0
|
|
210
|
+
raise ArgumentError, "timeout must be a positive Numeric, got: #{timeout.inspect}"
|
|
211
|
+
end
|
|
212
|
+
if !interval.respond_to?(:call) && !(_valid_duration?(interval) && interval.to_f > 0)
|
|
213
|
+
raise ArgumentError, "interval must be a positive number or a callable, got: #{interval.inspect}"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def _build_operation_failed
|
|
218
|
+
Dex::OperationFailed.new(
|
|
219
|
+
operation_name: record.name || "Unknown",
|
|
220
|
+
exception_class: record.error_code || "Unknown",
|
|
221
|
+
exception_message: record.error_message || "(no message recorded)"
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def _valid_duration?(value)
|
|
226
|
+
return true if value.is_a?(Numeric)
|
|
227
|
+
return true if defined?(ActiveSupport::Duration) && value.is_a?(ActiveSupport::Duration)
|
|
228
|
+
|
|
229
|
+
false
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def _coerce_typed_result(value)
|
|
233
|
+
return value if value.nil?
|
|
234
|
+
|
|
235
|
+
klass = record.name&.safe_constantize
|
|
236
|
+
return value unless klass
|
|
237
|
+
|
|
238
|
+
success_type = klass.respond_to?(:_success_type) && klass._success_type
|
|
239
|
+
return value unless success_type
|
|
240
|
+
|
|
241
|
+
klass.send(:_coerce_value, success_type, value)
|
|
242
|
+
rescue
|
|
243
|
+
value
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def _unwrap_result(result)
|
|
247
|
+
return result unless result.is_a?(Hash) && result.key?("_dex_value")
|
|
248
|
+
|
|
249
|
+
result["_dex_value"]
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def _symbolize_keys(value)
|
|
253
|
+
case value
|
|
254
|
+
when Hash
|
|
255
|
+
value.each_with_object({}) { |(k, v), h| h[k.to_sym] = _symbolize_keys(v) }
|
|
256
|
+
when Array
|
|
257
|
+
value.map { |v| _symbolize_keys(v) }
|
|
258
|
+
else
|
|
259
|
+
value
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def _monotonic_now
|
|
264
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
module TraceWrapper
|
|
5
|
+
extend Dex::Concern
|
|
6
|
+
|
|
7
|
+
def _trace_wrap
|
|
8
|
+
@_dex_execution_id ||= Dex::Id.generate("op_")
|
|
9
|
+
|
|
10
|
+
Dex::Trace.with_frame(
|
|
11
|
+
type: :operation,
|
|
12
|
+
id: @_dex_execution_id,
|
|
13
|
+
class: self.class.name
|
|
14
|
+
) do
|
|
15
|
+
@_dex_trace_id = Dex::Trace.trace_id
|
|
16
|
+
yield
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/dex/operation.rb
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
require_relative "operation/result_wrapper"
|
|
5
5
|
require_relative "operation/once_wrapper"
|
|
6
6
|
require_relative "operation/record_wrapper"
|
|
7
|
+
require_relative "operation/trace_wrapper"
|
|
7
8
|
require_relative "operation/transaction_wrapper"
|
|
8
9
|
require_relative "operation/lock_wrapper"
|
|
9
10
|
require_relative "operation/async_wrapper"
|
|
@@ -139,6 +140,7 @@ module Dex
|
|
|
139
140
|
include AsyncWrapper
|
|
140
141
|
include SafeWrapper
|
|
141
142
|
|
|
143
|
+
use TraceWrapper, at: :outer
|
|
142
144
|
use ResultWrapper
|
|
143
145
|
use GuardWrapper
|
|
144
146
|
use OnceWrapper
|
|
@@ -156,6 +158,7 @@ require_relative "operation/async_proxy"
|
|
|
156
158
|
require_relative "operation/record_backend"
|
|
157
159
|
require_relative "operation/transaction_adapter"
|
|
158
160
|
require_relative "operation/jobs"
|
|
161
|
+
require_relative "operation/ticket"
|
|
159
162
|
require_relative "operation/explain"
|
|
160
163
|
require_relative "operation/export"
|
|
161
164
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class OperationFailed < StandardError
|
|
5
|
+
attr_reader :operation_name, :exception_class, :exception_message
|
|
6
|
+
|
|
7
|
+
def initialize(operation_name:, exception_class:, exception_message:)
|
|
8
|
+
@operation_name = operation_name
|
|
9
|
+
@exception_class = exception_class
|
|
10
|
+
@exception_message = exception_message
|
|
11
|
+
super("#{operation_name} failed with #{exception_class}: #{exception_message}")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dex
|
|
4
|
+
class Query
|
|
5
|
+
module Export
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def build_hash(source)
|
|
9
|
+
h = {}
|
|
10
|
+
h[:name] = source.name if source.name
|
|
11
|
+
desc = source.description
|
|
12
|
+
h[:description] = desc if desc
|
|
13
|
+
h[:props] = _serialize_props(source)
|
|
14
|
+
ctx = _serialize_context(source)
|
|
15
|
+
h[:context] = ctx unless ctx.empty?
|
|
16
|
+
h[:filters] = source.filters unless source.filters.empty?
|
|
17
|
+
h[:sorts] = source.sorts unless source.sorts.empty?
|
|
18
|
+
h
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def build_json_schema(source) # rubocop:disable Metrics/MethodLength
|
|
22
|
+
descs = source.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
|
|
23
|
+
properties = {}
|
|
24
|
+
required = []
|
|
25
|
+
|
|
26
|
+
if source.respond_to?(:literal_properties)
|
|
27
|
+
source.literal_properties.each do |prop|
|
|
28
|
+
prop_desc = descs[prop.name]
|
|
29
|
+
schema = TypeSerializer.to_json_schema(prop.type, desc: prop_desc)
|
|
30
|
+
properties[prop.name.to_s] = schema
|
|
31
|
+
required << prop.name.to_s if prop.required?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
|
|
36
|
+
result[:title] = source.name if source.name
|
|
37
|
+
desc = source.description
|
|
38
|
+
result[:description] = desc if desc
|
|
39
|
+
result[:type] = "object"
|
|
40
|
+
result[:properties] = properties unless properties.empty?
|
|
41
|
+
result[:required] = required unless required.empty?
|
|
42
|
+
result[:additionalProperties] = false
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def _serialize_props(source)
|
|
47
|
+
return {} unless source.respond_to?(:literal_properties)
|
|
48
|
+
|
|
49
|
+
descs = source.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
|
|
50
|
+
source.literal_properties.each_with_object({}) do |prop, hash|
|
|
51
|
+
entry = { type: TypeSerializer.to_string(prop.type), required: prop.required? }
|
|
52
|
+
entry[:desc] = descs[prop.name] if descs[prop.name]
|
|
53
|
+
hash[prop.name] = entry
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def _serialize_context(source)
|
|
58
|
+
source.respond_to?(:context_mappings) ? source.context_mappings.presence || {} : {}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private_class_method :_serialize_props, :_serialize_context
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|