dtSpark 1.1.0a3__py3-none-any.whl → 1.1.0a6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. dtSpark/_version.txt +1 -1
  2. dtSpark/aws/authentication.py +1 -1
  3. dtSpark/aws/bedrock.py +238 -239
  4. dtSpark/aws/costs.py +9 -5
  5. dtSpark/aws/pricing.py +25 -21
  6. dtSpark/cli_interface.py +69 -62
  7. dtSpark/conversation_manager.py +54 -47
  8. dtSpark/core/application.py +112 -91
  9. dtSpark/core/context_compaction.py +241 -226
  10. dtSpark/daemon/__init__.py +36 -22
  11. dtSpark/daemon/action_monitor.py +46 -17
  12. dtSpark/daemon/daemon_app.py +126 -104
  13. dtSpark/daemon/daemon_manager.py +59 -23
  14. dtSpark/daemon/pid_file.py +3 -2
  15. dtSpark/database/autonomous_actions.py +3 -0
  16. dtSpark/database/credential_prompt.py +52 -54
  17. dtSpark/files/manager.py +6 -12
  18. dtSpark/limits/__init__.py +1 -1
  19. dtSpark/limits/tokens.py +2 -2
  20. dtSpark/llm/anthropic_direct.py +246 -141
  21. dtSpark/llm/ollama.py +3 -1
  22. dtSpark/mcp_integration/manager.py +4 -4
  23. dtSpark/mcp_integration/tool_selector.py +83 -77
  24. dtSpark/resources/config.yaml.template +10 -0
  25. dtSpark/safety/patterns.py +45 -46
  26. dtSpark/safety/prompt_inspector.py +8 -1
  27. dtSpark/scheduler/creation_tools.py +273 -181
  28. dtSpark/scheduler/executor.py +503 -221
  29. dtSpark/tools/builtin.py +70 -53
  30. dtSpark/web/endpoints/autonomous_actions.py +12 -9
  31. dtSpark/web/endpoints/chat.py +8 -6
  32. dtSpark/web/endpoints/conversations.py +11 -9
  33. dtSpark/web/endpoints/main_menu.py +132 -105
  34. dtSpark/web/endpoints/streaming.py +2 -2
  35. dtSpark/web/server.py +65 -5
  36. dtSpark/web/ssl_utils.py +3 -3
  37. dtSpark/web/static/css/dark-theme.css +8 -29
  38. dtSpark/web/static/js/chat.js +6 -8
  39. dtSpark/web/static/js/main.js +8 -8
  40. dtSpark/web/static/js/sse-client.js +130 -122
  41. dtSpark/web/templates/actions.html +5 -5
  42. dtSpark/web/templates/base.html +13 -0
  43. dtSpark/web/templates/chat.html +10 -10
  44. dtSpark/web/templates/conversations.html +2 -2
  45. dtSpark/web/templates/goodbye.html +2 -2
  46. dtSpark/web/templates/main_menu.html +17 -17
  47. dtSpark/web/web_interface.py +2 -2
  48. {dtspark-1.1.0a3.dist-info → dtspark-1.1.0a6.dist-info}/METADATA +9 -2
  49. dtspark-1.1.0a6.dist-info/RECORD +96 -0
  50. dtspark-1.1.0a3.dist-info/RECORD +0 -96
  51. {dtspark-1.1.0a3.dist-info → dtspark-1.1.0a6.dist-info}/WHEEL +0 -0
  52. {dtspark-1.1.0a3.dist-info → dtspark-1.1.0a6.dist-info}/entry_points.txt +0 -0
  53. {dtspark-1.1.0a3.dist-info → dtspark-1.1.0a6.dist-info}/licenses/LICENSE +0 -0
  54. {dtspark-1.1.0a3.dist-info → dtspark-1.1.0a6.dist-info}/top_level.txt +0 -0
@@ -2,7 +2,7 @@
2
2
  Tools for prompt-driven autonomous action creation.
3
3
 
4
4
  Exposes tools that an LLM can use to create scheduled actions through
