good_pipeline 0.1.0 → 0.2.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +52 -7
  4. data/app/controllers/good_pipeline/pipelines_controller.rb +3 -2
  5. data/app/frontend/good_pipeline/style.css +83 -4
  6. data/app/helpers/good_pipeline/mermaid_diagram_builder.rb +105 -0
  7. data/app/helpers/good_pipeline/pipelines_helper.rb +5 -30
  8. data/app/jobs/good_pipeline/pipeline_callback_job.rb +4 -4
  9. data/app/models/good_pipeline/step_record.rb +9 -3
  10. data/app/views/good_pipeline/pipelines/definitions.html.erb +13 -1
  11. data/app/views/good_pipeline/pipelines/show.html.erb +13 -1
  12. data/app/views/layouts/good_pipeline/application.html.erb +115 -0
  13. data/demo/app/pipelines/branch_test_pipeline.rb +28 -0
  14. data/demo/db/migrate/20260319205326_create_good_pipeline_tables.rb +6 -2
  15. data/demo/db/seeds.rb +225 -1
  16. data/demo/docs/screenshots/definitions.png +0 -0
  17. data/demo/docs/screenshots/show.png +0 -0
  18. data/demo/test/good_pipeline/test_coordinator.rb +18 -0
  19. data/demo/test/good_pipeline/test_step_record.rb +12 -10
  20. data/demo/test/integration/test_branch_execution.rb +98 -0
  21. data/demo/test/integration/test_halt_ignore_chain.rb +75 -0
  22. data/demo/test/integration/test_ignore_transitive_exemption.rb +94 -0
  23. data/demo/test/integration/test_late_chain_registration.rb +80 -0
  24. data/demo/test/integration/test_missing_decision_method.rb +34 -0
  25. data/demo/test/integration/test_sequential_branches.rb +124 -0
  26. data/demo/test/integration/test_undeclared_branch_arm.rb +143 -0
  27. data/docs/.vitepress/config.mts +1 -0
  28. data/docs/architecture.md +14 -16
  29. data/docs/branching.md +135 -0
  30. data/docs/callbacks.md +3 -7
  31. data/docs/cleanup.md +2 -6
  32. data/docs/dag-validation.md +1 -1
  33. data/docs/dashboard.md +2 -2
  34. data/docs/defining-pipelines.md +25 -8
  35. data/docs/failure-strategies.md +4 -4
  36. data/docs/getting-started.md +2 -1
  37. data/docs/index.md +7 -7
  38. data/docs/introduction.md +12 -12
  39. data/docs/monitoring.md +20 -20
  40. data/docs/pipeline-chaining.md +6 -5
  41. data/docs/public/screenshots/definitions.png +0 -0
  42. data/docs/public/screenshots/index.png +0 -0
  43. data/docs/public/screenshots/show.png +0 -0
  44. data/docs/screenshots/definitions.png +0 -0
  45. data/docs/screenshots/index.png +0 -0
  46. data/docs/screenshots/show.png +0 -0
  47. data/lib/generators/good_pipeline/install/templates/create_good_pipeline_tables.rb.erb +6 -2
  48. data/lib/good_pipeline/branch_builder.rb +23 -0
  49. data/lib/good_pipeline/branch_resolver.rb +55 -0
  50. data/lib/good_pipeline/chain.rb +8 -0
  51. data/lib/good_pipeline/chain_coordinator.rb +34 -33
  52. data/lib/good_pipeline/coordinator.rb +156 -133
  53. data/lib/good_pipeline/cycle_detector.rb +24 -22
  54. data/lib/good_pipeline/failure_metadata.rb +18 -16
  55. data/lib/good_pipeline/pipeline.rb +80 -10
  56. data/lib/good_pipeline/runner.rb +23 -4
  57. data/lib/good_pipeline/step_definition.rb +32 -4
  58. data/lib/good_pipeline/version.rb +1 -1
  59. data/lib/good_pipeline.rb +2 -0
  60. metadata +15 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f30546b22291c0898027eb5a9458771ab943c424c20312f3ea3a03b954e9f30
4
- data.tar.gz: 965deb9b03bfca479696dbf1c6baa980e57c332c4f75752912abb5027d31b44a
3
+ metadata.gz: ecdb6ba1864f5d087489e9f6b6fb74436af9da054fe2bf2d9443a34327d75ac8
4
+ data.tar.gz: 67d2ed61cd52229e854872e04e8e5da18ec3d95981a8287cf7c28742ebbb5f69
5
5
  SHA512:
