dexkit 0.7.0 → 0.8.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.
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Operation
5
+ module Export
6
+ module_function
7
+
8
+ def build_hash(source, contract) # rubocop:disable Metrics/MethodLength
9
+ h = {}
10
+ h[:name] = source.name if source&.name
11
+ desc = source&.description
12
+ h[:description] = desc if desc
13
+ h[:params] = _serialize_params(source, contract.params)
14
+ h[:success] = TypeSerializer.to_string(contract.success) if contract.success
15
+ h[:errors] = contract.errors unless contract.errors.empty?
16
+ h[:guards] = contract.guards unless contract.guards.empty?
17
+ ctx = _serialize_context(source)
18
+ h[:context] = ctx unless ctx.empty?
19
+ h[:pipeline] = source.pipeline.steps.map(&:name) if source
20
+ h[:settings] = _serialize_settings(source) if source
21
+ h
22
+ end
23
+
24
+ def build_json_schema(source, contract, section: :params) # rubocop:disable Metrics/MethodLength
25
+ case section
26
+ when :params then _params_schema(source, contract)
27
+ when :success then _success_schema(source, contract)
28
+ when :errors then _errors_schema(source, contract)
29
+ when :full then _full_schema(source, contract)
30
+ else
31
+ raise ArgumentError,
32
+ "unknown section: #{section.inspect}. Known: :params, :success, :errors, :full"
33
+ end
34
+ end
35
+
36
+ def _serialize_params(source, params)
37
+ descs = source&.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
38
+ params.each_with_object({}) do |(name, type), hash|
39
+ entry = { type: TypeSerializer.to_string(type), required: _required?(source, name) }
40
+ entry[:desc] = descs[name] if descs[name]
41
+ hash[name] = entry
42
+ end
43
+ end
44
+
45
+ def _required?(source, prop_name)
46
+ return true unless source&.respond_to?(:literal_properties)
47
+
48
+ prop = source.literal_properties.find { |p| p.name == prop_name }
49
+ return true unless prop
50
+
51
+ prop.required?
52
+ end
53
+
54
+ def _serialize_context(source)
55
+ source&.respond_to?(:context_mappings) ? source.context_mappings.presence || {} : {}
56
+ end
57
+
58
+ def _serialize_settings(source) # rubocop:disable Metrics/MethodLength
59
+ settings = {}
60
+
61
+ record_s = source.settings_for(:record)
62
+ settings[:record] = {
63
+ enabled: record_s.fetch(:enabled, true),
64
+ params: record_s.fetch(:params, true),
65
+ result: record_s.fetch(:result, true)
66
+ }
67
+
68
+ tx_s = source.settings_for(:transaction)
69
+ settings[:transaction] = { enabled: tx_s.fetch(:enabled, true) }
70
+
71
+ once_s = source.settings_for(:once)
72
+ settings[:once] = { defined: once_s.fetch(:defined, false) }
73
+
74
+ settings
75
+ end
76
+
77
+ def _params_schema(source, contract) # rubocop:disable Metrics/MethodLength
78
+ descs = source&.respond_to?(:prop_descriptions) ? source.prop_descriptions : {}
79
+ properties = {}
80
+ required = []
81
+
82
+ contract.params.each do |name, type|
83
+ prop_desc = descs[name]
84
+ schema = TypeSerializer.to_json_schema(type, desc: prop_desc)
85
+ properties[name.to_s] = schema
86
+ required << name.to_s if _required?(source, name)
87
+ end
88
+
89
+ result = { "$schema": "https://json-schema.org/draft/2020-12/schema", type: "object" }
90
+ result[:title] = source.name if source&.name
91
+ desc = source&.description
92
+ result[:description] = desc if desc
93
+ result[:properties] = properties unless properties.empty?
94
+ result[:required] = required unless required.empty?
95
+ result[:additionalProperties] = false
96
+ result
97
+ end
98
+
99
+ def _success_schema(source, contract)
100
+ return {} unless contract.success
101
+
102
+ schema = TypeSerializer.to_json_schema(contract.success)
103
+ result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
104
+ result[:title] = "#{source&.name} success" if source&.name
105
+ result.merge(schema)
106
+ end
107
+
108
+ def _errors_schema(source, contract)
109
+ result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
110
+ result[:title] = "#{source&.name} errors" if source&.name
111
+ result[:type] = "object"
112
+
113
+ properties = {}
114
+ contract.errors.each do |code|
115
+ properties[code.to_s] = {
116
+ type: "object",
117
+ properties: {
118
+ code: { const: code.to_s },
119
+ message: { type: "string" },
120
+ details: { type: "object" }
121
+ }
122
+ }
123
+ end
124
+ result[:properties] = properties unless properties.empty?
125
+ result
126
+ end
127
+
128
+ def _full_schema(source, contract)
129
+ result = { "$schema": "https://json-schema.org/draft/2020-12/schema" }
130
+ result[:title] = source.name if source&.name
131
+ result[:description] = "Operation contract"
132
+ result[:properties] = {
133
+ params: _params_schema(source, contract).except(:$schema),
134
+ success: _success_schema(source, contract).except(:$schema),
135
+ errors: _errors_schema(source, contract).except(:$schema)
136
+ }
137
+ result
138
+ end
139
+
140
+ private_class_method :_serialize_params, :_required?, :_serialize_context, :_serialize_settings,
141
+ :_params_schema, :_success_schema, :_errors_schema, :_full_schema
142
+ end
143
+ end
144
+ end
@@ -107,16 +107,17 @@ module Dex
107
107
 
