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
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ A pure-Ruby client for the [cgminer](https://github.com/ckolivas/cgminer) JSON-o

**Stack:** Ruby 3.2+ only. Zero runtime dependencies beyond Ruby stdlib (`json`, `socket`, `yaml`, `pp`). Dev deps are `rspec`, `rubocop` (+ `-rake` and `-rspec`), `rake`, `simplecov`.

**Footprint:** ~650 SLOC in `lib/`, ~1500 SLOC in `spec/`. Small, deliberately simple.
**Footprint:** ~510 SLOC in `lib/`, ~3000 SLOC in `spec/`. Small, deliberately simple.

**Logging posture:** silent-by-design — the library has no `Logger` module and emits no structured events. Callers (monitor, manager, CLI) own log call sites. For telemetry during debugging there's a CLI `-v`/`--verbose` flag (prints requests + responses to stderr, operator-facing, not a parse-able contract) and a library-level `on_wire:` callback kwarg on both `Miner.new` and `MinerPool.new` (a `proc.(direction, host, port, payload)` that fires on each request / response / response-repaired event). See [`docs/logging.md`](docs/logging.md) for the full story and for how sibling gems log api_client outcomes.

## Repo layout

Expand Down Expand Up @@ -84,6 +86,7 @@ Lib ─┼──> MinerPool ──(parallel threads)──> Miner ──> socket
3. **`MinerPool#query` always returns a `PoolResult`** — an Enumerable of per-miner `MinerResult` instances in pool order. Successes and failures are both captured structurally; `MinerPool#query` never raises on a single-miner failure.
4. **`Miner#query` raises on failure:** `ConnectionError` for transport-level problems, `ApiError` for cgminer `STATUS=E`/`F` responses. `TimeoutError < ConnectionError` for connect timeouts specifically.
5. **Library code never writes to stderr.** The CLI owns that channel. `Miner#check_status` does `puts` on cgminer `STATUS=I`/`W` to stdout, which is by design (cgminer advisory messages).
6. **`on_wire:` is best-effort telemetry, not a hard contract.** The callback is invoked from `safe_on_wire` which swallows any exception raised by the block — a buggy logger must not break the miner query. Three directions: `:request` (outbound JSON), `:response` (raw inbound string, pre-parse), `:response_repaired` (the rare `}{` legacy-repair path). The CLI's `-v` flag is implemented by installing a default `on_wire` that writes formatted lines to stderr.

## Conventions that matter when editing code

Expand Down Expand Up @@ -157,6 +160,8 @@ bundle exec rubocop -A # lint + auto-correct (review diffs!)

**Coverage** is always on (SimpleCov in `spec_helper.rb`). Reports land in `coverage/` — don't commit that directory (it's `.gitignore`d).