6
- metadata.gz: 8faa2b2c58f1a6dce5a5b10b3498a24f70d36516b4a5d949afa594297eae4f28b10806f4484dad65645a0c7807f9daea0277d36ff127452ccfd845b610978749
7
- data.tar.gz: 250813f051c7a5566666e11f73e260251b10b3a1c6957d7da52c2a4b79faa8d38bbf720741dce429ec09fa31a69cf50d23b5eb87d75d6c80e35bbb75643ae0ea
6
+ metadata.gz: ea301d87d187f22b3f35585b3a08fc9ff1d94d3ae793bedfe2efcbdc1224b828af50beff05a36cebc81b1b0b8167b62d40d016178c6a5488210b8809893c25cc
7
+ data.tar.gz: 4afe3fdb1d05f01773a3e4bca22711e1751a83db47a0db49dc955feff2402b81c57a0d60f6e5a1b65d21e06a40f571f07158dcb744fc3029996285ff23cf4e16
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-03-24
4
+
5
+ ### Added
6
+
7
+ - **Conditional branching** — `branch` DSL verb with `on` arms for runtime decision-making. The `by:` option names a method that returns which arm to execute. Non-matching arms are `skipped_by_branch` (satisfied for downstream). Decision results are validated against declared arms — undeclared results fail the branch step with a `ConfigurationError` and the pipeline reaches a terminal state through normal failure propagation.
8
+ - **Empty arms** — `on :skip` without a block for if-without-else patterns. Empty arms draw direct edges to the next step in the dashboard.
9
+ - **Sequential branches** — multiple `branch` calls in sequence, where each branch waits for the previous branch's chosen arm to complete before running its decision method.
10
+ - **Multiple steps per arm** — arms can contain multiple `run` calls with intra-arm `after:` dependencies.
11
+ - **Interactive diagrams** — zoom (buttons + mouse wheel), click-drag pan, and fullscreen toggle on pipeline DAG visualizations.
12
+ - **Terminal "End" node** — all DAG diagrams now show an End node connecting from terminal steps, giving every diagram a clear finish point.
13
+ - **`skipped_by_branch` status** — new terminal coordination status that counts as satisfied for downstream dependency resolution, distinct from failure-based `skipped`.
14
+ - **`branch` JSONB column** — stores all branch metadata (`decides`, `branch_result`, `branch_key`, `branch_arm`, `empty_arms`) via `store_accessor` on a single column on the steps table.
15
+ - **Database indexes** — added index on `coordination_status` (steps) and a unique index on chains.
16
+ - **Step-level failure strategy validation** — `StepDefinition` rejects invalid `on_failure:` values at definition time with a `ConfigurationError`.
17
+ - **`display_name`** — optional class-level DSL method to override how a pipeline appears on the dashboard. Falls back to the default `underscore.titleize` format when not set.
18
+
19
+ ### Changed
20
+
21
+ - **`enqueue_options` column** — replaced separate `queue` and `priority` columns with a single `enqueue_options` JSONB column. Supports `queue`, `priority`, `wait`, `good_job_labels`, and `good_job_notify`.
22
+ - **`private_class_method` replaced with `class << self`** — `Coordinator`, `ChainCoordinator`, `CycleDetector`, and `FailureMetadata` now use `class << self` with `private` keyword instead of `private_class_method` lists at the bottom of each class.
23
+
24
+ ### Fixed
25
+
26
+ - **Late `.then` registrations no longer strand downstream pipelines** — if `.then` is called after the upstream pipeline has already reached a terminal state, the chain coordinator now immediately propagates that state.
27
+ - **Step-level `:ignore` under `:halt` now protects the full downstream subgraph** — `skip_all_pending_steps` now computes the transitive closure of downstream steps (via BFS) instead of only exempting direct children. Previously, `A(ignore) -> B -> C` under `:halt` would preserve `B` but immediately skip `C`.
28
+
3
29
  ## [0.1.0] - 2026-03-20
4
30
 
