返回 CodeWhale
work.rs
根目录 / crates / cli / src / cloud / work.rs
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(&quote, &["disclosure", "computerTime", "estimatedSeconds"]);
869 let expected = at(&quote, &["disclosure", "funding"]).and_then(Value::as_str)
870 == Some("provider_trial")
871 && at(&quote, &["disclosure", "customerCreditsChargedUsd"]).and_then(Value::as_f64)
872 == Some(0.0)
873 && str_at(&quote, &["quote", "sku"]) == Some(BOAT_TRIAL_SKU)
874 && str_at(&quote, &["quote", "adapter"]) == Some("boat")
875 && str_at(&quote, &["disclosure", "sandboxTargetRegion"]) == Some("eu")
876 && str_at(&quote, &["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(&quote, &["confirmation", "token"]).unwrap_or_default();
884 if !valid_confirmation(token)
885 || str_at(&quote, &["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(&quote, &["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(&quote, &["confirmCopy", "title"]) {
918 writeln!(out, "{title}")?;
919 }
920 if let Some(copy) = str_at(&quote, &["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(&quote, &["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
1210 lines RUST