#![forbid(unsafe_code)]
pub mod agent;
mod cache;
mod cache_gc;
mod commands;
mod completions;
mod config;
mod doctor;
mod doctor_webhooks;
pub mod error;
pub mod fleet;
#[cfg_attr(not(unix), path = "hook_windows.rs")]
mod hook;
pub mod local_builds;
mod self_healing_overrides;
pub mod state;
mod status_display;
mod status_types;
mod toolchain;
#[cfg_attr(not(unix), path = "transfer_stub.rs")]
mod transfer;
pub mod tui;
pub mod ui;
mod update;
use anyhow::Result;
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::CompleteEnv;
use rch_common::{ApiError, ApiResponse, ErrorCode, LogConfig, init_logging};
use schemars::generate::SchemaSettings;
use std::env;
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::Arc;
use ui::{ColorChoice, OutputConfig, OutputContext, OutputFormat};
fn parse_exec_worker(value: &str) -> std::result::Result<rch_common::WorkerId, String> {
if value.is_empty()
|| value.contains(',')
|| value.chars().any(|c| c.is_whitespace() || c.is_control())
{
return Err(
"--worker requires one nonempty worker ID, without whitespace or commas".into(),
);
}
Ok(rch_common::WorkerId::new(value))
}
fn parse_required_tool(value: &str) -> std::result::Result<String, String> {
if value.is_empty()
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'+'))
{
return Err(
"--require-tool must name one declared tool using letters, digits, - _ . +".into(),
);
}
Ok(value.to_owned())
}
#[derive(Parser)]
#[command(name = "rch")]
#[command(
author,
version = rch_common::build_version_value_static(),
about = "Remote Compilation Helper - transparent compilation offloading",
long_about = "Remote Compilation Helper (RCH) transparently offloads compilation commands \
to remote workers. When invoked without a subcommand, RCH runs as a Claude Code \
PreToolUse hook, intercepting build commands and routing them to faster remote machines.",
after_help = r#"EXAMPLES:
# Quick start - install hook and start daemon
rch hook install && rch daemon start
# Check system status
rch status --workers --jobs
# Probe worker connectivity
rch workers probe --all
# Show where config values come from
rch config show --sources
# Test hook with a sample cargo build
rch hook test
# Generate shell completions
rch completions bash > ~/.local/share/bash-completion/completions/rch
HOOK MODE:
When invoked without arguments, RCH acts as a PreToolUse hook for Claude Code.
It reads JSON from stdin, decides whether to intercept the command, and writes
JSON to stdout. This is automatic when installed via 'rch hook install'.
ENVIRONMENT VARIABLES:
RCH_PROFILE Profile to use: dev, prod, test (sets defaults below)
RCH_LOG_LEVEL Logging level: trace, debug, info, warn, error, off
RCH_LOG_FORMAT Log format: pretty, json, compact
RCH_SOCKET_PATH Path to daemon Unix socket
RCH_DAEMON_TIMEOUT_MS Daemon socket connect/read timeout in ms, 100-600000 (default: 5000)
RCH_SSH_SERVER_ALIVE_INTERVAL_SECS SSH keepalive interval (ServerAliveInterval)
RCH_SSH_CONTROL_PERSIST_SECS SSH ControlPersist idle seconds (0 disables persistence)
RCH_COMPRESSION_LEVEL Compression level 1-22 (default: 3)
RCH_SYNC_TIMEOUT_MS Per-attempt source-sync timeout (default: payload-aware)
RCH_ENV_ALLOWLIST Comma-separated env vars to forward (e.g., RUSTFLAGS,CARGO_TARGET_DIR)
RCH_MIN_LOCAL_TIME_MS Minimum local runtime estimate required before offload
RCH_REMOTE_SPEEDUP_THRESHOLD Minimum predicted remote speedup ratio before offload
RCH_VISIBILITY Hook output visibility: none, summary, verbose
RCH_VERBOSE Convenience: sets visibility=verbose when true
RCH_QUIET Force visibility=none when true
RCH_OUTPUT_FORMAT Machine output format: json, toon (implies --json)
TOON_DEFAULT_FORMAT Default machine format when --json is set (json/toon)
RCH_MOCK_SSH Enable mock SSH for testing (set to 1)
RCH_TEST_MODE Enable test mode (set to 1)
RCH_ENABLE_METRICS Enable metrics collection (set to true)
CONFIG PRECEDENCE (highest to lowest):
1. Command-line arguments
2. Environment variables
3. Profile defaults (RCH_PROFILE)
4. .env / .rch.env files
5. Project config (.rch/config.toml)
6. User config (~/.config/rch/config.toml)
7. Built-in defaults
For more information, see: https://github.com/anthropics/rch"#
)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg(short, long, global = true)]
verbose: bool,
#[arg(short, long, global = true)]
quiet: bool,
#[arg(short = 'j', long, global = true, alias = "jason", alias = "jsno")]
json: bool,
#[arg(
short = 'F',
long,
global = true,
value_name = "format",
env = "RCH_OUTPUT_FORMAT",
value_parser = ["json", "toon"]
)]
format: Option<String>,
#[arg(long, global = true, default_value = "auto")]
color: String,
#[arg(long, global = true)]
no_color: bool,
#[arg(long, global = true)]
no_self_healing: bool,
#[arg(long, global = true)]
no_hook_auto_start: bool,
#[arg(long, global = true)]
robot_triage: bool,
#[arg(long, global = true)]
schema: bool,
#[arg(long, global = true)]
help_json: bool,
#[arg(long)]
capabilities: bool,
}
#[derive(Subcommand)]
enum Commands {
#[command(
alias = "setup",
after_help = r#"EXAMPLES:
rch init # Start interactive setup wizard
rch setup # Same as 'rch init' (alias)
rch init --yes # Accept all defaults without prompting
rch init --skip-test # Skip the test compilation step
The wizard will guide you through:
1. Detecting potential workers from SSH config
2. Selecting which hosts to use as workers
3. Probing hosts for connectivity
4. Deploying rch-wkr binary to workers
5. Synchronizing Rust toolchain
6. Starting the daemon
7. Installing the Claude Code hook
8. Running a test compilation
For more control, use the individual commands:
rch workers discover --add
rch workers setup --all
rch daemon start
rch hook install"#
)]
Init {
#[arg(long, short = 'y')]
yes: bool,
#[arg(long)]
skip_test: bool,
},
#[command(after_help = r#"EXAMPLES:
rch daemon start # Start the daemon in background
rch daemon status # Check if daemon is running
rch daemon logs -n 100 # View last 100 log lines
rch daemon restart # Restart after config changes"#)]
Daemon {
#[command(subcommand)]
action: DaemonAction,
},
#[command(after_help = r#"EXAMPLES:
rch workers list # Show all configured workers
rch workers probe --all # Test connectivity to all workers
rch workers probe css # Probe specific worker
rch workers benchmark # Run speed tests on all workers
rch workers drain css # Stop sending jobs to worker
rch workers enable css # Resume sending jobs to worker
rch workers disable css # Take worker offline
WORKER STATES:
HEALTHY Normal operation - accepting and running jobs
DRAINING Not accepting new jobs, finishing current ones
DRAINED Idle after drain completes (no active jobs)
DISABLED Completely offline, skipped by job assignment
STATE TRANSITIONS:
drain: HEALTHY -> DRAINING -> DRAINED (automatic when jobs finish)
disable: DRAINED -> DISABLED (or use --drain flag from any state)
enable: DRAINING/DRAINED/DISABLED -> HEALTHY
Use 'drain' to gracefully stop a worker before maintenance.
Use 'enable' to bring a drained/disabled worker back online.
Use 'disable' to mark a worker as unavailable (optionally with --reason)."#)]
Workers {
#[command(subcommand)]
action: WorkersAction,
},
#[command(after_help = r#"EXAMPLES:
rch status # Quick overview
rch status --workers # Include worker details
rch status --jobs # Show active compilations
rch status --workers --jobs # Full status report"#)]
Status {
#[arg(short = 'w', long)]
workers: bool,
#[arg(short = 'J', long)]
jobs: bool,
#[arg(long)]
fleet: bool,
#[arg(long)]
remediation: bool,
},
#[command(after_help = r#"EXAMPLES:
rch check # Quick status check
rch check --verbose # Detailed health report
rch check --json # Machine-readable output
EXIT CODES:
0 Ready - daemon running, hook installed, all workers healthy
1 Degraded - daemon running, some workers unreachable
2 Not ready - daemon/hook missing or fatal issues
USAGE:
# CI/CD health gate
rch check || exit 1
# Monitoring integration
rch check --json | jq '.data.status'
# Quick troubleshooting
rch check --verbose"#)]
Check,
Jobs {
#[command(subcommand)]
action: Option<commands::jobs::JobsAction>,
},
#[command(after_help = r#"EXAMPLES:
rch queue # Show active builds and queue
rch queue --watch # Watch queue in real-time (updates every second)
rch queue --json # Output as JSON for scripting
The queue shows:
- Currently running builds with worker, elapsed time
- Queued builds waiting for workers
- Worker availability summary"#)]
Queue {
#[arg(long, short = 'w')]
watch: bool,
#[arg(long, short = 'f')]
follow: bool,
},
#[command(after_help = r#"EXAMPLES:
rch cancel 42 # Cancel build with ID 42
rch cancel --all # Cancel all active builds (with confirmation)
rch cancel --all --yes # Cancel all without confirmation
rch cancel 42 --force # Force kill (SIGKILL instead of SIGTERM)
Graceful cancellation sends SIGTERM to the build process, allowing it
to clean up. Use --force to immediately terminate with SIGKILL."#)]
Cancel {
build_id: Option<u64>,
#[arg(short = 'a', long)]
all: bool,
#[arg(long, short = 'f')]
force: bool,
#[arg(long, short = 'y')]
yes: bool,
#[arg(long, short = 'n')]
dry_run: bool,
},
#[command(after_help = r#"EXAMPLES:
rch sync --project . # Preview: what force-resync would invalidate
rch sync --force --worker css # Invalidate css's RCH cache + trigger resync
rch sync --force --all # Force-resync every configured worker
rch sync --force --all --dry-run # Preview the apply plan, take no action
rch sync --force --worker css --json # Machine-readable report
Force-resync invalidates the RCH-managed worker cache (under transfer.remote_base)
for the target project and its path-dependency closure, then triggers a daemon
convergence repair so the closure re-syncs on the next build. It NEVER deletes
anything outside the RCH-managed base: canonical source mirrors are refused, not
wiped. Without --force (or with --dry-run) it only previews the plan."#)]
Sync {
#[arg(long)]
force: bool,
#[arg(long, short = 'w')]
worker: Option<String>,
#[arg(long, short = 'a')]
all: bool,
#[arg(long, short = 'p')]
project: Option<PathBuf>,
#[arg(long, short = 'n')]
dry_run: bool,
},
#[command(after_help = r#"EXAMPLES:
rch config show # Display effective config
rch config show --sources # Show where each value comes from
rch config init # Create project .rch/config.toml
rch config validate # Check for config errors
rch config set log_level debug # Update a setting
rch config export --format=env # Export as .env format"#)]
Config {
#[command(subcommand)]
action: ConfigAction,
},
#[command(after_help = r#"EXAMPLES:
rch cache warm # Warm cache on all healthy workers
rch cache warm --workers css # Warm cache on specific worker
rch cache warm --workers css --workers vmi1 # Warm cache on multiple workers
rch cache warm --project /path/to/project # Warm cache for a non-cwd project
rch cache warm --json # Emit per-worker results as JSON"#)]
Cache {
#[command(subcommand)]
action: CacheAction,
},
#[command(after_help = r#"EXAMPLES:
rch gc # Preview on every worker (default)
rch gc --workers hz2 --json # Preview one worker, JSON verdicts
rch gc --root /mnt/big/rch # Also scan an extra root
rch gc --apply # Actually collect what the preview showed
rch gc --apply --workers hz2 # Collect on one worker"#)]
Gc {
#[arg(long, short = 'n', conflicts_with = "apply")]
dry_run: bool,
#[arg(long)]
apply: bool,
#[arg(long, value_name = "WORKER_ID")]
workers: Vec<String>,
#[arg(long = "root", value_name = "PATH")]
roots: Vec<String>,
#[arg(long, default_value_t = 900, value_parser = clap::value_parser!(u64).range(1..))]
worker_timeout: u64,
},
#[command(after_help = r#"EXAMPLES:
rch diagnose "cargo build --release"
rch diagnose cargo build --release
rch diagnose "bun test"
rch diagnose "ls -la"
rch diagnose --dry-run "cargo build" # Show full pipeline without side effects"#)]
Diagnose {
#[arg(required = true, num_args = 1.., trailing_var_arg = true)]
command: Vec<String>,
#[arg(long, short = 'n')]
dry_run: bool,
},
#[command(after_help = r#"EXAMPLES:
rch why miss --prior prior.json --current current.json
cat current.json | rch why miss --prior prior.json --current -
rch why refusal --outcome first-seen
rch why refusal --outcome trust-refused"#)]
Why {
#[command(subcommand)]
action: commands::why::WhyAction,
},
#[command(after_help = r#"EXAMPLES:
rch rabs gc plan
rch rabs gc run --mode emergency --protect sha256:deadbeef
rch rabs gc history --limit 20 --json"#)]
Rabs {
#[command(subcommand)]
action: commands::rabs_gc::RabsCommand,
},
Admit {
#[arg(long)]
job: bool,
#[arg(long, value_name = "NAME", value_parser = parse_required_tool)]
require_tool: Vec<String>,
#[arg(required = true, num_args = 1.., trailing_var_arg = true)]
command: Vec<String>,
},
#[command(after_help = r#"EXAMPLES:
rch exec -- cargo build --release
rch exec -- cargo test
rch exec -- bun test
rch exec --base HEAD --clean-overlay --overlay-path src/lib.rs -- cargo test
rch exec --base HEAD --clean-overlay --no-overlay -- cargo check
rch exec --job -- sharded_tests/run.sh
rch exec --job --result-dir fuzz/corpus --result-dir crashes -- ./fuzz_target.sh
USAGE:
This command is primarily used internally by the PreToolUse hook.
The hook intercepts compilation commands and rewrites them as:
cargo build --release → rch exec -- cargo build --release
This allows the hook to return immediately (<50ms) while the actual
compilation runs as a normal command invocation."#)]
Exec {
#[arg(long, short = 'b', requires = "clean_overlay")]
base: Option<String>,
#[arg(long, requires = "clean_overlay", value_name = "PATH=REV")]
dependency_base: Vec<String>,
#[arg(long, requires = "base")]
clean_overlay: bool,
#[arg(
long,
short = 'o',
requires = "clean_overlay",
conflicts_with = "no_overlay"
)]
overlay_path: Vec<PathBuf>,
#[arg(long, requires = "clean_overlay", conflicts_with = "overlay_path")]
no_overlay: bool,
#[arg(long, conflicts_with = "clean_overlay")]
source_content_receipt: bool,
#[arg(
long,
conflicts_with_all = ["clean_overlay", "source_content_receipt"]
)]
job: bool,
#[arg(long, value_name = "DIR", requires = "job")]
result_dir: Vec<PathBuf>,
#[arg(long, value_name = "NAME", value_parser = parse_required_tool)]
require_tool: Vec<String>,
#[arg(long, value_name = "ID", value_parser = parse_exec_worker)]
worker: Option<rch_common::WorkerId>,
#[arg(required = true, num_args = 1.., trailing_var_arg = true)]
command: Vec<String>,
},
#[command(after_help = r#"EXAMPLES:
rch hook install # Register RCH as PreToolUse hook
rch hook uninstall # Remove the hook
rch hook test # Test with a sample 'cargo build' command
The hook intercepts Bash tool calls and transparently offloads
compilation commands to remote workers."#)]
Hook {
#[command(subcommand)]
action: HookAction,
},
#[command(after_help = r#"EXAMPLES:
rch agents list # Show detected agents
rch agents list --all # Include non-installed agents
rch agents status # Check hook status for all agents
rch agents status claude-code # Check specific agent
rch agents install-hook gemini-cli --dry-run # Preview hook install"#)]
Agents {
#[command(subcommand)]
action: AgentsAction,
},
#[command(after_help = r#"EXAMPLES:
rch shim install # Install the cargo offload shim (fail-closed)
rch shim install --allow-local-fallback # Offload but allow local under load
rch shim status # Check install, version, PATH order, local builds
rch shim uninstall # Remove the shim
The Claude Code hook only covers Claude Code. Codex, plain shells, scripts, and
CI invoke `cargo` directly with no hook to catch them, so their builds compile
locally on this box. The shim sits on PATH ahead of ~/.cargo/bin and routes
offloadable cargo subcommands through `rch exec`. It is loop-safe, fails open if
rch is unavailable, and leaves rust-analyzer (`--message-format`) builds local.
Local fallbacks (RCH_SHIM_LOCAL_IDE=1, wrapper bypass, IDE diagnostics) resolve
a WORKING real cargo in tiers, first executable wins:
1. $RCH_REAL_CARGO explicit operator override
2. $RCH_SHIM_REAL_CARGO set by the rch toolchain wrapper
3. <active-toolchain>/bin/cargo-rch-real active toolchain discovered via
`rustc --print sysroot` (honors
RUSTUP_TOOLCHAIN / rust-toolchain.toml)
4. ~/.cargo/bin/cargo the stock rustup proxy
Tier 3 is what saves a host whose toolchain cargo was renamed to cargo-rch-real:
the rustup proxy cannot dispatch there ("cargo component not applicable"), which
is what broke RCH_SHIM_LOCAL_IDE=1 before shim v4. A resolved cargo-rch-real
gets its bin dir prepended to PATH so the build's rustc/clippy-driver come from
the same toolchain (repo -Z flags need it). If no tier succeeds the shim exits
127 naming RCH_REAL_CARGO and `rch shim uninstall` as the fixes.
Install ONLY on dispatcher boxes (that offload OUT). NEVER on a worker box: a
worker runs cargo via rch-wkr to execute offloaded builds, and the shim would
re-offload/loop."#)]
Shim {
#[command(subcommand)]
action: ShimAction,
},
#[command(after_help = r#"EXAMPLES:
# Generate completions to stdout
rch completions generate bash > ~/.local/share/bash-completion/completions/rch
# Install completions automatically (recommended)
rch completions install bash
rch completions install zsh
rch completions install fish
# Install for current shell (auto-detected)
rch completions install
# Check installation status
rch completions status
# Uninstall completions
rch completions uninstall bash
INSTALL LOCATIONS:
Bash: ~/.local/share/bash-completion/completions/rch
Zsh: ~/.zfunc/_rch (adds fpath to .zshrc)
Fish: ~/.config/fish/completions/rch.fish
PowerShell: ~/.config/powershell/rch.ps1"#)]
Completions {
#[command(subcommand)]
action: CompletionsAction,
},
Error {
#[command(subcommand)]
sub: ErrorSubcommand,
},
#[command(after_help = r#"EXAMPLES:
rch doctor # Run all diagnostic checks
rch doctor --fix # Attempt to fix safe issues
rch doctor --fix --dry-run # Show what would be fixed
rch doctor --reliability # Inspect fleet reliability and remediation posture
rch doctor --reliability --check-schemas --json
rch doctor -v # Show detailed output
rch doctor --json # Output as JSON for scripting
CHECKS PERFORMED:
Prerequisites - rsync, zstd, ssh, rustup, cargo
Configuration - config.toml, workers.toml validity
SSH Keys - Identity files exist with correct permissions
Daemon - Socket exists and responds
Hooks - Claude Code hook installed
Workers - Connectivity (with --verbose)
Reliability - topology, repo convergence, disk pressure, process debt"#)]
Doctor {
#[arg(long)]
fix: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
reliability: bool,
#[arg(long, requires = "reliability")]
check_schemas: bool,
#[arg(long, requires = "reliability", conflicts_with = "lenient")]
strict: bool,
#[arg(long, requires = "reliability", conflicts_with = "strict")]
lenient: bool,
#[arg(
long,
requires = "reliability",
default_value = "all",
value_name = "SCOPES"
)]
scope: String,
#[arg(long, requires = "reliability", conflicts_with = "fix")]
watch: bool,
#[arg(long, requires = "watch", default_value = "5", value_name = "SECONDS")]
watch_interval: u64,
#[arg(long, requires = "watch")]
transitions_only: bool,
#[arg(long, requires = "watch", value_name = "PATH")]
watch_snapshot: Option<PathBuf>,
#[arg(long, value_name = "CODE", conflicts_with_all = ["fix", "watch"])]
runbook: Option<String>,
#[arg(
long = "runbook-list",
conflicts_with_all = ["fix", "watch", "runbook"]
)]
runbook_list: bool,
},
#[command(after_help = r#"EXAMPLES:
rch self-test # Test the first configured worker
rch self-test --all # Test all configured workers
rch self-test --worker css # Test a specific worker
rch self-test --project ../app # Test a different project directory
rch self-test --timeout 600 # Increase timeout to 10 minutes
rch self-test --debug # Use debug build instead of release
rch self-test status # Show schedule and last run
rch self-test history --limit 10 # Show recent runs"#)]
SelfTest {
#[command(subcommand)]
action: Option<SelfTestAction>,
#[arg(long)]
worker: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
project: Option<PathBuf>,
#[arg(long, default_value = "300")]
timeout: u64,
#[arg(long)]
debug: bool,
#[arg(long)]
scheduled: bool,
#[arg(long)]
smoke: bool,
#[arg(long)]
soak: bool,
#[arg(long)]
load: bool,
#[arg(long)]
dry_run: bool,
},
#[command(
visible_alias = "upgrade",
after_help = r#"EXAMPLES:
rch update --check # Check for available updates
rch upgrade --check # Same as 'rch update --check'
rch update # Update to latest stable
rch update --channel=beta # Update to beta channel
rch update --version=v0.3.0 # Install specific version
rch update --fleet # Update all workers too
rch update --dry-run # Preview what would happen
rch update --rollback # Restore previous version
rch update --verify # Check installation integrity
rch update --skip-verify # Skip checksum verification (dangerous)"#
)]
Update {
#[arg(long)]
check: bool,
#[arg(long)]
version: Option<String>,
#[arg(long, default_value = "stable")]
channel: String,
#[arg(long)]
fleet: bool,
#[arg(long)]
rollback: bool,
#[arg(long)]
verify: bool,
#[arg(long)]
skip_verify: bool,
#[arg(long, short = 'y')]
yes: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
no_restart: bool,
#[arg(long, default_value = "60")]
drain_timeout: u64,
#[arg(long)]
show_changelog: bool,
},
#[command(after_help = r#"EXAMPLES:
rch fleet deploy # Deploy to all workers
rch fleet deploy --canary 25 # Canary deployment to 25%
rch fleet rollback # Rollback to previous version
rch fleet status # Show deployment status
rch fleet verify # Verify installations
rch fleet history # Show deployment history
Fleet management provides centralized deployment, rollback, and
monitoring capabilities for the rch-wkr worker agent across all
configured remote workers."#)]
Fleet {
#[command(subcommand)]
action: FleetAction,
},
#[command(
name = "speedscore",
after_help = r#"EXAMPLES:
rch speedscore css # Show SpeedScore for worker 'css'
rch speedscore css --verbose # Show detailed component breakdown
rch speedscore css --history # Show score history
rch speedscore css --history --days 7 # Last 7 days of history
rch speedscore --all # Show SpeedScores for all workers
SpeedScore is a composite performance metric (0-100) combining:
- CPU performance (30%)
- Memory efficiency (15%)
- Disk I/O (20%)
- Network latency (15%)
- Compilation speed (20%)
Ratings:
90+ Excellent | 75+ Very Good | 60+ Good | 45+ Average | 30+ Below Average"#
)]
SpeedScore {
worker: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
history: bool,
#[arg(long, default_value = "30")]
days: u32,
#[arg(long, default_value = "20")]
limit: usize,
},
#[command(
alias = "tui",
after_help = r#"EXAMPLES:
rch dashboard # Launch TUI dashboard
rch dashboard --refresh 500 # 500ms refresh rate
rch dashboard --no-mouse # Disable mouse support
rch dashboard --high-contrast # High contrast mode
rch dashboard --color-blind tritanopia # Color blind palette
rch dashboard --test-mode # Render once and exit (CI-friendly)
rch dashboard --mock-data # Use deterministic mock data (no daemon required)
rch dashboard --dump-state # Print JSON state and exit (automation)
The dashboard provides real-time monitoring of:
- Worker status and slot utilization
- Active build progress
- Build history with filtering
- Log tail view for active builds
Controls:
q/Esc - Quit
↑/↓ - Navigate
Tab - Switch panels
r - Refresh data
? - Help"#
)]
Dashboard {
#[arg(long, default_value = "1000", alias = "refresh-ms")]
refresh: u64,
#[arg(long)]
no_mouse: bool,
#[arg(long)]
test_mode: bool,
#[arg(long)]
mock_data: bool,
#[arg(long)]
dump_state: bool,
#[arg(long)]
high_contrast: bool,
#[arg(long, value_enum, default_value = "none")]
color_blind: tui::ColorBlindMode,
},
#[command(after_help = r#"EXAMPLES:
rch web # Open the configured dashboard URL
rch web --no-open # Just print the URL (and the agent endpoint)
rch web --url https://x.vercel.app
rch config set dashboard.url https://rch-fleet.vercel.app
The fleet dashboard is the encrypted static console under dashboard/ (see
dashboard/README.md). URL resolution: --url, then RCH_DASHBOARD_URL, then
[dashboard] url in config.toml. Agents should use <url>/api/fleet?view=help."#)]
Web {
#[arg(long)]
url: Option<String>,
#[arg(long)]
no_open: bool,
},
#[command(after_help = r#"EXAMPLES:
rch capabilities --json # Stable agent-readable capability envelope
rch capabilities --format toon # Same data in TOON format
rch --capabilities # Legacy raw JSON capability flag
The capabilities report includes command names, aliases, output formats,
environment variables, exit codes, and recommended agent entry points."#)]
Capabilities,
#[command(
name = "robot-docs",
after_help = r#"EXAMPLES:
rch robot-docs guide # Paste-ready agent handbook
rch robot-docs guide --json # Same guide in the API response envelope
Use this when an agent needs the operating contract without opening README.md."#
)]
RobotDocs {
#[command(subcommand)]
action: RobotDocsAction,
},
#[command(after_help = r#"EXAMPLES:
rch schema export # Export to docs/api/schemas/
rch schema export --output ./schemas # Custom output directory
rch schema export --json # Output summary as JSON
rch schema list # List available schemas
The schema command generates machine-readable API documentation:
- api-response.schema.json JSON Schema for API response envelope
- api-error.schema.json JSON Schema for error structure
- error-codes.json Complete error code catalog
These files enable agents and tooling to validate RCH output
and understand error codes programmatically."#)]
Schema {
#[command(subcommand)]
action: SchemaAction,
},
}
#[derive(Subcommand)]
enum SchemaAction {
Export {
#[arg(long, short = 'o')]
output: Option<PathBuf>,
},
List,
}
#[derive(Subcommand)]
enum ErrorSubcommand {
Explain {
code: String,
#[arg(long)]
json: bool,
},
List {
#[arg(long)]
category: Option<String>,
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand)]
enum RobotDocsAction {
Guide,
}
#[derive(Subcommand)]
enum SelfTestAction {
Status,
History {
#[arg(long, default_value = "10")]
limit: usize,
},
}
#[derive(Subcommand)]
enum DaemonAction {
Start,
Stop {
#[arg(short = 'y', long)]
yes: bool,
#[arg(long)]
drain: bool,
#[arg(long, default_value = "300", requires = "drain")]
drain_timeout: u64,
#[arg(long)]
force: bool,
},
Restart {
#[arg(short = 'y', long)]
yes: bool,
#[arg(long)]
drain: bool,
#[arg(long, default_value = "300", requires = "drain")]
drain_timeout: u64,
#[arg(long)]
force: bool,
},
Status,
Logs {
#[arg(short = 'n', long, default_value = "50")]
lines: usize,
},
Reload,
}
impl DaemonAction {
fn as_str(&self) -> &'static str {
match self {
DaemonAction::Start => "start",
DaemonAction::Stop { .. } => "stop",
DaemonAction::Restart { .. } => "restart",
DaemonAction::Status => "status",
DaemonAction::Logs { .. } => "logs",
DaemonAction::Reload => "reload",
}
}
}
#[derive(Subcommand)]
enum WorkersAction {
List {
#[arg(long)]
speedscore: bool,
},
Capabilities {
#[arg(long)]
refresh: bool,
#[arg(long)]
command: Option<String>,
},
Probe {
worker: Option<String>,
#[arg(short = 'a', long)]
all: bool,
},
#[command(after_help = r#"EXAMPLES:
rch workers benchmark # Benchmark every configured worker
rch workers benchmark css # Benchmark one specific worker
rch workers benchmark --all # Equivalent to no worker id (explicit form)
rch workers benchmark css --force # Re-run even if recently benchmarked"#)]
Benchmark {
#[arg(value_name = "WORKER_ID", conflicts_with = "all")]
worker_id: Option<String>,
#[arg(long, conflicts_with = "worker_id")]
all: bool,
#[arg(long)]
force: bool,
},
#[command(after_help = r#"EXAMPLES:
rch workers compare css dlx # Compare two workers
rch workers compare css dlx vmi1 # Compare three+
rch workers compare css dlx --json # Machine-readable response"#)]
Compare {
#[arg(value_name = "WORKER_ID", num_args = 2..)]
worker_ids: Vec<String>,
},
#[command(after_help = r#"EXAMPLES:
rch workers drain css # Drain worker 'css' (completes active builds)
The worker will transition: HEALTHY → DRAINING → DRAINED (when jobs finish)
Use 'rch workers list' to monitor the drain progress."#)]
Drain {
worker: String,
#[arg(short = 'y', long)]
yes: bool,
},
#[command(after_help = r#"EXAMPLES:
rch workers enable css # Resume normal operation for worker 'css'
The worker will transition: DRAINING/DRAINED/DISABLED → HEALTHY"#)]
Enable {
worker: String,
},
#[command(after_help = r#"EXAMPLES:
rch workers disable css # Disable immediately
rch workers disable css --reason "hardware upgrade"
rch workers disable css --drain # Finish builds first
Use 'rch workers enable css' to bring the worker back online."#)]
Disable {
worker: String,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
drain: bool,
#[arg(short = 'y', long)]
yes: bool,
},
DeployBinary {
worker: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
force: bool,
#[arg(long)]
dry_run: bool,
},
#[command(after_help = r#"EXAMPLES:
rch workers discover # List discovered hosts
rch workers discover --probe # Probe discovered hosts for connectivity
rch workers discover --add # Add discovered hosts to workers.toml"#)]
Discover {
#[arg(long)]
probe: bool,
#[arg(long)]
add: bool,
#[arg(long)]
yes: bool,
},
#[command(after_help = r#"EXAMPLES:
rch workers sync-toolchain css # Sync toolchain to specific worker
rch workers sync-toolchain --all # Sync toolchain to all workers
rch workers sync-toolchain --all --dry-run # Preview what would happen"#)]
SyncToolchain {
worker: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
dry_run: bool,
},
#[command(after_help = r#"EXAMPLES:
rch workers setup css # Full setup for specific worker
rch workers setup --all # Setup all workers
rch workers setup --all --dry-run # Preview what would happen"#)]
Setup {
worker: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
skip_binary: bool,
#[arg(long)]
skip_toolchain: bool,
},
#[command(after_help = r#"EXAMPLES:
rch workers init # Interactive wizard to add a worker
rch workers init --yes # Accept all detected defaults
This wizard will guide you through adding a worker:
1. Enter hostname/IP and SSH credentials
2. Test SSH connection
3. Auto-detect CPU cores
4. Auto-detect Rust toolchain
5. Save to workers.toml"#)]
Init {
#[arg(long, short = 'y')]
yes: bool,
},
}
impl WorkersAction {
fn as_str(&self) -> &'static str {
match self {
WorkersAction::List { .. } => "list",
WorkersAction::Capabilities { .. } => "capabilities",
WorkersAction::Probe { .. } => "probe",
WorkersAction::Benchmark { .. } => "benchmark",
WorkersAction::Compare { .. } => "compare",
WorkersAction::Drain { .. } => "drain",
WorkersAction::Enable { .. } => "enable",
WorkersAction::Disable { .. } => "disable",
WorkersAction::DeployBinary { .. } => "deploy-binary",
WorkersAction::Discover { .. } => "discover",
WorkersAction::SyncToolchain { .. } => "sync-toolchain",
WorkersAction::Setup { .. } => "setup",
WorkersAction::Init { .. } => "init",
}
}
}
#[derive(Subcommand)]
enum CacheAction {
Warm {
#[arg(long, value_name = "WORKER_ID")]
workers: Vec<String>,
#[arg(long, value_name = "PATH")]
project: Option<PathBuf>,
},
Clean {
#[arg(long, value_name = "DURATION", default_value = "24h")]
older: String,
#[arg(long, value_name = "NAME")]
project: Option<String>,
#[arg(long)]
execute: bool,
#[arg(long, value_name = "PATH")]
base: Vec<PathBuf>,
},
Status {
#[arg(long, value_name = "WORKER_ID")]
workers: Vec<String>,
},
}
#[derive(Subcommand)]
enum ConfigAction {
Show {
#[arg(long)]
sources: bool,
},
Get {
key: String,
#[arg(long)]
sources: bool,
},
#[command(after_help = r#"EXAMPLES:
rch config init # Create config files with defaults
rch config init --wizard # Interactive wizard with prompts
rch config init --non-interactive # Generate full config without prompts
The wizard helps you configure:
• General settings (log level, socket path)
• Compilation thresholds (confidence, min local time)
• Transfer settings (compression, exclude patterns)
• Worker definitions (host, user, identity file, slots)"#)]
Init {
#[arg(long)]
wizard: bool,
#[arg(long)]
non_interactive: bool,
},
Validate,
Set { key: String, value: String },
Reset { key: String },
Export {
#[arg(long, default_value = "shell")]
format: String,
},
#[command(after_help = r#"EXAMPLES:
rch config lint # Check for issues with warnings/errors
rch config lint --json # Machine-readable output for CI
Checks for:
• Missing workers configuration
• Conflicting settings (force_local + force_remote)
• Invalid regex patterns in excludes
• Risky exclude patterns (removing essential directories)
• Performance warnings (compression=0 with large projects)"#)]
Lint,
#[command(after_help = r#"EXAMPLES:
rch config doctor # Run all health checks
rch config doctor --json # Machine-readable output for CI
Checks for:
• Socket path is writable (daemon can start)
• SSH identity files exist and are readable
• Remote paths are absolute
• Glob patterns are valid syntax
• File permissions are appropriate"#)]
Doctor,
#[command(after_help = r#"EXAMPLES:
rch config edit # Edit user config (~/.config/rch/config.toml)
rch config edit --project # Edit project config (.rch/config.toml)
rch config edit --workers # Edit workers config (~/.config/rch/workers.toml)
Opens the specified configuration file in $EDITOR (or $VISUAL).
Falls back to 'nano' if neither is set."#)]
Edit {
#[arg(long, conflicts_with_all = ["user", "workers"])]
project: bool,
#[arg(long, conflicts_with_all = ["project", "workers"])]
user: bool,
#[arg(long, conflicts_with_all = ["project", "user"])]
workers: bool,
},
#[command(after_help = r#"EXAMPLES:
rch config diff # Show all non-default values
rch config diff --json # Machine-readable output
Shows:
• Key name
• Current value
• Default value
• Source (env, project, user)"#)]
Diff,
}
impl ConfigAction {
fn as_str(&self) -> &'static str {
match self {
ConfigAction::Show { .. } => "show",
ConfigAction::Get { .. } => "get",
ConfigAction::Init { .. } => "init",
ConfigAction::Validate => "validate",
ConfigAction::Set { .. } => "set",
ConfigAction::Reset { .. } => "reset",
ConfigAction::Export { .. } => "export",
ConfigAction::Lint => "lint",
ConfigAction::Doctor => "doctor",
ConfigAction::Edit { .. } => "edit",
ConfigAction::Diff => "diff",
}
}
}
#[derive(Subcommand)]
enum HookAction {
Install,
Uninstall {
#[arg(short = 'y', long)]
yes: bool,
},
Test,
Status,
}
impl HookAction {
fn as_str(&self) -> &'static str {
match self {
HookAction::Install => "install",
HookAction::Uninstall { .. } => "uninstall",
HookAction::Test => "test",
HookAction::Status => "status",
}
}
}
#[derive(Subcommand)]
enum ShimAction {
Install {
#[arg(long)]
allow_local_fallback: bool,
#[arg(long)]
no_toolchains: bool,
},
Status,
Uninstall,
}
#[derive(Subcommand)]
enum AgentsAction {
List {
#[arg(long)]
all: bool,
},
Status {
agent: Option<String>,
},
InstallHook {
agent: String,
#[arg(long)]
dry_run: bool,
},
UninstallHook {
agent: String,
#[arg(long)]
dry_run: bool,
},
}
#[derive(Subcommand)]
enum CompletionsAction {
Generate {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
Install {
#[arg(value_enum)]
shell: Option<clap_complete::Shell>,
#[arg(long)]
dry_run: bool,
},
Uninstall {
#[arg(value_enum)]
shell: clap_complete::Shell,
#[arg(long)]
dry_run: bool,
},
Status,
}
#[derive(Subcommand)]
enum FleetAction {
#[command(after_help = r#"EXAMPLES:
rch fleet deploy # Deploy to all workers
rch fleet deploy --worker css # Deploy to specific worker
rch fleet deploy --canary 25 # Deploy to 25% first, then all
rch fleet deploy --parallel 4 # Max 4 concurrent deployments
rch fleet deploy --dry-run # Preview deployment plan
rch fleet deploy --verify # Verify after deployment
rch fleet deploy --drain-first # Drain builds before deploy"#)]
Deploy {
#[arg(long)]
worker: Option<String>,
#[arg(long, default_value = "4")]
parallel: usize,
#[arg(long)]
canary: Option<u8>,
#[arg(long, default_value = "60")]
canary_wait: u64,
#[arg(long)]
no_toolchain: bool,
#[arg(long)]
force: bool,
#[arg(long)]
verify: bool,
#[arg(long)]
drain_first: bool,
#[arg(long, default_value = "120")]
drain_timeout: u64,
#[arg(long)]
dry_run: bool,
#[arg(long)]
resume: bool,
#[arg(long)]
version: Option<String>,
#[arg(long)]
audit_log: Option<PathBuf>,
#[arg(short = 'y', long)]
yes: bool,
},
#[command(after_help = r#"EXAMPLES:
rch fleet rollback # Rollback all to previous version
rch fleet rollback --worker css # Rollback specific worker
rch fleet rollback --to-version v0.1.0 # Rollback to specific version
rch fleet rollback --dry-run # Preview rollback plan"#)]
Rollback {
#[arg(long)]
worker: Option<String>,
#[arg(long)]
to_version: Option<String>,
#[arg(long, default_value = "4")]
parallel: usize,
#[arg(long)]
verify: bool,
#[arg(long)]
dry_run: bool,
#[arg(short = 'y', long)]
yes: bool,
},
#[command(after_help = r#"EXAMPLES:
rch fleet status # Quick overview
rch fleet status --worker css # Show specific worker
rch fleet status --watch # Continuous update"#)]
Status {
#[arg(long)]
worker: Option<String>,
#[arg(long)]
watch: bool,
},
#[command(after_help = r#"EXAMPLES:
rch fleet verify # Verify all workers
rch fleet verify --worker css # Verify specific worker"#)]
Verify {
#[arg(long)]
worker: Option<String>,
},
#[command(after_help = r#"EXAMPLES:
rch fleet drain css # Drain specific worker
rch fleet drain --all # Drain all workers
rch fleet drain css --timeout 300 # Custom drain timeout"#)]
Drain {
worker: Option<String>,
#[arg(long)]
all: bool,
#[arg(long, default_value = "120")]
timeout: u64,
#[arg(short = 'y', long)]
yes: bool,
},
#[command(after_help = r#"EXAMPLES:
rch fleet history # Show last 10 deployments
rch fleet history --limit 20 # Show more entries
rch fleet history --worker css # Filter by worker"#)]
History {
#[arg(long, default_value = "10")]
limit: usize,
#[arg(long)]
worker: Option<String>,
},
#[command(after_help = r#"EXAMPLES:
rch fleet doctor --reliability # Fleet-wide health (worst verdict wins)
rch fleet doctor --reliability --json # Machine-readable envelope
rch fleet doctor --reliability --scope pressure # Narrow check across all workers
rch fleet doctor --reliability --workers css,bil # Only these workers
rch fleet doctor --reliability --fix --fleet-confirm # Apply fixes fleet-wide"#)]
Doctor {
#[arg(long)]
reliability: bool,
#[arg(long, value_delimiter = ',')]
scope: Vec<String>,
#[arg(long)]
fix: bool,
#[arg(long)]
fleet_confirm: bool,
#[arg(long)]
continue_on_failure: bool,
#[arg(long)]
workers: Option<String>,
#[arg(long, default_value = "10")]
worker_timeout: u64,
},
}
fn machine_output_requested(format: Option<&str>, json_flag: bool) -> bool {
machine_output_requested_with(format, json_flag, env::var("RCH_JSON").ok().as_deref())
}
fn machine_output_requested_with(
format: Option<&str>,
json_flag: bool,
rch_json: Option<&str>,
) -> bool {
json_flag || format.is_some() || rch_json.is_some_and(rch_common::placement::env_truthy)
}
fn resolve_output_format(format: Option<&str>, json_flag: bool) -> OutputFormat {
if let Some(raw) = format
&& let Some(parsed) = OutputFormat::parse(raw)
{
return parsed;
}
if json_flag
&& let Ok(value) = env::var("TOON_DEFAULT_FORMAT")
&& let Some(parsed) = OutputFormat::parse(&value)
{
return parsed;
}
OutputFormat::Json
}
fn base_log_level_for_cli(cli: &Cli) -> String {
if cli.command.is_none() {
"info".to_string()
} else {
match config::load_config() {
Ok(cfg) => cfg.general.log_level,
Err(_) => "info".to_string(),
}
}
}
fn reliability_metrics_requested(cli: &Cli) -> bool {
!cli.schema
&& !cli.robot_triage
&& matches!(
cli.command,
Some(Commands::Doctor {
reliability: true,
watch: false,
runbook: None,
runbook_list: false,
..
})
)
}
fn main() {
if hook::try_fast_passthrough() {
return;
}
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build the tokio runtime")
.block_on(async_main());
}
#[derive(Debug, thiserror::Error)]
#[error("storm-control invariants failed: {held}/{total} held")]
struct LoadStormReportFailure {
held: usize,
total: usize,
}
fn load_storm_report_delivered(error: &anyhow::Error) -> bool {
error.downcast_ref::<LoadStormReportFailure>().is_some()
|| error
.chain()
.any(|cause| cause.downcast_ref::<LoadStormReportFailure>().is_some())
}
fn output_delivery_failed(error: &anyhow::Error) -> bool {
error
.downcast_ref::<commands::jobs::JobOutputFailure>()
.is_some()
|| matches!(
error.downcast_ref::<ui::context::OutputError>(),
Some(ui::context::OutputError::Delivery(_))
)
|| error.chain().any(|cause| {
cause
.downcast_ref::<commands::jobs::JobOutputFailure>()
.is_some()
|| matches!(
cause.downcast_ref::<ui::context::OutputError>(),
Some(ui::context::OutputError::Delivery(_))
)
})
}
async fn async_main() {
use std::io::Write;
let args: Vec<OsString> = env::args_os().collect();
let wants_machine_output = top_level_machine_output_requested(&args);
if let Err(error) = run(args).await {
if let Some(exit) = error.downcast_ref::<doctor::DoctorExit>() {
std::process::exit(exit.0);
}
if output_delivery_failed(&error) || load_storm_report_delivered(&error) {
let _ = writeln!(std::io::stderr().lock(), "Error: {error:#}");
std::process::exit(1);
}
if let Some(failure) = error.downcast_ref::<GcFailure>() {
if wants_machine_output {
let ctx = OutputContext::new(OutputConfig {
json: true,
format: failure.format,
..Default::default()
});
if let Err(render_error) = ctx.json(&failure.response()) {
let _ = writeln!(
std::io::stderr().lock(),
"Failed to serialize GC report: {render_error}"
);
}
} else {
let _ = writeln!(std::io::stderr().lock(), "Error: {failure}");
}
} else if wants_machine_output {
let response: ApiResponse<()> =
ApiResponse::err(top_level_command_label(), top_level_api_error(&error));
match serde_json::to_string_pretty(&response) {
Ok(json) => {
let rendered = {
let mut output = std::io::stdout().lock();
writeln!(output, "{json}").and_then(|_| output.flush())
};
if let Err(render_error) = rendered {
let _ = writeln!(
std::io::stderr().lock(),
"Failed to write JSON error response: {render_error}"
);
}
}
Err(serialize_error) => {
let _ = writeln!(std::io::stderr().lock(), "Error: {error:#}");
let _ = writeln!(
std::io::stderr().lock(),
"Failed to serialize JSON error response: {serialize_error}"
);
}
}
} else {
let _ = writeln!(std::io::stderr().lock(), "Error: {error:#}");
}
std::process::exit(1);
}
}
async fn run(args: Vec<OsString>) -> Result<()> {
CompleteEnv::with_factory(Cli::command).complete();
let args: Vec<String> = args
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
if args.iter().any(|a| a == "--help-json") {
let subcommand_path: Vec<String> = args
.iter()
.skip_while(|a| a.as_str() != "--help-json")
.skip(1)
.filter(|a| !a.starts_with('-'))
.cloned()
.collect();
return handle_help_json_early(&subcommand_path);
}
if args.iter().any(|a| a == "--capabilities") {
return handle_capabilities();
}
let cli = Cli::parse();
if cli.no_self_healing {
crate::self_healing_overrides::set_no_self_healing(true);
}
if cli.no_hook_auto_start {
crate::self_healing_overrides::set_no_hook_auto_start(true);
}
let base_level = base_log_level_for_cli(&cli);
let mut log_config = LogConfig::from_env(&base_level).with_stderr();
if cli.verbose {
log_config = log_config.with_level("debug");
} else if cli.quiet {
log_config = log_config.with_level("error");
}
let otel = if reliability_metrics_requested(&cli) {
match rch_telemetry::otlp::OtelMetrics::from_env() {
Ok(exporter) => exporter,
Err(error) => {
eprintln!("rch: OTLP metrics initialization failed ({error:#}); continuing");
None
}
}
} else {
None
};
let metrics_layer =
otel.as_ref()
.and_then(|exporter| match rch_telemetry::metrics::Metrics::new() {
Ok(metrics) => Some(rch_telemetry::metrics::MetricsLayer::new(
metrics.with_otel(Some(exporter.clone())),
)),
Err(error) => {
eprintln!("rch: metrics initialization failed ({error}); continuing");
None
}
});
let logging = if let Some(layer) = metrics_layer {
use tracing_subscriber::Layer;
rch_common::init_logging_with_layer(
&log_config,
layer.with_filter(tracing_subscriber::filter::filter_fn(|metadata| {
matches!(
metadata.target(),
"rch::doctor::verdict"
| "rch::doctor::probe_duration"
| "rch::doctor::remediation"
)
})),
)
} else {
init_logging(&log_config)
};
let _logging_guards = match logging {
Ok(guards) => Some(guards),
Err(error) => {
eprintln!("rch: logging initialization failed ({error:#}); continuing without logging");
None
}
};
let active_overrides = crate::self_healing_overrides::active_cli_overrides();
if !active_overrides.is_empty() {
tracing::info!(
target: "rch::self_healing",
overrides = ?active_overrides,
"CLI self-healing overrides active"
);
}
update::spawn_update_check_if_needed();
let format = resolve_output_format(cli.format.as_deref(), cli.json);
let machine = machine_output_requested(cli.format.as_deref(), cli.json);
let output_config = OutputConfig {
json: machine,
format,
verbose: cli.verbose,
quiet: cli.quiet,
color: if cli.no_color {
ColorChoice::Never
} else {
ColorChoice::parse(&cli.color)
},
..Default::default()
};
let ctx = Arc::new(OutputContext::new(output_config));
if ctx.is_verbose() {
tracing::debug!(target: "rch::verbose", mode = ?ctx.mode(), format = %ctx.format(), "verbose output enabled");
}
let result = dispatch_command(cli, ctx).await;
if let Some(exporter) = otel
&& let Err(error) = tokio::task::spawn_blocking(move || exporter.shutdown()).await
{
eprintln!("rch: OTLP metrics shutdown task failed ({error})");
}
result
}
async fn dispatch_command(cli: Cli, ctx: Arc<OutputContext>) -> Result<()> {
if cli.robot_triage {
return handle_robot_triage(&ctx);
}
if cli.schema {
return handle_schema_request(&cli.command);
}
match cli.command {
None => {
use std::io::IsTerminal;
let forced_hook = env::var("RCH_HOOK_MODE")
.is_ok_and(|v| rch_common::placement::env_truthy(&v))
|| env::var("RCH_JSON").is_ok_and(|v| rch_common::placement::env_truthy(&v));
if !forced_hook && std::io::stdin().is_terminal() {
eprintln!("rch runs in PreToolUse hook mode when invoked without a subcommand.");
eprintln!("It is now waiting for a JSON hook request on stdin.");
eprintln!();
eprintln!("If you are a human at a terminal, try one of:");
eprintln!(" rch --help Full CLI help");
eprintln!(" rch status Show daemon and worker status");
eprintln!(" rch hook test Exercise the hook with a sample payload");
eprintln!(" rch dashboard Launch the TUI");
eprintln!();
eprintln!("To force hook mode anyway, set RCH_HOOK_MODE=1 or pipe JSON in.");
eprintln!("Press Ctrl-D to send EOF (allows unchanged) or Ctrl-C to exit.");
}
hook::install_hook_mode_panic_handler();
hook::run_hook().await
}
Some(cmd) => match cmd {
Commands::Init { yes, skip_test } => commands::init_wizard(yes, skip_test, &ctx).await,
Commands::Daemon { action } => handle_daemon(action, &ctx).await,
Commands::Workers { action } => handle_workers(action, &ctx).await,
Commands::Status {
workers,
jobs,
fleet,
remediation,
} => handle_status(workers, jobs, fleet, remediation, &ctx).await,
Commands::Check => commands::check(&ctx).await,
Commands::Queue { watch, follow } => commands::queue_status(watch, follow, &ctx).await,
Commands::Jobs { action } => commands::jobs::run(action, &ctx).await,
Commands::Cancel {
build_id,
all,
force,
yes,
dry_run,
} => commands::cancel_build(build_id, all, force, yes, dry_run, &ctx).await,
Commands::Sync {
force,
worker,
all,
project,
dry_run,
} => commands::sync_force(force, worker, all, project, dry_run, &ctx).await,
Commands::Rabs {
action: commands::rabs_gc::RabsCommand::Gc { action },
} => commands::rabs_gc::run_gc(action, &ctx).await,
Commands::Rabs {
action:
commands::rabs_gc::RabsCommand::Worker {
action: commands::rabs_gc::WorkerAction::Reconcile { worker, cas_root },
},
} => commands::rabs_gc::run_worker_reconcile(worker, cas_root, &ctx).await,
Commands::Rabs {
action:
commands::rabs_gc::RabsCommand::Doctor {
cas_root,
min_seq_lag,
},
} => commands::rabs_gc::run_doctor(cas_root, min_seq_lag, &ctx).await,
Commands::Why { action } => commands::why::run(action, &ctx).await,
Commands::Rabs {
action:
commands::rabs_gc::RabsCommand::Inventory {
cas_root,
l2_root,
allow_namespace,
},
} => commands::rabs_gc::run_inventory(cas_root, l2_root, allow_namespace, &ctx).await,
Commands::Config { action } => handle_config(action, &ctx).await,
Commands::Cache { action } => handle_cache(action, &ctx).await,
Commands::Gc {
dry_run,
apply,
workers,
roots,
worker_timeout,
} => handle_gc(dry_run, apply, workers, roots, worker_timeout, &ctx).await,
Commands::Diagnose { command, dry_run } => {
handle_diagnose(command, dry_run, &ctx).await
}
Commands::Admit {
job,
require_tool,
command,
} => handle_admit(job, require_tool, command, &ctx).await,
Commands::Exec {
base,
dependency_base,
clean_overlay,
overlay_path,
no_overlay,
source_content_receipt,
job,
result_dir,
require_tool,
worker,
command,
} => {
hook::run_exec(
base,
dependency_base,
clean_overlay,
overlay_path,
no_overlay,
source_content_receipt,
job,
result_dir,
require_tool,
worker,
command,
&ctx,
)
.await
}
Commands::Hook { action } => handle_hook(action, &ctx).await,
Commands::Shim { action } => handle_shim(action, &ctx),
Commands::Agents { action } => handle_agents(action, &ctx).await,
Commands::Completions { action } => handle_completions(action, &ctx),
Commands::Doctor {
fix,
dry_run,
reliability,
check_schemas,
strict,
lenient,
scope,
watch,
watch_interval,
transitions_only,
watch_snapshot,
runbook,
runbook_list,
} => {
handle_doctor(
fix,
dry_run,
reliability,
check_schemas,
strict,
lenient,
scope,
watch,
watch_interval,
transitions_only,
watch_snapshot,
runbook,
runbook_list,
&ctx,
)
.await
}
Commands::SelfTest {
action,
worker,
all,
project,
timeout,
debug,
scheduled,
smoke,
soak,
load,
dry_run,
} => {
commands::self_test(
action, worker, all, project, timeout, debug, scheduled, smoke, soak, load,
dry_run, &ctx,
)
.await
}
Commands::Update {
check,
version,
channel,
fleet,
rollback,
verify,
skip_verify,
yes,
dry_run,
no_restart,
drain_timeout,
show_changelog,
} => {
handle_update(
&ctx,
check,
version,
channel,
fleet,
rollback,
verify,
yes,
dry_run,
skip_verify,
no_restart,
drain_timeout,
show_changelog,
)
.await
}
Commands::Fleet { action } => handle_fleet(action, &ctx).await,
Commands::SpeedScore {
worker,
all,
history,
days,
limit,
} => commands::speedscore(worker, all, history, days, limit, &ctx).await,
Commands::Dashboard {
refresh,
no_mouse,
test_mode,
mock_data,
dump_state,
high_contrast,
color_blind,
} => {
let config = tui::TuiConfig {
refresh_interval_ms: refresh,
mouse_support: !no_mouse,
test_mode,
mock_data,
dump_state,
high_contrast,
color_blind,
};
tui::run_tui(config).await
}
Commands::Web { url, no_open } => handle_web(url, no_open, &ctx),
Commands::Capabilities => handle_capabilities_command(&ctx),
Commands::RobotDocs { action } => handle_robot_docs(action, &ctx),
Commands::Error { sub } => handle_error_explain(sub, &ctx),
Commands::Schema { action } => handle_schema_command(action, &ctx),
},
}
}
fn top_level_machine_output_requested(args: &[OsString]) -> bool {
if env::var_os("RCH_JSON").is_some_and(|value| value != "0" && !value.is_empty())
|| env::var_os("RCH_OUTPUT_FORMAT").is_some_and(|value| !value.is_empty())
|| env::var_os("TOON_DEFAULT_FORMAT").is_some_and(|value| !value.is_empty())
{
return true;
}
for arg in args.iter().skip(1) {
let Some(arg) = arg.to_str() else {
continue;
};
match arg {
"--json" | "-j" | "--help-json" | "--capabilities" | "--schema" | "--robot-triage" => {
return true;
}
"--format" | "-F" => {
return true;
}
_ if arg.starts_with("--format=") => return true,
_ => {}
}
}
false
}
fn unknown_worker_filter_error(missing: &[&str], configured: &str) -> anyhow::Error {
ApiError::from_code(ErrorCode::ConfigInvalidWorker)
.with_details(format!(
"unknown worker id(s) in --workers filter: {}; configured workers: {}",
missing.join(", "),
configured
))
.into()
}
fn top_level_api_error(error: &anyhow::Error) -> ApiError {
if let Some(api_error) = error
.downcast_ref::<ApiError>()
.or_else(|| error.chain().find_map(|e| e.downcast_ref::<ApiError>()))
{
return api_error.clone();
}
let details = format!("{error:#}");
let code = if details.contains("TOML parse error")
|| details.contains("Invalid TOML syntax")
|| details.contains("Failed to parse workers config")
{
ErrorCode::ConfigParseError
} else if details.contains("Failed to parse") || details.contains("Failed to decode") {
ErrorCode::InternalSerdeError
} else if details.contains("Failed to read") {
ErrorCode::ConfigReadError
} else {
ErrorCode::InternalStateError
};
ApiError::from_code(code).with_details(details)
}
fn top_level_command_label() -> &'static str {
"rch"
}
fn handle_schema_command(action: SchemaAction, ctx: &OutputContext) -> Result<()> {
use rch_common::api::{export_schemas, generate_error_catalog};
match action {
SchemaAction::Export { output } => {
let output_dir = output.unwrap_or_else(|| {
let cwd = std::env::current_dir().unwrap_or_default();
cwd.join("docs").join("api").join("schemas")
});
let result = export_schemas(&output_dir)?;
if ctx.is_json() {
let response = ApiResponse::ok("schema export", result);
println!("{}", serde_json::to_string_pretty(&response)?);
} else {
println!(
"Exported {} schema files to {}",
result.files_generated, result.output_dir
);
for file in &result.files {
println!(" - {}", file);
}
}
Ok(())
}
SchemaAction::List => {
#[derive(serde::Serialize, schemars::JsonSchema)]
struct SchemaListResponse {
schemas: Vec<SchemaInfo>,
}
#[derive(serde::Serialize, schemars::JsonSchema)]
struct SchemaInfo {
name: String,
description: String,
filename: String,
}
let schemas = vec![
SchemaInfo {
name: "API Response".to_string(),
description: "JSON Schema for the unified API response envelope".to_string(),
filename: "api-response.schema.json".to_string(),
},
SchemaInfo {
name: "API Error".to_string(),
description: "JSON Schema for error response structure".to_string(),
filename: "api-error.schema.json".to_string(),
},
SchemaInfo {
name: "Error Codes".to_string(),
description: "Machine-readable error code catalog with all RCH-Exxx codes"
.to_string(),
filename: "error-codes.json".to_string(),
},
];
if ctx.is_json() {
let response = ApiResponse::ok("schema list", SchemaListResponse { schemas });
println!("{}", serde_json::to_string_pretty(&response)?);
} else {
println!("Available RCH API Schemas:\n");
for schema in &schemas {
println!(" {} ({})", schema.name, schema.filename);
println!(" {}\n", schema.description);
}
let catalog = generate_error_catalog();
println!("Error Catalog Statistics:");
println!(" Categories: {}", catalog.categories.len());
println!(" Error codes: {}", catalog.errors.len());
}
Ok(())
}
}
}
fn handle_schema_request(command: &Option<Commands>) -> Result<()> {
println!("{}", schema_json_for_command(command)?);
Ok(())
}
fn schema_json_for_command(command: &Option<Commands>) -> Result<String> {
use commands::{
ConfigDiffResponse, ConfigDoctorResponse, ConfigGetResponse, ConfigLintResponse,
ConfigResetResponse, ConfigShowResponse, ConfigValidationResponse, DaemonStatusResponse,
DiagnoseResponse, HookActionResponse, WorkersListResponse,
};
let schema_json = match command {
Some(Commands::Config { action }) => match action {
ConfigAction::Lint => {
let schema = SchemaSettings::draft07()
.into_generator()
.into_root_schema_for::<ConfigLintResponse>();
serde_json::to_string_pretty(&schema)?
}
ConfigAction::Doctor => {
let schema = SchemaSettings::draft07()
.into_generator()
.into_root_schema_for::<ConfigDoctorResponse>();
serde_json::to_string_pretty(&schema)?
}
ConfigAction::Diff => {
let schema = SchemaSettings::draft07()
.into_generator()
.into_root_schema_for::<ConfigDiffResponse>();
serde_json::to_string_pretty(&schema)?
}
ConfigAction::Show { .. } => {
let schema = SchemaSettings::draft07()
.into_generator()
.into_root_schema_for::<ConfigShowResponse>();
serde_json::to_string_pretty(&schema)?
}
ConfigAction::Get { .. } => {
let schema = SchemaSettings::draft07()
.into_generator()
.into_root_schema_for::<ConfigGetResponse>();
serde_json::to_string_pretty(&schema)?
}
ConfigAction::Reset { .. } => {
let schema = SchemaSettings::draft07()
.into_generator()
.into_root_schema_for::<ConfigResetResponse>();
serde_json::to_string_pretty(&schema)?
}
ConfigAction::Validate => {
let schema = SchemaSettings::draft07()
.into_generator()
.into_root_schema_for::<ConfigValidationResponse>();
serde_json::to_string_pretty(&schema)?
}
_ => {
eprintln!(
"No JSON Schema available for 'config {}' output",
action.as_str()
);
std::process::exit(1);
}
},
Some(Commands::Workers { action }) => match action {
WorkersAction::List { .. } => {
let schema = SchemaSettings::draft07()
.into_generator()
.into_root_schema_for::<WorkersListResponse>();
serde_json::to_string_pretty(&schema)?
}
_ => {
eprintln!(
"No JSON Schema available for 'workers {}' output",
action.as_str()
);
std::process::exit(1);
}
},
Some(Commands::Daemon { action }) => match action {
DaemonAction::Status => {
let schema = SchemaSettings::draft07()
.into_generator()
.into_root_schema_for::<DaemonStatusResponse>();
serde_json::to_string_pretty(&schema)?
}
_ => {
eprintln!(
"No JSON Schema available for 'daemon {}' output",
action.as_str()
);
std::process::exit(1);
}
},
Some(Commands::Diagnose { .. }) => {
let schema = SchemaSettings::draft07()
.into_generator()
.into_root_schema_for::<DiagnoseResponse>();
serde_json::to_string_pretty(&schema)?
}
Some(Commands::Why {
action: commands::why::WhyAction::Miss { .. },
}) => {
let schema = SchemaSettings::draft07()
.into_generator()
.into_root_schema_for::<commands::why::MissExplanation>();
serde_json::to_string_pretty(&schema)?
}
Some(Commands::Why {
action: commands::why::WhyAction::Refusal { .. },
}) => {
let schema = SchemaSettings::draft07()
.into_generator()
.into_root_schema_for::<commands::why::RefusalExplanation>();
serde_json::to_string_pretty(&schema)?
}
Some(Commands::Hook { action }) => match action {
HookAction::Install | HookAction::Uninstall { .. } | HookAction::Status => {
let schema = SchemaSettings::draft07()
.into_generator()
.into_root_schema_for::<HookActionResponse>();
serde_json::to_string_pretty(&schema)?
}
_ => {
eprintln!(
"No JSON Schema available for 'hook {}' output",
action.as_str()
);
std::process::exit(1);
}
},
None => {
eprintln!("Usage: rch --schema <command> [subcommand]");
eprintln!();
eprintln!("Available schemas:");
eprintln!(" rch --schema config lint # ConfigLintResponse");
eprintln!(" rch --schema config diff # ConfigDiffResponse");
eprintln!(" rch --schema config show # ConfigShowResponse");
eprintln!(" rch --schema config get # ConfigGetResponse");
eprintln!(" rch --schema config reset # ConfigResetResponse");
eprintln!(" rch --schema config validate # ConfigValidationResponse");
eprintln!(" rch --schema workers list # WorkersListResponse");
eprintln!(" rch --schema daemon status # DaemonStatusResponse");
eprintln!(" rch --schema diagnose <cmd> # DiagnoseResponse");
eprintln!(" rch --schema hook install # HookActionResponse");
std::process::exit(0);
}
_ => {
eprintln!("No JSON Schema available for this command");
std::process::exit(1);
}
};
Ok(schema_json)
}
#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
struct HelpJsonOutput {
name: String,
version: String,
about: Option<String>,
subcommands: Vec<SubcommandHelp>,
global_flags: Vec<ArgHelp>,
#[serde(skip_serializing_if = "Vec::is_empty")]
arguments: Vec<ArgHelp>,
}
#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
struct SubcommandHelp {
name: String,
about: Option<String>,
aliases: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
subcommands: Vec<SubcommandHelp>,
#[serde(skip_serializing_if = "Vec::is_empty")]
arguments: Vec<ArgHelp>,
}
#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
struct ArgHelp {
name: String,
short: Option<char>,
long: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
help: Option<String>,
required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
default_value: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
possible_values: Vec<String>,
}
fn handle_help_json_early(subcommand_path: &[String]) -> Result<()> {
let cmd = Cli::command();
let output = if subcommand_path.is_empty() {
build_help_json(&cmd)
} else {
let mut current_cmd = &cmd;
let mut traversed = Vec::new();
for raw_part in subcommand_path {
for part in raw_part.split('/').filter(|part| !part.is_empty()) {
traversed.push(part.to_string());
match current_cmd.get_subcommands().find(|s| {
s.get_name() == part || s.get_all_aliases().any(|alias| alias == part)
}) {
Some(sub) => current_cmd = sub,
None => {
eprintln!("Unknown subcommand path: {}", traversed.join("/"));
eprintln!(
"Try a valid path from `rch --help-json`, for example: rch --help-json workers/list"
);
std::process::exit(1);
}
}
}
}
build_help_json(current_cmd)
};
let json = serde_json::to_string_pretty(&output)?;
println!("{json}");
Ok(())
}
fn build_help_json(cmd: &clap::Command) -> HelpJsonOutput {
HelpJsonOutput {
name: cmd.get_name().to_string(),
version: cmd.get_version().map(|v| v.to_string()).unwrap_or_default(),
about: cmd.get_about().map(|a| a.to_string()),
subcommands: cmd
.get_subcommands()
.filter(|s| !s.is_hide_set())
.map(build_subcommand_help)
.collect(),
global_flags: cmd
.get_arguments()
.filter(|a| a.is_global_set() && a.get_id() != "help" && a.get_id() != "version")
.map(build_arg_help)
.collect(),
arguments: cmd
.get_arguments()
.filter(|a| !a.is_global_set() && a.get_id() != "help" && a.get_id() != "version")
.map(build_arg_help)
.collect(),
}
}
fn build_subcommand_help(cmd: &clap::Command) -> SubcommandHelp {
SubcommandHelp {
name: cmd.get_name().to_string(),
about: cmd.get_about().map(|a| a.to_string()),
aliases: cmd.get_all_aliases().map(|s| s.to_string()).collect(),
subcommands: cmd
.get_subcommands()
.filter(|s| !s.is_hide_set())
.map(build_subcommand_help)
.collect(),
arguments: cmd
.get_arguments()
.filter(|a| a.get_id() != "help" && a.get_id() != "version")
.map(build_arg_help)
.collect(),
}
}
fn build_arg_help(arg: &clap::Arg) -> ArgHelp {
ArgHelp {
name: arg.get_id().to_string(),
short: arg.get_short(),
long: arg.get_long().map(|s| s.to_string()),
help: arg.get_help().map(|h| h.to_string()),
required: arg.is_required_set(),
default_value: arg
.get_default_values()
.first()
.map(|v| v.to_string_lossy().to_string()),
possible_values: arg
.get_possible_values()
.iter()
.map(|v| v.get_name().to_string())
.collect(),
}
}
#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
struct CapabilitiesOutput {
contract_version: String,
schema_version: String,
version: String,
#[serde(skip_serializing_if = "Option::is_none")]
build_timestamp: Option<String>,
runtimes: Vec<RuntimeCapability>,
commands: Vec<CommandCapability>,
features: Vec<FeatureCapability>,
hook_formats: Vec<String>,
output_formats: Vec<String>,
exit_codes: Vec<ExitCodeCapability>,
env_vars: Vec<EnvVarCapability>,
recommended_commands: Vec<RecommendedCommand>,
reason_code_families: Vec<ReasonCodeFamily>,
policies: Vec<PolicyCapability>,
}
#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
struct RuntimeCapability {
name: String,
description: String,
extensions: Vec<String>,
example_commands: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
struct CommandCapability {
name: String,
description: String,
category: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
aliases: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
struct FeatureCapability {
name: String,
description: String,
enabled: bool,
}
#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
struct ExitCodeCapability {
code: i32,
meaning: String,
agent_action: String,
}
#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
struct EnvVarCapability {
name: String,
effect: String,
}
#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
struct RecommendedCommand {
command: String,
purpose: String,
}
#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
struct ReasonCodeFamily {
family: String,
name: String,
description: String,
code_range: String,
lookup: String,
enumerate: String,
examples: Vec<ReasonCodeExample>,
}
#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
struct ReasonCodeExample {
code: String,
meaning: String,
}
#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
struct PolicyCapability {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
env_var: Option<String>,
behavior: String,
#[serde(skip_serializing_if = "Option::is_none")]
reason_code: Option<String>,
next_action: String,
}
fn build_capabilities_output() -> CapabilitiesOutput {
CapabilitiesOutput {
contract_version: "rch.capabilities.v1".to_string(),
schema_version: "1.1".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
build_timestamp: option_env!("RCH_BUILD_TIMESTAMP").map(|s| s.to_string()),
runtimes: vec![
RuntimeCapability {
name: "rust".to_string(),
description: "Rust/Cargo compilation".to_string(),
extensions: vec!["rs".to_string()],
example_commands: vec![
"cargo build".to_string(),
"cargo test".to_string(),
"cargo check".to_string(),
"rustc".to_string(),
],
},
RuntimeCapability {
name: "bun".to_string(),
description: "Bun JavaScript/TypeScript runtime".to_string(),
extensions: vec![
"js".to_string(),
"ts".to_string(),
"jsx".to_string(),
"tsx".to_string(),
],
example_commands: vec!["bun test".to_string(), "bun typecheck".to_string()],
},
RuntimeCapability {
name: "node".to_string(),
description: "Node.js/npm capability detection for worker parity".to_string(),
extensions: vec!["js".to_string(), "ts".to_string(), "mjs".to_string()],
example_commands: vec!["node --version".to_string(), "npm --version".to_string()],
},
RuntimeCapability {
name: "c-cpp".to_string(),
description: "C and C++ compiler commands".to_string(),
extensions: vec!["c".to_string(), "cc".to_string(), "cpp".to_string()],
example_commands: vec![
"gcc -o main main.c".to_string(),
"clang++ -O2 -o app main.cpp".to_string(),
],
},
RuntimeCapability {
name: "build-systems".to_string(),
description: "Make, CMake, Ninja, and Meson build commands".to_string(),
extensions: vec![],
example_commands: vec![
"make".to_string(),
"cmake --build build".to_string(),
"ninja".to_string(),
"meson compile -C build".to_string(),
],
},
],
commands: command_capabilities(),
features: vec![
FeatureCapability {
name: "rich-ui".to_string(),
description: "Rich terminal UI with colors and formatting".to_string(),
enabled: cfg!(feature = "rich-ui"),
},
FeatureCapability {
name: "json-output".to_string(),
description: "Machine-readable JSON output via --json flag".to_string(),
enabled: true,
},
FeatureCapability {
name: "toon-output".to_string(),
description: "TOON protocol output via --format=toon".to_string(),
enabled: true,
},
FeatureCapability {
name: "schema-introspection".to_string(),
description: "JSON Schema generation via --schema flag".to_string(),
enabled: true,
},
FeatureCapability {
name: "capabilities-command".to_string(),
description: "Agent-readable capability report via `rch capabilities --json`"
.to_string(),
enabled: true,
},
FeatureCapability {
name: "robot-docs".to_string(),
description: "In-tool agent handbook via `rch robot-docs guide`".to_string(),
enabled: true,
},
FeatureCapability {
name: "robot-triage".to_string(),
description: "Agent mega-command via `rch --robot-triage --json`".to_string(),
enabled: true,
},
FeatureCapability {
name: "shell-completions".to_string(),
description: "Tab completion for bash, zsh, fish, etc.".to_string(),
enabled: true,
},
FeatureCapability {
name: "tui-dashboard".to_string(),
description: "Interactive terminal dashboard".to_string(),
enabled: true,
},
],
hook_formats: vec![
"claude-code-pre-tool-use".to_string(),
"gemini-cli".to_string(),
],
output_formats: vec!["json".to_string(), "toon".to_string(), "human".to_string()],
exit_codes: exit_code_capabilities(),
env_vars: env_var_capabilities(),
recommended_commands: recommended_commands(),
reason_code_families: reason_code_families(),
policies: policies(),
}
}
fn handle_capabilities() -> Result<()> {
let output = build_capabilities_output();
let json = serde_json::to_string_pretty(&output)?;
println!("{json}");
Ok(())
}
fn handle_capabilities_command(ctx: &OutputContext) -> Result<()> {
let output = build_capabilities_output();
if ctx.is_json() {
ctx.json(&ApiResponse::ok("capabilities", output))?;
} else {
ctx.header("RCH Capabilities");
ctx.key_value("Contract", &output.contract_version);
ctx.key_value("Version", &output.version);
ctx.key_value("Commands", &output.commands.len().to_string());
ctx.key_value("Output formats", &output.output_formats.join(", "));
ctx.key_value(
"Reason-code families",
&output
.reason_code_families
.iter()
.map(|f| f.family.as_str())
.collect::<Vec<_>>()
.join(", "),
);
ctx.key_value(
"Policies",
&output
.policies
.iter()
.map(|p| p.id.as_str())
.collect::<Vec<_>>()
.join(", "),
);
ctx.print("");
ctx.print("Recommended agent entry points:");
for command in &output.recommended_commands {
ctx.print(&format!(" {} # {}", command.command, command.purpose));
}
}
Ok(())
}
fn command_capabilities() -> Vec<CommandCapability> {
Cli::command()
.get_subcommands()
.filter(|cmd| !cmd.is_hide_set() && cmd.get_name() != "help")
.map(|cmd| {
let name = cmd.get_name().to_string();
CommandCapability {
description: cmd.get_about().map(|s| s.to_string()).unwrap_or_default(),
category: command_category(&name).to_string(),
aliases: cmd
.get_all_aliases()
.map(|alias| alias.to_string())
.collect(),
name,
}
})
.collect()
}
fn command_category(name: &str) -> &'static str {
match name {
"init" | "hook" | "agents" | "completions" => "setup",
"status" | "check" | "queue" | "speedscore" | "dashboard" | "web" => "monitoring",
"daemon" | "workers" | "cancel" | "sync" | "exec" | "update" | "fleet" => "management",
"config" => "configuration",
"diagnose" | "doctor" | "self-test" | "schema" => "debugging",
"capabilities" | "robot-docs" => "agent-docs",
_ => "general",
}
}
fn exit_code_capabilities() -> Vec<ExitCodeCapability> {
vec![
ExitCodeCapability {
code: 0,
meaning: "success".to_string(),
agent_action: "Continue; stdout contains the requested data in machine mode."
.to_string(),
},
ExitCodeCapability {
code: 1,
meaning: "general error or degraded health".to_string(),
agent_action: "Read stderr or the JSON error/remediation fields; do not retry blindly."
.to_string(),
},
ExitCodeCapability {
code: 2,
meaning: "usage error or not ready health check".to_string(),
agent_action:
"Correct the command shape, or run `rch doctor --json` when this came from `rch check`."
.to_string(),
},
ExitCodeCapability {
code: 100,
meaning: "network or SSH failure".to_string(),
agent_action: "Run `rch workers probe --all --json` and inspect RCH-E1xx errors."
.to_string(),
},
ExitCodeCapability {
code: 101,
meaning: "worker error".to_string(),
agent_action: "Inspect `rch workers list --json` and worker capability mismatches."
.to_string(),
},
ExitCodeCapability {
code: 102,
meaning: "remote build failed".to_string(),
agent_action: "Treat as the build/test command result; local re-run is usually redundant."
.to_string(),
},
]
}
fn env_var_capabilities() -> Vec<EnvVarCapability> {
let mut caps = vec![
EnvVarCapability {
name: "RCH_OUTPUT_FORMAT".to_string(),
effect: "Set machine output format: json or toon; implies machine output.".to_string(),
},
EnvVarCapability {
name: "TOON_DEFAULT_FORMAT".to_string(),
effect: "Default format when --json is set: json or toon.".to_string(),
},
EnvVarCapability {
name: "RCH_HOOK_MODE".to_string(),
effect: "Force no-subcommand invocation to read Claude Code hook JSON from stdin."
.to_string(),
},
EnvVarCapability {
name: "RCH_JSON".to_string(),
effect: "Force hook/machine JSON behavior for automation.".to_string(),
},
EnvVarCapability {
name: "NO_COLOR".to_string(),
effect: "Disable ANSI color output.".to_string(),
},
EnvVarCapability {
name: "RCH_SOCKET_PATH".to_string(),
effect: "Override daemon Unix socket path.".to_string(),
},
EnvVarCapability {
name: "RCH_DAEMON_TIMEOUT_MS".to_string(),
effect: "Override the daemon socket connect/read timeout in milliseconds (100-600000; default 5000).".to_string(),
},
EnvVarCapability {
name: "RCH_MOCK_SSH".to_string(),
effect: "Enable mock SSH for tests and offline verification.".to_string(),
},
EnvVarCapability {
name: "RCH_COMPRESSION_LEVEL".to_string(),
effect: "Override transfer compression level.".to_string(),
},
EnvVarCapability {
name: "RCH_SYNC_TIMEOUT_MS".to_string(),
effect: "Override the per-attempt source-sync timeout in milliseconds; distinct from remote build and artifact-return timeouts.".to_string(),
},
EnvVarCapability {
name: "RCH_MIN_LOCAL_TIME_MS".to_string(),
effect: "Override the local-runtime threshold used before offload.".to_string(),
},
EnvVarCapability {
name: "RCH_REMOTE_SPEEDUP_THRESHOLD".to_string(),
effect: "Override the predicted remote speedup ratio required before offload."
.to_string(),
},
EnvVarCapability {
name: "RCH_VISIBILITY".to_string(),
effect: "Control hook output visibility: none, summary, or verbose.".to_string(),
},
];
for control in rch_common::placement_controls() {
if caps.iter().any(|c| c.name == control.canonical_env) {
continue;
}
let mut effect = format!("{} [{}]", control.description, control.value_form);
if !control.aliases.is_empty() {
effect.push_str(&format!(" (aliases: {})", control.aliases.join(", ")));
}
caps.push(EnvVarCapability {
name: control.canonical_env.to_string(),
effect,
});
}
caps
}
fn recommended_commands() -> Vec<RecommendedCommand> {
vec![
RecommendedCommand {
command: "rch --robot-triage --json".to_string(),
purpose: "Single-call agent quick reference with next commands.".to_string(),
},
RecommendedCommand {
command: "rch capabilities --json".to_string(),
purpose: "Discover commands, output formats, env vars, and exit codes.".to_string(),
},
RecommendedCommand {
command: "rch robot-docs guide".to_string(),
purpose: "Read the in-tool agent handbook without external docs.".to_string(),
},
RecommendedCommand {
command: "rch check --json".to_string(),
purpose: "Fast readiness probe with stable exit codes.".to_string(),
},
RecommendedCommand {
command: "rch diagnose --json \"cargo test\"".to_string(),
purpose: "Explain whether a command will be offloaded.".to_string(),
},
RecommendedCommand {
command: "rch doctor --dry-run --json".to_string(),
purpose: "Get remediation steps without making changes.".to_string(),
},
]
}
fn reason_code_families() -> Vec<ReasonCodeFamily> {
use rch_common::incident::IncidentReasonCode;
let incident_examples: Vec<ReasonCodeExample> = IncidentReasonCode::ALL
.iter()
.map(|reason| ReasonCodeExample {
code: reason.code().to_string(),
meaning: reason.failure_class().to_string(),
})
.collect();
let incident_range = match (
IncidentReasonCode::ALL.first(),
IncidentReasonCode::ALL.last(),
) {
(Some(first), Some(last)) => format!("{}..{}", first.code(), last.code()),
_ => "RCH-I001..".to_string(),
};
vec![
ReasonCodeFamily {
family: "RCH-E".to_string(),
name: "Operational error catalog".to_string(),
description:
"Configuration, network/SSH, worker, build, transfer, and internal errors carried \
in the JSON error envelope's `error.code`."
.to_string(),
code_range: "RCH-E001..RCH-E599".to_string(),
lookup: "rch error explain RCH-E100".to_string(),
enumerate: "rch error list --json".to_string(),
examples: vec![
ReasonCodeExample {
code: "RCH-E001".to_string(),
meaning: "configuration not found".to_string(),
},
ReasonCodeExample {
code: "RCH-E100".to_string(),
meaning: "SSH connection failed".to_string(),
},
ReasonCodeExample {
code: "RCH-E300".to_string(),
meaning: "build compilation failed".to_string(),
},
],
},
ReasonCodeFamily {
family: "RCH-R".to_string(),
name: "Reliability reason codes".to_string(),
description:
"Topology/fleet, disk-pressure, process-debt/cancellation, and repo-convergence \
reliability states surfaced by `rch status` and `rch doctor --reliability`."
.to_string(),
code_range: "RCH-R001..RCH-R3xx".to_string(),
lookup: "rch error explain RCH-R101".to_string(),
enumerate: "rch error list --json".to_string(),
examples: vec![
ReasonCodeExample {
code: "RCH-R006".to_string(),
meaning: "all workers unhealthy".to_string(),
},
ReasonCodeExample {
code: "RCH-R101".to_string(),
meaning: "worker disk pressure critical".to_string(),
},
ReasonCodeExample {
code: "RCH-R302".to_string(),
meaning: "repo convergence drift".to_string(),
},
],
},
ReasonCodeFamily {
family: "RCH-I".to_string(),
name: "Incident / refusal reason codes".to_string(),
description:
"Stable failure classes emitted when a build is steered, refused, deferred, or \
falls back (selection, admission, proof, fallback, artifact, worker lifecycle). \
Resolvable via `rch error explain RCH-Innn` and `rch error list --json`; live \
occurrences surface in incident events and `rch status --remediation`."
.to_string(),
code_range: incident_range,
lookup: "rch error explain RCH-I012".to_string(),
enumerate: "rch error list --json".to_string(),
examples: incident_examples,
},
]
}
fn policies() -> Vec<PolicyCapability> {
vec![
PolicyCapability {
id: "fail_open".to_string(),
env_var: None,
behavior:
"Default. If remote execution is unavailable or unsafe, the command runs locally \
instead of blocking."
.to_string(),
reason_code: Some("RCH-I011".to_string()),
next_action: "If you expected offload, inspect `rch status --remediation --json` and \
`rch diagnose --json \"<cmd>\"`."
.to_string(),
},
PolicyCapability {
id: "force_remote".to_string(),
env_var: Some("RCH_FORCE_REMOTE".to_string()),
behavior:
"Always attempt offload, bypassing the local-time and predicted-speedup gating, \
but still fail open to local execution if offload cannot proceed."
.to_string(),
reason_code: Some("RCH-I011".to_string()),
next_action:
"Force an offload attempt; confirm routing with `rch diagnose --json \"<cmd>\"`."
.to_string(),
},
PolicyCapability {
id: "require_remote".to_string(),
env_var: Some("RCH_REQUIRE_REMOTE".to_string()),
behavior:
"Proof mode: fail closed. Refuse local fallback entirely. Takes precedence over \
RCH_FORCE_REMOTE."
.to_string(),
reason_code: Some("RCH-I012".to_string()),
next_action:
"Run proofs as `RCH_REQUIRE_REMOTE=1 rch exec -- <build cmd>`; a refusal surfaces \
RCH-I012 instead of silently running locally."
.to_string(),
},
PolicyCapability {
id: "queue_when_busy".to_string(),
env_var: Some("RCH_QUEUE_WHEN_BUSY".to_string()),
behavior:
"Default on. Wait for a busy-but-eligible worker instead of falling back to local. \
Set to 0 to disable queueing."
.to_string(),
reason_code: None,
next_action: "Tune the wait with RCH_DAEMON_WAIT_RESPONSE_TIMEOUT_SECS; watch with \
`rch queue --follow`."
.to_string(),
},
]
}
#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
struct RemediationWorkflow {
id: String,
summary: String,
commands: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
observe: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
struct RobotDocsGuideOutput {
contract_version: String,
guide: String,
canonical_commands: Vec<RecommendedCommand>,
remediation_workflows: Vec<RemediationWorkflow>,
output_contracts: Vec<String>,
safety_notes: Vec<String>,
}
fn handle_robot_docs(action: RobotDocsAction, ctx: &OutputContext) -> Result<()> {
match action {
RobotDocsAction::Guide => {
let output = RobotDocsGuideOutput {
contract_version: "rch.robot_docs.v1".to_string(),
guide: robot_docs_guide_text().to_string(),
canonical_commands: recommended_commands(),
remediation_workflows: remediation_workflows(),
output_contracts: vec![
"Use --json or --format toon for parseable stdout.".to_string(),
"Diagnostics, progress, and remediation text belong on stderr.".to_string(),
"JSON commands use the ApiResponse envelope unless documented as legacy raw JSON."
.to_string(),
],
safety_notes: vec![
"Prefer --dry-run before commands that change hooks, workers, or fleet state."
.to_string(),
"Use --yes only when the intended mutation is explicit.".to_string(),
"If remote execution is unavailable, RCH fails open to local execution.".to_string(),
],
};
if ctx.is_json() {
ctx.json(&ApiResponse::ok("robot-docs guide", output))?;
} else {
ctx.print(robot_docs_guide_text());
}
}
}
Ok(())
}
fn robot_docs_guide_text() -> &'static str {
r#"RCH Agent Guide
Primary contract:
RCH transparently offloads build/test commands to remote workers. If remote
execution is unavailable or unsafe, it fails open and lets the command run
locally.
First commands to try:
rch --robot-triage --json One-call quick_ref and recommended commands
rch capabilities --json Machine-readable command/env/exit-code map
rch check --json Fast readiness status
rch status --workers --jobs --json
rch diagnose --json "cargo test"
rch doctor --dry-run --json
Output rules:
stdout is data. stderr is diagnostics. Use --json for JSON and
--format toon for TOON. Use --no-color, NO_COLOR=1, CI=true, or TERM=dumb
when output must be plain.
Safe mutation rules:
Use --dry-run before hook, worker, update, and fleet changes when available.
Use --yes only when the target operation is explicit. For active builds,
prefer graceful cancellation before --force.
Common workflows:
Install and validate hook:
rch hook install
rch hook test
rch check --json
Explain routing:
rch diagnose --json "cargo build --release"
rch diagnose --json "bun test"
Inspect fleet health:
rch workers list --json
rch workers probe --all --json
rch workers capabilities --refresh --json
Repair safely:
rch doctor --dry-run --json
rch config validate --json
Remediation workflows (machine-readable list under `remediation_workflows`):
Admit before proof:
rch admit --json "cargo test" # offload/local/queue/defer + required caps
RCH_REQUIRE_REMOTE=1 rch exec -- cargo test
Proof mode (queued / refused / replayed):
RCH_REQUIRE_REMOTE=1 rch exec -- cargo test --workspace
# fail-closed: a refusal is RCH-I012 (never a silent local run).
# queue/replay is daemon-driven; observe the proof_queue band in
rch status --remediation --json
Worker temporary bypass and auto-rejoin:
rch workers drain <id> # manual: stop sending new jobs
rch workers enable <id> # manual: bring it back
# transient bypass + auto-rejoin are automatic; observe per-worker state in
rch status --workers --json
Fleet status:
rch status --fleet --json
rch fleet status --json
Force resync (stale path-dependency roots):
# convergence/resync is daemon-driven today; observe drift with
rch doctor --reliability --scope convergence --json
rch status --remediation --json
Queue attach / cancel:
rch queue --json
rch queue --follow
rch cancel <id> | rch cancel --all --yes
Real-fleet smoke validation:
rch self-test --all --json
rch self-test status --json
rch self-test history --limit 10 --json
Dashboards and metrics:
rch dashboard # interactive TUI (press 'R' for remediation)
rch web --no-open # fleet dashboard URL + its agent endpoint (/api/fleet?view=help)
rchd serves Prometheus metrics (rch_remediation_* families) at its /metrics endpoint,
and, with [api] bind set, its full /status JSON on the tailnet (see configuration guide).
"#
}
fn remediation_workflows() -> Vec<RemediationWorkflow> {
vec![
RemediationWorkflow {
id: "admit_before_proof".to_string(),
summary: "Preflight admissibility, then run the build in proof mode.".to_string(),
commands: vec![
"rch admit --json \"cargo test\"".to_string(),
"RCH_REQUIRE_REMOTE=1 rch exec -- cargo test".to_string(),
],
observe: Some(
"admit returns an offload/local/queue/defer recommendation plus the capabilities a \
worker must have."
.to_string(),
),
},
RemediationWorkflow {
id: "job_requires_tool".to_string(),
summary: "Preflight and run a non-compilation job that needs a verified worker tool."
.to_string(),
commands: vec![
"rch admit --job --require-tool clang --json -- ./run_shards.sh".to_string(),
"rch exec --job --require-tool clang -- ./run_shards.sh".to_string(),
"rch workers capabilities --refresh --json".to_string(),
],
observe: Some(
"admit --job bypasses the compilation classifier, so a non-compilation workload \
reads offload rather than local. A required tool must appear under a worker's \
verified named tools; capability_missing:tool:<name>:not_declared means no \
worker declares a probe for it, :probe_failed means one does and the probe \
fails."
.to_string(),
),
},
RemediationWorkflow {
id: "proof_mode".to_string(),
summary: "Fail-closed remote proof; refusal is RCH-I012, queue/replay is daemon-driven."
.to_string(),
commands: vec![
"RCH_REQUIRE_REMOTE=1 rch exec -- cargo test --workspace".to_string(),
],
observe: Some(
"rch status --remediation --json shows the proof_queue band (queued/blocked/\
replaying/failed); a refusal surfaces RCH-I012 rather than a silent local run."
.to_string(),
),
},
RemediationWorkflow {
id: "worker_bypass_rejoin".to_string(),
summary: "Drain/enable a worker; transient bypass and auto-rejoin are automatic."
.to_string(),
commands: vec![
"rch workers drain <id>".to_string(),
"rch workers enable <id>".to_string(),
],
observe: Some(
"rch status --workers --json (or rch workers list --json) reports the per-worker \
bypass record: reason, next probe, and auto-rejoin posture."
.to_string(),
),
},
RemediationWorkflow {
id: "fleet_status".to_string(),
summary: "Fleet-wide desired/live grouping, dominant problem class, and absence alerts."
.to_string(),
commands: vec![
"rch status --fleet --json".to_string(),
"rch fleet status --json".to_string(),
],
observe: None,
},
RemediationWorkflow {
id: "force_resync".to_string(),
summary: "Force-resync stale path-dependency roots: invalidate the RCH-managed worker \
cache and trigger closure re-sync."
.to_string(),
commands: vec![
"rch sync --project . --json".to_string(),
"rch sync --force --worker <id> --json".to_string(),
"rch sync --force --all --json".to_string(),
],
observe: Some(
"Preview (no --force) lists the planned cache invalidations and any refusals; \
--force invalidates only paths strictly under transfer.remote_base (canonical \
source mirrors are refused, never wiped) and triggers a daemon convergence \
repair. rch status --remediation --json shows the repo_convergence band \
(RCH-R3xx) clearing afterward."
.to_string(),
),
},
RemediationWorkflow {
id: "queue_attach_cancel".to_string(),
summary: "Inspect the build queue and cancel active or queued builds.".to_string(),
commands: vec![
"rch queue --json".to_string(),
"rch queue --follow".to_string(),
"rch cancel <id>".to_string(),
"rch cancel --all --yes".to_string(),
],
observe: None,
},
RemediationWorkflow {
id: "real_fleet_smoke".to_string(),
summary: "Run a real-fleet smoke/self-test and read its history.".to_string(),
commands: vec![
"rch self-test --all --json".to_string(),
"rch self-test status --json".to_string(),
"rch self-test history --limit 10 --json".to_string(),
],
observe: None,
},
]
}
#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
struct RobotTriageOutput {
contract_version: String,
quick_ref: RobotTriageQuickRef,
recommended_commands: Vec<RecommendedCommand>,
health_checks: Vec<RecommendedCommand>,
safety_notes: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, schemars::JsonSchema)]
struct RobotTriageQuickRef {
purpose: String,
default_probe: String,
machine_output: Vec<String>,
no_external_docs_needed: bool,
}
fn handle_robot_triage(ctx: &OutputContext) -> Result<()> {
let output = RobotTriageOutput {
contract_version: "rch.robot_triage.v1".to_string(),
quick_ref: RobotTriageQuickRef {
purpose: "Transparent remote compilation offload for AI coding agents.".to_string(),
default_probe: "rch check --json".to_string(),
machine_output: vec![
"--json".to_string(),
"--format toon".to_string(),
"RCH_OUTPUT_FORMAT=json|toon".to_string(),
],
no_external_docs_needed: true,
},
recommended_commands: recommended_commands(),
health_checks: vec![
RecommendedCommand {
command: "rch check --json".to_string(),
purpose: "Fast readiness gate; exit 0 ready, 1 degraded, 2 not ready.".to_string(),
},
RecommendedCommand {
command: "rch status --workers --jobs --json".to_string(),
purpose: "Detailed daemon, worker, and active-job view.".to_string(),
},
RecommendedCommand {
command: "rch workers probe --all --json".to_string(),
purpose: "Connectivity probe for every configured worker.".to_string(),
},
],
safety_notes: vec![
"Run mutating commands with --dry-run first when the flag exists.".to_string(),
"Use `rch diagnose --json <command>` before forcing local/remote behavior.".to_string(),
"If RCH cannot safely offload, it fails open instead of blocking builds.".to_string(),
],
};
if ctx.is_json() {
ctx.json(&ApiResponse::ok("robot-triage", output))?;
} else {
ctx.header("RCH Robot Triage");
ctx.key_value("Purpose", &output.quick_ref.purpose);
ctx.key_value("Default probe", &output.quick_ref.default_probe);
ctx.print("");
ctx.print("Recommended commands:");
for command in &output.recommended_commands {
ctx.print(&format!(" {} # {}", command.command, command.purpose));
}
ctx.print("");
ctx.print("Health checks:");
for command in &output.health_checks {
ctx.print(&format!(" {} # {}", command.command, command.purpose));
}
}
Ok(())
}
async fn handle_daemon(action: DaemonAction, ctx: &OutputContext) -> Result<()> {
match action {
DaemonAction::Start => {
commands::daemon_start(ctx).await?;
}
DaemonAction::Stop {
yes,
drain,
drain_timeout,
force,
} => {
commands::daemon_stop(
commands::StopOptions {
yes,
drain,
drain_timeout_secs: drain_timeout,
force,
},
ctx,
)
.await?;
}
DaemonAction::Restart {
yes,
drain,
drain_timeout,
force,
} => {
commands::daemon_restart(
commands::StopOptions {
yes,
drain,
drain_timeout_secs: drain_timeout,
force,
},
ctx,
)
.await?;
}
DaemonAction::Status => {
commands::daemon_status(ctx).await?;
}
DaemonAction::Logs { lines } => {
commands::daemon_logs(lines, ctx)?;
}
DaemonAction::Reload => {
commands::daemon_reload(ctx).await?;
}
}
Ok(())
}
async fn handle_workers(action: WorkersAction, ctx: &OutputContext) -> Result<()> {
match action {
WorkersAction::List { speedscore } => {
commands::workers_list(speedscore, ctx).await?;
}
WorkersAction::Capabilities { refresh, command } => {
commands::workers_capabilities(command, refresh, ctx).await?;
}
WorkersAction::Probe { worker, all } => {
commands::workers_probe(worker, all, ctx).await?;
}
WorkersAction::Benchmark {
worker_id,
all: _,
force,
} => {
commands::workers_benchmark_filtered(worker_id.as_deref(), force, ctx).await?;
}
WorkersAction::Compare { worker_ids } => {
commands::workers_compare(&worker_ids, ctx).await?;
}
WorkersAction::Drain { worker, yes } => {
commands::workers_drain(&worker, yes, ctx).await?;
}
WorkersAction::Enable { worker } => {
commands::workers_enable(&worker, ctx).await?;
}
WorkersAction::Disable {
worker,
reason,
drain,
yes,
} => {
commands::workers_disable(&worker, reason, drain, yes, ctx).await?;
}
WorkersAction::DeployBinary {
worker,
all,
force,
dry_run,
} => {
commands::workers_deploy_binary(worker, all, force, dry_run, ctx).await?;
}
WorkersAction::Discover { probe, add, yes } => {
commands::workers_discover(probe, add, yes, ctx).await?;
}
WorkersAction::SyncToolchain {
worker,
all,
dry_run,
} => {
commands::workers_sync_toolchain(worker, all, dry_run, ctx).await?;
}
WorkersAction::Setup {
worker,
all,
dry_run,
skip_binary,
skip_toolchain,
} => {
commands::workers_setup(worker, all, dry_run, skip_binary, skip_toolchain, ctx).await?;
}
WorkersAction::Init { yes } => {
commands::workers_init(yes, ctx).await?;
}
}
Ok(())
}
async fn handle_status(
workers: bool,
jobs: bool,
fleet: bool,
remediation: bool,
ctx: &OutputContext,
) -> Result<()> {
commands::status_overview(workers, jobs, fleet, remediation, ctx).await?;
Ok(())
}
async fn handle_config(action: ConfigAction, ctx: &OutputContext) -> Result<()> {
match action {
ConfigAction::Show { sources } => {
commands::config_show(sources, ctx)?;
}
ConfigAction::Get { key, sources } => {
commands::config_get(&key, sources, ctx)?;
}
ConfigAction::Init {
wizard,
non_interactive,
} => {
let run_wizard = wizard || non_interactive;
commands::config_init(ctx, run_wizard, non_interactive)?;
}
ConfigAction::Validate => {
commands::config_validate(ctx)?;
}
ConfigAction::Set { key, value } => {
commands::config_set(&key, &value, ctx)?;
}
ConfigAction::Reset { key } => {
commands::config_reset(&key, ctx)?;
}
ConfigAction::Export { format } => {
commands::config_export(&format, ctx)?;
}
ConfigAction::Lint => {
commands::config_lint(ctx)?;
}
ConfigAction::Doctor => {
commands::config_doctor(ctx)?;
}
ConfigAction::Edit {
project,
user,
workers,
} => {
commands::config_edit(project, user, workers, ctx)?;
}
ConfigAction::Diff => {
commands::config_diff(ctx)?;
}
}
Ok(())
}
async fn handle_diagnose(command: Vec<String>, dry_run: bool, ctx: &OutputContext) -> Result<()> {
let joined = command.join(" ");
commands::diagnose(&joined, dry_run, ctx).await?;
Ok(())
}
async fn handle_admit(
job: bool,
require_tool: Vec<String>,
command: Vec<String>,
ctx: &OutputContext,
) -> Result<()> {
let joined = command.join(" ");
commands::admit(&joined, job, require_tool, ctx).await
}
#[derive(Debug, serde::Serialize)]
struct CacheWarmWorkerResult {
worker_id: String,
success: bool,
bytes_transferred: u64,
files_transferred: u64,
duration_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Debug, serde::Serialize)]
struct CacheWarmResponse {
project_root: String,
project_id: String,
project_hash: String,
workers_total: usize,
workers_succeeded: usize,
workers_failed: usize,
bytes_transferred: u64,
files_transferred: u64,
duration_ms: u64,
workers: Vec<CacheWarmWorkerResult>,
}
async fn handle_cache(action: CacheAction, ctx: &OutputContext) -> Result<()> {
match action {
CacheAction::Warm { workers, project } => handle_cache_warm(workers, project, ctx).await,
CacheAction::Clean {
older,
project,
base,
execute,
} => handle_cache_clean(older, project, base, execute, ctx).await,
CacheAction::Status { workers } => handle_cache_status(workers, ctx).await,
}
}
async fn handle_cache_clean(
older: String,
project: Option<String>,
extra_bases: Vec<PathBuf>,
execute: bool,
ctx: &OutputContext,
) -> Result<()> {
let min_age = cache_gc::parse_human_duration(&older)
.map_err(|e| anyhow::anyhow!("invalid --older value: {e}"))?;
let config = crate::config::load_config().unwrap_or_default();
let mut bases = vec![PathBuf::from(&config.transfer.remote_base)];
for base in extra_bases {
if !bases.contains(&base) {
bases.push(base);
}
}
let now = std::time::SystemTime::now();
let mut combined = cache_gc::StagingGcPlan {
entries: Vec::new(),
prunable_bytes: 0,
prunable_count: 0,
total_bytes: 0,
};
let mut outcome = cache_gc::StagingGcOutcome {
removed_count: 0,
removed_bytes: 0,
failed: Vec::new(),
};
for base in &bases {
let trees =
cache_gc::enumerate_staging_trees(base, project.as_deref(), now).map_err(|e| {
anyhow::anyhow!("failed to enumerate staging trees under {base:?}: {e}")
})?;
let plan = cache_gc::plan_staging_gc(&trees, cache_gc::StagingGcPolicy { min_age }, base);
if execute {
let o = cache_gc::execute_staging_gc(&plan, base);
outcome.removed_count += o.removed_count;
outcome.removed_bytes += o.removed_bytes;
outcome.failed.extend(o.failed);
}
combined.entries.extend(plan.entries);
combined.prunable_bytes += plan.prunable_bytes;
combined.prunable_count += plan.prunable_count;
combined.total_bytes += plan.total_bytes;
}
let style = ctx.theme();
if execute {
if ctx.is_json() {
let _ = ctx.json(&ApiResponse::ok("cache clean", &outcome));
} else {
println!(
"Pruned {} tree(s), reclaimed {} ({} failed) across {} base(s)",
outcome.removed_count,
cache_gc::human_bytes(outcome.removed_bytes),
outcome.failed.len(),
bases.len()
);
}
} else if ctx.is_json() {
let _ = ctx.json(&ApiResponse::ok("cache clean", &combined));
} else {
for base in &bases {
println!("base {}", base.display());
}
println!(
"Dry run over {} base(s) ({} tree(s), {} total). Would prune {} tree(s), reclaim {}.",
bases.len(),
combined.entries.len(),
cache_gc::human_bytes(combined.total_bytes),
combined.prunable_count,
cache_gc::human_bytes(combined.prunable_bytes)
);
for e in combined
.entries
.iter()
.filter(|e| matches!(e.action, cache_gc::GcAction::Prune))
{
println!(
" prune {} ({}, idle {}s)",
e.path,
cache_gc::human_bytes(e.size_bytes),
e.age_secs
);
}
println!(" {} pass --execute to actually delete", style.muted("→"));
}
Ok(())
}
fn resolve_cache_warm_project_root(
project_root: PathBuf,
policy: &rch_common::path_topology::PathTopologyPolicy,
) -> Result<PathBuf> {
let project_root = if project_root.is_absolute() {
project_root
} else {
std::env::current_dir()
.map_err(|e| anyhow::anyhow!("cannot determine cwd for project path: {e}"))?
.join(project_root)
};
if !project_root.exists() {
anyhow::bail!("project root does not exist: {}", project_root.display());
}
if !project_root.is_dir() {
anyhow::bail!(
"project root is not a directory: {}",
project_root.display()
);
}
let normalized =
rch_common::path_topology::normalize_project_path_with_policy(&project_root, policy)
.map_err(|e| {
anyhow::anyhow!(
"project path normalization failed for {}: {e}",
project_root.display()
)
})?;
Ok(normalized.canonical_path().to_path_buf())
}
fn selected_reap_workers(worker_filter: &[String]) -> Result<Vec<rch_common::WorkerConfig>> {
let all_workers = commands::load_workers_from_config()
.map_err(|e| anyhow::anyhow!("load workers config: {e}"))?;
if all_workers.is_empty() {
anyhow::bail!("no workers configured; run `rch workers init` first");
}
if worker_filter.is_empty() {
return Ok(all_workers);
}
let known_ids: std::collections::BTreeSet<String> =
all_workers.iter().map(|w| w.id.to_string()).collect();
let missing: Vec<&str> = worker_filter
.iter()
.map(String::as_str)
.filter(|id| !known_ids.contains(*id))
.collect();
if !missing.is_empty() {
return Err(unknown_worker_filter_error(
&missing,
&known_ids.into_iter().collect::<Vec<_>>().join(", "),
));
}
Ok(all_workers
.into_iter()
.filter(|w| worker_filter.iter().any(|id| id == w.id.as_str()))
.collect())
}
struct ReapSurfaceConfig {
roots: rch_common::gc_roots::GcRootSet,
policy: rch_common::stale_target_reap::GcPolicy,
idle_hours: u32,
pooled_idle_hours: u32,
cache_idle_days: u32,
max_cache_kb: Option<u64>,
}
fn reap_surface_config(cli_roots: &[String]) -> Result<ReapSurfaceConfig> {
use rch_common::stale_target_reap as reap;
let rch_config = config::load_config().map_err(|e| anyhow::anyhow!("load config: {e}"))?;
let pooled = &rch_config.remediation.pooled_target;
let roots = rch_common::gc_roots::derive_gc_roots(pooled, cli_roots);
if roots.scan_bases().is_empty() {
let detail = roots
.rejected
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ");
anyhow::bail!(
"no usable gc scan root: every candidate failed the reap safety validation, so \
nothing may be embedded in a remote shell command ({detail})"
);
}
let idle_hours = pooled.reaper_idle_hours;
let pooled_idle_hours = pooled.reaper_pooled_idle_hours;
let cache_idle_days = pooled.gc_cargo_cache_idle_days;
let policy = reap::GcPolicy {
idle_secs: reap::idle_minutes_from_hours(idle_hours) * 60,
pooled_idle_secs: reap::pooled_idle_minutes_from_hours(pooled_idle_hours).map(|m| m * 60),
cache_idle_secs: reap::cargo_cache_idle_minutes_from_days(cache_idle_days).map(|m| m * 60),
};
Ok(ReapSurfaceConfig {
roots,
policy,
idle_hours,
pooled_idle_hours,
cache_idle_days,
max_cache_kb: (pooled.reaper_max_cache_gb != 0)
.then(|| u64::from(pooled.reaper_max_cache_gb) * 1024 * 1024),
})
}
const GC_COLLECT_BATCH: usize = 100;
const GC_OUTPUT_LIMIT: u64 = 10 * 1024 * 1024;
#[cfg(unix)]
const GC_REAP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
#[derive(Debug, thiserror::Error)]
#[error("{api_error}")]
struct GcFailure {
data: serde_json::Value,
api_error: ApiError,
format: OutputFormat,
}
impl GcFailure {
fn response(&self) -> ApiResponse<serde_json::Value> {
let mut response = ApiResponse::err("gc", self.api_error.clone());
response.data = Some(self.data.clone());
response
}
}
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
struct GcCommandError {
message: String,
timed_out: bool,
command_started: bool,
}
async fn collect_gc_reports<F>(
tasks: impl IntoIterator<Item = F>,
mut completed: impl FnMut(&serde_json::Value),
) -> Vec<serde_json::Value>
where
F: std::future::Future<Output = serde_json::Value>,
{
use futures::StreamExt;
let mut pending: futures::stream::FuturesUnordered<F> = tasks.into_iter().collect();
let mut reports = Vec::new();
while let Some(report) = pending.next().await {
completed(&report);
reports.push(report);
}
reports
}
fn gc_failed(reports: &[serde_json::Value]) -> bool {
reports.iter().any(|r| r["timed_out"] == true) || !reports.iter().any(|r| r["ok"] == true)
}
fn gc_deadline(seconds: u64) -> Result<tokio::time::Instant> {
tokio::time::Instant::now()
.checked_add(std::time::Duration::from_secs(seconds))
.ok_or_else(|| {
anyhow::anyhow!("--worker-timeout is too large for this platform's monotonic clock")
})
}
#[cfg(any(unix, test))]
fn gc_ssh_timed_out(exit_code: Option<i32>, stderr: &str) -> bool {
let stderr = stderr.to_ascii_lowercase();
exit_code == Some(255)
&& (stderr.contains("timed out")
|| (stderr.contains("timeout, server ") && stderr.contains("not responding")))
}
fn gc_progress(report: &serde_json::Value) -> String {
let id = report["id"].as_str().unwrap_or("?");
if report["ok"] == true {
format!(
"[rch gc] {id}: complete; {} candidate(s), {} confirmed removal(s)",
report["would_remove"].as_u64().unwrap_or(0),
report["removed"].as_u64().unwrap_or(0),
)
} else {
let outcome = if report["timed_out"] == true {
"timed out"
} else {
"failed"
};
let uncertain = if report["apply_outcome_unknown"] == true {
"; current collection batch outcome unknown"
} else {
""
};
format!(
"[rch gc] {id}: {outcome}: {}; {} confirmed removal(s){uncertain}",
report["error"].as_str().unwrap_or("unknown error"),
report["removed"].as_u64().unwrap_or(0)
)
}
}
#[cfg(unix)]
fn gc_ssh_command(
worker: &rch_common::WorkerConfig,
remote_command: &str,
) -> tokio::process::Command {
let mut command = tokio::process::Command::new("ssh");
command.env("LC_ALL", "C").env("LANG", "C");
command.args([
"-T",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=10",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
"ControlMaster=no",
"-o",
"ControlPath=none",
"-o",
"ControlPersist=no",
"-o",
"ForkAfterAuthentication=no",
"-o",
"ServerAliveInterval=2",
]);
let identity = shellexpand::tilde(&worker.identity_file);
if std::path::Path::new(identity.as_ref()).exists() {
command
.arg("-o")
.arg("IdentitiesOnly=yes")
.arg("-i")
.arg(identity.as_ref());
}
command
.arg(format!("{}@{}", worker.user, worker.host))
.arg(format!(
"sh -c {}",
shell_escape::escape(remote_command.into())
));
command
}
#[cfg(unix)]
async fn gc_read_output(
reader: impl tokio::io::AsyncRead + Unpin,
limit: u64,
) -> std::io::Result<Vec<u8>> {
use tokio::io::AsyncReadExt;
let mut bytes = Vec::new();
reader.take(limit + 1).read_to_end(&mut bytes).await?;
if bytes.len() as u64 > limit {
return Err(std::io::Error::other(format!(
"SSH output exceeded {limit} bytes; refusing incomplete GC evidence"
)));
}
Ok(bytes)
}
#[cfg(unix)]
async fn run_gc_process(
mut command: tokio::process::Command,
deadline: tokio::time::Instant,
output_budget: u64,
) -> std::result::Result<rch_common::CommandResult, GcCommandError> {
use std::process::Stdio;
if tokio::time::Instant::now() >= deadline {
return Err(GcCommandError {
message: "worker deadline expired before command started".to_string(),
timed_out: true,
command_started: false,
});
}
if output_budget == 0 {
return Err(GcCommandError {
message: "worker output budget exhausted before command started".to_string(),
timed_out: false,
command_started: false,
});
}
let started = std::time::Instant::now();
let child = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|error| GcCommandError {
message: format!("spawn SSH: {error}"),
timed_out: false,
command_started: false,
})?;
finish_gc_process(child, deadline, output_budget, started).await
}
#[cfg(unix)]
async fn finish_gc_process(
mut child: tokio::process::Child,
deadline: tokio::time::Instant,
output_budget: u64,
started: std::time::Instant,
) -> std::result::Result<rch_common::CommandResult, GcCommandError> {
let stdout = child.stdout.take().expect("piped SSH stdout");
let stderr = child.stderr.take().expect("piped SSH stderr");
let outcome = tokio::time::timeout_at(deadline, async {
tokio::try_join!(
child.wait(),
gc_read_output(stdout, output_budget),
gc_read_output(stderr, output_budget)
)
})
.await;
let mut error = match outcome {
Ok(Ok((status, stdout, stderr))) => {
if stdout.len() as u64 + stderr.len() as u64 > output_budget {
return Err(GcCommandError {
message: format!(
"combined SSH output exceeded remaining worker budget of {output_budget} bytes"
),
timed_out: false,
command_started: true,
});
}
let stdout = String::from_utf8(stdout).map_err(|error| GcCommandError {
message: format!("invalid UTF-8 in SSH stdout: {error}"),
timed_out: false,
command_started: true,
})?;
let stderr = String::from_utf8(stderr).map_err(|error| GcCommandError {
message: format!("invalid UTF-8 in SSH stderr: {error}"),
timed_out: false,
command_started: true,
})?;
if gc_ssh_timed_out(status.code(), &stderr) {
return Err(GcCommandError {
message: stderr.trim().to_string(),
timed_out: true,
command_started: true,
});
}
return Ok(rch_common::CommandResult {
exit_code: status.code().unwrap_or(-1),
stdout,
stderr,
duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
});
}
Ok(Err(error)) => GcCommandError {
message: format!("read/wait SSH: {error}"),
timed_out: false,
command_started: true,
},
Err(_) => GcCommandError {
message: "worker deadline expired while SSH command was in flight".to_string(),
timed_out: true,
command_started: true,
},
};
if let Err(kill_error) = child.start_kill() {
error.message.push_str(&format!("; SSH kill: {kill_error}"));
}
match tokio::time::timeout(GC_REAP_TIMEOUT, child.wait()).await {
Ok(Ok(_)) => {}
Ok(Err(wait_error)) => error.message.push_str(&format!("; SSH reap: {wait_error}")),
Err(_) => error
.message
.push_str("; local SSH reap exceeded 2s; kill-on-drop remains armed"),
}
Err(error)
}
#[cfg(unix)]
async fn run_gc_surface_command(
worker: &rch_common::WorkerConfig,
command: &str,
deadline: tokio::time::Instant,
output_budget: &mut u64,
) -> std::result::Result<rch_common::CommandResult, GcCommandError> {
run_gc_budgeted_process(gc_ssh_command(worker, command), deadline, output_budget).await
}
#[cfg(unix)]
async fn run_gc_budgeted_process(
command: tokio::process::Command,
deadline: tokio::time::Instant,
output_budget: &mut u64,
) -> std::result::Result<rch_common::CommandResult, GcCommandError> {
let result = run_gc_process(command, deadline, *output_budget).await?;
*output_budget -= result.stdout.len() as u64 + result.stderr.len() as u64;
Ok(result)
}
#[cfg(not(unix))]
async fn run_gc_surface_command(
_worker: &rch_common::WorkerConfig,
_command: &str,
_deadline: tokio::time::Instant,
_output_budget: &mut u64,
) -> std::result::Result<rch_common::CommandResult, GcCommandError> {
Err(GcCommandError {
message: "gc requires the Unix SSH transport".to_string(),
timed_out: false,
command_started: false,
})
}
struct GcDecision {
entry: rch_common::stale_target_reap::RemoteTargetEntry,
verdict: rch_common::stale_target_reap::GcVerdict,
trigger: &'static str,
idle_minutes: u64,
root: String,
}
async fn handle_gc(
dry_run: bool,
apply: bool,
worker_filter: Vec<String>,
cli_roots: Vec<String>,
worker_timeout: u64,
ctx: &OutputContext,
) -> Result<()> {
use rch_common::stale_target_reap as reap;
let _ = dry_run;
let style = ctx.theme();
let surface = reap_surface_config(&cli_roots)?;
let workers = selected_reap_workers(&worker_filter)?;
let bases = surface.roots.scan_bases();
let now_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let deadline = gc_deadline(worker_timeout)?;
let bases_ref = &bases;
let surface_ref = &surface;
let tasks = workers.iter().map(|worker| async move {
let bases = bases_ref;
let surface = surface_ref;
let mut output_budget = GC_OUTPUT_LIMIT;
let mut stdout = String::new();
let mut failure: Option<serde_json::Value> = None;
let mut scanned_roots: Vec<serde_json::Value> = Vec::new();
let mut gates_available = reap::GateAvailability {
handles: true,
processes: true,
};
let mut gate_runs = 0usize;
let mut temp_base: Option<String> = None;
let mut attribution: Vec<(String, String)> = Vec::new();
for base in bases {
let command = reap::enumerate_targets_command(base);
match run_gc_surface_command(worker, &command, deadline, &mut output_budget).await {
Ok(result) if result.success() => {
let seen = reap::parse_scan_roots(&result.stdout);
if seen.skipped.is_none() {
let gates = reap::parse_gate_availability(&result.stdout);
gates_available.handles &= gates.handles;
gates_available.processes &= gates.processes;
gate_runs += 1;
}
if temp_base.is_none() {
temp_base.clone_from(&seen.temp_base);
}
if let Some(resolved) = seen.resolved.clone()
&& !attribution.iter().any(|(path, _)| *path == resolved)
{
attribution.push((resolved, base.clone()));
}
scanned_roots.push(serde_json::json!({
"requested": base,
"resolved": seen.resolved,
"skipped": seen.skipped,
}));
stdout.push_str(&result.stdout);
}
Ok(result) => {
failure = Some(serde_json::json!({
"id": worker.id.as_str(),
"ok": false,
"timed_out": false,
"phase": "enumerate",
"apply_outcome_unknown": false,
"unknown_batch_paths": [],
"error": format!(
"enumeration of {base} exited {}: {}",
result.exit_code,
result.stderr.trim()
),
}));
break;
}
Err(e) => {
failure = Some(serde_json::json!({
"id": worker.id.as_str(), "ok": false,
"error": format!("{} enumeration of {base} (worker deadline {worker_timeout}s): {e}", worker.id),
"timed_out": e.timed_out, "phase": "enumerate",
"apply_outcome_unknown": false, "unknown_batch_paths": [],
}));
break;
}
}
}
if let Some(error) = failure {
return error;
}
if gate_runs == 0 {
gates_available = reap::GateAvailability::default();
}
let mut seen_paths = std::collections::HashSet::new();
let entries: Vec<reap::RemoteTargetEntry> = reap::parse_target_entries(&stdout)
.into_iter()
.filter(|e| seen_paths.insert(e.path.clone()))
.collect();
let mut verdicts: Vec<reap::GcVerdict> = entries
.iter()
.map(|e| reap::evaluate_gc_candidate(e, now_unix, &surface.policy))
.collect();
let mut triggers: Vec<&'static str> = entries.iter().map(|e| e.class().trigger()).collect();
let mut windows: Vec<u64> = entries
.iter()
.map(|e| {
surface
.policy
.window_secs(e.class())
.map_or(0, |secs| (secs / 60).max(1))
})
.collect();
if let Some(cap_kb) = surface.max_cache_kb {
let short_window = reap::idle_minutes_from_hours(surface.idle_hours);
for idx in
reap::select_cap_evictions(&entries, &verdicts, now_unix, &surface.policy, cap_kb)
{
verdicts[idx] = reap::GcVerdict::Collect;
triggers[idx] = "cap";
windows[idx] = short_window;
}
}
let temp_base_label = temp_base
.clone()
.unwrap_or_else(|| rch_common::gc_roots::WORKER_TEMP_BASE_PLACEHOLDER.to_string());
if let Some(resolved_temp) = temp_base.clone()
&& !attribution.iter().any(|(path, _)| *path == resolved_temp)
{
attribution.push((resolved_temp, temp_base_label.clone()));
}
let decisions: Vec<GcDecision> = entries
.into_iter()
.zip(verdicts)
.zip(triggers)
.zip(windows)
.map(|(((entry, verdict), trigger), idle_minutes)| {
let root = attribution
.iter()
.filter(|(path, _)| rch_common::gc_roots::path_is_under(&entry.path, path))
.max_by_key(|(path, _)| path.len())
.map_or_else(|| temp_base_label.clone(), |(_, label)| label.clone());
GcDecision {
entry,
verdict,
trigger,
idle_minutes,
root,
}
})
.collect();
let targets: Vec<reap::GcCollectTarget> = decisions
.iter()
.filter(|d| d.verdict.is_collect())
.map(|d| reap::GcCollectTarget {
path: d.entry.path.clone(),
idle_minutes: d.idle_minutes,
trigger: d.trigger,
})
.collect();
let would_free_kb: u64 = decisions
.iter()
.filter(|d| d.verdict.is_collect())
.map(|d| d.entry.kb)
.sum();
let mut removed_total: u64 = 0;
let mut freed_kb_total: u64 = 0;
let mut removed_paths: Vec<serde_json::Value> = Vec::new();
let mut rm_errors: Vec<serde_json::Value> = Vec::new();
let mut skipped: Vec<serde_json::Value> = Vec::new();
let mut apply_error: Option<String> = None;
let mut timed_out = false;
let mut unknown_batch_paths: Vec<String> = Vec::new();
let mut embeddable: Vec<reap::GcCollectTarget> = Vec::with_capacity(targets.len());
for target in &targets {
match reap::collect_target_rejection(target) {
Some(reason) if apply => skipped.push(serde_json::json!({
"path": target.path, "trigger": target.trigger, "reason": reason,
})),
Some(_) => {}
None => embeddable.push(target.clone()),
}
}
if apply && !embeddable.is_empty() {
for (batch_index, batch) in embeddable.chunks(GC_COLLECT_BATCH).enumerate() {
let command = match reap::collect_paths_command(batch) {
Ok(command) => command,
Err(e) => {
apply_error = Some(e);
break;
}
};
match run_gc_surface_command(worker, &command, deadline, &mut output_budget).await {
Ok(result) if result.success() => {
if let Some((removed, freed_kb)) =
reap::parse_worker_reap_metrics(&result.stdout)
{
removed_total += removed;
freed_kb_total += freed_kb;
} else {
apply_error = Some("collection produced no metrics line".to_string());
unknown_batch_paths = batch.iter().map(|target| target.path.clone()).collect();
}
removed_paths.extend(
reap::parse_reap_events(&result.stdout)
.into_iter()
.map(|e| {
serde_json::json!({
"path": e.path, "kb": e.kb, "trigger": e.trigger,
})
}),
);
rm_errors.extend(reap::parse_reap_errors(&result.stdout).into_iter().map(
|e| {
serde_json::json!({
"path": e.path, "trigger": e.trigger, "message": e.message,
})
},
));
skipped.extend(reap::parse_gc_skips(&result.stdout).into_iter().map(|e| {
serde_json::json!({
"path": e.path, "trigger": e.trigger, "reason": e.reason,
})
}));
if apply_error.is_some() { break; }
}
Ok(result) => {
apply_error = Some(format!(
"collection exited {}: {}",
result.exit_code,
result.stderr.trim()
));
unknown_batch_paths = batch.iter().map(|target| target.path.clone()).collect();
break;
}
Err(e) => {
apply_error = Some(format!("{} collection batch {} (worker deadline {worker_timeout}s): {e}", worker.id, batch_index + 1));
timed_out = e.timed_out;
if e.command_started {
unknown_batch_paths = batch.iter().map(|target| target.path.clone()).collect();
}
break;
}
}
}
}
let gc_claims = match run_gc_surface_command(
worker,
&reap::abandoned_gc_claims_command(apply),
deadline,
&mut output_budget,
)
.await
{
Ok(result) if result.success() => {
let (status, claims) = reap::parse_gc_claim_reports(&result.stdout);
serde_json::json!({
"status": status.unwrap_or_else(|| "no-report".to_string()),
"min_age_minutes": reap::ABANDONED_GC_CLAIM_MIN_MINUTES,
"claims": claims
.iter()
.map(|c| serde_json::json!({
"name": c.name, "path": c.path, "age_minutes": c.age_minutes,
"verdict": c.verdict, "reason": c.reason,
}))
.collect::<Vec<_>>(),
})
}
Ok(result) => serde_json::json!({
"status": "error",
"error": format!("claims scan exited {}: {}", result.exit_code, result.stderr.trim()),
"claims": [],
}),
Err(e) => serde_json::json!({
"status": "error",
"error": format!("claims scan: {e}"),
"claims": [],
}),
};
let dirs: Vec<serde_json::Value> = decisions
.iter()
.map(|d| {
let (verdict, reason) = match &d.verdict {
reap::GcVerdict::Collect => ("collect".to_string(), d.trigger.to_string()),
reap::GcVerdict::Keep(reason) => {
("keep".to_string(), format!("{}: {reason}", reason.as_str()))
}
};
serde_json::json!({
"path": d.entry.path,
"root": d.root,
"class": d.entry.class().as_str(),
"kb": d.entry.kb,
"age_secs": d.entry.age_secs(now_unix),
"open_handles": d.entry.open_handles.as_token(),
"live_process": d.entry.live_process.as_token(),
"verdict": verdict,
"reason": reason,
})
})
.collect();
serde_json::json!({
"id": worker.id.as_str(),
"ok": apply_error.is_none(),
"error": apply_error,
"timed_out": timed_out,
"phase": if apply_error.is_some() { "collect" } else { "complete" },
"apply_outcome_unknown": !unknown_batch_paths.is_empty(),
"unknown_batch_paths": unknown_batch_paths,
"gates": {
"open_descriptors": gates_available.handles,
"processes": gates_available.processes,
},
"scanned_roots": scanned_roots,
"worker_temp_base": temp_base,
"dirs": dirs,
"would_remove": targets.len(),
"would_free_kb": would_free_kb,
"removed": removed_total,
"freed_kb": freed_kb_total,
"entries": removed_paths,
"rm_errors": rm_errors,
"skipped": skipped,
"gc_claims": gc_claims,
})
});
let worker_reports =
collect_gc_reports(tasks, |report| eprintln!("{}", gc_progress(report))).await;
let failed = gc_failed(&worker_reports);
let timed_out_workers: Vec<&str> = worker_reports
.iter()
.filter(|report| report["timed_out"] == true)
.filter_map(|report| report["id"].as_str())
.collect();
let api_error = if timed_out_workers.is_empty() {
ApiError::from_code(ErrorCode::SshConnectionFailed)
.with_details("gc failed on every selected worker")
} else {
ApiError::from_code(ErrorCode::SshTimeout).with_details(format!(
"gc timed out on worker(s): {}",
timed_out_workers.join(", ")
))
};
let data = serde_json::json!({
"dry_run": !apply,
"applied": apply,
"remote_base": bases.first().cloned().unwrap_or_default(),
"scan_bases": bases,
"roots": surface
.roots
.roots
.iter()
.map(|r| serde_json::json!({ "path": r.path, "source": r.source.as_str() }))
.collect::<Vec<_>>(),
"rejected_roots": surface
.roots
.rejected
.iter()
.map(|r| serde_json::json!({
"path": r.path, "source": r.source.as_str(), "reason": r.reason,
}))
.collect::<Vec<_>>(),
"idle_hours": surface.idle_hours,
"pooled_idle_hours": surface.pooled_idle_hours,
"cargo_cache_idle_days": surface.cache_idle_days,
"worker_timeout_secs": worker_timeout,
"workers": worker_reports,
});
if ctx.is_json() {
if failed {
return Err(GcFailure {
data,
api_error,
format: ctx.format(),
}
.into());
}
ctx.json(&ApiResponse::ok("gc", &data))?;
} else {
let mode = if apply {
"collect"
} else {
"preview — nothing is removed; pass --apply to collect"
};
println!("rch gc ({mode})");
println!(
" windows: per-job {}h · pooled {} · cargo cache {}",
surface.idle_hours,
if surface.pooled_idle_hours == 0 {
"disabled".to_string()
} else {
format!("{}h", surface.pooled_idle_hours)
},
if surface.cache_idle_days == 0 {
"disabled".to_string()
} else {
format!("{}d", surface.cache_idle_days)
},
);
println!(" roots:");
for root in &surface.roots.roots {
println!(" {} [{}]", root.path, root.source);
}
for rejection in &surface.roots.rejected {
println!(" {} {rejection}", style.muted("✗"));
}
println!();
for report in data["workers"].as_array().into_iter().flatten() {
let id = report["id"].as_str().unwrap_or("?");
if report["ok"].as_bool() != Some(true) {
println!(
" {} {}: {}",
style.muted("✗"),
id,
report["error"].as_str().unwrap_or("unknown error")
);
if apply {
println!(
" confirmed: removed {} dir(s), freed {} MB",
report["removed"].as_u64().unwrap_or(0),
report["freed_kb"].as_u64().unwrap_or(0) / 1024
);
if report["apply_outcome_unknown"] == true {
println!(
" current batch outcome UNKNOWN; remote collection may have run:"
);
for path in report["unknown_batch_paths"]
.as_array()
.into_iter()
.flatten()
{
println!(" {}", path.as_str().unwrap_or("?"));
}
}
}
continue;
}
let handles_ok = report["gates"]["open_descriptors"].as_bool() == Some(true);
let procs_ok = report["gates"]["processes"].as_bool() == Some(true);
println!(
" {id}: gates open-descriptors={} processes={}",
if handles_ok { "ok" } else { "UNAVAILABLE" },
if procs_ok { "ok" } else { "UNAVAILABLE" },
);
if !handles_ok || !procs_ok {
println!(
" {} a gate that cannot be evaluated counts as in-use: pooled and \
cargo-cache dirs stay put",
style.muted("!")
);
}
for root in report["scanned_roots"].as_array().into_iter().flatten() {
if let Some(skipped) = root["skipped"].as_str() {
println!(
" {} root {} not scanned: {skipped}",
style.muted("✗"),
root["requested"].as_str().unwrap_or("?"),
);
}
}
let mut by_root: std::collections::BTreeMap<&str, Vec<&serde_json::Value>> =
std::collections::BTreeMap::new();
for dir in report["dirs"].as_array().into_iter().flatten() {
by_root
.entry(dir["root"].as_str().unwrap_or("?"))
.or_default()
.push(dir);
}
for (root, dirs) in &by_root {
println!(" {root}");
for dir in dirs {
let collect = dir["verdict"].as_str() == Some("collect");
println!(
" {:>8} MB {:<8} {:<13} idle {:>4}h {} [{}]",
dir["kb"].as_u64().unwrap_or(0) / 1024,
if collect { "COLLECT" } else { "keep" },
dir["class"].as_str().unwrap_or("?"),
dir["age_secs"].as_u64().unwrap_or(0) / 3600,
dir["path"].as_str().unwrap_or("?"),
dir["reason"].as_str().unwrap_or("?"),
);
}
}
if apply {
println!(
" → removed {} dir(s), freed {} MB",
report["removed"].as_u64().unwrap_or(0),
report["freed_kb"].as_u64().unwrap_or(0) / 1024,
);
for skip in report["skipped"].as_array().into_iter().flatten() {
println!(
" {} declined at removal time [{}] {} — {}",
style.muted("!"),
skip["trigger"].as_str().unwrap_or("?"),
skip["path"].as_str().unwrap_or("?"),
skip["reason"].as_str().unwrap_or("?"),
);
}
for e in report["rm_errors"].as_array().into_iter().flatten() {
println!(
" {} rm FAILED [{}] {} — {}",
style.muted("✗"),
e["trigger"].as_str().unwrap_or("?"),
e["path"].as_str().unwrap_or("?"),
e["message"].as_str().unwrap_or("?"),
);
}
} else {
println!(
" → would collect {} dir(s), freeing {} MB (nothing removed)",
report["would_remove"].as_u64().unwrap_or(0),
report["would_free_kb"].as_u64().unwrap_or(0) / 1024,
);
}
print_gc_claims(&report["gc_claims"], style);
}
}
if failed {
return Err(GcFailure {
data,
api_error,
format: ctx.format(),
}
.into());
}
Ok(())
}
fn print_gc_claims(report: &serde_json::Value, style: &ui::Theme) {
let claims = report["claims"].as_array().map_or(&[][..], Vec::as_slice);
match report["status"].as_str() {
Some("ok" | "none") if claims.is_empty() => return,
Some("ok") => {}
Some("error") => {
println!(
" {} GC reservations not checked: {}",
style.muted("!"),
report["error"].as_str().unwrap_or("?")
);
return;
}
other => {
println!(
" {} GC reservations not checked: {}",
style.muted("!"),
other.unwrap_or("no report")
);
return;
}
}
println!(
" GC reservations (abandoned = older than {}h, nothing references the tree):",
report["min_age_minutes"].as_u64().unwrap_or(0) / 60
);
for claim in claims {
println!(
" {:<13} {:<14} {:>5}h {}",
claim["verdict"].as_str().unwrap_or("?"),
claim["reason"].as_str().unwrap_or("?"),
claim["age_minutes"].as_u64().unwrap_or(0) / 60,
claim["path"].as_str().unwrap_or("?"),
);
}
}
#[cfg(unix)]
async fn run_reap_surface_command(
worker: &rch_common::WorkerConfig,
command: &str,
) -> Result<rch_common::CommandResult> {
let options = rch_common::SshOptions {
command_timeout: std::time::Duration::from_secs(900),
..rch_common::SshOptions::default()
};
let mut client = rch_common::SshClient::new(worker.clone(), options);
client.connect().await?;
let result = client.execute(command).await;
let _ = client.disconnect().await;
result
}
#[cfg(not(unix))]
async fn run_reap_surface_command(
_worker: &rch_common::WorkerConfig,
_command: &str,
) -> Result<rch_common::CommandResult> {
anyhow::bail!("cache reap/gc/sweep require the Unix SSH transport")
}
async fn handle_cache_status(worker_filter: Vec<String>, ctx: &OutputContext) -> Result<()> {
use rch_common::stale_target_reap as reap;
let style = ctx.theme();
let surface = reap_surface_config(&[])?;
let bases = surface.roots.scan_bases();
let idle_hours = surface.idle_hours;
let pooled_idle_hours = surface.pooled_idle_hours;
let workers = selected_reap_workers(&worker_filter)?;
let now_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let commands: Vec<(String, String)> = bases
.iter()
.map(|base| (base.clone(), reap::enumerate_targets_command(base)))
.collect();
let mut worker_reports = Vec::new();
let mut any_ok = false;
for worker in &workers {
let mut stdout = String::new();
let mut failure: Option<serde_json::Value> = None;
for (base, command) in &commands {
match run_reap_surface_command(worker, command).await {
Ok(result) if result.success() => {
stdout.push_str(&result.stdout);
}
Ok(result) => {
failure = Some(serde_json::json!({
"id": worker.id.as_str(),
"ok": false,
"error": format!(
"enumeration of {base} exited {}: {}",
result.exit_code,
result.stderr.trim()
),
}));
break;
}
Err(e) => {
failure = Some(serde_json::json!({
"id": worker.id.as_str(),
"ok": false,
"error": format!("ssh: {e}"),
}));
break;
}
}
}
let report = match failure {
None => {
any_ok = true;
let mut seen = std::collections::HashSet::new();
let entries: Vec<serde_json::Value> = reap::parse_target_entries(&stdout)
.into_iter()
.filter(|e| seen.insert(e.path.clone()))
.map(|e| {
let verdict = reap::evaluate_gc_candidate(&e, now_unix, &surface.policy);
let reason = match &verdict {
reap::GcVerdict::Collect => None,
reap::GcVerdict::Keep(r) => Some(r.to_string()),
};
serde_json::json!({
"path": e.path,
"kb": e.kb,
"age_secs": e.age_secs(now_unix),
"class": e.class().as_str(),
"pooled": e.is_pooled(),
"open_handles": e.open_handles.as_token(),
"live_process": e.live_process.as_token(),
"reapable": verdict.is_collect(),
"kept_because": reason,
})
})
.collect();
let total_kb: u64 = entries.iter().filter_map(|e| e["kb"].as_u64()).sum();
let reapable_kb: u64 = entries
.iter()
.filter(|e| e["reapable"].as_bool() == Some(true))
.filter_map(|e| e["kb"].as_u64())
.sum();
serde_json::json!({
"id": worker.id.as_str(),
"ok": true,
"entries": entries,
"total_kb": total_kb,
"reapable_kb": reapable_kb,
})
}
Some(error) => error,
};
worker_reports.push(report);
}
let data = serde_json::json!({
"remote_base": bases.first().cloned().unwrap_or_default(),
"scan_bases": bases,
"idle_hours": idle_hours,
"pooled_idle_hours": pooled_idle_hours,
"pooled_janitor": {
"prunes_on_dispatch": pooled_idle_hours != 0,
"idle_hours": pooled_idle_hours,
},
"workers": worker_reports,
});
if ctx.is_json() {
ctx.json(&ApiResponse::ok("cache status", data))?;
} else {
println!(
"Remote target dirs (idle window: {idle_hours}h, pooled: {}, bases: {})",
if pooled_idle_hours == 0 {
"never".to_string()
} else {
format!("{pooled_idle_hours}h")
},
bases.join(", ")
);
if pooled_idle_hours == 0 {
println!(
" {}\n",
style.muted(
"pooled stores are never pruned (reaper_pooled_idle_hours = 0); \
the transfer-start janitor skips them too"
)
);
} else {
println!(
" {}\n",
style.muted(&format!(
"pooled stores idle > {pooled_idle_hours}h are also pruned by the \
transfer-start janitor on the next dispatch of their project"
))
);
}
for report in data["workers"].as_array().into_iter().flatten() {
let id = report["id"].as_str().unwrap_or("?");
if report["ok"].as_bool() != Some(true) {
println!(
" {} {}: {}",
style.muted("✗"),
id,
report["error"].as_str().unwrap_or("unknown error")
);
continue;
}
let entries = report["entries"].as_array().cloned().unwrap_or_default();
println!(
" {} ({} dirs, {} MB total, {} MB reapable)",
id,
entries.len(),
report["total_kb"].as_u64().unwrap_or(0) / 1024,
report["reapable_kb"].as_u64().unwrap_or(0) / 1024,
);
for e in &entries {
let verdict = if e["reapable"].as_bool() == Some(true) {
"REAPABLE"
} else if e["pooled"].as_bool() == Some(true) {
"pooled (kept)"
} else {
"active/recent (kept)"
};
println!(
" {:>8} MB {:>8}h idle {} {}",
e["kb"].as_u64().unwrap_or(0) / 1024,
e["age_secs"].as_u64().unwrap_or(0) / 3600,
verdict,
e["path"].as_str().unwrap_or("?"),
);
}
}
}
if !any_ok {
anyhow::bail!("cache status failed on every selected worker");
}
Ok(())
}
#[cfg(unix)]
async fn handle_cache_warm(
worker_filter: Vec<String>,
project: Option<PathBuf>,
ctx: &OutputContext,
) -> Result<()> {
let style = ctx.theme();
let rch_config = config::load_config().map_err(|e| anyhow::anyhow!("load config: {e}"))?;
let topology_policy = rch_config.path_topology.to_policy();
let project_root = match project {
Some(p) => p,
None => std::env::current_dir()
.map_err(|e| anyhow::anyhow!("cannot determine project root from cwd: {e}"))?,
};
let project_root = resolve_cache_warm_project_root(project_root, &topology_policy)?;
let project_id = transfer::project_id_from_path(&project_root);
let project_hash = transfer::compute_project_hash_with_dependency_roots_and_policy(
&project_root,
&[],
&topology_policy,
);
let all_workers = commands::load_workers_from_config()
.map_err(|e| anyhow::anyhow!("load workers config: {e}"))?;
if all_workers.is_empty() {
anyhow::bail!("no workers configured; run `rch workers init` first");
}
let selected: Vec<_> = if worker_filter.is_empty() {
all_workers
} else {
let filter_set: std::collections::BTreeSet<&str> =
worker_filter.iter().map(String::as_str).collect();
let known_ids: std::collections::BTreeSet<String> =
all_workers.iter().map(|w| w.id.to_string()).collect();
let missing: Vec<&str> = filter_set
.iter()
.copied()
.filter(|id| !known_ids.contains(*id))
.collect();
if !missing.is_empty() {
return Err(unknown_worker_filter_error(
&missing,
&ctx_worker_ids(&all_workers),
));
}
all_workers
.into_iter()
.filter(|w| filter_set.contains(w.id.as_str()))
.collect()
};
tracing::info!(
target: "rch::cache::warm",
project_root = %project_root.display(),
project_id = %project_id,
project_hash = %project_hash,
worker_count = selected.len(),
"cache.warm.start",
);
if !ctx.is_json() {
eprintln!(
"{} {}",
style.format_header("Cache warm"),
style.muted(&format!(
"project={} ({}/{}) workers={}",
project_root.display(),
project_id,
&project_hash[..project_hash.len().min(12)],
selected.len(),
)),
);
}
let cache_warm_started_at = std::time::Instant::now();
let transfer_config = rch_config.transfer;
let mut results: Vec<CacheWarmWorkerResult> = Vec::with_capacity(selected.len());
let mut total_bytes: u64 = 0;
let mut total_files: u64 = 0;
let mut succeeded: usize = 0;
let mut failed: usize = 0;
for worker in &selected {
let pipeline = transfer::TransferPipeline::new(
project_root.clone(),
project_id.clone(),
project_hash.clone(),
transfer_config.clone(),
)
.with_worker_platform(transfer::WorkerPlatform::from_worker(worker))
.with_pooled_target_prune_idle_hours(
rch_config
.remediation
.pooled_target
.reaper_pooled_idle_hours,
);
let started = std::time::Instant::now();
match pipeline.sync_to_remote(worker).await {
Ok(sync_result) => {
succeeded += 1;
total_bytes += sync_result.bytes_transferred;
total_files += u64::from(sync_result.files_transferred);
tracing::info!(
target: "rch::cache::warm",
worker = %worker.id,
bytes = sync_result.bytes_transferred,
files = sync_result.files_transferred,
duration_ms = sync_result.duration_ms,
"cache.warm.worker.ok",
);
if !ctx.is_json() {
eprintln!(
" {} {}: {} bytes, {} files, {}ms",
style.format_success("✓"),
style.highlight(worker.id.as_str()),
sync_result.bytes_transferred,
sync_result.files_transferred,
sync_result.duration_ms,
);
}
results.push(CacheWarmWorkerResult {
worker_id: worker.id.to_string(),
success: true,
bytes_transferred: sync_result.bytes_transferred,
files_transferred: u64::from(sync_result.files_transferred),
duration_ms: sync_result.duration_ms,
error: None,
});
}
Err(err) => {
failed += 1;
let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
let error_msg = err.to_string();
tracing::warn!(
target: "rch::cache::warm",
worker = %worker.id,
error = %error_msg,
duration_ms,
"cache.warm.worker.failed",
);
if !ctx.is_json() {
eprintln!(
" {} {}: {}",
style.format_error("✗"),
style.highlight(worker.id.as_str()),
style.error(&error_msg),
);
}
results.push(CacheWarmWorkerResult {
worker_id: worker.id.to_string(),
success: false,
bytes_transferred: 0,
files_transferred: 0,
duration_ms,
error: Some(error_msg),
});
}
}
}
let total_duration_ms =
u64::try_from(cache_warm_started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
let response = CacheWarmResponse {
project_root: project_root.display().to_string(),
project_id: project_id.clone(),
project_hash: project_hash.clone(),
workers_total: selected.len(),
workers_succeeded: succeeded,
workers_failed: failed,
bytes_transferred: total_bytes,
files_transferred: total_files,
duration_ms: total_duration_ms,
workers: results,
};
if ctx.is_json() {
let _ = ctx.json(&ApiResponse::ok("cache.warm", &response));
} else {
eprintln!();
eprintln!(
"{} {}/{} workers warmed, {} bytes, {} files, {}ms total",
style.format_header("Summary"),
style.highlight(&succeeded.to_string()),
style.highlight(&selected.len().to_string()),
style.highlight(&total_bytes.to_string()),
style.highlight(&total_files.to_string()),
style.highlight(&total_duration_ms.to_string()),
);
}
tracing::info!(
target: "rch::cache::warm",
workers_succeeded = succeeded,
workers_failed = failed,
bytes = total_bytes,
files = total_files,
duration_ms = total_duration_ms,
"cache.warm.complete",
);
if succeeded == 0 && failed > 0 {
std::process::exit(1);
}
Ok(())
}
#[cfg(not(unix))]
async fn handle_cache_warm(
_worker_filter: Vec<String>,
_project: Option<PathBuf>,
_ctx: &OutputContext,
) -> Result<()> {
anyhow::bail!("cache warm requires the Unix SSH transport")
}
fn ctx_worker_ids(workers: &[rch_common::WorkerConfig]) -> String {
if workers.is_empty() {
return "(none)".to_string();
}
workers
.iter()
.map(|w| w.id.to_string())
.collect::<Vec<_>>()
.join(", ")
}
async fn handle_hook(action: HookAction, ctx: &OutputContext) -> Result<()> {
match action {
HookAction::Install => {
commands::hook_install(ctx)?;
}
HookAction::Uninstall { yes } => {
commands::hook_uninstall(yes, ctx)?;
}
HookAction::Test => {
commands::hook_test(ctx).await?;
}
HookAction::Status => {
commands::hook_status(ctx)?;
}
}
Ok(())
}
fn handle_shim(action: ShimAction, ctx: &OutputContext) -> Result<()> {
match action {
ShimAction::Install {
allow_local_fallback,
no_toolchains,
} => commands::shim_install(!allow_local_fallback, !no_toolchains, ctx),
ShimAction::Status => commands::shim_status(ctx),
ShimAction::Uninstall => commands::shim_uninstall(ctx),
}
}
async fn handle_agents(action: AgentsAction, ctx: &OutputContext) -> Result<()> {
match action {
AgentsAction::List { all } => {
commands::agents_list(all, ctx)?;
}
AgentsAction::Status { agent } => {
commands::agents_status(agent, ctx)?;
}
AgentsAction::InstallHook { agent, dry_run } => {
commands::agents_install_hook(&agent, dry_run, ctx)?;
}
AgentsAction::UninstallHook { agent, dry_run } => {
commands::agents_uninstall_hook(&agent, dry_run, ctx)?;
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn handle_doctor(
fix: bool,
dry_run: bool,
reliability: bool,
check_schemas: bool,
strict: bool,
lenient: bool,
scope: String,
watch: bool,
watch_interval: u64,
transitions_only: bool,
watch_snapshot: Option<PathBuf>,
runbook: Option<String>,
runbook_list: bool,
ctx: &OutputContext,
) -> Result<()> {
use crate::doctor::{DoctorOptions, ReliabilityScopeSet};
if runbook_list {
return handle_runbook_list(ctx);
}
if let Some(code) = runbook {
return handle_runbook_render(&code, ctx);
}
let scope: ReliabilityScopeSet = scope
.parse()
.map_err(|e: String| anyhow::anyhow!("invalid --scope value: {e}"))?;
let watch_interval_secs = if watch {
if watch_interval == 0 {
tracing::warn!(
target: "rch::doctor::watch",
requested = watch_interval,
clamped_to = 1u64,
"watch interval 0 would busy-loop; clamping to 1 second"
);
1
} else if watch_interval > 3600 {
tracing::warn!(
target: "rch::doctor::watch",
requested = watch_interval,
clamped_to = 3600u64,
"watch interval > 3600 is pathological; clamping to 1 hour"
);
3600
} else {
watch_interval
}
} else {
watch_interval
};
let options = DoctorOptions {
fix,
dry_run,
reliability,
check_schemas,
verbose: ctx.is_verbose(),
strict,
lenient,
scope,
watch,
watch_interval_secs,
transitions_only,
watch_snapshot,
};
crate::doctor::run_doctor(ctx, options).await
}
fn handle_runbook_render(code_str: &str, ctx: &OutputContext) -> Result<()> {
let code = rch_common::ReliabilityReasonCode::from_code_str(code_str).ok_or_else(|| {
anyhow::anyhow!(
"unknown reliability reason code: {code_str:?} \
(try `rch doctor --runbook-list` for the list of authored codes)"
)
})?;
let entry = code.runbook().ok_or_else(|| {
anyhow::anyhow!(
"no authored runbook for {code_str} ({}); this is typically a \
Pass/Info state code that doesn't need an incident runbook. \
Try `rch doctor --runbook-list` for the authored set.",
code.name()
)
})?;
let markdown = render_runbook_markdown(code, &entry);
println!("{markdown}");
tracing::info!(
target: "rch::doctor::runbook",
code = code.code(),
name = code.name(),
"doctor.runbook.rendered",
);
let _ = ctx; Ok(())
}
fn handle_runbook_list(ctx: &OutputContext) -> Result<()> {
let codes = rch_common::ReliabilityReasonCode::authored_runbook_codes();
if ctx.is_json() {
let entries: Vec<_> = codes
.iter()
.map(|c| {
serde_json::json!({
"code": c.code(),
"name": c.name(),
"category": c.category().as_str(),
})
})
.collect();
ctx.json(&rch_common::ApiResponse::ok(
"doctor.runbook.list",
serde_json::json!({
"codes": entries,
"count": entries.len(),
}),
))?;
} else {
for c in &codes {
println!("{} {} ({})", c.code(), c.name(), c.category().as_str());
}
eprintln!();
eprintln!("{} authored runbook(s)", codes.len());
}
tracing::info!(
target: "rch::doctor::runbook",
count = codes.len(),
"doctor.runbook.listed",
);
Ok(())
}
fn render_runbook_markdown(
code: rch_common::ReliabilityReasonCode,
entry: &rch_common::RunbookEntry,
) -> String {
use std::fmt::Write as _;
let mut s = String::with_capacity(2048);
let _ = writeln!(s, "# {} — {}", code.code(), code.name());
let _ = writeln!(s);
let _ = writeln!(s, "**Category:** `{}` ", code.category().as_str());
let _ = writeln!(
s,
"**Requires restart:** `{}` ",
if code.requires_restart() { "yes" } else { "no" }
);
let hint = code.remediation_hint();
if !hint.is_empty() {
let _ = writeln!(s, "**Quick hint:** {hint}");
}
let _ = writeln!(s);
let _ = writeln!(s, "## Symptoms");
let _ = writeln!(s);
for sym in entry.symptoms {
let _ = writeln!(s, "- {sym}");
}
let _ = writeln!(s);
let _ = writeln!(s, "## Diagnosis");
let _ = writeln!(s);
let _ = writeln!(s, "Run these read-only commands to confirm the cause:");
let _ = writeln!(s);
let _ = writeln!(s, "```bash");
for step in entry.diagnosis_steps {
let _ = writeln!(s, "{step}");
}
let _ = writeln!(s, "```");
let _ = writeln!(s);
let _ = writeln!(s, "## Remediation");
let _ = writeln!(s);
let _ = writeln!(s, "```bash");
for step in entry.remediation_steps {
let _ = writeln!(s, "{step}");
}
let _ = writeln!(s, "```");
let _ = writeln!(s);
let _ = writeln!(s, "## Verification");
let _ = writeln!(s);
let _ = writeln!(s, "After remediation, confirm the issue resolved with:");
let _ = writeln!(s);
let _ = writeln!(s, "```bash");
let _ = writeln!(s, "{}", entry.verification_command);
let _ = writeln!(s, "```");
let _ = writeln!(s);
let _ = writeln!(s, "## Escalation");
let _ = writeln!(s);
if let Some(esc) = entry.escalation {
let _ = writeln!(s, "{esc}");
} else {
let _ = writeln!(
s,
"If the steps above don't resolve the issue, capture full diagnostic output:"
);
let _ = writeln!(s);
let _ = writeln!(s, "```bash");
let _ = writeln!(s, "rch doctor --reliability --json > /tmp/rch-diag.json");
let _ = writeln!(s, "```");
let _ = writeln!(s);
let _ = writeln!(
s,
"Then open an issue at https://github.com/Dicklesworthstone/remote_compilation_helper/issues \
with the JSON attached."
);
}
let _ = writeln!(s);
if !entry.references.is_empty() {
let _ = writeln!(s, "## References");
let _ = writeln!(s);
for r in entry.references {
let _ = writeln!(s, "- {r}");
}
let _ = writeln!(s);
}
let _ = writeln!(
s,
"---\n*Authored {} · Generated by `rch doctor --runbook {}` · {}*",
entry.authored_at,
code.code(),
concat!("rch ", env!("CARGO_PKG_VERSION")),
);
s
}
#[allow(clippy::too_many_arguments)]
async fn handle_update(
ctx: &OutputContext,
check_only: bool,
version: Option<String>,
channel: String,
fleet: bool,
do_rollback: bool,
verify_only: bool,
yes: bool,
dry_run: bool,
skip_verify: bool,
no_restart: bool,
drain_timeout: u64,
show_changelog: bool,
) -> Result<()> {
let channel = channel
.parse::<update::Channel>()
.map_err(|e| anyhow::anyhow!(e))?;
update::run_update(
ctx,
check_only,
version,
channel,
fleet,
do_rollback,
verify_only,
dry_run,
yes,
skip_verify,
no_restart,
drain_timeout,
show_changelog,
)
.await
.map_err(|e| anyhow::anyhow!("{}", e))
}
fn handle_error_explain(sub: ErrorSubcommand, ctx: &OutputContext) -> Result<()> {
use rch_common::api::ApiError;
use rch_common::errors::{
is_known_category, known_categories, list_all, list_by_category, lookup, render_human,
};
use rch_common::{ApiResponse, ErrorCode};
match sub {
ErrorSubcommand::Explain { code, json } => {
let trimmed = code.trim();
match lookup(trimmed) {
Some(explanation) => {
if json || ctx.is_json() {
let response = ApiResponse::ok("error.explain", &explanation);
if json {
ctx.json_force(&response)?;
} else {
ctx.json(&response)?;
}
} else {
print!("{}", render_human(&explanation));
}
Ok(())
}
None => {
if json || ctx.is_json() {
let response: ApiResponse<()> = ApiResponse::err(
"error.explain",
ApiError::new(
ErrorCode::ConfigValidationError,
format!("Unknown code {trimmed:?}"),
)
.with_remediation(["Run `rch error list` to see all known codes."]),
);
if json {
ctx.json_force(&response)?;
} else {
ctx.json(&response)?;
}
}
eprintln!(
"Unknown code {trimmed:?}. Try `rch error list` to see all known codes."
);
std::process::exit(2);
}
}
}
ErrorSubcommand::List { category, json } => {
let requested_category = category.as_deref().map(str::trim).filter(|c| !c.is_empty());
let entries = match requested_category {
Some(c) => {
let entries = list_by_category(c);
if entries.is_empty() && !is_known_category(c) {
let known = known_categories();
let known_display = known.join(", ");
if json || ctx.is_json() {
let response: ApiResponse<()> = ApiResponse::err(
"error.list",
ApiError::new(
ErrorCode::ConfigValidationError,
format!("Unknown error category {c:?}"),
)
.with_context("category", c)
.with_context("known_categories", known_display.as_str())
.with_remediation([format!("Use one of: {known_display}")]),
);
if json {
ctx.json_force(&response)?;
} else {
ctx.json(&response)?;
}
}
eprintln!("Unknown error category {c:?}. Use one of: {known_display}.");
std::process::exit(2);
}
entries
}
_ => list_all(),
};
if json || ctx.is_json() {
#[derive(serde::Serialize)]
struct ListPayload {
count: usize,
codes: Vec<rch_common::CodeExplanation>,
}
let payload = ListPayload {
count: entries.len(),
codes: entries,
};
let response = ApiResponse::ok("error.list", &payload);
if json {
ctx.json_force(&response)?;
} else {
ctx.json(&response)?;
}
} else {
println!("{} known code(s):", entries.len());
for e in &entries {
println!(
" {} [{}] {} — {}",
e.code, e.category, e.name, e.description
);
}
}
Ok(())
}
}
}
fn handle_completions(action: CompletionsAction, ctx: &OutputContext) -> Result<()> {
match action {
CompletionsAction::Generate { shell } => {
clap_complete::generate(shell, &mut Cli::command(), "rch", &mut std::io::stdout());
Ok(())
}
CompletionsAction::Install { shell, dry_run } => {
let shell = shell
.or_else(completions::detect_current_shell)
.ok_or_else(|| {
anyhow::anyhow!(
"Could not detect current shell. Please specify a shell explicitly:\n\
rch completions install bash\n\
rch completions install zsh\n\
rch completions install fish"
)
})?;
completions::install_completions(shell, ctx, dry_run)?;
Ok(())
}
CompletionsAction::Uninstall { shell, dry_run } => {
completions::uninstall_completions(shell, ctx, dry_run)?;
Ok(())
}
CompletionsAction::Status => {
completions::show_status(ctx)?;
Ok(())
}
}
}
async fn handle_fleet(action: FleetAction, ctx: &OutputContext) -> Result<()> {
match action {
FleetAction::Deploy {
worker,
parallel,
canary,
canary_wait,
no_toolchain,
force,
verify,
drain_first,
drain_timeout,
dry_run,
resume,
version,
audit_log,
yes,
} => {
fleet::deploy(
ctx,
worker,
parallel,
canary,
canary_wait,
no_toolchain,
force,
verify,
drain_first,
drain_timeout,
dry_run,
resume,
version,
audit_log,
yes,
)
.await
}
FleetAction::Rollback {
worker,
to_version,
parallel,
verify,
dry_run,
yes,
} => fleet::rollback(ctx, worker, to_version, parallel, verify, dry_run, yes).await,
FleetAction::Status { worker, watch } => fleet::status(ctx, worker, watch).await,
FleetAction::Verify { worker } => fleet::verify(ctx, worker).await,
FleetAction::Drain {
worker,
all,
timeout,
yes,
} => fleet::drain(ctx, worker, all, timeout, yes).await,
FleetAction::History { limit, worker } => fleet::history(ctx, limit, worker).await,
FleetAction::Doctor {
reliability,
scope,
fix,
fleet_confirm,
continue_on_failure,
workers,
worker_timeout,
} => {
if !reliability {
ctx.error("`rch fleet doctor` currently requires --reliability");
anyhow::bail!("fleet doctor requires --reliability");
}
let scope = if scope.is_empty() {
vec!["all".to_string()]
} else {
scope
};
fleet::doctor::run(
ctx,
fleet::doctor::FleetDoctorOptions {
scope,
fix,
fleet_confirm,
continue_on_failure,
workers,
worker_timeout_secs: worker_timeout,
},
)
.await
}
}
}
fn handle_web(url: Option<String>, no_open: bool, ctx: &OutputContext) -> Result<()> {
let url = resolve_dashboard_url(url)?;
let api = dashboard_agent_endpoint(&url)?;
if !no_open {
open_browser(&url)?;
}
if ctx.is_json() {
ctx.json(&ApiResponse::ok(
"web",
serde_json::json!({
"url": url,
"agent_endpoint": api,
"opened": !no_open,
}),
))?;
} else {
ctx.info(&format!("Fleet dashboard: {url}"));
ctx.info(&format!("Agent endpoint: {api} (help needs no key)"));
}
Ok(())
}
fn resolve_dashboard_url(flag: Option<String>) -> Result<String> {
if flag.as_deref().is_some_and(|url| !url.trim().is_empty()) {
return resolve_dashboard_url_from(flag, None, None);
}
let environment = std::env::var("RCH_DASHBOARD_URL").ok();
if environment
.as_deref()
.is_some_and(|url| !url.trim().is_empty())
{
return resolve_dashboard_url_from(flag, environment, None);
}
resolve_dashboard_url_from(
flag,
environment,
config::load_config().ok().and_then(|c| c.dashboard.url),
)
}
fn resolve_dashboard_url_from(
flag: Option<String>,
env: Option<String>,
configured: Option<String>,
) -> Result<String> {
let present = |s: Option<String>| s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
let candidate = present(flag)
.or_else(|| present(env))
.or_else(|| present(configured));
match candidate {
Some(u) if u.starts_with("http://") || u.starts_with("https://") => Ok(u),
Some(u) => Err(error::WebError::InvalidDashboardUrl { url: u }.into()),
None => Err(error::WebError::NoDashboardUrl.into()),
}
}
fn dashboard_agent_endpoint(url: &str) -> Result<String> {
let mut endpoint =
reqwest::Url::parse(url).map_err(|_| error::WebError::InvalidDashboardUrl {
url: url.to_owned(),
})?;
if !matches!(endpoint.scheme(), "http" | "https") || endpoint.host_str().is_none() {
return Err(error::WebError::InvalidDashboardUrl {
url: url.to_owned(),
}
.into());
}
let path = format!("{}/api/fleet", endpoint.path().trim_end_matches('/'));
endpoint.set_path(&path);
endpoint.set_query(Some("view=help"));
endpoint.set_fragment(None);
Ok(endpoint.to_string())
}
fn open_browser(url: &str) -> Result<()> {
#[cfg(target_os = "macos")]
{
std::process::Command::new("open").arg(url).spawn()?;
Ok(())
}
#[cfg(target_os = "linux")]
{
use anyhow::Context as _;
let mut last_error = match std::process::Command::new("xdg-open").arg(url).spawn() {
Ok(_) => return Ok(()),
Err(error) => error,
};
for browser in &["firefox", "chromium", "chromium-browser", "google-chrome"] {
match std::process::Command::new(browser).arg(url).spawn() {
Ok(_) => return Ok(()),
Err(error) => last_error = error,
}
}
Err(last_error).context("Could not start a dashboard browser launcher")
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("cmd")
.args(&["/C", "start", "", url])
.spawn()?;
Ok(())
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
anyhow::bail!("Opening the dashboard browser is unsupported on this platform")
}
}
#[cfg(test)]
mod tests {
use super::*;
use rch_common::test_guard;
#[test]
fn delivered_load_failure_keeps_typed_identity_without_message_matching() {
let error = anyhow::Error::new(LoadStormReportFailure { held: 2, total: 5 })
.context("load caller context");
assert!(load_storm_report_delivered(&error));
assert!(!output_delivery_failed(&error));
assert!(format!("{error:#}").contains("storm-control invariants failed: 2/5 held"));
#[derive(Debug, thiserror::Error)]
#[error("load report caller")]
struct CallerFailure(#[source] LoadStormReportFailure);
let wrapped =
anyhow::Error::new(CallerFailure(LoadStormReportFailure { held: 1, total: 5 }));
assert!(load_storm_report_delivered(&wrapped));
assert!(!output_delivery_failed(&wrapped));
let unrelated = anyhow::anyhow!("storm-control invariants failed: 2/5 held");
assert!(!load_storm_report_delivered(&unrelated));
}
#[cfg(unix)]
#[test]
fn output_delivery_classification_retains_real_error_identity_through_context() {
use std::collections::BTreeMap;
use std::os::unix::net::UnixStream;
let (socket, peer) = UnixStream::pair().unwrap();
socket
.set_write_timeout(Some(std::time::Duration::from_secs(2)))
.unwrap();
drop(peer);
let ctx = OutputContext::with_writers(
OutputConfig {
force_mode: Some(ui::context::OutputMode::Json),
color: ui::context::ColorChoice::Never,
..Default::default()
},
ui::OutputWriter::new(socket, false),
ui::OutputWriter::stderr(),
);
let failure = ctx
.json(&serde_json::json!({"status": "observed"}))
.unwrap_err();
let error = anyhow::Error::new(failure).context("outer caller context");
assert!(output_delivery_failed(&error));
assert!(format!("{error:#}").contains("structured output could not be delivered"));
let invalid = BTreeMap::from([(vec![1_u8, 2], "unsupported key")]);
let failure = ctx.json(&invalid).unwrap_err();
assert!(matches!(&failure, ui::context::OutputError::Encoding(_)));
let error = anyhow::Error::new(failure).context("encoding caller context");
assert!(!output_delivery_failed(&error));
let retained = tempfile::tempdir().unwrap().keep();
let absent = retained.join("missing-input.json");
let failure = std::fs::File::open(&absent).unwrap_err();
assert_eq!(failure.kind(), std::io::ErrorKind::NotFound);
let error = anyhow::Error::new(failure).context("input file context");
assert!(!output_delivery_failed(&error));
assert!(!absent.exists());
}
#[test]
fn reliability_metrics_leave_hook_and_nonreporting_modes_uninstrumented() {
for args in [
vec!["rch"],
vec!["rch", "status"],
vec!["rch", "doctor"],
vec!["rch", "doctor", "--reliability", "--watch"],
vec!["rch", "doctor", "--reliability", "--runbook-list"],
vec!["rch", "doctor", "--reliability", "--runbook", "RCH-R001"],
vec!["rch", "--schema", "doctor", "--reliability"],
vec!["rch", "--robot-triage", "doctor", "--reliability"],
] {
let cli = Cli::try_parse_from(&args).expect("valid CLI");
assert!(!reliability_metrics_requested(&cli), "{args:?}");
}
for args in [
vec!["rch", "doctor", "--reliability"],
vec!["rch", "--quiet", "doctor", "--reliability", "--strict"],
vec!["rch", "doctor", "--reliability", "--scope", "schema"],
] {
let cli = Cli::try_parse_from(&args).expect("valid CLI");
assert!(reliability_metrics_requested(&cli), "{args:?}");
}
}
#[test]
fn cli_schema_exports_preserve_draft_7() {
let _guard = test_guard!();
let commands: &[&[&str]] = &[
&["config", "lint"],
&["config", "doctor"],
&["config", "diff"],
&["config", "show"],
&["config", "get", "general.enabled"],
&["config", "reset", "general.enabled"],
&["config", "validate"],
&["workers", "list"],
&["daemon", "status"],
&["diagnose", "cargo", "build"],
&["why", "miss", "--prior", "p.json", "--current", "c.json"],
&["why", "refusal", "--outcome", "first-seen"],
&["hook", "install"],
&["hook", "uninstall"],
&["hook", "status"],
];
for command in commands {
let cli = Cli::try_parse_from(
["rch", "--schema"]
.into_iter()
.chain(command.iter().copied()),
)
.unwrap();
let schema: serde_json::Value =
serde_json::from_str(&schema_json_for_command(&cli.command).unwrap()).unwrap();
assert_eq!(
schema["$schema"], "http://json-schema.org/draft-07/schema#",
"{command:?}"
);
assert!(schema.get("$defs").is_none(), "{command:?}");
assert_eq!(schema["type"], "object", "{command:?}");
}
}
#[test]
fn gc_worker_deadline_argument_is_positive_and_checked() {
let cli = Cli::try_parse_from(["rch", "gc"]).unwrap();
assert!(matches!(
cli.command,
Some(Commands::Gc {
worker_timeout: 900,
..
})
));
assert!(Cli::try_parse_from(["rch", "gc", "--worker-timeout", "0"]).is_err());
assert!(
Cli::try_parse_from(["rch", "gc", "--worker-timeout", "18446744073709551615"]).is_ok()
);
assert!(gc_deadline(u64::MAX).is_err());
assert!(gc_deadline(2).is_ok());
}
#[test]
fn gc_partial_failure_policy_and_envelope_keep_worker_evidence() {
let good = serde_json::json!({"id":"good", "ok":true, "timed_out":false});
let refused = serde_json::json!({"id":"refused", "ok":false, "timed_out":false});
let slow = serde_json::json!({"id":"slow", "ok":false, "timed_out":true});
assert!(!gc_failed(&[good.clone(), refused.clone()]));
assert!(gc_failed(std::slice::from_ref(&refused)));
assert!(gc_failed(&[good.clone(), slow.clone()]));
let failure = GcFailure {
data: serde_json::json!({"workers":[good, slow], "dry_run":true}),
api_error: ApiError::from_code(ErrorCode::SshTimeout),
format: OutputFormat::Json,
};
let encoded = serde_json::to_string(&failure.response()).unwrap();
let value: serde_json::Value = serde_json::from_str(&encoded).unwrap();
assert_eq!(value["success"], false);
assert_eq!(value["data"]["workers"][0]["id"], "good");
assert_eq!(value["data"]["workers"][1]["timed_out"], true);
assert_eq!(value["error"]["code"], "RCH-E104");
}
#[tokio::test]
async fn gc_fast_completion_is_reported_before_first_worker_deadline() {
let tasks = ["slow", "fast"].into_iter().map(|id| async move {
if id == "slow" {
assert!(
tokio::time::timeout(
std::time::Duration::from_millis(20),
std::future::pending::<()>()
)
.await
.is_err()
);
}
serde_json::json!({"id":id, "ok":id == "fast", "timed_out":id == "slow"})
});
let mut observed = Vec::new();
let reports = collect_gc_reports(tasks, |report| {
observed.push(report["id"].as_str().unwrap().to_string());
})
.await;
assert_eq!(observed, ["fast", "slow"]);
assert_eq!(reports[0]["id"], "fast");
assert!(gc_failed(&reports));
}
#[test]
fn gc_timeout_classification_covers_connect_and_keepalive() {
assert!(gc_ssh_timed_out(
Some(255),
"ssh: connect to host example port 22: Connection timed out"
));
assert!(gc_ssh_timed_out(
Some(255),
"Timeout, server example not responding."
));
assert!(!gc_ssh_timed_out(
Some(255),
"Permission denied (publickey)."
));
assert!(!gc_ssh_timed_out(
Some(0),
"operation timed out in a log file"
));
}
#[cfg(unix)]
#[test]
fn gc_ssh_is_foreground_and_keeps_remote_script_one_argument() {
let worker = rch_common::WorkerConfig {
host: "builder.example".to_string(),
user: "builder".to_string(),
..Default::default()
};
let script = "printf '%s\\n' 'a; b'";
let command = gc_ssh_command(&worker, script);
let args: Vec<_> = command
.as_std()
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
for required in [
"ControlMaster=no",
"ControlPath=none",
"ControlPersist=no",
"ForkAfterAuthentication=no",
"BatchMode=yes",
] {
assert!(
args.iter().any(|arg| arg == required),
"missing {required}: {args:?}"
);
}
let invocation = shell_words::split(args.last().unwrap()).unwrap();
assert_eq!(invocation, ["sh", "-c", script]);
assert!(!args.iter().any(|arg| arg == "-f" || arg == "-M"));
assert!(
command
.as_std()
.get_envs()
.any(|(key, value)| key == "LC_ALL" && value == Some(std::ffi::OsStr::new("C")))
);
}
#[cfg(unix)]
#[tokio::test]
async fn gc_shared_deadline_is_not_reset_between_commands() {
let deadline = gc_deadline(1).unwrap();
let mut budget = GC_OUTPUT_LIMIT;
let mut first = tokio::process::Command::new("sleep");
first.arg("0.6");
assert!(
run_gc_budgeted_process(first, deadline, &mut budget)
.await
.unwrap()
.success()
);
let mut second = tokio::process::Command::new("sleep");
second.arg("0.6");
let error = run_gc_budgeted_process(second, deadline, &mut budget)
.await
.unwrap_err();
assert!(error.timed_out && error.command_started, "{error}");
let error = run_gc_budgeted_process(
tokio::process::Command::new("rch-gc-nonexistent-command"),
deadline,
&mut budget,
)
.await
.unwrap_err();
assert!(error.timed_out && !error.command_started, "{error}");
}
#[cfg(unix)]
#[tokio::test]
async fn gc_output_budget_is_shared_and_never_truncated_into_success() {
let deadline = gc_deadline(5).unwrap();
let mut budget = 7;
let mut first = tokio::process::Command::new("printf");
first.arg("abcd");
assert_eq!(
run_gc_budgeted_process(first, deadline, &mut budget)
.await
.unwrap()
.stdout,
"abcd"
);
assert_eq!(budget, 3);
let mut second = tokio::process::Command::new("printf");
second.arg("efgh");
let error = run_gc_budgeted_process(second, deadline, &mut budget)
.await
.unwrap_err();
assert!(!error.timed_out && error.command_started);
assert!(error.message.contains("exceeded"), "{error}");
let mut combined = tokio::process::Command::new("sh");
combined.args(["-c", "printf ab; printf cd >&2"]);
let error = run_gc_process(combined, deadline, 3).await.unwrap_err();
assert!(error.message.contains("combined SSH output"), "{error}");
let error = run_gc_process(
tokio::process::Command::new("rch-gc-nonexistent-command"),
deadline,
0,
)
.await
.unwrap_err();
assert!(!error.command_started && !error.timed_out);
assert!(error.message.contains("budget exhausted"), "{error}");
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn gc_timeout_and_output_overflow_reap_the_owned_child() {
for overflow in [false, true] {
let mut command = tokio::process::Command::new(if overflow { "yes" } else { "sleep" });
if !overflow {
command.arg("10");
}
let child = command
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.unwrap();
let pid = child.id().unwrap();
let result = finish_gc_process(
child,
tokio::time::Instant::now() + std::time::Duration::from_millis(100),
32,
std::time::Instant::now(),
)
.await;
let error = result.unwrap_err();
assert_eq!(error.timed_out, !overflow, "{error}");
assert!(
!std::path::Path::new(&format!("/proc/{pid}")).exists(),
"owned child {pid} was not reaped: {error}"
);
}
}
#[test]
fn gc_failed_apply_progress_preserves_confirmed_and_unknown_work() {
let report = serde_json::json!({"id":"slow", "ok":false, "timed_out":true, "error":"batch 2 deadline", "removed":100, "apply_outcome_unknown":true});
let progress = gc_progress(&report);
assert!(progress.contains("slow: timed out"));
assert!(progress.contains("100 confirmed removal(s)"));
assert!(progress.contains("current collection batch outcome unknown"));
}
fn gc_parts(argv: &[&str]) -> (bool, bool, Vec<String>, Vec<String>) {
match Cli::try_parse_from(argv)
.expect("gc argv should parse")
.command
{
Some(Commands::Gc {
dry_run,
apply,
workers,
roots,
..
}) => (dry_run, apply, workers, roots),
_ => panic!("expected Commands::Gc for {argv:?}"), }
}
#[test]
fn gc_defaults_to_a_preview() {
let (dry_run, apply, workers, roots) = gc_parts(&["rch", "gc"]);
assert!(!apply, "a bare `rch gc` must never apply");
assert!(
!dry_run,
"the flag is off; the DEFAULT is what makes it a preview"
);
assert!(workers.is_empty());
assert!(roots.is_empty());
}
#[test]
fn gc_requires_an_explicit_apply_to_remove_anything() {
let (_, apply, _, _) = gc_parts(&["rch", "gc", "--apply"]);
assert!(apply);
let (dry_run, apply, _, _) = gc_parts(&["rch", "gc", "--dry-run"]);
assert!(dry_run && !apply);
let (dry_run, apply, _, _) = gc_parts(&["rch", "gc", "-n"]);
assert!(dry_run && !apply);
assert!(
Cli::try_parse_from(["rch", "gc", "--dry-run", "--apply"]).is_err(),
"--dry-run and --apply must conflict rather than one quietly winning"
);
}
#[test]
fn gc_accepts_repeatable_root_flags() {
let (_, _, workers, roots) = gc_parts(&[
"rch",
"gc",
"--root",
"/mnt/big/rch",
"--root",
"/srv/rch",
"--workers",
"hz2",
]);
assert_eq!(roots, vec!["/mnt/big/rch", "/srv/rch"]);
assert_eq!(workers, vec!["hz2"]);
}
#[test]
fn gc_root_set_is_configurable_and_covers_the_worker_temp_base() {
use rch_common::gc_roots::{GcRootSource, derive_gc_roots};
let pooled = rch_common::remediation_config::PooledTargetConfig {
gc_extra_roots: vec!["/mnt/big/rch".to_string()],
..Default::default()
};
let set = derive_gc_roots(&pooled, &["/srv/rch".to_string()]);
assert_eq!(
set.scan_bases(),
vec![
pooled.remote_base.clone(),
"/mnt/big/rch".to_string(),
"/srv/rch".to_string()
]
);
assert!(
set.roots
.iter()
.any(|r| r.source == GcRootSource::WorkerTempBase),
"the worker temp base — where rch-cargo-cache-* lives — must always be scanned"
);
}
#[track_caller]
fn fail_expected(message: &str) {
assert!(std::hint::black_box(false), "{message}");
}
fn assert_golden_json(
actual: serde_json::Value,
expected: &serde_json::Value,
fixture_path: &str,
) {
if &actual == expected {
return;
}
let actual_json = serde_json::to_string_pretty(&actual).expect("actual JSON renders");
let expected_json = serde_json::to_string_pretty(expected).expect("expected JSON renders");
panic! { "golden mismatch in {fixture_path}\n\
expected_blake3={}\n\
actual_blake3={}\n\
bless path: review the semantic diff, update the fixture expected_* field, \
and record both hashes in the Beads closeout.\n\
expected:\n{expected_json}\n\
actual:\n{actual_json}",
blake3::hash(expected_json.as_bytes()),
blake3::hash(actual_json.as_bytes())
};
}
#[test]
fn cli_parses_no_args() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch"]).unwrap();
assert!(cli.command.is_none());
assert!(!cli.verbose);
assert!(!cli.quiet);
assert!(!cli.json);
assert_eq!(cli.color, "auto");
}
#[test]
fn cli_parses_exec_clean_overlay_paths() {
let _guard = test_guard!();
let cli = Cli::try_parse_from([
"rch",
"exec",
"--base",
"HEAD",
"--clean-overlay",
"--overlay-path",
"src/lib.rs",
"--overlay-path",
"tests/slice.rs",
"--",
"cargo",
"test",
])
.unwrap();
match cli.command {
Some(Commands::Exec {
base,
dependency_base: _,
clean_overlay,
overlay_path,
no_overlay,
source_content_receipt,
job,
result_dir,
require_tool: _,
worker: _,
command,
}) => {
assert_eq!(base.as_deref(), Some("HEAD"));
assert!(clean_overlay);
assert_eq!(
overlay_path,
vec![PathBuf::from("src/lib.rs"), PathBuf::from("tests/slice.rs")]
);
assert!(!no_overlay);
assert!(!source_content_receipt);
assert!(!job);
assert!(result_dir.is_empty());
assert_eq!(command, vec!["cargo", "test"]);
}
_ => fail_expected("Expected clean-overlay exec command"),
}
}
#[test]
fn cli_parses_exec_clean_base_without_overlay() {
let _guard = test_guard!();
let cli = Cli::try_parse_from([
"rch",
"exec",
"--base",
"HEAD",
"--clean-overlay",
"--no-overlay",
"--dependency-base",
"../dep=HEAD",
"--",
"cargo",
"check",
])
.unwrap();
match cli.command {
Some(Commands::Exec {
base,
dependency_base,
clean_overlay,
overlay_path,
no_overlay,
source_content_receipt,
job,
result_dir,
require_tool: _,
worker: _,
command,
}) => {
assert_eq!(base.as_deref(), Some("HEAD"));
assert_eq!(dependency_base, vec!["../dep=HEAD"]);
assert!(clean_overlay);
assert!(overlay_path.is_empty());
assert!(no_overlay);
assert!(!source_content_receipt);
assert!(!job);
assert!(result_dir.is_empty());
assert_eq!(command, vec!["cargo", "check"]);
}
_ => fail_expected("Expected base-only clean-overlay exec command"),
}
}
#[test]
fn cli_rejects_clean_overlay_path_with_no_overlay() {
let _guard = test_guard!();
let result = Cli::try_parse_from([
"rch",
"exec",
"--base",
"HEAD",
"--clean-overlay",
"--overlay-path",
"src/lib.rs",
"--no-overlay",
"--",
"cargo",
"check",
]);
assert!(result.is_err());
}
#[test]
fn exec_help_declares_clean_overlay_capability_flags() {
let _guard = test_guard!();
let mut command = Cli::command();
let exec = command
.find_subcommand_mut("exec")
.expect("exec subcommand");
let help = exec.render_long_help().to_string();
for flag in [
"--worker",
"--base",
"--clean-overlay",
"--overlay-path",
"--no-overlay",
"--source-content-receipt",
"--job",
"--result-dir",
] {
assert!(help.contains(flag), "exec help missing {flag}: {help}");
}
}
#[test]
fn explicit_tools_remain_compiler_requirements_without_job_bypass() {
let _guard = test_guard!();
for verb in ["exec", "admit"] {
let cli = Cli::try_parse_from([
"rch",
verb,
"--require-tool",
"cargo-xwin",
"--require-tool",
"llvm-ar",
"--",
"cargo",
"xwin",
"build",
"--release",
])
.unwrap();
match cli.command {
Some(Commands::Exec {
require_tool, job, ..
})
| Some(Commands::Admit {
require_tool, job, ..
}) => {
assert!(!job);
assert_eq!(require_tool, vec!["cargo-xwin", "llvm-ar"]);
}
_ => fail_expected("Expected normal compiler requirements"),
}
for name in ["", "tool one", "one,two", "tool=value", "tool\n"] {
assert!(
Cli::try_parse_from([
"rch",
verb,
"--require-tool",
name,
"--",
"cargo",
"build",
])
.is_err()
);
}
}
}
#[test]
fn cli_parses_exact_exec_worker_for_native_compilation() {
let _guard = test_guard!();
let cli = Cli::try_parse_from([
"rch",
"exec",
"--worker",
"native-worker",
"--base",
"HEAD",
"--clean-overlay",
"--no-overlay",
"--",
"cargo",
"build",
"--release",
])
.unwrap();
match cli.command {
Some(Commands::Exec {
worker,
clean_overlay,
no_overlay,
command,
job,
..
}) => {
assert_eq!(worker, Some(rch_common::WorkerId::new("native-worker")));
assert!(clean_overlay && no_overlay);
assert!(!job);
assert_eq!(command, vec!["cargo", "build", "--release"]);
}
_ => fail_expected("Expected exact-worker native compilation"),
}
}
#[test]
fn cli_rejects_empty_or_multiple_exec_worker_ids() {
let _guard = test_guard!();
for worker in ["", " ", "one,two", "one two", "one\ntwo", "worker\0"] {
assert!(
Cli::try_parse_from(["rch", "exec", "--worker", worker, "--", "cargo", "build",])
.is_err(),
"accepted malformed worker {worker:?}"
);
}
}
#[test]
fn cli_parses_exec_source_content_receipt() {
let _guard = test_guard!();
let cli = Cli::try_parse_from([
"rch",
"exec",
"--source-content-receipt",
"--",
"cargo",
"check",
])
.unwrap();
match cli.command {
Some(Commands::Exec {
base,
clean_overlay,
overlay_path,
no_overlay,
source_content_receipt,
job,
result_dir,
command,
..
}) => {
assert!(base.is_none());
assert!(!clean_overlay);
assert!(overlay_path.is_empty());
assert!(!no_overlay);
assert!(source_content_receipt);
assert!(!job);
assert!(result_dir.is_empty());
assert_eq!(command, vec!["cargo", "check"]);
}
_ => fail_expected("Expected source-content receipt exec command"),
}
}
#[test]
fn cli_rejects_source_content_receipt_with_clean_overlay() {
let _guard = test_guard!();
let result = Cli::try_parse_from([
"rch",
"exec",
"--base",
"HEAD",
"--clean-overlay",
"--no-overlay",
"--source-content-receipt",
"--",
"cargo",
"check",
]);
assert!(result.is_err());
}
#[test]
fn cli_parses_exec_job_flag() {
let _guard = test_guard!();
let cli =
Cli::try_parse_from(["rch", "exec", "--job", "--", "pytest", "-x", "-q"]).unwrap();
match cli.command {
Some(Commands::Exec {
base,
clean_overlay,
overlay_path,
no_overlay,
source_content_receipt,
job,
result_dir,
command,
..
}) => {
assert!(job, "--job must parse as explicit job admission");
assert!(base.is_none());
assert!(!clean_overlay);
assert!(overlay_path.is_empty());
assert!(!no_overlay);
assert!(!source_content_receipt);
assert!(result_dir.is_empty());
assert_eq!(command, vec!["pytest", "-x", "-q"]);
}
_ => fail_expected("Expected job-mode exec command"),
}
}
#[test]
fn cli_rejects_job_with_clean_overlay() {
let _guard = test_guard!();
let result = Cli::try_parse_from([
"rch",
"exec",
"--job",
"--base",
"HEAD",
"--clean-overlay",
"--no-overlay",
"--",
"pytest",
]);
assert!(result.is_err());
}
#[test]
fn cli_parses_exec_result_dir_repeatable() {
let _guard = test_guard!();
let cli = Cli::try_parse_from([
"rch",
"exec",
"--job",
"--result-dir",
"results/shard-a",
"--result-dir",
"fuzz/corpus",
"--",
"./run_fuzzer.sh",
])
.unwrap();
match cli.command {
Some(Commands::Exec {
job,
result_dir,
command,
..
}) => {
assert!(job);
assert_eq!(
result_dir,
vec![
PathBuf::from("results/shard-a"),
PathBuf::from("fuzz/corpus")
],
"--result-dir must be repeatable and order-preserving"
);
assert_eq!(command, vec!["./run_fuzzer.sh"]);
}
_ => fail_expected("Expected job-mode exec with result dirs"),
}
}
#[test]
fn cli_rejects_result_dir_without_job() {
let _guard = test_guard!();
let result = Cli::try_parse_from([
"rch",
"exec",
"--result-dir",
"results",
"--",
"cargo",
"build",
]);
assert!(
result.is_err(),
"--result-dir without --job must be refused"
);
}
#[test]
fn cli_rejects_job_with_source_content_receipt() {
let _guard = test_guard!();
let result = Cli::try_parse_from([
"rch",
"exec",
"--job",
"--source-content-receipt",
"--",
"pytest",
]);
assert!(result.is_err());
}
#[test]
fn cli_parses_verbose_flag() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "-v"]).unwrap();
assert!(cli.verbose);
assert!(!cli.quiet);
}
#[test]
fn cli_parses_verbose_long_flag() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "--verbose"]).unwrap();
assert!(cli.verbose);
}
#[test]
fn cli_parses_quiet_flag() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "-q"]).unwrap();
assert!(cli.quiet);
assert!(!cli.verbose);
}
#[test]
fn cli_parses_quiet_long_flag() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "--quiet"]).unwrap();
assert!(cli.quiet);
}
#[test]
fn cli_parses_json_flag() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "--json"]).unwrap();
assert!(cli.json);
}
#[test]
fn cli_parses_format_flag() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "--format", "toon"]).unwrap();
assert_eq!(cli.format.as_deref(), Some("toon"));
}
#[test]
fn cli_parses_color_always() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "--color", "always"]).unwrap();
assert_eq!(cli.color, "always");
}
#[test]
fn cli_parses_color_never() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "--color", "never"]).unwrap();
assert_eq!(cli.color, "never");
}
#[test]
fn cli_parses_color_auto() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "--color", "auto"]).unwrap();
assert_eq!(cli.color, "auto");
}
#[test]
fn cli_parses_no_color_flag() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "--no-color"]).unwrap();
assert!(cli.no_color);
}
#[test]
fn cli_parses_json_typo_aliases() {
let _guard = test_guard!();
let jsno = Cli::try_parse_from(["rch", "--jsno"]).unwrap();
let jason = Cli::try_parse_from(["rch", "--jason"]).unwrap();
assert!(jsno.json);
assert!(jason.json);
}
#[test]
fn cli_parses_robot_triage_flag() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "--robot-triage"]).unwrap();
assert!(cli.robot_triage);
}
#[test]
fn cli_parses_capabilities_command() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "capabilities"]).unwrap();
assert!(matches!(cli.command, Some(Commands::Capabilities)));
}
#[test]
fn cli_parses_robot_docs_guide() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "robot-docs", "guide"]).unwrap();
assert!(matches!(
cli.command,
Some(Commands::RobotDocs {
action: RobotDocsAction::Guide
})
));
}
#[test]
fn cli_parses_daemon_start() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "daemon", "start"]).unwrap();
match cli.command {
Some(Commands::Daemon {
action: DaemonAction::Start,
}) => {}
_ => fail_expected("Expected daemon start command"),
}
}
#[test]
fn cli_parses_daemon_stop() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "daemon", "stop"]).unwrap();
match cli.command {
Some(Commands::Daemon {
action: DaemonAction::Stop { yes, .. },
}) => {
assert!(!yes, "yes should default to false");
}
_ => fail_expected("Expected daemon stop command"),
}
}
#[test]
fn cli_parses_daemon_stop_with_yes() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "daemon", "stop", "--yes"]).unwrap();
match cli.command {
Some(Commands::Daemon {
action: DaemonAction::Stop { yes, .. },
}) => {
assert!(yes, "yes should be true");
}
_ => fail_expected("Expected daemon stop --yes command"),
}
}
#[test]
fn cli_parses_daemon_restart() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "daemon", "restart"]).unwrap();
match cli.command {
Some(Commands::Daemon {
action: DaemonAction::Restart { yes, .. },
}) => {
assert!(!yes, "yes should default to false");
}
_ => fail_expected("Expected daemon restart command"),
}
}
#[test]
fn cli_parses_daemon_restart_with_yes() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "daemon", "restart", "-y"]).unwrap();
match cli.command {
Some(Commands::Daemon {
action: DaemonAction::Restart { yes, .. },
}) => {
assert!(yes, "yes should be true with -y flag");
}
_ => fail_expected("Expected daemon restart -y command"),
}
}
#[test]
fn cli_parses_daemon_status() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "daemon", "status"]).unwrap();
match cli.command {
Some(Commands::Daemon {
action: DaemonAction::Status,
}) => {}
_ => fail_expected("Expected daemon status command"),
}
}
#[test]
fn cli_parses_daemon_logs_default() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "daemon", "logs"]).unwrap();
match cli.command {
Some(Commands::Daemon {
action: DaemonAction::Logs { lines },
}) => {
assert_eq!(lines, 50);
}
_ => fail_expected("Expected daemon logs command"),
}
}
#[test]
fn cli_parses_daemon_logs_custom_lines() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "daemon", "logs", "-n", "100"]).unwrap();
match cli.command {
Some(Commands::Daemon {
action: DaemonAction::Logs { lines },
}) => {
assert_eq!(lines, 100);
}
_ => fail_expected("Expected daemon logs command"),
}
}
#[test]
fn cli_parses_workers_list() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "workers", "list"]).unwrap();
match cli.command {
Some(Commands::Workers {
action: WorkersAction::List { speedscore },
}) => {
assert!(!speedscore);
}
_ => fail_expected("Expected workers list command"),
}
}
#[test]
fn cli_parses_workers_capabilities() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "workers", "capabilities"]).unwrap();
match cli.command {
Some(Commands::Workers {
action: WorkersAction::Capabilities { refresh, command },
}) => {
assert!(!refresh);
assert!(command.is_none());
}
_ => fail_expected("Expected workers capabilities command"),
}
}
#[test]
fn cli_parses_workers_capabilities_with_flags() {
let _guard = test_guard!();
let cli = Cli::try_parse_from([
"rch",
"workers",
"capabilities",
"--refresh",
"--command",
"bun test",
])
.unwrap();
match cli.command {
Some(Commands::Workers {
action: WorkersAction::Capabilities { refresh, command },
}) => {
assert!(refresh);
assert_eq!(command.as_deref(), Some("bun test"));
}
_ => fail_expected("Expected workers capabilities command"),
}
}
#[test]
fn cli_parses_workers_probe_specific() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "workers", "probe", "css"]).unwrap();
match cli.command {
Some(Commands::Workers {
action: WorkersAction::Probe { worker, all },
}) => {
assert_eq!(worker, Some("css".to_string()));
assert!(!all);
}
_ => fail_expected("Expected workers probe command"),
}
}
#[test]
fn cli_parses_workers_probe_all() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "workers", "probe", "--all"]).unwrap();
match cli.command {
Some(Commands::Workers {
action: WorkersAction::Probe { worker, all },
}) => {
assert!(worker.is_none());
assert!(all);
}
_ => fail_expected("Expected workers probe command"),
}
}
#[test]
fn cli_parses_workers_benchmark() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "workers", "benchmark"]).unwrap();
match cli.command {
Some(Commands::Workers {
action:
WorkersAction::Benchmark {
worker_id,
all,
force,
},
}) => {
assert!(worker_id.is_none(), "default has no worker_id");
assert!(!all, "default has no --all");
assert!(!force, "default has no --force");
}
_ => fail_expected("Expected workers benchmark command"),
}
}
#[test]
fn cli_parses_workers_benchmark_with_worker_id() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "workers", "benchmark", "css"]).unwrap();
match cli.command {
Some(Commands::Workers {
action:
WorkersAction::Benchmark {
worker_id,
all: _,
force: _,
},
}) => assert_eq!(worker_id, Some("css".to_string())),
_ => fail_expected("Expected workers benchmark with worker id"),
}
}
#[test]
fn cli_parses_workers_benchmark_force() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "workers", "benchmark", "css", "--force"]).unwrap();
match cli.command {
Some(Commands::Workers {
action:
WorkersAction::Benchmark {
worker_id,
all: _,
force,
},
}) => {
assert_eq!(worker_id, Some("css".to_string()));
assert!(force);
}
_ => fail_expected("Expected workers benchmark --force"),
}
}
#[test]
fn cli_parses_workers_benchmark_all_flag() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "workers", "benchmark", "--all"]).unwrap();
match cli.command {
Some(Commands::Workers {
action:
WorkersAction::Benchmark {
worker_id,
all,
force: _,
},
}) => {
assert!(worker_id.is_none());
assert!(all);
}
_ => fail_expected("Expected workers benchmark --all"),
}
}
#[test]
fn cli_rejects_workers_benchmark_worker_id_and_all() {
let _guard = test_guard!();
let result = Cli::try_parse_from(["rch", "workers", "benchmark", "css", "--all"]);
assert!(
result.is_err(),
"worker_id and --all are mutually exclusive"
);
}
#[test]
fn cli_parses_workers_compare() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "workers", "compare", "css", "dlx"]).unwrap();
match cli.command {
Some(Commands::Workers {
action: WorkersAction::Compare { worker_ids },
}) => assert_eq!(worker_ids, vec!["css".to_string(), "dlx".to_string()]),
_ => fail_expected("Expected workers compare command"),
}
}
#[test]
fn cli_parses_workers_compare_three_workers() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "workers", "compare", "css", "dlx", "vmi1"]).unwrap();
match cli.command {
Some(Commands::Workers {
action: WorkersAction::Compare { worker_ids },
}) => assert_eq!(
worker_ids,
vec!["css".to_string(), "dlx".to_string(), "vmi1".to_string()]
),
_ => fail_expected("Expected workers compare with 3 workers"),
}
}
#[test]
fn cli_rejects_workers_compare_with_one_worker() {
let _guard = test_guard!();
let result = Cli::try_parse_from(["rch", "workers", "compare", "css"]);
assert!(
result.is_err(),
"compare requires at least 2 worker ids (num_args = 2..)"
);
}
#[test]
fn cli_parses_workers_drain() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "workers", "drain", "css"]).unwrap();
match cli.command {
Some(Commands::Workers {
action: WorkersAction::Drain { worker, .. },
}) => {
assert_eq!(worker, "css");
}
_ => fail_expected("Expected workers drain command"),
}
}
#[test]
fn cli_parses_workers_enable() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "workers", "enable", "css"]).unwrap();
match cli.command {
Some(Commands::Workers {
action: WorkersAction::Enable { worker },
}) => {
assert_eq!(worker, "css");
}
_ => fail_expected("Expected workers enable command"),
}
}
#[test]
fn cli_parses_workers_discover() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "workers", "discover", "--probe", "--add"]).unwrap();
match cli.command {
Some(Commands::Workers {
action: WorkersAction::Discover { probe, add, yes },
}) => {
assert!(probe);
assert!(add);
assert!(!yes);
}
_ => fail_expected("Expected workers discover command"),
}
}
#[test]
fn cli_parses_workers_init() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "workers", "init"]).unwrap();
match cli.command {
Some(Commands::Workers {
action: WorkersAction::Init { yes },
}) => {
assert!(!yes);
}
_ => fail_expected("Expected workers init command"),
}
}
#[test]
fn cli_parses_workers_init_with_yes() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "workers", "init", "--yes"]).unwrap();
match cli.command {
Some(Commands::Workers {
action: WorkersAction::Init { yes },
}) => {
assert!(yes);
}
_ => fail_expected("Expected workers init command with --yes"),
}
}
#[test]
fn cli_parses_workers_init_short_yes_flag() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "workers", "init", "-y"]).unwrap();
match cli.command {
Some(Commands::Workers {
action: WorkersAction::Init { yes },
}) => {
assert!(yes);
}
_ => fail_expected("Expected workers init command with -y"),
}
}
#[test]
fn cli_parses_status_default() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "status"]).unwrap();
match cli.command {
Some(Commands::Status { workers, jobs, .. }) => {
assert!(!workers);
assert!(!jobs);
}
_ => fail_expected("Expected status command"),
}
}
#[test]
fn cli_parses_status_with_workers() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "status", "--workers"]).unwrap();
match cli.command {
Some(Commands::Status { workers, jobs, .. }) => {
assert!(workers);
assert!(!jobs);
}
_ => fail_expected("Expected status command"),
}
}
#[test]
fn cli_parses_status_with_jobs() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "status", "--jobs"]).unwrap();
match cli.command {
Some(Commands::Status { workers, jobs, .. }) => {
assert!(!workers);
assert!(jobs);
}
_ => fail_expected("Expected status command"),
}
}
#[test]
fn cli_parses_status_with_both() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "status", "--workers", "--jobs"]).unwrap();
match cli.command {
Some(Commands::Status { workers, jobs, .. }) => {
assert!(workers);
assert!(jobs);
}
_ => fail_expected("Expected status command"),
}
}
#[test]
fn cli_parses_status_with_fleet() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "status", "--fleet"]).unwrap();
match cli.command {
Some(Commands::Status {
workers,
jobs,
fleet,
remediation,
}) => {
assert!(!workers);
assert!(!jobs);
assert!(fleet);
assert!(!remediation);
}
_ => fail_expected("Expected status command"),
}
}
#[test]
fn cli_parses_config_show() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "config", "show"]).unwrap();
match cli.command {
Some(Commands::Config {
action: ConfigAction::Show { sources },
}) => {
assert!(!sources);
}
_ => fail_expected("Expected config show command"),
}
}
#[test]
fn cli_parses_config_show_sources() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "config", "show", "--sources"]).unwrap();
match cli.command {
Some(Commands::Config {
action: ConfigAction::Show { sources },
}) => {
assert!(sources);
}
_ => fail_expected("Expected config show command"),
}
}
#[test]
fn cli_parses_config_get() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "config", "get", "general.enabled"]).unwrap();
match cli.command {
Some(Commands::Config {
action: ConfigAction::Get { key, sources },
}) => {
assert_eq!(key, "general.enabled");
assert!(!sources);
}
_ => fail_expected("Expected config get command"),
}
}
#[test]
fn cli_parses_config_get_sources() {
let _guard = test_guard!();
let cli =
Cli::try_parse_from(["rch", "config", "get", "general.enabled", "--sources"]).unwrap();
match cli.command {
Some(Commands::Config {
action: ConfigAction::Get { key, sources },
}) => {
assert_eq!(key, "general.enabled");
assert!(sources);
}
_ => fail_expected("Expected config get command"),
}
}
#[test]
fn cli_parses_diagnose_single_arg() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "diagnose", "cargo build --release"]).unwrap();
match cli.command {
Some(Commands::Diagnose { command, dry_run }) => {
assert_eq!(command, vec!["cargo build --release"]);
assert!(!dry_run);
}
_ => fail_expected("Expected diagnose command"),
}
}
#[test]
fn cli_parses_diagnose_multi_arg() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "diagnose", "cargo", "build", "--release"]).unwrap();
match cli.command {
Some(Commands::Diagnose { command, dry_run }) => {
assert_eq!(command, vec!["cargo", "build", "--release"]);
assert!(!dry_run);
}
_ => fail_expected("Expected diagnose command"),
}
}
#[test]
fn cli_parses_diagnose_dry_run() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "diagnose", "--dry-run", "cargo", "build"]).unwrap();
match cli.command {
Some(Commands::Diagnose { command, dry_run }) => {
assert_eq!(command, vec!["cargo", "build"]);
assert!(dry_run);
}
_ => fail_expected("Expected diagnose command"),
}
}
#[test]
fn cli_parses_diagnose_dry_run_short() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "diagnose", "-n", "cargo", "build"]).unwrap();
match cli.command {
Some(Commands::Diagnose { command, dry_run }) => {
assert_eq!(command, vec!["cargo", "build"]);
assert!(dry_run);
}
_ => fail_expected("Expected diagnose command"),
}
}
#[test]
fn cli_parses_why_miss_and_refusal() {
let _guard = test_guard!();
let cli = Cli::try_parse_from([
"rch",
"why",
"miss",
"--prior",
"p.json",
"--current",
"c.json",
])
.unwrap();
match cli.command {
Some(Commands::Why {
action: commands::why::WhyAction::Miss { prior, current },
}) => {
assert_eq!(prior, "p.json");
assert_eq!(current, "c.json");
}
_ => fail_expected("Expected why miss command"),
}
let cli =
Cli::try_parse_from(["rch", "why", "refusal", "--outcome", "first-seen"]).unwrap();
match cli.command {
Some(Commands::Why {
action: commands::why::WhyAction::Refusal { outcome },
}) => {
assert_eq!(outcome, "first-seen");
}
_ => fail_expected("Expected why refusal command"),
}
}
#[test]
fn cli_parses_config_init() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "config", "init"]).unwrap();
match cli.command {
Some(Commands::Config {
action:
ConfigAction::Init {
wizard,
non_interactive,
},
}) => {
assert!(!wizard);
assert!(!non_interactive);
}
_ => fail_expected("Expected config init command"),
}
}
#[test]
fn cli_parses_config_init_wizard() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "config", "init", "--wizard"]).unwrap();
match cli.command {
Some(Commands::Config {
action: ConfigAction::Init { wizard, .. },
}) => {
assert!(wizard);
}
_ => fail_expected("Expected config init command"),
}
}
#[test]
fn cli_parses_config_validate() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "config", "validate"]).unwrap();
match cli.command {
Some(Commands::Config {
action: ConfigAction::Validate,
}) => {}
_ => fail_expected("Expected config validate command"),
}
}
#[test]
fn cli_parses_config_set() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "config", "set", "log_level", "debug"]).unwrap();
match cli.command {
Some(Commands::Config {
action: ConfigAction::Set { key, value },
}) => {
assert_eq!(key, "log_level");
assert_eq!(value, "debug");
}
_ => fail_expected("Expected config set command"),
}
}
#[test]
fn cli_parses_config_reset() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "config", "reset", "general.enabled"]).unwrap();
match cli.command {
Some(Commands::Config {
action: ConfigAction::Reset { key },
}) => {
assert_eq!(key, "general.enabled");
}
_ => fail_expected("Expected config reset command"),
}
}
#[test]
fn cli_parses_config_export() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "config", "export"]).unwrap();
match cli.command {
Some(Commands::Config {
action: ConfigAction::Export { format },
}) => {
assert_eq!(format, "shell");
}
_ => fail_expected("Expected config export command"),
}
}
#[test]
fn cli_parses_config_lint() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "config", "lint"]).unwrap();
match cli.command {
Some(Commands::Config {
action: ConfigAction::Lint,
}) => {}
_ => fail_expected("Expected config lint command"),
}
}
#[test]
fn cli_parses_config_diff() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "config", "diff"]).unwrap();
match cli.command {
Some(Commands::Config {
action: ConfigAction::Diff,
}) => {}
_ => fail_expected("Expected config diff command"),
}
}
#[test]
fn cli_parses_hook_install() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "hook", "install"]).unwrap();
match cli.command {
Some(Commands::Hook {
action: HookAction::Install,
}) => {}
_ => fail_expected("Expected hook install command"),
}
}
#[test]
fn cli_parses_hook_uninstall() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "hook", "uninstall"]).unwrap();
match cli.command {
Some(Commands::Hook {
action: HookAction::Uninstall { .. },
}) => {}
_ => fail_expected("Expected hook uninstall command"),
}
}
#[test]
fn cli_parses_hook_test() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "hook", "test"]).unwrap();
match cli.command {
Some(Commands::Hook {
action: HookAction::Test,
}) => {}
_ => fail_expected("Expected hook test command"),
}
}
#[test]
fn runbook_renders_for_every_authored_code() {
let _guard = test_guard!();
let codes = rch_common::ReliabilityReasonCode::authored_runbook_codes();
assert!(
codes.len() >= 10,
"MVP scope authored at least 10 high-leverage codes; got {}",
codes.len()
);
for code in codes {
let entry = code
.runbook()
.expect("authored_runbook_codes only yields Some(...)");
assert!(
!entry.symptoms.is_empty(),
"{} runbook has empty symptoms",
code.code()
);
assert!(
!entry.diagnosis_steps.is_empty(),
"{} runbook has empty diagnosis_steps",
code.code()
);
assert!(
!entry.remediation_steps.is_empty(),
"{} runbook has empty remediation_steps",
code.code()
);
assert!(
!entry.verification_command.is_empty(),
"{} runbook has empty verification_command",
code.code()
);
assert!(
!entry.authored_at.is_empty(),
"{} runbook has empty authored_at",
code.code()
);
let md = render_runbook_markdown(code, &entry);
assert!(
md.contains(code.code()),
"rendered markdown for {} missing the code itself",
code.code()
);
for heading in &[
"## Symptoms",
"## Diagnosis",
"## Remediation",
"## Verification",
] {
assert!(
md.contains(heading),
"rendered markdown for {} missing heading {heading:?}",
code.code()
);
}
}
}
#[test]
fn runbook_pass_state_codes_return_none() {
let _guard = test_guard!();
assert!(
rch_common::ReliabilityReasonCode::WorkersConfigured
.runbook()
.is_none(),
"Pass-state code WorkersConfigured must not have a runbook"
);
assert!(
rch_common::ReliabilityReasonCode::SchemaCompatible
.runbook()
.is_none(),
"Pass-state code SchemaCompatible must not have a runbook"
);
}
#[test]
fn cli_parses_doctor_runbook_with_code() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "doctor", "--runbook", "RCH-R002"]).unwrap();
match cli.command {
Some(Commands::Doctor {
runbook,
runbook_list,
..
}) => {
assert_eq!(runbook, Some("RCH-R002".to_string()));
assert!(!runbook_list);
}
_ => fail_expected("Expected doctor command"),
}
}
#[test]
fn cli_parses_doctor_runbook_list_flag() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "doctor", "--runbook-list"]).unwrap();
match cli.command {
Some(Commands::Doctor {
runbook,
runbook_list,
..
}) => {
assert!(runbook.is_none());
assert!(runbook_list);
}
_ => fail_expected("Expected doctor command"),
}
}
#[test]
fn cli_rejects_doctor_runbook_with_fix() {
let _guard = test_guard!();
let result = Cli::try_parse_from([
"rch",
"doctor",
"--reliability",
"--runbook",
"RCH-R002",
"--fix",
]);
assert!(
result.is_err(),
"--runbook and --fix must be mutually exclusive"
);
}
#[test]
fn cli_rejects_doctor_runbook_with_runbook_list() {
let _guard = test_guard!();
let result =
Cli::try_parse_from(["rch", "doctor", "--runbook", "RCH-R002", "--runbook-list"]);
assert!(
result.is_err(),
"--runbook and --runbook-list must be mutually exclusive (render vs enumerate)"
);
}
#[test]
fn cli_parses_cache_warm_default() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "cache", "warm"]).unwrap();
match cli.command {
Some(Commands::Cache {
action: CacheAction::Warm { workers, project },
}) => {
assert!(workers.is_empty(), "default --workers must be empty");
assert!(project.is_none(), "default --project must be None");
}
_ => fail_expected("Expected cache warm command"),
}
}
#[test]
fn cli_parses_cache_warm_single_worker() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "cache", "warm", "--workers", "css"]).unwrap();
match cli.command {
Some(Commands::Cache {
action:
CacheAction::Warm {
workers,
project: _,
},
}) => assert_eq!(workers, vec!["css".to_string()]),
_ => fail_expected("Expected cache warm command"),
}
}
#[test]
fn cli_parses_cache_warm_multiple_workers() {
let _guard = test_guard!();
let cli = Cli::try_parse_from([
"rch",
"cache",
"warm",
"--workers",
"css",
"--workers",
"vmi1",
])
.unwrap();
match cli.command {
Some(Commands::Cache {
action:
CacheAction::Warm {
workers,
project: _,
},
}) => assert_eq!(workers, vec!["css".to_string(), "vmi1".to_string()]),
_ => fail_expected("Expected cache warm command"),
}
}
#[test]
fn cli_parses_cache_warm_with_project_path() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "cache", "warm", "--project", "/tmp/some/project"])
.unwrap();
match cli.command {
Some(Commands::Cache {
action:
CacheAction::Warm {
workers: _,
project,
},
}) => assert_eq!(project, Some(PathBuf::from("/tmp/some/project"))),
_ => fail_expected("Expected cache warm command"),
}
}
#[test]
fn cli_rejects_cache_warm_unknown_flag() {
let _guard = test_guard!();
let result = Cli::try_parse_from(["rch", "cache", "warm", "--bogus"]);
assert!(result.is_err(), "unknown flag must error");
}
fn default_topology_free_base() -> std::path::PathBuf {
use rch_common::path_topology::{
DEFAULT_ALIAS_PROJECT_ROOT, DEFAULT_CANONICAL_PROJECT_ROOT,
};
let defaults = [
std::path::Path::new(DEFAULT_CANONICAL_PROJECT_ROOT),
std::path::Path::new(DEFAULT_ALIAS_PROJECT_ROOT),
];
let mut candidates = vec![std::env::temp_dir()];
if let Some(home) = std::env::var_os("HOME") {
candidates.push(std::path::PathBuf::from(home));
}
candidates.push(std::path::PathBuf::from("/tmp"));
candidates
.into_iter()
.find(|c| c.is_dir() && !defaults.iter().any(|d| c.starts_with(d)))
.expect(
"no writable base outside the default topology roots \
(/data/projects, /dp); this test cannot express its contract here",
)
}
#[test]
fn cache_warm_project_root_uses_configured_topology() {
let _guard = test_guard!();
let temp = tempfile::TempDir::new_in(default_topology_free_base()).unwrap();
let canonical_root = temp.path().join("projects");
let alias_root = temp.path().join("alias");
let project = canonical_root.join("demo");
std::fs::create_dir_all(&project).unwrap();
let policy = rch_common::path_topology::PathTopologyPolicy::new(canonical_root, alias_root);
let resolved = resolve_cache_warm_project_root(project.clone(), &policy).unwrap();
assert_eq!(resolved, std::fs::canonicalize(&project).unwrap());
assert!(
resolve_cache_warm_project_root(
project,
&rch_common::path_topology::PathTopologyPolicy::default(),
)
.is_err(),
"cache warm must honor configured topology instead of the compiled-in default"
);
}
#[test]
fn cache_warm_project_root_rejects_file_path() {
let _guard = test_guard!();
let temp = tempfile::TempDir::new().unwrap();
let project_file = temp.path().join("Cargo.toml");
std::fs::write(&project_file, "[package]\n").unwrap();
let policy = rch_common::path_topology::PathTopologyPolicy::new(
temp.path().to_path_buf(),
temp.path().join("alias"),
);
let err = resolve_cache_warm_project_root(project_file, &policy).unwrap_err();
assert!(
err.to_string().contains("project root is not a directory"),
"unexpected error: {err}"
);
}
#[test]
fn cli_parses_doctor_default() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "doctor"]).unwrap();
match cli.command {
Some(Commands::Doctor {
fix,
dry_run,
reliability,
check_schemas,
strict,
lenient,
..
}) => {
assert!(!fix);
assert!(!dry_run);
assert!(!reliability);
assert!(!check_schemas);
assert!(!strict);
assert!(!lenient);
}
_ => fail_expected("Expected doctor command"),
}
}
#[test]
fn cli_parses_doctor_with_fix() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "doctor", "--fix"]).unwrap();
match cli.command {
Some(Commands::Doctor {
fix,
dry_run,
reliability,
check_schemas,
strict,
lenient,
..
}) => {
assert!(fix);
assert!(!dry_run);
assert!(!reliability);
assert!(!check_schemas);
assert!(!strict);
assert!(!lenient);
}
_ => fail_expected("Expected doctor command"),
}
}
#[test]
fn cli_parses_doctor_with_dry_run() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "doctor", "--dry-run"]).unwrap();
match cli.command {
Some(Commands::Doctor {
fix,
dry_run,
reliability,
check_schemas,
strict,
lenient,
..
}) => {
assert!(!fix);
assert!(dry_run);
assert!(!reliability);
assert!(!check_schemas);
assert!(!strict);
assert!(!lenient);
}
_ => fail_expected("Expected doctor command"),
}
}
#[test]
fn cli_parses_doctor_reliability() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "doctor", "--reliability"]).unwrap();
match cli.command {
Some(Commands::Doctor {
fix,
dry_run,
reliability,
check_schemas,
strict,
lenient,
..
}) => {
assert!(!fix);
assert!(!dry_run);
assert!(reliability);
assert!(!check_schemas);
assert!(!strict);
assert!(!lenient);
}
_ => fail_expected("Expected doctor command"),
}
}
#[test]
fn cli_parses_doctor_reliability_schema_check() {
let _guard = test_guard!();
let cli =
Cli::try_parse_from(["rch", "doctor", "--reliability", "--check-schemas"]).unwrap();
match cli.command {
Some(Commands::Doctor {
fix,
dry_run,
reliability,
check_schemas,
strict,
lenient,
..
}) => {
assert!(!fix);
assert!(!dry_run);
assert!(reliability);
assert!(check_schemas);
assert!(!strict);
assert!(!lenient);
}
_ => fail_expected("Expected doctor command"),
}
}
#[test]
fn cli_parses_doctor_reliability_strict_and_lenient_modes() {
let _guard = test_guard!();
let strict_cli = Cli::try_parse_from(["rch", "doctor", "--reliability", "--strict"])
.expect("strict mode should parse with reliability mode");
assert!(
matches!(
strict_cli.command,
Some(Commands::Doctor {
reliability: true,
strict: true,
lenient: false,
..
})
),
"Expected doctor command with reliability strict mode"
);
let lenient_cli = Cli::try_parse_from(["rch", "doctor", "--reliability", "--lenient"])
.expect("lenient mode should parse with reliability mode");
assert!(
matches!(
lenient_cli.command,
Some(Commands::Doctor {
reliability: true,
strict: false,
lenient: true,
..
})
),
"Expected doctor command with reliability lenient mode"
);
}
#[test]
fn cli_rejects_doctor_reliability_strict_and_lenient_together() {
let _guard = test_guard!();
let result =
Cli::try_parse_from(["rch", "doctor", "--reliability", "--strict", "--lenient"]);
assert!(
result.is_err(),
"--strict and --lenient are mutually exclusive"
);
}
#[test]
fn cli_rejects_doctor_schema_check_without_reliability() {
let _guard = test_guard!();
let result = Cli::try_parse_from(["rch", "doctor", "--check-schemas"]);
assert!(
result.is_err(),
"--check-schemas only has an effect in reliability mode"
);
if let Err(err) = result {
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
}
#[test]
fn cli_parses_doctor_reliability_watch_flags() {
let _guard = test_guard!();
let cli = Cli::try_parse_from([
"rch",
"doctor",
"--reliability",
"--watch",
"--watch-interval",
"30",
"--transitions-only",
"--watch-snapshot",
"/tmp/rch-watch.json",
])
.expect("watch mode flags should parse with reliability mode");
match cli.command {
Some(Commands::Doctor {
reliability,
watch,
watch_interval,
transitions_only,
watch_snapshot,
..
}) => {
assert!(reliability);
assert!(watch);
assert_eq!(watch_interval, 30);
assert!(transitions_only);
assert_eq!(watch_snapshot, Some(PathBuf::from("/tmp/rch-watch.json")));
}
_ => fail_expected("Expected doctor command with reliability watch mode"),
}
}
#[test]
fn cli_rejects_doctor_watch_without_reliability() {
let _guard = test_guard!();
let result = Cli::try_parse_from(["rch", "doctor", "--watch"]);
assert!(
result.is_err(),
"--watch is only valid with reliability mode"
);
if let Err(err) = result {
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
}
#[test]
fn cli_rejects_doctor_watch_with_fix() {
let _guard = test_guard!();
let result = Cli::try_parse_from(["rch", "doctor", "--reliability", "--fix", "--watch"]);
assert!(result.is_err(), "--watch and --fix are mutually exclusive");
if let Err(err) = result {
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
}
#[test]
fn cli_rejects_doctor_watch_dependents_without_watch() {
let _guard = test_guard!();
for args in [
vec!["rch", "doctor", "--reliability", "--transitions-only"],
vec![
"rch",
"doctor",
"--reliability",
"--watch-snapshot",
"/tmp/rch-watch.json",
],
] {
let result = Cli::try_parse_from(args);
assert!(result.is_err(), "watch-only flags should require --watch");
if let Err(err) = result {
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
}
}
#[test]
fn cli_parses_init_default() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "init"]).unwrap();
match cli.command {
Some(Commands::Init { yes, skip_test }) => {
assert!(!yes);
assert!(!skip_test);
}
_ => fail_expected("Expected init command"),
}
}
#[test]
fn cli_parses_init_yes() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "init", "--yes"]).unwrap();
match cli.command {
Some(Commands::Init { yes, skip_test }) => {
assert!(yes);
assert!(!skip_test);
}
_ => fail_expected("Expected init command"),
}
}
#[test]
fn cli_parses_init_skip_test() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "init", "--skip-test"]).unwrap();
match cli.command {
Some(Commands::Init { yes, skip_test }) => {
assert!(!yes);
assert!(skip_test);
}
_ => fail_expected("Expected init command"),
}
}
#[test]
fn cli_parses_setup_as_alias_for_init() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "setup"]).unwrap();
match cli.command {
Some(Commands::Init { yes, skip_test }) => {
assert!(!yes);
assert!(!skip_test);
}
_ => fail_expected("Expected init command from setup alias"),
}
}
#[test]
fn cli_parses_setup_with_flags() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "setup", "--yes", "--skip-test"]).unwrap();
match cli.command {
Some(Commands::Init { yes, skip_test }) => {
assert!(yes);
assert!(skip_test);
}
_ => fail_expected("Expected init command from setup alias with flags"),
}
}
#[test]
fn cli_parses_update_default() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "update"]).unwrap();
match cli.command {
Some(Commands::Update {
check,
version,
channel,
fleet,
..
}) => {
assert!(!check);
assert!(version.is_none());
assert_eq!(channel, "stable");
assert!(!fleet);
}
_ => fail_expected("Expected update command"),
}
}
#[test]
fn cli_parses_update_check() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "update", "--check"]).unwrap();
match cli.command {
Some(Commands::Update { check, .. }) => {
assert!(check);
}
_ => fail_expected("Expected update command"),
}
}
#[test]
fn cli_parses_upgrade_alias_check() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "upgrade", "--check"]).unwrap();
match cli.command {
Some(Commands::Update { check, .. }) => {
assert!(check);
}
_ => fail_expected("Expected update command from upgrade alias"),
}
}
#[test]
fn cli_parses_update_version() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "update", "--version", "v0.2.0"]).unwrap();
match cli.command {
Some(Commands::Update { version, .. }) => {
assert_eq!(version, Some("v0.2.0".to_string()));
}
_ => fail_expected("Expected update command"),
}
}
#[test]
fn cli_parses_update_channel() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "update", "--channel", "beta"]).unwrap();
match cli.command {
Some(Commands::Update { channel, .. }) => {
assert_eq!(channel, "beta");
}
_ => fail_expected("Expected update command"),
}
}
#[test]
fn cli_parses_update_fleet() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "update", "--fleet"]).unwrap();
match cli.command {
Some(Commands::Update { fleet, .. }) => {
assert!(fleet);
}
_ => fail_expected("Expected update command"),
}
}
#[test]
fn cli_parses_update_skip_verify() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "update", "--skip-verify"]).unwrap();
match cli.command {
Some(Commands::Update { skip_verify, .. }) => {
assert!(skip_verify);
}
_ => fail_expected("Expected update command"),
}
}
#[test]
fn cli_parses_fleet_deploy_default() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "fleet", "deploy"]).unwrap();
match cli.command {
Some(Commands::Fleet {
action:
FleetAction::Deploy {
worker,
parallel,
canary,
dry_run,
..
},
}) => {
assert!(worker.is_none());
assert_eq!(parallel, 4);
assert!(canary.is_none());
assert!(!dry_run);
}
_ => fail_expected("Expected fleet deploy command"),
}
}
#[test]
fn cli_parses_fleet_deploy_worker() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "fleet", "deploy", "--worker", "css"]).unwrap();
match cli.command {
Some(Commands::Fleet {
action: FleetAction::Deploy { worker, .. },
}) => {
assert_eq!(worker, Some("css".to_string()));
}
_ => fail_expected("Expected fleet deploy command"),
}
}
#[test]
fn cli_parses_fleet_deploy_canary() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "fleet", "deploy", "--canary", "25"]).unwrap();
match cli.command {
Some(Commands::Fleet {
action: FleetAction::Deploy { canary, .. },
}) => {
assert_eq!(canary, Some(25));
}
_ => fail_expected("Expected fleet deploy command"),
}
}
#[test]
fn cli_parses_fleet_rollback() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "fleet", "rollback"]).unwrap();
match cli.command {
Some(Commands::Fleet {
action:
FleetAction::Rollback {
worker, to_version, ..
},
}) => {
assert!(worker.is_none());
assert!(to_version.is_none());
}
_ => fail_expected("Expected fleet rollback command"),
}
}
#[test]
fn cli_parses_fleet_status() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "fleet", "status"]).unwrap();
match cli.command {
Some(Commands::Fleet {
action: FleetAction::Status { worker, watch },
}) => {
assert!(worker.is_none());
assert!(!watch);
}
_ => fail_expected("Expected fleet status command"),
}
}
#[test]
fn cli_parses_fleet_verify() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "fleet", "verify"]).unwrap();
match cli.command {
Some(Commands::Fleet {
action: FleetAction::Verify { worker },
}) => {
assert!(worker.is_none());
}
_ => fail_expected("Expected fleet verify command"),
}
}
#[test]
fn cli_parses_fleet_history() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "fleet", "history", "--limit", "20"]).unwrap();
match cli.command {
Some(Commands::Fleet {
action: FleetAction::History { limit, worker },
}) => {
assert_eq!(limit, 20);
assert!(worker.is_none());
}
_ => fail_expected("Expected fleet history command"),
}
}
#[test]
fn cli_parses_dashboard_default() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "dashboard"]).unwrap();
match cli.command {
Some(Commands::Dashboard {
refresh,
no_mouse,
test_mode,
mock_data,
dump_state,
high_contrast,
color_blind,
}) => {
assert_eq!(refresh, 1000);
assert!(!no_mouse);
assert!(!test_mode);
assert!(!mock_data);
assert!(!dump_state);
assert!(!high_contrast);
assert_eq!(color_blind, tui::ColorBlindMode::None);
}
_ => fail_expected("Expected dashboard command"),
}
}
#[test]
fn cli_parses_dashboard_custom_refresh() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "dashboard", "--refresh", "500"]).unwrap();
match cli.command {
Some(Commands::Dashboard { refresh, .. }) => {
assert_eq!(refresh, 500);
}
_ => fail_expected("Expected dashboard command"),
}
}
#[test]
fn cli_parses_web_default() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "web"]).unwrap();
match cli.command {
Some(Commands::Web { url, no_open }) => {
assert!(url.is_none());
assert!(!no_open);
}
_ => fail_expected("Expected web command"),
}
}
#[test]
fn cli_parses_web_url_and_no_open() {
let _guard = test_guard!();
let cli =
Cli::try_parse_from(["rch", "web", "--url", "https://x.example", "--no-open"]).unwrap();
match cli.command {
Some(Commands::Web { url, no_open }) => {
assert_eq!(url.as_deref(), Some("https://x.example"));
assert!(no_open);
}
_ => fail_expected("Expected web command"),
}
}
#[test]
fn cli_rejects_the_retired_web_port_flag() {
let _guard = test_guard!();
assert!(Cli::try_parse_from(["rch", "web", "--port", "3001"]).is_err());
assert!(Cli::try_parse_from(["rch", "web", "--prod"]).is_err());
}
#[test]
fn cli_parses_error_explain_json() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "error", "explain", "RCH-R104", "--json"]).unwrap();
match cli.command {
Some(Commands::Error {
sub: ErrorSubcommand::Explain { code, json },
}) => {
assert_eq!(code, "RCH-R104");
assert!(json);
}
_ => fail_expected("Expected error explain command"),
}
}
#[test]
fn cli_parses_error_list_category() {
let _guard = test_guard!();
let cli =
Cli::try_parse_from(["rch", "error", "list", "--category", "disk_pressure"]).unwrap();
match cli.command {
Some(Commands::Error {
sub: ErrorSubcommand::List { category, json },
}) => {
assert_eq!(category.as_deref(), Some("disk_pressure"));
assert!(!json);
}
_ => fail_expected("Expected error list command"),
}
}
#[test]
fn cli_parses_speedscore_single_worker() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "speedscore", "css"]).unwrap();
assert!(!cli.verbose);
match cli.command {
Some(Commands::SpeedScore {
worker,
all,
history,
days,
limit,
}) => {
assert_eq!(worker, Some("css".to_string()));
assert!(!all);
assert!(!history);
assert_eq!(days, 30);
assert_eq!(limit, 20);
}
_ => fail_expected("Expected speedscore command"),
}
}
#[test]
fn cli_parses_speedscore_all() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "speedscore", "--all"]).unwrap();
match cli.command {
Some(Commands::SpeedScore { all, worker, .. }) => {
assert!(all);
assert!(worker.is_none());
}
_ => fail_expected("Expected speedscore --all command"),
}
}
#[test]
fn cli_parses_speedscore_verbose() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "speedscore", "css", "--verbose"]).unwrap();
assert!(cli.verbose, "Global verbose flag should be set");
match cli.command {
Some(Commands::SpeedScore { worker, .. }) => {
assert_eq!(worker, Some("css".to_string()));
}
_ => fail_expected("Expected speedscore --verbose command"),
}
}
#[test]
fn cli_parses_speedscore_history() {
let _guard = test_guard!();
let cli =
Cli::try_parse_from(["rch", "speedscore", "css", "--history", "--days", "7"]).unwrap();
match cli.command {
Some(Commands::SpeedScore {
worker,
history,
days,
..
}) => {
assert_eq!(worker, Some("css".to_string()));
assert!(history);
assert_eq!(days, 7);
}
_ => fail_expected("Expected speedscore --history command"),
}
}
#[test]
fn cli_parses_speedscore_short_verbose() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "speedscore", "css", "-v"]).unwrap();
assert!(cli.verbose, "Global verbose flag should be set with -v");
match cli.command {
Some(Commands::SpeedScore { .. }) => {}
_ => fail_expected("Expected speedscore -v command"),
}
}
#[test]
fn cli_parses_completions_generate_bash() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "completions", "generate", "bash"]).unwrap();
match cli.command {
Some(Commands::Completions {
action: CompletionsAction::Generate { shell },
}) => {
assert_eq!(shell, clap_complete::Shell::Bash);
}
_ => fail_expected("Expected completions generate command"),
}
}
#[test]
fn cli_parses_completions_install() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "completions", "install", "zsh"]).unwrap();
match cli.command {
Some(Commands::Completions {
action: CompletionsAction::Install { shell, dry_run },
}) => {
assert_eq!(shell, Some(clap_complete::Shell::Zsh));
assert!(!dry_run);
}
_ => fail_expected("Expected completions install command"),
}
}
#[test]
fn cli_parses_completions_status() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "completions", "status"]).unwrap();
match cli.command {
Some(Commands::Completions {
action: CompletionsAction::Status,
}) => {}
_ => fail_expected("Expected completions status command"),
}
}
#[test]
fn cli_parses_agents_list() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "agents", "list"]).unwrap();
match cli.command {
Some(Commands::Agents {
action: AgentsAction::List { all },
}) => {
assert!(!all);
}
_ => fail_expected("Expected agents list command"),
}
}
#[test]
fn cli_parses_agents_list_all() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "agents", "list", "--all"]).unwrap();
match cli.command {
Some(Commands::Agents {
action: AgentsAction::List { all },
}) => {
assert!(all);
}
_ => fail_expected("Expected agents list command"),
}
}
#[test]
fn cli_parses_agents_status() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "agents", "status"]).unwrap();
match cli.command {
Some(Commands::Agents {
action: AgentsAction::Status { agent },
}) => {
assert!(agent.is_none());
}
_ => fail_expected("Expected agents status command"),
}
}
#[test]
fn cli_parses_agents_install_hook() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "agents", "install-hook", "claude-code"]).unwrap();
match cli.command {
Some(Commands::Agents {
action: AgentsAction::InstallHook { agent, dry_run },
}) => {
assert_eq!(agent, "claude-code");
assert!(!dry_run);
}
_ => fail_expected("Expected agents install-hook command"),
}
}
#[test]
fn cli_global_flags_with_subcommand() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "-v", "--json", "daemon", "status"]).unwrap();
assert!(cli.verbose);
assert!(cli.json);
match cli.command {
Some(Commands::Daemon {
action: DaemonAction::Status,
}) => {}
_ => fail_expected("Expected daemon status command"),
}
}
#[test]
fn cli_global_flags_after_subcommand() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "daemon", "status", "-v", "--json"]).unwrap();
assert!(cli.verbose);
assert!(cli.json);
}
#[test]
fn cli_rejects_unknown_subcommand() {
let _guard = test_guard!();
let result = Cli::try_parse_from(["rch", "unknown"]);
assert!(result.is_err());
}
#[test]
fn cli_rejects_invalid_color_option() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "--color", "invalid"]).unwrap();
assert_eq!(cli.color, "invalid");
}
#[test]
fn cli_daemon_requires_action() {
let _guard = test_guard!();
let result = Cli::try_parse_from(["rch", "daemon"]);
assert!(result.is_err());
}
#[test]
fn cli_workers_requires_action() {
let _guard = test_guard!();
let result = Cli::try_parse_from(["rch", "workers"]);
assert!(result.is_err());
}
#[test]
fn cli_config_requires_action() {
let _guard = test_guard!();
let result = Cli::try_parse_from(["rch", "config"]);
assert!(result.is_err());
}
#[test]
fn cli_hook_requires_action() {
let _guard = test_guard!();
let result = Cli::try_parse_from(["rch", "hook"]);
assert!(result.is_err());
}
#[test]
fn color_choice_parse_auto() {
let _guard = test_guard!();
let choice = ColorChoice::parse("auto");
assert_eq!(choice, ColorChoice::Auto);
}
#[test]
fn color_choice_parse_always() {
let _guard = test_guard!();
let choice = ColorChoice::parse("always");
assert_eq!(choice, ColorChoice::Always);
}
#[test]
fn color_choice_parse_never() {
let _guard = test_guard!();
let choice = ColorChoice::parse("never");
assert_eq!(choice, ColorChoice::Never);
}
#[test]
fn color_choice_parse_unknown_defaults_to_auto() {
let _guard = test_guard!();
let choice = ColorChoice::parse("invalid");
assert_eq!(choice, ColorChoice::Auto);
}
#[test]
fn output_config_default_values() {
let _guard = test_guard!();
let config = OutputConfig::default();
assert!(!config.json);
assert!(!config.verbose);
assert!(!config.quiet);
assert_eq!(config.format, OutputFormat::Json);
}
#[test]
fn output_config_from_cli_args_verbose() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "-v"]).unwrap();
let format = resolve_output_format(cli.format.as_deref(), cli.json);
let machine = machine_output_requested(cli.format.as_deref(), cli.json);
let config = OutputConfig {
json: machine,
format,
verbose: cli.verbose,
quiet: cli.quiet,
color: ColorChoice::parse(&cli.color),
..Default::default()
};
assert!(config.verbose);
assert!(!config.quiet);
assert!(!config.json);
}
#[test]
fn output_config_from_cli_args_json() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "--json"]).unwrap();
let format = resolve_output_format(cli.format.as_deref(), cli.json);
let machine = machine_output_requested(cli.format.as_deref(), cli.json);
let config = OutputConfig {
json: machine,
format,
verbose: cli.verbose,
quiet: cli.quiet,
color: ColorChoice::parse(&cli.color),
..Default::default()
};
assert!(config.json);
assert!(!config.verbose);
}
#[test]
fn output_config_from_cli_args_quiet() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "-q"]).unwrap();
let format = resolve_output_format(cli.format.as_deref(), cli.json);
let machine = machine_output_requested(cli.format.as_deref(), cli.json);
let config = OutputConfig {
json: machine,
format,
verbose: cli.verbose,
quiet: cli.quiet,
color: ColorChoice::parse(&cli.color),
..Default::default()
};
assert!(config.quiet);
assert!(!config.verbose);
}
#[test]
fn output_format_resolves_to_toon() {
let _guard = test_guard!();
let format = resolve_output_format(Some("toon"), false);
let machine = machine_output_requested(Some("toon"), false);
assert_eq!(format, OutputFormat::Toon);
assert!(machine);
}
#[test]
fn output_context_creation_from_config() {
let _guard = test_guard!();
let config = OutputConfig {
json: true,
verbose: true,
quiet: false,
color: ColorChoice::Never,
..Default::default()
};
let ctx = OutputContext::new(config);
assert!(ctx.is_json());
assert!(ctx.is_verbose());
assert!(!ctx.is_quiet());
}
#[test]
fn output_context_is_verbose_false_by_default() {
let _guard = test_guard!();
let ctx = OutputContext::new(OutputConfig::default());
assert!(!ctx.is_verbose());
}
#[test]
fn output_context_is_quiet_false_by_default() {
let _guard = test_guard!();
let ctx = OutputContext::new(OutputConfig::default());
assert!(!ctx.is_quiet());
}
#[test]
fn resolve_dashboard_url_precedence_is_flag_env_config() {
let _guard = test_guard!();
let f = |a: &str, b: &str, c: &str| {
resolve_dashboard_url_from(Some(a.into()), Some(b.into()), Some(c.into()))
};
assert_eq!(
f("https://flag", "https://env", "https://cfg").unwrap(),
"https://flag"
);
assert_eq!(
f(" ", "https://env", "https://cfg").unwrap(),
"https://env"
);
assert_eq!(f("", "", "https://cfg").unwrap(), "https://cfg");
assert_eq!(
resolve_dashboard_url_from(
Some(" https://rch-fleet.vercel.app \n".into()),
None,
None
)
.unwrap(),
"https://rch-fleet.vercel.app"
);
}
#[test]
fn resolve_dashboard_url_rejects_non_http_and_names_the_value() {
let _guard = test_guard!();
let err = resolve_dashboard_url_from(Some("rch-fleet.vercel.app".into()), None, None)
.unwrap_err();
let msg = format!("{err:?}");
assert!(msg.contains("rch-fleet.vercel.app"), "{msg}");
assert!(msg.contains("http"), "{msg}");
}
#[test]
fn resolve_dashboard_url_without_any_source_explains_how_to_set_one() {
let _guard = test_guard!();
let err = resolve_dashboard_url_from(Some(" ".into()), None, None).unwrap_err();
assert!(
matches!(
err.downcast_ref::<error::WebError>(),
Some(error::WebError::NoDashboardUrl)
),
"expected WebError::NoDashboardUrl, got {err:?}"
);
}
#[test]
fn dashboard_agent_endpoint_preserves_path_and_replaces_ui_query_and_fragment() {
for (url, expected) in [
(
"https://dashboard.example.invalid",
"https://dashboard.example.invalid/api/fleet?view=help",
),
(
"https://dashboard.example.invalid/fleet/?tab=workers#selection",
"https://dashboard.example.invalid/fleet/api/fleet?view=help",
),
(
"https://dashboard.example.invalid/?tab=workers#selection",
"https://dashboard.example.invalid/api/fleet?view=help",
),
(
"https://dashboard.example.invalid/team%20α/?view=old#selection",
"https://dashboard.example.invalid/team%20%CE%B1/api/fleet?view=help",
),
(
"http://[::1]:8080/fleet?tab=workers#selection",
"http://[::1]:8080/fleet/api/fleet?view=help",
),
] {
assert_eq!(dashboard_agent_endpoint(url).unwrap(), expected, "{url}");
}
for url in [
"https://",
"http://[::1",
"https://dashboard.example.invalid:99999",
"ftp://dashboard.example.invalid/fleet",
] {
let error = dashboard_agent_endpoint(url).unwrap_err();
assert!(matches!(
error.downcast_ref::<error::WebError>(),
Some(error::WebError::InvalidDashboardUrl { url: rejected }) if rejected == url
));
}
}
#[cfg(unix)]
#[test]
fn web_no_open_delivers_formats_and_propagates_real_output_failures() {
use std::os::unix::net::UnixStream;
let retained = tempfile::tempdir().unwrap().keep();
let url = "https://dashboard.example.invalid/fleet/";
for format in [OutputFormat::Json, OutputFormat::Toon] {
for closed in [false, true] {
let path = retained.join(format!("{format:?}-{closed}.output"));
let stdout = if closed {
let (socket, peer) = UnixStream::pair().unwrap();
socket
.set_write_timeout(Some(std::time::Duration::from_secs(2)))
.unwrap();
drop(peer);
ui::OutputWriter::new(socket, false)
} else {
ui::OutputWriter::new(std::fs::File::create_new(&path).unwrap(), false)
};
let ctx = OutputContext::with_writers(
OutputConfig {
force_mode: Some(ui::context::OutputMode::Json),
color: ui::context::ColorChoice::Never,
format,
..Default::default()
},
stdout,
ui::OutputWriter::stderr(),
);
let result = handle_web(Some(url.into()), true, &ctx);
if closed {
let error = result.unwrap_err();
assert!(output_delivery_failed(&error));
assert!(matches!(
error.downcast_ref::<ui::context::OutputError>(),
Some(ui::context::OutputError::Delivery(cause))
if cause.kind() == std::io::ErrorKind::BrokenPipe
));
assert!(!path.exists());
} else {
result.unwrap();
let bytes = std::fs::read(path).unwrap();
let report = if format == OutputFormat::Toon {
serde_json::from_str::<serde_json::Value>(
&toon_rust::toon_to_json(std::str::from_utf8(&bytes).unwrap()).unwrap(),
)
.unwrap()
} else {
serde_json::from_slice::<serde_json::Value>(&bytes).unwrap()
};
assert_eq!(report["api_version"], "1.0");
assert_eq!(report["command"], "web");
assert_eq!(report["success"], true);
assert!(report["error"].is_null());
assert_eq!(
report["data"],
serde_json::json!({
"url": url,
"agent_endpoint": "https://dashboard.example.invalid/fleet/api/fleet?view=help",
"opened": false,
})
);
}
}
}
assert_eq!(std::fs::read_dir(retained).unwrap().count(), 2);
}
#[test]
fn cli_command_debug_assert_passes() {
let _guard = test_guard!();
use clap::CommandFactory;
Cli::command().debug_assert();
}
#[test]
fn cli_has_version() {
let _guard = test_guard!();
use clap::CommandFactory;
let cmd = Cli::command();
assert!(cmd.get_version().is_some());
}
#[test]
fn cli_has_about() {
let _guard = test_guard!();
use clap::CommandFactory;
let cmd = Cli::command();
assert!(cmd.get_about().is_some());
}
#[test]
fn cli_has_after_help_with_examples() {
let _guard = test_guard!();
use clap::CommandFactory;
let cmd = Cli::command();
let after_help = cmd
.get_after_help()
.map(|s| s.to_string())
.unwrap_or_default();
assert!(after_help.contains("EXAMPLES:"));
assert!(after_help.contains("ENVIRONMENT VARIABLES:"));
assert!(after_help.contains("CONFIG PRECEDENCE"));
}
#[test]
fn cli_subcommands_have_help() {
let _guard = test_guard!();
use clap::CommandFactory;
let cmd = Cli::command();
let subcommands: Vec<_> = cmd.get_subcommands().collect();
assert!(!subcommands.is_empty());
let names: Vec<_> = subcommands
.iter()
.filter_map(|c| c.get_name().into())
.collect();
assert!(names.contains(&"daemon"));
assert!(names.contains(&"workers"));
assert!(names.contains(&"status"));
assert!(names.contains(&"config"));
assert!(names.contains(&"hook"));
}
#[test]
fn cli_parses_queue_default() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "queue"]).unwrap();
match cli.command {
Some(Commands::Queue { watch, follow }) => {
assert!(!watch);
assert!(!follow);
}
_ => fail_expected("Expected queue command"),
}
}
#[test]
fn cli_parses_queue_watch() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "queue", "--watch"]).unwrap();
match cli.command {
Some(Commands::Queue { watch, .. }) => {
assert!(watch);
}
_ => fail_expected("Expected queue command with watch"),
}
}
#[test]
fn cli_parses_queue_watch_short() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "queue", "-w"]).unwrap();
match cli.command {
Some(Commands::Queue { watch, .. }) => {
assert!(watch);
}
_ => fail_expected("Expected queue command with -w"),
}
}
#[test]
fn cli_parses_queue_follow() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "queue", "--follow"]).unwrap();
match cli.command {
Some(Commands::Queue { follow, .. }) => {
assert!(follow);
}
_ => fail_expected("Expected queue command with follow"),
}
}
#[test]
fn cli_parses_queue_follow_short() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "queue", "-f"]).unwrap();
match cli.command {
Some(Commands::Queue { follow, .. }) => {
assert!(follow);
}
_ => fail_expected("Expected queue command with -f"),
}
}
#[test]
fn cli_parses_queue_watch_and_follow() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "queue", "-wf"]).unwrap();
match cli.command {
Some(Commands::Queue { watch, follow }) => {
assert!(watch);
assert!(follow);
}
_ => fail_expected("Expected queue command with watch and follow"),
}
}
#[test]
fn cli_parses_cancel_by_id() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "cancel", "42"]).unwrap();
match cli.command {
Some(Commands::Cancel {
build_id,
all,
force,
yes,
dry_run,
}) => {
assert_eq!(build_id, Some(42));
assert!(!all);
assert!(!force);
assert!(!yes);
assert!(!dry_run);
}
_ => fail_expected("Expected cancel command"),
}
}
#[test]
fn cli_parses_cancel_all() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "cancel", "--all"]).unwrap();
match cli.command {
Some(Commands::Cancel { all, .. }) => {
assert!(all);
}
_ => fail_expected("Expected cancel --all command"),
}
}
#[test]
fn cli_parses_cancel_all_yes() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "cancel", "--all", "--yes"]).unwrap();
match cli.command {
Some(Commands::Cancel { all, yes, .. }) => {
assert!(all);
assert!(yes);
}
_ => fail_expected("Expected cancel --all --yes command"),
}
}
#[test]
fn cli_parses_cancel_force() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "cancel", "42", "--force"]).unwrap();
match cli.command {
Some(Commands::Cancel {
build_id, force, ..
}) => {
assert_eq!(build_id, Some(42));
assert!(force);
}
_ => fail_expected("Expected cancel with --force"),
}
}
#[test]
fn cli_parses_cancel_force_short() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "cancel", "42", "-f"]).unwrap();
match cli.command {
Some(Commands::Cancel {
build_id, force, ..
}) => {
assert_eq!(build_id, Some(42));
assert!(force);
}
_ => fail_expected("Expected cancel with -f"),
}
}
#[test]
fn cli_parses_cancel_yes_short() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "cancel", "--all", "-y"]).unwrap();
match cli.command {
Some(Commands::Cancel { yes, .. }) => {
assert!(yes);
}
_ => fail_expected("Expected cancel with -y"),
}
}
#[test]
fn cli_parses_cancel_dry_run() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "cancel", "--dry-run"]).unwrap();
match cli.command {
Some(Commands::Cancel { dry_run, .. }) => {
assert!(dry_run);
}
_ => fail_expected("Expected cancel with --dry-run"),
}
}
#[test]
fn cli_parses_cancel_dry_run_short() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "cancel", "-n"]).unwrap();
match cli.command {
Some(Commands::Cancel { dry_run, .. }) => {
assert!(dry_run);
}
_ => fail_expected("Expected cancel with -n"),
}
}
#[test]
fn cli_parses_cancel_dry_run_with_id() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "cancel", "42", "--dry-run"]).unwrap();
match cli.command {
Some(Commands::Cancel {
build_id, dry_run, ..
}) => {
assert_eq!(build_id, Some(42));
assert!(dry_run);
}
_ => fail_expected("Expected cancel 42 --dry-run"),
}
}
#[test]
fn cli_parses_cancel_all_dry_run_combined() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "cancel", "-an"]).unwrap();
match cli.command {
Some(Commands::Cancel { all, dry_run, .. }) => {
assert!(all);
assert!(dry_run);
}
_ => fail_expected("Expected cancel -an"),
}
}
#[test]
fn cli_parses_self_test_default() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "self-test"]).unwrap();
match cli.command {
Some(Commands::SelfTest {
action,
worker,
all,
project,
timeout,
debug,
scheduled,
smoke,
soak,
load,
dry_run,
}) => {
assert!(action.is_none());
assert!(worker.is_none());
assert!(!all);
assert!(project.is_none());
assert_eq!(timeout, 300);
assert!(!debug);
assert!(!scheduled);
assert!(!smoke);
assert!(!soak);
assert!(!load);
assert!(!dry_run);
}
_ => fail_expected("Expected self-test command"),
}
}
#[test]
fn cli_parses_self_test_worker() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "self-test", "--worker", "css"]).unwrap();
match cli.command {
Some(Commands::SelfTest { worker, all, .. }) => {
assert_eq!(worker.as_deref(), Some("css"));
assert!(!all);
}
_ => fail_expected("Expected self-test --worker command"),
}
}
#[test]
fn cli_parses_self_test_smoke_dry_run() {
let _guard = test_guard!();
let cli =
Cli::try_parse_from(["rch", "self-test", "--smoke", "--dry-run", "--soak"]).unwrap();
match cli.command {
Some(Commands::SelfTest {
smoke,
soak,
dry_run,
..
}) => {
assert!(smoke);
assert!(soak);
assert!(dry_run);
}
_ => fail_expected("Expected self-test --smoke command"),
}
}
#[test]
fn cli_parses_self_test_smoke_load() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "self-test", "--smoke", "--load", "--all"]).unwrap();
match cli.command {
Some(Commands::SelfTest {
smoke, load, all, ..
}) => {
assert!(smoke);
assert!(load);
assert!(all);
}
_ => fail_expected("Expected self-test --smoke --load command"),
}
}
#[test]
fn cli_parses_self_test_all() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "self-test", "--all"]).unwrap();
match cli.command {
Some(Commands::SelfTest { all, .. }) => {
assert!(all);
}
_ => fail_expected("Expected self-test --all command"),
}
}
#[test]
fn cli_parses_self_test_timeout() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "self-test", "--timeout", "600"]).unwrap();
match cli.command {
Some(Commands::SelfTest { timeout, .. }) => {
assert_eq!(timeout, 600);
}
_ => fail_expected("Expected self-test --timeout command"),
}
}
#[test]
fn cli_parses_self_test_debug() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "self-test", "--debug"]).unwrap();
match cli.command {
Some(Commands::SelfTest { debug, .. }) => {
assert!(debug);
}
_ => fail_expected("Expected self-test --debug command"),
}
}
#[test]
fn cli_parses_self_test_scheduled() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "self-test", "--scheduled"]).unwrap();
match cli.command {
Some(Commands::SelfTest { scheduled, .. }) => {
assert!(scheduled);
}
_ => fail_expected("Expected self-test --scheduled command"),
}
}
#[test]
fn cli_parses_self_test_status() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "self-test", "status"]).unwrap();
match cli.command {
Some(Commands::SelfTest { action, .. }) => {
assert!(matches!(action, Some(SelfTestAction::Status)));
}
_ => fail_expected("Expected self-test status command"),
}
}
#[test]
fn cli_parses_self_test_history() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "self-test", "history"]).unwrap();
match cli.command {
Some(Commands::SelfTest { action, .. }) => {
assert!(matches!(
action,
Some(SelfTestAction::History { limit: 10 })
));
}
_ => fail_expected("Expected self-test history command"),
}
}
#[test]
fn cli_parses_self_test_history_limit() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "self-test", "history", "--limit", "20"]).unwrap();
match cli.command {
Some(Commands::SelfTest { action, .. }) => {
assert!(matches!(
action,
Some(SelfTestAction::History { limit: 20 })
));
}
_ => fail_expected("Expected self-test history --limit command"),
}
}
#[test]
fn cli_parses_self_test_project() {
let _guard = test_guard!();
let cli = Cli::try_parse_from(["rch", "self-test", "--project", "/tmp/test"]).unwrap();
match cli.command {
Some(Commands::SelfTest { project, .. }) => {
assert_eq!(
project.as_ref().map(|p| p.display().to_string()),
Some("/tmp/test".to_string())
);
}
_ => fail_expected("Expected self-test --project command"),
}
}
#[test]
fn machine_output_requested_json_flag_true() {
let _guard = test_guard!();
assert!(machine_output_requested(None, true));
}
#[test]
fn machine_output_requested_format_some() {
let _guard = test_guard!();
assert!(machine_output_requested(Some("toon"), false));
}
#[test]
fn machine_output_requested_both_set() {
let _guard = test_guard!();
assert!(machine_output_requested(Some("json"), true));
}
#[test]
fn machine_output_requested_neither_set() {
let _guard = test_guard!();
assert!(!machine_output_requested(None, false));
}
#[test]
fn machine_output_requested_honors_documented_rch_json_env() {
let _guard = test_guard!();
for value in ["1", "true", "TRUE", "yes", "on", "enabled"] {
assert!(
machine_output_requested_with(None, false, Some(value)),
"RCH_JSON={value} must request machine output"
);
}
for value in ["0", "false", "no", "off", "disabled", "", " "] {
assert!(
!machine_output_requested_with(None, false, Some(value)),
"RCH_JSON={value:?} must NOT request machine output"
);
}
assert!(!machine_output_requested_with(None, false, None));
assert!(machine_output_requested_with(None, true, Some("0")));
assert!(machine_output_requested_with(
Some("toon"),
false,
Some("0")
));
}
#[test]
fn top_level_api_error_classifies_daemon_response_parse_as_serde() {
let _guard = test_guard!();
let error = anyhow::anyhow!(
"Failed to parse SpeedScore list response: invalid type: string \"healthy\""
);
let api_error = top_level_api_error(&error);
assert_eq!(api_error.code, ErrorCode::InternalSerdeError.code_string());
assert_eq!(
api_error.details.as_deref(),
Some("Failed to parse SpeedScore list response: invalid type: string \"healthy\"")
);
}
#[test]
fn top_level_api_error_keeps_typed_config_invalid_worker_code() {
let _guard = test_guard!();
let error = unknown_worker_filter_error(&["nonexistent-worker"], "alpha, beta")
.context("rch gc --dry-run");
let api_error = top_level_api_error(&error);
assert_eq!(api_error.code, ErrorCode::ConfigInvalidWorker.code_string());
assert_ne!(api_error.code, ErrorCode::InternalStateError.code_string());
let rendered = format!("{api_error}");
assert!(rendered.contains("nonexistent-worker"), "{rendered}");
assert!(rendered.contains("alpha, beta"), "{rendered}");
}
#[test]
fn top_level_api_error_preserves_typed_context_and_local_build_warning() {
let _guard = test_guard!();
let expected = ApiError::from_code(ErrorCode::InternalDaemonSocket)
.with_details("Failed to connect to daemon: connection refused")
.with_context(
"local_build_warning",
"1 local build on a dispatcher: PID 42",
);
let error = anyhow::anyhow!("Failed to connect to daemon: connection refused")
.context(expected.clone())
.context("rch status");
assert_eq!(top_level_api_error(&error), expected);
}
#[test]
fn golden_top_level_api_error_for_daemon_response_parse_failure() {
let _guard = test_guard!();
const FIXTURE_PATH: &str =
"../../tests/goldens/ft_4tp7g/top_level_daemon_parse_error_api_error.json";
let fixture: serde_json::Value = serde_json::from_str(include_str!(
"../../tests/goldens/ft_4tp7g/top_level_daemon_parse_error_api_error.json"
))
.expect("top-level api error golden fixture parses");
assert_eq!(
fixture["schema_version"],
"rch.golden.top_level_api_error.v1"
);
let input_error = fixture["input_error"]
.as_str()
.expect("input_error is a string");
let api_error = top_level_api_error(&anyhow::anyhow!(input_error.to_string()));
assert_golden_json(
serde_json::to_value(&api_error).expect("api error serializes"),
&fixture["expected"],
FIXTURE_PATH,
);
}
#[test]
fn top_level_api_error_keeps_toml_parse_as_config() {
let _guard = test_guard!();
let error = anyhow::anyhow!("TOML parse error at line 1, column 1");
let api_error = top_level_api_error(&error);
assert_eq!(api_error.code, ErrorCode::ConfigParseError.code_string());
}
#[test]
fn resolve_output_format_explicit_json() {
let _guard = test_guard!();
assert_eq!(
resolve_output_format(Some("json"), false),
OutputFormat::Json
);
}
#[test]
fn resolve_output_format_explicit_toon() {
let _guard = test_guard!();
assert_eq!(
resolve_output_format(Some("toon"), false),
OutputFormat::Toon
);
}
#[test]
fn resolve_output_format_explicit_with_json_flag() {
let _guard = test_guard!();
assert_eq!(
resolve_output_format(Some("toon"), true),
OutputFormat::Toon
);
}
#[test]
fn resolve_output_format_invalid_format_falls_back() {
let _guard = test_guard!();
assert_eq!(
resolve_output_format(Some("invalid"), false),
OutputFormat::Json
);
}
}