active_durable 0.5.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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +131 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +630 -0
  5. data/Rakefile +12 -0
  6. data/app/controllers/active_durable/application_controller.rb +25 -0
  7. data/app/controllers/active_durable/executions_controller.rb +59 -0
  8. data/app/helpers/active_durable/dashboard_helper.rb +163 -0
  9. data/app/views/active_durable/executions/index.html.erb +90 -0
  10. data/app/views/active_durable/executions/show.html.erb +197 -0
  11. data/app/views/layouts/active_durable/application.html.erb +422 -0
  12. data/config/routes.rb +13 -0
  13. data/lib/active_durable/configuration.rb +59 -0
  14. data/lib/active_durable/engine.rb +24 -0
  15. data/lib/active_durable/errors.rb +61 -0
  16. data/lib/active_durable/execution.rb +43 -0
  17. data/lib/active_durable/flow.rb +277 -0
  18. data/lib/active_durable/flow_parallel.rb +200 -0
  19. data/lib/active_durable/lease.rb +50 -0
  20. data/lib/active_durable/notebook.rb +100 -0
  21. data/lib/active_durable/open_telemetry.rb +94 -0
  22. data/lib/active_durable/operations.rb +111 -0
  23. data/lib/active_durable/parallel.rb +54 -0
  24. data/lib/active_durable/record.rb +13 -0
  25. data/lib/active_durable/registry.rb +89 -0
  26. data/lib/active_durable/retry_policy.rb +42 -0
  27. data/lib/active_durable/run_job.rb +12 -0
  28. data/lib/active_durable/runner.rb +157 -0
  29. data/lib/active_durable/serializer.rb +40 -0
  30. data/lib/active_durable/signal_record.rb +20 -0
  31. data/lib/active_durable/step.rb +34 -0
  32. data/lib/active_durable/sweep_job.rb +12 -0
  33. data/lib/active_durable/sweeper.rb +22 -0
  34. data/lib/active_durable/testing.rb +118 -0
  35. data/lib/active_durable/version.rb +5 -0
  36. data/lib/active_durable.rb +145 -0
  37. data/lib/generators/active_durable/install/install_generator.rb +27 -0
  38. data/lib/generators/active_durable/install/templates/create_active_durable_tables.rb.tt +60 -0
  39. data/lib/tasks/active_durable.rake +24 -0
  40. data/sig/active_durable.rbs +4 -0
  41. metadata +134 -0
