nexo_ai 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34123b61080f43662220abb5a92686ac3123ef5ae1255a1318f311985a08e304
4
- data.tar.gz: 46fdf315414f80c500cc7fa33794c5a576839c6b67fcbb0e282d2ad327d354fd
3
+ metadata.gz: 7e3ef801100c09633b3bc0eba7f1c5444abc3a28d39b093b3f556a6e6a193304
4
+ data.tar.gz: 770aa194ebef30adc05049890df4611914d9af49ce5bc37460d142dce0a5277d
5
5
  SHA512:
6
- metadata.gz: 6df53f1442c2332977b40a2c3d1024699b2fc7ec9f464714e8dd0f0364dfa906c13c423af438523e7c054f93b61391c2810e44e4bab97a5a087361a82635f993
7
- data.tar.gz: 961b1ce3a8975f2127a2d0a207d93742e674ea233cb549942fe18c39b3cc4b742f61bf7b41fe6eadca815ee863cae956362a18acf9731450089e55bcfbf57204
6
+ metadata.gz: 201531d77a85255355cf77d34d0dd676843f20dee2149627226a1f9068c5b6dfcc7e9e4d92e33b357536326f35e861d9fc1d1d6230bac1e5eac5db9314e3ec75
7
+ data.tar.gz: aff13284d24b6465a10a34b60847d6ceece755b8d8791b688777a6ec7538d7506db82922a9e83d4382a66d3045ee45ee3a4c9eede4d2b8a37e4021e9e134a934
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.0] - 2026-07-16
4
+
5
+ Durability enhancements for workflows: a suspended run can now wake itself on a
6
+ timer, and independent checkpoints run concurrently, each persisting as it
7
+ completes. Both build on the existing ActiveJob and `Nexo.concurrent` seams — no
8
+ schema, run-status, or store change.
9
+
10
+ ### Added
11
+
12
+ - **Scheduled enqueue/resume.** `Workflow.run_later` and `Workflow.resume_later`
13
+ accept `wait:` (a duration) or `wait_until:` (an absolute time), forwarded to the
14
+ installed ActiveJob's own `.set(...)` scheduler — so a suspended run can wake
15
+ itself on a timer and an initial enqueue can be deferred, without Nexo adding a
16
+ scheduler. Passing both raises `ArgumentError`; with neither the enqueue is
17
+ unchanged. Status stays `"queued"`/`"suspended"` (no new status). No retry
18
+ semantics are added.
19
+ - **Parallel checkpoints.** `Workflow#checkpoint_all(name => callable, …)` runs
20
+ independent checkpoints concurrently through the existing `Nexo.concurrent`
21
+ driver, persisting **each step as it completes** so a resume after a partial
22
+ failure re-runs only the still-missing steps. Each newly-completed step emits a
23
+ `"checkpoint"`-typed event (name-only) on the existing `nexo.workflow.event`
24
+ seam. Reuses `state`/`save_state!` — no schema, run-status, or store change.
25
+
3
26
  ## [0.7.0] - 2026-07-10
4
27
 
5
28
  The harness fills out: MCP data sources, a web-content capability, durable
data/docs/concurrency.md CHANGED
@@ -41,6 +41,12 @@ most important knob for staying under provider rate limits.
41
41
  Using `Nexo.concurrent` with `async` not installed raises
42
42
  `Nexo::MissingDependencyError` with install guidance.
43
43
 
