Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ concurrency:

jobs:
test:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ['3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v4
Expand All @@ -35,4 +36,4 @@ jobs:
pip install -e ".[dev,http,jq,kafka,db,logs,grpc]"

- name: Run tests
run: pytest
run: pytest tests/unit
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ source of truth in [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md).

## Setup

**Requirements:** Python ≥ 3.11.
**Requirements:** Python ≥ 3.11. Runs on Linux, macOS, native Windows, and WSL.
The managed mock daemon (`mock start`/`stop`/`status`) is Linux/macOS/WSL-only —
on native Windows use `mock run` (foreground) or run inside WSL.

Install `agctl` into your project (this repo uses `uv` — `pip` works too):

Expand Down
9 changes: 8 additions & 1 deletion agctl/commands/config_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,14 @@ def _load_sample() -> str:
"""
try:
root = importlib.resources.files("agctl")
return (root.joinpath(*_SAMPLE_RESOURCE)).read_text(encoding="utf-8")
text = (root.joinpath(*_SAMPLE_RESOURCE)).read_text(encoding="utf-8")
# Normalize to LF: importlib's traversable read does NOT apply universal-
# newline translation, so a CRLF checkout on Windows (core.autocrlf=true)
# returns CRLF here, while pathlib.Path.read_text() DOES normalize to LF.
# That mismatch breaks consumers that compare a Path.read_text() result to
# this sample. Collapse CRLF and bare CR to LF so the packaged sample is
# consistent (LF) on every platform regardless of checkout.
return text.replace("\r\n", "\n").replace("\r", "\n")
except (FileNotFoundError, OSError) as err:
raise ConfigError(
f"Sample config not found in the agctl package: {err}",
Expand Down
27 changes: 27 additions & 0 deletions agctl/commands/mock_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,30 @@ def spawn_daemon(argv: list[str], log_path: str, env: dict | None = None) -> int
return proc.pid


def _require_posix_daemon() -> None:
"""Gate the managed mock daemon to POSIX.

On native Windows (``os.name == "nt"``) the managed daemon
(``mock start``/``stop``/``status``) is unsupported; raise ``ConfigError``
pointing at ``mock run`` or WSL. WSL reports ``"posix"`` and passes through.
Detection reads ``os.name`` via this module's ``os`` binding so a unit test
can force the branch without mutating the global ``os`` module.
"""
if os.name == "nt":
raise ConfigError(
"the managed mock daemon (mock start/stop/status) is supported on "
"Linux, macOS, and WSL; on native Windows use 'agctl mock run' or "
"run inside WSL",
{
"platform": sys.platform,
"hint": (
"use 'agctl mock run' (foreground) or run agctl inside WSL "
"for the managed daemon"
),
},
)


def _mock_start_core(
config_path: str | None,
http_listen: str | None,
Expand All @@ -231,6 +255,7 @@ def _mock_start_core(
Raises:
ConfigError: If already running, startup error, or timeout.
"""
_require_posix_daemon()
# Step 1: Load config
cfg = load_config_or_raise(config_path, overlay_paths=overlay_paths)

Expand Down Expand Up @@ -589,6 +614,7 @@ def _mock_stop_core(
Raises:
AssertionFailure: When any stopped mock had fatal failure events.
"""
_require_posix_daemon()
state_path = Path(state_dir)

# Step 1: Resolve targets
Expand Down Expand Up @@ -715,6 +741,7 @@ def _mock_status_core(
ConfigError: If multiple mocks running and no selector, or if
specified --listen doesn't match any running mock.
"""
_require_posix_daemon()
state_path = Path(state_dir)

# Step 1: Resolve targets (no --all flag for status)
Expand Down
28 changes: 18 additions & 10 deletions agctl/mock/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,11 +266,10 @@ def _handle_request(self) -> None:
# this path: it is only needed for a matched stub, so building it
# here would be wasted work whose result is immediately discarded.
if matched_stub is None:
self._send_json_response(
404,
{"mock_error": "no matching stub"},
headers={"Content-Type": "application/json"},
)
# Emit BEFORE sending the response: the handler thread runs
# emit -> send in program order, so once the client has the
# response the event is guaranteed appended (no test races on
# reading event_sink immediately after the response).
emit_event(
{
"event": "http.unmatched",
Expand All @@ -279,6 +278,11 @@ def _handle_request(self) -> None:
"status": 404,
}
)
self._send_json_response(
404,
{"mock_error": "no matching stub"},
headers={"Content-Type": "application/json"},
)
return

# Step 5: Build capture context for the matched stub only.
Expand Down Expand Up @@ -340,11 +344,10 @@ def _handle_request(self) -> None:
if stub.delay_ms > 0:
time.sleep(stub.delay_ms / 1000.0)

# Send response
self._send_json_response(
stub.response.status, rendered_body, rendered_headers
)

# Emit BEFORE sending the response (see the 404 path): the
# handler thread runs emit -> send, so the event is appended
# before the client can receive the response. duration_ms is
# measured up to emit (after the delay, before the TCP write).
duration_ms = int((time.time() - start_time) * 1000)
emit_event(
{
Expand All @@ -356,6 +359,11 @@ def _handle_request(self) -> None:
"duration_ms": duration_ms,
}
)

# Send response
self._send_json_response(
stub.response.status, rendered_body, rendered_headers
)
finally:
semaphore.release()
else:
Expand Down
12 changes: 12 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,18 @@ What the system does **not** do today (as-built; see DESIGN §10 for the roadmap
(deferred).
- **No MCP wrapper, no OpenTelemetry propagation, no parallel runner, no secret
backends** — deferred per DESIGN §10.
- **Native-Windows managed-daemon gate** — the three managed-daemon `_core`s
(`_mock_start_core`/`_mock_stop_core`/`_mock_status_core` in
`commands/mock_commands.py`) call `_require_posix_daemon()`, which raises
`ConfigError` (exit 2) when `os.name == "nt"`; WSL reports `"posix"`, so it
passes through ungated. `mock run` (foreground streaming) and every other
command group run natively on Windows. Streaming graceful-stop contract:
backgrounded streamers (`http ping`, `mock run`, `logs tail`, `grpc`
server-stream/bidi) install `SIGTERM`/`SIGINT` handlers; on native Windows
only the `SIGINT`/Ctrl+C path reaches the handler (a `SIGTERM` via `os.kill`
hard-terminates). The `SIGTERM`-driven graceful-stop (and the daemon's
`SIGTERM`-based shutdown that `mock stop` drives) is POSIX/WSL — the reason
the daemon is gated there.

**Mock server MVP limitations** (see DESIGN §10 "Known-wrong-result / Not Covered" for the full list with failure-mode analysis):

Expand Down
6 changes: 6 additions & 0 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1231,6 +1231,12 @@ agctl mock run --only http --http-listen 127.0.0.1:18080
agctl mock run --duration 30 --fail-fast
```

#### Platform support

The managed daemon (`mock start`/`stop`/`status`) is supported on **Linux, macOS, and WSL**. On **native Windows** it exits `2` with a `ConfigError` whose message points at `mock run` (foreground) or running inside WSL. `mock run` (foreground) and every other command group are cross-platform, including native Windows — the jq-powered features (`--match`, `--jq-path`, mock `match.jq`) included.

**Streaming graceful-stop.** Backgrounded streaming commands (`http ping`, `mock run`, `logs tail`, `grpc` server-stream/bidi) stop gracefully on `Ctrl+C`/`SIGINT` everywhere, including native Windows. The `SIGTERM`-driven graceful-stop pattern — which the managed daemon's shutdown (`mock stop`) relies on — is POSIX/WSL, which is why the daemon is gated to those platforms.

#### `agctl mock start` — managed daemon (start)

Start the mock server as a detached daemon with automatic pidfile management and readiness polling. The daemon runs the same engine as `mock run` but in the background, with its stdout redirected to a log file. The command returns a single JSON envelope once the daemon is ready (the `started` line has appeared in the log).
Expand Down
Loading
Loading