chrono_forge 0.9.1 → 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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +390 -46
  4. data/Rakefile +4 -0
  5. data/cliff.toml +62 -0
  6. data/docs/design/per-child-commit-overhead.md +213 -0
  7. data/docs/fanout-scale-test.md +247 -0
  8. data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md +1748 -0
  9. data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md.tasks.json +17 -0
  10. data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md +930 -0
  11. data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md.tasks.json +54 -0
  12. data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md +241 -0
  13. data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md.tasks.json +12 -0
  14. data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md +1378 -0
  15. data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md.tasks.json +67 -0
  16. data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md +709 -0
  17. data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md.tasks.json +19 -0
  18. data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md +205 -0
  19. data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md.tasks.json +33 -0
  20. data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md +1373 -0
  21. data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md.tasks.json +68 -0
  22. data/docs/superpowers/specs/2026-06-03-unified-retry-policy-design.md +226 -0
  23. data/docs/superpowers/specs/2026-06-25-chrono_forge-dashboard-design.md +190 -0
  24. data/docs/superpowers/specs/2026-06-25-composite-retry-policies-design.md +228 -0
  25. data/docs/superpowers/specs/2026-06-25-reserved-kwarg-guard-design.md +169 -0
  26. data/docs/superpowers/specs/2026-06-25-spawn-merge-branches-design.md +468 -0
  27. data/docs/superpowers/specs/2026-06-26-dashboard-branch-view-design.md +142 -0
  28. data/docs/superpowers/specs/2026-06-26-deferral-continuation-race-and-catchup-design.md +265 -0
  29. data/docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md +203 -0
  30. data/lib/chrono_forge/branch_merge_job.rb +275 -0
  31. data/lib/chrono_forge/branch_probe.rb +70 -0
  32. data/lib/chrono_forge/cleanup.rb +6 -0
  33. data/lib/chrono_forge/configuration.rb +25 -0
  34. data/lib/chrono_forge/definition.rb +37 -0
  35. data/lib/chrono_forge/definition_analyzer.rb +501 -0
  36. data/lib/chrono_forge/execution_log.rb +6 -0
  37. data/lib/chrono_forge/executor/composite_retry_policy.rb +47 -0
  38. data/lib/chrono_forge/executor/context.rb +23 -0
  39. data/lib/chrono_forge/executor/lock_strategy.rb +10 -3
  40. data/lib/chrono_forge/executor/methods/branch.rb +185 -0
  41. data/lib/chrono_forge/executor/methods/continue_if.rb +15 -6
  42. data/lib/chrono_forge/executor/methods/durably_execute.rb +36 -26
  43. data/lib/chrono_forge/executor/methods/durably_repeat.rb +148 -39
  44. data/lib/chrono_forge/executor/methods/merge_branches.rb +84 -0
  45. data/lib/chrono_forge/executor/methods/wait.rb +2 -4
  46. data/lib/chrono_forge/executor/methods/wait_until.rb +25 -25
  47. data/lib/chrono_forge/executor/methods/workflow_states.rb +50 -46
  48. data/lib/chrono_forge/executor/methods.rb +2 -0
  49. data/lib/chrono_forge/executor/retry_policy.rb +111 -0
  50. data/lib/chrono_forge/executor.rb +241 -28
  51. data/lib/chrono_forge/version.rb +1 -1
  52. data/lib/chrono_forge/workflow.rb +10 -1
  53. data/lib/chrono_forge.rb +8 -0
  54. data/lib/generators/chrono_forge/migration_actions.rb +1 -0
  55. data/lib/generators/chrono_forge/templates/add_chrono_forge_parent_execution_log.rb +38 -0
  56. data/lib/tasks/release.rake +212 -0
  57. metadata +67 -4
  58. data/lib/chrono_forge/executor/retry_strategy.rb +0 -29
