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,286 @@
|
|
|
1
|
+
id: support_agent
|
|
2
|
+
name: Customer Support Agent
|
|
3
|
+
version: "1.0"
|
|
4
|
+
description: AI-powered customer support with specialized agents
|
|
5
|
+
|
|
6
|
+
inputs:
|
|
7
|
+
message:
|
|
8
|
+
type: string
|
|
9
|
+
required: true
|
|
10
|
+
description: Customer's support request
|
|
11
|
+
customer_id:
|
|
12
|
+
type: string
|
|
13
|
+
required: false
|
|
14
|
+
description: Optional customer identifier
|
|
15
|
+
context:
|
|
16
|
+
type: object
|
|
17
|
+
required: false
|
|
18
|
+
description: Additional context (order_id, etc.)
|
|
19
|
+
|
|
20
|
+
# AI Extension Configuration
|
|
21
|
+
agents:
|
|
22
|
+
- id: triage
|
|
23
|
+
name: Triage Agent
|
|
24
|
+
model: gpt-4o-mini
|
|
25
|
+
instructions: |
|
|
26
|
+
You are a support triage agent. Analyze the customer's request and determine:
|
|
27
|
+
1. The category (billing, technical, general)
|
|
28
|
+
2. The urgency (low, medium, high)
|
|
29
|
+
3. Key entities mentioned (order IDs, product names, etc.)
|
|
30
|
+
|
|
31
|
+
Always be polite and acknowledge the customer's concern.
|
|
32
|
+
Use the classify_request tool to record your analysis.
|
|
33
|
+
tools:
|
|
34
|
+
- classify_request
|
|
35
|
+
|
|
36
|
+
- id: billing
|
|
37
|
+
name: Billing Agent
|
|
38
|
+
model: gpt-4o
|
|
39
|
+
instructions: |
|
|
40
|
+
You are a billing specialist. Help customers with:
|
|
41
|
+
- Order lookups and status
|
|
42
|
+
- Refunds and cancellations
|
|
43
|
+
- Payment issues
|
|
44
|
+
- Invoice requests
|
|
45
|
+
|
|
46
|
+
Always verify the customer's identity before processing sensitive requests.
|
|
47
|
+
Use available tools to look up information and take actions.
|
|
48
|
+
tools:
|
|
49
|
+
- lookup_order
|
|
50
|
+
- refund_order
|
|
51
|
+
- create_ticket
|
|
52
|
+
- escalate
|
|
53
|
+
handoffs:
|
|
54
|
+
- agent_id: technical
|
|
55
|
+
description: Transfer to technical support for product issues
|
|
56
|
+
|
|
57
|
+
- id: technical
|
|
58
|
+
name: Technical Support Agent
|
|
59
|
+
model: gpt-4o
|
|
60
|
+
instructions: |
|
|
61
|
+
You are a technical support specialist. Help customers with:
|
|
62
|
+
- Product setup and configuration
|
|
63
|
+
- Troubleshooting issues
|
|
64
|
+
- Password resets
|
|
65
|
+
- Account access problems
|
|
66
|
+
|
|
67
|
+
Be patient and provide step-by-step guidance.
|
|
68
|
+
tools:
|
|
69
|
+
- reset_password
|
|
70
|
+
- check_status
|
|
71
|
+
- create_ticket
|
|
72
|
+
- escalate
|
|
73
|
+
handoffs:
|
|
74
|
+
- agent_id: billing
|
|
75
|
+
description: Transfer to billing for payment issues
|
|
76
|
+
|
|
77
|
+
tools:
|
|
78
|
+
- id: classify_request
|
|
79
|
+
description: Classify the customer's support request
|
|
80
|
+
parameters:
|
|
81
|
+
- name: category
|
|
82
|
+
type: string
|
|
83
|
+
required: true
|
|
84
|
+
description: "Category: billing, technical, or general"
|
|
85
|
+
- name: urgency
|
|
86
|
+
type: string
|
|
87
|
+
required: true
|
|
88
|
+
description: "Urgency: low, medium, or high"
|
|
89
|
+
- name: summary
|
|
90
|
+
type: string
|
|
91
|
+
required: true
|
|
92
|
+
description: Brief summary of the request
|
|
93
|
+
service: SupportServices
|
|
94
|
+
method: classify_request
|
|
95
|
+
|
|
96
|
+
- id: lookup_order
|
|
97
|
+
description: Look up an order by ID or customer email
|
|
98
|
+
parameters:
|
|
99
|
+
- name: order_id
|
|
100
|
+
type: string
|
|
101
|
+
description: Order ID to look up
|
|
102
|
+
- name: email
|
|
103
|
+
type: string
|
|
104
|
+
description: Customer email to find orders
|
|
105
|
+
service: SupportServices
|
|
106
|
+
method: lookup_order
|
|
107
|
+
|
|
108
|
+
- id: refund_order
|
|
109
|
+
description: Process a refund for an order
|
|
110
|
+
parameters:
|
|
111
|
+
- name: order_id
|
|
112
|
+
type: string
|
|
113
|
+
required: true
|
|
114
|
+
description: Order ID to refund
|
|
115
|
+
- name: reason
|
|
116
|
+
type: string
|
|
117
|
+
required: true
|
|
118
|
+
description: Reason for refund
|
|
119
|
+
- name: amount
|
|
120
|
+
type: number
|
|
121
|
+
description: Partial refund amount (omit for full refund)
|
|
122
|
+
service: SupportServices
|
|
123
|
+
method: refund_order
|
|
124
|
+
|
|
125
|
+
- id: create_ticket
|
|
126
|
+
description: Create a support ticket for follow-up
|
|
127
|
+
parameters:
|
|
128
|
+
- name: subject
|
|
129
|
+
type: string
|
|
130
|
+
required: true
|
|
131
|
+
description: Ticket subject
|
|
132
|
+
- name: description
|
|
133
|
+
type: string
|
|
134
|
+
required: true
|
|
135
|
+
description: Detailed description
|
|
136
|
+
- name: priority
|
|
137
|
+
type: string
|
|
138
|
+
description: "Priority: low, medium, high"
|
|
139
|
+
service: SupportServices
|
|
140
|
+
method: create_ticket
|
|
141
|
+
|
|
142
|
+
- id: check_status
|
|
143
|
+
description: Check the status of a support ticket
|
|
144
|
+
parameters:
|
|
145
|
+
- name: ticket_id
|
|
146
|
+
type: string
|
|
147
|
+
required: true
|
|
148
|
+
description: Ticket ID to check
|
|
149
|
+
service: SupportServices
|
|
150
|
+
method: check_status
|
|
151
|
+
|
|
152
|
+
- id: reset_password
|
|
153
|
+
description: Send password reset email to customer
|
|
154
|
+
parameters:
|
|
155
|
+
- name: email
|
|
156
|
+
type: string
|
|
157
|
+
required: true
|
|
158
|
+
description: Customer's email address
|
|
159
|
+
service: SupportServices
|
|
160
|
+
method: reset_password
|
|
161
|
+
|
|
162
|
+
- id: escalate
|
|
163
|
+
description: Escalate to a human support agent
|
|
164
|
+
parameters:
|
|
165
|
+
- name: reason
|
|
166
|
+
type: string
|
|
167
|
+
required: true
|
|
168
|
+
description: Reason for escalation
|
|
169
|
+
- name: urgency
|
|
170
|
+
type: string
|
|
171
|
+
required: true
|
|
172
|
+
description: "Urgency level: medium or high"
|
|
173
|
+
service: SupportServices
|
|
174
|
+
method: escalate
|
|
175
|
+
|
|
176
|
+
# Workflow Steps
|
|
177
|
+
steps:
|
|
178
|
+
- id: start
|
|
179
|
+
type: start
|
|
180
|
+
next: moderate_input
|
|
181
|
+
|
|
182
|
+
- id: moderate_input
|
|
183
|
+
type: guardrail
|
|
184
|
+
content: "$input.message"
|
|
185
|
+
checks:
|
|
186
|
+
- type: moderation
|
|
187
|
+
- type: prompt_injection
|
|
188
|
+
on_fail: rejected
|
|
189
|
+
next: triage
|
|
190
|
+
|
|
191
|
+
- id: triage
|
|
192
|
+
type: agent
|
|
193
|
+
agent_id: triage
|
|
194
|
+
prompt: |
|
|
195
|
+
Customer message: $input.message
|
|
196
|
+
Customer ID: $input.customer_id
|
|
197
|
+
Context: $input.context
|
|
198
|
+
|
|
199
|
+
Please analyze this request and classify it.
|
|
200
|
+
output: triage_result
|
|
201
|
+
next: route_request
|
|
202
|
+
|
|
203
|
+
- id: route_request
|
|
204
|
+
type: router
|
|
205
|
+
routes:
|
|
206
|
+
- when:
|
|
207
|
+
field: triage_result.category
|
|
208
|
+
op: eq
|
|
209
|
+
value: "billing"
|
|
210
|
+
then: billing_agent
|
|
211
|
+
- when:
|
|
212
|
+
field: triage_result.category
|
|
213
|
+
op: eq
|
|
214
|
+
value: "technical"
|
|
215
|
+
then: technical_agent
|
|
216
|
+
default: general_response
|
|
217
|
+
|
|
218
|
+
- id: billing_agent
|
|
219
|
+
type: agent
|
|
220
|
+
agent_id: billing
|
|
221
|
+
prompt: |
|
|
222
|
+
Customer request (classified as billing, urgency: $triage_result.urgency):
|
|
223
|
+
$input.message
|
|
224
|
+
|
|
225
|
+
Customer ID: $input.customer_id
|
|
226
|
+
Context: $input.context
|
|
227
|
+
|
|
228
|
+
Please help resolve this billing issue.
|
|
229
|
+
output: agent_response
|
|
230
|
+
next: check_handoff
|
|
231
|
+
|
|
232
|
+
- id: technical_agent
|
|
233
|
+
type: agent
|
|
234
|
+
agent_id: technical
|
|
235
|
+
prompt: |
|
|
236
|
+
Customer request (classified as technical, urgency: $triage_result.urgency):
|
|
237
|
+
$input.message
|
|
238
|
+
|
|
239
|
+
Customer ID: $input.customer_id
|
|
240
|
+
|
|
241
|
+
Please help resolve this technical issue.
|
|
242
|
+
output: agent_response
|
|
243
|
+
next: check_handoff
|
|
244
|
+
|
|
245
|
+
- id: general_response
|
|
246
|
+
type: agent
|
|
247
|
+
agent_id: triage
|
|
248
|
+
prompt: |
|
|
249
|
+
The customer's request doesn't fit billing or technical categories.
|
|
250
|
+
Please provide a helpful general response or ask clarifying questions.
|
|
251
|
+
|
|
252
|
+
Customer message: $input.message
|
|
253
|
+
output: agent_response
|
|
254
|
+
next: end
|
|
255
|
+
|
|
256
|
+
- id: check_handoff
|
|
257
|
+
type: router
|
|
258
|
+
routes:
|
|
259
|
+
- when:
|
|
260
|
+
field: _handoff_to
|
|
261
|
+
op: exists
|
|
262
|
+
then: handle_handoff
|
|
263
|
+
default: end
|
|
264
|
+
|
|
265
|
+
- id: handle_handoff
|
|
266
|
+
type: handoff
|
|
267
|
+
target_agent: "$_handoff_to"
|
|
268
|
+
context:
|
|
269
|
+
original_message: "$input.message"
|
|
270
|
+
previous_response: "$agent_response"
|
|
271
|
+
triage: "$triage_result"
|
|
272
|
+
output: agent_response
|
|
273
|
+
next: end
|
|
274
|
+
|
|
275
|
+
- id: rejected
|
|
276
|
+
type: assign
|
|
277
|
+
set:
|
|
278
|
+
agent_response: "I apologize, but I cannot process this request. Please contact support directly."
|
|
279
|
+
triage_result: null
|
|
280
|
+
next: end
|
|
281
|
+
|
|
282
|
+
- id: end
|
|
283
|
+
type: end
|
|
284
|
+
result:
|
|
285
|
+
response: "$agent_response"
|
|
286
|
+
triage: "$triage_result"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Core
|
|
5
|
+
# Stateless condition evaluator
|
|
6
|
+
class ConditionEvaluator
|
|
7
|
+
OPS = {
|
|
8
|
+
'eq' => ->(v, e) { v == e },
|
|
9
|
+
'neq' => ->(v, e) { v != e },
|
|
10
|
+
'gt' => ->(v, e) { v.to_f > e.to_f },
|
|
11
|
+
'lt' => ->(v, e) { v.to_f < e.to_f },
|
|
12
|
+
'gte' => ->(v, e) { v.to_f >= e.to_f },
|
|
13
|
+
'lte' => ->(v, e) { v.to_f <= e.to_f },
|
|
14
|
+
'in' => ->(v, e) { Array(e).include?(v) },
|
|
15
|
+
'not_in' => ->(v, e) { !Array(e).include?(v) },
|
|
16
|
+
'contains' => ->(v, e) { v.to_s.include?(e.to_s) },
|
|
17
|
+
'starts_with' => ->(v, e) { v.to_s.start_with?(e.to_s) },
|
|
18
|
+
'ends_with' => ->(v, e) { v.to_s.end_with?(e.to_s) },
|
|
19
|
+
'matches' => ->(v, e) { v.to_s.match?(Regexp.new(e.to_s)) },
|
|
20
|
+
'exists' => ->(v, _) { !v.nil? },
|
|
21
|
+
'empty' => ->(v, _) { v.nil? || (v.respond_to?(:empty?) && v.empty?) },
|
|
22
|
+
'truthy' => ->(v, _) { !v.nil? },
|
|
23
|
+
'falsy' => ->(v, _) { !v }
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Evaluate Route or Condition
|
|
28
|
+
def match?(state, cond)
|
|
29
|
+
val = Resolver.resolve(state, "$#{cond.field}")
|
|
30
|
+
exp = Resolver.resolve(state, cond.value)
|
|
31
|
+
op = OPS.fetch(cond.op) { ->(_, _) { false } }
|
|
32
|
+
op.call(val, exp)
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
DurableWorkflow.log(:warn, "Condition failed: #{e.message}", field: cond.field, op: cond.op)
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Find first matching route
|
|
39
|
+
def find_route(state, routes)
|
|
40
|
+
routes.find { match?(state, _1) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'timeout'
|
|
4
|
+
|
|
5
|
+
module DurableWorkflow
|
|
6
|
+
module Core
|
|
7
|
+
class Engine
|
|
8
|
+
FINISHED = '__FINISHED__'
|
|
9
|
+
|
|
10
|
+
attr_reader :workflow, :store
|
|
11
|
+
|
|
12
|
+
def initialize(workflow, store: nil)
|
|
13
|
+
@workflow = workflow
|
|
14
|
+
@store = store || DurableWorkflow.config&.store
|
|
15
|
+
raise ConfigError, 'No store configured. Use Redis, ActiveRecord, or Sequel.' unless @store
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run(input: {}, execution_id: nil)
|
|
19
|
+
exec_id = execution_id || SecureRandom.uuid
|
|
20
|
+
|
|
21
|
+
state = State.new(
|
|
22
|
+
execution_id: exec_id,
|
|
23
|
+
workflow_id: workflow.id,
|
|
24
|
+
input: DurableWorkflow::Utils.deep_symbolize(input)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Save initial Execution with :running status
|
|
28
|
+
save_execution(state, ExecutionResult.new(status: :running, execution_id: exec_id))
|
|
29
|
+
|
|
30
|
+
if workflow.timeout
|
|
31
|
+
Timeout.timeout(workflow.timeout) do
|
|
32
|
+
execute_from(state, workflow.first_step.id)
|
|
33
|
+
end
|
|
34
|
+
else
|
|
35
|
+
execute_from(state, workflow.first_step.id)
|
|
36
|
+
end
|
|
37
|
+
rescue Timeout::Error
|
|
38
|
+
result = ExecutionResult.new(status: :failed, execution_id: state.execution_id, error: "Workflow timeout after #{workflow.timeout}s")
|
|
39
|
+
save_execution(state, result)
|
|
40
|
+
result
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def resume(execution_id, response: nil, approved: nil)
|
|
44
|
+
execution = @store.load(execution_id)
|
|
45
|
+
raise ExecutionError, "Execution not found: #{execution_id}" unless execution
|
|
46
|
+
|
|
47
|
+
state = execution.to_state
|
|
48
|
+
state = state.with_ctx(response:) if response
|
|
49
|
+
state = state.with_ctx(approved:) unless approved.nil?
|
|
50
|
+
|
|
51
|
+
# Use recover_to from Execution, or fall back to current_step
|
|
52
|
+
resume_step = execution.recover_to || execution.current_step
|
|
53
|
+
|
|
54
|
+
execute_from(state, resume_step)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def execute_from(state, step_id)
|
|
60
|
+
while step_id && step_id != FINISHED
|
|
61
|
+
state = state.with_current_step(step_id)
|
|
62
|
+
|
|
63
|
+
# Save intermediate state as :running
|
|
64
|
+
save_execution(state, ExecutionResult.new(status: :running, execution_id: state.execution_id))
|
|
65
|
+
|
|
66
|
+
step = workflow.find_step(step_id)
|
|
67
|
+
raise ExecutionError, "Step not found: #{step_id}" unless step
|
|
68
|
+
|
|
69
|
+
outcome = execute_step(state, step)
|
|
70
|
+
state = outcome.state
|
|
71
|
+
|
|
72
|
+
case outcome.result
|
|
73
|
+
when HaltResult
|
|
74
|
+
return handle_halt(state, outcome.result)
|
|
75
|
+
when ContinueResult
|
|
76
|
+
step_id = outcome.result.next_step
|
|
77
|
+
else
|
|
78
|
+
raise ExecutionError, "Unknown result: #{outcome.result.class}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Completed
|
|
83
|
+
result = ExecutionResult.new(status: :completed, execution_id: state.execution_id, output: state.ctx[:result])
|
|
84
|
+
save_execution(state, result)
|
|
85
|
+
result
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def execute_step(state, step)
|
|
89
|
+
executor_class = Executors::Registry[step.type]
|
|
90
|
+
raise ExecutionError, "No executor for: #{step.type}" unless executor_class
|
|
91
|
+
|
|
92
|
+
start = Time.now
|
|
93
|
+
outcome = executor_class.new(step).call(state)
|
|
94
|
+
duration = ((Time.now - start) * 1000).to_i
|
|
95
|
+
|
|
96
|
+
@store.record(Entry.new(
|
|
97
|
+
id: SecureRandom.uuid,
|
|
98
|
+
execution_id: state.execution_id,
|
|
99
|
+
step_id: step.id,
|
|
100
|
+
step_type: step.type,
|
|
101
|
+
action: outcome.result.is_a?(HaltResult) ? :halted : :completed,
|
|
102
|
+
duration_ms: duration,
|
|
103
|
+
output: outcome.result.output,
|
|
104
|
+
timestamp: Time.now
|
|
105
|
+
))
|
|
106
|
+
|
|
107
|
+
outcome
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
@store.record(Entry.new(
|
|
110
|
+
id: SecureRandom.uuid,
|
|
111
|
+
execution_id: state.execution_id,
|
|
112
|
+
step_id: step.id,
|
|
113
|
+
step_type: step.type,
|
|
114
|
+
action: :failed,
|
|
115
|
+
error: "#{e.class}: #{e.message}",
|
|
116
|
+
timestamp: Time.now
|
|
117
|
+
))
|
|
118
|
+
|
|
119
|
+
if step.on_error
|
|
120
|
+
# Store error info in ctx for access by error handler step
|
|
121
|
+
error_state = state.with_ctx(_last_error: { step: step.id, message: e.message, class: e.class.name })
|
|
122
|
+
return StepOutcome.new(state: error_state, result: ContinueResult.new(next_step: step.on_error))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
raise
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def handle_halt(state, halt_result)
|
|
129
|
+
result = ExecutionResult.new(
|
|
130
|
+
status: :halted,
|
|
131
|
+
execution_id: state.execution_id,
|
|
132
|
+
output: state.ctx[:result],
|
|
133
|
+
halt: halt_result
|
|
134
|
+
)
|
|
135
|
+
save_execution(state, result)
|
|
136
|
+
result
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def save_execution(state, result)
|
|
140
|
+
execution = Execution.from_state(state, result)
|
|
141
|
+
@store.save(execution)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Core
|
|
5
|
+
module Executors
|
|
6
|
+
class Approval < Base
|
|
7
|
+
Registry.register('approval', self)
|
|
8
|
+
|
|
9
|
+
def call(state)
|
|
10
|
+
# Check if timed out (when resuming)
|
|
11
|
+
requested_at_str = state.ctx.dig(:_halt, :requested_at)
|
|
12
|
+
if requested_at_str && config.timeout
|
|
13
|
+
requested_at = Time.parse(requested_at_str)
|
|
14
|
+
if Time.now - requested_at > config.timeout
|
|
15
|
+
return continue(state, next_step: config.on_timeout) if config.on_timeout
|
|
16
|
+
|
|
17
|
+
raise ExecutionError, 'Approval timeout'
|
|
18
|
+
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Resuming from approval
|
|
23
|
+
if state.ctx.key?(:approved)
|
|
24
|
+
approved = state.ctx[:approved]
|
|
25
|
+
state = state.with(ctx: state.ctx.except(:approved))
|
|
26
|
+
if approved
|
|
27
|
+
return continue(state)
|
|
28
|
+
elsif config.on_reject
|
|
29
|
+
return continue(state, next_step: config.on_reject)
|
|
30
|
+
else
|
|
31
|
+
raise ExecutionError, 'Rejected'
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Request approval
|
|
36
|
+
halt(state,
|
|
37
|
+
data: {
|
|
38
|
+
type: :approval,
|
|
39
|
+
prompt: resolve(state, config.prompt),
|
|
40
|
+
context: resolve(state, config.context),
|
|
41
|
+
approvers: config.approvers,
|
|
42
|
+
timeout: config.timeout,
|
|
43
|
+
requested_at: Time.now.iso8601
|
|
44
|
+
},
|
|
45
|
+
resume_step: step.id,
|
|
46
|
+
prompt: resolve(state, config.prompt))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Core
|
|
5
|
+
module Executors
|
|
6
|
+
class Assign < Base
|
|
7
|
+
Registry.register('assign', self)
|
|
8
|
+
|
|
9
|
+
def call(state)
|
|
10
|
+
state = config.set.reduce(state) do |s, (k, v)|
|
|
11
|
+
store(s, k, resolve(s, v))
|
|
12
|
+
end
|
|
13
|
+
continue(state)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'timeout'
|
|
4
|
+
|
|
5
|
+
module DurableWorkflow
|
|
6
|
+
module Core
|
|
7
|
+
module Executors
|
|
8
|
+
class Base
|
|
9
|
+
attr_reader :step, :config
|
|
10
|
+
|
|
11
|
+
def initialize(step)
|
|
12
|
+
@step = step
|
|
13
|
+
@config = step.config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Executors receive state and return StepOutcome
|
|
17
|
+
def call(state)
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def next_step
|
|
24
|
+
step.next_step
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Pure resolve - takes state explicitly
|
|
28
|
+
def resolve(state, v)
|
|
29
|
+
Resolver.resolve(state, v)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Return StepOutcome with continue result
|
|
33
|
+
def continue(state, next_step: nil, output: nil)
|
|
34
|
+
StepOutcome.new(
|
|
35
|
+
state:,
|
|
36
|
+
result: ContinueResult.new(next_step: next_step || self.next_step, output:)
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Return StepOutcome with halt result
|
|
41
|
+
def halt(state, data: {}, resume_step: nil, prompt: nil)
|
|
42
|
+
StepOutcome.new(
|
|
43
|
+
state:,
|
|
44
|
+
result: HaltResult.new(data:, resume_step: resume_step || next_step, prompt:)
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Immutable store - returns new state
|
|
49
|
+
def store(state, key, val)
|
|
50
|
+
return state unless key
|
|
51
|
+
|
|
52
|
+
state.with_ctx(key.to_sym => DurableWorkflow::Utils.deep_symbolize(val))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def with_timeout(seconds = nil, &)
|
|
56
|
+
timeout = seconds || config_timeout
|
|
57
|
+
return yield unless timeout
|
|
58
|
+
|
|
59
|
+
Timeout.timeout(timeout, &)
|
|
60
|
+
rescue Timeout::Error
|
|
61
|
+
raise ExecutionError, "Step '#{step.id}' timed out after #{timeout}s"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def with_retry(max_retries: 0, delay: 1.0, backoff: 2.0)
|
|
65
|
+
attempts = 0
|
|
66
|
+
begin
|
|
67
|
+
attempts += 1
|
|
68
|
+
yield
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
if attempts <= max_retries
|
|
71
|
+
sleep_time = delay * (backoff**(attempts - 1))
|
|
72
|
+
log(:warn, "Retry #{attempts}/#{max_retries} after #{sleep_time}s", error: e.message)
|
|
73
|
+
sleep(sleep_time)
|
|
74
|
+
retry
|
|
75
|
+
end
|
|
76
|
+
raise
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def config_timeout
|
|
81
|
+
config.respond_to?(:timeout) ? config.timeout : nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def log(level, msg, **data)
|
|
85
|
+
DurableWorkflow.log(level, msg, step_id: step.id, step_type: step.type, **data)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Core
|
|
5
|
+
module Executors
|
|
6
|
+
class Call < Base
|
|
7
|
+
Registry.register('call', self)
|
|
8
|
+
|
|
9
|
+
def call(state)
|
|
10
|
+
svc = resolve_service(config.service)
|
|
11
|
+
method = config.method_name
|
|
12
|
+
input = resolve(state, config.input)
|
|
13
|
+
|
|
14
|
+
result = with_retry(
|
|
15
|
+
max_retries: config.retries,
|
|
16
|
+
delay: config.retry_delay,
|
|
17
|
+
backoff: config.retry_backoff
|
|
18
|
+
) do
|
|
19
|
+
with_timeout { invoke(svc, method, input) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Runtime schema validation (if schema defined)
|
|
23
|
+
validate_output!(result) if output_schema
|
|
24
|
+
|
|
25
|
+
state = store(state, output_key, result)
|
|
26
|
+
continue(state, output: result)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def resolve_service(name)
|
|
32
|
+
DurableWorkflow.config&.service_resolver&.call(name) || Object.const_get(name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def invoke(svc, method, input)
|
|
36
|
+
target = svc.respond_to?(method) ? svc : svc.new
|
|
37
|
+
m = target.method(method)
|
|
38
|
+
|
|
39
|
+
# Check if method takes keyword args
|
|
40
|
+
has_kwargs = m.parameters.any? { |type, _| %i[key keyreq keyrest].include?(type) }
|
|
41
|
+
|
|
42
|
+
if has_kwargs && input.is_a?(Hash)
|
|
43
|
+
m.call(**input.transform_keys(&:to_sym))
|
|
44
|
+
elsif m.arity.zero?
|
|
45
|
+
m.call
|
|
46
|
+
else
|
|
47
|
+
m.call(input)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def output_key
|
|
52
|
+
case config.output
|
|
53
|
+
when Symbol, String then config.output
|
|
54
|
+
when OutputConfig then config.output.key
|
|
55
|
+
when Hash then config.output[:key]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def output_schema
|
|
60
|
+
case config.output
|
|
61
|
+
when OutputConfig then config.output.schema
|
|
62
|
+
when Hash then config.output[:schema]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_output!(result)
|
|
67
|
+
SchemaValidator.validate!(
|
|
68
|
+
result,
|
|
69
|
+
output_schema,
|
|
70
|
+
context: "Step '#{step.id}' output"
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|