xeno 0.0.1 → 0.0.2

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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -2
  3. data/README.md +3 -1
  4. data/Rakefile +2 -0
  5. data/app/controllers/xeno/api_controller.rb +15 -18
  6. data/app/controllers/xeno/application_controller.rb +2 -0
  7. data/app/controllers/xeno/dev_controller.rb +5 -4
  8. data/app/controllers/xeno/dev_ui_controller.rb +5 -4
  9. data/app/controllers/xeno/health_controller.rb +3 -1
  10. data/app/controllers/xeno/sessions_controller.rb +22 -24
  11. data/app/controllers/xeno/slack_controller.rb +9 -10
  12. data/app/controllers/xeno/streams_controller.rb +18 -22
  13. data/app/helpers/xeno/application_helper.rb +2 -0
  14. data/app/jobs/xeno/application_job.rb +2 -0
  15. data/app/jobs/xeno/reaper_job.rb +5 -4
  16. data/app/jobs/xeno/schedule_job.rb +10 -13
  17. data/app/jobs/xeno/slack_event_job.rb +7 -7
  18. data/app/jobs/xeno/turn_job.rb +5 -4
  19. data/app/mailers/xeno/application_mailer.rb +2 -0
  20. data/app/models/xeno/action.rb +7 -12
  21. data/app/models/xeno/application_record.rb +2 -0
  22. data/app/models/xeno/chat.rb +9 -11
  23. data/app/models/xeno/dedup.rb +7 -7
  24. data/app/models/xeno/event.rb +16 -20
  25. data/app/models/xeno/message.rb +2 -0
  26. data/app/models/xeno/pending_message.rb +4 -2
  27. data/app/models/xeno/session.rb +52 -67
  28. data/app/models/xeno/turn.rb +29 -34
  29. data/config/routes.rb +2 -0
  30. data/db/migrate/{20260806000001_move_transcript_support_tables_to_ruby_llm.rb → 20260808000001_create_ruby_llm_tables.rb} +25 -54
  31. data/db/migrate/{20260804000002_create_xeno_orchestration_tables.rb → 20260808000002_create_xeno_tables.rb} +44 -14
  32. data/docs/configuration.md +56 -0
  33. data/docs/runtime.md +4 -4
  34. data/exe/xeno +1 -1
  35. data/lib/generators/xeno/install/install_generator.rb +3 -2
  36. data/lib/generators/xeno/install/templates/agent.rb +1 -2
  37. data/lib/generators/xeno/install/templates/initializer.rb +6 -9
  38. data/lib/generators/xeno/tool/tool_generator.rb +4 -2
  39. data/lib/tasks/xeno_tasks.rake +2 -0
  40. data/lib/xeno/agent_config.rb +9 -8
  41. data/lib/xeno/agent_definition.rb +25 -28
  42. data/lib/xeno/approval_context.rb +2 -0
  43. data/lib/xeno/arguments.rb +8 -9
  44. data/lib/xeno/ask_question.rb +5 -5
  45. data/lib/xeno/channels/slack.rb +25 -29
  46. data/lib/xeno/channels.rb +10 -9
  47. data/lib/xeno/compaction.rb +20 -33
  48. data/lib/xeno/configuration.rb +35 -44
  49. data/lib/xeno/engine.rb +8 -7
  50. data/lib/xeno/errors.rb +11 -10
  51. data/lib/xeno/hooks.rb +7 -10
  52. data/lib/xeno/info.rb +3 -2
  53. data/lib/xeno/inputs.rb +26 -19
  54. data/lib/xeno/reaper.rb +13 -17
  55. data/lib/xeno/schedules.rb +8 -9
  56. data/lib/xeno/session_state.rb +8 -9
  57. data/lib/xeno/standalone/local_secret.rb +7 -6
  58. data/lib/xeno/standalone/model_refresh.rb +6 -6
  59. data/lib/xeno/standalone/puma.rb +9 -9
  60. data/lib/xeno/standalone.rb +22 -21
  61. data/lib/xeno/tool.rb +28 -25
  62. data/lib/xeno/turn_runner.rb +74 -96
  63. data/lib/xeno/version.rb +3 -1
  64. data/lib/xeno.rb +16 -14
  65. metadata +4 -9
  66. data/db/migrate/20260804000001_create_xeno_llm_tables.rb +0 -70
  67. data/db/migrate/20260805000001_add_resumes_to_xeno_turns.rb +0 -8
  68. data/db/migrate/20260805000002_add_transcript_deferred_to_xeno_turns.rb +0 -8
  69. data/db/migrate/20260805000003_create_xeno_dedups.rb +0 -14
  70. data/db/migrate/20260805000004_add_kind_to_xeno_turns.rb +0 -9
  71. data/db/migrate/20260805000005_add_state_to_xeno_sessions.rb +0 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '03921563a3c8f8a6ef1a2f6c8274f6851f65daeebe66b617e2fc9729673bd571'
