Skip to main content

microclaw_engine/tools/
schedule.rs

1use std::str::FromStr;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use chrono::{NaiveDateTime, TimeZone, Utc};
6use serde_json::json;
7
8use super::{authorize_chat_access, schema_object, Tool, ToolResult};
9use crate::internal::channels::channel::enforce_channel_policy;
10use crate::internal::channels::channel_adapter::ChannelRegistry;
11use crate::internal::storage::db::{call_blocking, Database};
12use microclaw_core::llm_types::ToolDefinition;
13
14fn compute_next_run(cron_expr: &str, tz_name: &str) -> Result<String, String> {
15    let tz: chrono_tz::Tz = tz_name
16        .parse()
17        .map_err(|_| format!("Invalid timezone: {tz_name}"))?;
18    let schedule =
19        cron::Schedule::from_str(cron_expr).map_err(|e| format!("Invalid cron expression: {e}"))?;
20    let next = schedule
21        .upcoming(tz)
22        .next()
23        .ok_or_else(|| "No upcoming run found for this cron expression".to_string())?;
24    Ok(next.with_timezone(&chrono::Utc).to_rfc3339())
25}
26
27fn parse_once_schedule_value(
28    schedule_value: &str,
29    tz_name: &str,
30) -> Result<chrono::DateTime<Utc>, String> {
31    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(schedule_value) {
32        return Ok(dt.with_timezone(&Utc));
33    }
34
35    // Be tolerant to common model outputs without explicit timezone.
36    let tz: chrono_tz::Tz = tz_name
37        .parse()
38        .map_err(|_| format!("Invalid timezone: {tz_name}"))?;
39    let candidate_formats = [
40        "%Y-%m-%d %H:%M:%S",
41        "%Y-%m-%d %H:%M",
42        "%Y-%m-%dT%H:%M:%S",
43        "%Y-%m-%dT%H:%M",
44    ];
45    for fmt in candidate_formats {
46        if let Ok(naive) = NaiveDateTime::parse_from_str(schedule_value, fmt) {
47            let local = tz.from_local_datetime(&naive).single().ok_or_else(|| {
48                format!(
49                    "Ambiguous/non-existent local time for timezone {tz_name}: {schedule_value}"
50                )
51            })?;
52            return Ok(local.with_timezone(&Utc));
53        }
54    }
55
56    Err(
57        "Invalid ISO 8601 timestamp for one-time schedule. Use RFC3339, e.g. 2026-02-27T14:25:00+08:00 or 2026-02-27T06:25:00Z."
58            .to_string(),
59    )
60}
61
62fn parse_cron_fields(cron_expr: &str) -> Vec<&str> {
63    cron_expr.split_whitespace().collect()
64}
65
66fn cron_human_hint(cron_expr: &str) -> Option<String> {
67    let fields = parse_cron_fields(cron_expr);
68    if fields.len() != 6 {
69        return None;
70    }
71    let sec = fields[0];
72    let min = fields[1];
73    let hour = fields[2];
74    let dom = fields[3];
75    let month = fields[4];
76    let dow = fields[5];
77
78    if sec == "0" && min == "*" && hour == "*" && dom == "*" && month == "*" && dow == "*" {
79        return Some("every minute".to_string());
80    }
81
82    if sec == "0"
83        && min.starts_with("*/")
84        && hour == "*"
85        && dom == "*"
86        && month == "*"
87        && dow == "*"
88    {
89        if let Some(step) = min.strip_prefix("*/") {
90            return Some(format!("every {step} minutes"));
91        }
92    }
93
94    if sec == "0"
95        && min.parse::<u32>().is_ok()
96        && hour == "*"
97        && dom == "*"
98        && month == "*"
99        && dow == "*"
100    {
101        return Some(format!("every hour at minute {:0>2}", min));
102    }
103
104    if sec == "0"
105        && min.parse::<u32>().is_ok()
106        && hour.starts_with("*/")
107        && dom == "*"
108        && month == "*"
109        && dow == "*"
110    {
111        if let Some(step) = hour.strip_prefix("*/") {
112            return Some(format!("every {step} hours"));
113        }
114    }
115
116    if sec == "0"
117        && min.parse::<u32>().is_ok()
118        && hour.parse::<u32>().is_ok()
119        && dom == "*"
120        && month == "*"
121        && dow == "*"
122    {
123        return Some(format!("daily at {:0>2}:{:0>2}", hour, min));
124    }
125
126    None
127}
128
129// --- schedule_task ---
130
131pub struct ScheduleTaskTool {
132    registry: Arc<ChannelRegistry>,
133    db: Arc<Database>,
134    default_timezone: String,
135}
136
137impl ScheduleTaskTool {
138    pub fn new(
139        registry: Arc<ChannelRegistry>,
140        db: Arc<Database>,
141        default_timezone: String,
142    ) -> Self {
143        ScheduleTaskTool {
144            registry,
145            db,
146            default_timezone,
147        }
148    }
149}
150
151#[async_trait]
152impl Tool for ScheduleTaskTool {
153    fn name(&self) -> &str {
154        "schedule_task"
155    }
156
157    fn definition(&self) -> ToolDefinition {
158        ToolDefinition {
159            name: "schedule_task".into(),
160            description: "Schedule a recurring or one-time task. For recurring tasks, provide a 6-field cron expression (sec min hour dom month dow). For one-time tasks, provide an ISO 8601 timestamp. The bot will execute the prompt at the scheduled time and send the result to this chat.".into(),
161            input_schema: schema_object(
162                json!({
163                    "chat_id": {
164                        "type": "integer",
165                        "description": "The chat ID where results will be sent"
166                    },
167                    "prompt": {
168                        "type": "string",
169                        "description": "The prompt/instruction to execute at the scheduled time"
170                    },
171                    "schedule_type": {
172                        "type": "string",
173                        "enum": ["cron", "once", "random"],
174                        "description": "Type of schedule: 'cron' for recurring (6-field: sec min hour dom month dow), 'once' for one-time, 'random' for spontaneous firing at a uniformly random moment inside a duration window after each run (feels like the bot 'just thought of it')"
175                    },
176                    "schedule_value": {
177                        "type": "string",
178                        "description": "cron: 6-field expression (e.g. '0 */5 * * * *'); once: ISO 8601 timestamp; random: a duration window like '2h..3d', '30m..4h', or '1d..2mo' (units s/m/h/d/w/mo; window between 1 minute and 366 days)"
179                    },
180                    "timezone": {
181                        "type": "string",
182                        "description": "Optional IANA timezone name (e.g. 'US/Eastern', 'Europe/London'). Defaults to server timezone setting."
183                    },
184                    "max_runs": {
185                        "type": "integer",
186                        "minimum": 1,
187                        "description": "Optional: retire the task as completed after it has run this many times (e.g. 'remind me 3 times'). Works with any schedule_type."
188                    },
189                    "not_after": {
190                        "type": "string",
191                        "description": "Optional ISO 8601 deadline: the task retires as completed once the next firing would pass this instant (e.g. 'until the end of the month'). Works with any schedule_type."
192                    },
193                    "exit_criteria": {
194                        "type": "array",
195                        "maxItems": 8,
196                        "description": "Optional completion contract verified after each run: {type:'file_exists', path} | {type:'file_contains', path, needle} | {type:'file_min_bytes', path, min_bytes} | {type:'result_contains', needle} | {type:'command', run, expect_contains?}. Paths are relative to the chat working dir. A run whose contract fails is recorded as failed (one-shot tasks then go to the DLQ and are auto-replayed).",
197                        "items": {"type": "object"}
198                    }
199                }),
200                &["chat_id", "prompt", "schedule_type", "schedule_value"],
201            ),
202        }
203    }
204
205    async fn execute(&self, input: serde_json::Value) -> ToolResult {
206        let chat_id = match input.get("chat_id").and_then(|v| v.as_i64()) {
207            Some(id) => id,
208            None => return ToolResult::error("Missing required parameter: chat_id".into()),
209        };
210        if let Err(e) = authorize_chat_access(&input, chat_id) {
211            return ToolResult::error(e);
212        }
213        if let Err(e) =
214            enforce_channel_policy(&self.registry, self.db.clone(), &input, chat_id).await
215        {
216            return ToolResult::error(e);
217        }
218        let prompt = match input.get("prompt").and_then(|v| v.as_str()) {
219            Some(p) => p,
220            None => return ToolResult::error("Missing required parameter: prompt".into()),
221        };
222        let schedule_type = match input.get("schedule_type").and_then(|v| v.as_str()) {
223            Some(t) => t,
224            None => return ToolResult::error("Missing required parameter: schedule_type".into()),
225        };
226        let schedule_value = match input.get("schedule_value").and_then(|v| v.as_str()) {
227            Some(v) => v,
228            None => return ToolResult::error("Missing required parameter: schedule_value".into()),
229        };
230        let tz_name = input
231            .get("timezone")
232            .and_then(|v| v.as_str())
233            .unwrap_or(&self.default_timezone);
234
235        let next_run = match schedule_type {
236            "cron" => match compute_next_run(schedule_value, tz_name) {
237                Ok(nr) => nr,
238                Err(e) => return ToolResult::error(e),
239            },
240            "once" => {
241                // Validate and normalize to UTC for consistent SQLite string comparison.
242                let mut dt_utc = match parse_once_schedule_value(schedule_value, tz_name) {
243                    Ok(dt) => dt,
244                    Err(e) => return ToolResult::error(e),
245                };
246                let now = Utc::now();
247                if dt_utc <= now {
248                    let lag = now - dt_utc;
249                    // Tolerate small scheduling races (e.g. model picks current minute :00 and
250                    // tool executes a few seconds later) by shifting to the next minute.
251                    if lag <= chrono::Duration::seconds(59) {
252                        dt_utc += chrono::Duration::minutes(1);
253                    } else {
254                        return ToolResult::error(
255                            "One-time schedule timestamp must be in the future".into(),
256                        );
257                    }
258                }
259                dt_utc.to_rfc3339()
260            }
261            "random" => match crate::schedule_lifecycle::parse_duration_range(schedule_value) {
262                Ok((min, max)) => crate::schedule_lifecycle::sample_random_next(
263                    Utc::now(),
264                    min,
265                    max,
266                    crate::schedule_lifecycle::random_unit(),
267                )
268                .to_rfc3339(),
269                Err(e) => return ToolResult::error(e),
270            },
271            _ => {
272                return ToolResult::error(
273                    "schedule_type must be 'cron', 'once', or 'random'".into(),
274                )
275            }
276        };
277
278        let max_runs = match input.get("max_runs") {
279            None | Some(serde_json::Value::Null) => None,
280            Some(v) => match v.as_i64() {
281                Some(n) if n >= 1 => Some(n),
282                _ => return ToolResult::error("max_runs must be a positive integer".into()),
283            },
284        };
285        let not_after = match input.get("not_after").and_then(|v| v.as_str()) {
286            None => None,
287            Some(raw) => match chrono::DateTime::parse_from_rfc3339(raw) {
288                Ok(dt) => {
289                    let dt_utc = dt.with_timezone(&Utc);
290                    if dt_utc <= Utc::now() {
291                        return ToolResult::error("not_after must be in the future".into());
292                    }
293                    Some(dt_utc.to_rfc3339())
294                }
295                Err(e) => {
296                    return ToolResult::error(format!(
297                        "not_after must be an ISO 8601 timestamp (e.g. 2026-08-01T00:00:00Z): {e}"
298                    ))
299                }
300            },
301        };
302
303        let exit_criteria =
304            match crate::completion_contract::parse_exit_criteria(input.get("exit_criteria")) {
305                Ok(v) => v,
306                Err(e) => return ToolResult::error(e),
307            };
308        let exit_criteria_json = if exit_criteria.is_empty() {
309            None
310        } else {
311            match serde_json::to_string(&exit_criteria) {
312                Ok(v) => Some(v),
313                Err(e) => return ToolResult::error(format!("Invalid exit_criteria: {e}")),
314            }
315        };
316
317        let prompt_owned = prompt.to_string();
318        let schedule_type_owned = schedule_type.to_string();
319        let schedule_value_owned = schedule_value.to_string();
320        let tz_name_owned = tz_name.to_string();
321        let next_run_owned = next_run.clone();
322        let not_after_owned = not_after.clone();
323        match call_blocking(self.db.clone(), move |db| {
324            db.create_scheduled_task_lifecycle(
325                chat_id,
326                &prompt_owned,
327                &schedule_type_owned,
328                &schedule_value_owned,
329                &tz_name_owned,
330                &next_run_owned,
331                exit_criteria_json.as_deref(),
332                max_runs,
333                not_after_owned.as_deref(),
334            )
335        })
336        .await
337        {
338            Ok(id) => {
339                let cadence = if schedule_type == "cron" {
340                    cron_human_hint(schedule_value)
341                } else {
342                    None
343                };
344                let mut message =
345                    format!("Task #{id} scheduled (tz: {tz_name}). Next run: {next_run}");
346                if schedule_type == "random" {
347                    message.push_str(&format!(
348                        "\nSpontaneous cadence: fires at a random moment within {schedule_value} after each run."
349                    ));
350                }
351                if let Some(n) = max_runs {
352                    message.push_str(&format!("\nLifecycle: retires after {n} run(s)."));
353                }
354                if let Some(deadline) = &not_after {
355                    message.push_str(&format!("\nLifecycle: retires after {deadline}."));
356                }
357                if let Some(c) = cadence {
358                    message.push_str(&format!("\nCron interpretation: {c}."));
359                }
360                ToolResult::success(message)
361            }
362            Err(e) => ToolResult::error(format!("Failed to create task: {e}")),
363        }
364    }
365}
366
367// --- list_tasks ---
368
369pub struct ListTasksTool {
370    registry: Arc<ChannelRegistry>,
371    db: Arc<Database>,
372}
373
374impl ListTasksTool {
375    pub fn new(registry: Arc<ChannelRegistry>, db: Arc<Database>) -> Self {
376        ListTasksTool { registry, db }
377    }
378}
379
380#[async_trait]
381impl Tool for ListTasksTool {
382    fn name(&self) -> &str {
383        "list_scheduled_tasks"
384    }
385
386    fn definition(&self) -> ToolDefinition {
387        ToolDefinition {
388            name: "list_scheduled_tasks".into(),
389            description: "List all active and paused scheduled tasks for a chat.".into(),
390            input_schema: schema_object(
391                json!({
392                    "chat_id": {
393                        "type": "integer",
394                        "description": "The chat ID to list tasks for"
395                    }
396                }),
397                &["chat_id"],
398            ),
399        }
400    }
401
402    async fn execute(&self, input: serde_json::Value) -> ToolResult {
403        let chat_id = match input.get("chat_id").and_then(|v| v.as_i64()) {
404            Some(id) => id,
405            None => return ToolResult::error("Missing required parameter: chat_id".into()),
406        };
407        if let Err(e) = authorize_chat_access(&input, chat_id) {
408            return ToolResult::error(e);
409        }
410        if let Err(e) =
411            enforce_channel_policy(&self.registry, self.db.clone(), &input, chat_id).await
412        {
413            return ToolResult::error(e);
414        }
415
416        match call_blocking(self.db.clone(), move |db| db.get_tasks_for_chat(chat_id)).await {
417            Ok(tasks) => {
418                if tasks.is_empty() {
419                    return ToolResult::success("No scheduled tasks found for this chat.".into());
420                }
421                let now = Utc::now();
422                let mut output = String::new();
423                for t in &tasks {
424                    let tz_label = if t.timezone.trim().is_empty() {
425                        "(default)".to_string()
426                    } else {
427                        t.timezone.clone()
428                    };
429                    let cadence = if t.schedule_type == "cron" {
430                        cron_human_hint(&t.schedule_value)
431                            .map(|s| format!(" | cadence: {s}"))
432                            .unwrap_or_default()
433                    } else if t.schedule_type == "random" {
434                        format!(" | cadence: random within {}", t.schedule_value)
435                    } else {
436                        String::new()
437                    };
438                    let countdown = if t.status == "active" {
439                        crate::schedule_lifecycle::humanize_until(now, &t.next_run)
440                            .map(|h| format!(" ({h})"))
441                            .unwrap_or_default()
442                    } else {
443                        String::new()
444                    };
445                    let mut lifecycle = String::new();
446                    match t.max_runs {
447                        Some(max) => lifecycle.push_str(&format!(" | runs: {}/{max}", t.run_count)),
448                        None if t.run_count > 0 => {
449                            lifecycle.push_str(&format!(" | runs: {}", t.run_count))
450                        }
451                        None => {}
452                    }
453                    if let Some(deadline) = &t.not_after {
454                        lifecycle.push_str(&format!(" | until: {deadline}"));
455                    }
456                    if t.exit_criteria.is_some() {
457                        lifecycle.push_str(" | contract: yes");
458                    }
459                    output.push_str(&format!(
460                        "#{} [{}] {} | {} '{}'{}{} | tz: {} | next: {}{}\n",
461                        t.id,
462                        t.status,
463                        t.prompt,
464                        t.schedule_type,
465                        t.schedule_value,
466                        cadence,
467                        lifecycle,
468                        tz_label,
469                        t.next_run,
470                        countdown
471                    ));
472                }
473                ToolResult::success(output)
474            }
475            Err(e) => ToolResult::error(format!("Failed to list tasks: {e}")),
476        }
477    }
478}
479
480// --- pause_task ---
481
482pub struct PauseTaskTool {
483    registry: Arc<ChannelRegistry>,
484    db: Arc<Database>,
485}
486
487impl PauseTaskTool {
488    pub fn new(registry: Arc<ChannelRegistry>, db: Arc<Database>) -> Self {
489        PauseTaskTool { registry, db }
490    }
491}
492
493#[async_trait]
494impl Tool for PauseTaskTool {
495    fn name(&self) -> &str {
496        "pause_scheduled_task"
497    }
498
499    fn definition(&self) -> ToolDefinition {
500        ToolDefinition {
501            name: "pause_scheduled_task".into(),
502            description: "Pause a scheduled task. It will not run until resumed.".into(),
503            input_schema: schema_object(
504                json!({
505                    "task_id": {
506                        "type": "integer",
507                        "description": "The task ID to pause"
508                    }
509                }),
510                &["task_id"],
511            ),
512        }
513    }
514
515    async fn execute(&self, input: serde_json::Value) -> ToolResult {
516        let task_id = match input.get("task_id").and_then(|v| v.as_i64()) {
517            Some(id) => id,
518            None => return ToolResult::error("Missing required parameter: task_id".into()),
519        };
520        let task = match call_blocking(self.db.clone(), move |db| db.get_task_by_id(task_id)).await
521        {
522            Ok(Some(t)) => t,
523            Ok(None) => return ToolResult::error(format!("Task #{task_id} not found.")),
524            Err(e) => return ToolResult::error(format!("Failed to load task: {e}")),
525        };
526        if let Err(e) = authorize_chat_access(&input, task.chat_id) {
527            return ToolResult::error(e);
528        }
529        if let Err(e) =
530            enforce_channel_policy(&self.registry, self.db.clone(), &input, task.chat_id).await
531        {
532            return ToolResult::error(e);
533        }
534
535        match call_blocking(self.db.clone(), move |db| {
536            db.update_task_status(task_id, "paused")
537        })
538        .await
539        {
540            Ok(true) => ToolResult::success(format!("Task #{task_id} paused.")),
541            Ok(false) => ToolResult::error(format!("Task #{task_id} not found.")),
542            Err(e) => ToolResult::error(format!("Failed to pause task: {e}")),
543        }
544    }
545}
546
547// --- resume_task ---
548
549pub struct ResumeTaskTool {
550    registry: Arc<ChannelRegistry>,
551    db: Arc<Database>,
552}
553
554impl ResumeTaskTool {
555    pub fn new(registry: Arc<ChannelRegistry>, db: Arc<Database>) -> Self {
556        ResumeTaskTool { registry, db }
557    }
558}
559
560#[async_trait]
561impl Tool for ResumeTaskTool {
562    fn name(&self) -> &str {
563        "resume_scheduled_task"
564    }
565
566    fn definition(&self) -> ToolDefinition {
567        ToolDefinition {
568            name: "resume_scheduled_task".into(),
569            description: "Resume a paused scheduled task.".into(),
570            input_schema: schema_object(
571                json!({
572                    "task_id": {
573                        "type": "integer",
574                        "description": "The task ID to resume"
575                    }
576                }),
577                &["task_id"],
578            ),
579        }
580    }
581
582    async fn execute(&self, input: serde_json::Value) -> ToolResult {
583        let task_id = match input.get("task_id").and_then(|v| v.as_i64()) {
584            Some(id) => id,
585            None => return ToolResult::error("Missing required parameter: task_id".into()),
586        };
587        let task = match call_blocking(self.db.clone(), move |db| db.get_task_by_id(task_id)).await
588        {
589            Ok(Some(t)) => t,
590            Ok(None) => return ToolResult::error(format!("Task #{task_id} not found.")),
591            Err(e) => return ToolResult::error(format!("Failed to load task: {e}")),
592        };
593        if let Err(e) = authorize_chat_access(&input, task.chat_id) {
594            return ToolResult::error(e);
595        }
596        if let Err(e) =
597            enforce_channel_policy(&self.registry, self.db.clone(), &input, task.chat_id).await
598        {
599            return ToolResult::error(e);
600        }
601
602        match call_blocking(self.db.clone(), move |db| {
603            db.update_task_status(task_id, "active")
604        })
605        .await
606        {
607            Ok(true) => ToolResult::success(format!("Task #{task_id} resumed.")),
608            Ok(false) => ToolResult::error(format!("Task #{task_id} not found.")),
609            Err(e) => ToolResult::error(format!("Failed to resume task: {e}")),
610        }
611    }
612}
613
614// --- cancel_task ---
615
616pub struct CancelTaskTool {
617    registry: Arc<ChannelRegistry>,
618    db: Arc<Database>,
619}
620
621impl CancelTaskTool {
622    pub fn new(registry: Arc<ChannelRegistry>, db: Arc<Database>) -> Self {
623        CancelTaskTool { registry, db }
624    }
625}
626
627#[async_trait]
628impl Tool for CancelTaskTool {
629    fn name(&self) -> &str {
630        "cancel_scheduled_task"
631    }
632
633    fn definition(&self) -> ToolDefinition {
634        ToolDefinition {
635            name: "cancel_scheduled_task".into(),
636            description: "Cancel (delete) a scheduled task permanently.".into(),
637            input_schema: schema_object(
638                json!({
639                    "task_id": {
640                        "type": "integer",
641                        "description": "The task ID to cancel"
642                    }
643                }),
644                &["task_id"],
645            ),
646        }
647    }
648
649    async fn execute(&self, input: serde_json::Value) -> ToolResult {
650        let task_id = match input.get("task_id").and_then(|v| v.as_i64()) {
651            Some(id) => id,
652            None => return ToolResult::error("Missing required parameter: task_id".into()),
653        };
654        let task = match call_blocking(self.db.clone(), move |db| db.get_task_by_id(task_id)).await
655        {
656            Ok(Some(t)) => t,
657            Ok(None) => return ToolResult::error(format!("Task #{task_id} not found.")),
658            Err(e) => return ToolResult::error(format!("Failed to load task: {e}")),
659        };
660        if let Err(e) = authorize_chat_access(&input, task.chat_id) {
661            return ToolResult::error(e);
662        }
663        if let Err(e) =
664            enforce_channel_policy(&self.registry, self.db.clone(), &input, task.chat_id).await
665        {
666            return ToolResult::error(e);
667        }
668
669        match call_blocking(self.db.clone(), move |db| {
670            db.update_task_status(task_id, "cancelled")
671        })
672        .await
673        {
674            Ok(true) => ToolResult::success(format!("Task #{task_id} cancelled.")),
675            Ok(false) => ToolResult::error(format!("Task #{task_id} not found.")),
676            Err(e) => ToolResult::error(format!("Failed to cancel task: {e}")),
677        }
678    }
679}
680
681// --- get_task_history ---
682
683pub struct GetTaskHistoryTool {
684    registry: Arc<ChannelRegistry>,
685    db: Arc<Database>,
686}
687
688// --- list_task_dlq ---
689
690pub struct ListTaskDlqTool {
691    registry: Arc<ChannelRegistry>,
692    db: Arc<Database>,
693}
694
695impl ListTaskDlqTool {
696    pub fn new(registry: Arc<ChannelRegistry>, db: Arc<Database>) -> Self {
697        ListTaskDlqTool { registry, db }
698    }
699}
700
701#[async_trait]
702impl Tool for ListTaskDlqTool {
703    fn name(&self) -> &str {
704        "list_scheduled_task_dlq"
705    }
706
707    fn definition(&self) -> ToolDefinition {
708        ToolDefinition {
709            name: "list_scheduled_task_dlq".into(),
710            description: "List failed scheduler runs in DLQ for a chat. Use include_replayed=true to inspect replayed entries.".into(),
711            input_schema: schema_object(
712                json!({
713                    "chat_id": {
714                        "type": "integer",
715                        "description": "The chat ID to inspect DLQ entries for"
716                    },
717                    "task_id": {
718                        "type": "integer",
719                        "description": "Optional task ID filter"
720                    },
721                    "include_replayed": {
722                        "type": "boolean",
723                        "description": "Whether to include already replayed entries"
724                    },
725                    "limit": {
726                        "type": "integer",
727                        "description": "Maximum entries to return (default: 20, max: 200)"
728                    }
729                }),
730                &["chat_id"],
731            ),
732        }
733    }
734
735    async fn execute(&self, input: serde_json::Value) -> ToolResult {
736        let chat_id = match input.get("chat_id").and_then(|v| v.as_i64()) {
737            Some(id) => id,
738            None => return ToolResult::error("Missing required parameter: chat_id".into()),
739        };
740        if let Err(e) = authorize_chat_access(&input, chat_id) {
741            return ToolResult::error(e);
742        }
743        if let Err(e) =
744            enforce_channel_policy(&self.registry, self.db.clone(), &input, chat_id).await
745        {
746            return ToolResult::error(e);
747        }
748        let task_id = input.get("task_id").and_then(|v| v.as_i64());
749        let include_replayed = input
750            .get("include_replayed")
751            .and_then(|v| v.as_bool())
752            .unwrap_or(false);
753        let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
754        let limit = limit.clamp(1, 200);
755
756        match call_blocking(self.db.clone(), move |db| {
757            db.list_scheduled_task_dlq(Some(chat_id), task_id, include_replayed, limit)
758        })
759        .await
760        {
761            Ok(entries) => {
762                if entries.is_empty() {
763                    return ToolResult::success("No DLQ entries found.".into());
764                }
765                let mut out = String::new();
766                out.push_str("Scheduled task DLQ entries (most recent first):\n\n");
767                for e in entries {
768                    let replay_state = if e.replayed_at.is_some() {
769                        "REPLAYED"
770                    } else {
771                        "PENDING"
772                    };
773                    out.push_str(&format!(
774                        "- #{id} task={task} [{state}] failed_at={failed} duration={dur}ms error={err}\n",
775                        id = e.id,
776                        task = e.task_id,
777                        state = replay_state,
778                        failed = e.failed_at,
779                        dur = e.duration_ms,
780                        err = e.error_summary.as_deref().unwrap_or("(no summary)")
781                    ));
782                }
783                ToolResult::success(out)
784            }
785            Err(e) => ToolResult::error(format!("Failed to list DLQ entries: {e}")),
786        }
787    }
788}
789
790// --- replay_task_dlq ---
791
792pub struct ReplayTaskDlqTool {
793    registry: Arc<ChannelRegistry>,
794    db: Arc<Database>,
795}
796
797impl ReplayTaskDlqTool {
798    pub fn new(registry: Arc<ChannelRegistry>, db: Arc<Database>) -> Self {
799        ReplayTaskDlqTool { registry, db }
800    }
801}
802
803#[async_trait]
804impl Tool for ReplayTaskDlqTool {
805    fn name(&self) -> &str {
806        "replay_scheduled_task_dlq"
807    }
808
809    fn definition(&self) -> ToolDefinition {
810        ToolDefinition {
811            name: "replay_scheduled_task_dlq".into(),
812            description: "Replay failed scheduler runs from DLQ by re-queueing their tasks for immediate execution.".into(),
813            input_schema: schema_object(
814                json!({
815                    "chat_id": {
816                        "type": "integer",
817                        "description": "The chat ID to replay DLQ entries for"
818                    },
819                    "task_id": {
820                        "type": "integer",
821                        "description": "Optional task ID filter"
822                    },
823                    "limit": {
824                        "type": "integer",
825                        "description": "Maximum DLQ entries to replay (default: 5, max: 50)"
826                    }
827                }),
828                &["chat_id"],
829            ),
830        }
831    }
832
833    async fn execute(&self, input: serde_json::Value) -> ToolResult {
834        let chat_id = match input.get("chat_id").and_then(|v| v.as_i64()) {
835            Some(id) => id,
836            None => return ToolResult::error("Missing required parameter: chat_id".into()),
837        };
838        if let Err(e) = authorize_chat_access(&input, chat_id) {
839            return ToolResult::error(e);
840        }
841        if let Err(e) =
842            enforce_channel_policy(&self.registry, self.db.clone(), &input, chat_id).await
843        {
844            return ToolResult::error(e);
845        }
846        let task_id_filter = input.get("task_id").and_then(|v| v.as_i64());
847        let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
848        let limit = limit.clamp(1, 50);
849        let now = Utc::now().to_rfc3339();
850
851        let entries = match call_blocking(self.db.clone(), move |db| {
852            db.list_scheduled_task_dlq(Some(chat_id), task_id_filter, false, limit)
853        })
854        .await
855        {
856            Ok(e) => e,
857            Err(e) => return ToolResult::error(format!("Failed to load DLQ entries: {e}")),
858        };
859        if entries.is_empty() {
860            return ToolResult::success("No pending DLQ entries to replay.".into());
861        }
862
863        let mut queued = 0usize;
864        let mut skipped = 0usize;
865        let mut notes = Vec::new();
866        for entry in entries {
867            let db = self.db.clone();
868            let now_for_requeue = now.clone();
869            let res = call_blocking(db, move |db| {
870                let task = db.get_task_by_id(entry.task_id)?;
871                let (queued_this, note) = match task {
872                    Some(t) => {
873                        if t.status == "cancelled" || t.status == "completed" {
874                            (
875                                false,
876                                format!("dlq #{} skipped: task status={}", entry.id, t.status),
877                            )
878                        } else {
879                            db.requeue_scheduled_task(entry.task_id, &now_for_requeue)?;
880                            (
881                                true,
882                                format!("dlq #{} queued task #{}", entry.id, entry.task_id),
883                            )
884                        }
885                    }
886                    None => (
887                        false,
888                        format!("dlq #{} skipped: task #{} missing", entry.id, entry.task_id),
889                    ),
890                };
891                db.mark_scheduled_task_dlq_replayed(entry.id, Some(&note))?;
892                Ok((queued_this, note))
893            })
894            .await;
895
896            match res {
897                Ok((true, note)) => {
898                    queued += 1;
899                    notes.push(note);
900                }
901                Ok((false, note)) => {
902                    skipped += 1;
903                    notes.push(note);
904                }
905                Err(e) => {
906                    skipped += 1;
907                    notes.push(format!("dlq #{} replay failed: {e}", entry.id));
908                }
909            }
910        }
911
912        ToolResult::success(format!(
913            "DLQ replay complete: queued={queued}, skipped={skipped}\n{}",
914            notes.join("\n")
915        ))
916    }
917}
918
919impl GetTaskHistoryTool {
920    pub fn new(registry: Arc<ChannelRegistry>, db: Arc<Database>) -> Self {
921        GetTaskHistoryTool { registry, db }
922    }
923}
924
925#[async_trait]
926impl Tool for GetTaskHistoryTool {
927    fn name(&self) -> &str {
928        "get_task_history"
929    }
930
931    fn definition(&self) -> ToolDefinition {
932        ToolDefinition {
933            name: "get_task_history".into(),
934            description: "Get the execution history/run logs for a scheduled task.".into(),
935            input_schema: schema_object(
936                json!({
937                    "task_id": {
938                        "type": "integer",
939                        "description": "The task ID to get history for"
940                    },
941                    "limit": {
942                        "type": "integer",
943                        "description": "Maximum number of log entries to return (default: 10)"
944                    }
945                }),
946                &["task_id"],
947            ),
948        }
949    }
950
951    async fn execute(&self, input: serde_json::Value) -> ToolResult {
952        let task_id = match input.get("task_id").and_then(|v| v.as_i64()) {
953            Some(id) => id,
954            None => return ToolResult::error("Missing required parameter: task_id".into()),
955        };
956        let task = match call_blocking(self.db.clone(), move |db| db.get_task_by_id(task_id)).await
957        {
958            Ok(Some(t)) => t,
959            Ok(None) => return ToolResult::error(format!("Task #{task_id} not found.")),
960            Err(e) => return ToolResult::error(format!("Failed to load task: {e}")),
961        };
962        if let Err(e) = authorize_chat_access(&input, task.chat_id) {
963            return ToolResult::error(e);
964        }
965        if let Err(e) =
966            enforce_channel_policy(&self.registry, self.db.clone(), &input, task.chat_id).await
967        {
968            return ToolResult::error(e);
969        }
970        let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
971
972        match call_blocking(self.db.clone(), move |db| {
973            db.get_task_run_logs(task_id, limit)
974        })
975        .await
976        {
977            Ok(logs) => {
978                if logs.is_empty() {
979                    return ToolResult::success(format!(
980                        "No run history found for task #{task_id}."
981                    ));
982                }
983                let mut output =
984                    format!("Run history for task #{task_id} (most recent first):\n\n");
985                for log in &logs {
986                    let status = if log.success { "OK" } else { "FAIL" };
987                    output.push_str(&format!(
988                        "- [{}] {} | duration: {}ms | {}\n",
989                        status,
990                        log.started_at,
991                        log.duration_ms,
992                        log.result_summary.as_deref().unwrap_or("(no summary)"),
993                    ));
994                }
995                ToolResult::success(output)
996            }
997            Err(e) => ToolResult::error(format!("Failed to get task history: {e}")),
998        }
999    }
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::*;
1005    use crate::internal::channels::channel::ConversationKind;
1006    use crate::internal::channels::channel_adapter::ChannelAdapter;
1007    use crate::internal::channels::channel_adapter::ChannelRegistry;
1008    use crate::internal::storage::db::Database;
1009    use serde_json::json;
1010
1011    fn test_registry() -> Arc<ChannelRegistry> {
1012        let mut registry = ChannelRegistry::new();
1013        registry.register(Arc::new(WebPolicyAdapter));
1014        Arc::new(registry)
1015    }
1016
1017    struct WebPolicyAdapter;
1018    const TEST_WEB_CHANNEL: &str = "web";
1019
1020    #[async_trait::async_trait]
1021    impl ChannelAdapter for WebPolicyAdapter {
1022        fn name(&self) -> &str {
1023            TEST_WEB_CHANNEL
1024        }
1025
1026        fn chat_type_routes(&self) -> Vec<(&str, ConversationKind)> {
1027            vec![("web", ConversationKind::Private)]
1028        }
1029
1030        fn is_local_only(&self) -> bool {
1031            true
1032        }
1033
1034        fn allows_cross_chat(&self) -> bool {
1035            false
1036        }
1037
1038        async fn send_text(&self, _external_chat_id: &str, _text: &str) -> Result<(), String> {
1039            Ok(())
1040        }
1041    }
1042
1043    fn test_db() -> (Arc<Database>, std::path::PathBuf) {
1044        let dir = std::env::temp_dir().join(format!("microclaw_sched_{}", uuid::Uuid::new_v4()));
1045        let db = Arc::new(Database::new(dir.to_str().unwrap()).unwrap());
1046        (db, dir)
1047    }
1048
1049    fn cleanup(dir: &std::path::Path) {
1050        let _ = std::fs::remove_dir_all(dir);
1051    }
1052
1053    #[test]
1054    fn test_compute_next_run_valid() {
1055        let result = compute_next_run("0 */5 * * * *", "UTC");
1056        assert!(result.is_ok());
1057        let ts = result.unwrap();
1058        // Should be a valid RFC3339 timestamp
1059        assert!(chrono::DateTime::parse_from_rfc3339(&ts).is_ok());
1060    }
1061
1062    #[test]
1063    fn test_compute_next_run_with_timezone() {
1064        let result = compute_next_run("0 */5 * * * *", "US/Eastern");
1065        assert!(result.is_ok());
1066    }
1067
1068    #[test]
1069    fn test_compute_next_run_invalid_cron() {
1070        let result = compute_next_run("not a cron", "UTC");
1071        assert!(result.is_err());
1072        assert!(result.unwrap_err().contains("Invalid cron"));
1073    }
1074
1075    #[test]
1076    fn test_compute_next_run_invalid_timezone() {
1077        let result = compute_next_run("0 */5 * * * *", "Not/A/Zone");
1078        assert!(result.is_err());
1079        assert!(result.unwrap_err().contains("Invalid timezone"));
1080    }
1081
1082    #[test]
1083    fn test_cron_human_hint_every_minutes() {
1084        let hint = cron_human_hint("0 */2 * * * *");
1085        assert_eq!(hint.as_deref(), Some("every 2 minutes"));
1086    }
1087
1088    #[test]
1089    fn test_cron_human_hint_every_hours() {
1090        let hint = cron_human_hint("0 0 */2 * * *");
1091        assert_eq!(hint.as_deref(), Some("every 2 hours"));
1092    }
1093
1094    #[tokio::test]
1095    async fn test_schedule_task_cron() {
1096        let (db, dir) = test_db();
1097        let tool = ScheduleTaskTool::new(test_registry(), db, "UTC".into());
1098        let result = tool
1099            .execute(json!({
1100                "chat_id": 100,
1101                "prompt": "say hi",
1102                "schedule_type": "cron",
1103                "schedule_value": "0 0 * * * *"
1104            }))
1105            .await;
1106        assert!(!result.is_error, "Error: {}", result.content);
1107        assert!(result.content.contains("scheduled"));
1108        assert!(result.content.contains("Next run"));
1109        assert!(result.content.contains("Cron interpretation"));
1110        cleanup(&dir);
1111    }
1112
1113    #[tokio::test]
1114    async fn test_schedule_task_once() {
1115        let (db, dir) = test_db();
1116        let tool = ScheduleTaskTool::new(test_registry(), db, "UTC".into());
1117        let result = tool
1118            .execute(json!({
1119                "chat_id": 100,
1120                "prompt": "one time thing",
1121                "schedule_type": "once",
1122                "schedule_value": "2099-12-31T23:59:59+00:00"
1123            }))
1124            .await;
1125        assert!(!result.is_error, "Error: {}", result.content);
1126        assert!(result.content.contains("scheduled"));
1127        cleanup(&dir);
1128    }
1129
1130    #[tokio::test]
1131    async fn test_schedule_task_once_accepts_naive_datetime_with_timezone() {
1132        let (db, dir) = test_db();
1133        let tool = ScheduleTaskTool::new(test_registry(), db, "UTC".into());
1134        let result = tool
1135            .execute(json!({
1136                "chat_id": 100,
1137                "prompt": "one time thing",
1138                "schedule_type": "once",
1139                "schedule_value": "2099-12-31 23:59:59",
1140                "timezone": "Asia/Shanghai"
1141            }))
1142            .await;
1143        assert!(!result.is_error, "Error: {}", result.content);
1144        assert!(result.content.contains("scheduled"));
1145        cleanup(&dir);
1146    }
1147
1148    #[tokio::test]
1149    async fn test_schedule_task_invalid_once_timestamp() {
1150        let (db, dir) = test_db();
1151        let tool = ScheduleTaskTool::new(test_registry(), db, "UTC".into());
1152        let result = tool
1153            .execute(json!({
1154                "chat_id": 100,
1155                "prompt": "test",
1156                "schedule_type": "once",
1157                "schedule_value": "not-a-timestamp"
1158            }))
1159            .await;
1160        assert!(result.is_error);
1161        assert!(result.content.contains("Invalid ISO 8601"));
1162        cleanup(&dir);
1163    }
1164
1165    #[tokio::test]
1166    async fn test_schedule_task_rejects_past_once_timestamp() {
1167        let (db, dir) = test_db();
1168        let tool = ScheduleTaskTool::new(test_registry(), db, "UTC".into());
1169        let past = (Utc::now() - chrono::Duration::minutes(2)).to_rfc3339();
1170        let result = tool
1171            .execute(json!({
1172                "chat_id": 100,
1173                "prompt": "test",
1174                "schedule_type": "once",
1175                "schedule_value": past
1176            }))
1177            .await;
1178        assert!(result.is_error);
1179        assert!(result.content.contains("must be in the future"));
1180        cleanup(&dir);
1181    }
1182
1183    #[tokio::test]
1184    async fn test_schedule_task_once_small_past_is_shifted_to_next_minute() {
1185        let (db, dir) = test_db();
1186        let tool = ScheduleTaskTool::new(test_registry(), db.clone(), "UTC".into());
1187        let slightly_past = (Utc::now() - chrono::Duration::seconds(30)).to_rfc3339();
1188        let result = tool
1189            .execute(json!({
1190                "chat_id": 100,
1191                "prompt": "test",
1192                "schedule_type": "once",
1193                "schedule_value": slightly_past
1194            }))
1195            .await;
1196        assert!(!result.is_error, "Error: {}", result.content);
1197
1198        let tasks = call_blocking(db.clone(), move |conn| conn.get_tasks_for_chat(100))
1199            .await
1200            .unwrap();
1201        assert_eq!(tasks.len(), 1);
1202        let next = chrono::DateTime::parse_from_rfc3339(&tasks[0].next_run)
1203            .unwrap()
1204            .with_timezone(&Utc);
1205        assert!(next > Utc::now());
1206        cleanup(&dir);
1207    }
1208
1209    #[tokio::test]
1210    async fn test_schedule_task_invalid_type() {
1211        let (db, dir) = test_db();
1212        let tool = ScheduleTaskTool::new(test_registry(), db, "UTC".into());
1213        let result = tool
1214            .execute(json!({
1215                "chat_id": 100,
1216                "prompt": "test",
1217                "schedule_type": "weekly",
1218                "schedule_value": "Monday"
1219            }))
1220            .await;
1221        assert!(result.is_error);
1222        assert!(result
1223            .content
1224            .contains("must be 'cron', 'once', or 'random'"));
1225        cleanup(&dir);
1226    }
1227
1228    #[tokio::test]
1229    async fn test_schedule_task_missing_params() {
1230        let (db, dir) = test_db();
1231        let tool = ScheduleTaskTool::new(test_registry(), db, "UTC".into());
1232        let result = tool.execute(json!({})).await;
1233        assert!(result.is_error);
1234        assert!(result.content.contains("Missing"));
1235        cleanup(&dir);
1236    }
1237
1238    #[tokio::test]
1239    async fn test_list_tasks_empty() {
1240        let (db, dir) = test_db();
1241        let tool = ListTasksTool::new(test_registry(), db);
1242        let result = tool.execute(json!({"chat_id": 100})).await;
1243        assert!(!result.is_error);
1244        assert!(result.content.contains("No scheduled tasks"));
1245        cleanup(&dir);
1246    }
1247
1248    #[tokio::test]
1249    async fn test_list_tasks_with_tasks() {
1250        let (db, dir) = test_db();
1251        db.create_scheduled_task(100, "task A", "cron", "0 * * * * *", "2024-01-01T00:00:00Z")
1252            .unwrap();
1253        db.create_scheduled_task(
1254            100,
1255            "task B",
1256            "once",
1257            "2024-06-01T00:00:00Z",
1258            "2024-06-01T00:00:00Z",
1259        )
1260        .unwrap();
1261
1262        let tool = ListTasksTool::new(test_registry(), db);
1263        let result = tool.execute(json!({"chat_id": 100})).await;
1264        assert!(!result.is_error);
1265        assert!(result.content.contains("task A"));
1266        assert!(result.content.contains("task B"));
1267        assert!(result.content.contains("[active]"));
1268        assert!(result.content.contains("cadence:"));
1269        cleanup(&dir);
1270    }
1271
1272    #[tokio::test]
1273    async fn test_pause_and_resume_task() {
1274        let (db, dir) = test_db();
1275        let id = db
1276            .create_scheduled_task(100, "test", "cron", "0 * * * * *", "2024-01-01T00:00:00Z")
1277            .unwrap();
1278
1279        let pause_tool = PauseTaskTool::new(test_registry(), db.clone());
1280        let result = pause_tool.execute(json!({"task_id": id})).await;
1281        assert!(!result.is_error);
1282        assert!(result.content.contains("paused"));
1283
1284        let resume_tool = ResumeTaskTool::new(test_registry(), db.clone());
1285        let result = resume_tool.execute(json!({"task_id": id})).await;
1286        assert!(!result.is_error);
1287        assert!(result.content.contains("resumed"));
1288        cleanup(&dir);
1289    }
1290
1291    #[tokio::test]
1292    async fn test_pause_nonexistent_task() {
1293        let (db, dir) = test_db();
1294        let tool = PauseTaskTool::new(test_registry(), db);
1295        let result = tool.execute(json!({"task_id": 9999})).await;
1296        assert!(result.is_error);
1297        assert!(result.content.contains("not found"));
1298        cleanup(&dir);
1299    }
1300
1301    #[tokio::test]
1302    async fn test_cancel_task() {
1303        let (db, dir) = test_db();
1304        let id = db
1305            .create_scheduled_task(100, "test", "cron", "0 * * * * *", "2024-01-01T00:00:00Z")
1306            .unwrap();
1307
1308        let tool = CancelTaskTool::new(test_registry(), db.clone());
1309        let result = tool.execute(json!({"task_id": id})).await;
1310        assert!(!result.is_error);
1311        assert!(result.content.contains("cancelled"));
1312
1313        // Task should no longer appear in list
1314        let tasks = db.get_tasks_for_chat(100).unwrap();
1315        assert!(tasks.is_empty());
1316        cleanup(&dir);
1317    }
1318
1319    #[tokio::test]
1320    async fn test_schedule_task_with_timezone() {
1321        let (db, dir) = test_db();
1322        let tool = ScheduleTaskTool::new(test_registry(), db, "UTC".into());
1323        let result = tool
1324            .execute(json!({
1325                "chat_id": 100,
1326                "prompt": "tz test",
1327                "schedule_type": "cron",
1328                "schedule_value": "0 0 * * * *",
1329                "timezone": "US/Eastern"
1330            }))
1331            .await;
1332        assert!(!result.is_error, "Error: {}", result.content);
1333        assert!(result.content.contains("scheduled"));
1334        assert!(result.content.contains("US/Eastern"));
1335        cleanup(&dir);
1336    }
1337
1338    #[tokio::test]
1339    async fn test_get_task_history_empty() {
1340        let (db, dir) = test_db();
1341        let task_id = db
1342            .create_scheduled_task(100, "test", "cron", "0 * * * * *", "2024-01-01T00:00:00Z")
1343            .unwrap();
1344        let tool = GetTaskHistoryTool::new(test_registry(), db);
1345        let result = tool.execute(json!({"task_id": task_id})).await;
1346        assert!(!result.is_error);
1347        assert!(result.content.contains("No run history"));
1348        cleanup(&dir);
1349    }
1350
1351    #[tokio::test]
1352    async fn test_get_task_history_with_logs() {
1353        let (db, dir) = test_db();
1354        let task_id = db
1355            .create_scheduled_task(100, "test", "cron", "0 * * * * *", "2024-01-01T00:00:00Z")
1356            .unwrap();
1357
1358        db.log_task_run(
1359            task_id,
1360            100,
1361            "2024-01-01T00:00:00Z",
1362            "2024-01-01T00:00:05Z",
1363            5000,
1364            true,
1365            Some("All good"),
1366        )
1367        .unwrap();
1368        db.log_task_run(
1369            task_id,
1370            100,
1371            "2024-01-01T00:01:00Z",
1372            "2024-01-01T00:01:02Z",
1373            2000,
1374            false,
1375            Some("Error: timeout"),
1376        )
1377        .unwrap();
1378
1379        let tool = GetTaskHistoryTool::new(test_registry(), db);
1380        let result = tool.execute(json!({"task_id": task_id})).await;
1381        assert!(!result.is_error);
1382        assert!(result.content.contains("OK"));
1383        assert!(result.content.contains("FAIL"));
1384        assert!(result.content.contains("All good"));
1385        assert!(result.content.contains("Error: timeout"));
1386        cleanup(&dir);
1387    }
1388
1389    #[tokio::test]
1390    async fn test_list_task_dlq_with_entries() {
1391        let (db, dir) = test_db();
1392        let task_id = db
1393            .create_scheduled_task(100, "test", "cron", "0 * * * * *", "2024-01-01T00:00:00Z")
1394            .unwrap();
1395        db.insert_scheduled_task_dlq(
1396            task_id,
1397            100,
1398            "2024-01-01T00:00:00Z",
1399            "2024-01-01T00:00:05Z",
1400            5000,
1401            Some("Error: timeout"),
1402        )
1403        .unwrap();
1404
1405        let tool = ListTaskDlqTool::new(test_registry(), db);
1406        let result = tool.execute(json!({"chat_id": 100})).await;
1407        assert!(!result.is_error, "{}", result.content);
1408        assert!(result.content.contains("PENDING"));
1409        assert!(result.content.contains("task="));
1410        cleanup(&dir);
1411    }
1412
1413    #[tokio::test]
1414    async fn test_replay_task_dlq_requeues_task_and_marks_replayed() {
1415        let (db, dir) = test_db();
1416        let task_id = db
1417            .create_scheduled_task(100, "test", "cron", "0 * * * * *", "2099-01-01T00:00:00Z")
1418            .unwrap();
1419        db.update_task_status(task_id, "paused").unwrap();
1420        db.insert_scheduled_task_dlq(
1421            task_id,
1422            100,
1423            "2024-01-01T00:00:00Z",
1424            "2024-01-01T00:00:05Z",
1425            5000,
1426            Some("Error: timeout"),
1427        )
1428        .unwrap();
1429
1430        let tool = ReplayTaskDlqTool::new(test_registry(), db.clone());
1431        let result = tool
1432            .execute(json!({"chat_id": 100, "task_id": task_id, "limit": 5}))
1433            .await;
1434        assert!(!result.is_error, "{}", result.content);
1435        assert!(result.content.contains("queued=1"));
1436
1437        let task = db.get_task_by_id(task_id).unwrap().unwrap();
1438        assert_eq!(task.status, "active");
1439        let pending = db
1440            .list_scheduled_task_dlq(Some(100), Some(task_id), false, 10)
1441            .unwrap();
1442        assert!(pending.is_empty());
1443        cleanup(&dir);
1444    }
1445
1446    #[tokio::test]
1447    async fn test_replay_task_dlq_permission_denied_cross_chat() {
1448        let (db, dir) = test_db();
1449        let tool = ReplayTaskDlqTool::new(test_registry(), db);
1450        let result = tool
1451            .execute(json!({
1452                "chat_id": 200,
1453                "limit": 1,
1454                "__microclaw_auth": {
1455                    "caller_chat_id": 100,
1456                    "control_chat_ids": []
1457                }
1458            }))
1459            .await;
1460        assert!(result.is_error);
1461        assert!(result.content.contains("Permission denied"));
1462        cleanup(&dir);
1463    }
1464
1465    #[tokio::test]
1466    async fn test_schedule_task_permission_denied_cross_chat() {
1467        let (db, dir) = test_db();
1468        let tool = ScheduleTaskTool::new(test_registry(), db, "UTC".into());
1469        let result = tool
1470            .execute(json!({
1471                "chat_id": 200,
1472                "prompt": "say hi",
1473                "schedule_type": "once",
1474                "schedule_value": "2099-12-31T23:59:59+00:00",
1475                "__microclaw_auth": {
1476                    "caller_chat_id": 100,
1477                    "control_chat_ids": []
1478                }
1479            }))
1480            .await;
1481        assert!(result.is_error);
1482        assert!(result.content.contains("Permission denied"));
1483        cleanup(&dir);
1484    }
1485
1486    #[tokio::test]
1487    async fn test_pause_task_permission_denied_cross_chat() {
1488        let (db, dir) = test_db();
1489        let task_id = db
1490            .create_scheduled_task(200, "test", "cron", "0 * * * * *", "2024-01-01T00:00:00Z")
1491            .unwrap();
1492        let tool = PauseTaskTool::new(test_registry(), db);
1493        let result = tool
1494            .execute(json!({
1495                "task_id": task_id,
1496                "__microclaw_auth": {
1497                    "caller_chat_id": 100,
1498                    "control_chat_ids": []
1499                }
1500            }))
1501            .await;
1502        assert!(result.is_error);
1503        assert!(result.content.contains("Permission denied"));
1504        cleanup(&dir);
1505    }
1506
1507    #[tokio::test]
1508    async fn test_web_caller_schedule_cross_chat_denied_even_for_control_chat() {
1509        let (db, dir) = test_db();
1510        db.upsert_chat(100, Some("web-main"), "web").unwrap();
1511        db.upsert_chat(200, Some("other"), "private").unwrap();
1512        let tool = ScheduleTaskTool::new(test_registry(), db, "UTC".into());
1513        let result = tool
1514            .execute(json!({
1515                "chat_id": 200,
1516                "prompt": "say hi",
1517                "schedule_type": "once",
1518                "schedule_value": "2099-12-31T23:59:59+00:00",
1519                "__microclaw_auth": {
1520                    "caller_chat_id": 100,
1521                    "control_chat_ids": [100]
1522                }
1523            }))
1524            .await;
1525        assert!(result.is_error);
1526        assert!(result
1527            .content
1528            .contains("web chats cannot operate on other chats"));
1529        cleanup(&dir);
1530    }
1531
1532    #[tokio::test]
1533    async fn test_schedule_task_allowed_for_control_chat_cross_chat() {
1534        let (db, dir) = test_db();
1535        let tool = ScheduleTaskTool::new(test_registry(), db.clone(), "UTC".into());
1536        let result = tool
1537            .execute(json!({
1538                "chat_id": 200,
1539                "prompt": "say hi",
1540                "schedule_type": "once",
1541                "schedule_value": "2099-12-31T23:59:59+00:00",
1542                "__microclaw_auth": {
1543                    "caller_chat_id": 100,
1544                    "control_chat_ids": [100]
1545                }
1546            }))
1547            .await;
1548        assert!(!result.is_error, "{}", result.content);
1549        let tasks = db.get_tasks_for_chat(200).unwrap();
1550        assert_eq!(tasks.len(), 1);
1551        cleanup(&dir);
1552    }
1553
1554    #[tokio::test]
1555    async fn test_pause_task_allowed_for_control_chat_cross_chat() {
1556        let (db, dir) = test_db();
1557        let task_id = db
1558            .create_scheduled_task(200, "test", "cron", "0 * * * * *", "2024-01-01T00:00:00Z")
1559            .unwrap();
1560        let tool = PauseTaskTool::new(test_registry(), db.clone());
1561        let result = tool
1562            .execute(json!({
1563                "task_id": task_id,
1564                "__microclaw_auth": {
1565                    "caller_chat_id": 100,
1566                    "control_chat_ids": [100]
1567                }
1568            }))
1569            .await;
1570        assert!(!result.is_error, "{}", result.content);
1571        let task = db.get_task_by_id(task_id).unwrap().unwrap();
1572        assert_eq!(task.status, "paused");
1573        cleanup(&dir);
1574    }
1575}