5
- natural conversation.
5
+ natural conversation.
6
6
  """
7
7
 
8
8
  from typing import List, Dict, Any, Optional
@@ -256,7 +256,27 @@ def _list_available_tools(mcp_manager, config: Optional[Dict[str, Any]] = None)
256
256
  tools = []
257
257
  errors = []
258
258
 
259
- # Get built-in tools (including filesystem tools if enabled in config)
259
+ _collect_builtin_tools(tools, errors, config)
260
+ _collect_mcp_tools(tools, errors, mcp_manager)
261
+
262
+ result = {
263
+ 'tools': tools,
264
+ 'count': len(tools),
265
+ 'message': f"Found {len(tools)} available tools"
266
+ }
267
+
268
+ if errors:
269
+ result['warnings'] = errors
270
+
271
+ return result
272
+
273
+
274
+ def _collect_builtin_tools(
275
+ tools: List[Dict[str, Any]],
276
+ errors: List[str],
277
+ config: Optional[Dict[str, Any]]
278
+ ) -> None:
279
+ """Collect built-in tools (including filesystem tools if enabled in config)."""
260
280
  try:
261
281
  from dtSpark.tools import builtin
262
282
  builtin_config = config or {}
@@ -271,60 +291,65 @@ def _list_available_tools(mcp_manager, config: Optional[Dict[str, Any]] = None)
271
291
  logger.warning(f"Failed to get built-in tools: {e}")
272
292
  errors.append(f"Builtin tools error: {e}")
273
293
 
274
- # Get MCP tools
275
- if mcp_manager:
276
- logger.debug(f"MCP manager present: {type(mcp_manager)}")
277
- try:
278
- # Handle async MCP manager
279
- if hasattr(mcp_manager, 'list_all_tools'):
280
- # Check if there's an initialization loop stored
281
- loop = getattr(mcp_manager, '_initialization_loop', None)
282
-
283
- if loop and not loop.is_closed():
284
- # Use the existing loop
285
- mcp_tools = loop.run_until_complete(mcp_manager.list_all_tools())
286
- else:
287
- # Create new loop
288
- mcp_tools = mcp_manager.list_all_tools()
289
- if asyncio.iscoroutine(mcp_tools):
290
- try:
291
- loop = asyncio.get_event_loop()
292
- if loop.is_closed():
293
- loop = asyncio.new_event_loop()
294
- asyncio.set_event_loop(loop)
295
- except RuntimeError:
296
- loop = asyncio.new_event_loop()
297
- asyncio.set_event_loop(loop)
298
- mcp_tools = loop.run_until_complete(mcp_tools)
299
-
300
- mcp_count = 0
301
- for tool in mcp_tools:
302
- tools.append({
303
- 'name': tool.get('name', 'unknown'),
304
- 'description': tool.get('description', 'No description available'),
305
- 'source': tool.get('server', 'mcp')
306
- })
307
- mcp_count += 1
308
- logger.debug(f"Loaded {mcp_count} MCP tools")
309
- else:
310
- logger.warning("MCP manager does not have list_all_tools method")
311
- errors.append("MCP manager missing list_all_tools method")
312
- except Exception as e:
313
- logger.warning(f"Failed to get MCP tools: {e}", exc_info=True)
314
- errors.append(f"MCP tools error: {e}")
315
- else:
294
+
295
+ def _collect_mcp_tools(
296
+ tools: List[Dict[str, Any]],
297
+ errors: List[str],
298
+ mcp_manager
299
+ ) -> None:
300
+ """Collect tools from the MCP manager, handling async resolution."""
301
+ if not mcp_manager:
316
302
  logger.debug("No MCP manager provided")
303
+ return
317
304
 
318
- result = {
319
- 'tools': tools,
320
- 'count': len(tools),
321
- 'message': f"Found {len(tools)} available tools"
322
- }
305
+ logger.debug(f"MCP manager present: {type(mcp_manager)}")
323
306
 
324
- if errors:
325
- result['warnings'] = errors
307
+ if not hasattr(mcp_manager, 'list_all_tools'):
308
+ logger.warning("MCP manager does not have list_all_tools method")
309
+ errors.append("MCP manager missing list_all_tools method")
310
+ return
326
311
 
327
- return result
312
+ try:
313
+ mcp_tools = _resolve_mcp_tools(mcp_manager)
314
+ mcp_count = 0
315
+ for tool in mcp_tools:
316
+ tools.append({
317
+ 'name': tool.get('name', 'unknown'),
318
+ 'description': tool.get('description', 'No description available'),
319
+ 'source': tool.get('server', 'mcp')
320
+ })
321
+ mcp_count += 1
322
+ logger.debug(f"Loaded {mcp_count} MCP tools")
323
+ except Exception as e:
324
+ logger.warning(f"Failed to get MCP tools: {e}", exc_info=True)
325
+ errors.append(f"MCP tools error: {e}")
326
+
327
+
328
+ def _resolve_mcp_tools(mcp_manager) -> list:
329
+ """Resolve MCP tools, handling both sync and async code paths."""
330
+ loop = getattr(mcp_manager, '_initialization_loop', None)
331
+
332
+ if loop and not loop.is_closed():
333
+ return loop.run_until_complete(mcp_manager.list_all_tools())
334
+
335
+ mcp_tools = mcp_manager.list_all_tools()
336
+ if asyncio.iscoroutine(mcp_tools):
337
+ loop = _get_or_create_event_loop()
338
+ mcp_tools = loop.run_until_complete(mcp_tools)
339
+ return mcp_tools
340
+
341
+
342
+ def _get_or_create_event_loop() -> asyncio.AbstractEventLoop:
343
+ """Return an open event loop, creating one if necessary."""
344
+ try:
345
+ loop = asyncio.get_event_loop()
346
+ if not loop.is_closed():
347
+ return loop
348
+ except RuntimeError:
349
+ pass
350
+ loop = asyncio.new_event_loop()
351
+ asyncio.set_event_loop(loop)
352
+ return loop
328
353
 
329
354
 
330
355
  def _validate_schedule(schedule_type: str, schedule_value: str) -> Dict[str, Any]:
@@ -345,89 +370,119 @@ def _validate_schedule(schedule_type: str, schedule_value: str) -> Dict[str, Any
345
370
  return {'valid': False, 'error': 'schedule_value is required'}
346
371
 
347
372
  if schedule_type == 'one_off':
348
- try:
349
- # Try multiple datetime formats
350
- dt = None
351
- formats = ['%Y-%m-%d %H:%M', '%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M', '%Y-%m-%dT%H:%M:%S']
352
- for fmt in formats:
353
- try:
354
- dt = datetime.strptime(schedule_value, fmt)
355
- break
356
- except ValueError:
357
- continue
358
-
359
- if dt is None:
360
- return {
361
- 'valid': False,
362
- 'error': f'Invalid datetime format. Use YYYY-MM-DD HH:MM (e.g., "2025-12-20 14:30")'
363
- }
373
+ return _validate_one_off_schedule(schedule_value)
364
374
 
365
- if dt <= datetime.now():
366
- return {
367
- 'valid': False,
368
- 'error': 'Date must be in the future'
369
- }
375
+ if schedule_type == 'recurring':
376
+ return _validate_recurring_schedule(schedule_value)
377
+
378
+ return {
379
+ 'valid': False,
380
+ 'error': f'Unknown schedule type: {schedule_type}. Use "one_off" or "recurring"'
381
+ }
370
382
 
383
+
384
+ def _validate_one_off_schedule(schedule_value: str) -> Dict[str, Any]:
385
+ """Validate a one-off (single execution) schedule value."""
386
+ try:
387
+ dt = _parse_datetime(schedule_value)
388
+
389
+ if dt is None:
371
390
  return {
372
- 'valid': True,
373
- 'schedule_type': 'one_off',
374
- 'parsed': dt.isoformat(),
375
- 'human_readable': dt.strftime('%A, %d %B %Y at %H:%M')
391
+ 'valid': False,
392
+ 'error': 'Invalid datetime format. Use YYYY-MM-DD HH:MM (e.g., "2025-12-20 14:30")'
376
393
  }
377
394
 
378
- except Exception as e:
379
- return {'valid': False, 'error': f'Invalid datetime: {e}'}
395
+ if dt <= datetime.now():
396
+ return {
397
+ 'valid': False,
398
+ 'error': 'Date must be in the future'
399
+ }
380
400
 
381
- elif schedule_type == 'recurring':
382
- try:
383
- from apscheduler.triggers.cron import CronTrigger
401
+ return {
402
+ 'valid': True,
403
+ 'schedule_type': 'one_off',
404
+ 'parsed': dt.isoformat(),
405
+ 'human_readable': dt.strftime('%A, %d %B %Y at %H:%M')
406
+ }
384
407
 
385
- # Parse cron expression
386
- parts = schedule_value.split()
387
- if len(parts) != 5:
388
- return {
389
- 'valid': False,
390
- 'error': (
391
- 'Cron expression must have 5 fields: minute hour day month day_of_week. '
392
- 'Example: "0 8 * * MON-FRI" for weekdays at 8am'
393
- )
394
- }
408
+ except Exception as e:
409
+ return {'valid': False, 'error': f'Invalid datetime: {e}'}
395
410
 
396
- minute, hour, day, month, dow = parts
397
411
 
398
- # Validate by creating trigger (will raise if invalid)
399
- trigger = CronTrigger(
400
- minute=minute,
401
- hour=hour,
402
- day=day,
403
- month=month,
404
- day_of_week=dow
405
- )
412
+ def _parse_datetime(value: str) -> Optional[datetime]:
413
+ """Attempt to parse a datetime string using common formats."""
414
+ formats = ['%Y-%m-%d %H:%M', '%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M', '%Y-%m-%dT%H:%M:%S']
415
+ for fmt in formats:
416
+ try:
417
+ return datetime.strptime(value, fmt)
418
+ except ValueError:
419
+ continue
420
+ return None
406
421
 
407
- # Get next run time to confirm it works
408
- next_run = trigger.get_next_fire_time(None, datetime.now())
409
422
 
410
- return {
411
- 'valid': True,
412
- 'schedule_type': 'recurring',
413
- 'cron_expression': schedule_value,
414
- 'human_readable': _cron_to_human(schedule_value),
415
- 'next_run': next_run.isoformat() if next_run else None
416
- }
423
+ def _validate_recurring_schedule(schedule_value: str) -> Dict[str, Any]:
424
+ """Validate a recurring (cron-based) schedule value."""
425
+ try:
426
+ from apscheduler.triggers.cron import CronTrigger
417
427
 
418
- except Exception as e:
428
+ parts = schedule_value.split()
429
+ if len(parts) != 5:
419
430
  return {
420
431
  'valid': False,
421
- 'error': f'Invalid cron expression: {e}'
432
+ 'error': (
433
+ 'Cron expression must have 5 fields: minute hour day month day_of_week. '
434
+ 'Example: "0 8 * * MON-FRI" for weekdays at 8am'
435
+ )
422
436
  }
423
437
 
424
- else:
438
+ minute, hour, day, month, dow = parts
439
+
440
+ trigger = CronTrigger(
441
+ minute=minute,
442
+ hour=hour,
443
+ day=day,
444
+ month=month,
445
+ day_of_week=dow
446
+ )
447
+
448
+ next_run = trigger.get_next_fire_time(None, datetime.now())
449
+
450
+ return {
451
+ 'valid': True,
452
+ 'schedule_type': 'recurring',
453
+ 'cron_expression': schedule_value,
454
+ 'human_readable': _cron_to_human(schedule_value),
455
+ 'next_run': next_run.isoformat() if next_run else None
456
+ }
457
+
458
+ except Exception as e:
425
459
  return {
426
460
  'valid': False,
427
- 'error': f'Unknown schedule type: {schedule_type}. Use "one_off" or "recurring"'
461
+ 'error': f'Invalid cron expression: {e}'
428
462
  }
429
463
 
430
464
 
465
+ # Mapping of day-of-week values to human-readable names.
466
+ _DOW_MAP = {
467
+ 'MON-FRI': 'Weekdays (Monday to Friday)',
468
+ '1-5': 'Weekdays (Monday to Friday)',
469
+ 'SAT,SUN': 'Weekends',
470
+ '0,6': 'Weekends',
471
+ '6,0': 'Weekends',
472
+ }
473
+
474
+ # Single-day mappings (value or uppercase alias -> label).
475
+ _SINGLE_DOW_MAP = {
476
+ '0': 'Every Sunday', 'SUN': 'Every Sunday',
477
+ '1': 'Every Monday', 'MON': 'Every Monday',
478
+ '2': 'Every Tuesday', 'TUE': 'Every Tuesday',
479
+ '3': 'Every Wednesday', 'WED': 'Every Wednesday',
480
+ '4': 'Every Thursday', 'THU': 'Every Thursday',
481
+ '5': 'Every Friday', 'FRI': 'Every Friday',
482
+ '6': 'Every Saturday', 'SAT': 'Every Saturday',
483
+ }
484
+
485
+
431
486
  def _cron_to_human(cron: str) -> str:
432
487
  """