4
- data.tar.gz: 51e0a0f0646cc28974e73e8cca0e5c485fc60113c664161a0e31b74d6bfb5201
3
+ metadata.gz: 4925abe330e6c36702e33e5c9322ef3b23aa7a9ad1f98d2e2aedbfa0ae691593
4
+ data.tar.gz: 8f7beb0a6be6b1ebe57f90811f3c99384ab42db88583643311426c756ba99223
5
5
  SHA512:
6
- metadata.gz: 8a5e8aa4d6ce79696fc85275c07ddb4522c2fd18b667120087841367d94a279258763ce9ab45bb7764556e8954b70f1f751f6b88549983c4622ea58eea2a3cc6
7
- data.tar.gz: cdb8ceae13f48c5277d9760715021749d6ddfa40fa0a9a7045631c7cbb6323096e17aabd097b3b0b0d37c3513d7a4f9f1e7d4585b654bee683955b56a71bbf46
6
+ metadata.gz: 5dc647886c481b5d0f19cddcca9759a73a07d354120c1e6399d40e0c3dad4a0cae94a278d5a61a6cbd6b1b82043f483a0a742b61aae4ce38a5816805c0531a6e
7
+ data.tar.gz: 5eae3ba1312fcaeda570024497bbbdea16a623fc6a6aff0be73ef16f4375a8509292f61ac7da67b72b08040af66d8a44f9b7e73805f9868ef2c6d33938b1f2c9
data/CHANGELOG.md CHANGED
@@ -2,12 +2,55 @@
2
2
 
3
3
  Format follows [Keep a Changelog](https://keepachangelog.com); versions follow
4
4
  [SemVer](https://semver.org). **xeno is on the `0.0.x` preview line: any release may
5
- change anything.** `0.1.0` will mark the first release we consider usable in anger
5
+ change anything.** `0.1.0` will mark the first release we consider usable in anger:
6
6
  after RubyLLM v2 GA is absorbed and the API has held still through early-adopter
7
7
  feedback.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.0.2] - 2026-08-13
12
+
13
+ **Upgrading from 0.0.1 requires a fresh database.** The migrations were squashed
14
+ and the old stamps no longer exist, so there is no migration path forward: the
15
+ `0.0.x` preview line reserves exactly this.
16
+
17
+ ### Added
18
+
19
+ - Interop with RubyLLM's tool approvals. A tool that declares `requires_approval`
20
+ and no xeno `approval` policy parks for approval instead of executing ungated;
21
+ an explicit policy, including `:never`, still wins. Approve and deny decisions
22
+ also land on the standard `ruby_llm_tool_calls.approval` column, so tooling
23
+ built against RubyLLM's convention reads xeno sessions natively.
24
+ - Tools that declare a `tool_call:` keyword on `execute` receive the
25
+ `RubyLLM::ToolCall` they were invoked by, so a tool body can read its own
26
+ call id for logging or correlation. Tools without the keyword are called
27
+ exactly as before.
28
+
29
+ ### Changed
30
+
31
+ - The `ruby_llm` pin moved from `2aaddf96` to `be80b6f1` (145 commits of upstream
32
+ v2 development). No xeno API usage changed; the bundle swaps `ruby_llm-schema`
33
+ for `schematist`. The scaffold Gemfile and README point at the new ref.
34
+ - The engine's eight migrations are now two: `create_ruby_llm_tables` and
35
+ `create_xeno_tables`. The old set replayed pre-release history, creating tables
36
+ only to drop them again, through a database no install has ever had. The new
37
+ pair produces the same final schema without the churn, and each
38
+ `ruby_llm_*` table is skipped when the database
39
+ already has it, so a host app that ran ruby_llm's own installer keeps what it
40
+ owns.
41
+ - The transcript tables match upstream's current install template: tool calls
42
+ carry `approval`, messages carry `server_tool_calls` and `raw_content`. All
43
+ three are read behind column guards, so behavior is unchanged until the pinned
44
+ ruby_llm persists them.
45
+ - The `Gemfile` no longer prefers a local ruby_llm checkout when one happens to
46
+ sit next door. Development bundles the same pinned ref as everyone else.
47
+
48
+ ### Fixed
49
+
50
+ - Removing an agent's instructions now takes effect at the next turn, the same
51
+ way editing them always has: staging clears the persisted system message when
52
+ nothing resolves, instead of silently keeping the stale one.
53
+
11
54
  ## [0.0.1] - 2026-08-07
12
55
 
13
56
  The first public release of xeno, a framework for durable AI agents built on Rails:
@@ -28,7 +71,7 @@ The first public release of xeno, a framework for durable AI agents built on Rai
28
71
  post-then-edit streaming).
