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
@@ -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
- # Enforce expected signature for perform_now with key as first arg and keywords after
27
- def perform_now(key, **kwargs)
28
- if !key.is_a?(String)
29
- raise ArgumentError, "Workflow key must be a string as the first argument"
30
- end
31
- super
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
- # Enforce expected signature for perform_later with key as first arg and keywords after
35
- def perform_later(key, **kwargs)
36
- if !key.is_a?(String)
37
- raise ArgumentError, "Workflow key must be a string as the first argument"
38
- end
39
- super
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
- # Add retry_now class method that calls perform_now with retry_workflow: true
43
- def retry_now(key, **)
44
- perform_now(key, retry_workflow: true, **)
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
- # Add retry_later class method that calls perform_later with retry_workflow: true
48
- def retry_later(key, **)
49
- perform_later(key, retry_workflow: true, **)
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
- # Prevent excessive retries
56
- if attempt >= self.class::RetryStrategy.max_attempts
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
- if should_retry?(e, attempt)
106
- self.class::RetryStrategy.schedule_retry(workflow, attempt: attempt)
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
- self.class::LockStrategy.release_lock(job_id, workflow)
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
- def should_retry?(error, attempt_count)
168
- attempt_count < 3
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!
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ChronoForge
4
- VERSION = "0.9.1"
4
+ VERSION = "0.11.0"
5
5
  end
@@ -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 (key) UNIQUE
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
@@ -18,6 +18,7 @@ module ChronoForge
18
18
  install_chrono_forge
19
19
  add_chrono_forge_workflow_state_index
20
20
  add_chrono_forge_error_log_step_context
21
+ add_chrono_forge_parent_execution_log
21
22
  ].freeze
22
23
 
23
24
  def copy_chrono_forge_migrations
@@ -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