feat: annuleren taak toegevoegd
This commit is contained in:
@@ -129,6 +129,40 @@ Response shape:
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/tasks/{task_id}/cancel`
|
||||
Success for cancellable file-action task:
|
||||
```json
|
||||
{
|
||||
"id": "<uuid>",
|
||||
"operation": "copy",
|
||||
"status": "cancelling",
|
||||
"source": "2 items",
|
||||
"destination": "storage1/dest",
|
||||
"done_bytes": null,
|
||||
"total_bytes": null,
|
||||
"done_items": 0,
|
||||
"total_items": 2,
|
||||
"current_item": "storage1/a.txt",
|
||||
"failed_item": null,
|
||||
"error_code": null,
|
||||
"error_message": null,
|
||||
"created_at": "2026-03-10T10:00:00Z",
|
||||
"started_at": "2026-03-10T10:00:01Z",
|
||||
"finished_at": null
|
||||
}
|
||||
```
|
||||
|
||||
Not cancellable:
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "task_not_cancellable",
|
||||
"message": "Task cannot be cancelled",
|
||||
"details": { "task_id": "<uuid>", "status": "completed" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Task not found:
|
||||
```json
|
||||
{
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, status
|
||||
|
||||
from backend.app.api.schemas import TaskDetailResponse, TaskListResponse
|
||||
from backend.app.dependencies import get_task_service
|
||||
@@ -17,3 +17,8 @@ async def list_tasks(service: TaskService = Depends(get_task_service)) -> TaskLi
|
||||
@router.get("/{task_id}", response_model=TaskDetailResponse)
|
||||
async def get_task(task_id: str, service: TaskService = Depends(get_task_service)) -> TaskDetailResponse:
|
||||
return service.get_task(task_id)
|
||||
|
||||
|
||||
@router.post("/{task_id}/cancel", response_model=TaskDetailResponse, status_code=status.HTTP_200_OK)
|
||||
async def cancel_task(task_id: str, service: TaskService = Depends(get_task_service)) -> TaskDetailResponse:
|
||||
return service.cancel_task(task_id)
|
||||
|
||||
Binary file not shown.
@@ -6,9 +6,9 @@ from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
VALID_STATUSES = {"queued", "running", "completed", "failed", "requested", "preparing", "ready", "cancelled"}
|
||||
VALID_STATUSES = {"queued", "running", "cancelling", "completed", "failed", "requested", "preparing", "ready", "cancelled"}
|
||||
VALID_OPERATIONS = {"copy", "move", "download", "duplicate", "delete"}
|
||||
NON_TERMINAL_STATUSES = ("queued", "running", "requested", "preparing")
|
||||
NON_TERMINAL_STATUSES = ("queued", "running", "cancelling", "requested", "preparing")
|
||||
TASK_MIGRATION_COLUMNS: dict[str, str] = {
|
||||
"operation": "TEXT NOT NULL DEFAULT 'copy'",
|
||||
"status": "TEXT NOT NULL DEFAULT 'queued'",
|
||||
@@ -143,17 +143,18 @@ class TaskRepository:
|
||||
done_items: int | None = None,
|
||||
total_items: int | None = None,
|
||||
current_item: str | None = None,
|
||||
) -> None:
|
||||
) -> bool:
|
||||
started_at = self._now_iso()
|
||||
with self._connection() as conn:
|
||||
conn.execute(
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status = ?, started_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = ?
|
||||
WHERE id = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""",
|
||||
("running", started_at, done_bytes, total_bytes, done_items, total_items, current_item, task_id),
|
||||
("running", started_at, done_bytes, total_bytes, done_items, total_items, current_item, task_id, "queued"),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def mark_preparing(
|
||||
self,
|
||||
@@ -200,17 +201,18 @@ class TaskRepository:
|
||||
total_bytes: int | None = None,
|
||||
done_items: int | None = None,
|
||||
total_items: int | None = None,
|
||||
) -> None:
|
||||
) -> bool:
|
||||
finished_at = self._now_iso()
|
||||
with self._connection() as conn:
|
||||
conn.execute(
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status = ?, finished_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?
|
||||
WHERE id = ?
|
||||
SET status = ?, finished_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = NULL
|
||||
WHERE id = ? AND status = ?
|
||||
""",
|
||||
("completed", finished_at, done_bytes, total_bytes, done_items, total_items, task_id),
|
||||
("completed", finished_at, done_bytes, total_bytes, done_items, total_items, task_id, "running"),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def mark_ready(
|
||||
self,
|
||||
@@ -311,6 +313,49 @@ class TaskRepository:
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def request_cancellation(self, task_id: str) -> dict | None:
|
||||
finished_at = self._now_iso()
|
||||
with self._connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status = ?, finished_at = ?, current_item = NULL
|
||||
WHERE id = ? AND status = ?
|
||||
""",
|
||||
("cancelled", finished_at, task_id, "queued"),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""",
|
||||
("cancelling", task_id, "running"),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
|
||||
return self._to_dict(row) if row else None
|
||||
|
||||
def finalize_cancelled(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
done_bytes: int | None = None,
|
||||
total_bytes: int | None = None,
|
||||
done_items: int | None = None,
|
||||
total_items: int | None = None,
|
||||
) -> bool:
|
||||
finished_at = self._now_iso()
|
||||
with self._connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status = ?, finished_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = NULL
|
||||
WHERE id = ? AND status IN (?, ?)
|
||||
""",
|
||||
("cancelled", finished_at, done_bytes, total_bytes, done_items, total_items, task_id, "cancelling", "queued"),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
db_path = Path(self._db_path)
|
||||
if db_path.parent and str(db_path.parent) not in {"", "."}:
|
||||
|
||||
@@ -102,7 +102,7 @@ async def get_archive_download_task_service() -> ArchiveDownloadTaskService:
|
||||
|
||||
|
||||
async def get_task_service() -> TaskService:
|
||||
return TaskService(repository=get_task_repository())
|
||||
return TaskService(repository=get_task_repository(), history_repository=get_history_repository())
|
||||
|
||||
|
||||
async def get_copy_task_service() -> CopyTaskService:
|
||||
|
||||
Binary file not shown.
@@ -2,12 +2,16 @@ from __future__ import annotations
|
||||
|
||||
from backend.app.api.errors import AppError
|
||||
from backend.app.api.schemas import TaskDetailResponse, TaskListItem, TaskListResponse
|
||||
from backend.app.db.history_repository import HistoryRepository
|
||||
from backend.app.db.task_repository import TaskRepository
|
||||
|
||||
FILE_ACTION_CANCELLABLE_OPERATIONS = {"copy", "move", "duplicate", "delete"}
|
||||
|
||||
|
||||
class TaskService:
|
||||
def __init__(self, repository: TaskRepository):
|
||||
def __init__(self, repository: TaskRepository, history_repository: HistoryRepository | None = None):
|
||||
self._repository = repository
|
||||
self._history_repository = history_repository
|
||||
|
||||
def create_task(self, operation: str, source: str, destination: str) -> TaskDetailResponse:
|
||||
task = self._repository.create_task(operation=operation, source=source, destination=destination)
|
||||
@@ -40,3 +44,41 @@ class TaskService:
|
||||
for task in tasks
|
||||
]
|
||||
)
|
||||
|
||||
def cancel_task(self, task_id: str) -> TaskDetailResponse:
|
||||
task = self._repository.get_task(task_id)
|
||||
if not task:
|
||||
raise AppError(
|
||||
code="task_not_found",
|
||||
message="Task was not found",
|
||||
status_code=404,
|
||||
details={"task_id": task_id},
|
||||
)
|
||||
if task["operation"] not in FILE_ACTION_CANCELLABLE_OPERATIONS:
|
||||
raise AppError(
|
||||
code="task_not_cancellable",
|
||||
message="Task cannot be cancelled",
|
||||
status_code=409,
|
||||
details={"task_id": task_id, "status": task["status"]},
|
||||
)
|
||||
if task["status"] not in {"queued", "running", "cancelling"}:
|
||||
raise AppError(
|
||||
code="task_not_cancellable",
|
||||
message="Task cannot be cancelled",
|
||||
status_code=409,
|
||||
details={"task_id": task_id, "status": task["status"]},
|
||||
)
|
||||
|
||||
updated = self._repository.request_cancellation(task_id)
|
||||
if not updated:
|
||||
raise AppError(
|
||||
code="task_not_cancellable",
|
||||
message="Task cannot be cancelled",
|
||||
status_code=409,
|
||||
details={"task_id": task_id, "status": task["status"]},
|
||||
)
|
||||
|
||||
if updated["status"] == "cancelled" and self._history_repository:
|
||||
self._history_repository.update_entry(entry_id=task_id, status="cancelled")
|
||||
|
||||
return TaskDetailResponse(**updated)
|
||||
|
||||
@@ -95,12 +95,14 @@ class TaskRunner:
|
||||
thread.start()
|
||||
|
||||
def _run_copy_file(self, task_id: str, source: str, destination: str, total_bytes: int) -> None:
|
||||
self._repository.mark_running(
|
||||
if not self._repository.mark_running(
|
||||
task_id=task_id,
|
||||
done_bytes=0,
|
||||
total_bytes=total_bytes,
|
||||
current_item=source,
|
||||
)
|
||||
):
|
||||
self._finalize_if_already_cancelled(task_id, done_bytes=0, total_bytes=total_bytes)
|
||||
return
|
||||
|
||||
progress = {"done": 0}
|
||||
|
||||
@@ -115,12 +117,11 @@ class TaskRunner:
|
||||
|
||||
try:
|
||||
self._filesystem.copy_file(source=source, destination=destination, on_progress=on_progress)
|
||||
self._repository.mark_completed(
|
||||
self._complete_or_cancel_file_task(
|
||||
task_id=task_id,
|
||||
done_bytes=total_bytes,
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
self._update_history_completed(task_id)
|
||||
except OSError as exc:
|
||||
self._repository.mark_failed(
|
||||
task_id=task_id,
|
||||
@@ -133,21 +134,22 @@ class TaskRunner:
|
||||
self._update_history_failed(task_id, str(exc))
|
||||
|
||||
def _run_copy_directory(self, task_id: str, source: str, destination: str) -> None:
|
||||
self._repository.mark_running(
|
||||
if not self._repository.mark_running(
|
||||
task_id=task_id,
|
||||
done_items=0,
|
||||
total_items=1,
|
||||
current_item=source,
|
||||
)
|
||||
):
|
||||
self._finalize_if_already_cancelled(task_id, done_items=0, total_items=1)
|
||||
return
|
||||
|
||||
try:
|
||||
self._filesystem.copy_directory(source=source, destination=destination)
|
||||
self._repository.mark_completed(
|
||||
self._complete_or_cancel_item_task(
|
||||
task_id=task_id,
|
||||
done_items=1,
|
||||
total_items=1,
|
||||
)
|
||||
self._update_history_completed(task_id)
|
||||
except OSError as exc:
|
||||
self._repository.mark_failed(
|
||||
task_id=task_id,
|
||||
@@ -164,15 +166,20 @@ class TaskRunner:
|
||||
def _run_copy_batch(self, task_id: str, items: list[dict[str, str]]) -> None:
|
||||
total_items = len(items)
|
||||
current_item = items[0]["source"] if items else None
|
||||
self._repository.mark_running(
|
||||
if not self._repository.mark_running(
|
||||
task_id=task_id,
|
||||
done_items=0,
|
||||
total_items=total_items,
|
||||
current_item=current_item,
|
||||
)
|
||||
):
|
||||
self._finalize_if_already_cancelled(task_id, done_items=0, total_items=total_items)
|
||||
return
|
||||
|
||||
completed_items = 0
|
||||
for index, item in enumerate(items):
|
||||
if self._is_cancel_requested(task_id):
|
||||
self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items)
|
||||
return
|
||||
source = item["source"]
|
||||
destination = item["destination"]
|
||||
try:
|
||||
@@ -188,6 +195,9 @@ class TaskRunner:
|
||||
total_items=total_items,
|
||||
current_item=next_item,
|
||||
)
|
||||
if self._is_cancel_requested(task_id):
|
||||
self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items)
|
||||
return
|
||||
except OSError as exc:
|
||||
self._repository.mark_failed(
|
||||
task_id=task_id,
|
||||
@@ -202,12 +212,11 @@ class TaskRunner:
|
||||
self._update_history_failed(task_id, str(exc))
|
||||
return
|
||||
|
||||
self._repository.mark_completed(
|
||||
self._complete_or_cancel_item_task(
|
||||
task_id=task_id,
|
||||
done_items=total_items,
|
||||
total_items=total_items,
|
||||
)
|
||||
self._update_history_completed(task_id)
|
||||
|
||||
def _run_move_file(
|
||||
self,
|
||||
@@ -217,24 +226,25 @@ class TaskRunner:
|
||||
total_bytes: int,
|
||||
same_root: bool,
|
||||
) -> None:
|
||||
self._repository.mark_running(
|
||||
if not self._repository.mark_running(
|
||||
task_id=task_id,
|
||||
done_bytes=0,
|
||||
total_bytes=total_bytes,
|
||||
current_item=source,
|
||||
)
|
||||
):
|
||||
self._finalize_if_already_cancelled(task_id, done_bytes=0, total_bytes=total_bytes)
|
||||
return
|
||||
|
||||
progress = {"done": 0}
|
||||
|
||||
try:
|
||||
if same_root:
|
||||
self._filesystem.move_file(source=source, destination=destination)
|
||||
self._repository.mark_completed(
|
||||
self._complete_or_cancel_file_task(
|
||||
task_id=task_id,
|
||||
done_bytes=total_bytes,
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
self._update_history_completed(task_id)
|
||||
return
|
||||
|
||||
def on_progress(done_bytes: int) -> None:
|
||||
@@ -248,12 +258,11 @@ class TaskRunner:
|
||||
|
||||
self._filesystem.copy_file(source=source, destination=destination, on_progress=on_progress)
|
||||
self._filesystem.delete_file(Path(source))
|
||||
self._repository.mark_completed(
|
||||
self._complete_or_cancel_file_task(
|
||||
task_id=task_id,
|
||||
done_bytes=total_bytes,
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
self._update_history_completed(task_id)
|
||||
except OSError as exc:
|
||||
self._repository.mark_failed(
|
||||
task_id=task_id,
|
||||
@@ -266,21 +275,22 @@ class TaskRunner:
|
||||
self._update_history_failed(task_id, str(exc))
|
||||
|
||||
def _run_move_directory(self, task_id: str, source: str, destination: str) -> None:
|
||||
self._repository.mark_running(
|
||||
if not self._repository.mark_running(
|
||||
task_id=task_id,
|
||||
done_items=0,
|
||||
total_items=1,
|
||||
current_item=source,
|
||||
)
|
||||
):
|
||||
self._finalize_if_already_cancelled(task_id, done_items=0, total_items=1)
|
||||
return
|
||||
|
||||
try:
|
||||
self._filesystem.move_directory(source=source, destination=destination)
|
||||
self._repository.mark_completed(
|
||||
self._complete_or_cancel_item_task(
|
||||
task_id=task_id,
|
||||
done_items=1,
|
||||
total_items=1,
|
||||
)
|
||||
self._update_history_completed(task_id)
|
||||
except OSError as exc:
|
||||
self._repository.mark_failed(
|
||||
task_id=task_id,
|
||||
@@ -295,15 +305,20 @@ class TaskRunner:
|
||||
def _run_move_batch(self, task_id: str, items: list[dict[str, str]]) -> None:
|
||||
total_items = len(items)
|
||||
current_item = items[0]["source"] if items else None
|
||||
self._repository.mark_running(
|
||||
if not self._repository.mark_running(
|
||||
task_id=task_id,
|
||||
done_items=0,
|
||||
total_items=total_items,
|
||||
current_item=current_item,
|
||||
)
|
||||
):
|
||||
self._finalize_if_already_cancelled(task_id, done_items=0, total_items=total_items)
|
||||
return
|
||||
|
||||
completed_items = 0
|
||||
for index, item in enumerate(items):
|
||||
if self._is_cancel_requested(task_id):
|
||||
self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items)
|
||||
return
|
||||
source = item["source"]
|
||||
destination = item["destination"]
|
||||
try:
|
||||
@@ -319,6 +334,9 @@ class TaskRunner:
|
||||
total_items=total_items,
|
||||
current_item=next_item,
|
||||
)
|
||||
if self._is_cancel_requested(task_id):
|
||||
self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items)
|
||||
return
|
||||
except OSError as exc:
|
||||
self._repository.mark_failed(
|
||||
task_id=task_id,
|
||||
@@ -333,25 +351,29 @@ class TaskRunner:
|
||||
self._update_history_failed(task_id, str(exc))
|
||||
return
|
||||
|
||||
self._repository.mark_completed(
|
||||
self._complete_or_cancel_item_task(
|
||||
task_id=task_id,
|
||||
done_items=total_items,
|
||||
total_items=total_items,
|
||||
)
|
||||
self._update_history_completed(task_id)
|
||||
|
||||
def _run_duplicate_batch(self, task_id: str, items: list[dict[str, str]]) -> None:
|
||||
total_items = len(items)
|
||||
current_item = items[0]["source"] if items else None
|
||||
self._repository.mark_running(
|
||||
if not self._repository.mark_running(
|
||||
task_id=task_id,
|
||||
done_items=0,
|
||||
total_items=total_items,
|
||||
current_item=current_item,
|
||||
)
|
||||
):
|
||||
self._finalize_if_already_cancelled(task_id, done_items=0, total_items=total_items)
|
||||
return
|
||||
|
||||
completed_items = 0
|
||||
for index, item in enumerate(items):
|
||||
if self._is_cancel_requested(task_id):
|
||||
self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items)
|
||||
return
|
||||
source = item["source"]
|
||||
destination = item["destination"]
|
||||
try:
|
||||
@@ -367,6 +389,9 @@ class TaskRunner:
|
||||
total_items=total_items,
|
||||
current_item=next_item,
|
||||
)
|
||||
if self._is_cancel_requested(task_id):
|
||||
self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items)
|
||||
return
|
||||
except OSError as exc:
|
||||
self._cleanup_partial_duplicate(Path(destination))
|
||||
self._repository.mark_failed(
|
||||
@@ -382,20 +407,21 @@ class TaskRunner:
|
||||
self._update_history_failed(task_id, str(exc))
|
||||
return
|
||||
|
||||
self._repository.mark_completed(
|
||||
self._complete_or_cancel_item_task(
|
||||
task_id=task_id,
|
||||
done_items=total_items,
|
||||
total_items=total_items,
|
||||
)
|
||||
self._update_history_completed(task_id)
|
||||
|
||||
def _run_delete_path(self, task_id: str, target: str, kind: str, recursive: bool) -> None:
|
||||
self._repository.mark_running(
|
||||
if not self._repository.mark_running(
|
||||
task_id=task_id,
|
||||
done_items=0,
|
||||
total_items=1,
|
||||
current_item=target,
|
||||
)
|
||||
):
|
||||
self._finalize_if_already_cancelled(task_id, done_items=0, total_items=1)
|
||||
return
|
||||
|
||||
try:
|
||||
path = Path(target)
|
||||
@@ -405,12 +431,11 @@ class TaskRunner:
|
||||
self._filesystem.delete_directory_recursive(path)
|
||||
else:
|
||||
self._filesystem.delete_empty_directory(path)
|
||||
self._repository.mark_completed(
|
||||
self._complete_or_cancel_item_task(
|
||||
task_id=task_id,
|
||||
done_items=1,
|
||||
total_items=1,
|
||||
)
|
||||
self._update_history_completed(task_id)
|
||||
except OSError as exc:
|
||||
self._repository.mark_failed(
|
||||
task_id=task_id,
|
||||
@@ -466,6 +491,88 @@ class TaskRunner:
|
||||
return
|
||||
path.unlink()
|
||||
|
||||
def _is_cancel_requested(self, task_id: str) -> bool:
|
||||
task = self._repository.get_task(task_id)
|
||||
return bool(task) and task["status"] == "cancelling"
|
||||
|
||||
def _finalize_if_already_cancelled(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
done_bytes: int | None = None,
|
||||
total_bytes: int | None = None,
|
||||
done_items: int | None = None,
|
||||
total_items: int | None = None,
|
||||
) -> None:
|
||||
task = self._repository.get_task(task_id)
|
||||
if task and task["status"] == "cancelled":
|
||||
self._update_history_cancelled(task_id)
|
||||
return
|
||||
if task and task["status"] == "cancelling":
|
||||
self._finalize_cancelled(
|
||||
task_id,
|
||||
done_bytes=done_bytes,
|
||||
total_bytes=total_bytes,
|
||||
done_items=done_items,
|
||||
total_items=total_items,
|
||||
)
|
||||
|
||||
def _complete_or_cancel_file_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
done_bytes: int | None,
|
||||
total_bytes: int | None,
|
||||
) -> None:
|
||||
if self._is_cancel_requested(task_id):
|
||||
self._finalize_cancelled(task_id, done_bytes=done_bytes, total_bytes=total_bytes)
|
||||
return
|
||||
if self._repository.mark_completed(
|
||||
task_id=task_id,
|
||||
done_bytes=done_bytes,
|
||||
total_bytes=total_bytes,
|
||||
):
|
||||
self._update_history_completed(task_id)
|
||||
return
|
||||
self._finalize_if_already_cancelled(task_id, done_bytes=done_bytes, total_bytes=total_bytes)
|
||||
|
||||
def _complete_or_cancel_item_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
done_items: int | None,
|
||||
total_items: int | None,
|
||||
) -> None:
|
||||
if self._is_cancel_requested(task_id):
|
||||
self._finalize_cancelled(task_id, done_items=done_items, total_items=total_items)
|
||||
return
|
||||
if self._repository.mark_completed(
|
||||
task_id=task_id,
|
||||
done_items=done_items,
|
||||
total_items=total_items,
|
||||
):
|
||||
self._update_history_completed(task_id)
|
||||
return
|
||||
self._finalize_if_already_cancelled(task_id, done_items=done_items, total_items=total_items)
|
||||
|
||||
def _finalize_cancelled(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
done_bytes: int | None = None,
|
||||
total_bytes: int | None = None,
|
||||
done_items: int | None = None,
|
||||
total_items: int | None = None,
|
||||
) -> None:
|
||||
if self._repository.finalize_cancelled(
|
||||
task_id=task_id,
|
||||
done_bytes=done_bytes,
|
||||
total_bytes=total_bytes,
|
||||
done_items=done_items,
|
||||
total_items=total_items,
|
||||
):
|
||||
self._update_history_cancelled(task_id)
|
||||
|
||||
def _update_history_completed(self, task_id: str) -> None:
|
||||
if self._history_repository:
|
||||
self._history_repository.update_entry(entry_id=task_id, status="completed")
|
||||
@@ -478,3 +585,7 @@ class TaskRunner:
|
||||
error_code="io_error",
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
def _update_history_cancelled(self, task_id: str) -> None:
|
||||
if self._history_repository:
|
||||
self._history_repository.update_entry(entry_id=task_id, status="cancelled")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -29,6 +30,18 @@ class FailingFilesystemAdapter(FilesystemAdapter):
|
||||
raise OSError("forced copy failure")
|
||||
|
||||
|
||||
class BlockingCopyFilesystemAdapter(FilesystemAdapter):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.entered = threading.Event()
|
||||
self.release = threading.Event()
|
||||
|
||||
def copy_file(self, source: str, destination: str, on_progress: callable | None = None) -> None:
|
||||
self.entered.set()
|
||||
self.release.wait(timeout=2.0)
|
||||
return super().copy_file(source=source, destination=destination, on_progress=on_progress)
|
||||
|
||||
|
||||
class CopyApiGoldenTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
@@ -72,11 +85,21 @@ class CopyApiGoldenTest(unittest.TestCase):
|
||||
while time.time() < deadline:
|
||||
response = self._request("GET", f"/api/tasks/{task_id}")
|
||||
body = response.json()
|
||||
if body["status"] in {"completed", "failed"}:
|
||||
if body["status"] in {"completed", "failed", "cancelled"}:
|
||||
return body
|
||||
time.sleep(0.02)
|
||||
self.fail("task did not reach terminal state in time")
|
||||
|
||||
def _wait_for_status(self, task_id: str, statuses: set[str], timeout_s: float = 2.0) -> dict:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
response = self._request("GET", f"/api/tasks/{task_id}")
|
||||
body = response.json()
|
||||
if body["status"] in statuses:
|
||||
return body
|
||||
time.sleep(0.02)
|
||||
self.fail(f"task did not reach one of {sorted(statuses)} in time")
|
||||
|
||||
def test_copy_success_create_task_shape(self) -> None:
|
||||
src = self.root / "source.txt"
|
||||
src.write_text("hello", encoding="utf-8")
|
||||
@@ -189,6 +212,40 @@ class CopyApiGoldenTest(unittest.TestCase):
|
||||
self.assertEqual((self.root / "dest" / "file.txt").read_text(encoding="utf-8"), "F")
|
||||
self.assertEqual((self.root / "dest" / "docs" / "nested" / "note.txt").read_text(encoding="utf-8"), "N")
|
||||
|
||||
def test_copy_batch_cancelled_after_current_file_finishes(self) -> None:
|
||||
blocking_fs = BlockingCopyFilesystemAdapter()
|
||||
path_guard = PathGuard({"storage1": str(self.root), "storage2": str(self.root)})
|
||||
self._set_services(path_guard=path_guard, filesystem=blocking_fs)
|
||||
(self.root / "a.txt").write_text("A", encoding="utf-8")
|
||||
(self.root / "b.txt").write_text("B", encoding="utf-8")
|
||||
(self.root / "dest").mkdir()
|
||||
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/api/files/copy",
|
||||
{
|
||||
"sources": ["storage1/a.txt", "storage1/b.txt"],
|
||||
"destination_base": "storage1/dest",
|
||||
},
|
||||
)
|
||||
|
||||
task_id = response.json()["task_id"]
|
||||
self.assertTrue(blocking_fs.entered.wait(timeout=2.0))
|
||||
running = self._wait_for_status(task_id, {"running"})
|
||||
self.assertEqual(running["current_item"], str(self.root / "a.txt"))
|
||||
|
||||
cancel_response = self._request("POST", f"/api/tasks/{task_id}/cancel")
|
||||
self.assertEqual(cancel_response.status_code, 200)
|
||||
self.assertEqual(cancel_response.json()["status"], "cancelling")
|
||||
|
||||
blocking_fs.release.set()
|
||||
detail = self._wait_task(task_id)
|
||||
self.assertEqual(detail["status"], "cancelled")
|
||||
self.assertEqual(detail["done_items"], 1)
|
||||
self.assertEqual(detail["total_items"], 2)
|
||||
self.assertTrue((self.root / "dest" / "a.txt").exists())
|
||||
self.assertFalse((self.root / "dest" / "b.txt").exists())
|
||||
|
||||
def test_copy_source_not_found(self) -> None:
|
||||
response = self._request(
|
||||
"POST",
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -33,6 +34,18 @@ class FailOnSecondCopyFilesystemAdapter(FilesystemAdapter):
|
||||
super().copy_file(source=source, destination=destination, on_progress=on_progress)
|
||||
|
||||
|
||||
class BlockingDuplicateFilesystemAdapter(FilesystemAdapter):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.entered = threading.Event()
|
||||
self.release = threading.Event()
|
||||
|
||||
def copy_file(self, source: str, destination: str, on_progress: callable | None = None) -> None:
|
||||
self.entered.set()
|
||||
self.release.wait(timeout=2.0)
|
||||
super().copy_file(source=source, destination=destination, on_progress=on_progress)
|
||||
|
||||
|
||||
class DuplicateApiGoldenTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
@@ -75,11 +88,21 @@ class DuplicateApiGoldenTest(unittest.TestCase):
|
||||
while time.time() < deadline:
|
||||
response = self._request("GET", f"/api/tasks/{task_id}")
|
||||
body = response.json()
|
||||
if body["status"] in {"completed", "failed"}:
|
||||
if body["status"] in {"completed", "failed", "cancelled"}:
|
||||
return body
|
||||
time.sleep(0.02)
|
||||
self.fail("task did not reach terminal state in time")
|
||||
|
||||
def _wait_for_status(self, task_id: str, statuses: set[str], timeout_s: float = 2.0) -> dict:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
response = self._request("GET", f"/api/tasks/{task_id}")
|
||||
body = response.json()
|
||||
if body["status"] in statuses:
|
||||
return body
|
||||
time.sleep(0.02)
|
||||
self.fail(f"task did not reach one of {sorted(statuses)} in time")
|
||||
|
||||
def test_duplicate_single_file_success(self) -> None:
|
||||
(self.root / "note.txt").write_text("hello", encoding="utf-8")
|
||||
|
||||
@@ -132,6 +155,36 @@ class DuplicateApiGoldenTest(unittest.TestCase):
|
||||
self.assertEqual((self.root / "a copy.txt").read_text(encoding="utf-8"), "A")
|
||||
self.assertEqual((self.root / "docs copy" / "nested" / "b.txt").read_text(encoding="utf-8"), "B")
|
||||
|
||||
def test_duplicate_multi_select_cancelled_after_current_item_finishes(self) -> None:
|
||||
blocking_fs = BlockingDuplicateFilesystemAdapter()
|
||||
path_guard = PathGuard({"storage1": str(self.root), "storage2": str(self.root)})
|
||||
self._set_services(path_guard=path_guard, filesystem=blocking_fs)
|
||||
(self.root / "a.txt").write_text("A", encoding="utf-8")
|
||||
(self.root / "b.txt").write_text("B", encoding="utf-8")
|
||||
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/api/files/duplicate",
|
||||
{"paths": ["storage1/a.txt", "storage1/b.txt"]},
|
||||
)
|
||||
|
||||
task_id = response.json()["task_id"]
|
||||
self.assertTrue(blocking_fs.entered.wait(timeout=2.0))
|
||||
running = self._wait_for_status(task_id, {"running"})
|
||||
self.assertEqual(running["current_item"], str(self.root / "a.txt"))
|
||||
|
||||
cancel_response = self._request("POST", f"/api/tasks/{task_id}/cancel")
|
||||
self.assertEqual(cancel_response.status_code, 200)
|
||||
self.assertEqual(cancel_response.json()["status"], "cancelling")
|
||||
|
||||
blocking_fs.release.set()
|
||||
detail = self._wait_task(task_id)
|
||||
self.assertEqual(detail["status"], "cancelled")
|
||||
self.assertEqual(detail["done_items"], 1)
|
||||
self.assertEqual(detail["total_items"], 2)
|
||||
self.assertTrue((self.root / "a copy.txt").exists())
|
||||
self.assertFalse((self.root / "b copy.txt").exists())
|
||||
|
||||
def test_duplicate_collision_resolution_for_files_and_directories(self) -> None:
|
||||
(self.root / "report.txt").write_text("R", encoding="utf-8")
|
||||
(self.root / "report copy.txt").write_text("existing", encoding="utf-8")
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -22,6 +23,18 @@ from backend.app.services.task_service import TaskService
|
||||
from backend.app.tasks_runner import TaskRunner
|
||||
|
||||
|
||||
class BlockingDeleteFilesystemAdapter(FilesystemAdapter):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.entered = threading.Event()
|
||||
self.release = threading.Event()
|
||||
|
||||
def delete_file(self, path: Path) -> None:
|
||||
self.entered.set()
|
||||
self.release.wait(timeout=2.0)
|
||||
super().delete_file(path)
|
||||
|
||||
|
||||
class FileOpsApiGoldenTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
@@ -84,11 +97,21 @@ class FileOpsApiGoldenTest(unittest.TestCase):
|
||||
while time.time() < deadline:
|
||||
response = self._get(f"/api/tasks/{task_id}")
|
||||
body = response.json()
|
||||
if body["status"] in {"completed", "failed"}:
|
||||
if body["status"] in {"completed", "failed", "cancelled"}:
|
||||
return body
|
||||
time.sleep(0.02)
|
||||
self.fail("task did not reach terminal state in time")
|
||||
|
||||
def _wait_for_status(self, task_id: str, statuses: set[str], timeout_s: float = 2.0) -> dict:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
response = self._get(f"/api/tasks/{task_id}")
|
||||
body = response.json()
|
||||
if body["status"] in statuses:
|
||||
return body
|
||||
time.sleep(0.02)
|
||||
self.fail(f"task did not reach one of {sorted(statuses)} in time")
|
||||
|
||||
def test_mkdir_success(self) -> None:
|
||||
response = self._post(
|
||||
"/api/files/mkdir",
|
||||
@@ -272,6 +295,54 @@ class FileOpsApiGoldenTest(unittest.TestCase):
|
||||
self.assertEqual(detail["status"], "completed")
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_delete_file_cancelled_after_current_delete_finishes(self) -> None:
|
||||
blocking_fs = BlockingDeleteFilesystemAdapter()
|
||||
path_guard = PathGuard({"storage1": str(self.root)})
|
||||
service = FileOpsService(path_guard=path_guard, filesystem=blocking_fs)
|
||||
delete_service = DeleteTaskService(
|
||||
path_guard=path_guard,
|
||||
repository=self.repo,
|
||||
runner=TaskRunner(repository=self.repo, filesystem=blocking_fs),
|
||||
)
|
||||
task_service = TaskService(repository=self.repo)
|
||||
|
||||
async def _override_file_ops_service() -> FileOpsService:
|
||||
return service
|
||||
|
||||
async def _override_delete_task_service() -> DeleteTaskService:
|
||||
return delete_service
|
||||
|
||||
async def _override_task_service() -> TaskService:
|
||||
return task_service
|
||||
|
||||
app.dependency_overrides[get_file_ops_service] = _override_file_ops_service
|
||||
app.dependency_overrides[get_delete_task_service] = _override_delete_task_service
|
||||
app.dependency_overrides[get_task_service] = _override_task_service
|
||||
|
||||
target = self.scope / "delete_later.txt"
|
||||
target.write_text("z", encoding="utf-8")
|
||||
|
||||
response = self._post(
|
||||
"/api/files/delete",
|
||||
{"path": "storage1/scope/delete_later.txt"},
|
||||
)
|
||||
|
||||
task_id = response.json()["task_id"]
|
||||
self.assertTrue(blocking_fs.entered.wait(timeout=2.0))
|
||||
running = self._wait_for_status(task_id, {"running"})
|
||||
self.assertEqual(running["current_item"], str(self.scope / "delete_later.txt"))
|
||||
|
||||
cancel_response = self._post(f"/api/tasks/{task_id}/cancel", {})
|
||||
self.assertEqual(cancel_response.status_code, 200)
|
||||
self.assertEqual(cancel_response.json()["status"], "cancelling")
|
||||
|
||||
blocking_fs.release.set()
|
||||
detail = self._wait_task(task_id)
|
||||
self.assertEqual(detail["status"], "cancelled")
|
||||
self.assertEqual(detail["done_items"], 1)
|
||||
self.assertEqual(detail["total_items"], 1)
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_delete_empty_directory_success(self) -> None:
|
||||
target = self.scope / "empty_dir"
|
||||
target.mkdir()
|
||||
|
||||
@@ -49,6 +49,18 @@ class BlockingArchiveBuildFileOpsService(FileOpsService):
|
||||
super()._write_download_target_to_zip(archive, resolved_target, on_each_item=on_each_item)
|
||||
|
||||
|
||||
class BlockingCopyFilesystemAdapter(FilesystemAdapter):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.entered = threading.Event()
|
||||
self.release = threading.Event()
|
||||
|
||||
def copy_file(self, source: str, destination: str, on_progress=None) -> None:
|
||||
self.entered.set()
|
||||
self.release.wait(timeout=2.0)
|
||||
return super().copy_file(source=source, destination=destination, on_progress=on_progress)
|
||||
|
||||
|
||||
class HistoryApiGoldenTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
@@ -82,7 +94,7 @@ class HistoryApiGoldenTest(unittest.TestCase):
|
||||
delete_service = DeleteTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo)
|
||||
duplicate_service = DuplicateTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo)
|
||||
move_service = MoveTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo)
|
||||
task_service = TaskService(repository=self.task_repo)
|
||||
task_service = TaskService(repository=self.task_repo, history_repository=self.history_repo)
|
||||
history_service = HistoryService(repository=self.history_repo)
|
||||
|
||||
async def _override_file_ops_service() -> FileOpsService:
|
||||
@@ -138,6 +150,16 @@ class HistoryApiGoldenTest(unittest.TestCase):
|
||||
time.sleep(0.02)
|
||||
self.fail('task did not reach terminal state in time')
|
||||
|
||||
def _wait_for_status(self, task_id: str, statuses: set[str], timeout_s: float = 2.0) -> dict:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
response = self._request('GET', f'/api/tasks/{task_id}')
|
||||
body = response.json()
|
||||
if body['status'] in statuses:
|
||||
return body
|
||||
time.sleep(0.02)
|
||||
self.fail(f"task did not reach one of {sorted(statuses)} in time")
|
||||
|
||||
def test_get_history_empty_list(self) -> None:
|
||||
response = self._request('GET', '/api/history')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -207,6 +229,35 @@ class HistoryApiGoldenTest(unittest.TestCase):
|
||||
self.assertEqual(history[0]['source'], 'storage1/source.txt')
|
||||
self.assertEqual(history[0]['destination'], 'storage1/copied.txt')
|
||||
|
||||
def test_copy_cancelled_history_item(self) -> None:
|
||||
blocking_fs = BlockingCopyFilesystemAdapter()
|
||||
self._set_services(blocking_fs)
|
||||
(self.root1 / 'a.txt').write_text('A', encoding='utf-8')
|
||||
(self.root1 / 'b.txt').write_text('B', encoding='utf-8')
|
||||
(self.root1 / 'dest').mkdir()
|
||||
|
||||
response = self._request(
|
||||
'POST',
|
||||
'/api/files/copy',
|
||||
{'sources': ['storage1/a.txt', 'storage1/b.txt'], 'destination_base': 'storage1/dest'},
|
||||
)
|
||||
|
||||
task_id = response.json()['task_id']
|
||||
self.assertTrue(blocking_fs.entered.wait(timeout=2.0))
|
||||
self._wait_for_status(task_id, {'running'})
|
||||
cancel_response = self._request('POST', f'/api/tasks/{task_id}/cancel')
|
||||
self.assertEqual(cancel_response.status_code, 200)
|
||||
self.assertEqual(cancel_response.json()['status'], 'cancelling')
|
||||
blocking_fs.release.set()
|
||||
detail = self._wait_task(task_id)
|
||||
|
||||
self.assertEqual(detail['status'], 'cancelled')
|
||||
history = self._request('GET', '/api/history').json()['items']
|
||||
self.assertEqual(history[0]['operation'], 'copy')
|
||||
self.assertEqual(history[0]['status'], 'cancelled')
|
||||
self.assertEqual(history[0]['source'], '2 items')
|
||||
self.assertEqual(history[0]['destination'], 'storage1/dest')
|
||||
|
||||
def test_move_failed_history_item(self) -> None:
|
||||
src = self.root1 / 'source.txt'
|
||||
src.write_text('hello', encoding='utf-8')
|
||||
@@ -334,6 +385,7 @@ class HistoryApiGoldenTest(unittest.TestCase):
|
||||
cancel = self._request('POST', f"/api/files/download/archive/{response.json()['task_id']}/cancel")
|
||||
release.set()
|
||||
self._wait_task(response.json()['task_id'])
|
||||
time.sleep(0.05)
|
||||
history = self._request('GET', '/api/history').json()['items']
|
||||
|
||||
self.assertEqual(cancel.status_code, 200)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -38,6 +39,18 @@ class FailingBatchFilesystemAdapter(FilesystemAdapter):
|
||||
super().move_directory(source, destination)
|
||||
|
||||
|
||||
class BlockingMoveFilesystemAdapter(FilesystemAdapter):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.entered = threading.Event()
|
||||
self.release = threading.Event()
|
||||
|
||||
def move_file(self, source: str, destination: str) -> None:
|
||||
self.entered.set()
|
||||
self.release.wait(timeout=2.0)
|
||||
super().move_file(source, destination)
|
||||
|
||||
|
||||
class MoveApiGoldenTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
@@ -83,11 +96,21 @@ class MoveApiGoldenTest(unittest.TestCase):
|
||||
while time.time() < deadline:
|
||||
response = self._request("GET", f"/api/tasks/{task_id}")
|
||||
body = response.json()
|
||||
if body["status"] in {"completed", "failed"}:
|
||||
if body["status"] in {"completed", "failed", "cancelled"}:
|
||||
return body
|
||||
time.sleep(0.02)
|
||||
self.fail("task did not reach terminal state in time")
|
||||
|
||||
def _wait_for_status(self, task_id: str, statuses: set[str], timeout_s: float = 2.0) -> dict:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
response = self._request("GET", f"/api/tasks/{task_id}")
|
||||
body = response.json()
|
||||
if body["status"] in statuses:
|
||||
return body
|
||||
time.sleep(0.02)
|
||||
self.fail(f"task did not reach one of {sorted(statuses)} in time")
|
||||
|
||||
def test_move_success_same_root_create_task_shape_and_completed(self) -> None:
|
||||
src = self.root1 / "source.txt"
|
||||
src.write_text("hello", encoding="utf-8")
|
||||
@@ -225,6 +248,42 @@ class MoveApiGoldenTest(unittest.TestCase):
|
||||
self.assertFalse(source_file.exists())
|
||||
self.assertFalse(source_dir.exists())
|
||||
|
||||
def test_move_batch_cancelled_after_current_file_finishes(self) -> None:
|
||||
blocking_fs = BlockingMoveFilesystemAdapter()
|
||||
path_guard = PathGuard({"storage1": str(self.root1), "storage2": str(self.root2)})
|
||||
self._set_services(path_guard=path_guard, filesystem=blocking_fs)
|
||||
(self.root1 / "a.txt").write_text("A", encoding="utf-8")
|
||||
(self.root1 / "b.txt").write_text("B", encoding="utf-8")
|
||||
target = self.root1 / "target"
|
||||
target.mkdir()
|
||||
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/api/files/move",
|
||||
{
|
||||
"sources": ["storage1/a.txt", "storage1/b.txt"],
|
||||
"destination_base": "storage1/target",
|
||||
},
|
||||
)
|
||||
|
||||
task_id = response.json()["task_id"]
|
||||
self.assertTrue(blocking_fs.entered.wait(timeout=2.0))
|
||||
running = self._wait_for_status(task_id, {"running"})
|
||||
self.assertEqual(running["current_item"], str(self.root1 / "a.txt"))
|
||||
|
||||
cancel_response = self._request("POST", f"/api/tasks/{task_id}/cancel")
|
||||
self.assertEqual(cancel_response.status_code, 200)
|
||||
self.assertEqual(cancel_response.json()["status"], "cancelling")
|
||||
|
||||
blocking_fs.release.set()
|
||||
detail = self._wait_task(task_id)
|
||||
self.assertEqual(detail["status"], "cancelled")
|
||||
self.assertEqual(detail["done_items"], 1)
|
||||
self.assertEqual(detail["total_items"], 2)
|
||||
self.assertTrue((target / "a.txt").exists())
|
||||
self.assertTrue((self.root1 / "b.txt").exists())
|
||||
self.assertFalse((target / "b.txt").exists())
|
||||
|
||||
def test_move_batch_cross_root_directories_blocked(self) -> None:
|
||||
first = self.root1 / "first-dir"
|
||||
second = self.root1 / "second-dir"
|
||||
|
||||
@@ -40,6 +40,14 @@ class TasksApiGoldenTest(unittest.TestCase):
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
def _post(self, url: str, payload: dict | None = None) -> httpx.Response:
|
||||
async def _run() -> httpx.Response:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
return await client.post(url, json=payload)
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
def _insert_task(
|
||||
self,
|
||||
*,
|
||||
@@ -265,6 +273,78 @@ class TasksApiGoldenTest(unittest.TestCase):
|
||||
self.assertEqual(body["total_items"], 1)
|
||||
self.assertEqual(body["current_item"], "storage1/trash.txt")
|
||||
|
||||
def test_cancel_running_delete_task_returns_cancelling(self) -> None:
|
||||
self._insert_task(
|
||||
task_id="task-delete",
|
||||
operation="delete",
|
||||
status="running",
|
||||
source="storage1/trash.txt",
|
||||
destination="",
|
||||
created_at="2026-03-10T10:00:00Z",
|
||||
started_at="2026-03-10T10:00:01Z",
|
||||
done_items=0,
|
||||
total_items=1,
|
||||
current_item="storage1/trash.txt",
|
||||
)
|
||||
|
||||
response = self._post("/api/tasks/task-delete/cancel")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body["operation"], "delete")
|
||||
self.assertEqual(body["status"], "cancelling")
|
||||
self.assertEqual(body["current_item"], "storage1/trash.txt")
|
||||
|
||||
def test_cancel_completed_task_rejected(self) -> None:
|
||||
self._insert_task(
|
||||
task_id="task-copy",
|
||||
operation="copy",
|
||||
status="completed",
|
||||
source="storage1/a.txt",
|
||||
destination="storage2/a.txt",
|
||||
created_at="2026-03-10T10:00:00Z",
|
||||
finished_at="2026-03-10T10:00:04Z",
|
||||
)
|
||||
|
||||
response = self._post("/api/tasks/task-copy/cancel")
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{
|
||||
"error": {
|
||||
"code": "task_not_cancellable",
|
||||
"message": "Task cannot be cancelled",
|
||||
"details": {"task_id": "task-copy", "status": "completed"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def test_cancel_download_task_rejected(self) -> None:
|
||||
self._insert_task(
|
||||
task_id="task-download",
|
||||
operation="download",
|
||||
status="preparing",
|
||||
source="single_directory_zip",
|
||||
destination="docs.zip",
|
||||
created_at="2026-03-10T10:00:00Z",
|
||||
started_at="2026-03-10T10:00:01Z",
|
||||
)
|
||||
|
||||
response = self._post("/api/tasks/task-download/cancel")
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{
|
||||
"error": {
|
||||
"code": "task_not_cancellable",
|
||||
"message": "Task cannot be cancelled",
|
||||
"details": {"task_id": "task-download", "status": "preparing"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def test_get_task_detail_ready_archive_download(self) -> None:
|
||||
self._insert_task(
|
||||
task_id="task-download-ready",
|
||||
|
||||
@@ -240,6 +240,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self._extract_js_function(app_js, "formatTaskLine"),
|
||||
self._extract_js_function(app_js, "isActiveTask"),
|
||||
self._extract_js_function(app_js, "activeTasksFromItems"),
|
||||
self._extract_js_function(app_js, "taskIsCancellable"),
|
||||
self._extract_js_function(app_js, "cancelTaskRequest"),
|
||||
self._extract_js_function(app_js, "activeTaskChipLabel"),
|
||||
self._extract_js_function(app_js, "headerTaskRenderKey"),
|
||||
self._extract_js_function(app_js, "shouldPollHeaderTasks"),
|
||||
@@ -289,6 +291,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
children: [],
|
||||
disabled: false,
|
||||
onclick: null,
|
||||
scrollTop: 0,
|
||||
attributes: {{}},
|
||||
append(...nodes) {{
|
||||
@@ -329,6 +333,9 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
return value || "now";
|
||||
}}
|
||||
|
||||
async function refreshTasksSnapshot() {{}}
|
||||
function setError() {{}}
|
||||
|
||||
let headerTaskState = {{
|
||||
activeItems: [],
|
||||
popoverOpen: false,
|
||||
@@ -336,7 +343,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
lastRenderKey: "",
|
||||
}};
|
||||
const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);
|
||||
const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);
|
||||
const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);
|
||||
|
||||
{functions}
|
||||
|
||||
@@ -347,6 +354,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
{{ id: "d", operation: "download", status: "preparing", source: "/src/d", destination: "folder.zip" }},
|
||||
{{ id: "dup", operation: "duplicate", status: "queued", source: "/src/dup", destination: "/dst/dup" }},
|
||||
{{ id: "del", operation: "delete", status: "running", source: "/src/del", destination: "" }},
|
||||
{{ id: "stop", operation: "copy", status: "cancelling", source: "/src/stop", destination: "/dst/stop" }},
|
||||
{{ id: "e", operation: "copy", status: "completed", source: "/src/e", destination: "/dst/e" }},
|
||||
{{ id: "f", operation: "move", status: "failed", source: "/src/f", destination: "/dst/f" }},
|
||||
{{ id: "g", operation: "download", status: "ready", source: "/src/g", destination: "folder.zip" }},
|
||||
@@ -354,21 +362,28 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
];
|
||||
|
||||
const activeTasks = activeTasksFromItems(mixedTasks);
|
||||
assert(activeTasks.length === 4, "Only task-based file actions in queued or running should count as active");
|
||||
assert(activeTasks.length === 5, "Only active task-based file actions should count as active");
|
||||
assert(activeTasks.every((task) => isActiveTask(task)), "All filtered tasks should be active");
|
||||
assert(activeTasks.some((task) => task.operation === "delete"), "Delete should count once it uses the shared task flow");
|
||||
assert(activeTaskChipLabel(activeTasks.length) === "4 active tasks", "Chip label should reflect active task count");
|
||||
assert(activeTasks.some((task) => task.status === "cancelling"), "Cancelling tasks should remain visible while stopping");
|
||||
assert(activeTaskChipLabel(activeTasks.length) === "5 active tasks", "Chip label should reflect active task count");
|
||||
|
||||
updateHeaderTaskState(mixedTasks);
|
||||
assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Chip should be visible with active tasks");
|
||||
assert(elements["header-task-chip-label"].textContent === "4 active tasks", "Chip label should render active task count");
|
||||
assert(elements["header-task-chip-label"].textContent === "5 active tasks", "Chip label should render active task count");
|
||||
assert(shouldPollHeaderTasks(), "Active tasks should enable header polling");
|
||||
|
||||
setHeaderTaskPopoverOpen(true);
|
||||
assert(headerTaskState.popoverOpen, "Popover should open when active tasks exist");
|
||||
assert(!elements["header-task-popover"].classList.contains("hidden"), "Popover should be visible when open");
|
||||
assert(elements["header-task-chip-btn"].attributes["aria-expanded"] === "true", "Chip button should expose expanded state");
|
||||
assert(elements["header-task-popover-list"].children.length === 4, "Popover should render only active file-action tasks");
|
||||
assert(elements["header-task-popover-list"].children.length === 5, "Popover should render only active file-action tasks");
|
||||
const firstActionButton = elements["header-task-popover-list"].children[0].children[3].children[0];
|
||||
const cancellingActionButton = elements["header-task-popover-list"].children[4].children[3].children[0];
|
||||
assert(firstActionButton.textContent === "Stop", "Queued/running tasks should expose a Stop action");
|
||||
assert(!firstActionButton.disabled, "Queued/running tasks should be cancellable");
|
||||
assert(cancellingActionButton.textContent === "Stopping...", "Cancelling tasks should show stopping state");
|
||||
assert(cancellingActionButton.disabled, "Cancelling tasks should not expose a second stop action");
|
||||
|
||||
updateHeaderTaskState([
|
||||
{{ id: "z1", operation: "copy", status: "completed", source: "/src/z1", destination: "/dst/z1" }},
|
||||
@@ -399,6 +414,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self._extract_js_function(app_js, "formatTaskLine"),
|
||||
self._extract_js_function(app_js, "isActiveTask"),
|
||||
self._extract_js_function(app_js, "activeTasksFromItems"),
|
||||
self._extract_js_function(app_js, "taskIsCancellable"),
|
||||
self._extract_js_function(app_js, "cancelTaskRequest"),
|
||||
self._extract_js_function(app_js, "activeTaskChipLabel"),
|
||||
self._extract_js_function(app_js, "headerTaskRenderKey"),
|
||||
self._extract_js_function(app_js, "shouldPollHeaderTasks"),
|
||||
@@ -449,6 +466,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
children: [],
|
||||
disabled: false,
|
||||
onclick: null,
|
||||
scrollTop: 0,
|
||||
attributes: {{}},
|
||||
append(...nodes) {{
|
||||
@@ -489,6 +508,9 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
return value || "now";
|
||||
}}
|
||||
|
||||
async function refreshTasksSnapshot() {{}}
|
||||
function setError() {{}}
|
||||
|
||||
let state = {{ lastTaskCount: 0 }};
|
||||
let headerTaskState = {{
|
||||
activeItems: [],
|
||||
@@ -497,7 +519,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
lastRenderKey: "",
|
||||
}};
|
||||
const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);
|
||||
const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);
|
||||
const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);
|
||||
|
||||
{functions}
|
||||
|
||||
@@ -781,11 +803,13 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('function formatTaskLine(task)', app_js)
|
||||
self.assertIn('let headerTaskState = {', app_js)
|
||||
self.assertIn('const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);', app_js)
|
||||
self.assertIn('const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);', app_js)
|
||||
self.assertIn('const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);', app_js)
|
||||
self.assertIn("The header chip reflects only user-visible file actions that use the shared task system.", app_js)
|
||||
self.assertIn('function headerTaskElements()', app_js)
|
||||
self.assertIn('function isActiveTask(task)', app_js)
|
||||
self.assertIn('function activeTasksFromItems(items)', app_js)
|
||||
self.assertIn('function taskIsCancellable(task)', app_js)
|
||||
self.assertIn('async function cancelTaskRequest(taskId)', app_js)
|
||||
self.assertIn('function activeTaskChipLabel(count)', app_js)
|
||||
self.assertIn('function shouldPollHeaderTasks()', app_js)
|
||||
self.assertIn('function scheduleHeaderTaskPolling()', app_js)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -109,6 +109,38 @@ class TaskRecoveryServiceTest(unittest.TestCase):
|
||||
self.assertEqual(task["status"], "failed")
|
||||
self.assertEqual(task["error_code"], "task_interrupted")
|
||||
|
||||
def test_reconcile_persisted_incomplete_tasks_marks_stale_cancelling_task_failed(self) -> None:
|
||||
self.task_repo.insert_task_for_testing(
|
||||
{
|
||||
"id": "task-cancelling",
|
||||
"operation": "duplicate",
|
||||
"status": "cancelling",
|
||||
"source": "2 items",
|
||||
"destination": "same directory",
|
||||
"created_at": "2026-03-10T10:00:00Z",
|
||||
"started_at": "2026-03-10T10:00:01Z",
|
||||
"current_item": "storage1/report.txt",
|
||||
}
|
||||
)
|
||||
self.history_repo.create_entry(
|
||||
entry_id="task-cancelling",
|
||||
operation="duplicate",
|
||||
status="queued",
|
||||
source="2 items",
|
||||
destination="same directory",
|
||||
created_at="2026-03-10T10:00:00Z",
|
||||
)
|
||||
|
||||
changed = reconcile_persisted_incomplete_tasks(self.task_repo, self.history_repo)
|
||||
|
||||
self.assertEqual(changed, ["task-cancelling"])
|
||||
task = self.task_repo.get_task("task-cancelling")
|
||||
self.assertEqual(task["status"], "failed")
|
||||
self.assertEqual(task["error_code"], "task_interrupted")
|
||||
history = self.history_repo.list_history(limit=5)[0]
|
||||
self.assertEqual(history["id"], "task-cancelling")
|
||||
self.assertEqual(history["status"], "failed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -107,6 +107,68 @@ class TaskRepositoryTest(unittest.TestCase):
|
||||
self.assertEqual(task["status"], "cancelled")
|
||||
self.assertIsNotNone(task["finished_at"])
|
||||
|
||||
def test_request_cancellation_moves_running_file_task_to_cancelling(self) -> None:
|
||||
created = self.repo.create_task(
|
||||
operation="copy",
|
||||
source="storage1/docs/a.txt",
|
||||
destination="storage1/docs-copy/a.txt",
|
||||
)
|
||||
self.repo.mark_running(
|
||||
created["id"],
|
||||
done_items=0,
|
||||
total_items=2,
|
||||
current_item="storage1/docs/a.txt",
|
||||
)
|
||||
|
||||
task = self.repo.request_cancellation(created["id"])
|
||||
|
||||
self.assertIsNotNone(task)
|
||||
self.assertEqual(task["status"], "cancelling")
|
||||
self.assertEqual(task["current_item"], "storage1/docs/a.txt")
|
||||
self.assertIsNone(task["finished_at"])
|
||||
|
||||
def test_request_cancellation_moves_queued_file_task_to_cancelled(self) -> None:
|
||||
created = self.repo.create_task(
|
||||
operation="delete",
|
||||
source="storage1/docs/a.txt",
|
||||
destination="",
|
||||
)
|
||||
|
||||
task = self.repo.request_cancellation(created["id"])
|
||||
|
||||
self.assertIsNotNone(task)
|
||||
self.assertEqual(task["status"], "cancelled")
|
||||
self.assertIsNone(task["current_item"])
|
||||
self.assertIsNotNone(task["finished_at"])
|
||||
|
||||
def test_finalize_cancelled_transitions_cancelling_task(self) -> None:
|
||||
created = self.repo.create_task(
|
||||
operation="move",
|
||||
source="storage1/docs/a.txt",
|
||||
destination="storage1/archive/a.txt",
|
||||
)
|
||||
self.repo.mark_running(
|
||||
created["id"],
|
||||
done_items=0,
|
||||
total_items=3,
|
||||
current_item="storage1/docs/a.txt",
|
||||
)
|
||||
self.repo.request_cancellation(created["id"])
|
||||
|
||||
changed = self.repo.finalize_cancelled(
|
||||
created["id"],
|
||||
done_items=1,
|
||||
total_items=3,
|
||||
)
|
||||
task = self.repo.get_task(created["id"])
|
||||
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(task["status"], "cancelled")
|
||||
self.assertEqual(task["done_items"], 1)
|
||||
self.assertEqual(task["total_items"], 3)
|
||||
self.assertIsNone(task["current_item"])
|
||||
self.assertIsNotNone(task["finished_at"])
|
||||
|
||||
def test_reconcile_incomplete_tasks_marks_non_terminal_failed(self) -> None:
|
||||
self.repo.insert_task_for_testing(
|
||||
{
|
||||
|
||||
+36
-1
@@ -122,7 +122,7 @@ let headerTaskState = {
|
||||
};
|
||||
// The header chip reflects only user-visible file actions that use the shared task system.
|
||||
const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);
|
||||
const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);
|
||||
const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);
|
||||
const VALID_THEME_FAMILIES = [
|
||||
"default",
|
||||
"macos-soft",
|
||||
@@ -3835,6 +3835,10 @@ function formatTaskStatusLabel(task) {
|
||||
return "Queued";
|
||||
case "running":
|
||||
return "Running";
|
||||
case "cancelling":
|
||||
return "Cancelling";
|
||||
case "cancelled":
|
||||
return "Cancelled";
|
||||
case "completed":
|
||||
return "Completed";
|
||||
case "failed":
|
||||
@@ -3883,6 +3887,14 @@ function activeTasksFromItems(items) {
|
||||
return Array.isArray(items) ? items.filter((task) => isActiveTask(task)) : [];
|
||||
}
|
||||
|
||||
function taskIsCancellable(task) {
|
||||
return Boolean(task) && ACTIVE_TASK_OPERATIONS.has(task.operation) && ["queued", "running"].includes(task.status);
|
||||
}
|
||||
|
||||
async function cancelTaskRequest(taskId) {
|
||||
return apiRequest("POST", `/api/tasks/${encodeURIComponent(taskId)}/cancel`);
|
||||
}
|
||||
|
||||
function activeTaskChipLabel(count) {
|
||||
return `${count} active task${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
@@ -3972,6 +3984,29 @@ function renderHeaderTaskPopover(items) {
|
||||
meta.className = "header-task-item-meta";
|
||||
meta.textContent = line.meta;
|
||||
row.append(title, path, meta);
|
||||
if (taskIsCancellable(task) || task.status === "cancelling") {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "header-task-item-actions";
|
||||
const cancelButton = document.createElement("button");
|
||||
cancelButton.type = "button";
|
||||
cancelButton.className = "header-task-item-action";
|
||||
cancelButton.textContent = task.status === "cancelling" ? "Stopping..." : "Stop";
|
||||
cancelButton.disabled = task.status === "cancelling";
|
||||
if (!cancelButton.disabled) {
|
||||
cancelButton.onclick = async () => {
|
||||
cancelButton.disabled = true;
|
||||
try {
|
||||
await cancelTaskRequest(task.id);
|
||||
await refreshTasksSnapshot();
|
||||
} catch (err) {
|
||||
cancelButton.disabled = false;
|
||||
setError("actions-error", `Stop task: ${err.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
actions.append(cancelButton);
|
||||
row.append(actions);
|
||||
}
|
||||
elements.popoverList.append(row);
|
||||
}
|
||||
headerTaskState.lastRenderKey = renderKey;
|
||||
|
||||
@@ -157,6 +157,18 @@ body {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.header-task-item-actions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.header-task-item-action {
|
||||
min-width: 74px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user