433
488
  Convert cron expression to human-readable string.
@@ -444,47 +499,62 @@ def _cron_to_human(cron: str) -> str:
444
499
 
445
500
  minute, hour, day, month, dow = parts
446
501
 
447
- # Build time string
502
+ time_str = _format_cron_time(minute, hour)
503
+ freq = _describe_cron_frequency(minute, hour, day, month, dow)
504
+
505
+ if freq is None:
506
+ return f"Cron: {cron}"
507
+
508
+ # Interval-based frequencies already include timing info
509
+ if freq.startswith("Every ") and ("minutes" in freq or "hours" in freq):
510
+ return freq
511
+
512
+ return f"{freq} at {time_str}"
513
+
514
+
515
+ def _format_cron_time(minute: str, hour: str) -> str:
516
+ """Build a human-readable time string from cron minute and hour fields."""
448
517
  if minute == '0':
449
- time_str = f"{hour}:00"
450
- elif minute.isdigit():
451
- time_str = f"{hour}:{minute.zfill(2)}"
452
- else:
453
- time_str = f"{hour}:{minute}"
518
+ return f"{hour}:00"
519
+ if minute.isdigit():
520
+ return f"{hour}:{minute.zfill(2)}"
521
+ return f"{hour}:{minute}"
522
+
454
523
 
