durable_workflow 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/.claude/todo/01.amend.md +133 -0
- data/.claude/todo/02.amend.md +444 -0
- data/.claude/todo/phase-1-core/01-GEMSPEC.md +193 -0
- data/.claude/todo/phase-1-core/02-TYPES.md +462 -0
- data/.claude/todo/phase-1-core/03-EXECUTION.md +551 -0
- data/.claude/todo/phase-1-core/04-STEPS.md +603 -0
- data/.claude/todo/phase-1-core/05-PARSER.md +719 -0
- data/.claude/todo/phase-1-core/todo.md +574 -0
- data/.claude/todo/phase-2-runtime/01-STORAGE.md +641 -0
- data/.claude/todo/phase-2-runtime/02-RUNNERS.md +511 -0
- data/.claude/todo/phase-3-extensions/01-EXTENSION-SYSTEM.md +298 -0
- data/.claude/todo/phase-3-extensions/02-AI-PLUGIN.md +936 -0
- data/.claude/todo/phase-3-extensions/todo.md +262 -0
- data/.claude/todo/phase-4-ai-rework/01-DEPENDENCIES.md +107 -0
- data/.claude/todo/phase-4-ai-rework/02-CONFIGURATION.md +123 -0
- data/.claude/todo/phase-4-ai-rework/03-TOOL-REGISTRY.md +237 -0
- data/.claude/todo/phase-4-ai-rework/04-MCP-SERVER.md +432 -0
- data/.claude/todo/phase-4-ai-rework/05-MCP-CLIENT.md +333 -0
- data/.claude/todo/phase-4-ai-rework/06-EXECUTORS.md +397 -0
- data/.claude/todo/phase-4-ai-rework/todo.md +265 -0
- data/.claude/todo/phase-5-validation/.DS_Store +0 -0
- data/.claude/todo/phase-5-validation/01-TEST-GAPS.md +615 -0
- data/.claude/todo/phase-5-validation/01-TESTS.md +2378 -0
- data/.claude/todo/phase-5-validation/02-EXAMPLES-SIMPLE.md +744 -0
- data/.claude/todo/phase-5-validation/02-EXAMPLES.md +1857 -0
- data/.claude/todo/phase-5-validation/03-EXAMPLE-SUPPORT-AGENT.md +95 -0
- data/.claude/todo/phase-5-validation/04-EXAMPLE-ORDER-FULFILLMENT.md +94 -0
- data/.claude/todo/phase-5-validation/05-EXAMPLE-DATA-PIPELINE.md +145 -0
- data/.env.example +3 -0
- data/.rubocop.yml +64 -0
- data/0.3.amend.md +89 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +22 -0
- data/Gemfile.lock +192 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +16 -0
- data/durable_workflow.gemspec +43 -0
- data/examples/approval_request.rb +106 -0
- data/examples/calculator.rb +154 -0
- data/examples/file_search_demo.rb +77 -0
- data/examples/hello_workflow.rb +57 -0
- data/examples/item_processor.rb +96 -0
- data/examples/order_fulfillment/Gemfile +6 -0
- data/examples/order_fulfillment/README.md +84 -0
- data/examples/order_fulfillment/run.rb +85 -0
- data/examples/order_fulfillment/services.rb +146 -0
- data/examples/order_fulfillment/workflow.yml +188 -0
- data/examples/parallel_fetch.rb +102 -0
- data/examples/service_integration.rb +137 -0
- data/examples/support_agent/Gemfile +6 -0
- data/examples/support_agent/README.md +91 -0
- data/examples/support_agent/config/claude_desktop.json +12 -0
- data/examples/support_agent/mcp_server.rb +49 -0
- data/examples/support_agent/run.rb +67 -0
- data/examples/support_agent/services.rb +113 -0
- data/examples/support_agent/workflow.yml +286 -0
- data/lib/durable_workflow/core/condition.rb +45 -0
- data/lib/durable_workflow/core/engine.rb +145 -0
- data/lib/durable_workflow/core/executors/approval.rb +51 -0
- data/lib/durable_workflow/core/executors/assign.rb +18 -0
- data/lib/durable_workflow/core/executors/base.rb +90 -0
- data/lib/durable_workflow/core/executors/call.rb +76 -0
- data/lib/durable_workflow/core/executors/end.rb +19 -0
- data/lib/durable_workflow/core/executors/halt.rb +24 -0
- data/lib/durable_workflow/core/executors/loop.rb +118 -0
- data/lib/durable_workflow/core/executors/parallel.rb +77 -0
- data/lib/durable_workflow/core/executors/registry.rb +34 -0
- data/lib/durable_workflow/core/executors/router.rb +26 -0
- data/lib/durable_workflow/core/executors/start.rb +61 -0
- data/lib/durable_workflow/core/executors/transform.rb +71 -0
- data/lib/durable_workflow/core/executors/workflow.rb +32 -0
- data/lib/durable_workflow/core/parser.rb +189 -0
- data/lib/durable_workflow/core/resolver.rb +61 -0
- data/lib/durable_workflow/core/schema_validator.rb +47 -0
- data/lib/durable_workflow/core/types/base.rb +41 -0
- data/lib/durable_workflow/core/types/condition.rb +25 -0
- data/lib/durable_workflow/core/types/configs.rb +103 -0
- data/lib/durable_workflow/core/types/entry.rb +26 -0
- data/lib/durable_workflow/core/types/results.rb +41 -0
- data/lib/durable_workflow/core/types/state.rb +95 -0
- data/lib/durable_workflow/core/types/step_def.rb +15 -0
- data/lib/durable_workflow/core/types/workflow_def.rb +43 -0
- data/lib/durable_workflow/core/types.rb +29 -0
- data/lib/durable_workflow/core/validator.rb +318 -0
- data/lib/durable_workflow/extensions/ai/ai.rb +149 -0
- data/lib/durable_workflow/extensions/ai/configuration.rb +41 -0
- data/lib/durable_workflow/extensions/ai/executors/agent.rb +150 -0
- data/lib/durable_workflow/extensions/ai/executors/file_search.rb +52 -0
- data/lib/durable_workflow/extensions/ai/executors/guardrail.rb +152 -0
- data/lib/durable_workflow/extensions/ai/executors/handoff.rb +33 -0
- data/lib/durable_workflow/extensions/ai/executors/mcp.rb +47 -0
- data/lib/durable_workflow/extensions/ai/mcp/adapter.rb +73 -0
- data/lib/durable_workflow/extensions/ai/mcp/client.rb +77 -0
- data/lib/durable_workflow/extensions/ai/mcp/rack_app.rb +66 -0
- data/lib/durable_workflow/extensions/ai/mcp/server.rb +122 -0
- data/lib/durable_workflow/extensions/ai/tool_registry.rb +63 -0
- data/lib/durable_workflow/extensions/ai/types.rb +213 -0
- data/lib/durable_workflow/extensions/ai.rb +6 -0
- data/lib/durable_workflow/extensions/base.rb +77 -0
- data/lib/durable_workflow/runners/adapters/inline.rb +42 -0
- data/lib/durable_workflow/runners/adapters/sidekiq.rb +69 -0
- data/lib/durable_workflow/runners/async.rb +100 -0
- data/lib/durable_workflow/runners/stream.rb +126 -0
- data/lib/durable_workflow/runners/sync.rb +40 -0
- data/lib/durable_workflow/storage/active_record.rb +148 -0
- data/lib/durable_workflow/storage/redis.rb +133 -0
- data/lib/durable_workflow/storage/sequel.rb +144 -0
- data/lib/durable_workflow/storage/store.rb +43 -0
- data/lib/durable_workflow/utils.rb +25 -0
- data/lib/durable_workflow/version.rb +5 -0
- data/lib/durable_workflow.rb +70 -0
- data/sig/durable_workflow.rbs +4 -0
- metadata +275 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Core
|
|
5
|
+
# Stateless resolver - all methods take state explicitly
|
|
6
|
+
class Resolver
|
|
7
|
+
PATTERN = /\$([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)/
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def resolve(state, value)
|
|
11
|
+
case value
|
|
12
|
+
when String then resolve_string(state, value)
|
|
13
|
+
when Hash then value.transform_values { resolve(state, _1) }
|
|
14
|
+
when Array then value.map { resolve(state, _1) }
|
|
15
|
+
when nil then nil
|
|
16
|
+
else value
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def resolve_ref(state, ref)
|
|
21
|
+
parts = ref.split('.')
|
|
22
|
+
root = parts.shift.to_sym
|
|
23
|
+
|
|
24
|
+
base = case root
|
|
25
|
+
when :input then state.input
|
|
26
|
+
when :now then return Time.now
|
|
27
|
+
when :history then return state.history
|
|
28
|
+
else state.ctx[root]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
dig(base, parts)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def resolve_string(state, str)
|
|
37
|
+
# Whole string is single reference -> return actual value (not stringified)
|
|
38
|
+
return resolve_ref(state, str[1..]) if str.match?(/\A\$[a-zA-Z_][a-zA-Z0-9_.]*\z/)
|
|
39
|
+
|
|
40
|
+
# Embedded references -> interpolate as strings
|
|
41
|
+
str.gsub(PATTERN) { resolve_ref(state, _1[1..]).to_s }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def dig(value, keys)
|
|
45
|
+
return value if keys.empty?
|
|
46
|
+
|
|
47
|
+
key = keys.shift
|
|
48
|
+
|
|
49
|
+
next_val = case value
|
|
50
|
+
when Hash then Utils.fetch(value, key)
|
|
51
|
+
when Array then key.match?(/\A\d+\z/) ? value[key.to_i] : nil
|
|
52
|
+
when Struct then value.respond_to?(key) ? value.send(key) : nil
|
|
53
|
+
else value.respond_to?(key) ? value.send(key) : nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
dig(next_val, keys)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Core
|
|
5
|
+
# Runtime JSON Schema validation (optional - requires json_schemer gem)
|
|
6
|
+
class SchemaValidator
|
|
7
|
+
def self.validate!(value, schema, context:)
|
|
8
|
+
return true if schema.nil?
|
|
9
|
+
|
|
10
|
+
begin
|
|
11
|
+
require 'json_schemer'
|
|
12
|
+
rescue LoadError
|
|
13
|
+
# If json_schemer not available, skip runtime validation
|
|
14
|
+
DurableWorkflow.log(:debug, 'json_schemer not available, skipping runtime schema validation')
|
|
15
|
+
return true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
schemer = JSONSchemer.schema(normalize(schema))
|
|
19
|
+
errors = schemer.validate(jsonify(value)).to_a
|
|
20
|
+
|
|
21
|
+
return true if errors.empty?
|
|
22
|
+
|
|
23
|
+
messages = errors.map { _1['error'] }.join('; ')
|
|
24
|
+
raise ValidationError, "#{context}: #{messages}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.normalize(schema)
|
|
28
|
+
deep_stringify(schema)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.jsonify(value)
|
|
32
|
+
JSON.parse(value.to_json)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.deep_stringify(obj)
|
|
36
|
+
case obj
|
|
37
|
+
when Hash
|
|
38
|
+
obj.transform_keys(&:to_s).transform_values { deep_stringify(_1) }
|
|
39
|
+
when Array
|
|
40
|
+
obj.map { deep_stringify(_1) }
|
|
41
|
+
else
|
|
42
|
+
obj
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'dry-types'
|
|
4
|
+
require 'dry-struct'
|
|
5
|
+
|
|
6
|
+
module DurableWorkflow
|
|
7
|
+
module Types
|
|
8
|
+
include Dry.Types()
|
|
9
|
+
|
|
10
|
+
# StepType is just a string - validated at parse time against executor registry
|
|
11
|
+
# This decouples core from extensions (no hardcoded AI types)
|
|
12
|
+
StepType = Types::Strict::String
|
|
13
|
+
|
|
14
|
+
# Condition operator enum
|
|
15
|
+
Operator = Types::Strict::String.enum(
|
|
16
|
+
'eq', 'neq', 'gt', 'gte', 'lt', 'lte',
|
|
17
|
+
'contains', 'starts_with', 'ends_with', 'matches',
|
|
18
|
+
'in', 'not_in', 'exists', 'empty', 'truthy', 'falsy'
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Entry action enum
|
|
22
|
+
EntryAction = Types::Strict::Symbol.enum(:completed, :halted, :failed)
|
|
23
|
+
|
|
24
|
+
# Wait mode for parallel - default "all"
|
|
25
|
+
WaitMode = Types::Strict::String.default('all').enum('all', 'any') | Types::Strict::Integer
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class BaseStruct < Dry::Struct
|
|
29
|
+
transform_keys(&:to_sym)
|
|
30
|
+
|
|
31
|
+
def to_h
|
|
32
|
+
super.transform_values do |v|
|
|
33
|
+
case v
|
|
34
|
+
when BaseStruct then v.to_h
|
|
35
|
+
when Array then v.map { |e| e.is_a?(BaseStruct) ? e.to_h : e }
|
|
36
|
+
else v
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Core
|
|
5
|
+
# Operator type with default
|
|
6
|
+
OperatorType = Types::Strict::String.default('eq').enum(
|
|
7
|
+
'eq', 'neq', 'gt', 'gte', 'lt', 'lte',
|
|
8
|
+
'contains', 'starts_with', 'ends_with', 'matches',
|
|
9
|
+
'in', 'not_in', 'exists', 'empty', 'truthy', 'falsy'
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
class Condition < BaseStruct
|
|
13
|
+
attribute :field, Types::Strict::String
|
|
14
|
+
attribute :op, OperatorType
|
|
15
|
+
attribute :value, Types::Any
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class Route < BaseStruct
|
|
19
|
+
attribute :field, Types::Strict::String
|
|
20
|
+
attribute :op, OperatorType
|
|
21
|
+
attribute :value, Types::Any
|
|
22
|
+
attribute :target, Types::Strict::String
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Core
|
|
5
|
+
# Base for all step configs
|
|
6
|
+
class StepConfig < BaseStruct; end
|
|
7
|
+
|
|
8
|
+
class StartConfig < StepConfig
|
|
9
|
+
attribute? :validate_input, Types::Strict::Bool.default(false)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class EndConfig < StepConfig
|
|
13
|
+
attribute? :result, Types::Any
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Output with optional schema validation
|
|
17
|
+
class OutputConfig < BaseStruct
|
|
18
|
+
attribute :key, Types::Coercible::Symbol
|
|
19
|
+
attribute? :schema, Types::Hash.optional
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class CallConfig < StepConfig
|
|
23
|
+
attribute :service, Types::Strict::String
|
|
24
|
+
attribute :method_name, Types::Strict::String
|
|
25
|
+
attribute? :input, Types::Any
|
|
26
|
+
attribute? :output, Types::Coercible::Symbol.optional | OutputConfig
|
|
27
|
+
attribute? :timeout, Types::Strict::Integer.optional
|
|
28
|
+
attribute? :retries, Types::Strict::Integer.optional.default(0)
|
|
29
|
+
attribute? :retry_delay, Types::Strict::Float.optional.default(1.0)
|
|
30
|
+
attribute? :retry_backoff, Types::Strict::Float.optional.default(2.0)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class AssignConfig < StepConfig
|
|
34
|
+
attribute :set, Types::Hash.default({}.freeze)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class RouterConfig < StepConfig
|
|
38
|
+
attribute :routes, Types::Strict::Array.default([].freeze)
|
|
39
|
+
attribute? :default, Types::Strict::String.optional
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class LoopConfig < StepConfig
|
|
43
|
+
# foreach mode
|
|
44
|
+
attribute? :over, Types::Any
|
|
45
|
+
attribute? :as, Types::Coercible::Symbol.optional.default(:item)
|
|
46
|
+
attribute? :index_as, Types::Coercible::Symbol.optional.default(:index)
|
|
47
|
+
# while mode
|
|
48
|
+
attribute? :while, Types::Any
|
|
49
|
+
# shared
|
|
50
|
+
attribute? :do, Types::Strict::Array.default([].freeze)
|
|
51
|
+
attribute? :output, Types::Coercible::Symbol.optional
|
|
52
|
+
attribute? :max, Types::Strict::Integer.optional.default(100)
|
|
53
|
+
attribute? :on_exhausted, Types::Strict::String.optional
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class HaltConfig < StepConfig
|
|
57
|
+
attribute? :reason, Types::Strict::String.optional
|
|
58
|
+
attribute? :data, Types::Hash.default({}.freeze)
|
|
59
|
+
attribute? :resume_step, Types::Strict::String.optional
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class ApprovalConfig < StepConfig
|
|
63
|
+
attribute :prompt, Types::Strict::String
|
|
64
|
+
attribute? :context, Types::Any
|
|
65
|
+
attribute? :approvers, Types::Strict::Array.of(Types::Strict::String).optional
|
|
66
|
+
attribute? :on_reject, Types::Strict::String.optional
|
|
67
|
+
attribute? :timeout, Types::Strict::Integer.optional
|
|
68
|
+
attribute? :on_timeout, Types::Strict::String.optional
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class TransformConfig < StepConfig
|
|
72
|
+
attribute? :input, Types::Strict::String.optional
|
|
73
|
+
attribute :expression, Types::Hash
|
|
74
|
+
attribute :output, Types::Coercible::Symbol
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class ParallelConfig < StepConfig
|
|
78
|
+
attribute :branches, Types::Strict::Array.default([].freeze)
|
|
79
|
+
attribute? :wait, Types::WaitMode
|
|
80
|
+
attribute? :output, Types::Coercible::Symbol.optional
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class WorkflowConfig < StepConfig
|
|
84
|
+
attribute :workflow_id, Types::Strict::String
|
|
85
|
+
attribute? :input, Types::Any
|
|
86
|
+
attribute? :output, Types::Coercible::Symbol.optional
|
|
87
|
+
attribute? :timeout, Types::Strict::Integer.optional
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Register core config types
|
|
91
|
+
Core.register_config('start', StartConfig)
|
|
92
|
+
Core.register_config('end', EndConfig)
|
|
93
|
+
Core.register_config('call', CallConfig)
|
|
94
|
+
Core.register_config('assign', AssignConfig)
|
|
95
|
+
Core.register_config('router', RouterConfig)
|
|
96
|
+
Core.register_config('loop', LoopConfig)
|
|
97
|
+
Core.register_config('halt', HaltConfig)
|
|
98
|
+
Core.register_config('approval', ApprovalConfig)
|
|
99
|
+
Core.register_config('transform', TransformConfig)
|
|
100
|
+
Core.register_config('parallel', ParallelConfig)
|
|
101
|
+
Core.register_config('workflow', WorkflowConfig)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Core
|
|
5
|
+
class Entry < BaseStruct
|
|
6
|
+
attribute :id, Types::Strict::String
|
|
7
|
+
attribute :execution_id, Types::Strict::String
|
|
8
|
+
attribute :step_id, Types::Strict::String
|
|
9
|
+
attribute :step_type, Types::StepType # Just a string
|
|
10
|
+
attribute :action, Types::EntryAction
|
|
11
|
+
attribute? :duration_ms, Types::Strict::Integer.optional
|
|
12
|
+
attribute? :input, Types::Any
|
|
13
|
+
attribute? :output, Types::Any
|
|
14
|
+
attribute? :error, Types::Strict::String.optional
|
|
15
|
+
attribute :timestamp, Types::Any
|
|
16
|
+
|
|
17
|
+
def self.from_h(h)
|
|
18
|
+
new(
|
|
19
|
+
**h,
|
|
20
|
+
action: h[:action]&.to_sym,
|
|
21
|
+
timestamp: h[:timestamp].is_a?(String) ? Time.parse(h[:timestamp]) : h[:timestamp]
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Core
|
|
5
|
+
class ContinueResult < BaseStruct
|
|
6
|
+
attribute? :next_step, Types::Strict::String.optional
|
|
7
|
+
attribute? :output, Types::Any
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class HaltResult < BaseStruct
|
|
11
|
+
# data is required (no default) - distinguishes from ContinueResult in union
|
|
12
|
+
attribute :data, Types::Hash
|
|
13
|
+
attribute? :resume_step, Types::Strict::String.optional
|
|
14
|
+
attribute? :prompt, Types::Strict::String.optional
|
|
15
|
+
|
|
16
|
+
# Common interface with ContinueResult
|
|
17
|
+
def output = data
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# ExecutionResult - returned by Engine.run/resume
|
|
21
|
+
# Note: ErrorResult removed - errors are captured in Execution.error field
|
|
22
|
+
class ExecutionResult < BaseStruct
|
|
23
|
+
attribute :status, ExecutionStatus
|
|
24
|
+
attribute :execution_id, Types::Strict::String
|
|
25
|
+
attribute? :output, Types::Any
|
|
26
|
+
attribute? :halt, HaltResult.optional
|
|
27
|
+
attribute? :error, Types::Strict::String.optional
|
|
28
|
+
|
|
29
|
+
def completed? = status == :completed
|
|
30
|
+
def halted? = status == :halted
|
|
31
|
+
def failed? = status == :failed
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Outcome of executing a step: new state + result
|
|
35
|
+
# HaltResult first - has required `data` field, so union can distinguish
|
|
36
|
+
class StepOutcome < BaseStruct
|
|
37
|
+
attribute :state, State
|
|
38
|
+
attribute :result, HaltResult | ContinueResult
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Core
|
|
5
|
+
# Runtime state - immutable during execution
|
|
6
|
+
# Used by executors. Contains only workflow variables in ctx (no internal _prefixed fields).
|
|
7
|
+
class State < BaseStruct
|
|
8
|
+
attribute :execution_id, Types::Strict::String
|
|
9
|
+
attribute :workflow_id, Types::Strict::String
|
|
10
|
+
attribute :input, Types::Hash.default({}.freeze)
|
|
11
|
+
attribute :ctx, Types::Hash.default({}.freeze) # User workflow variables only
|
|
12
|
+
attribute? :current_step, Types::Strict::String.optional
|
|
13
|
+
attribute :history, Types::Strict::Array.default([].freeze)
|
|
14
|
+
|
|
15
|
+
# Immutable update helpers
|
|
16
|
+
def with(**updates)
|
|
17
|
+
self.class.new(to_h.merge(updates))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def with_ctx(**updates)
|
|
21
|
+
with(ctx: ctx.merge(DurableWorkflow::Utils.deep_symbolize(updates)))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def with_current_step(step_id)
|
|
25
|
+
with(current_step: step_id)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Execution status enum
|
|
30
|
+
ExecutionStatus = Types::Strict::Symbol.enum(:pending, :running, :completed, :halted, :failed)
|
|
31
|
+
|
|
32
|
+
# Typed execution record for storage
|
|
33
|
+
# Storage layer saves/loads Execution, Engine works with State internally.
|
|
34
|
+
# This separation keeps ctx clean and provides typed status/halt_data/error fields.
|
|
35
|
+
class Execution < BaseStruct
|
|
36
|
+
attribute :id, Types::Strict::String
|
|
37
|
+
attribute :workflow_id, Types::Strict::String
|
|
38
|
+
attribute :status, ExecutionStatus
|
|
39
|
+
attribute :input, Types::Hash.default({}.freeze)
|
|
40
|
+
attribute :ctx, Types::Hash.default({}.freeze) # User workflow variables only
|
|
41
|
+
attribute? :current_step, Types::Strict::String.optional
|
|
42
|
+
attribute? :result, Types::Any # Final output when completed
|
|
43
|
+
attribute? :recover_to, Types::Strict::String.optional # Step to resume from
|
|
44
|
+
attribute? :halt_data, Types::Hash.optional # Data from HaltResult
|
|
45
|
+
attribute? :error, Types::Strict::String.optional # Error message when failed
|
|
46
|
+
attribute? :created_at, Types::Any
|
|
47
|
+
attribute? :updated_at, Types::Any
|
|
48
|
+
|
|
49
|
+
# Convert to State for executor use
|
|
50
|
+
def to_state
|
|
51
|
+
State.new(
|
|
52
|
+
execution_id: id,
|
|
53
|
+
workflow_id: workflow_id,
|
|
54
|
+
input: input,
|
|
55
|
+
ctx: ctx,
|
|
56
|
+
current_step: current_step
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Build from State + ExecutionResult
|
|
61
|
+
def self.from_state(state, result)
|
|
62
|
+
new(
|
|
63
|
+
id: state.execution_id,
|
|
64
|
+
workflow_id: state.workflow_id,
|
|
65
|
+
status: result.status,
|
|
66
|
+
input: state.input,
|
|
67
|
+
ctx: state.ctx,
|
|
68
|
+
current_step: state.current_step,
|
|
69
|
+
result: result.output,
|
|
70
|
+
recover_to: result.halt&.resume_step,
|
|
71
|
+
halt_data: result.halt&.data,
|
|
72
|
+
error: result.error,
|
|
73
|
+
updated_at: Time.now
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.from_h(hash)
|
|
78
|
+
new(
|
|
79
|
+
id: hash[:id],
|
|
80
|
+
workflow_id: hash[:workflow_id],
|
|
81
|
+
status: hash[:status]&.to_sym || :pending,
|
|
82
|
+
input: DurableWorkflow::Utils.deep_symbolize(hash[:input] || {}),
|
|
83
|
+
ctx: DurableWorkflow::Utils.deep_symbolize(hash[:ctx] || {}),
|
|
84
|
+
current_step: hash[:current_step],
|
|
85
|
+
result: hash[:result],
|
|
86
|
+
recover_to: hash[:recover_to],
|
|
87
|
+
halt_data: hash[:halt_data],
|
|
88
|
+
error: hash[:error],
|
|
89
|
+
created_at: hash[:created_at],
|
|
90
|
+
updated_at: hash[:updated_at]
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Core
|
|
5
|
+
class StepDef < BaseStruct
|
|
6
|
+
attribute :id, Types::Strict::String
|
|
7
|
+
attribute :type, Types::StepType # Just a string, not enum
|
|
8
|
+
attribute :config, Types::Any
|
|
9
|
+
attribute? :next_step, Types::Strict::String.optional
|
|
10
|
+
attribute? :on_error, Types::Strict::String.optional
|
|
11
|
+
|
|
12
|
+
def terminal? = type == 'end'
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Core
|
|
5
|
+
class InputDef < BaseStruct
|
|
6
|
+
attribute :name, Types::Strict::String
|
|
7
|
+
attribute? :type, Types::Strict::String.optional.default('string')
|
|
8
|
+
attribute? :required, Types::Strict::Bool.default(true)
|
|
9
|
+
attribute? :default, Types::Any
|
|
10
|
+
attribute? :description, Types::Strict::String.optional
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class WorkflowDef < BaseStruct
|
|
14
|
+
attribute :id, Types::Strict::String
|
|
15
|
+
attribute :name, Types::Strict::String
|
|
16
|
+
attribute? :version, Types::Strict::String.optional.default('1.0')
|
|
17
|
+
attribute? :description, Types::Strict::String.optional
|
|
18
|
+
attribute? :timeout, Types::Strict::Integer.optional
|
|
19
|
+
attribute :inputs, Types::Strict::Array.of(InputDef).default([].freeze)
|
|
20
|
+
attribute :steps, Types::Strict::Array.of(StepDef).default([].freeze)
|
|
21
|
+
# Generic extension data - AI extension stores agents/tools here
|
|
22
|
+
attribute? :extensions, Types::Hash.default({}.freeze)
|
|
23
|
+
|
|
24
|
+
def find_step(id) = steps.find { _1.id == id }
|
|
25
|
+
def first_step = steps.first
|
|
26
|
+
def step_ids = steps.map(&:id)
|
|
27
|
+
|
|
28
|
+
# Immutable update - preserve struct instances
|
|
29
|
+
def with(**updates)
|
|
30
|
+
self.class.new(
|
|
31
|
+
id: updates.fetch(:id, id),
|
|
32
|
+
name: updates.fetch(:name, name),
|
|
33
|
+
version: updates.fetch(:version, version),
|
|
34
|
+
description: updates.fetch(:description, description),
|
|
35
|
+
timeout: updates.fetch(:timeout, timeout),
|
|
36
|
+
inputs: updates.fetch(:inputs, inputs),
|
|
37
|
+
steps: updates.fetch(:steps, steps),
|
|
38
|
+
extensions: updates.fetch(:extensions, extensions)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Core
|
|
5
|
+
class << self
|
|
6
|
+
def config_registry
|
|
7
|
+
@config_registry ||= {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def register_config(type, klass)
|
|
11
|
+
config_registry[type.to_s] = klass
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def config_registered?(type)
|
|
15
|
+
config_registry.key?(type.to_s)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Load base types first
|
|
22
|
+
require_relative 'types/base'
|
|
23
|
+
require_relative 'types/condition'
|
|
24
|
+
require_relative 'types/configs'
|
|
25
|
+
require_relative 'types/step_def'
|
|
26
|
+
require_relative 'types/workflow_def'
|
|
27
|
+
require_relative 'types/state'
|
|
28
|
+
require_relative 'types/entry'
|
|
29
|
+
require_relative 'types/results'
|