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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/README.md +63 -254
  4. data/gemfiles/mongoid_no_ar.gemfile.lock +2 -2
  5. data/guides/llm/EVENT.md +25 -26
  6. data/guides/llm/FORM.md +200 -59
  7. data/guides/llm/OPERATION.md +115 -57
  8. data/guides/llm/QUERY.md +56 -0
  9. data/guides/llm/TOOL.md +308 -0
  10. data/lib/dex/context_dsl.rb +56 -0
  11. data/lib/dex/context_setup.rb +2 -33
  12. data/lib/dex/event/bus.rb +79 -11
  13. data/lib/dex/event/handler.rb +18 -1
  14. data/lib/dex/event/metadata.rb +15 -20
  15. data/lib/dex/event/processor.rb +2 -16
  16. data/lib/dex/event/test_helpers.rb +1 -1
  17. data/lib/dex/event.rb +3 -10
  18. data/lib/dex/form/context.rb +27 -0
  19. data/lib/dex/form/export.rb +128 -0
  20. data/lib/dex/form/nesting.rb +2 -0
  21. data/lib/dex/form.rb +119 -3
  22. data/lib/dex/id.rb +125 -0
  23. data/lib/dex/operation/async_proxy.rb +22 -4
  24. data/lib/dex/operation/guard_wrapper.rb +1 -1
  25. data/lib/dex/operation/jobs.rb +5 -4
  26. data/lib/dex/operation/once_wrapper.rb +1 -0
  27. data/lib/dex/operation/outcome.rb +14 -0
  28. data/lib/dex/operation/record_backend.rb +2 -1
  29. data/lib/dex/operation/record_wrapper.rb +14 -4
  30. data/lib/dex/operation/result_wrapper.rb +0 -12
  31. data/lib/dex/operation/test_helpers/assertions.rb +0 -88
  32. data/lib/dex/operation/test_helpers.rb +11 -1
  33. data/lib/dex/operation/ticket.rb +268 -0
  34. data/lib/dex/operation/trace_wrapper.rb +20 -0
  35. data/lib/dex/operation.rb +3 -0
  36. data/lib/dex/operation_failed.rb +14 -0
  37. data/lib/dex/query/export.rb +64 -0
  38. data/lib/dex/query.rb +41 -0
  39. data/lib/dex/test_log.rb +62 -4
  40. data/lib/dex/timeout.rb +14 -0
  41. data/lib/dex/tool.rb +388 -5
  42. data/lib/dex/trace.rb +291 -0
  43. data/lib/dex/version.rb +1 -1
  44. data/lib/dexkit.rb +22 -3
  45. metadata +12 -3
  46. data/lib/dex/event/trace.rb +0 -56
  47. data/lib/dex/event_test_helpers.rb +0 -3
@@ -1,33 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
-
5
3
  module Dex
6
4
  class Event
7
5
  class Metadata
8
- attr_reader :id, :timestamp, :trace_id, :caused_by_id, :context
6
+ attr_reader :id, :timestamp, :trace_id, :caused_by_id, :event_ancestry
9
7
 
10
- def initialize(id:, timestamp:, trace_id:, caused_by_id:, context:)
8
+ def initialize(id:, timestamp:, trace_id:, caused_by_id:, event_ancestry:)
11
9
  @id = id
12
10
  @timestamp = timestamp
13
11
  @trace_id = trace_id
14
12
  @caused_by_id = caused_by_id
15
- @context = context
13
+ @event_ancestry = event_ancestry
16
14
  freeze
17
15
  end
18
16
 
19
- def self.build(caused_by_id: nil)
20
- id = SecureRandom.uuid
21
- trace_id = Trace.current_trace_id || id
22
- caused = caused_by_id || Trace.current_event_id
23
-
24
- ctx = if Dex.configuration.event_context
25
- begin
26
- Dex.configuration.event_context.call
27
- rescue => e
28
- Event._warn("event_context failed: #{e.message}")
29
- nil
30
- end
17
+ def self.build
18
+ id = Dex::Id.generate("ev_")
19
+ trace_id = Dex::Trace.trace_id || Dex::Id.generate("tr_")
20
+ current_event = Dex::Trace.current_event_context
21
+ caused = current_event&.dig(:id)
22
+ ancestry = if current_event
23
+ Array(current_event[:event_ancestry]) + [caused].compact
24
+ else
25
+ []
31
26
  end
