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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/README.md +390 -46
- 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-25-chrono_forge-dashboard.md +1748 -0
- data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md.tasks.json +17 -0
- data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md +930 -0
- data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md.tasks.json +54 -0
- data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md +241 -0
- data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md.tasks.json +12 -0
- data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md +1378 -0
- data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md.tasks.json +67 -0
- data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md +709 -0
- data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md.tasks.json +19 -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-06-03-unified-retry-policy-design.md +226 -0
- data/docs/superpowers/specs/2026-06-25-chrono_forge-dashboard-design.md +190 -0
- data/docs/superpowers/specs/2026-06-25-composite-retry-policies-design.md +228 -0
- data/docs/superpowers/specs/2026-06-25-reserved-kwarg-guard-design.md +169 -0
- data/docs/superpowers/specs/2026-06-25-spawn-merge-branches-design.md +468 -0
- data/docs/superpowers/specs/2026-06-26-dashboard-branch-view-design.md +142 -0
- data/docs/superpowers/specs/2026-06-26-deferral-continuation-race-and-catchup-design.md +265 -0
- data/docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md +203 -0
- data/lib/chrono_forge/branch_merge_job.rb +275 -0
- data/lib/chrono_forge/branch_probe.rb +70 -0
- data/lib/chrono_forge/cleanup.rb +6 -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/execution_log.rb +6 -0
- data/lib/chrono_forge/executor/composite_retry_policy.rb +47 -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/branch.rb +185 -0
- data/lib/chrono_forge/executor/methods/continue_if.rb +15 -6
- data/lib/chrono_forge/executor/methods/durably_execute.rb +36 -26
- data/lib/chrono_forge/executor/methods/durably_repeat.rb +148 -39
- data/lib/chrono_forge/executor/methods/merge_branches.rb +84 -0
- data/lib/chrono_forge/executor/methods/wait.rb +2 -4
- data/lib/chrono_forge/executor/methods/wait_until.rb +25 -25
- data/lib/chrono_forge/executor/methods/workflow_states.rb +50 -46
- data/lib/chrono_forge/executor/methods.rb +2 -0
- data/lib/chrono_forge/executor/retry_policy.rb +111 -0
- data/lib/chrono_forge/executor.rb +241 -28
- data/lib/chrono_forge/version.rb +1 -1
- data/lib/chrono_forge/workflow.rb +10 -1
- data/lib/chrono_forge.rb +8 -0
- data/lib/generators/chrono_forge/migration_actions.rb +1 -0
- data/lib/generators/chrono_forge/templates/add_chrono_forge_parent_execution_log.rb +38 -0
- data/lib/tasks/release.rake +212 -0
- metadata +67 -4
- 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.
|