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/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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belay
4
+ VERSION = "0.1.0"
5
+ 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