From a52493459a19cd3705b9588137c6b456ea27302f Mon Sep 17 00:00:00 2001 From: kodi Date: Sun, 15 Mar 2026 13:06:48 +0100 Subject: [PATCH] feat: annuleren taak toegevoegd --- project_docs/API_GOLDEN.md | 34 ++++ .../__pycache__/dependencies.cpython-313.pyc | Bin 7301 -> 7341 bytes .../__pycache__/tasks_runner.cpython-313.pyc | Bin 17122 -> 20776 bytes .../__pycache__/routes_tasks.cpython-313.pyc | Bin 1162 -> 1541 bytes webui/backend/app/api/routes_tasks.py | 7 +- .../task_repository.cpython-313.pyc | Bin 21718 -> 23799 bytes webui/backend/app/db/task_repository.py | 67 +++++-- webui/backend/app/dependencies.py | 2 +- .../__pycache__/task_service.cpython-313.pyc | Bin 2247 -> 3605 bytes webui/backend/app/services/task_service.py | 44 ++++- webui/backend/app/tasks_runner.py | 179 ++++++++++++++---- webui/backend/data/tasks.db | Bin 323584 -> 344064 bytes .../test_api_copy_golden.cpython-313.pyc | Bin 21624 -> 25803 bytes .../test_api_duplicate_golden.cpython-313.pyc | Bin 17836 -> 21857 bytes .../test_api_file_ops_golden.cpython-313.pyc | Bin 18291 -> 23203 bytes .../test_api_history_golden.cpython-313.pyc | Bin 27911 -> 31848 bytes .../test_api_move_golden.cpython-313.pyc | Bin 29822 -> 33971 bytes .../test_api_tasks_golden.cpython-313.pyc | Bin 15588 -> 18703 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 79856 -> 81548 bytes .../tests/golden/test_api_copy_golden.py | 59 +++++- .../tests/golden/test_api_duplicate_golden.py | 55 +++++- .../tests/golden/test_api_file_ops_golden.py | 73 ++++++- .../tests/golden/test_api_history_golden.py | 54 +++++- .../tests/golden/test_api_move_golden.py | 61 +++++- .../tests/golden/test_api_tasks_golden.py | 80 ++++++++ .../tests/golden/test_ui_smoke_golden.py | 38 +++- ...test_task_recovery_service.cpython-313.pyc | Bin 5705 -> 6964 bytes .../test_task_repository.cpython-313.pyc | Bin 9242 -> 12090 bytes .../tests/unit/test_task_recovery_service.py | 32 ++++ .../tests/unit/test_task_repository.py | 62 ++++++ webui/html/app.js | 37 +++- webui/html/base.css | 12 ++ 32 files changed, 835 insertions(+), 61 deletions(-) diff --git a/project_docs/API_GOLDEN.md b/project_docs/API_GOLDEN.md index 98245e0..8842f3f 100644 --- a/project_docs/API_GOLDEN.md +++ b/project_docs/API_GOLDEN.md @@ -129,6 +129,40 @@ Response shape: } ``` +### `POST /api/tasks/{task_id}/cancel` +Success for cancellable file-action task: +```json +{ + "id": "", + "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": "", "status": "completed" } + } +} +``` + Task not found: ```json { diff --git a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc index 59f49de28b905601ecb23221326529e73f60bc19..590bbb65a49b3632afcdf3a65b86a7f6b1741f21 100644 GIT binary patch delta 507 zcmZp*Tx-eqnU|M~0SK;4+?J^#x{>c7-{cJ8Kyg!`P?0&1&}1%>0KgyCdEQQ^sY z{6d<3KyC&Q7lR~UGBhyU;TF51WO`Z2{EAP~WuKIb+^H8>Qhye?Os*HKpZttJnlW&) zlfWLvfXV*_+!;eBy9=5#1_R|97y~8=c`=4h_7;K*OlA>w<&OlK+YZDvi%ciyh?p?OPF^UYz*)=!RMR9R!ZT)BC&;#*isBp2!jNXB!rRJ zB1mj;BsM=1TMUUU0cCHN7vIIq4T_4QKoAi+`M#9WWK&Uo5e}Q&{FKt1RJ)=opadfj T7l&@vl-6csN@JWHDq{cuY+z@o delta 490 zcmZ2$*=ou6nU|M~0SJnFw`G18*~oX0Z?eCLrSM87KTXCWQ=o(9+=Vun6M3ayRlu?L z8;IG~r+_r_0tuiuz-(?LHU|=$6NwE95SV&tB(^mYTL6hIio_N|VoNbZAq)~kk`P8> zg8~v}nK%-gABio7#Fl`vH@k`NVrB!yM^W%(ZfPZ;hc%^TxoUs{j6hr*vN=Fnn~~9S Ia;=O30ERwdasU7T diff --git a/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc b/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc index 74f344046d82888041134bb4bb514e78d07477a4..4efaa92d35d2d579d7a353cfdd8bcca5fd58fa5b 100644 GIT binary patch literal 20776 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 TaskLi @router.get("/{task_id}", response_model=TaskDetailResponse) async def get_task(task_id: str, service: TaskService = Depends(get_task_service)) -> TaskDetailResponse: return service.get_task(task_id) + + +@router.post("/{task_id}/cancel", response_model=TaskDetailResponse, status_code=status.HTTP_200_OK) +async def cancel_task(task_id: str, service: TaskService = Depends(get_task_service)) -> TaskDetailResponse: + return service.cancel_task(task_id) 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 084f5b137f57dbecfb69348cd1cc34d0cb8e81f0..c2cd10dcf6c00e461dfb03f6b576155007726461 100644 GIT binary patch delta 4781 zcmbtXdu&tJ8NcWH`Wf4?^L8G7BqZb#NJ22LG*3tn1QuuxS+xPnf@6Xq*y*`WTS960 zV^Z67En{a@S+|yUT_sRg_2F(+RXVL~m9|ye+&5TS1=Xaj-78(YRbtb+?>qPU5z~jJ zIq2ul-{W__^PTg3_wwdB^3ij|{ejEnkl^#)H-8*^Z~Iejg%CS0Cp(=PM`SV{iO$T# z;?sOT>2R4DIVeBN{etwY-(nbpvZv|jl6Xg6&TQtjS4>aUg7>IWvV`k}oI z^g0?qV@~N8y5JIB0CYVxv_u!AjVv^6QhWJv^L^%)j>*~CRITM6rD+w=vnqrd1Rp@g znw-=QPsFBJHL4o<%Z>)pk$Ts0x2axeggd6d!U*dyMvhJKom+c%EQ24ag>|E0Bf9M8k*X^lm#&029hf4%$}hYQRm?3kbP>&u$> zk1DnVsVQt<3;lzixSdM)m;D`-Zm{#GhAQ~m{&w;^K2Z4re>@QC51`1YX6FSja=W9eOc(gzZ0&n(og#muI_A=U1G zf~*&1vwmXPidM7cSA(@Gf-A_SRDZBrR#j8c*{dZ5d_f3fA%p-_u%<4@s^JaFrH}2w zf7!Uw@+Z)06MSV$h@b4X@Sjv!QmsvUNV%pVl~#ju(kn^h62CHBA&)D($Gcjwjn1FR-LBs+@4=f@ zg2fPz&iSKY>*41EUcOlC#7185piOkM7)<7(!^}@7W6BlRON^+3dI4Rgf<t7`dZu-do>_SFri4?npz z?dko{Wp%k1J(A0Na>t!J7G=d-u?S%g!XmCP% z+u(t!;OH!D@B_=+$sck@kZtf_YfvwBZf1%GXNQ1TE9zuWWy%i3;*&G6`DnI=(Wy+R zsDRhHjA8YrARgMkOscNC@5remJaI?KMVI$27vB@~;W%*VH@|M|27L6y_b@;=4#2WnRGOP!5m zI`!|)Qzp;Srve+~4}^Ud5xtq854SWlW;ygnRnb`fI2N=hNj$1XS*>>#EuumUf9r8@-!Cli(oh*@mB^a zdBw&7@+CgL@z;R^poDR!=Z_pqch3jA!{k|hQ}==;h}KZ*U)_gg7#`ETJIDq8Oz$aW z8|b;Kcc6tmfF5TM9swpU?6rNb`>vH|hn_$A8ry>RY;*>_aw) zkz(2|11aO2otcb84<=@&qD<4D9Jntw%+-NT$m`1kBjowiqXQ4htplhRb!o;(b1WG> zqN&D{0k_GF$itgEEaIGR=MQaOL9V1;-dsXzSAd6V&RDe@XCQJ-#u`b)Y{d;NG2wxVv*$4)QemF1eU$L?fQ|;$np(@yAkd| zz)K0!?$b>Asl5bFTEUWc@na*+a;eO}KhlfbBX4`e>2@KbC|Do=&GvfPC-XmVU$cN} zl`w2JRn}Vxdrg%`#h_N5`b#Y8^fy@{;p?pENs-)Dhn8f)TZ5yCcvOGwM5l~}MU!(Z zo>6eK&@c9`akHMRSlIBybks(E%g%KRoglI; z2s;pVA>4rQ6@>i=(+GzV;s^|34&fGrV+gk)tVB48@GXRU5l$og5aD)&^9Tw;KS0K* zRVU%aC~_p4JeZgg|AW(?NqxL~SDpV1I(8w5;jtVg1iUWV{ao8Mx2@dVbQ#`#%<#Qv zktzd=R+MZ~RdCUck^^-mC^=E*Ldh*vhZajw@<JOk?p065F)?vq4hCFwVuG2lD$gSH@OS7cPdAFt$@rRB`n udX6Nf=4PUO>>W;bH#?7=aA(T5q$SVAnsNTM-Gkm+tz;MZyClBEG5i;p=mjPK delta 3819 zcmb7G3v8QL75?w<-;eke$4P9*dDu?UB!3>xvuV;LS(2trm!wJBTe_B}xM||9Zj-oN zJ1gtP5(YzJVk_OHR2rxQ3Kc;UQ2q)DG%6|{V!)~*p>_O12munHfrtpxP=zMWxxW)T zSqm(YJ|CaQcg{Wco_qbycgY(o#J%Qn*(LZ(KL4G>TSM2~3L!RLNGA9KX|^d0ZOoy4 zojgGrOz0;`WNk{8q)XD2#0Zs`ObIj5FqMPSX@w~jQiVh#{H)^Zq?J@bRZ4fzGBj>CR1zx_y7EoBa?o|ricPu-8e*ZxOlmj(iTSdn_FVkL zVnRD%c|jRy1S(dH;714m=+^Xf>P###%Y3Mcph}rZE-dIKmOPirgYV(>KNi(m9nYI0 z!c)+6U`#s#R;kNCgUnoa_v}+GtQ##|2s;q^5FQ55O^?OXs+nOux`G85*#+k=P^QcP zA}x!ekDq@g->)s@cgqiT!gp24a9V@q@0cWa_^vDThAVV0g=xMN7?4f3Wcz?eyW;;Lc@(;_IjwGkHO(X!7t*RN zr%|PtzYL)qVWS+~GPB51NtUvK8vBQTEXrRlX>(lxRcabwSz2}T7Qf1^!48trdV+D{ z-RSQ|8eC_gSmGA0Ka_Tm>pW0)jKAW~BVQ2>RinG)4y$PB_xUfQ7kFn?v)6^CeGnj* zGZ3gk_5FxHTG8hE5g1dC06>u*-dr2Zw9!%b+Ot*-AAm6H0guY33 zdb2!*-rdooN*CjUWK5%&%rw;1O}`^exK84k)HhsMYy-n#puvKRdLb3^?#?6XMs zIfTmqDH#BdBPw1(_&mUudE=fE%NIa8e-$pR3I5WaS4b}(9**>FUY%+;b}xHe@sQ}w zvvbok@l(mU**HrX-Twe`5AkcmEl}6p;SutJR=M|zT!+`a(Wq*SG$+#W`PBM2;i_3C z^3Nm97E$HXynM8t+|c%pI!I6j57n$&Qy-p#$adX|Pg8s*&FcC5SimbDG|{{Db@Iz& zHRM%(b1X_)`QOIgwHXXd<7@lsyx#`8O)cr;P2(YQOWQX-M=aPxR?NRQF%rNAt`Oq^ zfPIiM9US11$ujb~);C!}fFI5Te|&$}h}go9WArBwo<)cNz!Uc%ld(mwf>SDo{~7+T z{oCZ<6COIy3;f3p{MIA3vK1LccY2|MVt$-j<-Zc1pzTX&QVGL>RAnZ{4Chgm$Hi|x z)oDEVs54%DA>q?6=2jGV&z?%N@KfRO|f_RIBBpxIoiC5{TXhm}N za{$#eK0XddGt2ou1DBxO6UpS9szB&CG$k90^%&{@dm-8MGBDV3819!{c)yOtdeL(! z(`_+GqE%r`E2IjP#0uYkDT4g};D-F~i(FK`b!VB-!j59WVk2_*K%9TEATb0&5|e<^ zk`aW5=RF6;pt7+0^#)U&&zno6(nsNl9)nn%_IuvUeGuJcTnCBdLQYkg3U3?9hLED# zu^w?Cc`m1xj4PI?hI#L!olDIivfT)y2ong05RM{DBg7F-A)G--A~1wSga(99B76$r z(+C$3t|B~*pdly-c-`vGRC+p{n3<2KPbFQm;>W1*tn~7{!=)8lZh#t$L%@Ee_VU5Q zi;sBC?%x#@t(xI;)gl##R;?)6Bwu(n4<$S594I+a=R(OX`6H|OD0w8i`|SdhqAwo# z8sC4UGY=H`1)ylu{&Ykky!YtUDscy4Ue%-9W3iKqa5uzbF=oPgzJc%%H%&Epx=|Fr z_g_Y-n)go?heXkv7oyvG=99CFbMZd*Yo4Ck=A2)4>xFynNS-$X!~CmL0|gUSGD-d+ Ji4TV9{{sa<9>@Ry diff --git a/webui/backend/app/db/task_repository.py b/webui/backend/app/db/task_repository.py index 5154a14..253b3f5 100644 --- a/webui/backend/app/db/task_repository.py +++ b/webui/backend/app/db/task_repository.py @@ -6,9 +6,9 @@ from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path -VALID_STATUSES = {"queued", "running", "completed", "failed", "requested", "preparing", "ready", "cancelled"} +VALID_STATUSES = {"queued", "running", "cancelling", "completed", "failed", "requested", "preparing", "ready", "cancelled"} VALID_OPERATIONS = {"copy", "move", "download", "duplicate", "delete"} -NON_TERMINAL_STATUSES = ("queued", "running", "requested", "preparing") +NON_TERMINAL_STATUSES = ("queued", "running", "cancelling", "requested", "preparing") TASK_MIGRATION_COLUMNS: dict[str, str] = { "operation": "TEXT NOT NULL DEFAULT 'copy'", "status": "TEXT NOT NULL DEFAULT 'queued'", @@ -143,17 +143,18 @@ class TaskRepository: done_items: int | None = None, total_items: int | None = None, current_item: str | None = None, - ) -> None: + ) -> bool: started_at = self._now_iso() with self._connection() as conn: - conn.execute( + cursor = conn.execute( """ UPDATE tasks SET status = ?, started_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = ? - WHERE id = ? + WHERE id = ? AND status = ? """, - ("running", started_at, done_bytes, total_bytes, done_items, total_items, current_item, task_id), + ("running", started_at, done_bytes, total_bytes, done_items, total_items, current_item, task_id, "queued"), ) + return cursor.rowcount > 0 def mark_preparing( self, @@ -200,17 +201,18 @@ class TaskRepository: total_bytes: int | None = None, done_items: int | None = None, total_items: int | None = None, - ) -> None: + ) -> bool: finished_at = self._now_iso() with self._connection() as conn: - conn.execute( + cursor = conn.execute( """ UPDATE tasks - SET status = ?, finished_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ? - WHERE id = ? + SET status = ?, finished_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = NULL + WHERE id = ? AND status = ? """, - ("completed", finished_at, done_bytes, total_bytes, done_items, total_items, task_id), + ("completed", finished_at, done_bytes, total_bytes, done_items, total_items, task_id, "running"), ) + return cursor.rowcount > 0 def mark_ready( self, @@ -311,6 +313,49 @@ class TaskRepository: ) return cursor.rowcount > 0 + def request_cancellation(self, task_id: str) -> dict | None: + finished_at = self._now_iso() + with self._connection() as conn: + conn.execute( + """ + UPDATE tasks + SET status = ?, finished_at = ?, current_item = NULL + WHERE id = ? AND status = ? + """, + ("cancelled", finished_at, task_id, "queued"), + ) + conn.execute( + """ + UPDATE tasks + SET status = ? + WHERE id = ? AND status = ? + """, + ("cancelling", task_id, "running"), + ) + row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone() + return self._to_dict(row) if row else None + + def finalize_cancelled( + self, + task_id: str, + *, + done_bytes: int | None = None, + total_bytes: int | None = None, + done_items: int | None = None, + total_items: int | None = None, + ) -> bool: + finished_at = self._now_iso() + with self._connection() as conn: + cursor = conn.execute( + """ + UPDATE tasks + SET status = ?, finished_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = NULL + WHERE id = ? AND status IN (?, ?) + """, + ("cancelled", finished_at, done_bytes, total_bytes, done_items, total_items, task_id, "cancelling", "queued"), + ) + return cursor.rowcount > 0 + def _ensure_schema(self) -> None: db_path = Path(self._db_path) if db_path.parent and str(db_path.parent) not in {"", "."}: diff --git a/webui/backend/app/dependencies.py b/webui/backend/app/dependencies.py index 4fb2bc0..3db48b9 100644 --- a/webui/backend/app/dependencies.py +++ b/webui/backend/app/dependencies.py @@ -102,7 +102,7 @@ async def get_archive_download_task_service() -> ArchiveDownloadTaskService: async def get_task_service() -> TaskService: - return TaskService(repository=get_task_repository()) + return TaskService(repository=get_task_repository(), history_repository=get_history_repository()) async def get_copy_task_service() -> CopyTaskService: diff --git a/webui/backend/app/services/__pycache__/task_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/task_service.cpython-313.pyc index 0800c2e192916b0ea41e4774b3225e760afd5b80..0ec91831f0dc82aa3adff5106e027855f04b84e5 100644 GIT binary patch literal 3605 zcmbVP-ESMm5#ReDdE}8gij*bBUpg^zYlgC9CAD4Eg&NheY<-jobfGK2DGn65k}sx? z%pRH4Mo|4!V3`=5i)gATZpo#;6sbC1)b?8nIu;Dw#4EYk|kE;II*+~vcC#TtVeUP zr`X9!Trat9tx;+?HP?f#HdC)(Wvs?>3QxUL@@~)4M#-rzQ?CvRl*duF3mg(gx+xO0!;d%B2P^ z`@8k2;y2Rn0x%EAyM##=VX`GL#fn-nOF2&7Q{R)AYU!4WJPTQID*G4^%%bcAr%a#fV7*Wd786UN&!8{uo)inOB&$M3%t z*KM|cY@W7n`L5^@f?HLOR@Zo}M=|xHo`;(-O_RU&Azf`c6RV~2ZR%DgO7;4L=kL~= zXu#I(pf?V#)NI>vorZ1mfu2*1ccqYZ#Onj{8`3#(<_j&;dG*4Vnz@UICdjDB-IC9fqB<*GltxKTDS!i_wK`=Y<9OJs zHCdT*y+XZ);|k$JP-AJB&h;{*kO?-PlcB*}h#iB5okDUN$uJPEqJoDC;4#x=85|kG zk)d8c^jJ&6pf?5tTw$cQjp4R2ylv#$M*dT$_3nDh$hVAJ_teM6NZZJ5&H`y^Id%dj zlmiq2OGpHM!co5j=4BjJlr99hSBVoMg$CVyQ*xykv!@R4U!X^~7g| z!KrfK)JTE%p$BXLvb|Pox|RP3=$apvJQHN5kUjkatjHrm(WyLC5q=fQBOBE7O6w2; z52`@Zvwdkrz}{mfESFRLaA@na;l~1##Se($2Lw;RvOv0d>&IXmB!_dcYhnO(=Ybpw zz}dEO_D@4w#^gQqDUCA)d8l^NTKhSGh{NGQ>_7bnT=3(?s} zB$zhfC!+HRn3svp1<=ROl@6j&l+xse?8y_vlhfq-_hD2%h({4h5#}GXa?mP4s|IZ- zXv0Ap!Pe5IB5%t-%Mqdz6D+&36`ca0c(UJHBe__C%T7h4JitZ7s3-Mfeo|NLQiJPj zfPU{5#ZE55Cc(asl-H5lhYNcHyFUN|g*oIv9rwBsj#Z&xi4c1XbB$po05l@#cu1h^ z8=`y>M+bY6c3=ka>HQW6#5p;zoiy7?b32)DC-Ym$bN51zjp3a^aw@xfOi%Yc9{}$D z>|UDmpXv;q_;~iCyPeb7&CBi66P**oo7yMS-Nqc<{6*V*qci;K=2CljvJ>?846Q%4 zlL9L{Xm3YJBxBD=A{zVma~b-gLi}-jW=J~(VhwgD(&RHpo|j3J?*?K>5s?Bn(YNBe z4C|Kq4?#a-X;yS4WyM62=;N|q0AQ{!iND00Z?4|a?&=3^&v}%hvR{sl=x|6dx*iNiCnSCrrm@0$Wp+(b}|&oZC{DnSWM7T6h(vhN`Q zY!W6a65z^*03X;dkmZtFrqya`wMy@1FlkMYV3k~@$l(~RIG;dF7~&=;`=*eehBP5O z{C<-*X@y5v({&wpoyUS{$Vc~0z-vIGZiC&i0lfl&CYsYkEoE348J1C=ots~}YR_DG zXa22%ePyO_;DzD33Sm6}%(GQhrTVkJMxy z@pUR5E(jN-e?!9cDvm4ge&nV+7&s48yngV;cP`Y=;v}BDuebG+UyROe>2sp2=R29# zv8<2gA6@_RuiKfqgTn6XYrmgvjm&Q8v;SAUkEDmTQ`vSZyPdk!PF;Ew*-E{6FY=W! z_${W!)AxS}JKNN^^!%sl_WA4Z*NG>$2P=yrUv9iM!3>GR7i8^~(gVZb2Q>v8l+r;IAimfTkh_@|9>nIa%tFnjkPG;b;fmK_etDk5Nmw!&ay-mv zqsiQER7FY)m20Sir2&=~JQnL*3?G1QX@kN8FlO5uwMw&!eB8F*Zl1u|%aVqa%RL+AW?s9k`!H*9jP2`7Y2G6Ai@;F&uP&s_L9;}riD z@qC~R9Ub(_x9CR66OIr!mAJF`NUvUHb^OK0HPc`N{PYn$Q6Uaq<$eU;1Wj?eBgf|2 zkA446vt>{Ld=L+-%`-)kq$ec%50d{2xzQ#!o{*nBAya=nv(zHlufy|_bnZDpDm?LD D(ug&Y delta 954 zcmZuwJ!lj`6rS0?+uQreC5Gg@3+9MFabwUlL6m4E*qxG*Xh2w&yG>lZAAPf@Xp#wQI1+Z_^H>Aa*)|&MG-f`?97LEF7Rs|e2Hp%De z@lS49yf6l|C>G4}ra69|(CWd+hols9gxu!bLuVy1$b$pfxWHy{d=C_r*vJT({T zxOi{AI{Ih~Nwo(duXVafZLQnuG~?$~RcDYI#7$ppS+nk8)zG(~DBf9>v)aZq>QohQ zH9Bgi%E#0}jvrKBj}k3g5+!?C$u{im5ND7MPr{_f56t4FE}KD^$lO}oiTSMv4}Jz_ zRYf}bNsRXA2Op1f3VbTv)f}+-a1Mmd_ zx9_`JO`1G`RP79=aOHrJbRbH4&;i`MQq=3vlm(>dTLW~QK}J@(LC^`?Q4mNk2-@9d zuZ3|w2=4X7X1-Dy!#aF8z#YQ8c$2SQ!o9;df__*0K>z_Qa@^t*vSU#D#ExUwb31O< zp4*)mvAwheiFH3G4i&D8XN4NWB3%vtww3Nu>gOVyR}}U7gdb7x)1;JclbLVi)K{|d Sk*sW!bGyzA^?nfrTH_D*D$oJ| diff --git a/webui/backend/app/services/task_service.py b/webui/backend/app/services/task_service.py index 0032e5d..8fe48f2 100644 --- a/webui/backend/app/services/task_service.py +++ b/webui/backend/app/services/task_service.py @@ -2,12 +2,16 @@ from __future__ import annotations from backend.app.api.errors import AppError from backend.app.api.schemas import TaskDetailResponse, TaskListItem, TaskListResponse +from backend.app.db.history_repository import HistoryRepository from backend.app.db.task_repository import TaskRepository +FILE_ACTION_CANCELLABLE_OPERATIONS = {"copy", "move", "duplicate", "delete"} + class TaskService: - def __init__(self, repository: TaskRepository): + def __init__(self, repository: TaskRepository, history_repository: HistoryRepository | None = None): self._repository = repository + self._history_repository = history_repository def create_task(self, operation: str, source: str, destination: str) -> TaskDetailResponse: task = self._repository.create_task(operation=operation, source=source, destination=destination) @@ -40,3 +44,41 @@ class TaskService: for task in tasks ] ) + + def cancel_task(self, task_id: str) -> TaskDetailResponse: + task = self._repository.get_task(task_id) + if not task: + raise AppError( + code="task_not_found", + message="Task was not found", + status_code=404, + details={"task_id": task_id}, + ) + if task["operation"] not in FILE_ACTION_CANCELLABLE_OPERATIONS: + raise AppError( + code="task_not_cancellable", + message="Task cannot be cancelled", + status_code=409, + details={"task_id": task_id, "status": task["status"]}, + ) + if task["status"] not in {"queued", "running", "cancelling"}: + raise AppError( + code="task_not_cancellable", + message="Task cannot be cancelled", + status_code=409, + details={"task_id": task_id, "status": task["status"]}, + ) + + updated = self._repository.request_cancellation(task_id) + if not updated: + raise AppError( + code="task_not_cancellable", + message="Task cannot be cancelled", + status_code=409, + details={"task_id": task_id, "status": task["status"]}, + ) + + if updated["status"] == "cancelled" and self._history_repository: + self._history_repository.update_entry(entry_id=task_id, status="cancelled") + + return TaskDetailResponse(**updated) diff --git a/webui/backend/app/tasks_runner.py b/webui/backend/app/tasks_runner.py index 1ebe50d..f07d1f4 100644 --- a/webui/backend/app/tasks_runner.py +++ b/webui/backend/app/tasks_runner.py @@ -95,12 +95,14 @@ class TaskRunner: thread.start() def _run_copy_file(self, task_id: str, source: str, destination: str, total_bytes: int) -> None: - self._repository.mark_running( + if not self._repository.mark_running( task_id=task_id, done_bytes=0, total_bytes=total_bytes, current_item=source, - ) + ): + self._finalize_if_already_cancelled(task_id, done_bytes=0, total_bytes=total_bytes) + return progress = {"done": 0} @@ -115,12 +117,11 @@ class TaskRunner: try: self._filesystem.copy_file(source=source, destination=destination, on_progress=on_progress) - self._repository.mark_completed( + self._complete_or_cancel_file_task( task_id=task_id, done_bytes=total_bytes, total_bytes=total_bytes, ) - self._update_history_completed(task_id) except OSError as exc: self._repository.mark_failed( task_id=task_id, @@ -133,21 +134,22 @@ class TaskRunner: self._update_history_failed(task_id, str(exc)) def _run_copy_directory(self, task_id: str, source: str, destination: str) -> None: - self._repository.mark_running( + if not self._repository.mark_running( task_id=task_id, done_items=0, total_items=1, current_item=source, - ) + ): + self._finalize_if_already_cancelled(task_id, done_items=0, total_items=1) + return try: self._filesystem.copy_directory(source=source, destination=destination) - self._repository.mark_completed( + self._complete_or_cancel_item_task( task_id=task_id, done_items=1, total_items=1, ) - self._update_history_completed(task_id) except OSError as exc: self._repository.mark_failed( task_id=task_id, @@ -164,15 +166,20 @@ class TaskRunner: def _run_copy_batch(self, task_id: str, items: list[dict[str, str]]) -> None: total_items = len(items) current_item = items[0]["source"] if items else None - self._repository.mark_running( + if not self._repository.mark_running( task_id=task_id, done_items=0, total_items=total_items, current_item=current_item, - ) + ): + self._finalize_if_already_cancelled(task_id, done_items=0, total_items=total_items) + return completed_items = 0 for index, item in enumerate(items): + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) + return source = item["source"] destination = item["destination"] try: @@ -188,6 +195,9 @@ class TaskRunner: total_items=total_items, current_item=next_item, ) + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) + return except OSError as exc: self._repository.mark_failed( task_id=task_id, @@ -202,12 +212,11 @@ class TaskRunner: self._update_history_failed(task_id, str(exc)) return - self._repository.mark_completed( + self._complete_or_cancel_item_task( task_id=task_id, done_items=total_items, total_items=total_items, ) - self._update_history_completed(task_id) def _run_move_file( self, @@ -217,24 +226,25 @@ class TaskRunner: total_bytes: int, same_root: bool, ) -> None: - self._repository.mark_running( + if not self._repository.mark_running( task_id=task_id, done_bytes=0, total_bytes=total_bytes, current_item=source, - ) + ): + self._finalize_if_already_cancelled(task_id, done_bytes=0, total_bytes=total_bytes) + return progress = {"done": 0} try: if same_root: self._filesystem.move_file(source=source, destination=destination) - self._repository.mark_completed( + self._complete_or_cancel_file_task( task_id=task_id, done_bytes=total_bytes, total_bytes=total_bytes, ) - self._update_history_completed(task_id) return def on_progress(done_bytes: int) -> None: @@ -248,12 +258,11 @@ class TaskRunner: self._filesystem.copy_file(source=source, destination=destination, on_progress=on_progress) self._filesystem.delete_file(Path(source)) - self._repository.mark_completed( + self._complete_or_cancel_file_task( task_id=task_id, done_bytes=total_bytes, total_bytes=total_bytes, ) - self._update_history_completed(task_id) except OSError as exc: self._repository.mark_failed( task_id=task_id, @@ -266,21 +275,22 @@ class TaskRunner: self._update_history_failed(task_id, str(exc)) def _run_move_directory(self, task_id: str, source: str, destination: str) -> None: - self._repository.mark_running( + if not self._repository.mark_running( task_id=task_id, done_items=0, total_items=1, current_item=source, - ) + ): + self._finalize_if_already_cancelled(task_id, done_items=0, total_items=1) + return try: self._filesystem.move_directory(source=source, destination=destination) - self._repository.mark_completed( + self._complete_or_cancel_item_task( task_id=task_id, done_items=1, total_items=1, ) - self._update_history_completed(task_id) except OSError as exc: self._repository.mark_failed( task_id=task_id, @@ -295,15 +305,20 @@ class TaskRunner: def _run_move_batch(self, task_id: str, items: list[dict[str, str]]) -> None: total_items = len(items) current_item = items[0]["source"] if items else None - self._repository.mark_running( + if not self._repository.mark_running( task_id=task_id, done_items=0, total_items=total_items, current_item=current_item, - ) + ): + self._finalize_if_already_cancelled(task_id, done_items=0, total_items=total_items) + return completed_items = 0 for index, item in enumerate(items): + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) + return source = item["source"] destination = item["destination"] try: @@ -319,6 +334,9 @@ class TaskRunner: total_items=total_items, current_item=next_item, ) + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) + return except OSError as exc: self._repository.mark_failed( task_id=task_id, @@ -333,25 +351,29 @@ class TaskRunner: self._update_history_failed(task_id, str(exc)) return - self._repository.mark_completed( + self._complete_or_cancel_item_task( task_id=task_id, done_items=total_items, total_items=total_items, ) - self._update_history_completed(task_id) def _run_duplicate_batch(self, task_id: str, items: list[dict[str, str]]) -> None: total_items = len(items) current_item = items[0]["source"] if items else None - self._repository.mark_running( + if not self._repository.mark_running( task_id=task_id, done_items=0, total_items=total_items, current_item=current_item, - ) + ): + self._finalize_if_already_cancelled(task_id, done_items=0, total_items=total_items) + return completed_items = 0 for index, item in enumerate(items): + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) + return source = item["source"] destination = item["destination"] try: @@ -367,6 +389,9 @@ class TaskRunner: total_items=total_items, current_item=next_item, ) + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) + return except OSError as exc: self._cleanup_partial_duplicate(Path(destination)) self._repository.mark_failed( @@ -382,20 +407,21 @@ class TaskRunner: self._update_history_failed(task_id, str(exc)) return - self._repository.mark_completed( + self._complete_or_cancel_item_task( task_id=task_id, done_items=total_items, total_items=total_items, ) - self._update_history_completed(task_id) def _run_delete_path(self, task_id: str, target: str, kind: str, recursive: bool) -> None: - self._repository.mark_running( + if not self._repository.mark_running( task_id=task_id, done_items=0, total_items=1, current_item=target, - ) + ): + self._finalize_if_already_cancelled(task_id, done_items=0, total_items=1) + return try: path = Path(target) @@ -405,12 +431,11 @@ class TaskRunner: self._filesystem.delete_directory_recursive(path) else: self._filesystem.delete_empty_directory(path) - self._repository.mark_completed( + self._complete_or_cancel_item_task( task_id=task_id, done_items=1, total_items=1, ) - self._update_history_completed(task_id) except OSError as exc: self._repository.mark_failed( task_id=task_id, @@ -466,6 +491,88 @@ class TaskRunner: return path.unlink() + def _is_cancel_requested(self, task_id: str) -> bool: + task = self._repository.get_task(task_id) + return bool(task) and task["status"] == "cancelling" + + def _finalize_if_already_cancelled( + self, + task_id: str, + *, + done_bytes: int | None = None, + total_bytes: int | None = None, + done_items: int | None = None, + total_items: int | None = None, + ) -> None: + task = self._repository.get_task(task_id) + if task and task["status"] == "cancelled": + self._update_history_cancelled(task_id) + return + if task and task["status"] == "cancelling": + self._finalize_cancelled( + task_id, + done_bytes=done_bytes, + total_bytes=total_bytes, + done_items=done_items, + total_items=total_items, + ) + + def _complete_or_cancel_file_task( + self, + *, + task_id: str, + done_bytes: int | None, + total_bytes: int | None, + ) -> None: + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_bytes=done_bytes, total_bytes=total_bytes) + return + if self._repository.mark_completed( + task_id=task_id, + done_bytes=done_bytes, + total_bytes=total_bytes, + ): + self._update_history_completed(task_id) + return + self._finalize_if_already_cancelled(task_id, done_bytes=done_bytes, total_bytes=total_bytes) + + def _complete_or_cancel_item_task( + self, + *, + task_id: str, + done_items: int | None, + total_items: int | None, + ) -> None: + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_items=done_items, total_items=total_items) + return + if self._repository.mark_completed( + task_id=task_id, + done_items=done_items, + total_items=total_items, + ): + self._update_history_completed(task_id) + return + self._finalize_if_already_cancelled(task_id, done_items=done_items, total_items=total_items) + + def _finalize_cancelled( + self, + task_id: str, + *, + done_bytes: int | None = None, + total_bytes: int | None = None, + done_items: int | None = None, + total_items: int | None = None, + ) -> None: + if self._repository.finalize_cancelled( + task_id=task_id, + done_bytes=done_bytes, + total_bytes=total_bytes, + done_items=done_items, + total_items=total_items, + ): + self._update_history_cancelled(task_id) + def _update_history_completed(self, task_id: str) -> None: if self._history_repository: self._history_repository.update_entry(entry_id=task_id, status="completed") @@ -478,3 +585,7 @@ class TaskRunner: error_code="io_error", error_message=error_message, ) + + def _update_history_cancelled(self, task_id: str) -> None: + if self._history_repository: + self._history_repository.update_entry(entry_id=task_id, status="cancelled") diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 6a67fa5b7ca98efc354e431a1275de5366d50ad9..ec103e59c9064136467864d72e319a02fb137b6e 100644 GIT binary patch delta 13643 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 YhwQ&!Ahn{^)cb%vL$kOmgTcjQ2`%`iemJ& zrZN+wbn+2mAIBLpMVT^9C3DmoDo7=j@Ih&reL4GF`=j&6I(zNC9>2Zz+P`xO3c?B+ z+LX2zb5ALXGHyd;I+=TF_hS{d7G|%LGs?I!%a^d{zR1b;o(&OCL>eiHDLqqCQ&Vy# zR}Es1X`SpG*J^Z`wK;81hVMbUZ0h?JVfH%;_qKps_^W-&+hUKjJJ>JC<*&SD*FWM| z=%IcB_m_1z&=caz#vGUo-ODn3-5>((n~+d-@GBb&7EzV&mjPlhDU-CXs$-jB6!KZn zr=zvWns1G^x|qM1C&?)D9W&nynhCJQ1mhe&J`2)1ig(3gazsoJUHKH=i)`XLna3Jf zH7j9{JG*AVFha;h38#@@>P?7q?#>5ph+)bZ0a<7jKtiIGX~o%RwecjDRM@-h74{VR zyxp74QO?+gx6!+ocJZ#Kqt&}?1U;%Q$7c(mU7~nGo$QIQZn4f<90^#*`4IN8XaL2k zfNjPAcHL-8f6^A}&AbMF(+@e@3gEO#=q`!lJ<8%~7QtCtncoSlg%5&Drz-Uddl7Q% zvwW1ji%gUdCfhyfueJ(F-mggk>E``_M|#(I|K{!MwLMLq{hn7mPm|x_oF{{v_O!FE zTD8_2)@*AyeOIX$9j#7mtof69%q(ZOV3+vU zc`Hq?)6ePe>nrssdT%j|=Ie&mNaHj|TSqh57VU8@jn89#&Bs1do7DYknK}c6I!Jv$ zw1`^qy1*zO>45M�jVkZS7hSlSl5Lc@=u#?rRW_1DhZgcV2=-EN_McoO(%$4K2_W z&1(>YJC{Nne%k^91FPL;Nx_lL((^`t7ENF^-aId@$GYM_7i5HQy1LQx@%xKX$-OG4 zKaXdA+;l_w7haa?%-+l|pezI>D#9Jl2#uT1yTPa7XR~FxyO*SSdW(b=IS1kh?8Z^@ zEub=Fy6-6&vHK-Zv8+i(e83fFF3XMlctLt>Y?bJ8Z%O1!e}Q*#+-(SA{vwFu$sy-8 z_YuEPR)og)wGcm1e#W}*U;>MySx}aK+K-#h%j89UNHkG4V#Ec=#PEx7;@q!U16i1I zlKwys)7N+|eU1*L9qcpY6MKif%+9fsY~igZZ+fe|tGv^^$zH?L0LMJlJk_(-lS?8z zsUF+92rpRste34kYoHZIZkWwxjfv(=^C2_bxMqB8Y&7O(86%Ad{g(cjz6EaTFY05- zNIgcmt^KB*(zauk9Vj}BAWX{8vtDVR2h zd^cFD)JN;R^mclI{v>U$zfP0sMp{Jk=vZ1!>lmX~SZ9{W`m$%(O17E(o1YUFZxyj3 zM+_44#5%DJuRlz}ouL`z0$@ZoQPIjK58&yEq&;q)DAmFsiNiN0l2m*wDCdR+Njvlf zNiyD&Y>M@nM8{*9q&uD{paLs1Nn0$ct_kO>S*oXp^z-hWR=E5wGB@v15RAME`hFA;G|D$(VJ3 zn-)^r*&Za{0zSKlHek{s%5l#^x<~7koR*Z5qa1S*vdP)FrP@nMHZWqGFe;3NM!<;G zf78Fv{|R^Dzp$MwHh-iG=s5PU_XF=*Z>~2r#HJs5UiRd9(mi2Tvvo*cYQ1L7GNY_v zRwrPP4T(@l?vT&PR&AP=Y<{X4B&cVpjjBVBt0ii#nyT93qSz-k(6>e2V397`@@9UB z{>=YD;`y_DDDS}Ln{Tlw)@nY?j48bbB#5Gz#9(y^DZ<)*JOO(Rl%ljm_U@NSvN5->B02a@Dv!mQ6_UkbWQ|K? zm-hUE`ti{LT*bBNJRS%4mj*|dx%8IlauB9Z0YB-hV7MDHB8>-eV;WDyPgl}te7Yac z!SMb(=3axpsmU~@{rL{($pQQ?fKEEk#HlWdD5TK>Mu$jkdXt}blD%_6qCbjf3fxo& zLj>r4<^&uFjZ-_zPu`SuBmuCfP$Gc6c&AuyWlow5cPy3nz@O42+)6k4);{jFU5~rg zOR}yjCS7s-DmfanTH2SdmeslTChg*MTtkk6v!pMNhU!uRkt!=!@&IHRDoIseQbQUk z&?uTgr_(}OPHSi*19=n~Y&t6x<)TJ3s-Q-x8R~SkP%T$$)J6@oD5tfOoCGYdBH?OE zO3z+>b7o+9t;|_kOX6`sE$NKewImXI{2g>0R7=7!yp}}auZKwletB3f{Ohn(Uw737 zt~$vTsZv-e$*H}P`{YcKoxM+XwgWh15Aos2YN9#ws!6HNa#)Sx#2zI3$ew`jcW`QI z$wO<}X7HJceb(M-FAou~D)y`Wuzi5`$43IRqcDQJ*+_7f1n6*97VuR%GqWhRXV1Y9 z1!n}vD4a8kzJh^+Y?^zMe9nZ~bc*~PDU}e`($EyvS(QiscIkNiQD&GK-kSJStPX^J`()j=$#t!J-iz2`~K08bn1 zignO>)q2Jn?2H)1W(pVOtA!BftQgMn^E#=c6_e*Gb|3F-&vxqvbC!{>-_cbuQESU{ zCCZ`P=~~S$gUe4xWRKN4ZTGV44;nqS@IUH;_t&w(K4YrU+tBp}y;?8PbM-W5MjiXw zO;-OWkQwLLLDge@qfos_F}r)S^dgyJp6=Yf!ghcYxXLQsMBx6!&IPC92euvl4}nZx AQ2+n{ diff --git a/webui/backend/tests/golden/__pycache__/test_api_copy_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_copy_golden.cpython-313.pyc index d89f9fee6a09c2e0c3ed74827fc6e2491a027bf5..0467993c3eb6f4eb7f23b3ede1afb352fbc3880a 100644 GIT binary patch delta 6316 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 diff --git a/webui/backend/tests/golden/__pycache__/test_api_file_ops_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_file_ops_golden.cpython-313.pyc index 4a42ec0878fe7d8a003a06d7168dc754a4a68985..48b21026e74544a94c4d9e2ec7f575eb30ae9a0d 100644 GIT binary patch delta 7251 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 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 93fdc0cce3c8747d86804d28dc61eced921c945e..ce1e2f796f66314ce866be5662f07bc2d1f8ed1e 100644 GIT binary patch delta 6781 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 aCiI?*hOEanJ-q2Jwp`Z>1~31m;r{_EzYj71 delta 4682 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 delta 2404 zcma)7ZERCj81A|K+S+bxbnEEGSQ%sE4z_lmNZDq`*oQ$J&^Zw&?9yIFq3tc_-ZH2V z)QF%F3_HPSh(?1N7v+b!iTZ<>5I_Eym>6O-SAY1!keHB&fj=((@VxIWTT>9(mS@j- z&wJkQbIqxi?gQut7yx(_AObK5u!V!+icwk8HvEf56oA&ovhd6m0yykjr@?|mQkA zTS48MSLdR6JAE9sH?qNimrSypZ#UVaU-t!t`e(S@a2(0XSvgLh1*N?J`v79B!N1ah zhSB1{L!}BQiz*Zc*(7_zKSf^EZ~Lzb)e=bd0~GjVU#|?Ai3JKQ+_$`JLw$WSMuxpv z(!th(zxn(;JjIlHnOxC-tZx?jAX_?Bou@q)O*aBk6Y&L5OAiJ&H_hgHP3n3BpQWj;}6YcNhcgENOE$*{KrG4icWR(A{a zP!hv&KuRRZi}cLX&msR!G&W15kl7$_qZ z0MZ=h^HuW|gj{e5A=>qM!x-?=T`SN6WBFpGT2kbW{!2@Mup6u1ook+8Go5Q)MoC(tc!?V~N~z56sR|SI zGB`T}fB`I4Tr4qtd(4XGle)HP-Tykf8EIj`@Lm$u&xel+TTO=fX)BX~zuVgMOLy4>akQ1~bHI*kwu;$|{D|c5Q=PJUI8MgsSqq zMXO=MkFql*t16#)!x@(~ennNf3$mYN(V+=)l6^Gv+9`Z^>Ocz5747CAHd3%jQHs-z z3_k_P@J&I6Q-%z`oyg#cS#YTu4V(x(p`>Msllneb+>Bd^w03u^4~zT(>|xiD;bVsk zD~%^3pn?p)8OXLQ$}sfHd@1@^XgD$oZ;@(SBmgMDQGn+FUI2In;2gjOfQtZc z0lde)8}1<@BO}$UZsZ@V*yA9jAvaoYH#8|NO1se>kN*i-jsD=VHBHXPcKDiFV1(ay;=OyUx!FBGlyHqWZ$6SRsm*ns|uB&n#9p`r?L#bungi*e$;tY@LsmLT=m%m^%1h6X`&ihhA9 zFE5{3mQ*(5?@VxkU51b&FnH5`uN$9bt<%FX>pg-8pP$*V+JliS-HB5nE!_weHVu4I zm4OPSdsXEK6~etLlT@~_fx>2Qb!iqe?c?%sg2=RcctKnsb7k|3B4(_dfyPBC&{?gk zS!r#_bKzCoJj<Kr zzWfk<9Oz$|E&W=pr%^u~$uWYleidBJiGnY3qG3z@o|23hZRDsjPOs^nIW|WTlNdPD4IsTfVSYOIxp=G<2_Myv?3RJf<40>!`r zby|4rlwicjf)OVRhF%s7gWV_#1`!J^7xpAsFp_1#NSP3fS#YzVaR%Rl7vNhVt&GKIdpZ;s zP7ld`-IEYx0#f~%X!XoAz1zJm)x^>A=#wiI=_Gy-zZ z#iLh&IcGv!rLyb2NYdZLwa0H5VBl6ZQbOfm2I_&}4U@9BDGa_nm!aG}G45$n5g)w; z4Yy6ox0}@R?B1rC@(4{D&<&eVxa{p~ijZ^Pfr4R+Hc*Z1`|`$;k_yt~CK{%kx7aOxeqU2u;UNpI!C=<%am1zxs0nN( zu#LcL1hx~{L0~5V2LV?=XGF8mt^tb?ZAYpHxakX@4V;Ka)pQ^`8R-Jr6jUBqk%FRB zl^3#ZsTU6{NI_lU>g$I5<3+dBrGeI5G<)EeY*Z0m+)E0E&W|m#>Z#k2H&I9Cm3%b$ z!mfa$00BA>7%f0z-f&~#)W@-ADwNV6*jI>Z^Fs$Ad2bTzQZ==GB$84%jRoFuG&>dil z&|Y*r@MjTPWcrv?qe*|}kCN)-2NNGlg6guhKDXb?&gU-&T*auxS{H+?`_*^i&DZ12 jV+!I0n==)3ME9nf%7nXg#2ZU?EZMazs8rC2a+!Yse1R!Z delta 1398 zcmZXTYfKzf7>4(o8J1;Xm%X!qWf#~5R*Fc)QeYQs?LrHt1(u~Ilt>N8eXxt97t5tK zR+@5?uR5)_YGSl8l&X_Rj2D&y5-^Q6Z9<{c#02@JaT7@V;q{!e;18Y2Jl}c0b7tne z?_>u56hglW+6|3HtsqCy@MkTf{3qIptY{OFyg~sRe{u5jRaTD6j>S-QW~AQFX%#*L zrblJzjXsl~&*pt*pM{)pJ}cJyb08ka{GOMf+NGBg9R90k}6+#$}GSL zzPB?DW15=qgC;#zRG!JV=``96-e6V_L~W)i3<;8L%ogHmK(7h4a|&(TKzT^vvT8yd zTpwt38~k3*pdL_e?icWAR=wK1oseDbtSTe{=~zGp7LbtzWb&C=Kr|c@k1w7Dl)wT? z+zRL+yyvgP$7)PmO7#Du>8U@-qLIzdt_aEtpip$Z`yU@K;;jme$9qRt5=z!nLq=vO z<6lEZs3CFNP%JUj$vwvmH3KtLCAT%7cDY)Jp+GA|8$~-s2gON>QxqpCPRrV{eE>SS z62Db*e-z0{?+hj5GeHwhP32X^>ls)vK&zC<*g|AJ_`o}Ui)%cp0VPQ4k{ zv0po|?dAboB3I_518p-0AJ;kD(PV z;nSCW+ zy5gMldjTMvl|~BTg-0f6>pBG;xr9LJ_Ly~7ghQQTql*hKxgq7aq0AgML)Q&Zd`R(; zH0p*#7?;M~ke5`)MfhN|SMaiWNa*7t@;3J=MP-B+eyf+j17k2DUGhMQ^J8-NiqLVa zMGTD72U#eLw5f7;bT)LiHx>nEq`y5-XYSI&qnA}nR@ZH-Yg0j0s6ACWUIdB%0CBFg Ar2qf` diff --git a/webui/backend/tests/golden/test_api_copy_golden.py b/webui/backend/tests/golden/test_api_copy_golden.py index 934135b..f49ad47 100644 --- a/webui/backend/tests/golden/test_api_copy_golden.py +++ b/webui/backend/tests/golden/test_api_copy_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 @@ -29,6 +30,18 @@ class FailingFilesystemAdapter(FilesystemAdapter): raise OSError("forced copy failure") +class BlockingCopyFilesystemAdapter(FilesystemAdapter): + def __init__(self) -> None: + super().__init__() + self.entered = threading.Event() + self.release = threading.Event() + + def copy_file(self, source: str, destination: str, on_progress: callable | None = None) -> None: + self.entered.set() + self.release.wait(timeout=2.0) + return super().copy_file(source=source, destination=destination, on_progress=on_progress) + + class CopyApiGoldenTest(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() @@ -72,11 +85,21 @@ class CopyApiGoldenTest(unittest.TestCase): while time.time() < deadline: response = self._request("GET", f"/api/tasks/{task_id}") body = response.json() - if body["status"] in {"completed", "failed"}: + if body["status"] in {"completed", "failed", "cancelled"}: return body time.sleep(0.02) self.fail("task did not reach terminal state in time") + def _wait_for_status(self, task_id: str, statuses: set[str], timeout_s: float = 2.0) -> dict: + deadline = time.time() + timeout_s + while time.time() < deadline: + response = self._request("GET", f"/api/tasks/{task_id}") + body = response.json() + if body["status"] in statuses: + return body + time.sleep(0.02) + self.fail(f"task did not reach one of {sorted(statuses)} in time") + def test_copy_success_create_task_shape(self) -> None: src = self.root / "source.txt" src.write_text("hello", encoding="utf-8") @@ -189,6 +212,40 @@ class CopyApiGoldenTest(unittest.TestCase): self.assertEqual((self.root / "dest" / "file.txt").read_text(encoding="utf-8"), "F") self.assertEqual((self.root / "dest" / "docs" / "nested" / "note.txt").read_text(encoding="utf-8"), "N") + def test_copy_batch_cancelled_after_current_file_finishes(self) -> None: + blocking_fs = BlockingCopyFilesystemAdapter() + path_guard = PathGuard({"storage1": str(self.root), "storage2": str(self.root)}) + self._set_services(path_guard=path_guard, filesystem=blocking_fs) + (self.root / "a.txt").write_text("A", encoding="utf-8") + (self.root / "b.txt").write_text("B", encoding="utf-8") + (self.root / "dest").mkdir() + + response = self._request( + "POST", + "/api/files/copy", + { + "sources": ["storage1/a.txt", "storage1/b.txt"], + "destination_base": "storage1/dest", + }, + ) + + task_id = response.json()["task_id"] + self.assertTrue(blocking_fs.entered.wait(timeout=2.0)) + running = self._wait_for_status(task_id, {"running"}) + self.assertEqual(running["current_item"], str(self.root / "a.txt")) + + cancel_response = self._request("POST", f"/api/tasks/{task_id}/cancel") + self.assertEqual(cancel_response.status_code, 200) + self.assertEqual(cancel_response.json()["status"], "cancelling") + + blocking_fs.release.set() + detail = self._wait_task(task_id) + self.assertEqual(detail["status"], "cancelled") + self.assertEqual(detail["done_items"], 1) + self.assertEqual(detail["total_items"], 2) + self.assertTrue((self.root / "dest" / "a.txt").exists()) + self.assertFalse((self.root / "dest" / "b.txt").exists()) + def test_copy_source_not_found(self) -> None: response = self._request( "POST", diff --git a/webui/backend/tests/golden/test_api_duplicate_golden.py b/webui/backend/tests/golden/test_api_duplicate_golden.py index 4c0feb2..22a5a4d 100644 --- a/webui/backend/tests/golden/test_api_duplicate_golden.py +++ b/webui/backend/tests/golden/test_api_duplicate_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 @@ -33,6 +34,18 @@ class FailOnSecondCopyFilesystemAdapter(FilesystemAdapter): super().copy_file(source=source, destination=destination, on_progress=on_progress) +class BlockingDuplicateFilesystemAdapter(FilesystemAdapter): + def __init__(self) -> None: + super().__init__() + self.entered = threading.Event() + self.release = threading.Event() + + def copy_file(self, source: str, destination: str, on_progress: callable | None = None) -> None: + self.entered.set() + self.release.wait(timeout=2.0) + super().copy_file(source=source, destination=destination, on_progress=on_progress) + + class DuplicateApiGoldenTest(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() @@ -75,11 +88,21 @@ class DuplicateApiGoldenTest(unittest.TestCase): while time.time() < deadline: response = self._request("GET", f"/api/tasks/{task_id}") body = response.json() - if body["status"] in {"completed", "failed"}: + if body["status"] in {"completed", "failed", "cancelled"}: return body time.sleep(0.02) self.fail("task did not reach terminal state in time") + def _wait_for_status(self, task_id: str, statuses: set[str], timeout_s: float = 2.0) -> dict: + deadline = time.time() + timeout_s + while time.time() < deadline: + response = self._request("GET", f"/api/tasks/{task_id}") + body = response.json() + if body["status"] in statuses: + return body + time.sleep(0.02) + self.fail(f"task did not reach one of {sorted(statuses)} in time") + def test_duplicate_single_file_success(self) -> None: (self.root / "note.txt").write_text("hello", encoding="utf-8") @@ -132,6 +155,36 @@ class DuplicateApiGoldenTest(unittest.TestCase): self.assertEqual((self.root / "a copy.txt").read_text(encoding="utf-8"), "A") self.assertEqual((self.root / "docs copy" / "nested" / "b.txt").read_text(encoding="utf-8"), "B") + def test_duplicate_multi_select_cancelled_after_current_item_finishes(self) -> None: + blocking_fs = BlockingDuplicateFilesystemAdapter() + path_guard = PathGuard({"storage1": str(self.root), "storage2": str(self.root)}) + self._set_services(path_guard=path_guard, filesystem=blocking_fs) + (self.root / "a.txt").write_text("A", encoding="utf-8") + (self.root / "b.txt").write_text("B", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/duplicate", + {"paths": ["storage1/a.txt", "storage1/b.txt"]}, + ) + + task_id = response.json()["task_id"] + self.assertTrue(blocking_fs.entered.wait(timeout=2.0)) + running = self._wait_for_status(task_id, {"running"}) + self.assertEqual(running["current_item"], str(self.root / "a.txt")) + + cancel_response = self._request("POST", f"/api/tasks/{task_id}/cancel") + self.assertEqual(cancel_response.status_code, 200) + self.assertEqual(cancel_response.json()["status"], "cancelling") + + blocking_fs.release.set() + detail = self._wait_task(task_id) + self.assertEqual(detail["status"], "cancelled") + self.assertEqual(detail["done_items"], 1) + self.assertEqual(detail["total_items"], 2) + self.assertTrue((self.root / "a copy.txt").exists()) + self.assertFalse((self.root / "b copy.txt").exists()) + def test_duplicate_collision_resolution_for_files_and_directories(self) -> None: (self.root / "report.txt").write_text("R", encoding="utf-8") (self.root / "report copy.txt").write_text("existing", encoding="utf-8") diff --git a/webui/backend/tests/golden/test_api_file_ops_golden.py b/webui/backend/tests/golden/test_api_file_ops_golden.py index 26a75f9..23f5ac6 100644 --- a/webui/backend/tests/golden/test_api_file_ops_golden.py +++ b/webui/backend/tests/golden/test_api_file_ops_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 @@ -22,6 +23,18 @@ from backend.app.services.task_service import TaskService from backend.app.tasks_runner import TaskRunner +class BlockingDeleteFilesystemAdapter(FilesystemAdapter): + def __init__(self) -> None: + super().__init__() + self.entered = threading.Event() + self.release = threading.Event() + + def delete_file(self, path: Path) -> None: + self.entered.set() + self.release.wait(timeout=2.0) + super().delete_file(path) + + class FileOpsApiGoldenTest(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() @@ -84,11 +97,21 @@ class FileOpsApiGoldenTest(unittest.TestCase): while time.time() < deadline: response = self._get(f"/api/tasks/{task_id}") body = response.json() - if body["status"] in {"completed", "failed"}: + if body["status"] in {"completed", "failed", "cancelled"}: return body time.sleep(0.02) self.fail("task did not reach terminal state in time") + def _wait_for_status(self, task_id: str, statuses: set[str], timeout_s: float = 2.0) -> dict: + deadline = time.time() + timeout_s + while time.time() < deadline: + response = self._get(f"/api/tasks/{task_id}") + body = response.json() + if body["status"] in statuses: + return body + time.sleep(0.02) + self.fail(f"task did not reach one of {sorted(statuses)} in time") + def test_mkdir_success(self) -> None: response = self._post( "/api/files/mkdir", @@ -272,6 +295,54 @@ class FileOpsApiGoldenTest(unittest.TestCase): self.assertEqual(detail["status"], "completed") self.assertFalse(target.exists()) + def test_delete_file_cancelled_after_current_delete_finishes(self) -> None: + blocking_fs = BlockingDeleteFilesystemAdapter() + path_guard = PathGuard({"storage1": str(self.root)}) + service = FileOpsService(path_guard=path_guard, filesystem=blocking_fs) + delete_service = DeleteTaskService( + path_guard=path_guard, + repository=self.repo, + runner=TaskRunner(repository=self.repo, filesystem=blocking_fs), + ) + task_service = TaskService(repository=self.repo) + + async def _override_file_ops_service() -> FileOpsService: + return service + + async def _override_delete_task_service() -> DeleteTaskService: + return delete_service + + async def _override_task_service() -> TaskService: + return task_service + + app.dependency_overrides[get_file_ops_service] = _override_file_ops_service + app.dependency_overrides[get_delete_task_service] = _override_delete_task_service + app.dependency_overrides[get_task_service] = _override_task_service + + target = self.scope / "delete_later.txt" + target.write_text("z", encoding="utf-8") + + response = self._post( + "/api/files/delete", + {"path": "storage1/scope/delete_later.txt"}, + ) + + task_id = response.json()["task_id"] + self.assertTrue(blocking_fs.entered.wait(timeout=2.0)) + running = self._wait_for_status(task_id, {"running"}) + self.assertEqual(running["current_item"], str(self.scope / "delete_later.txt")) + + cancel_response = self._post(f"/api/tasks/{task_id}/cancel", {}) + self.assertEqual(cancel_response.status_code, 200) + self.assertEqual(cancel_response.json()["status"], "cancelling") + + blocking_fs.release.set() + detail = self._wait_task(task_id) + self.assertEqual(detail["status"], "cancelled") + self.assertEqual(detail["done_items"], 1) + self.assertEqual(detail["total_items"], 1) + self.assertFalse(target.exists()) + def test_delete_empty_directory_success(self) -> None: target = self.scope / "empty_dir" target.mkdir() diff --git a/webui/backend/tests/golden/test_api_history_golden.py b/webui/backend/tests/golden/test_api_history_golden.py index 454b2e2..1670cee 100644 --- a/webui/backend/tests/golden/test_api_history_golden.py +++ b/webui/backend/tests/golden/test_api_history_golden.py @@ -49,6 +49,18 @@ class BlockingArchiveBuildFileOpsService(FileOpsService): super()._write_download_target_to_zip(archive, resolved_target, on_each_item=on_each_item) +class BlockingCopyFilesystemAdapter(FilesystemAdapter): + def __init__(self) -> None: + super().__init__() + self.entered = threading.Event() + self.release = threading.Event() + + def copy_file(self, source: str, destination: str, on_progress=None) -> None: + self.entered.set() + self.release.wait(timeout=2.0) + return super().copy_file(source=source, destination=destination, on_progress=on_progress) + + class HistoryApiGoldenTest(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() @@ -82,7 +94,7 @@ class HistoryApiGoldenTest(unittest.TestCase): delete_service = DeleteTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo) duplicate_service = DuplicateTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo) move_service = MoveTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo) - task_service = TaskService(repository=self.task_repo) + task_service = TaskService(repository=self.task_repo, history_repository=self.history_repo) history_service = HistoryService(repository=self.history_repo) async def _override_file_ops_service() -> FileOpsService: @@ -138,6 +150,16 @@ class HistoryApiGoldenTest(unittest.TestCase): time.sleep(0.02) self.fail('task did not reach terminal state in time') + def _wait_for_status(self, task_id: str, statuses: set[str], timeout_s: float = 2.0) -> dict: + deadline = time.time() + timeout_s + while time.time() < deadline: + response = self._request('GET', f'/api/tasks/{task_id}') + body = response.json() + if body['status'] in statuses: + return body + time.sleep(0.02) + self.fail(f"task did not reach one of {sorted(statuses)} in time") + def test_get_history_empty_list(self) -> None: response = self._request('GET', '/api/history') self.assertEqual(response.status_code, 200) @@ -207,6 +229,35 @@ class HistoryApiGoldenTest(unittest.TestCase): self.assertEqual(history[0]['source'], 'storage1/source.txt') self.assertEqual(history[0]['destination'], 'storage1/copied.txt') + def test_copy_cancelled_history_item(self) -> None: + blocking_fs = BlockingCopyFilesystemAdapter() + self._set_services(blocking_fs) + (self.root1 / 'a.txt').write_text('A', encoding='utf-8') + (self.root1 / 'b.txt').write_text('B', encoding='utf-8') + (self.root1 / 'dest').mkdir() + + response = self._request( + 'POST', + '/api/files/copy', + {'sources': ['storage1/a.txt', 'storage1/b.txt'], 'destination_base': 'storage1/dest'}, + ) + + task_id = response.json()['task_id'] + self.assertTrue(blocking_fs.entered.wait(timeout=2.0)) + self._wait_for_status(task_id, {'running'}) + cancel_response = self._request('POST', f'/api/tasks/{task_id}/cancel') + self.assertEqual(cancel_response.status_code, 200) + self.assertEqual(cancel_response.json()['status'], 'cancelling') + blocking_fs.release.set() + detail = self._wait_task(task_id) + + self.assertEqual(detail['status'], 'cancelled') + history = self._request('GET', '/api/history').json()['items'] + self.assertEqual(history[0]['operation'], 'copy') + self.assertEqual(history[0]['status'], 'cancelled') + self.assertEqual(history[0]['source'], '2 items') + self.assertEqual(history[0]['destination'], 'storage1/dest') + def test_move_failed_history_item(self) -> None: src = self.root1 / 'source.txt' src.write_text('hello', encoding='utf-8') @@ -334,6 +385,7 @@ class HistoryApiGoldenTest(unittest.TestCase): cancel = self._request('POST', f"/api/files/download/archive/{response.json()['task_id']}/cancel") release.set() self._wait_task(response.json()['task_id']) + time.sleep(0.05) history = self._request('GET', '/api/history').json()['items'] self.assertEqual(cancel.status_code, 200) diff --git a/webui/backend/tests/golden/test_api_move_golden.py b/webui/backend/tests/golden/test_api_move_golden.py index fbdfaff..fb9ffcd 100644 --- a/webui/backend/tests/golden/test_api_move_golden.py +++ b/webui/backend/tests/golden/test_api_move_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 @@ -38,6 +39,18 @@ class FailingBatchFilesystemAdapter(FilesystemAdapter): super().move_directory(source, destination) +class BlockingMoveFilesystemAdapter(FilesystemAdapter): + def __init__(self) -> None: + super().__init__() + self.entered = threading.Event() + self.release = threading.Event() + + def move_file(self, source: str, destination: str) -> None: + self.entered.set() + self.release.wait(timeout=2.0) + super().move_file(source, destination) + + class MoveApiGoldenTest(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() @@ -83,11 +96,21 @@ class MoveApiGoldenTest(unittest.TestCase): while time.time() < deadline: response = self._request("GET", f"/api/tasks/{task_id}") body = response.json() - if body["status"] in {"completed", "failed"}: + if body["status"] in {"completed", "failed", "cancelled"}: return body time.sleep(0.02) self.fail("task did not reach terminal state in time") + def _wait_for_status(self, task_id: str, statuses: set[str], timeout_s: float = 2.0) -> dict: + deadline = time.time() + timeout_s + while time.time() < deadline: + response = self._request("GET", f"/api/tasks/{task_id}") + body = response.json() + if body["status"] in statuses: + return body + time.sleep(0.02) + self.fail(f"task did not reach one of {sorted(statuses)} in time") + def test_move_success_same_root_create_task_shape_and_completed(self) -> None: src = self.root1 / "source.txt" src.write_text("hello", encoding="utf-8") @@ -225,6 +248,42 @@ class MoveApiGoldenTest(unittest.TestCase): self.assertFalse(source_file.exists()) self.assertFalse(source_dir.exists()) + def test_move_batch_cancelled_after_current_file_finishes(self) -> None: + blocking_fs = BlockingMoveFilesystemAdapter() + path_guard = PathGuard({"storage1": str(self.root1), "storage2": str(self.root2)}) + self._set_services(path_guard=path_guard, filesystem=blocking_fs) + (self.root1 / "a.txt").write_text("A", encoding="utf-8") + (self.root1 / "b.txt").write_text("B", encoding="utf-8") + target = self.root1 / "target" + target.mkdir() + + response = self._request( + "POST", + "/api/files/move", + { + "sources": ["storage1/a.txt", "storage1/b.txt"], + "destination_base": "storage1/target", + }, + ) + + task_id = response.json()["task_id"] + self.assertTrue(blocking_fs.entered.wait(timeout=2.0)) + running = self._wait_for_status(task_id, {"running"}) + self.assertEqual(running["current_item"], str(self.root1 / "a.txt")) + + cancel_response = self._request("POST", f"/api/tasks/{task_id}/cancel") + self.assertEqual(cancel_response.status_code, 200) + self.assertEqual(cancel_response.json()["status"], "cancelling") + + blocking_fs.release.set() + detail = self._wait_task(task_id) + self.assertEqual(detail["status"], "cancelled") + self.assertEqual(detail["done_items"], 1) + self.assertEqual(detail["total_items"], 2) + self.assertTrue((target / "a.txt").exists()) + self.assertTrue((self.root1 / "b.txt").exists()) + self.assertFalse((target / "b.txt").exists()) + def test_move_batch_cross_root_directories_blocked(self) -> None: first = self.root1 / "first-dir" second = self.root1 / "second-dir" diff --git a/webui/backend/tests/golden/test_api_tasks_golden.py b/webui/backend/tests/golden/test_api_tasks_golden.py index 6303bf1..d84ffc4 100644 --- a/webui/backend/tests/golden/test_api_tasks_golden.py +++ b/webui/backend/tests/golden/test_api_tasks_golden.py @@ -40,6 +40,14 @@ class TasksApiGoldenTest(unittest.TestCase): return asyncio.run(_run()) + def _post(self, url: str, payload: dict | None = None) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + return await client.post(url, json=payload) + + return asyncio.run(_run()) + def _insert_task( self, *, @@ -265,6 +273,78 @@ class TasksApiGoldenTest(unittest.TestCase): self.assertEqual(body["total_items"], 1) self.assertEqual(body["current_item"], "storage1/trash.txt") + def test_cancel_running_delete_task_returns_cancelling(self) -> None: + self._insert_task( + task_id="task-delete", + operation="delete", + status="running", + source="storage1/trash.txt", + destination="", + created_at="2026-03-10T10:00:00Z", + started_at="2026-03-10T10:00:01Z", + done_items=0, + total_items=1, + current_item="storage1/trash.txt", + ) + + response = self._post("/api/tasks/task-delete/cancel") + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["operation"], "delete") + self.assertEqual(body["status"], "cancelling") + self.assertEqual(body["current_item"], "storage1/trash.txt") + + def test_cancel_completed_task_rejected(self) -> None: + self._insert_task( + task_id="task-copy", + operation="copy", + status="completed", + source="storage1/a.txt", + destination="storage2/a.txt", + created_at="2026-03-10T10:00:00Z", + finished_at="2026-03-10T10:00:04Z", + ) + + response = self._post("/api/tasks/task-copy/cancel") + + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.json(), + { + "error": { + "code": "task_not_cancellable", + "message": "Task cannot be cancelled", + "details": {"task_id": "task-copy", "status": "completed"}, + } + }, + ) + + def test_cancel_download_task_rejected(self) -> None: + self._insert_task( + task_id="task-download", + operation="download", + status="preparing", + source="single_directory_zip", + destination="docs.zip", + created_at="2026-03-10T10:00:00Z", + started_at="2026-03-10T10:00:01Z", + ) + + response = self._post("/api/tasks/task-download/cancel") + + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.json(), + { + "error": { + "code": "task_not_cancellable", + "message": "Task cannot be cancelled", + "details": {"task_id": "task-download", "status": "preparing"}, + } + }, + ) + def test_get_task_detail_ready_archive_download(self) -> None: self._insert_task( task_id="task-download-ready", diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 17ecc8d..6da33a5 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -240,6 +240,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self._extract_js_function(app_js, "formatTaskLine"), self._extract_js_function(app_js, "isActiveTask"), self._extract_js_function(app_js, "activeTasksFromItems"), + self._extract_js_function(app_js, "taskIsCancellable"), + self._extract_js_function(app_js, "cancelTaskRequest"), self._extract_js_function(app_js, "activeTaskChipLabel"), self._extract_js_function(app_js, "headerTaskRenderKey"), self._extract_js_function(app_js, "shouldPollHeaderTasks"), @@ -289,6 +291,8 @@ class UiSmokeGoldenTest(unittest.TestCase): textContent: "", innerHTML: "", children: [], + disabled: false, + onclick: null, scrollTop: 0, attributes: {{}}, append(...nodes) {{ @@ -329,6 +333,9 @@ class UiSmokeGoldenTest(unittest.TestCase): return value || "now"; }} + async function refreshTasksSnapshot() {{}} + function setError() {{}} + let headerTaskState = {{ activeItems: [], popoverOpen: false, @@ -336,7 +343,7 @@ class UiSmokeGoldenTest(unittest.TestCase): lastRenderKey: "", }}; const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); - const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); + const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]); {functions} @@ -347,6 +354,7 @@ class UiSmokeGoldenTest(unittest.TestCase): {{ id: "d", operation: "download", status: "preparing", source: "/src/d", destination: "folder.zip" }}, {{ id: "dup", operation: "duplicate", status: "queued", source: "/src/dup", destination: "/dst/dup" }}, {{ id: "del", operation: "delete", status: "running", source: "/src/del", destination: "" }}, + {{ id: "stop", operation: "copy", status: "cancelling", source: "/src/stop", destination: "/dst/stop" }}, {{ id: "e", operation: "copy", status: "completed", source: "/src/e", destination: "/dst/e" }}, {{ id: "f", operation: "move", status: "failed", source: "/src/f", destination: "/dst/f" }}, {{ id: "g", operation: "download", status: "ready", source: "/src/g", destination: "folder.zip" }}, @@ -354,21 +362,28 @@ class UiSmokeGoldenTest(unittest.TestCase): ]; const activeTasks = activeTasksFromItems(mixedTasks); - assert(activeTasks.length === 4, "Only task-based file actions in queued or running should count as active"); + assert(activeTasks.length === 5, "Only active task-based file actions should count as active"); assert(activeTasks.every((task) => isActiveTask(task)), "All filtered tasks should be active"); assert(activeTasks.some((task) => task.operation === "delete"), "Delete should count once it uses the shared task flow"); - assert(activeTaskChipLabel(activeTasks.length) === "4 active tasks", "Chip label should reflect active task count"); + assert(activeTasks.some((task) => task.status === "cancelling"), "Cancelling tasks should remain visible while stopping"); + assert(activeTaskChipLabel(activeTasks.length) === "5 active tasks", "Chip label should reflect active task count"); updateHeaderTaskState(mixedTasks); assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Chip should be visible with active tasks"); - assert(elements["header-task-chip-label"].textContent === "4 active tasks", "Chip label should render active task count"); + assert(elements["header-task-chip-label"].textContent === "5 active tasks", "Chip label should render active task count"); assert(shouldPollHeaderTasks(), "Active tasks should enable header polling"); setHeaderTaskPopoverOpen(true); assert(headerTaskState.popoverOpen, "Popover should open when active tasks exist"); assert(!elements["header-task-popover"].classList.contains("hidden"), "Popover should be visible when open"); assert(elements["header-task-chip-btn"].attributes["aria-expanded"] === "true", "Chip button should expose expanded state"); - assert(elements["header-task-popover-list"].children.length === 4, "Popover should render only active file-action tasks"); + assert(elements["header-task-popover-list"].children.length === 5, "Popover should render only active file-action tasks"); + const firstActionButton = elements["header-task-popover-list"].children[0].children[3].children[0]; + const cancellingActionButton = elements["header-task-popover-list"].children[4].children[3].children[0]; + assert(firstActionButton.textContent === "Stop", "Queued/running tasks should expose a Stop action"); + assert(!firstActionButton.disabled, "Queued/running tasks should be cancellable"); + assert(cancellingActionButton.textContent === "Stopping...", "Cancelling tasks should show stopping state"); + assert(cancellingActionButton.disabled, "Cancelling tasks should not expose a second stop action"); updateHeaderTaskState([ {{ id: "z1", operation: "copy", status: "completed", source: "/src/z1", destination: "/dst/z1" }}, @@ -399,6 +414,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self._extract_js_function(app_js, "formatTaskLine"), self._extract_js_function(app_js, "isActiveTask"), self._extract_js_function(app_js, "activeTasksFromItems"), + self._extract_js_function(app_js, "taskIsCancellable"), + self._extract_js_function(app_js, "cancelTaskRequest"), self._extract_js_function(app_js, "activeTaskChipLabel"), self._extract_js_function(app_js, "headerTaskRenderKey"), self._extract_js_function(app_js, "shouldPollHeaderTasks"), @@ -449,6 +466,8 @@ class UiSmokeGoldenTest(unittest.TestCase): textContent: "", innerHTML: "", children: [], + disabled: false, + onclick: null, scrollTop: 0, attributes: {{}}, append(...nodes) {{ @@ -489,6 +508,9 @@ class UiSmokeGoldenTest(unittest.TestCase): return value || "now"; }} + async function refreshTasksSnapshot() {{}} + function setError() {{}} + let state = {{ lastTaskCount: 0 }}; let headerTaskState = {{ activeItems: [], @@ -497,7 +519,7 @@ class UiSmokeGoldenTest(unittest.TestCase): lastRenderKey: "", }}; const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); - const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); + const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]); {functions} @@ -781,11 +803,13 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function formatTaskLine(task)', app_js) self.assertIn('let headerTaskState = {', app_js) self.assertIn('const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);', app_js) - self.assertIn('const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);', app_js) + self.assertIn('const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);', app_js) self.assertIn("The header chip reflects only user-visible file actions that use the shared task system.", app_js) self.assertIn('function headerTaskElements()', app_js) self.assertIn('function isActiveTask(task)', app_js) self.assertIn('function activeTasksFromItems(items)', app_js) + self.assertIn('function taskIsCancellable(task)', app_js) + self.assertIn('async function cancelTaskRequest(taskId)', app_js) self.assertIn('function activeTaskChipLabel(count)', app_js) self.assertIn('function shouldPollHeaderTasks()', app_js) self.assertIn('function scheduleHeaderTaskPolling()', app_js) diff --git a/webui/backend/tests/unit/__pycache__/test_task_recovery_service.cpython-313.pyc b/webui/backend/tests/unit/__pycache__/test_task_recovery_service.cpython-313.pyc index 46efd68c2a6e1225b625a1516f2cde58372f529b..81dd64c0a389ff3c9206a4906c25b6e2aedaae0b 100644 GIT binary patch delta 947 zcmaKqO-vI(6o6-!-R^Gx%a*pbP+GzM5ZVHvmCBD0(QrV5*$aszWYey-+CrIK66pcF za!?Et6TERTk)x^cq=|Yk(Nh~S-83svibI#H*aS4z3hj<7a99+ zo6QXHnA>|zbB(p^O!5s!N*g6bju`!03u(5`5*i44; z*f_%kiHGKb5Vy3vXqfTV7U>vA9o9C52!&q<<_FA38s7)w7>6b(5}zXA3ETwJ1Tz?M z8hVJ&)>a(f>F`D3V+2`(>lpEllME8LTFZClDD>3np&1&zc22UqBqVwCkPD-qtj~c9 zkXXqiS+b@OxG5luD~w*zBD(Cdp(iHDSy_CFWJ7H(Y@sE)F>|D8^oZ+4Dkt)iQ*vb~ zsS|zVdMOe8;NsgSThcV%iAM+&K-|4hb!f7BH<6Q9b4saHTrISO{6?iz%*mS4GM|R4 z+(@XXDP^^4SLL!2$`_%M)7Ic7?8k?}3m8>V#R0jX45y${S%X?qyQjejSxy144d4(_ z(oFbId&Ws@f`EJ+jAF$3GyOJNeVHs7ou4K~nhvjE#F?{wCDCgbDuWTi1A4Kv<~8)z zk_xJtTvAp}t6Cw&uiTQ0B_-d=wTm3 z^XVf#qdUait(~o=Bhqk0>d~=Jjx73WO`x=GFi?+WkN8XY)+X<3@V=n_a@;SnGt{4fAe*V}xlL4Z8E3SZvRqt+ zGD-Fm7!Bhm%4_+Jk}?I?aSWFW)J^oh>qgf$MP>GHH-o80Fm(*btUb%1q$ltf!=CC; delta 318 zcmdmDc2bA$GcPX}0}urFZOe2N+{kyA(Z>zQoyriyP{bI_P{b6>Si~I68O$Wbki}BO z0%S3RS*$=73z)?QWU+!->_8S9n8gvyUc{Nsp~`JXT;nt|BnAi@GfcmoMdW~d5~ zfb-@&p=8F%t0XiSxh7whaNq`cq{s(E1Ws0#G-Z^V94RTy_FYAo)sb;?y(AkWPbg6G z7E5koW?oU$rXxeO|Hh}6b_Qo-_*Ufm- zmuL+LP$LdaIt4^nP2sx zK+c~G&L=p{%g)VT^NDtR%`b3+EIwdQ;heNzRGq3zMFdW9i zfp&pY1qbv+S*VEdJmXTx=~BISh}E>8{4Kw^94O^YUOijv((rcjvK^jSFG~? zH!$Fpz9sM`Qw>HwN=_#igFCF?j%p-PmAk94JxeY|?p|W-vUuIKBr<~chA=5C@~sx% zTJ^|NhbLdEMk}#uOsOiVC4mvPK4Kh#xFj+D(2dKJmlu6Kmak_v_jPuz_@i&%b^8O} zv&bhbJ~1=2z;{$(6tno)OlW58ver@b6wlZX=0KIJ-eZrigGBlxc5;8lUdfVkI& zANv(N=nmmeBtL7z)8UxAdBcbCqj)4_Q|mF{>4S?(o;Iej;H6p$|5=FIRH-iVngwPs zfLXU3wyD0l=&O4%gEFk(`(an5c{WLxz;To$Rn!buBCBblq0CtRg1%vvAlMt7CGZe( zD#;-YCSC*R0-zRZ&Na;>f|)Ay>_9q{f~9J~(Y&D*@>lf6?bfsCD6F0WXnKuWZ-mU1 z?F2}WtR%@Z)N$)KNt;MB%6Ukka%1bf!RKh0)Lx+R@uXe#rYSf)OuqFxchw_f_mf1% z+Qc|gj||UhxNNuh?RPKEnx9@>;QJmlVku<>Q>&2!{cuk2YBb?5y)k@9?&@EOlY*t} znmtK;yEadXmh#lBw!rUR8z(FNp3lgCa1MIyaVCP;Vf=Gs)L9n}AB^_3)Gs>-khCM2 ze4v*BCIBwtJJH+h7xVqGej9rUk8cUb-+*~4VN<dQ?Cfv&ueRUW zApW&|VB1MpeFb0)pa_5ft^<@IHRAqmMm`D;>LhK}YX$x5u$(IT0t&k3a9|XWzlU zbWC>LAf+{XQOg_X7IZ;S*8gz$eZ^9CqM+|b_waP*spzaS3CnB|Rz%zngJsAHM1!nCVvrIf3E3of z!Al#VG@i*=pT|s3-3#b}{6)Tyn|w}h45nc%2Ps1iAx)Bt#!-T_q7m{EO(30EV&{{` zbkq}S#fpwkV9N{fz@xZOtM50f+9JLn-B{LZ@MsBL554=sZ@%y^$FAR1D;b>`{s%Ym BZjk^0 diff --git a/webui/backend/tests/unit/test_task_recovery_service.py b/webui/backend/tests/unit/test_task_recovery_service.py index e47b4b4..e55a07f 100644 --- a/webui/backend/tests/unit/test_task_recovery_service.py +++ b/webui/backend/tests/unit/test_task_recovery_service.py @@ -109,6 +109,38 @@ class TaskRecoveryServiceTest(unittest.TestCase): self.assertEqual(task["status"], "failed") self.assertEqual(task["error_code"], "task_interrupted") + def test_reconcile_persisted_incomplete_tasks_marks_stale_cancelling_task_failed(self) -> None: + self.task_repo.insert_task_for_testing( + { + "id": "task-cancelling", + "operation": "duplicate", + "status": "cancelling", + "source": "2 items", + "destination": "same directory", + "created_at": "2026-03-10T10:00:00Z", + "started_at": "2026-03-10T10:00:01Z", + "current_item": "storage1/report.txt", + } + ) + self.history_repo.create_entry( + entry_id="task-cancelling", + operation="duplicate", + status="queued", + source="2 items", + destination="same directory", + created_at="2026-03-10T10:00:00Z", + ) + + changed = reconcile_persisted_incomplete_tasks(self.task_repo, self.history_repo) + + self.assertEqual(changed, ["task-cancelling"]) + task = self.task_repo.get_task("task-cancelling") + self.assertEqual(task["status"], "failed") + self.assertEqual(task["error_code"], "task_interrupted") + history = self.history_repo.list_history(limit=5)[0] + self.assertEqual(history["id"], "task-cancelling") + self.assertEqual(history["status"], "failed") + if __name__ == "__main__": unittest.main() diff --git a/webui/backend/tests/unit/test_task_repository.py b/webui/backend/tests/unit/test_task_repository.py index dae4de8..9d5214b 100644 --- a/webui/backend/tests/unit/test_task_repository.py +++ b/webui/backend/tests/unit/test_task_repository.py @@ -107,6 +107,68 @@ class TaskRepositoryTest(unittest.TestCase): self.assertEqual(task["status"], "cancelled") self.assertIsNotNone(task["finished_at"]) + def test_request_cancellation_moves_running_file_task_to_cancelling(self) -> None: + created = self.repo.create_task( + operation="copy", + source="storage1/docs/a.txt", + destination="storage1/docs-copy/a.txt", + ) + self.repo.mark_running( + created["id"], + done_items=0, + total_items=2, + current_item="storage1/docs/a.txt", + ) + + task = self.repo.request_cancellation(created["id"]) + + self.assertIsNotNone(task) + self.assertEqual(task["status"], "cancelling") + self.assertEqual(task["current_item"], "storage1/docs/a.txt") + self.assertIsNone(task["finished_at"]) + + def test_request_cancellation_moves_queued_file_task_to_cancelled(self) -> None: + created = self.repo.create_task( + operation="delete", + source="storage1/docs/a.txt", + destination="", + ) + + task = self.repo.request_cancellation(created["id"]) + + self.assertIsNotNone(task) + self.assertEqual(task["status"], "cancelled") + self.assertIsNone(task["current_item"]) + self.assertIsNotNone(task["finished_at"]) + + def test_finalize_cancelled_transitions_cancelling_task(self) -> None: + created = self.repo.create_task( + operation="move", + source="storage1/docs/a.txt", + destination="storage1/archive/a.txt", + ) + self.repo.mark_running( + created["id"], + done_items=0, + total_items=3, + current_item="storage1/docs/a.txt", + ) + self.repo.request_cancellation(created["id"]) + + changed = self.repo.finalize_cancelled( + created["id"], + done_items=1, + total_items=3, + ) + task = self.repo.get_task(created["id"]) + + self.assertTrue(changed) + self.assertEqual(task["status"], "cancelled") + self.assertEqual(task["done_items"], 1) + self.assertEqual(task["total_items"], 3) + self.assertIsNone(task["current_item"]) + self.assertIsNotNone(task["finished_at"]) + def test_reconcile_incomplete_tasks_marks_non_terminal_failed(self) -> None: self.repo.insert_task_for_testing( { diff --git a/webui/html/app.js b/webui/html/app.js index a4b362d..07e281a 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -122,7 +122,7 @@ let headerTaskState = { }; // The header chip reflects only user-visible file actions that use the shared task system. const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); -const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]); +const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]); const VALID_THEME_FAMILIES = [ "default", "macos-soft", @@ -3835,6 +3835,10 @@ function formatTaskStatusLabel(task) { return "Queued"; case "running": return "Running"; + case "cancelling": + return "Cancelling"; + case "cancelled": + return "Cancelled"; case "completed": return "Completed"; case "failed": @@ -3883,6 +3887,14 @@ function activeTasksFromItems(items) { return Array.isArray(items) ? items.filter((task) => isActiveTask(task)) : []; } +function taskIsCancellable(task) { + return Boolean(task) && ACTIVE_TASK_OPERATIONS.has(task.operation) && ["queued", "running"].includes(task.status); +} + +async function cancelTaskRequest(taskId) { + return apiRequest("POST", `/api/tasks/${encodeURIComponent(taskId)}/cancel`); +} + function activeTaskChipLabel(count) { return `${count} active task${count === 1 ? "" : "s"}`; } @@ -3972,6 +3984,29 @@ function renderHeaderTaskPopover(items) { meta.className = "header-task-item-meta"; meta.textContent = line.meta; row.append(title, path, meta); + if (taskIsCancellable(task) || task.status === "cancelling") { + const actions = document.createElement("div"); + actions.className = "header-task-item-actions"; + const cancelButton = document.createElement("button"); + cancelButton.type = "button"; + cancelButton.className = "header-task-item-action"; + cancelButton.textContent = task.status === "cancelling" ? "Stopping..." : "Stop"; + cancelButton.disabled = task.status === "cancelling"; + if (!cancelButton.disabled) { + cancelButton.onclick = async () => { + cancelButton.disabled = true; + try { + await cancelTaskRequest(task.id); + await refreshTasksSnapshot(); + } catch (err) { + cancelButton.disabled = false; + setError("actions-error", `Stop task: ${err.message}`); + } + }; + } + actions.append(cancelButton); + row.append(actions); + } elements.popoverList.append(row); } headerTaskState.lastRenderKey = renderKey; diff --git a/webui/html/base.css b/webui/html/base.css index c6613fe..af5eea1 100644 --- a/webui/html/base.css +++ b/webui/html/base.css @@ -157,6 +157,18 @@ body { word-break: break-word; } +.header-task-item-actions { + margin-top: 8px; + display: flex; + justify-content: flex-end; +} + +.header-task-item-action { + min-width: 74px; + padding: 4px 8px; + font-size: 12px; +} + h1, h2, h3 { margin: 0; }