29
72
  - Markdown cron schedules compiled to Solid Queue recurring entries, with dedup.
30
73
  - Generators (`xeno:install`, `xeno:tool`) and standalone mode: `xeno new my-agent` /
31
- `xeno server` one process, sqlite, zero services. Scaffolds include a pinned
74
+ `xeno server` for one process, sqlite, zero services. Scaffolds include a pinned
32
75
  `ruby_llm` Gemfile, dotenv, and an AGENTS.md pointing coding agents at the gem docs.
33
76
  - Standalone first boot refreshes the model registry after the boot migrations, so
34
77
  current model ids resolve without a manual `RubyLLM.models.refresh!`.
data/README.md CHANGED
@@ -5,7 +5,7 @@ xeno is a framework for durable AI agents. Built on Rails, it runs standalone (`
5
5
  A xeno agent is a session that survives restarts and deploys mid-turn, waits days for a human approval without holding a process, and resumes exactly where it stopped. Under the hood, xeno composes [RubyLLM](https://rubyllm.com) for model calls with ActiveJob and ActiveRecord for durability.
6
6
 
7
7
  > [!WARNING]
8
- > xeno currently tracks RubyLLM v2 (unreleased), pinned to git ref [`2aaddf96`](https://github.com/crmne/ruby_llm/commit/2aaddf96). The released `ruby_llm` gem (1.16.0) is not compatible. Standalone apps get the pin from the scaffold's Gemfile. If your Rails app already uses a released `ruby_llm`, xeno cannot run alongside it today. This resolves at RubyLLM v2 GA, when xeno switches to the released gem.
8
+ > xeno currently tracks RubyLLM v2 (unreleased), pinned to git ref [`be80b6f1`](https://github.com/crmne/ruby_llm/commit/be80b6f1). The released `ruby_llm` gem (1.16.0) is not compatible. Standalone apps get the pin from the scaffold's Gemfile. If your Rails app already uses a released `ruby_llm`, xeno cannot run alongside it today. This resolves at RubyLLM v2 GA, when xeno switches to the released gem.
9
9
 
10
10
  ## The filesystem is the authoring interface
11
11
 
@@ -155,6 +155,8 @@ Xeno.configure do |config|
155
155
  end
156
156
  ```
157
157
 
158
+ Every knob is listed in [docs/configuration.md](docs/configuration.md).
159
+
158
160
  Development with no lambda configured stays usable on localhost; everywhere else the absence of a checker is a 401.
159
161
 
160
162
  The principal is also the ownership boundary: a session belongs to the principal stamped at creation, and every session-scoped endpoint returns 404 for any other principal. See `docs/runtime.md` for the authorization rules.
data/Rakefile CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "bundler/setup"
2
4
 
3
5
  APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
@@ -1,9 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # Base for every engine endpoint. Auth fails closed: without a configured
3
- # authenticate lambda, everything is 401. The lambda receives the request;
4
- # a falsy return is a 401; its truthy return value is the request
5
- # principal (stamped onto sessions by the HTTP channel).
6
- class ApiController < ActionController::API
4
+ # Base for every engine endpoint. Auth fails closed: without a configured authenticate lambda,
5
+ # everything is 401. The lambda receives the request; a falsy return is a 401; its truthy return
6
+ # value is the request principal (stamped onto sessions by the HTTP channel).
7
+ class ApiController < ActionController::API # :nodoc:
7
8
  before_action :authenticate!
8
9
 
9
10
  rescue_from ActiveRecord::RecordNotFound do
@@ -18,9 +19,8 @@ module Xeno
18
19
  render json: { error: error.message }, status: :conflict
19
20
  end
20
21
 
21
- # The development fallback principal (no auth lambda configured). It
22
- # matches every session — in development only — so the dev UI and
23
- # localhost curl stay usable.
22
+ # The development fallback principal (no auth lambda configured). It matches every session — in
23
+ # development only — so the dev UI and localhost curl stay usable.
24
24
  DEV_PRINCIPAL = { "user" => "dev" }.freeze
25
25
 
26
26
  private
@@ -28,8 +28,8 @@ module Xeno
28
28
  def authenticate!
29
29
  checker = Xeno.config.authenticate
30
30
 
31
- # Development with no auth configured stays usable (the dev UI, curl on
32
- # localhost). Everywhere else the absence of a checker is a 401.
31
+ # Development with no auth configured stays usable (the dev UI, curl on localhost). Everywhere
32
+ # else the absence of a checker is a 401.
33
33
  if checker.nil? && Rails.env.development?
34
34
  @current_principal = DEV_PRINCIPAL
35
35
  return
@@ -44,11 +44,9 @@ module Xeno
44
44
  @current_principal == true ? nil : @current_principal
45
45
  end
46
46
 
47
- # Ownership rule: a session belongs to the principal stamped at create.
48
- # A non-matching caller gets the same 404 as a nonexistent id (no
49
- # existence leak). A lambda that returns bare `true` yields nil
50
- # principals everywhere — single-tenant mode, every authenticated
51
- # caller matches (documented in docs/runtime.md).
47
+ # A session belongs to the principal stamped at create; a non-matching caller gets the same 404
48
+ # as a nonexistent id (no existence leak). A lambda returning bare `true` yields nil principals
49
+ # everywhere single-tenant mode, every authenticated caller matches.
52
50
  def find_owned_session!
53
51
  session = Session.find(params[:id])
54
52
  raise ActiveRecord::RecordNotFound unless owns?(session)
@@ -57,9 +55,8 @@ module Xeno
57
55
  end
58
56
 
59
57
  def owns?(session)
60
- # Object identity on purpose: only the fallback path above grants the
61
- # wildcard, never a configured lambda that happens to return the same
62
- # hash.
58
+ # Object identity on purpose: only the fallback path above grants the wildcard, never a
59
+ # configured lambda that happens to return the same hash.
63
60
  return true if Rails.env.development? && @current_principal.equal?(DEV_PRINCIPAL)
64
61
 
65
62
  session.principal == current_principal.as_json
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
4
  class ApplicationController < ActionController::Base
3
5
  end
@@ -1,8 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # Development conveniences. Schedules never fire on cadence in dev; this
3
- # endpoint triggers one by name. Refuses to exist outside
4
- # development (XENO_DEV_ROUTES=1 lets tests exercise it).
5
- class DevController < ApiController
4
+ # Development conveniences. Schedules never fire on cadence in dev; this endpoint triggers one by
5
+ # name. Refuses to exist outside development (XENO_DEV_ROUTES=1 lets tests exercise it).
6
+ class DevController < ApiController # :nodoc:
6
7
  before_action :ensure_development
7
8
 
8
9
  def dispatch_schedule
@@ -1,8 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # The mounted dev chat UI: chat with the
3
- # agent, watch the event stream live (over xeno's own SSE endpoint), and
4
- # approve/deny/answer parked work. Development only.
5
- class DevUiController < ApplicationController
4
+ # The mounted dev chat UI: chat with the agent, watch the event stream live (over xeno's own SSE
5
+ # endpoint), and approve/deny/answer parked work. Development only.
6
+ class DevUiController < ApplicationController # :nodoc:
6
7
  before_action :ensure_development
7
8
 
8
9
  def index
@@ -1,6 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
4
  # The one unauthenticated endpoint: liveness only, leaks nothing.
3
- class HealthController < ApiController
5
+ class HealthController < ApiController # :nodoc:
4
6
  skip_before_action :authenticate!
5
7
 
6
8
  def show
@@ -1,13 +1,13 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # The HTTP channel: always mounted, fail-closed. The channel owns the
3
- # continuation token (the resume handle): create returns one, follow-up
4
- # messages must present it, terminal states release it, reset retires the
5
- # session so the same token can start fresh.
6
- class SessionsController < ApiController
7
- # POST /v1/sessions { message:, continuation_token: (optional) }
8
- # Client-supplied tokens are restricted to the reserved `http:`
9
- # namespace — a crafted `slack:<channel>:<ts>` token would otherwise
10
- # hijack which Slack thread the agent posts into.
4
+ # The HTTP channel: always mounted, fail-closed. The channel owns the continuation token (the
5
+ # resume handle): create returns one, follow-up messages must present it, terminal states release
6
+ # it, reset retires the session so the same token can start fresh.
7
+ class SessionsController < ApiController # :nodoc:
8
+ # POST /v1/sessions { message:, continuation_token: (optional) } Client-supplied tokens are
9
+ # restricted to the reserved `http:` namespace — a crafted `slack:<channel>:<ts>` token would
10
+ # otherwise hijack which Slack thread the agent posts into.
11
11
  def create
12
12
  message = params.require(:message)
13
13
  token = params[:continuation_token].presence
@@ -31,9 +31,9 @@ module Xeno
31
31
  status: :conflict
32
32
  end
33
33
 
34
- # POST /v1/sessions/:id/messages { message:, continuation_token:, steer: }
35
- # steer: true stops the active turn (settled safely) and makes this
36
- # message the next turn instead of queueing behind the current work.
34
+ # POST /v1/sessions/:id/messages { message:, continuation_token:, steer: } steer: true stops the
35
+ # active turn (settled safely) and makes this message the next turn instead of queueing behind
36
+ # the current work.
37
37
  def message
38
38
  session = find_owned_session!
39
39
 
@@ -73,10 +73,9 @@ module Xeno
73
73
  turn_status: action.turn.reload.status }
