mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-09 00:12:21 +02:00
Preserve preview harness, editor, email and task improvements
Snapshot current maintainer-preview application changes and regression fixtures for integration into lab. Excludes local runtime data, evaluation outputs and source backups. Focused Python regression selection: 140 passed; full suite not certified.
This commit is contained in:
+87
-12
@@ -7,7 +7,8 @@ Holds the manage_calendar tool (CalDAV-backed event CRUD).
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
from typing import Dict, Optional
|
||||
|
||||
from src.tools._common import _parse_tool_args
|
||||
@@ -17,6 +18,80 @@ from src.upload_handler import reserve_upload_references
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_local_event_times(args: dict) -> dict:
|
||||
args = dict(args)
|
||||
for field, target in (('local_start', 'dtstart'), ('local_end', 'dtend')):
|
||||
if field not in args:
|
||||
continue
|
||||
value = args[field]
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f'{field} must contain date and time fields')
|
||||
day, clock = value.get('date'), value.get('time')
|
||||
if not isinstance(day, str) or not re.fullmatch(r'\d{4}-\d{2}-\d{2}', day):
|
||||
raise ValueError(f'{field}.date must be YYYY-MM-DD')
|
||||
if args.get('all_day') is True:
|
||||
if clock:
|
||||
raise ValueError(f'Omit {field}.time for an all-day event')
|
||||
normalized = day
|
||||
else:
|
||||
if not isinstance(clock, str) or not re.fullmatch(r'\d{2}:\d{2}(?::\d{2})?', clock):
|
||||
raise ValueError(f'{field}.time must be HH:MM or HH:MM:SS; put its zone in timezone')
|
||||
normalized = day + 'T' + clock
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if target in args and datetime.fromisoformat(str(args[target])) != parsed:
|
||||
raise ValueError(f'Conflicting {field} and {target}; use only one representation')
|
||||
args[target] = normalized
|
||||
return args
|
||||
|
||||
|
||||
def _saved_event_times(event) -> dict:
|
||||
"""Report persisted timestamps, not the model's unnormalized input."""
|
||||
def serialize(value):
|
||||
if value is None:
|
||||
return None
|
||||
if event.all_day:
|
||||
return value.date().isoformat()
|
||||
return value.isoformat() + ('Z' if event.is_utc else '')
|
||||
|
||||
return {
|
||||
'dtstart': serialize(event.dtstart),
|
||||
'dtend': serialize(event.dtend),
|
||||
'all_day': bool(event.all_day),
|
||||
'is_utc': bool(event.is_utc),
|
||||
}
|
||||
|
||||
|
||||
def _explicit_calendar_time(raw: str, zone_name: str) -> tuple[datetime, bool]:
|
||||
"""Convert a stated wall time without relying on the browser timezone."""
|
||||
zone_name = str(zone_name).strip()
|
||||
offset = re.fullmatch(r'(?:UTC|GMT)?([+-])(\d{2}):(\d{2})', zone_name, re.I)
|
||||
if zone_name.upper() in {'UTC', 'GMT', 'Z'}:
|
||||
zone = timezone.utc
|
||||
elif offset:
|
||||
hours, minutes = int(offset[2]), int(offset[3])
|
||||
if hours > 23 or minutes > 59:
|
||||
raise ValueError('Invalid timezone offset')
|
||||
zone = timezone(timedelta(minutes=(hours * 60 + minutes) * (1 if offset[1] == '+' else -1)))
|
||||
else:
|
||||
try:
|
||||
zone = ZoneInfo(zone_name)
|
||||
except (ZoneInfoNotFoundError, ValueError) as exc:
|
||||
raise ValueError('timezone must be UTC, a signed HH:MM offset, or an IANA zone') from exc
|
||||
value = datetime.fromisoformat(str(raw).replace('Z', '+00:00'))
|
||||
if value.tzinfo is not None:
|
||||
if value.utcoffset() != value.astimezone(zone).utcoffset():
|
||||
raise ValueError('Timestamp offset conflicts with timezone; preserve the stated wall time and zone')
|
||||
return value.astimezone(timezone.utc).replace(tzinfo=None), True
|
||||
candidates = set()
|
||||
for fold in (0, 1):
|
||||
instant = value.replace(tzinfo=zone, fold=fold).astimezone(timezone.utc)
|
||||
if instant.astimezone(zone).replace(tzinfo=None) == value:
|
||||
candidates.add(instant)
|
||||
if len(candidates) != 1:
|
||||
raise ValueError('Local time is ambiguous or nonexistent due to daylight saving; specify a valid time with explicit offset')
|
||||
return candidates.pop().replace(tzinfo=None), True
|
||||
|
||||
|
||||
async def do_manage_calendar(content: str, owner: Optional[str] = None, *, import_event_uid: Optional[str] = None) -> Dict:
|
||||
"""Handle manage_calendar tool calls: list/create/update/delete calendar events (local SQLite)."""
|
||||
from core.database import SessionLocal, CalendarCal, CalendarEvent, Note
|
||||
@@ -38,6 +113,10 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
||||
args = _parse_tool_args(content)
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
try:
|
||||
args = _normalize_local_event_times(args)
|
||||
except (ValueError, TypeError) as exc:
|
||||
return {"error": str(exc), "exit_code": 1}
|
||||
|
||||
# ── Batch normalization ──
|
||||
# Some models (e.g. deepseek-v4-flash) emit {"events": [{...}, ...]}
|
||||
@@ -180,6 +259,8 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
||||
|
||||
def _parse_event_dt(raw: str) -> tuple[datetime, bool]:
|
||||
"""Parse agent event datetimes in the user's timezone when available."""
|
||||
if args.get('timezone'):
|
||||
return _explicit_calendar_time(raw, args['timezone'])
|
||||
return _parse_dt_pair(parse_due_for_user(raw))
|
||||
|
||||
def _parse_all_day_event_dt(raw: str) -> tuple[datetime, bool]:
|
||||
@@ -495,12 +576,11 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
||||
)
|
||||
return {
|
||||
"response": (
|
||||
f"Event already exists: [{summary}](#event-{existing.uid}) on {dtstart_str}"
|
||||
f"Event already exists: [{summary}](#event-{existing.uid}) on {_saved_event_times(existing)['dtstart']}"
|
||||
+ reminder_text
|
||||
),
|
||||
"uid": existing.uid,
|
||||
"dtstart": dtstart_str,
|
||||
"all_day": bool(existing.all_day),
|
||||
**_saved_event_times(existing),
|
||||
"anchor": f"[{summary}](#event-{existing.uid})",
|
||||
"has_reminder": bool(reminder_note_id),
|
||||
"reminder_note_id": reminder_note_id,
|
||||
@@ -572,10 +652,9 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
||||
# that opens the calendar on that day. See the markdown
|
||||
# anchor convention ([Name](#event-<uid>)).
|
||||
return {
|
||||
"response": f"Created event [{summary}](#event-{uid}){tag_blurb} on {dtstart_str}{reminder_blurb}",
|
||||
"response": f"Created event [{summary}](#event-{uid}){tag_blurb} on {_saved_event_times(ev)['dtstart']}{reminder_blurb}",
|
||||
"uid": uid,
|
||||
"dtstart": dtstart_str,
|
||||
"all_day": bool(all_day),
|
||||
**_saved_event_times(ev),
|
||||
"anchor": f"[{summary}](#event-{uid})",
|
||||
"has_reminder": bool(reminder_note_id),
|
||||
"reminder_note_id": reminder_note_id,
|
||||
@@ -711,11 +790,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
||||
return {
|
||||
"response": f"Updated event [{ev.summary or uid}](#event-{base_uid}){reminder_text}",
|
||||
"uid": base_uid,
|
||||
"dtstart": (
|
||||
(ev.dtstart.isoformat() + ("Z" if bool(ev.is_utc) and not bool(ev.all_day) else ""))
|
||||
if ev.dtstart else None
|
||||
),
|
||||
"all_day": bool(ev.all_day),
|
||||
**_saved_event_times(ev),
|
||||
"anchor": f"[{ev.summary or uid}](#event-{base_uid})",
|
||||
"has_reminder": bool(reminder_note_id) or bool(_calendar_reminder_for_event(db, owner, ev)),
|
||||
"reminder_note_id": reminder_note_id,
|
||||
|
||||
+30
-2
@@ -9,6 +9,7 @@ function-locally here.
|
||||
import hashlib
|
||||
import io
|
||||
import uuid
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
@@ -25,12 +26,27 @@ async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
|
||||
action = args.get("action", "")
|
||||
if not image_id or not action:
|
||||
return {"error": "image_id and action are required", "exit_code": 1}
|
||||
if action not in {"upscale", "rembg"}:
|
||||
if action not in {"prompt", "upscale", "rembg"}:
|
||||
return {
|
||||
"error": f"Unsupported edit action: {action}. Use upscale or rembg.",
|
||||
"error": f"Unsupported edit action: {action}. Use prompt, upscale or rembg.",
|
||||
"exit_code": 1,
|
||||
}
|
||||
|
||||
if str(image_id).startswith('odysseus://attachment/'):
|
||||
from src.tool_utils import get_upload_handler
|
||||
from src.settings import load_settings
|
||||
ref = re.fullmatch(r'odysseus://attachment/([A-Za-z0-9_-]+(?:\.[A-Za-z0-9]+)?)', image_id)
|
||||
handler = get_upload_handler()
|
||||
info = handler.resolve_upload(ref[1], owner=owner, allow_admin=False) if ref and owner and handler else None
|
||||
if not info or not info.get('path') or not handler.is_image_file(info.get('name') or info.get('id') or ref[1], info.get('mime', '')):
|
||||
return {'error': 'Uploaded image not found or not accessible', 'exit_code': 1}
|
||||
if action != 'prompt':
|
||||
return {'error': 'Uploaded images support action=prompt here', 'exit_code': 1}
|
||||
if not load_settings().get('image_gen_enabled', True):
|
||||
return {'error': 'Image generation is disabled by the administrator.', 'exit_code': 1}
|
||||
from src.ai_interaction import do_edit_image as edit_with_model
|
||||
return await edit_with_model(str(args.get('prompt') or '').strip(), info['path'], owner=owner, size='auto')
|
||||
|
||||
from core.database import GalleryImage, SessionLocal
|
||||
from src.constants import GENERATED_IMAGES_DIR
|
||||
|
||||
@@ -53,6 +69,18 @@ async def do_edit_image(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if source_name != source.filename or source_path.parent != root or not source_path.is_file():
|
||||
return {"error": "Image file not found", "exit_code": 1}
|
||||
|
||||
if action == "prompt":
|
||||
from src.settings import load_settings
|
||||
if not load_settings().get('image_gen_enabled', True):
|
||||
return {"error": "Image generation is disabled by the administrator.", "exit_code": 1}
|
||||
prompt = str(args.get('prompt') or '').strip()
|
||||
if not prompt:
|
||||
return {"error": "prompt is required for instruction-based editing", "exit_code": 1}
|
||||
session_id = source.session_id
|
||||
db.close()
|
||||
from src.ai_interaction import do_edit_image as edit_with_model
|
||||
return await edit_with_model(prompt, str(source_path), session_id=session_id, owner=owner, size='auto')
|
||||
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(source_path) as opened:
|
||||
|
||||
+37
-2
@@ -53,6 +53,14 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"remove": "delete",
|
||||
}
|
||||
action = _NOTE_ACTION_ALIASES.get(action, action)
|
||||
if action == "add" and any(args.get(key) for key in ("id", "note_id", "noteId")):
|
||||
return {
|
||||
"error": 'Nothing saved. add creates a new note and cannot take an existing note ID. '
|
||||
'To fill or change that note, retry with action="update", id set to the existing '
|
||||
'note ID, and checklist_items plus note_type="checklist" for a to-do list. '
|
||||
'Do not create another note.',
|
||||
"exit_code": 1,
|
||||
}
|
||||
if action == "remove_item":
|
||||
return {
|
||||
"error": "To remove a checklist item, use update with id and the complete remaining checklist_items, preserving their done states. No item was changed.",
|
||||
@@ -267,6 +275,29 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
items_raw = args.get("items")
|
||||
items_json = json.dumps(items_raw) if items_raw is not None else None
|
||||
note_type = args.get("note_type", "checklist" if items_raw else "note")
|
||||
if not title and note_type in {"checklist", "todo", "goal"}:
|
||||
from src.user_time import now_user_local
|
||||
title = f"To-do - {now_user_local().date().isoformat()}"
|
||||
if note_type in {"checklist", "todo", "goal"} and not isinstance(items_raw, list):
|
||||
return {
|
||||
"error": 'Nothing saved. Checklist creation requires checklist_items as an array of '
|
||||
'{"text":"task including any stated time","done":false}. '
|
||||
'Put each task in its own item, not in title. Use a short title only; '
|
||||
'do not include explanations or timezone calculations. Retry with the structured items. '
|
||||
'Use [] only when the user explicitly requested an empty checklist.',
|
||||
"exit_code": 1,
|
||||
}
|
||||
if items_raw is not None and (
|
||||
not isinstance(items_raw, list)
|
||||
or any(not isinstance(item, dict)
|
||||
or not isinstance(item.get("text"), str)
|
||||
or not item["text"].strip()
|
||||
or not isinstance(item.get("done", False), bool)
|
||||
for item in items_raw)
|
||||
):
|
||||
return {"error": 'Nothing saved. checklist_items must be an array of objects with '
|
||||
'nonempty text and an optional boolean done. Retry with corrected items.',
|
||||
"exit_code": 1}
|
||||
# Accept natural-language due_date ("tomorrow at 1pm") in
|
||||
# addition to ISO. Use the user-tz-aware parser so the LLM's
|
||||
# naive times ("today at 9pm") are anchored to the USER's clock,
|
||||
@@ -451,7 +482,9 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if "archived" in args:
|
||||
note.archived = args["archived"]
|
||||
db.commit()
|
||||
return {"response": f"Note updated: \"{note.title or '(untitled)'}\"", "exit_code": 0}
|
||||
return {"response": f"Note updated: \"{note.title or '(untitled)'}\"",
|
||||
"note_id": note.id, "note_title": note.title or "",
|
||||
"open_url": f"/#open=notes¬e={note.id}", "exit_code": 0}
|
||||
|
||||
elif action == "delete":
|
||||
note_id = _note_id_arg()
|
||||
@@ -494,7 +527,9 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
|
||||
elif action == "toggle_item":
|
||||
note_id = _note_id_arg()
|
||||
index = args.get("index", 0)
|
||||
index = args.get("index")
|
||||
if not isinstance(index, int) or isinstance(index, bool):
|
||||
return {"error": "toggle_item requires an explicit integer index (0-based). Use view to inspect item indices if unknown; no change made.", "exit_code": 1}
|
||||
note = _note_by_prefix(note_id)
|
||||
if not note:
|
||||
return {"error": f"Note '{note_id}' not found", "exit_code": 1}
|
||||
|
||||
+74
-1
@@ -293,6 +293,38 @@ def _task_date_utc(value):
|
||||
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
def _task_structured_schedule(args, fallback_time=None):
|
||||
"""Translate unambiguous day fields into the scheduler's legacy format."""
|
||||
if 'day_of_month' in args:
|
||||
day = args['day_of_month']
|
||||
if isinstance(day, bool) or not isinstance(day, int) or not 1 <= day <= 31:
|
||||
raise ValueError('day_of_month must be an integer from 1 to 31')
|
||||
if ('weekdays' in args or args.get('scheduled_day') is not None
|
||||
or args.get('cron_expression') or args.get('schedule') not in (None, 'monthly')
|
||||
or args.get('trigger_type', 'schedule') != 'schedule'):
|
||||
raise ValueError('day_of_month is only for monthly schedules; omit other day fields')
|
||||
return {**args, 'schedule': 'monthly', 'scheduled_day': day}
|
||||
if 'weekdays' not in args:
|
||||
return args
|
||||
days = args['weekdays']
|
||||
names = ('sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday')
|
||||
if not isinstance(days, list) or not days or any(not isinstance(d, str) or d not in names for d in days):
|
||||
raise ValueError('weekdays must contain weekday names from monday through sunday')
|
||||
if args.get('cron_expression') or args.get('scheduled_day') is not None:
|
||||
raise ValueError('Use weekdays or cron_expression/scheduled_day, not both')
|
||||
if args.get('trigger_type', 'schedule') != 'schedule' or args.get('schedule') == 'once':
|
||||
raise ValueError('weekdays requires a recurring schedule trigger')
|
||||
from datetime import datetime
|
||||
clock = args.get('scheduled_time', fallback_time)
|
||||
try:
|
||||
parsed = datetime.strptime(clock, '%H:%M')
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError('scheduled_time in HH:MM UTC is required with weekdays') from exc
|
||||
cron_days = ','.join(str(n) for n in sorted({names.index(d) for d in days}))
|
||||
return {**args, 'schedule': 'cron', 'scheduled_time': parsed.strftime('%H:%M'),
|
||||
'cron_expression': f'{parsed.minute} {parsed.hour} * * {cron_days}'}
|
||||
|
||||
|
||||
async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Handle manage_tasks tool calls: CRUD on scheduled tasks."""
|
||||
import uuid as _uuid
|
||||
@@ -409,6 +441,8 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
bits = [t.status or "unknown"]
|
||||
if t.schedule:
|
||||
bits.append(str(t.schedule))
|
||||
if t.schedule == "cron" and t.cron_expression:
|
||||
bits.append(t.cron_expression)
|
||||
if t.scheduled_time:
|
||||
bits.append(str(t.scheduled_time))
|
||||
if t.next_run:
|
||||
@@ -420,6 +454,7 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
return {"response": "\n".join(lines), "exit_code": 0}
|
||||
|
||||
elif action == "create":
|
||||
args = _task_structured_schedule(args)
|
||||
task_type = args.get("task_type", "llm")
|
||||
trigger_type = args.get("trigger_type", "schedule")
|
||||
|
||||
@@ -433,14 +468,19 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
scheduled_date = None
|
||||
if trigger_type == "schedule":
|
||||
schedule = args.get("schedule", "daily")
|
||||
if args.get('scheduled_date') and schedule != 'once':
|
||||
raise ValueError('scheduled_date is only for schedule=once; use day_of_month and scheduled_time for monthly tasks, or weekdays and scheduled_time for weekly tasks')
|
||||
if schedule == "once":
|
||||
scheduled_date = _task_date_utc(args.get("scheduled_date"))
|
||||
next_run = compute_next_run(
|
||||
schedule, args.get("scheduled_time", "09:00"),
|
||||
args.get("scheduled_day"), scheduled_date,
|
||||
cron_expression=args.get("cron_expression"),
|
||||
)
|
||||
if schedule == "once" and next_run is None:
|
||||
return {"error": "scheduled_date must be in the future", "exit_code": 1}
|
||||
if schedule == "cron" and next_run is None:
|
||||
return {"error": "A valid cron_expression is required for schedule=cron", "exit_code": 1}
|
||||
|
||||
task_id = str(_uuid.uuid4())
|
||||
# Guard each fallback with `or`: args.get("prompt", default) returns
|
||||
@@ -458,6 +498,7 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
scheduled_time=args.get("scheduled_time", "09:00") if trigger_type == "schedule" else None,
|
||||
scheduled_day=args.get("scheduled_day"),
|
||||
scheduled_date=scheduled_date,
|
||||
cron_expression=args.get("cron_expression") if trigger_type == "schedule" else None,
|
||||
trigger_type=trigger_type,
|
||||
trigger_event=args.get("trigger_event"),
|
||||
trigger_count=args.get("trigger_count"),
|
||||
@@ -481,6 +522,30 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if owner and task.owner != owner:
|
||||
return {"error": "Access denied", "exit_code": 1}
|
||||
|
||||
if 'weekdays' in args or 'day_of_month' in args:
|
||||
clock = task.scheduled_time
|
||||
if task.schedule == 'cron':
|
||||
fields = (task.cron_expression or '').split()
|
||||
clock = (f'{fields[1]}:{fields[0]}' if len(fields) == 5
|
||||
and fields[0].isdigit() and fields[1].isdigit() else None)
|
||||
args = _task_structured_schedule({
|
||||
'trigger_type': task.trigger_type or 'schedule', **args,
|
||||
}, fallback_time=clock)
|
||||
if ((args.get('schedule') or task.schedule) == 'cron'
|
||||
and args.get('scheduled_time') is not None
|
||||
and args.get('cron_expression') is None):
|
||||
from datetime import datetime
|
||||
try:
|
||||
clock = datetime.strptime(args['scheduled_time'], '%H:%M')
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError('scheduled_time must be HH:MM UTC') from exc
|
||||
fields = (task.cron_expression or '').split()
|
||||
if len(fields) != 5:
|
||||
raise ValueError('Supply cron_expression to retime a schedule without a five-field cron expression')
|
||||
# For cron tasks the executable clock lives in the expression,
|
||||
# not the legacy scheduled_time column used by simple schedules.
|
||||
args = {**args, 'scheduled_time': clock.strftime('%H:%M'),
|
||||
'cron_expression': ' '.join([str(clock.minute), str(clock.hour), *fields[2:]])}
|
||||
changed = []
|
||||
for field in ("name", "prompt", "output_target"):
|
||||
if args.get(field) is not None:
|
||||
@@ -503,12 +568,14 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
changed.append("trigger_count")
|
||||
|
||||
schedule_changed = False
|
||||
for field in ("schedule", "scheduled_time", "scheduled_day"):
|
||||
for field in ("schedule", "scheduled_time", "scheduled_day", "cron_expression"):
|
||||
if args.get(field) is not None:
|
||||
setattr(task, field, args[field])
|
||||
changed.append(field)
|
||||
schedule_changed = True
|
||||
if "scheduled_date" in args:
|
||||
if args.get('scheduled_date') and task.schedule != 'once':
|
||||
raise ValueError('scheduled_date is only for schedule=once; use day_of_month and scheduled_time for monthly tasks, or weekdays and scheduled_time for weekly tasks')
|
||||
task.scheduled_date = _task_date_utc(args["scheduled_date"])
|
||||
changed.append("scheduled_date")
|
||||
schedule_changed = True
|
||||
@@ -519,9 +586,12 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
task.next_run = compute_next_run(
|
||||
task.schedule, task.scheduled_time, task.scheduled_day,
|
||||
task.scheduled_date,
|
||||
cron_expression=task.cron_expression,
|
||||
)
|
||||
if task.schedule == "once" and task.next_run is None:
|
||||
raise ValueError("scheduled_date must be in the future")
|
||||
if task.schedule == "cron" and task.next_run is None:
|
||||
raise ValueError("A valid cron_expression is required for schedule=cron")
|
||||
|
||||
db.commit()
|
||||
return {"response": f"Updated task '{task.name}': {', '.join(changed)}", "exit_code": 0}
|
||||
@@ -552,9 +622,12 @@ async def do_manage_tasks(content: str, owner: Optional[str] = None) -> Dict:
|
||||
task.next_run = compute_next_run(
|
||||
task.schedule, task.scheduled_time, task.scheduled_day,
|
||||
task.scheduled_date,
|
||||
cron_expression=task.cron_expression,
|
||||
)
|
||||
if task.schedule == "once" and task.next_run is None:
|
||||
raise ValueError("A future scheduled_date is required to resume this one-off task")
|
||||
if task.schedule == "cron" and task.next_run is None:
|
||||
raise ValueError("A valid cron_expression is required to resume this task")
|
||||
db.commit()
|
||||
return {"response": f"Task '{task.name}' {action}d", "exit_code": 0}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user