fosm-rails-coding-agent 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.
- checksums.yaml +7 -0
- data/AGENTS.md +77 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE +127 -0
- data/README.md +135 -0
- data/bin/fosm-coding-agent +14 -0
- data/lib/fosm_rails_coding_agent/acp_agent.rb +132 -0
- data/lib/fosm_rails_coding_agent/configuration.rb +27 -0
- data/lib/fosm_rails_coding_agent/exceptions_middleware.rb +67 -0
- data/lib/fosm_rails_coding_agent/middleware.rb +115 -0
- data/lib/fosm_rails_coding_agent/quiet_middleware.rb +18 -0
- data/lib/fosm_rails_coding_agent/railtie.rb +51 -0
- data/lib/fosm_rails_coding_agent/tools/base.rb +10 -0
- data/lib/fosm_rails_coding_agent/tools/execute_sql.rb +44 -0
- data/lib/fosm_rails_coding_agent/tools/fosm/available_events.rb +46 -0
- data/lib/fosm_rails_coding_agent/tools/fosm/fire_event.rb +46 -0
- data/lib/fosm_rails_coding_agent/tools/fosm/inspect_lifecycle.rb +44 -0
- data/lib/fosm_rails_coding_agent/tools/fosm/list_lifecycles.rb +45 -0
- data/lib/fosm_rails_coding_agent/tools/fosm/model_resolver.rb +25 -0
- data/lib/fosm_rails_coding_agent/tools/fosm/transition_history.rb +56 -0
- data/lib/fosm_rails_coding_agent/tools/fosm/why_blocked.rb +36 -0
- data/lib/fosm_rails_coding_agent/tools/get_docs.rb +58 -0
- data/lib/fosm_rails_coding_agent/tools/get_logs.rb +66 -0
- data/lib/fosm_rails_coding_agent/tools/get_models.rb +35 -0
- data/lib/fosm_rails_coding_agent/tools/get_source_location.rb +69 -0
- data/lib/fosm_rails_coding_agent/tools/project_eval.rb +76 -0
- data/lib/fosm_rails_coding_agent/transport.rb +84 -0
- data/lib/fosm_rails_coding_agent/version.rb +5 -0
- data/lib/fosm_rails_coding_agent.rb +26 -0
- metadata +139 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "fast_mcp"
|
|
5
|
+
require "rack/request"
|
|
6
|
+
require "json"
|
|
7
|
+
require_relative "transport"
|
|
8
|
+
|
|
9
|
+
module FosmRailsCodingAgent
|
|
10
|
+
# Rack middleware that serves the FosmRailsCodingAgent MCP server.
|
|
11
|
+
# Handles tool registration, localhost enforcement, and MCP routing.
|
|
12
|
+
# FOSM tools are registered conditionally when fosm-rails is detected.
|
|
13
|
+
class Middleware
|
|
14
|
+
ROUTE_PREFIX = "fosm-agent"
|
|
15
|
+
MCP_ROUTE = "mcp"
|
|
16
|
+
|
|
17
|
+
REMOTE_BLOCKED = <<~MSG.freeze
|
|
18
|
+
FosmRailsCodingAgent does not accept remote connections by default.
|
|
19
|
+
Set config.fosm_coding_agent.allow_remote_access = true if you need this.
|
|
20
|
+
MSG
|
|
21
|
+
|
|
22
|
+
def initialize(app, config)
|
|
23
|
+
@allow_remote = config.allow_remote_access
|
|
24
|
+
@project_name = Rails.application.class.module_parent.name
|
|
25
|
+
|
|
26
|
+
@app = FastMcp.rack_middleware(app,
|
|
27
|
+
name: "fosm-rails-coding-agent",
|
|
28
|
+
version: FosmRailsCodingAgent::VERSION,
|
|
29
|
+
path_prefix: "/#{ROUTE_PREFIX}/#{MCP_ROUTE}",
|
|
30
|
+
transport: FosmRailsCodingAgent::Transport,
|
|
31
|
+
logger: config.logger || Logger.new(Rails.root.join("log", "fosm-coding-agent.log")),
|
|
32
|
+
allowed_origins: [],
|
|
33
|
+
localhost_only: false
|
|
34
|
+
) do |server|
|
|
35
|
+
# Register core tools (always available)
|
|
36
|
+
core_tools = FosmRailsCodingAgent::Tools::Base.descendants.reject do |tool|
|
|
37
|
+
tool.module_parents.include?(FosmRailsCodingAgent::Tools::Fosm)
|
|
38
|
+
end
|
|
39
|
+
server.register_tools(*core_tools)
|
|
40
|
+
|
|
41
|
+
# Register FOSM tools only when fosm-rails is loaded
|
|
42
|
+
if FosmRailsCodingAgent.fosm_available?
|
|
43
|
+
fosm_tools = FosmRailsCodingAgent::Tools::Base.descendants.select do |tool|
|
|
44
|
+
tool.module_parents.include?(FosmRailsCodingAgent::Tools::Fosm)
|
|
45
|
+
end
|
|
46
|
+
server.register_tools(*fosm_tools) if fosm_tools.any?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def call(env)
|
|
52
|
+
request = Rack::Request.new(env)
|
|
53
|
+
path = request.path.split("/").reject(&:empty?)
|
|
54
|
+
|
|
55
|
+
if path.first == ROUTE_PREFIX
|
|
56
|
+
return forbidden(REMOTE_BLOCKED) unless local_ip?(request)
|
|
57
|
+
|
|
58
|
+
case [request.request_method, path]
|
|
59
|
+
when ["GET", [ROUTE_PREFIX]]
|
|
60
|
+
return home_page
|
|
61
|
+
when ["GET", [ROUTE_PREFIX, "config"]]
|
|
62
|
+
return config_endpoint
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
status, headers, body = @app.call(env)
|
|
67
|
+
headers.delete("X-Frame-Options")
|
|
68
|
+
[status, headers, body]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def home_page
|
|
74
|
+
[200, { "Content-Type" => "text/html" }, [<<~HTML]]
|
|
75
|
+
<!DOCTYPE html>
|
|
76
|
+
<html>
|
|
77
|
+
<head><meta charset="UTF-8"><title>FosmRailsCodingAgent</title></head>
|
|
78
|
+
<body>
|
|
79
|
+
<h1>FosmRailsCodingAgent</h1>
|
|
80
|
+
<p>FOSM-aware runtime intelligence is active.</p>
|
|
81
|
+
<p>MCP endpoint: <code>/fosm-agent/mcp</code></p>
|
|
82
|
+
<p>FOSM: #{FosmRailsCodingAgent.fosm_available? ? "detected" : "not detected"}</p>
|
|
83
|
+
<p>Project: #{@project_name}</p>
|
|
84
|
+
</body>
|
|
85
|
+
</html>
|
|
86
|
+
HTML
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def config_endpoint
|
|
90
|
+
[200, { "Content-Type" => "application/json" }, [JSON.generate({
|
|
91
|
+
project_name: @project_name,
|
|
92
|
+
framework: "rails",
|
|
93
|
+
fosm_agent_version: FosmRailsCodingAgent::VERSION,
|
|
94
|
+
fosm_available: FosmRailsCodingAgent.fosm_available?
|
|
95
|
+
})]]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def forbidden(message)
|
|
99
|
+
Rails.logger.warn(message)
|
|
100
|
+
[403, { "Content-Type" => "text/plain" }, [message]]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def local_ip?(request)
|
|
104
|
+
return true if @allow_remote
|
|
105
|
+
|
|
106
|
+
addr = IPAddr.new(request.ip.to_s)
|
|
107
|
+
addr.loopback? ||
|
|
108
|
+
addr == IPAddr.new("127.0.0.1") ||
|
|
109
|
+
addr == IPAddr.new("::1") ||
|
|
110
|
+
addr == IPAddr.new("::ffff:127.0.0.1")
|
|
111
|
+
rescue IPAddr::InvalidAddressError
|
|
112
|
+
false
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FosmRailsCodingAgent
|
|
4
|
+
# Suppresses FosmRailsCodingAgent request noise from the Rails development log.
|
|
5
|
+
class QuietMiddleware
|
|
6
|
+
def initialize(app)
|
|
7
|
+
@app = app
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(env)
|
|
11
|
+
if env["PATH_INFO"].start_with?("/fosm-agent")
|
|
12
|
+
Rails.logger.silence { @app.call(env) }
|
|
13
|
+
else
|
|
14
|
+
@app.call(env)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require "fosm_rails_coding_agent/configuration"
|
|
5
|
+
require "fosm_rails_coding_agent/middleware"
|
|
6
|
+
require "fosm_rails_coding_agent/exceptions_middleware"
|
|
7
|
+
require "fosm_rails_coding_agent/quiet_middleware"
|
|
8
|
+
|
|
9
|
+
# Load all tools
|
|
10
|
+
Dir[File.expand_path("tools/**/*.rb", __dir__)].each { |f| require f }
|
|
11
|
+
|
|
12
|
+
module FosmRailsCodingAgent
|
|
13
|
+
# Railtie that wires FosmRailsCodingAgent into the Rails middleware stack.
|
|
14
|
+
# Refuses to load in production (requires config.enable_reloading).
|
|
15
|
+
# Sets up MCP server middleware and optionally starts the ACP agent.
|
|
16
|
+
class Railtie < Rails::Railtie
|
|
17
|
+
config.fosm_coding_agent = FosmRailsCodingAgent::Configuration.new
|
|
18
|
+
|
|
19
|
+
initializer "fosm_coding_agent.setup" do |app|
|
|
20
|
+
unless app.config.enable_reloading
|
|
21
|
+
raise "fosm-rails-coding-agent is a development tool. It requires config.enable_reloading = true."
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
app.config.middleware.insert_after(
|
|
25
|
+
ActionDispatch::Callbacks,
|
|
26
|
+
FosmRailsCodingAgent::Middleware,
|
|
27
|
+
app.config.fosm_coding_agent
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
initializer "fosm_coding_agent.intercept_exceptions" do |app|
|
|
32
|
+
ActionDispatch::DebugExceptions.register_interceptor do |request, exception|
|
|
33
|
+
request.set_header("fosm_agent.exception", exception)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
app.middleware.insert_before(ActionDispatch::DebugExceptions, FosmRailsCodingAgent::ExceptionsMiddleware)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
initializer "fosm_coding_agent.quiet_logging" do |app|
|
|
40
|
+
app.middleware.insert_before(Rails::Rack::Logger, FosmRailsCodingAgent::QuietMiddleware)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
initializer "fosm_coding_agent.acp_agent" do |app|
|
|
44
|
+
next unless app.config.fosm_coding_agent.acp_enabled
|
|
45
|
+
|
|
46
|
+
# Register the ACP agent binary path so clients can discover it
|
|
47
|
+
agent_binary = File.expand_path("../../bin/fosm-agent-agent", __dir__)
|
|
48
|
+
Rails.logger&.info("[FosmRailsCodingAgent] ACP agent available at: #{agent_binary}")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FosmRailsCodingAgent
|
|
4
|
+
module Tools
|
|
5
|
+
# Executes SQL queries against the application database.
|
|
6
|
+
# Results are capped at the configured row limit (default 50).
|
|
7
|
+
class ExecuteSql < Base
|
|
8
|
+
tool_name "execute_sql"
|
|
9
|
+
description <<~DESC
|
|
10
|
+
Executes the given SQL query against the application database.
|
|
11
|
+
Results are limited to #{FosmRailsCodingAgent::Configuration.new.sql_row_limit} rows.
|
|
12
|
+
Use LIMIT/OFFSET for pagination. Select only the columns you need.
|
|
13
|
+
|
|
14
|
+
For PostgreSQL, use $1, $2 for parameter placeholders.
|
|
15
|
+
For MySQL, use ? for parameter placeholders.
|
|
16
|
+
DESC
|
|
17
|
+
|
|
18
|
+
arguments do
|
|
19
|
+
required(:query).filled(:string).description("The SQL query to execute")
|
|
20
|
+
optional(:arguments).value(:array).description("Bind parameters for the query placeholders")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def @input_schema.json_schema
|
|
24
|
+
schema = super
|
|
25
|
+
schema[:properties][:arguments][:items] = {}
|
|
26
|
+
schema
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(query:, arguments: [])
|
|
30
|
+
result = ActiveRecord::Base.connection.exec_query(query, "FOSM Agent SQL", arguments)
|
|
31
|
+
limit = FosmRailsCodingAgent.config.sql_row_limit
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
columns: result.columns,
|
|
35
|
+
rows: result.rows.first(limit),
|
|
36
|
+
row_count: result.rows.size,
|
|
37
|
+
truncated: result.rows.size > limit,
|
|
38
|
+
adapter: ActiveRecord::Base.connection.adapter_name,
|
|
39
|
+
database: ActiveRecord::Base.connection.current_database
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "model_resolver"
|
|
5
|
+
|
|
6
|
+
module FosmRailsCodingAgent
|
|
7
|
+
module Tools
|
|
8
|
+
module Fosm
|
|
9
|
+
# Lists events that can be fired from a record's current state,
|
|
10
|
+
# considering both transition validity and guard evaluation.
|
|
11
|
+
class AvailableEvents < FosmRailsCodingAgent::Tools::Base
|
|
12
|
+
include FosmRailsCodingAgent::Tools::Fosm::ModelResolver
|
|
13
|
+
tool_name "fosm_available_events"
|
|
14
|
+
description <<~DESC
|
|
15
|
+
Returns events that can fire right now on a FOSM record.
|
|
16
|
+
Checks current state, transition rules, and guard conditions.
|
|
17
|
+
Always call this before firing an event.
|
|
18
|
+
DESC
|
|
19
|
+
|
|
20
|
+
arguments do
|
|
21
|
+
required(:model).filled(:string).description("Model class name, e.g. 'Invoice'")
|
|
22
|
+
required(:id).filled(:integer).description("Record ID")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(model:, id:)
|
|
26
|
+
klass = resolve_model(model)
|
|
27
|
+
record = klass.find(id)
|
|
28
|
+
lifecycle = klass.fosm_lifecycle
|
|
29
|
+
|
|
30
|
+
available = record.available_events
|
|
31
|
+
events_detail = available.map do |event_name|
|
|
32
|
+
event_def = lifecycle.find_event(event_name)
|
|
33
|
+
{ name: event_name, to_state: event_def.to_state }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
JSON.generate({
|
|
37
|
+
model: klass.name,
|
|
38
|
+
id: record.id,
|
|
39
|
+
current_state: record.state,
|
|
40
|
+
available_events: events_detail
|
|
41
|
+
})
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "model_resolver"
|
|
5
|
+
|
|
6
|
+
module FosmRailsCodingAgent
|
|
7
|
+
module Tools
|
|
8
|
+
module Fosm
|
|
9
|
+
# Fires a FOSM lifecycle event on a record.
|
|
10
|
+
# Always uses actor: :agent for audit trail attribution.
|
|
11
|
+
class FireEvent < FosmRailsCodingAgent::Tools::Base
|
|
12
|
+
include FosmRailsCodingAgent::Tools::Fosm::ModelResolver
|
|
13
|
+
tool_name "fosm_fire_event"
|
|
14
|
+
description <<~DESC
|
|
15
|
+
Fires a lifecycle event on a FOSM record.
|
|
16
|
+
The event is attributed to actor: :agent in the audit trail.
|
|
17
|
+
Always check available_events first to avoid errors.
|
|
18
|
+
DESC
|
|
19
|
+
|
|
20
|
+
arguments do
|
|
21
|
+
required(:model).filled(:string).description("Model class name, e.g. 'Invoice'")
|
|
22
|
+
required(:id).filled(:integer).description("Record ID")
|
|
23
|
+
required(:event).filled(:string).description("Event name to fire, e.g. 'send_invoice'")
|
|
24
|
+
optional(:metadata).filled(:hash).description("Optional metadata for the transition log")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(model:, id:, event:, metadata: {})
|
|
28
|
+
klass = resolve_model(model)
|
|
29
|
+
record = klass.find(id)
|
|
30
|
+
record.fire!(event.to_sym, actor: :agent, metadata: metadata)
|
|
31
|
+
|
|
32
|
+
JSON.generate({
|
|
33
|
+
success: true,
|
|
34
|
+
model: klass.name,
|
|
35
|
+
id: record.id,
|
|
36
|
+
event: event,
|
|
37
|
+
new_state: record.state,
|
|
38
|
+
available_events: record.available_events
|
|
39
|
+
})
|
|
40
|
+
rescue ::Fosm::Error => e
|
|
41
|
+
JSON.generate({ success: false, error: e.message })
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "model_resolver"
|
|
5
|
+
|
|
6
|
+
module FosmRailsCodingAgent
|
|
7
|
+
module Tools
|
|
8
|
+
module Fosm
|
|
9
|
+
# Deep introspection of a single FOSM lifecycle: states, events,
|
|
10
|
+
# guards, side effects, access rules, and diagram data.
|
|
11
|
+
class InspectLifecycle < FosmRailsCodingAgent::Tools::Base
|
|
12
|
+
include FosmRailsCodingAgent::Tools::Fosm::ModelResolver
|
|
13
|
+
tool_name "fosm_inspect_lifecycle"
|
|
14
|
+
description <<~DESC
|
|
15
|
+
Deep introspection of one FOSM lifecycle model class.
|
|
16
|
+
Returns states, events with transitions, guards, side effects,
|
|
17
|
+
access control rules, snapshot configuration, and diagram data.
|
|
18
|
+
DESC
|
|
19
|
+
|
|
20
|
+
arguments do
|
|
21
|
+
required(:model).filled(:string).description("The model class name, e.g. 'Invoice' or 'Fosm::Invoice'")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(model:)
|
|
25
|
+
klass = resolve_model(model)
|
|
26
|
+
lifecycle = klass.fosm_lifecycle
|
|
27
|
+
raise "#{klass.name} has no FOSM lifecycle defined" unless lifecycle
|
|
28
|
+
|
|
29
|
+
result = {
|
|
30
|
+
model: klass.name,
|
|
31
|
+
states: inspect_states(lifecycle),
|
|
32
|
+
events: inspect_events(lifecycle),
|
|
33
|
+
diagram: lifecycle.to_diagram_data
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
result[:access] = inspect_access(lifecycle) if lifecycle.access_defined?
|
|
37
|
+
result[:snapshots] = inspect_snapshots(lifecycle) if lifecycle.snapshot_configured?
|
|
38
|
+
|
|
39
|
+
JSON.pretty_generate(result)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FosmRailsCodingAgent
|
|
4
|
+
module Tools
|
|
5
|
+
module Fosm
|
|
6
|
+
# Lists all FOSM lifecycle model classes registered in the application,
|
|
7
|
+
# with a summary of their states, events, and guards.
|
|
8
|
+
class ListLifecycles < FosmRailsCodingAgent::Tools::Base
|
|
9
|
+
tool_name "fosm_list_lifecycles"
|
|
10
|
+
description <<~DESC
|
|
11
|
+
Lists all FOSM lifecycle model classes in the application.
|
|
12
|
+
Returns each model with its states, events, and guard names.
|
|
13
|
+
Use this to discover what state machines exist in the project.
|
|
14
|
+
DESC
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
models = ::Fosm::Registry.model_classes
|
|
18
|
+
|
|
19
|
+
return "No FOSM lifecycles found" if models.empty?
|
|
20
|
+
|
|
21
|
+
models.map do |klass|
|
|
22
|
+
lifecycle = klass.fosm_lifecycle
|
|
23
|
+
next unless lifecycle
|
|
24
|
+
|
|
25
|
+
states = lifecycle.states.map do |s|
|
|
26
|
+
name = s.name.to_s
|
|
27
|
+
name += " (initial)" if s.initial?
|
|
28
|
+
name += " (terminal)" if s.terminal?
|
|
29
|
+
name
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
events = lifecycle.events.map do |e|
|
|
33
|
+
guards = e.guards.map(&:name).join(", ")
|
|
34
|
+
desc = "#{e.name}: #{e.from_states.join(", ")} → #{e.to_state}"
|
|
35
|
+
desc += " [guards: #{guards}]" if guards.present?
|
|
36
|
+
desc
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
"## #{klass.name}\nStates: #{states.join(", ")}\nEvents:\n#{events.map { |e| " - #{e}" }.join("\n")}"
|
|
40
|
+
end.compact.join("\n\n")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FosmRailsCodingAgent
|
|
4
|
+
module Tools
|
|
5
|
+
module Fosm
|
|
6
|
+
# Shared model resolution logic for FOSM tools.
|
|
7
|
+
# Tries direct constant lookup, Fosm:: prefix, and Registry search.
|
|
8
|
+
module ModelResolver
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def resolve_model(name)
|
|
12
|
+
Object.const_get(name)
|
|
13
|
+
rescue NameError
|
|
14
|
+
begin
|
|
15
|
+
Object.const_get("Fosm::#{name}")
|
|
16
|
+
rescue NameError
|
|
17
|
+
found = ::Fosm::Registry.model_classes.find { |k| k.name.demodulize == name }
|
|
18
|
+
raise NameError, "FOSM model '#{name}' not found. Use fosm_list_lifecycles to discover available models." unless found
|
|
19
|
+
found
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "model_resolver"
|
|
5
|
+
|
|
6
|
+
module FosmRailsCodingAgent
|
|
7
|
+
module Tools
|
|
8
|
+
module Fosm
|
|
9
|
+
# Returns the audit trail of state transitions for a FOSM record.
|
|
10
|
+
class TransitionHistory < FosmRailsCodingAgent::Tools::Base
|
|
11
|
+
include FosmRailsCodingAgent::Tools::Fosm::ModelResolver
|
|
12
|
+
tool_name "fosm_transition_history"
|
|
13
|
+
description <<~DESC
|
|
14
|
+
Returns the transition audit trail for a FOSM record.
|
|
15
|
+
Shows each state change with event, actor, timestamp, and metadata.
|
|
16
|
+
DESC
|
|
17
|
+
|
|
18
|
+
arguments do
|
|
19
|
+
required(:model).filled(:string).description("Model class name, e.g. 'Invoice'")
|
|
20
|
+
required(:id).filled(:integer).description("Record ID")
|
|
21
|
+
optional(:limit).filled(:integer).description("Max transitions to return (default: 50)")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(model:, id:, limit: 50)
|
|
25
|
+
klass = resolve_model(model)
|
|
26
|
+
record = klass.find(id)
|
|
27
|
+
|
|
28
|
+
logs = ::Fosm::TransitionLog
|
|
29
|
+
.where(record_type: klass.name, record_id: record.id.to_s)
|
|
30
|
+
.order(created_at: :desc)
|
|
31
|
+
.limit(limit)
|
|
32
|
+
|
|
33
|
+
result = {
|
|
34
|
+
model: klass.name,
|
|
35
|
+
id: record.id,
|
|
36
|
+
current_state: record.state,
|
|
37
|
+
transitions: logs.map do |log|
|
|
38
|
+
entry = {
|
|
39
|
+
event: log.event_name,
|
|
40
|
+
from: log.from_state,
|
|
41
|
+
to: log.to_state,
|
|
42
|
+
actor: log.actor_label || log.actor_type,
|
|
43
|
+
at: log.created_at.iso8601
|
|
44
|
+
}
|
|
45
|
+
entry[:metadata] = log.metadata if log.metadata.present?
|
|
46
|
+
entry[:snapshot] = true if log.state_snapshot.present?
|
|
47
|
+
entry
|
|
48
|
+
end
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
JSON.pretty_generate(result)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "model_resolver"
|
|
5
|
+
|
|
6
|
+
module FosmRailsCodingAgent
|
|
7
|
+
module Tools
|
|
8
|
+
module Fosm
|
|
9
|
+
# Explains why a specific event cannot fire on a record.
|
|
10
|
+
# Returns guard failures, invalid state, terminal state info, etc.
|
|
11
|
+
class WhyBlocked < FosmRailsCodingAgent::Tools::Base
|
|
12
|
+
include FosmRailsCodingAgent::Tools::Fosm::ModelResolver
|
|
13
|
+
tool_name "fosm_why_blocked"
|
|
14
|
+
description <<~DESC
|
|
15
|
+
Explains why a lifecycle event cannot fire on a FOSM record.
|
|
16
|
+
Returns detailed diagnostics: guard failures, invalid transitions,
|
|
17
|
+
terminal state blocks, and valid source states.
|
|
18
|
+
DESC
|
|
19
|
+
|
|
20
|
+
arguments do
|
|
21
|
+
required(:model).filled(:string).description("Model class name, e.g. 'Invoice'")
|
|
22
|
+
required(:id).filled(:integer).description("Record ID")
|
|
23
|
+
required(:event).filled(:string).description("Event name to diagnose, e.g. 'send_invoice'")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(model:, id:, event:)
|
|
27
|
+
klass = resolve_model(model)
|
|
28
|
+
record = klass.find(id)
|
|
29
|
+
result = record.why_cannot_fire?(event.to_sym)
|
|
30
|
+
|
|
31
|
+
JSON.pretty_generate(result)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FosmRailsCodingAgent
|
|
4
|
+
module Tools
|
|
5
|
+
# Extracts documentation comments from above a constant or method definition.
|
|
6
|
+
# Relies on GetSourceLocation to find the source file and line.
|
|
7
|
+
class GetDocs < Base
|
|
8
|
+
tool_name "get_docs"
|
|
9
|
+
description <<~DESC
|
|
10
|
+
Returns documentation comments for a Ruby constant or method.
|
|
11
|
+
|
|
12
|
+
Examples: `String`, `String#gsub`, `File.executable?`
|
|
13
|
+
|
|
14
|
+
Works for project code and gem dependencies.
|
|
15
|
+
Prefer this over grepping when you know the exact reference.
|
|
16
|
+
DESC
|
|
17
|
+
|
|
18
|
+
arguments do
|
|
19
|
+
required(:reference).filled(:string).description("Constant or method to look up documentation for")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(reference:)
|
|
23
|
+
file_path, line_number = FosmRailsCodingAgent::Tools::GetSourceLocation.resolve(reference)
|
|
24
|
+
raise NameError, "could not find docs for #{reference}" unless file_path
|
|
25
|
+
|
|
26
|
+
extract_documentation(file_path, line_number) || "No documentation found for #{reference}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def extract_documentation(file_path, line_number)
|
|
32
|
+
return nil unless File.exist?(file_path)
|
|
33
|
+
|
|
34
|
+
lines = File.readlines(file_path)
|
|
35
|
+
return nil if line_number <= 0 || line_number > lines.length
|
|
36
|
+
|
|
37
|
+
current_line = line_number - 2
|
|
38
|
+
comment_lines = []
|
|
39
|
+
|
|
40
|
+
while current_line >= 0
|
|
41
|
+
line = lines[current_line].chomp.strip
|
|
42
|
+
|
|
43
|
+
if line.start_with?("#")
|
|
44
|
+
comment_lines.unshift(line.sub(/^#\s?/, ""))
|
|
45
|
+
elsif line.empty?
|
|
46
|
+
# skip blank lines between comments
|
|
47
|
+
else
|
|
48
|
+
break
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
current_line -= 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
comment_lines.empty? ? nil : comment_lines.join("\n")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FosmRailsCodingAgent
|
|
4
|
+
module Tools
|
|
5
|
+
# Tails the Rails development log with optional regex filtering.
|
|
6
|
+
class GetLogs < Base
|
|
7
|
+
tool_name "get_logs"
|
|
8
|
+
description <<~DESC
|
|
9
|
+
Returns recent log entries from the Rails development log.
|
|
10
|
+
Use this to check for request logs, errors, or debugging output.
|
|
11
|
+
DESC
|
|
12
|
+
|
|
13
|
+
arguments do
|
|
14
|
+
required(:tail).filled(:integer).description("Number of log lines to return from the end")
|
|
15
|
+
optional(:grep).filled(:string).description("Filter logs with a case-insensitive regex pattern")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(tail:, grep: nil)
|
|
19
|
+
log_file = Rails.root.join("log", "#{Rails.env}.log")
|
|
20
|
+
return "Log file not found" unless File.exist?(log_file)
|
|
21
|
+
|
|
22
|
+
regex = Regexp.new(grep, Regexp::IGNORECASE) if grep
|
|
23
|
+
matching_lines = []
|
|
24
|
+
|
|
25
|
+
tail_lines(log_file) do |line|
|
|
26
|
+
if regex.nil? || line.match?(regex)
|
|
27
|
+
matching_lines.unshift(line)
|
|
28
|
+
break if matching_lines.size >= tail
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
matching_lines.join
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def tail_lines(file_path)
|
|
38
|
+
File.open(file_path, "rb") do |file|
|
|
39
|
+
file.seek(0, IO::SEEK_END)
|
|
40
|
+
file_size = file.pos
|
|
41
|
+
return if file_size == 0
|
|
42
|
+
|
|
43
|
+
buffer_size = [4096, file_size].min
|
|
44
|
+
pos = file_size
|
|
45
|
+
buffer = ""
|
|
46
|
+
|
|
47
|
+
while pos > 0 && buffer.count("\n") < 10_000
|
|
48
|
+
seek_pos = [pos - buffer_size, 0].max
|
|
49
|
+
file.seek(seek_pos)
|
|
50
|
+
chunk = file.read(pos - seek_pos)
|
|
51
|
+
buffer = chunk + buffer
|
|
52
|
+
pos = seek_pos
|
|
53
|
+
|
|
54
|
+
lines = buffer.split("\n")
|
|
55
|
+
buffer = pos > 0 && !buffer.start_with?("\n") ? (lines.shift || "") : ""
|
|
56
|
+
|
|
57
|
+
lines.reverse_each { |line| yield "#{line}\n" unless line.empty? }
|
|
58
|
+
break if pos == 0
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
yield "#{buffer}\n" unless buffer.empty?
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|