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/step.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belay
|
|
4
|
+
class Step < ApplicationRecord
|
|
5
|
+
self.table_name = "belay_steps"
|
|
6
|
+
|
|
7
|
+
# Explicit class_name: this gem has no Zeitwerk/app-autoloading, so association
|
|
8
|
+
# class lookup can't rely on namespace inference the way it would inside a
|
|
9
|
+
# conventionally-autoloaded Rails engine. Explicit optional: false too: Rails only
|
|
10
|
+
# requires belongs_to associations by default when the host app's load_defaults is
|
|
11
|
+
# 7.0+. A library shouldn't depend on that config to get a friendly validation error
|
|
12
|
+
# instead of a raw DB NotNullViolation.
|
|
13
|
+
belongs_to :run, class_name: "Belay::Run", optional: false
|
|
14
|
+
|
|
15
|
+
# has_one, not has_many: belay_approvals.step_id is unique at the DB level -
|
|
16
|
+
# at most one approval per gate step (see its migration's own comment on why,
|
|
17
|
+
# and the forward constraint that assumes).
|
|
18
|
+
has_one :approval, class_name: "Belay::Approval"
|
|
19
|
+
|
|
20
|
+
enum :kind, {
|
|
21
|
+
llm_call: "llm_call",
|
|
22
|
+
tool_call: "tool_call",
|
|
23
|
+
decision: "decision",
|
|
24
|
+
gate: "gate"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
enum :status, {
|
|
28
|
+
started: "started",
|
|
29
|
+
succeeded: "succeeded",
|
|
30
|
+
failed: "failed",
|
|
31
|
+
skipped: "skipped",
|
|
32
|
+
needs_review: "needs_review",
|
|
33
|
+
# Only a gate ever settles here: when: evaluated true, and this step is
|
|
34
|
+
# paused waiting for a human's Run#approve!/reject!. Distinct from
|
|
35
|
+
# "started" (crashed-or-in-flight, resumed via the
|
|
36
|
+
# idempotent?/needs_review policy) and from "needs_review" (a different
|
|
37
|
+
# kind of human intervention, for a non-idempotent step orphaned by a
|
|
38
|
+
# crash) - this one is a clean, intentional pause, not a recovery path.
|
|
39
|
+
awaiting_approval: "awaiting_approval"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
validates :position, presence: true
|
|
43
|
+
validates :name, presence: true
|
|
44
|
+
validates :kind, presence: true
|
|
45
|
+
validates :idempotency_key, presence: true
|
|
46
|
+
|
|
47
|
+
# status has a DB default ("started"), but the enum writer accepts nil as a valid
|
|
48
|
+
# "blank" assignment (no ArgumentError) - without this, an explicit status: nil
|
|
49
|
+
# would skip the default and hit a raw NotNullViolation instead of RecordInvalid.
|
|
50
|
+
validates :status, presence: true
|
|
51
|
+
|
|
52
|
+
# No guarded transitions here, unlike Run: the executor (Phase 1e) owns the
|
|
53
|
+
# started -> succeeded/failed/needs_review policy, including retry rules that
|
|
54
|
+
# depend on an idempotent: declaration this model doesn't have. Adding bang
|
|
55
|
+
# methods here now would mean rebuilding them once that policy exists.
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Belay
|
|
6
|
+
# Base class for workflow definitions (see README's Target API). .input/.step are pure
|
|
7
|
+
# declaration; .start (below) is where a Run actually gets created and executed.
|
|
8
|
+
class Workflow
|
|
9
|
+
StepDefinition = Struct.new(:name, :kind, :options, :block, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def input(*names)
|
|
13
|
+
names.each do |name|
|
|
14
|
+
name = name.to_sym
|
|
15
|
+
raise ArgumentError, "input :#{name} is already declared on #{self}" if raw_inputs.include?(name)
|
|
16
|
+
|
|
17
|
+
raw_inputs << name
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns a frozen copy: mutating the result (push, clear, reassign) can never
|
|
22
|
+
# corrupt what .input actually declared, and can't bypass its duplicate check.
|
|
23
|
+
def inputs
|
|
24
|
+
raw_inputs.dup.freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# kind isn't validated against Belay::Step's kind enum here - that's the AR/
|
|
28
|
+
# persistence layer's job (Step#kind), not this thin declaration layer's. nil
|
|
29
|
+
# (the default, since no Target API example passes it) hashes consistently with
|
|
30
|
+
# itself in workflow_version; inferring a real kind is executor work (Phase 1e+).
|
|
31
|
+
#
|
|
32
|
+
# kind: is reserved by this gem the same way the belay_steps.kind column is - a
|
|
33
|
+
# host app passing an unrelated option literally named kind: would have it
|
|
34
|
+
# silently captured here instead of landing in options. Accepted, not guarded:
|
|
35
|
+
# the name is already Belay-specific throughout this project, not a generic term
|
|
36
|
+
# a workflow author would likely reach for on their own.
|
|
37
|
+
def step(name, kind: nil, **options, &block)
|
|
38
|
+
raise ArgumentError, "step :#{name} needs a block" unless block
|
|
39
|
+
|
|
40
|
+
validate_step_kind!(name, kind)
|
|
41
|
+
name = reserve_name!(name)
|
|
42
|
+
|
|
43
|
+
raw_steps << StepDefinition.new(
|
|
44
|
+
name: name, kind: kind&.to_sym, options: options.dup.freeze, block: block
|
|
45
|
+
).freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# DESIGN_DECISIONS #7: "Gates are steps" - a gate lives in the same ordered
|
|
49
|
+
# raw_steps list as step (so Executor's single each_with_index walk sees it in
|
|
50
|
+
# position, and workflow_version's fingerprint already covers it via
|
|
51
|
+
# steps.map { [name, kind] }, with no special-casing needed there). kind: is
|
|
52
|
+
# always :gate here, not an option the caller can override - unlike step,
|
|
53
|
+
# where kind: is optional metadata, a gate's kind is what makes it one.
|
|
54
|
+
#
|
|
55
|
+
# when: is accepted through **options rather than declared as its own `when:`
|
|
56
|
+
# keyword parameter. Ruby accepts when: as a keyword argument NAME just fine
|
|
57
|
+
# (def gate(name, when:) parses), but referencing it afterward as a bare local
|
|
58
|
+
# variable does not - `when` alone is reserved for case/when, a SyntaxError
|
|
59
|
+
# distinct from the argument's own definition. Reading it back out of options
|
|
60
|
+
# (options[:when]) sidesteps that entirely rather than requiring
|
|
61
|
+
# binding.local_variable_get(:when) everywhere it's used.
|
|
62
|
+
#
|
|
63
|
+
# No block: a gate has nothing to execute - when: decides skip (false) or wait
|
|
64
|
+
# for a human (true). What the executor actually does with kind: :gate is the
|
|
65
|
+
# next commit's job; this one only declares it.
|
|
66
|
+
def gate(name, **options)
|
|
67
|
+
raise ArgumentError, "gate :#{name} must not pass kind: - a gate's kind is always :gate" if options.key?(:kind)
|
|
68
|
+
|
|
69
|
+
condition = options[:when]
|
|
70
|
+
validate_gate_when!(name, condition)
|
|
71
|
+
name = reserve_name!(name)
|
|
72
|
+
|
|
73
|
+
raw_steps << StepDefinition.new(
|
|
74
|
+
name: name, kind: :gate, options: options.dup.freeze, block: nil
|
|
75
|
+
).freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Frozen copy, same reasoning as #inputs. Each StepDefinition is frozen too, so a
|
|
79
|
+
# caller can't reach back in and change a step's block or options after the fact.
|
|
80
|
+
def steps
|
|
81
|
+
raw_steps.dup.freeze
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Hashes structure, not block bodies: Ruby source can't be hashed reliably (a
|
|
85
|
+
# renamed local variable or a reformatted block changes nothing about what the
|
|
86
|
+
# step actually does, but would change its source text). Covers declared inputs,
|
|
87
|
+
# and each step's name and kind in order - deliberately not step options yet
|
|
88
|
+
# (idempotent:/retries: aren't interpreted anywhere yet either; see .step's
|
|
89
|
+
# comment). No explicit position field: Array#inspect is already order-sensitive
|
|
90
|
+
# on its own, so swapping two steps changes the fingerprint without needing one.
|
|
91
|
+
def workflow_version
|
|
92
|
+
fingerprint = [inputs, steps.map { |step| [step.name, step.kind] }]
|
|
93
|
+
Digest::SHA256.hexdigest(fingerprint.inspect)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# README's Target API: InvoiceWorkflow.start(invoice_id: 42, idempotency_key: "inv-42").
|
|
97
|
+
# idempotency_key: dedups at the RUN level (distinct from a step's own
|
|
98
|
+
# step_idempotency_key): calling .start twice with the same key returns the same
|
|
99
|
+
# Run rather than starting a second one, racing on belay_runs' own unique index
|
|
100
|
+
# on idempotency_key rather than a check-then-create in Ruby, which two
|
|
101
|
+
# concurrent callers could both pass.
|
|
102
|
+
#
|
|
103
|
+
# The dedup lookup runs before input validation, not after: a repeat call with
|
|
104
|
+
# the same key is asking "what happened to that run," and the input was already
|
|
105
|
+
# validated when the run was first created - a caller-side bug on a retry
|
|
106
|
+
# (stale/missing input) shouldn't turn a successful repeat lookup into an
|
|
107
|
+
# ArgumentError for a run that already exists and needs no further input at all.
|
|
108
|
+
#
|
|
109
|
+
# Only the run THIS call actually creates gets executed. A found-existing run -
|
|
110
|
+
# whether found outright or found after losing the create race - is returned
|
|
111
|
+
# without calling the executor on it: this gem has no lease mechanism yet
|
|
112
|
+
# (Phase 1f) to make running the same Executor concurrently from two places
|
|
113
|
+
# safe, so piling onto a run some OTHER caller may already be executing isn't
|
|
114
|
+
# attempted here.
|
|
115
|
+
def start(idempotency_key:, **input)
|
|
116
|
+
existing = Run.find_by(idempotency_key: idempotency_key)
|
|
117
|
+
return check_same_workflow!(existing) if existing
|
|
118
|
+
|
|
119
|
+
validate_input!(input)
|
|
120
|
+
run, newly_created = find_or_create_run(idempotency_key, input)
|
|
121
|
+
Executor.new(run).call if newly_created
|
|
122
|
+
run
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def validate_input!(input)
|
|
128
|
+
missing = inputs - input.keys
|
|
129
|
+
raise ArgumentError, "#{self}.start is missing required input(s): #{missing.inspect}" unless missing.empty?
|
|
130
|
+
|
|
131
|
+
extra = input.keys - inputs
|
|
132
|
+
return if extra.empty?
|
|
133
|
+
|
|
134
|
+
raise ArgumentError,
|
|
135
|
+
"#{self}.start received undeclared input(s): #{extra.inspect} - declared inputs are #{inputs.inspect}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The first find_by in #start above already covers the common case; this
|
|
139
|
+
# second lookup only runs for the race window between that read and this
|
|
140
|
+
# create - rescue is scoped to just create_run, not the surrounding lookup,
|
|
141
|
+
# so a RecordNotUnique from somewhere unexpected is never silently caught here.
|
|
142
|
+
def find_or_create_run(idempotency_key, input)
|
|
143
|
+
[create_run(idempotency_key, input), true]
|
|
144
|
+
rescue ActiveRecord::RecordNotUnique
|
|
145
|
+
[check_same_workflow!(Run.find_by!(idempotency_key: idempotency_key)), false]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def create_run(idempotency_key, input)
|
|
149
|
+
raise Error, "#{self} must be assigned to a constant before .start can use it as a workflow name" if name.nil?
|
|
150
|
+
|
|
151
|
+
Run.create!(workflow: name, workflow_version: workflow_version, input: input,
|
|
152
|
+
idempotency_key: idempotency_key)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# A reused idempotency_key string across two different Workflow subclasses would
|
|
156
|
+
# otherwise silently return a Run belonging to the wrong workflow entirely.
|
|
157
|
+
def check_same_workflow!(run)
|
|
158
|
+
return run if run.workflow == name
|
|
159
|
+
|
|
160
|
+
raise Error, "idempotency_key #{run.idempotency_key.inspect} already belongs to a #{run.workflow} run " \
|
|
161
|
+
"(belay_runs##{run.id}), not #{self}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Subclasses never inherit a superclass's declarations: @inputs/@steps are plain
|
|
165
|
+
# class-level ivars, not class_attribute. `class Child < Base; end` with no further
|
|
166
|
+
# declarations has Child.steps == [], even if Base declared steps - nothing here
|
|
167
|
+
# makes that loud, so don't build a "shared base workflow" on this assumption.
|
|
168
|
+
def raw_inputs
|
|
169
|
+
@raw_inputs ||= []
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def raw_steps
|
|
173
|
+
@raw_steps ||= []
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def validate_step_kind!(name, kind)
|
|
177
|
+
return if kind.nil? || kind.is_a?(String) || kind.is_a?(Symbol)
|
|
178
|
+
|
|
179
|
+
raise ArgumentError, "step :#{name} kind: must be a String, Symbol, or nil, got #{kind.inspect}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def validate_gate_when!(name, condition)
|
|
183
|
+
return if condition.respond_to?(:call)
|
|
184
|
+
|
|
185
|
+
raise ArgumentError, "gate :#{name} needs when: (something that responds to #call), got #{condition.inspect}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Shared by step and gate: both end up in the same raw_steps list, walked by
|
|
189
|
+
# position, so a step and a gate with the same name collide with each other
|
|
190
|
+
# too, not just within their own kind.
|
|
191
|
+
def reserve_name!(name)
|
|
192
|
+
name = name.to_sym
|
|
193
|
+
return name unless raw_steps.any? { |s| s.name == name }
|
|
194
|
+
|
|
195
|
+
raise ArgumentError, "step/gate :#{name} is already declared on #{self}"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
data/lib/belay.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belay
|
|
4
|
+
# Error must exist before any required file below, since several of them (e.g.
|
|
5
|
+
# FakeProvider::ScriptExhaustedError) subclass it at load time, not lazily.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
require_relative "belay/version"
|
|
9
|
+
require_relative "belay/configuration"
|
|
10
|
+
require_relative "belay/application_record"
|
|
11
|
+
require_relative "belay/run"
|
|
12
|
+
require_relative "belay/step"
|
|
13
|
+
require_relative "belay/approval"
|
|
14
|
+
require_relative "belay/cache_entry"
|
|
15
|
+
require_relative "belay/rule"
|
|
16
|
+
require_relative "belay/daily_stats"
|
|
17
|
+
require_relative "belay/workflow"
|
|
18
|
+
require_relative "belay/context"
|
|
19
|
+
require_relative "belay/provider_response"
|
|
20
|
+
require_relative "belay/provider"
|
|
21
|
+
require_relative "belay/fake_provider"
|
|
22
|
+
require_relative "belay/anthropic_provider"
|
|
23
|
+
require_relative "belay/executor"
|
|
24
|
+
require_relative "belay/resume_run_job"
|
|
25
|
+
require_relative "belay/janitor_job"
|
|
26
|
+
require_relative "belay/engine"
|
|
27
|
+
|
|
28
|
+
# Raised by Run#start!/pause!/resume!/complete!/fail!/cancel! when the current
|
|
29
|
+
# status doesn't allow the requested transition.
|
|
30
|
+
class InvalidTransitionError < Error; end
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
def configure
|
|
34
|
+
yield config
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Not thread-safe: a lazy first read from two threads at once could construct
|
|
38
|
+
# two Configuration objects. Unexercised today (only read at Rails boot); revisit
|
|
39
|
+
# once workers read config concurrently with a lazy first access (Phase 1f).
|
|
40
|
+
def config
|
|
41
|
+
@config ||= Configuration.new
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Test-only. Call after mutating config in a spec so it doesn't leak into others.
|
|
45
|
+
def reset_config!
|
|
46
|
+
@config = nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record/migration"
|
|
5
|
+
|
|
6
|
+
module Belay
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
def create_belay_runs_migration
|
|
14
|
+
migration_template "create_belay_runs.rb.tt", "db/migrate/create_belay_runs.rb"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_belay_steps_migration
|
|
18
|
+
migration_template "create_belay_steps.rb.tt", "db/migrate/create_belay_steps.rb"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Last: belay_approvals FKs into both belay_runs and belay_steps, so its
|
|
22
|
+
# migration needs a timestamp after both of theirs - each migration_template
|
|
23
|
+
# call gets a fresh, later timestamp than the one before it, same reasoning
|
|
24
|
+
# that already orders steps after runs above.
|
|
25
|
+
def create_belay_approvals_migration
|
|
26
|
+
migration_template "create_belay_approvals.rb.tt", "db/migrate/create_belay_approvals.rb"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# No FK into belay_runs/belay_steps - a cache entry isn't tied to any
|
|
30
|
+
# particular run, so there's no ordering dependency forcing this after
|
|
31
|
+
# the others.
|
|
32
|
+
def create_belay_cache_entries_migration
|
|
33
|
+
migration_template "create_belay_cache_entries.rb.tt", "db/migrate/create_belay_cache_entries.rb"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Last: belay_rules' own migration is what adds the FK from belay_steps.
|
|
37
|
+
# rule_applied_id into belay_rules (see create_belay_rules.rb.tt's own
|
|
38
|
+
# comment on why that FK can't live in create_belay_steps.rb.tt instead),
|
|
39
|
+
# so this table has to exist before that FK statement runs. Not ordered
|
|
40
|
+
# relative to belay_cache_entries for any dependency reason - just the
|
|
41
|
+
# one predictable place to look for whichever migration was added most
|
|
42
|
+
# recently.
|
|
43
|
+
def create_belay_rules_migration
|
|
44
|
+
migration_template "create_belay_rules.rb.tt", "db/migrate/create_belay_rules.rb"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
class CreateBelayApprovals < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :belay_approvals, id: :uuid do |t|
|
|
4
|
+
t.uuid :run_id, null: false
|
|
5
|
+
# A direct FK to the specific gate's belay_steps row, not just a position
|
|
6
|
+
# column. run_id is kept alongside it too, even though it's already
|
|
7
|
+
# reachable via step.run_id (one join) - a different kind of
|
|
8
|
+
# denormalization than belay_steps' idempotency_key (a derived VALUE with
|
|
9
|
+
# no table to join to at all, kept stable across retries), just the plain
|
|
10
|
+
# avoid-a-join kind: a dashboard listing pending approvals across runs
|
|
11
|
+
# shouldn't need to join through steps first.
|
|
12
|
+
t.uuid :step_id, null: false
|
|
13
|
+
t.string :status, null: false, default: "pending"
|
|
14
|
+
t.string :decided_by
|
|
15
|
+
t.datetime :decided_at
|
|
16
|
+
# What a human edited before approving, if anything - feat(runs): support
|
|
17
|
+
# approve! with edited payload, a later commit. Added now, same as
|
|
18
|
+
# belay_steps' provider/model/token columns sat unused from Phase 1b until
|
|
19
|
+
# Phase 1g: one migration instead of two for columns whose shape is already
|
|
20
|
+
# known.
|
|
21
|
+
t.jsonb :edited_payload
|
|
22
|
+
|
|
23
|
+
t.timestamps
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
add_foreign_key :belay_approvals, :belay_runs, column: :run_id
|
|
27
|
+
add_foreign_key :belay_approvals, :belay_steps, column: :step_id
|
|
28
|
+
|
|
29
|
+
add_index :belay_approvals, :run_id
|
|
30
|
+
# One approval per gate step, enforced at the DB level - but this ASSUMES a
|
|
31
|
+
# true-condition gate settles into exactly one terminal belay_steps row per
|
|
32
|
+
# logical occurrence. That's not true yet: today's temporary when: true
|
|
33
|
+
# handling raises, which reuses the generic step retry loop (same as any
|
|
34
|
+
# step failure) - a gate declared with retries: N checkpoints N+1 separate
|
|
35
|
+
# attempt rows before the final raise, per the executor commit's own
|
|
36
|
+
# review. If the pause-on-gate commit creates an Approval per raise/attempt
|
|
37
|
+
# instead of exactly once per gate occurrence, THIS constraint breaks it.
|
|
38
|
+
# Whoever builds that commit: settle a true-condition gate once, not once
|
|
39
|
+
# per retry attempt, or revisit this index.
|
|
40
|
+
add_index :belay_approvals, :step_id, unique: true
|
|
41
|
+
add_index :belay_approvals, :status
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
class CreateBelayCacheEntries < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :belay_cache_entries, id: :uuid do |t|
|
|
4
|
+
# SHA256 of [provider class name, model, prompt, schema].inspect - exact,
|
|
5
|
+
# literal match only (symbol vs string key differences in prompt/schema
|
|
6
|
+
# are NOT normalized before hashing - a deliberately stricter, simpler
|
|
7
|
+
# bar than "semantically equivalent"). See Belay::CacheEntry.key_for.
|
|
8
|
+
#
|
|
9
|
+
# Named "key", not "cache_key" - ActiveRecord::Base already defines a
|
|
10
|
+
# #cache_key method of its own (used for fragment-caching/ETag support),
|
|
11
|
+
# and Rails 7's DangerousAttributeError refuses to let a real column
|
|
12
|
+
# shadow it. "key" is unambiguous enough inside a model whose own table
|
|
13
|
+
# is already named belay_cache_entries.
|
|
14
|
+
t.string :key, null: false
|
|
15
|
+
t.jsonb :content
|
|
16
|
+
t.integer :hits, null: false, default: 0
|
|
17
|
+
# nil means "never expires" - Belay.config.cache_ttl's default is a real
|
|
18
|
+
# duration, not nil, so this is normally set; nil stays possible for
|
|
19
|
+
# anyone who explicitly configures cache_ttl = nil.
|
|
20
|
+
t.datetime :expires_at
|
|
21
|
+
|
|
22
|
+
t.timestamps
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
add_index :belay_cache_entries, :key, unique: true
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
class CreateBelayRules < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :belay_rules, id: :uuid do |t|
|
|
4
|
+
t.string :workflow, null: false
|
|
5
|
+
t.string :workflow_version, null: false
|
|
6
|
+
t.string :gate_name, null: false
|
|
7
|
+
# SHA256 of [input, outputs, a fingerprint of the when: itself].inspect -
|
|
8
|
+
# see Belay::Rule.context_key_for for the exact fingerprint and why it's
|
|
9
|
+
# needed (workflow_version alone doesn't catch an edited when:). The
|
|
10
|
+
# input/outputs are the SAME ones a gate's when: was evaluated against,
|
|
11
|
+
# computed once at evaluation time (not re-derived later from a round-
|
|
12
|
+
# tripped jsonb Hash, which would risk a symbol/string key mismatch
|
|
13
|
+
# silently breaking the exact match - see Belay::CacheEntry.key_for for
|
|
14
|
+
# the same reasoning applied there). Deliberately the FULL snapshot, not
|
|
15
|
+
# just whatever a gate's own when: closure happens to read - a narrower
|
|
16
|
+
# key could auto-settle a future gate occurrence that looks identical to
|
|
17
|
+
# the closure but differs in some upstream value nobody declared as
|
|
18
|
+
# relevant.
|
|
19
|
+
t.string :context_key, null: false
|
|
20
|
+
t.integer :consistent_approvals, null: false, default: 0
|
|
21
|
+
# learning: still accumulating consistent_approvals toward the threshold.
|
|
22
|
+
# active: at or past the threshold - safe for a later commit to auto-
|
|
23
|
+
# settle this exact gate occurrence without ever pausing for a human.
|
|
24
|
+
# invalidated: a human rejected this exact gate occurrence at least
|
|
25
|
+
# once - terminal, permanent, never re-learned. A single rejection
|
|
26
|
+
# invalidates immediately, with no tolerance for "mostly consistent":
|
|
27
|
+
# see feat(rules) commits for the learning/auto-settle logic that
|
|
28
|
+
# enforces this.
|
|
29
|
+
t.string :status, null: false, default: "learning"
|
|
30
|
+
t.datetime :invalidated_at
|
|
31
|
+
|
|
32
|
+
t.timestamps
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Added here, not in create_belay_steps.rb.tt: belay_rules doesn't exist yet
|
|
36
|
+
# when that earlier migration runs, so the FK has to be added once this
|
|
37
|
+
# table does.
|
|
38
|
+
add_foreign_key :belay_steps, :belay_rules, column: :rule_applied_id
|
|
39
|
+
|
|
40
|
+
# One row per exact (workflow, workflow_version, gate_name, context_key)
|
|
41
|
+
# combination - a second consistent approval for the same gate occurrence
|
|
42
|
+
# updates this same row's counter rather than creating a sibling.
|
|
43
|
+
add_index :belay_rules, [:workflow, :workflow_version, :gate_name, :context_key],
|
|
44
|
+
unique: true, name: "index_belay_rules_on_identity"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
class CreateBelayRuns < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
enable_extension "pgcrypto"
|
|
4
|
+
|
|
5
|
+
create_table :belay_runs, id: :uuid do |t|
|
|
6
|
+
t.string :workflow, null: false
|
|
7
|
+
t.string :workflow_version, null: false
|
|
8
|
+
t.jsonb :input, null: false, default: {}
|
|
9
|
+
t.string :status, null: false, default: "pending"
|
|
10
|
+
t.integer :current_position, null: false, default: 0
|
|
11
|
+
t.string :idempotency_key
|
|
12
|
+
t.string :lease_owner
|
|
13
|
+
t.datetime :lease_expires_at
|
|
14
|
+
t.jsonb :error
|
|
15
|
+
t.datetime :started_at
|
|
16
|
+
t.datetime :finished_at
|
|
17
|
+
# Both nil for an ordinary run - only set on a shadow run created by
|
|
18
|
+
# Run#replay. replay_of_id is a self-referential FK (added below, after
|
|
19
|
+
# create_table - Postgres can't reference a table from inside its own
|
|
20
|
+
# creation), not a NOT NULL/required column: most runs are not replays.
|
|
21
|
+
# replay_model is the model: override Run#replay was given, threaded
|
|
22
|
+
# through to Context#llm by Executor::StepRunner#build_context - a
|
|
23
|
+
# replayed llm_call step's own hardcoded model: in the workflow body is
|
|
24
|
+
# a default this overrides, not something replay edits in place.
|
|
25
|
+
t.uuid :replay_of_id
|
|
26
|
+
t.string :replay_model
|
|
27
|
+
|
|
28
|
+
t.timestamps
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
add_index :belay_runs, :idempotency_key, unique: true
|
|
32
|
+
add_index :belay_runs, [:status, :lease_expires_at]
|
|
33
|
+
add_index :belay_runs, :workflow
|
|
34
|
+
add_foreign_key :belay_runs, :belay_runs, column: :replay_of_id
|
|
35
|
+
add_index :belay_runs, :replay_of_id
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
class CreateBelaySteps < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :belay_steps, id: :uuid do |t|
|
|
4
|
+
t.uuid :run_id, null: false
|
|
5
|
+
t.integer :position, null: false
|
|
6
|
+
t.string :name, null: false
|
|
7
|
+
t.string :kind, null: false
|
|
8
|
+
t.string :status, null: false, default: "started"
|
|
9
|
+
t.jsonb :input
|
|
10
|
+
t.jsonb :output
|
|
11
|
+
t.string :provider
|
|
12
|
+
t.string :model
|
|
13
|
+
t.integer :input_tokens
|
|
14
|
+
t.integer :output_tokens
|
|
15
|
+
t.integer :cost_cents
|
|
16
|
+
t.integer :latency_ms
|
|
17
|
+
t.integer :attempt, null: false, default: 1
|
|
18
|
+
# Derived from run_id + position only (not attempt) so a retry of the same step
|
|
19
|
+
# presents the same key to an external API. See DESIGN_DECISIONS.md #3.
|
|
20
|
+
t.string :idempotency_key, null: false
|
|
21
|
+
t.jsonb :error
|
|
22
|
+
# Who/when resolved a needs_review step (Run#resolve_needs_review!) - named
|
|
23
|
+
# to match belay_approvals' decided_by/decided_at rather than invented fresh,
|
|
24
|
+
# same concept (a human recording a decision), different table because a
|
|
25
|
+
# needs_review resolution isn't a gate approval: there's no Belay::Approval
|
|
26
|
+
# row for it, and reusing that model (built and documented specifically
|
|
27
|
+
# around gates, including its unique-per-gate-step index) for an unrelated
|
|
28
|
+
# kind of step would overload what it means.
|
|
29
|
+
t.string :resolved_by
|
|
30
|
+
t.datetime :resolved_at
|
|
31
|
+
# Set by Context#llm when ctx.llm(..., cacheable: true) is served from
|
|
32
|
+
# belay_cache_entries instead of a real provider call - input_tokens/
|
|
33
|
+
# output_tokens/cost_cents/latency_ms all stay nil/0 for that same row,
|
|
34
|
+
# since nothing was actually billed.
|
|
35
|
+
t.boolean :cache_hit, null: false, default: false
|
|
36
|
+
# Only ever set on a GATE step, by Executor::StepRunner#auto_settle_gate!
|
|
37
|
+
# (a later commit) - identifies which belay_rules row auto-settled this
|
|
38
|
+
# gate without ever pausing for a human. nil for every step a human
|
|
39
|
+
# actually decided (manual approve!/reject!) and for every non-gate step.
|
|
40
|
+
# The FK into
|
|
41
|
+
# belay_rules lives in create_belay_rules.rb.tt, not here: that table is
|
|
42
|
+
# created by a LATER migration (belay_rules needs this column to already
|
|
43
|
+
# exist on belay_steps before it can reference it), so adding the FK from
|
|
44
|
+
# the later file is what keeps a fresh install's migrations individually
|
|
45
|
+
# valid in sequence.
|
|
46
|
+
t.uuid :rule_applied_id
|
|
47
|
+
|
|
48
|
+
t.timestamps
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
add_foreign_key :belay_steps, :belay_runs, column: :run_id
|
|
52
|
+
|
|
53
|
+
# The full history of attempts is kept (one row per attempt); this is what
|
|
54
|
+
# uniquely identifies a row, not a candidate key for the idempotency_key column
|
|
55
|
+
# above, which repeats across attempts by design.
|
|
56
|
+
add_index :belay_steps, [:run_id, :position, :attempt], unique: true
|
|
57
|
+
end
|
|
58
|
+
end
|