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.
@@ -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.