**Mermaid validation.** Every `docs/*.md` may contain ` ```mermaid ` blocks. Run `script/validate_mermaid` to lint them all — the script extracts each block and pipes it through `npx @mermaid-js/mermaid-cli`. Requires `node >= 18` and `npx` on PATH. First run is slow (Puppeteer downloads Chromium into `~/.npm/_npx`, ~300 MB; cached after). Not wired into `bundle exec rake` — it's an opt-in local check for docs PRs.

**Manual sandbox** for exercising the CLI without real miners:

```sh
Expand All @@ -165,6 +170,7 @@ bundle exec rubocop -A # lint + auto-correct (review diffs!)
# terminal 2
cp config/miners.yml.example config/miners.yml
bundle exec bin/cgminer_api_client summary
bundle exec bin/cgminer_api_client -v summary # verbose: echo request + response to stderr
```

## Adding a new cgminer command wrapper
Expand Down Expand Up @@ -262,6 +268,7 @@ The `.gem` artifact shouldn't be committed to the repo, but one is present at th
| What's in a `MinerResult` / `PoolResult`? What errors can be raised? | [`docs/data_models.md`](docs/data_models.md) |
| How does a request flow through the code? Parallel fan-out? CLI? | [`docs/workflows.md`](docs/workflows.md) |
| Runtime deps? Why Ruby 3.2+? | [`docs/dependencies.md`](docs/dependencies.md) |
| Logging posture: silent-by-design, CLI `-v`/`--verbose`, library `on_wire:` callback, how callers log outcomes | [`docs/logging.md`](docs/logging.md) |
| Known doc/code drift, caveats, things I'm not sure about | [`docs/review_notes.md`](docs/review_notes.md) |
| Full knowledge-base index | [`docs/index.md`](docs/index.md) |
| User-facing docs | [`README.md`](README.md) |
Expand Down
10 changes: 6 additions & 4 deletions docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,17 @@ Mixed into `Miner`. The switch from `IO.select(nil, [socket], nil, timeout)` to
### `CgminerApiClient::Miner`
**File:** `lib/cgminer_api_client/miner.rb`

Per-host client. Carries `host`, `port`, `timeout`. Public surface:
Per-host client. Carries `host`, `port`, `timeout`. Constructor: `Miner.new(host = nil, port = nil, timeout = nil, on_wire: nil)` — positional args fall back to `CgminerApiClient.default_*`; `on_wire:` is an optional telemetry callback (see below).

Public surface:

- `query(method, *params)` — marshal request, send, parse, dispatch `check_status`, return sanitized data. Raises `ConnectionError` on transport failure, `ApiError` on cgminer-reported error.
- `available?` — true reachability probe. Opens a fresh socket every call (no cache). Returns `false` on `SocketError`/`SystemCallError`/`TimeoutError`; lets other exceptions propagate. Does **not** perform an API handshake.
- All methods from `Miner::Commands::ReadOnly` and `Miner::Commands::Privileged` via `include`.
- `method_missing` forwards unknown names to `query`.
- `respond_to_missing?` says yes for any name that isn't `to_*`/`_*`.

Private methods: `perform_request` (socket write/read/JSON parse, plus control-byte re-escape and `}{` repair), `check_status` (STATUS code dispatch), `sanitized` (recursive key normalization).
Private methods: `perform_request` (socket write/read/JSON parse, plus control-byte re-escape and `}{` repair), `check_status` (STATUS code dispatch), `sanitized` (recursive key normalization), `safe_on_wire` (invokes the `on_wire:` callback if present; swallows any exception the block raises so a buggy logger cannot break a query). `safe_on_wire` fires three directions: `:request` (outbound JSON), `:response` (raw inbound string, pre-parse), `:response_repaired` (only when the `}{` repair path actually runs — rare against modern cgminer).

### `CgminerApiClient::Miner::Commands` (module)
**File:** `lib/cgminer_api_client/miner/commands.rb`
Expand All @@ -111,7 +113,7 @@ Both `Miner` and `MinerPool` `include Miner::Commands`. The `Commands` methods o
### `CgminerApiClient::MinerPool`
**File:** `lib/cgminer_api_client/miner_pool.rb`

Parallel fan-out wrapper. Loads `config/miners.yml` on construction (via `load_miners!`, using `YAML.safe_load_file`) and builds one `Miner` per entry.
Parallel fan-out wrapper. Constructor: `MinerPool.new(on_wire: nil)` — optional `on_wire:` callback is forwarded verbatim into every `Miner` the pool constructs, so one hook covers the whole fan-out. Loads `config/miners.yml` on construction (via `load_miners!`, using `YAML.safe_load_file`) and builds one `Miner` per entry.

Public surface:

Expand Down Expand Up @@ -160,7 +162,7 @@ end
### `bin/cgminer_api_client`
**File:** `bin/cgminer_api_client` (42 lines, shebang + executable)

Thin driver. Validates command name against `Miner::Commands.instance_methods`, builds a `MinerPool`, runs `pool.query`, prints per-miner output, exits. See [architecture.md](architecture.md#what-the-cli-adds-on-top) and [workflows.md](workflows.md#cli-request-flow) for details.
Thin driver. Validates command name against `Miner::Commands.instance_methods`, builds a `MinerPool`, runs `pool.query`, prints per-miner output, exits. Accepts a `-v`/`--verbose` flag that installs a default `on_wire:` callback into the `MinerPool` it builds — the callback writes each direction (`>>>` request, `<<<` response, `<<< (repaired)` for the `}{` path) to stderr under a `Mutex` so interleaved per-miner lines don't tear, and rescues `Errno::EPIPE` / `IOError` so stderr closure mid-run doesn't break the query fan-out. See [architecture.md](architecture.md#what-the-cli-adds-on-top), [workflows.md](workflows.md#cli-request-flow), and [logging.md](logging.md) for details.

## Test-only components (not packaged in the gem)

Expand Down
22 changes: 21 additions & 1 deletion docs/interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,17 @@ miner = CgminerApiClient::Miner.new('10.0.0.5')
miner = CgminerApiClient::Miner.new('10.0.0.5', 4028)
miner = CgminerApiClient::Miner.new('10.0.0.5', 4028, 3)

# Optional on_wire callback for telemetry (see Logging section below)
miner = CgminerApiClient::Miner.new('10.0.0.5', on_wire: ->(dir, host, port, payload) {
warn "#{dir} #{host}:#{port} #{payload}"
})

miner.host # "10.0.0.5"
miner.port # 4028
miner.timeout # 3
```

Any of the three constructor args can be omitted/nil; the corresponding `CgminerApiClient.default_*` value is used.
Any of the three positional args can be omitted/nil; the corresponding `CgminerApiClient.default_*` value is used. `on_wire:` is optional; when omitted, no wire telemetry is emitted.

**Commands (all return the parsed, sanitized response on success; raise `ConnectionError` on transport failure, `ApiError` on cgminer rejection):**

Expand Down Expand Up @@ -112,10 +117,13 @@ miner.respond_to?(:_dump) # false (_* is carved out)

```ruby
pool = CgminerApiClient::MinerPool.new # reads ./config/miners.yml
pool = CgminerApiClient::MinerPool.new(on_wire: my_callback) # telemetry hook
pool.miners # Array<Miner> in config order
pool.reload_miners! # re-read config/miners.yml
```

`MinerPool.new` accepts a single `on_wire:` keyword arg; the same callback is forwarded into each `Miner` it constructs, so every per-miner request/response fires through one hook. See the Logging section below.

**Commands (same surface as `Miner`; all return a `PoolResult` of `MinerResult`s):**

```ruby
Expand Down Expand Up @@ -206,6 +214,14 @@ Invocation happens from a directory containing `config/miners.yml`. The CLI read

Library code never writes to stderr. The CLI owns that channel. Existing shell pipelines capturing stdout get clean output even when some miners fail.

### Flags

| Flag | Purpose |
|---|---|
| `-v` / `--verbose` | Print each outbound request (JSON) and inbound response (raw string, pre-parse) to stderr prefixed with direction (`>>>`, `<<<`, or `<<< (repaired)` for the legacy `}{`-repair path) and `host:port`. Operator-facing diagnostic output, not a parse-able contract. Writes are mutex-serialized so interleaved per-miner lines don't tear. |

Place the flag before the command: `cgminer_api_client -v summary`.

### Environment variables

- `DEBUG=1` — on a top-level (unhandled) exception, also print the full backtrace via `Exception#full_message(highlight: false)` to stderr.
Expand Down Expand Up @@ -235,6 +251,10 @@ $ DEBUG=1 cgminer_api_client summary
# ... same output plus full backtrace on any unhandled exception
```

### Logging and telemetry

The CLI is the only surface that writes diagnostic lines to stderr; the library is silent by design. See [`docs/logging.md`](logging.md) for the full posture, the event names sibling gems (`cgminer_monitor`, `cgminer_manager`) use when they log api_client outcomes, and how the `on_wire:` library hook compares to the CLI's `-v` flag (same mechanism — the CLI installs a default `on_wire` that writes to stderr).

## 3. `config/miners.yml` schema

Path: `./config/miners.yml` (relative to process CWD).
Expand Down
15 changes: 13 additions & 2 deletions docs/logging.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
# Logging

`cgminer_api_client` is **silent by design.** It has no `Logger` module and emits no structured log events. On failure it raises (`CgminerApiClient::ConnectionError`, `CgminerApiClient::SocketReadTimeout`, `CgminerApiClient::ProtocolError`, etc.); on success it returns `CgminerApiClient::MinerResult` or `CgminerApiClient::PoolResult` instances.
`cgminer_api_client` is **silent by design.** It has no `Logger` module and emits no structured log events. On failure `Miner#query` raises from the public hierarchy in `lib/cgminer_api_client/errors.rb` — `CgminerApiClient::Error`, `ConnectionError` (transport-layer), `TimeoutError < ConnectionError` (connect timeout specifically), `ApiError` (cgminer `STATUS=E`/`F` response). `MinerPool#query` captures these into `MinerResult.failure` rather than raising, so a pool call always returns a `PoolResult` even when every miner fails.

Callers — `cgminer_monitor`'s poll loop, `cgminer_manager`'s fleet commander, the `bin/cgminer_api_client` CLI — own the log call sites and decide what's worth emitting. This keeps the library dependency-free of any logging backend and lets each consumer pick its own verbosity / destination / format.

The CLI has a `-v`/`--verbose` flag that prints each request and response to stderr with a direction prefix + `host:port`. This is operator-facing diagnostic output, not a structured-log contract — the lines are not JSON and are not intended to be parsed. Use it to debug a specific miner interactively; don't pipe it into an aggregator.
## Library-level hook: `on_wire:`

Both `Miner.new` and `MinerPool.new` accept an optional `on_wire:` keyword arg — a `proc.(direction, host, port, payload)` invoked for each request / response / `}{`-repaired response. `MinerPool` forwards the same callback into every `Miner` it constructs so a single hook covers the whole fan-out. The callback is wrapped in `safe_on_wire` — any exception it raises is swallowed with no retry, so a buggy logger cannot break a query.

Directions:
- `:request` — outbound JSON just before `socket.write`.
- `:response` — raw inbound string, pre-parse.
- `:response_repaired` — the rare legacy path where cgminer returned `}{`-concatenated JSON and the client split it; carries the repaired string.

## CLI: `-v`/`--verbose`

The CLI has a `-v`/`--verbose` flag that installs a default `on_wire` writing each direction to stderr with a direction prefix (`>>>`, `<<<`, `<<< (repaired)`) and `host:port`. Writes are mutex-serialized to keep interleaved per-miner lines intact. Handles `Errno::EPIPE` / `IOError` defensively — if stderr closes mid-run (because a downstream `| head` exited), the query fan-out keeps going. This is operator-facing diagnostic output, not a structured-log contract — the lines are not JSON and are not intended to be parsed. Use it to debug a specific miner interactively; don't pipe it into an aggregator.

## How callers log api_client outcomes

Expand Down
6 changes: 6 additions & 0 deletions docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,15 @@ sequenceDiagram
SWT--xMiner: SocketError / SystemCallError
Miner--xCaller: ConnectionError (re-wrapped)
end
Miner->>Miner: safe_on_wire(:request, request.to_json) — fires on_wire: if set
Miner->>cgminer: write(request.to_json)
cgminer-->>Miner: JSON response + EOF
Miner->>Miner: safe_on_wire(:response, raw_response) — fires on_wire: if set
Miner->>Miner: re-escape control bytes < 0x20
Miner->>Miner: gsub repairs for '}{' & '[,{'
opt '}{' repair ran
Miner->>Miner: safe_on_wire(:response_repaired, repaired)
end
Miner->>Miner: JSON.parse(response)
Miner->>Miner: check_status(data) — raise ApiError on E/F
Miner->>Miner: sanitized(data) — keys lowercased + symbolized
Expand All @@ -39,6 +44,7 @@ sequenceDiagram
- No connection reuse. Every call opens and closes a fresh socket.
- Read-to-EOF pattern. Server closing the socket is what signals "response complete."
- `Miner#query` only raises `ConnectionError` or `ApiError` (or a bug like `ArgumentError` if misused); cgminer `STATUS=I`/`STATUS=W` just print a line and continue.
- `safe_on_wire` is a no-op when `on_wire:` wasn't passed to `Miner.new`; when it was, any exception the callback raises is swallowed so a buggy logger can't break a query. See `docs/logging.md` for the full posture and the CLI's `-v`/`--verbose` flag, which is implemented by installing a default `on_wire` callback.

## 2. Parallel pool request flow (`MinerPool#query`)

Expand Down
40 changes: 40 additions & 0 deletions script/validate_mermaid
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Lint every ```mermaid block under docs/ by feeding it to
# @mermaid-js/mermaid-cli via npx. Exits non-zero on any parse error.
#
# Requires: node >= 18 and npx on PATH. First run downloads Puppeteer
# + Chromium into ~/.npm/_npx (~300 MB, cached after).
set -euo pipefail

cd "$(dirname "$0")/.."

tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
echo '{"args":["--no-sandbox"]}' > "$tmp/puppeteer.json"

for md in docs/*.md; do
base=$(basename "$md" .md)
awk -v t="$tmp" -v b="$base" '
BEGIN {n = 0}
/^```mermaid$/ {capture = 1; n++; f = sprintf("%s/%s__%d.mmd", t, b, n); next}
/^```$/ && capture {capture = 0; next}
capture {print > f}
' "$md"
done

total=0
fail=0
for mmd in "$tmp"/*.mmd; do
[ -e "$mmd" ] || continue
total=$((total + 1))
if ! npx -y @mermaid-js/mermaid-cli -p "$tmp/puppeteer.json" \
-i "$mmd" -o "${mmd%.mmd}.svg" >/dev/null 2>&1; then
echo "FAIL: $(basename "$mmd")"
npx -y @mermaid-js/mermaid-cli -p "$tmp/puppeteer.json" \
-i "$mmd" -o /dev/null 2>&1 | grep -E "Parse|Lexical" | head -2 | sed 's/^/ /'
fail=$((fail + 1))
fi
done

echo "$((total - fail)) passed, $fail failed (of $total blocks)"
exit $fail
Loading