Give oa the published exit ladder and the --json error envelope

b3cac99daedf · AtlantisPleb · · parent 4f3a557fd818

Give oa the published exit ladder and the --json error envelope

A machine consuming `oa` could not tell what went wrong. Every refusal left
through `cli::fail`, which exits 2, so an expired token, a typo, a missing
repository, and an outage were one status; and 1 was reserved for internal
errors, inverted from the convention where 1 is generic and 2 is usage.

The ladder is not a new design. `packages/openagents-cli/src/errors.ts`
publishes one that consumers already code against and that release automation
keys on for 17, 18, and 19, so `crates/openagents-cli/src/errors.rs`
transcribes it rather than reinterpreting it: 2 input, 3 auth, 4 not-found,
5 conflict, 6 transport and 5xx, 7 import and provisioning, 8-15 Computer
pairing, 17-19 deployment. 16 stays retired.

`or_fail` now takes `Into<CliError>` rather than `Display`, which is what
routes a failure to its rung without touching its 129 call sites: a type
declares a rung with a `From` impl, and a type without one is an input error,
which is what it exited as before. `ApiError::Refused` carries the server's
`code` and the request id — `x-request-id` first, the body's `request_id`
second, the order the TypeScript transport resolves them in. A `--wait` that
runs out is now `ApiError::Timeout`, so `deploy view --wait` reports 18 (the
CLI stopped watching) rather than 17 (the fleet rejected the bytes).

Under `--json` a failure prints `{"code","message","exit_code","request_id"}`
on stdout and nothing on stderr, the shape `main.ts` builds, with
`request_id` omitted rather than null when the server sent none. Every
`--json` document is now one line: `emit` and `print_json` stringify
compactly, as `output.ts` does, because pretty-printing broke every NDJSON
consumer that worked against `openagents`.

Commands that took `--json` and did nothing with it now answer with a
document: `trace list`/`show`/`redact` publish the same schema names as
`trace-command.ts`, `plugin list`/`search`/`inspect`/`run` answer as data,
`api` sends the body compactly under the flag and pretty-printed without it,
`delegate` publishes `{agent, cwd, outcomes}` and moves its per-child
commentary to stderr, and `update` reports the outcome it already decided.

Measured side by side against a stub, `oa` here and
`packages/openagents-cli/dist/main.js`:

  HTTP 401  oa exit=3  openagents exit=3
  HTTP 404  oa exit=4  openagents exit=4
  HTTP 409  oa exit=5  openagents exit=5
  HTTP 500  oa exit=6  openagents exit=6
  dead port oa exit=6  openagents exit=6, both code transport_error

`tests/parity_test.rs` keeps its rule that nothing asserts bare `is_err()`,
because an unplugged cable satisfies that as well as a 404 does. Every new
test names the status or the code it expects. The ladder is also asserted arm
by arm, including the rungs no HTTP status reaches.

Refs #88.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/openagents-cli/src/api_passthrough.rs
  • modified crates/openagents-cli/src/box_client.rs
  • modified crates/openagents-cli/src/cli.rs
  • modified crates/openagents-cli/src/delegate.rs
  • added crates/openagents-cli/src/errors.rs
  • modified crates/openagents-cli/src/fleet.rs
  • modified crates/openagents-cli/src/lib.rs
  • modified crates/openagents-cli/src/main.rs
  • modified crates/openagents-cli/src/memory_client.rs
  • modified crates/openagents-cli/src/plugins.rs
  • modified crates/openagents-cli/src/trace_client.rs
  • modified crates/openagents-cli/src/tracker.rs
  • modified crates/openagents-cli/src/update.rs
  • modified crates/openagents-cli/tests/flags.rs
  • modified crates/openagents-cli/tests/parity_test.rs

Diff

15 files changed, +1390 -193

crates/openagents-cli/src/api_passthrough.rs modified +29 -12

@@ -510,7 +510,7 @@ fn pretty(value: &serde_json::Value) -> String {

510 510
    serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_string())
