chrono_forge 0.10.0 → 0.12.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/README.md +246 -119
  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 +246 -0
  8. data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md +205 -0
  9. data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md.tasks.json +33 -0
  10. data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md +1373 -0
  11. data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md.tasks.json +68 -0
  12. data/docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md +203 -0
  13. data/docs/superpowers/specs/2026-07-09-chrono-forge-reaper-design.md +175 -0
  14. data/lib/chrono_forge/branch_merge_job.rb +158 -21
  15. data/lib/chrono_forge/branch_probe.rb +44 -0
  16. data/lib/chrono_forge/configuration.rb +51 -0
  17. data/lib/chrono_forge/definition.rb +37 -0
  18. data/lib/chrono_forge/definition_analyzer.rb +501 -0
  19. data/lib/chrono_forge/executor/context.rb +23 -0
  20. data/lib/chrono_forge/executor/lock_strategy.rb +10 -3
  21. data/lib/chrono_forge/executor/methods/continue_if.rb +15 -6
  22. data/lib/chrono_forge/executor/methods/durably_execute.rb +15 -7
  23. data/lib/chrono_forge/executor/methods/durably_repeat.rb +30 -14
  24. data/lib/chrono_forge/executor/methods/merge_branches.rb +5 -4
  25. data/lib/chrono_forge/executor/methods/workflow_states.rb +35 -47
  26. data/lib/chrono_forge/executor.rb +35 -10
  27. data/lib/chrono_forge/version.rb +1 -1
  28. data/lib/chrono_forge/workflow.rb +43 -0
  29. data/lib/chrono_forge.rb +8 -0
  30. data/lib/tasks/release.rake +212 -0
  31. metadata +29 -2
@@ -4,7 +4,22 @@ module ChronoForge
4
4
  # Lightweight poller that joins one or more branches. NOT a workflow — it holds
5
5
  # no lock, does no replay, and carries no context. It exists so the heavy parent
6
6
  # workflow is replayed only twice per merge (kick off + completion wake).
7
+ #
8
+ # DEPLOY NOTE — queue placement matters. merge_branches enqueues this poller
9
+ # AFTER dispatching the branch's children, so if it runs on the SAME queue as a
10
+ # large fan-out's children it is starved behind the whole backlog and only gets a
11
+ # worker slot near the end. It then polls once, at pending≈0, with no prior sample
12
+ # (rate 0) and backs off to max_interval — so the parent's convergence lags by up
13
+ # to max_interval and no mid-drain throughput sample is ever recorded. Set
14
+ # ChronoForge.config.branch_merge_queue to a queue NOT saturated by the fan-out's
15
+ # own children so it polls throughout the drain (ETA cadence then converges
16
+ # tightly). See ChronoForge::Configuration and docs/fanout-scale-test.md.
7
17
  class BranchMergeJob < ActiveJob::Base
18
+ # Resolved per-enqueue from config (a block, so changing the config takes effect
19
+ # without redefining the job — and it can't be silently reset by a code reload,
20
+ # unlike a queue_as monkey-patch in a to_prepare block).
21
+ queue_as { ChronoForge.config.branch_merge_queue }
22
+
8
23
  # The poller is the parent's only wake mechanism, so survive TRANSIENT
9
24
  # infrastructure errors (DB connection/timeout/deadlock) with backoff. Any
10
25
  # other error — a programming bug, a bad guard — is NOT retried: it propagates
@@ -16,8 +31,7 @@ module ChronoForge
16
31
  ActiveRecord::LockWaitTimeout,
17
32
  wait: :polynomially_longer, attempts: 25
18
33
 
19
- CAP = 5_000 # cap the pending count; beyond it we just pick max_interval
20
- FACTOR = 0.06 # seconds of delay per pending child
34
+ ETA_FRACTION = 0.5 # poll at this fraction of the projected time-to-drain
21
35
  REKICK_AFTER = 5.minutes
22
36
  REKICK_BATCH = 200 # bound per-run rekicks; later polls handle the rest
23
37
 
@@ -29,44 +43,135 @@ module ChronoForge
29
43
  # re-enqueue) holds a stale token. It stops quietly — no poll, no wake, no
30
44
  # reschedule — leaving only the newest chain to drive the merge. (A nil token
31
45
  # is a pre-upgrade job enqueued before fencing existed; it runs unfenced.)
32
- return if superseded?(branch_log_ids, token)
46
+ logs = ExecutionLog.where(id: branch_log_ids).to_a
47
+ return if superseded?(logs, token)
33
48
 
