coatepec 0.5.0 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +33 -0
- data/lib/coatepec/introspection/model.rb +41 -2
- data/lib/coatepec/mcp/tools.rb +25 -0
- data/lib/coatepec/mcp.rb +4 -4
- data/lib/coatepec/spec/fork_strategy.rb +9 -0
- data/lib/coatepec/version.rb +1 -1
- data/lib/coatepec/worker/client.rb +12 -1
- data/lib/coatepec/worker_manager.rb +36 -8
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be24375e5f36c63e0511dc268bacecd57a54a33a94f5d44540925927c49f6655
|
|
4
|
+
data.tar.gz: 0e80f68351e4e814e9e4486f7e02d8d3f70512edaa7e9806c6baa3091111e779
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 297b7eb82d12ece3991893ed1052650648d37fb86b438356257e67e90f69260297b5c3f4b390a2ce25860e2e06a8487dc1597bb39219c3f13ac550901cc92e94
|
|
7
|
+
data.tar.gz: dbe1f843c872f42c20d7df9ee4226af83200b9c9457bb2a9b3aba185d22b7c5c8878fa3237a57dc4dfb4526c499e4c75cb1cb5c3135262aa24dd71fc4ea8805b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.2
|
|
4
|
+
|
|
5
|
+
- Fix `WorkerManager` getting permanently stuck treating a dead worker as
|
|
6
|
+
alive: `Worker::Client#alive?` used `Process.kill(0, pid)`, which returns
|
|
7
|
+
true for an unreaped zombie, and the one existing auto-restart-and-retry
|
|
8
|
+
path only caught `Worker::Client::DisconnectedError`, not a raw
|
|
9
|
+
`Errno::EPIPE`/`SystemCallError` from writing to a dead worker's pipe.
|
|
10
|
+
Every call after a worker died this way failed identically, forever --
|
|
11
|
+
since stdio MCP servers have no lighter reconnect in Claude Code, this
|
|
12
|
+
previously required a full session restart to recover from.
|
|
13
|
+
- Add `rails_runtime_restart`: unconditionally tears down and respawns the
|
|
14
|
+
test worker, for whatever the automatic detection above doesn't catch.
|
|
15
|
+
- Fix RSpec's own "(files took N seconds to load)" reporting growing
|
|
16
|
+
across every `rails_spec_run` call made through the same warm,
|
|
17
|
+
forked-per-run worker -- it was measuring time since the worker first
|
|
18
|
+
booted, not time this run's files took to load, because `rspec-core`
|
|
19
|
+
freezes that timestamp once per process and Coatepec never reset it on
|
|
20
|
+
reuse.
|
|
21
|
+
|
|
22
|
+
## 0.5.1
|
|
23
|
+
|
|
24
|
+
- Fix `rails_model` crashing outright for a model with a `has_one`/
|
|
25
|
+
`has_many :through` association that goes through a polymorphic
|
|
26
|
+
`belongs_to` (e.g. `has_one :x, through: :notable, source: :y` where
|
|
27
|
+
`belongs_to :notable, polymorphic: true`). `foreign_key` on that
|
|
28
|
+
reflection needs a single fixed class to resolve, which a polymorphic
|
|
29
|
+
association can't provide, and that was previously an unrescued
|
|
30
|
+
`ArgumentError` that took down the whole response. That specific case now
|
|
31
|
+
reports `foreign_key: nil`/`class_name: nil` for the affected association,
|
|
32
|
+
the same way an already-handled plain polymorphic `belongs_to` does.
|
|
33
|
+
- Add a general safety net around each association's metadata: if a single
|
|
34
|
+
association still fails for some other, not-yet-anticipated
|
|
35
|
+
`ActiveRecord` reflection quirk, only that association's entry degrades
|
|
36
|
+
(gaining an `error` field describing what went wrong) instead of the
|
|
37
|
+
entire `rails_model` call crashing for the whole model.
|
|
38
|
+
|
|
3
39
|
## 0.5.0
|
|
4
40
|
|
|
5
41
|
- **Breaking:** `mcp` is no longer a runtime dependency of the `coatepec`
|
data/README.md
CHANGED
|
@@ -127,9 +127,35 @@ a no-op.
|
|
|
127
127
|
|---|---|---|
|
|
128
128
|
| `rails_spec_run` | `paths: string[1..100]`, `example?`, `seed?`, `fail_fast?`, `timeout_seconds?` (1..900, default 120) | Isolated per run; output capped at 256 KiB per stream |
|
|
129
129
|
| `rails_runtime_status` | `{}` | Reports Ruby/Rails versions, worker PID, boot_id, lifecycle state |
|
|
130
|
+
| `rails_runtime_restart` | `{}` | Unconditionally respawns the worker, discarding its warm boot |
|
|
130
131
|
| `rails_routes` | `query?`, `limit?` (1..200, default 50), `offset?` | Case-insensitive filter across name/verb/path/controller/action |
|
|
131
132
|
| `rails_model` | `name` (constant path, e.g. `Widget` or `Admin::Widget`) | ActiveRecord models only; columns, associations, validators, enums -- no row data |
|
|
132
133
|
|
|
134
|
+
### Example queries
|
|
135
|
+
|
|
136
|
+
`rails_routes`:
|
|
137
|
+
|
|
138
|
+
- "What are all the routes in this app?" -- `rails_routes()`
|
|
139
|
+
- "What's the URL for widgets?" -- `rails_routes(query: "widget")`. `query` is a
|
|
140
|
+
case-insensitive substring match across name, verb, path, controller, *and*
|
|
141
|
+
action -- not just the path -- so a resource name alone typically returns
|
|
142
|
+
every route for that resource (index/create/new/...); narrow further with
|
|
143
|
+
something like `query: "new_widget"` to hit one route by name.
|
|
144
|
+
- "Which routes accept POST?" -- `rails_routes(query: "POST")`, the same
|
|
145
|
+
substring match applied to the verb column.
|
|
146
|
+
|
|
147
|
+
`rails_model`:
|
|
148
|
+
|
|
149
|
+
- "What columns does Widget have, and which are nullable?" --
|
|
150
|
+
`rails_model(name: "Widget")` -- see `columns[].null`, `columns[].sql_type`,
|
|
151
|
+
`columns[].default`.
|
|
152
|
+
- "What validations and associations does Widget enforce?" -- same call --
|
|
153
|
+
see `validators` and `associations`.
|
|
154
|
+
- "What happens if I ask about a non-model class, like a controller?" --
|
|
155
|
+
`rails_model(name: "ApplicationController")` raises `not_active_record_model`
|
|
156
|
+
rather than introspecting it (a nonexistent constant raises `model_not_found`
|
|
157
|
+
instead) -- the tool only ever reflects on `ActiveRecord::Base` descendants.
|
|
158
|
+
|
|
133
159
|
The warm test worker forces Rails' reload-checking on for its own boot,
|
|
134
160
|
regardless of the target app's own `test.rb` setting (which disables it by
|
|
135
161
|
default) -- so editing a model file takes effect on the next tool call
|
|
@@ -153,6 +179,13 @@ across a commit that touches the Gemfile, since the sidecar is managed by
|
|
|
153
179
|
your MCP client rather than by Coatepec itself: restart your MCP client (or
|
|
154
180
|
however it manages the Coatepec process) to pick up the change.
|
|
155
181
|
|
|
182
|
+
A dead *worker* (as opposed to a dead sidecar) recovers on its own: the
|
|
183
|
+
next tool call detects it and transparently boots a fresh one before
|
|
184
|
+
retrying, whether the worker exited outright or a request to it failed
|
|
185
|
+
with a broken pipe. If you want a fresh worker without waiting for a
|
|
186
|
+
failure -- or one keeps recurring -- call `rails_runtime_restart` directly;
|
|
187
|
+
it always respawns, even if the current worker looks healthy.
|
|
188
|
+
|
|
156
189
|
## Security boundary
|
|
157
190
|
|
|
158
191
|
No eval, console, SQL/record access, shell, Rake, or file-write tool. Spec
|
|
@@ -93,7 +93,32 @@ module Coatepec
|
|
|
93
93
|
end
|
|
94
94
|
|
|
95
95
|
def associations_for(klass)
|
|
96
|
-
klass.reflect_on_all_associations.first(MAX_ITEMS).map { |assoc|
|
|
96
|
+
klass.reflect_on_all_associations.first(MAX_ITEMS).map { |assoc| safe_association_data(assoc) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# build_association_data's own field-level rescues (association_class_name,
|
|
100
|
+
# association_foreign_key) cover every failure mode seen in practice so
|
|
101
|
+
# far, but ActiveRecord's reflection internals are large enough that
|
|
102
|
+
# betting the whole rails_model call on having anticipated all of them
|
|
103
|
+
# is optimistic -- a has_one/has_many :through a polymorphic belongs_to
|
|
104
|
+
# is exactly the kind of case that wasn't anticipated until it crashed
|
|
105
|
+
# this method outright (see association_foreign_key). This is the
|
|
106
|
+
# boundary of last resort: one association's introspection failing
|
|
107
|
+
# degrades just that entry instead of the whole model. It deliberately
|
|
108
|
+
# does not rescue StandardError -- a NoMethodError here is a genuine
|
|
109
|
+
# Coatepec bug (e.g. a typo), and letting that crash loudly beats
|
|
110
|
+
# silently reporting it as "this association is fine, no data".
|
|
111
|
+
def safe_association_data(assoc)
|
|
112
|
+
build_association_data(assoc)
|
|
113
|
+
rescue NameError, ArgumentError, ::ActiveRecord::ActiveRecordError => e
|
|
114
|
+
degraded_association_data(assoc, e)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def degraded_association_data(assoc, error)
|
|
118
|
+
{
|
|
119
|
+
name: assoc.name.to_s, macro: assoc.macro.to_s, class_name: nil, foreign_key: nil, through: nil,
|
|
120
|
+
polymorphic: nil, error: "#{error.class}: #{error.message}"
|
|
121
|
+
}
|
|
97
122
|
end
|
|
98
123
|
|
|
99
124
|
def build_association_data(assoc)
|
|
@@ -101,7 +126,7 @@ module Coatepec
|
|
|
101
126
|
name: assoc.name.to_s,
|
|
102
127
|
macro: assoc.macro.to_s,
|
|
103
128
|
class_name: association_class_name(assoc),
|
|
104
|
-
foreign_key: assoc
|
|
129
|
+
foreign_key: association_foreign_key(assoc),
|
|
105
130
|
through: assoc.through_reflection&.name&.to_s,
|
|
106
131
|
polymorphic: assoc.polymorphic? || false
|
|
107
132
|
}
|
|
@@ -120,6 +145,20 @@ module Coatepec
|
|
|
120
145
|
nil
|
|
121
146
|
end
|
|
122
147
|
|
|
148
|
+
# A has_one/has_many :through reflection whose `through:` target is
|
|
149
|
+
# itself a polymorphic belongs_to has no single fixed class either --
|
|
150
|
+
# ThroughReflection#foreign_key needs through_reflection.klass to find
|
|
151
|
+
# the source reflection, and .klass on a polymorphic reflection always
|
|
152
|
+
# raises ArgumentError (see association_class_name above). A dangling
|
|
153
|
+
# class_name on the reflection itself still raises NameError the same
|
|
154
|
+
# way. Report foreign_key: nil rather than letting either crash the
|
|
155
|
+
# call.
|
|
156
|
+
def association_foreign_key(assoc)
|
|
157
|
+
assoc.foreign_key.to_s
|
|
158
|
+
rescue NameError, ArgumentError
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
123
162
|
def validators_for(klass)
|
|
124
163
|
klass.validators.first(MAX_ITEMS).map do |validator|
|
|
125
164
|
{
|
data/lib/coatepec/mcp/tools.rb
CHANGED
|
@@ -69,6 +69,31 @@ module Coatepec
|
|
|
69
69
|
end
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
+
# The `rails_runtime_restart` MCP tool: unconditionally tears down and
|
|
73
|
+
# respawns the test worker, discarding its warm Rails boot. A manual
|
|
74
|
+
# escape hatch alongside WorkerManager's own automatic dead-worker
|
|
75
|
+
# detection, for whatever failure mode that detection doesn't catch.
|
|
76
|
+
class RuntimeRestartTool < ::MCP::Tool
|
|
77
|
+
tool_name "rails_runtime_restart"
|
|
78
|
+
description "Tear down and respawn the Coatepec test worker, discarding its warm Rails boot " \
|
|
79
|
+
"(use if rails_spec_run/rails_runtime_status keep failing and a fresh worker is needed)"
|
|
80
|
+
annotations(read_only_hint: false, destructive_hint: true, idempotent_hint: false, open_world_hint: false)
|
|
81
|
+
input_schema(properties: {}, required: [], additionalProperties: false)
|
|
82
|
+
|
|
83
|
+
class << self
|
|
84
|
+
def call(server_context:)
|
|
85
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
86
|
+
data = server_context[:worker_manager].restart!
|
|
87
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
88
|
+
Response.ok(data: data,
|
|
89
|
+
meta: { project_root: server_context[:project_root], environment: "test",
|
|
90
|
+
duration_ms: duration_ms })
|
|
91
|
+
rescue Coatepec::Error => e
|
|
92
|
+
Response.error(e)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
72
97
|
# The `rails_routes` MCP tool: lists/filters/paginates the target
|
|
73
98
|
# Rails app's routes.
|
|
74
99
|
class RoutesTool < ::MCP::Tool
|
data/lib/coatepec/mcp.rb
CHANGED
|
@@ -12,15 +12,15 @@ require_relative "mcp/response"
|
|
|
12
12
|
require_relative "mcp/tools"
|
|
13
13
|
|
|
14
14
|
module Coatepec
|
|
15
|
-
# Wires the `rails_spec_run`, `rails_runtime_status`, `
|
|
16
|
-
# `rails_model` tools into an `::MCP::Server` instance
|
|
17
|
-
# project's worker manager.
|
|
15
|
+
# Wires the `rails_spec_run`, `rails_runtime_status`, `rails_runtime_restart`,
|
|
16
|
+
# `rails_routes`, and `rails_model` tools into an `::MCP::Server` instance
|
|
17
|
+
# backed by the given project's worker manager.
|
|
18
18
|
module MCP
|
|
19
19
|
def self.build_server(project:, worker_manager:)
|
|
20
20
|
::MCP::Server.new(
|
|
21
21
|
name: "coatepec",
|
|
22
22
|
version: Coatepec::VERSION,
|
|
23
|
-
tools: [SpecRunTool, RuntimeStatusTool, RoutesTool, ModelTool],
|
|
23
|
+
tools: [SpecRunTool, RuntimeStatusTool, RuntimeRestartTool, RoutesTool, ModelTool],
|
|
24
24
|
server_context: { worker_manager: worker_manager, project_root: project.root }
|
|
25
25
|
)
|
|
26
26
|
end
|
|
@@ -14,6 +14,15 @@ module Coatepec
|
|
|
14
14
|
redirect_output(out_w, err_w)
|
|
15
15
|
# Forked children must not share the parent's live DB sockets.
|
|
16
16
|
ActiveRecord::Base.connection_handler.clear_all_connections! if defined?(ActiveRecord::Base)
|
|
17
|
+
# RSpec freezes its own "load started at" timestamp once, at the moment
|
|
18
|
+
# rspec/core.rb is first required -- in this architecture, that's when
|
|
19
|
+
# the long-lived warm worker booted, not when THIS run started. Every
|
|
20
|
+
# forked child inherits that frozen timestamp via copy-on-write, so
|
|
21
|
+
# RSpec's own "(files took N seconds to load)" reporting would
|
|
22
|
+
# otherwise measure "time since the worker booted" and grow across
|
|
23
|
+
# every run for as long as the worker stays warm. Reset it fresh
|
|
24
|
+
# before each run.
|
|
25
|
+
RSpec.configuration.start_time = RSpec::Core::Time.now
|
|
17
26
|
|
|
18
27
|
status = RSpec::Core::Runner.run(full_args, $stderr, $stdout)
|
|
19
28
|
$stdout.flush
|
data/lib/coatepec/version.rb
CHANGED
|
@@ -32,9 +32,20 @@ module Coatepec
|
|
|
32
32
|
def alive?
|
|
33
33
|
return false unless @pid
|
|
34
34
|
|
|
35
|
+
# A dead-but-unreaped worker (crash, the OS reclaiming a long-idle
|
|
36
|
+
# process, anything) is a zombie: Process.kill(0, pid) below would still
|
|
37
|
+
# succeed against it, since it still holds a process-table entry. Reap it
|
|
38
|
+
# here so staleness is detected instead of reported as "alive" forever --
|
|
39
|
+
# nothing else calls Process.wait on this pid except #stop, which only
|
|
40
|
+
# WorkerManager#restart_worker! reaches, and only once #alive? itself
|
|
41
|
+
# already says false.
|
|
42
|
+
_pid, status = Process.waitpid2(@pid, Process::WNOHANG)
|
|
43
|
+
@pid = nil if status
|
|
44
|
+
return false unless @pid
|
|
45
|
+
|
|
35
46
|
Process.kill(0, @pid)
|
|
36
47
|
true
|
|
37
|
-
rescue Errno::ESRCH
|
|
48
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
38
49
|
false
|
|
39
50
|
end
|
|
40
51
|
|
|
@@ -40,6 +40,27 @@ module Coatepec
|
|
|
40
40
|
@lock.synchronize { @client&.stop }
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# Unconditionally tears down and respawns the worker, bypassing the usual
|
|
44
|
+
# "only restart if #ensure_worker! thinks it's needed" check. Manual escape
|
|
45
|
+
# hatch for rails_runtime_restart, independent of whatever #perform's
|
|
46
|
+
# rescue below already recovers from automatically.
|
|
47
|
+
#
|
|
48
|
+
# Still honors the sidecar-restart invariant: if the Gemfile changed since
|
|
49
|
+
# boot, a worker-only respawn would silently adopt the new bundle in the
|
|
50
|
+
# worker while the parent MCP process stays on the old one, so that case
|
|
51
|
+
# raises :sidecar_restart_required instead of restarting. The check runs
|
|
52
|
+
# against the *pre-existing* snapshot, before restart_worker! re-baselines
|
|
53
|
+
# it -- same ordering #dispatch uses. Nothing to compare against on the
|
|
54
|
+
# very first call.
|
|
55
|
+
def restart!
|
|
56
|
+
@lock.synchronize do
|
|
57
|
+
raise_sidecar_restart_required! if sidecar_restart_required?
|
|
58
|
+
|
|
59
|
+
restart_worker!
|
|
60
|
+
status
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
43
64
|
private
|
|
44
65
|
|
|
45
66
|
def dispatch(command, args, timeout:, retried: false)
|
|
@@ -57,26 +78,33 @@ module Coatepec
|
|
|
57
78
|
|
|
58
79
|
def perform(command, args, timeout:, retried:)
|
|
59
80
|
@client.request(command, args, timeout: timeout)
|
|
60
|
-
rescue Worker::Client::DisconnectedError
|
|
61
|
-
raise Coatepec::Error.new(:worker_disconnected, "Worker disconnected") if retried
|
|
81
|
+
rescue Worker::Client::DisconnectedError, SystemCallError, IOError => e
|
|
82
|
+
raise Coatepec::Error.new(:worker_disconnected, "Worker disconnected: #{e.message}") if retried
|
|
62
83
|
|
|
63
84
|
restart_worker!
|
|
64
85
|
dispatch(command, args, timeout: timeout, retried: true)
|
|
65
86
|
end
|
|
66
87
|
|
|
67
88
|
def check_for_restart!
|
|
68
|
-
|
|
69
|
-
case reason
|
|
89
|
+
case @change_detector.restart_reason(@snapshot)
|
|
70
90
|
when :sidecar_restart_required
|
|
71
|
-
|
|
72
|
-
:sidecar_restart_required,
|
|
73
|
-
"Gemfile changed; restart your MCP client to restart Coatepec and pick up the change"
|
|
74
|
-
)
|
|
91
|
+
raise_sidecar_restart_required!
|
|
75
92
|
when :worker_restart_required
|
|
76
93
|
restart_worker!
|
|
77
94
|
end
|
|
78
95
|
end
|
|
79
96
|
|
|
97
|
+
def sidecar_restart_required?
|
|
98
|
+
@snapshot && @change_detector.restart_reason(@snapshot) == :sidecar_restart_required
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def raise_sidecar_restart_required!
|
|
102
|
+
raise Coatepec::Error.new(
|
|
103
|
+
:sidecar_restart_required,
|
|
104
|
+
"Gemfile changed; restart your MCP client to restart Coatepec and pick up the change"
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
80
108
|
def ensure_worker!
|
|
81
109
|
restart_worker! unless @client&.alive?
|
|
82
110
|
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: coatepec
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Enrique Mogollan
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-08-
|
|
10
|
+
date: 2026-08-10 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: railties
|