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 +4 -4
- data/CHANGELOG.md +23 -0
- data/docs/concurrency.md +6 -0
- data/docs/durable-workflows.md +46 -0
- data/docs/rails.md +33 -0
- data/lib/nexo/version.rb +1 -1
- data/lib/nexo/workflow.rb +145 -11
- data/sig/nexo/workflow.rbs +14 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7e3ef801100c09633b3bc0eba7f1c5444abc3a28d39b093b3f556a6e6a193304
|
|
4
|
+
data.tar.gz: 770aa194ebef30adc05049890df4611914d9af49ce5bc37460d142dce0a5277d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/docs/durable-workflows.md
CHANGED
|
@@ -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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
414
|
+
return {} unless RESERVED_STATE_KEYS.any? { |k| state.key?(k) }
|
|
364
415
|
|
|
365
|
-
{state: state.except(*
|
|
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.
|
|
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:
|