nexo_ai 0.1.0 → 0.7.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/.document +3 -0
- data/CHANGELOG.md +232 -0
- data/README.md +75 -4
- data/Rakefile +29 -0
- data/app/views/nexo/_event.html.erb +1 -0
- data/docs/concurrency.md +94 -0
- data/docs/durable-workflows.md +184 -0
- data/docs/getting-started.md +103 -0
- data/docs/loops.md +93 -0
- data/docs/mcp.md +144 -0
- data/docs/permissions.md +47 -0
- data/docs/rails.md +129 -0
- data/docs/sandboxes.md +290 -0
- data/docs/sessions.md +99 -0
- data/docs/skills.md +68 -0
- data/docs/tools.md +38 -0
- data/docs/web.md +130 -0
- data/docs/workflows.md +255 -0
- data/examples/README.md +51 -0
- data/examples/approval_agent.rb +64 -0
- data/examples/approval_workflow.rb +62 -0
- data/examples/artifact_from_template.rb +54 -0
- data/examples/chat_session.rb +49 -0
- data/examples/code_reviewer.rb +64 -0
- data/examples/container_review.rb +52 -0
- data/examples/inbox_digest.rb +62 -0
- data/examples/inbox_digest_http.rb +69 -0
- data/examples/inbox_digest_task.rb +40 -0
- data/examples/mcp_filesystem.rb +50 -0
- data/examples/news_search.rb +49 -0
- data/examples/news_summary.rb +37 -0
- data/examples/rails_usage.md +141 -0
- data/examples/skills/email_triage/SKILL.md +47 -0
- data/examples/skills/news_summary/SKILL.md +37 -0
- data/examples/skills/ruby-code-review/SKILL.md +61 -0
- data/lib/generators/nexo/artifacts/artifacts_generator.rb +37 -0
- data/lib/generators/nexo/artifacts/templates/add_artifacts_to_nexo_workflow_runs.rb +11 -0
- data/lib/generators/nexo/install/install_generator.rb +35 -0
- data/lib/generators/nexo/install/templates/nexo.rb +19 -0
- data/lib/generators/nexo/skill/skill_generator.rb +45 -0
- data/lib/generators/nexo/skill/templates/SKILL.md.tt +10 -0
- data/lib/generators/nexo/state/state_generator.rb +37 -0
- data/lib/generators/nexo/state/templates/add_state_to_nexo_workflow_runs.rb +13 -0
- data/lib/generators/nexo/workflows/templates/create_nexo_workflow_runs.rb +26 -0
- data/lib/generators/nexo/workflows/workflows_generator.rb +34 -0
- data/lib/nexo/agent.rb +423 -0
- data/lib/nexo/concurrent.rb +78 -0
- data/lib/nexo/configuration.rb +82 -0
- data/lib/nexo/engine.rb +51 -0
- data/lib/nexo/loop.rb +30 -0
- data/lib/nexo/loops/agent_sdk.rb +68 -0
- data/lib/nexo/loops/ruby_llm.rb +66 -0
- data/lib/nexo/mcp/gated_tool.rb +70 -0
- data/lib/nexo/mcp.rb +96 -0
- data/lib/nexo/output_truncator.rb +41 -0
- data/lib/nexo/permissions.rb +162 -0
- data/lib/nexo/read_tracker.rb +32 -0
- data/lib/nexo/run_store.rb +162 -0
- data/lib/nexo/sandbox.rb +64 -0
- data/lib/nexo/sandboxes/container.rb +293 -0
- data/lib/nexo/sandboxes/local.rb +157 -0
- data/lib/nexo/sandboxes/remote.rb +77 -0
- data/lib/nexo/sandboxes/virtual.rb +42 -0
- data/lib/nexo/sandboxes.rb +43 -0
- data/lib/nexo/session.rb +152 -0
- data/lib/nexo/skills.rb +54 -0
- data/lib/nexo/tools/fetch.rb +133 -0
- data/lib/nexo/tools/glob.rb +28 -0
- data/lib/nexo/tools/read_file.rb +54 -0
- data/lib/nexo/tools/shell.rb +35 -0
- data/lib/nexo/tools/web_search.rb +81 -0
- data/lib/nexo/tools/write_file.rb +61 -0
- data/lib/nexo/turbo_broadcaster.rb +41 -0
- data/lib/nexo/version.rb +2 -1
- data/lib/nexo/workflow.rb +705 -0
- data/lib/nexo/workflow_job.rb +42 -0
- data/lib/nexo/workflow_run.rb +128 -0
- data/lib/nexo.rb +138 -1
- data/lib/tasks/nexo.rake +17 -0
- data/sig/nexo/agent.rbs +23 -0
- data/sig/nexo/output_truncator.rbs +7 -0
- data/sig/nexo/permissions.rbs +15 -0
- data/sig/nexo/read_tracker.rbs +8 -0
- data/sig/nexo/sandbox.rbs +12 -0
- data/sig/nexo/sandboxes/container.rbs +26 -0
- data/sig/nexo/sandboxes/local.rbs +12 -0
- data/sig/nexo/sandboxes/virtual.rbs +7 -0
- data/sig/nexo/sandboxes.rbs +5 -0
- data/sig/nexo/tools.rbs +28 -0
- data/sig/nexo_ai.rbs +22 -1
- metadata +185 -2
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nexo
|
|
4
|
+
# The authorization gate for a sandbox's capabilities. Each tool asks
|
|
5
|
+
# +authorize!+ before it touches the sandbox, so gating is provider-independent
|
|
6
|
+
# and does not rely on any framework callback.
|
|
7
|
+
#
|
|
8
|
+
# Modes:
|
|
9
|
+
# * +:auto+ — allow everything.
|
|
10
|
+
# * +:read_only+ — allow +:read+/+:glob+, deny +:write+/+:shell+/+:fetch+/+:search+ (the default).
|
|
11
|
+
# * +:ask+ — defer to +on_ask+; a truthy return allows, anything else denies.
|
|
12
|
+
# * +:approve+ — durable, cross-process sibling of +:ask+ (Spec 16): with no
|
|
13
|
+
# +decision+ it raises Nexo::ApprovalRequired (→ Workflow#run_agent
|
|
14
|
+
# suspends the run); with +{approved: true}+ it allows, with
|
|
15
|
+
# +{approved: false}+ it Denied denies.
|
|
16
|
+
#
|
|
17
|
+
# Capabilities are +:read+, +:glob+, +:write+, +:shell+, +:fetch+, +:search+.
|
|
18
|
+
# Anything listed in +allow:+ is permitted regardless of mode.
|
|
19
|
+
class Permissions
|
|
20
|
+
# The recognized permission modes: +:auto+, +:read_only+, +:ask+, +:approve+.
|
|
21
|
+
MODES = %i[auto read_only ask approve].freeze
|
|
22
|
+
|
|
23
|
+
# Raised when a capability is not authorized. Tools rescue this and return
|
|
24
|
+
# +{ error: ... }+ so the agent loop continues.
|
|
25
|
+
class Denied < StandardError; end
|
|
26
|
+
|
|
27
|
+
# The configured Nexo permission mode (one of MODES). Read by the agent to
|
|
28
|
+
# map onto an opt-in backend's own permission vocabulary (see
|
|
29
|
+
# +Agent#permission_mode+).
|
|
30
|
+
attr_reader :mode
|
|
31
|
+
|
|
32
|
+
# The approval decision under +:approve+ (Spec 16): +nil+ (undecided ⇒
|
|
33
|
+
# suspend) or a +{approved: true|false}+ Hash. Writable after construction so
|
|
34
|
+
# Workflow#run_agent can thread a resume decision into an
|
|
35
|
+
# already-resolved +:approve+ gate without rebuilding it. Ignored by every
|
|
36
|
+
# other mode.
|
|
37
|
+
attr_accessor :decision
|
|
38
|
+
|
|
39
|
+
# +ask_when:+ is an optional +->(capability, detail)+ predicate that scopes
|
|
40
|
+
# *which* actions actually prompt under +:ask+ (and, unchanged, under
|
|
41
|
+
# +:approve+): when it returns falsey the action is auto-allowed without
|
|
42
|
+
# calling +on_ask+ / requiring a decision; truthy (or when unset) falls
|
|
43
|
+
# through to +on_ask+ / the approval gate exactly as before. It only ever
|
|
44
|
+
# *narrows* what is auto-allowed from the "ask/approve for everything"
|
|
45
|
+
# baseline — it never widens authority. Applies to #authorize! only, not
|
|
46
|
+
# #authorize_mcp!. +approve_when:+ is an alias that maps onto the same
|
|
47
|
+
# predicate (there is one predicate, not two — Spec 16 Q4).
|
|
48
|
+
#
|
|
49
|
+
# +decision:+ (default +nil+) seeds the +:approve+ decision (see #decision).
|
|
50
|
+
def initialize(mode: :read_only, allow: %i[read glob], mcp_allow: [], on_ask: nil, ask_when: nil,
|
|
51
|
+
approve_when: nil, decision: nil)
|
|
52
|
+
raise ArgumentError, "unknown mode #{mode}" unless MODES.include?(mode)
|
|
53
|
+
|
|
54
|
+
@mode = mode
|
|
55
|
+
@allow = allow
|
|
56
|
+
@mcp_allow = mcp_allow.map(&:to_s)
|
|
57
|
+
@on_ask = on_ask
|
|
58
|
+
@ask_when = ask_when || approve_when
|
|
59
|
+
@decision = decision
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns a copy of this gate carrying +decision+ (a +{approved: …}+ Hash or
|
|
63
|
+
# +nil+), leaving the receiver untouched (Spec 16). Used to thread a per-run
|
|
64
|
+
# resume decision into a user-supplied, class-level +:approve+ +Permissions+
|
|
65
|
+
# without mutating the shared instance.
|
|
66
|
+
def with_decision(decision)
|
|
67
|
+
dup.tap { |copy| copy.decision = decision }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Authorizes +capability+ (with optional +detail+ passed to an +:ask+ hook).
|
|
71
|
+
# Returns +true+ when allowed; raises Denied otherwise.
|
|
72
|
+
def authorize!(capability, detail = nil)
|
|
73
|
+
return true if @allow.include?(capability)
|
|
74
|
+
|
|
75
|
+
case @mode
|
|
76
|
+
when :auto
|
|
77
|
+
true
|
|
78
|
+
when :read_only
|
|
79
|
+
if %i[write shell fetch search].include?(capability)
|
|
80
|
+
raise Denied, "#{capability} denied in read_only mode"
|
|
81
|
+
end
|
|
82
|
+
true
|
|
83
|
+
when :ask
|
|
84
|
+
# Scoped-ask: when ask_when says this action doesn't need a prompt,
|
|
85
|
+
# auto-allow without calling on_ask. Unset ask_when = ask for everything.
|
|
86
|
+
return true if @ask_when && !@ask_when.call(capability, detail)
|
|
87
|
+
|
|
88
|
+
unless @on_ask&.call(capability, detail)
|
|
89
|
+
raise Denied, "#{capability} (#{detail}) denied by user"
|
|
90
|
+
end
|
|
91
|
+
true
|
|
92
|
+
when :approve
|
|
93
|
+
# Durable approval (Spec 16). Scoped-approve: when ask_when says this
|
|
94
|
+
# action doesn't need approval, auto-allow without a decision. Unset
|
|
95
|
+
# ask_when = approve for everything.
|
|
96
|
+
return true if @ask_when && !@ask_when.call(capability, detail)
|
|
97
|
+
|
|
98
|
+
if @decision.nil?
|
|
99
|
+
# Undecided ⇒ pause the run for a human (Branch A): the signal
|
|
100
|
+
# propagates out of the tool loop; run_agent turns it into a suspend.
|
|
101
|
+
raise Nexo::ApprovalRequired.new(capability, detail)
|
|
102
|
+
elsif @decision[:approved]
|
|
103
|
+
true
|
|
104
|
+
else
|
|
105
|
+
# Explicitly denied on resume ⇒ never auto-allow; the tool rescues
|
|
106
|
+
# Denied into {error:} and the model adapts.
|
|
107
|
+
raise Denied, "#{capability} (#{detail}) not approved"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Authorizes an MCP tool *call* by name. A deliberate sibling of #authorize!
|
|
113
|
+
# on a separate capability axis: an MCP tool runs inside the MCP server,
|
|
114
|
+
# outside the sandbox, so this gates the authority to *invoke* it — a different
|
|
115
|
+
# guarantee than sandbox capability. Fails closed under +:read_only+ (nothing
|
|
116
|
+
# allowed unless the exact +tool_name+ is listed in +mcp_allow+).
|
|
117
|
+
#
|
|
118
|
+
# * +:auto+ — allow every MCP tool.
|
|
119
|
+
# * +:read_only+ — allow only names in +mcp_allow+ (default +[]+ ⇒ deny all).
|
|
120
|
+
# * +:ask+ — defer to +on_ask+ with +(:mcp, {tool:, args:})+; a truthy
|
|
121
|
+
# return allows, anything else denies.
|
|
122
|
+
# * +:approve+ — durable sibling of +:ask+ on the MCP axis: names in
|
|
123
|
+
# +mcp_allow+ are pre-approved, anything else needs a decision — undecided
|
|
124
|
+
# raises Nexo::ApprovalRequired (→ Workflow#run_agent suspends), +approved+
|
|
125
|
+
# allows, +approved: false+ Denies.
|
|
126
|
+
#
|
|
127
|
+
# Returns +true+ when allowed; raises Denied otherwise. The +else+ is a
|
|
128
|
+
# fail-closed backstop: a future mode that forgets to extend this gate denies
|
|
129
|
+
# by default rather than silently allowing (the bug this replaced).
|
|
130
|
+
def authorize_mcp!(tool_name, args = {})
|
|
131
|
+
name = tool_name.to_s
|
|
132
|
+
|
|
133
|
+
case @mode
|
|
134
|
+
when :auto
|
|
135
|
+
true
|
|
136
|
+
when :read_only
|
|
137
|
+
return true if @mcp_allow.include?(name)
|
|
138
|
+
|
|
139
|
+
raise Denied, "mcp tool #{name} denied in read_only mode (not in mcp_allow)"
|
|
140
|
+
when :ask
|
|
141
|
+
unless @on_ask&.call(:mcp, {tool: name, args: args})
|
|
142
|
+
raise Denied, "mcp tool #{name} denied by user"
|
|
143
|
+
end
|
|
144
|
+
true
|
|
145
|
+
when :approve
|
|
146
|
+
# Pre-approved read tools pass; everything else routes through the same
|
|
147
|
+
# decision gate as #authorize!'s :approve branch.
|
|
148
|
+
return true if @mcp_allow.include?(name)
|
|
149
|
+
|
|
150
|
+
if @decision.nil?
|
|
151
|
+
raise Nexo::ApprovalRequired.new(:mcp, name, args)
|
|
152
|
+
elsif @decision[:approved]
|
|
153
|
+
true
|
|
154
|
+
else
|
|
155
|
+
raise Denied, "mcp tool #{name} not approved"
|
|
156
|
+
end
|
|
157
|
+
else
|
|
158
|
+
raise Denied, "mcp tool #{name} denied (unhandled mode #{@mode})"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nexo
|
|
4
|
+
# A tiny per-chat memory of which files an agent has read and at what
|
|
5
|
+
# last-modified time, so Tools::WriteFile can refuse to overwrite a file the
|
|
6
|
+
# model never read — or one that changed underneath it — within a session.
|
|
7
|
+
#
|
|
8
|
+
# Built once per chat in Agent#chat and threaded into both Tools::ReadFile
|
|
9
|
+
# (which records) and Tools::WriteFile (which enforces). Scope is clobber
|
|
10
|
+
# safety *within a session* only: no versioning, no locking, no VCS semantics.
|
|
11
|
+
class ReadTracker
|
|
12
|
+
# Starts an empty tracker (an internal +path => mtime+ map).
|
|
13
|
+
def initialize
|
|
14
|
+
@mtimes = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Records that +path+ was read when it had modification time +mtime+.
|
|
18
|
+
def record(path, mtime)
|
|
19
|
+
@mtimes[path] = mtime
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Whether +path+ has been read in this session.
|
|
23
|
+
def read?(path)
|
|
24
|
+
@mtimes.key?(path)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The mtime recorded when +path+ was read, or +nil+ if it was never read.
|
|
28
|
+
def recorded_mtime(path)
|
|
29
|
+
@mtimes[path]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nexo
|
|
4
|
+
# Storage seam for Workflow runs. Two interchangeable backends expose an
|
|
5
|
+
# identical create/find interface and return run objects responding to the
|
|
6
|
+
# same shape, which is what lets one Workflow implementation drive either
|
|
7
|
+
# the in-memory store (plain Ruby) or the ActiveRecord store (Rails).
|
|
8
|
+
#
|
|
9
|
+
# A run object responds to: +id+, +workflow_class+, +status+, +payload+,
|
|
10
|
+
# +result+, +error+, +events+, +artifacts+, +state+, plus +update!(attrs)+,
|
|
11
|
+
# +push_event(event)+, +save_events!+, +push_artifact(artifact)+,
|
|
12
|
+
# +save_artifacts!+, and +save_state!+ (Spec 13).
|
|
13
|
+
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.
|
|
18
|
+
def self.default
|
|
19
|
+
if defined?(::ActiveRecord::Base) && defined?(Nexo::WorkflowRun)
|
|
20
|
+
ActiveRecord.new
|
|
21
|
+
else
|
|
22
|
+
Memory.new
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# In-memory backend used by the plain-Ruby path and the offline test suite.
|
|
27
|
+
# Runs are held in a process-wide Hash keyed by their UUID id so that a run
|
|
28
|
+
# created by Workflow.run is still findable through a *later*
|
|
29
|
+
# RunStore.default call (e.g. Workflow.logs) — each call builds a fresh
|
|
30
|
+
# Memory instance, but they all share the same underlying store, mirroring
|
|
31
|
+
# how the ActiveRecord backend shares one database. Nothing is persisted to
|
|
32
|
+
# disk; the store lives only for the process.
|
|
33
|
+
class Memory
|
|
34
|
+
# A run record with the shared store shape. +update!+ assigns attributes,
|
|
35
|
+
# +push_event+ appends to the event log, and +save_events!+ is a no-op
|
|
36
|
+
# (the AR backend persists; Memory keeps everything in the Struct).
|
|
37
|
+
# Built with keyword arguments; a Struct defined without +keyword_init:+
|
|
38
|
+
# accepts them on Ruby 3.2+, so (well within the 3.3 floor) +keyword_init:
|
|
39
|
+
# true+ is unnecessary.
|
|
40
|
+
# Mutations serialize on the store-wide mutex (see Memory.mutex): the
|
|
41
|
+
# process-wide store can be driven from multiple threads (Local#offload
|
|
42
|
+
# workers, run_later under a threaded adapter, a multithreaded Puma host
|
|
43
|
+
# without ActiveRecord), and on Rubies without a GVL a bare +<<+/+[]=+ can
|
|
44
|
+
# lose events or corrupt the table.
|
|
45
|
+
Run = Struct.new(:id, :workflow_class, :status, :payload, :result, :error, :events, :artifacts, :state) do
|
|
46
|
+
def update!(attrs) = Memory.mutex.synchronize { attrs.each { |k, v| self[k] = v } }
|
|
47
|
+
|
|
48
|
+
def push_event(ev) = Memory.mutex.synchronize { events << ev }
|
|
49
|
+
|
|
50
|
+
def save_events! = nil
|
|
51
|
+
|
|
52
|
+
def push_artifact(a) = Memory.mutex.synchronize { artifacts << a }
|
|
53
|
+
|
|
54
|
+
def save_artifacts! = nil
|
|
55
|
+
|
|
56
|
+
# No-op counterpart to the AR model's save_state! — the Struct member
|
|
57
|
+
# already holds the checkpoint/suspend state in memory (Spec 13).
|
|
58
|
+
def save_state! = nil
|
|
59
|
+
|
|
60
|
+
# Read helpers mirroring Nexo::WorkflowRun so host code written against a
|
|
61
|
+
# finished run behaves identically in plain Ruby and under Rails (the
|
|
62
|
+
# "same run shape" contract covered only the write side before).
|
|
63
|
+
def done? = status == "done"
|
|
64
|
+
|
|
65
|
+
def failed? = status == "failed"
|
|
66
|
+
|
|
67
|
+
def running? = status == "running"
|
|
68
|
+
|
|
69
|
+
def queued? = status == "queued"
|
|
70
|
+
|
|
71
|
+
def suspended? = status == "suspended"
|
|
72
|
+
|
|
73
|
+
# The reason recorded when the run was suspended, or nil if it never was.
|
|
74
|
+
def suspend_reason = state&.dig("__suspend__", "reason")
|
|
75
|
+
|
|
76
|
+
# The stored result of a completed checkpoint(name), or nil.
|
|
77
|
+
def checkpoint_result(name) = state&.[](name.to_s)
|
|
78
|
+
|
|
79
|
+
# A recorded artifact by name (string/symbol tolerant), or nil.
|
|
80
|
+
def artifact(name) = (artifacts || []).find { |a| (a["name"] || a[:name]) == name.to_s }
|
|
81
|
+
|
|
82
|
+
# Just the stored body of a named artifact, or nil.
|
|
83
|
+
def artifact_content(name) = artifact(name)&.then { |a| a["content"] || a[:content] }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
@runs = {}
|
|
87
|
+
@mutex = Mutex.new
|
|
88
|
+
|
|
89
|
+
class << self
|
|
90
|
+
# The shared run table. Ids are UUIDs, so runs from independent callers
|
|
91
|
+
# never collide.
|
|
92
|
+
attr_reader :runs
|
|
93
|
+
|
|
94
|
+
# The store-wide lock guarding +runs+ and every run mutation. Not
|
|
95
|
+
# reentrant, so no method that holds it calls another that grabs it.
|
|
96
|
+
attr_reader :mutex
|
|
97
|
+
|
|
98
|
+
# Clears the shared table. Intended for test isolation.
|
|
99
|
+
def reset!
|
|
100
|
+
@mutex.synchronize { @runs = {} }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Builds a fresh +"pending"+ Run (UUID id, empty events/artifacts/state) and
|
|
105
|
+
# stores it in the process-wide table, returning it.
|
|
106
|
+
def create(workflow_class:, payload:)
|
|
107
|
+
run = Run.new(
|
|
108
|
+
id: Nexo.generate_run_id,
|
|
109
|
+
workflow_class: workflow_class,
|
|
110
|
+
status: "pending",
|
|
111
|
+
payload: payload,
|
|
112
|
+
result: nil,
|
|
113
|
+
error: nil,
|
|
114
|
+
events: [],
|
|
115
|
+
artifacts: [],
|
|
116
|
+
state: {}
|
|
117
|
+
)
|
|
118
|
+
self.class.mutex.synchronize { self.class.runs[run.id] = run }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Fetches a run by its UUID string id. A miss raises KeyError, which is
|
|
122
|
+
# acceptable for v1.
|
|
123
|
+
def find(id) = self.class.mutex.synchronize { self.class.runs.fetch(id) }
|
|
124
|
+
|
|
125
|
+
# Atomically claims a +"suspended"+ run for resume: flips it to +"running"+
|
|
126
|
+
# and returns true only if it was still suspended, so two concurrent resumes
|
|
127
|
+
# can't both re-enter #call (Spec 13 double-execution guard). The status
|
|
128
|
+
# flip is direct (not via #update!) to avoid re-entering the non-reentrant
|
|
129
|
+
# mutex.
|
|
130
|
+
def claim_for_resume!(run)
|
|
131
|
+
self.class.mutex.synchronize do
|
|
132
|
+
return false unless run.status == "suspended"
|
|
133
|
+
|
|
134
|
+
run.status = "running"
|
|
135
|
+
true
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# ActiveRecord backend used by the Rails path. Delegates to the
|
|
141
|
+
# Nexo::WorkflowRun model, which carries the same run shape.
|
|
142
|
+
class ActiveRecord
|
|
143
|
+
# Creates and persists a +"pending"+ Nexo::WorkflowRun row, returning it.
|
|
144
|
+
def create(workflow_class:, payload:)
|
|
145
|
+
Nexo::WorkflowRun.create!(workflow_class: workflow_class, payload: payload, status: "pending")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Fetches a persisted run by id (raises +ActiveRecord::RecordNotFound+ on a miss).
|
|
149
|
+
def find(id) = Nexo::WorkflowRun.find(id)
|
|
150
|
+
|
|
151
|
+
# Atomically claims a +"suspended"+ run for resume with a single conditional
|
|
152
|
+
# UPDATE, returning true only for the worker that won the row. This closes
|
|
153
|
+
# the cross-process double-resume race the plain +find+-then-check couldn't:
|
|
154
|
+
# a queued resume_later and a concurrent sync resume can't both re-enter.
|
|
155
|
+
def claim_for_resume!(run)
|
|
156
|
+
Nexo::WorkflowRun
|
|
157
|
+
.where(id: run.id, status: "suspended")
|
|
158
|
+
.update_all(status: "running") == 1
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
data/lib/nexo/sandbox.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nexo
|
|
4
|
+
# The execution-environment seam. A sandbox is where an agent's tools actually
|
|
5
|
+
# touch files and run commands; swapping the sandbox swaps the whole execution
|
|
6
|
+
# context (in-memory, host, or — later — remote) by constructor injection.
|
|
7
|
+
#
|
|
8
|
+
# Concrete sandboxes implement the four-method contract below. The base class
|
|
9
|
+
# raises +NotImplementedError+ for each so an incomplete subclass fails loudly.
|
|
10
|
+
#
|
|
11
|
+
# See Sandboxes::Virtual (default, zero host access) and Sandboxes::Local
|
|
12
|
+
# (host filesystem + shell, guarded).
|
|
13
|
+
class Sandbox
|
|
14
|
+
# Returns the contents of +path+ as a String.
|
|
15
|
+
def read(path)
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Writes +content+ to +path+.
|
|
20
|
+
def write(path, content)
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Runs +command+ and returns +{ stdout:, stderr:, status: }+ (status is the
|
|
25
|
+
# integer exit code). +timeout+ is in seconds.
|
|
26
|
+
def shell(command, timeout: 30)
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns the paths matching the glob +pattern+.
|
|
31
|
+
def glob(pattern)
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Releases any resources the sandbox holds. No-op by default.
|
|
36
|
+
def close
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A short, plain-text description of the execution environment (cwd, host
|
|
41
|
+
# access, network) for the agent to inject into the system prompt. Base
|
|
42
|
+
# returns +nil+ — inject nothing. Real-filesystem sandboxes (Local,
|
|
43
|
+
# Container) override this so a weak tool-caller knows where it runs.
|
|
44
|
+
def instructions
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Whether the sandbox supports +capability+ (one of +:read+, +:write+,
|
|
49
|
+
# +:glob+, +:shell+). The base supports everything but +:shell+ (an
|
|
50
|
+
# in-memory sandbox has no process to run a command in), so an agent only
|
|
51
|
+
# attaches the +Shell+ tool when the sandbox reports it. Real-process
|
|
52
|
+
# sandboxes (Local, Container) override to add +:shell+.
|
|
53
|
+
def supports?(capability)
|
|
54
|
+
capability != :shell
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The last-modified time of +path+, used by the read-before-write + stale
|
|
58
|
+
# guard for real-filesystem sandboxes. Base returns +nil+ (no external
|
|
59
|
+
# mutation to guard against, e.g. Virtual), which disables the guard.
|
|
60
|
+
def mtime(path)
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|