32
27
 
33
28
  new(
@@ -35,7 +30,7 @@ module Dex
35
30
  timestamp: Time.now.utc,
36
31
  trace_id: trace_id,
37
32
  caused_by_id: caused,
38
- context: ctx
33
+ event_ancestry: ancestry
39
34
  )
40
35
  end
41
36
 
@@ -43,10 +38,10 @@ module Dex
43
38
  h = {
44
39
  "id" => @id,
45
40
  "timestamp" => @timestamp.iso8601(6),
46
- "trace_id" => @trace_id
41
+ "trace_id" => @trace_id,
42
+ "event_ancestry" => @event_ancestry
47
43
  }
48
44
  h["caused_by_id"] = @caused_by_id if @caused_by_id
49
- h["context"] = @context if @context
50
45
  h
51
46
  end
52
47
  end
@@ -7,13 +7,11 @@ module Dex
7
7
  return super unless name == :Processor && defined?(ActiveJob::Base)
8
8
 
9
9
  const_set(:Processor, Class.new(ActiveJob::Base) do
10
- def perform(handler_class:, event_class:, payload:, metadata:, trace: nil, context: nil, attempt_number: 1)
11
- restore_context(context)
12
-
10
+ def perform(handler_class:, event_class:, payload:, metadata:, trace: nil, attempt_number: 1)
13
11
  handler = Object.const_get(handler_class)
14
12
  retry_config = handler._event_handler_retry_config
15
13
 
16
- Dex::Event::Trace.restore(trace) do
14
+ Dex::Trace.restore(trace) do
17
15
  handler._event_handle_from_payload(event_class, payload, metadata)
18
16
  end
19
17
  rescue => _e
@@ -25,7 +23,6 @@ module Dex
25
23
  payload: payload,
26
24
  metadata: metadata,
27
25
  trace: trace,
28
- context: context,
29
26
  attempt_number: attempt_number + 1
30
27
  )
31
28
  else
@@ -35,17 +32,6 @@ module Dex
35
32
 
36
33
  private
37
34
 
38
- def restore_context(context)
39
- return unless context
40
-
41
- restorer = Dex.configuration.restore_event_context
42
- return unless restorer
43
-
44
- restorer.call(context)
45
- rescue => e
46
- Dex::Event._warn("restore_event_context failed: #{e.message}")
47
- end
48
-
49
35
  def compute_delay(config, attempt)
50
36
  wait = config[:wait]
51
37
  case wait
@@ -65,7 +65,7 @@ module Dex
65
65
  def setup
66
66
  super
67
67
  EventTestWrapper.clear_published!
68
- Dex::Event::Trace.clear!
68
+ Dex::Trace.clear!
69
69
  Dex::Event::Suppression.clear!
70
70
  end
71
71
 
data/lib/dex/event.rb CHANGED
@@ -3,13 +3,12 @@
3
3
  # Modules loaded before class body (no reference to Dex::Event needed)
4
4
  require_relative "event/execution_state"
5
5
  require_relative "event/metadata"
6
- require_relative "event/trace"
7
6
  require_relative "event/suppression"
8
7
 
9
8
  module Dex
10
9
  class Event
11
10
  RESERVED_PROP_NAMES = %i[
12
- id timestamp trace_id caused_by_id caused_by
11
+ id timestamp trace_id caused_by_id caused_by event_ancestry
13
12
  context publish metadata sync
14
13
  ].to_set.freeze
15
14
 
@@ -67,8 +66,7 @@ module Dex
67
66
  def timestamp = metadata.timestamp
68
67
  def trace_id = metadata.trace_id
69
68
  def caused_by_id = metadata.caused_by_id
70
- def context = metadata.context
71
- def trace_frame = { id: id, trace_id: trace_id }
69
+ def event_ancestry = metadata.event_ancestry
72
70
 
73
71
  # Publishing
74
72
  def publish(sync: false)
@@ -77,7 +75,7 @@ module Dex
77
75
 
78
76
  def self.publish(sync: false, caused_by: nil, **kwargs)
