nexo_ai 0.9.0 → 0.10.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 +17 -0
- data/docs/workflows.md +28 -0
- data/lib/nexo/configuration.rb +9 -0
- data/lib/nexo/run_store.rb +147 -4
- data/lib/nexo/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8815820e94035d1d5c68defa4e862111c95572852098b735e885eaf98b6a3407
|
|
4
|
+
data.tar.gz: 57ca3d98c0d1acd6877276e9322715a7e003928b2da258866b1e7ccabcf33b29
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ad1a55220bcddd2ea1e2d06ea2bc2c680ae4f2a52c1cd8f24e0942f66ad38e38c6b1dc992fcc766b720400083bb07b500feaae9285fbb671e203bfa3f46e0f4
|
|
7
|
+
data.tar.gz: cea3ce8167dae3b4e44a5d87c84e42f04a333c7cd7e5aae8ad236133b821ba10fd3688e63851035d2c1b494b9c533807fa29bfbfd1c3439ed8c8ce6473fe1e52
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.10.0] - 2026-08-20
|
|
4
|
+
|
|
5
|
+
Durable workflows without a database.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `Nexo.config.run_store` — set a store instance and it wins over the automatic
|
|
10
|
+
choice (ActiveRecord under Rails, in-memory otherwise).
|
|
11
|
+
- `Nexo::RunStore::Disk` — a file-backed store, one JSON document per run under a
|
|
12
|
+
directory, written atomically (temp file + rename). This is what makes `checkpoint`,
|
|
13
|
+
`suspend!`/`resume` and a run's recorded artifacts available to a host with no
|
|
14
|
+
database: all of them read state back from the store, and the in-memory one dies
|
|
15
|
+
with the process. `Disk::Run` subclasses `Memory::Run`, so the run shape and every
|
|
16
|
+
read helper are shared and host code behaves identically on either store. `#all`
|
|
17
|
+
lists the directory newest-first. `claim_for_resume!` re-reads from disk so a stale
|
|
18
|
+
copy cannot win a claim; it is atomic within a process, not across machines.
|
|
19
|
+
|
|
3
20
|
## [0.9.0] - 2026-08-20
|
|
4
21
|
|
|
5
22
|
Skills and sandboxes learn to talk about the environment, and an agent's output finally
|
data/docs/workflows.md
CHANGED
|
@@ -82,6 +82,34 @@ ActiveRecord when it is available and the in-memory store otherwise. The schema
|
|
|
82
82
|
uses portable `json` columns (SQLite and PostgreSQL alike) and a UUID string
|
|
83
83
|
primary key.
|
|
84
84
|
|
|
85
|
+
### Durable without a database — `RunStore::Disk`
|
|
86
|
+
|
|
87
|
+
The in-memory store dies with the process, and **everything durable reads state
|
|
88
|
+
back from the store**: `checkpoint`, `suspend!`/`resume`, and a run's recorded
|
|
89
|
+
artifacts. So for a CLI or a script — no Rails, no database, but a process that
|
|
90
|
+
ends — point the store at a directory:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
Nexo.config.run_store = Nexo::RunStore::Disk.new(dir: "~/.local/state/myapp/runs")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
One JSON document per run, rewritten whenever the run changes, written to a temp
|
|
97
|
+
file and renamed so a crash cannot leave a truncated document behind. The run shape
|
|
98
|
+
and every read helper are Memory's — `Disk::Run` subclasses it — so host code
|
|
99
|
+
written against one store behaves identically on the other. `#all` lists the
|
|
100
|
+
directory newest-first, which is what a host wants for *"what did the last run
|
|
101
|
+
produce?"*.
|
|
102
|
+
|
|
103
|
+
A configured store wins over the automatic choice, including under Rails.
|
|
104
|
+
|
|
105
|
+
**Scope, honestly.** Rewriting the whole document per change suits the run sizes this
|
|
106
|
+
is meant for (a CLI's own history) and is wrong for a busy multi-worker queue — that
|
|
107
|
+
is what the ActiveRecord backend is for. `claim_for_resume!` re-reads from disk, so a
|
|
108
|
+
stale in-memory copy cannot win a claim, but it is atomic **within** a process only.
|
|
109
|
+
Runs are never pruned: retention is the host's policy and `dir` is a plain directory
|
|
110
|
+
it can manage. `Workflow.reconcile_interrupted!` sweeps the Memory and ActiveRecord
|
|
111
|
+
stores only.
|
|
112
|
+
|
|
85
113
|
## Input staging and artifacts
|
|
86
114
|
|
|
87
115
|
A run owns a **sandbox** — declared with the `sandbox` class macro (default
|
data/lib/nexo/configuration.rb
CHANGED
|
@@ -57,6 +57,14 @@ module Nexo
|
|
|
57
57
|
# this only gates the opt-in Turbo mirror.
|
|
58
58
|
attr_accessor :broadcast_events
|
|
59
59
|
|
|
60
|
+
# The run store Workflow persists runs through, default +nil+ → the automatic
|
|
61
|
+
# choice (ActiveRecord under Rails, in-memory otherwise). Set an instance to
|
|
62
|
+
# override, which is what a host outside Rails needs for anything durable:
|
|
63
|
+
# checkpoint/suspend/resume all read state back from the store, and the
|
|
64
|
+
# in-memory one dies with the process. +Nexo::RunStore::Disk.new(dir:)+ is the
|
|
65
|
+
# shipped answer for a CLI or a script.
|
|
66
|
+
attr_accessor :run_store
|
|
67
|
+
|
|
60
68
|
# The ActiveJob queue Workflow.run_later enqueues onto (Spec 11 R1), default
|
|
61
69
|
# +nil+ → ActiveJob's default queue. Set to a symbol (e.g. +:nexo+) to route
|
|
62
70
|
# workflow jobs to a dedicated queue.
|
|
@@ -74,6 +82,7 @@ module Nexo
|
|
|
74
82
|
@buffer_workflow_events = false
|
|
75
83
|
@session_chat_model = "Chat" # ruby_llm's own default acts_as_chat host
|
|
76
84
|
@broadcast_events = false # safe default: opt in to the Turbo mirror
|
|
85
|
+
@run_store = nil # nil → the automatic choice (ActiveRecord under Rails, else Memory)
|
|
77
86
|
@job_queue = nil # nil → ActiveJob's default queue
|
|
78
87
|
end
|
|
79
88
|
|
data/lib/nexo/run_store.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
3
6
|
module Nexo
|
|
4
7
|
# Storage seam for Workflow runs. Two interchangeable backends expose an
|
|
5
8
|
# identical create/find interface and return run objects responding to the
|
|
@@ -11,11 +14,19 @@ module Nexo
|
|
|
11
14
|
# +push_event(event)+, +save_events!+, +push_artifact(artifact)+,
|
|
12
15
|
# +save_artifacts!+, and +save_state!+ (Spec 13).
|
|
13
16
|
module RunStore
|
|
14
|
-
# Selects a backend:
|
|
15
|
-
# and Nexo::WorkflowRun are
|
|
16
|
-
#
|
|
17
|
-
# path never references
|
|
17
|
+
# Selects a backend: +Nexo.config.run_store+ when a host set one, else the
|
|
18
|
+
# ActiveRecord store when both ::ActiveRecord::Base and Nexo::WorkflowRun are
|
|
19
|
+
# defined (the Rails path), otherwise the in-memory store. With no Rails loaded
|
|
20
|
+
# the AR check short-circuits, so the plain-Ruby path never references
|
|
21
|
+
# ActiveRecord.
|
|
22
|
+
#
|
|
23
|
+
# The configured store wins over both, which is the only way a non-Rails host
|
|
24
|
+
# gets durability: everything that survives a process — checkpoint, suspend,
|
|
25
|
+
# resume, a run's recorded artifacts — is read back from the store, and Memory
|
|
26
|
+
# dies with the process. See Disk.
|
|
18
27
|
def self.default
|
|
28
|
+
return Nexo.config.run_store if Nexo.config.run_store
|
|
29
|
+
|
|
19
30
|
if defined?(::ActiveRecord::Base) && defined?(Nexo::WorkflowRun)
|
|
20
31
|
ActiveRecord.new
|
|
21
32
|
else
|
|
@@ -158,5 +169,137 @@ module Nexo
|
|
|
158
169
|
.update_all(status: "running") == 1
|
|
159
170
|
end
|
|
160
171
|
end
|
|
172
|
+
|
|
173
|
+
# File-backed backend for a host with no database: one JSON document per run
|
|
174
|
+
# under +dir+, rewritten whenever the run changes. This is what makes durability
|
|
175
|
+
# available outside Rails — checkpoint, suspend/resume and a run's recorded
|
|
176
|
+
# artifacts all read state back from the store, and Memory dies with the process.
|
|
177
|
+
#
|
|
178
|
+
# Nexo.config.run_store = Nexo::RunStore::Disk.new(dir: "~/.local/state/myapp/runs")
|
|
179
|
+
#
|
|
180
|
+
# The Run is Memory's, subclassed: the shape and every read helper are shared, so
|
|
181
|
+
# a host written against one store behaves identically on the other. Only the
|
|
182
|
+
# +save_*+ hooks differ — they write the document. +push_event+/+push_artifact+
|
|
183
|
+
# deliberately do NOT, because Workflow always calls the matching +save_*+ right
|
|
184
|
+
# after, and persisting twice would double the writes for nothing.
|
|
185
|
+
#
|
|
186
|
+
# Scope, honestly: rewriting the whole document per change is fine for the run
|
|
187
|
+
# sizes this is meant for (a CLI's own history) and wrong for a busy multi-worker
|
|
188
|
+
# queue — that is what the ActiveRecord backend is for. +claim_for_resume!+ is
|
|
189
|
+
# atomic within a process but NOT across processes; two machines resuming the
|
|
190
|
+
# same run concurrently is out of scope. Runs are never pruned here: retention is
|
|
191
|
+
# the host's policy, and +dir+ is a plain directory it can manage.
|
|
192
|
+
class Disk
|
|
193
|
+
# A Memory::Run that writes itself to +path+ whenever Workflow saves it.
|
|
194
|
+
class Run < Memory::Run
|
|
195
|
+
# Absolute path of this run's JSON document. Assigned by the store.
|
|
196
|
+
attr_accessor :path
|
|
197
|
+
|
|
198
|
+
# The three save hooks Workflow calls after mutating a run. Memory's are
|
|
199
|
+
# no-ops because the Struct already holds everything; here they are the
|
|
200
|
+
# whole point.
|
|
201
|
+
def save_events! = persist!
|
|
202
|
+
|
|
203
|
+
# Writes the run after Workflow appended an artifact.
|
|
204
|
+
def save_artifacts! = persist!
|
|
205
|
+
|
|
206
|
+
# Writes the run after Workflow stored checkpoint/suspend state.
|
|
207
|
+
def save_state! = persist!
|
|
208
|
+
|
|
209
|
+
# Assigns attributes (Memory's behaviour) and writes the run.
|
|
210
|
+
def update!(attrs)
|
|
211
|
+
super
|
|
212
|
+
persist!
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Writes the document atomically — a temp file in the same directory, then
|
|
216
|
+
# a rename — so a crash mid-write cannot leave a truncated run behind.
|
|
217
|
+
def persist!
|
|
218
|
+
return if path.nil?
|
|
219
|
+
|
|
220
|
+
tmp = "#{path}.#{Process.pid}.tmp"
|
|
221
|
+
::File.write(tmp, JSON.generate(to_h.except(:path)))
|
|
222
|
+
::File.rename(tmp, path)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Serializes writes within the process, mirroring Memory's store-wide lock.
|
|
227
|
+
MUTEX = Mutex.new
|
|
228
|
+
|
|
229
|
+
# +dir+ is created on demand; +~+ is expanded.
|
|
230
|
+
def initialize(dir:)
|
|
231
|
+
@dir = ::File.expand_path(dir)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# The directory runs are written to.
|
|
235
|
+
attr_reader :dir
|
|
236
|
+
|
|
237
|
+
# Builds a fresh +"pending"+ Run, writes it, and returns it.
|
|
238
|
+
def create(workflow_class:, payload:)
|
|
239
|
+
run = Run.new(
|
|
240
|
+
id: Nexo.generate_run_id, workflow_class: workflow_class, status: "pending",
|
|
241
|
+
payload: payload, result: nil, error: nil, events: [], artifacts: [], state: {}
|
|
242
|
+
)
|
|
243
|
+
::FileUtils.mkdir_p(@dir)
|
|
244
|
+
run.path = path_for(run.id)
|
|
245
|
+
run.persist!
|
|
246
|
+
run
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Reads a run back by id. A miss raises KeyError, matching Memory.
|
|
250
|
+
def find(id)
|
|
251
|
+
path = path_for(id)
|
|
252
|
+
raise KeyError, "run not found: #{id}" unless ::File.exist?(path)
|
|
253
|
+
|
|
254
|
+
hydrate(JSON.parse(::File.read(path)), path)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Every run in the directory, newest-written first. Not part of the store
|
|
258
|
+
# contract Workflow calls — it is here because a host with a run directory
|
|
259
|
+
# inevitably wants to list it ("what did the last run produce?").
|
|
260
|
+
def all
|
|
261
|
+
return [] unless ::File.directory?(@dir)
|
|
262
|
+
|
|
263
|
+
Dir.glob(::File.join(@dir, "*.json"))
|
|
264
|
+
.sort_by { |p| -::File.mtime(p).to_f }
|
|
265
|
+
.filter_map do |p|
|
|
266
|
+
hydrate(JSON.parse(::File.read(p)), p)
|
|
267
|
+
rescue JSON::ParserError, SystemCallError
|
|
268
|
+
# A half-written or hand-edited document must not break the listing.
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Atomically claims a +"suspended"+ run for resume, re-reading from disk so a
|
|
274
|
+
# stale in-memory copy cannot win. Within one process only — see the class
|
|
275
|
+
# note.
|
|
276
|
+
def claim_for_resume!(run)
|
|
277
|
+
MUTEX.synchronize do
|
|
278
|
+
current = find(run.id)
|
|
279
|
+
return false unless current.status == "suspended"
|
|
280
|
+
|
|
281
|
+
run.status = "running"
|
|
282
|
+
run.path ||= path_for(run.id)
|
|
283
|
+
run.persist!
|
|
284
|
+
true
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
private
|
|
289
|
+
|
|
290
|
+
def path_for(id) = ::File.join(@dir, "#{id}.json")
|
|
291
|
+
|
|
292
|
+
# Rebuilds a Run from its document. Missing members read as nil/empty rather
|
|
293
|
+
# than raising, so a document written by an older version still loads.
|
|
294
|
+
def hydrate(data, path)
|
|
295
|
+
run = Run.new(
|
|
296
|
+
id: data["id"], workflow_class: data["workflow_class"], status: data["status"],
|
|
297
|
+
payload: data["payload"], result: data["result"], error: data["error"],
|
|
298
|
+
events: data["events"] || [], artifacts: data["artifacts"] || [], state: data["state"] || {}
|
|
299
|
+
)
|
|
300
|
+
run.path = path
|
|
301
|
+
run
|
|
302
|
+
end
|
|
303
|
+
end
|
|
161
304
|
end
|
|
162
305
|
end
|
data/lib/nexo/version.rb
CHANGED