5
31
  ### Added
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  DAG-based job pipeline orchestration for Rails, built on [GoodJob](https://github.com/bensheldon/good_job).
4
4
 
5
- Define multi-step workflows as directed acyclic graphs, where each step is a GoodJob job. GoodPipeline handles dependency resolution, parallel execution, failure strategies, pipeline chaining, lifecycle callbacks, and provides a built-in web dashboard.
5
+ Define multi-step workflows as directed acyclic graphs, where each step is a GoodJob job. GoodPipeline handles dependency resolution, parallel execution, failure strategies, pipeline chaining, and lifecycle callbacks. It also ships with a web dashboard.
6
6
 
7
7
  ## Requirements
8
8
 
@@ -87,11 +87,10 @@ VideoProcessingPipeline.run(video_id: 123)
87
87
 
88
88
  ```ruby
89
89
  run :step_key, JobClass,
90
- with: { key: "value" }, # keyword args passed to the job
91
- after: :other_step, # dependency (symbol or array of symbols)
92
- on_failure: :ignore, # step-level failure strategy override
93
- queue: :media, # optional queue override
94
- priority: 10 # optional priority override
90
+ with: { key: "value" }, # keyword args passed to the job
91
+ after: :other_step, # dependency (symbol or array of symbols)
92
+ on_failure: :ignore, # step-level failure strategy override
93
+ enqueue: { queue: :media, priority: 10 } # options passed to job.enqueue()
95
94
  ```
96
95
 
97
96
  ### Failure strategies
@@ -106,6 +105,52 @@ Set at the pipeline level with `failure_strategy`:
106
105
 
107
106
  Per-step overrides via `on_failure:` in `run` apply to that step's outgoing edges only.
108
107
 
108
+ ### Conditional branching
109
+
110
+ Use `branch` to take different paths at runtime based on application state:
111
+
112
+ ```ruby
113
+ class MediaPipeline < GoodPipeline::Pipeline
114
+ def configure(media_id:)
115
+ run :analyze, AnalyzeJob, with: { media_id: media_id }
116
+
117
+ branch :format_check, after: :analyze, by: :detect_format do
118
+ on :hd do
119
+ run :transcode_hd, TranscodeHDJob, with: { media_id: media_id }
120
+ run :upscale, UpscaleJob, with: { media_id: media_id }, after: :transcode_hd
121
+ end
122
+
123
+ on :sd do
124
+ run :transcode_sd, TranscodeSDJob, with: { media_id: media_id }
125
+ end
126
+ end
127
+
128
+ run :publish, PublishJob, after: :format_check
129
+ end
130
+
131
+ private
132
+
133
+ def detect_format
134
+ Media.find(params[:media_id]).hd? ? :hd : :sd
135
+ end
136
+ end
137
+ ```
138
+
139
+ The `by:` method is evaluated at runtime when the branch step is reached. The matching arm runs; other arms are skipped. `after: :format_check` waits for whichever arm was chosen to complete.
140
+
141
+ Arms can also be empty for an if-without-else pattern:
142
+
143
+ ```ruby
144
+ branch :quality_check, after: :analyze, by: :needs_processing do
145
+ on :yes do
146
+ run :process, ProcessJob
147
+ end
148
+ on :no # skip — pipeline continues to next step
149
+ end
150
+ ```
151
+
152
+ The dashboard renders branches as diamond decision nodes with labeled edges.
153
+
109
154
  ### Pipeline chaining
110
155
 
111
156
  Chain pipelines together with `.then()`:
@@ -187,7 +232,7 @@ The dashboard provides:
187
232
 
188
233
  ![Pipeline Definitions](docs/screenshots/definitions.png)
189
234
 
190
- No build step required. Uses Pico CSS and Mermaid.js from CDN.
235
+ No build step. Uses Pico CSS and Mermaid.js from CDN.
191
236
 
192
237
  ## Cleanup
193
238
 
@@ -19,14 +19,15 @@ module GoodPipeline
19
19
  pipeline_ids = pipeline_types.filter_map do |type|
20
20
  PipelineRecord.where(type: type).order(created_at: :desc).pick(:id)
21
21
  end
22
- @pipelines = PipelineRecord.includes(:steps, dependencies: %i[step depends_on_step])
22
+ @pipelines = PipelineRecord.includes(steps: :upstream_steps, dependencies: %i[step depends_on_step])
23
23
  .where(id: pipeline_ids)
24
24
  .sort_by(&:type)
25
25
  end
26
26
 
27
27
  def show
28
28
  scope = PipelineRecord.includes(
29
- :upstream_pipelines, :downstream_pipelines, :steps,
29
+ :upstream_pipelines, :downstream_pipelines,
30
+ steps: :upstream_steps,
30
31
  dependencies: %i[step depends_on_step]
31
32
  )
32
33
  @pipeline = scope.find(params[:id])
@@ -121,6 +121,7 @@ body {
121
121
  .badge-failed { background-color: #ffebee; color: #c62828; border: 1px solid #ffcdd2; }
122
122
  .badge-halted { background-color: #fff3e0; color: #e65100; border: 1px solid #ffe0b2; }
123
123
  .badge-skipped { background-color: #f5f5f5; color: #757575; border: 1px solid #e0e0e0; }
124
+ .badge-skipped_by_branch { background-color: #f5f5f5; color: #757575; border: 1px solid #e0e0e0; }
124
125
 
125
126
  /* Strategy label */
126
127
  .strategy-label {
@@ -468,18 +469,18 @@ article.table-card > header {
468
469
 
469
470
  /* Detail panel */
470
471
  .definitions-detail {
471
- min-height: 400px;
472
472
  position: relative;
473
473
  }
474
474
 
475
475
  .definition-panel {
476
- position: absolute;
476
+ height: 0;
477
+ overflow: hidden;
477
478
  visibility: hidden;
478
- width: 100%;
479
479
  }
480
480
 
481
481
  .definition-panel.active {
482
- position: static;
482
+ height: auto;
483
+ overflow: visible;
483
484
  visibility: visible;
484
485
  }
485
486
 
@@ -516,3 +517,81 @@ article.table-card > header {
516
517
  font-size: 0.8em;
517
518
  padding: 0.1em 0.35em;
518
519
  }
520
+
521
+ /* Interactive diagram container */
522
+ .diagram-container {
523
+ position: relative;
524
+ }
525
+
526
+ .diagram-toolbar {
527
+ position: absolute;
528
+ bottom: 0.5rem;
529
+ right: 0.5rem;
530
+ z-index: 10;
531
+ display: flex;
532
+ gap: 0.25rem;
533
+ opacity: 0.4;
534
+ transition: opacity 0.15s ease;
535
+ }
536
+
537
+ .diagram-container:hover .diagram-toolbar {
538
+ opacity: 1;
539
+ }
540
+
541
+ .diagram-btn {
542
+ all: unset;
543
+ display: flex;
544
+ align-items: center;
545
+ justify-content: center;
546
+ width: 1.5rem;
547
+ height: 1.5rem;
548
+ background: #fff;
549
+ border: 1px solid #ddd;
550
+ border-radius: 4px;
551
+ cursor: pointer;
552
+ font-size: 0.72rem;
553
+ font-weight: 600;
554
+ color: #555;
555
+ user-select: none;
556
+ transition: background-color 0.1s ease;
557
+ }
558
+
559
+ .diagram-btn[data-action="zoom-reset"] {
560
+ width: auto;
561
+ padding: 0 0.4rem;
562
+ font-size: 0.62rem;
563
+ font-weight: 500;
564
+ }
565
+
566
+ .diagram-btn:hover {
567
+ background-color: #f0f0f0;
568
+ }
569
+
570
+ .diagram-viewport {
571
+ overflow: hidden;
572
+ cursor: grab;
573
+ min-height: 200px;
574
+ }
575
+
576
+ .diagram-viewport:active {
577
+ cursor: grabbing;
578
+ }
579
+
580
+ .diagram-canvas {
581
+ transform-origin: 0 0;
582
+ transition: transform 0.15s ease;
583
+ }
584
+
585
+ .diagram-canvas.dragging {
586
+ transition: none;
587
+ }
588
+
589
+ .diagram-container:fullscreen {
590
+ background: #fff;
591
+ display: flex;
592
+ flex-direction: column;
593
+ }
594
+
595
+ .diagram-container:fullscreen .diagram-viewport {
596
+ flex: 1;
597
+ }
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoodPipeline
4
+ class MermaidDiagramBuilder
5
+ DEFINITION_CLASSES = [
6
+ " classDef step fill:#4a90d9,color:#fff,stroke:#3a7bc8",
7
+ " classDef branch fill:#ff9800,color:#fff,stroke:#f57c00",
8
+ " classDef terminal fill:#1a1a2e,color:#fff,stroke:#1a1a2e"
9
+ ].freeze
10
+
11
+ STATUS_CLASSES = [
12
+ " classDef pending fill:#9e9e9e,color:#fff",
13
+ " classDef enqueued fill:#2196f3,color:#fff",
14
+ " classDef succeeded fill:#4caf50,color:#fff",
15
+ " classDef failed fill:#f44336,color:#fff",
16
+ " classDef skipped fill:#bdbdbd,color:#333",
17
+ " classDef skipped_by_branch fill:#bdbdbd,color:#333",
18
+ " classDef branch fill:#ff9800,color:#fff,stroke:#f57c00",
19
+ " classDef terminal fill:#1a1a2e,color:#fff,stroke:#1a1a2e"
20
+ ].freeze
21
+
22
+ def initialize(pipeline)
23
+ @pipeline = pipeline
24
+ end
25
+
26
+ def definition_diagram
27
+ lines = ["graph TD"]
28
+ append_step_nodes(lines) { |step| step.branch_step? ? "branch" : "step" }
29
+ append_edges(lines)
30
+ append_terminal_node(lines)
31
+ lines.concat(DEFINITION_CLASSES)
32
+ lines.join("\n")
33
+ end
34
+
35
+ def status_diagram
36
+ lines = ["graph TD"]
37
+ append_step_nodes(lines) { |step| step.branch_step? ? "branch" : step.coordination_status }
38
+ append_edges(lines)
39
+ append_terminal_node(lines)
40
+ lines.concat(STATUS_CLASSES)
41
+ lines.join("\n")
42
+ end
43
+
44
+ private
45
+
46
+ def append_step_nodes(lines)
47
+ @pipeline.steps.each do |step|
48
+ css_class = yield(step)
49
+ lines << if step.branch_step?
50
+ " #{step.key}{\"#{step.key}\"}:::#{css_class}"
51
+ else
52
+ " #{step.key}(\"#{step.key}\"):::#{css_class}"
53
+ end
54
+ end
55
+ end
56
+
57
+ def append_edges(lines)
58
+ @pipeline.dependencies.each do |dependency|
59
+ upstream = dependency.depends_on_step
60
+ downstream = dependency.step
61
+ lines << if upstream.branch_step? && downstream.branch_arm_step?
62
+ " #{upstream.key} -->|#{downstream.branch_arm}| #{downstream.key}"
63
+ else
64
+ " #{upstream.key} --> #{downstream.key}"
65
+ end
66
+ end
67
+ end
68
+
69
+ def append_terminal_node(lines)
70
+ has_downstream_ids = @pipeline.dependencies.to_set { |dependency| dependency.depends_on_step.id }
71
+ terminal_steps = @pipeline.steps.reject { |step| has_downstream_ids.include?(step.id) }
72
+
73
+ lines << " end_node((\" \")):::terminal"
74
+ terminal_steps.each { |step| lines << " #{step.key} --> end_node" }
75
+
76
+ append_empty_arm_edges(lines)
77
+ end
78
+
79
+ def append_empty_arm_edges(lines) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
80
+ arm_step_keys_by_branch = @pipeline.steps.select(&:branch_arm_step?).group_by(&:branch_key)
81
+
82
+ @pipeline.steps.select(&:branch_step?).each do |branch_step|
83
+ next if branch_step.empty_arms.blank?
84
+
85
+ targets = find_post_branch_targets(branch_step, arm_step_keys_by_branch)
86
+
87
+ branch_step.empty_arms.each do |arm_name|
88
+ if targets.any?
89
+ targets.each { |target| lines << " #{branch_step.key} -->|#{arm_name}| #{target.key}" }
90
+ else
91
+ lines << " #{branch_step.key} -->|#{arm_name}| end_node"
92
+ end
93
+ end
94
+ end
95
+ end
96
+
97
+ def find_post_branch_targets(branch_step, arm_step_keys_by_branch)
98
+ arm_keys = (arm_step_keys_by_branch[branch_step.key] || []).to_set(&:key)
99
+
100
+ @pipeline.steps.select do |step|
101
+ !arm_keys.include?(step.key) && step.upstream_steps.any? { |upstream| arm_keys.include?(upstream.key) }
102
+ end
103
+ end
104
+ end
105
+ end
@@ -9,7 +9,8 @@ module GoodPipeline
9
9
  "succeeded" => "\u2713 Succeeded",
10
10
  "failed" => "\u2717 Failed",
11
11
  "halted" => "\u2298 Halted",
12
- "skipped" => "\u2298 Skipped"
12
+ "skipped" => "\u2298 Skipped",
13
+ "skipped_by_branch" => "\u2298 Skipped (branch)"
13
14
  }.freeze
14
15
 
15
16
  def status_badge(status)
@@ -18,7 +19,7 @@ module GoodPipeline
18
19
  end
19
20
 
20
21
  def humanized_type(pipeline_type)
21
- pipeline_type.to_s.underscore.titleize
22
+ pipeline_type.to_s.safe_constantize&.display_name || pipeline_type.to_s.underscore.titleize
22
23
  end
23
24
 
24
25
  def relative_time_tag(datetime)
@@ -41,27 +42,11 @@ module GoodPipeline
41
42
  end
42
43
 
43
44
  def mermaid_definition_diagram(pipeline)
44
- lines = ["graph TD"]
45
- pipeline.steps.each do |step|
46
- lines << " #{step.key}(\"#{step.key}\"):::step"
47
- end
48
- pipeline.dependencies.each do |dependency|
49
- lines << " #{dependency.depends_on_step.key} --> #{dependency.step.key}"
50
- end
51
- lines << " classDef step fill:#4a90d9,color:#fff,stroke:#3a7bc8"
52
- lines.join("\n")
45
+ MermaidDiagramBuilder.new(pipeline).definition_diagram
53
46
  end
54
47
 
55
48
  def mermaid_diagram(pipeline)
56
- lines = ["graph TD"]
57
- pipeline.steps.each do |step|
58
- lines << " #{step.key}(\"#{step.key}\"):::#{step.coordination_status}"
59
- end
60
- pipeline.dependencies.each do |dependency|
61
- lines << " #{dependency.depends_on_step.key} --> #{dependency.step.key}"
62
- end
63
- lines.concat(mermaid_status_classes)
64
- lines.join("\n")
49
+ MermaidDiagramBuilder.new(pipeline).status_diagram
65
50
  end
66
51
 
67
52
  def good_job_step_url(step)
@@ -94,16 +79,6 @@ module GoodPipeline
94
79
 
95
80
  private
96
81
 
97
- def mermaid_status_classes
98
- [
99
- " classDef pending fill:#9e9e9e,color:#fff",
100
- " classDef enqueued fill:#2196f3,color:#fff",
101
- " classDef succeeded fill:#4caf50,color:#fff",
102
- " classDef failed fill:#f44336,color:#fff",
103
- " classDef skipped fill:#bdbdbd,color:#333"
104
- ]
105
- end
106
-
107
82
  def good_job_mount_path
108
83
  return nil unless defined?(GoodJob::Engine)
109
84
 
@@ -10,7 +10,7 @@ module GoodPipeline
10
10
  end
11
11
 
12
12
  pipeline_record = GoodPipeline::PipelineRecord.find(pipeline_id)
13
- pipeline = pipeline_record.type.constantize.for_callback(pipeline_record)
13
+ pipeline = pipeline_record.type.constantize.reconstruct(pipeline_record)
14
14
 
15
15
  errors = []
16
16
 
@@ -34,8 +34,8 @@ module GoodPipeline
34
34
  return unless callback
35
35
 
36
36
  pipeline.send(callback)
37
- rescue StandardError => e
38
- errors << e
37
+ rescue StandardError => error
38
+ errors << error
39
39
  end
40
40
 
41
41
  def raise_callback_errors(errors)
@@ -44,7 +44,7 @@ module GoodPipeline
44
44
  primary = errors.first
45
45
  if errors.size > 1
46
46
  suppressed = errors[1..].map { |error| "#{error.class}: #{error.message}" }.join("; ")
47
- raise primary.class, "#{primary.message} (suppressed #{errors.size - 1} additional error(s): #{suppressed})"
47
+ raise primary, "#{primary.message} (suppressed #{errors.size - 1} additional error(s): #{suppressed})"
48
48
  end
49
49
  raise primary
50
50
  end
@@ -4,10 +4,10 @@ module GoodPipeline
4
4
  class StepRecord < ActiveRecord::Base
5
5
  self.table_name = "good_pipeline_steps"
6
6
 
7
- TERMINAL_COORDINATION_STATUSES = %w[succeeded failed skipped].freeze
7
+ TERMINAL_COORDINATION_STATUSES = %w[succeeded failed skipped skipped_by_branch].freeze
8
8
 
9
9
  VALID_COORDINATION_TRANSITIONS = {
10
- "pending" => %w[enqueued skipped],
10
+ "pending" => %w[enqueued skipped skipped_by_branch succeeded failed],
11
11
  "enqueued" => %w[succeeded failed]
12
12
  }.freeze
13
13
 
@@ -16,11 +16,14 @@ module GoodPipeline
16
16
  enqueued: "enqueued",
17
17
  succeeded: "succeeded",
18
18
  failed: "failed",
19
- skipped: "skipped"
19
+ skipped: "skipped",
20
+ skipped_by_branch: "skipped_by_branch"
20
21
  }
21
22
 
22
23
  enum :on_failure_strategy, { halt: "halt", continue: "continue", ignore: "ignore" }
23
24
 
25
+ store_accessor :branch, :decides, :branch_result, :branch_key, :branch_arm, :empty_arms
26
+
24
27
  belongs_to :pipeline,
25
28
  class_name: "GoodPipeline::PipelineRecord",
26
29
  foreign_key: :pipeline_id,
@@ -46,6 +49,9 @@ module GoodPipeline
46
49
  through: :downstream_dependencies,
47
50
  source: :step
48
51
 
52
+ def branch_step? = job_class == GoodPipeline::BRANCH_JOB_CLASS
53
+ def branch_arm_step? = branch_arm.present?
54
+
49
55
  def duration
50
56
  return nil unless good_job_id
51
57
 
@@ -23,7 +23,19 @@
23
23
  <%= link_to "View Executions \u2192", pipelines_path(pipeline_type: pipeline.type), class: "definition-executions-link" %>
24
24
  </header>
25
25
  <% if pipeline.steps.any? %>
26
- <pre class="mermaid"><%= raw mermaid_definition_diagram(pipeline) %></pre>
26
+ <div class="diagram-container">
27
+ <div class="diagram-toolbar">
28
+ <button type="button" class="diagram-btn" data-action="zoom-in" title="Zoom in">+</button>
29
+ <button type="button" class="diagram-btn" data-action="zoom-out" title="Zoom out">&minus;</button>
30
+ <button type="button" class="diagram-btn" data-action="zoom-reset" title="Reset zoom">Reset</button>
31
+ <button type="button" class="diagram-btn" data-action="fullscreen" title="Fullscreen">&#x26F6;</button>
32
+ </div>
33
+ <div class="diagram-viewport">
34
+ <div class="diagram-canvas">
35
+ <pre class="mermaid"><%= raw mermaid_definition_diagram(pipeline) %></pre>
36
+ </div>
37
+ </div>
38
+ </div>
27
39
  <% else %>
28
40
  <p class="empty-state">No steps defined.</p>
29
41
  <% end %>
@@ -31,7 +31,19 @@
31
31
  <article>
32
32
  <header><h3>Pipeline Graph</h3></header>
33
33
  <% if @pipeline.steps.any? %>
34
- <pre class="mermaid"><%= raw mermaid_diagram(@pipeline) %></pre>
34
+ <div class="diagram-container">
35
+ <div class="diagram-toolbar">
36
+ <button type="button" class="diagram-btn" data-action="zoom-in" title="Zoom in">+</button>
37
+ <button type="button" class="diagram-btn" data-action="zoom-out" title="Zoom out">&minus;</button>
38
+ <button type="button" class="diagram-btn" data-action="zoom-reset" title="Reset zoom">Reset</button>
39
+ <button type="button" class="diagram-btn" data-action="fullscreen" title="Fullscreen">&#x26F6;</button>
40
+ </div>
41
+ <div class="diagram-viewport">
42
+ <div class="diagram-canvas">
43
+ <pre class="mermaid"><%= raw mermaid_diagram(@pipeline) %></pre>
44
+ </div>
45
+ </div>
46
+ </div>
35
47
  <% else %>
36
48
  <p class="empty-state">No steps defined.</p>
37
49
  <% end %>
@@ -36,5 +36,120 @@
36
36
  });
37
37
  });
38
38
  </script>
39
+ <script>
40
+ (function() {
41
+ var MIN_SCALE = 0.5, MAX_SCALE = 4, ZOOM_STEP = 0.25;
42
+ var states = new WeakMap();
43
+
44
+ function getState(container) {
45
+ if (!states.has(container)) states.set(container, { scale: 1, panX: 0, panY: 0 });
46
+ return states.get(container);
47
+ }
48
+
49
+ function applyTransform(container) {
50
+ var s = getState(container);
51
+ var canvas = container.querySelector('.diagram-canvas');
52
+ if (canvas) canvas.style.transform = 'translate(' + s.panX + 'px, ' + s.panY + 'px) scale(' + s.scale + ')';
53
+ }
54
+
55
+ function clampScale(val) {
56
+ return Math.min(MAX_SCALE, Math.max(MIN_SCALE, val));
57
+ }
58
+
59
+ function zoomAroundPoint(s, centerX, centerY, newScale) {
60
+ var oldScale = s.scale;
61
+ newScale = clampScale(newScale);
62
+ if (newScale === oldScale) return;
63
+ var ratio = newScale / oldScale;
64
+ s.panX = centerX - (centerX - s.panX) * ratio;
65
+ s.panY = centerY - (centerY - s.panY) * ratio;
66
+ s.scale = newScale;
67
+ }
68
+
69
+ // Button actions via event delegation
70
+ document.addEventListener('click', function(e) {
71
+ var btn = e.target.closest('[data-action]');
72
+ if (!btn) return;
73
+ var container = btn.closest('.diagram-container');
74
+ if (!container) return;
75
+ var s = getState(container);
76
+ var action = btn.dataset.action;
77
+
78
+ if (action === 'zoom-in' || action === 'zoom-out') {
79
+ var viewport = container.querySelector('.diagram-viewport');
80
+ var rect = viewport.getBoundingClientRect();
81
+ var centerX = rect.width / 2;
82
+ var centerY = rect.height / 2;
83
+ var delta = action === 'zoom-in' ? ZOOM_STEP : -ZOOM_STEP;
84
+ zoomAroundPoint(s, centerX, centerY, s.scale + delta);
85
+ } else if (action === 'zoom-reset') {
86
+ s.scale = 1; s.panX = 0; s.panY = 0;
87
+ } else if (action === 'fullscreen') {
88
+ if (document.fullscreenElement === container) {
89
+ document.exitFullscreen();
90
+ } else {
91
+ container.requestFullscreen();
92
+ }
93
+ return;
94
+ }
95
+ applyTransform(container);
96
+ });
97
+
98
+ // Mouse wheel zoom toward cursor
99
+ document.querySelectorAll('.diagram-viewport').forEach(function(viewport) {
100
+ viewport.addEventListener('wheel', function(e) {
101
+ e.preventDefault();
102
+ var container = viewport.closest('.diagram-container');
103
+ var s = getState(container);
104
+ var rect = viewport.getBoundingClientRect();
105
+ var cursorX = e.clientX - rect.left;
106
+ var cursorY = e.clientY - rect.top;
107
+ var delta = e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP;
108
+ zoomAroundPoint(s, cursorX, cursorY, s.scale + delta);
109
+ applyTransform(container);
110
+ }, { passive: false });
111
+ });
112
+
113
+ // Click-drag pan
114
+ document.querySelectorAll('.diagram-viewport').forEach(function(viewport) {
115
+ viewport.addEventListener('mousedown', function(e) {
116
+ if (e.button !== 0) return;
117
+ var container = viewport.closest('.diagram-container');
118
+ var canvas = container.querySelector('.diagram-canvas');
119
+ var s = getState(container);
120
+ var startX = e.clientX - s.panX;
121
+ var startY = e.clientY - s.panY;
122
+ canvas.classList.add('dragging');
123
+
124
+ function onMove(ev) {
125
+ s.panX = ev.clientX - startX;
126
+ s.panY = ev.clientY - startY;
127
+ applyTransform(container);
128
+ }
129
+ function onUp() {
130
+ canvas.classList.remove('dragging');
131
+ document.removeEventListener('mousemove', onMove);
132
+ document.removeEventListener('mouseup', onUp);
133
+ }
134
+ document.addEventListener('mousemove', onMove);
135
+ document.addEventListener('mouseup', onUp);
136
+ });
137
+ });
138
+
139
+ // Reset zoom when switching definitions
140
+ document.querySelectorAll('.definition-item').forEach(function(btn) {
141
+ btn.addEventListener('click', function() {
142
+ var type = btn.dataset.pipeline;
143
+ var panel = document.querySelector('.definition-panel[data-pipeline="' + type + '"]');
144
+ if (!panel) return;
145
+ var container = panel.querySelector('.diagram-container');
146
+ if (!container) return;
147
+ var s = getState(container);
148
+ s.scale = 1; s.panX = 0; s.panY = 0;
149
+ applyTransform(container);
150
+ });
151
+ });
152
+ })();
153
+ </script>
39
154
  </body>
40
155
  </html>