108
108
  private
109
109
 
110
- def _guard_evaluate
110
+ def _guard_evaluate_all
111
111
  guards = self.class._guard_list
112
112
  return [] if guards.empty?
113
113
 
114
114
  blocked_names = Set.new
115
- failures = []
115
+ results = []
116
116
 
117
117
  guards.each do |guard|
118
118
  if guard.requires.any? { |dep| blocked_names.include?(dep) }
119
119
  blocked_names << guard.name
120
+ results << { name: guard.name, passed: false, skipped: true }
120
121
  next
121
122
  end
122
123
 
@@ -128,11 +129,21 @@ module Dex
128
129
 
129
130
  if threat
130
131
  blocked_names << guard.name
131
- failures << { guard: guard.name, message: guard.message || guard.name.to_s }
132
+ results << { name: guard.name, passed: false, message: guard.message || guard.name.to_s }
133
+ else
134
+ results << { name: guard.name, passed: true }
132
135
  end
133
136
  end
134
137
 
135
- failures
138
+ results
139
+ end
140
+
141
+ def _guard_evaluate
142
+ _guard_evaluate_all.filter_map do |r|
143
+ next if r[:passed] || r[:skipped]
144
+
145
+ { guard: r[:name], message: r[:message] }
146
+ end
136
147
  end
137
148
  end
138
149
  end
@@ -42,6 +42,10 @@ module Dex
42
42
  raise NotImplementedError
43
43
  end
44
44
 
45
+ def find_pending_once_key(key)
46
+ raise NotImplementedError
47
+ end
48
+
45
49
  def update_record_by_once_key(key, **attributes)
46
50
  raise NotImplementedError
47
51
  end
@@ -80,6 +84,10 @@ module Dex
80
84
  .first
81
85
  end
82
86
 
87
+ def find_pending_once_key(key)
88
+ record_class.where(once_key: key, status: %w[pending running]).first
89
+ end
90
+
83
91
  def update_record_by_once_key(key, **attributes)
84
92
  record = record_class.where(once_key: key, status: %w[completed error]).first
85
93
  record&.update!(safe_attributes(attributes))
@@ -119,6 +127,10 @@ module Dex
119
127
  ).first
120
128
  end
121
129
 
130
+ def find_pending_once_key(key)
131
+ record_class.where(:once_key => key, :status.in => %w[pending running]).first
132
+ end
133
+
122
134
  def update_record_by_once_key(key, **attributes)
123
135
  record = record_class.where(:once_key => key, :status.in => %w[completed error]).first
124
136
  record&.update!(safe_attributes(attributes))
data/lib/dex/operation.rb CHANGED
@@ -49,7 +49,32 @@ module Dex
49
49
  include TypeCoercion
50
50
  include ContextSetup
51
51
 
52
- Contract = Data.define(:params, :success, :errors, :guards)
52
+ Contract = Data.define(:params, :success, :errors, :guards) do
53
+ attr_reader :source_class
54
+
55
+ def initialize(params:, success:, errors:, guards:, source_class: nil)
56
+ @source_class = source_class
57
+ super(params: params, success: success, errors: errors, guards: guards)
58
+ end
59
+
60
+ def to_h
61
+ if @source_class
62
+ Operation::Export.build_hash(@source_class, self)
63
+ else
64
+ super
65
+ end
66
+ end
67
+
68
+ def to_json_schema(**options)
69
+ unless @source_class
70
+ raise ArgumentError, "to_json_schema requires a source_class (use OperationClass.contract.to_json_schema)"
71
+ end
72
+
73
+ Operation::Export.build_json_schema(@source_class, self, **options)
74
+ end
75
+ end
76
+
77
+ extend Registry
53
78
 
