返回 CodeWhale
cloud_proposal.rs
根目录 / crates / tui / src / tools / subagent / cloud_proposal.rs
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
346 lines RUST