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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +52 -7
- data/app/controllers/good_pipeline/pipelines_controller.rb +3 -2
- data/app/frontend/good_pipeline/style.css +83 -4
- data/app/helpers/good_pipeline/mermaid_diagram_builder.rb +105 -0
- data/app/helpers/good_pipeline/pipelines_helper.rb +5 -30
- data/app/jobs/good_pipeline/pipeline_callback_job.rb +4 -4
- data/app/models/good_pipeline/step_record.rb +9 -3
- data/app/views/good_pipeline/pipelines/definitions.html.erb +13 -1
- data/app/views/good_pipeline/pipelines/show.html.erb +13 -1
- data/app/views/layouts/good_pipeline/application.html.erb +115 -0
- data/demo/app/pipelines/branch_test_pipeline.rb +28 -0
- data/demo/db/migrate/20260319205326_create_good_pipeline_tables.rb +6 -2
- data/demo/db/seeds.rb +225 -1
- data/demo/docs/screenshots/definitions.png +0 -0
- data/demo/docs/screenshots/show.png +0 -0
- data/demo/test/good_pipeline/test_coordinator.rb +18 -0
- data/demo/test/good_pipeline/test_step_record.rb +12 -10
- data/demo/test/integration/test_branch_execution.rb +98 -0
- data/demo/test/integration/test_halt_ignore_chain.rb +75 -0
- data/demo/test/integration/test_ignore_transitive_exemption.rb +94 -0
- data/demo/test/integration/test_late_chain_registration.rb +80 -0
- data/demo/test/integration/test_missing_decision_method.rb +34 -0
- data/demo/test/integration/test_sequential_branches.rb +124 -0
- data/demo/test/integration/test_undeclared_branch_arm.rb +143 -0
- data/docs/.vitepress/config.mts +1 -0
- data/docs/architecture.md +14 -16
- data/docs/branching.md +135 -0
- data/docs/callbacks.md +3 -7
- data/docs/cleanup.md +2 -6
- data/docs/dag-validation.md +1 -1
- data/docs/dashboard.md +2 -2
- data/docs/defining-pipelines.md +25 -8
- data/docs/failure-strategies.md +4 -4
- data/docs/getting-started.md +2 -1
- data/docs/index.md +7 -7
- data/docs/introduction.md +12 -12
- data/docs/monitoring.md +20 -20
- data/docs/pipeline-chaining.md +6 -5
- data/docs/public/screenshots/definitions.png +0 -0
- data/docs/public/screenshots/index.png +0 -0
- data/docs/public/screenshots/show.png +0 -0
- data/docs/screenshots/definitions.png +0 -0
- data/docs/screenshots/index.png +0 -0
- data/docs/screenshots/show.png +0 -0
- data/lib/generators/good_pipeline/install/templates/create_good_pipeline_tables.rb.erb +6 -2
- data/lib/good_pipeline/branch_builder.rb +23 -0
- data/lib/good_pipeline/branch_resolver.rb +55 -0
- data/lib/good_pipeline/chain.rb +8 -0
- data/lib/good_pipeline/chain_coordinator.rb +34 -33
- data/lib/good_pipeline/coordinator.rb +156 -133
- data/lib/good_pipeline/cycle_detector.rb +24 -22
- data/lib/good_pipeline/failure_metadata.rb +18 -16
- data/lib/good_pipeline/pipeline.rb +80 -10
- data/lib/good_pipeline/runner.rb +23 -4
- data/lib/good_pipeline/step_definition.rb +32 -4
- data/lib/good_pipeline/version.rb +1 -1
- data/lib/good_pipeline.rb +2 -0
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ecdb6ba1864f5d087489e9f6b6fb74436af9da054fe2bf2d9443a34327d75ac8
|
|
4
|
+
data.tar.gz: 67d2ed61cd52229e854872e04e8e5da18ec3d95981a8287cf7c28742ebbb5f69
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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" },
|
|
91
|
-
after: :other_step,
|
|
92
|
-
on_failure: :ignore,
|
|
93
|
-
queue:
|
|
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
|

|
|
189
234
|
|
|
190
|
-
No build step
|
|
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(:
|
|
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,
|
|
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
|
-
|
|
476
|
+
height: 0;
|
|
477
|
+
overflow: hidden;
|
|
477
478
|
visibility: hidden;
|
|
478
|
-
width: 100%;
|
|
479
479
|
}
|
|
480
480
|
|
|
481
481
|
.definition-panel.active {
|
|
482
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 =>
|
|
38
|
-
errors <<
|
|
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
|
|
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
|
-
<
|
|
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">−</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">⛶</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
|
-
<
|
|
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">−</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">⛶</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>
|