34
49
  # Per-branch probe (kept as maps so we can persist each branch's own state,
35
50
  # not just the merge aggregate). Same query count as a plain sum/all?.
36
- pending_by_branch = branch_log_ids.to_h { |id| [id, BranchProbe.incomplete(id).limit(CAP).count] }
51
+ # The pending count is UNCAPPED: it feeds the drain signal below (a change in
52
+ # pending since the prior poll), which a CAP would flatten into a false
53
+ # "not draining" for large branches.
54
+ prev_pending_by_branch = logs.to_h { |l| [l.id, l.metadata&.dig("poll", "pending")] }
55
+ pending_by_branch = branch_log_ids.to_h { |id| [id, BranchProbe.incomplete(id).count] }
37
56
  sealed_by_branch = branch_log_ids.to_h { |id| [id, BranchProbe.sealed?(id)] }
38
57
  pending = pending_by_branch.values.sum
39
58
  sealed = sealed_by_branch.values.all?
40
59
 
60
+ # Total children spawned per branch. Immutable once the branch is SEALED
61
+ # (dispatch done), so we count it exactly ONCE and cache it on the metadata;
62
+ # every later poll (and the dashboard) reuses the cached value, never recounting.
63
+ # Unsealed (mid-spawn, count still climbing) => nil, and the dashboard falls back
64
+ # to its capped live count until the seal freezes the total.
65
+ logs_by_id = logs.index_by(&:id)
66
+ spawned_by_branch = branch_log_ids.to_h do |id|
67
+ cached = logs_by_id[id]&.metadata&.dig("poll", "spawned")
68
+ [id, cached || (sealed_by_branch[id] ? BranchProbe.spawned(id).count : nil)]
69
+ end
70
+
41
71
  if sealed && pending.zero?
42
- record_poll!(pending_by_branch, sealed_by_branch, token, next_poll_at: nil)
72
+ record_poll!(pending_by_branch, sealed_by_branch, token, next_poll_at: nil, interval: nil,
73
+ rate_by_branch: {}, never_started_by_branch: {}, spawned_by_branch: spawned_by_branch, rekicked_by_branch: {})
43
74
  parent_job_class.constantize.perform_later(parent_key)
44
75
  return
45
76
  end
46
77
 
47
- rekick_dropped_jobs(branch_log_ids)
78
+ # DISPATCHED (never-started) count per branch — the rekick drain signal. A
79
+ # drop since the prior poll means workers are consuming this branch's queue,
80
+ # so a still-queued child is in line; a flat count with stale never-started
81
+ # children is a dropped job to recover. Keyed off this, NOT total pending,
82
+ # which a wait/wait_until child completing would drop without any never-started
83
+ # child moving (masking a genuinely-dropped one behind staggered waits).
84
+ prev_never_started_by_branch = logs.to_h { |l| [l.id, l.metadata&.dig("poll", "never_started")] }
85
+ never_started_by_branch = branch_log_ids.to_h { |id| [id, BranchProbe.never_started(id).count] }
86
+
87
+ rekicked_by_branch = rekick_dropped_jobs(branch_log_ids, never_started_by_branch, prev_never_started_by_branch)
88
+
89
+ # Cadence is driven by ESTIMATED TIME-TO-DRAIN, measured from the prior
90
+ # poll's persisted pending. `motion` (EXISTS probes) is the fallback signal
91
+ # when nothing completed this interval: :running => a live worker is
92
+ # executing a child (hold the floor, it'll finish); :never_started => the only
93
+ # motion is a queued/rekicked-but-unpicked child (back off exponentially,
94
+ # it may never be picked up); :none => blocked/waiting (max backstop).
95
+ # See reschedule_delay. Computed lazily below, only off the drain path.
96
+ prior = logs.map { |l| l.metadata&.dig("poll") }
97
+ # Only trust the AGGREGATE prev_pending when every requested branch log is
98
+ # loaded AND carries a prior sample — otherwise `pending` (over all
99
+ # branch_log_ids) and prev_pending (over loaded logs) would cover different
100
+ # sets and yield a bogus aggregate rate. Missing/partial => no sample =>
101
+ # bootstrap. Per-branch rate below is independently safe (missing => nil => 0).
102
+ complete_prior = logs.size == branch_log_ids.size && prior.all?
103
+ prev_pending = (prior.sum { |p| p["pending"].to_i } if complete_prior)
104
+ prev_polled_at = prior.filter_map { |p| p && p["last_polled_at"] }.map { |s| Time.zone.parse(s) }.min
105
+ elapsed = prev_polled_at && (Time.current - prev_polled_at)
106
+ prev_delay = prior.filter_map { |p| p && p["interval"] }.max
107
+
108
+ # Drain rate = children completed / second since the prior poll — THIS is the
109
+ # throughput surfaced on the dashboard. Per branch for display; aggregated for
110
+ # the ETA. Zero unless the branch actually drained (a no-headway / cold poll).
111
+ # NOTE: the aggregate ETA blurs a heterogeneous multi-branch merge; acceptable
112
+ # (the common case is single-branch; clamp + per-poll re-estimate bound any
113
+ # skew, and only poll timing is affected — the parent is still woken).
114
+ drained = ->(pend, prev) { prev && elapsed && elapsed > 0 && pend < prev }
115
+ rate_by_branch = pending_by_branch.to_h do |id, pend|
116
+ prev = prev_pending_by_branch[id]
117
+ [id, drained.call(pend, prev) ? (prev - pend) / elapsed.to_f : 0.0]
118
+ end
119
+ rate = drained.call(pending, prev_pending) ? (prev_pending - pending) / elapsed.to_f : 0.0
120
+
121
+ # Only needed when the ETA branch won't be taken (rate == 0); computing the
122
+ # EXISTS probes lazily keeps them off the hot drain path. See reschedule_delay.
123
+ motion = if rate > 0 then nil
124
+ elsif branch_log_ids.any? { |id| BranchProbe.running?(id) } then :running
125
+ elsif never_started_by_branch.values.any?(&:positive?) then :never_started
126
+ else :none
127
+ end
48
128
 
