feat(R07): task repository, schedule calculator, Qt clock adapter, task/AI-planner services
Team Hoa, EPIC R07 (Scheduling & Workflow Runtime) - Team Hoa scope only (R07-T01 -> T05; R07-T06 Co4EWorkflowService is Team Nam's). - R07-T01: infrastructure/persistence/json/task_repository_impl.py wraps core/tasks.py's CRUD; core/tasks.py::save_task now writes through atomic_write.write_json (same durability fix as R06-T02, save_task was still doing a plain write_text). - R07-T02: domain/tasks/schedule_calculator.py::ScheduleCalculator - the cron/interval/daily/weekly/monthly due-time math extracted from core/tasks.py, pure Python with is_holiday/make_cron injected so domain/ never imports core (ADR-001 I2). core/tasks.py keeps its old function names as thin wrappers so every existing caller is unchanged. This was previously untested; now has its own unit suite. - R07-T03: infrastructure/qt/qt_scheduler_clock.py::QtSchedulerClock wraps the QTimer TaskScheduler used to own directly, injected via a new `clock=` constructor param (defaults to a real one). Originally planned at platform/qt/... ; moved after confirming that name shadows the stdlib platform module (used by core/windows_sandbox_vm.py, core/appcontainer_sandbox.py) whenever the repo root is on sys.path. tests/fakes/fake_clock.py lets scheduler dispatch be tested tick-by-tick with no Qt event loop. - R07-T04: application/scheduling/task_application_service.py centralizes run_now/duplicate/pause/delete/bulk_delete and the Kanban drag-drop business rules (move_to_status), currently only reachable by driving the real ui/schedule_task_tab.py widget. - R07-T05: application/scheduling/ai_task_planner_service.py wraps core/ai_task_planner.py::plan_tasks and core/task_import.py::import_tasks as a seam, plus the attachment-stamping step that used to only exist inside the AI-create dialog's worker closure. pytest: 328 pass (same 4 pre-existing failures as the R05/R06 baseline, unrelated to this work - see docs/refactor/BaoCao_TeamHoa_R05_R06.md). scripts/check_imports.py: PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""Application services for Schedule Task (EPIC R07)."""
|
||||
|
||||
from .ai_task_planner_service import AiTaskPlannerService
|
||||
from .task_application_service import MoveResult, RunNowResult, TaskApplicationService
|
||||
|
||||
__all__ = ["TaskApplicationService", "RunNowResult", "MoveResult", "AiTaskPlannerService"]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""AiTaskPlannerService - AI-generate / import task lists, outside the widget
|
||||
(R07-T05).
|
||||
|
||||
``ui/schedule_task_tab.py``'s ``_AiCreateDialog`` already delegates the
|
||||
actual planning to two existing pure functions —
|
||||
``core/ai_task_planner.py::plan_tasks`` (natural-language description ->
|
||||
task dicts, via the active provider) and
|
||||
``core/task_import.py::import_tasks`` (Excel/CSV/JSON -> task dicts) — so
|
||||
this service does not reimplement either. What it DOES own is one small
|
||||
piece of business logic that currently only exists inside the dialog's
|
||||
``AgentWorker`` job closure (``_generate``'s ``job()``): every AI-generated
|
||||
task must carry the SAME file/link attachments the user attached to the
|
||||
request, so they're available again at run time, not just visible to the
|
||||
planner while it drafts the task list. Leaving that step trapped in a Qt
|
||||
worker closure means it can only be exercised by driving the real dialog;
|
||||
here it's a plain, independently testable method.
|
||||
|
||||
Pure Python: no Qt import. The provider is a constructor-injected factory
|
||||
(``() -> Provider``, no arguments — matches ``AppContext.build_active_
|
||||
provider``), the same dependency-inversion shape
|
||||
``application/conversations/conversation_application_service.py`` (R04-T03)
|
||||
uses for ITS provider factory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Union
|
||||
|
||||
ProviderFactory = Callable[[], Any]
|
||||
CancelFn = Callable[[], bool]
|
||||
|
||||
|
||||
class AiTaskPlannerService:
|
||||
"""AI task generation + file/Excel/CSV/JSON import, for
|
||||
``presentation/scheduling/ai_task_creator_dialog.py`` and
|
||||
``ai_task_import_dialog.py`` (R08-T11) to call instead of importing
|
||||
``core.ai_task_planner``/``core.task_import`` directly.
|
||||
|
||||
Args:
|
||||
provider_factory: ``() -> Provider``. Production passes
|
||||
``AppContext.build_active_provider``; tests pass a lambda
|
||||
returning a :class:`FakeProvider`.
|
||||
"""
|
||||
|
||||
def __init__(self, provider_factory: Optional[ProviderFactory] = None) -> None:
|
||||
self._provider_factory = provider_factory
|
||||
|
||||
def plan(
|
||||
self,
|
||||
description: str,
|
||||
*,
|
||||
file_paths: Sequence[str] = (),
|
||||
links: Sequence[str] = (),
|
||||
provider: Any = None,
|
||||
cancel: Optional[CancelFn] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Turn ``description`` into a list of NOT-yet-saved task dicts.
|
||||
|
||||
``provider`` overrides the constructor's factory for this one call
|
||||
(useful for tests, or a caller that already resolved a provider);
|
||||
omit it to use the injected factory. Raises ``RuntimeError`` when
|
||||
no provider is available at all, or when the model's reply had no
|
||||
parseable task list (same error ``core.ai_task_planner.plan_tasks``
|
||||
already raises).
|
||||
"""
|
||||
resolved = provider if provider is not None else self._resolve_provider()
|
||||
from cowork_local.core.ai_task_planner import plan_tasks
|
||||
|
||||
planned = plan_tasks(resolved, description, cancel=cancel)
|
||||
# Attachments apply to EVERY generated task so they're still there
|
||||
# when the task actually runs, not just while the planner drafts it
|
||||
# (see module docstring — this used to only happen inside the
|
||||
# dialog's worker closure).
|
||||
for task in planned:
|
||||
task["input"]["file_paths"] = list(file_paths)
|
||||
task["input"]["links"] = list(links)
|
||||
return planned
|
||||
|
||||
def import_file(self, path: Union[str, Path]) -> List[Dict[str, Any]]:
|
||||
"""Excel/CSV/JSON -> NOT-yet-saved task dicts, auto-chained in file
|
||||
order. Raises ``ValueError`` with a human-readable message on an
|
||||
unusable/unsupported file (same contract
|
||||
``core.task_import.import_tasks`` already has)."""
|
||||
from cowork_local.core.task_import import import_tasks
|
||||
|
||||
return import_tasks(path)
|
||||
|
||||
def _resolve_provider(self) -> Any:
|
||||
if self._provider_factory is None:
|
||||
raise RuntimeError("No provider available to plan tasks.")
|
||||
return self._provider_factory()
|
||||
|
||||
|
||||
__all__ = ["AiTaskPlannerService"]
|
||||
@@ -0,0 +1,172 @@
|
||||
"""TaskApplicationService - task CRUD + dispatch, outside the widget (R07-T04).
|
||||
|
||||
``ui/schedule_task_tab.py`` currently does all of this by importing
|
||||
``core/tasks.py`` module functions directly and calling
|
||||
``self.scheduler.run_now(...)`` inline inside Qt slot methods
|
||||
(``_run_now``, ``_context_menu``'s duplicate/pause/delete branches,
|
||||
``_on_task_dropped``'s per-lane business rules). None of it is Qt — it's
|
||||
plain CRUD plus a few small rules ("a manual task never auto-runs",
|
||||
"dropping a card on Done disables its schedule so it won't re-fire",
|
||||
"dropping on Scheduled with no time set needs the editor, not a silent
|
||||
no-op") — but it can only be exercised today by driving the real widget.
|
||||
|
||||
This service is the seam ``presentation/scheduling/kanban_board_widget.py``
|
||||
(R08-T11) calls instead: same rules, same
|
||||
:class:`~infrastructure.persistence.json.task_repository_impl.TaskRepository`
|
||||
underneath, testable with no Qt at all.
|
||||
|
||||
Pure Python: no Qt import. ``run_now`` dispatch is a plain injected callable
|
||||
(production wires ``TaskScheduler.run_now``; tests inject a stub), the same
|
||||
constructor-injection shape ``application/conversations/conversation_
|
||||
application_service.py`` (R04-T03) uses for its provider factory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
# "TaskRepository" here is a Protocol-shaped name, not an import: this module
|
||||
# only calls .get/.save/.delete/.duplicate, so any object with that shape
|
||||
# (the real infrastructure.persistence.json.task_repository_impl.TaskRepository,
|
||||
# or a test double) works without this file importing infrastructure/ at
|
||||
# module scope.
|
||||
RunNowFn = Callable[[str], bool]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunNowResult:
|
||||
"""Outcome of asking a task to run immediately.
|
||||
|
||||
``reason`` is one of ``""`` (ok), ``"not_found"``, ``"manual_task"``
|
||||
(manual tasks never auto-run — spec: they exist to be run by a human),
|
||||
``"no_scheduler"`` (no ``run_now`` callable was wired in), or
|
||||
``"already_running"`` (the scheduler's own dedupe rejected it).
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class MoveResult:
|
||||
"""Outcome of dropping a task card onto a Kanban lane
|
||||
(``move_to_status``). The caller (kanban widget) uses the flags to decide
|
||||
what to show — a full re-render, a "task is running" toast, or opening
|
||||
the task editor — without re-deriving the business rule itself."""
|
||||
|
||||
task: Optional[Dict[str, Any]]
|
||||
blocked: bool = False # dropped while already running — ignored
|
||||
ran_now: bool = False # dropped on the Running lane — dispatched
|
||||
run_now_result: Optional[RunNowResult] = None
|
||||
needs_schedule: bool = False # dropped on Scheduled with no run_at set — needs editing
|
||||
|
||||
|
||||
class TaskApplicationService:
|
||||
"""CRUD + dispatch for Schedule Task, backed by a ``TaskRepository``.
|
||||
|
||||
Args:
|
||||
repository: a ``TaskRepository``-shaped object (``.get``, ``.save``,
|
||||
``.delete``, ``.duplicate``). Production passes
|
||||
``infrastructure.persistence.json.task_repository_impl.
|
||||
TaskRepository()``; tests pass one scoped to a ``tmp_path``.
|
||||
run_now: ``(task_id) -> bool``. Production passes
|
||||
``TaskScheduler.run_now``; ``None`` means no scheduler is wired
|
||||
(matches the widget's own "no scheduler" guard today).
|
||||
"""
|
||||
|
||||
def __init__(self, repository: Any, run_now: Optional[RunNowFn] = None) -> None:
|
||||
self._repository = repository
|
||||
self._run_now = run_now
|
||||
|
||||
# -- single-task actions ------------------------------------------------ #
|
||||
def run_now(self, task_id: str) -> RunNowResult:
|
||||
"""Dispatch ``task_id`` immediately. A "Run now" always counts as
|
||||
manual approval (spec §13) — this is the ONE path that bypasses
|
||||
``execution.requires_approval``, same as the scheduler's own
|
||||
``run_now`` already does."""
|
||||
task = self._repository.get(task_id)
|
||||
if task is None:
|
||||
return RunNowResult(False, "not_found")
|
||||
if task.get("task_type") == "manual":
|
||||
return RunNowResult(False, "manual_task")
|
||||
if self._run_now is None:
|
||||
return RunNowResult(False, "no_scheduler")
|
||||
ok = self._run_now(task_id)
|
||||
return RunNowResult(ok, "" if ok else "already_running")
|
||||
|
||||
def duplicate(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""A saved copy with a fresh identity — see
|
||||
``core/tasks.py::duplicate_task`` for what's preserved/reset."""
|
||||
task = self._repository.get(task_id)
|
||||
if task is None:
|
||||
return None
|
||||
dup = self._repository.duplicate(task)
|
||||
self._repository.save(dup)
|
||||
return dup
|
||||
|
||||
def toggle_pause(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Pause a task, or resume a paused one back to Backlog (matches
|
||||
``ui/schedule_task_tab.py``'s context-menu action exactly — resuming
|
||||
does NOT restore whatever status the task had before pausing, only
|
||||
Backlog, so the user re-schedules explicitly rather than a stale
|
||||
schedule silently re-firing)."""
|
||||
task = self._repository.get(task_id)
|
||||
if task is None:
|
||||
return None
|
||||
task["status"] = "backlog" if task.get("status") == "paused" else "paused"
|
||||
self._repository.save(task)
|
||||
return task
|
||||
|
||||
def delete(self, task_id: str) -> bool:
|
||||
if self._repository.get(task_id) is None:
|
||||
return False
|
||||
self._repository.delete(task_id)
|
||||
return True
|
||||
|
||||
def bulk_delete(self, task_ids: List[str]) -> int:
|
||||
"""Delete every id in ``task_ids``; returns how many actually
|
||||
existed (mirrors ``_confirm_and_delete_selected``'s best-effort
|
||||
loop — a stale id in the selection doesn't abort the rest)."""
|
||||
return sum(1 for tid in task_ids if self.delete(tid))
|
||||
|
||||
# -- Kanban drag/drop ----------------------------------------------------- #
|
||||
def move_to_status(self, task_id: str, new_status: str) -> Optional[MoveResult]:
|
||||
"""Apply the business rule behind dropping a card into a lane
|
||||
(``ui/schedule_task_tab.py::_on_task_dropped``, moved here so it's
|
||||
testable without a real ``QListWidget`` drag gesture):
|
||||
|
||||
* already running -> the drop is ignored (a running task can't be
|
||||
re-filed by dragging it).
|
||||
* dropped on Running -> runs it now (counts as manual approval).
|
||||
* dropped on Done -> marks it done AND disables its schedule, so a
|
||||
repeating task marked done by hand doesn't quietly re-fire later.
|
||||
* dropped on Scheduled with no ``run_at`` set yet -> saved as-is but
|
||||
flagged ``needs_schedule`` — the caller should open the editor
|
||||
rather than leave a Scheduled card that will never actually run.
|
||||
* anything else -> plain status change.
|
||||
"""
|
||||
task = self._repository.get(task_id)
|
||||
if task is None:
|
||||
return None
|
||||
if task.get("status") == "running":
|
||||
return MoveResult(task=task, blocked=True)
|
||||
if new_status == "running":
|
||||
result = self.run_now(task_id)
|
||||
return MoveResult(task=self._repository.get(task_id), ran_now=True, run_now_result=result)
|
||||
if new_status == "done":
|
||||
task["status"] = "done"
|
||||
task["schedule"]["enabled"] = False
|
||||
self._repository.save(task)
|
||||
return MoveResult(task=task)
|
||||
task["status"] = new_status
|
||||
if new_status == "scheduled" and not task["schedule"].get("enabled"):
|
||||
if task["schedule"].get("run_at"):
|
||||
task["schedule"]["enabled"] = True
|
||||
else:
|
||||
self._repository.save(task)
|
||||
return MoveResult(task=task, needs_schedule=True)
|
||||
self._repository.save(task)
|
||||
return MoveResult(task=task)
|
||||
|
||||
|
||||
__all__ = ["TaskApplicationService", "RunNowResult", "MoveResult"]
|
||||
Reference in New Issue
Block a user