@@ -0,0 +1,422 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title><%= content_for?(:title) ? "#{yield(:title)} · ActiveDurable" : "ActiveDurable" %></title>
7
+ <%= csrf_meta_tags %>
8
+ <style nonce="<%= content_security_policy_nonce %>">
9
+ /* Layout: a fixed rail (statuses, recipes) and a full-width stage. Sagas are drawn as blocks wired
10
+ together; every animation encodes a state (running, waiting, sleeping, failed, undone). */
11
+ :root {
12
+ --paper: #f2f4fa; --surface: #ffffff; --sunken: #eaeef7;
13
+ --ink: #141b33; --soft: #56607e; --line: #d3daea;
14
+ --jade: #12936a; --jade-bg: #daf3e9; --amber: #a86f00; --amber-bg: #fcefcc;
15
+ --cobalt: #2c5fd6; --cobalt-bg: #e1e9fc; --ruby: #cc1f3d; --ruby-bg: #fbe1e6;
16
+ --violet: #6b44c8; --violet-bg: #ece5fb; --slate: #6b7593; --slate-bg: #e6e9f1;
17
+ --shade: rgba(20, 27, 51, .28);
18
+ --f-round: ui-rounded, "SF Pro Rounded", "Hiragino Maru Gothic ProN", "Nunito", "Varela Round", system-ui, sans-serif;
19
+ --f-body: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
20
+ --f-code: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
21
+ }
22
+ @media (prefers-color-scheme: dark) {
23
+ :root {
24
+ --paper: #0e1424; --surface: #151d33; --sunken: #10172a;
25
+ --ink: #e8ecf8; --soft: #9aa5c4; --line: #26304d;
26
+ --jade: #3dd6a0; --jade-bg: #0f3329; --amber: #f2b641; --amber-bg: #3a2d10;
27
+ --cobalt: #7ea6ff; --cobalt-bg: #182649; --ruby: #ff5c74; --ruby-bg: #3b1522;
28
+ --violet: #b08cff; --violet-bg: #281f48; --slate: #8b95b3; --slate-bg: #1d2540;
29
+ --shade: rgba(0, 0, 0, .45);
30
+ color-scheme: dark;
31
+ }
32
+ }
33
+ .tone-good, .st-completed { --c: var(--jade); --cb: var(--jade-bg); }
34
+ .tone-wait, .st-retrying, .st-waiting, .st-sleeping { --c: var(--amber); --cb: var(--amber-bg); }
35
+ .tone-busy, .st-running, .st-next { --c: var(--cobalt); --cb: var(--cobalt-bg); }
36
+ .tone-bad, .st-failed { --c: var(--ruby); --cb: var(--ruby-bg); }
37
+ .tone-undo, .st-undone { --c: var(--violet); --cb: var(--violet-bg); }
38
+ .tone-muted { --c: var(--slate); --cb: var(--slate-bg); }
39
+
40
+ * { box-sizing: border-box; }
41
+ html, body { min-height: 100%; }
42
+ body {
43
+ margin: 0; color: var(--ink); font: 15px/1.5 var(--f-body);
44
+ background-color: var(--paper);
45
+ }
46
+ a { color: var(--cobalt); text-underline-offset: 3px; }
47
+ :focus-visible { outline: 3px solid var(--cobalt); outline-offset: 2px; border-radius: 6px; }
48
+ h1, h2, h3 { font-family: var(--f-round); margin: 0; line-height: 1.15; text-wrap: balance; }
49
+ h2 { font-size: 1.1rem; }
50
+ p { margin: 0; }
51
+ .muted { color: var(--soft); }
52
+ code, .json, .mono { font-family: var(--f-code); font-size: .82rem; overflow-wrap: anywhere; }
53
+
54
+ /* ---------- shell ---------- */
55
+ .shell { display: grid; grid-template-columns: 250px minmax(0, 1fr); min-height: 100vh; }
56
+ .rail {
57
+ position: sticky; top: 0; height: 100vh; overflow-y: auto; padding: 22px 16px;
58
+ display: flex; flex-direction: column; gap: 26px; border-right: 1px solid var(--line);
59
+ background: color-mix(in srgb, var(--surface) 82%, transparent); backdrop-filter: blur(8px);
60
+ }
61
+ .brand { display: flex; align-items: center; gap: 10px; color: var(--ink); text-decoration: none;
62
+ font: 800 1.15rem/1 var(--f-round); }
63
+ .logo { display: grid; grid-template-columns: repeat(2, 11px); gap: 3px; }
64
+ .logo i { width: 11px; height: 11px; border-radius: 3px; background: var(--c); box-shadow: 0 2px 0 var(--shade); }
65
+ .logo i:nth-child(1) { --c: var(--jade); } .logo i:nth-child(2) { --c: var(--amber); }
66
+ .logo i:nth-child(3) { --c: var(--cobalt); } .logo i:nth-child(4) { --c: var(--ruby); }
67
+ .brand:hover .logo i { animation: hop .5s ease both; }
68
+ .brand:hover .logo i:nth-child(2) { animation-delay: .06s; } .brand:hover .logo i:nth-child(3) { animation-delay: .12s; }
69
+ .brand:hover .logo i:nth-child(4) { animation-delay: .18s; }
70
+ .rail h2 { font-size: .85rem; color: var(--soft); font-weight: 700; margin-bottom: 8px; }
71
+ .rail nav { display: flex; flex-direction: column; gap: 2px; }
72
+ .rail-link { display: flex; align-items: center; gap: 10px; padding: 7px 10px; border-radius: 9px; color: var(--ink);
73
+ text-decoration: none; font-weight: 600; }
74
+ .rail-link:hover { background: var(--sunken); }
75
+ .rail-link[aria-current="page"] { background: var(--cb, var(--sunken)); color: var(--c, var(--ink)); }
76
+ .rail-link .n { margin-left: auto; font-variant-numeric: tabular-nums; color: var(--soft); font-weight: 700; }
77
+ .rail-link .chip { width: 12px; height: 12px; border-radius: 3px; background: var(--c, var(--slate)); box-shadow: 0 2px 0 var(--shade); flex: none; }
78
+ .rail-foot { margin-top: auto; color: var(--soft); font-size: .82rem; }
79
+
80
+ .stage { padding: 26px 34px 56px; display: flex; flex-direction: column; gap: 22px; min-width: 0; }
81
+ .flash { padding: 12px 16px; border-radius: 12px; font-weight: 600; animation: rise .35s ease both; }
82
+ .flash.notice { background: var(--jade-bg); color: var(--jade); }
83
+ .flash.alert { background: var(--ruby-bg); color: var(--ruby); }
84
+
85
+ /* ---------- headings and tools ---------- */
86
+ .stage-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 16px 24px; }
87
+ .stage-head h1 { font-size: clamp(1.8rem, 3vw, 2.6rem); font-weight: 800; letter-spacing: -.01em; }
88
+ .lede { color: var(--soft); font-size: 1.02rem; margin-top: 6px; }
89
+ .tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
90
+ input, select { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px;
91
+ background: var(--surface); color: var(--ink); min-width: 0; }
92
+ button, .button { font: inherit; font-weight: 700; padding: 8px 14px; border-radius: 10px; border: 1px solid var(--line);
93
+ background: var(--surface); color: var(--ink); cursor: pointer; text-decoration: none; }
94
+ button:hover { border-color: var(--soft); }
95
+ button.primary { background: var(--cobalt); border-color: var(--cobalt); color: #fff; box-shadow: 0 3px 0 var(--shade); }
96
+ button.danger { background: var(--ruby); border-color: var(--ruby); color: #fff; box-shadow: 0 3px 0 var(--shade); }
97
+ button.primary:active, button.danger:active { transform: translateY(2px); box-shadow: 0 1px 0 var(--shade); }
98
+ form.inline { display: inline-flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0; }
99
+ .live { display: inline-flex; align-items: center; gap: 8px; }
100
+ .live .led { width: 9px; height: 9px; border-radius: 50%; background: var(--slate); }
101
+ .live[aria-pressed="true"] { border-color: var(--jade); color: var(--jade); }
102
+ .live[aria-pressed="true"] .led { background: var(--jade); animation: breathe 1.6s ease-in-out infinite; }
103
+ .live-bar { position: fixed; top: 0; left: 0; height: 3px; background: var(--jade); width: 0; z-index: 10; }
104
+ .live-bar.run { animation: fill var(--cycle, 5s) linear forwards; }
105
+
106
+ /* ---------- the list ---------- */
107
+ .sheet { background: color-mix(in srgb, var(--surface) 92%, transparent); border: 1px solid var(--line); border-radius: 16px;
108
+ overflow-x: auto; }
109
+ table { border-collapse: collapse; width: 100%; font-size: .92rem; }
110
+ th, td { text-align: left; padding: 12px 14px; border-bottom: 1px solid var(--line); vertical-align: middle; }
111
+ th { color: var(--soft); font-weight: 700; font-size: .82rem; background: var(--sunken); white-space: nowrap; }
112
+ tbody tr:last-child td { border-bottom: 0; }
113
+ tbody tr { transition: background-color .2s; }
114
+ tbody tr:hover { background: var(--sunken); }
115
+ tr.changed { animation: rowflash 1.8s ease; }
116
+ .exec-link { font: 700 .95rem/1.2 var(--f-code); white-space: nowrap; text-decoration: none; color: var(--ink); }
117
+ .exec-link:hover { text-decoration: underline; }
118
+ .recipe { display: block; color: var(--soft); font-size: .82rem; margin-top: 2px; }
119
+ .problem { color: var(--ruby); max-width: 34ch; }
120
+ .when { white-space: nowrap; font-variant-numeric: tabular-nums; }
121
+ .status { display: inline-flex; align-items: center; gap: 7px; padding: 4px 10px 4px 8px; border-radius: 999px;
122
+ background: var(--cb); color: var(--c); font-weight: 700; font-size: .82rem; white-space: nowrap; }
123
+ .status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--c); }
124
+ .status.tone-busy .dot, .status.tone-bad .dot { animation: breathe 1.4s ease-in-out infinite; }
125
+ .status.tone-wait .dot { animation: breathe 2.6s ease-in-out infinite; }
126
+ .pill { display: inline-block; font-size: .78rem; font-weight: 700; padding: 3px 9px; border-radius: 999px;
127
+ background: var(--sunken); color: var(--soft); white-space: nowrap; }
128
+ .pill-good { background: var(--jade-bg); color: var(--jade); } .pill-bad { background: var(--ruby-bg); color: var(--ruby); }
129
+ .pill-wait { background: var(--amber-bg); color: var(--amber); } .pill-busy { background: var(--cobalt-bg); color: var(--cobalt); }
130
+ .pill-undo { background: var(--violet-bg); color: var(--violet); }
131
+ .pager { display: flex; gap: 16px; }
132
+ .empty { padding: 48px 24px; text-align: center; display: flex; flex-direction: column; gap: 10px; align-items: center; }
133
+
134
+ /* mini track: one small block per step */
135
+ .mini { display: flex; align-items: center; gap: 5px; min-height: 26px; }
136
+ .mini .b { width: 18px; height: 18px; border-radius: 5px; background: var(--cb); border: 2px solid var(--c, var(--slate));
137
+ box-shadow: 0 2px 0 var(--shade); flex: none; animation: pop .35s cubic-bezier(.2, 1.6, .4, 1) both;
138
+ animation-delay: calc(var(--i, 0) * 45ms); }
139
+ .mini .b.st-completed { background: var(--c); }
140
+ .mini .b.k-pivot { transform: rotate(45deg) scale(.86); animation-name: pop-pivot; }
141
+ .mini .b.st-failed { animation: pop .35s both, shake .5s .5s ease 2; }
142
+ .mini .b.st-undone { background: repeating-linear-gradient(135deg, var(--cb) 0 3px, var(--c) 3px 5px); }
143
+ .mini .b.st-next { border-style: dashed; background: transparent; animation: pop .35s both, beat 1.2s ease-in-out infinite; }
144
+ .mini .b.st-retrying, .mini .b.st-sleeping { animation: pop .35s both, beat 2.2s ease-in-out infinite; }
145
+ .mini .b.st-waiting { animation: pop .35s both, ping-small 2s ease-out infinite; }
146
+ .mini .stack { display: flex; flex-direction: column; gap: 2px; }
147
+ .mini .stack .b { width: 18px; height: 7px; border-radius: 2px; border-width: 1.5px; box-shadow: none; }
148
+ .mini .more { font-size: .78rem; color: var(--soft); font-weight: 700; }
149
+ .no-entrance .b, .no-entrance .block, .no-entrance .wire { animation-delay: 0s !important; animation-duration: .01s !important; }
150
+
151
+ /* ---------- one execution ---------- */
152
+ .crumbs { color: var(--ink); font-size: .9rem; font-weight: 600; }
153
+ .hero { display: flex; align-items: center; gap: 22px; padding: 22px 24px; border-radius: 20px; flex-wrap: wrap;
154
+ background: linear-gradient(120deg, var(--cb), color-mix(in srgb, var(--surface) 90%, transparent) 70%);
155
+ border: 1px solid color-mix(in srgb, var(--c) 35%, var(--line)); }
156
+ .hero h1 { font: 800 clamp(1.5rem, 3vw, 2.3rem)/1.1 var(--f-code); overflow-wrap: anywhere; }
157
+ .hero .sub { color: var(--soft); margin-top: 6px; }
158
+ .hero-main { flex: 1 1 320px; min-width: 0; }
159
+ .badge { font: 800 1rem/1 var(--f-round); color: var(--c); padding: 10px 16px; border-radius: 999px;
160
+ background: var(--surface); border: 2px solid var(--c); white-space: nowrap; }
161
+ .beacon { width: 54px; height: 54px; border-radius: 16px; background: var(--c); position: relative; flex: none;
162
+ box-shadow: 0 6px 0 color-mix(in srgb, var(--c) 55%, #000); }
163
+ .hero.tone-bad .beacon { animation: alarm 1.6s ease-in-out infinite; }
164
+ .hero.tone-busy .beacon { animation: beat 1.3s ease-in-out infinite; }
165
+ .hero.tone-wait .beacon { animation: float 3s ease-in-out infinite; }
166
+ .hero.tone-good .beacon { animation: settle .7s cubic-bezier(.2, 1.6, .4, 1) both; }
167
+ .hero.tone-undo .beacon { animation: rewind 2.4s ease-in-out infinite; }
168
+
169
+ .problem-card { display: flex; flex-direction: column; gap: 6px; padding: 16px 18px; border-radius: 14px;
170
+ background: var(--ruby-bg); color: var(--ruby); border: 1px solid color-mix(in srgb, var(--ruby) 35%, transparent); }
171
+ .problem-card strong { font: 700 1.05rem/1.4 var(--f-round); }
172
+ .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
173
+ .actions .note { color: var(--soft); font-size: .88rem; flex-basis: 100%; }
174
+
175
+ /* the track: big blocks wired together */
176
+ .board { background: color-mix(in srgb, var(--surface) 70%, transparent); border: 1px solid var(--line); border-radius: 20px;
177
+ padding: 26px 24px 22px; display: flex; flex-direction: column; gap: 22px; }
178
+ .board-head { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; align-items: baseline; }
179
+ .track-scroll { overflow-x: auto; padding: 6px 4px 18px; }
180
+ .track { display: flex; align-items: center; width: 100%; }
181
+ .block { position: relative; flex: 1 1 0; min-width: 158px; max-width: 300px; min-height: 124px; padding: 14px 14px 12px; border-radius: 16px;
182
+ background: var(--cb, var(--slate-bg)); border: 2px solid var(--c, var(--slate)); --c2: color-mix(in srgb, var(--c, var(--slate)) 55%, #000);
183
+ box-shadow: 0 7px 0 var(--c2); display: flex; flex-direction: column; gap: 4px;
184
+ animation: drop .55s cubic-bezier(.2, 1.5, .4, 1) both; animation-delay: calc(var(--i, 0) * 90ms); }
185
+ .block .top { display: flex; justify-content: space-between; align-items: center; font-size: .78rem; color: var(--soft); }
186
+ .block .kind { font-weight: 700; }
187
+ .block .no { font: 800 .9rem/1 var(--f-round); color: var(--c, var(--soft)); }
188
+ .block h3 { font-size: 1.05rem; overflow-wrap: break-word; }
189
+ .block .state { font-weight: 700; color: var(--c, var(--soft)); font-size: .88rem; }
190
+ .block .meta { color: var(--soft); font-size: .8rem; }
191
+ .block .peek { font: .74rem/1.3 var(--f-code); color: var(--soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
192
+ .block.st-completed { --cb: color-mix(in srgb, var(--jade-bg) 85%, var(--surface)); }
193
+ .block.k-pivot { border-width: 3px; }
194
+ .block.k-pivot .kind { color: var(--amber); }
195
+ .block.st-failed { animation: drop .55s both, shake .5s .7s ease 2; box-shadow: 0 7px 0 var(--c2), 0 0 0 6px color-mix(in srgb, var(--ruby) 18%, transparent); }
196
+ .block.st-undone { background: repeating-linear-gradient(135deg, var(--violet-bg) 0 10px, color-mix(in srgb, var(--violet) 22%, var(--violet-bg)) 10px 20px); }
197
+ .block.st-undone h3 { text-decoration: line-through; text-decoration-thickness: 2px; }
198
+ .block.st-next { background: transparent; border-style: dashed; box-shadow: none; animation: drop .55s both, beat 1.4s ease-in-out infinite; }
199
+ .block.st-retrying { animation: drop .55s both, beat 2.4s ease-in-out infinite; }
200
+ .block.st-running { animation: drop .55s both, beat 1.2s ease-in-out infinite; }
201
+ .block .radar, .block .radar::after { position: absolute; right: 12px; bottom: 12px; width: 14px; height: 14px; border-radius: 50%;
202
+ border: 2px solid var(--amber); }
203
+ .block .radar::after { content: ""; right: -2px; bottom: -2px; animation: ping 1.8s ease-out infinite; }
204
+ .block .ring { position: absolute; right: 12px; bottom: 12px; width: 22px; height: 22px; border-radius: 50%;
205
+ background: conic-gradient(var(--amber) calc(var(--p, 0) * 1turn), color-mix(in srgb, var(--amber) 20%, transparent) 0);
206
+ -webkit-mask: radial-gradient(circle, transparent 5px, #000 6px); mask: radial-gradient(circle, transparent 5px, #000 6px); }
207
+ .block .spin { position: absolute; right: 12px; bottom: 12px; width: 18px; height: 18px; border-radius: 50%;
208
+ border: 3px solid color-mix(in srgb, var(--amber) 25%, transparent); border-top-color: var(--amber);
209
+ animation: spin 1.1s linear infinite; }
210
+ .block.k-parallel { flex-grow: 1.4; min-width: 200px; }
211
+ .branches { display: flex; flex-direction: column; gap: 5px; margin-top: 4px; }
212
+ .branch { display: flex; align-items: center; gap: 8px; font-size: .82rem; font-weight: 600; }
213
+ .branch i { width: 12px; height: 12px; border-radius: 3px; border: 2px solid var(--c, var(--slate)); background: var(--cb); flex: none; }
214
+ .branch i.st-completed { background: var(--c); }
215
+ .branch i.st-retrying, .branch i.st-running { animation: beat 1.4s ease-in-out infinite; }
216
+
217
+ .wire { flex: 0 1 46px; min-width: 22px; height: 6px; border-radius: 3px; position: relative;
218
+ background: repeating-linear-gradient(90deg, var(--line) 0 9px, transparent 9px 15px);
219
+ animation: grow .4s ease both; animation-delay: calc(var(--i, 0) * 90ms + 60ms); transform-origin: left; }
220
+ .wire.lit { background: repeating-linear-gradient(90deg, var(--jade) 0 9px, transparent 9px 15px); background-size: 30px 6px;
221
+ animation: grow .4s ease both, flow .9s linear infinite; animation-delay: calc(var(--i, 0) * 90ms + 60ms), 0s; }
222
+ .wire.lit.to-active { background-image: repeating-linear-gradient(90deg, var(--cobalt) 0 9px, transparent 9px 15px); }
223
+ .wire.back { background: repeating-linear-gradient(90deg, var(--violet) 0 9px, transparent 9px 15px); background-size: 30px 6px;
224
+ animation: grow .4s ease both, flow .9s linear infinite reverse; animation-delay: calc(var(--i, 0) * 90ms + 60ms), 0s; }
225
+ .gate { flex: none; align-self: stretch; width: 64px; display: flex; flex-direction: column; align-items: center; justify-content: center;
226
+ gap: 6px; position: relative; animation: rise .5s ease both; animation-delay: calc(var(--i, 0) * 90ms); }
227
+ .gate::before { content: ""; width: 4px; flex: 1; min-height: 70px; border-radius: 2px;
228
+ background: repeating-linear-gradient(180deg, var(--amber) 0 8px, transparent 8px 13px); }
229
+ .gate span { font: 700 .72rem/1.2 var(--f-round); color: var(--amber); text-align: center; }
230
+
231
+ .undo-lane { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
232
+ .undo-lane .label { color: var(--violet); font-weight: 700; margin-right: 4px; }
233
+ .undo-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 10px; font-weight: 700; font-size: .85rem;
234
+ color: var(--violet); background: repeating-linear-gradient(135deg, var(--violet-bg) 0 8px, color-mix(in srgb, var(--violet) 18%, var(--violet-bg)) 8px 16px);
235
+ border: 2px solid var(--violet); animation: slide-back .5s ease both; animation-delay: calc(var(--i, 0) * 120ms + .4s); }
236
+ .undo-chip.failed { color: var(--ruby); border-color: var(--ruby); background: var(--ruby-bg); }
237
+ .undo-lane .arrow { color: var(--violet); font-weight: 800; }
238
+
239
+ .details { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(280px, 1fr); gap: 22px; align-items: start; }
240
+ .panel { background: color-mix(in srgb, var(--surface) 92%, transparent); border: 1px solid var(--line); border-radius: 16px;
241
+ padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; min-width: 0; }
242
+ .side { display: flex; flex-direction: column; gap: 22px; min-width: 0; }
243
+ .panel table { font-size: .86rem; }
244
+ .panel td, .panel th { padding: 9px 10px; vertical-align: top; }
245
+ .json-block { font: .8rem/1.5 var(--f-code); background: var(--sunken); padding: 12px; border-radius: 10px; margin: 0; overflow-x: auto; }
246
+ .k { font-size: .76rem; color: var(--soft); font-weight: 700; margin-left: 4px; }
247
+ code.ticket { white-space: nowrap; overflow-wrap: normal; }
248
+ td.result { min-width: 20ch; }
249
+ .k-pivot-word { color: var(--amber); }
250
+ .error { color: var(--ruby); }
251
+ dl.facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 14px; margin: 0; }
252
+ dl.facts dt { color: var(--soft); } dl.facts dd { margin: 0; min-width: 0; }
253
+
254
+ /* ---------- motion ---------- */
255
+ @keyframes drop { from { opacity: 0; transform: translateY(-26px) scale(.92); } to { opacity: 1; transform: none; } }
256
+ @keyframes pop { from { opacity: 0; transform: scale(.3); } to { opacity: 1; transform: none; } }
257
+ @keyframes pop-pivot { from { opacity: 0; transform: rotate(45deg) scale(.2); } to { opacity: 1; transform: rotate(45deg) scale(.86); } }
258
+ @keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
259
+ @keyframes flow { from { background-position: 0 0; } to { background-position: 30px 0; } }
260
+ @keyframes beat { 0%, 100% { filter: none; } 50% { filter: brightness(1.25) drop-shadow(0 0 8px var(--c)); } }
261
+ @keyframes breathe { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.7); } }
262
+ @keyframes ping { from { transform: scale(1); opacity: .9; } to { transform: scale(3.2); opacity: 0; } }
263
+ @keyframes ping-small { 0% { box-shadow: 0 2px 0 var(--shade), 0 0 0 0 color-mix(in srgb, var(--amber) 60%, transparent); }
264
+ 100% { box-shadow: 0 2px 0 var(--shade), 0 0 0 9px transparent; } }
265
+ @keyframes shake { 0%, 100% { translate: 0; } 25% { translate: -4px; } 75% { translate: 4px; } }
266
+ @keyframes spin { to { transform: rotate(1turn); } }
267
+ @keyframes alarm { 0%, 100% { box-shadow: 0 6px 0 color-mix(in srgb, var(--c) 55%, #000), 0 0 0 0 color-mix(in srgb, var(--c) 45%, transparent); }
268
+ 60% { box-shadow: 0 6px 0 color-mix(in srgb, var(--c) 55%, #000), 0 0 0 16px transparent; } }
269
+ @keyframes float { 0%, 100% { translate: 0 0; } 50% { translate: 0 -6px; } }
270
+ @keyframes settle { from { transform: translateY(-30px) rotate(-12deg); opacity: 0; } to { transform: none; opacity: 1; } }
271
+ @keyframes rewind { 0%, 100% { rotate: 0deg; } 40% { rotate: -14deg; } 60% { rotate: -14deg; } }
272
+ @keyframes hop { 50% { transform: translateY(-5px); } }
273
+ @keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
274
+ @keyframes slide-back { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: none; } }
275
+ @keyframes fill { from { width: 0; } to { width: 100%; } }
276
+ @keyframes rowflash { 0% { background: color-mix(in srgb, var(--cobalt) 30%, transparent); } 100% { background: transparent; } }
277
+
278
+ @media (max-width: 1100px) { .details { grid-template-columns: minmax(0, 1fr); } }
279
+ @media (max-width: 860px) {
280
+ .shell { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr; }
281
+ .rail { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: flex-start; align-content: flex-start; gap: 14px 22px;
282
+ border-right: 0; border-bottom: 1px solid var(--line); padding: 14px 16px; }
283
+ .rail nav { flex-direction: row; flex-wrap: wrap; }
284
+ .rail-foot { display: none; }
285
+ .stage { padding: 20px 16px 40px; }
286
+ }
287
+ @media (prefers-reduced-motion: reduce) {
288
+ *, *::before, *::after { animation: none !important; transition: none !important; }
289
+ }
290
+ </style>
291
+ </head>
292
+ <body>
293
+ <div class="live-bar" data-live-bar></div>
294
+ <div class="shell">
295
+ <aside class="rail">
296
+ <%= link_to root_path, class: "brand" do %>
297
+ <span class="logo" aria-hidden="true"><i></i><i></i><i></i><i></i></span>ActiveDurable
298
+ <% end %>
299
+ <div data-live-rail>
300
+ <h2>Executions</h2>
301
+ <nav aria-label="Executions by status">
302
+ <%= link_to executions_path, class: "rail-link", "aria-current": ("page" if controller_name == "executions" && action_name == "index" && params[:status].blank?) do %>
303
+ <span class="chip" aria-hidden="true"></span>All<span class="n"><%= status_counts.values.sum %></span>
304
+ <% end %>
305
+ <% ActiveDurable::Execution::STATUSES.each do |status| %>
306
+ <% next unless status_counts[status] %>
307
+ <%= link_to executions_path(status: status), class: "rail-link tone-#{ActiveDurable::DashboardHelper::STATUS_TONES[status]}",
308
+ "aria-current": ("page" if params[:status] == status) do %>
309
+ <span class="chip" aria-hidden="true"></span><%= ActiveDurable::DashboardHelper::STATUS_WORDS[status] %><span class="n"><%= status_counts[status] %></span>
310
+ <% end %>
311
+ <% end %>
312
+ </nav>
313
+ </div>
314
+ <% if recipe_names.any? %>
315
+ <div>
316
+ <h2>Recipes</h2>
317
+ <nav aria-label="Executions by recipe">
318
+ <% recipe_names.each do |recipe| %>
319
+ <%= link_to recipe, executions_path(recipe: recipe), class: "rail-link", "aria-current": ("page" if params[:recipe] == recipe) %>
320
+ <% end %>
321
+ </nav>
322
+ </div>
323
+ <% end %>
324
+ <p class="rail-foot">Version <%= ActiveDurable::VERSION %></p>
325
+ </aside>
326
+ <main class="stage">
327
+ <% flash.each do |type, message| %>
328
+ <p class="flash <%= type %>" role="status"><%= message %></p>
329
+ <% end %>
330
+ <%= yield %>
331
+ </main>
332
+ </div>
333
+ <script nonce="<%= content_security_policy_nonce %>">
334
+ (function () {
335
+ var reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
336
+
337
+ function span(seconds) {
338
+ var s = Math.abs(Math.round(seconds));
339
+ if (s < 60) return s + " s";
340
+ var m = Math.floor(s / 60);
341
+ if (m < 60) return m + " min";
342
+ var h = Math.floor(m / 60);
343
+ if (h < 24) return h + " h " + (m % 60) + " min";
344
+ return Math.floor(h / 24) + " d " + (h % 24) + " h";
345
+ }
346
+
347
+ function tick() {
348
+ var now = Date.now();
349
+ document.querySelectorAll("time[data-relative]").forEach(function (el) {
350
+ var at = Date.parse(el.getAttribute("datetime"));
351
+ if (isNaN(at)) return;
352
+ var diff = (at - now) / 1000;
353
+ el.textContent = Math.abs(diff) < 2 ? "now" : diff > 0 ? "in " + span(diff) : span(diff) + " ago";
354
+ });
355
+ document.querySelectorAll("[data-ring-from]").forEach(function (el) {
356
+ var from = Date.parse(el.dataset.ringFrom), until = Date.parse(el.dataset.ringUntil);
357
+ if (isNaN(from) || isNaN(until) || until <= from) return;
358
+ el.style.setProperty("--p", Math.min(1, Math.max(0, (now - from) / (until - from))).toFixed(3));
359
+ });
360
+ }
361
+ tick();
362
+ setInterval(tick, 1000);
363
+
364
+ document.addEventListener("submit", function (event) {
365
+ var message = event.target.getAttribute("data-confirm");
366
+ if (message && !window.confirm(message)) event.preventDefault();
367
+ });
368
+
369
+ // Live updates on the list: re-fetch the page every few seconds and highlight rows that changed.
370
+ var toggle = document.querySelector("[data-live-toggle]");
371
+ if (!toggle) return;
372
+ var bar = document.querySelector("[data-live-bar]");
373
+ var cycle = 5000, timer = null;
374
+
375
+ function statuses() {
376
+ var map = {};
377
+ document.querySelectorAll("tr[data-id]").forEach(function (row) { map[row.dataset.id] = row.dataset.status; });
378
+ return map;
379
+ }
380
+
381
+ function restartBar() {
382
+ if (!bar || reduced || toggle.getAttribute("aria-pressed") !== "true") return;
383
+ bar.classList.remove("run"); void bar.offsetWidth; bar.style.setProperty("--cycle", cycle + "ms"); bar.classList.add("run");
384
+ }
385
+
386
+ function refresh() {
387
+ var before = statuses();
388
+ fetch(window.location.href, { headers: { "Accept": "text/html" }, credentials: "same-origin" })
389
+ .then(function (response) { return response.ok ? response.text() : null; })
390
+ .then(function (html) {
391
+ if (!html) return;
392
+ var doc = new DOMParser().parseFromString(html, "text/html");
393
+ ["[data-live-region]", "[data-live-rail]", "[data-live-summary]"].forEach(function (selector) {
394
+ var fresh = doc.querySelector(selector), current = document.querySelector(selector);
395
+ if (fresh && current) { fresh.classList.add("no-entrance"); current.replaceWith(fresh); }
396
+ });
397
+ document.querySelectorAll("tr[data-id]").forEach(function (row) {
398
+ if (before[row.dataset.id] !== row.dataset.status) row.classList.add("changed");
399
+ });
400
+ tick();
401
+ })
402
+ .catch(function () {})
403
+ .finally(restartBar);
404
+ }
405
+
406
+ function setLive(on) {
407
+ toggle.setAttribute("aria-pressed", on ? "true" : "false");
408
+ toggle.querySelector("[data-live-label]").textContent = on ? "Live" : "Paused";
409
+ clearInterval(timer); timer = null;
410
+ if (bar) bar.classList.remove("run");
411
+ if (on) { restartBar(); timer = setInterval(refresh, cycle); }
412
+ try { localStorage.setItem("active_durable_live", on ? "1" : "0"); } catch (e) {}
413
+ }
414
+
415
+ toggle.addEventListener("click", function () { setLive(toggle.getAttribute("aria-pressed") !== "true"); });
416
+ var saved = null;
417
+ try { saved = localStorage.getItem("active_durable_live"); } catch (e) {}
418
+ setLive(saved === "1");
419
+ })();
420
+ </script>
421
+ </body>
422
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveDurable::Engine.routes.draw do
4
+ root "executions#index"
5
+
6
+ resources :executions, only: %i[index show], constraints: { id: %r{[^/]+} } do
7
+ member do
8
+ post :retry, action: :retry_now
9
+ post :compensate
10
+ post :rerun
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDurable
4
+ # Settings for the whole gem. Change them in an initializer with ActiveDurable.configure.
5
+ class Configuration
6
+ # How long a worker owns an execution without writing to it. Must be longer than your slowest step:
7
+ # every notebook write renews it.
8
+ attr_accessor :lease_duration
9
+
10
+ # Active Job queue for ActiveDurable::RunJob and ActiveDurable::SweepJob.
11
+ attr_accessor :queue_name
12
+
13
+ # Attempts for a step before the point of no return (then the saga compensates).
14
+ attr_accessor :step_attempts
15
+
16
+ # Attempts for a step after the point of no return (then the execution is blocked for a human).
17
+ attr_accessor :after_pivot_attempts
18
+
19
+ # Attempts for an undo before the execution is blocked.
20
+ attr_accessor :undo_attempts
21
+
22
+ # Seconds to wait before retry number n (1-based). A Proc, an Array of seconds or a fixed number.
23
+ attr_accessor :backoff
24
+
25
+ # The sweeper ignores executions touched more recently than this, to leave room for their own job.
26
+ attr_accessor :sweep_grace
27
+
28
+ # Maximum threads used by flow.parallel. Your connection pool needs at least this many + 1 connections.
29
+ attr_accessor :parallel_concurrency
30
+
31
+ # Returns the current time. Tests can replace it.
32
+ attr_accessor :clock
33
+
34
+ # Who may open the dashboard: ->(controller) { ... } returning true or false. The controller gives you
35
+ # request, session and authenticate_or_request_with_http_basic. Without it, the dashboard is open in
36
+ # development and test only: closed in production, staging and any other environment.
37
+ attr_accessor :dashboard_authorize
38
+
39
+ attr_writer :logger
40
+
41
+ def initialize
42
+ @lease_duration = 300
43
+ @queue_name = :default
44
+ @step_attempts = 3
45
+ @after_pivot_attempts = 25
46
+ @undo_attempts = 10
47
+ @backoff = ->(attempt) { [2**attempt, 3600].min }
48
+ @sweep_grace = 60
49
+ @parallel_concurrency = 4
50
+ @clock = -> { Time.current }
51
+ @dashboard_authorize = nil
52
+ @logger = nil
53
+ end
54
+
55
+ def logger
56
+ @logger || (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) || ActiveSupport::Logger.new(nil)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDurable
4
+ # The dashboard and the rake tasks. Mount it in config/routes.rb:
5
+ #
6
+ # mount ActiveDurable::Engine => "/durable"
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace ActiveDurable
9
+
10
+ # rails new --api apps drop cookies, sessions and flash. The dashboard needs them for its forms
11
+ # (CSRF protection), so it brings its own, only inside its own middleware stack.
12
+ initializer "active_durable.api_only_middleware" do |app|
13
+ if app.config.api_only
14
+ middleware.use ActionDispatch::Cookies
15
+ middleware.use ActionDispatch::Session::CookieStore, key: "_active_durable_session", same_site: :strict
16
+ middleware.use ActionDispatch::Flash
17
+ end
18
+ end
19
+
20
+ rake_tasks do
21
+ load File.expand_path("../tasks/active_durable.rake", __dir__)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Error classes. Everything a recipe may want to rescue inherits from ActiveDurable::Error.
4
+ module ActiveDurable
5
+ class Error < StandardError; end
6
+
7
+ # Raised when a recipe name (or version) has not been defined.
8
+ class UnknownRecipe < Error; end
9
+
10
+ # Raised when a recipe is written in a way the engine cannot run safely.
11
+ class InvalidRecipe < Error; end
12
+
13
+ # Two steps in the same recipe share a name, so their notebook entries would collide.
14
+ class DuplicateStepName < InvalidRecipe; end
15
+
16
+ # The recipe no longer matches what the notebook recorded for an execution in flight.
17
+ class RecipeChanged < Error; end
18
+
19
+ # A step returned something that cannot be stored in the notebook as JSON.
20
+ class NotSerializable < Error; end
21
+
22
+ # Raise it inside a step to reject the work for a business reason: no retries, straight to compensation.
23
+ class Abort < Error; end
24
+
25
+ # A wait_for reached its timeout before the signal arrived.
26
+ class WaitTimeout < Error; end
27
+
28
+ # A step ran out of attempts (or was aborted). Rescue it in a recipe to try an alternative path.
29
+ class StepFailed < Error
30
+ attr_reader :step_name
31
+
32
+ def initialize(step_name, error = nil)
33
+ @step_name = step_name
34
+ detail = error.is_a?(Exception) ? "#{error.class}: #{error.message}" : error.to_s
35
+ super("step :#{step_name} failed#{" (#{detail})" unless detail.empty?}")
36
+ end
37
+ end
38
+
39
+ # An undo kept failing after all its attempts. The execution is blocked for a human to review.
40
+ class UndoFailed < Error; end
41
+
42
+ # Control flow signals. They inherit from Exception on purpose: a `rescue => e` inside
43
+ # user code must not swallow them, otherwise a lost lease could keep writing.
44
+ class ControlFlow < Exception; end # rubocop:disable Lint/InheritException
45
+
46
+ # Another worker took over this execution (our lease expired). Stop without writing anything else.
47
+ class LeaseLost < ControlFlow; end
48
+
49
+ # While compensating, replay reached a step that never completed: stop moving forward.
50
+ class StopForward < ControlFlow; end
51
+
52
+ # Serializes an exception for the notebook and the dashboard.
53
+ def self.dump_error(error, step: nil)
54
+ {
55
+ "class" => error.class.name,
56
+ "message" => error.message.to_s[0, 2000],
57
+ "step" => step,
58
+ "at" => now.utc.iso8601(6)
59
+ }.compact
60
+ end
61
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDurable
4
+ # One saga run: which recipe, with what input, and where it is.
5
+ class Execution < Record
6
+ self.table_name = "durable_executions"
7
+
8
+ ACTIVE = %w[pending running sleeping waiting].freeze
9
+ TERMINAL = %w[completed compensated blocked superseded].freeze
10
+ STATUSES = (ACTIVE + TERMINAL).freeze
11
+
12
+ attribute :input, JSON_TYPE, default: -> { {} }
13
+ attribute :output, JSON_TYPE
14
+ attribute :error, JSON_TYPE
15
+
16
+ has_many :steps, -> { order(:id) }, class_name: "ActiveDurable::Step",
17
+ foreign_key: :execution_id, inverse_of: :execution, dependent: :delete_all
18
+ has_many :signals, -> { order(:id) }, class_name: "ActiveDurable::SignalRecord",
19
+ foreign_key: :execution_id, inverse_of: :execution, dependent: :delete_all
20
+
21
+ validates :status, inclusion: { in: STATUSES }
22
+
23
+ scope :active, -> { where(status: ACTIVE) }
24
+ scope :terminal, -> { where(status: TERMINAL) }
25
+
26
+ # The buzón: the job is enqueued only after the row is committed. If the process dies in between,
27
+ # the sweeper finds the pending row and enqueues it.
28
+ after_create_commit { ActiveDurable.enqueue(id) }
29
+
30
+ def active?
31
+ ACTIVE.include?(status)
32
+ end
33
+
34
+ def terminal?
35
+ TERMINAL.include?(status)
36
+ end
37
+
38
+ # The forward steps in recipe order (undo entries excluded).
39
+ def notebook
40
+ steps.where.not(kind: "undo").reorder(:position).to_a
41
+ end
42
+ end
43
+ end