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,193 @@
|
|
|
1
|
+
# 01-GEMSPEC: Gem Setup and Dependencies
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Complete the gemspec with proper metadata and dependencies. Set up the base module structure.
|
|
6
|
+
|
|
7
|
+
## Dependencies
|
|
8
|
+
|
|
9
|
+
None - this is the first step.
|
|
10
|
+
|
|
11
|
+
## Files to Create/Modify
|
|
12
|
+
|
|
13
|
+
### 1. `durable_workflow.gemspec`
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# frozen_string_literal: true
|
|
17
|
+
|
|
18
|
+
require_relative "lib/durable_workflow/version"
|
|
19
|
+
|
|
20
|
+
Gem::Specification.new do |spec|
|
|
21
|
+
spec.name = "durable_workflow"
|
|
22
|
+
spec.version = DurableWorkflow::VERSION
|
|
23
|
+
spec.authors = ["Ben"]
|
|
24
|
+
spec.email = ["ben@dee.mx"]
|
|
25
|
+
|
|
26
|
+
spec.summary = "Durable workflow engine with YAML-defined steps and pluggable executors"
|
|
27
|
+
spec.description = "A workflow engine supporting loops, parallel execution, approvals, halts, and extensible step types. Designed for durable, resumable execution with optional AI capabilities."
|
|
28
|
+
spec.homepage = "https://github.com/your-org/durable_workflow"
|
|
29
|
+
spec.license = "MIT"
|
|
30
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
31
|
+
|
|
32
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
33
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
34
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
35
|
+
|
|
36
|
+
spec.files = Dir.chdir(__dir__) do
|
|
37
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
38
|
+
(File.expand_path(f) == __FILE__) ||
|
|
39
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .github .circleci appveyor])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
spec.bindir = "exe"
|
|
43
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
44
|
+
spec.require_paths = ["lib"]
|
|
45
|
+
|
|
46
|
+
# Core dependencies
|
|
47
|
+
spec.add_dependency "dry-types", "~> 1.7"
|
|
48
|
+
spec.add_dependency "dry-struct", "~> 1.6"
|
|
49
|
+
|
|
50
|
+
# Optional runtime dependencies (for specific features)
|
|
51
|
+
# async - for parallel executor
|
|
52
|
+
# redis - for Redis storage
|
|
53
|
+
# ruby_llm - for AI extension
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 2. `Gemfile`
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# frozen_string_literal: true
|
|
61
|
+
|
|
62
|
+
source "https://rubygems.org"
|
|
63
|
+
|
|
64
|
+
gemspec
|
|
65
|
+
|
|
66
|
+
# Development
|
|
67
|
+
gem "rake", "~> 13.0"
|
|
68
|
+
gem "minitest", "~> 5.0"
|
|
69
|
+
gem "rubocop", "~> 1.21"
|
|
70
|
+
|
|
71
|
+
# Optional runtime (for testing all features)
|
|
72
|
+
gem "async", "~> 2.21"
|
|
73
|
+
gem "redis", "~> 5.0"
|
|
74
|
+
gem "ruby_llm", "~> 1.0"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### 3. `lib/durable_workflow/version.rb`
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
# frozen_string_literal: true
|
|
81
|
+
|
|
82
|
+
module DurableWorkflow
|
|
83
|
+
VERSION = "0.1.0"
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 4. `lib/durable_workflow.rb`
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
# frozen_string_literal: true
|
|
91
|
+
|
|
92
|
+
require "securerandom"
|
|
93
|
+
require "time"
|
|
94
|
+
require_relative "durable_workflow/version"
|
|
95
|
+
|
|
96
|
+
module DurableWorkflow
|
|
97
|
+
class Error < StandardError; end
|
|
98
|
+
class ConfigError < Error; end
|
|
99
|
+
class ValidationError < Error; end
|
|
100
|
+
class ExecutionError < Error; end
|
|
101
|
+
|
|
102
|
+
class << self
|
|
103
|
+
attr_accessor :config
|
|
104
|
+
|
|
105
|
+
def configure
|
|
106
|
+
self.config ||= Config.new
|
|
107
|
+
yield config if block_given?
|
|
108
|
+
config
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def load(source)
|
|
112
|
+
wf = Core::Parser.parse(source)
|
|
113
|
+
Core::Validator.validate!(wf)
|
|
114
|
+
wf
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def registry
|
|
118
|
+
@registry ||= {}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def register(workflow)
|
|
122
|
+
registry[workflow.id] = workflow
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def log(level, msg, **data)
|
|
126
|
+
config&.logger&.send(level, "[DurableWorkflow] #{msg} #{data}")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
Config = Struct.new(:store, :service_resolver, :logger, keyword_init: true)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Core (always loaded)
|
|
134
|
+
require_relative "durable_workflow/utils"
|
|
135
|
+
require_relative "durable_workflow/core/types"
|
|
136
|
+
require_relative "durable_workflow/core/parser"
|
|
137
|
+
require_relative "durable_workflow/core/validator"
|
|
138
|
+
require_relative "durable_workflow/core/resolver"
|
|
139
|
+
require_relative "durable_workflow/core/condition"
|
|
140
|
+
require_relative "durable_workflow/core/executors/registry"
|
|
141
|
+
require_relative "durable_workflow/core/executors/base"
|
|
142
|
+
|
|
143
|
+
# Load all core executors
|
|
144
|
+
Dir[File.join(__dir__, "durable_workflow/core/executors/*.rb")].each { |f| require f }
|
|
145
|
+
|
|
146
|
+
require_relative "durable_workflow/core/engine"
|
|
147
|
+
|
|
148
|
+
# Storage (no default - must be configured)
|
|
149
|
+
require_relative "durable_workflow/storage/store"
|
|
150
|
+
|
|
151
|
+
# Runners
|
|
152
|
+
require_relative "durable_workflow/runners/sync"
|
|
153
|
+
require_relative "durable_workflow/runners/async"
|
|
154
|
+
require_relative "durable_workflow/runners/stream"
|
|
155
|
+
require_relative "durable_workflow/runners/adapters/inline"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### 5. `lib/durable_workflow/utils.rb`
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
# frozen_string_literal: true
|
|
162
|
+
|
|
163
|
+
module DurableWorkflow
|
|
164
|
+
module Utils
|
|
165
|
+
module_function
|
|
166
|
+
|
|
167
|
+
def deep_symbolize(obj)
|
|
168
|
+
case obj
|
|
169
|
+
when Hash
|
|
170
|
+
obj.transform_keys(&:to_sym).transform_values { deep_symbolize(_1) }
|
|
171
|
+
when Array
|
|
172
|
+
obj.map { deep_symbolize(_1) }
|
|
173
|
+
else
|
|
174
|
+
obj
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Acceptance Criteria
|
|
182
|
+
|
|
183
|
+
1. `bundle install` succeeds
|
|
184
|
+
2. `bundle exec ruby -e "require 'durable_workflow'; puts DurableWorkflow::VERSION"` outputs `0.1.0`
|
|
185
|
+
3. No reference to AI types in base module
|
|
186
|
+
4. Module namespace is `DurableWorkflow` (not `Workflow`)
|
|
187
|
+
|
|
188
|
+
## Notes
|
|
189
|
+
|
|
190
|
+
- The entry point is `lib/durable_workflow.rb` (standard gem layout)
|
|
191
|
+
- Core dependencies are dry-types and dry-struct only
|
|
192
|
+
- Optional deps (async, redis, ruby_llm) are development dependencies for testing
|
|
193
|
+
- Config struct removed `extensions` field - extensions register themselves
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
# 02-TYPES: Core Type Definitions
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Define all core types using dry-struct. **Critical change**: `StepType` is no longer an enum - it's a simple string validated against the executor registry at parse time. This decouples core from AI types.
|
|
6
|
+
|
|
7
|
+
## Dependencies
|
|
8
|
+
|
|
9
|
+
- 01-GEMSPEC completed
|
|
10
|
+
|
|
11
|
+
## Files to Create
|
|
12
|
+
|
|
13
|
+
### 1. `lib/durable_workflow/core/types.rb` (loader)
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# frozen_string_literal: true
|
|
17
|
+
|
|
18
|
+
# Load base types first
|
|
19
|
+
require_relative "types/base"
|
|
20
|
+
require_relative "types/condition"
|
|
21
|
+
require_relative "types/configs"
|
|
22
|
+
require_relative "types/step_def"
|
|
23
|
+
require_relative "types/workflow_def"
|
|
24
|
+
require_relative "types/state"
|
|
25
|
+
require_relative "types/entry"
|
|
26
|
+
require_relative "types/results"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### 2. `lib/durable_workflow/core/types/base.rb`
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# frozen_string_literal: true
|
|
33
|
+
|
|
34
|
+
require "dry-types"
|
|
35
|
+
require "dry-struct"
|
|
36
|
+
|
|
37
|
+
module DurableWorkflow
|
|
38
|
+
module Types
|
|
39
|
+
include Dry.Types()
|
|
40
|
+
|
|
41
|
+
# StepType is just a string - validated at parse time against executor registry
|
|
42
|
+
# This decouples core from extensions (no hardcoded AI types)
|
|
43
|
+
StepType = Types::Strict::String
|
|
44
|
+
|
|
45
|
+
# Condition operator enum
|
|
46
|
+
Operator = Types::Strict::String.enum(
|
|
47
|
+
"eq", "neq", "gt", "gte", "lt", "lte",
|
|
48
|
+
"contains", "starts_with", "ends_with", "in", "exists"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Entry action enum
|
|
52
|
+
EntryAction = Types::Strict::Symbol.enum(:completed, :halted, :failed)
|
|
53
|
+
|
|
54
|
+
# Wait mode for parallel - default "all"
|
|
55
|
+
WaitMode = Types::Strict::String.default("all").enum("all", "any") | Types::Strict::Integer
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class BaseStruct < Dry::Struct
|
|
59
|
+
transform_keys(&:to_sym)
|
|
60
|
+
|
|
61
|
+
def to_h
|
|
62
|
+
super.transform_values do |v|
|
|
63
|
+
case v
|
|
64
|
+
when BaseStruct then v.to_h
|
|
65
|
+
when Array then v.map { |e| e.is_a?(BaseStruct) ? e.to_h : e }
|
|
66
|
+
else v
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 3. `lib/durable_workflow/core/types/condition.rb`
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
# frozen_string_literal: true
|
|
78
|
+
|
|
79
|
+
module DurableWorkflow
|
|
80
|
+
module Core
|
|
81
|
+
# Operator type with default
|
|
82
|
+
OperatorType = Types::Strict::String.default("eq").enum(
|
|
83
|
+
"eq", "neq", "gt", "gte", "lt", "lte",
|
|
84
|
+
"contains", "starts_with", "ends_with", "in", "exists"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
class Condition < BaseStruct
|
|
88
|
+
attribute :field, Types::Strict::String
|
|
89
|
+
attribute :op, OperatorType
|
|
90
|
+
attribute :value, Types::Any
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
class Route < BaseStruct
|
|
94
|
+
attribute :field, Types::Strict::String
|
|
95
|
+
attribute :op, OperatorType
|
|
96
|
+
attribute :value, Types::Any
|
|
97
|
+
attribute :target, Types::Strict::String
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### 4. `lib/durable_workflow/core/types/configs.rb`
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# frozen_string_literal: true
|
|
107
|
+
|
|
108
|
+
module DurableWorkflow
|
|
109
|
+
module Core
|
|
110
|
+
# Base for all step configs
|
|
111
|
+
class StepConfig < BaseStruct; end
|
|
112
|
+
|
|
113
|
+
class StartConfig < StepConfig
|
|
114
|
+
attribute? :validate_input, Types::Strict::Bool.default(false)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
class EndConfig < StepConfig
|
|
118
|
+
attribute? :result, Types::Any
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
class CallConfig < StepConfig
|
|
122
|
+
attribute :service, Types::Strict::String
|
|
123
|
+
attribute :method_name, Types::Strict::String
|
|
124
|
+
attribute? :input, Types::Any
|
|
125
|
+
attribute? :output, Types::Coercible::Symbol.optional
|
|
126
|
+
attribute? :timeout, Types::Strict::Integer.optional
|
|
127
|
+
attribute? :retries, Types::Strict::Integer.optional.default(0)
|
|
128
|
+
attribute? :retry_delay, Types::Strict::Float.optional.default(1.0)
|
|
129
|
+
attribute? :retry_backoff, Types::Strict::Float.optional.default(2.0)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
class AssignConfig < StepConfig
|
|
133
|
+
attribute :set, Types::Hash.default({}.freeze)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
class RouterConfig < StepConfig
|
|
137
|
+
attribute :routes, Types::Strict::Array.default([].freeze)
|
|
138
|
+
attribute? :default, Types::Strict::String.optional
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
class LoopConfig < StepConfig
|
|
142
|
+
# foreach mode
|
|
143
|
+
attribute? :over, Types::Any
|
|
144
|
+
attribute? :as, Types::Coercible::Symbol.optional.default(:item)
|
|
145
|
+
attribute? :index_as, Types::Coercible::Symbol.optional.default(:index)
|
|
146
|
+
# while mode
|
|
147
|
+
attribute? :while, Types::Any
|
|
148
|
+
# shared
|
|
149
|
+
attribute? :do, Types::Strict::Array.default([].freeze)
|
|
150
|
+
attribute? :output, Types::Coercible::Symbol.optional
|
|
151
|
+
attribute? :max, Types::Strict::Integer.optional.default(100)
|
|
152
|
+
attribute? :on_exhausted, Types::Strict::String.optional
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
class HaltConfig < StepConfig
|
|
156
|
+
attribute? :reason, Types::Strict::String.optional
|
|
157
|
+
attribute? :data, Types::Hash.default({}.freeze)
|
|
158
|
+
attribute? :resume_step, Types::Strict::String.optional
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
class ApprovalConfig < StepConfig
|
|
162
|
+
attribute :prompt, Types::Strict::String
|
|
163
|
+
attribute? :context, Types::Any
|
|
164
|
+
attribute? :approvers, Types::Strict::Array.of(Types::Strict::String).optional
|
|
165
|
+
attribute? :on_reject, Types::Strict::String.optional
|
|
166
|
+
attribute? :timeout, Types::Strict::Integer.optional
|
|
167
|
+
attribute? :on_timeout, Types::Strict::String.optional
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
class TransformConfig < StepConfig
|
|
171
|
+
attribute? :input, Types::Strict::String.optional
|
|
172
|
+
attribute :expression, Types::Hash
|
|
173
|
+
attribute :output, Types::Coercible::Symbol
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
class ParallelConfig < StepConfig
|
|
177
|
+
attribute :branches, Types::Strict::Array.default([].freeze)
|
|
178
|
+
attribute? :wait, Types::WaitMode
|
|
179
|
+
attribute? :output, Types::Coercible::Symbol.optional
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
class WorkflowConfig < StepConfig
|
|
183
|
+
attribute :workflow_id, Types::Strict::String
|
|
184
|
+
attribute? :input, Types::Any
|
|
185
|
+
attribute? :output, Types::Coercible::Symbol.optional
|
|
186
|
+
attribute? :timeout, Types::Strict::Integer.optional
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Registry mapping type -> config class
|
|
190
|
+
# Extensions add to this registry
|
|
191
|
+
CONFIG_REGISTRY = {
|
|
192
|
+
"start" => StartConfig,
|
|
193
|
+
"end" => EndConfig,
|
|
194
|
+
"call" => CallConfig,
|
|
195
|
+
"assign" => AssignConfig,
|
|
196
|
+
"router" => RouterConfig,
|
|
197
|
+
"loop" => LoopConfig,
|
|
198
|
+
"halt" => HaltConfig,
|
|
199
|
+
"approval" => ApprovalConfig,
|
|
200
|
+
"transform" => TransformConfig,
|
|
201
|
+
"parallel" => ParallelConfig,
|
|
202
|
+
"workflow" => WorkflowConfig
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
# Allow extensions to register config classes
|
|
206
|
+
def self.register_config(type, klass)
|
|
207
|
+
CONFIG_REGISTRY[type.to_s] = klass
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### 5. `lib/durable_workflow/core/types/step_def.rb`
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
# frozen_string_literal: true
|
|
217
|
+
|
|
218
|
+
module DurableWorkflow
|
|
219
|
+
module Core
|
|
220
|
+
class StepDef < BaseStruct
|
|
221
|
+
attribute :id, Types::Strict::String
|
|
222
|
+
attribute :type, Types::StepType # Just a string, not enum
|
|
223
|
+
attribute :config, Types::Any
|
|
224
|
+
attribute? :next_step, Types::Strict::String.optional
|
|
225
|
+
attribute? :on_error, Types::Strict::String.optional
|
|
226
|
+
|
|
227
|
+
def terminal? = type == "end"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### 6. `lib/durable_workflow/core/types/workflow_def.rb`
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
# frozen_string_literal: true
|
|
237
|
+
|
|
238
|
+
module DurableWorkflow
|
|
239
|
+
module Core
|
|
240
|
+
class InputDef < BaseStruct
|
|
241
|
+
attribute :name, Types::Strict::String
|
|
242
|
+
attribute? :type, Types::Strict::String.optional.default("string")
|
|
243
|
+
attribute? :required, Types::Strict::Bool.default(true)
|
|
244
|
+
attribute? :default, Types::Any
|
|
245
|
+
attribute? :description, Types::Strict::String.optional
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
class WorkflowDef < BaseStruct
|
|
249
|
+
attribute :id, Types::Strict::String
|
|
250
|
+
attribute :name, Types::Strict::String
|
|
251
|
+
attribute? :version, Types::Strict::String.optional.default("1.0")
|
|
252
|
+
attribute? :description, Types::Strict::String.optional
|
|
253
|
+
attribute? :timeout, Types::Strict::Integer.optional
|
|
254
|
+
attribute :inputs, Types::Strict::Array.of(InputDef).default([].freeze)
|
|
255
|
+
attribute :steps, Types::Strict::Array.of(StepDef).default([].freeze)
|
|
256
|
+
# Generic extension data - AI extension stores agents/tools here
|
|
257
|
+
attribute? :extensions, Types::Hash.default({}.freeze)
|
|
258
|
+
|
|
259
|
+
def find_step(id) = steps.find { _1.id == id }
|
|
260
|
+
def first_step = steps.first
|
|
261
|
+
def step_ids = steps.map(&:id)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### 7. `lib/durable_workflow/core/types/state.rb`
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
# frozen_string_literal: true
|
|
271
|
+
|
|
272
|
+
module DurableWorkflow
|
|
273
|
+
module Core
|
|
274
|
+
# Runtime state - immutable during execution
|
|
275
|
+
# Used by executors. Contains only workflow variables in ctx (no internal _prefixed fields).
|
|
276
|
+
class State < BaseStruct
|
|
277
|
+
attribute :execution_id, Types::Strict::String
|
|
278
|
+
attribute :workflow_id, Types::Strict::String
|
|
279
|
+
attribute :input, Types::Hash.default({}.freeze)
|
|
280
|
+
attribute :ctx, Types::Hash.default({}.freeze) # User workflow variables only
|
|
281
|
+
attribute? :current_step, Types::Strict::String.optional
|
|
282
|
+
attribute :history, Types::Strict::Array.default([].freeze)
|
|
283
|
+
|
|
284
|
+
# Immutable update helpers
|
|
285
|
+
def with(**updates)
|
|
286
|
+
self.class.new(to_h.merge(updates))
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def with_ctx(**updates)
|
|
290
|
+
with(ctx: ctx.merge(DurableWorkflow::Utils.deep_symbolize(updates)))
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def with_current_step(step_id)
|
|
294
|
+
with(current_step: step_id)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Execution status enum
|
|
299
|
+
ExecutionStatus = Types::Strict::Symbol.enum(:pending, :running, :completed, :halted, :failed)
|
|
300
|
+
|
|
301
|
+
# Typed execution record for storage
|
|
302
|
+
# Storage layer saves/loads Execution, Engine works with State internally.
|
|
303
|
+
# This separation keeps ctx clean and provides typed status/halt_data/error fields.
|
|
304
|
+
class Execution < BaseStruct
|
|
305
|
+
attribute :id, Types::Strict::String
|
|
306
|
+
attribute :workflow_id, Types::Strict::String
|
|
307
|
+
attribute :status, ExecutionStatus
|
|
308
|
+
attribute :input, Types::Hash.default({}.freeze)
|
|
309
|
+
attribute :ctx, Types::Hash.default({}.freeze) # User workflow variables only
|
|
310
|
+
attribute? :current_step, Types::Strict::String.optional
|
|
311
|
+
attribute? :result, Types::Any # Final output when completed
|
|
312
|
+
attribute? :recover_to, Types::Strict::String.optional # Step to resume from
|
|
313
|
+
attribute? :halt_data, Types::Hash.optional # Data from HaltResult
|
|
314
|
+
attribute? :error, Types::Strict::String.optional # Error message when failed
|
|
315
|
+
attribute? :created_at, Types::Any
|
|
316
|
+
attribute? :updated_at, Types::Any
|
|
317
|
+
|
|
318
|
+
# Convert to State for executor use
|
|
319
|
+
def to_state
|
|
320
|
+
State.new(
|
|
321
|
+
execution_id: id,
|
|
322
|
+
workflow_id: workflow_id,
|
|
323
|
+
input: input,
|
|
324
|
+
ctx: ctx,
|
|
325
|
+
current_step: current_step
|
|
326
|
+
)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Build from State + ExecutionResult
|
|
330
|
+
def self.from_state(state, result)
|
|
331
|
+
new(
|
|
332
|
+
id: state.execution_id,
|
|
333
|
+
workflow_id: state.workflow_id,
|
|
334
|
+
status: result.status,
|
|
335
|
+
input: state.input,
|
|
336
|
+
ctx: state.ctx,
|
|
337
|
+
current_step: state.current_step,
|
|
338
|
+
result: result.output,
|
|
339
|
+
recover_to: result.halt&.resume_step,
|
|
340
|
+
halt_data: result.halt&.data,
|
|
341
|
+
error: result.error,
|
|
342
|
+
updated_at: Time.now
|
|
343
|
+
)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def self.from_h(hash)
|
|
347
|
+
new(
|
|
348
|
+
id: hash[:id],
|
|
349
|
+
workflow_id: hash[:workflow_id],
|
|
350
|
+
status: hash[:status]&.to_sym || :pending,
|
|
351
|
+
input: DurableWorkflow::Utils.deep_symbolize(hash[:input] || {}),
|
|
352
|
+
ctx: DurableWorkflow::Utils.deep_symbolize(hash[:ctx] || {}),
|
|
353
|
+
current_step: hash[:current_step],
|
|
354
|
+
result: hash[:result],
|
|
355
|
+
recover_to: hash[:recover_to],
|
|
356
|
+
halt_data: hash[:halt_data],
|
|
357
|
+
error: hash[:error],
|
|
358
|
+
created_at: hash[:created_at],
|
|
359
|
+
updated_at: hash[:updated_at]
|
|
360
|
+
)
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### 8. `lib/durable_workflow/core/types/entry.rb`
|
|
368
|
+
|
|
369
|
+
```ruby
|
|
370
|
+
# frozen_string_literal: true
|
|
371
|
+
|
|
372
|
+
module DurableWorkflow
|
|
373
|
+
module Core
|
|
374
|
+
class Entry < BaseStruct
|
|
375
|
+
attribute :id, Types::Strict::String
|
|
376
|
+
attribute :execution_id, Types::Strict::String
|
|
377
|
+
attribute :step_id, Types::Strict::String
|
|
378
|
+
attribute :step_type, Types::StepType # Just a string
|
|
379
|
+
attribute :action, Types::EntryAction
|
|
380
|
+
attribute? :duration_ms, Types::Strict::Integer.optional
|
|
381
|
+
attribute? :input, Types::Any
|
|
382
|
+
attribute? :output, Types::Any
|
|
383
|
+
attribute? :error, Types::Strict::String.optional
|
|
384
|
+
attribute :timestamp, Types::Any
|
|
385
|
+
|
|
386
|
+
def self.from_h(h)
|
|
387
|
+
new(
|
|
388
|
+
**h,
|
|
389
|
+
action: h[:action]&.to_sym,
|
|
390
|
+
timestamp: h[:timestamp].is_a?(String) ? Time.parse(h[:timestamp]) : h[:timestamp]
|
|
391
|
+
)
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### 9. `lib/durable_workflow/core/types/results.rb`
|
|
399
|
+
|
|
400
|
+
```ruby
|
|
401
|
+
# frozen_string_literal: true
|
|
402
|
+
|
|
403
|
+
module DurableWorkflow
|
|
404
|
+
module Core
|
|
405
|
+
class ContinueResult < BaseStruct
|
|
406
|
+
attribute? :next_step, Types::Strict::String.optional
|
|
407
|
+
attribute? :output, Types::Any
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
class HaltResult < BaseStruct
|
|
411
|
+
# data is required (no default) - distinguishes from ContinueResult in union
|
|
412
|
+
attribute :data, Types::Hash
|
|
413
|
+
attribute? :resume_step, Types::Strict::String.optional
|
|
414
|
+
attribute? :prompt, Types::Strict::String.optional
|
|
415
|
+
|
|
416
|
+
# Common interface with ContinueResult
|
|
417
|
+
def output = data
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# ExecutionResult - returned by Engine.run/resume
|
|
421
|
+
# Note: ErrorResult removed - errors are captured in Execution.error field
|
|
422
|
+
class ExecutionResult < BaseStruct
|
|
423
|
+
attribute :status, ExecutionStatus
|
|
424
|
+
attribute :execution_id, Types::Strict::String
|
|
425
|
+
attribute? :output, Types::Any
|
|
426
|
+
attribute? :halt, HaltResult.optional
|
|
427
|
+
attribute? :error, Types::Strict::String.optional
|
|
428
|
+
|
|
429
|
+
def completed? = status == :completed
|
|
430
|
+
def halted? = status == :halted
|
|
431
|
+
def failed? = status == :failed
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Outcome of executing a step: new state + result
|
|
435
|
+
# HaltResult first - has required `data` field, so union can distinguish
|
|
436
|
+
class StepOutcome < BaseStruct
|
|
437
|
+
attribute :state, State
|
|
438
|
+
attribute :result, HaltResult | ContinueResult
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
## Key Changes from Original
|
|
445
|
+
|
|
446
|
+
1. **`StepType` is now just `Types::Strict::String`** - no enum, no hardcoded types
|
|
447
|
+
2. **`WorkflowDef` has `extensions` instead of `agents`/`tools`** - generic hash for extension data
|
|
448
|
+
3. **`CONFIG_REGISTRY` is mutable** - extensions call `Core.register_config(type, klass)` to add their configs
|
|
449
|
+
4. **No `MessageRole` in core** - that's AI-specific, lives in extension
|
|
450
|
+
5. **`State` vs `Execution` separation** - State for runtime (clean ctx), Execution for storage (typed status/halt_data/error)
|
|
451
|
+
6. **`ErrorResult` removed** - errors captured in `Execution.error` field
|
|
452
|
+
7. **`ExecutionStatus` enum** - `:pending`, `:running`, `:completed`, `:halted`, `:failed`
|
|
453
|
+
|
|
454
|
+
## Acceptance Criteria
|
|
455
|
+
|
|
456
|
+
1. Can create State, StepDef, WorkflowDef without AI types
|
|
457
|
+
2. `StepDef.new(id: "x", type: "anything", config: {})` succeeds (type is just string)
|
|
458
|
+
3. `Core.register_config("custom", MyConfig)` adds to registry
|
|
459
|
+
4. `WorkflowDef` has no `agents` or `tools` attributes
|
|
460
|
+
5. `Execution.from_state(state, result)` creates typed Execution from State + ExecutionResult
|
|
461
|
+
6. `execution.to_state` converts back to State for executor use
|
|
462
|
+
7. `ctx` contains only user workflow variables - no `_status`, `_halt`, `_error`, `_resume_step`
|