Upload traces instead of refusing: the ingest route exists

830538b341d3 · AtlantisPleb · · parent a3e53b2acc1a

Upload traces instead of refusing: the ingest route exists

`openagents trace upload` refused with exit 16 and a sentence saying
openagents.com had no trace ingest route. That sentence is out of date. `POST
/api/v1/traces` landed, takes an ATIF v1 document as the whole request body,
stores it against the calling account behind a `chat:account` bearer token, and
answers 201 for a document it did not hold or 200 for one it already has under
the same digest. The Rust CLI had no upload at all, not even the refusal.

Both CLIs now send it. The retired refusal takes exit code 16 with it: the code
is not reassigned, so a script still checking for it stops seeing it rather than
starting to see it mean something else.

Three places this could report success without doing the work, and what each
does instead:

  - A 200 is "Already stored: the server holds this trace under the same
    digest", not "Uploaded". The status is the only thing that tells the two
    apart, so it is kept rather than discarded by the request helper.
  - An accepted status carrying no id or digest is an error. A 201 with an empty
    body is the server saying yes and saying nothing; reporting a stored trace
    there is how a caller comes to believe in one that nothing can be found by.
  - No link is printed. The response carries a `url` pointing at
    `GET /api/v1/traces/:id`, and that route does not exist -- printing it would
    hand the reader a 404 dressed as a receipt. The id and the digest are real
    and are what get reported. A Rust test asserts the client never carries that
    url through.

The old `--public` / `--unlisted` flags are replaced by `--visibility`, because
neither name was ever the server's vocabulary: the column is the forge
transparency ladder, `dark | pulse | ledger | glass`, enforced by a CHECK
constraint, defaulting to `dark` -- nothing public. A name outside the set is
refused here with the set, rather than sent on to earn a 422 that does not say
what the choices were. `--assignment` binds the trajectory to a forge attempt.

Everything checkable against the file is checked before anything leaves the
machine: the size against the route's 10 MiB ceiling, that the file is JSON,
that it is an object, and that it names a schema_version at all. Which schema
versions are acceptable stays the server's call.

Verified by deletion: treating a 200 as created fails 1 Rust and 1 TypeScript
test; dropping the id check and carrying the url through fails 2 more Rust tests
by name.

Also tidies four hunks rustfmt disagreed with in the milestone code from the
previous commit. `cli.rs` is not rustfmt-clean repo-wide (45 diffs at HEAD, 42
now), so it was not reformatted wholesale.

Refs #82. Does NOT close it: `coder-foreign-resume.ts` is still unported, and it
is blocked on something larger than a file port -- see the report.

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

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified crates/openagents-cli/src/cli.rs
  • modified crates/openagents-cli/src/lib.rs
  • added crates/openagents-cli/src/trace_client.rs
  • added crates/openagents-cli/tests/trace_upload_test.rs
  • modified packages/openagents-cli/src/errors.ts
  • modified packages/openagents-cli/src/main.ts
  • modified packages/openagents-cli/src/runtime.ts
  • added packages/openagents-cli/src/trace-client.ts
  • modified packages/openagents-cli/src/trace-command.ts
  • modified packages/openagents-cli/test/trace-command.test.ts

Diff

10 files changed, +989 -72

crates/openagents-cli/src/cli.rs modified +125 -22

@@ -1005,6 +1005,22 @@ pub enum TraceAction {

1005 1005
        #[arg(long, help = "Deprecated alias for the positional trace argument")]
1006 1006
        file: Option<String>,
1007 1007
    },
1008
    /// Upload one ATIF document to openagents.com
1009
    ///
1010
    /// Stored at `dark` — nothing public — unless --visibility names a higher
1011
    /// rung. Redact the trace first: what is uploaded is the file as it stands.
1012
    Upload {
1013
        #[arg(help = "A trace file path, or a file name inside ~/.openagents/exports")]
1014
        trace: String,
1015
        #[arg(
1016
            long,
1017
            default_value = crate::trace_client::DEFAULT_TRACE_VISIBILITY,
1018
            help = "Transparency rung: dark (nothing public), pulse (metadata only), ledger (content and metadata), or glass (full access)"
1019
        )]
1020
        visibility: String,
1021
        #[arg(long, help = "Bind the trace to the forge attempt with this id")]
1022
        assignment: Option<String>,
1023
    },
1008 1024
}
1009 1025
1010 1026
/// The completion script for one shell.

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

1188 1204
        Commands::Memory(mem) => run_memory(mem.action, &api_base, token, cli.json).await,
1189 1205
        Commands::Api(api) => crate::api_passthrough::run(api, &endpoint, cli.json).await,
1190 1206
        Commands::Plugin(plugin) => crate::plugins::run(plugin, cli.json).await,
1191
        Commands::Trace(trace) => run_trace(trace.action),
1207
        Commands::Trace(trace) => run_trace(trace.action, &api_base, token, cli.json).await,
1192 1208
        Commands::Update(update) => {
1193 1209
            crate::update::run(update.channel, update.version, update.check, update.force).await?;
1194 1210
        }

@@ -2327,17 +2343,17 @@ async fn run_issue(action: IssueAction, api_base: &str, token: Option<String>, j

2327 2343
                (None, true) => None,
2328 2344
            };
2329 2345
            let target = target_or_fail(repo);
2330
            let value = or_fail(tracker.set_issue_milestone(&target, number, milestone).await);
2346
            let value = or_fail(
2347
                tracker
2348
                    .set_issue_milestone(&target, number, milestone)
2349
                    .await,
2350
            );
2331 2351
            // Report what came BACK, not what was asked for. A server that
2332 2352
            // accepted the request and stored something else is the case a
2333 2353
            // printed echo of the argument would hide.
2334
            let stored = value.get("milestone").and_then(|m| {
2335
                if m.is_null() {
2336
                    None
2337
                } else {
2338
                    Some(m)
2339
                }
2340
            });
2354
            let stored = value
2355
                .get("milestone")
2356
                .filter(|milestone| !milestone.is_null());