49
- delay = reschedule_delay(pending, min_interval, max_interval)
50
- record_poll!(pending_by_branch, sealed_by_branch, token, next_poll_at: delay.seconds.from_now)
129
+ delay = reschedule_delay(pending, rate, motion, prev_delay, min_interval, max_interval)
130
+ record_poll!(pending_by_branch, sealed_by_branch, token, next_poll_at: delay.seconds.from_now,
131
+ interval: delay, rate_by_branch: rate_by_branch, never_started_by_branch: never_started_by_branch,
132
+ spawned_by_branch: spawned_by_branch, rekicked_by_branch: rekicked_by_branch)
51
133
  self.class.set(wait: delay.seconds)
52
134
  .perform_later(parent_key, parent_job_class, branch_log_ids, min_interval, max_interval, token)
53
135
  end
54
136
 
55
137
  private
56
138
 
57
- # Adaptive poll cadence: scale the wait with the number of pending children,
58
- # clamped to [min_interval, max_interval]. min_interval <= max_interval is
59
- # enforced up front in merge_branches, so the clamp can't raise here.
60
- def reschedule_delay(pending, min_interval, max_interval)
61
- (pending * FACTOR).clamp(min_interval, max_interval)
139
+ # Adaptive poll cadence driven by ESTIMATED TIME-TO-DRAIN, not backlog size.
140
+ # When the branch-set drained since the last poll we project completion from
141
+ # the measured rate and poll at ETA_FRACTION of it, clamped [min, max]. Because
142
+ # each poll re-estimates against the shrinking remainder, cadence converges
143
+ # geometrically and detects the merge within ~min_interval of the last child
144
+ # finishing — where the old count-based cadence polled SLOWEST (max_interval)
145
+ # exactly when a fast-draining backlog was about to complete.
146
+ #
147
+ # No completion observed this interval — fall back on `motion`:
148
+ # :running => a live worker is executing a child; it will finish, so hold
149
+ # the responsive floor (matches prior behaviour and avoids
150
+ # waking the parent late for a slow/low-fan-out child).
151
+ # :never_started => the only motion is a queued/rekicked-but-unpicked child that
152
+ # may never be picked up => exponential backoff from the floor
153
+ # (double prev_delay, capped at max), catching a quick recovery
154
+ # within seconds without spinning on a dead dispatch.
155
+ # :none => nothing can progress (blocked/failed or parked on a wait) =>
156
+ # straight to max_interval, the cheap recovery backstop.
157
+ # min_interval <= max_interval is enforced in merge_branches, so clamp is safe.
158
+ # `rate` is children/s measured by the caller (0 => nothing completed since the
159
+ # prior poll / cold poll).
160
+ def reschedule_delay(pending, rate, motion, prev_delay, min_interval, max_interval)
161
+ return (pending / rate * ETA_FRACTION).clamp(min_interval, max_interval) if rate > 0
162
+
163
+ case motion
164
+ when :running then min_interval
165
+ when :never_started then prev_delay ? (prev_delay * 2).clamp(min_interval, max_interval) : min_interval
166
+ else max_interval
167
+ end
62
168
  end
