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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6c12fe51de0643c44b5ede5f140d45a2c2c50b15b8f357ad0e40132bcd40034c
4
- data.tar.gz: 6c98b8a0adab754ba7ea290ca2227df8f464f9a71407ad4956b6a985b5a40346
3
+ metadata.gz: 8815820e94035d1d5c68defa4e862111c95572852098b735e885eaf98b6a3407
4
+ data.tar.gz: 57ca3d98c0d1acd6877276e9322715a7e003928b2da258866b1e7ccabcf33b29
5
5
  SHA512:
6
- metadata.gz: 221c754bcd1d4d46bf8550c5411c95e194840b5c9de42dbf66a4035a7b5d95bdccb2fb8c5a5ade8eccf88e238e763cb59b9e97d2b40d7d92fc9aee273dfe8376
7
- data.tar.gz: 337b96d65dcbfcb2d879ed2e6dc62867d1a27a768f0693e007db8a0f562de0a6e5d3d129f0092d804eb531cbac0b375b7b97731016e1551cd9d1c164bae94e05
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
@@ -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
 
@@ -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: the ActiveRecord store when both ::ActiveRecord::Base
15
- # and Nexo::WorkflowRun are defined (the Rails path), otherwise the in-memory
16
- # store. With no Rails loaded the AR check short-circuits, so the plain-Ruby
17
- # path never references ActiveRecord.
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
@@ -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.9.0"
5
+ VERSION = "0.10.0"
6
6
  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.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mario Alberto Chávez