diff --git a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc index 45e1530..551a980 100644 Binary files a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc and b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc differ diff --git a/webui/backend/app/api/routes_files.py b/webui/backend/app/api/routes_files.py index 4af2200..8ad4fae 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile from fastapi.responses import StreamingResponse from starlette.background import BackgroundTask -from backend.app.api.schemas import ArchivePrepareRequest, DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, TaskCreateResponse, UploadResponse, ViewResponse +from backend.app.api.schemas import ArchivePrepareRequest, DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, TaskCreateResponse, TaskDetailResponse, UploadResponse, ViewResponse from backend.app.dependencies import get_archive_download_task_service, get_file_ops_service from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService from backend.app.services.file_ops_service import FileOpsService @@ -100,6 +100,14 @@ async def archive_download( ) +@router.post("/download/archive/{task_id}/cancel", response_model=TaskDetailResponse) +async def archive_cancel( + task_id: str, + service: ArchiveDownloadTaskService = Depends(get_archive_download_task_service), +) -> TaskDetailResponse: + return TaskDetailResponse(**service.cancel_archive_prepare_task(task_id=task_id)) + + @router.get("/video") async def video( path: str, diff --git a/webui/backend/app/db/__pycache__/history_repository.cpython-313.pyc b/webui/backend/app/db/__pycache__/history_repository.cpython-313.pyc index fc5a4d6..c232b76 100644 Binary files a/webui/backend/app/db/__pycache__/history_repository.cpython-313.pyc and b/webui/backend/app/db/__pycache__/history_repository.cpython-313.pyc differ diff --git a/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc b/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc index aff9cd2..da7a6d7 100644 Binary files a/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc and b/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc differ diff --git a/webui/backend/app/db/history_repository.py b/webui/backend/app/db/history_repository.py index 38c9363..209ef8f 100644 --- a/webui/backend/app/db/history_repository.py +++ b/webui/backend/app/db/history_repository.py @@ -6,7 +6,7 @@ from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path -VALID_HISTORY_STATUSES = {"queued", "completed", "failed", "requested", "ready", "preflight_failed"} +VALID_HISTORY_STATUSES = {"queued", "completed", "failed", "requested", "ready", "preflight_failed", "cancelled"} VALID_HISTORY_OPERATIONS = {"mkdir", "rename", "delete", "copy", "move", "upload", "download"} diff --git a/webui/backend/app/db/task_repository.py b/webui/backend/app/db/task_repository.py index 1cbb0c0..65c5372 100644 --- a/webui/backend/app/db/task_repository.py +++ b/webui/backend/app/db/task_repository.py @@ -6,7 +6,7 @@ from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path -VALID_STATUSES = {"queued", "running", "completed", "failed", "requested", "preparing", "ready"} +VALID_STATUSES = {"queued", "running", "completed", "failed", "requested", "preparing", "ready", "cancelled"} VALID_OPERATIONS = {"copy", "move", "download"} TASK_MIGRATION_COLUMNS: dict[str, str] = { "operation": "TEXT NOT NULL DEFAULT 'copy'", @@ -160,17 +160,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 = COALESCE(started_at, ?), done_items = ?, total_items = ?, current_item = ? - WHERE id = ? + WHERE id = ? AND status = ? """, - ("preparing", started_at, done_items, total_items, current_item, task_id), + ("preparing", started_at, done_items, total_items, current_item, task_id, "requested"), ) + return cursor.rowcount > 0 def update_progress( self, @@ -215,17 +216,18 @@ class TaskRepository: task_id: str, 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_items = ?, total_items = ?, current_item = NULL - WHERE id = ? + WHERE id = ? AND status = ? """, - ("ready", finished_at, done_items, total_items, task_id), + ("ready", finished_at, done_items, total_items, task_id, "preparing"), ) + return cursor.rowcount > 0 def mark_failed( self, @@ -260,6 +262,54 @@ class TaskRepository: ), ) + def mark_failed_if_not_cancelled( + self, + task_id: str, + error_code: str, + error_message: str, + failed_item: str | None, + done_bytes: int | None, + total_bytes: int | 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 = ?, error_code = ?, error_message = ?, failed_item = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = NULL + WHERE id = ? AND status != ? + """, + ( + "failed", + finished_at, + error_code, + error_message, + failed_item, + done_bytes, + total_bytes, + done_items, + total_items, + task_id, + "cancelled", + ), + ) + return cursor.rowcount > 0 + + def mark_cancelled(self, task_id: str) -> bool: + finished_at = self._now_iso() + with self._connection() as conn: + cursor = conn.execute( + """ + UPDATE tasks + SET status = ?, finished_at = ?, current_item = NULL + WHERE id = ? AND status IN (?, ?) + """, + ("cancelled", finished_at, task_id, "requested", "preparing"), + ) + 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 {"", "."}: diff --git a/webui/backend/app/services/__pycache__/archive_download_task_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/archive_download_task_service.cpython-313.pyc index eb1ff9b..b4b25d9 100644 Binary files a/webui/backend/app/services/__pycache__/archive_download_task_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/archive_download_task_service.cpython-313.pyc differ diff --git a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc index 8460427..e86d77c 100644 Binary files a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc differ diff --git a/webui/backend/app/services/archive_download_task_service.py b/webui/backend/app/services/archive_download_task_service.py index bef67b0..904c3b1 100644 --- a/webui/backend/app/services/archive_download_task_service.py +++ b/webui/backend/app/services/archive_download_task_service.py @@ -17,6 +17,10 @@ from backend.app.tasks_runner import TaskRunner ARCHIVE_DOWNLOAD_TTL_SECONDS = 30 * 60 +class ArchivePrepareCancelled(Exception): + pass + + class ArchiveDownloadTaskService: def __init__( self, @@ -103,6 +107,13 @@ class ArchiveDownloadTaskService: status_code=400, details={"task_id": task_id}, ) + if task["status"] == "cancelled": + raise AppError( + code="download_cancelled", + message="Archive download was cancelled", + status_code=409, + details={"task_id": task_id}, + ) if task["status"] != "ready": raise AppError( code="download_not_ready", @@ -147,6 +158,58 @@ class ArchiveDownloadTaskService: "content_type": "application/zip", } + def cancel_archive_prepare_task(self, task_id: str) -> dict: + self.sweep_artifacts() + 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"] != "download": + raise AppError( + code="invalid_request", + message="Task is not an archive download", + status_code=400, + details={"task_id": task_id}, + ) + if task["status"] == "ready": + raise AppError( + code="download_not_cancellable", + message="Archive download is already ready", + status_code=409, + details={"task_id": task_id, "status": task["status"]}, + ) + if task["status"] in {"failed", "cancelled"}: + raise AppError( + code="download_not_cancellable", + message="Archive download cannot be cancelled", + status_code=409, + details={"task_id": task_id, "status": task["status"]}, + ) + if not self._repository.mark_cancelled(task_id): + current = self._repository.get_task(task_id) + current_status = current["status"] if current else task["status"] + raise AppError( + code="download_not_cancellable", + message="Archive download cannot be cancelled", + status_code=409, + details={"task_id": task_id, "status": current_status}, + ) + self._cleanup_task_artifacts(task_id) + self._update_history_cancelled(task_id) + cancelled_task = self._repository.get_task(task_id) + if not cancelled_task: + raise AppError( + code="task_not_found", + message="Task was not found", + status_code=404, + details={"task_id": task_id}, + ) + return cancelled_task + def sweep_artifacts(self) -> None: self._artifact_root.mkdir(parents=True, exist_ok=True) referenced_paths: set[Path] = set() @@ -177,37 +240,59 @@ class ArchiveDownloadTaskService: total_items = len(target_paths) try: - self._repository.mark_preparing( + self._raise_if_cancelled(task_id) + if not self._repository.mark_preparing( task_id=task_id, done_items=0, total_items=total_items, current_item=target_paths[0] if target_paths else None, - ) + ): + self._raise_if_cancelled(task_id) + return resolved_targets = [self._path_guard.resolve_existing_path(path) for path in target_paths] + self._raise_if_cancelled(task_id) self._file_ops_service._validate_zip_download_archive_names(resolved_targets) self._file_ops_service._run_zip_download_preflight(resolved_targets) + self._raise_if_cancelled(task_id) with zipfile.ZipFile(partial_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: - for resolved_target in resolved_targets: - self._file_ops_service._write_download_target_to_zip(archive, resolved_target) + for index, resolved_target in enumerate(resolved_targets): + self._raise_if_cancelled(task_id) + self._repository.update_progress( + task_id=task_id, + done_items=index, + total_items=total_items, + current_item=resolved_target.relative, + ) + self._file_ops_service._write_download_target_to_zip( + archive, + resolved_target, + on_each_item=lambda: self._raise_if_cancelled(task_id), + ) + self._raise_if_cancelled(task_id) os.replace(partial_path, final_path) + self._raise_if_cancelled(task_id) self._repository.upsert_artifact( task_id=task_id, file_path=str(final_path), file_name=archive_name, expires_at=self._expires_at_iso(), ) - self._repository.mark_ready( + if not self._repository.mark_ready( task_id=task_id, done_items=total_items, total_items=total_items, - ) + ): + self._cleanup_task_artifacts(task_id) + self._raise_if_cancelled(task_id) + return self._update_history_ready(task_id) + except ArchivePrepareCancelled: + self._cleanup_task_artifacts(task_id) except AppError as exc: - self._delete_artifact_record_and_file(task_id, str(partial_path)) - self._delete_artifact_record_and_file(task_id, str(final_path)) - self._repository.mark_failed( + self._cleanup_task_artifacts(task_id) + if self._repository.mark_failed_if_not_cancelled( task_id=task_id, error_code=exc.code, error_message=exc.message, @@ -216,12 +301,11 @@ class ArchiveDownloadTaskService: total_bytes=None, done_items=0, total_items=total_items, - ) - self._update_history_failed(task_id, exc.code, exc.message) + ): + self._update_history_failed(task_id, exc.code, exc.message) except OSError as exc: - self._delete_artifact_record_and_file(task_id, str(partial_path)) - self._delete_artifact_record_and_file(task_id, str(final_path)) - self._repository.mark_failed( + self._cleanup_task_artifacts(task_id) + if self._repository.mark_failed_if_not_cancelled( task_id=task_id, error_code="io_error", error_message=str(exc), @@ -230,8 +314,12 @@ class ArchiveDownloadTaskService: total_bytes=None, done_items=0, total_items=total_items, - ) - self._update_history_failed(task_id, "io_error", str(exc)) + ): + self._update_history_failed(task_id, "io_error", str(exc)) + + def _cleanup_task_artifacts(self, task_id: str) -> None: + self._delete_artifact_record_and_file(task_id, str(self._artifact_root / f"{task_id}.partial.zip")) + self._delete_artifact_record_and_file(task_id, str(self._artifact_root / f"{task_id}.zip")) def _delete_artifact_record_and_file(self, task_id: str, file_path: str) -> None: self._repository.delete_artifact(task_id) @@ -254,6 +342,10 @@ class ArchiveDownloadTaskService: 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") + def _record_history(self, **kwargs) -> None: if self._history_repository: self._history_repository.create_entry(**kwargs) @@ -264,3 +356,8 @@ class ArchiveDownloadTaskService: @staticmethod def _is_expired(expires_at: str) -> bool: return datetime.now(timezone.utc) >= datetime.fromisoformat(expires_at.replace("Z", "+00:00")) + + def _raise_if_cancelled(self, task_id: str) -> None: + task = self._repository.get_task(task_id) + if task and task["status"] == "cancelled": + raise ArchivePrepareCancelled() diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py index d999a48..59d9086 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -1005,14 +1005,18 @@ class FileOpsService: details={"reason": reason, **details}, ) - def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target) -> None: + def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target, on_each_item=None) -> None: root_name = resolved_target.absolute.name if resolved_target.absolute.is_file(): + if on_each_item: + on_each_item() archive.write(resolved_target.absolute, arcname=root_name) return archive.writestr(f"{root_name}/", b"") for child in sorted(resolved_target.absolute.rglob("*")): + if on_each_item: + on_each_item() arcname = f"{root_name}/{child.relative_to(resolved_target.absolute).as_posix()}" if child.is_dir(): archive.writestr(f"{arcname}/", b"") diff --git a/webui/backend/data/archive_tmp/14e942d8-7921-42c4-bf84-4a0ebbae390c.zip b/webui/backend/data/archive_tmp/14e942d8-7921-42c4-bf84-4a0ebbae390c.zip new file mode 100644 index 0000000..2937009 Binary files /dev/null and b/webui/backend/data/archive_tmp/14e942d8-7921-42c4-bf84-4a0ebbae390c.zip differ diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index f6e4791..6918d01 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ diff --git a/webui/backend/tests/golden/__pycache__/test_api_download_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_download_golden.cpython-313.pyc index e6d8e5e..5c368f5 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_api_download_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_api_download_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/__pycache__/test_api_history_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_history_golden.cpython-313.pyc index 377e497..8ae603a 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_api_history_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_api_history_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/__pycache__/test_api_tasks_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_tasks_golden.cpython-313.pyc index 9a20171..d2e3f20 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_api_tasks_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_api_tasks_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 18ff6c4..56b1a57 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_api_download_golden.py b/webui/backend/tests/golden/test_api_download_golden.py index 43b7478..0f17fea 100644 --- a/webui/backend/tests/golden/test_api_download_golden.py +++ b/webui/backend/tests/golden/test_api_download_golden.py @@ -37,11 +37,26 @@ class BlockingArchiveFileOpsService(FileOpsService): class FailingArchiveFileOpsService(FileOpsService): - def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target) -> None: + def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target, on_each_item=None) -> None: archive.writestr("partial.txt", b"partial") raise OSError("forced archive failure") +class BlockingArchiveBuildFileOpsService(FileOpsService): + def __init__(self, *args, entered: threading.Event, release: threading.Event, **kwargs): + super().__init__(*args, **kwargs) + self._entered = entered + self._release = release + + def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target, on_each_item=None) -> None: + archive.writestr("partial.txt", b"partial") + self._entered.set() + self._release.wait(timeout=2.0) + if on_each_item: + on_each_item() + super()._write_download_target_to_zip(archive, resolved_target, on_each_item=on_each_item) + + class DownloadApiGoldenTest(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() @@ -221,6 +236,71 @@ class DownloadApiGoldenTest(unittest.TestCase): self.assertEqual(task["error_code"], "io_error") self.assertEqual(list(self.artifact_root.glob("*")), []) + def test_archive_cancel_during_preparing_sets_cancelled_and_removes_partial_artifact(self) -> None: + entered = threading.Event() + release = threading.Event() + file_ops_service = BlockingArchiveBuildFileOpsService( + path_guard=self.path_guard, + filesystem=self.filesystem, + history_repository=self.history_repo, + zip_download_preflight_limits=ZipDownloadPreflightLimits(), + entered=entered, + release=release, + ) + self._override_services(file_ops_service=file_ops_service) + (self.root / "docs").mkdir() + (self.root / "docs" / "a.txt").write_text("a", encoding="utf-8") + + created = self._request("POST", "/api/files/download/archive-prepare", {"paths": ["storage1/docs"]}) + + self.assertEqual(created.status_code, 202) + self.assertTrue(entered.wait(timeout=2.0)) + response = self._request("POST", f"/api/files/download/archive/{created.json()['task_id']}/cancel") + release.set() + task = self._wait_for_task_status(created.json()["task_id"], {"cancelled"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], "cancelled") + self.assertEqual(task["status"], "cancelled") + self.assertEqual(list(self.artifact_root.glob("*")), []) + + def test_archive_retrieval_for_cancelled_task_rejected(self) -> None: + entered = threading.Event() + release = threading.Event() + file_ops_service = BlockingArchiveBuildFileOpsService( + path_guard=self.path_guard, + filesystem=self.filesystem, + history_repository=self.history_repo, + zip_download_preflight_limits=ZipDownloadPreflightLimits(), + entered=entered, + release=release, + ) + self._override_services(file_ops_service=file_ops_service) + (self.root / "docs").mkdir() + (self.root / "docs" / "a.txt").write_text("a", encoding="utf-8") + + created = self._request("POST", "/api/files/download/archive-prepare", {"paths": ["storage1/docs"]}) + + self.assertTrue(entered.wait(timeout=2.0)) + cancel_response = self._request("POST", f"/api/files/download/archive/{created.json()['task_id']}/cancel") + release.set() + response = self._request("GET", f"/api/files/download/archive/{created.json()['task_id']}") + + self.assertEqual(cancel_response.status_code, 200) + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "download_cancelled") + + def test_archive_cancel_after_ready_rejected(self) -> None: + (self.root / "docs").mkdir() + (self.root / "docs" / "a.txt").write_text("a", encoding="utf-8") + created = self._request("POST", "/api/files/download/archive-prepare", {"paths": ["storage1/docs"]}) + task = self._wait_for_task_status(created.json()["task_id"], {"ready"}) + + response = self._request("POST", f"/api/files/download/archive/{task['id']}/cancel") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "download_not_cancellable") + def test_expired_artifact_rejected_and_removed(self) -> None: (self.root / "docs").mkdir() (self.root / "docs" / "a.txt").write_text("a", encoding="utf-8") diff --git a/webui/backend/tests/golden/test_api_history_golden.py b/webui/backend/tests/golden/test_api_history_golden.py index 7653069..899bb98 100644 --- a/webui/backend/tests/golden/test_api_history_golden.py +++ b/webui/backend/tests/golden/test_api_history_golden.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import sys import tempfile +import threading import time import unittest from pathlib import Path @@ -11,12 +12,13 @@ import httpx sys.path.insert(0, str(Path(__file__).resolve().parents[3])) -from backend.app.dependencies import get_copy_task_service, get_file_ops_service, get_history_service, get_move_task_service, get_task_service +from backend.app.dependencies import get_archive_download_task_service, get_copy_task_service, get_file_ops_service, get_history_service, get_move_task_service, get_task_service from backend.app.db.history_repository import HistoryRepository from backend.app.db.task_repository import TaskRepository from backend.app.fs.filesystem_adapter import FilesystemAdapter from backend.app.main import app from backend.app.security.path_guard import PathGuard +from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService from backend.app.services.copy_task_service import CopyTaskService from backend.app.services.file_ops_service import FileOpsService from backend.app.services.history_service import HistoryService @@ -30,6 +32,21 @@ class FailingCopyFilesystemAdapter(FilesystemAdapter): raise OSError('forced copy failure') +class BlockingArchiveBuildFileOpsService(FileOpsService): + def __init__(self, *args, entered: threading.Event, release: threading.Event, **kwargs): + super().__init__(*args, **kwargs) + self._entered = entered + self._release = release + + def _write_download_target_to_zip(self, archive, resolved_target, on_each_item=None) -> None: + archive.writestr("partial.txt", b"partial") + self._entered.set() + self._release.wait(timeout=2.0) + if on_each_item: + on_each_item() + super()._write_download_target_to_zip(archive, resolved_target, on_each_item=on_each_item) + + class HistoryApiGoldenTest(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() @@ -38,6 +55,7 @@ class HistoryApiGoldenTest(unittest.TestCase): self.root1.mkdir(parents=True, exist_ok=True) self.root2.mkdir(parents=True, exist_ok=True) db_path = str(Path(self.temp_dir.name) / 'tasks.db') + self.artifact_root = Path(self.temp_dir.name) / "archive_tmp" self.task_repo = TaskRepository(db_path) self.history_repo = HistoryRepository(db_path) self.path_guard = PathGuard({'storage1': str(self.root1), 'storage2': str(self.root2)}) @@ -47,9 +65,17 @@ class HistoryApiGoldenTest(unittest.TestCase): app.dependency_overrides.clear() self.temp_dir.cleanup() - def _set_services(self, filesystem: FilesystemAdapter) -> None: + def _set_services(self, filesystem: FilesystemAdapter, file_ops_service: FileOpsService | None = None) -> None: runner = TaskRunner(repository=self.task_repo, filesystem=filesystem, history_repository=self.history_repo) - file_ops_service = FileOpsService(path_guard=self.path_guard, filesystem=filesystem, history_repository=self.history_repo) + file_ops_service = file_ops_service or FileOpsService(path_guard=self.path_guard, filesystem=filesystem, history_repository=self.history_repo) + archive_service = ArchiveDownloadTaskService( + path_guard=self.path_guard, + repository=self.task_repo, + runner=runner, + history_repository=self.history_repo, + file_ops_service=file_ops_service, + artifact_root=self.artifact_root, + ) copy_service = CopyTaskService(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) @@ -58,6 +84,9 @@ class HistoryApiGoldenTest(unittest.TestCase): async def _override_file_ops_service() -> FileOpsService: return file_ops_service + async def _override_archive_service() -> ArchiveDownloadTaskService: + return archive_service + async def _override_copy_service() -> CopyTaskService: return copy_service @@ -71,6 +100,7 @@ class HistoryApiGoldenTest(unittest.TestCase): return history_service app.dependency_overrides[get_file_ops_service] = _override_file_ops_service + app.dependency_overrides[get_archive_download_task_service] = _override_archive_service app.dependency_overrides[get_copy_task_service] = _override_copy_service app.dependency_overrides[get_move_task_service] = _override_move_service app.dependency_overrides[get_task_service] = _override_task_service @@ -91,7 +121,7 @@ class HistoryApiGoldenTest(unittest.TestCase): while time.time() < deadline: response = self._request('GET', f'/api/tasks/{task_id}') body = response.json() - if body['status'] in {'completed', 'failed', 'ready'}: + if body['status'] in {'completed', 'failed', 'ready', 'cancelled'}: return body time.sleep(0.02) self.fail('task did not reach terminal state in time') @@ -244,6 +274,38 @@ class HistoryApiGoldenTest(unittest.TestCase): self.assertEqual(history[0]['error_code'], 'download_preflight_failed') self.assertEqual(history[0]['error_message'], 'Zip download preflight failed') + def test_download_cancellation_writes_cancelled_history_item(self) -> None: + entered = threading.Event() + release = threading.Event() + file_ops_service = BlockingArchiveBuildFileOpsService( + path_guard=self.path_guard, + filesystem=FilesystemAdapter(), + history_repository=self.history_repo, + entered=entered, + release=release, + ) + self._set_services(FilesystemAdapter(), file_ops_service=file_ops_service) + (self.root1 / 'docs').mkdir() + (self.root1 / 'docs' / 'a.txt').write_text('A', encoding='utf-8') + + response = self._request('POST', '/api/files/download/archive-prepare', {'paths': ['storage1/docs']}) + + self.assertEqual(response.status_code, 202) + self.assertTrue(entered.wait(timeout=2.0)) + cancel = self._request('POST', f"/api/files/download/archive/{response.json()['task_id']}/cancel") + release.set() + self._wait_task(response.json()['task_id']) + history = self._request('GET', '/api/history').json()['items'] + + self.assertEqual(cancel.status_code, 200) + self.assertEqual(history[0]['operation'], 'download') + self.assertEqual(history[0]['status'], 'cancelled') + self.assertEqual(history[0]['source'], 'single_directory_zip') + self.assertEqual(history[0]['path'], 'storage1/docs') + self.assertEqual(history[0]['destination'], 'docs.zip') + self.assertEqual(history[0]['error_code'], None) + self.assertEqual(history[0]['error_message'], None) + def test_download_history_uses_server_certain_statuses_only(self) -> None: (self.root1 / 'report.txt').write_text('hello download', encoding='utf-8') @@ -251,5 +313,5 @@ class HistoryApiGoldenTest(unittest.TestCase): self.assertEqual(response.status_code, 200) history = self._request('GET', '/api/history').json()['items'] - self.assertIn(history[0]['status'], {'requested', 'ready', 'preflight_failed', 'failed'}) + self.assertIn(history[0]['status'], {'requested', 'ready', 'preflight_failed', 'failed', 'cancelled'}) self.assertNotIn(history[0]['status'], {'completed', 'downloaded', 'saved'}) diff --git a/webui/backend/tests/golden/test_api_tasks_golden.py b/webui/backend/tests/golden/test_api_tasks_golden.py index 88840e2..bd14b72 100644 --- a/webui/backend/tests/golden/test_api_tasks_golden.py +++ b/webui/backend/tests/golden/test_api_tasks_golden.py @@ -263,6 +263,28 @@ class TasksApiGoldenTest(unittest.TestCase): self.assertEqual(body["status"], "ready") self.assertEqual(body["destination"], "docs.zip") + def test_get_task_detail_cancelled_archive_download(self) -> None: + self._insert_task( + task_id="task-download-cancelled", + operation="download", + status="cancelled", + source="storage1/docs", + destination="docs.zip", + created_at="2026-03-10T10:00:00Z", + started_at="2026-03-10T10:00:01Z", + finished_at="2026-03-10T10:00:03Z", + done_items=0, + total_items=1, + ) + + response = self._get("/api/tasks/task-download-cancelled") + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["operation"], "download") + self.assertEqual(body["status"], "cancelled") + self.assertEqual(body["destination"], "docs.zip") + def test_get_task_not_found(self) -> None: response = self._get("/api/tasks/task-missing") diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 3084fec..9e25d32 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -74,6 +74,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="download-modal-progress-bar"', body) self.assertIn('id="download-modal-count"', body) self.assertIn('id="download-modal-status"', body) + self.assertIn('id="download-modal-cancel-btn"', body) self.assertIn('id="download-modal-close-btn"', body) self.assertIn('id="context-menu"', body) self.assertIn('id="context-menu-scope"', body) @@ -231,11 +232,14 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function openZipDownloadModal(selectedItems)', app_js) self.assertIn('function markZipDownloadReady(fileName)', app_js) self.assertIn('function markZipDownloadFailed(err)', app_js) + self.assertIn('function markZipDownloadCancelled()', app_js) self.assertIn('function closeDownloadModal()', app_js) self.assertIn('function zipDownloadRequestKey(paths)', app_js) self.assertIn('async function createArchiveDownloadTask(paths)', app_js) self.assertIn('async function getTaskRequest(taskId)', app_js) + self.assertIn('async function cancelArchiveDownloadTask(taskId)', app_js) self.assertIn('function startArchiveDownload(taskId, fileName)', app_js) + self.assertIn('async function requestArchiveDownloadCancel()', app_js) self.assertIn('async function waitForArchiveDownloadReady(taskId)', app_js) self.assertIn('function contextMenuElements()', app_js) self.assertIn('function openContextMenu(pane, entry, event)', app_js) @@ -251,12 +255,15 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('statusText: "Download started"', app_js) self.assertIn('countText: "Browser download started"', app_js) self.assertIn('countText: "Zip download failed"', app_js) + self.assertIn('countText: "Zip download cancelled"', app_js) + self.assertIn('statusText: "Cancelling download..."', app_js) self.assertIn('statusText: err.message || "Download failed"', app_js) self.assertIn('downloadProgressState.requestKey === requestKey', app_js) self.assertIn('setStatus("Preparing download...");', app_js) self.assertIn('"/api/files/download/archive-prepare"', app_js) self.assertIn('`/api/tasks/${encodeURIComponent(taskId)}`', app_js) self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}`', app_js) + self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}/cancel`', app_js) self.assertIn('function applyContextMenuSelection()', app_js) self.assertIn('function startContextMenuOpen()', app_js) self.assertIn('function startContextMenuEdit()', app_js) diff --git a/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc b/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc index 0cb995b..8de61b3 100644 Binary files a/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc and b/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc differ diff --git a/webui/backend/tests/unit/test_task_repository.py b/webui/backend/tests/unit/test_task_repository.py index 14d6e4e..4abf635 100644 --- a/webui/backend/tests/unit/test_task_repository.py +++ b/webui/backend/tests/unit/test_task_repository.py @@ -80,6 +80,21 @@ class TaskRepositoryTest(unittest.TestCase): self.assertEqual(task["status"], "requested") self.assertEqual(artifact["file_name"], "docs.zip") + def test_mark_cancelled_transitions_requested_download_task(self) -> None: + created = self.repo.create_task( + operation="download", + source="storage1/docs", + destination="docs.zip", + status="requested", + ) + + changed = self.repo.mark_cancelled(created["id"]) + task = self.repo.get_task(created["id"]) + + self.assertTrue(changed) + self.assertEqual(task["status"], "cancelled") + self.assertIsNotNone(task["finished_at"]) + def test_migrates_legacy_tasks_schema_missing_source_destination(self) -> None: legacy_db_path = Path(self.temp_dir.name) / "legacy.db" conn = sqlite3.connect(legacy_db_path) diff --git a/webui/html/app.js b/webui/html/app.js index 0960de0..68638a8 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -86,6 +86,8 @@ let downloadProgressState = { archiveLabel: "", totalItems: 0, requestKey: null, + taskId: null, + cancelRequested: false, }; let folderUploadPlanState = { targetPane: "left", @@ -336,6 +338,7 @@ function downloadModalElements() { count: document.getElementById("download-modal-count"), progressBar: document.getElementById("download-modal-progress-bar"), status: document.getElementById("download-modal-status"), + cancelButton: document.getElementById("download-modal-cancel-btn"), closeButton: document.getElementById("download-modal-close-btn"), }; } @@ -400,6 +403,8 @@ function updateDownloadModalDisplay(info) { elements.count.textContent = info.countText || ""; elements.status.textContent = info.statusText || ""; elements.progressBar.style.width = `${Math.max(0, Math.min(100, info.percent || 0))}%`; + elements.cancelButton.disabled = !!info.cancelDisabled; + elements.cancelButton.classList.toggle("hidden", !info.cancelVisible); elements.closeButton.disabled = !!info.active; elements.closeButton.classList.toggle("hidden", !!info.active); } @@ -410,6 +415,8 @@ function openZipDownloadModal(selectedItems) { downloadProgressState.archiveLabel = "ZIP archive"; downloadProgressState.totalItems = selectedItems.length; downloadProgressState.requestKey = zipDownloadRequestKey(requestPaths); + downloadProgressState.taskId = null; + downloadProgressState.cancelRequested = false; setDownloadModalVisible(true); updateDownloadModalDisplay({ active: true, @@ -418,6 +425,8 @@ function openZipDownloadModal(selectedItems) { countText: "Preparing zip download", statusText: "Preparing download...", percent: 20, + cancelVisible: true, + cancelDisabled: true, }); requestAnimationFrame(() => { if (!downloadProgressState.active) { @@ -430,12 +439,15 @@ function openZipDownloadModal(selectedItems) { countText: "Zip preflight and packaging", statusText: "Preparing download...", percent: 55, + cancelVisible: true, + cancelDisabled: !downloadProgressState.taskId || downloadProgressState.cancelRequested, }); }); } function markZipDownloadReady(fileName) { downloadProgressState.active = false; + downloadProgressState.cancelRequested = false; downloadProgressState.archiveLabel = fileName || "ZIP archive"; updateDownloadModalDisplay({ active: false, @@ -444,12 +456,14 @@ function markZipDownloadReady(fileName) { countText: "Browser download started", statusText: "Download started", percent: 100, + cancelVisible: false, }); window.setTimeout(closeDownloadModal, 480); } function markZipDownloadFailed(err) { downloadProgressState.active = false; + downloadProgressState.cancelRequested = false; updateDownloadModalDisplay({ active: false, targetText: "Preparing download...", @@ -457,6 +471,21 @@ function markZipDownloadFailed(err) { countText: "Zip download failed", statusText: err.message || "Download failed", percent: 0, + cancelVisible: false, + }); +} + +function markZipDownloadCancelled() { + downloadProgressState.active = false; + downloadProgressState.cancelRequested = false; + updateDownloadModalDisplay({ + active: false, + targetText: "Download cancelled", + currentFileText: `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`, + countText: "Zip download cancelled", + statusText: "Download cancelled", + percent: 0, + cancelVisible: false, }); } @@ -469,8 +498,10 @@ function updateZipDownloadTaskProgress(task) { targetText: "Preparing download...", currentFileText: task.current_item ? `Current: ${task.current_item}` : `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`, countText: task.total_items ? `${task.done_items || 0}/${task.total_items} top-level items` : "Preparing zip download", - statusText: task.status === "ready" ? "Download started" : "Preparing download...", + statusText: downloadProgressState.cancelRequested ? "Cancelling download..." : task.status === "ready" ? "Download started" : "Preparing download...", percent: task.status === "ready" ? 100 : 55, + cancelVisible: true, + cancelDisabled: !downloadProgressState.taskId || downloadProgressState.cancelRequested, }); } @@ -484,6 +515,11 @@ async function waitForArchiveDownloadReady(taskId) { if (task.status === "ready") { return task; } + if (task.status === "cancelled") { + const err = new Error("Archive download was cancelled"); + err.code = "download_cancelled"; + throw err; + } if (task.status === "failed") { const err = new Error(task.error_message || "Archive download failed"); err.code = task.error_code || null; @@ -501,6 +537,8 @@ function closeDownloadModal() { downloadProgressState.archiveLabel = ""; downloadProgressState.totalItems = 0; downloadProgressState.requestKey = null; + downloadProgressState.taskId = null; + downloadProgressState.cancelRequested = false; updateDownloadModalDisplay({ active: false, targetText: "", @@ -508,6 +546,7 @@ function closeDownloadModal() { countText: "", statusText: "", percent: 0, + cancelVisible: false, }); setDownloadModalVisible(false); } @@ -679,6 +718,13 @@ async function startDownloadSelected() { const selected = selectedItems[0]; if (zipDownload) { const created = await createArchiveDownloadTask(selectedPaths); + downloadProgressState.taskId = created.task_id; + updateZipDownloadTaskProgress({ + status: "preparing", + current_item: null, + done_items: 0, + total_items: selectedItems.length, + }); const task = await waitForArchiveDownloadReady(created.task_id); startArchiveDownload(task.id, task.destination); markZipDownloadReady(task.destination); @@ -697,8 +743,13 @@ async function startDownloadSelected() { setStatus(`Download started: ${anchor.download}`); } catch (err) { if (zipDownload) { - markZipDownloadFailed(err); - setStatus("Download failed"); + if (err.code === "download_cancelled") { + markZipDownloadCancelled(); + setStatus("Download cancelled"); + } else { + markZipDownloadFailed(err); + setStatus("Download failed"); + } } else { setActionError("Download", err); } @@ -1004,6 +1055,10 @@ async function getTaskRequest(taskId) { return apiRequest("GET", `/api/tasks/${encodeURIComponent(taskId)}`); } +async function cancelArchiveDownloadTask(taskId) { + return apiRequest("POST", `/api/files/download/archive/${encodeURIComponent(taskId)}/cancel`); +} + function startArchiveDownload(taskId, fileName) { const anchor = document.createElement("a"); anchor.href = `/api/files/download/archive/${encodeURIComponent(taskId)}`; @@ -1013,6 +1068,31 @@ function startArchiveDownload(taskId, fileName) { anchor.remove(); } +async function requestArchiveDownloadCancel() { + if (!downloadProgressState.active || !downloadProgressState.taskId || downloadProgressState.cancelRequested) { + return; + } + downloadProgressState.cancelRequested = true; + updateDownloadModalDisplay({ + active: true, + targetText: "Preparing download...", + currentFileText: `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`, + countText: "Zip download cancellation requested", + statusText: "Cancelling download...", + percent: 55, + cancelVisible: true, + cancelDisabled: true, + }); + try { + await cancelArchiveDownloadTask(downloadProgressState.taskId); + } catch (err) { + if (err.code !== "download_not_cancellable") { + downloadProgressState.cancelRequested = false; + throw err; + } + } +} + async function uploadFileRequest(targetPath, file, overwrite = false) { const formData = new FormData(); formData.append("target_path", targetPath); @@ -4164,6 +4244,14 @@ function setupEvents() { }; } const downloadModal = downloadModalElements(); + if (downloadModal.cancelButton) { + downloadModal.cancelButton.onclick = () => { + requestArchiveDownloadCancel().catch((err) => { + markZipDownloadFailed(err); + setStatus("Download failed"); + }); + }; + } if (downloadModal.closeButton) { downloadModal.closeButton.onclick = closeDownloadModal; } diff --git a/webui/html/index.html b/webui/html/index.html index 5165c86..2823fc4 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -129,6 +129,7 @@