63
169
 
64
170
  # A poller is superseded when its token no longer matches what's stored on the
65
171
  # branch logs (a newer merge_branches pass rotated it). A plain read is enough
66
172
  # for the early-out; the persisting write in record_poll! re-checks the token
67
173
  # under a row lock so it can never clobber the newer chain.
68
- def superseded?(branch_log_ids, token)
69
- logs = ExecutionLog.where(id: branch_log_ids).to_a
174
+ def superseded?(logs, token)
70
175
  logs.empty? || logs.any? { |log| log.metadata&.dig("poll_token") != token }
71
176
  end
72
177
 
@@ -78,7 +183,7 @@ module ChronoForge
78
183
  # work still pending is the signal that the poller was dropped). This is purely
79
184
  # observational — replay and correctness never read it. It writes a "poll"
80
185
  # sub-key, leaving spawn_each's "cursors" metadata untouched.
81
- def record_poll!(pending_by_branch, sealed_by_branch, token, next_poll_at:)
186
+ def record_poll!(pending_by_branch, sealed_by_branch, token, next_poll_at:, interval:, rate_by_branch:, never_started_by_branch:, spawned_by_branch:, rekicked_by_branch:)
82
187
  now = Time.current
83
188
  ExecutionLog.where(id: pending_by_branch.keys).find_each do |log|
84
189
  # Lock the row so this read-modify-write can't clobber a concurrent token
@@ -88,12 +193,25 @@ module ChronoForge
88
193
  log.with_lock do
89
194
  meta = log.metadata || {}
90
195
  next unless meta["poll_token"] == token
196
+ prev = meta["poll"] || {}
197
+ n = rekicked_by_branch[log.id].to_i
198
+ pend = pending_by_branch[log.id]
199
+ rate = rate_by_branch[log.id].to_f
91
200
  meta["poll"] = {
92
201
  "last_polled_at" => now.iso8601,
93
202
  "next_poll_at" => next_poll_at&.iso8601,
94
- "pending" => pending_by_branch[log.id],
203
+ "interval" => interval,
204
+ "pending" => pend,
205
+ "never_started" => never_started_by_branch[log.id], # never-started count (rekick drain signal)
206
+ "spawned" => prev["spawned"] || spawned_by_branch[log.id], # total spawned; immutable once sealed, so sticky
95
207
  "sealed" => sealed_by_branch[log.id],
96
- "polls" => meta.dig("poll", "polls").to_i + 1
208
+ "rate" => rate.round(3), # children/s (round(3), not (2), so a
209
+ # very slow but real drain still reads > 0)
210
+ "eta_seconds" => (rate > 0 ? (pend / rate).round : nil),
211
+ "polls" => prev["polls"].to_i + 1,
212
+ "rekicked" => n,
213
+ "rekick_total" => prev["rekick_total"].to_i + n,
214
+ "last_rekick_at" => (n.positive? ? now.iso8601 : prev["last_rekick_at"])
97
215
  }
98
216
  log.update!(metadata: meta)
99
217
  end
@@ -111,10 +229,22 @@ module ChronoForge
111
229
  # keep the :idle guard (a running/failed/stalled child must never be
112
230
  # re-dispatched). Re-enqueue of an :idle child a worker just grabbed is still
113
231
  # safe — the lock guard rejects the duplicate. Capped per run.
114
- def rekick_dropped_jobs(branch_log_ids)
115
- branch_log_ids.each do |id|
232
+ def rekick_dropped_jobs(branch_log_ids, never_started_by_branch, prev_never_started_by_branch)
233
+ cutoff = REKICK_AFTER.ago
234
+ branch_log_ids.to_h do |id|
235
+ # Skip a branch whose NEVER-STARTED count dropped since the last poll:
236
+ # workers are pulling its dispatched children off the queue, so a still-
237
+ # queued child is in line, not dropped. Deliberately NOT total pending —
238
+ # a wait/wait_until child completing would drop pending without any
239
+ # never-started child moving, masking a genuinely-dropped child behind
240
+ # staggered waits. With no prior sample (cold poll) we don't gate — the
241
+ # per-child staleness filter below still spares freshly-dispatched rows.
242
+ prev = prev_never_started_by_branch[id]
243
+ next [id, 0] if prev && never_started_by_branch[id] < prev
244
+
245
+ count = 0
116
246
  Workflow.where(parent_execution_log_id: id, state: Workflow.states[:idle], started_at: nil)
