chrono_forge 0.10.0 → 0.11.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 +4 -4
- data/CHANGELOG.md +34 -1
- data/README.md +188 -105
- data/Rakefile +4 -0
- data/cliff.toml +62 -0
- data/docs/design/per-child-commit-overhead.md +213 -0
- data/docs/fanout-scale-test.md +247 -0
- data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md +205 -0
- data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md.tasks.json +33 -0
- data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md +1373 -0
- data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md.tasks.json +68 -0
- data/docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md +203 -0
- data/lib/chrono_forge/branch_merge_job.rb +158 -21
- data/lib/chrono_forge/branch_probe.rb +44 -0
- data/lib/chrono_forge/configuration.rb +25 -0
- data/lib/chrono_forge/definition.rb +37 -0
- data/lib/chrono_forge/definition_analyzer.rb +501 -0
- data/lib/chrono_forge/executor/context.rb +23 -0
- data/lib/chrono_forge/executor/lock_strategy.rb +10 -3
- data/lib/chrono_forge/executor/methods/continue_if.rb +15 -6
- data/lib/chrono_forge/executor/methods/durably_execute.rb +15 -7
- data/lib/chrono_forge/executor/methods/durably_repeat.rb +30 -14
- data/lib/chrono_forge/executor/methods/merge_branches.rb +5 -4
- data/lib/chrono_forge/executor/methods/workflow_states.rb +35 -47
- data/lib/chrono_forge/executor.rb +34 -9
- data/lib/chrono_forge/version.rb +1 -1
- data/lib/chrono_forge.rb +8 -0
- data/lib/tasks/release.rake +212 -0
- metadata +28 -2
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChronoForge
|
|
4
|
+
# Rendering-agnostic graph model produced by DefinitionAnalyzer. Plain value
|
|
5
|
+
# objects so a Definition can be cached/serialized (to_h -> JSON) and consumed
|
|
6
|
+
# by any renderer. No DB, no Prism, no dashboard dependency here.
|
|
7
|
+
class Definition
|
|
8
|
+
# kind: :execute :wait :wait_until :continue_if :branch :merge :repeat :dynamic
|
|
9
|
+
# A node binds to runtime logs by EXACT step_name when known, else by
|
|
10
|
+
# step_name_pattern (a prefix for fan-out/repeat/dynamic).
|
|
11
|
+
Node = Struct.new(
|
|
12
|
+
:id, :kind, :label, :step_name, :step_name_pattern, :guard, :warnings,
|
|
13
|
+
keyword_init: true
|
|
14
|
+
) do
|
|
15
|
+
def dynamic? = kind == :dynamic || step_name.nil?
|
|
16
|
+
|
|
17
|
+
# Default a missing warnings member to [] here (rather than overriding the
|
|
18
|
+
# struct's generated reader, which triggers a method-redefined warning).
|
|
19
|
+
def to_h = super.merge(warnings: self[:warnings] || [])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# kind: :seq :conditional :fanout :join :terminal
|
|
23
|
+
Edge = Struct.new(:from, :to, :kind, :guard, keyword_init: true)
|
|
24
|
+
|
|
25
|
+
attr_reader :nodes, :edges, :warnings
|
|
26
|
+
|
|
27
|
+
def initialize(nodes: [], edges: [], warnings: [])
|
|
28
|
+
@nodes = nodes
|
|
29
|
+
@edges = edges
|
|
30
|
+
@warnings = warnings
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
{nodes: nodes.map(&:to_h), edges: edges.map(&:to_h), warnings: warnings}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module ChronoForge
|
|
6
|
+
# Statically analyzes a workflow class's `perform` method (via Prism) into a
|
|
7
|
+
# conservative Definition graph. Reads SOURCE TEXT ONLY — never the DB, never
|
|
8
|
+
# executes workflow code. Unresolvable Ruby becomes a :dynamic node + warning.
|
|
9
|
+
class DefinitionAnalyzer
|
|
10
|
+
# The durable DSL calls we recognize -> node kind.
|
|
11
|
+
DURABLE = {
|
|
12
|
+
durably_execute: :execute, wait: :wait, wait_until: :wait_until,
|
|
13
|
+
continue_if: :continue_if, branch: :branch, merge_branches: :merge,
|
|
14
|
+
merge_branch: :merge, durably_repeat: :repeat
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# Which argument carries the step NAME, per the real DSL signatures in
|
|
18
|
+
# executor/methods. `pos` is the 0-based positional index; `kw` = whether a
|
|
19
|
+
# `name:` keyword overrides it. `wait(duration, name)` is the one primitive
|
|
20
|
+
# whose name is the SECOND positional. merge_branches is special-cased in
|
|
21
|
+
# step_name_for (its name joins all positional branch names).
|
|
22
|
+
NAME_ARG = {
|
|
23
|
+
durably_execute: {pos: 0, kw: true},
|
|
24
|
+
wait: {pos: 1, kw: false},
|
|
25
|
+
wait_until: {pos: 0, kw: false},
|
|
26
|
+
continue_if: {pos: 0, kw: true},
|
|
27
|
+
durably_repeat: {pos: 0, kw: true},
|
|
28
|
+
branch: {pos: 0, kw: false}
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
def self.call(workflow_class) = new(workflow_class).call
|
|
32
|
+
|
|
33
|
+
def initialize(workflow_class)
|
|
34
|
+
@klass = workflow_class
|
|
35
|
+
@nodes = []
|
|
36
|
+
@edges = []
|
|
37
|
+
@warnings = []
|
|
38
|
+
@seq = 0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call
|
|
42
|
+
_file, method_node, defs = locate_perform
|
|
43
|
+
return unavailable unless method_node
|
|
44
|
+
|
|
45
|
+
@defs = defs # name(Symbol) => Prism::DefNode, for same-class helper tracing
|
|
46
|
+
walk(method_node.body, "start") # builds @nodes/@edges as a side effect
|
|
47
|
+
if @nodes.empty?
|
|
48
|
+
@warnings << "no durable steps found in this workflow's perform — it may be " \
|
|
49
|
+
"empty, or drive its steps through code this static analysis can't follow"
|
|
50
|
+
end
|
|
51
|
+
Definition.new(nodes: @nodes, edges: @edges, warnings: @warnings)
|
|
52
|
+
rescue => e
|
|
53
|
+
unavailable("analysis error: #{e.class}: #{e.message}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# The workflow's OWN perform, not the prepended ChronoForge::Executor#perform.
|
|
59
|
+
# A workflow does `prepend ChronoForge::Executor`, so instance_method(:perform)
|
|
60
|
+
# resolves to the executor's wrapper (owner is a Module). Walk the super chain
|
|
61
|
+
# to the first perform defined on a real Class in the ancestry — the user's.
|
|
62
|
+
def user_perform
|
|
63
|
+
um = @klass.instance_method(:perform)
|
|
64
|
+
um = um.super_method while um && !um.owner.is_a?(Class)
|
|
65
|
+
um
|
|
66
|
+
rescue NameError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Resolve perform's source file, parse it, and collect the instance-method
|
|
71
|
+
# DefNodes that lexically belong to the SAME class body as the bound perform
|
|
72
|
+
# (for same-class helper tracing). Scoping to the containing class avoids a
|
|
73
|
+
# bare helper call resolving to a same-named method in a DIFFERENT class.
|
|
74
|
+
def locate_perform
|
|
75
|
+
loc = user_perform&.source_location
|
|
76
|
+
return [nil, nil, {}] unless loc && File.readable?(loc.first)
|
|
77
|
+
|
|
78
|
+
file, line = loc
|
|
79
|
+
root = Prism.parse_file(file).value
|
|
80
|
+
klass = innermost_class_containing(root, line)
|
|
81
|
+
return [file, nil, {}] unless klass
|
|
82
|
+
|
|
83
|
+
defs = {}
|
|
84
|
+
perform = nil
|
|
85
|
+
class_method_defs(klass).each do |d|
|
|
86
|
+
defs[d.name] = d
|
|
87
|
+
# A file may hold several workflow classes (each with its own #perform);
|
|
88
|
+
# bind to the one whose `def` starts on this method's source line.
|
|
89
|
+
perform = d if d.name == :perform && d.location.start_line == line
|
|
90
|
+
end
|
|
91
|
+
[file, perform, defs]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The DEEPEST Class/Module node whose line range covers `line` (a class nested
|
|
95
|
+
# in a module returns the inner class, since descent reassigns last-wins).
|
|
96
|
+
def innermost_class_containing(node, line)
|
|
97
|
+
found = nil
|
|
98
|
+
visit = ->(n) do
|
|
99
|
+
return unless n.is_a?(Prism::Node)
|
|
100
|
+
if (n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode)) &&
|
|
101
|
+
n.location.start_line <= line && line <= n.location.end_line
|
|
102
|
+
found = n
|
|
103
|
+
end
|
|
104
|
+
n.compact_child_nodes.each { |c| visit.call(c) }
|
|
105
|
+
end
|
|
106
|
+
visit.call(node)
|
|
107
|
+
found
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# DefNodes directly in this class/module body — NOT inside a deeper nested
|
|
111
|
+
# class/module (those belong to other classes).
|
|
112
|
+
def class_method_defs(klass)
|
|
113
|
+
out = []
|
|
114
|
+
collect = ->(n) do
|
|
115
|
+
return unless n.is_a?(Prism::Node)
|
|
116
|
+
return if n.is_a?(Prism::ClassNode) || n.is_a?(Prism::ModuleNode)
|
|
117
|
+
out << n if n.is_a?(Prism::DefNode)
|
|
118
|
+
n.compact_child_nodes.each { |c| collect.call(c) }
|
|
119
|
+
end
|
|
120
|
+
klass.compact_child_nodes.each { |c| collect.call(c) }
|
|
121
|
+
out
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Walk a body node in source order, threading the "previous node id" so we can
|
|
125
|
+
# emit sequential edges. Returns the id of the last node reached (the exit).
|
|
126
|
+
def walk(node, prev)
|
|
127
|
+
return prev if node.nil?
|
|
128
|
+
|
|
129
|
+
statements =
|
|
130
|
+
case node
|
|
131
|
+
when Prism::StatementsNode then node.body
|
|
132
|
+
else [node]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
statements.each { |stmt| prev = visit(stmt, prev) }
|
|
136
|
+
prev
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Visit one statement; return the new "previous" id (unchanged if it emitted
|
|
140
|
+
# nothing). `prev` may be a single node id or a list of ids (the multiple
|
|
141
|
+
# exits of a conditional that rejoin at the next statement).
|
|
142
|
+
def visit(stmt, prev)
|
|
143
|
+
case stmt
|
|
144
|
+
when Prism::IfNode, Prism::UnlessNode
|
|
145
|
+
visit_conditional(stmt, prev)
|
|
146
|
+
when Prism::CaseNode, Prism::CaseMatchNode
|
|
147
|
+
visit_case(stmt, prev)
|
|
148
|
+
when Prism::BeginNode
|
|
149
|
+
visit_begin(stmt, prev)
|
|
150
|
+
when Prism::LocalVariableWriteNode, Prism::InstanceVariableWriteNode,
|
|
151
|
+
Prism::ClassVariableWriteNode, Prism::GlobalVariableWriteNode,
|
|
152
|
+
Prism::ConstantWriteNode, Prism::MultiWriteNode
|
|
153
|
+
# A durable call on an assignment's RHS (result = durably_execute :fetch)
|
|
154
|
+
# must still appear; unwrap to the value expression and visit it.
|
|
155
|
+
visit(stmt.value, prev)
|
|
156
|
+
when Prism::ArrayNode
|
|
157
|
+
# An implicit array (e.g. a multi-assign RHS `a, b = durably_execute(:x), 1`)
|
|
158
|
+
# — visit each element so a durable one is surfaced.
|
|
159
|
+
stmt.elements.reduce(prev) { |p, e| visit(e, p) }
|
|
160
|
+
when Prism::AndNode, Prism::OrNode
|
|
161
|
+
# Short-circuit boolean: the left always runs, the right conditionally.
|
|
162
|
+
# Surface both operands in order so a durable one (ok? || wait_until :warm)
|
|
163
|
+
# isn't dropped.
|
|
164
|
+
visit(stmt.right, visit(stmt.left, prev))
|
|
165
|
+
when Prism::ReturnNode
|
|
166
|
+
# An early `return` exits the run: a :terminal edge to the shared "halt"
|
|
167
|
+
# sink from each live predecessor. When the return sits inside an if/unless
|
|
168
|
+
# (the common `return unless ready?` form), mark_entry_conditional stamps
|
|
169
|
+
# this edge with the guard. This path does NOT continue, so it contributes
|
|
170
|
+
# no exit — return [] so the next statement builds only from the skip path.
|
|
171
|
+
to_list(prev).each { |p| add_edge(p, "halt", :terminal) }
|
|
172
|
+
[]
|
|
173
|
+
else
|
|
174
|
+
if (call = durable_call(stmt))
|
|
175
|
+
id = emit_durable(call, prev)
|
|
176
|
+
id = attach_terminal(call, id) if call.name == :continue_if
|
|
177
|
+
id
|
|
178
|
+
elsif (helper = traceable_helper(stmt))
|
|
179
|
+
trace_helper(helper, prev)
|
|
180
|
+
elsif loop_with_durable?(stmt)
|
|
181
|
+
@warnings << "durable step inside a loop (#{stmt.class.name.split("::").last}) — " \
|
|
182
|
+
"count is data-dependent; shown once, not unrolled"
|
|
183
|
+
walk_loop_body(stmt, prev)
|
|
184
|
+
else
|
|
185
|
+
prev
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# A bare (receiverless/self) call to a same-file method whose body contains a
|
|
191
|
+
# durable call — worth tracing inline. Recursion-guarded via @tracing.
|
|
192
|
+
def traceable_helper(node)
|
|
193
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
194
|
+
return nil unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
|
|
195
|
+
dfn = @defs[node.name]
|
|
196
|
+
return nil unless dfn
|
|
197
|
+
return nil if (@tracing ||= []).include?(node.name)
|
|
198
|
+
body_has_durable?(dfn.body) ? dfn : nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def trace_helper(dfn, prev)
|
|
202
|
+
(@tracing ||= []) << dfn.name
|
|
203
|
+
result = walk(dfn.body, prev)
|
|
204
|
+
@tracing.pop
|
|
205
|
+
result
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def body_has_durable?(node)
|
|
209
|
+
return false unless node.is_a?(Prism::Node)
|
|
210
|
+
if node.is_a?(Prism::CallNode) && DURABLE.key?(node.name) &&
|
|
211
|
+
(node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
|
|
212
|
+
return true
|
|
213
|
+
end
|
|
214
|
+
node.compact_child_nodes.any? { |c| body_has_durable?(c) }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def loop_with_durable?(node)
|
|
218
|
+
case node
|
|
219
|
+
when Prism::WhileNode, Prism::UntilNode, Prism::ForNode
|
|
220
|
+
body_has_durable?(node)
|
|
221
|
+
when Prism::CallNode
|
|
222
|
+
%i[each times upto downto each_with_index map].include?(node.name) &&
|
|
223
|
+
node.block && body_has_durable?(node.block)
|
|
224
|
+
else
|
|
225
|
+
false
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Walk a loop body ONCE so contained steps appear (with the warning), not
|
|
230
|
+
# unrolled. Handles both keyword loops and iterator blocks.
|
|
231
|
+
def walk_loop_body(node, prev)
|
|
232
|
+
body =
|
|
233
|
+
case node
|
|
234
|
+
when Prism::CallNode then node.block.is_a?(Prism::BlockNode) ? node.block.body : nil
|
|
235
|
+
else node.respond_to?(:statements) ? node.statements : nil
|
|
236
|
+
end
|
|
237
|
+
walk(body, prev)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# if/unless: walk the body under a guard, then expose BOTH the body exit(s)
|
|
241
|
+
# and the skip path (pre-`if` node, or the else-branch exits) so the next
|
|
242
|
+
# statement is reachable every way. Returns a list of exit ids.
|
|
243
|
+
def visit_conditional(node, prev)
|
|
244
|
+
raw = source_of(node.predicate)
|
|
245
|
+
unless_node = node.is_a?(Prism::UnlessNode)
|
|
246
|
+
# `unless P` runs its body when P is FALSE and its else when P is TRUE.
|
|
247
|
+
body_guard = unless_node ? negate(raw) : raw
|
|
248
|
+
else_guard = unless_node ? raw : negate(raw)
|
|
249
|
+
|
|
250
|
+
before = @edges.size
|
|
251
|
+
body_exit = walk(node.statements, prev)
|
|
252
|
+
mark_entry_conditional(before, prev, body_guard)
|
|
253
|
+
|
|
254
|
+
exits = to_list(body_exit)
|
|
255
|
+
if (sub = branch_else(node))
|
|
256
|
+
before_else = @edges.size
|
|
257
|
+
else_stmts = sub.is_a?(Prism::ElseNode) ? sub.statements : sub
|
|
258
|
+
else_exit = walk(else_stmts, prev)
|
|
259
|
+
mark_entry_conditional(before_else, prev, else_guard)
|
|
260
|
+
exits |= to_list(else_exit)
|
|
261
|
+
else
|
|
262
|
+
exits |= to_list(prev) # no else: skip path is the pre-`if` node
|
|
263
|
+
end
|
|
264
|
+
exits
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# begin/rescue/else/ensure: walk the main body and every rescue clause so
|
|
268
|
+
# durable calls in either path appear. Each is an alternative path from the
|
|
269
|
+
# same `prev`; their exits rejoin. (ensure always runs, so it follows all.)
|
|
270
|
+
def visit_begin(node, prev)
|
|
271
|
+
exits = to_list(walk(node.statements, prev))
|
|
272
|
+
rescue_clause = node.rescue_clause
|
|
273
|
+
while rescue_clause
|
|
274
|
+
exits |= to_list(walk(rescue_clause.statements, prev))
|
|
275
|
+
rescue_clause = rescue_clause.subsequent
|
|
276
|
+
end
|
|
277
|
+
exits |= to_list(walk(node.else_clause.statements, prev)) if node.else_clause
|
|
278
|
+
exits = to_list(walk(node.ensure_clause.statements, exits)) if node.ensure_clause
|
|
279
|
+
exits
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def visit_case(node, prev)
|
|
283
|
+
exits = []
|
|
284
|
+
Array(node.conditions).each do |clause|
|
|
285
|
+
before = @edges.size
|
|
286
|
+
exit_id = walk(clause.statements, prev)
|
|
287
|
+
mark_entry_conditional(before, prev, clause_guard(clause))
|
|
288
|
+
exits |= to_list(exit_id)
|
|
289
|
+
end
|
|
290
|
+
exits |=
|
|
291
|
+
if node.else_clause
|
|
292
|
+
to_list(walk(node.else_clause.statements, prev))
|
|
293
|
+
else
|
|
294
|
+
to_list(prev)
|
|
295
|
+
end
|
|
296
|
+
exits
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Guard text for one case clause: the "a, b" condition list of a `when`, or the
|
|
300
|
+
# pattern of a case/in `in` clause.
|
|
301
|
+
def clause_guard(clause)
|
|
302
|
+
case clause
|
|
303
|
+
when Prism::InNode then source_of(clause.pattern)
|
|
304
|
+
else Array(clause.conditions).map { |c| source_of(c) }.join(", ")
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# The else/elsif chain, across prism versions (IfNode uses #subsequent, older
|
|
309
|
+
# #consequent; Unless/Case expose #else_clause). Returns the node or nil.
|
|
310
|
+
def branch_else(node)
|
|
311
|
+
return node.subsequent if node.respond_to?(:subsequent)
|
|
312
|
+
return node.else_clause if node.respond_to?(:else_clause)
|
|
313
|
+
return node.consequent if node.respond_to?(:consequent)
|
|
314
|
+
nil
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# The edges added since `before` whose `from` is one of the incoming `prev`
|
|
318
|
+
# ids are exactly the conditional's ENTRY edges (internal body edges originate
|
|
319
|
+
# at body nodes, not at `prev`). Relabel them :conditional and COMPOSE the
|
|
320
|
+
# guard with any existing one so an outer conditional wrapping an inner one
|
|
321
|
+
# yields `outer && inner` on every entry edge.
|
|
322
|
+
def mark_entry_conditional(before, prev, guard)
|
|
323
|
+
prevs = to_list(prev)
|
|
324
|
+
@edges[before..].each do |e|
|
|
325
|
+
next unless prevs.include?(e.from)
|
|
326
|
+
# Keep a terminal (early-return / continue_if false) edge dashed; only its
|
|
327
|
+
# guard is composed. Other entry edges become conditional.
|
|
328
|
+
e.kind = :conditional unless e.kind == :terminal
|
|
329
|
+
e.guard = e.guard ? "#{guard} && #{e.guard}" : guard
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# continue_if's false path halts the workflow: a :terminal edge to the shared
|
|
334
|
+
# synthetic "halt" sink. Like "start", the sink is a virtual endpoint id, not
|
|
335
|
+
# a real node, so it never pollutes the durable-step node list.
|
|
336
|
+
def attach_terminal(_call, id)
|
|
337
|
+
add_edge(id, "halt", :terminal, "condition false")
|
|
338
|
+
id
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Best-effort source text of a predicate node (for guard labels). Falls back
|
|
342
|
+
# to the node type when slicing isn't available.
|
|
343
|
+
def source_of(node)
|
|
344
|
+
raw = node.respond_to?(:slice) ? node.slice : node.class.name.split("::").last
|
|
345
|
+
# Collapse internal whitespace/newlines and truncate so guard labels stay
|
|
346
|
+
# short enough to render on a single edge.
|
|
347
|
+
compact = raw.to_s.gsub(/\s+/, " ").strip
|
|
348
|
+
(compact.length > 60) ? "#{compact[0, 59]}…" : compact
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def negate(guard) = "!(#{guard})"
|
|
352
|
+
|
|
353
|
+
def to_list(value) = value.is_a?(Array) ? value : [value]
|
|
354
|
+
|
|
355
|
+
# A Prism::CallNode whose method is a recognized durable DSL call with no
|
|
356
|
+
# explicit receiver (or `self`). Returns the CallNode or nil.
|
|
357
|
+
def durable_call(node)
|
|
358
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
359
|
+
return nil unless DURABLE.key?(node.name)
|
|
360
|
+
return nil unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
|
|
361
|
+
node
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def emit_durable(call, prev)
|
|
365
|
+
kind = DURABLE.fetch(call.name)
|
|
366
|
+
name, dynamic = resolved_name(call)
|
|
367
|
+
step_name = dynamic ? nil : step_name_for(call.name, name, call)
|
|
368
|
+
# merge_branches only binds when EVERY branch name is literal; if step_name_for
|
|
369
|
+
# couldn't resolve them all it returns nil. Treat that as dynamic (unbindable)
|
|
370
|
+
# rather than a merge node with a nil step_name that silently never matches.
|
|
371
|
+
dynamic ||= step_name.nil?
|
|
372
|
+
node = add_node(
|
|
373
|
+
kind: dynamic ? :dynamic : kind,
|
|
374
|
+
label: ((kind == :merge) ? merge_label(call) : label_for(call.name, name)),
|
|
375
|
+
step_name: step_name,
|
|
376
|
+
step_name_pattern: ("#{prefix_for(call.name)}$" if dynamic),
|
|
377
|
+
warnings: (dynamic ? ["#{call.name}: dynamic name — bound by prefix/ordinal"] : [])
|
|
378
|
+
)
|
|
379
|
+
to_list(prev).each { |p| add_edge(p, node.id, :seq) }
|
|
380
|
+
|
|
381
|
+
if call.name == :branch && call.block
|
|
382
|
+
emit_branch_children(call.block, node)
|
|
383
|
+
(@branches ||= {})[name] = node.id if name # skip dynamic branch names
|
|
384
|
+
elsif kind == :merge
|
|
385
|
+
positional_args(call).each do |arg|
|
|
386
|
+
bname = literal_value(arg)
|
|
387
|
+
next unless bname # a dynamic merge name matches no recorded branch
|
|
388
|
+
src = @branches && @branches[bname]
|
|
389
|
+
add_edge(src, node.id, :join) if src
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
node.id
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# A branch's spawn/spawn_each calls each become one child-group node, reached
|
|
396
|
+
# by a :fanout edge. Children are keyed <wf.key>$<branch>$<name>_* at runtime;
|
|
397
|
+
# we record a prefix pattern for reference, but the dashboard overlay computes
|
|
398
|
+
# fan-out status from child-workflow counts (BranchProbe), not this pattern.
|
|
399
|
+
def emit_branch_children(block, branch_node)
|
|
400
|
+
body = block.is_a?(Prism::BlockNode) ? block.body : nil
|
|
401
|
+
stmts =
|
|
402
|
+
case body
|
|
403
|
+
when Prism::StatementsNode then body.body
|
|
404
|
+
else Array(body)
|
|
405
|
+
end
|
|
406
|
+
stmts.each do |stmt|
|
|
407
|
+
next unless stmt.is_a?(Prism::CallNode) && %i[spawn spawn_each].include?(stmt.name)
|
|
408
|
+
sname = literal_value(positional_args(stmt).first)
|
|
409
|
+
child = add_node(
|
|
410
|
+
kind: :dynamic,
|
|
411
|
+
label: "#{stmt.name} #{sname}".strip,
|
|
412
|
+
step_name: nil,
|
|
413
|
+
step_name_pattern: (sname ? "spawn:#{sname}" : "spawn"),
|
|
414
|
+
warnings: ["fan-out — status is aggregated from child workflows"]
|
|
415
|
+
)
|
|
416
|
+
add_edge(branch_node.id, child.id, :fanout)
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Resolve the step NAME literal from a durable call, per NAME_ARG. Returns
|
|
421
|
+
# [name_string_or_nil, dynamic?]. dynamic? is true when the name can't be
|
|
422
|
+
# resolved to a literal statically. (merge_branches resolves via all
|
|
423
|
+
# positionals in step_name_for; its `name` here is just the first branch.)
|
|
424
|
+
def resolved_name(call)
|
|
425
|
+
spec = NAME_ARG.fetch(call.name, {pos: 0, kw: true})
|
|
426
|
+
if spec[:kw] && (override = keyword_literal(call, :name))
|
|
427
|
+
return [override, false]
|
|
428
|
+
end
|
|
429
|
+
lit = literal_value(positional_args(call)[spec[:pos]])
|
|
430
|
+
lit ? [lit, false] : [nil, true]
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def step_name_for(dsl, name, call)
|
|
434
|
+
case dsl
|
|
435
|
+
when :merge_branches, :merge_branch
|
|
436
|
+
names = positional_args(call).map { |a| literal_value(a) }
|
|
437
|
+
return nil if names.any?(&:nil?)
|
|
438
|
+
"merge$#{names.sort.join(",")}"
|
|
439
|
+
else
|
|
440
|
+
"#{prefix_for(dsl)}$#{name}"
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def prefix_for(dsl)
|
|
445
|
+
case dsl
|
|
446
|
+
when :merge_branches, :merge_branch then "merge"
|
|
447
|
+
when :branch then "branch"
|
|
448
|
+
when :durably_repeat then "durably_repeat"
|
|
449
|
+
else dsl.to_s
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def label_for(dsl, name) = name ? "#{dsl} #{name}" : dsl.to_s
|
|
454
|
+
|
|
455
|
+
# A merge node lists ALL its literal branch names, e.g. "merge_branches a, b"
|
|
456
|
+
# (source order). Falls back to the bare DSL name if any name is non-literal.
|
|
457
|
+
def merge_label(call)
|
|
458
|
+
names = positional_args(call).map { |a| literal_value(a) }
|
|
459
|
+
return call.name.to_s if names.empty? || names.any?(&:nil?)
|
|
460
|
+
"#{call.name} #{names.join(", ")}"
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# ---- Prism literal helpers ----
|
|
464
|
+
|
|
465
|
+
def positional_args(call)
|
|
466
|
+
(call.arguments&.arguments || []).reject { |a| a.is_a?(Prism::KeywordHashNode) }
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def keyword_literal(call, key)
|
|
470
|
+
hash = (call.arguments&.arguments || []).find { |a| a.is_a?(Prism::KeywordHashNode) }
|
|
471
|
+
return nil unless hash
|
|
472
|
+
assoc = hash.elements.grep(Prism::AssocNode).find do |e|
|
|
473
|
+
e.key.is_a?(Prism::SymbolNode) && e.key.value.to_sym == key
|
|
474
|
+
end
|
|
475
|
+
assoc && literal_value(assoc.value)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def literal_value(node)
|
|
479
|
+
case node
|
|
480
|
+
when Prism::SymbolNode then node.value
|
|
481
|
+
when Prism::StringNode then node.unescaped
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# ---- graph builders ----
|
|
486
|
+
|
|
487
|
+
def add_node(**attrs)
|
|
488
|
+
node = Definition::Node.new(id: "n#{@seq += 1}", **attrs)
|
|
489
|
+
@nodes << node
|
|
490
|
+
node
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def add_edge(from, to, kind, guard = nil)
|
|
494
|
+
@edges << Definition::Edge.new(from: from, to: to, kind: kind, guard: guard)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def unavailable(msg = "perform source is not statically analyzable")
|
|
498
|
+
Definition.new(nodes: [], edges: [], warnings: [msg])
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
end
|
|
@@ -51,6 +51,29 @@ module ChronoForge
|
|
|
51
51
|
set_value(key, value)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# Sets multiple values in the context at once from a hash.
|
|
55
|
+
# The merge is atomic: every value is validated before any is written, so
|
|
56
|
+
# a single invalid value raises and leaves the context untouched.
|
|
57
|
+
# Returns self for chaining.
|
|
58
|
+
def merge(hash)
|
|
59
|
+
hash.each_value { |value| validate_value!(value) }
|
|
60
|
+
hash.each { |key, value| set_value(key, value) }
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
alias_method :set_multiple, :merge
|
|
64
|
+
|
|
65
|
+
# Like #merge, but only sets keys that don't already exist; present keys
|
|
66
|
+
# are skipped entirely (their values are never validated), matching
|
|
67
|
+
# #set_once semantics. The applied keys are written atomically: an invalid
|
|
68
|
+
# value among the new keys raises and writes nothing. Returns self.
|
|
69
|
+
def merge_once(hash)
|
|
70
|
+
new_pairs = hash.reject { |key, _| key?(key) }
|
|
71
|
+
new_pairs.each_value { |value| validate_value!(value) }
|
|
72
|
+
new_pairs.each { |key, value| set_value(key, value) }
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
alias_method :set_multiple_once, :merge_once
|
|
76
|
+
|
|
54
77
|
# Sets a value in the context only if the key doesn't already exist
|
|
55
78
|
# Returns true if the value was set, false otherwise
|
|
56
79
|
def set_once(key, value)
|
|
@@ -20,12 +20,19 @@ module ChronoForge
|
|
|
20
20
|
"Currently being executed by job(#{workflow.locked_by})"
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
# Atomic update of lock status
|
|
24
|
-
|
|
23
|
+
# Atomic update of lock status. Stamp started_at here on first
|
|
24
|
+
# acquisition too: branch children are pre-inserted by their parent
|
|
25
|
+
# without it, and folding the stamp into this UPDATE saves a separate
|
|
26
|
+
# commit (fsync) per child on large fan-outs. started_at then reliably
|
|
27
|
+
# means "has been picked up and run" — the BranchMergeJob rekick poller
|
|
28
|
+
# treats a nil started_at as a never-executed (dropped) child.
|
|
29
|
+
columns = {
|
|
25
30
|
locked_by: job_id,
|
|
26
31
|
locked_at: Time.current,
|
|
27
32
|
state: :running
|
|
28
|
-
|
|
33
|
+
}
|
|
34
|
+
columns[:started_at] = Time.current if workflow.started_at.nil?
|
|
35
|
+
workflow.update_columns(columns)
|
|
29
36
|
|
|
30
37
|
Rails.logger.debug { "ChronoForge:#{name}(#{workflow.key}) job(#{job_id}) acquired lock." }
|
|
31
38
|
|
|
@@ -99,9 +99,14 @@ module ChronoForge
|
|
|
99
99
|
validate_step_name_segment!(name || condition)
|
|
100
100
|
step_name = "continue_if$#{name || condition}"
|
|
101
101
|
|
|
102
|
-
# Find or create execution log
|
|
102
|
+
# Find or create execution log. A fresh gate records its first evaluation
|
|
103
|
+
# in the INSERT itself (attempts: 1, last_executed_at), so there is no
|
|
104
|
+
# separate pre-evaluation UPDATE to follow it.
|
|
103
105
|
execution_log = find_or_create_execution_log!(step_name) do |log|
|
|
104
|
-
|
|
106
|
+
now = Time.current
|
|
107
|
+
log.started_at = now
|
|
108
|
+
log.last_executed_at = now
|
|
109
|
+
log.attempts = 1
|
|
105
110
|
log.metadata = {
|
|
106
111
|
condition: condition.to_s,
|
|
107
112
|
name: name
|
|
@@ -115,10 +120,14 @@ module ChronoForge
|
|
|
115
120
|
|
|
116
121
|
# Evaluate condition once
|
|
117
122
|
begin
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
# Existing logs (re-evaluations after a not-met halt) still need the
|
|
124
|
+
# attempt bump; a freshly-created gate already recorded its first above.
|
|
125
|
+
unless execution_log.previously_new_record?
|
|
126
|
+
execution_log.update!(
|
|
127
|
+
attempts: execution_log.attempts + 1,
|
|
128
|
+
last_executed_at: Time.current
|
|
129
|
+
)
|
|
130
|
+
end
|
|
122
131
|
|
|
123
132
|
condition_met = send(condition)
|
|
124
133
|
rescue HaltExecutionFlow
|
|
@@ -66,9 +66,14 @@ module ChronoForge
|
|
|
66
66
|
policy = step_retry_policy(retry_policy)
|
|
67
67
|
validate_step_name_segment!(name || method)
|
|
68
68
|
step_name = "durably_execute$#{name || method}"
|
|
69
|
-
# Find or create execution log
|
|
69
|
+
# Find or create execution log. On a fresh step the first attempt is
|
|
70
|
+
# recorded in the INSERT itself (attempts: 1, last_executed_at) so there
|
|
71
|
+
# is no separate pre-execution UPDATE to follow it.
|
|
70
72
|
execution_log = find_or_create_execution_log!(step_name) do |log|
|
|
71
|
-
|
|
73
|
+
now = Time.current
|
|
74
|
+
log.started_at = now
|
|
75
|
+
log.last_executed_at = now
|
|
76
|
+
log.attempts = 1
|
|
72
77
|
end
|
|
73
78
|
|
|
74
79
|
# Return if already completed
|
|
@@ -76,11 +81,14 @@ module ChronoForge
|
|
|
76
81
|
|
|
77
82
|
# Execute with error handling
|
|
78
83
|
begin
|
|
79
|
-
#
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
# Existing logs (retries) still need the pre-execution attempt bump;
|
|
85
|
+
# a freshly-created log already recorded its first attempt above.
|
|
86
|
+
unless execution_log.previously_new_record?
|
|
87
|
+
execution_log.update!(
|
|
88
|
+
attempts: execution_log.attempts + 1,
|
|
89
|
+
last_executed_at: Time.current
|
|
90
|
+
)
|
|
91
|
+
end
|
|
84
92
|
|
|
85
93
|
# Execute the method
|
|
86
94
|
send(method)
|