@@ -0,0 +1,265 @@
1
+ # ChronoForge — deferral continuation race & catch-up surge
2
+
3
+ **Date:** 2026-06-26
4
+ **Gem:** `chrono_forge` 0.9.1
5
+ **Status:** design approved, ready for implementation plan
6
+
7
+ ## Problem
8
+
9
+ Two related findings in how ChronoForge's deferral primitives (`wait`, `wait_until`,
10
+ `durably_execute` retry, `durably_repeat`, workflow-level retry) schedule their
11
+ continuation jobs. Both are functionally benign in 0.9.1 (no lost work, no double
12
+ execution) but generate avoidable job/lock churn and log noise, and they interact.
13
+
14
+ ### Issue 1 — continuation/lock-release race (`ConcurrentExecutionError`)
15
+
16
+ Every deferral primitive enqueues its continuation **inline** and then halts:
17
+
18
+ ```ruby
19
+ self.class.set(wait: delay).perform_later(@workflow.key) # (1) enqueue continuation
20
+ halt_execution! # (2) raise HaltExecutionFlow
21
+ ```
22
+
23
+ The executor releases the lock in `ensure`, **after** the body runs
24
+ (`executor.rb:168-172`). So within one job run the order is: **enqueue continuation →
25
+ halt → (ensure) release lock.** The continuation is published while the current job
26
+ still holds the lock.
27
+
28
+ When the continuation is **immediately runnable** (`delay == 0`), SolidQueue puts it
29
+ straight in `ready_executions`. With multiple workers, a free worker can claim and
30
+ start it in the window between (1) and the `ensure` release. That second job calls
31
+ `acquire_lock`, finds `locked_at > max_duration.ago` (still freshly held by the first
32
+ job), and raises `ConcurrentExecutionError` at lock acquisition (failing
33
+ `execution_log.step_name` is `nil` — before any step).
34
+
35
+ `delay == 0` arises when:
36
+ - `wait` targets computed against wall-clock times already in the past on replay, and
37
+ - **every fast-forwarded tick in Issue 2** (`delay = max(next − now, 0) = 0`).
38
+
39
+ Benign today (loser is rescued, winner proceeds, continuation replays idempotently),
40
+ but costs wasted job executions, redundant lock attempts, and log noise.
41
+
42
+ ### Issue 2 — catch-up is O(missed intervals)
43
+
44
+ When a `durably_repeat` workflow resumes far behind schedule, each missed tick is
45
+ handled by `execute_repetition_now`. For an expired tick it correctly **skips the
46
+ periodic method**, but then advances by exactly **one interval** and enqueues a **new
47
+ job** (`durably_repeat.rb:200-212`, `:271-293`):
48
+
49
+ ```ruby
50
+ if Time.current > repetition_log.metadata["timeout_at"]
51
+ repetition_log.update!(state: :failed, error_class: "TimeoutError")
52
+ schedule_next_execution_after_completion(...) # advance ONE interval + enqueue a job
53
+ return # method NOT run (work correctly skipped)
54
+ end
55
+ ```
56
+
57
+ So expiry is a **work skip, not an iteration skip**. Walking a workflow from a far-past
58
+ `start_at` up to `now` churns through **one `delay == 0` job per missed interval** — each
59
+ job marks one tick timed-out, schedules the next, and halts. Resuming ~14 dormant
60
+ daily/weekly schedulers generated ~6,000 back-to-back `delay == 0` jobs. Every one of
61
+ those is the maximal trigger for Issue 1.
62
+
63
+ Worst case: a workflow resuming from genesis (no prior coordination/repetition logs)
64
+ with an ancient `start_at`.
65
+
66
+ ## Enqueue sites (complete inventory)
67
+
68
+ All 8 continuation enqueues are `.set(wait:).perform_later`; `continue_if` halts with no
69
+ continuation (it waits for an external trigger — correctly needs no fix).
70
+
71
+ | # | Site | kwargs passed | delay |
72
+ |---|------|---------------|-------|
73
+ | 1 | `executor.rb:163` workflow retry | `attempt:, retry_counts:` | backoff |
74
+ | 2 | `wait.rb:107` reschedule | — | duration |
75
+ | 3 | `wait_until.rb:135` cond-error retry | — | backoff |
76
+ | 4 | `wait_until.rb:181` poll | `wait_condition:` | check_interval |
77
+ | 5 | `durably_execute.rb:112` retry | — | backoff |
78
+ | 6 | `durably_repeat.rb:193` schedule-later | — | delay |
79
+ | 7 | `durably_repeat.rb:235` repetition retry | — | backoff |
80
+ | 8 | `durably_repeat.rb:288` schedule-next | — | delay (=0 in surge) |
81
+
82
+ ## Fix — Section 1: deferred continuation flush
83
+
84
+ Primitives stop calling `perform_later` inline. They **record** the intended
85
+ continuation on the instance; the executor flushes it in `ensure`, **after**
86
+ `release_lock`. The continuation becomes claimable only once the lock row reads
87
+ released, so no second worker can lose the acquire race. This is the report's
88
+ **option 1** (fully closes the window), not option 3 (epsilon delay heuristic).
89
+
90
+ **Single slot suffices.** Every primitive enqueues at most one continuation and then
91
+ either raises `HaltExecutionFlow` (sites 2–8) or falls through `rescue => e` into
92
+ `ensure` (site 1). No path schedules two.
93
+
94
+ ```ruby
95
+ # executor.rb — new private helper
96
+ def enqueue_continuation(wait:, **kwargs)
97
+ @continuation = {wait: wait, kwargs: kwargs}
98
+ end
99
+ ```
100
+
101
+ Each of the 8 sites changes from:
102
+
103
+ ```ruby
104
+ self.class.set(wait: delay).perform_later(@workflow.key) # or with kwargs
105
+ halt_execution!
106
+ ```
107
+
108
+ to:
109
+
110
+ ```ruby
111
+ enqueue_continuation(wait: delay) # kwargs preserved per-site
112
+ halt_execution!
113
+ ```
114
+
115
+ Flush in `ensure` (`executor.rb:168`), strictly ordered after release:
116
+
117
+ ```ruby
118
+ ensure
119
+ if lock_acquired
120
+ context.save!
121
+ self.class::LockStrategy.release_lock(job_id, workflow)
122
+ flush_continuation! # NEW — only now is the next job claimable
123
+ end
124
+ end
125
+
126
+ def flush_continuation!
127
+ return unless @continuation
128
+ self.class.set(wait: @continuation[:wait]).perform_later(@workflow.key, **@continuation[:kwargs])
129
+ end
130
+ ```
131
+
132
+ **Ordering guarantee:** `save! → release_lock → flush`. The continuation is published
133
+ only after the lock row is updated to released, so even a `delay == 0` continuation
134
+ finds the lock free.
135
+
136
+ **Edge cases:**
137
+ - If `release_lock` raises `LongRunningConcurrentExecutionError` (this job overran
138
+ `max_duration` and lost the lock), we do **not** flush — correct, another job already
139
+ owns the continuation.
140
+ - Site 1 (workflow retry) isn't a halt, but routing it through the same slot keeps all
141
+ enqueues post-release and is harmless (backoff is normally > 0 anyway).
142
+ - `@continuation` is per-job-execution instance state; nil unless a primitive set it.
143
+
144
+ ## Fix — Section 2: closed-form fast-forward of the expired prefix
145
+
146
+ In `durably_repeat` (`durably_repeat.rb:143-151`), after the naive `next_execution_at`
147
+ is computed and before `execute_or_schedule_repetition`, jump past the expired prefix in
148
+ closed form instead of walking one job per tick.
149
+
150
+ **Skip rule (from the code):** a tick `t` is expired iff `Time.current > t + timeout`,
151
+ i.e. `t < now − timeout`. Find the smallest tick on the grid `next_execution_at + n·every`
152
+ (n ≥ 0) that is **not** expired (`t ≥ now − timeout`):
153
+
154
+ ```ruby
155
+ def fast_forward_expired_prefix(next_execution_at, every, timeout)
156
+ cutoff = Time.current - timeout
157
+ return next_execution_at if next_execution_at >= cutoff # nothing expired
158
+
159
+ gap = cutoff - next_execution_at
160
+ n = (gap / every.to_f).ceil # n ≥ 1 here
161
+ Rails.logger.info {
162
+ "ChronoForge:#{self.class}(#{@workflow.key}) durably_repeat fast-forwarded #{n} expired tick(s)"
163
+ }
164
+ next_execution_at + (n * every)
165
+ end
166
+ ```
167
+
168
+ **Why anchor on `next_execution_at`, not `start_at`.** `next_execution_at` is always
169
+ already on the canonical grid `anchor + k·every`:
170
+
171
+ 1. `start_at` given, no `last_execution_at` → `next = start_at`. On-grid (k=0).
172
+ 2. No `start_at`, no `last_execution_at` → `next = created_at + every`. On-grid (k=0).
173
+ 3. `last_execution_at` present → `next = last_execution_at + every`. On-grid because
174
+ `last_execution_at` stores the **scheduled** tick time, not wall-clock:
175
+ `schedule_next_execution_after_completion` writes `current_execution_time.iso8601`
176
+ (`durably_repeat.rb:275`), where `current_execution_time` is the scheduled tick, not
177
+ `Time.current`. By induction, lateness never enters the recurrence.
178
+
179
+ So jumping by integer multiples of `every` from `next_execution_at` stays exactly on the
180
+ grid — **no drift**. Anchoring the ceil on `start_at` (as the report's formula literally
181
+ writes) would compute against a different anchor than the grid the workflow is actually
182
+ on (branches 2 and 3) and could land between real ticks.
183
+
184
+ **Boundary correctness — only the expired prefix is skipped.** The jump lands on the
185
+ first tick with `t ≥ now − timeout`, which is either:
186
+ - **in-window** (`now − timeout ≤ t ≤ now`): `execute_or_schedule_repetition` sees
187
+ `t ≤ now` → runs `execute_repetition_now`, which re-checks `now > timeout_at` (now
188
+ false) → **executes the work**. Legitimate catch-up preserved.
189
+ - **future** (`t > now`): → `schedule_repetition_for_later`. Normal.
190
+
191
+ If `timeout > every` there can be several in-window ticks; those still walk one job each
192
+ by design (real work, not bookkeeping). Only the expired prefix collapses to O(1).
193
+
194
+ **Coordination-log bookkeeping.** As part of the fast-forward, set the coordination
195
+ log's `last_execution_at = (first_valid − every).iso8601` (same format the reader
196
+ `Time.parse` expects). A replay then recomputes `naive_next = last_execution_at + every
197
+ = first_valid` — stable and idempotent — and the expired prefix produces **one metadata
198
+ update** instead of N `failed/TimeoutError` repetition rows and N jobs.
199
+
200
+ **One summary row for the skipped prefix (decided).** Instead of N `failed/TimeoutError`
201
+ repetition rows, the fast-forward writes a **single** durable `ExecutionLog` covering the
202
+ whole skipped prefix, so the skip stays dashboard-visible and queryable:
203
+
204
+ - **step_name:** `durably_repeat$<name>$<last_skipped_tick.to_i>`, where
205
+ `last_skipped_tick = first_valid − every`. This is the last expired grid tick, so it is
206
+ unique and **never collides** with the repetition row for `first_valid` (the first
207
+ in-window/future tick, which `execute_or_schedule_repetition` still creates and runs).
208
+ - **state:** `failed` (the enum has only `pending/completed/failed` — no migration),
209
+ **error_class:** `"TimeoutError"`, **error_message:** `"Fast-forwarded N expired tick(s)"`.
210
+ - **metadata:** `{ fast_forwarded: N, from: <first_expired.iso8601>,
211
+ to: <last_skipped.iso8601>, scheduled_for: <last_skipped>, timeout_at: <last_skipped + timeout>,
212
+ parent_id: <coordination_log.id> }` — mirrors the existing repetition-log metadata shape
213
+ plus the `fast_forwarded`/`from`/`to` summary fields.
214
+
215
+ Created via `find_or_create_execution_log!`, so it is idempotent on replay (and the
216
+ 3-segment step name is correctly excluded from `completed_step_cache`, matching ordinary
217
+ repetition logs). A `Rails.logger.info { "...fast-forwarded N expired tick(s)" }` line is
218
+ also emitted for ops. This is a deliberate behavior change from 0.9.1's one-row-per-tick.
219
+
220
+ The existing dashboard step-name parser already handles 3-segment
221
+ `durably_repeat$<name>$<ts>` repetition steps, so **no dashboard change is required** for
222
+ this plan; the summary row renders like any other repetition log.
223
+
224
+ **Observable-behavior change → existing tests updated.** Two tests assert the old
225
+ per-tick tombstones via `timeout: -1.second` and must be updated to the new behavior:
226
+ - `durably_repeat_test.rb:116` `test_durably_repeat_with_timeout` — asserts
227
+ `timeout_logs.size > 0` (filtering `error_message == "Execution timed out"`); flip to
228
+ asserting **no** `Execution timed out` rows and exactly **one** `fast_forwarded` summary
229
+ row for the expired prefix.
230
+ - `durably_repeat_test.rb:345` `test_durably_repeat_coordination_log_updated_on_timeout`
231
+ — its `last_execution_at`-advances assertion still holds; its `timeout_logs.size > 0`
232
+ assertion flips to asserting the single `fast_forwarded` summary row instead.
233
+
234
+ The `wait_until` negative-timeout test (`error_log_correlation_test.rb:23`) is a
235
+ different primitive and is unaffected. Catch-up tests using the default positive timeout
236
+ (`test_durably_repeat_with_past_start_at`, etc.) are unaffected because nothing is
237
+ expired under a 1-hour window.
238
+
239
+ **Idempotency / replay safety.** The skipped ticks never get repetition logs, but
240
+ they're never recomputed either (the jump advances `last_execution_at` past them), and
241
+ all execution-log lookups are by exact `step_name` — nothing scans for the missing rows.
242
+ Prior completed/failed ticks from before dormancy are untouched.
243
+
244
+ ## Interaction
245
+
246
+ The two share a root: continuations are published as immediately-claimable, same-key
247
+ jobs while/just-before the lock is released. The catch-up surge (Issue 2) is the
248
+ maximal trigger for the race (Issue 1). Section 1 closes the race structurally;
249
+ Section 2 removes the burst of `delay == 0` continuations that most reliably arms it.
250
+ Both together remove the class of problem.
251
+
252
+ ## Testing
253
+
254
+ - **Issue 1:** unit-test that each of the 8 primitives sets `@continuation` and does
255
+ **not** call `perform_later` inline; that the executor flushes after `release_lock`
256
+ (assert ordering — e.g. the enqueue observes the lock row released); that
257
+ `LongRunningConcurrentExecutionError` from `release_lock` suppresses the flush; that
258
+ per-site kwargs (`attempt`/`retry_counts`, `wait_condition`) are preserved.
259
+ - **Issue 2:** unit-test `fast_forward_expired_prefix` returns `next_execution_at`
260
+ unchanged when nothing is expired; lands exactly on the first non-expired grid tick;
261
+ is on-grid across all three anchor branches; that an in-window first tick executes its
262
+ work while the expired prefix creates no repetition rows; that `last_execution_at` is
263
+ advanced so a replay is stable. Integration: resume a far-past daily schedule and
264
+ assert O(1) jobs/log rows for the expired prefix instead of O(missed intervals).
265
+ ```
@@ -0,0 +1,203 @@
1
+ # Workflow Definition DAG — static "future timeline" for ChronoForge
2
+
3
+ **Status:** Design approved (pending written-spec review)
4
+ **Date:** 2026-07-01
5
+ **Reference:** the `durable_flow` gem's `DefinitionAnalyzer` (Prism-based static analyzer + definition DAG overlaid with runtime status).
6
+
7
+ ## Problem
8
+
9
+ The dashboard today shows only the **historical** timeline of a workflow — the
10
+ `execution_logs` that have already run. There is no forward view: an operator
11
+ can't see the steps a workflow *will* run, where the current run sits in the
12
+ overall shape, or which branches/loops are still ahead.
13
+
14
+ ChronoForge workflows are plain Ruby: a `perform` method that the engine
15
+ **replays** every resume, with each durable step identified by a string name
16
+ (`durably_execute$name`, `wait_until$cond`, `branch$name`, `merge$a,b`,
17
+ `durably_repeat$name$<ts>`). Because the structure is expressed in source, we can
18
+ recover a *projection* of the step sequence by statically parsing `perform` with
19
+ Prism — without executing anything — and then paint the run's actual status onto
20
+ that static map.
21
+
22
+ ## Goal
23
+
24
+ A **new per-run dashboard page** that renders a workflow's **conditional DAG**
25
+ (the static definition graph) with the current run's `execution_logs` **overlaid**
26
+ as node status. The existing workflow detail page is unchanged; it gains a link
27
+ to this page.
28
+
29
+ Non-goals for v1 are listed under [Scope](#scope-v1).
30
+
31
+ ## Key decisions (locked during brainstorming)
32
+
33
+ 1. **Primary consumer:** dashboard overlay — run status painted on the static map
34
+ (mirrors durable_flow's run → definition-DAG view).
35
+ 2. **Map shape:** a **conditional DAG** — guarded edges for `if`/`continue_if`,
36
+ fan-out groups for branches, joins for merges.
37
+ 3. **Fidelity:** **conservative + trace same-class helper methods**. Resolve step
38
+ names statically where possible; anything unresolvable (computed `name:`,
39
+ data-dependent loop count, a durable call behind an unknown/external call)
40
+ becomes an explicit **`dynamic` node with a warning**. No unrolling, no
41
+ cross-class tracing.
42
+ 4. **Rendering:** **Mermaid.js** (client-side, vendored). The analyzer's graph
43
+ model is rendering-agnostic; a renderer emits Mermaid flowchart text with
44
+ status encoded as node classes.
45
+ 5. **Static vs runtime:** static Prism analysis is the source of the *shape*
46
+ (only it can show not-yet-run steps and untaken branches); the run log is the
47
+ *overlay*, never the source of the graph.
48
+ 6. **Placement:** a **new route/page**, not an inline addition to the detail page.
49
+
50
+ ## Architecture
51
+
52
+ ```
53
+ workflow_class
54
+ │ DefinitionAnalyzer.call (core gem; Prism; memoized by class + source digest)
55
+
56
+ Definition (Node[], Edge[], warnings) (plain, JSON-serializable value objects)
57
+ │ DefinitionOverlay(execution_logs) (dashboard; read-only queries; per-run; never cached)
58
+
59
+ statused Definition
60
+ │ MermaidRenderer (dashboard; statused graph → flowchart text)
61
+
62
+ new DAG page → vendored Mermaid JS renders client-side (inside data-poll-region)
63
+ ```
64
+
65
+ ### Core gem — `lib/chrono_forge/` (rendering-agnostic, no dashboard/DB dependency)
66
+
67
+ - **`ChronoForge::DefinitionAnalyzer`** — `.call(workflow_class) → Definition`.
68
+ - Resolves `workflow_class.instance_method(:perform).source_location`, reads the
69
+ file, `Prism.parse`, locates the `perform` def node, and walks its body with a
70
+ visitor.
71
+ - **Traces durable calls in same-class helper methods** to a fixed point within
72
+ the class (a call to a method defined on the same class whose body contains
73
+ durable DSL calls is expanded inline; recursion is guarded).
74
+ - Emits nodes, edges, and warnings. **Only reads source text — never touches the
75
+ DB, never executes workflow code.**
76
+ - **`ChronoForge::Definition`** (+ `Node`, `Edge`) — plain value objects,
77
+ JSON-serializable so a `Definition` can be cached.
78
+ - `Node`: `id`, `kind` ∈ `{:execute, :wait, :wait_until, :continue_if, :branch,
79
+ :merge, :repeat, :dynamic}`, `label`, and **either** an exact `step_name`
80
+ **or** a `step_name_pattern` (fan-out/repeat/dynamic), plus optional `guard`
81
+ (condition source label) and `warnings`.
82
+ - `Edge`: `from`, `to`, optional `guard` label, and a `kind` (`:seq`,
83
+ `:conditional`, `:fanout`, `:join`, `:terminal`).
84
+
85
+ ### Dashboard package — `chrono_forge-dashboard/`
86
+
87
+ - **`DefinitionOverlay`** — takes a `Definition` + a workflow's `execution_logs`
88
+ (and, for `:branch`/`:merge` nodes, child-workflow state counts via the existing
89
+ `BranchProbe`) and annotates each node with a runtime `status`. Read-only.
90
+ - **`MermaidRenderer`** — `statused Definition → flowchart text`; status encoded
91
+ as `classDef` + `class` assignments.
92
+ - **New controller action + view** — `GET workflows/:id/definition`, plus a
93
+ "Definition graph" link from the existing detail page.
94
+ - **Vendored Mermaid JS** — the dashboard's first client script, initialized
95
+ inside the existing `data-poll-region` so the DAG re-renders on the normal
96
+ page refresh.
97
+
98
+ ## Node → step-name binding
99
+
100
+ Each node knows the step-name it *would* produce, so the overlay is a lookup, not
101
+ guesswork:
102
+
103
+ | DSL call | Node kind | Binds to |
104
+ |---|---|---|
105
+ | `durably_execute :m` / `name: "x"` | `:execute` | exact `durably_execute$x` (or `$m`) |
106
+ | `durably_execute :m, name: <expr>` | `:dynamic` | prefix `durably_execute$`, by ordinal |
107
+ | `wait <duration>, "n"` | `:wait` | exact `wait$n` (name is the 2nd positional) |
108
+ | `wait_until :cond` | `:wait_until` | exact `wait_until$cond` |
109
+ | `continue_if :cond` | `:continue_if` | exact `continue_if$cond` |
110
+ | `branch :name { spawn/spawn_each }` | `:branch` (fan-out) | `branch$name` + child-workflow aggregate |
111
+ | `merge_branches :a, :b` | `:merge` (join) | `merge$a,b` (names sorted) |
112
+ | `durably_repeat :name` | `:repeat` (loop) | `durably_repeat$name` coord + `$<ts>` reps |
113
+
114
+ **Fan-out (`branch`/`spawn_each`) and `durably_repeat` collapse to a single node
115
+ with aggregate status** — not one node per child/iteration.
116
+
117
+ ## Overlay status vocabulary (→ Mermaid classes)
118
+
119
+ - `done` — matching log is `completed`.
120
+ - `active` — log is `started`/`running`, not completed.
121
+ - `pending` — reached but not done (a coordination log exists, work outstanding).
122
+ - `not_reached` — no log yet.
123
+ - `failed` / `stalled` — from the log state.
124
+ - `conditional` — statically guarded; may be skipped.
125
+ - `dynamic` — unresolved name; bound by prefix + ordinal.
126
+ - `unmapped` — **a runtime log with no matching static node**; appended so
127
+ analyzer gaps are surfaced, not hidden.
128
+
129
+ Aggregates:
130
+ - `:repeat` → "N done, current active, `till` met?" from the coordination log +
131
+ its `$<ts>` repetition logs.
132
+ - `:branch`/`:merge` → child-workflow state counts (running/idle/completed/failed)
133
+ via `BranchProbe`.
134
+
135
+ ## Edges & conditionals
136
+
137
+ - Sequential DSL calls → `:seq` edges.
138
+ - `if`/`unless`/`case`/`&&`/`||`/early-return around a step → `:conditional` edge
139
+ labeled with the condition source; steps only reachable under a guard render
140
+ `conditional`.
141
+ - `continue_if` → a gate node; its false path is a `:terminal` edge (workflow
142
+ halts).
143
+ - `branch` block → fans out (`:fanout`) to its spawn/`spawn_each` child-group;
144
+ `merge_branches` is the `:join` those edges reconnect into.
145
+ - `each`/`times`/`while` containing durable calls → one node + a "dynamic loop
146
+ count" **warning** (conservative — no unrolling).
147
+
148
+ ## Error handling
149
+
150
+ The analyzer must never break the dashboard:
151
+
152
+ - Source unavailable (`source_location` nil, C-defined, `eval`'d, unreadable
153
+ file) → return a `Definition` carrying a single `unavailable` warning; the page
154
+ renders "can't be statically analyzed" gracefully. Never raises.
155
+ - Any Prism parse issue degrades the same way (Prism is error-tolerant).
156
+ - Missing/unloadable `job_class`, or a partially-resolved analysis → render what
157
+ was found plus a warnings panel.
158
+ - **Analyzer is pure/read-only over source text**; the overlay does read-only
159
+ queries only.
160
+
161
+ ## Caching
162
+
163
+ - Memoize `Definition` by `job_class` + source-file digest — auto-invalidates on
164
+ dev code reload, stable in prod.
165
+ - The **overlay is never cached** — it is per-run and changes every poll.
166
+
167
+ ## Testing
168
+
169
+ - **Analyzer unit tests (no DB):** a fixture set of workflow classes — linear,
170
+ conditional/`continue_if`, `branch`+`spawn_each`, `durably_repeat`, dynamic
171
+ `name:`, helper-traced, unanalyzable loop — asserting node kinds, edges, guards,
172
+ and warnings. Deterministic and fast.
173
+ - **Overlay tests (dashboard harness):** seed `execution_logs` + child workflows;
174
+ assert per-node status, fan-out aggregates, repeat counts, and the `unmapped`
175
+ path.
176
+ - **`MermaidRenderer`:** golden-text tests (statused `Definition` → expected
177
+ flowchart string).
178
+
179
+ ## Scope (v1)
180
+
181
+ **In:** all seven primitives as nodes + conditional edges + fan-out/repeat
182
+ aggregation + the overlay + the new per-run DAG page + Mermaid rendering;
183
+ same-class helper tracing.
184
+
185
+ **Out (deferred):**
186
+ - Cross-class helper tracing.
187
+ - Recursively expanding a spawned child *workflow class* into its own graph
188
+ (v1 shows it as one fan-out node; "drill into child" is a future feature).
189
+ - Per-node ETA/timing beyond status + counts (that's the separate progress/ETA
190
+ feature).
191
+ - A class-level (no-overlay) definition view (trivial later addition).
192
+
193
+ ## Open questions / risks
194
+
195
+ - **Helper-tracing fixed point:** need a clear rule for what counts as "a durable
196
+ call inside a same-class method" vs. ordinary work, and recursion/mutual-call
197
+ guards. The analyzer stays conservative — when in doubt, emit a `dynamic` node +
198
+ warning rather than a confident-but-wrong expansion.
199
+ - **Ordinal binding for dynamic siblings** is best-effort; if two dynamic
200
+ `durably_execute` calls interleave at runtime out of source order, the overlay
201
+ may mis-bind. Acceptable for v1 (surfaced as `dynamic`).
202
+ - **Mermaid as first client dependency** — keep it vendored and isolated so the
203
+ rest of the dashboard stays server-rendered.