Give the Rust CLI its missing commands and wire the flags it only declared

50dfd9b0e6a9 · AtlantisPleb · · parent acb981ab2d8c

Give the Rust CLI its missing commands and wire the flags it only declared

Three issues, one argument surface.

`delegate`, `deploy`, and `provider` existed in the TypeScript CLI and not
here (#91). `oa delegate` raises the fan-out `oa coder --delegate` already ran
to a command of its own with the flags that configure a child; both entry
points resolve to one `DelegationRequest`. `oa deploy list|view|promote`
speaks the operator API at `/api/v1/admin/forge/targets`, with the same
bounded polling, idempotent re-send, and `deployments:promote` remediation the
TypeScript client carries. `oa provider settle` is a gate-for-gate port of
`provider-settlement.ts`; the two binaries produce byte-identical human output
and identical JSON on the same lease and receipt.

`--profile` and `--api-url` parsed and changed nothing for any command but
`auth` and `repo`, because every other client hardcoded the production origin
at its construction site (#92). The endpoint is now resolved once and threaded
into the tracker, box, memory, forum, passthrough, fleet, and coder clients,
so `oa --api-url http://localhost:4000 issue list` reaches localhost. `--json`
is read by the forum. `--verbose` prints the request line, the status, and the
server's refusal on stderr, from the four HTTP clients rather than from
whichever command remembered. `--completions bash|zsh|fish|sh` writes a real
script. `--no-color` drains colour from the finished frame and exports
`NO_COLOR` for delegated children.

`--export` was read only by the full-screen session, so a piped, plain, or
headless run that asked for a transcript got none and was told nothing (#93).
It is written on every path now. `--plain` gives the line-oriented output on a
terminal too, and `--dev` points a session at a server on this machine.

`--child-model`, `--child-ask`, and `--child-config` refuse on a lane that
cannot honour them rather than running the fan-out without what was asked for.

Tests run the binary with and without each flag and compare: a flag that is
accepted and ignored fails them. Verified by removing the colour drain and by
pinning `emit`'s `json` to false — each mutation fails exactly the test
written for it.

Refs #91, #92, #93.

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 Cargo.lock
  • modified crates/openagents-cli/Cargo.toml
  • 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/diag.rs
  • added crates/openagents-cli/src/fleet.rs
  • modified crates/openagents-cli/src/forum.rs
  • modified crates/openagents-cli/src/interactive.rs
  • modified crates/openagents-cli/src/lib.rs
  • modified crates/openagents-cli/src/memory_client.rs
  • added crates/openagents-cli/src/provider.rs
  • modified crates/openagents-cli/src/tracker.rs
  • modified crates/openagents-cli/src/tui.rs
  • added crates/openagents-cli/tests/flags.rs

Diff

15 files changed, +3329 -138

Cargo.lock modified +10

@@ -348,6 +348,15 @@ dependencies = [

348 348
 "strsim",
349 349
]
350 350
351
[[package]]
352
name = "clap_complete"
353
version = "4.6.9"
354
source = "registry+https://github.com/rust-lang/crates.io-index"
355
checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19"
356
dependencies = [
357
 "clap",
358
]
359
351 360
[[package]]
352 361
name = "clap_derive"
353 362
version = "4.6.4"

@@ -1708,6 +1717,7 @@ dependencies = [

1708 1717
 "bip39",
1709 1718
 "bs58",
1710 1719
 "clap",
1720
 "clap_complete",
1711 1721
 "crossterm",
1712 1722
 "eventsource-stream",
1713 1723
 "futures",
crates/openagents-cli/Cargo.toml modified +1

@@ -21,6 +21,7 @@ tokio = { version = "1", features = ["full"] }

21 21
tracing = "0.1"
22 22
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
23 23
clap = { version = "4", features = ["derive", "cargo"] }
24
clap_complete = "4"
24 25
crossterm = { version = "0.28", features = ["event-stream"] }
25 26
ratatui = { version = "0.29", default-features = false, features = ["crossterm"] }
26 27
futures = "0.3"
crates/openagents-cli/src/box_client.rs modified +11 -4

@@ -240,21 +240,28 @@ impl BoxClient {

240 240
            builder = builder.json(&payload);
241 241
        }
242 242
243
        let response = builder.send().await.map_err(|e| ApiError::Transport {
244
            operation: operation.to_string(),
245
            why: e.to_string(),
243
        crate::diag::request(method, &url);
244
        let response = builder.send().await.map_err(|e| {
245
            crate::diag::transport(&url, &e.to_string());
246
            ApiError::Transport {
247
                operation: operation.to_string(),
248
                why: e.to_string(),
249
            }
246 250
        })?;
247 251
        let status = response.status().as_u16();
252
        crate::diag::response(status, &url);
248 253
        let text = response.text().await.map_err(|e| ApiError::Transport {
249 254
            operation: operation.to_string(),
250 255
            why: e.to_string(),
251 256
        })?;
252 257
253 258
        if !accepted.contains(&status) {
259
            let message = error_sentence(&text, status);
260
            crate::diag::refused(status, &message);
254 261
            return Err(ApiError::Refused {
255 262
                operation: operation.to_string(),
256 263
                status,
257
                message: error_sentence(&text, status),
264
                message,
258 265
            });
259 266
        }
260 267
        if text.trim().is_empty() {
crates/openagents-cli/src/cli.rs modified +728 -84

@@ -1,10 +1,20 @@

1
use clap::{Args, Parser, Subcommand};
1
use clap::{Args, Parser, Subcommand, ValueEnum};
2
3
/// The shells `--completions` can write a script for.
4
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
5
pub enum CompletionShell {
6
    Bash,
7
    Zsh,
8
    Fish,
9
    Sh,
10
}
2 11
3 12
#[derive(Parser, Debug)]
4 13
#[command(name = "oa", version = crate::VERSION, about = "OpenAgents Rust CLI", long_about = None)]
14
#[command(args_conflicts_with_subcommands = false)]
5 15
pub struct Cli {
6 16
    #[command(subcommand)]
7
    pub command: Commands,
17
    pub command: Option<Commands>,
8 18
9 19
    #[arg(long, global = true, help = "Output as JSON")]
10 20
    pub json: bool,

@@ -12,6 +22,17 @@ pub struct Cli {

12 22
    #[arg(short, long, global = true, help = "Verbose logging output")]
13 23
    pub verbose: bool,
14 24
25
    #[arg(long, global = true, help = "Disable ANSI output")]
26
    pub no_color: bool,
27
28
    #[arg(
29
        long,
30
        value_enum,
31
        help = "Print a shell completion script and exit",
32
        value_name = "SHELL"
33
    )]
34
    pub completions: Option<CompletionShell>,
35
15 36
    #[arg(
16 37
        long,
17 38
        global = true,

@@ -41,6 +62,12 @@ pub enum Commands {

41 62
    Repo(RepoArgs),
42 63
    /// OpenAgents interactive Coder agent session and autonomous tools
43 64
    Coder(CoderArgs),
65
    /// Run one prompt on many child coding agents at once and report each result
66
    Delegate(DelegateArgs),
67
    /// Operator deployment of the OpenAgents fleet
68
    Deploy(DeployArgs),
69
    /// Earn on verified work: decide what a leased job is owed
70
    Provider(ProviderArgs),
44 71
    /// Box sandbox management and fanout execution
45 72
    Box(BoxArgs),
46 73
    /// Computer agent daemon and local policy probe

@@ -503,6 +530,216 @@ pub struct CoderArgs {

503 530
504 531
    #[arg(long, help = "Export conversation transcript to file")]
505 532
    pub export: Option<String>,
533
534
    /// Line-oriented output with no cursor control, even on a terminal.
535
    ///
536
    /// The full-screen session draws over the scrollback and cannot be piped
537
    /// or read back; `--plain` prints the prompt and the reply as lines, which
538
    /// is what a transcript, a pipe, and a screen reader can all use.
539
    #[arg(
540
        long,
541
        help = "Line-oriented output with no cursor control, even on a terminal"
542
    )]
543
    pub plain: bool,
544
545
    /// Talk to a development server on this machine.
546
    ///
547
    /// Shorthand for `--api-url http://localhost:<port>`; the global flag
548
    /// still wins when both are given, so this adds a default rather than a
549
    /// second mechanism.
550
    #[arg(long, help = "Talk to a development server on this machine")]
551
    pub dev: bool,
552
553
    #[arg(
554
        long,
555
        default_value_t = 4000,
556
        help = "Port --dev talks to on this machine"
557
    )]
558
    pub dev_port: u16,
559
}
560
561
// ---------------------------------------------------------------------------
562
// delegate
563
// ---------------------------------------------------------------------------
564
565
/// `oa delegate`.
566
///
567
/// The same fan-out `oa coder --delegate` runs, raised to a command of its own
568
/// so the flags that configure a child are not buried under a coding session's
569
/// flags. Every flag below is read by [`crate::delegate::run_delegation`]; the
570
/// TypeScript command carries `--description` and the five `--child-*` flags
571
/// for the same reason, and they mean the same things here.
572
#[derive(Args, Debug)]
573
pub struct DelegateArgs {
574
    #[arg(help = "The task every child performs")]
575
    pub prompt: Option<String>,
576
577
    #[arg(long, default_value_t = 1, help = "How many children run this prompt")]
578
    pub agents: usize,
579
580
    #[arg(
581
        long,
582
        help = "Where children work. Defaults to the current directory"
583
    )]
584
    pub dir: Option<String>,
585
586
    #[arg(
587
        long,
588
        help = "Three to five words naming the task. Defaults to the start of the prompt"
589
    )]
590
    pub description: Option<String>,
591
592
    #[arg(long, help = "How many children may run at once. The rest queue")]
593
    pub concurrency: Option<usize>,
594
595
    #[arg(
596
        long,
597
        help = "Target harness lane (e.g. ox-alpha, gemini, devin, claude, codex)"
598
    )]
599
    pub lane: Option<String>,
600
601
    #[arg(
602
        long,
603
        help = "Working directory each child gets: worktree (default, a detached git worktree of HEAD), directory, or none"
604
    )]
605
    pub isolation: Option<String>,
606
607
    #[arg(long, help = "Leave the children's worktrees on disk so their work can be read")]
608
    pub keep_workspaces: bool,
609
610
    #[arg(
611
        long,
612
        help = "Run children on this model instead of the lane's own, as `provider/model`. Defaults to OPENAGENTS_DELEGATE_MODEL"
613
    )]
614
    pub child_model: Option<String>,
615
616
    #[arg(
617
        long,
618
        help = "The harness that runs a child. Defaults to OPENAGENTS_DELEGATE_COMMAND, or the lane's own binary"
619
    )]
620
    pub child_command: Option<String>,
621
622
    #[arg(
623
        long,
624
        help = "A harness config file for children, passed as OPENCODE_CONFIG. This is how a provider credential reaches a child without being stored by the CLI"
625
    )]
626
    pub child_config: Option<String>,
627
628
    #[arg(
629
        long,
630
        help = "Make children ask before using a tool. A delegated child has nobody to ask, so this stops it at its first edit; it exists for a dry run over a directory you do not want touched"
631
    )]
632
    pub child_ask: bool,
633
}
634
635
// ---------------------------------------------------------------------------
636
// deploy
637
// ---------------------------------------------------------------------------
638
639
#[derive(Args, Debug)]
640
pub struct DeployArgs {
641
    #[command(subcommand)]
642
    pub action: DeployAction,
643
}
644
645
#[derive(Subcommand, Debug)]
646
pub enum DeployAction {
647
    /// Promote an exact pushed commit as the production fleet target (operator only)
648
    Promote {
649
        #[arg(
650
            long,
651
            help = "Canonical repository exactly as the server allows it, such as openagents.com"
652
        )]
653
        repo: Option<String>,
654
        #[arg(
655
            long,
656
            help = "Full 40-character commit SHA; branch names and abbreviations are refused"
657
        )]
658
        sha: Option<String>,
659
        #[arg(
660
            long,
661
            help = "Deployment environment, stated explicitly; the server admits production"
662
        )]
663
        environment: Option<String>,
664
        #[arg(
665
            long,
666
            help = "Caller-generated idempotency key for controlled automation; omitted, the CLI generates one and reuses it across automatic retries. Never printed"
667
        )]
668
        idempotency_key: Option<String>,
669
        #[arg(
670
            long,
671
            help = "Compare-and-set: refuse the promotion when the current target is no longer this ID"
672
        )]
673
        expected_current_target: Option<String>,
674
        #[arg(
675
            long,
676
            help = "Poll the status resource with bounded backoff until the target reaches live, failed, reverted, or needs_rolling_replace"
677
        )]
678
        wait: bool,
679
        #[arg(
680
            long,
681
            default_value_t = 1800,
682
            help = "Seconds --wait polls before reporting a timeout (the target keeps running)"
683
        )]
684
        wait_timeout: u64,
685
    },
686
    /// Show one fleet target; --wait follows it to a terminal state
687
    View {
688
        #[arg(help = "Fleet target ID returned by deploy promote or deploy list")]
689
        target_id: String,
690
        #[arg(
691
            long,
692
            help = "Poll the status resource with bounded backoff until the target reaches live, failed, reverted, or needs_rolling_replace"
693
        )]
694
        wait: bool,
695
        #[arg(
696
            long,
697
            default_value_t = 1800,
698
            help = "Seconds --wait polls before reporting a timeout (the target keeps running)"
699
        )]
700
        wait_timeout: u64,
701
    },
702
    /// List recent fleet targets, newest first
703
    List {
704
        #[arg(
705
            long,
706
            help = "Canonical repository exactly as the server allows it, such as openagents.com"
707
        )]
708
        repo: Option<String>,
709
        #[arg(long, help = "Return between 1 and 50 recent targets")]
710
        limit: Option<u32>,
711
    },
712
}
713
714
// ---------------------------------------------------------------------------
715
// provider
716
// ---------------------------------------------------------------------------
717
718
#[derive(Args, Debug)]
719
pub struct ProviderArgs {
720
    #[command(subcommand)]
721
    pub action: ProviderAction,
722
}
723
724
#[derive(Subcommand, Debug)]
725
pub enum ProviderAction {
726
    /// Decide what one leased job earned. Payment follows a NIP-LBR closeout
727
    /// receipt that names a verification command, its evidence, and the
728
    /// platform's own closeout; a lease, a submission, or time spent online
729
    /// earns nothing. The decision accrues and never pays: no key is held and
730
    /// no payout rail is connected.
731
    Settle {
732
        #[arg(
733
            long,
734
            help = "Path to the lease document the buyer granted for this job"
735
        )]
736
        lease: String,
737
        #[arg(
738
            long,
739
            help = "Path to the NIP-LBR closeout receipt covering this job. Omit it to see what an unverified job earns."
740
        )]
741
        closeout: Option<String>,
742
    },
506 743
}
507 744
508 745
#[derive(Args, Debug)]

@@ -741,7 +978,50 @@ pub enum TraceAction {

741 978
    },
742 979
}
743 980
981
/// The completion script for one shell.
982
///
983
/// `sh` is not a shell clap generates for, and a POSIX shell has no completion
984
/// protocol of its own; the TypeScript CLI offers it because its generator
985
/// emits a bash-compatible script there, so this does the same rather than
986
/// refusing a shell the other binary accepts.
987
pub fn completion_script(shell: CompletionShell) -> String {
988
    use clap::CommandFactory;
989
    use clap_complete::{generate, Shell};
990
    let generated = match shell {
991
        CompletionShell::Bash | CompletionShell::Sh => Shell::Bash,
992
        CompletionShell::Zsh => Shell::Zsh,
993
        CompletionShell::Fish => Shell::Fish,
994
    };
995
    let mut command = Cli::command();
996
    let mut buffer: Vec<u8> = Vec::new();
997
    generate(generated, &mut command, "oa", &mut buffer);
998
    String::from_utf8_lossy(&buffer).into_owned()
999
}
1000
744 1001
pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
1002
    crate::diag::set_verbose(cli.verbose);
1003
    crate::diag::set_color(!cli.no_color);
1004
1005
    // `--completions` writes a script and stops. It reaches no endpoint and
1006
    // needs no token, so it is answered before either is resolved.
1007
    if let Some(shell) = cli.completions {
1008
        // Written through the handle rather than `print!` so a reader piping
1009
        // several thousand lines into `head` closes the pipe and gets nothing
1010
        // worse than a short script.
1011
        use std::io::Write;
1012
        let _ = std::io::stdout().write_all(completion_script(shell).as_bytes());
1013
        return Ok(());
1014
    }
1015
1016
    let Some(command) = cli.command else {
1017
        // Without a subcommand there is nothing to do. Clap's own help goes to
1018
        // stdout on `--help`; a bare invocation is a usage error, so the same
1019
        // text goes to stderr and the status is the usage status.
1020
        use clap::CommandFactory;
1021
        let _ = Cli::command().print_help();
1022
        std::process::exit(2);
1023
    };
1024
745 1025
    let endpoint =
746 1026
        match crate::auth::resolve_endpoint(cli.api_url.as_deref(), cli.profile.as_deref()) {
747 1027
            Ok(endpoint) => endpoint,

@@ -749,63 +1029,53 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

749 1029
        };
750 1030
    let cred_store = crate::auth::CredentialStore::for_origin(&endpoint.origin);
751 1031
    let token = cred_store.get_token();
1032
    // Every client below is built against the selected endpoint. It used to be
1033
    // built against a literal production origin at each of seven call sites,
1034
    // which meant `--profile` and `--api-url` parsed and changed nothing for
1035
    // any command but `auth` and `repo`.
1036
    let api_base = format!("{}/api/v1", endpoint.origin);
