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,137 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Service Integration - Calling external services from workflow
5
+ #
6
+ # Demonstrates: Call step, service resolution, error handling
7
+ #
8
+ # Run: ruby examples/service_integration.rb
9
+ # Requires: Redis running on localhost:6379
10
+
11
+ require "bundler/setup"
12
+ require "securerandom"
13
+ require "durable_workflow"
14
+ require "durable_workflow/storage/redis"
15
+
16
+ DurableWorkflow.configure do |c|
17
+ c.store = DurableWorkflow::Storage::Redis.new(url: "redis://localhost:6379")
18
+ end
19
+
20
+ # Inventory service (must be globally accessible constant)
21
+ module InventoryService
22
+ STOCK = {
23
+ "PROD-001" => 50,
24
+ "PROD-002" => 0,
25
+ "PROD-003" => 10
26
+ }
27
+
28
+ def self.check_availability(product_id:, quantity:)
29
+ available = STOCK.fetch(product_id, 0)
30
+ {
31
+ product_id: product_id,
32
+ requested: quantity,
33
+ available: available,
34
+ in_stock: available >= quantity
35
+ }
36
+ end
37
+
38
+ def self.reserve(product_id:, quantity:)
39
+ current = STOCK.fetch(product_id, 0)
40
+ raise "Insufficient stock" if current < quantity
41
+
42
+ STOCK[product_id] = current - quantity
43
+ {
44
+ reservation_id: "RES-#{SecureRandom.hex(4)}",
45
+ product_id: product_id,
46
+ quantity: quantity,
47
+ remaining: STOCK[product_id]
48
+ }
49
+ end
50
+ end
51
+
52
+ workflow = DurableWorkflow::Core::Parser.parse(<<~YAML)
53
+ id: inventory_check
54
+ name: Inventory Check and Reserve
55
+ version: "1.0"
56
+
57
+ inputs:
58
+ product_id:
59
+ type: string
60
+ required: true
61
+ quantity:
62
+ type: integer
63
+ required: true
64
+
65
+ steps:
66
+ - id: start
67
+ type: start
68
+ next: check
69
+
70
+ - id: check
71
+ type: call
72
+ service: InventoryService
73
+ method: check_availability
74
+ input:
75
+ product_id: "$input.product_id"
76
+ quantity: "$input.quantity"
77
+ output: availability
78
+ next: decide
79
+
80
+ - id: decide
81
+ type: router
82
+ routes:
83
+ - when:
84
+ field: availability.in_stock
85
+ op: eq
86
+ value: true
87
+ then: reserve
88
+ default: out_of_stock
89
+
90
+ - id: reserve
91
+ type: call
92
+ service: InventoryService
93
+ method: reserve
94
+ input:
95
+ product_id: "$input.product_id"
96
+ quantity: "$input.quantity"
97
+ output: reservation
98
+ next: success
99
+
100
+ - id: success
101
+ type: assign
102
+ set:
103
+ status: reserved
104
+ next: end
105
+
106
+ - id: out_of_stock
107
+ type: assign
108
+ set:
109
+ status: out_of_stock
110
+ error: "Insufficient stock available"
111
+ next: end
112
+
113
+ - id: end
114
+ type: end
115
+ result:
116
+ status: "$status"
117
+ availability: "$availability"
118
+ reservation: "$reservation"
119
+ error: "$error"
120
+ YAML
121
+
122
+ runner = DurableWorkflow::Runners::Sync.new(workflow)
123
+
124
+ # Available product
125
+ result = runner.run(input: { product_id: "PROD-001", quantity: 5 })
126
+ puts "PROD-001 (qty 5): #{result.output[:status]}"
127
+ puts " Reservation: #{result.output[:reservation][:reservation_id]}" if result.output[:reservation]
128
+
129
+ # Out of stock
130
+ result = runner.run(input: { product_id: "PROD-002", quantity: 1 })
131
+ puts "\nPROD-002 (qty 1): #{result.output[:status]}"
132
+ puts " Error: #{result.output[:error]}"
133
+
134
+ # Partial availability
135
+ result = runner.run(input: { product_id: "PROD-003", quantity: 20 })
136
+ puts "\nPROD-003 (qty 20): #{result.output[:status]}"
137
+ puts " Error: #{result.output[:error]}"
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "durable_workflow", path: "../.."
6
+ gem "redis"
@@ -0,0 +1,91 @@
1
+ # Support Agent
2
+
3
+ AI-powered customer support workflow with tool calling and MCP integration.
4
+
5
+ ## Features
6
+
7
+ - Multi-agent system (triage, billing, technical)
8
+ - Tool integration (lookup orders, create tickets, check status)
9
+ - Handoffs between specialized agents
10
+ - Content moderation guardrails
11
+ - MCP server for Claude Desktop integration
12
+
13
+ ## Setup
14
+
15
+ ```bash
16
+ cd examples/support_agent
17
+ bundle install
18
+
19
+ # Set API key
20
+ export OPENAI_API_KEY=your-key
21
+ # or
22
+ export ANTHROPIC_API_KEY=your-key
23
+
24
+ # Start Redis
25
+ redis-server
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ### Interactive CLI
31
+
32
+ ```bash
33
+ ruby run.rb
34
+ ```
35
+
36
+ ### As MCP Server (Claude Desktop)
37
+
38
+ 1. Copy config to Claude Desktop:
39
+ ```bash
40
+ cp config/claude_desktop.json ~/.config/claude/claude_desktop_config.json
41
+ ```
42
+
43
+ 2. Restart Claude Desktop
44
+
45
+ 3. The support tools will be available in Claude
46
+
47
+ ## Architecture
48
+
49
+ ```
50
+ User Input
51
+
52
+ ┌─────────────┐
53
+ │ Triage │ ← Classifies request
54
+ │ Agent │
55
+ └─────────────┘
56
+
57
+ ┌─────────────────────────────────┐
58
+ │ Router │
59
+ ├─────────────┬─────────┬─────────┤
60
+ │ Billing │ Tech │ Other │
61
+ │ Agent │ Agent │ │
62
+ └─────────────┴─────────┴─────────┘
63
+
64
+ Tools: lookup_order, create_ticket, check_status, escalate
65
+ ```
66
+
67
+ ## Tools Available
68
+
69
+ | Tool | Description |
70
+ |------|-------------|
71
+ | `lookup_order` | Find order by ID or customer email |
72
+ | `create_ticket` | Create support ticket |
73
+ | `check_status` | Check ticket status |
74
+ | `escalate` | Escalate to human agent |
75
+ | `refund_order` | Process refund (billing only) |
76
+ | `reset_password` | Reset user password (tech only) |
77
+
78
+ ## MCP Integration
79
+
80
+ When running as MCP server, external AI agents can:
81
+
82
+ 1. Discover available tools via `tools/list`
83
+ 2. Call tools via `tools/call`
84
+ 3. Run the full workflow as a tool
85
+
86
+ Example Claude Desktop interaction:
87
+ ```
88
+ User: "I need help with order ORD-12345"
89
+ Claude: [calls lookup_order with order_id: "ORD-12345"]
90
+ Claude: "I found your order. It was placed on..."
91
+ ```
@@ -0,0 +1,12 @@
1
+ {
2
+ "mcpServers": {
3
+ "support_agent": {
4
+ "command": "ruby",
5
+ "args": ["examples/support_agent/mcp_server.rb"],
6
+ "cwd": "/path/to/durable_workflow",
7
+ "env": {
8
+ "OPENAI_API_KEY": "your-key-here"
9
+ }
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # MCP Server for Claude Desktop integration
5
+ #
6
+ # Add to ~/.config/claude/claude_desktop_config.json:
7
+ # {
8
+ # "mcpServers": {
9
+ # "support_agent": {
10
+ # "command": "ruby",
11
+ # "args": ["examples/support_agent/mcp_server.rb"],
12
+ # "cwd": "/path/to/durable_workflow"
13
+ # }
14
+ # }
15
+ # }
16
+
17
+ require "bundler/setup"
18
+ require "dotenv/load" if File.exist?(File.expand_path("../../.env", __dir__))
19
+ require "durable_workflow"
20
+ require "durable_workflow/extensions/ai"
21
+ require "durable_workflow/storage/redis"
22
+ require_relative "services"
23
+
24
+ # Suppress stdout logging (corrupts MCP protocol)
25
+ $stderr = File.open("/tmp/support_agent_mcp.log", "a")
26
+
27
+ # Configure
28
+ DurableWorkflow.configure do |c|
29
+ c.store = DurableWorkflow::Storage::Redis.new(url: "redis://localhost:6379")
30
+ c.logger = Logger.new("/dev/null")
31
+ end
32
+
33
+ DurableWorkflow::Extensions::AI.configure do |c|
34
+ c.api_keys[:openai] = ENV["OPENAI_API_KEY"]
35
+ c.api_keys[:anthropic] = ENV["ANTHROPIC_API_KEY"]
36
+ end
37
+
38
+ # Services auto-resolved via Object.const_get
39
+
40
+ # Load and register workflow
41
+ workflow = DurableWorkflow.load(File.join(__dir__, "workflow.yml"))
42
+ DurableWorkflow.register(workflow)
43
+
44
+ # Run MCP server with workflow tools + workflow itself exposed
45
+ DurableWorkflow::Extensions::AI::MCP::Server.stdio(
46
+ workflow,
47
+ name: "support_agent",
48
+ expose_workflow: true # Makes "run_support_agent" available as a tool
49
+ )
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "dotenv/load" if File.exist?(File.expand_path("../../.env", __dir__))
6
+ require "durable_workflow"
7
+ require "durable_workflow/extensions/ai"
8
+ require "durable_workflow/storage/redis"
9
+ require_relative "services"
10
+
11
+ # Configure
12
+ DurableWorkflow.configure do |c|
13
+ c.store = DurableWorkflow::Storage::Redis.new(url: "redis://localhost:6379")
14
+ end
15
+
16
+ DurableWorkflow::Extensions::AI.configure do |c|
17
+ c.api_keys[:openai] = ENV["OPENAI_API_KEY"]
18
+ c.api_keys[:anthropic] = ENV["ANTHROPIC_API_KEY"]
19
+ c.default_model = "gpt-4o-mini"
20
+ end
21
+
22
+ # Services are auto-resolved via Object.const_get (SupportServices is a global module)
23
+
24
+ # Load and register workflow
25
+ workflow = DurableWorkflow.load(File.join(__dir__, "workflow.yml"))
26
+ DurableWorkflow.register(workflow)
27
+ runner = DurableWorkflow::Runners::Stream.new(workflow)
28
+
29
+ # Subscribe to events for visibility
30
+ runner.subscribe do |event|
31
+ case event.type
32
+ when "step.started"
33
+ puts " [#{event.data[:step_id]}]" if ENV["DEBUG"]
34
+ when "agent.tool_use"
35
+ puts " -> Tool: #{event.data[:tool]} #{event.data[:arguments]}"
36
+ end
37
+ end
38
+
39
+ puts "=" * 60
40
+ puts "Customer Support Agent"
41
+ puts "=" * 60
42
+ puts "Type 'quit' to exit"
43
+ puts
44
+
45
+ loop do
46
+ print "\nYou: "
47
+ input = gets&.chomp
48
+ break if input.nil? || input.downcase == "quit"
49
+ next if input.empty?
50
+
51
+ begin
52
+ result = runner.run(input: {
53
+ message: input,
54
+ customer_id: "CUST-001"
55
+ })
56
+
57
+ puts "\nAgent: #{result.output[:response]}"
58
+ if result.output[:triage].is_a?(Hash)
59
+ puts " [Category: #{result.output[:triage][:category]}, Urgency: #{result.output[:triage][:urgency]}]"
60
+ end
61
+ rescue => e
62
+ puts "\nError: #{e.message}"
63
+ puts e.backtrace.first(3).join("\n") if ENV["DEBUG"]
64
+ end
65
+ end
66
+
67
+ puts "\nGoodbye!"
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ # Mock services for support agent demo
6
+ module SupportServices
7
+ ORDERS = {
8
+ "ORD-12345" => {
9
+ id: "ORD-12345",
10
+ customer_email: "alice@example.com",
11
+ status: "shipped",
12
+ total: 149.99,
13
+ items: [
14
+ { name: "Wireless Headphones", quantity: 1, price: 149.99 }
15
+ ],
16
+ created_at: "2024-01-10",
17
+ tracking: "1Z999AA10123456784"
18
+ },
19
+ "ORD-67890" => {
20
+ id: "ORD-67890",
21
+ customer_email: "bob@example.com",
22
+ status: "delivered",
23
+ total: 299.99,
24
+ items: [
25
+ { name: "Smart Watch", quantity: 1, price: 299.99 }
26
+ ],
27
+ created_at: "2024-01-05",
28
+ delivered_at: "2024-01-08"
29
+ }
30
+ }
31
+
32
+ TICKETS = {}
33
+
34
+ class << self
35
+ def classify_request(category:, urgency:, summary:)
36
+ {
37
+ category: category,
38
+ urgency: urgency,
39
+ summary: summary,
40
+ classified_at: Time.now.iso8601
41
+ }
42
+ end
43
+
44
+ def lookup_order(order_id: nil, email: nil)
45
+ if order_id
46
+ order = ORDERS[order_id]
47
+ return { error: "Order not found: #{order_id}" } unless order
48
+ order
49
+ elsif email
50
+ orders = ORDERS.values.select { |o| o[:customer_email] == email }
51
+ return { error: "No orders found for #{email}" } if orders.empty?
52
+ { orders: orders, count: orders.size }
53
+ else
54
+ { error: "Please provide order_id or email" }
55
+ end
56
+ end
57
+
58
+ def refund_order(order_id:, reason:, amount: nil)
59
+ order = ORDERS[order_id]
60
+ return { error: "Order not found: #{order_id}" } unless order
61
+
62
+ refund_amount = amount || order[:total]
63
+ {
64
+ refund_id: "REF-#{SecureRandom.hex(4).upcase}",
65
+ order_id: order_id,
66
+ amount: refund_amount,
67
+ reason: reason,
68
+ status: "processed",
69
+ processed_at: Time.now.iso8601
70
+ }
71
+ end
72
+
73
+ def create_ticket(subject:, description:, priority: "medium")
74
+ ticket_id = "TKT-#{SecureRandom.hex(4).upcase}"
75
+ ticket = {
76
+ id: ticket_id,
77
+ subject: subject,
78
+ description: description,
79
+ priority: priority,
80
+ status: "open",
81
+ created_at: Time.now.iso8601
82
+ }
83
+ TICKETS[ticket_id] = ticket
84
+ ticket
85
+ end
86
+
87
+ def check_status(ticket_id:)
88
+ ticket = TICKETS[ticket_id]
89
+ return { error: "Ticket not found: #{ticket_id}" } unless ticket
90
+ ticket
91
+ end
92
+
93
+ def reset_password(email:)
94
+ {
95
+ email: email,
96
+ reset_sent: true,
97
+ message: "Password reset email sent to #{email}",
98
+ expires_in: "24 hours"
99
+ }
100
+ end
101
+
102
+ def escalate(reason:, urgency:)
103
+ {
104
+ escalation_id: "ESC-#{SecureRandom.hex(4).upcase}",
105
+ reason: reason,
106
+ urgency: urgency,
107
+ status: "pending_human_review",
108
+ estimated_response: urgency == "high" ? "1 hour" : "4 hours",
109
+ created_at: Time.now.iso8601
110
+ }
111
+ end
112
+ end
113
+ end