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 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
129pub 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 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 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) = ¬_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
367pub 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
480pub 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
547pub 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
614pub 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
681pub struct GetTaskHistoryTool {
684 registry: Arc<ChannelRegistry>,
685 db: Arc<Database>,
686}
687
688pub 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
790pub 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(¬e))?;
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 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 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}