belay 0.1.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 (51) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +155 -0
  4. data/app/assets/belay/application.css +1 -0
  5. data/app/controllers/belay/application_controller.rb +48 -0
  6. data/app/controllers/belay/runs_controller.rb +166 -0
  7. data/app/controllers/belay/stats_controller.rb +14 -0
  8. data/app/helpers/belay/application_helper.rb +76 -0
  9. data/app/views/belay/runs/_header.html.erb +20 -0
  10. data/app/views/belay/runs/_pending_gate.html.erb +25 -0
  11. data/app/views/belay/runs/_pending_needs_review.html.erb +30 -0
  12. data/app/views/belay/runs/_replayed_from.html.erb +7 -0
  13. data/app/views/belay/runs/_replays.html.erb +14 -0
  14. data/app/views/belay/runs/_step.html.erb +52 -0
  15. data/app/views/belay/runs/diff.html.erb +47 -0
  16. data/app/views/belay/runs/index.html.erb +60 -0
  17. data/app/views/belay/runs/show.html.erb +34 -0
  18. data/app/views/belay/shared/_flash.html.erb +11 -0
  19. data/app/views/belay/shared/_sidebar.html.erb +22 -0
  20. data/app/views/belay/stats/_chart.html.erb +56 -0
  21. data/app/views/belay/stats/_table.html.erb +22 -0
  22. data/app/views/belay/stats/index.html.erb +16 -0
  23. data/app/views/layouts/belay/application.html.erb +19 -0
  24. data/config/routes.rb +13 -0
  25. data/lib/belay/anthropic_provider.rb +166 -0
  26. data/lib/belay/application_record.rb +7 -0
  27. data/lib/belay/approval.rb +35 -0
  28. data/lib/belay/cache_entry.rb +65 -0
  29. data/lib/belay/configuration.rb +60 -0
  30. data/lib/belay/context.rb +241 -0
  31. data/lib/belay/daily_stats.rb +60 -0
  32. data/lib/belay/engine.rb +40 -0
  33. data/lib/belay/executor.rb +411 -0
  34. data/lib/belay/fake_provider.rb +41 -0
  35. data/lib/belay/janitor_job.rb +48 -0
  36. data/lib/belay/provider.rb +15 -0
  37. data/lib/belay/provider_response.rb +19 -0
  38. data/lib/belay/resume_run_job.rb +42 -0
  39. data/lib/belay/rule.rb +113 -0
  40. data/lib/belay/run.rb +629 -0
  41. data/lib/belay/step.rb +57 -0
  42. data/lib/belay/version.rb +5 -0
  43. data/lib/belay/workflow.rb +199 -0
  44. data/lib/belay.rb +49 -0
  45. data/lib/generators/belay/install/install_generator.rb +48 -0
  46. data/lib/generators/belay/install/templates/create_belay_approvals.rb.tt +43 -0
  47. data/lib/generators/belay/install/templates/create_belay_cache_entries.rb.tt +27 -0
  48. data/lib/generators/belay/install/templates/create_belay_rules.rb.tt +46 -0
  49. data/lib/generators/belay/install/templates/create_belay_runs.rb.tt +37 -0
  50. data/lib/generators/belay/install/templates/create_belay_steps.rb.tt +58 -0
  51. metadata +226 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 628271c83f016c169bdd9050abf23bbb6932fb27dcf15a74bbfbaae63e1814e4
