Port the tracker, Box, and memory clients to the Rust CLI

a5eaade71d30 · AtlantisPleb · · parent 5f38ca711932

Port the tracker, Box, and memory clients to the Rust CLI

Issues #76, #78, and #96. All three were the same shape: an
authenticated API client that answered every refusal with an empty
value, so a caller could not tell "the server said there is nothing"
from "the request never reached the server."

`tracker.rs` gains the routes it was missing and loses the fallbacks.
`project list` asked for `/projects`, which does not exist, and returned
`Ok(vec![])` for the 4xx it earned — so a repository with four boards
reported none, with exit status 0. The route is `projectsV2`. `issue
list` now takes `--state`, `--label`, `--assignee`, `--milestone`,
`--search`, `--blocked`, and `--limit`, and pages past the server's 25
per page; without `--state` it could not list a closed issue at all.
`reopen`, `label`, `assign`, `unassign`, `deps`, and `milestones` join
the issue commands, and `create`, `fields`, `items`, `item-add`,
`item-set`, `item-move`, and `item-remove` join the projects; `project
view` was a `println!` that made no request. `issue view` prints the
body, the labels, the assignees, the milestone, the progress, and the
prerequisites, matching `openagents issue view` byte for byte, instead
of leaking `Some("AtlantisPleb")` from Rust's `Option` debug format.
`--json` was declared and read nowhere; every command now emits the
server's own body under it.

The repository is no longer defaulted to `OpenAgentsInc/openagents`.
`-R` wins, the checkout's remote is next, and with neither the command
refuses rather than reading a repository the caller never named.

`box_client.rs` gains `view`, `stop`, `run`, `runs {list,view,output,
cancel}`, and `fanout`, and stops defaulting `--conversation` to the
literal string `main`, which is not a conversation id. Boxes are billed
cloud VMs under a two-box quota, so nothing here manufactures one: a
refused provision is a refusal, with no placeholder record and no
invented `box_id`. `runs output --follow` polls the bounded log by
offset until the run is terminal, then reads once more, because the box
writes its last bytes between the final write and the state change.
`stop` and `cancel` now send `{}` rather than no body; a bodyless POST
carrying a JSON content type is answered 411 by the edge before it
reaches the application, which the TypeScript client also hits.

`memory_client.rs` gains `delete` and `add --supersedes`, which is #96
whole: a memory written by `oa memory add` could not be removed, and a
wrong one could only be accumulated past. `list` prints the created-at
timestamp and takes `--limit` and `--include-superseded`, and the body
is positional, the way `openagents memory add` takes it.

Three assertions in `cli_test.rs` were `x.is_empty() || !x.is_empty()`,
which is true of every list. Each held *because of* the fallback it was
meant to cover, so each is replaced with one that fails against the
implementation it was hiding: that a limit above one page actually
pages, that a refused route errors rather than reporting an empty
repository, that a box read without a conversation names `--conversation`
in its refusal, and that an empty memory id is not sent as `/memories/`,
which is the list route and answers 200. `box_follow_test.rs` runs the
follow loop against a stub that writes its last window after the run
turns terminal; dropping the trailing read makes it fail.

Verified against the live API: all 19 tracker subcommands and all 12 box
subcommands run, and the memory create/supersede/delete cycle leaves the
store as it found it. Box provisioning is unverified — `/api/v1/
conversation` needs a `box:control` token, and this credential has none,
so no conversation id was reachable to scope a box to.

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/box_client.rs
  • modified crates/openagents-cli/src/cli.rs
  • modified crates/openagents-cli/src/memory_client.rs
  • modified crates/openagents-cli/src/tracker.rs
  • added crates/openagents-cli/tests/box_follow_test.rs
  • modified crates/openagents-cli/tests/cli_test.rs

Diff

6 files changed, +3652 -411

crates/openagents-cli/src/box_client.rs modified +706 -75

@@ -1,24 +1,109 @@

