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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +155 -0
- data/app/assets/belay/application.css +1 -0
- data/app/controllers/belay/application_controller.rb +48 -0
- data/app/controllers/belay/runs_controller.rb +166 -0
- data/app/controllers/belay/stats_controller.rb +14 -0
- data/app/helpers/belay/application_helper.rb +76 -0
- data/app/views/belay/runs/_header.html.erb +20 -0
- data/app/views/belay/runs/_pending_gate.html.erb +25 -0
- data/app/views/belay/runs/_pending_needs_review.html.erb +30 -0
- data/app/views/belay/runs/_replayed_from.html.erb +7 -0
- data/app/views/belay/runs/_replays.html.erb +14 -0
- data/app/views/belay/runs/_step.html.erb +52 -0
- data/app/views/belay/runs/diff.html.erb +47 -0
- data/app/views/belay/runs/index.html.erb +60 -0
- data/app/views/belay/runs/show.html.erb +34 -0
- data/app/views/belay/shared/_flash.html.erb +11 -0
- data/app/views/belay/shared/_sidebar.html.erb +22 -0
- data/app/views/belay/stats/_chart.html.erb +56 -0
- data/app/views/belay/stats/_table.html.erb +22 -0
- data/app/views/belay/stats/index.html.erb +16 -0
- data/app/views/layouts/belay/application.html.erb +19 -0
- data/config/routes.rb +13 -0
- data/lib/belay/anthropic_provider.rb +166 -0
- data/lib/belay/application_record.rb +7 -0
- data/lib/belay/approval.rb +35 -0
- data/lib/belay/cache_entry.rb +65 -0
- data/lib/belay/configuration.rb +60 -0
- data/lib/belay/context.rb +241 -0
- data/lib/belay/daily_stats.rb +60 -0
- data/lib/belay/engine.rb +40 -0
- data/lib/belay/executor.rb +411 -0
- data/lib/belay/fake_provider.rb +41 -0
- data/lib/belay/janitor_job.rb +48 -0
- data/lib/belay/provider.rb +15 -0
- data/lib/belay/provider_response.rb +19 -0
- data/lib/belay/resume_run_job.rb +42 -0
- data/lib/belay/rule.rb +113 -0
- data/lib/belay/run.rb +629 -0
- data/lib/belay/step.rb +57 -0
- data/lib/belay/version.rb +5 -0
- data/lib/belay/workflow.rb +199 -0
- data/lib/belay.rb +49 -0
- data/lib/generators/belay/install/install_generator.rb +48 -0
- data/lib/generators/belay/install/templates/create_belay_approvals.rb.tt +43 -0
- data/lib/generators/belay/install/templates/create_belay_cache_entries.rb.tt +27 -0
- data/lib/generators/belay/install/templates/create_belay_rules.rb.tt +46 -0
- data/lib/generators/belay/install/templates/create_belay_runs.rb.tt +37 -0
- data/lib/generators/belay/install/templates/create_belay_steps.rb.tt +58 -0
- 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
|
+

|
|
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 %> · <%= @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
|
+
· <%= 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
|
+
· <%= link_to "view diff", diff_run_path(replay), class: "text-violet-300 hover:text-violet-200" %>
|
|
11
|
+
</li>
|
|
12
|
+
<% end %>
|
|
13
|
+
</ul>
|
|
14
|
+
</div>
|