74
74
  end
75
75
 
76
- # POST /v1/sessions/:id/cancel — stop the active turn, session intact.
77
- # A parked/pending turn cancels immediately (no process holds it); a
78
- # running turn is cancelled cooperatively via the persisted flag the
79
- # runner's checker polls (takes effect within a chunk or step boundary).
76
+ # POST /v1/sessions/:id/cancel — stop the active turn, session intact. A parked/pending turn
77
+ # cancels immediately (no process holds it); a running turn is cancelled cooperatively via the
78
+ # persisted flag the runner's checker polls (takes effect within a chunk or step boundary).
80
79
  def cancel
81
80
  session = find_owned_session!
82
81
  turn = session.turns.where(status: %w[pending running waiting]).order(:sequence).first
@@ -87,9 +86,9 @@ module Xeno
87
86
  session.chat.cancel!
88
87
  render json: { turn_id: turn.id, cancelling: true }, status: :accepted
89
88
  else
90
- # Settle BEFORE cancelling: a parked turn's assistant tool_calls are
91
- # still unanswered, and leaving them dangling bricks the session
92
- # (every later generate replays the malformed transcript).
89
+ # Settle BEFORE cancelling: a parked turn's assistant tool_calls are still unanswered, and
90
+ # leaving them dangling bricks the session (every later generate replays the malformed
91
+ # transcript).
93
92
  session.settle_unanswered_tool_calls!(turn, reason: "cancelled by user")
