chore(repo): initialize Cowork Local Gitea repository
CI / test (push) Canceled after 0s

This commit is contained in:
thanhnv
2026-08-09 20:12:05 +07:00
commit 414eaddca3
192 changed files with 48160 additions and 0 deletions
+593
View File
@@ -0,0 +1,593 @@
"""Schedule Task module — task model, repository and pure scheduling logic.
Everything here is Qt-free so it can be unit-tested headlessly; the Qt wrapper
that actually runs tasks in the background lives in ``task_scheduler.py``.
Storage follows the app's existing pattern (one JSON file per item, like
history/agents): ``~/.cowork_local/tasks/<task_id>.json``. Run artifacts go to
``~/.cowork_local/task_artifacts/<task_id>/<run_id>/``.
"""
from __future__ import annotations
import copy
import json
import uuid
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from ..config import CONFIG_DIR
TASKS_DIR = CONFIG_DIR / "tasks"
ARTIFACTS_DIR = CONFIG_DIR / "task_artifacts"
STATUSES = ("backlog", "scheduled", "running", "waiting_input", "done", "failed", "paused")
TASK_TYPES = ("cowork", "co4e_code", "flow", "script", "manual")
PRIORITIES = ("low", "medium", "high", "critical")
REPEAT_TYPES = ("none", "daily", "weekly", "monthly", "cron") # monthly/cron: placeholder
RUN_NEXT_MODES = ("none", "run_after_success", "run_always", "run_after_manual_confirm")
INPUT_MODES = ("empty", "manual", "file", "previous_task_output")
OUTPUT_MODES = ("text", "file", "folder", "json", "markdown", "code_diff")
_TIME_FMT = "%Y-%m-%d %H:%M" # run_at stored as a local naive timestamp string
_MAX_RUNS_KEPT = 20 # recent run summaries kept inside the task file
# Defaults follow spec §5.3 exactly: empty input, no next task, no output
# chaining, schedule disabled, status Backlog.
DEFAULT_TASK: Dict[str, Any] = {
"task_id": "",
"title": "",
"description": "",
"task_type": "manual",
"agent_executor": "system",
"project_id": "", # optional workspace/project this task's agent runs in
# Which model runs the task. Blank provider/model = the machine's own
# Settings default (see state.build_provider_for). Replaces the older
# per-task Admin-agent preset (admin_agent_id) as the way to choose a model.
"provider": "",
"model": "",
"skill_slug": "", # optional skill applied to the run (its instructions are prepended)
"status": "backlog",
"priority": "medium",
"created_at": "",
"updated_at": "",
"created_by": "",
"is_ai_generated": False,
"schedule": {
"enabled": False,
"run_at": None, # "YYYY-MM-DD HH:MM" local time
"timezone": "local",
"repeat_type": "none",
"cron_expression": None, # used when repeat_type == "cron" (see core/cron.py)
"working_days_only": False,
"skip_holidays": False, # skip public holidays of holiday_country
"holiday_country": "VN", # ISO country code for the holiday calendar
},
"flow": {"flow_id": None, "selected_flow_template": None, "steps": []},
"input": {
"mode": "empty",
"manual_text": None,
"file_paths": [], # attached local files — always used, any mode
"links": [], # attached URLs — always used, any mode
"previous_task_id": None,
},
"output": {"output_mode": "text"},
"dependency": {
"next_task_id": None,
"previous_task_id": None,
"depends_on": [], # fan-in: ALL of these must be Done first
"run_next_mode": "none",
"pass_output_to_next": False,
},
"execution": {
"max_retry": 0,
"timeout_sec": 600,
"requires_approval": False,
"notify_on_complete": False,
"notify_on_error": False,
# Scheduled-reminder channel: "none" | "teams" | "outlook". When not
# "none" the scheduler notifies on both completion AND error via that
# channel (Teams webhook, or the local Outlook desktop app — no login).
"notify_channel": "none",
"notify_email": "", # recipient address(es) for the "outlook" channel
},
"logs": {"last_run_id": None, "last_status": None, "last_error": None},
"script_command": "",
"runs": [],
}
def _now_str() -> str:
return datetime.now().strftime(_TIME_FMT)
def parse_run_at(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
return datetime.strptime(value, _TIME_FMT)
except ValueError:
return None
def format_run_at(dt: datetime) -> str:
return dt.strftime(_TIME_FMT)
def new_task(title: str = "", **overrides) -> Dict[str, Any]:
"""A fresh task dict with spec-mandated defaults. ``overrides`` merge
shallowly for top-level keys and dict-merge for the nested groups."""
task = copy.deepcopy(DEFAULT_TASK)
task["task_id"] = uuid.uuid4().hex
task["title"] = title
task["created_at"] = task["updated_at"] = datetime.now().isoformat(timespec="seconds")
for key, value in overrides.items():
if isinstance(value, dict) and isinstance(task.get(key), dict):
task[key].update(value)
else:
task[key] = value
return task
def _normalize(task: Dict[str, Any]) -> Dict[str, Any]:
"""Fill any missing keys with defaults (tolerates files from older
versions / hand edits) without dropping unknown extras."""
base = copy.deepcopy(DEFAULT_TASK)
for key, value in task.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
base[key].update(value)
else:
base[key] = value
return base
# ---- repository ----------------------------------------------------------
def task_path(task_id: str, directory: Path = None) -> Path:
return (directory or TASKS_DIR) / f"{task_id}.json"
def save_task(task: Dict[str, Any], directory: Path = None) -> Path:
directory = directory or TASKS_DIR
directory.mkdir(parents=True, exist_ok=True)
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
path = task_path(task["task_id"], directory)
path.write_text(json.dumps(task, ensure_ascii=False, indent=2), encoding="utf-8")
return path
def load_task(task_id: str, directory: Path = None) -> Optional[Dict[str, Any]]:
path = task_path(task_id, directory)
try:
return _normalize(json.loads(path.read_text(encoding="utf-8")))
except (OSError, json.JSONDecodeError):
return None
def list_tasks(directory: Path = None) -> List[Dict[str, Any]]:
directory = directory or TASKS_DIR
if not directory.exists():
return []
items: List[Dict[str, Any]] = []
for path in directory.glob("*.json"):
try:
items.append(_normalize(json.loads(path.read_text(encoding="utf-8"))))
except (OSError, json.JSONDecodeError):
continue
items.sort(key=lambda t: t.get("created_at", ""), reverse=True)
return items
def delete_task(task_id: str, directory: Path = None) -> None:
try:
task_path(task_id, directory).unlink()
except OSError:
pass
def duplicate_task(task: Dict[str, Any]) -> Dict[str, Any]:
"""A copy with a fresh id that KEEPS the whole configuration — schedule,
input, flow steps, dependencies, execution options — so re-running an
already-run task needs no re-editing ('duplicate task đã chạy để không
phải chỉnh sửa nhiều'). Only the identity and run history reset. A
repeating/future schedule stays enabled and rolls forward to its next
occurrence; a one-shot time already in the past is disabled (it would
otherwise re-fire immediately and surprise the user)."""
dup = copy.deepcopy(task)
dup["task_id"] = uuid.uuid4().hex
dup["title"] = f"{task.get('title', '')} (copy)"
dup["logs"] = {"last_run_id": None, "last_status": None, "last_error": None}
dup["runs"] = []
dup["created_at"] = dup["updated_at"] = datetime.now().isoformat(timespec="seconds")
now = datetime.now()
if dup["schedule"].get("enabled"):
nxt = compute_next_run(dup, now)
run_at = parse_run_at(dup["schedule"].get("run_at"))
if nxt is not None: # repeating/cron → next occurrence
dup["schedule"]["run_at"] = format_run_at(nxt)
dup["status"] = "scheduled"
elif run_at is not None and run_at > now: # one-shot still in the future
dup["status"] = "scheduled"
else: # one-shot already fired
dup["schedule"]["enabled"] = False
dup["status"] = "backlog"
else:
dup["status"] = "backlog"
return dup
# ---- fan-in dependencies ("chờ các task") ----------------------------------
def _all_prerequisites(task: Dict[str, Any]) -> List[str]:
"""Every task id this one must wait for: the depends_on list plus the
legacy single previous_task_id (older files), deduplicated."""
dep = task.get("dependency", {})
ids = list(dep.get("depends_on") or [])
legacy = dep.get("previous_task_id")
if legacy and legacy not in ids:
ids.append(legacy)
return ids
def dependencies_met(task: Dict[str, Any], directory: Path = None) -> bool:
"""True when EVERY prerequisite has completed successfully at least once.
A task with unmet prerequisites must not run — it doesn't have its input
yet (spec: 'chưa Done task trước thì task sau không chạy')."""
for pid in _all_prerequisites(task):
prev = load_task(pid, directory)
if prev is None:
continue # prerequisite deleted → don't block forever
if prev.get("logs", {}).get("last_status") != "success":
return False
return True
def depends_cycle_error(tasks: List[Dict[str, Any]], task_id: str,
depends_on: List[str]) -> Optional[str]:
"""Validate a proposed depends_on list: no self-wait, no wait-cycle
(A waits B while B — directly or transitively — waits A)."""
if task_id in (depends_on or []):
return "A task cannot wait for itself."
by_id = {t["task_id"]: t for t in tasks}
# DFS from each proposed prerequisite through ITS prerequisites.
for start in depends_on or []:
stack, seen = [start], set()
while stack:
cur = stack.pop()
if cur == task_id:
return "This would create a circular wait between tasks."
if cur in seen:
continue
seen.add(cur)
stack.extend(_all_prerequisites(by_id.get(cur, {})))
return None
# ---- chain validation -----------------------------------------------------
def chain_error(tasks: List[Dict[str, Any]], task_id: str,
next_task_id: Optional[str]) -> Optional[str]:
"""Validate assigning ``next_task_id`` as ``task_id``'s next task.
Returns an error string (self-link / circular chain / unknown id), or
None when the assignment is safe."""
if not next_task_id:
return None
if next_task_id == task_id:
return "A task cannot chain to itself."
by_id = {t["task_id"]: t for t in tasks}
if next_task_id not in by_id:
return "Next task does not exist."
# Walk forward from the proposed next task; reaching task_id again means
# the new edge would close a cycle.
seen = {task_id}
cur = next_task_id
while cur:
if cur in seen:
return "This would create a circular task chain."
seen.add(cur)
cur = (by_id.get(cur) or {}).get("dependency", {}).get("next_task_id")
return None
# ---- schedule math --------------------------------------------------------
def _is_excluded_day(dt: datetime, sched: Dict[str, Any]) -> bool:
"""True when ``dt`` falls on a day this schedule must skip: a weekend
(working_days_only) or a public holiday of the configured country."""
if sched.get("working_days_only") and dt.weekday() >= 5: # 5=Sat, 6=Sun
return True
if sched.get("skip_holidays"):
from .holiday_calendar import is_holiday
if is_holiday(dt.date(), sched.get("holiday_country", "")):
return True
return False
def _add_month(dt: datetime) -> datetime:
import calendar
year = dt.year + (1 if dt.month == 12 else 0)
month = 1 if dt.month == 12 else dt.month + 1
day = min(dt.day, calendar.monthrange(year, month)[1])
return dt.replace(year=year, month=month, day=day)
def shift_off_excluded_days(dt: datetime, sched: Dict[str, Any]) -> datetime:
"""Push ``dt`` forward one day at a time until it lands on an allowed day
(same time of day) — used for one-time schedules set on a weekend/holiday."""
guard = 0
while _is_excluded_day(dt, sched) and guard < 400:
dt += timedelta(days=1)
guard += 1
return dt
def compute_next_run(task: Dict[str, Any], after: datetime) -> Optional[datetime]:
"""The next run time strictly after ``after`` for a repeating task
(daily / weekly / monthly / cron), or None for one-shot schedules.
Occurrences on excluded days (weekends with working_days_only, public
holidays with skip_holidays+holiday_country) are skipped forward."""
sched = task.get("schedule", {})
repeat = sched.get("repeat_type", "none")
if repeat == "cron":
from .cron import Cron, CronError
try:
cron = Cron(sched.get("cron_expression") or "")
except CronError:
return None
nxt = cron.next_after(after)
guard = 0
while nxt is not None and _is_excluded_day(nxt, sched) and guard < 400:
nxt = cron.next_after(nxt)
guard += 1
return nxt
base = parse_run_at(sched.get("run_at"))
if base is None:
return None
if repeat == "daily":
advance = lambda d: d + timedelta(days=1) # noqa: E731
elif repeat == "weekly":
advance = lambda d: d + timedelta(weeks=1) # noqa: E731
elif repeat == "monthly":
advance = _add_month
else:
return None
nxt = base
while nxt <= after:
nxt = advance(nxt)
guard = 0
while _is_excluded_day(nxt, sched) and guard < 400:
nxt = advance(nxt)
guard += 1
return nxt
def due_tasks(tasks: List[Dict[str, Any]], now: datetime) -> List[Dict[str, Any]]:
"""Tasks that should start now: Scheduled + schedule enabled + run_at due."""
due = []
for t in tasks:
if t.get("status") != "scheduled":
continue
sched = t.get("schedule", {})
if not sched.get("enabled"):
continue
run_at = parse_run_at(sched.get("run_at"))
if run_at is not None and run_at <= now:
due.append(t)
return due
# ---- post-run bookkeeping (pure; scheduler applies + saves) ---------------
def _append_run_record(task: Dict[str, Any], ok: bool, run_id: str, error: str,
now: datetime) -> None:
"""Shared by ``advance_after_run``/``record_interrupted_run``: write the
last-run log + append to the recent-runs list every completion path uses,
so a run is never recorded by one code path and silently skipped by
another."""
task["logs"] = {"last_run_id": run_id,
"last_status": "success" if ok else "failed",
"last_error": None if ok else (error or "failed")}
task.setdefault("runs", []).append({
"run_id": run_id,
"status": "success" if ok else "failed",
"finished_at": now.strftime(_TIME_FMT),
"error": None if ok else (error or "failed")[:500],
})
task["runs"] = task["runs"][-_MAX_RUNS_KEPT:]
def advance_after_run(task: Dict[str, Any], ok: bool, run_id: str, error: str = "",
now: Optional[datetime] = None) -> Dict[str, Any]:
"""Mutate ``task`` after a run: status, last-run log, repeat reschedule.
Returns the same dict for chaining convenience."""
now = now or datetime.now()
_append_run_record(task, ok, run_id, error, now)
# Gate on the REPEAT TYPE, not the current "enabled" flag: a repeating
# task must re-arm itself after ANY successful run, including one fired
# manually via "Run now" while enabled happened to be off (e.g. a task
# tested by hand before its first automatic occurrence). Gating on
# "enabled" here was the bug — a manual run on such a task silently
# dropped it to Done with the schedule left disabled, so the recurring
# time the user configured (e.g. "every week") would never fire again.
repeat = task["schedule"].get("repeat_type", "none")
nxt = compute_next_run(task, now) if (ok and repeat != "none") else None
if nxt is not None:
task["schedule"]["run_at"] = format_run_at(nxt)
task["schedule"]["enabled"] = True # re-arm regardless of prior state
task["status"] = "scheduled" # repeating task goes back on the calendar
else:
if repeat == "none":
task["schedule"]["enabled"] = False # one-shot: don't fire again
task["status"] = "done" if ok else "failed"
return task
def record_interrupted_run(task: Dict[str, Any], run_id: str, error: str,
now: Optional[datetime] = None) -> Dict[str, Any]:
"""Record a run that never reached a real finish (the app was closed or
killed while the task was still "running") — same run-history bookkeeping
as ``advance_after_run``, but WITHOUT its success-only repeat re-arm
logic: an interruption isn't a genuine failure of the task's own logic,
so a task whose schedule was still enabled simply goes back on the
calendar exactly as it was, instead of being forced into a terminal
"failed" state that would silently stop a recurring task from ever
firing again. Returns the same dict for chaining convenience."""
now = now or datetime.now()
_append_run_record(task, False, run_id, error, now)
task["status"] = "scheduled" if task["schedule"].get("enabled") else "failed"
return task
def chain_action(task: Dict[str, Any], ok: bool) -> Optional[Tuple[str, str]]:
"""What to do with the next task after this run, if anything:
``("enqueue", next_id)`` — run it now; ``("await_confirm", next_id)`` —
park it in Waiting Input until the user confirms; None — no chaining."""
dep = task.get("dependency", {})
next_id = dep.get("next_task_id")
if not next_id:
return None
mode = dep.get("run_next_mode", "none")
if mode == "run_always" or (mode == "run_after_success" and ok):
return ("enqueue", next_id)
if mode == "run_after_manual_confirm" and ok:
return ("await_confirm", next_id)
return None
# ---- input resolution -----------------------------------------------------
_MAX_INLINE_FILE_CHARS = 20_000
_MAX_INLINE_OUTPUT_CHARS = 20_000
_MAX_FOLDER_ATTACHMENT_FILES = 10
def _folder_attachment_text(folder: Path) -> str:
"""A ``file_paths``/``links`` entry that turned out to be a local/network
FOLDER (not a single file or a fetchable URL) — recursively inline its
files, mirroring ``task_executors.py``'s own project-folder auto-scan
(``_project_folder_input_text``) so a folder attached directly on the
task behaves the same way as a folder linked via its Project."""
from .doc_extract import extract_text, find_input_files
files, total = find_input_files(folder, max_files=_MAX_FOLDER_ATTACHMENT_FILES)
if not files:
return f"[Folder: {folder}] (no readable files found)"
lines = [f"[Folder: {folder}]"]
for f in files:
text, note = extract_text(str(f))
if text is None:
lines.append(f"- {f.name} ({note}; located at {f})")
continue
if len(text) > _MAX_INLINE_FILE_CHARS:
text = text[:_MAX_INLINE_FILE_CHARS] + "\n…(truncated)…"
lines.append(f"- {f.name} ({f})\n--- Content of {f.name} ---\n{text}\n--- end of {f.name} ---")
if total > len(files):
lines.append(f"…({total - len(files)} more files in this folder were not loaded — attachment limit)")
return "\n".join(lines)
def _local_path_attachment_text(value: str) -> Optional[str]:
"""If ``value`` is an existing local/network path (folder or single
file), return its inlined content; ``None`` when it isn't a local path at
all, so the caller falls back to treating it as a URL."""
from .doc_extract import extract_text
p = Path(value)
try:
exists = p.exists()
except OSError:
return None # e.g. an invalid path shape — let URL fetching try instead
if not exists:
return None
if p.is_dir():
return _folder_attachment_text(p)
text, note = extract_text(str(p))
if text is not None:
return f"[File: {p}]\n{text[:_MAX_INLINE_FILE_CHARS]}"
return f"[File not readable: {p}] ({note})"
def _resolve_attachments_text(inp: Dict[str, Any]) -> List[str]:
"""Local files/folders + link previews attached to a task — used
regardless of ``input.mode`` (an explicit attachment is never silently
dropped just because a different mode is selected, mirroring how
attachments work in the Cowork/Co4E chat composer). Uses the same
doc-aware ``doc_extract.extract_text`` every other attachment path in the
app uses (docx/xlsx/pptx/pdf, not just plain text), and a ``links`` entry
that's actually a local/network FOLDER path (not a URL) is recursively
scanned instead of failing to fetch it as one file."""
parts: List[str] = []
for fp in inp.get("file_paths", []) or []:
parts.append(_local_path_attachment_text(fp) or f"[File not readable: {fp}]")
for url in inp.get("links", []) or []:
local = _local_path_attachment_text(url)
if local is not None:
parts.append(local)
continue
from .link_fetch import fetch_link_preview
preview = fetch_link_preview(url)
if preview:
parts.append(preview)
return parts
def resolve_input_text(task: Dict[str, Any], directory: Path = None) -> str:
"""Build the input block appended to the task description when it runs.
``previous_task_output`` reads the chained task's latest artifact (path is
preserved; only a bounded preview is inlined, per spec §7.2). Attached
files/links are always included on top of whichever mode is selected."""
inp = task.get("input", {})
mode = inp.get("mode", "empty")
parts: List[str] = []
if mode == "manual" and inp.get("manual_text"):
parts.append(inp["manual_text"])
elif mode == "previous_task_output":
prev_ids = _input_prerequisites(task)
for prev_id in prev_ids:
prev = load_task(prev_id, directory)
if not prev:
continue
run_id = prev.get("logs", {}).get("last_run_id")
if not run_id:
continue
out_file = ARTIFACTS_DIR / prev_id / run_id / "output.md"
try:
preview = out_file.read_text(
encoding="utf-8", errors="replace")[:_MAX_INLINE_OUTPUT_CHARS]
parts.append(f"[Output of task '{prev.get('title', '')}' "
f"(full artifact: {out_file.parent})]\n{preview}")
except OSError:
parts.append(f"[Task '{prev.get('title', '')}' output folder: {out_file.parent}]")
parts.extend(_resolve_attachments_text(inp))
return "\n\n".join(parts)
def _input_prerequisites(task: Dict[str, Any]) -> List[str]:
"""Which predecessors feed this task's input: the explicit single
previous_task_id if set, else ALL depends_on prerequisites (fan-in —
several parallel tasks all passing their output to this one)."""
inp = task.get("input", {})
single = inp.get("previous_task_id") or task.get("dependency", {}).get("previous_task_id")
fan_in = task.get("dependency", {}).get("depends_on") or []
ids = list(fan_in)
if single and single not in ids:
ids.insert(0, single)
return ids
def previous_output_ready(task: Dict[str, Any], directory: Path = None) -> bool:
"""False when input.mode=previous_task_output but no feeding task has a
finished run yet — the task must wait (spec §7.3)."""
inp = task.get("input", {})
if inp.get("mode") != "previous_task_output":
return True
prev_ids = _input_prerequisites(task)
if not prev_ids:
return False
for pid in prev_ids:
prev = load_task(pid, directory)
if not (prev and prev.get("logs", {}).get("last_run_id")):
return False
return True