79
77
  if caused_by
80
- Trace.with_event(caused_by) do
78
+ Dex::Trace.with_event_context(caused_by) do
81
79
  new(**kwargs).publish(sync: sync)
82
80
  end
83
81
  else
@@ -85,11 +83,6 @@ module Dex
85
83
  end
86
84
  end
87
85
 
88
- # Tracing
89
- def trace(&block)
90
- Trace.with_event(self, &block)
91
- end
92
-
93
86
  # Suppression
94
87
  def self.suppress(*classes, &block)
95
88
  Suppression.suppress(*classes, &block)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Form
5
+ # Context DSL for Form (ActiveModel::Attributes-backed).
6
+ #
7
+ # Same DSL as Operation/Event context, but checks attribute_names
8
+ # instead of literal_properties. Injection happens in Form#initialize.
9
+ module Context
10
+ extend Dex::Concern
11
+
12
+ module ClassMethods
13
+ include ContextDSL
14
+
15
+ private
16
+
17
+ def _context_prop_declared?(name)
18
+ attribute_names.include?(name.to_s)
19
+ end
20
+
21
+ def _context_field_label
22
+ "field"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Form
5
+ module Export
6
+ module_function
7
+
8
+ TYPE_MAP = {
9
+ string: { type: "string" },
10
+ integer: { type: "integer" },
11
+ float: { type: "number" },
12
+ decimal: { type: "number" },
13
+ boolean: { type: "boolean" },
14
+ date: { type: "string", format: "date" },
15
+ datetime: { type: "string", format: "date-time" },
16
+ time: { type: "string", format: "time" }
17
+ }.freeze
18
+
19
+ def build_hash(source)
20
+ h = _serialize_form_definition(source)
21
+ h[:name] = source.name if source.name
22
+ desc = source.description
23
+ h[:description] = desc if desc
24
+ h
25
+ end
26
+
27
+ def build_json_schema(source) # rubocop:disable Metrics/MethodLength
28
+ properties = {}
29
+ required = []
30
+
31
+ source._field_registry.each do |name, field_def|
32
+ schema = _field_to_schema(field_def)
33
+ properties[name.to_s] = schema
34
+ required << name.to_s if field_def.required
35
+ end
36
+
37
+ _add_nested_properties(source, properties, required)
38
+
39
+ result = { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }
40
+ result[:title] = source.name if source.name
41
+ desc = source.description
42
+ result[:description] = desc if desc
43
+ result[:properties] = properties unless properties.empty?
44
+ result[:required] = required unless required.empty?
45
+ result[:additionalProperties] = false
46
+ result
47
+ end
48
+
49
+ def _serialize_fields(source)
50
+ source._field_registry.each_with_object({}) do |(name, field_def), hash|
51
+ entry = { type: field_def.type, required: field_def.required }
52
+ entry[:desc] = field_def.desc if field_def.desc
53
+ entry[:default] = field_def.default if field_def.default?
54
+ hash[name] = entry
55
+ end
56
+ end
57
+
58
+ def _serialize_form_definition(source)
59
+ h = { fields: _serialize_fields(source) }
60
+ nested = _serialize_nested(source)
61
+ h[:nested] = nested unless nested.empty?
62
+ h
63
+ end
64
+
65
+ def _serialize_nested(source)
66
+ nested = {}
67
+ source._nested_ones.each do |name, klass|
68
+ nested[name] = { type: :one }.merge(_serialize_form_definition(klass))
69
+ end
70
+ source._nested_manys.each do |name, klass|
71
+ nested[name] = { type: :many }.merge(_serialize_form_definition(klass))
72
+ end
73
+ nested
74
+ end
75
+
76
+ def _field_to_schema(field_def)
77
+ schema = TYPE_MAP[field_def.type]&.dup || {}
78
+ schema[:description] = field_def.desc if field_def.desc
79
+ schema[:default] = _coerce_default(field_def) if field_def.default?
80
+ schema
81
+ end
82
+
83
+ def _coerce_default(field_def)
84
+ val = field_def.default
85
+ case field_def.type
86
+ when :integer then val.is_a?(Integer) ? val : val.to_i
87
+ when :float, :decimal then val.is_a?(Float) ? val : val.to_f
88
+ when :boolean then !!val
89
+ when :string, :date, :datetime, :time then val.to_s
90
+ else val
91
+ end
92
+ end
93
+
94
+ def _nested_json_schema(klass)
95
+ properties = {}
96
+ required = []
97
+
98
+ klass._field_registry.each do |name, field_def|
99
+ schema = _field_to_schema(field_def)
100
+ properties[name.to_s] = schema
101
+ required << name.to_s if field_def.required
102
+ end
103
+
104
+ _add_nested_properties(klass, properties, required)
105
+
106
+ result = { type: "object" }
107
+ result[:properties] = properties unless properties.empty?
108
+ result[:required] = required unless required.empty?
109
+ result[:additionalProperties] = false
110
+ result
111
+ end
112
+
113
+ def _add_nested_properties(source, properties, required)
114
+ source._nested_ones.each do |name, klass|
115
+ properties[name.to_s] = _nested_json_schema(klass)
116
+ required << name.to_s
117
+ end
118
+
119
+ source._nested_manys.each do |name, klass|
120
+ properties[name.to_s] = { type: "array", items: _nested_json_schema(klass) }
121
+ end
122
+ end
123
+
124
+ private_class_method :_serialize_fields, :_serialize_form_definition, :_serialize_nested, :_field_to_schema,
125
+ :_coerce_default, :_nested_json_schema, :_add_nested_properties
126
+ end
127
+ end
128
+ end
@@ -70,6 +70,8 @@ module Dex
70
70
 
