xeno 0.0.1

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 (78) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +38 -0
  3. data/LICENSE +21 -0
  4. data/README.md +211 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/xeno/application.css +15 -0
  7. data/app/controllers/xeno/api_controller.rb +68 -0
  8. data/app/controllers/xeno/application_controller.rb +4 -0
  9. data/app/controllers/xeno/dev_controller.rb +24 -0
  10. data/app/controllers/xeno/dev_ui_controller.rb +71 -0
  11. data/app/controllers/xeno/health_controller.rb +10 -0
  12. data/app/controllers/xeno/sessions_controller.rb +131 -0
  13. data/app/controllers/xeno/slack_controller.rb +48 -0
  14. data/app/controllers/xeno/streams_controller.rb +122 -0
  15. data/app/helpers/xeno/application_helper.rb +4 -0
  16. data/app/jobs/xeno/application_job.rb +4 -0
  17. data/app/jobs/xeno/reaper_job.rb +12 -0
  18. data/app/jobs/xeno/schedule_job.rb +56 -0
  19. data/app/jobs/xeno/slack_event_job.rb +20 -0
  20. data/app/jobs/xeno/turn_job.rb +16 -0
  21. data/app/mailers/xeno/application_mailer.rb +6 -0
  22. data/app/models/xeno/action.rb +26 -0
  23. data/app/models/xeno/application_record.rb +5 -0
  24. data/app/models/xeno/chat.rb +22 -0
  25. data/app/models/xeno/dedup.rb +24 -0
  26. data/app/models/xeno/event.rb +63 -0
  27. data/app/models/xeno/message.rb +5 -0
  28. data/app/models/xeno/pending_message.rb +7 -0
  29. data/app/models/xeno/session.rb +231 -0
  30. data/app/models/xeno/turn.rb +125 -0
  31. data/app/views/layouts/xeno/application.html.erb +18 -0
  32. data/app/views/xeno/dev_ui/_styles.html.erb +24 -0
  33. data/app/views/xeno/dev_ui/index.html.erb +28 -0
  34. data/app/views/xeno/dev_ui/show.html.erb +115 -0
  35. data/config/routes.rb +25 -0
  36. data/db/migrate/20260804000001_create_xeno_llm_tables.rb +70 -0
  37. data/db/migrate/20260804000002_create_xeno_orchestration_tables.rb +70 -0
  38. data/db/migrate/20260805000001_add_resumes_to_xeno_turns.rb +8 -0
  39. data/db/migrate/20260805000002_add_transcript_deferred_to_xeno_turns.rb +8 -0
  40. data/db/migrate/20260805000003_create_xeno_dedups.rb +14 -0
  41. data/db/migrate/20260805000004_add_kind_to_xeno_turns.rb +9 -0
  42. data/db/migrate/20260805000005_add_state_to_xeno_sessions.rb +8 -0
  43. data/db/migrate/20260806000001_move_transcript_support_tables_to_ruby_llm.rb +133 -0
  44. data/docs/runtime.md +275 -0
  45. data/exe/xeno +133 -0
  46. data/lib/generators/xeno/install/install_generator.rb +51 -0
  47. data/lib/generators/xeno/install/templates/agent.rb +4 -0
  48. data/lib/generators/xeno/install/templates/initializer.rb +20 -0
  49. data/lib/generators/xeno/install/templates/instructions.md +6 -0
  50. data/lib/generators/xeno/tool/templates/tool.rb.tt +16 -0
  51. data/lib/generators/xeno/tool/tool_generator.rb +13 -0
  52. data/lib/tasks/xeno_tasks.rake +24 -0
  53. data/lib/xeno/agent_config.rb +66 -0
  54. data/lib/xeno/agent_definition.rb +286 -0
  55. data/lib/xeno/approval_context.rb +4 -0
  56. data/lib/xeno/arguments.rb +62 -0
  57. data/lib/xeno/ask_question.rb +18 -0
  58. data/lib/xeno/channels/slack.rb +311 -0
  59. data/lib/xeno/channels.rb +68 -0
  60. data/lib/xeno/compaction.rb +165 -0
  61. data/lib/xeno/configuration.rb +118 -0
  62. data/lib/xeno/engine.rb +29 -0
  63. data/lib/xeno/errors.rb +40 -0
  64. data/lib/xeno/hooks.rb +37 -0
  65. data/lib/xeno/info.rb +75 -0
  66. data/lib/xeno/inputs.rb +78 -0
  67. data/lib/xeno/reaper.rb +52 -0
  68. data/lib/xeno/schedules.rb +49 -0
  69. data/lib/xeno/session_state.rb +57 -0
  70. data/lib/xeno/standalone/local_secret.rb +26 -0
  71. data/lib/xeno/standalone/model_refresh.rb +26 -0
  72. data/lib/xeno/standalone/puma.rb +17 -0
  73. data/lib/xeno/standalone.rb +136 -0
  74. data/lib/xeno/tool.rb +73 -0
  75. data/lib/xeno/turn_runner.rb +545 -0
  76. data/lib/xeno/version.rb +3 -0
  77. data/lib/xeno.rb +117 -0
  78. metadata +151 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '03921563a3c8f8a6ef1a2f6c8274f6851f65daeebe66b617e2fc9729673bd571'