511 511
}
512 512
513
pub async fn run(args: ApiArgs, endpoint: &crate::auth::Endpoint, _json: bool) {
513
pub async fn run(args: ApiArgs, endpoint: &crate::auth::Endpoint, json: bool) {
514 514
    let fail = crate::cli::fail;
515 515
516 516
    // `oa api <METHOD> <PATH>` stays accepted, so the shape this command shipped

@@ -592,27 +592,44 @@ pub async fn run(args: ApiArgs, endpoint: &crate::auth::Endpoint, _json: bool) {

592 592
    };
593 593
594 594
    if !response.successful() {
595
        match &response.body {
596
            Some(value) => eprintln!("{}", pretty(value)),
597
            None if !response.text.trim().is_empty() => eprintln!("{}", response.text.trim_end()),
598
            None => {}
599
        }
600 595
        let details = api_error_details(response.body.as_ref());
601 596
        let request_id = response.request_id.clone().or(details.request_id);
602
        if let Some(id) = &request_id {
603
            eprintln!("Request id: {id}");
604
        }
605 597
        let summary = format!(
606 598
            "the API returned HTTP {} for {method} {request_path}",
607 599
            response.status
608 600
        );
609
        match details.message {
610
            Some(message) => fail(&format!("{summary}. {message}")),
611
            None => fail(&summary),
601
        let message = match &details.message {
602
            Some(text) => format!("{summary}. {text}"),
603
            None => summary,
604
        };
605
        // Under `--json` the envelope is the whole answer: a second copy of
606
        // the body on stderr and a `Request id:` line are prose a consumer did
607
        // not ask for, and the request id is a field of the envelope already.
608
        if !json {
609
            match &response.body {
610
                Some(value) => eprintln!("{}", pretty(value)),
611
                None if !response.text.trim().is_empty() => {
612
                    eprintln!("{}", response.text.trim_end())
613
                }
614
                None => {}
615
            }
616
            if let Some(id) = &request_id {
617
                eprintln!("Request id: {id}");
618
            }
612 619
        }
620
        crate::errors::fail(&crate::errors::CliError::Api {
621
            status: response.status,
622
            code: details.code,
623
            message,
624
            request_id,
625
        });
613 626
    }
614 627
615 628
    match &response.body {
629
        // `--json` is a machine contract, so the body goes out on one line the
630
        // way `openagents api --json` sends it. Without the flag it is
631
        // pretty-printed, which is what a person at a terminal wants.
632
        Some(value) if json => println!("{}", serde_json::Value::to_string(value)),
616 633
        Some(value) => println!("{}", pretty(value)),
617 634
        // A 2xx that is not JSON is shown exactly as it arrived. Replacing it
618 635
        // with `{}` or `null` would be the CLI inventing a body.
crates/openagents-cli/src/box_client.rs modified +12 -1

@@ -19,7 +19,7 @@ use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYP

19 19
use serde::{Deserialize, Serialize};
20 20
use serde_json::{json, Value};
21 21
22
use crate::tracker::{error_sentence, urlencode, ApiError};
22
use crate::tracker::{error_fields, error_sentence, header_request_id, urlencode, ApiError};
23 23
24 24
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25 25
pub struct BoxRecord {

@@ -261,6 +261,9 @@ impl BoxClient {

261 261
        })?;
262 262
        let status = response.status().as_u16();
263 263
        crate::diag::response(status, &url);
264
        // Read before the body is consumed; the header outranks the body's own
265
        // `request_id`, as it does in the TypeScript transport.
266
        let header_id = header_request_id(&response);
264 267
        let text = response.text().await.map_err(|e| ApiError::Transport {
265 268
            operation: operation.to_string(),
266 269
            why: e.to_string(),

@@ -269,10 +272,13 @@ impl BoxClient {

269 272
        if !accepted.contains(&status) {
270 273
            let message = error_sentence(&text, status);
271 274
            crate::diag::refused(status, &message);
275
            let (code, body_id) = error_fields(&text);
272 276
            return Err(ApiError::Refused {
273 277
                operation: operation.to_string(),
274 278
                status,
275 279
                message,
280
                code,
281
                request_id: header_id.or(body_id),
276 282
            });
277 283
        }
278 284
        if text.trim().is_empty() {

@@ -345,6 +351,11 @@ impl BoxClient {

345 351
            message: "This deployment does not report a conversation for the account. \
346 352
                      Pass --conversation <conversation_id> to name the conversation to use."
347 353
                .to_string(),
354
            // This refusal is the client's summary of two the server sent, so
355
            // no single `code` or request id belongs to it. The status is the
356
            // server's, and the status is what the ladder reads.
357
            code: None,
358
            request_id: None,
348 359
        })
349 360
    }
350 361
crates/openagents-cli/src/cli.rs modified +176 -64

@@ -1266,6 +1266,9 @@ pub fn completion_script(shell: CompletionShell) -> String {

1266 1266
pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
1267 1267
    crate::diag::set_verbose(cli.verbose);
1268 1268
    crate::diag::set_color(!cli.no_color);
1269
    // The failure path is reached from several hundred sites that never took
1270
    // the flag, so it is recorded once here and read there.
1271
    crate::errors::set_json(cli.json);
1269 1272
1270 1273
    // `--completions` writes a script and stops. It reaches no endpoint and
1271 1274
    // needs no token, so it is answered before either is resolved.

@@ -1375,6 +1378,7 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

1375 1378
                crate::delegate::run_delegation(
1376 1379
                    crate::delegate::DelegationRequest::from_coder(coder),
1377 1380
                    token,
1381
                    cli.json,
1378 1382
                )
1379 1383
                .await?;
1380 1384
            } else if coder.offline {

@@ -1421,6 +1425,7 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

1421 1425
            crate::delegate::run_delegation(
1422 1426
                crate::delegate::DelegationRequest::from_delegate(args),
1423 1427
                token,
1428
                cli.json,
1424 1429
            )
1425 1430
            .await?;
1426 1431
        }

@@ -1435,7 +1440,7 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

1435 1440
                    // A refusal ends the command. The version this replaces answered
1436 1441
                    // a non-2xx with two hardcoded boards, one of which the server
1437 1442
                    // has never served.
1438
                    let boards = client.list_boards().await.unwrap_or_else(|e| fail(&e.to_string()));
1443
                    let boards = or_fail(client.list_boards().await);
1439 1444
                    let human: Vec<String> = if boards.is_empty() {
1440 1445
                        vec!["No boards found.".to_string()]
1441 1446
                    } else {

@@ -1459,10 +1464,7 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

1459 1464
                    emit(cli.json, &value, &human);
1460 1465
                }
1461 1466
                ForumAction::Topics { board, page } => {
1462
                    let list = client
1463
                        .list_topics(&board, page)
1464
                        .await
1465
                        .unwrap_or_else(|e| fail(&e.to_string()));
1467
                    let list = or_fail(client.list_topics(&board, page).await);
1466 1468
                    emit(
1467 1469
                        cli.json,
1468 1470
                        &crate::forum::topic_list_value(&list),

@@ -1473,10 +1475,7 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

1473 1475
                    if query.trim().is_empty() {
1474 1476
                        fail("Pass the words to search for.");
1475 1477
                    }
1476
                    let list = client
1477
                        .search_topics(&query, board.as_deref(), page)
1478
                        .await
1479
                        .unwrap_or_else(|e| fail(&e.to_string()));
1478
                    let list = or_fail(client.search_topics(&query, board.as_deref(), page).await);
1480 1479
                    emit(
1481 1480
                        cli.json,
1482 1481
                        &crate::forum::topic_list_value(&list),

@@ -1484,10 +1483,7 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

1484 1483
                    );
1485 1484
                }
1486 1485
                ForumAction::Topic { id, page } => {
1487
                    let topic = client
1488
                        .read_topic(&id, page)
1489
                        .await
1490
                        .unwrap_or_else(|e| fail(&e.to_string()));
1486
                    let topic = or_fail(client.read_topic(&id, page).await);
1491 1487
                    emit(
1492 1488
                        cli.json,
1493 1489
                        &crate::forum::topic_page_value(&topic),

@@ -1501,20 +1497,43 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

1501 1497
        Commands::Plugin(plugin) => crate::plugins::run(plugin, cli.json).await,
1502 1498
        Commands::Trace(trace) => run_trace(trace.action, &api_base, token, cli.json).await,
1503 1499
        Commands::Update(update) => {
1504
            crate::update::run(update.channel, update.version, update.check, update.force).await?;
1500
            let outcome = crate::update::run(
1501
                update.channel,
1502
                update.version,
1503
                update.check,
1504
                update.force,
1505
                cli.json,
1506
            )
1507
            .await?;
1508
            if cli.json {
1509
                print_json(&outcome.document());
1510
            }
1505 1511
        }
1506 1512
    }
1507 1513
    Ok(())
1508 1514
}
1509 1515
1510
/// Print a refusal on stderr and exit non-zero.
1516
/// Refuse an input the CLI will not act on: exit 2, the usage status.
1511 1517
///
1512 1518
/// Exit code 2 is what the TypeScript CLI returns for an input or configuration
1513 1519
/// error, and the point of this whole path: a command that cannot reach its data
1514 1520
/// says so and exits non-zero rather than returning something plausible.
1521
///
1522
/// This used to be the *only* refusal path, so a 404, an expired token, and a
1523
/// misspelled flag all left here with the same status. A failure the server
1524
/// caused goes through [`or_fail`] instead, which classifies it. Reserve this
1525
/// for what the caller typed.
1515 1526
pub(crate) fn fail(message: &str) -> ! {
1516
    eprintln!("oa: {}", message);
1517
    std::process::exit(2)
1527
    crate::errors::fail(&crate::errors::CliError::Input(message.to_string()))
1528
}
1529
1530
/// Refuse with a class of the caller's choosing.
1531
///
1532
/// The escape hatch for failures that are neither an input error nor a server
1533
/// refusal — a deployment that reached `failed`, a document that would not
1534
/// render — so each reaches its own rung of the ladder.
1535
pub(crate) fn fail_as(error: crate::errors::CliError) -> ! {
1536
    crate::errors::fail(&error)
1518 1537
}
1519 1538
1520 1539
// ---------------------------------------------------------------------------

@@ -1533,10 +1552,18 @@ use crate::auth::{

1533 1552
///
1534 1553
/// That is the whole difference between reporting what the server said and
1535 1554
/// printing an empty list that reads as "there is nothing".
1536
fn or_fail<T, E: std::fmt::Display>(result: Result<T, E>) -> T {
1555
/// Unwrap, or report the failure on the rung of the ladder it belongs to.
1556
///
1557
/// The bound is `Into<CliError>` rather than `Display` on purpose. `Display`
1558
/// let every failure in the crate reach this function and leave it as exit 2,
1559
/// which is how an expired token, a missing repository, and a misspelled flag
1560
/// came to be indistinguishable to a caller. A type that wants a rung of its
1561
/// own declares it with a `From` impl in `crate::errors`; a type with no impl
1562
/// is an input error, which is what it exited as before.
1563
fn or_fail<T, E: Into<crate::errors::CliError>>(result: Result<T, E>) -> T {
1537 1564
    match result {
1538 1565
        Ok(value) => value,
1539
        Err(error) => fail(&error.to_string()),
1566
        Err(error) => crate::errors::fail(&error.into()),
1540 1567
    }
1541 1568
}
1542 1569

@@ -1854,10 +1881,14 @@ async fn run_auth_status(endpoint: &Endpoint, store: &CredentialStore, json: boo

1854 1881
// repo
1855 1882
// ---------------------------------------------------------------------------
1856 1883
1884
/// One `--json` document, on one line. See [`emit`] for why it is not
1885
/// pretty-printed.
1857 1886
fn print_json(value: &serde_json::Value) {
1858
    match serde_json::to_string_pretty(value) {
1887
    match serde_json::to_string(value) {
1859 1888
        Ok(text) => println!("{text}"),
1860
        Err(error) => fail(&format!("could not render JSON output: {error}")),
1889
        Err(error) => fail_as(crate::errors::CliError::Output(format!(
1890
            "could not render JSON output: {error}"
1891
        ))),
1861 1892
    }
1862 1893
}
1863 1894

@@ -2091,11 +2122,19 @@ fn home_directory() -> std::path::PathBuf {

2091 2122
// ---------------------------------------------------------------------------
2092 2123
2093 2124
/// Print the server's body verbatim under `--json`, or the human lines.
2125
///
2126
/// One line, not pretty-printed. The TypeScript CLI stringifies compactly
2127
/// (`output.ts`), so a consumer reading `oa … --json` in a loop gets one
2128
/// document per line; pretty-printing spread each document over dozens of
2129
/// lines and broke every NDJSON reader that worked against `openagents`.
2094 2130
fn emit(json: bool, value: &serde_json::Value, human: &[String]) {
2095 2131
    if json {
2096
        match serde_json::to_string_pretty(value) {
2132
        match serde_json::to_string(value) {
2097 2133
            Ok(text) => println!("{}", text),
2098
            Err(error) => fail(&format!("Could not render JSON: {}", error)),
2134
            Err(error) => fail_as(crate::errors::CliError::Output(format!(
2135
                "Could not render JSON: {}",
2136
                error
2137
            ))),
2099 2138
        }
2100 2139
    } else {
2101 2140
        for line in human {

@@ -2104,6 +2143,25 @@ fn emit(json: bool, value: &serde_json::Value, human: &[String]) {

2104 2143
    }
2105 2144
}
2106 2145
2146
/// A serializable value with a `schema` field in front of it.
2147
///
2148
/// The TypeScript commands publish `{ schema: "…", ...result }`, which is a
2149
/// spread. Rust has no spread, so the fields are merged here rather than
2150
/// restated field by field at each call — restating them is how `forum search
2151
/// --json` came to drop five of them.
2152
fn schema_document<T: serde::Serialize>(schema: &str, value: &T) -> serde_json::Value {
2153
    let mut document = serde_json::Map::new();
2154
    document.insert("schema".to_string(), schema.into());
2155
    if let Ok(serde_json::Value::Object(fields)) = serde_json::to_value(value) {
2156
        document.extend(fields);
2157
    }
2158
    serde_json::Value::Object(document)
2159
}
2160
2161
fn trace_summary_document(summary: &crate::trace::TraceSummary) -> serde_json::Value {
2162
    schema_document("openagents.trace_summary.v1", summary)
2163
}
2164
2107 2165
fn field(value: &serde_json::Value, key: &str) -> String {
2108 2166
    match value.get(key) {
2109 2167
        Some(serde_json::Value::String(text)) => text.clone(),

@@ -3738,9 +3796,14 @@ async fn run_trace(action: TraceAction, api_base: &str, token: Option<String>, j

3738 3796
            };
3739 3797
            let (scans, candidates) = trace::discover(&specs, bounds);
3740 3798
3799
            let mut human: Vec<String> = Vec::new();
3741 3800
            for scan in &scans {
3742 3801
                if !scan.present {
3743
                    println!("{}: {} (not present)", scan.kind.as_str(), scan.root.display());
3802
                    human.push(format!(
3803
                        "{}: {} (not present)",
3804
                        scan.kind.as_str(),
3805
                        scan.root.display()
3806
                    ));
3744 3807
                    continue;
3745 3808
                }
3746 3809
                let mut line = format!(

@@ -3757,21 +3820,34 @@ async fn run_trace(action: TraceAction, api_base: &str, token: Option<String>, j

3757 3820
                    line.push_str(", scan truncated at its entry budget");
3758 3821
                }
3759 3822
                line.push(')');
3760
                println!("{}", line);
3823
                human.push(line);
3761 3824
            }
3762 3825
3763 3826
            if candidates.is_empty() {
3764
                println!("No trace files found.");
3827
                human.push("No trace files found.".to_string());
3765 3828
            }
3766
            for candidate in candidates {
3767
                println!(
3829
            for candidate in &candidates {
3830
                human.push(format!(
3768 3831
                    "{}  {}  {}B  {}",
3769 3832
                    candidate.kind.as_str(),
3770 3833
                    candidate.modified_at,
3771 3834
                    candidate.bytes,
3772 3835
                    candidate.path.display()
3773
                );
3836
                ));
3774 3837
            }
3838
3839
            // The document the TypeScript CLI publishes for this command
3840
            // (`trace-command.ts:151`): the same schema name and the same two
3841
            // arrays, so a consumer that reads one reads the other.
3842
            emit(
3843
                json,
3844
                &serde_json::json!({
3845
                    "schema": "openagents.trace_list.v1",
3846
                    "stores": scans,
3847
                    "traces": candidates,
3848
                }),
3849
                &human,
3850
            );
3775 3851
        }
3776 3852
        TraceAction::Show { trace: argument } => {
3777 3853
            // An argument that resolves to nothing is refused. The version this

@@ -3786,35 +3862,39 @@ async fn run_trace(action: TraceAction, api_base: &str, token: Option<String>, j

3786 3862
                ))
3787 3863
            });
3788 3864
3789
            println!("File: {}", summary.path.display());
3865
            let mut human: Vec<String> = vec![format!("File: {}", summary.path.display())];
3790 3866
            if summary.format != "atif" {
3791 3867
                let described = if summary.format == "jsonl" {
3792 3868
                    "line-delimited session log (not ATIF)"
3793 3869
                } else {
3794 3870
                    "unknown"
3795 3871
                };
3796
                println!("Format: {}", described);
3797
                println!("Size: {} bytes", summary.bytes);
3872
                human.push(format!("Format: {}", described));
3873
                human.push(format!("Size: {} bytes", summary.bytes));
3798 3874
                if let Some(lines) = summary.lines {
3799
                    println!("Lines: {}", lines);
3875
                    human.push(format!("Lines: {}", lines));
3800 3876
                }
3801
                println!("This slice summarizes ATIF documents only; foreign logs get metadata.");
3877
                human.push(
3878
                    "This slice summarizes ATIF documents only; foreign logs get metadata."
3879
                        .to_string(),
3880
                );
3881
                emit(json, &trace_summary_document(&summary), &human);
3802 3882
                return;
3803 3883
            }
3804 3884
3805
            println!(
3885
            human.push(format!(
3806 3886
                "Schema: {}",
3807 3887
                summary.schema_version.as_deref().unwrap_or("(missing schema_version)")
3808
            );
3888
            ));
3809 3889
            if let Some(session) = &summary.session_id {
3810
                println!("Session: {}", session);
3890
                human.push(format!("Session: {}", session));
3811 3891
            }
3812 3892
            if summary.agent_name.is_some() || summary.agent_model.is_some() {
3813
                println!(
3893
                human.push(format!(
3814 3894
                    "Agent: {} ({})",
3815 3895
                    summary.agent_name.as_deref().unwrap_or("unknown"),
3816 3896
                    summary.agent_model.as_deref().unwrap_or("unknown model")
3817
                );
3897
                ));
3818 3898
            }
3819 3899
            let sources = summary
3820 3900
                .steps_by_source

@@ -3827,28 +3907,31 @@ async fn run_trace(action: TraceAction, api_base: &str, token: Option<String>, j

3827 3907
                        .join(", ")
3828 3908
                })
3829 3909
                .unwrap_or_default();
3830
            println!("Steps: {} ({})", summary.steps.unwrap_or(0), sources);
3831
            let models = summary.models.unwrap_or_default();
3832
            println!(
3910
            human.push(format!("Steps: {} ({})", summary.steps.unwrap_or(0), sources));
3911
            let models = summary.models.clone().unwrap_or_default();
3912
            human.push(format!(
3833 3913
                "Models: {}",
3834 3914
                if models.is_empty() {
3835 3915
                    "(none recorded)".to_string()
3836 3916
                } else {
3837 3917
                    models.join(", ")
3838 3918
                }
3919
            ));
3920
            human.push(format!("Tool calls: {}", summary.tool_calls.unwrap_or(0)));
3921
            human.push(
3922
                match (summary.total_prompt_tokens, summary.total_completion_tokens) {
3923
                    (None, None) => "Tokens: not recorded".to_string(),
3924
                    (prompt, completion) => format!(
3925
                        "Tokens: {} prompt, {} completion",
3926
                        prompt.unwrap_or(0),
3927
                        completion.unwrap_or(0)
3928
                    ),
3929
                },
3839 3930
            );
3840
            println!("Tool calls: {}", summary.tool_calls.unwrap_or(0));
3841
            match (summary.total_prompt_tokens, summary.total_completion_tokens) {
3842
                (None, None) => println!("Tokens: not recorded"),
3843
                (prompt, completion) => println!(
3844
                    "Tokens: {} prompt, {} completion",
3845
                    prompt.unwrap_or(0),
3846
                    completion.unwrap_or(0)
3847
                ),
3848
            }
3849 3931
            if let (Some(first), Some(last)) = (&summary.first_timestamp, &summary.last_timestamp) {
3850
                println!("Span: {} to {}", first, last);
3932
                human.push(format!("Span: {} to {}", first, last));
3851 3933
            }
3934
            emit(json, &trace_summary_document(&summary), &human);
3852 3935
        }
3853 3936
        TraceAction::Redact { trace: argument, file } => {
3854 3937
            let argument = match argument.or(file) {

@@ -3873,9 +3956,9 @@ async fn run_trace(action: TraceAction, api_base: &str, token: Option<String>, j

3873 3956
                ))
3874 3957
            });
3875 3958
3876
            println!("Wrote {}", result.output.display());
3959
            let mut human: Vec<String> = vec![format!("Wrote {}", result.output.display())];
3877 3960
            if result.total == 0 {
3878
                println!("Nothing matched the redaction rules.");
3961
                human.push("Nothing matched the redaction rules.".to_string());
3879 3962
            } else {
3880 3963
                // Counts per category, never the matched text.
3881 3964
                let detail = result

@@ -3884,18 +3967,24 @@ async fn run_trace(action: TraceAction, api_base: &str, token: Option<String>, j

3884 3967
                    .map(|(category, count)| format!("{} {}", category, count))
3885 3968
                    .collect::<Vec<_>>()
3886 3969
                    .join(", ");
3887
                println!(
3970
                human.push(format!(
3888 3971
                    "Redacted {} match{}: {}",
3889 3972
                    result.total,
3890 3973
                    if result.total == 1 { "" } else { "es" },
3891 3974
                    detail
3892
                );
3975
                ));
3893 3976
            }
3894 3977
            if result.valid_json == Some(false) {
3895
                println!(
3978
                human.push(
3896 3979
                    "Warning: the redacted copy no longer parses as JSON; review it before sharing."
3980
                        .to_string(),
3897 3981
                );
3898 3982
            }
3983
            emit(
3984
                json,
3985
                &schema_document("openagents.trace_redaction.v1", &result),
3986
                &human,
3987
            );
3899 3988
        }
3900 3989
        TraceAction::Upload {
3901 3990
            trace: argument,

@@ -4051,7 +4140,7 @@ async fn run_deploy(action: DeployAction, api_base: &str, token: Option<String>,

4051 4140
                );
4052 4141
                return;
4053 4142
            }
4054
            let target = or_fail(
4143
            let target = or_fail_deploy_wait(
4055 4144
                client
4056 4145
                    .wait(&target_id, std::time::Duration::from_secs(wait_timeout))
4057 4146
                    .await,

@@ -4143,7 +4232,7 @@ async fn run_deploy(action: DeployAction, api_base: &str, token: Option<String>,

4143 4232
                );
4144 4233
                return;
4145 4234
            }
4146
            let target = or_fail(
4235
            let target = or_fail_deploy_wait(
4147 4236
                client
4148 4237
                    .wait(&id, std::time::Duration::from_secs(wait_timeout))
4149 4238
                    .await,

@@ -4170,21 +4259,44 @@ async fn run_deploy(action: DeployAction, api_base: &str, token: Option<String>,

4170 4259
///
4171 4260
/// `failed` and `reverted` are a deployment failure; `needs_rolling_replace`
4172 4261
/// is its own condition; `live` succeeds.
4262
/// Unwrap a `--wait`, keeping "stopped watching" apart from "was refused".
4263
///
4264
/// A wait that runs out is not a failed deployment: the target keeps running.
4265
/// The TypeScript CLI gives it rung 18 of its own so release automation can
4266
/// resume rather than roll back, and this is where `oa` earns the same
4267
/// distinction. Every other failure keeps the class it already had.
4268
fn or_fail_deploy_wait<T>(result: Result<T, crate::tracker::ApiError>) -> T {
4269
    match result {
4270
        Ok(value) => value,
4271
        Err(crate::tracker::ApiError::Timeout { message, .. }) => {
4272
            fail_as(crate::errors::CliError::DeploymentWaitTimeout(message))
4273
        }
4274
        Err(other) => or_fail(Err(other)),
4275
    }
4276
}
4277
4173 4278
fn conclude_fleet_target(target: &serde_json::Value) {
4174 4279
    let status = crate::fleet::target_status(target);
4175 4280
    let id = crate::fleet::target_id(target);
4176 4281
    match status.as_str() {
4282
        // Three outcomes, three statuses. Release automation keys on 17, 18,
4283
        // and 19 to tell "the fleet rejected these bytes" from "the CLI
4284
        // stopped watching" from "an operator has to finish this by hand", and
4285
        // it can only do that if they never share a status with each other or
4286
        // with a transport failure.
4177 4287
        "failed" | "reverted" => {
4178 4288
            let code = crate::fleet::failure_code(target)
4179 4289
                .map(|code| format!(" ({code})"))
4180 4290
                .unwrap_or_default();
4181
            fail(&format!(
4291
            fail_as(crate::errors::CliError::DeploymentFailed(format!(
4182 4292
                "The fleet target {id} reached {status}{code}."
4183
            ));
4293
            )));
4184 4294
        }
4185
        "needs_rolling_replace" => fail(&format!(
4186
            "The fleet target {id} needs a rolling replacement before it can be live."
4187
        )),
4295
        "needs_rolling_replace" => fail_as(
4296
            crate::errors::CliError::DeploymentRollingReplaceRequired(format!(
4297
                "The fleet target {id} needs a rolling replacement before it can be live."
4298
            )),
4299
        ),
4188 4300
        _ => {}
4189 4301
    }
4190 4302
}
crates/openagents-cli/src/delegate.rs modified +79 -30

@@ -1242,7 +1242,19 @@ pub fn describe(description: Option<&str>, prompt: &str) -> String {

1242 1242
pub async fn run_delegation(
1243 1243
    args: DelegationRequest,
1244 1244
    user_token: Option<String>,
1245
    json: bool,
1245 1246
) -> Result<(), Box<dyn std::error::Error>> {
1247
    // Per-child progress is a running commentary, not the answer. Under
1248
    // `--json` the answer is the one document at the end, so the commentary
1249
    // moves to stderr — which is what the TypeScript CLI does with it too
1250
    // (`cli.ts:3025`, where the `--json` path unsubscribes the printer).
1251
    let say = move |line: String| {
1252
        if json {
1253
            eprintln!("{line}");
1254
        } else {
1255
            println!("{line}");
1256
        }
1257
    };
1246 1258
    let requested = args.count.max(1);
1247 1259
    if requested > MAX_DELEGATE_COUNT {
1248 1260
        fail(&format!(

@@ -1294,7 +1306,7 @@ pub async fn run_delegation(

1294 1306
        .in_directory(args.directory.as_deref().map(PathBuf::from))
1295 1307
        .with_child_options(child);
1296 1308
1297
    println!(
1309
    say(format!(
1298 1310
        "Delegating {}: {} {} on {}, {} at a time, isolation: {}.",
1299 1311
        description,
1300 1312
        supervisor.count,

@@ -1302,9 +1314,9 @@ pub async fn run_delegation(

1302 1314
        lane.label(),
1303 1315
        supervisor.max_parallel,
1304 1316
        isolation.name(),
1305
    );
1317
    ));
1306 1318
    if let Some(directory) = &supervisor.directory {
1307
        println!("Children work under {}.", directory.display());
1319
        say(format!("Children work under {}.", directory.display()));
1308 1320
    }
1309 1321
1310 1322
    // `ctrl+c` is the only stop signal a running fan-out has. Without it a

@@ -1329,27 +1341,27 @@ pub async fn run_delegation(

1329 1341
                    workspace,
1330 1342
                    pid,
1331 1343
                } => {
1332
                    println!(
1344
                    say(format!(
1333 1345
                        "[child {id}] started on {lane} in {workspace}{}",
1334 1346
                        match pid {
1335 1347
                            Some(pid) => format!(" as pid {pid}"),
1336 1348
                            None => " in this process".to_string(),
1337 1349
                        }
1338
                    );
1350
                    ));
1339 1351
                }
1340 1352
                ChildEvent::Output { id, text } => printer.feed(id, &text),
1341 1353
                ChildEvent::Activity { id, text } => {
1342 1354
                    printer.flush(id);
1343
                    println!("[child {id}] · {text}");
1355
                    say(format!("[child {id}] · {text}"));
1344 1356
                }
1345 1357
                ChildEvent::Finished(result) => {
1346 1358
                    printer.flush(result.id);
1347
                    println!(
1359
                    say(format!(
1348 1360
                        "[child {}] {} after {}ms",
1349 1361
                        result.id,
1350 1362
                        if result.success { "finished" } else { "FAILED" },
1351 1363
                        result.duration_ms
1352
                    );
1364
                    ));
1353 1365
                }
1354 1366
            }
1355 1367
        }

@@ -1363,33 +1375,70 @@ pub async fn run_delegation(

1363 1375
    let _ = printing.await;
1364 1376
    interrupt.abort();
1365 1377
1366
    println!();
1367 1378
    let succeeded = results.iter().filter(|result| result.success).count();
1368
    for result in &results {
1379
    if json {
1380
        // The field names the TypeScript CLI publishes for this command
1381
        // (`cli.ts:3070`): `agent`, `cwd`, and one entry per child. It
1382
        // pretty-prints this one document — `JSON.stringify(…, null, 2)` —
1383
        // where every other `--json` output it writes is compact. This matches
1384
        // it rather than tidying it, because a consumer already parsing that
1385
        // shape is what the flag is for.
1386
        let document = serde_json::json!({
1387
            "agent": lane.label(),
1388
            "cwd": supervisor
1389
                .directory
1390
                .as_ref()
1391
                .map(|path| path.to_string_lossy().into_owned()),
1392
            "lane": lane_name,
1393
            "outcomes": results
1394
                .iter()
1395
                .map(|result| serde_json::json!({
1396
                    "id": result.id,
1397
                    "success": result.success,
1398
                    "duration_ms": result.duration_ms,
1399
                    "pid": result.pid,
1400
                    "workspace": result
1401
                        .workspace
1402
                        .as_ref()
1403
                        .map(|path| path.to_string_lossy().into_owned()),
1404
                    "failure": result.failure,
1405
                }))
1406
                .collect::<Vec<_>>(),
1407
            "succeeded": succeeded,
1408
            "requested": results.len(),
1409
        });
1369 1410
        println!(
1370
            "child {}: {} in {}ms{}{}",
1371
            result.id,
1372
            if result.success { "ok" } else { "failed" },
1373
            result.duration_ms,
1374
            match result.pid {
1375
                Some(pid) => format!(", pid {pid}"),
1376
                None => String::new(),
1377
            },
1378
            match &result.workspace {
1379
                Some(path) => format!(", in {}", path.display()),
1380
                None => String::new(),
1381
            }
1411
            "{}",
1412
            serde_json::to_string_pretty(&document).unwrap_or_else(|_| document.to_string())
1382 1413
        );
1383
        if let Some(why) = &result.failure {
1384
            println!("  {why}");
1414
    } else {
1415
        println!();
1416
        for result in &results {
1417
            println!(
1418
                "child {}: {} in {}ms{}{}",
1419
                result.id,
1420
                if result.success { "ok" } else { "failed" },
1421
                result.duration_ms,
1422
                match result.pid {
1423
                    Some(pid) => format!(", pid {pid}"),
1424
                    None => String::new(),
1425
                },
1426
                match &result.workspace {
1427
                    Some(path) => format!(", in {}", path.display()),
1428
                    None => String::new(),
1429
                }
1430
            );
1431
            if let Some(why) = &result.failure {
1432
                println!("  {why}");
1433
            }
1385 1434
        }
1435
        println!(
1436
            "{succeeded} of {} {} completed on {}.",
1437
            results.len(),
1438
            if results.len() == 1 { "child" } else { "children" },
1439
            lane.label()
1440
        );
1386 1441
    }
1387
    println!(
1388
        "{succeeded} of {} {} completed on {}.",
1389
        results.len(),
1390
        if results.len() == 1 { "child" } else { "children" },
1391
        lane.label()
1392
    );
1393 1442
1394 1443
    if succeeded < results.len() {
1395 1444
        // A fan-out that lost a child is not a command that worked. This used
crates/openagents-cli/src/errors.rs added +368

@@ -0,0 +1,368 @@

1
//! The exit-code ladder and the `--json` error envelope.
2
//!
3
//! A machine consuming `oa` has to be able to tell an expired token from a
4
//! typo from a missing repository from an outage. Until this module existed it
5
//! could not: every refusal left through `cli::fail`, which exits 2, and an
6
//! internal failure exited 1 — inverted from the convention where 1 is the
7
//! generic failure and 2 is the usage error.
8
//!
9
//! The ladder here is not a new design. It is the one the TypeScript CLI
10
//! publishes at `packages/openagents-cli/src/errors.ts` (`exitCodeFor`), which
11
//! consumers already code against and which release automation keys on for
12
//! 17, 18, and 19. It is transcribed rather than reinterpreted, and
13
//! [`CliError::exit_code`] is asserted against that source arm by arm in
14
//! `tests/parity_test.rs`. Adding a code here without adding it there is a
15
//! divergence, which is the thing this module exists to prevent.
16
//!
17
//! ## The envelope
18
//!
19
//! Under `--json` a failure prints one compact JSON object on **stdout** and
20
//! nothing on stderr:
21
//!
22
//! ```text
23
//! {"code":"api_error","message":"Not Found","exit_code":4,"request_id":"…"}
24
//! ```
25
//!
26
//! That is `main.ts`'s shape, key for key, and compact for the same reason:
27
//! a consumer reading NDJSON gets one document per line. Without `--json` the
28
//! failure is one `oa: …` sentence on stderr, which is what it always was.
29
30
use std::sync::atomic::{AtomicBool, Ordering};
31
32
/// Whether `--json` was passed. Read by [`fail`], which has no other way to
33
/// know: it is called from several hundred sites that never took the flag.
34
static JSON: AtomicBool = AtomicBool::new(false);
35
36
/// Record `--json` for the failure path. Called once from `cli::run`.
37
pub fn set_json(on: bool) {
38
    JSON.store(on, Ordering::Relaxed);
39
}
40
41
pub fn json() -> bool {
42
    JSON.load(Ordering::Relaxed)
43
}
44
45
/// Why the command stopped, in the classes the TypeScript CLI distinguishes.
46
///
47
/// Each variant corresponds to one `_tag` in `errors.ts`. Variants the Rust
48
/// CLI has no producer for yet are still present, so the ladder is complete
49
/// and testable as a unit and so wiring a producer later is a one-line change
50
/// rather than a re-derivation of the mapping.
51
#[derive(Debug, Clone)]
52
pub enum CliError {
53
    /// A malformed argument, an impossible combination, a bad flag value.
54
    Input(String),
55
    /// The environment or a config file cannot support the request.
56
    Configuration(String),
57
    /// No usable credential, or one the store would not surrender.
58
    AuthenticationRequired(String),
59
    /// The credential store itself failed.
60
    CredentialStore(String),
61
    /// The request never reached a server, or never came back.
62
    Network(String),
63
    /// The server answered inside the accepted set with a body this cannot read.
64
    Contract(String),
65
    /// The server answered and refused. The status decides the code.
66
    Api {
67
        status: u16,
68
        /// The server's own `code` field, when it sent one.
69
        code: Option<String>,
70
        message: String,
71
        request_id: Option<String>,
72
    },
73
    /// A repository import ended in `failed`, or stopped being watched.
74
    Import(String),
75
    /// Repository provisioning ended in `failed`, or stopped being watched.
76
    Provisioning(String),
77
    /// A `git` invocation failed.
78
    Git(String),
79
    /// Rendering the answer failed after the answer arrived.
80
    Output(String),
81
    ComputerAlreadyPaired(String),
82
    ComputerPairingInProgress(String),
83
    ComputerDisabled(String),
84
    ComputerPairingExpired(String),
85
    ComputerPairingRefused(String),
86
    ComputerPairingNetworkFailure(String),
87
    ComputerStatusNetworkFailure(String),
88
    ComputerMachineUnavailable(String),
89
    ComputerMachineMismatch(String),
90
    ComputerReconnectExhausted(String),
91
    /// A fleet promotion target reached `failed` or `reverted`.
92
    DeploymentFailed(String),
93
    /// Polling ended while the target was still nonterminal. The target has
94
    /// not failed; the CLI stopped watching.
95
    DeploymentWaitTimeout(String),
96
    /// The target needs an operator-driven rolling replacement to finish.
97
    DeploymentRollingReplaceRequired(String),
98
    /// A failure that reached the top with no class of its own.
99
    ///
100
    /// The one variant with no counterpart in `errors.ts`, and deliberately
101
    /// so: it stands for the branch `main.ts` takes when `isCliError` is
102
    /// false, which exits 1 there too. It is rung 1 rather than rung 2
103
    /// because 1 is the generic failure and 2 is the usage error, and `oa`
104
    /// had those the wrong way round.
105
    Internal(String),
106
}
107
108
impl CliError {
109
    /// The status this failure exits with.
110
    ///
111
    /// Transcribed from `exitCodeFor` in `packages/openagents-cli/src/errors.ts`.
112
    /// Code 16 is deliberately absent there — it was `TraceUploadUnsupported`,
113
    /// retired rather than reassigned so a script still checking for it stops
114
    /// seeing it instead of starting to see it mean something else — and it is
115
    /// absent here for the same reason.
116
    pub fn exit_code(&self) -> i32 {
117
        match self {
118
            Self::Input(_) | Self::Configuration(_) => 2,
119
            Self::ComputerAlreadyPaired(_) | Self::ComputerPairingInProgress(_) => 5,
120
            Self::ComputerDisabled(_) => 8,
121
            Self::ComputerPairingExpired(_) => 9,
122
            Self::ComputerPairingRefused(_) => 10,
123
            Self::ComputerPairingNetworkFailure(_) => 11,
124
            Self::ComputerStatusNetworkFailure(_) => 12,
125
            Self::ComputerMachineUnavailable(_) => 13,
126
            Self::ComputerMachineMismatch(_) => 14,
127
            Self::ComputerReconnectExhausted(_) => 15,
128
            Self::DeploymentFailed(_) => 17,
129
            Self::DeploymentWaitTimeout(_) => 18,
130
            Self::DeploymentRollingReplaceRequired(_) => 19,
131
            Self::AuthenticationRequired(_) | Self::CredentialStore(_) => 3,
132
            Self::Network(_) | Self::Contract(_) => 6,
133
            Self::Api { status, .. } => match *status {
134
                401 | 403 => 3,
135
                404 => 4,
136
                409 => 5,
137
                400 | 422 => 2,
138
                status if status >= 500 => 6,
139
                _ => 1,
140
            },
141
            Self::Import(_) | Self::Provisioning(_) => 7,
142
            Self::Git(_) | Self::Output(_) | Self::Internal(_) => 1,
143
        }
144
    }
145
146
    /// The `code` field of the envelope.
147
    ///
148
    /// `errorCode` in `errors.ts` returns the server's own `code` for an API
149
    /// refusal that carried one, and otherwise the tag with its
150
    /// `OpenAgentsCli.` prefix dropped and its camel case broken into
151
    /// snake case.
152
    pub fn code(&self) -> String {
153
        if let Self::Api {
154
            code: Some(code), ..
155
        } = self
156
        {
157
            return code.clone();
158
        }
159
        snake_case(self.tag())
160
    }
161
162
    /// The tag, as `errors.ts` spells it after the `OpenAgentsCli.` prefix.
163
    fn tag(&self) -> &'static str {
164
        match self {
165
            Self::Input(_) => "InputError",
166
            Self::Configuration(_) => "ConfigurationError",
167
            Self::AuthenticationRequired(_) => "AuthenticationRequired",
168
            Self::CredentialStore(_) => "CredentialStoreError",
169
            Self::Network(_) => "TransportError",
170
            Self::Contract(_) => "ContractError",
171
            Self::Api { .. } => "ApiError",
172
            Self::Import(_) => "ImportFailed",
173
            Self::Provisioning(_) => "ProvisioningFailed",
174
            Self::Git(_) => "GitExecutionError",
175
            Self::Output(_) => "OutputError",
176
            Self::ComputerAlreadyPaired(_) => "ComputerAlreadyPaired",
177
            Self::ComputerPairingInProgress(_) => "ComputerPairingInProgress",
178
            Self::ComputerDisabled(_) => "ComputerDisabled",
179
            Self::ComputerPairingExpired(_) => "ComputerPairingExpired",
180
            Self::ComputerPairingRefused(_) => "ComputerPairingRefused",
181
            Self::ComputerPairingNetworkFailure(_) => "ComputerPairingNetworkFailure",
182
            Self::ComputerStatusNetworkFailure(_) => "ComputerStatusNetworkFailure",
183
            Self::ComputerMachineUnavailable(_) => "ComputerMachineUnavailable",
184
            Self::ComputerMachineMismatch(_) => "ComputerMachineMismatch",
185
            Self::ComputerReconnectExhausted(_) => "ComputerReconnectExhausted",
186
            Self::DeploymentFailed(_) => "DeploymentFailed",
187
            Self::DeploymentWaitTimeout(_) => "DeploymentWaitTimeout",
188
            Self::DeploymentRollingReplaceRequired(_) => "DeploymentRollingReplaceRequired",
189
            Self::Internal(_) => "InternalError",
190
        }
191
    }
192
193
    /// The request id, when the failure is one the server answered and
194
    /// labelled. `requestIdFor` in `errors.ts` publishes it only for an API
195
    /// refusal, and a made-up id would be worse than none.
196
    pub fn request_id(&self) -> Option<&str> {
197
        match self {
198
            Self::Api { request_id, .. } => request_id.as_deref(),
199
            _ => None,
200
        }
201
    }
202
203
    /// The sentence a person reads.
204
    pub fn message(&self) -> &str {
205
        match self {
206
            Self::Input(message)
207
            | Self::Configuration(message)
208
            | Self::AuthenticationRequired(message)
209
            | Self::CredentialStore(message)
210
            | Self::Network(message)
211
            | Self::Contract(message)
212
            | Self::Import(message)
213
            | Self::Provisioning(message)
214
            | Self::Git(message)
215
            | Self::Output(message)
216
            | Self::ComputerAlreadyPaired(message)
217
            | Self::ComputerPairingInProgress(message)
218
            | Self::ComputerDisabled(message)
219
            | Self::ComputerPairingExpired(message)
220
            | Self::ComputerPairingRefused(message)
221
            | Self::ComputerPairingNetworkFailure(message)
222
            | Self::ComputerStatusNetworkFailure(message)
223
            | Self::ComputerMachineUnavailable(message)
224
            | Self::ComputerMachineMismatch(message)
225
            | Self::ComputerReconnectExhausted(message)
226
            | Self::DeploymentFailed(message)
227
            | Self::DeploymentWaitTimeout(message)
228
            | Self::DeploymentRollingReplaceRequired(message)
229
            | Self::Internal(message)
230
            | Self::Api { message, .. } => message,
231
        }
232
    }
233
234
    /// The envelope, exactly as `main.ts` builds it: `code`, `message`,
235
    /// `exit_code`, and `request_id` only when there is one.
236
    pub fn envelope(&self) -> serde_json::Value {
237
        let mut object = serde_json::Map::new();
238
        object.insert("code".to_string(), self.code().into());
239
        object.insert("message".to_string(), self.message().into());
240
        object.insert("exit_code".to_string(), self.exit_code().into());
241
        if let Some(id) = self.request_id() {
242
            object.insert("request_id".to_string(), id.into());
243
        }
244
        serde_json::Value::Object(object)
245
    }
246
}
247
248
impl std::fmt::Display for CliError {
249
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250
        f.write_str(self.message())
251
    }
252
}
253
254
impl std::error::Error for CliError {}
255
256
/// `CamelCase` to `snake_case`, matching the `replaceAll(/([a-z])([A-Z])/gu)`
257
/// in `errorCode`. That regex inserts a separator only between a lower and an
258
/// upper, so a run of capitals stays together the way it does there.
259
fn snake_case(tag: &str) -> String {
260
    let mut out = String::with_capacity(tag.len() + 4);
261
    let mut previous_lower = false;
262
    for character in tag.chars() {
263
        if previous_lower && character.is_ascii_uppercase() {
264
            out.push('_');
265
        }
266
        previous_lower = character.is_ascii_lowercase();
267
        out.push(character.to_ascii_lowercase());
268
    }
269
    out
270
}
271
272
/// Report the failure and exit with its code.
273
///
274
/// Under `--json` the envelope goes to stdout, because that is where a
275
/// consumer that asked for JSON is reading and the TypeScript CLI writes it
276
/// there. Otherwise the sentence goes to stderr, so a body piped to `jq`
277
/// stays parseable.
278
pub fn fail(error: &CliError) -> ! {
279
    if json() {
280
        // `to_string`, not `to_string_pretty`: one document per line is what
281
        // an NDJSON consumer needs, and it is what `JSON.stringify` produces.
282
        println!("{}", serde_json::Value::to_string(&error.envelope()));
283
    } else {
284
        eprintln!("oa: {}", error.message());
285
    }
286
    std::process::exit(error.exit_code())
287
}
288
289
impl From<crate::tracker::ApiError> for CliError {
290
    fn from(error: crate::tracker::ApiError) -> Self {
291
        use crate::tracker::ApiError;
292
        // The rendered sentence is kept rather than the bare server message:
293
        // it names the operation and the status, which is strictly more than
294
        // the TypeScript CLI prints and is not a parity break. What the
295
        // envelope's `code` and `exit_code` say is the part a machine reads,
296
        // and that is the part this classification fixes.
297
        let message = error.to_string();
298
        match error {
299
            ApiError::Transport { .. } => Self::Network(message),
300
            ApiError::Malformed { .. } => Self::Contract(message),
301
            ApiError::Input(_) => Self::Input(message),
302
            // Only the fleet client produces this today, and `run_deploy`
303
            // relabels it as `DeploymentWaitTimeout` so it lands on rung 18.
304
            // A caller that adds a second producer without relabelling gets
305
            // rung 6, which says "the CLI never got an answer" — true of a
306
            // timeout, and never mistaken for a failed deployment.
307
            ApiError::Timeout { .. } => Self::Network(message),
308
            ApiError::Refused {
309
                status,
310
                code,
311
                request_id,
312
                ..
313
            } => Self::Api {
314
                status,
315
                code,
316
                message,
317
                request_id,
318
            },
319
        }
320
    }
321
}
322
323
impl From<crate::auth::AuthError> for CliError {
324
    /// A credential the CLI could not read, write, or refresh. `errors.ts`
325
    /// puts `CredentialStoreError` and `CredentialPersistenceUnavailable` on
326
    /// rung 3 alongside `AuthenticationRequired`, because to a caller they are
327
    /// the same problem: this run has no usable credential.
328
    ///
329
    /// `AuthError` is one undifferentiated newtype, so it cannot separate
330
    /// those three. A *configuration* failure — an unusable `--api-url`, say —
331
    /// is refused through `cli::fail` before a store is opened, and keeps
332
    /// rung 2 where it belongs.
333
    fn from(error: crate::auth::AuthError) -> Self {
334
        Self::CredentialStore(error.to_string())
335
    }
336
}
337
338
/// A message with no class of its own is an input error, which is the status
339
/// every one of these already exited with. Nothing here is reclassified by
340
/// accident: a failure only moves off rung 2 when something gives it a type.
341
impl From<String> for CliError {
342
    fn from(message: String) -> Self {
343
        Self::Input(message)
344
    }
345
}
346
347
impl From<&str> for CliError {
348
    fn from(message: &str) -> Self {
349
        Self::Input(message.to_string())
350
    }
351
}
352
353
impl From<crate::forum::ForumError> for CliError {
354
    fn from(error: crate::forum::ForumError) -> Self {
355
        use crate::forum::ForumError;
356
        let message = error.to_string();
357
        match error {
358
            ForumError::Transport(_) => Self::Network(message),
359
            ForumError::Malformed(_) => Self::Contract(message),
360
            ForumError::Refused { status, .. } => Self::Api {
361
                status,
362
                code: None,
363
                message,
364
                request_id: None,
365
            },
366
        }
367
    }
368
}
crates/openagents-cli/src/fleet.rs modified +17 -6

@@ -154,9 +154,13 @@ pub fn operator_remediation(error: ApiError) -> ApiError {

154 154
            operation,
155 155
            status: status @ (401 | 403),
156 156
            message,
157
            code,
158
            request_id,
157 159
        } => ApiError::Refused {
158 160
            operation,
159 161
            status,
162
            code,
163
            request_id,
160 164
            message: format!(
161 165
                "{message} Fleet promotion requires an operator API token holding \
162 166
                 {OPERATOR_SCOPE}; forge:write cannot promote, and neither can a Git credential \

@@ -309,12 +313,15 @@ impl FleetClient {

309 313
            }
310 314
            let elapsed = started.elapsed();
311 315
            if elapsed >= timeout {
312
                return Err(ApiError::Input(format!(
313
                    "The fleet target {id} was still {} after {} seconds. It keeps running; \
314
                     resume with: oa deploy view {id} --wait",
315
                    target_status(&target),
316
                    timeout.as_secs()
317
                )));
316
                return Err(ApiError::Timeout {
317
                    operation: "wait for a fleet target".to_string(),
318
                    message: format!(
319
                        "The fleet target {id} was still {} after {} seconds. It keeps running; \
320
                         resume with: oa deploy view {id} --wait",
321
                        target_status(&target),
322
                        timeout.as_secs()
323
                    ),
324
                });
318 325
            }
319 326
            let delay = poll_delay(attempt);
320 327
            let remaining = timeout - elapsed;

@@ -381,6 +388,8 @@ mod tests {

381 388
            operation: "list fleet targets".into(),
382 389
            status: 401,
383 390
            message: "Requires an API token carrying deployments:promote".into(),
391
            code: None,
392
            request_id: None,
384 393
        });
385 394
        let rendered = error.to_string();
386 395
        assert!(rendered.contains("deployments:promote"));

@@ -394,6 +403,8 @@ mod tests {

394 403
            operation: "read a fleet target".into(),
395 404
            status: 404,
396 405
            message: "No such target.".into(),
406
            code: None,
407
            request_id: None,
397 408
        });
398 409
        assert!(!error.to_string().contains("deployments:promote"));
399 410
    }
crates/openagents-cli/src/lib.rs modified +1

@@ -21,6 +21,7 @@ pub mod computer;

21 21
pub mod delegate;
22 22
pub mod diag;
23 23
pub mod diff;
24
pub mod errors;
24 25
pub mod fleet;
25 26
pub mod foreign_resume;
26 27
pub mod forum;
crates/openagents-cli/src/main.rs modified +6 -2

@@ -8,8 +8,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

8 8
    tracing_subscriber::fmt::init();
9 9
    let args = Cli::parse();
10 10
    if let Err(err) = cli::run(args).await {
11
        eprintln!("Error: {}", err);
12
        std::process::exit(1);
11
        // The last resort, for a failure that reached here without a class.
12
        // `main.ts` exits 1 for exactly this case — an error that is not one of
13
        // the published `CliError` tags — and reports it through the same
14
        // envelope, so a `--json` consumer never gets prose on stdout even
15
        // when the CLI itself did not see the failure coming.
16
        openagents_cli::errors::fail(&openagents_cli::errors::CliError::Internal(err.to_string()));
13 17
    }
14 18
    Ok(())
15 19
}
crates/openagents-cli/src/memory_client.rs modified +7 -1

@@ -14,7 +14,7 @@ use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYP

14 14
use serde::{Deserialize, Serialize};
15 15
use serde_json::{json, Value};
16 16
17
use crate::tracker::{error_sentence, urlencode, ApiError};
17
use crate::tracker::{error_fields, error_sentence, header_request_id, urlencode, ApiError};
18 18
19 19
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20 20
pub struct MemoryRecord {

@@ -115,6 +115,9 @@ impl MemoryClient {

115 115
        })?;
116 116
        let status = response.status().as_u16();
117 117
        crate::diag::response(status, &url);
118
        // Read before the body is consumed; the header outranks the body's own
119
        // `request_id`, as it does in the TypeScript transport.
120
        let header_id = header_request_id(&response);
118 121
        let text = response.text().await.map_err(|e| ApiError::Transport {
119 122
            operation: operation.to_string(),
120 123
            why: e.to_string(),

@@ -123,10 +126,13 @@ impl MemoryClient {

123 126
        if !accepted.contains(&status) {
124 127
            let message = error_sentence(&text, status);
125 128
            crate::diag::refused(status, &message);
129
            let (code, body_id) = error_fields(&text);
126 130
            return Err(ApiError::Refused {
127 131
                operation: operation.to_string(),
128 132
                status,
129 133
                message,
134
                code,
135
                request_id: header_id.or(body_id),
130 136
            });
131 137
        }
132 138
        serde_json::from_str(&text).map_err(|e| ApiError::Malformed {
crates/openagents-cli/src/plugins.rs modified +61 -5

@@ -1462,10 +1462,7 @@ pub async fn run(args: PluginArgs, json: bool) {

1462 1462
                        })
1463 1463
                    })
1464 1464
                    .collect();
1465
                println!(
1466
                    "{}",
1467
                    serde_json::to_string_pretty(&rows).unwrap_or_default()
1468
                );
1465
                println!("{}", serde_json::Value::from(rows));
1469 1466
            } else {
1470 1467
                for entry in &catalog {
1471 1468
                    let reach = match entry.tier() {

@@ -1482,7 +1479,27 @@ pub async fn run(args: PluginArgs, json: bool) {

1482 1479
                crate::cli::fail("no capability catalog could be read from this directory");
1483 1480
            }
1484 1481
            let ranked = match_capabilities(&catalog, &query);
1485
            if ranked.is_empty() {
1482
            let matched: Vec<serde_json::Value> = ranked
1483
                .iter()
1484
                .take(SEARCH_LIMIT)
1485
                .map(|(entry, hits)| {
1486
                    serde_json::json!({
1487
                        "name": entry.name,
1488
                        "version": entry.version,
1489
                        "terms_matched": hits,
1490
                        "description": first_sentence(&entry.description),
1491
                    })
1492
                })
1493
                .collect();
1494
            if json {
1495
                // The ranking is the answer, so it is the field. An empty
1496
                // array is a real answer to "nothing matched" and needs no
1497
                // sentence wrapped around it.
1498
                println!(
1499
                    "{}",
1500
                    serde_json::json!({ "query": query, "matches": matched })
1501
                );
1502
            } else if ranked.is_empty() {
1486 1503
                println!("Nothing installed matches that.");
1487 1504
            } else {
1488 1505
                for (entry, hits) in ranked.iter().take(SEARCH_LIMIT) {

@@ -1508,6 +1525,25 @@ pub async fn run(args: PluginArgs, json: bool) {

1508 1525
                            crate::cli::fail(&format!("`{name}` did not compile {refusal}"))
1509 1526
                        }
1510 1527
                    };
1528
                    if json {
1529
                        println!(
1530
                            "{}",
1531
                            serde_json::json!({
1532
                                "name": entry.name,
1533
                                "version": entry.version,
1534
                                "manifest": plugin.manifest_path.to_string_lossy(),
1535
                                "digest": plugin.digest,
1536
                                "imports": shape.imports,
1537
                                "mounts": plugin
1538
                                    .mounts
1539
                                    .iter()
1540
                                    .map(|root| root.to_string_lossy().into_owned())
1541
                                    .collect::<Vec<_>>(),
1542
                                "tier": format!("{:?}", entry.tier()),
1543
                            })
1544
                        );
1545
                        return;
1546
                    }
1511 1547
                    println!("{}", describe_load(&plugin));
1512 1548
                    println!("manifest: {}", plugin.manifest_path.display());
1513 1549
                    println!("digest:   {}", plugin.digest);

@@ -1547,7 +1583,11 @@ pub async fn run(args: PluginArgs, json: bool) {

1547 1583
                Ok(plugin) => plugin,
1548 1584
                Err(refusal) => crate::cli::fail(&format!("`{name}` did not load {refusal}")),
1549 1585
            };
1586
            // The load line is a diagnostic about what was mounted, not the
1587
            // capability's answer, so it stays on stderr in both modes and a
1588
            // `--json` reader piping stdout is unaffected by it.
1550 1589
            eprintln!("{}", describe_load(&plugin));
1590
            let digest = plugin.digest.clone();
1551 1591
            match invoke_async(
1552 1592
                Arc::new(plugin),
1553 1593
                serde_json::to_vec(&arguments).unwrap_or_default(),

@@ -1556,6 +1596,22 @@ pub async fn run(args: PluginArgs, json: bool) {

1556 1596
            {
1557 1597
                Err(refusal) => crate::cli::fail(&format!("`{name}` refused {refusal}")),
1558 1598
                Ok(bytes) => match String::from_utf8(bytes) {
1599
                    Ok(text) if json => {
1600
                        // A capability that answered JSON has its answer
1601
                        // carried as JSON; one that answered prose has it
1602
                        // carried as a string. Either way the envelope names
1603
                        // which, so a consumer never has to guess.
1604
                        let output = serde_json::from_str::<serde_json::Value>(&text)
1605
                            .unwrap_or_else(|_| serde_json::Value::String(text.clone()));
1606
                        println!(
1607
                            "{}",
1608
                            serde_json::json!({
1609
                                "name": name,
1610
                                "digest": digest,
1611
                                "output": output,
1612
                            })
1613
                        );
1614
                    }
1559 1615
                    Ok(text) => println!("{text}"),
1560 1616
                    Err(err) => crate::cli::fail(&format!(
1561 1617
                        "`{name}` answered with {} bytes that are not UTF-8",
crates/openagents-cli/src/trace_client.rs modified +5 -1

@@ -21,7 +21,7 @@ use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYP

21 21
use serde::{Deserialize, Serialize};
22 22
use serde_json::Value;
23 23
24
use crate::tracker::{error_sentence, urlencode, ApiError};
24
use crate::tracker::{error_fields, error_sentence, header_request_id, urlencode, ApiError};
25 25
26 26
/// The transparency ladder the server stores a trace under.
27 27
///

@@ -128,6 +128,7 @@ impl TraceClient {

128 128
129 129
        let status = response.status().as_u16();
130 130
        crate::diag::response(status, &url);
131
        let header_id = header_request_id(&response);
131 132
        let text = response.text().await.map_err(|error| ApiError::Transport {
132 133
            operation: "upload a trace".to_string(),
133 134
            why: error.to_string(),

@@ -136,10 +137,13 @@ impl TraceClient {

136 137
        if status != 200 && status != 201 {
137 138
            let message = error_sentence(&text, status);
138 139
            crate::diag::refused(status, &message);
140
            let (code, body_id) = error_fields(&text);
139 141
            return Err(ApiError::Refused {
140 142
                operation: "upload a trace".to_string(),
141 143
                status,
142 144
                message,
145
                code,
146
                request_id: header_id.or(body_id),
143 147
            });
144 148
        }
145 149
crates/openagents-cli/src/tracker.rs modified +49

@@ -33,11 +33,25 @@ pub enum ApiError {

33 33
        operation: String,
34 34
        status: u16,
35 35
        message: String,
36
        /// The server's own `code` field, when it sent one. It becomes the
37
        /// `code` of the `--json` error envelope, the way `trackerErrorDetails`
38
        /// feeds `ApiError.code` in the TypeScript CLI.
39
        code: Option<String>,
40
        /// `x-request-id`, or the body's `request_id`. The one field a caller
41
        /// can quote back to an operator, so it is carried rather than
42
        /// flattened into prose.
43
        request_id: Option<String>,
36 44
    },
37 45
    /// The server answered inside the accepted set with a body this cannot read.
38 46
    Malformed { operation: String, why: String },
39 47
    /// The caller asked for something the client will not send.
40 48
    Input(String),
49
    /// The client stopped waiting on a job that had not reached a terminal
50
    /// state. The job itself has not failed, which is why this is not a
51
    /// refusal: it is the difference between "the fleet rejected these bytes"
52
    /// and "the CLI stopped watching", and release automation reads the two as
53
    /// different exit statuses.
54
    Timeout { operation: String, message: String },
41 55
}
42 56
43 57
impl fmt::Display for ApiError {

@@ -50,6 +64,7 @@ impl fmt::Display for ApiError {

50 64
                operation,
51 65
                status,
52 66
                message,
67
                ..
53 68
            } => write!(
54 69
                f,
55 70
                "The API refused the request to {} (HTTP {}): {}",

@@ -61,6 +76,7 @@ impl fmt::Display for ApiError {

61 76
                operation, why
62 77
            ),
63 78
            Self::Input(message) => write!(f, "{}", message),
79
            Self::Timeout { message, .. } => write!(f, "{}", message),
64 80
        }
65 81
    }
66 82
}

@@ -149,6 +165,33 @@ pub fn error_sentence(body: &str, status: u16) -> String {

149 165
    sentence
150 166
}
151 167
168
/// The `code` and `request_id` a refusal body carries, if any.
169
///
170
/// [`error_sentence`] renders the body for a person; these two fields are what
171
/// a machine reads, and they travel separately so the `--json` error envelope
172
/// can publish them as fields rather than leave a caller to parse them back out
173
/// of the sentence. This is the Rust side of `trackerErrorDetails` in
174
/// `packages/openagents-cli/src/tracker-request.ts`.
175
pub fn error_fields(body: &str) -> (Option<String>, Option<String>) {
176
    let Ok(parsed) = serde_json::from_str::<Value>(body) else {
177
        return (None, None);
178
    };
179
    let text = |key: &str| parsed.get(key).and_then(Value::as_str).map(str::to_string);
180
    (text("code"), text("request_id"))
181
}
182
183
/// `x-request-id` off a response, read before the body is consumed.
184
///
185
/// The header wins over the body's own `request_id` in the TypeScript client
186
/// (`api-transport.ts:112`), so the caller resolves them in that order.
187
pub fn header_request_id(response: &reqwest::Response) -> Option<String> {
188
    response
189
        .headers()
190
        .get("x-request-id")
191
        .and_then(|value| value.to_str().ok())
192
        .map(str::to_string)
193
}
194
152 195
fn message_list(value: &Value) -> String {
153 196
    match value {
154 197
        Value::Array(items) => items

@@ -385,6 +428,9 @@ impl TrackerClient {

385 428
        })?;
386 429
        let status = response.status().as_u16();
387 430
        crate::diag::response(status, &url);
431
        // Read before the body is consumed; the header outranks the body's own
432
        // `request_id`, as it does in the TypeScript transport.
433
        let header_id = header_request_id(&response);
388 434
        let text = response.text().await.map_err(|e| ApiError::Transport {
389 435
            operation: operation.to_string(),
390 436
            why: e.to_string(),

@@ -393,10 +439,13 @@ impl TrackerClient {

393 439
        if !accepted.contains(&status) {
394 440
            let message = error_sentence(&text, status);
395 441
            crate::diag::refused(status, &message);
442
            let (code, body_id) = error_fields(&text);
396 443
            return Err(ApiError::Refused {
397 444
                operation: operation.to_string(),
398 445
                status,
399 446
                message,
447
                code,
448
                request_id: header_id.or(body_id),
400 449
            });
401 450
        }
402 451
        if text.trim().is_empty() {
crates/openagents-cli/src/update.rs modified +47 -9

@@ -237,6 +237,33 @@ pub enum Outcome {

237 237
    },
238 238
}
239 239
240
impl Outcome {
241
    /// The `--json` document. `Outcome` exists so a caller can report the
242
    /// decision without inferring it from printed text; this is that report.
243
    pub fn document(&self) -> serde_json::Value {
244
        match self {
245
            Self::AlreadyCurrent { version } => serde_json::json!({
246
                "schema": "openagents.cli_update.v1",
247
                "outcome": "already_current",
248
                "version": version,
249
            }),
250
            Self::Available { version } => serde_json::json!({
251
                "schema": "openagents.cli_update.v1",
252
                "outcome": "available",
253
                "version": version,
254
                "installed": crate::VERSION,
255
            }),
256
            Self::Replaced { from, to, path } => serde_json::json!({
257
                "schema": "openagents.cli_update.v1",
258
                "outcome": "replaced",
259
                "from": from,
260
                "to": to,
261
                "path": path.to_string_lossy(),
262
            }),
263
        }
264
    }
265
}
266
240 267
impl Updater {
241 268
    pub fn new(base_url: Option<String>, channel: Option<String>) -> Self {
242 269
        let base_url = base_url

@@ -473,7 +500,18 @@ pub async fn run(

473 500
    requested: Option<String>,
474 501
    check: bool,
475 502
    force: bool,
503
    json: bool,
476 504
) -> Result<Outcome, Box<dyn std::error::Error>> {
505
    // Progress is a diagnostic, not the answer. Under `--json` the answer is
506
    // the one document the caller printed at the end, so these lines move to
507
    // stderr rather than interleaving with it on stdout.
508
    let say = |line: String| {
509
        if json {
510
            eprintln!("{line}");
511
        } else {
512
            println!("{line}");
513
        }
514
    };
477 515
    let platform = platform().ok_or_else(|| UpdateError::UnsupportedPlatform {
478 516
        os: std::env::consts::OS.to_string(),
479 517
        arch: std::env::consts::ARCH.to_string(),

@@ -493,17 +531,17 @@ pub async fn run(

493 531
        None => {
494 532
            let resolved = updater.resolve_channel().await?;
495 533
496
            println!(
534
            say(format!(
497 535
                "Channel '{}' names {} ({} is installed).",
498 536
                updater.channel, resolved, current
499
            );
537
            ));
500 538
501 539
            resolved
502 540
        }
503 541
    };
504 542
505 543
    if version == current && !force {
506
        println!("Already running {current}. Nothing to do.");
544
        say(format!("Already running {current}. Nothing to do."));
507 545
508 546
        return Ok(Outcome::AlreadyCurrent {
509 547
            version: version.clone(),

@@ -511,26 +549,26 @@ pub async fn run(

511 549
    }
512 550
513 551
    if check {
514
        println!("Update available: {current} -> {version}");
552
        say(format!("Update available: {current} -> {version}"));
515 553
516 554
        return Ok(Outcome::Available { version });
517 555
    }
518 556
519 557
    let target = running_binary()?;
520 558
521
    println!(
559
    say(format!(
522 560
        "Downloading {} ({platform})...",
523 561
        artifact_name(&version, &platform)
524
    );
562
    ));
525 563
526 564
    let bytes = updater.fetch_verified(&version, &platform).await?;
527 565
528
    println!("  Verified sha256 {}.", hex_digest(&bytes));
566
    say(format!("  Verified sha256 {}.", hex_digest(&bytes)));
529 567
530 568
    replace_binary(&target, &bytes)?;
531 569
532
    println!("Replaced {}.", target.display());
533
    println!("OpenAgents CLI is now {version}.");
570
    say(format!("Replaced {}.", target.display()));
571
    say(format!("OpenAgents CLI is now {version}."));
534 572
535 573
    Ok(Outcome::Replaced {
536 574
        from: current.to_string(),
crates/openagents-cli/tests/flags.rs modified +7 -1

@@ -344,7 +344,13 @@ fn verbose_prints_the_servers_refusal() {

344 344
        "--repo",
345 345
        "owner/repo",
346 346
    ]);
347
    assert_eq!(run.status, Some(2));
347
    // 6 is the transport rung, not the usage rung. `oa` exited 2 for this and
348
    // for a misspelled flag alike until #88; measured after, against the same
349
    // dead port:
350
    //
351
    //   openagents --json issue list …  ->  {"code":"transport_error",…}  exit 6
352
    //   oa         --json issue list …  ->  {"code":"transport_error",…}  exit 6
353
    assert_eq!(run.status, Some(6));
348 354
    assert!(
349 355
        run.stderr
350 356
            .contains("http://127.0.0.1:1/api/v1/repos/owner/repo/issues"),
crates/openagents-cli/tests/parity_test.rs modified +526 -61

@@ -66,6 +66,13 @@ struct StubServer {

66 66
67 67
impl StubServer {
68 68
    fn start(script: Vec<(u16, &'static str, Vec<u8>)>) -> Self {
69
        Self::start_with_headers(script, Vec::new())
70
    }
71
72
    fn start_with_headers(
73
        script: Vec<(u16, &'static str, Vec<u8>)>,
74
        extra: Vec<(String, String)>,
75
    ) -> Self {
69 76
        let listener = TcpListener::bind("127.0.0.1:0").expect("bind a port");
70 77
        let port = listener.local_addr().expect("read the port").port();
71 78
        let (tx, hits) = mpsc::channel();

@@ -74,7 +81,7 @@ impl StubServer {

74 81
                let Ok(stream) = stream else { break };
75 82
                let index = answered.min(script.len().saturating_sub(1));
76 83
                let (code, content_type, body) = script[index].clone();
77
                serve_one(stream, code, content_type, &body, tx.clone());
84
                serve_one(stream, code, content_type, &body, &extra, tx.clone());
78 85
            }
79 86
        });
80 87
        Self { port, hits }

@@ -85,6 +92,24 @@ impl StubServer {

85 92
        Self::start(vec![(code, content_type, body)])
86 93
    }
87 94
95
    /// The same, with response headers of its own. `x-request-id` is the one
96
    /// the envelope reads, and it can only be tested from the header side by
97
    /// actually sending one.
98
    fn with_headers(
99
        code: u16,
100
        content_type: &'static str,
101
        body: Vec<u8>,
102
        headers: &[(&str, &str)],
103
    ) -> Self {
104
        Self::start_with_headers(
105
            vec![(code, content_type, body)],
106
            headers
107
                .iter()
108
                .map(|(name, value)| (name.to_string(), value.to_string()))
109
                .collect(),
110
        )
111
    }
112
88 113
    fn origin(&self) -> String {
89 114
        format!("http://127.0.0.1:{}", self.port)
90 115
    }

@@ -103,6 +128,7 @@ fn serve_one(

103 128
    code: u16,
104 129
    content_type: &str,
105 130
    body: &[u8],
131
    extra_headers: &[(String, String)],
106 132
    hits: mpsc::Sender<Hit>,
107 133
) {
108 134
    let mut reader = BufReader::new(stream.try_clone().expect("clone the stream"));

@@ -131,10 +157,14 @@ fn serve_one(

131 157
        return;
132 158
    }
133 159
    let _ = hits.send(Hit { method, path });
134
    let response = format!(
135
        "HTTP/1.1 {code} X\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
160
    let mut response = format!(
161
        "HTTP/1.1 {code} X\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n",
136 162
        body.len()
137 163
    );
164
    for (name, value) in extra_headers {
165
        response.push_str(&format!("{name}: {value}\r\n"));
166
    }
167
    response.push_str("\r\n");
138 168
    let _ = stream.write_all(response.as_bytes());
139 169
    let _ = stream.write_all(body);
140 170
    let _ = stream.flush();

@@ -580,33 +610,160 @@ fn the_json_flag_is_read_and_not_merely_accepted() {

580 610
    }
581 611
}
582 612
583
/// `trace list` accepts `--json` and prints human text anyway.
613
/// `trace list --json` prints the document the TypeScript CLI publishes.
584 614
///
585
/// Recorded from `openagents trace list --json` at cd0c05d465:
615
/// It printed the same human table it prints without the flag. Recorded from
616
/// `openagents trace list --json`, and built at `trace-command.ts:151`:
586 617
///
587 618
/// ```text
588 619
/// {"schema":"openagents.trace_list.v1","stores":[{"root":"…","kind":
589
///  "openagents_export","present":true,"matched":68,…}]}
620
///  "openagents_export","present":true,"matched":68,…}],"traces":[…]}
590 621
/// ```
591 622
///
592
/// `oa trace list --json` prints the same table it prints without the flag.
593
/// `run_trace` takes a `json` parameter and reads it in one of its four arms.
594
/// The same holds for `trace show`, `trace redact`, `plugin search`,
595
/// `plugin inspect`, `plugin run`, `api`, `coder`, `delegate`, and `update`.
623
/// The schema name and both arrays are asserted, not merely "it is JSON": a
624
/// command that printed `{}` would satisfy the weaker check.
596 625
#[test]
597
#[ignore = "#88: oa trace list ignores --json and prints the human table. \
598
            Run with --ignored to see it; delete the attribute when the flag \
599
            is read."]
600 626
fn trace_list_honours_the_json_flag() {
601 627
    let server = StubServer::always(200, "application/json", b"{}".to_vec());
602 628
    let run = oa(&server.origin(), &["--json", "trace", "list"]);
603 629
    assert_eq!(run.code(), 0, "stderr: {}", run.stderr);
604
    serde_json::from_str::<serde_json::Value>(&run.stdout).unwrap_or_else(|error| {
605
        panic!(
606
            "trace list --json did not print JSON ({error}): {}",
607
            run.stdout
608
        )
609
    });
630
    let document: serde_json::Value =
631
        serde_json::from_str(run.stdout.trim()).unwrap_or_else(|error| {
632
            panic!(
633
                "trace list --json did not print JSON ({error}): {}",
634
                run.stdout
635
            )
636
        });
637
    assert_eq!(document["schema"], "openagents.trace_list.v1");
638
    assert!(
639
        document["stores"].is_array(),
640
        "the scans the command performed are missing: {}",
641
        run.stdout
642
    );
643
    assert!(
644
        document["traces"].is_array(),
645
        "the discovered traces are missing: {}",
646
        run.stdout
647
    );
648
649
    let plain = StubServer::always(200, "application/json", b"{}".to_vec());
650
    let human = oa(&plain.origin(), &["trace", "list"]);
651
    assert_ne!(
652
        human.stdout, run.stdout,
653
        "trace list produced identical output with and without --json"
654
    );
655
}
656
657
/// The commands that took `--json` and did nothing with it now answer with a
658
/// document.
659
///
660
/// The audit listed fourteen. Each one below is run twice against the same
661
/// fixture; identical output means the flag is still accepted and ignored.
662
/// Comparing the two runs is what catches that — asserting that the `--json`
663
/// run "produces output" does not, because the human text is output too.
664
#[test]
665
fn the_previously_ignored_json_flags_are_read() {
666
    // Every case must print something without the flag, or the comparison
667
    // below would be two empty strings and prove nothing.
668
    let cases: &[&[&str]] = &[
669
        &["trace", "list"],
670
        &["plugin", "list"],
671
        &["api", "/api/v1/user"],
672
    ];
673
    for command in cases {
674
        let plain_server =
675
            StubServer::always(200, "application/json", br#"{"login":"x"}"#.to_vec());
676
        let plain = oa(&plain_server.origin(), command);
677
        let json_server = StubServer::always(200, "application/json", br#"{"login":"x"}"#.to_vec());
678
        let mut with_flag = vec!["--json"];
679
        with_flag.extend(command.iter().copied());
680
        let json = oa(&json_server.origin(), &with_flag);
681
682
        // A command whose fixture cannot make it succeed proves nothing about
683
        // the flag, so its exit status is asserted first.
684
        assert_eq!(
685
            plain.code(),
686
            json.code(),
687
            "oa {} disagreed with itself about whether it worked",
688
            command.join(" ")
689
        );
690
        assert!(
691
            !plain.stdout.trim().is_empty() || !json.stdout.trim().is_empty(),
692
            "oa {} printed nothing either way",
693
            command.join(" ")
694
        );
695
        assert_ne!(
696
            plain.stdout,
697
            json.stdout,
698
            "oa {} produced identical output with and without --json, \
699
             so the flag is accepted and ignored",
700
            command.join(" ")
701
        );
702
        if json.code() == 0 {
703
            serde_json::from_str::<serde_json::Value>(json.stdout.trim()).unwrap_or_else(|error| {
704
                panic!(
705
                    "oa --json {} did not print JSON ({error}): {}",
706
                    command.join(" "),
707
                    json.stdout
708
                )
709
            });
710
        }
711
    }
712
}
713
714
/// `oa api --json` prints the body on one line; without the flag, indented.
715
///
716
/// `openagents api` renders the body through the shared output layer, which
717
/// stringifies compactly under `--json` (`output.ts`) and pretty-prints for a
718
/// person (`cli.ts:1497`). `oa` pretty-printed in both.
719
#[test]
720
fn api_renders_its_body_compactly_only_under_json() {
721
    const BODY: &[u8] = br#"{"login":"AtlantisPleb","id":14167547}"#;
722
723
    let compact_server = StubServer::always(200, "application/json", BODY.to_vec());
724
    let compact = oa(&compact_server.origin(), &["--json", "api", "/api/v1/user"]);
725
    assert_eq!(compact.code(), 0, "stderr: {}", compact.stderr);
726
    assert_eq!(
727
        compact.stdout.trim_end().lines().count(),
728
        1,
729
        "api --json spanned several lines: {}",
730
        compact.stdout
731
    );
732
733
    let pretty_server = StubServer::always(200, "application/json", BODY.to_vec());
734
    let pretty = oa(&pretty_server.origin(), &["api", "/api/v1/user"]);
735
    assert_eq!(pretty.code(), 0, "stderr: {}", pretty.stderr);
736
    assert!(
737
        pretty.stdout.trim_end().lines().count() > 1,
738
        "api without --json stopped pretty-printing for a person: {}",
739
        pretty.stdout
740
    );
741
}
742
743
/// A refused `oa api` reaches the ladder, and says so once.
744
///
745
/// `oa api` reported every status through `fail`, so a 404 from a passthrough
746
/// route and a typo in its arguments exited alike. Under `--json` it also
747
/// echoed the body to stderr, which a consumer reading the envelope did not
748
/// ask for.
749
#[test]
750
fn a_refused_api_passthrough_reaches_the_ladder() {
751
    let server = StubServer::always(
752
        404,
753
        "application/json",
754
        br#"{"message":"no such route","code":"not_found"}"#.to_vec(),
755
    );
756
    let run = oa(&server.origin(), &["--json", "api", "/api/v1/nope"]);
757
    assert_eq!(run.code(), 4, "stderr: {}", run.stderr);
758
    let envelope: serde_json::Value = serde_json::from_str(run.stdout.trim())
759
        .unwrap_or_else(|error| panic!("api --json did not print JSON ({error}): {}", run.stdout));
760
    assert_eq!(envelope["code"], "not_found");
761
    assert_eq!(envelope["exit_code"], 4);
762
    assert!(
763
        run.stderr.trim().is_empty(),
764
        "the body was echoed alongside the envelope: {}",
765
        run.stderr
766
    );
610 767
}
611 768
612 769
// ---------------------------------------------------------------------------

@@ -640,33 +797,41 @@ fn no_refusal_exits_zero() {

640 797
    }
641 798
}
642 799
643
/// `oa` collapses every server refusal to exit 2. The TypeScript CLI does not.
644
///
645
/// This test records the divergence rather than blessing it. The TypeScript
646
/// ladder is deliberate and published in
647
/// `packages/openagents-cli/src/errors.ts:238-305`:
800
/// Each refusal class exits with its own status, the way `openagents` does.
648 801
///
649
/// | condition          | openagents | oa |
650
/// | ------------------ | ---------: | -: |
651
/// | 400, 422           |          2 |  2 |
652
/// | 401, 403           |          3 |  2 |
653
/// | 404                |          4 |  2 |
654
/// | 409                |          5 |  2 |
655
/// | 5xx                |          6 |  2 |
802
/// `oa` collapsed all of these to 2, so a caller could not tell an expired
803
/// token from a typo from a missing repository from an outage. The ladder is
804
/// published in `packages/openagents-cli/src/errors.ts` (`exitCodeFor`) and
805
/// consumers already code against it, so `oa` adopts it rather than inventing
806
/// a second one.
656 807
///
657
/// Measured against production at cd0c05d465: `openagents deploy list` exits
658
/// 3 and `oa deploy list` exits 2; `openagents issue view 999999` exits 4 and
659
/// `oa` exits 2.
808
/// The expectations here were not derived from that source by reading. Each
809
/// was measured by running both binaries against the same stub, at
810
/// `1fb228a72d` for `oa` and `packages/openagents-cli/dist/main.js` for
811
/// `openagents`:
660 812
///
661
/// A caller cannot tell an expired token from a typo from a missing repository
662
/// from a server outage. When the ladder lands in `oa`, this test is the file
663
/// that has to change, which is the point: the collapse becomes a deliberate
664
/// edit rather than a silent default.
813
/// ```text
814
/// HTTP 401  oa exit=3  openagents exit=3
815
/// HTTP 404  oa exit=4  openagents exit=4
816
/// HTTP 409  oa exit=5  openagents exit=5
817
/// HTTP 500  oa exit=6  openagents exit=6
818
/// ```
665 819
#[test]
666
fn every_server_refusal_currently_exits_two() {
667
    for status in [401u16, 403, 404, 409, 500, 502] {
820
fn each_refusal_class_exits_on_its_own_rung() {
821
    // (status, exit), transcribed from `exitCodeFor`'s `ApiError` arm.
822
    let ladder: &[(u16, i32)] = &[
823
        (400, 2),
824
        (422, 2),
825
        (401, 3),
826
        (403, 3),
827
        (404, 4),
828
        (409, 5),
829
        (500, 6),
830
        (502, 6),
831
    ];
832
    for (status, expected) in ladder {
668 833
        let server = StubServer::always(
669
            status,
834
            *status,
670 835
            "application/json",
671 836
            format!(r#"{{"message":"refused {status}"}}"#).into_bytes(),
672 837
        );

@@ -676,30 +841,71 @@ fn every_server_refusal_currently_exits_two() {

676 841
        );
677 842
        assert_eq!(
678 843
            run.code(),
679
            2,
680
            "HTTP {status} exited {}. If the #88 exit ladder has landed, \
681
             update this test to the new expectation rather than deleting it.",
844
            *expected,
845
            "HTTP {status} exited {} where the TypeScript CLI exits {expected}",
682 846
            run.code()
683 847
        );
684 848
    }
685 849
}
686 850
687
/// A refusal writes to stderr and leaves stdout clean.
851
/// The ladder itself, arm by arm, against the source it was transcribed from.
688 852
///
689
/// A `--json` consumer piping stdout must not receive prose. `oa` writes
690
/// `oa: …` to stderr on every failure, which is right; what it does not yet do
691
/// is write a JSON error object to stdout under `--json`, the way the
692
/// TypeScript CLI does. Recorded from `openagents box list --json` at
693
/// cd0c05d465:
694
///
695
/// ```text
696
/// {"code":"api_error","message":"This deployment does not report a
697
///  conversation for the account. …","exit_code":3}
698
/// ```
853
/// The test above covers the statuses one command can be made to produce. This
854
/// covers the rungs no HTTP status reaches — the pairing codes, the deployment
855
/// codes, rung 7, rung 1 — so a later edit to `exit_code` cannot quietly move
856
/// one of them. Every pair below is one `case` in `exitCodeFor`.
857
#[test]
858
fn the_ladder_matches_the_published_typescript_ladder() {
859
    use openagents_cli::errors::CliError::*;
860
861
    let m = || "x".to_string();
862
    let cases: Vec<(openagents_cli::errors::CliError, i32)> = vec![
863
        (Input(m()), 2),
864
        (Configuration(m()), 2),
865
        (AuthenticationRequired(m()), 3),
866
        (CredentialStore(m()), 3),
867
        (Network(m()), 6),
868
        (Contract(m()), 6),
869
        (Import(m()), 7),
870
        (Provisioning(m()), 7),
871
        (Git(m()), 1),
872
        (Output(m()), 1),
873
        (ComputerAlreadyPaired(m()), 5),
874
        (ComputerPairingInProgress(m()), 5),
875
        (ComputerDisabled(m()), 8),
876
        (ComputerPairingExpired(m()), 9),
877
        (ComputerPairingRefused(m()), 10),
878
        (ComputerPairingNetworkFailure(m()), 11),
879
        (ComputerStatusNetworkFailure(m()), 12),
880
        (ComputerMachineUnavailable(m()), 13),
881
        (ComputerMachineMismatch(m()), 14),
882
        (ComputerReconnectExhausted(m()), 15),
883
        (DeploymentFailed(m()), 17),
884
        (DeploymentWaitTimeout(m()), 18),
885
        (DeploymentRollingReplaceRequired(m()), 19),
886
    ];
887
    for (error, expected) in &cases {
888
        assert_eq!(
889
            error.exit_code(),
890
            *expected,
891
            "{error:?} left rung {expected}"
892
        );
893
    }
894
895
    // 16 is retired, not reassigned. It was `TraceUploadUnsupported`, and a
896
    // script still checking for it must stop seeing it rather than start
897
    // seeing it mean something else.
898
    assert!(
899
        !cases.iter().any(|(_, code)| *code == 16),
900
        "something was given the retired code 16"
901
    );
902
}
903
904
/// A refusal without `--json` is one sentence on stderr, and stdout stays
905
/// clean.
699 906
///
700
/// Until that lands, the contract this pins is the weaker one: stdout stays
701
/// empty, so a consumer sees a parse failure on empty input rather than prose
702
/// masquerading as data.
907
/// A consumer piping stdout must never receive prose there. This is the half
908
/// of the contract that held before the envelope landed, and it still holds.
703 909
#[test]
704 910
fn a_refusal_keeps_prose_off_stdout() {
705 911
    let server = StubServer::always(

@@ -709,12 +915,12 @@ fn a_refusal_keeps_prose_off_stdout() {

709 915
    );
710 916
    let run = oa(
711 917
        &server.origin(),
712
        &["--json", "issue", "list", "-R", "OpenAgentsInc/openagents"],
918
        &["issue", "list", "-R", "OpenAgentsInc/openagents"],
713 919
    );
714
    assert_ne!(run.code(), 0);
920
    assert_eq!(run.code(), 3, "stderr: {}", run.stderr);
715 921
    assert!(
716 922
        run.stdout.trim().is_empty(),
717
        "prose reached stdout under --json: {}",
923
        "prose reached stdout: {}",
718 924
        run.stdout
719 925
    );
720 926
    assert!(

@@ -724,6 +930,265 @@ fn a_refusal_keeps_prose_off_stdout() {

724 930
    );
725 931
}
726 932
933
/// Under `--json`, a refusal is a JSON object on stdout with the four fields
934
/// the TypeScript CLI publishes.
935
///
936
/// `oa` answered every `--json` failure with the human `oa: …` sentence, so a
937
/// consumer that asked for JSON got prose on any failure at all. Measured side
938
/// by side against a stub answering 401 with `x-request-id: req_abc123`, at
939
/// `1fb228a72d` and `packages/openagents-cli/dist/main.js`:
940
///
941
/// ```text
942
/// oa         {"code":"a_server_code","exit_code":3,"message":"…","request_id":"req_abc123"}
943
/// openagents {"code":"a_server_code","message":"…","exit_code":3,"request_id":"req_abc123"}
944
/// ```
945
///
946
/// Same four keys, same values but for `message`, which `oa` prefixes with the
947
/// operation and the status. That prefix is more than `openagents` prints and
948
/// is not a parity break; the fields a machine reads are.
949
#[test]
950
fn a_json_refusal_is_the_published_error_envelope() {
951
    let server = StubServer::always(
952
        401,
953
        "application/json",
954
        br#"{"message":"forbidden","code":"a_server_code","request_id":"req_from_body"}"#.to_vec(),
955
    );
956
    let run = oa(
957
        &server.origin(),
958
        &["--json", "issue", "list", "-R", "OpenAgentsInc/openagents"],
959
    );
960
    assert_eq!(run.code(), 3, "stderr: {}", run.stderr);
961
962
    let envelope: serde_json::Value =
963
        serde_json::from_str(run.stdout.trim()).unwrap_or_else(|error| {
964
            panic!(
965
                "--json failure did not print JSON ({error}): {}",
966
                run.stdout
967
            )
968
        });
969
    assert_eq!(envelope["code"], "a_server_code");
970
    assert_eq!(
971
        envelope["exit_code"], 3,
972
        "the envelope's exit_code disagrees with the process's"
973
    );
974
    assert_eq!(
975
        envelope["exit_code"].as_i64(),
976
        Some(i64::from(run.code())),
977
        "a caller reading the field and a caller reading $? would disagree"
978
    );
979
    assert!(
980
        envelope["message"]
981
            .as_str()
982
            .is_some_and(|text| text.contains("forbidden")),
983
        "the server's own sentence is missing: {}",
984
        run.stdout
985
    );
986
    assert_eq!(
987
        envelope["request_id"], "req_from_body",
988
        "the request id a caller quotes to an operator was dropped"
989
    );
990
    assert!(
991
        run.stderr.trim().is_empty(),
992
        "the sentence was printed twice, once as prose: {}",
993
        run.stderr
994
    );
995
}
996
997
/// The `x-request-id` header outranks the body's own field.
998
///
999
/// That is the order `packages/openagents-cli/src/api-transport.ts:112` and
1000
/// `tracker-request.ts:87` resolve them in, and it matters: the header is
1001
/// stamped by the edge that actually served the request, while the body's copy
1002
/// can be echoed from further in.
1003
#[test]
1004
fn the_request_id_header_wins_over_the_body() {
1005
    let server = StubServer::with_headers(
1006
        500,
1007
        "application/json",
1008
        br#"{"message":"boom","request_id":"req_from_body"}"#.to_vec(),
1009
        &[("x-request-id", "req_from_header")],
1010
    );
1011
    let run = oa(
1012
        &server.origin(),
1013
        &["--json", "issue", "list", "-R", "OpenAgentsInc/openagents"],
1014
    );
1015
    assert_eq!(run.code(), 6, "stderr: {}", run.stderr);
1016
    let envelope: serde_json::Value =
1017
        serde_json::from_str(run.stdout.trim()).expect("--json failure must print JSON");
1018
    assert_eq!(envelope["request_id"], "req_from_header");
1019
}
1020
1021
/// A refusal with no request id publishes no `request_id` key.
1022
///
1023
/// The TypeScript envelope omits the field rather than sending `null`
1024
/// (`main.ts:60`), and an invented id would be worse than none: it would be
1025
/// quoted to an operator who could not find it.
1026
#[test]
1027
fn an_envelope_omits_a_request_id_it_was_not_given() {
1028
    let server = StubServer::always(404, "application/json", br#"{"message":"gone"}"#.to_vec());
1029
    let run = oa(
1030
        &server.origin(),
1031
        &["--json", "issue", "list", "-R", "OpenAgentsInc/openagents"],
1032
    );
1033
    assert_eq!(run.code(), 4, "stderr: {}", run.stderr);
1034
    let envelope: serde_json::Value =
1035
        serde_json::from_str(run.stdout.trim()).expect("--json failure must print JSON");
1036
    assert!(
1037
        envelope.get("request_id").is_none(),
1038
        "a request id was published that the server never sent: {}",
1039
        run.stdout
1040
    );
1041
    // With no server `code`, the envelope falls back to the snake-cased tag,
1042
    // which is what `errorCode` does.
1043
    assert_eq!(envelope["code"], "api_error");
1044
}
1045
1046
/// Every `--json` document is one line, success or failure.
1047
///
1048
/// `oa` pretty-printed, which spread each document over dozens of lines and
1049
/// broke every NDJSON consumer that worked against `openagents`. Measured
1050
/// against the same stub at `1fb228a72d`:
1051
///
1052
/// ```text
1053
/// oa         {"memories":[]}
1054
/// openagents {"memories":[]}
1055
/// ```
1056
#[test]
1057
fn json_output_is_one_line_per_document() {
1058
    let ok = StubServer::always(
1059
        200,
1060
        "application/json",
1061
        br#"{"issues":[{"number":1,"title":"t","state":"open"}],"total_count":1}"#.to_vec(),
1062
    );
1063
    let success = oa(
1064
        &ok.origin(),
1065
        &["--json", "issue", "list", "-R", "OpenAgentsInc/openagents"],
1066
    );
1067
    assert_eq!(success.code(), 0, "stderr: {}", success.stderr);
1068
    assert_eq!(
1069
        success.stdout.trim_end().lines().count(),
1070
        1,
1071
        "a --json success spanned several lines: {}",
1072
        success.stdout
1073
    );
1074
1075
    let refused = StubServer::always(404, "application/json", br#"{"message":"gone"}"#.to_vec());
1076
    let failure = oa(
1077
        &refused.origin(),
1078
        &["--json", "issue", "list", "-R", "OpenAgentsInc/openagents"],
1079
    );
1080
    assert_eq!(
1081
        failure.stdout.trim_end().lines().count(),
1082
        1,
1083
        "a --json refusal spanned several lines: {}",
1084
        failure.stdout
1085
    );
1086
}
1087
1088
/// An input error keeps rung 2, and reports through the same envelope.
1089
///
1090
/// Rung 2 is the usage status, and it is the one rung that did not move. What
1091
/// changed is that it is now reached only by what the caller typed, rather
1092
/// than by every failure in the crate.
1093
#[test]
1094
fn an_input_error_stays_on_rung_two() {
1095
    let server = StubServer::always(200, "application/json", b"{}".to_vec());
1096
    let run = oa(
1097
        &server.origin(),
1098
        &[
1099
            "--json",
1100
            "deploy",
1101
            "promote",
1102
            "--repo",
1103
            "x",
1104
            "--sha",
1105
            "not-a-sha",
1106
        ],
1107
    );
1108
    assert_eq!(run.code(), 2, "stderr: {}", run.stderr);
1109
    let envelope: serde_json::Value =
1110
        serde_json::from_str(run.stdout.trim()).unwrap_or_else(|error| {
1111
            panic!(
1112
                "--json input error did not print JSON ({error}): {}",
1113
                run.stdout
1114
            )
1115
        });
1116
    assert_eq!(envelope["code"], "input_error");
1117
    assert_eq!(envelope["exit_code"], 2);
1118
}
1119
1120
// ---------------------------------------------------------------------------
1121
// 4b. The deployment rungs release automation keys on
1122
// ---------------------------------------------------------------------------
1123
1124
/// A fleet target that failed exits 17, and one needing an operator exits 19.
1125
///
1126
/// These are separate rungs in `errors.ts` for a reason: release automation
1127
/// has to tell "the fleet rejected these bytes" from "an operator must finish
1128
/// this by hand", and both from a transport failure. `oa` exited 2 for all
1129
/// three.
1130
#[test]
1131
fn a_terminal_deployment_state_has_a_rung_of_its_own() {
1132
    for (state, expected) in [
1133
        ("failed", 17),
1134
        ("reverted", 17),
1135
        ("needs_rolling_replace", 19),
1136
    ] {
1137
        let body = format!(
1138
            r#"{{"id":"tgt-1","status":"{state}","sha":"{}","environment":"production"}}"#,
1139
            "a".repeat(40)
1140
        );
1141
        let server = StubServer::always(200, "application/json", body.into_bytes());
1142
        let run = oa(&server.origin(), &["deploy", "view", "tgt-1", "--wait"]);
1143
        assert_eq!(
1144
            run.code(),
1145
            expected,
1146
            "a target in {state} exited {}: {}",
1147
            run.code(),
1148
            run.stderr
1149
        );
1150
    }
1151
}
1152
1153
/// A `--wait` that runs out exits 18, not 17.
1154
///
1155
/// The target has not failed; the CLI stopped watching. Rolling back on this
1156
/// would be rolling back a deployment that is still in flight.
1157
#[test]
1158
fn a_wait_that_runs_out_is_not_a_failed_deployment() {
1159
    let body = format!(
1160
        r#"{{"id":"tgt-1","status":"promoting","sha":"{}","environment":"production"}}"#,
1161
        "a".repeat(40)
1162
    );
1163
    let server = StubServer::always(200, "application/json", body.into_bytes());
1164
    let run = oa(
1165
        &server.origin(),
1166
        &[
1167
            "--json",
1168
            "deploy",
1169
            "view",
1170
            "tgt-1",
1171
            "--wait",
1172
            "--wait-timeout",
1173
            "1",
1174
        ],
1175
    );
1176
    assert_eq!(
1177
        run.code(),
1178
        18,
1179
        "a wait timeout exited {}: {}",
1180
        run.code(),
1181
        run.stderr
1182
    );
1183
    let envelope: serde_json::Value =
1184
        serde_json::from_str(run.stdout.trim()).expect("--json failure must print JSON");
1185
    assert_eq!(envelope["code"], "deployment_wait_timeout");
1186
    assert_ne!(
1187
        envelope["exit_code"], 17,
1188
        "a target that is still promoting was reported as failed"
1189
    );
1190
}
1191
727 1192
// ---------------------------------------------------------------------------
728 1193
// 5. Recorded parity with the TypeScript CLI
729 1194
// ---------------------------------------------------------------------------

This page updates live while a promote is in flight · changelog