| 1 | //! The account Work journey: assign Work, cancel it, read its outcome, quote |
| 2 | //! and launch bounded Boat trial compute, and connect a GitHub repository. |
| 3 | //! |
| 4 | //! Everything here is a thin, honest client of the account control plane. |
| 5 | //! Remote text is untrusted: it only reaches the terminal through `printable`, |
| 6 | //! ids are re-validated before they enter a URL path, and a mutating request |
| 7 | //! whose reply is lost is reported as an *unknown* outcome with the exact |
| 8 | //! command that is safe to run next, never as a failure or a success. |
| 9 | |
| 10 | use serde_json::{Value, json}; |
| 11 | |
| 12 | use super::*; |
| 13 | |
| 14 | /// The bounded Boat trial this CLI can start. These are the contract values |
| 15 | /// the control plane admits; anything else is refused by the server, so the |
| 16 | /// CLI states them instead of exposing knobs that could only fail. |
| 17 | const BOAT_TRIAL_SKU: &str = "boat-small"; |
| 18 | const BOAT_TRIAL_SECONDS: u64 = 300; |
| 19 | const BOAT_TRIAL_MODEL_PROVIDER: &str = "deepseek"; |
| 20 | const BOAT_TRIAL_MODEL: &str = "deepseek-flash"; |
| 21 | const MAX_CONFIRMATION_BYTES: usize = 4096; |
| 22 | const MAX_CANCEL_REASON_CHARS: usize = 500; |
| 23 | |
| 24 | fn valid_confirmation(token: &str) -> bool { |
| 25 | !token.is_empty() |
| 26 | && token.len() <= MAX_CONFIRMATION_BYTES |
| 27 | && token |
| 28 | .bytes() |
| 29 | .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) |
| 30 | } |
| 31 | |
| 32 | /// Consume a pipe rather than placing signed consent proof in process argv. |
| 33 | /// Bound the read before decoding and allow only one terminal line ending. |
| 34 | pub(super) fn read_confirmation(reader: impl Read) -> Result<String> { |
| 35 | let mut bytes = Vec::new(); |
| 36 | reader |
| 37 | .take(MAX_CONFIRMATION_BYTES as u64 + 3) |
| 38 | .read_to_end(&mut bytes) |
| 39 | .context("Could not read the launch confirmation from stdin")?; |
| 40 | if bytes.len() > MAX_CONFIRMATION_BYTES + 2 { |
| 41 | bail!("Launch confirmation from stdin is too long"); |
| 42 | } |
| 43 | let value = String::from_utf8(bytes).context("Launch confirmation from stdin must be UTF-8")?; |
| 44 | let token = value |
| 45 | .strip_suffix("\r\n") |
| 46 | .or_else(|| value.strip_suffix('\n')) |
| 47 | .unwrap_or(&value); |
| 48 | if !valid_confirmation(token) { |
| 49 | bail!("Launch confirmation from stdin must be one non-empty confirmation token"); |
| 50 | } |
| 51 | Ok(token.to_string()) |
| 52 | } |
| 53 | |
| 54 | fn at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> { |
| 55 | path.iter().try_fold(value, |cursor, key| cursor.get(*key)) |
| 56 | } |
| 57 | |
| 58 | fn str_at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> { |
| 59 | at(value, path).and_then(Value::as_str) |
| 60 | } |
| 61 | |
| 62 | /// A sanitized, non-empty string from a remote document. |
| 63 | fn text_at(value: &Value, path: &[&str]) -> Option<String> { |
| 64 | str_at(value, path) |
| 65 | .map(printable) |
| 66 | .filter(|text| !text.is_empty()) |
| 67 | } |
| 68 | |
| 69 | fn count_at(value: &Value, path: &[&str]) -> Option<u64> { |
| 70 | at(value, path).and_then(Value::as_u64) |
| 71 | } |
| 72 | |
| 73 | fn http_code(err: &anyhow::Error) -> Option<&str> { |
| 74 | err.downcast_ref::<CloudHttpError>() |
| 75 | .and_then(CloudHttpError::code) |
| 76 | } |
| 77 | |
| 78 | /// A 2xx reply whose body cannot be read: the service acted, but the client |
| 79 | /// cannot say how, so it is an unknown outcome rather than a failure. |
| 80 | fn parse_reply(body: &[u8], what: &'static str) -> Result<Value> { |
| 81 | serde_json::from_slice(body).map_err(|source| CloudTransportError::new(what, source).into()) |
| 82 | } |
| 83 | |
| 84 | fn validate_work_uuid(value: &str) -> Result<&str> { |
| 85 | let id = value.trim(); |
| 86 | let groups = id.split('-').map(str::len).collect::<Vec<_>>(); |
| 87 | if groups != [8, 4, 4, 4, 12] |
| 88 | || !id |
| 89 | .bytes() |
| 90 | .all(|byte| byte == b'-' || byte.is_ascii_hexdigit()) |
| 91 | { |
| 92 | bail!( |
| 93 | "Work ID must be the UUID printed when the Work was created (see `codewhale account agents work-status`)" |
| 94 | ); |
| 95 | } |
| 96 | Ok(id) |
| 97 | } |
| 98 | |
| 99 | fn github_owner_ok(owner: &str) -> bool { |
| 100 | (1..=39).contains(&owner.len()) |
| 101 | && owner |
| 102 | .bytes() |
| 103 | .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') |
| 104 | && !owner.starts_with('-') |
| 105 | && !owner.ends_with('-') |
| 106 | } |
| 107 | |
| 108 | fn github_name_ok(name: &str) -> bool { |
| 109 | (1..=100).contains(&name.len()) |
| 110 | && name |
| 111 | .bytes() |
| 112 | .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) |
| 113 | && !matches!(name, "." | "..") |
| 114 | } |
| 115 | |
| 116 | fn validate_github_repo(value: &str) -> Result<String> { |
| 117 | let value = value.trim(); |
| 118 | match value.split_once('/') { |
| 119 | Some((owner, name)) if github_owner_ok(owner) && github_name_ok(name) => { |
| 120 | Ok(value.to_string()) |
| 121 | } |
| 122 | _ => bail!("Repository must be written OWNER/REPO, for example `octo-org/app`"), |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | /// A draft-PR link is shown only when it is exactly a GitHub pull-request URL |
| 127 | /// (and, when the result names its repository, points into that repository). |
| 128 | /// A remote string that merely looks like a link is never echoed. |
| 129 | fn github_pull_request_url(url: &str, repository: &str) -> Option<String> { |
| 130 | let rest = url.strip_prefix("https://github.com/")?; |
| 131 | let mut parts = rest.split('/'); |
| 132 | let (owner, name, kind, number) = (parts.next()?, parts.next()?, parts.next()?, parts.next()?); |
| 133 | if parts.next().is_some() |
| 134 | || kind != "pull" |
| 135 | || !github_owner_ok(owner) |
| 136 | || !github_name_ok(name) |
| 137 | || number.is_empty() |
| 138 | || number.len() > 10 |
| 139 | || number.starts_with('0') |
| 140 | || !number.bytes().all(|byte| byte.is_ascii_digit()) |
| 141 | { |
| 142 | return None; |
| 143 | } |
| 144 | if !repository.is_empty() && !repository.eq_ignore_ascii_case(&format!("{owner}/{name}")) { |
| 145 | return None; |
| 146 | } |
| 147 | Some(url.to_string()) |
| 148 | } |
| 149 | |
| 150 | fn write_work_item<W: Write>(out: &mut W, item: &AccountAgentWork) -> Result<()> { |
| 151 | writeln!(out, "Work ID: {}", item.id)?; |
| 152 | writeln!(out, "Status: {}", printable(&item.status))?; |
| 153 | if !item.objective.is_empty() { |
| 154 | writeln!(out, "Objective: {}", printable(&item.objective))?; |
| 155 | } |
| 156 | Ok(()) |
| 157 | } |
| 158 | |
| 159 | /// `codewhale account agents work`: say what the Agent actually did with the |
| 160 | /// message. Only an actionable or queued reading creates Work; a control verb |
| 161 | /// or correction acts on Work that already exists, and a question changes |
| 162 | /// nothing, so those must never read as "Work is recorded". |
| 163 | pub(super) fn assign<T: CloudTransport, W: Write>( |
| 164 | client: &CloudClient<'_, T>, |
| 165 | out: &mut W, |
| 166 | agent: &AccountAgent, |
| 167 | objective: &str, |
| 168 | message_id: &str, |
| 169 | ) -> Result<()> { |
| 170 | let message_id = validate_operation_key(message_id)?; |
| 171 | let receipt = client |
| 172 | .assign_agent_work(&agent.id, objective, message_id) |
| 173 | .map_err(|err| { |
| 174 | if outcome_unknown(&err) { |
| 175 | err.context(format!( |
| 176 | "The request may or may not have reached Codewhale. Re-run this exact command with --message-id {message_id}; replaying the same --message-id never creates a second Work for the same instruction. If the message was a stop or a correction, check `work-status` first" |
| 177 | )) |
| 178 | } else { |
| 179 | err |
| 180 | } |
| 181 | })?; |
| 182 | let mut items = Vec::new(); |
| 183 | for item in receipt.work.iter().chain(receipt.queued_work.iter()) { |
| 184 | validate_resource_id(&item.id, "Work")?; |
| 185 | if item.agent_id != agent.id { |
| 186 | bail!("The Codewhale service returned Work for a different Agent"); |
| 187 | } |
| 188 | items.push(item); |
| 189 | } |
| 190 | let intent = receipt.intent.as_str(); |
| 191 | if items.is_empty() { |
| 192 | match intent { |
| 193 | "actionable" | "queued" => bail!( |
| 194 | "The Codewhale service read this message as {} but returned no Work record, so nothing is confirmed. Re-run this exact command with --message-id {message_id} to replay it", |
| 195 | printable(intent) |
| 196 | ), |
| 197 | "control" | "correction" => bail!( |
| 198 | "The Codewhale service reported a {} but returned no Work record, so nothing is confirmed. Run `codewhale account agents work-status` on the Work you meant", |
| 199 | printable(intent) |
| 200 | ), |
| 201 | _ => {} |
| 202 | } |
| 203 | } |
| 204 | writeln!(out, "Intent: {}", printable(intent))?; |
| 205 | if !receipt.reason.is_empty() { |
| 206 | writeln!(out, "Reason: {}", printable(&receipt.reason))?; |
| 207 | } |
| 208 | if receipt.confident == Some(false) { |
| 209 | writeln!(out, "The Agent was not confident in this reading.")?; |
| 210 | } |
| 211 | match intent { |
| 212 | "control" => { |
| 213 | let action = receipt |
| 214 | .control_action |
| 215 | .as_deref() |
| 216 | .map(printable) |
| 217 | .filter(|action| !action.is_empty()) |
| 218 | .unwrap_or_else(|| "unspecified".to_string()); |
| 219 | writeln!(out, "Applied control action: {action}")?; |
| 220 | } |
| 221 | "correction" => writeln!( |
| 222 | out, |
| 223 | "Applied: this message edited the objective of active Work. No new Work was created." |
| 224 | )?, |
| 225 | "queued" => writeln!(out, "This Work was queued behind active Work.")?, |
| 226 | "actionable" => {} |
| 227 | "informational" => { |
| 228 | writeln!(out, "No Work was created and nothing was changed.")?; |
| 229 | if let Some(suggestion) = receipt |
| 230 | .suggestion |
| 231 | .as_deref() |
| 232 | .map(printable) |
| 233 | .filter(|suggestion| !suggestion.is_empty()) |
| 234 | { |
| 235 | writeln!(out, "Suggestion: {suggestion}")?; |
| 236 | writeln!( |
| 237 | out, |
| 238 | "To have the Agent do it, send it as an instruction that starts with a verb such as fix, add or update." |
| 239 | )?; |
| 240 | } |
| 241 | } |
| 242 | _ => writeln!( |
| 243 | out, |
| 244 | "The Agent reported an intent this CLI does not recognize; verify with `codewhale account agents work-status`." |
| 245 | )?, |
| 246 | } |
| 247 | for item in &items { |
| 248 | write_work_item(out, item)?; |
| 249 | } |
| 250 | match (intent, items.first()) { |
| 251 | ("actionable" | "queued", Some(first)) => { |
| 252 | writeln!( |
| 253 | out, |
| 254 | "Work is recorded, not started. To run it on bounded Boat trial compute, review a quote first:" |
| 255 | )?; |
| 256 | writeln!( |
| 257 | out, |
| 258 | " codewhale account agents work-quote {} --operation-key <new-key>", |
| 259 | first.id |
| 260 | )?; |
| 261 | } |
| 262 | ("control" | "correction", _) => writeln!( |
| 263 | out, |
| 264 | "Status above is the Work's state after this message; `work-status` confirms it." |
| 265 | )?, |
| 266 | _ => {} |
| 267 | } |
| 268 | writeln!(out, "Message ID: {message_id}")?; |
| 269 | Ok(()) |
| 270 | } |
| 271 | |
| 272 | /// `codewhale account agents work-cancel`. |
| 273 | pub(super) fn cancel<T: CloudTransport, W: Write>( |
| 274 | client: &CloudClient<'_, T>, |
| 275 | out: &mut W, |
| 276 | id: &str, |
| 277 | queue: Option<WorkQueueChoice>, |
| 278 | reason: Option<&str>, |
| 279 | ) -> Result<()> { |
| 280 | let id = validate_resource_id(id, "Work")?; |
| 281 | let mut body = json!({}); |
| 282 | if let Some(reason) = reason { |
| 283 | body["reason"] = json!(validate_named_text( |
| 284 | reason, |
| 285 | "Cancel reason", |
| 286 | MAX_CANCEL_REASON_CHARS |
| 287 | )?); |
| 288 | } |
| 289 | if let Some(queue) = queue { |
| 290 | body["queue"] = json!(match queue { |
| 291 | WorkQueueChoice::Discard => "discard", |
| 292 | WorkQueueChoice::Park => "park", |
| 293 | }); |
| 294 | } |
| 295 | let unknown = |err: anyhow::Error| { |
| 296 | err.context(format!( |
| 297 | "The cancel outcome is unknown: the request may or may not have been applied. Run `codewhale account agents work-status {id}` before retrying; cancelling is safe to repeat" |
| 298 | )) |
| 299 | }; |
| 300 | let response = client |
| 301 | .execute_authenticated( |
| 302 | HttpMethod::Post, |
| 303 | &format!("/api/runs/{id}/cancel"), |
| 304 | Some(json_body(&body)?), |
| 305 | ) |
| 306 | .map_err(|err| { |
| 307 | if outcome_unknown(&err) { |
| 308 | unknown(err) |
| 309 | } else { |
| 310 | err |
| 311 | } |
| 312 | })?; |
| 313 | if !(200..300).contains(&response.status) { |
| 314 | let err = response_error(&response); |
| 315 | return match (response.status, http_code(&err)) { |
| 316 | (409, Some("run_control_terminal")) => { |
| 317 | writeln!(out, "Work ID: {id}")?; |
| 318 | writeln!(out, "Work is already final; there is nothing to cancel.")?; |
| 319 | writeln!( |
| 320 | out, |
| 321 | "Read its outcome: codewhale account agents work-result {id}" |
| 322 | )?; |
| 323 | Ok(()) |
| 324 | } |
| 325 | (422, Some("run_prompt_queue_choice_required")) => Err(err.context( |
| 326 | "This Work still has queued prompts and was not cancelled. Re-run with --queue discard or --queue park to choose what happens to them", |
| 327 | )), |
| 328 | (404, _) => Err(err.context(format!("Work {id} was not found on this account"))), |
| 329 | _ if outcome_unknown(&err) => Err(unknown(err)), |
| 330 | _ => Err(err.context("The cancel request was refused")), |
| 331 | }; |
| 332 | } |
| 333 | let reply = parse_reply( |
| 334 | &response.body, |
| 335 | "The Codewhale service returned an unreadable cancel reply", |
| 336 | ) |
| 337 | .map_err(unknown)?; |
| 338 | if let Some(run_id) = str_at(&reply, &["run", "id"]) |
| 339 | && run_id != id |
| 340 | { |
| 341 | bail!("The Codewhale service returned a different Work record"); |
| 342 | } |
| 343 | writeln!(out, "Work ID: {id}")?; |
| 344 | let state = text_at(&reply, &["run", "state"]); |
| 345 | if reply.get("queued").is_some() && state.is_none() { |
| 346 | writeln!( |
| 347 | out, |
| 348 | "Cancel requested. The runtime has not confirmed the stop yet." |
| 349 | )?; |
| 350 | if reply.get("replayed").and_then(Value::as_bool) == Some(true) { |
| 351 | writeln!(out, "This cancellation was already pending.")?; |
| 352 | } |
| 353 | writeln!( |
| 354 | out, |
| 355 | "Check `codewhale account agents work-status {id}` until its status is canceled." |
| 356 | )?; |
| 357 | } else if let Some(state) = state { |
| 358 | writeln!(out, "Status: {state}")?; |
| 359 | if state == "canceled" { |
| 360 | writeln!(out, "Work canceled.")?; |
| 361 | } else { |
| 362 | writeln!( |
| 363 | out, |
| 364 | "Cancel accepted; run `codewhale account agents work-status {id}` to confirm the final state." |
| 365 | )?; |
| 366 | } |
| 367 | } else { |
| 368 | writeln!( |
| 369 | out, |
| 370 | "Cancel accepted; run `codewhale account agents work-status {id}` to confirm the final state." |
| 371 | )?; |
| 372 | } |
| 373 | if reply.get("promptQueue").is_some_and(Value::is_object) |
| 374 | && let Some(queue) = queue |
| 375 | { |
| 376 | writeln!( |
| 377 | out, |
| 378 | "Queued prompts: {} (recorded).", |
| 379 | match queue { |
| 380 | WorkQueueChoice::Discard => "discarded", |
| 381 | WorkQueueChoice::Park => "parked", |
| 382 | } |
| 383 | )?; |
| 384 | } |
| 385 | writeln!( |
| 386 | out, |
| 387 | "Compute stop and provider usage are final only in `codewhale account agents work-result {id}`." |
| 388 | )?; |
| 389 | Ok(()) |
| 390 | } |
| 391 | |
| 392 | /// The model route recorded for a Work, wherever the account API reports it. |
| 393 | fn recorded_model_route(envelope: &Value, result: &Value) -> Option<(String, String)> { |
| 394 | let candidates = [ |
| 395 | at(result, &["modelRoute"]), |
| 396 | at(result, &["modelAuthority"]), |
| 397 | at(result, &["model"]), |
| 398 | at(result, &["run", "modelAuthority"]), |
| 399 | at(envelope, &["modelAuthority"]), |
| 400 | at(result, &["run"]), |
| 401 | ]; |
| 402 | candidates.into_iter().flatten().find_map(|candidate| { |
| 403 | let provider = |
| 404 | str_at(candidate, &["provider"]).or_else(|| str_at(candidate, &["modelProvider"]))?; |
| 405 | let model = str_at(candidate, &["model"])?; |
| 406 | validate_model_route(provider, model) |
| 407 | .ok() |
| 408 | .map(|(provider, model)| (provider.to_string(), model.to_string())) |
| 409 | }) |
| 410 | } |
| 411 | |
| 412 | fn usd(value: f64) -> String { |
| 413 | if value == 0.0 { |
| 414 | "$0".to_string() |
| 415 | } else { |
| 416 | format!("${value:.4}") |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | fn write_boat_usage<W: Write>(out: &mut W, envelope: &Value, result: &Value) -> Result<()> { |
| 421 | let block = [ |
| 422 | at(result, &["boatUsage"]), |
| 423 | at(result, &["providerUsage"]), |
| 424 | at(result, &["usage"]), |
| 425 | at(envelope, &["boatUsage"]), |
| 426 | at(envelope, &["providerUsage"]), |
| 427 | at(envelope, &["usage"]), |
| 428 | ] |
| 429 | .into_iter() |
| 430 | .flatten() |
| 431 | .find(|block| block.is_object()); |
| 432 | let Some(block) = block else { |
| 433 | return Ok(()); |
| 434 | }; |
| 435 | writeln!(out, "Boat usage:")?; |
| 436 | let number = |keys: &[&str]| { |
| 437 | keys.iter() |
| 438 | .find_map(|key| block.get(*key).and_then(Value::as_f64)) |
| 439 | }; |
| 440 | if let Some(seconds) = number(&["providerSeconds", "seconds", "usedSeconds"]) { |
| 441 | writeln!(out, " Provider seconds: {seconds}")?; |
| 442 | } |
| 443 | if let Some(dollars) = number(&["providerListPriceDollars", "dollars"]) { |
| 444 | writeln!(out, " Provider list price: {}", usd(dollars))?; |
| 445 | } |
| 446 | if let Some(charged) = number(&["customerCreditsChargedUsd", "customerChargeDollars"]) { |
| 447 | writeln!(out, " Codewhale credits charged: {}", usd(charged))?; |
| 448 | } |
| 449 | if let Some(funding) = text_at(block, &["funding"]) { |
| 450 | writeln!(out, " Funding: {funding}")?; |
| 451 | } |
| 452 | if let Some(running) = block.get("running").and_then(Value::as_bool) { |
| 453 | writeln!( |
| 454 | out, |
| 455 | " Provider VM: {}", |
| 456 | if running { "still running" } else { "stopped" } |
| 457 | )?; |
| 458 | } |
| 459 | if let Some(confirmed) = block.get("cleanupConfirmed").and_then(Value::as_bool) { |
| 460 | writeln!( |
| 461 | out, |
| 462 | " Provider cleanup: {}", |
| 463 | if confirmed { "confirmed" } else { "pending" } |
| 464 | )?; |
| 465 | } |
| 466 | Ok(()) |
| 467 | } |
| 468 | |
| 469 | fn write_work_result<W: Write>( |
| 470 | out: &mut W, |
| 471 | id: &str, |
| 472 | envelope: &Value, |
| 473 | attempts: Option<&Value>, |
| 474 | ) -> Result<()> { |
| 475 | let result = envelope |
| 476 | .get("result") |
| 477 | .filter(|result| result.is_object()) |
| 478 | .ok_or_else(|| anyhow!("The Codewhale service returned no Work result"))?; |
| 479 | if str_at(result, &["run", "id"]) != Some(id) { |
| 480 | bail!("The Codewhale service returned a different Work result"); |
| 481 | } |
| 482 | writeln!(out, "Work ID: {id}")?; |
| 483 | writeln!( |
| 484 | out, |
| 485 | "State: {}", |
| 486 | text_at(result, &["run", "state"]).unwrap_or_else(|| "unknown".to_string()) |
| 487 | )?; |
| 488 | let status = text_at(result, &["status"]); |
| 489 | if let Some(status) = &status { |
| 490 | writeln!(out, "Result: {status}")?; |
| 491 | } |
| 492 | if let Some(title) = text_at(result, &["run", "title"]) { |
| 493 | writeln!(out, "Objective: {title}")?; |
| 494 | } |
| 495 | if status.as_deref() == Some("in_progress") { |
| 496 | writeln!(out, "This Work is still running; nothing below is final.")?; |
| 497 | } |
| 498 | if let Some(summary) = str_at(result, &["summary", "text"]) |
| 499 | .map(|text| printable_max(text, 600)) |
| 500 | .filter(|text| !text.is_empty()) |
| 501 | { |
| 502 | writeln!(out, "Summary: {summary}")?; |
| 503 | } |
| 504 | |
| 505 | let files = count_at(result, &["changes", "fileCount"]).unwrap_or(0); |
| 506 | match text_at(result, &["changes", "evidence"]).as_deref() { |
| 507 | Some("recorded") => writeln!( |
| 508 | out, |
| 509 | "Changes: {files} file(s), +{} -{} (recorded)", |
| 510 | count_at(result, &["changes", "additions"]).unwrap_or(0), |
| 511 | count_at(result, &["changes", "deletions"]).unwrap_or(0) |
| 512 | )?, |
| 513 | Some(other) => writeln!(out, "Changes: not verified (evidence: {other})")?, |
| 514 | None => writeln!(out, "Changes: not reported")?, |
| 515 | } |
| 516 | if let Some(checks) = at(result, &["checks"]).and_then(Value::as_array) { |
| 517 | if checks.is_empty() { |
| 518 | writeln!(out, "Checks: none recorded")?; |
| 519 | } else { |
| 520 | let passed = checks |
| 521 | .iter() |
| 522 | .filter(|check| check.get("passed").and_then(Value::as_bool) == Some(true)) |
| 523 | .count(); |
| 524 | writeln!(out, "Checks: {passed} passed of {}", checks.len())?; |
| 525 | for check in checks |
| 526 | .iter() |
| 527 | .filter(|check| check.get("passed").and_then(Value::as_bool) != Some(true)) |
| 528 | .take(10) |
| 529 | { |
| 530 | writeln!( |
| 531 | out, |
| 532 | " not passed: {} ({})", |
| 533 | text_at(check, &["name"]).unwrap_or_else(|| "check".to_string()), |
| 534 | text_at(check, &["status"]).unwrap_or_else(|| "unverified".to_string()) |
| 535 | )?; |
| 536 | } |
| 537 | } |
| 538 | } |
| 539 | if let Some(findings) = at(result, &["findings"]).and_then(Value::as_array) |
| 540 | && !findings.is_empty() |
| 541 | { |
| 542 | writeln!(out, "Findings: {}", findings.len())?; |
| 543 | for finding in findings.iter().take(5) { |
| 544 | writeln!( |
| 545 | out, |
| 546 | " {}: {}", |
| 547 | text_at(finding, &["severity"]).unwrap_or_else(|| "info".to_string()), |
| 548 | text_at(finding, &["title"]).unwrap_or_else(|| "finding".to_string()) |
| 549 | )?; |
| 550 | } |
| 551 | } |
| 552 | if let Some(approvals) = at(result, &["pendingApprovals"]).and_then(Value::as_array) |
| 553 | && !approvals.is_empty() |
| 554 | { |
| 555 | writeln!(out, "Waiting on {} approval(s).", approvals.len())?; |
| 556 | } |
| 557 | let artifacts = at(result, &["artifacts"]) |
| 558 | .and_then(Value::as_array) |
| 559 | .map(Vec::as_slice) |
| 560 | .unwrap_or_default(); |
| 561 | if artifacts.is_empty() { |
| 562 | writeln!(out, "Artifacts: none")?; |
| 563 | } else { |
| 564 | writeln!(out, "Artifacts ({}):", artifacts.len())?; |
| 565 | for artifact in artifacts.iter().take(20) { |
| 566 | writeln!( |
| 567 | out, |
| 568 | " {} ({}, {}, {} bytes)", |
| 569 | text_at(artifact, &["name"]).unwrap_or_else(|| "artifact".to_string()), |
| 570 | text_at(artifact, &["status"]).unwrap_or_else(|| "unknown".to_string()), |
| 571 | text_at(artifact, &["contentType"]).unwrap_or_else(|| "unknown type".to_string()), |
| 572 | count_at(artifact, &["size"]).unwrap_or(0) |
| 573 | )?; |
| 574 | } |
| 575 | } |
| 576 | |
| 577 | let repository = str_at(result, &["repository", "name"]).unwrap_or_default(); |
| 578 | if let Some(branch) = text_at(result, &["repository", "branch"]) { |
| 579 | writeln!(out, "Branch: {branch}")?; |
| 580 | } |
| 581 | if let Some(revision) = text_at(result, &["repository", "revision"]) { |
| 582 | writeln!(out, "Revision: {revision}")?; |
| 583 | } |
| 584 | match str_at(result, &["repository", "pullRequest", "url"]) { |
| 585 | Some(url) => match github_pull_request_url(url, repository) { |
| 586 | Some(url) => { |
| 587 | let draft = |
| 588 | str_at(result, &["repository", "pullRequest", "state"]) == Some("draft"); |
| 589 | writeln!( |
| 590 | out, |
| 591 | "{}: {url}", |
| 592 | if draft { "Draft PR" } else { "Pull request" } |
| 593 | )?; |
| 594 | } |
| 595 | None => writeln!( |
| 596 | out, |
| 597 | "Pull request: the service reported a link that is not a GitHub pull request for this repository, so it is not shown" |
| 598 | )?, |
| 599 | }, |
| 600 | None => writeln!(out, "Draft PR: none")?, |
| 601 | } |
| 602 | if let Some(blockers) = at(result, &["readiness", "blockers"]).and_then(Value::as_array) |
| 603 | && !blockers.is_empty() |
| 604 | { |
| 605 | writeln!( |
| 606 | out, |
| 607 | "Blockers: {}", |
| 608 | blockers |
| 609 | .iter() |
| 610 | .filter_map(Value::as_str) |
| 611 | .map(printable) |
| 612 | .collect::<Vec<_>>() |
| 613 | .join(", ") |
| 614 | )?; |
| 615 | } |
| 616 | if let Some(next) = text_at(result, &["nextAction", "label"]) { |
| 617 | writeln!(out, "Next: {next}")?; |
| 618 | } |
| 619 | match recorded_model_route(envelope, result) { |
| 620 | Some((provider, model)) => writeln!(out, "Model route: {provider}/{model}")?, |
| 621 | None => writeln!(out, "Model route: not reported by the account API")?, |
| 622 | } |
| 623 | write_boat_usage(out, envelope, result)?; |
| 624 | match attempts { |
| 625 | Some(attempts) => { |
| 626 | let rows = at(attempts, &["attempts"]) |
| 627 | .and_then(Value::as_array) |
| 628 | .map(Vec::as_slice) |
| 629 | .unwrap_or_default(); |
| 630 | writeln!( |
| 631 | out, |
| 632 | "Attempts: {}", |
| 633 | count_at(attempts, &["attemptCount"]).unwrap_or(rows.len() as u64) |
| 634 | )?; |
| 635 | for attempt in rows.iter().take(10) { |
| 636 | let mut line = format!( |
| 637 | " #{} {} {}", |
| 638 | count_at(attempt, &["sequence"]).unwrap_or(0), |
| 639 | text_at(attempt, &["kind"]).unwrap_or_else(|| "attempt".to_string()), |
| 640 | text_at(attempt, &["status"]).unwrap_or_else(|| "unknown".to_string()) |
| 641 | ); |
| 642 | if let Some(code) = text_at(attempt, &["errorCode"]) { |
| 643 | line.push_str(&format!(" (error {code})")); |
| 644 | } |
| 645 | writeln!(out, "{line}")?; |
| 646 | } |
| 647 | } |
| 648 | None => writeln!(out, "Attempts: unavailable")?, |
| 649 | } |
| 650 | if let Some(seq) = count_at(result, &["receipt", "eventsThroughSeq"]) { |
| 651 | writeln!(out, "Evidence through event {seq}")?; |
| 652 | } |
| 653 | Ok(()) |
| 654 | } |
| 655 | |
| 656 | /// `codewhale account agents work-result`. |
| 657 | pub(super) fn result<T: CloudTransport, W: Write>( |
| 658 | client: &CloudClient<'_, T>, |
| 659 | out: &mut W, |
| 660 | id: &str, |
| 661 | json: bool, |
| 662 | ) -> Result<()> { |
| 663 | let id = validate_resource_id(id, "Work")?; |
| 664 | let envelope: Value = expect_json( |
| 665 | client.execute_authenticated(HttpMethod::Get, &format!("/api/runs/{id}/result"), None)?, |
| 666 | &[200], |
| 667 | )?; |
| 668 | // Attempt lineage is supporting evidence: without it the result is still |
| 669 | // worth showing, so its failure is reported instead of hiding the result. |
| 670 | let attempts = client |
| 671 | .execute_authenticated(HttpMethod::Get, &format!("/api/runs/{id}/attempts"), None) |
| 672 | .and_then(|response| expect_json::<Value>(response, &[200])) |
| 673 | .ok(); |
| 674 | if json { |
| 675 | return write_computer_json( |
| 676 | out, |
| 677 | &json!({ "result": envelope, "attempts": attempts.unwrap_or(Value::Null) }), |
| 678 | ); |
| 679 | } |
| 680 | write_work_result(out, id, &envelope, attempts.as_ref()) |
| 681 | } |
| 682 | |
| 683 | struct LaunchTarget { |
| 684 | run_id: String, |
| 685 | agent_id: String, |
| 686 | project_id: String, |
| 687 | repo: String, |
| 688 | prompt: String, |
| 689 | state: String, |
| 690 | } |
| 691 | |
| 692 | /// The Work as the account serves it: the launch acts on this record, not on |
| 693 | /// anything the caller types, so the reviewed quote and the launch agree. |
| 694 | fn load_launch_target<T: CloudTransport>( |
| 695 | client: &CloudClient<'_, T>, |
| 696 | id: &str, |
| 697 | ) -> Result<LaunchTarget> { |
| 698 | let envelope: Value = expect_json( |
| 699 | client.execute_authenticated(HttpMethod::Get, &format!("/api/runs/{id}"), None)?, |
| 700 | &[200], |
| 701 | )?; |
| 702 | let run = envelope |
| 703 | .get("run") |
| 704 | .filter(|run| run.is_object()) |
| 705 | .ok_or_else(|| anyhow!("The Codewhale service returned no Work record"))?; |
| 706 | if str_at(run, &["id"]) != Some(id) { |
| 707 | bail!("The Codewhale service returned a different Work record"); |
| 708 | } |
| 709 | let field = |name: &str| str_at(run, &[name]).unwrap_or_default().trim().to_string(); |
| 710 | let provider = field("repoProvider").to_ascii_lowercase(); |
| 711 | if !provider.is_empty() && provider != "github" { |
| 712 | bail!( |
| 713 | "Boat trial Work supports GitHub repositories only; this Work uses {}", |
| 714 | printable(&provider) |
| 715 | ); |
| 716 | } |
| 717 | let agent_id = field("agentId"); |
| 718 | let project_id = field("projectId"); |
| 719 | if agent_id.is_empty() || project_id.is_empty() { |
| 720 | bail!( |
| 721 | "This Work is not attached to an Agent and Project, so it cannot be launched; create it with `codewhale account agents work`" |
| 722 | ); |
| 723 | } |
| 724 | validate_resource_id(&agent_id, "Agent")?; |
| 725 | validate_resource_id(&project_id, "Project")?; |
| 726 | let prompt = field("title"); |
| 727 | if prompt.is_empty() || prompt.chars().count() > 32_000 { |
| 728 | bail!("Work objective must contain 1-32000 characters"); |
| 729 | } |
| 730 | Ok(LaunchTarget { |
| 731 | run_id: id.to_string(), |
| 732 | agent_id, |
| 733 | project_id, |
| 734 | repo: field("repo"), |
| 735 | prompt, |
| 736 | state: field("state"), |
| 737 | }) |
| 738 | } |
| 739 | |
| 740 | /// Cross-check the Work against the Agent and Project the account serves now. |
| 741 | fn verify_launch_authority<T: CloudTransport>( |
| 742 | client: &CloudClient<'_, T>, |
| 743 | target: &mut LaunchTarget, |
| 744 | ) -> Result<()> { |
| 745 | let agents: AgentListResponse = serde_json::from_value(client.agents()?) |
| 746 | .context("The Codewhale service returned an invalid Agent list")?; |
| 747 | let agent = resolve_account_agent(&agents.agents, &target.agent_id)?; |
| 748 | if agent.project_id != target.project_id { |
| 749 | bail!( |
| 750 | "Agent {} is no longer bound to this Work's Project; bind it again or create new Work", |
| 751 | printable(&agent.name) |
| 752 | ); |
| 753 | } |
| 754 | let projects: ProjectListResponse = serde_json::from_value(client.projects()?) |
| 755 | .context("The Codewhale service returned an invalid Project list")?; |
| 756 | let project = projects |
| 757 | .projects |
| 758 | .iter() |
| 759 | .find(|project| project.id == target.project_id) |
| 760 | .ok_or_else(|| { |
| 761 | anyhow!( |
| 762 | "Project {} is not available on this account. Run `codewhale account projects list`", |
| 763 | target.project_id |
| 764 | ) |
| 765 | })?; |
| 766 | if project.default_repo_provider != "github" || project.default_repo.trim().is_empty() { |
| 767 | bail!( |
| 768 | "Project {} has no GitHub repository, so its Work cannot be launched", |
| 769 | printable(&project.name) |
| 770 | ); |
| 771 | } |
| 772 | if target.repo.is_empty() { |
| 773 | target.repo = project.default_repo.trim().to_string(); |
| 774 | } else if !target |
| 775 | .repo |
| 776 | .eq_ignore_ascii_case(project.default_repo.trim()) |
| 777 | { |
| 778 | bail!("This Work's repository differs from its Project's repository; create new Work"); |
| 779 | } |
| 780 | Ok(()) |
| 781 | } |
| 782 | |
| 783 | /// Contract C5: the launch-quote and cloud-sessions request for one bounded |
| 784 | /// Boat trial. The quote and the launch send the same fields so the signed |
| 785 | /// confirmation binds to exactly what starts. |
| 786 | fn launch_body(target: &LaunchTarget, operation_key: &str) -> Result<Value> { |
| 787 | Ok(json!({ |
| 788 | "workRunId": target.run_id, |
| 789 | "agentId": target.agent_id, |
| 790 | "projectId": target.project_id, |
| 791 | "repo": validate_github_repo(&target.repo)?, |
| 792 | "provider": "github", |
| 793 | "prompt": target.prompt, |
| 794 | "runnerKind": "hosted", |
| 795 | "sandboxSku": BOAT_TRIAL_SKU, |
| 796 | "estimatedSeconds": BOAT_TRIAL_SECONDS, |
| 797 | "modelProvider": BOAT_TRIAL_MODEL_PROVIDER, |
| 798 | "model": BOAT_TRIAL_MODEL, |
| 799 | "billingMode": "byok_external", |
| 800 | "computeRegion": "eu", |
| 801 | "sandboxTargetRegion": "eu", |
| 802 | "crossRegionSandboxOptIn": true, |
| 803 | "operationKey": operation_key, |
| 804 | })) |
| 805 | } |
| 806 | |
| 807 | /// What to do next for a control-plane code the launch flow knows about. |
| 808 | fn launch_hint(code: &str) -> Option<&'static str> { |
| 809 | Some(match code { |
| 810 | "boat_work_trial_unavailable" => "Boat trial Work is not available for this account", |
| 811 | "boat_work_provider_unavailable" => "Boat compute is not configured on this Codewhale API", |
| 812 | "hosted_launch_quote_required" |
| 813 | | "hosted_launch_quote_invalid" |
| 814 | | "hosted_launch_quote_expired" |
| 815 | | "hosted_launch_quote_mismatch" => { |
| 816 | "The confirmation is not valid for this launch. Run `work-quote` again with the same --operation-key and use the new confirmation" |
| 817 | } |
| 818 | "launch_operation_mismatch" => { |
| 819 | "This --operation-key belongs to different launch inputs. Check `work-status` and `work-result` before starting again; never replace the key to retry an unknown launch" |
| 820 | } |
| 821 | "work_run_not_queued" => { |
| 822 | "Only queued Work can be launched. Run `work-status` to see what happened to it" |
| 823 | } |
| 824 | "repo_access_required" => { |
| 825 | "Connect the repository first with `codewhale account github bind OWNER/REPO`" |
| 826 | } |
| 827 | "hosted_byok_required" => { |
| 828 | "Save your DeepSeek key first with `codewhale account keys set deepseek`" |
| 829 | } |
| 830 | _ => return None, |
| 831 | }) |
| 832 | } |
| 833 | |
| 834 | /// `codewhale account agents work-quote`. |
| 835 | pub(super) fn quote<T: CloudTransport, W: Write>( |
| 836 | client: &CloudClient<'_, T>, |
| 837 | out: &mut W, |
| 838 | id: &str, |
| 839 | operation_key: &str, |
| 840 | ) -> Result<()> { |
| 841 | let id = validate_work_uuid(id)?; |
| 842 | let operation_key = validate_operation_key(operation_key)?; |
| 843 | let mut target = load_launch_target(client, id)?; |
| 844 | if target.state != "queued" { |
| 845 | bail!( |
| 846 | "Work is {}, and only queued Work can be quoted for launch. Run `codewhale account agents work-status {id}`", |
| 847 | printable(&target.state) |
| 848 | ); |
| 849 | } |
| 850 | verify_launch_authority(client, &mut target)?; |
| 851 | let body = launch_body(&target, operation_key)?; |
| 852 | let response = client.execute_authenticated( |
| 853 | HttpMethod::Post, |
| 854 | "/api/sandbox/launch-quote", |
| 855 | Some(json_body(&body)?), |
| 856 | )?; |
| 857 | if response.status != 200 { |
| 858 | let err = response_error(&response); |
| 859 | return Err(match http_code(&err).and_then(launch_hint) { |
| 860 | Some(hint) => err.context(hint), |
| 861 | None => err, |
| 862 | }); |
| 863 | } |
| 864 | let quote: Value = parse_json_body(&response.body)?; |
| 865 | |
| 866 | // This command starts only the $0 EU Boat trial. If the service quotes |
| 867 | // anything else, refuse to hand out a confirmation for it. |
| 868 | let seconds = count_at("e, &["disclosure", "computerTime", "estimatedSeconds"]); |
| 869 | let expected = at("e, &["disclosure", "funding"]).and_then(Value::as_str) |
| 870 | == Some("provider_trial") |
| 871 | && at("e, &["disclosure", "customerCreditsChargedUsd"]).and_then(Value::as_f64) |
| 872 | == Some(0.0) |
| 873 | && str_at("e, &["quote", "sku"]) == Some(BOAT_TRIAL_SKU) |
| 874 | && str_at("e, &["quote", "adapter"]) == Some("boat") |
| 875 | && str_at("e, &["disclosure", "sandboxTargetRegion"]) == Some("eu") |
| 876 | && str_at("e, &["disclosure", "modelInference", "billing"]) == Some("byok_external") |
| 877 | && seconds == Some(BOAT_TRIAL_SECONDS); |
| 878 | if !expected { |
| 879 | bail!( |
| 880 | "The Codewhale service quoted something other than the $0 five-minute EU Boat trial. Refusing to print a confirmation for it; nothing started" |
| 881 | ); |
| 882 | } |
| 883 | let token = str_at("e, &["confirmation", "token"]).unwrap_or_default(); |
| 884 | if !valid_confirmation(token) |
| 885 | || str_at("e, &["confirmation", "workRunId"]).is_some_and(|quoted| quoted != id) |
| 886 | { |
| 887 | bail!("The Codewhale service returned an unusable launch confirmation"); |
| 888 | } |
| 889 | let seconds = BOAT_TRIAL_SECONDS; |
| 890 | writeln!( |
| 891 | out, |
| 892 | "Boat trial Work quote. Nothing has started and nothing is charged." |
| 893 | )?; |
| 894 | writeln!(out, "Work ID: {id}")?; |
| 895 | writeln!(out, "Repository: {}", printable(&target.repo))?; |
| 896 | writeln!( |
| 897 | out, |
| 898 | "Model: {BOAT_TRIAL_MODEL_PROVIDER}/{BOAT_TRIAL_MODEL} with your own DeepSeek key; DeepSeek bills your BYOK usage directly" |
| 899 | )?; |
| 900 | writeln!( |
| 901 | out, |
| 902 | "Computer: {BOAT_TRIAL_SKU} on Boat in the EU, up to {seconds} seconds" |
| 903 | )?; |
| 904 | writeln!( |
| 905 | out, |
| 906 | "Funding: provider trial; Codewhale credits charged: $0" |
| 907 | )?; |
| 908 | if let Some(estimate) = |
| 909 | at("e, &["disclosure", "providerCostEstimateUsd"]).and_then(Value::as_f64) |
| 910 | { |
| 911 | writeln!(out, "Provider cost estimate: {}", usd(estimate))?; |
| 912 | } |
| 913 | writeln!( |
| 914 | out, |
| 915 | "EU placement: Codewhale admission attestation; Boat reports no region field. Repository code and Work files are admitted to EU compute." |
| 916 | )?; |
| 917 | if let Some(title) = text_at("e, &["confirmCopy", "title"]) { |
| 918 | writeln!(out, "{title}")?; |
| 919 | } |
| 920 | if let Some(copy) = str_at("e, &["confirmCopy", "body"]) |
| 921 | .map(|body| printable_max(body, 800)) |
| 922 | .filter(|body| !body.is_empty()) |
| 923 | { |
| 924 | writeln!(out, "{copy}")?; |
| 925 | } |
| 926 | if let Some(expires) = text_at("e, &["confirmation", "expiresAt"]) { |
| 927 | writeln!(out, "Confirmation expires: {expires}")?; |
| 928 | } |
| 929 | writeln!(out, "Confirmation: {token}")?; |
| 930 | writeln!(out, "Operation key: {operation_key}")?; |
| 931 | writeln!( |
| 932 | out, |
| 933 | "To start: codewhale account agents work-launch {id} --operation-key {operation_key} --confirmation {token} --confirm-eu-compute" |
| 934 | )?; |
| 935 | Ok(()) |
| 936 | } |
| 937 | |
| 938 | /// `codewhale account agents work-launch`. |
| 939 | pub(super) fn launch<T: CloudTransport, W: Write>( |
| 940 | client: &CloudClient<'_, T>, |
| 941 | out: &mut W, |
| 942 | id: &str, |
| 943 | operation_key: &str, |
| 944 | confirmation: &str, |
| 945 | confirm_eu_compute: bool, |
| 946 | ) -> Result<()> { |
| 947 | if !confirm_eu_compute { |
| 948 | bail!( |
| 949 | "Boat trial Work runs your repository code and Work files on Boat's EU compute. Nothing was sent; re-run with --confirm-eu-compute to agree" |
| 950 | ); |
| 951 | } |
| 952 | let id = validate_work_uuid(id)?; |
| 953 | let operation_key = validate_operation_key(operation_key)?; |
| 954 | let confirmation = confirmation.trim(); |
| 955 | if !valid_confirmation(confirmation) { |
| 956 | bail!("Confirmation must be the value printed by `codewhale account agents work-quote`"); |
| 957 | } |
| 958 | let mut target = load_launch_target(client, id)?; |
| 959 | // A queued Work is checked against the Agent and Project as they are now. |
| 960 | // Any other state is a replay of a launch that already ran (or a Work that |
| 961 | // cannot launch); the control plane's operation ledger decides which, so |
| 962 | // the same command stays safe to repeat after a lost reply. |
| 963 | if target.state == "queued" { |
| 964 | verify_launch_authority(client, &mut target)?; |
| 965 | } |
| 966 | let mut body = launch_body(&target, operation_key)?; |
| 967 | body["launchQuoteConfirmation"] = json!(confirmation); |
| 968 | body["customerEuPlacementConsent"] = json!(true); |
| 969 | |
| 970 | let refused = |err: anyhow::Error| -> anyhow::Error { |
| 971 | if http_code(&err) == Some("boat_task_replay_expired") { |
| 972 | return err.context(format!( |
| 973 | "The launch outcome is unknown and the provider's safe replay window expired. Run `codewhale account agents work-status {id}` and `work-result {id}`; operator reconciliation is required. Do not submit a new operation key or retry the provider allocation" |
| 974 | )); |
| 975 | } |
| 976 | if outcome_unknown(&err) { |
| 977 | return err.context(format!( |
| 978 | "The launch outcome is unknown, and a computer may already be running. Run `codewhale account agents work-status {id}` (and `work-cancel {id}` to stop it). To retry, re-run this exact command with the same --operation-key and --confirmation; it replays the launch and cannot start a second computer" |
| 979 | )); |
| 980 | } |
| 981 | match http_code(&err) { |
| 982 | Some("launch_in_progress" | "launch_idempotency_commit_failed") => err.context( |
| 983 | "This launch is still being recorded. Re-run this exact command with the same --operation-key and --confirmation", |
| 984 | ), |
| 985 | Some(code) => match launch_hint(code) { |
| 986 | Some(hint) => err.context(hint), |
| 987 | None => err.context("The launch was refused"), |
| 988 | }, |
| 989 | None => err, |
| 990 | } |
| 991 | }; |
| 992 | let response = client |
| 993 | .execute_authenticated( |
| 994 | HttpMethod::Post, |
| 995 | "/api/cloud-sessions", |
| 996 | Some(json_body(&body)?), |
| 997 | ) |
| 998 | .map_err(refused)?; |
| 999 | if !matches!(response.status, 200..=202) { |
| 1000 | return Err(refused(response_error(&response))); |
| 1001 | } |
| 1002 | let reply = parse_reply( |
| 1003 | &response.body, |
| 1004 | "The Codewhale service returned an unreadable launch reply", |
| 1005 | ) |
| 1006 | .map_err(refused)?; |
| 1007 | // The service acted, so a reply without its `session` document is an |
| 1008 | // unknown outcome, not a success and not a different Work. |
| 1009 | let Some(session) = reply.get("session").filter(|value| value.is_object()) else { |
| 1010 | return Err(refused( |
| 1011 | CloudTransportError::new( |
| 1012 | "The Codewhale service returned a launch reply without a session", |
| 1013 | std::io::Error::other("missing session"), |
| 1014 | ) |
| 1015 | .into(), |
| 1016 | )); |
| 1017 | }; |
| 1018 | let Some(returned_run_id) = str_at(session, &["run", "id"]) else { |
| 1019 | return Err(refused( |
| 1020 | CloudTransportError::new( |
| 1021 | "The Codewhale service returned a launch reply without a Work ID", |
| 1022 | std::io::Error::other("missing Work ID"), |
| 1023 | ) |
| 1024 | .into(), |
| 1025 | )); |
| 1026 | }; |
| 1027 | if returned_run_id != id { |
| 1028 | bail!( |
| 1029 | "The Codewhale service launched a different Work than requested. Run `codewhale account agents work-status {id}` before doing anything else" |
| 1030 | ); |
| 1031 | } |
| 1032 | writeln!(out, "Work launched on bounded Boat trial compute.")?; |
| 1033 | writeln!(out, "Work ID: {id}")?; |
| 1034 | writeln!( |
| 1035 | out, |
| 1036 | "Status: {}", |
| 1037 | text_at(session, &["run", "state"]).unwrap_or_else(|| "unknown".to_string()) |
| 1038 | )?; |
| 1039 | if let Some(session_id) = text_at(session, &["id"]) { |
| 1040 | writeln!(out, "Session: {session_id}")?; |
| 1041 | } |
| 1042 | if let Some(provider) = text_at(session, &["sandbox", "provider"]) { |
| 1043 | writeln!( |
| 1044 | out, |
| 1045 | "Computer: {provider} ({})", |
| 1046 | text_at(session, &["sandbox", "status"]) |
| 1047 | .unwrap_or_else(|| "status unknown".to_string()) |
| 1048 | )?; |
| 1049 | } |
| 1050 | if let Some(region) = text_at(session, &["sandboxTargetRegion"]) { |
| 1051 | writeln!(out, "Compute region: {region}")?; |
| 1052 | } |
| 1053 | if let Some(charged) = |
| 1054 | at(session, &["quote", "customerCreditsChargedUsd"]).and_then(Value::as_f64) |
| 1055 | { |
| 1056 | writeln!(out, "Codewhale credits charged: {}", usd(charged))?; |
| 1057 | } |
| 1058 | if let Some(attempt) = text_at(session, &["attempt", "status"]) { |
| 1059 | writeln!(out, "Attempt: {attempt}")?; |
| 1060 | } |
| 1061 | if let Some(turn) = text_at(session, &["initialTurn", "status"]) { |
| 1062 | writeln!(out, "First turn: {turn} (not complete yet)")?; |
| 1063 | } |
| 1064 | writeln!(out, "Operation key: {operation_key}")?; |
| 1065 | writeln!( |
| 1066 | out, |
| 1067 | "Follow it: codewhale account agents work-status {id}, then work-result {id}. Stop it: work-cancel {id}" |
| 1068 | )?; |
| 1069 | Ok(()) |
| 1070 | } |
| 1071 | |
| 1072 | /// `codewhale account github bind`. |
| 1073 | pub(super) fn bind_github_repo<T: CloudTransport, W: Write>( |
| 1074 | client: &CloudClient<'_, T>, |
| 1075 | out: &mut W, |
| 1076 | repo: &str, |
| 1077 | installation_id: Option<&str>, |
| 1078 | ) -> Result<()> { |
| 1079 | let repo = validate_github_repo(repo)?; |
| 1080 | let installation_id = installation_id |
| 1081 | .map(|value| { |
| 1082 | let value = value.trim(); |
| 1083 | if value.is_empty() |
| 1084 | || value.len() > 20 |
| 1085 | || !value.bytes().all(|byte| byte.is_ascii_digit()) |
| 1086 | { |
| 1087 | bail!("GitHub installation ID must be the numeric ID of the installed app"); |
| 1088 | } |
| 1089 | Ok(value.to_string()) |
| 1090 | }) |
| 1091 | .transpose()?; |
| 1092 | let listing: GitHubBindingListResponse = serde_json::from_value(client.github_bindings()?) |
| 1093 | .context("The Codewhale service returned an invalid GitHub repository list")?; |
| 1094 | let usable = |status: &str| { |
| 1095 | !["error", "revoked", "suspended", "disabled"] |
| 1096 | .contains(&status.to_ascii_lowercase().as_str()) |
| 1097 | }; |
| 1098 | if let Some(existing) = listing.bindings.iter().find(|binding| { |
| 1099 | binding.provider == "github" |
| 1100 | && binding.repo.eq_ignore_ascii_case(&repo) |
| 1101 | && !binding.installation_id.is_empty() |
| 1102 | && usable(&binding.status) |
| 1103 | && installation_id |
| 1104 | .as_deref() |
| 1105 | .is_none_or(|requested| requested == binding.installation_id) |
| 1106 | }) { |
| 1107 | writeln!( |
| 1108 | out, |
| 1109 | "{} is already connected: {} ({})", |
| 1110 | printable(&existing.repo), |
| 1111 | printable(&existing.id), |
| 1112 | printable(&existing.status) |
| 1113 | )?; |
| 1114 | writeln!( |
| 1115 | out, |
| 1116 | "Create a Project: codewhale account projects create NAME --repo-binding-id {} --operation-key <new-key>", |
| 1117 | printable(&existing.id) |
| 1118 | )?; |
| 1119 | return Ok(()); |
| 1120 | } |
| 1121 | let installation_id = match installation_id { |
| 1122 | Some(id) => id, |
| 1123 | None => { |
| 1124 | let mut known = listing |
| 1125 | .bindings |
| 1126 | .iter() |
| 1127 | .filter(|binding| binding.provider == "github") |
| 1128 | .map(|binding| binding.installation_id.as_str()) |
| 1129 | .filter(|id| !id.is_empty()) |
| 1130 | .collect::<Vec<_>>(); |
| 1131 | known.sort_unstable(); |
| 1132 | known.dedup(); |
| 1133 | match known.as_slice() { |
| 1134 | [only] => (*only).to_string(), |
| 1135 | [] => bail!( |
| 1136 | "No GitHub App installation is known for this account yet. Install the Codewhale GitHub App, then pass its numeric ID with --installation-id" |
| 1137 | ), |
| 1138 | many => bail!( |
| 1139 | "This account has several GitHub App installations ({}); choose one with --installation-id", |
| 1140 | many.iter() |
| 1141 | .map(|id| printable(id)) |
| 1142 | .collect::<Vec<_>>() |
| 1143 | .join(", ") |
| 1144 | ), |
| 1145 | } |
| 1146 | } |
| 1147 | }; |
| 1148 | let response = client |
| 1149 | .execute_authenticated( |
| 1150 | HttpMethod::Post, |
| 1151 | "/api/integrations/github/bindings", |
| 1152 | Some(json_body(&json!({ |
| 1153 | "installationId": installation_id, |
| 1154 | "repo": repo, |
| 1155 | }))?), |
| 1156 | ) |
| 1157 | .map_err(|err| { |
| 1158 | if outcome_unknown(&err) { |
| 1159 | err.context( |
| 1160 | "The bind outcome is unknown. Run `codewhale account github bindings` to see whether it was saved; binding is safe to repeat", |
| 1161 | ) |
| 1162 | } else { |
| 1163 | err |
| 1164 | } |
| 1165 | })?; |
| 1166 | if response.status == 404 { |
| 1167 | return Err(response_error(&response)).context( |
| 1168 | "GitHub repository bindings are unavailable on this Codewhale API, or this repository is not reachable through that installation", |
| 1169 | ); |
| 1170 | } |
| 1171 | if !matches!(response.status, 200 | 201) { |
| 1172 | let err = response_error(&response); |
| 1173 | return Err(if outcome_unknown(&err) { |
| 1174 | err.context( |
| 1175 | "The bind outcome is unknown. Run `codewhale account github bindings` to see whether it was saved; binding is safe to repeat", |
| 1176 | ) |
| 1177 | } else { |
| 1178 | err |
| 1179 | }); |
| 1180 | } |
| 1181 | let reply = parse_reply(&response.body, "The Codewhale service returned an unreadable bind reply") |
| 1182 | .map_err(|err| { |
| 1183 | err.context( |
| 1184 | "The bind outcome is unknown. Run `codewhale account github bindings` to see whether it was saved", |
| 1185 | ) |
| 1186 | })?; |
| 1187 | let binding: AccountGitHubBinding = |
| 1188 | serde_json::from_value(reply.get("binding").cloned().unwrap_or(Value::Null)) |
| 1189 | .context("The Codewhale service returned an invalid GitHub repository binding")?; |
| 1190 | if binding.provider != "github" |
| 1191 | || binding.id.is_empty() |
| 1192 | || !binding.repo.eq_ignore_ascii_case(&repo) |
| 1193 | { |
| 1194 | bail!("The Codewhale service returned a binding for a different repository"); |
| 1195 | } |
| 1196 | writeln!( |
| 1197 | out, |
| 1198 | "Connected {}: {} ({})", |
| 1199 | printable(&binding.repo), |
| 1200 | printable(&binding.id), |
| 1201 | printable(&binding.status) |
| 1202 | )?; |
| 1203 | writeln!( |
| 1204 | out, |
| 1205 | "Create a Project: codewhale account projects create NAME --repo-binding-id {} --operation-key <new-key>", |
| 1206 | printable(&binding.id) |
| 1207 | )?; |
| 1208 | Ok(()) |
| 1209 | } |
| 1210 |