From d2a38eddc60379c166d4ad25ae06dcfbb9e3f373 Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 18 Mar 2026 11:36:26 +0200 Subject: [PATCH 1/8] feat(agent): improve execution contract for agent reliability - Semantic exit codes: errors now exit with typed codes (1=validation, 2=auth, 3=api, 4=discovery, 5=internal) instead of always exiting 1 - `gws exit-codes`: new command outputs machine-readable exit code taxonomy as JSON for agent retry/abort branching - Schema enrichment: `gws schema ` now includes `timeout_ms` (30000) and `exit_codes` array so agents can self-document a method - SIGTERM handling: `gws gmail +watch` and `gws events +subscribe` now handle SIGTERM for clean shutdown alongside existing Ctrl+C support - `--idempotency-key`: new global flag injects an `Idempotency-Key` HTTP header on POST/PUT/PATCH requests; surfaced in --dry-run output --- .changeset/execution-contract.md | 11 ++++++ src/commands.rs | 7 ++++ src/error.rs | 65 ++++++++++++++++++++++++++++++++ src/executor.rs | 15 ++++++++ src/helpers/calendar.rs | 1 + src/helpers/chat.rs | 1 + src/helpers/docs.rs | 1 + src/helpers/drive.rs | 1 + src/helpers/events/subscribe.rs | 27 ++++++++++++- src/helpers/gmail/mod.rs | 1 + src/helpers/gmail/watch.rs | 24 ++++++++++++ src/helpers/script.rs | 1 + src/helpers/sheets.rs | 2 + src/main.rs | 16 +++++++- src/schema.rs | 2 + 15 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 .changeset/execution-contract.md diff --git a/.changeset/execution-contract.md b/.changeset/execution-contract.md new file mode 100644 index 00000000..e8ec0478 --- /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=validation, 2=auth, 3=api, 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/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..4759d92c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -190,6 +190,20 @@ fn error_label(err: &GwsError) -> String { } } +/// Returns the exit code taxonomy as a JSON value for `gws exit-codes` and schema output. +pub fn exit_codes_json() -> serde_json::Value { + serde_json::json!({ + "exit_codes": [ + { "code": 0, "reason": "success", "meaning": "Command completed successfully" }, + { "code": GwsError::EXIT_CODE_API, "reason": "apiError", "meaning": "Remote API returned an error response" }, + { "code": GwsError::EXIT_CODE_AUTH, "reason": "authError", "meaning": "Missing or invalid credentials" }, + { "code": GwsError::EXIT_CODE_VALIDATION, "reason": "validationError", "meaning": "Bad input or wrong arguments" }, + { "code": GwsError::EXIT_CODE_DISCOVERY, "reason": "discoveryError", "meaning": "Could not load API Discovery document" }, + { "code": GwsError::EXIT_CODE_OTHER, "reason": "internalError", "meaning": "Unexpected or transient failure" }, + ] + }) +} + /// 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 +363,57 @@ mod tests { assert_eq!(json["error"]["reason"], "internalError"); } + // --- exit_code tests --- + + #[test] + fn test_exit_code_validation() { + let err = GwsError::Validation("bad input".to_string()); + assert_eq!(err.exit_code(), 1); + } + + #[test] + fn test_exit_code_auth() { + let err = GwsError::Auth("no creds".to_string()); + assert_eq!(err.exit_code(), 2); + } + + #[test] + fn test_exit_code_api() { + let err = GwsError::Api { + code: 404, + message: "not found".to_string(), + reason: "notFound".to_string(), + enable_url: None, + }; + assert_eq!(err.exit_code(), 3); + } + + #[test] + fn test_exit_code_discovery() { + let err = GwsError::Discovery("no doc".to_string()); + assert_eq!(err.exit_code(), 4); + } + + #[test] + fn test_exit_code_other() { + let err = GwsError::Other(anyhow::anyhow!("oops")); + assert_eq!(err.exit_code(), 5); + } + + #[test] + fn test_exit_codes_json_contains_all_codes() { + let val = exit_codes_json(); + let codes: Vec = val["exit_codes"] + .as_array() + .unwrap() + .iter() + .map(|e| e["code"].as_i64().unwrap()) + .collect(); + for expected in 0..=5 { + assert!(codes.contains(&expected), "missing code {expected}"); + } + } + // --- 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..0648baec 100644 --- a/src/helpers/events/subscribe.rs +++ b/src/helpers/events/subscribe.rs @@ -320,6 +320,11 @@ async fn pull_loop( pubsub_api_base: &str, ) -> Result<(), GwsError> { let mut file_counter: u64 = 0; + + #[cfg(unix)] + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to register SIGTERM handler"); + loop { let token = token_provider .access_token() @@ -337,6 +342,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 +361,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 +427,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..94ca84b8 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -263,6 +263,10 @@ async fn watch_pull_loop( last_history_id: &mut u64, config: WatchConfig, ) -> Result<(), GwsError> { + #[cfg(unix)] + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to register SIGTERM handler"); + loop { let pubsub_token = runtime .pubsub_token_provider @@ -279,6 +283,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 +302,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 +360,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/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..f8aec516 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,15 @@ async fn run() -> Result<(), GwsError> { return schema::handle_schema_command(path, resolve_refs).await; } + // Handle the `exit-codes` command + if first_arg == "exit-codes" { + println!( + "{}", + serde_json::to_string_pretty(&exit_codes_json()).unwrap_or_default() + ); + return Ok(()); + } + // Handle the `generate-skills` command if first_arg == "generate-skills" { let gen_args: Vec = args.iter().skip(2).cloned().collect(); @@ -251,6 +260,9 @@ async fn run() -> Result<(), GwsError> { }; let dry_run = matched_args.get_flag("dry-run"); + let idempotency_key = matched_args + .get_one::("idempotency-key") + .map(|s| s.as_str()); // Build pagination config from flags let pagination = parse_pagination_config(matched_args); @@ -293,6 +305,7 @@ async fn run() -> Result<(), GwsError> { &sanitize_config.mode, &output_format, false, + idempotency_key, ) .await .map(|_| ()) @@ -449,6 +462,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..bac21076 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -155,6 +155,8 @@ fn build_schema_output(doc: &RestDescription, method: &RestMethod) -> Value { "description": method.description.as_deref().unwrap_or(""), "parameters": params, "scopes": method.scopes, + "timeout_ms": 30_000_u32, + "exit_codes": crate::error::exit_codes_json()["exit_codes"].clone(), }); if !method.parameter_order.is_empty() { From 96fd71b6c5490c823801f55c10301ad4a8933b76 Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 18 Mar 2026 11:36:26 +0200 Subject: [PATCH 2/8] docs: document agent execution contract in README Add exit code taxonomy, --idempotency-key usage, and gws exit-codes examples to the "For AI agents" section so agents can discover the retry/abort contract without reading source. --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 14b0fa5b..38e04208 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 = bad input, 2 = auth, 3 = API error, 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 From 84fc563d68834e49835e0593c6c24295e2461c64 Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 18 Mar 2026 11:36:26 +0200 Subject: [PATCH 3/8] fix: propagate SIGTERM registration failure instead of panicking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace .expect() with .context()? so a failure to register the SIGTERM handler returns a GwsError::Other rather than panicking — consistent with the clean-shutdown goal. Addresses review comment from gemini-code-assist. --- src/error.rs | 37 --------------------------------- src/helpers/events/subscribe.rs | 2 +- src/helpers/gmail/watch.rs | 2 +- 3 files changed, 2 insertions(+), 39 deletions(-) diff --git a/src/error.rs b/src/error.rs index 4759d92c..48dfaba6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -363,43 +363,6 @@ mod tests { assert_eq!(json["error"]["reason"], "internalError"); } - // --- exit_code tests --- - - #[test] - fn test_exit_code_validation() { - let err = GwsError::Validation("bad input".to_string()); - assert_eq!(err.exit_code(), 1); - } - - #[test] - fn test_exit_code_auth() { - let err = GwsError::Auth("no creds".to_string()); - assert_eq!(err.exit_code(), 2); - } - - #[test] - fn test_exit_code_api() { - let err = GwsError::Api { - code: 404, - message: "not found".to_string(), - reason: "notFound".to_string(), - enable_url: None, - }; - assert_eq!(err.exit_code(), 3); - } - - #[test] - fn test_exit_code_discovery() { - let err = GwsError::Discovery("no doc".to_string()); - assert_eq!(err.exit_code(), 4); - } - - #[test] - fn test_exit_code_other() { - let err = GwsError::Other(anyhow::anyhow!("oops")); - assert_eq!(err.exit_code(), 5); - } - #[test] fn test_exit_codes_json_contains_all_codes() { let val = exit_codes_json(); diff --git a/src/helpers/events/subscribe.rs b/src/helpers/events/subscribe.rs index 0648baec..6df1056d 100644 --- a/src/helpers/events/subscribe.rs +++ b/src/helpers/events/subscribe.rs @@ -323,7 +323,7 @@ async fn pull_loop( #[cfg(unix)] let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("failed to register SIGTERM handler"); + .context("failed to register SIGTERM handler")?; loop { let token = token_provider diff --git a/src/helpers/gmail/watch.rs b/src/helpers/gmail/watch.rs index 94ca84b8..adfd9c3f 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -265,7 +265,7 @@ async fn watch_pull_loop( ) -> Result<(), GwsError> { #[cfg(unix)] let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("failed to register SIGTERM handler"); + .context("failed to register SIGTERM handler")?; loop { let pubsub_token = runtime From 4acb08ead82f356a950bd207de079d552f428443 Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 18 Mar 2026 12:26:55 +0200 Subject: [PATCH 4/8] fix: single source of truth for exit code taxonomy, handle serialization error - Introduce EXIT_CODE_TABLE as the canonical (code, reason, meaning) array; EXIT_CODE_DOCUMENTATION and exit_codes_json() both derive from it so the three representations can no longer drift - Replace unwrap_or_default() in the exit-codes command with proper error propagation so a serialization failure surfaces as GwsError::Other instead of silently printing empty output Addresses gemini-code-assist review comments on PR #538. --- src/error.rs | 60 ++++++++++++++++++++++++++++++++++------------------ src/main.rs | 7 +++--- 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/src/error.rs b/src/error.rs index 48dfaba6..4e6bd02c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -39,29 +39,50 @@ 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, keyed by (code, description). +/// +/// Used by `print_usage()` so the help text stays in sync with the +/// constants defined above without requiring manual updates in two places. +pub const EXIT_CODE_DOCUMENTATION: &[(i32, &str)] = &[ + (EXIT_CODE_TABLE[0].0, EXIT_CODE_TABLE[0].2), + (EXIT_CODE_TABLE[1].0, EXIT_CODE_TABLE[1].2), + (EXIT_CODE_TABLE[2].0, EXIT_CODE_TABLE[2].2), + (EXIT_CODE_TABLE[3].0, EXIT_CODE_TABLE[3].2), + (EXIT_CODE_TABLE[4].0, EXIT_CODE_TABLE[4].2), + (EXIT_CODE_TABLE[5].0, EXIT_CODE_TABLE[5].2), ]; impl GwsError { @@ -191,17 +212,16 @@ 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 { - serde_json::json!({ - "exit_codes": [ - { "code": 0, "reason": "success", "meaning": "Command completed successfully" }, - { "code": GwsError::EXIT_CODE_API, "reason": "apiError", "meaning": "Remote API returned an error response" }, - { "code": GwsError::EXIT_CODE_AUTH, "reason": "authError", "meaning": "Missing or invalid credentials" }, - { "code": GwsError::EXIT_CODE_VALIDATION, "reason": "validationError", "meaning": "Bad input or wrong arguments" }, - { "code": GwsError::EXIT_CODE_DISCOVERY, "reason": "discoveryError", "meaning": "Could not load API Discovery document" }, - { "code": GwsError::EXIT_CODE_OTHER, "reason": "internalError", "meaning": "Unexpected or transient failure" }, - ] - }) + 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. diff --git a/src/main.rs b/src/main.rs index f8aec516..a0424b47 100644 --- a/src/main.rs +++ b/src/main.rs @@ -127,10 +127,9 @@ async fn run() -> Result<(), GwsError> { // Handle the `exit-codes` command if first_arg == "exit-codes" { - println!( - "{}", - serde_json::to_string_pretty(&exit_codes_json()).unwrap_or_default() - ); + 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(()); } From 4318d6fc27562ecb1340de823980b5bf49847a4a Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 18 Mar 2026 12:45:44 +0200 Subject: [PATCH 5/8] fix: derive EXIT_CODE_DOCUMENTATION from table, fix wrong mapping in docs - Replace hardcoded EXIT_CODE_TABLE index literals in EXIT_CODE_DOCUMENTATION with a const macro loop so adding/reordering entries never silently breaks the documentation array - Derive test_exit_codes_json_contains_all_codes expected codes from EXIT_CODE_TABLE instead of hardcoded 0..=5 - Fix incorrect exit code mapping in README and changeset (was 1=validation, 3=api; correct is 1=api, 3=validation per EXIT_CODE_API/VALIDATION constants) Addresses gemini-code-assist review 3966742572. --- .changeset/execution-contract.md | 2 +- README.md | 2 +- src/error.rs | 35 ++++++++++++++++++++------------ 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/.changeset/execution-contract.md b/.changeset/execution-contract.md index e8ec0478..a3a9f70c 100644 --- a/.changeset/execution-contract.md +++ b/.changeset/execution-contract.md @@ -4,7 +4,7 @@ Improve execution contract for agent reliability: -- **Semantic exit codes**: Errors now exit with typed codes (1=validation, 2=auth, 3=api, 4=discovery, 5=internal) instead of always exiting 1 +- **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) diff --git a/README.md b/README.md index 38e04208..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. Exit codes are semantic (1 = bad input, 2 = auth, 3 = API error, 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. +**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 diff --git a/src/error.rs b/src/error.rs index 4e6bd02c..0fec4b66 100644 --- a/src/error.rs +++ b/src/error.rs @@ -72,18 +72,24 @@ pub const EXIT_CODE_TABLE: &[(i32, &str, &str)] = &[ ), ]; -/// Human-readable exit code table, keyed by (code, description). +/// Human-readable exit code table derived from [`EXIT_CODE_TABLE`]: (code, meaning). /// -/// Used by `print_usage()` so the help text stays in sync with the -/// constants defined above without requiring manual updates in two places. -pub const EXIT_CODE_DOCUMENTATION: &[(i32, &str)] = &[ - (EXIT_CODE_TABLE[0].0, EXIT_CODE_TABLE[0].2), - (EXIT_CODE_TABLE[1].0, EXIT_CODE_TABLE[1].2), - (EXIT_CODE_TABLE[2].0, EXIT_CODE_TABLE[2].2), - (EXIT_CODE_TABLE[3].0, EXIT_CODE_TABLE[3].2), - (EXIT_CODE_TABLE[4].0, EXIT_CODE_TABLE[4].2), - (EXIT_CODE_TABLE[5].0, EXIT_CODE_TABLE[5].2), -]; +/// Used by `print_usage()` so the help text stays in sync without manual updates. +/// Defined as a macro-generated array so it remains a `const` while still being +/// derived programmatically from `EXIT_CODE_TABLE`. +macro_rules! exit_code_documentation { + () => {{ + 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 + }}; +} +pub const EXIT_CODE_DOCUMENTATION: [(i32, &str); EXIT_CODE_TABLE.len()] = + exit_code_documentation!(); impl GwsError { /// Exit code for [`GwsError::Api`] variants. @@ -392,8 +398,11 @@ mod tests { .iter() .map(|e| e["code"].as_i64().unwrap()) .collect(); - for expected in 0..=5 { - assert!(codes.contains(&expected), "missing code {expected}"); + for (expected, _, _) in EXIT_CODE_TABLE { + assert!( + codes.contains(&(*expected as i64)), + "missing code {expected}" + ); } } From 14c2627a63694c78b0bf23e3a2b6005b41284c8f Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 18 Mar 2026 13:00:27 +0200 Subject: [PATCH 6/8] fix: enforce advertised timeout on HTTP client, reject empty idempotency key - Extract SCHEMA_TIMEOUT_MS constant from schema.rs and apply it as the reqwest client timeout in client.rs so the contract advertised in schema output is actually enforced on every request - Filter out empty --idempotency-key values so passing "" behaves the same as omitting the flag instead of sending a blank Idempotency-Key header Addresses gemini-code-assist review 3966832626. --- src/client.rs | 3 +++ src/main.rs | 2 +- src/schema.rs | 5 ++++- 3 files changed, 8 insertions(+), 2 deletions(-) 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/main.rs b/src/main.rs index a0424b47..f4ac8679 100644 --- a/src/main.rs +++ b/src/main.rs @@ -261,7 +261,7 @@ async fn run() -> Result<(), GwsError> { let dry_run = matched_args.get_flag("dry-run"); let idempotency_key = matched_args .get_one::("idempotency-key") - .map(|s| s.as_str()); + .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); diff --git a/src/schema.rs b/src/schema.rs index bac21076..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,7 +158,7 @@ fn build_schema_output(doc: &RestDescription, method: &RestMethod) -> Value { "description": method.description.as_deref().unwrap_or(""), "parameters": params, "scopes": method.scopes, - "timeout_ms": 30_000_u32, + "timeout_ms": SCHEMA_TIMEOUT_MS, "exit_codes": crate::error::exit_codes_json()["exit_codes"].clone(), }); From c2f2b981d18fb80a18626e13a26555738f7fb672 Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 18 Mar 2026 13:17:30 +0200 Subject: [PATCH 7/8] test: use HashSet equality in exit codes test to catch duplicates and extras Addresses gemini-code-assist review 3966901258. --- src/error.rs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/error.rs b/src/error.rs index 0fec4b66..f95ed336 100644 --- a/src/error.rs +++ b/src/error.rs @@ -391,19 +391,25 @@ mod tests { #[test] fn test_exit_codes_json_contains_all_codes() { + use std::collections::HashSet; + let val = exit_codes_json(); - let codes: Vec = val["exit_codes"] + let actual: HashSet = val["exit_codes"] .as_array() - .unwrap() + .expect("exit_codes must be an array") .iter() - .map(|e| e["code"].as_i64().unwrap()) + .map(|e| e["code"].as_i64().expect("code must be an integer")) .collect(); - for (expected, _, _) in EXIT_CODE_TABLE { - assert!( - codes.contains(&(*expected as i64)), - "missing code {expected}" - ); - } + + 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 --- From 8fbb88ab04e534b5160ab4f6592a63e6d210ea14 Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 18 Mar 2026 13:28:20 +0200 Subject: [PATCH 8/8] refactor: const fn for EXIT_CODE_DOCUMENTATION, shared SIGTERM registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace macro_rules! with a const fn build_exit_code_documentation() — same compile-time derivation from EXIT_CODE_TABLE, more idiomatic Rust - Extract SIGTERM signal registration into helpers::register_sigterm() so gmail::watch and events::subscribe share one definition instead of two Addresses gemini-code-assist review 3966990380. --- src/error.rs | 25 +++++++++++-------------- src/helpers/events/subscribe.rs | 3 +-- src/helpers/gmail/watch.rs | 3 +-- src/helpers/mod.rs | 12 ++++++++++++ 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/error.rs b/src/error.rs index f95ed336..53e19a1b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -75,21 +75,18 @@ pub const EXIT_CODE_TABLE: &[(i32, &str, &str)] = &[ /// 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. -/// Defined as a macro-generated array so it remains a `const` while still being -/// derived programmatically from `EXIT_CODE_TABLE`. -macro_rules! exit_code_documentation { - () => {{ - 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 - }}; -} pub const EXIT_CODE_DOCUMENTATION: [(i32, &str); EXIT_CODE_TABLE.len()] = - exit_code_documentation!(); + 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. diff --git a/src/helpers/events/subscribe.rs b/src/helpers/events/subscribe.rs index 6df1056d..8075d3b4 100644 --- a/src/helpers/events/subscribe.rs +++ b/src/helpers/events/subscribe.rs @@ -322,8 +322,7 @@ async fn pull_loop( let mut file_counter: u64 = 0; #[cfg(unix)] - let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .context("failed to register SIGTERM handler")?; + let mut sigterm = super::super::register_sigterm()?; loop { let token = token_provider diff --git a/src/helpers/gmail/watch.rs b/src/helpers/gmail/watch.rs index adfd9c3f..27a625e4 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -264,8 +264,7 @@ async fn watch_pull_loop( config: WatchConfig, ) -> Result<(), GwsError> { #[cfg(unix)] - let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .context("failed to register SIGTERM handler")?; + let mut sigterm = super::super::register_sigterm()?; loop { let pubsub_token = runtime 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