54
79
  class << self
55
80
  def contract
@@ -57,10 +82,25 @@ module Dex
57
82
  params: _contract_params,
58
83
  success: _success_type,
59
84
  errors: _declared_errors,
60
- guards: _contract_guards
85
+ guards: _contract_guards,
86
+ source_class: self
61
87
  )
62
88
  end
63
89
 
90
+ def export(format: :hash, **options)
91
+ unless %i[hash json_schema].include?(format)
92
+ raise ArgumentError, "unknown format: #{format.inspect}. Known: :hash, :json_schema"
93
+ end
94
+
95
+ sorted = registry.sort_by(&:name)
96
+ sorted.map do |klass|
97
+ case format
98
+ when :hash then klass.contract.to_h
99
+ when :json_schema then klass.contract.to_json_schema(**options)
100
+ end
101
+ end
102
+ end
103
+
64
104
  private
65
105
 
66
106
  def _contract_params
@@ -116,6 +156,10 @@ require_relative "operation/async_proxy"
116
156
  require_relative "operation/record_backend"
117
157
  require_relative "operation/transaction_adapter"
118
158
  require_relative "operation/jobs"
159
+ require_relative "operation/explain"
160
+ require_relative "operation/export"
161
+
162
+ Dex::Operation.extend(Dex::Operation::Explain)
119
163
 
120
164
  # Top-level aliases (depend on Operation::Ok/Err)
121
165
  require_relative "match"
@@ -17,10 +17,17 @@ module Dex
17
17
  end
18
18
 
19
19
  module ClassMethods
20
- def prop(name, type, kind = :keyword, **options, &block)
20
+ def prop(name, type, kind = :keyword, desc: nil, **options, &block)
21
21
  if const_defined?(:RESERVED_PROP_NAMES) && self::RESERVED_PROP_NAMES.include?(name)
22
22
  raise ArgumentError, "Property :#{name} is reserved."
23
23
  end
24
+ if !desc.nil?
25
+ raise ArgumentError, "desc: must be a String, got #{desc.class}" unless desc.is_a?(String)
26
+
27
+ _prop_desc_own[name] = desc
28
+ elsif superclass.respond_to?(:prop_descriptions) && superclass.prop_descriptions.key?(name)
29
+ _prop_desc_own[name] = nil
30
+ end
24
31
  options[:reader] = :public unless options.key?(:reader)
25
32
  if type.is_a?(Dex::RefType) && !block
26
33
  ref = type
@@ -29,7 +36,12 @@ module Dex
29
36
  super(name, type, kind, **options, &block)
30
37
  end
31
38
 
32
- def prop?(name, type, kind = :keyword, **options, &block)
39
+ def prop?(name, type, kind = :keyword, desc: nil, **options, &block)
40
+ if !desc.nil?
41
+ raise ArgumentError, "desc: must be a String, got #{desc.class}" unless desc.is_a?(String)
42
+
43
+ _prop_desc_own[name] = desc
44
+ end
33
45
  options[:reader] = :public unless options.key?(:reader)
34
46
  options[:default] = nil unless options.key?(:default)
35
47
  if type.is_a?(Dex::RefType) && !block
@@ -39,9 +51,20 @@ module Dex
39
51
  prop(name, _Nilable(type), kind, **options, &block)
40
52
  end
41
53
 
54
+ def prop_descriptions
55
+ parent = superclass.respond_to?(:prop_descriptions) ? superclass.prop_descriptions : {}
56
+ parent.merge(_prop_desc_own).compact
57
+ end
58
+
42
59
  def _Ref(model_class, lock: false) # rubocop:disable Naming/MethodName
43
60
  Dex::RefType.new(model_class, lock: lock)
44
61
  end
62
+
63
+ private
64
+
65
+ def _prop_desc_own
66
+ @_prop_desc_own ||= {}
67
+ end
45
68
  end
46
69
  end