94
93
  turn.update!(status: "cancelled")
95
94
  session.update!(status: "running") if session.status == "waiting"
@@ -98,9 +97,8 @@ module Xeno
98
97
  end
99
98
  end
100
99
 
101
- # POST /v1/sessions/:id/compact — request a manual compaction. It runs
102
- # as a claimed turn, so it queues behind any active or parked turn and
103
- # executes the moment the session is free. Never appends a user message.
100
+ # POST /v1/sessions/:id/compact — manual compaction. Runs as a claimed turn: queues behind
101
+ # active or parked work, never appends a user message.
104
102
  def compact
105
103
  session = find_owned_session!
106
104
 
@@ -114,8 +112,8 @@ module Xeno
114
112
  render json: { session_id: session.id, turn_id: turn.id, status: "requested" }, status: :accepted
115
113
  end
116
114
 
117
- # POST /v1/sessions/:id/reset — retire the session (the /new command);
118
- # releases the continuation token so the same handle can start fresh.
115
+ # POST /v1/sessions/:id/reset — retire the session (the /new command); releases the continuation
116
+ # token so the same handle can start fresh.
119
117
  def reset
120
118
  session = find_owned_session!
121
119
 
@@ -1,9 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # The Slack Events API endpoint. The generic auth lambda is skipped —
3
- # Slack's own request signature IS the authentication (verified in
4
- # constant time), and it fails closed: no configured slack channel = 404,
5
- # bad or stale signature = 401.
6
- class SlackController < ApiController
4
+ # The Slack Events API endpoint. The generic auth lambda is skipped — Slack's own request
5
+ # signature IS the authentication (verified in constant time), and it fails closed: no configured
6
+ # slack channel = 404, bad or stale signature = 401.
7
+ class SlackController < ApiController # :nodoc:
7
8
  skip_before_action :authenticate!
