feat: voortgang copy/duplicate/move in headerbar

This commit is contained in:
kodi
2026-03-15 11:40:21 +01:00
parent 9d5fb5a0c9
commit 73b09d2802
24 changed files with 1104 additions and 2 deletions
Binary file not shown.
@@ -119,6 +119,27 @@ class HistoryRepository:
),
)
def reconcile_entries_failed(
self,
entry_ids: list[str],
*,
error_code: str = "task_interrupted",
error_message: str = "Task was interrupted before completion",
) -> None:
if not entry_ids:
return
finished_at = self._now_iso()
placeholders = ", ".join("?" for _ in entry_ids)
with self._connection() as conn:
conn.execute(
f"""
UPDATE history
SET status = ?, error_code = ?, error_message = ?, finished_at = ?
WHERE id IN ({placeholders})
""",
("failed", error_code, error_message, finished_at, *entry_ids),
)
def _ensure_schema(self) -> None:
db_path = Path(self._db_path)
if db_path.parent and str(db_path.parent) not in {"", "."}:
+39
View File
@@ -8,6 +8,7 @@ from pathlib import Path
VALID_STATUSES = {"queued", "running", "completed", "failed", "requested", "preparing", "ready", "cancelled"}
VALID_OPERATIONS = {"copy", "move", "download", "duplicate"}
NON_TERMINAL_STATUSES = ("queued", "running", "requested", "preparing")
TASK_MIGRATION_COLUMNS: dict[str, str] = {
"operation": "TEXT NOT NULL DEFAULT 'copy'",
"status": "TEXT NOT NULL DEFAULT 'queued'",
@@ -394,6 +395,44 @@ class TaskRepository:
with self._connection() as conn:
conn.execute("DELETE FROM task_artifacts WHERE task_id = ?", (task_id,))
def reconcile_incomplete_tasks(
self,
*,
error_code: str = "task_interrupted",
error_message: str = "Task was interrupted before completion",
) -> list[str]:
finished_at = self._now_iso()
placeholders = ", ".join("?" for _ in NON_TERMINAL_STATUSES)
with self._connection() as conn:
rows = conn.execute(
f"""
SELECT id
FROM tasks
WHERE status IN ({placeholders})
""",
NON_TERMINAL_STATUSES,
).fetchall()
task_ids = [row["id"] for row in rows]
if not task_ids:
return []
task_placeholders = ", ".join("?" for _ in task_ids)
conn.execute(
f"""
UPDATE tasks
SET status = ?, finished_at = ?, error_code = ?, error_message = ?, current_item = NULL
WHERE id IN ({task_placeholders})
""",
("failed", finished_at, error_code, error_message, *task_ids),
)
conn.execute(
f"""
DELETE FROM task_artifacts
WHERE task_id IN ({task_placeholders})
""",
task_ids,
)
return task_ids
def _migrate_tasks_columns(self, conn: sqlite3.Connection) -> None:
rows = conn.execute("PRAGMA table_info(tasks)").fetchall()
existing_columns = {row["name"] for row in rows}
+10
View File
@@ -17,7 +17,9 @@ from backend.app.api.routes_move import router as move_router
from backend.app.api.routes_search import router as search_router
from backend.app.api.routes_settings import router as settings_router
from backend.app.api.routes_tasks import router as tasks_router
from backend.app.dependencies import get_history_repository, get_task_repository
from backend.app.logging import configure_logging
from backend.app.services.task_recovery_service import reconcile_persisted_incomplete_tasks
configure_logging()
@@ -40,6 +42,14 @@ app.include_router(history_router, prefix="/api")
app.include_router(tasks_router, prefix="/api")
@app.on_event("startup")
async def reconcile_incomplete_tasks_on_startup() -> None:
reconcile_persisted_incomplete_tasks(
task_repository=get_task_repository(),
history_repository=get_history_repository(),
)
@app.exception_handler(AppError)
async def handle_app_error(_: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
@@ -0,0 +1,14 @@
from __future__ import annotations
from backend.app.db.history_repository import HistoryRepository
from backend.app.db.task_repository import TaskRepository
def reconcile_persisted_incomplete_tasks(
task_repository: TaskRepository,
history_repository: HistoryRepository,
) -> list[str]:
task_ids = task_repository.reconcile_incomplete_tasks()
if task_ids:
history_repository.reconcile_entries_failed(task_ids)
return task_ids
Binary file not shown.
@@ -231,6 +231,300 @@ class UiSmokeGoldenTest(unittest.TestCase):
)
self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout)
def _run_header_task_chip_behavior_check(self, app_js: str) -> None:
functions = "\n\n".join(
[
self._extract_js_function(app_js, "headerTaskElements"),
self._extract_js_function(app_js, "formatTaskStatusLabel"),
self._extract_js_function(app_js, "inferDownloadTaskContext"),
self._extract_js_function(app_js, "formatTaskLine"),
self._extract_js_function(app_js, "isActiveTask"),
self._extract_js_function(app_js, "activeTasksFromItems"),
self._extract_js_function(app_js, "activeTaskChipLabel"),
self._extract_js_function(app_js, "headerTaskRenderKey"),
self._extract_js_function(app_js, "shouldPollHeaderTasks"),
self._extract_js_function(app_js, "stopHeaderTaskPolling"),
self._extract_js_function(app_js, "scheduleHeaderTaskPolling"),
self._extract_js_function(app_js, "setHeaderTaskPopoverOpen"),
self._extract_js_function(app_js, "renderHeaderTaskPopover"),
self._extract_js_function(app_js, "renderHeaderTaskChip"),
self._extract_js_function(app_js, "updateHeaderTaskState"),
]
)
script = textwrap.dedent(
f"""
const assert = (condition, message) => {{
if (!condition) {{
throw new Error(message);
}}
}};
function createClassList(initialHidden = false) {{
const names = new Set(initialHidden ? ["hidden"] : []);
return {{
add(name) {{ names.add(name); }},
remove(name) {{ names.delete(name); }},
toggle(name, force) {{
if (force === undefined) {{
if (names.has(name)) {{
names.delete(name);
}} else {{
names.add(name);
}}
return;
}}
if (force) {{
names.add(name);
}} else {{
names.delete(name);
}}
}},
contains(name) {{ return names.has(name); }},
}};
}}
function createElement(initialHidden = false) {{
return {{
classList: createClassList(initialHidden),
textContent: "",
innerHTML: "",
children: [],
scrollTop: 0,
attributes: {{}},
append(...nodes) {{
this.children.push(...nodes);
}},
setAttribute(name, value) {{
this.attributes[name] = value;
}},
}};
}}
const elements = {{
"header-task-chip-container": createElement(true),
"header-task-chip-btn": createElement(false),
"header-task-chip-label": createElement(false),
"header-task-popover": createElement(true),
"header-task-popover-list": createElement(false),
"header-task-logs-btn": createElement(false),
}};
const document = {{
getElementById(id) {{
return elements[id] || null;
}},
createElement() {{
return createElement(false);
}},
}};
const window = {{
setTimeout(fn, delay) {{
return 1;
}},
clearTimeout(id) {{}},
}};
function formatModified(value) {{
return value || "now";
}}
let headerTaskState = {{
activeItems: [],
popoverOpen: false,
pollTimer: null,
lastRenderKey: "",
}};
const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]);
const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);
{functions}
const mixedTasks = [
{{ id: "a", operation: "copy", status: "queued", source: "/src/a", destination: "/dst/a" }},
{{ id: "b", operation: "move", status: "running", source: "/src/b", destination: "/dst/b", done_items: 1, total_items: 3, current_item: "b.mkv" }},
{{ id: "c", operation: "download", status: "requested", source: "/src/c", destination: "kodidownload-20260315-120000.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: "del", operation: "delete", status: "running", source: "/src/del", destination: "" }},
{{ id: "e", operation: "copy", status: "completed", source: "/src/e", destination: "/dst/e" }},
{{ id: "f", operation: "move", status: "failed", source: "/src/f", destination: "/dst/f" }},
{{ id: "g", operation: "download", status: "ready", source: "/src/g", destination: "folder.zip" }},
{{ id: "h", operation: "download", status: "cancelled", source: "/src/h", destination: "folder.zip" }},
];
const activeTasks = activeTasksFromItems(mixedTasks);
assert(activeTasks.length === 3, "Only copy, move and duplicate tasks in queued or running should count as active");
assert(activeTasks.every((task) => isActiveTask(task)), "All filtered tasks should be active");
assert(!activeTasks.some((task) => task.operation === "delete"), "Delete should not be counted because it is not task-based in the current UI flow");
assert(activeTaskChipLabel(activeTasks.length) === "3 active tasks", "Chip label should reflect active task count");
updateHeaderTaskState(mixedTasks);
assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Chip should be visible with active tasks");
assert(elements["header-task-chip-label"].textContent === "3 active tasks", "Chip label should render active task count");
assert(shouldPollHeaderTasks(), "Active tasks should enable header polling");
setHeaderTaskPopoverOpen(true);
assert(headerTaskState.popoverOpen, "Popover should open when active tasks exist");
assert(!elements["header-task-popover"].classList.contains("hidden"), "Popover should be visible when open");
assert(elements["header-task-chip-btn"].attributes["aria-expanded"] === "true", "Chip button should expose expanded state");
assert(elements["header-task-popover-list"].children.length === 3, "Popover should render only active file-action tasks");
updateHeaderTaskState([
{{ id: "z1", operation: "copy", status: "completed", source: "/src/z1", destination: "/dst/z1" }},
{{ id: "z2", operation: "move", status: "failed", source: "/src/z2", destination: "/dst/z2" }},
{{ id: "z3", operation: "download", status: "ready", source: "/src/z3", destination: "folder.zip" }},
]);
assert(elements["header-task-chip-container"].classList.contains("hidden"), "Chip should hide when no active tasks remain");
assert(!headerTaskState.popoverOpen, "Popover should close when no active tasks remain");
assert(elements["header-task-popover"].classList.contains("hidden"), "Popover should be hidden when no active tasks remain");
assert(elements["header-task-chip-btn"].attributes["aria-expanded"] === "false", "Chip button should reset expanded state when hidden");
"""
)
result = subprocess.run(
["node", "-e", script],
cwd="/workspace/webmanager-mvp",
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout)
def _run_task_snapshot_sync_behavior_check(self, app_js: str) -> None:
functions = "\n\n".join(
[
self._extract_js_function(app_js, "headerTaskElements"),
self._extract_js_function(app_js, "formatTaskStatusLabel"),
self._extract_js_function(app_js, "inferDownloadTaskContext"),
self._extract_js_function(app_js, "formatTaskLine"),
self._extract_js_function(app_js, "isActiveTask"),
self._extract_js_function(app_js, "activeTasksFromItems"),
self._extract_js_function(app_js, "activeTaskChipLabel"),
self._extract_js_function(app_js, "headerTaskRenderKey"),
self._extract_js_function(app_js, "shouldPollHeaderTasks"),
self._extract_js_function(app_js, "stopHeaderTaskPolling"),
self._extract_js_function(app_js, "scheduleHeaderTaskPolling"),
self._extract_js_function(app_js, "setHeaderTaskPopoverOpen"),
self._extract_js_function(app_js, "renderHeaderTaskPopover"),
self._extract_js_function(app_js, "renderHeaderTaskChip"),
self._extract_js_function(app_js, "updateHeaderTaskState"),
self._extract_js_function(app_js, "applyTaskSnapshot"),
]
)
script = textwrap.dedent(
f"""
const assert = (condition, message) => {{
if (!condition) {{
throw new Error(message);
}}
}};
function createClassList(initialHidden = false) {{
const names = new Set(initialHidden ? ["hidden"] : []);
return {{
add(name) {{ names.add(name); }},
remove(name) {{ names.delete(name); }},
toggle(name, force) {{
if (force === undefined) {{
if (names.has(name)) {{
names.delete(name);
}} else {{
names.add(name);
}}
return;
}}
if (force) {{
names.add(name);
}} else {{
names.delete(name);
}}
}},
contains(name) {{ return names.has(name); }},
}};
}}
function createElement(initialHidden = false) {{
return {{
classList: createClassList(initialHidden),
textContent: "",
innerHTML: "",
children: [],
scrollTop: 0,
attributes: {{}},
append(...nodes) {{
this.children.push(...nodes);
}},
setAttribute(name, value) {{
this.attributes[name] = value;
}},
}};
}}
const elements = {{
"header-task-chip-container": createElement(true),
"header-task-chip-btn": createElement(false),
"header-task-chip-label": createElement(false),
"header-task-popover": createElement(true),
"header-task-popover-list": createElement(false),
"header-task-logs-btn": createElement(false),
}};
const document = {{
getElementById(id) {{
return elements[id] || null;
}},
createElement() {{
return createElement(false);
}},
}};
const window = {{
setTimeout(fn, delay) {{
return 1;
}},
clearTimeout(id) {{}},
}};
function formatModified(value) {{
return value || "now";
}}
let state = {{ lastTaskCount: 0 }};
let headerTaskState = {{
activeItems: [],
popoverOpen: false,
pollTimer: null,
lastRenderKey: "",
}};
const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]);
const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);
{functions}
applyTaskSnapshot([
{{ id: "copy-1", operation: "copy", status: "running", source: "/src", destination: "/dst" }},
]);
assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Running task should make chip visible");
assert(elements["header-task-chip-label"].textContent === "1 active task", "Chip should show one active task");
assert(headerTaskState.activeItems.length === 1, "Snapshot should store active task state");
applyTaskSnapshot([
{{ id: "copy-1", operation: "copy", status: "completed", source: "/src", destination: "/dst" }},
]);
assert(elements["header-task-chip-container"].classList.contains("hidden"), "Chip should hide when latest task snapshot has no active tasks");
assert(headerTaskState.activeItems.length === 0, "Active task state should be reset when tasks are completed");
assert(state.lastTaskCount === 1, "Total task snapshot should still reflect fetched tasks list length");
"""
)
result = subprocess.run(
["node", "-e", script],
cwd="/workspace/webmanager-mvp",
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout)
def test_ui_mount_and_index_contains_expected_panels(self) -> None:
mount = self._ui_mount()
self.assertIsInstance(mount.app, StaticFiles)
@@ -256,6 +550,11 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('/ui/assets/img/logo.svg', body)
self.assertIn('id="title-zone-actions"', body)
self.assertIn('id="status"', body)
self.assertIn('id="header-task-chip-container"', body)
self.assertIn('id="header-task-chip-btn"', body)
self.assertIn('id="header-task-popover"', body)
self.assertIn('id="header-task-popover-list"', body)
self.assertIn('id="header-task-logs-btn"', body)
self.assertIn('id="theme-toggle"', body)
self.assertIn('id="theme-toggle-icon"', body)
self.assertIn('id="left-pane"', body)
@@ -438,6 +737,10 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn("#title-brand {", base_css)
self.assertIn("#title-logo {", base_css)
self.assertIn("height: 32px;", base_css)
self.assertIn(".header-task-chip-container {", base_css)
self.assertIn(".header-task-chip {", base_css)
self.assertIn(".header-task-popover {", base_css)
self.assertIn(".header-task-popover-list {", base_css)
self.assertIn("width: min(1180px, calc(100vw - 32px));", base_css)
self.assertIn(".settings-activity-grid {", base_css)
self.assertIn("grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);", base_css)
@@ -476,6 +779,32 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('function formatTaskStatusLabel(task)', app_js)
self.assertIn('function inferDownloadTaskContext(task)', app_js)
self.assertIn('function formatTaskLine(task)', app_js)
self.assertIn('let headerTaskState = {', app_js)
self.assertIn('const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]);', app_js)
self.assertIn('const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);', app_js)
self.assertIn("Delete stays out of this set because it still runs as a direct request flow", app_js)
self.assertIn('function headerTaskElements()', app_js)
self.assertIn('function isActiveTask(task)', app_js)
self.assertIn('function activeTasksFromItems(items)', app_js)
self.assertIn('function activeTaskChipLabel(count)', app_js)
self.assertIn('function shouldPollHeaderTasks()', app_js)
self.assertIn('function scheduleHeaderTaskPolling()', app_js)
self.assertIn('function setHeaderTaskPopoverOpen(nextOpen)', app_js)
self.assertIn('function renderHeaderTaskPopover(items)', app_js)
self.assertIn('function renderHeaderTaskChip(items)', app_js)
self.assertIn('function updateHeaderTaskState(taskItems)', app_js)
self.assertIn('function applyTaskSnapshot(taskItems)', app_js)
self.assertIn('return `${count} active task${count === 1 ? "" : "s"}`;', app_js)
self.assertIn('ACTIVE_TASK_OPERATIONS.has(task.operation)', app_js)
self.assertIn('headerTaskState.activeItems = activeTasksFromItems(taskItems);', app_js)
self.assertIn('const open = Boolean(nextOpen) && headerTaskState.activeItems.length > 0;', app_js)
self.assertIn('const headerTasks = headerTaskElements();', app_js)
self.assertIn('headerTasks.chipButton.onclick = (event) => {', app_js)
self.assertIn('headerTasks.logsButton.onclick = () => {', app_js)
self.assertIn('setHeaderTaskPopoverOpen(!headerTaskState.popoverOpen);', app_js)
self.assertIn('setHeaderTaskPopoverOpen(false);', app_js)
self.assertIn('updateHeaderTaskState(items);', app_js)
self.assertIn('renderTaskItems(applyTaskSnapshot(data.items));', app_js)
self.assertIn('function renderTaskItems(items)', app_js)
self.assertIn('async function loadTasksForSettings()', app_js)
self.assertIn('async function loadLogsAndTasksForSettings()', app_js)
@@ -529,6 +858,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('setStatus(`Download requested: ${anchor.download}`);', app_js)
self.assertIn('"/api/files/download/archive-prepare"', app_js)
self.assertIn('"/api/files/duplicate"', app_js)
self.assertIn('"/api/files/delete"', 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)
@@ -553,6 +883,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('function startContextMenuDownload()', app_js)
self.assertIn('function startContextMenuRename()', app_js)
self.assertIn('function startDuplicateSelected()', app_js)
self.assertIn('async function deleteSelected()', app_js)
self.assertIn('function startContextMenuDuplicate()', app_js)
self.assertIn('function startContextMenuCopy()', app_js)
self.assertIn('function startContextMenuMove()', app_js)
@@ -637,6 +968,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('Delete selected items and folder contents?', app_js)
self.assertIn('async function loadSettings()', app_js)
self.assertIn('await loadSettings();', app_js)
self.assertIn('await refreshTasksSnapshot();', app_js)
self.assertIn('settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;', app_js)
self.assertIn('settings.generalSaveButton.onclick = handlePreferredStartupPathSave;', app_js)
self.assertIn('settings.interfaceSaveButton.onclick = handleInterfaceSave;', app_js)
@@ -676,6 +1008,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('function ensureFolderUploadPicker()', app_js)
self.assertIn('function openFolderPicker()', app_js)
self.assertIn('function uploadModalElements()', app_js)
self._run_header_task_chip_behavior_check(app_js)
self._run_task_snapshot_sync_behavior_check(app_js)
self.assertIn('function setUploadModalVisible(', app_js)
self.assertIn('function updateUploadModalDisplay(', app_js)
@@ -0,0 +1,93 @@
from __future__ import annotations
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from backend.app.db.history_repository import HistoryRepository
from backend.app.db.task_repository import TaskRepository
from backend.app.services.task_recovery_service import reconcile_persisted_incomplete_tasks
class TaskRecoveryServiceTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.db_path = str(Path(self.temp_dir.name) / "tasks.db")
self.task_repo = TaskRepository(self.db_path)
self.history_repo = HistoryRepository(self.db_path)
def tearDown(self) -> None:
self.temp_dir.cleanup()
def test_reconcile_persisted_incomplete_tasks_marks_old_non_terminal_tasks_failed(self) -> None:
self.task_repo.insert_task_for_testing(
{
"id": "task-running",
"operation": "copy",
"status": "running",
"source": "storage1/a.txt",
"destination": "storage2/a.txt",
"created_at": "2026-03-10T10:00:00Z",
"started_at": "2026-03-10T10:00:01Z",
"current_item": "storage1/a.txt",
}
)
self.history_repo.create_entry(
entry_id="task-running",
operation="copy",
status="queued",
source="storage1/a.txt",
destination="storage2/a.txt",
created_at="2026-03-10T10:00:00Z",
)
self.task_repo.insert_task_for_testing(
{
"id": "task-ready",
"operation": "download",
"status": "ready",
"source": "single_directory_zip",
"destination": "docs.zip",
"created_at": "2026-03-10T10:02:00Z",
"finished_at": "2026-03-10T10:03:00Z",
}
)
changed = reconcile_persisted_incomplete_tasks(self.task_repo, self.history_repo)
self.assertEqual(changed, ["task-running"])
task = self.task_repo.get_task("task-running")
self.assertEqual(task["status"], "failed")
self.assertEqual(task["error_code"], "task_interrupted")
self.assertEqual(task["error_message"], "Task was interrupted before completion")
self.assertIsNone(task["current_item"])
history = self.history_repo.list_history(limit=5)[0]
self.assertEqual(history["id"], "task-running")
self.assertEqual(history["status"], "failed")
self.assertEqual(history["error_code"], "task_interrupted")
ready_task = self.task_repo.get_task("task-ready")
self.assertEqual(ready_task["status"], "ready")
def test_reconcile_persisted_incomplete_tasks_is_noop_when_all_tasks_terminal(self) -> None:
self.task_repo.insert_task_for_testing(
{
"id": "task-completed",
"operation": "move",
"status": "completed",
"source": "storage1/a.txt",
"destination": "storage2/a.txt",
"created_at": "2026-03-10T10:00:00Z",
"finished_at": "2026-03-10T10:00:02Z",
}
)
changed = reconcile_persisted_incomplete_tasks(self.task_repo, self.history_repo)
self.assertEqual(changed, [])
self.assertEqual(self.task_repo.get_task("task-completed")["status"], "completed")
if __name__ == "__main__":
unittest.main()
@@ -107,6 +107,64 @@ class TaskRepositoryTest(unittest.TestCase):
self.assertEqual(task["status"], "cancelled")
self.assertIsNotNone(task["finished_at"])
def test_reconcile_incomplete_tasks_marks_non_terminal_failed(self) -> None:
self.repo.insert_task_for_testing(
{
"id": "task-running",
"operation": "copy",
"status": "running",
"source": "storage1/a",
"destination": "storage2/a",
"created_at": "2026-03-10T09:00:00Z",
"started_at": "2026-03-10T09:00:01Z",
"current_item": "storage1/a",
}
)
self.repo.insert_task_for_testing(
{
"id": "task-completed",
"operation": "copy",
"status": "completed",
"source": "storage1/b",
"destination": "storage2/b",
"created_at": "2026-03-10T09:05:00Z",
"finished_at": "2026-03-10T09:06:00Z",
}
)
changed = self.repo.reconcile_incomplete_tasks()
running = self.repo.get_task("task-running")
completed = self.repo.get_task("task-completed")
self.assertEqual(changed, ["task-running"])
self.assertEqual(running["status"], "failed")
self.assertEqual(running["error_code"], "task_interrupted")
self.assertEqual(running["error_message"], "Task was interrupted before completion")
self.assertIsNone(running["current_item"])
self.assertIsNotNone(running["finished_at"])
self.assertEqual(completed["status"], "completed")
def test_reconcile_incomplete_tasks_removes_stale_artifact(self) -> None:
created = self.repo.create_task(
operation="download",
source="storage1/docs",
destination="docs.zip",
status="preparing",
)
self.repo.upsert_artifact(
task_id=created["id"],
file_path="/tmp/docs.zip.partial",
file_name="docs.zip",
expires_at="2026-03-10T10:30:00Z",
)
changed = self.repo.reconcile_incomplete_tasks()
task = self.repo.get_task(created["id"])
self.assertEqual(changed, [created["id"]])
self.assertEqual(task["status"], "failed")
self.assertIsNone(self.repo.get_artifact(created["id"]))
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)