From e06592320a4c5cf1363e704c93c29815d59a4fb0 Mon Sep 17 00:00:00 2001 From: Cui Chen Date: Mon, 9 Nov 2026 16:10:38 +0800 Subject: [PATCH 01/12] Batch manuscript compilation until reviewed changes are applied --- docs/images/review-building-toolbar.png | Bin 0 -> 81251 bytes docs/performance.md | 6 +- package.json | 1 + scripts/check-review-batching.mjs | 177 ++++++++++++++++++++++++ scripts/check-review-flow.mjs | 44 +++++- scripts/check-source-edit.mjs | 11 +- src/server/app.ts | 57 ++++---- src/server/workspace.ts | 2 - src/web/review-frame.tsx | 8 ++ tests/app.test.ts | 97 ++++++++++--- 10 files changed, 345 insertions(+), 58 deletions(-) create mode 100644 docs/images/review-building-toolbar.png create mode 100644 scripts/check-review-batching.mjs diff --git a/docs/images/review-building-toolbar.png b/docs/images/review-building-toolbar.png new file mode 100644 index 0000000000000000000000000000000000000000..e485b9c69966e3dba636c2b70fdfe02ba616e44a GIT binary patch literal 81251 zcmaHSWl$W!7H)t5!GpUKJXmmdcb5=?FAj?b3+@iV-66QU1$TG1;O??-a&O(L_v6)@ zAIR)%_jJ$cKA)TkRaTTjK_ozY_wF5vjI_AwyLa#9-@SwR`vDp_BPa8c_U_%scQWE4 z>Tc;LYw(_!d!%p|Tx$ie!o;|NEqXo}HbY zo12@Bjg5&(anj1n%xr#szS;3$JmpIf*{~uW_+LY+SlHNmr>BwD#U1*J$c{X z;$vby5)vNo?ChMMpa1;}GBy1aQUeckB&+A?=_xOd?7hQEMk^+-pin(B)6)YDW1%>T zlIec9yeUp60yQu*^F=C(sG*@@J5sqs-LhU6&kqVW5kp*1E)r;ri>r}BBCIN3Z0S8P zh5{%d{n*&pMWNs<_XgZA$PTd2d{f_R8X6@1$r9Mi%P~c;_+?<;g*wGDd&|!(c#{1? zTt)=ps`Ab1rg+K4-Cci25LdjtGPs+D)AejM=;^mn{prccg%<`9QN(C;Y1E*Ro1`_9 zbsfeh*$=smkpqbsDq$C`A#u=mbT!>Jw2N_znTw@EB*FbCw)Q1<_POfW$auK8_tZPp zP?%BBNPmp_()@~gC3_1o#q9_gc*GOU5(8~|EMyKU>>#!be)T5dMx^v!LQAexx9()Oh#>NQ` z%*&@-|6t8-&6}AbW=H&Q9zXEyf_DeW|3b(ksi>$xS>xe_wV|P*Zzv#kXdpb|q7LAc zH0NzDPEK0SJ6atNP~m>u9?iA7+Fh<+BdG~_Khm$z+T;3!2-rg9I(e&f&Bu^y{?h0p z^o@4?C5Vedf{lG^CfWgxpq*OjixCqa>W#Mb^yDu?T#jpKNW8p?aTU;8Uo1@~aXeH9 zH+4Ik9KubE$JpERIqu8vrhnFJP_OvP^St0_vL=%TwXig1z>gwNG-&#X$g$+)cVK_1$_X6S5lrwZjwW=srC+ z=A+bfPrdbrmn-K$lPLW7;Oz?y`@6iH)&6>a>}xYPkx@q#qzLZ%s$C<g#AO!)-{N#5ntCF2*f z(T^#GGTfmkH#K*qq7WZ#UDWG8oubX^-^jOfDs{i44FBYjB7MF;hum-Y#79XDKE7N} z1bR=O{CzxGOGdgkt}hA;(QI}5vp^re7}M(`e9E@}xl}j*4a?%Kh<{qzE7j$686lU^ zMN6!ip%Ynxmv#4JyT@03{VBLUX3*=?ts1Y1=l$6lC}!@r&X+?mP+g8jnB)Or>xtOW z83>E%>%(+`+}73>2#*d;LQ;~KkI%SkV}sOYkKg_3JKkqAi zEK(S4R8b@xYzz$W%Y#F6C;^vk#5{|4XBNLZ1-sVaFAmHly|T<F&R(V++nE>?NTC zJWGGd>Oq^oKsVdleh770u!Kb9@a~6iug~>X3%@8Y^KNer)&3}shI}?CkWEa`ygIeD z$G3R8IrR6vK9~q!@I7dg>7etRaWIlx| z=ayIfhRvY)M{dw${1QoRgg|jlw6u+mbR_h`*tD?aG%?xH`ZJ2>s_s#Y{c@vS<=V}_ z{iu@AYe2?_ra7U5{OttCQS%Y8R*o9>sUJEWA_z<*4%p$J-q5Iv``m6f#ku153iR4q#%Jo!#Z>{BJS*&CahF5(z}@? z1}&z;lkVO_{1kmEk`e3fRo z?iCx-mF_Y~Z-m-RS@|P0LTatskid_dE?m;(c^4NKT8@XOCyLx`i>M`Zt&h7bNQivQ z1|6Q4+prx(rj&Scl6)QCyZs(w1>LVq#?yIBpPvU~DU%z4O@gkRex7RS(v3FkEZ6&Mp6d;=Ba-Kxzot!Qf@jq)BOPiST~1>OPlzi^y>%H?a!9n?lZi4D45^bW*u4Ne7Ra-XqF^9BLv`vc#=r_DYi!O zE_^DFru`R>qjx%vAsf;OKiDwA!ZhWd0u69puGw~DpboKY0`|ql3!*Y^xV2wdcPCp> zT66at*rG{=Bu6+7Sy@1R^JQ8}cu4H!lV^uZwFiE%NQ&v)tR{mo8fBW1L_DhIY)dr> z@E;BrTV0tExfek?`j$;QuY*g^D%b;TiNx`bJmO_ox5avmwmGAI{j8~jc%f!oK`Qw` z2pPw9PjUI=87Y%N(eT4KGsZHlhzf*o+&eR z#a&{D7UZy&t0S|pmVN~N<>Ti5{>{@nJw1I=asVTh%Pw=;DpqQghWH%F;aQ%@>^95n zhc`NnfwATk_;O-+>-o{rDj-aR2T&fdhPC|0uCs4vEcrN8Qy z=rn+meJ@s?S=a!Jt?I)I)A>#YayyIzHJ1yU&lxW*vEW;J?HTZkI+U z{s~^TMv#j7dgp0OuBkgwJ(HdQgdSQVJ{RQ1cxr_n@B0Ru6;A8Lk7JYhK}WD$P7ML&0=M!!@Zls8Ct2$zFh36h46wLVuNyB4o`I!GP(B;>VuBX z6Tw2CH!BNu7YUo5Dx?Pau6w7^aVl4$ zW{va5&0i9MA9c2Cy5?+k)zpgVA)%3Znf@DcvxW&S8e^#(NuTwC_A?Z5g&!*Am+wUV zu_3h}&+K{f8r4(;cx86sWBjpWAgv*d8LAfx8fIK7_!)^lUhT2MX>iX%ReWm?L-?q; z8&NDigqUc3c=R5lu4iw{a`W&obj(j;DENXS5Ct0xD}Z%~#LAZ!Mw5DJ-x+ucDO6MQ zN)2P>F#L<*Afr zZK^IWqrUG@u4iNU@8+##`8G6mber~3Cn6vx+T{P1<`#`-64!;2%(9pc;E#7g7{)vG zsyoCk<&hg7=OwNk3>Dj${AB1vxAB=jJDj$+4l`=-53YKlsG=Uqi_=yxe57;^&rib6 zZy>d^l}5C&67?rKieajg{DCO<6npXEH6tTvc{6q`1Mjz&=93HAIH~tJV>Ho%aVBaV z-mg!**T7U`p>(4Oy(r@)*d;@%2a_W^Ni)3co3mhEj z%a%yH2?Hl2XzZy=f773?eU84_VFM=DFg#{Y;4TbLR6}y zqq1sDC829L&yRc^BcC9}UjF((Nnk64adFe_F@$yzb$xwZA`FXlDrSZO=I-upaknEd8Ch^xuv+>)+4kBv5LbSO7~Qk5GErGW4h2WC5Xln0->6{PmV zV&IH{t#yn*hMM-(cJ^K`a+1#U9CzqRDg`r3rU~KYzXhmivj76hT6UW=>Un z-TMTnLC%MGo&hdK;jDl>;bSQmP>|}+$-3a(!}F#@1IsHG`crv74A#_fQA{fp_I@pt zZv;nhz@FACtxgqV$@Hy__8Cgd2ESTrJmij*9)tF;j!E}Y&Zyybg0 z^$OvmDtDERE-g~dH$7yYDcT(n2C6M778Gu!7Ien(DoNODuC zkhUGHT(l)2o}yJKkB>z=xyhhaL8R-A%k))vMfAYuW2ChEufRKLEiJ88lGzwzmY>Ae zS1(ucqHQ9B-K0UcO5CC|v%hRK)!_mPAx7?X$#)DrVzk;h5oGM}j6-yvbOMD_Dx40d z7unn-+_sA8vuA(SVB1bHb~x+=B_-kBRSlaCkMytu8}(;!U7yN%&#!J_1eBbtiwpbT z^flXw3|c0sEuN2TqUzJOXEjk^k|?lZl$#f_9zAk>w<&2&qwN~o+{aX==EU{s1cu!< zw@YXZvOkKe5J{?yU zDWnCNJ&mPtX;pkJ`7$ z05wvmd`!3SS;J@;fnp7mSOdw0_%Jp<3OIwP4d>Ja;cS@X2!I)Rii#tDVlc5%qJSTd zmXgC&>V7G0v@vAE1hT=O;0zyP{0AK#F17%SpB-Cm*b8?e+2_i&J$&8?iK=l1aKnX1 zkiI`C@H^F=q_Gxz4ayyJ?i>e@${w!|Mhs#BE-{hHfn<=OjY^?>JYRk(2V_Lym#Vc6 zFTz@%+c~X=jQ(8J7Cee}{i{*qPX}bzEW0KoE@6~&o{xJ;(tIFIiUZe^MHO=bA0J5m z!n^vas!1Mp+NDBEvwVj24+=sHvSONdc0t6DD|%XB9ru}xV5>=ri+_JtuIJvYlygW# zv;hNpHcHDT@j#B9*g+6p92T{lZCM;7MP zo#Gd&R)vfDJTF68&jTz*ZDrIEoHnqQ2TrVMj0u?CA~(aFapuTP7|+QcNnv7YFEI1O-B^4y2mDzG&v(zh`^m{DujN7ZAKCx3mBKxrWs{DDG4Ug@edlMmZLt zM(@j#Lk$1o^rlqFCqn&QY5vxeT-|Tcq9&+D5ntUOb4>zeIWs>=y zV%GHw42^j5+-i4Wg`FkefZ*NMr({M*oz8oJ+bof~sbJ9_6p`&-H)ZFzSaCdAWOcV? z_a@n`uhwg@4keYw8o2EJ8ACbY@_O31ae;JykmWN>+6J)vdeD^7-O19!c;ERM5K*|F zb*Yqmoxxzs`QJ+hKCc|si&eI3Eg6Dd1S*ISv&9U1;9_VCeMrFM4B1zw>UOb%j*kV~L zxfc*WCKl8h(MOs*pVur%yZnevl%IKQHwUn}n?JK5vU<6$2FOp!#3X1WWbpzMnz7Qf|-9f+^0};tq!?&VSx#UV^~0 z1#|jH@FS+lTb6Kt8ap$BtcftM^LH3F?ZNK#rcZLe{zh1@HdRl)G#Qpc345ZiMglfwGX*@1$U*kmDSodZpspNEe@;YiWz!W{J|tHB{rc+a*bMQZqU;ly zeWGU4B!q#g-96bjQ9cX^w+wSw>e^thenY%`9@dYik@(_cz= zY7F{!i!^>Wx$Kh;TvSxzI5;encU7PyD;6zSlzifoZ!(AKZW+s>sg;T*UxL;)@%&Mu z?CjjnOid$Ak_y-G$!fTa;#_FfWYk*j5Ib*Cn3Q^WDs$3P9j$yS=lAsflel<^`ERA)!xUR5+_>#d@`|w* z^4vGnbOz`$q&-?V9ZfaoeZo^l!W{i`bJ&K30PzNyfUg7`pr>9%N&6;B%0_R8e~_CmT8bR1X^Dt0xLMj1Rb$6IEb`HUS~Bq;LE0bu zV!x{o<5K?1`BGA+W4r`1Y+s|*FvP+iF0WQ1PsUdIv%J)etC91!*bY&a^QcOfW}A#OtPGm^Op36H;*KoN2No^TK1(gG(^Gl+B$nC+`In0w zq3?a>Jgg}|jz&?-+?BV(=m=2_W4-#mFzTshYBPw>X`MW&K5BZ;XK`kIeR)X-AeK~0 zuBw6Hf`TOE`h-YF9pxld=pY@_L02@ z+4dEGLN{34vvp;$3B^oXr^`#hgEvTcb=N**OR z#EC)`8FYH%#Uwod(3DXObxM!QwP3`@5Ti0}4di%S*EX}VtY}dN_CFpxLQ#ZEC6S>iOl`owEzZZo`k>@XfCpbS*}r@}WDH&xtEnvN>JoNel$U+}!RmXW ztgZZ%qaWg<*MRCg_^WX+AfbVt%dMuy*1uC9P)<#>n@s%kE21 z{Mm(Y(B=JolDXw}GYe~>xSV=WI^f&@>h0Xf2ofvx5=4NDi|YkBfk8|0?o(h<3{p2* zBICY^zD?3PLK2gH&w0!wJ8s;oW*?qd2G$Kp5K+jK@R#s0TYq6bxKmvul#Hn8=I&Zi zdct(+d;)OS+8S|I3YYR|H$+gd%&h{DbMfvF%|IOFTi+k;3YD<k6N^7^`0 z5cMQ;#Ir_py^9M$wBz?yo9~3s+fJ3$%HWTOd{^8FWXfEup$+{LWtJ~rdMk0g8S9zB zqKAH;L&rk)!$T!&P*82{VW!RfF$++i3Bs`GwgI_dtzJieA{O!{>ga_3cE1-|NS9%o zx}Tc>+fg0hFCQ4-Kb9zK@FH$Et%t-@Q|V{o!AO>SB6ir1*!U1RvB=DXP;Mr5^aPO?H;$+}XDIq6yVR0zw#&5SM1&nR2BZx&8dxe%0a3FD!0yqk8W4Gh|)gF87Pb zKTD7zmn-Q1;}@Q|{ggKF_4W1p=L6OVd_zv4B?g`kU>@R!1C;EpLeeC`t%=~Dm-zp* z8UJe_h71<7?0)=rO{|LHAo+j#&FcXiqJM`ZpmP6f{r~nWzlZ=I+?*kyq1|6y^$6#P z2L7kh_5tmof8RF#`0-;*wl9>3_&Jy6KZ|k@uXxTf1B&8@U3J|5T1_OPfVqxlM~mP@ ziDnH|P)4AjkWFb0UGAqKk2dubJk}Ngv;n{mh1(&u*vGew?G;_rNyDna#3=*@tBQ_U zi5Sg^5W?6F~e2WRe(HeSLjE@Gogc6}JoW$#1T2UY?Hw z9?#%A>(3IX;eRSM@BZD7VKR(0A}&+96BPxZ(pZdretCH@8&6-#5eptpWSB^1-2pbp z1rC6rHh`vbk1NTc>1yROc(Jkb2L}fOk#M)q03P39yG9?f-qZzB7w7>p3lLXX?7iSn!oYj zfc_3nPuEJ+iU3YVk!0wV&?viT0b&l~?3QQQ~v zaR7&AayxAA4#hKnYb#bOib_dY0?wVBp6!?VX&&Go(JYqgNx)e?tQ%>*qzma5x>N3N=32F4+Xtsk8!q zc>ozuFz{b3z~9#d&1}X6^7hJV-2WaPp0bPVbTpj@GgP8n6AhnDTye5ggMpta7?51t z0Kd&*rjQ(!9E(`Un;#IZ(>QHfe%zMwJ4OCy&8(ZJpzvg7W=?SI%0R9vrlEeeF-#vR zw#X*Zn@?mC12-E4dDuZ)rD$(zZjL12kX2M}ak5aG`P05(r;&hlB5fK*F^A-GXG*<>p zyG9EG2(U}zn!@e2q@AQH=4iQ$`{ueU3tvR7AC;c-|d zBqeD9){6O9>YSeOW|!|!rnUc2MaTZx*&C2*_iTlQf07OV%4`x8^uCF?OP-vJVeN%K zl*b_@<#M^1gug$hGycVt+3BjqMqK0J(TR6IbkWoVVWOR?G%o@Q7B0;)_`OZWN`-bW z<`|N2CbBY|QnS0de!la0$uWEWQIXdPd2*x=7XX7G6XE*^ubz*OH23;vZ?|aJ$7ZPBU6&tgG4CjB)L5*AnaY`Yabd4W<)l=V zFeEXB$ZNBh@9Q6|xaD1*&?)#&P`#~XdI);F{LQ)BK&m{T$f){wI#%3e4qi?`0CNNL ze$X&>m4!6{-dV=Z5Q;9cUH&qj!Xa==<8^bGr?V*Ih>%<$7K{pR0@Sj9n8?MY1Awum8f65W*8k4E zKHt-1l$8e&nGL|)R9xKqJkA|tDJI`2M5fkDA)hg@a|~TvR7ut|j398@ZfN~>3)Np~ z%=Lo&-SQ{m3^mViK3}qXWDd8nw)W9)(N=6Q-q<4Iv1VbhdRkEYB$twslEv-9eW|Nz z$&otye1BF`RmaZ3VKY;acUSxD*16bVDmG`y!D5|6WA})dI65|lxj@c#HN2;#Fx2LL zom8d_g`~bOs)pSy?7@RV$Q55SMAw!n-e$+$4VXlGKK3_3>;rdWRS zt?6mgVWaxkfCFY7YAIXo`}?yDy^#cl=q(C~@F68=g4RVWUZer|LX~*4#VeuD>9rQm z9qG+rV;{`3ZyjzVn2>7ElH(mIu1W(GR7A#axx39@ijL;(@<%=PXA$Zage+I4R#*Fa zdakaOq=*E(@DInSgHZ&nUw=3)yY4!5`vtP>J=PXYdq9#}r@Pa}xaA+GB6ZG#{s1Q%3s;73} zy%}t8Z@)e5Xtfcw-AaUG4@i6JrgGXfo9Gxh4VYims)(ujR7eO_f;LJR*iPpaj*q#l zBeIRom6g5CHv7U4WOJjU`pvxu=EshGbS`nhbZ?y(CvLcw3nk{ck687f$L<((kMfR8WCqO;oEHS{Qp_{cQA|?iMVab;c(LhA)f6~qS*y>`n z#<%4$J}J-D`^x$>$a>WnWKXUxZor4_D<|94?pS!k2cvfcHSvXtoerm#9WStg_|JW% zi43WY)&jOYySBuFi(Z>edB)^c$3(QUjOlzxD9UveEa#_bT$A&$6Mh-p`=DZ7-J<{^ zc6J|~ny#s8veerUJ!u8PhEqDNj@-)1`;cycB#kh$vm!ToJ^4i36^F_gRo|a?dP|9E z3epqvIrhyi>!KRlWVN&k+~0IXfck+FoTt?#HKnyx^OVh6UvZFquSS945|hbHdP>HI_g zug#7g0{7`ReUOai;QsB=L$#BWtM7@0o;=>$yLOgy6-hDKlEz*;4ejpNd$o0^Oy>TC$#%fA*W@Sq`AMO!i5nli3Hl8Dw6hRg!V1d^(Jp@_paGdZP!N%|JDe`fvwSQpjcVL?c?hB zxXK}f*WNs)KQexx%)h-BeVq+66}mm*Ql=(aY;o@$5R5>&%m&7A9rVsIy!@>@Q4U;#japPSYA1WE*N>`hOe+WTWoviRf9| z^8Yb%d3Ak$wF`?ucUGZSdo)5fqwBMXvs0GvIn0OkL`1vBKI*FN;k*_6Y*?e@TL(TJ z{?tY}4}n;u@OW9iLBsAdeWgDS3JTgEPSis+-tzhVjmuoqsG>L60A$H%EQO-eXxey( z5i=o1#!V~8^bJQ3tbM@k_Wk@Qu@V}ggGjS{Edr|T@JbK1RgqdTHOwA#zNx)F9hjPIYtCY2(q*W2XkK2Sutx zh$Y+3gcOQwDfy_H+%C5tE_c*)q=(`@#YZs$as_~7e=!@Z14wXB7@dMrxmq=mvWPh+ z=X&BO5!BWXiqh>?bY0af{fZqaR2M7=nj!hzgSO(-g=&}Z^{jO@qxCOtKs@O_lO!g8 zqyF}-6j#Ob^K59I71ycohZ6MydV*W&CK^!Q#vTN6ogm!zxXTKkf6sJ$tGj~&BVARN!8#tnWppq3K)w5J* zob?;F?~k~v?TV^msN2QbY-JTCe!0t1X+NW=8cNr@hIfcq(x`Z~7e#wRg1D((A9%pr z6#T1u>uaW+m6{~_EUspG{bS0eajqG%Z(3(ve&|mD)LnlWXI6W@o}IpB>j>x^#ZIm! z!N8sno4oHyt2cRz4pA+(NT}2rIZ(Qsc=KXMbsZw~3D)uJ3U^HlX4^teU&hPECu{9$rWAgBK zq6%dqOCa##=Z65aS|Xo?$Ga_<=Z-WETxn`P`K*DCKF=k2`o-SY!bn)h-VL0b8><>GL#8_)mrC*%hYV-A(u{kY91 zPtM;O2#pV&lBei%JSJvXT9+I7^3isQy+uq$jeQNDkG(wG;5}dHhe=jD(Ui*NgD7?n z<+xP7|Dn0N88R-e&6%&eIM@^5^$OrZJ^u}mZk(V)H=y_{;qOv+Y81Uj>1bM#8{->5 zJ&F`Fblco`y;nkNXxPi|PS<^XxAFwVblyd>oUA6Icr_>8rwq(Enk#ZzPQN^p?%$rx zuO9^BN|G!#uB<#uWL2E*ipnN1-rhgi;0YhsdJR-Cf8&Q(Os&$*n2ly$mS2hMFE{>z z5tE8KoHG}$Ver>)9#jEo9i}(FHwZQUlvi5qSQlvaKCgvCSZF9U|Xt!rvgA)q_ zx0hl%*CIei1*Cq#<496th${`U^+#@qbtl!VX`FV6s!mGuf`YbJ0e@&DZeg6KV1<|m z;G__2kMbvCk5pEzW;)_0-=Eu2d-*~=x{&eEpdw)p=PwS*bgtTRQ$`!oGS(%i@AGV>-+YD5>w0>5Q}SF* z32#28zx(ue3qW<3oGEE3ik{fG|I-4N7MF8t0(3Foq#CSsJFm`BgghZAf;@{@fr!`@ zy5(>k0=v@*c8O4=Xj0{cw^FCSAZ^kJJjq^Hb@+G>t`myQG_VGO$$R^^Wu2AZn0u*) z;~whJ2p9P-ubg_NkSHkfz9&0^m5XC(cB!6=C(%?RGt3}PfLJXV{dVp#zrNl9xPl6) zFAE9ytVRKf`xc8;hCodftje8ct)@OZ3PihOc47jTuUSP;xD0w{iX|2^8=Z0j{vErO z__lv&d3)h)nXwT2UFUYi@UF&AlXWxVAWaBGR#e(z+4)a2LCYfkDLJ7HzVXJX&7l+q zE&5|XLDeJFOUou(>)@R<87LoFV_-vLHF zX?r}?N~$}@cSqcx{QlO*qa#beIa(=1iqCDh@eB0pE8uR&G{Nvux!pxrP*kfVyM4vf z)n|Bnf&Z-Dww})rQOE*L0OO(98q>^Jh=b?|B{7XDsA<9AY| zPz6N`xxa*D=#!{ti5q3XLyXYcU2LS#F)&`XA01~iF0`_L3f%2YBnub_CiM2hozpG< zre(c76rw$B)6S|k4l*PV5Q~E5c>jL$qDnbmbI+bboqRH^?`J{5r=GpOYxA@QP$1md z^+D!f`YRl2g?_>4qm}DKrl7--vmI14sbH~wk$1dVC{0TQ#DqKbwwUGpL+2lJvz2`m z|KPWc4o|8A18==RIDvUiPHG%FR3+c^cqvRWmA_r!k94f~(8;oDu)L0szxee|`j9Gd zP^=IYhNjkYvcZC*Xd6lbRdz}XB<)m?bRGHy>5fQ00 zcIyChrw~ik)26^X&a!7$YN4j3{Rrc5>Ch|bwY{mVeAo}uik!+ZP+LE6F}&ezG4&*d zkMRzh#RQLk3Ly1vbEKShK&6j4Mbj|CN4Q}vX~t68cD8(I$WKa1AD5kejevB9q;X2t z;Y;Tu4()o|kwq1;OL8e#i%UPAET#16T2~+S>5uw~S=@JPXpVcFf6NAww?Cz`WNXqC zs}J2^Q*~;*y_?0&T;_ST(*wmqYzR=7~6 z@^&@$hf=88RI~EMW^aS=QiBZ8nFFCjqw;-{Wn@%PkauU4`*CL`Gq3WZ1*cQE4%x+* zlw))IE*d>Nw%=v}5}JG2X!VS<$L@5JD797KK7uwGl`do+#HQu>b$jTN67K#B$8#l* z;NkH(z-ReQzALdS2SwmT&X4ja94r5L*nFE3dj=}z2)Jx=)WSrN&^{ZVph+@z*BTGl z)X-c46_E2Jq9MpUe2q5!S)Ro<&x%*p*8iB&g}57enN~$|=k9`)7L!J)>g{Z(u|&9l zWV1{PvPVLhdb!}sP6xBqC)LapAtZa$>HqdRHs%P zDNls&_v^2xNk18s(Or$(@q*?fF-%eMHyN0=Wae6#%;`hr%=-!#qQc3=Xvn?-&oXbY$G{4C$E z)#@oR5&j7Qi0$QBHV+ef(eJPAV03)&R2wM~5_W%Xy6mrSw*yx`oM+nXKAiLU;5kdt z896-NbwC;v5hIeI(rLlxY0BdF_$Jf$knM7`oF_7-6cM04|j>VsxV!QhXo?+QY z1g(eN)M3#`gk0~(PrvQ-lQB>@Y(cgsI0z4zzj6AE%b!qcKU^c~6x>Lkz))U>oHaGM zVaA*IRljImS?N(kkHt2HgfB?fhK|rS4**5T=ND>k*OBEZS^69p1&Katsoo~da;jWnUH^y7d zm3Mj_Oa2y`zq>c^kreD&zB^f^=B8EVvoT6BSIqdB`WEh>SEHVS0{IR0*nTQ6JpS_F z^*BMe@D};0a;eGd>W{c#bal6h5W&OH*BtSoxY`3|I*k_OH{7ablL6O79TwLq zRh~TQm&K-`8p7c;+T6moh;Nw_sH9JoU#54Y(1z}z19kRZXl>SbJuj9Xn3d973$1+s z+|nH*cH=UrIQo@TNR>C`_Qx4rnkz^>gV#Zwr}rEBCIrJHwOXk~e(7ps{Nd6+dO26% zGjoUUzW3Twq_=(xXza-*nHW0`m$|JY{mN$gKqBvJ5oVEz%~BOkqipYokyBf9i zHgD%qbAv&(Ee6d}zrTU61LWNzhB-NlmH#sS9xN*9E!{lMsj~^3j=H<^L*#xjKi9$l zC?dCl!b4d&{G-ifcdwLj);*xPE8{5t(2U6xx@T0Oa6scq4FW~wkp%WLP-sGVz`HBP zo;<$mgIcOjm`wM{UHeJG_oJAo>+G~7UT{D-T(Ukvs!Z$U+^+{)mfGcUlJN7{R$dV5 zN#wHhDerBJ2Cw(5=gj!)V!kvpZ3FG=r@_sDT-k*?Qf+NrGKzKA=GK!USZTUq2aP9) znbT2=)A3x{#JgRFS>9cATfC}9*O+J{;f~xDuF5Py_coQ8eOScBJm@N$>mU-ZC)e{O zDV-?(dGvoo=I~>iagr@+R21yV=|9?!*e_+n_q{eR!0+cmy~p`C{pDeLgLt#g=ta!w za*U|6RPSzSenbu)ehq%J8GY5 z%Tt^byYn??!5a$G>2%n$zj|hka&g+h(GLad3KsD=>J1=9c zX2#PZln-Q`cr;`zM4Se#Ll-h(S+|Y|g6<+^@D{nQP=Z85)>>;lCOS$w1Ar-aKAX?8 zt-oqTBVyJrj=2+KZfoOhv~ho&_u6I6xHC}xPL|oIwBXYIjP~R{)#ekOKBdDbx-h>6 zFNJa%jos?JlM432fYojJr^kOE0-H8^smi!k12xAAt>jEOMlO%@FxJ9tp~cLhe|EgD zM;KsbW{SM$YHou;^9K_K?-Em5UDMCGH$*=l12l`k5;gp2wt!D`cDnDHUaLH+E)VhL zoV`2|xb;U^D8gQNw3EKm*xu*(J8s4aQ&B#>{&$chW(f?p<4ZooV}5;aTL*u>+A9yXv1!?1%BvsG1foXYN&_D!1J*_!u_Erh2McuR>@chQsY%r zL7_oQ@!6ndH`qPAUdjl*`}cWACaXt>s#ySF1@YTA(?m}se5^C^{^jbpxSL$Nd0-+( z5!UKzo2MCSA3jVds3@&q;K>QzPY?VM8S-a^v+{h7>oQD!kkji4Ei788i$>~@yVdx> zHhh1AE zf@BvjHpm4)B?n_kLbTi;PDir5U&T0UtE`ATy7zQ2WpWS%UALx@4kofgDV0PNWliH$ zk7*p*_?tdN?!ZN+F&Q*V9-@*vUm;rr@)H}NDy8$=+>e)E<=e2ECow*tBS~GOw!T!G zw`*5pG=rCNru{NjL8m!)6f0(zmkv^>#1CfExB#M9XHp^=8!nl2!A*rb{;W{c4+;jd z^ZmLQ&G|;FUWqIR`($h-$UwAaZf=zcPIV4x`C3_CX8K+HRI%j14hHPQ}Y z8wI@n1chM2oI^Dc8s1^bkc3z_?BPCk2F(gKkGD8db9pg74G=FBDX(Y5S3}|Y-KTnn zVlbI&BE3GGjVaauAVm{!S_D1N6qP7fg9BwXegJlu#j~Jkze!(zpKcoySwK+GAPFUw z`_(=Q8q!LK^G?p|Gk=!*%@NTSZ((pOjJcCnE z_OB!YoE8eQdm9Ux0>b%nL29pe}6WWz(C zykg0?|6aWL85LrmSRfIc(wWBJ-B*!j=;0x9x6A%n0RanJkomFYgj>O>|q#e)2^7x?can>wDQm zp4`<7{1;-MUlR^v$6rA8=s<5YQTdSFzcfkx!B?}fcZv@m#S}*3_i=w-$WiX6`_X8$ z*(uJ#Zr-O^ zg1YxR2*XAVYj%v%=*7p%d{h!k#A7$v0}dQ?buC%HDwg$U2~V)wuuDA&`2Ev(Sz0>x z`UXPU z`o3o{{VoE< zaztL$E%%=nyEx|i{ivj<5fTatdz-=^ott-DUTlVsPLNk!t5Bi7ch5)NyN*gvWk|+G ziJD3TG`4A4Og;|SH>RgdqTGVEYV_^ylp)_YAdIK`G_%gaSqH%ZNw@s z6Vv_I+o>d8Yx9i(#PNt6+L6grUY(yRoy-R{nT|h!w!2oTqxI!vc$Wn?vB1RE$||@| z$Hcx=Y6&}DyKXLxI4o?{Ua)REJ^cPB?@uZf5`D_vVXL@U_}IJ_)iqLou~^xjiZ%Q` z!$!WZZSPy)EjMX=b8@UR`zkLz`c}8|sT(PmgY5Hb7LE2RHad;pZzmjipNkYXn7@k% z5BJI5F{U+n^k}>M-JC9#+WT06M;wL%wxtBw+OLi#SH4_?SL|$Ug%Z((V6c5g?|3kf z^F#Z@o?S%CzgcrAU0XBXuU&hWU|U8@5)NIt zrYbg_!{pcB8BC?0cy?a%{4O7PSW>7BSjki6j#lp5V%)9oRZmhUGfi0kS{Ya}TVfZD z)Kh8PIsITA$}#(COYJ<^_#>X`W8CWd2_4<&;eA|poYoDG2;vk&LifL9tm*dZh^s8+ zbfzwqg@%TvNUx|Un7-9+IqyBmDV2-UgxZv0Io!!bU0Tc~h2fER2s=ErQHOikY#O?Oz_QlGCB$e!{W`UBdL;7*5@R}G)^C$cUoC74I)< zLcIq7m+g#Xw#4rpiHilT(l8Zhej(@lqN*3Q5dFUW@auo zEKBh?|Iy&G{2s>Mdii!hPVRGvEYr;o5n6>gKV`5IF|8^3Cs<>$u?00;uG9TyZ$iD- zpmOU|izD=1dkHNqKW}gUU$akPBoL+Yd;WYv_VV}g>goxymly>59Vvb3-28Xzdd3WY z8Sa9>DVjt}36ATBY32A`svbm?m_q8~u0;C8;GG{0vB&ODo-_ts{!L<3 zgt_@=iy%z?3qn72V(raOe@=dY_Tt5h7yS3$;{>zw@!?^Y8J6kq&NQwKzDgV+a8z=v zry|aqcHf;rNHL=?EiWIf^gjjZCXjYyT!xrvU}LiX>lgXISNNDuK(iYS#DA*Idb><( zJxx&(hXSGjn+P>paGy}x#Kghrm<3aj2K!;A4ck{ie;b9iH#g6>r-airJi*gLzgVL; zxksV&EG#ne$Mp0!n8>3%T4Q}!(O&sWzBQ$GZP8uhVin%)vZ?xRF5=5vkGBamD)(+C^jEs!FW0>xIA?5-j*&p9t z0(LP@cqRHcn?~Gwe*x|$+^tj(t+#LA_GR$K_9fQW-$DnmzwPe7=TflEU(1NGQ4B9( zpPA1GsyAX9Z@y3jB$y~(efas=)%k#5In7Nj^D;%x;e+3qRJYB|(LDvnhZ65VpNvGLC8^4l;;UNFoH*~U4I4c37)0k^rH?k+W z5C-ff-kDLuY#hk#Jn+npe*T=TvBQ2N2woPQFb;VE?h9m|Mb@1$oTJBk^Yb$^ziS6& z6dlx>nnYptRhCZ1Kk6ou*s3d@T8v4n-g%bFW@cta;5NuI%gedZ%|H2XtgmzY=joVP zp2h|<4aCvQntQQVTz@e}j0Y;(O4Hg0AaABHZ*ZpF6b8i~NQ~#|os3~1SUXn=Vb@1u zw*>0rC(B*&6oLu}8QiNkiBWHYDQnQ*-)_hWiy&T%yYC;BjXGR?BiAGAcFoCY3kFKe zKe*=G_NLHEc*xD1uROO9_zNa8`T6-mn2eX0|L%=1IfV>TO@k7{LG{&!qJo01+s1HV zH_WAgYFM}QiL2}BdSN1*$lw`$(6PgN+|LQK0O}pn_LIiPYG^D!Wfbmi zTR8ST6BEp@!}c7@pl8N#uw85m*LaAQ`3TgmcKsR3JIcAA-T$0>=Bvw~d?-xeV_*^{ z{3UafR%#WpXjTWNgSPIQjdgaefF9UovT89=_AiGBEp?u)VT-RP&RYL-{9x68Dt^89 z4>OZM7wnbhoI;n%)-QhUQQ@`qop%RqWOugd7Z}!*k2?wr*Or!Y6#LmD39jdZs$#Y^ zs8ufbnu5Cuu5Xjc6gG+=Nq1QrKNvu7hU7YIX=@}m#t#^*I ze|U(6gM%8?SsyNZB@+-6^A(=R<@pxB60MZ->_^XJ6D|kLuV;u4f3|A}(~1Kw|NN5Z zr};&sM47#vr=7y3iZE9n;dWP|g;ppY>UY5`|-qX>$dH zo+%YOEdAZxufaJ@15f_u4bpTSnX~IwHf!Hs@U6%R+Da|#jrq;Hhs z^78VYn`6oCthdr4`_ed>F~+F+X2FffhW4L18=;-c8;}+!<92j*_VN`C=13anE8MtF z7Zen{rC6T3Z>*UOs$6hfSglYhlCNOpN(Dv5^to~Hp{scN zHinG%97dm~o=Y&1t@4^w<#{DyX8!4nCC{WtFNV>t{y=O&Y_<$y%Q>)@>%rb=10k@W zN`(&{3^+gLWb^Xy@NjcW8OE!P3=f~0p5~g!$HK(ihY_VK-zk@Gz~qY_8;roT zXsMA;R9dKxp|a&@iB@=tq$fUpB1rY2TAt7&{qkQf-~^6y=9R3gdRGt7)X@!2Hb%iQ z?`XJCHHxN(6bzGwhljzlMlm-!E^Y=?&(4Hd0teB=?79}4kk7!HiO{f0gd``1lsn=X z>L`(svP>tUN!FOmdx zm*muu5Cz0s<~k_G)Cq`^Xb5l!)IP#oT9G<&TqR7^MdH!+J?Bqk(S@)QS9ZTqC`K*w zO8dKa?>;_ zV2zIc#;77j2rotoj@pGBn$viJGPYEiriQvPPV{ZI3<=LohOYZfUNDm`MNU@%Nn-v} z0AV?S-+r?<7S8gW{?RM}Z1{X~JuK{hFJ4wV@Chd{4o=WXmZqxvD%l2EZLWshCvtdN z2crs3gHcQa@Iob%B{At5%UjCHBzM1tA(1dBU~fQj3B)7q~zSef!2IC3W9kLvebvCw3-3H+4I8i44J>{`zyAhOI^= zRC-gLAqpolk#|#@WBYVKC6)9QHEdHk4>ONSwgiCsr>-4dal>;I0_=&4gERIp18^lw z#FHRUPwqqPU4l1!hsOco4Gm(zsu8O{3<3)Tsi~2e*^5usQ;;3ORTTJ-ah*G_cr+IfAxY zzzMvny^9O~>*&P9ZnCGuxgk-<&$?Q+^-a40j1u0~J&?OS zJ3A{4_K#&RrCXgQ-T}D9)>0jWM)8A?DLohYSu{p=n91RAmhwHuEGvYso{7^h<-O0a z6bXRRkpV(~X}Kki5xNb5G91jj}fpDd%VJ_ZO0w*h%KKj9d>>;|GDcjsFZ@3Z(J+MVRtzMNZ1J?OY@vIE&DGb@YL zOpwG85P#Tu2sy_EV^wXf`}ZV#QkZFiQ7P|n@P9bj-~W6rjzC!Hm69{kGTtZUGK^{| zC#0m`G5A|!=;~Va_GTejd?kOtgD>XnUiWlt(+t%I)z}JbZ=j<)S|q3hKak!eDk&+c zkq9d<_u+$=Q(a!UQl|siN>B?_0oLhx0Z=QW5c<|h;fmO0F z>?5%#ZDq2?vAN)*zhCBveM0CzsY5g?8RVI?U>-DMVPb}3T2z^)knr9%6c>z4Igf&& zl#C*2rr=1vD)57V&@Sar`Me9yg_(&N(pnm4oS2eQM6vsIpbXI=65Cy`#U<3su(pbf z5(GV?xRGvZ@NY=%^wXkFuXdQ%LEUPGhB(_sFZg6LLfb@KxfSA8)Ly3r3{i` zF)t$25;D5GQVtLA>Vo7h1O|u)1If&h48FDc>ij(01jByXuN*ZuiC0+vX=!mj*ipKKJJq=BI(r9qXYJS!3aQa+7{O&3m%=f!T*-hL8;FYlCvbA3cEF)?9FgFfd1haP+J z0Vi(~-1-zD*B<#4`6|Nf(om}di5-sD>n!~p9YqhmW?!7_K=0HTDy9`R;9X>8TcNgO zyk=@@Y9*IN7yKIj4In}!K0Ta8*{(-(&6FEGy}cU{GokQ*o(c!fABv&A`!I~Ic(c@H z{u5_~ikeyvV4su(5~WI3TwaP$d>DVJ$616}PkCCL0Ap?1y+wMF@V$;7o@#spFsxUd ztcQV6CG!d^!3Z&2#lpkONJh6KF3L9iRMC|{OHEJTYSkVAu1s;1!sx^{wg4k8&(`R1 zU~~`(Lv*B+De(WG6y1tz_S(s2(6|~HaZr{TsY*nsx0>Yy9gU?1x~8N$dh1MhZQ*x% z++ETH40O=YI4a;Sv4X|guV24ZquJQlR%7`;ea`Goq`x>nhdSu$B0CQc4jRmfa+p*t zfUSqoyZci`>Zy=Z8G{!nQT{9JIgoGRZE3yydjWHc(KrVO2kH|1xn{cWpDuUd`v2Cw zIq*V^e|IVM*?b$+j8@_t9QJSlr`L@t&CMv%q?EKSdei|PV*1xdL`Je}-QrxKS*2$h zv8qZcoE^Oqg0YEkYnHOtD#=O9qXZuwJt*zLinY?DT7mB?#A+d-LoojOpqzt&Hd!B< zWB{-Ss)^w*U*zqrqfgu7F`v{{3n@*rm?8hRIYgfkW)(ajrkQi=eU@*;YPZ{raNfUW1JL zDkQ&D9hs?*p4^|7C7$la)zzYCpj7t$$RwYnaHOl8+?DD2$yolqz@x8XQ16jahPUaB zBwz^W>%KQOQ*4a=P>xghUfB28-b%Mmhcn0^I;tk>M2xpwL@CW29TTBWoIvwE=q3Q} znE3c{JcPpNYj>cOQncYB{xT{?^em!?5g57T*t7J8Ar-m`=470y{X<2=&$q0=JYF3Dng1RE?eB0%WB{I1jxC4<1yS z-}d)!-j0{kfpP_Kh@u_`6Vo+*`8B;bDZi6#7}8AY03d$7Wj@n2Dr$~mN1*+sqS8jn zR8puR!-amoYnvW;4KuiObaYrwf-%9yHRTs+YzYYob041A^k?v<%7_I0^-uGKLmnQe zdzsvVZTU6XE9q@gPS|P~y%e?z(=DrO=Dd-0f{O%%Emu)-MG$6Qfk;w$4+DQq+q@F( zH!*-XLHb+*-OX+ER&6Cj6xeX{dPh?00StQoq+>l6)*=#c;jrmWa7YZ=U%2W{-5eY!U=Fb6vz83g5^+ORRLhH3?3RA{_JAVOPakF5;TN`R4qh$nqG9`R6$1;j} zFh>*liifTr*?bJ_g*D2C0@TeA&U7TSfD;7cy=N4oK*FFL_%7~Mf@>;RX^035EB3Mi zoB%IW3DxJU)Sr91Sr7ZVv(;u8Zt#-VBq9V>3wRsv)KuUEB5mNU`(7% zpx}P~E;rH=IJN#*eS+lV-UP^!ehNK$e1ACkQ$lOb&9C-CoA0%8!T-zpON5iT<9$GV|YmYA4``mE6n<@g*`V!e-$z>8nRNgTfK6^kY} z`gq`e?#s*0R4^6}-_|m<+Phs>={R?J5x#@c@i)s~Xi7!+z+E3N1~0(3k{jW_7q~){ z{1?TFhqTtie|nZ{@&30q8xt*nRMd~H;pD*;J@hBtjDQg|%8s7dcH6|k1*3++0Rc~L zT^aUENT(_AuxVyw^0hfW>$@eW{%Ei#tPn?Fji6-^YF7=6E^4NN2BxXqq7^f{0XT)b zb1kvKFqnM{JR#>NmG*?;E5$AIa}es0sqc~V5JAj-imal69QBi?;1@B}Q!v~7G&4@H zCZaIuX_jl$9ICZX`GpFPaF{yp@_EI#wBH$QzJqY*0K@*rTERE6v%4GRGy@dX;Vxtd z?N>56tM`G0Yk`4o0Va6!z#TheE;lnXGc4@7e3tvqCkiz|ZLYhQvdMOVzYn^3%w!1l zHc^*9xBH?*XLgxAK1a&tE% zgJS(M@XM}Oh0}lvo|9t$t-qY0Y(8EaKJ2<%?})0a9~yvN**>J0zCy5+gab#u7demL z^~Z}rv{+TO;THWkn}O_<s7@ztCZjc{HBOXkKrPf_uIpL5~DzoWf^t}-J?F0jP zt6*2aG!8JrZ75IPz6?$^9q%4$?uyThir5f*T|KE02gViwq@<+b$4h&Am!0GmCU@`l z9c1Rg)ckbpEU<3Cw4tIU*NA$=XR(=6`}t2T%^DGZt2q4w0+d%RFY-KdC7my%#}{0m5fNiyC$l#WU~tXeo3hiz6+FNneH0DG3;$?D}ZDQR`(ZUJXy8>Lb&hv@-{!#6tX+v8)BD@sMijiLbluBvs&xPKlUK?@rLBV08cNG0tHhJ4z z_euEp_)h;O-^7?#Gm-fe?va}owT=sb*S9~3V`y4sz#eAkKR#%N2M%!)90a1Vy6PN8 zbqCKb_F9Mgj*pMu-fuh%yGb1O!1yDP2)$Gzmh%q{R^10swP*tedSK-(7lj2p+p#&&mgm6O~6K7*r`K@ZR(Fru75zfk<$ zs%#Z_(&GDbD7WiM3_37298?UQjKA|pl013=c1T<(Y4wcNoJGemE^uphC?Ss!Hn7xfJi%7g6hRUNK=HDpV+ZL4nRebF0+rY0UGLZR!1x4Wdneq#Gfij6l_kd0Kxv#06_ke^Uv z_8*+8+3>4p3E2S|&+#kHHuEjX29lD6##F`{N&>OCHHei-@(K4OJvT{+@b0>8Xo*i+ z$N-a%lh>H|1Bz&C48+|vr|)m&zsbKK(mE<~x57UM^NbKt){`R9{Wj**IcV)XR%Gs;?5`su;%YzbF1XXySajUd{x zURJVuKF_l*5jI6d(?WW!|lesa{8`T47{3U~^$o){^%iO6MA?1@mk&u0hk8`_X3z0gQOy zF;Cp?W=--KI$OE}(J%zvO_XJ1?#toik^12%@{O>Z zO28!cRDDex%Xt#Rko*DH6=*>fvVw45{S9wb4_7I;bhxY7zoQBc*s*t7(i4#z=IS)d zN#&3;vP@%r{amB?e6zd~NQ)3G(vz?KF{)h*pBYLmgHcS$1;y8Sh#L9Q@ggCjSv9Mf znrrJHQdn2v=1iZHa&MF#%_=#2QWh>ANF zedxT*R+9y9-8H;xhEqgY*iMLt`0G>oySib=hldKMX;1If*#|e=L=e2R!CDH)sBxSG8QgnV80*ckPmvLQXC4cs&{NybP}m%S#&_zB^tVW}W$Q zG^wOFRTbVO-|Pmg`jmUomT)DrZj5t<7TlAbF-ETMKuy=_?6Q5S@7Mgti$ry=e0W$2WWOgZ-}jE)%dDLXwR+65JAV zo|!B+=BH$?Z|ZD+P^n)WLz9tpe-Q1OUi<&?BG>-TN|VpI+!GdhFZ5;7(K|(tEwmi99(!{E(0_xAtgWrRee&D;PyRSq!kj?YvGQI6-q#Me z-h9Z%P@F1#QT3|Gzd2(`y3od zHnd|`f$0YqEtlDk#X;OyjO@TUg0&$A`uVRti#LXxB)CvO#;05xS&kxP)=iKrY<@IV zgNKJ_pA1fCA9eE;giIW}`=L_gMuVfR@{V{<%vOPgrdbl`LFJ4LX21&DNMx9lMWdVD zBa;r06fW#S0FPSnW=@+D#FgoCqu`|VyPD>FWt7E9yhpTj^E3j()~rqMTf3h2x8BW{ z?pmX~@(<2gI4TdIAb^CT;5BSz(kJNPb_xx{Lyr4wG)%tt0CCPyA>&0KPlM^aC3H+n z-xYbV+{v!uHY?W8=Lz)|cAgQhzboxHRthm~-TG70PVcV@BEozMmWj1-Q}v3j@8 zez+r=7}qb(PfZQ;`*qS3s570!%!Mr>eJHrI&^fPgUWd2lolyl9sGIgH-sVdiL*YXCT6QZJ$#@Sc&MCwVJa$%W< zpvyl`KW@I%_~wK5&*YaG=efJeZ=#&-aallV`AC)<~Hpp!J z_rhL6-+Ei~4xf9l7e(L?eMCLEY-*-V@hF8rlgvZ$JOl+TZM^h)$okHg-5peRp?&r- z#!W%Z*UEfb+~R{PV|GfZa-K+7CL_Olw2>qh?c~!rz*kTqFEr{lWkOd1Tws8~7_PLC zg+^LhKnfjU+9J|@sUsTJI4o_{tS|VHM1e1xm%#Rc=UzH=*9#&-KkyAkx4p(5SN8u+ z@W58~`e|KZOjKUfI#cUD&>oz!4r(&#Tz68|v=;f62sFIZv`Sq|epzZGX)s>+XYKJy znBEKwl#s^lv5F&)z(lmtM=7_NB1E&6(&ay9s7zww;^Klm;``B~fv2P#M{#b+H2LaA zAyMm(b$b;HaBoP;6Vg3;{P?Hr=PN){i*0+V2qB>KUhYmD#ybLkG$Jrob{Z?ydut+? zk07D@dJFLff{qik1YdrQn_WD-#-K1-bg}XJRTVY0-}S;7Z+I3JPU+ zNS)l=+#N+=nz)Q61&)&2LKAbURSu(U&A*|0g{{>j40I8pTJFih)>bTX6v)n;cNtM5 zH#)Cwn<>2iav6Sd$26V0eW{i!5iKoIMOk?f8!Vt2xQJWXlOF4~f4M@WO=iZW+LpKt zjp)p}+|V~dpXkFF8IaHjXFfVW_Zd)9WV2c^nz~UKmrS1ys`qss`R~C7kv;59JnT%& zHdPu4iHU{b@YZJ$f zFna$;Utiz8rc8YwCQYLnTlMY1Pi`wQQo$&jhRIi90)+u@rKUSLFu>=hcl7zeZ6NXQ z);{TIFnmX z(bhJ{zC;B;`831>DyCl9JU8R_^65hbgIRa??tm4m!q@fptENu{b(O*Y?Vu&!p!dtu z5czjaT#I7QZ$L!Z0zN9qRn%o}28h)aH+le5ldVBP(DTv16jY3mr^apw`t#EmC^o$t zAH5F>q!Q?XF&O*{(HBfE?f9g^kcBk6m0|ST*$TWU#xnEvNqiG*$`}hlUY#fM65E}w z#}>qy->|2gsJKVoPJwE@Om*|M#13V=Njj`%sg`5Md`^t+Lg!G8Se}i%g;0S|2dJl33Na0#B}ACrK6wod&ERr3V+x&+LqdXz za2;-a6gUb=po%FbkE9GBK6 zBZlO{O7awFb?l1@QjUN`f$juTIm3QfSXfW*kQy}9A3>p!qz>hSUU@GdB{2R@)=Ubw z4JyP`0L_L&ZW;|RDR5|@*04l)dtIliivulz#lweduo*1RJS?sQo2&Jn%Eg~xDN}<^~eh)2W+g%(Xw%IX;c`!&Y~3f3V)jW&Ks6{1!|HU zwc?H_n{NA$L7luQy~Vema~YPpp6*)>=)gWR%~oPP|lTRP2Ztix6{uyB8gFk-he6k zuOR_9>-evrTu(Kio515Dt1j5%gkb*}w!E+` zUK!0Baf6g?=m`j(l$!bi9xY0h;1yl+(%S%Dd-(VJc+4XZuoNT68rd@GI5E*UC>e95 zeGad4s@W$bghH=)WAu-R-NVD4cC5WWw|b!R1@;4IP{TuAb7?!02qKE7*$fl z0Nsg=B`#6SQhbkYYNE)CUz&p$HX^CvG4C)aaXy6;A#L4(8S(NdEjG|_(BeR}dr0X5 zGQUogX7t9mGGX#r9}W>7({&BLvcb~XUsNL~r606uU03=@NgVO$q$WQF_!S%UgK>rB zyi|rMtT-EcNbm~wum!8?CBcQMop?8<S#9B)*|Z+z-k`E zBbz(~Pi~tdNE%?K(}Xqurd~}^sG!L>2p)fJP3WkLdt+&q+;2YyZp$PnjK(lYfQ}L4 z2L0PBpe2DHJ<*3%=4X10WtI5>ZooXaC3w{>LP@s zBs|RNZ{OYpUH%0*M0`R5o#-P91xzlf9%_L2ZD~DH9F$O!;o&>br+WQ9J%BDuA4-$h z7mP~yU}-4TgL4J;N!!P6kIA1wU*c7C>07* zAD$s5J`*=IIadKg6|3sC-vpt_O83l+6qx%?sk|->sj3p7R3-t7MNGrKbU9D3QFO2n z6)6Ml8w;z?6K6XdM@cnfgse1^?lBq!t4^y5M8 zCk#y@!eklHt7wTJD{I}#f17aIAkDx1OI%h1nSu^7*wa@4w1z~*X=hkiAktxXlJs?s zhqFjv;Wn;I+{6HjS||DjtLN9oi4lldxT=+m--i7b4NGX3(< zJ=p=%d>U)b&*RjH{&c*?OM&(8Io1ImUyyQB(bLfplZ2Ip?0rD;m~r)WvoB0*rK>Nc zOB{(gS~@zMA@@8L40?Uu2PFDv|1i;_?EX1;>jN*sHm@)3;$C#m{=ci_xK%o0f{-}1 z_!!e+ZCv42G=a_ScOiAFe42ziM%CT;9S6H&PxStw&|qqJiT7|`6CYXQBQ2hRI}b`a z{hr2RC#H>Nu2u;NHhh)~TXV0aK|s%gBX++(9PXb!2{qYnz-eYy{jCW141T8vPQ=ti zREG=s5;9limF=}BqEGyyWs|)Hewd~OxZUB1+jpHz3RjUbFdfLm(H+bn|;#4GuH+j%!Y=Ubap##$OJL!tTHn7Nl4vC`G@MK zmHQ()Qa-G;4=1iL{GB@$*i9wqm+PD4u47j=N z7$3YxyrtP*DU*aGe+4#IDk&9_yHmfQQO1gtJrb#|i^9gn_wp%~BB3Xxb^WRGNH?i( zOhkrhM876XDq>fg<(^L6uVbFAak z{_+{Y^9voZ$9J-_!#;j&beXwZbZ?Erg?_KSyJKr>1^Mo7qxUO1ukkmQ;yORykog?J zD~p*=rle#FJ$<;qkq23_79U=(#r}y5)41cd|Lp!sf8PNDi`*8)`asaUrP^w{ zQGrd`@$B)zl3iDvW*pC59&BXLEmQeOqR{g`tMuqMuCGtr#ErwTvh@^<-15fu6K_Bp z$@<^ZtShZ^7KZ7iVd}KqBw})Ma=;KtNqI&`x_5oq%M7`QiHQvi41|Rv{88tcIW0kj zu0{)Xs@CGRZ0%DfOn`4Y>a{%q{1AJ z`eNNjuImBM1`#ql{~Vy_0vy6<=-L(c@B^n&IXO;|iWVQ*dzb~k&}4T|zt_bWpVV$M zzW(QnjwB?3vM=QQ8aJCUS!&2(0@YK0Yk4 zEV%10{ha$0aL@Kj1S{&+^{wLQ_Ldn{Of$v<|L|pgz7>84lawh(U;>aFE%Y3NQEqD> z5?yL}xQ}}qbqN4g8IUHXrlb@0Q#C8EUj>4_HREj#$NF;+3GaZG9};|t&MSZz3CWlW zuy4V5^$*ni5cdh8HxKy>`T_;llSksfVE85ULtZ)oDUylmeYSho!A`1aAM`ueQdDGQ zZ0vrpzKez))~24Vy#mALO4!h(aff|sZ3KL26T)9g`M5z^fb@61`oaa|8Yz9v=auXa z_F#1oWN+jwtf>hECdus-5?|)&ux$pgag?7y z?+6m{ROZwGz8zx{V&c#>1rMm9uoT+ht7h<+{<|5kHHtw>_-cSRlh6u%kzgyL2>`qb zOKn4gEa);p*b?0fo0Q@zL|{7-kWV+iUiCJC!~`PnaARJ9%U31Hd)2i4j21zY|Qg%{~yNCtlP*o2cEprK~}-R5&= zrybN-u=zyEfUaTG$9EIRDd{R#R>$pya%Fw;Cb8v`mQv=jZ=n2BFIF;s;5&i6H z5P);YTb_dy2&kuGz;3`-0Rc!&^#?lKUABEhx~3qgkYwY#_nrq`0R0Z_OhI1oT@3Uj zvcU5V)RqEZOxZ8)_n1jk60(wCzmm_1v3QHg5JX{iZqoUHlA$5~`7?+$KrfqgMNqJ$ z(dURQBlMu$s6xRI=utu1T>}qq?Tig?cMD}B)PX5J|-7O{ar70?n4aj0S`e5L>) z9UQp$NavXk!KHbFzOZUh9*;p3Q%e*Z3roX+)gVhuNQmgDAS31MXGbQm^p-b!M1gu$ zjjLH z7zXa+CBXVl8Eh^~=tniWE{6PzqP}7f5b``fJ@`t+LR0q*4Gj&9f5S?af`34-2q7-s z(+hGYmMsbnRn2UXn|fCn*Fy1SIdFcBViS*{#>CGgM6;h?Zj_Re8d|@E!+dP2 zqlAZemaV=yn4aY`TAypzmj>k}{ULr0ZjA-J<`6?rLqBP`0xk*)3K06SEc%Ys7!G|r zjnGrAOia}^*^cKhOP%shiUWl7m6XD~pJv^~BzDgiBIWvuZOD%4*{l{tdLXzY9Xg0F z!+ZLBftRccll}sNjCCv8NqW>8bmt9 zvunYs4-sVpQC^n-*agVZ_ucQ68}TY#Be{c2HdE(p{mQAn(l}z9^s_?+%=&xa@4|W` z!SM!$I3TjU2JyAMw$&L#pH}y^1ff4rirU>!QH6u2C*D?g$j6kShA%>lTH}1taddnv z^>p{8H+fbzcI^E&o=7%i^IuDwD^CT9@UyaGfAyk-7}9ufyyZH?ag4J}2& zu(UwQ<1qalxC8*?E3@!&!t{(@voxh2sl(kG+vs?*ZBJOhcK|#raw^(;d>Vi8;vp27 zhcmZ7H@wtxn|t;59Mls(gpl7v>rRLJEZ#q&9Jz6V3^*QEJv~!uRLNzU2d?C7G%6Ct zO5j~m0QY#215dJ)zPPBXrEZ|xF{gVT-UKfyuh~bD7)EttbjIryS2f?7oey2Y?J51K zbO;MUOXT!0rz)Xx%``z&RX_;?+|K!*U-QHEnX*AL&d>r(^I+`J!&ELMv9cSI(EF$| zM2zM1_wQe@xuzAgR)W4}^_!&}n+LoDkN)D$*65cn@RODeZM;E`4s>)bY^l3DSo$Mx#jMU=4?&3~ z-h+mDvzJaSbtDc1)lWS1_LH|-^)vk}vTlUsA8>c3h zqVi8^bNQG*YL~NC%mY^Yd87Et=cMX! zP#KyTO0)HI^%sEzBWxX(;MXhYfZt2rJRk@WbMve*u$~0fs1uW4|7Uh$g9K_ZyhiNHOkX}F znOR`_VY@8H1c9U~%q=Ft&f>aL(32uu=A_E?GE)V$FR5Iv;>pH2e7T6 zV3m{KI~@HSc=OcV=~APLY%g|t?=6_>90BYJ7_Z-^T7a1>H6mdU zvEXIMhTsbHA*y0An1Aj3n5ec9SnU&m_(xTQV7N~S zo2Y_&O3ha+v&1CJhcab>{N+rplL0gTs^!FA2$P`7iCzThjPq2Dvbl*Ur_wE{I^c4O z2*!2)WAnF7t= zdmZ>JG-u8Kj!#&qF9{j!yVoKKujA*43p@HXAH-bDEVP(j(8V+oLh;N#k|2saVJ$_c z2(1_6cKc@loYhZk=h0>V925V&C9n)}U69$DLS>0E`TG7|=<{f=AebnCS;yl0Seo51 zeYMXI%wqj~(4}-SrQU7%ZTXOt+J(lj5^}$t6-#%1jn#HMxjHrwEwvo*NdkQ$iAp1%^)eILoEyzCy5OuXX`SUqE*isR#qbM*wdVLeNEJ z=umovec$laOupuT(%b9zeQ&drAxSv(l{Q#tNpF&f?Wd$V(;&!2!?*Qtg$w*yD!Z^b zFs$gY`h+I$#3^8qF-c~0NI+|mdYIe6CC22Mxp+yMUyeaG;l_>dWMB~AnYY}vcho4Q zdJMVs;!fI#oT)k=WppWUq-x}_#rdAqpor*=Ts-VX-=AE6n^w9P;LTCW9W&*AR1Lb^ zUi>Sf8xSjW?G;#eKzh}8-X1@TXAqBGZ$ms%{VyT$qUT2+xV{29r^0!|xQ%faG zddzn4O;G*s&}EeU&w|H`^y4*B9=sBrl<{>24s`~4to9>C(IZ1|4PN8L2xFpi-Z>+1 zO1G*@ukNS!6Z_YEzs1F?*2=-()1IZflA7AvzpcTk75%<`DD0}8MqOsId0Nvsj}ojv z{g8sR%hUan^lB11x;+P_4;awAy}hIA?0?M#QzX?{r4f;o)IChgsQ`KtTPx6jP9avd zc(+rxSnu3O+d-vSi{lQX_itEn870!gD`AFQoK5bY=G+-5h(Wgk;z!N{>|TqN~(6om?))S!J{a~$T_<=$jU26-K~V-hgn_@ zw&~VAdX#I$HVaU+>WDz(DUtpQFE`v#Z7;GN_m%Yai>isBjwr&(gG3|oyDgJn_$$x2 z9W40Ria8%Ye*9z$$l#i1FNwZ7@2rhKWGs7|Uo=ZLaWu^{ty*2O<;KS;ZH(I2a)cIP zFfI7j{ZL0k7;peNBZeKSjcB>$@KEZ&ckdRL)btV4oFz^OhQb1fli)%A8djyx7l>_w z?t6>(0n=`ua8ydl>)SV5dY#__#Hl#;k%KyXRscEF#M3BKINQA3kQ06PkKJw(rsu~8 zB6v`}zyLQA5>om{Tq4*8fogJTFs?0p358N8qa9+D{E3`XU{@UFWKQBXWA`g*Khffc znhqL1t6fmya`W$oxiabSt4}BtK`(&o4z^ECOtWZO4?5(u5Cu~xU~EfcfrFOz6>&7m z$|7`;2Ti};PX&ZCFv${YWCahx8^i?!bbTgtCs4(?ZVhp^^)O^8kT>u&m;ek0nQt`L z6x4O$j0}^SSwh8dY~lR{{UJQ7Sn^^vn_6!_lgRT7JFM2kt8>s=;kBttuTRtzzI97E z83|C!$k*|EHK1XjHc&fX=U^#c-c4?i@M{(p7Et)T)_L^}RHcW=UQTHT4TA!Pqj6)4 z#a-wGRr{a0iOd@`u4-=TM5e2n#DKNFO#nP8fbPoJ_iP1y5@d3JL8>d$6a_*2Oaj#{ ztZ{bDGw2XI&wnzz!2BHdRou2G36OW;N%70!qQ|ha7}F2vjE>bdQA!beg2RYriYIXH zt64#S$cIm&baf4?Wzd|K(UfOM@KEXmYb-zo^9j^=fS6z|dBIFAfX{GW_kG(vPE?hM z9zo;#yv(yn+wZT?-;qC)7lTV(f2o>1jK#bBTeyz1Ec~;DsW7G(O`tpB(PgvuY3P2P z;8f#~8*mrF@$p|j2cr%9s;IQ}?0vd`X*e3(0K?#+q4{c?NVUaRXn`!FnRGF z?mNEBsoeXx)SV-NpReyhsSip%T?;5q_28d%)Ab*Wt$7P0;)RM&cqmj-rKcKoc~L7R zy-eAr))eX{p^gMV3C*SGE6)l>L62l0A)t9l3A+YS>yMkwSK6|tE3e&p2|Y3ELMwMG zs{AP>p($%>YXh5=6$jB$a@aOln41bfB>AvQ{U)7qcAG&Sj=0CVuI+1B8YnI)2~$8) zKYeU$Y(j6rINI=!&vWDJW)GYTM}NuLhxrb6av*+>u?J3^l`_(=u8+2HCGK1{bP-uA zKk^<~q5SIUcFZCv*zOb^8|&`rsoX!_*Qeok4JtLjS`NQ*(o>2Ith0c#QjnIXYhV+R zmWR18*ta87-1TH6yeD??K8(0SIS(E2-a^J2`bUaB0Xk`!Bc8cB5f4eS8G;{MKYaLH z`XXZFNXSl!(`wHVbnsIL)5g5Nfw9O1XrBKm6X`uMVu7O1dOw8j|TKoY0%rIU*aPW?xp7q{+^UCWBCLz5oT1T zn`|@)W(Rp2!!inc#eBJS_Kh@?5@=IGHX$e`Foez;gYX?Lt+TOLc-Vwfq}itDF5IC5 zm{BvmpPZ(y6GQ8%McHLZbv>;1yepMN=jxumPB9CaLuc@*ubmba8{&Q2N|tYbV@F}! zw>bl`cbVe-d1P$2f3c47RlG4^J8*tS!-Y(07aP|TtjvAqDle*rInKRzNOAq0kovI6cAyxIMshGVBiVwAwFeksF!Eh;^oRd?dBTWhv zR#z@>PBI8g^(3;sf>b!H?2bkY=2)fWle6c)d!_QU1-}0+tbGB;AEmDXKIYIFDzH(h z=b>=8IF9C&dN@;bO2;odL&KYvWSv|bfBIfZrbG{2e}*w=!Agv?R%291RM3+2zTU7H zyKH()Bs!kBVTS1L9jzJKxNH4p`1Lk)iND=kmds>|3{xp}`B+A1oP7rt_+Vm4 zj<1Wd35$0Aaiv78E}#NYylY9xi5Q_AYFP)p$GU@#cj%`bsuly_T(> zTHF2FhK`F!EMWt^azkqwA4;ac8M66?vfdv2%W}HymJmm|`0CK-h+{h}C?298DiY!*>-`UVZyi=;+wKi6 zL`6_Q2^9oXlrAah5Rh)xqC+~Q1d$FAP*Om;I~FCS(kLn2C@DxwNJ=y3^4;${bL?;L znPZMU^Ua<)hJT)i=K~}T20Pz<_7b_6q%c&GiM1u7)fQp{ztp{@b054M~(L% zFqbm=UfptfJEtRNTR+>#U1|58*V~|ok}+moL3F=gn3jd189iot^`$&@M$4aDKMAMb z{Mpe^S}_~T=#G-Ebp30{N(WI*?8>q z@R6nI!h#=LO@gLX2Vn&Jihz<+?_B)I;M~T~z_!Hi+JOhJ6qJvjx#mbZ(cD*Eo5^L3 zquc!MtSiksQz&_0x?Jyw6*C;Cq;z{c4r zL66`GWV9eV|Ikf5S4+$z!hV}vq**I)+OB}=R%K+SxcvMN>SyJ#BlYKle7A8E&Y=VX zUAgd*((Tpeoq?KCnFjEB@iNMl2^e=(&KF`a^3k_s?S$9Wd4gX*sz&o6<_?hN*k`)FOrh2co!-~P)Gj7#v$jQLaV92ULdDP|MKz99~WW; z;_64P(S^fz6yf)i`!ctlvtGX`M4o>t z7^$2cx3Z_UTX;(QP~B)(W!E2PrB@&UNuS|Hb!WnJXtx7x^s^av@w|o|x0S@UGcA37 zYlj#!l^Oq1E^k(Be4>7GHu2S*`YrmY4#8*L)BhP1w&r9S}#27ulMSM(igjQ*?JXkrudR8zO zmosKln3#BLh2?N(=BX&0{UgG!uXdS>~mI1ui7b?9x|@o8PEF(8CAwg*ne+J z=*Pj9#njYztJHUy>cMOpZ8zGB+}2Yys~?$O^!@T!R}jq=$lfTgw)V&Z_X^MV=d|hC zxWhfcR`+k0h(?ISuKez9u2MFB)O{YCGzcxIAG8$Ly!?~6xs{FB;OEC*#J%AR1`;$) zsL4^fDWtQiP2w>+X6Gblf3H#@yo_kW!q#kuvu-}-)9=^4!*{REcHGNKYp%(=L)y(2 zZe?O6=1F^BZ0$+z;V)^iUn}VRr)MRXEB(dgFE3p4rjY$Ke`;X3vUzzIor(KFJNEuT z^=p|3kFP_Ym64F)-~b@Hgd5m;4vvmCrW*{%UZrK68CrsAc#wb(s4@^x%qIIhXcEMX zXWjAYM7CS%bRzk56{bkZd_VkYT%a9JLV8|LyUQBzLD5>@eLsK09X zZ&$|3WOJ6>Ni)0fBdhZ%Mi-EAEgib_Qf2d%q+c<{){Cq>*T~7W^OV!U=t^(=rKt%} z6BNn!Bg+k4fpGsOSnFFmUL=echoVSH1Ex5P(>IJPzsnK|qUudpDv;zOI zoS^oVo(Z{CMe^^ok09?Sh4JHpE|H^}p)t$c`#yK>h=f*W`73wnYm;h9^tqa|mVOmy zmk6tmYts?vBm`qm$A47|IVVPZ6euZQ<}q0i|Mk~mWbX8n?$~vOIHA6swxUhSUwOrX z>UO-*zhwl7gQKQjhv+G@-M`-q(IJ59z3cothSzP;w}iHzs~ZSRMxJXN)TuAk5OP%6(X?l18s(Y6FnxZ|+qDDO-#(x<9^hus@&siFbE7mhR ziF-*zrF12ja9H|=p~}jp_o)WL!Qm?BVS?Ah7D`=|WV!sua3-!5yN#!>M?$>yI26)V;9D660Gek|t0Hdbi$w>+J>eB0jZdwyy5PWFB``&LnS zzX{}5FBCt!F#K&VFgxqDzE$d--Fl{y>q|Gbte*UGbqZ#rEkZBn6e>C?kjE&kmKGVQ zI4%2W^f2u4i8!(^h>r+ndnV99;Njgc6xhG{+ z=XOshAu+LFIPf#+M_lsyhT`T)ba_26rzX5pQ&Y6$TQ@YSP+mUK><%RpknHe0<^g16 zai!7-3QmAq`)Wl0SII@oADt@-v=q|Y%Uj=Q1!ya)AW|aIKF#Ij93AU&TxAJz@k-M3 z`FB~9s6@-ZVO$x`FK2qr{bRs%$Hjq5e1`^F{!4mo|E5KdzBhNXtcfjSH8k}F=63uH zmn`TR;(9gu=f2zG??FY_noKC*x6I7U1Or}A;p``sJ7QD{@Pm7seBjsfZU46E`1i-S z1_=08h2uh~MCLFXd#glAK(fETf1mC()vIKb&9@G4>Hhv=+Q!t=igTddfaHbw`KiDV zAK7dU9v*Xij69{iJuB0dj&S*r5FLC-ckjNmd_ASo{!n@qk~T1T6(GZ4m<$z6sRJ|w zzP^){cws8h|9EEhUo8z-=NZ8yiup1@{wKmNxUT=*;oJZH{t%1dXIGM36U{K|J%`gHUORwYg?`_XSKrmyodh=5=JhDd$6O&TG3yxZ26I*UqDaZn99$p`*3{pQ{JC2#+9wRAt97;JEE3cVh zSGiWIGQ%F>Wr*c9gT?-=wC-s|(W)lquVei~(4^q2?KwSKrykKUnlf3lyqhL5B{rHm zS+lrpqMRH?N-66v;eX_hV^sF@E!!}&Hv1jwrRGdITH2hOgZZlE6Y6}AjU@TC%cUO+ zJROfbz#C=Kuk=x%lH&xePL%1t3E9k3beWzcE`H0r9AkDJ{-j&Leq3S#idB;w7w7Fm z1}!zkh2z;7@#)+TF?=0kv7ZiCS!YAD^RqoJ4LTQoTWz305BHa{i)9-P-OXVB$1HXR zyr3(5E@#%u$1NE@jg&*55&jPGAe%qj(&EFRi*oK5k9`vsyo-N1oOq+gS+z)=hhBdz z<>S*=v#6pjmbv}7{cqXkJookX7Zq^sV8ZdlfAU<04=j7B{;m6@Q8YHS*n<#1HTqcb z2-&bdCldwNnST-=65U;tX)I^qA&l-Zs8H{hHB1m6z3Npf=5cv}^-kT| z-7@Gq>8e5bD+(|2eWT%Q$^)D2Z`yO=#vWq&3*8Y%k!@@5-j;kDUx>a}Cw6=|1Vh(c z?oo5_{{0vSqu zS+Mi)K)H#LBwT&sw}5&o0x5FY;J<-@x=vR%y=s$p6rNKgz`!%N$Pz?c`?7vu;C(`h z4HBR@LD#a<>yA4nMK2$^RWD%D*Z@KWR6GVC%m{pwRn!tT+i#!=9Bv+%{8^s_nrs|c zE~qFTEP$9LT;==p^cf(=po7BXdgefkTR@U2U`~pPies0VHHwo!DdScE4`oOn$g{w$ z@n`k>AeiZaRj;s}sU~2kcDKVFOJG)jzeh5nOib>l5MW~{L$ceq-|l^c4+F4mC;baV zlYm%*fGFq&TgqyuHF)u%K}*rFSmDee-wK4}g>1)5F(jm6s|nad2utP8lXT)LIP$G!?bEU@bQL z=t0sXO#pT?F<}N!)`4__oha~u)aAy1VgW4>Rau_4rUQa{(2(VD(R`||2Aw5k%>leb z2rd{216=EBYj+IZByE8_Ky2Z2Mqh#*$k!ljb&IHPs0R{ysJ>jrn2O8Th!tKiH#aq5 zH(jEGY^EiCrjS>;cPOgNZ9l?IXDcwZijP!zmo*A(wsT3n=~6NVVSkw?OQ z6&Rtui3YaPAw(zqxV{a^QJmRi5cu|{JEQh8w1jUofyo*0BE9qgDAdoBfY@JW)xEiq zTvKxd4+#4LWZFb}HeI~cTPM!=Z6?k~A?g4hL z@&d*Im{|8+pN5Vcgb3_{&zJhZ#gjEu(NgdIZ$vc->I0-60y8`9b%altUKBC|68tn> zA0oUU7Ln8a%s#G?XTfHyDm*yqZ>ce}nor#ZU&)1vcv`YS;>m%i)oofa@Ur2#cT% z_V6j8`|ZGs&YXrY!pG%xxOEg*yR4fz>s9l6eRhJJZbb%5;-9W)$58I zh{_X8BmccSYLNNj<*X<$Xza0j+biW)`R2`2Jx9Q?#(bVT94z8Q*9b_d4zA`jTa5!% za@DT}K3ZbSM?{U=3!p(wy!8DCut+kw44UunFy6mO5A=n)64fjULW~6k%vK?77gBye z(iZ{Twj(ru&w^tEtv@{Zd@e*{U3M?OG^L%7zTpQdm+M}TH2o^Q9a$?7b>LK4o9}|G zXeOU(Wr$L}1-ZvrulxKfKvwkNRoWX%c>Dh6{cZebAOX}Q1#TowjrzBqImQUdSdy;zAgTuft0U_*f`O%!T3FTx}&) z+X*}Dfs8R>s)A1(!XBiuFVK)g-GC$KH;BUGx)fDmxgmUCVv>X6iTp2X(~oLE#8qWsqQgfC11TymJ}f{;;yA`YOnA3C zYpCv~YLbJjye#Lejaa@FY%vhrQczPP;G3a%!_~zZHrb3i;j$_J0cFshUcFR`pPZBg z!FBBSF8-E;qe*Vj|CeY-BsNI6S_2;M4GQDSyk6B)2oQzQ$TJu*Jcg&eN)`=oQ>fo! zq%eNZWpx3Lsb^*7&24Q=O=VSAyiX4n!IupbP5_2x2H&~pFRgg_BL{W{1k|h; zz+L{h{gLG_*s8y|@t;VxzYofY*{glSbMw`Ymsj67sDrXl_;4u`jCeoRVSxhg^`-{G z7^GhmT`e~2O<~!gttpkn>gep0gT}&C4jLy(QwTVLRqg?5bJdTJ#6*q%K+cUU!yJX6 zi#=Gg9QI*TX{WO2?u}!|uGx+dn%jr8k(`|n1_g%RiG5J4U@UuFGZHEKk;VSf5M7Bv zU*9r`2Jwj%UFyd?SUU7kyG#Az0o6+w)wxurS#&(v=Jg7Q$40S5{ge!&$R9krWu@*C z3^LPbXMCq3yZVERGy>@i*A-HQ>m24g9D*SU8biM3MS^e;dGnlaHzd8gK_CG5e=&7T z#|NmiA~qpkU8?h@jp^FGL3srQmHU4qT9_4I6vNwt93Md5&uvrj+KZ-4_?G#Ul)%WB zUT}6Tf}0DZMo}qT*$gV-2cQqJ{{u1<&3X^MgpJRiKQHUO@%t}R%8u0UxWNDIR|ng+ zk)vVa6>_diu9oFA%%TJRMxp#+yMG!;_i#d;t z8=g1#_dHn!&K1Yn_$0}ARrl*xo*E2G-9poV<8+X<^Zt?Uo9nQ52|$b+WJg~^C_f*A zT}~D!U|GZ%YAJ{n3mh%b)l@q|r3G+0uj^WKS+mOD<4TmK)v*@0wkoU@?7p~54dvXB z;J1TTxeULYH#awTjYvf*C2qe@=*u7%p=hwa(L8Xif z^`S<=u-M>%gKuj{d7wtRR{u|(O#-pyi=Qu+(6F-hfM$+G16#6%9`Lxns{b|R47wtj;|tUo z%_~8w?0o@ZFZ+}R7)s?KY#D-g?#SNF1NWq;85)_el`lehow|&Pt9)l#{bKyJt5xqj zN6T<&VUfE9yL=+QW*QAED1%uIP68dBaZJW^z8YP(BOe@OZ#dq%kG+{P0BzpJ_{WlV z4iH}mxbGF_=9-I&iuPwb=Csd>fA>xefGnu;F*@&oTjB)$M{?P!buW*VCB#igFl6$z z>>GcBxc=VkQIHPPLWf`xDf$NNp&kV; zoizVNzsRtgZzW?l|y^#w3jV<={L(xH;<5-DqFXn;n$sHn*0XZS^8A|hsH=JOHkL4kqn z!&mKkAL1uRKyeN=9*(VldUp~ZXK;-CKhpe|D**9_=Jy^Z!mZ4p!3#2t_h*CI`qEOt zHwEEYemVXaB^2K#0Ir?G=s#O)wboi#Uy8+yJyU%d4x*8!-Gtn(U&KR2yVdgvF;mDC^N4W=DroWH3h z_GmsAl6t2-w($e5nLW?|Cve3LcL)Wzp9Ed^x9{Ke>fA6fFks>m^&pDJbOk#B*^P~& z^G|B8FhPVXRLxIpZNrGJs}ftO`L@4#(r|I5_*?fg(7Q%Gf8U9HuavC`PK>*`Ft%a; zwp9ELgUj@0AFRI<2@qgh;=c z!SgtE%4EpS*Delr9BRP?^9SS)Rk?y79qdu*>x$ai31iPe3jj*IbsLO!ajTP!X~sfR z=ziz0!X}9WSo|#xQP9i34GRPL+)3rAehp;b!dtaEg&Z#DiD$3PT$TrDqF)W^Q&Ms& zQEG<;2M_WWq&@#t^wCTo75~6cOjR}VB1A+qK}_@R`lL=>DP$eDH80A2=!2J8+VKSOztb5#ooPm%BX<)D;Z=w@BPWw@JnR0i!2>}()?+705k zPq7UC643f}{f3COLXix`7!o^xalD%Fd6wRk;BkX@Z&7rhMH_DPQgN|VEjy@=5yJd0P9I|gX^}z4egHnn`yF%L7 zwsx}>=re|=4CArXbU2fj*!9;*#hx44i>)Z5{_h zf4m2BPVN(`VOAs~JKf09!2tt;{!%_7qb$Hz`C+iKjYJDI%8RSXIsu>{+1D?f9%$C( z4Gkef!N(Zlrl(yT8A6%Ck(!q?D-Y2zyGtikyZjY}#=QWmsrdAl8sanYS~8KeBD+e} zQVGPK_F8aHM%}4ECPV1@Jzk7`_+{D&1Ktocj8gH{pPy&VDkfar<0HP*+F-k zaw;Vyv$T>DkE62X4p=v=N?u(63zZ7F2|;|$rbMK! zg53sf&!GlBoN(-&eAY&({sAeh+WS>7fgLb4nDm0wGX?JwAbOiz=~dF&Qn73JDmGGN zh0s%^tS{!g`V14TG;G=gx#hTq@773~RI$=s{ev6JqLbq_o~M$@+^7W&l#M_Lmdb4!(ieat73acH$WC)(*dMU1 zOuM&CDHOH3ST(iSh*+%L0<73qhg@)=tA9BFk+(NYPvMzz$pnv2PUkEuKmVA6-W%^p zr%^o+V8ZdM)dMs6jM~HR{+V*$fEL7JJ_V1LF)Ob%a{TfWh&U->3_apw>9Aan6)rf+ zK24g9CqO20+7R3pE&m-E}?Ui15R!5uc zm`fHv5MUamKSoCW8h(l|~PfIIV zsqCq0TYq52=>R<&zi8UG=~~xq=qnh1>l6!SmR#^OK?+ogytOjUje-kVxphxmh3ghu z{eH%m0KR9%jo%lu;tl;N+^^u`G?G4Q$HJQgw--Q9+>^KZ)64A}+@7aZMl!IbyW?_G+&U({ zvXo1*lJ|A(S}=+PQA4(kYU_I@Iu_jtx{6+3NAKn0n)Qwc-l#k(mmgS1`M#lJ_i1Pt zTI0$msoa+CZmX)@;GF1gN$Kz;@KIY9PgkmzO=fID$8mmbnV{32zH%rAGs)6AvY>=| z;H*~wMU3!1^s$@``IRPLi9F5VAP^AU=zQ@uUBjdoe`DNX^D8D>lw zGCm8T6c|YvS7TK_-9I?c@^3v7?`#|vCzTtv-@$*fbq2T**!!wsKl0o|HOeb0sYOqH{*pHdcdI)($5pvnkW^j!h(E#x2)ltEgk2d;u+I*LTG9sq_?&s>NpDfPNq?h76D3%mAu*0Mu8@4gTDn7zNPV&Kp%JEnYq z@P6S((Wv8ki4WmZdv^}ePPkrt5rIH7s=$ANFAD!~?Emt&K{|-7yR@{e=MV@{6|r`# zEt-`krJZlb(Knb8qM!KyRK9?4ZCSSZPhZf%o`HK<_RRCniK?5GhsQ&fL85mfzhs|<-86YB3?MXoud|IpV{WYu*6cqIR{rB9sx2?#fZ=Xk}&wj*CYZlA) z^e;Qgp2x|q?%>4{K9YUwB#HlLbKZr?$5#q-rQcPeY116Cmz;h@Yj%9Nq$KD?eTR!H z%W1oj+|aF3-i3Hj{_f(J&7U;T{yhyyo-FMwd9KXfKqTDa`E<(lsKrlWu=OKSw3Nt; z{L@v}LYI8i`xFR&KeihP#1EB~1HX47EDZW(SplzZann2`W`N%0bpLxC2qlAZRU;LZfjq_nK zB9wVCYoal;xxIahax8u(>mA(*%tCNb>n)p6@Vzyp6d33f8p6-v`C>K3=5CR8R3U|S zhPg6`Y{pic3=Bm`ZpG=}VyzZKMrOuQRFBnlF=mnE7>rIexGB4doW_6*6oS+cJ zX|*{p-K)x^&TKorCp%da_wXh6Q)GQcxNL=`- z!+hG9{^si-dU{G^*<|QzYK*4$vA$yb!VEVKA^mtpe1N$2!#_+n92mG;$_8LrQTV}? zg>#*6mLI8dIIF00o)?^T;lo|_rtDihFG9=YX{1)ds>c8I%xpe$?;5W_ z*pof0CdWL1Qm_rcmpzFX{PF+E-|nl)Dk+Ho4htQMlNJTckAy^#>j=a?Cwx9VI~(3- zHIOZG+jA#kUKx-LD2BcR;2Fk($sq(IE;!A!tDZsq2`nXyY>CT-GVBbt?II>RJDrGo zCxi@Z7(50N!hu|R=epY?u<_h`8T?dU5Csb(-MS03;|H|9S3ZrsfM_H|O`Q9|yOP!b zuA-F?uiu|>&bCF);HQlBT97X>VRXQ*7v~%T-vkvLCQBQ7-GWQ)$R6a8sLDp&(+ax4GgNnrLo) zAv;o300E^{su(SVfvB><94T@r5!E*YJG+qzl8HIuKQll|$jD?SjK&c8+!)GST8()Ey9fO%*+KBnyk-Ttmtae%(tjQc|WY2$$6bq~Nn@Je3m(0rgwOo*7p&ij;Mca&@^mpMk*4%56B_3U&kdE15#vTCnl}2I%cMN&W0g`UFTdIN=)tppl*egocrpmbNoy zMW&$O5S|csNSj$HWAYO`S}ms~fdY1|YB6OH%iSq>6w=K}NOs1amS`1sp6p_hd!C+R zDv)2rHG-AMuu@5gB}gtsan~ zRfRshr#-+p4NpXk0ECq4%8AN-eyU#ZPb^>n<}ab=`2~K#Mb^y6=zvv|=hrO@cC>ugRsPlr+ps9P#9Wd;SIRjtw* zGvy%yN}dy<9xm@%q`uBMuz035GRVQ}F!$zR5kMy?II2jxCV#Cuv z_bw^P8tb>Okq?1GqU}d2wyLDVr9Z*_i49x6D3d&~45|j|mqE9JcmRsg*f{%)bGvI| z3&098VUJH?Cjf$d3sKqdhKgF27LA~{%WJY}Er`j;O7{(Ln_0;rgys|GSuM~gv7mxf zO0-oB!RWb=EwEAR23hikIbR$1YJcS5GJSlV+j9jw)_WpBi7Wo?@-;<$aDEBQa9xdPzE99M2fpdNv zEz1wg8bE}ua}iQeMBP&#bkAfkNE2*?jGm@(=?iIQZc<$g<|(uuhs(L(`0@r=g>lr< z1M+gZz8bIVq?aiHZy8P4okV&JIaKKr+3&L`Ve|$vr=`+r*y=52gilO{r$t6djq{Z2 zV!U|@nUGt>{GmZnbB1!R!k{r2A)y6Y0QU>5wGmIEI#1Z7FevWG;84v3H=v+$6+Rwn zWaV+eRp>si3O0`Cx9#GTLJdzpF1kF%PP04LZzU>4Z^yS}pjTl0p!S zb;{crZk*R(O9+4|26d8_m#@W?*s4R9HIxn^J}T$>a~Lr)D1XqL(yS&|er`S1F>fp_ z+*kyJaSN#3ACo#ODx8o}`r+cxWuBjMpyOfLU7$b^*%Lr#SBGWFGzO? zny+q(qy8*TC}h%*l?$*@w*x_k``%;j6k24gB25W2;Npg;$E3rb_1%_f{=_d$_AfjY zz9$*-z__c*23*Uvu(y#qV7ln!%m?|&`4kztCOs!=hTU6@^-1{v=xWn+gARuyg*?%5 z3f#g*DU76!aO|h$!7&|H1_W&#E6|iqw@a%1_&Vu1|J+Niudk0`Vp!%qfk(k}RL^l#c;OJhzn6`$vI z=iLsyUN%$DTuv?tWL8PdI{#6A0Oy2Fmzl3t7%{|P?m&zfWMI$0lZ|rbxbgd&Jd28Z zucvl)C4h&Ryj2+`c2qVMjd--t&U2u^0M#5+I|^+V#g!s`(D@Y2u&ly4`trqPgb#IK zI5uwwsDrfO_yP)Tyni0rSXg7QhxH2jCt=h~5Qbx>NjVEgVgcjUXv1kGpniLQEHl5<)1CT5@2oM`%;2lyUi1KkmzWma|r=bofF(BdDlon!ova%gb zgd)1IUXSJT3Nz|9r_W9`NaFzduF2)-M3oQdB*6xd>iz<#TrfHJBj-rMPvPu^vEX7( zoSi74L`XIVG09em_X$A4j6Vf;htxh!zJOS(N=!FJ9ej%r(Q}QGvM6N};K9jYPDXXX z{U5L3ygLBfUwP8X?X&$hpa6)7iRqM53aNdn;w1EbLu?Ds;b6LoC>L^FC6Nk;RY4-vrDZsU<$?Hk^rcR3;p+!$V4U*%VezFx#0u;atQL2B;F2 z<0QC=Gv5lMCzlwM+(gpLx`A(py~zSVm3254jxe8~_leV*D#{XhUch7D9%>`zQ9es_ z97AEkp%Ul?cT-y1+8}_nw}ZHjVIl0Cb}H~74El!$2Pd!zcX^>PAOdyn$ANQ*AF`M# z%}|zxKPJ_%mdtp4F#5Y3Tip;O(hlRSSN|$*IG$%)dGRz0jRmF4Wqf=T$`JH3dqOoF zojKXnUtn%HOeOV`#3+*BqRRx6?#7(Vk~omyo;!CAb0(AfA1Rb_CVgG-x*kubpE8^y zd?@s1!V!)^m{?1jXP^JLDK0Nz?DBPYlCaq)w0YzNeE;@K%sg{@dZh7-3Ejl5DrQ)Vc~pRny6^j2h5 z`iG1mIIMTR8NX$Sj^AGIBKW%X#g;TDDok*4gb?BLrFC)`VRxdnUc4aW=*(@?33VtT z1#jBc8(%+n>|q?(WEYpE9+unXyP_qBfY5N9N)Utr?{J(JyUnE-NiM`w@IRM6+2wvZ zlE+js1BGIMpP%OAw|g@Ha4?Uu4AovyIY{brm{ioxrdqpd$1yygdxog0p2KbTsVTr} zCpihcsoe_tJFBdFaNM>=p)z@*>rU{!O6Uvt zsOJzGCF!i{>G}F~hd|zCWMn`#bAh4bty0~5R6z`%$W`bT-J$vMFNh4j03Ah@y)!qa zr3eEI0RHiV9@7~8|KI-Ka5q|55C;$H{eSiUgn5TjA9`}=6Y1_aVKhA zxw6To|K*&InD}3)MeCWpcuQJ1&C$sIjXzIS^A+c+@`&nNnhJ*0zVsI)|EhS{{UP~V z&7Yx}J7=o+Vo!@is>L^3r$?u2v-Ug_pC2^v<>b=k=4c8FOHcyFf&s(?`4e4iXV#;4 z8Hc3n<1(z|CFD(J@>L(<|Y=rtsf3Chr}uVippr6Yst*phUH#T<$80 zE5a#E!?OD-`W;8_ap|wu3}>1M<-UD0cqXXU!h$R`?g=h@;lpQ|L9*!OyA5{KC`oaZ zsERP2hKTiij>}ZzInyi=(@cDyv#6I_%y3L0;*6G!4>BWIj3o9w!M?pL$R*78*|Wz~ z?&1ySInL&6G4 zj@>`tmgsKmE$}2nq?RNa0!KCD3@cQ$=g{9-<583}NSc1n!EDlXZBR-#=xj)oC394LC&r8SaeaR`6Yo~XN9IcVVInQ07dN#;C zefGjcXRu=z9aDUhT-UaD`OUXx#~MXSF17qEW4O{y8AZhp2p2FVe~<_yT3&9roiOy1 zCN@^X3!VEDr}hb!kK=N1Z`-a#)kW4<(EVL^+}u3ye7ToNW`EmQiR0fPu@Kg7f@M#^ z2=Lk+9AVqH2<4q;7-;8zGWr6q_wP}ksM+fXIvA7$@N^_dCqTD1F9EFy^aK#54igrnS|H%a6S5T9-QH%>KR!EU&8jvzh|J2(rK6=aOTh@CN{uKUVIy>9 zjw#P2Ed%2SSvkDU#%(k9971fNqT(7X25Z9urm0#61cHQ-kinDyQ*AoFZoNkpnYwU1 zRC?V!(-8eQp<`S1*ce(K!weYcx&sQZDUa=L@ExE%p#tr?52YKXaX7jH@?Hxpg#BY^ z*l{oebl^u|&fs8)K?48|Q!YP4wH^YJ6}dvX!e5||>0E(RGUeTs!+!zVmO{-^cvC=6 z#76+Ib?MS2X$1waLC68bATA~*SXrTK&P6Hg!F%^^I@DsodwZAYbu0sS3dnV?}c9(lO!2Mb`#5*oHWKW!YAPEV#) zq0Au_u!aC>rtO_1XqK90(WAk2f37R!pICsqj#6chHkJ}yaGglL_mZKs+i0GA?`$w z-9W9a?{#2m3UJO?-r@?sd;|Hei}s|v~*HDJealey)v znCxs;6~9vUlorU}{`X(LLTSHiQM~{(zj6`n_I+)pcUM5B>F0 zVa_qANWqDt{0#;>(xWf&`3&ZyBd=x0HAs4Jb;( z?ugFWL9h2tqLi3ykS`-?o5H)+t`Eq#Eu<(P024pcD6oR3~@F?HcsnuHgTA z25aN3;MNk&eKEH>2$@+p!RfP^kF@J&rdJ#H1CN*3Thy zMNrmfRNuWwi|GLT9o+$Ox#ZJ;TtaQG!`Dv*^D~PbGIKBHyw5C)bVWCo_l4@sU?G0s zLAk34gX5-`Hb0Ep4*sNco=9&4@Zw?Op7+uCG=MfRYvh?}B*<72FF!!eD>H~v+B&77 zK79iCczCn2c=I`jcIFTp7>(G=?Z@ zFFN4o>}-@Prmu4rDN_Rx_IOn3M*fud0b zd8izn1`jghjW~7yE>O=FO53P7N6k!fj-L<$vf8~9IbQknquZ_(N*gJr;yMEfa&E)I zN?j&&O=@zaqFjA|@784F-Jtbx6(Qb6A8|h(TPkcA|oNmYJ(%Cfcc3wdVH;3*Ul`mo1A>z^BJ3WV#`SY zR@EWj4W&-i?`=XN7N3CF(CWF%%-nU7pNsqY3Ry;Y=D-MSc$I%RCV0O1p-3x1aZ}rW z^8%s2XmmM@`h;P8kfUz3ybMqa;FJZqAV%zRC?YA=cVFY~e7f*(gi(|d_QZ3DG-+7< zVH|+o!np*4+NO)g>F?&1W6%S4JU-BC!RWj!GW`mRh_csi+W?A`5iByf$87=#zf&Qh z#L^voO9Uno!Jf>b!OI>SJ7-1P3Lonl+P_LO8$!d^zb@igft3T#6f%?^q_gtz9RN+O z(ak7Ayu$KnIVTahtID@tf3)6df5tOhxO2U&wRLPGVMW{fiP2ux7HP=n$-A(U%v{dze03- zzU{LZHNdLfA!!~!E#Dj%9Q(5B(__<@AeMRj_%WQg`=Nju?0q*EVi|&^2rivNOCe7f zVhk=WW2b3&8US<>XDWFg6&IJ+bBFbnu2T#J0N%OdY07Mb6qSl+J{(RM-4m)Kr7v;2TmDu} z_R)Rv7iXy3&ghsBDxtAD=6CaDBrM=a{RpGY;Gkkct^lF^GmwBGG<=mD9Lg>e=T zny`tj|NhhuS*`NE{{xJA+Jv>TQg!kC`A-eSfExQ+AmFgnV01Kf3-yMnu?BqRI} z%*Gb9nz=pC{x_fvhDg96q`LB~G>B?DF_&C9kU?YgU=HvV()$%kP>B6nCQ4(HJHslw zQsEP;m?Egv)pFkDsJ|Fl^OlEJKJE!&;!o}r+fPP=kV1QadoEn*_`>SIAe4n*M;OLu6E=#01Lkf^jrkOef@Kf33?tG6;CW!CGUQ=%0I3AxXhL(0 zPWcG72!NI0Uiyq);%gl4Y-?NOn0vj#s;(aaFCuFD-+(**54xBCCn)UyOD@CS7nFpi zHxc&3v%vo%cprfqnf(U7CZgCDpdEydNW=U0@9|*Vb{fcpulp3-!iXO*NE!`Jdn{%_ z!#B!Nf1BU`5oUu85P?Yg0c6tnN4t-@eh>ncRIGa4?4gHxlAXRwv` zgBTQ8*J>a}zjy6{y8x79mFJY?|+( zTEjZz!j#EmwAOGS+uQ$!rmIWMb8gKLBCodac_yB?F1p8FxtGXg32aI~qXme5G_K#p zp_c%Nfr`^CL^YIu-FebU10Huwa}n*F0)jv&gLI0iT7g2aCKrMI5xPatPB7*Q!bt>O zoLMb7m(@^vN-nK8@PP|G$(Z?V(DCM_a@mYkK+q1;G*k)0AX3y-$k>ttD@wFThdUT5 zkVrKRJJ2vz;qjqE^c+fkGGtS=D24lY8i9n%iwrxsUHtX{ni~=aMi?8NDTz;sWeJD8 zgBJ)DcZJdj_o13+*0`z~SP2jc|Hbr1fa=MFtUSOMPT-gXVx6O5|^fm z{@%D9grq7Y-v=S(Hm3UzA>$nS&ZuAO0)f3G4~ABOhK0vqM9{8k{<;vVMdrfw-Qpn; zSWPRPS&_bbu(pP){LE5Uef@`I6lsT%PvU6-;KryijqyYNa1u%FNpFf(M7OH!oW_k#=fHhX#}%aOi_kjSYen zVt+kReT{poLmNnEvs&R0M#^7uq6PwojoJrK7$1{ug%E`?+>UTW=t3$AHVNM2H-MlF z9p0-3h(kWv>p~>7s;t@-dC-?@>7QiW`-JJ954mI?tx)8Jo|6nv(~UZxqqwPiljNUR zKr*N@=R2fPXk=;0!Pf!E40vv>>6!yB+k|MNzy*lMJZqzJmp{2*6k_=T`fc=O(_UGc z1hC2Y6Nesy+NM0o(fbA}38OT9XO9d}^;3&fc>zRqDu{uH9aY(^I%8O%tF)f1uH-9c z<>p3B^4b&%G0ju&Rzyuy$i|Ff8qKM6z_Q>@Lagz~<~g-EeAolAtAv?pk*ia6M_@r! z)@46?x7*)G_xM>JbPePWLrE8Quc^y~HW5eo1T~@U(bCAK6x}6jyvh?(k0WB=U zUUQi?MPAx2dbN19A6;?Gy;0acVvlKX=t)+}3hreT2f7}9xy;rDI|{IhE58bIF}4Iu ztL2{zSQ0Sei3<`QC@xPnjjpHZRPV)yKMm9s6+oFUCLlRm=qFry5RB#c|zAM z<)}WZTG96teiLb|a$4%HBFwt3hRM3Xl`U;Ps<{J^6ng%YC>IhtF7s&93bd@Otm)Ww zM+$8KYI1xdkFq8BmGw&Wh1RfakZ{lHv|?UFF!HSsSkY;Us#Jmm_T;t3`_cElLBi?_ z?}|UE0>l@JjOXIxbA@pjj0e}5_KqT?9uuG(cJ6XG_RR#qCWNjFy8vK^RJ7*s~H5E6lzz>zJ*q!!hucv3;XIXk1lC$i&-PAMpKNM`7tyJ z&{n^oq~TUV{px!;074K9N4_$R(X)Wq1$ihfWe@D?!y{Gzskecs(zl2 z*1xXYyd;X5zkuvwS3=DtVVdH4js{Q7bkN-JQ>6XQ94p-Fg8gseEpOhJ{I~D%1o+>i zGTN)e+H21pA7IzW@W${Pl5o3mMS{|`lIe- zfl(7gr_)7k(3!8bq8%W}MeoE5<5M_ej|ERlx9q`8er`%x?e$x?bYQ1-ioVXKZ#~}% zr8Xw2+^HQT@7Aq(=Q6X$OC|bkfe|0moadpm9+5Wv0CQ?!Lv{z(&I^Kms=jAWowi7< zcXmK1!S7Yr%Gd*I`PFxR*%QD%xa_8MYp6S;`%k&(M)QyIma;Q{Tvy(SBUZOThcSN5 zPy z#~2Fl*@H+hS_$0x_bz+@YIUT8OtuH5a%OB|%F>Sz&|-S8a7UT`Z9aBlVz zyu=(7GP32UFDj(%&&T9Xq$YWl3RGGjW20sBOREBI(a!=TVo$ORkgbJqOU$pH^ZdYA zhpE;HbHHv}sp~3vd(C~LPUANF(f`ukn@3~W|81j}q$27rR3xP$bD1MkO3FOXL*^+F zDKkxqG9+XQnP)O&h)9{IGDaw6h)@V2d5^Q_`R%pez1O?fexLR3z4qGc*FX2V*UHu9 zJio)|b9|2BOu8_1kFgBGclRmQ3CWFX^7TA|v-xG6sO_QN$}tSujn5iTyc_O zf$HlJQ~`4qwrKo^-i56^YK=$)df~hACvX)2b~X6gNGZ|(8{Zs>>ZyPPh~vhbngd)#B{K#^=#)$&*sq6BRR4Diqh~Q8)X+3?tMA-kh#rKb)b4KEZSJB zNoK2Ak4B%#`d@KF5^pfo-r4j?2xCgeoe$7(^<->3-Dcqvz5wVEh3AVe!KdhK`CARS z5`QPZd??cQmAvex=7;VCearpcmxhi%kJh;iw@3we!k{r4*K8H52$YwMtZnWLa=k` zlbG&?lDl!0vdBg98hJoBntdFj1SIVSGaU~uJ^re+A@0=mc4wniGcswiCe@~Q*Ta{q zxjgzYhaTP9;CDmDB0(bmOX2X~pKRcl4E6swlIY4R*O_;aeg_z?7^q-)O_C-(XfPiQCAl2}Hoc+84R)5e+2>XsvY$vHYp%1qulQ#ap=wbbs4CR$d zI(-kGuMUrAJeZ)=QvBvUC#;s`nA>N?XTNM; zS--qW$cHMCvMXomqM{aKZ|?6sRqFY@pXY-;^~+?ppn~Gtx5JC9dpIwz<$dySeUy}B zCVXj$V?3FUUhLYB#bv2qV?)2L&AT+cl#?<=y^gP!A z&DQ+eDpx+nTp#w~V|iCR$3w50b30)R?^$Lp<19MLt+UtHlar$tHs;5&2fK?d zxjp){Wl_V-%;-pRuwudiaOTfv?_uS-8_BMrKQdAKjMisNW;!~2+YVm6)IyW<(I2hd zrf-)=Z6#6d1VBO}g~*H8W$5mH;hybJZr}4jzw+mDQFkEy{SVKp)R%eFlrFY0MmSDe z`9D&5B00aVxbar?qaVqWt?rgQg(j8I<5^OTr8Ggaa7#UV@nYsw&+g!Jdlr^oxJj~H z?lWFXElwH5n^f!_Fd;@rlZp> zz2;O^aplAHJH?mvUOLzZsRRd$mfA0md{0~XwdcN+6T8`b@l@%uiWr0Id^&6I!Rp5v zY3^=qugII9+oX+zMz*at-@%%FmU9)$YAZ3LA8oCcS-IWVNW0)$?BzA}fJwVTja=ZR zW=U4bU7Fq~zvhUJnfFE$@gt{Q?Rxf!pWxx(@cAy;Ro_t9ez|{7k*P*gmezXnU8Z@W;IgO1l`kgGYBz+l)iZnVu~@xNk5Vb| z@osgRtPe6u=GJI>SYbZ<{9k)U*=vw_L70bRc<;uVp)~OK_lM2dkaHvm(RL~WD(k<02kzaduHK%>tM~FK z`0D7hqbC^t`A0QppWIs@al3u*lB_*&W5a6aqsKR%mZ!KVdr?3>Idy&fy)z^UR5F;@ z6*wOO{tYH^KuCy)3c55Y_j!wN%}s=^0QkrDs~SmX0}#;1qgx(NsEbZAg;jnB6!3YHsomB7d7UbOp^4-OCzRcKSo}o znFTNI5()hJp*5dNUQEWOmP4wZIj=szkvJbesG=!2EdpT}hLSxAVA znOV}FbTWdRo(AW{jRW8y0AryxczU)JHxN*j-W)xFmbU4x^bUZV#fB|f33IM|+p}<; z`)d?DNJ%;BayXK?WD|a<(4;fCv)RU^TqVQxJ=%rc_j(64Qi}Ihq4DFlY>p~v6E~HY z54;uw3WUVd-qS74AE`V$*j+V2$OYBE_u#RR{$uPh3-oQny&Abi+BIDP7T3())j*C7 z&b!|LT5!`6vvr_kfFyTXO!A&9s?pD?zXZqTuA~l2I2!tUR zL}Zw%v>{Q}5bOesDD?Uv;1-^8m%{vZ>RPOhP|oOE1mlf80vZVG1-q2p3b4~;Q~Owo zb{yt%|3gb@k)Yfswl9$r8K z#-09y?h{Y+E~iyY$26h(5X^Dn2R}n1CeIySVw%}a;sL$QM_(g`)MfyBV>?~@yq>Oo zxlh@|5q)7>2{uOVE%ytyGeGd#orQ|u0(vqGi6NM@pp=uI(%<|S7J!1U#9;b53qQa5 zF8j}K0zfS%E!6LK{A!D3oQ*DZnfLJF&zP|@E}-#`r9_LJpN^uDPep2FlvYCPXNhyt zgA~mo(3`nUU<}9zuP<1G(k&5>lT&8LC&8)v9dV)y7#(%}z#K>bHc%=?1_##q6tG`Z z8P(J*(aJhjE%y5USvXjg^0Lq$4=3OGhCDmxbAmMHZ#WF!36IFh(sy`6a zh$5HJTNoybh3x(WrwIFAc_MeOh-Eyp9`ty^-%DG$_%7JE*@Is*Z*WX!si8(E2}7*5|3`|bdMjw0nReYIO)`-Bx1|CJmw)l+cp36 zF7wntNw!rIW21(OMC!J5su1@JJS77#oZ#F%ISK4BCyt%crW{&}K3;XPwIvYC|VXlhah((;su^vuZF1H z*j|T{bV{wW>ZT1oErM2%RdN0*>zIhpF@8st!K;b3KUZ83e6JrR(^&|E~sA?LYgsFjX zNe8V!e-pSW6#vltguUtI%-TDY7a?b=obpk6DBR|3v@dzKY>UV8tV2E50l2iii))Lh zf^%U1Yx!-Q2la;4Vw@A+4qn0`=J2=gTMtXRpZ?)KM_vEpHxMT|K9bk!5MO8ophvfB z*VDwPkv;An*U=DteJPnXoZ9&|w{$p5I>TERU^iGPN*QETyP&#iDi~p4w~W4FpI3hF zly-l|I)4gv>rjv4ws2sY1a0jXn1}uYaRwYIY7QE*A8BfA#Sg(g9y! z(2CcTYh310MToJ!_g^0WN8EpAzb*0)= zI?t!QuB%XU=}N~=#yp_tkuKY|Z+G#XvaMw~love9w)BpI z=U$y*Q60;{1+@hI05E@#3QQnzWKP+ll`Asjx|0piwQWA8Jg9_96-PiRNHbwr{mF`P zC!gV7!Zlp_O;~>0)U*$FR=i9*#6(~K+4}G0(NCFvI{X`Ishrm<&CFK6K_rpjc?XHb zSUqS(#QEGpuiQGuS{@U?2M@LvcBZ`Awt#*;=gV)Rl}?lC_ah@$%urBPCi2U)ikdra zdu+d4G0CP?r5Ox_d5wQ?LrI9Mst!iAOE1zYdQCP~bt0y@b)J2NT8w(5XgXd zYDP?>tuLUud9w``7a~w{VVXyfer5OcftdeSi|}l{{5%30vkc=Q- z#kBm|@fM46ZrS@7z491Wmf@nJB5{f-Mf2Bn1(*)2xV7++r~eeNak8+=hr>6e)KJ9a<*4&i@<-o0X)9dEb$w+xhuGiS z7=X0t_Nj(!(U5%8rosL2_6i5JsA`7EI-)#v99*zJ8jG9P(vQN8p$Wx{2oF!2#>B}5 zMcg_me^#-A^Qx8xKoz)BLk;_3RaP7csn7zAT z9`=Y$Nj+R$RS&d`Ce8k1D%NQX-QADMj;V8?$)(f{}V%3s+oE&0H4 z#G{zNZ#4f0V{JlA5_)U&e&%G}4TZ_aNwl8}Z5a~0PCMkpr5KP@@0HErryF1W%$4e) z&K!7lEju&TM|>Y`|MAM$bG_R#)d!@UTIM9K1WYUnzRkCYEnwTwlUQumuCfo$ot9kj zPd>F7OgvP)@{eR zADoNfPCxA?#B!C29F8r`O1PeEtk%5sR{vYbtZL=l@}kJ`Oz)3uMt>ZGCnuXSIU+-L zssXTpiV)yOJ6pU#nWtqHl@MeL8H}{MXrtZOCTpUT0W)g5r>oiI;<=``H$>GhT;o+x zQTeV~RM-A!t629XX4Tq`2Pzj7)-R5wv7MH13lY9%AJ&7 z%b(Oz(8(Guo}ca7)t22I5-61%>T+WIYj>MeZ}vaEY&nzeeVO)DQqUG;si!4Hzb+rW zeQ~2kPHbn8in3FJOMlp_IwR{6GU*#f{(j}W+#auwldr3p1`|ivBBnY9pO;-Lx2yBY zDcNPPrDPfcM0Nv9n{NiKKStiu()P7w{6n{URHyktfY|jk!%Y5f!IzAlZ4hX zca+z+t5auL(%hPSgo|P$-l3MO??u&0kk#nKH!DawY<@i*8GWD){{2bDdXd{QHH3o~|vW6&LQrY+R1A_Mx_(d6;{^e%;6AX2!z1 z*u&SOeXVJ~z)3<-lUM=mS&u2Tp0D_{Bs~|3k?eQ1DA?GD57MqS%dMcd%cynv)cS7H zV6Rlq;%q0`>9(xyjjL_T)d%`}i=C^oQn~!MJpSM!c360BFi&G^n`YK;;+uMfJ$l4L zGQJlS#G{sU)?@YiUA9HHrm?YzgsI72WXE+$pA1(&FIdk-zd-jpHQBEBRl^_cK(g*{2ST%H?s3@War<=&i@<8&is5ckR7YvetUDISRyXd zT`J}Q4OZOglb=T(85!9u4Kf8lD9sR`UM!xLZ*A{=D~B-BN9QZjwf6eQ%I{8yh|2+A^!jUQqc6 z08=?;htPU*$a7?H>lV~lWL>J5??0|A@GOwiHKp>S4h8>Hc4zCAJo;TAJJ-;^flVY; z3x+Y8k+#E??y9P9q1OPw9s8w(pun#*JQV&kC4>|vfWf+V(%|q#q)_2cKmg_T?JcjP zt$_Nzbel^e=%4VQ5R%#9v^YOES5Ce1wBmk*4*n5RsKNh72GRd3%s2ll1MvSF|886& zEan)tkPJ(HK@~z${)d^_xK)-MS_FeDK+HaM9OvfFk-0;~$gWqILh)(p-2d2v;(rHl z{WS>raqJUJ_Q?FMZxfYfo}1W1qM|n`BF#U5_@L!)GfHTJF;>ApOXv>#21kBUlFC~7 zedp%2s1JGmeRqF;-jg5=suAk{zXFIu$4^>pgGr>xbJDy&z(JB9`L0lC99XtCp89ih z6NoF@_elG|kS=ul?{^e_T(w^7R=;+SpJMPkcAM!?->)Jn(qkHva?-qCSC60g=kL5w zl&hE&d9811-1RT*1&mE^ez^xDMYk2pmh1d})cF3hWg8qlPU`Am6eM=lRlBwymi7-V zNTXYT$FDfUp2*LSL@Y|Y<(0O3Y)o=H*em6eSmq%c4=OK^`L7-aO?52 zv$LN#@wQn+W=E*~YD-LfyfHF(B#bMr!lo_@`odLReUjgAHRThO=~4G1`cAm?A6A7) zp`zl0RqLtfJO2KwYisuJH-##QiG{|+dBG74Ks@3L3ZZUB!n(F3o~Gf6x%&0YKU=<; zXI49nzkRZ9Crr-ZPgc{>qwg1G>QlbB!T;#bq21p#od$*`5r9QN%7k!lTx-;>J3 z+#ab9HbfoonYMPW;~2^C*MMIj)k*1*ce^%ANmXNNMKi%Nfo~zs-(T-@^`NbPoAh|~ zSB84)fNh&Ub=1ag4*SyKVZ}SCTTXfYu?F6g-N~a&pZMRV=Rm)K;fMA>(~LXN-$Q-; zdtIiBzx=WDx0;=qF;y{5%Q*0Sv)^FzoG16%?Jr|qyc?ydb|(#QlN$=J=1kJ2cNjsO z?ksQsD=dhbjBP=qCBlGzr-i_o?W8<@Nzoynnr1q;!1D~p!29ciBG1XM@ONj6)%&#@T!&d$UJZ`*ZgO&;bis?3yfv&+J837KXX&yMw62m7&A~)Q~xZKfE4KXylT~z1Yw0e zq~A?rzt$rsvVtR8_V1?ifAi*S^U0csq`#kEnjS_zdNbq$O_#WqBR`#;c#i}B$Do?K zapW~uyqqiCullEpYP%ICI6RfVaA9tFxs+r`i3z;GS8q?M2l2}P!UC|sPw9;_acPV{ zE0j=cJKRYsTea}*jL|Bl4Ngf-#g5Sb&+qs1r39XE$tCCDh|H^x%nL@YOOc&a2^KxN zHW;Q z{8I;)4&Uq8pE9*%q1Jfu-^HJVMk*CFN!I_k&giFMxv)NN8MUI}dj?@viI^mgClOk$ zXa}%$s^OD?AP{~*Nkju6qjPPE`P4N0cg4|D}5Tl@{N7oG46y_EL=%q9LNjvS?=NpX3Kh*x_4ZrHJ%**9=J%$$xJXwqaTnLp`2*v)()B5Dkt1jb zQ$x5YWFJluwa(ZMiP-mFb6?77M8R9pIVQQ_)+RsXp>>H)6znKX91lp6vo>Cs?$gt( zAjk}qq70yC>$*2djHaZe%?^}&1Jher1yx**QLa0u+@vjrbEtLboi!yiUNscID;RzP ztxW)lRetX*jbJtuv?h>6BuHUTK)RwvwBJF+c>UNadq^O4;PQAbuGXm|N z-M}|*wdZQLyVz{ppmne!j4KrxCnFWUh;i@XmWeiLke*fX3$Aj)#AacYZdyomDxYuwSa7Su zU?%A6C{+D%9POLUnvawdCoyg)xt_4jKaqTCcV|n1X-}d#hTC0x@aHH0z>9nxWu_G zeReOLXeNaLVzV@ZY87NoB^(*G%6S8H6jKLbO=DaxODQ+>6@;%C`%CB+G*#Xvi@o98 z3#y)qw~Zx9i@tj@LBuiMANr-d;qo+f^7WCoe&flJpwyKZ9?Km<&cKvc_Q8CzqLcJo znlDehW~dktjyMFGX3%qG=e0ji!|Vj^XeN z71zJ#Wd+N$vZrbN!oD29_)KHm=Wr`%l+&xp1~ari*8 zP zCrt%9*9E0mpW#F;zVb{iE4o){A%Wjg9$L=AYLBfHDDvr|E?v&rLi&+X^G#g#E++{w z5Nk-HK-!XhLO5&S=ByJL)%*AEO*h|2Q^@tX20bIBu&~V3Vr5ti=sEtqH#ascWQa$b zhUGO4x#l$@cLVZVYZ=)s2;!O~rt1&o4=t_t4=$XE=Xj0Gi4WAVBIgZK#3IHjCT0BZ z`{J_mHf$}Je@%!o<(S=%E=(GGWFlN^c3H7Yxod~cE6*3XSLG?ghkxJCg>ifZWiqi|Tbb9o+1q03vM9b$72p!J-fZmYs*1Q4o7RL?_ zGS14$nc-*VcZao487CFsw!HZ``(r(sU$)Zl&E|w+>OB2dgmm?%I?C#`G~=mXvsn36 z7Oo{A$a%ubA^N6&{2#FYrdV;Ym{d8i#sTEbx|R2`r2J!yOmOrqtJD*TRs%fa!A;lx zQO{G|i;AGLcWl5mt0!Z1MDpCT5!ji7U8HmT<4X#?%YAx}xZKL_zNQw8kT-3<4b#VB zLkfm9)*u}RIjy~QjS#b(9^>a(hRU;OKl_YDS%e|jN?K<$CKb!ibD5umLVrq0ji*uE zsM^OHNBwlVaLGG6t}Z*>l9N66HRU_S5ICz~t{LC?B4;`Hw#(o8;Kx;s5~lR|pSxGK z`f1Q#T3+q&Ov>LK?D0O37nPsL_2ojiYG>xd{4|HkkLmylhXc^c)~mz{E3L?d$e*Ml z!Qeo1=v zD;okSVRtHiErm3(=T5e%Q>(JdTOQU@n5OM4&N0j_vdZ4lf19aFKj|#xow(NH9JHKy zHh25rBHLtv0};7a4WLRF_VWzGdx?q1QNNVzXn@RAsE&Ws0ukX_< zT3VbIEe4Lo}u37c$%r_pK2cf6~9jCx32UMPu)Vp$FnG$)h|qf zoz6F6U8(@0xaM`((3R9a4Nx3dq0*V{qhQ&I*Zq?2lId2`r6YGI~jIstYY~YgK9$s zxaBBbdi>69*}CuDVc*PCX`)m!_6=uBhp&)^YIj!yq7& z@I~pjgSHj??V#Xb;nc9fyrTK|%Led4=35!F=K7cG+}LzW*aDQYn6pO+ zhT+E{<WefqIXq^b^(e z*+x$fG&)X>CaCx)SW!MlDEgdtplYA26=xj2RGJB%AaKr zvbuG%p~3TF-5jx z(w#8QU9-LbyB(0WY(MsM?;qC`T+0l5O|%yf|D{}@T_5_5(mDKz=KFVc87~sDU3+ti zJBsvvLK8~(2)URK^K%5r3^j@`JI(bW$?Cj6<{3VzWR~=~Dk+nV?LMfd5?w{^%sR_cN}8b`Xf?=rQyJht)xPo0*Ug& z`<&;`Je2mfam^H9>edO8m2h;8p;u7!QMEi{2bZ8Igoc%A!W`kjqd4%!a*K* z?`x^alkKC4TvX5@x>kNS;<_1dGN5J#4W7-CZjd<>#- zhm$Im?Bz4c!i`d-Jm$wUQN^!i+UZk6)%Q}ifVP?3>AcUd**s){Q{eN(;f)pCL6@^=~ zI6tz^nwyBQXEQSh0Z1fY{kq(i&p1skT(|(a$9(rEuvm$t5g1;BNI%%%I9#~`%>{HQ z$gIDw9>foI$!7?oVYe6~>_E6i>manukrA!fZ&=~=)j&ZHhTx3kK^||QbulVv?jaBv zPi{@NMY;tNJ`}pxgoIpy@Wm9sy&X=8fe8r-0GvsmR1R0OOeAo<{L`1^DELFS!Bpic z^Fz!1T1JpBqtZNq8;g5C5m-URL*Vb=D@Uln9n+>7KH6)9@yN&OYLq_m@}PA_p&o-q z-9ZE>tA(3e8N(8g?9rr3*r&(_af~P3fNotF%{aOI$z~b2Nf9*uVpUJcW@Y^gbp`k( zV)(MSZMBhm0Kzkg^xJ*(+trfmI$rC;&H2YuxwH&Du8;LmiAR6PxX6$57zc}`=vLN) z^eS}cK{oqI9Q#d#VtXmm2KEsUMO_B5dDDfJ`7u=cxkn$ZZoR`$7ynjDGe?*3RU$ab z0p~&pBImlCz`H@sUFu3{sL!)Lu+wFp{1WdZcre$Wk$q$uRC(B0V1e`K&fD7? zwl{c@%qv-=mEW^Q-fMQc^K0-meEjJ9Ht{so(dc7{{pi3cLgxrZaY{Pj;{n&K%Vi^5 zdK}VJ5{C1PPSx$#s86fkKC zJRC^NP~VrWqr$pZcCRJmRUSELuE>dKPWQ#?jrDwqOXFV15}|i$O%-a^ls}C)hRAHm zWJ;B|lWOp64-coSV;N%=i0EvA6@ryN>|UI~RmHnR4rL);0@kTc_d72{@l_%(t<8`I z7Ne#F)cs4KQK!+p_)EdE_MeX&b|J1zR%7Qd$dRZ@seBq|?yse$W*86{D2C{Zu}GCH z?c6M31c&x2WKm?AK?$DZmaMLbhMA{tXa}8+qJWmGW)t6+y1u?XZ{5X%Jayq7f-9H@ z5g9y#vaE+xuDe04N>bfC#x*M5?|!@E;IUkZsg-*2m6Y=uSg3d-Y)6kG2;d7OJ>89% z4{g((E^R~O9fhDpE z?-jaCD7ze-f=bWBSGLWebjYfFcRRc|2{)e0`>T96d<1;)!6e^3k0-A?&npI`n!ff= zV@%$hRAXMBAnH<2O$ek-$5AYC&THlydt-HTevgt5mKQ^Qol`<=IZNzVh2b$));i}Q zbLa{_UYF6=A3zxdbj}I;a5#kZ14H$}>P0gbwUF1TE+$h%{j#rkQ0>@{pO5agT1_zDAzEV-qHh zo{YUuaFwmFE@(t%iG1yWs>QXgr%gD*U*@yM)x50I?}3r5tTL=jzP`Q-Ka=iny5Mis ze1H70Lo0>olG~ch6K-41w!tdzaXx8G7{+7CHjx7FpMItNOI|Y0#C)A?CMtPo{Oal@pU*ql#eQz+)kSmro?!E& zl7%s?C5%WPgzboc{L!!FG}*Zlw#6y_V{xrKJ&HjtmnaSORznzRX&>FaZEho@beT8e zxGupP_LE6Z9xA8dVWYE^+0GvJGhqQ2%F2V!F@^#hKQ<=*{*~I)9ftAwH+yf7GSMj^ z!J0z-Qn#WYSGF`ErzTN76mwds?0CdBQ7J?xguBRHQh`UHO~#ft88=n zVI@!@eVF~PD4Q-26EMf-lkz7CLoVhb$22`#f9=cfDIu;9W6b~kXuTNs3_L1-Bk)7= z&#y0pqLNO!k`Gnoch8z=jvjW;p9nbCdJGuZxV+u)*VDFqwPgSKkW3b!w`!hWh?t3E z4}yY9e1#;*7mJA0(J?r=^kT~|L-xU_8q=wOzOC;zgiN-bDFKlgKU@%bT~u{PGsnpf z8q(tib{&pa_|(!bTE|Ne2HvDQhfAa1#4>oSc#h2q#c^{?WeD?M9xUTFA@Z@qY7qYl z;JteiJ{f5;GB_o3T4Y|G<4U}NOV}Xq{Sked)Q70nhr)}l7WiTx&pyuLdR?YNf=}c+ z1UoQ@`i&cZ22IdS4H){s+(L`}?O-tvU>)^+fDIEkMbS2hek-)@VBRh|H*f8rSgTg2 zm?}V{JKWd2^DS8KOjrS14+DjJi_$fVoJrV?2%O((XdJ|Ub* z@s{+SncW#O?*BwsBDGlqeO@}B8jO~KXmXm*r*Rfuo6)Dg*P0DIC1JiBsUUT2I?AIS zjbRo>6srw(OHl4$#;E?(5`tU+DH@wS`s=*!vL}@}ssYX*Jl!yVfQZM0asqII*7Sp5E68q?cQtc$HHvAVa+tsvqD4!nkgn7>2 zU#RlM^ceMw#yb>kQ<>V2Xz0bR3L3GzVon}`_(MNW;9IwfmgpF^lqM&_KyRI^fAi)y z^5LMs+b`;DVPTOXs-u*hC7smV1O;bz!#cWp!9uiFfoJ58$AruW7`Ija#8#MetL{Dh z@XR~H^$Ph^ZQGAmCVKIsMWA6%wRv&w!M>-WWsm9ToCl9YawGpao6olMC{1=s(dbU3 zd1Y5|JKUSvqQ8KHQP{R~6!yAQl$4pkXvglfUf_=%{CZT>-{I8A*IW_v$_`NqR~yWn zi4#UvIQq>xRUAsd{*dR~-_LxqY<%`&z)JdIjmCt*7lDsH(y6;uMD|0#{2d{`>6a2m zfCOeHbwV?Y-43yCcb1mI5qekT2wyG_3-QQCLD5^lMwpP&o!69^gzpuweEQq7WnY51 zb((PN$K6hAYV%7AgvZ&az6Yegf@u%#uU$|Q`<+jphe<+`4wN(8eL~_{a<;kM>OJXb zJ|PS07VM?^aO&)hx1%DlY-%sjr|JbCQ;OyhD$Ol2jFvN>59QprT3Fr5?NZC9*sjL> zyime1H_>NzAn_KR^rBDESnLz+s`FWeliP$_wUEl7Mo`5Nr*`G9&-eA7YwneDNjFTT zPQA~azoB-Om=|LvfmGG8iq9XJp|`(Sctf8v`oTe|;Rh zrbHl=L%++FVMDMs98#Hq!P`vU|pITidw5lIXEH8M{BzQg1|U>}*6F zP=@H@LxEhx2s)4*KKtIUEor`HZn=5u*35oVMv|GbPM5;eyWo9%hhMh}8Axy{*vv!a z)T$t|w;r>jtXCaU8#$u(lF%*cR9T{8iB5jpc`uCc9=r^9E!IdPat0GyH2u)?S3y^d zm>t_dQ@f55#qOIi&!TzB@+^yM1#IX`Jmh3)l$sQ|kg!xu?$NN}dU^AANVz{F@#Xwv>b za|z?Zn6vI@uB1Y8tiO@qvzmz%sBtQWpr%S2*)-fT#L1Dme~ck@mxR&tOVM^FROeCo z^^RUM?{Nr!N#rlG?wOHjGm*HYb*Xq9z%j2&sSU+G)+BU$u^-+<8_;v9_Ui<&kJ?;W z;(4GoqefNeoK%%1aVeqrn$X)db=6}LmZC1%hv?)R3Cx}lHQJLseg~AgdgG5LO~%XV zNktP9i8prB_wC~ln7poIe%^9w>AZ^30)~F<(Z83gH?%ICW%(N*TuV>yLbng>E7}%U zA02!YPv1_PsUBQ*`b}~SXS5Erxc2G+*FtBQ81vwo3`B4x^MKahsr^Rp#wnhWk+k~2 zTP8+nW-%->TdWnOA2iJa2pb5lRYBkJXSJ_7tJCcA8f?K{00<(~q=bbr#K(#$3@4pF z(W;2Q%QX;LtEbe@6L!Jjo;6ju(0&SIfL#4yk8io-+TBA&iVCcJZ2~)u?qze1=;n#^ z{dQAhX&dC+c4xQqPhT5H3u>0FkE3@7i+XF5i!t{CeLU84c??aXMg*-^#-Lo5ursEF1j;80$B3o}no$Ugy9&eJ_5Dvc z|A{6`Gt~b`@n#6K$N5k}u&Uj?#h96Z79rw7Kh&;_6CTMnHeU%5JEq!_Fv7$L(hCXUgsEU^ zKCZr2<6^s$jOHIbmC*B*DV3si+>TexQE6 zFsM~$GD>5!lIa9gx}1|y`(KoozB4e%(&RXnPNvZqX#YUzPITwZ)d+0V93(vL zD$3($hsd@Tbf{=NP>Xu@{bo`+V|7>E=V>8mOyPMy$K=aAJb3p|!**w9SH_U?P1g7< zJ!4OeaFzV|>rE%>ncD}YTE*+D?&t7Y`it&kDrwz0V>;$={J!XL!~Pc6+ewLG)?{~? z)}=frQy&wHZo74`rtOgR=)ytom5MRTc6VqAW=z*jH2Hqwu7u~J-bb&9jF;s&$A_yt zt!bURclP>ve>abMhKcEa_~N=rH4z73jN@q|If^r6=e~c!6@9Thtjg?x(X)32l&fqX zG74Vz+hd{D4i0cNm?;NS%9U()p$S+X{4RL0Gk8TS4))ywu|?e^M|Rdd^$hkD`y{vX zk7hhDmsG%kXi_5dImJ@T!Sh=+oALs`tY)5JIU>Pffuccn1ssU1Ce3VZrT&`cAh^i6 ze{{o$1W;Ow)uJX(j~Xp4ds|0-miS(rW^{CPyLX$(+^+{G#FG6W;`Cm1baXIXd8gP- z(I=@Ed$WRr`4U@2R0E*%bx5BXzxHH5&T>a9EbF&pXuSGVRQaBVTvWn)RGqq8v<|7I z4{@H1F+DxxTEkfbfYy9gX%0}*x48h%?_Qrwq_*fd6iYpeGoBy+%w$PfE%kHe!!nFT z`Z3LxX~)wfCe2VSbLwR9cO}d16hh$LhS4)K+0^?4z>LD5uzE;42>tgnc@YMA=|Cm| zV-7anym>Qb5*{$brP|+Y4lD>`w?S+zc=)N|r&_a-fszDcU9`ligoTb4^(mzF;h+M4 z;ri0F9?uo{oe(G6a&VF|PCP+c@B3}h0&?b{NTet8qslcXb9%ZdtlZ>jdDmg8v$T7) zY!&(BD6K&QA-wthg%af+xpWg2y5U#gwG-%ZU~1p!1gDKs`xSLMApk7s4JlmhY3MnH z&SZb0Yu$S8##zfClwdKp7djP=+qyZm_Qv?>4tjG_rMImY~H(+#GG8JGa~X2W+r@pDk`C|Zu^hC&I1 zY&#fkFKRAFYFmj$*yo_UmKD9z6k;`JO8Pjb3~`JAo)aL#I=1S|$q6O^t<}pjD1Z@< zv5^A>kIVIK6v8u1btiT!-w>FqH*`y848FGJz{_}o^$7~`Z(6+2o*L>+JsT~jOQziZ zM14&f6vjaJdHkYTY%ahs_j_aZ5%^tXxB62J@le@_D z^_BxybSl-{&+5tQ$;^GioavdN^&MmYo8qH@s#3up)$qDCLM)=TXxjGUXVco|wK+K} z(I`*gIDNs<2Kv zQ71Lgv;c#H0A07s|L$D_c8l?G+juG=!$5Kp`(ZlG?nfFD4QnRte5{A;PO=yNtgZkm zn8i0bv7Go*ifGM`nw%)o2$D6G7DdejbC3<@%iId)t2Ca8`X3Cm-0ifEPpkp~?OShoO14K-7 z8*1|=+aR9=7sQOvWovG0_G%EzwT8{klN`l+NYISi-Hk%8E&5WRsh0>nGrirkf$TE2 zdR!_SGHw__5*rgsRdSft7=Rj5;DB43SLtU%zGc7OS6xhwvWbyX3u8;l`Q3WN+hL>}GpW8xN&QSdz+~swStsl@iz}9l#&86=5 zCdGk+okrY!uQcY?Z&Ccdz@CJf4hG+nrOPa;B{gf`oPJKWXqi;h$h4!^w4pl5Hldqm zkR7da_v72fW-eN)lJCW)xac;3K=VrpMC z(i%J#T781a;Nh#fVby_fh4DN;94 z`6$^Brz!1sDN(`HnP6oJ~y05hC{g;KMYzO4lU69>(ps>}qk?%ae)4n=- z?{ncUmF15U^~aZt?N)!fZ9lMNfqAW*sF{s~MSs&jOAsekd!KQB6*QW2pePuIK~ zu0xS_YzVw?%Kre=7SrXus-FY#IZ$=>yV| zI>%XV;bJfgZ>tI^txsyFo0zUBY$n|33S}uTw*A-MV?_RDO3eyEU}E3?1RnZd7M+Wk2y5XNw`>&x%z97 zt9sYQMQ6rWs#h^)FeWofGkeq#d^Ww>wI#`w@~2N%OR-~FJ=M=O z`aU*nR2C?Gi2)hr`TS|F=n;_u!rEgM>0IV?d#VOCVgi;fjVH6Mh*$5?iS%ZUDSsGK zzI0BsYlk?6Tax~zOWabS)FjznR86fc76)eA=8exrPiDBJ%8Qx^UpnRVNg5efR9R8v zH42IINEtxIXPNqzZS)WQ{dhTUAS>4?)LV*%FH2gzQ30ZqGg#Sb|0XP-M3bK zW1=(lM0Vn&YE}Bd(pNoAaaC3c35e}eeJdj)1I`=bFYQ0R+9qw~LZ*M9>J{HqO9y2lrbEPQdVgM0)7MYX1I% z2ZGhd$P#~i)ky1zk-Xx5@qdX829O-=4`wq|CnyS+4vD7FJka>JckkMWlN zg#ycyjUOi@e1sny;_5Lp@5r>5CQ~OnCMG7v!^5-FI_c+gT!_f?$6PwRrPmo7|Dr6RaxA=*l$2A^n3HqjBv=;!MSb$C zUsf_OW+a!H-58uenlBLB<2QKs_#M85TvDJHPILq~RznGMe12+DncZJ%zOpLHCNo~f z0+Qj-f20p=qo&5J Papilot -> real review server -> real Git and LaTeX. +// Only the agent is inert. All repositories, state and browser profiles are disposable. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { chromium, expect } from '../engine/node_modules/@playwright/test/index.mjs'; + +const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-batching-')); +process.env.PAPILOT_DATA = path.join(temp, 'data'); +delete process.env.PAPILOT_DESKTOP; +const latexmk = execFileSync('which', ['latexmk'], { encoding: 'utf8' }).trim(); +const calls = path.join(temp, 'compiles.jsonl'), fail = path.join(temp, 'fail'), hold = path.join(temp, 'hold'); +const bin = path.join(temp, 'bin'); fs.mkdirSync(bin); +fs.writeFileSync(path.join(bin, 'latexmk'), `#!${process.execPath} +const fs = require('node:fs'); +const build = process.argv.includes('-outdir=build'); +if (build) fs.appendFileSync(${JSON.stringify(calls)}, JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2) }) + '\\n'); +if (build && fs.existsSync(${JSON.stringify(fail)})) { console.error('Intentional compilation failure'); process.exit(1); } +const deadline = Date.now() + 30000; +while (build && fs.existsSync(${JSON.stringify(hold)}) && Date.now() < deadline) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 30); +const result = require('node:child_process').spawnSync(${JSON.stringify(latexmk)}, process.argv.slice(2), { stdio: 'inherit' }); +process.exit(result.status ?? 1); +`, { mode: 0o755 }); +process.env.PATH = bin + path.delimiter + process.env.PATH; +const [{ Fixture }, { createServer }, { Store }, { Workspace }, { Manuscripts }, { buildApp, gitQueue }, { Updates }, { AGENT_IDS }] = await Promise.all([ + import('../engine/tests/ts/helpers.ts'), import('../engine/src/server.ts'), import('../src/server/store.ts'), + import('../src/server/workspace.ts'), import('../src/server/manuscript.ts'), import('../src/server/app.ts'), + import('../src/server/updates.ts'), import('../src/shared.ts'), +]); +class InertAgent extends EventEmitter { ready = false; close() {} } +const f = new Fixture(), store = new Store(path.join(temp, 'data/state.sqlite3')), manuscripts = new Manuscripts(); +let server, reviewServer, browser, page, workspace, updates; +const count = () => fs.existsSync(calls) ? fs.readFileSync(calls, 'utf8').trim().split('\n').length : 0; +fs.mkdirSync('test-results', { recursive: true }); +const report = { decisions: 0, decisionCompiles: 0, applicationCompiles: 0, refreshes: 0, errors: [] }; +try { + f.write('paper/main.tex', '\\documentclass{article}\n\\begin{document}\n\\input{first}\n\\input{second}\n\\input{third}\n\\end{document}\n'); + for (const name of ['first', 'second', 'third']) f.write(`paper/${name}.tex`, `\\section{${name}}\nOriginal ${name} sentence.\n`); + f.repo.run(['add', '.']); f.repo.run(['commit', '-qm', 'Browser batching fixture']); + const engine = f.app(); await engine.ready; + const proposal = engine.tasks.create('batching', 'Review batching', [], ['paper'], true); + for (const name of ['first', 'second', 'third']) f.edit(proposal, `paper/${name}.tex`, `Original ${name}`, `Revised ${name}`); + engine.publisher.submit(proposal.name); await engine.publisher.join(); + assert.equal(engine.publisher.current(proposal.name)?.status, 'done', JSON.stringify(engine.publisher.current(proposal.name))); + reviewServer = createServer(engine); await reviewServer.listen({ host: '127.0.0.1', port: 0 }); + engine.url = `http://127.0.0.1:${reviewServer.server.address().port}`; + const call = async (route, body) => { + const response = await fetch(engine.url + route, { method: body === undefined ? 'GET' : 'POST', + headers: { 'Content-Type': 'application/json', 'X-Review-Token': engine.csrf }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || response.statusText); + return result; + }; + const client = { url: engine.url, token: engine.csrf, get: route => call(route), post: (route, body = {}) => call(route, body) }; + workspace = new Workspace(store, new InertAgent(), async () => client, Object.fromEntries(AGENT_IDS.filter(id => id !== 'codex').map(id => [id, new InertAgent()]))); + store.saveProject({ id: 'paper', name: 'Batching test paper', path: f.root, createdAt: Date.now() }); + store.saveTask({ id: 'batching', projectId: 'paper', name: proposal.name, title: 'Review batching', instructions: '', worktree: proposal.worktree, + deferred: true, threadId: null, turnId: null, model: null, status: 'reviewing', createdAt: Date.now(), updatedAt: Date.now(), feedbackCursor: 0, error: null, review: null }); + await workspace.refresh('batching'); + const settle = async () => { + let result; + await expect.poll(async () => { result = await manuscripts.state('paper', f.root); return result.build.status; }, { timeout: 60000 }).not.toBe('building'); + return result.build; + }; + assert.equal((await settle()).status, 'ready'); + const refresh = manuscripts.refresh.bind(manuscripts); + manuscripts.refresh = (...args) => { report.refreshes++; return refresh(...args); }; + const listener = { kind: 'local', port: 0, publicOrigin: '', owner: '' }; + updates = new Updates({ root: process.cwd(), data: process.env.PAPILOT_DATA, version: '0.9.2', port: 0, supported: false, busy: () => false, shutdown() {}, latest: async () => '0.9.2' }); + server = await buildApp({ store, workspace, manuscripts, listener, csrf: 'batching-test', serialGit: gitQueue(), dist: path.resolve('dist'), version: '0.9.2', connectReview: async () => client, updates }); + await server.listen({ host: '127.0.0.1', port: 0 }); listener.port = server.server.address().port; + const origin = `http://127.0.0.1:${listener.port}`; + console.log(`Isolated web test: ${origin}, real review service: ${engine.url}`); + browser = await chromium.launch({ channel: process.env.PAPILOT_BROWSER_CHANNEL || 'chrome', headless: true }); + page = await browser.newPage({ locale: 'en-US', viewport: { width: 1100, height: 900 } }); + page.setDefaultTimeout(15000); + page.on('pageerror', error => report.errors.push(error.message)); + page.on('response', response => { if (response.status() >= 500) report.errors.push(`${response.status()} ${response.url()}`); }); + await page.addInitScript(() => { localStorage.setItem('papilot:task', 'batching'); localStorage.setItem('papilot:project', 'paper'); localStorage.setItem('papilot:language', 'en'); }); + await page.goto(origin); + const viewer = () => page.frameLocator('.review-frame'); + await expect(viewer().locator('.change.current')).toBeVisible(); + assert.equal(engine.review.summary(proposal.name).counts.pending, 3); + const start = count(), refreshStart = report.refreshes, round = engine.review.latestRound(proposal.name).number; + const choose = async (action, pending) => { + await viewer().locator('.change.current').getByRole('button', { name: action, exact: true }).click(); + await expect.poll(() => engine.review.summary(proposal.name).counts.pending).toBe(pending); + await expect(viewer().locator('.change.current').getByRole('button', { name: 'Accept', exact: true })).toBeEnabled(); + report.decisions++; + }; + await choose('Accept', 2); + await choose('Reject', 1); + // Undo through the real embedded viewer, then reload with persisted choices. + await viewer().locator('.change.current .change-number').click(); await page.keyboard.press('ControlOrMeta+z'); + await expect.poll(() => engine.review.summary(proposal.name).counts.pending).toBe(2); + await page.reload(); + await expect(viewer().locator('.bar .reject-all')).toBeEnabled(); + await choose('Accept', 1); + await viewer().locator('.change.current').getByRole('button', { name: 'Accept', exact: true }).click(); report.decisions++; + await expect.poll(() => engine.review.summary(proposal.name).counts.pending).toBe(0); + await expect(viewer().getByRole('button', { name: 'Apply to workspace', exact: true })).toBeEnabled(); + await page.reload(); + await expect(viewer().getByRole('button', { name: 'Apply to workspace', exact: true })).toBeEnabled(); + // Wait through the actual periodic review refresh; it must not enqueue a build. + await page.waitForResponse(response => response.url().includes('/api/tasks/batching/state') && response.ok()); + assert.equal(engine.review.latestRound(proposal.name).number, round); + report.decisionCompiles = count() - start; + assert.equal(report.decisionCompiles, 0); + assert.equal(report.refreshes, refreshStart); + for (const name of ['first', 'second', 'third']) assert.match(f.read(`paper/${name}.tex`), /Original/); + await page.screenshot({ path: 'test-results/review-batching-before-apply.png' }); + + await viewer().getByRole('button', { name: 'Apply to workspace', exact: true }).click(); + await expect.poll(() => store.task('batching').delivery?.mode).toBe('workspace'); + assert.equal((await settle()).status, 'ready'); + assert.equal(report.refreshes - refreshStart, 1); + report.applicationCompiles = count() - start; + assert.equal(report.applicationCompiles, 1, 'the final selected version runs one latexmk build'); + assert.match(f.read('paper/first.tex'), /Revised first/); + assert.match(f.read('paper/second.tex'), /Revised second/); + assert.match(f.read('paper/third.tex'), /Revised third/); + console.log('PASS: browser decisions, rejection, undo and reload compile zero times; final application compiles once with the correct text'); + + // A new task exercises a real in-flight comparison, compiler failure and retry. + f.repo.run(['add', '.']); f.repo.run(['commit', '-qm', 'Applied selection']); + const retry = engine.tasks.create('retry', 'Compilation recovery', [], ['paper'], true); + f.edit(retry, 'paper/first.tex', 'Revised first', 'Recovery first'); + f.edit(retry, 'paper/third.tex', 'Revised third', 'Recovery third'); + store.saveTask({ ...store.task('batching'), id: 'retry', name: retry.name, title: 'Compilation recovery', worktree: retry.worktree, delivery: null, review: null }); + await workspace.publish('retry'); + await page.getByRole('button', { name: /^Compilation recovery/ }).click(); + await expect(viewer().locator('.bar .accept-all')).toBeEnabled(); + f.edit(retry, 'paper/first.tex', 'Recovery first', 'Updated first'); + fs.writeFileSync(hold, ''); + await page.locator('.review-rebuild').click(); + await expect(page.locator('.review-rebuild')).toHaveAttribute('aria-busy', 'true'); + await expect(viewer().locator('.bar .status[data-state="building"]')).toHaveCSS('clip-path', 'inset(50%)'); + assert.equal(await viewer().locator('.bar').evaluate(el => el.offsetHeight), 44); + await page.screenshot({ path: 'test-results/review-batching-real-build.png' }); + fs.rmSync(hold); + await expect(page.locator('.review-rebuild')).toHaveAttribute('aria-busy', 'false', { timeout: 60000 }); + await expect(viewer().locator('.bar .accept-all')).toBeEnabled(); + const goodRound = engine.review.latestRound(retry.name).number; + + f.edit(retry, 'paper/first.tex', 'Updated first', 'Broken first'); fs.writeFileSync(fail, ''); + await page.locator('.review-rebuild').click(); + await expect(page.locator('.review-rebuild')).toHaveAttribute('data-state', 'failed', { timeout: 60000 }); + await expect(viewer().locator('.bar .status[data-state="build_failed"]')).toBeVisible(); + await expect(viewer().locator('.bar .accept-all')).toBeEnabled(); + assert.equal(engine.review.latestRound(retry.name).number, goodRound, 'a failed build preserves the last review'); + await expect(viewer().locator('.change.current')).toBeVisible(); + await page.screenshot({ path: 'test-results/review-batching-failed.png' }); + fs.rmSync(fail); await page.locator('.review-rebuild').click(); + await expect(page.locator('.review-rebuild')).toHaveAttribute('data-state', 'ready', { timeout: 60000 }); + await expect(viewer().locator('.bar .accept-all')).toBeEnabled(); + await expect(viewer().locator('.bar .status')).toHaveCount(0); + assert.deepEqual(report.errors, []); + console.log('PASS: real comparison progress, old preview retention, failed compilation and retry recover without stuck controls'); + fs.writeFileSync('test-results/review-batching.json', JSON.stringify(report, null, 2)); +} catch (error) { + console.error(error); + console.error('Task at failure:', JSON.stringify(store.tasks())); + if (page) { await page.screenshot({ path: 'test-results/review-batching-error.png' }); fs.writeFileSync('test-results/review-batching-error.txt', await page.locator('body').innerText()); } + throw error; +} finally { + fs.rmSync(hold, { force: true }); + if (browser) await browser.close(); + if (server) await server.close(); + workspace?.close(); updates?.close(); + await manuscripts.stop(); + if (reviewServer) await reviewServer.close(); + await f.close(); store.close(); + fs.rmSync(temp, { recursive: true, force: true }); +} diff --git a/scripts/check-review-flow.mjs b/scripts/check-review-flow.mjs index 293ef682..d7e6b298 100644 --- a/scripts/check-review-flow.mjs +++ b/scripts/check-review-flow.mjs @@ -25,7 +25,7 @@ const actions = [], writes = [], messageRequests = [], commitRequests = [], appl let gitFiles = []; let pushError, conflictOnApply = false, combinedOnCommit = false; let timeline = []; -const state = round => ({ task: 'flow', title: task.title, csrf: 'fixture', closed: false, state: task.review.state, latest, history: Object.keys(rounds).map(number => ({ number: Number(number), created: 1793322000 + Number(number) * 120 })), round, revision: `after-${round}`, decisions: rounds[round], comments: {}, undo, job: null }); +const state = round => ({ task: 'flow', title: task.title, csrf: 'fixture', closed: false, state: task.review.state, latest, history: Object.keys(rounds).map(number => ({ number: Number(number), created: 1793322000 + Number(number) * 120 })), round, revision: `after-${round}`, decisions: rounds[round], comments: {}, undo, job: task.review.job || null }); function update() { const values = Object.values(rounds[latest]); task.review.counts = Object.fromEntries(['pending', 'accepted', 'rejected', 'commented'].map(value => [value, values.filter(v => v === value).length])); @@ -355,6 +355,38 @@ try { await expect(viewer().getByRole('progressbar')).toBeInViewport(); await expect.poll(() => viewer().locator('.change.current .dock').evaluate(el => getComputedStyle(el).position)).not.toBe('fixed'); } + // An engine-side build without a host spinner must keep its own progress. + task.review.state = 'building'; task.review.job = { id: 'engine-build', status: 'running', phase: 'build' }; + await sync(); + await expect(page.locator('.review-rebuild')).toHaveAttribute('aria-busy', 'false'); + await expect(viewer().locator('.bar .status[data-state="building"]')).toHaveCSS('clip-path', 'none'); + delete task.review.job; update(); await sync(); + // Rebuilding an existing comparison keeps the toolbar's height. In + // particular, the phase label must not wrap below the batch buttons. + for (const language of ['en', 'zh-CN']) { + await page.evaluate(language => localStorage.setItem('papilot:language', language), language); + await page.reload(); + await viewer().locator('.bar .accept-all').waitFor(); + for (const width of [1440, 1100, 900]) { + await page.setViewportSize({ width, height: 1000 }); + const bar = viewer().locator('.bar'); + await expect.poll(() => bar.evaluate(el => el.offsetHeight)).toBe(44); + task.status = 'building'; task.review.state = 'building'; + task.review.job = { id: 'toolbar-build', status: 'running', phase: 'build' }; + await sync(); + await expect(page.locator('.review-rebuild')).toHaveAttribute('aria-busy', 'true'); + await expect(bar.locator('.status[data-state="building"]')).toHaveCSS('clip-path', 'inset(50%)'); + await expect(bar.locator('.accept-all')).toBeDisabled(); + assert.equal(await bar.evaluate(el => el.offsetHeight), 44, 'compilation does not add a toolbar row'); + if (language === 'zh-CN' && width === 1100) await page.screenshot({ path: 'test-results/review-building-zh.png' }); + task.status = 'reviewing'; delete task.review.job; update(); await sync(); + await expect(bar.locator('.accept-all')).toBeEnabled(); + await expect(page.locator('.review-rebuild')).toHaveAttribute('aria-busy', 'false'); + } + } + await page.evaluate(() => localStorage.setItem('papilot:language', 'en')); + await page.reload(); + await viewer().locator('.change.current').waitFor(); // The shared reader switches to phone controls at 680px. The toolbar can // take a second row, preserving progress, navigation and tools. for (const width of [680, 640, 390, 320]) { @@ -368,6 +400,16 @@ try { await expect(page.locator('.review-tools .review-rebuild')).toBeInViewport(); await expect.poll(() => viewer().locator('.change.current .dock').evaluate(el => getComputedStyle(el).position)).toBe('fixed'); assert.equal(await viewer().locator('#paper').evaluate(el => el.scrollWidth > el.clientWidth + 1), false); + await expect(viewer().locator('.bar .status[data-state="building"]')).toHaveCount(0); + const height = await viewer().locator('.bar').evaluate(el => el.offsetHeight); + task.status = 'building'; task.review.state = 'building'; task.review.job = { id: 'phone-build', status: 'running', phase: 'build' }; + await sync(); + await expect(page.locator('.review-rebuild')).toHaveAttribute('aria-busy', 'true'); + await expect(viewer().locator('.bar .status[data-state="building"]')).toHaveCSS('clip-path', 'inset(50%)'); + assert.equal(await viewer().locator('.bar').evaluate(el => el.offsetHeight), height, 'phone compilation preserves toolbar height'); + task.status = 'reviewing'; delete task.review.job; update(); await sync(); + await expect(page.locator('.review-rebuild')).toHaveAttribute('aria-busy', 'false'); + await expect(viewer().locator('.bar .status[data-state="building"]')).toHaveCount(0); } await page.setViewportSize({ width: 1440, height: 1000 }); // Resizing can make a different change the visible navigation target. diff --git a/scripts/check-source-edit.mjs b/scripts/check-source-edit.mjs index 023a00ad..d928ad84 100644 --- a/scripts/check-source-edit.mjs +++ b/scripts/check-source-edit.mjs @@ -19,6 +19,7 @@ await server.listen({ host: '127.0.0.1', port: 0 }); app.url = `http://127.0.0.1:${server.server.address().port}`; const browser = await chromium.launch({ channel: process.env.PAPILOT_BROWSER_CHANNEL || 'chrome', headless: true }); const page = await browser.newPage({ viewport: { width: 1200, height: 850 } }); +page.setDefaultTimeout(15000); const errors = []; page.on('pageerror', error => errors.push(error.message)); const edit = () => page.locator('.source-editor').getByRole('button', { name: '编辑', exact: true }); const field = () => page.getByRole('textbox', { name: '编辑 LaTeX 源码' }); @@ -26,13 +27,13 @@ const save = () => page.getByRole('button', { name: '保存并更新 PDF', exact const waitRound = number => expect.poll(() => app.review.latestRound(task.name)?.number, { timeout: 90000 }).toBe(number); const open = async () => { const source = page.locator('.source'); - if (!await source.isVisible()) await page.getByRole('button', { name: 'LaTeX', exact: true }).click(); + if (!await source.isVisible()) await page.locator('.change.current').getByRole('button', { name: 'LaTeX', exact: true }).click(); await edit().click(); }; try { await page.goto(app.url + '/?task=manual&lang=zh-Hans'); - await expect(page.locator('.change')).toBeVisible(); - await page.getByRole('button', { name: 'LaTeX', exact: true }).click(); + await expect(page.locator('.change.current')).toBeVisible(); + await page.locator('.change.current').getByRole('button', { name: 'LaTeX', exact: true }).click(); const originalBox = await page.locator('.source-code').boundingBox(); await open(); await expect(page.locator('.source-code')).toHaveCount(1); @@ -113,6 +114,10 @@ Literal symbols: \% and \$; \verb|% $ { }|. assert.equal(f.read(METHOD, task.worktree), f.read(METHOD)); assert.deepEqual(errors, []); console.log('Source editing passed: one source surface, in-place edit and diff switching, header icons, syntax highlighting, wrapping and scrolling, draft recovery, keyboard save, real PDF rebuild, stale write refusal, compilation repair, mobile layout, empty review.'); +} catch (error) { + console.error(error); + await page.screenshot({ path: 'test-results/source-edit-error.png' }).catch(() => {}); + throw error; } finally { await browser.close(); await server.close(); await f.close(); } diff --git a/src/server/app.ts b/src/server/app.ts index 8ea5d738..82bd5d94 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -36,8 +36,6 @@ import { systemRequirements } from './system-requirements.ts'; import { findExecutable } from '../../bin/agent-paths.mjs'; const importsByWorkspace = new WeakMap(); -// Each workspace compiles predicted manuscripts once, however many listeners serve it. -const predicting = new WeakSet(); // Git writes to one repository run one at a time; each returns the new status. // Concurrent requests from browser tabs share the same queue. @@ -119,33 +117,28 @@ export async function buildApp(options: AppOptions) { if (id) projectRequests.set(request, workspace.enterProjectRequest(id)); }); app.addHook('onSend', async (request, _reply, payload) => { projectRequests.get(request)?.(); return payload; }); - // Whatever may have written a paper's files compiles its manuscript right - // away: committing or applying a task, a Git action, a review decision. The - // PDF is on its way before anyone opens it, and a tree that did not change - // costs one snapshot. + // Compile after applying the reviewed result. Individual decisions only + // update the proposal; they must not start manuscript or speculative builds. const WRITES = /^\/api\/projects\/:id\/git\/|^\/api\/tasks\/:id\/(?:commit|apply|reconcile)$|^\/review\/:projectId\/(?:api\/changes\/(?:decide|undo)|\*)$/; + const completedTaskWrites = new WeakSet(); app.addHook('onResponse', async (request, reply) => { if (request.method !== 'POST' || reply.statusCode >= 400 || !WRITES.test(request.routeOptions.url || '')) return; const params = (request.params || {}) as Record; try { + // Save routes can return a confirmation or merge suggestion without + // changing the manuscript. A status check is not itself a write either. + if (request.routeOptions.url?.startsWith('/api/tasks/') && !completedTaskWrites.has(request)) return; + if (request.routeOptions.url === '/review/:projectId/*') { + // Older tasks write accepted edits directly into the manuscript. + // Batch those too, until the last decision; notes never need a build. + const task = store.tasks().find(task => task.projectId === params.projectId && task.name === params['*']?.split('/')[2]); + if (!task || task.deferred || !/\/(decide|undo)$/.test(params['*'] || '') + || (request.body as any)?.action === 'comment' || !task.review || (task.review.counts.pending ?? 0) > 0) return; + } const project = store.project(params.projectId ?? (request.routeOptions.url!.startsWith('/api/tasks/') ? store.task(params.id).projectId : params.id)); void manuscripts.refresh(project.id, project.path).catch(() => undefined); } catch { /* The paper or task is gone. */ } }); - // Prepare the manuscript only after the author has decided the proposal. - // Pending chapter edits must not trigger speculative full-paper builds. - const predict = async (taskId: string) => { - const task = store.task(taskId), project = store.project(task.projectId); - if (!task.deferred || task.mode === 'review' || task.delivery?.pending || task.status === 'deleting' || (task.review?.counts.pending ?? 0) > 0) return; - const client = await connectReview(project.path); - const preview = await client.get<{ files: Record | null }>(`${taskRoute(task.name)}/preview`); - if (preview.files) await manuscripts.predict(project.id, project.path, Object.fromEntries(Object.entries(preview.files).map(([file, data]) => [file, data === null ? null : Buffer.from(data, 'base64')]))); - }; - if (!predicting.has(workspace)) { - predicting.add(workspace); - workspace.on('reviewed', (taskId: string) => void predict(taskId).catch(() => undefined)); - // The review's complete PDF is compiled on demand when opened. - } app.addHook('onError', async request => { projectRequests.get(request)?.(); }); await app.register(compress); app.setErrorHandler((error, _request, reply) => { reply.code((error as any).statusCode || 400).send({ error: (error as Error).message }); }); @@ -355,7 +348,7 @@ export async function buildApp(options: AppOptions) { return value; }, { route, body: input }); if (result.error) throw new Error(result.error); - await workspace.refresh(current.id); workspace.emit('reviewed', current.id); + await workspace.refresh(current.id); return (await noteOptionContext(store, store.task(current.id), body.key, client)).options; } const route = `${taskRoute(current.name)}/source/options`, input = { id: string(body.id, 'Option request ID', 80), round: body.round, edits: option.edits, ...(body.accept ? { accept: true } : {}) }; @@ -413,14 +406,18 @@ export async function buildApp(options: AppOptions) { return workspace.enqueue(task.id, text, string(b.requestKey, 'Request ID', 100), true, { skill, feedback, reviewNotes, attachments }); }); // Commits what the author accepted from this task, with the message they confirmed. - app.post<{ Params: { id: string } }>('/api/tasks/:id/commit', task, request => { + app.post<{ Params: { id: string } }>('/api/tasks/:id/commit', task, async request => { const task = store.task(request.params.id), b = request.body as any; - return serialGit(task.projectId, () => workspace.commitTask(task.id, string(b.message, 'Commit message', 5000), b.push === true)); + const result = await serialGit(task.projectId, () => workspace.commitTask(task.id, string(b.message, 'Commit message', 5000), b.push === true)); + if (result.commit) completedTaskWrites.add(request); + return result; }); - app.post<{ Params: { id: string } }>('/api/tasks/:id/apply', task, request => { + app.post<{ Params: { id: string } }>('/api/tasks/:id/apply', task, async request => { const task = store.task(request.params.id), b = (request.body || {}) as any; if (b.mode !== undefined && b.mode !== 'workspace' && b.mode !== 'commit') throw new Error('Invalid save action.'); - return serialGit(task.projectId, () => workspace.applyTask(task.id, b.keepCurrent === true, b.mode === 'commit' ? { message: string(b.message, 'Commit message', 5000), ...(b.confirmation ? { confirmation: string(b.confirmation, 'Commit confirmation', 100) } : {}) } : undefined)); + const result = await serialGit(task.projectId, () => workspace.applyTask(task.id, b.keepCurrent === true, b.mode === 'commit' ? { message: string(b.message, 'Commit message', 5000), ...(b.confirmation ? { confirmation: string(b.confirmation, 'Commit confirmation', 100) } : {}) } : undefined)); + if (result.delivery && !result.delivery.pending && result.delivery.mode !== 'branch') completedTaskWrites.add(request); + return result; }); app.post<{ Params: { id: string } }>('/api/tasks/:id/commit-message', task, request => { const task = store.task(request.params.id); @@ -443,7 +440,13 @@ export async function buildApp(options: AppOptions) { if ((request.body as any)?.confirm !== true) throw new Error('Confirm permanent task deletion before continuing.'); return workspace.deleteTask(request.params.id); }); - app.post<{ Params: { id: string } }>('/api/tasks/:id/reconcile', task, request => workspace.reconcile(request.params.id)); + app.post<{ Params: { id: string } }>('/api/tasks/:id/reconcile', task, async request => { + const before = store.task(request.params.id), result = await workspace.reconcile(before.id); + // A recovered final decision can write directly to a legacy manuscript. + if (!before.deferred && before.pendingReview && /\/(decide|undo)$/.test(before.pendingReview.route) + && !result.pendingReview && !result.error && result.review?.state === 'done' && !result.review.counts.pending) completedTaskWrites.add(request); + return result; + }); app.post<{ Params: { id: string } }>('/api/tasks/:id/publish', task, request => { const task = store.task(request.params.id); if (['running', 'waiting_input', 'building'].includes(task.status)) throw new Error('This task is busy. Wait for it to finish or pause it first.'); void workspace.publish(task.id); return { ok: true }; @@ -705,8 +708,6 @@ export async function buildApp(options: AppOptions) { if (task) { store.updateTask(task.id, { error: null }); await workspace.refresh(task.id); - // A decision changes what applying the task will write. - workspace.emit('reviewed', task.id); } } // A round's new version as one complete PDF, which Papilot compiles, diff --git a/src/server/workspace.ts b/src/server/workspace.ts index 5a8a0e90..fd6b8362 100644 --- a/src/server/workspace.ts +++ b/src/server/workspace.ts @@ -968,8 +968,6 @@ ${this.agentOf(task) === 'antigravity' ? 'Use file tools for manuscript edits. S await delay(1500); } await this.refresh(taskId); - // What applying the new round will write can now be compiled ahead. - this.emit('reviewed', taskId); } catch (e) { if (this.closing) return; if (reviewConnectionError(e)) { diff --git a/src/web/review-frame.tsx b/src/web/review-frame.tsx index 086b17ad..a2ae7bdf 100644 --- a/src/web/review-frame.tsx +++ b/src/web/review-frame.tsx @@ -228,6 +228,9 @@ function compactReviewFrame(doc: Document, reserve: number, lead: number, change /* Reserve the outer tools' row when there is too little room. Keep progress, batch counts and history; shorten only the batch labels. */ .bar{container:review-bar/inline-size;height:auto;gap:8px} + /* The host's compile button already shows build activity. Keep the phase + available to screen readers without pushing decisions into another row. */ + :root[data-papilot-build-indicator] .bar .status[data-state="building"]{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%)} .bar .accept-all,.bar .reject-all{flex-shrink:0;white-space:nowrap} .bar .batch-count{margin-left:0;background:var(--bg-muted);color:var(--text-2)} :root[data-papilot-toolbar-stacked] .bar{padding:calc(var(--papilot-bar-height,44px) + 4px) 8px 4px;min-height:calc(var(--papilot-bar-height,44px) + 44px)} @@ -371,6 +374,11 @@ export function ReviewFrame({ task, changesOf, tools, lead, completion, onCommen useEffect(() => { document?.defaultView?.dispatchEvent(new CustomEvent(VIEW_EVENT, { detail: view })); }, [document, view]); + useEffect(() => { + // Manuscript comparisons have their own build. Hide the duplicate phase + // only while the task's host toolbar is showing its compilation spinner. + document?.documentElement.toggleAttribute('data-papilot-build-indicator', task?.status === 'building'); + }, [document, task?.status]); useEffect(() => { const target = document?.defaultView; if (!target) return; diff --git a/tests/app.test.ts b/tests/app.test.ts index df30a3db..8374af7b 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -944,7 +944,44 @@ test('inline source writes use the review token and reserve the task while its P } finally { finish({ status: 'done' }); engine.closeAllConnections(); await new Promise(resolve => engine.close(() => resolve())); await f.close(); } }); -test('writing a paper compiles its manuscript, while pending chapter proposals do not precompile the whole paper', async () => { +test('save previews and status checks do not compile an unapplied manuscript', async () => { + const f = await fixture(); + let refreshes = 0; + f.manuscripts.refresh = async () => { refreshes++; }; + const headers = { ...LOCAL, 'x-papilot-token': 'local-csrf' }; + try { + for (const result of [ + { commitReview: { token: 'confirm', diff: 'Review these changes first.' } }, + { reconciliation: { id: 'merge', ready: true } }, + { delivery: { mode: 'workspace', pending: true } }, + { delivery: { mode: 'branch' } }, + ]) { + f.workspace.applyTask = async () => result as any; + f.workspace.commitTask = async () => result as any; + for (const route of ['apply', 'commit']) { + const response = await f.local.inject({ method: 'POST', url: `/api/tasks/thesis-task/${route}`, headers, payload: { mode: 'commit', message: 'Save changes' } }); + assert.equal(response.statusCode, 200, response.body); + assert.equal(refreshes, 0, `${route} must wait for confirmed manuscript delivery`); + } + } + // A successful status check can merely confirm a decision or agent run. + f.store.updateTask('thesis-task', { deferred: true, review: { state: 'done', round: 1, counts: { pending: 0 }, unsent: 0 } }); + f.workspace.reconcile = async () => f.store.task('thesis-task'); + const checked = await f.local.inject({ method: 'POST', url: '/api/tasks/thesis-task/reconcile', headers, payload: {} }); + assert.equal(checked.statusCode, 200, checked.body); + assert.equal(refreshes, 0); + // Recovering a lost decision response obeys the same batching boundary. + for (const [deferred, pending, expected] of [[true, 0, 0], [false, 1, 0], [false, 0, 1]] as const) { + f.store.updateTask('thesis-task', { deferred, status: 'uncertain', pendingReview: { route: '/api/tasks/p-thesis-task/decide', body: { id: 'lost', action: 'accept' }, previousStatus: 'reviewing' } }); + f.workspace.reconcile = async () => f.store.updateTask('thesis-task', { status: 'reviewing', pendingReview: null, review: { state: pending ? 'reviewing' : 'done', round: 1, counts: { pending }, unsent: 0 } }); + const recovered = await f.local.inject({ method: 'POST', url: '/api/tasks/thesis-task/reconcile', headers, payload: {} }); + assert.equal(recovered.statusCode, 200, recovered.body); + assert.equal(refreshes, expected); + } + } finally { await f.close(); } +}); + +test('review decisions wait until application to compile the manuscript', async () => { const f = await fixture(); try { const refreshed: string[][] = [], predicted: [string, string, Record][] = []; @@ -960,30 +997,48 @@ test('writing a paper compiles its manuscript, while pending chapter proposals d assert.equal(failed.statusCode, 400); await new Promise(resolve => setImmediate(resolve)); assert.equal(refreshed.length, 1, 'a request that changed nothing compiles nothing'); - // A review action through the review page, which may write the manuscript. - const engine = http.createServer((_request, response) => { response.setHeader('Content-Type', 'application/json'); response.end('{}'); }); + // Exercise the real review proxy, including its response hook, rather + // than just emitting the event that previously started speculative builds. + let pending = 2; + f.store.updateTask('thesis-task', { deferred: true }); + f.workspace.refresh = async () => { + f.store.updateTask('thesis-task', { review: { state: pending ? 'reviewing' : 'done', round: 1, counts: { pending }, unsent: 0 } }); + }; + const engine = http.createServer((_request, response) => { + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({ task: 'p-thesis-task', csrf: 'review-token', closed: false, decisions: {}, job: null })); + }); await new Promise(resolve => engine.listen(0, '127.0.0.1', resolve)); f.review.url = `http://127.0.0.1:${(engine.address() as { port: number }).port}`; try { - const noted = await f.local.inject({ method: 'POST', url: '/review/thesis/api/tasks/p-thesis-task/note', headers: { ...LOCAL, 'x-review-token': 'review-token' }, payload: { body: 'A note.' } }); - assert.equal(noted.statusCode, 200, noted.body); + const review = async (route: string, action?: string) => { + const response = await f.local.inject({ method: 'POST', url: `/review/thesis/api/tasks/p-thesis-task/${route}`, headers: { ...LOCAL, 'x-review-token': 'review-token' }, payload: { id: crypto.randomUUID(), action, body: 'A note.' } }); + assert.equal(response.statusCode, 200, response.body); + await new Promise(resolve => setImmediate(resolve)); + }; + for (const [route, action, remaining] of [['decide', 'accept', 1], ['decide', 'reject', 0], ['undo', undefined, 1], ['decide', 'accept', 0], ['note', undefined, 0]] as const) { + pending = remaining; + await review(route, action); + assert.equal(refreshed.length, 1, `${route} does not refresh the manuscript`); + assert.deepEqual(predicted, [], `${route} does not speculate on a result that can still be undone`); + } + f.workspace.applyTask = async () => ({ delivery: { mode: 'workspace', accepted: 'accepted' } }) as any; + const applied = await f.local.inject({ method: 'POST', url: '/api/tasks/thesis-task/apply', headers, payload: { mode: 'workspace' } }); + assert.equal(applied.statusCode, 200, applied.body); await new Promise(resolve => setImmediate(resolve)); - assert.deepEqual(refreshed[1], ['thesis', '/unused/thesis'], 'a review action through the review page compiles too'); - } finally { engine.close(); } - f.store.updateTask('thesis-task', { deferred: true, review: { state: 'reviewing', round: 1, counts: { pending: 1 }, unsent: 0 } } as any); - f.review.get = async (route: string) => (route.endsWith('/preview') ? { files: { 'main.tex': Buffer.from('Applied.\n').toString('base64'), 'old.tex': null } } : {}) as any; - f.workspace.emit('publishing', 'thesis-task', 'proposed-revision'); - f.workspace.emit('reviewed', 'thesis-task'); - await new Promise(resolve => setTimeout(resolve, 20)); - assert.deepEqual(predicted, [], 'undecided proposals do not start a speculative full manuscript build'); - f.store.updateTask('thesis-task', { review: { state: 'done', round: 1, counts: { accepted: 1 }, unsent: 0 } }); - f.workspace.emit('reviewed', 'thesis-task'); - for (let i = 0; i < 20 && !predicted.length; i++) await new Promise(resolve => setTimeout(resolve, 5)); - assert.deepEqual(predicted, [['thesis', '/unused/thesis', { 'main.tex': 'Applied.\n', 'old.tex': null }]], 'once, though two listeners serve the workspace'); - f.store.updateTask('thesis-task', { deferred: false } as any); - f.workspace.emit('reviewed', 'thesis-task'); - await new Promise(resolve => setTimeout(resolve, 20)); - assert.equal(predicted.length, 1, 'a task that applies as it is reviewed needs no prediction'); + assert.equal(refreshed.length, 2, 'application starts one manuscript refresh'); + + // Legacy tasks write to the manuscript while reviewing. They refresh + // only once the selection is complete, and never for a note or undo. + f.store.updateTask('thesis-task', { deferred: false }); + pending = 1; await review('decide', 'accept'); + assert.equal(refreshed.length, 2); + pending = 0; await review('decide', 'reject'); + assert.equal(refreshed.length, 3); + pending = 1; await review('undo'); + await review('note'); + assert.equal(refreshed.length, 3); + } finally { engine.closeAllConnections(); await new Promise(resolve => engine.close(() => resolve())); } } finally { await f.close(); } }); From 5d3ee1794c680434c5bb68780dde34b6b0a828c4 Mon Sep 17 00:00:00 2001 From: Cui Chen Date: Mon, 9 Nov 2026 16:18:19 +0800 Subject: [PATCH 02/12] Flush completed legacy review batches with revision feedback --- .github/workflows/ci.yml | 7 +++++++ src/server/app.ts | 7 ++++--- tests/app.test.ts | 14 ++++++++++---- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e670f57..36a2aab6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,13 @@ jobs: - run: npm run i18n -- check - run: npm run i18n:check-app - run: npm run build + - name: Review batching and toolbar browser regressions + if: matrix.node == 22 + run: | + node --import tsx scripts/check-review-batching.mjs + node --import tsx scripts/check-review-flow.mjs + env: + PAPILOT_BROWSER_CHANNEL: chromium # Checks the generated UI demos and local asset links before main. - run: npm run site - name: Verify the static playground without a backend diff --git a/src/server/app.ts b/src/server/app.ts index 82bd5d94..95dfce38 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -130,10 +130,11 @@ export async function buildApp(options: AppOptions) { if (request.routeOptions.url?.startsWith('/api/tasks/') && !completedTaskWrites.has(request)) return; if (request.routeOptions.url === '/review/:projectId/*') { // Older tasks write accepted edits directly into the manuscript. - // Batch those too, until the last decision; notes never need a build. + // A final comment also flushes any earlier accepted changes. Notes + // do not resolve a pending choice and need no refresh of their own. const task = store.tasks().find(task => task.projectId === params.projectId && task.name === params['*']?.split('/')[2]); if (!task || task.deferred || !/\/(decide|undo)$/.test(params['*'] || '') - || (request.body as any)?.action === 'comment' || !task.review || (task.review.counts.pending ?? 0) > 0) return; + || !task.review || (task.review.counts.pending ?? 0) > 0) return; } const project = store.project(params.projectId ?? (request.routeOptions.url!.startsWith('/api/tasks/') ? store.task(params.id).projectId : params.id)); void manuscripts.refresh(project.id, project.path).catch(() => undefined); @@ -444,7 +445,7 @@ export async function buildApp(options: AppOptions) { const before = store.task(request.params.id), result = await workspace.reconcile(before.id); // A recovered final decision can write directly to a legacy manuscript. if (!before.deferred && before.pendingReview && /\/(decide|undo)$/.test(before.pendingReview.route) - && !result.pendingReview && !result.error && result.review?.state === 'done' && !result.review.counts.pending) completedTaskWrites.add(request); + && !result.pendingReview && !result.error && result.review && !result.review.counts.pending) completedTaskWrites.add(request); return result; }); app.post<{ Params: { id: string } }>('/api/tasks/:id/publish', task, request => { diff --git a/tests/app.test.ts b/tests/app.test.ts index 8374af7b..aa293d87 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -971,9 +971,9 @@ test('save previews and status checks do not compile an unapplied manuscript', a assert.equal(checked.statusCode, 200, checked.body); assert.equal(refreshes, 0); // Recovering a lost decision response obeys the same batching boundary. - for (const [deferred, pending, expected] of [[true, 0, 0], [false, 1, 0], [false, 0, 1]] as const) { + for (const [deferred, pending, state, expected] of [[true, 0, 'revise', 0], [false, 1, 'reviewing', 0], [false, 0, 'done', 1], [false, 0, 'revise', 2]] as const) { f.store.updateTask('thesis-task', { deferred, status: 'uncertain', pendingReview: { route: '/api/tasks/p-thesis-task/decide', body: { id: 'lost', action: 'accept' }, previousStatus: 'reviewing' } }); - f.workspace.reconcile = async () => f.store.updateTask('thesis-task', { status: 'reviewing', pendingReview: null, review: { state: pending ? 'reviewing' : 'done', round: 1, counts: { pending }, unsent: 0 } }); + f.workspace.reconcile = async () => f.store.updateTask('thesis-task', { status: 'reviewing', pendingReview: null, review: { state, round: 1, counts: { pending }, unsent: 0 } }); const recovered = await f.local.inject({ method: 'POST', url: '/api/tasks/thesis-task/reconcile', headers, payload: {} }); assert.equal(recovered.statusCode, 200, recovered.body); assert.equal(refreshes, expected); @@ -999,10 +999,10 @@ test('review decisions wait until application to compile the manuscript', async assert.equal(refreshed.length, 1, 'a request that changed nothing compiles nothing'); // Exercise the real review proxy, including its response hook, rather // than just emitting the event that previously started speculative builds. - let pending = 2; + let pending = 2, commented = false; f.store.updateTask('thesis-task', { deferred: true }); f.workspace.refresh = async () => { - f.store.updateTask('thesis-task', { review: { state: pending ? 'reviewing' : 'done', round: 1, counts: { pending }, unsent: 0 } }); + f.store.updateTask('thesis-task', { review: { state: pending ? 'reviewing' : commented ? 'revise' : 'done', round: 1, counts: { pending }, unsent: 0 } }); }; const engine = http.createServer((_request, response) => { response.setHeader('Content-Type', 'application/json'); @@ -1038,6 +1038,12 @@ test('review decisions wait until application to compile the manuscript', async pending = 1; await review('undo'); await review('note'); assert.equal(refreshed.length, 3); + // A final comment resolves the last pending choice without writing a + // file, but must flush changes accepted earlier in this legacy batch. + await review('decide', 'accept'); + assert.equal(refreshed.length, 3); + pending = 0; commented = true; await review('decide', 'comment'); + assert.equal(refreshed.length, 4); } finally { engine.closeAllConnections(); await new Promise(resolve => engine.close(() => resolve())); } } finally { await f.close(); } }); From 3b7e013c050e6bd1946bf500bd70aa80079206ab Mon Sep 17 00:00:00 2001 From: Cui Chen Date: Mon, 9 Nov 2026 16:25:17 +0800 Subject: [PATCH 03/12] Recognize deferred commit deliveries without a commit hash --- src/server/app.ts | 7 ++++--- tests/app.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/server/app.ts b/src/server/app.ts index 95dfce38..dc6ea512 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -20,7 +20,7 @@ import { gitOverview } from './git-overview.ts'; import { findSkill, listedSkill, loadSkills } from './skills.ts'; import { registerSkillRoutes } from './skill-routes.ts'; import { type Listener, equals, guard } from './access.ts'; -import { isTaskReviewNote, isAgentId, isMarkKind, PRESET_QUICK_NOTES, QUICK_NOTE_LIMIT, type AgentId, type NoteSummary, type QuickNote } from '../shared.ts'; +import { isTaskReviewNote, isAgentId, isMarkKind, PRESET_QUICK_NOTES, QUICK_NOTE_LIMIT, type AgentId, type NoteSummary, type QuickNote, type Task } from '../shared.ts'; import { parseGroupInstructions } from '../note-groups.ts'; import { noteOptionContext, noteOptionsKey } from './note-options.ts'; import { taskNoteResults } from './note-results.ts'; @@ -36,6 +36,7 @@ import { systemRequirements } from './system-requirements.ts'; import { findExecutable } from '../../bin/agent-paths.mjs'; const importsByWorkspace = new WeakMap(); +const deliveredToManuscript = (delivery: Task['delivery']) => Boolean(delivery && !delivery.pending && delivery.mode !== 'branch'); // Git writes to one repository run one at a time; each returns the new status. // Concurrent requests from browser tabs share the same queue. @@ -410,14 +411,14 @@ export async function buildApp(options: AppOptions) { app.post<{ Params: { id: string } }>('/api/tasks/:id/commit', task, async request => { const task = store.task(request.params.id), b = request.body as any; const result = await serialGit(task.projectId, () => workspace.commitTask(task.id, string(b.message, 'Commit message', 5000), b.push === true)); - if (result.commit) completedTaskWrites.add(request); + if ('delivery' in result ? deliveredToManuscript(result.delivery) : result.commit) completedTaskWrites.add(request); return result; }); app.post<{ Params: { id: string } }>('/api/tasks/:id/apply', task, async request => { const task = store.task(request.params.id), b = (request.body || {}) as any; if (b.mode !== undefined && b.mode !== 'workspace' && b.mode !== 'commit') throw new Error('Invalid save action.'); const result = await serialGit(task.projectId, () => workspace.applyTask(task.id, b.keepCurrent === true, b.mode === 'commit' ? { message: string(b.message, 'Commit message', 5000), ...(b.confirmation ? { confirmation: string(b.confirmation, 'Commit confirmation', 100) } : {}) } : undefined)); - if (result.delivery && !result.delivery.pending && result.delivery.mode !== 'branch') completedTaskWrites.add(request); + if (deliveredToManuscript(result.delivery)) completedTaskWrites.add(request); return result; }); app.post<{ Params: { id: string } }>('/api/tasks/:id/commit-message', task, request => { diff --git a/tests/app.test.ts b/tests/app.test.ts index aa293d87..1f960930 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -981,6 +981,29 @@ test('save previews and status checks do not compile an unapplied manuscript', a } finally { await f.close(); } }); +test('deferred commits refresh only after a completed manuscript delivery, even without a commit hash', async () => { + const f = await fixture(); + let refreshes = 0; + f.manuscripts.refresh = async () => { refreshes++; }; + f.store.updateTask('thesis-task', { deferred: true }); + try { + // Use the real commitTask adapter: current-branch engine deliveries can + // omit the commit hash, while branch-only deliveries can include one. + for (const [result, expected] of [ + [{ commitReview: { token: 'confirm', diff: 'Confirm earlier edits.' } }, 0], + [{ reconciliation: { id: 'merge', ready: true } }, 0], + [{ delivery: { mode: 'commit', accepted: 'accepted', pending: true } }, 0], + [{ delivery: { mode: 'branch', accepted: 'accepted', commit: 'branch-commit' } }, 0], + [{ delivery: { mode: 'commit', accepted: 'accepted' } }, 1], + ] as const) { + f.workspace.applyTask = async () => result as any; + const response = await f.local.inject({ method: 'POST', url: '/api/tasks/thesis-task/commit', headers: { ...LOCAL, 'x-papilot-token': 'local-csrf' }, payload: { message: 'Save reviewed changes' } }); + assert.equal(response.statusCode, 200, response.body); + assert.equal(refreshes, expected, JSON.stringify(result)); + } + } finally { await f.close(); } +}); + test('review decisions wait until application to compile the manuscript', async () => { const f = await fixture(); try { From f0c85decefb2ee599b4fe8c352d562c36016525f Mon Sep 17 00:00:00 2001 From: Cui Chen Date: Mon, 9 Nov 2026 16:39:30 +0800 Subject: [PATCH 04/12] Compare compilation toolbar height against its settled layout --- scripts/check-review-flow.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/check-review-flow.mjs b/scripts/check-review-flow.mjs index d7e6b298..b3de3df0 100644 --- a/scripts/check-review-flow.mjs +++ b/scripts/check-review-flow.mjs @@ -370,14 +370,18 @@ try { for (const width of [1440, 1100, 900]) { await page.setViewportSize({ width, height: 1000 }); const bar = viewer().locator('.bar'); - await expect.poll(() => bar.evaluate(el => el.offsetHeight)).toBe(44); + await expect(bar.locator('.status[data-state="building"]')).toHaveCount(0); + await bar.evaluate(el => el.ownerDocument.fonts.ready); + // Platform fonts can wrap the ready toolbar at narrow desktop widths. + // Compilation must preserve that layout, whatever its initial height. + const height = await bar.evaluate(el => el.offsetHeight); task.status = 'building'; task.review.state = 'building'; task.review.job = { id: 'toolbar-build', status: 'running', phase: 'build' }; await sync(); await expect(page.locator('.review-rebuild')).toHaveAttribute('aria-busy', 'true'); await expect(bar.locator('.status[data-state="building"]')).toHaveCSS('clip-path', 'inset(50%)'); await expect(bar.locator('.accept-all')).toBeDisabled(); - assert.equal(await bar.evaluate(el => el.offsetHeight), 44, 'compilation does not add a toolbar row'); + assert.equal(await bar.evaluate(el => el.offsetHeight), height, `compilation preserves the ${language} toolbar at ${width}px`); if (language === 'zh-CN' && width === 1100) await page.screenshot({ path: 'test-results/review-building-zh.png' }); task.status = 'reviewing'; delete task.review.job; update(); await sync(); await expect(bar.locator('.accept-all')).toBeEnabled(); From 65732f2ae399d801ce9387371145f71c67f10f87 Mon Sep 17 00:00:00 2001 From: Cui Chen Date: Mon, 9 Nov 2026 17:38:25 +0800 Subject: [PATCH 05/12] Keep note review open and match each option to its preview --- .github/workflows/ci.yml | 2 + package.json | 2 +- scripts/check-note-options-flow.mjs | 148 ++++++++++++++++++++++++++++ scripts/check-note-results.mjs | 4 +- src/server/app.ts | 40 ++++---- src/server/review.ts | 10 ++ src/web/note-results.tsx | 64 ++++++++---- tests/note-option-reject.test.ts | 21 +++- 8 files changed, 247 insertions(+), 44 deletions(-) create mode 100644 scripts/check-note-options-flow.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36a2aab6..0cc981dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,8 @@ jobs: run: | node --import tsx scripts/check-review-batching.mjs node --import tsx scripts/check-review-flow.mjs + node --import tsx scripts/check-note-results.mjs + node --import tsx scripts/check-note-options-flow.mjs env: PAPILOT_BROWSER_CHANNEL: chromium # Checks the generated UI demos and local asset links before main. diff --git a/package.json b/package.json index 7bb854ac..7a04a0c0 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "doctor": "node bin/papilot.mjs doctor", "service:install": "tsx scripts/service.ts install", "service:stop": "tsx scripts/service.ts stop", - "test:ui": "npm run build && node scripts/check-system-requirements.mjs && node scripts/check-settings-layout.mjs && node scripts/check-git-workspace.mjs && node scripts/check-ui.mjs && node scripts/check-git-panel.mjs && node scripts/check-task-model.mjs && node scripts/check-comparison-progress.mjs && node scripts/check-appearance.mjs && node --import tsx scripts/check-review-pdf-appearance.mjs && node scripts/check-updates.mjs && node scripts/check-papers.mjs && node scripts/check-workspace-switcher.mjs && node scripts/check-workspace-switcher-notes.mjs && node scripts/check-imports.mjs && node scripts/check-compilation.mjs && node scripts/check-build-problems.mjs && node scripts/check-review-notes.mjs && node --import tsx scripts/check-note-groups.mjs && node --import tsx scripts/check-pdf-tools.mjs && node scripts/check-note-selection.mjs && node scripts/check-ai-review.mjs && node --import tsx scripts/check-note-results.mjs && node --import tsx scripts/check-review-flow.mjs && node scripts/check-skills.mjs && node --import tsx scripts/check-skill-library.mjs && node scripts/check-attachments.mjs && node --import tsx scripts/check-places.mjs && node scripts/check-pdf-words.mjs && node scripts/check-reply-duplicates.mjs && node --import tsx scripts/check-task-permissions.mjs", + "test:ui": "npm run build && node scripts/check-system-requirements.mjs && node scripts/check-settings-layout.mjs && node scripts/check-git-workspace.mjs && node scripts/check-ui.mjs && node scripts/check-git-panel.mjs && node scripts/check-task-model.mjs && node scripts/check-comparison-progress.mjs && node scripts/check-appearance.mjs && node --import tsx scripts/check-review-pdf-appearance.mjs && node scripts/check-updates.mjs && node scripts/check-papers.mjs && node scripts/check-workspace-switcher.mjs && node scripts/check-workspace-switcher-notes.mjs && node scripts/check-imports.mjs && node scripts/check-compilation.mjs && node scripts/check-build-problems.mjs && node scripts/check-review-notes.mjs && node --import tsx scripts/check-note-groups.mjs && node --import tsx scripts/check-pdf-tools.mjs && node scripts/check-note-selection.mjs && node scripts/check-ai-review.mjs && node --import tsx scripts/check-note-results.mjs && node --import tsx scripts/check-note-options-flow.mjs && node --import tsx scripts/check-review-flow.mjs && node scripts/check-skills.mjs && node --import tsx scripts/check-skill-library.mjs && node scripts/check-attachments.mjs && node --import tsx scripts/check-places.mjs && node scripts/check-pdf-words.mjs && node scripts/check-reply-duplicates.mjs && node --import tsx scripts/check-task-permissions.mjs", "site": "node scripts/build-site.mjs", "test:site": "npm run site && node scripts/check-site-analytics.mjs && node scripts/check-site-privacy.mjs && node scripts/check-site.mjs && node scripts/check-site-blog.mjs", "prepack": "npm run build && npm run setup:review", diff --git a/scripts/check-note-options-flow.mjs b/scripts/check-note-options-flow.mjs new file mode 100644 index 00000000..d2628835 --- /dev/null +++ b/scripts/check-note-options-flow.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import Fastify from 'fastify'; +import staticFiles from '@fastify/static'; +import { chromium, expect } from '../engine/node_modules/@playwright/test/index.mjs'; +import { rewriteReviewHtml } from '../src/server/review.ts'; +import { readyRequirements } from './required-tools-fixture.mjs'; + +const server = Fastify(); +await server.register(staticFiles, { root: path.resolve('dist') }); +await server.listen({ host: '127.0.0.1', port: 0 }); +const origin = `http://127.0.0.1:${server.server.address().port}`; +const browser = await chromium.launch({ channel: process.env.PAPILOT_BROWSER_CHANNEL || 'chrome', headless: true }); +fs.mkdirSync('test-results', { recursive: true }); +try { + for (const width of [1440, 390]) { + const page = await browser.newPage({ locale: 'en-US', viewport: { width, height: 900 } }); + page.setDefaultTimeout(15000); + const errors = [], writes = [], previews = []; + page.on('pageerror', error => errors.push(error.message)); + await page.addInitScript(() => { localStorage.setItem('papilot:language', 'en'); localStorage.setItem('papilot:task', 'task'); }); + const task = { id: 'task', projectId: 'paper', name: 'flow', title: 'Review note choices', status: 'reviewing', createdAt: 1, updatedAt: 1, review: { round: 1, state: 'reviewing', counts: { pending: 3 } } }; + const noteUnits = ['a', 'a', 'b', 'c']; + const annotations = noteUnits.map((unit, index) => ({ where: `main.tex line ${index + 1}`, quote: `Old ${unit}`, comment: `Improve note ${index + 1}.` })); + const items = [{ id: 'run', taskId: 'task', type: 'user', text: '', createdAt: 1, meta: { notes: annotations } }]; + const decisions = { a: 'pending', b: 'pending', c: 'pending' }, selected = { a: 'current', b: 'current', c: 'current' }; + const strip = (unit, text) => { + const src = 'data:image/svg+xml,' + encodeURIComponent(`${text}`); + return { src, clean: src, width: 600, height: 36, page: 1, label: '1', text: [{ text, chars: Array.from(text, (text, index) => ({ text, rect: [12 + index * 9, 8, 21 + index * 9, 28] })) }], marks: [{ unit, kind: 'added', rect: [12, 8, 220, 28] }] }; + }; + let version = 1, failPreview = false, latePreview = null, saveFails = false, pendingBuild = null; + const changed = () => { task.updatedAt++; task.review.counts.pending = Object.values(decisions).filter(state => state === 'pending').length; }; + const notes = () => noteUnits.map((unit, index) => ({ key: `run:${index}`, itemId: 'run', index, note: annotations[index], round: task.review.round, state: 'matched', changes: [{ round: task.review.round, stop: ['a', 'b', 'c'].indexOf(unit), units: [unit], state: decisions[unit], match: 'source' }] })); + const detail = (unit, option) => ({ task: 'flow', round: task.review.round, before: 'before', after: `${unit}-${option}-v${version}`, files: '', warnings: [], sections: [], + units: ['a', 'b', 'c'].map((id, index) => ({ id, file: 'main.tex', kind: 'text', old_start: index + 1, old_end: index + 1, new_start: index + 1, new_end: index + 1, old_text: `Old ${id}`, new_text: `${id === unit ? option : selected[id]} ${id}` })), + loose: ['a', 'b', 'c'].map(id => ({ kind: 'change', units: [id], before: [strip(id, `Old ${id}`)], after: [strip(id, `${id === unit ? option : selected[id]} ${id}`)] })) }); + const options = key => { + const unit = noteUnits[Number(key.split(':')[1])]; + return { key, round: task.review.round, comparisonKey: `v${version}`, selected: selected[unit], + ...(decisions[unit] !== 'pending' ? { locked: 'This note includes a reviewed change. Undo that decision before replacing it.' } : {}), + options: [{ id: 'current', label: `Original ${unit}`, reason: 'Keep the original proposal.', edits: [{ unit, text: `current ${unit}` }] }, + { id: 'alternative', label: `Alternative ${unit}`, reason: 'Use the alternative wording.', edits: [{ unit, text: `alternative ${unit}` }] }] }; + }; + await page.route('**/api/**', async route => { + const url = new URL(route.request().url()), json = value => route.fulfill({ json: value }); + if (url.pathname.startsWith('/review/')) return route.fallback(); + if (url.pathname === '/api/state') return json({ version: 'test', csrf: 'fixture', projects: [{ id: 'paper', name: 'Paper', path: '/fixture/paper' }], tasks: [task], requests: [], runtime: { connected: true, authenticated: true, models: [] } }); + if (url.pathname === '/api/runtime/requirements') return json(readyRequirements); + if (url.pathname === '/api/updates') return json({ currentVersion: 'test', phase: 'idle' }); + if (url.pathname === '/api/indirect-changes') return json({ indirectChanges: false }); + if (url.pathname === '/api/note-alternatives') return json({ noteAlternatives: true }); + if (url.pathname.endsWith('/items')) return json({ items }); + if (url.pathname.endsWith('/note-results')) return json({ notes: notes() }); + if (url.pathname.endsWith('/note-options/preview')) { + const unit = noteUnits[Number(url.searchParams.get('key').split(':')[1])], option = url.searchParams.get('option'); + const result = detail(unit, option); previews.push({ unit, option, version }); + if (unit === 'a' && option === 'alternative') { + if (latePreview) { const wait = latePreview; latePreview = null; await wait; return route.fulfill({ status: 503, json: { error: 'Late preview failure' } }); } + if (failPreview && url.searchParams.get('retry') !== '1') return route.fulfill({ status: 503, json: { error: 'fetch failed' } }); + } + return json(result); + } + if (url.pathname.endsWith('/note-options')) { + if (route.request().method() === 'GET') return json(options(url.searchParams.get('key'))); + const body = route.request().postDataJSON(), unit = noteUnits[Number(body.key.split(':')[1])]; writes.push(body); + if (saveFails) return route.fulfill({ status: 400, json: { error: 'The source changed. Try again.' } }); + if (body.accept && body.option !== selected[unit]) { pendingBuild = { unit, option: body.option }; task.status = 'building'; changed(); return json({ building: true }); } + decisions[unit] = body.accept ? 'accepted' : 'rejected'; changed(); return json(options(body.key)); + } + if (url.pathname.endsWith('/skills')) return json({ skills: [] }); + if (url.pathname.endsWith('/git')) return json({ files: [], branch: 'main' }); + if (url.pathname === '/api/events') return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' }); + throw new Error(`Unexpected request: ${url.pathname}`); + }); + const css = fs.readFileSync('engine/src/web/style.css', 'utf8'); + await page.route('**/review/**', async route => { + const url = new URL(route.request().url()); + if (url.pathname.includes('/rounds/')) return route.fulfill({ json: detail('a', selected.a) }); + const html = `
${['a', 'b', 'c'].map((unit, index) => `
Change ${index + 1}

Old ${unit}

${selected[unit]} ${unit}

`).join('')}
`; + return route.fulfill({ contentType: 'text/html', body: rewriteReviewHtml(html, '/review/paper') }); + }); + const frame = () => page.frameLocator('.review-frame'), drawer = page.locator('.review-note-controls[data-open="true"]'); + const sync = () => frame().locator('body').evaluate(el => el.ownerDocument.defaultView.dispatchEvent(new Event('latex-review:decided'))); + const tab = name => drawer.getByRole('tab', { name, exact: true }); + const comparison = revision => drawer.locator(`.note-option-comparison[data-revision="${revision}"]`); + try { + await page.goto(origin); + await frame().getByRole('button', { name: 'Show note 1: Improve note 1.', exact: true }).click(); + await expect(comparison('a-current-v1')).toBeVisible(); + failPreview = true; version++; changed(); await sync(); + await expect(comparison('a-current-v2')).toBeVisible(); + await tab('Alternative a').click(); + await expect(drawer.getByRole('alert')).toBeVisible(); + await expect(drawer.locator('.note-option-comparison')).toHaveCount(0); + await expect(drawer.getByRole('button', { name: 'Accept Alternative a', exact: true })).toBeDisabled(); + await drawer.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect(comparison('a-alternative-v2')).toBeVisible(); + await expect(comparison('a-alternative-v2').locator('.note-comparison-side.after')).toContainText('alternative a'); + await expect(drawer.getByRole('alert')).toHaveCount(0); + assert.equal(writes.length, 0, 'switching and retrying previews never saves a choice'); + + failPreview = false; await tab('Original a').click(); + let releaseLate; latePreview = new Promise(resolve => { releaseLate = resolve; }); + version++; changed(); await sync(); + await expect(comparison('a-current-v3')).toBeVisible(); + await expect.poll(() => previews.some(request => request.unit === 'a' && request.option === 'alternative' && request.version === 3)).toBe(true); + await tab('Alternative a').click(); + await expect(drawer.locator('.note-option-comparison')).toHaveCount(0); + await tab('Original a').click(); releaseLate(); + await expect(comparison('a-current-v3')).toBeVisible(); + await expect(drawer.getByRole('alert')).toHaveCount(0); + await drawer.getByRole('button', { name: 'Accept Original a', exact: true }).click(); + await expect(drawer.locator('.review-note-position')).toHaveText('Note 3 / 4'); + await expect(comparison('b-current-v3')).toBeVisible(); + assert.equal(decisions.a, 'accepted', 'shared notes are skipped only after confirmed acceptance'); + + await tab('Alternative b').click(); + await expect(comparison('b-alternative-v3')).toBeVisible(); + await drawer.getByRole('button', { name: 'Accept Alternative b', exact: true }).click(); + await expect.poll(() => pendingBuild?.unit).toBe('b'); + await sync(); + await expect(drawer.locator('.review-note-position')).toHaveText('Note 3 / 4'); + selected.b = pendingBuild.option; decisions.b = 'accepted'; pendingBuild = null; + task.status = 'reviewing'; task.review.round++; version++; changed(); await sync(); + await expect(drawer.locator('.review-note-position')).toHaveText('Note 4 / 4'); + await expect(comparison('c-current-v4')).toBeVisible(); + + saveFails = true; + await drawer.getByRole('button', { name: 'Reject Original c', exact: true }).click(); + await expect(drawer.getByRole('alert')).toContainText('The source changed'); + await expect(drawer.locator('.review-note-position')).toHaveText('Note 4 / 4'); + assert.equal(decisions.c, 'pending'); + saveFails = false; + await drawer.getByRole('button', { name: 'Reject Original c', exact: true }).click(); + await expect.poll(() => decisions.c).toBe('rejected'); + await expect(drawer.locator('.review-note-position')).toHaveText('Note 4 / 4'); + await expect(drawer.getByRole('button', { name: 'Accept Original c', exact: true })).toBeDisabled(); + assert.equal(writes.at(-1).id, writes.at(-2).id, 'a failed decision keeps its retry identity'); + assert.deepEqual(errors, []); + await page.screenshot({ path: `test-results/note-options-flow-${width}.png` }); + console.log(`PASS ${width}px: preview failure/retry, stale response isolation, confirmed acceptance, shared-note skipping, asynchronous alternative save and final drawer retention`); + } catch (error) { + await page.screenshot({ path: `test-results/note-options-flow-error-${width}.png` }); + throw error; + } finally { await page.close(); } + } +} finally { await browser.close(); await server.close(); } diff --git a/scripts/check-note-results.mjs b/scripts/check-note-results.mjs index 81a055cd..70cd3c40 100644 --- a/scripts/check-note-results.mjs +++ b/scripts/check-note-results.mjs @@ -251,8 +251,8 @@ try { assert.equal(decisions[2].c1, 'pending'); rejectFails = false; await controls().getByRole('button', { name: 'Reject Concise', exact: true }).click(); - await controls().waitFor({ state: 'hidden' }); - assert.equal(decisions[2].c1, 'rejected'); + await expect.poll(() => decisions[2].c1).toBe('rejected'); + await expect(controls()).toHaveAttribute('data-open', 'true'); await frame().locator('.papilot-note-options button').waitFor({ state: 'hidden' }); assert.equal(optionWrites.length, 2); assert.ok(optionWrites.every(body => body.reject === true && body.accept === undefined && body.option === 'brief')); diff --git a/src/server/app.ts b/src/server/app.ts index dc6ea512..01795db5 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -10,7 +10,7 @@ import { randomUUID } from 'node:crypto'; import { registerFrontend } from './frontend.ts'; import type { Store } from './store.ts'; import type { Workspace } from './workspace.ts'; -import { reviewClient, allowedReviewRoute, rewriteReviewHtml, rewriteReviewJson, confirmedReviewResponse, annotateReviewState, taskRoute } from './review.ts'; +import { reviewClient, readReview, allowedReviewRoute, rewriteReviewHtml, rewriteReviewJson, confirmedReviewResponse, annotateReviewState, taskRoute } from './review.ts'; import { fullPaperPdf, nextPaperPass, paperPassPdf } from './pdf.ts'; import { type ChangeAction, Changes } from './changes.ts'; import { bibliography, confirmEntry } from './references.ts'; @@ -305,25 +305,29 @@ export async function buildApp(options: AppOptions) { return { notes: await taskNoteResults(task, store.items(task.id), store.runs().filter(run => run.taskId === task.id), client) }; }); app.get<{ Params: { id: string }; Querystring: { key: string } }>('/api/tasks/:id/note-options', task, async request => { - const current = store.task(request.params.id), client = await connectReview(store.project(current.projectId).path); - return (await noteOptionContext(store, current, string(request.query.key, 'Note key', 200), client)).options; + const project = store.project(store.task(request.params.id).projectId); + return readReview(connectReview, project.path, async client => + (await noteOptionContext(store, store.task(request.params.id), string(request.query.key, 'Note key', 200), client)).options); }); app.get<{ Params: { id: string }; Querystring: { key: string; round: string; option: string; retry?: string; comparison?: string } }>('/api/tasks/:id/note-options/preview', task, async request => { - const current = store.task(request.params.id), client = await connectReview(store.project(current.projectId).path); - const context = await noteOptionContext(store, current, string(request.query.key, 'Note key', 200), client); - if (context.options.round !== Number(request.query.round)) throw new Error('The review changed. Reload the options.'); - if (request.query.comparison && request.query.comparison !== context.options.comparisonKey) throw new Error('The review changed. Reload the options.'); - if (request.query.option === context.options.selected && (!context.rejected.length || context.options.locked)) return rewriteReviewJson(context.detail, `/review/${current.projectId}`); - if (context.options.locked) throw new Error(context.options.locked); - const option = context.options.options.find(option => option.id === request.query.option); - if (!option) throw new Error('This option is no longer available.'); - if (option.previewError && request.query.retry !== '1') throw new Error(option.previewError); - const detail = await client.post(`${taskRoute(current.name)}/source/options/preview`, { id: option.id, round: context.options.round, edits: option.edits, rejected: context.rejected }, 120000); - if (option.previewError) { - delete option.previewError; - store.savePreference(noteOptionsKey(current.id, context.options.key, context.options.round), context.options); - } - return rewriteReviewJson(detail, `/review/${current.projectId}`); + const project = store.project(store.task(request.params.id).projectId); + return readReview(connectReview, project.path, async client => { + const current = store.task(request.params.id); + const context = await noteOptionContext(store, current, string(request.query.key, 'Note key', 200), client); + if (context.options.round !== Number(request.query.round)) throw new Error('The review changed. Reload the options.'); + if (request.query.comparison && request.query.comparison !== context.options.comparisonKey) throw new Error('The review changed. Reload the options.'); + if (request.query.option === context.options.selected && (!context.rejected.length || context.options.locked)) return rewriteReviewJson(context.detail, `/review/${current.projectId}`); + if (context.options.locked) throw new Error(context.options.locked); + const option = context.options.options.find(option => option.id === request.query.option); + if (!option) throw new Error('This option is no longer available.'); + if (option.previewError && request.query.retry !== '1') throw new Error(option.previewError); + const detail = await client.post(`${taskRoute(current.name)}/source/options/preview`, { id: option.id, round: context.options.round, edits: option.edits, rejected: context.rejected }, 120000); + if (option.previewError) { + delete option.previewError; + store.savePreference(noteOptionsKey(current.id, context.options.key, context.options.round), context.options); + } + return rewriteReviewJson(detail, `/review/${current.projectId}`); + }); }); app.post<{ Params: { id: string }; Body: { key: string; round: number; option?: string; id?: string; accept?: boolean; reject?: boolean } }>('/api/tasks/:id/note-options', task, async request => { const current = store.task(request.params.id), client = await connectReview(store.project(current.projectId).path), body = request.body; diff --git a/src/server/review.ts b/src/server/review.ts index 8a5ab5fc..3fab4264 100644 --- a/src/server/review.ts +++ b/src/server/review.ts @@ -20,6 +20,16 @@ export function reviewConnectionError(error: unknown): boolean { return ['AbortError', 'TimeoutError'].includes(error.name) || /fetch failed|ECONNREFUSED|ECONNRESET|socket hang up|The review server failed to start/.test(error.message); } +// A preview is a repeatable read, even when the engine prepares its cache. +// Reconnect once if the engine restarts between connecting and reading it. +export async function readReview(connect: (root: string) => Promise, root: string, read: (client: ReviewClient) => Promise): Promise { + try { return await read(await connect(root)); } + catch (error) { + if (!reviewConnectionError(error)) throw error; + return read(await connect(root)); + } +} + export function confirmedReviewResponse(status: number, value: unknown, task: string): boolean { if (!value || typeof value !== 'object') return false; const result = value as Record; diff --git a/src/web/note-results.tsx b/src/web/note-results.tsx index f9b5d3a3..a9933dbb 100644 --- a/src/web/note-results.tsx +++ b/src/web/note-results.tsx @@ -11,7 +11,7 @@ import { NotePreviewCache } from './note-preview-cache.ts'; export interface NoteFocus { key: string; change: NoteChange; sequence: number; historical: boolean } type OptionsRequest = (id: string, key: string, body?: { round: number; option?: string; id?: string; accept?: boolean; reject?: boolean }) => Promise; -type Preview = { key: string; round: number; detail?: RoundDetail } | null; +type Preview = { key: string; round: number; option: string; version?: string; detail?: RoundDetail } | null; type PreviewRequest = (id: string, key: string, round: number, option: string, retry?: boolean, version?: string) => Promise; const previewKey = (id: string, key: string, round: number, option: string, version?: string) => JSON.stringify([id, key, round, option, version]); const comparisonCache = new NotePreviewCache(); @@ -40,6 +40,8 @@ function optionLockText(reason: string) { default: return errorText(reason); } } +const optionError = (message: string) => /fetch failed|failed to fetch|networkerror|load failed/i.test(message) + ? t('Request failed. Please try again.') : errorText(message); async function warmComparison(detail: RoundDetail) { const strips = [...detail.sections.flatMap(section => section.blocks), ...detail.loose].flatMap(block => block.kind === 'context' ? [block.image] : block.kind === 'change' ? [...block.before, ...block.after] : []); // Decode the first visible strips before swapping, so a ready comparison @@ -60,7 +62,7 @@ interface Links { cachedPreview: (id: string, key: string, round: number, option: string, version?: string) => RoundDetail | undefined; notes: NoteResult[]; error: boolean; loading: boolean; selected: string; reveal: number; focus: NoteFocus | null; scope: 'all' | 'note'; setScope: (scope: 'all' | 'note') => void; reload: () => void; - clearFocus: () => void; + clearFocus: () => void; decisionSaved: (key: string) => void; alternativeCounts: Record; focusNote: (key: string) => void; showChange: (key: string, change: NoteChange) => void; showNote: (key: string) => void; @@ -76,6 +78,7 @@ export function NoteResultsProvider({ task, items, read, optionsRequest, preview const [selected, setSelected] = useState(''), [focus, setFocus] = useState(null); const [reveal, setReveal] = useState(0), [scope, setScope] = useState<'all' | 'note'>('all'); const [preview, setPreview] = useState(null); + const [advance, setAdvance] = useState(null); const [available, setAvailable] = useState>({}); const sequence = useRef(0), actions = useRef({ read, openReview, openChat }); actions.current = { read, openReview, openChat }; const signature = items.filter(item => item.type === 'user' && (item.meta?.notes?.length || readNotesInstructions(item.text))).map(item => item.id).join(','); @@ -87,7 +90,7 @@ export function NoteResultsProvider({ task, items, read, optionsRequest, preview }, [id, signature]); const decisions = JSON.stringify(task?.review?.counts); useEffect(() => { reload(); return () => { ++sequence.current; }; }, [reload, task?.review?.round, task?.status, task?.updatedAt, decisions]); - useEffect(() => { setNotes([]); setAvailable({}); setScope('all'); setPreview(null); setFocus(null); setSelected(''); setError(false); }, [id]); + useEffect(() => { setNotes([]); setAvailable({}); setScope('all'); setPreview(null); setFocus(null); setSelected(''); setError(false); setAdvance(null); }, [id]); const showNote = (key: string) => { setSelected(key); setReveal(value => value + 1); actions.current.openChat(); requestAnimationFrame(() => { @@ -110,6 +113,20 @@ export function NoteResultsProvider({ task, items, read, optionsRequest, preview const note = notes.find(note => note.key === focus.key); if (note?.changes[0] && note.changes[0].round !== focus.change.round) setFocus({ ...focus, change: note.changes[0], sequence: focus.sequence + 1, historical: note.changes[0].round !== task?.review?.round }); }, [notes]); + useEffect(() => { + if (!advance) return; + if (focus?.key !== advance || ['failed', 'uncertain', 'interrupted'].includes(task?.status || '')) { setAdvance(null); return; } + if (task?.status !== 'reviewing' || task.pendingReview) return; + const current = notes.find(note => note.key === advance); + if (!current?.changes.length || current.changes.some(change => change.state === 'pending')) return; + // Read the confirmed decisions first: several notes can share one edit. + // Skip those together and keep the last result open when the batch is done. + const siblings = notes.filter(note => note.itemId === current.itemId), index = siblings.indexOf(current); + const next = [...siblings.slice(index + 1), ...siblings.slice(0, index)].find(note => note.round === task.review?.round + && note.changes.length && note.changes.every(change => change.state === 'pending')); + setAdvance(null); + if (next) showChange(next.key, next.changes[0]); + }, [advance, notes, focus?.key, task?.status, task?.pendingReview, task?.review?.round]); const requests = useRef({ optionsRequest, previewRequest }); requests.current = { optionsRequest, previewRequest }; const noteSignature = JSON.stringify(notes.map(note => [note.key, note.round, note.changes.map(change => change.state)])); useEffect(() => { @@ -139,7 +156,8 @@ export function NoteResultsProvider({ task, items, read, optionsRequest, preview }, [id, task?.status, task?.review?.round, task?.updatedAt, decisions, noteSignature]); const value: Links = { task, optionsRequest, previewRequest: previewRequest && ((id, key, round, option, retry, version) => comparisonCache.read(previewKey(id, key, round, option, version), () => previewRequest(id, key, round, option, retry, version).then(warmComparison))), cachedPreview: (id, key, round, option, version) => comparisonCache.peek(previewKey(id, key, round, option, version)), preview: preview?.key === focus?.key && preview?.round === task?.review?.round ? preview : null, setPreview, notes, loading, error, selected, reveal, focus, scope, setScope, reload, showNote, focusNote, alternativeCounts: Object.fromEntries(notes.map(note => [note.key, task?.status === 'reviewing' && note.round === task.review?.round && note.changes.every(change => change.state === 'pending') && available[note.key]?.round === note.round ? available[note.key].count : 0])), - clearFocus: () => { setScope('all'); setPreview(null); setFocus(null); setSelected(''); }, showChange }; + clearFocus: () => { setScope('all'); setPreview(null); setFocus(null); setSelected(''); setAdvance(null); }, + decisionSaved: key => { setAdvance(key); reload(); }, showChange }; return {children}; } @@ -255,9 +273,8 @@ function NoteAlternatives({ note, navigation }: { note: NoteResult; navigation: const [source, setSource] = useState(false); const [data, setData] = useState(remembered || null), [selected, setSelected] = useState(() => rememberedChoice(storageKey, remembered)); const [busy, setBusy] = useState(false), [applying, setApplying] = useState(false), [error, setError] = useState(''); - const [baseline, setBaseline] = useState(() => task && remembered ? links.cachedPreview(task.id, note.key, remembered.round, remembered.selected || 'current', remembered.comparisonKey) || null : null), [baselineError, setBaselineError] = useState(''); + const [baseline, setBaseline] = useState<{ key: string; detail: RoundDetail } | null>(null), [baselineError, setBaselineError] = useState(''); const [retry, setRetry] = useState(0), [previewBusy, setPreviewBusy] = useState(false); - const [shown, setShown] = useState(() => task && remembered ? links.cachedPreview(task.id, note.key, remembered.round, selected, remembered.comparisonKey) || baseline : null); const previewSequence = useRef(0), requestId = useRef<{ option: string; action: 'accept' | 'reject'; id: string } | null>(null); useEffect(() => { if (applying && task && ['failed', 'uncertain', 'interrupted'].includes(task.status)) { @@ -279,9 +296,11 @@ function NoteAlternatives({ note, navigation }: { note: NoteResult; navigation: useEffect(() => { if (editor) commentInput.current?.focus(); }, [editor]); useEffect(() => { if (!task || !data || !links.previewRequest) return; - let cancelled = false; setBaseline(links.cachedPreview(task.id, note.key, data.round, saved, data.comparisonKey) || null); setBaselineError(''); - void links.previewRequest(task.id, note.key, data.round, saved, false, data.comparisonKey).then(detail => { - if (!cancelled) setBaseline(detail); + const key = previewKey(task.id, note.key, data.round, saved, data.comparisonKey); + const cached = links.cachedPreview(task.id, note.key, data.round, saved, data.comparisonKey); + let cancelled = false; setBaseline(cached ? { key, detail: cached } : null); setBaselineError(''); + void links.previewRequest(task.id, note.key, data.round, saved, retry > 0, data.comparisonKey).then(detail => { + if (!cancelled) setBaseline({ key, detail }); }).catch(error => { if (!cancelled) setBaselineError(error.message); }); return () => { cancelled = true; }; }, [task?.id, note.key, data?.round, data?.comparisonKey, saved, retry]); @@ -289,12 +308,13 @@ function NoteAlternatives({ note, navigation }: { note: NoteResult; navigation: const sequence = ++previewSequence.current; setPreviewBusy(false); if (!task || !data || !links.previewRequest || links.focus?.key !== note.key || selected === saved || data.locked) { links.setPreview(null); return; } + const identity = { key: note.key, round: data.round, option: selected, version: data.comparisonKey }; const cached = links.cachedPreview(task.id, note.key, data.round, selected, data.comparisonKey); - if (cached) { links.setPreview({ key: note.key, round: data.round, detail: cached }); return; } - links.setPreview({ key: note.key, round: data.round }); setPreviewBusy(true); + if (cached) { links.setPreview({ ...identity, detail: cached }); return; } + links.setPreview(identity); setPreviewBusy(true); void links.previewRequest(task.id, note.key, data.round, selected, retry > 0, data.comparisonKey).then(detail => { if (previewSequence.current !== sequence) return; - links.setPreview({ key: note.key, round: data.round, detail }); + links.setPreview({ ...identity, detail }); }).catch(error => { if (previewSequence.current === sequence) setError(error.message); }) .finally(() => { if (previewSequence.current === sequence) setPreviewBusy(false); }); return () => { ++previewSequence.current; }; @@ -319,10 +339,12 @@ function NoteAlternatives({ note, navigation }: { note: NoteResult; navigation: // Read the prepared comparisons; tab switches share the same decoded cache. for (const option of data.options.filter(option => !option.previewError)) void links.previewRequest(task.id, note.key, data.round, option.id, false, data.comparisonKey).catch(() => {}); }, [task?.id, note.key, data?.round, data?.comparisonKey, data?.locked, optionIds]); - const readyComparison = selected === saved ? baseline : links.preview?.detail; - useEffect(() => { if (readyComparison) setShown(readyComparison); }, [readyComparison]); if (!request || !task) return null; - const comparison = readyComparison || shown; + // A tab must never label another option's PDF, including while loading or + // after an error. Late responses are matched to the complete comparison key. + const comparison = data && (selected === saved + ? baseline?.key === previewKey(task.id, note.key, data.round, saved, data.comparisonKey) ? baseline.detail : undefined + : links.preview?.option === selected && links.preview.version === data.comparisonKey ? links.preview.detail : undefined); const focusedUnits = [...new Set(note.changes.filter(change => change.round === comparison?.round).flatMap(change => change.units))]; const locked = data?.locked || (note.changes.some(change => change.state !== 'pending') ? 'This note includes a reviewed change. Undo that decision before replacing it.' : task.status !== 'reviewing' || task.pendingReview ? 'Wait for the current task and review actions to finish.' : ''); const choose = (option: string) => { @@ -343,8 +365,8 @@ function NoteAlternatives({ note, navigation }: { note: NoteResult; navigation: const result = await request(task.id, note.key, { round: data.round, option: option.id, id: requestId.current.id, ...(action === 'accept' ? { accept: true } : { reject: true }) }); const rejected = optionFeedback(option.id, 'reject'); if (rejected) draft.remove(rejected.id); browserStorage.removeItem(storageKey); - if ('options' in result) { setData(result); setSelected(result.selected || 'current'); setApplying(false); links.clearFocus(); } - links.reload(); + if ('options' in result) { setData(result); setSelected(result.selected || 'current'); setApplying(false); } + links.decisionSaved(note.key); } catch (error) { setError((error as Error).message); setApplying(false); } }; @@ -383,7 +405,7 @@ function NoteAlternatives({ note, navigation }: { note: NoteResult; navigation: : {name})} {navigation} {active &&
- + @@ -396,11 +418,11 @@ function NoteAlternatives({ note, navigation }: { note: NoteResult; navigation:
} {comparison && (source ? : )} - {!comparison && !baselineError && !error &&

{t('Loading PDF comparison…')}

} - {baselineError && !comparison &&

{baselineError}

} + {!comparison && !(selected === saved && baselineError) && !error &&

{t('Loading PDF comparison…')}

} + {selected === saved && baselineError && !comparison &&

{optionError(baselineError)}

} {(saving || previewBusy) && {saving ? t('Saving choice…') : t('Preparing this option’s PDF diff…')}} {locked && !saving &&

{optionLockText(locked)}

} - {error &&

{errorText(error)}

} + {error &&

{optionError(error)}

} ; } diff --git a/tests/note-option-reject.test.ts b/tests/note-option-reject.test.ts index d9171404..0fe84452 100644 --- a/tests/note-option-reject.test.ts +++ b/tests/note-option-reject.test.ts @@ -31,8 +31,13 @@ test('rejecting a note restores its source even when previewing an alternative; return { ...unit, old_context: { text: lines[unit.old_start - 1], start } }; }), sections: [], loose: round.units.map(unit => ({ kind: 'change', units: [unit.id] })) }; const previews: unknown[] = []; + let previewFailure: Error | null = null, failOnce = false, connections = 0; const client = { url: transport.url, token: transport.token, get: async (route: string) => route.includes('/rounds/') ? { ...detail, round: 1 } as T : transport.get(route), post: async (route: string, body?: unknown) => { - if (route.endsWith('/source/options/preview')) { previews.push(body); return { ...detail, round: 1, after: 'respects-rejection' } as T; } + if (route.endsWith('/source/options/preview')) { + previews.push(body); + if (previewFailure) { const error = previewFailure; if (failOnce) previewFailure = null; throw error; } + return { ...detail, round: 1, after: 'respects-rejection' } as T; + } return transport.post(route, body); } }; const store = new Store(':memory:'); @@ -45,7 +50,7 @@ test('rejecting a note restores its source even when previewing an alternative; { id: 'current', label: 'Main', edits: [{ unit: word.id, text: word.new_text }] }, { id: 'alternative', label: 'Alternative', reason: 'Another wording.', edits: [{ unit: word.id, text: 'Procedure' }] }, ] }); - const local = await buildApp({ listener: { kind: 'local', port: 7274, publicOrigin: '', owner: '' }, store, workspace, manuscripts: new Manuscripts(), csrf: 'test-csrf', serialGit: gitQueue(), dist: null, version: 'test', connectReview: async () => client }); + const local = await buildApp({ listener: { kind: 'local', port: 7274, publicOrigin: '', owner: '' }, store, workspace, manuscripts: new Manuscripts(), csrf: 'test-csrf', serialGit: gitQueue(), dist: null, version: 'test', connectReview: async () => { connections++; return client; } }); const headers = { host: '127.0.0.1:7274', origin: 'http://127.0.0.1:7274', 'x-papilot-token': 'test-csrf' }; try { const options = async () => (await local.inject({ method: 'GET', url: '/api/tasks/task/note-options?key=run:0', headers })).json(); @@ -60,6 +65,18 @@ test('rejecting a note restores its source even when previewing an alternative; assert.equal(currentPreview.json().after, 'respects-rejection', 'Current proposal also uses a decision-aware preview'); assert.equal(previews.length, 1); assert.deepEqual((previews[0] as { rejected: string[] }).rejected, [neighbor.id], 'The engine must validate the same rejection state as the browser comparison key'); + for (const [message, transient, expectedStatus] of [['fetch failed', true, 200], ['fetch failed', false, 400], ['Invalid optional LaTeX', false, 400]] as const) { + previewFailure = new Error(message); failOnce = transient; + const beforeConnections: number = connections, beforePreviews: number = previews.length; + const response = await local.inject({ method: 'GET', url: url + rejectedKey, headers }); + assert.equal(response.statusCode, expectedStatus, response.body); + const attempts = message === 'fetch failed' ? 2 : 1; + assert.equal(connections - beforeConnections, attempts, 'Only connection failures reconnect, at most once'); + assert.equal(previews.length - beforePreviews, attempts); + assert.equal(engine.review.decisions(task.name, round)[word.id], 'pending', 'Preview recovery never saves a decision'); + if (expectedStatus === 200) assert.equal(response.json().after, 'respects-rejection'); + } + previewFailure = null; await engine.review.undo(task.name, 'reject-other'); assert.equal((await options()).comparisonKey, initialKey, 'Undo restores the reusable preview identity'); for (const option of ['current', 'alternative']) { From 16c13a5f801bc3b906a8a6689ac67b672e638125 Mon Sep 17 00:00:00 2001 From: Cui Chen Date: Mon, 9 Nov 2026 17:46:11 +0800 Subject: [PATCH 06/12] Confirm reused error translations and document the open note drawer Review the existing generic request-failure translations in their added note-preview context. Preserve their text and generation provenance while refreshing context fingerprints. --- docs/images/review-note-drawer.png | Bin 0 -> 82389 bytes i18n/state/de.json | 2 +- i18n/state/es.json | 2 +- i18n/state/fr.json | 2 +- i18n/state/it.json | 2 +- i18n/state/ja.json | 2 +- i18n/state/ko.json | 2 +- i18n/state/pt.json | 2 +- i18n/state/sv.json | 2 +- i18n/state/zh-Hant.json | 2 +- 10 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 docs/images/review-note-drawer.png diff --git a/docs/images/review-note-drawer.png b/docs/images/review-note-drawer.png new file mode 100644 index 0000000000000000000000000000000000000000..dc883cb7857cf8cae48afe2ae7c00d814f9b6933 GIT binary patch literal 82389 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 Date: Mon, 9 Nov 2026 18:09:28 +0800 Subject: [PATCH 07/12] Preserve the selected note marker when the PDF view repaints --- scripts/check-playground.mjs | 19 ++++++++++-- src/web/review-note-connections.tsx | 45 ++++++++++++++++++----------- 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/scripts/check-playground.mjs b/scripts/check-playground.mjs index f0e5afb1..fe1a9c69 100644 --- a/scripts/check-playground.mjs +++ b/scripts/check-playground.mjs @@ -149,8 +149,14 @@ try { await notePanel.getByRole('textbox', { name: 'Comment on Initial proposal', exact: true }).fill('Keep the author’s emphasis here.'); await page.screenshot({ path: 'test-results/demo-prepared-task.png' }); await notePanel.getByRole('button', { name: 'Reject Initial proposal', exact: true }).click(); - await expect(notePanel).toBeHidden(); + await expect(notePanel).toBeVisible(); + await expect(notePanel.getByRole('button', { name: 'Reject Initial proposal', exact: true })).toBeDisabled(); + await expect(notePanel.getByRole('textbox', { name: 'Comment on Initial proposal', exact: true })).toHaveValue('Keep the author’s emphasis here.'); const rejectedChange = page.frameLocator('iframe.review-frame').locator('.change[data-state="rejected"]'); + await expect(rejectedChange).toHaveAttribute('data-note-focus', 'true'); + // The final note remains inspectable until the author explicitly closes it. + await rejectedChange.locator('.change-where').click(); + await expect(notePanel).toBeHidden(); await expect(rejectedChange).toHaveAttribute('data-resolved', 'before'); await expect(rejectedChange.locator('.side.after')).toBeHidden(); await page.reload(); @@ -178,7 +184,9 @@ try { await expect(notePanel.locator('.note-comparison-change[data-related="true"]')).toHaveCount(1); await notePanel.getByRole('button', { name: 'Accept Initial proposal', exact: true }).click(); await expect(page.locator('[data-task="example-note-math"] .task-badge')).toHaveText('1'); - await expect(notePanel.getByRole('button', { name: 'Accept Initial proposal', exact: true })).toBeDisabled(); + await expect(notePanel.getByLabel('Note 1 of 2', { exact: true })).toBeVisible(); + await expect(notePanel.getByRole('button', { name: 'Accept Initial proposal', exact: true })).toBeEnabled(); + await expect(page.frameLocator('iframe.review-frame').locator('.papilot-linked-target[data-state="pending"]')).toHaveCount(1); await expect(page.frameLocator('iframe.review-frame').locator('.change[data-state="accepted"]')).toHaveCount(1); await expect(page.frameLocator('iframe.review-frame').locator('.change[data-state="pending"]')).toHaveCount(1); await openManuscript(); @@ -389,4 +397,9 @@ try { assert.deepEqual(failed, [], 'all static assets resolve under /demo/'); assert.deepEqual(errors, []); console.log('PASS: precise notes, prepared tasks and note-to-diff links, review decisions, model controls, local Git diff/stage/commit/sync/restore/history, demo reset, dismissible banner, download gates, mobile layout and zero API requests.'); -} catch (error) { await page.screenshot({ path: 'test-results/playground-failure.png' }); console.error({ errors, failed, apiRequests }); throw error; } finally { await browser.close(); await new Promise(resolve => server.close(resolve)); } +} catch (error) { + await page.screenshot({ path: 'test-results/playground-failure.png' }); + const changes = await page.frameLocator('iframe.review-frame').locator('.change').evaluateAll(elements => elements.map(el => ({ data: { ...el.dataset }, classes: el.className, round: el.ownerDocument.documentElement.dataset.papilotRound }))).catch(() => []); + console.error(JSON.stringify({ errors, failed, apiRequests, changes })); + throw error; +} finally { await browser.close(); await new Promise(resolve => server.close(resolve)); } diff --git a/src/web/review-note-connections.tsx b/src/web/review-note-connections.tsx index b58ee1a5..e4bba70a 100644 --- a/src/web/review-note-connections.tsx +++ b/src/web/review-note-connections.tsx @@ -45,24 +45,35 @@ export function ReviewNoteConnections({ doc, round }: { doc: Document | null; ro useEffect(() => { const focus = links?.focus; const selected = links?.notes.find(note => note.key === focus?.key); - for (const place of places) { - const linked = Boolean(links?.scope === 'note' && selected && place.notes.some(note => note.key === selected.key)); - place.element.classList.toggle('papilot-linked-change', linked); - place.element.classList.toggle('papilot-linked-target', linked && place.round === focus?.change.round && place.stop === focus.change.stop); - } - if (!doc || !focus || (jumped.current?.doc === doc && jumped.current.sequence === focus.sequence)) return; - const place = places.find(place => place.round === focus.change.round && place.stop === focus.change.stop && place.notes.some(note => note.key === focus.key)); - if (!place) return; - place.element.click(); - // Scroll only the PDF pane: scrollIntoView can also move the host page - // horizontally or leave the target behind the bottom drawer. - const paper = doc.getElementById('paper'); - if (paper) { - const padding = parseFloat(doc.defaultView?.getComputedStyle(paper).scrollPaddingTop || '') || 0; - paper.scrollBy({ top: place.element.getBoundingClientRect().top - paper.getBoundingClientRect().top - padding - 12, behavior: 'instant' }); + const highlight = () => { + for (const place of places) { + const linked = Boolean(links?.scope === 'note' && selected && place.notes.some(note => note.key === selected.key)); + const target = linked && place.round === focus?.change.round && place.stop === focus.change.stop; + if (place.element.classList.contains('papilot-linked-change') !== linked) place.element.classList.toggle('papilot-linked-change', linked); + if (place.element.classList.contains('papilot-linked-target') !== target) place.element.classList.toggle('papilot-linked-target', target); + } + }; + highlight(); + // The embedded renderer owns className and can repaint after our focus + // update. Restore the host's markers without scrolling or moving focus. + const observer = new MutationObserver(highlight); + for (const place of places) observer.observe(place.element, { attributes: true, attributeFilter: ['class'] }); + if (doc && focus && !(jumped.current?.doc === doc && jumped.current.sequence === focus.sequence)) { + const place = places.find(place => place.round === focus.change.round && place.stop === focus.change.stop && place.notes.some(note => note.key === focus.key)); + if (place) { + place.element.click(); + // Scroll only the PDF pane: scrollIntoView can also move the host page + // horizontally or leave the target behind the bottom drawer. + const paper = doc.getElementById('paper'); + if (paper) { + const padding = parseFloat(doc.defaultView?.getComputedStyle(paper).scrollPaddingTop || '') || 0; + paper.scrollBy({ top: place.element.getBoundingClientRect().top - paper.getBoundingClientRect().top - padding - 12, behavior: 'instant' }); + } + place.element.focus({ preventScroll: true }); + jumped.current = { doc, sequence: focus.sequence }; + } } - place.element.focus({ preventScroll: true }); - jumped.current = { doc, sequence: focus.sequence }; + return () => observer.disconnect(); }, [doc, places, links?.focus, links?.scope]); useEffect(() => { const target = doc?.defaultView; From f19fcb123eca9b63bed248763dcf2eccb076e2b7 Mon Sep 17 00:00:00 2001 From: Cui Chen Date: Mon, 9 Nov 2026 23:05:34 +0800 Subject: [PATCH 08/12] Flush final note batches and renew confirmed decision IDs --- scripts/check-note-options-flow.mjs | 16 +++++++- src/server/app.ts | 27 ++++++++++-- src/server/workspace.ts | 3 ++ src/web/note-results.tsx | 3 ++ tests/note-option-reject.test.ts | 64 +++++++++++++++++++++++++++++ 5 files changed, 107 insertions(+), 6 deletions(-) diff --git a/scripts/check-note-options-flow.mjs b/scripts/check-note-options-flow.mjs index d2628835..e62f38e4 100644 --- a/scripts/check-note-options-flow.mjs +++ b/scripts/check-note-options-flow.mjs @@ -17,7 +17,7 @@ try { for (const width of [1440, 390]) { const page = await browser.newPage({ locale: 'en-US', viewport: { width, height: 900 } }); page.setDefaultTimeout(15000); - const errors = [], writes = [], previews = []; + const errors = [], writes = [], previews = [], undone = new Set(); page.on('pageerror', error => errors.push(error.message)); await page.addInitScript(() => { localStorage.setItem('papilot:language', 'en'); localStorage.setItem('papilot:task', 'task'); }); const task = { id: 'task', projectId: 'paper', name: 'flow', title: 'Review note choices', status: 'reviewing', createdAt: 1, updatedAt: 1, review: { round: 1, state: 'reviewing', counts: { pending: 3 } } }; @@ -64,6 +64,7 @@ try { if (url.pathname.endsWith('/note-options')) { if (route.request().method() === 'GET') return json(options(url.searchParams.get('key'))); const body = route.request().postDataJSON(), unit = noteUnits[Number(body.key.split(':')[1])]; writes.push(body); + if (undone.has(body.id)) return route.fulfill({ status: 400, json: { error: 'That decision was undone or failed. Make it again.' } }); if (saveFails) return route.fulfill({ status: 400, json: { error: 'The source changed. Try again.' } }); if (body.accept && body.option !== selected[unit]) { pendingBuild = { unit, option: body.option }; task.status = 'building'; changed(); return json({ building: true }); } decisions[unit] = body.accept ? 'accepted' : 'rejected'; changed(); return json(options(body.key)); @@ -137,9 +138,20 @@ try { await expect(drawer.locator('.review-note-position')).toHaveText('Note 4 / 4'); await expect(drawer.getByRole('button', { name: 'Accept Original c', exact: true })).toBeDisabled(); assert.equal(writes.at(-1).id, writes.at(-2).id, 'a failed decision keeps its retry identity'); + // The final drawer stays mounted. An undo elsewhere must permit a new + // decision, while an unconfirmed retry above keeps the original ID. + for (const action of ['reject', 'accept', 'accept']) { + const previous = writes.at(-1).id; + undone.add(previous); decisions.c = 'pending'; version++; changed(); await sync(); + const button = drawer.getByRole('button', { name: `${action === 'accept' ? 'Accept' : 'Reject'} Original c`, exact: true }); + await expect(button).toBeEnabled(); await button.click(); + await expect.poll(() => decisions.c).toBe(action === 'accept' ? 'accepted' : 'rejected'); + assert.notEqual(writes.at(-1).id, previous, 'a confirmed decision retires its request identity before undo'); + await expect(drawer.locator('.review-note-position')).toHaveText('Note 4 / 4'); + } assert.deepEqual(errors, []); await page.screenshot({ path: `test-results/note-options-flow-${width}.png` }); - console.log(`PASS ${width}px: preview failure/retry, stale response isolation, confirmed acceptance, shared-note skipping, asynchronous alternative save and final drawer retention`); + console.log(`PASS ${width}px: preview failure/retry, stale response isolation, confirmed acceptance, shared-note skipping, asynchronous alternative save, final drawer retention and decisions after undo`); } catch (error) { await page.screenshot({ path: `test-results/note-options-flow-error-${width}.png` }); throw error; diff --git a/src/server/app.ts b/src/server/app.ts index 01795db5..92c3b735 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -37,6 +37,8 @@ import { findExecutable } from '../../bin/agent-paths.mjs'; const importsByWorkspace = new WeakMap(); const deliveredToManuscript = (delivery: Task['delivery']) => Boolean(delivery && !delivery.pending && delivery.mode !== 'branch'); +const completedLegacyBatch = (task: Task) => !task.deferred && Boolean(task.review && !task.review.counts.pending); +const legacyBatchListeners = new WeakSet(); // Git writes to one repository run one at a time; each returns the new status. // Concurrent requests from browser tabs share the same queue. @@ -120,7 +122,7 @@ export async function buildApp(options: AppOptions) { app.addHook('onSend', async (request, _reply, payload) => { projectRequests.get(request)?.(); return payload; }); // Compile after applying the reviewed result. Individual decisions only // update the proposal; they must not start manuscript or speculative builds. - const WRITES = /^\/api\/projects\/:id\/git\/|^\/api\/tasks\/:id\/(?:commit|apply|reconcile)$|^\/review\/:projectId\/(?:api\/changes\/(?:decide|undo)|\*)$/; + const WRITES = /^\/api\/projects\/:id\/git\/|^\/api\/tasks\/:id\/(?:commit|apply|reconcile|note-options)$|^\/review\/:projectId\/(?:api\/changes\/(?:decide|undo)|\*)$/; const completedTaskWrites = new WeakSet(); app.addHook('onResponse', async (request, reply) => { if (request.method !== 'POST' || reply.statusCode >= 400 || !WRITES.test(request.routeOptions.url || '')) return; @@ -134,13 +136,26 @@ export async function buildApp(options: AppOptions) { // A final comment also flushes any earlier accepted changes. Notes // do not resolve a pending choice and need no refresh of their own. const task = store.tasks().find(task => task.projectId === params.projectId && task.name === params['*']?.split('/')[2]); - if (!task || task.deferred || !/\/(decide|undo)$/.test(params['*'] || '') - || !task.review || (task.review.counts.pending ?? 0) > 0) return; + if (!task || !completedLegacyBatch(task) || !/\/(decide|undo)$/.test(params['*'] || '')) return; } const project = store.project(params.projectId ?? (request.routeOptions.url!.startsWith('/api/tasks/') ? store.task(params.id).projectId : params.id)); void manuscripts.refresh(project.id, project.path).catch(() => undefined); } catch { /* The paper or task is gone. */ } }); + // An alternative can finish accepting its edits after the HTTP response, + // including when its saved build resumes after a restart. Flush only the + // completed legacy batch, once per workspace even with multiple listeners. + if (!legacyBatchListeners.has(workspace)) { + legacyBatchListeners.add(workspace); + workspace.on('reviewed', (taskId: string) => { + try { + const task = store.task(taskId); + if (!completedLegacyBatch(task) || task.pendingReview || task.pendingBuild) return; + const project = store.project(task.projectId); + void manuscripts.refresh(project.id, project.path).catch(() => undefined); + } catch { /* The paper or task is gone. */ } + }); + } app.addHook('onError', async request => { projectRequests.get(request)?.(); }); await app.register(compress); app.setErrorHandler((error, _request, reply) => { reply.code((error as any).statusCode || 400).send({ error: (error as Error).message }); }); @@ -355,7 +370,11 @@ export async function buildApp(options: AppOptions) { }, { route, body: input }); if (result.error) throw new Error(result.error); await workspace.refresh(current.id); - return (await noteOptionContext(store, store.task(current.id), body.key, client)).options; + const confirmed = store.task(current.id); + // The final choice can come from this drawer after earlier decisions + // in the PDF. Legacy tasks have no later application step to flush it. + if (completedLegacyBatch(confirmed)) completedTaskWrites.add(request); + return (await noteOptionContext(store, confirmed, body.key, client)).options; } const route = `${taskRoute(current.name)}/source/options`, input = { id: string(body.id, 'Option request ID', 80), round: body.round, edits: option.edits, ...(body.accept ? { accept: true } : {}) }; // Keep the annotation's original association before the engine starts diff --git a/src/server/workspace.ts b/src/server/workspace.ts index fd6b8362..270447ca 100644 --- a/src/server/workspace.ts +++ b/src/server/workspace.ts @@ -968,6 +968,9 @@ ${this.agentOf(task) === 'antigravity' ? 'Use file tools for manuscript edits. S await delay(1500); } await this.refresh(taskId); + // A saved note option may have accepted the last legacy change while + // this job ran. Notify listeners only after its result is confirmed. + this.emit('reviewed', taskId); } catch (e) { if (this.closing) return; if (reviewConnectionError(e)) { diff --git a/src/web/note-results.tsx b/src/web/note-results.tsx index a9933dbb..477909e3 100644 --- a/src/web/note-results.tsx +++ b/src/web/note-results.tsx @@ -363,6 +363,9 @@ function NoteAlternatives({ note, navigation }: { note: NoteResult; navigation: // Rejection restores the original edit and never waits for an option PDF. if (action === 'accept') await links.previewRequest(task.id, note.key, data.round, option.id, false, data.comparisonKey); const result = await request(task.id, note.key, { round: data.round, option: option.id, id: requestId.current.id, ...(action === 'accept' ? { accept: true } : { reject: true }) }); + // The drawer can remain mounted through undo. Only an unconfirmed + // request may reuse its ID; the next decision needs a fresh identity. + requestId.current = null; const rejected = optionFeedback(option.id, 'reject'); if (rejected) draft.remove(rejected.id); browserStorage.removeItem(storageKey); if ('options' in result) { setData(result); setSelected(result.selected || 'current'); setApplying(false); } diff --git a/tests/note-option-reject.test.ts b/tests/note-option-reject.test.ts index 0fe84452..b952c414 100644 --- a/tests/note-option-reject.test.ts +++ b/tests/note-option-reject.test.ts @@ -13,6 +13,70 @@ import type { Codex } from '../src/server/codex.ts'; import type { Task } from '../src/shared.ts'; class Agent extends EventEmitter { ready = false; close() {} } +for (const deferred of [false, true]) test(`${deferred ? 'deferred' : 'legacy'} note decisions respect the final manuscript batch boundary`, async () => { + const f = new Fixture(), engine = f.app(); await engine.ready; + const task = engine.tasks.create('note-batch', 'Note batch', [], ['paper'], deferred); + f.edit(task, METHOD, 'Method sentence 2.', 'Approach sentence 2.'); + f.edit(task, METHOD, 'Detail sentence 15.', 'Detail proposal 15.'); + const round = fakeRound(engine, task.name); + const note = round.units.find(unit => unit.new_text === 'Approach')!, earlier = round.units.find(unit => unit.new_text === 'proposal')!; + assert.equal(round.units.length, 2); + const detail = { ...round, round: 1, units: round.units.map(unit => { + const lines = engine.repo.blob(round.before, unit.file)!.toString().split('\n'); + return { ...unit, old_context: { text: lines[unit.old_start - 1], start: lines.slice(0, unit.old_start - 1).map(line => line + '\n').join('').length } }; + }), sections: [], loose: round.units.map(unit => ({ kind: 'change', units: [unit.id] })) }; + const server = createServer(engine); await server.listen({ host: '127.0.0.1', port: 0 }); + const transport = new Client(`http://127.0.0.1:${(server.server.address() as { port: number }).port}`, engine.csrf); + engine.url = transport.url; + let releaseBuild!: () => void, polledBuild!: () => void; + const buildGate = new Promise<{ status: string; round: number }>(resolve => { releaseBuild = () => resolve({ status: 'done', round: 1 }); }); + const buildStarted = new Promise(resolve => { polledBuild = resolve; }); + const client = { url: transport.url, token: transport.token, get: (route: string) => { + if (route === '/api/jobs/option-build') { polledBuild(); return buildGate as Promise; } + return route.includes('/rounds/') ? Promise.resolve(detail as T) : transport.get(route); + }, post: (route: string, body?: unknown) => transport.post(route, body) }; + const store = new Store(':memory:'), workspace = new Workspace(store, new Agent() as unknown as Codex, async () => client); + const manuscripts = new Manuscripts(); let refreshes = 0; + manuscripts.refresh = async () => { refreshes++; }; + store.saveProject({ id: 'paper', name: 'Paper', path: f.root, createdAt: 1 }); + store.saveTask({ id: 'task', projectId: 'paper', name: task.name, title: task.title, worktree: task.worktree, instructions: '', deferred, status: 'reviewing', createdAt: 1, updatedAt: 1, feedbackCursor: 0, review: { round: 1 }, error: null } as Task); + store.saveRun({ id: 'run', taskId: 'task', requestKey: 'run', prompt: '', status: 'completed', createdAt: 1, finishedAt: 2, reviewRound: 1 }); + store.saveItem({ id: 'run', taskId: 'task', type: 'user', text: '', createdAt: 1, meta: { notes: [{ where: `${METHOD} line ${note.old_start}`, quote: 'Method', comment: 'Improve wording.' }] } }); + const local = await buildApp({ listener: { kind: 'local', port: 7274, publicOrigin: '', owner: '' }, store, workspace, manuscripts, csrf: 'test-csrf', serialGit: gitQueue(), dist: null, version: 'test', connectReview: async () => client }); + const headers = { host: '127.0.0.1:7274', origin: 'http://127.0.0.1:7274', 'x-papilot-token': 'test-csrf', 'x-review-token': engine.csrf }; + const proxy = (route: string, payload: Record) => local.inject({ method: 'POST', url: `/review/paper/api/tasks/${task.name}/${route}`, headers, payload }); + const choose = (payload: Record) => local.inject({ method: 'POST', url: '/api/tasks/task/note-options', headers, payload: { key: 'run:0', round: 1, option: 'current', ...payload } }); + try { + const first = await proxy('decide', { id: 'earlier', round: 1, changes: [earlier.id], action: 'accept' }); + assert.equal(first.statusCode, 200, first.body); + assert.equal(refreshes, 0, 'earlier proxy acceptance waits for the remaining note'); + const selection = await choose({}); + assert.equal(selection.statusCode, 200, selection.body); + assert.equal((await choose({ id: 'invalid', round: 2, accept: true })).statusCode, 400); + assert.equal(refreshes, 0, 'a selection or failed decision is not a batch boundary'); + for (const [index, action] of ['accept', 'reject'].entries()) { + const response = await choose({ id: `final-${action}`, [action]: true }); + assert.equal(response.statusCode, 200, response.body); + assert.equal(store.task('task').review?.counts.pending, 0); + assert.equal(refreshes, deferred ? 0 : index + 1, 'the confirmed final drawer decision flushes only a legacy manuscript'); + const invalid = await choose({ id: `new-${action}`, [action]: true }); + assert.equal(invalid.statusCode, 400, invalid.body); + assert.equal(refreshes, deferred ? 0 : index + 1, 'a failed duplicate cannot trigger compilation'); + const undo = await proxy('undo', { id: `final-${action}` }); + assert.equal(undo.statusCode, 200, undo.body); + assert.equal(refreshes, deferred ? 0 : index + 1, 'undo reopens a pending batch'); + } + // An accepted alternative finishes its decision in the engine build job, + // after the drawer's HTTP response. Resuming that job must flush as well. + store.updateTask('task', { status: 'building', pendingBuild: { requestId: 'option-save', job: 'option-build', runs: [], sendBack: false, restarts: 0 } }); + const building = workspace.resumeBuild('task'); await buildStarted; + assert.equal(refreshes, deferred ? 0 : 2, 'an unfinished option build does not flush'); + await engine.review.decide(task.name, { id: 'built-option', round: 1, changes: [note.id], action: 'accept' }); + releaseBuild(); await building; + assert.equal(store.task('task').status, 'reviewing'); + assert.equal(refreshes, deferred ? 0 : 3, 'a confirmed option build flushes the final legacy batch'); + } finally { releaseBuild(); await local.close(); workspace.close(); store.close(); await server.close(); await f.close(); } +}); test('rejecting a note restores its source even when previewing an alternative; neighboring edits and undo survive', async () => { const f = new Fixture(), engine = f.app(); await engine.ready; const task = engine.tasks.create('reject-note', 'Reject a note', [], ['paper'], true); From 58b12a4b5a6f1315b2be2964e3adb913e4766a96 Mon Sep 17 00:00:00 2001 From: Chen Cui Date: Tue, 10 Nov 2026 14:51:07 +0800 Subject: [PATCH 09/12] Confirm legacy review completion once across recovery paths --- src/server/app.ts | 40 ++++++++++++------------ src/server/workspace.ts | 9 ++++-- tests/app.test.ts | 69 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 91 insertions(+), 27 deletions(-) diff --git a/src/server/app.ts b/src/server/app.ts index 9af0b0aa..162d24af 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -55,15 +55,15 @@ function settle(workspace: Workspace, taskId: string, legacy = false) { tasks.set(taskId, entry); entry.done = (async () => { try { - let confirmed = false; + let confirmed = false, confirmation: Promise | undefined; do { entry.again = false; - try { confirmed = await workspace.refresh(taskId); } + try { confirmation = workspace.refresh(taskId); confirmed = await confirmation; } catch { confirmed = false; } } while (entry.again); const current = confirmed && entry.legacy && workspace.store.findTask(taskId); if (current && completedLegacyBatch(current) && !current.pendingReview && !current.pendingBuild) - workspace.emit('reviewed', taskId); + workspace.emit('reviewed', taskId, confirmation); } finally { if (tasks.get(taskId) === entry) tasks.delete(taskId); } })().catch(() => undefined); } @@ -155,7 +155,7 @@ export async function buildApp(options: AppOptions) { app.addHook('onSend', async (request, _reply, payload) => { projectRequests.get(request)?.(); return payload; }); // Refresh after applying the reviewed result. Intermediate decisions only // update the proposal; the final selection can still be predicted ahead. - const WRITES = /^\/api\/projects\/:id\/git\/|^\/api\/tasks\/:id\/(?:commit|apply|reconcile|note-options)$|^\/review\/:projectId\/(?:api\/changes\/(?:decide|undo)|\*)$/; + const WRITES = /^\/api\/projects\/:id\/git\/|^\/api\/tasks\/:id\/(?:commit|apply)$|^\/review\/:projectId\/(?:api\/changes\/(?:decide|undo)|\*)$/; const completedTaskWrites = new WeakSet(); app.addHook('onResponse', async (request, reply) => { if (request.method !== 'POST' || reply.statusCode >= 400 || !WRITES.test(request.routeOptions.url || '')) return; @@ -171,15 +171,20 @@ export async function buildApp(options: AppOptions) { void manuscripts.refresh(project.id, project.path).catch(() => undefined); } catch { /* The paper or task is gone. */ } }); - // An alternative can finish accepting its edits after the HTTP response, - // including when its saved build resumes after a restart. Flush only the - // completed legacy batch, once per workspace even with multiple listeners. + // Decisions and resumed alternatives announce their confirmed result here. + // Flush only the completed legacy batch, once per workspace even with + // multiple listeners; a pending build announces its result when it finishes. if (!legacyBatchListeners.has(workspace)) { legacyBatchListeners.add(workspace); - workspace.on('reviewed', (taskId: string) => { + const completed = new WeakSet>(); + workspace.on('reviewed', (taskId: string, confirmation?: Promise) => { try { const task = store.task(taskId); if (!completedLegacyBatch(task) || task.pendingReview || task.pendingBuild) return; + // A resumed build and a decision can await the same summary read. + // Its promise identifies that confirmation without retaining tasks. + if (confirmation && completed.has(confirmation)) return; + if (confirmation) completed.add(confirmation); const project = store.project(task.projectId); void manuscripts.refresh(project.id, project.path).catch(() => undefined); } catch { /* The paper or task is gone. */ } @@ -411,12 +416,11 @@ export async function buildApp(options: AppOptions) { return value; }, { route, body: input }); if (result.error) throw new Error(result.error); - const refreshed = await workspace.refresh(current.id); - const confirmed = store.task(current.id); - // The final choice can come from this drawer after earlier decisions - // in the PDF. Legacy tasks have no later application step to flush it. - if (refreshed && completedLegacyBatch(confirmed)) completedTaskWrites.add(request); - return (await noteOptionContext(store, confirmed, body.key, client)).options; + // Share the proxy's settlement: a preceding undo and this final + // drawer choice may still be reading the same summary. + settle(workspace, current.id, !current.deferred); + await settled(workspace, current.id); + return (await noteOptionContext(store, store.task(current.id), body.key, client)).options; } const route = `${taskRoute(current.name)}/source/options`, input = { id: string(body.id, 'Option request ID', 80), round: body.round, edits: option.edits, ...(body.accept ? { accept: true } : {}) }; // Keep the annotation's original association before the engine starts @@ -535,13 +539,7 @@ export async function buildApp(options: AppOptions) { if ((request.body as any)?.confirm !== true) throw new Error('Confirm permanent task deletion before continuing.'); return workspace.deleteTask(request.params.id); }); - app.post<{ Params: { id: string } }>('/api/tasks/:id/reconcile', task, async request => { - const before = store.task(request.params.id), result = await workspace.reconcile(before.id); - // A recovered final decision can write directly to a legacy manuscript. - if (!before.deferred && before.pendingReview && /\/(decide|undo)$/.test(before.pendingReview.route) - && !result.pendingReview && !result.error && result.review && !result.review.counts.pending) completedTaskWrites.add(request); - return result; - }); + app.post<{ Params: { id: string } }>('/api/tasks/:id/reconcile', task, request => workspace.reconcile(request.params.id)); app.post<{ Params: { id: string } }>('/api/tasks/:id/publish', task, request => { const task = store.task(request.params.id); if (task.preparation || ['running', 'waiting_input', 'building'].includes(task.status)) throw new Error('This task is busy. Wait for it to finish or pause it first.'); void workspace.publish(task.id); return { ok: true }; diff --git a/src/server/workspace.ts b/src/server/workspace.ts index 466fee38..31c56925 100644 --- a/src/server/workspace.ts +++ b/src/server/workspace.ts @@ -919,7 +919,8 @@ ${this.agentOf(task) === 'antigravity' ? 'Use file tools for manuscript edits. S if (!confirmedReviewResponse(response.status, value, task.name)) throw new Error('The review engine could not confirm this action. Check again when it is available.'); // Keep the replay identity until both the action and its resulting // summary are confirmed; old completed counts cannot confirm an undo. - if (!await this.refresh(task.id)) return this.store.task(task.id); + const confirmation = this.refresh(task.id); + if (!await confirmation) return this.store.task(task.id); const sourceBuild = response.ok && startsBuild(pending.route, value); this.store.transaction(() => { // The status the task reached meanwhile, recorded by holdForReview. A @@ -933,6 +934,9 @@ ${this.agentOf(task) === 'antigravity' ? 'Use file tools for manuscript edits. S // So does the build held after the agent's turn for this action, and a // build whose connection was lost meanwhile. if (sourceBuild || this.store.task(task.id).pendingBuild) void this.resumeBuild(task.id); + // The replay succeeded even if a previous build's error stays visible. + // A still-pending build delays the legacy refresh until its own result. + if (response.ok && /\/(decide|undo)$/.test(pending.route)) this.emit('reviewed', task.id, confirmation); return this.store.task(task.id); } finally { this.reviewWrites.delete(task.id); this.schedule(); } } @@ -1142,7 +1146,8 @@ ${this.agentOf(task) === 'antigravity' ? 'Use file tools for manuscript edits. S } // A saved note option may have accepted the last legacy change while // this job ran. Notify listeners only after its result is confirmed. - if (await this.refresh(taskId)) this.emit('reviewed', taskId); + const confirmation = this.refresh(taskId); + if (await confirmation) this.emit('reviewed', taskId, confirmation); } catch (e) { if (this.closing) return; if (reviewConnectionError(e)) { diff --git a/tests/app.test.ts b/tests/app.test.ts index 31ccd0df..3ba5a722 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -1248,6 +1248,64 @@ test('a completed alternative build does not announce a legacy batch after an un } }); +test('recovering a final legacy decision waits for its pending alternative build before refreshing', { timeout: 10000 }, async () => { + const f = await legacyBatchFixture(0); + let finish = (_job: object) => {}; + const job = new Promise(resolve => { finish = resolve; }); + f.review.get = async (route: string) => { + if (route.startsWith('/api/jobs/')) return await job as any; + return (route.includes('/feedback') ? { feedback: [] } : { state: 'done', round: 1, counts: { pending: 0, accepted: 1 } }) as any; + }; + try { + f.store.updateTask('thesis-task', { status: 'uncertain', + pendingBuild: { requestId: 'saved-option', job: 'option-job', runs: [], sendBack: false, restarts: 0 }, + pendingReview: { route: '/api/tasks/p-thesis-task/decide', body: { id: 'final-accept', action: 'accept' }, previousStatus: 'building' } }); + const response = await f.local.inject({ method: 'POST', url: '/api/tasks/thesis-task/reconcile', headers: f.as, payload: {} }); + assert.equal(response.statusCode, 200, response.body); + assert.equal(response.json().pendingReview, null); assert.equal(response.json().pendingBuild.job, 'option-job'); + assert.deepEqual(f.refreshes, [], 'the recovered decision must not flush a result still being built'); + finish({ status: 'done', round: 1 }); await eventually(() => f.refreshes.length === 1); + assert.equal(f.store.task('thesis-task').pendingBuild, null); + assert.deepEqual(f.refreshes, ['thesis']); + } finally { finish({ status: 'done', round: 1 }); await eventually(() => !f.store.task('thesis-task').pendingBuild); await f.close(); } +}); + +test('a confirmed legacy replay refreshes without clearing an earlier build error', { timeout: 10000 }, async () => { + const f = await legacyBatchFixture(0); + f.review.get = async (route: string) => (route.includes('/feedback') ? { feedback: [] } : { state: 'done', round: 1, counts: { pending: 0, accepted: 1 } }) as any; + try { + f.store.updateTask('thesis-task', { status: 'uncertain', error: 'Check the action.', + pendingReview: { route: '/api/tasks/p-thesis-task/decide', body: { id: 'final-accept', action: 'accept' }, previousStatus: 'failed', previousError: 'PDF build failed.' } }); + const response = await f.local.inject({ method: 'POST', url: '/api/tasks/thesis-task/reconcile', headers: f.as, payload: {} }); + assert.equal(response.statusCode, 200, response.body); + assert.equal(response.json().pendingReview, null); + assert.equal(response.json().status, 'failed'); assert.equal(response.json().error, 'PDF build failed.'); + assert.deepEqual(f.refreshes, ['thesis'], 'the old error does not negate the confirmed final decision'); + } finally { await f.close(); } +}); + +test('a legacy build completion and a pending decision summary flush their shared result once', { timeout: 10000 }, async () => { + const f = await legacyBatchFixture(1); + let finish = (_job: object) => {}, release = () => {}, reads = 0; + const job = new Promise(resolve => { finish = resolve; }); + f.review.get = async (route: string) => { + if (route.startsWith('/api/jobs/')) return await job as any; + if (route.includes('/feedback')) return { feedback: [] } as any; + if (++reads === 1) await new Promise(resolve => { release = resolve; }); + return { state: 'done', round: 1, counts: { pending: 0, accepted: 1 } } as any; + }; + let building = Promise.resolve(false); + try { + f.store.updateTask('thesis-task', { status: 'building', pendingBuild: { requestId: 'saved-option', job: 'option-job', runs: [], sendBack: false, restarts: 0 } }); + building = f.workspace.resumeBuild('thesis-task'); + assert.equal((await f.post()).statusCode, 200); + assert.equal(reads, 1); + finish({ status: 'done', round: 1 }); await eventually(() => !f.store.task('thesis-task').pendingBuild); + release(); await building; await f.poll(); + assert.deepEqual(f.refreshes, ['thesis']); + } finally { finish({ status: 'done', round: 1 }); release(); await building; await f.close(); } +}); + test('a review decision replies before the task summary is read again, and the next poll waits for that read', { timeout: 10000 }, async () => { const f = await fixture(), engine = await decisionEngine(); f.review.url = engine.url; @@ -1347,7 +1405,8 @@ test('accepting and rejecting go ahead while the agent edits a deferred task; un }); test('save previews and status checks do not compile an unapplied manuscript', async () => { - const f = await fixture(); + const f = await fixture(), engine = await decisionEngine(); + f.review.url = engine.url; let refreshes = 0; f.manuscripts.refresh = async () => { refreshes++; }; const headers = { ...LOCAL, 'x-papilot-token': 'local-csrf' }; @@ -1372,15 +1431,17 @@ test('save previews and status checks do not compile an unapplied manuscript', a const checked = await f.local.inject({ method: 'POST', url: '/api/tasks/thesis-task/reconcile', headers, payload: {} }); assert.equal(checked.statusCode, 200, checked.body); assert.equal(refreshes, 0); - // Recovering a lost decision response obeys the same batching boundary. + // Exercise the real replay adapter: only it knows whether the recovered + // action succeeded, independently of a previous build error. + f.workspace.reconcile = Workspace.prototype.reconcile.bind(f.workspace); for (const [deferred, pending, state, expected] of [[true, 0, 'revise', 0], [false, 1, 'reviewing', 0], [false, 0, 'done', 1], [false, 0, 'revise', 2]] as const) { f.store.updateTask('thesis-task', { deferred, status: 'uncertain', pendingReview: { route: '/api/tasks/p-thesis-task/decide', body: { id: 'lost', action: 'accept' }, previousStatus: 'reviewing' } }); - f.workspace.reconcile = async () => f.store.updateTask('thesis-task', { status: 'reviewing', pendingReview: null, review: { state, round: 1, counts: { pending }, unsent: 0 } }); + f.review.get = async (route: string) => (route.includes('/feedback') ? { feedback: [] } : { state, round: 1, counts: { pending } }) as any; const recovered = await f.local.inject({ method: 'POST', url: '/api/tasks/thesis-task/reconcile', headers, payload: {} }); assert.equal(recovered.statusCode, 200, recovered.body); assert.equal(refreshes, expected); } - } finally { await f.close(); } + } finally { await engine.close(); await f.close(); } }); test('deferred commits refresh only after a completed manuscript delivery, even without a commit hash', async () => { From 941d74d0b5832dea640e7f1db6543e59b281c533 Mon Sep 17 00:00:00 2001 From: Chen Cui Date: Tue, 10 Nov 2026 14:53:23 +0800 Subject: [PATCH 10/12] Clarify continuous note review in the changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d9984fc..2baf1470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Notable changes to Papilot, newest first. The format follows [Keep a Changelog]( - **Smoother review scrolling.** The review scrolls natively, and selectable PDF text is laid out once scrolling rests. ([#112](https://github.com/PapilotAI/papilot/pull/112)) - **A much smaller desktop installer.** The desktop app no longer includes Codex: it uses the Codex installed on the computer, and **Settings → Runtime → Agents** says when none is found or none runs, so you can install it and select **Reconnect**. The review engine also no longer carries its own copy of dependencies the app already has. ([#110](https://github.com/PapilotAI/papilot/pull/110)) - **Quicker with a long history.** Each streamed agent message updates one task and one message instead of reading every task and the whole conversation again, and the app's state poll gets an empty answer when nothing changed instead of every task again. ([#110](https://github.com/PapilotAI/papilot/pull/110)) -- **Note review stays continuous.** Prepared alternatives retain the current note and toolbar while switching, saving or compiling; alternative previews reconnect once if the review engine restarts. ([#109](https://github.com/PapilotAI/papilot/pull/109)) +- **Note review stays continuous.** The drawer stays open, advances to the next pending note after a confirmed decision, and leaves the final note available to inspect. Alternative previews reconnect once if the review engine restarts. ([#109](https://github.com/PapilotAI/papilot/pull/109)) ### Fixed From eec474bc4b12bc95a0ae5800ec903246c22b94f9 Mon Sep 17 00:00:00 2001 From: Chen Cui Date: Tue, 10 Nov 2026 15:01:47 +0800 Subject: [PATCH 11/12] Announce review readiness only after comparisons finish --- engine/src/web/review.tsx | 15 +++++++++++++-- scripts/check-review-application.mjs | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/engine/src/web/review.tsx b/engine/src/web/review.tsx index e416ecb6..d92dc1c1 100644 --- a/engine/src/web/review.tsx +++ b/engine/src/web/review.tsx @@ -570,6 +570,10 @@ export function ReviewApp({ setOnline(true); setError(""); setSettling(false); + const unavailable = (value: PageState) => value.state === "building" || value.state === "build_failed" || Boolean(value.rendering); + // A new source round can precede its PDF, or retain a failed build. + // Clear only the previous build's success message, not action feedback. + if (unavailable(next)) setToast(message => message === t("Changes are ready") ? "" : message); const target = pinned || next.latest; if ( target && @@ -641,9 +645,16 @@ export function ReviewApp({ const shown = paper.current?.querySelector(`[data-stop="${index}"]`); if (shown) reveal(shown); }); - else if (previous.detail) + if (previous.detail && !loaded.rendering && !unavailable(loadedState) && (!same || previous.detail.rendering)) setToast(t("Changes are ready")); - } else setState(next); + } else { + setState(next); + // The PDF and job status are read separately. If the PDF arrived + // first, announce it when the publication's status catches up. + if (previous.detail && !previous.detail.rendering && !unavailable(next) && + (previous.state?.state === "building" || previous.state?.state === "build_failed")) + setToast(t("Changes are ready")); + } } catch (e) { if (!cancelled) { setOnline(false); diff --git a/scripts/check-review-application.mjs b/scripts/check-review-application.mjs index ea432bae..0271a8a6 100644 --- a/scripts/check-review-application.mjs +++ b/scripts/check-review-application.mjs @@ -215,16 +215,36 @@ try { await workspace.publish('retry'); await page.getByRole('button', { name: /^Compilation recovery/ }).click(); await expect(viewer().locator('.bar .accept-all')).toBeEnabled(); + // Record transient false success messages too: waiting for a toast to + // disappear could otherwise pass only because its six-second timer expired. + await viewer().locator('body').evaluate(body => { + window.readyWhileUnavailable = []; + const record = () => { + const toast = [...body.querySelectorAll('.toast')].find(element => element.textContent.trim() === 'Changes are ready'); + const unavailable = body.querySelector('.comparison-waiting, .bar .status[data-state="building"], .bar .status[data-state="build_failed"]'); + if (toast && unavailable) window.readyWhileUnavailable.push(unavailable.textContent.trim()); + }; + new MutationObserver(record).observe(body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['data-state'] }); + record(); + }); + const readyToast = () => viewer().locator('.toast').filter({ hasText: /^Changes are ready$/ }); + const noFalseReady = async () => { + assert.equal(await readyToast().count(), 0, 'an unavailable comparison never claims that changes are ready'); + assert.deepEqual(await viewer().locator('body').evaluate(() => window.readyWhileUnavailable), [], 'no transient ready toast while comparisons build or fail'); + }; f.edit(retry, 'paper/first.tex', 'Recovery first', 'Updated first'); fs.writeFileSync(hold, ''); await page.locator('.review-rebuild').click(); await expect(page.locator('.review-rebuild')).toHaveAttribute('aria-busy', 'true'); await expect(viewer().locator('.bar .status[data-state="building"]')).toHaveCSS('clip-path', 'inset(50%)'); + await expect(viewer().locator('.comparison-waiting[data-state="pending"]').first()).toBeVisible(); + await noFalseReady(); assert.equal(await viewer().locator('.bar').evaluate(el => el.offsetHeight), 44); await page.screenshot({ path: 'test-results/review-application-real-build.png' }); fs.rmSync(hold); await expect(page.locator('.review-rebuild')).toHaveAttribute('aria-busy', 'false', { timeout: 60000 }); await expect(viewer().locator('.bar .accept-all')).toBeEnabled(); + await expect(readyToast()).toBeVisible(); const goodRound = engine.review.latestRound(retry.name).number; f.edit(retry, 'paper/first.tex', 'Updated first', 'Broken first'); fs.writeFileSync(fail, ''); @@ -244,6 +264,7 @@ try { await expect(viewer().locator('.comparison-waiting').first()).toContainText('PDF comparison failed'); await expect(viewer().locator('.change:has(.comparison-waiting) button.accept')).toHaveCount(0); await expect(viewer().locator('.change.current')).toBeVisible(); + await noFalseReady(); await page.screenshot({ path: 'test-results/review-application-failed.png' }); fs.rmSync(fail); await page.locator('.review-rebuild').click(); await expect(page.locator('.review-rebuild')).toHaveAttribute('data-state', 'ready', { timeout: 60000 }); @@ -251,6 +272,8 @@ try { await expect(viewer().locator('.bar .status')).toHaveCount(0); assert.equal(engine.review.latestRound(retry.name).visuals, 'ready'); await expect(viewer().locator('.comparison-waiting')).toHaveCount(0); + await expect(readyToast()).toBeVisible(); + assert.deepEqual(await viewer().locator('body').evaluate(() => window.readyWhileUnavailable), [], 'success is announced only when the comparison is ready'); assert.deepEqual(report.errors, []); console.log('PASS: real comparison progress, carried pages, source-first failure gating and retry recover without stuck controls'); report.builds = observedBuilds; report.compiles = compiles(); From 99b4a9d3bdfb977ee2b36fc94a51087980a3ec1a Mon Sep 17 00:00:00 2001 From: Chen Cui Date: Tue, 10 Nov 2026 15:20:05 +0800 Subject: [PATCH 12/12] Wait for the current review frame in note option checks --- scripts/check-note-options-flow.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/check-note-options-flow.mjs b/scripts/check-note-options-flow.mjs index e62f38e4..128b705e 100644 --- a/scripts/check-note-options-flow.mjs +++ b/scripts/check-note-options-flow.mjs @@ -82,7 +82,15 @@ try { return route.fulfill({ contentType: 'text/html', body: rewriteReviewHtml(html, '/review/paper') }); }); const frame = () => page.frameLocator('.review-frame'), drawer = page.locator('.review-note-controls[data-open="true"]'); - const sync = () => frame().locator('body').evaluate(el => el.ownerDocument.defaultView.dispatchEvent(new Event('latex-review:decided'))); + const sync = () => expect.poll(() => page.evaluate(() => { + // The review round can replace the iframe. Resolve its current window + // in the parent context and wait until the host has attached listeners. + const current = document.querySelector('.review-frame'); + const doc = current?.contentDocument, view = current?.contentWindow; + if (!view || !doc?.querySelector('#paper') || !doc.querySelector('#papilot-embed-style')) return false; + view.dispatchEvent(new view.Event('latex-review:decided')); + return true; + }), { timeout: 15000 }).toBe(true); const tab = name => drawer.getByRole('tab', { name, exact: true }); const comparison = revision => drawer.locator(`.note-option-comparison[data-revision="${revision}"]`); try {