8
9
 
9
10
  def events
@@ -28,11 +29,9 @@ module Xeno
28
29
 
29
30
  private
30
31
 
31
- # Fast ack (Slack retries anything that misses its 3s window) + retry
32
- # dedup: the event_id claim and the job enqueue commit together, so a
33
- # retry storm yields exactly one processing job and a crash before
34
- # the ack leaves the claim unclaimed for the retry to win. Events
35
- # without an event_id (shouldn't happen for event_callback) process
32
+ # Fast ack (Slack retries anything past its 3s window) with retry dedup: the event_id claim and
33
+ # the job enqueue commit together, so a retry storm yields one processing job, and a crash
34
+ # before the ack leaves the claim for the retry to win. Events without an event_id process
36
35
  # without dedup rather than being dropped.
37
36
  def enqueue_event(payload)
38
37
  event_id = payload["event_id"]
@@ -1,14 +1,13 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # The session event stream: SSE by default, NDJSON on request
3
- # (`?format=ndjson` or `Accept: application/x-ndjson` — one envelope per
4
- # line, friendlier to curl/jq and non-browser consumers). `?start_index=N`
5
- # rewinds/resumes — events are durable rows, so reconnecting clients
6
- # replay history and then follow live. The stream ends when the session
7
- # reaches a terminal status (or the optional configured max duration).
4
+ # The session event stream: SSE by default, NDJSON on request (`?format=ndjson` or `Accept:
5
+ # application/x-ndjson`). `?start_index=N` rewinds events are durable rows, so clients replay
6
+ # history and then follow live. The stream ends at a terminal session status or the configured max
7
+ # duration.
8
8
  #
9
- # Known limitation (documented): ActionController::Live holds a thread
10
- # per connected client.
11
- class StreamsController < ApiController
9
+ # Known limitation: ActionController::Live holds a thread per client.
10
+ class StreamsController < ApiController # :nodoc:
12
11
  include ActionController::Live
13
12
 
14
13
  KEEPALIVE_INTERVAL = 5 # seconds without a write before a ping
@@ -26,8 +25,8 @@ module Xeno
26
25
  def close = @sse.close
27
26
  end
28
27
 
29
- # NDJSON framing: one envelope per line (type/index live inside the
30
- # envelope); a bare newline as keepalive — line parsers skip empties.
28
+ # NDJSON framing: one envelope per line (type/index live inside the envelope); a bare newline as
29
+ # keepalive — line parsers skip empties.
31
30
  class NdjsonWriter
32
31
  def initialize(stream)
33
32
  @stream = stream
@@ -58,21 +57,19 @@ module Xeno
58
57
  end
59
58
  last_write = Process.clock_gettime(Process::CLOCK_MONOTONIC) if events.any?
60
59
 
61
- # A full batch means more history is waiting — keep paging through
62
- # the catch-up without sleeping or ending on a terminal status.
60
+ # A full batch means more history is waiting — keep paging through the catch-up without
61
+ # sleeping or ending on a terminal status.
63
62
  next if events.size == batch_limit
64
63
 
65
64
  break unless session.reload.active?
66
65
  break if stream_expired?(started)
67
- # A graceful stop must not wait out the in-flight-request window
68
- # the stream is resumable by design (events are durable rows, the
69
- # client reconnects with its cursor), so close it and let the
70
- # server exit. The 5s force cap in the puma config stays as the
66
+ # A graceful stop must not wait out the in-flight-request window. The stream is resumable by
67
+ # design, so close it and let the server exit; the 5s force cap in the puma config is the
71
68
  # backstop.
72
69
  break if server_shutting_down?
73
70
 
74
- # A quiet stream never writes, so a dead client would never raise and
75
- # this thread would poll forever — keepalives make disconnects visible.
71
+ # A quiet stream never writes, so a dead client would never raise and this thread would poll
72
+ # forever — keepalives make disconnects visible.
76
73
  if Process.clock_gettime(Process::CLOCK_MONOTONIC) - last_write > KEEPALIVE_INTERVAL
77
74
  writer.keepalive
78
75
  last_write = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -110,9 +107,8 @@ module Xeno
110
107
  false
111
108
  end
112
109
 
113
- # ActionController::Live runs the action on its OWN thread, so Puma's
114
- # thread-local Server.current is nil here (verified against a live
115
- # server). Find the process's server instance once per connection.
110
+ # ActionController::Live runs the action on its own thread, so Puma's thread-local
111
+ # Server.current is nil here. Find the process's server instance once per connection.
116
112
  def puma_server