71
71
  def _build_nested_class(name, class_name, &block)
72
72
  klass = Class.new(Dex::Form, &block)
73
+ klass.instance_variable_set(:@_dex_nested_form, true)
74
+ Dex::Form.deregister(klass)
73
75
  const_name = class_name || name.to_s.singularize.camelize
74
76
  const_set(const_name, klass)
75
77
  klass
data/lib/dex/form.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "active_model"
4
4
 
5
5
  require_relative "form/nesting"
6
+ require_relative "form/context"
6
7
 
7
8
  module Dex
8
9
  class Form
@@ -15,8 +16,17 @@ module Dex
15
16
  end
16
17
 
17
18
  include Nesting
19
+ include Context
18
20
  include Match
19
21
 
22
+ extend Registry
23
+
24
+ FIELD_DEFAULT_UNSET = Object.new.freeze
25
+
26
+ FieldDef = Data.define(:name, :type, :desc, :required, :default) do
27
+ def default? = !default.equal?(Dex::Form::FIELD_DEFAULT_UNSET)
28
+ end
29
+
20
30
  class ValidationError < StandardError
21
31
  attr_reader :form
22
32
 
@@ -27,6 +37,37 @@ module Dex
27
37
  end
28
38
 
29
39
  class << self
40
+ def _field_registry
41
+ @_field_registry ||= {}
42
+ end
43
+
44
+ def _required_fields
45
+ _field_registry.each_with_object([]) do |(name, f), list|
46
+ list << name if f.required
47
+ end
48
+ end
49
+
50
+ def field(name, type, desc: nil, default: FIELD_DEFAULT_UNSET, **options)
51
+ raise ArgumentError, "field name must be a Symbol, got #{name.inspect}" unless name.is_a?(Symbol)
52
+ raise ArgumentError, "field type must be a Symbol, got #{type.inspect}" unless type.is_a?(Symbol)
53
+ raise ArgumentError, "desc must be a String, got #{desc.inspect}" if desc && !desc.is_a?(String)
54
+
55
+ am_options = options.dup
56
+ am_options[:default] = default unless default.equal?(FIELD_DEFAULT_UNSET)
57
+ attribute name, type, **am_options
58
+ _field_registry[name] = FieldDef.new(name: name, type: type, desc: desc, required: true, default: default)
59
+ end
60
+
61
+ def field?(name, type, desc: nil, default: FIELD_DEFAULT_UNSET, **options)
62
+ raise ArgumentError, "field name must be a Symbol, got #{name.inspect}" unless name.is_a?(Symbol)
63
+ raise ArgumentError, "field type must be a Symbol, got #{type.inspect}" unless type.is_a?(Symbol)
64
+ raise ArgumentError, "desc must be a String, got #{desc.inspect}" if desc && !desc.is_a?(String)
65
+
66
+ actual_default = default.equal?(FIELD_DEFAULT_UNSET) ? nil : default
67
+ attribute name, type, default: actual_default, **options
68
+ _field_registry[name] = FieldDef.new(name: name, type: type, desc: desc, required: false, default: default)
69
+ end
70
+
30
71
  def model(klass = nil)
