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,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Extensions
|
|
5
|
+
module AI
|
|
6
|
+
# Module to hold dynamically generated RubyLLM::Tool classes
|
|
7
|
+
module GeneratedTools
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Message role enum (AI-specific, not in core)
|
|
11
|
+
module Types
|
|
12
|
+
MessageRole = DurableWorkflow::Types::Strict::String.enum('system', 'user', 'assistant', 'tool')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Handoff definition
|
|
16
|
+
class HandoffDef < BaseStruct
|
|
17
|
+
attribute :agent_id, DurableWorkflow::Types::Strict::String
|
|
18
|
+
attribute? :description, DurableWorkflow::Types::Strict::String.optional
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Agent definition
|
|
22
|
+
class AgentDef < BaseStruct
|
|
23
|
+
attribute :id, DurableWorkflow::Types::Strict::String
|
|
24
|
+
attribute? :name, DurableWorkflow::Types::Strict::String.optional
|
|
25
|
+
attribute :model, DurableWorkflow::Types::Strict::String
|
|
26
|
+
attribute? :instructions, DurableWorkflow::Types::Strict::String.optional
|
|
27
|
+
attribute :tools, DurableWorkflow::Types::Strict::Array.of(DurableWorkflow::Types::Strict::String).default([].freeze)
|
|
28
|
+
attribute :handoffs, DurableWorkflow::Types::Strict::Array.of(HandoffDef).default([].freeze)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Tool parameter
|
|
32
|
+
class ToolParam < BaseStruct
|
|
33
|
+
attribute :name, DurableWorkflow::Types::Strict::String
|
|
34
|
+
attribute? :type, DurableWorkflow::Types::Strict::String.optional.default('string')
|
|
35
|
+
attribute? :required, DurableWorkflow::Types::Strict::Bool.default(true)
|
|
36
|
+
attribute? :description, DurableWorkflow::Types::Strict::String.optional
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Tool definition
|
|
40
|
+
class ToolDef < BaseStruct
|
|
41
|
+
attribute :id, DurableWorkflow::Types::Strict::String
|
|
42
|
+
attribute :description, DurableWorkflow::Types::Strict::String
|
|
43
|
+
attribute :parameters, DurableWorkflow::Types::Strict::Array.of(ToolParam).default([].freeze)
|
|
44
|
+
attribute :service, DurableWorkflow::Types::Strict::String
|
|
45
|
+
attribute :method_name, DurableWorkflow::Types::Strict::String
|
|
46
|
+
|
|
47
|
+
def to_function_schema
|
|
48
|
+
{
|
|
49
|
+
name: id,
|
|
50
|
+
description:,
|
|
51
|
+
parameters: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: parameters.each_with_object({}) do |p, h|
|
|
54
|
+
h[p.name] = { type: p.type, description: p.description }.compact
|
|
55
|
+
end,
|
|
56
|
+
required: parameters.select(&:required).map(&:name)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Convert to RubyLLM::Tool class
|
|
62
|
+
def to_ruby_llm_tool
|
|
63
|
+
tool_def = self
|
|
64
|
+
class_name = id.split('_').map(&:capitalize).join
|
|
65
|
+
short_name = id # Use the tool id as the name (e.g., "classify_request")
|
|
66
|
+
|
|
67
|
+
# Create named class under GeneratedTools module
|
|
68
|
+
AI::GeneratedTools.const_set(class_name, Class.new(RubyLLM::Tool) do
|
|
69
|
+
# Store reference to original definition
|
|
70
|
+
@tool_def = tool_def
|
|
71
|
+
|
|
72
|
+
# Set description
|
|
73
|
+
description tool_def.description
|
|
74
|
+
|
|
75
|
+
# Override name to avoid long namespace in tool name
|
|
76
|
+
define_method(:name) { short_name }
|
|
77
|
+
|
|
78
|
+
# Define parameters
|
|
79
|
+
tool_def.parameters.each do |p|
|
|
80
|
+
param p.name.to_sym,
|
|
81
|
+
type: p.type.to_sym,
|
|
82
|
+
desc: p.description,
|
|
83
|
+
required: p.required
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Execute calls the service method
|
|
87
|
+
define_method(:execute) do |**args|
|
|
88
|
+
svc = Object.const_get(tool_def.service)
|
|
89
|
+
svc.public_send(tool_def.method_name, **args)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class << self
|
|
93
|
+
attr_reader :tool_def
|
|
94
|
+
end
|
|
95
|
+
end)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Tool call from LLM
|
|
100
|
+
class ToolCall < BaseStruct
|
|
101
|
+
attribute :id, DurableWorkflow::Types::Strict::String
|
|
102
|
+
attribute :name, DurableWorkflow::Types::Strict::String
|
|
103
|
+
attribute :arguments, DurableWorkflow::Types::Hash.default({}.freeze)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Message in conversation
|
|
107
|
+
class Message < BaseStruct
|
|
108
|
+
attribute :role, Types::MessageRole
|
|
109
|
+
attribute? :content, DurableWorkflow::Types::Strict::String.optional
|
|
110
|
+
attribute? :tool_calls, DurableWorkflow::Types::Strict::Array.of(ToolCall).optional
|
|
111
|
+
attribute? :tool_call_id, DurableWorkflow::Types::Strict::String.optional
|
|
112
|
+
attribute? :name, DurableWorkflow::Types::Strict::String.optional
|
|
113
|
+
|
|
114
|
+
def self.system(content)
|
|
115
|
+
new(role: 'system', content:)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.user(content)
|
|
119
|
+
new(role: 'user', content:)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.assistant(content, tool_calls: nil)
|
|
123
|
+
new(role: 'assistant', content:, tool_calls:)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.tool(content, tool_call_id:, name: nil)
|
|
127
|
+
new(role: 'tool', content:, tool_call_id:, name:)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def system? = role == 'system'
|
|
131
|
+
def user? = role == 'user'
|
|
132
|
+
def assistant? = role == 'assistant'
|
|
133
|
+
def tool? = role == 'tool'
|
|
134
|
+
def tool_calls? = tool_calls&.any?
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# LLM response
|
|
138
|
+
class Response < BaseStruct
|
|
139
|
+
attribute? :content, DurableWorkflow::Types::Strict::String.optional
|
|
140
|
+
attribute :tool_calls, DurableWorkflow::Types::Strict::Array.of(ToolCall).default([].freeze)
|
|
141
|
+
attribute? :finish_reason, DurableWorkflow::Types::Strict::String.optional
|
|
142
|
+
attribute? :usage, DurableWorkflow::Types::Hash.optional
|
|
143
|
+
|
|
144
|
+
def tool_calls? = tool_calls.any?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Moderation result
|
|
148
|
+
class ModerationResult < BaseStruct
|
|
149
|
+
attribute :flagged, DurableWorkflow::Types::Strict::Bool
|
|
150
|
+
attribute? :categories, DurableWorkflow::Types::Hash.optional
|
|
151
|
+
attribute? :scores, DurableWorkflow::Types::Hash.optional
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Guardrail check
|
|
155
|
+
class GuardrailCheck < BaseStruct
|
|
156
|
+
attribute :type, DurableWorkflow::Types::Strict::String
|
|
157
|
+
attribute? :pattern, DurableWorkflow::Types::Strict::String.optional
|
|
158
|
+
attribute? :block_on_match, DurableWorkflow::Types::Strict::Bool.default(true)
|
|
159
|
+
attribute? :max, DurableWorkflow::Types::Strict::Integer.optional
|
|
160
|
+
attribute? :min, DurableWorkflow::Types::Strict::Integer.optional
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Guardrail result
|
|
164
|
+
class GuardrailResult < BaseStruct
|
|
165
|
+
attribute :passed, DurableWorkflow::Types::Strict::Bool
|
|
166
|
+
attribute :check_type, DurableWorkflow::Types::Strict::String
|
|
167
|
+
attribute? :reason, DurableWorkflow::Types::Strict::String.optional
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# AI Step Configs
|
|
171
|
+
class AgentConfig < Core::StepConfig
|
|
172
|
+
attribute :agent_id, DurableWorkflow::Types::Strict::String
|
|
173
|
+
attribute :prompt, DurableWorkflow::Types::Strict::String
|
|
174
|
+
attribute :output, DurableWorkflow::Types::Coercible::Symbol
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
class GuardrailConfig < Core::StepConfig
|
|
178
|
+
attribute? :content, DurableWorkflow::Types::Strict::String.optional
|
|
179
|
+
attribute? :input, DurableWorkflow::Types::Strict::String.optional
|
|
180
|
+
attribute :checks, DurableWorkflow::Types::Strict::Array.default([].freeze)
|
|
181
|
+
attribute? :on_fail, DurableWorkflow::Types::Strict::String.optional
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
class HandoffConfig < Core::StepConfig
|
|
185
|
+
attribute? :to, DurableWorkflow::Types::Strict::String.optional
|
|
186
|
+
attribute? :from, DurableWorkflow::Types::Strict::String.optional
|
|
187
|
+
attribute? :reason, DurableWorkflow::Types::Strict::String.optional
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
class FileSearchConfig < Core::StepConfig
|
|
191
|
+
attribute :query, DurableWorkflow::Types::Strict::String
|
|
192
|
+
attribute :files, DurableWorkflow::Types::Strict::Array.of(DurableWorkflow::Types::Strict::String).default([].freeze)
|
|
193
|
+
attribute? :max_results, DurableWorkflow::Types::Strict::Integer.optional.default(10)
|
|
194
|
+
attribute? :output, DurableWorkflow::Types::Coercible::Symbol.optional
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
class MCPConfig < Core::StepConfig
|
|
198
|
+
attribute :server, DurableWorkflow::Types::Strict::String
|
|
199
|
+
attribute :tool, DurableWorkflow::Types::Strict::String
|
|
200
|
+
attribute? :arguments, DurableWorkflow::Types::Hash.default({}.freeze)
|
|
201
|
+
attribute? :output, DurableWorkflow::Types::Coercible::Symbol.optional
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# MCP Server configuration for consuming external MCP servers
|
|
205
|
+
class MCPServerConfig < BaseStruct
|
|
206
|
+
attribute? :url, DurableWorkflow::Types::Strict::String.optional
|
|
207
|
+
attribute? :headers, DurableWorkflow::Types::Hash.default({}.freeze)
|
|
208
|
+
attribute? :transport, DurableWorkflow::Types::Coercible::Symbol.default(:http)
|
|
209
|
+
attribute? :command, DurableWorkflow::Types::Strict::Array.of(DurableWorkflow::Types::Strict::String).optional
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Extensions
|
|
5
|
+
# Base class for extensions
|
|
6
|
+
# Extensions inherit from this and call register! to set up
|
|
7
|
+
class Base
|
|
8
|
+
class << self
|
|
9
|
+
# Extension name (used as key in workflow.extensions)
|
|
10
|
+
def extension_name
|
|
11
|
+
@extension_name ||= (name ? name.split('::').last.downcase : 'anonymous')
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_writer :extension_name
|
|
15
|
+
|
|
16
|
+
# Register all components of the extension
|
|
17
|
+
def register!
|
|
18
|
+
register_configs
|
|
19
|
+
register_executors
|
|
20
|
+
register_parser_hooks
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Override in subclass to register config classes
|
|
24
|
+
def register_configs
|
|
25
|
+
# Example:
|
|
26
|
+
# DurableWorkflow::Core.register_config("agent", AgentConfig)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Override in subclass to register executors
|
|
30
|
+
def register_executors
|
|
31
|
+
# Example:
|
|
32
|
+
# DurableWorkflow::Core::Executors::Registry.register("agent", AgentExecutor)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Override in subclass to register parser hooks
|
|
36
|
+
def register_parser_hooks
|
|
37
|
+
# Example:
|
|
38
|
+
# DurableWorkflow::Core::Parser.after_parse { |wf| ... }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Helper to get extension data from workflow
|
|
42
|
+
def data_from(workflow)
|
|
43
|
+
workflow.extensions[extension_name.to_sym] || {}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Helper to store extension data in workflow
|
|
47
|
+
def store_in(workflow, data)
|
|
48
|
+
workflow.with(extensions: workflow.extensions.merge(extension_name.to_sym => data))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Registry of loaded extensions
|
|
54
|
+
@extensions = {}
|
|
55
|
+
|
|
56
|
+
class << self
|
|
57
|
+
attr_reader :extensions
|
|
58
|
+
|
|
59
|
+
def register(name, extension_class)
|
|
60
|
+
@extensions[name.to_sym] = extension_class
|
|
61
|
+
extension_class.register!
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def [](name)
|
|
65
|
+
@extensions[name.to_sym]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def loaded?(name)
|
|
69
|
+
@extensions.key?(name.to_sym)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def reset!
|
|
73
|
+
@extensions = {}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Runners
|
|
5
|
+
module Adapters
|
|
6
|
+
class Inline
|
|
7
|
+
def initialize(store: nil)
|
|
8
|
+
@store = store
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def enqueue(workflow_id:, workflow_data:, execution_id:, action:, **kwargs)
|
|
12
|
+
# Execute immediately in current thread (for testing/dev)
|
|
13
|
+
perform(
|
|
14
|
+
workflow_id:,
|
|
15
|
+
workflow_data:,
|
|
16
|
+
execution_id:,
|
|
17
|
+
action:,
|
|
18
|
+
**kwargs
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def perform(workflow_id:, workflow_data:, execution_id:, action:, input: {}, response: nil, approved: nil, **_)
|
|
23
|
+
workflow = DurableWorkflow.registry[workflow_id]
|
|
24
|
+
raise ExecutionError, "Workflow not found: #{workflow_id}" unless workflow
|
|
25
|
+
|
|
26
|
+
store = @store || DurableWorkflow.config&.store
|
|
27
|
+
raise ConfigError, 'No store configured' unless store
|
|
28
|
+
|
|
29
|
+
engine = Core::Engine.new(workflow, store:)
|
|
30
|
+
|
|
31
|
+
# Engine saves Execution with proper typed status - no manual status update needed
|
|
32
|
+
case action.to_sym
|
|
33
|
+
when :start
|
|
34
|
+
engine.run(input:, execution_id:)
|
|
35
|
+
when :resume
|
|
36
|
+
engine.resume(execution_id, response:, approved:)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Runners
|
|
5
|
+
module Adapters
|
|
6
|
+
class Sidekiq
|
|
7
|
+
def initialize(job_class: nil)
|
|
8
|
+
@job_class = job_class || default_job_class
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def enqueue(workflow_id:, workflow_data:, execution_id:, action:, queue: nil, priority: nil, **kwargs)
|
|
12
|
+
job_args = {
|
|
13
|
+
workflow_id:,
|
|
14
|
+
workflow_data:,
|
|
15
|
+
execution_id:,
|
|
16
|
+
action: action.to_s,
|
|
17
|
+
**kwargs.compact
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if queue
|
|
21
|
+
@job_class.set(queue:).perform_async(job_args)
|
|
22
|
+
else
|
|
23
|
+
@job_class.perform_async(job_args)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
execution_id
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def default_job_class
|
|
32
|
+
# Define a default job class if sidekiq is available
|
|
33
|
+
return @default_job_class if defined?(@default_job_class)
|
|
34
|
+
|
|
35
|
+
@default_job_class = Class.new do
|
|
36
|
+
if defined?(::Sidekiq::Job)
|
|
37
|
+
include ::Sidekiq::Job
|
|
38
|
+
|
|
39
|
+
def perform(args)
|
|
40
|
+
args = DurableWorkflow::Utils.deep_symbolize(args)
|
|
41
|
+
|
|
42
|
+
workflow = DurableWorkflow.registry[args[:workflow_id]]
|
|
43
|
+
raise DurableWorkflow::ExecutionError, "Workflow not found: #{args[:workflow_id]}" unless workflow
|
|
44
|
+
|
|
45
|
+
store = DurableWorkflow.config&.store
|
|
46
|
+
raise DurableWorkflow::ConfigError, 'No store configured' unless store
|
|
47
|
+
|
|
48
|
+
engine = DurableWorkflow::Core::Engine.new(workflow, store:)
|
|
49
|
+
|
|
50
|
+
# Engine saves Execution with proper typed status - no manual status update needed
|
|
51
|
+
case args[:action].to_sym
|
|
52
|
+
when :start
|
|
53
|
+
engine.run(input: args[:input], execution_id: args[:execution_id])
|
|
54
|
+
when :resume
|
|
55
|
+
engine.resume(args[:execution_id], response: args[:response], approved: args[:approved])
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Register in Object so it can be found by Sidekiq
|
|
62
|
+
Object.const_set(:DurableWorkflowJob, @default_job_class) unless defined?(::DurableWorkflowJob)
|
|
63
|
+
|
|
64
|
+
@default_job_class
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Runners
|
|
5
|
+
class Async
|
|
6
|
+
attr_reader :workflow, :store, :adapter
|
|
7
|
+
|
|
8
|
+
def initialize(workflow, store: nil, adapter: nil)
|
|
9
|
+
@workflow = workflow
|
|
10
|
+
@store = store || DurableWorkflow.config&.store
|
|
11
|
+
raise ConfigError, 'No store configured' unless @store
|
|
12
|
+
|
|
13
|
+
@adapter = adapter || Adapters::Inline.new(store: @store)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Queue workflow for execution, return immediately
|
|
17
|
+
def run(input: {}, execution_id: nil, queue: nil, priority: nil)
|
|
18
|
+
exec_id = execution_id || SecureRandom.uuid
|
|
19
|
+
|
|
20
|
+
# Pre-create Execution with :pending status
|
|
21
|
+
execution = Core::Execution.new(
|
|
22
|
+
id: exec_id,
|
|
23
|
+
workflow_id: workflow.id,
|
|
24
|
+
status: :pending,
|
|
25
|
+
input: input.freeze,
|
|
26
|
+
ctx: {}
|
|
27
|
+
)
|
|
28
|
+
store.save(execution)
|
|
29
|
+
|
|
30
|
+
# Enqueue
|
|
31
|
+
adapter.enqueue(
|
|
32
|
+
workflow_id: workflow.id,
|
|
33
|
+
workflow_data: serialize_workflow,
|
|
34
|
+
execution_id: exec_id,
|
|
35
|
+
input:,
|
|
36
|
+
action: :start,
|
|
37
|
+
queue:,
|
|
38
|
+
priority:
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
exec_id
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Queue resume
|
|
45
|
+
def resume(execution_id, response: nil, approved: nil, queue: nil)
|
|
46
|
+
adapter.enqueue(
|
|
47
|
+
workflow_id: workflow.id,
|
|
48
|
+
workflow_data: serialize_workflow,
|
|
49
|
+
execution_id:,
|
|
50
|
+
response:,
|
|
51
|
+
approved:,
|
|
52
|
+
action: :resume,
|
|
53
|
+
queue:
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
execution_id
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Poll for completion
|
|
60
|
+
def wait(execution_id, timeout: 30, interval: 0.1)
|
|
61
|
+
deadline = Time.now + timeout
|
|
62
|
+
|
|
63
|
+
while Time.now < deadline
|
|
64
|
+
execution = store.load(execution_id)
|
|
65
|
+
|
|
66
|
+
case execution&.status
|
|
67
|
+
when :completed, :failed, :halted
|
|
68
|
+
return build_result(execution)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
sleep(interval)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
nil # Timeout
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Get current status
|
|
78
|
+
def status(execution_id)
|
|
79
|
+
execution = store.load(execution_id)
|
|
80
|
+
execution&.status || :unknown
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def serialize_workflow
|
|
86
|
+
{ id: workflow.id, name: workflow.name, version: workflow.version }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build_result(execution)
|
|
90
|
+
Core::ExecutionResult.new(
|
|
91
|
+
status: execution.status,
|
|
92
|
+
execution_id: execution.id,
|
|
93
|
+
output: execution.result,
|
|
94
|
+
halt: execution.status == :halted ? Core::HaltResult.new(data: execution.halt_data || {}) : nil,
|
|
95
|
+
error: execution.error
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module DurableWorkflow
|
|
6
|
+
module Runners
|
|
7
|
+
# Stream event type
|
|
8
|
+
class Event < BaseStruct
|
|
9
|
+
attribute :type, Types::Strict::String
|
|
10
|
+
attribute :data, Types::Hash.default({}.freeze)
|
|
11
|
+
attribute :timestamp, Types::Any
|
|
12
|
+
|
|
13
|
+
def to_h
|
|
14
|
+
{ type:, data:, timestamp: timestamp.is_a?(Time) ? timestamp.iso8601 : timestamp }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_json(*)
|
|
18
|
+
JSON.generate(to_h)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_sse
|
|
22
|
+
"event: #{type}\ndata: #{to_json}\n\n"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class Stream
|
|
27
|
+
EVENTS = %w[
|
|
28
|
+
workflow.started workflow.completed workflow.halted workflow.failed
|
|
29
|
+
step.started step.completed step.failed step.halted
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
attr_reader :workflow, :store, :subscribers
|
|
33
|
+
|
|
34
|
+
def initialize(workflow, store: nil)
|
|
35
|
+
@workflow = workflow
|
|
36
|
+
@store = store || DurableWorkflow.config&.store
|
|
37
|
+
raise ConfigError, 'No store configured' unless @store
|
|
38
|
+
|
|
39
|
+
@subscribers = []
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Subscribe to events
|
|
43
|
+
def subscribe(events: nil, &block)
|
|
44
|
+
@subscribers << { events:, handler: block }
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Run with event streaming
|
|
49
|
+
def run(input: {}, execution_id: nil)
|
|
50
|
+
emit('workflow.started', workflow_id: workflow.id, input:)
|
|
51
|
+
|
|
52
|
+
engine = StreamingEngine.new(workflow, store:, emitter: method(:emit))
|
|
53
|
+
result = engine.run(input:, execution_id:)
|
|
54
|
+
|
|
55
|
+
case result.status
|
|
56
|
+
when :completed
|
|
57
|
+
emit('workflow.completed', execution_id: result.execution_id, output: result.output)
|
|
58
|
+
when :halted
|
|
59
|
+
emit('workflow.halted', execution_id: result.execution_id, halt: result.halt&.data, prompt: result.halt&.prompt)
|
|
60
|
+
when :failed
|
|
61
|
+
emit('workflow.failed', execution_id: result.execution_id, error: result.error)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Resume with event streaming
|
|
68
|
+
def resume(execution_id, response: nil, approved: nil)
|
|
69
|
+
emit('workflow.resumed', execution_id:)
|
|
70
|
+
|
|
71
|
+
engine = StreamingEngine.new(workflow, store:, emitter: method(:emit))
|
|
72
|
+
result = engine.resume(execution_id, response:, approved:)
|
|
73
|
+
|
|
74
|
+
case result.status
|
|
75
|
+
when :completed
|
|
76
|
+
emit('workflow.completed', execution_id: result.execution_id, output: result.output)
|
|
77
|
+
when :halted
|
|
78
|
+
emit('workflow.halted', execution_id: result.execution_id, halt: result.halt&.data, prompt: result.halt&.prompt)
|
|
79
|
+
when :failed
|
|
80
|
+
emit('workflow.failed', execution_id: result.execution_id, error: result.error)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
result
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Emit event
|
|
87
|
+
def emit(type, **data)
|
|
88
|
+
event = Event.new(type:, data:, timestamp: Time.now)
|
|
89
|
+
|
|
90
|
+
subscribers.each do |sub|
|
|
91
|
+
next if sub[:events] && !sub[:events].include?(type)
|
|
92
|
+
|
|
93
|
+
sub[:handler].call(event)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Engine subclass with event hooks
|
|
99
|
+
class StreamingEngine < Core::Engine
|
|
100
|
+
def initialize(workflow, store:, emitter:)
|
|
101
|
+
super(workflow, store:)
|
|
102
|
+
@emitter = emitter
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def execute_step(state, step)
|
|
108
|
+
@emitter.call('step.started', step_id: step.id, step_type: step.type)
|
|
109
|
+
|
|
110
|
+
outcome = super
|
|
111
|
+
|
|
112
|
+
event = case outcome.result
|
|
113
|
+
when Core::HaltResult then 'step.halted'
|
|
114
|
+
else 'step.completed'
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
@emitter.call(event, step_id: step.id, output: outcome.result.output)
|
|
118
|
+
|
|
119
|
+
outcome
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
@emitter.call('step.failed', step_id: step.id, error: e.message)
|
|
122
|
+
raise
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Runners
|
|
5
|
+
class Sync
|
|
6
|
+
attr_reader :workflow, :store
|
|
7
|
+
|
|
8
|
+
def initialize(workflow, store: nil)
|
|
9
|
+
@workflow = workflow
|
|
10
|
+
@store = store || DurableWorkflow.config&.store
|
|
11
|
+
raise ConfigError, 'No store configured' unless @store
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Run workflow, block until complete/halted
|
|
15
|
+
def run(input: {}, execution_id: nil)
|
|
16
|
+
engine = Core::Engine.new(workflow, store:)
|
|
17
|
+
engine.run(input:, execution_id:)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Resume halted workflow
|
|
21
|
+
def resume(execution_id, response: nil, approved: nil)
|
|
22
|
+
engine = Core::Engine.new(workflow, store:)
|
|
23
|
+
engine.resume(execution_id, response:, approved:)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Run until fully complete (auto-handle halts with block)
|
|
27
|
+
# Without block, returns halted result when halt encountered
|
|
28
|
+
def run_until_complete(input: {}, execution_id: nil)
|
|
29
|
+
result = run(input:, execution_id:)
|
|
30
|
+
|
|
31
|
+
while result.halted? && block_given?
|
|
32
|
+
response = yield result.halt
|
|
33
|
+
result = resume(result.execution_id, response:)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
result
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|