117
113
  return nil unless defined?(Puma::Server)
118
114
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
4
  module ApplicationHelper
3
5
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
4
  class ApplicationJob < ActiveJob::Base
3
5
  end
@@ -1,8 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # Periodic sweep for turns with no live owner (see Xeno::Reaper). Fired
3
- # by Solid Queue's recurring machinery, a cron hitting `xeno:reap`, or
4
- # any other scheduler running it twice is harmless.
5
- class ReaperJob < ApplicationJob
4
+ # Periodic sweep for turns with no live owner (see Xeno::Reaper). Fired by Solid Queue's recurring
5
+ # machinery, a cron hitting `xeno:reap`, or any other scheduler — running it twice is harmless.
6
+ class ReaperJob < ApplicationJob # :nodoc:
6
7
  queue_as :default
7
8
 
8
9
  def perform
@@ -1,15 +1,13 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # Fires one scheduled run: a fire-and-forget task-mode session under the
3
- # app principal. Solid Queue recurring entries (rake xeno:schedules:sync)
4
- # and the dev dispatch endpoint both enqueue this.
4
+ # Fires one scheduled run: a fire-and-forget task-mode session under the app principal, enqueued
5
+ # by Solid Queue recurring entries or the dev dispatch endpoint.
5
6
  #
6
- # At-least-once safe: queues redeliver, and Solid Queue's recurring
7
- # (task, run-at) dedup only covers the enqueue, not the execution. Each
8
- # enqueue carries one ActiveJob job_id; the dedup row claims it in the
9
- # SAME transaction that opens the session, so a redelivered tick can
10
- # never double-open — it re-enqueues the recorded turn instead (idle
11
- # no-op if the first delivery finished: the claim CAS decides).
12
- class ScheduleJob < ApplicationJob
7
+ # At-least-once safe: queues redeliver, and recurring dedup covers only the enqueue. The dedup row
8
+ # claims this job_id in the same transaction that opens the session, so a redelivered tick can
9
+ # never double-open it re-enqueues the recorded turn instead.
10
+ class ScheduleJob < ApplicationJob # :nodoc:
13
11
  queue_as :default
14
12
 
15
13
  discard_on ArgumentError
@@ -43,9 +41,8 @@ module Xeno
43
41
 
44
42
  private
45
43
 
46
- # This job_id already ran (or died mid-run): pick up ITS turn instead of
47
- # opening a second session. The claim CAS makes this a no-op when the
48
- # first delivery actually finished.
44
+ # This job_id already ran (or died mid-run): pick up ITS turn instead of opening a second
45
+ # session. The claim CAS makes this a no-op when the first delivery actually finished.
49
46
  def redelivered_turn(claimed)
50
47
  return nil if claimed
51
48
 
@@ -1,11 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # Processes one verified Slack event off the webhook path. The endpoint
3
- # acks inside Slack's 3-second window and hands the payload here; session
4
- # work (DB writes, LLM turns via TurnJob) happens on the queue. Slack
5
- # retry storms die at the event_id dedup row (claimed atomically with
6
- # this enqueue); a session can never be double-opened by a retry — the
7
- # thread's continuation token is unique among active sessions.
8
- class SlackEventJob < ApplicationJob
4
+ # Processes one verified Slack event off the webhook path: the endpoint acks inside Slack's
5
+ # 3-second window and hands the payload here. Retry storms die at the event_id dedup row, and a
6
+ # session can never be double-opened the thread's continuation token is unique among active
7
+ # sessions.
8
+ class SlackEventJob < ApplicationJob # :nodoc:
9
9
  queue_as :default
10
10
 
11
11
  discard_on ActiveJob::DeserializationError
@@ -1,8 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # One turn = one job. All correctness (claim, fencing, replay) lives in
3
- # TurnRunner + the database; the queue only provides at-least-once
4
- # delivery and retry backoff.
5
- class TurnJob < ApplicationJob
4
+ # One turn = one job. All correctness (claim, fencing, replay) lives in TurnRunner + the database;
5
+ # the queue only provides at-least-once delivery and retry backoff.
6
+ class TurnJob < ApplicationJob # :nodoc:
6
7
  queue_as :default
7
8
 
8
9
  retry_on StandardError, wait: :polynomially_longer, attempts: 5
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
4
  class ApplicationMailer < ActionMailer::Base
3
5
  default from: "from@example.com"
