ask-graph 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/CHANGELOG.md +36 -0
- data/LICENSE +21 -0
- data/README.md +202 -0
- data/lib/ask/graph/context.rb +104 -0
- data/lib/ask/graph/runner.rb +162 -0
- data/lib/ask/graph/version.rb +7 -0
- data/lib/ask/graph.rb +148 -0
- data/lib/ask-graph.rb +4 -0
- metadata +109 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2bcc4cd34c3c6b401b4a5e6e47ea28bfeb8fcfe6cc117c46230ddf58df6249f8
|
|
4
|
+
data.tar.gz: 20bec4635a80dd53b1b497c38985327efcef79a7acace61d8725fac85842e2ce
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a122a01aa42024e4be602f8fe859e0b3148867c248e36d434e86b63ad62561b53ea081cc1903c033d6aa603b89e232d981b94fda01b1f6fcf09356b31ef801eb
|
|
7
|
+
data.tar.gz: 96d289a9595599a663da22cef7357af63dc74dde4a6c399589eea339441c5a32f7b49ff77f82bfbf1232e1f548b6c3ae85aa0dd4bd2b6e430ff26a3d689d28fd
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
## [0.1.0] — 2026-07-28
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Initial release** — Durable workflow graphs for the ask-rb ecosystem.
|
|
6
|
+
|
|
7
|
+
- **Graph definition** — Define workflows with declarative steps:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class HandleCall < Ask::Graph
|
|
11
|
+
step Transcribe
|
|
12
|
+
step Classify, if: :needs_classification?
|
|
13
|
+
step BookAppointment
|
|
14
|
+
end
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- **Conditional steps** — `step ClassName, if: :method?` and `step ClassName, unless: :method?` — methods on the graph class control whether a step runs.
|
|
18
|
+
|
|
19
|
+
- **Parallel execution** — `steps ClassA, ClassB, ClassC` runs multiple steps simultaneously and waits for all to complete before continuing.
|
|
20
|
+
|
|
21
|
+
- **Human-in-the-loop** — `approve ReviewBooking` runs a step then pauses execution, persisting state and waiting for external input via `resume`.
|
|
22
|
+
|
|
23
|
+
- **Per-item checkpointing** — `context.each(items)` iterates with automatic checkpointing after each item. Crashes resume from the last completed item, not the beginning.
|
|
24
|
+
|
|
25
|
+
- **Step checkpointing** — every completed step is persisted via an optional store. Crashes resume from the last completed step.
|
|
26
|
+
|
|
27
|
+
- **Step failure handling** — failed steps raise `Ask::Graph::StepFailed` with the step name in the message.
|
|
28
|
+
|
|
29
|
+
- **Inheritance** — child graphs inherit parent steps without affecting the parent.
|
|
30
|
+
|
|
31
|
+
- **Class and instance execution** — `Graph.run` (class method) or `Graph.new.run` (instance method).
|
|
32
|
+
|
|
33
|
+
### Tested
|
|
34
|
+
|
|
35
|
+
- 31 tests, 42 assertions, 0 failures
|
|
36
|
+
- Covers: step order, conditions, parallel, approve, checkpointing, context, inheritance, edge cases
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaka Ruto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# ask-graph
|
|
2
|
+
|
|
3
|
+
**Durable workflow graphs for the ask-rb ecosystem.** Define workflows as graphs of named steps with conditional routing, parallel execution, human-in-the-loop approval, per-item checkpointing loops, and sub-graph composition. Each step is a plain Ruby class.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
gem "ask-graph"
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
require "ask-graph"
|
|
13
|
+
|
|
14
|
+
# Define a workflow
|
|
15
|
+
class ProcessOrder < Ask::Graph
|
|
16
|
+
step ValidateOrder
|
|
17
|
+
step ChargeCustomer, if: :valid?
|
|
18
|
+
step SendConfirmation, if: :valid?
|
|
19
|
+
step NotifyAdmin, unless: :valid?
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def valid?
|
|
24
|
+
context.order.valid?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Run it
|
|
29
|
+
result = ProcessOrder.run(order: order)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Steps
|
|
33
|
+
|
|
34
|
+
Steps are plain Ruby classes with a `call(context)` method:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
class ValidateOrder
|
|
38
|
+
def call(context)
|
|
39
|
+
context.order = OrderValidator.validate(context.order)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Sequential steps
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
class HandleCall < Ask::Graph
|
|
48
|
+
step Transcribe
|
|
49
|
+
step Classify
|
|
50
|
+
step Respond
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Conditional steps
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
class HandleCall < Ask::Graph
|
|
58
|
+
step BookAppointment, if: :booking?
|
|
59
|
+
step EmergencyAlert, if: :emergency?
|
|
60
|
+
step HandleInquiry, unless: :known_intent?
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def booking? = context.intent == "booking"
|
|
65
|
+
def emergency? = context.intent == "emergency"
|
|
66
|
+
def known_intent? = %w[booking emergency inquiry].include?(context.intent)
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Parallel steps
|
|
71
|
+
|
|
72
|
+
Use `steps` (plural) to run multiple steps simultaneously:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
class SyncData < Ask::Graph
|
|
76
|
+
step FetchRecords
|
|
77
|
+
|
|
78
|
+
# All three run in parallel
|
|
79
|
+
steps CrmUpdate, CalendarSync, SendNotification
|
|
80
|
+
|
|
81
|
+
step ConfirmResponse
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Human-in-the-loop (approve)
|
|
86
|
+
|
|
87
|
+
`approve` runs a step, then pauses the workflow and waits for external input:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
class ProcessBooking < Ask::Graph
|
|
91
|
+
step BookAppointment
|
|
92
|
+
approve ReviewBooking, if: :expensive?
|
|
93
|
+
step ConfirmBooking
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
After `approve` pauses, resume with:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
graph = ProcessBooking.new
|
|
101
|
+
result = graph.run # runs, pauses after ReviewBooking
|
|
102
|
+
result = graph.resume(input: "approved") # resumes, runs ConfirmBooking
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Per-item loops
|
|
106
|
+
|
|
107
|
+
Use `context.each` inside a step for iteration with automatic checkpointing:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
class SendVoiceReminders
|
|
111
|
+
def call(context)
|
|
112
|
+
context.each(context.appointments) do |appt|
|
|
113
|
+
PhoneService.call(appt.number, appt.message)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
class DailyReminders < Ask::Graph
|
|
119
|
+
step FetchAppointments
|
|
120
|
+
step SendVoiceReminders # loops with per-item checkpointing
|
|
121
|
+
step MarkComplete
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
If the process crashes mid-loop, it resumes from the last completed item.
|
|
126
|
+
|
|
127
|
+
### Context
|
|
128
|
+
|
|
129
|
+
Shared state flows between steps via `context`:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
class SetValue
|
|
133
|
+
def call(context)
|
|
134
|
+
context.greeting = "hello" # set via method
|
|
135
|
+
context[:color] = "blue" # set via bracket
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
class ReadValue
|
|
140
|
+
def call(context)
|
|
141
|
+
puts context.greeting # => "hello"
|
|
142
|
+
puts context[:color] # => "blue"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Sub-workflows
|
|
148
|
+
|
|
149
|
+
A step can delegate to another workflow:
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
class IntentHandler
|
|
153
|
+
def call(context)
|
|
154
|
+
case context.intent
|
|
155
|
+
when "booking"
|
|
156
|
+
BookingWorkflow.new.call(context) # sub-workflow as a step
|
|
157
|
+
when "emergency"
|
|
158
|
+
EmergencyWorkflow.new.call(context)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Checkpointing
|
|
165
|
+
|
|
166
|
+
Pass a checkpoint store to make workflows durable across crashes:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
store = Ask::State::Memory.new # or Redis, SQLite, etc.
|
|
170
|
+
|
|
171
|
+
# Runs through all steps, saving after each
|
|
172
|
+
result = MyWorkflow.run(input, checkpoint_store: store)
|
|
173
|
+
|
|
174
|
+
# If a crash occurs, resume from the last completed step
|
|
175
|
+
result = MyWorkflow.run(input, checkpoint_store: store)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Error handling
|
|
179
|
+
|
|
180
|
+
Failed steps raise `Ask::Graph::StepFailed` with the step name:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
class MyGraph < Ask::Graph
|
|
184
|
+
step RiskyOperation
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
begin
|
|
188
|
+
MyGraph.run
|
|
189
|
+
rescue Ask::Graph::StepFailed => e
|
|
190
|
+
puts e.message # => "RiskyOperation failed: connection timeout"
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Development
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
bundle exec rake test
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
MIT
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
class Graph
|
|
5
|
+
# Shared state that flows through every step of a graph execution.
|
|
6
|
+
#
|
|
7
|
+
# Stores input, output, and intermediate data produced by steps.
|
|
8
|
+
# Accessed by step classes via +context.data_key+ or +context[:data_key]+.
|
|
9
|
+
# Thread-safe by design — each context is scoped to one graph run.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# class MyStep
|
|
13
|
+
# def call(context)
|
|
14
|
+
# context.transcript = "Hello"
|
|
15
|
+
# context[:classification] = "booking"
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
class Context
|
|
20
|
+
# @return [Ask::Graph] the graph this context belongs to
|
|
21
|
+
attr_reader :graph
|
|
22
|
+
|
|
23
|
+
# @return [Object, nil] the current item when iterating via {#each}
|
|
24
|
+
attr_reader :item
|
|
25
|
+
|
|
26
|
+
def initialize(graph, input = nil)
|
|
27
|
+
@graph = graph
|
|
28
|
+
@store = {}
|
|
29
|
+
@each_index = nil
|
|
30
|
+
@each_items = nil
|
|
31
|
+
@item = nil
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
@store[:input] = input if input
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Access a stored value by method name.
|
|
37
|
+
def method_missing(name, *args, &block)
|
|
38
|
+
if name.to_s.end_with?("=")
|
|
39
|
+
key = name.to_s.chomp("=").to_sym
|
|
40
|
+
@mutex.synchronize { @store[key] = args.first }
|
|
41
|
+
elsif args.empty? && !block
|
|
42
|
+
@mutex.synchronize { @store[name] }
|
|
43
|
+
else
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def respond_to_missing?(name, include_private = false)
|
|
49
|
+
clean = name.to_s.sub(/=$/, "").to_sym
|
|
50
|
+
@store.key?(clean) || super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Access a stored value by key.
|
|
54
|
+
# @param key [Symbol] the key
|
|
55
|
+
# @return [Object, nil]
|
|
56
|
+
def [](key)
|
|
57
|
+
@mutex.synchronize { @store[key.to_sym] }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Store a value by key.
|
|
61
|
+
# @param key [Symbol] the key
|
|
62
|
+
# @param value [Object] the value
|
|
63
|
+
def []=(key, value)
|
|
64
|
+
@mutex.synchronize { @store[key.to_sym] = value }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Iterate over items with per-item checkpointing.
|
|
68
|
+
#
|
|
69
|
+
# Each iteration is checkpointed so that if the process crashes,
|
|
70
|
+
# it resumes from the last completed item rather than starting over.
|
|
71
|
+
#
|
|
72
|
+
# @param items [Array] the items to iterate over
|
|
73
|
+
# @yield [item] called once per item
|
|
74
|
+
# @yieldparam item [Object] the current item
|
|
75
|
+
def each(items, &block)
|
|
76
|
+
@each_index = graph.runner.resume_index_for(items) || 0
|
|
77
|
+
@each_items = items
|
|
78
|
+
|
|
79
|
+
items.each_with_index do |item, idx|
|
|
80
|
+
break if idx < @each_index
|
|
81
|
+
|
|
82
|
+
@item = item
|
|
83
|
+
block.call(item)
|
|
84
|
+
graph.runner.checkpoint_each!(idx)
|
|
85
|
+
end
|
|
86
|
+
ensure
|
|
87
|
+
@each_index = nil
|
|
88
|
+
@each_items = nil
|
|
89
|
+
@item = nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Hash] serializable snapshot of the context state
|
|
93
|
+
def to_h
|
|
94
|
+
@mutex.synchronize do
|
|
95
|
+
serializable = {}
|
|
96
|
+
@store.each do |k, v|
|
|
97
|
+
serializable[k] = v.respond_to?(:to_h) ? v.to_h : v
|
|
98
|
+
end
|
|
99
|
+
serializable
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
class Graph
|
|
7
|
+
# Error raised when a step fails.
|
|
8
|
+
class StepFailed < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Error raised when a step pauses for human approval.
|
|
11
|
+
class Paused < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Executes the declared steps of a graph, managing checkpointing,
|
|
14
|
+
# condition evaluation, parallel execution, and human-in-the-loop pauses.
|
|
15
|
+
class Runner
|
|
16
|
+
# @return [Array<Hash>] step declarations with name, type, condition
|
|
17
|
+
attr_reader :declarations
|
|
18
|
+
|
|
19
|
+
# @param declarations [Array<Hash>] step declarations
|
|
20
|
+
# @param checkpoint_store [#set, #get, #delete] optional persistent store
|
|
21
|
+
def initialize(declarations, checkpoint_store: nil)
|
|
22
|
+
@declarations = declarations
|
|
23
|
+
@store = checkpoint_store
|
|
24
|
+
@completed_steps = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Run the graph from the beginning or resume from the last checkpoint.
|
|
28
|
+
# @param context [Ask::Graph::Context] the shared context
|
|
29
|
+
# @return [Ask::Graph::Context] the completed context
|
|
30
|
+
def run(context)
|
|
31
|
+
resume_index = load_checkpoint
|
|
32
|
+
|
|
33
|
+
@declarations.each_with_index do |decl, idx|
|
|
34
|
+
next if resume_index && idx <= resume_index
|
|
35
|
+
|
|
36
|
+
unless condition_met?(decl, context)
|
|
37
|
+
record_completion(idx, :skipped)
|
|
38
|
+
next
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
execute_step(decl, context)
|
|
42
|
+
record_completion(idx, :completed)
|
|
43
|
+
save_checkpoint(idx)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
context
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Resume a paused graph with human input.
|
|
50
|
+
# @param context [Ask::Graph::Context] the context at pause point
|
|
51
|
+
# @param input [Object] the human's input
|
|
52
|
+
# @return [Ask::Graph::Context] the completed context
|
|
53
|
+
def resume(context, input:)
|
|
54
|
+
context.resume_input = input
|
|
55
|
+
run(context)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Get the index to resume from for an {Context#each} iteration.
|
|
59
|
+
# @param items [Array] the full list of items
|
|
60
|
+
# @return [Integer, nil] the index to resume from, or nil
|
|
61
|
+
def resume_index_for(items)
|
|
62
|
+
key = each_checkpoint_key
|
|
63
|
+
raw = @store&.get(key)
|
|
64
|
+
raw ? raw.to_i : nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Checkpoint after a single iteration of {Context#each}.
|
|
68
|
+
# @param index [Integer] the completed iteration index
|
|
69
|
+
def checkpoint_each!(index)
|
|
70
|
+
key = each_checkpoint_key
|
|
71
|
+
@store&.set(key, index.to_s)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def execute_step(decl, context)
|
|
77
|
+
case decl[:type]
|
|
78
|
+
when :step
|
|
79
|
+
run_single(decl[:class], decl[:name], context)
|
|
80
|
+
when :steps
|
|
81
|
+
run_parallel(decl[:classes], decl[:name], context)
|
|
82
|
+
when :approve
|
|
83
|
+
run_approve(decl[:class], decl[:name], context)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def run_single(klass, _name, context)
|
|
88
|
+
instance = klass.new
|
|
89
|
+
instance.call(context)
|
|
90
|
+
rescue Paused
|
|
91
|
+
raise
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
raise StepFailed, "#{klass.name} failed: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def run_parallel(classes, _name, context)
|
|
97
|
+
threads = classes.map do |klass|
|
|
98
|
+
Thread.new do
|
|
99
|
+
begin
|
|
100
|
+
instance = klass.new
|
|
101
|
+
instance.call(context)
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
raise StepFailed, "#{klass.name} failed: #{e.message}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
threads.each(&:join)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def run_approve(klass, _name, context)
|
|
111
|
+
instance = klass.new
|
|
112
|
+
instance.call(context)
|
|
113
|
+
# After the step runs, pause and wait for human input
|
|
114
|
+
save_checkpoint(current_index)
|
|
115
|
+
raise Paused, "Graph paused for approval after #{klass.name}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def condition_met?(decl, context)
|
|
119
|
+
if decl[:if]
|
|
120
|
+
context.graph.send(decl[:if])
|
|
121
|
+
elsif decl[:unless]
|
|
122
|
+
!context.graph.send(decl[:unless])
|
|
123
|
+
else
|
|
124
|
+
true
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def record_completion(idx, status)
|
|
129
|
+
@completed_steps << { index: idx, status: status }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# --- Checkpointing ---
|
|
133
|
+
|
|
134
|
+
RUN_KEY = "ask:graph:run:%s"
|
|
135
|
+
|
|
136
|
+
def checkpoint_key
|
|
137
|
+
RUN_KEY % @declarations.object_id.to_s
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def each_checkpoint_key
|
|
141
|
+
"#{checkpoint_key}:each"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def save_checkpoint(idx)
|
|
145
|
+
data = { completed_index: idx, timestamp: Time.now.iso8601 }
|
|
146
|
+
@store&.set(checkpoint_key, JSON.generate(data))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def load_checkpoint
|
|
150
|
+
raw = @store&.get(checkpoint_key)
|
|
151
|
+
return nil unless raw
|
|
152
|
+
|
|
153
|
+
data = JSON.parse(raw)
|
|
154
|
+
data["completed_index"]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def current_index
|
|
158
|
+
@completed_steps.size
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
data/lib/ask/graph.rb
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "graph/version"
|
|
4
|
+
require_relative "graph/context"
|
|
5
|
+
require_relative "graph/runner"
|
|
6
|
+
|
|
7
|
+
module Ask
|
|
8
|
+
# Define durable workflow graphs with named steps, conditional routing,
|
|
9
|
+
# parallel execution, human-in-the-loop approval, and per-item
|
|
10
|
+
# checkpointing loops.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic workflow
|
|
13
|
+
# class HandleCall < Ask::Graph
|
|
14
|
+
# step Transcribe
|
|
15
|
+
# step Classify, if: :needs_classification?
|
|
16
|
+
# step BookAppointment
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# @example With parallel steps
|
|
20
|
+
# class SyncData < Ask::Graph
|
|
21
|
+
# step FetchRecords
|
|
22
|
+
#
|
|
23
|
+
# steps CrmUpdate, CalendarSync, SendNotification
|
|
24
|
+
#
|
|
25
|
+
# step ConfirmResponse
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @example With human-in-the-loop
|
|
29
|
+
# class ProcessBooking < Ask::Graph
|
|
30
|
+
# step BookAppointment
|
|
31
|
+
# approve ReviewBooking, if: :expensive?
|
|
32
|
+
# step ConfirmBooking
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# @example With per-item looping
|
|
36
|
+
# class SendReminders < Ask::Graph
|
|
37
|
+
# step FetchAppointments
|
|
38
|
+
# step RemindEach # calls context.each(:appointments) internally
|
|
39
|
+
# step MarkComplete
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
class Graph
|
|
43
|
+
class << self
|
|
44
|
+
# All step declarations collected across the class hierarchy.
|
|
45
|
+
def declarations
|
|
46
|
+
@declarations ||= []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Inherited hook — copies declarations to subclasses.
|
|
50
|
+
def inherited(subclass)
|
|
51
|
+
super
|
|
52
|
+
subclass.instance_variable_set(:@declarations, declarations.dup)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Declare a sequential step.
|
|
56
|
+
#
|
|
57
|
+
# @param klass [Class] a class that responds to +#call(context)+
|
|
58
|
+
# @param conditions [Hash] optional +if:+ or +unless:+ condition method name
|
|
59
|
+
def step(klass, **conditions)
|
|
60
|
+
declarations << {
|
|
61
|
+
class: klass,
|
|
62
|
+
type: :step,
|
|
63
|
+
name: klass.name || klass.to_s,
|
|
64
|
+
if: conditions[:if],
|
|
65
|
+
unless: conditions[:unless]
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Declare parallel steps — all run simultaneously.
|
|
70
|
+
#
|
|
71
|
+
# @param klasses [Array<Class>] step classes to run in parallel
|
|
72
|
+
# @param conditions [Hash] optional +if:+ or +unless:+ condition method name
|
|
73
|
+
def steps(*klasses, **conditions)
|
|
74
|
+
declarations << {
|
|
75
|
+
classes: klasses,
|
|
76
|
+
type: :steps,
|
|
77
|
+
name: klasses.map { |k| k.name || k.to_s }.join(", "),
|
|
78
|
+
if: conditions[:if],
|
|
79
|
+
unless: conditions[:unless]
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Declare a human-in-the-loop step. Runs the step, then pauses
|
|
84
|
+
# and waits for external input via {Ask::Graph#resume}.
|
|
85
|
+
#
|
|
86
|
+
# @param klass [Class] a class that responds to +#call(context)+
|
|
87
|
+
# @param conditions [Hash] optional +if:+ or +unless:+ condition method name
|
|
88
|
+
def approve(klass, **conditions)
|
|
89
|
+
declarations << {
|
|
90
|
+
class: klass,
|
|
91
|
+
type: :approve,
|
|
92
|
+
name: klass.name || klass.to_s,
|
|
93
|
+
if: conditions[:if],
|
|
94
|
+
unless: conditions[:unless]
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Create a new instance and run the graph.
|
|
99
|
+
# @param input [Object] optional initial input
|
|
100
|
+
# @param checkpoint_store [#set, #get, #delete] optional persistent store
|
|
101
|
+
# @return [Ask::Graph::Context] the completed context
|
|
102
|
+
def run(input = nil, checkpoint_store: nil)
|
|
103
|
+
new(checkpoint_store: checkpoint_store).run(input)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Resume a paused graph with human input.
|
|
107
|
+
# @param input [Object] the human's input
|
|
108
|
+
# @param checkpoint_store [#set, #get, #delete] the same store used for the original run
|
|
109
|
+
# @return [Ask::Graph::Context] the completed context
|
|
110
|
+
def resume(input:, checkpoint_store:)
|
|
111
|
+
new(checkpoint_store: checkpoint_store).resume(input: input)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @param checkpoint_store [#set, #get, #delete, nil] optional persistent store
|
|
116
|
+
def initialize(checkpoint_store: nil)
|
|
117
|
+
@runner = Runner.new(self.class.declarations, checkpoint_store: checkpoint_store)
|
|
118
|
+
@context = nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @return [Ask::Graph::Runner] the runner (exposed for checkpoint coordination)
|
|
122
|
+
attr_reader :runner
|
|
123
|
+
|
|
124
|
+
# @return [Ask::Graph::Context, nil] the shared context
|
|
125
|
+
attr_reader :context
|
|
126
|
+
|
|
127
|
+
# Run the graph with the given input.
|
|
128
|
+
# @param input [Object] optional initial input
|
|
129
|
+
# @return [Ask::Graph::Context] the completed context
|
|
130
|
+
def run(input = nil)
|
|
131
|
+
@context = Context.new(self, input)
|
|
132
|
+
@runner.run(@context)
|
|
133
|
+
@context
|
|
134
|
+
rescue => e
|
|
135
|
+
raise unless e.class.name&.end_with?("Paused")
|
|
136
|
+
@context
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Resume a paused graph with human input.
|
|
140
|
+
# @param input [Object] the human's input
|
|
141
|
+
# @return [Ask::Graph::Context] the completed context
|
|
142
|
+
def resume(input:)
|
|
143
|
+
@context.resume_input = input.to_s
|
|
144
|
+
@runner.run(@context)
|
|
145
|
+
@context
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/ask-graph.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ask-graph
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kaka Ruto
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ask-core
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.6'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.6'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.25'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.25'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: mocha
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.1'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.1'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rake
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '13.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '13.0'
|
|
68
|
+
description: Define workflows as graphs of named steps with conditional routing, parallel
|
|
69
|
+
execution, human-in-the-loop approval, per-item checkpointing loops, and sub-graph
|
|
70
|
+
composition. Each step is a plain Ruby class.
|
|
71
|
+
email:
|
|
72
|
+
- kaka@myrrlabs.com
|
|
73
|
+
executables: []
|
|
74
|
+
extensions: []
|
|
75
|
+
extra_rdoc_files: []
|
|
76
|
+
files:
|
|
77
|
+
- CHANGELOG.md
|
|
78
|
+
- LICENSE
|
|
79
|
+
- README.md
|
|
80
|
+
- lib/ask-graph.rb
|
|
81
|
+
- lib/ask/graph.rb
|
|
82
|
+
- lib/ask/graph/context.rb
|
|
83
|
+
- lib/ask/graph/runner.rb
|
|
84
|
+
- lib/ask/graph/version.rb
|
|
85
|
+
homepage: https://github.com/ask-rb/ask-graph
|
|
86
|
+
licenses:
|
|
87
|
+
- MIT
|
|
88
|
+
metadata:
|
|
89
|
+
homepage_uri: https://github.com/ask-rb/ask-graph
|
|
90
|
+
source_code_uri: https://github.com/ask-rb/ask-graph
|
|
91
|
+
changelog_uri: https://github.com/ask-rb/ask-graph/blob/master/CHANGELOG.md
|
|
92
|
+
rdoc_options: []
|
|
93
|
+
require_paths:
|
|
94
|
+
- lib
|
|
95
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - ">="
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: '3.2'
|
|
100
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
101
|
+
requirements:
|
|
102
|
+
- - ">="
|
|
103
|
+
- !ruby/object:Gem::Version
|
|
104
|
+
version: '0'
|
|
105
|
+
requirements: []
|
|
106
|
+
rubygems_version: 4.0.3
|
|
107
|
+
specification_version: 4
|
|
108
|
+
summary: Durable workflow graphs for the ask-rb ecosystem
|
|
109
|
+
test_files: []
|