feat: annuleren taak toegevoegd

This commit is contained in:
kodi
2026-03-15 13:06:48 +01:00
parent 7d910479f9
commit a52493459a
32 changed files with 835 additions and 61 deletions
+34
View File
@@ -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: Task not found:
```json ```json
{ {
+6 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations 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.api.schemas import TaskDetailResponse, TaskListResponse
from backend.app.dependencies import get_task_service 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) @router.get("/{task_id}", response_model=TaskDetailResponse)
async def get_task(task_id: str, service: TaskService = Depends(get_task_service)) -> TaskDetailResponse: async def get_task(task_id: str, service: TaskService = Depends(get_task_service)) -> TaskDetailResponse:
return service.get_task(task_id) 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)
+56 -11
View File
@@ -6,9 +6,9 @@ from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path 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"} 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] = { TASK_MIGRATION_COLUMNS: dict[str, str] = {
"operation": "TEXT NOT NULL DEFAULT 'copy'", "operation": "TEXT NOT NULL DEFAULT 'copy'",
"status": "TEXT NOT NULL DEFAULT 'queued'", "status": "TEXT NOT NULL DEFAULT 'queued'",
@@ -143,17 +143,18 @@ class TaskRepository:
done_items: int | None = None, done_items: int | None = None,
total_items: int | None = None, total_items: int | None = None,
current_item: str | None = None, current_item: str | None = None,
) -> None: ) -> bool:
started_at = self._now_iso() started_at = self._now_iso()
with self._connection() as conn: with self._connection() as conn:
conn.execute( cursor = conn.execute(
""" """
UPDATE tasks UPDATE tasks
SET status = ?, started_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = ? 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( def mark_preparing(
self, self,
@@ -200,17 +201,18 @@ class TaskRepository:
total_bytes: int | None = None, total_bytes: int | None = None,
done_items: int | None = None, done_items: int | None = None,
total_items: int | None = None, total_items: int | None = None,
) -> None: ) -> bool:
finished_at = self._now_iso() finished_at = self._now_iso()
with self._connection() as conn: with self._connection() as conn:
conn.execute( cursor = conn.execute(
""" """
UPDATE tasks UPDATE tasks
SET status = ?, finished_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ? SET status = ?, finished_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = NULL
WHERE id = ? 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( def mark_ready(
self, self,
@@ -311,6 +313,49 @@ class TaskRepository:
) )
return cursor.rowcount > 0 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: def _ensure_schema(self) -> None:
db_path = Path(self._db_path) db_path = Path(self._db_path)
if db_path.parent and str(db_path.parent) not in {"", "."}: if db_path.parent and str(db_path.parent) not in {"", "."}:
+1 -1
View File
@@ -102,7 +102,7 @@ async def get_archive_download_task_service() -> ArchiveDownloadTaskService:
async def get_task_service() -> TaskService: 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: async def get_copy_task_service() -> CopyTaskService:
+43 -1
View File
@@ -2,12 +2,16 @@ from __future__ import annotations
from backend.app.api.errors import AppError from backend.app.api.errors import AppError
from backend.app.api.schemas import TaskDetailResponse, TaskListItem, TaskListResponse 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 from backend.app.db.task_repository import TaskRepository
FILE_ACTION_CANCELLABLE_OPERATIONS = {"copy", "move", "duplicate", "delete"}
class TaskService: class TaskService:
def __init__(self, repository: TaskRepository): def __init__(self, repository: TaskRepository, history_repository: HistoryRepository | None = None):
self._repository = repository self._repository = repository
self._history_repository = history_repository
def create_task(self, operation: str, source: str, destination: str) -> TaskDetailResponse: def create_task(self, operation: str, source: str, destination: str) -> TaskDetailResponse:
task = self._repository.create_task(operation=operation, source=source, destination=destination) task = self._repository.create_task(operation=operation, source=source, destination=destination)
@@ -40,3 +44,41 @@ class TaskService:
for task in tasks 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)
+145 -34
View File
@@ -95,12 +95,14 @@ class TaskRunner:
thread.start() thread.start()
def _run_copy_file(self, task_id: str, source: str, destination: str, total_bytes: int) -> None: 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, task_id=task_id,
done_bytes=0, done_bytes=0,
total_bytes=total_bytes, total_bytes=total_bytes,
current_item=source, current_item=source,
) ):
self._finalize_if_already_cancelled(task_id, done_bytes=0, total_bytes=total_bytes)
return
progress = {"done": 0} progress = {"done": 0}
@@ -115,12 +117,11 @@ class TaskRunner:
try: try:
self._filesystem.copy_file(source=source, destination=destination, on_progress=on_progress) 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, task_id=task_id,
done_bytes=total_bytes, done_bytes=total_bytes,
total_bytes=total_bytes, total_bytes=total_bytes,
) )
self._update_history_completed(task_id)
except OSError as exc: except OSError as exc:
self._repository.mark_failed( self._repository.mark_failed(
task_id=task_id, task_id=task_id,
@@ -133,21 +134,22 @@ class TaskRunner:
self._update_history_failed(task_id, str(exc)) self._update_history_failed(task_id, str(exc))
def _run_copy_directory(self, task_id: str, source: str, destination: str) -> None: 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, task_id=task_id,
done_items=0, done_items=0,
total_items=1, total_items=1,
current_item=source, current_item=source,
) ):
self._finalize_if_already_cancelled(task_id, done_items=0, total_items=1)
return
try: try:
self._filesystem.copy_directory(source=source, destination=destination) self._filesystem.copy_directory(source=source, destination=destination)
self._repository.mark_completed( self._complete_or_cancel_item_task(
task_id=task_id, task_id=task_id,
done_items=1, done_items=1,
total_items=1, total_items=1,
) )
self._update_history_completed(task_id)
except OSError as exc: except OSError as exc:
self._repository.mark_failed( self._repository.mark_failed(
task_id=task_id, task_id=task_id,
@@ -164,15 +166,20 @@ class TaskRunner:
def _run_copy_batch(self, task_id: str, items: list[dict[str, str]]) -> None: def _run_copy_batch(self, task_id: str, items: list[dict[str, str]]) -> None:
total_items = len(items) total_items = len(items)
current_item = items[0]["source"] if items else None current_item = items[0]["source"] if items else None
self._repository.mark_running( if not self._repository.mark_running(
task_id=task_id, task_id=task_id,
done_items=0, done_items=0,
total_items=total_items, total_items=total_items,
current_item=current_item, current_item=current_item,
) ):
self._finalize_if_already_cancelled(task_id, done_items=0, total_items=total_items)
return
completed_items = 0 completed_items = 0
for index, item in enumerate(items): 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"] source = item["source"]
destination = item["destination"] destination = item["destination"]
try: try:
@@ -188,6 +195,9 @@ class TaskRunner:
total_items=total_items, total_items=total_items,
current_item=next_item, 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: except OSError as exc:
self._repository.mark_failed( self._repository.mark_failed(
task_id=task_id, task_id=task_id,
@@ -202,12 +212,11 @@ class TaskRunner:
self._update_history_failed(task_id, str(exc)) self._update_history_failed(task_id, str(exc))
return return
self._repository.mark_completed( self._complete_or_cancel_item_task(
task_id=task_id, task_id=task_id,
done_items=total_items, done_items=total_items,
total_items=total_items, total_items=total_items,
) )
self._update_history_completed(task_id)
def _run_move_file( def _run_move_file(
self, self,
@@ -217,24 +226,25 @@ class TaskRunner:
total_bytes: int, total_bytes: int,
same_root: bool, same_root: bool,
) -> None: ) -> None:
self._repository.mark_running( if not self._repository.mark_running(
task_id=task_id, task_id=task_id,
done_bytes=0, done_bytes=0,
total_bytes=total_bytes, total_bytes=total_bytes,
current_item=source, current_item=source,
) ):
self._finalize_if_already_cancelled(task_id, done_bytes=0, total_bytes=total_bytes)
return
progress = {"done": 0} progress = {"done": 0}
try: try:
if same_root: if same_root:
self._filesystem.move_file(source=source, destination=destination) self._filesystem.move_file(source=source, destination=destination)
self._repository.mark_completed( self._complete_or_cancel_file_task(
task_id=task_id, task_id=task_id,
done_bytes=total_bytes, done_bytes=total_bytes,
total_bytes=total_bytes, total_bytes=total_bytes,
) )
self._update_history_completed(task_id)
return return
def on_progress(done_bytes: int) -> None: 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.copy_file(source=source, destination=destination, on_progress=on_progress)
self._filesystem.delete_file(Path(source)) self._filesystem.delete_file(Path(source))
self._repository.mark_completed( self._complete_or_cancel_file_task(
task_id=task_id, task_id=task_id,
done_bytes=total_bytes, done_bytes=total_bytes,
total_bytes=total_bytes, total_bytes=total_bytes,
) )
self._update_history_completed(task_id)
except OSError as exc: except OSError as exc:
self._repository.mark_failed( self._repository.mark_failed(
task_id=task_id, task_id=task_id,
@@ -266,21 +275,22 @@ class TaskRunner:
self._update_history_failed(task_id, str(exc)) self._update_history_failed(task_id, str(exc))
def _run_move_directory(self, task_id: str, source: str, destination: str) -> None: 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, task_id=task_id,
done_items=0, done_items=0,
total_items=1, total_items=1,
current_item=source, current_item=source,
) ):
self._finalize_if_already_cancelled(task_id, done_items=0, total_items=1)
return
try: try:
self._filesystem.move_directory(source=source, destination=destination) self._filesystem.move_directory(source=source, destination=destination)
self._repository.mark_completed( self._complete_or_cancel_item_task(
task_id=task_id, task_id=task_id,
done_items=1, done_items=1,
total_items=1, total_items=1,
) )
self._update_history_completed(task_id)
except OSError as exc: except OSError as exc:
self._repository.mark_failed( self._repository.mark_failed(
task_id=task_id, task_id=task_id,
@@ -295,15 +305,20 @@ class TaskRunner:
def _run_move_batch(self, task_id: str, items: list[dict[str, str]]) -> None: def _run_move_batch(self, task_id: str, items: list[dict[str, str]]) -> None:
total_items = len(items) total_items = len(items)
current_item = items[0]["source"] if items else None current_item = items[0]["source"] if items else None
self._repository.mark_running( if not self._repository.mark_running(
task_id=task_id, task_id=task_id,
done_items=0, done_items=0,
total_items=total_items, total_items=total_items,
current_item=current_item, current_item=current_item,
) ):
self._finalize_if_already_cancelled(task_id, done_items=0, total_items=total_items)
return
completed_items = 0 completed_items = 0
for index, item in enumerate(items): 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"] source = item["source"]
destination = item["destination"] destination = item["destination"]
try: try:
@@ -319,6 +334,9 @@ class TaskRunner:
total_items=total_items, total_items=total_items,
current_item=next_item, 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: except OSError as exc:
self._repository.mark_failed( self._repository.mark_failed(
task_id=task_id, task_id=task_id,
@@ -333,25 +351,29 @@ class TaskRunner:
self._update_history_failed(task_id, str(exc)) self._update_history_failed(task_id, str(exc))
return return
self._repository.mark_completed( self._complete_or_cancel_item_task(
task_id=task_id, task_id=task_id,
done_items=total_items, done_items=total_items,
total_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: def _run_duplicate_batch(self, task_id: str, items: list[dict[str, str]]) -> None:
total_items = len(items) total_items = len(items)
current_item = items[0]["source"] if items else None current_item = items[0]["source"] if items else None
self._repository.mark_running( if not self._repository.mark_running(
task_id=task_id, task_id=task_id,
done_items=0, done_items=0,
total_items=total_items, total_items=total_items,
current_item=current_item, current_item=current_item,
) ):
self._finalize_if_already_cancelled(task_id, done_items=0, total_items=total_items)
return
completed_items = 0 completed_items = 0
for index, item in enumerate(items): 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"] source = item["source"]
destination = item["destination"] destination = item["destination"]
try: try:
@@ -367,6 +389,9 @@ class TaskRunner:
total_items=total_items, total_items=total_items,
current_item=next_item, 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: except OSError as exc:
self._cleanup_partial_duplicate(Path(destination)) self._cleanup_partial_duplicate(Path(destination))
self._repository.mark_failed( self._repository.mark_failed(
@@ -382,20 +407,21 @@ class TaskRunner:
self._update_history_failed(task_id, str(exc)) self._update_history_failed(task_id, str(exc))
return return
self._repository.mark_completed( self._complete_or_cancel_item_task(
task_id=task_id, task_id=task_id,
done_items=total_items, done_items=total_items,
total_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: 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, task_id=task_id,
done_items=0, done_items=0,
total_items=1, total_items=1,
current_item=target, current_item=target,
) ):
self._finalize_if_already_cancelled(task_id, done_items=0, total_items=1)
return
try: try:
path = Path(target) path = Path(target)
@@ -405,12 +431,11 @@ class TaskRunner:
self._filesystem.delete_directory_recursive(path) self._filesystem.delete_directory_recursive(path)
else: else:
self._filesystem.delete_empty_directory(path) self._filesystem.delete_empty_directory(path)
self._repository.mark_completed( self._complete_or_cancel_item_task(
task_id=task_id, task_id=task_id,
done_items=1, done_items=1,
total_items=1, total_items=1,
) )
self._update_history_completed(task_id)
except OSError as exc: except OSError as exc:
self._repository.mark_failed( self._repository.mark_failed(
task_id=task_id, task_id=task_id,
@@ -466,6 +491,88 @@ class TaskRunner:
return return
path.unlink() 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: def _update_history_completed(self, task_id: str) -> None:
if self._history_repository: if self._history_repository:
self._history_repository.update_entry(entry_id=task_id, status="completed") self._history_repository.update_entry(entry_id=task_id, status="completed")
@@ -478,3 +585,7 @@ class TaskRunner:
error_code="io_error", error_code="io_error",
error_message=error_message, 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.
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import sys import sys
import tempfile import tempfile
import threading
import time import time
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -29,6 +30,18 @@ class FailingFilesystemAdapter(FilesystemAdapter):
raise OSError("forced copy failure") 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): class CopyApiGoldenTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory() self.temp_dir = tempfile.TemporaryDirectory()
@@ -72,11 +85,21 @@ class CopyApiGoldenTest(unittest.TestCase):
while time.time() < deadline: while time.time() < deadline:
response = self._request("GET", f"/api/tasks/{task_id}") response = self._request("GET", f"/api/tasks/{task_id}")
body = response.json() body = response.json()
if body["status"] in {"completed", "failed"}: if body["status"] in {"completed", "failed", "cancelled"}:
return body return body
time.sleep(0.02) time.sleep(0.02)
self.fail("task did not reach terminal state in time") 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: def test_copy_success_create_task_shape(self) -> None:
src = self.root / "source.txt" src = self.root / "source.txt"
src.write_text("hello", encoding="utf-8") 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" / "file.txt").read_text(encoding="utf-8"), "F")
self.assertEqual((self.root / "dest" / "docs" / "nested" / "note.txt").read_text(encoding="utf-8"), "N") 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: def test_copy_source_not_found(self) -> None:
response = self._request( response = self._request(
"POST", "POST",
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import sys import sys
import tempfile import tempfile
import threading
import time import time
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -33,6 +34,18 @@ class FailOnSecondCopyFilesystemAdapter(FilesystemAdapter):
super().copy_file(source=source, destination=destination, on_progress=on_progress) 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): class DuplicateApiGoldenTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory() self.temp_dir = tempfile.TemporaryDirectory()
@@ -75,11 +88,21 @@ class DuplicateApiGoldenTest(unittest.TestCase):
while time.time() < deadline: while time.time() < deadline:
response = self._request("GET", f"/api/tasks/{task_id}") response = self._request("GET", f"/api/tasks/{task_id}")
body = response.json() body = response.json()
if body["status"] in {"completed", "failed"}: if body["status"] in {"completed", "failed", "cancelled"}:
return body return body
time.sleep(0.02) time.sleep(0.02)
self.fail("task did not reach terminal state in time") 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: def test_duplicate_single_file_success(self) -> None:
(self.root / "note.txt").write_text("hello", encoding="utf-8") (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 / "a copy.txt").read_text(encoding="utf-8"), "A")
self.assertEqual((self.root / "docs copy" / "nested" / "b.txt").read_text(encoding="utf-8"), "B") 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: def test_duplicate_collision_resolution_for_files_and_directories(self) -> None:
(self.root / "report.txt").write_text("R", encoding="utf-8") (self.root / "report.txt").write_text("R", encoding="utf-8")
(self.root / "report copy.txt").write_text("existing", 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 asyncio
import sys import sys
import tempfile import tempfile
import threading
import time import time
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -22,6 +23,18 @@ from backend.app.services.task_service import TaskService
from backend.app.tasks_runner import TaskRunner 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): class FileOpsApiGoldenTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory() self.temp_dir = tempfile.TemporaryDirectory()
@@ -84,11 +97,21 @@ class FileOpsApiGoldenTest(unittest.TestCase):
while time.time() < deadline: while time.time() < deadline:
response = self._get(f"/api/tasks/{task_id}") response = self._get(f"/api/tasks/{task_id}")
body = response.json() body = response.json()
if body["status"] in {"completed", "failed"}: if body["status"] in {"completed", "failed", "cancelled"}:
return body return body
time.sleep(0.02) time.sleep(0.02)
self.fail("task did not reach terminal state in time") 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: def test_mkdir_success(self) -> None:
response = self._post( response = self._post(
"/api/files/mkdir", "/api/files/mkdir",
@@ -272,6 +295,54 @@ class FileOpsApiGoldenTest(unittest.TestCase):
self.assertEqual(detail["status"], "completed") self.assertEqual(detail["status"], "completed")
self.assertFalse(target.exists()) 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: def test_delete_empty_directory_success(self) -> None:
target = self.scope / "empty_dir" target = self.scope / "empty_dir"
target.mkdir() target.mkdir()
@@ -49,6 +49,18 @@ class BlockingArchiveBuildFileOpsService(FileOpsService):
super()._write_download_target_to_zip(archive, resolved_target, on_each_item=on_each_item) 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): class HistoryApiGoldenTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory() 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) 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) 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) 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) history_service = HistoryService(repository=self.history_repo)
async def _override_file_ops_service() -> FileOpsService: async def _override_file_ops_service() -> FileOpsService:
@@ -138,6 +150,16 @@ class HistoryApiGoldenTest(unittest.TestCase):
time.sleep(0.02) time.sleep(0.02)
self.fail('task did not reach terminal state in time') 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: def test_get_history_empty_list(self) -> None:
response = self._request('GET', '/api/history') response = self._request('GET', '/api/history')
self.assertEqual(response.status_code, 200) 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]['source'], 'storage1/source.txt')
self.assertEqual(history[0]['destination'], 'storage1/copied.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: def test_move_failed_history_item(self) -> None:
src = self.root1 / 'source.txt' src = self.root1 / 'source.txt'
src.write_text('hello', encoding='utf-8') 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") cancel = self._request('POST', f"/api/files/download/archive/{response.json()['task_id']}/cancel")
release.set() release.set()
self._wait_task(response.json()['task_id']) self._wait_task(response.json()['task_id'])
time.sleep(0.05)
history = self._request('GET', '/api/history').json()['items'] history = self._request('GET', '/api/history').json()['items']
self.assertEqual(cancel.status_code, 200) self.assertEqual(cancel.status_code, 200)
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import sys import sys
import tempfile import tempfile
import threading
import time import time
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -38,6 +39,18 @@ class FailingBatchFilesystemAdapter(FilesystemAdapter):
super().move_directory(source, destination) 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): class MoveApiGoldenTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory() self.temp_dir = tempfile.TemporaryDirectory()
@@ -83,11 +96,21 @@ class MoveApiGoldenTest(unittest.TestCase):
while time.time() < deadline: while time.time() < deadline:
response = self._request("GET", f"/api/tasks/{task_id}") response = self._request("GET", f"/api/tasks/{task_id}")
body = response.json() body = response.json()
if body["status"] in {"completed", "failed"}: if body["status"] in {"completed", "failed", "cancelled"}:
return body return body
time.sleep(0.02) time.sleep(0.02)
self.fail("task did not reach terminal state in time") 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: def test_move_success_same_root_create_task_shape_and_completed(self) -> None:
src = self.root1 / "source.txt" src = self.root1 / "source.txt"
src.write_text("hello", encoding="utf-8") src.write_text("hello", encoding="utf-8")
@@ -225,6 +248,42 @@ class MoveApiGoldenTest(unittest.TestCase):
self.assertFalse(source_file.exists()) self.assertFalse(source_file.exists())
self.assertFalse(source_dir.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: def test_move_batch_cross_root_directories_blocked(self) -> None:
first = self.root1 / "first-dir" first = self.root1 / "first-dir"
second = self.root1 / "second-dir" second = self.root1 / "second-dir"
@@ -40,6 +40,14 @@ class TasksApiGoldenTest(unittest.TestCase):
return asyncio.run(_run()) 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( def _insert_task(
self, self,
*, *,
@@ -265,6 +273,78 @@ class TasksApiGoldenTest(unittest.TestCase):
self.assertEqual(body["total_items"], 1) self.assertEqual(body["total_items"], 1)
self.assertEqual(body["current_item"], "storage1/trash.txt") 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: def test_get_task_detail_ready_archive_download(self) -> None:
self._insert_task( self._insert_task(
task_id="task-download-ready", 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, "formatTaskLine"),
self._extract_js_function(app_js, "isActiveTask"), self._extract_js_function(app_js, "isActiveTask"),
self._extract_js_function(app_js, "activeTasksFromItems"), 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, "activeTaskChipLabel"),
self._extract_js_function(app_js, "headerTaskRenderKey"), self._extract_js_function(app_js, "headerTaskRenderKey"),
self._extract_js_function(app_js, "shouldPollHeaderTasks"), self._extract_js_function(app_js, "shouldPollHeaderTasks"),
@@ -289,6 +291,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
textContent: "", textContent: "",
innerHTML: "", innerHTML: "",
children: [], children: [],
disabled: false,
onclick: null,
scrollTop: 0, scrollTop: 0,
attributes: {{}}, attributes: {{}},
append(...nodes) {{ append(...nodes) {{
@@ -329,6 +333,9 @@ class UiSmokeGoldenTest(unittest.TestCase):
return value || "now"; return value || "now";
}} }}
async function refreshTasksSnapshot() {{}}
function setError() {{}}
let headerTaskState = {{ let headerTaskState = {{
activeItems: [], activeItems: [],
popoverOpen: false, popoverOpen: false,
@@ -336,7 +343,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
lastRenderKey: "", lastRenderKey: "",
}}; }};
const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); 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} {functions}
@@ -347,6 +354,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
{{ id: "d", operation: "download", status: "preparing", source: "/src/d", destination: "folder.zip" }}, {{ 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: "dup", operation: "duplicate", status: "queued", source: "/src/dup", destination: "/dst/dup" }},
{{ id: "del", operation: "delete", status: "running", source: "/src/del", destination: "" }}, {{ 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: "e", operation: "copy", status: "completed", source: "/src/e", destination: "/dst/e" }},
{{ id: "f", operation: "move", status: "failed", source: "/src/f", destination: "/dst/f" }}, {{ id: "f", operation: "move", status: "failed", source: "/src/f", destination: "/dst/f" }},
{{ id: "g", operation: "download", status: "ready", source: "/src/g", destination: "folder.zip" }}, {{ id: "g", operation: "download", status: "ready", source: "/src/g", destination: "folder.zip" }},
@@ -354,21 +362,28 @@ class UiSmokeGoldenTest(unittest.TestCase):
]; ];
const activeTasks = activeTasksFromItems(mixedTasks); 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.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(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); updateHeaderTaskState(mixedTasks);
assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Chip should be visible with active tasks"); 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"); assert(shouldPollHeaderTasks(), "Active tasks should enable header polling");
setHeaderTaskPopoverOpen(true); setHeaderTaskPopoverOpen(true);
assert(headerTaskState.popoverOpen, "Popover should open when active tasks exist"); 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-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-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([ updateHeaderTaskState([
{{ id: "z1", operation: "copy", status: "completed", source: "/src/z1", destination: "/dst/z1" }}, {{ 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, "formatTaskLine"),
self._extract_js_function(app_js, "isActiveTask"), self._extract_js_function(app_js, "isActiveTask"),
self._extract_js_function(app_js, "activeTasksFromItems"), 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, "activeTaskChipLabel"),
self._extract_js_function(app_js, "headerTaskRenderKey"), self._extract_js_function(app_js, "headerTaskRenderKey"),
self._extract_js_function(app_js, "shouldPollHeaderTasks"), self._extract_js_function(app_js, "shouldPollHeaderTasks"),
@@ -449,6 +466,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
textContent: "", textContent: "",
innerHTML: "", innerHTML: "",
children: [], children: [],
disabled: false,
onclick: null,
scrollTop: 0, scrollTop: 0,
attributes: {{}}, attributes: {{}},
append(...nodes) {{ append(...nodes) {{
@@ -489,6 +508,9 @@ class UiSmokeGoldenTest(unittest.TestCase):
return value || "now"; return value || "now";
}} }}
async function refreshTasksSnapshot() {{}}
function setError() {{}}
let state = {{ lastTaskCount: 0 }}; let state = {{ lastTaskCount: 0 }};
let headerTaskState = {{ let headerTaskState = {{
activeItems: [], activeItems: [],
@@ -497,7 +519,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
lastRenderKey: "", lastRenderKey: "",
}}; }};
const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); 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} {functions}
@@ -781,11 +803,13 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('function formatTaskLine(task)', app_js) self.assertIn('function formatTaskLine(task)', app_js)
self.assertIn('let headerTaskState = {', 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_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("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 headerTaskElements()', app_js)
self.assertIn('function isActiveTask(task)', app_js) self.assertIn('function isActiveTask(task)', app_js)
self.assertIn('function activeTasksFromItems(items)', 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 activeTaskChipLabel(count)', app_js)
self.assertIn('function shouldPollHeaderTasks()', app_js) self.assertIn('function shouldPollHeaderTasks()', app_js)
self.assertIn('function scheduleHeaderTaskPolling()', app_js) self.assertIn('function scheduleHeaderTaskPolling()', app_js)
@@ -109,6 +109,38 @@ class TaskRecoveryServiceTest(unittest.TestCase):
self.assertEqual(task["status"], "failed") self.assertEqual(task["status"], "failed")
self.assertEqual(task["error_code"], "task_interrupted") 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__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -107,6 +107,68 @@ class TaskRepositoryTest(unittest.TestCase):
self.assertEqual(task["status"], "cancelled") self.assertEqual(task["status"], "cancelled")
self.assertIsNotNone(task["finished_at"]) 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: def test_reconcile_incomplete_tasks_marks_non_terminal_failed(self) -> None:
self.repo.insert_task_for_testing( self.repo.insert_task_for_testing(
{ {
+36 -1
View File
@@ -122,7 +122,7 @@ let headerTaskState = {
}; };
// The header chip reflects only user-visible file actions that use the shared task system. // 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_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 = [ const VALID_THEME_FAMILIES = [
"default", "default",
"macos-soft", "macos-soft",
@@ -3835,6 +3835,10 @@ function formatTaskStatusLabel(task) {
return "Queued"; return "Queued";
case "running": case "running":
return "Running"; return "Running";
case "cancelling":
return "Cancelling";
case "cancelled":
return "Cancelled";
case "completed": case "completed":
return "Completed"; return "Completed";
case "failed": case "failed":
@@ -3883,6 +3887,14 @@ function activeTasksFromItems(items) {
return Array.isArray(items) ? items.filter((task) => isActiveTask(task)) : []; 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) { function activeTaskChipLabel(count) {
return `${count} active task${count === 1 ? "" : "s"}`; return `${count} active task${count === 1 ? "" : "s"}`;
} }
@@ -3972,6 +3984,29 @@ function renderHeaderTaskPopover(items) {
meta.className = "header-task-item-meta"; meta.className = "header-task-item-meta";
meta.textContent = line.meta; meta.textContent = line.meta;
row.append(title, path, 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); elements.popoverList.append(row);
} }
headerTaskState.lastRenderKey = renderKey; headerTaskState.lastRenderKey = renderKey;
+12
View File
@@ -157,6 +157,18 @@ body {
word-break: break-word; 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 { h1, h2, h3 {
margin: 0; margin: 0;
} }