diff --git a/api/scheduler_task_create.py b/api/scheduler_task_create.py index 31522d377..0868a535c 100644 --- a/api/scheduler_task_create.py +++ b/api/scheduler_task_create.py @@ -1,7 +1,7 @@ from helpers.api import ApiHandler, Input, Output, Request from helpers.task_scheduler import ( TaskScheduler, ScheduledTask, AdHocTask, PlannedTask, TaskSchedule, - serialize_task, parse_task_schedule, parse_task_plan, TaskType + serialize_task, parse_task_schedule, parse_task_plan, TaskType, TaskState ) from helpers.projects import load_basic_project_data from helpers.localization import Localization @@ -27,6 +27,7 @@ class SchedulerTaskCreate(ApiHandler): name = input.get("name") system_prompt = input.get("system_prompt", "") prompt = input.get("prompt") + state = TaskState(input.get("state", TaskState.IDLE)) attachments = input.get("attachments", []) requested_project_slug = input.get("project_name") @@ -138,6 +139,7 @@ class SchedulerTaskCreate(ApiHandler): printer.print(f"AdHocTask created with token: '{task.token}'") # Add the task to the scheduler + task.state = state await scheduler.add_task(task) # Verify the task was added correctly - retrieve by UUID to check persistence diff --git a/api/scheduler_task_create.py.dox.md b/api/scheduler_task_create.py.dox.md index 332118085..286c2dc75 100644 --- a/api/scheduler_task_create.py.dox.md +++ b/api/scheduler_task_create.py.dox.md @@ -20,6 +20,7 @@ - Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change. - `SchedulerTaskCreate` is an `ApiHandler`. - `SchedulerTaskCreate` defines `process(...)`. +- An optional `state` is validated as `TaskState` and applied before persistence; it defaults to `idle`. Creating a disabled task must not briefly expose it as runnable. - Observed side-effect areas: filesystem writes, secret handling, scheduler state. - Imported dependency areas include: `helpers.api`, `helpers.localization`, `helpers.print_style`, `helpers.projects`, `helpers.task_scheduler`, `random`. @@ -37,7 +38,7 @@ ## Verification - Run endpoint-specific or API/WebSocket tests for changed behavior; smoke-test browser callers when no focused test exists. -- No direct test reference was found by name search; choose the nearest behavioral test or perform a focused smoke check. +- Run `tests/test_scheduler_editor.py` for initial-state persistence. ## Child DOX Index diff --git a/helpers/task_scheduler.py b/helpers/task_scheduler.py index fada618e9..356423ecf 100644 --- a/helpers/task_scheduler.py +++ b/helpers/task_scheduler.py @@ -22,7 +22,7 @@ from helpers.print_style import PrintStyle from helpers.defer import DeferredTask from helpers.files import get_abs_path, make_dirs, read_file, write_file from helpers.localization import Localization -from helpers import projects, guids +from helpers import projects, guids, yaml import pytz from typing import Annotated @@ -509,6 +509,12 @@ class PlannedTask(BaseTask): await super().on_error(error) +def _task_file_path() -> str: + json_path = get_abs_path(SCHEDULER_FOLDER, "tasks.json") + yaml_path = get_abs_path(SCHEDULER_FOLDER, "tasks.yaml") + return yaml_path if not exists(json_path) and exists(yaml_path) else json_path + + class SchedulerTaskList(BaseModel): tasks: list[Annotated[Union[ScheduledTask, AdHocTask, PlannedTask], Field(discriminator="type")]] = Field(default_factory=list) # Singleton instance @@ -518,26 +524,33 @@ class SchedulerTaskList(BaseModel): @classmethod def get(cls) -> "SchedulerTaskList": - path = get_abs_path(SCHEDULER_FOLDER, "tasks.json") + path = _task_file_path() if cls.__instance is None: if not exists(path): make_dirs(path) cls.__instance = asyncio.run(cls(tasks=[]).save()) else: - cls.__instance = cls.model_validate_json(read_file(path)) + cls.__instance = cls._load(path) else: asyncio.run(cls.__instance.reload()) return cls.__instance + @classmethod + def _load(cls, path: str) -> "SchedulerTaskList": + content = read_file(path) + if path.endswith(".yaml"): + return cls.model_validate(yaml.loads(content)) + return cls.model_validate_json(content) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._lock = threading.RLock() async def reload(self) -> "SchedulerTaskList": - path = get_abs_path(SCHEDULER_FOLDER, "tasks.json") + path = _task_file_path() if exists(path): with self._lock: - data = self.__class__.model_validate_json(read_file(path)) + data = self.__class__._load(path) self.tasks.clear() self.tasks.extend(data.tasks) return self @@ -563,7 +576,7 @@ class SchedulerTaskList(BaseModel): f"Fixed: Generated new token '{task.token}' for task {task.name}" ) - path = get_abs_path(SCHEDULER_FOLDER, "tasks.json") + path = _task_file_path() if not exists(path): make_dirs(path) @@ -576,7 +589,7 @@ class SchedulerTaskList(BaseModel): "ERROR: Found null token in JSON output for an adhoc task" ) - write_file(path, json_data) + write_file(path, yaml.from_json(json_data) if path.endswith(".yaml") else json_data) # Debug: Verify after saving if exists(path): diff --git a/helpers/task_scheduler.py.dox.md b/helpers/task_scheduler.py.dox.md index 7292b4faf..1b5342fcb 100644 --- a/helpers/task_scheduler.py.dox.md +++ b/helpers/task_scheduler.py.dox.md @@ -83,6 +83,8 @@ ## Runtime Contracts +- `SchedulerTaskList` keeps `usr/scheduler/tasks.json` as its default and prefers it when both formats exist. An existing `tasks.yaml` is supported when no JSON file exists; saves use the same selection and format. Loading never migrates, renames, or creates a second file. +- JSON retains its existing Pydantic parser and `helpers.files` persistence path. Optional YAML uses `helpers.yaml` with the same task models. Invalid data raises before replacing the in-memory task list; YAML comments and formatting are regenerated on save. - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together. - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change. - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, network calls, settings/state persistence, secret handling, scheduler state. @@ -104,6 +106,7 @@ - Run targeted tests for changed helper behavior; run security regressions for auth, filesystem, WebSocket, tunnel, upload, or secret-handling helpers. - Related tests observed by source search: - `tests/test_task_scheduler_timezone.py` + - `tests/test_scheduler_editor.py` - `tests/test_timezone_regressions.py` - `tests/test_tool_action_contracts.py` diff --git a/tests/test_scheduler_editor.py b/tests/test_scheduler_editor.py new file mode 100644 index 000000000..859ca8a27 --- /dev/null +++ b/tests/test_scheduler_editor.py @@ -0,0 +1,241 @@ +"""Scheduler prompt editing and human-editable task persistence.""" +import asyncio +from datetime import datetime, timezone, timedelta +import json +from pathlib import Path +import re +import subprocess +import sys +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import yaml + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from helpers import task_scheduler as scheduler +from api.scheduler_task_create import SchedulerTaskCreate + + +def test_scheduler_json_default_and_optional_yaml(): + common = dict( + name="Prompt editing", system_prompt="First line\n Keep indentation: # sì\n", + prompt="Check these values:\ntrue\n123\n*\n\n", state="disabled", + created_at=datetime(2026, 9, 24, tzinfo=timezone.utc), + updated_at=datetime(2026, 9, 24, tzinfo=timezone.utc), + attachments=["/tmp/report.md"], last_result="A result\nwith two lines", + ) + original = scheduler.SchedulerTaskList(tasks=[ + scheduler.AdHocTask(**common, token="000123"), + scheduler.ScheduledTask(**common, schedule=scheduler.TaskSchedule( + minute="30", hour="9", day="*", month="*", weekday="*", timezone="Europe/Rome", + )), + scheduler.PlannedTask(**common, plan=scheduler.TaskPlan( + todo=[datetime(2026, 10, 1, 9, tzinfo=timezone.utc)], + )), + ]) + expected = original.model_dump(mode="json") + for extension in ("json", "yaml"): + with TemporaryDirectory() as folder, patch.object(scheduler, "SCHEDULER_FOLDER", folder), patch.object( + scheduler.SchedulerTaskList, "_SchedulerTaskList__instance", None, + ): + path = Path(folder, f"tasks.{extension}") + text = original.model_dump_json() if extension == "json" else scheduler.yaml.dumps(expected) + path.write_text(text, encoding="utf-8") + tasks = scheduler.SchedulerTaskList.get() + assert tasks.model_dump(mode="json") == expected + assert path.read_text(encoding="utf-8") == text, "loading must not rewrite the file" + asyncio.run(tasks.save()) + assert list(Path(folder).iterdir()) == [path], "saving must not migrate formats" + assert yaml.safe_load(path.read_text(encoding="utf-8")) == expected + + path.write_text(path.read_text(encoding="utf-8").replace("First line", "Edited externally"), encoding="utf-8") + asyncio.run(tasks.reload()) + assert all(task.system_prompt.startswith("Edited externally\n") for task in tasks.tasks) + before = tasks.model_dump(mode="json") + for invalid in ("tasks: [", "", '{"tasks":{}}', '{"tasks":[{"type":"unknown"}]}', "!!python/object:builtins.object {}"): + path.write_text(invalid, encoding="utf-8") + try: + asyncio.run(tasks.reload()) + except (ValueError, yaml.YAMLError): + pass + else: + raise AssertionError("Invalid task data was accepted") + assert tasks.model_dump(mode="json") == before + assert path.read_text(encoding="utf-8") == invalid + + path.write_text(text, encoding="utf-8") + json_path, yaml_path = Path(folder, "tasks.json"), Path(folder, "tasks.yaml") + json_path.write_text(original.model_dump_json(), encoding="utf-8") + yaml_path.write_text("tasks: []\n", encoding="utf-8") + with patch.object(scheduler.yaml, "loads", side_effect=AssertionError("JSON must not use YAML parsing")): + asyncio.run(tasks.reload()) + assert tasks.model_dump(mode="json") == expected + asyncio.run(tasks.remove_task_by_uuid(tasks.tasks[0].uuid)) + assert len(json.loads(json_path.read_text())["tasks"]) == 2 + assert yaml_path.read_text() == "tasks: []\n", "JSON takes precedence without changing YAML" + + with TemporaryDirectory() as folder, patch.object(scheduler, "SCHEDULER_FOLDER", folder), patch.object( + scheduler.SchedulerTaskList, "_SchedulerTaskList__instance", None, + ): + assert scheduler.SchedulerTaskList.get().tasks == [] + assert json.loads(Path(folder, "tasks.json").read_text()) == {"tasks": []} + assert not Path(folder, "tasks.yaml").exists() + + +def test_scheduler_due_tasks_and_execution_guards_in_both_formats(): + now = datetime(2026, 9, 24, 8, 30, 30, tzinfo=timezone.utc) + common = dict(name="Execution check", system_prompt="", prompt="Check scheduler state") + templates = [ + scheduler.AdHocTask(**common), + scheduler.ScheduledTask(**common, schedule=scheduler.TaskSchedule( + minute="30", hour="10", day="*", month="*", weekday="*", timezone="Europe/Rome", + )), + scheduler.PlannedTask(**common, plan=scheduler.TaskPlan(todo=[now - timedelta(minutes=1)])), + ] + data = scheduler.SchedulerTaskList(tasks=[ + task.__class__.model_validate({**task.model_dump(), "uuid": f"{task.type.value}-{state.value}", "state": state}) + for task in templates for state in scheduler.TaskState + ]) + + async def check(engine): + due = await engine._tasks.get_due_tasks() + assert {task.uuid for task in due} == {"scheduled-idle", "planned-idle"} + for task in list(engine.get_tasks()): + if task.state in (scheduler.TaskState.DISABLED, scheduler.TaskState.RUNNING): + try: + await engine.run_task_by_uuid(task.uuid) + except ValueError: + pass + else: + raise AssertionError("A disabled or running task was started") + else: + await engine.run_task_by_uuid(task.uuid) + assert engine._run_task.call_args.args[0].state == scheduler.TaskState.IDLE + assert engine._run_task.await_count == 6 + + claim = await engine.update_task_checked( + "planned-idle", lambda task: task.state == scheduler.TaskState.IDLE, state=scheduler.TaskState.RUNNING, + ) + assert claim is not None + assert await engine.update_task_checked( + "planned-idle", lambda task: task.state == scheduler.TaskState.IDLE, state=scheduler.TaskState.RUNNING, + ) is None + await claim.on_run() + await claim.on_success("Result\nwith preserved line breaks") + await claim.on_finish() + await engine.reload() + finished = engine.get_task_by_uuid("planned-idle") + assert finished.state == scheduler.TaskState.IDLE + assert finished.last_run == now + assert finished.last_result == "Result\nwith preserved line breaks" + assert finished.plan.todo == [] and finished.plan.in_progress is None + assert finished.plan.done == [now - timedelta(minutes=1)] + assert "planned-idle" not in {task.uuid for task in await engine._tasks.get_due_tasks()} + + failed = engine.get_task_by_uuid("adhoc-idle") + await failed.on_error("Expected failure") + await engine.reload() + assert engine.get_task_by_uuid(failed.uuid).state == scheduler.TaskState.ERROR + assert engine.get_task_by_uuid(failed.uuid).last_result == "ERROR: Expected failure" + + for extension in ("json", "yaml"): + with TemporaryDirectory() as folder, patch.object(scheduler, "SCHEDULER_FOLDER", folder), patch.object( + scheduler, "_now", return_value=now, + ), patch("helpers.state_monitor_integration.mark_dirty_all"): + content = data.model_dump_json() if extension == "json" else scheduler.yaml.from_json(data.model_dump_json()) + Path(folder, f"tasks.{extension}").write_text(content, encoding="utf-8") + engine = object.__new__(scheduler.TaskScheduler) + engine._tasks = asyncio.run(scheduler.SchedulerTaskList().reload()) + engine._run_task = AsyncMock() + with patch.object(scheduler.TaskScheduler, "get", return_value=engine): + asyncio.run(check(engine)) + + +def test_scheduler_creation_applies_state_before_persistence(): + states = [] + fake = SimpleNamespace( + reload=AsyncMock(), add_task=AsyncMock(side_effect=lambda task: states.append(task.state)), + get_task_by_uuid=lambda _uuid: fake.add_task.call_args.args[0], + ) + with patch.object(scheduler.TaskScheduler, "get", return_value=fake): + for config in ({}, {"schedule": "0 9 * * *"}, {"plan": {"todo": ["2026-10-01T09:00:00+00:00"]}}): + for state in ({}, {"state": "disabled"}): + result = asyncio.run(SchedulerTaskCreate(None, None).process({ + "name": "Initial state", "prompt": "Keep disabled until enabled", **config, **state, + }, None)) + assert result["ok"] + assert states[-1] == state.get("state", "idle") == result["task"]["state"] + + +def test_scheduler_editor_preserves_drafts_until_save_succeeds(): + source = (PROJECT_ROOT / "webui/components/modals/scheduler/scheduler-store.js").read_text() + source = re.sub(r"^import\b[\s\S]*?;\n", "", source, flags=re.M) + source = source.replace("export { store };", "") + script = r''' +import assert from 'node:assert/strict'; +const window = { localStorage: { getItem: () => null }, alert: assert.fail }; +const createStore = (_name, model) => model; +const getUserTimezone = () => 'Europe/Rome'; +let response, sent, destroyed = 0; +const errors = [], opened = [], closed = []; +const openModal = async path => opened.push(path); +const closeModal = async path => closed.push(path); +const notificationsStore = { frontendError: message => errors.push(message), frontendSuccess() {} }; +const fetchApi = async (_endpoint, options) => { + sent = JSON.parse(options.body); + if (response instanceof Error) throw response; + return response; +}; +''' + source + r''' +store.destroyFlatpickr = () => destroyed++; +for (const isCreating of [true, false]) { + store.isCreating = isCreating; + store.isEditing = !isCreating; + store.editingTask = defaultEditingTask({ + uuid: 'task-id', type: 'adhoc', name: 'Task', token: '123', + system_prompt: 'Keep\nall instructions', prompt: 'A long prompt\nwith edits\n', + }); + const draft = store.editingTask; + for (const failure of [ + { ok: true, json: async () => ({ error: 'Invalid task' }) }, + { ok: false, json: async () => ({ error: 'Save failed' }) }, + new Error('Network unavailable'), + ]) { + response = failure; + await store.saveTask(); + assert.equal(store.editingTask, draft); + assert.equal(store.isCreating, isCreating); + assert.equal(store.isEditing, !isCreating); + assert.equal(sent.prompt, draft.prompt); + } + for (const field of ['system_prompt', 'prompt']) { + await store.openPromptEditor(field); + assert.equal(store.promptField, field); + store.editingTask[store.promptField] += '\nExpanded edit'; + await store.closePromptEditor(); + assert.ok(draft[field].endsWith('\nExpanded edit')); + assert.equal(opened.at(-1), closed.at(-1)); + } + response = { ok: true, json: async () => ({ ok: true, task: { ...draft } }) }; + await store.saveTask(); + assert.equal(store.isCreating, false); + assert.equal(store.isEditing, false); + assert.equal(store.editingTask.prompt, ''); + assert.equal(store.tasks.find(task => task.uuid === draft.uuid).prompt, draft.prompt); +} +assert.equal(errors.length, 6); +assert.equal(destroyed, 2, 'failed saves must leave the editor and date picker intact'); +await store.openPromptEditor('name'); +assert.equal(opened.length, 4, 'only prompt fields can be expanded'); +store.refreshProjectOptions = async () => {}; +store.projectOptions = [{ name: 'unrelated-project' }]; +store.deriveActiveProject = () => null; +await store.startCreateTask(); +assert.equal(store.selectedProjectSlug, ''); +assert.equal(store.editingTask.project, null, 'no active project must not select an unrelated project'); +''' + subprocess.run(["node", "--input-type=module"], input=script, text=True, check=True) diff --git a/webui/components/modals/AGENTS.md b/webui/components/modals/AGENTS.md index 9e9c710c9..cc0c99e2d 100644 --- a/webui/components/modals/AGENTS.md +++ b/webui/components/modals/AGENTS.md @@ -10,6 +10,8 @@ - Each direct child folder owns one modal workflow and its store. - Modal HTML files own body content, titles, scoped styles, and `data-modal-footer` content. - Modal store files own modal-local state and cleanup. +- `scheduler/` owns task editing: both prompt fields use full-width resizable textareas, with a shared expanded prompt modal bound to the same draft. Closing the expanded view retains draft edits; only a successful task save clears the draft. API error bodies must be treated as failures even with HTTP 200. +- Scheduler actions live in the pinned footer. New tasks offer idle/disabled initial states and inherit only the active chat's project, otherwise defaulting to no project. ## Local Contracts @@ -25,6 +27,7 @@ ## Verification - Smoke-test open, close, Escape, click-outside, scrolling, stacked modals, and pinned footers after modal changes. +- For scheduler editing, run `pytest tests/test_scheduler_editor.py` and check prompt expansion, draft retention, and save/reopen on desktop and mobile. ## Child DOX Index diff --git a/webui/components/modals/scheduler/scheduler-prompt-editor.html b/webui/components/modals/scheduler/scheduler-prompt-editor.html new file mode 100644 index 000000000..77a6cadff --- /dev/null +++ b/webui/components/modals/scheduler/scheduler-prompt-editor.html @@ -0,0 +1,37 @@ + + + Edit Task Prompt + + + +
+ + +
+ + + diff --git a/webui/components/modals/scheduler/scheduler-store.js b/webui/components/modals/scheduler/scheduler-store.js index 40721a1e4..662c5308f 100644 --- a/webui/components/modals/scheduler/scheduler-store.js +++ b/webui/components/modals/scheduler/scheduler-store.js @@ -1,5 +1,6 @@ import { createStore } from "/js/AlpineStore.js"; import { fetchApi } from "/js/api.js"; +import { openModal, closeModal } from "/js/modals.js"; import { formatDateTime, getUserDateTimeParts, @@ -282,7 +283,7 @@ async function callSchedulerEndpoint(endpoint, payload = {}, defaultError) { body: JSON.stringify(payload), }); const data = await response.json().catch(() => ({})); - if (!response.ok) { + if (!response.ok || data?.error) { return { ok: false, error: data?.error || defaultError || "Task request failed" }; } return { ok: true, data }; @@ -479,6 +480,7 @@ const schedulerStoreModel = { isCreating: false, isEditing: false, editingTask: defaultEditingTask(), + promptField: "prompt", selectedProjectSlug: "", projectOptions: [], @@ -679,12 +681,8 @@ const schedulerStoreModel = { } catch (error) { this.notifyError(`Failed to save task: ${error.message}`); return; - } finally { - this.destroyFlatpickr("all"); - this.resetEditingTask(); - this.isCreating = false; - this.isEditing = false; } + this.cancelEdit(); }, async runTask(taskId) { @@ -756,8 +754,19 @@ const schedulerStoreModel = { }, // Domain helpers ----------------------------------------------------------- + openPromptEditor(field) { + if (!["system_prompt", "prompt"].includes(field)) return; + this.promptField = field; + return openModal("modals/scheduler/scheduler-prompt-editor.html"); + }, + + closePromptEditor() { + return closeModal("modals/scheduler/scheduler-prompt-editor.html"); + }, + resetEditingTask() { this.editingTask = defaultEditingTask(); + this.promptField = "prompt"; this.selectedProjectSlug = ""; }, @@ -1022,10 +1031,7 @@ const schedulerStoreModel = { this.isEditing = false; await this.refreshProjectOptions(); - let initialProject = this.deriveActiveProject(); - if (!initialProject && this.projectOptions.length > 0) { - initialProject = { ...this.projectOptions[0] }; - } + const initialProject = this.deriveActiveProject(); this.editingTask = defaultEditingTask({ token: this.generateRandomToken(), diff --git a/webui/components/modals/scheduler/scheduler-task-editor.html b/webui/components/modals/scheduler/scheduler-task-editor.html index bd09a126f..256c9ac11 100644 --- a/webui/components/modals/scheduler/scheduler-task-editor.html +++ b/webui/components/modals/scheduler/scheduler-task-editor.html @@ -13,15 +13,6 @@
Create New Task
-
-
-
- - -
-
@@ -64,31 +55,13 @@
- +
Select the initial state of the task
-
-
- Idle - Running - Disabled - Error -
-
- Idle: Ready to run - Running: Currently executing - Disabled: Won't execute automatically - Error: Task encountered an error -
-
+
-
-
- -
System-level instructions for the assistant
+
+
+
+ +
System-level instructions for the assistant
+
+
- +
-
-
- -
The main task prompt that will be executed
+
+
+
+ +
The main task prompt that will be executed
+
+
- +
@@ -220,18 +207,6 @@
Edit Task
-
-
- -
-
- - -
-
@@ -391,22 +366,36 @@
-
-
- -
System-level instructions for the assistant
+
+
+
+ +
System-level instructions for the assistant
+
+
- +
-
-
- -
The main task prompt that will be executed
+
+
+
+ +
The main task prompt that will be executed
+
+
- +
@@ -424,7 +413,33 @@
-