455
- # Build day/frequency description
524
+ def _describe_cron_frequency(
525
+ minute: str, hour: str, day: str, month: str, dow: str
526
+ ) -> Optional[str]:
527
+ """
528
+ Derive a human-readable frequency description from cron fields.
529
+
530
+ Returns None when no known pattern matches.
531
+ """
532
+ # Every day
456
533
  if dow == '*' and day == '*' and month == '*':
457
- freq = "Every day"
458
- elif dow == 'MON-FRI' or dow == '1-5':
459
- freq = "Weekdays (Monday to Friday)"
460
- elif dow == 'SAT,SUN' or dow == '0,6' or dow == '6,0':
461
- freq = "Weekends"
462
- elif dow == '0' or dow.upper() == 'SUN':
463
- freq = "Every Sunday"
464
- elif dow == '1' or dow.upper() == 'MON':
465
- freq = "Every Monday"
466
- elif dow == '2' or dow.upper() == 'TUE':
467
- freq = "Every Tuesday"
468
- elif dow == '3' or dow.upper() == 'WED':
469
- freq = "Every Wednesday"
470
- elif dow == '4' or dow.upper() == 'THU':
471
- freq = "Every Thursday"
472
- elif dow == '5' or dow.upper() == 'FRI':
473
- freq = "Every Friday"
474
- elif dow == '6' or dow.upper() == 'SAT':
475
- freq = "Every Saturday"
476
- elif day != '*' and month == '*':
477
- freq = f"Day {day} of each month"
478
- elif '/' in minute:
534
+ return "Every day"
535
+
536
+ # Multi-day patterns (weekdays, weekends)
537
+ if dow in _DOW_MAP:
538
+ return _DOW_MAP[dow]
539
+
540
+ # Single named/numbered day of week
541
+ dow_label = _SINGLE_DOW_MAP.get(dow) or _SINGLE_DOW_MAP.get(dow.upper())
542
+ if dow_label:
543
+ return dow_label
544
+
545
+ # Day-of-month pattern
546
+ if day != '*' and month == '*':
547
+ return f"Day {day} of each month"
548
+
549
+ # Interval-based patterns
550
+ if '/' in minute:
479
551
  interval = minute.split('/')[1]