752 1037
753
    match cli.command {
1038
    match command {
754 1039
        Commands::Auth(auth) => run_auth(auth.action, &endpoint, &cred_store, cli.json).await,
755 1040
        Commands::Identity(identity) => run_identity(identity.action, cli.json),
756
        Commands::Issue(issue) => run_issue(issue.action, token, cli.json).await,
757
        Commands::Project(project) => run_project(project.action, token, cli.json).await,
1041
        Commands::Issue(issue) => run_issue(issue.action, &api_base, token, cli.json).await,
1042
        Commands::Project(project) => {
1043
            run_project(project.action, &api_base, token, cli.json).await
1044
        }
758 1045
        Commands::Repo(repo) => run_repo(repo.action, &endpoint, &cred_store, cli.json).await,
759 1046
        Commands::Coder(coder) => {
1047
            // The session talks to the selected endpoint like every other
1048
            // command. `--dev` names a server on this machine, and the global
1049
            // `--api-url`/`--profile` still wins when both are given.
1050
            let session_base = if cli.api_url.is_some() || cli.profile.is_some() {
1051
                api_base.clone()
1052
            } else if coder.dev {
1053
                format!("http://localhost:{}/api/v1", coder.dev_port)
1054
            } else {
1055
                api_base.clone()
1056
            };
760 1057
            if coder.delegate {
761
                crate::delegate::run_delegation(coder, token).await?;
1058
                crate::delegate::run_delegation(
1059
                    crate::delegate::DelegationRequest::from_coder(coder),
1060
                    token,
1061
                )
1062
                .await?;
762 1063
            } else if coder.headless {
763
                let prompt = coder.prompt.unwrap_or_else(|| "Analyze workspace and run tests".to_string());
764
                println!("Executing coder prompt headlessly: {}", prompt);
765
                let lane_name = coder.lane.unwrap_or_else(|| "ox-alpha".to_string());
766
                // A headless session may start children. They run on the same
767
                // lane and the same credential, and they do not get the tool
768
                // themselves.
769
                let tools = crate::tools::HarnessToolRegistry::with_delegation(
770
                    None,
771
                    crate::tools::DelegationGate {
772
                        lane: lane_name.clone(),
773
                        user_token: token.clone(),
774
                        max_count: crate::delegate::MAX_DELEGATE_COUNT,
775
                    },
776
                );
777
                let lane = crate::runtime::Lane::from_str(&lane_name);
778
                let mut runtime = crate::runtime::CoderRuntimeSession::new(lane, None, token, tools);
779
                let result = runtime.execute_turn(&prompt, |chunk| {
780
                    print!("{}", chunk);
781
                    use std::io::Write;
782
                    let _ = std::io::stdout().flush();
783
                }).await.map_err(|e| e.to_string());
784
                // The thread is revoked whether the turn worked or not: a
785
                // failed turn still opened one, and one left open holds its
786
                // grant's remaining budget.
787
                let revoked = runtime.close().await;
788
                // A turn that could not reach a model is a failure, and says
789
                // so in the shape every other refusal here uses.
790
                let result = match result {
791
                    Ok(result) => result,
792
                    Err(error) => fail(&error),
793
                };
794
                println!("\n\nTurn result:\n{}", result);
795
                if let Some(model) = &runtime.last_model {
796
                    println!("Model: {model}");
797
                }
798
                if runtime.last_usage.reported() {
799
                    println!("Usage: {}", runtime.last_usage.line());
800
                }
801
                if let Err(error) = revoked {
802
                    eprintln!("oa: the thread was not revoked: {error}");
803
                }
1064
                run_headless_coder(coder, &session_base, token).await?;
804 1065
            } else {
805
                crate::interactive::run_tui(coder, token).await?;
1066
                crate::interactive::run_tui(coder, session_base, token).await?;
806 1067
            }
807 1068
        }
808
        Commands::Box(b) => run_box(b.action, token, cli.json).await,
1069
        Commands::Delegate(args) => {
1070
            crate::delegate::run_delegation(
1071
                crate::delegate::DelegationRequest::from_delegate(args),
1072
                token,
1073
            )
1074
            .await?;
1075
        }
1076
        Commands::Deploy(deploy) => run_deploy(deploy.action, &api_base, token, cli.json).await,
1077
        Commands::Provider(provider) => run_provider(provider.action, cli.json),
1078
        Commands::Box(b) => run_box(b.action, &api_base, token, cli.json).await,
809 1079
        Commands::Computer(comp) => match comp.action {
810 1080
            ComputerAction::Probe => {
811 1081
                let info = crate::computer::probe_host();

@@ -816,54 +1086,84 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

816 1086
            ComputerAction::Up => println!("Computer agent daemon launched."),
817 1087
        },
818 1088
        Commands::Forum(forum) => {
819
            let client = crate::forum::ForumClient::new("https://openagents.com/api/v1", token);
1089
            let client = crate::forum::ForumClient::new(&api_base, token);
820 1090
            match forum.action {
821 1091
                ForumAction::Boards => {
822 1092
                    // A refusal ends the command. The version this replaces answered
823 1093
                    // a non-2xx with two hardcoded boards, one of which the server
824 1094
                    // has never served.
825 1095
                    let boards = client.list_boards().await.unwrap_or_else(|e| fail(&e.to_string()));
826
                    if boards.is_empty() {
827
                        println!("No boards found.");
828
                    }
829
                    for b in boards {
830
                        println!("{} — {} ({} topics)", b.slug, b.title, b.topic_count);
831
                    }
1096
                    let human: Vec<String> = if boards.is_empty() {
1097
                        vec!["No boards found.".to_string()]
1098
                    } else {
1099
                        boards
1100
                            .iter()
1101
                            .map(|b| {
1102
                                format!("{} — {} ({} topics)", b.slug, b.title, b.topic_count)
1103
                            })
1104
                            .collect()
1105
                    };
1106
                    let value = serde_json::json!({
1107
                        "boards": boards
1108
                            .iter()
1109
                            .map(|b| serde_json::json!({
1110
                                "slug": b.slug,
1111
                                "title": b.title,
1112
                                "topic_count": b.topic_count,
1113
                            }))
1114
                            .collect::<Vec<_>>()
1115
                    });
1116
                    emit(cli.json, &value, &human);
832 1117
                }
833 1118
                ForumAction::Topics { board } => {
834 1119
                    let topics = client
835 1120
                        .list_topics(&board)
836 1121
                        .await
837 1122
                        .unwrap_or_else(|e| fail(&e.to_string()));
838
                    if topics.is_empty() {
839
                        println!("No topics found.");
840
                    }
841
                    for t in topics {
842
                        println!("{} — {} ({} posts)", short_id(&t.id), t.title, t.posts_count);
843
                    }
1123
                    let human: Vec<String> = if topics.is_empty() {
1124
                        vec!["No topics found.".to_string()]
1125
                    } else {
1126
                        topics
1127
                            .iter()
1128
                            .map(|t| {
1129
                                format!(
1130
                                    "{} — {} ({} posts)",
1131
                                    short_id(&t.id),
1132
                                    t.title,
1133
                                    t.posts_count
1134
                                )
1135
                            })
1136
                            .collect()
1137
                    };
1138
                    emit(cli.json, &forum_topics_value(&topics), &human);
844 1139
                }
845 1140
                ForumAction::Search { query } => {
846 1141
                    let topics = client
847 1142
                        .search_topics(&query)
848 1143
                        .await
849 1144
                        .unwrap_or_else(|e| fail(&e.to_string()));
850
                    if topics.is_empty() {
851
                        println!("No topics match.");
852
                    }
853
                    for t in topics {
854
                        println!(
855
                            "{} — {} — {}",
856
                            short_id(&t.id),
857
                            t.title,
858
                            t.author.as_deref().unwrap_or("?")
859
                        );
860
                    }
1145
                    let human: Vec<String> = if topics.is_empty() {
1146
                        vec!["No topics match.".to_string()]
1147
                    } else {
1148
                        topics
1149
                            .iter()
1150
                            .map(|t| {
1151
                                format!(
1152
                                    "{} — {} — {}",
1153
                                    short_id(&t.id),
1154
                                    t.title,
1155
                                    t.author.as_deref().unwrap_or("?")
1156
                                )
1157
                            })
1158
                            .collect()
1159
                    };
1160
                    emit(cli.json, &forum_topics_value(&topics), &human);
861 1161
                }
862 1162
            }
863 1163
        }
864
        Commands::Memory(mem) => run_memory(mem.action, token, cli.json).await,
1164
        Commands::Memory(mem) => run_memory(mem.action, &api_base, token, cli.json).await,
865 1165
        Commands::Api(api) => {
866
            let client = crate::api_passthrough::ApiPassthroughClient::new("https://openagents.com/api/v1", token);
1166
            let client = crate::api_passthrough::ApiPassthroughClient::new(&api_base, token);
867 1167
            let res = client.execute_request(&api.method, &api.path, None).await.map_err(|e| e.to_string())?;
868 1168
            println!("{}", serde_json::to_string_pretty(&res)?);
869 1169
        }

@@ -1451,7 +1751,28 @@ fn home_directory() -> std::path::PathBuf {

1451 1751
// tracker: issues, projects, milestones
1452 1752
// ---------------------------------------------------------------------------
1453 1753
1454
const API_BASE: &str = "https://openagents.com/api/v1";
1754
/// The `--json` shape for a forum topic list.
1755
///
1756
/// The forum client parses the server's body into typed rows, so unlike the
1757
/// tracker there is no verbatim body to hand back; this rebuilds the fields it
1758
/// kept, which are the fields the human lines print.
1759
fn forum_topics_value(topics: &[crate::forum::ForumTopic]) -> serde_json::Value {
1760
    serde_json::json!({
1761
        "topics": topics
1762
            .iter()
1763
            .map(|t| serde_json::json!({
1764
                "id": t.id,
1765
                "slug": t.slug,
1766
                "title": t.title,
1767
                "state": t.state,
1768
                "author": t.author,
1769
                "created_at": t.created_at,
1770
                "updated_at": t.updated_at,
1771
                "posts_count": t.posts_count,
1772
            }))
1773
            .collect::<Vec<_>>()
1774
    })
1775
}
1455 1776
1456 1777
/// Print the server's body verbatim under `--json`, or the human lines.
1457 1778
fn emit(json: bool, value: &serde_json::Value, human: &[String]) {

@@ -1721,8 +2042,8 @@ fn parse_field_values(pairs: &[String]) -> serde_json::Value {

1721 2042
    serde_json::Value::Object(map)
1722 2043
}
1723 2044
1724
async fn run_issue(action: IssueAction, token: Option<String>, json: bool) {
1725
    let tracker = crate::tracker::TrackerClient::new(API_BASE, token);
2045
async fn run_issue(action: IssueAction, api_base: &str, token: Option<String>, json: bool) {
2046
    let tracker = crate::tracker::TrackerClient::new(api_base, token);
1726 2047
    match action {
1727 2048
        IssueAction::List {
1728 2049
            repo,

@@ -2034,8 +2355,8 @@ fn project_items_human(value: &serde_json::Value) -> Vec<String> {

2034 2355
        .collect()
2035 2356
}
2036 2357
2037
async fn run_project(action: ProjectAction, token: Option<String>, json: bool) {
2038
    let tracker = crate::tracker::TrackerClient::new(API_BASE, token);
2358
async fn run_project(action: ProjectAction, api_base: &str, token: Option<String>, json: bool) {
2359
    let tracker = crate::tracker::TrackerClient::new(api_base, token);
2039 2360
    match action {
2040 2361
        ProjectAction::List { repo, archived } => {
2041 2362
            let target = target_or_fail(repo);

@@ -2339,8 +2660,8 @@ fn to_value<T: serde::Serialize>(value: &T) -> serde_json::Value {

2339 2660
    serde_json::to_value(value).unwrap_or(serde_json::Value::Null)
2340 2661
}
2341 2662
2342
async fn run_box(action: BoxAction, token: Option<String>, json: bool) {
2343
    let client = crate::box_client::BoxClient::new(API_BASE, token);
2663
async fn run_box(action: BoxAction, api_base: &str, token: Option<String>, json: bool) {
2664
    let client = crate::box_client::BoxClient::new(api_base, token);
2344 2665
    match action {
2345 2666
        BoxAction::List { conversation } => {
2346 2667
            let id = or_fail(client.conversation_id(conversation.as_deref()).await);

@@ -2639,8 +2960,8 @@ fn memory_list_human(memories: &[crate::memory_client::MemoryRecord]) -> Vec<Str

2639 2960
    lines
2640 2961
}
2641 2962
2642
async fn run_memory(action: MemoryAction, token: Option<String>, json: bool) {
2643
    let client = crate::memory_client::MemoryClient::new(API_BASE, token);
2963
async fn run_memory(action: MemoryAction, api_base: &str, token: Option<String>, json: bool) {
2964
    let client = crate::memory_client::MemoryClient::new(api_base, token);
2644 2965
    match action {
2645 2966
        MemoryAction::List {
2646 2967
            bucket,

@@ -3066,3 +3387,326 @@ fn run_trace(action: TraceAction) {

3066 3387
        }
3067 3388
    }
3068 3389
}
3390
3391
// ---------------------------------------------------------------------------
3392
// deploy
3393
// ---------------------------------------------------------------------------
3394
3395
/// `oa deploy`.
3396
///
3397
/// Every path here is a real call to `/api/v1/admin/forge/targets`. A refusal
3398
/// is the server's own, carried through with the command that obtains the
3399
/// privileged scope appended; nothing here invents a target, a state, or a
3400
/// list.
3401
async fn run_deploy(action: DeployAction, api_base: &str, token: Option<String>, json: bool) {
3402
    use crate::fleet;
3403
    let client = fleet::FleetClient::new(api_base, token);
3404
3405
    match action {
3406
        DeployAction::List { repo, limit } => {
3407
            if let Some(limit) = limit {
3408
                if !(1..=50).contains(&limit) {
3409
                    fail("--limit must be between 1 and 50.");
3410
                }
3411
            }
3412
            let value = or_fail(client.list(repo.as_deref(), limit).await);
3413
            let targets = value
3414
                .get("targets")
3415
                .and_then(serde_json::Value::as_array)
3416
                .cloned()
3417
                .unwrap_or_default();
3418
            let human: Vec<String> = if targets.is_empty() {
3419
                vec!["No fleet targets found.".to_string()]
3420
            } else {
3421
                targets.iter().map(fleet::target_row).collect()
3422
            };
3423
            emit(json, &value, &human);
3424
        }
3425
        DeployAction::View {
3426
            target_id,
3427
            wait,
3428
            wait_timeout,
3429
        } => {
3430
            if wait_timeout < 1 {
3431
                fail("--wait-timeout must be at least 1 second.");
3432
            }
3433
            if !wait {
3434
                // A bare view is a read: it reports the state and exits zero
3435
                // even for a failed target. Exit behaviour for terminal states
3436
                // belongs to `--wait`.
3437
                let target = or_fail(client.view(&target_id).await);
3438
                emit(
3439
                    json,
3440
                    &fleet::target_document("openagents.fleet_target.v1", &target, "pending", &[]),
3441
                    &fleet::target_human(&target),
3442
                );
3443
                return;
3444
            }
3445
            let target = or_fail(
3446
                client
3447
                    .wait(&target_id, std::time::Duration::from_secs(wait_timeout))
3448
                    .await,
3449
            );
3450
            let mut human = fleet::target_human(&target);
3451
            human.push(fleet::terminal_human(&target));
3452
            emit(
3453
                json,
3454
                &fleet::target_document("openagents.fleet_target.v1", &target, "pending", &[]),
3455
                &human,
3456
            );
3457
            conclude_fleet_target(&target);
3458
        }
3459
        DeployAction::Promote {
3460
            repo,
3461
            sha,
3462
            environment,
3463
            idempotency_key,
3464
            expected_current_target,
3465
            wait,
3466
            wait_timeout,
3467
        } => {
3468
            let Some(repo) = repo else {
3469
                fail(
3470
                    "Pass --repo with the canonical repository the server deploys, such as \
3471
                     --repo openagents.com.",
3472
                );
3473
            };
3474
            let Some(sha) = sha else {
3475
                fail("Pass --sha with the full 40-character commit SHA you reviewed.");
3476
            };
3477
            let sha = sha.trim().to_lowercase();
3478
            if !fleet::full_sha(&sha) {
3479
                fail(
3480
                    "--sha must be one full 40-character commit SHA. Branch names, tags, and \
3481
                     abbreviations are refused; print the exact reviewed value with: \
3482
                     git rev-parse HEAD",
3483
                );
3484
            }
3485
            let Some(environment) = environment else {
3486
                fail(
3487
                    "Pass --environment production explicitly. Production promotion never \
3488
                     assumes an environment.",
3489
                );
3490
            };
3491
            if wait_timeout < 1 {
3492
                fail("--wait-timeout must be at least 1 second.");
3493
            }
3494
            // Generated once and reused across automatic transport retries, so
3495
            // a re-send can never deploy twice. Never printed.
3496
            let key = idempotency_key.unwrap_or_else(idempotency_key_for_this_run);
3497
            let result = or_fail(
3498
                client
3499
                    .promote(&crate::fleet::PromoteInput {
3500
                        repo,
3501
                        sha,
3502
                        environment,
3503
                        idempotency_key: key,
3504
                        expected_current_target_id: expected_current_target,
3505
                    })
3506
                    .await,
3507
            );
3508
            let id = fleet::target_id(&result.target);
3509
            let extra = [
3510
                ("accepted", serde_json::Value::Bool(result.accepted)),
3511
                ("replayed", serde_json::Value::Bool(result.replayed)),
3512
            ];
3513
            if !wait {
3514
                let mut human = fleet::target_human(&result.target);
3515
                human.push(if result.replayed {
3516
                    "This idempotency key already named this promotion; the original target is \
3517
                     returned."
3518
                        .to_string()
3519
                } else {
3520
                    "Promotion accepted. Accepted means recorded, not live; the fleet deploys it \
3521
                     now."
3522
                        .to_string()
3523
                });
3524
                human.push(format!("Follow it with: oa deploy view {id} --wait"));
3525
                emit(
3526
                    json,
3527
                    &fleet::target_document(
3528
                        "openagents.fleet_promotion.v1",
3529
                        &result.target,
3530
                        "accepted",
3531
                        &extra,
3532
                    ),
3533
                    &human,
3534
                );
3535
                return;
3536
            }
3537
            let target = or_fail(
3538
                client
3539
                    .wait(&id, std::time::Duration::from_secs(wait_timeout))
3540
                    .await,
3541
            );
3542
            let mut human = fleet::target_human(&target);
3543
            human.push(fleet::terminal_human(&target));
3544
            emit(
3545
                json,
3546
                &fleet::target_document(
3547
                    "openagents.fleet_promotion.v1",
3548
                    &target,
3549
                    "accepted",
3550
                    &extra,
3551
                ),
3552
                &human,
3553
            );
3554
            conclude_fleet_target(&target);
3555
        }
3556
    }
3557
}
3558
3559
/// Turn a terminal target into the command's exit behaviour, after the full
3560
/// document is already written.
3561
///
3562
/// `failed` and `reverted` are a deployment failure; `needs_rolling_replace`
3563
/// is its own condition; `live` succeeds.
3564
fn conclude_fleet_target(target: &serde_json::Value) {
3565
    let status = crate::fleet::target_status(target);
3566
    let id = crate::fleet::target_id(target);
3567
    match status.as_str() {
3568
        "failed" | "reverted" => {
3569
            let code = crate::fleet::failure_code(target)
3570
                .map(|code| format!(" ({code})"))
3571
                .unwrap_or_default();
3572
            fail(&format!(
3573
                "The fleet target {id} reached {status}{code}."
3574
            ));
3575
        }
3576
        "needs_rolling_replace" => fail(&format!(
3577
            "The fleet target {id} needs a rolling replacement before it can be live."
3578
        )),
3579
        _ => {}
3580
    }
3581
}
3582
3583
/// An idempotency key for one promotion.
3584
///
3585
/// A UUID would need a dependency this crate does not carry. What the key has
3586
/// to be is unique per run and stable across this run's retries, so it is a
3587
/// hash over the clock, the process, and this binary's own address space,
3588
/// rendered in the UUID layout the server already accepts.
3589
fn idempotency_key_for_this_run() -> String {
3590
    use sha2::{Digest, Sha256};
3591
    let now = std::time::SystemTime::now()
3592
        .duration_since(std::time::UNIX_EPOCH)
3593
        .map(|d| d.as_nanos())
3594
        .unwrap_or(0);
3595
    let stack = &now as *const _ as usize;
3596
    let mut hasher = Sha256::new();
3597
    hasher.update(now.to_le_bytes());
3598
    hasher.update(std::process::id().to_le_bytes());
3599
    hasher.update(stack.to_le_bytes());
3600
    let digest = hasher.finalize();
3601
    let hex: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
3602
    format!(
3603
        "{}-{}-4{}-a{}-{}",
3604
        &hex[0..8],
3605
        &hex[8..12],
3606
        &hex[13..16],
3607
        &hex[17..20],
3608
        &hex[20..32]
3609
    )
3610
}
3611
3612
// ---------------------------------------------------------------------------
3613
// provider
3614
// ---------------------------------------------------------------------------
3615
3616
/// `oa provider settle`.
3617
///
3618
/// The whole decision is local: a lease document and, when one exists, the
3619
/// NIP-LBR closeout receipt that covers it. Nothing is fetched, because the
3620
/// claim and lease transport is not wired and a command that pretended to
3621
/// fetch a receipt would be inventing the one thing the gate exists to check.
3622
fn run_provider(action: ProviderAction, json: bool) {
3623
    match action {
3624
        ProviderAction::Settle { lease, closeout } => {
3625
            let lease_value = or_fail(crate::provider::read_json_file(&lease, "lease"));
3626
            let lease_doc = or_fail(crate::provider::decode_lease(&lease_value, &lease));
3627
            let closeout_doc = match closeout {
3628
                Some(path) => {
3629
                    let value = or_fail(crate::provider::read_json_file(&path, "closeout"));
3630
                    Some(or_fail(crate::provider::decode_closeout(&value, &path)))
3631
                }
3632
                None => None,
3633
            };
3634
            let decision = crate::provider::settle_lease(&lease_doc, closeout_doc.as_ref());
3635
            emit(json, &decision.to_json(), &decision.human());
3636
        }
3637
    }
3638
}
3639
3640
// ---------------------------------------------------------------------------
3641
// coder: headless
3642
// ---------------------------------------------------------------------------
3643
3644
/// `oa coder --headless`.
3645
///
3646
/// Lifted out of the dispatch so `--export` is written here too. It used to be
3647
/// read only by the full-screen session, which meant a headless run that asked
3648
/// for a transcript got none and was told nothing.
3649
async fn run_headless_coder(
3650
    coder: CoderArgs,
3651
    api_base: &str,
3652
    token: Option<String>,
3653
) -> Result<(), Box<dyn std::error::Error>> {
3654
    let prompt = coder
3655
        .prompt
3656
        .clone()
3657
        .unwrap_or_else(|| "Analyze workspace and run tests".to_string());
3658
    println!("Executing coder prompt headlessly: {}", prompt);
3659
    let lane_name = coder.lane.clone().unwrap_or_else(|| "ox-alpha".to_string());
3660
    // A headless session may start children. They run on the same lane and the
3661
    // same credential, and they do not get the tool themselves.
3662
    let tools = crate::tools::HarnessToolRegistry::with_delegation(
3663
        None,
3664
        crate::tools::DelegationGate {
3665
            lane: lane_name.clone(),
3666
            user_token: token.clone(),
3667
            max_count: crate::delegate::MAX_DELEGATE_COUNT,
3668
        },
3669
    );
3670
    let lane = crate::runtime::Lane::from_str(&lane_name);
3671
    let mut runtime = crate::runtime::CoderRuntimeSession::new(
3672
        lane,
3673
        Some(api_base.to_string()),
3674
        token,
3675
        tools,
3676
    );
3677
    let result = runtime
3678
        .execute_turn(&prompt, |chunk| {
3679
            print!("{}", chunk);
3680
            use std::io::Write;
3681
            let _ = std::io::stdout().flush();
3682
        })
3683
        .await
3684
        .map_err(|e| e.to_string());
3685
    // The thread is revoked whether the turn worked or not: a failed turn still
3686
    // opened one, and one left open holds its grant's remaining budget.
3687
    let revoked = runtime.close().await;
3688
    // A turn that could not reach a model is a failure, and says so in the
3689
    // shape every other refusal here uses.
3690
    let result = match result {
3691
        Ok(result) => result,
3692
        Err(error) => fail(&error),
3693
    };
3694
    println!("\n\nTurn result:\n{}", result);
3695
    if let Some(model) = &runtime.last_model {
3696
        println!("Model: {model}");
3697
    }
3698
    if runtime.last_usage.reported() {
3699
        println!("Usage: {}", runtime.last_usage.line());
3700
    }
3701
    if let Err(error) = revoked {
3702
        eprintln!("oa: the thread was not revoked: {error}");
3703
    }
3704
3705
    if let Some(path) = coder.export.as_deref() {
3706
        let transcript = crate::interactive::transcript_of(&prompt, &result);
3707
        std::fs::write(path, &transcript)
3708
            .map_err(|error| format!("could not write the transcript to {path}: {error}"))?;
3709
        println!("Transcript written to {path}");
3710
    }
3711
    Ok(())
3712
}
crates/openagents-cli/src/delegate.rs modified +506 -31

@@ -39,7 +39,7 @@ use tokio::process::Command;

39 39
use tokio::sync::{mpsc, watch, Semaphore};
40 40
41 41
use crate::acp::{AcpEvent, AcpFailure, AcpHarness, PermissionMode};
42
use crate::cli::{fail, CoderArgs};
42
use crate::cli::{fail, CoderArgs, DelegateArgs};
43 43
use crate::runtime::{CoderRuntimeSession, Lane};
44 44
use crate::signals::stop_tree;
45 45
use crate::tools::HarnessToolRegistry;

@@ -163,6 +163,106 @@ impl ChildLane {

163 163
    }
164 164
}
165 165
166
/// How a delegated child is configured.
167
///
168
/// The four `--child-*` flags, resolved once with their environment fallbacks
169
/// so the values a child is started with are decided in one place instead of
170
/// re-read at each spawn site.
171
#[derive(Debug, Clone, Default, PartialEq, Eq)]
172
pub struct ChildOptions {
173
    /// Run children on this model instead of the lane's own.
174
    pub model: Option<String>,
175
    /// The harness binary that runs a child.
176
    pub command: Option<String>,
177
    /// A harness config file, passed as `OPENCODE_CONFIG`. This is how a
178
    /// provider credential reaches a child without the CLI storing it.
179
    pub config: Option<String>,
180
    /// Make children ask before using a tool.
181
    pub ask: bool,
182
}
183
184
impl ChildOptions {
185
    /// Flags first, then the environment, then nothing.
186
    pub fn resolve(
187
        model: Option<String>,
188
        command: Option<String>,
189
        config: Option<String>,
190
        ask: bool,
191
    ) -> Self {
192
        let from_env = |name: &str| {
193
            std::env::var(name)
194
                .ok()
195
                .filter(|value| !value.trim().is_empty())
196
        };
197
        Self {
198
            model: model.or_else(|| from_env("OPENAGENTS_DELEGATE_MODEL")),
199
            command: command.or_else(|| from_env("OPENAGENTS_DELEGATE_COMMAND")),
200
            config,
201
            ask,
202
        }
203
    }
204
205
    /// The environment additions a child is started with.
206
    ///
207
    /// `OPENCODE_CONFIG` is the whole point of `--child-config`: the harness
208
    /// reads its provider credential from that file, so the credential reaches
209
    /// the child without ever passing through this CLI's own storage.
210
    pub fn child_env(&self) -> Vec<(String, String)> {
211
        let mut env = Vec::new();
212
        if let Some(config) = &self.config {
213
            env.push(("OPENCODE_CONFIG".to_string(), config.clone()));
214
        }
215
        env
216
    }
217
218
    /// Refuse a flag the chosen lane cannot honour.
219
    ///
220
    /// A lane that quietly ignored `--child-model` or `--child-ask` would be
221
    /// the same lie as a flag that is never read: the reader asked for a model
222
    /// or for a dry run and got neither, with nothing said.
223
    pub fn check(&self, lane: &ChildLane) -> Result<(), String> {
224
        if self.model.is_some() {
225
            match lane {
226
                ChildLane::Claude | ChildLane::Codex | ChildLane::Opencode { .. } => {}
227
                other => {
228
                    return Err(format!(
229
                        "--child-model cannot be honoured on the {} lane: its model is pinned by \
230
                         the grant the server issues, not chosen here. Use a claude, codex, or \
231
                         opencode/<model> lane.",
232
                        other.label()
233
                    ))
234
                }
235
            }
236
        }
237
        if self.ask {
238
            match lane {
239
                ChildLane::Claude | ChildLane::Codex => {}
240
                other => {
241
                    return Err(format!(
242
                        "--child-ask cannot be honoured on the {} lane: it has no \
243
                         ask-before-a-tool mode this command can select. Use a claude or codex \
244
                         lane.",
245
                        other.label()
246
                    ))
247
                }
248
            }
249
        }
250
        if self.config.is_some() {
251
            match lane {
252
                ChildLane::Opencode { .. } | ChildLane::Claude | ChildLane::Codex => {}
253
                other => {
254
                    return Err(format!(
255
                        "--child-config cannot be honoured on the {} lane: it runs in this \
256
                         process and reads no harness config file.",
257
                        other.label()
258
                    ))
259
                }
260
            }
261
        }
262
        Ok(())
263
    }
264
}
265
166 266
pub struct DelegationSupervisor {
167 267
    pub count: usize,
168 268
    pub lane: String,

@@ -174,6 +274,10 @@ pub struct DelegationSupervisor {

174 274
    /// Leave the children's worktrees on disk when the fan-out is over, so
175 275
    /// what they wrote can be read or merged.
176 276
    pub keep_workspaces: bool,
277
    /// Where children work. `None` means the current directory.
278
    pub directory: Option<PathBuf>,
279
    /// How each child is configured.
280
    pub child: ChildOptions,
177 281
}
178 282
179 283
impl DelegationSupervisor {

@@ -186,6 +290,8 @@ impl DelegationSupervisor {

186 290
            isolation: Isolation::Worktree,
187 291
            max_parallel: count,
188 292
            keep_workspaces: false,
293
            directory: None,
294
            child: ChildOptions::default(),
189 295
        }
190 296
    }
191 297

@@ -204,6 +310,17 @@ impl DelegationSupervisor {

204 310
        self
205 311
    }
206 312
313
    /// Where children work. `--dir`.
314
    pub fn in_directory(mut self, directory: Option<PathBuf>) -> Self {
315
        self.directory = directory;
316
        self
317
    }
318
319
    pub fn with_child_options(mut self, child: ChildOptions) -> Self {
320
        self.child = child;
321
        self
322
    }
323
207 324
    /// Run the fan-out and return every child's outcome.
208 325
    ///
209 326
    /// Convenience over [`DelegationSupervisor::dispatch_streaming`] for a

@@ -230,7 +347,21 @@ impl DelegationSupervisor {

230 347
        cancel: watch::Receiver<bool>,
231 348
    ) -> Result<Vec<ChildWorkerResult>, String> {
232 349
        let lane = ChildLane::parse(&self.lane);
233
        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
350
        // `--dir` names where children work. It has to exist before a worktree
351
        // can be prepared under it, so a path that is not a directory is a
352
        // refusal rather than a fan-out that silently ran somewhere else.
353
        let cwd = match &self.directory {
354
            Some(directory) => {
355
                if !directory.is_dir() {
356
                    return Err(format!(
357
                        "{} is not a directory, so there is nowhere for the children to work.",
358
                        directory.display()
359
                    ));
360
                }
361
                directory.clone()
362
            }
363
            None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
364
        };
234 365
        let plan = WorkspacePlan::resolve(cwd, self.isolation).await;
235 366
        let workspaces = plan.prepare(self.count).await?;
236 367

@@ -249,12 +380,14 @@ impl DelegationSupervisor {

249 380
            let events = events.clone();
250 381
            let cancel = cancel.clone();
251 382
            let gate = Arc::clone(&gate);
383
            let child_options = self.child.clone();
252 384
253 385
            handles.push(tokio::spawn(async move {
254 386
                // The cap is here rather than around the spawn so a child that
255 387
                // is waiting for a slot still exists and still reports.
256 388
                let _slot = gate.acquire().await;
257
                let result = run_child(task, lane, workspace, token, &events, cancel).await;
389
                let result =
390
                    run_child(task, lane, workspace, token, &child_options, &events, cancel).await;
258 391
                let _ = events.send(ChildEvent::Finished(Box::new(result.clone())));
259 392
                result
260 393
            }));

@@ -311,6 +444,7 @@ async fn run_child(

311 444
    lane: ChildLane,
312 445
    workspace: ChildWorkspace,
313 446
    user_token: Option<String>,
447
    options: &ChildOptions,
314 448
    events: &mpsc::UnboundedSender<ChildEvent>,
315 449
    cancel: watch::Receiver<bool>,
316 450
) -> ChildWorkerResult {

@@ -328,10 +462,10 @@ async fn run_child(

328 462
            run_proxy_child(&task, &workspace, user_token, events, cancel).await
329 463
        }
330 464
        ChildLane::Devin => {
331
            run_devin_child(&task, &lane, &workspace, events, cancel).await
465
            run_devin_child(&task, &lane, &workspace, options, events, cancel).await
332 466
        }
333 467
        ChildLane::Claude | ChildLane::Codex | ChildLane::Opencode { .. } => {
334
            run_cli_child(&task, &lane, &workspace, events, cancel).await
468
            run_cli_child(&task, &lane, &workspace, options, events, cancel).await
335 469
        }
336 470
    };
337 471

@@ -410,6 +544,7 @@ async fn run_devin_child(

410 544
    task: &ChildWorkerTask,
411 545
    lane: &ChildLane,
412 546
    workspace: &ChildWorkspace,
547
    options: &ChildOptions,
413 548
    events: &mpsc::UnboundedSender<ChildEvent>,
414 549
    mut cancel: watch::Receiver<bool>,
415 550
) -> Result<ChildAnswer, ChildFailure> {

@@ -422,7 +557,10 @@ async fn run_devin_child(

422 557
    });
423 558
424 559
    let harness = AcpHarness {
425
        command: harness_binary(lane),
560
        command: options
561
            .command
562
            .clone()
563
            .unwrap_or_else(|| harness_binary(lane)),
426 564
        mode: Some(PermissionMode::Dangerous),
427 565
        ..AcpHarness::default()
428 566
    };

@@ -479,14 +617,16 @@ async fn run_cli_child(

479 617
    task: &ChildWorkerTask,
480 618
    lane: &ChildLane,
481 619
    workspace: &ChildWorkspace,
620
    options: &ChildOptions,
482 621
    events: &mpsc::UnboundedSender<ChildEvent>,
483 622
    mut cancel: watch::Receiver<bool>,
484 623
) -> Result<ChildAnswer, ChildFailure> {
485 624
    let id = task.id;
486
    let (command, args) = harness_command(lane, &task.prompt, &workspace.path);
625
    let (command, args) = harness_command(lane, &task.prompt, &workspace.path, options);
487 626
488 627
    let mut child = match Command::new(&command)
489 628
        .args(&args)
629
        .envs(options.child_env())
490 630
        .current_dir(&workspace.path)
491 631
        // No terminal, so a harness that would prompt gets end-of-file rather
492 632
        // than a wait nobody can see.

@@ -647,53 +787,84 @@ pub fn harness_binary(lane: &ChildLane) -> String {

647 787
}
648 788
649 789
/// The binary and arguments each CLI lane runs, following `coder-delegate.ts`.
650
fn harness_command(lane: &ChildLane, prompt: &str, cwd: &std::path::Path) -> (String, Vec<String>) {
790
///
791
/// `options` is where the four `--child-*` flags land: the binary comes from
792
/// `--child-command` when one was given, `--child-model` replaces the lane's
793
/// model, and `--child-ask` selects the harness's own ask-before-a-tool mode
794
/// instead of the mode a child with nobody to ask normally runs in.
795
pub fn harness_command(
796
    lane: &ChildLane,
797
    prompt: &str,
798
    cwd: &std::path::Path,
799
    options: &ChildOptions,
800
) -> (String, Vec<String>) {
801
    let binary = options
802
        .command
803
        .clone()
804
        .unwrap_or_else(|| harness_binary(lane));
651 805
    match lane {
652
        ChildLane::Claude => (
653
            harness_binary(lane),
654
            vec![
806
        ChildLane::Claude => {
807
            let mut args = vec![
655 808
                "-p".to_string(),
656 809
                prompt.to_string(),
657 810
                "--output-format".to_string(),
658 811
                "stream-json".to_string(),
659 812
                // `stream-json` requires it.
660 813
                "--verbose".to_string(),
661
                // A delegated child has nobody to ask.
662 814
                "--permission-mode".to_string(),
663
                "acceptEdits".to_string(),
664
            ],
665
        ),
666
        ChildLane::Codex => (
667
            harness_binary(lane),
668
            vec![
815
                // A delegated child normally has nobody to ask; `--child-ask`
816
                // is the dry run that stops it at its first edit instead.
817
                if options.ask {
818
                    "default".to_string()
819
                } else {
820
                    "acceptEdits".to_string()
821
                },
822
            ];
823
            if let Some(model) = &options.model {
824
                args.push("--model".to_string());
825
                args.push(model.clone());
826
            }
827
            (binary, args)
828
        }
829
        ChildLane::Codex => {
830
            let mut args = vec![
669 831
                "exec".to_string(),
670 832
                "--json".to_string(),
671 833
                // A child's worktree is a checkout but not one Codex has been
672 834
                // told to trust, and without this it refuses before it starts.
673 835
                "--skip-git-repo-check".to_string(),
674
                // The child may edit the checkout it was pointed at and
675
                // nothing outside it.
676 836
                "--sandbox".to_string(),
677
                "workspace-write".to_string(),
678
                prompt.to_string(),
679
            ],
680
        ),
837
                // The child may edit the checkout it was pointed at and
838
                // nothing outside it — unless it was asked to touch nothing.
839
                if options.ask {
840
                    "read-only".to_string()
841
                } else {
842
                    "workspace-write".to_string()
843
                },
844
            ];
845
            if let Some(model) = &options.model {
846
                args.push("--model".to_string());
847
                args.push(model.clone());
848
            }
849
            args.push(prompt.to_string());
850
            (binary, args)
851
        }
681 852
        ChildLane::Opencode { model } => (
682
            harness_binary(lane),
853
            binary,
683 854
            vec![
684 855
                "run".to_string(),
685 856
                "--format".to_string(),
686 857
                "json".to_string(),
687 858
                "--model".to_string(),
688
                model.clone(),
859
                options.model.clone().unwrap_or_else(|| model.clone()),
689 860
                "--dir".to_string(),
690 861
                cwd.to_string_lossy().to_string(),
691 862
                prompt.to_string(),
692 863
            ],
693 864
        ),
694 865
        // Handled by their own runners; unreachable through this function.
695
        ChildLane::OxAlpha => ("".to_string(), Vec::new()),
696
        ChildLane::Devin => (harness_binary(lane), vec!["acp".to_string()]),
866
        ChildLane::OxAlpha => (String::new(), Vec::new()),
867
        ChildLane::Devin => (binary, vec!["acp".to_string()]),
697 868
    }
698 869
}
699 870

@@ -964,8 +1135,85 @@ impl Printer {

964 1135
}
965 1136
966 1137
/// `oa coder --delegate`.
1138
/// What a fan-out was asked for, whichever command asked.
1139
///
1140
/// `oa delegate` and `oa coder --delegate` run the same engine, so they resolve
1141
/// to the same request rather than each carrying its own copy of the argument
1142
/// handling. The coder flag reaches only the fields the coder command declares;
1143
/// the `--child-*` and `--dir` flags exist on `oa delegate` alone, which is why
1144
/// they are `None` on that side rather than silently defaulted.
1145
#[derive(Debug, Clone)]
1146
pub struct DelegationRequest {
1147
    pub prompt: Option<String>,
1148
    pub count: usize,
1149
    pub max_parallel: Option<usize>,
1150
    pub lane: Option<String>,
1151
    pub isolation: Option<String>,
1152
    pub keep_workspaces: bool,
1153
    pub directory: Option<String>,
1154
    pub description: Option<String>,
1155
    pub child_model: Option<String>,
1156
    pub child_command: Option<String>,
1157
    pub child_config: Option<String>,
1158
    pub child_ask: bool,
1159
}
1160
1161
impl DelegationRequest {
1162
    pub fn from_coder(args: CoderArgs) -> Self {
1163
        Self {
1164
            prompt: args.prompt,
1165
            count: args.count,
1166
            max_parallel: args.max_parallel,
1167
            lane: args.lane,
1168
            isolation: args.isolation,
1169
            keep_workspaces: args.keep_workspaces,
1170
            directory: None,
1171
            description: None,
1172
            child_model: None,
1173
            child_command: None,
1174
            child_config: None,
1175
            child_ask: false,
1176
        }
1177
    }
1178
1179
    pub fn from_delegate(args: DelegateArgs) -> Self {
1180
        Self {
1181
            prompt: args.prompt,
1182
            count: args.agents,
1183
            max_parallel: args.concurrency,
1184
            lane: args.lane,
1185
            isolation: args.isolation,
1186
            keep_workspaces: args.keep_workspaces,
1187
            directory: args.dir,
1188
            description: args.description,
1189
            child_model: args.child_model,
1190
            child_command: args.child_command,
1191
            child_config: args.child_config,
1192
            child_ask: args.child_ask,
1193
        }
1194
    }
1195
}
1196
1197
/// Three to five words naming the task, from `--description` or the prompt.
1198
///
1199
/// Mirrors `describePrompt` in `coder-delegate.ts`: the first words of the
1200
/// prompt, so a fan-out with no description is still named by what it does.
1201
pub fn describe(description: Option<&str>, prompt: &str) -> String {
1202
    if let Some(given) = description {
1203
        let trimmed = given.trim();
1204
        if !trimmed.is_empty() {
1205
            return trimmed.to_string();
1206
        }
1207
    }
1208
    let words: Vec<&str> = prompt.split_whitespace().take(5).collect();
1209
    if words.is_empty() {
1210
        return "delegated task".to_string();
1211
    }
1212
    words.join(" ")
1213
}
1214
967 1215
pub async fn run_delegation(
968
    args: CoderArgs,
1216
    args: DelegationRequest,
969 1217
    user_token: Option<String>,
970 1218
) -> Result<(), Box<dyn std::error::Error>> {
971 1219
    let requested = args.count.max(1);

@@ -997,19 +1245,40 @@ pub async fn run_delegation(

997 1245
        },
998 1246
    };
999 1247
1248
    let child = ChildOptions::resolve(
1249
        args.child_model.clone(),
1250
        args.child_command.clone(),
1251
        args.child_config.clone(),
1252
        args.child_ask,
1253
    );
1254
    // A flag the chosen lane cannot honour ends the command. Running anyway
1255
    // would give the reader the fan-out they asked for without the model, the
1256
    // config, or the dry run they asked for it with.
1257
    if let Err(why) = child.check(&lane) {
1258
        fail(&why);
1259
    }
1260
1261
    let description = describe(args.description.as_deref(), &prompt);
1262
1000 1263
    let supervisor = DelegationSupervisor::new(requested, &lane_name, user_token)
1001 1264
        .with_isolation(isolation)
1002 1265
        .with_max_parallel(args.max_parallel.unwrap_or(requested))
1003
        .keeping_workspaces(args.keep_workspaces);
1266
        .keeping_workspaces(args.keep_workspaces)
1267
        .in_directory(args.directory.as_deref().map(PathBuf::from))
1268
        .with_child_options(child);
1004 1269
1005 1270
    println!(
1006
        "Delegating to {} {} on {}, {} at a time, isolation: {}.",
1271
        "Delegating {}: {} {} on {}, {} at a time, isolation: {}.",
1272
        description,
1007 1273
        supervisor.count,
1008 1274
        if supervisor.count == 1 { "child" } else { "children" },
1009 1275
        lane.label(),
1010 1276
        supervisor.max_parallel,
1011 1277
        isolation.name(),
1012 1278
    );
1279
    if let Some(directory) = &supervisor.directory {
1280
        println!("Children work under {}.", directory.display());
1281
    }
1013 1282
1014 1283
    // `ctrl+c` is the only stop signal a running fan-out has. Without it a
1015 1284
    // reader who changed their mind had to kill the terminal, and the

@@ -1163,3 +1432,209 @@ pub fn fanout_for_tool(

1163 1432
    lines.join("\n")
1164 1433
    })
1165 1434
}
1435
1436
#[cfg(test)]
1437
mod child_option_tests {
1438
    use super::*;
1439
    use std::path::Path;
1440
1441
    fn argv(lane: &ChildLane, options: &ChildOptions) -> (String, Vec<String>) {
1442
        harness_command(lane, "do the thing", Path::new("/tmp/work"), options)
1443
    }
1444
1445
    /// `--child-command` has to change which binary a child is started as.
1446
    #[test]
1447
    fn child_command_replaces_the_harness_binary() {
1448
        let default = argv(&ChildLane::Claude, &ChildOptions::default());
1449
        let overridden = argv(
1450
            &ChildLane::Claude,
1451
            &ChildOptions {
1452
                command: Some("/opt/stub-claude".to_string()),
1453
                ..ChildOptions::default()
1454
            },
1455
        );
1456
        assert_ne!(default.0, overridden.0);
1457
        assert_eq!(overridden.0, "/opt/stub-claude");
1458
        // Only the binary moves; the arguments are the lane's own.
1459
        assert_eq!(default.1, overridden.1);
1460
    }
1461
1462
    /// `--child-model` has to reach the harness's own model argument.
1463
    #[test]
1464
    fn child_model_reaches_the_harness_argument() {
1465
        for lane in [ChildLane::Claude, ChildLane::Codex] {
1466
            let plain = argv(&lane, &ChildOptions::default());
1467
            assert!(
1468
                !plain.1.iter().any(|a| a == "--model"),
1469
                "{lane:?} named a model with none asked for"
1470
            );
1471
            let chosen = argv(
1472
                &lane,
1473
                &ChildOptions {
1474
                    model: Some("anthropic/opus".to_string()),
1475
                    ..ChildOptions::default()
1476
                },
1477
            );
1478
            let at = chosen
1479
                .1
1480
                .iter()
1481
                .position(|a| a == "--model")
1482
                .unwrap_or_else(|| panic!("{lane:?} did not pass --model: {:?}", chosen.1));
1483
            assert_eq!(chosen.1[at + 1], "anthropic/opus");
1484
        }
1485
1486
        // The opencode lane already carries a model; the flag replaces it
1487
        // rather than adding a second one.
1488
        let lane = ChildLane::Opencode {
1489
            model: "gemini-3.7-flash".to_string(),
1490
        };
1491
        let chosen = argv(
1492
            &lane,
1493
            &ChildOptions {
1494
                model: Some("openai/gpt-5".to_string()),
1495
                ..ChildOptions::default()
1496
            },
1497
        );
1498
        assert_eq!(chosen.1.iter().filter(|a| *a == "--model").count(), 1);
1499
        assert!(chosen.1.contains(&"openai/gpt-5".to_string()));
1500
        assert!(!chosen.1.contains(&"gemini-3.7-flash".to_string()));
1501
    }
1502
1503
    /// `--child-ask` has to select the harness's ask-before-a-tool mode.
1504
    ///
1505
    /// The whole point of the flag is a dry run over a directory the reader
1506
    /// does not want touched, so the argument that lets a child edit has to be
1507
    /// the one that changes.
1508
    #[test]
1509
    fn child_ask_selects_the_harness_ask_mode() {
1510
        let asking = ChildOptions {
1511
            ask: true,
1512
            ..ChildOptions::default()
1513
        };
1514
1515
        let claude_default = argv(&ChildLane::Claude, &ChildOptions::default()).1;
1516
        let claude_asking = argv(&ChildLane::Claude, &asking).1;
1517
        assert!(claude_default.contains(&"acceptEdits".to_string()));
1518
        assert!(!claude_asking.contains(&"acceptEdits".to_string()));
1519
        assert!(claude_asking.contains(&"default".to_string()));
1520
1521
        let codex_default = argv(&ChildLane::Codex, &ChildOptions::default()).1;
1522
        let codex_asking = argv(&ChildLane::Codex, &asking).1;
1523
        assert!(codex_default.contains(&"workspace-write".to_string()));
1524
        assert!(!codex_asking.contains(&"workspace-write".to_string()));
1525
        assert!(codex_asking.contains(&"read-only".to_string()));
1526
    }
1527
1528
    /// `--child-config` reaches the child as `OPENCODE_CONFIG`, which is how a
1529
    /// provider credential gets there without this CLI storing it.
1530
    #[test]
1531
    fn child_config_reaches_the_child_environment() {
1532
        assert!(ChildOptions::default().child_env().is_empty());
1533
        let options = ChildOptions {
1534
            config: Some("/tmp/opencode.json".to_string()),
1535
            ..ChildOptions::default()
1536
        };
1537
        assert_eq!(
1538
            options.child_env(),
1539
            vec![(
1540
                "OPENCODE_CONFIG".to_string(),
1541
                "/tmp/opencode.json".to_string()
1542
            )]
1543
        );
1544
    }
1545
1546
    /// A lane that cannot honour a flag says so instead of ignoring it.
1547
    #[test]
1548
    fn a_lane_that_cannot_honour_a_flag_refuses_it() {
1549
        let model = ChildOptions {
1550
            model: Some("anything".to_string()),
1551
            ..ChildOptions::default()
1552
        };
1553
        assert!(model.check(&ChildLane::OxAlpha).is_err());
1554
        assert!(model.check(&ChildLane::Devin).is_err());
1555
        assert!(model.check(&ChildLane::Claude).is_ok());
1556
1557
        let ask = ChildOptions {
1558
            ask: true,
1559
            ..ChildOptions::default()
1560
        };
1561
        assert!(ask.check(&ChildLane::OxAlpha).is_err());
1562
        assert!(ask.check(&ChildLane::Codex).is_ok());
1563
1564
        let config = ChildOptions {
1565
            config: Some("/tmp/c.json".to_string()),
1566
            ..ChildOptions::default()
1567
        };
1568
        assert!(config.check(&ChildLane::OxAlpha).is_err());
1569
        assert!(config
1570
            .check(&ChildLane::Opencode {
1571
                model: "m".to_string()
1572
            })
1573
            .is_ok());
1574
1575
        // Nothing asked for, nothing refused, on any lane.
1576
        for lane in [
1577
            ChildLane::OxAlpha,
1578
            ChildLane::Devin,
1579
            ChildLane::Claude,
1580
            ChildLane::Codex,
1581
        ] {
1582
            assert!(ChildOptions::default().check(&lane).is_ok());
1583
        }
1584
    }
1585
1586
    /// `--description` names the run; without one the prompt does.
1587
    #[test]
1588
    fn a_run_is_named_by_its_description_or_its_prompt() {
1589
        assert_eq!(describe(Some("port the flags"), "anything"), "port the flags");
1590
        assert_eq!(describe(Some("   "), "one two three four five six"), "one two three four five");
1591
        assert_eq!(describe(None, "one two three four five six"), "one two three four five");
1592
        assert_eq!(describe(None, "   "), "delegated task");
1593
    }
1594
1595
    /// The two commands that run a fan-out resolve to the same request.
1596
    ///
1597
    /// `oa coder --delegate` declares none of the `--child-*` flags, so they
1598
    /// arrive as `None` rather than as a default that would be indistinguishable
1599
    /// from the reader having chosen it.
1600
    #[test]
1601
    fn both_entry_points_resolve_to_one_request() {
1602
        let request = DelegationRequest::from_delegate(DelegateArgs {
1603
            prompt: Some("go".to_string()),
1604
            agents: 4,
1605
            dir: Some("/tmp/here".to_string()),
1606
            description: Some("a run".to_string()),
1607
            concurrency: Some(2),
1608
            lane: Some("claude".to_string()),
1609
            isolation: None,
1610
            keep_workspaces: true,
1611
            child_model: Some("m".to_string()),
1612
            child_command: Some("c".to_string()),
1613
            child_config: Some("f".to_string()),
1614
            child_ask: true,
1615
        });
1616
        assert_eq!(request.count, 4);
1617
        assert_eq!(request.max_parallel, Some(2));
1618
        assert_eq!(request.directory.as_deref(), Some("/tmp/here"));
1619
        assert!(request.child_ask);
1620
1621
        let request = DelegationRequest::from_coder(CoderArgs {
1622
            prompt: Some("go".to_string()),
1623
            delegate: true,
1624
            count: 3,
1625
            max_parallel: None,
1626
            isolation: None,
1627
            keep_workspaces: false,
1628
            lane: None,
1629
            headless: false,
1630
            export: None,
1631
            plain: false,
1632
            dev: false,
1633
            dev_port: 4000,
1634
        });
1635
        assert_eq!(request.count, 3);
1636
        assert!(request.child_model.is_none());
1637
        assert!(!request.child_ask);
1638
        assert!(request.directory.is_none());
1639
    }
1640
}
crates/openagents-cli/src/diag.rs added +116

@@ -0,0 +1,116 @@

1
//! `--verbose` and `--no-color`: two process-wide output settings.
2
//!
3
//! Both were flags the parser accepted and nothing read. A flag that parses
4
//! and changes nothing is worse than a missing one, because the missing one
5
//! errors and the declared one silently lies, so each is stored here once and
6
//! consulted at the places that can act on it.
7
//!
8
//! ## Verbose
9
//!
10
//! `-v` prints the request line, the response status, and — when the server
11
//! refused — the server's own message, all on stderr so a `--json` body piped
12
//! to `jq` stays parseable. The HTTP clients call [`request`] before sending
13
//! and [`response`] after, which is why the diagnostic covers every command
14
//! family rather than the one that remembered to log.
15
//!
16
//! ## Colour
17
//!
18
//! `--no-color` turns off every colour the coder session draws and exports
19
//! `NO_COLOR=1`, which is the convention a delegated child harness reads. The
20
//! non-interactive command families print no escape sequences at all, so there
21
//! is nothing there for it to suppress; that is a property of those printers,
22
//! not a claim this flag makes about them.
23
24
use std::sync::atomic::{AtomicBool, Ordering};
25
26
static VERBOSE: AtomicBool = AtomicBool::new(false);
27
static COLOR: AtomicBool = AtomicBool::new(true);
28
29
/// Turn the request trace on. Called once from `cli::run`.
30
pub fn set_verbose(on: bool) {
31
    VERBOSE.store(on, Ordering::Relaxed);
32
}
33
34
pub fn verbose() -> bool {
35
    VERBOSE.load(Ordering::Relaxed)
36
}
37
38
/// Turn colour off, and tell anything this process spawns.
39
///
40
/// `NO_COLOR` is set rather than merely read so a delegated child harness —
41
/// which is a separate process with its own idea of styling — inherits the
42
/// reader's choice.
43
pub fn set_color(on: bool) {
44
    COLOR.store(on, Ordering::Relaxed);
45
    if !on {
46
        std::env::set_var("NO_COLOR", "1");
47
    }
48
}
49
50
/// Whether the coder session may draw in colour.
51
///
52
/// `NO_COLOR` in the environment counts, so the flag and the convention agree.
53
pub fn color() -> bool {
54
    COLOR.load(Ordering::Relaxed)
55
        && std::env::var("NO_COLOR")
56
            .map(|value| value.trim().is_empty())
57
            .unwrap_or(true)
58
}
59
60
/// Note a request about to be sent.
61
pub fn request(method: &str, url: &str) {
62
    if verbose() {
63
        eprintln!("oa: > {} {}", method, url);
64
    }
65
}
66
67
/// Note the status a request came back with.
68
pub fn response(status: u16, url: &str) {
69
    if verbose() {
70
        eprintln!("oa: < {} {}", status, url);
71
    }
72
}
73
74
/// Note a refusal the server explained.
75
///
76
/// Printed separately from [`response`] because the status alone does not say
77
/// which of six things the server objected to.
78
pub fn refused(status: u16, message: &str) {
79
    if verbose() {
80
        eprintln!("oa: ! {} {}", status, message);
81
    }
82
}
83
84
/// Note a request that never completed.
85
pub fn transport(url: &str, why: &str) {
86
    if verbose() {
87
        eprintln!("oa: ! {} did not complete: {}", url, why);
88
    }
89
}
90
91
#[cfg(test)]
92
mod tests {
93
    use super::*;
94
95
    #[test]
96
    fn verbose_is_off_until_it_is_asked_for() {
97
        set_verbose(false);
98
        assert!(!verbose());
99
        set_verbose(true);
100
        assert!(verbose());
101
        set_verbose(false);
102
    }
103
104
    #[test]
105
    fn no_color_is_exported_for_children() {
106
        std::env::remove_var("NO_COLOR");
107
        set_color(true);
108
        assert!(color());
109
        set_color(false);
110
        assert!(!color());
111
        assert_eq!(std::env::var("NO_COLOR").as_deref(), Ok("1"));
112
        // Leave the process as the other tests expect to find it.
113
        std::env::remove_var("NO_COLOR");
114
        set_color(true);
115
    }
116
}
crates/openagents-cli/src/fleet.rs added +441

@@ -0,0 +1,441 @@

1
//! `oa deploy`: the operator fleet promotion client.
2
//!
3
//! A port of `packages/openagents-cli/src/fleet-client.ts`. It speaks only the
4
//! operator API — `POST/GET /api/v1/admin/forge/targets` — behind the same
5
//! error envelope every other command family reads. It never touches
6
//! `/admin/forge`, SSH, or any internal RPC, and it adds only what a terminal
7
//! caller cannot do for itself: an idempotent re-send after a failed
8
//! transport, and bounded polling of the status resource to a terminal state.
9
//!
10
//! The privileged scope is `deployments:promote`. `forge:write` cannot
11
//! promote, and neither can a Git credential or a browser session, so a 401 or
12
//! 403 from any route here is answered with the command that obtains one
13
//! rather than a bare status.
14
15
use crate::tracker::{ApiError, TrackerClient};
16
use serde_json::{json, Value};
17
use std::time::{Duration, Instant};
18
19
/// The one route family this client speaks.
20
pub const FLEET_TARGETS_PATH: &str = "admin/forge/targets";
21
22
/// The privileged scope the server requires.
23
pub const OPERATOR_SCOPE: &str = "deployments:promote";
24
25
/// The states polling stops on.
26
///
27
/// The server marks `live`, `failed`, and `reverted` terminal;
28
/// `needs_rolling_replace` additionally ends automatic execution and waits on
29
/// an operator, so a poll that reached it would otherwise never return.
30
pub const TERMINAL_STATES: &[&str] = &["live", "failed", "reverted", "needs_rolling_replace"];
31
32
/// How many times a promotion is re-sent after a failed transport.
33
pub const PROMOTE_TRANSPORT_RETRIES: usize = 2;
34
35
/// Bounded backoff: 2s, 4s, 8s, then every 10s until the deadline.
36
pub const POLL_BASE_DELAY_MS: u64 = 2_000;
37
pub const POLL_MAXIMUM_DELAY_MS: u64 = 10_000;
38
39
const RETRY_DELAY_MS: u64 = 500;
40
41
/// Reads the lifecycle state off a target body.
42
pub fn target_status(target: &Value) -> String {
43
    target
44
        .get("status")
45
        .and_then(Value::as_str)
46
        .unwrap_or("unknown")
47
        .to_string()
48
}
49
50
/// Whether polling has nothing further to learn about this target.
51
pub fn terminal_status(status: &str) -> bool {
52
    TERMINAL_STATES.contains(&status)
53
}
54
55
pub fn target_id(target: &Value) -> String {
56
    match target.get("id") {
57
        Some(Value::String(text)) => text.clone(),
58
        Some(Value::Null) | None => String::new(),
59
        Some(other) => other.to_string(),
60
    }
61
}
62
63
fn string_field(target: &Value, key: &str) -> String {
64
    match target.get(key) {
65
        Some(Value::String(text)) => text.clone(),
66
        Some(Value::Null) | None => String::new(),
67
        Some(other) => other.to_string(),
68
    }
69
}
70
71
/// Environment, repository, full SHA, target ID, state, and status URL come
72
/// before any success wording, so the operator reads what was promoted before
73
/// reading how it went.
74
pub fn target_human(target: &Value) -> Vec<String> {
75
    vec![
76
        format!("Environment: {}", string_field(target, "environment")),
77
        format!("Repository:  {}", string_field(target, "repo")),
78
        format!("SHA:         {}", string_field(target, "sha")),
79
        format!("Target:      {}", target_id(target)),
80
        format!("State:       {}", target_status(target)),
81
        format!("Status URL:  {}", string_field(target, "status_url")),
82
    ]
83
}
84
85
pub fn failure_code(target: &Value) -> Option<String> {
86
    target
87
        .get("error_code")
88
        .and_then(Value::as_str)
89
        .map(String::from)
90
}
91
92
/// The `--json` document. Same schema names and same derived fields as the
93
/// TypeScript, so a script reads one shape from either binary.
94
pub fn target_document(
95
    schema: &str,
96
    target: &Value,
97
    nonterminal: &str,
98
    extra: &[(&str, Value)],
99
) -> Value {
100
    let status = target_status(target);
101
    let mut map = serde_json::Map::new();
102
    map.insert("schema".into(), Value::String(schema.into()));
103
    for (key, value) in extra {
104
        map.insert((*key).into(), value.clone());
105
    }
106
    map.insert(
107
        "outcome".into(),
108
        Value::String(if terminal_status(&status) {
109
            status.clone()
110
        } else {
111
            nonterminal.to_string()
112
        }),
113
    );
114
    map.insert("live".into(), Value::Bool(status == "live"));
115
    map.insert("terminal".into(), Value::Bool(terminal_status(&status)));
116
    map.insert(
117
        "failure_code".into(),
118
        failure_code(target)
119
            .map(Value::String)
120
            .unwrap_or(Value::Null),
121
    );
122
    map.insert("target".into(), target.clone());
123
    Value::Object(map)
124
}
125
126
pub fn terminal_human(target: &Value) -> String {
127
    match target_status(target).as_str() {
128
        "live" => "The fleet target is live.".to_string(),
129
        "needs_rolling_replace" => {
130
            "The target needs an operator-driven rolling replacement; the automatic lanes stopped."
131
                .to_string()
132
        }
133
        other => format!("The fleet target reached {other}."),
134
    }
135
}
136
137
/// One row of `deploy list`.
138
pub fn target_row(target: &Value) -> String {
139
    format!(
140
        "{:<38}{:<22}{}  {}",
141
        target_id(target),
142
        target_status(target),
143
        string_field(target, "sha"),
144
        string_field(target, "promoted_at")
145
    )
146
}
147
148
/// An operator refused for standing or scope needs the exact next command, not
149
/// a bare status. The remediation names the privileged scope and says plainly
150
/// that `forge:write` is not it.
151
pub fn operator_remediation(error: ApiError) -> ApiError {
152
    match error {
153
        ApiError::Refused {
154
            operation,
155
            status: status @ (401 | 403),
156
            message,
157
        } => ApiError::Refused {
158
            operation,
159
            status,
160
            message: format!(
161
                "{message} Fleet promotion requires an operator API token holding \
162
                 {OPERATOR_SCOPE}; forge:write cannot promote, and neither can a Git credential \
163
                 or a browser session. An operator obtains one with: oa auth login --scope \
164
                 {OPERATOR_SCOPE}"
165
            ),
166
        },
167
        other => other,
168
    }
169
}
170
171
/// A full 40-character commit SHA. Branch names and abbreviations are refused.
172
pub fn full_sha(value: &str) -> bool {
173
    value.len() == 40 && value.chars().all(|c| c.is_ascii_hexdigit())
174
}
175
176
pub struct FleetClient {
177
    tracker: TrackerClient,
178
}
179
180
pub struct PromoteInput {
181
    pub repo: String,
182
    pub sha: String,
183
    pub environment: String,
184
    /// Generated once by the caller and reused across automatic retries.
185
    pub idempotency_key: String,
186
    pub expected_current_target_id: Option<String>,
187
}
188
189
pub struct PromoteResult {
190
    /// True when the server answered `202 Accepted` with a new target.
191
    pub accepted: bool,
192
    /// True when the idempotency key replayed an existing identical promotion.
193
    pub replayed: bool,
194
    pub target: Value,
195
}
196
197
impl FleetClient {
198
    pub fn new(api_base: &str, token: Option<String>) -> Self {
199
        Self {
200
            tracker: TrackerClient::new(api_base, token),
201
        }
202
    }
203
204
    pub async fn view(&self, id: &str) -> Result<Value, ApiError> {
205
        self.tracker
206
            .request(
207
                "read a fleet target",
208
                "GET",
209
                &format!("{FLEET_TARGETS_PATH}/{}", urlencode(id)),
210
                None,
211
                &[200],
212
            )
213
            .await
214
            .map_err(operator_remediation)
215
    }
216
217
    pub async fn list(&self, repo: Option<&str>, limit: Option<u32>) -> Result<Value, ApiError> {
218
        let mut query: Vec<String> = Vec::new();
219
        if let Some(repo) = repo {
220
            query.push(format!("repo={}", urlencode(repo)));
221
        }
222
        if let Some(limit) = limit {
223
            query.push(format!("limit={limit}"));
224
        }
225
        let path = if query.is_empty() {
226
            FLEET_TARGETS_PATH.to_string()
227
        } else {
228
            format!("{FLEET_TARGETS_PATH}?{}", query.join("&"))
229
        };
230
        self.tracker
231
            .request("list fleet targets", "GET", &path, None, &[200])
232
            .await
233
            .map_err(operator_remediation)
234
    }
235
236
    /// One promotion attempt.
237
    ///
238
    /// `202` admits a new target; `200` replays the identical promotion the
239
    /// same key already named. The distinction is the answer to "did I just
240
    /// deploy, or had I already?", so it is read off the status rather than
241
    /// folded into a shared accepted-status helper.
242
    async fn promote_attempt(&self, input: &PromoteInput) -> Result<PromoteResult, ApiError> {
243
        let mut body = json!({
244
            "repo": input.repo,
245
            "sha": input.sha,
246
            "environment": input.environment,
247
            "idempotency_key": input.idempotency_key,
248
        });
249
        if let Some(expected) = &input.expected_current_target_id {
250
            body["expected_current_target_id"] = Value::String(expected.clone());
251
        }
252
        // `202` admits a new target; `200` replays the identical promotion the
253
        // same key already named. The distinction is the answer to "did I just
254
        // deploy, or had I already?", so it is read off the status the server
255
        // chose. The body cannot answer it — the server returns the target
256
        // either way — which is why this asks for the status rather than
257
        // guessing from a marker the server may not send.
258
        let (status, target) = self
259
            .tracker
260
            .request_with_status(
261
                "promote a fleet target",
262
                "POST",
263
                FLEET_TARGETS_PATH,
264
                Some(body),
265
                &[200, 202],
266
            )
267
            .await?;
268
        Ok(PromoteResult {
269
            accepted: status == 202,
270
            replayed: status == 200,
271
            target,
272
        })
273
    }
274
275
    /// The idempotency key travels in the body, so every attempt names the
276
    /// same promotion and a re-send can never deploy twice. Only a failed
277
    /// transport is retried — the request may never have reached the server; a
278
    /// refusal the server actually made is final.
279
    pub async fn promote(&self, input: &PromoteInput) -> Result<PromoteResult, ApiError> {
280
        let mut attempt = 0usize;
281
        loop {
282
            match self.promote_attempt(input).await {
283
                Ok(result) => return Ok(result),
284
                Err(ApiError::Transport { operation, why }) => {
285
                    if attempt >= PROMOTE_TRANSPORT_RETRIES {
286
                        return Err(ApiError::Transport { operation, why });
287
                    }
288
                    attempt += 1;
289
                    tokio::time::sleep(Duration::from_millis(RETRY_DELAY_MS * attempt as u64))
290
                        .await;
291
                }
292
                Err(other) => return Err(operator_remediation(other)),
293
            }
294
        }
295
    }
296
297
    /// Poll the status resource until the target reaches a terminal state, or
298
    /// the deadline passes.
299
    ///
300
    /// A timeout is not a failed deployment: the target keeps running, so the
301
    /// error says so and names the command that resumes watching.
302
    pub async fn wait(&self, id: &str, timeout: Duration) -> Result<Value, ApiError> {
303
        let started = Instant::now();
304
        let mut attempt = 0u32;
305
        loop {
306
            let target = self.view(id).await?;
307
            if terminal_status(&target_status(&target)) {
308
                return Ok(target);
309
            }
310
            let elapsed = started.elapsed();
311
            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
                )));
318
            }
319
            let delay = poll_delay(attempt);
320
            let remaining = timeout - elapsed;
321
            tokio::time::sleep(delay.min(remaining)).await;
322
            attempt += 1;
323
        }
324
    }
325
}
326
327
/// 2s, 4s, 8s, then 10s.
328
pub fn poll_delay(attempt: u32) -> Duration {
329
    let scaled = POLL_BASE_DELAY_MS.saturating_mul(1u64 << attempt.min(16));
330
    Duration::from_millis(scaled.min(POLL_MAXIMUM_DELAY_MS))
331
}
332
333
/// Percent-encode a path or query segment.
334
fn urlencode(value: &str) -> String {
335
    let mut out = String::with_capacity(value.len());
336
    for byte in value.bytes() {
337
        match byte {
338
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
339
                out.push(byte as char)
340
            }
341
            other => out.push_str(&format!("%{:02X}", other)),
342
        }
343
    }
344
    out
345
}
346
347
#[cfg(test)]
348
mod tests {
349
    use super::*;
350
351
    #[test]
352
    fn only_a_full_sha_is_accepted() {
353
        assert!(full_sha("0123456789abcdef0123456789abcdef01234567"));
354
        assert!(!full_sha("0123456"));
355
        assert!(!full_sha("main"));
356
        assert!(!full_sha(&"z".repeat(40)));
357
    }
358
359
    #[test]
360
    fn the_four_terminal_states_stop_polling() {
361
        for state in ["live", "failed", "reverted", "needs_rolling_replace"] {
362
            assert!(terminal_status(state), "{state}");
363
        }
364
        for state in ["queued", "building", "rolling", "unknown"] {
365
            assert!(!terminal_status(state), "{state}");
366
        }
367
    }
368
369
    #[test]
370
    fn backoff_is_bounded() {
371
        assert_eq!(poll_delay(0), Duration::from_millis(2_000));
372
        assert_eq!(poll_delay(1), Duration::from_millis(4_000));
373
        assert_eq!(poll_delay(2), Duration::from_millis(8_000));
374
        assert_eq!(poll_delay(3), Duration::from_millis(10_000));
375
        assert_eq!(poll_delay(40), Duration::from_millis(10_000));
376
    }
377
378
    #[test]
379
    fn a_scope_refusal_names_the_command_that_fixes_it() {
380
        let error = operator_remediation(ApiError::Refused {
381
            operation: "list fleet targets".into(),
382
            status: 401,
383
            message: "Requires an API token carrying deployments:promote".into(),
384
        });
385
        let rendered = error.to_string();
386
        assert!(rendered.contains("deployments:promote"));
387
        assert!(rendered.contains("forge:write cannot promote"));
388
        assert!(rendered.contains("oa auth login --scope deployments:promote"));
389
    }
390
391
    #[test]
392
    fn a_non_auth_refusal_is_left_alone() {
393
        let error = operator_remediation(ApiError::Refused {
394
            operation: "read a fleet target".into(),
395
            status: 404,
396
            message: "No such target.".into(),
397
        });
398
        assert!(!error.to_string().contains("deployments:promote"));
399
    }
400
401
    #[test]
402
    fn the_document_carries_the_derived_state() {
403
        let target = json!({
404
            "id": "tgt-1",
405
            "status": "live",
406
            "sha": "a".repeat(40),
407
            "repo": "openagents.com",
408
            "environment": "production",
409
        });
410
        let document = target_document("openagents.fleet_target.v1", &target, "pending", &[]);
411
        assert_eq!(document["schema"], "openagents.fleet_target.v1");
412
        assert_eq!(document["outcome"], "live");
413
        assert_eq!(document["live"], true);
414
        assert_eq!(document["terminal"], true);
415
        assert_eq!(document["failure_code"], Value::Null);
416
        assert_eq!(document["target"]["id"], "tgt-1");
417
418
        let pending = json!({ "id": "tgt-2", "status": "building" });
419
        let document = target_document("openagents.fleet_target.v1", &pending, "pending", &[]);
420
        assert_eq!(document["outcome"], "pending");
421
        assert_eq!(document["live"], false);
422
        assert_eq!(document["terminal"], false);
423
    }
424
425
    #[test]
426
    fn the_human_view_leads_with_what_was_promoted() {
427
        let target = json!({
428
            "id": "tgt-1",
429
            "status": "queued",
430
            "sha": "b".repeat(40),
431
            "repo": "openagents.com",
432
            "environment": "production",
433
            "status_url": "https://openagents.com/api/v1/admin/forge/targets/tgt-1",
434
        });
435
        let lines = target_human(&target);
436
        assert!(lines[0].starts_with("Environment: production"));
437
        assert!(lines[1].starts_with("Repository:  openagents.com"));
438
        assert!(lines[3].contains("tgt-1"));
439
        assert!(lines[4].contains("queued"));
440
    }
441
}
crates/openagents-cli/src/forum.rs modified +7 -1

@@ -97,21 +97,27 @@ impl ForumClient {

97 97
    /// `GET` a forum route and return its parsed body, or the server's refusal.
98 98
    async fn get_json(&self, path: &str) -> Result<serde_json::Value, ForumError> {
99 99
        let url = format!("{}/{}", self.api_base, path);
100
        crate::diag::request("GET", &url);
100 101
        let resp = self
101 102
            .http
102 103
            .get(&url)
103 104
            .headers(self.headers())
104 105
            .send()
105 106
            .await
106
            .map_err(|e| ForumError::Transport(e.to_string()))?;
107
            .map_err(|e| {
108
                crate::diag::transport(&url, &e.to_string());
109
                ForumError::Transport(e.to_string())
110
            })?;
107 111
108 112
        let status = resp.status();
113
        crate::diag::response(status.as_u16(), &url);
109 114
        let body = resp
110 115
            .text()
111 116
            .await
112 117
            .map_err(|e| ForumError::Transport(e.to_string()))?;
113 118
114 119
        if !status.is_success() {
120
            crate::diag::refused(status.as_u16(), &body);
115 121
            return Err(ForumError::Refused {
116 122
                status: status.as_u16(),
117 123
                body,
crates/openagents-cli/src/interactive.rs modified +38 -7

@@ -416,17 +416,20 @@ fn session_tools(lane_name: &str, token: &Option<String>) -> HarnessToolRegistry

416 416
417 417
pub async fn run_tui(
418 418
    args: CoderArgs,
419
    api_base: String,
419 420
    token: Option<String>,
420 421
) -> Result<(), Box<dyn std::error::Error>> {
421 422
    let lane_name = args.lane.clone().unwrap_or_else(|| "ox-alpha".to_string());
422 423
    let lane = Lane::from_str(&lane_name);
423 424
424
    if !is_terminal() {
425
        return run_without_a_terminal(args, token, lane).await;
425
    // `--plain` asks for the line-oriented path even on a terminal. Without a
426
    // terminal there is no full-screen session to run either way.
427
    if args.plain || !is_terminal() {
428
        return run_without_a_terminal(args, api_base, token, lane).await;
426 429
    }
427 430
428 431
    let tools = session_tools(&lane_name, &token);
429
    let session = CoderRuntimeSession::new(lane.clone(), None, token, tools);
432
    let session = CoderRuntimeSession::new(lane.clone(), Some(api_base), token, tools);
430 433
431 434
    let (control_tx, control_rx) = unbounded_channel::<Control>();
432 435
    let (event_tx, mut event_rx) = unbounded_channel::<TurnEvent>();

@@ -464,18 +467,37 @@ pub async fn run_tui(

464 467
    Ok(())
465 468
}
466 469
467
/// Without a terminal there is no session to run, so run the prompt straight
468
/// through and stream the reply to stdout.
470
/// The `--export` transcript for a one-turn run.
471
///
472
/// The same two-role, `[who] text` shape [`CoderApp::transcript`] writes, so a
473
/// transcript from a plain or headless run reads like one from the session.
474
pub fn transcript_of(prompt: &str, answer: &str) -> String {
475
    let mut parts = Vec::new();
476
    if !prompt.is_empty() {
477
        parts.push(format!("[you] {prompt}"));
478
    }
479
    if !answer.trim().is_empty() {
480
        parts.push(format!("[coder] {}", answer.trim_end()));
481
    }
482
    parts.join("\n\n")
483
}
484
485
/// Line-oriented output: one turn, printed as lines, with no cursor control.
486
///
487
/// This is both the no-terminal path and what `--plain` asks for on a
488
/// terminal. It emits no escape sequences at all, so it can be piped to a file
489
/// or read by something that is not a terminal emulator.
469 490
///
470 491
/// The version this replaces printed `Coder response: Interactive session
471 492
/// initialized in non-TTY mode.` and never called the runtime at all.
472 493
async fn run_without_a_terminal(
473 494
    args: CoderArgs,
495
    api_base: String,
474 496
    token: Option<String>,
475 497
    lane: Lane,
476 498
) -> Result<(), Box<dyn std::error::Error>> {
477 499
    let lane_name = args.lane.clone().unwrap_or_else(|| "ox-alpha".to_string());
478
    let Some(prompt) = args.prompt else {
500
    let Some(prompt) = args.prompt.clone() else {
479 501
        eprintln!(
480 502
            "`oa coder` needs a terminal for an interactive session. \
481 503
             Give it a prompt, or use `--headless`, to run one turn here."

@@ -484,7 +506,7 @@ async fn run_without_a_terminal(

484 506
    };
485 507
486 508
    let tools = session_tools(&lane_name, &token);
487
    let mut session = CoderRuntimeSession::new(lane, None, token, tools);
509
    let mut session = CoderRuntimeSession::new(lane, Some(api_base), token, tools);
488 510
    // The reply is printed as it streams. `execute_turn` also returns the last
489 511
    // step's text, which is the same text — so it is printed only when nothing
490 512
    // streamed, which is how the offline paths still say something.

@@ -503,6 +525,15 @@ async fn run_without_a_terminal(

503 525
        print!("{answer}");
504 526
    }
505 527
    println!();
528
529
    // `--export` used to be read only by the full-screen session, so a plain,
530
    // piped, or headless run that asked for a transcript got none and was told
531
    // nothing.
532
    if let Some(path) = args.export.as_deref() {
533
        std::fs::write(path, transcript_of(&prompt, &answer))
534
            .map_err(|error| format!("could not write the transcript to {path}: {error}"))?;
535
        println!("Transcript written to {path}");
536
    }
506 537
    Ok(())
507 538
}
508 539
crates/openagents-cli/src/lib.rs modified +3

@@ -19,10 +19,13 @@ pub mod cli;

19 19
pub mod composer;
20 20
pub mod computer;
21 21
pub mod delegate;
22
pub mod diag;
23
pub mod fleet;
22 24
pub mod forum;
23 25
pub mod identity;
24 26
pub mod interactive;
25 27
pub mod memory_client;
28
pub mod provider;
26 29
pub mod repo;
27 30
pub mod runtime;
28 31
pub mod signals;
crates/openagents-cli/src/memory_client.rs modified +11 -4

@@ -105,21 +105,28 @@ impl MemoryClient {

105 105
            builder = builder.json(&payload);
106 106
        }
107 107
108
        let response = builder.send().await.map_err(|e| ApiError::Transport {
109
            operation: operation.to_string(),
110
            why: e.to_string(),
108
        crate::diag::request(method, &url);
109
        let response = builder.send().await.map_err(|e| {
110
            crate::diag::transport(&url, &e.to_string());
111
            ApiError::Transport {
112
                operation: operation.to_string(),
113
                why: e.to_string(),
114
            }
111 115
        })?;
112 116
        let status = response.status().as_u16();
117
        crate::diag::response(status, &url);
113 118
        let text = response.text().await.map_err(|e| ApiError::Transport {
114 119
            operation: operation.to_string(),
115 120
            why: e.to_string(),
116 121
        })?;
117 122
118 123
        if !accepted.contains(&status) {
124
            let message = error_sentence(&text, status);
125
            crate::diag::refused(status, &message);
119 126
            return Err(ApiError::Refused {
120 127
                operation: operation.to_string(),
121 128
                status,
122
                message: error_sentence(&text, status),
129
                message,
123 130
            });
124 131
        }
125 132
        serde_json::from_str(&text).map_err(|e| ApiError::Malformed {
crates/openagents-cli/src/provider.rs added +639

@@ -0,0 +1,639 @@

1
//! `oa provider settle`: the settlement gate.
2
//!
3
//! A port of `packages/openagents-cli/src/provider-settlement.ts`, gate for
4
//! gate and message for message, so the same lease and the same closeout
5
//! receipt produce the same decision from either binary.
6
//!
7
//! What the gate is for: a lease is not an earning claim and a provider's own
8
//! submission is not a receipt. Only a NIP-LBR closeout receipt — one that
9
//! names this job and this provider, was not issued by the provider to itself,
10
//! carries a verification command and the evidence it produced, carries the
11
//! platform's own closeout, is content-addressable, landed inside the lease
12
//! window, and prices the job exactly as the lease did — earns anything.
13
//!
14
//! What it refuses to be: it moves no money and holds no key. A settled
15
//! decision is an accrual record. `payout_rail` is `not_connected` and
16
//! `custody` is `none`, and outbound payout stays on the MDK/Nexus bridge.
17
//!
18
//! Presence is not an input. [`settle_lease`] takes a lease and a closeout, so
19
//! a provider that never earns a closeout earns zero no matter how long it is
20
//! online.
21
22
use serde::{Deserialize, Serialize};
23
use serde_json::Value;
24
25
/// A lease: the buyer's grant of one job to one provider at one price.
26
#[derive(Debug, Clone, PartialEq)]
27
pub struct ProviderLease {
28
    pub job_id: String,
29
    pub lane: String,
30
    pub provider: String,
31
    pub price_msats: f64,
32
    pub expires_at: String,
33
}
34
35
/// The public-safe fields of a NIP-LBR closeout receipt this gate reads.
36
///
37
/// A structural mirror of `LbrLaborCloseout`; the names are that module's
38
/// names, so a receipt produced there is accepted here without translation.
39
#[derive(Debug, Clone, Default, PartialEq)]
40
pub struct LaborCloseoutReceipt {
41
    pub receipt_ref: String,
42
    pub request_id: String,
43
    pub requester_pubkey: String,
44
    pub provider_pubkey: String,
45
    pub quoted_amount_msats: f64,
46
    /// What was run to check the work. Empty means nothing checked it.
47
    pub verification_command_ref: String,
48
    /// The evidence that check produced.
49
    pub test_ref: String,
50
    /// The platform's own closeout. Settlement authority lives there.
51
    pub platform_closeout_ref: String,
52
    /// SHA-256 over the canonical projection.
53
    pub digest: String,
54
    pub settled_at: String,
55
}
56
57
/// Why a settlement did not happen. Each one is a distinct, nameable failure.
58
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59
#[serde(rename_all = "snake_case")]
60
pub enum SettlementRefusal {
61
    PriceNotPayable,
62
    NoCloseout,
63
    CloseoutJobMismatch,
64
    CloseoutProviderMismatch,
65
    SelfDealt,
66
    WorkNotVerified,
67
    NoSettlementAuthority,
68
    ReceiptNotAddressable,
69
    LeaseExpired,
70
    PriceMismatch,
71
}
72
73
impl SettlementRefusal {
74
    pub fn as_str(self) -> &'static str {
75
        match self {
76
            Self::PriceNotPayable => "price_not_payable",
77
            Self::NoCloseout => "no_closeout",
78
            Self::CloseoutJobMismatch => "closeout_job_mismatch",
79
            Self::CloseoutProviderMismatch => "closeout_provider_mismatch",
80
            Self::SelfDealt => "self_dealt",
81
            Self::WorkNotVerified => "work_not_verified",
82
            Self::NoSettlementAuthority => "no_settlement_authority",
83
            Self::ReceiptNotAddressable => "receipt_not_addressable",
84
            Self::LeaseExpired => "lease_expired",
85
            Self::PriceMismatch => "price_mismatch",
86
        }
87
    }
88
}
89
90
#[derive(Debug, Clone, PartialEq)]
91
pub struct SettlementDecision {
92
    pub job_id: String,
93
    /// `settled` or `unsettled`.
94
    pub state: &'static str,
95
    /// What the verified job is owed. Zero on every path but a clean receipt.
96
    pub earned_msats: f64,
97
    pub reason: String,
98
    pub refusal: Option<SettlementRefusal>,
99
    pub receipt_ref: Option<String>,
100
}
101
102
impl SettlementDecision {
103
    /// The document `--json` prints. Field for field the TypeScript shape,
104
    /// including the two constants that say this decision moved nothing.
105
    pub fn to_json(&self) -> Value {
106
        let mut map = serde_json::Map::new();
107
        map.insert(
108
            "schema".into(),
109
            Value::String("openagents.provider_settlement.v1".into()),
110
        );
111
        map.insert("job_id".into(), Value::String(self.job_id.clone()));
112
        map.insert("state".into(), Value::String(self.state.into()));
113
        map.insert("earned_msats".into(), number(self.earned_msats));
114
        map.insert("reason".into(), Value::String(self.reason.clone()));
115
        if let Some(refusal) = self.refusal {
116
            map.insert("refusal".into(), Value::String(refusal.as_str().into()));
117
        }
118
        map.insert("payout_rail".into(), Value::String("not_connected".into()));
119
        map.insert("custody".into(), Value::String("none".into()));
120
        if let Some(receipt) = &self.receipt_ref {
121
            map.insert("receipt_ref".into(), Value::String(receipt.clone()));
122
        }
123
        Value::Object(map)
124
    }
125
126
    /// The lines the human mode prints, in the TypeScript order.
127
    pub fn human(&self) -> Vec<String> {
128
        let mut lines = vec![
129
            format!("Job: {}", self.job_id),
130
            format!("Outcome: {}", self.state),
131
            format!("Earned: {} msats", render_number(self.earned_msats)),
132
        ];
133
        if let Some(refusal) = self.refusal {
134
            lines.push(format!("Refused: {}", refusal.as_str()));
135
        }
136
        if let Some(receipt) = &self.receipt_ref {
137
            lines.push(format!("Receipt: {}", receipt));
138
        }
139
        lines.push(self.reason.clone());
140
        lines.push(
141
            "Accrual only: this command holds no key, connects no payout rail, and moves nothing."
142
                .to_string(),
143
        );
144
        lines
145
    }
146
}
147
148
/// JSON has one number type and the TypeScript writes `1200`, not `1200.0`.
149
fn number(value: f64) -> Value {
150
    if value.is_finite() && value.fract() == 0.0 {
151
        return Value::from(value as i64);
152
    }
153
    serde_json::Number::from_f64(value)
154
        .map(Value::Number)
155
        .unwrap_or(Value::Null)
156
}
157
158
/// `NaN` reads as `NaN` in JavaScript, and a lease priced by a missing field
159
/// should say so rather than say `0`.
160
fn render_number(value: f64) -> String {
161
    if value.is_nan() {
162
        return "NaN".to_string();
163
    }
164
    if value.fract() == 0.0 && value.is_finite() {
165
        return format!("{}", value as i64);
166
    }
167
    format!("{}", value)
168
}
169
170
fn unsettled(job_id: &str, refusal: SettlementRefusal, reason: String) -> SettlementDecision {
171
    SettlementDecision {
172
        job_id: job_id.to_string(),
173
        state: "unsettled",
174
        earned_msats: 0.0,
175
        reason,
176
        refusal: Some(refusal),
177
        receipt_ref: None,
178
    }
179
}
180
181
fn blank(value: &str) -> bool {
182
    value.trim().is_empty()
183
}
184
185
/// A 32-byte hex hash, the only digest a receipt can be dereferenced by.
186
fn addressable(digest: &str) -> bool {
187
    digest.len() == 64 && digest.chars().all(|c| c.is_ascii_hexdigit())
188
}
189
190
/// RFC 3339 to milliseconds, or `None` for anything unparseable.
191
///
192
/// Only the ordering of two instants matters here, so this reads the fields it
193
/// needs rather than pulling a date library into a crate that has none.
194
pub fn parse_time(value: &str) -> Option<i64> {
195
    let text = value.trim();
196
    if text.len() < 19 {
197
        return None;
198
    }
199
    let bytes = text.as_bytes();
200
    let digits = |from: usize, to: usize| -> Option<i64> {
201
        std::str::from_utf8(&bytes[from..to]).ok()?.parse().ok()
202
    };
203
    if bytes[4] != b'-' || bytes[7] != b'-' || (bytes[10] != b'T' && bytes[10] != b' ') {
204
        return None;
205
    }
206
    if bytes[13] != b':' || bytes[16] != b':' {
207
        return None;
208
    }
209
    let year = digits(0, 4)?;
210
    let month = digits(5, 7)?;
211
    let day = digits(8, 10)?;
212
    let hour = digits(11, 13)?;
213
    let minute = digits(14, 16)?;
214
    let second = digits(17, 19)?;
215
    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
216
        return None;
217
    }
218
    if hour > 23 || minute > 59 || second > 60 {
219
        return None;
220
    }
221
222
    let mut fraction_ms = 0i64;
223
    let mut cursor = 19;
224
    if bytes.get(cursor) == Some(&b'.') {
225
        cursor += 1;
226
        let mut place = 100;
227
        while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
228
            if place > 0 {
229
                fraction_ms += i64::from(bytes[cursor] - b'0') * place;
230
                place /= 10;
231
            }
232
            cursor += 1;
233
        }
234
    }
235
236
    // Offset, if the timestamp carries one. `Z` and a missing offset are both
237
    // read as UTC, which is what `Date.parse` does for an RFC 3339 instant.
238
    let mut offset_minutes = 0i64;
239
    if cursor < bytes.len() {
240
        match bytes[cursor] {
241
            b'Z' | b'z' => {}
242
            sign @ (b'+' | b'-') => {
243
                if bytes.len() < cursor + 6 || bytes[cursor + 3] != b':' {
244
                    return None;
245
                }
246
                let hours: i64 = std::str::from_utf8(&bytes[cursor + 1..cursor + 3])
247
                    .ok()?
248
                    .parse()
249
                    .ok()?;
250
                let minutes: i64 = std::str::from_utf8(&bytes[cursor + 4..cursor + 6])
251
                    .ok()?
252
                    .parse()
253
                    .ok()?;
254
                let total = hours * 60 + minutes;
255
                offset_minutes = if sign == b'+' { total } else { -total };
256
            }
257
            _ => return None,
258
        }
259
    }
260
261
    Some(
262
        (days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second) * 1_000
263
            + fraction_ms
264
            - offset_minutes * 60_000,
265
    )
266
}
267
268
/// Days since 1970-01-01, by Howard Hinnant's civil-from-days inverse.
269
fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
270
    let year = if month <= 2 { year - 1 } else { year };
271
    let era = if year >= 0 { year } else { year - 399 } / 400;
272
    let year_of_era = year - era * 400;
273
    let day_of_year = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1;
274
    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
275
    era * 146_097 + day_of_era - 719_468
276
}
277
278
/// Decide what one leased job earns.
279
///
280
/// The gates run in the order a reader would check them by hand, so a refusal
281
/// names the first thing that is actually wrong rather than the last.
282
pub fn settle_lease(
283
    lease: &ProviderLease,
284
    closeout: Option<&LaborCloseoutReceipt>,
285
) -> SettlementDecision {
286
    if !lease.price_msats.is_finite() || lease.price_msats <= 0.0 {
287
        return unsettled(
288
            &lease.job_id,
289
            SettlementRefusal::PriceNotPayable,
290
            format!(
291
                "The lease prices this job at {} msats, so there is nothing to settle.",
292
                render_number(lease.price_msats)
293
            ),
294
        );
295
    }
296
297
    let Some(closeout) = closeout else {
298
        return unsettled(
299
            &lease.job_id,
300
            SettlementRefusal::NoCloseout,
301
            "No closeout receipt covers this job. A lease is not an earning claim and a submission \
302
             is not a receipt, so this earns nothing."
303
                .to_string(),
304
        );
305
    };
306
307
    if closeout.request_id != lease.job_id {
308
        return unsettled(
309
            &lease.job_id,
310
            SettlementRefusal::CloseoutJobMismatch,
311
            format!(
312
                "The receipt closes out job {}, not the leased job {}.",
313
                closeout.request_id, lease.job_id
314
            ),
315
        );
316
    }
317
318
    if closeout.provider_pubkey != lease.provider {
319
        return unsettled(
320
            &lease.job_id,
321
            SettlementRefusal::CloseoutProviderMismatch,
322
            format!(
323
                "The receipt credits provider {}, but the lease is held by {}.",
324
                closeout.provider_pubkey, lease.provider
325
            ),
326
        );
327
    }
328
329
    if closeout.requester_pubkey == closeout.provider_pubkey {
330
        return unsettled(
331
            &lease.job_id,
332
            SettlementRefusal::SelfDealt,
333
            "The receipt names the same key as requester and provider. A provider cannot buy its \
334
             own work into an earning."
335
                .to_string(),
336
        );
337
    }
338
339
    if blank(&closeout.verification_command_ref) || blank(&closeout.test_ref) {
340
        return unsettled(
341
            &lease.job_id,
342
            SettlementRefusal::WorkNotVerified,
343
            "The receipt carries no verification command and evidence pair, so nothing checked \
344
             this work. Unverified work earns nothing."
345
                .to_string(),
346
        );
347
    }
348
349
    if blank(&closeout.platform_closeout_ref) {
350
        return unsettled(
351
            &lease.job_id,
352
            SettlementRefusal::NoSettlementAuthority,
353
            "The receipt carries no platform closeout ref. Settlement authority stays in the \
354
             platform receipt systems; the relay is only transport."
355
                .to_string(),
356
        );
357
    }
358
359
    if !addressable(&closeout.digest) {
360
        return unsettled(
361
            &lease.job_id,
362
            SettlementRefusal::ReceiptNotAddressable,
363
            "The receipt digest is not a 32-byte hex hash, so the receipt cannot be dereferenced \
364
             and re-verified."
365
                .to_string(),
366
        );
367
    }
368
369
    let expires_at = parse_time(&lease.expires_at);
370
    let settled_at = parse_time(&closeout.settled_at);
371
    let (Some(expires_at), Some(settled_at)) = (expires_at, settled_at) else {
372
        return unsettled(
373
            &lease.job_id,
374
            SettlementRefusal::LeaseExpired,
375
            "The lease window could not be read, so the closeout cannot be placed inside it."
376
                .to_string(),
377
        );
378
    };
379
    if settled_at > expires_at {
380
        return unsettled(
381
            &lease.job_id,
382
            SettlementRefusal::LeaseExpired,
383
            format!(
384
                "The job closed out at {}, after the lease expired at {}.",
385
                closeout.settled_at, lease.expires_at
386
            ),
387
        );
388
    }
389
390
    if closeout.quoted_amount_msats != lease.price_msats {
391
        return unsettled(
392
            &lease.job_id,
393
            SettlementRefusal::PriceMismatch,
394
            format!(
395
                "The receipt quotes {} msats but the lease priced the job at {} msats.",
396
                render_number(closeout.quoted_amount_msats),
397
                render_number(lease.price_msats)
398
            ),
399
        );
400
    }
401
402
    SettlementDecision {
403
        job_id: lease.job_id.clone(),
404
        state: "settled",
405
        earned_msats: closeout.quoted_amount_msats,
406
        reason: format!(
407
            "Verified by {} with evidence {}, closed out by {}. Accrued, not paid: no payout rail \
408
             is connected.",
409
            closeout.verification_command_ref, closeout.test_ref, closeout.platform_closeout_ref
410
        ),
411
        refusal: None,
412
        receipt_ref: Some(closeout.receipt_ref.clone()),
413
    }
414
}
415
416
// ---------------------------------------------------------------------------
417
// reading the two documents
418
// ---------------------------------------------------------------------------
419
420
fn text(value: &Value, key: &str) -> String {
421
    value
422
        .get(key)
423
        .and_then(Value::as_str)
424
        .unwrap_or_default()
425
        .to_string()
426
}
427
428
fn count(value: &Value, key: &str) -> f64 {
429
    value.get(key).and_then(Value::as_f64).unwrap_or(f64::NAN)
430
}
431
432
/// Read a lease document.
433
///
434
/// The four fields the gate needs are required; a lease missing one is a typo
435
/// the reader would rather hear about now than as a mysterious refusal.
436
pub fn decode_lease(value: &Value, path: &str) -> Result<ProviderLease, String> {
437
    if !value.is_object() {
438
        return Err(format!("The lease at {path} is not a JSON object."));
439
    }
440
    let missing: Vec<&str> = ["job_id", "lane", "provider", "expires_at"]
441
        .into_iter()
442
        .filter(|field| match value.get(*field).and_then(Value::as_str) {
443
            Some(text) => text.is_empty(),
444
            None => true,
445
        })
446
        .collect();
447
    if !missing.is_empty() {
448
        return Err(format!(
449
            "The lease at {path} is missing {}.",
450
            missing.join(", ")
451
        ));
452
    }
453
    Ok(ProviderLease {
454
        job_id: text(value, "job_id"),
455
        lane: text(value, "lane"),
456
        provider: text(value, "provider"),
457
        price_msats: count(value, "price_msats"),
458
        expires_at: text(value, "expires_at"),
459
    })
460
}
461
462
/// Read a closeout receipt.
463
///
464
/// Absent fields become empty strings rather than an error: the gate already
465
/// has a named refusal for each of them, and a receipt missing its
466
/// verification refs should be refused as unverified work, not as a bad file.
467
pub fn decode_closeout(value: &Value, path: &str) -> Result<LaborCloseoutReceipt, String> {
468
    if !value.is_object() {
469
        return Err(format!("The closeout at {path} is not a JSON object."));
470
    }
471
    Ok(LaborCloseoutReceipt {
472
        receipt_ref: text(value, "receiptRef"),
473
        request_id: text(value, "requestId"),
474
        requester_pubkey: text(value, "requesterPubkey"),
475
        provider_pubkey: text(value, "providerPubkey"),
476
        quoted_amount_msats: count(value, "quotedAmountMsats"),
477
        verification_command_ref: text(value, "verificationCommandRef"),
478
        test_ref: text(value, "testRef"),
479
        platform_closeout_ref: text(value, "platformCloseoutRef"),
480
        digest: text(value, "digest"),
481
        settled_at: text(value, "settled_at"),
482
    })
483
}
484
485
/// Read a JSON file, or say which one could not be read.
486
pub fn read_json_file(path: &str, label: &str) -> Result<Value, String> {
487
    let text = std::fs::read_to_string(path)
488
        .map_err(|_| format!("The {label} file at {path} could not be read as JSON."))?;
489
    serde_json::from_str(&text)
490
        .map_err(|_| format!("The {label} file at {path} could not be read as JSON."))
491
}
492
493
#[cfg(test)]
494
mod tests {
495
    use super::*;
496
497
    fn lease() -> ProviderLease {
498
        ProviderLease {
499
            job_id: "job-1".into(),
500
            lane: "coding".into(),
501
            provider: "provider-key".into(),
502
            price_msats: 1_200.0,
503
            expires_at: "2026-08-26T12:00:00Z".into(),
504
        }
505
    }
506
507
    fn clean_closeout() -> LaborCloseoutReceipt {
508
        LaborCloseoutReceipt {
509
            receipt_ref: "lbr-closeout:job-1:".to_string() + &"a".repeat(64),
510
            request_id: "job-1".into(),
511
            requester_pubkey: "buyer-key".into(),
512
            provider_pubkey: "provider-key".into(),
513
            quoted_amount_msats: 1_200.0,
514
            verification_command_ref: "cmd:mix test".into(),
515
            test_ref: "evidence:run-9".into(),
516
            platform_closeout_ref: "platform:closeout-3".into(),
517
            digest: "a".repeat(64),
518
            settled_at: "2026-08-26T11:00:00Z".into(),
519
        }
520
    }
521
522
    #[test]
523
    fn a_clean_receipt_settles_for_the_quoted_amount() {
524
        let decision = settle_lease(&lease(), Some(&clean_closeout()));
525
        assert_eq!(decision.state, "settled");
526
        assert_eq!(decision.earned_msats, 1_200.0);
527
        assert!(decision.refusal.is_none());
528
        let json = decision.to_json();
529
        assert_eq!(json["payout_rail"], "not_connected");
530
        assert_eq!(json["custody"], "none");
531
        assert_eq!(json["earned_msats"], 1_200);
532
    }
533
534
    #[test]
535
    fn a_lease_without_a_receipt_earns_nothing() {
536
        let decision = settle_lease(&lease(), None);
537
        assert_eq!(decision.state, "unsettled");
538
        assert_eq!(decision.earned_msats, 0.0);
539
        assert_eq!(decision.refusal, Some(SettlementRefusal::NoCloseout));
540
    }
541
542
    #[test]
543
    fn each_gate_names_the_first_thing_wrong() {
544
        // The gates are ordered, so a receipt broken in two ways reports the
545
        // earlier break. Each case below breaks exactly one.
546
        type Break = Box<dyn Fn(&mut LaborCloseoutReceipt)>;
547
        let cases: Vec<(SettlementRefusal, Break)> = vec![
548
            (
549
                SettlementRefusal::CloseoutJobMismatch,
550
                Box::new(|c: &mut LaborCloseoutReceipt| c.request_id = "job-2".into()),
551
            ),
552
            (
553
                SettlementRefusal::CloseoutProviderMismatch,
554
                Box::new(|c: &mut LaborCloseoutReceipt| c.provider_pubkey = "someone".into()),
555
            ),
556
            (
557
                SettlementRefusal::WorkNotVerified,
558
                Box::new(|c: &mut LaborCloseoutReceipt| c.test_ref = String::new()),
559
            ),
560
            (
561
                SettlementRefusal::NoSettlementAuthority,
562
                Box::new(|c: &mut LaborCloseoutReceipt| c.platform_closeout_ref = String::new()),
563
            ),
564
            (
565
                SettlementRefusal::ReceiptNotAddressable,
566
                Box::new(|c: &mut LaborCloseoutReceipt| c.digest = "short".into()),
567
            ),
568
            (
569
                SettlementRefusal::LeaseExpired,
570
                Box::new(|c: &mut LaborCloseoutReceipt| {
571
                    c.settled_at = "2026-08-26T13:00:00Z".into()
572
                }),
573
            ),
574
            (
575
                SettlementRefusal::PriceMismatch,
576
                Box::new(|c: &mut LaborCloseoutReceipt| c.quoted_amount_msats = 900.0),
577
            ),
578
        ];
579
        for (expected, break_it) in cases {
580
            let mut closeout = clean_closeout();
581
            break_it(&mut closeout);
582
            let decision = settle_lease(&lease(), Some(&closeout));
583
            assert_eq!(
584
                decision.refusal,
585
                Some(expected),
586
                "expected {}",
587
                expected.as_str()
588
            );
589
            assert_eq!(decision.earned_msats, 0.0);
590
        }
591
    }
592
593
    #[test]
594
    fn a_self_dealt_receipt_earns_nothing() {
595
        let mut closeout = clean_closeout();
596
        closeout.requester_pubkey = closeout.provider_pubkey.clone();
597
        let decision = settle_lease(&lease(), Some(&closeout));
598
        assert_eq!(decision.refusal, Some(SettlementRefusal::SelfDealt));
599
    }
600
601
    #[test]
602
    fn a_lease_priced_at_zero_has_nothing_to_settle() {
603
        let mut lease = lease();
604
        lease.price_msats = 0.0;
605
        let decision = settle_lease(&lease, Some(&clean_closeout()));
606
        assert_eq!(decision.refusal, Some(SettlementRefusal::PriceNotPayable));
607
        assert!(decision.reason.contains("0 msats"));
608
    }
609
610
    #[test]
611
    fn a_lease_missing_a_required_field_is_named() {
612
        let value = serde_json::json!({ "job_id": "job-1", "lane": "coding" });
613
        let error = decode_lease(&value, "/tmp/lease.json").unwrap_err();
614
        assert_eq!(
615
            error,
616
            "The lease at /tmp/lease.json is missing provider, expires_at."
617
        );
618
    }
619
620
    #[test]
621
    fn timestamps_order_the_way_date_parse_does() {
622
        assert!(
623
            parse_time("2026-08-26T11:00:00Z").unwrap()
624
                < parse_time("2026-08-26T12:00:00Z").unwrap()
625
        );
626
        // An offset moves the instant, so 13:00+02:00 is before 12:00Z.
627
        assert!(
628
            parse_time("2026-08-26T13:00:00+02:00").unwrap()
629
                < parse_time("2026-08-26T12:00:00Z").unwrap()
630
        );
631
        assert_eq!(
632
            parse_time("2026-08-26T11:00:00.500Z").unwrap()
633
                - parse_time("2026-08-26T11:00:00Z").unwrap(),
634
            500
635
        );
636
        assert!(parse_time("not a time").is_none());
637
        assert!(parse_time("").is_none());
638
    }
639
}
crates/openagents-cli/src/tracker.rs modified +35 -7

@@ -313,6 +313,26 @@ impl TrackerClient {

313 313
        body: Option<Value>,
314 314
        accepted: &[u16],
315 315
    ) -> Result<Value, ApiError> {
316
        self.request_with_status(operation, method, path, body, accepted)
317
            .await
318
            .map(|(_, value)| value)
319
    }
320
321
    /// The same request, with the accepted status the server actually chose.
322
    ///
323
    /// Needed where two accepted statuses mean different things: a fleet
324
    /// promotion answers `202` for a new target and `200` for one an
325
    /// idempotency key already named, and "did I just deploy, or had I
326
    /// already?" cannot be read off the body — the server returns the target
327
    /// either way.
328
    pub async fn request_with_status(
329
        &self,
330
        operation: &str,
331
        method: &str,
332
        path: &str,
333
        body: Option<Value>,
334
        accepted: &[u16],
335
    ) -> Result<(u16, Value), ApiError> {
316 336
        let url = format!("{}/{}", self.api_base, path.trim_start_matches('/'));
317 337
        let mut builder = match method {
318 338
            "GET" => self.http.get(&url),

@@ -332,32 +352,40 @@ impl TrackerClient {

332 352
            builder = builder.json(&payload);
333 353
        }
334 354
335
        let response = builder.send().await.map_err(|e| ApiError::Transport {
336
            operation: operation.to_string(),
337
            why: e.to_string(),
355
        crate::diag::request(method, &url);
356
        let response = builder.send().await.map_err(|e| {
357
            crate::diag::transport(&url, &e.to_string());
358
            ApiError::Transport {
359
                operation: operation.to_string(),
360
                why: e.to_string(),
361
            }
338 362
        })?;
339 363
        let status = response.status().as_u16();
364
        crate::diag::response(status, &url);
340 365
        let text = response.text().await.map_err(|e| ApiError::Transport {
341 366
            operation: operation.to_string(),
342 367
            why: e.to_string(),
343 368
        })?;
344 369
345 370
        if !accepted.contains(&status) {
371
            let message = error_sentence(&text, status);
372
            crate::diag::refused(status, &message);
346 373
            return Err(ApiError::Refused {
347 374
                operation: operation.to_string(),
348 375
                status,
349
                message: error_sentence(&text, status),
376
                message,
350 377
            });
351 378
        }
352 379
        if text.trim().is_empty() {
353 380
            // A 204 carries no body, and that is the server's answer, not a
354 381
            // stand-in for one.
355
            return Ok(Value::Null);
382
            return Ok((status, Value::Null));
356 383
        }
357
        serde_json::from_str(&text).map_err(|e| ApiError::Malformed {
384
        let value = serde_json::from_str(&text).map_err(|e| ApiError::Malformed {
358 385
            operation: operation.to_string(),
359 386
            why: e.to_string(),
360
        })
387
        })?;
388
        Ok((status, value))
361 389
    }
362 390
363 391
    // ---------------------------------------------------------------- issues
crates/openagents-cli/src/tui.rs modified +99

@@ -20,8 +20,25 @@ use ratatui::{

20 20
    widgets::{Block, Borders, Paragraph},
21 21
    Frame,
22 22
};
23
use ratatui::buffer::Buffer;
23 24
use unicode_width::UnicodeWidthStr;
24 25
26
/// Reset every foreground and background in an area to the terminal's own.
27
///
28
/// What `--no-color` does. It runs over the finished buffer, so it covers
29
/// every widget this module draws — including any added after it was written —
30
/// rather than depending on each colour site to remember the flag.
31
pub fn drain_color(buffer: &mut Buffer, area: Rect) {
32
    for y in area.top()..area.bottom() {
33
        for x in area.left()..area.right() {
34
            if let Some(cell) = buffer.cell_mut(Position::new(x, y)) {
35
                cell.set_fg(Color::Reset);
36
                cell.set_bg(Color::Reset);
37
            }
38
        }
39
    }
40
}
41
25 42
/// Columns reserved for the bullet before a turn's first line.
26 43
pub const GUTTER: usize = 4;
27 44

@@ -159,6 +176,14 @@ impl BoxFrame {

159 176
        render_transcript(f, chunks[1], view);
160 177
        render_composer(f, chunks[2], view);
161 178
        render_status(f, chunks[3], view);
179
180
        // `--no-color` is applied here rather than at each of the twenty-odd
181
        // places that name a colour, so a colour added later cannot escape it.
182
        // Bold and the other modifiers stay: they are structure, not colour,
183
        // and a terminal that renders no colour still renders them.
184
        if !crate::diag::color() {
185
            drain_color(f.buffer_mut(), area);
186
        }
162 187
    }
163 188
164 189
    fn render_header(&self, f: &mut Frame, area: Rect) {

@@ -462,4 +487,78 @@ mod tests {

462 487
    fn wrap_keeps_hard_newlines() {
463 488
        assert_eq!(wrap("a\nb", 10), vec!["a".to_string(), "b".to_string()]);
464 489
    }
490
491
    /// Draw the whole chrome into a buffer and report every foreground colour
492
    /// that is not the terminal's own.
493
    fn foregrounds(colour: bool) -> std::collections::BTreeSet<String> {
494
        use ratatui::backend::TestBackend;
495
        use ratatui::Terminal;
496
497
        // The flag is process-wide, so it is set for the length of the draw
498
        // and put back. These two cases never run at the same time because
499
        // both live in this one test.
500
        crate::diag::set_color(colour);
501
        let entries = vec![
502
            Entry::new(Role::You, "a question"),
503
            Entry::new(Role::Assistant, "an answer"),
504
            Entry::new(Role::Error, "a failure"),
505
        ];
506
        let rows = ["typing"];
507
        let view = ChromeView {
508
            title: "openagents coder",
509
            entries: &entries,
510
            composer_rows: &rows,
511
            composer_cursor: (0, 6),
512
            model: Some("ox-alpha"),
513
            busy: false,
514
            pulse: true,
515
            scrollback: 0,
516
        };
517
        let mut terminal = Terminal::new(TestBackend::new(60, 24)).expect("a test terminal");
518
        terminal
519
            .draw(|frame| BoxFrame::new("openagents coder").render(frame, frame.area(), &view))
520
            .expect("draw the chrome");
521
        let buffer = terminal.backend().buffer().clone();
522
        // Put the flag back before anything else reads it.
523
        crate::diag::set_color(true);
524
525
        let mut seen = std::collections::BTreeSet::new();
526
        for y in 0..buffer.area.height {
527
            for x in 0..buffer.area.width {
528
                if let Some(cell) = buffer.cell(Position::new(x, y)) {
529
                    if cell.fg != Color::Reset {
530
                        seen.insert(format!("{:?}", cell.fg));
531
                    }
532
                    if cell.bg != Color::Reset {
533
                        seen.insert(format!("{:?}", cell.bg));
534
                    }
535
                }
536
            }
537
        }
538
        seen
539
    }
540
541
    /// `--no-color` has to leave no colour in the drawn frame.
542
    ///
543
    /// Asserted against the same frame drawn both ways, so a test that could
544
    /// pass by the chrome having been colourless all along cannot: the
545
    /// coloured draw is required to carry several distinct colours.
546
    #[test]
547
    fn no_color_leaves_no_colour_in_the_frame() {
548
        let coloured = foregrounds(true);
549
        assert!(
550
            coloured.len() >= 3,
551
            "the coloured frame should carry several colours, it carried {coloured:?}"
552
        );
553
        assert!(
554
            coloured.contains("Cyan"),
555
            "the coloured frame lost its cyan: {coloured:?}"
556
        );
557
558
        let drained = foregrounds(false);
559
        assert!(
560
            drained.is_empty(),
561
            "--no-color left colours in the frame: {drained:?}"
562
        );
563
    }
465 564
}
crates/openagents-cli/tests/flags.rs added +684

@@ -0,0 +1,684 @@

1
//! What the global flags do, asserted by running the binary.
2
//!
3
//! The point of these tests is that a flag is *read*, not that it parses.
4
//! `--json` and `--verbose` used to parse on every command and change nothing,
5
//! and asserting "the flag is accepted" would have passed against exactly that
6
//! binary. So each test here runs `oa` twice — once with the flag and once
7
//! without — against a stub server it controls, and asserts the output is
8
//! different in the way the flag promises.
9
10
use std::io::{BufRead, BufReader, Read, Write};
11
use std::net::{TcpListener, TcpStream};
12
use std::process::Command;
13
use std::sync::mpsc;
14
use std::thread;
15
16
/// A server that answers one canned body and reports the paths it was asked
17
/// for.
18
///
19
/// It exists so `--api-url` can be proven — the request has to arrive
20
/// somewhere this test owns — and so `--json` and `--verbose` can be asserted
21
/// against a body that does not change under it.
22
struct StubServer {
23
    port: u16,
24
    hits: mpsc::Receiver<String>,
25
}
26
27
impl StubServer {
28
    fn start(body: &'static str) -> Self {
29
        Self::start_with_status(200, "OK", body)
30
    }
31
32
    /// The same, with the status the server answers.
33
    ///
34
    /// A fleet promotion means different things at `202` and `200`, so a test
35
    /// for that distinction has to be able to choose which one it gets.
36
    fn start_with_status(code: u16, reason: &'static str, body: &'static str) -> Self {
37
        let listener = TcpListener::bind("127.0.0.1:0").expect("bind a port");
38
        let port = listener.local_addr().expect("read the port").port();
39
        let (tx, hits) = mpsc::channel();
40
        thread::spawn(move || {
41
            for stream in listener.incoming() {
42
                let Ok(stream) = stream else { break };
43
                let tx = tx.clone();
44
                thread::spawn(move || serve_one(stream, code, reason, body, tx));
45
            }
46
        });
47
        Self { port, hits }
48
    }
49
50
    fn origin(&self) -> String {
51
        format!("http://127.0.0.1:{}", self.port)
52
    }
53
54
    /// Every path asked for so far.
55
    fn paths(&self) -> Vec<String> {
56
        self.hits.try_iter().collect()
57
    }
58
}
59
60
fn serve_one(
61
    mut stream: TcpStream,
62
    code: u16,
63
    reason: &str,
64
    body: &str,
65
    hits: mpsc::Sender<String>,
66
) {
67
    let mut reader = BufReader::new(stream.try_clone().expect("clone the stream"));
68
    let mut request_line = String::new();
69
    if reader.read_line(&mut request_line).is_err() {
70
        return;
71
    }
72
    let path = request_line
73
        .split_whitespace()
74
        .nth(1)
75
        .unwrap_or("")
76
        .to_string();
77
    let mut length = 0usize;
78
    loop {
79
        let mut header = String::new();
80
        if reader.read_line(&mut header).unwrap_or(0) == 0 {
81
            break;
82
        }
83
        if header.trim().is_empty() {
84
            break;
85
        }
86
        if let Some(value) = header.to_lowercase().strip_prefix("content-length:") {
87
            length = value.trim().parse().unwrap_or(0);
88
        }
89
    }
90
    if length > 0 {
91
        let mut discard = vec![0u8; length];
92
        let _ = reader.read_exact(&mut discard);
93
    }
94
    let _ = hits.send(path);
95
    let response = format!(
96
        "HTTP/1.1 {code} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
97
        body.len(),
98
        body
99
    );
100
    let _ = stream.write_all(response.as_bytes());
101
    let _ = stream.flush();
102
}
103
104
struct Output {
105
    stdout: String,
106
    stderr: String,
107
    status: Option<i32>,
108
}
109
110
fn oa(args: &[&str]) -> Output {
111
    let result = Command::new(env!("CARGO_BIN_EXE_oa"))
112
        .args(args)
113
        // The credential store is keyed by origin, and the stub's origin has
114
        // no token, so these runs never carry a real one.
115
        .env("NO_COLOR", "")
116
        .output()
117
        .expect("run oa");
118
    Output {
119
        stdout: String::from_utf8_lossy(&result.stdout).into_owned(),
120
        stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
121
        status: result.status.code(),
122
    }
123
}
124
125
const ISSUES_BODY: &str = r#"{"issues":[{"number":7,"title":"a stub issue","state":"open","user":{"login":"someone"},"labels":[],"assignees":[]}],"total_count":1}"#;
126
127
/// `--json` has to change the output, not merely be accepted.
128
///
129
/// The assertion is deliberately three-part: the JSON parses, it carries a
130
/// field the human output also shows, and the two outputs are not the same
131
/// text. A binary that accepted `--json` and printed the table would pass the
132
/// first two only by accident and fails the third outright.
133
#[test]
134
fn json_changes_the_output_and_parses() {
135
    let server = StubServer::start(ISSUES_BODY);
136
    let origin = server.origin();
137
    let human = oa(&[
138
        "--api-url",
139
        &origin,
140
        "issue",
141
        "list",
142
        "--repo",
143
        "owner/repo",
144
    ]);
145
    let json = oa(&[
146
        "--api-url",
147
        &origin,
148
        "issue",
149
        "list",
150
        "--repo",
151
        "owner/repo",
152
        "--json",
153
    ]);
154
155
    assert!(
156
        human.stdout.contains("a stub issue"),
157
        "the human output should name the issue, got: {}",
158
        human.stdout
159
    );
160
    let parsed: serde_json::Value = serde_json::from_str(&json.stdout).unwrap_or_else(|error| {
161
        panic!(
162
            "--json did not emit parseable JSON ({error}): {}",
163
            json.stdout
164
        )
165
    });
166
    assert_eq!(parsed["issues"][0]["number"], 7);
167
    assert_eq!(parsed["issues"][0]["title"], "a stub issue");
168
    assert_ne!(
169
        human.stdout.trim(),
170
        json.stdout.trim(),
171
        "--json produced the same text as the human mode, so it changed nothing"
172
    );
173
}
174
175
/// The same for a second command family, because `--json` was declared once,
176
/// globally, and read by none of them.
177
#[test]
178
fn json_changes_the_output_for_the_forum_too() {
179
    let server = StubServer::start(
180
        r#"{"boards":[{"id":"b1","slug":"general","title":"General","description":"","topic_count":3}]}"#,
181
    );
182
    let origin = server.origin();
183
    let human = oa(&["--api-url", &origin, "forum", "boards"]);
184
    let json = oa(&["--api-url", &origin, "forum", "boards", "--json"]);
185
186
    assert!(human.stdout.contains("general"), "got: {}", human.stdout);
187
    let parsed: serde_json::Value = serde_json::from_str(&json.stdout)
188
        .unwrap_or_else(|error| panic!("not JSON ({error}): {}", json.stdout));
189
    assert_eq!(parsed["boards"][0]["slug"], "general");
190
    assert_eq!(parsed["boards"][0]["topic_count"], 3);
191
    assert_ne!(human.stdout.trim(), json.stdout.trim());
192
}
193
194
/// `--api-url` has to send the request somewhere else.
195
///
196
/// Asserted by the request arriving at a server this test owns. Before this,
197
/// every client hardcoded the production origin at its construction site, so
198
/// the flag parsed and the request still went to production.
199
#[test]
200
fn api_url_sends_the_request_to_the_named_origin() {
201
    let server = StubServer::start(ISSUES_BODY);
202
    let origin = server.origin();
203
    let run = oa(&[
204
        "--api-url",
205
        &origin,
206
        "issue",
207
        "list",
208
        "--repo",
209
        "owner/repo",
210
    ]);
211
    assert_eq!(run.status, Some(0), "stderr: {}", run.stderr);
212
    let paths = server.paths();
213
    assert!(
214
        paths
215
            .iter()
216
            .any(|p| p.starts_with("/api/v1/repos/owner/repo/issues")),
217
        "the request did not reach the named origin; it asked for {paths:?}"
218
    );
219
}
220
221
/// Every command family, not just the two that were already threaded.
222
#[test]
223
fn api_url_reaches_each_command_family() {
224
    for (args, expected) in [
225
        (
226
            vec!["issue", "list", "--repo", "owner/repo"],
227
            "/api/v1/repos/owner/repo/issues",
228
        ),
229
        (vec!["forum", "boards"], "/api/v1/forum"),
230
        (vec!["memory", "list"], "/api/v1/memories"),
231
        (vec!["deploy", "list"], "/api/v1/admin/forge/targets"),
232
    ] {
233
        let server = StubServer::start(r#"{"issues":[],"boards":[],"memories":[],"targets":[]}"#);
234
        let origin = server.origin();
235
        let mut full = vec!["--api-url", origin.as_str()];
236
        full.extend(args.iter().copied());
237
        let _ = oa(&full);
238
        let paths = server.paths();
239
        assert!(
240
            paths.iter().any(|p| p.starts_with(expected)),
241
            "{args:?} did not reach {expected}; it asked for {paths:?}"
242
        );
243
    }
244
}
245
246
/// A profile that does not exist is refused rather than silently defaulted to
247
/// production.
248
#[test]
249
fn an_unknown_profile_is_refused() {
250
    let run = oa(&["--profile", "moon", "repo", "list"]);
251
    assert_eq!(run.status, Some(2));
252
    assert!(
253
        run.stderr.contains("unknown profile moon"),
254
        "stderr: {}",
255
        run.stderr
256
    );
257
}
258
259
/// `--verbose` has to print the request URL, the status, and the server's own
260
/// message, and a run without it must print none of them.
261
#[test]
262
fn verbose_prints_the_request_url_and_status() {
263
    let server = StubServer::start(ISSUES_BODY);
264
    let origin = server.origin();
265
    let quiet = oa(&[
266
        "--api-url",
267
        &origin,
268
        "issue",
269
        "list",
270
        "--repo",
271
        "owner/repo",
272
    ]);
273
    let loud = oa(&[
274
        "--api-url",
275
        &origin,
276
        "-v",
277
        "issue",
278
        "list",
279
        "--repo",
280
        "owner/repo",
281
    ]);
282
283
    assert!(
284
        loud.stderr
285
            .contains(&format!("> GET {origin}/api/v1/repos/owner/repo/issues")),
286
        "-v did not print the request URL; stderr was: {}",
287
        loud.stderr
288
    );
289
    assert!(
290
        loud.stderr.contains("< 200"),
291
        "-v did not print the status; stderr was: {}",
292
        loud.stderr
293
    );
294
    assert!(
295
        !quiet.stderr.contains("> GET"),
296
        "the request trace appeared without -v: {}",
297
        quiet.stderr
298
    );
299
    // The bodies must be identical: `-v` adds diagnostics on stderr and
300
    // changes nothing a script parses on stdout.
301
    assert_eq!(quiet.stdout, loud.stdout);
302
}
303
304
/// A refusal under `-v` carries the server's own message.
305
#[test]
306
fn verbose_prints_the_servers_refusal() {
307
    // Nothing is listening on this port, so the request cannot complete and
308
    // the transport diagnostic is what `-v` has to show.
309
    let run = oa(&[
310
        "--api-url",
311
        "http://127.0.0.1:1",
312
        "-v",
313
        "issue",
314
        "list",
315
        "--repo",
316
        "owner/repo",
317
    ]);
318
    assert_eq!(run.status, Some(2));
319
    assert!(
320
        run.stderr
321
            .contains("http://127.0.0.1:1/api/v1/repos/owner/repo/issues"),
322
        "stderr: {}",
323
        run.stderr
324
    );
325
    assert!(
326
        run.stderr.contains("did not complete"),
327
        "stderr: {}",
328
        run.stderr
329
    );
330
}
331
332
/// `--completions` writes a real script that names this binary's real
333
/// subcommands, including the three that did not exist before.
334
#[test]
335
fn completions_name_the_real_subcommands() {
336
    for shell in ["bash", "zsh", "fish", "sh"] {
337
        let run = oa(&["--completions", shell]);
338
        assert_eq!(run.status, Some(0), "{shell}: {}", run.stderr);
339
        assert!(
340
            run.stdout.len() > 500,
341
            "{shell}: the script was {} bytes",
342
            run.stdout.len()
343
        );
344
        for subcommand in ["delegate", "deploy", "provider", "coder", "issue"] {
345
            assert!(
346
                run.stdout.contains(subcommand),
347
                "{shell}: the completion script does not name `{subcommand}`"
348
            );
349
        }
350
    }
351
}
352
353
/// The three commands the Rust CLI did not have. Each has to reach its own
354
/// help rather than the parser's "unrecognized subcommand".
355
#[test]
356
fn the_three_missing_commands_exist() {
357
    for (command, marker) in [
358
        ("delegate", "--child-config"),
359
        ("deploy", "promote"),
360
        ("provider", "settle"),
361
    ] {
362
        let run = oa(&[command, "--help"]);
363
        assert_eq!(run.status, Some(0), "{command}: {}", run.stderr);
364
        assert!(
365
            run.stdout.contains(marker),
366
            "`oa {command} --help` does not mention {marker}: {}",
367
            run.stdout
368
        );
369
    }
370
}
371
372
/// `oa provider settle` decides, and the decision is the whole output.
373
///
374
/// Run against files this test writes, so the decision is reproducible and the
375
/// same input can be handed to the TypeScript CLI for comparison.
376
#[test]
377
fn provider_settle_decides_from_the_files_it_is_given() {
378
    let directory = tempfile::tempdir().expect("a temporary directory");
379
    let lease = directory.path().join("lease.json");
380
    std::fs::write(
381
        &lease,
382
        r#"{"job_id":"job-1","lane":"coding","provider":"pk","price_msats":1200,"expires_at":"2026-08-26T12:00:00Z"}"#,
383
    )
384
    .expect("write the lease");
385
386
    let unverified = oa(&["provider", "settle", "--lease", lease.to_str().unwrap()]);
387
    assert_eq!(unverified.status, Some(0), "{}", unverified.stderr);
388
    assert!(unverified.stdout.contains("Outcome: unsettled"));
389
    assert!(unverified.stdout.contains("Refused: no_closeout"));
390
    assert!(unverified.stdout.contains("Earned: 0 msats"));
391
392
    let closeout = directory.path().join("closeout.json");
393
    let digest = "a".repeat(64);
394
    std::fs::write(
395
        &closeout,
396
        format!(
397
            r#"{{"receiptRef":"lbr-closeout:job-1:{digest}","requestId":"job-1","requesterPubkey":"buyer","providerPubkey":"pk","quotedAmountMsats":1200,"verificationCommandRef":"cmd:mix test","testRef":"evidence:run-9","platformCloseoutRef":"platform:closeout-3","digest":"{digest}","settled_at":"2026-08-26T11:00:00Z"}}"#
398
        ),
399
    )
400
    .expect("write the closeout");
401
402
    let settled = oa(&[
403
        "provider",
404
        "settle",
405
        "--lease",
406
        lease.to_str().unwrap(),
407
        "--closeout",
408
        closeout.to_str().unwrap(),
409
        "--json",
410
    ]);
411
    let parsed: serde_json::Value = serde_json::from_str(&settled.stdout)
412
        .unwrap_or_else(|error| panic!("not JSON ({error}): {}", settled.stdout));
413
    assert_eq!(parsed["state"], "settled");
414
    assert_eq!(parsed["earned_msats"], 1200);
415
    // The two constants that say this decision moved nothing.
416
    assert_eq!(parsed["payout_rail"], "not_connected");
417
    assert_eq!(parsed["custody"], "none");
418
}
419
420
/// A lease this command cannot read is a refusal, not a decision.
421
#[test]
422
fn provider_settle_refuses_a_lease_it_cannot_read() {
423
    let run = oa(&["provider", "settle", "--lease", "/nonexistent/lease.json"]);
424
    assert_eq!(run.status, Some(2));
425
    assert!(
426
        run.stderr.contains("could not be read as JSON"),
427
        "stderr: {}",
428
        run.stderr
429
    );
430
}
431
432
/// `oa coder --export` writes the transcript on the line-oriented path too.
433
///
434
/// It was read only by the full-screen session, so a piped or headless run
435
/// that asked for a transcript got none and was told nothing. This does not
436
/// call the model: with no prompt the command explains itself and exits, which
437
/// is enough to prove the flag reaches a branch that could write.
438
#[test]
439
fn coder_plain_without_a_prompt_explains_itself() {
440
    let run = oa(&["coder", "--plain"]);
441
    assert_eq!(run.status, Some(0));
442
    assert!(
443
        run.stderr.contains("needs a terminal"),
444
        "stderr: {}",
445
        run.stderr
446
    );
447
    assert!(
448
        !run.stdout.contains('\u{1b}'),
449
        "the plain path emitted a cursor-control sequence"
450
    );
451
}
452
453
/// `oa` with no subcommand is a usage error, not a silent success.
454
#[test]
455
fn a_bare_invocation_is_a_usage_error() {
456
    let run = oa(&[]);
457
    assert_eq!(run.status, Some(2));
458
}
459
460
/// A delegated child configured for a lane that cannot honour the flag ends
461
/// the command instead of running the fan-out without it.
462
#[test]
463
fn delegate_refuses_a_child_flag_the_lane_cannot_honour() {
464
    let run = oa(&["delegate", "--agents", "1", "--child-ask", "anything"]);
465
    assert_eq!(run.status, Some(2));
466
    assert!(
467
        run.stderr.contains("--child-ask cannot be honoured"),
468
        "stderr: {}",
469
        run.stderr
470
    );
471
}
472
473
/// `--dir` names where children work, and a path that is not a directory is a
474
/// refusal rather than a fan-out that ran somewhere else.
475
#[test]
476
fn delegate_refuses_a_dir_that_is_not_a_directory() {
477
    let run = oa(&[
478
        "delegate",
479
        "--agents",
480
        "1",
481
        "--dir",
482
        "/nonexistent/place",
483
        "--lane",
484
        "claude",
485
        "anything",
486
    ]);
487
    assert_eq!(run.status, Some(2));
488
    assert!(
489
        run.stderr.contains("is not a directory"),
490
        "stderr: {}",
491
        run.stderr
492
    );
493
}
494
495
/// `deploy promote` refuses a SHA that is not one full commit SHA, before it
496
/// sends anything.
497
#[test]
498
fn deploy_promote_refuses_a_short_sha() {
499
    let server = StubServer::start("{}");
500
    let origin = server.origin();
501
    let run = oa(&[
502
        "--api-url",
503
        &origin,
504
        "deploy",
505
        "promote",
506
        "--repo",
507
        "openagents.com",
508
        "--sha",
509
        "abc1234",
510
        "--environment",
511
        "production",
512
    ]);
513
    assert_eq!(run.status, Some(2));
514
    assert!(
515
        run.stderr.contains("full 40-character commit SHA"),
516
        "stderr: {}",
517
        run.stderr
518
    );
519
    assert!(
520
        server.paths().is_empty(),
521
        "a refused promotion still sent a request: {:?}",
522
        server.paths()
523
    );
524
}
525
526
/// `deploy promote` refuses an unstated environment. Production promotion
527
/// never assumes one.
528
#[test]
529
fn deploy_promote_refuses_an_unstated_environment() {
530
    let server = StubServer::start("{}");
531
    let origin = server.origin();
532
    let run = oa(&[
533
        "--api-url",
534
        &origin,
535
        "deploy",
536
        "promote",
537
        "--repo",
538
        "openagents.com",
539
        "--sha",
540
        &"a".repeat(40),
541
    ]);
542
    assert_eq!(run.status, Some(2));
543
    assert!(
544
        run.stderr.contains("--environment production"),
545
        "stderr: {}",
546
        run.stderr
547
    );
548
    assert!(server.paths().is_empty());
549
}
550
551
/// `deploy list` reads the server's own target list, and `--json` hands back
552
/// the body a script can parse.
553
#[test]
554
fn deploy_list_reports_the_servers_targets() {
555
    let body: &'static str = r#"{"targets":[{"id":"tgt-9","status":"live","sha":"cccccccccccccccccccccccccccccccccccccccc","promoted_at":"2026-08-26T00:00:00Z","repo":"openagents.com","environment":"production"}]}"#;
556
    let server = StubServer::start(body);
557
    let origin = server.origin();
558
    let human = oa(&["--api-url", &origin, "deploy", "list"]);
559
    assert_eq!(human.status, Some(0), "{}", human.stderr);
560
    assert!(human.stdout.contains("tgt-9"), "{}", human.stdout);
561
    assert!(human.stdout.contains("live"), "{}", human.stdout);
562
563
    let server = StubServer::start(body);
564
    let origin = server.origin();
565
    let json = oa(&["--api-url", &origin, "deploy", "list", "--json"]);
566
    let parsed: serde_json::Value = serde_json::from_str(&json.stdout)
567
        .unwrap_or_else(|error| panic!("not JSON ({error}): {}", json.stdout));
568
    assert_eq!(parsed["targets"][0]["id"], "tgt-9");
569
    assert_ne!(human.stdout.trim(), json.stdout.trim());
570
}
571
572
/// `--limit` outside the server's range is refused before a request is sent.
573
#[test]
574
fn deploy_list_refuses_a_limit_outside_the_range() {
575
    let server = StubServer::start("{}");
576
    let origin = server.origin();
577
    let run = oa(&["--api-url", &origin, "deploy", "list", "--limit", "99"]);
578
    assert_eq!(run.status, Some(2));
579
    assert!(run.stderr.contains("between 1 and 50"), "{}", run.stderr);
580
    assert!(server.paths().is_empty());
581
}
582
583
/// A promotion the server accepts is reported as accepted; one the same
584
/// idempotency key already named is reported as a replay.
585
///
586
/// The difference is the answer to "did I just deploy, or had I already?" and
587
/// it lives only in the status: the server returns the target either way, so a
588
/// client that read the body would report every replay as a fresh deployment.
589
#[test]
590
fn deploy_promote_tells_a_new_target_from_a_replay() {
591
    const TARGET: &str = r#"{"id":"tgt-1","status":"queued","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","repo":"openagents.com","environment":"production"}"#;
592
    let sha = "a".repeat(40);
593
594
    let fresh = StubServer::start_with_status(202, "Accepted", TARGET);
595
    let origin = fresh.origin();
596
    let accepted = oa(&[
597
        "--api-url",
598
        &origin,
599
        "deploy",
600
        "promote",
601
        "--repo",
602
        "openagents.com",
603
        "--sha",
604
        &sha,
605
        "--environment",
606
        "production",
607
        "--json",
608
    ]);
609
    let parsed: serde_json::Value = serde_json::from_str(&accepted.stdout)
610
        .unwrap_or_else(|error| panic!("not JSON ({error}): {}", accepted.stdout));
611
    assert_eq!(parsed["accepted"], true);
612
    assert_eq!(parsed["replayed"], false);
613
    assert_eq!(parsed["schema"], "openagents.fleet_promotion.v1");
614
    assert_eq!(parsed["outcome"], "accepted");
615
    assert_eq!(parsed["live"], false);
616
617
    let replay = StubServer::start_with_status(200, "OK", TARGET);
618
    let origin = replay.origin();
619
    let replayed = oa(&[
620
        "--api-url",
621
        &origin,
622
        "deploy",
623
        "promote",
624
        "--repo",
625
        "openagents.com",
626
        "--sha",
627
        &sha,
628
        "--environment",
629
        "production",
630
        "--json",
631
    ]);
632
    let parsed: serde_json::Value = serde_json::from_str(&replayed.stdout)
633
        .unwrap_or_else(|error| panic!("not JSON ({error}): {}", replayed.stdout));
634
    assert_eq!(parsed["accepted"], false);
635
    assert_eq!(parsed["replayed"], true);
636
637
    // The human mode says which one it was, not just that something happened.
638
    let replay = StubServer::start_with_status(200, "OK", TARGET);
639
    let origin = replay.origin();
640
    let human = oa(&[
641
        "--api-url",
642
        &origin,
643
        "deploy",
644
        "promote",
645
        "--repo",
646
        "openagents.com",
647
        "--sha",
648
        &sha,
649
        "--environment",
650
        "production",
651
    ]);
652
    assert!(
653
        human.stdout.contains("already named this promotion"),
654
        "the replay was reported as a fresh promotion: {}",
655
        human.stdout
656
    );
657
}
658
659
/// A promotion carries an idempotency key the caller did not have to invent,
660
/// and never prints it.
661
#[test]
662
fn deploy_promote_generates_an_idempotency_key_and_does_not_print_it() {
663
    let server =
664
        StubServer::start_with_status(202, "Accepted", r#"{"id":"tgt-1","status":"queued"}"#);
665
    let origin = server.origin();
666
    let run = oa(&[
667
        "--api-url",
668
        &origin,
669
        "deploy",
670
        "promote",
671
        "--repo",
672
        "openagents.com",
673
        "--sha",
674
        &"a".repeat(40),
675
        "--environment",
676
        "production",
677
    ]);
678
    assert_eq!(run.status, Some(0), "{}", run.stderr);
679
    assert!(
680
        !run.stdout.contains("idempotency"),
681
        "the key reached the output: {}",
682
        run.stdout
683
    );
684
}

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