返回 CodeWhale
automation.rs
根目录 / crates / tui / src / tools / automation.rs
1 //! Model-visible automation tools over `AutomationManager`.
2 //!
3 //! Unified surface (piagent phase B): the model sees one tool, `automation`,
4 //! with an `action` parameter routing to the per-action logic. The legacy
5 //! `automation_*` execution aliases were removed in v0.9.3.
6
7 use std::path::PathBuf;
8
9 use async_trait::async_trait;
10 use serde_json::{Value, json};
11
12 use crate::automation_manager::{
13 AUTOMATION_WATCHER_NO_REPORT_SENTINEL, AutomationDeliveryMode, AutomationRecord,
14 AutomationStatus, CreateAutomationRequest, UpdateAutomationRequest, run_now_shared,
15 };
16 use crate::tools::spec::{
17 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
18 optional_str, optional_u64, required_str,
19 };
20
21 /// Why an unbound host cannot start durable work, in the model's own terms.
22 pub(crate) const DISPATCH_OWNER_HINT: &str = "durable scheduling and runs need a host with an attached persistent execution owner (the Runtime or the interactive session). This one-shot host can inspect automations and store paused definitions only.";
23
24 /// Refuse work that would promise dispatch this host cannot deliver.
25 ///
26 /// An `AutomationManager` is bound to a task-execution scope only after
27 /// `bind_task_manager`, and the scheduler admits a record only in its own
28 /// scope (`collect_due_runs` skips every unbound and foreign one). A one-shot
29 /// host — headless `exec` — attaches the shared store for inspection but owns
30 /// no execution lease, so anything it marks Active would carry a `next_run_at`
31 /// that nothing can honor: a schedule that silently never fires.
32 ///
33 /// Paused definitions stay honest and are deliberately still allowed: they are
34 /// inert by definition, and the first resume from an owning host adopts them
35 /// into that host's scope (`update_automation_unlocked`).
36 pub(crate) fn require_dispatch_owner(
37 manager: &crate::automation_manager::AutomationManager,
38 intent: &str,
39 ) -> Result<(), ToolError> {
40 if manager.execution_scope().is_some() {
41 return Ok(());
42 }
43 Err(ToolError::not_available(format!(
44 "cannot {intent}: {DISPATCH_OWNER_HINT}"
45 )))
46 }
47
48 /// Read-only actions — these are the only ones the Plan-mode surface exposes.
49 const READ_ACTIONS: &[&str] = &["list", "read"];
50 const ALL_ACTIONS: &[&str] = &[
51 "create", "list", "read", "update", "pause", "resume", "delete", "run",
52 ];
53
54 /// Unified automation tool.
55 ///
56 /// One struct, one input schema per surface: the canonical `automation`
57 /// tool (all actions, or the read-only subset via [`AutomationTool::read_only`])
58 /// plus hidden legacy aliases carrying a `forced_action`.
59 pub struct AutomationTool {
60 name: &'static str,
61 forced_action: Option<&'static str>,
62 read_only: bool,
63 }
64
65 impl AutomationTool {
66 pub const fn new(name: &'static str) -> Self {
67 Self {
68 name,
69 forced_action: None,
70 read_only: false,
71 }
72 }
73
74 /// Plan-mode variant: only the read-only actions are advertised and routed.
75 pub const fn read_only(name: &'static str) -> Self {
76 Self {
77 name,
78 forced_action: None,
79 read_only: true,
80 }
81 }
82
83 #[cfg(test)]
84 pub const fn alias(name: &'static str, action: &'static str) -> Self {
85 Self {
86 name,
87 forced_action: Some(action),
88 read_only: false,
89 }
90 }
91
92 fn allowed_actions(&self) -> &'static [&'static str] {
93 if self.read_only {
94 READ_ACTIONS
95 } else {
96 ALL_ACTIONS
97 }
98 }
99
100 fn resolve_action<'a>(&'a self, input: &'a Value) -> Result<&'a str, ToolError> {
101 let action = match self.forced_action {
102 Some(action) => action,
103 None => input.get("action").and_then(Value::as_str).ok_or_else(|| {
104 ToolError::invalid_input(format!(
105 "automation: missing `action` (one of: {})",
106 self.allowed_actions().join(", ")
107 ))
108 })?,
109 };
110 if self.allowed_actions().contains(&action) {
111 Ok(action)
112 } else {
113 Err(ToolError::invalid_input(format!(
114 "automation: invalid action `{action}` (one of: {})",
115 self.allowed_actions().join(", ")
116 )))
117 }
118 }
119
120 fn action_is_read(action: &str) -> bool {
121 READ_ACTIONS.contains(&action)
122 }
123 }
124
125 #[async_trait]
126 impl ToolSpec for AutomationTool {
127 fn name(&self) -> &'static str {
128 self.name
129 }
130
131 fn model_visible(&self) -> bool {
132 self.forced_action.is_none()
133 }
134
135 fn description(&self) -> &'static str {
136 match self.forced_action {
137 Some("create") => {
138 "Create a durable scheduled automation. Creation requires approval. Supported schedules: FREQ=ONCE;AT=YYYY-MM-DDTHH:MM[:SS] (local time) or RFC3339, FREQ=HOURLY..., FREQ=WEEKLY..., and FREQ=CRON;EXPR=<standard 5-field local cron>. delivery_mode=watcher is for condition checks: return EXACTLY NOTHING_TO_REPORT when there is no change."
139 }
140 Some("list") => {
141 "List durable automations with status, next run, and last run timestamps."
142 }
143 Some("read") => "Read one durable automation plus recent run records.",
144 Some("update") => {
145 "Update a durable automation. Requires approval; schedules support ONCE, HOURLY, WEEKLY, and 5-field CRON forms."
146 }
147 Some("pause") => "Pause a durable automation. Requires approval.",
148 Some("resume") => "Resume a paused durable automation. Requires approval.",
149 Some("delete") => "Delete a durable automation and its run history. Requires approval.",
150 Some("run") => {
151 "Run an automation now. The run enqueues a normal durable task and returns linked task/thread/turn ids as they become available."
152 }
153 _ if self.read_only => {
154 "Inspect durable scheduled automations. Actions: \"list\" (status, next run, last run) and \"read\" (one automation plus recent run records)."
155 }
156 _ => {
157 "Manage durable scheduled automations. Actions: \"create\" (approval; schedules support ONCE, HOURLY, WEEKLY, and 5-field CRON forms; watcher mode uses EXACT NOTHING_TO_REPORT for no-change checks), \"list\", \"read\", \"update\" (approval), \"pause\" (approval), \"resume\" (approval), \"delete\" (approval), \"run\" (approval)."
158 }
159 }
160 }
161
162 fn input_schema(&self) -> Value {
163 if let Some(action) = self.forced_action {
164 return legacy_action_schema(action);
165 }
166 let actions: Vec<&str> = self.allowed_actions().to_vec();
167 let mut properties = serde_json::Map::new();
168 properties.insert(
169 "action".to_string(),
170 json!({
171 "type": "string",
172 "enum": actions,
173 "description": "Action to perform."
174 }),
175 );
176 if !self.read_only {
177 properties.insert(
178 "name".to_string(),
179 json!({ "type": "string", "description": "Automation name (action=create/update)." }),
180 );
181 properties.insert(
182 "prompt".to_string(),
183 json!({ "type": "string", "description": "Prompt for scheduled runs (action=create/update)." }),
184 );
185 properties.insert(
186 "rrule".to_string(),
187 json!({
188 "type": "string",
189 "description": "Supported: FREQ=ONCE;AT=2026-08-03T14:30 (local time or RFC3339), FREQ=HOURLY;INTERVAL=N[;BYDAY=MO,TU][;BYHOUR=9][;BYMINUTE=30], FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=30, or FREQ=CRON;EXPR=*/17 * * * *. Cron uses standard 5-field local time. For HOURLY, BYHOUR/BYMINUTE choose the initial local wall-clock anchor and INTERVAL advances from that anchor; BYHOUR is not a daily-only filter. Anchored wall times skip nonexistent clock times and use the first occurrence of ambiguous clock times. (action=create/update)"
190 }),
191 );
192 properties.insert(
193 "cwds".to_string(),
194 json!({ "type": "array", "items": { "type": "string" }, "description": "Working directories for scheduled runs (action=create/update)." }),
195 );
196 properties.insert(
197 "model_provider".to_string(),
198 json!({ "type": "string", "description": "Provider kind for the pinned model. Omit to inherit the configured provider." }),
199 );
200 properties.insert(
201 "model_provider_id".to_string(),
202 json!({ "type": "string", "description": "Exact configured provider id, including named custom routes. Keeps the model on that route." }),
203 );
204 properties.insert(
205 "model".to_string(),
206 json!({ "type": "string", "description": "Model id for scheduled runs (action=create/update)." }),
207 );
208 properties.insert(
209 "mode".to_string(),
210 json!({ "type": "string", "description": "Task mode for scheduled runs. Defaults to agent when omitted. (action=create/update)" }),
211 );
212 properties.insert(
213 "allow_shell".to_string(),
214 json!({ "type": "boolean", "default": false, "description": "(action=create/update)" }),
215 );
216 properties.insert(
217 "trust_mode".to_string(),
218 json!({ "type": "boolean", "default": false, "description": "(action=create/update)" }),
219 );
220 properties.insert(
221 "auto_approve".to_string(),
222 json!({ "type": "boolean", "default": false, "description": "(action=create/update)" }),
223 );
224 properties.insert(
225 "delivery_mode".to_string(),
226 json!({
227 "type": "string",
228 "enum": ["task", "watcher"],
229 "default": "task",
230 "description": format!("Delivery mode for scheduled checks. \"task\" creates a normal durable background run. \"watcher\" is for condition-shaped prompts; when there is no change, return EXACTLY {AUTOMATION_WATCHER_NO_REPORT_SENTINEL}. (action=create/update)")
231 }),
232 );
233 properties.insert(
234 "paused".to_string(),
235 json!({ "type": "boolean", "default": false, "description": "Create the automation paused (action=create)." }),
236 );
237 properties.insert(
238 "status".to_string(),
239 json!({ "type": "string", "enum": ["active", "paused"], "description": "(action=update)" }),
240 );
241 }
242 properties.insert(
243 "automation_id".to_string(),
244 json!({ "type": "string", "description": "Target automation id (action=read/update/pause/resume/delete/run)." }),
245 );
246 properties.insert(
247 "limit".to_string(),
248 json!({ "type": "integer", "minimum": 1, "maximum": 100, "default": 50, "description": "(action=list)" }),
249 );
250 json!({
251 "type": "object",
252 "properties": properties,
253 "additionalProperties": false
254 })
255 }
256
257 fn capabilities(&self) -> Vec<ToolCapability> {
258 match self.forced_action {
259 Some(action) if Self::action_is_read(action) => vec![ToolCapability::ReadOnly],
260 // `run` executes a stored automation now; the other mutating
261 // actions schedule one to execute later, with its own prompt, cwd,
262 // and task mode. Declaring only `RequiresApproval` described the
263 // *approval* consequence and hid the *execution* one, which left
264 // every capability-derived policy — including the child execution
265 // envelope — unable to see that this family spawns agent runs.
266 Some(_) => vec![
267 ToolCapability::ExecutesCode,
268 ToolCapability::RequiresApproval,
269 ],
270 None if self.read_only => vec![ToolCapability::ReadOnly],
271 None => vec![
272 ToolCapability::ExecutesCode,
273 ToolCapability::RequiresApproval,
274 ],
275 }
276 }
277
278 fn approval_requirement(&self) -> ApprovalRequirement {
279 match self.forced_action {
280 Some(action) if Self::action_is_read(action) => ApprovalRequirement::Auto,
281 Some(_) => ApprovalRequirement::Required,
282 None if self.read_only => ApprovalRequirement::Auto,
283 None => ApprovalRequirement::Required,
284 }
285 }
286
287 fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
288 match self.resolve_action(input) {
289 Ok(action) if Self::action_is_read(action) => ApprovalRequirement::Auto,
290 _ => ApprovalRequirement::Required,
291 }
292 }
293
294 fn is_read_only_for(&self, input: &Value) -> bool {
295 match self.resolve_action(input) {
296 Ok(action) => Self::action_is_read(action),
297 Err(_) => self.is_read_only(),
298 }
299 }
300
301 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
302 crate::core::engine::tool_catalog::enforce_tool_denial(
303 context,
304 self.name(),
305 &json!({"action": self.resolve_action(&input)?}),
306 )?;
307 match self.resolve_action(&input)? {
308 "create" => self.execute_create(&input, context).await,
309 "list" => self.execute_list(&input, context).await,
310 "read" => self.execute_read(&input, context).await,
311 "update" => self.execute_update(&input, context).await,
312 "pause" => self.execute_simple(context, &input, "pause").await,
313 "resume" => self.execute_simple(context, &input, "resume").await,
314 "delete" => self.execute_simple(context, &input, "delete").await,
315 "run" => self.execute_run(&input, context).await,
316 action => Err(ToolError::invalid_input(format!(
317 "automation: invalid action `{action}`"
318 ))),
319 }
320 }
321 }
322
323 impl AutomationTool {
324 async fn execute_create(
325 &self,
326 input: &Value,
327 context: &ToolContext,
328 ) -> Result<ToolResult, ToolError> {
329 let manager = context
330 .runtime
331 .automations
332 .as_ref()
333 .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?;
334 let manager = manager.lock().await;
335 // A scheduled run keeps no creating session to ask, so the authority
336 // it asks for is capped at what this session holds now.
337 let (allow_shell, trust_mode, auto_approve) = context.cap_delegated_authority(
338 optional_bool_value(input, "allow_shell"),
339 optional_bool_value(input, "trust_mode"),
340 optional_bool_value(input, "auto_approve"),
341 );
342 let req = CreateAutomationRequest {
343 name: required_str(input, "name")?.to_string(),
344 prompt: required_str(input, "prompt")?.to_string(),
345 rrule: required_str(input, "rrule")?.to_string(),
346 cwds: session_reachable_cwds(context, string_array(input, "cwds")?)?,
347 model: optional_str(input, "model")?.map(ToString::to_string),
348 model_provider: optional_str(input, "model_provider")?.map(ToString::to_string),
349 model_provider_id: optional_str(input, "model_provider_id")?.map(ToString::to_string),
350 mode: optional_str(input, "mode")?.map(ToString::to_string),
351 allow_shell,
352 trust_mode,
353 auto_approve,
354 delivery_mode: optional_delivery_mode(input)?,
355 status: Some(
356 if input
357 .get("paused")
358 .and_then(Value::as_bool)
359 .unwrap_or(false)
360 {
361 AutomationStatus::Paused
362 } else {
363 AutomationStatus::Active
364 },
365 ),
366 };
367 if req.status != Some(AutomationStatus::Paused) {
368 require_dispatch_owner(&manager, "create an active automation")?;
369 }
370 let automation = manager
371 .create_automation(req)
372 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
373 ToolResult::json(&automation).map_err(|e| ToolError::execution_failed(e.to_string()))
374 }
375
376 async fn execute_list(
377 &self,
378 input: &Value,
379 context: &ToolContext,
380 ) -> Result<ToolResult, ToolError> {
381 let manager = context
382 .runtime
383 .automations
384 .as_ref()
385 .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?;
386 let manager = manager.lock().await;
387 let mut automations = manager
388 .list_automations()
389 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
390 automations.truncate(optional_u64(input, "limit", 50)?.clamp(1, 100) as usize);
391 ToolResult::json(&automations).map_err(|e| ToolError::execution_failed(e.to_string()))
392 }
393
394 async fn execute_read(
395 &self,
396 input: &Value,
397 context: &ToolContext,
398 ) -> Result<ToolResult, ToolError> {
399 let manager = context
400 .runtime
401 .automations
402 .as_ref()
403 .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?;
404 let manager = manager.lock().await;
405 let id = required_str(input, "automation_id")?;
406 let automation = manager
407 .get_automation(id)
408 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
409 let runs = manager
410 .list_runs(id, Some(20))
411 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
412 ToolResult::json(&json!({ "automation": automation, "recent_runs": runs }))
413 .map_err(|e| ToolError::execution_failed(e.to_string()))
414 }
415
416 async fn execute_update(
417 &self,
418 input: &Value,
419 context: &ToolContext,
420 ) -> Result<ToolResult, ToolError> {
421 let manager = context
422 .runtime
423 .automations
424 .as_ref()
425 .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?;
426 let manager = manager.lock().await;
427 let status = optional_str(input, "status")?
428 .map(parse_automation_status)
429 .transpose()?;
430 if status == Some(AutomationStatus::Active) {
431 require_dispatch_owner(&manager, "activate an automation")?;
432 }
433 let (allow_shell, trust_mode, auto_approve) = context.cap_delegated_authority(
434 optional_bool_value(input, "allow_shell"),
435 optional_bool_value(input, "trust_mode"),
436 optional_bool_value(input, "auto_approve"),
437 );
438 let id = required_str(input, "automation_id")?;
439 let existing = manager
440 .get_automation(id)
441 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
442 // Fields the call leaves out keep their stored values, so the record
443 // that results must also sit within this session's authority: a
444 // session cannot edit the prompt of an automation that runs with more
445 // than it holds.
446 require_within_session_authority(
447 context,
448 &AutomationRecord {
449 allow_shell: allow_shell.or(existing.allow_shell),
450 trust_mode: trust_mode.or(existing.trust_mode),
451 auto_approve: auto_approve.or(existing.auto_approve),
452 ..existing
453 },
454 "update",
455 )?;
456 let req = UpdateAutomationRequest {
457 name: optional_str(input, "name")?.map(ToString::to_string),
458 prompt: optional_str(input, "prompt")?.map(ToString::to_string),
459 rrule: optional_str(input, "rrule")?.map(ToString::to_string),
460 cwds: if input.get("cwds").is_some() {
461 Some(session_reachable_cwds(
462 context,
463 string_array(input, "cwds")?,
464 )?)
465 } else {
466 None
467 },
468 model: optional_str(input, "model")?.map(ToString::to_string),
469 model_provider: optional_str(input, "model_provider")?.map(ToString::to_string),
470 model_provider_id: optional_str(input, "model_provider_id")?.map(ToString::to_string),
471 mode: optional_str(input, "mode")?.map(ToString::to_string),
472 allow_shell,
473 trust_mode,
474 auto_approve,
475 delivery_mode: optional_delivery_mode(input)?,
476 status,
477 };
478 let automation = manager
479 .update_automation(id, req)
480 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
481 ToolResult::json(&automation).map_err(|e| ToolError::execution_failed(e.to_string()))
482 }
483
484 /// pause / resume / delete share the same shape: one id in, automation out.
485 async fn execute_simple(
486 &self,
487 context: &ToolContext,
488 input: &Value,
489 action: &str,
490 ) -> Result<ToolResult, ToolError> {
491 let manager = context
492 .runtime
493 .automations
494 .as_ref()
495 .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?;
496 let manager = manager.lock().await;
497 if action == "resume" {
498 // An unknown id falls through to resume's own error.
499 if let Ok(existing) = manager.get_automation(required_str(input, "automation_id")?) {
500 require_within_session_authority(context, &existing, "resume")?;
501 }
502 require_dispatch_owner(&manager, "resume an automation")?;
503 }
504 let automation = match action {
505 "pause" => manager.pause_automation(required_str(input, "automation_id")?),
506 "resume" => manager.resume_automation(required_str(input, "automation_id")?),
507 "delete" => manager.delete_automation(required_str(input, "automation_id")?),
508 _ => unreachable!("execute_simple only routes pause/resume/delete"),
509 }
510 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
511 ToolResult::json(&automation).map_err(|e| ToolError::execution_failed(e.to_string()))
512 }
513
514 async fn execute_run(
515 &self,
516 input: &Value,
517 context: &ToolContext,
518 ) -> Result<ToolResult, ToolError> {
519 let manager = context
520 .runtime
521 .automations
522 .as_ref()
523 .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?;
524 let id = required_str(input, "automation_id")?;
525 {
526 let existing = manager
527 .lock()
528 .await
529 .get_automation(id)
530 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
531 require_within_session_authority(context, &existing, "run")?;
532 }
533 let task_manager = context.runtime.task_manager.as_ref().ok_or_else(|| {
534 ToolError::not_available(format!(
535 "TaskManager is not attached — {DISPATCH_OWNER_HINT}"
536 ))
537 })?;
538 // run_now_shared handles its own lock phases so the manager mutex is
539 // never held across the task-manager await.
540 let run = run_now_shared(manager, id, task_manager)
541 .await
542 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
543 ToolResult::json(&run).map_err(|e| ToolError::execution_failed(e.to_string()))
544 }
545 }
546
547 /// Refuse to update, resume or run an automation whose stored authority is
548 /// more than this session holds.
549 ///
550 /// A scheduled run executes the record's prompt with the record's own
551 /// `allow_shell` / `trust_mode` / `auto_approve`. A session with less than
552 /// that could otherwise rewrite or start work that runs with more, and the
553 /// approval card for the call would only show the fields the call sent.
554 /// Pausing and deleting only reduce what runs, so they are not checked.
555 fn require_within_session_authority(
556 context: &ToolContext,
557 automation: &AutomationRecord,
558 verb: &str,
559 ) -> Result<(), ToolError> {
560 let stored = [
561 ("allow_shell", automation.allow_shell),
562 ("trust_mode", automation.trust_mode),
563 ("auto_approve", automation.auto_approve),
564 ];
565 let (shell, trust, auto) = context.cap_delegated_authority(
566 automation.allow_shell,
567 automation.trust_mode,
568 automation.auto_approve,
569 );
570 let capped = [shell, trust, auto];
571 let above: Vec<&str> = stored
572 .iter()
573 .zip(capped)
574 .filter(|((_, value), capped)| *value == Some(true) && *capped != Some(true))
575 .map(|((name, _), _)| *name)
576 .collect();
577 if above.is_empty() {
578 return Ok(());
579 }
580 Err(ToolError::permission_denied(format!(
581 "automation: cannot {verb} `{}`: it runs with {} = true, which this session does not hold. Set {} to false in the same update, or ask the user to {verb} it from a session that holds that access.",
582 automation.id,
583 above.join(", "),
584 above.join(" / "),
585 )))
586 }
587
588 /// The exact schema the legacy per-action tool exposed, kept so hidden alias
589 /// registrations report an identical contract to the pre-unification tools.
590 fn legacy_action_schema(action: &str) -> Value {
591 match action {
592 "create" => json!({
593 "type": "object",
594 "properties": {
595 "name": { "type": "string" },
596 "prompt": { "type": "string" },
597 "rrule": {
598 "type": "string",
599 "description": "Supported: FREQ=ONCE;AT=2026-08-03T14:30 (local time or RFC3339), FREQ=HOURLY;INTERVAL=N[;BYDAY=MO,TU][;BYHOUR=9][;BYMINUTE=30], FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=30, or FREQ=CRON;EXPR=*/17 * * * *. Cron uses standard 5-field local time. For HOURLY, BYHOUR/BYMINUTE choose the initial local wall-clock anchor and INTERVAL advances from that anchor; BYHOUR is not a daily-only filter. Anchored wall times skip nonexistent clock times and use the first occurrence of ambiguous clock times."
600 },
601 "cwds": { "type": "array", "items": { "type": "string" } },
602 "model": { "type": "string", "description": "Model id for scheduled runs." },
603 "model_provider": { "type": "string", "description": "Provider kind for the pinned model." },
604 "model_provider_id": { "type": "string", "description": "Exact configured provider id." },
605 "mode": { "type": "string", "description": "Task mode for scheduled runs. Defaults to agent when omitted." },
606 "allow_shell": { "type": "boolean", "default": false },
607 "trust_mode": { "type": "boolean", "default": false },
608 "auto_approve": { "type": "boolean", "default": false },
609 "delivery_mode": {
610 "type": "string",
611 "enum": ["task", "watcher"],
612 "default": "task",
613 "description": "Delivery mode. watcher prompts must return EXACTLY NOTHING_TO_REPORT when there is no change."
614 },
615 "paused": { "type": "boolean", "default": false }
616 },
617 "required": ["name", "prompt", "rrule"],
618 "additionalProperties": false
619 }),
620 "list" => json!({
621 "type": "object",
622 "properties": {
623 "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }
624 },
625 "additionalProperties": false
626 }),
627 "update" => json!({
628 "type": "object",
629 "properties": {
630 "automation_id": { "type": "string" },
631 "name": { "type": "string" },
632 "prompt": { "type": "string" },
633 "rrule": { "type": "string" },
634 "cwds": { "type": "array", "items": { "type": "string" } },
635 "model": { "type": "string", "description": "Model id for scheduled runs." },
636 "model_provider": { "type": "string", "description": "Provider kind for the pinned model." },
637 "model_provider_id": { "type": "string", "description": "Exact configured provider id." },
638 "mode": { "type": "string", "description": "Task mode for scheduled runs. Defaults to agent when omitted." },
639 "allow_shell": { "type": "boolean" },
640 "trust_mode": { "type": "boolean" },
641 "auto_approve": { "type": "boolean" },
642 "delivery_mode": { "type": "string", "enum": ["task", "watcher"] },
643 "status": { "type": "string", "enum": ["active", "paused"] }
644 },
645 "required": ["automation_id"],
646 "additionalProperties": false
647 }),
648 // read / pause / resume / delete / run share the id-only schema.
649 _ => automation_id_schema(true),
650 }
651 }
652
653 fn automation_id_schema(require_id: bool) -> Value {
654 let mut schema = json!({
655 "type": "object",
656 "properties": {
657 "automation_id": { "type": "string" }
658 },
659 "additionalProperties": false
660 });
661 if require_id {
662 schema["required"] = json!(["automation_id"]);
663 }
664 schema
665 }
666
667 fn string_array(input: &Value, field: &str) -> Result<Vec<String>, ToolError> {
668 Ok(input
669 .get(field)
670 .and_then(Value::as_array)
671 .map(|items| {
672 items
673 .iter()
674 .filter_map(Value::as_str)
675 .map(ToString::to_string)
676 .collect::<Vec<_>>()
677 })
678 .unwrap_or_default())
679 }
680
681 /// Automation directories must be ones this session can already reach:
682 /// inside its workspace, or anywhere in trust mode.
683 fn session_reachable_cwds(
684 context: &ToolContext,
685 cwds: Vec<String>,
686 ) -> Result<Vec<PathBuf>, ToolError> {
687 cwds.iter().map(|raw| context.resolve_path(raw)).collect()
688 }
689
690 fn optional_bool_value(input: &Value, field: &str) -> Option<bool> {
691 input.get(field).and_then(Value::as_bool)
692 }
693
694 /// Parse an `automation_update` status. #5123-class: unknown statuses used to
695 /// coerce to Active — the opposite of pause intent, and run-scheduling.
696 fn parse_automation_status(value: &str) -> Result<AutomationStatus, ToolError> {
697 match value {
698 "active" => Ok(AutomationStatus::Active),
699 "paused" => Ok(AutomationStatus::Paused),
700 other => Err(ToolError::invalid_input(format!(
701 "unknown automation status '{other}'; expected 'active' or 'paused'"
702 ))),
703 }
704 }
705
706 fn optional_delivery_mode(input: &Value) -> Result<Option<AutomationDeliveryMode>, ToolError> {
707 match optional_str(input, "delivery_mode")? {
708 None => Ok(None),
709 Some("task") => Ok(Some(AutomationDeliveryMode::Task)),
710 Some("watcher") => Ok(Some(AutomationDeliveryMode::Watcher)),
711 Some(other) => Err(ToolError::invalid_input(format!(
712 "automation: invalid delivery_mode `{other}` (expected task or watcher)"
713 ))),
714 }
715 }
716
717 #[cfg(test)]
718 mod tests {
719 use super::*;
720 use crate::tools::spec::ToolSpec;
721
722 #[tokio::test]
723 async fn create_and_update_cap_requested_authority_at_the_session() {
724 let workspace = tempfile::tempdir().expect("workspace");
725 let manager = crate::automation_manager::AutomationManager::open_for_test(
726 workspace.path().join("automations"),
727 )
728 .expect("automation manager");
729 let mut context = ToolContext::new(workspace.path());
730 context.shell_policy = crate::worker_profile::ShellPolicy::None;
731 context.runtime.automations = Some(std::sync::Arc::new(tokio::sync::Mutex::new(manager)));
732 let tool = AutomationTool::new("automation");
733
734 let created = tool
735 .execute(
736 json!({
737 "action": "create",
738 "name": "nightly",
739 "prompt": "summarise the day",
740 "rrule": "FREQ=HOURLY",
741 "paused": true,
742 "auto_approve": true,
743 "trust_mode": true,
744 "allow_shell": true
745 }),
746 &context,
747 )
748 .await
749 .expect("create accepted");
750 let record: Value = serde_json::from_str(&created.content).expect("record json");
751 assert_eq!(record["auto_approve"], json!(false), "{record}");
752 assert_eq!(record["trust_mode"], json!(false), "{record}");
753 assert_eq!(record["allow_shell"], json!(false), "{record}");
754 let id = record["id"].as_str().expect("id").to_string();
755
756 let updated = tool
757 .execute(
758 json!({"action": "update", "automation_id": id, "auto_approve": true}),
759 &context,
760 )
761 .await
762 .expect("update accepted");
763 let record: Value = serde_json::from_str(&updated.content).expect("record json");
764 assert_eq!(record["auto_approve"], json!(false), "{record}");
765
766 let outside = tool
767 .execute(
768 json!({
769 "action": "create",
770 "name": "elsewhere",
771 "prompt": "summarise",
772 "rrule": "FREQ=HOURLY",
773 "paused": true,
774 "cwds": ["/"]
775 }),
776 &context,
777 )
778 .await;
779 assert!(
780 outside.is_err(),
781 "a directory outside the workspace is refused"
782 );
783
784 // A session that holds the authority can still hand it on.
785 context.approval_mode = codewhale_execpolicy::ApprovalMode::Bypass;
786 context.trust_mode = true;
787 let granted = tool
788 .execute(
789 json!({
790 "action": "create",
791 "name": "unattended",
792 "prompt": "summarise",
793 "rrule": "FREQ=HOURLY",
794 "paused": true,
795 "auto_approve": true,
796 "trust_mode": true
797 }),
798 &context,
799 )
800 .await
801 .expect("create accepted");
802 let record: Value = serde_json::from_str(&granted.content).expect("record json");
803 assert_eq!(record["auto_approve"], json!(true), "{record}");
804 assert_eq!(record["trust_mode"], json!(true), "{record}");
805 }
806
807 #[tokio::test]
808 async fn a_session_cannot_edit_or_start_an_automation_that_holds_more_than_it() {
809 let workspace = tempfile::tempdir().expect("workspace");
810 let manager = crate::automation_manager::AutomationManager::open_for_test(
811 workspace.path().join("automations"),
812 )
813 .expect("automation manager");
814 let mut full = ToolContext::new(workspace.path());
815 full.approval_mode = codewhale_execpolicy::ApprovalMode::Bypass;
816 full.trust_mode = true;
817 full.runtime.automations = Some(std::sync::Arc::new(tokio::sync::Mutex::new(manager)));
818 let tool = AutomationTool::new("automation");
819 let created = tool
820 .execute(
821 json!({
822 "action": "create",
823 "name": "unattended",
824 "prompt": "summarise",
825 "rrule": "FREQ=HOURLY",
826 "paused": true,
827 "auto_approve": true,
828 "trust_mode": true
829 }),
830 &full,
831 )
832 .await
833 .expect("create accepted");
834 let record: Value = serde_json::from_str(&created.content).expect("record json");
835 assert_eq!(record["auto_approve"], json!(true), "{record}");
836 let id = record["id"].as_str().expect("id").to_string();
837
838 // The same store, seen from a session in the default approval mode.
839 let mut lesser = full.clone();
840 lesser.approval_mode = codewhale_execpolicy::ApprovalMode::default();
841 lesser.trust_mode = false;
842 assert_ne!(
843 lesser.approval_mode,
844 codewhale_execpolicy::ApprovalMode::Bypass
845 );
846
847 for call in [
848 json!({"action": "update", "automation_id": id, "prompt": "something else"}),
849 json!({"action": "update", "automation_id": id, "auto_approve": false, "prompt": "x"}),
850 json!({"action": "resume", "automation_id": id}),
851 json!({"action": "run", "automation_id": id}),
852 ] {
853 let err = tool
854 .execute(call.clone(), &lesser)
855 .await
856 .expect_err("refused while stored authority exceeds the session");
857 assert!(err.to_string().contains("does not hold"), "{call}: {err}");
858 }
859 let unchanged = full
860 .runtime
861 .automations
862 .as_ref()
863 .expect("store")
864 .lock()
865 .await
866 .get_automation(&id)
867 .expect("record");
868 assert_eq!(unchanged.prompt, "summarise");
869
870 // Lowering every stored field in the same update is allowed, and
871 // pausing never needs the authority.
872 let lowered = tool
873 .execute(
874 json!({
875 "action": "update",
876 "automation_id": id,
877 "prompt": "something else",
878 "auto_approve": false,
879 "trust_mode": false
880 }),
881 &lesser,
882 )
883 .await
884 .expect("lowering update accepted");
885 let record: Value = serde_json::from_str(&lowered.content).expect("record json");
886 assert_eq!(record["auto_approve"], json!(false), "{record}");
887 assert_eq!(record["trust_mode"], json!(false), "{record}");
888 assert_eq!(record["prompt"], json!("something else"), "{record}");
889 tool.execute(json!({"action": "pause", "automation_id": id}), &lesser)
890 .await
891 .expect("pause accepted");
892 }
893
894 #[test]
895 fn create_schema_exposes_rrule() {
896 let schema = AutomationTool::alias("automation_create", "create").input_schema();
897 assert!(schema["properties"]["rrule"].is_object());
898 assert_eq!(schema["required"][0], "name");
899 }
900
901 #[test]
902 fn update_status_rejects_unknown_values_instead_of_coercing_to_active() {
903 assert!(matches!(
904 parse_automation_status("active"),
905 Ok(AutomationStatus::Active)
906 ));
907 assert!(matches!(
908 parse_automation_status("paused"),
909 Ok(AutomationStatus::Paused)
910 ));
911 for bad in ["pause", "disabled", "off", "stopped", ""] {
912 let err = parse_automation_status(bad).expect_err("must not coerce");
913 assert!(
914 err.to_string().contains("expected 'active' or 'paused'"),
915 "{err}"
916 );
917 }
918 }
919
920 #[test]
921 fn create_schema_auto_approve_defaults_to_false() {
922 let schema = AutomationTool::alias("automation_create", "create").input_schema();
923 let auto_approve = &schema["properties"]["auto_approve"];
924 assert_eq!(auto_approve["type"], "boolean");
925 assert_eq!(auto_approve["default"], false);
926 }
927
928 #[test]
929 fn create_schema_exposes_delivery_mode_and_new_schedule_forms() {
930 let schema = AutomationTool::alias("automation_create", "create").input_schema();
931 assert!(schema["properties"]["model"].is_object());
932 assert_eq!(
933 schema["properties"]["delivery_mode"]["enum"],
934 json!(["task", "watcher"])
935 );
936 let description = schema["properties"]["rrule"]["description"]
937 .as_str()
938 .expect("rrule description");
939 assert!(description.contains("FREQ=ONCE"));
940 assert!(description.contains("FREQ=CRON"));
941 }
942
943 #[test]
944 fn canonical_schema_lists_all_actions_and_union_fields() {
945 let schema = AutomationTool::new("automation").input_schema();
946 let actions = schema["properties"]["action"]["enum"]
947 .as_array()
948 .expect("action enum");
949 for action in [
950 "create", "list", "read", "update", "pause", "resume", "delete", "run",
951 ] {
952 assert!(
953 actions.iter().any(|value| value.as_str() == Some(action)),
954 "canonical schema must offer action {action}"
955 );
956 }
957 for field in [
958 "name",
959 "prompt",
960 "rrule",
961 "model",
962 "delivery_mode",
963 "automation_id",
964 "limit",
965 ] {
966 assert!(
967 schema["properties"][field].is_object(),
968 "canonical schema must carry union field {field}"
969 );
970 }
971 assert_eq!(schema["additionalProperties"], json!(false));
972 }
973
974 #[test]
975 fn read_only_variant_only_offers_read_actions() {
976 let tool = AutomationTool::read_only("automation");
977 let schema = tool.input_schema();
978 let actions = schema["properties"]["action"]["enum"]
979 .as_array()
980 .expect("action enum");
981 assert_eq!(actions, &vec![json!("list"), json!("read")]);
982 assert!(!schema["properties"]["rrule"].is_object());
983 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
984 assert!(tool.is_read_only());
985 }
986
987 #[test]
988 fn aliases_hide_from_model_and_force_action() {
989 let create = AutomationTool::alias("automation_create", "create");
990 assert!(!create.model_visible());
991 assert_eq!(create.name(), "automation_create");
992 assert_eq!(create.approval_requirement(), ApprovalRequirement::Required);
993
994 let list = AutomationTool::alias("automation_list", "list");
995 assert!(!list.model_visible());
996 assert_eq!(list.approval_requirement(), ApprovalRequirement::Auto);
997 assert!(list.is_read_only_for(&json!({})));
998
999 let canonical = AutomationTool::new("automation");
1000 assert!(canonical.model_visible());
1001 // Approval routing stays per action: read actions auto, writes required.
1002 assert_eq!(
1003 canonical.approval_requirement_for(&json!({"action": "list"})),
1004 ApprovalRequirement::Auto
1005 );
1006 assert_eq!(
1007 canonical.approval_requirement_for(&json!({"action": "delete"})),
1008 ApprovalRequirement::Required
1009 );
1010 assert!(canonical.is_read_only_for(&json!({"action": "read"})));
1011 assert!(!canonical.is_read_only_for(&json!({"action": "create"})));
1012 }
1013
1014 #[test]
1015 fn canonical_rejects_unknown_or_missing_action() {
1016 let tool = AutomationTool::new("automation");
1017 let err = tool
1018 .resolve_action(&json!({}))
1019 .expect_err("missing action must fail");
1020 assert!(err.to_string().contains("missing `action`"));
1021 let err = tool
1022 .resolve_action(&json!({"action": "explode"}))
1023 .expect_err("unknown action must fail");
1024 assert!(err.to_string().contains("invalid action"));
1025
1026 let read_only = AutomationTool::read_only("automation");
1027 let err = read_only
1028 .resolve_action(&json!({"action": "delete"}))
1029 .expect_err("read-only surface must reject write actions");
1030 assert!(err.to_string().contains("invalid action"));
1031 }
1032
1033 /// Exec-shaped services: the shared store is attached, but the one-shot
1034 /// host holds no task-execution lease, so its manager is unbound.
1035 fn exec_shaped_context(tmp: &tempfile::TempDir) -> ToolContext {
1036 let manager = crate::automation_manager::AutomationManager::open(tmp.path().to_path_buf())
1037 .expect("open store");
1038 assert!(
1039 manager.execution_scope().is_none(),
1040 "fixture must model the unbound one-shot host"
1041 );
1042 context_with(manager)
1043 }
1044
1045 /// A host that owns the task-execution lease, as the Runtime and the
1046 /// interactive session do.
1047 fn owning_context(tmp: &tempfile::TempDir) -> ToolContext {
1048 context_with(
1049 crate::automation_manager::AutomationManager::open_for_test(tmp.path().to_path_buf())
1050 .expect("open store"),
1051 )
1052 }
1053
1054 fn context_with(manager: crate::automation_manager::AutomationManager) -> ToolContext {
1055 ToolContext::new(".").with_runtime_services(crate::tools::spec::RuntimeToolServices {
1056 automations: Some(std::sync::Arc::new(tokio::sync::Mutex::new(manager))),
1057 ..Default::default()
1058 })
1059 }
1060
1061 fn create_input(paused: bool) -> Value {
1062 json!({
1063 "action": "create",
1064 "name": "nightly",
1065 "prompt": "Summarize what landed today.",
1066 "rrule": "FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=30",
1067 "paused": paused,
1068 })
1069 }
1070
1071 /// The reproduced defect. With the store attached, inspection works —
1072 /// headless exec no longer answers "AutomationManager is not attached" for
1073 /// the read actions it advertises.
1074 #[tokio::test]
1075 async fn inspection_works_without_a_dispatch_owner() {
1076 let tmp = tempfile::TempDir::new().expect("tempdir");
1077 let ctx = exec_shaped_context(&tmp);
1078 let tool = AutomationTool::new("automation");
1079 let result = tool
1080 .execute(json!({"action": "list"}), &ctx)
1081 .await
1082 .expect("list must serve an attached store");
1083 assert_eq!(result.content.trim(), "[]");
1084 }
1085
1086 /// Attaching the store must not let a host without a dispatch owner
1087 /// promise work it cannot deliver: only the owning scope's scheduler
1088 /// admits a record, so an Active definition written here would carry a
1089 /// `next_run_at` nothing honors.
1090 #[tokio::test]
1091 async fn activating_work_requires_a_dispatch_owner() {
1092 let tmp = tempfile::TempDir::new().expect("tempdir");
1093 let ctx = exec_shaped_context(&tmp);
1094 let tool = AutomationTool::new("automation");
1095
1096 for (input, what) in [
1097 (create_input(false), "create an active automation"),
1098 (
1099 json!({"action": "update", "automation_id": "a1", "status": "active"}),
1100 "activate an automation",
1101 ),
1102 (json!({"action": "resume", "automation_id": "a1"}), "resume"),
1103 ] {
1104 let err = tool
1105 .execute(input, &ctx)
1106 .await
1107 .expect_err("must refuse without a dispatch owner");
1108 let message = err.to_string();
1109 assert!(
1110 message.contains("persistent execution owner"),
1111 "{what} must explain the missing owner: {message}"
1112 );
1113 }
1114 assert!(
1115 crate::automation_manager::AutomationManager::open(tmp.path().to_path_buf())
1116 .expect("reopen")
1117 .list_automations()
1118 .expect("list")
1119 .is_empty(),
1120 "a refused activation must not leave a record behind"
1121 );
1122 }
1123
1124 /// A paused definition is inert by construction and is adopted by the
1125 /// first owning host that resumes it, so storing intent stays honest.
1126 #[tokio::test]
1127 async fn a_paused_definition_is_still_allowed_without_an_owner() {
1128 let tmp = tempfile::TempDir::new().expect("tempdir");
1129 let ctx = exec_shaped_context(&tmp);
1130 let tool = AutomationTool::new("automation");
1131 tool.execute(create_input(true), &ctx)
1132 .await
1133 .expect("paused definitions need no dispatch owner");
1134 let stored = crate::automation_manager::AutomationManager::open(tmp.path().to_path_buf())
1135 .expect("reopen")
1136 .list_automations()
1137 .expect("list");
1138 assert_eq!(stored.len(), 1);
1139 assert_eq!(stored[0].status, AutomationStatus::Paused);
1140 assert!(
1141 stored[0].next_run_at.is_none(),
1142 "a paused definition must not advertise a next run"
1143 );
1144 }
1145
1146 /// The guard is about the *host*, not the action: a host that owns the
1147 /// lease keeps creating active automations exactly as before.
1148 #[tokio::test]
1149 async fn an_owning_host_still_creates_active_automations() {
1150 let tmp = tempfile::TempDir::new().expect("tempdir");
1151 let ctx = owning_context(&tmp);
1152 let tool = AutomationTool::new("automation");
1153 tool.execute(create_input(false), &ctx)
1154 .await
1155 .expect("an owning host may schedule");
1156 let stored = crate::automation_manager::AutomationManager::open(tmp.path().to_path_buf())
1157 .expect("reopen")
1158 .list_automations()
1159 .expect("list");
1160 assert_eq!(stored.len(), 1);
1161 assert_eq!(stored[0].status, AutomationStatus::Active);
1162 assert!(stored[0].next_run_at.is_some());
1163 }
1164 }
1165
1165 lines RUST