31
72
  if klass
32
73
  raise ArgumentError, "model must be a Class, got #{klass.inspect}" unless klass.is_a?(Class)
@@ -42,9 +83,38 @@ module Dex
42
83
 
43
84
  def inherited(subclass)
44
85
  super
86
+ subclass.instance_variable_set(:@_field_registry, _field_registry.dup)
45
87
  subclass.instance_variable_set(:@_nested_ones, _nested_ones.dup)
46
88
  subclass.instance_variable_set(:@_nested_manys, _nested_manys.dup)
47
89
  end
90
+
91
+ def _dex_nested_form?
92
+ !!instance_variable_get(:@_dex_nested_form)
93
+ end
94
+
95
+ # Export
96
+
97
+ def to_h
98
+ Export.build_hash(self)
99
+ end
100
+
101
+ def to_json_schema
102
+ Export.build_json_schema(self)
103
+ end
104
+
105
+ def export(format: :hash)
106
+ unless %i[hash json_schema].include?(format)
107
+ raise ArgumentError, "unknown format: #{format.inspect}. Known: :hash, :json_schema"
108
+ end
109
+
110
+ sorted = registry.reject { |klass| klass._dex_nested_form? }.sort_by(&:name)
111
+ sorted.map do |klass|
112
+ case format
113
+ when :hash then klass.to_h
114
+ when :json_schema then klass.to_json_schema
115
+ end
116
+ end
117
+ end
48
118
  end
49
119
 
50
120
  silence_redefinition_of_method :model_name
@@ -66,6 +136,18 @@ module Dex
66
136
  # setters are assignable; everything else is silently dropped.
67
137
  attributes = attributes.to_unsafe_h if attributes.respond_to?(:to_unsafe_h)
68
138
  attrs = (attributes || {}).transform_keys(&:to_s)
139
+
140
+ # Context injection — fill ambient values for unmapped keys
141
+ mappings = self.class.context_mappings
142
+ unless mappings.empty?
143
+ ambient = Dex.context
144
+ mappings.each do |attr_name, context_key|
145
+ str_name = attr_name.to_s
146
+ next if attrs.key?(str_name)
147
+ attrs[str_name] = ambient[context_key] if ambient.key?(context_key)
148
+ end
149
+ end
150
+
69
151
  record = attrs.delete("record")
70
152
  @record = record if record.nil? || record.respond_to?(:persisted?)
71
153
  provided_keys = attrs.keys
@@ -95,9 +177,10 @@ module Dex
95
177
  end
96
178
 
97
179
  def valid?(context = nil)
98
- super_result = super
99
- nested_result = _validate_nested(context)
100
- super_result && nested_result
180
+ super
181
+ _fix_boolean_presence_errors
182
+ nested_valid = _validate_nested(context)
183
+ errors.empty? && nested_valid
101
184
  end
102
185
 
103
186
  def to_h
@@ -111,8 +194,40 @@ module Dex
111
194
 
112
195
  alias_method :to_hash, :to_h
113
196
 
197
+ validate :_validate_required_fields
198
+
114
199
  private
115
200
 
201
+ def _validate_required_fields
202
+ explicit = self.class.validators
203
+ .select { |v| v.is_a?(ActiveModel::Validations::PresenceValidator) }
204
+ .reject { |v| v.options.key?(:on) || v.options.key?(:if) || v.options.key?(:unless) }
205
+ .flat_map(&:attributes).map(&:to_sym).to_set
206
+
207
+ self.class._required_fields.each do |name|
208
+ next if explicit.include?(name)
209
+ field_def = self.class._field_registry[name]
210
+ value = public_send(name)
211
+ blank = (field_def.type == :boolean) ? value.nil? : value.blank?
212
+ errors.add(name, :blank) if blank
213
+ end
214
+ end
215
+
216
+ def _fix_boolean_presence_errors
217
+ explicit = self.class.validators
218
+ .select { |v| v.is_a?(ActiveModel::Validations::PresenceValidator) }
219
+ .reject { |v| v.options.key?(:on) || v.options.key?(:if) || v.options.key?(:unless) }
220
+ .flat_map(&:attributes).map(&:to_sym)
221
+
222
+ explicit.each do |name|
223
+ field_def = self.class._field_registry[name]
224
+ next unless field_def&.type == :boolean
225
+ next if public_send(name).nil?
226
+
227
+ errors.delete(name, :blank)
228
+ end
229
+ end
230
+
116
231
  def _extract_nested_attributes(attrs)
