From a03e77deabfd069341713cceacecbefd56185df9 Mon Sep 17 00:00:00 2001 From: Luca Sforza Date: Mon, 14 Sep 2026 12:51:48 +0200 Subject: [PATCH 1/2] Use case updated --- analysis/uml/nn.vpp | Bin 1212416 -> 1212416 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/analysis/uml/nn.vpp b/analysis/uml/nn.vpp index 5f37537f9297ea996bc6a77e69f80f66c1f45b3a..0efc6c9fbc6fc288e5d7bef546012e3b27acdc0a 100644 GIT binary patch delta 45914 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 delta 35217 zcmce-bzD^4*Ec-Dz|0Us4qYNDCEeX6Afj|icc;Jrf}kKF$|0l-5a}FBLXeOY6p#=E z1QY}W>3R-V+}HKH@B4k9zutGwXU;jZ_S$RrT5Iq1ornmnhzPA@Mv80*1bPMhazG#u zUI>IJJcU;nnq6sKfFZa8f!;~=w!T^|LCy^Y6eiHLG!RH+vojRKN5RYtWkyDQaR6ka zY*IDJuT&6m_1yhGVjN!XTB^Vt=i3$c>`LT{j#QWY)}RGae^z^Dfx z2#g&R7?r40QuJ>47r}I1K^# zb_GcBg;7;9W_rq@AtaGj;5%WYdFs691*lsp!izoiikE~2858CrkrL(=N`+vO z>;X|10XJDc@&Q>L3W+wxI3PuccrlMqrUX%>>4X;%8W=L7Xyg~NJOV?aErcK8N3wa$ zM@$RpB+@#y-iry1ks_8SEkiF88K*9J38j*IBme$ZW4&*oVKDw`_dBFe95tK|s&1v} zuOH&BcuUzu%u7j06C5Atg1EYm-akY|cb;=jFBQpu6ScTO&IVU;JE8yLRJ3yBI_K2) z_WL){@O`>%CH$0Xd`VfU88G)euB_zH5 z0RbK;4ZfWlN&#aaHH7dw8+fW2I2x%ddMhexC_1Y`=13bnY+gu>lOLI5HGXftUISsK|2*(rt^bCVrBK$AG2!POLbo=~dDX4ikMb(epwbq%uf71U7Y z1tl0pWqEC>d<1?;KAm_^FT;T*7krL?TaOM(RWA zM8--+16W>%eZfoV!!X1!((ia}b675vhysFCxA(9I6fI$_i2G!s5CVY45_SPpCpw;X z_HH-q-SA*iJ3IUeBnd!7z!nJLsU)7jW&}(HFAxdaC%_WAK(K25x;J!;&sU&__Er5r z2e=U+cMrykB`kqpRV8$_{z(%ShFjtz?!lt(L+=0xaySnlL=L|Wu*?x%0$zh}WC$;W zDasjT@aKgmpn!8jp+E8bl<*W7;6x24q0@&>(!lv(Rvw!FT42JaFhFq)P7Nfj!rAb2 zH1IVkg4fU;2-qj^P~hHm_(h=VIy?dp(+A%y_2K*gP9IK&zpoEpAi_|>%Mox03|@;G zg%HAP0QMVj8X(yOu8bvSfMn{b8TvW=mwNGXffJoGP{Uwv|9bwB0sLM4*YEPbo*92V z(||}rP+*iPJPR6tXEB4TL3zoJA-pPL8XErycG7~q0IAsjL4A%-Hi!Gcp#XrehWi2$ z*6NQ+K!*6b35-1nthNR`}QRhdg?kMR zf@07yGb zB*qnU3nNc7hvC9dk)4n&BT3)_WFN?y$X=2qkp+|4lIf6%ld+Q#lkSktlJ=5TlV+2~ zl6sSxk*bpNlhTvINq&-ylK`Z1C=wcp@a zN?S318-HHes3XlDX`TzxHzQHX3hb6edd96|pr48?G!s|8( zkP3vL*6PL_f$D7;PMnT}BKf!=?B!AzJOewL4hG!v!-(RIIM7{CI2y$TZ2Dsu0QyVl z%V-G!NihKl5ivngATk}T1SDKSlL19yh<~Ms0N(s)8esDhdK{m01ucm<7m@l)mnv02 z&mD;8M|)F%g{MH#;2i>bN|@L>*m3D{)7P^7_;6hJZyDFyyJ2{H|a z#}guxpui1fumQ;}Py~>JL5cwGo#bfX8#4h_>hKj}!U8sz$5(IRi&01`2RwQhxyOo! zCL_`0#95dlbO%}isY5gkRV z*)ira<^u+YDZ&_l`AW!uq!uJCurW+bPJ+b1IiQ|C?p`(?_V~RPB&i}H6utw2?|_V} z|G$rfKmH7ZLXcAu5lur#-xGHdtNq{FizlKcf3#Otl3APEhcNidky%`6W5*)TB_1NE<~KoO8blL15<%76hafdnwi zm_cY9`T(tu4nnh_Ezz#%7&H$M(uS%-+QV~6;Eb?ac!74*A`t;v1&RhLk6>8fYA?zb zXghV1`y4`9RaU7xEvb!_U{WE zsW~Ei+6r6(fdxH%tjT|Ra!#-cVDTrM7fbdSOcNK^|Hq#UF@e1VF0I2ou%PFSH4f7C z{3lIZ9EJy8uftbB-xk7aY$7T2k79BagkJ}#SYxfLhNdCzDpwW3kE7=`(7$NefUDsN zHc?Ok1bhzy-vcZ66OK+NBqI8ALH~cfp7G#%zC%tB3PFW}MZ3ZA0m^7XLV}XuO9&uB z6K#N##6xqzszWqY6mJGu{>;Af^wuZ;ZoR3b(p$y%b*NZVE?&Bo`pPLG>h_a8?VJuq zYB~i=7#!X|P5*e%+Kxm|QISP0Zc70EDh~RLj+O2q`qif{F-^^^OFE-9&ZWOcPXiAR z*6)nl&xQ?2?g=iF2?vgCY#)TJzd3qZJrs6~a~PO(Eq_zDT+2Q!<=bn)F)r3R#lTeQ zv#hmuidHRoWnAs+a?*6Ru-qrexO?T-fXKx<{+n^q+X9yCo& zAENx2yO=M5+ex+e!wp4RC7PONi`Nx--lH1`ERM!DPVTgvrIHNF9vdvU^h^HU3*T`) z!#(oXp}RHw;1S2Q;ZNnKlezwLaNHMS`5~#L9Py)rR?+24Uk=Jk7R3JG(HP#w?dyeq z(mq=m&y{%cf=9P$MoR9eEj9&fhA6!v8Ab5WOwfCZ0{2n*&T&d{VAe2Gexr91%ci<~ z^AHnj7J8P{eR5qdWZoXPm0F%DR#J*}mbswP;wabB*Qq7k!XhA;U9VxF*&}=T}{N@&Kb=qpDm3te2li*lGzxQ>E&E`&Cx2*;{Wb- z_USb1asP%{)1(Yp*qk?2Y}XB{+$EVkQve)<>3!v)?Uo)88P<&zJGAg%MkUvA|rTX@u3q_`|#)-Oy-S&X}n z3>Ub^PI4TL%lmb@yQ28t)b(i$ZJ)AjU;bGy!OxjavPdbC-(1u*D+LermZsIxlMF+W)5>_vnl-D1Lo6B8of-D>!(P`LI1l9Vh3+%!=`?7 zbT>}lviG?uQF!~?RbQ5{%}iri_osdS$#UF*EIT}g>l&?ka(j7QeBntA50}i#7;D_( zXTx;w%9kFvkK)qSe3RtEx3`y4_GXJ4xemT$)xDh6D}UW32X;c~v)$&QketAbDdaPP z0TOokm8g#deF=qnb~>-x8W+y8{oTpqX=j-9y^rJiqp{T(aV%ec1p~@9D zR;jqSFU%xwL+6Ly-X=I1&^NeOzauLpHPpJY#W=H3#1t7HSxgaAll+n$$QxbP(3q#B zATz|1yISIomo};rr+C$dA0!eHhLM~;yPayHgjGRhP6+j$dfcHi5n-&QQ$UpVAl`ag zc`@=uqfN*;oqR;nx$G{)^de2d-#y|H=Bf~r!TdT%9DGk*~g@fbmP#Cw)2l}UhKdM$<^@94IeKleMK|aRD~yh zy7IBuc87rH5_0BOXyWq9AO64rO@czf%Av4w5@lovX!G)6WbtZ|=6@HLMhCQequ=3?s}y?# z_~0PWOM($P<4GdWQ*an88vp$+S^*AtWg|I(WF`y+&>BQW0g%O^iSXQvn455X-vhLS zC>_Mp*2e9+kEe^ht>0e{HD@<_Upzq`i6{oRUQSYtgIx2odtWxraruw#eamO+?XZ$LCJ@x*q46JI<@GVq@Z=nJF_R|Ah4#{jFQ#spP)4H~sc$L* z;ulNqX}2#R)={NUnwkq9Y)iuTln5S{uQf8>Ba+`*ezT8}>>-rq^A zaGz(&<~zB3pEgn{!qs0zUBwB|E0r8^lH%4<4&HXO3wjq->1ug z9Z)gbD#!Y& zp`sj?;k}rr&AN)*OY)n;-;xkH%lUe6wNZb8-^7d@Y^WAf+N|J*&yRlM5XXh z|HM+q({BWSsV$lpR8b}uo!6!1<`48MZ^XSe6V&(|5OgG~4G{6sq2Q@}vNx12jpH&7 z2O(z+Hn|&5tpe!Ycx=d0^A-lxQ!^~5Kv(9v1}^Q&`V1HpJbb$-^QeUz(K|E0Ld^ug zmODp}K9*?FP~xV{`zpilixx=649u38y~aywei+wqIm{sW z4ems0yLmk7TS2)5ElVz!?&RsnYbD%+^4xF1ET1OJHBp((PB%0V9~TtXZ2ToBWyx!f zIN!c<()uW9-~6=d=wVW~$MjiNYo;YG_h>2x3JugO{q3NZLc_KHDd0qkR}|ZHi!izL zzJMan6t-Jt%dhsF%Rsm0dG&HIZ6WfN!26PCq+_gDu?U&fl0nDZJBk~^s<`!y7EVU) zmECqF0@CyxhkV|&jHk{NLPdoogcv1Dcux*7E7M#{!EkRq)6 zx~M+Wc8~D(%$^-wG074?zW-{!q;57Imte)6wLP~y-?4SL)ca}aVB+D?07urrMe*hK z@fn+A^Yx)S8)Bth`^_9i8#t|_FK>#oMzYYHVS&d>i+8M_vmU<4J{rb!hxD7J)k3dt zwRmRu9(^j^>Gqh&T`x4++4?wA=TJ>yu(T}sCL`r%d|x_r)-u$)fi^dCGRh{4w71@7 z?VjUt`<~~3ljBpRvn~1;R>BTWTHyhMjtd-#y@BC}fxOp-A%ep3SIltPy|*E+FMIR8 zx=0ttq=4mKsn1Btn{j>Yp7aiB8qM{t-&?k4m}*WwUYatx4GJo1dZKs=5$lL6h73kg ziqG7GO@2P&p}lAeDT*@eYQK%pVPXh`yFScSK%Nep|awk0_S;H zOP2kv(t_+6Ei!AwQjydDu7vHZH*Rt-+-)l_Rq5mjg-cge33AB?CrQl=j{|UhB%7VN zg!Sx-FWbm*b1pvPu?|l(za>6D{T_ND3ys^yVd#AL1vV4m`(=4ELYCDzM*=4@Zcns6?lioLGDKF4 z#L?^9=Ks8~axtC&2bC9nzSYX(6l%p=!6Wlo#K~ziU|XYYMmBc!ZCx1gbF?Wto0j4= z20VPPW4DlkH%*G;rmGlLcR$PKL00Rb>vkNCS~Cg3b8O#|Hcic2!nE{c9J6yl^i~Al?N0Z?tVW6D z*DV|c)x;${HDk}vre9&j38I&l5|Y*qN2^9S$&H;JnLc|@_V^L#wvwBVgY|*_T4~F6 zc=t`f-WL=8n6v(d0iqNM`&wUZfHid>a>p?wcPOr_6S}cxaw@u&WDF%%dV>x$@~*LS zsB@^x92pwdJJ{FLL(D+kB{ptIb zEsHW#Q7oys77riKCH#(n*+kvjwv=(+HRQ86ykHU~@8l^cL~v;PpceR(5D^g9RZ*+stf=D70g_YVtlOBSGD z+31*`ArqtXbJ{ZwGt!+{x)fsE`!M~Z^V_C0mj4pF0hCPMie2nth*n4ws_Fhp@2E8W%tq(#9eleHL&wbQMbDyvlPIs^$ zZd;o<-!Yx;P-+@`9Lb(9L*;ndK2)Vbr&9Ue+^DDRd_zXK=plgndO<@kenHtlz2oBu zlH9y(@uE$orWRO8Wkso9h^aYYjIq%}Wu8eY4buz3n$`vHW+ZBO{+MS@sA)vC(L_|2 zQq!ql5vYD@KsPJ7lhbQV^lrCa9RmAJ4~PNGBz1U36SrTuIFAKOf;^p( zHBZ!rjh&>wBAAh*|DO3b{&nEQAXy_av|$tVV26NLg6`KyTSMdC1<_;2YgOiUxj*nW284?atq(}v;+uUQAy^rsJ)R8vtAwa zP63B!wU;^eE97aCY$~qh@oV(o9ckYs%4|40(MZ9Xo=MWi(};Z1`)r;wz*@QQM%U|* zTqViby@Kn(vj}H7RGD1Nvfk?E%2Inzn6~RLJ|o#}-Yx1TF?4Cy?Ln`%*zqDAqk~Xa zp5A)abaQx;Riz6sJMM;_eEJ1)Q>oW4TnAC-|tI+Clal= z!1FAtYC6mtUHRV9^L3pzHx==+b*_pF%0%Dfn<|}qAeEUGWw7J5+eJ|xcii@(9EKFe zFKAD`nyK4YZ{6wklu8!0O>d7dQQeb|7`Tm)T{N2&EOy?!^$QW)xubaq44i^~R0`wJ ztdPBa$hLpL-XopW)NuDI%<%wYH*P@dsB?e#`S?%KQvo@d@b7c>X4hEfjePY6J2c{0 z<2SUk_8V%valh;($M@gf7d2s=UKnqjH7WYMju?nVR*hV%>zDQs%3j+rb$A~wSov0^ z5A=;QS!Z}ZOa_Z|y!Fk#c-B;X{~dZVjQ{<q{C8En(CH1SF2j6{hbhZgIn{L)?CZYc8n$!3rbLmSt3GBk#AxBRMeFu9) zXSeV0hYnB2w%mW%G&`DM_xZe*nfzI-Lnp)e2^Ian7fyq~*7+wr{#98*pcAf^R_r~0 zGsIdVWQGMdzY84O=u9QZ%iR7nn9#^<_jTsYGBfV&nDS|jtY_A^5F3t!@=j$`z>z+< ztEBY%0Y^O0i|d=aw-lAGH{(R(H)Z=7#UMz#=ui{!4tdyzCtdlB>pgUTKT>gfr>u58 z@QVlem6eGkLf*M^6g+0xDxPe~l52*v$Sm(R7)fBovO7%6g6Uuxse z*x$IfaIW-m6g)nt%pm?$zD14HVnyR2S#=FD7Ivvi6w|igOys$(dR4J{^GSWx6w5m$ zY(HqDDlY(e-aK9IAz$o1{0f~Ld023g*s3cxa&>g*TFLbv3h!C#!w{g6jiYHG(800@ zes74qyQN z)ZfP5gOa(8r`zKJ^!iBtmaRsVhgqe}*|R=Rzu8o!50l9!9W)QB6uQE{qE+w2g2w^U zWjiwEO+o`KRn@>V=4X4;_r7>^=1tR!9!VS>$sbq^ZI>D9;|%vZjUVaRklqz%mCc`057(OLz2mdm23U-i5I^~#QOtY@|U?e zxAwBe!DFSDw9mFo(XASSD&Gx$Eb1%#@>6_kcQ%4iH8y7_E);D&z*()0xUSKbwc1A? zS#V84wcPWcm)Mvsm7Hwk`nB??N<*U3m~dz<1AUkGSBa&AyNioBk8bPly#QwSN-dt0 zFf`8h{-(rj@z;~NPW#_};;hbAXP@R%bzAa#?$)g`nz}QN&CDtzpX{UDNY`$UpsrA| z!)1!y*B(OM-JaoJ%BQ_QZe)jKuiZ%sZf(Zp6zBCpUe*o2yj{fCif+t?Dca7AjNx%V z!a=WWA(n=ROI5GpaqsOY%~fuMu}4{nu9@~Pi^>N~zNMGkJrq5*Br4L3ZGPduXD$B@ zo1~rfNcX* z_T$HoMo!B6r}4D>E!hkH{{9vq1;_2;;&Sz?e^^*pQe$Id6PC~P_vg&a%py`!QpfSJ zu>jqRl&J@A5a|tN1WId2c!AAnG#%hsO@gj9B2C4Rk%Gx^I1z+c zh=>DD1Ri`Gz{uk0B5lxojOXVpd`S%WU#^s71O#!!k=B4fG)4gbvx9;Q4hXnXR^f%D ziASiBFhUy$u>heBnEMtX-o}tPpI$)-avvH6L3ks~5UL1%crx4$<_A9ogDdn1IKfYX zae}u5r3C4iRrm$?3g#206^yE-V!|+v7=4U1h7$uulf8&}0WmM)OMs;pu?7KB5yk|R zc@awyBHx`~cs6fhN~kfcAEFczpe!i%Uoaw7Us(G&^}j)ev|&vkl{fG|;Xy{Q`tumA zr;TvPKf%}_dw3P_z=zl!S^(_(5OV`_%P0mw#+UdC;4%-sh5Hgy0ul3I0Jy-HSP6K4 zo~m^Ilg^Kr5{8Hcc>Rdo0IOw`GBD*wd=m)ziZaH>`V+^Y0O$`C6>tGZ+)MultjNYk z@SpI$u`UTX7S%vX9Vs2qT|t65kDz_R6eJT5qt8bP3Oj|sPQgC@4I|FL!H|5Me9~1u z7(9A`V3!2p4|4PaiucHv|K~8LVCo`B2-tASIDwT8d-~+4woT#zG(^lbdzqTx`F0dV zXzScj?N~V3H0ktD?m+F9*4t438j&w{qWimsP7R~Q@G2V0mQOE36y7tK2{OX{mi3)3X& zFHEVFN*5oOnzo~>Nu9nn7XE~Cm$<+U>-hZ$5y{<%8yp_*QYZ+qv3QfbY(jZ)3w*_&T6)v~ihKLd8zs8Zj z{t?Rd>I6AuJLp!mWn)t3gWdkx1J4Bdy%pCY3|@*-q4B@26B_w;^uVgeI0pL_=m*o& zm;&kB)%ssQ)Hv~fu?E$AFBVQy)T57``028jmx6nW9yt~s7x{+aB7CrL2~$D%25H>C z+2w$U!29HTkjE#+t9l(rEsUEkNaDQ2z3|1;rDZS;xr@Nyb`vvym$-MM#`;%y)NN^^ zu-_uA!>$^U2QjbO(GUlgLkEOH^pl=;qEIZ0;Soh$chzURwGQ6sd+v>DEMBg$TK8wy zA2{5uee&G!qh8&9W7didoF^l#IQvo<2VnUI#brqw&aVkcFYVvHnOwzWe-9^a)D+wI zeAAm-V|&Palo#W@P+9=a+*!+bI5pJpHNr>a z5e0d@z+>`fl2i%IIOLwYDK|1-3B}O~ePV5`JjVMGqi$Xl$*E@QdJ(w!x+G;H;iJ{^ z7cNLdu(G*!(JMQ1Cbu>%-xo#V>pUlS)lP$78*l4bq=sRFtsdv;W4zNZ;Cs!@3v~Yp}u!)c&C8iZjvM z>wNbk_{MB>Nhk~MGw&aMjnkG+{wUy5LUWV+8{@%>C4EtALbR;H=eCt;5fhr1_k5{S zu7a&(42dnp9gjXvS<0;+>=kzZb%kF%kp{XzRiFRIO7|_hd)?3UN|dd|-j!Y(z)@LH z0M;$Uc0K9Egy_~ax$OwO4?EQtgCp*d_gCK-)*ECGOlkM6mb#g6zng`dFr6%62o^aY z`5+>ij|Xhk@CW60QVzG|Y=vvT4{@J9>Z-OE%H6?vEcf!x1U!I*8~E+$d{>WE-8{-W z7>YBLuXtMsv=+w9-rVqgdaZ2KCN~CCa)g}Pepe^GG`a)vp7WUVFlsOx`L@AN>n&qu zIW&5-Sgl9`si}XKfyJ4tF?P{6#~MPKwfKYl5AK-0dZ+wRR%Ira@$K$}P!@KVf-G33 z{%Ru5Co|Ui8_|;sXZQ;>4V+3?+K=o|h>KJV;V#khr|g2x+~m)_T)04`J5lCuv%ZOK ziFC4|D{?Wm?0sd)Uu^4v@a7u2K}t=N;Or1%|B*$Y%&0fGa(0|aZ94@}-98Qvugt_zm*08MGE8XLp|APy zu|i?g2Q1drvJD-h$3hX}& zDi1Ngqo$MW} zqqCq*n1d6tR@4Ug{WZ_FpjHF&Lj~KvZf$J)GwIkp48+S+D%GI=lW%Fm164;ckH!qUy*>~*Msz@RGtCO^x z;lwVt2m(8i1>33NbsDw3$BQ%n!zgH0x4$p!|AsU+kALyF7fP5`LwR8w$E}pb7}AqV zfL@WkIrP>TEzN5TSF5S2BDo&LxNOsOA)3@|*(Cb!^G+$Ic1IecXwGPrB5|;~BaW2g5ZL&RQmn<|{ zO1MlE4IVuzc;RmJLn;i8dmM-3RhQfr>(D69LKkIV8}; zz+Y~aKpX$dikw<MtR+`;Fy4$3pHOcd3{=#O5`9^hHsYxjgbx81OP``Pos- zYxuG=FTKhhqF?t#6NQWV!mYSmagvtCX8oWvQtF}w@^X(U@l#oXwvZHi8c60zNYJ27 zmXOYIaAJM8$U8PD7kq-(DPq$`qzF3KBZSh=d*cfECf*qlVFE=muptkr5ZMvYnq!2D z;{AwHe?9-@50I7*0!6C&0~eA#uQ26SCG8#(S3<%1>8Qt+sm6sS5~t6(bK0_ zAtXh8uiH5%Ge>qp-0z0RKroFEw{3V8Ximh)O+#Ol@^jb8eU2mgxB#X)JVWj0qi zq4LdXiW@-Q(KW?nzv?tw5g9Zj%ta`O{EMeikYHTY@`ugF1afN2{>!RgpAdd~@&r2h z&~AjS*`3(IR-7MZdc|8}8m@2MGi^qcs%iGs$fNgC>H%s+1$L=1fu?$W#MY|go`z+D5g=^)=k zDt!qGNXV}u^(|8S?4R%);zk;H8ZBa!{{TGPUGDD4_vwfkGYW(geIX0kNqj$4fc1?(hghOC6wt#K^LJn;3p zkWwYhN75D_do`ezGl;86lZ9W&f=7;evF@8I{%<4&u zlie|@x@7b#_S=e7M&p#vj`P;huMarw{gdyzw}f(dyTmtU_%<9^mnO0nI0|R)jC%_; z+)L@t_WF7+l&O=AilM7=abthqi9+wuf!k;8}L|3k)zwxO}JaaH0re`-FD&}~+`n+A3(ay=jWMLtkx0J@N*B+F+#hAKw(&yMz5 zRW1ESiQ=ycUl{iud#qv7(G#Fa*C%Dg1 zQ}-;R?EEl(C2l`BxVGGl{C+HDIpGopu~2LVB=^+C2PSj24sKIXy@AsroW@c2WrtHr zxTI8Dem>Bh38i}Y;q!$y87k|oB@zBMJO*n$(cm@nt7}ak%}BK#ne3re2}t01q#n|@ z`j39T@RpbAYoO4s9J{9PFG9xM` zs#Y|7hyCLOaASVDY(+#2Wh9>6#2F+?)LrhmMcL0P2cs=fotUYhA84VKN)T6#-H7>f zd>YKcjSReKb6bXbHww?fq7f;24Az+3yfd@z*y zm5%=W*k~DpCuk!%V}*VIZ0w*s=SNJjC}4q_6bsY@lWIc+{yb``+a!UL|6egJNu-r{ zC^;#d?Vm#_n2lqd0x5yO|KlMRBUqdNcdQKldJ<_h8xA5O$K8YB?LYC%a_w*+0Z(<=1Y*Jx-TKvguwdkr zobv*jRfj+|G%YM9{kX)LmRu7Vh6-tB!a#jYz{j-~2!kY{CL<)KeokzTXQ3IIg%%rE1_shBfHe*3Ka zkyBtRQkL;RHIPvjPJxrv@f3N!l8l|C99Jeo-c5H+VW9E5|J>&tmDQPHM+e68@Y|}d z<^eB=G_)Xjou|zxb`Dnh6&kE~2ZZg#F~%Jvu(K*I@Ms5iY|b^UH!p|N&@UM%0z{ix zLv%hAH4nA%TTgJ`0%$JFi!2vOe--zN=3W^gN@efNl;_Z^q4l8Nl;mpl^=xRNNX8yn^@rewA7^UQwSvqTXIx{a{V~pvQeAMDX{HX67ql8Bc-Y?uM zws>;&;%Q?8;0vd8z2^NpJl-&UTxLox@bC*&{<~qx*|4{v!j&RHy~;}&zjYLMBX?H& zSLKM)vRdZNgF*`=ypNI$znO(HyY`bJGn;WBK8f#DcjUy~A3lB|Y)%K9Z!4U%CM3`C zNvluxy4|MFVp}sT&)g51h~XwdC4a+{z8v}+yYL1yk+4lsDMlFv@Fs?@^tv20#lxan zF6t=6=sVE7u|Ba0hxa6f|IVx)lx*F|xG)5;_7cqU8Ux;KA-lHNWaYQW?6D*JEnO)kpXFr5sy9Bc^(N9L)QPT7Xu3^gqO1C-p4`q5CH;YxIIywO%{ZRq!CVMuU@vsv}$xT2jQ-w`p?Y2OAv|5t$szUnFc!&FTA%0%W>U2CYw=BY4>H?$68A?V_l#neG>TN<$vw zc(|z4oGt3--r;r+H`blPtj^O{T;pf(eO>%RHAIk9~%kR&G`DzHjn%AugDwY80PNnKY?|W?^@_w4RbQg zxc0@nKf7hGUOHeD8mKVCz~Q(1>(WIPV;mW~@5O8C`-#`d$F0BLH7p_k<9wTUcM98$ z!v2OF-59P)7%wT*v$rHFy@6_ye*o%d*5*?Yw!IF_eo-m+nYQA>(e22Wn`;G%9Sry^ z4rpL7=^n!U2&)335|EZShW6VT(-QB#VQmQp zA#FAfN5$@QA^XJweo@r`_4Ebx2Onm$I1K5236YbS@n-RW#^y?pYC`>+?*){OR-bVD zegTnBlwA42%=*cvH5T9JiFrjd`E2Zo9%zCE(*~V5E67X+7MiU3q*pZ%%??nSSAv+1 zMRd75Y0p=-z2`K|aySv~s0m|XfZ28dL?@+S7W0zVnhoiL!|%S8AiUE6TAUuDkzB$r z2v|$hy^jSP&mwEnMrSPRK4li?(HtlpjG-ztnHYr@*5b3k75JA)*T94mX_DOrREKz$fy3%figZ5ozK+E+ylBj zCGER$T2nrShYxQf6i_FFP^Wf?=P zpBRYlGHzqice)w4t01vF zET8^_Pye0M+dtCf6*-1`rLDFGaD&6C&fWfXAYjLz;yk1slbcuBVvuPm#&aWRAc3v= zo~`)0xNvkoozLQ=cNQk|vyua*!El%69I0ctk79_=@|>3&k2U=bIS;kJ)f-Np|F%C! zotLuAOVy2=4eXYizU`tu6e4wNI%Sx(ff>Z-xc`aInG&-c?P`GDuU%6J(Qmvb>Rf6E z-D25d0r5F|=lGnhatq zj8{M_MUr-MW#Magllf=mwX(5>U|Oh_Tu3ZbR&ypK3XwIKuVn-2cMk%v6k? zHvcxObki;#(A4vsYnVDN3E0-jQU6x*VRhGo*>C9Z;gtqqS2nAgpmC$JuCbt)JAeE_rfcg@YiKiv$nfWqtDD?t8fT z@|xkJ4cVu@`;Pf#;9n9J&eQf6F@&+@U+m{=WgYni1xLKIa=nkPHqSPf88_lS?^o`J z$QFFc@ZCDCXtBwUs*|hP@i%eT#m!vi>)FrUFQn1N(^-vm*_8ebw_zf>IF{+^UQ&M) zeURZA*P8Ts^RegJ=ffW|sr8=a4PVlOxbOA%uk!ga^ow$g9k+$X%EY!jKaH;y*H#)f zUJ2dK^X;9n-U$@stvSREzq8duG8`TZ2P}FBuWtfHqmldr!By+)lPCA2!&liy*H3PL zj9q`qORs3Q#Pp5aZRkqunmRqJw(jtwhOv5LU%n{gxV+Y3-|HKZ@#CHb*w`nq#rUMBF6XJbs&EwUe6c?;Cm49qcD|<} zU{15>-A=Pxp!hl$5_~o8PmqmYCGDRu8Li=Z8e4H@ftbNy*`Qvv(bLja9cE|z=+H$z z{U2><^2uS~{sV-`=tmUWRR{4a382E{c}C!VUm-*B#W4{04bRyk zY>(^kp{Y98%sf6cHNXMM;W=)w^Fj4c%Ut7--%l|aR!g66wF~xU`7#^xJRZrpn)yzi z=4_1A4+awqk79VJvzfsLX!|(La9hisY5qg2}bXr(DWc$Gc!y;G!zn3798SF zu1Bk)Kl_+&7oY7lc!YI@?lxw{_HAf=KwWnuW_BQz7IOTFJCZ%H;F5xp*9_qD^F(M~ z7Bt)2T>dGMX<6mT_hY?(>*&2tGZ@=4rXUgLopJUBsz=^ss_6cj<;OyhyWokN0Q`{rz zEb5$!Xy{#NGrb)H4)|V{X+w?}nrZTkq)tN|`*bygzkZ0?sW*^g*az!( zA*H^htZcoU>b}Z`$^~x=5Dn96C-j*kMT5k=#_0p!Q@u;6Gmm<4cJ}W7r?0mFi=%1& zMR9j0cyM>OK!UrwySvLIxD#}72m}f4F2RBm+}#Nt90J@W-}`>&f9|>W%<~jG^tM!W zb@$G6RsYK2q_DlJs;Y|{{FJ7nrgnL_w6yfsS!Q;2)*wlmil)%$R=?f*LVFzSEMwTv z(6BB|lZ3mX2fpY<-*+F-;bn+()G{mXEd%_>0w`b~n*BkZNU-b8r9~#bSO9U?^Fv`R zvy;B>bp!7Rs!=j(Y%pHcym>Ka$O}BluAbQEb}5sq*ZqQjon-{9m!z6-UY%vWqvipx z-&hHgLcf{f7PRi=oC=c=$bBLOU4Lq)E&iH^C?|hMwz{Dx z)Zy#G-+JOD)5IxLbP@eBjK%g&E3j2%LmS&w z^mN+qpDdqrcy}82rhJ&+tz&mSaGp-(_RjEQ5oYN{EgCyt!+5Xc($V;kREo zdXy=sirGxNVKi2hd#TMGrQhvtd=-Ck{{HR6#)hvLMeTzS)@Z#$Uevs>9Xi2eovJg# zw**x>KV=$*t0>MHbadp0xYF4S~p9TG%9C#}uX-*`j02zm)rx&L9@w@7NipCjB z?jt2wOdo_fJ%k=NfKhz?`v++;JW-B!c{uf-r3$BQk;>^gfNhJx+n@+-2#hM13wv+m zb+K~--_l}c>3X)eCVVm3>2MA2476b!c?@K&Uk=#N?a#0JvFixYEgCkq&-g%esQEjp(H?hj53D8m)F_>cNZV zSn*PPq-iUw2EoFD@RT8D8i6Il)Nqd6>bt0v3oT!G^?V>I&M+pCAo4@Ml(MPMK77jC z!?uqTth1RSUnY@1B+f)W(=(0B*0GsLm&X(|1kc1qLEOuu8Q;s0% z(X`kPPaZqRkd^b<7aS_7+oRQsyL*s{Ko-{IA5Jaq-T1L?*mvTo5W$3N5nwwhWm6HN z?WAi1!Y3nyKW~nN;Qzz*t_H-=z|NFJU{k^U0gdAR5VYw#Hv4_!_#8ZFU*sXSE(~Ts z8}j>T@95GVF9viN0UL%t8aw%@xV7bk9o+W{5(dQ~0$!PdbZy7olF*AJ=FZcs z0FdEWz5XfJVFJRcJWds*6}qy9>y6w8nK;hw%#kr@suw=$e6SgPa53Bc(i4*Lvmm}hoNAU(ERnG$zU}9G4fdjO9uqeXQy9{yVm1{u^f^E{EBQrVzY^Q z#C~V6EeVy1*d03|D+Zy|LL;dydT3`H55X)3Ya;({B1dIZRp!qQd;Y{2&PD6*$2Z?I z0#@FCbY?F3oR}8OAyc!(pw9DA)t(@4!!4P9FQ=iD%AA0>`x~UaL8T*iYnWxlcxH>5 zgmOqDy*T6J(cV?qSlq`YQsC!5x>}e1U__NY`So)7;P>S9h=@%|?iwc*LGO8_2cXuTNDB z0auoQ0<8oFAMZvf_+j%S<6K>zv|a2@qHGF9{;UQ-D`NljfU=n#K;Y$4YWwg-670u4T6Mb0p*NURcA((mRG= zWlNHC88k5e7!^L<9?DTXj1matWvA3Gz*EPUW_hDTYUQ!}Z3ZQM5X4<_7-L^Z|t zI*tjYF$$~H+rgI2MfM-^LJ!L9Qil+9EnnX0L9scl6yQRvjrYdr?SUO>cpFZ^jx-rR zrX@HsJsquvxH@ieAn%F}erHa_i+=mG|KRy2#`+$b(ckyp@6DZpM`z#Aoxq8g4siE< z39LsW`?>-DC6RZ;Ux~ghYaUiE{zeB~^5w8pS{({&uE5Igv%x`V>Q9bFId!z4g&6XU zZ&21lZ7BFsl#Z-VzhfkoT z|4gjS$q!;5E3b}Og8p&Mv55IA9Gb-=p6DC*^am^l>#!@-e4(HJn?sE}j=G3kUH!tb z>LrGZf3Wp)I^%Gy`5kxSY(^{8_dhJee}7(G46I%k0ziWC@%P?U@>pEl66y9obHQ#~ zt#|V3uRqPD3Wq|`wqN3VeyVWYryaA8cf(Wn{a}#8inGrk$PdT`>L$sh*Jkft7lL@p zf}}H!J%Z*8Xc*(%cN8iA@u&G1obYWf)pm1ysvbTIOM0MAIMQRkI68+r2D6t6ARbAl z!0bq%GrY|b&OE6>ll9~f7TY$vG~v8@-{IB?Qiy!2=qiaNAmATasSY@qTXjOt3V^jVhF18@!)C5%?X7PHR;)pz-}A?NpvIy{M+U18)b{2 zLvx~-U!d)=eJDaWqa5tr=wmrSc*R` zt~zIB$I%9liJfIhJEF*-@TAc}{vDMrKNA{CQQ$wqLn~W3HQV^5q}W!Lrr1~1`aP}V zXeU+l+G-+{4e0{f3Yndf`TVI4)3s!%;lkUgaw~iW7^kWdkMz*OQGJk~o0Ecrd_dr5 zuScT(mN(3mMlpMrlex}4y(aioD~9oS?6x!S;eFp!C9J1M^&Wf~Cz++n%F)W-t==6#74;dPT3wXA%242 zLup*L6qXKPqmgi9(Kc5x=M;%u2{;=OfBv!yNwtb%WR&b5Pz!DJvRpOuUJeunG{*YjvQ{D@>(T(r6blhqnA@b-a{pe%}=?S|VE7hWv}H<|6|gY6yBYmWT{R{b{YUjx zq-&96GzH~q8PJAyKgzZ*fSoBQHh(?3v%W`ukF2*Ue$0q>e!Ez_++(Ell!CI$@Z3zG zPmArPS1BSBC@A8K*d@B&DJ!{sf3O$awrl_35>~-4;(F8JcJkAvmbejb^nq>j1|}SE zWV&Y*5y=?XHs>*LpVAS?XvVH=U2IU~=!nX0qiV}OJfJ!Cs`~l-wAXvLKb<712cpQ^A&`3UehV${iKUJwIE^gE(<7%WTHnBQm7Dlm zRoxdk9#eFC|II$uF`TNdWnTI?-}S-JW4b~EM40I0ZNRV_ zhrYm$FI`d=s-$Aswxw1v^FcxXR7F3BzOwU)y{g&NufN|d-oGwXuqQtiL{7gBO*V4$ z3q9I(88^3dm&SJ6Egq^){*%&+nJbczUsDIva{CZQD@#9?`pKvGuO1il!w!I!e`#uF zN$gKyBzaC)8Yjg|B>%PssNP=%BxzzzPh%nk-L0j*4-Y{*p8dtP-H}UoD+N7NYMXUU zy;(>v7Z!H7dt6RJU$?kB5aO0M&eG!FHeApTI{aJfDv~|tlY$d4ba?jXnliuXPk6Dm z!+R+G*@ygMt6tP9viT z?uf$7kA26d+4SemXVBmg>#{C@+YoxI3?lhjeeQ4@6NAIe&LHauZX7QQEYj+mat_qz zu4OHLR6}{vP00NLc?pMLXn%Bjc?0_>@ny`+s8E3Bm#JIR9t-Vi-~RgV492E;{RAhT z3I}3@sjpr?rRIt>Oj-T^<*+TwC)!V-xa*Me2jCIduD-lv+5dG7I`@IeSH%&mpN5qW z4*sWe>$@;3Kb!VNE3R&*$8LJNLKii4m-`3`S58A!96SRQT)|nFar9@wSvP=P6ymPV zAv?D9wO~5$Xm@T;-7>9KMv<8Ley}fv(yS|X!$phL!`tcg=%1oHSGSihBBobNPA*R> zat#N#%g|(sM_X#i%awQP%)BZ@xoy{8{Es{$hr8EakM%Oz?j649?K~Y=reyxMkBa}R z6Riw@fc{-tkXtml!JFTs)Lg*b;g!E9Zd)OGGn}c*pBEs~@eL#k*zLii$Qg}1l^huc z?*DSUq5%d&jO%|x?&1M8@59LBcq?*q{}<0o^}Ws8S4cbz1w&Bm0gNhWBLM*hj)#wz zkDdJ$Rqs8B=@8}v5*rsU_dkjM0_u^0&OOn8fzp1#U}C&?W&fX5x_~x+!GO=}Tw`E1 zZT*6ohyFh~`Em;L4s^qef%z{WABgP?W&-pg3JY5dlZR#uTqnMV{Pg6oL>`<#F*rdD zUjwuB%+vdMa}dFNC-FT5_$x_@g6Qxk!XFQ4?R>Eoa%^HS(^_O+pRWb>VANE8brz4FW7avl+6YOlfUk{1P_#}b z_)w0{kh_r#AlzY!)3-wb!<)FxDu`9z!)-s5`0Rb#tdE<^*-0;c>$8%=%rB^Xo>tt}e$jjAprw_uqsHBq9)kbFnAU*;cR@GtREht^7ceCp*AhRVi z75gB*g@)@O;S(O!o70mr!Iy>!%Aq9o89Kh1f&Hay6ai9ZXP^!E4X%8iri zog2Cb7SUHDkX*#+gp0MaRagS6*Mqvq$Vm8p1z*9FE{vc<1?qplH0OEhy89Aw!7tJa zyU9Yg34ltPwua7UcQ+b|HcPlk6}GfqGUDt5$r>CbWrX~=NmpYMxtKkT{8KCjNT~B+ z^#&YCRuOfTQ-j3_Q5kvxqt=*BqBBBY`rZ?u|5ws2{+XihnW9L$A21h-SCamNWzE0! zPT#)fPK9#}4*WKIn(V&4N!Jq5MO{Jloa$b1J8hyL=PtV=o{eQwecEPDZyRtVnY?^( zDaBh`C~}UQ9shkA;AYt`Qt>!3Ejs!L>EO zmSZSUaettc`SrW;!MuC@q|EO3DR2*%zr9_pU+-Lva_9=YyxB23%Re-oe7Wid!k(%4 zG`L|K9rMQ;E~!<2DH0+kqylmEFltM_rDBfG@J%g28SiaX;KaqoHV2WuC){f}!e5+< z2Q=xs1x>V9N;@=oPV|q@Efw0I&sqZu51CJe1w$G~Rg>rIjB4-MDmpo7KyNJ5DU5hI zne@OX<>P(Rk{5bMoh3L3rame&`f4-&?UPb>xrCE}ZgW0E;i=7@^bvIfzUiN&hjhCm zNLt^S+Eaqodt(;syzwLjdZY&oIj?B@ztJ7w;LZsvcYoKjb`balX|CVn4ipzK=O@?a zsOn?Rbvq14I(#?QfRit}tSm<%PWPfD5o;GzF%Zq?S3t^eW24({BmIR?Lzlc92x1az z6>Du{SXFHa5XhF_lJl$jS(>;UdfX|Op10F@beW^&3c}{?Ej7*{PIe6q|E5RR@_wj8 zR3_vFXlgBAFCFd zr(JXqK{cISkOdLMUw}Be2yd;dQOYIk<~$boU~q?+M97+ao!FjC1;|&)R}E4eZN8qb z?%c5WY>`N_n3e9MeZ@`J%OFg~3Jp)!BcMVF4tY!xz56*af-(Svk=|SjN1FktQf_*5 z)D9yrp2V~l!)>%w4Q@&S6?kq&TP-SMHMWA9;lc{)uv`mc9)*3>X;^*)v*P5)pX}EO z9e-@Onp01DUjVT zaEntHO~lUTYpCq-)c)`VIo&E9gDRM?k+Bn4C>bi#^py0(vNCdPg%ws6B21_NMGnsfC_KHdMxmZXIt46Gb(am z+S(Y)cJXS!sj27st~?v@-LXui>(&ov*wMsb)yi`=k4t+#HhI_3n(hwE1Em}_4V{lh z)|chA##3u%dSQn=_=GNqiEq4ubnI!y7UXQ@ZEohU8-srsTZ2D(70md3@XEqV z?zdaEW2K{<0an*tgnkRwEnhEDgLi|xv)D6g0ZI z1nH=e(cPMqy~11lfLeqXLf`a#uughAJFO;<`J?3`UG&^~-{yjtSv<;@;$}_aZ8Tu( z(qEFBVMuDiRhVIFF$Qm9zlfUT+s2>-+gUR*Wrc45qb!Oo;i?N_oc+?YxASjxB{Rjh zdFBV-a4gci;VyUTTR|(5f%bF95IohDJe;Od8-DAW{EW&EpW^Sdo!=eVF&Jsn1hiSQ zz`spX-?At4lr;>Z_Yj`u(*0gw|INKoVHet*j%mUz1a{+~-AztxP;keZbTT(t;)$R5 z3X`S}5VuC^Wg|uJeSJPZ1<&dC5l@Hbj@RmG(F*|}&X(1*+$V^yw3bdfp31U#q?S%O zu|#2u)oc;X^C%tiu79i!OpMm+lvBSi#ijU#xoFRBI!b$dK`H(cv*~e*0&N(bsYlI; ztL);X6(Fl0L|+sBiK(v=qYc~-pak$*Mhd)+WdpZnGUweN#?FFLMlLD+EJ>j(wWmLf zzn7k9liVc#z*Tl@Qqq1c?t)-I;eO(8e4hIS^y#%YxHPgs^Yr~$$|q#Js^S;iXqJT< zA26_;JYG{_ad!zy-Tt>E*@F|^2aFc+oh9Bd!ym9{pj}^ zX4IHUMuC*whU8Lt8DjOlj^V6SU!q$-1_~d$$!sE~9dALMYGexhE;|8z~hh%~%{Y5{i!%*A*GOSQ4V-B@W6Tju>hGpT5up=h47 zAYZYLQ!PTN6?u0D{rQGSDiywRGT;id91XTpTG{7kJAhp1*RQs`Js8G`>y8L0uFf8OcR4l&@gR2OVnF z$j8U2gW{PR>!lf@7t1klOmVp|4-J0w1QqL?SYK}mf;?^FF~2Z^*ktfBD4Vys7NR$% z`lFB04`U(avW(-b9$$jCc0#fn;?F`rHX6ucWAJ8CqY6vZYx~Vg3oNSJ`P2*^A8%Q# zC06WH3g~u@h}iiOZDGyJDYm(?g|gISCaI7HW%}yFzC0f~@uEnkvqg><>ji1>Y+-w2 z1G3m3){FgmPoPuA!CvI~q_MrdX$|A$O(Xyl{6x2FdC8{tQMU-cyMytUrX>V0fis$l zQPLJYh#4j;NbnFhR?%}Tz)Y=L7@{SusCW~t7_q>8$={*y3e%+~DIN<+(@P2Z%s$2n9m-C>`=w;6w|(y1KhpPw=Zq*N43EtlW-zHEeX+bNH_OCQ>laO?bn zI;`JYHm&H_JaV7Hey(r<;A^3r%}u1Lt0}eBx;t!TnVc$plt-qDPp(5td$6kvJg3AL zBS(TOt1lf|4*H&FvM6jGMWBqpJKv|3vQ5eLW5r%|BX^N9MCEBGy#ax&{x@oyr`@}^ zYOHPBY*Y>uHp|9Snr!nzC04$6b^#Pr4kk=&A(l?!ul6@^4M0<}ree|iOtng4*m^;_ z*26oH$TCe6{x+rHut+UnHc0ngvZcl3dwa{C7<2E(2Mlr*JC5eaHkGc+s+TS` zURtsw5ldDJU(a+- zg@1P7pLTS(4s@=d>C{7K7CLp3Osfl0w>Xq$XYBqUvIF+NXKY?fe&Sq#hl}Ba?)0XF zzlHO^H3@+J!Q{2+e5psG=5Q6 z^?(kiA&gVu|B#`P=_$ZRFn+me^!WA{)%fz+_0kQajmVPithPF>E%FJ0JdtfN-T!3Z zZQc6xcyZR#Gr#t7An=X8q?_w29Lp8n5(NS8EA(H4%&HfYojvyZ#%`|3C_BC}i;EksFKtay zFgA8FWvB6*r)?^cQ+f8l3CQy1Q7?Y?_;!r-Xt@fY$H}h$Ry|%1&N+79pTEL0t2pMm z9otZ6`WwvX^2JMgN7H&!)6p_3-hEtgwY@B+enth?%tE`ijysWEiRI+Q-W%VQD11)m zD&U^es^99Vp@P7(PDj7VQo%oPAIHI>A0J^^bAkK+I7BjS8w?F>Ee?WbPK>h_k1@##WE)fKU79|RY{13`% zG93CV%xViFAHN_HqPH*)4=5o31Fs2;oT>+d?h%0ti~KK62nKr7tsWNQYbrL(e^bFI z7TW4q@GzhkCoI?|7;^L{Bf`JnuXqqBVi0%;USJIIzgGm-C$pwEAJHXY{s+DY81WSb z;a?<@I9CuDiG&$4yvZv9J&qR<+86@b7(p7`;k{2}Thxrzp{LD7_Z4VfUcI%%yqm)75Iz>=J zvBR-m0rCkUs3SQ*?ZfDipr27FaFAU831U#0fSv(D8AYc9DUO5xfFl$#SbWa5P1?h1{5=F;{-Y^8zO`u1ky3=GK3*mL}j!nU$9;%|DxX}yMm(? zAYt}4HN#^LLxI@t5UfymIR8Pu^%3R;C8A;Gg39kOa6pc}ke@(odzgY~QD|Uo;r}h4 zM1$V+>sZ8iikFuDsu#g@_7#Pa0F=r5xD9Gqi%^~6ci|OBi{+f;m znp{U>1)aM>!u+3jBYuCgAb}=-BC)=nCmQ?vKPqU_3569jVS$46dZMTYk&cs%i{md? zFCzG2=Vs#vMNi;Nk z{9OO7kdK%5|56_mY~wpjSYuekZxD#zz~i151_X#9<0ZwXlY>5LqM`sNZcBXFFVBB) zW1hGvd_h9Sj(kgB(B zA5vI)6B>Qw!!@!o!z%;eS-9}I>P`-NiFEzm8o5}mdk%lzkO0_SbY<93NAwrxbq)lP zi|2*uhmJj1UxeDErA$_i;--$~sA7I3je6k(7X?K1{A8U&?}(LL?DJylHFP=1KFiC0 z)-rt2H#zdaPcp`1@m*ynM4dY1l9sf(yxK1JFu>@x$1-3In|upglHj)t_OpEZV}jo@ zV{txU;4(nwl`dznBdgV&yKm~gzST0EqATdhPLJflzFeftC&x?Rh98~p9PqAH8@JyL zvDK-YG%J5uvRY1Br9^I_136U9y%)hNk}k?UvehfWIjR$Hs|9B_)cfJa7?gO1Q;zIP z??E4&e}=Kkf~ySF3o(F^e-FyO=8Sx1QIEDYlwX13hi_ zUzW}92g|MN2wpwUjuiUd!FD>IR$ZBYtH^J8d#+;!GadxkPfd{pa}D)SU1lQ>FC9b} z&+KK38Cp4XBNXXlgw#+;;b@>47serIW@yy6eDj10siz0YvM~r3M)XiwO&N^9eYrQ8 zu=^#gPEG896?sr$XmEnU!;O|U+Fy>yY;d+tvIWygj2>?ZXUa5szcg4~lIyH|z5I5jzroY`?!HUaU|qhV(qG9bpYPNI--b9= z_i5g$>k)0UJs#0V;j<8B%uD6ZpfkT!?B0MxqlHMvMu{HQpb!Zjr5CbFGsq4}tCFA~ zJ;l5A53Chzgd|4roaKx76Je`GB{PZG$ zKhrlZp!9fKEr|)CotIMDv!RMA7drUq#yJhU61vC?O_y5d8#a2NZrC?*2(9h>)nePR z;A8#_;IhrW??C)zch(2+Uh5J9eD(yJ)P;vDbGq7mjb^ol)#G*T4D42eYaRKbYRk^$ z=7xJ%)6ewBg6Sd?FIGvoy;G3${YF?jDN`!_uSv2=|5TIUI;q}RmT5=~uf>%6)#bWV zKZwKIWR0I~E69%W+9WnO2HD^M*V$Go7{z8laYIC>jaG&q;@H$t4i1llfTxlY8EpH{ zoZOprl&W8(U5C`+bt{XD*Q_342HOkeUyp)qo=6al{z6$VL8ycNmE@`9_qNo)4zBE zwTevHnPYA&S6qmOM`-3TAjaA zkVwW3Rl^1IP5BIk?q(^GQI?&Z@q;VPRzj@m<+fx4Mg7}&&M1D(EY3}m3gklDZ-l?H zyTfh>S=_{qR7bQr3NVZgWh59bhZI`)WZ7(s*gjT>6Eyy?P{bpEM@PwgTwJNDxu|OZ z1O0u`^`gE#iXWI9Fee`G)l7_=Dwn5*by(m-PXf}}URO}?A#fYFWxm?=^(y5^jmIGs zXEKWsJws|2RlA?7fG&d;RD<)p5_(_72!Oulu7 zrz0NH8_*NjnUOzhKq2>?Dtj527sbyYA!Du@Xu9})XWdgNd6}6BTpa&$XMgctZX5l0apK|HA|#i3 zSWT7?@qx=U6ySP`84vR(DWP>cH|9ehCD!5KcTpl0*L71$Y#XU;$z+RADRrT*&dnnp zX~Pkv<j zjnX`-fN;qya4q^PZO+ZGhBYzG!%=a~KGKuUpd+HD1ah4(3C7CaR3E6Qa3IN}L!v_B zLO$g~nmZ#$hQv~}IAOj^5EPMqBa@1ItDGpMYAzwEy4oh)+%Ho%lfb1@lkoPId8&t5 z(G@2aX9Ouexf%Q>5$%UZO<4$L#Nsa_z*<~w0!=5~0J{R^Sf^?UBh{496cm#GGRG8B zRBy4zS5%zaG^|>f)gT@jPTOCi4=qXp&M`_NQ(Hh z{_#@}L1ZwB#W~Tb>vlYm2kABC#()WADUOtQp6n+E=+c;*x=)X$IdR3koz<;?KbOn< z)<<+WH)SP7My90o;*08B7Lgx{i1(sSQ-~eBqFFI4YXL8-LyPxE(PV3}Yv_+Da$Vqr z455R@rl&{jfR-Z7JpFffr2sdA)4-v}&!;zIRyIO%;ZJTXa62EdIX{O}maFk72dT)! z@TAhwp!0oV)Mt>So1IC6R-FAv%*Op$TbP)gXG4{Uy1DTO+xv8<4=0x8-X!#otq?dp zs*;hrx}^5vvIpQ*k2H=49K!3~s?2yy__8W|yfux#?Jzt(?h_X$Ya>u|=bEts#R_it z(AT{GBXN$5VX50#o?*EuvXrnvjMWJGW&c<;pa{Q{b(gEZ+Mz7P@?t1THGOz7=_;a7 z=}Rk`^=!9brJAGsBhdImE(gWS?&+)Ps-tg?^(nZ+eHCr$Jm#xOI?LWmY}xO3f#k(s z$q~y^&qW{L!L#%l*mCt3JWdL9${5|k2S%~?HLv`@9@MOc@=0J1>P9}W2Q}wVji`_P zZ33s&fS(F~Z>uvat202mQasB3QDy+5rV{@2O!-D^?dQ2d3HhVnW+%dmQ{tl$r;*XT z1NwYi^(>ws=eRK>A=VsD*^0IDw~4P1Y^>Hw_-;k3v{z6-efZa}>icu~s`C~Qx((x7 zPns=@LkR`K>lTdpD75Pa?SlM%UcaALy^4FEk!-^j`j3oQ0vT1A3~j^gUy*Q%n~995 z4<-IkpA=qz+!wmEW0G_@GFOC^gEjfB9P9sZ924y_0H}}S!V63BK|DitoO^1T@Pc^RFn{h>n~*j1UXuG|}4Z z-y;k;svtC7sPrMLbsqI&oU$vr=Mg$3?M@oLuL7^&VNE-B@1WM)|9Ela_UrvYEjAn% z>-aceGE^WOm+x9&@m)4m%h5i`6**AqTu{Jawh0U#R-TRuS8L3IL=j1Z4hDKlt@)fG zJ5IRCGTmDWiLLJP9u@Stg@Uk;`@3`~7=DXWcw)*~k+3MjJQiUw2DggR1}0<{nh~}* zk^BvPXf3?P*Pe3v_fO9CUw{Q9vXUt7ml^K#yHxNy-$+q4CPwN83Jd@(KH4cG&9SSJ z?DAe`oWp{@fU$-O+i@~P#cI@KoPrTWn=kyO*t|}CS9&nBlvu?oN~TSwt*vymbgV*L zj-+WLQ0r*olU9GLV(UeH|M!i=p4jG6(}d~>YOWpm**Nu+h1>|5XSnUi7KU1iAEOwM zUpBNcB^DvK%GG81r3?TlolHBLIC7JS3pN`0l#!iiWYgdMvq^$#9ad7IHtn@`w1!->-Pl zg@8B+!CxGQ<<`hMZ-adGao<8OGMLXL31VAtFqt@V9L@Tni6t+}EYzkTjNlp2Q>9-= zj=k{+BH3euxx~;qZ%I5G0{ zS1~W7^!bWQhJ+JNVqkaluPqQI87*V}flEy)!Q)C{_Yi znPdkyPLlURFI}@3BQ@tV2Kl;YeAb9)G#ophvUi+0E29pRaj|-v{Ub5+?Vx60N*?Eo zg`73GkB|f(_ehjC(G^xu$DmsX-17evRI;)~d=obWgd40i%gs33ZV^iKgAhv*M>O9u z>A5!%BueCt=c|B9g8f5%e0&P=@AQXU5BJ2y_mky+WXS&`*eb2X=%tNFI?$$b$)vJZ z!9UBcC2{}WL1?pfQM>Lf9%8h1P{G*XFd4XHY3Q*>@Wlr`4I-TPq-=`xUcdFpwIl%5 zV`LOlTP8O=r1e65S*0}wY>8nDuv6mKhR^1c#dX#*gdU%()Ftq64JYK8sdQLo#RK)- z;{?Z7ul+e}cx}A28jfbfeUrNPCo=>hByaKr5SIK4E-azPqTW$4=_8_Xt5Hzlir>(F zj_m687OyC+h`=m_^po*ZqC(p?C1?Q%YyQ&_3VvFYP#!d)V-MUP5Ps81e)+;=m0p`Q zsqsEhCwHM-g?7#aO%1b3oiFBR$|&g9Tf}lT1FByu|K&mAnVqn5Inf{TRL%nPEyH}S zqmby=#fUl%0z_mTakFuCJQ<6Gr%1CcC$?c}#%DOn<0HSYVsyaBzqGwLQ}q@bxyoM6 zUE{e|a#aas&c)B@9mdli6=Y8n`*zdkU{=nU5qv_5P8w;2&#g|Vl8D;i@c}lCaY~cG z9Vbw&hSU#)FaYLfE4{Hz(!V{G_$J=2?3O)mg<#OcwGZXu{_bh^|AhDs`{SvQQ2)Ad z@;T0bMiw-nyx{n9+bYGRWefkKRk#Yf3R~^Qe0h8b22yLcJRO4Noqqk<(uGW-#wmL_Ti0) zZf%4373&p{@W~#R13QWaXVoVrs;irho#7b{0QbXVc)lHn@~4%yyu=M%ME$4q^`oQ#s z@MIkVZcF;rv%5c;)M(-jN?{xom#rbxZXhIr;aW4mCP0XN$2;1IHG!Ij;uy zHEj@g@A;dsn)9*L+M7{z)-WkPN6Yn6V}hbMg06DLbH0<)C>i$r49OLYR|KnxS^%u| zkbr=n=WaarLLK^-?a(l*9t^@kfDi6Rks{Z!EWH@rkfv`KVYZYkaRDqwrUW2X_D|MO zliA@VVD7Q`Ip8IwOHHgqns?uAQ(oguMM5 zmgfI!d_Yerp{!o1iEFaH7KBQ1C+nT6Rj>7b z)Qb+E1Jx$@gVPJXYarFJ0byT<;ndUTxPbMu?tY{W(#&*hnti$fR3W-m0rLOVu2 z(G0pA*BFtkxG7ysx|GhiRE&Jxz58z~QVyn7*Nhj@pZianB`NXn;h?8XML4j%gKr%D zTCE6mN*{gy{>quFopfA~u<*f$8o0>}HwuS-GIJraXc}AET{@%0afGbQwSpC)s(yW>&>+wx3p$h{)_Ubs^%B0JA-YL;YFY!8_00K+~zO zvYwHCJGud;AmY~^b<>z0l9;8Lnrw;^i#PobT%5X3Ws!LXNT+o#T8oa0y2#5E2s#{V zXarR&7i?48ojvt=@)I$`NCW*$ zGqI@qWKzN*1njUJdWE$(K?1|vWsZ4Dx@#RLP`8<9jmcK3>E;~-6(G23OE@-5@+>(U z1DrSQp$&yBecmj^5|-w4#iGgdnah+vLZucpbH;n&pB4vtpWT;UEPVnZg_sbg5 zkqZ3q8>n*H_^*Z-a;$j|r(=+qmO>37R32ZxKNZqDBAHwCR6n`QY}3r)&Op2qTWC{> zxr1pi1L93D-&a3Tgnd?Bux*giZtReV@jJ0sSe`w~Ow9ILAGeXW-&_021?%FtvWWG7 zId*-8D@D;*8cMH;5bJ66n@y%To&>9 z)32{DFnNM7c?cOcpOEm?28}!3^G%HMcbNNR~7Q>~rPOPXR7ZoDBlO%_c3xb!Qj zKFPcCn1;WvT7|RU2@9B&o}R`!>(3wajk<3S$`k(54Pr%F>!becyTr{iG;ljRKwWb= z9Q} z%C7l>XC#YW-@3R+=WnaxaGpJQQC z86dacuTYCSrK#a;t5$H0r(3||+>z_+nBGOz`+dK2nJE<*QCmbQDj5~}>p?N){mFeb z31t+IZJaO=D->vquUK%7W?m4bw9-);_!0W?Xvlc%y`X#G6z7N!*`s}2 zVQ4qw4X1uh8lN!f_M}J_X*{OBvbN{EMSf~f&otPoOn@uGC*EI(fGdDmN4#|Hxrr(%R!5dWfX<@Bm#5@t@ZSz<@S@Brg0+>dC3BPOGj${ii+S_299GPjKm} zWTtN6F}FpP-iV>xX{-DUC)w@LgZQwanov3${~LM5e;B>kxi+sB$*F%#yB*CLr^!=~ z_D&RH%V9c+2>fX{2`TukIFAE1MGu*QB-fzGb`fVMvh70@GcdZ~8ecU5_)d=g3HW_q zbTxmul>|FN{=z2s$VYrw#oR%L`V!#!XS6-A#Yg1WNa1nb=}OBmPE`JQHjCH}!a86y zT#GC=UXD}GcykRYsi!x5{W59WCXhQT#;W4pe2vUzXB02DA#7W$vW$z*F7|QWhxT!a zXR?a}4*OIFU4uR!N8pcwZ!-EPFbu%k79@4c2m6wqCMT`1~{E|{K<=tw^^1Y?Gb z9w1u$B%Gy($&Z+O*r8ouB_v>oT%2cWo_n~VuQ+(fLm63?n1sA?{!_RW-YFD0kVb~561J|b_=dv!8Op_6?Y)7G$K=)iYNRP~oSoc*skkxiJU10+*8XkE9Z-#bG-iDEK)R1g*kkY1 z=E0xF4SRBo*f&%0pL$2Y^&U3eh@Sa3JskxHfKp?Dii}d!^w^z7mhgxoBv^|M2XKe@ zzIMn-m-}Xy>$$}cux=!@0Pv@dcp5iT2&SQmHL^#Xt6+eA8JkbIev_sIj3hSoiVNFT#Ge=SU%oc88}unQ{dq7QZVH zO`E?}Zk0IN1^v<_aZsGFW$o->yd5cPT0d*Qk!U#GAo;5Gm&rD0-#<*OK$c(yDt*@tI7=lL4k>=RdfF|92$7 z*QlAbP>`hmzhHum=a`V>s3_v#h@%A%Fpdb!5Cvd!YD!Z9238b2Oa$g9uO@Z^tQKg{ zcNr9J5Jnav6)FUDClWL6UrT8mOp`wj78N8E9~DK8eC77}!ghou3< z%>_~sKxTzvV+U1hAlIalW5ENA;O&h?Fztis7uaO^8ccox{0R;x4~YQ2UjctYf(agc ze*vc7;CnD)0VE>$eg^zW2PRrDQG~fnAiHK|9@kOynajj{|;mE{{ZW)ksSa4 From e024a271626bf789c3d2714a31ccf88cb3c06d23 Mon Sep 17 00:00:00 2001 From: Luca Sforza Date: Mon, 14 Sep 2026 15:54:14 +0200 Subject: [PATCH 2/2] feat: add MCP project authoring parity --- docs/knowledge/architecture/browser-mcp.md | 12 +- ...ect-workspaces-and-stereotype-authoring.md | 22 +- docs/knowledge/uml/mcp-use-case-parity.md | 62 +++- .../evidence/T05-final-qa.md | 34 ++ .../mcp-project-authoring-parity/plan.md | 136 +++++++ .../tasks/T01-contract-and-kb.md | 25 ++ .../tasks/T02-frontend-shared-authoring.md | 78 +++++ .../tasks/T03-mcp-authoring-tools.md | 71 ++++ .../tasks/T04-integration-review.md | 28 ++ .../tasks/T05-final-qa.md | 46 +++ front-end/scripts/guard-package-only.mjs | 2 +- front-end/src/Diagram.svelte.ts | 103 ++++-- front-end/src/FlowCanvas.svelte | 36 +- .../src/__tests__/BrowserRPCHandler.test.ts | 101 ++++++ .../diagramProjectScopeTransition.test.ts | 96 +++++ .../src/__tests__/packageManager.test.ts | 9 +- .../__tests__/projectDatasetAuthoring.test.ts | 94 ++++- .../projectStereotypeCreation.test.ts | 271 +++++++++++++- .../src/components/PackageManager.svelte | 66 ++++ front-end/src/i18n.ts | 2 + .../project-workspace/authoring-service.ts | 80 +++++ front-end/src/project-workspace/authoring.ts | 331 ++++++++++++++++-- front-end/src/sync/BrowserRPCHandler.ts | 95 ++++- front-end/src/type-system/editor-runtime.ts | 11 +- mcp-server/__tests__/authoring-tools.test.ts | 128 +++++++ mcp-server/src/server.ts | 2 + mcp-server/src/tools/authoring.ts | 156 +++++++++ 27 files changed, 2003 insertions(+), 94 deletions(-) create mode 100644 docs/plans/active/mcp-project-authoring-parity/evidence/T05-final-qa.md create mode 100644 docs/plans/active/mcp-project-authoring-parity/plan.md create mode 100644 docs/plans/active/mcp-project-authoring-parity/tasks/T01-contract-and-kb.md create mode 100644 docs/plans/active/mcp-project-authoring-parity/tasks/T02-frontend-shared-authoring.md create mode 100644 docs/plans/active/mcp-project-authoring-parity/tasks/T03-mcp-authoring-tools.md create mode 100644 docs/plans/active/mcp-project-authoring-parity/tasks/T04-integration-review.md create mode 100644 docs/plans/active/mcp-project-authoring-parity/tasks/T05-final-qa.md create mode 100644 front-end/src/__tests__/diagramProjectScopeTransition.test.ts create mode 100644 front-end/src/project-workspace/authoring-service.ts create mode 100644 mcp-server/__tests__/authoring-tools.test.ts create mode 100644 mcp-server/src/tools/authoring.ts diff --git a/docs/knowledge/architecture/browser-mcp.md b/docs/knowledge/architecture/browser-mcp.md index 1c91e8bd..a39bf1b2 100644 --- a/docs/knowledge/architecture/browser-mcp.md +++ b/docs/knowledge/architecture/browser-mcp.md @@ -1,7 +1,7 @@ --- kind: knowledge status: current -updated: 2026-08-30 +updated: 2026-09-14 --- # Browser-backed MCP architecture @@ -116,6 +116,16 @@ counterparts use the distinct routes above. | Format view | `FlowCanvas` calls `diagram.autoLayout` for horizontal/vertical **Disponi**. No MCP tool or browser RPC case exposes it. `fit_view`, `center_view` and `move_nodes` are not equivalent. | | Screenshot | CDP capture supports optional reload and output-handle hover. It neither performs layout nor waits for the editor's layout/render completion, so it does not enforce the required layout-before-capture workflow. | | Open a project | MCP exposes `create_project({projectPath,id,version,name,description})` and `open_project({projectPath})` through a confined `NNM_PROJECT_ROOT`; the server prepares/loads bytes and the browser bridge owns activation and model-save notifications. A live browser tab is still required before activation; `import_diagram`/`reset_diagram` remain distinct operations. | +| Create stereotype | `create_stereotype` validates the complete JSON form shape at MCP, then delegates the unchanged semantic request to the selected browser's shared project-authoring service and stereotype coordinator. The browser writes, stages and activates the project package. | +| Delete stereotype | `delete_stereotype({id,version,path})` delegates exact ownership, graph-use and dependency checks to the shared browser coordinator. The UI uses the same operation with confirmation; core, missing, in-use and dependency-required packages are rejected without cascade. | +| Create dataset | `create_dataset` exposes the complete dataset form shape. Optional file bytes use canonical base64 only across JSON and are decoded before the shared browser dataset coordinator validates and writes them. | +| Delete dataset | `delete_dataset({id,version,path})` delegates to the same coordinator as the UI. It removes the project manifest/directory/catalog/training descriptor while preserving backend archives and historical jobs. | + +The four authoring operations are serialized by the browser-owned project +authoring service. Package-scope transitions temporarily suppress the visible +catalog and reject package-node additions until the runtime commit or rollback +finishes, so asynchronous create/delete/import work cannot reintroduce an +identity absent from the committed project scope. ### Implementation evidence diff --git a/docs/knowledge/decisions/project-workspaces-and-stereotype-authoring.md b/docs/knowledge/decisions/project-workspaces-and-stereotype-authoring.md index 27eaec32..0d257a82 100644 --- a/docs/knowledge/decisions/project-workspaces-and-stereotype-authoring.md +++ b/docs/knowledge/decisions/project-workspaces-and-stereotype-authoring.md @@ -1,7 +1,7 @@ --- kind: decision status: accepted -updated: 2026-09-10 +updated: 2026-09-14 --- # Writable project workspaces and model-owned stereotype authoring @@ -58,6 +58,18 @@ the user can edit the Lua and PyTorch implementations outside NNModelling. `inference.lua`, and `pytorch.py`, adds the exact relative entry to the model's `manifest.customPackages`, stages the resulting package scope, and exposes the stereotype in the current palette. +- An exact project-owned stereotype may be deleted from the visible manager or +through browser-backed MCP. Core packages are immutable. Deletion is rejected +when the graph uses the exact package identity or another project-owned package +resolves it as a dependency; the operation never cascades into nodes or rewrites +dependencies. The editor runtime resolves dependency ranges against the complete +model scope while loading project package records and exposes those exact keys +through `packageExports()` for the shared UI/MCP deletion guard. +- Successful deletion removes the exact `manifest.customPackages` entry, + project-relative package directory and active runtime/catalog entry. The next + scope is staged before commit. On write, removal or activation failure, the + previous manifest, package bytes and runtime scope are restored; unrelated + project resources are never removed. - Generation is transactional from the application's perspective: validate all content and the staged package scope before committing it; on a later filesystem or activation failure, restore the previous model and remove only @@ -85,6 +97,9 @@ the user can edit the Lua and PyTorch implementations outside NNModelling. automatic saving. - Project creation, opening, graph editing, package authoring, and backend export all operate on the same `core + current-model-custom` package scope. +- UI and browser-backed MCP creation/deletion call the same project authoring + coordinators. The MCP boundary adapts JSON transport but does not own a second + generator, package catalog or filesystem model. - Direct edits made by another application are not watched automatically. Reloading a project or an explicit future refresh action is the boundary for rereading those files. @@ -100,5 +115,8 @@ the user can edit the Lua and PyTorch implementations outside NNModelling. ## Implementation -The executable work is defined in +The base workspace and authoring work is defined in [`../../plans/active/project-workspaces-and-stereotype-authoring/plan.md`](../../plans/active/project-workspaces-and-stereotype-authoring/plan.md). +UI/MCP creation and deletion parity is tracked by the +[`../../plans/active/mcp-project-authoring-parity/plan.md`](../../plans/active/mcp-project-authoring-parity/plan.md) +initiative. diff --git a/docs/knowledge/uml/mcp-use-case-parity.md b/docs/knowledge/uml/mcp-use-case-parity.md index 4462c64e..1b11034e 100644 --- a/docs/knowledge/uml/mcp-use-case-parity.md +++ b/docs/knowledge/uml/mcp-use-case-parity.md @@ -1,21 +1,22 @@ --- kind: decision status: accepted -updated: 2026-08-30 +updated: 2026-09-14 --- # MCP use-case parity with the editor ## Context and authority -The MCP interface must expose the agent-facing modeling and training workflows -below, not merely a collection of low-level graph and HTTP operations. This is +The MCP interface must expose the agent-facing modeling, project-authoring and +training workflows below, not merely a collection of low-level graph and HTTP operations. This is an accepted functional constraint, not a claim that the server already implements it. The [browser-backed MCP architecture](../architecture/browser-mcp.md) describes actual implementation and gaps separately. The -[adaptation plan](../../plans/active/mcp-use-case-parity/plan.md) maps these -requirements to implementation tasks and verification gates; its existence does -not mean the server already satisfies this constraint. +[adaptation plan](../../plans/active/mcp-use-case-parity/plan.md) and the +[project-authoring parity plan](../../plans/active/mcp-project-authoring-parity/plan.md) +map these requirements to implementation tasks and verification gates; their +existence does not mean the server already satisfies this constraint. The diagram preserves the user-supplied use cases, associations, extension and notes. `Agent` is the single primary actor. The two groups are capability areas, @@ -31,21 +32,25 @@ flowchart LR subgraph TRAINING["Training"] direction TB - T1(["Collegarsi al backend"]) - T2(["Modificare parametri"]) - T3(["Lanciare training"]) - T4(["Monitoraggio training"]) + T1(["Connect to backend"]) + T2(["Edit training parameters"]) + T3(["Start training"]) + T4(["Monitor training"]) T5(["Download wheel"]) end - subgraph MODELLAZIONE["Modellazione"] + subgraph MODELING["Modeling"] direction TB - M1(["Aggiungere nodo al canvas"]) - M2(["Collegare nodi"]) - M3(["Modificare parametri nodi"]) - M4(["Formattare la vista"]) - M5(["Screenshot
─────────────
extension points
Formattare la vista"]) - M6(["Aprire un progetto"]) + M1(["Add node to canvas"]) + M2(["Connect nodes"]) + M3(["Edit node parameters"]) + M4(["Format view"]) + M5(["Screenshot
─────────────
extension points
Format view"]) + M6(["Open a project"]) + M7(["Create stereotype"]) + M8(["Delete stereotype"]) + M9(["Create dataset"]) + M10(["Delete dataset"]) M4 -. "«extend»" .-> M5 end @@ -62,19 +67,28 @@ flowchart LR A --- M4 A --- M5 A --- M6 + A --- M7 + A --- M8 + A --- M9 + A --- M10 N1["«note»
Every training parameter shown in the sidebar
must be editable through MCP."] N2["«note»
Create a node by filling in its stereotype parameters.
Creation must behave exactly like selecting the stereotype
in the browser sidebar and clicking Create."] - N3["«note»
Use the editor's Arrange (Disponi) operation
to place nodes in a standard layout.
Run it BEFORE capturing the browser screenshot.
Support both horizontal and vertical layouts."] + N3["«note»
Use the editor's Arrange operation
to place nodes in a standard layout.
Run it BEFORE capturing the browser screenshot.
Support both horizontal and vertical layouts."] N4["«note»
The MCP server must create a new project
or open an existing one.
Project creation must use the same parameters and behavior
as the graphical interface, sharing as much code as possible."] + N5["«note»
MCP stereotype and dataset creation uses the same semantic
parameters, validation, defaults and project transaction as the UI.
Deletion is available in both UI and MCP and removes only project-owned resources."] T2 -.- N1 M1 -.- N2 M4 -.- N3 M6 -.- N4 + M7 -.- N5 + M8 -.- N5 + M9 -.- N5 + M10 -.- N5 ``` ## Required observable behavior @@ -92,6 +106,18 @@ flowchart LR | M4 | Format view | Apply the editor's **Disponi** auto-layout in either horizontal or vertical direction. Panning, zooming, fitting the viewport, or manually guessing node coordinates are not equivalent. | | M5 | Screenshot | Capture the browser diagram after the required layout has been applied and rendered. | | M6 | Open a project | Create a new project or open an existing one through the browser's shared project workflow. Creation exposes the same form parameters, defaults, validation and behavior as the UI; opening activates the same writable project and resource scope. | +| M7 | Create stereotype | Create one project-owned stereotype with the UI form's complete semantic request: identity, directory, display metadata, kind, view, dependencies, parameter definitions and optional objective metadata. The shared browser transaction writes and activates the package. | +| M8 | Delete stereotype | Delete only an exact project-owned stereotype through a UI confirmation or MCP request. Reject core, missing, graph-used or dependency-required packages. Success removes the exact manifest entry, project directory, active catalog/runtime entry and palette entry without deleting nodes or cascading to other packages. | +| M9 | Create dataset | Create one project-owned dataset with the UI form's complete semantic request: identity, directory, display metadata, parameter definitions, named input/target slots, class metadata and optional data files. Binary file transport may be adapted to JSON-safe base64 without changing domain semantics. | +| M10 | Delete dataset | Delete only an exact project-owned dataset through a UI confirmation or MCP request. Success removes its manifest entry, exact project directory, browser catalog entry and active training descriptor; immutable backend uploads and historical jobs are preserved. | + +Creation and deletion are project authoring operations, not graph-node creation +or backend archive administration. Both entry points must call the same +browser-owned coordinators. Destructive calls identify the exact project +resource by ID, version and relative path; display names are never identities. +Failures preserve the previous manifest, runtime/catalog state and unrelated +files. No deletion operation may cascade into graph nodes, dependent packages, +backend dataset archives or historical jobs. The original `«extend»` relation is retained faithfully. Its note explicitly requires layout **before** screenshot; this ordering is the acceptance rule, diff --git a/docs/plans/active/mcp-project-authoring-parity/evidence/T05-final-qa.md b/docs/plans/active/mcp-project-authoring-parity/evidence/T05-final-qa.md new file mode 100644 index 00000000..8ece2b9e --- /dev/null +++ b/docs/plans/active/mcp-project-authoring-parity/evidence/T05-final-qa.md @@ -0,0 +1,34 @@ +# T05 final QA evidence + +**Run date:** 2026-09-14 +**Result:** PASS — automated gates and disposable live UI/MCP QA passed. + +## Automated gates + +| Gate | Result | Evidence | +| --- | --- | --- | +| `pnpm --dir front-end check` | Pass | 0 errors; 35 warnings across 8 files. | +| `pnpm --dir front-end test` | Pass | 50 files; 302 tests passed. | +| `pnpm --dir front-end guard:package-only` | Pass | `package-only guard: ok` with the current `data.package` identity contract (`id` and `version`). | +| `pnpm --dir mcp-server test` | Pass | TypeScript build succeeded; 7 files and 74 tests passed. | +| `git diff --check` | Pass | No whitespace errors. | + +The implementation owner also reported 10/10 focused authoring tests passing. The full frontend suite above was rerun after the final runtime dependency-resolution fix. + +## Disposable live-project QA + +Used a temporary copy of the existing VAE project at `/tmp/nnmodelling-t05-commit-qa`; all live authoring and failure injection targeted this clone. The public MCP registry exposed all four authoring tools. + +- Created semantically equivalent stereotypes through the UI (`qa.ui-layer`) and MCP (`qa.mcp-layer`). Their project folders contained the expected four package files and appeared in the live catalog. +- Created equivalent named-slot datasets through the UI (`qa.ui-dataset`) and MCP (`qa.mcp-dataset`), plus an MCP dataset with `dataFiles`. The manifests, generated source and live catalog reflected the submitted contracts; `data/probe.bin` contained exactly `00 01 02 03 04`. +- Created `qa.guard-target` and `qa.guard-dependent` through MCP. Runtime diagnostics were clear, and deleting the target while required was rejected with “required by another project stereotype” and no mutation. Deleting the dependent then allowed the target deletion. +- Core stereotype deletion and deletion of graph-used `example.vae.sampling@0.1.0` were both rejected without mutation. +- UI cancellation of `qa.mcp-dataset` left it listed and present. Confirmed UI deletion then removed it. UI deletion of `qa.ui-layer` and MCP deletion of `qa.mcp-layer`, `qa.ui-dataset` and the file-backed dataset succeeded through their respective paths. +- Injected an `EACCES` failure by removing write permission from the package parent before deleting `qa.ui-layer` through MCP. The operation failed; SHA-256 hashes of `model.json` and all stereotype files were unchanged, the package remained, and the parent permission was restored. +- Reopened the clone through MCP. The existing VAE graph returned as Saved with `resourceCount: 17`, and no QA resources remained in its project folders or catalogs. + +The earlier live failure of the dependency guard is superseded by this run: the latest runtime resolves dependency ranges against the complete package scope, and the live guard now rejects the dependent deletion before mutation. + +## Cleanup and final state + +Removed the disposable project and rollback snapshots. Stopped the temporary-root MCP server and restarted the local server against the repository's existing `Variational Autoencoder` project (`example.vae-mnist`, `resourceCount: 17`). The original project opened successfully in the in-app editor. `git status --short -- examples/` is empty; no QA changes remain in `examples/` or `transformer.spam`. diff --git a/docs/plans/active/mcp-project-authoring-parity/plan.md b/docs/plans/active/mcp-project-authoring-parity/plan.md new file mode 100644 index 00000000..9c72df5d --- /dev/null +++ b/docs/plans/active/mcp-project-authoring-parity/plan.md @@ -0,0 +1,136 @@ +--- +id: mcp-project-authoring-parity +kind: plan +status: completed +updated: 2026-09-14 +areas: [architecture, frontend, mcp, testing] +--- + +# MCP parity for project stereotype and dataset authoring + +## Goal + +Expose four project-authoring workflows through MCP—create/delete stereotype +and create/delete dataset—with the same semantic parameters, validation, +transactions and live editor effects as the visible frontend. Add stereotype +deletion to the UI and keep dataset deletion on the same shared operation. + +## Delivered behavior + +The frontend and browser-backed MCP expose shared project-scoped operations to +create and delete stereotypes and datasets. The UI and MCP paths use the same +validation, transactions, active package/dataset catalogs and rollback. Exact +project ownership is required for deletion; core, graph-used and +dependency-required stereotypes are protected. T05 final QA passed on +2026-09-14; see [the evidence](evidence/T05-final-qa.md). + +## Scope + +- Shared browser operations for all four use cases. +- UI confirmation and transactional deletion for project stereotypes. +- JSON-safe MCP transport for the frontend forms' complete semantic requests, + including optional dataset data files. +- Exact public schemas, frontend RPC handling, MCP adapters and regression tests. + +## Non-goals + +- Dataset update through MCP, stereotype source-code editing, rename or move. +- Core stereotype deletion, cascading node deletion or dependency rewriting. +- Deleting immutable backend dataset archives or historical jobs. +- A second server-side package catalog, authoring generator or graph owner. + +## Decisions and invariants + +- The accepted behavior is defined in + [MCP use-case parity](../../../knowledge/uml/mcp-use-case-parity.md). +- The active browser project and `DiagramCore` remain authoritative. UI and RPC + call the same project coordinators; MCP performs only schema and transport + adaptation. +- Create payloads preserve the exact semantic fields of + `StereotypeAuthoringRequest` and `DatasetAuthoringRequest`. Dataset bytes use + `{path, dataBase64}` only at the JSON boundary and become `Uint8Array` before + domain validation. +- Delete payloads use exact `{id, version, path}` identity. Only project-owned + resources are eligible. +- Stereotype deletion rejects packages referenced by graph nodes or required by + another active custom package. It never cascades into nodes or dependencies. +- Dataset deletion preserves backend archives and jobs. Both deletions are + transactional and preserve manifest, runtime/catalog state and unrelated + files on failure. +- Create returns the created exact identity. Delete returns the deleted exact + identity. Errors are truthful and do not report partial success. + +## Contracts and control flow + +```text +UI form/confirmation ─┐ + ├─> shared FlowCanvas authoring service +MCP tool -> browser RPC┘ -> project coordinator -> ordered writer + ├─> project directory + └─> live package/dataset scope +``` + +Public operations are `create_stereotype`, `delete_stereotype`, +`create_dataset`, and `delete_dataset`. The MCP server validates their JSON +shape and forwards them to the selected browser tab. Domain defaults, +cross-field validation, package activation, dataset catalog updates and +rollback stay in the frontend. + +## Task graph + +| Task | Role | Depends on | May run with | Write scope | Outcome | +| --- | --- | --- | --- | --- | --- | +| [T01](tasks/T01-contract-and-kb.md) | architecture | — | — | This plan and current KB | Accepted four-operation contract | +| [T02](tasks/T02-frontend-shared-authoring.md) | frontend | T01 | T03 | `front-end/` | Shared UI/RPC CRUD behavior and frontend regressions | +| [T03](tasks/T03-mcp-authoring-tools.md) | integration | T01 | T02 | `mcp-server/` | Four public tools and proxy regressions | +| [T04](tasks/T04-integration-review.md) | review | T02, T03 | — | Read-only; fixes return to owners | Cross-boundary contract review | +| [T05](tasks/T05-final-qa.md) | integration | T04 | — | Disposable fixtures and evidence only | Passed real UI/MCP/filesystem QA | + +## Integration and review gates + +- T02 and T03 use the contract above and have disjoint write scopes. +- T04 checks schema/RPC agreement, shared browser ownership, exact destructive + targeting and rollback. Findings return to the owning implementation task. +- The user resumed T05 after implementation fixes. Its automated and disposable + live-interface gates passed; the evidence records the final runtime guard + behavior and cleanup. + +## Acceptance criteria + +- [x] All four tools are discoverable and forward to the selected browser tab. +- [x] UI and MCP create equivalent stereotype/dataset project resources from + equivalent semantic requests. +- [x] UI can delete project stereotypes with confirmation; UI and MCP dataset + deletion share the same coordinator operation. +- [x] Core/in-use/dependency-required stereotype deletion is rejected without + mutation; valid deletions remove exact project resources and live entries. +- [x] Failures preserve graph, manifest, active scope and unrelated files. +- [x] No MCP-side catalog, generator or graph state is introduced. +- [x] T05 was resumed and completed with passing evidence. + +## Final verification + +T05 ran these gates on 2026-09-14: + +```bash +pnpm --dir front-end check +pnpm --dir front-end test +pnpm --dir front-end guard:package-only +pnpm --dir mcp-server test +git diff --check +``` + +It must also exercise create/delete for both resource types through the visible +UI and public MCP interface on a disposable project, compare disk/manifest/live +catalog state, reopen the project, and cover cancellation and rollback failure. + +Results: frontend check passed with 0 errors and 35 warnings; frontend tests +passed (302); package-only guard passed; MCP tests/build passed (74); and +`git diff --check` passed. The live acceptance cases passed on a disposable +copy of the existing VAE project. Full details are in the T05 evidence. + +## Knowledge and archive impact + +The UML parity and browser-MCP coverage documents were updated with the +implementation. The related dependency-guard issue was reproduced, fixed and +verified through live MCP. T05 passed and this initiative is complete. diff --git a/docs/plans/active/mcp-project-authoring-parity/tasks/T01-contract-and-kb.md b/docs/plans/active/mcp-project-authoring-parity/tasks/T01-contract-and-kb.md new file mode 100644 index 00000000..686771d2 --- /dev/null +++ b/docs/plans/active/mcp-project-authoring-parity/tasks/T01-contract-and-kb.md @@ -0,0 +1,25 @@ +--- +id: T01 +kind: task +status: done +plan: ../plan.md +role: architecture +depends_on: [] +parallel_with: [] +write_scope: + - docs/knowledge/uml/mcp-use-case-parity.md + - docs/plans/active/mcp-project-authoring-parity/ +--- + +# Fix the four project-authoring contracts + +## Objective + +Record the accepted UI/MCP parity, ownership and deletion-safety rules needed +for independent frontend and MCP implementation. + +## Acceptance criteria + +- [x] Four use cases and exact identities are explicit. +- [x] Shared browser ownership and JSON-only binary adaptation are explicit. +- [x] Destructive non-cascading and rollback rules are explicit. diff --git a/docs/plans/active/mcp-project-authoring-parity/tasks/T02-frontend-shared-authoring.md b/docs/plans/active/mcp-project-authoring-parity/tasks/T02-frontend-shared-authoring.md new file mode 100644 index 00000000..523101c5 --- /dev/null +++ b/docs/plans/active/mcp-project-authoring-parity/tasks/T02-frontend-shared-authoring.md @@ -0,0 +1,78 @@ +--- +id: T02 +kind: task +status: done +plan: ../plan.md +role: frontend +depends_on: [T01] +parallel_with: [T03] +write_scope: + - front-end/ +--- + +# Implement shared frontend authoring operations + +## Objective + +Make UI and browser RPC share create/delete operations for project stereotypes +and datasets, including a new confirmed stereotype-delete UI. + +## Context required + +Read the plan, UML parity decision, project-workspace stereotype decision, +project-owned dataset decision and browser-MCP architecture. Inspect the two +authoring coordinators, `FlowCanvas.svelte`, package manager/forms, +`BrowserRPCHandler`, project path adapter and focused tests. + +## Invariants + +- Browser project/DiagramCore owns live state; no second catalog or graph. +- Creation uses the existing request validators/generators and ordered writer. +- Stereotype deletion targets exact project identity and rejects core, in-use + and dependency-required packages without mutation or cascade. +- Deletion updates disk, model manifest and live scope transactionally. +- Dataset create/delete continues to synchronize training and type catalogs. +- RPC data files decode from strict base64 before domain validation. + +## Allowed files + +Any file under `front-end/`, limited to the narrow authoring, UI, RPC and test +seams needed for this objective. + +## Out of scope + +MCP server code, dataset update over MCP, rename/move, backend archive deletion, +or unrelated known UI bugs. + +## Work + +1. Add stereotype delete transaction and regression coverage. +2. Extract/bind one frontend authoring service used by UI callbacks and RPC. +3. Add the stereotype confirmation UI and keep core rows immutable. +4. Add four browser RPC cases with exact result/error behavior. +5. Add focused frontend tests; T05 executes the planned gates. + +## Acceptance criteria + +- [x] Four RPC calls and UI calls reach the same coordinators. +- [x] Equivalent create requests produce identical domain resources. +- [x] Safe stereotype delete and existing dataset delete update live state. +- [x] Failure/cancellation has no partial visible or persistent success. +- [x] No changes outside `front-end/`. + +## Validation + +Executed during T05: + +```bash +pnpm --dir front-end exec vitest run src/__tests__/projectStereotypeCreation.test.ts src/__tests__/projectDatasetAuthoring.test.ts src/__tests__/BrowserRPCHandler.test.ts +pnpm --dir front-end check +``` + +The focused authoring tests passed (10/10); the full frontend suite passed +(302/302) and `svelte-check` reported 0 errors. + +## Required handoff + +Handoff completed; T04 reviewed the integrated boundary and T05 passed final +automated and live-interface QA. diff --git a/docs/plans/active/mcp-project-authoring-parity/tasks/T03-mcp-authoring-tools.md b/docs/plans/active/mcp-project-authoring-parity/tasks/T03-mcp-authoring-tools.md new file mode 100644 index 00000000..986bc8bb --- /dev/null +++ b/docs/plans/active/mcp-project-authoring-parity/tasks/T03-mcp-authoring-tools.md @@ -0,0 +1,71 @@ +--- +id: T03 +kind: task +status: done +plan: ../plan.md +role: integration +depends_on: [T01] +parallel_with: [T02] +write_scope: + - mcp-server/ +--- + +# Expose project authoring through MCP + +## Objective + +Register four accurately typed MCP tools that validate JSON transport and proxy +the accepted requests to the selected browser tab. + +## Context required + +Read the plan and browser-MCP architecture. Inspect tool discovery/dispatch, +existing project and graph tool patterns, browser client, and MCP tests. + +## Invariants + +- Tools are thin adapters and keep no catalog, generator or project graph. +- Schemas expose every semantic UI request field with correct JSON types. +- Dataset data files use strict `{path, dataBase64}` JSON transport. +- Delete uses exact `{id, version, path}`; browser owns eligibility and domain + validation. + +## Allowed files + +Any file under `mcp-server/`, limited to tool registration/schema and tests. + +## Out of scope + +Frontend code, direct project-file authoring, backend calls, dataset update, +rename/move or compatibility-tool removal. + +## Work + +1. Add `create_stereotype`, `delete_stereotype`, `create_dataset`, and + `delete_dataset` tool schemas/handlers. +2. Reuse tool discovery and selected-tab BrowserRPCClient forwarding. +3. Add schema and proxy regression tests, including malformed base64/identity. +4. Defer test execution to T05. + +## Acceptance criteria + +- [x] Four tools appear through normal discovery. +- [x] Valid payloads are forwarded unchanged except documented JSON transport. +- [x] Invalid JSON shapes fail before browser mutation. +- [x] Browser/domain failures remain visible. +- [x] No changes outside `mcp-server/`. + +## Validation + +Executed during T05: + +```bash +pnpm --dir mcp-server test +``` + +The TypeScript build succeeded and all 74 MCP tests passed. + +## Required handoff + +Handoff completed; T04 reviewed the cross-boundary contract and T05 passed final +automated and live-interface QA. diff --git a/docs/plans/active/mcp-project-authoring-parity/tasks/T04-integration-review.md b/docs/plans/active/mcp-project-authoring-parity/tasks/T04-integration-review.md new file mode 100644 index 00000000..c1ebdf3e --- /dev/null +++ b/docs/plans/active/mcp-project-authoring-parity/tasks/T04-integration-review.md @@ -0,0 +1,28 @@ +--- +id: T04 +kind: task +status: done +plan: ../plan.md +role: review +depends_on: [T02, T03] +parallel_with: [] +write_scope: [] +--- + +# Review the integrated authoring boundary + +## Objective + +Read the combined diff and identify contract mismatches, ownership violations, +unsafe deletion or missing regression coverage before QA. + +## Acceptance criteria + +- [x] Tool schemas and browser RPC payloads agree field-for-field. +- [x] UI and RPC share domain operations. +- [x] Destructive paths are exact, non-cascading and rollback-safe. +- [x] No tests or live workflows are executed. + +## Validation + +Read-only diff inspection only. Return findings to T02 or T03 owners. diff --git a/docs/plans/active/mcp-project-authoring-parity/tasks/T05-final-qa.md b/docs/plans/active/mcp-project-authoring-parity/tasks/T05-final-qa.md new file mode 100644 index 00000000..0542dc4d --- /dev/null +++ b/docs/plans/active/mcp-project-authoring-parity/tasks/T05-final-qa.md @@ -0,0 +1,46 @@ +--- +id: T05 +kind: task +status: done +plan: ../plan.md +role: integration +depends_on: [T04] +parallel_with: [] +write_scope: + - docs/plans/active/mcp-project-authoring-parity/evidence/ +--- + +# Run final automated and real-interface QA + +## Objective + +Prove the four use cases through automated gates and a disposable live project, +then update current KB evidence/status without touching user projects. + +## Stop condition + +The user explicitly resumed QA. Final automated and live-interface QA passed; +see `../evidence/T05-final-qa.md`. + +## Work + +1. Load the repository `verify-task` and `nnmodelling-mcp` skills. +2. Run the plan's automated gates. +3. On a disposable project, create/delete each resource through UI and MCP; + compare manifest, exact directories, live catalogs and reopened state. +4. Exercise cancellation, core/in-use/dependency rejection and injected + persistence failure/rollback without deleting backend archives or jobs. +5. Record current evidence and update stale KB statements only after proof. + +## Acceptance criteria + +- [x] All automated gates pass with current results. +- [x] UI/MCP/disk parity is demonstrated for all four use cases. +- [x] Reopen and failure-path evidence proves transactionality. + +## Validation + +The full frontend suite passed (302 tests), the MCP suite passed (74 tests), +the package-only guard passed, and `svelte-check` reported 0 errors. Disposable +live QA covered both authoring channels, guarded and valid deletions, +cancellation, reopen and injected rollback failure. See the linked evidence. diff --git a/front-end/scripts/guard-package-only.mjs b/front-end/scripts/guard-package-only.mjs index 61d6b85a..dcf8646a 100644 --- a/front-end/scripts/guard-package-only.mjs +++ b/front-end/scripts/guard-package-only.mjs @@ -44,7 +44,7 @@ for (const directory of [join(repositoryRoot, "examples/diagrams")]) { if (!Array.isArray(document.nodes)) continue for (const node of document.nodes) { const identity = node?.data?.package - if (typeof identity?.id !== "string" || typeof identity?.version !== "string" || typeof identity?.name !== "string") { + if (typeof identity?.id !== "string" || typeof identity?.version !== "string") { failures.push(`${path} node ${String(node?.id)} lacks exact data.package identity`) } if (wrapped(node?.data?.params)) failures.push(`${path} node ${String(node?.id)} contains wrapped parameter values`) diff --git a/front-end/src/Diagram.svelte.ts b/front-end/src/Diagram.svelte.ts index 9406a272..7e6c4bc8 100644 --- a/front-end/src/Diagram.svelte.ts +++ b/front-end/src/Diagram.svelte.ts @@ -21,7 +21,7 @@ import type { LayoutDirection } from "./layout/autoLayout"; import type { DatasetInferenceContext, GraphInferenceResult } from "./type-system/graph/types"; import { EditorTypeSystemRuntime } from "./type-system/editor-runtime"; import type { ModelBundleResources, PackageCatalogMetadata, PreparedModelScope } from "./type-system/editor-runtime"; -import type { PackageExportInfo } from "./type-system/packages/types"; +import type { PackageExportInfo, PackageKey } from "./type-system/packages/types"; import type { PackageIdentity } from "./core/types"; import type { DatasetDefinition } from "./project-workspace/dataset-contract"; import type { DiagramCoreSnapshot } from "./core/types"; @@ -46,6 +46,7 @@ export class Diagram extends DiagramCore { public packageRuntimeDiagnostics: PackageRuntimeDiagnostic[] = $state.raw([]); /** Current dataset instance used to resolve top-level Input nodes. */ public datasetInferenceContext: DatasetInferenceContext | null = $state.raw(null); + private projectScopeTransition = false; private packageTypeRuntime: EditorTypeSystemRuntime | null = null; private readonly packageRuntimeReadyPromise: Promise; private readonly diagnosticCollection = new PackageRuntimeDiagnosticCollection(); @@ -135,6 +136,21 @@ export class Diagram extends DiagramCore { this.refreshTypes() } + /** Add only packages from the visible, committed scope. */ + public override addPackageNode( + identity: PackageIdentity, + kind: Parameters[1], + x: number, + y: number, + config?: Parameters[4], + ): Node { + const key: PackageKey = `${identity.id}@${identity.version}`; + if (this.projectScopeTransition || !this.packageCatalog.some((metadata) => metadata.key === key)) { + throw new Error(`package '${key}' is unavailable in the active project scope`); + } + return super.addPackageNode(identity, kind, x, y, config); + } + /** Presentation docking is intentionally limited to ordinary layer nodes. */ public isLayerNode(node: Node): boolean { const identity = node.data?.package as { id?: unknown; version?: unknown } | undefined; @@ -259,33 +275,83 @@ export class Diagram extends DiagramCore { } /** Commit one already-prepared scope through DiagramCore and refresh UI state. */ - public async commitPreparedProjectScope(prepared: { readonly snapshot: DiagramCoreSnapshot; readonly scope: PreparedModelScope }): Promise { - await this.packageRuntimeReadyPromise; - if (!this.packageTypeRuntime) throw new Error("package type-system is unavailable"); - this.commitProject(prepared.snapshot); - await this.packageTypeRuntime.commitModelScope(prepared.scope); - this.diagnosticCollection.clear(); - this.syncPackageCatalog(); - this.syncRuntimeDiagnostics(); - this.refreshTypes(); + public async commitPreparedProjectScope( + prepared: { readonly snapshot: DiagramCoreSnapshot; readonly scope: PreparedModelScope }, + options: { readonly preserveLiveGraph?: boolean } = {}, + ): Promise { + let runtime = this.packageTypeRuntime; + if (!runtime) { + await this.packageRuntimeReadyPromise; + runtime = this.packageTypeRuntime; + } + if (!runtime) throw new Error("package type-system is unavailable"); + + // Authoring prepares package resources asynchronously. Capture the graph + // at the synchronous commit boundary so edits made during staging are not + // replaced by the older graph included in the prepared model snapshot. + const snapshot = options.preserveLiveGraph + ? { ...this.getSnapshot(), manifest: prepared.snapshot.manifest } + : prepared.snapshot; + if (options.preserveLiveGraph) { + for (const node of snapshot.nodes) { + const identity = node.data?.package as { readonly id?: unknown; readonly version?: unknown } | undefined; + if (typeof identity?.id !== "string" || typeof identity.version !== "string") continue; + const key: PackageKey = `${identity.id}@${identity.version}`; + if (!prepared.scope.catalog.getExact(key) || prepared.scope.coordinator.status(key)?.state !== "active") { + throw new Error(`package '${key}' is unavailable in the prepared model scope`); + } + } + } + await this.commitProjectScopeSnapshot(snapshot, prepared.scope, runtime, { retainBarrierOnFailure: true }); } /** Restore a previously committed project after a failed authoring commit. */ public async restoreProjectScope(modelJson: string, modelBundle: ModelBundleResources): Promise { const prepared = await this.prepareProjectScope(modelJson, modelBundle); - await this.commitPreparedProjectScope(prepared); + await this.commitPreparedProjectScope(prepared, { preserveLiveGraph: true }); } private syncPackageCatalog(): void { + if (this.projectScopeTransition) return; if (this.packageTypeRuntime) this.packageCatalog = this.packageTypeRuntime.availablePackages(); } + /** Swap graph and runtime behind one package-addition barrier. */ + private async commitProjectScopeSnapshot( + snapshot: DiagramCoreSnapshot, + scope: PreparedModelScope, + runtime: EditorTypeSystemRuntime, + options: { readonly retainBarrierOnFailure?: boolean } = {}, + ): Promise { + this.projectScopeTransition = true; + this.packageCatalog = []; + try { + this.commitProject(snapshot); + await runtime.commitModelScope(scope); + } catch (cause) { + // Authoring callers keep additions disabled for their coordinated rollback. + // A plain model import has no rollback owner, so republish the runtime's + // current catalog if its scope swap cannot finish cleanly. + if (!options.retainBarrierOnFailure) { + this.projectScopeTransition = false; + this.syncPackageCatalog(); + } + throw cause; + } + this.projectScopeTransition = false; + this.diagnosticCollection.clear(); + this.syncPackageCatalog(); + this.syncRuntimeDiagnostics(); + this.refreshTypes(); + } + /** Reconcile exact package references before the single graph commit. */ public async importProjectJson(jsonString: string, modelBundle?: ModelBundleResources): Promise { const parsed = this.parseProjectJson(jsonString); if (!parsed) return false; await this.packageRuntimeReadyPromise; - if (this.packageTypeRuntime) { + const runtime = this.packageTypeRuntime; + if (runtime) { let prepared; try { const graphIdentities = parsed.nodes.flatMap((node) => { @@ -294,7 +360,7 @@ export class Diagram extends DiagramCore { ? [{ id: identity.id, version: identity.version, ...(typeof identity.name === "string" ? { name: identity.name } : {}) }] : []; }); - prepared = await this.packageTypeRuntime.prepareModelScope(parsed.manifest, modelBundle, graphIdentities); + prepared = await runtime.prepareModelScope(parsed.manifest, modelBundle, graphIdentities); } catch (error) { this.diagnosticCollection.record({ occurrenceId: `runtime:model-switch:${parsed.manifest.id}@${parsed.manifest.version}`, @@ -304,14 +370,9 @@ export class Diagram extends DiagramCore { this.publishDiagnostics(); return false; } - // DiagramCore remains the graph authority: prepare all package runtime - // resources first, then commit the parsed graph and finally swap scopes. - this.commitProject(parsed); - await this.packageTypeRuntime.commitModelScope(prepared); - this.diagnosticCollection.clear(); - this.syncPackageCatalog(); - this.syncRuntimeDiagnostics(); - this.refreshTypes(); + // DiagramCore remains the graph authority; imports share the same + // package-addition barrier as transactional authoring scope swaps. + await this.commitProjectScopeSnapshot(parsed, prepared, runtime); return true; } this.commitProject(parsed); diff --git a/front-end/src/FlowCanvas.svelte b/front-end/src/FlowCanvas.svelte index 5bab3565..e6cfd43b 100644 --- a/front-end/src/FlowCanvas.svelte +++ b/front-end/src/FlowCanvas.svelte @@ -57,9 +57,11 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. // 1. Importiamo la classe Diagram import { Diagram, DIAGRAM_CONTEXT_KEY } from "./Diagram.svelte"; import { setContext, tick } from "svelte"; + import type { ModelPackageReference } from "./core/types"; import type { ProjectSaveStatus, ProjectWorkspaceSession } from "./project-workspace"; import { ProjectStereotypeAuthoringCoordinator } from "./project-workspace"; import { ProjectDatasetAuthoringCoordinator, type DatasetAuthoringRequest, type GeneratedDatasetResources } from "./project-workspace/dataset-authoring"; + import { ProjectAuthoringService } from "./project-workspace/authoring-service"; import type { DatasetParameterValue, DatasetReference, ModelDatasetReference } from "./project-workspace/dataset-contract"; import type { StereotypeAuthoringRequest } from "./stereotype-authoring"; import type { DatasetInfo } from "./training/api"; @@ -169,7 +171,11 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. } function authorStereotype(request: StereotypeAuthoringRequest): Promise { - return stereotypeAuthoring.author(request).then(() => undefined); + return authoringService.createStereotype(request).then(() => undefined); + } + + function deleteStereotype(target: ModelPackageReference): Promise { + return authoringService.deleteStereotype(target).then(() => undefined); } function datasetReference(modelDataset: ModelDatasetReference): DatasetReference { @@ -198,17 +204,14 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. } async function authorDataset(request: DatasetAuthoringRequest): Promise { - const { generated } = await datasetAuthoring.author(request); - installProjectDataset(generated); + await authoringService.createDataset(request); } async function updateDataset(target: ModelDatasetReference, request: DatasetAuthoringRequest): Promise { - const { generated } = await datasetAuthoring.update(target, request); - installProjectDataset(generated); + await authoringService.updateDataset(target, request); } - async function deleteDataset(target: ModelDatasetReference): Promise { - await datasetAuthoring.delete(target); + function removeProjectDataset(target: ModelDatasetReference): void { const reference = datasetReference(target); projectDatasetInfos = projectDatasetInfos.filter((dataset) => dataset.reference.ref !== reference.ref); const nextResources = new Map(projectDatasetResources); @@ -218,6 +221,15 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. diagram.setDatasetCatalog(projectDatasetInfos.map((dataset) => dataset.definition)); } + const authoringService = new ProjectAuthoringService(stereotypeAuthoring, datasetAuthoring, { + installDataset: installProjectDataset, + removeDataset: removeProjectDataset, + }); + + function deleteDataset(target: ModelDatasetReference): Promise { + return authoringService.deleteDataset(target).then(() => undefined); + } + // Stage package-aware import before exposing Svelte Flow. New projects carry // an empty graph, so retain Diagram's normal bootstrap Input and save that // accepted initial graph through the same writer. @@ -320,10 +332,14 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. let syncClient: BrowserRPCHandler; $effect(() => { - syncClient = rpcHandler ?? new BrowserRPCHandler(diagram, undefined, { fitView, setCenter }, trainingController); + syncClient = rpcHandler ?? new BrowserRPCHandler(diagram, undefined, { fitView, setCenter }, trainingController, undefined, authoringService); if (rpcHandler) { syncClient.bindDiagram(diagram); - return () => syncClient.bindDiagram(undefined); + syncClient.bindAuthoringService(authoringService); + return () => { + syncClient.bindDiagram(undefined); + syncClient.bindAuthoringService(undefined); + }; } syncClient.connect(); return () => syncClient.disconnect(); @@ -787,7 +803,9 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. > = [] @@ -115,3 +116,103 @@ describe("BrowserRPCHandler training download", () => { expect(sent[0]?.result).toEqual({ status: "ok" }) }) }) + +describe("BrowserRPCHandler project authoring", () => { + test("routes all four top-level requests to the shared project operations", async () => { + const stereotype = { id: "model.custom", version: "1.0.0", path: "packages/custom" } + const dataset = { id: "demo.tokens", version: "1.0.0", path: "datasets/tokens" } + const authoring: ProjectAuthoringOperations = { + createStereotype: vi.fn().mockResolvedValue(stereotype), + deleteStereotype: vi.fn().mockResolvedValue(stereotype), + createDataset: vi.fn().mockResolvedValue(dataset), + deleteDataset: vi.fn().mockResolvedValue(dataset), + } + const diagram = { nodes: [], edges: [], packageCatalog: [] } as any + const sent: Array> = [] + const handler: any = new BrowserRPCHandler(diagram, "ws://test", undefined, undefined, undefined, authoring) + handler.ws = { readyState: 1, send(payload: string) { sent.push(JSON.parse(payload)) } } + const stereotypeRequest = { + id: "model.custom", + version: "1.0.0", + directory: "packages/custom", + name: "Custom", + kind: "layer", + view: { color: "#123456", width: 120, height: 80 }, + dependencies: { "core.relu": "^0.1.0" }, + parameters: [], + } + const datasetRequest = { + id: "demo.tokens", + version: "1.0.0", + directory: "datasets/tokens", + name: "Tokens", + parameters: [], + inputs: [{ name: "tokens", shape: ["B", "T"], dtype: "int64" }], + targets: [{ name: "next_tokens", shape: ["B", "T"], dtype: "int64" }], + classes: { count: 2, names: ["no", "yes"] }, + dataFiles: [{ path: "train.pt", dataBase64: "AQID" }], + } + const calls = [ + { id: "create-stereotype", method: "create_stereotype", params: stereotypeRequest }, + { id: "delete-stereotype", method: "delete_stereotype", params: stereotype }, + { id: "create-dataset", method: "create_dataset", params: datasetRequest }, + { id: "delete-dataset", method: "delete_dataset", params: dataset }, + ] + for (const call of calls) handler.handleMessage({ data: JSON.stringify(call) }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(authoring.createStereotype).toHaveBeenCalledWith(stereotypeRequest) + expect(authoring.deleteStereotype).toHaveBeenCalledWith(stereotype) + expect(authoring.createDataset).toHaveBeenCalledWith({ + ...datasetRequest, + dataFiles: [{ path: "train.pt", bytes: new Uint8Array([1, 2, 3]) }], + }) + expect(authoring.deleteDataset).toHaveBeenCalledWith(dataset) + expect(sent.map((response) => response.result)).toEqual([stereotype, stereotype, dataset, dataset]) + }) + + test("rejects non-canonical dataset base64 before the shared coordinator runs", async () => { + const createDataset = vi.fn() + const authoring = { + createStereotype: vi.fn(), + deleteStereotype: vi.fn(), + createDataset, + deleteDataset: vi.fn(), + } as unknown as ProjectAuthoringOperations + const sent: Array> = [] + const handler: any = new BrowserRPCHandler({} as any, "ws://test", undefined, undefined, undefined, authoring) + handler.ws = { readyState: 1, send(payload: string) { sent.push(JSON.parse(payload)) } } + + handler.handleMessage({ data: JSON.stringify({ + id: "bad-dataset", + method: "create_dataset", + params: { id: "demo.tokens", dataFiles: [{ path: "train.pt", dataBase64: "AB==" }] }, + }) }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(createDataset).not.toHaveBeenCalled() + expect(sent[0]?.error).toMatchObject({ message: expect.stringMatching(/canonical base64/) }) + }) + + test("returns coordinator failures without reporting a successful delete", async () => { + const authoring: ProjectAuthoringOperations = { + createStereotype: vi.fn(), + deleteStereotype: vi.fn().mockRejectedValue(new Error("stereotype is required by another package")), + createDataset: vi.fn(), + deleteDataset: vi.fn(), + } + const sent: Array> = [] + const handler: any = new BrowserRPCHandler({} as any, "ws://test", undefined, undefined, undefined, authoring) + handler.ws = { readyState: 1, send(payload: string) { sent.push(JSON.parse(payload)) } } + + handler.handleMessage({ data: JSON.stringify({ + id: "blocked-delete", + method: "delete_stereotype", + params: { id: "model.custom", version: "1.0.0", path: "packages/custom" }, + }) }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(sent[0]?.result).toBeUndefined() + expect(sent[0]?.error).toEqual({ message: "stereotype is required by another package" }) + }) +}) diff --git a/front-end/src/__tests__/diagramProjectScopeTransition.test.ts b/front-end/src/__tests__/diagramProjectScopeTransition.test.ts new file mode 100644 index 00000000..095c57de --- /dev/null +++ b/front-end/src/__tests__/diagramProjectScopeTransition.test.ts @@ -0,0 +1,96 @@ +import { afterAll, afterEach, describe, expect, test } from "vitest" + +import { Diagram } from "../Diagram.svelte" +import type { EditorTypeSystemRuntime } from "../type-system/editor-runtime" +import { stubWindow, unstubWindow } from "./helpers" + +stubWindow() + +const packageReference = { id: "model.transition-probe", version: "1.0.0", path: "packages/transition-probe" } +const oldManifest = { + schemaVersion: 1 as const, + id: "model.old", + version: "1.0.0", + name: "Old model", + customPackages: [packageReference], +} +const oldModel = JSON.stringify({ manifest: oldManifest, nodes: [], edges: [] }) +const newModel = JSON.stringify({ + manifest: { schemaVersion: 1, id: "model.new", version: "1.0.0", name: "New model", customPackages: [] }, + nodes: [], + edges: [], +}) +const oldResources = { + "packages/transition-probe/manifest.json": JSON.stringify({ + schemaVersion: 1, + id: packageReference.id, + version: packageReference.version, + dependencies: {}, + entrypoints: { + definition: "stereotype.json", + inference: { language: "lua", file: "inference.lua" }, + }, + }), + "packages/transition-probe/stereotype.json": JSON.stringify({ + name: "Transition probe", + kind: "layer", + view: { color: "#123456", width: 100, height: 60 }, + parameters: {}, + }), + "packages/transition-probe/inference.lua": "return function(context) return { status = 'success', output = context.inputs[1] } end", +} + +const diagrams: Diagram[] = [] + +afterEach(async () => { + for (const diagram of diagrams.splice(0)) { + await (diagram as unknown as { packageTypeRuntime: EditorTypeSystemRuntime }).packageTypeRuntime.dispose() + } +}) +afterAll(() => unstubWindow()) + +describe("Diagram project scope transition", () => { + test("blocks old-scope package additions while an import swaps the runtime", async () => { + const diagram = new Diagram() + diagrams.push(diagram) + await diagram.waitForPackageRuntime() + await expect(diagram.importProjectJson(oldModel, oldResources)).resolves.toBe(true) + expect(diagram.packageCatalog.some(({ id, version }) => id === packageReference.id && version === packageReference.version)).toBe(true) + + const runtime = (diagram as unknown as { packageTypeRuntime: EditorTypeSystemRuntime }).packageTypeRuntime + const mutableRuntime = runtime as EditorTypeSystemRuntime & { commitModelScope: typeof runtime.commitModelScope } + const originalCommit = runtime.commitModelScope.bind(runtime) + let enteredCommit!: () => void + let releaseCommit!: () => void + const commitEntered = new Promise((resolve) => { enteredCommit = resolve }) + const holdCommit = new Promise((resolve) => { releaseCommit = resolve }) + mutableRuntime.commitModelScope = async (scope) => { + enteredCommit() + await holdCommit + return originalCommit(scope) + } + + const importing = diagram.importProjectJson(newModel) + try { + await commitEntered + expect(diagram.packageCatalog).toEqual([]) + expect(() => diagram.addPackageNode( + { id: packageReference.id, version: packageReference.version, name: "Transition probe" }, + "layer", + 30, + 30, + )).toThrow(/unavailable in the active project scope/) + releaseCommit() + await expect(importing).resolves.toBe(true) + expect(diagram.packageCatalog.some(({ id }) => id === packageReference.id)).toBe(false) + expect(diagram.nodes.some((node) => ( + node.data?.package && + (node.data.package as { id?: string; version?: string }).id === packageReference.id + ))).toBe(false) + } finally { + releaseCommit() + mutableRuntime.commitModelScope = originalCommit + await importing.catch(() => undefined) + } + }) +}) diff --git a/front-end/src/__tests__/packageManager.test.ts b/front-end/src/__tests__/packageManager.test.ts index 3138a412..c8604ff5 100644 --- a/front-end/src/__tests__/packageManager.test.ts +++ b/front-end/src/__tests__/packageManager.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest" -import PackageManager from "../components/PackageManager.svelte" +import PackageManager, { modelPackageDeleteTarget } from "../components/PackageManager.svelte" import type { PackageManagerPackage } from "../components/PackageManager.svelte" const definition = (name: string) => ({ name, kind: "layer" as const, view: { color: "#123456", width: 100, height: 80 }, parameters: {} }) @@ -19,4 +19,11 @@ describe("Stereotype manager", () => { expect(packages.filter((item) => item.source === "model").map((item) => item.definition.name)).toEqual(["Project layer"]) expect(packages.some((item) => item.source === "external")).toBe(true) }) + + test("only resolves exact active-project identities for confirmed deletion", () => { + const modelPackages = [{ id: "model.layer", version: "1.0.0", path: "packages/layer" }] + expect(modelPackageDeleteTarget({ key: "core.relu@1.0.0", source: "bundled" }, modelPackages)).toBeUndefined() + expect(modelPackageDeleteTarget({ key: "model.layer@1.0.0", source: "model" }, modelPackages)).toEqual(modelPackages[0]) + expect(modelPackageDeleteTarget({ key: "model.layer@2.0.0", source: "model" }, modelPackages)).toBeUndefined() + }) }) diff --git a/front-end/src/__tests__/projectDatasetAuthoring.test.ts b/front-end/src/__tests__/projectDatasetAuthoring.test.ts index 69ba65e6..3473ccf6 100644 --- a/front-end/src/__tests__/projectDatasetAuthoring.test.ts +++ b/front-end/src/__tests__/projectDatasetAuthoring.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest" +import { describe, expect, test, vi } from "vitest" import { createProjectWorkspace, openProjectWorkspace, @@ -12,8 +12,10 @@ import { ProjectDatasetAuthoringCoordinator, generateDatasetResources, readDatasetDataFile, + type GeneratedDatasetResources, } from "../project-workspace/dataset-authoring" -import type { ModelManifestV2 } from "../project-workspace/dataset-contract" +import { ProjectAuthoringService } from "../project-workspace/authoring-service" +import type { ModelDatasetReference, ModelManifestV2 } from "../project-workspace/dataset-contract" class MemoryFile implements ProjectFile { constructor(public value: string | Uint8Array) {} @@ -280,6 +282,94 @@ describe("project dataset authoring", () => { expect(restored.nodes).toEqual([{ id: "latest", position: { x: 350, y: 20 } }]) expect(restored.manifest.customDatasets).toEqual([]) }) + + test("shared project authoring returns exact identities and synchronizes dataset catalogs", async () => { + const parent = new MemoryDirectory() + const session = await createProjectWorkspace(parent, "demo", MODEL) + const stereotypeIdentity = { id: "model.custom", version: "1.0.0", path: "packages/custom" } + const stereotypes = { + author: vi.fn().mockResolvedValue({ generated: { modelPackage: stereotypeIdentity }, modelJson: MODEL } as never), + delete: vi.fn().mockResolvedValue(stereotypeIdentity), + } + const installed: GeneratedDatasetResources[] = [] + const removed: ModelDatasetReference[] = [] + const service = new ProjectAuthoringService( + stereotypes, + new ProjectDatasetAuthoringCoordinator(session), + { installDataset: (generated) => installed.push(generated), removeDataset: (target) => removed.push(target) }, + ) + + await expect(service.createStereotype({ + id: "model.custom", + version: "1.0.0", + directory: "packages/custom", + name: "Custom", + kind: "layer", + view: { color: "#123456", width: 100, height: 80 }, + parameters: [], + })).resolves.toEqual(stereotypeIdentity) + await expect(service.deleteStereotype(stereotypeIdentity)).resolves.toEqual(stereotypeIdentity) + + const createdDataset = await service.createDataset(REQUEST) + expect(createdDataset).toEqual({ id: "demo.tokens", version: "1.0.0", path: "datasets/tokens" }) + expect(installed).toHaveLength(1) + expect(installed[0]?.modelDataset).toEqual(createdDataset) + await expect(service.deleteDataset(createdDataset)).resolves.toEqual(createdDataset) + expect(removed).toEqual([createdDataset]) + expect(new ProjectDatasetAuthoringCoordinator(session).listProjectDatasets()).toEqual([]) + }) + + test("serializes stereotype and dataset mutations through the shared service", async () => { + const stereotypeIdentity = { id: "model.custom", version: "1.0.0", path: "packages/custom" } + let releaseStereotype!: () => void + const stereotypeBlocked = new Promise((resolve) => { releaseStereotype = resolve }) + const order: string[] = [] + const stereotypes = { + author: vi.fn(async () => { + order.push("stereotype-start") + await stereotypeBlocked + order.push("stereotype-end") + return { generated: { modelPackage: stereotypeIdentity }, modelJson: MODEL } as never + }), + delete: vi.fn().mockResolvedValue(stereotypeIdentity), + } + const generated = generateDatasetResources(REQUEST) + const datasets = { + author: vi.fn(async () => { + order.push("dataset") + return { generated, modelJson: MODEL } + }), + update: vi.fn(), + delete: vi.fn(), + } + const service = new ProjectAuthoringService( + stereotypes, + datasets, + { installDataset: () => undefined, removeDataset: () => undefined }, + ) + + const stereotypeRequest = { + id: "model.custom", + version: "1.0.0", + directory: "packages/custom", + name: "Custom", + kind: "layer" as const, + view: { color: "#123456", width: 100, height: 80 }, + parameters: [], + } + const stereotypeOperation = service.createStereotype(stereotypeRequest) + const datasetOperation = service.createDataset(REQUEST) + await Promise.resolve() + expect(order).toEqual(["stereotype-start"]) + expect(datasets.author).not.toHaveBeenCalled() + + releaseStereotype() + await expect(Promise.all([stereotypeOperation, datasetOperation])).resolves.toEqual([ + stereotypeIdentity, + generated.modelDataset, + ]) + expect(order).toEqual(["stereotype-start", "stereotype-end", "dataset"]) + }) }) function datasetBytes(value: string | Uint8Array): Uint8Array { diff --git a/front-end/src/__tests__/projectStereotypeCreation.test.ts b/front-end/src/__tests__/projectStereotypeCreation.test.ts index cd860142..1ced8783 100644 --- a/front-end/src/__tests__/projectStereotypeCreation.test.ts +++ b/front-end/src/__tests__/projectStereotypeCreation.test.ts @@ -2,13 +2,17 @@ import { afterEach, describe, expect, test } from "vitest" import { ProjectStereotypeAuthoringCoordinator, createProjectWorkspace, + openProjectWorkspace, readProjectWorkspace, + writeProjectFiles, type ProjectDirectoryHandle, type ProjectFile, type ProjectFileHandle, type ProjectWritableFile, } from "../project-workspace" -import type { ModelManifest } from "../core/types" +import type { DiagramCoreSnapshot, ModelManifest } from "../core/types" +import { generateStereotypePackage } from "../stereotype-authoring" +import { resolveModelPackageRecords } from "../type-system/editor-runtime" class File implements ProjectFile { constructor(public value: string | Uint8Array) {} @@ -28,6 +32,7 @@ class Directory implements ProjectDirectoryHandle { readonly kind = "directory" as const readonly directories = new Map() readonly files = new Map() + removalFailure: Error | undefined constructor(readonly name = "root") {} async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise { const current = this.directories.get(name) @@ -49,7 +54,10 @@ class Directory implements ProjectDirectoryHandle { for (const [name, value] of this.directories) yield [name, value] for (const [name, value] of this.files) yield [name, value] } - async removeEntry(name: string): Promise { this.directories.delete(name) } + async removeEntry(name: string): Promise { + if (this.removalFailure) throw this.removalFailure + this.directories.delete(name) + } } const manifest: ModelManifest = { schemaVersion: 1, id: "demo", version: "1.0.0", name: "Demo", customPackages: [] } @@ -103,24 +111,267 @@ describe("project stereotype authoring transaction", () => { expect(loaded.resources).not.toHaveProperty("packages/custom/manifest.json") expect(diagram.restores).toBe(1) }) + + test("preserves graph edits made while the created package scope is preparing", async () => { + parent = new Directory() + const session = await createProjectWorkspace(parent, "demo", modelJson) + const diagram = new FakeDiagram(manifest, modelJson) + const coordinator = new ProjectStereotypeAuthoringCoordinator(session, diagram) + diagram.beforePrepare = async () => { + diagram.beforePrepare = undefined + await Promise.resolve() + diagram.addGraphNode("concurrent-create") + } + + await coordinator.author(request) + + const persisted = JSON.parse((await readProjectWorkspace(session.directory)).modelJson) + expect(persisted.nodes).toContainEqual(expect.objectContaining({ id: "concurrent-create" })) + expect(JSON.parse(diagram.exportToJson()).nodes).toContainEqual(expect.objectContaining({ id: "concurrent-create" })) + }) + + test("deletes one exact project stereotype and commits the reduced live scope", async () => { + parent = new Directory() + const session = await createProjectWorkspace(parent, "demo", modelJson) + const diagram = new FakeDiagram(manifest, modelJson) + const coordinator = new ProjectStereotypeAuthoringCoordinator(session, diagram) + const { generated } = await coordinator.author(request) + + await expect(coordinator.delete(generated.modelPackage)).resolves.toEqual(generated.modelPackage) + + const loaded = await readProjectWorkspace(session.directory) + expect(JSON.parse(loaded.modelJson).manifest.customPackages).toEqual([]) + expect(Object.keys(loaded.resources)).toEqual(["model.json"]) + expect(diagram.modelManifest.customPackages).toEqual([]) + expect(diagram.commits).toBe(2) + }) + + test("preserves graph edits made while the reduced package scope is preparing", async () => { + parent = new Directory() + const session = await createProjectWorkspace(parent, "demo", modelJson) + const diagram = new FakeDiagram(manifest, modelJson) + const coordinator = new ProjectStereotypeAuthoringCoordinator(session, diagram) + const { generated } = await coordinator.author(request) + diagram.beforePrepare = async () => { + diagram.beforePrepare = undefined + await Promise.resolve() + diagram.addGraphNode("concurrent-delete") + } + + await coordinator.delete(generated.modelPackage) + + const persisted = JSON.parse((await readProjectWorkspace(session.directory)).modelJson) + expect(persisted.nodes).toContainEqual(expect.objectContaining({ id: "concurrent-delete" })) + expect(JSON.parse(diagram.exportToJson()).nodes).toContainEqual(expect.objectContaining({ id: "concurrent-delete" })) + }) + + test("does not add a deleted package while the asynchronous scope swap is committing", async () => { + parent = new Directory() + const session = await createProjectWorkspace(parent, "demo", modelJson) + const diagram = new FakeDiagram(manifest, modelJson) + const coordinator = new ProjectStereotypeAuthoringCoordinator(session, diagram) + const { generated } = await coordinator.author(request) + let rejectedDuringSwap = false + diagram.beforeScopeSwap = async () => { + await Promise.resolve() + try { + diagram.addPackageNode(generated.modelPackage.id, generated.modelPackage.version) + } catch { + rejectedDuringSwap = true + } + } + + await coordinator.delete(generated.modelPackage) + + const persisted = JSON.parse((await readProjectWorkspace(session.directory)).modelJson) + expect(rejectedDuringSwap).toBe(true) + expect(persisted.manifest.customPackages).toEqual([]) + expect(persisted.nodes.some((node: { data?: { package?: { id?: string; version?: string } } }) => ( + node.data?.package?.id === generated.modelPackage.id && + node.data.package.version === generated.modelPackage.version + ))).toBe(false) + }) + + test("rejects core, graph-used, and dependency-required targets without mutation", async () => { + parent = new Directory() + const session = await createProjectWorkspace(parent, "demo", modelJson) + const diagram = new FakeDiagram(manifest, modelJson) + const coordinator = new ProjectStereotypeAuthoringCoordinator(session, diagram) + const { generated } = await coordinator.author(request) + const before = await readProjectWorkspace(session.directory) + + await expect(coordinator.delete({ id: "core.relu", version: "1.0.0", path: "packages/relu" })).rejects.toThrow(/not owned/) + await expect(coordinator.delete({ ...generated.modelPackage, path: "packages/other" })).rejects.toThrow(/not owned/) + diagram.addPackageNode(generated.modelPackage.id, generated.modelPackage.version) + await expect(coordinator.delete(generated.modelPackage)).rejects.toThrow(/used by the project graph/) + diagram.clearGraph() + diagram.exports.set("model.dependent@1.0.0", { + manifest: { schemaVersion: 1, id: "model.dependent", version: "1.0.0", dependencies: {}, entrypoints: { definition: "stereotype.json" } }, + definition: { name: "Dependent", kind: "layer", view: { color: "#000000", width: 1, height: 1 }, parameters: {} }, + resolvedDependencies: { "model.custom": "model.custom@1.0.0" }, + state: "failed", + }) + await expect(coordinator.delete(generated.modelPackage)).rejects.toThrow(/required by another/) + + expect(await readProjectWorkspace(session.directory)).toEqual(before) + expect(diagram.modelManifest.customPackages).toEqual([generated.modelPackage]) + }) + + test("rejects deleting a dependency from package records loaded by the model runtime", async () => { + parent = new Directory() + const target = generateStereotypePackage({ + ...request, + id: "qa.mcp-layer", + directory: "packages/qa.mcp-layer", + }) + const dependent = { + id: "qa.dependent", + version: "1.0.0", + path: "packages/qa.dependent", + } + const dependentManifest = { + schemaVersion: 1, + id: dependent.id, + version: dependent.version, + dependencies: { "qa.mcp-layer": "^1.0.0" }, + entrypoints: { definition: "stereotype.json" }, + } + const dependentDefinition = { + name: "Dependent", + kind: "layer", + view: { color: "#123456", width: 100, height: 60 }, + parameters: {}, + } + const projectManifest: ModelManifest = { + ...manifest, + customPackages: [target.modelPackage, dependent], + } + const projectJson = JSON.stringify({ manifest: projectManifest, nodes: [], edges: [] }) + const projectFiles = { + "model.json": projectJson, + ...Object.fromEntries(Object.entries(target.files).map(([path, value]) => [`${target.modelPackage.path}/${path}`, value])), + [`${dependent.path}/manifest.json`]: JSON.stringify(dependentManifest), + [`${dependent.path}/stereotype.json`]: JSON.stringify(dependentDefinition), + } + await writeProjectFiles(parent, projectFiles) + const session = await openProjectWorkspace(parent) + const runtimeRecords = await resolveModelPackageRecords(projectManifest, session.resources) + const diagram = new FakeDiagram(projectManifest, projectJson) + diagram.exports = new Map(runtimeRecords.map((record) => [record.key, { + manifest: record.manifest, + definition: record.definition, + resources: record.resources, + resolvedDependencies: record.resolvedDependencies, + }])) + const coordinator = new ProjectStereotypeAuthoringCoordinator(session, diagram) + + await expect(coordinator.delete(target.modelPackage)).rejects.toThrow(/required by another project stereotype/) + + expect(runtimeRecords.find((record) => record.manifest.id === dependent.id)?.resolvedDependencies) + .toEqual({ "qa.mcp-layer": "qa.mcp-layer@1.0.0" }) + expect(await readProjectWorkspace(session.directory)).toEqual({ + modelJson: session.modelJson, + resources: session.resources, + }) + }) + + test("restores the exact package and manifest when deletion cannot finish", async () => { + parent = new Directory() + const session = await createProjectWorkspace(parent, "demo", modelJson) + const diagram = new FakeDiagram(manifest, modelJson) + const coordinator = new ProjectStereotypeAuthoringCoordinator(session, diagram) + const { generated } = await coordinator.author(request) + const packages = await session.directory.getDirectoryHandle("packages") as Directory + packages.removalFailure = new Error("directory is busy") + + await expect(coordinator.delete(generated.modelPackage)).rejects.toThrow(/directory is busy/) + + const restored = await readProjectWorkspace(session.directory) + expect(JSON.parse(restored.modelJson).manifest.customPackages).toEqual([generated.modelPackage]) + expect(restored.resources).toHaveProperty("packages/custom/stereotype.json") + expect(diagram.modelManifest.customPackages).toEqual([generated.modelPackage]) + }) + + test("restores package files, manifest, and active scope after commit failure", async () => { + parent = new Directory() + const session = await createProjectWorkspace(parent, "demo", modelJson) + const diagram = new FakeDiagram(manifest, modelJson) + const coordinator = new ProjectStereotypeAuthoringCoordinator(session, diagram) + const { generated } = await coordinator.author(request) + diagram.failCommit = true + + await expect(coordinator.delete(generated.modelPackage)).rejects.toThrow(/activation failed/) + + const restored = await readProjectWorkspace(session.directory) + expect(JSON.parse(restored.modelJson).manifest.customPackages).toEqual([generated.modelPackage]) + expect(restored.resources).toHaveProperty("packages/custom/stereotype.json") + expect(diagram.modelManifest.customPackages).toEqual([generated.modelPackage]) + expect(diagram.restores).toBe(1) + }) }) class FakeDiagram { commits = 0 restores = 0 + failCommit: boolean + exports = new Map() + beforePrepare: (() => Promise | void) | undefined + beforeScopeSwap: (() => Promise | void) | undefined + private scopeTransition = false + private visiblePackages = new Set() + private project: Record constructor( public modelManifest: ModelManifest, - private readonly json: string, - private readonly failCommit = false, - ) {} - exportToJson(): string { return this.json } + json: string, + failCommit = false, + ) { + this.project = JSON.parse(json) as Record + this.failCommit = failCommit + } + exportToJson(): string { return JSON.stringify({ ...this.project, manifest: this.modelManifest }) } + packageExports() { return this.exports } + addPackageNode(id: string, version: string): void { + const key = `${id}@${version}` + if (this.scopeTransition || !this.visiblePackages.has(key)) throw new Error(`package '${key}' is unavailable`) + this.project.nodes = [{ id: "node", data: { package: { id, version } } }] + } + addGraphNode(id: string): void { + const nodes = Array.isArray(this.project.nodes) ? this.project.nodes : [] + this.project.nodes = [...nodes, { id, position: { x: 350, y: 20 }, data: { concurrent: true } }] + } + clearGraph(): void { this.project.nodes = [] } async prepareProjectScope(modelJson: string, modelBundle: Record) { - return { snapshot: { manifest: JSON.parse(modelJson).manifest, nodes: [], edges: [], layoutDirection: "vertical" }, scope: { modelBundle } } as never + const project = JSON.parse(modelJson) as Record + const beforePrepare = this.beforePrepare + this.beforePrepare = undefined + await beforePrepare?.() + return { snapshot: { manifest: project.manifest, nodes: project.nodes ?? [], edges: project.edges ?? [], layoutDirection: "vertical" }, scope: { modelBundle } } as never } - async commitPreparedProjectScope(): Promise { + async commitPreparedProjectScope( + prepared: { readonly snapshot: DiagramCoreSnapshot }, + options: { readonly preserveLiveGraph?: boolean } = {}, + ): Promise { this.commits += 1 + const current = JSON.parse(this.exportToJson()) as DiagramCoreSnapshot + const snapshot = options.preserveLiveGraph + ? { ...prepared.snapshot, ...current, manifest: prepared.snapshot.manifest } + : prepared.snapshot + this.modelManifest = snapshot.manifest + this.project = { ...snapshot } + this.scopeTransition = true + this.visiblePackages.clear() + const beforeScopeSwap = this.beforeScopeSwap + this.beforeScopeSwap = undefined + await beforeScopeSwap?.() if (this.failCommit) throw new Error("activation failed") + this.visiblePackages = new Set(snapshot.manifest.customPackages.map((candidate) => `${candidate.id}@${candidate.version}`)) + this.scopeTransition = false + } + async restoreProjectScope(modelJson: string): Promise { + this.restores += 1 + this.project = JSON.parse(modelJson) as Record + this.modelManifest = this.project.manifest as ModelManifest + this.visiblePackages = new Set(this.modelManifest.customPackages.map((candidate) => `${candidate.id}@${candidate.version}`)) + this.scopeTransition = false } - async restoreProjectScope(): Promise { this.restores += 1 } } - diff --git a/front-end/src/components/PackageManager.svelte b/front-end/src/components/PackageManager.svelte index be1f1c7a..8ecec965 100644 --- a/front-end/src/components/PackageManager.svelte +++ b/front-end/src/components/PackageManager.svelte @@ -1,9 +1,23 @@ + +