4
+ data.tar.gz: 51e0a0f0646cc28974e73e8cca0e5c485fc60113c664161a0e31b74d6bfb5201
5
+ SHA512:
6
+ metadata.gz: 8a5e8aa4d6ce79696fc85275c07ddb4522c2fd18b667120087841367d94a279258763ce9ab45bb7764556e8954b70f1f751f6b88549983c4622ea58eea2a3cc6
7
+ data.tar.gz: cdb8ceae13f48c5277d9760715021749d6ddfa40fa0a9a7045631c7cbb6323096e17aabd097b3b0b0d37c3513d7a4f9f1e7d4585b654bee683955b56a71bbf46
data/CHANGELOG.md ADDED
@@ -0,0 +1,38 @@
1
+ # Changelog
2
+
3
+ Format follows [Keep a Changelog](https://keepachangelog.com); versions follow
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 —
6
+ after RubyLLM v2 GA is absorbed and the API has held still through early-adopter
7
+ feedback.
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [0.0.1] - 2026-08-07
12
+
13
+ The first public release of xeno, a framework for durable AI agents built on Rails:
14
+
15
+ - `agent/` conventions with path-derived names (`agent.rb`, `instructions.md`,
16
+ dynamic `instructions.rb`, `tools/`, `hooks/`, `schedules/`, `channels/`).
17
+ - Durable session → turn → step runtime on ActiveJob: atomic claims, fencing tokens,
18
+ heartbeats (including during long calls), checkpoint replay, a reaper for orphaned
19
+ turns, graceful-shutdown handoff, per-turn step budgets.
20
+ - Human-in-the-loop parking: tool `approval` policies, `ask_question`, crash-safe
21
+ approve→resume, full text resolution of prompts (labels, indexes, held replies).
22
+ - Append-only event stream (SSE + NDJSON) with cursor replay; the events table doubles
23
+ as an audit log.
24
+ - Compaction, per-session token budgets, session-scoped state, steering, pending-message
25
+ queueing with drain-on-park.
26
+ - Channels: HTTP (fail-closed auth, per-principal session ownership) and Slack
27
+ (signature verification, event dedup, thread sessions, text approvals, optional
28
+ post-then-edit streaming).
29
+ - Markdown cron schedules compiled to Solid Queue recurring entries, with dedup.
30
+ - Generators (`xeno:install`, `xeno:tool`) and standalone mode: `xeno new my-agent` /
31
+ `xeno server` — one process, sqlite, zero services. Scaffolds include a pinned
32
+ `ruby_llm` Gemfile, dotenv, and an AGENTS.md pointing coding agents at the gem docs.
33
+ - Standalone first boot refreshes the model registry after the boot migrations, so
34
+ current model ids resolve without a manual `RubyLLM.models.refresh!`.
35
+ - Tracks RubyLLM v2 (unreleased) at a pinned git commit: transcript support tables
36
+ (`ruby_llm_*`), per-attempt usage ledger backing token budgets and compaction.
37
+ - Test suite: 180 runs / 987 assertions on SQLite and PostgreSQL, plus end-to-end
38
+ kill-and-resume scripts with real processes and real signals.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Nicolas Erlichman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,211 @@
1
+ # xeno
2
+
3
+ xeno is a framework for durable AI agents. Built on Rails, it runs standalone (`xeno new my-agent`) or inside the Rails app you already have.
4
+
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
+
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.
9
+
10
+ ## The filesystem is the authoring interface
11
+
12
+ A xeno agent is a directory:
13
+
14
+ ```text
15
+ agent/
16
+ ├── agent.rb # optional: model and runtime config
17
+ ├── instructions.md # the always-on system prompt
18
+ ├── instructions.rb # optional: dynamic instructions, resolved per turn
19
+ ├── tools/
20
+ │ └── get_weather.rb # tool "get_weather" (class Xeno::Tools::GetWeather)
21
+ ├── hooks/
22
+ │ └── metrics.rb # observe-only event handlers
23
+ └── schedules/
24
+ └── weekly_recap.md # cron-triggered agent session
25
+ ```
26
+
27
+ The path supplies the name; there are no `name:` fields. `xeno info` prints the resolved agent and flags anything misplaced.
28
+
29
+ ## Quick start
30
+
31
+ Standalone, from nothing:
32
+
33
+ ```bash
34
+ xeno new my-agent && cd my-agent
35
+ bundle install
36
+ xeno server
37
+ ```
38
+
39
+ One process: web and jobs in the same Puma, SQLite, no external services. The dev chat UI is at `http://localhost:3000/agent/dev`.
40
+
41
+ In an existing Rails app:
42
+
43
+ ```bash
44
+ bundle add xeno
45
+ bin/rails g xeno:install
46
+ bin/rails db:migrate
47
+ ```
48
+
49
+ The generator creates `agent/` at your app root and mounts the engine at `/agent`. Tools are your domain code: they call your models, policies, and credentials directly, with no connector layer in between.
50
+
51
+ ## A minimal example
52
+
53
+ Add a gated tool at `agent/tools/charge_card.rb`:
54
+
55
+ ```ruby
56
+ class Xeno::Tools::ChargeCard < Xeno::Tool
57
+ description "Charge the customer's card. Irreversible."
58
+ parameter :amount_cents, type: :integer, description: "Amount in cents"
59
+
60
+ approval :always
61
+
62
+ def execute(amount_cents:)
63
+ PaymentService.charge!(amount_cents)
64
+ end
65
+ end
66
+ ```
67
+
68
+ Send "charge $42 to my card" and the agent decides to call the tool. `approval :always` parks the turn: the job ends, and the pending approval is a database row. Kill the process, deploy, come back tomorrow. When you approve (over HTTP, or one click in the dev UI), the turn resumes from its last checkpoint and the tool runs exactly once.
69
+
70
+ That arc is the flagship integration test (`test/demo_acceptance_test.rb`, three real processes) and a watchable script (`script/demo.sh`).
71
+
72
+ ## How durability works
73
+
74
+ Work nests session → turn → step. A turn is one ActiveJob; a step is one model call plus its tool calls, checkpointed in your database:
75
+
76
+ - A tool execution and its transcript write commit in one transaction, so recorded calls never re-execute. A killed process resumes from the last checkpoint.
77
+ - Turn claims use an atomic compare-and-swap with heartbeats, stale-claim takeover, fencing tokens, and a reaper for orphaned turns. Any ActiveJob backend works; with Solid Queue the whole story is database rows.
78
+ - Parking ends the job. A parked session costs nothing for days; resolving its pending input enqueues the resume.
79
+ - Messages that arrive mid-turn queue up and fold into the next turn.
80
+
81
+ `docs/runtime.md`, shipped inside the gem, documents the runtime invariants in full.
82
+
83
+ ## Sessions over HTTP
84
+
85
+ The engine mounts at `/agent`:
86
+
87
+ ```text
88
+ POST /agent/v1/sessions create a session → { session_id, continuation_token }
89
+ POST /agent/v1/sessions/:id/messages follow-up message (steer: true replaces the active turn)
90
+ POST /agent/v1/sessions/:id/inputs approve / deny / answer (by action_id)
91
+ POST /agent/v1/sessions/:id/cancel stop the active turn
92
+ POST /agent/v1/sessions/:id/compact summarize old history
93
+ POST /agent/v1/sessions/:id/reset retire the session
94
+ GET /agent/v1/sessions/:id/stream event stream
95
+ GET /agent/v1/health
96
+ ```
97
+
98
+ Events are append-only rows with a per-session index. The stream endpoint serves Server-Sent Events (SSE) or NDJSON (`?format=ndjson`) and replays from any cursor (`?start_index=N`); the same table is your audit log.
99
+
100
+ ## Slack
101
+
102
+ Two credentials and the agent answers mentions in threads:
103
+
104
+ ```ruby
105
+ # agent/channels/slack.rb
106
+ Xeno.channel :slack do
107
+ signing_secret ENV["SLACK_SIGNING_SECRET"]
108
+ bot_token ENV["SLACK_BOT_TOKEN"]
109
+ end
110
+ ```
111
+
112
+ Standalone apps load these from `.env`; in a Rails app, `Rails.application.credentials` works here too.
113
+
114
+ Point your Slack app's Events API at `POST /agent/v1/channels/slack/events`. xeno verifies signatures in constant time, answers the URL handshake, dedupes retries, and acks inside Slack's 3-second window. Mentions and direct messages start sessions; each thread is one session; replies continue it.
115
+
116
+ When the agent parks, the prompt lands in the thread, and replies resolve it:
117
+
118
+ - **approve** or **deny** settles an approval
119
+ - a number ("2") or a label ("tuesday") picks a choice
120
+ - any text answers a free-form question
121
+ - unrelated replies are held as the next message, never treated as an answer
122
+
123
+ Replies post at turn completion by default; `stream_replies true` in the channel block opts into post-then-edit streaming.
124
+
125
+ ## Schedules
126
+
127
+ A schedule is a markdown file with a cron line:
128
+
129
+ ```markdown
130
+ ---
131
+ cron: "0 9 * * 1"
132
+ ---
133
+ Summarize the week's activity and post highlights to the team.
134
+ ```
135
+
136
+ `bin/rails xeno:schedules:sync` compiles `agent/schedules/*.md` into Solid Queue recurring entries; standalone apps sync at boot. Development never fires on cadence: trigger by name via the dev endpoint. Schedule runs execute under the app principal and cannot wait on a human, so a gated tool deterministically fails the run.
137
+
138
+ ## Configuration
139
+
140
+ Standalone apps configure in `agent/agent.rb`; mounted apps use `config/initializers/xeno.rb`:
141
+
142
+ ```ruby
143
+ Xeno.configure do |config|
144
+ # Fail closed: every endpoint except health returns 401 until you set this.
145
+ # The truthy return value becomes the session principal.
146
+ config.authenticate = ->(request) do
147
+ token = request.headers["Authorization"]&.delete_prefix("Bearer ")
148
+ { "user" => "api" } if ActiveSupport::SecurityUtils.secure_compare(
149
+ token.to_s, ENV["XENO_API_KEY"].to_s
150
+ )
151
+ end
152
+
153
+ config.max_steps = 20 # per-turn model-call budget
154
+ config.turn_stale_after = 5.minutes # dead-worker takeover window
155
+ end
156
+ ```
157
+
158
+ Development with no lambda configured stays usable on localhost; everywhere else the absence of a checker is a 401.
159
+
160
+ 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.
161
+
162
+ ### Model registry
163
+
164
+ Model ids resolve against RubyLLM's registry, and models released after the gem's catalog snapshot need a registry refresh before they resolve. Standalone apps refresh automatically on first boot, right after the boot-time migrations (skip with `XENO_SKIP_MODEL_REFRESH=1`). To refresh later, run it manually for now (a friendlier command is planned):
165
+
166
+ ```bash
167
+ # mounted
168
+ bin/rails runner "RubyLLM.models.refresh!"
169
+ ```
170
+
171
+ ```bash
172
+ # standalone
173
+ bundle exec ruby -r dotenv/load -r xeno/standalone -e 'Xeno.rails_app; Xeno.definition; RubyLLM.models.refresh!'
174
+ ```
175
+
176
+ Or bypass the registry per model with `model "the-id", provider: :openai, assume_model_exists: true`. Automatic compaction then needs `config.compaction_context_window`, since the registry cannot supply the window.
177
+
178
+ ## Security model
179
+
180
+ xeno runs tools inside your app on purpose:
181
+
182
+ - There is no sandbox and no `bash` tool. The model's only execution surface is the tools you write.
183
+ - That power cuts both ways: prompt injection reaches whatever your tools expose. Keep parameters narrow and typed, scope queries to the session's principal, authorize inside tools with your existing layer, and treat tool output fed back to the model as untrusted.
184
+ - Approvals are the guardrail. Gate anything irreversible or externally visible with `:once` or `:always`.
185
+ - Webhooks verify signatures in constant time, HTTP auth fails closed, and dev routes never mount outside development.
186
+
187
+ ## Known limitations
188
+
189
+ - SSE holds a thread per client, which is fine for dev UIs and small deployments. Catch-up reads are batched so reconnects on long sessions stay bounded.
190
+ - SQLite and PostgreSQL are the tested databases; the suite runs on both. MySQL is untested and has known caveats.
191
+ - Execution is at-least-once: a step interrupted mid-flight re-runs on retry, so tool side effects need idempotency or approval gates. Consumers dedupe events by `(session, index)`.
192
+ - Cancellation is cooperative and lands at the next step boundary; an in-flight model call finishes first. A parked turn cancels instantly.
193
+ - Sessions retire explicitly: `reset` is the only terminal transition.
194
+ - One agent per app.
195
+
196
+ ## Development
197
+
198
+ ```bash
199
+ bin/rails test # the whole suite: offline, deterministic fake LLM
200
+ script/demo.sh # the kill -9 demo, narrated
201
+ script/verify_generators.sh # generators against a fresh rails new app
202
+ script/verify_standalone.sh # the standalone one-process arc
203
+ ```
204
+
205
+ Tests run against a scripted fake server: no network, no keys, no flake. The kill-and-resume arcs use real processes and real signals.
206
+
207
+ ## Status: preview (0.0.x)
208
+
209
+ xeno is an early preview with no production mileage yet; anything may change between 0.0.x releases. It targets RubyLLM v2 (unreleased, pinned to a known-good commit; see the warning above); the pin drops at v2 GA. `0.1.0` will mark the first release we consider stable enough to build on. See [CHANGELOG.md](CHANGELOG.md).
210
+
211
+ MIT License.
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,68 @@
1
+ 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
7
+ before_action :authenticate!
8
+
9
+ rescue_from ActiveRecord::RecordNotFound do
10
+ render json: { error: "not found" }, status: :not_found
11
+ end
12
+
13
+ rescue_from ActionController::ParameterMissing do |error|
14
+ render json: { error: error.message }, status: :bad_request
15
+ end
16
+
17
+ rescue_from Xeno::Error do |error|
18
+ render json: { error: error.message }, status: :conflict
19
+ end
20
+
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.
24
+ DEV_PRINCIPAL = { "user" => "dev" }.freeze
25
+
26
+ private
27
+
28
+ def authenticate!
29
+ checker = Xeno.config.authenticate
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.
33
+ if checker.nil? && Rails.env.development?
34
+ @current_principal = DEV_PRINCIPAL
35
+ return
36
+ end
37
+
38
+ @current_principal = checker && instance_exec(request, &checker)
39
+ head :unauthorized unless @current_principal
40
+ end
41
+
42
+ def current_principal
43
+ # Normalize truthy-but-not-descriptive results (true) to nil-principal.
44
+ @current_principal == true ? nil : @current_principal
45
+ end
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).
52
+ def find_owned_session!
53
+ session = Session.find(params[:id])
54
+ raise ActiveRecord::RecordNotFound unless owns?(session)
55
+
56
+ session
57
+ end
58
+
59
+ 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.
63
+ return true if Rails.env.development? && @current_principal.equal?(DEV_PRINCIPAL)
64
+
65
+ session.principal == current_principal.as_json
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,4 @@
1
+ module Xeno
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,24 @@
1
+ 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
6
+ before_action :ensure_development
7
+
8
+ def dispatch_schedule
9
+ name = params[:name].to_s
10
+ unless Xeno.definition.schedules.key?(name)
11
+ return render json: { error: "unknown schedule: #{name}" }, status: :not_found
12
+ end
13
+
14
+ ScheduleJob.perform_later(name)
15
+ render json: { dispatched: name }, status: :accepted
16
+ end
17
+
18
+ private
19
+
20
+ def ensure_development
21
+ head :not_found unless Xeno.dev_routes_enabled?
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,71 @@
1
+ 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
6
+ before_action :ensure_development
7
+
8
+ def index
9
+ @sessions = Session.order(id: :desc).limit(50)
10
+ end
11
+
12
+ def create
13
+ session_record = Session.start!(
14
+ message: params.require(:message),
15
+ channel: "dev",
16
+ principal: { "user" => "dev-ui" }
17
+ )
18
+ redirect_to dev_session_path(session_record)
19
+ end
20
+
21
+ def show
22
+ @session = Session.find(params[:id])
23
+ @messages = @session.chat.messages_association.order(:id)
24
+ @pending_actions = Action.joins(:turn)
25
+ .where(xeno_turns: { session_id: @session.id }, status: "pending_approval")
26
+ .order(:id)
27
+ @events = @session.events.order(:index)
28
+ end
29
+
30
+ def message
31
+ session_record = Session.find(params[:id])
32
+ content = params.require(:message)
33
+ if ActiveModel::Type::Boolean.new.cast(params[:steer])
34
+ session_record.steer!(content)
35
+ else
36
+ session_record.receive_message!(content)
37
+ end
38
+ redirect_to dev_session_path(session_record)
39
+ end
40
+
41
+ def compact
42
+ session_record = Session.find(params[:id])
43
+ unless session_record.turns.where(kind: "compaction", status: %w[pending running]).exists?
44
+ session_record.stage_compaction_turn!(reason: "manual").enqueue!
45
+ end
46
+ redirect_to dev_session_path(session_record)
47
+ end
48
+
49
+ def input
50
+ session_record = Session.find(params[:id])
51
+ action = Action.joins(:turn)
52
+ .where(xeno_turns: { session_id: session_record.id })
53
+ .find(params.require(:action_id))
54
+
55
+ principal = { "user" => "dev-ui" }
56
+ case params[:decision]
57
+ when "approve" then Inputs.approve!(action, principal: principal)
58
+ when "deny" then Inputs.deny!(action, reason: params[:reason], principal: principal)
59
+ else Inputs.answer!(action, params.require(:answer), principal: principal)
60
+ end
61
+
62
+ redirect_to dev_session_path(session_record)
63
+ end
64
+
65
+ private
66
+
67
+ def ensure_development
68
+ head :not_found unless Xeno.dev_routes_enabled?
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,10 @@
1
+ module Xeno
2
+ # The one unauthenticated endpoint: liveness only, leaks nothing.
3
+ class HealthController < ApiController
4
+ skip_before_action :authenticate!
5
+
6
+ def show
7
+ render json: { status: "ok" }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,131 @@
1
+ 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.
11
+ def create
12
+ message = params.require(:message)
13
+ token = params[:continuation_token].presence
14
+ if token && !token.start_with?("http:")
15
+ return render json: { error: "client-supplied continuation_token must use the http: namespace" },
16
+ status: :unprocessable_entity
17
+ end
18
+ token ||= "http:#{SecureRandom.base58(24)}"
19
+
20
+ session = Session.start!(
21
+ message: message,
22
+ channel: "http",
23
+ principal: current_principal,
24
+ continuation_token: token
25
+ )
26
+
27
+ render json: { session_id: session.id, continuation_token: session.continuation_token },
28
+ status: :created
29
+ rescue ActiveRecord::RecordNotUnique
30
+ render json: { error: "continuation_token already in use by an active session" },
31
+ status: :conflict
32
+ end
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.
37
+ def message
38
+ session = find_owned_session!
39
+
40
+ return render json: { error: "session is finished — start a new one" }, status: :gone unless session.active?
41
+
42
+ supplied = params[:continuation_token].to_s
43
+ unless supplied.present? && session.continuation_token.present? &&
44
+ ActiveSupport::SecurityUtils.secure_compare(supplied, session.continuation_token)
45
+ return render json: { error: "continuation_token missing or wrong" }, status: :forbidden
46
+ end
47
+
48
+ content = params.require(:message)
49
+ steer = ActiveModel::Type::Boolean.new.cast(params[:steer])
50
+ turn = steer ? session.steer!(content) : session.receive_message!(content)
51
+ render json: { session_id: session.id, queued: turn.nil?, turn_id: turn&.id, steered: steer || nil }.compact,
52
+ status: :accepted
53
+ end
54
+
55
+ # POST /v1/sessions/:id/inputs { action_id:, decision: approve|deny, reason: } or { action_id:, answer: }
56
+ def input
57
+ session = find_owned_session!
58
+ action = Action.joins(:turn).where(xeno_turns: { session_id: session.id })
59
+ .find(params.require(:action_id))
60
+
61
+ if action.kind == "question"
62
+ Inputs.answer!(action, params.require(:answer), principal: current_principal)
63
+ else
64
+ case params.require(:decision)
65
+ when "approve" then Inputs.approve!(action, principal: current_principal)
66
+ when "deny" then Inputs.deny!(action, reason: params[:reason], principal: current_principal)
67
+ else
68
+ return render json: { error: "decision must be approve or deny" }, status: :unprocessable_entity
69
+ end
70
+ end
71
+
72
+ render json: { action_id: action.id, status: action.reload.status,
73
+ turn_status: action.turn.reload.status }
74
+ end
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).
80
+ def cancel
81
+ session = find_owned_session!
82
+ turn = session.turns.where(status: %w[pending running waiting]).order(:sequence).first
83
+
84
+ return render json: { error: "no active turn" }, status: :conflict unless turn
85
+
86
+ if turn.status == "running"
87
+ session.chat.cancel!
88
+ render json: { turn_id: turn.id, cancelling: true }, status: :accepted
89
+ 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).
93
+ session.settle_unanswered_tool_calls!(turn, reason: "cancelled by user")
94
+ turn.update!(status: "cancelled")
95
+ session.update!(status: "running") if session.status == "waiting"
96
+ session.emit("turn.cancelled", { turn_id: turn.id })
97
+ render json: { turn_id: turn.id, cancelled: true }
98
+ end
99
+ end
100
+
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.
104
+ def compact
105
+ session = find_owned_session!
106
+
107
+ return render json: { error: "session is finished" }, status: :gone unless session.active?
108
+ if session.turns.where(kind: "compaction", status: %w[pending running]).exists?
109
+ return render json: { error: "a compaction is already queued" }, status: :conflict
110
+ end
111
+
112
+ turn = session.stage_compaction_turn!(reason: "manual")
113
+ turn.enqueue!
114
+ render json: { session_id: session.id, turn_id: turn.id, status: "requested" }, status: :accepted
115
+ end
116
+
117
+ # POST /v1/sessions/:id/reset — retire the session (the /new command);
118
+ # releases the continuation token so the same handle can start fresh.
119
+ def reset
120
+ session = find_owned_session!
121
+
122
+ return render json: { error: "session already finished" }, status: :gone unless session.active?
123
+
124
+ session.chat.cancel! if session.turns.where(status: "running").exists?
125
+ released = session.continuation_token
126
+ session.finish!("completed")
127
+
128
+ render json: { session_id: session.id, released_continuation_token: released }
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,48 @@
1
+ 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
7
+ skip_before_action :authenticate!
8
+
9
+ def events
10
+ channel = Channels.registry[:slack]
11
+ return head :not_found unless channel
12
+
13
+ raw = request.raw_post
14
+ unless channel.verify_signature(
15
+ request.headers["X-Slack-Request-Timestamp"],
16
+ request.headers["X-Slack-Signature"],
17
+ raw
18
+ )
19
+ return head :unauthorized
20
+ end
21
+
22
+ payload = JSON.parse(raw)
23
+ return render json: { challenge: payload["challenge"] } if payload["type"] == "url_verification"
24
+
25
+ enqueue_event(payload)
26
+ head :ok
27
+ end
28
+
29
+ private
30
+
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
36
+ # without dedup rather than being dropped.
37
+ def enqueue_event(payload)
38
+ event_id = payload["event_id"]
39
+ if event_id.present?
40
+ Dedup.transaction do
41
+ SlackEventJob.perform_later(payload) if Dedup.claim("slack_event", event_id)
42
+ end
43
+ else
44
+ SlackEventJob.perform_later(payload)
45
+ end
46
+ end
47
+ end
48
+ end