117
232
  nested_keys = self.class._nested_ones.keys.map(&:to_s) +
118
233
  self.class._nested_manys.keys.map(&:to_s)
@@ -140,3 +255,4 @@ module Dex
140
255
  end
141
256
 
142
257
  require_relative "form/uniqueness_validator"
258
+ require_relative "form/export"
data/lib/dex/id.rb ADDED
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Dex
6
+ module Id
7
+ ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
8
+ BASE = ALPHABET.length
9
+ TIMESTAMP_WIDTH = 8
10
+ DEFAULT_RANDOM_WIDTH = 12
11
+ MIN_RANDOM_WIDTH = 8
12
+ PREFIX_PATTERN = /\A[a-z][a-z0-9_]*_\z/
13
+ MIN_PAYLOAD_LENGTH = 9 # 8 timestamp + at least 1 random
14
+
15
+ ALPHABET_INDEX = ALPHABET.each_char.with_index.to_h.freeze
16
+
17
+ Parsed = Data.define(:prefix, :created_at, :random)
18
+
19
+ module_function
20
+
21
+ def generate(prefix, random: DEFAULT_RANDOM_WIDTH)
22
+ validate_prefix!(prefix)
23
+ validate_random_width!(random)
24
+
25
+ "#{prefix}#{base58_encode(current_milliseconds, TIMESTAMP_WIDTH)}#{random_suffix(random)}"
26
+ end
27
+
28
+ def parse(id)
29
+ id = String(id)
30
+ last_underscore = id.rindex("_")
31
+
32
+ unless last_underscore
33
+ raise ArgumentError,
34
+ "Cannot parse #{id.inspect}: no underscore found. Dex::Id strings have the format \"prefix_<timestamp><random>\"."
35
+ end
36
+
37
+ prefix = id[0..last_underscore]
38
+ payload = id[(last_underscore + 1)..]
39
+
40
+ if payload.length < MIN_PAYLOAD_LENGTH
41
+ raise ArgumentError,
42
+ "Cannot parse #{id.inspect}: payload after prefix is #{payload.length} characters, " \
43
+ "need at least #{MIN_PAYLOAD_LENGTH} (#{TIMESTAMP_WIDTH} timestamp + 1 random)."
44
+ end
45
+
46
+ validate_base58!(payload, id)
47
+
48
+ timestamp_chars = payload[0, TIMESTAMP_WIDTH]
49
+ random_chars = payload[TIMESTAMP_WIDTH..]
50
+
51
+ ms = base58_decode(timestamp_chars)
52
+ created_at = Time.at(ms / 1000, ms % 1000 * 1000, :usec).utc
53
+
54
+ Parsed.new(prefix: prefix, created_at: created_at, random: random_chars)
55
+ end
56
+
57
+ def base58_encode(number, width = nil)
58
+ encoded = +""
59
+ value = number.to_i
60
+
61
+ loop do
62
+ value, remainder = value.divmod(BASE)
63
+ encoded.prepend(ALPHABET[remainder])
64
+ break unless value.positive?
65
+ end
66
+
67
+ width ? encoded.rjust(width, ALPHABET[0]) : encoded
68
+ end
69
+
70
+ def base58_decode(string)
71
+ raise ArgumentError, "expected a String, got #{string.inspect}" unless string.is_a?(String)
72
+
73
+ value = 0
74
+ string.each_char do |char|
75
+ index = ALPHABET_INDEX[char]
76
+ raise ArgumentError, "invalid base58 character #{char.inspect} in #{string.inspect}" unless index
77
+
78
+ value = value * BASE + index
79
+ end
80
+ value
81
+ end
82
+
83
+ def random_suffix(width)
84
+ Array.new(width) { ALPHABET[SecureRandom.random_number(BASE)] }.join
85
+ end
86
+
87
+ def current_milliseconds
88
+ Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
89
+ end
90
+
91
+ def validate_prefix!(prefix)
92
+ raise ArgumentError, "prefix must be a non-empty String" unless prefix.is_a?(String) && !prefix.empty?
93
+
94
+ return if PREFIX_PATTERN.match?(prefix)
95
+
96
+ unless prefix.end_with?("_")
97
+ raise ArgumentError,
98
+ "Invalid prefix #{prefix.inspect}: prefix must end with underscore. Did you mean #{"#{prefix}_".inspect}?"
99
+ end
100
+
101
+ raise ArgumentError,
102
+ "Invalid prefix #{prefix.inspect}: prefix must match #{PREFIX_PATTERN.inspect} " \
103
+ "(lowercase alphanumeric with internal underscores, ending in underscore)."
104
+ end
105
+ private_class_method :validate_prefix!
106
+
107
+ def validate_random_width!(width)
108
+ unless width.is_a?(Integer) && width >= MIN_RANDOM_WIDTH
109
+ raise ArgumentError,
110
+ "random: must be an Integer >= #{MIN_RANDOM_WIDTH}, got #{width.inspect}."
111
+ end
112
+ end
113
+ private_class_method :validate_random_width!
114
+
115
+ def validate_base58!(payload, original_id)
116
+ payload.each_char do |char|
117
+ next if ALPHABET_INDEX.key?(char)
118
+
119
+ raise ArgumentError,
120
+ "Cannot parse #{original_id.inspect}: invalid base58 character #{char.inspect} in payload."
121
+ end
122
+ end
123
+ private_class_method :validate_base58!
124
+ end
125
+ end
@@ -17,27 +17,45 @@ module Dex
17
17
  end
