| 1 | //! `agent action=start runtime:"cloud"`: the model may propose a long job for |
| 2 | //! a cloud sandbox; only a person can start it. |
| 3 | //! |
| 4 | //! This is a thin front door onto `/dispatch`. It plans through |
| 5 | //! [`crate::cloud_dispatch::plan_dispatch`] and queues the job through |
| 6 | //! [`crate::cloud_dispatch::execute_dispatch`] with `confirm = false`, which |
| 7 | //! saves a `proposed` record and nothing else: no sandbox, no push, no spend. |
| 8 | //! The person confirms with `/dispatch confirm <id>`, and `confirm_job` runs |
| 9 | //! the same fail-closed credential and machine-token gates it always has. |
| 10 | //! Nothing here can confirm, so there is no auto-confirm to guard. |
| 11 | |
| 12 | use std::path::Path; |
| 13 | |
| 14 | use serde_json::{Value, json}; |
| 15 | |
| 16 | use crate::cloud_dispatch::{ |
| 17 | CloudJobStore, CredentialState, DispatchOutcome, Forge, GitRemote, MachineTokenState, |
| 18 | discover_credentials, discover_machine_token, discover_remotes, execute_dispatch, format_job, |
| 19 | missing_credentials_message, missing_machine_token_message, plan_dispatch, |
| 20 | }; |
| 21 | use crate::tools::spec::{ToolError, ToolResult}; |
| 22 | |
| 23 | /// Where an `agent action=start` runs. |
| 24 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 25 | pub(super) enum StartRuntime { |
| 26 | Local, |
| 27 | Cloud, |
| 28 | } |
| 29 | |
| 30 | /// Fields a cloud proposal reads. Everything else on `start` shapes a local |
| 31 | /// child (scopes, roles, worktrees, limits); those are not refused, because a |
| 32 | /// retry costs more than they are worth, but the result names them as unused |
| 33 | /// so neither the model nor the person assumes they applied. |
| 34 | const CLOUD_START_FIELDS: &[&str] = &["action", "op", "runtime", "prompt", "remote"]; |
| 35 | |
| 36 | pub(super) fn parse_start_runtime(input: &Value) -> Result<StartRuntime, ToolError> { |
| 37 | match input.get("runtime") { |
| 38 | None | Some(Value::Null) => Ok(StartRuntime::Local), |
| 39 | Some(Value::String(value)) => match value.trim().to_ascii_lowercase().as_str() { |
| 40 | "" | "local" => Ok(StartRuntime::Local), |
| 41 | "cloud" => Ok(StartRuntime::Cloud), |
| 42 | other => Err(ToolError::invalid_input(format!( |
| 43 | "runtime must be local or cloud, not `{other}`." |
| 44 | ))), |
| 45 | }, |
| 46 | Some(_) => Err(ToolError::invalid_input( |
| 47 | "runtime must be a string: local or cloud.", |
| 48 | )), |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | pub(super) fn is_cloud_start(input: &Value) -> bool { |
| 53 | matches!(parse_start_runtime(input), Ok(StartRuntime::Cloud)) |
| 54 | } |
| 55 | |
| 56 | /// Queue a cloud proposal for the person to confirm. Never spawns. |
| 57 | pub(super) fn propose_cloud_run( |
| 58 | input: &Value, |
| 59 | workspace: &Path, |
| 60 | spawn_depth: u32, |
| 61 | ) -> Result<ToolResult, ToolError> { |
| 62 | if spawn_depth > 0 { |
| 63 | return Err(ToolError::permission_denied( |
| 64 | "Only the main session can propose a cloud run. Say in your report that this work needs one.", |
| 65 | )); |
| 66 | } |
| 67 | let store = CloudJobStore::from_env() |
| 68 | .map_err(|error| ToolError::execution_failed(error.to_string()))?; |
| 69 | let payload = propose_with( |
| 70 | input, |
| 71 | &discover_remotes(workspace), |
| 72 | &store, |
| 73 | &discover_credentials(), |
| 74 | &discover_machine_token(), |
| 75 | )?; |
| 76 | let mut result = |
| 77 | ToolResult::json(&payload).map_err(|e| ToolError::execution_failed(e.to_string()))?; |
| 78 | result.metadata = Some(json!({ |
| 79 | "action": "start", |
| 80 | "runtime": "cloud", |
| 81 | "status": "proposed", |
| 82 | "job_id": payload["job_id"], |
| 83 | })); |
| 84 | Ok(result) |
| 85 | } |
| 86 | |
| 87 | fn propose_with( |
| 88 | input: &Value, |
| 89 | remotes: &[GitRemote], |
| 90 | store: &CloudJobStore, |
| 91 | credentials: &CredentialState, |
| 92 | machine_token: &MachineTokenState, |
| 93 | ) -> Result<Value, ToolError> { |
| 94 | let mut unused: Vec<&str> = input |
| 95 | .as_object() |
| 96 | .map(|object| { |
| 97 | object |
| 98 | .keys() |
| 99 | .map(String::as_str) |
| 100 | .filter(|key| !CLOUD_START_FIELDS.contains(key)) |
| 101 | .collect() |
| 102 | }) |
| 103 | .unwrap_or_default(); |
| 104 | unused.sort_unstable(); |
| 105 | let prompt = match input.get("prompt") { |
| 106 | Some(Value::String(prompt)) => prompt.as_str(), |
| 107 | _ => { |
| 108 | return Err(ToolError::invalid_input( |
| 109 | "runtime=\"cloud\" needs prompt: the task the cloud agent should do.", |
| 110 | )); |
| 111 | } |
| 112 | }; |
| 113 | let remote = match input.get("remote") { |
| 114 | None | Some(Value::Null) => None, |
| 115 | Some(Value::String(value)) => Some( |
| 116 | Forge::parse(value) |
| 117 | .ok_or_else(|| ToolError::invalid_input("remote must be github, cnb, or gitee."))?, |
| 118 | ), |
| 119 | Some(_) => { |
| 120 | return Err(ToolError::invalid_input( |
| 121 | "remote must be a string: github, cnb, or gitee.", |
| 122 | )); |
| 123 | } |
| 124 | }; |
| 125 | let plan = plan_dispatch(remotes, prompt, remote, None) |
| 126 | .map_err(|error| ToolError::invalid_input(error.to_string()))?; |
| 127 | // `confirm = false` saves a proposal and returns before any credential, |
| 128 | // sandbox or forge call. Confirmation belongs to the person. |
| 129 | let job = match execute_dispatch(store, plan, false, credentials, machine_token) |
| 130 | .map_err(|error| ToolError::execution_failed(error.to_string()))? |
| 131 | { |
| 132 | DispatchOutcome::Proposal(job) => job, |
| 133 | DispatchOutcome::Refused(job) | DispatchOutcome::Accepted(job) => { |
| 134 | return Err(ToolError::execution_failed(format!( |
| 135 | "cloud job {} did not come back as a proposal; nothing was started from here.", |
| 136 | job.id |
| 137 | ))); |
| 138 | } |
| 139 | }; |
| 140 | // Say now what confirm will refuse, so the person is not asked to |
| 141 | // approve a job that cannot run. |
| 142 | let blocked_by = match (credentials, machine_token) { |
| 143 | (CredentialState::Missing, _) => Some(missing_credentials_message()), |
| 144 | (_, MachineTokenState::Missing) => Some(missing_machine_token_message()), |
| 145 | _ => None, |
| 146 | }; |
| 147 | let mut summary = match &blocked_by { |
| 148 | None => format!( |
| 149 | "Proposed cloud job {id}. Nothing runs or spends until the person types \ |
| 150 | `/dispatch confirm {id}`. Do not confirm it yourself; tell the person it is waiting.", |
| 151 | id = job.id |
| 152 | ), |
| 153 | Some(reason) => format!( |
| 154 | "Proposed cloud job {id}, but confirming it will be refused until this is fixed: {reason}", |
| 155 | id = job.id |
| 156 | ), |
| 157 | }; |
| 158 | if !unused.is_empty() { |
| 159 | summary.push_str(&format!( |
| 160 | " Not used by a cloud job, which gets only the prompt: {}.", |
| 161 | unused.join(", ") |
| 162 | )); |
| 163 | } |
| 164 | Ok(json!({ |
| 165 | "action": "start", |
| 166 | "runtime": "cloud", |
| 167 | "status": "proposed", |
| 168 | "started": false, |
| 169 | "job_id": job.id, |
| 170 | "confirm_with": format!("/dispatch confirm {}", job.id), |
| 171 | "cancel_with": format!("/dispatch cancel {}", job.id), |
| 172 | "ready_to_confirm": blocked_by.is_none(), |
| 173 | "blocked_by": blocked_by, |
| 174 | "unused_fields": unused, |
| 175 | "summary": summary, |
| 176 | "card": format_job(&job), |
| 177 | })) |
| 178 | } |
| 179 | |
| 180 | #[cfg(test)] |
| 181 | mod tests { |
| 182 | use super::*; |
| 183 | use crate::cloud_dispatch::{CloudJobStatus, CredentialSource}; |
| 184 | |
| 185 | fn github_remote() -> Vec<GitRemote> { |
| 186 | vec![GitRemote { |
| 187 | name: "github".to_string(), |
| 188 | url: "https://github.com/example/repo.git".to_string(), |
| 189 | }] |
| 190 | } |
| 191 | |
| 192 | const READY: CredentialState = CredentialState::Present { |
| 193 | source: CredentialSource::Env, |
| 194 | }; |
| 195 | |
| 196 | #[test] |
| 197 | fn runtime_defaults_to_local_and_rejects_unknown_values() { |
| 198 | assert_eq!( |
| 199 | parse_start_runtime(&json!({"prompt": "x"})).unwrap(), |
| 200 | StartRuntime::Local |
| 201 | ); |
| 202 | assert_eq!( |
| 203 | parse_start_runtime(&json!({"runtime": "Cloud"})).unwrap(), |
| 204 | StartRuntime::Cloud |
| 205 | ); |
| 206 | assert!(parse_start_runtime(&json!({"runtime": "moon"})).is_err()); |
| 207 | assert!(parse_start_runtime(&json!({"runtime": true})).is_err()); |
| 208 | assert!(is_cloud_start( |
| 209 | &json!({"action": "start", "runtime": "cloud"}) |
| 210 | )); |
| 211 | assert!(!is_cloud_start(&json!({"action": "start"}))); |
| 212 | } |
| 213 | |
| 214 | #[test] |
| 215 | fn cloud_start_only_queues_a_proposal_for_the_person() { |
| 216 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 217 | let store = CloudJobStore::from_path(tmp.path().join("cloud-jobs")); |
| 218 | let payload = propose_with( |
| 219 | &json!({"action": "start", "runtime": "cloud", "prompt": "run the full soak suite and fix flakes"}), |
| 220 | &github_remote(), |
| 221 | &store, |
| 222 | &READY, |
| 223 | &MachineTokenState::Present, |
| 224 | ) |
| 225 | .expect("proposal"); |
| 226 | assert_eq!(payload["status"], "proposed"); |
| 227 | assert_eq!(payload["started"], false); |
| 228 | assert_eq!(payload["ready_to_confirm"], true); |
| 229 | let id = payload["job_id"].as_str().expect("job id"); |
| 230 | assert_eq!(payload["confirm_with"], format!("/dispatch confirm {id}")); |
| 231 | assert!( |
| 232 | payload["summary"] |
| 233 | .as_str() |
| 234 | .unwrap() |
| 235 | .contains("Do not confirm it yourself") |
| 236 | ); |
| 237 | |
| 238 | // The queued record is the same card `/dispatch show` renders, still |
| 239 | // unconfirmed, with no sandbox. |
| 240 | let jobs = store.list().expect("jobs"); |
| 241 | assert_eq!(jobs.len(), 1); |
| 242 | let job = &jobs[0]; |
| 243 | assert_eq!(job.id, id); |
| 244 | assert_eq!(job.status, CloudJobStatus::Proposed); |
| 245 | assert!(!job.confirmed); |
| 246 | assert!(job.sandbox_id.is_none()); |
| 247 | assert_eq!(payload["card"], format_job(job)); |
| 248 | } |
| 249 | |
| 250 | #[test] |
| 251 | fn a_missing_machine_token_is_named_before_the_person_confirms() { |
| 252 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 253 | let store = CloudJobStore::from_path(tmp.path().join("cloud-jobs")); |
| 254 | let payload = propose_with( |
| 255 | &json!({"runtime": "cloud", "prompt": "long job"}), |
| 256 | &github_remote(), |
| 257 | &store, |
| 258 | &READY, |
| 259 | &MachineTokenState::Missing, |
| 260 | ) |
| 261 | .expect("proposal is still queued"); |
| 262 | assert_eq!(payload["ready_to_confirm"], false); |
| 263 | assert_eq!(payload["blocked_by"], missing_machine_token_message()); |
| 264 | assert_eq!( |
| 265 | store.list().expect("jobs")[0].status, |
| 266 | CloudJobStatus::Proposed, |
| 267 | "the gate runs at confirm, not here" |
| 268 | ); |
| 269 | } |
| 270 | |
| 271 | #[test] |
| 272 | fn local_only_fields_are_named_as_unused_not_refused() { |
| 273 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 274 | let store = CloudJobStore::from_path(tmp.path().join("cloud-jobs")); |
| 275 | let payload = propose_with( |
| 276 | &json!({"runtime": "cloud", "prompt": "x", "worktree": true, "type": "implement"}), |
| 277 | &github_remote(), |
| 278 | &store, |
| 279 | &READY, |
| 280 | &MachineTokenState::Present, |
| 281 | ) |
| 282 | .expect("a proposal, not a retry"); |
| 283 | assert_eq!(payload["unused_fields"], json!(["type", "worktree"])); |
| 284 | assert!( |
| 285 | payload["summary"] |
| 286 | .as_str() |
| 287 | .unwrap() |
| 288 | .contains("gets only the prompt: type, worktree."), |
| 289 | "{payload}" |
| 290 | ); |
| 291 | assert_eq!(store.list().expect("jobs").len(), 1); |
| 292 | } |
| 293 | |
| 294 | #[test] |
| 295 | fn bad_remotes_and_missing_prompts_are_refused_without_a_record() { |
| 296 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 297 | let store = CloudJobStore::from_path(tmp.path().join("cloud-jobs")); |
| 298 | let error = propose_with( |
| 299 | &json!({"runtime": "cloud", "prompt": "x", "remote": "bitbucket"}), |
| 300 | &github_remote(), |
| 301 | &store, |
| 302 | &READY, |
| 303 | &MachineTokenState::Present, |
| 304 | ) |
| 305 | .expect_err("unknown forge refused"); |
| 306 | assert!( |
| 307 | error.to_string().contains("github, cnb, or gitee"), |
| 308 | "{error}" |
| 309 | ); |
| 310 | assert!( |
| 311 | propose_with( |
| 312 | &json!({"runtime": "cloud"}), |
| 313 | &github_remote(), |
| 314 | &store, |
| 315 | &READY, |
| 316 | &MachineTokenState::Present, |
| 317 | ) |
| 318 | .is_err(), |
| 319 | "prompt is required" |
| 320 | ); |
| 321 | assert!( |
| 322 | propose_with( |
| 323 | &json!({"runtime": "cloud", "prompt": "x"}), |
| 324 | &[], |
| 325 | &store, |
| 326 | &READY, |
| 327 | &MachineTokenState::Present, |
| 328 | ) |
| 329 | .is_err(), |
| 330 | "no forge remote, no proposal" |
| 331 | ); |
| 332 | assert!(store.list().expect("jobs").is_empty()); |
| 333 | } |
| 334 | |
| 335 | #[test] |
| 336 | fn a_child_agent_cannot_propose() { |
| 337 | let error = propose_cloud_run( |
| 338 | &json!({"runtime": "cloud", "prompt": "x"}), |
| 339 | Path::new("."), |
| 340 | 1, |
| 341 | ) |
| 342 | .expect_err("children refused"); |
| 343 | assert!(error.to_string().contains("main session"), "{error}"); |
| 344 | } |
| 345 | } |
| 346 |