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,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belay
4
+ # Runs a workflow's steps in order against a Run: start/complete the run itself,
5
+ # iterate steps in order, hand each one to StepRunner. The per-step mechanics
6
+ # (checkpointing, resume, retries) are StepRunner's job, not this class's - they're
7
+ # genuinely separate responsibilities, which is why they're split out below rather
8
+ # than all flattened into one large class.
9
+ class Executor
10
+ def initialize(run)
11
+ @run = run
12
+ @step_runner = StepRunner.new(run)
13
+ end
14
+
15
+ # catch(:gate_paused), not a rescue: a gate whose when: evaluates true throws
16
+ # this tag (see StepRunner#evaluate_gate!) after already pausing the run and
17
+ # releasing its lease - a throw, not a raise, specifically so it unwinds past
18
+ # run_attempt's rescue StandardError untouched (throw/catch and raise/rescue
19
+ # are separate mechanisms in Ruby; no rescue clause anywhere in between ever
20
+ # sees it), skipping both the generic succeeded/failed checkpoint AND the
21
+ # complete! call below, with no further steps run.
22
+ def call
23
+ @run.start! if @run.pending?
24
+ outputs = {}
25
+
26
+ catch(:gate_paused) do
27
+ workflow_class.steps.each_with_index do |step_definition, position|
28
+ outputs[step_definition.name] = @step_runner.run(step_definition, position, outputs)
29
+ end
30
+
31
+ # Guarded the same way start! is above: a second call on an already-
32
+ # completed run must be a clean no-op, not InvalidTransitionError on
33
+ # complete! -> complete!.
34
+ @run.complete! if @run.running?
35
+ end
36
+
37
+ @run
38
+ end
39
+
40
+ private
41
+
42
+ # @run.pending? above guards start! itself. complete! above assumes every call to
43
+ # this method runs all the way to the end successfully - calling this again on a
44
+ # run some OTHER code path already moved to failed/cancelled (nothing does that
45
+ # yet) would skip/re-run steps as normal but never re-reach complete!, since
46
+ # status never returns to "running". Not handled; stated here, not silently.
47
+ def workflow_class
48
+ @run.workflow.constantize
49
+ rescue NameError => e
50
+ raise Error, "belay_runs##{@run.id} names workflow #{@run.workflow.inspect}, which isn't a loadable " \
51
+ "constant (#{e.message})"
52
+ end
53
+
54
+ # Runs one step to completion: checkpointing ("started" before the block, then
55
+ # "succeeded"/"failed" after - no transaction spans the block call itself,
56
+ # DESIGN_DECISIONS #4), resuming an orphaned started row in place, skipping an
57
+ # already-succeeded one (reusing its output, deep-symbolized so a later step's
58
+ # ctx.extract[:key]-style access sees the same shape a fresh run would), and
59
+ # retrying up to step(..., retries: N) additional attempts on a raise, each its
60
+ # own row sharing the position's stable idempotency_key (DESIGN_DECISIONS #3).
61
+ #
62
+ # retries: is a different, narrower kind of safety than the idempotent:/
63
+ # needs_review gate for an orphaned started row: that gate decides whether
64
+ # resuming a crash is actually safe (idempotent: true resumes it; otherwise the
65
+ # row is flagged needs_review and the run stops for a human), where retries:
66
+ # only ever answers "did retries run out" for a row the author already opted in
67
+ # to retrying. retries: is the step author opting a specific step into automatic
68
+ # retry - the same contract
69
+ # every job-retry system has (Sidekiq's retry:, ActiveJob's retry_on): declaring
70
+ # it does not make a step's side effects safe to repeat, it says the author has
71
+ # already made them safe (or the step has none). A step whose block charges a
72
+ # card and then raises will be retried exactly as declared, with no additional
73
+ # gate - that's what ctx.step_idempotency_key is for passing to an idempotent-
74
+ # aware external API, if the author chooses to; nothing here enforces that they
75
+ # do. The two commits are separate because the safety question is different, not
76
+ # because one is "done" and the other "also needed."
77
+ class StepRunner # rubocop:disable Metrics/ClassLength
78
+ # A step declared without kind: (every current README Target API example) can't
79
+ # be written to belay_steps as-is - kind is NOT NULL with no DB default, unlike
80
+ # status. "decision" is the generic catch-all of the four enum values for a step
81
+ # that's plain Ruby, not specifically an llm_call/tool_call/gate. Written at
82
+ # start_checkpoint! time, before the block has run - run_attempt below upgrades
83
+ # it to "llm_call" after the fact if the block actually called ctx.llm and the
84
+ # author never declared kind: explicitly (an explicit kind: is never overridden).
85
+ DEFAULT_KIND = "decision"
86
+
87
+ # Returned by evaluate_gate! only for the rule-auto-settled case (see
88
+ # auto_settle_gate! below) - a sentinel distinguishing "this gate step's row
89
+ # is already fully, correctly written" from a real output value, since a
90
+ # gate's own legitimate output (nil, same as approve! with no
91
+ # edited_payload) is otherwise indistinguishable from "nothing happened
92
+ # yet." run_attempt checks for this by identity, not by nil, specifically
93
+ # so it never re-checkpoints over what auto_settle_gate! just wrote.
94
+ AUTO_SETTLED = Object.new.freeze
95
+ private_constant :AUTO_SETTLED
96
+
97
+ # What one step's execution needs, bundled so passing it around doesn't mean a
98
+ # five-plus-argument parameter list on every private method below.
99
+ StepRun = Struct.new(:step_definition, :position, :outputs, :max_attempts, keyword_init: true)
100
+
101
+ def initialize(run)
102
+ @run = run
103
+ end
104
+
105
+ def run(step_definition, position, outputs)
106
+ max_attempts = 1 + (step_definition.options[:retries] || 0)
107
+ step_run = StepRun.new(step_definition: step_definition, position: position, outputs: outputs,
108
+ max_attempts: max_attempts)
109
+ dispatch(step_run, latest_attempt(position))
110
+ end
111
+
112
+ private
113
+
114
+ # Step#status has six legal values now. "succeeded" and "skipped" resume the
115
+ # same way - reuse the stored output, never re-evaluate (DESIGN_DECISIONS #7:
116
+ # a gate's when: is not re-run against whatever context resume happens to
117
+ # have). needs_review/awaiting_approval were each left that way for a reason
118
+ # this can't resolve itself - re-running either would be guessing, same as a
119
+ # freshly-flagged needs_review row would be if dispatch were called on it
120
+ # again in the same process (it never is, today: flagging always raises
121
+ # right after, see flag_needs_review! below). An awaiting_approval row is
122
+ # resolved the same way: Run#approve!/#reject! move it to succeeded/failed
123
+ # BEFORE the executor is ever invoked again, so dispatch here never actually
124
+ # sees "awaiting_approval" on a row a human has already acted on - this
125
+ # branch only fires if something re-dispatches the run while the gate is
126
+ # genuinely still unresolved.
127
+ def dispatch(step_run, existing) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
128
+ case existing&.status
129
+ when nil
130
+ attempt_step(step_run, attempt: 1)
131
+ when "started"
132
+ resume_orphaned_started(step_run, existing)
133
+ when "succeeded", "skipped"
134
+ symbolize_output(existing.output)
135
+ when "failed"
136
+ retry_or_raise(step_run, existing)
137
+ when "needs_review"
138
+ raise unsafe_to_rerun_error(step_run, existing)
139
+ when "awaiting_approval"
140
+ raise Error, "belay_steps##{existing.id} at position #{step_run.position} is awaiting_approval - " \
141
+ "resolve it via run.approve!/reject! first"
142
+ else
143
+ raise Error, "belay_steps##{existing.id} at position #{step_run.position} has unrecognized status " \
144
+ "#{existing.status.inspect} - refusing to guess what resuming it should do"
145
+ end
146
+ end
147
+
148
+ # DESIGN_DECISIONS #2: a step found "started" was interrupted before anything
149
+ # caught an exception for it (retries: never applies) - nobody asked this
150
+ # executor to retry it. idempotent: true is the author's explicit sign-off that
151
+ # redoing it is safe; without it, retrying automatically would be guessing
152
+ # exactly as much as the unsafe_to_rerun_error cases below - so instead the row
153
+ # is flagged needs_review for a human, exactly as decision #2 says, and the run
154
+ # stops (flag_needs_review! raises; nothing resumes a needs_review run yet -
155
+ # that's dashboard/resolve-action work, not built).
156
+ #
157
+ # options[:idempotent] == true, not bare truthiness: nothing validates step
158
+ # option values at declare time (unlike kind:, see validate_step_kind! in
159
+ # Workflow), so idempotent: "false" - a string, not a boolean, from an author
160
+ # who doesn't think in Ruby truthiness - would otherwise be truthy and silently
161
+ # authorize resuming a step the author was trying to mark unsafe.
162
+ #
163
+ # Applies to an orphaned gate exactly the same way, even though re-evaluating
164
+ # a pure when: condition is arguably always safe regardless of idempotent: -
165
+ # a gate crashing mid-evaluation is rare and not this commit's problem to
166
+ # solve; an author who wants one resumed without flagging needs_review can
167
+ # already declare idempotent: true on it today, same as any step.
168
+ def resume_orphaned_started(step_run, existing)
169
+ return attempt_step(step_run, attempt: existing.attempt, reuse: existing) if idempotent?(step_run)
170
+
171
+ flag_needs_review!(step_run, existing)
172
+ end
173
+
174
+ def idempotent?(step_run)
175
+ step_run.step_definition.options[:idempotent] == true
176
+ end
177
+
178
+ def flag_needs_review!(step_run, existing)
179
+ reason = "belay_steps##{existing.id} at position #{step_run.position} was left \"started\" (by a " \
180
+ "crash, or by another worker concurrently running this same step - no lease mechanism " \
181
+ "exists yet to tell which) and step :#{step_run.step_definition.name} isn't declared " \
182
+ "idempotent: true"
183
+ existing.update!(status: "needs_review", error: { "reason" => reason })
184
+ # pause!, not leaving status at "running": otherwise Run.where(status: "running")
185
+ # can't distinguish a run legitimately executing from one silently stuck on a
186
+ # human. Run#pause! already exists (running -> paused) for exactly this shape
187
+ # of "stopped, not failed, waiting on something external."
188
+ @run.pause!
189
+ raise Error, "#{reason} - flagged needs_review; the run is paused until a human resolves it"
190
+ end
191
+
192
+ def unsafe_to_rerun_error(step_run, existing)
193
+ Error.new(
194
+ "belay_steps##{existing.id} at position #{step_run.position} is needs_review - resolve it via " \
195
+ "run.resolve_needs_review! first"
196
+ )
197
+ end
198
+
199
+ def retry_or_raise(step_run, existing)
200
+ if existing.attempt < step_run.max_attempts
201
+ attempt_step(step_run, attempt: existing.attempt + 1)
202
+ else
203
+ raise Error, "belay_steps##{existing.id} at position #{step_run.position} failed on its final attempt " \
204
+ "(#{existing.attempt}/#{step_run.max_attempts}): #{existing.error}"
205
+ end
206
+ end
207
+
208
+ # Loops in-process across retries (no separate Executor#call per attempt): each
209
+ # failure is caught and marked on its own row by run_attempt, then either a
210
+ # fresh attempt follows or the error is re-raised once max_attempts is reached.
211
+ def attempt_step(step_run, attempt:, reuse: nil)
212
+ loop do
213
+ step = reuse || start_checkpoint!(step_run, attempt)
214
+ output, error = run_attempt(step_run, step)
215
+ return output unless error
216
+
217
+ raise error if attempt >= step_run.max_attempts
218
+
219
+ attempt += 1
220
+ reuse = nil
221
+ end
222
+ end
223
+
224
+ # One attempt: run the block (or, for a gate, evaluate when:), checkpoint the
225
+ # result. Returns [output, nil] on success or [nil, error] on failure - never
226
+ # raises itself, so the retry loop above decides whether to try again or
227
+ # propagate. context is built before the risky call so the rescue clause can
228
+ # still reach its llm_metadata - ctx.llm may have already made a real, billed
229
+ # provider call before the block went on to raise for an unrelated reason;
230
+ # that cost is recorded on the failed row too, not dropped.
231
+ #
232
+ # A gate reuses this same checkpoint/retry machinery rather than a separate
233
+ # path: start_checkpoint! already writes kind: :gate correctly (it was never
234
+ # nil, so DEFAULT_KIND never kicks in), and evaluate_gate! below stands in for
235
+ # block.call - when: false settles the row exactly like a step's output
236
+ # would, just under status: "skipped" instead of "succeeded". when: true
237
+ # takes a different exit entirely (throw, not return) - see evaluate_gate!.
238
+ def run_attempt(step_run, step)
239
+ context = build_context(step_run)
240
+ output = run_block(step_run, context, step)
241
+ return [nil, nil] if output.equal?(AUTO_SETTLED) # auto_settle_gate! already wrote this row itself
242
+
243
+ step.update!(status: settled_status(step_run), output: output, **extra_step_attrs(step_run, context))
244
+ [output, nil]
245
+ rescue StandardError => e
246
+ step.update!(status: "failed", error: { "class" => e.class.name, "message" => e.message },
247
+ **extra_step_attrs(step_run, context))
248
+ [nil, e]
249
+ end
250
+
251
+ # Wrapped in @run.heartbeat_while, not called bare - this is the one call in
252
+ # the whole step/gate lifecycle that can plausibly run longer than
253
+ # lease_duration (a model call, or a gate's own when: doing something
254
+ # slow), so it's the only thing that needs a concurrent heartbeat. Every
255
+ # other method here (checkpointing, retries, dispatch) is fast,
256
+ # synchronous Ruby - heartbeating around those too would just spin up
257
+ # threads for no reason.
258
+ def run_block(step_run, context, step)
259
+ @run.heartbeat_while do
260
+ next evaluate_gate!(step_run, context, step) if step_run.step_definition.kind == :gate
261
+
262
+ step_run.step_definition.block.call(context)
263
+ end
264
+ end
265
+
266
+ def settled_status(step_run)
267
+ step_run.step_definition.kind == :gate ? "skipped" : "succeeded"
268
+ end
269
+
270
+ # when: false settles normally (returns nil, caller checkpoints "skipped").
271
+ # when: true either auto-settles (auto_settle_gate!) or pauses
272
+ # (pause_on_gate!) - see each for its own reasoning.
273
+ def evaluate_gate!(step_run, context, step)
274
+ condition = step_run.step_definition.options[:when]
275
+ return nil unless condition.call(context)
276
+
277
+ # Computed from the live Context's own symbol-keyed Hashes, before anything
278
+ # round-trips through jsonb - see Rule.context_key_for and belay_rules' own
279
+ # migration comment for why that ordering matters. Stashed on whichever of
280
+ # the two paths below actually settles this gate, so Run#approve!/reject!
281
+ # and the dashboard can both read it back later without re-deriving
282
+ # outputs-so-far themselves.
283
+ context_key = Rule.context_key_for(input: context.input, outputs: context.outputs, condition: condition)
284
+ rule = matching_active_rule(step_run, context_key)
285
+ return auto_settle_gate!(step, context_key, rule) if rule
286
+
287
+ pause_on_gate!(step, context_key)
288
+ end
289
+
290
+ def matching_active_rule(step_run, context_key)
291
+ Rule.active_for(workflow: @run.workflow, workflow_version: @run.workflow_version,
292
+ gate_name: step_run.step_definition.name, context_key: context_key)
293
+ end
294
+
295
+ # Auto-approves this exact gate occurrence without ever pausing for a human,
296
+ # because Belay::Rule already learned it (status: "active") from N
297
+ # consistent, unedited human approvals of this identical (workflow,
298
+ # workflow_version, gate_name, context_key) - see feat(rules): learn a
299
+ # rule.... output: nil, same shape approve! leaves when no edited_payload
300
+ # was given: an auto-settled gate is indistinguishable from an unedited
301
+ # human approval except for rule_applied_id being set, and there's no
302
+ # Belay::Approval row, since no human actually decided this one.
303
+ #
304
+ # A single atomic UPDATE is enough here, unlike pause_on_gate! below's
305
+ # multi-write transaction: this path never touches the run's own status or
306
+ # its lease, so there's no multi-table invariant a mid-write crash could
307
+ # leave half-done - the existing orphaned-"started"-gate recovery
308
+ # (resume_orphaned_started's idempotent?/needs_review branch) already
309
+ # covers a crash before this single write commits, same as it does for the
310
+ # when: false "skipped" path.
311
+ def auto_settle_gate!(step, context_key, rule)
312
+ step.update!(status: "succeeded", output: nil, input: { "context_key" => context_key },
313
+ rule_applied_id: rule.id)
314
+ AUTO_SETTLED
315
+ end
316
+
317
+ # when: true, no matching active rule: pauses right here instead of
318
+ # returning. This step's own row becomes "awaiting_approval" (not
319
+ # "succeeded", not "failed" - run_attempt's own checkpoint line never
320
+ # runs, since throw skips straight past it, same as it skips the rescue),
321
+ # the run is paused and its lease released, then throw :gate_paused unwinds
322
+ # all the way up to Executor#call's catch, skipping every remaining step
323
+ # and the complete! call - cleanly, not via an exception any rescue
324
+ # StandardError could intercept or retry. That's the actual fix for an
325
+ # earlier commit's documented retries: gap: there's no raise on this path
326
+ # for retries: to apply to at all.
327
+ #
328
+ # The three writes below are wrapped in one transaction, and the throw
329
+ # happens AFTER it (not inside it): a process killed mid-transaction rolls
330
+ # back to the gate's original "started" row, which is the already-handled
331
+ # crash path (idempotent?/needs_review) - not a new, worse state. Without
332
+ # the transaction, a crash between step.update! and @run.pause! would leave
333
+ # the step "awaiting_approval" while the run stays "running": JanitorJob's
334
+ # own query (status: running, lease expired or none - today's realistic
335
+ # case, since inline Workflow.start never takes a lease at all) would pick
336
+ # that run back up and hand it to ResumeRunJob, which has no rescue around
337
+ # Executor.new(run).call - dispatch's awaiting_approval branch just raises,
338
+ # uncaught, forever, every lease cycle, and the run never reaches "paused"
339
+ # at all. Caught by review before this landed; keeping the throw outside the
340
+ # transaction block (rather than inside it) is deliberate too - a throw is
341
+ # not an exception, so it wouldn't trigger Rails' own rollback-on-exception
342
+ # path, but there's no reason to rely on that distinction when the simpler
343
+ # structure (commit first, throw after) removes the question entirely.
344
+ def pause_on_gate!(step, context_key)
345
+ ActiveRecord::Base.transaction do
346
+ step.update!(status: "awaiting_approval", input: { "context_key" => context_key })
347
+ @run.pause!
348
+ @run.release_lease!
349
+ end
350
+ throw :gate_paused
351
+ end
352
+
353
+ def extra_step_attrs(step_run, context)
354
+ return {} if context.nil?
355
+
356
+ attrs = context.llm_metadata
357
+ attrs = attrs.merge(kind: "llm_call") if step_run.step_definition.kind.nil? && !attrs.empty?
358
+ attrs
359
+ end
360
+
361
+ def build_context(step_run)
362
+ Context.new(
363
+ input: @run.input,
364
+ outputs: step_run.outputs,
365
+ step_idempotency_key: idempotency_key(step_run.position),
366
+ model_override: @run.replay_model
367
+ )
368
+ end
369
+
370
+ def start_checkpoint!(step_run, attempt)
371
+ Step.create!(
372
+ run: @run,
373
+ position: step_run.position,
374
+ attempt: attempt,
375
+ name: step_run.step_definition.name,
376
+ kind: step_run.step_definition.kind || DEFAULT_KIND,
377
+ status: "started",
378
+ idempotency_key: idempotency_key(step_run.position)
379
+ )
380
+ end
381
+
382
+ # attempt is no longer always 1 (retries), so the latest attempt's row is what a
383
+ # resume/skip decision must look at, not an arbitrary one.
384
+ def latest_attempt(position)
385
+ Step.where(run: @run, position: position).order(attempt: :desc).first
386
+ end
387
+
388
+ # A step's output read back from the DB (jsonb round-trips through JSON) has
389
+ # string keys, even though the very same Hash returned fresh from a block in
390
+ # this same process would have symbol keys. Without this, a step block written
391
+ # like the README's own ctx.extract[:total_cents] would get nil instead of the
392
+ # real value the moment the step it depends on was skipped rather than freshly
393
+ # run - silently, since symbol access on a string-keyed Hash just returns nil,
394
+ # not an error. Only one level deep matters here (only Hash has
395
+ # deep_symbolize_keys); scalars and bare Arrays pass through untouched.
396
+ def symbolize_output(output)
397
+ output.is_a?(Hash) ? output.deep_symbolize_keys : output
398
+ end
399
+
400
+ # run_id + position only, matching DESIGN_DECISIONS #3 - stable across attempts,
401
+ # so a retry presents the same key to an external API. Used both for what's
402
+ # written to the row (idempotency_key is NOT NULL on belay_steps) and for what's
403
+ # exposed to the step's own block as ctx.step_idempotency_key, via build_context
404
+ # above - the two must be the same value, which is why both read from this one
405
+ # method.
406
+ def idempotency_key(position)
407
+ "#{@run.id}:#{position}"
408
+ end
409
+ end
410
+ end
411
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belay
4
+ # A Provider for specs: no network, a fixed script of responses, and a record of
5
+ # every call so a spec can assert what was actually sent. See section 9 of the
6
+ # architecture notes ("FakeProvider with scripted responses - no network in tests").
7
+ class FakeProvider < Provider
8
+ class ScriptExhaustedError < Error; end
9
+
10
+ # responses: each entry is either a ProviderResponse (full control over model/
11
+ # tokens/cost/latency) or a plain String/anything else, auto-wrapped as
12
+ # ProviderResponse.new(content: entry, model: model) using whatever model the
13
+ # matching call actually asked for - the common "just give back this content" case
14
+ # shouldn't need to predict which model name will be passed.
15
+ def initialize(responses: [])
16
+ super()
17
+ @responses = responses.dup
18
+ @calls = []
19
+ end
20
+
21
+ # Frozen dup, same reasoning as Workflow#steps/#inputs: a spec reaching in and
22
+ # doing provider.calls.clear or provider.calls << {...} would otherwise corrupt
23
+ # this provider's own record of what it actually received.
24
+ def calls
25
+ @calls.dup.freeze
26
+ end
27
+
28
+ def call(model:, prompt:, schema: nil)
29
+ @calls << { model: model, prompt: prompt, schema: schema }.freeze
30
+
31
+ if @responses.empty?
32
+ raise ScriptExhaustedError,
33
+ "#{self.class} received call ##{@calls.size} (model: #{model.inspect}) with no scripted " \
34
+ "response left - pass more entries to responses:"
35
+ end
36
+
37
+ response = @responses.shift
38
+ response.is_a?(ProviderResponse) ? response : ProviderResponse.new(content: response, model: model)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module Belay
6
+ # Finds runs stuck in "running" with no live lease - either the lease has expired,
7
+ # or (today's realistic case, since Workflow.start still runs the executor inline,
8
+ # not through ResumeRunJob) no lease was ever taken out before the process
9
+ # crashed - and re-enqueues ResumeRunJob for each. Doesn't reclaim anything itself:
10
+ # acquire_lease!'s conditional UPDATE is still the only thing that actually claims
11
+ # a lease, so if this runs twice, or races a worker that's about to heartbeat, the
12
+ # side that loses that race quietly no-ops (same as any other lease contention)
13
+ # rather than the two stepping on each other.
14
+ #
15
+ # Deliberately leaves paused runs alone: a run paused via flag_needs_review! is
16
+ # waiting on a human decision, not a crashed worker - re-enqueuing it would just
17
+ # have ResumeRunJob acquire the lease and Executor hit the same needs_review step
18
+ # again, achieving nothing. Only something that actually resolves the review (not
19
+ # built yet) should move a paused run back to running.
20
+ #
21
+ # Nothing schedules this job on a recurring basis - that's a host app's concern
22
+ # (cron, sidekiq-cron, whenever, etc.), not this gem's.
23
+ #
24
+ # DESIGN_DECISIONS #12 sizes lease_duration/heartbeat_interval assuming
25
+ # something heartbeats roughly every heartbeat_interval while a step is in
26
+ # flight, so a live worker's lease never actually goes stale under this
27
+ # job's nose - Run#heartbeat_while (wired into Executor::StepRunner#run_block)
28
+ # is that something, a background thread renewing the lease for as long as
29
+ # a single step's own block or gate when: is running. A step genuinely
30
+ # slower than heartbeat_while's own thread can keep up with (the process
31
+ # died, or heartbeat_lease! itself started failing because another worker
32
+ # already won the lease) still falls through to this job reclaiming it,
33
+ # same as before - heartbeating narrows the race, it doesn't close it by
34
+ # itself for every possible failure mode.
35
+ class JanitorJob < ActiveJob::Base
36
+ def perform
37
+ stale_runs.find_each do |run|
38
+ ResumeRunJob.perform_later(run.id)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def stale_runs
45
+ Run.running.where("lease_owner IS NULL OR lease_expires_at < ?", Time.current)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belay
4
+ # Interface every provider (FakeProvider, and later AnthropicProvider) implements. Pure
5
+ # interface, no shared logic: #call must return a Belay::ProviderResponse or raise.
6
+ #
7
+ # schema: is for structured output (see README's Target API, llm(schema: InvoiceSchema))
8
+ # - not validated or interpreted here. That belongs to the llm helper (Phase 1g), once
9
+ # it exists to decide what "validate against a schema" even means for this gem.
10
+ class Provider
11
+ def call(model:, prompt:, schema: nil)
12
+ raise NotImplementedError, "#{self.class} must implement #call(model:, prompt:, schema: nil)"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belay
4
+ # What every Belay::Provider#call returns. content and model are required - a response
5
+ # without either is a bug in the provider, not a legitimate "empty" result. The token/
6
+ # cost/latency fields default to nil: not every provider call has them (a tool_call
7
+ # step has no token usage at all), and only the provider itself knows its own pricing
8
+ # well enough to fill in cost_cents.
9
+ ProviderResponse = Struct.new(:content, :model, :input_tokens, :output_tokens, :cost_cents, :latency_ms,
10
+ keyword_init: true) do
11
+ def initialize(**attrs)
12
+ super
13
+ raise ArgumentError, "ProviderResponse requires content" if content.nil?
14
+ raise ArgumentError, "ProviderResponse requires model" if model.nil?
15
+
16
+ freeze
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ module Belay
6
+ # Drives a run forward: acquire the lease, run the executor. Enqueue with
7
+ # Belay::ResumeRunJob.perform_later(run.id) - nothing in this gem enqueues it yet
8
+ # (Workflow.start runs the executor inline); this is the piece later work would
9
+ # call to move that off the calling process.
10
+ #
11
+ # job_id is the lease owner: ActiveJob already gives every performed job a unique
12
+ # one, so there's no separate owner-identity scheme to invent.
13
+ #
14
+ # If acquire_lease! fails, another worker already holds this run (or is between
15
+ # attempts with time still left on its lease) - this job quietly returns rather
16
+ # than erroring or forcing its way in. Nothing here heartbeats the lease itself -
17
+ # Executor::StepRunner does, via Run#heartbeat_while, for as long as any single
18
+ # step/gate is actually in flight (see DESIGN_DECISIONS #12/#13) - and nothing
19
+ # releases it afterward either: a terminal run (completed/failed) is left holding
20
+ # its lease, harmless today since every caller of acquire_lease! already scopes to
21
+ # status: running first (see Run#release_lease!'s own comment). JanitorJob
22
+ # reclaiming an expired lease is what recovers from a crash, or from a step
23
+ # genuinely slower than heartbeat_while's own thread can keep renewing for.
24
+ #
25
+ # No retries: are configured on this class today, which matters because job_id is
26
+ # NOT fresh across an ActiveJob-level retry - retry_job copies the failed attempt's
27
+ # job_id onto the retried instance by design (only provider_job_id changes). If
28
+ # retry_on were ever added here, a job that acquires the lease then raises before
29
+ # the lease naturally expires would have its own retry see that still-valid lease
30
+ # as "owned by job_id X" (itself) and quietly no-op instead of resuming, since
31
+ # acquire_lease! has no same-owner bypass (that's heartbeat_lease!'s job). Adding
32
+ # retry_on to this class would need to account for that; don't add it without
33
+ # also handling same-owner reacquisition.
34
+ class ResumeRunJob < ActiveJob::Base
35
+ def perform(run_id)
36
+ run = Run.find(run_id)
37
+ return unless run.acquire_lease!(owner: job_id)
38
+
39
+ Executor.new(run).call
40
+ end
41
+ end
42
+ end