coatepec 0.8.0 → 0.9.0
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 +29 -0
- data/README.md +51 -24
- data/ROADMAP.md +3 -2
- data/lib/coatepec/mcp/defaults.rb +25 -0
- data/lib/coatepec/mcp/tools.rb +29 -17
- data/lib/coatepec/mcp.rb +1 -0
- data/lib/coatepec/project_config.rb +40 -1
- data/lib/coatepec/spec/fork_strategy.rb +2 -0
- data/lib/coatepec/spec/guarded_fork_strategy.rb +9 -8
- data/lib/coatepec/spec/process_strategy.rb +6 -0
- data/lib/coatepec/spec/runner.rb +16 -8
- data/lib/coatepec/spec/spawn_strategy.rb +2 -0
- data/lib/coatepec/version.rb +1 -1
- data/lib/coatepec/worker/rails_runtime.rb +10 -1
- data/lib/coatepec/worker/server.rb +7 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72a7dae3c61c36718da0a815ac037c9a50b1a2ae76e282e67d6533131d21c28f
|
|
4
|
+
data.tar.gz: 99c4c1bd193cffe81e49d711f6bedfebd5cb1552ab91f4455ab7b4d31fbabda5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 845750211ab96287bccd73b1108451357453925cd0113e92d4251dacca836437783cf78288fa8ca63de5fdbaa772e1ed1f1fc8ce2afbcb346ff9e2f55c1be85a
|
|
7
|
+
data.tar.gz: '04698525bb87f82b589a6d50a61a18edbbd4b8e1520fcdf41d3c9e682b75af664480e4dd5adc2cea54be1f8c6afbe71defc4d8157949e954e90c63d60d9e935c'
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
- `rails_spec_run` results always carry `execution_mode`: `fork` (Linux, or
|
|
6
|
+
a macOS guarded fork that ran), `spawn` (macOS with `macos_fork: false`),
|
|
7
|
+
`spawn_fallback` (the guard declined to fork) or `spawn_after_crash` (the
|
|
8
|
+
forked child crashed and the run was retried). The key used to appear
|
|
9
|
+
only when the guarded fork was in play.
|
|
10
|
+
- Behaviour change on macOS: projects that never set `macos_fork: true` now
|
|
11
|
+
fork the warm worker by default, the way it always has on Linux, using
|
|
12
|
+
the guarded fork shipped in 0.7.0 (thread-count and gem-denylist checks,
|
|
13
|
+
a spawn retry if the child crashes). Set `macos_fork: false` in
|
|
14
|
+
`.coatepec.yml` to keep a fresh spawn per call. Measured on a Rails
|
|
15
|
+
8.2.0.alpha app: 966 ms saved per run, no fallbacks across nine runs.
|
|
16
|
+
`OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` is now a recommendation for
|
|
17
|
+
projects that see `spawn_after_crash`, not a requirement.
|
|
18
|
+
- `.coatepec.yml` gains a `defaults` map: `spec_run.include_passing`,
|
|
19
|
+
`spec_run.include_stdout`, `spec_run.timeout_seconds` and `routes.engines`
|
|
20
|
+
set project-wide defaults for those inputs. A call argument always wins;
|
|
21
|
+
an omitted key means the built-in default; an unknown key or an
|
|
22
|
+
out-of-range value fails the call with `invalid_config` naming it. The
|
|
23
|
+
file is re-read on every call.
|
|
24
|
+
- `rails_runtime_status` reports `spec_strategy` (`fork`, `guarded_fork` or
|
|
25
|
+
`spawn`), `fallbacks` (how many guarded-fork runs fell back to spawn in
|
|
26
|
+
this worker's lifetime; `null` unless the guarded fork is in use) and
|
|
27
|
+
`defaults` (the effective `rails_spec_run` and `rails_routes` defaults
|
|
28
|
+
after `.coatepec.yml`), so an agent can see every default in one call.
|
|
29
|
+
`fallbacks` also counts `rails_spec_flaky_check` rounds, which run through
|
|
30
|
+
the same strategy.
|
|
31
|
+
|
|
3
32
|
## 0.8.0
|
|
4
33
|
|
|
5
34
|
- Tool responses are now compact JSON rather than pretty-printed (22-32%
|
data/README.md
CHANGED
|
@@ -58,8 +58,8 @@ MCP client
|
|
|
58
58
|
Coatepec parent (Rails-free)
|
|
59
59
|
`-- private NDJSON --> test worker (Rails "test", booted lazily, kept warm)
|
|
60
60
|
|-- Linux: Process.fork --> isolated RSpec/Minitest child
|
|
61
|
-
`-- macOS: Process.
|
|
62
|
-
Process.
|
|
61
|
+
`-- macOS: Process.fork, guarded --> isolated RSpec/Minitest child (default)
|
|
62
|
+
Process.spawn --> fresh process, see Configuration (macos_fork: false)
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
## Configuration
|
|
@@ -68,28 +68,43 @@ An optional `.coatepec.yml` at the target Rails app's root enables
|
|
|
68
68
|
per-project settings:
|
|
69
69
|
|
|
70
70
|
```yaml
|
|
71
|
-
macos_fork:
|
|
71
|
+
macos_fork: false # opt out of forking on macOS (default true; see below)
|
|
72
72
|
macos_fork_unsafe_gems: [some_gem] # extends the built-in fork-unsafe denylist
|
|
73
|
+
|
|
74
|
+
defaults:
|
|
75
|
+
spec_run:
|
|
76
|
+
include_passing: true # true or false (default false)
|
|
77
|
+
include_stdout: always # failures (default), always, never
|
|
78
|
+
timeout_seconds: 300 # 1..900 (default 120)
|
|
79
|
+
routes:
|
|
80
|
+
engines: include # include, exclude (default), only
|
|
73
81
|
```
|
|
74
82
|
|
|
75
83
|
A missing file means every setting takes its default -- this file is never
|
|
76
84
|
required.
|
|
77
85
|
|
|
86
|
+
`defaults` sets project-wide values for those four inputs; a call argument
|
|
87
|
+
always wins, an omitted key falls back to the built-in default, and an
|
|
88
|
+
unknown key or value fails the call with `invalid_config` naming it. The
|
|
89
|
+
file is re-read on every call, so edits apply immediately.
|
|
90
|
+
`defaults.spec_run` applies to `rails_spec_run` only; `rails_spec_flaky_check`
|
|
91
|
+
keeps its own per-round budget.
|
|
92
|
+
|
|
78
93
|
Tool responses are compact JSON -- no indentation, nothing downstream reads
|
|
79
94
|
it. Set `COATEPEC_PRETTY=1` in the MCP server's `env` (the same place as
|
|
80
95
|
`OBJC_DISABLE_INITIALIZE_FORK_SAFETY` in the JSON example below) to
|
|
81
96
|
pretty-print them when you are reading the sidecar by hand.
|
|
82
97
|
|
|
83
|
-
### macOS fork (
|
|
98
|
+
### macOS fork (default since 0.9.0)
|
|
84
99
|
|
|
85
|
-
On macOS, `rails_spec_run`
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
100
|
+
On macOS, `rails_spec_run` forks the warm worker for each call, reusing the
|
|
101
|
+
boot the way it always has on Linux. Setting `macos_fork: false` in
|
|
102
|
+
`.coatepec.yml` opts the project out: every call then spawns a fresh
|
|
103
|
+
`bundle exec rspec` process and re-boots Rails, so only the Linux lane
|
|
104
|
+
gets the warm-worker speedup described above.
|
|
90
105
|
|
|
91
|
-
|
|
92
|
-
isn't universally safe. Before each fork, Coatepec checks the worker's
|
|
106
|
+
The fork is guarded, because forking a process with native extensions
|
|
107
|
+
loaded isn't universally safe. Before each fork, Coatepec checks the worker's
|
|
93
108
|
live thread count against its post-boot baseline and its loaded gems
|
|
94
109
|
against a denylist, falling back to a fresh spawn for that one call if
|
|
95
110
|
either check looks risky. The built-in denylist ships empty -- no single
|
|
@@ -98,17 +113,17 @@ thread-count-only until a project adds its own
|
|
|
98
113
|
`macos_fork_unsafe_gems`. If a fork is attempted and the child crashes
|
|
99
114
|
anyway, Coatepec transparently retries via spawn and returns that result
|
|
100
115
|
-- fork stays enabled for later calls. Every `rails_spec_run` result
|
|
101
|
-
includes an `execution_mode` field (`fork`, `spawn_fallback`,
|
|
102
|
-
`spawn_after_crash`) so you can see which path actually ran for a given
|
|
116
|
+
includes an `execution_mode` field (`fork`, `spawn`, `spawn_fallback`,
|
|
117
|
+
or `spawn_after_crash`) so you can see which path actually ran for a given
|
|
103
118
|
call; `spawn_after_crash` results also carry the crashed fork's own stderr
|
|
104
119
|
under `crashed_fork_stderr` so the crash can be diagnosed.
|
|
105
120
|
|
|
106
|
-
`
|
|
107
|
-
`
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
121
|
+
`OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` in Coatepec's own environment is
|
|
122
|
+
recommended if you ever see `spawn_after_crash`. Without it, a forked child
|
|
123
|
+
that touches an Objective-C-initialized class aborts -- Coatepec retries via
|
|
124
|
+
spawn, so it degrades to the slow path for that call (no crash, no error
|
|
125
|
+
surfaced) rather than failing loudly, and you simply never get the speedup.
|
|
126
|
+
Set it on the MCP server process itself:
|
|
112
127
|
|
|
113
128
|
```json
|
|
114
129
|
{
|
|
@@ -130,10 +145,10 @@ a no-op.
|
|
|
130
145
|
|
|
131
146
|
| Tool | Input | Notes |
|
|
132
147
|
|---|---|---|
|
|
133
|
-
| `rails_spec_run` | `paths: string[1..100]`, `example?`, `seed?`, `fail_fast?`, `timeout_seconds?` (1..900, default 120), `include_passing?`, `include_stdout?` (`failures` default, `always`, `never`) | RSpec (`spec/**/*_spec.rb`) or Minitest (`test/**/*_test.rb`), chosen from the paths; isolated per run; output capped at 256 KiB per stream; failure blocks in `stdout` that repeat an earlier block's error verbatim are replaced by a roll-up line naming the tests; returns `summary` counts (`example_count`, `failure_count`, `error_count`, `pending_count`, `assertion_count`, `duration`; the error and assertion counts come from Minitest and are `null` for RSpec) plus only the failed/pending `examples`, pass `include_passing: true` for the full roster (still capped at 500 examples); `stdout` is returned only for failing runs by default; `include_stdout: "always"` keeps it for green runs, `"never"` drops it always (the key stays, `null`); `stderr` is always returned; `child_pid`/`signaled`/`termsig`/`stopsig`/`coredump` appear only when the child did not exit normally (a timeout kill or a crash) |
|
|
134
|
-
| `rails_runtime_status` | `{}` | Reports Ruby/Rails versions, worker PID, boot_id, lifecycle state,
|
|
135
|
-
| `rails_runtime_restart` | `{}` | Unconditionally respawns the worker, discarding its warm boot |
|
|
136
|
-
| `rails_routes` | `query?`, `limit?` (1..200, default 100), `offset?`, `engines?` (`exclude` default, `include`, `only`) | Returns `columns` (`name`, `verb`, `path`, `controller`, `action`, `engine`) and `rows` in that order; paths omit the `(.:format)` suffix Rails appends. Case-insensitive filter across name/verb/path/controller/action/engine. Application routes only by default; `engines: "include"` adds the routes of mounted engines (one level deep, paths prefixed with the mount point, `engine` naming the engine class; `null` for an application route), `"only"` returns just those. Every response carries `engines` (the filter applied) and `engines_excluded` (how many routes matching `query` the filter withheld), so nothing is hidden silently. The filter applies after `query` and before paging, so `matched`/`next_offset` describe the kept set. The engine's mount route counts as an application route. Routes Rails marks `internal` are omitted, like `bin/rails routes`. `next_offset` is the offset of the next page, or `null` on the last one |
|
|
148
|
+
| `rails_spec_run` | `paths: string[1..100]`, `example?`, `seed?`, `fail_fast?`, `timeout_seconds?` (1..900, default 120), `include_passing?`, `include_stdout?` (`failures` default, `always`, `never`) (all three default-able in `.coatepec.yml`) | RSpec (`spec/**/*_spec.rb`) or Minitest (`test/**/*_test.rb`), chosen from the paths; isolated per run; output capped at 256 KiB per stream; failure blocks in `stdout` that repeat an earlier block's error verbatim are replaced by a roll-up line naming the tests; returns `summary` counts (`example_count`, `failure_count`, `error_count`, `pending_count`, `assertion_count`, `duration`; the error and assertion counts come from Minitest and are `null` for RSpec) plus only the failed/pending `examples`, pass `include_passing: true` for the full roster (still capped at 500 examples); `stdout` is returned only for failing runs by default; `include_stdout: "always"` keeps it for green runs, `"never"` drops it always (the key stays, `null`); `stderr` is always returned; `child_pid`/`signaled`/`termsig`/`stopsig`/`coredump` appear only when the child did not exit normally (a timeout kill or a crash); every result carries `execution_mode` (`fork`, `spawn`, `spawn_fallback`, `spawn_after_crash`) |
|
|
149
|
+
| `rails_runtime_status` | `{}` | Reports Ruby/Rails versions, worker PID, boot_id, lifecycle state, the project root, `spec_strategy` (`fork`, `guarded_fork`, `spawn`), `fallbacks` (guarded-fork runs that fell back to spawn; `null` unless guarded) and `defaults` (the effective `rails_spec_run`/`rails_routes` defaults after `.coatepec.yml`) |
|
|
150
|
+
| `rails_runtime_restart` | `{}` | Unconditionally respawns the worker, discarding its warm boot; returns the fresh worker's status, including `spec_strategy` and `fallbacks` |
|
|
151
|
+
| `rails_routes` | `query?`, `limit?` (1..200, default 100), `offset?`, `engines?` (`exclude` default, `include`, `only`) (default-able in `.coatepec.yml`) | Returns `columns` (`name`, `verb`, `path`, `controller`, `action`, `engine`) and `rows` in that order; paths omit the `(.:format)` suffix Rails appends. Case-insensitive filter across name/verb/path/controller/action/engine. Application routes only by default; `engines: "include"` adds the routes of mounted engines (one level deep, paths prefixed with the mount point, `engine` naming the engine class; `null` for an application route), `"only"` returns just those. Every response carries `engines` (the filter applied) and `engines_excluded` (how many routes matching `query` the filter withheld), so nothing is hidden silently. The filter applies after `query` and before paging, so `matched`/`next_offset` describe the kept set. The engine's mount route counts as an application route. Routes Rails marks `internal` are omitted, like `bin/rails routes`. `next_offset` is the offset of the next page, or `null` on the last one |
|
|
137
152
|
| `rails_model` | `name` (constant path, e.g. `Widget` or `Admin::Widget`), `fields?` (any of `columns`, `associations`, `validators`, `enums`; omitted or `[]` means none) | Returns `name`, `table_name`, `primary_key`, `abstract_class` and `counts` (the size of each of the four lists, each capped at 200) by default and only the lists named in `fields` -- so a how-many question costs a few dozen bytes and `fields: ["columns"]` asks for the one list you need; ActiveRecord models only; columns, associations, validators, enums -- no row data; validators are de-duplicated by class, attributes and options (a concern and the model body declaring the same validation count once), so the count can be lower than `klass.validators.size`; an array-valued validator option longer than 20 entries (a country-code `inclusion` list, say) is cut to its first 20 with `<option>_count` and `<option>_truncated: true` beside it |
|
|
138
153
|
| `rails_controller` | `name` (constant path, e.g. `WidgetsController` or `Admin::ReportsController`) | Actions, action callbacks, concerns, and the routes reaching each action -- no request dispatch |
|
|
139
154
|
| `rails_spec_flaky_check` | `paths`, `example?`, `timeout_seconds?` (per round, 1..900), `runs?` (2..20, default 5) | Runs the selection `runs` times with a fresh random seed each round; reports tests whose status was inconsistent across runs; RSpec or Minitest, chosen from the paths |
|
|
@@ -161,6 +176,18 @@ a no-op.
|
|
|
161
176
|
routes mounted from it. Application routes always sort before engine routes,
|
|
162
177
|
so an unfiltered listing (with `engines: "include"`) reads the way
|
|
163
178
|
`bin/rails routes` does.
|
|
179
|
+
- `engines_excluded` is a correctness signal, not only a saving. Engine
|
|
180
|
+
routes print their paths relative to the mount point, so
|
|
181
|
+
`bin/rails routes -g admin` cannot find an admin engine's routes at all: it
|
|
182
|
+
matches the mount line and answers "one admin route" with no hint that
|
|
183
|
+
anything is missing. `rails_routes(query: "admin")` returns that one row
|
|
184
|
+
plus `engines_excluded: 243`, so the reader knows the rest exists.
|
|
185
|
+
- Opting in is the expensive path, and it paginates. A broad query with
|
|
186
|
+
`engines: "include"` on an app with a mounted admin engine costs several
|
|
187
|
+
times the default response and can still stop at the 100-row page of a
|
|
188
|
+
larger match (`next_offset` says so), where the default returned the
|
|
189
|
+
application's routes complete. Reach for `engines: "only"` with the
|
|
190
|
+
engine's class name when the engine's routes are the question.
|
|
164
191
|
- An engine route's `name` is relative to its engine, not a top-level url
|
|
165
192
|
helper: the row `["audits", "GET", "/widget_admin/audits",
|
|
166
193
|
"widget_admin/audits", "index", "WidgetAdmin::Engine"]` is reached as
|
|
@@ -369,7 +396,7 @@ structure without ever handing it a REPL.
|
|
|
369
396
|
Ruby `>= 3.2`, Rails `>= 7.1, < 8.2`, Minitest 5.x and 6.x (the fixture
|
|
370
397
|
apps pin 6.0.6; the 5.x name-filter flag is unit-tested). CI tests three
|
|
371
398
|
lanes: Rails 8.1 on Linux (primary), Rails 7.1 on Linux (compat), and Rails
|
|
372
|
-
8.1 on macOS (
|
|
399
|
+
8.1 on macOS (the lane that exercises the guarded-fork path above).
|
|
373
400
|
|
|
374
401
|
## Development
|
|
375
402
|
|
data/ROADMAP.md
CHANGED
|
@@ -39,8 +39,9 @@ root-cause writeup and the fix options considered (forcing `SpawnStrategy`
|
|
|
39
39
|
for profiled runs specifically, vs. detecting and raising a clear error on
|
|
40
40
|
non-spawn strategies, vs. a Linux-only opt-out config knob). Paused
|
|
41
41
|
specifically to wait for real signal on how coatepec is actually used
|
|
42
|
-
(Linux vs. macOS, `macos_fork` adoption)
|
|
43
|
-
|
|
42
|
+
(Linux vs. macOS, `macos_fork` adoption) -- `macos_fork` is the default from
|
|
43
|
+
0.9.0, so the fork path is now the common case on both platforms -- before
|
|
44
|
+
picking a fix, rather than guessing.
|
|
44
45
|
|
|
45
46
|
## Run Rails 8.1's built-in CI (`bin/ci`)
|
|
46
47
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../coatepec"
|
|
4
|
+
|
|
5
|
+
module Coatepec
|
|
6
|
+
module MCP
|
|
7
|
+
# Fills a tool's omitted inputs: call argument first, then .coatepec.yml, then the built-in.
|
|
8
|
+
module Defaults
|
|
9
|
+
BUILTIN = {
|
|
10
|
+
spec_run: { include_passing: false, include_stdout: "failures", timeout_seconds: 120 },
|
|
11
|
+
routes: { engines: Introspection::Routes::DEFAULT_ENGINES }
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Re-reads the file each call: a few hundred bytes, and an edit then applies to the next call.
|
|
17
|
+
def resolve(tool, project_root, **given)
|
|
18
|
+
configured = ProjectConfig.new(project_root).defaults_for(tool)
|
|
19
|
+
BUILTIN.fetch(tool).to_h do |key, builtin|
|
|
20
|
+
[key, given[key].nil? ? configured.fetch(key, builtin) : given[key]]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/coatepec/mcp/tools.rb
CHANGED
|
@@ -33,18 +33,21 @@ module Coatepec
|
|
|
33
33
|
"paths; there is no separate Minitest tool#{RSPEC_VOCABULARY}" \
|
|
34
34
|
"; returns only failed and pending examples unless include_passing is true" \
|
|
35
35
|
"; failure blocks in stdout that repeat an earlier error are rolled up into one line" \
|
|
36
|
-
"; stdout is returned only for failing runs unless include_stdout is \"always\" or \"never\""
|
|
36
|
+
"; stdout is returned only for failing runs unless include_stdout is \"always\" or \"never\"" \
|
|
37
|
+
"; defaults for include_passing, include_stdout and timeout_seconds can be set project-wide " \
|
|
38
|
+
"in .coatepec.yml"
|
|
37
39
|
annotations(read_only_hint: false, destructive_hint: true, idempotent_hint: false, open_world_hint: true)
|
|
38
40
|
input_schema(**INPUT_SCHEMA)
|
|
39
41
|
|
|
40
42
|
class << self
|
|
41
|
-
def call(paths:, server_context:, example: nil, seed: nil, fail_fast: false, timeout_seconds:
|
|
42
|
-
include_passing:
|
|
43
|
+
def call(paths:, server_context:, example: nil, seed: nil, fail_fast: false, timeout_seconds: nil,
|
|
44
|
+
include_passing: nil, include_stdout: nil)
|
|
43
45
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
inputs = Defaults.resolve(:spec_run, server_context[:project_root], timeout_seconds: timeout_seconds,
|
|
47
|
+
include_passing: include_passing,
|
|
48
|
+
include_stdout: include_stdout)
|
|
49
|
+
data = server_context[:worker_manager].run_spec(paths: paths, example: example, seed: seed,
|
|
50
|
+
fail_fast: fail_fast, **inputs)
|
|
48
51
|
Response.ok(data: data, meta: Response.meta(started_at))
|
|
49
52
|
rescue Coatepec::Error => e
|
|
50
53
|
Response.error(e)
|
|
@@ -53,21 +56,28 @@ module Coatepec
|
|
|
53
56
|
end
|
|
54
57
|
|
|
55
58
|
# The `rails_runtime_status` MCP tool: reports the test worker's
|
|
56
|
-
# Ruby/Rails versions, PID, boot_id, lifecycle state
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
59
|
+
# Ruby/Rails versions, PID, boot_id, lifecycle state, the spec strategy
|
|
60
|
+
# and its fallback count, the project root and the effective tool
|
|
61
|
+
# defaults. Worker::Server#handle boots the Rails runtime before
|
|
62
|
+
# dispatching any command, so the first call to this tool starts (and
|
|
63
|
+
# blocks on) a full Rails boot just like a spec run.
|
|
60
64
|
class RuntimeStatusTool < ::MCP::Tool
|
|
61
65
|
tool_name "rails_runtime_status"
|
|
62
|
-
description "Report the Coatepec test worker's identity and boot status " \
|
|
63
|
-
"
|
|
66
|
+
description "Report the Coatepec test worker's identity and boot status (boots the warm worker if it is " \
|
|
67
|
+
"not up yet); includes project_root, spec_strategy (fork, guarded_fork or spawn), fallbacks " \
|
|
68
|
+
"(how many guarded-fork runs fell back to spawn; null unless guarded_fork) and defaults (the " \
|
|
69
|
+
"effective rails_spec_run and rails_routes defaults after .coatepec.yml)"
|
|
64
70
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
65
71
|
input_schema(properties: {}, required: [], additionalProperties: false)
|
|
66
72
|
|
|
67
73
|
class << self
|
|
68
74
|
def call(server_context:)
|
|
69
75
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
70
|
-
|
|
76
|
+
root = server_context[:project_root]
|
|
77
|
+
data = server_context[:worker_manager].status.merge(
|
|
78
|
+
project_root: root,
|
|
79
|
+
defaults: { spec_run: Defaults.resolve(:spec_run, root), routes: Defaults.resolve(:routes, root) }
|
|
80
|
+
)
|
|
71
81
|
Response.ok(data: data, meta: Response.meta(started_at))
|
|
72
82
|
rescue Coatepec::Error => e
|
|
73
83
|
Response.error(e)
|
|
@@ -147,7 +157,8 @@ module Coatepec
|
|
|
147
157
|
"engine field (null for an application route; query also matches that field); returns columns " \
|
|
148
158
|
"(name, verb, path, controller, action, engine) and up to limit rows (default 100) in that " \
|
|
149
159
|
"order, paths without the (.:format) suffix Rails appends, with next_offset -- the offset to " \
|
|
150
|
-
"pass back for the next page, null on the last one"
|
|
160
|
+
"pass back for the next page, null on the last one" \
|
|
161
|
+
"; the engines default can be set project-wide in .coatepec.yml"
|
|
151
162
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
152
163
|
input_schema(
|
|
153
164
|
properties: {
|
|
@@ -161,9 +172,10 @@ module Coatepec
|
|
|
161
172
|
)
|
|
162
173
|
|
|
163
174
|
class << self
|
|
164
|
-
def call(server_context:, query: nil, limit: 100, offset: 0, engines:
|
|
175
|
+
def call(server_context:, query: nil, limit: 100, offset: 0, engines: nil)
|
|
165
176
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
166
|
-
|
|
177
|
+
inputs = Defaults.resolve(:routes, server_context[:project_root], engines: engines)
|
|
178
|
+
data = server_context[:worker_manager].routes(query: query, limit: limit, offset: offset, **inputs)
|
|
167
179
|
Response.ok(data: data, meta: Response.meta(started_at))
|
|
168
180
|
rescue Coatepec::Error => e
|
|
169
181
|
Response.error(e)
|
data/lib/coatepec/mcp.rb
CHANGED
|
@@ -7,12 +7,28 @@ module Coatepec
|
|
|
7
7
|
# project root. A missing file means every setting takes its default --
|
|
8
8
|
# this file has never been required for Coatepec to work.
|
|
9
9
|
class ProjectConfig
|
|
10
|
+
# Mirrors the MCP input schemas so a value the file accepts is one the tool accepts.
|
|
11
|
+
DEFAULT_KEYS = {
|
|
12
|
+
"spec_run" => {
|
|
13
|
+
"include_passing" => { boolean: true },
|
|
14
|
+
"include_stdout" => { enum: %w[failures always never] },
|
|
15
|
+
"timeout_seconds" => { range: 1..900 }
|
|
16
|
+
},
|
|
17
|
+
"routes" => { "engines" => { enum: %w[include exclude only] } }
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
10
20
|
def initialize(root)
|
|
11
21
|
@data = load(root)
|
|
22
|
+
@defaults = validate_defaults!(@data["defaults"] || {}) # a bare `defaults:` key means none
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def defaults_for(tool)
|
|
26
|
+
@defaults.fetch(tool.to_s, {}).transform_keys(&:to_sym)
|
|
12
27
|
end
|
|
13
28
|
|
|
29
|
+
# Forking is the macOS default since 0.9.0; `macos_fork: false` opts a project out.
|
|
14
30
|
def macos_fork?
|
|
15
|
-
|
|
31
|
+
@data.fetch("macos_fork", true) ? true : false
|
|
16
32
|
end
|
|
17
33
|
|
|
18
34
|
def macos_fork_unsafe_gems
|
|
@@ -21,6 +37,29 @@ module Coatepec
|
|
|
21
37
|
|
|
22
38
|
private
|
|
23
39
|
|
|
40
|
+
def validate_defaults!(defaults)
|
|
41
|
+
invalid!("defaults must be a mapping of tool names") unless defaults.is_a?(Hash)
|
|
42
|
+
defaults.each do |tool, keys|
|
|
43
|
+
rules = DEFAULT_KEYS[tool.to_s] || invalid!("defaults.#{tool} is not a configurable tool")
|
|
44
|
+
invalid!("defaults.#{tool} must be a mapping") unless keys.is_a?(Hash)
|
|
45
|
+
keys.each { |key, value| validate_default!(tool, key, value, rules[key.to_s]) }
|
|
46
|
+
end
|
|
47
|
+
defaults
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate_default!(tool, key, value, rule)
|
|
51
|
+
invalid!("defaults.#{tool}.#{key} is not a configurable input") unless rule
|
|
52
|
+
ok = if rule[:boolean] then [true, false].include?(value)
|
|
53
|
+
elsif rule[:enum] then rule[:enum].include?(value)
|
|
54
|
+
else value.is_a?(Integer) && rule[:range].cover?(value)
|
|
55
|
+
end
|
|
56
|
+
invalid!("defaults.#{tool}.#{key}: #{value.inspect} is not allowed") unless ok
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def invalid!(message)
|
|
60
|
+
raise Coatepec::Error.new(:invalid_config, "Invalid .coatepec.yml: #{message}")
|
|
61
|
+
end
|
|
62
|
+
|
|
24
63
|
def load(root)
|
|
25
64
|
path = File.join(root, ".coatepec.yml")
|
|
26
65
|
return {} unless File.exist?(path)
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Coatepec
|
|
4
4
|
module Spec
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# crashes. See
|
|
5
|
+
# The macOS fork strategy (the default since 0.9.0; macos_fork: false
|
|
6
|
+
# opts out): attempts Process.fork like ForkStrategy (reusing the warm
|
|
7
|
+
# worker's boot), but only after two cheap guard checks pass, and
|
|
8
|
+
# transparently falls back to a fresh SpawnStrategy run -- for this call
|
|
9
|
+
# only -- when a guard fails or the forked child crashes. See
|
|
10
|
+
# docs/superpowers/specs/2026-07-31-macos-guarded-fork-design.md.
|
|
10
11
|
class GuardedForkStrategy < ForkStrategy
|
|
11
12
|
# Intentionally empty at ship time: the one documented crash this
|
|
12
13
|
# guards against didn't name a specific culprit gem, just "something
|
|
@@ -52,11 +53,11 @@ module Coatepec
|
|
|
52
53
|
# Process.fork itself failed (Errno::EAGAIN/ENOMEM under
|
|
53
54
|
# process-table pressure), so no child was ever produced -- from the
|
|
54
55
|
# caller's side that is indistinguishable from a failed guard, hence
|
|
55
|
-
# the same mode.
|
|
56
|
-
#
|
|
56
|
+
# the same mode. Forking on macOS must never surface an error that
|
|
57
|
+
# plain SpawnStrategy wouldn't have.
|
|
57
58
|
return fallback_result(args, timeout_seconds, "spawn_fallback", result_options)
|
|
58
59
|
end
|
|
59
|
-
return result
|
|
60
|
+
return result unless crashed?(result)
|
|
60
61
|
|
|
61
62
|
retry_after_crash(args, timeout_seconds, started_at, result, result_options)
|
|
62
63
|
end
|
|
@@ -29,10 +29,16 @@ module Coatepec
|
|
|
29
29
|
|
|
30
30
|
reap(pid, [out_r, err_r], timeout_seconds, json_path,
|
|
31
31
|
{ include_passing: include_passing, include_stdout: include_stdout })
|
|
32
|
+
.merge(execution_mode: execution_mode)
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
private
|
|
35
36
|
|
|
37
|
+
# Every result says how it ran, so an absent key can never be mistaken for "the guard fell back".
|
|
38
|
+
def execution_mode
|
|
39
|
+
raise NotImplementedError, "#{self.class} must implement #execution_mode"
|
|
40
|
+
end
|
|
41
|
+
|
|
36
42
|
# Subclasses start a process and return its pid; the test framework's
|
|
37
43
|
# own output must be wired to out_w/err_w. json_path is where the
|
|
38
44
|
# adapter's structured per-example output must land.
|
data/lib/coatepec/spec/runner.rb
CHANGED
|
@@ -4,9 +4,9 @@ module Coatepec
|
|
|
4
4
|
module Spec
|
|
5
5
|
# Validates a `rails_spec_run` request's paths, picks the RSpec or
|
|
6
6
|
# Minitest adapter from their shape, builds the CLI args, and delegates
|
|
7
|
-
# to the platform-appropriate process strategy (fork on Linux,
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# to the platform-appropriate process strategy (fork on Linux, and a
|
|
8
|
+
# guarded fork on macOS unless the project opts out via .coatepec.yml,
|
|
9
|
+
# which selects a fresh spawn per call).
|
|
10
10
|
class Runner
|
|
11
11
|
DEFAULT_TIMEOUT = 120
|
|
12
12
|
|
|
@@ -24,8 +24,17 @@ module Coatepec
|
|
|
24
24
|
adapter.require_framework!
|
|
25
25
|
args = adapter.build_args(validated[:selectors], example, seed, fail_fast)
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
result = strategy_class
|
|
28
|
+
.new(@project_root, adapter: adapter, project: @project, rails_runtime: @rails_runtime)
|
|
29
|
+
.run(args, timeout_seconds, include_passing: include_passing, include_stdout: include_stdout)
|
|
30
|
+
@rails_runtime&.record_execution_mode(result[:execution_mode])
|
|
31
|
+
result
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Reported by rails_runtime_status so a caller can see which path a run will take.
|
|
35
|
+
def strategy_name
|
|
36
|
+
{ ForkStrategy => "fork", GuardedForkStrategy => "guarded_fork", SpawnStrategy => "spawn" }
|
|
37
|
+
.fetch(strategy_class)
|
|
29
38
|
end
|
|
30
39
|
|
|
31
40
|
private
|
|
@@ -49,9 +58,8 @@ module Coatepec
|
|
|
49
58
|
end
|
|
50
59
|
end
|
|
51
60
|
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
# Accepted asymmetry: the file exists to configure this branch.
|
|
61
|
+
# Forking is the default; only an explicit `macos_fork: false` spawns. Only the macOS
|
|
62
|
+
# branch reads the file here; the MCP layer reads it on every call for `defaults`.
|
|
55
63
|
def macos_strategy_class
|
|
56
64
|
@project.config.macos_fork? ? GuardedForkStrategy : SpawnStrategy
|
|
57
65
|
end
|
|
@@ -9,6 +9,8 @@ module Coatepec
|
|
|
9
9
|
class SpawnStrategy < ProcessStrategy
|
|
10
10
|
private
|
|
11
11
|
|
|
12
|
+
def execution_mode = "spawn"
|
|
13
|
+
|
|
12
14
|
def start(full_args, out_w, err_w, json_path)
|
|
13
15
|
env, argv = @adapter.spawn_command(full_args, json_path)
|
|
14
16
|
Process.spawn(env, *argv, chdir: @project_root, out: out_w, err: err_w, pgroup: true)
|
data/lib/coatepec/version.rb
CHANGED
|
@@ -8,17 +8,26 @@ module Coatepec
|
|
|
8
8
|
# per worker process and reports its identity (pid, boot_id, versions,
|
|
9
9
|
# lifecycle state) for rails_runtime_status.
|
|
10
10
|
class RailsRuntime
|
|
11
|
-
attr_reader :pid, :boot_id, :boot_duration_ms, :ruby_version, :rails_version, :post_boot_thread_count
|
|
11
|
+
attr_reader :pid, :boot_id, :boot_duration_ms, :ruby_version, :rails_version, :post_boot_thread_count,
|
|
12
|
+
:fallback_count
|
|
13
|
+
|
|
14
|
+
FALLBACK_MODES = %w[spawn_fallback spawn_after_crash].freeze
|
|
12
15
|
|
|
13
16
|
def initialize(project_root)
|
|
14
17
|
@project_root = project_root
|
|
15
18
|
@booted = false
|
|
19
|
+
@fallback_count = 0
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
def booted?
|
|
19
23
|
@booted
|
|
20
24
|
end
|
|
21
25
|
|
|
26
|
+
# A guarded fork that declined or crashed ran via spawn; rails_runtime_status reports how often.
|
|
27
|
+
def record_execution_mode(mode)
|
|
28
|
+
@fallback_count += 1 if FALLBACK_MODES.include?(mode)
|
|
29
|
+
end
|
|
30
|
+
|
|
22
31
|
def boot!
|
|
23
32
|
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
24
33
|
ENV["RAILS_ENV"] = "test"
|
|
@@ -40,7 +40,7 @@ module Coatepec
|
|
|
40
40
|
|
|
41
41
|
def execute_command(command, args)
|
|
42
42
|
case command
|
|
43
|
-
when "status" then
|
|
43
|
+
when "status" then handle_status
|
|
44
44
|
when "spec_run" then handle_spec_run(args)
|
|
45
45
|
when "flaky_check" then handle_flaky_check(args)
|
|
46
46
|
when "routes" then handle_routes(args)
|
|
@@ -51,6 +51,12 @@ module Coatepec
|
|
|
51
51
|
end
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# spec_strategy and fallbacks are the worker's to report: it owns the platform check and the counter.
|
|
55
|
+
def handle_status
|
|
56
|
+
name = Spec::Runner.new(@project_root, rails_runtime: @runtime).strategy_name
|
|
57
|
+
@runtime.status.merge(spec_strategy: name, fallbacks: name == "guarded_fork" ? @runtime.fallback_count : nil)
|
|
58
|
+
end
|
|
59
|
+
|
|
54
60
|
def handle_spec_run(args)
|
|
55
61
|
Spec::Runner.new(@project_root, rails_runtime: @runtime).run(**args.transform_keys(&:to_sym))
|
|
56
62
|
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.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Enrique Mogollan
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
10
|
+
date: 2026-09-23 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: railties
|
|
@@ -75,6 +75,7 @@ files:
|
|
|
75
75
|
- lib/coatepec/introspection/routes.rb
|
|
76
76
|
- lib/coatepec/introspection/safe_options.rb
|
|
77
77
|
- lib/coatepec/mcp.rb
|
|
78
|
+
- lib/coatepec/mcp/defaults.rb
|
|
78
79
|
- lib/coatepec/mcp/response.rb
|
|
79
80
|
- lib/coatepec/mcp/tools.rb
|
|
80
81
|
- lib/coatepec/project.rb
|