Class: Nexo::Workflow
- Inherits:
-
Object
- Object
- Nexo::Workflow
- Defined in:
- lib/nexo/workflow.rb
Overview
A finite-job lifecycle primitive. Subclass Workflow, implement
#call(payload), and run it with MyWorkflow.run(payload) to get back a
persisted run record carrying a stable runId, status, payload, result,
error, and an ordered, inspectable event log.
class SummarizeDocument < Nexo::Workflow
def call(payload)
emit(:started, doc_id: payload[:doc_id])
summary = payload[:text].to_s.slice(0, 280)
emit(:summarized, length: summary.length)
{ summary: summary }
end
end
run = SummarizeDocument.run(doc_id: 123, text: "Long text…")
run.status # => "done"
run.result # => { "summary" => "Long text…" } (string keys after round-trip)
#call receives a symbol-keyed payload, but run.payload and run.result
read back string-keyed (top-level) in both backends — the Hash keys are
stringified before storage, matching what the ActiveRecord json column would
round-trip to, so a single workflow drives either store consistently.
The lifecycle records failures but never swallows them: a raising #call
leaves the run "failed" with error set and re-raises. (This is the
opposite of a Nexo tool failure, which returns { error: … } and never
raises into the agent loop.) An orphaned "running" run (crashed worker) is
abandoned — sweep it with ::reconcile_interrupted!.
A run can pause and continue on purpose, though (Spec 13): call #suspend!
mid-+#call+ to leave the run "suspended" (not failed), then ::resume /
::resume_later to re-enter #call from the top. #checkpoint guards the
expensive/side-effectful steps so resume skips already-paid-for work — see the
"Durable workflows" README section for the honest resume semantics.
Defined Under Namespace
Classes: Suspended
Constant Summary collapse
- RESERVED_STATE_KEYS =
State keys Nexo reserves for lifecycle metadata, never a caller's data:
"__suspend__"(suspend reason/resume_key — Spec 13),"__approval__"(pending approval call — Spec 16), and"__buffer_events__"(the persisted buffering choice — Spec 5). ::cleared_state strips these when a run reaches"done"; #checkpoint_all refuses a step named after any of them (Spec 21). %w[__suspend__ __approval__ __buffer_events__].freeze
Class Method Summary collapse
-
.agent(klass = nil) ⇒ Object
The Agent subclass this workflow drives (Spec 8).
-
.cwd(value = nil) ⇒ Object
The working directory used when this workflow's sandbox is
:local. -
.execute(run, payload:, buffer_events: Nexo.config.buffer_workflow_events, resume_input: {}) ⇒ Object
Executes an already-created run: "running" →
#call→ "done"/"failed", flushing buffered events in the ensure (on both success and failure) and firing a status notification on each transition. -
.logs(id) ⇒ Object
Looks up a run by its UUID string id through whichever store RunStore.default selects, yields each event when a block is given, and returns the ordered
eventsarray. -
.reconcile_interrupted! ⇒ Object
One-shot boot/deploy sweep (Spec 7 R6) that rewrites runs orphaned in
"running"to"interrupted"so a crashed worker doesn't leave zombie runs. -
.resume(run_id, input = {}) ⇒ Object
Continues a
"suspended"run synchronously (Spec 13): re-instantiates the workflow fromrun.workflow_classand re-runs#callfrom the top with the run's original payload, makinginputavailable via #resume_input. -
.resume_later(run_id, input = {}, queue: Nexo.config.job_queue, wait: nil, wait_until: nil) ⇒ Object
Enqueues a durable, cross-process resume of a
"suspended"run (Spec 13 Q4), mirroring ::run_later: the job carries the run id plus the resumeinput(the payload still lives on the run — only the input travels). -
.run(payload = nil, buffer_events: Nexo.config.buffer_workflow_events, **kwargs) ⇒ Object
Runs the workflow end to end: creates a run record (status "pending"), marks it "running", invokes the subclass's
#callwith a symbol-keyed payload, and records the outcome. -
.run_later(payload = nil, queue: Nexo.config.job_queue, wait: nil, wait_until: nil, **kwargs) ⇒ Object
Enqueues the workflow on the host's ActiveJob adapter and hands back the run immediately (status "queued") so a controller can return while the work happens in the background.
-
.sandbox(value = nil, **opts) ⇒ Object
The sandbox this workflow's runs stage inputs into and write artifacts to (Spec 7).
Instance Method Summary collapse
-
#artifact(name, content: nil, from: nil, locals: {}) ⇒ Object
Records a named deliverable on the run (Spec 7 R3).
-
#call(payload) ⇒ Object
Subclasses implement the work here.
-
#checkpoint(name) ⇒ Object
Runs +name+'s block once and stores its json-serializable result under
name.to_sin the run'sstate— the primitive that makes resume cheap and side-effect-safe (Spec 13). -
#checkpoint_all(steps) ⇒ Object
Runs several independent checkpoints concurrently on the first pass and, crucially, persists each step as it completes — so a resume after a partial failure only re-runs the steps that never landed (Spec 21):.
-
#emit(type, data = {}) ⇒ Object
Appends an event to the run's ordered log and persists it incrementally.
-
#flush_events! ⇒ Object
Replays any buffered events through the run and persists them in a single
save_events!, then clears the buffer. -
#initialize(run, buffer_events: false) ⇒ Workflow
constructor
Binds the instance to its persisted
run. -
#release_sandbox! ⇒ Object
Releases the run's sandbox at the end of #execute — but ONLY if one was actually built.
-
#resume_input ⇒ Object
The symbol-keyed input passed to ::resume/::resume_later (Spec 13).
-
#run_agent(prompt, max_turns: 25) ⇒ Object
Drives the workflow's declared ::agent (Spec 8), bound to this run's sandbox (Spec 7), forwarding every
(type, payload)event the agent's loop yields into the run log as anagent_*event, and ensuring the agent is closed afterward (tearing down any memoized MCP servers from Spec 6). -
#sandbox ⇒ Object
The run's sandbox (Spec 7), resolved lazily from the class-level ::sandbox macro and memoized.
-
#stage(files) ⇒ Object
Stages provided files into the run's sandbox before work begins (Spec 7 R2).
-
#suspend!(reason:, resume_key: nil) ⇒ Object
Pauses the run durably (Spec 13): raises Suspended, which ::execute catches to mark the run
"suspended"(a non-failure outcome) and return it to the caller.
Constructor Details
#initialize(run, buffer_events: false) ⇒ Workflow
Binds the instance to its persisted run. buffer_events: accumulates
emitted events in memory and flushes them once at the end of the run rather
than persisting each immediately. Prefer the ::run / ::resume entry points
over constructing directly.
433 434 435 436 437 438 439 440 441 442 |
# File 'lib/nexo/workflow.rb', line 433 def initialize(run, buffer_events: false) @run = run @buffer_events = buffer_events @event_buffer = [] # Serializes the read-current → merge → assign → save_state! sequence across # the concurrent fibers #checkpoint_all runs each step on (Spec 21). A plain # Mutex is fiber-safe under the async reactor (Group 0 verified it serializes # without stalling); #checkpoint (singular, no concurrency) does not use it. @checkpoint_mutex = Mutex.new end |
Class Method Details
.agent(klass = nil) ⇒ Object
The Agent subclass this workflow drives (Spec 8). Follows the same
read-vs-write ivar convention as ::sandbox/::cwd: with no argument it
reads (default nil — a workflow need not drive an agent); with one it
sets. Consumed by #run_agent, which binds the agent to the run's shared
sandbox. There is no per-call override — this macro is the only source.
96 97 98 |
# File 'lib/nexo/workflow.rb', line 96 def agent(klass = nil) klass.nil? ? @agent : (@agent = klass) end |
.cwd(value = nil) ⇒ Object
The working directory used when this workflow's sandbox is :local. The
Dir.pwd default is evaluated at read time (in the macro body), so the
working directory is captured when the sandbox is actually resolved, not
at class-definition time. Never read for a :virtual workflow.
87 88 89 |
# File 'lib/nexo/workflow.rb', line 87 def cwd(value = nil) value.nil? ? (@cwd || Dir.pwd) : (@cwd = value) end |
.execute(run, payload:, buffer_events: Nexo.config.buffer_workflow_events, resume_input: {}) ⇒ Object
Executes an already-created run: "running" → #call → "done"/"failed",
flushing buffered events in the ensure (on both success and failure) and
firing a status notification on each transition. Shared by ::run (sync) and
WorkflowJob#perform (async). Re-raises on failure.
payload: is symbol-keyed: ::run passes the caller's original (nested Ruby
values intact); the job passes the JSON-normalized run.payload symbolized.
resume_input: (Spec 13, default {}) is the symbol-keyed input handed to
::resume/::resume_later; it is exposed to #call via #resume_input and
is {} on the first (non-resume) pass. A #call that raises Suspended
(via #suspend!) leaves the run "suspended" (a non-failure outcome) and
returns it — completed #checkpoints persist and are skipped on resume.
293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 |
# File 'lib/nexo/workflow.rb', line 293 def execute(run, payload:, buffer_events: Nexo.config.buffer_workflow_events, resume_input: {}) # Remember a non-default buffering choice on the run so a later resume # (sync or job) honors it instead of silently reverting to the config # default. Only written when buffering is on, so the unbuffered Spec 2 hot # path takes no extra state write (see #resume, which reads it back). persist_buffer_choice(run, buffer_events) run.update!(status: "running") notify_status(run) instance = new(run, buffer_events: buffer_events) instance.instance_variable_set(:@resume_input, resume_input) result = instance.call(payload) run.update!(status: "done", result: stringify_keys(result), **cleared_state(run)) notify_status(run) run rescue Nexo::Workflow::Suspended => s # A durable pause, NOT a failure: mark "suspended", record the suspend # metadata under the reserved "__suspend__" state key (alongside any # completed checkpoints), broadcast the transition, and RETURN the run — # never re-raise. The $!-aware ensure below still flushes buffered events # ($! is nil here, since the signal is caught, not propagating). run.update!(status: "suspended", state: (run.state || {}).merge("__suspend__" => { "reason" => s.reason, "resume_key" => s.resume_key, "at" => Time.now.utc.iso8601 })) notify_status(run) run rescue StandardError, ScriptError, SecurityError => e # The sandbox seam raises OUTSIDE StandardError: SecurityError on a path # escape (Local/Container#absolute) and NotImplementedError (a # ScriptError) from a shell-less sandbox. Catch those too, so an escaping # #stage/#artifact or a Virtual shell marks the run "failed" instead of # leaving it stranded in "running" in a live, healthy process. if run run.update!(status: "failed", error: e.) notify_status(run) end raise ensure # Flush buffered events on both success and failure. $! is the exception # already propagating out of #call (nil on the success path); a flush # failure must never mask that original error, so it is only surfaced # when the workflow itself succeeded. pending = $! begin instance&.flush_events! rescue raise if pending.nil? end # Release the run's sandbox (if one was built) on every terminal path — # done, suspended, or failed — so a container/remote sandbox never leaks. # Best-effort inside #release_sandbox!, so it can't mask +pending+. instance&.release_sandbox! end |
.logs(id) ⇒ Object
Looks up a run by its UUID string id through whichever store
RunStore.default selects, yields each event when a block is given, and
returns the ordered events array. Works identically in plain Ruby and
under Rails.
352 353 354 355 356 |
# File 'lib/nexo/workflow.rb', line 352 def logs(id) run = Nexo::RunStore.default.find(id) run.events.each { |ev| yield ev if block_given? } run.events end |
.reconcile_interrupted! ⇒ Object
One-shot boot/deploy sweep (Spec 7 R6) that rewrites runs orphaned in
"running" to "interrupted" so a crashed worker doesn't leave zombie
runs. Touches only "running" rows — "done" and "failed" are never
rewritten. Under Rails it is a single update_all; offline it iterates the
Memory store. NEVER auto-invoked — call it from a boot hook or the shipped
nexo:reconcile rake task.
This is not a liveness check: it cannot distinguish an orphaned run from
one genuinely running in another process. Run it once at boot, before any
worker starts new runs. Returns the number of runs rewritten and fires a
nexo.workflow.status notification per swept run so a dashboard learns of it.
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
# File 'lib/nexo/workflow.rb', line 111 def reconcile_interrupted! if defined?(::ActiveRecord::Base) && defined?(Nexo::WorkflowRun) ids = Nexo::WorkflowRun.where(status: "running").pluck(:id) Nexo::WorkflowRun.where(id: ids).update_all(status: "interrupted") ids.each { |id| instrument_status(id, "interrupted") } ids.size else running = Nexo::RunStore::Memory.runs.each_value.select { |run| run.status == "running" } running.each do |run| run.update!(status: "interrupted") notify_status(run) end running.size end end |
.resume(run_id, input = {}) ⇒ Object
Continues a "suspended" run synchronously (Spec 13): re-instantiates the
workflow from run.workflow_class and re-runs #call from the top with
the run's original payload, making input available via #resume_input.
This is re-entry, NOT replay — Ruby has no transparent continuation capture. Everything outside a #checkpoint re-runs; only checkpoint-guarded work is skipped (its stored result is returned without re-running the block). Guard every side effect and expensive step with a checkpoint; idempotency of the non-checkpointed code is the author's responsibility.
Loads the run through RunStore.default, so it works with either store —
but durable cross-process resume needs the ActiveRecord store (a Memory
run doesn't survive the process); Memory resume is valid in-process.
Raises Nexo::Error for a run that is not currently "suspended".
225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/nexo/workflow.rb', line 225 def resume(run_id, input = {}) store = Nexo::RunStore.default run = store.find(run_id) unless run.status == "suspended" raise Nexo::Error, "run #{run_id} is not suspended (#{run.status})" end # Atomically claim the run (compare-and-set "suspended" → "running") so a # concurrent resume/resume_later can't both re-enter #call and double-run # the non-checkpointed work. The loser sees the claim fail and stops here. unless store.claim_for_resume!(run) raise Nexo::Error, "run #{run_id} is already being resumed" end # Object.const_get (not String#constantize) so resume works in plain Ruby # without ActiveSupport's core_ext — mirrors Nexo::Session (Spec 10). klass = Object.const_get(run.workflow_class) # Honor the original run's buffering choice (persisted by ::execute) so a # run started with buffer_events: true under a fiber reactor doesn't revert # to per-emit DB writes on resume; falls back to the config default. buffered = (run.state || {}).fetch("__buffer_events__", Nexo.config.buffer_workflow_events) klass.execute(run, payload: symbolize(run.payload), resume_input: input, buffer_events: buffered) end |
.resume_later(run_id, input = {}, queue: Nexo.config.job_queue, wait: nil, wait_until: nil) ⇒ Object
Enqueues a durable, cross-process resume of a "suspended" run (Spec 13 Q4),
mirroring ::run_later: the job carries the run id plus the resume input
(the payload still lives on the run — only the input travels). input must be
ActiveJob/json-serializable. Returns the run (still "suspended" until the
job picks it up and re-enters ::resume's guard).
Requires ActiveJob (Rails): without it this raises Nexo::MissingDependencyError
pointing at ::resume for synchronous execution. queue: (default
Nexo.config.job_queue) routes the job exactly like ::run_later.
wait: / wait_until: (Spec 21) defer the resume via the installed
ActiveJob's own .set(...) — wait: a duration (+resume_later(id, input,
wait: 1.hour)+ so a suspended run wakes itself on a timer), wait_until: an
absolute time. Nexo adds no scheduler and no retry; a crashed scheduled-resume
job remains the host's reconcile_interrupted! / retry_on story. Passing
both raises ArgumentError (the installed ActiveJob would silently keep one),
checked before the job is enqueued. With neither given the enqueue is
byte-for-byte the pre-Spec-21 immediate one. The return value is unchanged: the
run stays "suspended" until the job fires and re-enters ::resume's atomic claim.
266 267 268 269 270 271 272 273 274 275 276 277 278 |
# File 'lib/nexo/workflow.rb', line 266 def resume_later(run_id, input = {}, queue: Nexo.config.job_queue, wait: nil, wait_until: nil) unless defined?(::ActiveJob) raise Nexo::MissingDependencyError, "resume_later requires ActiveJob (Rails). Use `resume` for synchronous execution." end if wait && wait_until raise ArgumentError, "pass either `wait:` or `wait_until:`, not both (got both)" end run = Nexo::RunStore.default.find(run_id) enqueue_job(Nexo::WorkflowJob, queue: queue, wait: wait, wait_until: wait_until).perform_later(run.id, input) run end |
.run(payload = nil, buffer_events: Nexo.config.buffer_workflow_events, **kwargs) ⇒ Object
Runs the workflow end to end: creates a run record (status "pending"),
marks it "running", invokes the subclass's #call with a symbol-keyed
payload, and records the outcome. On success the run is "done" with the
return value as result; on any raised error it is "failed" with the
message as error and the exception is re-raised. Returns the run.
buffer_events: (Spec 5, default Nexo.config.buffer_workflow_events)
controls persistence of the event log. When false each emit persists
immediately (Spec 2 behavior). When true events are buffered in memory
and flushed to the store exactly once — the flush runs in the ensure so
events land on both success and failure. Buffering avoids a blocking
per-event DB write under a fiber reactor.
The payload keeps its Spec 2 shape: it may be passed as an explicit Hash
(+run(1, buffer_events: true)+) or as bare keywords
(+run(doc_id: 1)+) — when no positional Hash is given, the collected
keywords (minus buffer_events:) become the payload. This keeps the
documented Workflow.run(doc_id: …, text: …) form working now that
buffer_events: is a real keyword.
146 147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/nexo/workflow.rb', line 146 def run(payload = nil, buffer_events: Nexo.config.buffer_workflow_events, **kwargs) if payload && !kwargs.empty? raise ArgumentError, "pass the payload either as a positional Hash or as keywords, not both (got both)" end payload ||= kwargs run = Nexo::RunStore.default.create(workflow_class: name, payload: stringify(payload)) # Pass the caller's ORIGINAL symbolized payload (nested Ruby values intact) # — the sync path never crosses a JSON/DB boundary, so it must not read back # the store-normalized run.payload. Only the async job symbolizes run.payload. execute(run, payload: symbolize(payload), buffer_events: buffer_events) end |
.run_later(payload = nil, queue: Nexo.config.job_queue, wait: nil, wait_until: nil, **kwargs) ⇒ Object
Enqueues the workflow on the host's ActiveJob adapter and hands back the run immediately (status "queued") so a controller can return while the work happens in the background. The job carries only the run id — the payload lives on the run record.
Requires ActiveJob (Rails): with no ActiveJob loaded this raises
Nexo::MissingDependencyError pointing at ::run for synchronous execution.
queue: (default Nexo.config.job_queue) routes the job to a named queue;
nil uses ActiveJob's default queue.
It is only meaningful with a shared run store — the AR store plus a real
adapter, so a worker in another process finds the run in the database. Under
the +:inline+/+:test+ adapter the job runs in-process, so the Memory store is
also reachable. Not resumable: a crashed or retried job re-runs #call from
scratch (Nexo adds no retry_on); pair with ::reconcile_interrupted! to
catch runs orphaned in "running".
wait: / wait_until: (Spec 21) defer the enqueue via the installed
ActiveJob's own .set(...) scheduler — wait: takes a duration
(+wait: 1.hour+), wait_until: an absolute time (+wait_until: tomorrow_9am+).
Nexo adds no scheduler of its own; it just forwards these to .set. The run
is still "queued" (no "scheduled" status is invented). Passing both in
one call raises ArgumentError (the installed ActiveJob would silently keep one)
— checked before any run is created. With neither given the enqueue is
byte-for-byte the pre-Spec-21 immediate one (no :at on the job).
+wait:+/+wait_until:+/+queue:+ share the bare-keyword/positional ambiguity
that queue: already carried: a bare-keyword call like run_later(wait: 60)
consumes wait as the scheduling option (the payload stays {}). A payload
that legitimately needs a key literally named "wait" must be passed as an
explicit positional Hash — run_later({wait: "value"}).
190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/nexo/workflow.rb', line 190 def run_later(payload = nil, queue: Nexo.config.job_queue, wait: nil, wait_until: nil, **kwargs) unless defined?(::ActiveJob) raise Nexo::MissingDependencyError, "run_later requires ActiveJob (Rails). Use `run` for synchronous execution." end if wait && wait_until raise ArgumentError, "pass either `wait:` or `wait_until:`, not both (got both)" end if payload && !kwargs.empty? raise ArgumentError, "pass the payload either as a positional Hash or as keywords, not both (got both)" end payload ||= kwargs run = Nexo::RunStore.default.create(workflow_class: name, payload: stringify(payload)) run.update!(status: "queued") notify_status(run) # let a dashboard learn the run was enqueued enqueue_job(Nexo::WorkflowJob, queue: queue, wait: wait, wait_until: wait_until).perform_later(run.id) run end |
.sandbox(value = nil, **opts) ⇒ Object
The sandbox this workflow's runs stage inputs into and write artifacts to
(Spec 7). Follows the same read-vs-write ivar convention as Agent's
macros: with no argument (and no opts) it reads (default :virtual —
safe, in-memory); with a bare value it stores a symbol/instance; with
keywords it stores an options Hash (+{ type: value, **opts }+) — e.g.
sandbox :docker, image: "node:22-slim" (Spec 15). Resolution is lazy —
a data-only workflow that never stages or emits artifacts builds nothing
(see #sandbox).
77 78 79 80 81 |
# File 'lib/nexo/workflow.rb', line 77 def sandbox(value = nil, **opts) return @sandbox || :virtual if value.nil? && opts.empty? @sandbox = opts.empty? ? value : {type: value, **opts} end |
Instance Method Details
#artifact(name, content: nil, from: nil, locals: {}) ⇒ Object
Records a named deliverable on the run (Spec 7 R3). The body comes from
either content: (used verbatim) or from: (a trusted, developer-authored
ERB template — a real disk file when File.exist?(from), else a staged
sandbox path via sandbox.read — rendered with the given locals).
SECURITY: ERB executes arbitrary Ruby. A from: template must be a trusted
file you control — NEVER model output or user-uploaded content. Templates are
code, not data (see README).
The body is written to the sandbox at /artifacts/<name> (so scripts/agents
can read it during the run) and recorded on the run as a string-keyed hash
{"name" =>, "content" =>, "at" =>}, matching how #emit string-keys events
so Memory and the AR json column round-trip identically. Artifacts persist
immediately (never buffered). Raises Nexo::Error when neither content: nor
from: produces a body. Returns the artifact hash.
646 647 648 649 650 651 652 653 654 655 656 657 658 659 |
# File 'lib/nexo/workflow.rb', line 646 def artifact(name, content: nil, from: nil, locals: {}) body = content if from template = File.exist?(from) ? File.read(from) : sandbox.read(from) body = ERB.new(template, trim_mode: "-").result_with_hash(locals) end raise Nexo::Error, "artifact #{name} needs content: or from:" if body.nil? sandbox.write("/artifacts/#{name}", body) art = {"name" => name.to_s, "content" => body, "at" => Time.now.utc.iso8601} @run.push_artifact(art) @run.save_artifacts! if @run.respond_to?(:save_artifacts!) art end |
#call(payload) ⇒ Object
Subclasses implement the work here. The payload is symbol-keyed; the
returned value becomes the run's result (read back string-keyed).
446 447 448 |
# File 'lib/nexo/workflow.rb', line 446 def call(payload) raise NotImplementedError, "#{self.class} must implement #call(payload)" end |
#checkpoint(name) ⇒ Object
Runs +name+'s block once and stores its json-serializable result under
name.to_s in the run's state — the primitive that makes resume cheap and
side-effect-safe (Spec 13). On a later run/resume of the same run a present
checkpoint returns the stored value without re-running the block:
fetched = checkpoint(:fetch) { expensive_api_call(payload[:id]) }
published = checkpoint(:publish) { publish!(fetched) }
Persists immediately (like artifacts, not buffered like events), so a completed checkpoint survives a subsequent #suspend!. Values must be json-serializable — they round-trip the store exactly like +result+/+events+.
Do NOT call #suspend! inside a checkpoint block (undefined — v1 unsupported),
and do NOT name a checkpoint "__suspend__" (reserved for suspend metadata)
or "__approval__" (reserved for the pending approval call — Spec 16).
A crash inside a checkpoint re-runs that checkpoint on resume (at-least-once
for the in-flight step) — guard side effects accordingly.
467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 |
# File 'lib/nexo/workflow.rb', line 467 def checkpoint(name) key = name.to_s store = @run.state || {} return store[key] if store.key?(key) # JSON round-trip the block's value before storing so it reads back the SAME # shape (string keys, no symbols) whether the run stays in the Memory store # or is reloaded from the AR json column on a cross-process resume — and so # the first pass and the resume pass see identical data. Values must be # json-serializable (documented above). value = json_normalize(yield) @run.state = store.merge(key => value) @run.save_state! if @run.respond_to?(:save_state!) value end |
#checkpoint_all(steps) ⇒ Object
Runs several independent checkpoints concurrently on the first pass and, crucially, persists each step as it completes — so a resume after a partial failure only re-runs the steps that never landed (Spec 21):
fetched = checkpoint_all(
account: -> { fetch_account(payload[:id]) },
usage: -> { fetch_usage(payload[:id]) }
)
fetched[:account] # => the account value (this pass or a prior one)
steps is a Hash of name => callable (each value a Proc/lambda); names may
be symbols or strings and are stringified for storage exactly like #checkpoint
(+name.to_s+). The returned Hash is keyed by the original (un-stringified)
names with values read back from @run.state, so a caller gets the same shape
whether a value came from this call or a prior pass.
The pending steps (those not already in @run.state) run through the existing
Nexo.concurrent driver, all in flight at once — callers bound the batch by how
many keys they pass; there is no separate rate knob. Each step persists on its
own through the same read-current → merge → assign → save_state! sequence as
#checkpoint, serialized across the concurrent fibers by an internal Mutex, and
emits a :checkpoint event naming the step (Spec 21 R3). Concurrency (and the
async gem) is touched only when something is pending — an all-persisted
pass returns the prior-pass values directly without requiring async.
Known trade-off: this is per-step persistence, not an atomic batch. If
step B raises after step A persisted, A stays in run.state, B is absent, the
run goes "failed", and the exception propagates through the workflow's normal
failure path (Nexo.concurrent's "first failure re-raises, the rest stop" — not
rescued away). A subsequent ::execute of the SAME run re-submits only the
still-missing names — A is skipped, B re-runs. Do NOT treat a batch as
all-or-nothing.
Same restrictions as #checkpoint: values must be json-serializable (they round-trip the store), a step must NOT be named after a RESERVED_STATE_KEYS entry (raises Nexo::Error before any step runs), and do NOT call #suspend! inside a step (undefined — v1 unsupported, documented not enforced).
520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 |
# File 'lib/nexo/workflow.rb', line 520 def checkpoint_all(steps) if (reserved = steps.keys.find { |name| RESERVED_STATE_KEYS.include?(name.to_s) }) raise Nexo::Error, "checkpoint name #{reserved.to_s.inspect} is reserved (#{RESERVED_STATE_KEYS.join(", ")})" end state = @run.state || {} pending = steps.reject { |name, _| state.key?(name.to_s) } unless pending.empty? Nexo.concurrent(max_in_flight: pending.size) do |c| pending.each { |name, callable| c.add { persist_checkpoint(name, callable.call) } } end end current = @run.state || {} steps.keys.to_h { |name| [name, current[name.to_s]] } end |
#emit(type, data = {}) ⇒ Object
Appends an event to the run's ordered log and persists it incrementally.
The event's own keys ("type"/"data"/"at") are strings so the record reads
back the same shape after the ActiveRecord backend's JSON round-trip; the
caller-supplied data hash is stored verbatim (symbol keys survive the
in-memory store, stringify through the json column). Returns the event hash.
560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 |
# File 'lib/nexo/workflow.rb', line 560 def emit(type, data = {}) ev = {"type" => type.to_s, "data" => data, "at" => Time.now.utc.iso8601} if @buffer_events # Defer the DB hit — accumulate in memory and persist once in # #flush_events! (called from Workflow.run's ensure). @event_buffer << ev else @run.push_event(ev) @run.save_events! if @run.respond_to?(:save_events!) end # Live broadcast (Spec 11 R2): fires regardless of buffering — persistence # stays separate/buffered above, but the notification is live. A no-op without # ActiveSupport, so this is exactly the Spec 2 emit in the plain-Ruby core. notify_event(ev) ev end |
#flush_events! ⇒ Object
Replays any buffered events through the run and persists them in a single
save_events!, then clears the buffer. A no-op when buffering is off or the
buffer is empty. Called from Workflow.run's ensure, so buffered events
are saved on both success and failure. Idempotent (a second call, e.g. if a
workflow calls it explicitly, finds an empty buffer and does nothing).
582 583 584 585 586 587 588 |
# File 'lib/nexo/workflow.rb', line 582 def flush_events! return unless @buffer_events && @event_buffer.any? @event_buffer.each { |ev| @run.push_event(ev) } @run.save_events! if @run.respond_to?(:save_events!) @event_buffer.clear end |
#release_sandbox! ⇒ Object
Releases the run's sandbox at the end of #execute — but ONLY if one was
actually built. Reads @sandbox directly instead of calling #sandbox so a
data-only workflow (which never resolved a sandbox) doesn't construct one
just to close it. Idempotent and best-effort: a container/remote sandbox is
force-removed/closed here so a #run_agent-driven container doesn't leak —
#run_agent BORROWS this shared sandbox, so Agent#close leaves teardown to the
owner (this workflow). A failing teardown must never raise out of #execute.
606 607 608 609 610 |
# File 'lib/nexo/workflow.rb', line 606 def release_sandbox! @sandbox&.close rescue nil end |
#resume_input ⇒ Object
The symbol-keyed input passed to ::resume/::resume_later (Spec 13). It is
{} on the first (non-resume) pass, so a workflow gates on it to decide
whether to #suspend! or proceed:
suspend!(reason: "needs approval") unless resume_input[:approved]
553 |
# File 'lib/nexo/workflow.rb', line 553 def resume_input = @resume_input || {} |
#run_agent(prompt, max_turns: 25) ⇒ Object
Drives the workflow's declared ::agent (Spec 8), bound to this run's
sandbox (Spec 7), forwarding every (type, payload) event the agent's loop
yields into the run log as an agent_* event, and ensuring the agent is
closed afterward (tearing down any memoized MCP servers from Spec 6).
Returns the agent's response object (read response.content).
Composition only — no new loop, no orchestration engine: it wires the
existing Agent#prompt + +before_tool_call+/+after_tool_result+ seam
(source: Loops::RubyLLM) through the existing #emit path, so the events
honor the run's buffer_events setting and persist in both run stores with
no extra wiring.
Shared-sandbox precedence: under run_agent the agent uses the workflow's
sandbox; the agent's own sandbox class macro is ignored (it only applies
when the agent runs standalone via .new.prompt). The agent keeps its own
+permissions+/+skills+/+mcp+/+mcp_allow+ — the workflow provides the where
(sandbox), the agent owns the what (permissions) and how (skills). Driving
an agent never widens its authority; its safe default (+:read_only+) is
untouched. Raises ConfigurationError when no agent is declared.
Durable approval (Spec 16): when the driven agent runs under an :approve
permission gate and hits a sensitive capability with no decision yet, the
gate raises Nexo::ApprovalRequired, which propagates out of the ruby_llm
tool loop (Group 0: the loop does not rescue tool exceptions) and out of
Agent#prompt. run_agent rescues it, records the pending call under the
reserved "__approval__" state key, and #suspend!s the run — so the worker
returns and a host renders "approval pending" from run.state. On
::resume/::resume_later with {approved: …} the decision is threaded into
the agent (via decision:), so the same gate now allows (→ run completes) or
denies (→ the tool returns {error:}, the model adapts, run still completes).
690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 |
# File 'lib/nexo/workflow.rb', line 690 def run_agent(prompt, max_turns: 25) klass = self.class.agent or raise Nexo::ConfigurationError, "#{self.class} has no `agent` declared; add `agent MyAgent`" agent = klass.new(sandbox: sandbox, **agent_decision_kwargs) agent.prompt(prompt, max_turns: max_turns) do |type, payload| emit(:"agent_#{type}", serializable(type, payload)) end rescue Nexo::ApprovalRequired => a # Persist the pending call (immediately, like a checkpoint) so a host can # render the approval prompt, then suspend — the existing Workflow.execute # `rescue Suspended` path marks the run "suspended" and returns it. @run.state = (@run.state || {}).merge( "__approval__" => { "capability" => a.capability.to_s, "tool" => a.detail.to_s, "args" => a.args } ) @run.save_state! if @run.respond_to?(:save_state!) suspend!(reason: "approval: #{a.detail}", resume_key: a.detail.to_s) ensure # Guarded so it's safe if Spec 6 (Agent#close) isn't present in the agent, # or if agent construction itself raised (agent is nil). agent.close if agent.respond_to?(:close) end |
#sandbox ⇒ Object
The run's sandbox (Spec 7), resolved lazily from the class-level ::sandbox macro and memoized. Built on first touch — by #stage, #artifact, or a workflow reading/writing files directly — so a data-only workflow that never calls any of them constructs nothing new and keeps the Spec 2 hot path byte-for-byte unchanged.
595 596 597 |
# File 'lib/nexo/workflow.rb', line 595 def sandbox @sandbox ||= resolve_sandbox(self.class.sandbox) end |
#stage(files) ⇒ Object
Stages provided files into the run's sandbox before work begins (Spec 7 R2).
Accepts either a Hash { "path" => "content" } or an Array of
{ path:, content: } hashes; both normalize to [path, content] pairs.
Each pair is written via sandbox.write. Emits a :staged event carrying
the count (reusing the existing #emit path) and returns the number of
files staged.
618 619 620 621 622 623 624 625 626 627 628 629 |
# File 'lib/nexo/workflow.rb', line 618 def stage(files) pairs = if files.is_a?(Hash) files.to_a else # Tolerate symbol- OR string-keyed hashes (e.g. an Array parsed from JSON), # matching how the Hash form already accepts string paths. files.map { |f| [f[:path] || f["path"], f[:content] || f["content"]] } end pairs.each { |path, content| sandbox.write(path, content) } emit(:staged, count: pairs.size) pairs.size end |
#suspend!(reason:, resume_key: nil) ⇒ Object
Pauses the run durably (Spec 13): raises Suspended, which ::execute catches
to mark the run "suspended" (a non-failure outcome) and return it to the
caller. Call this outside a checkpoint block. reason is surfaced to a
host UI; the optional resume_key is persisted so a host can correlate which
resume it is awaiting. Continue the run later with ::resume/::resume_later.
544 545 546 |
# File 'lib/nexo/workflow.rb', line 544 def suspend!(reason:, resume_key: nil) raise Suspended.new(reason, resume_key) end |