18
18
  end
19
19
 
20
+ def safe(*)
21
+ raise NoMethodError,
22
+ "safe and async are alternative execution strategies. " \
23
+ "For async outcome reconstruction, use wait/wait! on the ticket."
24
+ end
25
+
20
26
  private
21
27
 
22
28
  def enqueue_direct_job
23
29
  job = apply_options(Operation::DirectJob)
24
- payload = { class_name: operation_class_name, params: serialized_params }
30
+ payload = {
31
+ class_name: operation_class_name,
32
+ params: serialized_params,
33
+ trace: Dex::Trace.dump
34
+ }
25
35
  apply_once_payload!(payload)
26
- job.perform_later(**payload)
36
+ job = job.perform_later(**payload)
37
+ Operation::Ticket.new(record: nil, job: job)
27
38
  end
28
39
 
29
40
  def enqueue_record_job
30
41
  @operation.send(:_record_validate_backend!, async: true)
42
+ execution_id = Dex::Id.generate("op_")
31
43
  record = Dex.record_backend.create_record(
44
+ id: execution_id,
32
45
  name: operation_class_name,
33
46
  params: serialized_params,
34
47
  status: "pending"
35
48
  )
36
49
  begin
37
50
  job = apply_options(Operation::RecordJob)
38
- payload = { class_name: operation_class_name, record_id: record.id.to_s }
51
+ payload = {
52
+ class_name: operation_class_name,
53
+ record_id: record.id.to_s,
54
+ trace: Dex::Trace.dump
55
+ }
39
56
  apply_once_payload!(payload)
40
- job.perform_later(**payload)
57
+ job = job.perform_later(**payload)
58
+ Operation::Ticket.new(record: record, job: job)
41
59
  rescue => e
42
60
  begin
43
61
  record.destroy
@@ -124,7 +124,7 @@ module Dex
124
124
  threat = catch(:_dex_halt) { instance_exec(&guard.block) }
125
125
  if threat.is_a?(Operation::Halt)
126
126
  raise ArgumentError,
127
- "guard :#{guard.name} must return truthy/falsy, not call error!/success!/assert!"
127
+ "guard :#{guard.name} must return truthy/falsy, not call error!/success!"
128
128
  end
129
129
 
130
130
  if threat