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,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Extensions
|
|
5
|
+
module AI
|
|
6
|
+
module Executors
|
|
7
|
+
class FileSearch < Core::Executors::Base
|
|
8
|
+
def call(state)
|
|
9
|
+
query = resolve(state, config.query)
|
|
10
|
+
files = config.files
|
|
11
|
+
max_results = config.max_results
|
|
12
|
+
|
|
13
|
+
results = search_files(query, files, max_results)
|
|
14
|
+
|
|
15
|
+
state = store(state, config.output, results) if config.output
|
|
16
|
+
continue(state, output: results)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def search_files(query, files, max_results)
|
|
22
|
+
# Placeholder - returns dummy results
|
|
23
|
+
# In production, integrate with vector stores (OpenAI, Pinecone, etc.)
|
|
24
|
+
dummy_results = generate_dummy_results(query, files, max_results)
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
query:,
|
|
28
|
+
results: dummy_results,
|
|
29
|
+
total: dummy_results.size,
|
|
30
|
+
searched_files: files.size,
|
|
31
|
+
max_results:
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def generate_dummy_results(query, files, max_results)
|
|
36
|
+
return [] if files.empty?
|
|
37
|
+
|
|
38
|
+
# Generate plausible dummy results based on query
|
|
39
|
+
files.take(max_results).map.with_index do |file, idx|
|
|
40
|
+
{
|
|
41
|
+
file: file,
|
|
42
|
+
score: (0.95 - (idx * 0.1)).round(2),
|
|
43
|
+
snippet: "...relevant content matching '#{query}' found in #{file}...",
|
|
44
|
+
line_number: rand(1..100)
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Extensions
|
|
5
|
+
module AI
|
|
6
|
+
module Executors
|
|
7
|
+
class Guardrail < Core::Executors::Base
|
|
8
|
+
INJECTION_PATTERNS = [
|
|
9
|
+
/ignore\s+(all\s+)?previous\s+instructions/i,
|
|
10
|
+
/disregard\s+(all\s+)?previous/i,
|
|
11
|
+
/forget\s+(everything|all)/i,
|
|
12
|
+
/you\s+are\s+now\s+/i,
|
|
13
|
+
/new\s+instructions?:/i,
|
|
14
|
+
/system\s*:\s*/i,
|
|
15
|
+
/\[system\]/i,
|
|
16
|
+
/pretend\s+you\s+are/i,
|
|
17
|
+
/act\s+as\s+if/i,
|
|
18
|
+
/roleplay\s+as/i
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
PII_PATTERNS = [
|
|
22
|
+
/\b\d{3}-\d{2}-\d{4}\b/, # SSN
|
|
23
|
+
/\b\d{16}\b/, # Credit card (no spaces)
|
|
24
|
+
/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/, # Credit card (with spaces/dashes)
|
|
25
|
+
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/, # Email
|
|
26
|
+
/\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/ # Phone
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
def call(state)
|
|
30
|
+
content = resolve(state, config.content || config.input)
|
|
31
|
+
checks = parse_checks(config.checks)
|
|
32
|
+
on_fail = config.on_fail
|
|
33
|
+
|
|
34
|
+
results = checks.map { |check| run_check(check, content) }
|
|
35
|
+
failed = results.find { |r| !r.passed }
|
|
36
|
+
|
|
37
|
+
if failed
|
|
38
|
+
state = state.with_ctx(_guardrail_failure: {
|
|
39
|
+
check_type: failed.check_type,
|
|
40
|
+
reason: failed.reason
|
|
41
|
+
})
|
|
42
|
+
return on_fail ? continue(state, next_step: on_fail) : raise(ExecutionError, "Guardrail failed: #{failed.reason}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
continue(state)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def parse_checks(checks)
|
|
51
|
+
checks.map do |check|
|
|
52
|
+
case check
|
|
53
|
+
when Hash
|
|
54
|
+
GuardrailCheck.new(
|
|
55
|
+
type: check[:type],
|
|
56
|
+
pattern: check[:pattern],
|
|
57
|
+
block_on_match: check.fetch(:block_on_match, true),
|
|
58
|
+
max: check[:max],
|
|
59
|
+
min: check[:min]
|
|
60
|
+
)
|
|
61
|
+
when GuardrailCheck
|
|
62
|
+
check
|
|
63
|
+
else
|
|
64
|
+
GuardrailCheck.new(type: check.to_s)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def run_check(check, content)
|
|
70
|
+
case check.type
|
|
71
|
+
when 'prompt_injection'
|
|
72
|
+
check_prompt_injection(content)
|
|
73
|
+
when 'pii'
|
|
74
|
+
check_pii(content)
|
|
75
|
+
when 'moderation'
|
|
76
|
+
check_moderation(content)
|
|
77
|
+
when 'regex'
|
|
78
|
+
check_regex(content, check.pattern, check.block_on_match)
|
|
79
|
+
when 'length'
|
|
80
|
+
check_length(content, check.max, check.min)
|
|
81
|
+
else
|
|
82
|
+
GuardrailResult.new(passed: true, check_type: check.type)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def check_prompt_injection(content)
|
|
87
|
+
detected = INJECTION_PATTERNS.any? { |pattern| content.to_s.match?(pattern) }
|
|
88
|
+
GuardrailResult.new(
|
|
89
|
+
passed: !detected,
|
|
90
|
+
check_type: 'prompt_injection',
|
|
91
|
+
reason: detected ? 'Potential prompt injection detected' : nil
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def check_pii(content)
|
|
96
|
+
detected = PII_PATTERNS.any? { |pattern| content.to_s.match?(pattern) }
|
|
97
|
+
GuardrailResult.new(
|
|
98
|
+
passed: !detected,
|
|
99
|
+
check_type: 'pii',
|
|
100
|
+
reason: detected ? 'PII detected in content' : nil
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def check_moderation(content)
|
|
105
|
+
result = RubyLLM.moderate(content)
|
|
106
|
+
GuardrailResult.new(
|
|
107
|
+
passed: !result.flagged,
|
|
108
|
+
check_type: 'moderation',
|
|
109
|
+
reason: result.flagged ? 'Content flagged by moderation' : nil
|
|
110
|
+
)
|
|
111
|
+
rescue StandardError
|
|
112
|
+
# If moderation fails (e.g., no API key), pass by default
|
|
113
|
+
GuardrailResult.new(passed: true, check_type: 'moderation')
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def check_regex(content, pattern, block_on_match = true)
|
|
117
|
+
return GuardrailResult.new(passed: true, check_type: 'regex') unless pattern
|
|
118
|
+
|
|
119
|
+
matches = content.to_s.match?(Regexp.new(pattern))
|
|
120
|
+
passed = block_on_match ? !matches : matches
|
|
121
|
+
|
|
122
|
+
GuardrailResult.new(
|
|
123
|
+
passed:,
|
|
124
|
+
check_type: 'regex',
|
|
125
|
+
reason: if passed
|
|
126
|
+
nil
|
|
127
|
+
else
|
|
128
|
+
"Content #{block_on_match ? 'matched' : 'did not match'} pattern"
|
|
129
|
+
end
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def check_length(content, max, min)
|
|
134
|
+
len = content.to_s.length
|
|
135
|
+
passed = true
|
|
136
|
+
reason = nil
|
|
137
|
+
|
|
138
|
+
if max && len > max
|
|
139
|
+
passed = false
|
|
140
|
+
reason = "Content exceeds max length (#{len} > #{max})"
|
|
141
|
+
elsif min && len < min
|
|
142
|
+
passed = false
|
|
143
|
+
reason = "Content below min length (#{len} < #{min})"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
GuardrailResult.new(passed:, check_type: 'length', reason:)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Extensions
|
|
5
|
+
module AI
|
|
6
|
+
module Executors
|
|
7
|
+
class Handoff < Core::Executors::Base
|
|
8
|
+
def call(state)
|
|
9
|
+
target_agent = config.to || state.ctx[:_handoff_to]
|
|
10
|
+
raise ExecutionError, 'No handoff target specified' unless target_agent
|
|
11
|
+
|
|
12
|
+
workflow = DurableWorkflow.registry[state.workflow_id]
|
|
13
|
+
agents = Extension.agents(workflow)
|
|
14
|
+
raise ExecutionError, "Agent not found: #{target_agent}" unless agents.key?(target_agent)
|
|
15
|
+
|
|
16
|
+
new_ctx = state.ctx.except(:_handoff_to).merge(
|
|
17
|
+
_current_agent: target_agent,
|
|
18
|
+
_handoff_context: {
|
|
19
|
+
from: config.from,
|
|
20
|
+
to: target_agent,
|
|
21
|
+
reason: config.reason,
|
|
22
|
+
timestamp: Time.now.iso8601
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
state = state.with(ctx: new_ctx)
|
|
26
|
+
|
|
27
|
+
continue(state)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Extensions
|
|
5
|
+
module AI
|
|
6
|
+
module Executors
|
|
7
|
+
class MCP < Core::Executors::Base
|
|
8
|
+
def call(state)
|
|
9
|
+
server_config = resolve_server(state, config.server)
|
|
10
|
+
tool_name = config.tool
|
|
11
|
+
arguments = resolve(state, config.arguments)
|
|
12
|
+
|
|
13
|
+
result = AI::MCP::Client.call_tool(server_config, tool_name, arguments)
|
|
14
|
+
|
|
15
|
+
# Extract text content from MCP response
|
|
16
|
+
output = extract_output(result)
|
|
17
|
+
|
|
18
|
+
state = store(state, config.output, output) if config.output
|
|
19
|
+
continue(state, output: output)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def resolve_server(state, server_id)
|
|
25
|
+
servers = Extension.mcp_servers(workflow(state))
|
|
26
|
+
server_config = Utils.fetch(servers, server_id)
|
|
27
|
+
raise ExecutionError, "MCP server not found: #{server_id}" unless server_config
|
|
28
|
+
|
|
29
|
+
server_config
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def workflow(state)
|
|
33
|
+
DurableWorkflow.registry[state.workflow_id]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def extract_output(result)
|
|
37
|
+
if result.respond_to?(:content)
|
|
38
|
+
result.content.map { |c| Utils.fetch(c, :text) }.compact.join("\n")
|
|
39
|
+
else
|
|
40
|
+
result.to_s
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Extensions
|
|
5
|
+
module AI
|
|
6
|
+
module MCP
|
|
7
|
+
class Adapter
|
|
8
|
+
class << self
|
|
9
|
+
# Convert RubyLLM::Tool instance to MCP::Tool
|
|
10
|
+
def to_mcp_tool(ruby_llm_tool)
|
|
11
|
+
tool_name = extract_name(ruby_llm_tool)
|
|
12
|
+
tool_description = ruby_llm_tool.description
|
|
13
|
+
tool_schema = ruby_llm_tool.class.respond_to?(:params_schema) ? ruby_llm_tool.class.params_schema : {}
|
|
14
|
+
|
|
15
|
+
captured_tool = ruby_llm_tool
|
|
16
|
+
adapter = self # Capture Adapter class for use in block
|
|
17
|
+
|
|
18
|
+
::MCP::Tool.define(
|
|
19
|
+
name: tool_name,
|
|
20
|
+
description: tool_description,
|
|
21
|
+
input_schema: normalize_schema(tool_schema)
|
|
22
|
+
) do |server_context:, **params|
|
|
23
|
+
adapter.execute_tool(captured_tool, params, server_context)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def execute_tool(tool, params, _server_context)
|
|
28
|
+
result = tool.call(**params.transform_keys(&:to_sym))
|
|
29
|
+
formatted = format_result(result)
|
|
30
|
+
|
|
31
|
+
::MCP::Tool::Response.new([
|
|
32
|
+
{ type: 'text', text: formatted }
|
|
33
|
+
])
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
::MCP::Tool::Response.new([
|
|
36
|
+
{ type: 'text', text: "Error: #{e.message}" }
|
|
37
|
+
], is_error: true)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def extract_name(tool)
|
|
43
|
+
if tool.class.respond_to?(:tool_def) && tool.class.tool_def
|
|
44
|
+
tool.class.tool_def.id
|
|
45
|
+
elsif tool.respond_to?(:name)
|
|
46
|
+
tool.name
|
|
47
|
+
else
|
|
48
|
+
tool.class.name&.split('::')&.last&.gsub(/([A-Z])/, '_\1')&.downcase&.sub(/^_/, '') || 'unknown'
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def normalize_schema(schema)
|
|
53
|
+
return { properties: {}, required: [] } if schema.nil? || schema.empty?
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
properties: Utils.fetch(schema, :properties, {}),
|
|
57
|
+
required: Utils.fetch(schema, :required, [])
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def format_result(result)
|
|
62
|
+
case result
|
|
63
|
+
when String then result
|
|
64
|
+
when Hash, Array then JSON.pretty_generate(result)
|
|
65
|
+
else result.to_s
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Extensions
|
|
5
|
+
module AI
|
|
6
|
+
module MCP
|
|
7
|
+
class Client
|
|
8
|
+
@connections = {}
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
# Get or create client for server config
|
|
12
|
+
def for(server_config)
|
|
13
|
+
cache_key = server_config.url || server_config.command.to_s
|
|
14
|
+
@connections[cache_key] ||= build_client(server_config)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# List tools from server
|
|
18
|
+
def tools(server_config)
|
|
19
|
+
client = self.for(server_config)
|
|
20
|
+
client.tools
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Call tool on server
|
|
24
|
+
def call_tool(server_config, tool_name, arguments)
|
|
25
|
+
client = self.for(server_config)
|
|
26
|
+
tool = client.tools.find { |t| t.name == tool_name }
|
|
27
|
+
raise DurableWorkflow::ExecutionError, "MCP tool not found: #{tool_name}" unless tool
|
|
28
|
+
|
|
29
|
+
client.call_tool(tool: tool, arguments: arguments)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Clear connection cache
|
|
33
|
+
def reset!
|
|
34
|
+
@connections = {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def build_client(server_config)
|
|
40
|
+
transport = build_transport(server_config)
|
|
41
|
+
::MCP::Client.new(transport: transport)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def build_transport(server_config)
|
|
45
|
+
case server_config.transport&.to_sym
|
|
46
|
+
when :stdio
|
|
47
|
+
build_stdio_transport(server_config)
|
|
48
|
+
else
|
|
49
|
+
build_http_transport(server_config)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def build_http_transport(config)
|
|
54
|
+
::MCP::Client::HTTP.new(
|
|
55
|
+
url: config.url,
|
|
56
|
+
headers: interpolate_env(config.headers || {})
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_stdio_transport(config)
|
|
61
|
+
# Stdio transport for command-line MCP servers
|
|
62
|
+
# This would require implementing or using a stdio transport
|
|
63
|
+
raise NotImplementedError, 'Stdio transport not yet implemented'
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Replace ${ENV_VAR} with actual values
|
|
67
|
+
def interpolate_env(headers)
|
|
68
|
+
headers.transform_values do |v|
|
|
69
|
+
v.to_s.gsub(/\$\{(\w+)\}/) { ENV.fetch(::Regexp.last_match(1), '') }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'rack'
|
|
5
|
+
|
|
6
|
+
module DurableWorkflow
|
|
7
|
+
module Extensions
|
|
8
|
+
module AI
|
|
9
|
+
module MCP
|
|
10
|
+
class RackApp
|
|
11
|
+
def initialize(server)
|
|
12
|
+
@server = server
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(env)
|
|
16
|
+
request = Rack::Request.new(env)
|
|
17
|
+
|
|
18
|
+
case request.request_method
|
|
19
|
+
when 'POST'
|
|
20
|
+
handle_post(request)
|
|
21
|
+
when 'GET'
|
|
22
|
+
handle_sse(request)
|
|
23
|
+
when 'DELETE'
|
|
24
|
+
handle_delete(request)
|
|
25
|
+
else
|
|
26
|
+
[405, { 'Content-Type' => 'text/plain' }, ['Method not allowed']]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def handle_post(request)
|
|
33
|
+
body = request.body.read
|
|
34
|
+
result = @server.handle_json(body)
|
|
35
|
+
|
|
36
|
+
[200, { 'Content-Type' => 'application/json' }, [result]]
|
|
37
|
+
rescue JSON::ParserError => e
|
|
38
|
+
error_response(-32_700, "Parse error: #{e.message}")
|
|
39
|
+
rescue StandardError
|
|
40
|
+
error_response(-32_603, 'Internal error')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def handle_sse(_request)
|
|
44
|
+
# SSE for notifications (optional)
|
|
45
|
+
[501, { 'Content-Type' => 'text/plain' }, ['SSE not implemented']]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def handle_delete(_request)
|
|
49
|
+
# Session cleanup
|
|
50
|
+
[200, {}, ['']]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def error_response(code, message)
|
|
54
|
+
[400, { 'Content-Type' => 'application/json' }, [
|
|
55
|
+
JSON.generate({
|
|
56
|
+
jsonrpc: '2.0',
|
|
57
|
+
error: { code: code, message: message },
|
|
58
|
+
id: nil
|
|
59
|
+
})
|
|
60
|
+
]]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Extensions
|
|
5
|
+
module AI
|
|
6
|
+
module MCP
|
|
7
|
+
class Server
|
|
8
|
+
attr_reader :workflow, :options
|
|
9
|
+
|
|
10
|
+
def initialize(workflow, **options)
|
|
11
|
+
@workflow = workflow
|
|
12
|
+
@options = options
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Build MCP::Server with workflow tools
|
|
16
|
+
def build(server_context: {})
|
|
17
|
+
::MCP::Server.new(
|
|
18
|
+
name: server_name,
|
|
19
|
+
version: server_version,
|
|
20
|
+
tools: build_tools,
|
|
21
|
+
server_context: server_context
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Run as stdio transport (for Claude Desktop)
|
|
26
|
+
def stdio(server_context: {})
|
|
27
|
+
require 'mcp/server/transports/stdio_transport'
|
|
28
|
+
server = build(server_context: server_context)
|
|
29
|
+
transport = ::MCP::Server::Transports::StdioTransport.new(server)
|
|
30
|
+
transport.open
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Build Rack app for HTTP transport
|
|
34
|
+
def rack_app(server_context: {})
|
|
35
|
+
server = build(server_context: server_context)
|
|
36
|
+
RackApp.new(server)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
def build(workflow, **options)
|
|
41
|
+
new(workflow, **options).build
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def stdio(workflow, **options)
|
|
45
|
+
new(workflow, **options).stdio
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def rack_app(workflow, **options)
|
|
49
|
+
new(workflow, **options).rack_app
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def server_name
|
|
56
|
+
options[:name] || "durable_workflow_#{workflow.id}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def server_version
|
|
60
|
+
options[:version] || DurableWorkflow::VERSION
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_tools
|
|
64
|
+
# Convert workflow tools to MCP tools
|
|
65
|
+
# for_workflow returns instances, not classes
|
|
66
|
+
mcp_tools = ToolRegistry.for_workflow(workflow).map do |tool_instance|
|
|
67
|
+
Adapter.to_mcp_tool(tool_instance)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Optionally expose workflow itself as a tool
|
|
71
|
+
mcp_tools << build_workflow_tool if options[:expose_workflow]
|
|
72
|
+
|
|
73
|
+
mcp_tools
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def build_workflow_tool
|
|
77
|
+
wf = workflow
|
|
78
|
+
store = DurableWorkflow.config&.store
|
|
79
|
+
|
|
80
|
+
::MCP::Tool.define(
|
|
81
|
+
name: "run_#{workflow.id}",
|
|
82
|
+
description: workflow.description || "Run #{workflow.name} workflow",
|
|
83
|
+
input_schema: workflow_input_schema
|
|
84
|
+
) do |server_context:, **params|
|
|
85
|
+
runner = DurableWorkflow::Runners::Sync.new(wf, store: store)
|
|
86
|
+
result = runner.run(params)
|
|
87
|
+
|
|
88
|
+
::MCP::Tool::Response.new([{
|
|
89
|
+
type: 'text',
|
|
90
|
+
text: JSON.pretty_generate({
|
|
91
|
+
status: result.status,
|
|
92
|
+
output: result.output
|
|
93
|
+
})
|
|
94
|
+
}])
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
warn "Workflow error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
|
|
97
|
+
::MCP::Tool::Response.new([{
|
|
98
|
+
type: 'text',
|
|
99
|
+
text: "Error: #{e.message}"
|
|
100
|
+
}], is_error: true)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def workflow_input_schema
|
|
105
|
+
props = {}
|
|
106
|
+
required = []
|
|
107
|
+
|
|
108
|
+
(workflow.inputs || []).each do |input_def|
|
|
109
|
+
props[input_def.name] = {
|
|
110
|
+
type: input_def.type,
|
|
111
|
+
description: input_def.description
|
|
112
|
+
}.compact
|
|
113
|
+
required << input_def.name if input_def.required
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
{ properties: props, required: required }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DurableWorkflow
|
|
4
|
+
module Extensions
|
|
5
|
+
module AI
|
|
6
|
+
class ToolRegistry
|
|
7
|
+
class << self
|
|
8
|
+
def registry
|
|
9
|
+
@registry ||= {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Register a RubyLLM::Tool class directly
|
|
13
|
+
def register(tool_class)
|
|
14
|
+
name = tool_name(tool_class)
|
|
15
|
+
registry[name] = tool_class
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Register from ToolDef (YAML-defined)
|
|
19
|
+
def register_from_def(tool_def)
|
|
20
|
+
tool_class = tool_def.to_ruby_llm_tool
|
|
21
|
+
registry[tool_def.id] = tool_class
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Get tool class by name
|
|
25
|
+
def [](name)
|
|
26
|
+
registry[name.to_s]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Get all tool classes
|
|
30
|
+
def all
|
|
31
|
+
registry.values
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Get tools for a workflow
|
|
35
|
+
def for_workflow(workflow)
|
|
36
|
+
tool_ids = Extension.data_from(workflow)[:tools]&.keys || []
|
|
37
|
+
for_tool_ids(tool_ids)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get tool instances by IDs
|
|
41
|
+
def for_tool_ids(tool_ids)
|
|
42
|
+
tool_ids.map { |id| registry[id.to_s]&.new }.compact
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Clear registry (for testing)
|
|
46
|
+
def reset!
|
|
47
|
+
@registry = {}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def tool_name(tool_class)
|
|
53
|
+
if tool_class.respond_to?(:tool_def) && tool_class.tool_def
|
|
54
|
+
tool_class.tool_def.id
|
|
55
|
+
else
|
|
56
|
+
tool_class.name&.split('::')&.last&.gsub(/([A-Z])/, '_\1')&.downcase&.sub(/^_/, '') || 'unknown'
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|