117
- .where("updated_at < ?", REKICK_AFTER.ago)
247
+ .where("updated_at < ?", cutoff)
118
248
  .limit(REKICK_BATCH)
119
249
  .find_each do |child|
120
250
  # Intentionally uses the GUARDED perform_later (single-child path),
@@ -126,12 +256,19 @@ module ChronoForge
126
256
  # error — dead-letter the poller, orphaning every healthy sibling. Catch
127
257
  # per child, log, and let the next poll retry it (it's still idle+stale).
128
258
  child.job_klass.perform_later(child.key, **child.kwargs.symbolize_keys)
259
+ # Debounce: bump updated_at so this child isn't re-rekicked until it's
260
+ # been unstarted for another REKICK_AFTER — one redelivery window for a
261
+ # worker to pick it up. Only on a SUCCESSFUL enqueue; a rescued failure
262
+ # leaves it stale so the next poll retries.
263
+ child.touch
264
+ count += 1
129
265
  rescue => e
130
266
  Rails.logger.error do
131
267
  "ChronoForge:BranchMergeJob rekick failed for child #{child.key}: " \
132
268
  "#{e.class}: #{e.message}"
133
269
  end
134
270
  end
271
+ [id, count]
135
272
  end
136
273
  end
137
274
  end
@@ -19,6 +19,50 @@ module ChronoForge
19
19
  .where.not(state: Workflow.states[:completed])
20
20
  end
21
21
 
22
+ # Relation of children that can advance on their own — actively running, or
23
+ # dispatched-but-not-yet-started (started_at nil). This drives the adaptive
24
+ # poll cadence. Deliberately EXCLUDES waiting children (idle with started_at
25
+ # SET — parked on a wait/wait_until) and blocked children (failed/stalled —
26
+ # awaiting operator recovery): polling can't make either progress, so they
27
+ # must not pin the cadence at the responsive floor. They still count as
28
+ # +incomplete+ (the branch stays open), they just don't accelerate polling.
29
+ def progressing(branch_log_id)
30
+ base = Workflow.where(parent_execution_log_id: branch_log_id)
31
+ base.where(state: Workflow.states[:running])
32
+ .or(base.where(state: Workflow.states[:idle], started_at: nil))
33
+ end
34
+
35
+ # A child of this branch is actively executing — a live worker will complete
36
+ # it, so the poller can hold its responsive floor rather than backing off.
37
+ def running?(branch_log_id)
38
+ Workflow.where(parent_execution_log_id: branch_log_id, state: Workflow.states[:running]).exists?
39
+ end
40
+
41
+ # Children dispatched but not yet started (idle, started_at nil) — the queue of
42
+ # never-started work for this branch. A DROP in this count between polls means
43
+ # workers are actively pulling it off the queue (so a still-queued child is in
44
+ # line, not dropped); the rekick gate keys off that. Distinct from total pending,
45
+ # which a wait/wait_until child completing would drop without any never-started
46
+ # child moving. (Not to be confused with the dashboard's "Dispatched" column,
47
+ # which is the TOTAL children spawned.)
48
+ def never_started(branch_log_id)
49
+ Workflow.where(parent_execution_log_id: branch_log_id,
50
+ state: Workflow.states[:idle], started_at: nil)
51
+ end
52
+
53
+ # A child was dispatched but no worker has started it yet. If this is the only
54
+ # motion left, it's a queued/rekicked-but-unpicked straggler (which may never be
55
+ # picked up), NOT active work — so the poller backs off.
56
+ def never_started?(branch_log_id) = never_started(branch_log_id).exists?
57
+
58
+ # All children spawned into this branch (every state) — the dispatch total. Fixed
59
+ # once the branch is sealed, so the poller counts it exactly once and caches it on
60
+ # the branch-log metadata. This is the dashboard's "Spawned" column. Distinct from
61
+ # #never_started, which is only the idle-and-unstarted subset.
62
+ def spawned(branch_log_id)
63
+ Workflow.where(parent_execution_log_id: branch_log_id)
64
+ end
65
+
22
66
  def done?(branch_log_id)
