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
|
@@ -14,46 +14,105 @@ module ChronoForge
|
|
|
14
14
|
|
|
15
15
|
class InvalidStepName < NotExecutableError; end
|
|
16
16
|
|
|
17
|
+
# spawn/spawn_each called outside a branch block. NotExecutableError so it
|
|
18
|
+
# propagates (fail-fast on a programming error) rather than being retried.
|
|
19
|
+
class NotInBranchError < NotExecutableError; end
|
|
20
|
+
|
|
21
|
+
# A branch was opened but neither merged via merge_branches nor declared
|
|
22
|
+
# automerge: true. Raised at the completion gate. Fail-fast (not retried).
|
|
23
|
+
class UnmergedBranchError < NotExecutableError; end
|
|
24
|
+
|
|
25
|
+
# merge_branches given a name that was never opened as a branch this pass.
|
|
26
|
+
# NotExecutableError so it propagates (fail-fast) instead of being retried.
|
|
27
|
+
class UnknownBranchError < NotExecutableError; end
|
|
28
|
+
|
|
17
29
|
# "$" separates the segments of a step name (e.g. "durably_repeat$name$ts").
|
|
18
30
|
# User-supplied names/methods must not contain it.
|
|
19
31
|
STEP_NAME_DELIMITER = "$"
|
|
20
32
|
|
|
33
|
+
# Keyword args ChronoForge threads through job args internally. Users must
|
|
34
|
+
# not pass these to perform_now/perform_later; the framework injects them
|
|
35
|
+
# via `.set(...)` continuations, whose ConfiguredJob proxy bypasses the
|
|
36
|
+
# class-level guard in `prepended` below.
|
|
37
|
+
RESERVED_KWARGS = %i[attempt retry_counts retry_workflow].freeze
|
|
38
|
+
|
|
21
39
|
include Methods
|
|
22
40
|
|
|
23
41
|
# Add class methods
|
|
24
42
|
def self.prepended(base)
|
|
43
|
+
# Class-wide default retry policy, inherited by subclasses. Set via the
|
|
44
|
+
# `retry_policy` DSL below; nil means "use the per-site built-in default".
|
|
45
|
+
base.class_attribute :default_retry_policy, instance_accessor: false, default: nil
|
|
46
|
+
|
|
25
47
|
class << base
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
48
|
+
# Public enqueue contract: exactly one positional (`key`) plus keywords.
|
|
49
|
+
# Reserved internal kwargs (RESERVED_KWARGS) are rejected here; the
|
|
50
|
+
# framework injects them only via `.set(...)` continuations, whose
|
|
51
|
+
# ActiveJob ConfiguredJob proxy bypasses these class-level overrides.
|
|
52
|
+
def perform_now(key, *extra, **kwargs)
|
|
53
|
+
__validate_enqueue!(key, extra, kwargs)
|
|
54
|
+
super(key, **kwargs)
|
|
32
55
|
end
|
|
33
56
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
57
|
+
def perform_later(key, *extra, **kwargs)
|
|
58
|
+
__validate_enqueue!(key, extra, kwargs)
|
|
59
|
+
super(key, **kwargs)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Re-run a failed/stalled workflow. Routes through `.set(...)` so the
|
|
63
|
+
# reserved `retry_workflow: true` flag reaches the instance perform
|
|
64
|
+
# without tripping the public guard above.
|
|
65
|
+
def retry_now(key, **kwargs)
|
|
66
|
+
__validate_enqueue!(key, [], kwargs)
|
|
67
|
+
set.perform_now(key, retry_workflow: true, **kwargs)
|
|
40
68
|
end
|
|
41
69
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
70
|
+
def retry_later(key, **kwargs)
|
|
71
|
+
__validate_enqueue!(key, [], kwargs)
|
|
72
|
+
set.perform_later(key, retry_workflow: true, **kwargs)
|
|
45
73
|
end
|
|
46
74
|
|
|
47
|
-
#
|
|
48
|
-
|
|
49
|
-
|
|
75
|
+
# Class-level DSL to set this workflow's default retry policy. Applies to
|
|
76
|
+
# workflow-level retries and to steps without a per-call override.
|
|
77
|
+
# Positional RetryPolicy objects build a composite (per-error budgets);
|
|
78
|
+
# keyword options build a single RetryPolicy. The two forms are mutually
|
|
79
|
+
# exclusive.
|
|
80
|
+
def retry_policy(*policies, **opts)
|
|
81
|
+
if policies.any? && opts.any?
|
|
82
|
+
raise ArgumentError, "retry_policy takes either positional policies or keyword options, not both"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
self.default_retry_policy =
|
|
86
|
+
policies.any? ? RetryPolicy.compose(*policies) : RetryPolicy.new(**opts)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def __validate_enqueue!(key, extra, kwargs)
|
|
92
|
+
unless key.is_a?(String)
|
|
93
|
+
raise ArgumentError, "Workflow key must be a string as the first argument"
|
|
94
|
+
end
|
|
95
|
+
unless extra.empty?
|
|
96
|
+
raise ArgumentError,
|
|
97
|
+
"ChronoForge workflows accept only `key` positionally; pass " \
|
|
98
|
+
"everything else as keywords (got #{extra.size} extra positional arg(s))"
|
|
99
|
+
end
|
|
100
|
+
reserved = kwargs.keys & RESERVED_KWARGS
|
|
101
|
+
if reserved.any?
|
|
102
|
+
raise ArgumentError,
|
|
103
|
+
"#{reserved.join(", ")} #{reserved.one? ? "is a reserved" : "are reserved"} " \
|
|
104
|
+
"ChronoForge #{reserved.one? ? "keyword" : "keywords"} and cannot be passed to perform_now/perform_later"
|
|
105
|
+
end
|
|
50
106
|
end
|
|
51
107
|
end
|
|
52
108
|
end
|
|
53
109
|
|
|
54
|
-
def perform(key, attempt: 0, retry_workflow: false, options: {}, **kwargs)
|
|
55
|
-
#
|
|
56
|
-
|
|
110
|
+
def perform(key, attempt: 0, retry_counts: {}, retry_workflow: false, options: {}, **kwargs)
|
|
111
|
+
# Safety net: prevent re-running a workflow whose attempts are exhausted
|
|
112
|
+
# (e.g. a stale job left in the queue). The normal exhaustion path fails the
|
|
113
|
+
# workflow from the rescue below before this is ever reached.
|
|
114
|
+
policy = workflow_retry_policy
|
|
115
|
+
if policy.max_attempts && attempt >= policy.max_attempts
|
|
57
116
|
Rails.logger.error { "ChronoForge:#{self.class} max attempts reached for job workflow(#{key})" }
|
|
58
117
|
return
|
|
59
118
|
end
|
|
@@ -101,16 +160,39 @@ module ChronoForge
|
|
|
101
160
|
Rails.logger.error { "ChronoForge:#{self.class}(#{key}) workflow execution failed" }
|
|
102
161
|
error_log = self.class::ExecutionTracker.track_error(workflow, e, attempt: attempt)
|
|
103
162
|
|
|
104
|
-
# Retry if applicable
|
|
105
|
-
|
|
106
|
-
|
|
163
|
+
# Retry if applicable. `attempt` is a 0-based index, so the count of
|
|
164
|
+
# attempts made so far (including this one) is attempt + 1. For a
|
|
165
|
+
# composite policy the per-error budget lives in `retry_counts` (keyed by
|
|
166
|
+
# the matched policy's budget_key) and rides along the job args, mirroring
|
|
167
|
+
# how `attempt` is threaded — there is no execution log at this level.
|
|
168
|
+
attempts_made = attempt + 1
|
|
169
|
+
backoff = policy.retry_backoff(e, attempts: attempts_made) do |policy_key|
|
|
170
|
+
retry_counts[policy_key] = retry_counts[policy_key].to_i + 1
|
|
171
|
+
retry_counts[policy_key]
|
|
172
|
+
end
|
|
173
|
+
if backoff
|
|
174
|
+
enqueue_continuation(wait: backoff, attempt: attempts_made, retry_counts: retry_counts)
|
|
107
175
|
else
|
|
108
176
|
fail_workflow! error_log
|
|
109
177
|
end
|
|
110
178
|
ensure
|
|
111
179
|
if lock_acquired # Only release lock if we acquired it
|
|
112
|
-
context.save!
|
|
113
|
-
|
|
180
|
+
# Release the lock and publish the continuation even if context.save!
|
|
181
|
+
# raises — otherwise a transient save failure would leave the lock held
|
|
182
|
+
# (until it goes stale) AND drop the continuation, stranding the workflow
|
|
183
|
+
# with nothing scheduled to resume it. On a save failure the continuation
|
|
184
|
+
# resumes from the last persisted context, which is exactly crash
|
|
185
|
+
# semantics (durable steps replay).
|
|
186
|
+
begin
|
|
187
|
+
context.save!
|
|
188
|
+
ensure
|
|
189
|
+
self.class::LockStrategy.release_lock(job_id, workflow)
|
|
190
|
+
# Publish the continuation only now — after the lock is released — so a
|
|
191
|
+
# zero-delay, same-key continuation can't lose the acquire race against
|
|
192
|
+
# this still-locked job. If release_lock raised (this job overran and
|
|
193
|
+
# lost the lock), we never reach here and another job owns continuation.
|
|
194
|
+
flush_continuation!
|
|
195
|
+
end
|
|
114
196
|
end
|
|
115
197
|
end
|
|
116
198
|
end
|
|
@@ -148,11 +230,84 @@ module ChronoForge
|
|
|
148
230
|
# which accumulate unbounded repetition logs: we touch only the rows we need,
|
|
149
231
|
# never the whole set. create_or_find_by! is used only on a miss, keeping
|
|
150
232
|
# creation safe if a lock takeover ever lets two executors race.
|
|
233
|
+
#
|
|
234
|
+
# Completed steps are short-circuited up front from a single bulk read (see
|
|
235
|
+
# #completed_step_cache) so that replaying N already-done steps costs one
|
|
236
|
+
# query for the whole batch rather than one SELECT each — without that, a
|
|
237
|
+
# workflow with hundreds of steps pays hundreds of SELECTs on every resume.
|
|
238
|
+
# The cached value is a readonly, unsaved stand-in: completed steps are only
|
|
239
|
+
# ever read (.completed? and metadata["result"]), never written, so it needs
|
|
240
|
+
# no database row.
|
|
151
241
|
def find_or_create_execution_log!(step_name, &)
|
|
242
|
+
if completed_step_cache.key?(step_name)
|
|
243
|
+
return ExecutionLog.new(
|
|
244
|
+
workflow: @workflow, step_name: step_name, state: :completed,
|
|
245
|
+
metadata: completed_step_cache[step_name]
|
|
246
|
+
).tap(&:readonly!)
|
|
247
|
+
end
|
|
248
|
+
|
|
152
249
|
ExecutionLog.find_by(workflow: @workflow, step_name: step_name) ||
|
|
153
250
|
ExecutionLog.create_or_find_by!(workflow: @workflow, step_name: step_name, &)
|
|
154
251
|
end
|
|
155
252
|
|
|
253
|
+
# Record a step that completes synchronously within this pass as a single
|
|
254
|
+
# INSERT already in its terminal :completed state — there is no
|
|
255
|
+
# started→completed UPDATE chasing the INSERT (one statement, not two). Use
|
|
256
|
+
# this for steps with no deferral point between create and completion
|
|
257
|
+
# (workflow completion/failure markers and similar); deferring steps (waits,
|
|
258
|
+
# branch coordination) must stay :started across resumes and use
|
|
259
|
+
# find_or_create_execution_log! instead.
|
|
260
|
+
#
|
|
261
|
+
# Idempotent: an already-existing row (a resume after a partial pass, or a
|
|
262
|
+
# create race) is flipped to :completed instead of duplicated. The block runs
|
|
263
|
+
# only on create, letting callers attach metadata to the new row.
|
|
264
|
+
def create_completed_execution_log!(step_name)
|
|
265
|
+
now = Time.current
|
|
266
|
+
log = find_or_create_execution_log!(step_name) do |l|
|
|
267
|
+
l.attempts = 1
|
|
268
|
+
l.started_at = now
|
|
269
|
+
l.last_executed_at = now
|
|
270
|
+
l.completed_at = now
|
|
271
|
+
l.state = :completed
|
|
272
|
+
yield l if block_given?
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
unless log.completed?
|
|
276
|
+
log.update!(
|
|
277
|
+
attempts: log.attempts + 1,
|
|
278
|
+
last_executed_at: now,
|
|
279
|
+
completed_at: now,
|
|
280
|
+
state: :completed
|
|
281
|
+
)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
log
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# One bulk read of this workflow's completed steps, mapping step_name to its
|
|
288
|
+
# metadata, memoized for the duration of a single replay pass.
|
|
289
|
+
#
|
|
290
|
+
# Only completed rows are loaded: they are the ones replayed steps short-
|
|
291
|
+
# circuit on, and once completed a step never changes, so the snapshot stays
|
|
292
|
+
# valid for the whole pass. Plucking (step_name, metadata) avoids
|
|
293
|
+
# instantiating AR objects and keeps the read portable — Rails type-casts the
|
|
294
|
+
# JSON metadata column to a Hash on SQLite, PostgreSQL and MySQL alike, with
|
|
295
|
+
# no database-specific JSON extraction.
|
|
296
|
+
#
|
|
297
|
+
# durably_repeat repetition logs (durably_repeat$<name>$<timestamp>) are
|
|
298
|
+
# deliberately excluded: they accumulate without bound yet are never replayed
|
|
299
|
+
# (durably_repeat only ever looks up its coordination log plus the single
|
|
300
|
+
# current repetition), so pulling them into memory would be all cost and no
|
|
301
|
+
# benefit. Their coordination log (durably_repeat$<name>, only two segments)
|
|
302
|
+
# is not matched by the pattern and is still cached.
|
|
303
|
+
def completed_step_cache
|
|
304
|
+
@completed_step_cache ||= ExecutionLog
|
|
305
|
+
.where(workflow: @workflow, state: ExecutionLog.states[:completed])
|
|
306
|
+
.where.not("step_name LIKE ?", "durably_repeat#{STEP_NAME_DELIMITER}%#{STEP_NAME_DELIMITER}%")
|
|
307
|
+
.pluck(:step_name, :metadata)
|
|
308
|
+
.to_h
|
|
309
|
+
end
|
|
310
|
+
|
|
156
311
|
# Guards the user-supplied portion of a step name (a custom name, method, or
|
|
157
312
|
# condition). The "$" separator is reserved for the framework's own segment
|
|
158
313
|
# structure, so a user value containing it would make step names ambiguous
|
|
@@ -164,8 +319,66 @@ module ChronoForge
|
|
|
164
319
|
"ChronoForge step name may not contain '#{STEP_NAME_DELIMITER}' (reserved separator): #{segment.inspect}"
|
|
165
320
|
end
|
|
166
321
|
|
|
167
|
-
|
|
168
|
-
|
|
322
|
+
# Retry policy for workflow-level (uncaught) errors: the class default if one
|
|
323
|
+
# was declared, else the workflow built-in (10 attempts, up to ~8.5 min).
|
|
324
|
+
# Each retry replays the whole workflow from the top.
|
|
325
|
+
def workflow_retry_policy
|
|
326
|
+
self.class.default_retry_policy || RetryPolicy.workflow_default
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Retry policy for a durable step: an explicit per-call override, else the
|
|
330
|
+
# class default, else the step built-in (short, snappy fast-fail).
|
|
331
|
+
def step_retry_policy(override)
|
|
332
|
+
coerce_policy(override) || self.class.default_retry_policy || RetryPolicy.step_default
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Retry policy for a wait_until condition error. Deliberately does NOT inherit
|
|
336
|
+
# the class default, so a class-wide "retry everything" can't silently turn
|
|
337
|
+
# condition-evaluation bugs into retried errors. Built-in retries nothing.
|
|
338
|
+
def wait_retry_policy(override)
|
|
339
|
+
coerce_policy(override) || RetryPolicy.wait_default
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Normalize a retry-policy value: an Array becomes a composite; a RetryPolicy
|
|
343
|
+
# or CompositeRetryPolicy passes through; nil stays nil.
|
|
344
|
+
def coerce_policy(value)
|
|
345
|
+
value.is_a?(Array) ? RetryPolicy.compose(*value) : value
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# JSON metadata key holding the per-error attempt counts of a composite
|
|
349
|
+
# policy, keyed by the matched policy's declared errors (RetryPolicy#budget_key).
|
|
350
|
+
RETRY_COUNTS_KEY = "retry_counts"
|
|
351
|
+
|
|
352
|
+
# Increment the matched policy's slot in the log's retry-count map and return
|
|
353
|
+
# the new count. Reassigns `metadata` so the JSON column is marked dirty.
|
|
354
|
+
def bump_retry_count!(log, policy_key)
|
|
355
|
+
meta = log.metadata || {}
|
|
356
|
+
counts = meta[RETRY_COUNTS_KEY] || {}
|
|
357
|
+
counts[policy_key] = counts[policy_key].to_i + 1
|
|
358
|
+
meta[RETRY_COUNTS_KEY] = counts
|
|
359
|
+
log.update!(metadata: meta)
|
|
360
|
+
counts[policy_key]
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Record the continuation this job intends to enqueue. It is NOT published
|
|
364
|
+
# here: publishing while the lock is still held lets another worker claim it
|
|
365
|
+
# and lose the lock-acquisition race. The executor flushes it in `ensure`,
|
|
366
|
+
# after release_lock (see #flush_continuation!). At most one continuation is
|
|
367
|
+
# recorded per job run (every primitive records one then halts, or falls
|
|
368
|
+
# through the workflow-retry rescue).
|
|
369
|
+
def enqueue_continuation(wait:, **kwargs)
|
|
370
|
+
@continuation = {wait: wait, kwargs: kwargs}
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Publish the recorded continuation, if any. Called from `ensure` only after
|
|
374
|
+
# the lock row has been updated to released, so even a zero-delay continuation
|
|
375
|
+
# finds the lock free.
|
|
376
|
+
def flush_continuation!
|
|
377
|
+
return unless @continuation
|
|
378
|
+
|
|
379
|
+
self.class
|
|
380
|
+
.set(wait: @continuation[:wait])
|
|
381
|
+
.perform_later(@workflow.key, **@continuation[:kwargs])
|
|
169
382
|
end
|
|
170
383
|
|
|
171
384
|
def halt_execution!
|
data/lib/chrono_forge/version.rb
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
# kwargs :json not null
|
|
13
13
|
# options :json not null
|
|
14
14
|
# locked_at :datetime
|
|
15
|
+
# parent_execution_log_id :integer
|
|
15
16
|
# started_at :datetime
|
|
16
17
|
# state :integer default("idle"), not null
|
|
17
18
|
# created_at :datetime not null
|
|
@@ -19,7 +20,10 @@
|
|
|
19
20
|
#
|
|
20
21
|
# Indexes
|
|
21
22
|
#
|
|
22
|
-
# index_chrono_forge_workflows_on_key
|
|
23
|
+
# index_chrono_forge_workflows_on_key (key)
|
|
24
|
+
# index_chrono_forge_workflows_on_job_class_and_key (job_class,key) UNIQUE
|
|
25
|
+
# index_chrono_forge_workflows_on_parent_execution_log_and_st (parent_execution_log_id,state)
|
|
26
|
+
# index_chrono_forge_workflows_on_state_and_completed_at (state,completed_at)
|
|
23
27
|
#
|
|
24
28
|
module ChronoForge
|
|
25
29
|
class Workflow < ApplicationRecord()
|
|
@@ -28,6 +32,11 @@ module ChronoForge
|
|
|
28
32
|
has_many :execution_logs, dependent: :destroy
|
|
29
33
|
has_many :error_logs, dependent: :destroy
|
|
30
34
|
|
|
35
|
+
belongs_to :parent_execution_log,
|
|
36
|
+
class_name: "ChronoForge::ExecutionLog",
|
|
37
|
+
inverse_of: :spawned_workflows,
|
|
38
|
+
optional: true
|
|
39
|
+
|
|
31
40
|
enum :state, %i[
|
|
32
41
|
idle
|
|
33
42
|
running
|
data/lib/chrono_forge.rb
CHANGED
|
@@ -13,4 +13,12 @@ module ChronoForge
|
|
|
13
13
|
class Error < StandardError; end
|
|
14
14
|
|
|
15
15
|
def self.ApplicationRecord = defined?(::ApplicationRecord) ? ::ApplicationRecord : ActiveRecord::Base
|
|
16
|
+
|
|
17
|
+
# Engine configuration (see ChronoForge::Configuration).
|
|
18
|
+
# ChronoForge.configure { |c| c.branch_merge_queue = :chrono_forge_pollers }
|
|
19
|
+
def self.config = @config ||= Configuration.new
|
|
20
|
+
|
|
21
|
+
def self.configure = yield(config)
|
|
22
|
+
|
|
23
|
+
def self.reset_configuration! = @config = Configuration.new
|
|
16
24
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adds chrono_forge_workflows.parent_execution_log_id: the execution log that
|
|
4
|
+
# spawned a workflow (for branches, the branch$<name> log). Deliberately generic
|
|
5
|
+
# so any future step that spawns sub-workflows can reuse it. The composite
|
|
6
|
+
# [parent_execution_log_id, state] index makes the merge completion probe and the
|
|
7
|
+
# dropped-job re-kick index-only at hundreds of thousands of children.
|
|
8
|
+
#
|
|
9
|
+
# Shipped standalone (matching add_chrono_forge_workflow_state_index) so existing
|
|
10
|
+
# installs pick it up via `rails generate chrono_forge:upgrade`.
|
|
11
|
+
class AddChronoForgeParentExecutionLog < ActiveRecord::Migration[7.1]
|
|
12
|
+
disable_ddl_transaction!
|
|
13
|
+
|
|
14
|
+
def change
|
|
15
|
+
add_column :chrono_forge_workflows, :parent_execution_log_id, parent_log_fk_type,
|
|
16
|
+
null: true, if_not_exists: true
|
|
17
|
+
|
|
18
|
+
add_index :chrono_forge_workflows, %i[parent_execution_log_id state],
|
|
19
|
+
if_not_exists: true, **chrono_forge_index_algorithm
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Match the type of chrono_forge_workflows.id so the FK lines up on both bigint
|
|
25
|
+
# and uuid installs.
|
|
26
|
+
def parent_log_fk_type
|
|
27
|
+
id_col = connection.columns(:chrono_forge_workflows).find { |c| c.name == "id" }
|
|
28
|
+
(id_col&.type == :uuid) ? :uuid : :bigint
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def chrono_forge_index_algorithm
|
|
32
|
+
if connection.adapter_name.to_s.downcase.include?("postgresql")
|
|
33
|
+
{algorithm: :concurrently}
|
|
34
|
+
else
|
|
35
|
+
{}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Release flow (chrono_forge monorepo: core + dashboard)
|
|
4
|
+
# ------------------------------------------------------
|
|
5
|
+
# Publishing happens from a laptop. CI does NOT push to any registry — it only
|
|
6
|
+
# cuts the GitHub Release (notes + the built gem) when the tag lands.
|
|
7
|
+
#
|
|
8
|
+
# 1. rake release:core:prepare # auto-computes next version (git-cliff)
|
|
9
|
+
# rake release:core:prepare[0.11.0] # ...or pass one explicitly
|
|
10
|
+
# 2. git diff # review the bump + changelog (nothing committed yet)
|
|
11
|
+
# 3. rake release:core:publish # commit, build + push gem, then tag + push → CI cuts the Release
|
|
12
|
+
#
|
|
13
|
+
# Same tasks under release:dashboard:*. Release `core` BEFORE `dashboard` —
|
|
14
|
+
# the dashboard depends on core, so bump its `chrono_forge` floor first if needed.
|
|
15
|
+
#
|
|
16
|
+
# prepare leaves the bump + changelog UNCOMMITTED so you can review the diff
|
|
17
|
+
# first; publish commits them, then publishes. publish is idempotent + resumable:
|
|
18
|
+
# it skips a gem already live and only tags if the tag is missing, so a partial
|
|
19
|
+
# failure can just be re-run.
|
|
20
|
+
|
|
21
|
+
RELEASE_CLIFF_CONFIG = "cliff.toml"
|
|
22
|
+
|
|
23
|
+
# Per-gem config. tag_pattern + scope mirror what each CHANGELOG should reflect:
|
|
24
|
+
# core = everything except the dashboard subtree, dashboard = only that subtree.
|
|
25
|
+
RELEASE_GEMS = {
|
|
26
|
+
"core" => {
|
|
27
|
+
name: "chrono_forge",
|
|
28
|
+
version_file: "lib/chrono_forge/version.rb",
|
|
29
|
+
changelog: "CHANGELOG.md",
|
|
30
|
+
gemspec: "chrono_forge.gemspec",
|
|
31
|
+
build_dir: ".",
|
|
32
|
+
tag_prefix: "v",
|
|
33
|
+
tag_pattern: "^v[0-9]",
|
|
34
|
+
scope: ["--exclude-path", "chrono_forge-dashboard/**"],
|
|
35
|
+
extra_files: []
|
|
36
|
+
},
|
|
37
|
+
"dashboard" => {
|
|
38
|
+
name: "chrono_forge-dashboard",
|
|
39
|
+
version_file: "chrono_forge-dashboard/lib/chrono_forge/dashboard/version.rb",
|
|
40
|
+
changelog: "chrono_forge-dashboard/CHANGELOG.md",
|
|
41
|
+
gemspec: "chrono_forge-dashboard.gemspec",
|
|
42
|
+
build_dir: "chrono_forge-dashboard",
|
|
43
|
+
tag_prefix: "chrono_forge-dashboard-v",
|
|
44
|
+
tag_pattern: "^chrono_forge-dashboard-v[0-9]",
|
|
45
|
+
scope: ["--include-path", "chrono_forge-dashboard/**"],
|
|
46
|
+
# The dashboard ships a compiled stylesheet — recompile it so the tagged
|
|
47
|
+
# tree (and the gem) never ship CSS that lags the source/views.
|
|
48
|
+
assets: -> {
|
|
49
|
+
Dir.chdir("chrono_forge-dashboard") do
|
|
50
|
+
system("bundle", "exec", "rake", "tailwind:build") || abort("tailwind:build failed")
|
|
51
|
+
end
|
|
52
|
+
},
|
|
53
|
+
extra_files: ["chrono_forge-dashboard/app/assets/chrono_forge/dashboard/dashboard.css"]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
namespace :release do
|
|
58
|
+
# --- helpers --------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def git_cliff?
|
|
61
|
+
system("which git-cliff > /dev/null 2>&1")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def release_current_version(cfg)
|
|
65
|
+
File.read(cfg[:version_file])[/VERSION = "([\d.]+)"/, 1] ||
|
|
66
|
+
abort("Could not read VERSION from #{cfg[:version_file]}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def release_cliff_cmd(cfg, *extra)
|
|
70
|
+
["git-cliff", "--config", RELEASE_CLIFF_CONFIG, "--tag-pattern", cfg[:tag_pattern], *cfg[:scope], *extra]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Capture git-cliff stdout, discarding the stderr update-check chatter.
|
|
74
|
+
def release_capture(cmd)
|
|
75
|
+
IO.popen(cmd, err: File::NULL, &:read)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Next version per conventional commits. git-cliff owns the semver math
|
|
79
|
+
# (including the pre-1.0 rules under [bump] in cliff.toml). Returns it
|
|
80
|
+
# without the gem's tag prefix.
|
|
81
|
+
def release_next_version(cfg)
|
|
82
|
+
abort "git-cliff not found. Install with: brew install git-cliff" unless git_cliff?
|
|
83
|
+
bumped = release_capture(release_cliff_cmd(cfg, "--bumped-version")).strip
|
|
84
|
+
abort "git-cliff could not compute a version (no conventional commits since the last #{cfg[:name]} tag?)" if bumped.empty?
|
|
85
|
+
bumped.delete_prefix(cfg[:tag_prefix])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def release_gem_published?(cfg, version)
|
|
89
|
+
out = `gem list --remote --exact --all #{cfg[:name]} 2>/dev/null`
|
|
90
|
+
out.include?("#{version},") || out.include?("#{version})") || out.include?(" #{version} ")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Inject ONLY this version's section above the latest entry. A full -o regen
|
|
94
|
+
# would misattribute past releases because path-filtering confuses cliff's
|
|
95
|
+
# historical tag boundaries; existing entries are preserved verbatim.
|
|
96
|
+
def release_prepend_changelog(path, section)
|
|
97
|
+
body = File.read(path)
|
|
98
|
+
block = "#{section.strip}\n\n"
|
|
99
|
+
updated = (body =~ /^## \[/) ? body.sub(/^## \[/, "#{block}## [") : "#{body.rstrip}\n\n#{block}"
|
|
100
|
+
File.write(path, updated)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# --- per-gem tasks --------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
RELEASE_GEMS.each do |key, cfg|
|
|
106
|
+
namespace key do
|
|
107
|
+
desc "Show #{cfg[:name]}'s next version computed from conventional commits"
|
|
108
|
+
task :version do
|
|
109
|
+
puts "#{cfg[:name]} current: #{release_current_version(cfg)}"
|
|
110
|
+
puts "#{cfg[:name]} next: #{release_next_version(cfg)}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
desc "Prepare a #{cfg[:name]} release commit (bump + changelog + assets). Version optional; git-cliff computes it."
|
|
114
|
+
task :prepare, [:version] do |_t, args|
|
|
115
|
+
version = args[:version] || release_next_version(cfg)
|
|
116
|
+
abort "Error: version must be in format X.Y.Z (got #{version.inspect})" unless version.match?(/^\d+\.\d+\.\d+$/)
|
|
117
|
+
abort "Error: working tree is dirty. Commit or stash first." unless `git status --porcelain`.strip.empty?
|
|
118
|
+
abort "Error: not on main." unless `git rev-parse --abbrev-ref HEAD`.strip == "main"
|
|
119
|
+
|
|
120
|
+
system("git fetch -q origin")
|
|
121
|
+
abort "Error: main is not in sync with origin/main." unless `git rev-parse HEAD`.strip == `git rev-parse origin/main`.strip
|
|
122
|
+
|
|
123
|
+
tag = "#{cfg[:tag_prefix]}#{version}"
|
|
124
|
+
abort "Error: tag #{tag} already exists." if system("git rev-parse #{tag} >/dev/null 2>&1")
|
|
125
|
+
|
|
126
|
+
puts "Preparing #{cfg[:name]} #{version} (tag #{tag})..."
|
|
127
|
+
|
|
128
|
+
# Bump version.
|
|
129
|
+
content = File.read(cfg[:version_file])
|
|
130
|
+
File.write(cfg[:version_file], content.gsub(/VERSION = "[\d.]+"/, %(VERSION = "#{version}")))
|
|
131
|
+
puts "✓ #{cfg[:version_file]}"
|
|
132
|
+
|
|
133
|
+
# Compile assets (dashboard CSS), if any.
|
|
134
|
+
cfg[:assets]&.call
|
|
135
|
+
|
|
136
|
+
# Changelog — same config CI uses for the notes, so they agree.
|
|
137
|
+
section = release_capture(release_cliff_cmd(cfg, "--tag", tag, "--unreleased", "--strip", "all"))
|
|
138
|
+
abort "git-cliff found no entries since the last #{cfg[:name]} tag — nothing to release." if section.strip.empty?
|
|
139
|
+
release_prepend_changelog(cfg[:changelog], section)
|
|
140
|
+
puts "✓ #{cfg[:changelog]}"
|
|
141
|
+
|
|
142
|
+
# Leave everything uncommitted so the bump + changelog can be reviewed
|
|
143
|
+
# before anything is committed or published. publish makes the commit.
|
|
144
|
+
files = [cfg[:version_file], cfg[:changelog], *cfg[:extra_files]]
|
|
145
|
+
|
|
146
|
+
puts "\n✓ Prepared #{cfg[:name]} #{version} — nothing committed yet."
|
|
147
|
+
puts "Next:"
|
|
148
|
+
puts " git diff -- #{files.join(" ")}"
|
|
149
|
+
puts " rake release:#{key}:publish # commit, build + push gem, then tag + push"
|
|
150
|
+
puts " (abort with: git checkout -- #{files.join(" ")})"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
desc "Publish #{cfg[:name]} (build + push gem, then tag + push). Idempotent + resumable."
|
|
154
|
+
task :publish do
|
|
155
|
+
version = release_current_version(cfg)
|
|
156
|
+
tag = "#{cfg[:tag_prefix]}#{version}"
|
|
157
|
+
files = [cfg[:version_file], cfg[:changelog], *cfg[:extra_files]]
|
|
158
|
+
|
|
159
|
+
# Commit the prepared changes (you review the diff between prepare and
|
|
160
|
+
# here). Resumable: if they're already committed — e.g. a re-run after a
|
|
161
|
+
# partial failure — skip straight to publishing.
|
|
162
|
+
if `git status --porcelain -- #{files.join(" ")}`.strip.empty?
|
|
163
|
+
unless `git log -1 --format=%s`.strip == "chore(release): #{cfg[:name]} #{version}"
|
|
164
|
+
abort "Nothing prepared — run rake release:#{key}:prepare first."
|
|
165
|
+
end
|
|
166
|
+
else
|
|
167
|
+
system("git", "add", *files) || abort("git add failed")
|
|
168
|
+
system("git", "commit", "-m", "chore(release): #{cfg[:name]} #{version}") || abort("git commit failed")
|
|
169
|
+
puts "✓ Committed #{cfg[:name]} #{version}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
if release_gem_published?(cfg, version)
|
|
173
|
+
puts "• #{cfg[:name]} #{version} already on RubyGems — skipping"
|
|
174
|
+
else
|
|
175
|
+
puts "Building + pushing gem..."
|
|
176
|
+
Dir.chdir(cfg[:build_dir]) do
|
|
177
|
+
system("gem build #{cfg[:gemspec]}") || abort("Gem build failed")
|
|
178
|
+
gem_file = "#{cfg[:name]}-#{version}.gem"
|
|
179
|
+
system("gem push #{gem_file}") || abort("Gem push failed")
|
|
180
|
+
File.delete(gem_file) if File.exist?(gem_file)
|
|
181
|
+
end
|
|
182
|
+
puts "✓ Published #{cfg[:name]} #{version} to RubyGems"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Tag + push last, so CI cuts the Release only once the gem is live.
|
|
186
|
+
branch = `git branch --show-current`.strip
|
|
187
|
+
if system("git rev-parse #{tag} >/dev/null 2>&1")
|
|
188
|
+
puts "• tag #{tag} already exists — skipping tag"
|
|
189
|
+
else
|
|
190
|
+
system("git", "tag", tag) || abort("git tag failed")
|
|
191
|
+
end
|
|
192
|
+
system("git", "push", "origin", branch) || abort("git push branch failed")
|
|
193
|
+
system("git", "push", "origin", tag) || abort("git push tag failed")
|
|
194
|
+
|
|
195
|
+
puts "\n✓ Released #{tag}. GitHub Actions will cut the Release from the tag."
|
|
196
|
+
if key == "core"
|
|
197
|
+
puts " Next: bump the dashboard's chrono_forge floor if it should require #{version}, then release:dashboard:*"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Neutralize the dangerous bare `rake release` that bundler/gem_tasks defines
|
|
205
|
+
# (it would tag + gem push directly). Point people at the real flow instead.
|
|
206
|
+
if Rake::Task.task_defined?("release")
|
|
207
|
+
Rake::Task["release"].clear
|
|
208
|
+
desc "Disabled — use release:core:* or release:dashboard:* (see lib/tasks/release.rake)"
|
|
209
|
+
task :release do
|
|
210
|
+
warn "Use `rake release:core:prepare` then `rake release:core:publish` (or :dashboard). See lib/tasks/release.rake."
|
|
211
|
+
end
|
|
212
|
+
end
|