@@ -47,14 +88,39 @@

{t("User packages")}

{#each project as packageInfo (packageInfo.key)} + {@const target = modelPackageDeleteTarget(packageInfo, modelPackages)}
{packageInfo.definition.name}{packageInfo.key} {t("Project")} + {#if target && onStereotypeDeleteRequest} + + {/if}
{/each}
{/if} + {#if pendingStereotypeDeletion} +
+

{t("Delete this project stereotype and its folder?")}

+ {pendingStereotypeDeletion.id}@{pendingStereotypeDeletion.version} · {pendingStereotypeDeletion.path} + {#if stereotypeDeletionError} + + {/if} +
+ + +
+
+ {/if} +

{t("Create new")}

diff --git a/front-end/src/i18n.ts b/front-end/src/i18n.ts index d7fee833..e1dd241e 100644 --- a/front-end/src/i18n.ts +++ b/front-end/src/i18n.ts @@ -185,6 +185,8 @@ const italianMessages: Record = { "Delete {name}, its project folder, and its active training entry?": "Eliminare {name}, la cartella del progetto e la relativa voce di training attiva?", "Deleting…": "Eliminazione…", "Delete dataset": "Elimina dataset", + "Delete this project stereotype and its folder?": "Eliminare questo stereotype del progetto e la relativa cartella?", + "Delete stereotype": "Elimina stereotype", "Dataset updated.": "Dataset aggiornato.", "Dataset created.": "Dataset creato.", "Identity and metadata": "Identità e metadati", diff --git a/front-end/src/project-workspace/authoring-service.ts b/front-end/src/project-workspace/authoring-service.ts new file mode 100644 index 00000000..28652b3c --- /dev/null +++ b/front-end/src/project-workspace/authoring-service.ts @@ -0,0 +1,80 @@ +import type { ModelPackageReference } from "../core/types" +import type { StereotypeAuthoringRequest } from "../stereotype-authoring" +import type { + DatasetAuthoringRequest, + GeneratedDatasetResources, + ProjectDatasetAuthoringCoordinator, +} from "./dataset-authoring" +import type { ModelDatasetReference } from "./dataset-contract" +import type { ProjectStereotypeAuthoringCoordinator } from "./authoring" + +/** The browser-owned operations shared by visible forms and browser RPC. */ +export interface ProjectAuthoringOperations { + createStereotype(request: StereotypeAuthoringRequest): Promise + deleteStereotype(target: ModelPackageReference): Promise + createDataset(request: DatasetAuthoringRequest): Promise + deleteDataset(target: ModelDatasetReference): Promise +} + +export type ProjectAuthoringServiceEffects = { + readonly installDataset: (generated: GeneratedDatasetResources) => void + readonly removeDataset: (target: ModelDatasetReference) => void +} + +type StereotypeCoordinator = Pick +type DatasetCoordinator = Pick + +/** + * Routes every authoring entry point through the project's existing + * coordinators, then keeps the browser's training and inference catalogs in + * step with successful dataset transactions. + */ +export class ProjectAuthoringService implements ProjectAuthoringOperations { + private tail: Promise = Promise.resolve() + + constructor( + private readonly stereotypes: StereotypeCoordinator, + private readonly datasets: DatasetCoordinator, + private readonly effects: ProjectAuthoringServiceEffects, + ) {} + + createStereotype(request: StereotypeAuthoringRequest): Promise { + return this.serialize(async () => { + const { generated } = await this.stereotypes.author(request) + return generated.modelPackage + }) + } + + deleteStereotype(target: ModelPackageReference): Promise { + return this.serialize(() => this.stereotypes.delete(target)) + } + + createDataset(request: DatasetAuthoringRequest): Promise { + return this.serialize(async () => { + const { generated } = await this.datasets.author(request) + this.effects.installDataset(generated) + return generated.modelDataset + }) + } + + updateDataset(target: ModelDatasetReference, request: DatasetAuthoringRequest): Promise { + return this.serialize(async () => { + const { generated } = await this.datasets.update(target, request) + this.effects.installDataset(generated) + }) + } + + deleteDataset(target: ModelDatasetReference): Promise { + return this.serialize(async () => { + await this.datasets.delete(target) + this.effects.removeDataset(target) + return target + }) + } + + private serialize(operation: () => Promise): Promise { + const result = this.tail.then(operation) + this.tail = result.then(() => undefined, () => undefined) + return result + } +} diff --git a/front-end/src/project-workspace/authoring.ts b/front-end/src/project-workspace/authoring.ts index ddde2ad9..e4c58432 100644 --- a/front-end/src/project-workspace/authoring.ts +++ b/front-end/src/project-workspace/authoring.ts @@ -1,10 +1,12 @@ -import type { DiagramCoreSnapshot, ModelManifest } from "../core/types" +import type { DiagramCoreSnapshot, ModelManifest, ModelPackageReference } from "../core/types" import { generateStereotypePackage, type GeneratedStereotypeResources, type StereotypeAuthoringRequest } from "../stereotype-authoring" import type { ModelBundleResources, PreparedModelScope } from "../type-system/editor-runtime" +import type { PackageExportInfo, PackageKey } from "../type-system/packages/types" import { ensureProjectPermission, normalizeProjectPath, type ProjectDirectoryHandle, + type ProjectFileHandle, type ProjectWorkspaceSession, writeProjectFiles, } from "./index" @@ -16,14 +18,15 @@ export type PreparedProjectScope = { /** The narrow Diagram seam needed by project-owned stereotype authoring. */ export interface ProjectAuthoringDiagram { - readonly modelManifest: ModelManifest + modelManifest: ModelManifest exportToJson(): string + packageExports(): ReadonlyMap prepareProjectScope(modelJson: string, modelBundle: ModelBundleResources): Promise - commitPreparedProjectScope(prepared: PreparedProjectScope): Promise + commitPreparedProjectScope(prepared: PreparedProjectScope, options?: { readonly preserveLiveGraph?: boolean }): Promise restoreProjectScope(modelJson: string, modelBundle: ModelBundleResources): Promise } -export type ProjectAuthoringPhase = "validation" | "scope" | "package-write" | "model-write" | "activation" | "rollback" +export type ProjectAuthoringPhase = "validation" | "scope" | "package-write" | "package-delete" | "model-write" | "activation" | "rollback" export class ProjectStereotypeAuthoringError extends Error { constructor( @@ -62,7 +65,6 @@ export type ProjectStereotypeAuthoringResult = { */ export class ProjectStereotypeAuthoringCoordinator { private resources: ModelBundleResources - private modelJson: string private tail: Promise = Promise.resolve() constructor( @@ -70,7 +72,6 @@ export class ProjectStereotypeAuthoringCoordinator { private readonly diagram: ProjectAuthoringDiagram, ) { this.resources = { ...session.resources } - this.modelJson = session.modelJson } author(request: StereotypeAuthoringRequest): Promise { @@ -79,10 +80,13 @@ export class ProjectStereotypeAuthoringCoordinator { return operation } + delete(target: ModelPackageReference): Promise { + const operation = this.tail.then(() => this.runDelete(target)) + this.tail = operation.then(() => undefined, () => undefined) + return operation + } + private async run(request: StereotypeAuthoringRequest): Promise { - // The session's initial modelJson is intentionally immutable. Graph - // autosaves may have advanced since startup, so author against the live - // DiagramCore snapshot and use that same snapshot for rollback. const previousModelJson = this.diagram.exportToJson() let generated: GeneratedStereotypeResources try { @@ -93,7 +97,14 @@ export class ProjectStereotypeAuthoringCoordinator { const packagePath = normalizeProjectPath(generated.modelPackage.path) const packagePrefix = `${packagePath}/` - if (this.diagram.modelManifest.customPackages.some((candidate) => ( + try { + await ensureProjectPermission(this.session.directory) + } catch (cause) { + throw new ProjectStereotypeAuthoringError(messageOf(cause), "package-write", cause) + } + + const currentManifest = this.diagram.modelManifest + if (currentManifest.customPackages.some((candidate) => ( candidate.id === generated.modelPackage.id || candidate.path === packagePath ))) { throw new ProjectStereotypeAuthoringError( @@ -104,20 +115,11 @@ export class ProjectStereotypeAuthoringCoordinator { if (Object.keys(this.resources).some((path) => path === packagePath || path.startsWith(packagePrefix))) { throw new ProjectStereotypeAuthoringError(`Project package directory '${packagePath}' already exists`, "validation") } - try { - await ensureProjectPermission(this.session.directory) - } catch (cause) { - throw new ProjectStereotypeAuthoringError(messageOf(cause), "package-write", cause) - } - - const currentParsed = JSON.parse(previousModelJson) as Record - const currentManifest = this.diagram.modelManifest const nextManifest: ModelManifest = { ...currentManifest, customPackages: [...currentManifest.customPackages, generated.modelPackage], } - const nextProject = { ...currentParsed, manifest: nextManifest } - const nextModelJson = JSON.stringify(nextProject, null, 2) + let nextModelJson = withModelManifest(this.diagram.exportToJson(), nextManifest) const nextResources: ModelBundleResources = { ...this.resources, ...Object.fromEntries(Object.entries(generated.files).map(([path, value]) => [`${packagePrefix}${path}`, value])), @@ -132,6 +134,7 @@ export class ProjectStereotypeAuthoringCoordinator { const created = await createPackageDirectory(this.session.directory, packagePath) let modelWriteAttempted = false + let modelManifestInstalled = false let commitAttempted = false try { try { @@ -141,33 +144,181 @@ export class ProjectStereotypeAuthoringCoordinator { } try { modelWriteAttempted = true + this.diagram.modelManifest = nextManifest + modelManifestInstalled = true + // Include graph edits made while scope preparation and package I/O + // were awaiting; the Diagram commit also rebases at its boundary. + nextModelJson = withModelManifest(this.diagram.exportToJson(), nextManifest) await this.session.save(nextModelJson) } catch (cause) { throw new ProjectStereotypeAuthoringError(messageOf(cause), "model-write", cause) } try { commitAttempted = true - await this.diagram.commitPreparedProjectScope(prepared) + await this.diagram.commitPreparedProjectScope(prepared, { preserveLiveGraph: true }) } catch (cause) { throw new ProjectStereotypeAuthoringError(messageOf(cause), "activation", cause) } + const committedModelJson = this.diagram.exportToJson() + if (committedModelJson !== nextModelJson) { + await this.session.save(committedModelJson) + nextModelJson = committedModelJson + } } catch (operationError) { const rollbackErrors: unknown[] = [] + if (modelManifestInstalled) this.diagram.modelManifest = currentManifest + const rollbackModelJson = modelJsonForRollback(previousModelJson, this.diagram.exportToJson(), currentManifest) if (modelWriteAttempted) { - try { await this.session.save(previousModelJson) } catch (cause) { rollbackErrors.push(new Error(`model restore: ${messageOf(cause)}`)) } + try { await this.session.save(rollbackModelJson) } catch (cause) { rollbackErrors.push(new Error(`model restore: ${messageOf(cause)}`)) } } try { await removeCreatedPackageDirectory(created) } catch (cause) { rollbackErrors.push(new Error(`package removal: ${messageOf(cause)}`)) } if (commitAttempted) { - try { await this.diagram.restoreProjectScope(previousModelJson, this.resources) } catch (cause) { rollbackErrors.push(new Error(`runtime restore: ${messageOf(cause)}`)) } + try { await this.diagram.restoreProjectScope(rollbackModelJson, this.resources) } catch (cause) { rollbackErrors.push(new Error(`runtime restore: ${messageOf(cause)}`)) } } if (rollbackErrors.length > 0) throw new ProjectAuthoringRollbackError(operationError, new Error(rollbackErrors.map(messageOf).join("; "))) throw operationError } this.resources = nextResources - this.modelJson = nextModelJson return { generated, modelJson: nextModelJson } } + + private async runDelete(target: ModelPackageReference): Promise { + const initialManifest = this.diagram.modelManifest + const initialTarget = initialManifest.customPackages.find((candidate) => samePackageIdentity(candidate, target)) + if (!initialTarget) { + throw new ProjectStereotypeAuthoringError( + `Stereotype '${target.id}@${target.version}' at '${target.path}' is not owned by the active project`, + "validation", + ) + } + if (graphUsesPackage(this.diagram.exportToJson(), initialTarget)) { + throw new ProjectStereotypeAuthoringError( + `Stereotype '${initialTarget.id}@${initialTarget.version}' is used by the project graph`, + "validation", + ) + } + if (isRequiredByAnotherPackage(this.diagram.packageExports(), initialTarget)) { + throw new ProjectStereotypeAuthoringError( + `Stereotype '${initialTarget.id}@${initialTarget.version}' is required by another project stereotype`, + "validation", + ) + } + if (!this.session.directory.removeEntry) { + throw new ProjectStereotypeAuthoringError("The browser cannot remove project stereotype directories", "package-delete") + } + + let directory: ProjectDirectoryHandle + let previousPackageResources: Record + let previousPackageDirectories: string[] + try { + await ensureProjectPermission(this.session.directory) + directory = await openPackageDirectory(this.session.directory, initialTarget.path) + const snapshot = await readPackageDirectory(directory, initialTarget.path) + previousPackageResources = snapshot.resources + previousPackageDirectories = snapshot.directories + } catch (cause) { + throw new ProjectStereotypeAuthoringError(messageOf(cause), "package-delete", cause) + } + + const previousModelJson = this.diagram.exportToJson() + const currentManifest = this.diagram.modelManifest + const owned = currentManifest.customPackages.find((candidate) => samePackageIdentity(candidate, target)) + if (!owned) { + throw new ProjectStereotypeAuthoringError( + `Stereotype '${target.id}@${target.version}' is no longer owned by the active project`, + "validation", + ) + } + if (graphUsesPackage(previousModelJson, owned)) { + throw new ProjectStereotypeAuthoringError( + `Stereotype '${owned.id}@${owned.version}' is used by the project graph`, + "validation", + ) + } + if (isRequiredByAnotherPackage(this.diagram.packageExports(), owned)) { + throw new ProjectStereotypeAuthoringError( + `Stereotype '${owned.id}@${owned.version}' is required by another project stereotype`, + "validation", + ) + } + + const prefix = `${owned.path}/` + const nextResources = Object.fromEntries( + Object.entries(this.resources).filter(([path]) => !path.startsWith(prefix)), + ) + const nextPackages = currentManifest.customPackages.filter((candidate) => !samePackageIdentity(candidate, owned)) + const nextManifest: ModelManifest = { ...currentManifest, customPackages: nextPackages } + let nextModelJson = withModelManifest(previousModelJson, nextManifest) + + let prepared: PreparedProjectScope + try { + prepared = await this.diagram.prepareProjectScope(nextModelJson, nextResources) + } catch (cause) { + throw new ProjectStereotypeAuthoringError(messageOf(cause), "scope", cause) + } + + const parentPath = owned.path.split("/").slice(0, -1).join("/") + const directoryName = owned.path.split("/").pop()! + let modelWriteAttempted = false + let directoryRemovalAttempted = false + let modelManifestInstalled = false + let commitAttempted = false + try { + try { + modelWriteAttempted = true + this.diagram.modelManifest = nextManifest + modelManifestInstalled = true + nextModelJson = withModelManifest(this.diagram.exportToJson(), nextManifest) + await this.session.save(nextModelJson) + } catch (cause) { + throw new ProjectStereotypeAuthoringError(messageOf(cause), "model-write", cause) + } + try { + directoryRemovalAttempted = true + const parent = parentPath ? await openPackageDirectory(this.session.directory, parentPath) : this.session.directory + if (!parent.removeEntry) throw new Error("The browser cannot remove project stereotype directories") + await parent.removeEntry(directoryName, { recursive: true }) + } catch (cause) { + throw new ProjectStereotypeAuthoringError(messageOf(cause), "package-delete", cause) + } + try { + commitAttempted = true + await this.diagram.commitPreparedProjectScope(prepared, { preserveLiveGraph: true }) + } catch (cause) { + throw new ProjectStereotypeAuthoringError(messageOf(cause), "activation", cause) + } + const committedModelJson = this.diagram.exportToJson() + if (committedModelJson !== nextModelJson) { + await this.session.save(committedModelJson) + nextModelJson = committedModelJson + } + } catch (operationError) { + const rollbackErrors: unknown[] = [] + if (modelManifestInstalled) this.diagram.modelManifest = currentManifest + const rollbackModelJson = modelJsonForRollback(previousModelJson, this.diagram.exportToJson(), currentManifest) + if (directoryRemovalAttempted) { + try { + await restorePackageDirectory(this.session.directory, owned.path, previousPackageDirectories, previousPackageResources) + } catch (cause) { + rollbackErrors.push(new Error(`package restore: ${messageOf(cause)}`)) + } + } + if (modelWriteAttempted) { + try { await this.session.save(rollbackModelJson) } catch (cause) { rollbackErrors.push(new Error(`model restore: ${messageOf(cause)}`)) } + } + if (commitAttempted) { + const previousResources = { ...this.resources, ...previousPackageResources } + try { await this.diagram.restoreProjectScope(rollbackModelJson, previousResources) } + catch (cause) { rollbackErrors.push(new Error(`runtime restore: ${messageOf(cause)}`)) } + } + if (rollbackErrors.length > 0) throw new ProjectAuthoringRollbackError(operationError, new Error(rollbackErrors.map(messageOf).join("; "))) + throw operationError + } + + this.resources = nextResources + return owned + } } type CreatedPackageDirectory = { @@ -202,6 +353,138 @@ async function removeCreatedPackageDirectory(created: CreatedPackageDirectory): await created.parent.removeEntry(created.name, { recursive: true }) } +async function openPackageDirectory(root: ProjectDirectoryHandle, path: string): Promise { + let directory = root + for (const part of normalizeProjectPath(path).split("/")) directory = await directory.getDirectoryHandle(part) + return directory +} + +async function readPackageDirectory( + directory: ProjectDirectoryHandle, + projectPath: string, +): Promise<{ readonly resources: Record; readonly directories: string[] }> { + const resources: Record = {} + const directories: string[] = [] + await readPackageEntries(directory, projectPath, resources, directories) + return { resources, directories } +} + +async function readPackageEntries( + directory: ProjectDirectoryHandle, + prefix: string, + resources: Record, + directories: string[], +): Promise { + const entries = directory.entries ? directory.entries() : directory.values ? namedEntries(directory.values()) : undefined + if (!entries) throw new Error("Directory handle does not support recursive enumeration") + for await (const [name, handle] of entries) { + const path = normalizeProjectPath(`${prefix}/${name}`) + if (isProjectDirectory(handle)) { + directories.push(path) + await readPackageEntries(handle, path, resources, directories) + } else { + const file = await (handle as ProjectFileHandle).getFile() + // Prefer bytes because File.text() would corrupt arbitrary binary assets + // when a failed deletion needs to restore the exact package contents. + if (file.arrayBuffer) resources[path] = new Uint8Array(await file.arrayBuffer()) + else if (file.text) resources[path] = await file.text() + else throw new Error(`Project file '${path}' cannot be read`) + } + } +} + +async function* namedEntries( + values: AsyncIterable, +): AsyncIterable<[string, ProjectDirectoryHandle | ProjectFileHandle]> { + for await (const handle of values) { + if (!handle.name) throw new Error("File system entry has no name") + yield [handle.name, handle] + } +} + +function isProjectDirectory(handle: ProjectDirectoryHandle | ProjectFileHandle): handle is ProjectDirectoryHandle { + return handle.kind === "directory" || (handle.kind === undefined && ("entries" in handle || "values" in handle)) +} + +async function restorePackageDirectory( + root: ProjectDirectoryHandle, + path: string, + directories: readonly string[], + resources: Readonly>, +): Promise { + const packageDirectory = await createPackageDirectoryForRestore(root, path) + for (const directoryPath of directories) { + await openOrCreatePackageDirectory(root, directoryPath) + } + const prefix = `${path}/` + const files = Object.fromEntries(Object.entries(resources) + .filter(([resourcePath]) => resourcePath.startsWith(prefix)) + .map(([resourcePath, value]) => [resourcePath.slice(prefix.length), value])) + await writeProjectFiles(packageDirectory, files) +} + +async function createPackageDirectoryForRestore(root: ProjectDirectoryHandle, path: string): Promise { + const segments = normalizeProjectPath(path).split("/") + const name = segments.pop()! + let parent = root + for (const segment of segments) parent = await openOrCreateChildDirectory(parent, segment) + return openOrCreateChildDirectory(parent, name) +} + +async function openOrCreatePackageDirectory(root: ProjectDirectoryHandle, path: string): Promise { + let directory = root + for (const part of normalizeProjectPath(path).split("/")) directory = await openOrCreateChildDirectory(directory, part) + return directory +} + +async function openOrCreateChildDirectory(parent: ProjectDirectoryHandle, name: string): Promise { + try { return await parent.getDirectoryHandle(name) } + catch (cause) { + if (!isNotFoundError(cause)) throw cause + return parent.getDirectoryHandle(name, { create: true }) + } +} + +function graphUsesPackage(modelJson: string, target: ModelPackageReference): boolean { + const project = JSON.parse(modelJson) as { readonly nodes?: unknown } + if (!Array.isArray(project.nodes)) return false + return project.nodes.some((value) => { + if (!value || typeof value !== "object") return false + const node = value as { readonly data?: { readonly package?: { readonly id?: unknown; readonly version?: unknown } } } + return node.data?.package?.id === target.id && node.data.package.version === target.version + }) +} + +function withModelManifest(modelJson: string, manifest: ModelManifest): string { + const project = JSON.parse(modelJson) as Record + return JSON.stringify({ ...project, manifest }, null, 2) +} + +function modelJsonForRollback(previousModelJson: string, liveModelJson: string, manifest: ModelManifest): string { + const previous = JSON.parse(previousModelJson) as Record + const live = JSON.parse(liveModelJson) as Record + const graphUnchanged = JSON.stringify(previous.nodes ?? []) === JSON.stringify(live.nodes ?? []) && + JSON.stringify(previous.edges ?? []) === JSON.stringify(live.edges ?? []) + if (graphUnchanged) return previousModelJson + return JSON.stringify({ ...previous, nodes: live.nodes ?? [], edges: live.edges ?? [], manifest }, null, 2) +} + +function isRequiredByAnotherPackage( + packageExports: ReadonlyMap, + target: ModelPackageReference, +): boolean { + const targetKey: PackageKey = `${target.id}@${target.version}` + for (const [candidateKey, candidate] of packageExports) { + if (candidateKey === targetKey) continue + if (Object.values(candidate.resolvedDependencies ?? {}).includes(targetKey)) return true + } + return false +} + +function samePackageIdentity(left: ModelPackageReference, right: ModelPackageReference): boolean { + return left.id === right.id && left.version === right.version && left.path === right.path +} + function isNotFoundError(cause: unknown): boolean { if (!cause || typeof cause !== "object") return false const error = cause as { name?: string; code?: number | string; message?: string } diff --git a/front-end/src/sync/BrowserRPCHandler.ts b/front-end/src/sync/BrowserRPCHandler.ts index 33b646d1..60a8b258 100644 --- a/front-end/src/sync/BrowserRPCHandler.ts +++ b/front-end/src/sync/BrowserRPCHandler.ts @@ -21,6 +21,7 @@ // | { id: string, error: { message: string } } import type { Diagram } from "../Diagram.svelte"; +import type { ModelPackageReference } from "../core/types"; import type { Node, Edge } from "@xyflow/svelte"; import type { GraphInferenceResult } from "../type-system/graph/types"; import { packageIdentity } from "../type-system/graph/types"; @@ -28,7 +29,10 @@ import { compileGraphBindings } from "../type-system/graph/bindings"; import { initialPackageParameters, validatePackageParameterValues } from "../type-system/editor/package-ui"; import { TrainingController } from "../training/controller"; import type { ProjectPathPayload } from "../project-workspace/path"; -import type { DatasetParameterValue } from "../project-workspace/dataset-contract"; +import type { DatasetParameterValue, ModelDatasetReference } from "../project-workspace/dataset-contract"; +import type { DatasetAuthoringRequest } from "../project-workspace/dataset-authoring"; +import type { ProjectAuthoringOperations } from "../project-workspace/authoring-service"; +import type { StereotypeAuthoringRequest } from "../stereotype-authoring"; // ── RPC Types ────────────────────────────────────────────────────────── @@ -124,6 +128,7 @@ export class BrowserRPCHandler { private viewport?: ViewportController; private training?: TrainingController; private project?: ProjectRPCBridge; + private authoring?: ProjectAuthoringOperations; private readonly pendingBrowserRequests = new Map(); private nextBrowserRequestId = 0; @@ -136,7 +141,14 @@ export class BrowserRPCHandler { * @param viewport Optional viewport controller (fitView/setCenter). * Passed from FlowCanvas.svelte via useSvelteFlow(). */ - constructor(diagram: Diagram | undefined, url?: string, viewport?: ViewportController, training?: TrainingController, project?: ProjectRPCBridge) { + constructor( + diagram: Diagram | undefined, + url?: string, + viewport?: ViewportController, + training?: TrainingController, + project?: ProjectRPCBridge, + authoring?: ProjectAuthoringOperations, + ) { this.diagram = diagram as Diagram; this.diagramActive = diagram !== undefined; this.url = @@ -147,6 +159,7 @@ export class BrowserRPCHandler { this.viewport = viewport; this.training = training; this.project = project; + this.authoring = authoring; } bindDiagram(diagram: Diagram | undefined): void { @@ -154,6 +167,10 @@ export class BrowserRPCHandler { if (diagram) this.diagram = diagram; } + bindAuthoringService(authoring: ProjectAuthoringOperations | undefined): void { + this.authoring = authoring; + } + /** Send a fire-and-forget browser-owned notification to the MCP bridge. */ notify(method: string, params: Record): void { if (this.ws?.readyState === WEBSOCKET_OPEN) this.ws.send(JSON.stringify({ method, params })); @@ -304,6 +321,20 @@ export class BrowserRPCHandler { result = this.handleGetPackageDiagnostics(); break; + // ── Project authoring ──────────────────────────────────── + case "create_stereotype": + result = this.requireAuthoringService().createStereotype(params as unknown as StereotypeAuthoringRequest); + break; + case "delete_stereotype": + result = this.requireAuthoringService().deleteStereotype(projectPackageIdentity(params)); + break; + case "create_dataset": + result = this.requireAuthoringService().createDataset(decodeDatasetAuthoringRequest(params)); + break; + case "delete_dataset": + result = this.requireAuthoringService().deleteDataset(projectDatasetIdentity(params)); + break; + // ── Mutations ───────────────────────────────────────────── case "create_node": result = this.handleCreateNode(params); @@ -466,6 +497,11 @@ export class BrowserRPCHandler { return this.training; } + private requireAuthoringService(): ProjectAuthoringOperations { + if (!this.authoring) throw Object.assign(new Error("Project authoring service unavailable for this editor"), { code: "PROJECT_AUTHORING_UNAVAILABLE" }); + return this.authoring; + } + private syncDatasetInference(): void { const training = this.training; if (!training) return; @@ -1192,3 +1228,58 @@ export class BrowserRPCHandler { }; } } + +function projectPackageIdentity(params: unknown): ModelPackageReference { + return projectResourceIdentity(params, "stereotype") +} + +function projectDatasetIdentity(params: unknown): ModelDatasetReference { + return projectResourceIdentity(params, "dataset") +} + +function projectResourceIdentity( + value: unknown, + resource: "stereotype" | "dataset", +): T { + if (!isRecord(value)) throw new Error(`${resource} identity must be an object`) + for (const key of ["id", "version", "path"] as const) { + if (typeof value[key] !== "string" || value[key].length === 0) { + throw new Error(`${resource} identity '${key}' must be a non-empty string`) + } + } + return { id: value.id as string, version: value.version as string, path: value.path as string } as T +} + +function decodeDatasetAuthoringRequest(value: unknown): DatasetAuthoringRequest { + if (!isRecord(value)) throw new Error("dataset authoring request must be an object") + if (value.dataFiles === undefined) return value as unknown as DatasetAuthoringRequest + if (!Array.isArray(value.dataFiles)) throw new Error("dataset dataFiles must be an array") + const dataFiles = value.dataFiles.map((entry, index) => { + if (!isRecord(entry)) throw new Error(`dataset data file ${index} must be an object`) + if (typeof entry.path !== "string") throw new Error(`dataset data file ${index} path must be a string`) + if (typeof entry.dataBase64 !== "string") throw new Error(`dataset data file ${index} dataBase64 must be a string`) + return { path: entry.path, bytes: decodeStrictBase64(entry.dataBase64) } + }) + return { ...value, dataFiles } as unknown as DatasetAuthoringRequest +} + +const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ + +function decodeStrictBase64(value: string): Uint8Array { + if (!BASE64.test(value)) throw new Error("dataset data file dataBase64 must be canonical base64") + let binary: string + try { + binary = atob(value) + } catch { + throw new Error("dataset data file dataBase64 must be canonical base64") + } + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)) + let encodedBytes = "" + for (const byte of bytes) encodedBytes += String.fromCharCode(byte) + if (btoa(encodedBytes) !== value) throw new Error("dataset data file dataBase64 must be canonical base64") + return bytes +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} diff --git a/front-end/src/type-system/editor-runtime.ts b/front-end/src/type-system/editor-runtime.ts index d9714816..004cc910 100644 --- a/front-end/src/type-system/editor-runtime.ts +++ b/front-end/src/type-system/editor-runtime.ts @@ -3,7 +3,7 @@ import type { DatasetDefinition } from "../project-workspace/dataset-contract" import { PackageGraphScheduler } from "./graph/scheduler" import { TypeSystemHost, type ActivePackageMetadata, type PackageSelection } from "./host" import { bundledCoreRecords } from "./bundled/catalog" -import { PackageCatalog, packageKey, packageRecordKey } from "./packages/catalog" +import { immutableRecord, PackageCatalog, packageKey, packageRecordKey } from "./packages/catalog" import type { InstalledPackageRecord, PackageExportInfo, PackageKey, PackageSource } from "./packages/types" import { parseModelManifest, type ModelManifest, type PackageIdentity } from "../core/types" import { parseDefinition, parseManifest } from "./packages/validation" @@ -226,7 +226,7 @@ export class EditorTypeSystemRuntime { graphIdentities: readonly RuntimePackageIdentity[] = [], ): Promise { const manifest = parseModelManifest(manifestValue) - const customPackages = await resolveModelPackageRecords(manifest, bundle) + const customPackages = await resolveModelPackageRecords(manifest, bundle, this.bundled) const catalog = PackageCatalog.composeModel(this.bundled, customPackages) const host = await TypeSystemHost.create(catalog.records().map((record) => ({ resources: record.resources }))) const coordinator = new PackageActivationCoordinator(host, catalog) @@ -299,6 +299,7 @@ export class EditorTypeSystemRuntime { export async function resolveModelPackageRecords( manifest: ModelManifest, bundle: ModelBundleResources | undefined, + bundled: readonly InstalledPackageRecord[] = [], ): Promise { if (manifest.customPackages.length > 0 && bundle === undefined) { throw new Error(`model '${manifest.id}' declares custom packages but no model bundle was provided`) @@ -335,7 +336,11 @@ export async function resolveModelPackageRecords( resources, })) } - return records + const resolver = PackageCatalog.composeModel(bundled, records) + return records.map((record) => immutableRecord({ + ...record, + resolvedDependencies: resolver.resolveDependencies(record.key), + })) } function decodeModelResource(value: string | Uint8Array): string { diff --git a/mcp-server/__tests__/authoring-tools.test.ts b/mcp-server/__tests__/authoring-tools.test.ts new file mode 100644 index 00000000..aa3d0285 --- /dev/null +++ b/mcp-server/__tests__/authoring-tools.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from "vitest" +import type { ServerContext } from "../src/server" +import type { BrowserRPCClient } from "../src/browser-client" +import * as authoringTools from "../src/tools/authoring" +import { z } from "zod" + +function createContext() { + const call = vi.fn().mockResolvedValue({ status: "ok" }) + const ctx = { browser: { call } as unknown as BrowserRPCClient } as ServerContext + return { ctx, call } +} + +async function invoke( + tool: { schema: S; handler(ctx: ServerContext, input: z.infer): Promise }, + ctx: ServerContext, + input: unknown, +) { + return tool.handler(ctx, tool.schema.parse(input)) +} + +describe("project authoring tools", () => { + it("exports exactly the four auto-discoverable authoring tools", () => { + expect(Object.keys(authoringTools).sort()).toEqual([ + "create_dataset", + "create_stereotype", + "delete_dataset", + "delete_stereotype", + ]) + for (const tool of Object.values(authoringTools)) { + expect(tool.schema).toBeDefined() + expect(tool.handler).toBeTypeOf("function") + } + }) + + it("forwards the complete stereotype form request and returns the browser result", async () => { + const { ctx, call } = createContext() + const request = { + id: "example.loss", + version: "0.1.0", + directory: "packages/example-loss", + name: "Example loss", + description: "A project-owned loss", + kind: "loss", + view: { color: "#123456", width: 180, height: 72 }, + dependencies: { "example.base": "^1.0.0" }, + parameters: [{ + name: "reduction", + definition: { type: "string", choices: ["mean", "sum"], default: "mean", position: "bottom" }, + }], + objective: { externalInputs: [{ name: "target", source: "batch.targets.target", transform: "flatten_batch" }] }, + } + const browserResult = { id: request.id, version: request.version, path: request.directory } + call.mockResolvedValue(browserResult) + + await expect(invoke(authoringTools.create_stereotype, ctx, request)).resolves.toEqual(browserResult) + expect(call).toHaveBeenCalledWith("create_stereotype", request) + }) + + it("forwards dataset semantic fields and strict base64 file transport unchanged", async () => { + const { ctx, call } = createContext() + const request = { + id: "example.dataset", + version: "1.2.0", + directory: "datasets/example-dataset", + name: "Example dataset", + description: "Tiny fixture", + parameters: [{ name: "batch_size", type: "integer", required: true, default: 8 }], + inputs: [{ name: "features", shape: ["B", 4], dtype: "float32" }], + targets: [{ name: "labels", shape: ["B"], dtype: "int64" }], + classes: { count: 2, names: ["negative", "positive"] }, + dataFiles: [{ path: "train.bin", dataBase64: "AAEC" }], + } + + await invoke(authoringTools.create_dataset, ctx, request) + expect(call).toHaveBeenCalledWith("create_dataset", request) + }) + + it("proxies a valid dataset identity to the selected browser tab", async () => { + const { ctx, call } = createContext() + const request = { id: "example.dataset", version: "1.2.0", path: "datasets/example-dataset" } + await invoke(authoringTools.delete_dataset, ctx, request) + expect(call).toHaveBeenCalledWith("delete_dataset", request) + }) + + it("proxies a valid stereotype identity to the selected browser tab", async () => { + const { ctx, call } = createContext() + const request = { id: "example.layer", version: "0.1.0", path: "packages/example-layer" } + await invoke(authoringTools.delete_stereotype, ctx, request) + expect(call).toHaveBeenCalledWith("delete_stereotype", request) + }) + + it.each(["AAE", "AA?=", "Zh=="])("rejects malformed or non-canonical base64 (%s) before browser RPC", async (dataBase64) => { + const { ctx, call } = createContext() + const request = { + id: "example.dataset", + version: "1.2.0", + directory: "datasets/example-dataset", + name: "Example dataset", + parameters: [], + inputs: [], + targets: [], + dataFiles: [{ path: "train.bin", dataBase64 }], + } + + await expect(invoke(authoringTools.create_dataset, ctx, request)).rejects.toThrow() + expect(call).not.toHaveBeenCalled() + }) + + it.each([ + { id: 42, version: "1.0.0", path: "datasets/example" }, + { id: "example.dataset", version: "1.0.0" }, + { id: "example.dataset", version: "1.0.0", path: "datasets/example", name: "display name" }, + ])("rejects malformed or non-exact delete identity before browser RPC", async (identity) => { + const { ctx, call } = createContext() + await expect(invoke(authoringTools.delete_dataset, ctx, identity)).rejects.toThrow() + expect(call).not.toHaveBeenCalled() + }) + + it("leaves browser/domain errors visible to the MCP caller", async () => { + const { ctx, call } = createContext() + call.mockRejectedValue(new Error("stereotype is required by another package")) + const request = { id: "example.layer", version: "0.1.0", path: "packages/example-layer" } + + await expect(invoke(authoringTools.delete_stereotype, ctx, request)).rejects.toThrow( + "stereotype is required by another package", + ) + }) +}) diff --git a/mcp-server/src/server.ts b/mcp-server/src/server.ts index c793e271..fce899aa 100644 --- a/mcp-server/src/server.ts +++ b/mcp-server/src/server.ts @@ -51,6 +51,7 @@ import * as connectionTools from "./tools/connection.js"; import * as screenshotTools from "./tools/screenshot.js"; import * as remoteTrainingTools from "./tools/remote-training.js"; import * as projectTools from "./tools/project.js"; +import * as authoringTools from "./tools/authoring.js"; import { applyProjectResource, type ProjectResourceOperation } from "./project-path.js"; // ── ServerContext ─────────────────────────────────────────────────────── @@ -180,6 +181,7 @@ export async function createServer( screenshotTools, remoteTrainingTools, projectTools, + authoringTools, ] as Record[]; for (const module of allToolModules) { diff --git a/mcp-server/src/tools/authoring.ts b/mcp-server/src/tools/authoring.ts new file mode 100644 index 00000000..884490b1 --- /dev/null +++ b/mcp-server/src/tools/authoring.ts @@ -0,0 +1,156 @@ +import { z } from "zod" +import type { ServerContext } from "../server.js" + +const packageKind = z.enum(["input", "layer", "loss", "join", "subflow", "output"]) +const dtype = z.enum([ + "float16", "bfloat16", "float32", "float64", + "int8", "uint8", "int16", "int32", "int64", "bool", +]) +const dimension = z.union([z.string(), z.number().finite()]) +const jsonValue: z.ZodType = z.lazy(() => z.union([ + z.null(), + z.boolean(), + z.number().finite(), + z.string(), + z.array(jsonValue), + z.record(z.string(), jsonValue), +])) + +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } + +const listItemDefinition = z.discriminatedUnion("type", [ + z.object({ type: z.literal("integer"), minimum: z.number().optional(), maximum: z.number().optional(), default: z.number().int().optional() }).strict(), + z.object({ type: z.literal("number"), minimum: z.number().optional(), maximum: z.number().optional(), default: z.number().optional() }).strict(), + z.object({ type: z.literal("boolean"), default: z.boolean().optional() }).strict(), + z.object({ type: z.literal("string"), choices: z.array(z.string()).optional(), default: z.string().optional() }).strict(), +]) + +const parameterDefinition = z.discriminatedUnion("type", [ + z.object({ type: z.literal("integer"), minimum: z.number().optional(), maximum: z.number().optional(), default: z.number().int().optional(), position: z.enum(["top", "bottom"]) }).strict(), + z.object({ type: z.literal("number"), minimum: z.number().optional(), maximum: z.number().optional(), default: z.number().optional(), position: z.enum(["top", "bottom"]) }).strict(), + z.object({ type: z.literal("boolean"), default: z.boolean().optional(), position: z.enum(["top", "bottom"]) }).strict(), + z.object({ type: z.literal("string"), choices: z.array(z.string()).optional(), default: z.string().optional(), position: z.enum(["top", "bottom"]) }).strict(), + z.object({ type: z.literal("dtype"), choices: z.array(dtype), default: dtype.optional(), position: z.enum(["top", "bottom"]) }).strict(), + z.object({ type: z.literal("shape"), default: z.array(dimension).optional(), position: z.enum(["top", "bottom"]) }).strict(), + z.object({ + type: z.literal("list"), + items: listItemDefinition, + minItems: z.number().optional(), + maxItems: z.number().optional(), + default: z.array(jsonValue).optional(), + position: z.enum(["top", "bottom"]), + }).strict(), + z.object({ + type: z.literal("stereotype"), + kind: packageKind, + default: z.object({ + id: z.string().min(1), + version: z.string().min(1), + parameters: z.record(z.string(), jsonValue), + }).strict().optional(), + position: z.enum(["top", "bottom"]), + }).strict(), +]) + +const stereotypeAuthoringRequest = z.object({ + id: z.string().min(1), + version: z.string().min(1), + directory: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + kind: packageKind, + view: z.object({ + color: z.string(), + width: z.number(), + height: z.number(), + }).strict(), + dependencies: z.record(z.string(), z.string()).optional(), + parameters: z.array(z.object({ + name: z.string().min(1), + definition: parameterDefinition, + }).strict()), + objective: z.object({ + externalInputs: z.array(z.object({ + name: z.string(), + source: z.string(), + transform: z.enum(["flatten_batch"]).optional(), + }).strict()), + }).strict().optional(), +}).strict() + +const datasetParameter = z.discriminatedUnion("type", [ + z.object({ name: z.string(), type: z.literal("string"), required: z.boolean(), default: z.string().optional() }).strict(), + z.object({ name: z.string(), type: z.literal("integer"), required: z.boolean(), default: z.number().int().optional() }).strict(), + z.object({ name: z.string(), type: z.literal("number"), required: z.boolean(), default: z.number().optional() }).strict(), + z.object({ name: z.string(), type: z.literal("boolean"), required: z.boolean(), default: z.boolean().optional() }).strict(), +]) + +const datasetSlot = z.object({ + name: z.string(), + shape: z.array(dimension), + dtype, +}).strict() + +const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ + +const isCanonicalBase64 = (value: string): boolean => { + if (!BASE64.test(value)) return false + return Buffer.from(value, "base64").toString("base64") === value +} + +const datasetAuthoringRequest = z.object({ + id: z.string().min(1), + version: z.string().min(1), + directory: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + parameters: z.array(datasetParameter), + inputs: z.array(datasetSlot), + targets: z.array(datasetSlot), + classes: z.object({ + count: z.number().int(), + names: z.array(z.string()).optional(), + }).strict().optional(), + dataFiles: z.array(z.object({ + path: z.string().min(1), + dataBase64: z.string().regex(BASE64, "must use standard base64").refine(isCanonicalBase64, "must be canonical standard base64"), + }).strict()).optional(), +}).strict() + +const resourceIdentity = z.object({ + id: z.string().min(1), + version: z.string().min(1), + path: z.string().min(1), +}).strict() + +/** Create a project-owned stereotype through the selected browser's authoring coordinator. */ +export const create_stereotype = { + schema: stereotypeAuthoringRequest, + async handler(ctx: ServerContext, input: z.infer) { + return ctx.browser.call("create_stereotype", input) + }, +} + +/** Delete exactly one project-owned stereotype; ownership and eligibility stay browser-owned. */ +export const delete_stereotype = { + schema: resourceIdentity, + async handler(ctx: ServerContext, input: z.infer) { + return ctx.browser.call("delete_stereotype", input) + }, +} + +/** Create a project-owned dataset; optional file bytes cross JSON as canonical base64. */ +export const create_dataset = { + schema: datasetAuthoringRequest, + async handler(ctx: ServerContext, input: z.infer) { + return ctx.browser.call("create_dataset", input) + }, +} + +/** Delete exactly one project-owned dataset; backend archives and jobs are untouched by this proxy. */ +export const delete_dataset = { + schema: resourceIdentity, + async handler(ctx: ServerContext, input: z.infer) { + return ctx.browser.call("delete_dataset", input) + }, +}