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
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belay
4
+ class Configuration
5
+ # Seconds a worker holds a run before the janitor may reclaim it. See
6
+ # DESIGN_DECISIONS.md #12 for why 300/30.
7
+ attr_accessor :lease_duration
8
+
9
+ # Seconds between heartbeats while a worker has a step in flight.
10
+ attr_accessor :heartbeat_interval
11
+
12
+ # The Belay::Provider ctx.llm calls. nil by default - a workflow with no
13
+ # llm-calling steps never touches this, but a step that calls ctx.llm without
14
+ # one configured gets a clear error instead of a confusing NoMethodError on nil.
15
+ attr_accessor :provider
16
+
17
+ # Gates who can load Belay::Engine's mounted dashboard: a ->(request) { boolean }
18
+ # proc, called on every request. nil by default - deliberately NOT "open to
19
+ # anyone" by default, since the dashboard shows prompts, responses, costs and
20
+ # approve/reject actions. Belay::ApplicationController raises a clear error if
21
+ # this is still nil when a request actually arrives, rather than quietly
22
+ # serving it unauthenticated. See lib/belay/application_controller.rb.
23
+ attr_accessor :authenticate_with
24
+
25
+ # How long a ctx.llm(..., cacheable: true) entry stays valid, in seconds.
26
+ # nil means never expires. One global setting, not a per-call override -
27
+ # cacheable: is already the one knob a step author needs; a second
28
+ # ttl: keyword on every ctx.llm call would be more API surface for a
29
+ # distinction most workflows don't need to make per-step.
30
+ attr_accessor :cache_ttl
31
+
32
+ # How many consistent, unedited approve!s of the exact same gate occurrence
33
+ # (same workflow_version, gate_name, and input/outputs - see Belay::Rule)
34
+ # it takes before that occurrence is learned as a rule. One global setting,
35
+ # same reasoning as cache_ttl above - a per-gate override is more API
36
+ # surface than any workflow has asked for yet. Does not by itself make
37
+ # anything auto-approve; see feat(rules): auto-settle a gate when a
38
+ # learned rule matches, before ever pausing.
39
+ attr_accessor :rule_approval_threshold
40
+
41
+ # Extra CSS appended, in the layout's own <style> tag, right after the
42
+ # dashboard's compiled Tailwind build - not a replacement for it. Appended
43
+ # rather than merged means a same-specificity rule (redeclaring .btn-primary,
44
+ # say) wins by ordinary cascade order, no !important required; nil by
45
+ # default renders nothing extra. A plain String, not a path or an asset -
46
+ # this gem has no asset pipeline opinion to lean on (see Engine.stylesheet's
47
+ # own comment), so the host app owns wherever this string actually comes from.
48
+ attr_accessor :custom_css
49
+
50
+ def initialize
51
+ @lease_duration = 300
52
+ @heartbeat_interval = 30
53
+ @provider = nil
54
+ @authenticate_with = nil
55
+ @cache_ttl = 7 * 24 * 60 * 60 # 7 days
56
+ @rule_approval_threshold = 3
57
+ @custom_css = nil
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json_schemer"
4
+
5
+ module Belay
6
+ # Passed as `ctx` to a step block (see README's Target API). Exposes the run's input
7
+ # and prior steps' outputs as dotted method calls - ctx.invoice_id, ctx.extract - built
8
+ # from two plain hashes, with no coupling to a specific Belay::Workflow class. Also
9
+ # exposes step_idempotency_key: a plain reader, not a method_missing-served dynamic
10
+ # key, since it's one fixed value for whichever step this Context was built for, not
11
+ # a named entry in input or outputs. Belay::Executor is the only thing that passes a
12
+ # real one (run_id + position, DESIGN_DECISIONS #3); it defaults to nil so Context
13
+ # stays constructible standalone, as every existing spec already does.
14
+ #
15
+ # The freeze below is shallow: @input/@outputs are frozen hashes, but a value inside
16
+ # them (e.g. ctx.extract returning a Hash) is not. If the executor ever reuses one
17
+ # Context, or shares an output value, across more than one step, a step mutating
18
+ # its own ctx.extract in place could corrupt what a later step reads. Not guarded
19
+ # here - the executor's construction pattern (one Context per step? shared?) isn't
20
+ # decided yet, and deep-freezing arbitrary step output is real added cost to pay for
21
+ # a risk that depends on that undecided design.
22
+ class Context
23
+ # Raised when schema: was given to ctx.llm but the provider's response doesn't
24
+ # match it. errors is JSONSchemer's own list of validation error hashes, not a
25
+ # single summarized string, so a caller can inspect exactly what failed.
26
+ class SchemaValidationError < Error
27
+ attr_reader :errors
28
+
29
+ def initialize(errors:, content:, schema:)
30
+ @errors = errors
31
+ messages = errors.map { |e| e["error"] }.join("; ")
32
+ super("ctx.llm's response did not match schema: #{messages} (content: #{content.inspect}, " \
33
+ "schema: #{schema.inspect})")
34
+ end
35
+ end
36
+
37
+ attr_reader :input, :outputs, :step_idempotency_key
38
+
39
+ # model_override: is Run#replay(model:)'s whole mechanism - nil for every
40
+ # ordinary run (the common case: #llm just uses whatever model: the step's
41
+ # own block passed). Executor::StepRunner#build_context is the only real
42
+ # caller that ever passes one, reading it off @run.replay_model.
43
+ def initialize(input: {}, outputs: {}, step_idempotency_key: nil, model_override: nil)
44
+ @input = symbolize_without_collision!(input, "input")
45
+ @outputs = symbolize_without_collision!(outputs, "prior step output")
46
+ @step_idempotency_key = step_idempotency_key
47
+ @model_override = model_override
48
+ # Mutated in place by #llm below, not reassigned - this object freezes itself
49
+ # at the end of #initialize, which would make `@llm_metadata = ...` raise
50
+ # FrozenError from inside #llm. Mutating the Hash this ivar already points to
51
+ # is a different, allowed thing: freezing Context doesn't freeze objects it
52
+ # merely references.
53
+ @llm_metadata = {}
54
+ validate_no_name_collisions!
55
+ freeze
56
+ end
57
+
58
+ # Calls Belay.config.provider, validates the response against schema: (if given)
59
+ # with JSONSchemer, and returns response.content - a step block's return value
60
+ # becomes its checkpointed output, so #llm's return value IS the step's output,
61
+ # not a wrapper around it.
62
+ #
63
+ # Validated here, provider-agnostically, rather than inside each Provider:
64
+ # AnthropicProvider's own schema: already asks Anthropic's API to constrain its
65
+ # output, but FakeProvider enforces nothing at all - without this, a spec's
66
+ # scripted response could silently not match the schema: it claims to, and
67
+ # nothing would catch that until production. This is the one place that's true
68
+ # for every provider.
69
+ #
70
+ # schema: must use string keys for its own keywords (symbol-keyed schemas are
71
+ # deep-stringified below first) and set additionalProperties: false on every
72
+ # object, per Anthropic's own requirement for the same schema when a provider
73
+ # like AnthropicProvider forwards it as output_config.format.schema.
74
+ # model: passed here is a default, not the final word - model_override
75
+ # (set only on a replay's shadow run) takes precedence, so a replayed
76
+ # llm_call step actually calls the overriding model without the workflow
77
+ # body itself needing to know anything about replay.
78
+ #
79
+ # cacheable: false by default - opt-in, exact-match caching (DESIGN_
80
+ # DECISIONS #10, Belay::CacheEntry). Deliberately not inferred from
81
+ # kind: or anything else about the step: "decision steps only" can't be
82
+ # enforced mechanically (kind: "llm_call" covers both a classification
83
+ # AND an extraction call; Belay has no way to tell them apart), so this
84
+ # is a plain, explicit choice the step author makes, the same contract
85
+ # idempotent: true already uses elsewhere - only set this on a step
86
+ # whose ENTIRE prompt text fully and uniquely determines the correct
87
+ # answer. A short, self-contained classification prompt usually
88
+ # qualifies; an extraction prompt that references a large document
89
+ # (especially over a vision/file attachment, whose bytes aren't part of
90
+ # what gets hashed) usually doesn't - two different documents could
91
+ # share a cache key and get back the wrong answer.
92
+ def llm(model:, prompt:, schema: nil, cacheable: false)
93
+ effective_model = @model_override || model
94
+ provider = resolve_provider!
95
+ call_args = { provider: provider, model: effective_model, prompt: prompt, schema: schema }
96
+
97
+ cached = fetch_cached_llm_response(**call_args) if cacheable
98
+ return cached unless cached.nil?
99
+
100
+ response = provider.call(model: effective_model, prompt: prompt, schema: schema)
101
+ record_llm_metadata!(provider: provider, prompt: prompt, response: response)
102
+ validate_against_schema!(response.content, schema) if schema
103
+ store_cached_llm_response!(**call_args, content: response.content) if cacheable
104
+ response.content
105
+ end
106
+
107
+ # {} if #llm was never called this step - nothing to persist onto the step row,
108
+ # same as any step that made no provider call. Frozen dup, same reasoning as
109
+ # Workflow#steps/#inputs and FakeProvider#calls: a caller reaching in and
110
+ # mutating this would corrupt what StepRunner later reads to update the row.
111
+ def llm_metadata
112
+ @llm_metadata.dup.freeze
113
+ end
114
+
115
+ def method_missing(name, *args)
116
+ return super unless args.empty? && !block_given?
117
+
118
+ if @input.key?(name)
119
+ @input[name]
120
+ elsif @outputs.key?(name)
121
+ @outputs[name]
122
+ else
123
+ super
124
+ end
125
+ end
126
+
127
+ def respond_to_missing?(name, include_private = false)
128
+ @input.key?(name) || @outputs.key?(name) || super
129
+ end
130
+
131
+ private
132
+
133
+ def resolve_provider!
134
+ Belay.config.provider || raise(Error, "Belay.config.provider is not set - configure one before calling " \
135
+ "ctx.llm, e.g. Belay.configure { |c| c.provider = " \
136
+ "Belay::AnthropicProvider.new }")
137
+ end
138
+
139
+ # nil on a cache miss (or an expired entry) - the caller falls through to
140
+ # a real provider call either way, same as CacheEntry.fetch's own nil
141
+ # means to its two callers. Assumes a cached response's content is never
142
+ # itself nil, which ProviderResponse's own validation already guarantees
143
+ # (content is required) - there's no cacheable response this could ever
144
+ # mistake for a miss.
145
+ #
146
+ # input_tokens/output_tokens/cost_cents/latency_ms are 0, not nil, for a
147
+ # hit - nil already means something specific elsewhere (e.g. cost_cents
148
+ # is nil when no pricing: was configured for a REAL call - "we don't
149
+ # know"), and a hit's true cost really is zero, not unknown.
150
+ def fetch_cached_llm_response(provider:, model:, prompt:, schema:)
151
+ content = CacheEntry.fetch(provider: provider, model: model, prompt: prompt, schema: schema)
152
+ return nil if content.nil?
153
+
154
+ @llm_metadata.merge!(provider: provider.class.name, input: prompt, model: model, cache_hit: true,
155
+ input_tokens: 0, output_tokens: 0, cost_cents: 0, latency_ms: 0)
156
+ content
157
+ end
158
+
159
+ # Called only after validate_against_schema! has already passed (see #llm) -
160
+ # a response that fails validation never reaches here, so no cacheable: true
161
+ # key is ever stuck returning a bad answer forever.
162
+ def store_cached_llm_response!(provider:, model:, prompt:, schema:, content:)
163
+ CacheEntry.store!(provider: provider, model: model, prompt: prompt, schema: schema, content: content)
164
+ end
165
+
166
+ # Recorded before validating, not after: provider.call above already made a real,
167
+ # billed request by the time we know whether its content matches schema:. A response
168
+ # that fails validation raises below and this step's row ends up "failed" -
169
+ # run_attempt's dual-merge (see executor.rb) persists whatever's in @llm_metadata onto
170
+ # that failed row regardless, which only works if the merge already happened before
171
+ # the raise, not after it.
172
+ #
173
+ # input: prompt, not :prompt - matches belay_steps' own column name exactly (same
174
+ # reasoning as every other key here: extra_step_attrs below merges this whole hash
175
+ # straight into step.update!, with no translation step in between), so the dashboard's
176
+ # run page has something real to show as "prompt" - the exact value passed to
177
+ # ctx.llm, not a summary of it.
178
+ def record_llm_metadata!(provider:, prompt:, response:)
179
+ fields = response.to_h.slice(:model, :input_tokens, :output_tokens, :cost_cents, :latency_ms)
180
+ @llm_metadata.merge!(provider: provider.class.name, input: prompt, cache_hit: false, **fields)
181
+ end
182
+
183
+ def validate_against_schema!(content, schema)
184
+ document = json_normalize(content)
185
+ schemer = JSONSchemer.schema(json_normalize(schema))
186
+ return if schemer.valid?(document)
187
+
188
+ raise SchemaValidationError.new(errors: schemer.validate(document).to_a, content: content, schema: schema)
189
+ end
190
+
191
+ # Hash#deep_stringify_keys only converts hash keys, not a bare Symbol used as a
192
+ # VALUE - required: [:total_cents] (a real, common JSON Schema shape, not an
193
+ # edge case) comes through untouched, so "total_cents" (the stringified
194
+ # document's actual key) never matches :total_cents. JSON itself has no symbol
195
+ # type; normalizing every symbol anywhere in the structure, key or value, is
196
+ # what makes a Ruby-idiomatic symbol-keyed/valued schema or FakeProvider script
197
+ # behave the same as what the real API actually returns (plain JSON.parse'd
198
+ # strings throughout).
199
+ def json_normalize(value)
200
+ case value
201
+ when Hash
202
+ value.to_h { |k, v| [k.to_s, json_normalize(v)] }
203
+ when Array
204
+ value.map { |v| json_normalize(v) }
205
+ when Symbol
206
+ value.to_s
207
+ else
208
+ value
209
+ end
210
+ end
211
+
212
+ def validate_no_name_collisions!
213
+ name_overlap = @input.keys & @outputs.keys
214
+ unless name_overlap.empty?
215
+ raise ArgumentError, "input and prior step output names collide: #{name_overlap.inspect} - rename one"
216
+ end
217
+
218
+ # self.class.method_defined?, not respond_to?: respond_to? would consult
219
+ # respond_to_missing? above, which checks @input/@outputs themselves - every key
220
+ # would "collide" with its own dynamic accessor. method_defined? only sees real,
221
+ # statically-defined methods: #input/#outputs (attr_reader) and inherited
222
+ # Object/Kernel ones (class, hash, freeze, send, ...) - not method_missing-served
223
+ # ones. A key literally named "input" would otherwise shadow the reader above
224
+ # with no error.
225
+ reserved = (@input.keys + @outputs.keys).select { |key| self.class.method_defined?(key) }
226
+ return if reserved.empty?
227
+
228
+ raise ArgumentError,
229
+ "input/output name(s) #{reserved.inspect} collide with an existing method on #{self.class} - rename them"
230
+ end
231
+
232
+ def symbolize_without_collision!(hash, label)
233
+ symbolized = hash.transform_keys(&:to_sym)
234
+ if symbolized.size != hash.size
235
+ raise ArgumentError, "two #{label} keys collapse to the same name once symbolized: #{hash.keys.inspect}"
236
+ end
237
+
238
+ symbolized.freeze
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belay
4
+ # How many REAL provider calls (an llm_call step where cache_hit: false - a
5
+ # hit or a rule-auto-settled gate made no call at all) this install made per
6
+ # 100 runs, by day. Backs the dashboard's Stats page - plain Ruby, not an
7
+ # ActiveRecord model of its own: there's no belay_daily_stats table, this is
8
+ # a read-only view over belay_runs/belay_steps' own created_at timestamps.
9
+ class DailyStats
10
+ Row = Struct.new(:date, :runs, :real_calls, :calls_per_100_runs, keyword_init: true)
11
+
12
+ # Calls are grouped by the STEP's own day, not the owning run's - a run
13
+ # can pause on a gate for a real, unbounded amount of time (nothing times
14
+ # a pending approval out; Phase 4's own rule-learning needs N separate,
15
+ # time-separated approvals to even happen), so a run created Jan 1 whose
16
+ # gate isn't approved until Jan 15 makes its post-gate steps' real calls
17
+ # on Jan 15, not Jan 1. Grouping by the run's day instead would silently
18
+ # mutate Jan 1's historical count every time an old run resumes - exactly
19
+ # backwards for a chart whose whole purpose is "is this week's cost down,"
20
+ # and would make a past day's bar change on a later page load. Runs are
21
+ # still grouped by their OWN created_at below - "how many runs started
22
+ # that day" is a legitimately different question from "how many real
23
+ # calls happened that day," and conflating them was the bug this comment
24
+ # replaced.
25
+ def self.for_range
26
+ calls_by_day = calls_by_day_counts
27
+ runs_by_day = runs_by_day_counts
28
+
29
+ days = (calls_by_day.keys + runs_by_day.keys).uniq.sort
30
+ days.map { |date| build_row(date, runs_by_day.fetch(date, 0), calls_by_day.fetch(date, 0)) }
31
+ end
32
+
33
+ def self.calls_by_day_counts
34
+ normalized_counts(Step.llm_call.where(cache_hit: false).group("DATE(created_at)").count)
35
+ end
36
+ private_class_method :calls_by_day_counts
37
+
38
+ def self.runs_by_day_counts
39
+ normalized_counts(Run.group("DATE(created_at)").count)
40
+ end
41
+ private_class_method :runs_by_day_counts
42
+
43
+ # .group("DATE(...)").count's hash keys come back as plain Strings on some
44
+ # adapter/Rails version combinations, real Date objects on others - a raw
45
+ # SQL group-by fragment (not an actual typed column) gives ActiveRecord
46
+ # nothing to infer the cast from. Normalized here, once, so every lookup
47
+ # and every Row's date below is reliably a Date, regardless of which
48
+ # shape the DB adapter actually returned.
49
+ def self.normalized_counts(counts_by_raw_date)
50
+ counts_by_raw_date.transform_keys { |key| key.is_a?(Date) ? key : Date.parse(key.to_s) }
51
+ end
52
+ private_class_method :normalized_counts
53
+
54
+ def self.build_row(date, runs, calls)
55
+ Row.new(date: date, runs: runs, real_calls: calls,
56
+ calls_per_100_runs: runs.zero? ? 0.0 : (calls.to_f / runs * 100).round(1))
57
+ end
58
+ private_class_method :build_row
59
+ end
60
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module Belay
6
+ # Makes Belay mountable in a host app: mount Belay::Engine => "/belay" in
7
+ # config/routes.rb. isolate_namespace keeps every route helper, controller and
8
+ # view path under the Belay:: namespace, so a host app's own RunsController or
9
+ # route named "runs" never collides with this gem's. isolate_namespace also
10
+ # gives the engine its own Zeitwerk autoloader for app/controllers and
11
+ # app/views - unlike every plain Ruby class under lib/belay/ (Run, Step,
12
+ # Approval, ...), which is deliberately require_relative'd from lib/belay.rb
13
+ # instead, with no Rails autoloading convention to lean on (see those files'
14
+ # own comments on this). Controllers are different: Rails engines are BUILT to
15
+ # autoload app/controllers this way, so this is the one place in the gem that
16
+ # actually uses it, via the conventional app/controllers + app/views +
17
+ # config/routes.rb directory layout at the gem root (not under lib/ -
18
+ # belay.gemspec's spec.files lists app/**/* and config/routes.rb alongside
19
+ # lib/**/* for exactly this).
20
+ class Engine < ::Rails::Engine
21
+ isolate_namespace Belay
22
+
23
+ # The dashboard's CSS, inlined into the layout via <style> rather than
24
+ # served through a <link> and whichever asset pipeline (Sprockets,
25
+ # Propshaft, none at all) the host app happens to have configured - that's
26
+ # exactly the kind of host-app assumption Belay has never made anywhere
27
+ # else (no Zeitwerk assumption, explicit class_name: throughout, routes
28
+ # drawn from the engine's own config/routes.rb). app/assets/belay/
29
+ # application.css is a committed, precompiled Tailwind build (see
30
+ # package.json's build:css) - there is no runtime build step, and this
31
+ # gem's own end users never run npm.
32
+ #
33
+ # Memoized at the class level (not per-request, unlike an instance
34
+ # variable in a helper) - the file never changes while the process is
35
+ # running, so re-reading it on every single request would be pure waste.
36
+ def self.stylesheet
37
+ @stylesheet ||= root.join("app/assets/belay/application.css").read
38
+ end
39
+ end
40
+ end