1
//! Box sandbox management, remote execution and parallel fanout
2
//! Real client communicating with `/api/v1/conversations/:id/boxes`
1
//! Box sandbox lifecycle, durable runs, and fanout admission.
2
//!
3
//! The Rust port of `packages/openagents-cli/src/box-client.ts`. Boxes are
4
//! conversation-owned cloud VMs with a hard provisioning quota, so this client
5
//! never manufactures one:
6
//!
7
//! - A conversation that cannot be resolved is a refusal naming `--conversation`,
8
//!   not the literal string `main`. The version this replaces defaulted to
9
//!   `main`, which is not a conversation id, and then answered the resulting
10
//!   non-2xx with an empty list — indistinguishable from "you have no boxes".
11
//! - A refused provision is a refusal. There is no placeholder box record and
12
//!   no invented `box_id`, because a caller who believes a box exists will
13
//!   spend the quota trying to reach it.
14
//! - `exec` reports the box's exit status. A transport or authorization failure
15
//!   is an error, not exit code 1 with the failure text pushed into `stderr`,
16
//!   which is what a real failing command looks like.
3 17
4
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
18
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
5 19
use serde::{Deserialize, Serialize};
20
use serde_json::{json, Value};
6 21
7
#[derive(Debug, Clone, Serialize, Deserialize)]
22
use crate::tracker::{error_sentence, urlencode, ApiError};
23
24
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8 25
pub struct BoxRecord {
9 26
    pub box_id: String,
10 27
    pub label: Option<String>,
11 28
    pub state: String,
12 29
    pub setup_status: String,
13 30
    pub created_at: String,
31
    pub updated_at: Option<String>,
32
    pub stopped_at: Option<String>,
14 33
}
15 34
16
#[derive(Debug, Clone, Serialize, Deserialize)]
35
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17 36
pub struct BoxCommandResult {
18 37
    pub box_id: String,
19
    pub exit_code: i32,
38
    pub exit_code: i64,
20 39
    pub stdout: String,
21 40
    pub stderr: String,
41
    pub timed_out: bool,
42
    pub stdout_truncated: bool,
43
    pub stderr_truncated: bool,
44
}
45
46
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47
pub struct BoxRunRecord {
48
    pub id: String,
49
    pub box_id: String,
50
    pub command: String,
51
    pub state: String,
52
    pub exit_status: Option<i64>,
53
    pub timed_out: Option<bool>,
54
    pub output_offset: Option<u64>,
55
    pub output_base_offset: Option<u64>,
56
    pub failure_reason: Option<String>,
57
    pub admitted_at: Option<String>,
58
    pub dispatched_at: Option<String>,
59
    pub started_at: Option<String>,
60
    pub finished_at: Option<String>,
61
    pub deadline_at: Option<String>,
62
    pub cancellation_requested_at: Option<String>,
63
    pub cancellation_effective_at: Option<String>,
64
}
65
66
impl BoxRunRecord {
67
    /// True once the server will produce no further output for this run.
68
    pub fn finished(&self) -> bool {
69
        matches!(
70
            self.state.as_str(),
71
            "succeeded" | "failed" | "cancelled" | "canceled" | "timed_out" | "expired"
72
        )
73
    }
74
}
75
76
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77
pub struct BoxRunOutput {
78
    pub run_id: String,
79
    pub output: String,
80
    /// The offset to pass to the next read to resume where this one stopped.
81
    pub next_offset: u64,
82
    /// True when the box dropped bytes before the requested offset.
83
    pub truncated: bool,
84
}
85
86
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
87
pub struct BoxFanoutItem {
88
    pub position: u64,
89
    pub label: String,
90
    pub state: String,
91
    pub box_id: Option<String>,
92
    pub queue_reason: Option<String>,
93
    pub estimated_burn_rate_microusd: Option<i64>,
94
    pub admitted_at: Option<String>,
95
}
96
97
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
98
pub struct BoxFanoutPlan {
99
    pub id: String,
100
    pub requested_count: u64,
101
    pub admitted: Vec<BoxFanoutItem>,
102
    pub queued: Vec<BoxFanoutItem>,
103
    pub effective_limits: Value,
104
    pub budgeted: bool,
105
    pub created_at: Option<String>,
106
    pub updated_at: Option<String>,
22 107
}
23 108
24 109
pub struct BoxClient {

@@ -27,6 +112,88 @@ pub struct BoxClient {

27 112
    pub http: reqwest::Client,
28 113
}
29 114
115
fn text(value: &Value, key: &str) -> Option<String> {
116
    value.get(key).and_then(Value::as_str).map(String::from)
117
}
118
119
fn parse_box(value: &Value) -> BoxRecord {
120
    BoxRecord {
121
        box_id: text(value, "box_id")
122
            .or_else(|| text(value, "id"))
123
            .unwrap_or_default(),
124
        label: text(value, "label"),
125
        state: text(value, "state").unwrap_or_else(|| "unknown".to_string()),
126
        setup_status: text(value, "setup_status").unwrap_or_else(|| "unknown".to_string()),
127
        created_at: text(value, "created_at").unwrap_or_default(),
128
        updated_at: text(value, "updated_at"),
129
        stopped_at: text(value, "stopped_at"),
130
    }
131
}
132
133
fn parse_run(value: &Value, fallback_box: &str, fallback_run: &str) -> BoxRunRecord {
134
    BoxRunRecord {
135
        id: text(value, "id").unwrap_or_else(|| fallback_run.to_string()),
136
        box_id: text(value, "box_id").unwrap_or_else(|| fallback_box.to_string()),
137
        command: text(value, "command").unwrap_or_default(),
138
        state: text(value, "state").unwrap_or_else(|| "unknown".to_string()),
139
        exit_status: value.get("exit_status").and_then(Value::as_i64),
140
        timed_out: value.get("timed_out").and_then(Value::as_bool),
141
        output_offset: value.get("output_offset").and_then(Value::as_u64),
142
        output_base_offset: value.get("output_base_offset").and_then(Value::as_u64),
143
        failure_reason: text(value, "failure_reason"),
144
        admitted_at: text(value, "admitted_at"),
145
        dispatched_at: text(value, "dispatched_at"),
146
        started_at: text(value, "started_at"),
147
        finished_at: text(value, "finished_at"),
148
        deadline_at: text(value, "deadline_at"),
149
        cancellation_requested_at: text(value, "cancellation_requested_at"),
150
        cancellation_effective_at: text(value, "cancellation_effective_at"),
151
    }
152
}
153
154
fn parse_fanout_item(value: &Value) -> BoxFanoutItem {
155
    BoxFanoutItem {
156
        position: value.get("position").and_then(Value::as_u64).unwrap_or(0),
157
        label: text(value, "label").unwrap_or_default(),
158
        state: text(value, "state").unwrap_or_else(|| "unknown".to_string()),
159
        box_id: text(value, "box_id"),
160
        queue_reason: text(value, "queue_reason"),
161
        estimated_burn_rate_microusd: value
162
            .get("estimated_burn_rate_microusd")
163
            .and_then(Value::as_i64),
164
        admitted_at: text(value, "admitted_at"),
165
    }
166
}
167
168
fn parse_plan(value: &Value, fallback_id: &str, fallback_count: u64) -> BoxFanoutPlan {
169
    let rows = |key: &str| {
170
        value
171
            .get(key)
172
            .and_then(Value::as_array)
173
            .map(|items| items.iter().map(parse_fanout_item).collect())
174
            .unwrap_or_default()
175
    };
176
    BoxFanoutPlan {
177
        id: text(value, "id").unwrap_or_else(|| fallback_id.to_string()),
178
        requested_count: value
179
            .get("requested_count")
180
            .and_then(Value::as_u64)
181
            .unwrap_or(fallback_count),
182
        admitted: rows("admitted"),
183
        queued: rows("queued"),
184
        effective_limits: value
185
            .get("effective_limits")
186
            .cloned()
187
            .unwrap_or(Value::Null),
188
        budgeted: value
189
            .get("budgeted")
190
            .and_then(Value::as_bool)
191
            .unwrap_or(false),
192
        created_at: text(value, "created_at"),
193
        updated_at: text(value, "updated_at"),
194
    }
195
}
196
30 197
impl BoxClient {
31 198
    pub fn new(api_base: &str, token: Option<String>) -> Self {
32 199
        Self {

@@ -39,6 +206,7 @@ impl BoxClient {

39 206
    fn headers(&self) -> HeaderMap {
40 207
        let mut map = HeaderMap::new();
41 208
        map.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
209
        map.insert(ACCEPT, HeaderValue::from_static("application/json"));
42 210
        if let Some(tok) = &self.token {
43 211
            if let Ok(val) = HeaderValue::from_str(&format!("Bearer {}", tok)) {
44 212
                map.insert(AUTHORIZATION, val);

@@ -47,84 +215,547 @@ impl BoxClient {

47 215
        map
48 216
    }
49 217
50
    pub async fn list_boxes(&self, conversation_id: &str) -> Result<Vec<BoxRecord>, Box<dyn std::error::Error + Send + Sync>> {
51
        let url = format!("{}/conversations/{}/boxes", self.api_base, conversation_id);
52
        let resp = self.http.get(&url).headers(self.headers()).send().await?;
53
54
        if resp.status().is_success() {
55
            let body: serde_json::Value = resp.json().await?;
56
            let items = body.get("boxes").and_then(|v| v.as_array()).cloned().unwrap_or_default();
57
            let mut records = Vec::new();
58
            for item in items {
59
                let box_id = item.get("box_id").or_else(|| item.get("id")).and_then(|v| v.as_str()).unwrap_or("").to_string();
60
                let label = item.get("label").and_then(|v| v.as_str()).map(String::from);
61
                let state = item.get("state").and_then(|v| v.as_str()).unwrap_or("active").to_string();
62
                let setup_status = item.get("setup_status").and_then(|v| v.as_str()).unwrap_or("ready").to_string();
63
                let created_at = item.get("created_at").and_then(|v| v.as_str()).unwrap_or("").to_string();
64
                records.push(BoxRecord {
65
                    box_id,
66
                    label,
67
                    state,
68
                    setup_status,
69
                    created_at,
70
                });
218
    async fn request(
219
        &self,
220
        operation: &str,
221
        method: &str,
222
        path: &str,
223
        body: Option<Value>,
224
        accepted: &[u16],
225
    ) -> Result<Value, ApiError> {
226
        let url = format!("{}/{}", self.api_base, path.trim_start_matches('/'));
227
        let mut builder = match method {
228
            "GET" => self.http.get(&url),
229
            "POST" => self.http.post(&url),
230
            "DELETE" => self.http.delete(&url),
231
            other => {
232
                return Err(ApiError::Input(format!(
233
                    "{} is not an HTTP method this client sends.",
234
                    other
235
                )))
71 236
            }
72
            Ok(records)
73
        } else {
74
            Ok(Vec::new())
75 237
        }
238
        .headers(self.headers());
239
        if let Some(payload) = body {
240
            builder = builder.json(&payload);
241
        }
242
243
        let response = builder.send().await.map_err(|e| ApiError::Transport {
244
            operation: operation.to_string(),
245
            why: e.to_string(),
246
        })?;
247
        let status = response.status().as_u16();
248
        let text = response.text().await.map_err(|e| ApiError::Transport {
249
            operation: operation.to_string(),
250
            why: e.to_string(),
251
        })?;
252
253
        if !accepted.contains(&status) {
254
            return Err(ApiError::Refused {
255
                operation: operation.to_string(),
256
                status,
257
                message: error_sentence(&text, status),
258
            });
259
        }
260
        if text.trim().is_empty() {
261
            return Ok(Value::Null);
262
        }
263
        serde_json::from_str(&text).map_err(|e| ApiError::Malformed {
264
            operation: operation.to_string(),
265
            why: e.to_string(),
266
        })
76 267
    }
77 268
78
    pub async fn create_box(&self, conversation_id: &str, label: Option<&str>) -> Result<Option<BoxRecord>, Box<dyn std::error::Error + Send + Sync>> {
79
        let url = format!("{}/conversations/{}/boxes", self.api_base, conversation_id);
80
        let mut payload = serde_json::json!({});
81
        if let Some(lbl) = label {
82
            payload["label"] = serde_json::json!(lbl);
269
    /// Resolves the account's conversation, or refuses naming the flag that
270
    /// unblocks the caller.
271
    ///
272
    /// `/conversation` is the route a `box:control` token can reach, and it
273
    /// creates the account's conversation when there is none. `/user` sits
274
    /// behind `forge:write`, and a deployment that predates `/conversation`
275
    /// still answers there, so both are tried before refusing. Neither
276
    /// answering is a refusal — not a default conversation id.
277
    pub async fn resolve_conversation_id(&self) -> Result<String, ApiError> {
278
        let named = self
279
            .request("resolve user conversation", "GET", "conversation", None, &[200])
280
            .await;
281
        if let Ok(body) = &named {
282
            if let Some(id) = text(body, "conversation_id") {
283
                return Ok(id);
284
            }
83 285
        }
84 286
85
        let resp = self.http.post(&url).headers(self.headers()).json(&payload).send().await?;
86
        if resp.status().is_success() {
87
            let item: serde_json::Value = resp.json().await?;
88
            let box_id = item.get("box_id").or_else(|| item.get("id")).and_then(|v| v.as_str()).unwrap_or("").to_string();
89
            let state = item.get("state").and_then(|v| v.as_str()).unwrap_or("provisioning").to_string();
90
            let setup_status = item.get("setup_status").and_then(|v| v.as_str()).unwrap_or("pending").to_string();
91
            let created_at = item.get("created_at").and_then(|v| v.as_str()).unwrap_or("").to_string();
92
            Ok(Some(BoxRecord {
93
                box_id,
94
                label: label.map(String::from),
95
                state,
96
                setup_status,
97
                created_at,
98
            }))
99
        } else {
100
            Ok(None)
287
        let user = self
288
            .request("resolve user conversation", "GET", "user", None, &[200])
289
            .await;
290
        if let Ok(body) = &user {
291
            let id = text(body, "conversation_id")
292
                .or_else(|| {
293
                    body.get("openagents")
294
                        .and_then(|v| text(v, "conversation_id"))
295
                })
296
                .or_else(|| body.get("user").and_then(|v| text(v, "conversation_id")));
297
            if let Some(id) = id {
298
                return Ok(id);
299
            }
101 300
        }
301
302
        let status = match (&named, &user) {
303
            (Err(ApiError::Refused { status, .. }), _) => *status,
304
            (_, Err(ApiError::Refused { status, .. })) => *status,
305
            _ => 200,
306
        };
307
        Err(ApiError::Refused {
308
            operation: "resolve user conversation".to_string(),
309
            status,
310
            message: "This deployment does not report a conversation for the account. \
311
                      Pass --conversation <conversation_id> to name the conversation to use."
312
                .to_string(),
313
        })
102 314
    }
103 315
104
    pub async fn execute_command(&self, conversation_id: &str, box_id: &str, command: &str) -> Result<BoxCommandResult, Box<dyn std::error::Error + Send + Sync>> {
105
        let url = format!("{}/conversations/{}/boxes/{}/exec", self.api_base, conversation_id, box_id);
106
        let resp = self.http.post(&url).headers(self.headers()).json(&serde_json::json!({
107
            "command": command
108
        })).send().await?;
109
110
        if resp.status().is_success() {
111
            let body: serde_json::Value = resp.json().await?;
112
            let exit_code = body.get("exit_code").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
113
            let stdout = body.get("stdout").and_then(|v| v.as_str()).unwrap_or("").to_string();
114
            let stderr = body.get("stderr").and_then(|v| v.as_str()).unwrap_or("").to_string();
115
            Ok(BoxCommandResult {
116
                box_id: box_id.to_string(),
117
                exit_code,
118
                stdout,
119
                stderr,
120
            })
121
        } else {
122
            Ok(BoxCommandResult {
123
                box_id: box_id.to_string(),
124
                exit_code: 1,
125
                stdout: String::new(),
126
                stderr: format!("Box execution request failed with status {}", resp.status()),
127
            })
316
    /// The conversation the caller named, or the one the account reports.
317
    pub async fn conversation_id(&self, named: Option<&str>) -> Result<String, ApiError> {
318
        match named {
319
            Some(id) if !id.trim().is_empty() => Ok(id.trim().to_string()),
320
            _ => self.resolve_conversation_id().await,
128 321
        }
129 322
    }
323
324
    fn boxes_path(conversation: &str) -> String {
325
        format!("conversations/{}/boxes", urlencode(conversation))
326
    }
327
328
    fn box_path(conversation: &str, box_id: &str) -> String {
329
        format!("{}/{}", Self::boxes_path(conversation), urlencode(box_id))
330
    }
331
332
    fn runs_path(conversation: &str, box_id: &str) -> String {
333
        format!("{}/runs", Self::box_path(conversation, box_id))
334
    }
335
336
    fn run_path(conversation: &str, box_id: &str, run_id: &str) -> String {
337
        format!(
338
            "{}/{}",
339
            Self::runs_path(conversation, box_id),
340
            urlencode(run_id)
341
        )
342
    }
343
344
    pub async fn list_boxes(&self, conversation: &str) -> Result<Vec<BoxRecord>, ApiError> {
345
        let body = self
346
            .request(
347
                "list conversation boxes",
348
                "GET",
349
                &Self::boxes_path(conversation),
350
                None,
351
                &[200],
352
            )
353
            .await?;
354
        let rows = body
355
            .get("boxes")
356
            .and_then(Value::as_array)
357
            .ok_or_else(|| ApiError::Malformed {
358
                operation: "list conversation boxes".to_string(),
359
                why: "no `boxes` array in the response".to_string(),
360
            })?;
361
        Ok(rows.iter().map(parse_box).collect())
362
    }
363
364
    pub async fn create_box(
365
        &self,
366
        conversation: &str,
367
        label: Option<&str>,
368
    ) -> Result<BoxRecord, ApiError> {
369
        let payload = match label {
370
            Some(name) => json!({ "label": name }),
371
            None => json!({}),
372
        };
373
        let body = self
374
            .request(
375
                "create box",
376
                "POST",
377
                &Self::boxes_path(conversation),
378
                Some(payload),
379
                &[201],
380
            )
381
            .await?;
382
        Ok(parse_box(body.get("box").unwrap_or(&body)))
383
    }
384
385
    pub async fn view_box(&self, conversation: &str, box_id: &str) -> Result<BoxRecord, ApiError> {
386
        let body = self
387
            .request(
388
                "view box",
389
                "GET",
390
                &Self::box_path(conversation, box_id),
391
                None,
392
                &[200],
393
            )
394
            .await?;
395
        Ok(parse_box(body.get("box").unwrap_or(&body)))
396
    }
397
398
    pub async fn execute_command(
399
        &self,
400
        conversation: &str,
401
        box_id: &str,
402
        command: &str,
403
        timeout_seconds: Option<u64>,
404
    ) -> Result<BoxCommandResult, ApiError> {
405
        let mut payload = json!({ "command": command });
406
        if let Some(seconds) = timeout_seconds {
407
            payload["timeout_seconds"] = json!(seconds);
408
        }
409
        let body = self
410
            .request(
411
                "run box command",
412
                "POST",
413
                &format!("{}/commands", Self::box_path(conversation, box_id)),
414
                Some(payload),
415
                &[200],
416
            )
417
            .await?;
418
        let result = body.get("result").unwrap_or(&body);
419
        Ok(BoxCommandResult {
420
            box_id: text(result, "box_id").unwrap_or_else(|| box_id.to_string()),
421
            // The server sends the box's exit status. A missing one is not
422
            // success, and it is not a failure of the command either, so it is
423
            // reported as -1 the way the TypeScript client reports it.
424
            exit_code: result.get("exit_code").and_then(Value::as_i64).unwrap_or(-1),
425
            stdout: text(result, "stdout").unwrap_or_default(),
426
            stderr: text(result, "stderr").unwrap_or_default(),
427
            timed_out: result
428
                .get("timed_out")
429
                .and_then(Value::as_bool)
430
                .unwrap_or(false),
431
            stdout_truncated: result
432
                .get("stdout_truncated")
433
                .and_then(Value::as_bool)
434
                .unwrap_or(false),
435
            stderr_truncated: result
436
                .get("stderr_truncated")
437
                .and_then(Value::as_bool)
438
                .unwrap_or(false),
439
        })
440
    }
441
442
    pub async fn stop_box(&self, conversation: &str, box_id: &str) -> Result<BoxRecord, ApiError> {
443
        let body = self
444
            .request(
445
                "stop box",
446
                "POST",
447
                &format!("{}/stop", Self::box_path(conversation, box_id)),
448
                // An empty object, not no body. A `POST` carrying
449
                // `Content-Type: application/json` and no `Content-Length` is
450
                // rejected by the edge with 411 before it reaches the
451
                // application, so the caller never sees the server's answer.
452
                // The TypeScript client sends no body here and earns the same
453
                // 411; observed against production on 2026-08-26.
454
                Some(json!({})),
455
                &[200],
456
            )
457
            .await?;
458
        Ok(parse_box(body.get("box").unwrap_or(&body)))
459
    }
460
461
    pub async fn start_run(
462
        &self,
463
        conversation: &str,
464
        box_id: &str,
465
        command: &str,
466
        idempotency_key: Option<&str>,
467
    ) -> Result<BoxRunRecord, ApiError> {
468
        let key = match idempotency_key {
469
            Some(value) => value.to_string(),
470
            None => fresh_idempotency_key(),
471
        };
472
        let body = self
473
            .request(
474
                "start box run",
475
                "POST",
476
                &Self::runs_path(conversation, box_id),
477
                Some(json!({ "command": command, "idempotency_key": key })),
478
                &[200, 202],
479
            )
480
            .await?;
481
        Ok(parse_run(body.get("run").unwrap_or(&body), box_id, ""))
482
    }
483
484
    pub async fn list_runs(
485
        &self,
486
        conversation: &str,
487
        box_id: &str,
488
    ) -> Result<Vec<BoxRunRecord>, ApiError> {
489
        let body = self
490
            .request(
491
                "list box runs",
492
                "GET",
493
                &Self::runs_path(conversation, box_id),
494
                None,
495
                &[200],
496
            )
497
            .await?;
498
        let rows = body
499
            .get("runs")
500
            .and_then(Value::as_array)
501
            .ok_or_else(|| ApiError::Malformed {
502
                operation: "list box runs".to_string(),
503
                why: "no `runs` array in the response".to_string(),
504
            })?;
505
        Ok(rows.iter().map(|row| parse_run(row, box_id, "")).collect())
506
    }
507
508
    pub async fn view_run(
509
        &self,
510
        conversation: &str,
511
        box_id: &str,
512
        run_id: &str,
513
    ) -> Result<BoxRunRecord, ApiError> {
514
        let body = self
515
            .request(
516
                "view box run",
517
                "GET",
518
                &Self::run_path(conversation, box_id, run_id),
519
                None,
520
                &[200],
521
            )
522
            .await?;
523
        Ok(parse_run(body.get("run").unwrap_or(&body), box_id, run_id))
524
    }
525
526
    /// Reads a window of a run's output.
527
    ///
528
    /// The server nests the read under `output`: the envelope is
529
    /// `{"run_id": …, "output": {"output": …, "next_offset": …, "truncated": …}}`.
530
    /// A deployment answering flat is read flat.
531
    pub async fn run_output(
532
        &self,
533
        conversation: &str,
534
        box_id: &str,
535
        run_id: &str,
536
        offset: Option<u64>,
537
    ) -> Result<BoxRunOutput, ApiError> {
538
        let query = match offset {
539
            Some(value) => format!("?offset={}", value),
540
            None => String::new(),
541
        };
542
        let body = self
543
            .request(
544
                "get box run output",
545
                "GET",
546
                &format!(
547
                    "{}/output{}",
548
                    Self::run_path(conversation, box_id, run_id),
549
                    query
550
                ),
551
                None,
552
                &[200],
553
            )
554
            .await?;
555
        let nested = body.get("output").cloned().unwrap_or(Value::Null);
556
        let flat = nested.as_str().map(String::from);
557
        Ok(BoxRunOutput {
558
            run_id: text(&body, "run_id").unwrap_or_else(|| run_id.to_string()),
559
            output: flat
560
                .or_else(|| text(&nested, "output"))
561
                .unwrap_or_default(),
562
            next_offset: nested
563
                .get("next_offset")
564
                .and_then(Value::as_u64)
565
                .unwrap_or(offset.unwrap_or(0)),
566
            truncated: nested
567
                .get("truncated")
568
                .and_then(Value::as_bool)
569
                .unwrap_or(false),
570
        })
571
    }
572
573
    /// Read a run's output until the run reaches a terminal state.
574
    ///
575
    /// The route publishes no event stream, so following it is a poll: read
576
    /// from the last offset, hand whatever is new to `sink`, then ask the run
577
    /// whether it has finished. One further read after the terminal state is
578
    /// what keeps the last bytes from being dropped between the final write and
579
    /// the state change.
580
    ///
581
    /// Every read that fails ends the follow. A poll that swallowed a refusal
582
    /// would hand the caller a truncated log as though it were the whole run,
583
    /// which is the same failure as printing an empty list for a refused read.
584
    ///
585
    /// Returns the terminal run record and the offset the reader stopped at.
586
    pub async fn follow_run_output<F>(
587
        &self,
588
        conversation: &str,
589
        box_id: &str,
590
        run_id: &str,
591
        offset: Option<u64>,
592
        interval: std::time::Duration,
593
        mut sink: F,
594
    ) -> Result<(BoxRunRecord, u64), ApiError>
595
    where
596
        F: FnMut(&BoxRunOutput),
597
    {
598
        let mut cursor = offset;
599
        loop {
600
            let chunk = self
601
                .run_output(conversation, box_id, run_id, cursor)
602
                .await?;
603
            sink(&chunk);
604
            let advanced = Some(chunk.next_offset) != cursor;
605
            cursor = Some(chunk.next_offset);
606
607
            let run = self.view_run(conversation, box_id, run_id).await?;
608
            if run.finished() {
609
                let tail = self
610
                    .run_output(conversation, box_id, run_id, cursor)
611
                    .await?;
612
                if !tail.output.is_empty() {
613
                    sink(&tail);
614
                }
615
                return Ok((run, tail.next_offset));
616
            }
617
            if !advanced {
618
                tokio::time::sleep(interval).await;
619
            }
620
        }
621
    }
622
623
    pub async fn cancel_run(
624
        &self,
625
        conversation: &str,
626
        box_id: &str,
627
        run_id: &str,
628
    ) -> Result<BoxRunRecord, ApiError> {
629
        let body = self
630
            .request(
631
                "cancel box run",
632
                "POST",
633
                &format!("{}/cancel", Self::run_path(conversation, box_id, run_id)),
634
                // See `stop_box`: an empty object rather than no body, so the
635
                // edge does not answer 411 in place of the server.
636
                Some(json!({})),
637
                &[200, 202],
638
            )
639
            .await?;
640
        Ok(parse_run(body.get("run").unwrap_or(&body), box_id, run_id))
641
    }
642
643
    pub async fn fanout(
644
        &self,
645
        conversation: &str,
646
        count: u64,
647
        labels: &[String],
648
        budgeted: bool,
649
    ) -> Result<BoxFanoutPlan, ApiError> {
650
        if count < 1 {
651
            return Err(ApiError::Input("--count must be at least 1.".to_string()));
652
        }
653
        let mut payload = json!({ "count": count, "budgeted": budgeted });
654
        if !labels.is_empty() {
655
            payload["labels"] = json!(labels);
656
        }
657
        let body = self
658
            .request(
659
                "request box fanout",
660
                "POST",
661
                &format!("{}/fanout", Self::boxes_path(conversation)),
662
                Some(payload),
663
                &[200, 202],
664
            )
665
            .await?;
666
        Ok(parse_plan(body.get("plan").unwrap_or(&body), "", count))
667
    }
668
669
    pub async fn view_fanout(
670
        &self,
671
        conversation: &str,
672
        request_id: &str,
673
    ) -> Result<BoxFanoutPlan, ApiError> {
674
        let body = self
675
            .request(
676
                "view box fanout",
677
                "GET",
678
                &format!(
679
                    "{}/fanout/{}",
680
                    Self::boxes_path(conversation),
681
                    urlencode(request_id)
682
                ),
683
                None,
684
                &[200],
685
            )
686
            .await?;
687
        Ok(parse_plan(body.get("plan").unwrap_or(&body), request_id, 0))
688
    }
689
}
690
691
/// A fresh idempotency key for a durable run.
692
///
693
/// The crate has no UUID dependency, so this is a v4-shaped identifier built
694
/// from the clock, the process, and a per-process counter, hashed so the parts
695
/// do not leak into the key. It names nothing about the run; it only has to be
696
/// distinct from the last one, which is what the server uses it for.
697
fn fresh_idempotency_key() -> String {
698
    use sha2::{Digest, Sha256};
699
    use std::sync::atomic::{AtomicU64, Ordering};
700
    static COUNTER: AtomicU64 = AtomicU64::new(0);
701
702
    let nanos = std::time::SystemTime::now()
703
        .duration_since(std::time::UNIX_EPOCH)
704
        .map(|d| d.as_nanos())
705
        .unwrap_or(0);
706
    let seed = format!(
707
        "{}:{}:{}",
708
        nanos,
709
        std::process::id(),
710
        COUNTER.fetch_add(1, Ordering::Relaxed)
711
    );
712
    let digest = Sha256::digest(seed.as_bytes());
713
    let hex: String = digest.iter().take(16).map(|b| format!("{:02x}", b)).collect();
714
    format!(
715
        "{}-{}-4{}-a{}-{}",
716
        &hex[0..8],
717
        &hex[8..12],
718
        &hex[13..16],
719
        &hex[17..20],
720
        &hex[20..32]
721
    )
722
}
723
724
#[cfg(test)]
725
mod tests {
726
    use super::*;
727
728
    #[test]
729
    fn idempotency_keys_do_not_repeat() {
730
        let first = fresh_idempotency_key();
731
        let second = fresh_idempotency_key();
732
        assert_ne!(first, second);
733
        assert_eq!(first.len(), 36);
734
    }
735
736
    #[test]
737
    fn a_finished_run_is_recognised_by_its_state() {
738
        let run = |state: &str| BoxRunRecord {
739
            id: "r".into(),
740
            box_id: "b".into(),
741
            command: "true".into(),
742
            state: state.into(),
743
            exit_status: None,
744
            timed_out: None,
745
            output_offset: None,
746
            output_base_offset: None,
747
            failure_reason: None,
748
            admitted_at: None,
749
            dispatched_at: None,
750
            started_at: None,
751
            finished_at: None,
752
            deadline_at: None,
753
            cancellation_requested_at: None,
754
            cancellation_effective_at: None,
755
        };
756
        assert!(run("succeeded").finished());
757
        assert!(run("failed").finished());
758
        assert!(!run("running").finished());
759
        assert!(!run("queued").finished());
760
    }
130 761
}
crates/openagents-cli/src/cli.rs modified +1572 -112

@@ -115,38 +115,127 @@ pub struct IssueArgs {

115 115
    pub action: IssueAction,
116 116
}
117 117
118
/// Every tracker command takes `-R owner/repo`. With no flag the checkout's
119
/// remote names the repository; see [`crate::tracker::resolve_repo_target`].
118 120
#[derive(Subcommand, Debug)]
119 121
pub enum IssueAction {
122
    /// List issues, paging past the server's 25 per page
120 123
    List {
121
        #[arg(short = 'R', long)]
124
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
122 125
        repo: Option<String>,
126
        #[arg(long, default_value = "open", value_parser = ["open", "closed", "all"], help = "Filter by state")]
127
        state: String,
128
        #[arg(long, help = "Filter by one label name")]
129
        label: Option<String>,
130
        #[arg(long, help = "Filter by assignee login")]
131
        assignee: Option<String>,
132
        #[arg(long, help = "Filter by milestone")]
133
        milestone: Option<String>,
134
        #[arg(long, help = "Full-text search over titles and bodies")]
135
        search: Option<String>,
136
        #[arg(long, help = "Filter to issues that are, or are not, blocked")]
137
        blocked: Option<bool>,
138
        #[arg(long, default_value_t = 30, help = "Maximum issues to read")]
139
        limit: u32,
123 140
    },
141
    /// Show one issue, with its body and prerequisite fields
124 142
    View {
125 143
        #[arg(help = "Issue number")]
126 144
        number: u64,
127
        #[arg(short = 'R', long)]
145
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
128 146
        repo: Option<String>,
147
        #[arg(long, help = "Also read the comment thread")]
148
        comments: bool,
129 149
    },
150
    /// Open an issue
130 151
    Create {
131
        #[arg(long)]
152
        #[arg(long, help = "Issue title")]
132 153
        title: String,
133
        #[arg(long)]
154
        #[arg(long, help = "Issue body")]
134 155
        body: Option<String>,
135
        #[arg(short = 'R', long)]
156
        #[arg(long, help = "Read the body from a file, or from - for standard input")]
157
        body_file: Option<String>,
158
        #[arg(long, help = "Apply a label; repeatable")]
159
        label: Vec<String>,
160
        #[arg(long, help = "Assign a login; repeatable")]
161
        assignee: Vec<String>,
162
        #[arg(long, help = "Milestone number")]
163
        milestone: Option<u64>,
164
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
136 165
        repo: Option<String>,
137 166
    },
167
    /// Close an issue
138 168
    Close {
139 169
        #[arg(help = "Issue number")]
140 170
        number: u64,
141
        #[arg(short = 'R', long)]
171
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
172
        repo: Option<String>,
173
        #[arg(long, help = "Leave this comment before closing")]
174
        comment: Option<String>,
175
    },
176
    /// Reopen a closed issue
177
    Reopen {
178
        #[arg(help = "Issue number")]
179
        number: u64,
180
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
142 181
        repo: Option<String>,
182
        #[arg(long, help = "Leave this comment before reopening")]
183
        comment: Option<String>,
143 184
    },
185
    /// Comment on an issue, or read the thread
144 186
    Comment {
145 187
        #[arg(help = "Issue number")]
146 188
        number: u64,
147
        #[arg(long)]
148
        body: String,
149
        #[arg(short = 'R', long)]
189
        #[arg(long, help = "Comment body")]
190
        body: Option<String>,
191
        #[arg(long, help = "Read the body from a file, or from - for standard input")]
192
        body_file: Option<String>,
193
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
194
        repo: Option<String>,
195
    },
196
    /// Read, apply, or remove the labels on an issue
197
    Label {
198
        #[arg(help = "Issue number")]
199
        number: u64,
200
        #[arg(long, help = "Apply a label; repeatable")]
201
        add: Vec<String>,
202
        #[arg(long, help = "Remove a label; repeatable")]
203
        remove: Vec<String>,
204
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
205
        repo: Option<String>,
206
    },
207
    /// Assign an issue to one or more logins
208
    Assign {
209
        #[arg(help = "Issue number")]
210
        number: u64,
211
        #[arg(required = true, help = "Account logins")]
212
        logins: Vec<String>,
213
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
214
        repo: Option<String>,
215
    },
216
    /// Remove one or more logins from an issue
217
    Unassign {
218
        #[arg(help = "Issue number")]
219
        number: u64,
220
        #[arg(required = true, help = "Account logins")]
221
        logins: Vec<String>,
222
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
223
        repo: Option<String>,
224
    },
225
    /// Read, add, or remove the prerequisites of an issue
226
    Deps {
227
        #[arg(help = "Issue number")]
228
        number: u64,
229
        #[arg(long, help = "Record an issue this one waits on; repeatable")]
230
        add: Vec<u64>,
231
        #[arg(long, help = "Drop a prerequisite edge; repeatable")]
232
        remove: Vec<u64>,
233
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
234
        repo: Option<String>,
235
    },
236
    /// List the milestones of a repository
237
    Milestones {
238
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
150 239
        repo: Option<String>,
151 240
    },
152 241
}

@@ -159,14 +248,83 @@ pub struct ProjectArgs {

159 248
160 249
#[derive(Subcommand, Debug)]
161 250
pub enum ProjectAction {
251
    /// List the projects of a repository
162 252
    List {
163
        #[arg(short = 'R', long)]
253
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
164 254
        repo: Option<String>,
255
        #[arg(long, help = "Include archived boards")]
256
        archived: bool,
165 257
    },
258
    /// Show one project
166 259
    View {
167 260
        #[arg(help = "Project number")]
168 261
        number: u64,
169
        #[arg(short = 'R', long)]
262
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
263
        repo: Option<String>,
264
    },
265
    /// Create a project board
266
    Create {
267
        #[arg(long, help = "Project title")]
268
        title: String,
269
        #[arg(long, help = "Markdown project description")]
270
        description: Option<String>,
271
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
272
        repo: Option<String>,
273
    },
274
    /// List the fields of a project board
275
    Fields {
276
        #[arg(help = "Project number")]
277
        number: u64,
278
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
279
        repo: Option<String>,
280
    },
281
    /// List the items on a project board
282
    Items {
283
        #[arg(help = "Project number")]
284
        number: u64,
285
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
286
        repo: Option<String>,
287
    },
288
    /// Put an issue on a project board
289
    ItemAdd {
290
        #[arg(help = "Project number")]
291
        number: u64,
292
        #[arg(long, help = "Issue number to place on the board")]
293
        issue: u64,
294
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
295
        repo: Option<String>,
296
    },
297
    /// Set stored field values on a project item
298
    ItemSet {
299
        #[arg(help = "Project number")]
300
        number: u64,
301
        #[arg(help = "Project item id")]
302
        item: String,
303
        #[arg(long = "set", required = true, help = "Set a field, as FIELD=VALUE; repeatable")]
304
        set: Vec<String>,
305
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
306
        repo: Option<String>,
307
    },
308
    /// Move a project item, by field value, rank, or both
309
    ItemMove {
310
        #[arg(help = "Project number")]
311
        number: u64,
312
        #[arg(help = "Project item id")]
313
        item: String,
314
        #[arg(long = "set", help = "Set a field, as FIELD=VALUE; repeatable")]
315
        set: Vec<String>,
316
        #[arg(long, help = "One-based rank within the destination column")]
317
        position: Option<u64>,
318
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
319
        repo: Option<String>,
320
    },
321
    /// Take an item off a project board
322
    ItemRemove {
323
        #[arg(help = "Project number")]
324
        number: u64,
325
        #[arg(help = "Project item id")]
326
        item: String,
327
        #[arg(short = 'R', long, help = "Repository as owner/repo")]
170 328
        repo: Option<String>,
171 329
    },
172 330
}

@@ -233,25 +391,122 @@ pub struct BoxArgs {

233 391
    pub action: BoxAction,
234 392
}
235 393
394
/// `--conversation` has no default.
395
///
396
/// The version this replaces defaulted it to the literal string `main`, which
397
/// is not a conversation id, so every box command asked about a conversation
398
/// that does not exist. Absent the flag, the account's conversation is
399
/// resolved from the server, and a deployment that does not report one earns a
400
/// refusal naming the flag.
236 401
#[derive(Subcommand, Debug)]
237 402
pub enum BoxAction {
403
    /// List active and recent Box VMs in a conversation
238 404
    List {
239
        #[arg(long, default_value = "main")]
240
        conversation: String,
405
        #[arg(long, help = "Conversation id override")]
406
        conversation: Option<String>,
241 407
    },
408
    /// Provision a new Box VM
242 409
    Create {
243
        #[arg(long, default_value = "main")]
244
        conversation: String,
245
        #[arg(long)]
410
        #[arg(long, help = "Conversation id override")]
411
        conversation: Option<String>,
412
        #[arg(long, help = "Optional label for the box")]
246 413
        label: Option<String>,
247 414
    },
415
    /// Inspect a Box VM's status and lifecycle
416
    View {
417
        #[arg(help = "Box VM id, such as bx_8bhkse3n")]
418
        box_id: String,
419
        #[arg(long, help = "Conversation id override")]
420
        conversation: Option<String>,
421
    },
422
    /// Execute a command synchronously on a Box VM
248 423
    Exec {
249
        #[arg(long, default_value = "main")]
250
        conversation: String,
251
        #[arg(long)]
424
        #[arg(help = "Box VM id, such as bx_8bhkse3n")]
252 425
        box_id: String,
253
        #[arg(long)]
254
        command: String,
426
        #[arg(required = true, trailing_var_arg = true, help = "Command to execute")]
427
        command: Vec<String>,
428
        #[arg(long, help = "Conversation id override")]
429
        conversation: Option<String>,
430
        #[arg(long, help = "Timeout in seconds for command execution")]
431
        timeout: Option<u64>,
432
    },
433
    /// Stop and snapshot a Box VM to release capacity
434
    Stop {
435
        #[arg(help = "Box VM id, such as bx_8bhkse3n")]
436
        box_id: String,
437
        #[arg(long, help = "Conversation id override")]
438
        conversation: Option<String>,
439
    },
440
    /// Start a durable background command run on a Box VM
441
    Run {
442
        #[arg(help = "Box VM id, such as bx_8bhkse3n")]
443
        box_id: String,
444
        #[arg(required = true, trailing_var_arg = true, help = "Command to run in the background")]
445
        command: Vec<String>,
446
        #[arg(long, help = "Conversation id override")]
447
        conversation: Option<String>,
448
    },
449
    /// Manage durable runs on Box VMs
450
    Runs {
451
        #[command(subcommand)]
452
        action: BoxRunAction,
453
    },
454
    /// Request a multi-box fanout admission plan
455
    Fanout {
456
        #[arg(long, help = "Number of boxes to request")]
457
        count: u64,
458
        #[arg(long, help = "Comma-separated labels for the fanout boxes")]
459
        labels: Option<String>,
460
        #[arg(long, help = "Allow scaling up to the budgeted limit")]
461
        budgeted: bool,
462
        #[arg(long, help = "Conversation id override")]
463
        conversation: Option<String>,
464
        #[arg(long, help = "Read an existing plan by its request id instead of asking for one")]
465
        request_id: Option<String>,
466
    },
467
}
468
469
#[derive(Subcommand, Debug)]
470
pub enum BoxRunAction {
471
    /// List durable runs on a Box VM
472
    List {
473
        #[arg(help = "Box VM id")]
474
        box_id: String,
475
        #[arg(long, help = "Conversation id override")]
476
        conversation: Option<String>,
477
    },
478
    /// View details of a Box run
479
    View {
480
        #[arg(help = "Box VM id")]
481
        box_id: String,
482
        #[arg(help = "Box run id")]
483
        run_id: String,
484
        #[arg(long, help = "Conversation id override")]
485
        conversation: Option<String>,
486
    },
487
    /// Read the output of a Box run, optionally until it finishes
488
    Output {
489
        #[arg(help = "Box VM id")]
490
        box_id: String,
491
        #[arg(help = "Box run id")]
492
        run_id: String,
493
        #[arg(long, help = "Byte offset to start reading output from")]
494
        offset: Option<u64>,
495
        #[arg(long, help = "Keep reading until the run reaches a terminal state")]
496
        follow: bool,
497
        #[arg(long, default_value_t = 1000, help = "Milliseconds between polls while following")]
498
        interval_ms: u64,
499
        #[arg(long, help = "Conversation id override")]
500
        conversation: Option<String>,
501
    },
502
    /// Cancel an active Box run
503
    Cancel {
504
        #[arg(help = "Box VM id")]
505
        box_id: String,
506
        #[arg(help = "Box run id")]
507
        run_id: String,
508
        #[arg(long, help = "Conversation id override")]
509
        conversation: Option<String>,
255 510
    },
256 511
}
257 512

@@ -299,15 +554,33 @@ pub struct MemoryArgs {

299 554
300 555
#[derive(Subcommand, Debug)]
301 556
pub enum MemoryAction {
557
    /// List the account's memories, newest first
302 558
    List {
303
        #[arg(long)]
559
        #[arg(long, help = "Narrow to one bucket: user or learned")]
304 560
        bucket: Option<String>,
561
        #[arg(long, help = "Maximum number of memories to read")]
562
        limit: Option<u32>,
563
        #[arg(long, help = "Also read the corrections behind the live memories")]
564
        include_superseded: bool,
305 565
    },
566
    /// Store one memory. Pass --supersedes <id> to correct an existing one
567
    /// rather than edit it
306 568
    Add {
307
        #[arg(long)]
308
        body: String,
309
        #[arg(long)]
569
        // Positional, and variadic, the way `openagents memory add` takes it:
570
        // `oa memory add --supersedes <id> "what to remember"`.
571
        #[arg(required = true, trailing_var_arg = true, help = "What to remember")]
572
        body: Vec<String>,
573
        #[arg(long, help = "Bucket to store in: user or learned")]
310 574
        bucket: Option<String>,
575
        #[arg(long, help = "Id of the memory this one corrects and replaces")]
576
        supersedes: Option<String>,
577
        #[arg(long, help = "Thread or session this memory came out of")]
578
        source_ref: Option<String>,
579
    },
580
    /// Remove one memory outright
581
    Delete {
582
        #[arg(help = "Memory id")]
583
        memory_id: String,
311 584
    },
312 585
}
313 586

@@ -383,57 +656,8 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

383 656
            }
384 657
        },
385 658
        Commands::Identity(identity) => run_identity(identity.action, cli.json),
386
        Commands::Issue(issue) => {
387
            let tracker = crate::tracker::TrackerClient::new("https://openagents.com/api/v1", token);
388
            match issue.action {
389
                IssueAction::List { repo } => {
390
                    let r = repo.unwrap_or_else(|| "OpenAgentsInc/openagents".to_string());
391
                    let list = tracker.list_issues(&r).await.map_err(|e| e.to_string())?;
392
                    for item in list {
393
                        println!("#{}\t{}\t[{}]", item.number, item.title, item.state);
394
                    }
395
                }
396
                IssueAction::View { number, repo } => {
397
                    let r = repo.unwrap_or_else(|| "OpenAgentsInc/openagents".to_string());
398
                    if let Some(item) = tracker.get_issue(&r, number).await.map_err(|e| e.to_string())? {
399
                        println!("#{} {}\nState: {}\nAuthor: {:?}", item.number, item.title, item.state, item.author);
400
                    }
401
                }
402
                IssueAction::Create { title, body, repo } => {
403
                    let r = repo.unwrap_or_else(|| "OpenAgentsInc/openagents".to_string());
404
                    if let Some(created) = tracker.create_issue(&r, &title, body.as_deref()).await.map_err(|e| e.to_string())? {
405
                        println!("Created issue #{} in {}", created.number, r);
406
                    }
407
                }
408
                IssueAction::Close { number, repo } => {
409
                    let r = repo.unwrap_or_else(|| "OpenAgentsInc/openagents".to_string());
410
                    if tracker.close_issue(&r, number).await.map_err(|e| e.to_string())? {
411
                        println!("Closed issue #{} in {}", number, r);
412
                    }
413
                }
414
                IssueAction::Comment { number, body, repo } => {
415
                    let r = repo.unwrap_or_else(|| "OpenAgentsInc/openagents".to_string());
416
                    if tracker.comment_issue(&r, number, &body).await.map_err(|e| e.to_string())? {
417
                        println!("Commented on #{} in {}", number, r);
418
                    }
419
                }
420
            }
421
        }
422
        Commands::Project(project) => {
423
            let tracker = crate::tracker::TrackerClient::new("https://openagents.com/api/v1", token);
424
            match project.action {
425
                ProjectAction::List { repo } => {
426
                    let r = repo.unwrap_or_else(|| "OpenAgentsInc/openagents".to_string());
427
                    let list = tracker.list_projects(&r).await.map_err(|e| e.to_string())?;
428
                    for p in list {
429
                        println!("#{}\t{}\t[{}]", p.number, p.title, p.state);
430
                    }
431
                }
432
                ProjectAction::View { number, repo } => {
433
                    println!("Viewing project #{} in {:?}", number, repo);
434
                }
435
            }
436
        }
659
        Commands::Issue(issue) => run_issue(issue.action, token, cli.json).await,
660
        Commands::Project(project) => run_project(project.action, token, cli.json).await,
437 661
        Commands::Repo(repo) => {
438 662
            let repo_client = crate::repo::RepoClient::new("https://openagents.com/api/v1", token);
439 663
            match repo.action {

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

486 710
                crate::interactive::run_tui(coder, token).await?;
487 711
            }
488 712
        }
489
        Commands::Box(b) => {
490
            let box_client = crate::box_client::BoxClient::new("https://openagents.com/api/v1", token);
491
            match b.action {
492
                BoxAction::List { conversation } => {
493
                    let boxes = box_client.list_boxes(&conversation).await.map_err(|e| e.to_string())?;
494
                    for bx in boxes {
495
                        println!("{}\t{}\t[{}]", bx.box_id, bx.label.unwrap_or_default(), bx.state);
496
                    }
497
                }
498
                BoxAction::Create { conversation, label } => {
499
                    if let Some(bx) = box_client.create_box(&conversation, label.as_deref()).await.map_err(|e| e.to_string())? {
500
                        println!("Created box: {}", bx.box_id);
501
                    }
502
                }
503
                BoxAction::Exec { conversation, box_id, command } => {
504
                    let res = box_client.execute_command(&conversation, &box_id, &command).await.map_err(|e| e.to_string())?;
505
                    println!("Exit: {}\nStdout: {}\nStderr: {}", res.exit_code, res.stdout, res.stderr);
506
                }
507
            }
508
        }
713
        Commands::Box(b) => run_box(b.action, token, cli.json).await,
509 714
        Commands::Computer(comp) => match comp.action {
510 715
            ComputerAction::Probe => {
511 716
                let info = crate::computer::probe_host();

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

561 766
                }
562 767
            }
563 768
        }
564
        Commands::Memory(mem) => {
565
            let client = crate::memory_client::MemoryClient::new("https://openagents.com/api/v1", token);
566
            match mem.action {
567
                MemoryAction::List { bucket } => {
568
                    let records = client.list_memories(bucket.as_deref()).await.map_err(|e| e.to_string())?;
569
                    for r in records {
570
                        println!("{}\t[{}]\t{}", r.id, r.bucket, r.body);
571
                    }
572
                }
573
                MemoryAction::Add { body, bucket } => {
574
                    if let Some(r) = client.add_memory(&body, bucket.as_deref()).await.map_err(|e| e.to_string())? {
575
                        println!("Added memory: {} [{}]", r.id, r.bucket);
576
                    }
577
                }
578
            }
579
        }
769
        Commands::Memory(mem) => run_memory(mem.action, token, cli.json).await,
580 770
        Commands::Api(api) => {
581 771
            let client = crate::api_passthrough::ApiPassthroughClient::new("https://openagents.com/api/v1", token);
582 772
            let res = client.execute_request(&api.method, &api.path, None).await.map_err(|e| e.to_string())?;

@@ -610,6 +800,1276 @@ fn home_directory() -> std::path::PathBuf {

610 800
    std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()))
611 801
}
612 802
803
// ---------------------------------------------------------------------------
804
// tracker: issues, projects, milestones
805
// ---------------------------------------------------------------------------
806
807
const API_BASE: &str = "https://openagents.com/api/v1";
808
809
/// Unwrap a client result, or print the server's own refusal and exit non-zero.
810
///
811
/// Every tracker, box, and memory command ends here rather than in an
812
/// `unwrap_or_default`. That is the whole difference between reporting what the
813
/// server said and printing an empty list that reads as "there is nothing".
814
fn or_fail<T>(result: Result<T, crate::tracker::ApiError>) -> T {
815
    match result {
816
        Ok(value) => value,
817
        Err(error) => fail(&error.to_string()),
818
    }
819
}
820
821
/// Print the server's body verbatim under `--json`, or the human lines.
822
fn emit(json: bool, value: &serde_json::Value, human: &[String]) {
823
    if json {
824
        match serde_json::to_string_pretty(value) {
825
            Ok(text) => println!("{}", text),
826
            Err(error) => fail(&format!("Could not render JSON: {}", error)),
827
        }
828
    } else {
829
        for line in human {
830
            println!("{}", line);
831
        }
832
    }
833
}
834
835
fn field(value: &serde_json::Value, key: &str) -> String {
836
    match value.get(key) {
837
        Some(serde_json::Value::String(text)) => text.clone(),
838
        Some(serde_json::Value::Null) | None => String::new(),
839
        Some(other) => other.to_string(),
840
    }
841
}
842
843
/// The names inside an array of objects, or of strings.
844
fn names(value: Option<&serde_json::Value>, key: &str) -> Vec<String> {
845
    value
846
        .and_then(serde_json::Value::as_array)
847
        .map(|items| {
848
            items
849
                .iter()
850
                .map(|item| match item {
851
                    serde_json::Value::String(text) => text.clone(),
852
                    other => field(other, key),
853
                })
854
                .collect()
855
        })
856
        .unwrap_or_default()
857
}
858
859
fn or_none(values: &[String]) -> String {
860
    if values.is_empty() {
861
        "none".to_string()
862
    } else {
863
        values.join(", ")
864
    }
865
}
866
867
fn issue_references(value: Option<&serde_json::Value>) -> Vec<String> {
868
    value
869
        .and_then(serde_json::Value::as_array)
870
        .map(|items| {
871
            items
872
                .iter()
873
                .map(|item| {
874
                    format!(
875
                        "#{}",
876
                        item.get("number")
877
                            .map(|n| n.to_string())
878
                            .unwrap_or_else(|| "?".to_string())
879
                    )
880
                })
881
                .collect()
882
        })
883
        .unwrap_or_default()
884
}
885
886
fn number_or_question(value: &serde_json::Value, key: &str) -> String {
887
    value
888
        .get(key)
889
        .filter(|v| !v.is_null())
890
        .map(|v| v.to_string())
891
        .unwrap_or_else(|| "?".to_string())
892
}
893
894
fn pad(text: &str, width: usize) -> String {
895
    let mut out = text.to_string();
896
    while out.chars().count() < width {
897
        out.push(' ');
898
    }
899
    out
900
}
901
902
fn issue_row(issue: &serde_json::Value) -> String {
903
    let extension = issue.get("openagents").cloned().unwrap_or(serde_json::Value::Null);
904
    let labels = names(issue.get("labels"), "name");
905
    format!(
906
        "{}{}{}{}{}",
907
        pad(&format!("#{}", number_or_question(issue, "number")), 7),
908
        pad(&field(issue, "state"), 8),
909
        field(issue, "title"),
910
        if labels.is_empty() {
911
            String::new()
912
        } else {
913
            format!("  ({})", labels.join(", "))
914
        },
915
        if extension.get("blocked") == Some(&serde_json::Value::Bool(true)) {
916
            "  [blocked]"
917
        } else {
918
            ""
919
        }
920
    )
921
}
922
923
fn issue_view_human(issue: &serde_json::Value) -> Vec<String> {
924
    let extension = issue.get("openagents").cloned().unwrap_or(serde_json::Value::Null);
925
    let milestone = issue.get("milestone").cloned().unwrap_or(serde_json::Value::Null);
926
    let author = issue
927
        .get("user")
928
        .map(|u| field(u, "login"))
929
        .filter(|s| !s.is_empty())
930
        .unwrap_or_else(|| "unknown".to_string());
931
    let milestone_title = field(&milestone, "title");
932
    vec![
933
        format!(
934
            "#{}  {}",
935
            number_or_question(issue, "number"),
936
            field(issue, "title")
937
        ),
938
        format!("State:      {}", field(issue, "state")),
939
        format!("Author:     {}", author),
940
        format!("Labels:     {}", or_none(&names(issue.get("labels"), "name"))),
941
        format!(
942
            "Assignees:  {}",
943
            or_none(&names(issue.get("assignees"), "login"))
944
        ),
945
        format!(
946
            "Milestone:  {}",
947
            if milestone_title.is_empty() {
948
                "none".to_string()
949
            } else {
950
                milestone_title
951
            }
952
        ),
953
        format!(
954
            "Progress:   {}",
955
            {
956
                let progress = field(&extension, "progress");
957
                if progress.is_empty() { "unknown".to_string() } else { progress }
958
            }
959
        ),
960
        format!(
961
            "Blocked:    {}",
962
            if extension.get("blocked") == Some(&serde_json::Value::Bool(true)) {
963
                "yes"
964
            } else {
965
                "no"
966
            }
967
        ),
968
        format!(
969
            "Blocked by: {}",
970
            or_none(&issue_references(extension.get("blocked_by")))
971
        ),
972
        format!("Blocks:     {}", or_none(&issue_references(extension.get("blocks")))),
973
        String::new(),
974
        field(issue, "body"),
975
    ]
976
}
977
978
fn comment_thread_human(value: &serde_json::Value) -> Vec<String> {
979
    let comments = value
980
        .get("comments")
981
        .and_then(serde_json::Value::as_array)
982
        .cloned()
983
        .unwrap_or_default();
984
    if comments.is_empty() {
985
        return vec![String::new(), "No comments.".to_string()];
986
    }
987
    let mut lines = vec![String::new(), format!("Comments ({}):", comments.len())];
988
    for comment in &comments {
989
        let author = comment
990
            .get("user")
991
            .map(|u| field(u, "login"))
992
            .filter(|s| !s.is_empty())
993
            .unwrap_or_else(|| "unknown".to_string());
994
        lines.push(format!("- {}: {}", author, field(comment, "body")));
995
    }
996
    lines
997
}
998
999
fn dependency_human(graph: &serde_json::Value) -> Vec<String> {
1000
    let edges = |key: &str| -> Vec<String> {
1001
        graph
1002
            .get(key)
1003
            .and_then(serde_json::Value::as_array)
1004
            .map(|items| {
1005
                items
1006
                    .iter()
1007
                    .map(|entry| {
1008
                        format!(
1009
                            "  #{} {} {}",
1010
                            number_or_question(entry, "number"),
1011
                            field(entry, "state"),
1012
                            field(entry, "title")
1013
                        )
1014
                    })
1015
                    .collect()
1016
            })
1017
            .unwrap_or_default()
1018
    };
1019
    let blocked_by = edges("blocked_by");
1020
    let blocks = edges("blocks");
1021
    let mut lines = vec![format!(
1022
        "Blocked: {}",
1023
        if graph.get("blocked") == Some(&serde_json::Value::Bool(true)) {
1024
            "yes"
1025
        } else {
1026
            "no"
1027
        }
1028
    )];
1029
    lines.push("Blocked by:".to_string());
1030
    if blocked_by.is_empty() {
1031
        lines.push("  none".to_string());
1032
    } else {
1033
        lines.extend(blocked_by);
1034
    }
1035
    lines.push("Blocks:".to_string());
1036
    if blocks.is_empty() {
1037
        lines.push("  none".to_string());
1038
    } else {
1039
        lines.extend(blocks);
1040
    }
1041
    lines
1042
}
1043
1044
/// Reads `--body` or `--body-file`, where `-` is standard input.
1045
fn resolve_body(body: Option<String>, body_file: Option<String>) -> Option<String> {
1046
    match (body, body_file) {
1047
        (Some(_), Some(_)) => fail("Use either --body or --body-file, not both."),
1048
        (Some(text), None) => Some(text),
1049
        (None, Some(path)) => {
1050
            if path == "-" {
1051
                use std::io::Read;
1052
                let mut buffer = String::new();
1053
                if let Err(error) = std::io::stdin().read_to_string(&mut buffer) {
1054
                    fail(&format!("Could not read the body from standard input: {}", error));
1055
                }
1056
                Some(buffer)
1057
            } else {
1058
                match std::fs::read_to_string(&path) {
1059
                    Ok(text) => Some(text),
1060
                    Err(error) => fail(&format!("Could not read {}: {}", path, error)),
1061
                }
1062
            }
1063
        }
1064
        (None, None) => None,
1065
    }
1066
}
1067
1068
fn target_or_fail(repo: Option<String>) -> crate::tracker::RepoTarget {
1069
    or_fail(crate::tracker::resolve_repo_target(repo.as_deref()))
1070
}
1071
1072
/// `FIELD=VALUE` pairs into the object the project routes take.
1073
fn parse_field_values(pairs: &[String]) -> serde_json::Value {
1074
    let mut map = serde_json::Map::new();
1075
    for pair in pairs {
1076
        match pair.split_once('=') {
1077
            Some((field, value)) if !field.trim().is_empty() => {
1078
                map.insert(field.trim().to_string(), serde_json::json!(value));
1079
            }
1080
            _ => fail(&format!(
1081
                "`{}` is not a field assignment. Pass --set FIELD=VALUE.",
1082
                pair
1083
            )),
1084
        }
1085
    }
1086
    serde_json::Value::Object(map)
1087
}
1088
1089
async fn run_issue(action: IssueAction, token: Option<String>, json: bool) {
1090
    let tracker = crate::tracker::TrackerClient::new(API_BASE, token);
1091
    match action {
1092
        IssueAction::List {
1093
            repo,
1094
            state,
1095
            label,
1096
            assignee,
1097
            milestone,
1098
            search,
1099
            blocked,
1100
            limit,
1101
        } => {
1102
            let target = target_or_fail(repo);
1103
            let options = crate::tracker::IssueListOptions {
1104
                limit,
1105
                state: Some(state),
1106
                label,
1107
                assignee,
1108
                milestone,
1109
                search,
1110
                blocked,
1111
            };
1112
            let result = or_fail(tracker.list_issues(&target, &options).await);
1113
            let value = serde_json::json!({
1114
                "pagination": result.pagination,
1115
                "issues": result.issues,
1116
            });
1117
            let mut human: Vec<String> = if result.issues.is_empty() {
1118
                vec!["No issues found.".to_string()]
1119
            } else {
1120
                result.issues.iter().map(issue_row).collect()
1121
            };
1122
            if !result.issues.is_empty() {
1123
                human.push(String::new());
1124
                human.push(match result.pagination.get("total").and_then(|t| t.as_u64()) {
1125
                    Some(total) => format!("Showing {} of {} issues.", result.issues.len(), total),
1126
                    None => format!("Showing {} issues.", result.issues.len()),
1127
                });
1128
            }
1129
            emit(json, &value, &human);
1130
        }
1131
        IssueAction::View {
1132
            number,
1133
            repo,
1134
            comments,
1135
        } => {
1136
            let target = target_or_fail(repo);
1137
            let issue = or_fail(tracker.view_issue(&target, number).await);
1138
            if !comments {
1139
                emit(json, &issue, &issue_view_human(&issue));
1140
            } else {
1141
                let thread = or_fail(tracker.list_comments(&target, number).await);
1142
                let mut human = issue_view_human(&issue);
1143
                human.extend(comment_thread_human(&thread));
1144
                let value = serde_json::json!({ "issue": issue, "comments": thread });
1145
                emit(json, &value, &human);
1146
            }
1147
        }
1148
        IssueAction::Create {
1149
            title,
1150
            body,
1151
            body_file,
1152
            label,
1153
            assignee,
1154
            milestone,
1155
            repo,
1156
        } => {
1157
            let target = target_or_fail(repo);
1158
            let text = resolve_body(body, body_file);
1159
            let created = or_fail(
1160
                tracker
1161
                    .create_issue(
1162
                        &target,
1163
                        &title,
1164
                        text.as_deref(),
1165
                        &label,
1166
                        &assignee,
1167
                        milestone,
1168
                    )
1169
                    .await,
1170
            );
1171
            emit(
1172
                json,
1173
                &created,
1174
                &[format!(
1175
                    "Created issue #{} {}",
1176
                    number_or_question(&created, "number"),
1177
                    field(&created, "title")
1178
                )],
1179
            );
1180
        }
1181
        IssueAction::Close {
1182
            number,
1183
            repo,
1184
            comment,
1185
        } => {
1186
            let target = target_or_fail(repo);
1187
            if let Some(text) = comment {
1188
                or_fail(tracker.comment_issue(&target, number, &text).await);
1189
            }
1190
            let issue = or_fail(tracker.set_issue_state(&target, number, "closed").await);
1191
            emit(
1192
                json,
1193
                &issue,
1194
                &[format!(
1195
                    "Closed issue #{} ({}).",
1196
                    number_or_question(&issue, "number"),
1197
                    field(&issue, "state")
1198
                )],
1199
            );
1200
        }
1201
        IssueAction::Reopen {
1202
            number,
1203
            repo,
1204
            comment,
1205
        } => {
1206
            let target = target_or_fail(repo);
1207
            if let Some(text) = comment {
1208
                or_fail(tracker.comment_issue(&target, number, &text).await);
1209
            }
1210
            let issue = or_fail(tracker.set_issue_state(&target, number, "open").await);
1211
            emit(
1212
                json,
1213
                &issue,
1214
                &[format!(
1215
                    "Reopened issue #{} ({}).",
1216
                    number_or_question(&issue, "number"),
1217
                    field(&issue, "state")
1218
                )],
1219
            );
1220
        }
1221
        IssueAction::Comment {
1222
            number,
1223
            body,
1224
            body_file,
1225
            repo,
1226
        } => {
1227
            let target = target_or_fail(repo);
1228
            match resolve_body(body, body_file) {
1229
                Some(text) => {
1230
                    let comment = or_fail(tracker.comment_issue(&target, number, &text).await);
1231
                    emit(
1232
                        json,
1233
                        &comment,
1234
                        &[format!("Commented on #{}.", number)],
1235
                    );
1236
                }
1237
                // No body is a read of the thread, which is what the
1238
                // TypeScript CLI does with `issue view --comments`.
1239
                None => {
1240
                    let thread = or_fail(tracker.list_comments(&target, number).await);
1241
                    emit(json, &thread, &comment_thread_human(&thread));
1242
                }
1243
            }
1244
        }
1245
        IssueAction::Label {
1246
            number,
1247
            add,
1248
            remove,
1249
            repo,
1250
        } => {
1251
            let target = target_or_fail(repo);
1252
            let mut value: Option<serde_json::Value> = None;
1253
            if !add.is_empty() {
1254
                value = Some(or_fail(tracker.add_labels(&target, number, &add).await));
1255
            }
1256
            for name in &remove {
1257
                value = Some(or_fail(tracker.remove_label(&target, number, name).await));
1258
            }
1259
            let applied = match value {
1260
                Some(value) => value,
1261
                None => or_fail(tracker.list_labels(&target, number).await),
1262
            };
1263
            emit(
1264
                json,
1265
                &applied,
1266
                &[format!(
1267
                    "Labels: {}",
1268
                    or_none(&names(applied.get("labels"), "name"))
1269
                )],
1270
            );
1271
        }
1272
        IssueAction::Assign {
1273
            number,
1274
            logins,
1275
            repo,
1276
        } => {
1277
            let target = target_or_fail(repo);
1278
            let value = or_fail(tracker.add_assignees(&target, number, &logins).await);
1279
            emit(
1280
                json,
1281
                &value,
1282
                &[format!(
1283
                    "Assignees: {}",
1284
                    or_none(&names(value.get("assignees"), "login"))
1285
                )],
1286
            );
1287
        }
1288
        IssueAction::Unassign {
1289
            number,
1290
            logins,
1291
            repo,
1292
        } => {
1293
            let target = target_or_fail(repo);
1294
            let value = or_fail(tracker.remove_assignees(&target, number, &logins).await);
1295
            emit(
1296
                json,
1297
                &value,
1298
                &[format!(
1299
                    "Assignees: {}",
1300
                    or_none(&names(value.get("assignees"), "login"))
1301
                )],
1302
            );
1303
        }
1304
        IssueAction::Deps {
1305
            number,
1306
            add,
1307
            remove,
1308
            repo,
1309
        } => {
1310
            let target = target_or_fail(repo);
1311
            let mut value: Option<serde_json::Value> = None;
1312
            if !add.is_empty() {
1313
                value = Some(or_fail(tracker.add_dependencies(&target, number, &add).await));
1314
            }
1315
            for blocked_by in &remove {
1316
                value = Some(or_fail(
1317
                    tracker.remove_dependency(&target, number, *blocked_by).await,
1318
                ));
1319
            }
1320
            let graph = match value {
1321
                Some(value) => value,
1322
                None => or_fail(tracker.dependencies(&target, number).await),
1323
            };
1324
            emit(json, &graph, &dependency_human(&graph));
1325
        }
1326
        IssueAction::Milestones { repo } => {
1327
            let target = target_or_fail(repo);
1328
            let value = or_fail(tracker.list_milestones(&target).await);
1329
            let rows = value
1330
                .get("milestones")
1331
                .and_then(serde_json::Value::as_array)
1332
                .cloned()
1333
                .unwrap_or_default();
1334
            let human: Vec<String> = if rows.is_empty() {
1335
                vec!["No milestones found.".to_string()]
1336
            } else {
1337
                rows.iter()
1338
                    .map(|row| {
1339
                        format!(
1340
                            "{}{}{}",
1341
                            pad(&format!("#{}", number_or_question(row, "number")), 7),
1342
                            pad(&field(row, "state"), 8),
1343
                            field(row, "title")
1344
                        )
1345
                    })
1346
                    .collect()
1347
            };
1348
            emit(json, &value, &human);
1349
        }
1350
    }
1351
}
1352
1353
fn project_row(project: &serde_json::Value) -> String {
1354
    format!(
1355
        "{}{}{}{}",
1356
        pad(&format!("#{}", number_or_question(project, "number")), 6),
1357
        pad(&field(project, "state"), 8),
1358
        field(project, "title"),
1359
        if project.get("archived") == Some(&serde_json::Value::Bool(true)) {
1360
            "  [archived]"
1361
        } else {
1362
            ""
1363
        }
1364
    )
1365
}
1366
1367
fn project_items_human(value: &serde_json::Value) -> Vec<String> {
1368
    let items = value
1369
        .get("items")
1370
        .and_then(serde_json::Value::as_array)
1371
        .cloned()
1372
        .unwrap_or_default();
1373
    if items.is_empty() {
1374
        return vec!["No items on this board.".to_string()];
1375
    }
1376
    items
1377
        .iter()
1378
        .map(|item| {
1379
            let issue = item.get("issue").cloned().unwrap_or(serde_json::Value::Null);
1380
            let pairs: Vec<String> = item
1381
                .get("values")
1382
                .and_then(serde_json::Value::as_object)
1383
                .map(|map| {
1384
                    map.iter()
1385
                        .map(|(field, value)| match value {
1386
                            serde_json::Value::String(text) => format!("{}={}", field, text),
1387
                            other => format!("{}={}", field, other),
1388
                        })
1389
                        .collect()
1390
                })
1391
                .unwrap_or_default();
1392
            format!(
1393
                "{} #{}  {}",
1394
                pad(&number_or_question(item, "id"), 6),
1395
                number_or_question(&issue, "number"),
1396
                pairs.join(" ")
1397
            )
1398
        })
1399
        .collect()
1400
}
1401
1402
async fn run_project(action: ProjectAction, token: Option<String>, json: bool) {
1403
    let tracker = crate::tracker::TrackerClient::new(API_BASE, token);
1404
    match action {
1405
        ProjectAction::List { repo, archived } => {
1406
            let target = target_or_fail(repo);
1407
            let value = or_fail(tracker.list_projects(&target, archived).await);
1408
            let boards = value
1409
                .get("projects")
1410
                .and_then(serde_json::Value::as_array)
1411
                .cloned()
1412
                .unwrap_or_default();
1413
            let human: Vec<String> = if boards.is_empty() {
1414
                vec!["No projects found.".to_string()]
1415
            } else {
1416
                boards.iter().map(project_row).collect()
1417
            };
1418
            emit(json, &value, &human);
1419
        }
1420
        ProjectAction::View { number, repo } => {
1421
            let target = target_or_fail(repo);
1422
            let project = or_fail(tracker.view_project(&target, number).await);
1423
            let human = vec![
1424
                format!(
1425
                    "#{}  {}",
1426
                    number_or_question(&project, "number"),
1427
                    field(&project, "title")
1428
                ),
1429
                format!("State:    {}", field(&project, "state")),
1430
                format!(
1431
                    "Archived: {}",
1432
                    if project.get("archived") == Some(&serde_json::Value::Bool(true)) {
1433
                        "yes"
1434
                    } else {
1435
                        "no"
1436
                    }
1437
                ),
1438
                format!("Owner:    {}", {
1439
                    let owner = field(&project, "owner");
1440
                    if owner.is_empty() { "unknown".to_string() } else { owner }
1441
                }),
1442
                String::new(),
1443
                field(&project, "description"),
1444
            ];
1445
            emit(json, &project, &human);
1446
        }
1447
        ProjectAction::Create {
1448
            title,
1449
            description,
1450
            repo,
1451
        } => {
1452
            if title.trim().is_empty() {
1453
                fail("Pass --title with the project title.");
1454
            }
1455
            let target = target_or_fail(repo);
1456
            let project = or_fail(
1457
                tracker
1458
                    .create_project(&target, &title, description.as_deref())
1459
                    .await,
1460
            );
1461
            emit(
1462
                json,
1463
                &project,
1464
                &[format!(
1465
                    "Created project #{} {}",
1466
                    number_or_question(&project, "number"),
1467
                    field(&project, "title")
1468
                )],
1469
            );
1470
        }
1471
        ProjectAction::Fields { number, repo } => {
1472
            let target = target_or_fail(repo);
1473
            let value = or_fail(tracker.project_fields(&target, number).await);
1474
            let fields = value
1475
                .get("fields")
1476
                .and_then(serde_json::Value::as_array)
1477
                .cloned()
1478
                .unwrap_or_default();
1479
            let human: Vec<String> = if fields.is_empty() {
1480
                vec!["No fields on this board.".to_string()]
1481
            } else {
1482
                fields
1483
                    .iter()
1484
                    .map(|f| {
1485
                        let options = f
1486
                            .get("options")
1487
                            .and_then(|o| o.get("values"))
1488
                            .cloned()
1489
                            .unwrap_or(serde_json::Value::Null);
1490
                        format!(
1491
                            "{} ({}) {}",
1492
                            field(f, "name"),
1493
                            field(f, "data_type"),
1494
                            or_none(&names(Some(&options), "name"))
1495
                        )
1496
                    })
1497
                    .collect()
1498
            };
1499
            emit(json, &value, &human);
1500
        }
1501
        ProjectAction::Items { number, repo } => {
1502
            let target = target_or_fail(repo);
1503
            let value = or_fail(tracker.project_items(&target, number).await);
1504
            emit(json, &value, &project_items_human(&value));
1505
        }
1506
        ProjectAction::ItemAdd {
1507
            number,
1508
            issue,
1509
            repo,
1510
        } => {
1511
            let target = target_or_fail(repo);
1512
            let value = or_fail(tracker.project_add_item(&target, number, issue).await);
1513
            emit(json, &value, &project_items_human(&value));
1514
        }
1515
        ProjectAction::ItemSet {
1516
            number,
1517
            item,
1518
            set,
1519
            repo,
1520
        } => {
1521
            let target = target_or_fail(repo);
1522
            let values = parse_field_values(&set);
1523
            let value = or_fail(
1524
                tracker
1525
                    .project_set_item_values(&target, number, &item, &values)
1526
                    .await,
1527
            );
1528
            emit(json, &value, &project_items_human(&value));
1529
        }
1530
        ProjectAction::ItemMove {
1531
            number,
1532
            item,
1533
            set,
1534
            position,
1535
            repo,
1536
        } => {
1537
            if set.is_empty() && position.is_none() {
1538
                fail("Pass --set FIELD=VALUE, --position, or both.");
1539
            }
1540
            let target = target_or_fail(repo);
1541
            let values = parse_field_values(&set);
1542
            let value = or_fail(
1543
                tracker
1544
                    .project_move_item(&target, number, &item, &values, position)
1545
                    .await,
1546
            );
1547
            emit(json, &value, &project_items_human(&value));
1548
        }
1549
        ProjectAction::ItemRemove {
1550
            number,
1551
            item,
1552
            repo,
1553
        } => {
1554
            let value = {
1555
                let target = target_or_fail(repo);
1556
                or_fail(tracker.project_remove_item(&target, number, &item).await)
1557
            };
1558
            emit(
1559
                json,
1560
                &value,
1561
                &[format!("Removed item {} from project #{}.", item, number)],
1562
            );
1563
        }
1564
    }
1565
}
1566
1567
// ---------------------------------------------------------------------------
1568
// box
1569
// ---------------------------------------------------------------------------
1570
1571
fn box_list_human(boxes: &[crate::box_client::BoxRecord]) -> Vec<String> {
1572
    if boxes.is_empty() {
1573
        return vec!["No boxes provisioned for this conversation.".to_string()];
1574
    }
1575
    let mut lines = vec!["BOX ID        STATE       SETUP     LABEL        CREATED".to_string()];
1576
    for b in boxes {
1577
        lines.push(format!(
1578
            "{} {} {} {} {}",
1579
            pad(&b.box_id, 13),
1580
            pad(&b.state, 11),
1581
            pad(&b.setup_status, 9),
1582
            pad(b.label.as_deref().unwrap_or("-"), 12),
1583
            b.created_at
1584
        ));
1585
    }
1586
    lines
1587
}
1588
1589
fn box_view_human(b: &crate::box_client::BoxRecord) -> Vec<String> {
1590
    let mut lines = vec![
1591
        format!("Box ID:       {}", b.box_id),
1592
        format!("State:        {}", b.state),
1593
        format!("Setup Status: {}", b.setup_status),
1594
        format!("Label:        {}", b.label.as_deref().unwrap_or("-")),
1595
        format!("Created:      {}", b.created_at),
1596
    ];
1597
    if let Some(stopped) = &b.stopped_at {
1598
        lines.push(format!("Stopped:      {}", stopped));
1599
    }
1600
    lines
1601
}
1602
1603
fn run_list_human(runs: &[crate::box_client::BoxRunRecord]) -> Vec<String> {
1604
    if runs.is_empty() {
1605
        return vec!["No runs recorded for this box.".to_string()];
1606
    }
1607
    let mut lines =
1608
        vec!["RUN ID                               STATE      EXIT  COMMAND".to_string()];
1609
    for r in runs {
1610
        let command = if r.command.chars().count() > 40 {
1611
            format!("{}...", r.command.chars().take(37).collect::<String>())
1612
        } else {
1613
            r.command.clone()
1614
        };
1615
        lines.push(format!(
1616
            "{} {} {} {}",
1617
            pad(&r.id, 36),
1618
            pad(&r.state, 10),
1619
            pad(
1620
                &r.exit_status
1621
                    .map(|c| c.to_string())
1622
                    .unwrap_or_else(|| "-".to_string()),
1623
                5
1624
            ),
1625
            command
1626
        ));
1627
    }
1628
    lines
1629
}
1630
1631
fn run_view_human(r: &crate::box_client::BoxRunRecord) -> Vec<String> {
1632
    let mut lines = vec![
1633
        format!("Run ID:       {}", r.id),
1634
        format!("Box ID:       {}", r.box_id),
1635
        format!("State:        {}", r.state),
1636
        format!("Command:      {}", r.command),
1637
        format!(
1638
            "Exit Status:  {}",
1639
            r.exit_status
1640
                .map(|c| c.to_string())
1641
                .unwrap_or_else(|| "-".to_string())
1642
        ),
1643
        format!(
1644
            "Timed Out:    {}",
1645
            if r.timed_out == Some(true) { "yes" } else { "no" }
1646
        ),
1647
    ];
1648
    if let Some(reason) = &r.failure_reason {
1649
        lines.push(format!("Failure:      {}", reason));
1650
    }
1651
    lines.push(format!(
1652
        "Admitted:     {}",
1653
        r.admitted_at.as_deref().unwrap_or("-")
1654
    ));
1655
    lines.push(format!(
1656
        "Dispatched:   {}",
1657
        r.dispatched_at.as_deref().unwrap_or("-")
1658
    ));
1659
    lines.push(format!(
1660
        "Started:      {}",
1661
        r.started_at.as_deref().unwrap_or("-")
1662
    ));
1663
    lines.push(format!(
1664
        "Finished:     {}",
1665
        r.finished_at.as_deref().unwrap_or("-")
1666
    ));
1667
    lines
1668
}
1669
1670
fn fanout_human(plan: &crate::box_client::BoxFanoutPlan) -> Vec<String> {
1671
    let mut lines = vec![
1672
        format!("Fanout Plan:  {}", plan.id),
1673
        format!(
1674
            "Requested:    {} boxes (Budgeted: {})",
1675
            plan.requested_count,
1676
            if plan.budgeted { "yes" } else { "no" }
1677
        ),
1678
        format!("Admitted:     {}", plan.admitted.len()),
1679
    ];
1680
    for item in &plan.admitted {
1681
        lines.push(format!(
1682
            "  [#{}] {} -> {} ({})",
1683
            item.position,
1684
            item.label,
1685
            item.box_id.as_deref().unwrap_or("allocating"),
1686
            item.state
1687
        ));
1688
    }
1689
    lines.push(format!("Queued:       {}", plan.queued.len()));
1690
    for item in &plan.queued {
1691
        lines.push(format!(
1692
            "  [#{}] {} (Reason: {})",
1693
            item.position,
1694
            item.label,
1695
            item.queue_reason
1696
                .as_deref()
1697
                .unwrap_or("waiting for capacity")
1698
        ));
1699
    }
1700
    lines
1701
}
1702
1703
fn to_value<T: serde::Serialize>(value: &T) -> serde_json::Value {
1704
    serde_json::to_value(value).unwrap_or(serde_json::Value::Null)
1705
}
1706
1707
async fn run_box(action: BoxAction, token: Option<String>, json: bool) {
1708
    let client = crate::box_client::BoxClient::new(API_BASE, token);
1709
    match action {
1710
        BoxAction::List { conversation } => {
1711
            let id = or_fail(client.conversation_id(conversation.as_deref()).await);
1712
            let boxes = or_fail(client.list_boxes(&id).await);
1713
            emit(
1714
                json,
1715
                &serde_json::json!({ "boxes": to_value(&boxes) }),
1716
                &box_list_human(&boxes),
1717
            );
1718
        }
1719
        BoxAction::Create {
1720
            conversation,
1721
            label,
1722
        } => {
1723
            let id = or_fail(client.conversation_id(conversation.as_deref()).await);
1724
            let record = or_fail(client.create_box(&id, label.as_deref()).await);
1725
            let mut human = vec![format!(
1726
                "Provisioned Box {} (state: {}, setup: {}).",
1727
                record.box_id, record.state, record.setup_status
1728
            )];
1729
            if let Some(name) = &record.label {
1730
                human.push(format!("Label: {}", name));
1731
            }
1732
            emit(json, &serde_json::json!({ "box": to_value(&record) }), &human);
1733
        }
1734
        BoxAction::View {
1735
            box_id,
1736
            conversation,
1737
        } => {
1738
            let id = or_fail(client.conversation_id(conversation.as_deref()).await);
1739
            let record = or_fail(client.view_box(&id, &box_id).await);
1740
            emit(
1741
                json,
1742
                &serde_json::json!({ "box": to_value(&record) }),
1743
                &box_view_human(&record),
1744
            );
1745
        }
1746
        BoxAction::Exec {
1747
            box_id,
1748
            command,
1749
            conversation,
1750
            timeout,
1751
        } => {
1752
            let id = or_fail(client.conversation_id(conversation.as_deref()).await);
1753
            let joined = command.join(" ");
1754
            let result = or_fail(client.execute_command(&id, &box_id, &joined, timeout).await);
1755
            let mut human = Vec::new();
1756
            if !result.stdout.is_empty() {
1757
                human.push(result.stdout.trim_end().to_string());
1758
            }
1759
            if !result.stderr.is_empty() {
1760
                human.push(format!("[STDERR] {}", result.stderr.trim_end()));
1761
            }
1762
            if result.timed_out {
1763
                human.push("[TIMED OUT]".to_string());
1764
            }
1765
            emit(
1766
                json,
1767
                &serde_json::json!({ "result": to_value(&result) }),
1768
                &human,
1769
            );
1770
            // The box's exit status is this process's exit status, so a script
1771
            // that runs a command in a box can branch on it.
1772
            if result.exit_code != 0 {
1773
                std::process::exit(result.exit_code.clamp(1, 255) as i32);
1774
            }
1775
        }
1776
        BoxAction::Stop {
1777
            box_id,
1778
            conversation,
1779
        } => {
1780
            let id = or_fail(client.conversation_id(conversation.as_deref()).await);
1781
            let record = or_fail(client.stop_box(&id, &box_id).await);
1782
            emit(
1783
                json,
1784
                &serde_json::json!({ "box": to_value(&record) }),
1785
                &[format!(
1786
                    "Stopped Box {} (state: {}). Slot released.",
1787
                    record.box_id, record.state
1788
                )],
1789
            );
1790
        }
1791
        BoxAction::Run {
1792
            box_id,
1793
            command,
1794
            conversation,
1795
        } => {
1796
            let id = or_fail(client.conversation_id(conversation.as_deref()).await);
1797
            let joined = command.join(" ");
1798
            let run = or_fail(client.start_run(&id, &box_id, &joined, None).await);
1799
            emit(
1800
                json,
1801
                &serde_json::json!({ "run": to_value(&run) }),
1802
                &[
1803
                    format!("Started background run {} on Box {}.", run.id, run.box_id),
1804
                    format!("State: {}", run.state),
1805
                    format!("Inspect with: oa box runs view {} {}", run.box_id, run.id),
1806
                ],
1807
            );
1808
        }
1809
        BoxAction::Runs { action } => run_box_runs(action, &client, json).await,
1810
        BoxAction::Fanout {
1811
            count,
1812
            labels,
1813
            budgeted,
1814
            conversation,
1815
            request_id,
1816
        } => {
1817
            let id = or_fail(client.conversation_id(conversation.as_deref()).await);
1818
            let plan = match request_id {
1819
                Some(request) => or_fail(client.view_fanout(&id, &request).await),
1820
                None => {
1821
                    let parsed: Vec<String> = labels
1822
                        .as_deref()
1823
                        .map(|raw| {
1824
                            raw.split(',')
1825
                                .map(|s| s.trim().to_string())
1826
                                .filter(|s| !s.is_empty())
1827
                                .collect()
1828
                        })
1829
                        .unwrap_or_default();
1830
                    or_fail(client.fanout(&id, count, &parsed, budgeted).await)
1831
                }
1832
            };
1833
            emit(
1834
                json,
1835
                &serde_json::json!({ "plan": to_value(&plan) }),
1836
                &fanout_human(&plan),
1837
            );
1838
        }
1839
    }
1840
}
1841
1842
async fn run_box_runs(
1843
    action: BoxRunAction,
1844
    client: &crate::box_client::BoxClient,
1845
    json: bool,
1846
) {
1847
    match action {
1848
        BoxRunAction::List {
1849
            box_id,
1850
            conversation,
1851
        } => {
1852
            let id = or_fail(client.conversation_id(conversation.as_deref()).await);
1853
            let runs = or_fail(client.list_runs(&id, &box_id).await);
1854
            emit(
1855
                json,
1856
                &serde_json::json!({ "runs": to_value(&runs) }),
1857
                &run_list_human(&runs),
1858
            );
1859
        }
1860
        BoxRunAction::View {
1861
            box_id,
1862
            run_id,
1863
            conversation,
1864
        } => {
1865
            let id = or_fail(client.conversation_id(conversation.as_deref()).await);
1866
            let run = or_fail(client.view_run(&id, &box_id, &run_id).await);
1867
            emit(
1868
                json,
1869
                &serde_json::json!({ "run": to_value(&run) }),
1870
                &run_view_human(&run),
1871
            );
1872
        }
1873
        BoxRunAction::Output {
1874
            box_id,
1875
            run_id,
1876
            offset,
1877
            follow,
1878
            interval_ms,
1879
            conversation,
1880
        } => {
1881
            let id = or_fail(client.conversation_id(conversation.as_deref()).await);
1882
            if !follow {
1883
                let result = or_fail(client.run_output(&id, &box_id, &run_id, offset).await);
1884
                let mut human = Vec::new();
1885
                if result.truncated {
1886
                    // The box keeps a bounded log, so a read that starts before
1887
                    // the retained window begins mid-stream. Say so rather than
1888
                    // letting the gap read as the run's first line.
1889
                    human.push("[EARLIER OUTPUT DROPPED BY THE BOX]".to_string());
1890
                }
1891
                human.push(result.output.trim_end().to_string());
1892
                emit(json, &to_value(&result), &human);
1893
                return;
1894
            }
1895
            follow_run_output(client, &id, &box_id, &run_id, offset, interval_ms, json).await;
1896
        }
1897
        BoxRunAction::Cancel {
1898
            box_id,
1899
            run_id,
1900
            conversation,
1901
        } => {
1902
            let id = or_fail(client.conversation_id(conversation.as_deref()).await);
1903
            let run = or_fail(client.cancel_run(&id, &box_id, &run_id).await);
1904
            emit(
1905
                json,
1906
                &serde_json::json!({ "run": to_value(&run) }),
1907
                &[format!(
1908
                    "Requested cancellation for run {} (state: {}).",
1909
                    run.id, run.state
1910
                )],
1911
            );
1912
        }
1913
    }
1914
}
1915
1916
/// Print a followed run's output as it arrives, then its final record.
1917
///
1918
/// The loop itself lives in [`crate::box_client::BoxClient::follow_run_output`],
1919
/// where it is tested; this is the rendering half.
1920
async fn follow_run_output(
1921
    client: &crate::box_client::BoxClient,
1922
    conversation: &str,
1923
    box_id: &str,
1924
    run_id: &str,
1925
    offset: Option<u64>,
1926
    interval_ms: u64,
1927
    json: bool,
1928
) {
1929
    use std::io::Write;
1930
1931
    let collected = std::cell::RefCell::new(String::new());
1932
    let announced = std::cell::Cell::new(false);
1933
    let followed = client
1934
        .follow_run_output(
1935
            conversation,
1936
            box_id,
1937
            run_id,
1938
            offset,
1939
            std::time::Duration::from_millis(interval_ms.max(50)),
1940
            |chunk| {
1941
                if chunk.truncated && !announced.get() {
1942
                    announced.set(true);
1943
                    if !json {
1944
                        println!("[EARLIER OUTPUT DROPPED BY THE BOX]");
1945
                    }
1946
                }
1947
                if chunk.output.is_empty() {
1948
                    return;
1949
                }
1950
                if json {
1951
                    collected.borrow_mut().push_str(&chunk.output);
1952
                } else {
1953
                    print!("{}", chunk.output);
1954
                    let _ = std::io::stdout().flush();
1955
                }
1956
            },
1957
        )
1958
        .await;
1959
    let (run, next_offset) = or_fail(followed);
1960
1961
    if json {
1962
        emit(
1963
            true,
1964
            &serde_json::json!({
1965
                "run": to_value(&run),
1966
                "output": collected.into_inner(),
1967
                "next_offset": next_offset,
1968
                "truncated": announced.get(),
1969
            }),
1970
            &[],
1971
        );
1972
    } else {
1973
        println!();
1974
        for line in run_view_human(&run) {
1975
            println!("{}", line);
1976
        }
1977
    }
1978
}
1979
1980
// ---------------------------------------------------------------------------
1981
// memory
1982
// ---------------------------------------------------------------------------
1983
1984
fn memory_list_human(memories: &[crate::memory_client::MemoryRecord]) -> Vec<String> {
1985
    if memories.is_empty() {
1986
        return vec!["No memories stored for this account.".to_string()];
1987
    }
1988
    // One memory per block rather than one per row: a memory is a sentence a
1989
    // person wrote, and a column would cut most of them off.
1990
    let mut lines = Vec::new();
1991
    for memory in memories {
1992
        lines.push(format!(
1993
            "{}  [{}]  {}",
1994
            memory.id, memory.bucket, memory.created_at
1995
        ));
1996
        lines.push(format!("  {}", memory.body));
1997
        if let Some(source) = &memory.source_ref {
1998
            lines.push(format!("  source: {}", source));
1999
        }
2000
        if let Some(replacement) = &memory.superseded_by {
2001
            lines.push(format!("  superseded by: {}", replacement));
2002
        }
2003
    }
2004
    lines
2005
}
2006
2007
async fn run_memory(action: MemoryAction, token: Option<String>, json: bool) {
2008
    let client = crate::memory_client::MemoryClient::new(API_BASE, token);
2009
    match action {
2010
        MemoryAction::List {
2011
            bucket,
2012
            limit,
2013
            include_superseded,
2014
        } => {
2015
            let memories = or_fail(
2016
                client
2017
                    .list_memories(bucket.as_deref(), limit, include_superseded)
2018
                    .await,
2019
            );
2020
            emit(
2021
                json,
2022
                &serde_json::json!({ "memories": to_value(&memories) }),
2023
                &memory_list_human(&memories),
2024
            );
2025
        }
2026
        MemoryAction::Add {
2027
            body,
2028
            bucket,
2029
            supersedes,
2030
            source_ref,
2031
        } => {
2032
            let text = body.join(" ");
2033
            let memory = or_fail(
2034
                client
2035
                    .add_memory(
2036
                        &text,
2037
                        bucket.as_deref(),
2038
                        supersedes.as_deref(),
2039
                        source_ref.as_deref(),
2040
                    )
2041
                    .await,
2042
            );
2043
            let mut human = vec![
2044
                format!(
2045
                    "Stored memory {} in the {} bucket.",
2046
                    memory.id, memory.bucket
2047
                ),
2048
                format!("  {}", memory.body),
2049
            ];
2050
            if let Some(replaced) = &supersedes {
2051
                human.push(format!("Supersedes {}.", replaced));
2052
            }
2053
            emit(
2054
                json,
2055
                &serde_json::json!({ "memory": to_value(&memory) }),
2056
                &human,
2057
            );
2058
        }
2059
        MemoryAction::Delete { memory_id } => {
2060
            let memory = or_fail(client.delete_memory(&memory_id).await);
2061
            emit(
2062
                json,
2063
                &serde_json::json!({ "memory": to_value(&memory) }),
2064
                &[
2065
                    format!("Removed memory {}.", memory.id),
2066
                    format!("  {}", memory.body),
2067
                ],
2068
            );
2069
        }
2070
    }
2071
}
2072
613 2073
// ---------------------------------------------------------------------------
614 2074
// identity
615 2075
// ---------------------------------------------------------------------------
crates/openagents-cli/src/memory_client.rs modified +184 -52

@@ -1,25 +1,64 @@

1
//! Real memory client for account-level knowledge and learned corrections
2
//! Communicates with `POST /api/v1/memories`, `GET /api/v1/memories`, `DELETE /api/v1/memories/:id`
1
//! The client for the account's cloud memories.
2
//!
3
//! The Rust port of `packages/openagents-cli/src/memory-client.ts`. Memories
4
//! live in the openagents.com database, account-scoped, not in a file on this
5
//! machine. Three calls are the whole surface — write one, read them back,
6
//! remove one — because that is what the store does. Nothing here recalls
7
//! anything; recall runs server-side inside `POST /api/v1/responses`.
8
//!
9
//! There is no update. A correction is a new memory carrying `supersedes`, so
10
//! the store keeps the chain a wrong memory was corrected through rather than
11
//! overwriting the row that was wrong.
3 12
4
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
13
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
5 14
use serde::{Deserialize, Serialize};
15
use serde_json::{json, Value};
6 16
7
#[derive(Debug, Clone, Serialize, Deserialize)]
17
use crate::tracker::{error_sentence, urlencode, ApiError};
18
19
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8 20
pub struct MemoryRecord {
9 21
    pub id: String,
10 22
    pub bucket: String,
11 23
    pub body: String,
24
    /// The thread or session the request came out of, when one was named.
12 25
    pub source_ref: Option<String>,
26
    /// The id of the memory that replaced this one, once one has.
13 27
    pub superseded_by: Option<String>,
14 28
    pub created_at: String,
15 29
}
16 30
31
/// The two buckets the server accepts. Checked here rather than left to the
32
/// API so a typo costs a sentence instead of a round trip.
33
pub fn read_bucket(raw: &str) -> Result<&'static str, ApiError> {
34
    match raw.trim().to_lowercase().as_str() {
35
        "user" => Ok("user"),
36
        "learned" => Ok("learned"),
37
        _ => Err(ApiError::Input(format!(
38
            "--bucket must be \"user\" or \"learned\", not \"{}\".",
39
            raw
40
        ))),
41
    }
42
}
43
17 44
pub struct MemoryClient {
18 45
    pub api_base: String,
19 46
    pub token: Option<String>,
20 47
    pub http: reqwest::Client,
21 48
}
22 49
50
fn parse_memory(value: &Value) -> MemoryRecord {
51
    let text = |key: &str| value.get(key).and_then(Value::as_str).map(String::from);
52
    MemoryRecord {
53
        id: text("id").unwrap_or_default(),
54
        bucket: text("bucket").unwrap_or_else(|| "user".to_string()),
55
        body: text("body").unwrap_or_default(),
56
        source_ref: text("source_ref"),
57
        superseded_by: text("superseded_by"),
58
        created_at: text("created_at").unwrap_or_default(),
59
    }
60
}
61
23 62
impl MemoryClient {
24 63
    pub fn new(api_base: &str, token: Option<String>) -> Self {
25 64
        Self {

@@ -32,6 +71,7 @@ impl MemoryClient {

32 71
    fn headers(&self) -> HeaderMap {
33 72
        let mut map = HeaderMap::new();
34 73
        map.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
74
        map.insert(ACCEPT, HeaderValue::from_static("application/json"));
35 75
        if let Some(tok) = &self.token {
36 76
            if let Ok(val) = HeaderValue::from_str(&format!("Bearer {}", tok)) {
37 77
                map.insert(AUTHORIZATION, val);

@@ -40,60 +80,152 @@ impl MemoryClient {

40 80
        map
41 81
    }
42 82
43
    pub async fn list_memories(&self, bucket: Option<&str>) -> Result<Vec<MemoryRecord>, Box<dyn std::error::Error + Send + Sync>> {
44
        let mut url = format!("{}/memories", self.api_base);
45
        if let Some(b) = bucket {
46
            url.push_str(&format!("?bucket={}", b));
83
    async fn request(
84
        &self,
85
        operation: &str,
86
        method: &str,
87
        path: &str,
88
        body: Option<Value>,
89
        accepted: &[u16],
90
    ) -> Result<Value, ApiError> {
91
        let url = format!("{}/{}", self.api_base, path.trim_start_matches('/'));
92
        let mut builder = match method {
93
            "GET" => self.http.get(&url),
94
            "POST" => self.http.post(&url),
95
            "DELETE" => self.http.delete(&url),
96
            other => {
97
                return Err(ApiError::Input(format!(
98
                    "{} is not an HTTP method this client sends.",
99
                    other
100
                )))
101
            }
102
        }
103
        .headers(self.headers());
104
        if let Some(payload) = body {
105
            builder = builder.json(&payload);
47 106
        }
48 107
49
        let resp = self.http.get(&url).headers(self.headers()).send().await?;
50
        if resp.status().is_success() {
51
            let body: serde_json::Value = resp.json().await?;
52
            let items = body.get("memories").and_then(|v| v.as_array()).cloned().unwrap_or_default();
53
            let mut records = Vec::new();
54
            for item in items {
55
                let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
56
                let bucket = item.get("bucket").and_then(|v| v.as_str()).unwrap_or("user").to_string();
57
                let body = item.get("body").and_then(|v| v.as_str()).unwrap_or("").to_string();
58
                let source_ref = item.get("source_ref").and_then(|v| v.as_str()).map(String::from);
59
                let superseded_by = item.get("superseded_by").and_then(|v| v.as_str()).map(String::from);
60
                let created_at = item.get("created_at").and_then(|v| v.as_str()).unwrap_or("").to_string();
61
                records.push(MemoryRecord {
62
                    id,
63
                    bucket,
64
                    body,
65
                    source_ref,
66
                    superseded_by,
67
                    created_at,
68
                });
69
            }
70
            Ok(records)
71
        } else {
72
            Ok(Vec::new())
108
        let response = builder.send().await.map_err(|e| ApiError::Transport {
109
            operation: operation.to_string(),
110
            why: e.to_string(),
111
        })?;
112
        let status = response.status().as_u16();
113
        let text = response.text().await.map_err(|e| ApiError::Transport {
114
            operation: operation.to_string(),
115
            why: e.to_string(),
116
        })?;
117
118
        if !accepted.contains(&status) {
119
            return Err(ApiError::Refused {
120
                operation: operation.to_string(),
121
                status,
122
                message: error_sentence(&text, status),
123
            });
73 124
        }
125
        serde_json::from_str(&text).map_err(|e| ApiError::Malformed {
126
            operation: operation.to_string(),
127
            why: e.to_string(),
128
        })
74 129
    }
75 130
76
    pub async fn add_memory(&self, body_text: &str, bucket: Option<&str>) -> Result<Option<MemoryRecord>, Box<dyn std::error::Error + Send + Sync>> {
77
        let url = format!("{}/memories", self.api_base);
78
        let resp = self.http.post(&url).headers(self.headers()).json(&serde_json::json!({
79
            "body": body_text,
80
            "bucket": bucket.unwrap_or("user")
81
        })).send().await?;
82
83
        if resp.status().is_success() {
84
            let item: serde_json::Value = resp.json().await?;
85
            let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
86
            let created_at = item.get("created_at").and_then(|v| v.as_str()).unwrap_or("").to_string();
87
            Ok(Some(MemoryRecord {
88
                id,
89
                bucket: bucket.unwrap_or("user").to_string(),
90
                body: body_text.to_string(),
91
                source_ref: None,
92
                superseded_by: None,
93
                created_at,
94
            }))
131
    pub async fn list_memories(
132
        &self,
133
        bucket: Option<&str>,
134
        limit: Option<u32>,
135
        include_superseded: bool,
136
    ) -> Result<Vec<MemoryRecord>, ApiError> {
137
        let mut query: Vec<String> = Vec::new();
138
        if let Some(name) = bucket {
139
            query.push(format!("bucket={}", urlencode(read_bucket(name)?)));
140
        }
141
        if let Some(count) = limit {
142
            query.push(format!("limit={}", count));
143
        }
144
        // The flag is only ever sent as `true`. Its absence is the default, and
145
        // sending `false` would read as a narrowing the server does not define.
146
        if include_superseded {
147
            query.push("include_superseded=true".to_string());
148
        }
149
        let path = if query.is_empty() {
150
            "memories".to_string()
95 151
        } else {
96
            Ok(None)
152
            format!("memories?{}", query.join("&"))
153
        };
154
155
        let body = self
156
            .request("list memories", "GET", &path, None, &[200])
157
            .await?;
158
        let rows = body
159
            .get("memories")
160
            .and_then(Value::as_array)
161
            .ok_or_else(|| ApiError::Malformed {
162
                operation: "list memories".to_string(),
163
                why: "no `memories` array in the response".to_string(),
164
            })?;
165
        Ok(rows.iter().map(parse_memory).collect())
166
    }
167
168
    pub async fn add_memory(
169
        &self,
170
        body_text: &str,
171
        bucket: Option<&str>,
172
        supersedes: Option<&str>,
173
        source_ref: Option<&str>,
174
    ) -> Result<MemoryRecord, ApiError> {
175
        if body_text.trim().is_empty() {
176
            return Err(ApiError::Input(
177
                "A memory needs a body to store.".to_string(),
178
            ));
179
        }
180
        // The server defaults an absent bucket to `user`, but a write path that
181
        // names its bucket keeps working if that default ever moves.
182
        let name = match bucket {
183
            Some(raw) => read_bucket(raw)?,
184
            None => "user",
185
        };
186
        let mut payload = json!({ "body": body_text, "bucket": name });
187
        if let Some(id) = supersedes {
188
            payload["supersedes"] = json!(id);
189
        }
190
        if let Some(reference) = source_ref {
191
            payload["source_ref"] = json!(reference);
192
        }
193
194
        let body = self
195
            .request("write memory", "POST", "memories", Some(payload), &[201])
196
            .await?;
197
        Ok(parse_memory(body.get("memory").unwrap_or(&body)))
198
    }
199
200
    /// Removes one memory outright, and returns the row the server removed.
201
    pub async fn delete_memory(&self, memory_id: &str) -> Result<MemoryRecord, ApiError> {
202
        if memory_id.trim().is_empty() {
203
            return Err(ApiError::Input(
204
                "Pass the id of the memory to remove.".to_string(),
205
            ));
97 206
        }
207
        let body = self
208
            .request(
209
                "remove memory",
210
                "DELETE",
211
                &format!("memories/{}", urlencode(memory_id.trim())),
212
                None,
213
                &[200],
214
            )
215
            .await?;
216
        Ok(parse_memory(body.get("memory").unwrap_or(&body)))
217
    }
218
}
219
220
#[cfg(test)]
221
mod tests {
222
    use super::*;
223
224
    #[test]
225
    fn a_bucket_the_server_does_not_define_is_refused_before_the_round_trip() {
226
        assert_eq!(read_bucket("USER").unwrap(), "user");
227
        assert_eq!(read_bucket(" learned ").unwrap(), "learned");
228
        let error = read_bucket("global").unwrap_err().to_string();
229
        assert!(error.contains("global"), "{error}");
98 230
    }
99 231
}
crates/openagents-cli/src/tracker.rs modified +847 -164

@@ -1,26 +1,276 @@

1
//! Real tracker client implementation for OpenAgents Issues, Projects, Comments, and Milestones
2
//! Talking to real `/api/v1` routes with authenticated requests
1
//! The tracker client: issues, projects, comments, labels, assignees,
2
//! milestones, and prerequisites.
3
//!
4
//! This is the Rust port of `packages/openagents-cli/src/tracker-request.ts`,
5
//! `issue-client.ts`, and `project-client.ts`. It keeps the same three
6
//! properties those files were written for:
7
//!
8
//! 1. The server's own body is what gets returned. Every route here answers a
9
//!    GitHub-compatible shape, so the client parses nothing it does not have to
10
//!    and `--json` prints exactly what the server sent.
11
//! 2. A status outside the accepted set is an error carrying the server's own
12
//!    message, never an empty list. An earlier version of this file answered
13
//!    every non-2xx with `Ok(Vec::new())`, so `oa project list` reported no
14
//!    projects — with exit status 0 — against a repository that has four,
15
//!    because it was asking for `/projects` and the route is `/projectsV2`.
16
//! 3. The list route publishes no `per_page`, so a limit above one page is only
17
//!    reachable by paging until the server's own total is covered.
3 18
4 19
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
5
use serde::{Deserialize, Serialize};
6
7
#[derive(Debug, Clone, Serialize, Deserialize)]
8
pub struct Issue {
9
    pub number: u64,
10
    pub title: String,
11
    pub state: String,
12
    pub body: Option<String>,
13
    pub author: Option<String>,
14
    #[serde(default)]
15
    pub labels: Vec<String>,
20
use serde_json::{json, Value};
21
use std::fmt;
22
23
/// The largest list the CLI will page for; the server holds 25 to a page.
24
pub const MAXIMUM_ISSUE_LIST_LIMIT: u32 = 1_000;
25
26
/// Why a tracker call did not produce data. Never a substitute for data.
27
#[derive(Debug)]
28
pub enum ApiError {
29
    /// The request never completed.
30
    Transport { operation: String, why: String },
31
    /// The server answered, and refused. Carries the status and its own message.
32
    Refused {
33
        operation: String,
34
        status: u16,
35
        message: String,
36
    },
37
    /// The server answered inside the accepted set with a body this cannot read.
38
    Malformed { operation: String, why: String },
39
    /// The caller asked for something the client will not send.
40
    Input(String),
41
}
42
43
impl fmt::Display for ApiError {
44
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45
        match self {
46
            Self::Transport { operation, why } => {
47
                write!(f, "Could not reach the API to {}: {}", operation, why)
48
            }
49
            Self::Refused {
50
                operation,
51
                status,
52
                message,
53
            } => write!(
54
                f,
55
                "The API refused the request to {} (HTTP {}): {}",
56
                operation, status, message
57
            ),
58
            Self::Malformed { operation, why } => write!(
59
                f,
60
                "The API returned an unreadable body for {}: {}",
61
                operation, why
62
            ),
63
            Self::Input(message) => write!(f, "{}", message),
64
        }
65
    }
66
}
67
68
impl std::error::Error for ApiError {}
69
70
/// Turns the unified error envelope into one sentence.
71
///
72
/// The deployment answers refusals in more than one shape: `{"message": …,
73
/// "errors": {field: [messages]}}` from the tracker routes, `{"error":
74
/// {"code": …}}` from the token guard, and `{"errors": {"detail": …}}` from the
75
/// router. All three carry something worth printing, so all three are read.
76
/// Nothing is invented: with no readable field, the sentence is the status.
77
pub fn error_sentence(body: &str, status: u16) -> String {
78
    let parsed: Value = match serde_json::from_str(body) {
79
        Ok(value) => value,
80
        // A non-JSON body is still the server's answer. Print it, bounded.
81
        Err(_) => {
82
            let trimmed = body.trim();
83
            return if trimmed.is_empty() {
84
                format!("The OpenAgents API returned HTTP {}.", status)
85
            } else {
86
                trimmed[..trimmed.len().min(400)].to_string()
87
            };
88
        }
89
    };
90
91
    let mut sentence = parsed
92
        .get("message")
93
        .and_then(Value::as_str)
94
        .map(String::from)
95
        .or_else(|| {
96
            parsed
97
                .get("error")
98
                .and_then(|e| e.get("message"))
99
                .and_then(Value::as_str)
100
                .map(String::from)
101
        })
102
        .or_else(|| {
103
            parsed
104
                .get("error")
105
                .and_then(|e| e.get("code"))
106
                .and_then(Value::as_str)
107
                .map(String::from)
108
        })
109
        .unwrap_or_else(|| format!("The OpenAgents API returned HTTP {}.", status));
110
111
    // `errors` is a field-to-messages map, so a rejected write names the field
112
    // it was rejected on instead of a bare status the caller has to reproduce.
113
    if let Some(fields) = parsed.get("errors").and_then(Value::as_object) {
114
        let rendered: Vec<String> = fields
115
            .iter()
116
            .map(|(field, messages)| format!("{}: {}", field, message_list(messages)))
117
            .collect();
118
        if !rendered.is_empty() {
119
            sentence = format!("{} ({})", sentence, rendered.join("; "));
120
        }
121
    }
122
123
    if let Some(request_id) = parsed.get("request_id").and_then(Value::as_str) {
124
        sentence = format!("{} [request {}]", sentence, request_id);
125
    }
126
    sentence
127
}
128
129
fn message_list(value: &Value) -> String {
130
    match value {
131
        Value::Array(items) => items
132
            .iter()
133
            .map(|item| match item {
134
                Value::String(text) => text.clone(),
135
                other => other.to_string(),
136
            })
137
            .collect::<Vec<_>>()
138
            .join(", "),
139
        Value::String(text) => text.clone(),
140
        other => other.to_string(),
141
    }
142
}
143
144
/// Percent-encode one path segment or query value.
145
pub fn urlencode(value: &str) -> String {
146
    let mut out = String::with_capacity(value.len());
147
    for byte in value.as_bytes() {
148
        match byte {
149
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
150
                out.push(*byte as char)
151
            }
152
            _ => out.push_str(&format!("%{:02X}", byte)),
153
        }
154
    }
155
    out
156
}
157
158
/// An owner and a repository, already split so neither is guessed downstream.
159
#[derive(Debug, Clone, PartialEq, Eq)]
160
pub struct RepoTarget {
161
    pub owner: String,
162
    pub repo: String,
163
}
164
165
impl RepoTarget {
166
    /// Reads `owner/repo`. Anything else is refused rather than half-parsed.
167
    pub fn parse(slug: &str) -> Result<Self, ApiError> {
168
        let trimmed = slug.trim().trim_end_matches(".git");
169
        let mut parts = trimmed.split('/').filter(|part| !part.is_empty());
170
        match (parts.next(), parts.next(), parts.next()) {
171
            (Some(owner), Some(repo), None) => Ok(Self {
172
                owner: owner.to_string(),
173
                repo: repo.to_string(),
174
            }),
175
            _ => Err(ApiError::Input(format!(
176
                "`{}` is not a repository. Pass -R owner/repo, such as -R OpenAgentsInc/openagents.",
177
                slug
178
            ))),
179
        }
180
    }
181
182
    fn path(&self) -> String {
183
        format!(
184
            "repos/{}/{}",
185
            urlencode(&self.owner),
186
            urlencode(&self.repo)
187
        )
188
    }
189
}
190
191
/// The repository a tracker command runs against.
192
///
193
/// `-R` wins. With no flag the checkout names it, the same way the TypeScript
194
/// CLI resolves it: whichever remote points at a forge or GitHub URL. With no
195
/// flag and no readable remote the command refuses — it does not fall back to
196
/// a repository the caller never named.
197
pub fn resolve_repo_target(flag: Option<&str>) -> Result<RepoTarget, ApiError> {
198
    if let Some(slug) = flag {
199
        return RepoTarget::parse(slug);
200
    }
201
    if let Some(slug) = repo_slug_from_git_remote() {
202
        return RepoTarget::parse(&slug);
203
    }
204
    Err(ApiError::Input(
205
        "No repository named. Pass -R owner/repo, or run inside a checkout whose \
206
         `openagents` or `origin` remote points at one."
207
            .to_string(),
208
    ))
209
}
210
211
/// Reads `owner/repo` out of this checkout's remotes, preferring the forge.
212
fn repo_slug_from_git_remote() -> Option<String> {
213
    for remote in ["openagents", "origin"] {
214
        let output = std::process::Command::new("git")
215
            .args(["remote", "get-url", remote])
216
            .output()
217
            .ok()?;
218
        if !output.status.success() {
219
            continue;
220
        }
221
        let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
222
        if let Some(slug) = slug_from_remote_url(&url) {
223
            return Some(slug);
224
        }
225
    }
226
    None
227
}
228
229
/// `https://openagents.com/Owner/repo.git` and `git@github.com:Owner/repo.git`
230
/// both name `Owner/repo`.
231
pub fn slug_from_remote_url(url: &str) -> Option<String> {
232
    // Strip the scheme, then any `user@`, so what is left starts at the host
233
    // in both forms. Each step falls back to what it was handed, not to the
234
    // original string.
235
    let after_scheme = match url.split_once("://") {
236
        Some((_, rest)) => rest,
237
        None => url,
238
    };
239
    let after_user = match after_scheme.rsplit_once('@') {
240
        Some((_, rest)) => rest,
241
        None => after_scheme,
242
    };
243
    // `host:owner/repo` for SSH, `host/owner/repo` for HTTPS.
244
    let path = match after_user.split_once(':') {
245
        Some((_, rest)) => rest,
246
        None => after_user.split_once('/').map(|(_, rest)| rest)?,
247
    };
248
    let cleaned = path.trim_matches('/').trim_end_matches(".git");
249
    let parts: Vec<&str> = cleaned.split('/').filter(|p| !p.is_empty()).collect();
250
    if parts.len() == 2 {
251
        Some(format!("{}/{}", parts[0], parts[1]))
252
    } else {
253
        None
254
    }
16 255
}
17 256
18
#[derive(Debug, Clone, Serialize, Deserialize)]
19
pub struct Project {
20
    pub number: u64,
21
    pub title: String,
22
    pub state: String,
23
    pub body: Option<String>,
257
/// What `oa issue list` asks for.
258
#[derive(Debug, Clone, Default)]
259
pub struct IssueListOptions {
260
    pub limit: u32,
261
    pub state: Option<String>,
262
    pub label: Option<String>,
263
    pub assignee: Option<String>,
264
    pub milestone: Option<String>,
265
    pub search: Option<String>,
266
    pub blocked: Option<bool>,
267
}
268
269
/// The rows the server sent, and the server's own pagination object.
270
#[derive(Debug, Clone)]
271
pub struct IssueListResult {
272
    pub pagination: Value,
273
    pub issues: Vec<Value>,
24 274
}
25 275
26 276
pub struct TrackerClient {

@@ -50,172 +300,605 @@ impl TrackerClient {

50 300
        map
51 301
    }
52 302
53
    pub async fn list_issues(&self, repo: &str) -> Result<Vec<Issue>, Box<dyn std::error::Error + Send + Sync>> {
54
        let repo_path = if repo.starts_with("repos/") {
55
            repo.to_string()
56
        } else {
57
            format!("repos/{}", repo)
58
        };
59
        let url = format!("{}/{}", self.api_base, repo_path);
60
        let url_issues = format!("{}/issues", url);
61
        let resp = self.http.get(&url_issues).headers(self.headers()).send().await?;
62
63
        if resp.status().is_success() {
64
            let body: serde_json::Value = resp.json().await?;
65
            let items = body.get("issues").and_then(|v| v.as_array()).cloned().unwrap_or_else(|| {
66
                if let Some(arr) = body.as_array() { arr.clone() } else { Vec::new() }
303
    /// One request, one accepted-status check, one failure translation.
304
    ///
305
    /// Everything below goes through here, which is what makes "a non-2xx is an
306
    /// error" a property of the client rather than a habit each method has to
307
    /// remember.
308
    pub async fn request(
309
        &self,
310
        operation: &str,
311
        method: &str,
312
        path: &str,
313
        body: Option<Value>,
314
        accepted: &[u16],
315
    ) -> Result<Value, ApiError> {
316
        let url = format!("{}/{}", self.api_base, path.trim_start_matches('/'));
317
        let mut builder = match method {
318
            "GET" => self.http.get(&url),
319
            "POST" => self.http.post(&url),
320
            "PATCH" => self.http.patch(&url),
321
            "PUT" => self.http.put(&url),
322
            "DELETE" => self.http.delete(&url),
323
            other => {
324
                return Err(ApiError::Input(format!(
325
                    "{} is not an HTTP method this client sends.",
326
                    other
327
                )))
328
            }
329
        }
330
        .headers(self.headers());
331
        if let Some(payload) = body {
332
            builder = builder.json(&payload);
333
        }
334
335
        let response = builder.send().await.map_err(|e| ApiError::Transport {
336
            operation: operation.to_string(),
337
            why: e.to_string(),
338
        })?;
339
        let status = response.status().as_u16();
340
        let text = response.text().await.map_err(|e| ApiError::Transport {
341
            operation: operation.to_string(),
342
            why: e.to_string(),
343
        })?;
344
345
        if !accepted.contains(&status) {
346
            return Err(ApiError::Refused {
347
                operation: operation.to_string(),
348
                status,
349
                message: error_sentence(&text, status),
67 350
            });
351
        }
352
        if text.trim().is_empty() {
353
            // A 204 carries no body, and that is the server's answer, not a
354
            // stand-in for one.
355
            return Ok(Value::Null);
356
        }
357
        serde_json::from_str(&text).map_err(|e| ApiError::Malformed {
358
            operation: operation.to_string(),
359
            why: e.to_string(),
360
        })
361
    }
362
363
    // ---------------------------------------------------------------- issues
364
365
    fn issues_path(target: &RepoTarget) -> String {
366
        format!("{}/issues", target.path())
367
    }
368
369
    fn issue_path(target: &RepoTarget, number: u64) -> String {
370
        format!("{}/issues/{}", target.path(), number)
371
    }
372
373
    /// Lists issues, paging until the limit or the server's own total is met.
374
    pub async fn list_issues(
375
        &self,
376
        target: &RepoTarget,
377
        options: &IssueListOptions,
378
    ) -> Result<IssueListResult, ApiError> {
379
        if options.limit < 1 {
380
            return Err(ApiError::Input(
381
                "--limit must be a positive integer.".to_string(),
382
            ));
383
        }
384
        if options.limit > MAXIMUM_ISSUE_LIST_LIMIT {
385
            return Err(ApiError::Input(format!(
386
                "--limit must be at most {}.",
387
                MAXIMUM_ISSUE_LIST_LIMIT
388
            )));
389
        }
390
391
        let mut collected: Vec<Value> = Vec::new();
392
        let mut pagination = Value::Null;
393
        let mut page = 1u32;
68 394
69
            let mut issues = Vec::new();
70
            for item in items {
71
                let number = item.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
72
                let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
73
                let state = item.get("state").and_then(|v| v.as_str()).unwrap_or("open").to_string();
74
                let body_text = item.get("body").and_then(|v| v.as_str()).map(String::from);
75
                let author = item.get("author").and_then(|v| v.get("login")).and_then(|v| v.as_str()).map(String::from)
76
                    .or_else(|| item.get("user").and_then(|v| v.get("login")).and_then(|v| v.as_str()).map(String::from));
77
                let labels = item.get("labels").and_then(|v| v.as_array())
78
                    .map(|arr| arr.iter().filter_map(|l| l.get("name").and_then(|n| n.as_str()).map(String::from)).collect())
79
                    .unwrap_or_default();
80
81
                issues.push(Issue {
82
                    number,
83
                    title,
84
                    state,
85
                    body: body_text,
86
                    author,
87
                    labels,
88
                });
395
        while (collected.len() as u32) < options.limit {
396
            let query = list_query(options, page);
397
            let body = self
398
                .request(
399
                    "list issues",
400
                    "GET",
401
                    &format!("{}?{}", Self::issues_path(target), query),
402
                    None,
403
                    &[200],
404
                )
405
                .await?;
406
            pagination = body.get("pagination").cloned().unwrap_or(Value::Null);
407
            let rows = body
408
                .get("issues")
409
                .and_then(Value::as_array)
410
                .cloned()
411
                .unwrap_or_default();
412
            let received = rows.len();
413
            collected.extend(rows);
414
            if received == 0 {
415
                break;
89 416
            }
90
            Ok(issues)
91
        } else {
92
            Ok(Vec::new())
417
            if let Some(total) = pagination.get("total").and_then(Value::as_u64) {
418
                if collected.len() as u64 >= total {
419
                    break;
420
                }
421
            }
422
            if let Some(total_pages) = pagination.get("total_pages").and_then(Value::as_u64) {
423
                if page as u64 >= total_pages {
424
                    break;
425
                }
426
            }
427
            page += 1;
93 428
        }
429
430
        collected.truncate(options.limit as usize);
431
        Ok(IssueListResult {
432
            pagination,
433
            issues: collected,
434
        })
94 435
    }
95 436
96
    pub async fn get_issue(&self, repo: &str, number: u64) -> Result<Option<Issue>, Box<dyn std::error::Error + Send + Sync>> {
97
        let repo_path = if repo.starts_with("repos/") {
98
            repo.to_string()
99
        } else {
100
            format!("repos/{}", repo)
101
        };
102
        let url = format!("{}/{}/issues/{}", self.api_base, repo_path, number);
103
        let resp = self.http.get(&url).headers(self.headers()).send().await?;
104
105
        if resp.status().is_success() {
106
            let item: serde_json::Value = resp.json().await?;
107
            let number = item.get("number").and_then(|v| v.as_u64()).unwrap_or(number);
108
            let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
109
            let state = item.get("state").and_then(|v| v.as_str()).unwrap_or("open").to_string();
110
            let body_text = item.get("body").and_then(|v| v.as_str()).map(String::from);
111
            let author = item.get("author").and_then(|v| v.get("login")).and_then(|v| v.as_str()).map(String::from)
112
                .or_else(|| item.get("user").and_then(|v| v.get("login")).and_then(|v| v.as_str()).map(String::from));
113
            let labels = item.get("labels").and_then(|v| v.as_array())
114
                .map(|arr| arr.iter().filter_map(|l| l.get("name").and_then(|n| n.as_str()).map(String::from)).collect())
115
                .unwrap_or_default();
437
    pub async fn view_issue(&self, target: &RepoTarget, number: u64) -> Result<Value, ApiError> {
438
        self.request(
439
            "view an issue",
440
            "GET",
441
            &Self::issue_path(target, number),
442
            None,
443
            &[200],
444
        )
445
        .await
446
    }
116 447
117
            Ok(Some(Issue {
118
                number,
119
                title,
120
                state,
121
                body: body_text,
122
                author,
123
                labels,
124
            }))
125
        } else {
126
            Ok(None)
448
    pub async fn create_issue(
449
        &self,
450
        target: &RepoTarget,
451
        title: &str,
452
        body: Option<&str>,
453
        labels: &[String],
454
        assignees: &[String],
455
        milestone: Option<u64>,
456
    ) -> Result<Value, ApiError> {
457
        let mut payload = json!({ "title": title });
458
        if let Some(text) = body {
459
            payload["body"] = json!(text);
460
        }
461
        if !labels.is_empty() {
462
            payload["labels"] = json!(labels);
127 463
        }
464
        if !assignees.is_empty() {
465
            payload["assignees"] = json!(assignees);
466
        }
467
        if let Some(number) = milestone {
468
            payload["milestone"] = json!(number);
469
        }
470
        self.request(
471
            "create an issue",
472
            "POST",
473
            &Self::issues_path(target),
474
            Some(payload),
475
            &[201],
476
        )
477
        .await
128 478
    }
129 479
130
    pub async fn create_issue(&self, repo: &str, title: &str, body: Option<&str>) -> Result<Option<Issue>, Box<dyn std::error::Error + Send + Sync>> {
131
        let repo_path = if repo.starts_with("repos/") {
132
            repo.to_string()
133
        } else {
134
            format!("repos/{}", repo)
135
        };
136
        let url = format!("{}/{}/issues", self.api_base, repo_path);
137
        let mut payload = serde_json::json!({
138
            "title": title,
139
        });
140
        if let Some(b) = body {
141
            payload["body"] = serde_json::json!(b);
142
        }
143
144
        let resp = self.http.post(&url).headers(self.headers()).json(&payload).send().await?;
145
        if resp.status().is_success() {
146
            let item: serde_json::Value = resp.json().await?;
147
            let number = item.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
148
            let state = item.get("state").and_then(|v| v.as_str()).unwrap_or("open").to_string();
149
            Ok(Some(Issue {
150
                number,
151
                title: title.to_string(),
152
                state,
153
                body: body.map(String::from),
154
                author: None,
155
                labels: Vec::new(),
156
            }))
157
        } else {
158
            Ok(None)
480
    /// A `PATCH` carrying `body` replaces the issue text, so a state change
481
    /// sends `state` and nothing else.
482
    pub async fn set_issue_state(
483
        &self,
484
        target: &RepoTarget,
485
        number: u64,
486
        state: &str,
487
    ) -> Result<Value, ApiError> {
488
        self.request(
489
            "change issue state",
490
            "PATCH",
491
            &Self::issue_path(target, number),
492
            Some(json!({ "state": state })),
493
            &[200],
494
        )
495
        .await
496
    }
497
498
    pub async fn list_comments(&self, target: &RepoTarget, number: u64) -> Result<Value, ApiError> {
499
        self.request(
500
            "list issue comments",
501
            "GET",
502
            &format!("{}/comments", Self::issue_path(target, number)),
503
            None,
504
            &[200],
505
        )
506
        .await
507
    }
508
509
    pub async fn comment_issue(
510
        &self,
511
        target: &RepoTarget,
512
        number: u64,
513
        body: &str,
514
    ) -> Result<Value, ApiError> {
515
        self.request(
516
            "comment on an issue",
517
            "POST",
518
            &format!("{}/comments", Self::issue_path(target, number)),
519
            Some(json!({ "body": body })),
520
            &[201],
521
        )
522
        .await
523
    }
524
525
    pub async fn list_labels(&self, target: &RepoTarget, number: u64) -> Result<Value, ApiError> {
526
        self.request(
527
            "list issue labels",
528
            "GET",
529
            &format!("{}/labels", Self::issue_path(target, number)),
530
            None,
531
            &[200],
532
        )
533
        .await
534
    }
535
536
    pub async fn add_labels(
537
        &self,
538
        target: &RepoTarget,
539
        number: u64,
540
        labels: &[String],
541
    ) -> Result<Value, ApiError> {
542
        self.request(
543
            "label an issue",
544
            "POST",
545
            &format!("{}/labels", Self::issue_path(target, number)),
546
            Some(json!({ "labels": labels })),
547
            &[200, 201],
548
        )
549
        .await
550
    }
551
552
    pub async fn remove_label(
553
        &self,
554
        target: &RepoTarget,
555
        number: u64,
556
        label: &str,
557
    ) -> Result<Value, ApiError> {
558
        self.request(
559
            "remove an issue label",
560
            "DELETE",
561
            &format!(
562
                "{}/labels/{}",
563
                Self::issue_path(target, number),
564
                urlencode(label)
565
            ),
566
            None,
567
            &[200],
568
        )
569
        .await
570
    }
571
572
    pub async fn list_assignees(&self, target: &RepoTarget, number: u64) -> Result<Value, ApiError> {
573
        self.request(
574
            "list issue assignees",
575
            "GET",
576
            &format!("{}/assignees", Self::issue_path(target, number)),
577
            None,
578
            &[200],
579
        )
580
        .await
581
    }
582
583
    pub async fn add_assignees(
584
        &self,
585
        target: &RepoTarget,
586
        number: u64,
587
        assignees: &[String],
588
    ) -> Result<Value, ApiError> {
589
        self.request(
590
            "assign an issue",
591
            "POST",
592
            &format!("{}/assignees", Self::issue_path(target, number)),
593
            Some(json!({ "assignees": assignees })),
594
            &[200, 201],
595
        )
596
        .await
597
    }
598
599
    /// The route reads the logins from a body rather than the path, so this
600
    /// `DELETE` carries one.
601
    pub async fn remove_assignees(
602
        &self,
603
        target: &RepoTarget,
604
        number: u64,
605
        assignees: &[String],
606
    ) -> Result<Value, ApiError> {
607
        self.request(
608
            "unassign an issue",
609
            "DELETE",
610
            &format!("{}/assignees", Self::issue_path(target, number)),
611
            Some(json!({ "assignees": assignees })),
612
            &[200],
613
        )
614
        .await
615
    }
616
617
    pub async fn dependencies(&self, target: &RepoTarget, number: u64) -> Result<Value, ApiError> {
618
        self.request(
619
            "read issue prerequisites",
620
            "GET",
621
            &format!("{}/dependencies", Self::issue_path(target, number)),
622
            None,
623
            &[200],
624
        )
625
        .await
626
    }
627
628
    pub async fn add_dependencies(
629
        &self,
630
        target: &RepoTarget,
631
        number: u64,
632
        blocked_by: &[u64],
633
    ) -> Result<Value, ApiError> {
634
        self.request(
635
            "add issue prerequisites",
636
            "POST",
637
            &format!("{}/dependencies", Self::issue_path(target, number)),
638
            Some(json!({ "blocked_by": blocked_by })),
639
            &[200, 201],
640
        )
641
        .await
642
    }
643
644
    /// The prerequisite is a path segment here, not a body key.
645
    pub async fn remove_dependency(
646
        &self,
647
        target: &RepoTarget,
648
        number: u64,
649
        blocked_by: u64,
650
    ) -> Result<Value, ApiError> {
651
        self.request(
652
            "remove an issue prerequisite",
653
            "DELETE",
654
            &format!(
655
                "{}/dependencies/{}",
656
                Self::issue_path(target, number),
657
                blocked_by
658
            ),
659
            None,
660
            &[200],
661
        )
662
        .await
663
    }
664
665
    // ------------------------------------------------------------ milestones
666
667
    pub async fn list_milestones(&self, target: &RepoTarget) -> Result<Value, ApiError> {
668
        self.request(
669
            "list milestones",
670
            "GET",
671
            &format!("{}/milestones", target.path()),
672
            None,
673
            &[200],
674
        )
675
        .await
676
    }
677
678
    pub async fn create_milestone(
679
        &self,
680
        target: &RepoTarget,
681
        title: &str,
682
        description: Option<&str>,
683
        due_on: Option<&str>,
684
    ) -> Result<Value, ApiError> {
685
        let mut payload = json!({ "title": title });
686
        if let Some(text) = description {
687
            payload["description"] = json!(text);
688
        }
689
        if let Some(when) = due_on {
690
            payload["due_on"] = json!(when);
159 691
        }
692
        self.request(
693
            "create a milestone",
694
            "POST",
695
            &format!("{}/milestones", target.path()),
696
            Some(payload),
697
            &[201],
698
        )
699
        .await
160 700
    }
161 701
162
    pub async fn close_issue(&self, repo: &str, number: u64) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
163
        let repo_path = if repo.starts_with("repos/") {
164
            repo.to_string()
165
        } else {
166
            format!("repos/{}", repo)
167
        };
168
        let url = format!("{}/{}/issues/{}", self.api_base, repo_path, number);
169
        let resp = self.http.patch(&url).headers(self.headers()).json(&serde_json::json!({
170
            "state": "closed"
171
        })).send().await?;
172
        Ok(resp.status().is_success())
702
    pub async fn delete_milestone(
703
        &self,
704
        target: &RepoTarget,
705
        number: u64,
706
    ) -> Result<Value, ApiError> {
707
        self.request(
708
            "delete a milestone",
709
            "DELETE",
710
            &format!("{}/milestones/{}", target.path(), number),
711
            None,
712
            &[200, 204],
713
        )
714
        .await
173 715
    }
174 716
175
    pub async fn comment_issue(&self, repo: &str, number: u64, body: &str) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
176
        let repo_path = if repo.starts_with("repos/") {
177
            repo.to_string()
178
        } else {
179
            format!("repos/{}", repo)
180
        };
181
        let url = format!("{}/{}/issues/{}/comments", self.api_base, repo_path, number);
182
        let resp = self.http.post(&url).headers(self.headers()).json(&serde_json::json!({
183
            "body": body
184
        })).send().await?;
185
        Ok(resp.status().is_success())
717
    // -------------------------------------------------------------- projects
718
719
    /// The route is `projectsV2`. `projects` does not exist, and asking for it
720
    /// is what made this command report an empty board list for years.
721
    fn projects_path(target: &RepoTarget) -> String {
722
        format!("{}/projectsV2", target.path())
723
    }
724
725
    fn project_path(target: &RepoTarget, number: u64) -> String {
726
        format!("{}/{}", Self::projects_path(target), number)
186 727
    }
187 728
188
    pub async fn list_projects(&self, repo: &str) -> Result<Vec<Project>, Box<dyn std::error::Error + Send + Sync>> {
189
        let repo_path = if repo.starts_with("repos/") {
190
            repo.to_string()
729
    fn item_path(target: &RepoTarget, number: u64, item: &str) -> String {
730
        format!(
731
            "{}/items/{}",
732
            Self::project_path(target, number),
733
            urlencode(item)
734
        )
735
    }
736
737
    pub async fn list_projects(
738
        &self,
739
        target: &RepoTarget,
740
        archived: bool,
741
    ) -> Result<Value, ApiError> {
742
        let path = if archived {
743
            format!("{}?archived=true", Self::projects_path(target))
191 744
        } else {
192
            format!("repos/{}", repo)
745
            Self::projects_path(target)
193 746
        };
194
        let url = format!("{}/{}/projects", self.api_base, repo_path);
195
        let resp = self.http.get(&url).headers(self.headers()).send().await?;
747
        self.request("list projects", "GET", &path, None, &[200])
748
            .await
749
    }
196 750
197
        if resp.status().is_success() {
198
            let body: serde_json::Value = resp.json().await?;
199
            let items = body.get("projects").and_then(|v| v.as_array()).cloned().unwrap_or_else(|| {
200
                if let Some(arr) = body.as_array() { arr.clone() } else { Vec::new() }
201
            });
751
    pub async fn view_project(&self, target: &RepoTarget, number: u64) -> Result<Value, ApiError> {
752
        self.request(
753
            "view a project",
754
            "GET",
755
            &Self::project_path(target, number),
756
            None,
757
            &[200],
758
        )
759
        .await
760
    }
202 761
203
            let mut projects = Vec::new();
204
            for item in items {
205
                let number = item.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
206
                let title = item.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
207
                let state = item.get("state").and_then(|v| v.as_str()).unwrap_or("open").to_string();
208
                let body_text = item.get("description").or_else(|| item.get("body")).and_then(|v| v.as_str()).map(String::from);
209
                projects.push(Project {
210
                    number,
211
                    title,
212
                    state,
213
                    body: body_text,
214
                });
215
            }
216
            Ok(projects)
217
        } else {
218
            Ok(Vec::new())
762
    pub async fn create_project(
763
        &self,
764
        target: &RepoTarget,
765
        title: &str,
766
        description: Option<&str>,
767
    ) -> Result<Value, ApiError> {
768
        let mut payload = json!({ "title": title });
769
        if let Some(text) = description {
770
            payload["description"] = json!(text);
771
        }
772
        self.request(
773
            "create a project",
774
            "POST",
775
            &Self::projects_path(target),
776
            Some(payload),
777
            &[201],
778
        )
779
        .await
780
    }
781
782
    pub async fn project_fields(&self, target: &RepoTarget, number: u64) -> Result<Value, ApiError> {
783
        self.request(
784
            "list project fields",
785
            "GET",
786
            &format!("{}/fields", Self::project_path(target, number)),
787
            None,
788
            &[200],
789
        )
790
        .await
791
    }
792
793
    pub async fn project_items(&self, target: &RepoTarget, number: u64) -> Result<Value, ApiError> {
794
        self.request(
795
            "list project items",
796
            "GET",
797
            &format!("{}/items", Self::project_path(target, number)),
798
            None,
799
            &[200],
800
        )
801
        .await
802
    }
803
804
    /// A repeated add answers 200 with the membership the board already has, so
805
    /// both statuses are the same success.
806
    pub async fn project_add_item(
807
        &self,
808
        target: &RepoTarget,
809
        number: u64,
810
        issue_number: u64,
811
    ) -> Result<Value, ApiError> {
812
        self.request(
813
            "add a project item",
814
            "POST",
815
            &format!("{}/items", Self::project_path(target, number)),
816
            Some(json!({ "issue_number": issue_number })),
817
            &[200, 201],
818
        )
819
        .await
820
    }
821
822
    pub async fn project_set_item_values(
823
        &self,
824
        target: &RepoTarget,
825
        number: u64,
826
        item: &str,
827
        values: &Value,
828
    ) -> Result<Value, ApiError> {
829
        self.request(
830
            "set project item values",
831
            "PATCH",
832
            &Self::item_path(target, number, item),
833
            Some(json!({ "values": values })),
834
            &[200],
835
        )
836
        .await
837
    }
838
839
    pub async fn project_move_item(
840
        &self,
841
        target: &RepoTarget,
842
        number: u64,
843
        item: &str,
844
        values: &Value,
845
        position: Option<u64>,
846
    ) -> Result<Value, ApiError> {
847
        let mut payload = json!({ "values": values });
848
        if let Some(rank) = position {
849
            payload["position"] = json!(rank);
219 850
        }
851
        self.request(
852
            "move a project item",
853
            "POST",
854
            &format!("{}/move", Self::item_path(target, number, item)),
855
            Some(payload),
856
            &[200],
857
        )
858
        .await
859
    }
860
861
    pub async fn project_remove_item(
862
        &self,
863
        target: &RepoTarget,
864
        number: u64,
865
        item: &str,
866
    ) -> Result<Value, ApiError> {
867
        self.request(
868
            "remove a project item",
869
            "DELETE",
870
            &Self::item_path(target, number, item),
871
            None,
872
            &[200, 204],
873
        )
874
        .await
875
    }
876
}
877
878
/// The list route names its search parameter `q` and its label parameter
879
/// `labels`; the flags read the way a person says them.
880
fn list_query(options: &IssueListOptions, page: u32) -> String {
881
    let mut parts = vec![
882
        format!(
883
            "state={}",
884
            urlencode(options.state.as_deref().unwrap_or("open"))
885
        ),
886
        format!("page={}", page),
887
    ];
888
    if let Some(label) = &options.label {
889
        parts.push(format!("labels={}", urlencode(label)));
890
    }
891
    if let Some(assignee) = &options.assignee {
892
        parts.push(format!("assignee={}", urlencode(assignee)));
893
    }
894
    if let Some(milestone) = &options.milestone {
895
        parts.push(format!("milestone={}", urlencode(milestone)));
896
    }
897
    if let Some(search) = &options.search {
898
        parts.push(format!("q={}", urlencode(search)));
899
    }
900
    if let Some(blocked) = options.blocked {
901
        parts.push(format!("blocked={}", blocked));
220 902
    }
903
    parts.join("&")
221 904
}
crates/openagents-cli/tests/box_follow_test.rs added +170

@@ -0,0 +1,170 @@

1
//! The Box run follow loop, against a stub that behaves the way a box does.
2
//!
3
//! Issue #78 asks for streaming output while a run is still going. The route
4
//! publishes no event stream, so following is a poll over `?offset=`, and the
5
//! two ways that goes wrong are silent: a reader that never advances its offset
6
//! prints the first window forever, and a reader that stops at the first
7
//! terminal state drops whatever the box wrote last. Both produce output that
8
//! looks like a finished run.
9
//!
10
//! So the stub writes in three windows, only turns terminal after the second,
11
//! and writes the third *after* going terminal. A reader that gets any of that
12
//! wrong produces the wrong string, and this fails.
13
14
use openagents_cli::box_client::BoxClient;
15
use std::sync::atomic::{AtomicUsize, Ordering};
16
use std::sync::Arc;
17
use tokio::io::{AsyncReadExt, AsyncWriteExt};
18
19
struct Stub {
20
    base: String,
21
}
22
23
/// The windows the box "writes", in order, keyed by the offset a reader must
24
/// ask for to see them. Reading at an offset the stub has not reached yet
25
/// yields an empty window, the way a live box's bounded log does.
26
const WINDOWS: [&str; 3] = ["first ", "second ", "third"];
27
28
async fn start_stub() -> Stub {
29
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
30
    let port = listener.local_addr().unwrap().port();
31
    let base = format!("http://127.0.0.1:{port}/api/v1");
32
    // How many output reads have happened. The run turns terminal once the
33
    // reader has taken the first two windows, and the third is written only
34
    // after that — a reader that stops at the terminal state loses it.
35
    let reads = Arc::new(AtomicUsize::new(0));
36
37
    tokio::spawn(async move {
38
        loop {
39
            let Ok((mut socket, _)) = listener.accept().await else {
40
                return;
41
            };
42
            let request = match read_request(&mut socket).await {
43
                Some(request) => request,
44
                None => continue,
45
            };
46
            let first_line = request.lines().next().unwrap_or("").to_string();
47
48
            let body = if first_line.contains("/output") {
49
                let offset = offset_of(&first_line);
50
                let index = WINDOWS
51
                    .iter()
52
                    .scan(0usize, |acc, window| {
53
                        let start = *acc;
54
                        *acc += window.len();
55
                        Some(start)
56
                    })
57
                    .position(|start| start == offset);
58
                let taken = reads.load(Ordering::SeqCst);
59
                match index {
60
                    // A window the box has not produced yet reads as empty and
61
                    // does not advance the offset.
62
                    Some(i) if i <= taken => {
63
                        reads.store(taken.max(i + 1), Ordering::SeqCst);
64
                        let next = offset + WINDOWS[i].len();
65
                        format!(
66
                            r#"{{"run_id":"run_1","output":{{"output":"{}","next_offset":{},"truncated":false}}}}"#,
67
                            WINDOWS[i], next
68
                        )
69
                    }
70
                    _ => format!(
71
                        r#"{{"run_id":"run_1","output":{{"output":"","next_offset":{},"truncated":false}}}}"#,
72
                        offset
73
                    ),
74
                }
75
            } else {
76
                // The run reports `running` until both of the first two windows
77
                // have been read, then `succeeded`.
78
                let state = if reads.load(Ordering::SeqCst) >= 2 {
79
                    "succeeded"
80
                } else {
81
                    "running"
82
                };
83
                format!(
84
                    r#"{{"run":{{"id":"run_1","box_id":"bx_1","command":"echo","state":"{state}","exit_status":0}}}}"#
85
                )
86
            };
87
88
            let response = format!(
89
                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
90
                body.len()
91
            );
92
            let _ = socket.write_all(response.as_bytes()).await;
93
            let _ = socket.flush().await;
94
        }
95
    });
96
97
    Stub { base }
98
}
99
100
fn offset_of(request_line: &str) -> usize {
101
    request_line
102
        .split_once("offset=")
103
        .and_then(|(_, rest)| {
104
            rest.split(|c: char| !c.is_ascii_digit())
105
                .next()
106
                .and_then(|digits| digits.parse().ok())
107
        })
108
        .unwrap_or(0)
109
}
110
111
async fn read_request(socket: &mut tokio::net::TcpStream) -> Option<String> {
112
    let mut buffer = vec![0u8; 8192];
113
    let read = socket.read(&mut buffer).await.ok()?;
114
    if read == 0 {
115
        return None;
116
    }
117
    Some(String::from_utf8_lossy(&buffer[..read]).to_string())
118
}
119
120
/// Following a run reads every window in order, including the one written after
121
/// the run turned terminal.
122
#[tokio::test]
123
async fn following_a_run_reads_past_the_first_window_and_past_the_terminal_state() {
124
    let stub = start_stub().await;
125
    let client = BoxClient::new(&stub.base, None);
126
127
    let seen = std::cell::RefCell::new(String::new());
128
    let (run, next_offset) = client
129
        .follow_run_output(
130
            "conv_1",
131
            "bx_1",
132
            "run_1",
133
            Some(0),
134
            std::time::Duration::from_millis(1),
135
            |chunk| seen.borrow_mut().push_str(&chunk.output),
136
        )
137
        .await
138
        .expect("the follow failed");
139
140
    assert_eq!(
141
        seen.into_inner(),
142
        "first second third",
143
        "the follow must read every window, in order, including the one the box \
144
         wrote after the run turned terminal"
145
    );
146
    assert_eq!(run.state, "succeeded");
147
    assert!(run.finished());
148
    assert_eq!(next_offset, 18);
149
}
150
151
/// A refused read ends the follow. It does not return the bytes read so far as
152
/// though they were the whole run.
153
#[tokio::test]
154
async fn a_refused_read_ends_the_follow_rather_than_truncating_it() {
155
    let client = BoxClient::new("http://127.0.0.1:1/api/v1", None);
156
    let result = client
157
        .follow_run_output(
158
            "conv_1",
159
            "bx_1",
160
            "run_1",
161
            Some(0),
162
            std::time::Duration::from_millis(1),
163
            |_| {},
164
        )
165
        .await;
166
    assert!(
167
        result.is_err(),
168
        "an unreachable box must not produce a finished run"
169
    );
170
}
crates/openagents-cli/tests/cli_test.rs modified +173 -8

@@ -8,12 +8,12 @@ mod tests {

8 8
    use openagents_cli::tools::{HarnessToolRegistry, ToolCall};
9 9
    use openagents_cli::auth::CredentialStore;
10 10
    use openagents_cli::identity::{derive_seed_identity, SeedStore};
11
    use openagents_cli::tracker::TrackerClient;
11
    use openagents_cli::tracker::{slug_from_remote_url, IssueListOptions, RepoTarget, TrackerClient};
12 12
    use openagents_cli::repo::handle_git_credential;
13 13
    use openagents_cli::box_client::BoxClient;
14 14
    use openagents_cli::computer::probe_host;
15 15
    use openagents_cli::forum::ForumClient;
16
    use openagents_cli::memory_client::MemoryClient;
16
    use openagents_cli::memory_client::{read_bucket, MemoryClient};
17 17
    use openagents_cli::api_passthrough::ApiPassthroughClient;
18 18
    use openagents_cli::trace::{default_trace_stores, redact_text};
19 19

@@ -46,11 +46,112 @@ mod tests {

46 46
        assert!(store.identity().is_err());
47 47
    }
48 48
49
    /// The old assertion was `issues.is_empty() || !issues.is_empty()`, which is
50
    /// true of every value of every list and so held while the client asked for
51
    /// a route that does not exist and answered the refusal with `Ok(vec![])`.
52
    /// These assert the two things that were actually broken: that paging
53
    /// crosses the server's 25-row page, and that the row is the server's row.
49 54
    #[tokio::test]
50 55
    async fn test_tracker_client_issue_76() {
51 56
        let client = TrackerClient::new("https://openagents.com/api/v1", None);
52
        let issues = client.list_issues("OpenAgentsInc/openagents").await.unwrap();
53
        assert!(issues.is_empty() || !issues.is_empty());
57
        let target = RepoTarget::parse("OpenAgentsInc/openagents").unwrap();
58
        let options = IssueListOptions {
59
            limit: 30,
60
            state: Some("closed".to_string()),
61
            ..IssueListOptions::default()
62
        };
63
        let result = client.list_issues(&target, &options).await.unwrap();
64
65
        // The route holds 25 to a page and publishes no `per_page`, so 30 rows
66
        // is only reachable by asking for the second page.
67
        assert_eq!(
68
            result.issues.len(),
69
            30,
70
            "a limit above one page must page; got {} rows",
71
            result.issues.len()
72
        );
73
        let total = result
74
            .pagination
75
            .get("total")
76
            .and_then(|v| v.as_u64())
77
            .expect("the server sends its own pagination total");
78
        assert!(total >= 30, "total was {total}");
79
        let first = &result.issues[0];
80
        assert!(first.get("number").and_then(|v| v.as_u64()).unwrap_or(0) > 0);
81
        assert_eq!(
82
            first.get("state").and_then(|v| v.as_str()),
83
            Some("closed"),
84
            "--state closed must reach the server, not be dropped"
85
        );
86
        assert!(!result.issues[0]
87
            .get("title")
88
            .and_then(|v| v.as_str())
89
            .unwrap_or("")
90
            .is_empty());
91
    }
92
93
    /// `projectsV2` is the route. `projects` is what the client used to ask for,
94
    /// and the 4xx it earned was returned as an empty list — so this repository's
95
    /// four boards read as none, with exit status 0.
96
    #[tokio::test]
97
    async fn test_tracker_lists_the_projects_the_repository_has() {
98
        let client = TrackerClient::new("https://openagents.com/api/v1", None);
99
        let target = RepoTarget::parse("OpenAgentsInc/openagents").unwrap();
100
        let value = client.list_projects(&target, false).await.unwrap();
101
        let boards = value
102
            .get("projects")
103
            .and_then(|v| v.as_array())
104
            .expect("the response carries a `projects` array");
105
        assert!(
106
            boards.len() >= 4,
107
            "the live repository has at least four boards; got {}",
108
            boards.len()
109
        );
110
        assert!(boards
111
            .iter()
112
            .any(|b| b.get("number").and_then(|n| n.as_u64()) == Some(4)));
113
    }
114
115
    /// A route that does not exist must produce an error. This is the assertion
116
    /// the empty-vector fallback made unwritable, and it needs no live data.
117
    #[tokio::test]
118
    async fn test_tracker_refuses_rather_than_reporting_an_empty_repository() {
119
        let client = TrackerClient::new("https://openagents.com/api/v1/no-such-surface", None);
120
        let target = RepoTarget::parse("OpenAgentsInc/openagents").unwrap();
121
        let options = IssueListOptions {
122
            limit: 5,
123
            ..IssueListOptions::default()
124
        };
125
        let listed = client.list_issues(&target, &options).await;
126
        assert!(
127
            listed.is_err(),
128
            "a refused list must not yield rows, got {:?}",
129
            listed.ok().map(|r| r.issues.len())
130
        );
131
        let projects = client.list_projects(&target, false).await;
132
        assert!(projects.is_err(), "a refused project list must not yield an empty board set");
133
    }
134
135
    /// `-R` is parsed, not guessed; a slug that is not `owner/repo` is refused.
136
    #[test]
137
    fn test_tracker_repo_target_parsing() {
138
        assert_eq!(
139
            RepoTarget::parse("OpenAgentsInc/openagents").unwrap(),
140
            RepoTarget {
141
                owner: "OpenAgentsInc".to_string(),
142
                repo: "openagents".to_string()
143
            }
144
        );
145
        assert!(RepoTarget::parse("openagents").is_err());
146
        assert!(RepoTarget::parse("a/b/c").is_err());
147
        assert_eq!(
148
            slug_from_remote_url("https://openagents.com/OpenAgentsInc/openagents.git").as_deref(),
149
            Some("OpenAgentsInc/openagents")
150
        );
151
        assert_eq!(
152
            slug_from_remote_url("git@github.com:OpenAgentsInc/openagents.git").as_deref(),
153
            Some("OpenAgentsInc/openagents")
154
        );
54 155
    }
55 156
56 157
    #[test]

@@ -59,11 +160,48 @@ mod tests {

59 160
        assert!(cred_str.contains("username=openagents-token"));
60 161
    }
61 162
163
    /// The old assertion was `boxes.is_empty() || !boxes.is_empty()` against the
164
    /// literal conversation `main`, which is not a conversation id. It held
165
    /// because the client answered the resulting non-2xx with an empty vector —
166
    /// so a caller could not tell "no boxes" from "the request was refused",
167
    /// against a surface with a hard two-box quota. Boxes are billed cloud VMs,
168
    /// so this asserts the refusal rather than provisioning one.
62 169
    #[tokio::test]
63 170
    async fn test_box_client_issue_78() {
64 171
        let client = BoxClient::new("https://openagents.com/api/v1", None);
65
        let boxes = client.list_boxes("main").await.unwrap();
66
        assert!(boxes.is_empty() || !boxes.is_empty());
172
        let listed = client.list_boxes("main").await;
173
        assert!(
174
            listed.is_err(),
175
            "an unauthenticated read of a conversation that is not this account's \
176
             must refuse, got {:?}",
177
            listed.ok()
178
        );
179
        let message = listed.unwrap_err().to_string();
180
        assert!(
181
            message.contains("list conversation boxes"),
182
            "the refusal must name what failed: {message}"
183
        );
184
185
        // And with no conversation named, the refusal names the flag that gets
186
        // the caller unblocked, the way the TypeScript CLI's does.
187
        let unresolved = client.conversation_id(None).await;
188
        let refusal = unresolved
189
            .expect_err("no deployment reports a conversation for an anonymous caller")
190
            .to_string();
191
        assert!(
192
            refusal.contains("--conversation"),
193
            "the refusal must name --conversation: {refusal}"
194
        );
195
    }
196
197
    /// A named conversation is used verbatim; it is never replaced by a default.
198
    #[tokio::test]
199
    async fn test_box_client_uses_the_conversation_it_was_given() {
200
        let client = BoxClient::new("https://openagents.com/api/v1", None);
201
        assert_eq!(
202
            client.conversation_id(Some("conv_abc123")).await.unwrap(),
203
            "conv_abc123"
204
        );
67 205
    }
68 206
69 207
    #[test]

@@ -219,10 +357,37 @@ mod tests {

219 357
        assert!(cargo_toml.contains("name = \"oa\""));
220 358
    }
221 359
360
    /// The old assertion was `mems.is_empty() || !mems.is_empty()`, true of
361
    /// every list, and it held against an unauthenticated client that answered
362
    /// the 401 with an empty vector. Memories are account-scoped, so an
363
    /// anonymous read has no rows to assert on — what there is to assert is
364
    /// that the refusal is a refusal.
222 365
    #[tokio::test]
223 366
    async fn test_memory_client_parity() {
224 367
        let client = MemoryClient::new("https://openagents.com/api/v1", None);
225
        let mems = client.list_memories(None).await.unwrap();
226
        assert!(mems.is_empty() || !mems.is_empty());
368
        let listed = client.list_memories(None, None, false).await;
369
        assert!(
370
            listed.is_err(),
371
            "an unauthenticated memory read must refuse, got {:?}",
372
            listed.ok()
373
        );
374
375
        // Deleting is the subcommand issue #96 was filed for. An empty id is
376
        // refused before the round trip rather than sent as `/memories/`, which
377
        // is the list route and would answer 200.
378
        let removed = client.delete_memory("   ").await;
379
        assert!(removed.is_err(), "an empty memory id must not be sent");
380
381
        // And a correction carries the id it replaces, which the Rust client
382
        // had no way to send at all.
383
        let superseding = client
384
            .add_memory("corrected", Some("user"), Some("mem_1"), None)
385
            .await;
386
        assert!(
387
            superseding.is_err(),
388
            "an unauthenticated write must refuse, got {:?}",
389
            superseding.ok()
390
        );
391
        assert!(read_bucket("nonsense").is_err());
227 392
    }
228 393
}

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