(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() {