2341 2357
            let human = match stored {
2342 2358
                Some(m) => format!(
2343 2359
                    "Issue #{} is on milestone #{} {}",

@@ -2382,12 +2398,7 @@ fn milestone_listing(value: &serde_json::Value) -> Vec<String> {

2382 2398
/// `create` and `delete` existed on the client and were wired to nothing, so a
2383 2399
/// milestone could only be opened or removed in a browser -- which made
2384 2400
/// milestones useless to agents, and agents file most of the issues here.
2385
async fn run_milestone(
2386
    action: MilestoneAction,
2387
    api_base: &str,
2388
    token: Option<String>,
2389
    json: bool,
2390
) {
2401
async fn run_milestone(action: MilestoneAction, api_base: &str, token: Option<String>, json: bool) {
2391 2402
    let tracker = crate::tracker::TrackerClient::new(api_base, token);
2392 2403
    match action {
2393 2404
        MilestoneAction::List { repo } => {

@@ -2404,12 +2415,7 @@ async fn run_milestone(

2404 2415
            let target = target_or_fail(repo);
2405 2416
            let value = or_fail(
2406 2417
                tracker
2407
                    .create_milestone(
2408
                        &target,
2409
                        &title,
2410
                        description.as_deref(),
2411
                        due_on.as_deref(),
2412
                    )
2418
                    .create_milestone(&target, &title, description.as_deref(), due_on.as_deref())
2413 2419
                    .await,
2414 2420
            );
2415 2421
            // The server assigns the number. Printing the one it returned is

@@ -3327,7 +3333,7 @@ fn run_identity(action: IdentityAction, json: bool) {

3327 3333
// trace
3328 3334
// ---------------------------------------------------------------------------
3329 3335
3330
fn run_trace(action: TraceAction) {
3336
async fn run_trace(action: TraceAction, api_base: &str, token: Option<String>, json: bool) {
3331 3337
    use crate::trace;
3332 3338
    let home = home_directory();
3333 3339

@@ -3510,6 +3516,103 @@ fn run_trace(action: TraceAction) {

3510 3516
                );
3511 3517
            }
3512 3518
        }
3519
        TraceAction::Upload {
3520
            trace: argument,
3521
            visibility,
3522
            assignment,
3523
        } => {
3524
            // Everything checkable against the file is checked before anything
3525
            // leaves this machine, so a refusal names the file rather than
3526
            // arriving as a status the caller then has to interpret.
3527
            let visibility = or_fail(crate::trace_client::read_visibility(&visibility));
3528
            let path = trace::resolve_trace_argument(&argument, &home)
3529
                .unwrap_or_else(|message| fail(&message));
3530
3531
            let size = std::fs::metadata(&path)
3532
                .map(|meta| meta.len())
3533
                .unwrap_or_else(|error| {
3534
                    fail(&format!(
3535
                        "The trace file at {} could not be read: {}",
3536
                        path.display(),
3537
                        error
3538
                    ))
3539
                });
3540
            if size > crate::trace_client::MAXIMUM_TRACE_BYTES {
3541
                fail(&format!(
3542
                    "{} is {} bytes; the ingest route accepts at most {}. Upload a redacted or trimmed copy instead.",
3543
                    path.display(),
3544
                    size,
3545
                    crate::trace_client::MAXIMUM_TRACE_BYTES
3546
                ));
3547
            }
3548
3549
            let text = std::fs::read_to_string(&path).unwrap_or_else(|error| {
3550
                fail(&format!(
3551
                    "The trace file at {} could not be read: {}",
3552
                    path.display(),
3553
                    error
3554
                ))
3555
            });
3556
            let document: serde_json::Value = serde_json::from_str(&text).unwrap_or_else(|_| {
3557
                fail(&format!(
3558
                    "{} is not JSON. The ingest route takes one ATIF document; a line-delimited session log has to be converted first.",
3559
                    path.display()
3560
                ))
3561
            });
3562
            if !document.is_object() {
3563
                fail(&format!(
3564
                    "{} is JSON but not an object, so it is not an ATIF document.",
3565
                    path.display()
3566
                ));
3567
            }
3568
            // The server decides which schema versions it accepts. This only
3569
            // catches a file that names none at all, which it can say something
3570
            // more useful about the file than a 422 can.
3571
            if document.get("schema_version").and_then(|v| v.as_str()).is_none() {
3572
                fail(&format!(
3573
                    "{} carries no schema_version, so it is not an ATIF document. `oa trace show {}` reports what it is.",
3574
                    path.display(),
3575
                    argument
3576
                ));
3577
            }
3578
3579
            let client = crate::trace_client::TraceClient::new(api_base, token);
3580
            let stored = or_fail(
3581
                client
3582
                    .upload(&document, visibility, assignment.as_deref())
3583
                    .await,
3584
            );
3585
3586
            let value = serde_json::json!({
3587
                "schema": "openagents.trace_upload.v1",
3588
                "input": path,
3589
                "id": stored.id,
3590
                "digest": stored.digest,
3591
                "byte_size": stored.byte_size,
3592
                "visibility": stored.visibility,
3593
                "inserted_at": stored.inserted_at,
3594
                "created": stored.created,
3595
            });
3596
            let human = vec![
3597
                // A 200 means the server already held this digest. Calling that
3598
                // an upload would report a write that did not happen.
3599
                if stored.created {
3600
                    format!("Uploaded {}", path.display())
3601
                } else {
3602
                    "Already stored: the server holds this trace under the same digest.".to_string()
3603
                },
3604
                format!("Trace: {}", stored.id),
3605
                format!("Digest: {}", stored.digest),
3606
                format!(
3607
                    "Stored: {} bytes at visibility {}",
3608
                    stored.byte_size, stored.visibility
3609
                ),
3610
                // No link. The response carries a url pointing at
3611
                // GET /api/v1/traces/:id, and that route does not exist, so
3612
                // printing it would hand the reader a 404 dressed as a receipt.
3613
            ];
3614
            emit(json, &value, &human);
3615
        }
3513 3616
    }
3514 3617
}
3515 3618
crates/openagents-cli/src/lib.rs modified +1

@@ -32,6 +32,7 @@ pub mod runtime;

32 32
pub mod signals;
33 33
pub mod tools;
34 34
pub mod trace;
35
pub mod trace_client;
35 36
pub mod tracker;
36 37
pub mod tui;
37 38
pub mod update;
crates/openagents-cli/src/trace_client.rs added +174

@@ -0,0 +1,174 @@

1
//! The trace ingest client: `POST /api/v1/traces`.
2
//!
3
//! The Rust half of what `packages/openagents-cli/src/trace-client.ts` does. The
4
//! route takes an ATIF v1 document as the whole request body, stores it against the
5
//! calling account, and answers 201 for a document it did not hold or 200 for one it
6
//! already has under the same digest.
7
//!
8
//! Three things this client will not do:
9
//!
10
//! - It does not invent a visibility. The server's vocabulary is the forge
11
//!   transparency ladder, and `dark` — nothing public — is the default there and
12
//!   here. A caller who wants more has to say which rung.
13
//! - It does not call an existing trace a new one. The status is the only thing that
14
//!   tells them apart, so it is kept rather than discarded.
15
//! - It does not report a stored trace by a link. The response carries a `url`
16
//!   pointing at `GET /api/v1/traces/:id`, and that route does not exist, so
17
//!   printing it would hand someone a 404 dressed as a receipt. The id and the
18
//!   digest are real, and they are what get reported.
19
20
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
21
use serde::{Deserialize, Serialize};
22
use serde_json::Value;
23
24
use crate::tracker::{error_sentence, urlencode, ApiError};
25
26
/// The transparency ladder the server stores a trace under.
27
///
28
/// `dark` is nothing public, `pulse` is metadata only, `ledger` is content and
29
/// metadata, `glass` is full access. The database enforces this exact set with a
30
/// CHECK constraint, so a name outside it is refused here with the list rather than
31
/// sent on to earn a 422 that does not say what the choices were.
32
pub const TRACE_VISIBILITIES: [&str; 4] = ["dark", "pulse", "ledger", "glass"];
33
34
/// What the server defaults to when no visibility is named: nothing public.
35
pub const DEFAULT_TRACE_VISIBILITY: &str = "dark";
36
37
/// The largest body the ingest route accepts, in bytes.
38
pub const MAXIMUM_TRACE_BYTES: u64 = 10_485_760;
39
40
/// Read a visibility name, or refuse with the set the server actually has.
41
pub fn read_visibility(raw: &str) -> Result<&'static str, ApiError> {
42
    let trimmed = raw.trim().to_lowercase();
43
    TRACE_VISIBILITIES
44
        .iter()
45
        .copied()
46
        .find(|name| *name == trimmed)
47
        .ok_or_else(|| {
48
            ApiError::Input(format!(
49
                "--visibility must be one of {}; got \"{}\".",
50
                TRACE_VISIBILITIES.join(", "),
51
                raw
52
            ))
53
        })
54
}
55
56
/// What the server said it stored. Every field is the server's, not a guess.
57
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58
pub struct StoredTrace {
59
    pub id: String,
60
    pub digest: String,
61
    pub byte_size: u64,
62
    pub visibility: String,
63
    pub inserted_at: String,
64
    /// 201: the server did not hold this document. 200: it already did.
65
    pub created: bool,
66
}
67
68
pub struct TraceClient {
69
    pub api_base: String,
70
    pub token: Option<String>,
71
    pub http: reqwest::Client,
72
}
73
74
impl TraceClient {
75
    pub fn new(api_base: &str, token: Option<String>) -> Self {
76
        Self {
77
            api_base: api_base.trim_end_matches('/').to_string(),
78
            token,
79
            http: reqwest::Client::new(),
80
        }
81
    }
82
83
    fn headers(&self) -> HeaderMap {
84
        let mut map = HeaderMap::new();
85
        map.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
86
        map.insert(ACCEPT, HeaderValue::from_static("application/json"));
87
        if let Some(token) = &self.token {
88
            if let Ok(value) = HeaderValue::from_str(&format!("Bearer {}", token)) {
89
                map.insert(AUTHORIZATION, value);
90
            }
91
        }
92
        map
93
    }
94
95
    /// Send one ATIF document. `visibility` must already have passed
96
    /// [`read_visibility`]; `assignment_id` names the forge attempt the trajectory
97
    /// belongs to, when there is one.
98
    pub async fn upload(
99
        &self,
100
        document: &Value,
101
        visibility: &str,
102
        assignment_id: Option<&str>,
103
    ) -> Result<StoredTrace, ApiError> {
104
        let mut url = format!(
105
            "{}/traces?visibility={}",
106
            self.api_base,
107
            urlencode(visibility)
108
        );
109
        if let Some(assignment) = assignment_id {
110
            url.push_str(&format!("&assignment_id={}", urlencode(assignment)));
111
        }
112
113
        crate::diag::request("POST", &url);
114
        let response = self
115
            .http
116
            .post(&url)
117
            .headers(self.headers())
118
            .json(document)
119
            .send()
120
            .await
121
            .map_err(|error| {
122
                crate::diag::transport(&url, &error.to_string());
123
                ApiError::Transport {
124
                    operation: "upload a trace".to_string(),
125
                    why: error.to_string(),
126
                }
127
            })?;
128
129
        let status = response.status().as_u16();
130
        crate::diag::response(status, &url);
131
        let text = response.text().await.map_err(|error| ApiError::Transport {
132
            operation: "upload a trace".to_string(),
133
            why: error.to_string(),
134
        })?;
135
136
        if status != 200 && status != 201 {
137
            let message = error_sentence(&text, status);
138
            crate::diag::refused(status, &message);
139
            return Err(ApiError::Refused {
140
                operation: "upload a trace".to_string(),
141
                status,
142
                message,
143
            });
144
        }
145
146
        let stored: Value = serde_json::from_str(&text).map_err(|error| ApiError::Malformed {
147
            operation: "upload a trace".to_string(),
148
            why: error.to_string(),
149
        })?;
150
        let field = |key: &str| stored.get(key).and_then(Value::as_str).map(String::from);
151
152
        // An accepted status with no id is not a stored trace. Reporting one anyway
153
        // is how a caller comes to believe a trace exists server-side that nothing
154
        // can ever be found by.
155
        let (Some(id), Some(digest)) = (field("id"), field("digest")) else {
156
            return Err(ApiError::Malformed {
157
                operation: "upload a trace".to_string(),
158
                why: "the server accepted the trace but named no id or digest".to_string(),
159
            });
160
        };
161
162
        Ok(StoredTrace {
163
            id,
164
            digest,
165
            byte_size: stored
166
                .get("byte_size")
167
                .and_then(Value::as_u64)
168
                .unwrap_or_default(),
169
            visibility: field("visibility").unwrap_or_else(|| visibility.to_string()),
170
            inserted_at: field("inserted_at").unwrap_or_default(),
171
            created: status == 201,
172
        })
173
    }
174
}
crates/openagents-cli/tests/trace_upload_test.rs added +279

@@ -0,0 +1,279 @@

1
//! `oa trace upload` against the ingest route.
2
//!
3
//! The command used to be absent from this CLI and to refuse with exit 16 in the
4
//! TypeScript one, on the grounds that `POST /api/v1/traces` did not exist. It does
5
//! exist. So the thing worth asserting is no longer "does it refuse" but what it
6
//! actually puts on the wire, and — more importantly — what it refuses to say when
7
//! the server's answer does not support saying it.
8
//!
9
//! These run against a stub server on localhost. Uploading a trace is a write, and a
10
//! test that proves upload works by writing to the real store is not one anyone can
11
//! run twice.
12
13
use openagents_cli::trace_client::{read_visibility, TraceClient, TRACE_VISIBILITIES};
14
use std::io::{BufRead, BufReader, Read, Write};
15
use std::net::TcpListener;
16
use std::sync::mpsc::{channel, Receiver};
17
18
#[derive(Debug, Clone)]
19
struct SeenRequest {
20
    method: String,
21
    path: String,
22
    authorization: Option<String>,
23
    body: serde_json::Value,
24
}
25
26
struct StubApi {
27
    base: String,
28
    seen: Receiver<SeenRequest>,
29
}
30
31
/// Serve one request with `status` and `body`, and report what was asked.
32
fn start_stub_api(status: u16, body: serde_json::Value) -> StubApi {
33
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
34
    let port = listener.local_addr().unwrap().port();
35
    let (sender, seen) = channel();
36
37
    std::thread::spawn(move || {
38
        let Ok((stream, _)) = listener.accept() else {
39
            return;
40
        };
41
        let mut reader = BufReader::new(stream);
42
43
        let mut request_line = String::new();
44
        if reader.read_line(&mut request_line).is_err() {
45
            return;
46
        }
47
        let mut parts = request_line.split_whitespace();
48
        let method = parts.next().unwrap_or_default().to_string();
49
        let path = parts.next().unwrap_or_default().to_string();
50
51
        let mut content_length = 0usize;
52
        let mut authorization = None;
53
        loop {
54
            let mut header = String::new();
55
            match reader.read_line(&mut header) {
56
                Ok(0) => break,
57
                Ok(_) => {}
58
                Err(_) => return,
59
            }
60
            let trimmed = header.trim_end();
61
            if trimmed.is_empty() {
62
                break;
63
            }
64
            if let Some((name, value)) = trimmed.split_once(':') {
65
                if name.eq_ignore_ascii_case("content-length") {
66
                    content_length = value.trim().parse().unwrap_or(0);
67
                }
68
                if name.eq_ignore_ascii_case("authorization") {
69
                    authorization = Some(value.trim().to_string());
70
                }
71
            }
72
        }
73
74
        let mut raw = vec![0u8; content_length];
75
        if content_length > 0 && reader.read_exact(&mut raw).is_err() {
76
            return;
77
        }
78
        let parsed = if raw.is_empty() {
79
            serde_json::Value::Null
80
        } else {
81
            serde_json::from_slice(&raw).unwrap_or(serde_json::Value::Null)
82
        };
83
        let _ = sender.send(SeenRequest {
84
            method,
85
            path,
86
            authorization,
87
            body: parsed,
88
        });
89
90
        let payload = if body.is_null() {
91
            String::new()
92
        } else {
93
            body.to_string()
94
        };
95
        let response = format!(
96
            "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{payload}",
97
            payload.len()
98
        );
99
        let stream = reader.get_mut();
100
        let _ = stream.write_all(response.as_bytes());
101
        let _ = stream.flush();
102
    });
103
104
    StubApi {
105
        base: format!("http://127.0.0.1:{port}/api/v1"),
106
        seen,
107
    }
108
}
109
110
fn seen(stub: &StubApi) -> SeenRequest {
111
    stub.seen
112
        .recv_timeout(std::time::Duration::from_secs(10))
113
        .expect("the client never sent a request")
114
}
115
116
fn atif_document() -> serde_json::Value {
117
    serde_json::json!({
118
        "schema_version": "ATIF-v1.7",
119
        "session_id": "probe",
120
        "steps": [{ "step_id": 1, "source": "user", "message": "hello" }]
121
    })
122
}
123
124
fn stored_body(visibility: &str) -> serde_json::Value {
125
    serde_json::json!({
126
        "id": "trace-1",
127
        // The route the server points at here does not exist. The client must
128
        // not carry it through to the caller as a place to look.
129
        "url": "https://openagents.com/api/v1/traces/trace-1",
130
        "digest": format!("sha256:{}", "a".repeat(64)),
131
        "byte_size": 412,
132
        "visibility": visibility,
133
        "inserted_at": "2026-08-26T03:00:00Z"
134
    })
135
}
136
137
#[test]
138
fn a_visibility_outside_the_servers_set_is_refused_with_the_set() {
139
    for name in TRACE_VISIBILITIES {
140
        assert_eq!(read_visibility(name).unwrap(), name);
141
    }
142
    // The names the old flags spoke. They were never the server's vocabulary.
143
    for wrong in ["public", "unlisted", "owner_only", ""] {
144
        let refusal = read_visibility(wrong)
145
            .expect_err(&format!("{wrong} is not a rung the server stores at"));
146
        let text = refusal.to_string();
147
        assert!(
148
            text.contains("dark, pulse, ledger, glass"),
149
            "the refusal must name the choices; got {text}"
150
        );
151
    }
152
}
153
154
#[tokio::test]
155
async fn upload_posts_the_document_itself_with_the_visibility_named() {
156
    let stub = start_stub_api(201, stored_body("dark"));
157
    let client = TraceClient::new(&stub.base, Some("oa_pat_test".to_string()));
158
159
    let stored = client
160
        .upload(&atif_document(), "dark", None)
161
        .await
162
        .expect("the stub answered 201");
163
164
    let request = seen(&stub);
165
    assert_eq!(request.method, "POST");
166
    assert_eq!(request.path, "/api/v1/traces?visibility=dark");
167
    assert_eq!(
168
        request.authorization.as_deref(),
169
        Some("Bearer oa_pat_test"),
170
        "the ingest route is account-scoped"
171
    );
172
    // The body is the document, with nothing wrapped around it.
173
    assert_eq!(request.body, atif_document());
174
175
    assert_eq!(stored.id, "trace-1");
176
    assert!(
177
        stored.created,
178
        "201 means the server did not hold it before"
179
    );
180
    assert_eq!(stored.byte_size, 412);
181
    assert_eq!(stored.visibility, "dark");
182
}
183
184
#[tokio::test]
185
async fn an_existing_digest_is_reported_as_existing_not_as_an_upload() {
186
    let stub = start_stub_api(200, stored_body("dark"));
187
    let client = TraceClient::new(&stub.base, None);
188
189
    let stored = client.upload(&atif_document(), "dark", None).await.unwrap();
190
191
    let _ = seen(&stub);
192
    // The status is the only thing that tells the two apart. Discarding it is
193
    // how a caller comes to believe a write happened that did not.
194
    assert!(
195
        !stored.created,
196
        "a 200 means the server already held this digest"
197
    );
198
}
199
200
#[tokio::test]
201
async fn the_attempt_binding_and_a_higher_rung_reach_the_route() {
202
    let stub = start_stub_api(201, stored_body("ledger"));
203
    let client = TraceClient::new(&stub.base, None);
204
205
    client
206
        .upload(&atif_document(), "ledger", Some("asg-9"))
207
        .await
208
        .unwrap();
209
210
    let request = seen(&stub);
211
    assert!(
212
        request.path.contains("visibility=ledger"),
213
        "path was {}",
214
        request.path
215
    );
216
    assert!(
217
        request.path.contains("assignment_id=asg-9"),
218
        "path was {}",
219
        request.path
220
    );
221
}
222
223
#[tokio::test]
224
async fn nothing_the_client_returns_carries_the_dead_url_the_server_sends() {
225
    let stub = start_stub_api(201, stored_body("dark"));
226
    let client = TraceClient::new(&stub.base, None);
227
228
    let stored = client.upload(&atif_document(), "dark", None).await.unwrap();
229
    let _ = seen(&stub);
230
231
    // `GET /api/v1/traces/:id` is not a route. Reporting the url the server
232
    // builds would hand the reader a 404 dressed as a receipt.
233
    let rendered = serde_json::to_string(&stored).unwrap();
234
    assert!(
235
        !rendered.contains("openagents.com/api/v1/traces/"),
236
        "the client carried the server's unreachable url through: {rendered}"
237
    );
238
}
239
240
#[tokio::test]
241
async fn an_accepted_status_that_names_nothing_stored_is_an_error() {
242
    // 201 with an empty body: the server said yes and said nothing. Reporting a
243
    // stored trace here is how a caller comes to believe in one that has no id.
244
    let stub = start_stub_api(201, serde_json::json!({}));
245
    let client = TraceClient::new(&stub.base, None);
246
247
    let refused = client.upload(&atif_document(), "dark", None).await;
248
    let _ = seen(&stub);
249
250
    let error = refused.expect_err("an id-less 201 is not a stored trace");
251
    let text = error.to_string();
252
    assert!(
253
        text.contains("no id or digest"),
254
        "the error must say what was missing; got {text}"
255
    );
256
}
257
258
#[tokio::test]
259
async fn a_refusal_is_an_error_carrying_what_the_server_said() {
260
    let stub = start_stub_api(
261
        422,
262
        serde_json::json!({
263
            "message": "Validation Failed",
264
            "errors": { "document": ["The document is not a valid ATIF v1 object."] }
265
        }),
266
    );
267
    let client = TraceClient::new(&stub.base, None);
268
269
    let refused = client.upload(&atif_document(), "dark", None).await;
270
    let _ = seen(&stub);
271
272
    let text = refused
273
        .expect_err("a 422 is not a stored trace")
274
        .to_string();
275
    assert!(
276
        text.contains("not a valid ATIF v1 object"),
277
        "the refusal must carry the server's own words; got {text}"
278
    );
279
}
packages/openagents-cli/src/errors.ts modified +5 -8

@@ -171,11 +171,6 @@ export class ComputerReconnectExhausted extends Schema.TaggedErrorClass<Computer

171 171
  { message: Schema.String },
172 172
) {}
173 173
174
export class TraceUploadUnsupported extends Schema.TaggedErrorClass<TraceUploadUnsupported>()(
175
  "OpenAgentsCli.TraceUploadUnsupported",
176
  { message: Schema.String },
177
) {}
178
179 174
/** A fleet promotion target reached `failed` or `reverted`. */
180 175
export class DeploymentFailed extends Schema.TaggedErrorClass<DeploymentFailed>()(
181 176
  "OpenAgentsCli.DeploymentFailed",

@@ -236,7 +231,6 @@ export type CliError =

236 231
  | ComputerMachineUnavailable
237 232
  | ComputerMachineMismatch
238 233
  | ComputerReconnectExhausted
239
  | TraceUploadUnsupported
240 234
  | DeploymentFailed
241 235
  | DeploymentWaitTimeout
242 236
  | DeploymentRollingReplaceRequired;

@@ -265,8 +259,11 @@ export const exitCodeFor = (error: CliError): number => {

265 259
      return 14;
266 260
    case "OpenAgentsCli.ComputerReconnectExhausted":
267 261
      return 15;
268
    case "OpenAgentsCli.TraceUploadUnsupported":
269
      return 16;
262
    // 16 was TraceUploadUnsupported, the refusal `trace upload` returned while
263
    // POST /api/v1/traces did not exist. The route exists and the command
264
    // uploads, so the code is retired rather than reassigned: a script that
265
    // still checks for 16 should stop seeing it, not start seeing it mean
266
    // something else.
270 267
    // Deployment outcomes stay apart from each other and from transport
271 268
    // failures, so release automation can tell "the fleet rejected these
272 269
    // bytes" from "the CLI stopped watching" without parsing prose.
packages/openagents-cli/src/main.ts modified -1

@@ -34,7 +34,6 @@ const cliErrorTags = new Set([

34 34
  "OpenAgentsCli.ComputerMachineUnavailable",
35 35
  "OpenAgentsCli.ComputerMachineMismatch",
36 36
  "OpenAgentsCli.ComputerReconnectExhausted",
37
  "OpenAgentsCli.TraceUploadUnsupported",
38 37
  "OpenAgentsCli.DeploymentFailed",
39 38
  "OpenAgentsCli.DeploymentWaitTimeout",
40 39
  "OpenAgentsCli.DeploymentRollingReplaceRequired",
packages/openagents-cli/src/runtime.ts modified +3

@@ -25,6 +25,7 @@ import { outputLayer } from "./output.js";

25 25
import { persistedConfigurationLayer } from "./persisted-configuration.js";
26 26
import { projectClientLayer } from "./project-client.js";
27 27
import { repositoryClientLayer } from "./repository-client.js";
28
import { traceClientLayer } from "./trace-client.js";
28 29
import { requestBodyInputLayer } from "./request-body-input.js";
29 30
import { secretInputLayer } from "./secret-input.js";
30 31
import { terminalSessionNodeLayer } from "./terminal-session.js";

@@ -40,6 +41,7 @@ const deviceLayer = deviceClientLayer.pipe(Layer.provide(transportLayer));

40 41
const issueLayer = issueClientLayer.pipe(Layer.provide(transportLayer));
41 42
const projectLayer = projectClientLayer.pipe(Layer.provide(transportLayer));
42 43
const memoryLayer = memoryClientLayer.pipe(Layer.provide(transportLayer));
44
const traceLayer = traceClientLayer.pipe(Layer.provide(transportLayer));
43 45
const boxClient = boxClientLayer.pipe(Layer.provide(transportLayer));
44 46
const computerClient = computerClientLayer.pipe(Layer.provide(transportLayer));
45 47
const credentialsLayer = credentialStoreOsLayer.pipe(Layer.provide(NodeServices.layer));

@@ -89,6 +91,7 @@ export const runtimeLayer = Layer.mergeAll(

89 91
  issueLayer,
90 92
  projectLayer,
91 93
  memoryLayer,
94
  traceLayer,
92 95
  deviceLayer,
93 96
  boxClient,
94 97
  computerClient,
packages/openagents-cli/src/trace-client.ts added +142

@@ -0,0 +1,142 @@

1
/**
2
 * The trace ingest client: `POST /api/v1/traces`.
3
 *
4
 * This is the server half `trace upload` used to refuse for. The route exists
5
 * -- it takes an ATIF v1 document as the whole request body, stores it against
6
 * the calling account, and answers 201 for a document it did not hold or 200
7
 * for one it already has under the same digest.
8
 *
9
 * Three things this client will not do:
10
 *
11
 * - It does not invent a visibility. The server's vocabulary is the forge
12
 *   transparency ladder, and `dark` -- nothing public -- is the default there
13
 *   and here. A caller who wants more has to say which rung.
14
 * - It does not call an existing trace a new one. The status is the only thing
15
 *   that distinguishes them, so it is read rather than discarded, and a 200 is
16
 *   reported as "already held" rather than as an upload that wrote something.
17
 * - It does not report a stored trace by a link. The response carries a `url`
18
 *   pointing at `GET /api/v1/traces/:id`, and that route does not exist, so
19
 *   printing it would hand someone a 404 dressed as a receipt. The id and the
20
 *   digest are real, and they are what get reported.
21
 */
22
23
import { Effect, Layer } from "effect";
24
import * as Context from "effect/Context";
25
26
import { ApiTransport } from "./api-transport.js";
27
import { API_VERSION_PATH } from "./constants.js";
28
import { ApiError, type CliError } from "./errors.js";
29
import type { AuthenticatedApi } from "./repository-client.js";
30
import { asNumber, asRecord, asText, trackerErrorDetails } from "./tracker-request.js";
31
32
/**
33
 * The transparency ladder the server stores a trace under.
34
 *
35
 * `dark` is nothing public, `pulse` is metadata only, `ledger` is content and
36
 * metadata, and `glass` is full access. The database enforces this exact set
37
 * with a CHECK constraint, so a name outside it is refused here with the list
38
 * rather than sent on to earn a 422 that does not say what the choices were.
39
 */
40
export const TRACE_VISIBILITIES = ["dark", "pulse", "ledger", "glass"] as const;
41
42
export type TraceVisibility = (typeof TRACE_VISIBILITIES)[number];
43
44
/** What the server defaults to when no visibility is named: nothing public. */
45
export const DEFAULT_TRACE_VISIBILITY: TraceVisibility = "dark";
46
47
/** The largest body the ingest route accepts, in bytes. */
48
export const MAXIMUM_TRACE_BYTES = 10_485_760;
49
50
export const isTraceVisibility = (value: string): value is TraceVisibility =>
51
  (TRACE_VISIBILITIES as ReadonlyArray<string>).includes(value);
52
53
export interface TraceUploadInput extends AuthenticatedApi {
54
  /** The ATIF document, parsed. It is sent as the whole request body. */
55
  readonly document: unknown;
56
  readonly visibility: TraceVisibility;
57
  /** The forge attempt this trajectory belongs to, when there is one. */
58
  readonly assignmentId?: string;
59
}
60
61
/** What the server said it stored. Every field is the server's, not a guess. */
62
export interface TraceUploadResult {
63
  readonly id: string;
64
  readonly digest: string;
65
  readonly byte_size: number;
66
  readonly visibility: string;
67
  readonly inserted_at: string;
68
  /** 201: the server did not hold this document. 200: it already did. */
69
  readonly created: boolean;
70
}
71
72
interface TraceClientInterface {
73
  readonly upload: (input: TraceUploadInput) => Effect.Effect<TraceUploadResult, CliError>;
74
}
75
76
export class TraceClient extends Context.Service<TraceClient, TraceClientInterface>()(
77
  "@openagentsinc/cli/TraceClient",
78
) {}
79
80
export const traceClientLayer = Layer.effect(
81
  TraceClient,
82
  Effect.gen(function* () {
83
    const transport = yield* ApiTransport;
84
85
    return TraceClient.of({
86
      upload: Effect.fn("TraceClient.upload")(function* (input: TraceUploadInput) {
87
        // Visibility and the attempt binding are query parameters; the body is
88
        // the document itself, with nothing wrapped around it.
89
        const parameters = new URLSearchParams({ visibility: input.visibility });
90
        if (input.assignmentId !== undefined) {
91
          parameters.set("assignment_id", input.assignmentId);
92
        }
93
94
        const response = yield* transport.request({
95
          origin: input.origin,
96
          token: input.token,
97
          method: "POST",
98
          path: `${API_VERSION_PATH}/traces?${parameters.toString()}`,
99
          body: input.document,
100
        });
101
102
        if (response.status !== 200 && response.status !== 201) {
103
          const details = trackerErrorDetails(response.body, response.status);
104
          return yield* new ApiError({
105
            operation: "upload a trace",
106
            status: response.status,
107
            ...(details.code === undefined ? {} : { code: details.code }),
108
            message: details.message,
109
            ...(response.requestId === undefined && details.requestId === undefined
110
              ? {}
111
              : { requestId: response.requestId ?? details.requestId }),
112
          });
113
        }
114
115
        const stored = asRecord(response.body);
116
        const id = asText(stored["id"]);
117
        const digest = asText(stored["digest"]);
118
        if (id === undefined || digest === undefined) {
119
          // An accepted status with no id is not a stored trace. Reporting one
120
          // anyway is how a caller comes to believe a trace exists server-side
121
          // that nothing can ever be found by.
122
          return yield* new ApiError({
123
            operation: "upload a trace",
124
            status: response.status,
125
            message:
126
              "The server accepted the trace but did not say what it stored: the response carries no id or digest.",
127
            ...(response.requestId === undefined ? {} : { requestId: response.requestId }),
128
          });
129
        }
130
131
        return {
132
          id,
133
          digest,
134
          byte_size: asNumber(stored["byte_size"]) ?? 0,
135
          visibility: asText(stored["visibility"]) ?? input.visibility,
136
          inserted_at: asText(stored["inserted_at"]) ?? "",
137
          created: response.status === 201,
138
        } satisfies TraceUploadResult;
139
      }),
140
    });
141
  }),
142
);
packages/openagents-cli/src/trace-command.ts modified +109 -27

@@ -1,26 +1,33 @@

1 1
/**
2 2
 * The `openagents trace` command family.
3 3
 *
4
 * This is the LOCAL half of the trace pipeline: list what session exports
5
 * exist on this machine, summarize one, and produce a redacted sibling copy.
6
 * The upload half needs a forge ingest route that does not exist yet, so
7
 * `trace upload` refuses with a typed error that names the missing route
8
 * instead of pretending.
4
 * `list`, `show`, and `redact` are the local half: what session exports exist
5
 * on this machine, what one holds, and a redacted sibling copy of it. `upload`
6
 * is the remote half, and it now sends the document to `POST /api/v1/traces`
7
 * rather than refusing -- that route exists.
9 8
 *
10 9
 * The family is defined through a factory taking the root command, so the
11 10
 * registration hunk in `cli.ts` stays a single import and a single list entry.
12 11
 */
13 12
14
import { existsSync } from "node:fs";
13
import { existsSync, readFileSync, statSync } from "node:fs";
15 14
import { homedir } from "node:os";
16 15
import { isAbsolute, join, resolve } from "node:path";
17 16
18
import { Effect } from "effect";
17
import { Effect, Option } from "effect";
19 18
import { Argument, Command, Flag } from "effect/unstable/cli";
20 19
21
import { InputError, TraceUploadUnsupported } from "./errors.js";
22
import { API_VERSION_PATH } from "./constants.js";
20
import { type EndpointOverrides, type Profile } from "./endpoint.js";
21
import { InputError } from "./errors.js";
23 22
import { Output, type OutputMode } from "./output.js";
23
import { resolveApiSession } from "./session.js";
24
import {
25
  DEFAULT_TRACE_VISIBILITY,
26
  MAXIMUM_TRACE_BYTES,
27
  TRACE_VISIBILITIES,
28
  TraceClient,
29
  isTraceVisibility,
30
} from "./trace-client.js";
24 31
import {
25 32
  defaultDiscoveryBounds,
26 33
  defaultTraceStores,

@@ -35,16 +42,17 @@ import {

35 42
36 43
/** The shared flags a trace handler reads back off the root command. */
37 44
interface SharedFlags {
45
  readonly profile: Option.Option<Profile>;
46
  readonly apiUrl: Option.Option<string>;
38 47
  readonly json: boolean;
39 48
}
40 49
41 50
const outputMode = (json: boolean): OutputMode => (json ? "json" : "human");
42 51
43
/** The server half `trace upload` is waiting for. One place, one sentence. */
44
export const TRACE_INGEST_ROUTE_GAP =
45
  "openagents.com has no trace ingest route yet. Upload needs the server half first: " +
46
  `POST ${API_VERSION_PATH}/traces accepting an ATIF v1.7 document with owner_only default visibility. ` +
47
  "Until that route exists, this command refuses rather than pretending to upload.";
52
const endpointOverrides = (flags: SharedFlags): EndpointOverrides => ({
53
  profile: flags.profile,
54
  apiUrl: flags.apiUrl,
55
});
48 56
49 57
const listPathFlag = Flag.string("path").pipe(
50 58
  Flag.atLeast(0),

@@ -234,29 +242,103 @@ export const makeTraceCommand = <R>(root: Effect.Effect<SharedFlags, never, R>)

234 242
    ),
235 243
  );
236 244
237
  const uploadPublicFlag = Flag.boolean("public").pipe(
238
    Flag.withDescription("Ask for public visibility instead of the owner_only default"),
245
  const uploadVisibilityFlag = Flag.string("visibility").pipe(
246
    Flag.withDefault(DEFAULT_TRACE_VISIBILITY as string),
247
    Flag.withDescription(
248
      `Transparency rung to store the trace at: ${TRACE_VISIBILITIES.join(", ")}. ` +
249
        "dark is nothing public, pulse is metadata only, ledger is content and metadata, glass is full access.",
250
    ),
239 251
  );
240
  const uploadUnlistedFlag = Flag.boolean("unlisted").pipe(
241
    Flag.withDescription("Ask for unlisted visibility instead of the owner_only default"),
252
  const uploadAssignmentFlag = Flag.string("assignment").pipe(
253
    Flag.optional,
254
    Flag.withDescription("Bind the trace to the forge attempt with this id"),
242 255
  );
243 256
244 257
  const traceUploadCommand = Command.make(
245 258
    "upload",
246
    { trace: traceArgument, public: uploadPublicFlag, unlisted: uploadUnlistedFlag },
247
    ({ public: isPublic, trace, unlisted }) =>
259
    {
260
      trace: traceArgument,
261
      visibility: uploadVisibilityFlag,
262
      assignment: uploadAssignmentFlag,
263
    },
264
    ({ assignment, trace, visibility }) =>
248 265
      Effect.gen(function* () {
249
        if (isPublic && unlisted) {
250
          return yield* new InputError({ message: "Use either --public or --unlisted, not both." });
266
        if (!isTraceVisibility(visibility)) {
267
          return yield* new InputError({
268
            message: `--visibility must be one of ${TRACE_VISIBILITIES.join(", ")}; got ${visibility}.`,
269
          });
270
        }
271
        const flags = yield* root;
272
        const output = yield* Output;
273
        const path = yield* resolveTraceArgument(trace);
274
275
        // Everything that can be checked against the file is checked before
276
        // anything leaves this machine, so a refusal names the file rather than
277
        // arriving as a status from a server the caller then has to interpret.
278
        const size = yield* Effect.try({
279
          try: () => statSync(path).size,
280
          catch: () => new InputError({ message: `The trace file at ${path} could not be read.` }),
281
        });
282
        if (size > MAXIMUM_TRACE_BYTES) {
283
          return yield* new InputError({
284
            message: `${path} is ${size} bytes; the ingest route accepts at most ${MAXIMUM_TRACE_BYTES}. Upload a redacted or trimmed copy instead.`,
285
          });
286
        }
287
288
        const document = yield* Effect.try({
289
          try: () => JSON.parse(readFileSync(path, "utf8")) as unknown,
290
          catch: () =>
291
            new InputError({
292
              message: `${path} is not JSON. The ingest route takes one ATIF document; a line-delimited session log has to be converted first.`,
293
            }),
294
        });
295
        if (document === null || typeof document !== "object" || Array.isArray(document)) {
296
          return yield* new InputError({
297
            message: `${path} is JSON but not an object, so it is not an ATIF document.`,
298
          });
251 299
        }
252
        // Validate the local half first so the refusal is about the real gap,
253
        // not about a path typo the reader would rather hear about now.
254
        yield* resolveTraceArgument(trace);
255
        return yield* new TraceUploadUnsupported({ message: TRACE_INGEST_ROUTE_GAP });
300
        // The server decides which schema versions it accepts. This only
301
        // catches a file that names none at all, which it can say more usefully
302
        // about the file than a 422 can.
303
        if (typeof (document as Record<string, unknown>)["schema_version"] !== "string") {
304
          return yield* new InputError({
305
            message: `${path} carries no schema_version, so it is not an ATIF document. openagents trace show ${path} reports what it is.`,
306
          });
307
        }
308
309
        const session = yield* resolveApiSession(endpointOverrides(flags));
310
        const traces = yield* TraceClient;
311
        const stored = yield* traces.upload({
312
          origin: session.endpoint.origin,
313
          token: session.token,
314
          document,
315
          visibility,
316
          ...(Option.isNone(assignment) ? {} : { assignmentId: assignment.value }),
317
        });
318
319
        yield* output.write(
320
          {
321
            value: { schema: "openagents.trace_upload.v1", input: path, ...stored },
322
            human: [
323
              // A 200 means the server already held this digest. Calling that an
324
              // upload would report a write that did not happen.
325
              stored.created
326
                ? `Uploaded ${path}`
327
                : `Already stored: the server holds this trace under the same digest.`,
328
              `Trace: ${stored.id}`,
329
              `Digest: ${stored.digest}`,
330
              `Stored: ${stored.byte_size} bytes at visibility ${stored.visibility}`,
331
              // No link. The response carries a url pointing at
332
              // GET /api/v1/traces/:id, and that route does not exist, so
333
              // printing it would hand the reader a 404 dressed as a receipt.
334
            ],
335
          },
336
          outputMode(flags.json),
337
        );
256 338
      }),
257 339
  ).pipe(
258 340
    Command.withDescription(
259
      "Upload a redacted trace to openagents.com with owner_only visibility by default. The forge ingest route does not exist yet, so this refuses and names the missing server half.",
341
      `Upload one ATIF document to openagents.com. Stored at ${DEFAULT_TRACE_VISIBILITY} -- nothing public -- unless --visibility names a higher rung. Redact the trace first: what is uploaded is the file as it stands.`,
260 342
    ),
261 343
  );
262 344
packages/openagents-cli/test/trace-command.test.ts modified +151 -14

@@ -6,7 +6,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices";

6 6
import { Effect, Layer } from "effect";
7 7
import { describe, expect, it } from "vitest";
8 8
9
import { apiTransportTestLayer, type ApiRequest, type ApiResponse } from "../src/api-transport.js";
9 10
import { runCliWith } from "../src/cli.js";
11
import { traceClientLayer } from "../src/trace-client.js";
10 12
import { credentialStoreUnavailableLayer } from "../src/credential-store.js";
11 13
import { environmentLayerFromValues } from "../src/environment.js";
12 14
import { outputTestLayer, type OutputDocument, type OutputMode } from "../src/output.js";

@@ -18,14 +20,26 @@ interface Written {

18 20
  readonly mode: OutputMode;
19 21
}
20 22
21
const harness = () => {
23
const harness = (
24
  handler: (input: ApiRequest) => Effect.Effect<ApiResponse, never> = () =>
25
    Effect.succeed({ status: 500, body: {} }),
26
  token?: string,
27
) => {
22 28
  const written: Array<Written> = [];
29
  const requests: Array<ApiRequest> = [];
30
  const transport = apiTransportTestLayer((input) =>
31
    Effect.suspend(() => {
32
      requests.push(input);
33
      return handler(input);
34
    }),
35
  );
23 36
  const layer = Layer.mergeAll(
24 37
    NodeServices.layer,
25
    environmentLayerFromValues({}),
38
    environmentLayerFromValues(token === undefined ? {} : { token }),
26 39
    persistedConfigurationTestLayer({}),
27 40
    terminalSessionTestLayer(false),
28 41
    credentialStoreUnavailableLayer,
42
    traceClientLayer.pipe(Layer.provide(transport)),
29 43
    outputTestLayer((document, mode) =>
30 44
      Effect.sync(() => {
31 45
        written.push({ document, mode });

@@ -43,7 +57,7 @@ const harness = () => {

43 57
        unknown
44 58
      >,
45 59
    );
46
  return { run, fail, written };
60
  return { run, fail, written, requests };
47 61
};
48 62
49 63
const atifDocument = (message: string) => ({

@@ -174,24 +188,147 @@ describe("openagents trace", () => {

174 188
    expect(error).toMatchObject({ _tag: "OpenAgentsCli.InputError" });
175 189
  });
176 190
177
  it("refuses upload with a typed error naming the missing server route", async () => {
191
  it("uploads the document to the ingest route at the dark default", async () => {
178 192
    const { path } = scratchStore();
179
    const { fail } = harness();
180
    const error = await fail(["trace", "upload", path]);
181
    expect(error).toMatchObject({ _tag: "OpenAgentsCli.TraceUploadUnsupported" });
182
    expect(String((error as { message: string }).message)).toContain("POST /api/v1/traces");
193
    const { requests, run, written } = harness(
194
      () =>
195
        Effect.succeed({
196
          status: 201,
197
          body: {
198
            id: "trace-1",
199
            url: "https://openagents.com/api/v1/traces/trace-1",
200
            digest: "sha256:" + "a".repeat(64),
201
            byte_size: 412,
202
            visibility: "dark",
203
            inserted_at: "2026-08-26T03:00:00Z",
204
          },
205
        }),
206
      "test-token",
207
    );
208
209
    await run(["--profile", "local", "trace", "upload", path]);
210
211
    expect(requests[0]?.method).toBe("POST");
212
    // The visibility the server stores at is named explicitly rather than
213
    // relying on a default the CLI cannot see.
214
    expect(requests[0]?.path).toBe("/api/v1/traces?visibility=dark");
215
    // The body is the document itself, with nothing wrapped around it.
216
    expect((requests[0]?.body as Record<string, unknown>)["schema_version"]).toBe("ATIF-v1.7");
217
218
    const human = written[0]?.document.human ?? [];
219
    expect(human[0]).toContain("Uploaded");
220
    expect(human).toContain("Trace: trace-1");
221
    // No link is printed: the url the server returns points at
222
    // GET /api/v1/traces/:id, and that route does not exist.
223
    expect(human.join("\n")).not.toContain("http");
183 224
  });
184 225
185
  it("still validates the local path before refusing an upload", async () => {
186
    const { fail } = harness();
187
    const error = await fail(["trace", "upload", "no-such-trace-file-atif.json"]);
226
  it("reports a 200 as already stored rather than as an upload", async () => {
227
    const { path } = scratchStore();
228
    const { run, written } = harness(
229
      () =>
230
        Effect.succeed({
231
          status: 200,
232
          body: {
233
            id: "trace-1",
234
            digest: "sha256:" + "b".repeat(64),
235
            byte_size: 412,
236
            visibility: "dark",
237
            inserted_at: "2026-08-26T03:00:00Z",
238
          },
239
        }),
240
      "test-token",
241
    );
242
243
    await run(["--profile", "local", "trace", "upload", path]);
244
245
    // The server answers 200 for a digest it already holds. Calling that an
246
    // upload would report a write that did not happen.
247
    expect(written[0]?.document.human?.[0]).toContain("Already stored");
248
  });
249
250
  it("passes a named visibility and an attempt binding through to the route", async () => {
251
    const { path } = scratchStore();
252
    const { requests, run } = harness(
253
      () =>
254
        Effect.succeed({
255
          status: 201,
256
          body: {
257
            id: "trace-2",
258
            digest: "sha256:" + "c".repeat(64),
259
            byte_size: 1,
260
            visibility: "ledger",
261
            inserted_at: "2026-08-26T03:00:00Z",
262
          },
263
        }),
264
      "test-token",
265
    );
266
267
    await run([
268
      "--profile",
269
      "local",
270
      "trace",
271
      "upload",
272
      path,
273
      "--visibility",
274
      "ledger",
275
      "--assignment",
276
      "asg-9",
277
    ]);
278
279
    const url = new URL(requests[0]?.path ?? "", "http://localhost/");
280
    expect(url.searchParams.get("visibility")).toBe("ledger");
281
    expect(url.searchParams.get("assignment_id")).toBe("asg-9");
282
  });
283
284
  it("refuses a visibility the server does not have, and names the ones it does", async () => {
285
    const { path } = scratchStore();
286
    const { fail, requests } = harness(() => Effect.succeed({ status: 201, body: {} }), "t");
287
    const error = await fail([
288
      "--profile",
289
      "local",
290
      "trace",
291
      "upload",
292
      path,
293
      "--visibility",
294
      "public",
295
    ]);
296
188 297
    expect(error).toMatchObject({ _tag: "OpenAgentsCli.InputError" });
298
    expect(String((error as { message: string }).message)).toContain("dark, pulse, ledger, glass");
299
    // Refused before anything left this machine.
300
    expect(requests).toHaveLength(0);
189 301
  });
190 302
191
  it("refuses --public together with --unlisted", async () => {
303
  it("refuses a file that is not an ATIF document before sending it", async () => {
304
    const { root } = scratchStore();
305
    const notAtif = join(root, "notes.json");
306
    writeFileSync(notAtif, JSON.stringify({ hello: "world" }), "utf8");
307
    const { fail, requests } = harness(() => Effect.succeed({ status: 201, body: {} }), "t");
308
309
    const error = await fail(["--profile", "local", "trace", "upload", notAtif]);
310
311
    expect(error).toMatchObject({ _tag: "OpenAgentsCli.InputError" });
312
    expect(String((error as { message: string }).message)).toContain("schema_version");
313
    expect(requests).toHaveLength(0);
314
  });
315
316
  it("refuses an accepted status that names nothing stored", async () => {
192 317
    const { path } = scratchStore();
193
    const { fail } = harness();
194
    const error = await fail(["trace", "upload", path, "--public", "--unlisted"]);
318
    // 201 with an empty body: the server said yes and said nothing. Reporting a
319
    // stored trace here is how a caller comes to believe in one that has no id.
320
    const { fail } = harness(() => Effect.succeed({ status: 201, body: {} }), "test-token");
321
322
    const error = await fail(["--profile", "local", "trace", "upload", path]);
323
324
    expect(error).toMatchObject({ _tag: "OpenAgentsCli.ApiError" });
325
    expect(String((error as { message: string }).message)).toContain("did not say what it stored");
326
  });
327
328
  it("still validates the local path before reaching for the network", async () => {
329
    const { fail, requests } = harness();
330
    const error = await fail(["trace", "upload", "no-such-trace-file-atif.json"]);
195 331
    expect(error).toMatchObject({ _tag: "OpenAgentsCli.InputError" });
332
    expect(requests).toHaveLength(0);
196 333
  });
197 334
});

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