Skip to content
11 changes: 11 additions & 0 deletions .changeset/execution-contract.md
Original file line number Diff line number Diff line change
@@ -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 <method>` 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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ pub fn build_client() -> Result<reqwest::Client, crate::error::GwsError> {

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}"))
Expand Down
7 changes: 7 additions & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
80 changes: 70 additions & 10 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<serde_json::Value> = 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
Expand Down Expand Up @@ -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<i64> = 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<i64> = 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]
Expand Down
15 changes: 15 additions & 0 deletions src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ async fn build_http_request(
page_token: Option<&str>,
pages_fetched: u32,
upload: &Option<UploadSource<'_>>,
idempotency_key: Option<&str>,
) -> Result<reqwest::RequestBuilder, GwsError> {
let mut request = match method.http_method.as_str() {
"GET" => client.get(&input.full_url),
Expand All @@ -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);
Expand Down Expand Up @@ -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<Option<Value>, GwsError> {
let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload.is_some())?;

Expand All @@ -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));
Expand All @@ -446,6 +455,7 @@ pub async fn execute_method(
page_token.as_deref(),
pages_fetched,
&upload,
idempotency_key,
)
.await?;

Expand Down Expand Up @@ -2096,6 +2106,7 @@ async fn test_execute_method_dry_run() {
&sanitize_mode,
&crate::formatter::OutputFormat::default(),
false,
None,
)
.await;

Expand Down Expand Up @@ -2139,6 +2150,7 @@ async fn test_execute_method_missing_path_param() {
&sanitize_mode,
&crate::formatter::OutputFormat::default(),
false,
None,
)
.await;

Expand Down Expand Up @@ -2310,6 +2322,7 @@ async fn test_post_without_body_sets_content_length_zero() {
None,
0,
&None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -2350,6 +2363,7 @@ async fn test_post_with_body_does_not_add_content_length_zero() {
None,
0,
&None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -2388,6 +2402,7 @@ async fn test_get_does_not_set_content_length_zero() {
None,
0,
&None,
None,
)
.await
.unwrap();
Expand Down
1 change: 1 addition & 0 deletions src/helpers/calendar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ TIPS:
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
None,
)
.await?;

Expand Down
1 change: 1 addition & 0 deletions src/helpers/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ TIPS:
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
None,
)
.await?;

Expand Down
1 change: 1 addition & 0 deletions src/helpers/docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ TIPS:
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
None,
)
.await?;

Expand Down
1 change: 1 addition & 0 deletions src/helpers/drive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ TIPS:
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
None,
)
.await?;

Expand Down
26 changes: 25 additions & 1 deletion src/helpers/events/subscribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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::<Option<()>>();

let resp = tokio::select! {
result = pull_future => {
match result {
Expand All @@ -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() {
Expand Down Expand Up @@ -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::<Option<()>>();

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;
}
}
}

Expand Down
1 change: 1 addition & 0 deletions src/helpers/gmail/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,7 @@ pub(super) async fn send_raw_email(
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
None,
)
.await?;

Expand Down
Loading
Loading