47
70
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ namespace :dex do
7
+ desc "Export operation/event/handler contracts (FORMAT=hash|json_schema SECTION=operations|events|handlers FILE=path)"
8
+ task export: :environment do
9
+ Rails.application.eager_load!
10
+
11
+ format = (ENV["FORMAT"] || "hash").to_sym
12
+ section = ENV["SECTION"] || "operations"
13
+ file = ENV["FILE"]
14
+
15
+ data = case section
16
+ when "operations" then Dex::Operation.export(format: format)
17
+ when "events" then Dex::Event.export(format: format)
18
+ when "handlers"
19
+ if format != :hash
20
+ raise "Handlers only support FORMAT=hash (got #{format})"
21
+ end
22
+
23
+ Dex::Event::Handler.export(format: format)
24
+ else
25
+ raise "Unknown SECTION=#{section}. Known: operations, events, handlers"
26
+ end
27
+
28
+ json = JSON.pretty_generate(data)
29
+
30
+ if file
31
+ File.write(file, json)
32
+ puts "Wrote #{data.size} #{section} to #{file}"
33
+ else
34
+ puts json
35
+ end
36
+ end
37
+
38
+ desc "Install LLM guides as AGENTS.md in app directories (FORCE=1 to overwrite app-owned files)"
39
+ task :guides do
40
+ gem_root = File.expand_path("../..", __dir__)
41
+ guide_dir = File.join(gem_root, "guides", "llm")
42
+ marker = "<!-- dexkit v#{Dex::VERSION} | Auto-generated by: rake dex:guides -->"
43
+ force = ENV["FORCE"] == "1"
44
+ written = 0
45
+
46
+ # [source_file, env_var, default_path]
47
+ mapping = [
48
+ ["OPERATION.md", "OPERATIONS_PATH", "app/operations"],
49
+ ["EVENT.md", "EVENTS_PATH", "app/events"],
50
+ ["EVENT.md", "EVENT_HANDLERS_PATH", "app/event_handlers"],
51
+ ["FORM.md", "FORMS_PATH", "app/forms"],
52
+ ["QUERY.md", "QUERIES_PATH", "app/queries"]
53
+ ]
54
+
55
+ mapping.each do |source_file, env_var, default_path|
56
+ target_dir = ENV[env_var] || default_path
57
+
58
+ unless File.directory?(target_dir)
59
+ puts " #{target_dir}/AGENTS.md (skipped — directory not found)"
60
+ next
61
+ end
62
+
63
+ target = File.join(target_dir, "AGENTS.md")
64
+
65
+ if File.exist?(target) && !force
66
+ first_line = File.open(target, &:readline).chomp rescue "" # rubocop:disable Style/RescueModifier
67
+ unless first_line.start_with?("<!-- dexkit v")
68
+ puts " #{target} (skipped — not generated by dexkit, use FORCE=1 to overwrite)"
69
+ next
70
+ end
71
+ end
72
+
73
+ source = File.join(guide_dir, source_file)
74
+ File.write(target, "#{marker}\n\n#{File.read(source)}")
75
+ written += 1
76
+ puts " #{target} ← #{source_file}"
77
+ end
78
+
79
+ puts "\n#{written} guide(s) installed."
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module Registry
5
+ def self.extended(base)
6
+ base.instance_variable_set(:@_registry, Set.new)
7
+ end
8
+
9
+ def inherited(subclass)
10
+ super
11
+ _dex_registry.add(subclass)
12
+ end
13
+
14
+ def registry
15
+ live = _dex_registry.select { |k| k.name && _dex_reachable?(k) }
16
+ _dex_registry.replace(live)
17
+ live.to_set.freeze
18
+ end
19
+
20
+ def deregister(klass)
21
+ _dex_registry.delete(klass)
22
+ end
23
+
24
+ def clear!
25
+ _dex_registry.clear
26
+ end
27
+
28
+ def description(text = nil)
29
+ if !text.nil?
30
+ raise ArgumentError, "description must be a String" unless text.is_a?(String)
31
+
32
+ @_description = text
33
+ else
34
+ defined?(@_description) ? @_description : _dex_parent_description
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def _dex_reachable?(klass)
41
+ Object.const_get(klass.name) == klass
42
+ rescue NameError
43
+ false
44
+ end
45
+
46
+ def _dex_registry
47
+ if instance_variable_defined?(:@_registry)
48
+ @_registry
49
+ elsif superclass.respond_to?(:_dex_registry, true)
50
+ superclass.send(:_dex_registry)
51
+ else
52
+ @_registry = Set.new
53
+ end
54
+ end
55
+
56
+ def _dex_parent_description
57
+ return nil unless superclass.respond_to?(:description)
58
+ return nil if superclass.instance_variable_defined?(:@_registry)
59
+
60
+ superclass.description
61
+ end
62
+ end
63
+ end
data/lib/dex/tool.rb ADDED
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dex
4
+ module Tool
5
+ module_function
6
+
7
+ def from(operation_class)
8
+ _require_ruby_llm!
9
+ _build_tool(operation_class)
10
+ end
11
+
12
+ def all
13
+ _require_ruby_llm!
14
+ Operation.registry.sort_by(&:name).map { |klass| _build_tool(klass) }
15
+ end
16
+
17
+ def from_namespace(namespace)
18
+ _require_ruby_llm!
19
+ prefix = "#{namespace}::"
20
+ Operation.registry
21
+ .select { |op| op.name&.start_with?(prefix) }
22
+ .sort_by(&:name)
23
+ .map { |klass| _build_tool(klass) }
24
+ end
25
+
26
+ def explain_tool
27
+ _require_ruby_llm!
28
+ _build_explain_tool
29
+ end
30
+
31
+ def _require_ruby_llm!
32
+ require "ruby_llm"
33
+ rescue LoadError
34
+ raise LoadError,
35
+ "Dex::Tool requires the ruby-llm gem. Add `gem 'ruby_llm'` to your Gemfile."
36
+ end
37
+
38
+ def _build_tool(operation_class) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
39
+ op = operation_class
40
+ schema = op.contract.to_json_schema
41
+ tool_description = _tool_description(op)
42
+
43
+ Class.new(RubyLLM::Tool) do
44
+ define_method(:name) { "dex_#{op.name.gsub("::", "_").downcase}" }
45
+ define_method(:description) { tool_description }
46
+ define_method(:params_schema) { schema }
47
+
48
+ define_method(:execute) do |**params|
49
+ coerced = params.transform_keys(&:to_sym)
50
+ result = op.new(**coerced).safe.call
51
+ case result
52
+ when Dex::Operation::Ok
53
+ value = result.value
54
+ value.respond_to?(:as_json) ? value.as_json : value
55
+ when Dex::Operation::Err
56
+ { error: result.code, message: result.message, details: result.details }
57
+ end
58
+ end
59
+ end.new
60
+ end
61
+
62
+ def _tool_description(op)
63
+ parts = []
64
+ desc = op.description
65
+ parts << desc if desc
66
+ parts << op.name unless desc
67
+
68
+ guards = op.contract.guards
69
+ if guards.any?
70
+ messages = guards.map { |g| g[:message] || g[:name].to_s }
71
+ parts << "Preconditions: #{messages.join("; ")}."
72
+ end
73
+
74
+ errors = op.contract.errors
75
+ parts << "Errors: #{errors.join(", ")}." if errors.any?
76
+
77
+ parts.join("\n")
78
+ end
79
+
80
+ def _build_explain_tool # rubocop:disable Metrics/MethodLength
81
+ Class.new(RubyLLM::Tool) do
82
+ define_method(:name) { "dex_explain" }
83
+ define_method(:description) { "Check if an operation can be executed with given params, without running it." }
84
+ define_method(:params_schema) do
85
+ {
86
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
87
+ type: "object",
88
+ properties: {
89
+ "operation" => { type: "string", description: "Operation class name (e.g. 'Order::Place')" },
90
+ "params" => { type: "object", description: "Params to check" }
91
+ },
92
+ required: ["operation"],
93
+ additionalProperties: false
94
+ }
95
+ end
96
+
97
+ define_method(:execute) do |operation:, params: {}|
98
+ op_class = Dex::Operation.registry.find { |klass| klass.name == operation }
99
+ return { error: "unknown_operation", message: "Operation '#{operation}' not found in registry" } unless op_class
100
+
101
+ coerced = params.transform_keys(&:to_sym)
102
+ info = op_class.explain(**coerced)
103
+ {
104
+ callable: info[:callable],
105
+ guards: info[:guards],
106
+ once: info[:once],
107
+ lock: info[:lock]
108
+ }
109
+ end
110
+ end.new
111
+ end
112
+
113
+ private_class_method :_require_ruby_llm!, :_build_tool, :_tool_description, :_build_explain_tool
114
+ end
115
+ end