diff --git a/.changeset/execution-contract.md b/.changeset/execution-contract.md new file mode 100644 index 00000000..a3a9f70c --- /dev/null +++ b/.changeset/execution-contract.md @@ -0,0 +1,11 @@ +--- +"googleworkspace-cli": minor +--- + +Improve execution contract for agent reliability: + +- **Semantic exit codes**: Errors now exit with typed codes (1=api, 2=auth, 3=validation, 4=discovery, 5=internal) instead of always exiting 1 +- **`gws exit-codes`**: New command outputs machine-readable exit code taxonomy as JSON +- **Schema enrichment**: `gws schema ` now includes `timeout_ms` (30000) and `exit_codes` in output +- **SIGTERM handling**: `gws gmail +watch` and `gws events +subscribe` now handle SIGTERM for clean shutdown (in addition to Ctrl+C) +- **`--idempotency-key`**: New global flag sends an `Idempotency-Key` HTTP header on POST/PUT/PATCH requests diff --git a/README.md b/README.md index 14b0fa5b..4314e479 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ gws drive files list --params '{"pageSize": 5}' **For humans** — stop writing `curl` calls against REST docs. `gws` gives you `--help` on every resource, `--dry-run` to preview requests, and auto‑pagination. -**For AI agents** — every response is structured JSON. Pair it with the included agent skills and your LLM can manage Workspace without custom tooling. +**For AI agents** — every response is structured JSON. Pair it with the included agent skills and your LLM can manage Workspace without custom tooling. Exit codes are semantic (1 = API error, 2 = auth, 3 = bad input, 4 = discovery, 5 = internal) so agents can branch on retry vs. abort without parsing stderr. Every schema includes `timeout_ms` and the full `exit_codes` table. Use `--idempotency-key` on mutating commands for safe retries. ```bash # List the 10 most recent files @@ -100,11 +100,17 @@ gws chat spaces messages create \ --json '{"text": "Deploy complete."}' \ --dry-run -# Introspect any method's request/response schema +# Introspect any method's request/response schema (includes timeout_ms + exit_codes) gws schema drive.files.list # Stream paginated results as NDJSON gws drive files list --params '{"pageSize": 100}' --page-all | jq -r '.files[].name' + +# Safe retry — idempotency key prevents duplicate mutations +gws drive files create --json '{"name": "report.pdf"}' --idempotency-key my-key-123 + +# Machine-readable exit code taxonomy for agent branching +gws exit-codes | jq '.exit_codes[] | select(.code == 3)' ``` ## Authentication diff --git a/src/client.rs b/src/client.rs index e0abe409..0ca01924 100644 --- a/src/client.rs +++ b/src/client.rs @@ -13,6 +13,9 @@ pub fn build_client() -> Result { reqwest::Client::builder() .default_headers(headers) + .timeout(std::time::Duration::from_millis( + crate::schema::SCHEMA_TIMEOUT_MS.into(), + )) .build() .map_err(|e| { crate::error::GwsError::Other(anyhow::anyhow!("Failed to build HTTP client: {e}")) diff --git a/src/commands.rs b/src/commands.rs index 27324e42..8dc0f784 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -40,6 +40,13 @@ pub fn build_cli(doc: &RestDescription) -> Command { .action(clap::ArgAction::SetTrue) .global(true), ) + .arg( + clap::Arg::new("idempotency-key") + .long("idempotency-key") + .value_name("KEY") + .help("Idempotency key sent as 'Idempotency-Key' HTTP header for POST/PUT/PATCH requests") + .global(true), + ) .arg( clap::Arg::new("format") .long("format") diff --git a/src/error.rs b/src/error.rs index 2fd4d795..53e19a1b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -39,31 +39,55 @@ pub enum GwsError { Other(#[from] anyhow::Error), } -/// Human-readable exit code table, keyed by (code, description). +/// Single source of truth for the exit code taxonomy: (code, reason, meaning). /// -/// Used by `print_usage()` so the help text stays in sync with the -/// constants defined below without requiring manual updates in two places. -pub const EXIT_CODE_DOCUMENTATION: &[(i32, &str)] = &[ - (0, "Success"), +/// Consumed by both [`exit_codes_json()`] (machine-readable) and +/// [`EXIT_CODE_DOCUMENTATION`] (human-readable help text). +pub const EXIT_CODE_TABLE: &[(i32, &str, &str)] = &[ + (0, "success", "Command completed successfully"), ( GwsError::EXIT_CODE_API, - "API error — Google returned an error response", + "apiError", + "Remote API returned an error response", ), ( GwsError::EXIT_CODE_AUTH, - "Auth error — credentials missing or invalid", + "authError", + "Missing or invalid credentials", ), ( GwsError::EXIT_CODE_VALIDATION, - "Validation — bad arguments or input", + "validationError", + "Bad input or wrong arguments", ), ( GwsError::EXIT_CODE_DISCOVERY, - "Discovery — could not fetch API schema", + "discoveryError", + "Could not load API Discovery document", + ), + ( + GwsError::EXIT_CODE_OTHER, + "internalError", + "Unexpected or transient failure", ), - (GwsError::EXIT_CODE_OTHER, "Internal — unexpected failure"), ]; +/// Human-readable exit code table derived from [`EXIT_CODE_TABLE`]: (code, meaning). +/// +/// Used by `print_usage()` so the help text stays in sync without manual updates. +pub const EXIT_CODE_DOCUMENTATION: [(i32, &str); EXIT_CODE_TABLE.len()] = + build_exit_code_documentation(); + +const fn build_exit_code_documentation() -> [(i32, &'static str); EXIT_CODE_TABLE.len()] { + let mut out = [(0i32, ""); EXIT_CODE_TABLE.len()]; + let mut i = 0; + while i < EXIT_CODE_TABLE.len() { + out[i] = (EXIT_CODE_TABLE[i].0, EXIT_CODE_TABLE[i].2); + i += 1; + } + out +} + impl GwsError { /// Exit code for [`GwsError::Api`] variants. pub const EXIT_CODE_API: i32 = 1; @@ -190,6 +214,19 @@ fn error_label(err: &GwsError) -> String { } } +/// Returns the exit code taxonomy as a JSON value for `gws exit-codes` and schema output. +/// +/// Derived from [`EXIT_CODE_TABLE`] — no duplication with the constants. +pub fn exit_codes_json() -> serde_json::Value { + let codes: Vec = EXIT_CODE_TABLE + .iter() + .map(|(code, reason, meaning)| { + serde_json::json!({ "code": code, "reason": reason, "meaning": meaning }) + }) + .collect(); + serde_json::json!({ "exit_codes": codes }) +} + /// Formats any error as a JSON object and prints to stdout. /// /// A human-readable colored label is printed to stderr when connected to a @@ -349,6 +386,29 @@ mod tests { assert_eq!(json["error"]["reason"], "internalError"); } + #[test] + fn test_exit_codes_json_contains_all_codes() { + use std::collections::HashSet; + + let val = exit_codes_json(); + let actual: HashSet = val["exit_codes"] + .as_array() + .expect("exit_codes must be an array") + .iter() + .map(|e| e["code"].as_i64().expect("code must be an integer")) + .collect(); + + let expected: HashSet = EXIT_CODE_TABLE + .iter() + .map(|(code, _, _)| *code as i64) + .collect(); + + assert_eq!( + actual, expected, + "exit_codes_json() codes must exactly match EXIT_CODE_TABLE (no duplicates, no extras)" + ); + } + // --- accessNotConfigured tests --- #[test] diff --git a/src/executor.rs b/src/executor.rs index b6b8dadf..763d56ce 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -166,6 +166,7 @@ async fn build_http_request( page_token: Option<&str>, pages_fetched: u32, upload: &Option>, + idempotency_key: Option<&str>, ) -> Result { let mut request = match method.http_method.as_str() { "GET" => client.get(&input.full_url), @@ -186,6 +187,12 @@ async fn build_http_request( } } + if let Some(key) = idempotency_key { + if matches!(method.http_method.as_str(), "POST" | "PUT" | "PATCH") { + request = request.header("Idempotency-Key", key); + } + } + // Set quota project from ADC for billing/quota attribution if let Some(quota_project) = crate::auth::get_quota_project() { request = request.header("x-goog-user-project", quota_project); @@ -409,6 +416,7 @@ pub async fn execute_method( sanitize_mode: &crate::helpers::modelarmor::SanitizeMode, output_format: &crate::formatter::OutputFormat, capture_output: bool, + idempotency_key: Option<&str>, ) -> Result, GwsError> { let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload.is_some())?; @@ -420,6 +428,7 @@ pub async fn execute_method( "query_params": input.query_params, "body": input.body, "is_multipart_upload": input.is_upload, + "idempotency_key": idempotency_key, }); if capture_output { return Ok(Some(dry_run_info)); @@ -446,6 +455,7 @@ pub async fn execute_method( page_token.as_deref(), pages_fetched, &upload, + idempotency_key, ) .await?; @@ -2096,6 +2106,7 @@ async fn test_execute_method_dry_run() { &sanitize_mode, &crate::formatter::OutputFormat::default(), false, + None, ) .await; @@ -2139,6 +2150,7 @@ async fn test_execute_method_missing_path_param() { &sanitize_mode, &crate::formatter::OutputFormat::default(), false, + None, ) .await; @@ -2310,6 +2322,7 @@ async fn test_post_without_body_sets_content_length_zero() { None, 0, &None, + None, ) .await .unwrap(); @@ -2350,6 +2363,7 @@ async fn test_post_with_body_does_not_add_content_length_zero() { None, 0, &None, + None, ) .await .unwrap(); @@ -2388,6 +2402,7 @@ async fn test_get_does_not_set_content_length_zero() { None, 0, &None, + None, ) .await .unwrap(); diff --git a/src/helpers/calendar.rs b/src/helpers/calendar.rs index cf28b249..966e64f3 100644 --- a/src/helpers/calendar.rs +++ b/src/helpers/calendar.rs @@ -193,6 +193,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + None, ) .await?; diff --git a/src/helpers/chat.rs b/src/helpers/chat.rs index 676dc78b..9ff06534 100644 --- a/src/helpers/chat.rs +++ b/src/helpers/chat.rs @@ -116,6 +116,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + None, ) .await?; diff --git a/src/helpers/docs.rs b/src/helpers/docs.rs index d3ef7fa2..ea5a6c11 100644 --- a/src/helpers/docs.rs +++ b/src/helpers/docs.rs @@ -106,6 +106,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + None, ) .await?; diff --git a/src/helpers/drive.rs b/src/helpers/drive.rs index 68662ec6..44aac70f 100644 --- a/src/helpers/drive.rs +++ b/src/helpers/drive.rs @@ -120,6 +120,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + None, ) .await?; diff --git a/src/helpers/events/subscribe.rs b/src/helpers/events/subscribe.rs index 3a1c8e13..8075d3b4 100644 --- a/src/helpers/events/subscribe.rs +++ b/src/helpers/events/subscribe.rs @@ -320,6 +320,10 @@ async fn pull_loop( pubsub_api_base: &str, ) -> Result<(), GwsError> { let mut file_counter: u64 = 0; + + #[cfg(unix)] + let mut sigterm = super::super::register_sigterm()?; + loop { let token = token_provider .access_token() @@ -337,6 +341,13 @@ async fn pull_loop( .timeout(std::time::Duration::from_secs(config.poll_interval.max(10))) .send(); + // Hoist SIGTERM future outside select! — #[cfg] attributes are not supported + // inside tokio::select! branches; we use a cfg'd let binding instead. + #[cfg(unix)] + let sigterm_recv = sigterm.recv(); + #[cfg(not(unix))] + let sigterm_recv = std::future::pending::>(); + let resp = tokio::select! { result = pull_future => { match result { @@ -349,6 +360,10 @@ async fn pull_loop( eprintln!("\nReceived interrupt, stopping..."); return Ok(()); } + _ = sigterm_recv => { + eprintln!("\nReceived SIGTERM, stopping..."); + return Ok(()); + } }; if !resp.status().is_success() { @@ -411,13 +426,22 @@ async fn pull_loop( break; } - // Check for SIGINT between polls + // Check for SIGINT/SIGTERM between polls + #[cfg(unix)] + let sigterm_sleep = sigterm.recv(); + #[cfg(not(unix))] + let sigterm_sleep = std::future::pending::>(); + tokio::select! { _ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {}, _ = tokio::signal::ctrl_c() => { eprintln!("\nReceived interrupt, stopping..."); break; } + _ = sigterm_sleep => { + eprintln!("\nReceived SIGTERM, stopping..."); + break; + } } } diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index b9ed7c8e..8cd4297a 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -810,6 +810,7 @@ pub(super) async fn send_raw_email( &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + None, ) .await?; diff --git a/src/helpers/gmail/watch.rs b/src/helpers/gmail/watch.rs index 5785fa7a..27a625e4 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -263,6 +263,9 @@ async fn watch_pull_loop( last_history_id: &mut u64, config: WatchConfig, ) -> Result<(), GwsError> { + #[cfg(unix)] + let mut sigterm = super::super::register_sigterm()?; + loop { let pubsub_token = runtime .pubsub_token_provider @@ -279,6 +282,13 @@ async fn watch_pull_loop( .timeout(std::time::Duration::from_secs(config.poll_interval.max(10))) .send(); + // Hoist SIGTERM future outside select! — #[cfg] attributes are not supported + // inside tokio::select! branches; we use a cfg'd let binding instead. + #[cfg(unix)] + let sigterm_recv = sigterm.recv(); + #[cfg(not(unix))] + let sigterm_recv = std::future::pending::>(); + let resp = tokio::select! { result = pull_future => { match result { @@ -291,6 +301,10 @@ async fn watch_pull_loop( eprintln!("\nReceived interrupt, stopping..."); return Ok(()); } + _ = sigterm_recv => { + eprintln!("\nReceived SIGTERM, stopping..."); + return Ok(()); + } }; if !resp.status().is_success() { @@ -345,12 +359,21 @@ async fn watch_pull_loop( break; } + #[cfg(unix)] + let sigterm_sleep = sigterm.recv(); + #[cfg(not(unix))] + let sigterm_sleep = std::future::pending::>(); + tokio::select! { _ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {}, _ = tokio::signal::ctrl_c() => { eprintln!("\nReceived interrupt, stopping..."); break; } + _ = sigterm_sleep => { + eprintln!("\nReceived SIGTERM, stopping..."); + break; + } } } diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index 72d31272..798d8d3a 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -27,6 +27,18 @@ pub mod script; pub mod sheets; pub mod workflows; +/// Register a SIGTERM signal handler. +/// +/// Shared by `gmail::watch` and `events::subscribe` so the registration +/// pattern is defined once. Returns an error if the OS rejects the handler. +#[cfg(unix)] +pub(crate) fn register_sigterm() -> Result { + use anyhow::Context as _; + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .context("failed to register SIGTERM handler") + .map_err(Into::into) +} + /// Base URL for the Google Cloud Pub/Sub v1 API. /// /// Shared across `events::subscribe` and `gmail::watch` so the constant diff --git a/src/helpers/script.rs b/src/helpers/script.rs index 11bcdebe..0d54d16e 100644 --- a/src/helpers/script.rs +++ b/src/helpers/script.rs @@ -129,6 +129,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + None, ) .await?; diff --git a/src/helpers/sheets.rs b/src/helpers/sheets.rs index 8f67f8d9..ad0860ef 100644 --- a/src/helpers/sheets.rs +++ b/src/helpers/sheets.rs @@ -143,6 +143,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + None, ) .await?; @@ -186,6 +187,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + None, ) .await?; diff --git a/src/main.rs b/src/main.rs index 2fe5efc7..f4ac8679 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,7 +42,7 @@ mod timezone; mod token_storage; pub(crate) mod validate; -use error::{print_error_json, GwsError}; +use error::{exit_codes_json, print_error_json, GwsError}; #[tokio::main] async fn main() { @@ -125,6 +125,14 @@ async fn run() -> Result<(), GwsError> { return schema::handle_schema_command(path, resolve_refs).await; } + // Handle the `exit-codes` command + if first_arg == "exit-codes" { + let json = serde_json::to_string_pretty(&exit_codes_json()) + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to serialize exit codes: {e}")))?; + println!("{json}"); + return Ok(()); + } + // Handle the `generate-skills` command if first_arg == "generate-skills" { let gen_args: Vec = args.iter().skip(2).cloned().collect(); @@ -251,6 +259,9 @@ async fn run() -> Result<(), GwsError> { }; let dry_run = matched_args.get_flag("dry-run"); + let idempotency_key = matched_args + .get_one::("idempotency-key") + .and_then(|s| if s.is_empty() { None } else { Some(s.as_str()) }); // Build pagination config from flags let pagination = parse_pagination_config(matched_args); @@ -293,6 +304,7 @@ async fn run() -> Result<(), GwsError> { &sanitize_config.mode, &output_format, false, + idempotency_key, ) .await .map(|_| ()) @@ -449,6 +461,7 @@ fn print_usage() { println!(" gws sheets spreadsheets get --params '{{\"spreadsheetId\": \"...\"}}'"); println!(" gws gmail users messages list --params '{{\"userId\": \"me\"}}'"); println!(" gws schema drive.files.list"); + println!(" gws exit-codes"); println!(); println!("FLAGS:"); println!(" --params URL/Query parameters as JSON"); diff --git a/src/schema.rs b/src/schema.rs index f6f54821..febda25b 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -20,6 +20,9 @@ use serde_json::{json, Value}; +/// The request timeout advertised in schema output and enforced by the HTTP client. +pub const SCHEMA_TIMEOUT_MS: u32 = 30_000; + use crate::discovery::{ fetch_discovery_document, JsonSchema, MethodParameter, RestDescription, RestMethod, RestResource, @@ -155,6 +158,8 @@ fn build_schema_output(doc: &RestDescription, method: &RestMethod) -> Value { "description": method.description.as_deref().unwrap_or(""), "parameters": params, "scopes": method.scopes, + "timeout_ms": SCHEMA_TIMEOUT_MS, + "exit_codes": crate::error::exit_codes_json()["exit_codes"].clone(), }); if !method.parameter_order.is_empty() {