circuit_breaker-wf 0.1.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 +7 -0
- data/.gitignore +4 -0
- data/CHANGELOG.md +52 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +116 -0
- data/LICENSE +21 -0
- data/README.md +324 -0
- data/examples/document/README.md +150 -0
- data/examples/document/document_assistant.rb +535 -0
- data/examples/document/document_rules.rb +60 -0
- data/examples/document/document_token.rb +83 -0
- data/examples/document/document_workflow.rb +114 -0
- data/examples/document/mock_executor.rb +80 -0
- data/lib/circuit_breaker/executors/README.md +664 -0
- data/lib/circuit_breaker/executors/agent_executor.rb +187 -0
- data/lib/circuit_breaker/executors/assistant_executor.rb +245 -0
- data/lib/circuit_breaker/executors/base_executor.rb +56 -0
- data/lib/circuit_breaker/executors/docker_executor.rb +56 -0
- data/lib/circuit_breaker/executors/dsl.rb +97 -0
- data/lib/circuit_breaker/executors/llm/memory.rb +82 -0
- data/lib/circuit_breaker/executors/llm/tools.rb +94 -0
- data/lib/circuit_breaker/executors/nats_executor.rb +230 -0
- data/lib/circuit_breaker/executors/serverless_executor.rb +25 -0
- data/lib/circuit_breaker/executors/step_executor.rb +47 -0
- data/lib/circuit_breaker/history.rb +81 -0
- data/lib/circuit_breaker/rules.rb +251 -0
- data/lib/circuit_breaker/templates/mermaid.html.erb +51 -0
- data/lib/circuit_breaker/templates/plantuml.html.erb +55 -0
- data/lib/circuit_breaker/token.rb +486 -0
- data/lib/circuit_breaker/visualizer.rb +173 -0
- data/lib/circuit_breaker/workflow_dsl.rb +359 -0
- data/lib/circuit_breaker.rb +236 -0
- data/workflow-editor/.gitignore +24 -0
- data/workflow-editor/README.md +106 -0
- data/workflow-editor/eslint.config.js +28 -0
- data/workflow-editor/index.html +13 -0
- data/workflow-editor/package-lock.json +6864 -0
- data/workflow-editor/package.json +50 -0
- data/workflow-editor/postcss.config.js +6 -0
- data/workflow-editor/public/vite.svg +1 -0
- data/workflow-editor/src/App.css +42 -0
- data/workflow-editor/src/App.tsx +365 -0
- data/workflow-editor/src/assets/react.svg +1 -0
- data/workflow-editor/src/components/AddNodeButton.tsx +68 -0
- data/workflow-editor/src/components/EdgeDetails.tsx +175 -0
- data/workflow-editor/src/components/NodeDetails.tsx +177 -0
- data/workflow-editor/src/components/ResizablePanel.tsx +74 -0
- data/workflow-editor/src/components/SaveButton.tsx +45 -0
- data/workflow-editor/src/config/change_workflow.yaml +59 -0
- data/workflow-editor/src/config/constants.ts +11 -0
- data/workflow-editor/src/config/flowConfig.ts +189 -0
- data/workflow-editor/src/config/uiConfig.ts +77 -0
- data/workflow-editor/src/config/workflow.yaml +58 -0
- data/workflow-editor/src/hooks/useKeyPress.ts +29 -0
- data/workflow-editor/src/index.css +34 -0
- data/workflow-editor/src/main.tsx +10 -0
- data/workflow-editor/src/server/saveWorkflow.ts +81 -0
- data/workflow-editor/src/utils/saveWorkflow.ts +92 -0
- data/workflow-editor/src/utils/workflowLoader.ts +26 -0
- data/workflow-editor/src/utils/workflowTransformer.ts +91 -0
- data/workflow-editor/src/vite-env.d.ts +1 -0
- data/workflow-editor/src/yaml.d.ts +4 -0
- data/workflow-editor/tailwind.config.js +15 -0
- data/workflow-editor/tsconfig.app.json +26 -0
- data/workflow-editor/tsconfig.json +7 -0
- data/workflow-editor/tsconfig.node.json +24 -0
- data/workflow-editor/vite.config.ts +8 -0
- metadata +267 -0
@@ -0,0 +1,486 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
require 'json'
|
3
|
+
require 'yaml'
|
4
|
+
require 'async'
|
5
|
+
require_relative 'rules'
|
6
|
+
require_relative 'visualizer'
|
7
|
+
require_relative 'history'
|
8
|
+
|
9
|
+
module CircuitBreaker
|
10
|
+
# Base class for all workflow tokens
|
11
|
+
class Token
|
12
|
+
include History
|
13
|
+
|
14
|
+
class ValidationError < StandardError; end
|
15
|
+
class StateError < StandardError; end
|
16
|
+
class TransitionError < StandardError; end
|
17
|
+
|
18
|
+
attr_reader :id, :created_at, :updated_at
|
19
|
+
attr_accessor :state, :history
|
20
|
+
|
21
|
+
# Class-level storage for hooks, validations, and transitions
|
22
|
+
class << self
|
23
|
+
def before_transition_hooks
|
24
|
+
@before_transition_hooks ||= []
|
25
|
+
end
|
26
|
+
|
27
|
+
def after_transition_hooks
|
28
|
+
@after_transition_hooks ||= []
|
29
|
+
end
|
30
|
+
|
31
|
+
def attribute_validations
|
32
|
+
@attribute_validations ||= {}
|
33
|
+
end
|
34
|
+
|
35
|
+
def state_validations
|
36
|
+
@state_validations ||= {}
|
37
|
+
end
|
38
|
+
|
39
|
+
def state_transitions
|
40
|
+
@state_transitions ||= {}
|
41
|
+
end
|
42
|
+
|
43
|
+
def transition_rules
|
44
|
+
@transition_rules ||= {}
|
45
|
+
end
|
46
|
+
|
47
|
+
# DSL methods for defining hooks and validations
|
48
|
+
def before_transition(&block)
|
49
|
+
before_transition_hooks << block
|
50
|
+
end
|
51
|
+
|
52
|
+
def after_transition(&block)
|
53
|
+
after_transition_hooks << block
|
54
|
+
end
|
55
|
+
|
56
|
+
def validate_state(state, &block)
|
57
|
+
state_validations[state] = block
|
58
|
+
end
|
59
|
+
|
60
|
+
def transitions_from(from, to:)
|
61
|
+
state_transitions[from] = Array(to)
|
62
|
+
end
|
63
|
+
|
64
|
+
def transition_rule(from:, to:, &block)
|
65
|
+
transition_rules[[from, to]] = block
|
66
|
+
end
|
67
|
+
|
68
|
+
# Enhanced DSL methods
|
69
|
+
def states(*state_list)
|
70
|
+
state_list.each do |state|
|
71
|
+
state_transitions[state] ||= []
|
72
|
+
end
|
73
|
+
|
74
|
+
# Define predicate methods for states
|
75
|
+
state_list.each do |state|
|
76
|
+
define_method("#{state}?") do
|
77
|
+
@state == state
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
const_set(:VALID_STATES, state_list.freeze)
|
82
|
+
end
|
83
|
+
|
84
|
+
def validate_attribute(name, &block)
|
85
|
+
attribute_validations[name] = block
|
86
|
+
end
|
87
|
+
|
88
|
+
def attribute(name, type = nil, **options)
|
89
|
+
# Add to list of attributes
|
90
|
+
attributes << name
|
91
|
+
|
92
|
+
# Define the attribute accessor
|
93
|
+
attr_accessor name
|
94
|
+
|
95
|
+
# Define the validator if type is specified
|
96
|
+
if type
|
97
|
+
validate_attribute name do |value|
|
98
|
+
next true if value.nil?
|
99
|
+
next false unless value.is_a?(type)
|
100
|
+
|
101
|
+
if options[:allowed]
|
102
|
+
options[:allowed].include?(value)
|
103
|
+
else
|
104
|
+
true
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def attributes
|
111
|
+
@attributes ||= []
|
112
|
+
end
|
113
|
+
|
114
|
+
def track_timestamp(*fields, on_state: nil, on_states: nil)
|
115
|
+
states = on_states || [on_state]
|
116
|
+
states.compact.each do |state|
|
117
|
+
state_timestamps[state] ||= []
|
118
|
+
state_timestamps[state].concat(fields)
|
119
|
+
end
|
120
|
+
attr_accessor(*fields)
|
121
|
+
end
|
122
|
+
|
123
|
+
def state_timestamps
|
124
|
+
@state_timestamps ||= {}
|
125
|
+
end
|
126
|
+
|
127
|
+
def state_messages
|
128
|
+
@state_messages ||= {}
|
129
|
+
end
|
130
|
+
|
131
|
+
# Default state message if none specified
|
132
|
+
def default_state_message
|
133
|
+
@default_state_message ||= ->(token, from, to) { "State changed from #{from} to #{to}" }
|
134
|
+
end
|
135
|
+
|
136
|
+
def default_state_message=(block)
|
137
|
+
@default_state_message = block
|
138
|
+
end
|
139
|
+
|
140
|
+
def state_message(for_state:, &block)
|
141
|
+
state_messages[for_state] = block
|
142
|
+
end
|
143
|
+
|
144
|
+
# Combined state configuration
|
145
|
+
def state_config(state, timestamps: nil, message: nil, &block)
|
146
|
+
# Handle timestamps
|
147
|
+
if timestamps
|
148
|
+
track_timestamp(*Array(timestamps), on_state: state)
|
149
|
+
end
|
150
|
+
|
151
|
+
# Handle message
|
152
|
+
if block_given?
|
153
|
+
state_message(for_state: state, &block)
|
154
|
+
elsif message
|
155
|
+
state_message(for_state: state) { |token| message }
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
# Multiple state configuration
|
160
|
+
def state_configs(&block)
|
161
|
+
config_dsl = StateConfigDSL.new(self)
|
162
|
+
config_dsl.instance_eval(&block)
|
163
|
+
end
|
164
|
+
|
165
|
+
# DSL for state configuration
|
166
|
+
class StateConfigDSL
|
167
|
+
def initialize(token_class)
|
168
|
+
@token_class = token_class
|
169
|
+
end
|
170
|
+
|
171
|
+
def state(name, timestamps: nil, message: nil, &block)
|
172
|
+
@token_class.state_config(name, timestamps: timestamps, message: message, &block)
|
173
|
+
end
|
174
|
+
|
175
|
+
def on_states(states, timestamps:)
|
176
|
+
Array(timestamps).each do |timestamp|
|
177
|
+
@token_class.track_timestamp(timestamp, on_states: states)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
# Visualization methods
|
183
|
+
def visualize(format = :mermaid)
|
184
|
+
case format
|
185
|
+
when :mermaid
|
186
|
+
Visualizer.to_mermaid(self)
|
187
|
+
when :dot
|
188
|
+
Visualizer.to_dot(self)
|
189
|
+
when :plantuml
|
190
|
+
Visualizer.to_plantuml(self)
|
191
|
+
when :html
|
192
|
+
Visualizer.to_html(self)
|
193
|
+
when :markdown
|
194
|
+
Visualizer.to_markdown(self)
|
195
|
+
else
|
196
|
+
raise ArgumentError, "Unsupported format: #{format}"
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
def initialize(attributes = {})
|
202
|
+
@id = SecureRandom.uuid
|
203
|
+
@state = self.class::VALID_STATES.first
|
204
|
+
|
205
|
+
# Initialize all attributes to nil
|
206
|
+
self.class.attributes.each do |attr|
|
207
|
+
instance_variable_set("@#{attr}", nil)
|
208
|
+
end
|
209
|
+
|
210
|
+
# Set provided attributes
|
211
|
+
attributes.each do |key, value|
|
212
|
+
send("#{key}=", value) if respond_to?("#{key}=")
|
213
|
+
end
|
214
|
+
|
215
|
+
# Add default transition hook for timestamps and history
|
216
|
+
self.class.before_transition do |from, to|
|
217
|
+
# Set timestamps for the target state
|
218
|
+
if (timestamp_fields = self.class.state_timestamps[to.to_sym])
|
219
|
+
timestamp_fields.each do |field|
|
220
|
+
send("#{field}=", Time.now)
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
# Record the transition in history with details
|
225
|
+
record_event(
|
226
|
+
:state_transition,
|
227
|
+
{
|
228
|
+
from: from,
|
229
|
+
to: to,
|
230
|
+
timestamp: Time.now,
|
231
|
+
details: state_change_details(from, to)
|
232
|
+
}
|
233
|
+
)
|
234
|
+
end
|
235
|
+
|
236
|
+
@created_at = Time.now
|
237
|
+
@updated_at = @created_at
|
238
|
+
@event_handlers = Hash.new { |h, k| h[k] = [] }
|
239
|
+
@async_handlers = Hash.new { |h, k| h[k] = [] }
|
240
|
+
@observers = Hash.new { |h, k| h[k] = [] }
|
241
|
+
@async_observers = Hash.new { |h, k| h[k] = [] }
|
242
|
+
@history = []
|
243
|
+
end
|
244
|
+
|
245
|
+
def update_state(new_state, actor_id: nil)
|
246
|
+
old_state = @state
|
247
|
+
|
248
|
+
begin
|
249
|
+
# Run before transition hooks
|
250
|
+
self.class.before_transition_hooks.each { |hook| instance_exec(old_state, new_state, &hook) }
|
251
|
+
|
252
|
+
# Run state-specific validations
|
253
|
+
validate_transition(from: old_state, to: new_state)
|
254
|
+
validate_current_state(new_state)
|
255
|
+
|
256
|
+
# Run custom transition rules
|
257
|
+
if rule = self.class.transition_rules[[old_state, new_state]]
|
258
|
+
result = instance_exec(&rule)
|
259
|
+
raise TransitionError, result if result.is_a?(String)
|
260
|
+
end
|
261
|
+
|
262
|
+
@state = new_state
|
263
|
+
@updated_at = Time.now
|
264
|
+
|
265
|
+
# Record the transition in history
|
266
|
+
record_event(:state_transition, {
|
267
|
+
from: old_state,
|
268
|
+
to: new_state,
|
269
|
+
timestamp: @updated_at
|
270
|
+
}, actor_id: actor_id)
|
271
|
+
|
272
|
+
# Run after transition hooks
|
273
|
+
self.class.after_transition_hooks.each { |hook| instance_exec(old_state, new_state, &hook) }
|
274
|
+
|
275
|
+
# Trigger state change events
|
276
|
+
trigger(:state_changed, old_state: old_state, new_state: new_state)
|
277
|
+
trigger_async(:state_changed, old_state: old_state, new_state: new_state)
|
278
|
+
|
279
|
+
notify(:state_changed, old_state: old_state, new_state: new_state)
|
280
|
+
|
281
|
+
true
|
282
|
+
rescue StandardError => e
|
283
|
+
# Record the failed transition
|
284
|
+
record_event(:transition_failed, {
|
285
|
+
from: old_state,
|
286
|
+
to: new_state,
|
287
|
+
error: e.message
|
288
|
+
}, actor_id: actor_id)
|
289
|
+
|
290
|
+
trigger(:transition_failed, error: e, from: old_state, to: new_state)
|
291
|
+
trigger_async(:transition_failed, error: e, from: old_state, to: new_state)
|
292
|
+
notify(:transition_failed, error: e, from: old_state, to: new_state)
|
293
|
+
raise
|
294
|
+
end
|
295
|
+
end
|
296
|
+
|
297
|
+
# Event handling
|
298
|
+
def on(event, async: false, &handler)
|
299
|
+
if async
|
300
|
+
@async_handlers[event] << handler
|
301
|
+
else
|
302
|
+
@event_handlers[event] << handler
|
303
|
+
end
|
304
|
+
self
|
305
|
+
end
|
306
|
+
|
307
|
+
def trigger(event, **data)
|
308
|
+
@event_handlers[event].each { |handler| handler.call(data) }
|
309
|
+
end
|
310
|
+
|
311
|
+
def trigger_async(event, **data)
|
312
|
+
return if @async_handlers[event].empty?
|
313
|
+
|
314
|
+
Async do |task|
|
315
|
+
@async_handlers[event].each do |handler|
|
316
|
+
task.async do
|
317
|
+
begin
|
318
|
+
handler.call(data)
|
319
|
+
rescue => e
|
320
|
+
# Log async handler errors and record in history
|
321
|
+
error_msg = "[ERROR] Async handler failed: #{e.message}"
|
322
|
+
puts error_msg
|
323
|
+
record_event(:async_handler_error, {
|
324
|
+
event: event,
|
325
|
+
error: error_msg,
|
326
|
+
data: data
|
327
|
+
})
|
328
|
+
end
|
329
|
+
end
|
330
|
+
end
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
def on(event, async: false, &block)
|
335
|
+
if async
|
336
|
+
@async_observers[event] << block
|
337
|
+
else
|
338
|
+
@observers[event] << block
|
339
|
+
end
|
340
|
+
end
|
341
|
+
|
342
|
+
def notify(event, data = {})
|
343
|
+
@observers[event].each { |observer| observer.call(data) }
|
344
|
+
|
345
|
+
@async_observers[event].each do |observer|
|
346
|
+
Thread.new do
|
347
|
+
observer.call(data)
|
348
|
+
end
|
349
|
+
end
|
350
|
+
end
|
351
|
+
|
352
|
+
# Serialization methods
|
353
|
+
def to_h(include_private = false)
|
354
|
+
if include_private
|
355
|
+
# Get all instance variables including private state
|
356
|
+
instance_variables.each_with_object({}) do |var, hash|
|
357
|
+
next if [:@event_handlers, :@async_handlers, :@observers, :@async_observers].include?(var)
|
358
|
+
key = var.to_s.delete_prefix('@').to_sym
|
359
|
+
hash[key] = instance_variable_get(var)
|
360
|
+
end
|
361
|
+
else
|
362
|
+
# Get only publicly accessible attributes
|
363
|
+
self.class.instance_methods(false)
|
364
|
+
.select { |method| method.to_s !~ /[=!?]$/ }
|
365
|
+
.reject { |method| [:inspect, :pretty_print, :to_h, :to_json, :to_yaml, :to_xml].include?(method) }
|
366
|
+
.each_with_object({}) do |method, hash|
|
367
|
+
hash[method] = send(method)
|
368
|
+
end
|
369
|
+
end
|
370
|
+
end
|
371
|
+
|
372
|
+
def to_json(include_private = false)
|
373
|
+
JSON.pretty_generate(to_h(include_private))
|
374
|
+
end
|
375
|
+
|
376
|
+
def to_yaml(include_private = false)
|
377
|
+
to_h(include_private).to_yaml
|
378
|
+
end
|
379
|
+
|
380
|
+
def to_xml(include_private = false)
|
381
|
+
require 'nokogiri'
|
382
|
+
builder = Nokogiri::XML::Builder.new do |xml|
|
383
|
+
xml.token(class: self.class.name) {
|
384
|
+
to_h(include_private).each do |key, value|
|
385
|
+
xml.send(key, value)
|
386
|
+
end
|
387
|
+
}
|
388
|
+
end
|
389
|
+
builder.to_xml
|
390
|
+
rescue LoadError
|
391
|
+
raise "Nokogiri is required for XML serialization. Add it to your Gemfile."
|
392
|
+
end
|
393
|
+
|
394
|
+
# Pretty print the object's state
|
395
|
+
def pretty_print(include_private = false)
|
396
|
+
fields = to_h(include_private)
|
397
|
+
|
398
|
+
# Find the longest key for padding
|
399
|
+
max_key_length = fields.keys.map(&:to_s).map(&:length).max
|
400
|
+
|
401
|
+
output = ["#<#{self.class}"]
|
402
|
+
fields.each do |key, value|
|
403
|
+
value_str = case value
|
404
|
+
when nil then "nil"
|
405
|
+
when String then "\"#{value}\""
|
406
|
+
when Time then "\"#{value.iso8601}\""
|
407
|
+
else value.to_s
|
408
|
+
end
|
409
|
+
|
410
|
+
# Pad the key with spaces for alignment
|
411
|
+
padded_key = key.to_s.ljust(max_key_length)
|
412
|
+
output << " #{padded_key}: #{value_str}"
|
413
|
+
end
|
414
|
+
output << ">"
|
415
|
+
|
416
|
+
output.join("\n")
|
417
|
+
end
|
418
|
+
|
419
|
+
# Show all state in inspect for debugging
|
420
|
+
def inspect
|
421
|
+
pretty_print(true)
|
422
|
+
end
|
423
|
+
|
424
|
+
def record_transition(transition_name, old_state, new_state)
|
425
|
+
@history << {
|
426
|
+
transition: transition_name,
|
427
|
+
from: old_state,
|
428
|
+
to: new_state,
|
429
|
+
timestamp: Time.now
|
430
|
+
}
|
431
|
+
@updated_at = Time.now
|
432
|
+
end
|
433
|
+
|
434
|
+
protected
|
435
|
+
|
436
|
+
def validate_current_state(state = @state)
|
437
|
+
return unless state
|
438
|
+
if validator = self.class.state_validations[state]
|
439
|
+
result = instance_exec(&validator)
|
440
|
+
raise StateError, "Invalid state #{state}: #{result}" if result.is_a?(String)
|
441
|
+
end
|
442
|
+
end
|
443
|
+
|
444
|
+
def validate_transition(from:, to:)
|
445
|
+
valid_transitions = self.class.state_transitions[from&.to_sym] || []
|
446
|
+
unless valid_transitions.include?(to.to_sym)
|
447
|
+
raise StateError, "Cannot transition from #{from} to #{to}"
|
448
|
+
end
|
449
|
+
end
|
450
|
+
|
451
|
+
def method_missing(method_name, *args)
|
452
|
+
# Check if it's a setter method (ends with =)
|
453
|
+
if method_name.to_s.end_with?('=')
|
454
|
+
attr_name = method_name.to_s.chomp('=')
|
455
|
+
if self.class.attributes.include?(attr_name.to_sym)
|
456
|
+
self.class.send(:attr_accessor, attr_name.to_sym)
|
457
|
+
send(method_name, *args)
|
458
|
+
else
|
459
|
+
super
|
460
|
+
end
|
461
|
+
else
|
462
|
+
super
|
463
|
+
end
|
464
|
+
end
|
465
|
+
|
466
|
+
def respond_to_missing?(method_name, include_private = false)
|
467
|
+
# Check if it's a setter method (ends with =)
|
468
|
+
if method_name.to_s.end_with?('=')
|
469
|
+
attr_name = method_name.to_s.chomp('=')
|
470
|
+
self.class.attributes.include?(attr_name.to_sym)
|
471
|
+
else
|
472
|
+
super
|
473
|
+
end
|
474
|
+
end
|
475
|
+
|
476
|
+
private
|
477
|
+
|
478
|
+
def state_change_details(from, to)
|
479
|
+
if (message_block = self.class.state_messages[to.to_sym])
|
480
|
+
message_block.call(self)
|
481
|
+
else
|
482
|
+
self.class.default_state_message.call(self, from, to)
|
483
|
+
end
|
484
|
+
end
|
485
|
+
end
|
486
|
+
end
|
@@ -0,0 +1,173 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'erb'
|
3
|
+
|
4
|
+
module CircuitBreaker
|
5
|
+
class Visualizer
|
6
|
+
TEMPLATES_DIR = File.join(File.dirname(__FILE__), 'templates')
|
7
|
+
|
8
|
+
class << self
|
9
|
+
def to_mermaid(token_class)
|
10
|
+
transitions = token_class.state_transitions
|
11
|
+
states = transitions.keys.concat(transitions.values.flatten).uniq
|
12
|
+
validations = token_class.state_validations
|
13
|
+
|
14
|
+
lines = ["stateDiagram-v2"]
|
15
|
+
|
16
|
+
# Add states with validation notes
|
17
|
+
states.each do |state|
|
18
|
+
lines << " #{state}"
|
19
|
+
if validations[state]
|
20
|
+
lines << " note right of #{state}"
|
21
|
+
# Get the file path relative to the project root
|
22
|
+
validation_file = validations[state].source_location.first
|
23
|
+
relative_path = validation_file.sub(File.expand_path('../../..', __FILE__), '')
|
24
|
+
relative_path = relative_path.start_with?('/') ? relative_path[1..-1] : relative_path
|
25
|
+
lines << " Validations in:"
|
26
|
+
lines << " #{relative_path}"
|
27
|
+
lines << " end note"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Add transitions with hooks
|
32
|
+
transitions.each do |from, to_states|
|
33
|
+
to_states.each do |to|
|
34
|
+
lines << " #{from} --> #{to}"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
lines.join("\n")
|
39
|
+
end
|
40
|
+
|
41
|
+
def to_dot(token_class)
|
42
|
+
transitions = token_class.state_transitions
|
43
|
+
validations = token_class.state_validations
|
44
|
+
|
45
|
+
lines = ["digraph {"]
|
46
|
+
lines << " rankdir=LR;"
|
47
|
+
|
48
|
+
# Style definitions
|
49
|
+
lines << " node [shape=circle];"
|
50
|
+
lines << " edge [fontsize=10];"
|
51
|
+
|
52
|
+
# Add states with validation info
|
53
|
+
transitions.keys.concat(transitions.values.flatten).uniq.each do |state|
|
54
|
+
validation = validations[state] ? "with validation" : "no validation"
|
55
|
+
lines << " #{state} [label=\"#{state}\\n#{validation}\"];"
|
56
|
+
end
|
57
|
+
|
58
|
+
# Add transitions
|
59
|
+
transitions.each do |from, to_states|
|
60
|
+
to_states.each do |to|
|
61
|
+
lines << " #{from} -> #{to};"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
lines << "}"
|
66
|
+
lines.join("\n")
|
67
|
+
end
|
68
|
+
|
69
|
+
def to_plantuml(token_class)
|
70
|
+
transitions = token_class.state_transitions
|
71
|
+
validations = token_class.state_validations
|
72
|
+
|
73
|
+
lines = ["@startuml"]
|
74
|
+
lines << "skinparam monochrome true"
|
75
|
+
lines << "skinparam defaultFontName Arial"
|
76
|
+
|
77
|
+
# Add states
|
78
|
+
transitions.keys.concat(transitions.values.flatten).uniq.each do |state|
|
79
|
+
if validations[state]
|
80
|
+
lines << "state #{state} {"
|
81
|
+
lines << " note right: Has validations"
|
82
|
+
lines << "}"
|
83
|
+
else
|
84
|
+
lines << "state #{state}"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Add transitions
|
89
|
+
transitions.each do |from, to_states|
|
90
|
+
to_states.each do |to|
|
91
|
+
lines << "#{from} --> #{to}"
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
lines << "@enduml"
|
96
|
+
lines.join("\n")
|
97
|
+
end
|
98
|
+
|
99
|
+
def to_html(token_class, engine = :mermaid)
|
100
|
+
case engine
|
101
|
+
when :mermaid
|
102
|
+
diagram = to_mermaid(token_class)
|
103
|
+
template_path = File.join(TEMPLATES_DIR, 'mermaid.html.erb')
|
104
|
+
when :plantuml
|
105
|
+
diagram = to_plantuml(token_class)
|
106
|
+
template_path = File.join(TEMPLATES_DIR, 'plantuml.html.erb')
|
107
|
+
else
|
108
|
+
raise ArgumentError, "Unsupported engine: #{engine}"
|
109
|
+
end
|
110
|
+
|
111
|
+
template = ERB.new(File.read(template_path))
|
112
|
+
template.result(binding)
|
113
|
+
end
|
114
|
+
|
115
|
+
def to_markdown(token_class)
|
116
|
+
transitions = token_class.state_transitions
|
117
|
+
states = transitions.keys.concat(transitions.values.flatten).uniq
|
118
|
+
validations = token_class.state_validations
|
119
|
+
|
120
|
+
lines = ["# #{token_class.name} Workflow\n"]
|
121
|
+
lines << "## States\n"
|
122
|
+
|
123
|
+
states.each do |state|
|
124
|
+
lines << "### #{state.to_s.capitalize}\n"
|
125
|
+
if validations[state]
|
126
|
+
lines << "**Validations:**\n"
|
127
|
+
lines << "- State-specific validation rules are enforced\n"
|
128
|
+
end
|
129
|
+
lines << "\n"
|
130
|
+
end
|
131
|
+
|
132
|
+
lines << "## Transitions\n"
|
133
|
+
transitions.each do |from, to_states|
|
134
|
+
to_states.each do |to|
|
135
|
+
lines << "### #{from} → #{to}\n"
|
136
|
+
if token_class.transition_rules[[from, to]]
|
137
|
+
lines << "**Rules:**\n"
|
138
|
+
lines << "- Transition-specific validation rules are enforced\n"
|
139
|
+
end
|
140
|
+
lines << "\n"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
lines << "## Hooks\n"
|
145
|
+
lines << "### Before Transition\n"
|
146
|
+
lines << "- #{token_class.before_transition_hooks.size} hooks registered\n\n"
|
147
|
+
lines << "### After Transition\n"
|
148
|
+
lines << "- #{token_class.after_transition_hooks.size} hooks registered\n\n"
|
149
|
+
|
150
|
+
lines.join
|
151
|
+
end
|
152
|
+
|
153
|
+
def save(token_class, format:, filename:)
|
154
|
+
content = case format
|
155
|
+
when :mermaid
|
156
|
+
to_mermaid(token_class)
|
157
|
+
when :dot
|
158
|
+
to_dot(token_class)
|
159
|
+
when :plantuml
|
160
|
+
to_plantuml(token_class)
|
161
|
+
when :html
|
162
|
+
to_html(token_class)
|
163
|
+
when :markdown
|
164
|
+
to_markdown(token_class)
|
165
|
+
else
|
166
|
+
raise ArgumentError, "Unsupported format: #{format}"
|
167
|
+
end
|
168
|
+
|
169
|
+
File.write(filename, content)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|