rails-agent-stack 0.1.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.
- checksums.yaml +7 -0
- data/.github/workflows/README.md +16 -0
- data/.github/workflows/ci.yml +32 -0
- data/.gitignore +8 -0
- data/.rspec +3 -0
- data/AGENTS.md +18 -0
- data/CHANGELOG.md +40 -0
- data/Gemfile +15 -0
- data/MIT-LICENSE +21 -0
- data/README.md +228 -0
- data/Rakefile +8 -0
- data/app/assets/images/rails_agents/kip-logo.png +0 -0
- data/app/assets/images/rails_agents/kip-waving.png +0 -0
- data/app/assets/stylesheets/rails_agents/application.css +411 -0
- data/app/controllers/rails_agents/application_controller.rb +25 -0
- data/app/controllers/rails_agents/connect_controller.rb +36 -0
- data/app/controllers/rails_agents/dashboard_controller.rb +70 -0
- data/app/controllers/rails_agents/handshake_controller.rb +55 -0
- data/app/controllers/rails_agents/open_wire_inbound_controller.rb +153 -0
- data/app/controllers/rails_agents/pulls_controller.rb +42 -0
- data/app/controllers/rails_agents/registrations_controller.rb +96 -0
- data/app/controllers/rails_agents/schema_controller.rb +123 -0
- data/app/controllers/rails_agents/sessions_controller.rb +76 -0
- data/app/controllers/rails_agents/tool_bridge_controller.rb +67 -0
- data/app/views/layouts/rails_agents/application.html.erb +18 -0
- data/app/views/rails_agents/dashboard/show.html.erb +139 -0
- data/app/views/rails_agents/registrations/new.html.erb +83 -0
- data/app/views/rails_agents/sessions/new.html.erb +73 -0
- data/app/views/rails_agents/shared/_auth_brand.html.erb +20 -0
- data/config/routes.rb +34 -0
- data/docs/AGENTS.md +220 -0
- data/docs/PRD.md +89 -0
- data/docs/assets/hero-banner.png +0 -0
- data/examples/support/agent.rb +14 -0
- data/examples/support/channels/.keep +0 -0
- data/examples/support/evals/smoke.yml +4 -0
- data/examples/support/knowledge/shipping.md +1 -0
- data/examples/support/memory.rb +3 -0
- data/examples/support/prompt.md +9 -0
- data/examples/support/skills/.keep +0 -0
- data/examples/support/tools/.keep +0 -0
- data/exe/rails-agents +6 -0
- data/lib/generators/rails_agents/agent/agent_generator.rb +80 -0
- data/lib/generators/rails_agents/agent/templates/agent.rb.tt +14 -0
- data/lib/generators/rails_agents/agent/templates/agent_database.rb.tt +42 -0
- data/lib/generators/rails_agents/agent/templates/channels/slack.rb.tt +8 -0
- data/lib/generators/rails_agents/agent/templates/evals/smoke.yml.tt +4 -0
- data/lib/generators/rails_agents/agent/templates/imports.yml.tt +9 -0
- data/lib/generators/rails_agents/agent/templates/memory.rb.tt +6 -0
- data/lib/generators/rails_agents/agent/templates/prompt.md.tt +10 -0
- data/lib/generators/rails_agents/agent/templates/prompt_database.md.tt +14 -0
- data/lib/generators/rails_agents/install/INSTALL +10 -0
- data/lib/generators/rails_agents/install/install_generator.rb +72 -0
- data/lib/generators/rails_agents/install/templates/AGENTS.md.tt +46 -0
- data/lib/generators/rails_agents/install/templates/initializer.rb.tt +8 -0
- data/lib/generators/rails_agents/install/templates/library_README.md.tt +28 -0
- data/lib/generators/rails_agents/install/templates/library_manifest.yml.tt +5 -0
- data/lib/generators/rails_agents/install/templates/rails_agents_autoload.rb.tt +10 -0
- data/lib/rails-agent-stack.rb +4 -0
- data/lib/rails_agents/background_agent.rb +15 -0
- data/lib/rails_agents/base.rb +294 -0
- data/lib/rails_agents/chat_agent.rb +15 -0
- data/lib/rails_agents/cli.rb +245 -0
- data/lib/rails_agents/client.rb +216 -0
- data/lib/rails_agents/configuration.rb +41 -0
- data/lib/rails_agents/creation_agent.rb +10 -0
- data/lib/rails_agents/credentials_writer.rb +38 -0
- data/lib/rails_agents/engine.rb +19 -0
- data/lib/rails_agents/knowledge_agent.rb +10 -0
- data/lib/rails_agents/library_imports.rb +163 -0
- data/lib/rails_agents/local_auth.rb +56 -0
- data/lib/rails_agents/local_sync.rb +113 -0
- data/lib/rails_agents/monitoring_agent.rb +10 -0
- data/lib/rails_agents/open_wire_adapter.rb +31 -0
- data/lib/rails_agents/operations_agent.rb +10 -0
- data/lib/rails_agents/railtie.rb +14 -0
- data/lib/rails_agents/tasks.rake +8 -0
- data/lib/rails_agents/version.rb +5 -0
- data/lib/rails_agents/workflow_agent.rb +11 -0
- data/lib/rails_agents.rb +57 -0
- data/rails-agent-stack.gemspec +49 -0
- data/rails-agents.code-workspace +25 -0
- data/spec/generators/agent_generator_spec.rb +36 -0
- data/spec/rails_agents/base_spec.rb +202 -0
- data/spec/rails_agents/client_spec.rb +121 -0
- data/spec/rails_agents/configuration_spec.rb +55 -0
- data/spec/rails_agents/library_imports_spec.rb +80 -0
- data/spec/rails_agents/local_sync_spec.rb +63 -0
- data/spec/rails_agents/taxonomy_spec.rb +52 -0
- data/spec/spec_helper.rb +27 -0
- metadata +229 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RailsAgents
|
|
6
|
+
# Receives open-wire/1 message.inbound POSTs from the Open-Wire gateway.
|
|
7
|
+
# Mounted under the Rails Agents engine (no separate OpenWire::Engine).
|
|
8
|
+
#
|
|
9
|
+
# POST /agents/open_wire/inbound
|
|
10
|
+
# Headers: X-Open-Wire-Secret, X-Open-Wire-Protocol, X-Open-Wire-Installation
|
|
11
|
+
#
|
|
12
|
+
# Resolves agent via:
|
|
13
|
+
# ?agent=order_notification OR JSON meta / installation mapping in channels/*.yml
|
|
14
|
+
class OpenWireInboundController < ApplicationController
|
|
15
|
+
skip_forgery_protection
|
|
16
|
+
|
|
17
|
+
def create
|
|
18
|
+
slug = resolve_agent_slug
|
|
19
|
+
return render json: { error: "agent is required (?agent=slug)" }, status: :bad_request if slug.blank?
|
|
20
|
+
|
|
21
|
+
klass = load_agent_class(slug)
|
|
22
|
+
return render json: { error: "Agent not found" }, status: :not_found unless klass
|
|
23
|
+
|
|
24
|
+
secret = webhook_secret_for(slug)
|
|
25
|
+
return render json: { error: "Open-Wire webhook secret not configured for agent" }, status: :unauthorized if secret.blank?
|
|
26
|
+
|
|
27
|
+
message = RailsAgents::OpenWireAdapter.verify_inbound!(
|
|
28
|
+
secret: secret,
|
|
29
|
+
body: request.raw_post,
|
|
30
|
+
headers: request.headers
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
result = dispatch_to_agent(klass, message, slug)
|
|
34
|
+
|
|
35
|
+
render json: {
|
|
36
|
+
object: "open_wire_inbound_ack",
|
|
37
|
+
data: {
|
|
38
|
+
agent: slug,
|
|
39
|
+
message_id: message.id,
|
|
40
|
+
thread_id: message.thread_id,
|
|
41
|
+
result: result
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
rescue ::OpenWire::AuthError => e
|
|
45
|
+
render json: { error: e.message }, status: :unauthorized
|
|
46
|
+
rescue ::OpenWire::WebhookError => e
|
|
47
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
Rails.logger.error("[RailsAgents::OpenWireInbound] #{e.class}: #{e.message}\n#{e.backtrace&.first(8)&.join("\n")}")
|
|
50
|
+
render json: { error: "Inbound handler failed" }, status: :internal_server_error
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def resolve_agent_slug
|
|
56
|
+
params[:agent].presence ||
|
|
57
|
+
params[:agent_slug].presence ||
|
|
58
|
+
request.headers["X-Rails-Agent-Slug"].presence
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def webhook_secret_for(slug)
|
|
62
|
+
yml = channel_yml(slug)
|
|
63
|
+
return ENV["OPEN_WIRE_WEBHOOK_SECRET"].presence unless yml
|
|
64
|
+
|
|
65
|
+
yml[/open_wire_webhook_secret:\s*["']?([^"'\s]+)/, 1].presence ||
|
|
66
|
+
ENV["OPEN_WIRE_WEBHOOK_SECRET"].presence
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def installation_id_for(slug)
|
|
70
|
+
yml = channel_yml(slug)
|
|
71
|
+
return nil unless yml
|
|
72
|
+
|
|
73
|
+
yml[/open_wire_installation_id:\s*["']?([^"'\s]+)/, 1]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def channel_yml(slug)
|
|
77
|
+
%w[slack.yml slack.yaml].each do |name|
|
|
78
|
+
path = Rails.root.join("app", "agents", slug, "channels", name)
|
|
79
|
+
return path.read if path.file?
|
|
80
|
+
end
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def dispatch_to_agent(klass, message, slug)
|
|
85
|
+
# Prefer class-level on_channel_message callback when defined.
|
|
86
|
+
if klass.respond_to?(:channel_message_handler) && klass.channel_message_handler
|
|
87
|
+
return klass.channel_message_handler.call(message)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Default: forward into agent run so Cloud/HITL loop can continue.
|
|
91
|
+
run_message = [
|
|
92
|
+
"open_wire_inbound:",
|
|
93
|
+
"channel: #{message.channel}",
|
|
94
|
+
"thread_id: #{message.thread_id}",
|
|
95
|
+
"from: #{message.from_id}",
|
|
96
|
+
"to: #{message.to_id}",
|
|
97
|
+
"kind: #{message.to_kind}",
|
|
98
|
+
"text: #{message.text}"
|
|
99
|
+
].join("\n")
|
|
100
|
+
|
|
101
|
+
session_id = message.thread_id.present? ? "ow:#{message.thread_id}" : nil
|
|
102
|
+
result = klass.run(run_message, session_id: session_id)
|
|
103
|
+
|
|
104
|
+
maybe_auto_reply(slug, message, result)
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
mode: "agent_run",
|
|
108
|
+
run_id: (result.respond_to?(:id) ? result.id : nil),
|
|
109
|
+
output_preview: result.to_s.to_s[0, 240]
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def maybe_auto_reply(slug, message, result)
|
|
114
|
+
installation_id = message.installation_id.presence || installation_id_for(slug)
|
|
115
|
+
return unless installation_id && message.to_id.present?
|
|
116
|
+
|
|
117
|
+
text =
|
|
118
|
+
if result.respond_to?(:output)
|
|
119
|
+
result.output.to_s
|
|
120
|
+
else
|
|
121
|
+
result.to_s
|
|
122
|
+
end
|
|
123
|
+
text = text.strip
|
|
124
|
+
return if text.empty?
|
|
125
|
+
|
|
126
|
+
# Cap auto-reply length for Slack
|
|
127
|
+
text = text[0, 2800]
|
|
128
|
+
RailsAgents::OpenWireAdapter.reply!(
|
|
129
|
+
installation_id: installation_id,
|
|
130
|
+
to: { id: message.to_id, kind: message.dm? ? "dm" : "channel" },
|
|
131
|
+
text: text,
|
|
132
|
+
thread_id: message.thread_id
|
|
133
|
+
)
|
|
134
|
+
rescue ::OpenWire::Error => e
|
|
135
|
+
Rails.logger.warn("[RailsAgents::OpenWireInbound] auto-reply skipped: #{e.message}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def load_agent_class(slug)
|
|
139
|
+
safe_slug = slug.to_s
|
|
140
|
+
return nil unless safe_slug.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
141
|
+
|
|
142
|
+
path = Rails.root.join("app", "agents", safe_slug, "agent.rb")
|
|
143
|
+
return nil unless path.file?
|
|
144
|
+
|
|
145
|
+
source = path.read
|
|
146
|
+
class_name = source[/class\s+([A-Z][A-Za-z0-9_:]*)\s*<\s*RailsAgents::/, 1]
|
|
147
|
+
return nil unless class_name
|
|
148
|
+
|
|
149
|
+
require_dependency path.to_s
|
|
150
|
+
class_name.constantize
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module RailsAgents
|
|
6
|
+
class PullsController < ApplicationController
|
|
7
|
+
# Deprecated: JSON pull from embed bridge. Prefer local `app/agents/` + `rails-agents sync`.
|
|
8
|
+
# Kept for CLI `rails-agents pull` and backward compatibility until cloud authoring is removed.
|
|
9
|
+
def create
|
|
10
|
+
load_local_credentials!
|
|
11
|
+
|
|
12
|
+
unless RailsAgents.config.configured?
|
|
13
|
+
return render json: { ok: false, error: "Not connected to Rails Agent Cloud" }, status: :unprocessable_entity
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
agent_id = params[:agent_id].presence || params[:agent].presence || params.dig(:pull, :agent_id)
|
|
17
|
+
if agent_id.blank?
|
|
18
|
+
return render json: { ok: false, error: "agent_id is required" }, status: :bad_request
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
result = LocalSync.new.pull!(agent_id)
|
|
22
|
+
render json: result
|
|
23
|
+
rescue LocalSync::Error, Client::Error => e
|
|
24
|
+
render json: { ok: false, error: e.message }, status: :unprocessable_entity
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def load_local_credentials!
|
|
30
|
+
path = Rails.root.join("config/rails_agents_credentials.yml")
|
|
31
|
+
return unless path.exist?
|
|
32
|
+
|
|
33
|
+
data = YAML.safe_load(path.read) || {}
|
|
34
|
+
RailsAgents.configure do |config|
|
|
35
|
+
config.api_key = data["api_key"] if data["api_key"].present?
|
|
36
|
+
config.project_id = data["project_id"] if data["project_id"].present?
|
|
37
|
+
end
|
|
38
|
+
rescue Psych::SyntaxError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgents
|
|
4
|
+
class RegistrationsController < ApplicationController
|
|
5
|
+
include LocalAuth
|
|
6
|
+
|
|
7
|
+
before_action :redirect_if_configured!
|
|
8
|
+
|
|
9
|
+
def new
|
|
10
|
+
@github_url = github_oauth_url
|
|
11
|
+
pending = session[:ra_auth]
|
|
12
|
+
if pending.is_a?(Hash) && pending["email"].present? && pending["purpose"] == "signup"
|
|
13
|
+
@step = :code
|
|
14
|
+
@email = pending["email"]
|
|
15
|
+
@name = pending["name"]
|
|
16
|
+
@workspace = pending["workspace"].presence || default_workspace
|
|
17
|
+
@dev_code = pending["dev_code"]
|
|
18
|
+
else
|
|
19
|
+
@step = :email
|
|
20
|
+
@workspace = default_workspace
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def create
|
|
25
|
+
@github_url = github_oauth_url
|
|
26
|
+
@workspace = params[:workspace].to_s.strip.presence || default_workspace
|
|
27
|
+
@name = params[:name].to_s.strip
|
|
28
|
+
@email = params[:email].to_s.strip.downcase
|
|
29
|
+
|
|
30
|
+
if @email.blank?
|
|
31
|
+
flash.now[:alert] = "Email is required."
|
|
32
|
+
@step = :email
|
|
33
|
+
render :new, status: :unprocessable_entity
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
response = Client.new.email_start(
|
|
38
|
+
email: @email,
|
|
39
|
+
purpose: "signup",
|
|
40
|
+
name: @name.presence,
|
|
41
|
+
company: @workspace
|
|
42
|
+
)
|
|
43
|
+
session[:ra_auth] = {
|
|
44
|
+
"purpose" => "signup",
|
|
45
|
+
"email" => @email,
|
|
46
|
+
"name" => @name,
|
|
47
|
+
"workspace" => @workspace,
|
|
48
|
+
"dev_code" => response["devCode"] || response["dev_code"]
|
|
49
|
+
}
|
|
50
|
+
@step = :code
|
|
51
|
+
@dev_code = session[:ra_auth]["dev_code"]
|
|
52
|
+
render :new
|
|
53
|
+
rescue Client::Error => e
|
|
54
|
+
flash.now[:alert] = e.message
|
|
55
|
+
@step = :email
|
|
56
|
+
render :new, status: :unprocessable_entity
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def verify
|
|
60
|
+
pending = session[:ra_auth] || {}
|
|
61
|
+
@email = pending["email"].to_s
|
|
62
|
+
@name = pending["name"].to_s
|
|
63
|
+
@workspace = pending["workspace"].to_s
|
|
64
|
+
@github_url = github_oauth_url
|
|
65
|
+
code = params[:code].to_s.strip
|
|
66
|
+
|
|
67
|
+
if @email.blank? || code.blank?
|
|
68
|
+
flash.now[:alert] = "Enter the code we emailed you."
|
|
69
|
+
@step = :code
|
|
70
|
+
render :new, status: :unprocessable_entity
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
response = Client.new.email_verify(
|
|
75
|
+
email: @email,
|
|
76
|
+
code: code,
|
|
77
|
+
name: @name.presence,
|
|
78
|
+
company: @workspace.presence
|
|
79
|
+
)
|
|
80
|
+
session.delete(:ra_auth)
|
|
81
|
+
complete_cloud_auth!(response)
|
|
82
|
+
rescue Client::Error => e
|
|
83
|
+
flash.now[:alert] = e.message
|
|
84
|
+
@step = :code
|
|
85
|
+
@dev_code = pending["dev_code"]
|
|
86
|
+
@github_url = github_oauth_url
|
|
87
|
+
render :new, status: :unprocessable_entity
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def default_workspace
|
|
93
|
+
(Rails.application.class.module_parent_name rescue "MyApp")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgents
|
|
4
|
+
class SchemaController < ApplicationController
|
|
5
|
+
# GET /agents/schema — table list for Knowledge → Rails database connector
|
|
6
|
+
def show
|
|
7
|
+
databases = configured_databases
|
|
8
|
+
tables = active_record_tables
|
|
9
|
+
tables.concat(mongoid_collections) if tables.empty? || databases.any? { |db| db[:adapter] == "mongoid" }
|
|
10
|
+
primary = databases.find { |db| db[:name] == "primary" } || databases.first
|
|
11
|
+
|
|
12
|
+
render json: {
|
|
13
|
+
ok: true,
|
|
14
|
+
tables: tables,
|
|
15
|
+
adapter: primary&.dig(:adapter),
|
|
16
|
+
databases: databases,
|
|
17
|
+
config_files: detected_config_files
|
|
18
|
+
}
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
render json: { ok: false, error: e.message, tables: [] }, status: :unprocessable_entity
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def configured_databases
|
|
26
|
+
configs = []
|
|
27
|
+
if defined?(ActiveRecord::Base)
|
|
28
|
+
ActiveRecord::Base.configurations.configs_for(env_name: Rails.env).each do |db|
|
|
29
|
+
configs << {
|
|
30
|
+
name: db.name.to_s,
|
|
31
|
+
adapter: normalize_adapter(db.adapter),
|
|
32
|
+
raw_adapter: db.adapter.to_s,
|
|
33
|
+
source: "config/database.yml"
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
if Rails.root.join("config/mongoid.yml").exist?
|
|
38
|
+
configs << {
|
|
39
|
+
name: "mongoid",
|
|
40
|
+
adapter: "mongoid",
|
|
41
|
+
raw_adapter: "mongodb",
|
|
42
|
+
source: "config/mongoid.yml"
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
configs.uniq { |db| [db[:name], db[:adapter], db[:source]] }
|
|
46
|
+
rescue StandardError
|
|
47
|
+
[]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def active_record_tables
|
|
51
|
+
return [] unless defined?(ActiveRecord::Base)
|
|
52
|
+
|
|
53
|
+
ActiveRecord::Base.connection.tables
|
|
54
|
+
.reject { |table| internal_table?(table) }
|
|
55
|
+
.sort
|
|
56
|
+
.map do |name|
|
|
57
|
+
columns =
|
|
58
|
+
begin
|
|
59
|
+
ActiveRecord::Base.connection.columns(name).map(&:name)
|
|
60
|
+
rescue StandardError
|
|
61
|
+
[]
|
|
62
|
+
end
|
|
63
|
+
{
|
|
64
|
+
name: name,
|
|
65
|
+
columns: columns.first(40),
|
|
66
|
+
kind: "table",
|
|
67
|
+
samples: sample_rows(name, columns)
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
rescue StandardError
|
|
71
|
+
[]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def sample_rows(_table, columns)
|
|
75
|
+
return [] if columns.blank?
|
|
76
|
+
|
|
77
|
+
quoted = ActiveRecord::Base.connection.quote_table_name(_table)
|
|
78
|
+
rows = ActiveRecord::Base.connection.exec_query("SELECT * FROM #{quoted} LIMIT 8").to_a
|
|
79
|
+
rows.map do |row|
|
|
80
|
+
row.transform_values { |value| truncate_sample(value) }
|
|
81
|
+
end
|
|
82
|
+
rescue StandardError
|
|
83
|
+
[]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def truncate_sample(value)
|
|
87
|
+
text = value.is_a?(String) ? value : value.inspect
|
|
88
|
+
text.length > 120 ? "#{text[0, 117]}..." : text
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def mongoid_collections
|
|
92
|
+
return [] unless defined?(Mongoid) && Mongoid.respond_to?(:default_client)
|
|
93
|
+
|
|
94
|
+
Mongoid.default_client.database.collection_names
|
|
95
|
+
.reject { |name| name.start_with?("system.") }
|
|
96
|
+
.sort
|
|
97
|
+
.map { |name| { name: name, columns: [], kind: "collection", samples: [] } }
|
|
98
|
+
rescue StandardError
|
|
99
|
+
[]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def detected_config_files
|
|
103
|
+
%w[config/database.yml config/mongoid.yml]
|
|
104
|
+
.select { |path| Rails.root.join(path).exist? }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def normalize_adapter(adapter)
|
|
108
|
+
value = adapter.to_s.downcase
|
|
109
|
+
return "postgresql" if value.include?("postgres") || value.include?("postgis") || value.include?("cockroach")
|
|
110
|
+
return "mysql" if value.include?("mysql") || value.include?("trilogy") || value.include?("maria")
|
|
111
|
+
return "sqlite" if value.include?("sqlite")
|
|
112
|
+
return "sqlserver" if value.include?("sqlserver")
|
|
113
|
+
return "oracle" if value.include?("oracle")
|
|
114
|
+
|
|
115
|
+
value.presence || "active_record"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def internal_table?(table)
|
|
119
|
+
table.start_with?("ar_", "solid_", "rails_agents") ||
|
|
120
|
+
%w[schema_migrations ar_internal_metadata].include?(table)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgents
|
|
4
|
+
class SessionsController < ApplicationController
|
|
5
|
+
include LocalAuth
|
|
6
|
+
|
|
7
|
+
before_action :redirect_if_configured!, only: %i[new create verify]
|
|
8
|
+
|
|
9
|
+
def new
|
|
10
|
+
@github_url = github_oauth_url
|
|
11
|
+
pending = session[:ra_auth]
|
|
12
|
+
if pending.is_a?(Hash) && pending["email"].present? && pending["purpose"] == "signin"
|
|
13
|
+
@step = :code
|
|
14
|
+
@email = pending["email"]
|
|
15
|
+
@dev_code = pending["dev_code"]
|
|
16
|
+
else
|
|
17
|
+
@step = :email
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create
|
|
22
|
+
@github_url = github_oauth_url
|
|
23
|
+
@email = params[:email].to_s.strip.downcase
|
|
24
|
+
|
|
25
|
+
if @email.blank?
|
|
26
|
+
flash.now[:alert] = "Email is required."
|
|
27
|
+
@step = :email
|
|
28
|
+
render :new, status: :unprocessable_entity
|
|
29
|
+
return
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
response = Client.new.email_start(email: @email, purpose: "signin")
|
|
33
|
+
session[:ra_auth] = {
|
|
34
|
+
"purpose" => "signin",
|
|
35
|
+
"email" => @email,
|
|
36
|
+
"dev_code" => response["devCode"] || response["dev_code"]
|
|
37
|
+
}
|
|
38
|
+
@step = :code
|
|
39
|
+
@dev_code = session[:ra_auth]["dev_code"]
|
|
40
|
+
render :new
|
|
41
|
+
rescue Client::Error => e
|
|
42
|
+
flash.now[:alert] = e.message
|
|
43
|
+
@step = :email
|
|
44
|
+
render :new, status: :unprocessable_entity
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def verify
|
|
48
|
+
pending = session[:ra_auth] || {}
|
|
49
|
+
@email = pending["email"].to_s
|
|
50
|
+
@github_url = github_oauth_url
|
|
51
|
+
code = params[:code].to_s.strip
|
|
52
|
+
|
|
53
|
+
if @email.blank? || code.blank?
|
|
54
|
+
flash.now[:alert] = "Enter the code we emailed you."
|
|
55
|
+
@step = :code
|
|
56
|
+
render :new, status: :unprocessable_entity
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
response = Client.new.email_verify(email: @email, code: code)
|
|
61
|
+
session.delete(:ra_auth)
|
|
62
|
+
complete_cloud_auth!(response)
|
|
63
|
+
rescue Client::Error => e
|
|
64
|
+
flash.now[:alert] = e.message
|
|
65
|
+
@step = :code
|
|
66
|
+
@dev_code = pending["dev_code"]
|
|
67
|
+
render :new, status: :unprocessable_entity
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def destroy
|
|
71
|
+
cloud = RailsAgents.config.dashboard_base.to_s.chomp("/")
|
|
72
|
+
cloud = "https://cloud.rails-agent.com" if cloud.blank?
|
|
73
|
+
redirect_to "#{cloud}/signin", allow_other_host: true
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module RailsAgents
|
|
9
|
+
class ToolBridgeController < ApplicationController
|
|
10
|
+
skip_forgery_protection
|
|
11
|
+
|
|
12
|
+
def create
|
|
13
|
+
return render json: { error: "Unauthorized" }, status: :unauthorized unless authorized_runtime?
|
|
14
|
+
|
|
15
|
+
klass = load_agent_class(params[:agent])
|
|
16
|
+
tool = klass&.tool_definitions&.fetch(params[:tool].to_sym, nil)
|
|
17
|
+
return render json: { error: "Tool not found" }, status: :not_found unless tool
|
|
18
|
+
|
|
19
|
+
arguments = request.request_parameters.presence || {}
|
|
20
|
+
result = tool.call(**arguments.deep_symbolize_keys)
|
|
21
|
+
render json: { ok: true, result: result }
|
|
22
|
+
rescue ArgumentError => e
|
|
23
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
Rails.logger.error("[RailsAgents::ToolBridge] #{e.class}: #{e.message}")
|
|
26
|
+
render json: { error: "Tool execution failed" }, status: :internal_server_error
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def authorized_runtime?
|
|
32
|
+
token = request.authorization.to_s.sub(/\ABearer\s+/i, "")
|
|
33
|
+
return false if token.empty?
|
|
34
|
+
|
|
35
|
+
uri = URI("#{RailsAgents.config.api_v1_base}/auth/introspect")
|
|
36
|
+
req = Net::HTTP::Get.new(uri)
|
|
37
|
+
req["Authorization"] = "Bearer #{token}"
|
|
38
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
39
|
+
http.open_timeout = 3
|
|
40
|
+
http.read_timeout = 5
|
|
41
|
+
http.request(req)
|
|
42
|
+
end
|
|
43
|
+
return false unless response.is_a?(Net::HTTPSuccess)
|
|
44
|
+
|
|
45
|
+
body = JSON.parse(response.body)
|
|
46
|
+
body.dig("data", "projectId").to_s == RailsAgents.config.project_id.to_s
|
|
47
|
+
rescue JSON::ParserError, IOError, SystemCallError, Timeout::Error
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def load_agent_class(slug)
|
|
52
|
+
safe_slug = slug.to_s
|
|
53
|
+
return nil unless safe_slug.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
54
|
+
|
|
55
|
+
path = Rails.root.join("app", "agents", safe_slug, "agent.rb")
|
|
56
|
+
return nil unless path.file?
|
|
57
|
+
|
|
58
|
+
source = path.read
|
|
59
|
+
class_name = source[/class\s+([A-Z][A-Za-z0-9_:]*)\s*<\s*RailsAgents::/, 1]
|
|
60
|
+
return nil unless class_name
|
|
61
|
+
|
|
62
|
+
require_dependency path.to_s
|
|
63
|
+
class_name.constantize
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Rails Agent</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,500;0,9..144,600;0,9..144,700;1,9..144,500;1,9..144,600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
10
|
+
<%= csrf_meta_tags if respond_to?(:csrf_meta_tags) %>
|
|
11
|
+
<%= stylesheet_link_tag "rails_agents/application", media: "all" %>
|
|
12
|
+
</head>
|
|
13
|
+
<body class="rails-agents-body">
|
|
14
|
+
<main class="<%= configured? ? "rails-agents-main--embed" : nil %>">
|
|
15
|
+
<%= yield %>
|
|
16
|
+
</main>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|