diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cee8639..7349cac 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -168,6 +168,24 @@ instructions, and Pi through its system-prompt flag. All three receive `prompt` through their ordinary prompt input. Restricted evaluator runs keep their separate evaluator-specific instruction contract. +### CLI Automation Contract + +Human-readable CLI output remains the default. Read-only automation commands +can select a versioned JSON envelope with `--json`. Success writes one document +to stdout. Failure writes one structured error to stderr with a stable category +and exit code. JSON mode does not initialize tracing, print progress, or mix +tables into stdout. + +Diagnostic JSON reports credential presence without values. Job run history +reports state and content-presence flags without stored result, evaluation, or +error text. Commands that mutate runtime state reject JSON mode because Push +cannot always know whether an interrupted external mutation is safe to retry. +Resolved-path output reads every Push-owned runtime location from the loaded +`PushPaths` owner. It adds the selected config and user-owned assistant paths +without reconstructing runtime defaults. Although job runs now share `push.db` +with channel cursors and backend sessions, their JSON projection queries only +job-run rows and never exposes co-located session IDs or conversation content. + ### Durable State Contract Push uses separate stores because they have different update and query needs: @@ -823,6 +841,7 @@ result before proactive delivery. | Module | Responsibility | | --- | --- | | [`src/main.rs`](src/main.rs) | CLI parsing and process entry | +| [`src/cli_json.rs`](src/cli_json.rs) | versioned JSON envelopes, exit categories, and secret-safe command projections | | [`src/config.rs`](src/config.rs) | configuration parsing, migration, validation, and routing | | [`src/gateway/`](src/gateway/) | channel coordination, polling, queues, workers, acknowledgement, delivery | | [`src/channel.rs`](src/channel.rs) | provider-neutral channel contract and static dispatch | diff --git a/docs/reference/cli.md b/docs/reference/cli.md index fad5b0b..dc90625 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1,9 +1,10 @@ # CLI reference -Push has one gateway command, one diagnostic command, and a small set of job -commands. All commands accept `--config ` anywhere in the argument list. -The default is `$PUSH_HOME/config.toml`; `PUSH_HOME` defaults to `~/.push`. -`--config` changes only the selected config file, not the runtime root. +Push has one gateway command, diagnostic and service commands, and a small set +of job commands. All commands accept `--config ` anywhere in the argument +list. The default is `$PUSH_HOME/config.toml`; `PUSH_HOME` defaults to +`~/.push`. `--config` changes only the selected config file, not the runtime +root. | Command | Purpose | | --- | --- | @@ -12,6 +13,8 @@ The default is `$PUSH_HOME/config.toml`; `PUSH_HOME` defaults to `~/.push`. | `push init [path]` | Create and Git-initialize the one assistant repository; defaults to `./assistant` | | `push` | Start the configured channel gateway and scheduler | | `push doctor` | Validate config, paths, channel requirements, and required backend binaries | +| `push status` | Show whether the installed launchd or systemd gateway service is running | +| `push paths` | Show the resolved config, assistant, job, and runtime storage paths | | `push reload`, `push restart` | Restart the managed gateway to load updated config | | `push job validate` | Validate every installed job; exits non-zero if any are invalid | | `push job list` | List valid and invalid jobs with backend or error | @@ -26,6 +29,8 @@ push init ~/Code/assistant push help push version push doctor +push status +push paths push push reload push job validate @@ -42,7 +47,8 @@ help shown by `push --help`. `com.owainlewis.push` under launchd on macOS and the `push.service` user unit under systemd on Linux. The service definition controls its config path, environment, and executable; `--config` does not override the service definition -for this command. Run `push doctor` separately when you want to validate those +for this command. `push status` reads the same service definition and also +ignores `--config`. Run `push doctor` separately when you want to validate those settings from the current shell. `push init` accepts an empty target, the selected config by itself, or a @@ -58,6 +64,219 @@ Push upgrade; if the skill or an exposure link has diverged, Push leaves it unchanged and reports how to move or restore the conflicting content. User-created skills and global agent skills are not copied or managed. +## JSON contract + +Pass the global `--json` option anywhere in the argument list to select the +version 1 JSON contract. Human-readable output remains the default. JSON mode is +available for: + +- `help` and `version` +- `doctor`, `status`, and `paths` +- `job validate`, `job list`, `job show`, and `job runs` + +Commands that start or mutate runtime state reject `--json`. This includes the +gateway, `init`, `reload`, `restart`, and `job run`. In particular, Push does +not claim that an interrupted mutation is safe to retry when its outcome is +unknown. + +A successful command writes exactly one JSON document and a trailing newline to +stdout. It writes nothing to stderr: + +```json +{ + "schema_version": 1, + "ok": true, + "command": "paths", + "data": {} +} +``` + +A failed command writes exactly one JSON document and a trailing newline to +stderr. It writes nothing to stdout: + +```json +{ + "schema_version": 1, + "ok": false, + "error": { + "category": "configuration", + "message": "configuration not found at ~/.push/config.toml", + "exit_code": 3, + "retryable": false + } +} +``` + +`error.details` is optional and contains command-specific structured evidence, +such as failed doctor checks or invalid jobs. `retryable` is optional. Push +omits it for unexpected failures where it cannot make an honest retry claim. +Diagnostic payloads report whether credentials are configured, never their +values. `job runs` reports content-presence booleans and omits stored result, +evaluation, and error text. + +### Exit codes + +The category strings and process exit codes are stable within schema version 1: + +| Exit code | Category | Meaning | +| --- | --- | --- | +| `0` | success | The command completed | +| `2` | `invalid_input` | Arguments, names, or validated input are invalid | +| `3` | `configuration` | Config or configured local state is missing or invalid | +| `4` | `unavailable_dependency` | A required backend, service manager, or dependency is unavailable | +| `5` | `transient_transport` | A transport failed in a way Push knows is safe to retry | +| `6` | `conflict` | Current state conflicts with the requested operation | +| `70` | `unexpected` | Push cannot classify the failure safely | + +### Command data + +Every listed field is required unless marked optional. `integer` values are JSON +integers. Path fields are UTF-8 strings; Push replaces invalid filesystem bytes +rather than emitting invalid JSON. + +`help` data contains `text` as a string. `version` data contains `name` and +`version` as strings. + +`doctor` data: + +| Field | Type | Values | +| --- | --- | --- | +| `checks` | array of check objects | All checks in execution order | +| `checks[].name` | string | Stable human-readable check name | +| `checks[].status` | string enum | `pass` or `fail` | +| `checks[].message` | string | Secret-safe explanation | + +Failed doctor output places the same object under `error.details`. + +`status` data: + +| Field | Type | Values | +| --- | --- | --- | +| `manager` | string enum | `launchd` or `systemd` | +| `unit` | string | `com.owainlewis.push` or `push.service` | +| `running` | boolean | `true` only when the normalized state is `active` | +| `state` | string | Normalized service state. Common values are `active`, `inactive`, `not_loaded`, `failed`, `activating`, `deactivating`, or `unknown` | + +An operational service-manager failure is an `unavailable_dependency` error, +not a successful inactive status. This command observes the managed process. It +does not open or migrate the configured SQLite database. + +`paths` data contains these required string paths: + +| Field | Meaning | +| --- | --- | +| `push_home` | Resolved Push runtime root | +| `config` | Actually loaded config file, including a `--config` selection | +| `default_config` | Config path derived from `push_home` | +| `assistant_root` | User-owned assistant repository | +| `assistant_context` | Assistant `context` directory | +| `assistant_evals` | Assistant `evals` directory | +| `jobs` | Installed Markdown jobs | +| `jobs_run` | Local run-lock directory | +| `state` | Legacy JSON migration source and retained recovery copy | +| `audit_log` | Structured audit log | +| `database` | Canonical SQLite database for conversations, jobs, delivery, channel cursors, and backend sessions | +| `slack_inbox` | Durable Slack acknowledgement inbox | +| `cache` | Push-owned cache directory | +| `imessage_database` | Configured Messages database | + +All Push-owned fields in this object come from the loaded `PushPaths` owner. +Setting `PUSH_HOME` relocates its derived fields together. Explicit compatibility +overrides for state, database, audit, and job-run paths appear in their +respective fields without changing `push_home`. The `state` path is not live +runtime state after its one-time import. Live cursors and backend sessions share +the `database` path. + +`job validate` and `job list` share catalog data: + +| Field | Type | Values | +| --- | --- | --- | +| `valid_count` | integer | Number of entries in `valid` | +| `invalid_count` | integer | Number of entries in `invalid` | +| `valid` | array of valid entry objects | Valid installed jobs | +| `valid[].name` | string | Job slug | +| `valid[].status` | string constant | `valid` | +| `valid[].path` | string | Installed Markdown path | +| `valid[].backend` | string enum | `claude`, `codex`, or `pi` | +| `invalid` | array of invalid entry objects | Invalid installed entries | +| `invalid[].name` | string | Best available filename or job slug | +| `invalid[].status` | string constant | `invalid` | +| `invalid[].path` | string | Rejected entry path | +| `invalid[].message` | string | Validation reason | + +`job validate` puts catalog data under `error.details` and exits with +`invalid_input` when `invalid_count` is nonzero. `job list` returns the catalog +successfully so callers can inspect valid and invalid entries together. + +`job show` data: + +| Field | Type | Values | +| --- | --- | --- | +| `name` | string | Job slug | +| `path` | string | Installed Markdown path | +| `backend` | string enum | `claude`, `codex`, or `pi` | +| `timeout_ms` | integer | Validated timeout in milliseconds | +| `workdir` | string | Resolved backend working directory | +| `snapshot_hash` | string | Validated job snapshot SHA-256 | +| `evals` | array of strings | Assigned eval names | +| `triggers` | array of trigger objects | Validated triggers | +| `triggers[].id` | string | Trigger slug | +| `triggers[].kind` | string constant | `cron` | +| `triggers[].schedule` | string | Five-field cron expression | +| `triggers[].timezone` | string | IANA timezone name | +| `triggers[].enabled` | boolean | Whether the scheduler may enqueue it | +| `body` | string | Runbook instruction body | + +`job runs` data: + +| Field | Type | Values | +| --- | --- | --- | +| `job_name` | string or null | Requested job filter, or null for all jobs | +| `runs` | array of run objects | Up to 100 newest rows | +| `runs[].id` | string | Run UUID | +| `runs[].job_name` | string | Job slug | +| `runs[].state` | string | Persisted execution state | +| `runs[].backend` | string enum | `claude`, `codex`, or `pi` | +| `runs[].queued_at_ms` | integer | Unix epoch milliseconds | +| `runs[].trigger.kind` | string | `manual` or `cron` | +| `runs[].trigger.id` | string or null | Trigger ID for a scheduled run | +| `runs[].trigger.scheduled_at_ms` | integer or null | Scheduled Unix epoch milliseconds | +| `runs[].execution.has_result` | boolean | Whether stored result text exists | +| `runs[].execution.has_error` | boolean | Whether stored execution error text exists | +| `runs[].evaluation.state` | string | Persisted evaluation state | +| `runs[].evaluation.has_result` | boolean | Whether stored evaluation result text exists | +| `runs[].evaluation.has_error` | boolean | Whether stored evaluation error text exists | +| `runs[].delivery.state` | string | Persisted delivery state | +| `runs[].delivery.attempts` | integer | Delivery attempt count | +| `runs[].delivery.has_error` | boolean | Whether stored delivery error text exists | +| `runs[].delivery.channel` | string or null | Delivery channel | +| `runs[].delivery.target` | string or null | Delivery target | + +The run projection queries only `job_runs` from the shared SQLite database. It +does not include co-located channel cursors, backend session IDs, conversation +messages, stored job output, evaluation text, or error text. + +Fields may be added compatibly within version 1. Existing fields, meanings, +category names, and types will not change without a schema-version change. + +Shell examples: + +```sh +# Read one path. +push paths --json | jq -r '.data.database' + +# Fail unless doctor passes, then list failed checks if it does not. +if ! report=$(push doctor --json 2>doctor.json); then + jq '.error.details.checks[] | select(.status == "fail")' doctor.json +fi + +# List valid job names. +push --json job list | jq -r '.data.valid[].name' + +# Inspect recent failed run metadata without exposing stored output. +push job runs --json | jq '.data.runs[] | select(.state == "failed")' +``` + ## Commands sent in chat These messages are handled by the gateway before backend dispatch: diff --git a/src/cli_json.rs b/src/cli_json.rs new file mode 100644 index 0000000..8db0189 --- /dev/null +++ b/src/cli_json.rs @@ -0,0 +1,451 @@ +//! Stable JSON output for automation-facing CLI commands. + +use std::io::{self, Write}; + +use anyhow::anyhow; +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::{config, doctor, jobs, restart, Command, JobCommand, HELP}; + +const SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +#[allow(dead_code)] // The public contract reserves categories not yet emitted by read-only commands. +pub(crate) enum ErrorCategory { + InvalidInput, + Configuration, + UnavailableDependency, + TransientTransport, + Conflict, + Unexpected, +} + +impl ErrorCategory { + fn exit_code(self) -> i32 { + match self { + Self::InvalidInput => 2, + Self::Configuration => 3, + Self::UnavailableDependency => 4, + Self::TransientTransport => 5, + Self::Conflict => 6, + Self::Unexpected => 70, + } + } + + fn retryable(self) -> Option { + match self { + Self::InvalidInput + | Self::Configuration + | Self::UnavailableDependency + | Self::Conflict => Some(false), + Self::TransientTransport => Some(true), + Self::Unexpected => None, + } + } +} + +pub(crate) struct CliError { + category: ErrorCategory, + message: String, + source: anyhow::Error, + details: Option, +} + +impl CliError { + pub(crate) fn invalid_input(source: anyhow::Error) -> Self { + Self::new(ErrorCategory::InvalidInput, source.to_string(), source) + } + + pub(crate) fn configuration(message: impl Into, source: anyhow::Error) -> Self { + Self::new(ErrorCategory::Configuration, message, source) + } + + pub(crate) fn unavailable_dependency( + message: impl Into, + source: anyhow::Error, + ) -> Self { + Self::new(ErrorCategory::UnavailableDependency, message, source) + } + + pub(crate) fn unexpected(source: anyhow::Error) -> Self { + let message = source.to_string(); + Self::new(ErrorCategory::Unexpected, message, source) + } + + fn new(category: ErrorCategory, message: impl Into, source: anyhow::Error) -> Self { + Self { + category, + message: message.into(), + source, + details: None, + } + } + + fn with_details(mut self, details: Value) -> Self { + self.details = Some(details); + self + } + + pub(crate) fn exit_code(&self) -> i32 { + self.category.exit_code() + } + + pub(crate) fn source(&self) -> &anyhow::Error { + &self.source + } +} + +#[derive(Serialize)] +struct SuccessEnvelope<'a> { + schema_version: u32, + ok: bool, + command: &'a str, + data: Value, +} + +#[derive(Serialize)] +struct ErrorEnvelope<'a> { + schema_version: u32, + ok: bool, + error: ErrorBody<'a>, +} + +#[derive(Serialize)] +struct ErrorBody<'a> { + category: ErrorCategory, + message: &'a str, + exit_code: i32, + #[serde(skip_serializing_if = "Option::is_none")] + retryable: Option, + #[serde(skip_serializing_if = "Option::is_none")] + details: Option<&'a Value>, +} + +pub(crate) async fn run(config_path: &str, command: Command) -> Result<(), CliError> { + match command { + Command::Help => write_success("help", json!({ "text": HELP })), + Command::Version => write_success( + "version", + json!({ + "name": "push", + "version": env!("CARGO_PKG_VERSION"), + }), + ), + Command::Doctor => run_doctor(config_path), + Command::Status => { + let status = restart::gateway_status().map_err(|error| { + CliError::unavailable_dependency( + "the gateway service manager is unavailable", + error, + ) + })?; + write_success( + "status", + serde_json::to_value(status).expect("status serializes"), + ) + } + Command::Paths => { + let cfg = load_config(config_path)?; + write_success("paths", paths_value(&cfg)) + } + Command::Job(command) => run_job_command(config_path, command), + Command::Run | Command::Init(_) | Command::Restart => Err(CliError::invalid_input( + anyhow!("--json is not supported for commands that start or mutate runtime state"), + )), + } +} + +fn run_doctor(config_path: &str) -> Result<(), CliError> { + let cfg = load_config(config_path)?; + let report = doctor::report(&cfg); + let data = serde_json::to_value(&report).expect("doctor report serializes"); + if report.is_ok() { + write_success("doctor", data) + } else { + let failed = report.failed_count(); + let category = if report.has_unavailable_dependency() { + ErrorCategory::UnavailableDependency + } else { + ErrorCategory::Configuration + }; + Err(CliError::new( + category, + format!("doctor found {failed} failed check(s)"), + anyhow!("doctor found {failed} failed check(s)"), + ) + .with_details(data)) + } +} + +fn run_job_command(config_path: &str, command: JobCommand) -> Result<(), CliError> { + if matches!(command, JobCommand::Run(_)) { + return Err(CliError::invalid_input(anyhow!( + "--json is not supported for `job run` because an interrupted mutation can have an unknown outcome" + ))); + } + let cfg = load_config(config_path)?; + match command { + JobCommand::Validate => { + let catalog = jobs::Catalog::load(&cfg).map_err(|error| { + CliError::configuration("the installed jobs could not be inspected", error) + })?; + let data = catalog_value(&catalog); + if catalog.errors.is_empty() { + write_success("job.validate", data) + } else { + let count = catalog.errors.len(); + Err(CliError::new( + ErrorCategory::InvalidInput, + format!("{count} installed job(s) are invalid"), + anyhow!("{count} installed job(s) are invalid"), + ) + .with_details(data)) + } + } + JobCommand::List => { + let catalog = jobs::Catalog::load(&cfg).map_err(|error| { + CliError::configuration("the installed jobs could not be inspected", error) + })?; + write_success("job.list", catalog_value(&catalog)) + } + JobCommand::Show(name) => { + jobs::validate_job_name(&name).map_err(CliError::invalid_input)?; + let job = jobs::Catalog::load_named(&cfg, &name).map_err(|error| { + CliError::configuration( + format!("job {name:?} could not be loaded or validated"), + error, + ) + })?; + write_success("job.show", job_value(&job)) + } + JobCommand::Runs(name) => { + if let Some(name) = name.as_deref() { + jobs::validate_job_name(name).map_err(CliError::invalid_input)?; + } + let ledger = jobs::Ledger::open(&cfg.paths.database).map_err(|error| { + CliError::configuration("the job run ledger could not be opened", error) + })?; + let rows = ledger.runs(name.as_deref()).map_err(|error| { + CliError::configuration("the job run ledger could not be read", error) + })?; + let runs = rows + .into_iter() + .map(|run| { + json!({ + "id": run.id, + "job_name": run.job_name, + "state": run.state, + "backend": run.backend, + "queued_at_ms": run.queued_at_ms, + "trigger": { + "kind": run.trigger_kind, + "id": run.trigger_id, + "scheduled_at_ms": run.scheduled_at_ms, + }, + "execution": { + "has_result": run.result.is_some(), + "has_error": run.error.is_some(), + }, + "evaluation": { + "state": run.evaluation_state, + "has_result": run.evaluation_result.is_some(), + "has_error": run.evaluation_error.is_some(), + }, + "delivery": { + "state": run.delivery_state, + "attempts": run.delivery_attempts, + "has_error": run.delivery_error.is_some(), + "channel": run.delivery_channel, + "target": run.delivery_target, + }, + }) + }) + .collect::>(); + write_success( + "job.runs", + json!({ + "job_name": name, + "runs": runs, + }), + ) + } + JobCommand::Run(_) => unreachable!("job run JSON mode is rejected before config loading"), + } +} + +fn load_config(path: &str) -> Result { + if crate::missing_config_message(path).is_some() { + return Err(CliError::configuration( + format!("configuration not found at {path}"), + anyhow!("configuration not found at {path}"), + )); + } + config::Config::load(path).map_err(|error| { + CliError::configuration( + format!("configuration at {path} could not be loaded; run `push doctor` for details"), + error, + ) + }) +} + +fn catalog_value(catalog: &jobs::Catalog) -> Value { + let valid = catalog + .jobs + .values() + .map(|job| { + json!({ + "name": job.name, + "status": "valid", + "path": job.path.to_string_lossy(), + "backend": job.backend.as_str(), + }) + }) + .collect::>(); + let invalid = catalog + .errors + .iter() + .map(|error| { + json!({ + "name": error.name, + "status": "invalid", + "path": error.path.to_string_lossy(), + "message": error.message, + }) + }) + .collect::>(); + json!({ + "valid_count": valid.len(), + "invalid_count": invalid.len(), + "valid": valid, + "invalid": invalid, + }) +} + +fn job_value(job: &jobs::Job) -> Value { + let triggers = job + .triggers + .iter() + .map(|trigger| { + json!({ + "id": trigger.id, + "kind": trigger.kind, + "schedule": trigger.schedule, + "timezone": trigger.timezone, + "enabled": trigger.enabled, + }) + }) + .collect::>(); + json!({ + "name": job.name, + "path": job.path.to_string_lossy(), + "backend": job.backend.as_str(), + "timeout_ms": u64::try_from(job.timeout.as_millis()).unwrap_or(u64::MAX), + "workdir": job.workdir.to_string_lossy(), + "snapshot_hash": job.snapshot_hash, + "evals": job.evals.iter().map(|eval| &eval.name).collect::>(), + "triggers": triggers, + "body": job.body, + }) +} + +fn paths_value(cfg: &config::Config) -> Value { + json!({ + "push_home": cfg.paths.root.to_string_lossy(), + "config": cfg.config_path, + "default_config": cfg.paths.config.to_string_lossy(), + "assistant_root": cfg.assistant_root, + "assistant_context": std::path::Path::new(&cfg.assistant_root).join("context").to_string_lossy(), + "assistant_evals": std::path::Path::new(&cfg.assistant_root).join("evals").to_string_lossy(), + "jobs": cfg.jobs_dir, + "jobs_run": cfg.paths.jobs_run.to_string_lossy(), + "state": cfg.paths.state.to_string_lossy(), + "audit_log": cfg.paths.audit.to_string_lossy(), + "database": cfg.paths.database.to_string_lossy(), + "slack_inbox": cfg.paths.inbox.to_string_lossy(), + "cache": cfg.paths.cache.to_string_lossy(), + "imessage_database": cfg.db_path, + }) +} + +pub(crate) fn format_paths(cfg: &config::Config) -> String { + let paths = paths_value(cfg); + let data = paths.as_object().expect("paths are an object"); + let mut output = String::from("push paths\n"); + for key in [ + "push_home", + "config", + "default_config", + "assistant_root", + "assistant_context", + "assistant_evals", + "jobs", + "jobs_run", + "state", + "audit_log", + "database", + "slack_inbox", + "cache", + "imessage_database", + ] { + let value = data[key].as_str().expect("path is a string"); + output.push_str(&format!("{key}: {value}\n")); + } + output +} + +fn write_success(command: &str, data: Value) -> Result<(), CliError> { + let envelope = SuccessEnvelope { + schema_version: SCHEMA_VERSION, + ok: true, + command, + data, + }; + write_json(&envelope).map_err(|error| CliError::unexpected(error.into())) +} + +pub(crate) fn write_error(error: &CliError) { + let envelope = ErrorEnvelope { + schema_version: SCHEMA_VERSION, + ok: false, + error: ErrorBody { + category: error.category, + message: &error.message, + exit_code: error.exit_code(), + retryable: error.category.retryable(), + details: error.details.as_ref(), + }, + }; + let mut stderr = io::stderr().lock(); + let _ = serde_json::to_writer(&mut stderr, &envelope); + let _ = writeln!(stderr); +} + +fn write_json(value: &impl Serialize) -> io::Result<()> { + let mut stdout = io::stdout().lock(); + serde_json::to_writer(&mut stdout, value)?; + writeln!(stdout) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exit_codes_are_stable_and_distinct() { + assert_eq!(ErrorCategory::InvalidInput.exit_code(), 2); + assert_eq!(ErrorCategory::Configuration.exit_code(), 3); + assert_eq!(ErrorCategory::UnavailableDependency.exit_code(), 4); + assert_eq!(ErrorCategory::TransientTransport.exit_code(), 5); + assert_eq!(ErrorCategory::Conflict.exit_code(), 6); + assert_eq!(ErrorCategory::Unexpected.exit_code(), 70); + } + + #[test] + fn retryability_is_only_claimed_for_known_categories() { + assert_eq!(ErrorCategory::InvalidInput.retryable(), Some(false)); + assert_eq!(ErrorCategory::TransientTransport.retryable(), Some(true)); + assert_eq!(ErrorCategory::Unexpected.retryable(), None); + } +} diff --git a/src/doctor.rs b/src/doctor.rs index 6de49a1..5010789 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -4,6 +4,7 @@ use std::fmt; use std::path::{Path, PathBuf}; use anyhow::{bail, Result}; +use serde::Serialize; use crate::{channel::Channel, config, jobs}; use config::{SLACK_APP_TOKEN_ENV, SLACK_BOT_TOKEN_ENV, TELEGRAM_BOT_TOKEN_ENV}; @@ -49,6 +50,10 @@ pub fn doctor(config_path: &str) -> Result<()> { } } +pub(crate) fn report(cfg: &config::Config) -> CheckReport { + run_checks(cfg) +} + fn run_checks(cfg: &config::Config) -> CheckReport { let mut checks = Vec::new(); check_config(cfg, &mut checks); @@ -400,19 +405,19 @@ fn check_bins_with( } } -#[derive(Debug)] -struct CheckReport { - checks: Vec, +#[derive(Debug, Serialize)] +pub(crate) struct CheckReport { + pub(crate) checks: Vec, } impl CheckReport { - fn is_ok(&self) -> bool { + pub(crate) fn is_ok(&self) -> bool { self.checks .iter() .all(|check| matches!(check.status, CheckStatus::Pass)) } - fn failed_count(&self) -> usize { + pub(crate) fn failed_count(&self) -> usize { self.checks .iter() .filter(|check| matches!(check.status, CheckStatus::Fail)) @@ -424,6 +429,13 @@ impl CheckReport { !matches!(check.status, CheckStatus::Fail) || check.name == "scheduled delivery" }) } + + pub(crate) fn has_unavailable_dependency(&self) -> bool { + self.checks.iter().any(|check| { + matches!(check.status, CheckStatus::Fail) + && (check.name == "agent binaries" || check.name.starts_with("binary ")) + }) + } } impl fmt::Display for CheckReport { @@ -444,8 +456,8 @@ impl fmt::Display for CheckReport { } } -#[derive(Debug)] -struct Check { +#[derive(Debug, Serialize)] +pub(crate) struct Check { name: String, status: CheckStatus, message: String, @@ -469,7 +481,8 @@ impl Check { } } -#[derive(Debug)] +#[derive(Debug, Serialize)] +#[serde(rename_all = "lowercase")] enum CheckStatus { Pass, Fail, diff --git a/src/main.rs b/src/main.rs index 74aa66b..4a50460 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod assistant; mod audit; mod channel; mod claude; +mod cli_json; mod codex; mod config; mod doctor; @@ -39,6 +40,8 @@ Commands: version Print the installed Push version init [path] Create an assistant repository (default: ./assistant) doctor Validate the configuration and dependencies + status Show the installed gateway service status + paths Show resolved configuration and storage paths reload Reload the installed gateway service restart Alias for reload job validate Validate all installed jobs @@ -49,6 +52,7 @@ Commands: Options: --config Use a configuration file (default: $PUSH_HOME/config.toml) + --json Emit stable machine-readable output where supported -h, --help Print help -V, --version Print version @@ -57,17 +61,47 @@ Environment: "; #[tokio::main] -async fn main() -> Result<()> { - tracing_subscriber::fmt().with_target(false).init(); +async fn main() { + let raw_args = std::env::args().skip(1).collect::>(); + let wants_json = raw_args.iter().any(|arg| arg == "--json"); + if !wants_json { + tracing_subscriber::fmt().with_target(false).init(); + } - let args = Args::parse(std::env::args().skip(1).collect())?; - let config_path = if matches!( - &args.command, - Command::Help | Command::Version | Command::Restart - ) { - None + let result = match Args::parse(raw_args) { + Ok(args) if args.json => run_json(args).await, + Ok(args) => run_human(args) + .await + .map_err(cli_json::CliError::unexpected), + Err(error) => Err(cli_json::CliError::invalid_input(error)), + }; + if let Err(error) = result { + if wants_json { + cli_json::write_error(&error); + } else { + eprintln!("Error: {:#}", error.source()); + } + std::process::exit(if wants_json { error.exit_code() } else { 1 }); + } +} + +async fn run_json(args: Args) -> Result<(), cli_json::CliError> { + let config_path = if args.command.json_needs_config() { + Some(args.resolved_config_path().map_err(|error| { + cli_json::CliError::configuration("Push runtime paths could not be resolved", error) + })?) } else { + None + }; + cli_json::run(config_path.as_deref().unwrap_or(""), args.command).await +} + +async fn run_human(args: Args) -> Result<()> { + let explicit_config = args.config_path.is_some(); + let config_path = if args.command.needs_config() { Some(args.resolved_config_path()?) + } else { + None }; match args.command { Command::Help => { @@ -96,7 +130,7 @@ async fn main() -> Result<()> { println!(" $EDITOR {}/SOUL.md", result.root.display()); println!(" $EDITOR {}/context/README.md", result.root.display()); println!(" Validate and run:"); - if args.config_path.is_none() { + if !explicit_config { println!(" push doctor"); println!(" push"); } else { @@ -106,6 +140,12 @@ async fn main() -> Result<()> { Ok(()) } Command::Doctor => doctor::doctor(config_path.as_deref().expect("doctor has config")), + Command::Status => restart::print_gateway_status(), + Command::Paths => { + let cfg = load_run_config(config_path.as_deref().expect("paths has config"))?; + print!("{}", cli_json::format_paths(&cfg)); + Ok(()) + } Command::Restart => restart::gateway(), Command::Job(command) => { run_job_command( @@ -167,21 +207,24 @@ fn shell_quote(value: &str) -> String { struct Args { command: Command, config_path: Option, + json: bool, } #[derive(Debug, PartialEq, Eq)] -enum Command { +pub(crate) enum Command { Help, Version, Run, Init(String), Doctor, + Status, + Paths, Restart, Job(JobCommand), } #[derive(Debug, PartialEq, Eq)] -enum JobCommand { +pub(crate) enum JobCommand { Validate, List, Show(String), @@ -189,6 +232,29 @@ enum JobCommand { Runs(Option), } +impl Command { + fn needs_config(&self) -> bool { + !matches!( + self, + Self::Help | Self::Version | Self::Status | Self::Restart + ) + } + + fn json_needs_config(&self) -> bool { + matches!( + self, + Self::Doctor + | Self::Paths + | Self::Job( + JobCommand::Validate + | JobCommand::List + | JobCommand::Show(_) + | JobCommand::Runs(_) + ) + ) + } +} + impl Args { fn resolved_config_path(&self) -> Result { if let Some(path) = &self.config_path { @@ -207,6 +273,7 @@ impl Args { } fn parse(args: Vec) -> Result { + let json = args.iter().any(|arg| arg == "--json"); if args .iter() .any(|arg| matches!(arg.as_str(), "-h" | "--help")) @@ -214,6 +281,7 @@ impl Args { return Ok(Self { command: Command::Help, config_path: None, + json, }); } if args @@ -223,6 +291,7 @@ impl Args { return Ok(Self { command: Command::Version, config_path: None, + json, }); } @@ -238,6 +307,9 @@ impl Args { config_path = Some(path.clone()); i += 2; } + "--json" => { + i += 1; + } value => { positional.push(value.to_string()); i += 1; @@ -251,6 +323,8 @@ impl Args { ["init"] => Command::Init("./assistant".to_string()), ["init", path] => Command::Init((*path).to_string()), ["doctor"] => Command::Doctor, + ["status"] => Command::Status, + ["paths"] => Command::Paths, ["reload" | "restart"] => Command::Restart, ["job", "validate"] => Command::Job(JobCommand::Validate), ["job", "list"] => Command::Job(JobCommand::List), @@ -259,12 +333,13 @@ impl Args { ["job", "runs"] => Command::Job(JobCommand::Runs(None)), ["job", "runs", name] => Command::Job(JobCommand::Runs(Some((*name).to_string()))), _ => bail!( - "unknown command; expected help, version, init [path], doctor, reload, restart, job validate, job list, job show , job run , job runs [], or --config " + "unknown command; expected help, version, init [path], doctor, status, paths, reload, restart, job validate, job list, job show , job run , job runs [], --config , or --json" ), }; Ok(Self { command, config_path, + json, }) } } @@ -408,6 +483,7 @@ mod tests { Args { command: Command::Doctor, config_path: Some("custom.toml".to_string()), + json: false, } ); } @@ -426,6 +502,7 @@ mod tests { Args { command: Command::Restart, config_path: Some("custom.toml".to_string()), + json: false, } ); } @@ -456,6 +533,7 @@ mod tests { Args { command: Command::Help, config_path: None, + json: false, } ); assert_eq!( @@ -463,6 +541,7 @@ mod tests { Args { command: Command::Help, config_path: None, + json: false, } ); assert_eq!( @@ -517,6 +596,7 @@ mod tests { Args { command: Command::Job(JobCommand::List), config_path: Some("x.toml".to_string()), + json: false, } ); assert_eq!( @@ -562,6 +642,7 @@ mod tests { Args { command: Command::Init("~/Code/assistant".to_string()), config_path: Some("custom.toml".to_string()), + json: false, } ); } @@ -591,6 +672,19 @@ mod tests { Args { command: Command::Run, config_path: None, + json: false, + } + ); + } + + #[test] + fn parses_json_as_a_global_option() { + assert_eq!( + Args::parse(vec!["job".into(), "--json".into(), "list".into()]).unwrap(), + Args { + command: Command::Job(JobCommand::List), + config_path: None, + json: true, } ); } diff --git a/src/restart.rs b/src/restart.rs index d436ec6..2b7ceeb 100644 --- a/src/restart.rs +++ b/src/restart.rs @@ -2,6 +2,7 @@ use std::io::{self, Write}; use std::process::Command; use anyhow::{bail, Context, Result}; +use serde::Serialize; const LAUNCHD_LABEL: &str = "com.owainlewis.push"; const SYSTEMD_UNIT: &str = "push.service"; @@ -22,6 +23,44 @@ pub fn gateway() -> Result<()> { Ok(()) } +#[derive(Debug, Serialize, PartialEq, Eq)] +pub(crate) struct ServiceStatus { + manager: &'static str, + unit: &'static str, + running: bool, + state: String, +} + +pub(crate) fn gateway_status() -> Result { + let (manager, unit, command) = + status_command_for(std::env::consts::OS, effective_user_id()?.as_deref())?; + status_with(manager, unit, &command, |command| { + let mut process = Command::new(command.program); + process.args(&command.args); + let output = process.output()?; + Ok(StatusOutput { + success: output.status.success(), + stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }) + }) +} + +pub(crate) fn print_gateway_status() -> Result<()> { + let status = gateway_status()?; + println!( + "Gateway service: {}", + if status.running { + "running" + } else { + status.state.as_str() + } + ); + println!("Manager: {}", status.manager); + println!("Unit: {}", status.unit); + Ok(()) +} + fn write_status(message: &str) { let mut stdout = io::stdout().lock(); let _ = writeln!(stdout, "{message}"); @@ -33,6 +72,12 @@ struct ProcessStatus { description: String, } +struct StatusOutput { + success: bool, + stdout: String, + stderr: String, +} + #[derive(Debug, PartialEq, Eq)] struct PlatformCommand { program: &'static str, @@ -67,6 +112,93 @@ fn platform_command() -> Result { command_for(std::env::consts::OS, effective_user_id()?.as_deref()) } +fn status_command_for( + os: &str, + user_id: Option<&str>, +) -> Result<(&'static str, &'static str, PlatformCommand)> { + match os { + "macos" => { + let user_id = user_id.context("determine current user id for launchd")?; + Ok(( + "launchd", + LAUNCHD_LABEL, + PlatformCommand { + program: "launchctl", + args: vec![ + "print".to_string(), + format!("gui/{user_id}/{LAUNCHD_LABEL}"), + ], + }, + )) + } + "linux" => Ok(( + "systemd", + SYSTEMD_UNIT, + PlatformCommand { + program: "systemctl", + args: vec![ + "--user".to_string(), + "is-active".to_string(), + SYSTEMD_UNIT.to_string(), + ], + }, + )), + _ => bail!("gateway status is supported only on macOS and Linux"), + } +} + +fn status_with( + manager: &'static str, + unit: &'static str, + command: &PlatformCommand, + runner: impl FnOnce(&PlatformCommand) -> std::io::Result, +) -> Result { + let output = runner(command).with_context(|| format!("run {}", command.display()))?; + let state = if manager == "launchd" && output.success { + normalize_launchd_state(&output.stdout)? + } else if output.success { + "active".to_string() + } else if manager == "launchd" + && (output.stderr.contains("Could not find service") + || output.stderr.contains("service not found")) + { + "not_loaded".to_string() + } else if manager == "systemd" + && matches!( + output.stdout.as_str(), + "inactive" | "failed" | "activating" | "deactivating" | "unknown" + ) + { + output.stdout + } else { + let detail = if output.stderr.is_empty() { + "no status was returned" + } else { + output.stderr.as_str() + }; + bail!("{} failed: {detail}", command.display()); + }; + Ok(ServiceStatus { + manager, + unit, + running: output.success && state == "active", + state, + }) +} + +fn normalize_launchd_state(output: &str) -> Result { + let state = output + .lines() + .map(str::trim) + .find_map(|line| line.strip_prefix("state = ")) + .context("launchctl did not report a service state")?; + Ok(match state { + "running" => "active".to_string(), + "not running" => "inactive".to_string(), + other => other.replace(' ', "_"), + }) +} + fn command_for(os: &str, user_id: Option<&str>) -> Result { match os { "macos" => { @@ -142,6 +274,107 @@ mod tests { ); } + #[test] + fn macos_status_inspects_the_documented_launchd_service() { + let (manager, unit, command) = status_command_for("macos", Some("501")).unwrap(); + assert_eq!(manager, "launchd"); + assert_eq!(unit, LAUNCHD_LABEL); + assert_eq!( + command, + PlatformCommand { + program: "launchctl", + args: vec![ + "print".to_string(), + "gui/501/com.owainlewis.push".to_string(), + ], + } + ); + } + + #[test] + fn macos_status_parses_running_launchd_service() { + let (manager, unit, command) = status_command_for("macos", Some("501")).unwrap(); + let status = status_with(manager, unit, &command, |_| { + Ok(StatusOutput { + success: true, + stdout: "service = {\n\tstate = running\n}".to_string(), + stderr: String::new(), + }) + }) + .unwrap(); + + assert_eq!( + status, + ServiceStatus { + manager: "launchd", + unit: LAUNCHD_LABEL, + running: true, + state: "active".to_string(), + } + ); + } + + #[test] + fn macos_status_parses_loaded_but_stopped_launchd_service() { + let (manager, unit, command) = status_command_for("macos", Some("501")).unwrap(); + let status = status_with(manager, unit, &command, |_| { + Ok(StatusOutput { + success: true, + stdout: "service = {\n\tstate = not running\n}".to_string(), + stderr: String::new(), + }) + }) + .unwrap(); + + assert_eq!( + status, + ServiceStatus { + manager: "launchd", + unit: LAUNCHD_LABEL, + running: false, + state: "inactive".to_string(), + } + ); + } + + #[test] + fn linux_status_reports_inactive_as_a_successful_observation() { + let (manager, unit, command) = status_command_for("linux", None).unwrap(); + let status = status_with(manager, unit, &command, |_| { + Ok(StatusOutput { + success: false, + stdout: "inactive".to_string(), + stderr: String::new(), + }) + }) + .unwrap(); + + assert_eq!( + status, + ServiceStatus { + manager: "systemd", + unit: SYSTEMD_UNIT, + running: false, + state: "inactive".to_string(), + } + ); + } + + #[test] + fn service_manager_operational_failure_is_an_error() { + let (manager, unit, command) = status_command_for("linux", None).unwrap(); + let error = status_with(manager, unit, &command, |_| { + Ok(StatusOutput { + success: false, + stdout: String::new(), + stderr: "Failed to connect to bus".to_string(), + }) + }) + .unwrap_err(); + + assert!(error.to_string().contains("Failed to connect to bus")); + } + #[test] fn unsupported_platform_reports_the_supported_hosts() { let error = command_for("windows", None).unwrap_err(); diff --git a/tests/json_cli.rs b/tests/json_cli.rs new file mode 100644 index 0000000..a8fa2d3 --- /dev/null +++ b/tests/json_cli.rs @@ -0,0 +1,631 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use rusqlite::params; +use serde_json::Value; +use uuid::Uuid; + +const SECRET: &str = "xoxb-json-contract-secret"; + +struct Fixture { + root: PathBuf, + home: PathBuf, + config: PathBuf, + assistant: PathBuf, +} + +impl Fixture { + fn new(name: &str) -> Self { + let root = std::env::temp_dir().join(format!("push-json-{name}-{}", Uuid::new_v4())); + let home = root.join("home"); + let assistant = root.join("assistant"); + let config_dir = home.join(".push"); + let config = config_dir.join("config.toml"); + std::fs::create_dir_all(assistant.join("jobs")).unwrap(); + std::fs::create_dir_all(assistant.join("evals")).unwrap(); + std::fs::create_dir_all(assistant.join("context")).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write(assistant.join("SOUL.md"), "# Assistant\n").unwrap(); + std::fs::write( + &config, + format!( + "channel = \"telegram\"\nagent = \"codex\"\nassistant_root = {:?}\n\n[telegram]\nbot_token = {SECRET:?}\nallow_user_ids = [123]\n", + assistant + ), + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + Self { + root, + home, + config, + assistant, + } + } + + fn command(&self) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_push")); + command + .arg("--json") + .args(["--config"]) + .arg(&self.config) + .env("HOME", &self.home) + .env_remove("PUSH_HOME"); + command + } + + fn install_job(&self, name: &str, contents: &str) { + std::fs::write( + self.assistant.join("jobs").join(format!("{name}.md")), + contents, + ) + .unwrap(); + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +fn valid_job() -> &'static str { + "+++\nversion = 1\ntimeout = \"5m\"\nbackend = \"codex\"\n\n[[triggers]]\nid = \"daily\"\nkind = \"cron\"\nschedule = \"0 9 * * *\"\ntimezone = \"Europe/London\"\nenabled = false\n+++\n\nInspect the assistant repository.\n" +} + +fn json_stdout(output: &Output) -> Value { + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.stderr.is_empty(), + "unexpected stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap() +} + +fn json_stderr(output: &Output) -> Value { + assert!(!output.status.success()); + assert!( + output.stdout.is_empty(), + "unexpected stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); + serde_json::from_slice(&output.stderr).unwrap() +} + +fn assert_keys(value: &Value, expected: &[&str]) { + let object = value.as_object().expect("value is an object"); + let actual = object.keys().map(String::as_str).collect::>(); + assert_eq!(actual, expected); +} + +fn assert_success_envelope(payload: &Value, command: &str) { + assert_keys(payload, &["command", "data", "ok", "schema_version"]); + assert_eq!(payload["schema_version"], 1); + assert_eq!(payload["ok"], true); + assert_eq!(payload["command"], command); + assert!(payload["data"].is_object()); +} + +#[test] +fn scoped_commands_emit_one_json_document_without_unrelated_output() { + let fixture = Fixture::new("success"); + fixture.install_job("daily", valid_job()); + + let paths = json_stdout(&fixture.command().arg("paths").output().unwrap()); + assert_success_envelope(&paths, "paths"); + assert_keys( + &paths["data"], + &[ + "assistant_context", + "assistant_evals", + "assistant_root", + "audit_log", + "cache", + "config", + "database", + "default_config", + "imessage_database", + "jobs", + "jobs_run", + "push_home", + "slack_inbox", + "state", + ], + ); + for value in paths["data"].as_object().unwrap().values() { + assert!(value.is_string()); + } + + for (subcommand, expected_command) in [("list", "job.list"), ("validate", "job.validate")] { + let payload = json_stdout( + &fixture + .command() + .args(["job", subcommand]) + .output() + .unwrap(), + ); + assert_success_envelope(&payload, expected_command); + assert_keys( + &payload["data"], + &["invalid", "invalid_count", "valid", "valid_count"], + ); + assert_eq!(payload["data"]["valid_count"], 1); + assert_eq!(payload["data"]["invalid_count"], 0); + assert_keys( + &payload["data"]["valid"][0], + &["backend", "name", "path", "status"], + ); + assert_eq!(payload["data"]["valid"][0]["name"], "daily"); + assert_eq!(payload["data"]["valid"][0]["status"], "valid"); + assert!(payload["data"]["valid"][0]["path"].is_string()); + assert!(payload["data"]["valid"][0]["backend"].is_string()); + assert!(payload["data"]["invalid"].as_array().unwrap().is_empty()); + } + + let show = json_stdout( + &fixture + .command() + .args(["job", "show", "daily"]) + .output() + .unwrap(), + ); + assert_success_envelope(&show, "job.show"); + assert_keys( + &show["data"], + &[ + "backend", + "body", + "evals", + "name", + "path", + "snapshot_hash", + "timeout_ms", + "triggers", + "workdir", + ], + ); + for field in [ + "backend", + "body", + "name", + "path", + "snapshot_hash", + "workdir", + ] { + assert!(show["data"][field].is_string(), "{field}"); + } + assert!(show["data"]["timeout_ms"].is_u64()); + assert!(show["data"]["evals"].is_array()); + assert_keys( + &show["data"]["triggers"][0], + &["enabled", "id", "kind", "schedule", "timezone"], + ); + assert!(show["data"]["triggers"][0]["enabled"].is_boolean()); + for field in ["id", "kind", "schedule", "timezone"] { + assert!(show["data"]["triggers"][0][field].is_string(), "{field}"); + } + + let runs = json_stdout(&fixture.command().args(["job", "runs"]).output().unwrap()); + assert_success_envelope(&runs, "job.runs"); + assert_keys(&runs["data"], &["job_name", "runs"]); + assert!(runs["data"]["job_name"].is_null()); + assert!(runs["data"]["runs"].is_array()); +} + +#[test] +fn paths_use_pushpaths_for_the_selected_runtime_home() { + let fixture = Fixture::new("push-paths"); + let push_home = fixture.root.join("isolated-runtime"); + let output = fixture + .command() + .arg("paths") + .env("PUSH_HOME", &push_home) + .output() + .unwrap(); + let payload = json_stdout(&output); + let data = &payload["data"]; + + assert_eq!(data["push_home"], push_home.to_string_lossy().as_ref()); + assert_eq!( + data["default_config"], + push_home.join("config.toml").to_string_lossy().as_ref() + ); + assert_eq!( + data["config"], + fixture + .config + .canonicalize() + .unwrap() + .to_string_lossy() + .as_ref() + ); + assert_eq!( + data["database"], + push_home.join("push.db").to_string_lossy().as_ref() + ); + assert_eq!( + data["state"], + push_home.join("state.json").to_string_lossy().as_ref() + ); + assert_eq!( + data["audit_log"], + push_home.join("audit.jsonl").to_string_lossy().as_ref() + ); + assert_eq!( + data["jobs_run"], + push_home.join("run").to_string_lossy().as_ref() + ); + assert_eq!( + data["slack_inbox"], + push_home + .join("state.json.slack-inbox.db") + .to_string_lossy() + .as_ref() + ); + assert_eq!( + data["cache"], + push_home.join("cache").to_string_lossy().as_ref() + ); +} + +#[test] +fn help_and_version_pin_their_json_schemas() { + for (command, fields) in [ + ("help", &["text"][..]), + ("version", &["name", "version"][..]), + ] { + let output = Command::new(env!("CARGO_BIN_EXE_push")) + .args(["--json", command]) + .output() + .unwrap(); + let payload = json_stdout(&output); + assert_success_envelope(&payload, command); + assert_keys(&payload["data"], fields); + for field in fields { + assert!(payload["data"][field].is_string()); + } + } +} + +#[test] +fn status_emits_json_with_a_fake_service_manager() { + let fixture = Fixture::new("status"); + let bin_dir = fixture.root.join("bin"); + std::fs::create_dir(&bin_dir).unwrap(); + let manager = if cfg!(target_os = "macos") { + "launchctl" + } else { + "systemctl" + }; + let manager_path = bin_dir.join(manager); + let status_output = if cfg!(target_os = "macos") { + "service = {\\n\\tstate = running\\n}" + } else { + "active" + }; + std::fs::write( + &manager_path, + format!("#!/bin/sh\nprintf '{status_output}\\n'\n"), + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&manager_path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let path = std::env::join_paths(std::iter::once(bin_dir).chain(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + ))) + .unwrap(); + + let output = fixture + .command() + .arg("status") + .env("PATH", path) + .output() + .unwrap(); + let payload = json_stdout(&output); + assert_success_envelope(&payload, "status"); + assert_keys(&payload["data"], &["manager", "running", "state", "unit"]); + assert!(payload["data"]["manager"].is_string()); + assert!(payload["data"]["unit"].is_string()); + assert_eq!(payload["data"]["running"], true); + assert_eq!(payload["data"]["state"], "active"); +} + +#[test] +fn doctor_success_is_machine_readable_and_redacts_credentials() { + let fixture = Fixture::new("doctor"); + let bin_dir = fixture.root.join("bin"); + std::fs::create_dir(&bin_dir).unwrap(); + make_executable(&bin_dir.join("codex")); + + let output = fixture + .command() + .arg("doctor") + .env("PATH", &bin_dir) + .output() + .unwrap(); + let payload = json_stdout(&output); + assert_success_envelope(&payload, "doctor"); + assert_keys(&payload["data"], &["checks"]); + let checks = payload["data"]["checks"].as_array().unwrap(); + assert!(!checks.is_empty()); + for check in checks { + assert_keys(check, &["message", "name", "status"]); + assert!(check["message"].is_string()); + assert!(check["name"].is_string()); + assert!(matches!(check["status"].as_str(), Some("pass" | "fail"))); + } + assert!(!String::from_utf8_lossy(&output.stdout).contains(SECRET)); +} + +#[test] +fn unavailable_service_manager_is_a_structured_error() { + let fixture = Fixture::new("status-unavailable"); + let bin_dir = fixture.root.join("bin"); + std::fs::create_dir(&bin_dir).unwrap(); + let manager = if cfg!(target_os = "macos") { + "launchctl" + } else { + "systemctl" + }; + let manager_path = bin_dir.join(manager); + std::fs::write( + &manager_path, + "#!/bin/sh\nprintf 'service manager unavailable\\n' >&2\nexit 1\n", + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&manager_path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + let path = std::env::join_paths(std::iter::once(bin_dir).chain(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + ))) + .unwrap(); + + let output = fixture + .command() + .arg("status") + .env("PATH", path) + .output() + .unwrap(); + let payload = json_stderr(&output); + assert_eq!(output.status.code(), Some(4)); + assert_eq!(payload["error"]["category"], "unavailable_dependency"); +} + +#[test] +fn mutations_are_rejected_before_config_loading_or_runtime_changes() { + let fixture = Fixture::new("mutation-rejection"); + for args in [ + Vec::<&str>::new(), + vec!["init"], + vec!["restart"], + vec!["job", "run", "daily"], + ] { + let output = fixture.command().args(&args).output().unwrap(); + let payload = json_stderr(&output); + assert_eq!(output.status.code(), Some(2), "{args:?}"); + assert_eq!(payload["error"]["category"], "invalid_input", "{args:?}"); + assert_eq!(payload["error"]["exit_code"], 2, "{args:?}"); + assert_eq!(payload["error"]["retryable"], false, "{args:?}"); + assert!( + payload["error"]["message"] + .as_str() + .unwrap() + .contains("--json is not supported"), + "{args:?}" + ); + } +} + +#[test] +fn validation_failure_is_json_on_stderr_with_invalid_input_exit_code() { + let fixture = Fixture::new("validation"); + fixture.install_job("broken", "not a runbook\n"); + + let output = fixture + .command() + .args(["job", "validate"]) + .output() + .unwrap(); + let payload = json_stderr(&output); + assert_eq!(output.status.code(), Some(2)); + assert_eq!(payload["error"]["category"], "invalid_input"); + assert_eq!(payload["error"]["exit_code"], 2); + assert_eq!(payload["error"]["retryable"], false); + assert_eq!(payload["error"]["details"]["invalid_count"], 1); + assert_keys( + &payload["error"]["details"]["invalid"][0], + &["message", "name", "path", "status"], + ); +} + +#[test] +fn missing_config_is_json_on_stderr_with_configuration_exit_code() { + let fixture = Fixture::new("missing"); + let missing = fixture.root.join("missing.toml"); + let output = Command::new(env!("CARGO_BIN_EXE_push")) + .args(["--json", "--config"]) + .arg(&missing) + .arg("paths") + .env("HOME", &fixture.home) + .output() + .unwrap(); + let payload = json_stderr(&output); + assert_eq!(output.status.code(), Some(3)); + assert_eq!(payload["error"]["category"], "configuration"); + assert_eq!(payload["error"]["exit_code"], 3); +} + +#[test] +fn unavailable_backend_is_json_on_stderr_without_secrets() { + let fixture = Fixture::new("backend"); + let empty_path = fixture.root.join("empty-bin"); + std::fs::create_dir(&empty_path).unwrap(); + + let output = fixture + .command() + .arg("doctor") + .env("PATH", empty_path) + .output() + .unwrap(); + let payload = json_stderr(&output); + assert_eq!(output.status.code(), Some(4)); + assert_eq!(payload["error"]["category"], "unavailable_dependency"); + assert_eq!(payload["error"]["exit_code"], 4); + assert!(!String::from_utf8_lossy(&output.stderr).contains(SECRET)); +} + +#[test] +fn malformed_config_errors_do_not_echo_secret_values() { + let fixture = Fixture::new("redaction"); + std::fs::write( + &fixture.config, + format!("telegram_bot_token = {SECRET:?}\ninvalid = ["), + ) + .unwrap(); + + let output = fixture.command().arg("paths").output().unwrap(); + let payload = json_stderr(&output); + assert_eq!(payload["error"]["category"], "configuration"); + assert!(!String::from_utf8_lossy(&output.stderr).contains(SECRET)); +} + +#[test] +fn missing_installed_job_is_a_configuration_error() { + let fixture = Fixture::new("missing-job"); + let output = fixture + .command() + .args(["job", "show", "absent"]) + .output() + .unwrap(); + let payload = json_stderr(&output); + assert_eq!(output.status.code(), Some(3)); + assert_eq!(payload["error"]["category"], "configuration"); +} + +#[cfg(target_os = "linux")] +#[test] +fn non_utf8_job_filename_still_produces_valid_json() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let fixture = Fixture::new("non-utf8"); + let filename = OsString::from_vec(b"invalid-\xff.md".to_vec()); + std::fs::write(fixture.assistant.join("jobs").join(filename), "invalid").unwrap(); + + let output = fixture.command().args(["job", "list"]).output().unwrap(); + let payload = json_stdout(&output); + assert_eq!(payload["data"]["invalid_count"], 1); + assert!(payload["data"]["invalid"][0]["path"].is_string()); +} + +#[test] +fn job_runs_json_omits_stored_content_fields() { + let fixture = Fixture::new("run-content"); + let initial = fixture.command().args(["job", "runs"]).output().unwrap(); + json_stdout(&initial); + let database = fixture.home.join(".push/push.db"); + let connection = rusqlite::Connection::open(database).unwrap(); + connection + .execute( + "INSERT INTO job_runs ( + id, job_name, snapshot_hash, trigger_kind, owner_kind, + queued_at_ms, backend, permission_profile, timeout_ms, workdir, + state, result, error, evaluation_state, evaluation_result, + evaluation_error, delivery_state, delivery_error + ) VALUES ( + ?1, 'daily', 'hash', 'manual', 'manual_cli', + 1, 'codex', 'agent', 1000, '/tmp', + 'failed', ?2, ?3, 'error', ?4, ?5, 'failed', ?6 + )", + params![ + "run-1", + "stored-result-secret", + "stored-error-secret", + "stored-evaluation-secret", + "stored-evaluation-error-secret", + "stored-delivery-error-secret", + ], + ) + .unwrap(); + connection + .execute( + "INSERT INTO channel_cursors (channel, cursor) VALUES ('telegram', 42)", + [], + ) + .unwrap(); + connection + .execute( + "INSERT INTO backend_sessions ( + channel, thread_key, backend, session_id, started + ) VALUES ('telegram', 'dm:123', 'codex', ?1, 1)", + ["sqlite-session-secret"], + ) + .unwrap(); + + let output = fixture.command().args(["job", "runs"]).output().unwrap(); + let payload = json_stdout(&output); + assert_success_envelope(&payload, "job.runs"); + assert_keys(&payload["data"], &["job_name", "runs"]); + let run = &payload["data"]["runs"][0]; + assert_keys( + run, + &[ + "backend", + "delivery", + "evaluation", + "execution", + "id", + "job_name", + "queued_at_ms", + "state", + "trigger", + ], + ); + assert_keys(&run["trigger"], &["id", "kind", "scheduled_at_ms"]); + assert_keys(&run["execution"], &["has_error", "has_result"]); + assert_keys(&run["evaluation"], &["has_error", "has_result", "state"]); + assert_keys( + &run["delivery"], + &["attempts", "channel", "has_error", "state", "target"], + ); + let text = payload.to_string(); + assert_eq!(payload["data"]["runs"][0]["execution"]["has_result"], true); + assert_eq!(payload["data"]["runs"][0]["execution"]["has_error"], true); + for secret in [ + "stored-result-secret", + "stored-error-secret", + "stored-evaluation-secret", + "stored-evaluation-error-secret", + "stored-delivery-error-secret", + "sqlite-session-secret", + ] { + assert!(!text.contains(secret)); + } +} + +#[cfg(unix)] +fn make_executable(path: &Path) { + use std::os::unix::fs::PermissionsExt; + std::fs::write(path, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); +} + +#[cfg(not(unix))] +fn make_executable(path: &Path) { + std::fs::write(path, "").unwrap(); +}