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
data/lib/belay/rule.rb ADDED
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Belay
6
+ # A learned, auto-approvable gate occurrence - see belay_rules' own migration
7
+ # comment for the identity/status columns. The only two write entry points are
8
+ # .record_approval! and .record_rejection!, called from Run#approve!/reject!
9
+ # below; nothing else ever changes a rule's status.
10
+ class Rule < ApplicationRecord
11
+ self.table_name = "belay_rules"
12
+
13
+ enum :status, {
14
+ learning: "learning",
15
+ active: "active",
16
+ invalidated: "invalidated"
17
+ }
18
+
19
+ validates :workflow, :workflow_version, :gate_name, :context_key, :status, presence: true
20
+
21
+ # SHA256 of [input, outputs, a fingerprint of condition].inspect - the exact
22
+ # input/outputs a gate's when: was evaluated against, AND which when: actually
23
+ # evaluated them. Computed once, at evaluation time (Executor#evaluate_gate!),
24
+ # from the live Context's own symbol-keyed Hashes - never re-derived later from
25
+ # a round-tripped jsonb Hash (string-keyed), which would silently stop matching
26
+ # anything. Same exact-match-hash approach as Belay::CacheEntry.key_for, same
27
+ # reasoning: deliberately not normalized, and deliberately the FULL input/
28
+ # outputs snapshot rather than just whatever a gate's own when: closure happens
29
+ # to read - a narrower key could auto-settle a future occurrence that looks
30
+ # identical to the closure but differs in some upstream value nobody declared
31
+ # as relevant.
32
+ #
33
+ # condition is included because Workflow.workflow_version is NOT enough to
34
+ # catch an edited when: on its own: workflow_version deliberately hashes only
35
+ # [inputs, steps.map { [name, kind] }] - not step/gate options: (see its own
36
+ # comment) - so rewriting a gate's when: entirely, while keeping its name and
37
+ # the workflow's other steps unchanged, leaves workflow_version identical.
38
+ # Without this, a rule learned under the OLD condition could auto-settle a
39
+ # gate running the NEW one, for any (input, outputs) that happens to recur -
40
+ # silently approving a decision whose logic no human ever actually reviewed.
41
+ # condition.source_location (file + line) is used as that fingerprint rather
42
+ # than condition.inspect - a Proc's default #inspect embeds its object_id
43
+ # (the lambda's memory address), which changes across every process restart
44
+ # even with zero code changes, which would make every rule stop matching
45
+ # after every deploy. source_location only changes when the when: actually
46
+ # moves or is rewritten - not a perfect signal (editing a single-line lambda's
47
+ # body in place without changing which line it starts on isn't caught, and
48
+ # neither is a parameterized factory reused as when: threshold_gate(100) ->
49
+ # threshold_gate(200), since the lambda literal itself never moves), but it
50
+ # closes the realistic case (any edit that adds/removes a line anywhere
51
+ # above it in the file, or rewrites the condition across multiple lines)
52
+ # without the deploy-time false-invalidation problem.
53
+ # Falls back to the condition's class name for a when: that isn't a Proc/
54
+ # Method (Workflow#gate only requires #call, via validate_gate_when!) and so
55
+ # has no #source_location of its own.
56
+ def self.context_key_for(input:, outputs:, condition:)
57
+ fingerprint = condition.respond_to?(:source_location) ? condition.source_location : condition.class.name
58
+ Digest::SHA256.hexdigest([input, outputs, fingerprint].inspect)
59
+ end
60
+
61
+ # Records one more consistent, unedited approve! of this exact gate
62
+ # occurrence. A rule already invalidated never advances and never becomes
63
+ # active again, regardless of how many further approvals arrive for the
64
+ # same context - re-checked AFTER the counter update (not just before),
65
+ # since a concurrent record_rejection! for this same row could invalidate
66
+ # it in the gap between create_or_find_by! and increment! below. That's
67
+ # the one invariant this method guarantees; the counter itself tolerates
68
+ # a little noise (one increment landing on a row a racing reject! is about
69
+ # to invalidate) because invalidation is permanent and always wins.
70
+ def self.record_approval!(workflow:, workflow_version:, gate_name:, context_key:,
71
+ threshold: Belay.config.rule_approval_threshold)
72
+ # create_or_find_by!, not find_or_create_by! - two different RUNS racing to be
73
+ # the FIRST approval for an identical, not-yet-existing context_key is a real
74
+ # scenario here (any two runs with identical input/outputs at this gate
75
+ # collide by design), and find_or_create_by! isn't atomic against that: both
76
+ # could find nothing and both call create!, and the loser would raise
77
+ # ActiveRecord::RecordNotUnique uncaught. create_or_find_by! inserts first and
78
+ # falls back to a find on exactly that violation. Same race CacheEntry.store!
79
+ # (via upsert) and Workflow.find_or_create_run (via an explicit rescue) each
80
+ # already guard against elsewhere in this codebase.
81
+ rule = create_or_find_by!(workflow: workflow, workflow_version: workflow_version, gate_name: gate_name,
82
+ context_key: context_key)
83
+ rule.increment!(:consistent_approvals) unless rule.invalidated?
84
+ rule.reload
85
+ rule.update!(status: "active") if rule.learning? && rule.consistent_approvals >= threshold
86
+ rule
87
+ end
88
+
89
+ # Permanently invalidates the rule for this exact gate occurrence - a SINGLE
90
+ # rejection is enough, with no tolerance for "mostly consistent," and once
91
+ # invalidated a rule can never become active again (record_approval! above
92
+ # refuses to advance an invalidated rule's counter). Creates the row even if
93
+ # this is the very first decision ever made for this context, so a future
94
+ # run of consistent approvals can never learn a rule here from scratch.
95
+ def self.record_rejection!(workflow:, workflow_version:, gate_name:, context_key:)
96
+ # create_or_find_by! - same race as record_approval! above, guarded the same way.
97
+ rule = create_or_find_by!(workflow: workflow, workflow_version: workflow_version, gate_name: gate_name,
98
+ context_key: context_key)
99
+ rule.update!(status: "invalidated", invalidated_at: Time.current)
100
+ rule
101
+ end
102
+
103
+ # nil when there's no rule for this exact gate occurrence yet, or it's still
104
+ # learning, or it's been invalidated - Executor#evaluate_gate! treats all
105
+ # three the same way (pause for a human), so this collapses them into one
106
+ # "nothing to auto-apply" answer rather than returning a status a caller
107
+ # would have to branch on.
108
+ def self.active_for(workflow:, workflow_version:, gate_name:, context_key:)
109
+ find_by(workflow: workflow, workflow_version: workflow_version, gate_name: gate_name,
110
+ context_key: context_key, status: "active")
111
+ end
112
+ end
113
+ end