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,1373 @@
|
|
|
1
|
+
# Workflow Definition DAG Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Statically parse a ChronoForge workflow's `perform` method with Prism to produce a conditional-DAG "definition" of the durable steps it will run, then render it on a new per-run dashboard page with the run's `execution_logs` overlaid as node status.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A rendering-agnostic core analyzer (`ChronoForge::DefinitionAnalyzer`, Prism) emits value objects (`ChronoForge::Definition` / `Node` / `Edge`). The dashboard package overlays a run's logs (`DefinitionOverlay`, reusing `BranchProbe` for fan-out) and renders the statused graph to Mermaid flowchart text (`MermaidRenderer`) on a new `workflows/:id/definition` page. The analyzer only reads source text — never the DB, never executes workflow code.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby, Prism 1.x, ActiveRecord/ActiveJob, Minitest + Combustion (core), Rails engine + Tailwind + vendored Mermaid.js (dashboard).
|
|
10
|
+
|
|
11
|
+
**User Verification:** NO — no user verification required.
|
|
12
|
+
|
|
13
|
+
**Working directory:** worktree `.worktrees/workflow-definition-dag` (branch `feat/workflow-definition-dag`). Design spec: `docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md`.
|
|
14
|
+
|
|
15
|
+
**Step-name reference** (what the analyzer must reproduce, from `lib/chrono_forge/executor/methods/`):
|
|
16
|
+
|
|
17
|
+
| DSL | step name |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `durably_execute :m` / `name:` | `durably_execute$#{name || m}` |
|
|
20
|
+
| `wait <duration>, "n"` | `wait$#{n}` (name is the 2nd positional) |
|
|
21
|
+
| `wait_until :c` | `wait_until$#{c}` |
|
|
22
|
+
| `continue_if :c` / `name:` | `continue_if$#{name || c}` |
|
|
23
|
+
| `branch :n` | `branch$#{n}` |
|
|
24
|
+
| `merge_branches :a, :b` | `merge$#{[a,b].sort.join(",")}` |
|
|
25
|
+
| `durably_repeat :m` / `name:` | coord `durably_repeat$#{name || m}`; reps `durably_repeat$<name>$<ts>` |
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
### Task 1: `Definition` value objects + Prism dependency
|
|
30
|
+
|
|
31
|
+
**Goal:** The rendering-agnostic graph data model (`Definition`, `Node`, `Edge`) and the `prism` runtime dependency, with round-trippable `to_h`.
|
|
32
|
+
|
|
33
|
+
**Files:**
|
|
34
|
+
- Create: `lib/chrono_forge/definition.rb`
|
|
35
|
+
- Modify: `chrono_forge.gemspec` (add `prism`)
|
|
36
|
+
- Test: `test/definition_test.rb`
|
|
37
|
+
|
|
38
|
+
**Acceptance Criteria:**
|
|
39
|
+
- [ ] `ChronoForge::Definition` holds `nodes`, `edges`, `warnings`; `#to_h` returns plain JSON-safe hashes.
|
|
40
|
+
- [ ] `Node` exposes `id, kind, label, step_name, step_name_pattern, guard, warnings` and `#dynamic?`.
|
|
41
|
+
- [ ] `Edge` exposes `from, to, kind, guard`.
|
|
42
|
+
- [ ] `prism` is a declared runtime dependency (Ruby 3.2 doesn't bundle it).
|
|
43
|
+
|
|
44
|
+
**Verify:** `bundle exec ruby -I test test/definition_test.rb` → all green.
|
|
45
|
+
|
|
46
|
+
**Steps:**
|
|
47
|
+
|
|
48
|
+
- [ ] **Step 1: Add the Prism dependency** (`chrono_forge.gemspec`, after the `zeitwerk` line ~38)
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
spec.add_dependency "zeitwerk"
|
|
52
|
+
spec.add_dependency "prism"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Then `bundle install` (updates the git-ignored `Gemfile.lock` already present in the worktree).
|
|
56
|
+
|
|
57
|
+
- [ ] **Step 2: Write the failing test** (`test/definition_test.rb`)
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
require "test_helper"
|
|
61
|
+
|
|
62
|
+
class DefinitionTest < ActiveSupport::TestCase
|
|
63
|
+
def test_node_dynamic_predicate
|
|
64
|
+
exact = ChronoForge::Definition::Node.new(id: "n1", kind: :execute, label: "charge", step_name: "durably_execute$charge")
|
|
65
|
+
dyn = ChronoForge::Definition::Node.new(id: "n2", kind: :dynamic, label: "?", step_name_pattern: "durably_execute$")
|
|
66
|
+
refute exact.dynamic?
|
|
67
|
+
assert dyn.dynamic?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def test_to_h_is_json_safe
|
|
71
|
+
d = ChronoForge::Definition.new(
|
|
72
|
+
nodes: [ChronoForge::Definition::Node.new(id: "n1", kind: :execute, label: "charge", step_name: "durably_execute$charge")],
|
|
73
|
+
edges: [ChronoForge::Definition::Edge.new(from: "start", to: "n1", kind: :seq)],
|
|
74
|
+
warnings: ["heads up"]
|
|
75
|
+
)
|
|
76
|
+
h = d.to_h
|
|
77
|
+
assert_equal "durably_execute$charge", h[:nodes].first[:step_name]
|
|
78
|
+
assert_equal :seq, h[:edges].first[:kind]
|
|
79
|
+
assert_equal ["heads up"], h[:warnings]
|
|
80
|
+
# Round-trips through JSON without custom coders.
|
|
81
|
+
assert JSON.generate(h)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- [ ] **Step 3: Run to verify failure**
|
|
87
|
+
|
|
88
|
+
Run: `bundle exec ruby -I test test/definition_test.rb`
|
|
89
|
+
Expected: FAIL — `uninitialized constant ChronoForge::Definition`.
|
|
90
|
+
|
|
91
|
+
- [ ] **Step 4: Implement** (`lib/chrono_forge/definition.rb`)
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
# frozen_string_literal: true
|
|
95
|
+
|
|
96
|
+
module ChronoForge
|
|
97
|
+
# Rendering-agnostic graph model produced by DefinitionAnalyzer. Plain value
|
|
98
|
+
# objects so a Definition can be cached/serialized (to_h -> JSON) and consumed
|
|
99
|
+
# by any renderer. No DB, no Prism, no dashboard dependency here.
|
|
100
|
+
class Definition
|
|
101
|
+
# kind: :execute :wait :wait_until :continue_if :branch :merge :repeat :dynamic
|
|
102
|
+
# A node binds to runtime logs by EXACT step_name when known, else by
|
|
103
|
+
# step_name_pattern (a prefix for fan-out/repeat/dynamic).
|
|
104
|
+
Node = Struct.new(
|
|
105
|
+
:id, :kind, :label, :step_name, :step_name_pattern, :guard, :warnings,
|
|
106
|
+
keyword_init: true
|
|
107
|
+
) do
|
|
108
|
+
def warnings = super || []
|
|
109
|
+
def dynamic? = kind == :dynamic || step_name.nil?
|
|
110
|
+
def to_h = super.merge(warnings: warnings)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# kind: :seq :conditional :fanout :join :terminal
|
|
114
|
+
Edge = Struct.new(:from, :to, :kind, :guard, keyword_init: true)
|
|
115
|
+
|
|
116
|
+
attr_reader :nodes, :edges, :warnings
|
|
117
|
+
|
|
118
|
+
def initialize(nodes: [], edges: [], warnings: [])
|
|
119
|
+
@nodes = nodes
|
|
120
|
+
@edges = edges
|
|
121
|
+
@warnings = warnings
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def to_h
|
|
125
|
+
{nodes: nodes.map(&:to_h), edges: edges.map(&:to_h), warnings: warnings}
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
- [ ] **Step 5: Run to verify pass**
|
|
132
|
+
|
|
133
|
+
Run: `bundle exec ruby -I test test/definition_test.rb`
|
|
134
|
+
Expected: PASS (2 tests).
|
|
135
|
+
|
|
136
|
+
- [ ] **Step 6: Commit**
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
git add lib/chrono_forge/definition.rb test/definition_test.rb chrono_forge.gemspec Gemfile.lock
|
|
140
|
+
git commit -m "feat(definition): graph value objects + prism dependency"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
### Task 2: Analyzer — linear steps
|
|
146
|
+
|
|
147
|
+
**Goal:** `DefinitionAnalyzer.call(workflow_class)` resolves the `perform` source via Prism and emits a node per straight-line durable call (`durably_execute`, `wait`, `wait_until`, `continue_if`, `merge_branches`) with sequential edges from a synthetic `start`.
|
|
148
|
+
|
|
149
|
+
**Files:**
|
|
150
|
+
- Create: `lib/chrono_forge/definition_analyzer.rb`
|
|
151
|
+
- Create: `test/support/definition_fixtures.rb` (fixture workflow classes)
|
|
152
|
+
- Test: `test/definition_analyzer_test.rb`
|
|
153
|
+
|
|
154
|
+
**Acceptance Criteria:**
|
|
155
|
+
- [ ] A linear workflow yields one node per durable call, in source order, each with the correct exact `step_name`.
|
|
156
|
+
- [ ] Edges chain `start → n1 → n2 → …` with `kind: :seq`.
|
|
157
|
+
- [ ] Non-durable Ruby (plain method calls, `context[...]=`) produces no nodes.
|
|
158
|
+
- [ ] No DB access, no workflow execution.
|
|
159
|
+
|
|
160
|
+
**Verify:** `bundle exec ruby -I test test/definition_analyzer_test.rb -n /linear/` → green.
|
|
161
|
+
|
|
162
|
+
**Steps:**
|
|
163
|
+
|
|
164
|
+
- [ ] **Step 1: Add the linear fixture** (`test/support/definition_fixtures.rb`)
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# Fixture workflow classes for DefinitionAnalyzer. Only their SOURCE is read
|
|
168
|
+
# (Prism); they are never executed, so the bodies can reference helpers freely.
|
|
169
|
+
module DefinitionFixtures
|
|
170
|
+
class Linear
|
|
171
|
+
def perform
|
|
172
|
+
context["started"] = true
|
|
173
|
+
durably_execute :charge_card
|
|
174
|
+
wait_until :funds_cleared
|
|
175
|
+
wait 30.seconds, "cooloff"
|
|
176
|
+
continue_if :approved
|
|
177
|
+
durably_execute :ship, name: "ship_it"
|
|
178
|
+
merge_branches :b, :a
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
- [ ] **Step 2: Write the failing test** (`test/definition_analyzer_test.rb`)
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
require "test_helper"
|
|
188
|
+
require "support/definition_fixtures"
|
|
189
|
+
|
|
190
|
+
class DefinitionAnalyzerTest < ActiveSupport::TestCase
|
|
191
|
+
def defn(klass) = ChronoForge::DefinitionAnalyzer.call(klass)
|
|
192
|
+
|
|
193
|
+
def test_linear_emits_a_node_per_durable_call_in_order
|
|
194
|
+
d = defn(DefinitionFixtures::Linear)
|
|
195
|
+
assert_equal(
|
|
196
|
+
%w[durably_execute$charge_card wait_until$funds_cleared wait$cooloff
|
|
197
|
+
continue_if$approved durably_execute$ship_it merge$a,b],
|
|
198
|
+
d.nodes.map(&:step_name)
|
|
199
|
+
)
|
|
200
|
+
assert_equal %i[execute wait_until wait continue_if execute merge], d.nodes.map(&:kind)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def test_linear_chains_sequential_edges_from_start
|
|
204
|
+
d = defn(DefinitionFixtures::Linear)
|
|
205
|
+
ids = d.nodes.map(&:id)
|
|
206
|
+
assert_equal "start", d.edges.first.from
|
|
207
|
+
assert_equal ids.first, d.edges.first.to
|
|
208
|
+
# Every consecutive pair is connected by a :seq edge.
|
|
209
|
+
ids.each_cons(2) do |a, b|
|
|
210
|
+
assert d.edges.any? { |e| e.from == a && e.to == b && e.kind == :seq }
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def test_ignores_non_durable_ruby
|
|
215
|
+
d = defn(DefinitionFixtures::Linear)
|
|
216
|
+
refute d.nodes.any? { |n| n.label.include?("context") }
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
- [ ] **Step 3: Run to verify failure**
|
|
222
|
+
|
|
223
|
+
Run: `bundle exec ruby -I test test/definition_analyzer_test.rb -n /linear/`
|
|
224
|
+
Expected: FAIL — `uninitialized constant ChronoForge::DefinitionAnalyzer`.
|
|
225
|
+
|
|
226
|
+
- [ ] **Step 4: Implement the analyzer core + linear visitor** (`lib/chrono_forge/definition_analyzer.rb`)
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
# frozen_string_literal: true
|
|
230
|
+
|
|
231
|
+
require "prism"
|
|
232
|
+
|
|
233
|
+
module ChronoForge
|
|
234
|
+
# Statically analyzes a workflow class's `perform` method (via Prism) into a
|
|
235
|
+
# conservative Definition graph. Reads SOURCE TEXT ONLY — never the DB, never
|
|
236
|
+
# executes workflow code. Unresolvable Ruby becomes a :dynamic node + warning.
|
|
237
|
+
class DefinitionAnalyzer
|
|
238
|
+
# The durable DSL calls we recognize -> node kind.
|
|
239
|
+
DURABLE = {
|
|
240
|
+
durably_execute: :execute, wait: :wait, wait_until: :wait_until,
|
|
241
|
+
continue_if: :continue_if, branch: :branch, merge_branches: :merge,
|
|
242
|
+
merge_branch: :merge, durably_repeat: :repeat
|
|
243
|
+
}.freeze
|
|
244
|
+
|
|
245
|
+
def self.call(workflow_class) = new(workflow_class).call
|
|
246
|
+
|
|
247
|
+
def initialize(workflow_class)
|
|
248
|
+
@klass = workflow_class
|
|
249
|
+
@nodes = []
|
|
250
|
+
@edges = []
|
|
251
|
+
@warnings = []
|
|
252
|
+
@seq = 0
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def call
|
|
256
|
+
file, method_node, defs = locate_perform
|
|
257
|
+
return unavailable unless method_node
|
|
258
|
+
|
|
259
|
+
@defs = defs # name(Symbol) => Prism::DefNode, for same-class helper tracing
|
|
260
|
+
last = "start"
|
|
261
|
+
last = walk(method_node.body, last)
|
|
262
|
+
Definition.new(nodes: @nodes, edges: @edges, warnings: @warnings)
|
|
263
|
+
rescue => e
|
|
264
|
+
unavailable("analysis error: #{e.class}: #{e.message}")
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
private
|
|
268
|
+
|
|
269
|
+
# Resolve perform's source file, parse it, and collect every instance-method
|
|
270
|
+
# DefNode in the same class body (for helper tracing).
|
|
271
|
+
def locate_perform
|
|
272
|
+
loc = @klass.instance_method(:perform).source_location
|
|
273
|
+
return [nil, nil, {}] unless loc && File.readable?(loc.first)
|
|
274
|
+
|
|
275
|
+
result = Prism.parse_file(loc.first)
|
|
276
|
+
defs = {}
|
|
277
|
+
perform = nil
|
|
278
|
+
collect = ->(node) do
|
|
279
|
+
return unless node.is_a?(Prism::DefNode)
|
|
280
|
+
defs[node.name] = node
|
|
281
|
+
perform = node if node.name == :perform
|
|
282
|
+
end
|
|
283
|
+
each_def(result.value, &collect)
|
|
284
|
+
[loc.first, perform, defs]
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Yield every DefNode anywhere under `node` (workflows may nest in modules).
|
|
288
|
+
def each_def(node, &blk)
|
|
289
|
+
return unless node.is_a?(Prism::Node)
|
|
290
|
+
blk.call(node)
|
|
291
|
+
node.compact_child_nodes.each { |c| each_def(c, &blk) }
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Walk a body node in source order, threading the "previous node id" so we can
|
|
295
|
+
# emit sequential edges. Returns the id of the last node reached (the exit).
|
|
296
|
+
def walk(node, prev)
|
|
297
|
+
return prev if node.nil?
|
|
298
|
+
|
|
299
|
+
statements =
|
|
300
|
+
case node
|
|
301
|
+
when Prism::StatementsNode then node.body
|
|
302
|
+
else [node]
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
statements.each { |stmt| prev = visit(stmt, prev) }
|
|
306
|
+
prev
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Visit one statement; return the new "previous" id (unchanged if it emitted
|
|
310
|
+
# nothing). Task 2 handles only durable call statements + descends into plain
|
|
311
|
+
# calls' blocks are added later.
|
|
312
|
+
def visit(stmt, prev)
|
|
313
|
+
if (call = durable_call(stmt))
|
|
314
|
+
return emit_durable(call, prev)
|
|
315
|
+
end
|
|
316
|
+
prev
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# A Prism::CallNode whose method is a recognized durable DSL call with no
|
|
320
|
+
# explicit receiver (or `self`). Returns the CallNode or nil.
|
|
321
|
+
def durable_call(node)
|
|
322
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
323
|
+
return nil unless DURABLE.key?(node.name)
|
|
324
|
+
return nil unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
|
|
325
|
+
node
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def emit_durable(call, prev)
|
|
329
|
+
kind = DURABLE.fetch(call.name)
|
|
330
|
+
name, dynamic = resolved_name(call)
|
|
331
|
+
step_name = dynamic ? nil : step_name_for(call.name, name, call)
|
|
332
|
+
node = add_node(
|
|
333
|
+
kind: dynamic ? :dynamic : kind,
|
|
334
|
+
label: label_for(call.name, name),
|
|
335
|
+
step_name: step_name,
|
|
336
|
+
step_name_pattern: ("#{prefix_for(call.name)}$" if dynamic),
|
|
337
|
+
warnings: (dynamic ? ["#{call.name}: dynamic name — bound by prefix/ordinal"] : [])
|
|
338
|
+
)
|
|
339
|
+
add_edge(prev, node.id, :seq)
|
|
340
|
+
node.id
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# First positional arg as a literal Symbol/String, honoring a literal `name:`
|
|
344
|
+
# keyword override. Returns [name_string_or_nil, dynamic?].
|
|
345
|
+
def resolved_name(call)
|
|
346
|
+
override = keyword_literal(call, :name)
|
|
347
|
+
return [override, false] if override
|
|
348
|
+
|
|
349
|
+
first = positional_args(call).first
|
|
350
|
+
lit = literal_value(first)
|
|
351
|
+
lit ? [lit, false] : [nil, true]
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def step_name_for(dsl, name, call)
|
|
355
|
+
case dsl
|
|
356
|
+
when :merge_branches, :merge_branch
|
|
357
|
+
names = positional_args(call).map { |a| literal_value(a) }
|
|
358
|
+
return nil if names.any?(&:nil?)
|
|
359
|
+
"merge$#{names.sort.join(",")}"
|
|
360
|
+
else
|
|
361
|
+
"#{prefix_for(dsl)}$#{name}"
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def prefix_for(dsl)
|
|
366
|
+
case dsl
|
|
367
|
+
when :merge_branches, :merge_branch then "merge"
|
|
368
|
+
when :branch then "branch"
|
|
369
|
+
when :durably_repeat then "durably_repeat"
|
|
370
|
+
else dsl.to_s
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def label_for(dsl, name) = name ? "#{dsl} #{name}" : dsl.to_s
|
|
375
|
+
|
|
376
|
+
# ---- Prism literal helpers ----
|
|
377
|
+
|
|
378
|
+
def positional_args(call)
|
|
379
|
+
(call.arguments&.arguments || []).reject { |a| a.is_a?(Prism::KeywordHashNode) }
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def keyword_literal(call, key)
|
|
383
|
+
hash = (call.arguments&.arguments || []).find { |a| a.is_a?(Prism::KeywordHashNode) }
|
|
384
|
+
return nil unless hash
|
|
385
|
+
assoc = hash.elements.grep(Prism::AssocNode).find do |e|
|
|
386
|
+
e.key.is_a?(Prism::SymbolNode) && e.key.value.to_sym == key
|
|
387
|
+
end
|
|
388
|
+
assoc && literal_value(assoc.value)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def literal_value(node)
|
|
392
|
+
case node
|
|
393
|
+
when Prism::SymbolNode then node.value
|
|
394
|
+
when Prism::StringNode then node.unescaped
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# ---- graph builders ----
|
|
399
|
+
|
|
400
|
+
def add_node(**attrs)
|
|
401
|
+
node = Definition::Node.new(id: "n#{@seq += 1}", **attrs)
|
|
402
|
+
@nodes << node
|
|
403
|
+
node
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def add_edge(from, to, kind, guard = nil)
|
|
407
|
+
@edges << Definition::Edge.new(from: from, to: to, kind: kind, guard: guard)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def unavailable(msg = "perform source is not statically analyzable")
|
|
411
|
+
Definition.new(nodes: [], edges: [], warnings: [msg])
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
- [ ] **Step 5: Run to verify pass**
|
|
418
|
+
|
|
419
|
+
Run: `bundle exec ruby -I test test/definition_analyzer_test.rb -n /linear/`
|
|
420
|
+
Expected: PASS (linear tests). Then run the whole file — the ignore test passes too.
|
|
421
|
+
|
|
422
|
+
- [ ] **Step 6: Commit**
|
|
423
|
+
|
|
424
|
+
```bash
|
|
425
|
+
git add lib/chrono_forge/definition_analyzer.rb test/definition_analyzer_test.rb test/support/definition_fixtures.rb
|
|
426
|
+
git commit -m "feat(analyzer): linear durable-step extraction via Prism"
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
---
|
|
430
|
+
|
|
431
|
+
### Task 3: Analyzer — conditionals & guards
|
|
432
|
+
|
|
433
|
+
**Goal:** Model `if`/`unless`/`case` around durable calls as `:conditional` edges carrying a guard label; a step reachable only inside a conditional is marked `conditional`; `continue_if`'s false path becomes a `:terminal` edge.
|
|
434
|
+
|
|
435
|
+
**Files:**
|
|
436
|
+
- Modify: `lib/chrono_forge/definition_analyzer.rb` (`visit`)
|
|
437
|
+
- Modify: `test/support/definition_fixtures.rb` (add `Conditional`)
|
|
438
|
+
- Test: `test/definition_analyzer_test.rb`
|
|
439
|
+
|
|
440
|
+
**Acceptance Criteria:**
|
|
441
|
+
- [ ] A durable call inside `if cond` yields a node reached by a `:conditional` edge whose `guard` is the condition source (e.g., `"vip?"`).
|
|
442
|
+
- [ ] Statements after the `if` rejoin: the post-`if` node has edges from BOTH the conditional body's exit and the pre-`if` node (skip path).
|
|
443
|
+
- [ ] `continue_if` emits a `:terminal` edge to a synthetic `halt` sink (false path halts the workflow).
|
|
444
|
+
|
|
445
|
+
**Verify:** `bundle exec ruby -I test test/definition_analyzer_test.rb -n /conditional|guard|continue_if/` → green.
|
|
446
|
+
|
|
447
|
+
**Steps:**
|
|
448
|
+
|
|
449
|
+
- [ ] **Step 1: Add the fixture** (`test/support/definition_fixtures.rb`, inside the module)
|
|
450
|
+
|
|
451
|
+
```ruby
|
|
452
|
+
class Conditional
|
|
453
|
+
def perform
|
|
454
|
+
durably_execute :charge
|
|
455
|
+
if vip?
|
|
456
|
+
durably_execute :gift
|
|
457
|
+
end
|
|
458
|
+
continue_if :approved
|
|
459
|
+
durably_execute :ship
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
- [ ] **Step 2: Write the failing tests** (`test/definition_analyzer_test.rb`)
|
|
465
|
+
|
|
466
|
+
```ruby
|
|
467
|
+
def test_conditional_body_reached_by_guarded_edge
|
|
468
|
+
d = defn(DefinitionFixtures::Conditional)
|
|
469
|
+
gift = d.nodes.find { |n| n.step_name == "durably_execute$gift" }
|
|
470
|
+
edge = d.edges.find { |e| e.to == gift.id }
|
|
471
|
+
assert_equal :conditional, edge.kind
|
|
472
|
+
assert_equal "vip?", edge.guard
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def test_conditional_rejoins_skip_and_body_paths
|
|
476
|
+
d = defn(DefinitionFixtures::Conditional)
|
|
477
|
+
charge = d.nodes.find { |n| n.step_name == "durably_execute$charge" }
|
|
478
|
+
gift = d.nodes.find { |n| n.step_name == "durably_execute$gift" }
|
|
479
|
+
approved = d.nodes.find { |n| n.step_name == "continue_if$approved" }
|
|
480
|
+
# continue_if is reachable from the gift body AND directly from charge (skip).
|
|
481
|
+
assert d.edges.any? { |e| e.from == gift.id && e.to == approved.id }
|
|
482
|
+
assert d.edges.any? { |e| e.from == charge.id && e.to == approved.id }
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def test_continue_if_has_terminal_false_path
|
|
486
|
+
d = defn(DefinitionFixtures::Conditional)
|
|
487
|
+
approved = d.nodes.find { |n| n.step_name == "continue_if$approved" }
|
|
488
|
+
assert d.edges.any? { |e| e.from == approved.id && e.kind == :terminal }
|
|
489
|
+
end
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
- [ ] **Step 3: Run to verify failure**
|
|
493
|
+
|
|
494
|
+
Run: `bundle exec ruby -I test test/definition_analyzer_test.rb -n /conditional|continue_if/`
|
|
495
|
+
Expected: FAIL — conditional bodies aren't walked yet (no `gift` node), no terminal edge.
|
|
496
|
+
|
|
497
|
+
- [ ] **Step 4: Extend `visit`** (`lib/chrono_forge/definition_analyzer.rb`) — replace the `visit` method with:
|
|
498
|
+
|
|
499
|
+
```ruby
|
|
500
|
+
def visit(stmt, prev)
|
|
501
|
+
case stmt
|
|
502
|
+
when Prism::IfNode, Prism::UnlessNode
|
|
503
|
+
return visit_conditional(stmt, prev)
|
|
504
|
+
when Prism::CaseNode
|
|
505
|
+
return visit_case(stmt, prev)
|
|
506
|
+
else
|
|
507
|
+
if (call = durable_call(stmt))
|
|
508
|
+
id = emit_durable(call, prev)
|
|
509
|
+
id = attach_terminal(call, id) if call.name == :continue_if
|
|
510
|
+
return id
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
prev
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# if/unless: walk the body under a guard, then rejoin with the skip path so
|
|
517
|
+
# the next statement is reachable both ways. Returns a synthetic join id.
|
|
518
|
+
def visit_conditional(node, prev)
|
|
519
|
+
guard = source_of(node.predicate)
|
|
520
|
+
body = node.is_a?(Prism::UnlessNode) ? node.statements : node.statements
|
|
521
|
+
# Re-point the FIRST durable edge emitted in the body as :conditional(guard).
|
|
522
|
+
before = @edges.size
|
|
523
|
+
body_exit = walk(body, prev)
|
|
524
|
+
mark_first_edge_conditional(before, prev, guard)
|
|
525
|
+
|
|
526
|
+
# else branch (if present) also flows from prev.
|
|
527
|
+
else_exit = prev
|
|
528
|
+
if node.respond_to?(:subsequent) && (sub = node.subsequent)
|
|
529
|
+
before_else = @edges.size
|
|
530
|
+
else_exit = walk(sub.is_a?(Prism::ElseNode) ? sub.statements : sub, prev)
|
|
531
|
+
mark_first_edge_conditional(before_else, prev, "!(#{guard})") if else_exit != prev
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
join = add_node(kind: :join, label: "join", step_name: nil)
|
|
535
|
+
add_edge(body_exit, join.id, :seq)
|
|
536
|
+
add_edge(prev, join.id, :seq) if body_exit != prev && else_exit == prev # skip path
|
|
537
|
+
add_edge(else_exit, join.id, :seq) if else_exit != prev
|
|
538
|
+
join.id
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def visit_case(node, prev)
|
|
542
|
+
exits = []
|
|
543
|
+
node.conditions.each do |when_node|
|
|
544
|
+
guard = when_node.conditions.map { |c| source_of(c) }.join(", ")
|
|
545
|
+
before = @edges.size
|
|
546
|
+
exit_id = walk(when_node.statements, prev)
|
|
547
|
+
mark_first_edge_conditional(before, prev, guard)
|
|
548
|
+
exits << exit_id
|
|
549
|
+
end
|
|
550
|
+
exits << walk(node.else_clause&.statements, prev) if node.else_clause
|
|
551
|
+
join = add_node(kind: :join, label: "join", step_name: nil)
|
|
552
|
+
(exits.uniq - [prev]).each { |e| add_edge(e, join.id, :seq) }
|
|
553
|
+
add_edge(prev, join.id, :seq) # fall-through/no-match path
|
|
554
|
+
join.id
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# The first edge added at/after `before` that starts at `prev` is the entry
|
|
558
|
+
# into the conditional body; relabel it :conditional with the guard.
|
|
559
|
+
def mark_first_edge_conditional(before, prev, guard)
|
|
560
|
+
edge = @edges[before..].find { |e| e.from == prev }
|
|
561
|
+
return unless edge
|
|
562
|
+
edge.kind = :conditional
|
|
563
|
+
edge.guard = guard
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def attach_terminal(_call, id)
|
|
567
|
+
sink = (@halt ||= add_node(kind: :dynamic, label: "halt", step_name: nil))
|
|
568
|
+
add_edge(id, sink.id, :terminal, "condition false")
|
|
569
|
+
id
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Best-effort source text of a predicate node (for guard labels). Falls back
|
|
573
|
+
# to the node type when slicing isn't available.
|
|
574
|
+
def source_of(node)
|
|
575
|
+
node.respond_to?(:slice) ? node.slice : node.class.name.split("::").last
|
|
576
|
+
end
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
Note: `Prism::Node#slice` returns the exact source substring — ideal for guard labels.
|
|
580
|
+
|
|
581
|
+
- [ ] **Step 5: Run to verify pass**
|
|
582
|
+
|
|
583
|
+
Run: `bundle exec ruby -I test test/definition_analyzer_test.rb`
|
|
584
|
+
Expected: PASS (linear + conditional). If a `:join` node interferes with the linear `each_cons` edge test, confirm linear has no conditionals so no join nodes are added there.
|
|
585
|
+
|
|
586
|
+
- [ ] **Step 6: Commit**
|
|
587
|
+
|
|
588
|
+
```bash
|
|
589
|
+
git add lib/chrono_forge/definition_analyzer.rb test/definition_analyzer_test.rb test/support/definition_fixtures.rb
|
|
590
|
+
git commit -m "feat(analyzer): guarded conditional edges and continue_if terminal"
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
---
|
|
594
|
+
|
|
595
|
+
### Task 4: Analyzer — branch fan-out + merge join
|
|
596
|
+
|
|
597
|
+
**Goal:** A `branch name do … end` block becomes a `:branch` fan-out node; its `spawn`/`spawn_each` calls become a child-group node reached by a `:fanout` edge; a following `merge_branches` node is reached by a `:join` edge.
|
|
598
|
+
|
|
599
|
+
**Files:**
|
|
600
|
+
- Modify: `lib/chrono_forge/definition_analyzer.rb` (`visit` block handling)
|
|
601
|
+
- Modify: `test/support/definition_fixtures.rb` (add `FanOut`)
|
|
602
|
+
- Test: `test/definition_analyzer_test.rb`
|
|
603
|
+
|
|
604
|
+
**Acceptance Criteria:**
|
|
605
|
+
- [ ] `branch :ship do spawn_each(:pkg, …) end` yields a `:branch` node (`step_name "branch$ship"`) plus a child-group node with `step_name_pattern` for the spawn.
|
|
606
|
+
- [ ] The branch node → child-group edge is `:fanout`.
|
|
607
|
+
- [ ] A later `merge_branches :ship` node is connected from the branch by a `:join` edge.
|
|
608
|
+
|
|
609
|
+
**Verify:** `bundle exec ruby -I test test/definition_analyzer_test.rb -n /fanout|branch|merge/` → green.
|
|
610
|
+
|
|
611
|
+
**Steps:**
|
|
612
|
+
|
|
613
|
+
- [ ] **Step 1: Add the fixture** (`test/support/definition_fixtures.rb`)
|
|
614
|
+
|
|
615
|
+
```ruby
|
|
616
|
+
class FanOut
|
|
617
|
+
def perform
|
|
618
|
+
branch :ship do
|
|
619
|
+
spawn_each :pkg, orders
|
|
620
|
+
end
|
|
621
|
+
merge_branches :ship
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
- [ ] **Step 2: Write the failing tests** (`test/definition_analyzer_test.rb`)
|
|
627
|
+
|
|
628
|
+
```ruby
|
|
629
|
+
def test_branch_emits_fanout_node_and_child_group
|
|
630
|
+
d = defn(DefinitionFixtures::FanOut)
|
|
631
|
+
br = d.nodes.find { |n| n.step_name == "branch$ship" }
|
|
632
|
+
assert_equal :branch, br.kind
|
|
633
|
+
child = d.nodes.find { |n| n.kind == :dynamic && n.label.include?("spawn_each") }
|
|
634
|
+
assert child, "expected a child-group node for spawn_each"
|
|
635
|
+
assert d.edges.any? { |e| e.from == br.id && e.to == child.id && e.kind == :fanout }
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def test_merge_joins_the_branch
|
|
639
|
+
d = defn(DefinitionFixtures::FanOut)
|
|
640
|
+
br = d.nodes.find { |n| n.step_name == "branch$ship" }
|
|
641
|
+
mg = d.nodes.find { |n| n.step_name == "merge$ship" }
|
|
642
|
+
assert d.edges.any? { |e| e.from == br.id && e.to == mg.id && e.kind == :join }
|
|
643
|
+
end
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
- [ ] **Step 3: Run to verify failure**
|
|
647
|
+
|
|
648
|
+
Run: `bundle exec ruby -I test test/definition_analyzer_test.rb -n /fanout|merge_joins/`
|
|
649
|
+
Expected: FAIL — branch block body isn't walked; no child-group node; merge not joined.
|
|
650
|
+
|
|
651
|
+
- [ ] **Step 4: Handle branch blocks + merge join** (`lib/chrono_forge/definition_analyzer.rb`)
|
|
652
|
+
|
|
653
|
+
In `emit_durable`, after creating the node, special-case `:branch` to walk its block for spawns and remember it for the join. Add to the top of `emit_durable` (after `node =` is created and the `:seq` edge added, replace the `node.id` return) with:
|
|
654
|
+
|
|
655
|
+
```ruby
|
|
656
|
+
add_edge(prev, node.id, :seq)
|
|
657
|
+
|
|
658
|
+
if call.name == :branch && call.block
|
|
659
|
+
emit_branch_children(call.block, node)
|
|
660
|
+
@branches ||= {}
|
|
661
|
+
@branches[name] = node.id
|
|
662
|
+
end
|
|
663
|
+
if kind == :merge
|
|
664
|
+
positional_args(call).each do |a|
|
|
665
|
+
bname = literal_value(a)
|
|
666
|
+
src = @branches && @branches[bname]
|
|
667
|
+
add_edge(src, node.id, :join) if src
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
node.id
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# A branch's spawn/spawn_each calls become one child-group node per call,
|
|
674
|
+
# reached by a :fanout edge. Children are keyed <wf.key>$<branch>$<name>_* at
|
|
675
|
+
# runtime, so we bind by that prefix pattern (best effort — the overlay uses
|
|
676
|
+
# BranchProbe counts, not the pattern, for fan-out status).
|
|
677
|
+
def emit_branch_children(block, branch_node)
|
|
678
|
+
body = block.is_a?(Prism::BlockNode) ? block.body : nil
|
|
679
|
+
stmts = body.is_a?(Prism::StatementsNode) ? body.body : Array(body)
|
|
680
|
+
stmts.each do |stmt|
|
|
681
|
+
next unless stmt.is_a?(Prism::CallNode) && %i[spawn spawn_each].include?(stmt.name)
|
|
682
|
+
sname = literal_value(positional_args(stmt).first)
|
|
683
|
+
child = add_node(
|
|
684
|
+
kind: :dynamic,
|
|
685
|
+
label: "#{stmt.name} #{sname}".strip,
|
|
686
|
+
step_name: nil,
|
|
687
|
+
step_name_pattern: (sname ? "spawn:#{sname}" : "spawn"),
|
|
688
|
+
warnings: ["fan-out — status is aggregated from child workflows"]
|
|
689
|
+
)
|
|
690
|
+
add_edge(branch_node.id, child.id, :fanout)
|
|
691
|
+
end
|
|
692
|
+
end
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
Note: this requires `@branches` to be reset per `call` — it's an instance var initialized lazily and the analyzer instance is per-workflow, so it's fine. `emit_durable`'s existing `add_edge(prev, node.id, :seq)` line is now inside this block; delete the old duplicate at the end.
|
|
696
|
+
|
|
697
|
+
- [ ] **Step 5: Run to verify pass**
|
|
698
|
+
|
|
699
|
+
Run: `bundle exec ruby -I test test/definition_analyzer_test.rb`
|
|
700
|
+
Expected: PASS (all prior + fan-out).
|
|
701
|
+
|
|
702
|
+
- [ ] **Step 6: Commit**
|
|
703
|
+
|
|
704
|
+
```bash
|
|
705
|
+
git add lib/chrono_forge/definition_analyzer.rb test/definition_analyzer_test.rb test/support/definition_fixtures.rb
|
|
706
|
+
git commit -m "feat(analyzer): branch fan-out nodes and merge join edges"
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
---
|
|
710
|
+
|
|
711
|
+
### Task 5: Analyzer — repeat loop, same-class helper tracing, warnings
|
|
712
|
+
|
|
713
|
+
**Goal:** `durably_repeat` becomes a `:repeat` node; durable calls factored into same-class helper methods are traced inline (fixed point, recursion-guarded); a durable call inside an `each`/`times`/`while` loop or behind an unknown call emits a warning.
|
|
714
|
+
|
|
715
|
+
**Files:**
|
|
716
|
+
- Modify: `lib/chrono_forge/definition_analyzer.rb`
|
|
717
|
+
- Modify: `test/support/definition_fixtures.rb` (add `Repeat`, `Traced`, `Loopy`)
|
|
718
|
+
- Test: `test/definition_analyzer_test.rb`
|
|
719
|
+
|
|
720
|
+
**Acceptance Criteria:**
|
|
721
|
+
- [ ] `durably_repeat :tick` → one `:repeat` node, `step_name "durably_repeat$tick"`.
|
|
722
|
+
- [ ] A `perform` that calls a same-class helper containing `durably_execute` produces that step's node (traced), in position.
|
|
723
|
+
- [ ] A durable call inside `orders.each { durably_execute … }` produces a warning on the Definition and does not crash.
|
|
724
|
+
- [ ] Recursive/mutually-recursive helpers don't loop forever.
|
|
725
|
+
|
|
726
|
+
**Verify:** `bundle exec ruby -I test test/definition_analyzer_test.rb -n /repeat|traced|loop|helper/` → green.
|
|
727
|
+
|
|
728
|
+
**Steps:**
|
|
729
|
+
|
|
730
|
+
- [ ] **Step 1: Add fixtures** (`test/support/definition_fixtures.rb`)
|
|
731
|
+
|
|
732
|
+
```ruby
|
|
733
|
+
class Repeat
|
|
734
|
+
def perform
|
|
735
|
+
durably_repeat :tick, every: 1.second, till: :done?
|
|
736
|
+
end
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
class Traced
|
|
740
|
+
def perform
|
|
741
|
+
setup
|
|
742
|
+
durably_execute :finish
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
private
|
|
746
|
+
|
|
747
|
+
def setup
|
|
748
|
+
durably_execute :charge
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
class Loopy
|
|
753
|
+
def perform
|
|
754
|
+
orders.each { |o| durably_execute :ship }
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
- [ ] **Step 2: Write the failing tests** (`test/definition_analyzer_test.rb`)
|
|
760
|
+
|
|
761
|
+
```ruby
|
|
762
|
+
def test_repeat_is_a_single_repeat_node
|
|
763
|
+
d = defn(DefinitionFixtures::Repeat)
|
|
764
|
+
rep = d.nodes.find { |n| n.kind == :repeat }
|
|
765
|
+
assert_equal "durably_repeat$tick", rep.step_name
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
def test_traces_durable_calls_in_same_class_helpers
|
|
769
|
+
d = defn(DefinitionFixtures::Traced)
|
|
770
|
+
assert_equal %w[durably_execute$charge durably_execute$finish], d.nodes.map(&:step_name)
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def test_durable_call_inside_loop_warns
|
|
774
|
+
d = defn(DefinitionFixtures::Loopy)
|
|
775
|
+
assert d.warnings.any? { |w| w.match?(/loop/i) }
|
|
776
|
+
end
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
- [ ] **Step 3: Run to verify failure**
|
|
780
|
+
|
|
781
|
+
Run: `bundle exec ruby -I test test/definition_analyzer_test.rb -n /repeat|traced|loop/`
|
|
782
|
+
Expected: FAIL — helper not traced (only `finish`), no loop warning.
|
|
783
|
+
|
|
784
|
+
- [ ] **Step 4: Add helper tracing + loop warnings** (`lib/chrono_forge/definition_analyzer.rb`)
|
|
785
|
+
|
|
786
|
+
Extend `visit`'s `else` branch: when a bare call matches a same-class def, recurse into it; when it's an iteration node, warn. Replace the `else` clause body in `visit` with:
|
|
787
|
+
|
|
788
|
+
```ruby
|
|
789
|
+
else
|
|
790
|
+
if (call = durable_call(stmt))
|
|
791
|
+
id = emit_durable(call, prev)
|
|
792
|
+
id = attach_terminal(call, id) if call.name == :continue_if
|
|
793
|
+
return id
|
|
794
|
+
elsif (helper = traceable_helper(stmt))
|
|
795
|
+
return trace_helper(helper, prev)
|
|
796
|
+
elsif loop_with_durable?(stmt)
|
|
797
|
+
@warnings << "durable step inside a loop (#{stmt.class.name.split("::").last}) — " \
|
|
798
|
+
"count is data-dependent; shown once, not unrolled"
|
|
799
|
+
return walk_loop_body(stmt, prev)
|
|
800
|
+
end
|
|
801
|
+
end
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
Add these helpers:
|
|
805
|
+
|
|
806
|
+
```ruby
|
|
807
|
+
# A bare (receiverless) call to a method defined on the same class whose body
|
|
808
|
+
# contains a durable call — worth tracing inline. Guards against recursion.
|
|
809
|
+
def traceable_helper(node)
|
|
810
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
811
|
+
return nil unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
|
|
812
|
+
dfn = @defs[node.name]
|
|
813
|
+
return nil unless dfn
|
|
814
|
+
return nil if (@tracing ||= []).include?(node.name)
|
|
815
|
+
body_has_durable?(dfn.body) ? dfn : nil
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
def trace_helper(dfn, prev)
|
|
819
|
+
(@tracing ||= []) << dfn.name
|
|
820
|
+
result = walk(dfn.body, prev)
|
|
821
|
+
@tracing.pop
|
|
822
|
+
result
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
def body_has_durable?(node)
|
|
826
|
+
return false unless node.is_a?(Prism::Node)
|
|
827
|
+
return true if node.is_a?(Prism::CallNode) && DURABLE.key?(node.name) &&
|
|
828
|
+
(node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
|
|
829
|
+
node.compact_child_nodes.any? { |c| body_has_durable?(c) }
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
def loop_with_durable?(node)
|
|
833
|
+
case node
|
|
834
|
+
when Prism::WhileNode, Prism::UntilNode, Prism::ForNode
|
|
835
|
+
body_has_durable?(node)
|
|
836
|
+
when Prism::CallNode
|
|
837
|
+
%i[each times upto downto each_with_index map].include?(node.name) &&
|
|
838
|
+
node.block && body_has_durable?(node.block)
|
|
839
|
+
else
|
|
840
|
+
false
|
|
841
|
+
end
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
# Walk a loop body ONCE so the contained steps appear (with the warning), not
|
|
845
|
+
# unrolled. Handles both keyword loops and iterator blocks.
|
|
846
|
+
def walk_loop_body(node, prev)
|
|
847
|
+
body =
|
|
848
|
+
case node
|
|
849
|
+
when Prism::CallNode then node.block.is_a?(Prism::BlockNode) ? node.block.body : nil
|
|
850
|
+
else node.respond_to?(:statements) ? node.statements : nil
|
|
851
|
+
end
|
|
852
|
+
walk(body, prev)
|
|
853
|
+
end
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
- [ ] **Step 5: Run to verify pass**
|
|
857
|
+
|
|
858
|
+
Run: `bundle exec ruby -I test test/definition_analyzer_test.rb`
|
|
859
|
+
Expected: PASS (all analyzer tests, including a full run over every fixture).
|
|
860
|
+
|
|
861
|
+
- [ ] **Step 6: Commit**
|
|
862
|
+
|
|
863
|
+
```bash
|
|
864
|
+
git add lib/chrono_forge/definition_analyzer.rb test/definition_analyzer_test.rb test/support/definition_fixtures.rb
|
|
865
|
+
git commit -m "feat(analyzer): repeat node, same-class helper tracing, loop warnings"
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
---
|
|
869
|
+
|
|
870
|
+
### Task 6: Dashboard — `DefinitionOverlay`
|
|
871
|
+
|
|
872
|
+
**Goal:** Given a `Definition` + a workflow, annotate each node with a runtime `status` from `execution_logs` (exact-name lookup), fan-out/repeat aggregates (via `BranchProbe` / repetition logs), and append `unmapped` nodes for logs with no matching static node.
|
|
873
|
+
|
|
874
|
+
**Files:**
|
|
875
|
+
- Create: `chrono_forge-dashboard/app/presenters/chrono_forge/dashboard/definition_overlay.rb`
|
|
876
|
+
- Test: `chrono_forge-dashboard/test/definition_overlay_test.rb`
|
|
877
|
+
|
|
878
|
+
**Acceptance Criteria:**
|
|
879
|
+
- [ ] Exact-name node → `status` ∈ `{done, active, failed, stalled, not_reached}` from its log.
|
|
880
|
+
- [ ] `:branch`/`:merge` node → `status` + `counts` (running/idle/completed/failed) from child workflows.
|
|
881
|
+
- [ ] `:repeat` node → `repetitions` count from `durably_repeat$<name>$*` logs.
|
|
882
|
+
- [ ] A completed log with no matching node appends an `unmapped` node.
|
|
883
|
+
|
|
884
|
+
**Verify:** `cd chrono_forge-dashboard && bundle exec rake test TEST=test/definition_overlay_test.rb` → green. (Copy the git-ignored working `Gemfile.lock` into the worktree's `chrono_forge-dashboard/` first — already done during worktree setup; see [[worktree-gemfile-lock]].)
|
|
885
|
+
|
|
886
|
+
**Steps:**
|
|
887
|
+
|
|
888
|
+
- [ ] **Step 1: Write the failing test** (`chrono_forge-dashboard/test/definition_overlay_test.rb`)
|
|
889
|
+
|
|
890
|
+
```ruby
|
|
891
|
+
require "test_helper"
|
|
892
|
+
|
|
893
|
+
class DefinitionOverlayTest < ActiveSupport::TestCase
|
|
894
|
+
def setup
|
|
895
|
+
@wf = create_workflow(key: "ov", state: :running)
|
|
896
|
+
@wf.execution_logs.create!(step_name: "durably_execute$charge",
|
|
897
|
+
state: ChronoForge::ExecutionLog.states[:completed], attempts: 1, started_at: 1.minute.ago)
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
def defn
|
|
901
|
+
ChronoForge::Definition.new(
|
|
902
|
+
nodes: [
|
|
903
|
+
ChronoForge::Definition::Node.new(id: "n1", kind: :execute, label: "charge", step_name: "durably_execute$charge"),
|
|
904
|
+
ChronoForge::Definition::Node.new(id: "n2", kind: :execute, label: "ship", step_name: "durably_execute$ship")
|
|
905
|
+
],
|
|
906
|
+
edges: [], warnings: []
|
|
907
|
+
)
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
def test_marks_done_and_not_reached
|
|
911
|
+
nodes = ChronoForge::Dashboard::DefinitionOverlay.new(defn, @wf).nodes
|
|
912
|
+
by_id = nodes.index_by { |n| n[:id] }
|
|
913
|
+
assert_equal :done, by_id["n1"][:status]
|
|
914
|
+
assert_equal :not_reached, by_id["n2"][:status]
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
def test_appends_unmapped_logs
|
|
918
|
+
@wf.execution_logs.create!(step_name: "durably_execute$mystery",
|
|
919
|
+
state: ChronoForge::ExecutionLog.states[:completed], attempts: 1, started_at: 1.minute.ago)
|
|
920
|
+
nodes = ChronoForge::Dashboard::DefinitionOverlay.new(defn, @wf).nodes
|
|
921
|
+
unmapped = nodes.select { |n| n[:status] == :unmapped }
|
|
922
|
+
assert_equal ["durably_execute$mystery"], unmapped.map { |n| n[:step_name] }
|
|
923
|
+
end
|
|
924
|
+
end
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
(If `create_workflow` isn't a shared helper, mirror the factory used in `test/branches_test.rb`.)
|
|
928
|
+
|
|
929
|
+
- [ ] **Step 2: Run to verify failure**
|
|
930
|
+
|
|
931
|
+
Run: `cd chrono_forge-dashboard && bundle exec rake test TEST=test/definition_overlay_test.rb`
|
|
932
|
+
Expected: FAIL — `uninitialized constant ChronoForge::Dashboard::DefinitionOverlay`.
|
|
933
|
+
|
|
934
|
+
- [ ] **Step 3: Implement** (`chrono_forge-dashboard/app/presenters/chrono_forge/dashboard/definition_overlay.rb`)
|
|
935
|
+
|
|
936
|
+
```ruby
|
|
937
|
+
module ChronoForge
|
|
938
|
+
module Dashboard
|
|
939
|
+
# Overlays a workflow run's execution_logs onto a static Definition, producing
|
|
940
|
+
# per-node hashes with a runtime :status (and fan-out/repeat aggregates).
|
|
941
|
+
# Read-only. The Definition is the static map; logs are the source of truth
|
|
942
|
+
# for a specific run.
|
|
943
|
+
class DefinitionOverlay
|
|
944
|
+
LOG_STATUS = {"completed" => :done, "running" => :active,
|
|
945
|
+
"failed" => :failed, "stalled" => :stalled}.freeze
|
|
946
|
+
|
|
947
|
+
def initialize(definition, workflow)
|
|
948
|
+
@definition = definition
|
|
949
|
+
@workflow = workflow
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
def nodes
|
|
953
|
+
mapped = @definition.nodes.map { |n| overlay(n) }
|
|
954
|
+
mapped + unmapped_nodes(mapped)
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
def warnings = @definition.warnings
|
|
958
|
+
|
|
959
|
+
private
|
|
960
|
+
|
|
961
|
+
def overlay(node)
|
|
962
|
+
base = node.to_h.merge(status: :not_reached)
|
|
963
|
+
case node.kind
|
|
964
|
+
when :branch, :merge then base.merge(fanout_status(node))
|
|
965
|
+
when :repeat then base.merge(repeat_status(node))
|
|
966
|
+
else
|
|
967
|
+
log = logs_by_name[node.step_name]
|
|
968
|
+
log ? base.merge(status: LOG_STATUS.fetch(log_state(log), :active)) : base
|
|
969
|
+
end
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
def fanout_status(node)
|
|
973
|
+
log = logs_by_name[node.step_name]
|
|
974
|
+
return {status: :not_reached} unless log
|
|
975
|
+
counts = ChronoForge::Workflow
|
|
976
|
+
.where(parent_execution_log_id: log.id)
|
|
977
|
+
.group(:state).count
|
|
978
|
+
.transform_keys { |k| ChronoForge::Workflow.states.key(k) || k }
|
|
979
|
+
status = if counts["failed"].to_i.positive? then :failed
|
|
980
|
+
elsif counts.except("completed").values.sum.positive? then :active
|
|
981
|
+
elsif counts.any? then :done
|
|
982
|
+
else :not_reached
|
|
983
|
+
end
|
|
984
|
+
{status: status, counts: counts}
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
def repeat_status(node)
|
|
988
|
+
coord = logs_by_name[node.step_name]
|
|
989
|
+
return {status: :not_reached} unless coord
|
|
990
|
+
reps = @workflow.execution_logs
|
|
991
|
+
.where("step_name LIKE ?", "#{node.step_name}$%").count
|
|
992
|
+
{status: (coord_done?(coord) ? :done : :active), repetitions: reps}
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
def unmapped_nodes(mapped)
|
|
996
|
+
known = mapped.filter_map { |n| n[:step_name] }.to_set
|
|
997
|
+
@workflow.execution_logs
|
|
998
|
+
.reject { |l| known.include?(l.step_name) || framework_log?(l) }
|
|
999
|
+
.map do |l|
|
|
1000
|
+
{id: "log-#{l.id}", kind: :dynamic, label: l.step_name, step_name: l.step_name,
|
|
1001
|
+
status: :unmapped, warnings: ["no matching static node"]}
|
|
1002
|
+
end
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
# Skip framework-internal and fan-out child/rep logs (they're aggregated).
|
|
1006
|
+
def framework_log?(log)
|
|
1007
|
+
log.step_name.start_with?("$") ||
|
|
1008
|
+
log.step_name.count("$") >= 2 # durably_repeat$name$ts, etc.
|
|
1009
|
+
end
|
|
1010
|
+
|
|
1011
|
+
def logs_by_name
|
|
1012
|
+
@logs_by_name ||= @workflow.execution_logs.index_by(&:step_name)
|
|
1013
|
+
end
|
|
1014
|
+
|
|
1015
|
+
def log_state(log) = ChronoForge::ExecutionLog.states.key(log.state) || log.state.to_s
|
|
1016
|
+
def coord_done?(log) = log_state(log) == "completed"
|
|
1017
|
+
end
|
|
1018
|
+
end
|
|
1019
|
+
end
|
|
1020
|
+
```
|
|
1021
|
+
|
|
1022
|
+
- [ ] **Step 4: Run to verify pass**
|
|
1023
|
+
|
|
1024
|
+
Run: `cd chrono_forge-dashboard && bundle exec rake test TEST=test/definition_overlay_test.rb`
|
|
1025
|
+
Expected: PASS.
|
|
1026
|
+
|
|
1027
|
+
- [ ] **Step 5: Commit**
|
|
1028
|
+
|
|
1029
|
+
```bash
|
|
1030
|
+
git add chrono_forge-dashboard/app/presenters/chrono_forge/dashboard/definition_overlay.rb chrono_forge-dashboard/test/definition_overlay_test.rb
|
|
1031
|
+
git commit -m "feat(dashboard): overlay execution_logs onto the definition graph"
|
|
1032
|
+
```
|
|
1033
|
+
|
|
1034
|
+
---
|
|
1035
|
+
|
|
1036
|
+
### Task 7: Dashboard — `MermaidRenderer`
|
|
1037
|
+
|
|
1038
|
+
**Goal:** Turn statused overlay nodes + Definition edges into a Mermaid `flowchart TD` string, with status encoded via `classDef`/`class`.
|
|
1039
|
+
|
|
1040
|
+
**Files:**
|
|
1041
|
+
- Create: `chrono_forge-dashboard/app/presenters/chrono_forge/dashboard/mermaid_renderer.rb`
|
|
1042
|
+
- Test: `chrono_forge-dashboard/test/mermaid_renderer_test.rb`
|
|
1043
|
+
|
|
1044
|
+
**Acceptance Criteria:**
|
|
1045
|
+
- [ ] Emits `flowchart TD`, one line per node with a shape by kind and a `:::status` class.
|
|
1046
|
+
- [ ] Emits one edge line per Definition edge; conditional/terminal edges carry their guard as an edge label.
|
|
1047
|
+
- [ ] Includes `classDef` lines for every status used.
|
|
1048
|
+
|
|
1049
|
+
**Verify:** `cd chrono_forge-dashboard && bundle exec rake test TEST=test/mermaid_renderer_test.rb` → green.
|
|
1050
|
+
|
|
1051
|
+
**Steps:**
|
|
1052
|
+
|
|
1053
|
+
- [ ] **Step 1: Write the failing test** (`chrono_forge-dashboard/test/mermaid_renderer_test.rb`)
|
|
1054
|
+
|
|
1055
|
+
```ruby
|
|
1056
|
+
require "test_helper"
|
|
1057
|
+
|
|
1058
|
+
class MermaidRendererTest < ActiveSupport::TestCase
|
|
1059
|
+
def test_renders_nodes_edges_and_classes
|
|
1060
|
+
nodes = [
|
|
1061
|
+
{id: "n1", kind: :execute, label: "charge", status: :done},
|
|
1062
|
+
{id: "n2", kind: :wait_until, label: "funds?", status: :active}
|
|
1063
|
+
]
|
|
1064
|
+
edges = [ChronoForge::Definition::Edge.new(from: "n1", to: "n2", kind: :conditional, guard: "vip?")]
|
|
1065
|
+
out = ChronoForge::Dashboard::MermaidRenderer.new(nodes, edges).to_mermaid
|
|
1066
|
+
|
|
1067
|
+
assert_match(/\Aflowchart TD/, out)
|
|
1068
|
+
assert_includes out, 'n1["charge"]:::done'
|
|
1069
|
+
assert_includes out, "n1 -->|vip?| n2"
|
|
1070
|
+
assert_match(/classDef done /, out)
|
|
1071
|
+
end
|
|
1072
|
+
end
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
- [ ] **Step 2: Run to verify failure**
|
|
1076
|
+
|
|
1077
|
+
Run: `cd chrono_forge-dashboard && bundle exec rake test TEST=test/mermaid_renderer_test.rb`
|
|
1078
|
+
Expected: FAIL — constant missing.
|
|
1079
|
+
|
|
1080
|
+
- [ ] **Step 3: Implement** (`chrono_forge-dashboard/app/presenters/chrono_forge/dashboard/mermaid_renderer.rb`)
|
|
1081
|
+
|
|
1082
|
+
```ruby
|
|
1083
|
+
module ChronoForge
|
|
1084
|
+
module Dashboard
|
|
1085
|
+
# Renders statused overlay nodes + Definition edges to Mermaid flowchart text.
|
|
1086
|
+
# Rendering-only: no DB, no analysis.
|
|
1087
|
+
class MermaidRenderer
|
|
1088
|
+
SHAPES = {
|
|
1089
|
+
execute: ->(l) { "[\"#{l}\"]" }, wait: ->(l) { "([\"#{l}\"])" },
|
|
1090
|
+
wait_until: ->(l) { "{{\"#{l}\"}}" }, continue_if: ->(l) { "{\"#{l}\"}" },
|
|
1091
|
+
branch: ->(l) { "[/\"#{l}\"/]" }, merge: ->(l) { "[\\\"#{l}\\\"]" },
|
|
1092
|
+
repeat: ->(l) { "[[\"#{l}\"]]" }, join: ->(_) { "((\" \"))" },
|
|
1093
|
+
dynamic: ->(l) { "[\"#{l}\"]" }
|
|
1094
|
+
}.freeze
|
|
1095
|
+
|
|
1096
|
+
CLASS_DEFS = {
|
|
1097
|
+
done: "fill:#16a34a22,stroke:#16a34a", active: "fill:#2563eb22,stroke:#2563eb",
|
|
1098
|
+
pending: "fill:#a1a1aa22,stroke:#a1a1aa", not_reached: "fill:#fff,stroke:#d4d4d8",
|
|
1099
|
+
failed: "fill:#dc262622,stroke:#dc2626", stalled: "fill:#d9770622,stroke:#d97706",
|
|
1100
|
+
unmapped: "fill:#f5f5f4,stroke:#a8a29e,stroke-dasharray:3 3"
|
|
1101
|
+
}.freeze
|
|
1102
|
+
|
|
1103
|
+
def initialize(nodes, edges)
|
|
1104
|
+
@nodes = nodes
|
|
1105
|
+
@edges = edges
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
def to_mermaid
|
|
1109
|
+
lines = ["flowchart TD"]
|
|
1110
|
+
@nodes.each { |n| lines << " #{n[:id]}#{shape(n)}:::#{n[:status]}" }
|
|
1111
|
+
@edges.each { |e| lines << " #{edge(e)}" }
|
|
1112
|
+
used_statuses.each { |s| lines << " classDef #{s} #{CLASS_DEFS[s]}" }
|
|
1113
|
+
lines.join("\n")
|
|
1114
|
+
end
|
|
1115
|
+
|
|
1116
|
+
private
|
|
1117
|
+
|
|
1118
|
+
def shape(node)
|
|
1119
|
+
label = sanitize(node[:label].to_s)
|
|
1120
|
+
(SHAPES[node[:kind]] || SHAPES[:dynamic]).call(label)
|
|
1121
|
+
end
|
|
1122
|
+
|
|
1123
|
+
def edge(e)
|
|
1124
|
+
guard = e.guard && !e.guard.empty? ? "|#{sanitize(e.guard)}| " : ""
|
|
1125
|
+
arrow = e.kind == :terminal ? "-.->" : "-->"
|
|
1126
|
+
"#{e.from} #{arrow}#{guard.empty? ? " " : " #{guard}"}#{e.to}".squeeze(" ")
|
|
1127
|
+
end
|
|
1128
|
+
|
|
1129
|
+
def used_statuses = @nodes.map { |n| n[:status] }.uniq.select { |s| CLASS_DEFS.key?(s) }
|
|
1130
|
+
def sanitize(s) = s.gsub('"', "'").gsub(/[\[\]{}|]/, " ").strip
|
|
1131
|
+
end
|
|
1132
|
+
end
|
|
1133
|
+
end
|
|
1134
|
+
```
|
|
1135
|
+
|
|
1136
|
+
- [ ] **Step 4: Run to verify pass**
|
|
1137
|
+
|
|
1138
|
+
Run: `cd chrono_forge-dashboard && bundle exec rake test TEST=test/mermaid_renderer_test.rb`
|
|
1139
|
+
Expected: PASS. (If the edge-label assertion is whitespace-sensitive, adjust `edge` spacing to match `n1 -->|vip?| n2` exactly.)
|
|
1140
|
+
|
|
1141
|
+
- [ ] **Step 5: Commit**
|
|
1142
|
+
|
|
1143
|
+
```bash
|
|
1144
|
+
git add chrono_forge-dashboard/app/presenters/chrono_forge/dashboard/mermaid_renderer.rb chrono_forge-dashboard/test/mermaid_renderer_test.rb
|
|
1145
|
+
git commit -m "feat(dashboard): render statused definition graph to Mermaid"
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
---
|
|
1149
|
+
|
|
1150
|
+
### Task 8: Dashboard — definition page (route, controller, view, Mermaid, link)
|
|
1151
|
+
|
|
1152
|
+
**Goal:** A new `GET workflows/:id/definition` page that analyzes the workflow's class, overlays the run, renders the Mermaid graph client-side (vendored), lists warnings, and is linked from the workflow detail page.
|
|
1153
|
+
|
|
1154
|
+
**Files:**
|
|
1155
|
+
- Modify: `chrono_forge-dashboard/config/routes.rb`
|
|
1156
|
+
- Create: `chrono_forge-dashboard/app/controllers/chrono_forge/dashboard/definitions_controller.rb`
|
|
1157
|
+
- Create: `chrono_forge-dashboard/app/views/chrono_forge/dashboard/definitions/show.html.erb`
|
|
1158
|
+
- Create: `chrono_forge-dashboard/app/assets/chrono_forge/dashboard/mermaid.min.js` (vendored Mermaid UMD build)
|
|
1159
|
+
- Modify: `chrono_forge-dashboard/app/views/chrono_forge/dashboard/workflows/show.html.erb` (add link)
|
|
1160
|
+
- Modify: `chrono_forge-dashboard/config/routes.rb` assets constraint (serve mermaid.js)
|
|
1161
|
+
- Test: `chrono_forge-dashboard/test/definitions_controller_test.rb`
|
|
1162
|
+
|
|
1163
|
+
**Acceptance Criteria:**
|
|
1164
|
+
- [ ] `GET workflows/:id/definition` returns 200 and includes a `flowchart TD` payload for an analyzable workflow.
|
|
1165
|
+
- [ ] An unanalyzable/unknown class renders the page with a warning, not a 500.
|
|
1166
|
+
- [ ] The workflow detail page links to the new page.
|
|
1167
|
+
|
|
1168
|
+
**Verify:** `cd chrono_forge-dashboard && bundle exec rake test TEST=test/definitions_controller_test.rb` → green.
|
|
1169
|
+
|
|
1170
|
+
**Steps:**
|
|
1171
|
+
|
|
1172
|
+
- [ ] **Step 1: Write the failing controller test** (`chrono_forge-dashboard/test/definitions_controller_test.rb`)
|
|
1173
|
+
|
|
1174
|
+
```ruby
|
|
1175
|
+
require "test_helper"
|
|
1176
|
+
|
|
1177
|
+
class DefinitionsControllerTest < ActionDispatch::IntegrationTest
|
|
1178
|
+
include ChronoForge::Dashboard::Engine.routes.url_helpers
|
|
1179
|
+
def setup
|
|
1180
|
+
@wf = create_workflow(key: "def-page", state: :running, job_class: "DefinitionFixtures::Linear")
|
|
1181
|
+
end
|
|
1182
|
+
|
|
1183
|
+
def test_show_renders_a_flowchart
|
|
1184
|
+
get definition_workflow_path(@wf)
|
|
1185
|
+
assert_response :success
|
|
1186
|
+
assert_match(/flowchart TD/, @response.body)
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1189
|
+
def test_unknown_class_degrades_gracefully
|
|
1190
|
+
@wf.update!(job_class: "Nope::DoesNotExist")
|
|
1191
|
+
get definition_workflow_path(@wf)
|
|
1192
|
+
assert_response :success
|
|
1193
|
+
assert_match(/statically analyz/i, @response.body)
|
|
1194
|
+
end
|
|
1195
|
+
end
|
|
1196
|
+
```
|
|
1197
|
+
|
|
1198
|
+
Ensure `DefinitionFixtures::Linear` is loadable from the dashboard test env (add `require` in the dashboard `test_helper.rb`, or define a small analyzable workflow class in the dashboard test support).
|
|
1199
|
+
|
|
1200
|
+
- [ ] **Step 2: Run to verify failure**
|
|
1201
|
+
|
|
1202
|
+
Run: `cd chrono_forge-dashboard && bundle exec rake test TEST=test/definitions_controller_test.rb`
|
|
1203
|
+
Expected: FAIL — no route/controller.
|
|
1204
|
+
|
|
1205
|
+
- [ ] **Step 3: Add the route** (`chrono_forge-dashboard/config/routes.rb`) — add inside the `resources :workflows` member block, and extend the assets constraint:
|
|
1206
|
+
|
|
1207
|
+
```ruby
|
|
1208
|
+
member do
|
|
1209
|
+
post :retry, to: "actions#retry"
|
|
1210
|
+
post :resume, to: "actions#resume"
|
|
1211
|
+
post :unlock, to: "actions#unlock"
|
|
1212
|
+
get :repetitions, to: "repetitions#index"
|
|
1213
|
+
get :definition, to: "definitions#show"
|
|
1214
|
+
end
|
|
1215
|
+
```
|
|
1216
|
+
|
|
1217
|
+
and change the assets line to also serve `mermaid.min.js`:
|
|
1218
|
+
|
|
1219
|
+
```ruby
|
|
1220
|
+
get "assets/:file", to: "assets#show", constraints: {file: /(dashboard\.(css|js)|mermaid\.min\.js)/}
|
|
1221
|
+
```
|
|
1222
|
+
|
|
1223
|
+
(Confirm `AssetsController#show` maps the filename to `app/assets/chrono_forge/dashboard/#{file}`; extend its allowlist if it hardcodes names.)
|
|
1224
|
+
|
|
1225
|
+
- [ ] **Step 4: Add the controller** (`chrono_forge-dashboard/app/controllers/chrono_forge/dashboard/definitions_controller.rb`)
|
|
1226
|
+
|
|
1227
|
+
```ruby
|
|
1228
|
+
module ChronoForge
|
|
1229
|
+
module Dashboard
|
|
1230
|
+
class DefinitionsController < BaseController
|
|
1231
|
+
def show
|
|
1232
|
+
@workflow = ChronoForge::Workflow.find(params[:id])
|
|
1233
|
+
definition = analyze(@workflow)
|
|
1234
|
+
overlay = DefinitionOverlay.new(definition, @workflow)
|
|
1235
|
+
@nodes = overlay.nodes
|
|
1236
|
+
@warnings = overlay.warnings
|
|
1237
|
+
@mermaid = MermaidRenderer.new(@nodes, definition.edges).to_mermaid
|
|
1238
|
+
end
|
|
1239
|
+
|
|
1240
|
+
private
|
|
1241
|
+
|
|
1242
|
+
# Never let analysis break the page: an unknown/unloadable class or an
|
|
1243
|
+
# unanalyzable body yields an empty definition with a warning.
|
|
1244
|
+
def analyze(workflow)
|
|
1245
|
+
klass = workflow.job_class.constantize
|
|
1246
|
+
ChronoForge::DefinitionAnalyzer.call(klass) ||
|
|
1247
|
+
ChronoForge::Definition.new(warnings: ["perform source is not statically analyzable"])
|
|
1248
|
+
rescue NameError
|
|
1249
|
+
ChronoForge::Definition.new(warnings: ["workflow class #{workflow.job_class} is not loadable"])
|
|
1250
|
+
end
|
|
1251
|
+
end
|
|
1252
|
+
end
|
|
1253
|
+
end
|
|
1254
|
+
```
|
|
1255
|
+
|
|
1256
|
+
- [ ] **Step 5: Add the view** (`chrono_forge-dashboard/app/views/chrono_forge/dashboard/definitions/show.html.erb`)
|
|
1257
|
+
|
|
1258
|
+
```erb
|
|
1259
|
+
<%= link_to "‹ Back to workflow", workflow_path(@workflow), class: cf_chip("mb-2") %>
|
|
1260
|
+
|
|
1261
|
+
<div class="mb-4">
|
|
1262
|
+
<h1 class="text-lg font-semibold text-zinc-800">Definition graph</h1>
|
|
1263
|
+
<p class="text-xs text-zinc-500"><%= @workflow.job_class %> — <%= @workflow.key %></p>
|
|
1264
|
+
</div>
|
|
1265
|
+
|
|
1266
|
+
<% if @warnings.any? %>
|
|
1267
|
+
<div class="mb-4 rounded border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
|
|
1268
|
+
<p class="font-medium mb-1">Static analysis notes</p>
|
|
1269
|
+
<ul class="list-disc pl-4 space-y-0.5">
|
|
1270
|
+
<% @warnings.each do |w| %><li><%= w %></li><% end %>
|
|
1271
|
+
</ul>
|
|
1272
|
+
</div>
|
|
1273
|
+
<% end %>
|
|
1274
|
+
|
|
1275
|
+
<div class="rounded border border-zinc-200 bg-white p-4 overflow-auto">
|
|
1276
|
+
<pre class="mermaid"><%= @mermaid %></pre>
|
|
1277
|
+
</div>
|
|
1278
|
+
|
|
1279
|
+
<script src="<%= dashboard_asset_path("mermaid.min.js") %>"></script>
|
|
1280
|
+
<script>
|
|
1281
|
+
(function () {
|
|
1282
|
+
if (window.mermaid) { window.mermaid.initialize({ startOnLoad: true }); window.mermaid.run(); }
|
|
1283
|
+
})();
|
|
1284
|
+
</script>
|
|
1285
|
+
```
|
|
1286
|
+
|
|
1287
|
+
Use whatever asset-path helper the dashboard already exposes for `dashboard.js`/`dashboard.css` (grep the layout `application.html.erb`); if it's a bare route, replace `dashboard_asset_path("mermaid.min.js")` with `assets_path(file: "mermaid.min.js")` or the engine's equivalent. `cf_chip` is the existing chip helper used in `workflows/show.html.erb`.
|
|
1288
|
+
|
|
1289
|
+
- [ ] **Step 6: Vendor Mermaid** — download a pinned Mermaid UMD build to `chrono_forge-dashboard/app/assets/chrono_forge/dashboard/mermaid.min.js`:
|
|
1290
|
+
|
|
1291
|
+
```bash
|
|
1292
|
+
curl -fsSL https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js \
|
|
1293
|
+
-o chrono_forge-dashboard/app/assets/chrono_forge/dashboard/mermaid.min.js
|
|
1294
|
+
test -s chrono_forge-dashboard/app/assets/chrono_forge/dashboard/mermaid.min.js && echo "vendored"
|
|
1295
|
+
```
|
|
1296
|
+
|
|
1297
|
+
- [ ] **Step 7: Link from the workflow detail page** (`chrono_forge-dashboard/app/views/chrono_forge/dashboard/workflows/show.html.erb`) — add near the existing action links/header (match surrounding markup):
|
|
1298
|
+
|
|
1299
|
+
```erb
|
|
1300
|
+
<%= link_to "Definition graph", definition_workflow_path(@workflow), class: cf_chip("mb-2") %>
|
|
1301
|
+
```
|
|
1302
|
+
|
|
1303
|
+
- [ ] **Step 8: Run the page test + full dashboard suite**
|
|
1304
|
+
|
|
1305
|
+
Run: `cd chrono_forge-dashboard && bundle exec rake test TEST=test/definitions_controller_test.rb`
|
|
1306
|
+
Then: `cd chrono_forge-dashboard && bundle exec rake test`
|
|
1307
|
+
Expected: all PASS.
|
|
1308
|
+
|
|
1309
|
+
- [ ] **Step 9: Commit**
|
|
1310
|
+
|
|
1311
|
+
```bash
|
|
1312
|
+
git add chrono_forge-dashboard/config/routes.rb \
|
|
1313
|
+
chrono_forge-dashboard/app/controllers/chrono_forge/dashboard/definitions_controller.rb \
|
|
1314
|
+
chrono_forge-dashboard/app/views/chrono_forge/dashboard/definitions/show.html.erb \
|
|
1315
|
+
chrono_forge-dashboard/app/views/chrono_forge/dashboard/workflows/show.html.erb \
|
|
1316
|
+
chrono_forge-dashboard/app/assets/chrono_forge/dashboard/mermaid.min.js \
|
|
1317
|
+
chrono_forge-dashboard/test/definitions_controller_test.rb
|
|
1318
|
+
git commit -m "feat(dashboard): per-run workflow definition DAG page with Mermaid"
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
---
|
|
1322
|
+
|
|
1323
|
+
### Task 9: Full suite + docs
|
|
1324
|
+
|
|
1325
|
+
**Goal:** Confirm the whole feature is green across both packages and note the feature in the scale/dashboard docs.
|
|
1326
|
+
|
|
1327
|
+
**Files:**
|
|
1328
|
+
- Modify: `chrono_forge-dashboard/README.md` (or the dashboard docs) — one paragraph on the definition page.
|
|
1329
|
+
|
|
1330
|
+
**Acceptance Criteria:**
|
|
1331
|
+
- [ ] Core suite green: `bundle exec rake test`.
|
|
1332
|
+
- [ ] Dashboard suite green: `cd chrono_forge-dashboard && bundle exec rake test`.
|
|
1333
|
+
- [ ] `bundle exec standardrb` (or the repo's linter) clean on new files.
|
|
1334
|
+
|
|
1335
|
+
**Verify:** both suites green; lint clean.
|
|
1336
|
+
|
|
1337
|
+
**Steps:**
|
|
1338
|
+
|
|
1339
|
+
- [ ] **Step 1: Run both suites**
|
|
1340
|
+
|
|
1341
|
+
```bash
|
|
1342
|
+
bundle exec rake test
|
|
1343
|
+
cd chrono_forge-dashboard && bundle exec rake test && cd ..
|
|
1344
|
+
```
|
|
1345
|
+
Expected: all PASS.
|
|
1346
|
+
|
|
1347
|
+
- [ ] **Step 2: Lint new files**
|
|
1348
|
+
|
|
1349
|
+
Run: `bundle exec standardrb lib/chrono_forge/definition.rb lib/chrono_forge/definition_analyzer.rb`
|
|
1350
|
+
Fix any offenses.
|
|
1351
|
+
|
|
1352
|
+
- [ ] **Step 3: Document the page** (`chrono_forge-dashboard/README.md`) — add a short "Definition graph" paragraph describing the per-run static DAG + overlay.
|
|
1353
|
+
|
|
1354
|
+
- [ ] **Step 4: Commit**
|
|
1355
|
+
|
|
1356
|
+
```bash
|
|
1357
|
+
git add -A
|
|
1358
|
+
git commit -m "docs: note the workflow definition DAG page; final lint pass"
|
|
1359
|
+
```
|
|
1360
|
+
|
|
1361
|
+
---
|
|
1362
|
+
|
|
1363
|
+
## Self-Review
|
|
1364
|
+
|
|
1365
|
+
**Spec coverage:** Analyzer core + all 7 primitives → Tasks 2–5 (linear; conditionals/continue_if; branch/merge fan-out; repeat/helper-tracing/loop-warnings). Value objects → Task 1. Overlay + status vocabulary + fan-out/repeat aggregates + unmapped → Task 6. Mermaid rendering → Task 7. New per-run page + route + link + vendored Mermaid + graceful degradation → Task 8. Caching is an optimization noted in the spec; the controller analyzes per request (memoization by class+digest is a trivial follow-up, deliberately deferred to avoid premature caching — noted here as the one spec item intentionally not wired in v1). Testing → each task is TDD; full-suite gate → Task 9.
|
|
1366
|
+
|
|
1367
|
+
**Placeholder scan:** No TBD/TODO. Two steps say "use the existing helper (grep X)" for the asset-path helper and `create_workflow` factory — these reference concrete existing dashboard conventions the implementer must match rather than invent; acceptable (they name the exact thing to find).
|
|
1368
|
+
|
|
1369
|
+
**Type consistency:** `DefinitionAnalyzer.call → Definition`; `Definition#nodes/#edges/#warnings`; `Node#to_h` used by the overlay; overlay returns **hashes** (with `:status`) and `MermaidRenderer.new(nodes_hashes, edges)` consumes hashes for nodes + `Edge` structs for edges — consistent between Tasks 6 and 7. Step-name strings match the DSL table. `DURABLE` keys match method names.
|
|
1370
|
+
|
|
1371
|
+
**Verification requirement scan:** The prompt ("explore… parsing a workflow for the future timeline", "Do it") requests NO user verification, confirmation, or human sign-off of outcomes. Answer: **NO.** No `requiresUserVerification` task needed.
|
|
1372
|
+
|
|
1373
|
+
**Deferred (per spec):** cross-class tracing; recursive child-workflow expansion; per-node ETA; class-level no-overlay view; Definition memoization.
|