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
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
|