Feature/delta team/epic r04 (#7)
CI / test (push) Canceled after 0s

## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
This commit was merged in pull request #7.
This commit is contained in:
2026-08-31 05:15:13 +00:00
committed by gitea-admin
co-authored by anhtnm1 huongltt35 Nam Pham Dinh Thanh vudt15 Hiep Ha Van lamhv7
parent 86c27e2e79
commit f9f6bc01fd
496 changed files with 68421 additions and 19688 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,100 @@
"""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:
"""``provider_factory`` là hàm dựng provider, gọi lúc cần chứ không dựng sẵn —
provider có thể bị đổi giữa hai lần lập kế hoạch.
"""
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:
"""Provider dùng để lập kế hoạch; chưa cấu hình thì báo lỗi rõ ràng ngay tại
đây thay vì để lỗi nổ ra ở tận tầng HTTP.
"""
if self._provider_factory is None:
raise RuntimeError("No provider available to plan tasks.")
return self._provider_factory()
__all__ = ["AiTaskPlannerService"]
@@ -0,0 +1,176 @@
"""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:
"""``run_now`` để None thì service chỉ đọc/ghi task, không chạy được cái nào —
đúng cho ngữ cảnh không có scheduler (test, hay màn chỉ xem).
"""
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:
"""Xoá một task; trả về ``False`` nếu id không tồn tại."""
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"]