44
+ Inside a durable workflow, `Workflow#checkpoint_all` is the workflow-durability
45
+ flavored sibling of `Nexo.concurrent`: it drives this same bounded fan-out but
46
+ persists each step to the run's `state` as it lands, so a resume only re-runs what
47
+ never completed. See [Parallel checkpoints](durable-workflows.md#parallel-checkpoints--checkpoint_all)
48
+ in the durable-workflows guide.
49
+
44
50
  ## `Sandboxes::Local` offload
45
51
 
46
52
  Under a reactor, blocking file/subprocess I/O would stall every other fiber. Flip
@@ -64,6 +64,52 @@ DocumentApproval.resume_later(run.id, { approved: true }, queue: :nexo)
64
64
  See [`examples/approval_workflow.rb`](../examples/approval_workflow.rb) for the full
65
65
  offline flow (`ruby -Ilib examples/approval_workflow.rb`).
66
66
 
67
+ ## Parallel checkpoints — `checkpoint_all`
68
+
69
+ When several checkpoints are **independent** (no step depends on another's
70
+ result), run them concurrently with `checkpoint_all(name => callable, …)` instead
71
+ of a sequence of `checkpoint` calls. It fans the pending steps out through
72
+ [`Nexo.concurrent`](concurrency.md) — all in flight at once — and persists **each
73
+ step as it completes** (not the batch as a whole), so a resume after a partial
74
+ failure only re-runs the steps that never landed:
75
+
76
+ ```ruby
77
+ class BuildDashboard < Nexo::Workflow
78
+ def call(payload)
79
+ data = checkpoint_all(
80
+ account: -> { fetch_account(payload[:id]) }, # these two run
81
+ usage: -> { fetch_usage(payload[:id]) } # concurrently
82
+ )
83
+ { report: render(data[:account], data[:usage]) }
84
+ end
85
+ end
86
+ ```
87
+
88
+ `checkpoint_all` returns a Hash keyed by the **original** names you passed
89
+ (`data[:account]`), with values read back from `state` — the same shape whether a
90
+ value came from this pass or a prior one. Each newly-completed step also surfaces a
91
+ `"checkpoint"`-typed event on the run's event log and the `nexo.workflow.event`
92
+ notification (data is the step **name** only, never the value — so a dashboard can
93
+ show batch progress without the event log carrying large or sensitive results).
94
+ Steps already present in `state` are skipped silently and emit nothing.
95
+
96
+ Bound the batch by how many keys you pass — there is no separate rate knob; every
97
+ pending step goes in flight. Because it drives `Nexo.concurrent`, `checkpoint_all`
98
+ needs the `async` gem **only when something is actually pending** — an
99
+ all-persisted pass (every step already done) returns the prior values directly
100
+ without touching concurrency. The same restrictions as `checkpoint` apply: values
101
+ must be json-serializable, a step must **not** be named after a reserved state key
102
+ (`__suspend__`/`__approval__`/`__buffer_events__` — raises `Nexo::Error` before any
103
+ step runs), and do **not** call `suspend!` inside a step (undefined — unsupported).
104
+
105
+ > **⚠️ Known trade-off: per-step persistence, not an atomic batch.** `checkpoint_all`
106
+ > is **not** transactional. If step B raises after step A persisted, A stays in
107
+ > `state`, B is absent, the run goes `"failed"`, and the exception propagates through
108
+ > the workflow's normal failure path (`Nexo.concurrent`'s "first failure re-raises,
109
+ > the rest stop" — it is not rescued away). A subsequent `execute` of the **same** run
110
+ > re-submits only the still-missing names — A is skipped, B re-runs. Do **not** treat
111
+ > a batch as all-or-nothing.
112
+
67
113
  ## Durable **agent** approval — `:approve` (bridge a mid-run gate to a suspend)
68
114
 
69
115
  The example above suspends at an **explicit** `suspend!` the workflow author placed.
data/docs/rails.md CHANGED
@@ -35,6 +35,39 @@ GenerateReport.run_later(account_id: 42, queue: :nexo) # per-call
35
35
  Nexo.configure { |c| c.job_queue = :nexo } # or a global default
36
36
  ```
37
37
 
38
+ ### Scheduling a future run or resume
39
+
40
+ `run_later` and `resume_later` accept `wait:` (a duration) or `wait_until:` (an
41
+ absolute time), forwarded straight to **the installed ActiveJob's own**
42
+ `.set(...)` scheduler — Nexo adds no scheduler of its own. Use them to defer an
43
+ initial enqueue ("send this digest at 9am") or to let a suspended run wake itself
44
+ on a timer, symmetrically:
45
+
46
+ ```ruby
47
+ # Defer the initial enqueue until tomorrow morning.
48
+ DailyDigest.run_later({account_id: 42}, wait_until: Date.tomorrow.noon)
49
+
50
+ # Let a suspended run wake itself up in an hour (no separate scheduled job).
51
+ MyWorkflow.resume_later(run.id, {reminder: true}, wait: 1.hour)
52
+ ```
53
+
54
+ The run's status is unchanged — a scheduled `run_later` is still `"queued"` (no
55
+ `"scheduled"` status is invented), and a scheduled `resume_later` leaves the run
56
+ `"suspended"` until the job fires. Passing **both** `wait:` and `wait_until:` in
57
+ one call raises `ArgumentError` (checked before any run is created or job
58
+ enqueued) — the installed ActiveJob would otherwise silently keep just one. With
59
+ neither given, the enqueue is byte-for-byte the immediate one above.
60
+
61
+ > **⚠️ `wait:`/`wait_until:`/`queue:` are scheduling options, not payload.** Like
62
+ > `queue:` already was, a bare-keyword call consumes them as options: `run_later(wait:
63
+ > 60)` schedules the job **60 seconds out** and leaves the payload `{}` — it does
64
+ > **not** store `"wait" => 60` as data. A payload that legitimately needs a key named
65
+ > `"wait"` must be passed as an explicit positional Hash: `run_later({wait: "value"})`.
66
+
67
+ This is still "no scheduler, no cron" — `wait:`/`wait_until:` schedule a **single**
68
+ future run/resume via ActiveJob; recurring schedules stay the host's (see the "no
69
+ queue and no scheduler" note below).
70
+
38
71
  Nexo ships **no queue and no scheduler** — ActiveJob uses whatever adapter your
39
72
  app configured (Sidekiq, GoodJob, Solid Queue, …), and scheduling (cron / GoodJob
40
73
  / `whenever`) stays the host's. Without ActiveJob, `run_later` raises
data/lib/nexo/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Nexo
4
4
  # The gem version (also used as +spec.version+ in the gemspec).
5
- VERSION = "0.7.0"
5
+ VERSION = "0.8.0"
6
6
  end
data/lib/nexo/workflow.rb CHANGED
@@ -40,6 +40,13 @@ module Nexo
40
40
  # expensive/side-effectful steps so resume skips already-paid-for work — see the
41
41
  # "Durable workflows" README section for the honest resume semantics.
42
42
  class Workflow
43
+ # State keys Nexo reserves for lifecycle metadata, never a caller's data:
44
+ # +"__suspend__"+ (suspend reason/resume_key — Spec 13), +"__approval__"+
45
+ # (pending approval call — Spec 16), and +"__buffer_events__"+ (the persisted
46
+ # buffering choice — Spec 5). ::cleared_state strips these when a run reaches
47
+ # +"done"+; #checkpoint_all refuses a step named after any of them (Spec 21).
48
+ RESERVED_STATE_KEYS = %w[__suspend__ __approval__ __buffer_events__].freeze
49
+
43
50
  # Control-flow signal raised by #suspend! and caught by ::execute to pause
44
51
  # a run durably — NOT a failure. It transitions the run to +"suspended"+
45
52
  # (never +"failed"+) and returns the run to the caller rather than re-raising.
@@ -165,11 +172,30 @@ module Nexo
165
172
  # also reachable. Not resumable: a crashed or retried job re-runs +#call+ from
166
173
  # scratch (Nexo adds no +retry_on+); pair with ::reconcile_interrupted! to
167
174
  # catch runs orphaned in +"running"+.
168
- def run_later(payload = nil, queue: Nexo.config.job_queue, **kwargs)
175
+ #
176
+ # +wait:+ / +wait_until:+ (Spec 21) defer the enqueue via the installed
177
+ # ActiveJob's own +.set(...)+ scheduler — +wait:+ takes a duration
178
+ # (+wait: 1.hour+), +wait_until:+ an absolute time (+wait_until: tomorrow_9am+).
179
+ # Nexo adds no scheduler of its own; it just forwards these to +.set+. The run
180
+ # is still +"queued"+ (no +"scheduled"+ status is invented). Passing **both** in
181
+ # one call raises ArgumentError (the installed ActiveJob would silently keep one)
182
+ # — checked before any run is created. With neither given the enqueue is
183
+ # byte-for-byte the pre-Spec-21 immediate one (no +:at+ on the job).
184
+ #
185
+ # +wait:+/+wait_until:+/+queue:+ share the bare-keyword/positional ambiguity
186
+ # that +queue:+ already carried: a bare-keyword call like +run_later(wait: 60)+
187
+ # consumes +wait+ as the scheduling option (the payload stays +{}+). A payload
188
+ # that legitimately needs a key literally named +"wait"+ must be passed as an
189
+ # explicit positional Hash — +run_later({wait: "value"})+.
190
+ def run_later(payload = nil, queue: Nexo.config.job_queue, wait: nil, wait_until: nil, **kwargs)
169
191
  unless defined?(::ActiveJob)
170
192
  raise Nexo::MissingDependencyError,
171
193
  "run_later requires ActiveJob (Rails). Use `run` for synchronous execution."
172
194
  end
195
+ if wait && wait_until
196
+ raise ArgumentError,
197
+ "pass either `wait:` or `wait_until:`, not both (got both)"
198
+ end
173
199
  if payload && !kwargs.empty?
174
200
  raise ArgumentError,
175
201
  "pass the payload either as a positional Hash or as keywords, not both (got both)"
@@ -178,9 +204,7 @@ module Nexo
178
204
  run = Nexo::RunStore.default.create(workflow_class: name, payload: stringify(payload))
179
205
  run.update!(status: "queued")
180
206
  notify_status(run) # let a dashboard learn the run was enqueued
181
- job = Nexo::WorkflowJob
182
- job = job.set(queue: queue) if queue
183
- job.perform_later(run.id)
207
+ enqueue_job(Nexo::WorkflowJob, queue: queue, wait: wait, wait_until: wait_until).perform_later(run.id)
184
208
  run
185
209
  end
186
210
 
@@ -229,15 +253,27 @@ module Nexo
229
253
  # Requires ActiveJob (Rails): without it this raises Nexo::MissingDependencyError
230
254
  # pointing at ::resume for synchronous execution. +queue:+ (default
231
255
  # Nexo.config.job_queue) routes the job exactly like ::run_later.
232
- def resume_later(run_id, input = {}, queue: Nexo.config.job_queue)
256
+ #
257
+ # +wait:+ / +wait_until:+ (Spec 21) defer the resume via the installed
258
+ # ActiveJob's own +.set(...)+ — +wait:+ a duration (+resume_later(id, input,
259
+ # wait: 1.hour)+ so a suspended run wakes itself on a timer), +wait_until:+ an
260
+ # absolute time. Nexo adds no scheduler and no retry; a crashed scheduled-resume
261
+ # job remains the host's +reconcile_interrupted!+ / +retry_on+ story. Passing
262
+ # **both** raises ArgumentError (the installed ActiveJob would silently keep one),
263
+ # checked before the job is enqueued. With neither given the enqueue is
264
+ # byte-for-byte the pre-Spec-21 immediate one. The return value is unchanged: the
265
+ # run stays +"suspended"+ until the job fires and re-enters ::resume's atomic claim.
266
+ def resume_later(run_id, input = {}, queue: Nexo.config.job_queue, wait: nil, wait_until: nil)
233
267
  unless defined?(::ActiveJob)
234
268
  raise Nexo::MissingDependencyError,
235
269
  "resume_later requires ActiveJob (Rails). Use `resume` for synchronous execution."
236
270
  end
271
+ if wait && wait_until
272
+ raise ArgumentError,
273
+ "pass either `wait:` or `wait_until:`, not both (got both)"
274
+ end
237
275
  run = Nexo::RunStore.default.find(run_id)
238
- job = Nexo::WorkflowJob
239
- job = job.set(queue: queue) if queue
240
- job.perform_later(run.id, input)
276
+ enqueue_job(Nexo::WorkflowJob, queue: queue, wait: wait, wait_until: wait_until).perform_later(run.id, input)
241
277
  run
242
278
  end
243
279
 
@@ -341,6 +377,22 @@ module Nexo
341
377
  )
342
378
  end
343
379
 
380
+ # Builds the ActiveJob dispatch for ::run_later/::resume_later, forwarding
381
+ # only the non-nil scheduling options to the installed ActiveJob's own
382
+ # +.set(...)+ (Spec 21). The options Hash is accumulated conditionally so that
383
+ # with no +queue+/+wait+/+wait_until+ there is no +.set+ call at all — keeping
384
+ # the no-scheduling-option enqueue byte-for-byte identical to the pre-Spec-21
385
+ # path (the job carries no +:at+). +wait:+ and +wait_until:+ are never both
386
+ # present here (the callers raise ArgumentError first), so ActiveJob never has
387
+ # to silently pick between them.
388
+ def enqueue_job(job, queue: nil, wait: nil, wait_until: nil)
389
+ options = {}
390
+ options[:queue] = queue if queue
391
+ options[:wait] = wait if wait
392
+ options[:wait_until] = wait_until if wait_until
393
+ options.empty? ? job : job.set(**options)
394
+ end
395
+
344
396
  # Persists a +true+ buffering choice under the reserved "__buffer_events__"
345
397
  # state key (idempotent — written once) so ::resume can honor it. The
346
398
  # unbuffered default writes nothing, keeping the Spec 2 hot path untouched.
@@ -358,11 +410,10 @@ module Nexo
358
410
  def cleared_state(run)
359
411
  return {} unless run.respond_to?(:state)
360
412
 
361
- reserved = %w[__suspend__ __approval__ __buffer_events__]
362
413
  state = run.state || {}
363
- return {} unless reserved.any? { |k| state.key?(k) }
414
+ return {} unless RESERVED_STATE_KEYS.any? { |k| state.key?(k) }
364
415
 
365
- {state: state.except(*reserved)}
416
+ {state: state.except(*RESERVED_STATE_KEYS)}
366
417
  end
367
418
 
368
419
  def stringify(hash) = hash.transform_keys(&:to_s)
@@ -383,6 +434,11 @@ module Nexo
383
434
  @run = run
384
435
  @buffer_events = buffer_events
385
436
  @event_buffer = []
437
+ # Serializes the read-current → merge → assign → save_state! sequence across
438
+ # the concurrent fibers #checkpoint_all runs each step on (Spec 21). A plain
439
+ # Mutex is fiber-safe under the async reactor (Group 0 verified it serializes
440
+ # without stalling); #checkpoint (singular, no concurrency) does not use it.
441
+ @checkpoint_mutex = Mutex.new
386
442
  end
387
443
 
388
444
  # Subclasses implement the work here. The +payload+ is symbol-keyed; the
@@ -424,6 +480,62 @@ module Nexo
424
480
  value
425
481
  end
426
482
 
483
+ # Runs several independent checkpoints **concurrently** on the first pass and,
484
+ # crucially, persists **each step as it completes** — so a resume after a
485
+ # partial failure only re-runs the steps that never landed (Spec 21):
486
+ #
487
+ # fetched = checkpoint_all(
488
+ # account: -> { fetch_account(payload[:id]) },
489
+ # usage: -> { fetch_usage(payload[:id]) }
490
+ # )
491
+ # fetched[:account] # => the account value (this pass or a prior one)
492
+ #
493
+ # +steps+ is a Hash of +name => callable+ (each value a Proc/lambda); names may
494
+ # be symbols or strings and are stringified for storage exactly like #checkpoint
495
+ # (+name.to_s+). The returned Hash is keyed by the **original** (un-stringified)
496
+ # names with values read back from +@run.state+, so a caller gets the same shape
497
+ # whether a value came from this call or a prior pass.
498
+ #
499
+ # The pending steps (those not already in +@run.state+) run through the existing
500
+ # Nexo.concurrent driver, all in flight at once — callers bound the batch by how
501
+ # many keys they pass; there is no separate rate knob. Each step persists on its
502
+ # own through the same read-current → merge → assign → +save_state!+ sequence as
503
+ # #checkpoint, serialized across the concurrent fibers by an internal Mutex, and
504
+ # emits a +:checkpoint+ event naming the step (Spec 21 R3). Concurrency (and the
505
+ # +async+ gem) is touched **only** when something is pending — an all-persisted
506
+ # pass returns the prior-pass values directly without requiring +async+.
507
+ #
508
+ # Known trade-off: this is per-step persistence, **not** an atomic batch. If
509
+ # step B raises after step A persisted, A stays in +run.state+, B is absent, the
510
+ # run goes +"failed"+, and the exception propagates through the workflow's normal
511
+ # failure path (Nexo.concurrent's "first failure re-raises, the rest stop" — not
512
+ # rescued away). A subsequent ::execute of the SAME run re-submits only the
513
+ # still-missing names — A is skipped, B re-runs. Do NOT treat a batch as
514
+ # all-or-nothing.
515
+ #
516
+ # Same restrictions as #checkpoint: values must be json-serializable (they
517
+ # round-trip the store), a step must NOT be named after a RESERVED_STATE_KEYS
518
+ # entry (raises Nexo::Error before any step runs), and do NOT call #suspend!
519
+ # inside a step (undefined — v1 unsupported, documented not enforced).
520
+ def checkpoint_all(steps)
521
+ if (reserved = steps.keys.find { |name| RESERVED_STATE_KEYS.include?(name.to_s) })
522
+ raise Nexo::Error,
523
+ "checkpoint name #{reserved.to_s.inspect} is reserved (#{RESERVED_STATE_KEYS.join(", ")})"
524
+ end
525
+
526
+ state = @run.state || {}
527
+ pending = steps.reject { |name, _| state.key?(name.to_s) }
528
+
529
+ unless pending.empty?
530
+ Nexo.concurrent(max_in_flight: pending.size) do |c|
531
+ pending.each { |name, callable| c.add { persist_checkpoint(name, callable.call) } }
532
+ end
533
+ end
534
+
535
+ current = @run.state || {}
536
+ steps.keys.to_h { |name| [name, current[name.to_s]] }
537
+ end
538
+
427
539
  # Pauses the run durably (Spec 13): raises Suspended, which ::execute catches
428
540
  # to mark the run +"suspended"+ (a non-failure outcome) and return it to the
429
541
  # caller. Call this **outside** a checkpoint block. +reason+ is surfaced to a
@@ -615,6 +727,28 @@ module Nexo
615
727
  d.key?(:approved) ? {decision: {approved: !!d[:approved]}} : {}
616
728
  end
617
729
 
730
+ # Persists ONE completed #checkpoint_all step (Spec 21) — json_normalizes the
731
+ # raw value, then, inside +@checkpoint_mutex.synchronize+, re-reads the current
732
+ # +@run.state+, merges the single key, reassigns, and +save_state!+s it: the
733
+ # identical read-current → merge → assign → save sequence as #checkpoint, but
734
+ # serialized across the concurrent fibers so two steps landing at once can't
735
+ # clobber each other's merge. The +:checkpoint+ event is emitted **inside** the
736
+ # same synchronized block, alongside the state write, so the event-log append
737
+ # (which mutates the shared buffer / run) serializes with it too. Only reached
738
+ # for genuinely pending steps, so a skipped (already-persisted) step emits
739
+ # nothing. Returns the normalized value.
740
+ def persist_checkpoint(name, raw_value)
741
+ key = name.to_s
742
+ value = json_normalize(raw_value)
743
+ @checkpoint_mutex.synchronize do
744
+ store = @run.state || {}
745
+ @run.state = store.merge(key => value)
746
+ @run.save_state! if @run.respond_to?(:save_state!)
747
+ emit(:checkpoint, name: key)
748
+ end
749
+ value
750
+ end
751
+
618
752
  # Coerces a checkpoint value into exactly what it would become after a round
619
753
  # trip through the AR json column (string keys, symbol values → strings), so
620
754
  # the Memory store and the AR store return identical data on resume. A
@@ -0,0 +1,14 @@
1
+ # Partial RBS for the Spec 21 surface only (RBS reopening) — the scheduled
2
+ # enqueue/resume keywords on the class methods and the new instance
3
+ # #checkpoint_all. The rest of Workflow stays unsigned, matching today's
4
+ # partial RBS coverage.
5
+ module Nexo
6
+ class Workflow
7
+ RESERVED_STATE_KEYS: Array[String]
8
+
9
+ def self.run_later: (?untyped payload, ?queue: untyped, ?wait: untyped, ?wait_until: untyped, **untyped) -> untyped
10
+ def self.resume_later: (untyped run_id, ?untyped input, ?queue: untyped, ?wait: untyped, ?wait_until: untyped) -> untyped
11
+
12
+ def checkpoint_all: (Hash[untyped, untyped] steps) -> Hash[untyped, untyped]
13
+ end
14
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nexo_ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mario Alberto Chávez
@@ -222,6 +222,7 @@ files:
222
222
  - sig/nexo/sandboxes/local.rbs
223
223
  - sig/nexo/sandboxes/virtual.rbs
224
224
  - sig/nexo/tools.rbs
225
+ - sig/nexo/workflow.rbs
225
226
  - sig/nexo_ai.rbs
226
227
  homepage: https://maquina.app
227
228
  licenses: