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:
2026-08-27 17:27:53 +09:00
co-authored by Claude Sonnet 5
parent 8ab29800db
commit 69ab8e125b
20 changed files with 1347 additions and 95 deletions
+6
View File
@@ -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"]