(WIP) feat: Task Scheduler Management UI/UX Part 2

Rafael Uzarowski committed Apr 6, 2025 at 20:57 UTC 282cbf90dc6371085a2b44384547d2decf3ff326
5 files changed +161 -55
python/api/scheduler_task_run.py
+15 -3
@@ -1,14 +1,16 @@
1 from python.helpers.api import ApiHandler, Input, Output, Request
2 from python.helpers.task_scheduler import TaskScheduler, TaskState
3 +from python.helpers.print_style import PrintStyle
4
5
6 class SchedulerTaskRun(ApiHandler):
7 +
8 + _printer: PrintStyle = PrintStyle(italic=True, font_color="green", padding=False)
9 +
10 async def process(self, input: Input, request: Request) -> Output:
11 """
12 Manually run a task from the scheduler by ID
13 """
10 - scheduler = TaskScheduler.get()
11 - await scheduler.reload()
14
15 # Get task ID from input
16 task_id: str = input.get("task_id", "")
@@ -16,15 +18,22 @@ class SchedulerTaskRun(ApiHandler):
18 if not task_id:
19 return {"error": "Missing required field: task_id"}
20
21 + self._printer.print(f"SchedulerTaskRun: On-Demand running task {task_id}")
22 +
23 + scheduler = TaskScheduler.get()
24 + await scheduler.reload()
25 +
26 # Check if the task exists first
27 task = scheduler.get_task_by_uuid(task_id)
28 if not task:
29 + self._printer.error(f"SchedulerTaskRun: Task with ID '{task_id}' not found")
30 return {"error": f"Task with ID '{task_id}' not found"}
31
32 # Check if task is already running
25 - if task.state != TaskState.IDLE:
33 + if task.state == TaskState.RUNNING:
34 # Return task details along with error for better frontend handling
35 serialized_task = scheduler.serialize_task(task_id)
36 + self._printer.error(f"SchedulerTaskRun: Task '{task_id}' is in state '{task.state}' and cannot be run")
37 return {
38 "error": f"Task '{task_id}' is in state '{task.state}' and cannot be run",
39 "task": serialized_task
@@ -33,6 +42,7 @@ class SchedulerTaskRun(ApiHandler):
42 # Run the task, which now includes atomic state checks and updates
43 try:
44 await scheduler.run_task_by_uuid(task_id)
45 + self._printer.print(f"SchedulerTaskRun: Task '{task_id}' started successfully")
46 # Get updated task after run starts
47 serialized_task = scheduler.serialize_task(task_id)
48 if serialized_task:
@@ -44,6 +54,8 @@ class SchedulerTaskRun(ApiHandler):
54 else:
55 return {"success": True, "message": f"Task '{task_id}' started successfully"}
56 except ValueError as e:
57 + self._printer.error(f"SchedulerTaskRun: Task '{task_id}' failed to start: {str(e)}")
58 return {"error": str(e)}
59 except Exception as e:
60 + self._printer.error(f"SchedulerTaskRun: Task '{task_id}' failed to start: {str(e)}")
61 return {"error": f"Failed to run task '{task_id}': {str(e)}"}
python/helpers/task_scheduler.py
+33 -17
@@ -1,12 +1,15 @@
1 import asyncio
2 +from datetime import datetime, timezone, timedelta
3 +import json
4 import os
5 import random
6 import threading
7 +from urllib.parse import urlparse
8 import uuid
6 -from datetime import datetime, timezone, timedelta
9 +from dataclasses import dataclass
10 from enum import Enum
11 from os.path import exists
9 -from typing import ClassVar, Literal, Optional, Union, Dict, Any, Type, TypeVar, cast
12 +from typing import Any, Callable, Coroutine, Dict, Literal, Optional, Type, TypeVar, Union, cast, ClassVar
13
14 import nest_asyncio
15 nest_asyncio.apply()
@@ -14,12 +17,14 @@ nest_asyncio.apply()
17 from crontab import CronTab
18 from pydantic import BaseModel, Field, PrivateAttr
19
17 -from agent import Agent, AgentContext, UserMessage
20 +from agent import Agent, AgentConfig, AgentContext, UserMessage
21 from initialize import initialize
22 from python.helpers.persist_chat import load_tmp_chats, save_tmp_chat
23 from python.helpers.print_style import PrintStyle
24 from python.helpers.defer import DeferredTask
22 -from python.helpers.files import make_dirs, write_file, get_abs_path, read_file
25 +from python.helpers.files import get_abs_path, list_files, make_dirs, read_file, write_file
26 +from python.helpers.persist_chat import load_tmp_chats, save_tmp_chat
27 +from python.helpers.print_style import PrintStyle
28
29 SCHEDULER_FOLDER = "memory/scheduler"
30
@@ -115,7 +120,7 @@ class AdHocTask(BaseModel):
120 self.token = token
121 self.updated_at = datetime.now(timezone.utc)
122
118 - def check_schedule(self) -> bool:
123 + def check_schedule(self, frequency_seconds: float = 60.0) -> bool:
124 with self._lock:
125 return False
126
@@ -287,9 +292,10 @@ class SchedulerTaskList(BaseModel):
292 with self._lock:
293 return self.tasks
294
290 - def get_due_tasks(self) -> list[Union[ScheduledTask, AdHocTask]]:
295 + async def get_due_tasks(self) -> list[Union[ScheduledTask, AdHocTask]]:
296 with self._lock:
292 - return [task for task in self.tasks if task.check_schedule()]
297 + await self.reload()
298 + return [task for task in self.tasks if task.check_schedule() and task.state == TaskState.IDLE]
299
300 def get_task_by_uuid(self, task_uuid: str) -> Union[ScheduledTask, AdHocTask] | None:
301 with self._lock:
@@ -356,7 +362,7 @@ class TaskScheduler:
362 return self._tasks.get_task_by_name(name)
363
364 async def tick(self):
359 - for task in self._tasks.get_due_tasks():
365 + for task in await self._tasks.get_due_tasks():
366 await self._run_task(task)
367
368 async def run_task_by_uuid(self, task_uuid: str):
@@ -432,15 +438,9 @@ class TaskScheduler:
438 if task_snapshot is None:
439 self._printer.print(f"Scheduler Task with UUID '{task_uuid}' not found")
440 return
435 - if not isinstance(task_snapshot, ScheduledTask):
436 - self._printer.error(f"Scheduler Task '{task_snapshot.name}' is not an ScheduledTask, this should not happen, skipping")
437 - return
441 if task_snapshot.state == TaskState.RUNNING:
442 self._printer.print(f"Scheduler Task '{task_snapshot.name}' already running, skipping")
443 return
441 - if task_snapshot.state != TaskState.IDLE:
442 - self._printer.print(f"Scheduler Task '{task_snapshot.name}' state is '{task_snapshot.state}', skipping")
443 - return
444
445 # Atomically fetch and check the task's current state
446 current_task = await self.update_task(task_uuid, state=TaskState.RUNNING)
@@ -456,6 +456,11 @@ class TaskScheduler:
456 self._printer.print(f"Scheduler Task '{current_task.name}' started")
457
458 context = await self._get_chat_context(current_task)
459 +
460 + # Ensure the context is properly registered in the AgentContext._contexts
461 + # This is critical for the polling mechanism to find and stream logs
462 + AgentContext._contexts[context.id] = context
463 +
464 agent = Agent(0, context.config, context)
465
466 # Prepare attachment filenames for logging
@@ -463,7 +468,16 @@ class TaskScheduler:
468 if current_task.attachments:
469 for attachment in current_task.attachments:
470 if os.path.exists(attachment):
466 - attachment_filenames.append(os.path.basename(attachment))
471 + attachment_filenames.append(attachment)
472 + else:
473 + try:
474 + url = urlparse(attachment)
475 + if url.scheme in ["http", "https", "ftp", "ftps", "sftp"]:
476 + attachment_filenames.append(attachment)
477 + else:
478 + self._printer.print(f"Skipping attachment: [{attachment}]")
479 + except Exception:
480 + self._printer.print(f"Skipping attachment: [{attachment}]")
481
482 self._printer.print("User message:")
483 self._printer.print(f"> {current_task.prompt}")
@@ -485,8 +499,10 @@ class TaskScheduler:
499 UserMessage(
500 message=current_task.prompt,
501 system_message=[current_task.system_prompt],
488 - attachments=[]))
502 + attachments=attachment_filenames))
503
504 + # Persist after setting up the context but before running the agent
505 + # This ensures the task context is saved and can be found by polling
506 await self._persist_chat(current_task, context)
507
508 result = await agent.monologue()
@@ -578,7 +594,7 @@ def parse_task_schedule(schedule_data: Dict[str, str]) -> TaskSchedule:
594 weekday=schedule_data.get('weekday', '*')
595 )
596 except Exception as e:
581 - raise ValueError(f"Invalid schedule format: {e}")
597 + raise ValueError(f"Invalid schedule format: {e}") from e
598
599
600 T = TypeVar('T', bound=Union[ScheduledTask, AdHocTask])
webui/index.html
+1 -2
@@ -625,8 +625,7 @@
625 </nav>
626
627 <div id="section-task-scheduler" class="section"
628 - x-data="schedulerSettings"
629 - x-init="$watch('activeTab', (val) => { if(val === 'scheduler') { fetchTasks(); } })">
628 + x-data="schedulerSettings">
629 <div class="section-title">Task Scheduler</div>
630 <div class="section-description">Manage scheduled tasks and automated processes for Agent Zero.</div>
631
webui/js/scheduler.js
+51 -19
@@ -5,8 +5,6 @@
5
6 // Add a document ready event handler to ensure the scheduler tab can be clicked on first load
7 document.addEventListener('DOMContentLoaded', function() {
8 - console.log('DOMContentLoaded: Setting up scheduler tab click handler');
9 -
8 // Setup scheduler tab click handling
9 const setupSchedulerTab = () => {
10 const settingsModal = document.getElementById('settingsModal');
@@ -15,15 +13,12 @@ document.addEventListener('DOMContentLoaded', function() {
13 return;
14 }
15
18 - console.log('Setting up click interceptor for scheduler tab');
19 -
16 // Create a global event listener for clicks on the scheduler tab
17 document.addEventListener('click', function(e) {
18 // Find if the click was on the scheduler tab or its children
19 const schedulerTab = e.target.closest('.settings-tab[title="Task Scheduler"]');
20 if (!schedulerTab) return;
21
26 - console.log('Intercepted click on scheduler tab');
22 e.preventDefault();
23 e.stopPropagation();
24
@@ -31,7 +26,6 @@ document.addEventListener('DOMContentLoaded', function() {
26 try {
27 const modalData = Alpine.$data(settingsModal);
28 if (modalData.activeTab !== 'scheduler') {
34 - console.log(`Directly switching to scheduler tab via click interceptor.`);
29 // Directly call the modal's switchTab method
30 modalData.switchTab('scheduler');
31 }
@@ -57,6 +51,7 @@ document.addEventListener('alpine:init', () => {
51 filterType: 'all', // all, scheduled, adhoc
52 filterState: 'all', // all, idle, running, disabled, error
53 pollingInterval: null,
54 + pollingActive: false, // Track if polling is currently active
55 editingTask: null,
56 isCreating: false,
57 isEditing: false,
@@ -108,32 +103,56 @@ document.addEventListener('alpine:init', () => {
103 // Use a small delay to ensure Alpine.js has fully initialized
104 // before fetching tasks, which helps prevent layout shift
105 setTimeout(() => {
106 + // Initial fetch to populate the list
107 this.fetchTasks();
112 - }, 50);
108
114 - // Set up polling when component is active
115 - this.$watch('$store.root.activeTab', (newTab, oldTab) => {
116 - if (newTab === 'scheduler') {
109 + // Only start polling if the modal is actually open
110 + const store = Alpine.store('root');
111 + if (store && store.isOpen === true) {
112 this.startPolling();
118 - } else if (oldTab === 'scheduler') {
119 - this.stopPolling();
113 }
114 + }, 100);
115 +
116 + // Initialize safe watchers with defensive checks
117 + this.$nextTick(() => {
118 + // Watch the modal state from the root store
119 + this.$watch('$store.root.isOpen', (isOpen) => {
120 + // Only proceed if the value is not undefined
121 + if (typeof isOpen !== 'undefined') {
122 + if (isOpen === true) {
123 + // Modal just opened
124 + this.startPolling();
125 + } else if (isOpen === false) {
126 + // Modal closed, stop polling
127 + this.stopPolling();
128 + }
129 + }
130 + });
131 });
122 -
123 - // Initial polling if tab is active on load
124 - if (this.$store.root.activeTab === 'scheduler') {
125 - this.startPolling();
126 - }
132 },
133
134 // Start polling for task updates
135 startPolling() {
136 + // Don't start if already polling
137 + if (this.pollingInterval) {
138 + return;
139 + }
140 +
141 + this.pollingActive = true;
142 +
143 + // Fetch immediately, then set up interval for every 2 seconds
144 this.fetchTasks();
132 - this.pollingInterval = setInterval(() => this.fetchTasks(), 5000); // Poll every 5 seconds
145 + this.pollingInterval = setInterval(() => {
146 + if (this.pollingActive) {
147 + this.fetchTasks();
148 + }
149 + }, 2000); // Poll every 2 seconds as requested
150 },
151
152 // Stop polling when tab is inactive
153 stopPolling() {
154 + this.pollingActive = false;
155 +
156 if (this.pollingInterval) {
157 clearInterval(this.pollingInterval);
158 this.pollingInterval = null;
@@ -142,6 +161,16 @@ document.addEventListener('alpine:init', () => {
161
162 // Fetch tasks from API
163 async fetchTasks() {
164 + // Don't fetch if polling is inactive (prevents race conditions)
165 + if (!this.pollingActive && this.pollingInterval) {
166 + return;
167 + }
168 +
169 + // Don't fetch while creating/editing a task
170 + if (this.isCreating || this.isEditing) {
171 + return;
172 + }
173 +
174 this.isLoading = true;
175 try {
176 const response = await fetch('/scheduler_tasks_list', {
@@ -160,7 +189,10 @@ document.addEventListener('alpine:init', () => {
189 this.tasks = data.tasks || [];
190 } catch (error) {
191 console.error('Error fetching tasks:', error);
163 - showToast('Failed to fetch tasks: ' + error.message, 'error');
192 + // Only show toast for errors on manual refresh, not during polling
193 + if (!this.pollingInterval) {
194 + showToast('Failed to fetch tasks: ' + error.message, 'error');
195 + }
196 } finally {
197 this.isLoading = false;
198 }
webui/js/settings.js
+61 -14
@@ -19,24 +19,23 @@ const settingsModalProxy = {
19
20 // Switch tab method
21 switchTab(tabName) {
22 - console.log(`Switching tab from ${this.activeTab} to ${tabName}`);
22 + // Update our component state
23 this.activeTab = tabName;
24 +
25 + // Update the store safely
26 + const store = Alpine.store('root');
27 + if (store) {
28 + store.activeTab = tabName;
29 + }
30 +
31 localStorage.setItem('settingsActiveTab', tabName);
32
33 // Auto-scroll active tab into view after a short delay to ensure DOM updates
34 setTimeout(() => {
35 const activeTab = document.querySelector('.settings-tab.active');
36 if (activeTab) {
30 - console.log(`Scrolling active tab into view: ${activeTab.textContent}`);
37 activeTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
32 - } else {
33 - console.warn('No active tab found to scroll into view');
38 }
35 -
36 - // Debug the scheduler tab specifically
37 - const schedulerTab = document.querySelector('.settings-tab[title="Task Scheduler"]');
38 - console.log('Scheduler tab:', schedulerTab);
39 - console.log('Scheduler tab active?', schedulerTab && schedulerTab.classList.contains('active'));
39 }, 10);
40 },
41
@@ -45,6 +44,13 @@ const settingsModalProxy = {
44 const modalEl = document.getElementById('settingsModal');
45 const modalAD = Alpine.$data(modalEl);
46
47 + // First, ensure the store is updated properly
48 + const store = Alpine.store('root');
49 + if (store) {
50 + // Set isOpen first to ensure proper state
51 + store.isOpen = true;
52 + }
53 +
54 //get settings from backend
55 try {
56 const set = await sendJsonData("/settings_get", null);
@@ -81,6 +87,12 @@ const settingsModalProxy = {
87
88 // Directly set the active tab
89 modalAD.activeTab = savedTab;
90 +
91 + // Also update the store
92 + if (store) {
93 + store.activeTab = savedTab;
94 + }
95 +
96 localStorage.setItem('settingsActiveTab', savedTab);
97
98 // Add a small delay *after* setting the tab to ensure scrolling works
@@ -157,7 +169,18 @@ const settingsModalProxy = {
169 } else if (buttonId === 'cancel') {
170 this.handleCancel();
171 }
172 +
173 + // First update our component state
174 this.isOpen = false;
175 +
176 + // Then safely update the store
177 + const store = Alpine.store('root');
178 + if (store) {
179 + // Use a slight delay to avoid reactivity issues
180 + setTimeout(() => {
181 + store.isOpen = false;
182 + }, 10);
183 + }
184 },
185
186 async handleCancel() {
@@ -165,7 +188,18 @@ const settingsModalProxy = {
188 status: 'cancelled',
189 data: null
190 });
191 +
192 + // First update our component state
193 this.isOpen = false;
194 +
195 + // Then safely update the store
196 + const store = Alpine.store('root');
197 + if (store) {
198 + // Use a slight delay to avoid reactivity issues
199 + setTimeout(() => {
200 + store.isOpen = false;
201 + }, 10);
202 + }
203 },
204
205 handleFieldButton(field) {
@@ -191,6 +225,7 @@ const settingsModalProxy = {
225 // });
226
227 document.addEventListener('alpine:init', function () {
228 + // Initialize the root store first to ensure it exists before components try to access it
229 Alpine.store('root', {
230 activeTab: localStorage.getItem('settingsActiveTab') || 'agent',
231 isOpen: false,
@@ -200,6 +235,7 @@ document.addEventListener('alpine:init', function () {
235 }
236 });
237
238 + // Then initialize other Alpine components
239 Alpine.data('settingsModal', function () {
240 return {
241 settingsData: {},
@@ -208,21 +244,32 @@ document.addEventListener('alpine:init', function () {
244 isLoading: true,
245
246 async init() {
247 + // Initialize with the store value
248 + this.activeTab = Alpine.store('root').activeTab || 'agent';
249 +
250 // Watch store tab changes
251 this.$watch('$store.root.activeTab', (newTab) => {
213 - this.activeTab = newTab;
214 - localStorage.setItem('settingsActiveTab', newTab);
215 - this.updateFilteredSections();
252 + if (typeof newTab !== 'undefined') {
253 + this.activeTab = newTab;
254 + localStorage.setItem('settingsActiveTab', newTab);
255 + this.updateFilteredSections();
256 + }
257 });
258
259 // Load settings
260 await this.fetchSettings();
220 - this.activeTab = this.$store.root.activeTab;
261 this.updateFilteredSections();
262 },
263
264 switchTab(tab) {
225 - this.$store.root.activeTab = tab;
265 + // Update our component state
266 + this.activeTab = tab;
267 +
268 + // Update the store safely
269 + const store = Alpine.store('root');
270 + if (store) {
271 + store.activeTab = tab;
272 + }
273 },
274
275 async fetchSettings() {