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.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/todo/01.amend.md +133 -0
  3. data/.claude/todo/02.amend.md +444 -0
  4. data/.claude/todo/phase-1-core/01-GEMSPEC.md +193 -0
  5. data/.claude/todo/phase-1-core/02-TYPES.md +462 -0
  6. data/.claude/todo/phase-1-core/03-EXECUTION.md +551 -0
  7. data/.claude/todo/phase-1-core/04-STEPS.md +603 -0
  8. data/.claude/todo/phase-1-core/05-PARSER.md +719 -0
  9. data/.claude/todo/phase-1-core/todo.md +574 -0
  10. data/.claude/todo/phase-2-runtime/01-STORAGE.md +641 -0
  11. data/.claude/todo/phase-2-runtime/02-RUNNERS.md +511 -0
  12. data/.claude/todo/phase-3-extensions/01-EXTENSION-SYSTEM.md +298 -0
  13. data/.claude/todo/phase-3-extensions/02-AI-PLUGIN.md +936 -0
  14. data/.claude/todo/phase-3-extensions/todo.md +262 -0
  15. data/.claude/todo/phase-4-ai-rework/01-DEPENDENCIES.md +107 -0
  16. data/.claude/todo/phase-4-ai-rework/02-CONFIGURATION.md +123 -0
  17. data/.claude/todo/phase-4-ai-rework/03-TOOL-REGISTRY.md +237 -0
  18. data/.claude/todo/phase-4-ai-rework/04-MCP-SERVER.md +432 -0
  19. data/.claude/todo/phase-4-ai-rework/05-MCP-CLIENT.md +333 -0
  20. data/.claude/todo/phase-4-ai-rework/06-EXECUTORS.md +397 -0
  21. data/.claude/todo/phase-4-ai-rework/todo.md +265 -0
  22. data/.claude/todo/phase-5-validation/.DS_Store +0 -0
  23. data/.claude/todo/phase-5-validation/01-TEST-GAPS.md +615 -0
  24. data/.claude/todo/phase-5-validation/01-TESTS.md +2378 -0
  25. data/.claude/todo/phase-5-validation/02-EXAMPLES-SIMPLE.md +744 -0
  26. data/.claude/todo/phase-5-validation/02-EXAMPLES.md +1857 -0
  27. data/.claude/todo/phase-5-validation/03-EXAMPLE-SUPPORT-AGENT.md +95 -0
  28. data/.claude/todo/phase-5-validation/04-EXAMPLE-ORDER-FULFILLMENT.md +94 -0
  29. data/.claude/todo/phase-5-validation/05-EXAMPLE-DATA-PIPELINE.md +145 -0
  30. data/.env.example +3 -0
  31. data/.rubocop.yml +64 -0
  32. data/0.3.amend.md +89 -0
  33. data/CHANGELOG.md +5 -0
  34. data/CODE_OF_CONDUCT.md +84 -0
  35. data/Gemfile +22 -0
  36. data/Gemfile.lock +192 -0
  37. data/LICENSE.txt +21 -0
  38. data/README.md +39 -0
  39. data/Rakefile +16 -0
  40. data/durable_workflow.gemspec +43 -0
  41. data/examples/approval_request.rb +106 -0
  42. data/examples/calculator.rb +154 -0
  43. data/examples/file_search_demo.rb +77 -0
  44. data/examples/hello_workflow.rb +57 -0
  45. data/examples/item_processor.rb +96 -0
  46. data/examples/order_fulfillment/Gemfile +6 -0
  47. data/examples/order_fulfillment/README.md +84 -0
  48. data/examples/order_fulfillment/run.rb +85 -0
  49. data/examples/order_fulfillment/services.rb +146 -0
  50. data/examples/order_fulfillment/workflow.yml +188 -0
  51. data/examples/parallel_fetch.rb +102 -0
  52. data/examples/service_integration.rb +137 -0
  53. data/examples/support_agent/Gemfile +6 -0
  54. data/examples/support_agent/README.md +91 -0
  55. data/examples/support_agent/config/claude_desktop.json +12 -0
  56. data/examples/support_agent/mcp_server.rb +49 -0
  57. data/examples/support_agent/run.rb +67 -0
  58. data/examples/support_agent/services.rb +113 -0
  59. data/examples/support_agent/workflow.yml +286 -0
  60. data/lib/durable_workflow/core/condition.rb +45 -0
  61. data/lib/durable_workflow/core/engine.rb +145 -0
  62. data/lib/durable_workflow/core/executors/approval.rb +51 -0
  63. data/lib/durable_workflow/core/executors/assign.rb +18 -0
  64. data/lib/durable_workflow/core/executors/base.rb +90 -0
  65. data/lib/durable_workflow/core/executors/call.rb +76 -0
  66. data/lib/durable_workflow/core/executors/end.rb +19 -0
  67. data/lib/durable_workflow/core/executors/halt.rb +24 -0
  68. data/lib/durable_workflow/core/executors/loop.rb +118 -0
  69. data/lib/durable_workflow/core/executors/parallel.rb +77 -0
  70. data/lib/durable_workflow/core/executors/registry.rb +34 -0
  71. data/lib/durable_workflow/core/executors/router.rb +26 -0
  72. data/lib/durable_workflow/core/executors/start.rb +61 -0
  73. data/lib/durable_workflow/core/executors/transform.rb +71 -0
  74. data/lib/durable_workflow/core/executors/workflow.rb +32 -0
  75. data/lib/durable_workflow/core/parser.rb +189 -0
  76. data/lib/durable_workflow/core/resolver.rb +61 -0
  77. data/lib/durable_workflow/core/schema_validator.rb +47 -0
  78. data/lib/durable_workflow/core/types/base.rb +41 -0
  79. data/lib/durable_workflow/core/types/condition.rb +25 -0
  80. data/lib/durable_workflow/core/types/configs.rb +103 -0
  81. data/lib/durable_workflow/core/types/entry.rb +26 -0
  82. data/lib/durable_workflow/core/types/results.rb +41 -0
  83. data/lib/durable_workflow/core/types/state.rb +95 -0
  84. data/lib/durable_workflow/core/types/step_def.rb +15 -0
  85. data/lib/durable_workflow/core/types/workflow_def.rb +43 -0
  86. data/lib/durable_workflow/core/types.rb +29 -0
  87. data/lib/durable_workflow/core/validator.rb +318 -0
  88. data/lib/durable_workflow/extensions/ai/ai.rb +149 -0
  89. data/lib/durable_workflow/extensions/ai/configuration.rb +41 -0
  90. data/lib/durable_workflow/extensions/ai/executors/agent.rb +150 -0
  91. data/lib/durable_workflow/extensions/ai/executors/file_search.rb +52 -0
  92. data/lib/durable_workflow/extensions/ai/executors/guardrail.rb +152 -0
  93. data/lib/durable_workflow/extensions/ai/executors/handoff.rb +33 -0
  94. data/lib/durable_workflow/extensions/ai/executors/mcp.rb +47 -0
  95. data/lib/durable_workflow/extensions/ai/mcp/adapter.rb +73 -0
  96. data/lib/durable_workflow/extensions/ai/mcp/client.rb +77 -0
  97. data/lib/durable_workflow/extensions/ai/mcp/rack_app.rb +66 -0
  98. data/lib/durable_workflow/extensions/ai/mcp/server.rb +122 -0
  99. data/lib/durable_workflow/extensions/ai/tool_registry.rb +63 -0
  100. data/lib/durable_workflow/extensions/ai/types.rb +213 -0
  101. data/lib/durable_workflow/extensions/ai.rb +6 -0
  102. data/lib/durable_workflow/extensions/base.rb +77 -0
  103. data/lib/durable_workflow/runners/adapters/inline.rb +42 -0
  104. data/lib/durable_workflow/runners/adapters/sidekiq.rb +69 -0
  105. data/lib/durable_workflow/runners/async.rb +100 -0
  106. data/lib/durable_workflow/runners/stream.rb +126 -0
  107. data/lib/durable_workflow/runners/sync.rb +40 -0
  108. data/lib/durable_workflow/storage/active_record.rb +148 -0
  109. data/lib/durable_workflow/storage/redis.rb +133 -0
  110. data/lib/durable_workflow/storage/sequel.rb +144 -0
  111. data/lib/durable_workflow/storage/store.rb +43 -0
  112. data/lib/durable_workflow/utils.rb +25 -0
  113. data/lib/durable_workflow/version.rb +5 -0
  114. data/lib/durable_workflow.rb +70 -0
  115. data/sig/durable_workflow.rbs +4 -0
  116. 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`