4
+ data.tar.gz: 154ee22a4b15d42c20ae9879bad70b2f55597fd89b8f7a26bd4e96472d9f386e
5
+ SHA512:
6
+ metadata.gz: f75b75dd4980103b44896aac3b8c2b20adbf643975c198cd5127efadc353a86b70c7cd12ad825153ef816696af4066cb58e8c1d8972abbc9a79508ba94837e8b
7
+ data.tar.gz: 34595eaa201103123588bb2f08a0111c305ab0579c12b119b15508f55ab0c410ae2bd094feb326f16dc2f4f7063408f6d60823b42d00822613f8d06436d766f6
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Swakhar Dey
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # Belay
2
+
3
+ Durable execution for AI agents in Rails.
4
+
5
+ Sidekiq gives you durable background jobs. Belay gives you durable *AI* jobs — every model call and tool call is a database row, so a run can crash at step 47 and resume at step 47.
6
+
7
+ It sits under whatever prompting style you use and records everything. It is not a prompt library, not a chain-builder DSL, and not a wrapper that hides your LLM SDK.
8
+
9
+ **Status:** early release (`v0.1.0`). Core runtime, gates & approvals, replay, caching & rules, and the dashboard are implemented and proven with real process kills and real concurrent workers, not just designed - see [DESIGN_DECISIONS.md #13](DESIGN_DECISIONS.md).
10
+
11
+ **Docs:** [swakhar.github.io/belay](https://swakhar.github.io/belay/) for the browsable version of everything under `docs/`.
12
+
13
+ ## How it works
14
+
15
+ ```mermaid
16
+ flowchart TD
17
+ Start(["YourWorkflow<br/>start"]) --> Running["running<br/>steps checkpointed<br/>one at a time"]
18
+ Running -->|every step ran| Completed(["completed"])
19
+ Running -->|"gate's when: true"| Paused["paused<br/>awaiting a human"]
20
+ Paused -->|"run.approve!"| Running
21
+ Paused -->|"run.reject!"| Failed(["failed"])
22
+ Running -->|"crash, not idempotent"| NeedsReview["needs_review<br/>awaiting a human"]
23
+ NeedsReview -->|"run.resolve_needs_review!"| Running
24
+ Running -->|"crash, idempotent: true"| Running
25
+ Running -->|unhandled error| Failed
26
+ Completed -.->|"run.replay(model:)"| Shadow(["shadow run<br/>vs. the original"])
27
+ ```
28
+
29
+ Every box is a real `run.status`; every arrow is a real method on `Belay::Run` (or the crash-recovery policy a step's own `idempotent:` declares). This is the run-level picture - each individual step is its own checkpointed database row underneath it.
30
+
31
+ | Method | Called when |
32
+ |---|---|
33
+ | `Workflow.start(**input, idempotency_key:)` | Kick off a new run. |
34
+ | `run.approve!(by:, edited_payload: nil)` | A gate is paused (`run.pending_gate` present) and a human says "proceed." |
35
+ | `run.reject!(by:)` | A gate is paused and a human says "no, stop." |
36
+ | `run.resolve_needs_review!(by:, outcome:, output: nil)` | A step crashed non-idempotently (`run.pending_needs_review` present) and a human confirms what actually happened. |
37
+ | `run.replay(model:)` | A run has `completed`, and you want to compare it against a different model. |
38
+
39
+ ### Crash and resume, for real
40
+
41
+ A worker process running a 3-step invoice workflow gets `kill -9`'d mid-step. The next thing that notices - here, `Belay::JanitorJob`, the same job a real app schedules on a cron - reclaims the run and resumes it. `extract` and `verify` were already checkpointed, so they never re-run; only the interrupted `book` step (declared `idempotent: true`) does, safely.
42
+
43
+ ![A terminal recording: a Rails process running an invoice workflow gets kill -9'd mid-step, then Belay::JanitorJob reclaims and resumes the run, re-running only the interrupted step](docs/images/crash-and-resume.gif)
44
+
45
+ Full source for this demo - the workflow, the fake provider, and the two scripts shown above - is [belay-demo](https://github.com/Swakhar/belay-demo).
46
+
47
+ ## Installation
48
+
49
+ ```ruby
50
+ # Gemfile
51
+ gem "belay"
52
+ ```
53
+
54
+ ```sh
55
+ bundle install
56
+ rails generate belay:install
57
+ rails db:migrate
58
+ ```
59
+
60
+ Requires Ruby 3.2+, Rails 7.1+, and Postgres. See [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) for why.
61
+
62
+ ## Configuration
63
+
64
+ ```ruby
65
+ # config/initializers/belay.rb
66
+ Belay.configure do |config|
67
+ config.provider = Belay::AnthropicProvider.new # the provider ctx.llm calls - see docs/providers.md
68
+ config.lease_duration = 300 # seconds a worker holds a run before the janitor reclaims it
69
+ config.heartbeat_interval = 30 # seconds between heartbeats while a step is in flight
70
+ config.cache_ttl = 7 * 24 * 60 * 60 # seconds a ctx.llm(..., cacheable: true) entry stays valid; nil never expires
71
+ config.rule_approval_threshold = 3 # consistent, unedited approve!s before a gate occurrence becomes a rule
72
+ end
73
+ ```
74
+
75
+ Every setting above has the default shown; the initializer is only needed to change one.
76
+
77
+ ## Target API
78
+
79
+ ```ruby
80
+ class InvoiceWorkflow < Belay::Workflow
81
+ input :invoice_id
82
+
83
+ step :extract do |ctx|
84
+ ctx.llm(model: "claude-sonnet-5-5", prompt: Prompts.extract(ctx.invoice.raw_text), schema: InvoiceSchema)
85
+ end
86
+
87
+ step :verify do |ctx|
88
+ Verifier.check_totals!(ctx.extract) # deterministic Ruby - no model involved
89
+ end
90
+
91
+ gate :approval, when: ->(ctx) { ctx.extract[:total_cents] > 100_00 } # true pauses for a human
92
+
93
+ step :book, idempotent: true do |ctx|
94
+ Bookkeeping.record!(ctx.extract, key: ctx.step_idempotency_key)
95
+ end
96
+ end
97
+
98
+ run = InvoiceWorkflow.start(invoice_id: 42, idempotency_key: "inv-42")
99
+ run.status # => "paused" if the gate's when: was true; "completed" if every gate skipped
100
+ ```
101
+
102
+ A step is never re-executed once its output is checkpointed, retries up to `retries:` on a raise, and - if orphaned by a crash - resumes automatically only when declared `idempotent: true`; otherwise it's flagged `needs_review` for a human. A gate pauses for a human the same way, via `run.approve!`/`run.reject!`.
103
+
104
+ **→ [docs/gates.md](docs/gates.md)** for approve!/reject!/resolve_needs_review! and everything a gate can do.
105
+
106
+ ## Rules
107
+
108
+ A gate approved consistently enough, without edits, learns to auto-settle a future identical occurrence without ever pausing a human again.
109
+
110
+ ```ruby
111
+ Belay.configure { |config| config.rule_approval_threshold = 3 } # default
112
+ ```
113
+
114
+ **→ [docs/rules.md](docs/rules.md)** for how learning, invalidation, and matching actually work.
115
+
116
+ ## Replay
117
+
118
+ Re-run a completed run's model calls against a different model and diff the two side by side - everything else replays from what actually happened, never re-executed.
119
+
120
+ ```ruby
121
+ shadow = run.replay(model: "claude-opus-5-5")
122
+ shadow.replay_diff # => [{ position:, name:, original_output:, replay_output:, replayed:, changed: }, ...]
123
+ ```
124
+
125
+ **→ [docs/replay.md](docs/replay.md)** for the diff shape and what `replayed: false` means.
126
+
127
+ ## ctx.llm
128
+
129
+ ```ruby
130
+ ctx.llm(model:, prompt:, schema: nil, cacheable: false)
131
+ ```
132
+
133
+ Calls `Belay.config.provider`, validates the response against `schema:` provider-agnostically, and returns `response.content` directly as the step's own output.
134
+
135
+ **→ [docs/providers.md](docs/providers.md)** - Belay isn't Anthropic-only; bring your own provider.
136
+ **→ [docs/caching.md](docs/caching.md)** - `cacheable: true` and when it's actually safe to use.
137
+
138
+ ## Dashboard
139
+
140
+ ```ruby
141
+ # config/routes.rb
142
+ mount Belay::Engine => "/belay"
143
+ ```
144
+
145
+ A runs index, a run detail page with the full step timeline, a replay diff view, and a Stats page charting cost over time - all behind a configurable `authenticate_with` gate with no default that lets anyone through.
146
+
147
+ **→ [docs/dashboard.md](docs/dashboard.md)** for mounting, wiring in your own auth (Devise/Pundit/CanCanCan/anything), and what each page shows.
148
+
149
+ ## Roadmap
150
+
151
+ See [ROADMAP.md](ROADMAP.md).
152
+
153
+ ## License
154
+
155
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1 @@
1
+ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.btn-primary{display:inline-flex;align-items:center;justify-content:center;border-radius:.375rem;padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.btn-primary:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-offset-width:2px;--tw-ring-offset-color:#020617}.btn-primary:disabled{opacity:.5}.btn-primary{--tw-bg-opacity:1;background-color:rgb(139 92 246/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.btn-primary:hover{--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1))}.btn-primary:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(167 139 250/var(--tw-ring-opacity,1))}.btn-secondary{display:inline-flex;align-items:center;justify-content:center;border-radius:.375rem;padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.btn-secondary:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-offset-width:2px;--tw-ring-offset-color:#020617}.btn-secondary:disabled{opacity:.5}.btn-secondary{border-width:1px;--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity,1))}.btn-secondary:hover{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.btn-secondary:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(167 139 250/var(--tw-ring-opacity,1))}.btn-danger{display:inline-flex;align-items:center;justify-content:center;border-radius:.375rem;padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.btn-danger:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-offset-width:2px;--tw-ring-offset-color:#020617}.btn-danger:disabled{opacity:.5}.btn-danger{--tw-bg-opacity:1;background-color:rgb(225 29 72/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.btn-danger:hover{--tw-bg-opacity:1;background-color:rgb(244 63 94/var(--tw-bg-opacity,1))}.btn-danger:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(251 113 133/var(--tw-ring-opacity,1))}.input-field,.select-field,.textarea-field{display:block;width:100%;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity,1));padding:.5rem .75rem;font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity,1))}.input-field::-moz-placeholder,.select-field::-moz-placeholder,.textarea-field::-moz-placeholder{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.input-field::placeholder,.select-field::placeholder,.textarea-field::placeholder{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.input-field:focus,.select-field:focus,.textarea-field:focus{--tw-border-opacity:1;border-color:rgb(167 139 250/var(--tw-border-opacity,1));outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000);--tw-ring-opacity:1;--tw-ring-color:rgb(167 139 250/var(--tw-ring-opacity,1))}.field-label{margin-bottom:.25rem;display:block;font-size:.875rem;line-height:1.25rem;font-weight:500;--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.card{border-radius:.5rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(30 41 59/var(--tw-border-opacity,1));--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity,1))}.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.inset-x-0{left:0;right:0}.bottom-0{bottom:0}.z-10{z-index:10}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-auto{margin-top:auto}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-2{height:.5rem}.h-48{height:12rem}.h-8{height:2rem}.min-h-screen{min-height:100vh}.w-2{width:.5rem}.w-56{width:14rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-60{min-width:15rem}.min-w-full{min-width:100%}.max-w-4xl{max-width:56rem}.max-w-\[22px\]{max-width:22px}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-slate-800>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(30 41 59/var(--tw-divide-opacity,1))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-sm{border-radius:.125rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.border{border-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-amber-500\/25{border-color:rgba(245,158,11,.25)}.border-emerald-500\/20{border-color:rgba(16,185,129,.2)}.border-rose-500\/20{border-color:rgba(244,63,94,.2)}.border-slate-700{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity,1))}.border-slate-800{--tw-border-opacity:1;border-color:rgb(30 41 59/var(--tw-border-opacity,1))}.bg-amber-500\/10{background-color:rgba(245,158,11,.1)}.bg-amber-500\/5{background-color:rgba(245,158,11,.05)}.bg-emerald-500\/10{background-color:rgba(16,185,129,.1)}.bg-rose-500\/10{background-color:rgba(244,63,94,.1)}.bg-sky-500\/10{background-color:rgba(14,165,233,.1)}.bg-slate-500\/10{background-color:rgba(100,116,139,.1)}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-slate-900\/60{background-color:rgba(15,23,42,.6)}.bg-slate-900\/80{background-color:rgba(15,23,42,.8)}.bg-slate-950{--tw-bg-opacity:1;background-color:rgb(2 6 23/var(--tw-bg-opacity,1))}.bg-slate-950\/60{background-color:rgba(2,6,23,.6)}.bg-violet-400{--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1))}.bg-violet-500\/10{background-color:rgba(139,92,246,.1)}.bg-violet-500\/40{background-color:rgba(139,92,246,.4)}.p-2{padding:.5rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-6{padding-bottom:1.5rem}.pr-2{padding-right:.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-none{line-height:1}.tracking-wide{letter-spacing:.025em}.text-amber-200\/80{color:hsla(48,97%,77%,.8)}.text-amber-300{--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.text-emerald-400{--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.text-rose-300{--tw-text-opacity:1;color:rgb(253 164 175/var(--tw-text-opacity,1))}.text-rose-400{--tw-text-opacity:1;color:rgb(251 113 133/var(--tw-text-opacity,1))}.text-sky-400{--tw-text-opacity:1;color:rgb(56 189 248/var(--tw-text-opacity,1))}.text-slate-100{--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity,1))}.text-slate-200{--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-violet-300{--tw-text-opacity:1;color:rgb(196 181 253/var(--tw-text-opacity,1))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-40{opacity:.4}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}:root{color-scheme:dark}.hover\:bg-slate-800:hover{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.hover\:bg-slate-800\/60:hover{background-color:rgba(30,41,59,.6)}.hover\:text-slate-100:hover{--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity,1))}.hover\:text-violet-200:hover{--tw-text-opacity:1;color:rgb(221 214 254/var(--tw-text-opacity,1))}.group:hover .group-hover\:bg-violet-400\/70{background-color:rgba(167,139,250,.7)}@media (min-width:640px){.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:px-10{padding-left:2.5rem;padding-right:2.5rem}}
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belay
4
+ # Base controller for everything Belay::Engine mounts. The only thing it does
5
+ # today is gate every request behind Belay.config.authenticate_with - no
6
+ # dashboard controller exists yet (that's the next several commits), but
7
+ # whichever ones land will inherit this and get the same gate for free, rather
8
+ # than each wiring its own before_action.
9
+ class ApplicationController < ActionController::Base
10
+ # ActionController::Base inherits forgery protection from the HOST app's own
11
+ # default_protect_from_forgery config (true since Rails 5.2, via
12
+ # config.load_defaults) - true for any app on a remotely current
13
+ # load_defaults, but not guaranteed: a host app running Rails 7.1 gems while
14
+ # still pinned to an older load_defaults value wouldn't get it for free.
15
+ # Declared explicitly here, rather than assumed, now that the dashboard has
16
+ # its first mutating actions (approve!/reject!) to protect.
17
+ protect_from_forgery with: :exception
18
+
19
+ before_action :authenticate_belay!
20
+
21
+ private
22
+
23
+ # authenticate_with defaulting to nil means "not configured yet," not
24
+ # "allow everyone" - the dashboard shows prompts, responses, costs and
25
+ # approve/reject actions, so serving it to an unauthenticated request by
26
+ # default would be the wrong failure mode for something this sensitive.
27
+ # Raising here is loud and immediate (the same "fail clearly, don't guess"
28
+ # choice ctx.llm makes for a missing provider:, see lib/belay/context.rb) -
29
+ # a host app that mounts the engine and forgets to configure this finds out
30
+ # on its very first request, not by discovering the dashboard was open the
31
+ # whole time.
32
+ #
33
+ # Once configured, the proc is called with the real ActionDispatch::Request
34
+ # (not this controller) - it only needs to answer "is this request allowed,"
35
+ # not act on the response; it returns truthy to allow, anything else denies
36
+ # with a plain 401.
37
+ def authenticate_belay!
38
+ authenticator = Belay.config.authenticate_with
39
+ if authenticator.nil?
40
+ raise Error, "Belay::Engine is mounted but Belay.config.authenticate_with isn't set - configure one, " \
41
+ "e.g. Belay.configure { |c| c.authenticate_with = ->(request) { ... } }, before anyone " \
42
+ "can reach this dashboard"
43
+ end
44
+
45
+ head :unauthorized unless authenticator.call(request)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belay
4
+ class RunsController < ApplicationController
5
+ # No config knob for this - unlike lease_duration/heartbeat_interval/etc.,
6
+ # page size isn't something a workflow author's runtime behavior depends
7
+ # on, just a dashboard rendering choice. 25 keeps a page's table short
8
+ # enough to scan without scrolling on a typical laptop screen.
9
+ PER_PAGE = 25
10
+
11
+ # status: is untrusted query input (anyone who passes authenticate_belay!
12
+ # can still type anything into the URL). where(status: ...) on an
13
+ # unrecognized value doesn't actually raise - unlike direct assignment
14
+ # (run.status = "bogus", which DOES raise ArgumentError), a where clause
15
+ # just matches zero rows, the same as any other string column. Checked
16
+ # against Run.statuses (the enum's own declared mapping) anyway, as a UX
17
+ # choice, not a crash guard: a mistyped or hand-edited ?status= should
18
+ # show every run rather than silently show none.
19
+ #
20
+ # workflow: needs no such check - it's a plain string column, not an enum,
21
+ # so an unrecognized value just matches zero rows via an ordinary
22
+ # parameterized WHERE, the same as it would for a typo'd value on any
23
+ # other plain column.
24
+ #
25
+ # page: is untrusted the same way - clamp_page below never raises on a
26
+ # garbage value (negative, zero, non-numeric, past the last real page),
27
+ # same "friendly over strictly correct" treatment status:/workflow: above
28
+ # already get, for the same reason: a hand-edited or stale bookmarked URL
29
+ # should land somewhere sensible, not error or render an empty table.
30
+ def index
31
+ @workflows = Run.distinct.order(:workflow).pluck(:workflow)
32
+ @statuses = Run.statuses.keys
33
+ @runs = paginate(filtered_runs)
34
+ end
35
+
36
+ # Run.find, not a rescued custom lookup: ActiveRecord::RecordNotFound is
37
+ # already in Rails' own default list of exceptions rendered as a plain 404,
38
+ # unlike a bespoke Belay::Error (see ApplicationController's own comment on
39
+ # authenticate_belay! raising loudly on purpose) - a run id that doesn't
40
+ # exist is an ordinary, expected "nothing there" case, not a misconfiguration
41
+ # worth failing loudly over.
42
+ #
43
+ # Every attempt of every step, not just the latest: a "timeline" is a record
44
+ # of what happened, including retries, same reasoning DESIGN_DECISIONS #7
45
+ # ("gates are steps") gives for checkpointing every outcome rather than only
46
+ # the final one.
47
+ def show
48
+ @run = Run.find(params[:id])
49
+ @steps = @run.steps.order(:position, :attempt)
50
+ end
51
+
52
+ # replay_diff itself raises a plain Belay::Error (not a 404) for a run
53
+ # that isn't a replay at all - caught here the same way approve!/reject!/
54
+ # resolve_needs_review!'s own errors are, rather than letting a stray
55
+ # /belay/runs/:id/diff on an ordinary run 500.
56
+ def diff
57
+ @run = Run.find(params[:id])
58
+ @diff = @run.replay_diff
59
+ rescue Error => e
60
+ redirect_with(alert: e.message)
61
+ end
62
+
63
+ # by: is a plain form field, not derived from the authenticated request -
64
+ # authenticate_with only ever answers "is this request allowed," not "who
65
+ # is making it" (see ApplicationController's own comment on that), so this
66
+ # gem has no identity concept of its own to read an actor from. Required
67
+ # non-blank here, not at the model/migration level (Run#approve!/reject!
68
+ # and the belay_approvals.decided_by column both happily accept nil) - a
69
+ # recorded decision with no actor defeats the point of the audit trail
70
+ # this dashboard exists to show, but that's a dashboard-level policy
71
+ # choice, not something every caller of approve!/reject! should be forced
72
+ # into (a script calling them directly might reasonably use a fixed
73
+ # system identity instead).
74
+ def approve
75
+ @run = Run.find(params[:id])
76
+ return redirect_with(alert: "who is approving this?") if params[:by].blank?
77
+
78
+ @run.approve!(by: params[:by], edited_payload: parsed_edited_payload)
79
+ redirect_with(notice: "Approved.")
80
+ rescue Error => e
81
+ redirect_with(alert: e.message)
82
+ rescue JSON::ParserError
83
+ redirect_with(alert: "edited payload must be valid JSON")
84
+ end
85
+
86
+ def reject
87
+ @run = Run.find(params[:id])
88
+ return redirect_with(alert: "who is rejecting this?") if params[:by].blank?
89
+
90
+ @run.reject!(by: params[:by])
91
+ redirect_with(notice: "Rejected.")
92
+ rescue Error => e
93
+ redirect_with(alert: e.message)
94
+ end
95
+
96
+ # outcome: is form input (two separate forms on the run page, one per
97
+ # outcome, each with a hidden field set to its own value), checked against
98
+ # Run::RESOLUTIONS the same way approve/reject check for a pending gate
99
+ # first - resolve_needs_review! itself also raises ArgumentError for an
100
+ # unrecognized outcome, but checking it here first keeps that message a
101
+ # flash alert instead of a raw 500 for a tampered/malformed request.
102
+ def resolve # rubocop:disable Metrics/AbcSize
103
+ @run = Run.find(params[:id])
104
+ invalid = invalid_resolve_params
105
+ return redirect_with(alert: invalid) if invalid
106
+
107
+ @run.resolve_needs_review!(by: params[:by], outcome: params[:outcome], output: parsed_output)
108
+ redirect_with(notice: "Resolved as #{params[:outcome]}.")
109
+ rescue Error => e
110
+ redirect_with(alert: e.message)
111
+ rescue JSON::ParserError
112
+ redirect_with(alert: "output must be valid JSON")
113
+ end
114
+
115
+ private
116
+
117
+ def invalid_resolve_params
118
+ return "who is resolving this?" if params[:by].blank?
119
+ return "outcome must be succeeded or failed" unless Run::RESOLUTIONS.include?(params[:outcome])
120
+
121
+ nil
122
+ end
123
+
124
+ def redirect_with(notice: nil, alert: nil)
125
+ redirect_to run_path(@run), notice: notice, alert: alert
126
+ end
127
+
128
+ # nil (not {}) when the field was left blank - approve!'s own edited_payload:
129
+ # nil default means "nothing edited," distinct from an explicitly-submitted
130
+ # empty object.
131
+ def parsed_edited_payload
132
+ raw = params[:edited_payload].presence
133
+ raw && JSON.parse(raw)
134
+ end
135
+
136
+ # Only parsed for outcome: "succeeded" - resolve_failed! ignores output:
137
+ # entirely, so a stray/malformed output: on an outcome: "failed" request
138
+ # shouldn't reject it over something that was never going to be used.
139
+ def parsed_output
140
+ return nil unless params[:outcome] == "succeeded"
141
+
142
+ raw = params[:output].presence
143
+ raw && JSON.parse(raw)
144
+ end
145
+
146
+ def filtered_runs
147
+ runs = Run.order(created_at: :desc)
148
+ runs = runs.where(status: params[:status]) if Run.statuses.key?(params[:status])
149
+ runs = runs.where(workflow: params[:workflow]) if params[:workflow].present?
150
+ runs
151
+ end
152
+
153
+ # @total_pages and @page are both set here (not in #index itself) - they're
154
+ # pagination's own bookkeeping, not index's, and splitting them out of
155
+ # #index keeps that action's own ABC size down besides.
156
+ def paginate(scope)
157
+ @total_pages = [(scope.count / PER_PAGE.to_f).ceil, 1].max
158
+ @page = clamp_page(params[:page])
159
+ scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE)
160
+ end
161
+
162
+ def clamp_page(raw)
163
+ raw.to_i.clamp(1, @total_pages)
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belay
4
+ # One page, one metric: how many REAL provider calls (llm_call steps where
5
+ # cache_hit: false - a hit made no call at all) this install is making per
6
+ # 100 runs, by day. Exists to make Phase 4's cost-reduction features
7
+ # (caching, rules) visible as a trend, not just as code nobody can see the
8
+ # effect of.
9
+ class StatsController < ApplicationController
10
+ def index
11
+ @daily_stats = Belay::DailyStats.for_range
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belay
4
+ module ApplicationHelper
5
+ # One canonical place deciding what a status looks like, rather than each
6
+ # view picking its own color per status - a Run has six possible statuses,
7
+ # a Step has six different ones, and they overlap in meaning (e.g. both
8
+ # have a "failed") without sharing a Ruby class to hang a shared method on
9
+ # (Run and Step are unrelated models) - a view helper is the natural shared
10
+ # home instead.
11
+ #
12
+ # Semantic grouping, not one color per literal status value: "succeeded"
13
+ # and "completed" both mean done-and-fine (emerald); "paused",
14
+ # "awaiting_approval" and "needs_review" all mean stopped-and-waiting-on-a-
15
+ # human (amber); "failed" and "cancelled" both mean done-and-not-fine
16
+ # (rose); "running" and "started" both mean in-progress (sky); anything
17
+ # else ("pending", "skipped") is neutral (slate).
18
+ STATUS_COLORS = {
19
+ "succeeded" => :emerald,
20
+ "completed" => :emerald,
21
+ "paused" => :amber,
22
+ "awaiting_approval" => :amber,
23
+ "needs_review" => :amber,
24
+ "failed" => :rose,
25
+ "cancelled" => :rose,
26
+ "running" => :sky,
27
+ "started" => :sky
28
+ }.freeze
29
+
30
+ # Tinted-on-dark, not the light "-100 bg / -800 text" pairing a white page
31
+ # would use (Phase 5's dark-mode redesign) - a translucent background at
32
+ # the same lightness step as the text color keeps each badge legible
33
+ # against the dark card surface it sits on, rather than becoming a solid
34
+ # light rectangle that'd clash with everything around it.
35
+ BADGE_CLASSES = {
36
+ emerald: "bg-emerald-500/10 text-emerald-400",
37
+ amber: "bg-amber-500/10 text-amber-400",
38
+ rose: "bg-rose-500/10 text-rose-400",
39
+ sky: "bg-sky-500/10 text-sky-400",
40
+ slate: "bg-slate-500/10 text-slate-400"
41
+ }.freeze
42
+
43
+ def status_badge(status)
44
+ color = STATUS_COLORS.fetch(status, :slate)
45
+ content_tag(:span, status, class: "status-badge inline-flex items-center rounded-full px-2.5 py-0.5 " \
46
+ "text-xs font-medium #{BADGE_CLASSES.fetch(color)}")
47
+ end
48
+
49
+ # The runs index hands its own current path (filters and all) to each run
50
+ # link as return_to:, so a run's "back to runs" link restores the exact
51
+ # filtered list a human came from, not always the unfiltered one.
52
+ # return_to: is otherwise untrusted query input - anyone can hand-edit a
53
+ # URL - so only a value that's actually runs_path itself, or runs_path
54
+ # with a query string, is honored; anything else (an absolute URL to
55
+ # another host, a lookalike path) falls back to the plain runs index
56
+ # instead of becoming an open redirect a human could be tricked into
57
+ # clicking.
58
+ # The runs index's own pagination links need every OTHER current query
59
+ # param (status:/workflow:) preserved while only page: changes - plain
60
+ # symbolize_keys, not a fresh hash, so an existing ?page= in
61
+ # request.query_parameters (String-keyed) is actually replaced by the
62
+ # Symbol-keyed page: below rather than both ending up in the merged hash
63
+ # as two different keys pointing at the same query param.
64
+ def pagination_params(request, page)
65
+ request.query_parameters.symbolize_keys.merge(page: page)
66
+ end
67
+
68
+ def back_to_runs_path(request)
69
+ candidate = request.params[:return_to]
70
+ base = runs_path
71
+ return base unless candidate.is_a?(String) && (candidate == base || candidate.start_with?("#{base}?"))
72
+
73
+ candidate
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,20 @@
1
+ <div class="card mb-6 p-6">
2
+ <div class="mb-4 flex items-start justify-between gap-4">
3
+ <div>
4
+ <h1 class="text-lg font-semibold text-slate-100"><%= @run.workflow %></h1>
5
+ <p class="mt-1 font-mono text-xs text-slate-500">version <%= @run.workflow_version %> &middot; <%= @run.id %></p>
6
+ </div>
7
+ <span class="run-detail-status"><%= status_badge(@run.status) %></span>
8
+ </div>
9
+
10
+ <dl class="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
11
+ <div>
12
+ <dt class="text-xs uppercase tracking-wide text-slate-500">Started</dt>
13
+ <dd class="mt-0.5 font-mono text-xs text-slate-300"><%= @run.started_at || "–" %></dd>
14
+ </div>
15
+ <div>
16
+ <dt class="text-xs uppercase tracking-wide text-slate-500">Finished</dt>
17
+ <dd class="mt-0.5 font-mono text-xs text-slate-300"><%= @run.finished_at || "–" %></dd>
18
+ </div>
19
+ </dl>
20
+ </div>
@@ -0,0 +1,25 @@
1
+ <div class="mb-6 rounded-lg border border-amber-500/25 bg-amber-500/5 p-6">
2
+ <h2 class="mb-4 text-sm font-semibold text-amber-300">Pending gate: <%= @run.pending_gate.name %></h2>
3
+
4
+ <div class="flex flex-wrap gap-6">
5
+ <%= form_with url: approve_run_path(@run), method: :post, local: true, class: "min-w-60 flex-1 space-y-2" do |f| %>
6
+ <div>
7
+ <%= f.label :by, "Approved by", class: "field-label" %>
8
+ <%= f.text_field :by, class: "input-field" %>
9
+ </div>
10
+ <div>
11
+ <%= f.label :edited_payload, "Edited payload (optional JSON)", class: "field-label" %>
12
+ <%= f.text_area :edited_payload, class: "textarea-field", rows: 3 %>
13
+ </div>
14
+ <%= f.submit "Approve", class: "btn-primary" %>
15
+ <% end %>
16
+
17
+ <%= form_with url: reject_run_path(@run), method: :post, local: true, class: "min-w-60 flex-1 space-y-2" do |f| %>
18
+ <div>
19
+ <%= f.label :by, "Rejected by", class: "field-label" %>
20
+ <%= f.text_field :by, class: "input-field" %>
21
+ </div>
22
+ <%= f.submit "Reject", class: "btn-danger" %>
23
+ <% end %>
24
+ </div>
25
+ </div>
@@ -0,0 +1,30 @@
1
+ <div class="mb-6 rounded-lg border border-amber-500/25 bg-amber-500/5 p-6">
2
+ <h2 class="mb-1 text-sm font-semibold text-amber-300">
3
+ Needs review: <%= @run.pending_needs_review.name %>
4
+ </h2>
5
+ <p class="mb-4 text-sm text-amber-200/80"><%= @run.pending_needs_review.error&.dig("reason") %></p>
6
+
7
+ <div class="flex flex-wrap gap-6">
8
+ <%= form_with url: resolve_run_path(@run), method: :post, local: true, class: "min-w-60 flex-1 space-y-2" do |f| %>
9
+ <%= f.hidden_field :outcome, value: "succeeded" %>
10
+ <div>
11
+ <%= f.label :by, "Resolved by", class: "field-label" %>
12
+ <%= f.text_field :by, class: "input-field" %>
13
+ </div>
14
+ <div>
15
+ <%= f.label :output, "Output (optional JSON) - the step's own block never returned one", class: "field-label" %>
16
+ <%= f.text_area :output, class: "textarea-field", rows: 3 %>
17
+ </div>
18
+ <%= f.submit "Mark succeeded", class: "btn-primary" %>
19
+ <% end %>
20
+
21
+ <%= form_with url: resolve_run_path(@run), method: :post, local: true, class: "min-w-60 flex-1 space-y-2" do |f| %>
22
+ <%= f.hidden_field :outcome, value: "failed" %>
23
+ <div>
24
+ <%= f.label :by, "Resolved by", class: "field-label" %>
25
+ <%= f.text_field :by, class: "input-field" %>
26
+ </div>
27
+ <%= f.submit "Mark failed", class: "btn-danger" %>
28
+ <% end %>
29
+ </div>
30
+ </div>
@@ -0,0 +1,7 @@
1
+ <div class="card mb-6 p-4 text-sm text-slate-400">
2
+ Replayed from <%= link_to @run.replay_of_id, run_path(@run.replay_of), class: "font-mono text-violet-300 hover:text-violet-200" %>
3
+ <% if @run.replay_model %>
4
+ using <code class="rounded bg-slate-800 px-1 py-0.5 font-mono text-xs text-slate-300"><%= @run.replay_model %></code>
5
+ <% end %>
6
+ &middot; <%= link_to "view diff", diff_run_path(@run), class: "text-violet-300 hover:text-violet-200" %>
7
+ </div>
@@ -0,0 +1,14 @@
1
+ <div class="card mb-6 p-4">
2
+ <h2 class="mb-2 text-sm font-semibold text-slate-100">Replays</h2>
3
+ <ul class="space-y-1 text-sm text-slate-400">
4
+ <% @run.replays.each do |replay| %>
5
+ <li>
6
+ <%= link_to replay.id, run_path(replay), class: "font-mono text-violet-300 hover:text-violet-200" %>
7
+ <% if replay.replay_model %>
8
+ (<code class="rounded bg-slate-800 px-1 py-0.5 font-mono text-xs text-slate-300"><%= replay.replay_model %></code>)
9
+ <% end %>
10
+ &middot; <%= link_to "view diff", diff_run_path(replay), class: "text-violet-300 hover:text-violet-200" %>
11
+ </li>
12
+ <% end %>
13
+ </ul>
14
+ </div>