23
67
  sealed?(branch_log_id) && !incomplete(branch_log_id).exists?
24
68
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChronoForge
4
+ # Engine-wide configuration. Set via ChronoForge.configure in an initializer.
5
+ class Configuration
6
+ # The queue the branch-merge poller (BranchMergeJob) runs on.
7
+ #
8
+ # This MUST NOT be a queue that a fan-out's own children saturate: merge_branches
9
+ # enqueues the poller AFTER dispatching the branch's children, so on a shared
10
+ # queue it is starved behind the whole backlog and only gets a worker slot near
11
+ # the end — it then polls once, at pending≈0, and backs off, so the parent's
12
+ # convergence lags by up to max_interval and no mid-drain throughput is recorded.
13
+ # Because the poller is OUR code (not the user's job), its placement is a
14
+ # first-class setting rather than something to monkey-patch onto BranchMergeJob.
15
+ #
16
+ # Defaults to :default (fine when fan-outs run on their own queues). For large
17
+ # fan-outs, point this at a dedicated queue with its own worker so the poller
18
+ # runs promptly throughout the drain.
19
+ attr_accessor :branch_merge_queue
20
+
21
+ # How long a single workflow pass may hold its lock before another job is
22
+ # allowed to steal it (LockStrategy.acquire_lock treats a lock older than this
23
+ # as stale). It bounds the assumed maximum duration of one execution pass.
24
+ # Defaults to 10 minutes.
25
+ attr_accessor :max_duration
26
+
27
+ def initialize
28
+ @branch_merge_queue = :default
29
+ @max_duration = 10.minutes
30
+ @reap_stale_after = nil
31
+ end
32
+
33
+ # Age past which a workflow still in :running is treated as stranded and
34
+ # re-enqueued by ChronoForge::Workflow.reap_stalled. A workflow reaches this
35
+ # state when its worker is hard-killed (SIGKILL/OOM/eviction) mid-pass, before
36
+ # the executor's `ensure` block could release the lock and publish the resume
37
+ # continuation — so it stays locked in :running with nothing scheduled to wake it.
38
+ #
39
+ # Defaults to 3x max_duration (30 min out of the box), so it always comfortably
40
+ # exceeds the lock-steal threshold: acquire_lock only steals locks older than
41
+ # max_duration, so a shorter reap threshold would just enqueue resume jobs that
42
+ # immediately no-op via ConcurrentExecutionError. Deriving from max_duration keeps
43
+ # that invariant automatic — raise max_duration and the reaper backs off with it.
44
+ # An explicit value (set via the writer) overrides the derived default.
45
+ def reap_stale_after
46
+ @reap_stale_after || max_duration * 3
47
+ end
48
+
49
+ attr_writer :reap_stale_after
50
+ end
51
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChronoForge
4
+ # Rendering-agnostic graph model produced by DefinitionAnalyzer. Plain value
5
+ # objects so a Definition can be cached/serialized (to_h -> JSON) and consumed
6
+ # by any renderer. No DB, no Prism, no dashboard dependency here.
7
+ class Definition
8
+ # kind: :execute :wait :wait_until :continue_if :branch :merge :repeat :dynamic
9
+ # A node binds to runtime logs by EXACT step_name when known, else by
10
+ # step_name_pattern (a prefix for fan-out/repeat/dynamic).
11
+ Node = Struct.new(
12
+ :id, :kind, :label, :step_name, :step_name_pattern, :guard, :warnings,
13
+ keyword_init: true
14
+ ) do
15
+ def dynamic? = kind == :dynamic || step_name.nil?
16
+
17
+ # Default a missing warnings member to [] here (rather than overriding the
18
+ # struct's generated reader, which triggers a method-redefined warning).
19
+ def to_h = super.merge(warnings: self[:warnings] || [])
20
+ end
21
+
22
+ # kind: :seq :conditional :fanout :join :terminal
23
+ Edge = Struct.new(:from, :to, :kind, :guard, keyword_init: true)
24
+
25
+ attr_reader :nodes, :edges, :warnings
26
+
27
+ def initialize(nodes: [], edges: [], warnings: [])
28
+ @nodes = nodes
29
+ @edges = edges
30
+ @warnings = warnings
31
+ end
32
+
33
+ def to_h
34
+ {nodes: nodes.map(&:to_h), edges: edges.map(&:to_h), warnings: warnings}
35
+ end
36
+ end
37
+ end