返回 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, AutomationStatus,
14 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 let req = CreateAutomationRequest {
336 name: required_str(input, "name")?.to_string(),
337 prompt: required_str(input, "prompt")?.to_string(),
338 rrule: required_str(input, "rrule")?.to_string(),
339 cwds: string_array(input, "cwds")?
340 .into_iter()
341 .map(PathBuf::from)
342 .collect(),
343 model: optional_str(input, "model")?.map(ToString::to_string),
344 model_provider: optional_str(input, "model_provider")?.map(ToString::to_string),
345 model_provider_id: optional_str(input, "model_provider_id")?.map(ToString::to_string),
346 mode: optional_str(input, "mode")?.map(ToString::to_string),
347 allow_shell: optional_bool_value(input, "allow_shell"),
348 trust_mode: optional_bool_value(input, "trust_mode"),
349 auto_approve: optional_bool_value(input, "auto_approve"),
350 delivery_mode: optional_delivery_mode(input)?,
351 status: Some(
352 if input
353 .get("paused")
354 .and_then(Value::as_bool)
355 .unwrap_or(false)
356 {
357 AutomationStatus::Paused
358 } else {
359 AutomationStatus::Active
360 },
361 ),
362 };
363 if req.status != Some(AutomationStatus::Paused) {
364 require_dispatch_owner(&manager, "create an active automation")?;
365 }
366 let automation = manager
367 .create_automation(req)
368 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
369 ToolResult::json(&automation).map_err(|e| ToolError::execution_failed(e.to_string()))
370 }
371
372 async fn execute_list(
373 &self,
374 input: &Value,
375 context: &ToolContext,
376 ) -> Result<ToolResult, ToolError> {
377 let manager = context
378 .runtime
379 .automations
380 .as_ref()
381 .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?;
382 let manager = manager.lock().await;
383 let mut automations = manager
384 .list_automations()
385 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
386 automations.truncate(optional_u64(input, "limit", 50)?.clamp(1, 100) as usize);
387 ToolResult::json(&automations).map_err(|e| ToolError::execution_failed(e.to_string()))
388 }
389
390 async fn execute_read(
391 &self,
392 input: &Value,
393 context: &ToolContext,
394 ) -> Result<ToolResult, ToolError> {
395 let manager = context
396 .runtime
397 .automations
398 .as_ref()
399 .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?;
400 let manager = manager.lock().await;
401 let id = required_str(input, "automation_id")?;
402 let automation = manager
403 .get_automation(id)
404 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
405 let runs = manager
406 .list_runs(id, Some(20))
407 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
408 ToolResult::json(&json!({ "automation": automation, "recent_runs": runs }))
409 .map_err(|e| ToolError::execution_failed(e.to_string()))
410 }
411
412 async fn execute_update(
413 &self,
414 input: &Value,
415 context: &ToolContext,
416 ) -> Result<ToolResult, ToolError> {
417 let manager = context
418 .runtime
419 .automations
420 .as_ref()
421 .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?;
422 let manager = manager.lock().await;
423 let status = optional_str(input, "status")?
424 .map(parse_automation_status)
425 .transpose()?;
426 if status == Some(AutomationStatus::Active) {
427 require_dispatch_owner(&manager, "activate an automation")?;
428 }
429 let req = UpdateAutomationRequest {
430 name: optional_str(input, "name")?.map(ToString::to_string),
431 prompt: optional_str(input, "prompt")?.map(ToString::to_string),
432 rrule: optional_str(input, "rrule")?.map(ToString::to_string),
433 cwds: if input.get("cwds").is_some() {
434 Some(
435 string_array(input, "cwds")?
436 .into_iter()
437 .map(PathBuf::from)
438 .collect(),
439 )
440 } else {
441 None
442 },
443 model: optional_str(input, "model")?.map(ToString::to_string),
444 model_provider: optional_str(input, "model_provider")?.map(ToString::to_string),
445 model_provider_id: optional_str(input, "model_provider_id")?.map(ToString::to_string),
446 mode: optional_str(input, "mode")?.map(ToString::to_string),
447 allow_shell: optional_bool_value(input, "allow_shell"),
448 trust_mode: optional_bool_value(input, "trust_mode"),
449 auto_approve: optional_bool_value(input, "auto_approve"),
450 delivery_mode: optional_delivery_mode(input)?,
451 status,
452 };
453 let automation = manager
454 .update_automation(required_str(input, "automation_id")?, req)
455 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
456 ToolResult::json(&automation).map_err(|e| ToolError::execution_failed(e.to_string()))
457 }
458
459 /// pause / resume / delete share the same shape: one id in, automation out.
460 async fn execute_simple(
461 &self,
462 context: &ToolContext,
463 input: &Value,
464 action: &str,
465 ) -> Result<ToolResult, ToolError> {
466 let manager = context
467 .runtime
468 .automations
469 .as_ref()
470 .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?;
471 let manager = manager.lock().await;
472 if action == "resume" {
473 require_dispatch_owner(&manager, "resume an automation")?;
474 }
475 let automation = match action {
476 "pause" => manager.pause_automation(required_str(input, "automation_id")?),
477 "resume" => manager.resume_automation(required_str(input, "automation_id")?),
478 "delete" => manager.delete_automation(required_str(input, "automation_id")?),
479 _ => unreachable!("execute_simple only routes pause/resume/delete"),
480 }
481 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
482 ToolResult::json(&automation).map_err(|e| ToolError::execution_failed(e.to_string()))
483 }
484
485 async fn execute_run(
486 &self,
487 input: &Value,
488 context: &ToolContext,
489 ) -> Result<ToolResult, ToolError> {
490 let manager = context
491 .runtime
492 .automations
493 .as_ref()
494 .ok_or_else(|| ToolError::not_available("AutomationManager is not attached"))?;
495 let task_manager = context.runtime.task_manager.as_ref().ok_or_else(|| {
496 ToolError::not_available(format!(
497 "TaskManager is not attached — {DISPATCH_OWNER_HINT}"
498 ))
499 })?;
500 // run_now_shared handles its own lock phases so the manager mutex is
501 // never held across the task-manager await.
502 let run = run_now_shared(manager, required_str(input, "automation_id")?, task_manager)
503 .await
504 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
505 ToolResult::json(&run).map_err(|e| ToolError::execution_failed(e.to_string()))
506 }
507 }
508
509 /// The exact schema the legacy per-action tool exposed, kept so hidden alias
510 /// registrations report an identical contract to the pre-unification tools.
511 fn legacy_action_schema(action: &str) -> Value {
512 match action {
513 "create" => json!({
514 "type": "object",
515 "properties": {
516 "name": { "type": "string" },
517 "prompt": { "type": "string" },
518 "rrule": {
519 "type": "string",
520 "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."
521 },
522 "cwds": { "type": "array", "items": { "type": "string" } },
523 "model": { "type": "string", "description": "Model id for scheduled runs." },
524 "model_provider": { "type": "string", "description": "Provider kind for the pinned model." },
525 "model_provider_id": { "type": "string", "description": "Exact configured provider id." },
526 "mode": { "type": "string", "description": "Task mode for scheduled runs. Defaults to agent when omitted." },
527 "allow_shell": { "type": "boolean", "default": false },
528 "trust_mode": { "type": "boolean", "default": false },
529 "auto_approve": { "type": "boolean", "default": false },
530 "delivery_mode": {
531 "type": "string",
532 "enum": ["task", "watcher"],
533 "default": "task",
534 "description": "Delivery mode. watcher prompts must return EXACTLY NOTHING_TO_REPORT when there is no change."
535 },
536 "paused": { "type": "boolean", "default": false }
537 },
538 "required": ["name", "prompt", "rrule"],
539 "additionalProperties": false
540 }),
541 "list" => json!({
542 "type": "object",
543 "properties": {
544 "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 }
545 },
546 "additionalProperties": false
547 }),
548 "update" => json!({
549 "type": "object",
550 "properties": {
551 "automation_id": { "type": "string" },
552 "name": { "type": "string" },
553 "prompt": { "type": "string" },
554 "rrule": { "type": "string" },
555 "cwds": { "type": "array", "items": { "type": "string" } },
556 "model": { "type": "string", "description": "Model id for scheduled runs." },
557 "model_provider": { "type": "string", "description": "Provider kind for the pinned model." },
558 "model_provider_id": { "type": "string", "description": "Exact configured provider id." },
559 "mode": { "type": "string", "description": "Task mode for scheduled runs. Defaults to agent when omitted." },
560 "allow_shell": { "type": "boolean" },
561 "trust_mode": { "type": "boolean" },
562 "auto_approve": { "type": "boolean" },
563 "delivery_mode": { "type": "string", "enum": ["task", "watcher"] },
564 "status": { "type": "string", "enum": ["active", "paused"] }
565 },
566 "required": ["automation_id"],
567 "additionalProperties": false
568 }),
569 // read / pause / resume / delete / run share the id-only schema.
570 _ => automation_id_schema(true),
571 }
572 }
573
574 fn automation_id_schema(require_id: bool) -> Value {
575 let mut schema = json!({
576 "type": "object",
577 "properties": {
578 "automation_id": { "type": "string" }
579 },
580 "additionalProperties": false
581 });
582 if require_id {
583 schema["required"] = json!(["automation_id"]);
584 }
585 schema
586 }
587
588 fn string_array(input: &Value, field: &str) -> Result<Vec<String>, ToolError> {
589 Ok(input
590 .get(field)
591 .and_then(Value::as_array)
592 .map(|items| {
593 items
594 .iter()
595 .filter_map(Value::as_str)
596 .map(ToString::to_string)
597 .collect::<Vec<_>>()
598 })
599 .unwrap_or_default())
600 }
601
602 fn optional_bool_value(input: &Value, field: &str) -> Option<bool> {
603 input.get(field).and_then(Value::as_bool)
604 }
605
606 /// Parse an `automation_update` status. #5123-class: unknown statuses used to
607 /// coerce to Active — the opposite of pause intent, and run-scheduling.
608 fn parse_automation_status(value: &str) -> Result<AutomationStatus, ToolError> {
609 match value {
610 "active" => Ok(AutomationStatus::Active),
611 "paused" => Ok(AutomationStatus::Paused),
612 other => Err(ToolError::invalid_input(format!(
613 "unknown automation status '{other}'; expected 'active' or 'paused'"
614 ))),
615 }
616 }
617
618 fn optional_delivery_mode(input: &Value) -> Result<Option<AutomationDeliveryMode>, ToolError> {
619 match optional_str(input, "delivery_mode")? {
620 None => Ok(None),
621 Some("task") => Ok(Some(AutomationDeliveryMode::Task)),
622 Some("watcher") => Ok(Some(AutomationDeliveryMode::Watcher)),
623 Some(other) => Err(ToolError::invalid_input(format!(
624 "automation: invalid delivery_mode `{other}` (expected task or watcher)"
625 ))),
626 }
627 }
628
629 #[cfg(test)]
630 mod tests {
631 use super::*;
632 use crate::tools::spec::ToolSpec;
633
634 #[test]
635 fn create_schema_exposes_rrule() {
636 let schema = AutomationTool::alias("automation_create", "create").input_schema();
637 assert!(schema["properties"]["rrule"].is_object());
638 assert_eq!(schema["required"][0], "name");
639 }
640
641 #[test]
642 fn update_status_rejects_unknown_values_instead_of_coercing_to_active() {
643 assert!(matches!(
644 parse_automation_status("active"),
645 Ok(AutomationStatus::Active)
646 ));
647 assert!(matches!(
648 parse_automation_status("paused"),
649 Ok(AutomationStatus::Paused)
650 ));
651 for bad in ["pause", "disabled", "off", "stopped", ""] {
652 let err = parse_automation_status(bad).expect_err("must not coerce");
653 assert!(
654 err.to_string().contains("expected 'active' or 'paused'"),
655 "{err}"
656 );
657 }
658 }
659
660 #[test]
661 fn create_schema_auto_approve_defaults_to_false() {
662 let schema = AutomationTool::alias("automation_create", "create").input_schema();
663 let auto_approve = &schema["properties"]["auto_approve"];
664 assert_eq!(auto_approve["type"], "boolean");
665 assert_eq!(auto_approve["default"], false);
666 }
667
668 #[test]
669 fn create_schema_exposes_delivery_mode_and_new_schedule_forms() {
670 let schema = AutomationTool::alias("automation_create", "create").input_schema();
671 assert!(schema["properties"]["model"].is_object());
672 assert_eq!(
673 schema["properties"]["delivery_mode"]["enum"],
674 json!(["task", "watcher"])
675 );
676 let description = schema["properties"]["rrule"]["description"]
677 .as_str()
678 .expect("rrule description");
679 assert!(description.contains("FREQ=ONCE"));
680 assert!(description.contains("FREQ=CRON"));
681 }
682
683 #[test]
684 fn canonical_schema_lists_all_actions_and_union_fields() {
685 let schema = AutomationTool::new("automation").input_schema();
686 let actions = schema["properties"]["action"]["enum"]
687 .as_array()
688 .expect("action enum");
689 for action in [
690 "create", "list", "read", "update", "pause", "resume", "delete", "run",
691 ] {
692 assert!(
693 actions.iter().any(|value| value.as_str() == Some(action)),
694 "canonical schema must offer action {action}"
695 );
696 }
697 for field in [
698 "name",
699 "prompt",
700 "rrule",
701 "model",
702 "delivery_mode",
703 "automation_id",
704 "limit",
705 ] {
706 assert!(
707 schema["properties"][field].is_object(),
708 "canonical schema must carry union field {field}"
709 );
710 }
711 assert_eq!(schema["additionalProperties"], json!(false));
712 }
713
714 #[test]
715 fn read_only_variant_only_offers_read_actions() {
716 let tool = AutomationTool::read_only("automation");
717 let schema = tool.input_schema();
718 let actions = schema["properties"]["action"]["enum"]
719 .as_array()
720 .expect("action enum");
721 assert_eq!(actions, &vec![json!("list"), json!("read")]);
722 assert!(!schema["properties"]["rrule"].is_object());
723 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
724 assert!(tool.is_read_only());
725 }
726
727 #[test]
728 fn aliases_hide_from_model_and_force_action() {
729 let create = AutomationTool::alias("automation_create", "create");
730 assert!(!create.model_visible());
731 assert_eq!(create.name(), "automation_create");
732 assert_eq!(create.approval_requirement(), ApprovalRequirement::Required);
733
734 let list = AutomationTool::alias("automation_list", "list");
735 assert!(!list.model_visible());
736 assert_eq!(list.approval_requirement(), ApprovalRequirement::Auto);
737 assert!(list.is_read_only_for(&json!({})));
738
739 let canonical = AutomationTool::new("automation");
740 assert!(canonical.model_visible());
741 // Approval routing stays per action: read actions auto, writes required.
742 assert_eq!(
743 canonical.approval_requirement_for(&json!({"action": "list"})),
744 ApprovalRequirement::Auto
745 );
746 assert_eq!(
747 canonical.approval_requirement_for(&json!({"action": "delete"})),
748 ApprovalRequirement::Required
749 );
750 assert!(canonical.is_read_only_for(&json!({"action": "read"})));
751 assert!(!canonical.is_read_only_for(&json!({"action": "create"})));
752 }
753
754 #[test]
755 fn canonical_rejects_unknown_or_missing_action() {
756 let tool = AutomationTool::new("automation");
757 let err = tool
758 .resolve_action(&json!({}))
759 .expect_err("missing action must fail");
760 assert!(err.to_string().contains("missing `action`"));
761 let err = tool
762 .resolve_action(&json!({"action": "explode"}))
763 .expect_err("unknown action must fail");
764 assert!(err.to_string().contains("invalid action"));
765
766 let read_only = AutomationTool::read_only("automation");
767 let err = read_only
768 .resolve_action(&json!({"action": "delete"}))
769 .expect_err("read-only surface must reject write actions");
770 assert!(err.to_string().contains("invalid action"));
771 }
772
773 /// Exec-shaped services: the shared store is attached, but the one-shot
774 /// host holds no task-execution lease, so its manager is unbound.
775 fn exec_shaped_context(tmp: &tempfile::TempDir) -> ToolContext {
776 let manager = crate::automation_manager::AutomationManager::open(tmp.path().to_path_buf())
777 .expect("open store");
778 assert!(
779 manager.execution_scope().is_none(),
780 "fixture must model the unbound one-shot host"
781 );
782 context_with(manager)
783 }
784
785 /// A host that owns the task-execution lease, as the Runtime and the
786 /// interactive session do.
787 fn owning_context(tmp: &tempfile::TempDir) -> ToolContext {
788 context_with(
789 crate::automation_manager::AutomationManager::open_for_test(tmp.path().to_path_buf())
790 .expect("open store"),
791 )
792 }
793
794 fn context_with(manager: crate::automation_manager::AutomationManager) -> ToolContext {
795 ToolContext::new(".").with_runtime_services(crate::tools::spec::RuntimeToolServices {
796 automations: Some(std::sync::Arc::new(tokio::sync::Mutex::new(manager))),
797 ..Default::default()
798 })
799 }
800
801 fn create_input(paused: bool) -> Value {
802 json!({
803 "action": "create",
804 "name": "nightly",
805 "prompt": "Summarize what landed today.",
806 "rrule": "FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=30",
807 "paused": paused,
808 })
809 }
810
811 /// The reproduced defect. With the store attached, inspection works —
812 /// headless exec no longer answers "AutomationManager is not attached" for
813 /// the read actions it advertises.
814 #[tokio::test]
815 async fn inspection_works_without_a_dispatch_owner() {
816 let tmp = tempfile::TempDir::new().expect("tempdir");
817 let ctx = exec_shaped_context(&tmp);
818 let tool = AutomationTool::new("automation");
819 let result = tool
820 .execute(json!({"action": "list"}), &ctx)
821 .await
822 .expect("list must serve an attached store");
823 assert_eq!(result.content.trim(), "[]");
824 }
825
826 /// Attaching the store must not let a host without a dispatch owner
827 /// promise work it cannot deliver: only the owning scope's scheduler
828 /// admits a record, so an Active definition written here would carry a
829 /// `next_run_at` nothing honors.
830 #[tokio::test]
831 async fn activating_work_requires_a_dispatch_owner() {
832 let tmp = tempfile::TempDir::new().expect("tempdir");
833 let ctx = exec_shaped_context(&tmp);
834 let tool = AutomationTool::new("automation");
835
836 for (input, what) in [
837 (create_input(false), "create an active automation"),
838 (
839 json!({"action": "update", "automation_id": "a1", "status": "active"}),
840 "activate an automation",
841 ),
842 (json!({"action": "resume", "automation_id": "a1"}), "resume"),
843 ] {
844 let err = tool
845 .execute(input, &ctx)
846 .await
847 .expect_err("must refuse without a dispatch owner");
848 let message = err.to_string();
849 assert!(
850 message.contains("persistent execution owner"),
851 "{what} must explain the missing owner: {message}"
852 );
853 }
854 assert!(
855 crate::automation_manager::AutomationManager::open(tmp.path().to_path_buf())
856 .expect("reopen")
857 .list_automations()
858 .expect("list")
859 .is_empty(),
860 "a refused activation must not leave a record behind"
861 );
862 }
863
864 /// A paused definition is inert by construction and is adopted by the
865 /// first owning host that resumes it, so storing intent stays honest.
866 #[tokio::test]
867 async fn a_paused_definition_is_still_allowed_without_an_owner() {
868 let tmp = tempfile::TempDir::new().expect("tempdir");
869 let ctx = exec_shaped_context(&tmp);
870 let tool = AutomationTool::new("automation");
871 tool.execute(create_input(true), &ctx)
872 .await
873 .expect("paused definitions need no dispatch owner");
874 let stored = crate::automation_manager::AutomationManager::open(tmp.path().to_path_buf())
875 .expect("reopen")
876 .list_automations()
877 .expect("list");
878 assert_eq!(stored.len(), 1);
879 assert_eq!(stored[0].status, AutomationStatus::Paused);
880 assert!(
881 stored[0].next_run_at.is_none(),
882 "a paused definition must not advertise a next run"
883 );
884 }
885
886 /// The guard is about the *host*, not the action: a host that owns the
887 /// lease keeps creating active automations exactly as before.
888 #[tokio::test]
889 async fn an_owning_host_still_creates_active_automations() {
890 let tmp = tempfile::TempDir::new().expect("tempdir");
891 let ctx = owning_context(&tmp);
892 let tool = AutomationTool::new("automation");
893 tool.execute(create_input(false), &ctx)
894 .await
895 .expect("an owning host may schedule");
896 let stored = crate::automation_manager::AutomationManager::open(tmp.path().to_path_buf())
897 .expect("reopen")
898 .list_automations()
899 .expect("list");
900 assert_eq!(stored.len(), 1);
901 assert_eq!(stored[0].status, AutomationStatus::Active);
902 assert!(stored[0].next_run_at.is_some());
903 }
904 }
905
905 lines RUST