480
552
  return f"Every {interval} minutes"
481
- elif '/' in hour:
553
+ if '/' in hour:
482
554
  interval = hour.split('/')[1]
483
555
  return f"Every {interval} hours"
484
- else:
485
- return f"Cron: {cron}"
486
556
 
487
- return f"{freq} at {time_str}"
557
+ return None
488
558
 
489
559
 
490
560
  def _create_action(
@@ -532,13 +602,8 @@ def _create_action(
532
602
  'error': f'An action named "{params["name"]}" already exists'
533
603
  }
534
604
 
535
- # Combine system_prompt with action_prompt for storage
536
- # The system_prompt becomes the instructions, action_prompt is the actual task
537
- full_prompt = params['action_prompt']
538
- if params.get('system_prompt'):
539
- full_prompt = f"[System Instructions]\n{params['system_prompt']}\n\n[Task]\n{params['action_prompt']}"
605
+ full_prompt = _build_full_prompt(params)
540
606
 
541
- # Create action using database wrapper method
542
607
  action_id = database.create_action(
543
608
  name=params['name'],
544
609
  description=params['description'],
@@ -551,36 +616,16 @@ def _create_action(
551
616
  max_tokens=params.get('max_tokens', 8192)
552
617
  )
553
618
 
554
- # Set tool permissions using database wrapper method
555
- tool_names = params.get('tool_names', [])
556
- if tool_names:
557
- tool_permissions = [
558
- {
559
- 'tool_name': t,
560
- 'server_name': None,
561
- 'permission_state': 'allowed'
562
- }
563
- for t in tool_names
564
- ]
565
- database.set_action_tool_permissions_batch(action_id, tool_permissions)
566
-
567
- # Schedule the action
568
- next_run = None
569
- if scheduler_manager:
570
- try:
571
- scheduler_manager.schedule_action(
572
- action_id=action_id,
573
- action_name=params['name'],
574
- schedule_type=params['schedule_type'],
575
- schedule_config=schedule_config,
576
- user_guid=user_guid
577
- )
578
- next_run = scheduler_manager.get_next_run_time(action_id)
579
- except Exception as e:
580
- logger.warning(f"Failed to schedule action: {e}")
619
+ _set_tool_permissions(database, action_id, params.get('tool_names', []))
620
+
621
+ next_run = _schedule_action(scheduler_manager, action_id, params, schedule_config, user_guid)
581
622
 
582
623
  # Build success message
583
- schedule_desc = _cron_to_human(params['schedule_value']) if params['schedule_type'] == 'recurring' else params['schedule_value']
624
+ schedule_desc = (
625
+ _cron_to_human(params['schedule_value'])
626
+ if params['schedule_type'] == 'recurring'
627
+ else params['schedule_value']
628
+ )
584
629
 
585
630
  return {
586
631
  'success': True,
@@ -597,3 +642,50 @@ def _create_action(
597
642
  'success': False,
598
643
  'error': str(e)
599
644
  }
645
+
646
+
647
+ def _build_full_prompt(params: Dict[str, Any]) -> str:
648
+ """Combine system_prompt and action_prompt into a single prompt for storage."""
649
+ full_prompt = params['action_prompt']
650
+ if params.get('system_prompt'):
651
+ full_prompt = f"[System Instructions]\n{params['system_prompt']}\n\n[Task]\n{params['action_prompt']}"
652
+ return full_prompt
653
+
654
+
655
+ def _set_tool_permissions(database, action_id: str, tool_names: List[str]) -> None:
656
+ """Set tool permissions for the newly created action."""
657
+ if not tool_names:
658
+ return
659
+ tool_permissions = [
660
+ {
661
+ 'tool_name': t,
662
+ 'server_name': None,
663
+ 'permission_state': 'allowed'
664
+ }
665
+ for t in tool_names
666
+ ]
667
+ database.set_action_tool_permissions_batch(action_id, tool_permissions)
668
+
669
+
670
+ def _schedule_action(
671
+ scheduler_manager,
672
+ action_id: str,
673
+ params: Dict[str, Any],
674
+ schedule_config: Dict[str, Any],
675
+ user_guid: str
676
+ ):
677
+ """Schedule the action and return the next run time, or None on failure."""
678
+ if not scheduler_manager:
679
+ return None
680
+ try:
681
+ scheduler_manager.schedule_action(
682
+ action_id=action_id,
683
+ action_name=params['name'],
684
+ schedule_type=params['schedule_type'],
685
+ schedule_config=schedule_config,
686
+ user_guid=user_guid
687
+ )
688
+ return scheduler_manager.get_next_run_time(action_id)
689
+ except Exception as e:
690
+ logger.warning(f"Failed to schedule action: {e}")
691
+ return None