@@ -1,17 +1,15 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # One requested tool call: the durability checkpoint and (in M4) the
3
- # approval state. A completed action means "executed AND its result is in
4
- # the transcript" — the two are committed in one transaction, so replay
5
- # logic can trust either signal.
4
+ # One requested tool call: the durability checkpoint and the approval state. A completed action
5
+ # means executed and its result is in the transcript — committed in one transaction, so replay can
6
+ # trust either signal.
6
7
  class Action < ApplicationRecord
7
- STATUSES = %w[pending pending_approval approved denied completed failed].freeze
8
- KINDS = %w[tool question].freeze
8
+ enum :status, %w[pending pending_approval approved denied completed failed].index_with(&:itself), validate: true
9
+ enum :kind, %w[tool question].index_with(&:itself), prefix: true, validate: true
9
10
 
10
11
  belongs_to :turn, class_name: "Xeno::Turn"
11
12
 
12
- validates :status, inclusion: { in: STATUSES }
13
- validates :kind, inclusion: { in: KINDS }
14
-
15
13
  def self.record!(turn, tool_call, kind: "tool")
16
14
  find_or_create_by!(turn: turn, tool_call_id: tool_call.id) do |action|
17
15
  action.tool_name = tool_call.name
@@ -19,8 +17,5 @@ module Xeno
19
17
  action.input = tool_call.arguments
20
18
  end
21
19
  end
22
-
23
- def completed? = status == "completed"
24
- def awaiting_input? = %w[pending_approval].include?(status)
25
20
  end
26
21
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
4
  class ApplicationRecord < ActiveRecord::Base
3
5
  self.abstract_class = true
@@ -1,18 +1,16 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # The persisted RubyLLM chat backing one session's transcript.
3
- # Owned by xeno host apps with their own acts_as chat classes are
4
- # unaffected; the message class below pins the transcript inside the
5
- # engine namespace. Models, tool calls, usage, and batches are RubyLLM
6
- # library records (ruby_llm_* tables), polymorphic toward this class.
4
+ # The persisted RubyLLM chat backing one session's transcript, owned by xeno — host apps with
5
+ # their own acts_as chat classes are unaffected. Models, tool calls, usage, and batches are
6
+ # RubyLLM library records (ruby_llm_* tables), polymorphic toward this class.
7
7
  class Chat < ApplicationRecord
8
8
  acts_as_chat message_class: "Xeno::Message"
9
9
 
10
- # assume_model_exists and protocol are runtime attributes, not columns
11
- # a freshly loaded record loses them, and the first to_llm build (which
12
- # with_instructions triggers) then resolves the model strictly and with
13
- # the provider's default protocol: ModelNotFoundError on the SECOND
14
- # message of any session using a custom/OpenAI-compatible model
15
- # (finding K). Every entry point re-applies them from the definition.
10
+ # assume_model_exists and protocol are runtime attributes, not columns, so a freshly loaded
11
+ # record loses them and the first to_llm build resolves the model strictly — ModelNotFoundError
12
+ # on the session's second message for a custom model. Every entry point re-applies them from the
13
+ # definition.
16
14
  def apply_runtime_options!(model_options)
17
15
  self.assume_model_exists = model_options[:assume_model_exists] if model_options.key?(:assume_model_exists)
18
16
  self.protocol = model_options[:protocol] if model_options.key?(:protocol)
@@ -1,14 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xeno
2
- # The at-least-once dedup ledger. Redelivered work (a cron tick fired
3
- # twice, a Slack retry storm) claims its (scope, key) once; every other
4
- # delivery loses the unique-index race and can read what the winner
5
- # recorded in metadata.
4
+ # The at-least-once dedup ledger. Redelivered work (a cron tick fired twice, a Slack retry storm)
5
+ # claims its (scope, key) once; every other delivery loses the unique-index race and can read what
6
+ # the winner recorded in metadata.
6
7
  class Dedup < ApplicationRecord
7
8
  validates :scope, :key, presence: true
8
9
 
9
- # Returns the freshly claimed row, or nil when the key was already
10
- # claimed. Safe inside enclosing transactions (savepoint), safe under
11
- # concurrency (the unique index decides).
10
+ # Returns the freshly claimed row, or nil when the key was already claimed. Safe inside
11
+ # enclosing transactions (savepoint), safe under concurrency (the unique index decides).
12
12
  def self.claim(scope, key, metadata: {})
13
13
  transaction(requires_new: true) do
14
14
  create!(scope: scope, key: key, metadata: metadata)