diff --git a/Cargo.lock b/Cargo.lock index 1158704ac..f918fbe60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,7 +5,7 @@ version = 4 [[package]] name = "ab_glyph_rasterizer" version = "0.1.8" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "accessory" @@ -262,7 +262,7 @@ dependencies = [ [[package]] name = "arrayvec" version = "0.7.6" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "as-slice" @@ -282,7 +282,7 @@ checksum = "9dbc3a507a82b17ba0d98f6ce8fd6954ea0c8152e98009d36a40d8dcc8ce078a" [[package]] name = "ash" version = "0.38.0+1.3.281" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "libloading 0.8.9", ] @@ -834,7 +834,7 @@ dependencies = [ [[package]] name = "bit-set" version = "0.8.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "bit-vec", ] @@ -842,7 +842,7 @@ dependencies = [ [[package]] name = "bit-vec" version = "0.8.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "bit_field" @@ -868,7 +868,7 @@ dependencies = [ [[package]] name = "bitflags" version = "2.10.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "bitmaps" @@ -1020,7 +1020,7 @@ checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "bytemuck" version = "1.25.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "byteorder" @@ -1031,7 +1031,7 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "byteorder" version = "1.5.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "byteorder-lite" @@ -1132,7 +1132,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg-if" version = "1.0.4" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "cfg_aliases" @@ -1143,7 +1143,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "cfg_aliases" version = "0.2.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "chacha20" @@ -1505,7 +1505,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crunchy" version = "0.2.4" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "crypto-bigint" @@ -1921,7 +1921,7 @@ checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] name = "downcast-rs" version = "1.2.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "dunce" @@ -2076,7 +2076,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "equivalent" version = "1.0.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "errno" @@ -2300,7 +2300,7 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "foldhash" version = "0.2.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "foreign-types" @@ -2457,9 +2457,9 @@ dependencies = [ [[package]] name = "fxhash" version = "0.2.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ - "byteorder 1.5.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "byteorder 1.5.0 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", ] [[package]] @@ -2665,9 +2665,9 @@ dependencies = [ [[package]] name = "hashbrown" version = "0.16.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ - "foldhash 0.2.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "foldhash 0.2.0 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", ] [[package]] @@ -2700,7 +2700,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hexf-parse" version = "0.2.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "hilog-sys" @@ -3182,10 +3182,10 @@ dependencies = [ [[package]] name = "indexmap" version = "2.13.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ - "equivalent 1.0.2 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", - "hashbrown 0.16.1 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "equivalent 1.0.2 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", + "hashbrown 0.16.1 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", ] [[package]] @@ -3404,9 +3404,9 @@ dependencies = [ [[package]] name = "libloading" version = "0.8.9" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ - "cfg-if 1.0.4 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "cfg-if 1.0.4 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", "windows-link 0.2.1", ] @@ -3478,7 +3478,7 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "log" version = "0.4.29" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "loom" @@ -3568,7 +3568,7 @@ dependencies = [ [[package]] name = "makepad-apple-sys" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-objc-sys", ] @@ -3576,12 +3576,12 @@ dependencies = [ [[package]] name = "makepad-byteorder-lite" version = "0.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "makepad-code-editor" version = "2.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-widgets", ] @@ -3589,7 +3589,7 @@ dependencies = [ [[package]] name = "makepad-derive-wasm-bridge" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-micro-proc-macro", ] @@ -3597,7 +3597,7 @@ dependencies = [ [[package]] name = "makepad-derive-widget" version = "2.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-live-id", "makepad-micro-proc-macro", @@ -3606,7 +3606,7 @@ dependencies = [ [[package]] name = "makepad-draw" version = "2.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "ab_glyph_rasterizer", "fxhash", @@ -3623,49 +3623,54 @@ dependencies = [ "rustybuzz", "sdfer", "serde", - "unicode-bidi 0.3.18 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "unicode-bidi 0.3.18 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", "unicode-linebreak", - "unicode-segmentation 1.12.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "unicode-segmentation 1.12.0 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", ] [[package]] name = "makepad-error-log" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-micro-serde", ] +[[package]] +name = "makepad-fast-inflate" +version = "0.1.0" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" + [[package]] name = "makepad-filesystem-watcher" version = "0.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "makepad-futures" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "makepad-futures-legacy" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "makepad-gif" version = "0.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ - "weezl 0.1.12 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "weezl 0.1.12 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", ] [[package]] name = "makepad-half" version = "2.7.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ - "cfg-if 1.0.4 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", - "crunchy 0.2.4 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "cfg-if 1.0.4 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", + "crunchy 0.2.4 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", "num-traits 0.2.20", "zerocopy 0.8.39", ] @@ -3673,7 +3678,7 @@ dependencies = [ [[package]] name = "makepad-html" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-live-id", ] @@ -3687,7 +3692,7 @@ checksum = "9775cbec5fa0647500c3e5de7c850280a88335d1d2d770e5aa2332b801ba7064" [[package]] name = "makepad-latex-math" version = "0.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "ttf-parser", ] @@ -3695,7 +3700,7 @@ dependencies = [ [[package]] name = "makepad-live-id" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-live-id-macros", "serde", @@ -3704,7 +3709,7 @@ dependencies = [ [[package]] name = "makepad-live-id-macros" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-micro-proc-macro", ] @@ -3712,7 +3717,7 @@ dependencies = [ [[package]] name = "makepad-live-reload-core" version = "0.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-filesystem-watcher", ] @@ -3720,7 +3725,7 @@ dependencies = [ [[package]] name = "makepad-math" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-micro-serde", ] @@ -3728,12 +3733,12 @@ dependencies = [ [[package]] name = "makepad-micro-proc-macro" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "makepad-micro-serde" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-live-id", "makepad-micro-serde-derive", @@ -3742,7 +3747,7 @@ dependencies = [ [[package]] name = "makepad-micro-serde-derive" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-micro-proc-macro", ] @@ -3750,7 +3755,7 @@ dependencies = [ [[package]] name = "makepad-network" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-apple-sys", "makepad-error-log", @@ -3764,15 +3769,15 @@ dependencies = [ [[package]] name = "makepad-objc-sys" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "makepad-platform" version = "2.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "ash", - "bitflags 2.10.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "bitflags 2.10.0 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", "ctrlc", "hilog-sys", "makepad-android-state", @@ -3788,13 +3793,14 @@ dependencies = [ "makepad-shared-bytes", "makepad-studio-protocol", "makepad-tsdf", + "makepad-video", "makepad-wasm-bridge", "makepad-zune-png", "naga", "napi-derive-ohos", "napi-ohos", "ohos-sys", - "smallvec 1.15.1 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "smallvec 1.15.1 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", "wayland-client 0.31.12", "wayland-egl", "wayland-protocols 0.32.10", @@ -3806,12 +3812,12 @@ dependencies = [ [[package]] name = "makepad-regex" version = "0.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "makepad-script" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-error-log", "makepad-html", @@ -3819,13 +3825,14 @@ dependencies = [ "makepad-math", "makepad-regex", "makepad-script-derive", - "smallvec 1.15.1 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "makepad-stitch", + "smallvec 1.15.1 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", ] [[package]] name = "makepad-script-derive" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-micro-proc-macro", ] @@ -3833,7 +3840,7 @@ dependencies = [ [[package]] name = "makepad-script-std" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-network", "makepad-script", @@ -3842,14 +3849,19 @@ dependencies = [ [[package]] name = "makepad-shared-bytes" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" + +[[package]] +name = "makepad-stitch" +version = "0.1.0" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "makepad-studio-protocol" version = "0.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ - "bitflags 2.10.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "bitflags 2.10.0 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", "makepad-error-log", "makepad-live-id", "makepad-micro-serde", @@ -3859,7 +3871,7 @@ dependencies = [ [[package]] name = "makepad-svg" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-html", "makepad-live-id", @@ -3868,16 +3880,26 @@ dependencies = [ [[package]] name = "makepad-tsdf" version = "0.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-math", "makepad-micro-serde", ] +[[package]] +name = "makepad-video" +version = "1.0.0" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" +dependencies = [ + "makepad-apple-sys", + "makepad-objc-sys", + "windows 0.62.2", +] + [[package]] name = "makepad-wasm-bridge" version = "1.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-derive-wasm-bridge", "makepad-live-id", @@ -3886,7 +3908,7 @@ dependencies = [ [[package]] name = "makepad-webp" version = "0.2.4" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-byteorder-lite", ] @@ -3894,7 +3916,7 @@ dependencies = [ [[package]] name = "makepad-widgets" version = "2.0.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-derive-widget", "makepad-draw", @@ -3903,13 +3925,13 @@ dependencies = [ "pulldown-cmark 0.12.2", "serde", "ttf-parser", - "unicode-segmentation 1.12.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "unicode-segmentation 1.12.0 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", ] [[package]] name = "makepad-zune-bmp" version = "0.5.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "log 0.4.28", "makepad-zune-core", @@ -3918,7 +3940,7 @@ dependencies = [ [[package]] name = "makepad-zune-core" version = "0.5.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "log 0.4.28", ] @@ -3926,15 +3948,15 @@ dependencies = [ [[package]] name = "makepad-zune-inflate" version = "0.2.54" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ - "simd-adler32 0.3.9 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "simd-adler32 0.3.9 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", ] [[package]] name = "makepad-zune-jpeg" version = "0.5.15" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-zune-core", ] @@ -3942,8 +3964,9 @@ dependencies = [ [[package]] name = "makepad-zune-png" version = "0.5.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ + "makepad-fast-inflate", "makepad-zune-core", "makepad-zune-inflate", ] @@ -3951,7 +3974,7 @@ dependencies = [ [[package]] name = "makepad-zune-qoi" version = "0.5.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-zune-core", ] @@ -4347,7 +4370,7 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memchr" version = "2.7.6" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "memoffset" @@ -4436,20 +4459,20 @@ dependencies = [ [[package]] name = "naga" version = "27.0.3" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ - "arrayvec 0.7.6 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "arrayvec 0.7.6 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", "bit-set", "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", - "cfg-if 1.0.4 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", - "cfg_aliases 0.2.1 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", - "hashbrown 0.16.1 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "cfg-if 1.0.4 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", + "cfg_aliases 0.2.1 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", + "hashbrown 0.16.1 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", "hexf-parse", - "indexmap 2.13.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "indexmap 2.13.0 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", "makepad-error-log", "makepad-half", "num-traits 0.2.20", - "once_cell 1.21.3 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "once_cell 1.21.3 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", "rustc-hash 1.1.0", "spirv", "thiserror 2.0.18", @@ -4676,7 +4699,7 @@ dependencies = [ [[package]] name = "num-traits" version = "0.2.20" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "num_cpus" @@ -4803,7 +4826,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell" version = "1.21.3" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "once_cell_polyfill" @@ -5075,7 +5098,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "pkg-config" version = "0.3.32" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "png" @@ -5281,10 +5304,10 @@ dependencies = [ [[package]] name = "pulldown-cmark" version = "0.12.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ - "bitflags 2.10.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", - "memchr 2.7.6 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "bitflags 2.10.0 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", + "memchr 2.7.6 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", "unicase 2.9.0", ] @@ -6134,7 +6157,7 @@ dependencies = [ [[package]] name = "rustc-hash" version = "1.1.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "rustc-hash" @@ -6259,12 +6282,12 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rustybuzz" version = "0.18.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ - "bitflags 2.10.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", - "bytemuck 1.25.0 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "bitflags 2.10.0 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", + "bytemuck 1.25.0 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", "makepad-error-log", - "smallvec 1.15.1 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "smallvec 1.15.1 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", "ttf-parser", "unicode-bidi-mirroring", "unicode-ccc", @@ -6353,7 +6376,7 @@ checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" [[package]] name = "scoped-tls" version = "1.0.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "scopeguard" @@ -6364,7 +6387,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sdfer" version = "0.2.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "sealed" @@ -6680,7 +6703,7 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simd-adler32" version = "0.3.9" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "simd_helpers" @@ -6715,7 +6738,7 @@ dependencies = [ [[package]] name = "smallvec" version = "1.15.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "socket2" @@ -6739,7 +6762,7 @@ dependencies = [ [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -7231,7 +7254,7 @@ dependencies = [ [[package]] name = "thiserror" version = "2.0.18" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "thiserror-impl 2.0.18", ] @@ -7261,7 +7284,7 @@ dependencies = [ [[package]] name = "thiserror-impl" version = "2.0.18" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "makepad-micro-proc-macro", ] @@ -7657,7 +7680,7 @@ dependencies = [ [[package]] name = "ttf-parser" version = "0.24.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "tungstenite" @@ -7726,7 +7749,7 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicase" version = "2.9.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "unicode-bidi" @@ -7737,17 +7760,17 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-bidi" version = "0.3.18" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "unicode-bidi-mirroring" version = "0.3.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "unicode-ccc" version = "0.3.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "unicode-ident" @@ -7758,7 +7781,7 @@ checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-linebreak" version = "0.1.5" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "unicode-normalization" @@ -7778,12 +7801,12 @@ checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" [[package]] name = "unicode-properties" version = "0.1.4" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "unicode-script" version = "0.5.8" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "unicode-segmentation" @@ -7794,7 +7817,7 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-segmentation" version = "1.12.0" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "unicode-xid" @@ -7882,9 +7905,13 @@ dependencies = [ name = "ux-harness" version = "0.1.0" dependencies = [ + "makepad-micro-serde", + "makepad-studio-protocol", "png", + "proptest", "serde", "serde_json", + "tungstenite", ] [[package]] @@ -8140,11 +8167,11 @@ dependencies = [ [[package]] name = "wayland-backend" version = "0.3.12" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ - "downcast-rs 1.2.1 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "downcast-rs 1.2.1 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", "libc", - "scoped-tls 1.0.1 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "scoped-tls 1.0.1 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", "smallvec 1.15.1 (registry+https://github.com/rust-lang/crates.io-index)", "wayland-sys 0.31.8", ] @@ -8166,7 +8193,7 @@ dependencies = [ [[package]] name = "wayland-client" version = "0.31.12" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", "libc", @@ -8188,7 +8215,7 @@ dependencies = [ [[package]] name = "wayland-egl" version = "0.32.9" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "wayland-backend 0.3.12", "wayland-sys 0.31.8", @@ -8197,7 +8224,7 @@ dependencies = [ [[package]] name = "wayland-protocols" version = "0.32.10" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", "wayland-backend 0.3.12", @@ -8230,10 +8257,10 @@ dependencies = [ [[package]] name = "wayland-sys" version = "0.31.8" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "log 0.4.29", - "pkg-config 0.3.32 (git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193)", + "pkg-config 0.3.32 (git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d)", ] [[package]] @@ -8307,7 +8334,7 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "weezl" version = "0.1.12" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "whoami" @@ -8360,7 +8387,7 @@ dependencies = [ [[package]] name = "windows" version = "0.62.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "windows-collections 0.3.2", "windows-core 0.62.2", @@ -8379,7 +8406,7 @@ dependencies = [ [[package]] name = "windows-collections" version = "0.3.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "windows-core 0.62.2", ] @@ -8412,7 +8439,7 @@ dependencies = [ [[package]] name = "windows-core" version = "0.62.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "windows-link 0.2.1", "windows-result 0.4.1", @@ -8433,7 +8460,7 @@ dependencies = [ [[package]] name = "windows-future" version = "0.3.2" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "windows-core 0.62.2", ] @@ -8497,7 +8524,7 @@ checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" [[package]] name = "windows-link" version = "0.2.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "windows-numerics" @@ -8541,7 +8568,7 @@ dependencies = [ [[package]] name = "windows-result" version = "0.4.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "windows-link 0.2.1", ] @@ -8558,7 +8585,7 @@ dependencies = [ [[package]] name = "windows-strings" version = "0.5.1" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" dependencies = [ "windows-link 0.2.1", ] @@ -9128,7 +9155,7 @@ dependencies = [ [[package]] name = "zerocopy" version = "0.8.39" -source = "git+https://github.com/ZhangHanDong/makepad?rev=1dd8d372cfb65b20160a3782586ed4d593cf8193#1dd8d372cfb65b20160a3782586ed4d593cf8193" +source = "git+https://github.com/ZhangHanDong/makepad?rev=45e826c2814fccd2c69d558b27775858444c907d#45e826c2814fccd2c69d558b27775858444c907d" [[package]] name = "zerocopy-derive" diff --git a/Cargo.toml b/Cargo.toml index 9911bc16f..c6eb41c63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -387,10 +387,7 @@ used_underscore_binding = "allow" ## sandbox dirs, mobile-layout detection, ArkUI native inputs), with the legacy ## `old/` crate copies removed so the repo resolves as a cargo git dependency. [patch."https://github.com/makepad/makepad"] -## Pinned to ZhangHanDong/makepad dev at the merge of PR #6 (Splash-isolate -## shader identity, headless renderer correctness, ttf-parser gvar-alloc for -## Apple variable fonts, the CoreText outline fallback for hvgl-only system -## fonts like PingFang, and the closed-Modal overdraw fix), plus the -## selectable-Html fix from PR #5. -makepad-widgets = { git = "https://github.com/ZhangHanDong/makepad", rev = "1dd8d372cfb65b20160a3782586ed4d593cf8193", features = ["serde"] } -makepad-code-editor = { git = "https://github.com/ZhangHanDong/makepad", rev = "1dd8d372cfb65b20160a3782586ed4d593cf8193" } +## Isolated E2E integration of reviewed Dock visibility PR 8. +## Build Makepad Studio from this same commit for GUI E2E tests. +makepad-widgets = { git = "https://github.com/ZhangHanDong/makepad", rev = "45e826c2814fccd2c69d558b27775858444c907d" } +makepad-code-editor = { git = "https://github.com/ZhangHanDong/makepad", rev = "45e826c2814fccd2c69d558b27775858444c907d" } diff --git a/proptest-regressions/event_preview.txt b/proptest-regressions/event_preview.txt new file mode 100644 index 000000000..ff5d33e65 --- /dev/null +++ b/proptest-regressions/event_preview.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 0c081b28fcee5c8c4b5439561a52585269c1cfb4d75510d9001f584fb8ac3aea # shrinks to body = "", detail = "" diff --git a/resources/i18n/en.json b/resources/i18n/en.json index f92965eee..a4630bc4a 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -686,6 +686,7 @@ "forward_modal.button.forward": "Forward", "forward_modal.popup.submitting": "Forwarding message...", "room_screen.popup.action_response.failed": "Failed to send action response.\n\nError: {error}", + "room_screen.popup.action_response.outcome_unknown": "The send outcome is unknown. Check the canonical approval status before trying again.\n\nError: {error}", "room_screen.send_state.resend_modal.title": "Message not sent", "room_screen.send_state.resend_modal.body": "This message could not be sent. Resend it?", "room_screen.send_state.resend_modal.body_reason": "This message could not be sent: {reason}. Resend it?", @@ -694,6 +695,17 @@ "room_screen.popup.approval_expired": "This approval request has expired. Ask the agent to create a new request.", "room_screen.approval.pending": "Pending", "room_screen.approval.expired": "Expired", + "room_screen.approval.approved": "Approved", + "room_screen.approval.denied": "Denied", + "room_screen.approval.consumed": "Consumed", + "room_screen.approval.unavailable": "Unavailable", + "room_screen.approval.submitting": "Submitting…", + "room_screen.approval.sent_awaiting_canonical": "Sent — awaiting status", + "room_screen.approval.outcome_unknown": "Send outcome unknown", + "room_screen.approval.task_result_unknown": "Approval consumption does not confirm that the task completed.", + "room_screen.approval.view_pending": "View pending approvals", + "room_screen.approval.navigation_unavailable": "The approval room is not available yet. Wait for room sync, then try again.", + "room_screen.approval.navigation_ambiguous": "More than one approval room matches this agent. Ask its owner to update the room binding.", "room_screen.modal.delete_message.title": "Delete Message", "room_screen.modal.delete_message.body": "Are you sure you want to delete this message? This cannot be undone.", "room_screen.modal.delete_message.accept": "Delete", diff --git a/resources/i18n/zh-CN.json b/resources/i18n/zh-CN.json index 84277cce0..01af1812d 100644 --- a/resources/i18n/zh-CN.json +++ b/resources/i18n/zh-CN.json @@ -686,6 +686,7 @@ "forward_modal.button.forward": "转发", "forward_modal.popup.submitting": "正在转发消息...", "room_screen.popup.action_response.failed": "发送动作响应失败。\n\n错误:{error}", + "room_screen.popup.action_response.outcome_unknown": "发送结果未知。再次尝试前,请先检查 canonical 审批状态。\n\n错误:{error}", "room_screen.send_state.resend_modal.title": "消息未发送", "room_screen.send_state.resend_modal.body": "这条消息未能发出,是否重新发送?", "room_screen.send_state.resend_modal.body_reason": "这条消息未能发出:{reason}。是否重新发送?", @@ -694,6 +695,17 @@ "room_screen.popup.approval_expired": "该审批请求已过期,请让 agent 重新发起请求。", "room_screen.approval.pending": "等待审批", "room_screen.approval.expired": "已过期", + "room_screen.approval.approved": "已批准", + "room_screen.approval.denied": "已拒绝", + "room_screen.approval.consumed": "已消费", + "room_screen.approval.unavailable": "不可用", + "room_screen.approval.submitting": "正在提交…", + "room_screen.approval.sent_awaiting_canonical": "已发送,等待状态更新", + "room_screen.approval.outcome_unknown": "发送结果未知", + "room_screen.approval.task_result_unknown": "审批已消费不代表任务已经完成。", + "room_screen.approval.view_pending": "查看待审批", + "room_screen.approval.navigation_unavailable": "审批房间暂不可用。请等待房间同步后重试。", + "room_screen.approval.navigation_ambiguous": "此 Agent 对应多个审批房间,请联系所有者更新房间绑定。", "room_screen.modal.delete_message.title": "删除消息", "room_screen.modal.delete_message.body": "确认要删除这条消息吗?此操作无法撤销。", "room_screen.modal.delete_message.accept": "删除", diff --git a/scripts/spec-guard.sh b/scripts/spec-guard.sh index 6cb4d9041..514ade6fe 100755 --- a/scripts/spec-guard.sh +++ b/scripts/spec-guard.sh @@ -12,10 +12,13 @@ # `manual_test_*` selectors, which agent-spec reports as `skip`, and the # boundary layer applies the whole change set to every spec, so a naive # `agent-spec guard` fails on every PR. Instead: -# - specs changed in this change set are the active contracts: run -# `lifecycle` WITH the change set (boundaries + tests), fail on failed>0; +# - a single changed contract receives the full change set; +# - multiple active contracts collectively cover every changed path, +# then each runs `lifecycle` with its owned paths (boundaries + tests). +# Shared files go to every declaring task; explicit denies still fail; # - all other specs are regression checks: run `verify` WITHOUT a change -# set (tests only), fail on failed>0. Skips are tolerated. +# set (tests only), fail on failed>0. Skips and legacy regression +# uncertainty remain nonblocking, explicitly reported as UNVERIFIED. # # Usage: # scripts/spec-guard.sh [--change-scope staged|worktree] [--base ] [--fast] @@ -101,39 +104,17 @@ for cap in specs/capabilities/*.spec.md; do done # ---- 4. task specs --------------------------------------------------------------- -# 4a. Changed specs are the active contracts of this change: verify them WITH the -# change set (boundaries + bound tests). Manual `skip`s are tolerated; any -# `failed > 0` (test or boundary violation) fails the gate. +# 4a. Changed specs own the change collectively, not by intersecting unrelated +# allow-lists. The helper checks total path coverage and explicit denies, +# then runs the native lifecycle boundary verifier and tests for each scope. +# Manual skips stay visible; failed/uncertain active contracts fail the gate. # 4b. Every other spec is a regression check: verify WITHOUT a change set (no # boundary layer, since foreign files would trivially violate them) and fail # only on `failed > 0`. say "4/4 task specs: changed = contract check (with change set), others = regression (tests only)" CHANGE_ARGS=() for f in "${CHANGED[@]:-}"; do [ -n "$f" ] && [ -e "$f" ] && CHANGE_ARGS+=(--change "$f"); done -summary_failed() { grep -Eo '"failed": [0-9]+' | head -1 | grep -Eo '[0-9]+'; } -is_changed_spec() { local x; for x in "${CHANGED_SPECS[@]:-}"; do [ "$x" = "$1" ] && return 0; done; return 1; } -for spec in specs/*.spec.md; do - case "$spec" in specs/project.spec.md) continue;; esac - if is_changed_spec "$spec"; then - out="$(agent-spec lifecycle "$spec" --code . --run-log-dir .agent-spec/runs "${CHANGE_ARGS[@]}" --format json 2>&1 || true)" - n="$(echo "$out" | summary_failed || echo "?")" - if [ "$n" != "0" ]; then - echo "FAIL (contract): $spec — failed=$n" - echo "$out" | grep -E '"reason": "not covered|"verdict": "fail"' -B3 | grep -E 'reason|step_text|scenario_name' | head -12 - fail=1 - else - echo "ok (contract): $spec" - fi - else - out="$(agent-spec verify "$spec" --code . --format json 2>&1 || true)" - n="$(echo "$out" | summary_failed || echo "?")" - if [ "$n" != "0" ]; then - echo "FAIL (regression): $spec — failed=$n" - echo "$out" | grep -E '"verdict": "fail"' -B8 | grep -E 'scenario_name|reason' | head -8 - fail=1 - fi - fi -done +if ! python3 scripts/spec_guard.py "${CHANGE_ARGS[@]}"; then fail=1; fi if [ "$fail" = "0" ]; then echo; echo "spec-guard: OK"; else echo; echo "spec-guard: FAILED"; fi exit "$fail" diff --git a/scripts/spec_guard.py b/scripts/spec_guard.py new file mode 100644 index 000000000..c35ee022a --- /dev/null +++ b/scripts/spec_guard.py @@ -0,0 +1,149 @@ +"""Task composition for spec-guard.sh. Native agent-spec still verifies each scope.""" +import argparse +from functools import lru_cache +import json +from pathlib import Path +import re +import subprocess +import sys + + +def path_pattern(text): + # Match the path forms recognized by agent-spec; prose is not an allow-list. + text = text.strip() + if not (any(c in text for c in '/\\*') or text.endswith(('.rs', '.ts', '.js', '.py', '.md', '.spec'))): + return None + text = text.strip('`').replace('\\', '/') + while text.startswith('./'): + text = text[2:] + return text.strip('/') + + +def matches(pattern, path): + pattern = tuple(p for p in pattern.split('/') if p) + path = tuple(p for p in path.split('/') if p) + + @lru_cache(None) + def visit(i, j): + if i == len(pattern): + return j == len(path) + if pattern[i] == '**': + return visit(i + 1, j) or (j < len(path) and visit(i, j + 1)) + return (j < len(path) + and re.fullmatch(re.escape(pattern[i]).replace(r'\*', '.*'), path[j]) is not None + and visit(i + 1, j + 1)) + + return visit(0, 0) + + +def partition_changes(changes, documents): + """All owners receive shared files; unowned paths and explicit denies fail.""" + scopes = {spec: [] for spec in documents} + rules = {} + for spec, document in documents.items(): + allow, deny = [], [] + for section in document['sections']: + if section['kind'] != 'boundaries': + continue + for item in section['items']: + pattern = path_pattern(item['text']) + if pattern and item['category'] in ('allow', 'deny', 'general'): + (allow if item['category'] == 'allow' else deny).append(pattern) + rules[spec] = (allow, deny) + errors = [] + for change in changes: + owned = False + for spec, (allow, deny) in rules.items(): + if any(matches(pattern, change) for pattern in deny): + errors.append(f'{change}: forbidden by {spec}') + if any(matches(pattern, change) for pattern in allow): + scopes[spec].append(change) + owned = True + if not owned: + errors.append(f'{change}: not covered by any active task boundary') + if errors: + raise ValueError('\n'.join(errors)) + return scopes + + +def cli_json(arguments): + result = subprocess.run(['agent-spec', *arguments], text=True, capture_output=True) + try: + document = json.loads(result.stdout) + except (ValueError, TypeError) as error: + raise ValueError(f'invalid agent-spec JSON (exit {result.returncode}): ' + f'{(result.stderr or result.stdout)[:1200]}') from error + if not isinstance(document, dict): + raise ValueError('agent-spec report must be an object') + return result.returncode, document + + +def check_report(code, document, regression=False): + report = document.get('verification', document) + summary = report['summary'] + counts = {key: summary[key] for key in ('total', 'passed', 'failed', 'skipped', 'uncertain')} + if any(type(value) is not int or value < 0 for value in counts.values()): + raise ValueError('invalid verification counts') + # Preserve the existing nonblocking uncertainty policy for unchanged tasks. + # Those results remain UNVERIFIED; active contracts cannot use this policy. + permitted_uncertain = regression and counts['uncertain'] > 0 + unverified = counts['skipped'] > 0 or counts['uncertain'] > 0 + ok = (code in (0, 1) and counts['total'] > 0 and counts['failed'] == 0 + and (counts['uncertain'] == 0 or permitted_uncertain) + and (code == 0 or counts['skipped'] > 0 or permitted_uncertain)) + detail = ', '.join(f'{key}={counts[key]}' for key in ('passed', 'failed', 'skipped', 'uncertain')) + if not ok or counts['uncertain'] > 0: + # Slice parsed diagnostics, never a live stdout pipe. Later tasks still run. + failures = [r for r in report.get('results', []) if r.get('verdict') in ('fail', 'uncertain')] + for result in failures[:12]: + print(' ' + result.get('scenario_name', '')) + for step in result.get('step_results', [])[:3]: + if step.get('verdict') in ('fail', 'uncertain'): + print(f" {step.get('step_text', '')}: {step.get('reason', '')}"[:1200]) + return ok, detail, unverified + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--change', action='append', default=[]) + args = parser.parse_args() + changes = list(dict.fromkeys(args.change)) + specs = sorted(str(p) for p in Path('specs').glob('*.spec.md') if p.name != 'project.spec.md') + active = [spec for spec in specs if spec in changes] + scopes = {spec: changes for spec in active} + if len(active) > 1: + try: + documents = {} + for spec in active: + code, document = cli_json(['parse', spec, '--format', 'json']) + if code != 0: + raise ValueError(f'could not parse {spec}: exit {code}') + documents[spec] = document + scopes = partition_changes(changes, documents) + except (ValueError, KeyError, TypeError) as error: + print(f'FAIL (composition): {error}', file=sys.stderr) + return 1 + failed = False + for spec in specs: + changed = spec in scopes + mode = 'contract' if changed else 'regression' + arguments = ['lifecycle' if changed else 'verify', spec, '--code', '.', '--format', 'json'] + if changed: + arguments += ['--run-log-dir', '.agent-spec/runs'] + for change in scopes[spec]: + arguments += ['--change', change] + try: + code, document = cli_json(arguments) + ok, detail, unverified = check_report(code, document, regression=not changed) + except (ValueError, KeyError, TypeError) as error: + ok, detail, unverified = False, str(error), False + status = ('UNVERIFIED' if unverified else 'ok') if ok else 'FAIL' + print(f'{status} ({mode}): {spec} — {detail}', flush=True) + if ok and unverified: + print(' Nonblocking under existing CI policy; skipped/uncertain scenarios are not passes.', flush=True) + failed |= not ok + return int(failed) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/test_spec_guard.py b/scripts/test_spec_guard.py new file mode 100644 index 000000000..778d595c1 --- /dev/null +++ b/scripts/test_spec_guard.py @@ -0,0 +1,172 @@ +"""Subprocess regressions for the real spec-guard entry point; no live services.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parent.parent +FAKE_CLI = '''#!/usr/bin/env python3 +import json, pathlib, sys +a = sys.argv[1:] +root = pathlib.Path.cwd() +with (root / 'calls.jsonl').open('a') as log: log.write(json.dumps(a) + '\\n') +if a[0] in ['lint', 'check-structure', 'trace']: sys.exit(0) +doc = json.loads(pathlib.Path(a[1]).read_text()) +if a[0] == 'parse': + print(json.dumps({'sections': [{'kind': 'boundaries', 'items': + [{'text': p, 'category': 'allow'} for p in doc['allow']] + + [{'text': p, 'category': 'deny'} for p in doc.get('deny', [])]}]})); sys.exit(0) +if doc.get('malformed'): print('not JSON'); sys.exit(0) +changes = [a[i+1] for i,x in enumerate(a) if x == '--change'] +bad = [p for p in changes if p not in doc['allow'] or p in doc.get('deny', [])] +n = doc.get('failed', 0) + bool(bad) +results = [{'scenario_name': 'failure ' + str(i), 'verdict': 'fail', + 'step_results': [{'step_text': p, 'verdict': 'fail', 'reason': 'not covered by any allowed boundary'} for p in bad]} + for i in range(n)] +summary = {'total': 1+n+doc.get('skipped', 0)+doc.get('uncertain', 0), 'passed': 1, + 'failed': n, 'skipped': doc.get('skipped', 0), 'uncertain': doc.get('uncertain', 0)} +report = {'summary': summary, 'results': results} +print(json.dumps({'verification': report} if a[0] == 'lifecycle' else report, indent=2)) +sys.exit(1 if n or summary['skipped'] or summary['uncertain'] else 0) +''' + + +class GuardTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='robrix-spec-guard-') + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + for directory in ['scripts', 'specs', 'src', 'bin']: + (self.root / directory).mkdir() + for name in ['spec-guard.sh', 'spec_guard.py']: + source = ROOT / 'scripts' / name + if source.exists(): shutil.copy2(source, self.root / 'scripts' / name) + cli = self.root / 'bin/agent-spec' + cli.write_text(FAKE_CLI) + cli.chmod(0o755) + self.env = dict(os.environ, PATH=str(self.root / 'bin') + os.pathsep + os.environ['PATH']) + subprocess.run(['git', 'init', '-q', str(self.root)], check=True) + + def contract(self, name, paths, changed=True, **options): + spec = 'specs/' + name + '.spec.md' + doc = {'allow': [spec, *paths], **options} + (self.root / spec).write_text(json.dumps(doc)) + for path in paths: + target = self.root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text('// fixture\n') + if changed: + subprocess.run(['git', 'add', spec, *paths], cwd=self.root, check=True) + return spec + + def run_guard(self): + # macOS's system Bash lacks mapfile; use the same Bash 5 entry point as local CI. + bash = '/opt/homebrew/bin/bash' if Path('/opt/homebrew/bin/bash').exists() else 'bash' + result = subprocess.run([bash, 'scripts/spec-guard.sh'], cwd=self.root, + env=self.env, text=True, capture_output=True, timeout=30) + calls = [json.loads(line) for line in (self.root / 'calls.jsonl').read_text().splitlines()] + return result, calls + + def owned(self, calls, spec): + calls = [c for c in calls if c[:2] == ['lifecycle', spec]] + self.assertEqual(len(calls), 1) + return {calls[0][i+1] for i, a in enumerate(calls[0]) if a == '--change'} + + def test_disjoint_and_unchanged_contracts(self): + a = self.contract('task-a', ['src/a.rs']) + b = self.contract('task-b', ['src/b.rs']) + c = self.contract('task-old', [], changed=False) + result, calls = self.run_guard() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.owned(calls, a), {a, 'src/a.rs'}) + self.assertEqual(self.owned(calls, b), {b, 'src/b.rs'}) + self.assertTrue(any(ca[:2] == ['verify', c] for ca in calls)) + + def test_unowned_file_rejected(self): + self.contract('task-a', ['src/a.rs']) + self.contract('task-b', ['src/b.rs']) + (self.root / 'src/unowned.rs').write_text('// outside all contracts\n') + subprocess.run(['git', 'add', 'src/unowned.rs'], cwd=self.root, check=True) + result, _ = self.run_guard() + self.assertNotEqual(result.returncode, 0) + self.assertIn('src/unowned.rs', result.stdout + result.stderr) + + def test_forbidden_path_cannot_be_owned_by_another_contract(self): + self.contract('task-a', ['src/a.rs'], deny=['src/b.rs']) + self.contract('task-b', ['src/b.rs']) + result, _ = self.run_guard() + self.assertNotEqual(result.returncode, 0) + self.assertIn('src/b.rs', result.stdout + result.stderr) + + def test_failed_contract_does_not_stop_later_checks(self): + self.contract('task-a', ['src/shared.rs'], failed=300) + b = self.contract('task-b', ['src/shared.rs']) + result, calls = self.run_guard() + self.assertNotEqual(result.returncode, 0) + self.assertIn('spec-guard: FAILED', result.stdout) + self.assertEqual(self.owned(calls, b), {b, 'src/shared.rs'}) + self.assertNotIn('Broken pipe', result.stderr) + + def test_malformed_report_fails_and_continues(self): + self.contract('task-a', ['src/shared.rs'], malformed=True) + b = self.contract('task-b', ['src/shared.rs']) + result, calls = self.run_guard() + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.owned(calls, b), {b, 'src/shared.rs'}) + + def test_single_contract_receives_full_change_set(self): + a = self.contract('task-a', ['src/a.rs']) + (self.root / 'src/outside.rs').write_text('// outside\n') + subprocess.run(['git', 'add', 'src/outside.rs'], cwd=self.root, check=True) + result, calls = self.run_guard() + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.owned(calls, a), {a, 'src/a.rs', 'src/outside.rs'}) + + def test_manual_skip_is_reported_without_becoming_failure(self): + self.contract('task-a', ['src/a.rs'], skipped=1) + result, _ = self.run_guard() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('skipped=1', result.stdout) + + def test_uncertain_regression_remains_visibly_unverified(self): + self.contract('task-a', ['src/a.rs']) + old = self.contract('task-old', [], changed=False, uncertain=5) + result, _ = self.run_guard() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn('UNVERIFIED (regression): ' + old, result.stdout) + self.assertIn('uncertain=5', result.stdout) + self.assertNotIn('ok (regression): ' + old, result.stdout) + + def test_uncertain_active_contract_fails(self): + active = self.contract('task-a', ['src/a.rs'], uncertain=1) + result, _ = self.run_guard() + self.assertNotEqual(result.returncode, 0) + self.assertIn('FAIL (contract): ' + active, result.stdout) + self.assertIn('uncertain=1', result.stdout) + + def generated(self, count, unowned): + contracts = [self.contract('task-' + str(i), ['src/shared.rs', 'src/' + str(i) + '.rs']) for i in range(count)] + if unowned: + (self.root / 'src/unowned.rs').write_text('// unowned\n') + subprocess.run(['git', 'add', 'src/unowned.rs'], cwd=self.root, check=True) + result, calls = self.run_guard() + self.assertEqual(result.returncode == 0, not unowned, result.stdout + result.stderr) + if not unowned: + for i, spec in enumerate(contracts): + self.assertEqual(self.owned(calls, spec), {spec, 'src/shared.rs', 'src/' + str(i) + '.rs'}) + + +if __name__ == '__main__': + if len(sys.argv) == 4 and sys.argv[1] == '--generated': + case = GuardTests() + try: + case.setUp() + case.generated(int(sys.argv[2]), sys.argv[3] == '1') + finally: + case.doCleanups() + else: + unittest.main() diff --git a/specs/task-agent-chat-owner-approval.spec.md b/specs/task-agent-chat-owner-approval.spec.md index a6d6e2339..fb3bc9343 100644 --- a/specs/task-agent-chat-owner-approval.spec.md +++ b/specs/task-agent-chat-owner-approval.spec.md @@ -41,6 +41,8 @@ expiration, and one-shot state before releasing a coding-agent operation. prevents an approval click from becoming undecryptable when the bridge device was registered after an older room session was established. - Keep the existing `org.octos.approval_request` protocol backward compatible. +- Custom room-message previews show only the HTML-escaped fallback body, never + the Rust debug representation or extra protocol fields. ## Boundaries @@ -50,6 +52,8 @@ expiration, and one-shot state before releasing a coding-agent operation. - src/shared/mod.rs - src/home/room_screen.rs - src/sliding_sync.rs +- src/event_preview.rs +- proptest-regressions/event_preview.txt - resources/i18n/en.json - resources/i18n/zh-CN.json @@ -69,6 +73,16 @@ expiration, and one-shot state before releasing a coding-agent operation. ## Completion Criteria +### Rule: custom-preview — preview depends only on escaped fallback body + +Scenario: Custom message summaries do not expose protocol internals + Test: custom_message_preview_shows_escaped_body_without_protocol_details + Test: prop_custom_preview_depends_only_on_escaped_body + Given a custom room message with a fallback body and arbitrary extra fields + When text_preview_of_message creates a room or reply summary + Then the preview is exactly the HTML-escaped fallback body + And protocol fields and Rust debug output do not appear + Scenario: Public project-room status is read-only Test: test_agentchat_public_status_has_no_actions Given a timeline event has msgtype `com.agentchat.approval.status.v1` diff --git a/specs/task-approval-action-wrap-layout.spec.md b/specs/task-approval-action-wrap-layout.spec.md new file mode 100644 index 000000000..45b34a9a9 --- /dev/null +++ b/specs/task-approval-action-wrap-layout.spec.md @@ -0,0 +1,53 @@ +spec: task +name: "Approval action rows retain inner responsive wrapping" +inherits: project +tags: [bugfix, makepad, layout, approval] +--- + +## Intent + +Remove the repeated Makepad error caused by a Fill-width Splash directly inside a wrapping horizontal action row. Preserve responsive action-button wrapping and existing message/pane ownership in full, condensed and private approval cards. + +## Decisions + +- Change the two `action_button_row` containers in `src/home/room_screen/message.rs` and the `approval_action_button_row` container in `src/shared/approval_card.rs` to `flow: Down`; retain their existing Fill-width, Fit-height Splash children. +- The generated Splash body remains the owner of `Flow.Right{wrap: true}` and Fit-sized buttons or disabled labels. Preserve spacing, label text, action payloads, and pane/message ownership. +- This is a layout-only correction with two manual runtime checks. Source diff review verifies exactly three flow changes, with the inner Splash generator and event/ownership code unchanged. Build and runtime evidence are recorded separately; source inspection does not count as a runtime pass. + +## Boundaries + +### Allowed Changes +- src/home/room_screen/message.rs +- src/shared/approval_card.rs +- specs/task-approval-action-wrap-layout.spec.md + +### Forbidden +- Do not modify dependencies, generated Splash bodies, approval state, action dispatch, or widget visibility policy. +- Do not suppress Makepad diagnostics or remove action buttons to silence the error. +- Do not run Rust formatters. + +## Out of Scope + +- Approval protocol or room-discovery changes. +- Native approval decisions or provider calls during layout verification. +- General timeline, typography or theme redesign. + +## Completion Criteria + +Scenario: Visible action rows do not repeat the unsupported Fill-width error + Test: manual_test_approval_action_rows_no_wrap_fill_error + Given the built app renders a full message action row, a condensed message action row and a private approval action row + And a fresh log cursor excludes warnings from earlier binaries + When the visible action rows redraw during a "30" second observation window including narrow and wide room panes + Then the log contains "0" new occurrences of "flow: Right { wrap: true } does not support width: Fill" + And each tested row has positive visible geometry + +Scenario: Narrow action rows preserve wrapping and source ownership + Test: manual_test_approval_action_rows_wrap_and_keep_ownership + Given full and condensed message action rows and a private approval card with multiple action buttons are visible + And a disabled action row and multiline approval text are available + When the room pane changes between widths of "360" and "800" pixels + Then buttons that exceed the available line width continue on the next line within their own message + And disabled labels and approval text remain readable within the pane + And a regular action's captured pane and source-message identity match the row that contains it + And no native approval decision or provider call is issued by this layout check diff --git a/specs/task-approval-state-model.spec.md b/specs/task-approval-state-model.spec.md new file mode 100644 index 000000000..19f80d856 --- /dev/null +++ b/specs/task-approval-state-model.spec.md @@ -0,0 +1,134 @@ +spec: task +name: "Model canonical owner approval state" +inherits: project +tags: [approval, matrix, security, frontend] +--- + +## Intent + +Provide one pure, account-scoped model for strict private approval parsing, canonical state folding, private room discovery, and one-time local verdict claims. UI and Matrix I/O remain separate consumers of this model. + +## Decisions + +- `ApprovalSession::ingest_message(account_mxid, room_id, event_id, actual_sender, original_content, now_ms)` in `src/approval_state.rs` is the sole request/status admission and fold point. +- `ApprovalMarkerIndex::observe_v2(...)`, `ingest_marker(account_mxid, room_id, event_id, event_type, actual_sender, state_key, original_content, now_ms)`, and `resolve(...)` are the sole marker protocol-floor, admission, and navigation decision points. +- `ApprovalSession::is_actionable(...)`, `try_claim(...)`, and `record_send_result(...)` are the sole local verdict eligibility and claim transition points. +- The session is ephemeral and shared by all panes for an account. Marker cache format v2 migrates persisted v1 single-agent entries into `(agent, project)` tuples with validation cleared; unknown formats fail closed. Persisted entries require fresh authenticated state revalidation before resolution. +- An observed empty-key marker v2 establishes a durable per-room protocol floor. V2 is never downgraded to v1, including after malformed, duplicate, absent, or lower-generation observations. +- A v2 manifest admits at most 64 unique `(agent, project_room_id)` tuples atomically. Different agents may share a project and approval room; one tuple resolving to multiple rooms remains ambiguous. +- This module performs no UI or Matrix I/O and accepts original raw event content plus actual sender/room/account context as plain values. + +## Boundaries + +### Allowed Changes +- ./src/lib.rs +- ./src/approval_state.rs +- ./src/approval_discovery.rs +- ./src/app.rs +- ./src/sliding_sync.rs +- ./src/home/room_screen/** +- ./src/home/rooms_list.rs +- ./src/shared/approval_card.rs +- ./resources/i18n/** +- ./specs/task-approval-state-model.spec.md +- ./specs/task-approval-state-navigation.spec.md + +### Forbidden + +- Cargo/dependency changes, provider/backend changes, protocol transport changes, or live Matrix actions. + +## Acceptance Criteria + + + +### Rule: strict-original-admission — Private approval events bind original content to Matrix context + +Scenario: Strict request and status parsing + Test: approval_state_accepts_exact_original_bindings_and_rejects_sender_owner_digest_and_action_mismatches + Given original private approval content and actual room sender and account context + When the model ingests the event + Then only a complete valid binding is retained and only its exact owner can act + +Scenario: Generated invalid bindings fail closed + Test: prop_invalid_binding_never_becomes_actionable + Given generated malformed binding fields + When the model ingests them + Then no malformed request becomes actionable + +### Rule: monotonic-canonical-fold — Canonical state never regresses or forks + +Scenario: Status can precede request and terminal state wins + Test: approval_status_before_request_folds_to_the_highest_terminal_revision + Given reordered request and status history + When valid projections are ingested + Then every pane query observes the same greatest valid terminal state + +Scenario: Revision order is invariant + Test: prop_status_order_preserves_highest_terminal + Given generated valid terminal revisions + When they arrive in different orders + Then the folded terminal result is the same + +### Rule: authenticated-marker-resolution — Public navigation resolves only fresh private marker state + +Scenario: Marker mappings fail closed on ambiguity staleness and non-membership + Test: approval_navigation_requires_unique_joined_loaded_room + Given private marker state for an account agent and project room + When the model resolves a public navigation request + Then only one fresh authenticated already-joined room is returned + +Scenario: Generated competing markers never choose the first + Test: prop_approval_marker_resolution_is_unique + Given generated distinct approval rooms for one association + When both markers are active + Then resolution is ambiguous + +Scenario: Shared-room v2 preserves every agent and project tuple + Test: approval_marker_v2_shared_room_resolves_all_four_bindings + Given three agents and two projects produce four active tuples for one owner room + When the authenticated v2 manifest is ingested + Then both project-two agent routes resolve to that same joined room + And an unknown agent membership observation does not remove a tuple + +Scenario: V2 protocol evidence cannot fall back to v1 + Test: approval_marker_v2_floor_blocks_v1_rollback_and_malformed_fallback + Given a room has exposed an empty-key v2 marker + When a v1 marker or malformed lower-generation v2 is later observed + Then the cached room is unavailable and no v1 route resolves + +Scenario: Persisted v1 marker cache migrates without authority + Test: persisted_v1_marker_cache_migrates_to_agent_project_tuple_unvalidated + Given a serialized cache-format-v1 single-agent marker + When it is loaded by the cache-format-v2 model + Then its association includes the legacy agent + And it remains stale until authenticated current-state revalidation + +### Rule: shared-one-time-claim — All panes share one conservative verdict claim + +Scenario: Ambiguous transport remains locked + Test: approval_verdict_outcome_keeps_unknown_claim_locked + Given an actionable request claimed by one pane + When transport is successful ambiguous or definitively fails before send + Then only the definitive pre-send failure can restore eligibility + +Scenario: Generated repeats admit one live claim + Test: prop_repeated_claims_admit_at_most_one + Given a generated number of pane attempts for one request + When every pane attempts to claim + Then at most one live claim is granted + +Scenario: Generated late pre-send failures cannot unlock unknown outcomes + Test: prop_approval_claim_never_releases_unknown_send + Given an approval claim whose send outcome is unknown + When generated late pre-send failures are reported + Then the claim remains locked + +## Out of Scope + +- Rendering, localized copy, Matrix timeline extraction, sending verdicts, persistent AppState wiring, background marker revalidation, and HAFleet backend behavior. +- This implementation task owns only `src/approval_state.rs`, its `src/lib.rs` inclusion, and this contract. The wider allowed boundary is reserved for the planned UI integration in the same approval feature changeset. diff --git a/specs/task-approval-state-navigation.spec.md b/specs/task-approval-state-navigation.spec.md new file mode 100644 index 000000000..b0d4ce2b8 --- /dev/null +++ b/specs/task-approval-state-navigation.spec.md @@ -0,0 +1,165 @@ +spec: task +name: "Owner approval UI — canonical state, one-time input and private navigation" +inherits: project +tags: [approval, matrix, ux] +--- + +## Intent + +Show HAFleet's canonical approval state on the original request card and let an owner reach an already joined private approval room from a redacted project notice. Preserve the three-layer distinction between an approval verdict, runtime consumption and a task result. + +## Constraints + +- HAFleet remains the sole verdict authority; Matrix events are its presentation and transport. +- No public approval notice contains a private destination, request ID, digest, tool input or verdict control. +- Use existing MatrixRequest submission, original event content and authenticated full MXIDs. +- Never retry an uncertain verdict with another action, content or transaction identity. +- Preserve unrelated sessions and local Docker Palpo isolation during GUI verification. +- Do not run formatters, change dependencies, lower gates or claim manual scenarios passed from unit tests. + +## Decisions + +- ApprovalSession and ApprovalMarkerIndex in src/approval_state.rs provide the shared pure state model; their separate task contract governs parsing, fold, claims and discovery evidence. +- AppState holds one ephemeral shared session for all cached and split panes and one tolerant persisted marker cache per account. A cached marker needs current-run authenticated revalidation before use. +- approval_action_notification_matches(source, slot, pane, item, context) in src/home/room_screen/octos_actions.rs matches both decimal widget identities before accepting a Splash notification. Event ID and slot alone are insufficient. +- Canonical state is folded before invalidating request renderings. Each pane observes a shared change epoch; one pane must not drain another pane's invalidation signal. A stale local deadline never replaces an observed terminal verdict. +- The normal Matrix worker carries a claim key and fixed transaction ID with a verdict. Errors before send are distinguishable from unknown results after send. Only confirmed pre-send failure can release a live pending claim. +- ApprovalDiscoverySchedule in src/approval_discovery.rs selects bounded due joined-room batches using account generations and request nonces. Its pure schedule and response acceptance functions reject stale account/membership responses. Authenticated full state preserves actual sender metadata; a content-only cache miss is insufficient. One room fetch selects empty-key marker v2 exclusively whenever present and otherwise permits v1 only below the persisted room protocol floor. +- Background refresh uses an App-owned timer, at most four rooms per tick and at most two concurrent full-state requests with ten-second deadlines. Confirmed negative results expire after sixty seconds; positive results refresh after one hundred twenty seconds. Missing or failed discovery is visible as unavailable navigation. +- The public CTA emits only agent plus its containing project room. The App resolves a unique current-owner, joined-and-loaded private mapping and emits `RoomsListAction::OpenPendingApprovals` with `SelectedRoom::JoinedRoom` for desktop and mobile. The addressed active main-room RoomScreen consumes an exact-room one-shot request after timeline items are ready and shows the latest item; cached threads and ordinary room selection preserve reading position. It never calls the join-capable generic room navigation helper. +- Mobile transitions coalesce one pending intent and revalidate its destination after transition completion. +- The room Info overlay acquires focus after its first visible draw, cancels that pending focus when another widget takes it, and restores prior focus on close only while it still owns focus. Escape remains routed through focused-area hit testing, and pointer handling respects existing capture by overlay modals and children. +- New strings use locale catalogs and existing approval card, badge, row and design tokens. Consumed means runtime received the verdict; the task result remains unknown here. + +## Boundaries + +### Allowed Changes +- src/app.rs +- src/approval_discovery.rs +- src/approval_state.rs +- src/lib.rs +- src/sliding_sync.rs +- src/home/room_screen/** +- src/home/home_screen.rs +- src/home/main_desktop_ui.rs +- src/home/main_mobile_ui.rs +- src/home/rooms_list.rs +- src/shared/approval_card.rs +- src/i18n.rs +- resources/i18n/** +- specs/task-approval-state-navigation.spec.md +- specs/task-approval-state-model.spec.md + +### Forbidden +- Do not add an HAFleet API, bearer token or approval authorization fallback to Robrix. +- Do not add private approval routing data or native verdict actions to public content. +- Do not change backend task state or infer task completion from consumed approvals. +- Do not alter user profiles, global agent settings or unrelated live instances. + +## Acceptance Criteria + + + +### Rule: scoped-notification — only the originating pane and item consume input + +Scenario: Reject another cached pane or recycled item + Test: approval_notification_requires_pane_and_item_ownership + Given two panes displaying the same source event and slot + When the source pane receives its notification and another pane receives the same payload + Then only the matching pane and current item accept it + +Scenario: Generated widget identities preserve exact ownership + Test: prop_approval_notification_matches_all_owner_fields + Given generated pane, item, event and slot identities + When a notification is checked against a context + Then it is accepted exactly when all four identities match + +### Rule: discovery-response — late asynchronous results cannot restore stale mappings + +Scenario: Reject responses after account change or room removal + Test: approval_discovery_rejects_late_account_and_membership_results + Given an in-flight private marker discovery + When the account changes or the room ceases to be joined and loaded + Then its result cannot update the marker cache + +Scenario: Generated discovery batches remain bounded and fair + Test: prop_approval_discovery_bounded_fair_batches + Given generated joined-room sets and repeated refresh ticks + When the scheduler selects due rooms + Then no batch exceeds four rooms and each eligible room is eventually selected + +Scenario: Full-state selection gives v2 strict precedence + Test: approval_discovery_selects_v2_without_v1_fallback + Given one authenticated room-state response contains valid v1 and malformed or duplicate v2 marker events + When the discovery worker selects a marker + Then it reports v2 observation and never returns the v1 event + +Scenario: Malformed sibling state cannot erase an observed v2 floor + Test: approval_discovery_raw_error_retains_observed_v2_floor + Given an authenticated full-state response contains an empty-key v2 marker and a sibling with a non-string event type + When raw event extraction rejects the response + Then discovery remains unavailable and still reports the observed v2 protocol floor + +### Rule: verdict-outcome — native controls preserve uncertain delivery + +Scenario: Separate pre-send failure from unknown send outcome + Test: approval_verdict_outcome_keeps_unknown_claim_locked + Given a native verdict with one fixed transaction ID + When preparation fails or a send response is lost + Then only preparation failure may release its pending claim + And the unknown result cannot enable a second decision + +Scenario: Generated shared claims cannot emit conflicting verdicts + Test: prop_approval_claim_never_releases_unknown_send + Given generated repeated send outcomes and attempts from multiple panes + When they update the shared approval session + Then an unknown send retains the original one-time choice + +### Rule: native-navigation — public notices only open a resolved joined room + +Scenario: Reject absent, ambiguous or unjoined destinations + Test: approval_navigation_requires_unique_joined_loaded_room + Given a redacted public notice and private marker candidates + When the owner activates the navigation control + Then only one currently joined and loaded owner room can be selected + +Scenario: Generated destination sets never choose the first ambiguous room + Test: prop_approval_marker_resolution_is_unique + Given generated active private destination candidates + When the marker index resolves the project notice + Then selection succeeds exactly for one eligible candidate + +Scenario: Pending-approval navigation is deferred, room-bound and one-shot + Test: latest_approval_navigation_is_room_bound_deferred_and_one_shot + Given an existing approval room is scrolled to older content and another RoomScreen is cached + When the public CTA selects the resolved room before its current timeline items are ready + Then the matching RoomScreen consumes the request once after items arrive and shows its latest item + And mismatched rooms, cached threads and ordinary room selection do not consume or inherit the request + +Scenario: A deferred intent cannot move a reused thread + Test: deferred_approval_navigation_rejects_same_room_thread_reuse + Given the selected main room has no loaded items and retains a pending approval navigation + When its RoomScreen is reused for a thread of the same room before loading completes + Then the pending request is cleared without scrolling that thread + And returning to the main room cannot restore the old request + +Scenario: Verify real local three-layer approval and navigation + Test: manual_test_canonical_approval_studio_computer_use + Given local Docker Palpo, aligned Makepad Studio and the isolated E2E agents + When Studio and Computer Use select an actual agent through the member picker and activate a private native verdict + Then all request copies show HAFleet's increasing canonical state + And consumed is distinct from the independently verified task result + And the public CTA opens the correct existing private room on desktop and mobile + And an already-open room is moved from older content to the latest approval while ordinary room selection retains its reading position + And reconnect and cold history discovery preserve one-time verdicts + And opening room Info followed by Escape closes it without an extra pane click + And clicking or pressing Escape in an overlaid room search or invitation modal leaves the underlying room Info pane open + +## Out of Scope + +- AccessKit, new orchestration layers and remote homeservers +- Treating missing legacy request delivery as permission to create fresh actionable cards +- Inferring other-platform GUI coverage from macOS tests diff --git a/specs/task-e2e-dock-runtime-alignment.spec.md b/specs/task-e2e-dock-runtime-alignment.spec.md new file mode 100644 index 000000000..f30d8f561 --- /dev/null +++ b/specs/task-e2e-dock-runtime-alignment.spec.md @@ -0,0 +1,70 @@ +spec: task +name: "Pin compatible Makepad fixes for owner approval GUI verification" +inherits: project +tags: [makepad, studio, e2e] +--- + +## Intent + +Validate the canonical approval frontend with the reviewed Makepad Dock visibility, widget path query and PortalList scrolling repairs. Keep the separate pull requests and their dependency relationships explicit. + +## Constraints + +- Keep the original source trees, user profiles and unrelated running applications unchanged. +- Use only the isolated local Docker Palpo service for subsequent business E2E. +- Preserve strict Studio selectors and explicit action postconditions. +- Do not run Rust formatters, relax gates or reinterpret manual skips as passes. + +## Decisions + +- This task explicitly authorizes changing the two Makepad fork patches, the harness's two direct Makepad protocol dependencies, and their associated lock entries from dev commit 139d751442608653df2afcef0d0f4f060a10343e to reviewed Makepad PR 10 commit 45e826c2814fccd2c69d558b27775858444c907d, stacked on PRs 8 and 9. All four declarations must agree so the harness and app cannot load separate protocol versions. +- Pin all app and harness Cargo declarations to the same Makepad commit. The previously built Studio hub at 5ab3161938841f905581377a0afff9b8670b44f4 is compatible with this pin: the intervening changes affect only widgets/src/dock.rs, widgets/src/widget_tree.rs and widgets/src/portal_list.rs, with no protocol changes. Record the separate hub, app and harness binaries and require correlated live commands before input; do not infer compatibility for future revisions. +- `unique_match(widgets, selector)` in tools/ux-harness/src/locator.rs remains the pure selection rule. It accepts exactly one visible positive-area match and rejects absent or ambiguous targets. +- No Cargo patch with a personal filesystem path may be introduced. + +## Boundaries + +### Allowed Changes +- ./Cargo.toml +- ./Cargo.lock +- tools/ux-harness/Cargo.toml +- specs/task-e2e-dock-runtime-alignment.spec.md + +### Forbidden +- Do not change frontend behavior, harness selection rules, HAFleet code, live credentials or provider settings in this alignment task. +- Do not update unrelated dependencies or substitute remote homeservers. + +## Acceptance Criteria + + + +### Rule: strict-selection — dependency alignment preserves unambiguous input + +Scenario: Reject missing hidden and duplicate targets + Test: + Package: ux-harness + Filter: locator_requires_one_visible_match + Given missing hidden unique and duplicate widget candidates + When the runtime harness resolves its target + Then only the unique visible candidate may receive input + +Scenario: Generated candidate counts never select an arbitrary duplicate + Test: + Package: ux-harness + Filter: prop_locator_never_picks_an_ambiguous_widget + Given generated numbers of matching widgets + When the harness resolves the selector + Then it succeeds exactly when one candidate is eligible + +Scenario: Verify the aligned real Dock and approval runtime + Test: manual_test_aligned_dock_approval_runtime + Given the app and harness use the recorded Makepad pin and the Studio hub has verified unchanged wire types + When the isolated local E2E exercises cached and split panes with native approval controls + Then hidden duplicate widgets never receive input + And canonical approval state remains shared by every visible request copy + And saved evidence distinguishes Studio results from Computer Use capture limitations + +## Out of Scope + +- Publishing this integration checkout as a replacement for the separate reviewed PRs +- Inferring full three-layer completion from build or selector tests diff --git a/specs/task-legacy-approval-readonly-state.spec.md b/specs/task-legacy-approval-readonly-state.spec.md new file mode 100644 index 000000000..bcf4d6a25 --- /dev/null +++ b/specs/task-legacy-approval-readonly-state.spec.md @@ -0,0 +1,91 @@ +spec: task +name: "Legacy approval cards — corroborated read-only state" +inherits: project +tags: [approval, frontend, legacy, provenance] +--- + +## Intent + +Show canonical legacy approval status on its positively matched original card without making that card actionable. Preserve the old request payload and native approval admission rules while requiring exact Matrix sender, event relation, room, owner and request tuple evidence for display. + +## Constraints + +- Every legacy record remains non-actionable and status-only; do not synthesize a native request, owner, publisher or revision for old request content. +- Native binding, revision, claim and account isolation rules remain unchanged. +- Do not add a cache, backend/API/provider calls, public private-locator fields or new approval authority. +- Do not run formatters or change dependencies. Reuse existing localized approval state and runtime-consumed/task-unknown copy. + +## Decisions + +- Retain an optional original event ID from the legacy status reply relation in ApprovalSession's existing record/view. Conflicting original relations fail closed, including at the same revision. +- `legacy_approval_status_for_original(view, account, room, event_id, actual_original_sender, original_content)` in src/home/room_screen/octos_actions.rs is the pure display predicate. It uses the existing old-request parser and compares the exact relation, tuple, full sender, current owner and room; missing proof returns None. +- Actual status sender is already checked against its publisher by the strict model parser. The display predicate requires that publisher to equal the original event's actual sender. Edited effective content cannot establish proof. +- Rendering uses its existing original_content, source_event_id and original_sender inputs. No legacy request cache or native-request backfill is added; status-first and request-first histories produce the same read-only display. +- Canonical consumed means the runtime used the verdict, with task outcome unknown. Legacy pending remains disabled even if unexpired; terminal state does not enable any control. + +## Boundaries + +### Allowed Changes +- src/approval_state.rs +- src/home/room_screen/octos_actions.rs +- specs/task-legacy-approval-readonly-state.spec.md + +### Forbidden +- Do not edit backend, transport, App lifecycle, marker discovery, dependency or live runtime files. +- Do not relax native request requirements or permit a different publisher to relabel an old request. + +## Acceptance Criteria + + + +### Rule: corroborated-read-only — only the exact original card receives canonical legacy state + +Scenario: Reject conflicting original relations at the same revision + Test: legacy_approval_relation_conflict_is_not_a_duplicate + Given two legacy statuses with equal state revision and tuple but different original event relations + When the model folds them + Then it marks the record conflicted instead of treating the second as a duplicate + +Scenario: Match the real old request shape in either arrival order + Test: legacy_approval_old_builder_displays_readonly_state_in_both_orders + Given an old request without owner publisher and revision and a complete related legacy status + When request-first and status-first histories are folded and rendered through the display predicate + Then the canonical consumed state is selected in both orders with its preserved decision + And the record remains status-only and every verdict claim is denied + +Scenario: Reject wrong sender relation tuple owner room or edited content + Test: legacy_approval_display_rejects_mismatched_original_evidence + Given original-card evidence differs from a valid legacy status in one identity field + When the display predicate evaluates it + Then it returns None and the card remains unavailable + +Scenario: Generated identity mismatches never select a legacy state + Test: prop_legacy_approval_display_requires_exact_evidence + Given generated unequal identity fields and original relations + When they are substituted into otherwise valid original-card evidence + Then no mismatched event receives a canonical legacy state + +Scenario: All canonical legacy states remain disabled + Test: legacy_approval_states_never_create_actionable_requests + Given pending approved denied expired and consumed legacy statuses + When the matching old request is displayed + Then canonical state is retained and no approval becomes actionable + +Scenario: Shared epochs notify both panes without consuming invalidation + Test: legacy_approval_epoch_is_shared_by_two_pane_observers + Given two pane observers hold the same room epoch before a legacy status arrives + When the model accepts the status + Then both observers detect the new epoch and querying one does not clear the other's notification + +Scenario: Preserve native terminal claims and epochs + Test: terminal_status_retires_claim_and_advances_room_epoch + Given an actionable native request with an existing claim + When a valid native terminal status arrives + Then the claim is retired and the room epoch advances + +## Out of Scope + +- Backend historical event retrieval/attestation, credential generation recovery, publication or migration. +- Live GUI acceptance, provider execution and changing canonical backend approval decisions. diff --git a/specs/task-room-pane-action-ownership.spec.md b/specs/task-room-pane-action-ownership.spec.md new file mode 100644 index 000000000..7a5aa48cd --- /dev/null +++ b/specs/task-room-pane-action-ownership.spec.md @@ -0,0 +1,80 @@ +spec: task +name: "Scope room pane navigation actions to their owning controls" +inherits: project +tags: [bugfix, threads, search, actions] +--- + +## Intent + +Selecting a thread in one room screen must not start an event search in another +cached room or thread tab. Carry pane ownership through dynamic thread/search +rows and consume pane, toolbar, and header actions only in their owning screen. + +## Constraints + +- Preserve actions for other room screens and split views; do not discard all actions in inactive tabs. +- Keep Matrix requests, message actions, timeline lifecycle, and global app navigation unchanged. +- Do not change layout, visible strings, locale keys, dependencies, or formatter policy. +- Do not contact Matrix services in automated tests. + +## Decisions + +- The pure function `room_control_action(action: &Action, owners: &[WidgetUid]) -> Option<&WidgetAction>` in `src/home/room_screen/action_scope.rs` is the ownership decision used by room controls. +- Dynamic thread and search entries store their pane's WidgetUid at binding time and emit selection under that owner rather than their recycled row UID. +- Thread pane actions accept only the owning pane UID. Toolbar and RoomTopBar actions accept only their own control UID. Search actions accept the local pane, search button, and room-screen relay UIDs. +- Example and property tests verify WidgetAction ownership before building. Studio/CUA checks the rendered entry-to-pane wiring. The original blanket cast is retained only long enough to record a failing regression, then replaced by the ownership decision. +- The i18n review finds no new text or changed alignment; translation resources are unchanged. + +## Boundaries + +### Allowed Changes +- src/home/room_screen/action_scope.rs +- src/home/room_screen/mod.rs +- src/home/room_screen/threads_pane.rs +- src/home/room_screen/search.rs +- src/home/search_messages.rs +- specs/task-room-pane-action-ownership.spec.md + +### Forbidden +- Do not edit src/app.rs, src/event_preview.rs, tools/ux-harness/**, or src/sliding_sync.rs. +- Do not run cargo fmt or rustfmt. +- Do not commit or create a PR before user testing. + +## Acceptance Criteria + + + +### Rule: pane-owner — Room controls accept only an explicitly owned action + +Scenario: Other thread panes cannot consume a root selection + Test: room_control_action_keeps_thread_selection_in_its_own_pane + Given main, Claude-thread, Codex-thread and other-room panes have distinct widget identities + When the main pane emits a Codex root selection + Then only the main pane accepts that action and the other panes retain no jump target + +Scenario: Search pane and header relays stay local + Test: room_control_action_accepts_local_search_controls_only + Given search pane, search button and header relay identities belong to one room screen + When each control emits a search action + Then that screen accepts the original action and a different room screen rejects it + +Scenario: Missing ownership fails closed + Test: room_control_action_rejects_missing_or_zero_ownership + Given a non-widget action, an empty owner list, or an unbound zero widget identity + When ownership is checked + Then no action is accepted + +Scenario: Generated identities preserve action isolation + Test: prop_room_control_action_matches_explicit_owner_set + Given arbitrary source identities and owner sets + When ownership is checked + Then acceptance equals nonzero source membership in the owner set + +## Out of Scope + +- Repairing unrelated global-search routing in src/app.rs. +- Changing background pagination completion or Matrix history availability. diff --git a/specs/task-spec-guard-composition.spec.md b/specs/task-spec-guard-composition.spec.md new file mode 100644 index 000000000..d5506030a --- /dev/null +++ b/specs/task-spec-guard-composition.spec.md @@ -0,0 +1,98 @@ +spec: task +name: "Check composed task changes without intersecting unrelated boundaries" +inherits: project +tags: [ci, contracts, regression] +--- + +## Intent + +Validate a PR containing multiple independently scoped tasks without requiring every task to own every changed file. Preserve file coverage, explicit prohibitions, complete task tests and readable failure reporting. + +## Decisions + +- For two or more changed task contracts, `partition_changes(changes, documents)` assigns each path to every active contract whose allowed path pattern matches. Every changed file must have an owner; an explicit forbidden path in any active contract fails the composition. No existing task boundary is expanded. +- Read boundary categories through `agent-spec parse --format json`. Scoped paths still go through the real agent-spec lifecycle boundary verifier and all bound tests. Single-contract verification continues to receive the complete change set; unchanged contracts remain regression checks. +- Parse lifecycle and verification JSON structurally. Invalid reports fail closed. Print bounded diagnostics without pipelines that terminate the producer early, and continue checking other contracts after a failure. +- Preserve the existing manual-scenario skip policy, nonblocking uncertainty for unchanged task regressions, and capability/ADR gates. Report skipped/uncertain scenarios as UNVERIFIED with counts and reasons; they never become passing evidence. Active contract uncertainty remains blocking. Do not remove or replace external test bindings to obtain a passing report. +- [platform-specific] The Bash CI entry point and its Python 3 integration tests run on the existing macOS/Linux tooling hosts. Cargo workspace tests bind the Python regression suite through the ux-harness package. + +## Boundaries + +### Allowed Changes +- scripts/spec-guard.sh +- scripts/spec_guard.py +- scripts/test_spec_guard.py +- tools/ux-harness/tests/spec_guard.rs +- specs/task-spec-guard-composition.spec.md + +### Forbidden +- Do not broaden existing task boundaries or remove acceptance scenarios. +- Do not change product code, Cargo dependencies, workflow skip conditions or UX thresholds. +- Do not accept files outside all active contracts or hide failed test results. + +## Acceptance Criteria + + +### Rule: coverage — every changed path has an accountable task + +Scenario: Independent tasks verify only their declared paths + Test: + Package: ux-harness + Filter: spec_guard_composition_regressions + Given two changed contracts with disjoint allowed files and one unchanged contract + When the gate checks the combined PR + Then each changed contract receives only its own files + And both task lifecycles and the unchanged regression execute + +Scenario: Unowned and forbidden files fail the combined gate + Test: + Package: ux-harness + Filter: spec_guard_composition_regressions + Given a file outside all active boundaries or explicitly forbidden by an active contract + When another contract permits the remaining files + Then the gate fails and identifies the uncovered or forbidden path + +Scenario: Shared files are checked by every declaring task + Test: + Package: ux-harness + Filter: prop_spec_guard_composition_keeps_all_owners + Given generated sets of active contracts sharing one changed file + When the gate partitions the changes + Then every declaring contract receives the shared file + And an additional unowned file always fails + +### Rule: truthful-failure — diagnostics cannot erase failures or stop later checks + +Scenario: Malformed reports and failed tests remain failures + Test: + Package: ux-harness + Filter: spec_guard_composition_regressions + Given invalid JSON or a task with many failed scenarios + When the gate prints its diagnostics + Then the final exit is nonzero + And later contracts are still checked + And the final failure summary is printed without a broken pipe + +Scenario: Single-contract boundaries and manual skips retain their meaning + Test: + Package: ux-harness + Filter: spec_guard_composition_regressions + Given one changed contract with an out-of-scope file or a manual skipped scenario + When the gate runs the original full-change lifecycle check + Then the out-of-scope change fails + And a permitted manual skip is reported separately from passes + +Scenario: Unchanged external uncertainty remains unverified + Test: + Package: ux-harness + Filter: spec_guard_composition_regressions + Given an unchanged historical contract whose external tests cannot run in this workspace + When its verifier returns uncertain scenarios without actual test failures + Then the existing regression policy remains nonblocking + And the report says UNVERIFIED with the uncertain count instead of claiming a pass + And the same uncertainty in an active changed contract fails the gate + +## Out of Scope + +- Changing agent-spec's boundary language or Rust test runner. +- Certifying manual UI scenarios or complete business E2E. diff --git a/specs/task-studio-e2e-harness.spec.md b/specs/task-studio-e2e-harness.spec.md new file mode 100644 index 000000000..19d377b4d --- /dev/null +++ b/specs/task-studio-e2e-harness.spec.md @@ -0,0 +1,249 @@ +spec: task +name: "Studio E2E harness — trustworthy evidence and real widget interactions" +inherits: project +tags: [ux-harness, studio, e2e] +--- + +## Intent + +Make the existing ux-harness report actual protocol, capture and process failures and drive Robrix through Makepad Studio. Preserve the static UX gate while recording explicit GUI outcomes for the operator-approved local three-layer E2E. + +## Constraints + +- Do not run cargo fmt or weaken existing UX thresholds or tests. +- Matrix traffic is restricted to local Docker Palpo at 127.0.0.1:8008. +- Preserve existing user sessions; input and shutdown target only the selected test build/process. +- GUI dispatch uses actual member-picker selection and input events; direct API message injection is not GUI evidence. +- Protocol failures, missing state and stale images never become successful evidence. + +## Decisions + +- Keep serde/serde_json/png for the existing harness. This task approves direct harness dependencies on the already-locked tungstenite 0.26.2, matching Makepad protocol/micro-serde libraries and the already-approved proptest test dependency; no unrelated dependency upgrades. +- `ROBRIX_DATA_DIR` selects an absolute test profile directory; unset preserves the existing directory and invalid relative/empty overrides fail explicitly. +- `decode` preserves a valid empty widget snapshot but emits an explicit protocol error for malformed widget data or a missing request ID. +- Capture success requires a fresh response/frame and a successfully written artifact. Process cleanup runs on success and early error. +- `unique_match(widgets, selector)` requires exactly one visible match, optionally within an observed container. `input_center(widgets, target, windowed)` converts desktop snapshot coordinates only in windowed mode. Actions wait for an explicit state predicate with a bounded deadline. +- Studio `click`, `scroll`, `type` and `key` steps require an `expect` selector and a 1..60000 ms deadline (default 10000). The CLI validates the entire step plan before input and records success only after a correlated snapshot satisfies the predicate. A preexisting predicate proves observed state, not event acknowledgement or causality; choose a changed value or newly visible control when testing transitions. +- Studio output uses a newly created directory. An existing output path fails before connection or input and is never cleared or overwritten, keeping each run's artifacts separate. +- `validate_capture` checks request identity and PNG content; `Diagnostics::push` bounds retained bytes; `resolve_app_data_dir` isolates the selected profile. Studio binary input uses the official `StudioToAppVec` envelope. +- Business results remain separate from UX scores and require independent Matrix/HAFleet/artifact evidence. + +## Boundaries + +### Allowed Changes +- tools/ux-harness/** +- src/lib.rs +- src/data_directory.rs +- ./Cargo.toml +- ./Cargo.lock +- src/app.rs +- specs/task-studio-e2e-harness.spec.md +- docs/superpowers/plans/2026-09-09-studio-e2e.md + +### Forbidden +- Do not edit global agent configuration, user session databases or unrelated projects. +- Do not raise tools/ux-harness/gate.json thresholds. +- Do not write task done or fabricated inner-loop results from the GUI driver. + +## Acceptance Criteria + +### Rule: protocol-evidence — malformed protocol data is never valid empty state + +Scenario: Reject malformed snapshot fields + Test: + Package: ux-harness + Filter: malformed_widget_snapshot_is_error + Given a WidgetSnapshot response whose widgets field is an invalid object + When decode parses the response + Then it reports a protocol error instead of an empty widget vector + +Scenario: Reject a missing correlation ID + Test: + Package: ux-harness + Filter: missing_snapshot_request_id_is_error + Given a WidgetSnapshot response without request_id + When decode parses the response + Then it reports a protocol error instead of substituting zero + +Scenario: Accept a valid empty snapshot + Test: + Package: ux-harness + Filter: valid_empty_widget_snapshot_is_preserved + Given a WidgetSnapshot response with request_id 17 and widgets [] + When decode parses the response + Then it preserves request_id 17 and the empty vector + +Scenario: Preserve Unicode through protocol cleanup + Test: + Package: ux-harness + Filter: prop_protocol_cleanup_preserves_text + Given generated Unicode labels including Chinese + When protocol cleanup removes trailing delimiters + Then valid JSON text remains byte-identical + +Scenario: Correlate real websocket replies + Test: + Package: ux-harness + Filter: studio_snapshot_requires_matching_build_and_query + Given a local websocket server returning stale-query and foreign-build snapshots + When the harness requests its selected build snapshot + Then only its matching reply is accepted + +Scenario: Fail on a disconnected Studio socket + Test: + Package: ux-harness + Filter: studio_disconnect_is_error_not_empty_state + Given a local websocket server closing before its reply + When the harness requests a snapshot + Then the transport returns an error + +### Rule: capture-evidence — stale or unwritten captures fail + +Scenario: Reject an old frame after capture deadline + Test: + Package: ux-harness + Filter: capture_deadline_never_reuses_old_file_or_response + Given only the frame that existed before a screenshot request + When the capture deadline expires + Then capture returns an error and no success artifact + +Scenario: Reject a capture artifact write failure + Test: + Package: ux-harness + Filter: capture_write_failure_is_error + Given a valid fresh frame and an unwritable destination + When capture writes its evidence + Then it returns the write error + +Scenario: Generate request identities + Test: + Package: ux-harness + Filter: prop_capture_requires_matching_request + Given generated expected and received request identities + When a valid PNG is checked + Then acceptance is equivalent to matching request identities + +### Rule: lifecycle-evidence — error paths retain diagnostics and cleanup + +Scenario: Retain bounded stderr diagnostics + Test: + Package: ux-harness + Filter: stderr_diagnostics_are_bounded_and_retained + Given an owned child emitting stderr beyond the diagnostic capacity + When the harness records diagnostics + Then the newest error is retained and storage remains bounded + +Scenario: Reclaim owned process after early error + Test: + Package: ux-harness + Filter: owned_child_is_reaped_on_early_return + Given an owned test child and a failing startup operation + When the driver scope ends before explicit shutdown + Then the child is terminated and reaped + +Scenario: Generate bounded diagnostic streams + Test: + Package: ux-harness + Filter: prop_diagnostics_storage_never_exceeds_capacity + Given generated capacities and Unicode log streams + When each line is appended + Then retained bytes never exceed capacity + +### Rule: gui-evidence — business acceptance uses the actual UI + +Scenario: Preserve default directory and isolate explicit profiles + Test: data_directory_override_is_explicit + Given the default profile and an absolute test profile + When the override is absent or explicitly supplied + Then absence preserves the default and an absolute override selects only the test profile + And relative and empty overrides return an error + +Scenario: Reject ambiguous locator matches + Test: + Package: ux-harness + Filter: prop_locator_never_picks_an_ambiguous_widget + Given a generated number of visible matching widgets + When unique_match selects a target + Then selection succeeds if and only if the count is one + +Scenario: Correct independent-window coordinates + Test: + Package: ux-harness + Filter: prop_windowed_coordinates_are_translation_invariant + Given a target and window translated by generated offsets + When input_center converts the observed geometry + Then the window-local target remains unchanged + +Scenario: Send the event envelope decoded by the app + Test: + Package: ux-harness + Filter: studio_input_uses_the_apps_vector_envelope + Given a real local websocket connection + When a Chinese text event is sent + Then the application protocol decodes exactly one event with unchanged text + +Scenario: Scroll the observed container and record the resulting state + Test: + Package: ux-harness + Filter: studio_scroll_targets_observed_container_and_records_new_state + Given a uniquely located scrollable container in an independent window + When a scroll step runs + Then the official event targets its window-local center + And the step evidence records a new snapshot after the scroll + +Scenario: Reject mutations without an expected state + Test: + Package: ux-harness + Filter: studio_mutations_require_explicit_postconditions + Given click, scroll, type and key steps without an expect selector + When the step plan is decoded + Then each mutation is rejected before input + +Scenario: Reject a reused output directory without altering old evidence + Test: + Package: ux-harness + Filter: studio_rejects_existing_output_without_touching_its_evidence + Given an output directory containing a previous run's failure record + When a Studio run selects that directory + Then it fails before connecting + And the previous evidence remains unchanged + +Scenario: Validate later steps before any earlier mutation is sent + Test: + Package: ux-harness + Filter: studio_validates_the_complete_plan_before_any_input + Given a valid first mutation followed by an invalid deadline, selector or key + When the CLI validates the complete step plan + Then it identifies the invalid step before connecting or creating output + +Scenario: Fail when an ignored event never reaches its postcondition + Test: + Package: ux-harness + Filter: studio_ignored_input_cannot_pass_its_postcondition + Given a local Studio server that accepts an input event but returns unchanged snapshots + When a mutation waits for the expected new value + Then it fails within its bounded deadline and preserves failure evidence + And the input is sent exactly once + +Scenario: Wait for a delayed postcondition without replaying input + Test: + Package: ux-harness + Filter: studio_waits_for_delayed_postcondition_without_replaying_input + Given a local Studio server that returns old state before the expected new value + When a mutation runs + Then it records only the snapshot that satisfies the expected value as step evidence + And the input is sent exactly once + +Scenario: Studio drives real mentions and verifies the returned result + Test: manual_test_studio_local_three_layer_e2e + Given matching Studio and Robrix builds and local E2E agents + When Studio and Computer Use select the actual member candidate and send a unique task + Then the matching Matrix mention routes to the intended middle agent + And the middle independently monitors and verifies lower work + And task done, dispatch completed and the unique final same-thread reply have independent evidence + +## Out of Scope + +- AccessKit and system accessibility integration +- Claiming Windows/Linux GUI coverage from macOS execution +- Replacing Herdr or implementing a new agent orchestrator inside ux-harness diff --git a/specs/task-studio-splash-compatibility.spec.md b/specs/task-studio-splash-compatibility.spec.md new file mode 100644 index 000000000..780842172 --- /dev/null +++ b/specs/task-studio-splash-compatibility.spec.md @@ -0,0 +1,79 @@ +spec: task +name: "Studio Splash compatibility for Robrix widgets" +inherits: project +tags: [makepad, studio, splash, compatibility] +--- + +## Intent + +Restore Robrix startup compatibility with Makepad `5ab3161` after the aligned Studio runtime reported a reserved `self` shader parse error and eleven removed `PortalList.max_pull_down` properties. Preserve the affected widgets' visual behavior while keeping the repair limited to the proven compatibility sites. + +## Constraints + +- Use Makepad 2.0 property-form shader syntax `pixel: fn()` with an implicit `self` receiver. +- Replace each removed zero pull-down limit with `bounce_at_start: false`, preserving disabled start-edge bounce. +- Do not run formatters, add dependencies, or change unrelated shaders, fonts, updater behavior, approval behavior, or end-edge bounce. +- Runtime acceptance requires the exact aligned Robrix and Makepad heads and correlated Studio evidence; source inspection alone cannot establish runtime acceptance. + +## Decisions + +- Convert only `RoomFilterSearchResultItem.draw_bg.pixel` from the legacy named-method form to the Makepad 2.0 property callback form. +- Apply the keyboard selection animator through the typed result item's contained View; a custom result widget cannot be borrowed as a standalone View. +- Bind the selected background color as a draw instance and read it through `self` in the shader so deferred shader compilation resolves it. +- Convert only the eleven observed `max_pull_down: 0.0` assignments to `bounce_at_start: false`. +- Verify through the existing Studio harness and real aligned application instead of adding source-string or mirrored implementation tests. + +## Boundaries + +### Allowed Changes +- src/shared/room_filter_search_results.rs +- src/settings/devices_settings.rs +- src/home/search_messages.rs +- src/home/space_lobby.rs +- src/home/spaces_bar.rs +- src/home/directory_screen.rs +- src/home/room_settings_modal.rs +- src/home/global_message_search.rs +- src/home/room_screen/dsl.rs +- src/home/room_screen/threads_pane.rs +- src/home/room_screen/room_info_pane.rs +- specs/task-studio-splash-compatibility.spec.md + +### Forbidden +- Cargo dependency or lockfile changes +- Makepad source changes +- Approval state, Matrix, font, updater, or Dock behavior changes + +## Acceptance Criteria + +Scenario: Aligned Studio startup has no repaired DSL diagnostics + Test: manual_test_aligned_studio_startup_has_no_compatibility_diagnostics + Given Robrix commit `9114bf638b6f534f66b4a3702e60456b0f3be193` plus this patch is built against Makepad `5ab3161` + When the owned application is launched through the existing Studio harness + Then its build log contains no reserved `self`, frozen vector, or undefined `max_pull_down` diagnostic + +Scenario: Aligned application renders after compatibility repair + Test: manual_test_aligned_studio_capture_after_compatibility_repair + Given the owned aligned application has connected to Studio + When the harness requests a widget snapshot and PNG for the exact build and query identifiers + Then it receives a nonempty correlated widget tree containing the login or authenticated Robrix view + And it writes a correlated nonempty PNG + +Scenario: Search result selection remains visible + Test: manual_test_room_filter_selected_highlight_after_shader_conversion + Given the room filter search results contain at least two entries + When keyboard selection moves between the entries + Then only the selected entry renders the existing selected background color + +Scenario: Start-edge bounce remains disabled at repaired lists + Test: manual_test_repaired_portal_lists_disable_start_edge_bounce + Given each repaired PortalList contains enough rows to scroll + When the operator pulls beyond the start edge + Then no pull-down bounce animation is shown + And normal list scrolling remains usable + +## Out of Scope + +- Repairing unrelated runtime diagnostics or Makepad resources +- Changing end-edge scrolling behavior +- Approval workflow validation beyond proving the application renders diff --git a/specs/task-studio-transport-deadlines.spec.md b/specs/task-studio-transport-deadlines.spec.md new file mode 100644 index 000000000..8e44e5769 --- /dev/null +++ b/specs/task-studio-transport-deadlines.spec.md @@ -0,0 +1,142 @@ +spec: task +name: "Studio transport — resumable upgrade and phase deadlines" +inherits: project +tags: [ux-harness, studio, transport] +--- + +## Intent + +Make the Studio harness retain a partially completed WebSocket upgrade across temporary socket waits without extending its total deadline. Report the failing transport phase so an HTTP upgrade timeout is distinguishable from a missing Hello, command response or capture. + +## Constraints + +- Preserve the existing 5-second TCP connection, 5-second upgrade, 5-second Hello and 25-second capture budgets. Snapshot and locator operations retain their existing caller deadline. +- Retry only temporary socket waits inside the current connection and operation; do not reconnect, replay input or submit another capture request after uncertainty. +- Preserve query/build correlation, strict unique locators, fresh capture validation and failure evidence files. +- Do not run formatters, upgrade dependencies or change application, Matrix, Studio server or operating-system settings. + +## Decisions + +- Keep the pinned tungstenite transport. Resume its interrupted MidHandshake with its buffered state and the original absolute upgrade deadline. +- The pure function `transport_wait(deadline: Instant, now: Instant) -> Option` in tools/ux-harness/src/studio.rs returns the positive remaining duration capped at a 50 ms socket wait, or None at/after the deadline. A narrow TCP stream adapter applies this decision before each underlying read/write, including tungstenite's internal fragment loops; operation loops retain the same deadline. +- Error messages identify the phase as Studio upgrade, Studio Hello, Studio command or Studio capture, and distinguish deadline expiration from protocol/disconnection errors. Existing failure.txt records the returned error unchanged. +- Synthetic loopback servers test the real StudioDriver APIs without starting an app or sending traffic to the owned live Studio. Fragment progress beyond five seconds must not extend the upgrade budget; progress within budget is accepted on the original connection. +- These changes do not establish the cause of the intermittent live Studio handshake failures or clear the separate Computer Use window-discovery blocker. No product UI or i18n strings change. + +## Boundaries + +### Allowed Changes +- tools/ux-harness/src/studio.rs +- tools/ux-harness/README.md +- specs/task-studio-transport-deadlines.spec.md + +### Forbidden +- Do not modify Cargo.toml, Cargo.lock, the application sources or Makepad sources. +- Do not reconnect or replay a control/capture command after a timeout. +- Do not weaken existing test assertions, locator uniqueness or UX gate thresholds. + +## Acceptance Criteria + + + +### Rule: fixed-phase-deadline — progress cannot reset the budget + +Scenario: Reject an upgrade that keeps progressing beyond its total deadline + Test: + Package: ux-harness + Filter: studio_transport_upgrade_progress_cannot_extend_deadline + Given a real server fragments its upgrade response over six seconds + When StudioDriver connects with the existing five-second upgrade budget + Then it reports Studio upgrade deadline expired before accepting Hello + And no replacement connection is opened + +Scenario: Accept fragmented upgrade progress within the deadline + Test: + Package: ux-harness + Filter: studio_transport_fragmented_upgrade_succeeds_on_original_connection + Given upgrade fragments arrive across multiple temporary socket waits + When the complete response and Hello arrive within the budget + Then the original connection succeeds without restarting the upgrade + +Scenario: Reject an expired wait without a zero socket timeout + Test: + Package: ux-harness + Filter: studio_transport_wait_boundary + Given the current time equals or exceeds the phase deadline + When transport_wait calculates the next socket wait + Then it returns None instead of an indefinite or extended timeout + +Scenario: Generated waits preserve the remaining deadline + Test: + Package: ux-harness + Filter: prop_studio_transport_wait_never_extends_deadline + Given generated phase budgets and elapsed times + When transport_wait calculates the next socket wait + Then it returns None exactly when elapsed time reaches the budget + And every positive wait is at most fifty milliseconds and the remaining budget + +Scenario: Distinguish a missing Hello from an upgrade failure + Test: + Package: ux-harness + Filter: studio_transport_hello_timeout_names_phase + Given the HTTP upgrade succeeds but no Hello arrives + When the five-second Hello deadline expires + Then the error identifies Studio Hello deadline expired + +Scenario: Distinguish a silent command response + Test: + Package: ux-harness + Filter: studio_transport_command_timeout_names_phase_without_replay + Given a server accepts one snapshot request and sends no response + When the caller deadline expires + Then the error identifies Studio command deadline expired + And exactly one request was sent + +Scenario: Distinguish an absent capture without creating success evidence + Test: + Package: ux-harness + Filter: studio_transport_capture_timeout_names_phase_without_replay + Given a server accepts one screenshot request and sends no response + When the twenty-five-second capture deadline expires + Then the error identifies Studio capture deadline expired + And exactly one capture request was sent and no capture file was created + +Scenario: Reject continuously progressing response fragments after the deadline + Test: + Package: ux-harness + Filter: studio_transport_command_fragments_cannot_extend_deadline + Given a correlated snapshot frame arrives one byte every twenty milliseconds + When the 150 ms command deadline expires before the complete frame + Then the command fails within 300 ms instead of accepting the late snapshot + +Scenario: Preserve a partially written command through temporary backpressure + Test: + Package: ux-harness + Filter: studio_transport_slow_command_write_keeps_one_frame + Given a server stops reading for 250 ms after the first bytes of an input arrive + When the harness writes the remainder inside its existing command budget + Then the receiver obtains exactly one complete input frame + And no command is recreated after a partial write + +Scenario: Delayed state observation does not replay the input + Test: + Package: ux-harness + Filter: studio_waits_for_delayed_postcondition_without_replaying_input + Given one input produces the expected state after several snapshots + When the harness waits for its correlated postcondition + Then it observes the state after sending that input exactly once + +Scenario: Failed attachment preserves the CLI evidence + Test: + Package: ux-harness + Filter: studio_connection_failure_preserves_cli_evidence + Given the server disconnects before Hello + When the CLI run fails to attach + Then failure.txt contains the actual returned transport error + +## Out of Scope + +- New timeout flags, automatic reconnection, command replay or changes to Studio protocol schemas. +- Diagnosing the live Studio process stall, GPU behavior, Computer Use access or operating-system permissions. +- Provider, Matrix, native approval and live GUI execution; the root agent owns that validation separately. diff --git a/specs/task-studio-widget-path-selection.spec.md b/specs/task-studio-widget-path-selection.spec.md new file mode 100644 index 000000000..a1ec48ed9 --- /dev/null +++ b/specs/task-studio-widget-path-selection.spec.md @@ -0,0 +1,58 @@ +spec: task +name: "Studio native widget path selection" +inherits: project +tags: [ux-harness, studio, locator] +--- + +## Intent + +Select repeated widget IDs only when the app's retained widget graph proves the requested ancestry and effective interaction visibility. Preserve ambiguity and transport uncertainty instead of guessing from geometry or response order. + +## Constraints + +- Path segments are exact named widget IDs. Unnamed intermediary nodes may be skipped; unexpected named nodes may not. +- Require a correlated native path response and matching snapshot state before input. +- Do not fall back to ID, geometry order, dump indexes, or the first match. +- Preserve the existing absolute deadlines and no-replay behavior. + +## Boundaries + +### Allowed Changes +- tools/ux-harness/src/locator.rs +- tools/ux-harness/src/studio.rs +- tools/ux-harness/README.md +- specs/task-studio-widget-path-selection.spec.md + +### Forbidden +- Do not modify dependencies, application sources, or Makepad sources in this Robrix unit. +- Do not select an ambiguous native path or an unmatched snapshot rectangle. + +## Acceptance Criteria + +Scenario: Native ownership and window translation + Test: + Package: ux-harness + Filter: path_match_uses_native_ownership_and_window_translation + Given a native path result uses window-local coordinates + When the snapshot carries desktop coordinates + Then the harness matches only after translating the owning window + +Scenario: Ambiguity and legacy responses fail closed + Test: + Package: ux-harness + Filter: path_match_preserves_native_ambiguity_and_rejects_legacy_or_invalid_rows + Given zero, multiple, or malformed native path rows + When the harness resolves the selector + Then it reports missing, ambiguous, or invalid state without a fallback + +Scenario: Studio query correlation + Test: + Package: ux-harness + Filter: studio_path_query_requires_exact_correlation_before_snapshot_join + Given unrelated query and build responses arrive before the requested path result + When the Studio driver waits for the native path query + Then only the exact query ID, build ID, and query string are accepted + +## Out of Scope + +- Binary protocol changes, live GUI execution, geometry joins, and index-based selection. diff --git a/src/app.rs b/src/app.rs index 40db799ea..5e004c17b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,7 +6,7 @@ use std::{fs::{File, OpenOptions}, io::Write, sync::Mutex}; use std::{ cell::RefCell, - collections::{hash_map::DefaultHasher, BTreeMap, HashMap}, + collections::{hash_map::DefaultHasher, BTreeMap, BTreeSet, HashMap}, hash::{Hash, Hasher}, sync::atomic::{AtomicBool, Ordering}, time::Duration, @@ -40,6 +40,7 @@ use crate::home::sticker_modal::StickerModalWidgetRefExt; use crate::sliding_sync::GlobalMessageSearchAction; use crate::settings::agent_add_modal::{AddAgentModalAction, AddAgentModalWidgetRefExt}; use crate::settings::agent_settings::AgentSettingsAction; +use crate::approval_discovery::ApprovalDiscoverySchedule; script_mod! { use mod.prelude.widgets.* @@ -549,6 +550,12 @@ pub struct App { /// dock also emits when auto-refocusing another tab without touching the /// mobile stack. #[rust] pending_room_nav_pop: bool, + #[rust(Timer::empty())] approval_discovery_timer: Timer, + #[rust] approval_discovery: ApprovalDiscoverySchedule, + #[rust] approval_account: Option, + #[rust] approval_joined_rooms: BTreeSet, + #[rust] approval_discovery_suspended: bool, + #[rust] pending_approval_navigation: Option<(String, OwnedRoomId)>, #[rust(Timer::empty())] room_filter_debounce_timer: Timer, #[rust] pending_room_filter_keywords: String, /// The last server-side directory search: `(query, results)`. @@ -816,7 +823,9 @@ impl MatchEvent for App { // surfaces are light, so agent splash-card content rendered near-white // text on white — visually blank. Select the light theme for every // isolate before any splash card evaluates. - cx.set_splash_isolate_theme(live_id!(light)); + makepad_widgets::widget_async::set_splash_theme( + makepad_widgets::widget_async::SplashTheme::Light, + ); // only init logging/tracing once let _ = tracing_subscriber::fmt() @@ -852,6 +861,7 @@ impl MatchEvent for App { log!("App::Startup: starting matrix sdk loop"); let _tokio_rt_handle = crate::sliding_sync::start_matrix_tokio().unwrap(); + self.approval_discovery_timer = cx.start_interval(10.0); #[cfg(feature = "tsp")] { log!("App::Startup: initializing TSP (Trust Spanning Protocol) module."); @@ -867,6 +877,9 @@ impl MatchEvent for App { } fn handle_timer(&mut self, cx: &mut Cx, event: &TimerEvent) { + if self.approval_discovery_timer.is_timer(event).is_some() { + self.refresh_approval_discovery(cx); + } if self.room_filter_debounce_timer.is_timer(event).is_some() { self.room_filter_debounce_timer = Timer::empty(); let keywords = std::mem::take(&mut self.pending_room_filter_keywords); @@ -880,6 +893,7 @@ impl MatchEvent for App { fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { self.sync_app_language(cx); + self.handle_approval_results(cx, actions); // Results for join/leave requests whose modal was dismissed before they // came back. Handled here rather than in the modal because a closed @@ -1162,6 +1176,8 @@ impl MatchEvent for App { continue; } Some(LogoutAction::ClearAppState { on_clear_appstate }) => { + self.clear_approval_runtime(); + self.approval_discovery_suspended = true; // Clear user profile cache, invited_rooms timeline states clear_all_app_state(cx); // Reset all app state to its default. @@ -1199,6 +1215,7 @@ impl MatchEvent for App { } if let Some(LoginAction::LoginSuccess) = action.downcast_ref() { + self.approval_discovery_suspended = false; log!("Received LoginAction::LoginSuccess, hiding login view."); self.app_state.logged_in = true; self.app_state.adding_account = false; @@ -1247,6 +1264,8 @@ impl MatchEvent for App { // Handle account switch actions match action.downcast_ref() { Some(AccountSwitchAction::Starting(user_id)) => { + self.clear_approval_runtime(); + self.approval_discovery_suspended = true; log!("Account switch starting to: {}", user_id); // Show a loading toast so the heavy client teardown + rebuild + resync // doesn't look frozen. The popup list is append-only (no dismiss-by-key), @@ -1270,6 +1289,7 @@ impl MatchEvent for App { continue; } Some(AccountSwitchAction::Switched(user_id)) => { + self.approval_discovery_suspended = false; log!("Account switch completed to: {}", user_id); // Release the UI-thread switch guard so the next switch can proceed. end_account_switch_guard(); @@ -1286,6 +1306,7 @@ impl MatchEvent for App { continue; } Some(AccountSwitchAction::Failed(error)) => { + self.approval_discovery_suspended = false; log!("Account switch failed: {}", error); // Release the UI-thread switch guard so the user can retry. end_account_switch_guard(); @@ -1636,6 +1657,28 @@ impl MatchEvent for App { self.push_selected_room_view(cx, selected_room); continue; } + RoomsListAction::OpenPendingApprovals(selected_room) => { + if !effective_is_desktop(cx) + && self.ui.stack_navigation(cx, ids!(view_stack)).is_transitioning() + { + continue; + } + self.push_selected_room_view(cx, selected_room.clone()); + if !effective_is_desktop(cx) { + let room_id = selected_room.room_id().clone(); + let view_stack = self.ui.stack_navigation(cx, ids!(view_stack)); + if let Some(room_screen_id) = view_stack + .destination_view() + .and_then(Self::room_screen_id_for_view) + { + self.ui + .room_screen(cx, &[room_screen_id]) + .show_latest_approval(cx, &room_id); + } + } + self.pending_approval_navigation = None; + continue; + } // An invite was accepted; upgrade the selected room from invite to joined. // In Desktop mode, MainDesktopUI also handles this (harmless duplicate). RoomsListAction::InviteAccepted { room_name_id } => { @@ -1695,6 +1738,14 @@ impl MatchEvent for App { } } + if self.pending_approval_navigation.is_some() && matches!( + action.as_widget_action().cast(), + StackNavigationTransitionAction::ShowDone + | StackNavigationTransitionAction::HideEnd(_) + ) { + cx.action(AppStateAction::RetryPendingApprovalNavigation); + } + // Handle actions that instruct us to update the top-level app state. if let Some(LeaveRoomResultAction::Left { room_id }) = action.downcast_ref() { enqueue_rooms_list_update(RoomsListUpdate::HideRoom { room_id: room_id.clone() }); @@ -1710,6 +1761,23 @@ impl MatchEvent for App { } match action.downcast_ref() { + Some(AppStateAction::OpenPendingApprovals { agent, project_room_id }) => { + self.open_pending_approvals(cx, agent, project_room_id); + continue; + } + Some(AppStateAction::RetryPendingApprovalNavigation) => { + if let Some((agent, project_room)) = self.pending_approval_navigation.take() { + self.open_pending_approvals(cx, &agent, &project_room); + } + continue; + } + Some(AppStateAction::ApprovalRoomStateChanged { account_mxid, room_id }) => { + if current_user_id().as_ref() == Some(account_mxid) { + self.approval_discovery.refresh_room(account_mxid.as_str(), room_id.as_str()); + self.ui.redraw(cx); + } + continue; + } Some(AppStateAction::RoomFocused(selected_room)) => { self.app_state.selected_room = Some(selected_room.clone()); // Deliberately do NOT clear `pending_room_nav_pop` here: @@ -1779,6 +1847,7 @@ impl MatchEvent for App { continue; } Some(AppStateAction::RestoreAppStateFromPersistentState(app_state)) => { + self.clear_approval_runtime(); // Ignore the `logged_in` state that was stored persistently. let logged_in_actual = self.app_state.logged_in; self.app_state = *app_state.clone(); @@ -2706,6 +2775,176 @@ impl AppMain for App { } impl App { + fn approval_now_ms() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis().min(u64::MAX as u128) as u64) + .unwrap_or(0) + } + + fn clear_approval_runtime(&mut self) { + if let Some(account) = self.approval_account.take() { + self.app_state.approval_markers.clear_account(account.as_str()); + if let Ok(mut session) = self.app_state.approval_session.lock() { + session.clear_account(account.as_str()); + } + } + self.approval_joined_rooms.clear(); + // Keep the scheduler's generation/nonce counters across account changes. + self.approval_discovery.sync_membership("", &self.approval_joined_rooms); + self.pending_approval_navigation = None; + } + + fn sync_approval_membership(&mut self, cx: &mut Cx) -> Option { + let account = current_user_id().filter(|account| { + self.app_state.logged_in && !self.approval_discovery_suspended + && get_client().is_some_and(|client| client.user_id() == Some(account.as_ref())) + }); + if self.approval_account != account { + self.clear_approval_runtime(); + self.approval_account = account.clone(); + } + let account = account?; + let client = get_client()?; + let joined = cx.get_global::().joined_room_ids().into_iter() + .filter(|room| RoomId::parse(room).ok().is_some_and(|id| { + client.get_room(&id).is_some_and(|room| room.state() == RoomState::Joined) + })) + .collect::>(); + for removed in self.approval_joined_rooms.difference(&joined) { + self.app_state.approval_markers.remove_room(account.as_str(), removed); + if let Ok(mut session) = self.app_state.approval_session.lock() { + session.remove_room(account.as_str(), removed); + } + } + self.approval_joined_rooms = joined; + if !self.approval_discovery.sync_membership(account.as_str(), &self.approval_joined_rooms) { + return None; + } + Some(account) + } + + fn refresh_approval_discovery(&mut self, cx: &mut Cx) { + let Some(account_mxid) = self.sync_approval_membership(cx) else { return }; + let requests = self.approval_discovery.select_due(Self::approval_now_ms()); + let Some(first) = requests.first() else { return }; + let generation = first.generation; + let rooms = requests.into_iter().filter_map(|request| { + RoomId::parse(request.room_id).ok().map(|id| (id, request.nonce)) + }).collect(); + submit_async_request(MatrixRequest::DiscoverApprovalRoomMarkers { + account_mxid, generation, rooms, enqueued_at: std::time::Instant::now(), + }); + } + + /// Apply shared state before RoomScreen handles the same result batch. + fn handle_approval_results(&mut self, cx: &mut Cx, actions: &Actions) { + use crate::approval_discovery::{ApprovalDiscoveryResult, DiscoveryOutcome}; + use crate::approval_state::SendResult; + use crate::home::room_screen::ActionResponseResultAction; + + for action in actions { + if let Some(result) = action.downcast_ref::() { + let Some(account) = self.sync_approval_membership(cx) else { continue }; + let now = Self::approval_now_ms(); + if !self.approval_discovery.accept_result(&result.request, result.outcome, now) { + continue; + } + let room = &result.request.room_id; + if result.observed_v2 + && self + .app_state + .approval_markers + .observe_v2(account.as_str(), room) + .is_err() + { + self.app_state + .approval_markers + .mark_room_unavailable(account.as_str(), room); + } + match result.outcome { + DiscoveryOutcome::Present => { + let accepted = result.marker.as_ref().is_some_and(|event| { + let Some(event_id) = event.get("event_id").and_then(serde_json::Value::as_str) else { return false }; + let Some(sender) = event.get("sender").and_then(serde_json::Value::as_str) else { return false }; + let Some(state_key) = event.get("state_key").and_then(serde_json::Value::as_str) else { return false }; + let Some(content) = event.get("content") else { return false }; + let Some(event_type) = event.get("type").and_then(serde_json::Value::as_str) else { return false }; + matches!(event_type, "com.agentchat.approval.room.v1" | "com.agentchat.approval.room.v2") + && self.app_state.approval_markers.ingest_marker( + account.as_str(), room, event_id, event_type, sender, state_key, content, now, + ).is_ok() + }); + if !accepted { + self.app_state.approval_markers.mark_room_unavailable(account.as_str(), room); + } + } + DiscoveryOutcome::Absent => self.app_state.approval_markers.mark_room_unavailable(account.as_str(), room), + DiscoveryOutcome::Failed => self.app_state.approval_markers.mark_room_unavailable(account.as_str(), room), + } + // Persist discovery evidence only. Revalidation flags are deliberately not serialized. + if let Err(error) = persistence::save_app_state(self.app_state.clone(), account) { + error!("Failed to persist approval room discovery: {error}"); + } + self.ui.redraw(cx); + } + + let Some(result) = action.downcast_ref::() else { continue }; + let (room, claim, outcome) = match result { + ActionResponseResultAction::Sent { room_id, approval_claim, .. } => (room_id, approval_claim, SendResult::Sent), + ActionResponseResultAction::Failed { room_id, approval_claim, outcome_unknown, .. } => ( + room_id, approval_claim, + if *outcome_unknown { SendResult::OutcomeUnknown } else { SendResult::ConfirmedPreSendFailure }, + ), + }; + let Some(claim) = claim else { continue }; + let Some(account) = current_user_id() else { continue }; + if claim.account_mxid != account.as_str() || claim.approval_room_id != room.as_str() { + continue; + } + if let Ok(mut session) = self.app_state.approval_session.lock() { + session.record_send_result(claim, outcome, Self::approval_now_ms()); + } + cx.action(AppStateAction::ApprovalRoomStateChanged { account_mxid: account, room_id: room.clone() }); + } + } + + fn open_pending_approvals(&mut self, cx: &mut Cx, agent: &str, project_room_id: &OwnedRoomId) { + use crate::approval_state::MarkerResolution; + + let account = self.sync_approval_membership(cx); + let resolution = account.as_ref().filter(|_| self.approval_joined_rooms.contains(project_room_id.as_str())) + .map(|account| self.app_state.approval_markers.resolve( + account.as_str(), agent, project_room_id.as_str(), + &self.approval_joined_rooms.iter().cloned().collect(), Self::approval_now_ms(), + )).unwrap_or(MarkerResolution::Missing); + let room_name_id = match &resolution { + MarkerResolution::Unique(room) => RoomId::parse(room).ok() + .and_then(|id| cx.get_global::().joined_room_name(&id)), + _ => None, + }; + let Some(room_name_id) = room_name_id else { + self.pending_approval_navigation = None; + let key = if matches!(resolution, MarkerResolution::Ambiguous) { + "room_screen.approval.navigation_ambiguous" + } else { + "room_screen.approval.navigation_unavailable" + }; + enqueue_popup_notification(tr_key(self.app_state.app_language, key), PopupKind::Info, Some(5.0)); + self.refresh_approval_discovery(cx); + return; + }; + if !effective_is_desktop(cx) && self.ui.stack_navigation(cx, ids!(view_stack)).is_transitioning() { + self.pending_approval_navigation = Some((agent.to_owned(), project_room_id.clone())); + return; + } + self.pending_approval_navigation = Some((agent.to_owned(), project_room_id.clone())); + enqueue_rooms_list_update(RoomsListUpdate::ScrollToRoom(room_name_id.room_id().clone())); + cx.widget_action( + self.ui.widget_uid(), + RoomsListAction::OpenPendingApprovals(SelectedRoom::JoinedRoom { room_name_id }), + ); + } + /// Returns `true` if the "Add an agent" sheet is waiting for this DM result /// (it created the direct room to bind an agent). When it is, the global /// DirectMessage handler should be skipped so the user stays in the sheet @@ -3159,6 +3398,15 @@ impl App { /// selected room if it was this one, persists the app state if anything /// changed, and closes any open dock tabs for it. fn purge_room_ui_state(&mut self, cx: &mut Cx, room_id: &OwnedRoomId) { + if let Some(account) = self.approval_account.as_ref() { + self.app_state.approval_markers.remove_room(account.as_str(), room_id.as_str()); + if let Ok(mut session) = self.app_state.approval_session.lock() { + session.remove_room(account.as_str(), room_id.as_str()); + } + self.approval_joined_rooms.remove(room_id.as_str()); + self.approval_discovery.sync_membership(account.as_str(), &self.approval_joined_rooms); + } + self.pending_approval_navigation = None; self.app_state .bot_settings .set_room_bound(room_id.clone(), None, false); @@ -3371,6 +3619,12 @@ pub struct AppState { /// deserialize to an empty registry via `#[serde(default)]`. #[serde(default, deserialize_with = "crate::utils::deserialize_or_default")] pub agent_registry: AgentRegistry, + /// Shared across every cached/split pane; server projections remain the authority. + #[serde(skip)] + pub(crate) approval_session: std::sync::Arc>, + /// Discovery cache only. Deserialization clears its current-run validation. + #[serde(default, deserialize_with = "crate::utils::deserialize_or_default")] + pub(crate) approval_markers: crate::approval_state::ApprovalMarkerIndex, /// Translation API configuration. #[serde(default, deserialize_with = "crate::utils::deserialize_or_default")] pub translation: crate::room::translation::TranslationConfig, @@ -5033,6 +5287,9 @@ mod tests { /// These are *NOT* widget actions. #[derive(Debug)] pub enum AppStateAction { + ApprovalRoomStateChanged { account_mxid: OwnedUserId, room_id: OwnedRoomId }, + OpenPendingApprovals { agent: String, project_room_id: OwnedRoomId }, + RetryPendingApprovalNavigation, /// The given room was focused (selected). RoomFocused(SelectedRoom), /// Resets the focus to none, meaning that no room is selected. diff --git a/src/approval_discovery.rs b/src/approval_discovery.rs new file mode 100644 index 000000000..c0fdc48a5 --- /dev/null +++ b/src/approval_discovery.rs @@ -0,0 +1,282 @@ +//! Bounded scheduling for authenticated private-room marker discovery. +//! No network calls or marker trust decisions live here. + +use std::collections::{BTreeMap, BTreeSet}; + +pub(crate) const DISCOVERY_BATCH_SIZE: usize = 4; +pub(crate) const DISCOVERY_CONCURRENCY: usize = 2; +pub(crate) const DISCOVERY_TIMEOUT_MS: u64 = 10_000; +const DISCOVERY_RESULT_GRACE_MS: u64 = 5_000; +const POSITIVE_REFRESH_MS: u64 = 120_000; +const NEGATIVE_REFRESH_MS: u64 = 60_000; +const FAILURE_REFRESH_MS: u64 = 30_000; +const MAX_DISCOVERY_ROOMS: usize = 4096; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ApprovalDiscoveryRequest { + pub account: String, + pub room_id: String, + pub generation: u64, + pub nonce: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DiscoveryOutcome { + Present, + Absent, + Failed, +} + +#[derive(Debug)] +pub(crate) struct ApprovalDiscoveryResult { + pub request: ApprovalDiscoveryRequest, + pub outcome: DiscoveryOutcome, + /// True when the authenticated full-state response contained an empty-key + /// v2 event, even if that event was duplicate, oversized, or malformed. + pub observed_v2: bool, + /// Full authenticated state event, including actual sender and event ID. + pub marker: Option, +} + +#[derive(Debug, Default)] +struct RoomSchedule { + next_due_ms: u64, + in_flight: Option<(u64, u64)>, + refresh_pending: bool, +} + +#[derive(Debug, Default)] +pub(crate) struct ApprovalDiscoverySchedule { + account: String, + generation: u64, + nonce: u64, + rooms: BTreeMap, + cursor: Option, + unavailable: bool, +} + +impl ApprovalDiscoverySchedule { + /// `joined_loaded` is supplied from the currently displayed account's room list. + /// A removed/rejoined room gets a new nonce, so an old request cannot repopulate it. + pub fn sync_membership(&mut self, account: &str, joined_loaded: &BTreeSet) -> bool { + if self.account != account { + let Some(generation) = self.generation.checked_add(1) else { + self.unavailable = true; + return false; + }; + self.account = account.to_owned(); + self.generation = generation; + self.rooms.clear(); + self.cursor = None; + } + self.unavailable = account.is_empty() || joined_loaded.len() > MAX_DISCOVERY_ROOMS; + if self.unavailable { + self.rooms.clear(); + return false; + } + self.rooms.retain(|room, _| joined_loaded.contains(room)); + for room in joined_loaded { + self.rooms.entry(room.clone()).or_default(); + } + true + } + + pub fn select_due(&mut self, now_ms: u64) -> Vec { + if self.unavailable { return Vec::new(); } + for room in self.rooms.values_mut() { + if room.in_flight.is_some_and(|(_, deadline)| now_ms >= deadline) { + room.in_flight = None; + room.next_due_ms = now_ms.saturating_add(FAILURE_REFRESH_MS); + } + } + // Bound outstanding requests as well as each tick's batch. A slow worker + // must not let repeated timer ticks build an unbounded semaphore queue. + let available = DISCOVERY_BATCH_SIZE.saturating_sub( + self.rooms.values().filter(|room| room.in_flight.is_some()).count(), + ); + let ordered: Vec = self.rooms.keys() + .filter(|room| self.cursor.as_ref().is_none_or(|cursor| *room > cursor)) + .chain(self.rooms.keys().filter(|room| self.cursor.as_ref().is_some_and(|cursor| *room <= cursor))) + .cloned().collect(); + let mut selected = Vec::new(); + for room_id in ordered { + if selected.len() >= available { break; } + let Some(room) = self.rooms.get_mut(&room_id) else { continue }; + if room.in_flight.is_some() || now_ms < room.next_due_ms { continue; } + let Some(nonce) = self.nonce.checked_add(1) else { + self.unavailable = true; + break; + }; + self.nonce = nonce; + // Two waves of two requests, with a small UI delivery allowance. + let deadline = now_ms.saturating_add( + DISCOVERY_TIMEOUT_MS * 2 + DISCOVERY_RESULT_GRACE_MS, + ); + room.in_flight = Some((nonce, deadline)); + self.cursor = Some(room_id.clone()); + selected.push(ApprovalDiscoveryRequest { + account: self.account.clone(), room_id, generation: self.generation, nonce, + }); + } + selected + } + + /// Recheck account and membership immediately before this call. Only an + /// accepted response may update or remove a private marker cache entry. + pub fn accept_result( + &mut self, + request: &ApprovalDiscoveryRequest, + outcome: DiscoveryOutcome, + now_ms: u64, + ) -> bool { + if self.unavailable || request.account != self.account || request.generation != self.generation { + return false; + } + let Some(room) = self.rooms.get_mut(&request.room_id) else { return false }; + if !room.in_flight.is_some_and(|(nonce, deadline)| nonce == request.nonce && now_ms < deadline) { + return false; + } + room.in_flight = None; + room.next_due_ms = if room.refresh_pending && outcome != DiscoveryOutcome::Failed { + now_ms + } else { + now_ms.saturating_add(match outcome { + DiscoveryOutcome::Present => POSITIVE_REFRESH_MS, + DiscoveryOutcome::Absent => NEGATIVE_REFRESH_MS, + DiscoveryOutcome::Failed => FAILURE_REFRESH_MS, + }) + }; + room.refresh_pending = false; + true + } + + /// Relevant private events can request a refresh without duplicating a send + /// that is already in flight. Timer refresh still covers missed events. + pub fn refresh_room(&mut self, account: &str, room_id: &str) { + if account == self.account { + if let Some(room) = self.rooms.get_mut(room_id) { + room.next_due_ms = 0; + room.refresh_pending = room.in_flight.is_some(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + fn rooms(count: usize) -> BTreeSet { + (0..count).map(|id| format!("!room{id:04}:test")).collect() + } + + #[test] + fn approval_discovery_rejects_late_account_and_membership_results() { + let mut schedule = ApprovalDiscoverySchedule::default(); + let joined = rooms(2); + assert!(schedule.sync_membership("@owner:test", &joined)); + let first = schedule.select_due(1000); + schedule.sync_membership("@other:test", &joined); + assert!(!schedule.accept_result(&first[0], DiscoveryOutcome::Present, 1001)); + let second = schedule.select_due(1002); + schedule.sync_membership("@other:test", &BTreeSet::new()); + schedule.sync_membership("@other:test", &joined); + let third = schedule.select_due(1003); + assert!(!schedule.accept_result(&second[0], DiscoveryOutcome::Present, 1004)); + assert!(schedule.accept_result(&third[0], DiscoveryOutcome::Present, 1004)); + assert!(!schedule.accept_result(&third[0], DiscoveryOutcome::Present, 1005)); + } + + #[test] + fn approval_discovery_bounds_outstanding_work_and_refreshes_negatives() { + let mut schedule = ApprovalDiscoverySchedule::default(); + schedule.sync_membership("@owner:test", &rooms(10)); + let first = schedule.select_due(1000); + assert_eq!(first.len(), DISCOVERY_BATCH_SIZE); + assert!(schedule.select_due(1001).is_empty()); + assert!(schedule.accept_result(&first[0], DiscoveryOutcome::Absent, 1002)); + let next = schedule.select_due(1003); + assert_eq!(next.len(), 1); + assert_ne!(first[0].room_id, next[0].room_id); + let remaining = schedule.select_due(1000 + DISCOVERY_TIMEOUT_MS * 2 + DISCOVERY_RESULT_GRACE_MS); + assert!(remaining.len() <= DISCOVERY_BATCH_SIZE); + assert!(!schedule.accept_result(&first[1], DiscoveryOutcome::Present, 100_000)); + let mut seen = BTreeSet::new(); + for tick in 0..10 { + let now = 200_000 + tick * 1000; + for request in schedule.select_due(now) { + seen.insert(request.room_id.clone()); + assert!(schedule.accept_result(&request, DiscoveryOutcome::Absent, now)); + } + } + assert!(seen.contains(&first[0].room_id)); + } + + #[test] + fn approval_discovery_overflow_and_expired_response_fail_closed() { + let mut schedule = ApprovalDiscoverySchedule::default(); + assert!(!schedule.sync_membership("@owner:test", &rooms(MAX_DISCOVERY_ROOMS + 1))); + assert!(schedule.select_due(1000).is_empty()); + assert!(schedule.sync_membership("@owner:test", &rooms(1))); + let request = schedule.select_due(1000).remove(0); + assert!(!schedule.accept_result(&request, DiscoveryOutcome::Present, 100_000)); + schedule.select_due(100_000); + let retried = schedule.select_due(130_000); + assert_eq!(retried.len(), 1); + assert_ne!(request.nonce, retried[0].nonce); + } + + #[test] + fn approval_discovery_visits_every_eligible_room_before_refreshing_completed_work() { + for count in [0, 1, 3, 4, 5, 31, 128] { + let joined = rooms(count); + let mut schedule = ApprovalDiscoverySchedule::default(); + schedule.sync_membership("@owner:test", &joined); + let mut visited = BTreeSet::new(); + for _ in 0..count.div_ceil(DISCOVERY_BATCH_SIZE) { + let batch = schedule.select_due(1000); + assert!(batch.len() <= DISCOVERY_BATCH_SIZE); + for request in batch { + assert!(visited.insert(request.room_id.clone())); + assert!(schedule.accept_result(&request, DiscoveryOutcome::Absent, 1000)); + } + } + assert_eq!(visited, joined); + assert!(schedule.select_due(1001).is_empty()); + } + } + + #[test] + fn approval_discovery_retains_refresh_received_during_an_in_flight_fetch() { + let mut schedule = ApprovalDiscoverySchedule::default(); + schedule.sync_membership("@owner:test", &rooms(1)); + let request = schedule.select_due(1000).remove(0); + schedule.refresh_room("@owner:test", &request.room_id); + assert!(schedule.select_due(1001).is_empty()); + assert!(schedule.accept_result(&request, DiscoveryOutcome::Present, 1002)); + let refresh = schedule.select_due(1003); + assert_eq!(refresh.len(), 1, "a private event received during fetch still needs revalidation"); + assert_ne!(request.nonce, refresh[0].nonce); + } + + proptest! { + #[test] + fn prop_approval_discovery_bounded_fair_batches(count in 0usize..257, now in 1u64..1_000_000) { + let joined = rooms(count); + let mut schedule = ApprovalDiscoverySchedule::default(); + prop_assert!(schedule.sync_membership("@owner:test", &joined)); + let mut visited = BTreeSet::new(); + for _ in 0..count.div_ceil(DISCOVERY_BATCH_SIZE) { + let batch = schedule.select_due(now); + prop_assert!(batch.len() <= DISCOVERY_BATCH_SIZE); + for request in batch { + prop_assert!(visited.insert(request.room_id.clone())); + prop_assert!(schedule.accept_result(&request, DiscoveryOutcome::Absent, now)); + } + } + prop_assert_eq!(visited, joined); + prop_assert!(schedule.select_due(now + 1).is_empty()); + } + } +} diff --git a/src/approval_state.rs b/src/approval_state.rs new file mode 100644 index 000000000..cf81aa4aa --- /dev/null +++ b/src/approval_state.rs @@ -0,0 +1,1792 @@ +//! Pure, account-scoped state for agent-chat approval projections. + +use std::collections::{BTreeSet, HashMap, HashSet}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +const KEY: &str = "com.agentchat.approval"; +const REQUEST_TYPE: &str = "com.agentchat.approval.request.v1"; +const STATUS_TYPE: &str = "com.agentchat.approval.status.v1"; +const MARKER_TYPE: &str = "com.agentchat.approval.room.v1"; +const MARKER_TYPE_V2: &str = "com.agentchat.approval.room.v2"; +pub(crate) const MARKER_FRESHNESS_MS: u64 = 300_000; +const MAX_ACCOUNTS: usize = 16; +const MAX_APPROVALS: usize = 1024; +const MAX_MARKERS: usize = 256; +const MAX_V1_ASSOCIATIONS: usize = 128; +const MAX_V2_ASSOCIATIONS: usize = 64; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CanonicalApprovalState { + Pending, + Approved, + Denied, + Expired, + Consumed, +} + +impl CanonicalApprovalState { + fn terminal(self) -> bool { + self != Self::Pending + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ApprovalDecision { + Allow, + Deny, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ApprovalAction { + ApproveOnce, + Deny, +} + +impl ApprovalAction { + fn name(self) -> &'static str { + match self { + Self::ApproveOnce => "approve_once", + Self::Deny => "deny", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) struct ApprovalRequestKey { + pub(crate) account_mxid: String, + pub(crate) approval_room_id: String, + pub(crate) request_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ApprovalClaimKey { + pub(crate) account_mxid: String, + pub(crate) approval_room_id: String, + pub(crate) request_id: String, + pub(crate) input_digest: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct ApprovalBinding { + pub(crate) request_id: String, + pub(crate) agent: String, + pub(crate) project: String, + pub(crate) project_room_id: String, + pub(crate) input_digest: String, + pub(crate) owner_mxid: String, + pub(crate) publisher_mxid: String, + pub(crate) approval_room_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ApprovalView { + pub(crate) binding: ApprovalBinding, + pub(crate) state: CanonicalApprovalState, + pub(crate) decision: Option, + pub(crate) latest_revision: u64, + pub(crate) expires_at: Option, + pub(crate) source_event_ids: Vec, + pub(crate) legacy_read_only: bool, + pub(crate) legacy_original_event_id: Option, + pub(crate) conflicted: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ChangedApproval { + pub(crate) key: ApprovalRequestKey, + pub(crate) room_epoch: u64, + pub(crate) source_event_ids: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ApprovalModelError { + InvalidContext, + InvalidContent, + BindingMismatch, + RevisionConflict, + InvalidTransition, + CapacityExceeded, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum IngestResult { + Changed(ChangedApproval), + Duplicate, + Rejected(ApprovalModelError), +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ClaimGrant { + pub(crate) transaction_id: String, + pub(crate) action: ApprovalAction, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SendResult { + ConfirmedPreSendFailure, + OutcomeUnknown, + Sent, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ClaimDisposition { + Released, + Retained, + Missing, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ClaimError { + NotActionable, + AlreadyClaimed, + BindingMismatch, + CapacityExceeded, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ClaimState { + Claimed, + OutcomeUnknown, + Sent, +} +#[derive(Clone, Debug)] +struct Claim { + state: ClaimState, +} +#[derive(Clone, Debug)] +struct Record { + binding: ApprovalBinding, + state: CanonicalApprovalState, + decision: Option, + revision: u64, + expires_at: Option, + sources: BTreeSet, + has_request: bool, + legacy: bool, + legacy_original_event_id: Option, + conflicted: bool, +} +impl Record { + fn view(&self) -> ApprovalView { + ApprovalView { + binding: self.binding.clone(), + state: self.state, + decision: self.decision, + latest_revision: self.revision, + expires_at: self.expires_at, + source_event_ids: self.sources.iter().cloned().collect(), + legacy_read_only: self.legacy, + legacy_original_event_id: self.legacy_original_event_id.clone(), + conflicted: self.conflicted, + } + } +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct ApprovalSession { + records: HashMap, + claims: HashMap, + epochs: HashMap<(String, String), u64>, +} + +#[derive(Clone, Debug)] +struct Parsed { + binding: ApprovalBinding, + state: CanonicalApprovalState, + decision: Option, + revision: u64, + expires_at: Option, + request: bool, + legacy: bool, + legacy_original_event_id: Option, +} + +impl ApprovalSession { + pub(crate) fn ingest_message( + &mut self, + account: &str, + room: &str, + event: &str, + sender: &str, + content: &Value, + _now: u64, + ) -> IngestResult { + if !mxid(account) || !room_id(room) || !event_id(event) || !mxid(sender) { + return IngestResult::Rejected(ApprovalModelError::InvalidContext); + } + let parsed = match parse_projection(room, sender, content) { + Ok(v) => v, + Err(e) => return IngestResult::Rejected(e), + }; + let key = ApprovalRequestKey { + account_mxid: account.into(), + approval_room_id: room.into(), + request_id: parsed.binding.request_id.clone(), + }; + if !self.records.contains_key(&key) { + if (self.account_count() >= MAX_ACCOUNTS && !self.has_account(account)) + || self + .records + .keys() + .filter(|k| k.account_mxid == account) + .count() + >= MAX_APPROVALS + { + return IngestResult::Rejected(ApprovalModelError::CapacityExceeded); + } + let mut sources = BTreeSet::new(); + if parsed.request { + sources.insert(event.into()); + } + self.records.insert( + key.clone(), + Record { + binding: parsed.binding, + state: parsed.state, + decision: parsed.decision, + revision: parsed.revision, + expires_at: parsed.expires_at, + sources, + has_request: parsed.request, + legacy: parsed.legacy, + legacy_original_event_id: parsed.legacy_original_event_id, + conflicted: false, + }, + ); + return IngestResult::Changed(self.changed(&key)); + } + let binding_mismatch = self + .records + .get(&key) + .is_some_and(|r| r.binding != parsed.binding); + if binding_mismatch { + if let Some(record) = self.records.get_mut(&key) { + record.conflicted = true; + } + let _ = self.changed(&key); + return IngestResult::Rejected(ApprovalModelError::BindingMismatch); + } + if parsed.request { + let Some(record) = self.records.get_mut(&key) else { + return IngestResult::Rejected(ApprovalModelError::InvalidTransition); + }; + // A status-only legacy baseline never becomes a native request. + if record.legacy { + return IngestResult::Rejected(ApprovalModelError::InvalidTransition); + } + if record.has_request { + if record.expires_at == parsed.expires_at { + return IngestResult::Duplicate; + } + record.conflicted = true; + let _ = self.changed(&key); + return IngestResult::Rejected(ApprovalModelError::RevisionConflict); + } + record.has_request = true; + record.expires_at = parsed.expires_at; + record.sources.insert(event.into()); + record.legacy |= parsed.legacy; + return IngestResult::Changed(self.changed(&key)); + } + let Some(record) = self.records.get(&key) else { + return IngestResult::Rejected(ApprovalModelError::InvalidTransition); + }; + if (record.legacy || parsed.legacy) + && (record.legacy != parsed.legacy + || record.legacy_original_event_id != parsed.legacy_original_event_id) + { + if let Some(record) = self.records.get_mut(&key) { + record.conflicted = true; + } + let _ = self.changed(&key); + return IngestResult::Rejected(ApprovalModelError::RevisionConflict); + } + let (old_rev, old_state, old_decision, old_legacy) = ( + record.revision, + record.state, + record.decision, + record.legacy, + ); + if parsed.revision < old_rev { + return IngestResult::Duplicate; + } + if parsed.revision == old_rev { + if old_state == parsed.state + && old_decision == parsed.decision + && old_legacy == parsed.legacy + { + return IngestResult::Duplicate; + } + if let Some(record) = self.records.get_mut(&key) { + record.conflicted = true; + } + let _ = self.changed(&key); + return IngestResult::Rejected(ApprovalModelError::RevisionConflict); + } + if !transition(old_state, old_decision, parsed.state, parsed.decision) { + if let Some(record) = self.records.get_mut(&key) { + record.conflicted = true; + } + let _ = self.changed(&key); + return IngestResult::Rejected(ApprovalModelError::InvalidTransition); + } + if let Some(record) = self.records.get_mut(&key) { + record.revision = parsed.revision; + record.state = parsed.state; + record.decision = parsed.decision; + record.legacy |= parsed.legacy; + } + if parsed.state.terminal() { + self.claims.retain(|c, _| { + !(c.account_mxid == account + && c.approval_room_id == room + && c.request_id == key.request_id) + }); + } + IngestResult::Changed(self.changed(&key)) + } + + pub(crate) fn approval( + &self, + account: &str, + room: &str, + request: &str, + ) -> Option { + self.records + .get(&ApprovalRequestKey { + account_mxid: account.into(), + approval_room_id: room.into(), + request_id: request.into(), + }) + .map(Record::view) + } + pub(crate) fn room_epoch(&self, account: &str, room: &str) -> u64 { + self.epochs + .get(&(account.into(), room.into())) + .copied() + .unwrap_or(0) + } + pub(crate) fn is_actionable(&self, account: &str, room: &str, request: &str, now: u64) -> bool { + let key = ApprovalRequestKey { + account_mxid: account.into(), + approval_room_id: room.into(), + request_id: request.into(), + }; + let Some(r) = self.records.get(&key) else { + return false; + }; + r.has_request + && !r.legacy + && !r.conflicted + && r.state == CanonicalApprovalState::Pending + && r.binding.owner_mxid == account + && r.expires_at.is_some_and(|e| now < e) + && !self.claims.keys().any(|c| { + c.account_mxid == account + && c.approval_room_id == room + && c.request_id == request + && c.input_digest == r.binding.input_digest + }) + } + pub(crate) fn try_claim( + &mut self, + key: ApprovalClaimKey, + action: ApprovalAction, + now: u64, + ) -> Result { + let rk = ApprovalRequestKey { + account_mxid: key.account_mxid.clone(), + approval_room_id: key.approval_room_id.clone(), + request_id: key.request_id.clone(), + }; + let Some(r) = self.records.get(&rk) else { + return Err(ClaimError::NotActionable); + }; + if r.binding.input_digest != key.input_digest { + return Err(ClaimError::BindingMismatch); + } + if !self.is_actionable( + &key.account_mxid, + &key.approval_room_id, + &key.request_id, + now, + ) { + return Err(if self.claims.contains_key(&key) { + ClaimError::AlreadyClaimed + } else { + ClaimError::NotActionable + }); + } + if self.claims.len() >= MAX_ACCOUNTS * MAX_APPROVALS { + return Err(ClaimError::CapacityExceeded); + } + let transaction_id = stable_txn(&key.request_id, action, &key.input_digest) + .ok_or(ClaimError::BindingMismatch)?; + self.claims.insert( + key.clone(), + Claim { + state: ClaimState::Claimed, + }, + ); + let _ = self.bump_room_epoch(&key.account_mxid, &key.approval_room_id); + Ok(ClaimGrant { + transaction_id, + action, + }) + } + pub(crate) fn record_send_result( + &mut self, + key: &ApprovalClaimKey, + result: SendResult, + now: u64, + ) -> ClaimDisposition { + let Some(claim) = self.claims.get(key) else { + return ClaimDisposition::Missing; + }; + match result { + SendResult::ConfirmedPreSendFailure => { + let rk = ApprovalRequestKey { + account_mxid: key.account_mxid.clone(), + approval_room_id: key.approval_room_id.clone(), + request_id: key.request_id.clone(), + }; + let release = claim.state == ClaimState::Claimed + && self.records.get(&rk).is_some_and(|r| { + r.state == CanonicalApprovalState::Pending + && !r.conflicted + && r.expires_at.is_some_and(|e| now < e) + }); + if release { + self.claims.remove(key); + let _ = self.bump_room_epoch(&key.account_mxid, &key.approval_room_id); + ClaimDisposition::Released + } else { + ClaimDisposition::Retained + } + } + SendResult::OutcomeUnknown => { + if let Some(claim) = self.claims.get_mut(key) { + claim.state = ClaimState::OutcomeUnknown; + } + let _ = self.bump_room_epoch(&key.account_mxid, &key.approval_room_id); + ClaimDisposition::Retained + } + SendResult::Sent => { + if let Some(claim) = self.claims.get_mut(key) { + claim.state = ClaimState::Sent; + } + let _ = self.bump_room_epoch(&key.account_mxid, &key.approval_room_id); + ClaimDisposition::Retained + } + } + } + pub(crate) fn claim_state(&self, key: &ApprovalClaimKey) -> Option { + self.claims.get(key).map(|c| c.state) + } + pub(crate) fn remove_room(&mut self, account: &str, room: &str) { + self.records + .retain(|k, _| k.account_mxid != account || k.approval_room_id != room); + self.claims + .retain(|k, _| k.account_mxid != account || k.approval_room_id != room); + self.epochs.remove(&(account.into(), room.into())); + } + pub(crate) fn clear_account(&mut self, account: &str) { + self.records.retain(|k, _| k.account_mxid != account); + self.claims.retain(|k, _| k.account_mxid != account); + self.epochs.retain(|(a, _), _| a != account); + } + fn changed(&mut self, key: &ApprovalRequestKey) -> ChangedApproval { + let epoch = self + .epochs + .entry((key.account_mxid.clone(), key.approval_room_id.clone())) + .or_default(); + *epoch = epoch.saturating_add(1); + ChangedApproval { + key: key.clone(), + room_epoch: *epoch, + source_event_ids: self + .records + .get(key) + .map(|r| r.sources.iter().cloned().collect()) + .unwrap_or_default(), + } + } + fn bump_room_epoch(&mut self, account: &str, room: &str) -> u64 { + let epoch = self + .epochs + .entry((account.into(), room.into())) + .or_default(); + *epoch = epoch.saturating_add(1); + *epoch + } + fn has_account(&self, a: &str) -> bool { + self.records.keys().any(|k| k.account_mxid == a) + } + fn account_count(&self) -> usize { + self.records + .keys() + .map(|k| &k.account_mxid) + .collect::>() + .len() + } +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct ApprovalMarkerIndex { + version: u8, + markers: Vec, + protocol_floors: Vec, + #[serde(skip)] + validated: HashSet<(String, String, u64)>, +} + +impl Default for ApprovalMarkerIndex { + fn default() -> Self { + Self { + version: 2, + markers: Vec::new(), + protocol_floors: Vec::new(), + validated: HashSet::new(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default)] +struct Association { + agent: String, + project_room_id: String, + active: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default)] +struct Marker { + account: String, + room: String, + event: String, + publisher: String, + owner: String, + /// Present only in persisted cache format v1. New records keep this empty. + agent: String, + protocol: u8, + generation: u64, + associations: Vec, + validated_at: u64, + conflicted: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default)] +struct ProtocolFloor { + account: String, + room: String, + minimum: u8, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct ApprovalMarkerIndexWire { + version: u8, + markers: Vec, + protocol_floors: Vec, +} + +impl<'de> Deserialize<'de> for ApprovalMarkerIndex { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut wire = ApprovalMarkerIndexWire::deserialize(deserializer)?; + match wire.version { + 1 => { + for marker in &mut wire.markers { + if marker.agent.is_empty() + || marker.associations.len() > MAX_V1_ASSOCIATIONS + || marker.associations.iter().any(|association| { + !association.agent.is_empty() + || !room_id(&association.project_room_id) + }) + { + return Ok(Self::default()); + } + for association in &mut marker.associations { + association.agent = marker.agent.clone(); + } + marker.agent.clear(); + marker.protocol = 1; + } + wire.protocol_floors = wire + .markers + .iter() + .map(|marker| ProtocolFloor { + account: marker.account.clone(), + room: marker.room.clone(), + minimum: 1, + }) + .collect(); + } + 2 => {} + _ => return Ok(Self::default()), + } + if wire.markers.len() > MAX_ACCOUNTS.saturating_mul(MAX_MARKERS) + || wire.protocol_floors.len() > MAX_ACCOUNTS.saturating_mul(MAX_MARKERS) + { + return Ok(Self::default()); + } + Ok(Self { + version: 2, + markers: wire.markers, + protocol_floors: wire.protocol_floors, + validated: HashSet::new(), + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum MarkerResolution { + Unique(String), + Missing, + Ambiguous, + NotJoined, + Stale, +} + +impl ApprovalMarkerIndex { + pub(crate) fn observe_v2( + &mut self, + account: &str, + room: &str, + ) -> Result<(), ApprovalModelError> { + if !mxid(account) || !room_id(room) { + return Err(ApprovalModelError::InvalidContext); + } + self.mark_room_unavailable(account, room); + if let Some(floor) = self + .protocol_floors + .iter_mut() + .find(|floor| floor.account == account && floor.room == room) + { + floor.minimum = 2; + return Ok(()); + } + let account_rooms = self + .protocol_floors + .iter() + .filter(|floor| floor.account == account) + .count(); + let accounts = self + .protocol_floors + .iter() + .map(|floor| &floor.account) + .collect::>() + .len(); + if account_rooms >= MAX_MARKERS + || (accounts >= MAX_ACCOUNTS + && !self + .protocol_floors + .iter() + .any(|floor| floor.account == account)) + { + return Err(ApprovalModelError::CapacityExceeded); + } + self.protocol_floors.push(ProtocolFloor { + account: account.into(), + room: room.into(), + minimum: 2, + }); + Ok(()) + } + + fn protocol_floor(&self, account: &str, room: &str) -> u8 { + self.protocol_floors + .iter() + .find(|floor| floor.account == account && floor.room == room) + .map_or(1, |floor| floor.minimum) + } + + pub(crate) fn ingest_marker( + &mut self, + account: &str, + room: &str, + event: &str, + event_type: &str, + sender: &str, + state_key: &str, + content: &Value, + now: u64, + ) -> Result<(), ApprovalModelError> { + if !state_key.is_empty() + || !mxid(account) + || !room_id(room) + || !event_id(event) + || !mxid(sender) + { + return Err(ApprovalModelError::InvalidContext); + } + let detail = content.get(KEY).unwrap_or(content); + let protocol = detail + .get("version") + .and_then(Value::as_u64) + .and_then(|version| u8::try_from(version).ok()) + .ok_or(ApprovalModelError::InvalidContent)?; + if !matches!((event_type, protocol), (MARKER_TYPE, 1) | (MARKER_TYPE_V2, 2)) { + return Err(ApprovalModelError::InvalidContent); + } + if protocol == 2 { + self.observe_v2(account, room)?; + } else if self.protocol_floor(account, room) >= 2 { + self.mark_room_unavailable(account, room); + return Err(ApprovalModelError::RevisionConflict); + } + if content + .get("type") + .and_then(Value::as_str) + .is_some_and(|found| found != event_type) + { + return Err(ApprovalModelError::InvalidContent); + } + let publisher = req(detail, "publisher_mxid")?; + let owner = req(detail, "owner_mxid")?; + let generation = detail + .get("binding_generation") + .and_then(Value::as_u64) + .filter(|generation| *generation > 0) + .ok_or(ApprovalModelError::InvalidContent)?; + if !mxid(&publisher) || !mxid(&owner) || publisher != sender || owner != account { + return Err(ApprovalModelError::InvalidContext); + } + let items = detail + .get("project_room_associations") + .and_then(Value::as_array) + .ok_or(ApprovalModelError::InvalidContent)?; + let association_limit = if protocol == 1 { + MAX_V1_ASSOCIATIONS + } else { + MAX_V2_ASSOCIATIONS + }; + if items.len() > association_limit || (protocol == 1 && items.is_empty()) { + return Err(ApprovalModelError::CapacityExceeded); + } + let legacy_agent = if protocol == 1 { + Some(req(detail, "agent")?) + } else { + if detail.get("agent").is_some() { + return Err(ApprovalModelError::InvalidContent); + } + None + }; + let mut seen = HashSet::new(); + let mut associations = Vec::with_capacity(items.len()); + for item in items { + let agent = match &legacy_agent { + Some(agent) => agent.clone(), + None => req(item, "agent")?, + }; + let project_room_id = req(item, "project_room_id")?; + let active = item + .get("active") + .and_then(Value::as_bool) + .ok_or(ApprovalModelError::InvalidContent)?; + if agent.len() > 128 + || !room_id(&project_room_id) + || !seen.insert((agent.clone(), project_room_id.clone())) + { + return Err(ApprovalModelError::InvalidContent); + } + associations.push(Association { + agent, + project_room_id, + active, + }); + } + associations.sort_by(|left, right| { + (&left.agent, &left.project_room_id).cmp(&(&right.agent, &right.project_room_id)) + }); + let candidate = Marker { + account: account.into(), + room: room.into(), + event: event.into(), + publisher, + owner, + agent: String::new(), + protocol, + generation, + associations, + validated_at: now, + conflicted: false, + }; + let validation = (account.into(), room.into(), generation); + if let Some(old) = self + .markers + .iter_mut() + .find(|marker| marker.account == account && marker.room == room) + { + if protocol < old.protocol || generation < old.generation { + self.validated + .remove(&(account.into(), room.into(), old.generation)); + return Err(ApprovalModelError::RevisionConflict); + } + if generation == old.generation { + let same = old.protocol == candidate.protocol + && old.publisher == candidate.publisher + && old.owner == candidate.owner + && old.associations == candidate.associations; + if same { + old.validated_at = now; + old.event = event.into(); + self.validated.insert(validation); + return Ok(()); + } + old.conflicted = true; + self.validated + .remove(&(account.into(), room.into(), generation)); + return Err(ApprovalModelError::RevisionConflict); + } + self.validated + .retain(|(found_account, found_room, _)| { + found_account != account || found_room != room + }); + *old = candidate; + self.validated.insert(validation); + return Ok(()); + } + let accounts = self + .markers + .iter() + .map(|marker| &marker.account) + .collect::>() + .len(); + if self + .markers + .iter() + .filter(|marker| marker.account == account) + .count() + >= MAX_MARKERS + || (accounts >= MAX_ACCOUNTS + && !self.markers.iter().any(|marker| marker.account == account)) + { + return Err(ApprovalModelError::CapacityExceeded); + } + if !self.protocol_floors.iter().any(|floor| { + floor.account == account && floor.room == room + }) { + self.protocol_floors.push(ProtocolFloor { + account: account.into(), + room: room.into(), + minimum: protocol, + }); + } + self.markers.push(candidate); + self.validated.insert(validation); + Ok(()) + } + + pub(crate) fn resolve( + &self, + account: &str, + agent: &str, + project: &str, + joined: &HashSet, + now: u64, + ) -> MarkerResolution { + let candidates: Vec<_> = self + .markers + .iter() + .filter(|marker| { + marker.account == account + && marker.owner == account + && marker.associations.iter().any(|association| { + association.active + && association.agent == agent + && association.project_room_id == project + }) + }) + .collect(); + if candidates.is_empty() { + return MarkerResolution::Missing; + } + if candidates.iter().any(|marker| marker.conflicted) { + return MarkerResolution::Ambiguous; + } + if candidates.iter().any(|marker| { + !self.validated.contains(&( + marker.account.clone(), + marker.room.clone(), + marker.generation, + )) || marker.validated_at == 0 + || now.saturating_sub(marker.validated_at) > MARKER_FRESHNESS_MS + }) { + return MarkerResolution::Stale; + } + if candidates.len() != 1 { + return MarkerResolution::Ambiguous; + } + if !joined.contains(&candidates[0].room) { + MarkerResolution::NotJoined + } else { + MarkerResolution::Unique(candidates[0].room.clone()) + } + } + + pub(crate) fn mark_room_unavailable(&mut self, account: &str, room: &str) { + self.validated + .retain(|(found_account, found_room, _)| { + found_account != account || found_room != room + }); + } + + pub(crate) fn remove_room(&mut self, account: &str, room: &str) { + self.markers + .retain(|marker| marker.account != account || marker.room != room); + self.protocol_floors + .retain(|floor| floor.account != account || floor.room != room); + self.mark_room_unavailable(account, room); + } + + pub(crate) fn clear_account(&mut self, account: &str) { + self.markers.retain(|marker| marker.account != account); + self.protocol_floors + .retain(|floor| floor.account != account); + self.validated + .retain(|(found_account, _, _)| found_account != account); + } +} + +fn parse_projection(room: &str, sender: &str, c: &Value) -> Result { + let typ = c + .get("msgtype") + .and_then(Value::as_str) + .ok_or(ApprovalModelError::InvalidContent)?; + let d = c.get(KEY).ok_or(ApprovalModelError::InvalidContent)?; + if d.get("version").and_then(Value::as_u64) != Some(1) { + return Err(ApprovalModelError::InvalidContent); + } + let kind = if typ == REQUEST_TYPE { + "request" + } else if typ == STATUS_TYPE { + "status" + } else { + return Err(ApprovalModelError::InvalidContent); + }; + if d.get("kind").and_then(Value::as_str) != Some(kind) { + return Err(ApprovalModelError::InvalidContent); + } + let request_id = req(d, "request_id")?; + let agent = req(d, "agent")?; + let project = req(d, "project")?; + let project_room_id = req(d, "project_room_id")?; + let input_digest = req(d, "input_digest")?; + let owner_mxid = req(d, "owner_mxid")?; + let publisher_mxid = req(d, "publisher_mxid")?; + let revision = d + .get("revision") + .and_then(Value::as_u64) + .filter(|v| *v > 0) + .ok_or(ApprovalModelError::InvalidContent)?; + if !request_id + .strip_prefix("approval_") + .is_some_and(|s| hex(s, 32)) + || !room_id(&project_room_id) + || !hex(&input_digest, 64) + || !mxid(&owner_mxid) + || !mxid(&publisher_mxid) + || publisher_mxid != sender + { + return Err(ApprovalModelError::InvalidContent); + } + let binding = ApprovalBinding { + request_id, + agent, + project, + project_room_id, + input_digest, + owner_mxid, + publisher_mxid, + approval_room_id: room.into(), + }; + if typ == REQUEST_TYPE { + let expires_at = d + .get("expires_at") + .and_then(Value::as_u64) + .filter(|v| *v > 0) + .ok_or(ApprovalModelError::InvalidContent)?; + if revision != 1 || !actions(d.get("actions")) { + return Err(ApprovalModelError::InvalidContent); + } + return Ok(Parsed { + binding, + state: CanonicalApprovalState::Pending, + decision: None, + revision, + expires_at: Some(expires_at), + request: true, + legacy: false, + legacy_original_event_id: None, + }); + } + if d.get("actions").is_some() { + return Err(ApprovalModelError::InvalidContent); + } + let state = match d.get("state").and_then(Value::as_str) { + Some("pending") => CanonicalApprovalState::Pending, + Some("approved") => CanonicalApprovalState::Approved, + Some("denied") => CanonicalApprovalState::Denied, + Some("expired") => CanonicalApprovalState::Expired, + Some("consumed") => CanonicalApprovalState::Consumed, + _ => return Err(ApprovalModelError::InvalidContent), + }; + let decision = match d.get("decision") { + None | Some(Value::Null) => None, + Some(Value::String(v)) if v == "allow" => Some(ApprovalDecision::Allow), + Some(Value::String(v)) if v == "deny" => Some(ApprovalDecision::Deny), + _ => return Err(ApprovalModelError::InvalidContent), + }; + if !state_decision(state, decision) { + return Err(ApprovalModelError::InvalidContent); + } + let legacy = d.get("migration_kind").and_then(Value::as_str) == Some("legacy_v1"); + let legacy_original_event_id = if legacy { + if c.get("m.new_content").is_some() { + return Err(ApprovalModelError::InvalidContent); + } + match c.get("m.relates_to") { + None => None, + Some(relation) => { + if relation.get("rel_type").is_some() { + return Err(ApprovalModelError::InvalidContent); + } + let original = relation.get("m.in_reply_to") + .and_then(|reply| reply.get("event_id")) + .and_then(Value::as_str) + .filter(|id| event_id(id)) + .ok_or(ApprovalModelError::InvalidContent)?; + Some(original.into()) + } + } + } else { None }; + Ok(Parsed { + binding, + state, + decision, + revision, + expires_at: None, + request: false, + legacy, + legacy_original_event_id, + }) +} +fn transition( + a: CanonicalApprovalState, + ad: Option, + b: CanonicalApprovalState, + bd: Option, +) -> bool { + matches!( + (a, b), + ( + CanonicalApprovalState::Pending, + CanonicalApprovalState::Approved + | CanonicalApprovalState::Denied + | CanonicalApprovalState::Expired + | CanonicalApprovalState::Consumed + ) + ) || matches!( + (a, b), + ( + CanonicalApprovalState::Approved + | CanonicalApprovalState::Denied + | CanonicalApprovalState::Expired, + CanonicalApprovalState::Consumed + ) + ) && ad == bd +} +fn state_decision(s: CanonicalApprovalState, d: Option) -> bool { + match s { + CanonicalApprovalState::Pending => d.is_none(), + CanonicalApprovalState::Approved => d == Some(ApprovalDecision::Allow), + CanonicalApprovalState::Denied | CanonicalApprovalState::Expired => { + d == Some(ApprovalDecision::Deny) + } + CanonicalApprovalState::Consumed => d.is_some(), + } +} +fn stable_txn(request: &str, action: ApprovalAction, digest: &str) -> Option { + let suffix = request.strip_prefix("approval_")?; + if !hex(suffix, 32) || !hex(digest, 64) { + return None; + } + let s = format!( + "robrix.approval.{suffix}.{}.{}", + action.name(), + &digest[..16] + ); + (s.len() <= 128).then_some(s) +} +fn actions(v: Option<&Value>) -> bool { + let Some(a) = v.and_then(Value::as_array) else { + return false; + }; + let e = [("approve_once", "primary"), ("deny", "danger")]; + a.len() == 2 + && a.iter().zip(e).all(|(v, (id, style))| { + v.get("id").and_then(Value::as_str) == Some(id) + && v.get("style").and_then(Value::as_str) == Some(style) + && v.get("label") + .and_then(Value::as_str) + .is_some_and(|s| !s.trim().is_empty()) + }) +} +fn req(v: &Value, k: &str) -> Result { + v.get(k) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty() && s.len() <= 4096) + .map(str::to_owned) + .ok_or(ApprovalModelError::InvalidContent) +} +fn mxid(s: &str) -> bool { + sigil(s, '@') +} +fn room_id(s: &str) -> bool { + sigil(s, '!') +} +fn event_id(s: &str) -> bool { + s.starts_with('$') && s.len() > 1 && s.len() <= 255 && !s.chars().any(char::is_whitespace) +} +fn sigil(s: &str, c: char) -> bool { + s.starts_with(c) + && s.len() <= 255 + && s[1..] + .split_once(':') + .is_some_and(|(a, b)| !a.is_empty() && !b.is_empty()) + && !s.chars().any(char::is_whitespace) +} +fn hex(s: &str, n: usize) -> bool { + s.len() == n + && s.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + use serde_json::json; + + const A: &str = "@owner:test"; + const R: &str = "!approval:test"; + const P: &str = "@bridge:test"; + const Q: &str = "approval_0123456789abcdef0123456789abcdef"; + const D: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + fn detail() -> Value { + json!({"version":1,"request_id":Q,"agent":"worker","project":"p","project_room_id":"!project:test","input_digest":D,"owner_mxid":A,"publisher_mxid":P}) + } + fn request() -> Value { + let mut d = detail(); + d["kind"] = json!("request"); + d["revision"] = json!(1); + d["expires_at"] = json!(5000); + d["actions"] = json!([{"id":"approve_once","label":"Approve once","style":"primary"},{"id":"deny","label":"Deny","style":"danger"}]); + json!({"msgtype":REQUEST_TYPE,KEY:d}) + } + fn status(rev: u64, state: &str, decision: Option<&str>) -> Value { + let mut d = detail(); + d["kind"] = json!("status"); + d["revision"] = json!(rev); + d["state"] = json!(state); + d["decision"] = decision.map_or(Value::Null, |v| json!(v)); + json!({"msgtype":STATUS_TYPE,KEY:d}) + } + fn put(s: &mut ApprovalSession, id: &str, v: &Value) -> IngestResult { + s.ingest_message(A, R, id, P, v, 1000) + } + fn ck() -> ApprovalClaimKey { + ApprovalClaimKey { + account_mxid: A.into(), + approval_room_id: R.into(), + request_id: Q.into(), + input_digest: D.into(), + } + } + fn marker() -> Value { + json!({"type":MARKER_TYPE,KEY:{"version":1,"binding_generation":1,"publisher_mxid":P,"owner_mxid":A,"agent":"worker","project_room_associations":[{"project_room_id":"!project:test","active":true}]}}) + } + + fn marker_v2() -> Value { + json!({ + "version": 2, + "binding_generation": 7, + "publisher_mxid": P, + "owner_mxid": A, + "project_room_associations": [ + {"agent":"probe","project_room_id":"!project1:test","active":true}, + {"agent":"claude","project_room_id":"!project1:test","active":true}, + {"agent":"claude","project_room_id":"!project2:test","active":true}, + {"agent":"codex","project_room_id":"!project2:test","active":true} + ] + }) + } + + #[test] + fn approval_state_accepts_exact_original_bindings_and_rejects_sender_owner_digest_and_action_mismatches() + { + let mut s = ApprovalSession::default(); + assert!(matches!( + put(&mut s, "$r", &request()), + IngestResult::Changed(_) + )); + assert!(s.is_actionable(A, R, Q, 1000)); + let mut bad = request(); + bad[KEY]["owner_mxid"] = json!("owner"); + assert!(matches!(put(&mut s, "$b", &bad), IngestResult::Rejected(_))); + let mut bad = request(); + bad[KEY]["input_digest"] = json!("abc"); + assert!(matches!(put(&mut s, "$b", &bad), IngestResult::Rejected(_))); + assert!(matches!( + s.ingest_message(A, R, "$b", "@other:test", &request(), 1000), + IngestResult::Rejected(_) + )); + assert!(!s.is_actionable("@viewer:test", R, Q, 1000)); + } + #[test] + fn approval_status_before_request_folds_to_the_highest_terminal_revision() { + let mut s = ApprovalSession::default(); + put(&mut s, "$s", &status(3, "consumed", Some("allow"))); + put(&mut s, "$r", &request()); + put(&mut s, "$old", &status(2, "approved", Some("allow"))); + let v = s.approval(A, R, Q).unwrap(); + assert_eq!(v.state, CanonicalApprovalState::Consumed); + assert_eq!(v.latest_revision, 3); + assert!(!s.is_actionable(A, R, Q, 1000)); + } + #[test] + fn equal_revision_conflict_fails_closed() { + let mut s = ApprovalSession::default(); + put(&mut s, "$r", &request()); + put(&mut s, "$a", &status(2, "approved", Some("allow"))); + assert!(matches!( + put(&mut s, "$d", &status(2, "denied", Some("deny"))), + IngestResult::Rejected(ApprovalModelError::RevisionConflict) + )); + assert!(s.approval(A, R, Q).unwrap().conflicted); + } + #[test] + fn legacy_status_is_retained_read_only() { + let mut v = status(1, "pending", None); + v[KEY]["migration_kind"] = json!("legacy_v1"); + let mut s = ApprovalSession::default(); + put(&mut s, "$l", &v); + assert!(s.approval(A, R, Q).unwrap().legacy_read_only); + assert!(!s.is_actionable(A, R, Q, 1000)); + } + #[test] + fn legacy_approval_relation_conflict_is_not_a_duplicate() { + let mut first = status(1, "consumed", Some("allow")); + first[KEY]["migration_kind"] = json!("legacy_v1"); + first["m.relates_to"] = json!({"m.in_reply_to":{"event_id":"$original"}}); + let mut other = first.clone(); + other["m.relates_to"]["m.in_reply_to"]["event_id"] = json!("$different"); + let mut session = ApprovalSession::default(); + assert!(matches!(put(&mut session, "$first-status", &first), IngestResult::Changed(_))); + assert!(matches!( + put(&mut session, "$other-status", &other), + IngestResult::Rejected(ApprovalModelError::RevisionConflict) + )); + assert!(session.approval(A, R, Q).unwrap().conflicted); + } + #[test] + fn legacy_approval_states_never_create_actionable_requests() { + for (state, decision) in [ + ("pending", None), ("approved", Some("allow")), ("denied", Some("deny")), + ("expired", Some("deny")), ("consumed", Some("allow")), ("consumed", Some("deny")), + ] { + let mut content = status(1, state, decision); + content[KEY]["migration_kind"] = json!("legacy_v1"); + content["m.relates_to"] = json!({"m.in_reply_to":{"event_id":"$original"}}); + let mut session = ApprovalSession::default(); + assert!(matches!(put(&mut session, "$status", &content), IngestResult::Changed(_))); + let view = session.approval(A, R, Q).unwrap(); + assert!(view.legacy_read_only); + assert_eq!(view.legacy_original_event_id.as_deref(), Some("$original")); + assert!(!session.is_actionable(A, R, Q, 1000)); + assert!(session.try_claim(ck(), ApprovalAction::ApproveOnce, 1000).is_err()); + assert!(session.try_claim(ck(), ApprovalAction::Deny, 1000).is_err()); + assert!(matches!(put(&mut session, "$late-native", &request()), IngestResult::Rejected(_))); + assert!(session.records.values().all(|record| !record.has_request)); + } + } + #[test] + fn legacy_approval_epoch_is_shared_by_two_pane_observers() { + let mut session = ApprovalSession::default(); + let pane_one_epoch = session.room_epoch(A, R); + let pane_two_epoch = session.room_epoch(A, R); + let mut content = status(1, "consumed", Some("allow")); + content[KEY]["migration_kind"] = json!("legacy_v1"); + content["m.relates_to"] = json!({"m.in_reply_to":{"event_id":"$original"}}); + put(&mut session, "$status", &content); + let pane_one_view = session.approval(A, R, Q).unwrap(); + assert!(session.room_epoch(A, R) > pane_one_epoch); + let pane_two_view = session.approval(A, R, Q).unwrap(); + assert!(session.room_epoch(A, R) > pane_two_epoch); + assert_eq!(pane_one_view, pane_two_view); + let after = session.room_epoch(A, R); + assert_eq!(put(&mut session, "$duplicate", &content), IngestResult::Duplicate); + assert_eq!(session.room_epoch(A, R), after); + } + #[test] + fn approval_navigation_requires_unique_joined_loaded_room() { + let mut i = ApprovalMarkerIndex::default(); + i.ingest_marker(A, R, "$m", MARKER_TYPE, P, "", &marker(), 1000).unwrap(); + let joined = HashSet::from([R.into()]); + assert_eq!( + i.resolve(A, "worker", "!project:test", &joined, 1000), + MarkerResolution::Unique(R.into()) + ); + assert_eq!( + i.resolve( + A, + "worker", + "!project:test", + &joined, + 1000 + MARKER_FRESHNESS_MS + 1 + ), + MarkerResolution::Stale + ); + assert_eq!( + i.resolve(A, "worker", "!project:test", &HashSet::new(), 1000), + MarkerResolution::NotJoined + ); + } + #[test] + fn persisted_marker_requires_revalidation() { + let mut i = ApprovalMarkerIndex::default(); + i.ingest_marker(A, R, "$m", MARKER_TYPE, P, "", &marker(), 1000).unwrap(); + let bytes = serde_json::to_vec(&i).unwrap(); + let restored: ApprovalMarkerIndex = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + restored.resolve( + A, + "worker", + "!project:test", + &HashSet::from([R.into()]), + 1000 + ), + MarkerResolution::Stale + ); + } + + #[test] + fn approval_marker_v2_shared_room_resolves_all_four_bindings() { + // Membership observation is deliberately absent from the marker wire + // model. `None` cannot remove a canonical binding-derived tuple. + let agent_joined_observation: Option = None; + assert_eq!(agent_joined_observation, None); + let mut index = ApprovalMarkerIndex::default(); + index + .ingest_marker(A, R, "$v2", MARKER_TYPE_V2, P, "", &marker_v2(), 1000) + .unwrap(); + let joined = HashSet::from([R.into()]); + + assert_eq!( + index.resolve(A, "claude", "!project2:test", &joined, 1000), + MarkerResolution::Unique(R.into()), + ); + assert_eq!( + index.resolve(A, "codex", "!project2:test", &joined, 1000), + MarkerResolution::Unique(R.into()), + ); + assert_eq!( + index.resolve(A, "probe", "!project1:test", &joined, 1000), + MarkerResolution::Unique(R.into()), + ); + } + + #[test] + fn approval_marker_v2_floor_blocks_v1_rollback_and_malformed_fallback() { + let mut index = ApprovalMarkerIndex::default(); + index.observe_v2(A, R).unwrap(); + assert!(index + .ingest_marker(A, R, "$v1", MARKER_TYPE, P, "", &marker(), 1000) + .is_err()); + assert_eq!( + index.resolve( + A, + "worker", + "!project:test", + &HashSet::from([R.into()]), + 1000, + ), + MarkerResolution::Missing, + ); + + index + .ingest_marker(A, R, "$v2", MARKER_TYPE_V2, P, "", &marker_v2(), 2000) + .unwrap(); + let mut malformed = marker_v2(); + malformed["binding_generation"] = json!(6); + assert!(index + .ingest_marker(A, R, "$old", MARKER_TYPE_V2, P, "", &malformed, 3000) + .is_err()); + assert_eq!( + index.resolve( + A, + "claude", + "!project2:test", + &HashSet::from([R.into()]), + 3000, + ), + MarkerResolution::Stale, + ); + } + + #[test] + fn persisted_v1_marker_cache_migrates_to_agent_project_tuple_unvalidated() { + let legacy = json!({ + "version": 1, + "markers": [{ + "account": A, + "room": R, + "event": "$legacy", + "publisher": P, + "owner": A, + "agent": "worker", + "generation": 3, + "associations": [{"project_room_id":"!project:test","active":true}], + "validated_at": 1000, + "conflicted": false + }] + }); + let index: ApprovalMarkerIndex = serde_json::from_value(legacy).unwrap(); + assert_eq!( + index.resolve( + A, + "worker", + "!project:test", + &HashSet::from([R.into()]), + 1000, + ), + MarkerResolution::Stale, + ); + let persisted = serde_json::to_value(index).unwrap(); + assert_eq!(persisted["version"], json!(2)); + assert_eq!(persisted["markers"][0]["associations"][0]["agent"], json!("worker")); + } + + #[test] + fn approval_marker_v2_rejects_duplicate_and_overflow_without_partial_routes() { + let mut index = ApprovalMarkerIndex::default(); + let mut duplicate = marker_v2(); + duplicate["project_room_associations"] = json!([ + {"agent":"claude","project_room_id":"!project:test","active":true}, + {"agent":"claude","project_room_id":"!project:test","active":false} + ]); + assert!(index + .ingest_marker(A, R, "$duplicate", MARKER_TYPE_V2, P, "", &duplicate, 1000) + .is_err()); + assert_eq!( + index.resolve(A, "claude", "!project:test", &HashSet::from([R.into()]), 1000), + MarkerResolution::Missing, + ); + + let mut overflow = marker_v2(); + overflow["project_room_associations"] = Value::Array( + (0..=MAX_V2_ASSOCIATIONS) + .map(|item| { + json!({ + "agent": format!("agent{item}"), + "project_room_id": format!("!project{item}:test"), + "active": true + }) + }) + .collect(), + ); + assert_eq!( + index.ingest_marker(A, R, "$overflow", MARKER_TYPE_V2, P, "", &overflow, 2000), + Err(ApprovalModelError::CapacityExceeded), + ); + assert_eq!( + index.resolve(A, "agent0", "!project0:test", &HashSet::from([R.into()]), 2000), + MarkerResolution::Missing, + ); + } + + #[test] + fn unknown_persisted_marker_cache_version_fails_closed() { + let restored: ApprovalMarkerIndex = serde_json::from_value(json!({ + "version": 99, + "markers": [{ + "account": A, + "room": R, + "owner": A, + "publisher": P, + "generation": 1, + "associations": [{ + "agent": "worker", + "project_room_id": "!project:test", + "active": true + }] + }] + })) + .unwrap(); + assert_eq!( + restored.resolve(A, "worker", "!project:test", &HashSet::from([R.into()]), 1000), + MarkerResolution::Missing, + ); + } + + #[test] + fn lower_marker_generation_invalidates_cached_high_water_mapping() { + let mut index = ApprovalMarkerIndex::default(); + let mut generation_two = marker(); + generation_two[KEY]["binding_generation"] = json!(2); + index + .ingest_marker(A, R, "$m2", MARKER_TYPE, P, "", &generation_two, 1000) + .unwrap(); + + let mut stale_generation = marker(); + stale_generation[KEY]["project_room_associations"][0]["active"] = json!(false); + assert_eq!( + index.ingest_marker(A, R, "$m1", MARKER_TYPE, P, "", &stale_generation, 2000), + Err(ApprovalModelError::RevisionConflict), + ); + assert_eq!( + index.resolve( + A, + "worker", + "!project:test", + &HashSet::from([R.into()]), + 2000, + ), + MarkerResolution::Stale, + ); + + index + .ingest_marker(A, R, "$m2-refresh", MARKER_TYPE, P, "", &generation_two, 3000) + .unwrap(); + assert_eq!( + index.resolve( + A, + "worker", + "!project:test", + &HashSet::from([R.into()]), + 3000, + ), + MarkerResolution::Unique(R.into()), + ); + } + #[test] + fn approval_verdict_outcome_keeps_unknown_claim_locked() { + let mut s = ApprovalSession::default(); + put(&mut s, "$r", &request()); + let k = ck(); + let before = s.room_epoch(A, R); + let g = s + .try_claim(k.clone(), ApprovalAction::ApproveOnce, 1000) + .unwrap(); + assert!(g.transaction_id.len() <= 128); + assert!(s.room_epoch(A, R) > before); + assert_eq!( + s.record_send_result(&k, SendResult::ConfirmedPreSendFailure, 1000), + ClaimDisposition::Released + ); + s.try_claim(k.clone(), ApprovalAction::ApproveOnce, 1000) + .unwrap(); + assert_eq!( + s.record_send_result(&k, SendResult::OutcomeUnknown, 1000), + ClaimDisposition::Retained + ); + assert_eq!(s.claim_state(&k), Some(ClaimState::OutcomeUnknown)); + assert_eq!( + s.record_send_result(&k, SendResult::ConfirmedPreSendFailure, 1000), + ClaimDisposition::Retained + ); + assert_eq!( + s.try_claim(k, ApprovalAction::ApproveOnce, 1000), + Err(ClaimError::AlreadyClaimed) + ); + } + #[test] + fn terminal_status_retires_claim_and_advances_room_epoch() { + let mut s = ApprovalSession::default(); + put(&mut s, "$r", &request()); + let before = s.room_epoch(A, R); + let k = ck(); + s.try_claim(k.clone(), ApprovalAction::Deny, 1000).unwrap(); + let changed = put(&mut s, "$s", &status(2, "denied", Some("deny"))); + assert!(matches!(changed, IngestResult::Changed(_))); + assert!(s.room_epoch(A, R) > before); + assert_eq!(s.claim_state(&k), None); + } + #[test] + fn same_revision_duplicate_ignores_body_and_timestamp() { + let mut s = ApprovalSession::default(); + put(&mut s, "$r", &request()); + let a = status(2, "approved", Some("allow")); + put(&mut s, "$a", &a); + let mut b = a.clone(); + b["body"] = json!("translated"); + b[KEY]["decided_at"] = json!(9999); + assert_eq!(put(&mut s, "$b", &b), IngestResult::Duplicate); + } + #[test] + fn lifecycle_helpers_are_account_scoped() { + let mut s = ApprovalSession::default(); + put(&mut s, "$r", &request()); + assert!(s.approval(A, R, Q).is_some()); + let key = ck(); + s.try_claim(key.clone(), ApprovalAction::Deny, 1000) + .unwrap(); + assert_eq!( + s.record_send_result(&key, SendResult::Sent, 1000), + ClaimDisposition::Retained + ); + s.remove_room(A, R); + assert!(s.approval(A, R, Q).is_none()); + put(&mut s, "$r2", &request()); + s.clear_account(A); + assert!(s.approval(A, R, Q).is_none()); + let mut index = ApprovalMarkerIndex::default(); + index + .ingest_marker(A, R, "$m", MARKER_TYPE, P, "", &marker(), 1000) + .unwrap(); + index.mark_room_unavailable(A, R); + assert_eq!( + index.resolve( + A, + "worker", + "!project:test", + &HashSet::from([R.into()]), + 1000 + ), + MarkerResolution::Stale + ); + index.remove_room(A, R); + assert_eq!( + index.resolve(A, "worker", "!project:test", &HashSet::new(), 1000), + MarkerResolution::Missing + ); + index + .ingest_marker(A, R, "$m2", MARKER_TYPE, P, "", &marker(), 1000) + .unwrap(); + index.clear_account(A); + assert_eq!( + index.resolve(A, "worker", "!project:test", &HashSet::new(), 1000), + MarkerResolution::Missing + ); + } + + proptest! { + #[test] + fn prop_invalid_binding_never_becomes_actionable(owner in "[^@!$][^\\n]{0,32}") { + let mut value = request(); + value[KEY]["owner_mxid"] = json!(owner); + let mut session = ApprovalSession::default(); + let _ = put(&mut session, "$r", &value); + prop_assert!(!session.is_actionable(A, R, Q, 1000)); + } + + #[test] + fn prop_status_order_preserves_highest_terminal(reverse in any::()) { + let mut session = ApprovalSession::default(); + put(&mut session, "$r", &request()); + let events = if reverse { + vec![ + status(3, "consumed", Some("allow")), + status(2, "approved", Some("allow")), + ] + } else { + vec![ + status(2, "approved", Some("allow")), + status(3, "consumed", Some("allow")), + ] + }; + for (index, value) in events.iter().enumerate() { + let _ = put(&mut session, &format!("$s{index}"), value); + } + prop_assert_eq!( + session.approval(A, R, Q).map(|view| view.state), + Some(CanonicalApprovalState::Consumed), + ); + } + + #[test] + fn prop_approval_marker_resolution_is_unique( + candidate_count in 0usize..4, + correct_owner in any::(), + joined in any::(), + fresh in any::(), + ) { + let mut index = ApprovalMarkerIndex::default(); + let mut joined_rooms = HashSet::new(); + for candidate in 0..candidate_count { + let room = format!("!approval{candidate}:test"); + let mut content = marker(); + if !correct_owner { + content[KEY]["owner_mxid"] = json!("@other:test"); + } + let result = index.ingest_marker( + A, + &room, + &format!("$marker{candidate}"), + MARKER_TYPE, + P, + "", + &content, + 1000, + ); + prop_assert_eq!(result.is_ok(), correct_owner); + if joined { + joined_rooms.insert(room); + } + } + let now = if fresh { 1000 } else { 1000 + MARKER_FRESHNESS_MS + 1 }; + let expected = match (candidate_count, correct_owner, fresh, joined) { + (0, _, _, _) | (_, false, _, _) => MarkerResolution::Missing, + (_, true, false, _) => MarkerResolution::Stale, + (1, true, true, false) => MarkerResolution::NotJoined, + (1, true, true, true) => MarkerResolution::Unique("!approval0:test".into()), + (_, true, true, _) => MarkerResolution::Ambiguous, + }; + prop_assert_eq!( + index.resolve(A, "worker", "!project:test", &joined_rooms, now), + expected, + ); + } + + #[test] + fn prop_approval_claim_never_releases_unknown_send(repeats in 1usize..20) { + let mut session = ApprovalSession::default(); + put(&mut session, "$r", &request()); + let key = ck(); + session.try_claim(key.clone(), ApprovalAction::ApproveOnce, 1000).unwrap(); + session.record_send_result(&key, SendResult::OutcomeUnknown, 1000); + for _ in 0..repeats { + prop_assert_eq!( + session.record_send_result( + &key, + SendResult::ConfirmedPreSendFailure, + 1000, + ), + ClaimDisposition::Retained, + ); + prop_assert_eq!( + session.try_claim(key.clone(), ApprovalAction::ApproveOnce, 1000), + Err(ClaimError::AlreadyClaimed), + ); + } + } + + #[test] + fn prop_repeated_claims_admit_at_most_one(attempts in 1usize..40) { + let mut session = ApprovalSession::default(); + put(&mut session, "$r", &request()); + let granted = (0..attempts) + .filter(|_| { + session + .try_claim(ck(), ApprovalAction::ApproveOnce, 1000) + .is_ok() + }) + .count(); + prop_assert_eq!(granted, 1); + } + } +} diff --git a/src/data_directory.rs b/src/data_directory.rs new file mode 100644 index 000000000..d95dd33bb --- /dev/null +++ b/src/data_directory.rs @@ -0,0 +1,38 @@ +//! Explicit profile isolation for local UI regression tests. + +use std::path::{Path, PathBuf}; + +pub(crate) fn resolve_app_data_dir(override_dir: Option<&Path>, default: &Path) -> Result { + match override_dir { + None => Ok(default.to_path_buf()), + Some(path) if path.is_absolute() => Ok(path.to_path_buf()), + Some(_) => Err("ROBRIX_DATA_DIR must be a non-empty absolute path".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn data_directory_override_is_explicit() { + let base = std::env::temp_dir(); + let default = base.join("robrix-default"); + let isolated = base.join("robrix-isolated"); + assert_eq!(resolve_app_data_dir(None, &default).unwrap(), default); + assert_eq!(resolve_app_data_dir(Some(isolated.as_path()), &default).unwrap(), isolated); + assert!(resolve_app_data_dir(Some(Path::new("relative")), &default).is_err()); + assert!(resolve_app_data_dir(Some(Path::new("")), &default).is_err()); + } + + proptest::proptest! { + #[test] + fn prop_data_directory_never_implicitly_changes_default(name in "[a-zA-Z0-9_-]{1,32}") { + let base = std::env::temp_dir().join("robrix-default"); + let isolated = std::env::temp_dir().join(name); + proptest::prop_assert_eq!(resolve_app_data_dir(None, &base).unwrap(), base.clone()); + proptest::prop_assert_eq!(resolve_app_data_dir(Some(&isolated), &base).unwrap(), isolated); + } + } +} diff --git a/src/event_preview.rs b/src/event_preview.rs index a80bf5810..4bbb0b721 100644 --- a/src/event_preview.rs +++ b/src/event_preview.rs @@ -478,10 +478,7 @@ fn text_preview_of_message( htmlize::escape_text(&video.body) } ), - MessageType::_Custom(custom) => format!( - "[Custom message]: {:?}", - custom, - ), + MessageType::_Custom(_) => htmlize::escape_text(msg.body()).to_string(), other => format!( "[Unknown message type]: {}", htmlize::escape_text(other.body()), @@ -834,3 +831,34 @@ pub fn text_preview_of_room_membership_change( }; Some(TextPreview::from((text, BeforeText::UsernameWithoutColon))) } + +#[cfg(test)] +mod preview_tests { + use super::*; + use proptest::prelude::*; + + fn custom_message(body: &str, detail: &str) -> MessageType { + serde_json::from_value(serde_json::json!({ + "msgtype": "com.agentchat.approval.status.v1", + "body": body, + "com.agentchat.approval": { "kind": "status", "internal_detail": detail }, + })).expect("valid custom room message") + } + + #[test] + fn custom_message_preview_shows_escaped_body_without_protocol_details() { + let message = custom_message("等待 & approval", "PRIVATE_SENTINEL"); + let preview = text_preview_of_message(&message, "agent").text; + assert_eq!(preview, "等待 <owner> & approval"); + assert!(!preview.contains("PRIVATE_SENTINEL")); + assert!(!preview.contains("CustomEventContent")); + } + + proptest! { + #[test] + fn prop_custom_preview_depends_only_on_escaped_body(body in any::(), detail in any::()) { + let preview = text_preview_of_message(&custom_message(&body, &detail), "agent").text; + prop_assert_eq!(preview, htmlize::escape_text(&body).to_string()); + } + } +} diff --git a/src/home/directory_screen.rs b/src/home/directory_screen.rs index c028c50e2..83bdd4be0 100644 --- a/src/home/directory_screen.rs +++ b/src/home/directory_screen.rs @@ -397,7 +397,7 @@ script_mod! { room_list := PortalList { width: Fill, height: Fill keep_invisible: false, - max_pull_down: 0.0, + bounce_at_start: false, auto_tail: false, flow: Down margin: Inset{top: (SPACE_XS)} diff --git a/src/home/global_message_search.rs b/src/home/global_message_search.rs index 71beca73a..823e7a109 100644 --- a/src/home/global_message_search.rs +++ b/src/home/global_message_search.rs @@ -133,7 +133,7 @@ script_mod! { width: Fill, height: Fill, flow: Down, - max_pull_down: 0.0, + bounce_at_start: false, section_header := mod.widgets.GlobalSearchSectionHeader {} hit_row := mod.widgets.GlobalSearchHitRow {} diff --git a/src/home/home_screen.rs b/src/home/home_screen.rs index b9537e503..74e69aa24 100644 --- a/src/home/home_screen.rs +++ b/src/home/home_screen.rs @@ -549,7 +549,7 @@ impl Widget for HomeScreen { // the focused room can never open behind an overlay page. if matches!( action.as_widget_action().cast_ref(), - RoomsListAction::Selected(_), + RoomsListAction::Selected(_) | RoomsListAction::OpenPendingApprovals(_), ) { let next_selection = selection_after_opening_room(&app_state.selected_tab); if next_selection != app_state.selected_tab { diff --git a/src/home/main_desktop_ui.rs b/src/home/main_desktop_ui.rs index 0f8b3f253..bda38c50a 100644 --- a/src/home/main_desktop_ui.rs +++ b/src/home/main_desktop_ui.rs @@ -777,6 +777,14 @@ impl WidgetMatchEvent for MainDesktopUI { // a redraw to be happening in order to draw the tab content. self.focus_or_create_tab(cx, selected_room.clone()); } + RoomsListAction::OpenPendingApprovals(selected_room) => { + self.focus_or_create_tab(cx, selected_room.clone()); + if let Some(widget) = self.tab_widget(cx, selected_room.tab_id()) { + widget + .as_room_screen() + .show_latest_approval(cx, selected_room.room_id()); + } + } RoomsListAction::InviteAccepted { room_name_id } => { self.replace_invite_with_joined_room(cx, scope, room_name_id); } diff --git a/src/home/main_mobile_ui.rs b/src/home/main_mobile_ui.rs index d34ed3914..1e8a9b34e 100644 --- a/src/home/main_mobile_ui.rs +++ b/src/home/main_mobile_ui.rs @@ -59,6 +59,8 @@ impl Widget for MainMobileUI { match action.as_widget_action().cast() { // This is currently handled in the top-level App. RoomsListAction::Selected(_selected_room) => {} + // The top-level App owns the room-bound approval scroll intent. + RoomsListAction::OpenPendingApprovals(_selected_room) => {} // Because the MainMobileUI is drawn based on the AppState only, // all we need to do is update the AppState here. RoomsListAction::InviteAccepted { room_name_id: room_name } => { diff --git a/src/home/room_screen/action_scope.rs b/src/home/room_screen/action_scope.rs new file mode 100644 index 000000000..56c8db52f --- /dev/null +++ b/src/home/room_screen/action_scope.rs @@ -0,0 +1,76 @@ +//! Widget-instance ownership for room controls shared by multiple cached tabs. + +use makepad_widgets::*; + +pub(super) fn room_control_action<'a>(action: &'a Action, owners: &[WidgetUid]) -> Option<&'a WidgetAction> { + let action = action.as_widget_action()?; + (action.widget_uid.0 != 0 && owners.contains(&action.widget_uid)).then_some(action) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::home::room_screen::ThreadsPaneAction; + use crate::home::search_messages::SearchMessagesAction; + use matrix_sdk::ruma::OwnedEventId; + use proptest::prelude::*; + + fn emitted_by(uid: u64, payload: impl WidgetActionTrait) -> Action { + Box::new(WidgetAction { + data: None, + action: Box::new(payload), + widget_uid: WidgetUid(uid), + group: None, + }) + } + + #[test] + fn room_control_action_keeps_thread_selection_in_its_own_pane() { + let root = OwnedEventId::try_from("$codex-root").unwrap(); + let action = emitted_by(11, ThreadsPaneAction::OpenThread(root.clone())); + // Main and two thread tabs share a Matrix room, but never a widget UID. + for (pane, uid) in [("Claude thread", 12), ("Codex thread", 13), ("other room", 21)] { + assert!(room_control_action(&action, &[WidgetUid(uid)]).is_none(), "{pane} accepted another pane's jump"); + } + let selected = room_control_action(&action, &[WidgetUid(11)]).unwrap(); + assert!(matches!(selected.cast_ref::(), ThreadsPaneAction::OpenThread(event) if event == &root)); + assert_eq!(action.as_widget_action().unwrap().widget_uid, WidgetUid(11)); + } + + #[test] + fn room_control_action_accepts_local_search_controls_only() { + let local = [WidgetUid(30), WidgetUid(31), WidgetUid(32)]; + let other = [WidgetUid(40), WidgetUid(41), WidgetUid(42)]; + for source in local { + let action = emitted_by(source.0, SearchMessagesAction::OpenRequested); + assert!(matches!(room_control_action(&action, &local).cast_ref(), SearchMessagesAction::OpenRequested)); + assert!(room_control_action(&action, &other).is_none()); + } + } + + #[test] + fn room_control_action_rejects_missing_or_zero_ownership() { + let plain: Action = Box::new(()); + assert!(room_control_action(&plain, &[WidgetUid(1)]).is_none()); + assert!(room_control_action(&emitted_by(1, ThreadsPaneAction::CloseRequested), &[]).is_none()); + assert!(room_control_action(&emitted_by(0, ThreadsPaneAction::CloseRequested), &[WidgetUid(0)]).is_none()); + } + + proptest! { + #[test] + fn prop_room_control_action_matches_explicit_owner_set( + source in prop_oneof![Just(0), any::()], + mut owners in prop::collection::vec(any::(), 0..8), + include_source in any::(), + ) { + if include_source { + owners.push(source); + } + let expected = source != 0 && owners.iter().any(|owner| *owner == source); + let action = emitted_by(source, ThreadsPaneAction::LoadMoreRequested); + let owners = owners.into_iter().map(WidgetUid).collect::>(); + prop_assert_eq!(room_control_action(&action, &owners).is_some(), expected); + } + } + +} diff --git a/src/home/room_screen/dsl.rs b/src/home/room_screen/dsl.rs index 5eaf5dfe7..36490480b 100644 --- a/src/home/room_screen/dsl.rs +++ b/src/home/room_screen/dsl.rs @@ -254,7 +254,7 @@ script_mod! { flow: Down auto_tail: true, // set to `true` to lock the view to the last item. - max_pull_down: 0.0, // set to `0.0` to disable the pulldown bounce animation. + bounce_at_start: false, // disable the pull-down bounce animation. // TODO: enable `reuse_items: true` once Makepad's Html/TextFlow widget // properly resets all internal state during `script_apply(Reload)`. // Currently, stale TextFlow layout state (particularly related to diff --git a/src/home/room_screen/interactions.rs b/src/home/room_screen/interactions.rs index 5900da01b..a04627efd 100644 --- a/src/home/room_screen/interactions.rs +++ b/src/home/room_screen/interactions.rs @@ -439,8 +439,8 @@ impl RoomScreen { scope: &mut Scope, ) { // Splash-built action buttons report clicks via `agent.notify` from - // their isolate VM; the payload carries (source event id, slot index), - // which keys `octos_action_button_contexts`. + // their isolate VM. Bind the notification to the exact pane and current + // recycled item as well as the source event and slot. if let Some(clicked_context) = actions .iter() .find_map(|action| { @@ -453,12 +453,19 @@ impl RoomScreen { let parsed: serde_json::Value = serde_json::from_str(&payload).ok()?; let source_event_id = OwnedEventId::try_from(parsed.get("source")?.as_str()?).ok()?; - let slot = parsed.get("slot")?.as_u64()? as usize; - self.octos_action_button_contexts - .get(&(source_event_id, slot)) - .cloned() + let slot = usize::try_from(parsed.get("slot")?.as_u64()?).ok()?; + let context = self.octos_action_button_contexts.get(&(source_event_id.clone(), slot))?; + approval_action_notification_matches(&parsed, self.widget_uid().0, + context.item_widget_uid.0, source_event_id.as_str(), slot) + .then(|| context.clone()) }) { + if let OctosActionButtonRequest::OpenPendingApprovals { agent, project_room_id, .. } = &clicked_context.request { + cx.action(AppStateAction::OpenPendingApprovals { + agent: agent.clone(), project_room_id: project_room_id.clone(), + }); + return; + } if clicked_context.request.is_expired(current_unix_time_millis()) { mark_action_buttons_disabled( &mut self.disabled_octos_action_source_event_ids, @@ -481,6 +488,7 @@ impl RoomScreen { ) { let Some(tl) = self.tl_state.as_ref() else { return }; let request = match &clicked_context.request { + OctosActionButtonRequest::OpenPendingApprovals { .. } => return, OctosActionButtonRequest::Generic { action_id, label, .. } => build_octos_action_response_request( &tl.kind, label, @@ -516,17 +524,42 @@ impl RoomScreen { } } }; + let mut approval_claim = None; + let mut transaction_id = None; + if matches!(clicked_context.request, OctosActionButtonRequest::Approval { + protocol: ApprovalProtocol::AgentChat { .. }, .. + }) { + let (Some(account), Some(app_state)) = (current_user_id(), scope.data.get::()) else { return }; + let Some((key, action)) = approval_claim_for_context(&clicked_context, &account, tl.kind.room_id()) else { return }; + let Ok(mut session) = app_state.approval_session.lock() else { return }; + let Ok(grant) = session.try_claim(key.clone(), action, current_unix_time_millis()) else { + drop(session); + self.invalidate_timeline_event_content(clicked_context.source_event_id.as_ref()); + self.redraw_timeline_list(cx); + return; + }; + transaction_id = Some(ruma::OwnedTransactionId::from(grant.transaction_id)); + approval_claim = Some(key); + drop(session); + cx.action(AppStateAction::ApprovalRoomStateChanged { + account_mxid: account, room_id: tl.kind.room_id().clone(), + }); + } mark_action_buttons_disabled( &mut self.disabled_octos_action_source_event_ids, &clicked_context.source_event_id, ); - mark_selected_octos_action( - &mut self.selected_octos_action_by_source_event_id, - &clicked_context.source_event_id, - clicked_context.request.action_id(), - clicked_context.request.label(), - clicked_context.request.style(), - ); + // Native approvals render only the canonical shared state. A + // local checkmark would incorrectly imply backend approval. + if approval_claim.is_none() { + mark_selected_octos_action( + &mut self.selected_octos_action_by_source_event_id, + &clicked_context.source_event_id, + clicked_context.request.action_id(), + clicked_context.request.label(), + clicked_context.request.style(), + ); + } self.invalidate_timeline_event_content( clicked_context.source_event_id.as_ref(), ); @@ -537,6 +570,8 @@ impl RoomScreen { target_user_id: request.target_user_id, explicit_room: request.explicit_room, source_event_id: request.source_event_id, + approval_claim, + transaction_id, }); } return; diff --git a/src/home/room_screen/message.rs b/src/home/room_screen/message.rs index ef3a0cbd1..bf85449a6 100644 --- a/src/home/room_screen/message.rs +++ b/src/home/room_screen/message.rs @@ -746,7 +746,7 @@ script_mod! { visible: false width: Fill height: Fit - flow: Flow.Right{wrap: true} + flow: Down spacing: 8.0 // Octos action buttons are built dynamically in this @@ -925,7 +925,7 @@ script_mod! { visible: false width: Fill height: Fit - flow: Flow.Right{wrap: true} + flow: Down spacing: 8.0 // Octos action buttons are built dynamically in this diff --git a/src/home/room_screen/mod.rs b/src/home/room_screen/mod.rs index c59dd4da2..6e2e4d5f2 100644 --- a/src/home/room_screen/mod.rs +++ b/src/home/room_screen/mod.rs @@ -27,6 +27,7 @@ use ruma::{OwnedUserId, api::client::receipt::create_receipt::v3::ReceiptType, e use matrix_sdk_ui::sync_service::State; use crate::{ + approval_state::ApprovalClaimKey, app::{AppState, AppStateAction, BotSettingsState, ConfirmDeleteAction, PositiveConfirmationModalAction, SelectedRoom}, avatar_cache, event_preview::{plaintext_body_of_timeline_item, text_preview_of_encrypted_message, text_preview_of_member_profile_change, text_preview_of_other_message_like, text_preview_of_other_state, text_preview_of_room_membership_change, text_preview_of_timeline_item}, home::{bot_binding_modal::BotBindingModalAction, create_bot_modal::{CreateBotModalAction, CreateBotModalWidgetExt}, delete_bot_modal::{DeleteBotModalAction, DeleteBotModalWidgetExt}, edited_indicator::EditedIndicatorWidgetRefExt, encryption_notice::{EncryptionNoticeWidgetRefExt, first_other_member_display_name}, invite_modal::InviteModalAction, link_preview::{LinkPreviewCache, LinkPreviewRef, LinkPreviewWidgetRefExt}, loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, room_image_viewer::{get_image_name_and_filesize, populate_matrix_image_modal}, rooms_list::{RoomsListAction, RoomsListRef}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails}, i18n::{AppLanguage, tr_fmt, tr_key}, media_cache::{MediaCache, MediaCacheEntry}, profile::{ user_profile::{ShowUserProfileAction, UserProfile, UserProfileAndRoomId, UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt}, user_profile_cache, @@ -58,6 +59,7 @@ use rangemap::RangeSet; use super::{ContextMenuOpenGesture, event_reaction_list::ReactionData, invite_modal::is_invite_modal_open, loading_pane::LoadingPaneRef, new_message_context_menu::{MessageAbilities, MessageDetails}, room_read_receipt::{self, populate_read_receipts, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT}}; +mod action_scope; mod bot_admin; mod bot_message; mod dsl; @@ -74,6 +76,7 @@ mod thread_lifecycle; mod threads_pane; mod updates; +use action_scope::room_control_action; pub use bot_admin::*; use bot_message::*; pub use message::*; @@ -135,6 +138,35 @@ fn item_event_id(item: &Arc) -> Option<&EventId> { event.event_id() } +#[derive(Default)] +struct LatestApprovalNavigation { + room_id: Option, +} + +impl LatestApprovalNavigation { + fn request(&mut self, displayed: Option<&TimelineKind>, requested_room: &RoomId) -> bool { + if !matches!( + displayed, + Some(TimelineKind::MainRoom { room_id }) if room_id == requested_room + ) { + return false; + } + self.room_id = Some(requested_room.to_owned()); + true + } + + fn resolve(&mut self, displayed: Option<&TimelineKind>, item_count: usize) -> Option { + let requested_room = self.room_id.as_deref()?; + if !matches!(displayed, Some(TimelineKind::MainRoom { room_id }) if room_id == requested_room) { + self.room_id = None; + return None; + } + let last_index = item_count.checked_sub(1)?; + self.room_id = None; + Some(last_index) + } +} + /// Registers this module's DSL blocks in dependency order: the sliding /// panes, the report modal, and the app-service panel first — the /// `RoomScreen` template in `dsl` references them via `mod.widgets.*`, and a @@ -162,6 +194,9 @@ pub struct RoomScreen { #[rust] timeline_kind: Option, /// The persistent UI-relevant states for the room that this widget is currently displaying. #[rust] tl_state: Option, + /// Exact room for a one-shot public-CTA request to show the latest private + /// approval. Kept on the addressed RoomScreen, never as ambient app state. + #[rust] latest_approval_navigation: LatestApprovalNavigation, /// Whether this RoomScreen is currently visible and should consume room-specific signals. #[rust] timeline_updates_enabled: bool, /// Restarts paused streaming timers on the first signal after becoming visible. @@ -211,6 +246,8 @@ pub struct RoomScreen { #[rust] octos_action_button_contexts: HashMap<(OwnedEventId, usize), OctosActionButtonContext>, #[rust] disabled_octos_action_source_event_ids: HashSet, #[rust] selected_octos_action_by_source_event_id: HashMap, + #[rust] approval_render_epoch: u64, + #[rust] approval_fold_pending: bool, /// Per-room state for the server-side search pane. Tracks the active /// query, the room it targets, the most recent `next_batch` token, and /// whether a request is currently in flight. @@ -645,7 +682,7 @@ impl Widget for RoomScreen { } // Mobile RoomTopBar (header + Chat/Info tabs) actions. - match action.as_widget_action().cast::() { + match room_control_action(action, &[self.room_top_bar(cx, ids!(room_top_bar)).widget_uid()]).cast::() { RoomTopBarAction::Back => { cx.widget_action(room_screen_widget_uid, StackNavigationAction::Pop); } @@ -804,22 +841,47 @@ impl Widget for RoomScreen { }); } } - if let Some(ActionResponseResultAction::Failed { room_id, source_event_id, error }) = action.downcast_ref() { + if let Some(ActionResponseResultAction::Failed { + room_id, + source_event_id, + error, + approval_claim, + outcome_unknown, + }) = action.downcast_ref() { if self.room_name_id.as_ref().is_some_and(|rn| rn.room_id() == room_id) { - clear_action_buttons_disabled( - &mut self.disabled_octos_action_source_event_ids, - source_event_id.as_ref(), - ); - clear_selected_octos_action( - &mut self.selected_octos_action_by_source_event_id, - source_event_id.as_ref(), - ); + let should_restore = approval_claim.as_ref().is_none_or(|claim| { + if *outcome_unknown { + return false; + } + scope.data.get::() + .and_then(|state| state.approval_session.lock().ok()) + .is_some_and(|session| session.is_actionable( + &claim.account_mxid, + &claim.approval_room_id, + &claim.request_id, + current_unix_time_millis(), + )) + }); + if should_restore { + clear_action_buttons_disabled( + &mut self.disabled_octos_action_source_event_ids, + source_event_id.as_ref(), + ); + clear_selected_octos_action( + &mut self.selected_octos_action_by_source_event_id, + source_event_id.as_ref(), + ); + } self.invalidate_timeline_event_content(source_event_id.as_ref()); self.redraw_timeline_list(cx); enqueue_popup_notification( tr_fmt( self.app_language, - "room_screen.popup.action_response.failed", + if *outcome_unknown { + "room_screen.popup.action_response.outcome_unknown" + } else { + "room_screen.popup.action_response.failed" + }, &[("error", error.as_str())], ), PopupKind::Error, @@ -828,12 +890,9 @@ impl Widget for RoomScreen { } } - // No `widget_uid_eq` filter here — `OpenThread` is emitted from - // a `ThreadsPaneEntry` (a list item), not from the pane itself, - // so its widget_uid is the entry's. `LoadMoreRequested` and - // `CloseRequested` come from the pane, but `cast_ref` handles - // all three regardless of emitter. - match action.as_widget_action().cast_ref::() { + // Entries emit under their pane's UID so cached room/thread + // tabs only handle selections from their own pane. + match room_control_action(action, &[threads_sliding_pane.widget_uid()]).cast_ref::() { ThreadsPaneAction::OpenThread(thread_root_event_id) => { log!("RoomScreen: OpenThread received, jumping to {}", thread_root_event_id); threads_sliding_pane.hide(cx); @@ -1001,7 +1060,7 @@ impl Widget for RoomScreen { // Floating threads button click → open the threads sliding pane. for action in actions { - if let ThreadsButtonAction::OpenRequested = action.as_widget_action().cast_ref() { + if let ThreadsButtonAction::OpenRequested = room_control_action(action, &[self.view.threads_button(cx, ids!(timeline.threads_button)).widget_uid()]).cast_ref() { self.show_threads_pane(cx); break; } @@ -1010,7 +1069,7 @@ impl Widget for RoomScreen { // Floating info button click → open the room info sliding pane // (desktop only — the button is hidden on mobile). for action in actions { - if let InfoButtonAction::OpenRequested = action.as_widget_action().cast_ref() { + if let InfoButtonAction::OpenRequested = room_control_action(action, &[self.view.widget(cx, ids!(timeline.info_button)).widget_uid()]).cast_ref() { self.show_room_info_pane(cx, scope.data.get::()); break; } @@ -1558,6 +1617,17 @@ impl Widget for RoomScreen { } self.last_has_encryption_notice = Some(has_encryption_notice); + let approval_session = scope.data.get::().map(|state| state.approval_session.clone()); + if let (Some(session), Some(account), Some(tl)) = (&approval_session, current_user_id(), self.tl_state.as_mut()) { + if let Ok(session) = session.lock() { + let epoch = session.room_epoch(account.as_str(), tl.kind.room_id().as_str()); + if epoch != self.approval_render_epoch { + self.approval_render_epoch = epoch; + tl.content_drawn_since_last_update.clear(); + self.octos_action_button_contexts.clear(); + } + } + } let mut room_scope = if let Some(app_state) = scope.data.get_mut::() { Scope::with_data_props(app_state, &room_props) } else { @@ -1700,6 +1770,7 @@ impl Widget for RoomScreen { &self.disabled_octos_action_source_event_ids, &self.selected_octos_action_by_source_event_id, &tl_state.expanded_bot_body_event_ids, + approval_session.as_ref(), ); action_contexts_changed |= contexts_rebound; (item, drawn_status) @@ -2208,6 +2279,8 @@ impl RoomScreen { self.octos_action_button_contexts.clear(); self.disabled_octos_action_source_event_ids.clear(); self.selected_octos_action_by_source_event_id.clear(); + self.approval_render_epoch = 0; + self.approval_fold_pending = false; self.set_timeline_updates_enabled(false); self.save_state(); @@ -2445,6 +2518,38 @@ impl RoomScreen { self.show_timeline(cx); } + fn apply_latest_approval_request(&mut self, cx: &mut Cx) -> bool { + let item_count = self.tl_state.as_ref().map_or(0, |timeline| timeline.items.len()); + let Some(last_index) = self + .latest_approval_navigation + .resolve(self.timeline_kind.as_ref(), item_count) + else { + return false; + }; + + let has_encryption_notice = self.current_has_encryption_notice(cx); + let portal_list = self.portal_list(cx, ids!(timeline.list)); + portal_list.set_first_id_and_scroll( + item_id_from_tl_idx(last_index, has_encryption_notice), + 0.0, + ); + portal_list.set_tail_range(true); + self.jump_to_bottom_button(cx, ids!(jump_to_bottom_button)) + .update_visibility(cx, true); + self.redraw(cx); + true + } + + pub fn show_latest_approval(&mut self, cx: &mut Cx, room_id: &RoomId) { + if !self.latest_approval_navigation.request( + self.timeline_kind.as_ref(), + room_id, + ) { + return; + } + self.apply_latest_approval_request(cx); + } + /// Sends read receipts based on the current scroll position of the timeline. fn send_user_read_receipts_based_on_scroll_pos( &mut self, @@ -2568,6 +2673,11 @@ impl RoomScreenRef { let Some(mut inner) = self.borrow_mut() else { return }; inner.set_timeline_updates_enabled(enabled); } + + pub fn show_latest_approval(&self, cx: &mut Cx, room_id: &RoomId) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.show_latest_approval(cx, room_id); + } } /// Returns info about the item in the list of `new_items` that matches the event ID @@ -2695,11 +2805,14 @@ pub enum ActionResponseResultAction { Sent { room_id: OwnedRoomId, source_event_id: OwnedEventId, + approval_claim: Option, }, Failed { room_id: OwnedRoomId, source_event_id: OwnedEventId, error: String, + approval_claim: Option, + outcome_unknown: bool, }, } @@ -2729,6 +2842,51 @@ mod tests { assert_eq!(item_id_from_tl_idx(6, false), 6); } + #[test] + fn latest_approval_navigation_is_room_bound_deferred_and_one_shot() { + let approval_room = OwnedRoomId::try_from("!approvals:example.org").unwrap(); + let other_room = OwnedRoomId::try_from("!other:example.org").unwrap(); + let approval_timeline = TimelineKind::MainRoom { + room_id: approval_room.clone(), + }; + let other_timeline = TimelineKind::MainRoom { + room_id: other_room.clone(), + }; + let approval_thread = TimelineKind::Thread { + room_id: approval_room.clone(), + thread_root_event_id: OwnedEventId::try_from("$thread:example.org").unwrap(), + }; + let mut navigation = LatestApprovalNavigation::default(); + + assert!(!navigation.request(Some(&other_timeline), &approval_room)); + assert!(!navigation.request(Some(&approval_thread), &approval_room)); + assert_eq!(navigation.resolve(Some(&approval_timeline), 3), None); + + assert!(navigation.request(Some(&approval_timeline), &approval_room)); + assert_eq!(navigation.resolve(Some(&approval_timeline), 0), None); + assert_eq!(navigation.resolve(Some(&approval_timeline), 4), Some(3)); + assert_eq!(navigation.resolve(Some(&approval_timeline), 4), None); + + assert!(navigation.request(Some(&approval_timeline), &approval_room)); + assert_eq!(navigation.resolve(Some(&other_timeline), 4), None); + assert_eq!(navigation.resolve(Some(&approval_timeline), 4), None); + } + + #[test] + fn deferred_approval_navigation_rejects_same_room_thread_reuse() { + let room_id = OwnedRoomId::try_from("!approvals:example.org").unwrap(); + let main = TimelineKind::MainRoom { room_id: room_id.clone() }; + let thread = TimelineKind::Thread { + room_id: room_id.clone(), + thread_root_event_id: OwnedEventId::try_from("$thread:example.org").unwrap(), + }; + let mut navigation = LatestApprovalNavigation::default(); + assert!(navigation.request(Some(&main), &room_id)); + assert_eq!(navigation.resolve(Some(&main), 0), None); + assert_eq!(navigation.resolve(Some(&thread), 5), None); + assert_eq!(navigation.resolve(Some(&main), 5), None); + } + #[test] fn test_room_members_fetch_updates_rooms_list_member_ids() { let src = include_str!("mod.rs"); diff --git a/src/home/room_screen/octos_actions.rs b/src/home/room_screen/octos_actions.rs index f06b33bdb..fe286a022 100644 --- a/src/home/room_screen/octos_actions.rs +++ b/src/home/room_screen/octos_actions.rs @@ -3,6 +3,9 @@ //! populate pass. use super::*; +use crate::approval_state::{ + ApprovalAction, ApprovalClaimKey, ApprovalSession, ApprovalView, CanonicalApprovalState, ClaimState, +}; pub(super) const MAX_OCTOS_ACTION_BUTTONS: usize = 6; pub(super) const AGENTCHAT_APPROVAL_EVENT_KEY: &str = "com.agentchat.approval"; @@ -323,7 +326,11 @@ pub(super) fn parse_agentchat_approval_request_from_content( title: tool_name.to_owned(), summary, risk_level: OctosApprovalRiskLevel::Normal, - authorized_approvers: Vec::new(), + authorized_approvers: approval.get("owner_mxid") + .and_then(serde_json::Value::as_str) + .filter(|owner| UserId::parse(*owner).is_ok()) + .map(|owner| vec![owner.to_owned()]) + .unwrap_or_default(), expires_at: expires_at.to_string(), on_timeout: OctosApprovalTimeoutBehavior::Notify, }) @@ -423,6 +430,13 @@ pub(super) fn parse_octos_action_payload_for_render( .map(parse_agentchat_approval_actions_from_detail) .unwrap_or_default(), } + } else if original_content.is_some_and(|content| matches!( + content.get("msgtype").and_then(serde_json::Value::as_str), + Some(AGENTCHAT_APPROVAL_STATUS_MSGTYPE | AGENTCHAT_APPROVAL_VERDICT_MSGTYPE) + )) { + // State projections never supply verdict or generic action controls. + // Navigation is a separate local presentation of the public notice. + Vec::new() } else { content .map(parse_octos_actions_from_content) @@ -542,6 +556,11 @@ pub(super) struct OctosActionResponseRequest { #[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum OctosActionButtonRequest { + OpenPendingApprovals { + agent: String, + project_room_id: OwnedRoomId, + label: String, + }, Generic { action_id: String, label: String, @@ -562,6 +581,7 @@ pub(super) enum OctosActionButtonRequest { impl OctosActionButtonRequest { pub(super) fn action_id(&self) -> &str { match self { + Self::OpenPendingApprovals { .. } => "view_pending_approvals", Self::Generic { action_id, .. } => action_id, Self::Approval { decision, .. } => decision, } @@ -569,6 +589,7 @@ impl OctosActionButtonRequest { pub(super) fn label(&self) -> &str { match self { + Self::OpenPendingApprovals { label, .. } => label, Self::Generic { label, .. } => label, Self::Approval { label, .. } => label, } @@ -576,13 +597,14 @@ impl OctosActionButtonRequest { pub(super) fn style(&self) -> OctosActionStyle { match self { + Self::OpenPendingApprovals { .. } => OctosActionStyle::Secondary, Self::Generic { style, .. } | Self::Approval { style, .. } => *style, } } fn expiry_millis(&self) -> Option { match self { - Self::Generic { .. } => None, + Self::Generic { .. } | Self::OpenPendingApprovals { .. } => None, Self::Approval { protocol, expires_at, .. } => { approval_expiry_millis(protocol, expires_at) } @@ -736,10 +758,9 @@ pub(super) fn local_user_can_approve( ApprovalProtocol::Octos => approval_request.authorized_approvers .iter() .any(|approver| approver == current_user_id.as_str()), - // The dedicated encrypted approval room only contains the owner and - // managed service accounts. This enables the UI affordance, but is not - // an authorization decision: agent-chat still validates event.sender. - ApprovalProtocol::AgentChat { .. } => true, + ApprovalProtocol::AgentChat { .. } => approval_request.authorized_approvers + .iter() + .any(|owner| owner == current_user_id.as_str()), } } @@ -785,6 +806,119 @@ pub(super) fn are_action_buttons_disabled( disabled_source_event_ids.contains(source_event_id) } +pub(super) fn approval_action_notification_matches( + payload: &serde_json::Value, + pane: u64, + item: u64, + source: &str, + slot: usize, +) -> bool { + pane != 0 && item != 0 + && payload.get("pane").and_then(serde_json::Value::as_str) == Some(pane.to_string().as_str()) + && payload.get("item").and_then(serde_json::Value::as_str) == Some(item.to_string().as_str()) + && payload.get("source").and_then(serde_json::Value::as_str) == Some(source) + && payload.get("slot").and_then(serde_json::Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) == Some(slot) +} + +/// The destination is always derived from private local discovery. Public +/// payloads with additional approval fields cannot become a navigation control. +pub(super) fn public_approval_notice_agent(content: &serde_json::Value) -> Option<&str> { + if content.get("msgtype")?.as_str()? != AGENTCHAT_APPROVAL_STATUS_MSGTYPE { return None; } + let detail = content.get(AGENTCHAT_APPROVAL_EVENT_KEY)?.as_object()?; + if detail.len() != 5 + || detail.get("version")?.as_u64()? != 1 + || detail.get("kind")?.as_str()? != "status" + || detail.get("state")?.as_str()? != "waiting_for_owner" + || detail.get("project")?.as_str()?.is_empty() + { return None; } + let agent = detail.get("agent")?.as_str()?; + (!agent.is_empty() && agent.len() <= 128 && agent.trim() == agent).then_some(agent) +} + +pub(super) fn fold_approval_events( + app_state: Option<&AppState>, + room: &RoomId, + items: &Vector>, +) -> bool { + let (Some(app_state), Some(account)) = (app_state, current_user_id()) else { return false }; + let Ok(mut session) = app_state.approval_session.lock() else { return false }; + let before = session.room_epoch(account.as_str(), room.as_str()); + for item in items { + let Some(event) = item.as_event() else { continue }; + let Some(event_id) = event.event_id() else { continue }; + let Some(content) = original_event_content_json(event) else { continue }; + if !matches!(content.get("msgtype").and_then(serde_json::Value::as_str), + Some(AGENTCHAT_APPROVAL_REQUEST_MSGTYPE | AGENTCHAT_APPROVAL_STATUS_MSGTYPE)) + { continue; } + session.ingest_message(account.as_str(), room.as_str(), event_id.as_str(), + event.sender().as_str(), &content, current_unix_time_millis()); + } + let changed = session.room_epoch(account.as_str(), room.as_str()) != before; + drop(session); + if changed { + Cx::post_action(AppStateAction::ApprovalRoomStateChanged { + account_mxid: account, room_id: room.to_owned(), + }); + } + changed +} + +pub(super) fn approval_claim_for_context( + context: &OctosActionButtonContext, + account: &UserId, + room: &RoomId, +) -> Option<(ApprovalClaimKey, ApprovalAction)> { + let OctosActionButtonRequest::Approval { + protocol: ApprovalProtocol::AgentChat { .. }, request_id, tool_args_digest, decision, .. + } = &context.request else { return None }; + let action = match decision.as_str() { + "approve_once" => ApprovalAction::ApproveOnce, + "deny" => ApprovalAction::Deny, + _ => return None, + }; + Some((ApprovalClaimKey { + account_mxid: account.to_string(), approval_room_id: room.to_string(), + request_id: request_id.clone(), input_digest: tool_args_digest.clone(), + }, action)) +} + +fn legacy_approval_status_for_original( + view: &ApprovalView, + account: &str, + room: &str, + event_id: &str, + actual_original_sender: &str, + original_content: &serde_json::Value, +) -> Option { + if !view.legacy_read_only || view.conflicted + || view.binding.owner_mxid != account || view.binding.approval_room_id != room + || view.binding.publisher_mxid != actual_original_sender + || view.legacy_original_event_id.as_deref() != Some(event_id) + || original_content.get("m.new_content").is_some() + || original_content.get("m.relates_to").and_then(|r| r.get("rel_type")) + .and_then(serde_json::Value::as_str) == Some("m.replace") + { return None; } + parse_agentchat_approval_request_from_content(original_content)?; + let detail = original_content.get(AGENTCHAT_APPROVAL_EVENT_KEY)?; + // Compare the raw tuple exactly; the old display parser's whitespace + // normalization must not turn a different request into provenance proof. + for (field, expected) in [ + ("request_id", view.binding.request_id.as_str()), + ("agent", view.binding.agent.as_str()), + ("project", view.binding.project.as_str()), + ("project_room_id", view.binding.project_room_id.as_str()), + ("input_digest", view.binding.input_digest.as_str()), + ] { + if detail.get(field).and_then(serde_json::Value::as_str) != Some(expected) { return None; } + } + for (field, expected) in [("owner_mxid", account), ("publisher_mxid", actual_original_sender)] { + if let Some(value) = detail.get(field) { + if value.as_str() != Some(expected) { return None; } + } + } + Some(view.state) +} pub(super) fn populate_octos_action_buttons( cx: &mut Cx, @@ -798,6 +932,9 @@ pub(super) fn populate_octos_action_buttons( action_button_contexts: &mut HashMap<(OwnedEventId, usize), OctosActionButtonContext>, disabled_source_event_ids: &HashSet, selected_actions: &HashMap, + pane_uid: WidgetUid, + room_id: &OwnedRoomId, + approval_session: Option<&std::sync::Arc>>, ) { let container = item.view(cx, ids!(content.action_buttons)); if content.is_none() && original_content.is_none() { @@ -817,11 +954,78 @@ pub(super) fn populate_octos_action_buttons( warning!("approval request: skipping malformed structured payload"); } - let render_state = compute_action_button_render_state( + let mut render_state = compute_action_button_render_state( &parsed_payload.actions, parsed_payload.approval_request.as_ref(), current_user_id().as_deref(), ); + let public_agent = original_content.and_then(public_approval_notice_agent); + if public_agent.is_some() { + render_state.show_container = true; + render_state.show_button_row = true; + render_state.visible_slots = vec![ActionButtonRenderSlot { + id: "view_pending_approvals".into(), + label: tr_key(app_language, "room_screen.approval.view_pending").into(), + style: OctosActionStyle::Secondary, + }]; + } + let mut canonical_label = None; + let mut runtime_consumed = false; + if let Some(request) = parsed_payload.approval_request.as_ref() + .filter(|request| matches!(request.protocol, ApprovalProtocol::AgentChat { .. })) + { + // Missing, poisoned or incomplete shared state leaves native controls disabled. + render_state.buttons_enabled = false; + if let (Some(account), Some(session)) = (current_user_id(), approval_session) { + if let Ok(session) = session.lock() { + render_state.buttons_enabled = session.is_actionable( + account.as_str(), room_id.as_str(), &request.request_id, current_unix_time_millis(), + ); + if let Some(view) = session.approval(account.as_str(), room_id.as_str(), &request.request_id) { + let claim_key = ApprovalClaimKey { + account_mxid: account.to_string(), + approval_room_id: room_id.to_string(), + request_id: view.binding.request_id.clone(), + input_digest: view.binding.input_digest.clone(), + }; + let legacy_state = original_content.and_then(|original| legacy_approval_status_for_original( + &view, account.as_str(), room_id.as_str(), source_event_id.as_str(), + original_sender.as_str(), original, + )); + canonical_label = Some(if view.conflicted || (view.legacy_read_only && legacy_state.is_none()) { + "room_screen.approval.unavailable" + } else if let Some(state) = legacy_state { + match state { + CanonicalApprovalState::Pending => "room_screen.approval.pending", + CanonicalApprovalState::Approved => "room_screen.approval.approved", + CanonicalApprovalState::Denied => "room_screen.approval.denied", + CanonicalApprovalState::Expired => "room_screen.approval.expired", + CanonicalApprovalState::Consumed => { runtime_consumed = true; "room_screen.approval.consumed" }, + } + } else { + match view.state { + CanonicalApprovalState::Pending => match session.claim_state(&claim_key) { + Some(ClaimState::Claimed) => "room_screen.approval.submitting", + Some(ClaimState::Sent) => "room_screen.approval.sent_awaiting_canonical", + Some(ClaimState::OutcomeUnknown) => "room_screen.approval.outcome_unknown", + None if approval_request_is_expired( + request, + current_unix_time_millis(), + ) => "room_screen.approval.expired", + None if render_state.buttons_enabled => "room_screen.approval.pending", + None => "room_screen.approval.unavailable", + }, + CanonicalApprovalState::Approved => "room_screen.approval.approved", + CanonicalApprovalState::Denied => "room_screen.approval.denied", + CanonicalApprovalState::Expired => "room_screen.approval.expired", + CanonicalApprovalState::Consumed => { runtime_consumed = true; "room_screen.approval.consumed" }, + } + }); + } + } + } + if canonical_label.is_none() { canonical_label = Some("room_screen.approval.unavailable"); } + } let is_disabled = are_action_buttons_disabled(disabled_source_event_ids, source_event_id.as_ref()) || !render_state.buttons_enabled; let selected_action = selected_actions.get(source_event_id); @@ -845,15 +1049,17 @@ pub(super) fn populate_octos_action_buttons( .set_text(cx, &approval_card.title); item.label(cx, ids!(content.action_buttons.approval_request_view.approval_summary_label)) .set_text(cx, &approval_card.summary); - item.label(cx, ids!(content.action_buttons.approval_request_view.approval_header.pending_badge.pending_label)) + item.label(cx, ids!(content.action_buttons.approval_request_view.approval_header.approval_state_badge.approval_state_label)) .set_text(cx, tr_key( app_language, - if approval_card.expired { + canonical_label.unwrap_or(if approval_card.expired { "room_screen.approval.expired" } else { "room_screen.approval.pending" - }, + }), )); + item.label(cx, ids!(content.action_buttons.approval_request_view.approval_task_result_unknown_label)) + .set_text(cx, if runtime_consumed { tr_key(app_language, "room_screen.approval.task_result_unknown") } else { "" }); } // Dynamic action row: ONE Splash per message replaces the former pool of @@ -885,12 +1091,16 @@ pub(super) fn populate_octos_action_buttons( active_splash.set_visible(cx, true); active_splash.set_text( cx, - &build_octos_actions_splash_body(&visible_slots, !is_disabled, source_event_id.as_str()), + &build_octos_actions_splash_body(&visible_slots, !is_disabled, source_event_id.as_str(), pane_uid, item.widget_uid()), ); if !is_disabled { for (index, render_slot) in visible_slots.iter().enumerate() { - let request = if let Some(approval_request) = parsed_payload.approval_request.as_ref() { + let request = if let Some(agent) = public_agent { + OctosActionButtonRequest::OpenPendingApprovals { + agent: agent.into(), project_room_id: room_id.clone(), label: render_slot.label.clone(), + } + } else if let Some(approval_request) = parsed_payload.approval_request.as_ref() { OctosActionButtonRequest::Approval { protocol: approval_request.protocol.clone(), request_id: approval_request.request_id.clone(), @@ -954,6 +1164,8 @@ pub(super) fn build_octos_actions_splash_body( slots: &[ActionButtonRenderSlot], enabled: bool, source_event_id: &str, + pane_uid: WidgetUid, + item_uid: WidgetUid, ) -> String { use std::fmt::Write; let mut body = @@ -962,13 +1174,21 @@ pub(super) fn build_octos_actions_splash_body( for (index, slot) in slots.iter().enumerate() { let label = escape_splash_string(&slot.label); if enabled { - let _ = write!( + let widget_id = match slot.id.as_str() { + "approve_once" => "approval_approve_once_button".to_owned(), + "deny" => "approval_deny_button".to_owned(), + "view_pending_approvals" => "view_pending_approvals_button".to_owned(), + _ => format!("b{index}"), + }; + let _ = writeln!( body, - "b{index} := Button {{ text: \"{label}\" on_click: || {{ agent.notify(\"{event}\", {{source: \"{source}\", slot: {index}}}) }} }}\n", + "{widget_id} := Button {{ text: \"{label}\" on_click: || {{ agent.notify(\"{event}\", {{source: \"{source}\", slot: {index}, pane: \"{pane}\", item: \"{item}\"}}) }} }}", event = OCTOS_SPLASH_NOTIFY_EVENT, + pane = pane_uid.0, + item = item_uid.0, ); } else { - let _ = write!(body, "s{index} := Label {{ text: \"{label}\" }}\n"); + let _ = writeln!(body, "s{index} := Label {{ text: \"{label}\" }}"); } } body @@ -978,6 +1198,7 @@ pub(super) fn build_octos_actions_splash_body( #[cfg(test)] mod tests { use super::*; + use proptest::prelude::*; #[test] fn test_forward_menu() { @@ -1106,6 +1327,8 @@ mod tests { "request_id": "approval_0123456789abcdef0123456789abcdef", "upstream_request_id": "turn-1:Bash", "input_digest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "owner_mxid": "@owner:example.test", + "publisher_mxid": "@bridge:example.test", "runtime": "codex", "tool_name": "Bash", "description": "Create a GitHub issue", @@ -1119,6 +1342,147 @@ mod tests { }) } + fn old_builder_approval_content() -> serde_json::Value { + // Exact old buildOwnerApprovalRequest shape: no owner, publisher or + // canonical revision. Keep all required legacy detail/action fields. + serde_json::json!({ + "msgtype": "com.agentchat.approval.request.v1", + "body": concat!("Approval required for test_agent\nProject: test_project\nRuntime: codex\nTool: Bash\n", + "Description: Inspect a local file\nInput: cat README.md\nExpires: 2026-07-22T18:40:00.000Z\n", + "Use the Approve once or Deny button. Text replies are not approval."), + "com.agentchat.approval": { + "version": 1, "kind": "request", "agent": TEST_AGENTCHAT_AGENT, + "project": TEST_AGENTCHAT_PROJECT, "project_room_id": TEST_AGENTCHAT_PROJECT_ROOM_ID, + "request_id": "approval_0123456789abcdef0123456789abcdef", + "upstream_request_id": "turn-1:Bash", + "input_digest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "runtime": "codex", "tool_name": "Bash", "description": "Inspect a local file", + "input_preview": "cat README.md", "expires_at": 1784745600000u64, + "actions": [ + { "id": "approve_once", "label": "Approve once", "style": "primary" }, + { "id": "deny", "label": "Deny", "style": "danger" } + ] + } + }) + } + + fn legacy_status_content(state: &str, decision: Option<&str>) -> serde_json::Value { + let original = old_builder_approval_content(); + let detail = &original[AGENTCHAT_APPROVAL_EVENT_KEY]; + serde_json::json!({ + "msgtype": AGENTCHAT_APPROVAL_STATUS_MSGTYPE, + "com.agentchat.approval": { + "version":1, "kind":"status", "migration_kind":"legacy_v1", "revision":1, + "request_id":detail["request_id"], "agent":detail["agent"], "project":detail["project"], + "project_room_id":detail["project_room_id"], "input_digest":detail["input_digest"], + "owner_mxid":"@owner:example.test", "publisher_mxid":"@bridge:example.test", + "state":state, "decision":decision + }, + "m.relates_to":{"m.in_reply_to":{"event_id":"$old-request"}} + }) + } + + #[test] + fn legacy_approval_old_builder_displays_readonly_state_in_both_orders() { + let original = old_builder_approval_content(); + assert!(parse_agentchat_approval_request_from_content(&original).is_some()); + let status = legacy_status_content("consumed", Some("allow")); + for status_first in [false, true] { + let mut session = ApprovalSession::default(); + let events = if status_first { [(&status, "$status"), (&original, "$old-request")] } + else { [(&original, "$old-request"), (&status, "$status")] }; + for (content, event) in events { + let _result = session.ingest_message("@owner:example.test", "!approval:example.test", event, + "@bridge:example.test", content, 1000); + } + let view = session.approval("@owner:example.test", "!approval:example.test", + "approval_0123456789abcdef0123456789abcdef").unwrap(); + assert!(view.legacy_read_only); + assert_eq!(view.decision, Some(crate::approval_state::ApprovalDecision::Allow)); + assert!(!session.is_actionable("@owner:example.test", "!approval:example.test", &view.binding.request_id, 1000)); + assert_eq!(legacy_approval_status_for_original(&view, "@owner:example.test", "!approval:example.test", + "$old-request", "@bridge:example.test", &original), Some(CanonicalApprovalState::Consumed)); + for action in [ApprovalAction::ApproveOnce, ApprovalAction::Deny] { + assert!(session.try_claim(ApprovalClaimKey { + account_mxid:"@owner:example.test".into(), approval_room_id:"!approval:example.test".into(), + request_id:view.binding.request_id.clone(), input_digest:view.binding.input_digest.clone(), + }, action, 1000).is_err()); + } + } + } + + fn legacy_status_view(content: &serde_json::Value) -> ApprovalView { + let mut session = ApprovalSession::default(); + assert!(matches!(session.ingest_message("@owner:example.test", "!approval:example.test", "$status", + "@bridge:example.test", content, 1000), crate::approval_state::IngestResult::Changed(_))); + session.approval("@owner:example.test", "!approval:example.test", + "approval_0123456789abcdef0123456789abcdef").unwrap() + } + + #[test] + fn legacy_approval_display_rejects_mismatched_original_evidence() { + let original = old_builder_approval_content(); + let status = legacy_status_content("consumed", Some("allow")); + let view = legacy_status_view(&status); + for (field, value) in [ + ("request_id", "approval_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + ("agent", "other_agent"), ("project", "other_project"), + ("project_room_id", "!other:example.test"), + ("input_digest", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + ("owner_mxid", "@other:example.test"), ("publisher_mxid", "@other:example.test"), + ] { + let mut wrong = original.clone(); + wrong[AGENTCHAT_APPROVAL_EVENT_KEY][field] = serde_json::json!(value); + assert_eq!(legacy_approval_status_for_original(&view, "@owner:example.test", "!approval:example.test", + "$old-request", "@bridge:example.test", &wrong), None, "must compare {field}"); + } + for (account, room, event, sender) in [ + ("@other:example.test", "!approval:example.test", "$old-request", "@bridge:example.test"), + ("@owner:example.test", "!other:example.test", "$old-request", "@bridge:example.test"), + ("@owner:example.test", "!approval:example.test", "$different", "@bridge:example.test"), + ("@owner:example.test", "!approval:example.test", "$old-request", "@other:example.test"), + ] { + assert_eq!(legacy_approval_status_for_original(&view, account, room, event, sender, &original), None); + } + let mut unproven = status.clone(); + unproven.as_object_mut().unwrap().remove("m.relates_to"); + assert_eq!(legacy_approval_status_for_original(&legacy_status_view(&unproven), "@owner:example.test", + "!approval:example.test", "$old-request", "@bridge:example.test", &original), None); + for field in ["m.new_content", "m.relates_to"] { + let mut edited = original.clone(); + edited[field] = if field == "m.new_content" { original.clone() } + else { serde_json::json!({"rel_type":"m.replace", "event_id":"$old-request"}) }; + assert_eq!(legacy_approval_status_for_original(&view, "@owner:example.test", "!approval:example.test", + "$old-request", "@bridge:example.test", &edited), None); + } + let mut session = ApprovalSession::default(); + assert!(matches!(session.ingest_message("@owner:example.test", "!approval:example.test", "$forged", + "@other:example.test", &status, 1000), crate::approval_state::IngestResult::Rejected(_)), + "actual status sender must match publisher before any display proof"); + } + + proptest::proptest! { + #[test] + fn prop_legacy_approval_display_requires_exact_evidence(suffix in "[a-z0-9]{1,24}", field in 0usize..7) { + let mut original = old_builder_approval_content(); + let view = legacy_status_view(&legacy_status_content("consumed", Some("allow"))); + let mut account = "@owner:example.test".to_owned(); + let mut room = "!approval:example.test".to_owned(); + let mut event = "$old-request".to_owned(); + let mut sender = "@bridge:example.test".to_owned(); + match field { + 0 => account = format!("@different_{suffix}:example.test"), + 1 => room = format!("!different_{suffix}:example.test"), + 2 => event = format!("$different_{suffix}"), + 3 => sender = format!("@different_{suffix}:example.test"), + 4 => original[AGENTCHAT_APPROVAL_EVENT_KEY]["agent"] = serde_json::json!(format!("different_{suffix}")), + 5 => original[AGENTCHAT_APPROVAL_EVENT_KEY]["project"] = serde_json::json!(format!("different_{suffix}")), + _ => original[AGENTCHAT_APPROVAL_EVENT_KEY]["project_room_id"] = serde_json::json!(format!("!different_{suffix}:example.test")), + } + proptest::prop_assert_eq!(legacy_approval_status_for_original(&view, &account, &room, &event, &sender, &original), None); + } + } + #[test] fn test_parse_agentchat_owner_approval_request() { let content = valid_agentchat_approval_content(); @@ -1688,4 +2052,72 @@ mod tests { clear_selected_octos_action(&mut selected_actions, source_event_id.as_ref()); assert!(!selected_actions.contains_key(&source_event_id)); } + + #[test] + fn approval_notification_requires_pane_and_item_ownership() { + let exact = serde_json::json!({ + "pane": "41", + "item": "73", + "source": "$approval", + "slot": 1, + }); + assert!(approval_action_notification_matches( + &exact, 41, 73, "$approval", 1, + )); + + for changed in [ + serde_json::json!({"pane":"42","item":"73","source":"$approval","slot":1}), + serde_json::json!({"pane":"41","item":"74","source":"$approval","slot":1}), + serde_json::json!({"pane":"41","item":"73","source":"$other","slot":1}), + serde_json::json!({"pane":"41","item":"73","source":"$approval","slot":0}), + ] { + assert!(!approval_action_notification_matches( + &changed, 41, 73, "$approval", 1, + )); + } + } + + #[test] + fn public_notice_navigation_rejects_private_or_actionable_fields() { + let notice = serde_json::json!({ + "msgtype": AGENTCHAT_APPROVAL_STATUS_MSGTYPE, + AGENTCHAT_APPROVAL_EVENT_KEY: { + "version": 1, + "kind": "status", + "state": "waiting_for_owner", + "agent": "worker", + "project": "demo", + }, + }); + assert_eq!(public_approval_notice_agent(¬ice), Some("worker")); + + for forbidden in ["request_id", "input_digest", "actions", "owner_mxid"] { + let mut unsafe_notice = notice.clone(); + unsafe_notice[AGENTCHAT_APPROVAL_EVENT_KEY][forbidden] = + serde_json::json!("private"); + assert_eq!(public_approval_notice_agent(&unsafe_notice), None); + } + } + + proptest! { + #[test] + fn prop_approval_notification_matches_all_owner_fields( + pane in 1u64..u64::MAX, + item in 1u64..u64::MAX, + slot in 0usize..MAX_OCTOS_ACTION_BUTTONS, + ) { + let payload = serde_json::json!({ + "pane": pane.to_string(), + "item": item.to_string(), + "source": "$approval", + "slot": slot, + }); + prop_assert!(approval_action_notification_matches( + &payload, pane, item, "$approval", slot, + )); + prop_assert!(!approval_action_notification_matches( + &payload, pane.saturating_add(1), item, "$approval", slot, + )); + } + } } diff --git a/src/home/room_screen/populate.rs b/src/home/room_screen/populate.rs index d3215f7f2..b8477c696 100644 --- a/src/home/room_screen/populate.rs +++ b/src/home/room_screen/populate.rs @@ -135,6 +135,7 @@ pub(super) fn populate_message_view( disabled_action_source_event_ids: &HashSet, selected_actions: &HashMap, expanded_bot_body_event_ids: &HashSet, + approval_session: Option<&std::sync::Arc>>, ) -> (WidgetRef, ItemDrawnStatus, bool) { let mut new_drawn_status = item_drawn_status; let ts_millis = event_tl_item.timestamp(); @@ -878,6 +879,9 @@ pub(super) fn populate_message_view( action_button_contexts, disabled_action_source_event_ids, selected_actions, + room_screen_widget_uid, + timeline_kind.room_id(), + approval_session, ); } diff --git a/src/home/room_screen/room_info_pane.rs b/src/home/room_screen/room_info_pane.rs index 6e585b184..4624e135a 100644 --- a/src/home/room_screen/room_info_pane.rs +++ b/src/home/room_screen/room_info_pane.rs @@ -985,7 +985,7 @@ script_mod! { width: Fill height: Fill flow: Down - max_pull_down: 0.0 + bounce_at_start: false PersonEntry := mod.widgets.RoomInfoPeopleEntry {} } @@ -1320,6 +1320,9 @@ pub struct RoomInfoSlidingPane { #[rust] info: Option, #[rust] is_animating_out: bool, + /// The hidden overlay has no current draw area. Focus it only after its + /// first visible draw, unless another widget takes focus in the meantime. + #[rust] focus_after_draw: bool, #[rust] show_people_page: bool, #[rust] topic_expanded: bool, #[rust] people_display_count: usize, @@ -1357,6 +1360,9 @@ pub(super) fn show_avatar_or_text( impl Widget for RoomInfoSlidingPane { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + if matches!(event, Event::KeyFocus(_) | Event::KeyFocusLost(_)) { + self.focus_after_draw = false; + } self.view.handle_event(cx, event, scope); if !self.visible { return; } @@ -1369,7 +1375,9 @@ impl Widget for RoomInfoSlidingPane { if self.is_animating_out && !self.animator.is_track_animating(id!(panel)) { self.visible = false; self.is_animating_out = false; - cx.revert_key_focus(); + if cx.has_key_focus(self.view.area()) { + cx.revert_key_focus(); + } self.view(cx, ids!(bg_view)).set_visible(cx, false); self.redraw(cx); return; @@ -1434,7 +1442,8 @@ impl Widget for RoomInfoSlidingPane { Event::Actions(actions) if self.button(cx, ids!(close_button)).clicked(actions) ) || event.back_pressed() - || match event.hits_with_capture_overload(cx, area, true) { + // Respect hits already captured by an overlay modal or child. + || match event.hits(cx, area) { Hit::KeyUp(key) => key.key_code == KeyCode::Escape, Hit::FingerDown(_fde) => { cx.set_key_focus(area); @@ -1534,6 +1543,7 @@ impl Widget for RoomInfoSlidingPane { // force-hide just because info hasn't been populated yet. if !self.inline { self.visible = false; + self.focus_after_draw = false; } return self.view.draw_walk(cx, scope, walk); }; @@ -1649,6 +1659,13 @@ impl Widget for RoomInfoSlidingPane { item.draw_all(cx, &mut Scope::empty()); } } + if self.focus_after_draw && self.visible && !self.inline && !self.is_animating_out { + let area = self.view.area(); + if area.is_valid(cx) { + self.focus_after_draw = false; + cx.set_key_focus(area); + } + } DrawStep::done() } } @@ -1705,7 +1722,7 @@ impl RoomInfoSlidingPane { self.show_people_page = false; self.topic_expanded = false; self.people_display_count = 0; - cx.set_key_focus(self.view.area()); + self.focus_after_draw = !self.inline; self.animator_play(cx, ids!(panel.show)); self.view(cx, ids!(bg_view)).set_visible(cx, true); self.view.button(cx, ids!(close_button)).reset_hover(cx); @@ -1713,6 +1730,7 @@ impl RoomInfoSlidingPane { } pub fn hide(&mut self, cx: &mut Cx) { + self.focus_after_draw = false; if !self.visible { return; } @@ -1739,6 +1757,9 @@ impl RoomInfoSlidingPaneRef { pub fn set_inline(&self, inline: bool) { if let Some(mut inner) = self.borrow_mut() { inner.inline = inline; + if inline { + inner.focus_after_draw = false; + } } } diff --git a/src/home/room_screen/search.rs b/src/home/room_screen/search.rs index 3f28364d6..8e5a89885 100644 --- a/src/home/room_screen/search.rs +++ b/src/home/room_screen/search.rs @@ -168,6 +168,7 @@ impl RoomScreen { ) { let pane = self.search_messages_sliding_pane(cx, ids!(search_messages_pane)); let button = self.search_messages_button(cx, ids!(timeline.search_messages_button)); + let owners = [pane.widget_uid(), button.widget_uid(), self.widget_uid()]; let mut requested_close = false; let mut requested_open = false; @@ -176,11 +177,9 @@ impl RoomScreen { let mut jump_target: Option = None; for action in actions { - // Widget-emitted actions are wrapped in a `WidgetAction`, so we - // must unwrap via `as_widget_action()` before downcasting to the - // inner `SearchMessagesAction`. `cast_ref` falls back to the - // `None` sentinel for non-matching actions. - match action.as_widget_action().cast_ref::() { + // Result rows emit under the pane UID; the RoomTopBar search + // control relays under this room screen's UID. + match room_control_action(action, &owners).cast_ref::() { SearchMessagesAction::OpenRequested => requested_open = true, SearchMessagesAction::CloseRequested => requested_close = true, SearchMessagesAction::QueryChanged(q) => new_query = Some(q.clone()), diff --git a/src/home/room_screen/threads_pane.rs b/src/home/room_screen/threads_pane.rs index b8244a63f..b9ad91ee2 100644 --- a/src/home/room_screen/threads_pane.rs +++ b/src/home/room_screen/threads_pane.rs @@ -220,7 +220,7 @@ script_mod! { width: Fill height: Fill flow: Down - max_pull_down: 0.0 + bounce_at_start: false ThreadEntry := mod.widgets.ThreadsPaneEntry {} } @@ -320,6 +320,7 @@ pub struct ThreadsPaneEntry { #[deref] view: View, #[rust] thread_root_event_id: Option, + #[rust] owner_widget_uid: Option, } impl Widget for ThreadsPaneEntry { @@ -327,7 +328,7 @@ impl Widget for ThreadsPaneEntry { // Hit-test the parent area BEFORE propagating to children, so the inner // HtmlOrPlaintext (and its TextFlow / HtmlLink children) don't steal // FingerDown/Up — mirrors the pattern in rooms_list_entry.rs. - if let Some(thread_root_event_id) = self.thread_root_event_id.clone() { + if let (Some(thread_root_event_id), Some(owner_widget_uid)) = (self.thread_root_event_id.clone(), self.owner_widget_uid) { let area = self.view.area(); match event.hits(cx, area) { Hit::FingerDown(_) => { @@ -336,7 +337,7 @@ impl Widget for ThreadsPaneEntry { Hit::FingerUp(fe) if fe.is_over && fe.is_primary_hit() && fe.was_tap() => { log!("ThreadsPaneEntry: tap detected, emitting OpenThread({})", thread_root_event_id); cx.widget_action( - self.widget_uid(), + owner_widget_uid, ThreadsPaneAction::OpenThread(thread_root_event_id), ); } @@ -353,8 +354,9 @@ impl Widget for ThreadsPaneEntry { } impl ThreadsPaneEntry { - fn set_entry(&mut self, cx: &mut Cx, entry: &ThreadsPaneEntryInfo) { + fn set_entry(&mut self, cx: &mut Cx, entry: &ThreadsPaneEntryInfo, owner_widget_uid: WidgetUid) { self.thread_root_event_id = Some(entry.thread_root_event_id.clone()); + self.owner_widget_uid = Some(owner_widget_uid); self.html_or_plaintext(cx, ids!(title)).show_html(cx, &entry.title); self.label(cx, ids!(time)).set_text(cx, &entry.time); self.label(cx, ids!(subtitle)).set_text(cx, &entry.subtitle); @@ -363,9 +365,9 @@ impl ThreadsPaneEntry { } impl ThreadsPaneEntryRef { - fn set_entry(&self, cx: &mut Cx, entry: &ThreadsPaneEntryInfo) { + fn set_entry(&self, cx: &mut Cx, entry: &ThreadsPaneEntryInfo, owner_widget_uid: WidgetUid) { let Some(mut inner) = self.borrow_mut() else { return }; - inner.set_entry(cx, entry); + inner.set_entry(cx, entry, owner_widget_uid); } } @@ -481,6 +483,7 @@ impl Widget for ThreadsSlidingPane { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let pane_widget_uid = self.widget_uid(); let Some(info) = self.info.as_ref() else { self.visible = false; return self.view.draw_walk(cx, scope, walk); @@ -520,7 +523,7 @@ impl Widget for ThreadsSlidingPane { while let Some(item_id) = list.next_visible_item(cx) { let Some(entry) = info.entries.get(item_id) else { continue }; let item = list.item(cx, item_id, id!(ThreadEntry)); - item.as_threads_pane_entry().set_entry(cx, entry); + item.as_threads_pane_entry().set_entry(cx, entry, pane_widget_uid); item.draw_all(cx, &mut Scope::empty()); } } diff --git a/src/home/room_screen/updates.rs b/src/home/room_screen/updates.rs index 5acd45f3a..87859178e 100644 --- a/src/home/room_screen/updates.rs +++ b/src/home/room_screen/updates.rs @@ -113,6 +113,13 @@ impl RoomScreen { !tl.pending_updates.is_empty(), ) }; + if self.approval_fold_pending { + if let (Some(app_state), Some(tl)) = (app_state, self.tl_state.as_ref()) { + fold_approval_events(Some(app_state), tl.kind.room_id(), &tl.items); + self.approval_fold_pending = false; + } + } + self.apply_latest_approval_request(cx); if !has_pending_updates { return; } @@ -145,6 +152,11 @@ impl RoomScreen { num_updates += 1; match update { TimelineUpdate::FirstUpdate { initial_items } => { + if app_state.is_some() { + fold_approval_events(app_state, tl.kind.room_id(), &initial_items); + } else { + self.approval_fold_pending = true; + } if let Some(app_state) = app_state { let discovered_bot_user_ids = Self::discover_known_bot_user_ids_from_timeline_items( @@ -198,6 +210,11 @@ impl RoomScreen { is_append, clear_cache, } => { + if app_state.is_none() { + self.approval_fold_pending = true; + } else if fold_approval_events(app_state, tl.kind.room_id(), &new_items) { + tl.content_drawn_since_last_update.clear(); + } if let Some(app_state) = app_state { let discovered_bot_user_ids = Self::discover_known_bot_user_ids_from_timeline_items( @@ -840,5 +857,6 @@ impl RoomScreen { if has_more_updates { SignalToUI::set_ui_signal(); } + self.apply_latest_approval_request(cx); } } diff --git a/src/home/room_settings_modal.rs b/src/home/room_settings_modal.rs index dcd870b68..2d287d811 100644 --- a/src/home/room_settings_modal.rs +++ b/src/home/room_settings_modal.rs @@ -1780,7 +1780,7 @@ script_mod! { height: 0 flow: Down grab_key_focus: false - max_pull_down: 0.0 + bounce_at_start: false auto_tail: false keep_invisible: false @@ -2096,7 +2096,7 @@ script_mod! { height: Fill flow: Down auto_tail: false - max_pull_down: 0.0 + bounce_at_start: false member_row := View { width: Fill diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index ccd5344db..cbe89bca4 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -276,6 +276,9 @@ fn set_room_id_displayed( pub enum RoomsListAction { /// A new room or space was selected. Selected(SelectedRoom), + /// Open the resolved private approval room and consume a room-bound, + /// one-shot request to show its latest timeline item. + OpenPendingApprovals(SelectedRoom), /// A new room was joined from an accepted invite, /// meaning that the existing `InviteScreen` should be converted /// to a `RoomScreen` to display the now-joined room. @@ -2285,6 +2288,16 @@ impl Widget for RoomsList { } impl RoomsListRef { + /// Joined and loaded rooms only; invited rooms cannot be approval destinations. + pub(crate) fn joined_room_ids(&self) -> std::collections::BTreeSet { + self.borrow().map(|inner| inner.all_joined_rooms.keys().map(ToString::to_string).collect()) + .unwrap_or_default() + } + + pub(crate) fn joined_room_name(&self, room_id: &OwnedRoomId) -> Option { + self.borrow()?.all_joined_rooms.get(room_id).map(|room| room.room_name_id.clone()) + } + /// See [`RoomsList::all_rooms_loaded()`]. pub fn all_rooms_loaded(&self) -> bool { let Some(inner) = self.borrow() else { return false; }; diff --git a/src/home/search_messages.rs b/src/home/search_messages.rs index 793e91426..1d709e7d6 100644 --- a/src/home/search_messages.rs +++ b/src/home/search_messages.rs @@ -307,7 +307,7 @@ script_mod! { width: Fill, height: Fill, flow: Down, - max_pull_down: 0.0, + bounce_at_start: false, result_item := mod.widgets.SearchResultItem {} } @@ -452,18 +452,19 @@ pub struct SearchResultItem { /// The matrix event this row represents. `None` until the row is bound /// to a [`MessageSearchHit`] via [`SearchResultItem::set_hit`]. #[rust] event_id: Option, + #[rust] owner_widget_uid: Option, } impl Widget for SearchResultItem { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { self.view.handle_event(cx, event, scope); - let Some(event_id) = self.event_id.as_ref() else { return }; + let (Some(event_id), Some(owner_widget_uid)) = (self.event_id.as_ref(), self.owner_widget_uid) else { return }; let area = self.view.area(); if let Hit::FingerUp(fe) = event.hits(cx, area) { if fe.is_over && fe.is_primary_hit() && fe.was_tap() { cx.widget_action( - self.widget_uid(), + owner_widget_uid, SearchMessagesAction::JumpToEvent(event_id.clone()), ); } @@ -476,8 +477,9 @@ impl Widget for SearchResultItem { } impl SearchResultItem { - pub fn set_hit(&mut self, cx: &mut Cx, hit: &MessageSearchHit) { + pub fn set_hit(&mut self, cx: &mut Cx, hit: &MessageSearchHit, owner_widget_uid: WidgetUid) { self.event_id = Some(hit.event_id.clone()); + self.owner_widget_uid = Some(owner_widget_uid); self.label(cx, ids!(sender_label)).set_text(cx, &hit.sender_display); self.label(cx, ids!(ts_label)).set_text(cx, &hit.timestamp_display); self.label(cx, ids!(body_label)).set_text(cx, &hit.body_preview); @@ -689,6 +691,7 @@ impl Widget for SearchMessagesSlidingPane { self.refresh_status_display(cx); + let pane_widget_uid = self.widget_uid(); while let Some(widget) = self.view.draw_walk(cx, scope, walk).step() { let portal_list_ref = widget.as_portal_list(); let Some(mut list) = portal_list_ref.borrow_mut() else { continue }; @@ -698,7 +701,7 @@ impl Widget for SearchMessagesSlidingPane { let Some(hit) = self.hits.get(idx) else { continue }; let item = list.item(cx, idx, id!(result_item)); if let Some(mut row) = item.as_search_result_item().borrow_mut() { - row.set_hit(cx, hit); + row.set_hit(cx, hit, pane_widget_uid); } item.draw_all(cx, &mut Scope::empty()); } diff --git a/src/home/space_lobby.rs b/src/home/space_lobby.rs index b31a81189..5a67c3d13 100644 --- a/src/home/space_lobby.rs +++ b/src/home/space_lobby.rs @@ -700,7 +700,7 @@ script_mod! { // The hierarchical tree list tree_list := PortalList { keep_invisible: false, - max_pull_down: 0.0, + bounce_at_start: false, auto_tail: false, width: Fill, height: Fill flow: Down, diff --git a/src/home/spaces_bar.rs b/src/home/spaces_bar.rs index 99f5d5879..24f9dfb37 100644 --- a/src/home/spaces_bar.rs +++ b/src/home/spaces_bar.rs @@ -338,7 +338,7 @@ script_mod! { spacing: 0.0 auto_tail: false, - max_pull_down: 0.0, + bounce_at_start: false, scroll_bar: ScrollBar { // hide the scroll bar bar_size: 0.0, min_handle_size: 0.0 diff --git a/src/lib.rs b/src/lib.rs index 5b221ae43..8731b35bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,12 +38,16 @@ pub fn view_from_live_ptr( /// The top-level main application module. pub mod app; +mod data_directory; +mod approval_discovery; /// Function for loading and saving persistent application/session state. pub mod persistence; /// The settings screen and settings-related content/widgets. pub mod settings; /// App-localized text and language preference definitions. pub mod i18n; +/// Pure, account-scoped state for agent-chat approval projections. +pub(crate) mod approval_state; /// Login screen pub mod login; @@ -114,5 +118,15 @@ pub fn project_dir() -> &'static ProjectDirs { } pub fn app_data_dir() -> &'static Path { - project_dir().data_dir() + static DATA_DIR: OnceLock = OnceLock::new(); + DATA_DIR.get_or_init(|| { + let override_dir = std::env::var_os("ROBRIX_DATA_DIR"); + match data_directory::resolve_app_data_dir(override_dir.as_deref().map(Path::new), project_dir().data_dir()) { + Ok(path) => path, + Err(error) => { + eprintln!("Robrix configuration error: {error}"); + std::process::exit(2); + } + } + }).as_path() } diff --git a/src/settings/devices_settings.rs b/src/settings/devices_settings.rs index 616cf4804..45875f782 100644 --- a/src/settings/devices_settings.rs +++ b/src/settings/devices_settings.rs @@ -273,7 +273,7 @@ script_mod! { devices_list := PortalList { width: Fill, height: Fill keep_invisible: false - max_pull_down: 0.0 + bounce_at_start: false auto_tail: false flow: Down grab_key_focus: false diff --git a/src/shared/approval_card.rs b/src/shared/approval_card.rs index 5218ee397..4b3f0ab4a 100644 --- a/src/shared/approval_card.rs +++ b/src/shared/approval_card.rs @@ -123,7 +123,7 @@ script_mod! { text: "" } - pending_badge := RoundedView { + approval_state_badge := RoundedView { width: Fit height: Fit padding: Inset{left: 8.0, right: 8.0, top: 4.0, bottom: 4.0} @@ -132,7 +132,7 @@ script_mod! { color: (RBX_BG_SURFACE) border_radius: (RBX_RADIUS_PILL) } - pending_label := Label { + approval_state_label := Label { width: Fit height: Fit draw_text +: { @@ -155,11 +155,22 @@ script_mod! { text: "" } + approval_task_result_unknown_label := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + text_style: (RBX_TEXT_BODY) + color: (RBX_FG_SECONDARY) + } + text: "" + } + approval_action_button_row := View { visible: false width: Fill height: Fit - flow: Flow.Right{wrap: true} + flow: Down spacing: 8.0 // Approval decision buttons are built dynamically in this diff --git a/src/shared/room_filter_search_results.rs b/src/shared/room_filter_search_results.rs index a803fcacf..d5c74a771 100644 --- a/src/shared/room_filter_search_results.rs +++ b/src/shared/room_filter_search_results.rs @@ -32,8 +32,9 @@ script_mod! { show_bg: true draw_bg +: { selected: instance(0.0) - fn pixel(self) -> vec4 { - return mix(#0000, (RBX_BG_SELECTED), self.selected) + color_selected: instance(RBX_BG_SELECTED) + pixel: fn() { + return mix(#0000, self.color_selected, self.selected) } } animator: Animator { @@ -342,11 +343,12 @@ impl Widget for RoomFilterSearchResultsList { // Both states are written every frame, never only the selected // one: these rows are reused by index, so a row left `on` would // stay highlighted after the selection moved elsewhere. - let item_view = item.as_view(); - if index == self.selected_index { - item_view.animator_cut(cx, ids!(highlight.on)); - } else { - item_view.animator_cut(cx, ids!(highlight.off)); + if let Some(mut result_item) = item.borrow_mut::() { + if index == self.selected_index { + result_item.view.animator_cut(cx, ids!(highlight.on)); + } else { + result_item.view.animator_cut(cx, ids!(highlight.off)); + } } let mut scope = Scope::with_props(target); item.draw_all(cx, &mut scope); diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 09b970b0f..9c563f887 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -1,4 +1,7 @@ use anyhow::{anyhow, bail, Result}; +// Opaque identity carried by the public request/result enums. The model itself +// remains private, and callers cannot construct a claim outside its validation. +pub use crate::approval_state::ApprovalClaimKey; use bitflags::bitflags; use clap::Parser; use eyeball::Subscriber; @@ -1759,6 +1762,15 @@ pub enum MatrixRequest { target_user_id: OwnedUserId, explicit_room: bool, source_event_id: OwnedEventId, + approval_claim: Option, + transaction_id: Option, + }, + /// Authenticated discovery of private approval-room markers; never joins rooms. + DiscoverApprovalRoomMarkers { + account_mxid: OwnedUserId, + generation: u64, + rooms: Vec<(OwnedRoomId, u64)>, + enqueued_at: std::time::Instant, }, /// Send an `m.sticker` event to the given room. SendSticker { @@ -2897,6 +2909,483 @@ pub struct RegisterAccount { } +#[derive(Clone, Debug, PartialEq, Eq)] +struct ApprovalSendContext { + account: String, + device: String, + homeserver: String, + room: String, + joined: bool, +} + +impl ApprovalSendContext { + fn from_client(client: &Client, room_id: &RoomId) -> Option { + Some(Self { + account: client.user_id()?.to_string(), + device: client.device_id()?.to_string(), + homeserver: client.homeserver().to_string(), + room: room_id.to_string(), + joined: client.get_room(room_id).is_some_and(|room| room.state() == RoomState::Joined), + }) + } +} + +fn approval_send_context_matches( + claim: &ApprovalClaimKey, + captured: &ApprovalSendContext, + current: &ApprovalSendContext, + transitioning: bool, +) -> bool { + !transitioning && captured == current && current.joined + && claim.account_mxid == current.account + && claim.approval_room_id == current.room +} + +fn native_approval_send_is_current(room: &Room, claim: &ApprovalClaimKey) -> bool { + let Some(client) = get_client() else { return false }; + let Some(current) = ApprovalSendContext::from_client(&client, room.room_id()) else { return false }; + let Some(captured) = ApprovalSendContext::from_client(&room.client(), room.room_id()) else { return false }; + approval_send_context_matches( + claim, &captured, ¤t, + is_logout_in_progress() || ACCOUNT_SWITCH_IN_FLIGHT.load(Ordering::Acquire), + ) +} + +// Shared across batches and replacement account workers. A worker-local +// semaphore would leave detached requests from the previous account uncounted. +fn approval_discovery_permits() -> &'static Semaphore { + static PERMITS: Semaphore = Semaphore::const_new(crate::approval_discovery::DISCOVERY_CONCURRENCY); + &PERMITS +} + +async fn run_bounded_approval_discovery( + permits: &Semaphore, + deadline: std::time::Instant, + request: F, +) -> Option { + if std::time::Instant::now() >= deadline { return None; } + tokio::time::timeout_at(deadline.into(), async { + let _permit = permits.acquire().await.ok()?; + // A ready permit/future must not win over an already elapsed deadline. + if std::time::Instant::now() >= deadline { return None; } + Some(request.await) + }).await.ok().flatten() +} + +#[derive(Debug)] +struct ApprovalMarkerSelection { + observed_v2: bool, + marker: Result, ()>, +} + +struct ApprovalMarkerEnvelope<'a> { + event_type: std::borrow::Cow<'a, str>, + state_key: std::borrow::Cow<'a, str>, + has_single_content: bool, +} + +impl<'de> serde::Deserialize<'de> for ApprovalMarkerEnvelope<'de> { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct Visitor; + + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = ApprovalMarkerEnvelope<'de>; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a Matrix state event envelope") + } + + fn visit_map(self, mut map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let mut event_type = None; + let mut state_key = None; + let mut content_count = 0_usize; + while let Some(key) = map.next_key::>()? { + match key.as_ref() { + "type" if event_type.is_none() => { + event_type = Some(map.next_value()?); + } + "state_key" if state_key.is_none() => { + state_key = Some(map.next_value()?); + } + "content" => { + let _: &serde_json::value::RawValue = map.next_value()?; + content_count = content_count.saturating_add(1); + } + _ => { + let _: &serde_json::value::RawValue = map.next_value()?; + } + } + } + Ok(ApprovalMarkerEnvelope { + event_type: event_type.ok_or_else(|| serde::de::Error::missing_field("type"))?, + state_key: state_key.ok_or_else(|| serde::de::Error::missing_field("state_key"))?, + has_single_content: content_count == 1, + }) + } + } + + deserializer.deserialize_map(Visitor) + } +} + +fn select_approval_marker_state( + events: Vec<(String, String, usize, serde_json::Value)>, +) -> ApprovalMarkerSelection { + const V1: &str = "com.agentchat.approval.room.v1"; + const V2: &str = "com.agentchat.approval.room.v2"; + const MAX_EVENT_BYTES: usize = 65_536; + + let mut v1 = Vec::new(); + let mut v2 = Vec::new(); + for (event_type, state_key, bytes, event) in events { + if !state_key.is_empty() { + continue; + } + match event_type.as_str() { + V1 => v1.push((bytes, event)), + V2 => v2.push((bytes, event)), + _ => {} + } + } + let observed_v2 = !v2.is_empty(); + let selected = if observed_v2 { v2 } else { v1 }; + let marker = match selected.as_slice() { + [] => Ok(None), + [(bytes, event)] if *bytes <= MAX_EVENT_BYTES => Ok(Some(event.clone())), + _ => Err(()), + }; + ApprovalMarkerSelection { + observed_v2, + marker, + } +} + +fn select_approval_marker_raw_state<'a, T: 'a>( + events: impl IntoIterator>, +) -> ApprovalMarkerSelection { + const V1: &str = "com.agentchat.approval.room.v1"; + const V2: &str = "com.agentchat.approval.room.v2"; + + let mut candidates = Vec::new(); + let mut invalid = false; + for raw in events { + let raw_json = raw.json().get(); + let envelope = match serde_json::from_str::>(raw_json) { + Ok(envelope) => envelope, + Err(_) => { + invalid = true; + continue; + } + }; + let event_type = envelope.event_type; + if !matches!(event_type.as_ref(), V1 | V2) { + continue; + } + let state_key = envelope.state_key; + let event = match raw_json.len() <= 65_536 && envelope.has_single_content { + true => match serde_json::from_str::(raw_json) { + Ok(event) => event, + Err(_) => { + invalid = true; + serde_json::Value::Null + } + }, + false => { + invalid = true; + serde_json::Value::Null + } + }; + candidates.push((event_type.into_owned(), state_key.into_owned(), raw_json.len(), event)); + } + let mut selected = select_approval_marker_state(candidates); + if invalid { + selected.marker = Err(()); + } + selected +} + +#[cfg(test)] +mod approval_discovery_worker_tests { + use super::{ + select_approval_marker_raw_state, + select_approval_marker_state, + run_bounded_approval_discovery, + }; + use ruma::serde::Raw; + use serde_json::{Value, json}; + use std::{future::{Future, pending}, sync::atomic::{AtomicUsize, Ordering}, task::{Context, Waker}, time::{Duration, Instant}}; + use tokio::sync::Semaphore; + + #[tokio::test] + async fn approval_discovery_overlapping_batches_share_permits_and_cancel_cleanly() { + let permits = Semaphore::new(2); + let started = AtomicUsize::new(0); + let request = || async { started.fetch_add(1, Ordering::SeqCst); pending::<()>().await }; + let deadline = Instant::now() + Duration::from_secs(25); + let mut first = Box::pin(run_bounded_approval_discovery(&permits, deadline, request())); + let mut second = Box::pin(run_bounded_approval_discovery(&permits, deadline, request())); + let mut replacement = Box::pin(run_bounded_approval_discovery(&permits, deadline, request())); + let mut cx = Context::from_waker(Waker::noop()); + assert!(first.as_mut().poll(&mut cx).is_pending()); + assert!(second.as_mut().poll(&mut cx).is_pending()); + assert!(replacement.as_mut().poll(&mut cx).is_pending()); + assert_eq!(started.load(Ordering::SeqCst), 2, "a new batch must not bypass active requests"); + assert_eq!(permits.available_permits(), 0); + drop(first); + assert!(replacement.as_mut().poll(&mut cx).is_pending()); + assert_eq!(started.load(Ordering::SeqCst), 3, "cancellation releases exactly one request slot"); + assert_eq!(permits.available_permits(), 0); + drop(second); + drop(replacement); + assert_eq!(permits.available_permits(), 2); + } + + #[tokio::test] + async fn approval_discovery_expired_channel_work_never_starts() { + let permits = Semaphore::new(2); + let started = AtomicUsize::new(0); + let result = run_bounded_approval_discovery(&permits, Instant::now(), async { + started.fetch_add(1, Ordering::SeqCst); + }).await; + assert!(result.is_none()); + assert_eq!(started.load(Ordering::SeqCst), 0); + assert_eq!(permits.available_permits(), 2); + } + + #[tokio::test] + async fn approval_discovery_deadline_includes_permit_wait() { + let permits = Semaphore::new(0); + let started = AtomicUsize::new(0); + let result = run_bounded_approval_discovery(&permits, Instant::now() + Duration::from_millis(1), async { + started.fetch_add(1, Ordering::SeqCst); + }).await; + assert!(result.is_none()); + assert_eq!(started.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn approval_discovery_deadline_cancels_fetch_and_releases_permit() { + let permits = Semaphore::new(2); + let started = AtomicUsize::new(0); + let result = run_bounded_approval_discovery(&permits, Instant::now() + Duration::from_secs(1), async { + started.fetch_add(1, Ordering::SeqCst); + pending::<()>().await + }).await; + assert!(result.is_none()); + assert_eq!(started.load(Ordering::SeqCst), 1); + assert_eq!(permits.available_permits(), 2); + } + + #[test] + fn approval_discovery_selects_v2_without_v1_fallback() { + let v1 = json!({ + "type": "com.agentchat.approval.room.v1", + "state_key": "", + "event_id": "$v1", + "content": {"version": 1} + }); + let malformed_v2 = json!({ + "type": "com.agentchat.approval.room.v2", + "state_key": "", + "event_id": "$v2", + "content": {"version": "invalid"} + }); + let selected = select_approval_marker_state(vec![ + ("com.agentchat.approval.room.v1".into(), "".into(), 100, v1), + ("com.agentchat.approval.room.v2".into(), "".into(), 100, malformed_v2.clone()), + ]); + assert!(selected.observed_v2); + assert_eq!(selected.marker.unwrap(), Some(malformed_v2)); + + let duplicate = select_approval_marker_state(vec![ + ("com.agentchat.approval.room.v1".into(), "".into(), 100, json!({})), + ("com.agentchat.approval.room.v2".into(), "".into(), 100, json!({"event_id":"$a"})), + ("com.agentchat.approval.room.v2".into(), "".into(), 100, json!({"event_id":"$b"})), + ]); + assert!(duplicate.observed_v2); + assert!(duplicate.marker.is_err()); + + let oversized = select_approval_marker_state(vec![( + "com.agentchat.approval.room.v2".into(), + "".into(), + 65_537, + json!({"event_id":"$large"}), + )]); + assert!(oversized.observed_v2); + assert!(oversized.marker.is_err()); + + let v1_only = json!({ + "type": "com.agentchat.approval.room.v1", + "state_key": "", + "event_id": "$legacy", + "content": {"version": 1} + }); + let selected_v1 = select_approval_marker_state(vec![( + "com.agentchat.approval.room.v1".into(), + "".into(), + 100, + v1_only.clone(), + )]); + assert!(!selected_v1.observed_v2); + assert_eq!(selected_v1.marker.unwrap(), Some(v1_only)); + } + + #[test] + fn approval_discovery_raw_error_retains_observed_v2_floor() { + let v2 = json!({ + "type": "com.agentchat.approval.room.v2", + "state_key": "", + "event_id": "$v2", + "sender": "@publisher:test", + "content": {"version": 2} + }) + .to_string(); + let malformed_sibling = json!({ + "type": 17, + "state_key": "", + "content": {} + }) + .to_string(); + + let v2 = Raw::::new(&serde_json::from_str::(&v2).unwrap()).unwrap(); + let malformed_sibling = Raw::::new( + &serde_json::from_str::(&malformed_sibling).unwrap(), + ) + .unwrap(); + + let selected = select_approval_marker_raw_state([&v2, &malformed_sibling]); + assert!(selected.observed_v2); + assert!(selected.marker.is_err()); + } + + #[test] + fn approval_discovery_deep_v2_content_retains_protocol_floor_in_both_orders() { + let deep_content = format!("{}0{}", "[".repeat(140), "]".repeat(140)); + let v2_json = format!( + r#"{{"type":"com.agentchat.approval.room.v2","state_key":"","content":{deep_content}}}"#, + ); + let v1_json = r#"{"type":"com.agentchat.approval.room.v1","state_key":"","content":{"version":1}}"#; + let v2 = Raw::::from_json( + serde_json::value::RawValue::from_string(v2_json).unwrap(), + ); + let v1 = Raw::::from_json( + serde_json::value::RawValue::from_string(v1_json.into()).unwrap(), + ); + + for events in [[&v2, &v1], [&v1, &v2]] { + let selected = select_approval_marker_raw_state(events); + assert!(selected.observed_v2); + assert!(selected.marker.is_err()); + } + } + + #[test] + fn approval_discovery_missing_or_oversized_v2_retains_floor_without_fallback() { + let missing = Raw::::from_json( + serde_json::value::RawValue::from_string( + r#"{"type":"com.agentchat.approval.room.v2","state_key":""}"#.into(), + ) + .unwrap(), + ); + let oversized = Raw::::from_json( + serde_json::value::RawValue::from_string(format!( + r#"{{"type":"com.agentchat.approval.room.v2","state_key":"","content":{{"padding":"{}"}}}}"#, + "x".repeat(65_536), + )) + .unwrap(), + ); + let v1 = Raw::::new(&json!({ + "type": "com.agentchat.approval.room.v1", + "state_key": "", + "content": {"version": 1}, + })) + .unwrap(); + + for invalid_v2 in [&missing, &oversized] { + let selected = select_approval_marker_raw_state([invalid_v2, &v1]); + assert!(selected.observed_v2); + assert!(selected.marker.is_err()); + } + + let escaped_type = Raw::::from_json( + serde_json::value::RawValue::from_string( + r#"{"type":"com.agentchat.approval.room.\u00762","state_key":"","content":{}}"#.into(), + ) + .unwrap(), + ); + let duplicate_content = Raw::::from_json( + serde_json::value::RawValue::from_string( + r#"{"type":"com.agentchat.approval.room.v2","state_key":"","content":{},"content":{}}"#.into(), + ) + .unwrap(), + ); + for v2 in [&escaped_type, &duplicate_content] { + assert!(select_approval_marker_raw_state([v2, &v1]).observed_v2); + } + } +} + +#[cfg(test)] +mod approval_send_context_tests { + use super::{ApprovalClaimKey, ApprovalSendContext, approval_send_context_matches}; + + fn fixture() -> (ApprovalClaimKey, ApprovalSendContext) { + ( + ApprovalClaimKey { + account_mxid: "@owner:test".into(), approval_room_id: "!private:test".into(), + request_id: "approval_0123456789abcdef0123456789abcdef".into(), + input_digest: "a".repeat(64), + }, + ApprovalSendContext { + account: "@owner:test".into(), device: "DEVICE".into(), + homeserver: "https://test/".into(), room: "!private:test".into(), joined: true, + }, + ) + } + + #[test] + fn approval_send_rechecks_account_after_preparation() { + let (claim, captured) = fixture(); + assert!(approval_send_context_matches(&claim, &captured, &captured, false)); + let mut current = captured.clone(); + current.account = "@other:test".into(); + assert!(!approval_send_context_matches(&claim, &captured, ¤t, false)); + // Even a new timeline must not send a claim belonging to the old owner. + assert!(!approval_send_context_matches(&claim, ¤t, ¤t, false)); + } + + #[test] + fn approval_send_rejects_changed_device_server_room_or_membership() { + let (claim, captured) = fixture(); + let mut changed = captured.clone(); + changed.device = "OTHER".into(); + assert!(!approval_send_context_matches(&claim, &captured, &changed, false)); + changed = captured.clone(); + changed.homeserver = "https://other/".into(); + assert!(!approval_send_context_matches(&claim, &captured, &changed, false)); + changed = captured.clone(); + changed.room = "!other:test".into(); + assert!(!approval_send_context_matches(&claim, &changed, &changed, false)); + changed = captured.clone(); + changed.joined = false; + assert!(!approval_send_context_matches(&claim, &changed, &changed, false)); + } + + #[test] + fn approval_send_waiting_for_account_teardown_cannot_begin() { + let (claim, captured) = fixture(); + // The UI reserves account switching before CLIENT is replaced. + assert!(!approval_send_context_matches(&claim, &captured, &captured, true)); + assert!(approval_send_context_matches(&claim, &captured, &captured, false)); + } +} + /// The entry point for the worker task that runs Matrix-related operations. /// /// All this task does is wait for [`MatrixRequests`] from the main UI thread @@ -2912,6 +3401,7 @@ async fn matrix_worker_task( let mut subscribers_pinned_events: HashMap> = HashMap::new(); // In-flight attachment-download tasks keyed by MXC URI, for cancel support. let download_tasks: Arc>> = Arc::new(Mutex::new(HashMap::new())); + let approval_discovery_permits = approval_discovery_permits(); while let Some(request) = request_receiver.recv().await { match request { @@ -6037,14 +6527,29 @@ async fn matrix_worker_task( target_user_id, explicit_room, source_event_id, + approval_claim, + transaction_id, } => { let Some((timeline, _sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for send action response request"); + Cx::post_action(ActionResponseResultAction::Failed { + room_id: timeline_kind.room_id().clone(), source_event_id, + error: "The room timeline is unavailable".into(), approval_claim, + outcome_unknown: false, + }); continue; }; let room_id = timeline_kind.room_id().to_owned(); let _send_action_response_task = Handle::current().spawn(async move { + if approval_claim.as_ref().is_some_and(|claim| !native_approval_send_is_current(timeline.room(), claim)) { + Cx::post_action(ActionResponseResultAction::Failed { + room_id, source_event_id, approval_claim, + error: "The approval account or room is no longer active".into(), + outcome_unknown: false, + }); + return; + } if should_ensure_action_response_target_joined(&content) { if let Err(error) = ensure_target_user_joined_room( timeline.room(), @@ -6057,6 +6562,8 @@ async fn matrix_worker_task( room_id, source_event_id, error: error.to_string(), + approval_claim, + outcome_unknown: false, }); return; } @@ -6077,6 +6584,8 @@ async fn matrix_worker_task( room_id, source_event_id, error: error.to_string(), + approval_claim, + outcome_unknown: false, }); return; } @@ -6089,22 +6598,39 @@ async fn matrix_worker_task( room_id, source_event_id, error: error.to_string(), + approval_claim, + outcome_unknown: false, }); return; } } + // Preparation awaits Matrix I/O. A switch or leave during + // that interval must not start a verdict with the old client. + if approval_claim.as_ref().is_some_and(|claim| !native_approval_send_is_current(timeline.room(), claim)) { + Cx::post_action(ActionResponseResultAction::Failed { + room_id, source_event_id, approval_claim, + error: "The approval account or room changed during preparation".into(), + outcome_unknown: false, + }); + return; + } let raw_content = prepare_action_response_content( content, target_user_id.as_ref(), explicit_room, ); - match timeline.room().send_raw("m.room.message", raw_content).await { + let mut send = timeline.room().send_raw("m.room.message", raw_content); + if let Some(transaction_id) = transaction_id.as_deref() { + send = send.with_transaction_id(transaction_id); + } + match send.await { Ok(_response) => { log!("Sent action response message to {timeline_kind}."); Cx::post_action(ActionResponseResultAction::Sent { room_id, source_event_id, + approval_claim, }); } Err(error) => { @@ -6113,12 +6639,86 @@ async fn matrix_worker_task( room_id, source_event_id, error: error.to_string(), + approval_claim, + outcome_unknown: true, }); } } }); } + MatrixRequest::DiscoverApprovalRoomMarkers { account_mxid, generation, rooms, enqueued_at } => { + use crate::approval_discovery::{ApprovalDiscoveryRequest, ApprovalDiscoveryResult, + DiscoveryOutcome, DISCOVERY_BATCH_SIZE, DISCOVERY_CONCURRENCY, DISCOVERY_TIMEOUT_MS}; + let client = get_client(); + // Includes time spent in the MatrixRequest channel, then waiting + // for a shared permit. App's generation/nonce guard remains final. + let deadline = enqueued_at + Duration::from_millis(DISCOVERY_TIMEOUT_MS * 2 + 5_000); + let _approval_discovery_task = Handle::current().spawn(async move { + stream::iter(rooms.into_iter().take(DISCOVERY_BATCH_SIZE)) + .for_each_concurrent(DISCOVERY_CONCURRENCY, |(room_id, nonce)| { + let client = client.clone(); + let account = account_mxid.clone(); + async move { + let request = ApprovalDiscoveryRequest { + account: account.to_string(), room_id: room_id.to_string(), generation, nonce, + }; + let fetched = run_bounded_approval_discovery(approval_discovery_permits, deadline, async { + tokio::time::timeout(Duration::from_millis(DISCOVERY_TIMEOUT_MS), async { + let client = client.ok_or_else(|| anyhow!("signed-in client unavailable"))?; + let current_context_matches = || get_client().is_some_and(|current| { + current.user_id() == Some(account.as_ref()) + && client.user_id() == current.user_id() + && client.device_id() == current.device_id() + && client.homeserver() == current.homeserver() + && current.get_room(&room_id).is_some_and(|room| room.state() == RoomState::Joined) + }); + if !current_context_matches() { bail!("account or room changed"); } + let room = client.get_room(&room_id).ok_or_else(|| anyhow!("room unavailable"))?; + if room.state() != RoomState::Joined { bail!("room not joined"); } + let response = client.send(ruma::api::client::state::get_state_events::v3::Request::new(room_id.clone())) + .with_request_config(RequestConfig::default().retry_limit(0)).await?; + if !current_context_matches() || room.state() != RoomState::Joined { + bail!("account or room no longer current"); + } + Ok::<_, anyhow::Error>( + select_approval_marker_raw_state( + response.room_state.iter(), + ), + ) + }).await + }).await; + let (outcome, observed_v2, marker) = match fetched { + Some(Ok(Ok(selection))) => match selection.marker { + Ok(Some(marker)) => ( + DiscoveryOutcome::Present, + selection.observed_v2, + Some(marker), + ), + Ok(None) => ( + DiscoveryOutcome::Absent, + selection.observed_v2, + None, + ), + Err(()) => ( + DiscoveryOutcome::Failed, + selection.observed_v2, + None, + ), + }, + _ => (DiscoveryOutcome::Failed, false, None), + }; + Cx::post_action(ApprovalDiscoveryResult { + request, + outcome, + observed_v2, + marker, + }); + } + }).await; + }); + } + MatrixRequest::SendSticker { timeline_kind, body, mxc_url, width, height, size } => { log!("[sticker-dbg] LAYER4: MatrixRequest::SendSticker received timeline={timeline_kind} body={body:?} mxc={mxc_url:?} w={width} h={height} size={size}"); let Some((timeline, _sender)) = get_timeline_and_sender(&timeline_kind) else { diff --git a/tools/ux-harness/Cargo.toml b/tools/ux-harness/Cargo.toml index bd59f0ed4..88a9dd6a6 100644 --- a/tools/ux-harness/Cargo.toml +++ b/tools/ux-harness/Cargo.toml @@ -19,3 +19,9 @@ path = "src/main.rs" serde = { version = "1", features = ["derive"] } serde_json = "1" png = "0.18" +tungstenite = "=0.26.2" +makepad-studio-protocol = { git = "https://github.com/ZhangHanDong/makepad", rev = "45e826c2814fccd2c69d558b27775858444c907d" } +makepad-micro-serde = { git = "https://github.com/ZhangHanDong/makepad", rev = "45e826c2814fccd2c69d558b27775858444c907d" } + +[dev-dependencies] +proptest = { version = "1", default-features = false, features = ["std"] } diff --git a/tools/ux-harness/README.md b/tools/ux-harness/README.md index 9192b6fe3..f0e9af7fb 100644 --- a/tools/ux-harness/README.md +++ b/tools/ux-harness/README.md @@ -4,9 +4,10 @@ A UX audit harness for Robrix2. It drives the real app through **makepad's internal event channel**, captures the frames the app actually renders, and turns both into a scored report with per-finding evidence. -No screen recording, no accessibility permissions, no window server. The app -runs under makepad's headless CPU renderer and speaks newline-delimited JSON -over stdin/stdout, so the whole thing works over SSH and in CI. +The headless mode uses Makepad's CPU renderer over stdin/stdout and works over +SSH and in CI. The Studio mode attaches to a real running desktop build for +widget queries and event injection. Computer Use can independently check the +same window's focus, popup visibility and rendered appearance. ## What it measures @@ -88,6 +89,118 @@ cargo run -- contrast --fg '#687283' --bg '#FFFFFF' When a pair fails it prints the nearest compliant color along the same hue, so a palette fix keeps its hue relationships. +### Attach to a Studio build + +Build Studio and Robrix with the same Makepad revision. Launch an isolated +Studio hub on loopback and start the test Robrix with an absolute +`ROBRIX_DATA_DIR`. Use a unique application bundle ID when another Robrix +instance is running, so Computer Use selects the same instance as Studio. + +```bash +cargo build --release --locked -p ux-harness +target/release/ux-harness studio --address 127.0.0.1:18001 \ + --build-id 3 --app-mode windowed --steps steps.json --out target/ux-studio +``` + +`steps.json` is an array, for example: + +```json +[ + {"action":"wait","selector":{"id":"home_screen_view"},"timeout_ms":10000}, + {"action":"click","selector":{"text":"e2e project room","within":{"id":"rooms_list"}},"expect":{"id":"at_mention_button"}}, + {"action":"click","selector":{"id":"at_mention_button"},"expect":{"id":"popup"}}, + {"action":"wait","selector":{"id":"popup"},"timeout_ms":10000}, + {"action":"capture","label":"member-picker"} +] +``` + +Selectors combine exact `id`, `text`, `widget_type`, `value`, `window_index` +and optional `within` geometry. Missing targets wait to a bounded deadline; +ambiguous targets fail. `type` injects text; `key` supports navigation, Enter, +Escape, Backspace and `a` with optional `logo`/`shift` modifiers. Every `click`, +`scroll`, `type` and `key` requires an `expect` selector. The harness polls fresh, +correlated snapshots until that expected state appears, with `timeout_ms` in +1..60000 (default 10000), and saves the exact matching snapshot. Polling never +replays input. The whole plan is validated before connection or input. +Choose a changed value or newly visible control to test a transition: a +predicate that was already true proves only observed state, not event +acknowledgement or causality. Event delivery alone is not a business result. + +`--out` must name a new directory. Existing paths are rejected before connection +and are never cleared or overwritten. Keep step files outside that directory; +choose a different output path for every run so stale artifacts cannot mix with +current evidence. Re-running a step file can repeat its mutations; inspect the +current app state and prepare only the remaining steps after a failure. + +Attachment allows five seconds each for TCP connection, WebSocket upgrade and +the initial Hello. Upgrade fragments resume on the same connection within one +fixed deadline. Snapshot responses use their caller's deadline; captures allow +25 seconds including the request write. Errors identify `Studio upgrade`, +`Studio Hello`, `Studio command` or `Studio capture`, so failure evidence records +which phase expired. A failed write can have sent bytes: the harness does not +reconnect or replay that command. These bounds do not identify the cause of a +live Studio stall. + +For repeated widget IDs, `path` asks a compatible Studio app to match exact +named ancestry in its retained widget graph, for example +`{"path":["info_button","inner_button"]}`. Unnamed intermediary widgets are +ignored, while every named intermediary must appear in order. The app applies +effective interaction visibility before returning rectangles. Multiple visible +paths remain ambiguous. The harness requires the correlated native query and a +matching snapshot geometry in the same window coordinate space; it never falls +back to an ID or geometry-only selection when an older app does not support the +query. + +`windowed` converts desktop snapshot coordinates using the current Window +geometry; `embedded` uses desktop coordinates directly. The pinned Studio +revision ignores the input window index, so non-primary-window input fails +explicitly. The attachment never stops or clears builds. Screenshot responses +must match both query and build IDs, decode as PNG and persist successfully. +Failures save diagnostics and the last observed snapshot. Password input +values are redacted from snapshot artifacts; keep credential step files private +and remove them after use. + +Keep Studio snapshots/captures, Computer Use screenshots and independent +Matrix/HAFleet/artifact checks as separate evidence. A static UX score does not +prove GUI behavior, agent work, or final message delivery. + +### Combined desktop verification and current limits + +The September 9 macOS run used one uniquely bundled Robrix test process for +both transports. A Cargo binary with only a new compile-time name was not +sufficient for Computer Use discovery; an actual `.app` with a unique +`CFBundleIdentifier` and a Studio-launched executable wrapper worked. Keep its +profile separate from every existing Robrix process. + +Use Studio to find a unique visible control, inject text, assert its resulting +value and capture correlated evidence. Use Computer Use to activate the real +window, inspect visual layout and select candidates that the snapshot cannot +identify. Re-query Studio after each Computer Use action before injecting the +next event. For `@`, verify the chosen candidate in the composer before sending +and verify the structured Matrix mention afterward. + +Use a container selector for scrolling, for example +`{"action":"scroll","selector":{"id":"threads_list"},"sx":0,"sy":500,"expect":{"text":"Expected thread title"}}`. +The harness resolves fresh geometry, injects the scroll at that container's +center and waits for the expected visible thread before saving its snapshot. +Replace the example title with the actual expected result. Positive `sy` scrolls down. Inspect +the new state before choosing a thread; a long first entry can fill the pane. + +Observed limitations in the pinned fork: + +- Dynamic member rows were visible on screen but lacked usable text/geometry + in snapshots. Computer Use performed the real selection. +- Inactive Dock tabs retained `visible: true` input entries at identical + coordinates. Treat ambiguity as a failure; do not choose the first entry. +- Focus affected member-popup behavior. Successful event injection alone did + not establish that the popup remained visible. +- With the Mac locked, Computer Use returned `cgWindowNotFound` for both Chrome + and Robrix while Studio snapshots still responded. Continue protocol checks, + but keep real desktop/dashboard acceptance pending until an unlocked run. + +These are separate evidence limitations, not a claim of full accessibility or +cross-platform GUI coverage. Preserve failed attempts when a later retry works. + ## How it drives the app Makepad's headless backend (`MAKEPAD=headless`, run with `--stdin-loop`) reads @@ -96,8 +209,7 @@ channel Makepad Studio uses to remote-control a running app. - **Input**: `MouseDown/Up/Move`, `KeyDown/Up`, `TextInput`, `Scroll` are delivered as genuine `Event`s, through the same dispatch a real click takes. -- **Introspection**: `WidgetSnapshot` returns every widget's post-layout rect, - visibility, enabled state, and text — an accessibility-tree equivalent. +- **Introspection**: `WidgetSnapshot` exposes widget geometry, visibility, enabled state and text. Coverage depends on each widget implementation; validate it against the visible UI. - **Capture**: every draw cycle writes a PNG to `MAKEPAD_HEADLESS_OUT_DIR`. Two details are load-bearing: diff --git a/tools/ux-harness/src/driver.rs b/tools/ux-harness/src/driver.rs index 327af42b1..89f0ea74e 100644 --- a/tools/ux-harness/src/driver.rs +++ b/tools/ux-harness/src/driver.rs @@ -10,15 +10,17 @@ use std::collections::VecDeque; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, Command, Stdio}; +use std::process::{ChildStdin, Command, Stdio}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; use crate::proto::{decode, Incoming, Key, Modifiers, Msg, WidgetSnapshot}; +use crate::evidence::{Diagnostics, OwnedChild, validate_capture, write_capture}; pub struct Driver { - child: Child, + child: OwnedChild, stdin: ChildStdin, rx: Receiver, pending: VecDeque, @@ -28,14 +30,12 @@ pub struct Driver { clock: f64, frames_dir: PathBuf, pub last_focus_rect: Option<(f64, f64, f64, f64)>, - pub logs: Vec, + diagnostics: Arc>, /// Set UX_HARNESS_TRACE=1 to mirror the whole protocol exchange to stderr. trace: bool, } const TICK_STEP: f64 = 1.0 / 60.0; -/// How long to wait for any single line from the app before declaring it hung. -const LINE_TIMEOUT: Duration = Duration::from_secs(60); /// How long a Tick may go unanswered before the UI counts as idle. Generous /// because the headless backend rasterises on the CPU. const IDLE_WINDOW: Duration = Duration::from_millis(2500); @@ -71,9 +71,9 @@ impl Driver { cmd.env(k, v); } - let mut child = cmd + let mut child = OwnedChild(cmd .spawn() - .map_err(|e| format!("cannot spawn {}: {e}", app_bin.display()))?; + .map_err(|e| format!("cannot spawn {}: {e}", app_bin.display()))?); let stdin = child.stdin.take().ok_or("no stdin on child")?; let stdout = child.stdout.take().ok_or("no stdout on child")?; @@ -86,7 +86,7 @@ impl Driver { for line in reader.lines() { let Ok(line) = line else { break }; if trace { - eprintln!("← {}", &line[..line.len().min(220)]); + eprintln!("← {}", line.chars().take(220).collect::()); } if let Some(msg) = decode(&line) { if tx.send(msg).is_err() { @@ -95,8 +95,9 @@ impl Driver { } } }); - // Drain stderr so a chatty app can never fill the pipe and deadlock. - let (etx, erx) = mpsc::channel(); + // Drain stderr into a bounded tail, including errors outside the protocol. + let diagnostics = Arc::new(Mutex::new(Diagnostics::new(64 * 1024))); + let stderr_diagnostics = diagnostics.clone(); thread::spawn(move || { let reader = BufReader::new(stderr); for line in reader.lines() { @@ -104,15 +105,11 @@ impl Driver { if trace { eprintln!("!! {line}"); } - if etx.send(line).is_err() { - break; + if let Ok(mut log) = stderr_diagnostics.lock() { + log.push(format!("stderr: {line}")); } } }); - thread::spawn(move || { - // Keep the receiver alive; stderr is only surfaced on failure. - while erx.recv().is_ok() {} - }); Ok(Driver { child, @@ -123,15 +120,24 @@ impl Driver { clock: 0.0, frames_dir: frames_dir.to_path_buf(), last_focus_rect: None, - logs: Vec::new(), + diagnostics, trace: std::env::var("UX_HARNESS_TRACE").is_ok(), }) } + fn record_log(&self, line: String) { + if let Ok(mut log) = self.diagnostics.lock() { log.push(line); } + } + + pub fn diagnostic_text(&self) -> String { + self.diagnostics.lock().map(|log| log.text()).unwrap_or_else(|_| "diagnostics lock poisoned".into()) + } + fn send(&mut self, msg: Msg) -> Result<(), String> { let line = msg.to_line(); if self.trace { - eprintln!("→ {}", line.trim()); + if matches!(msg, Msg::TextInput { .. }) { eprintln!("→ TextInput [redacted]"); } + else { eprintln!("→ {}", line.trim()); } } self.stdin .write_all(line.as_bytes()) @@ -139,12 +145,15 @@ impl Driver { self.stdin.flush().map_err(|e| format!("flush failed: {e}")) } - fn recv(&mut self) -> Result { + fn recv_until(&mut self, deadline: Instant) -> Result { + if Instant::now() >= deadline { return Err("app response deadline expired".into()); } if let Some(msg) = self.pending.pop_front() { + if let Incoming::ProtocolError(error) = msg { return Err(error); } return Ok(msg); } - match self.rx.recv_timeout(LINE_TIMEOUT) { + match self.rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) { Ok(msg) => { + if let Incoming::ProtocolError(error) = msg { return Err(error); } if let Incoming::KeyFocusRect { x, y, width, height } = &msg { self.last_focus_rect = match (x, y, width, height) { (Some(x), Some(y), Some(w), Some(h)) => Some((*x, *y, *w, *h)), @@ -153,7 +162,7 @@ impl Driver { } Ok(msg) } - Err(RecvTimeoutError::Timeout) => Err("app went silent (30s)".to_string()), + Err(RecvTimeoutError::Timeout) => Err("app response deadline expired".to_string()), Err(RecvTimeoutError::Disconnected) => Err("app exited".to_string()), } } @@ -165,9 +174,9 @@ impl Driver { if Instant::now() > deadline { return Err("timed out waiting for AfterStartup".to_string()); } - match self.recv()? { + match self.recv_until(deadline)? { Incoming::AfterStartup => return Ok(()), - Incoming::Log(l) => self.logs.push(l), + Incoming::Log(l) => self.record_log(l), _ => {} } } @@ -198,7 +207,7 @@ impl Driver { // instead of pipelining more Ticks — queueing them just makes the // app render frames nobody asked for. let mut wants_more = false; - let deadline = Instant::now() + IDLE_WINDOW; + let deadline = (Instant::now() + IDLE_WINDOW).min(budget); loop { let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { @@ -209,7 +218,8 @@ impl Driver { wants_more = true; break; } - Ok(Incoming::Log(l)) => self.logs.push(l), + Ok(Incoming::Log(l)) => self.record_log(l), + Ok(Incoming::ProtocolError(error)) => return Err(error), Ok(Incoming::KeyFocusRect { x, y, width, height }) => { self.last_focus_rect = match (x, y, width, height) { (Some(x), Some(y), Some(w), Some(h)) => Some((x, y, w, h)), @@ -258,11 +268,7 @@ impl Driver { pub fn click(&mut self, x: f64, y: f64) -> Result { let time = self.clock; self.send(Msg::MouseMove { x, y, time, modifiers: Modifiers::default() })?; - self.settle(30)?; - let time = self.clock; self.send(Msg::MouseDown { x, y, time, modifiers: Modifiers::default() })?; - self.settle(30)?; - let time = self.clock; self.send(Msg::MouseUp { x, y, time, modifiers: Modifiers::default() })?; self.settle(24) } @@ -270,8 +276,6 @@ impl Driver { pub fn key(&mut self, key: Key, modifiers: Modifiers) -> Result { let time = self.clock; self.send(Msg::KeyDown { key, time, modifiers })?; - self.settle(60)?; - let time = self.clock; self.send(Msg::KeyUp { key, time, modifiers })?; self.settle(120) } @@ -321,7 +325,8 @@ impl Driver { Ok(Incoming::WidgetSnapshot { request_id, widgets }) if request_id == id => { return Ok(widgets) } - Ok(Incoming::Log(l)) => self.logs.push(l), + Ok(Incoming::Log(l)) => self.record_log(l), + Ok(Incoming::ProtocolError(error)) => return Err(error), Ok(other) => self.pending.push_back(other), Err(RecvTimeoutError::Timeout) => { return Err("no WidgetSnapshot response within 180s".to_string()) @@ -331,41 +336,24 @@ impl Driver { } } - /// Force a repaint and return the newest frame file on disk. - /// - /// The headless backend writes every drawn frame to `MAKEPAD_HEADLESS_OUT_DIR`, - /// so "capture" is really "make it draw, then take the latest file". + /// Save only the PNG returned for this request; old on-disk frames are not evidence. pub fn capture(&mut self, label: &str) -> Result, String> { let id = self.next_request_id; self.next_request_id += 1; - let before = latest_frame(&self.frames_dir); + let deadline = Instant::now() + Duration::from_secs(25); self.send(Msg::Screenshot { request_id: id })?; self.settle(120)?; - - // The frame is written by the render pass, which lands after the Tick - // that triggered it — and encoding a full-window PNG on the CPU is not - // instant. Wait for a file that is actually newer than the one we had, - // instead of assuming settle() outran the encoder. - let deadline = Instant::now() + Duration::from_secs(25); - let mut path = None; while Instant::now() < deadline { - match latest_frame(&self.frames_dir) { - Some(p) if Some(&p) != before.as_ref() => { - path = Some(p); - break; - } - Some(p) => { - path = Some(p); - thread::sleep(Duration::from_millis(150)); + match self.recv_until(deadline)? { + Incoming::Screenshot { request_ids, width, height, png } if request_ids.contains(&id) => { + validate_capture(id, &request_ids, width, height, &png)?; + return write_capture(&self.frames_dir, label, &png).map(Some); } - None => thread::sleep(Duration::from_millis(150)), + Incoming::Log(line) => self.record_log(line), + _ => {} } } - let Some(path) = path else { return Ok(None) }; - // Give the frame a stable, human-meaningful name next to the raw one. - let named = self.frames_dir.join(format!("scene_{label}.png")); - let _ = std::fs::copy(&path, &named); - Ok(Some(named)) + Err(format!("no screenshot response for request {id} before deadline")) } pub fn shutdown(mut self) { @@ -377,22 +365,41 @@ impl Driver { } } -/// Newest `window_*_frame_*.png` in a directory, by modified time. -pub fn latest_frame(dir: &Path) -> Option { - let mut best: Option<(std::time::SystemTime, PathBuf)> = None; - for entry in std::fs::read_dir(dir).ok()? { - let Ok(entry) = entry else { continue }; - let path = entry.path(); - let name = path.file_name()?.to_string_lossy().to_string(); - if !name.starts_with("window_") || !name.ends_with(".png") { - continue; - } - let Ok(meta) = entry.metadata() else { continue }; - let Ok(mtime) = meta.modified() else { continue }; - match &best { - Some((t, _)) if *t >= mtime => {} - _ => best = Some((mtime, path)), - } +#[cfg(all(test, unix))] +mod tests { + use super::*; + + fn test_driver(directory: &Path) -> (Driver, mpsc::Sender) { + let mut child = OwnedChild(Command::new("/bin/cat") + .stdin(Stdio::piped()).stdout(Stdio::null()).spawn().unwrap()); + let stdin = child.stdin.take().unwrap(); + let (tx, rx) = mpsc::channel(); + (Driver { child, stdin, rx, pending: VecDeque::new(), next_request_id: 18, + clock: 0.0, frames_dir: directory.into(), last_focus_rect: None, + diagnostics: Arc::new(Mutex::new(Diagnostics::new(1024))), trace: false }, tx) + } + + #[test] + fn expired_response_deadline_rejects_queued_frame() { + let (mut driver, _tx) = test_driver(Path::new(".")); + driver.pending.push_back(Incoming::Screenshot { request_ids: vec![18], width: 1, height: 1, png: vec![] }); + let result = driver.recv_until(Instant::now() - Duration::from_millis(1)); + assert!(result.is_err(), "an already queued frame must not defeat an expired deadline"); + } + + #[test] + fn capture_deadline_never_reuses_old_file_or_response() { + let directory = std::env::temp_dir().join(format!("ux-stale-capture-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let old_frame = directory.join("window_0_frame_1.png"); + std::fs::write(&old_frame, b"old frame must never become evidence").unwrap(); + let (mut driver, tx) = test_driver(&directory); + tx.send(Incoming::Screenshot { request_ids: vec![17], width: 1, height: 1, png: vec![] }).unwrap(); + let result = driver.capture("deadline"); + assert!(result.is_err()); + assert!(!directory.join("scene_deadline.png").exists()); + assert_eq!(std::fs::read(&old_frame).unwrap(), b"old frame must never become evidence"); + drop(driver); + std::fs::remove_dir_all(directory).unwrap(); } - best.map(|(_, p)| p) } diff --git a/tools/ux-harness/src/evidence.rs b/tools/ux-harness/src/evidence.rs new file mode 100644 index 000000000..a43223a8c --- /dev/null +++ b/tools/ux-harness/src/evidence.rs @@ -0,0 +1,158 @@ +//! Failure evidence and ownership rules shared by UI transports. + +use std::collections::VecDeque; +use std::io::Cursor; +use std::ops::{Deref, DerefMut}; +use std::path::{Path, PathBuf}; +use std::process::Child; + +pub struct OwnedChild(pub Child); + +impl Deref for OwnedChild { + type Target = Child; + fn deref(&self) -> &Child { &self.0 } +} + +impl DerefMut for OwnedChild { + fn deref_mut(&mut self) -> &mut Child { &mut self.0 } +} + +impl Drop for OwnedChild { + fn drop(&mut self) { + if !matches!(self.0.try_wait(), Ok(Some(_))) { + let _ = self.0.kill(); + } + let _ = self.0.wait(); + } +} + +pub struct Diagnostics { + lines: VecDeque, + bytes: usize, + capacity: usize, +} + +impl Diagnostics { + pub fn new(capacity: usize) -> Self { + Self { lines: VecDeque::new(), bytes: 0, capacity } + } + + pub fn push(&mut self, mut line: String) { + if self.capacity == 0 { return; } + if line.len() >= self.capacity { + let mut start = line.len() - self.capacity + 1; + while !line.is_char_boundary(start) { start += 1; } + line = line[start..].to_string(); + } + let size = line.len() + 1; + while self.bytes + size > self.capacity { + let Some(old) = self.lines.pop_front() else { break }; + self.bytes -= old.len() + 1; + } + self.bytes += size; + self.lines.push_back(line); + } + + pub fn len_bytes(&self) -> usize { self.bytes } + pub fn text(&self) -> String { self.lines.iter().cloned().collect::>().join("\n") } +} + +pub fn validate_capture(request_id: u64, request_ids: &[u64], width: u32, height: u32, bytes: &[u8]) -> Result<(), String> { + if !request_ids.contains(&request_id) { + return Err(format!("stale screenshot: expected request {request_id}, received {request_ids:?}")); + } + let decoder = png::Decoder::new(Cursor::new(bytes)); + let mut reader = decoder.read_info().map_err(|e| format!("invalid capture PNG: {e}"))?; + if width == 0 || height == 0 || reader.info().width != width || reader.info().height != height { + return Err("capture dimensions do not match the response".to_string()); + } + let size = reader.output_buffer_size().filter(|size| *size <= 128 * 1024 * 1024) + .ok_or("capture exceeds the 128 MiB decoded image limit")?; + reader.next_frame(&mut vec![0; size]).map_err(|e| format!("incomplete capture PNG: {e}"))?; + Ok(()) +} + +pub fn write_capture(directory: &Path, label: &str, bytes: &[u8]) -> Result { + if label.is_empty() || !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { + return Err("capture label must contain only letters, digits, underscores or hyphens".into()); + } + std::fs::create_dir_all(directory).map_err(|e| format!("create capture directory: {e}"))?; + let path = directory.join(format!("scene_{label}.png")); + let temporary = directory.join(format!(".scene_{label}.png.tmp")); + std::fs::write(&temporary, bytes).map_err(|e| format!("write capture: {e}"))?; + if let Err(e) = std::fs::rename(&temporary, &path) { + let _ = std::fs::remove_file(&temporary); + return Err(format!("publish capture: {e}")); + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn png() -> Vec { + let mut bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut bytes, 1, 1); + encoder.set_color(png::ColorType::Rgba); + let mut writer = encoder.write_header().unwrap(); + writer.write_image_data(&[0, 0, 0, 255]).unwrap(); + } + bytes + } + + #[test] + fn stale_capture_is_error() { + assert!(validate_capture(18, &[17], 1, 1, &png()).is_err()); + assert!(validate_capture(18, &[18], 1, 1, &png()).is_ok()); + assert!(validate_capture(18, &[18], 2, 1, &png()).is_err()); + } + + proptest::proptest! { + #[test] + fn prop_capture_requires_matching_request(request in 0u64..1000, received in 0u64..1000) { + proptest::prop_assert_eq!(validate_capture(request, &[received], 1, 1, &png()).is_ok(), request == received); + } + + #[test] + fn prop_diagnostics_storage_never_exceeds_capacity(capacity in 0usize..1000, lines in proptest::collection::vec(".{0,100}", 0..40)) { + let mut diagnostics = Diagnostics::new(capacity); + for line in lines { + diagnostics.push(line); + proptest::prop_assert!(diagnostics.len_bytes() <= capacity); + proptest::prop_assert!(diagnostics.text().len() <= capacity); + } + } + } + + #[test] + fn capture_write_failure_is_error() { + let path = std::env::temp_dir().join(format!("ux-capture-test-{}", std::process::id())); + std::fs::write(&path, b"a file cannot be an output directory").unwrap(); + let result = write_capture(&path, "frame", &png()); + std::fs::remove_file(path).unwrap(); + assert!(result.is_err()); + } + + #[test] + fn stderr_diagnostics_are_bounded_and_retained() { + let mut log = Diagnostics::new(128); + for i in 0..100 { log.push(format!("stderr line {i}: {}", "测试".repeat(40))); } + log.push("stderr: final failure".into()); + assert!(log.text().contains("stderr: final failure")); + assert!(log.len_bytes() <= 128); + } + + #[cfg(unix)] + #[test] + fn owned_child_is_reaped_on_early_return() { + let child = std::process::Command::new("/bin/sleep").arg("30").spawn().unwrap(); + let pid = child.id(); + let owned = OwnedChild(child); + drop(owned); + let status = std::process::Command::new("/bin/kill") + .args(["-0", &pid.to_string()]).stderr(std::process::Stdio::null()).status().unwrap(); + assert!(!status.success(), "owned child {pid} survived driver scope"); + } +} diff --git a/tools/ux-harness/src/locator.rs b/tools/ux-harness/src/locator.rs new file mode 100644 index 000000000..3b63794f2 --- /dev/null +++ b/tools/ux-harness/src/locator.rs @@ -0,0 +1,215 @@ +//! Strict, post-layout selectors. No coordinate fallback on missing or ambiguous state. + +use serde::Deserialize; +use crate::proto::WidgetSnapshot; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Selector { + pub path: Option>, + pub id: Option, + pub text: Option, + pub widget_type: Option, + pub window_index: Option, + pub value: Option, + pub within: Option>, +} + +impl Selector { + pub fn validate(&self) -> Result<(), String> { + if self.id.is_none() && self.text.is_none() && self.widget_type.is_none() && self.value.is_none() && self.path.is_none() { + return Err("selector needs id, text, widget_type or value".into()); + } + if let Some(path) = &self.path { + let encoded_len = path.iter().map(String::len).sum::() + path.len().saturating_sub(1); + if self.within.is_some() || encoded_len > 4096 || !(2..=32).contains(&path.len()) || path.iter().any(|segment| { + segment.is_empty() || segment.len() > 128 || segment == "-" || !segment.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) + }) { + return Err("path needs 2..32 safe named segments and cannot be combined with within".into()); + } + } + if let Some(parent) = &self.within { parent.validate()?; } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QueryRect { + pub id: String, + pub widget_type: String, + pub x: i64, + pub y: i64, + pub width: i64, + pub height: i64, +} + +pub fn parse_path_query_rects(rows: &[String]) -> Result, String> { + if rows.len() > 256 { return Err("path query returned more than 256 rows".into()); } + rows.iter().map(|row| { + let fields: Vec<_> = row.split_whitespace().collect(); + if fields.len() != 7 || fields[0].parse::().is_err() { + return Err("path query returned an invalid native rectangle".into()); + } + let number = |index: usize| fields[index].parse::().map_err(|_| "path query returned an invalid coordinate".to_string()); + let rect = QueryRect { id: fields[1].into(), widget_type: fields[2].into(), x: number(3)?, y: number(4)?, width: number(5)?, height: number(6)? }; + if rect.width <= 0 || rect.height <= 0 { return Err("path query returned a non-interactive rectangle".into()); } + Ok(rect) + }).collect() +} + +pub fn unique_path_match<'a>(widgets: &'a [WidgetSnapshot], selector: &Selector, rows: &[QueryRect], windowed: bool) -> Result<&'a WidgetSnapshot, String> { + selector.validate()?; + let path = selector.path.as_ref().ok_or_else(|| "native path matching requires a path selector".to_string())?; + let [row] = rows else { + return if rows.is_empty() { Err(format!("no visible match for {selector:?}")) } else { Err(format!("ambiguous selector {selector:?}: {} native path matches", rows.len())) }; + }; + if row.id != path[path.len() - 1] { + return Err("native path row does not identify the requested leaf".into()); + } + let matches: Vec<_> = widgets.iter().filter(|widget| { + if !widget.visible || widget.width <= 0 || widget.height <= 0 { return false; } + let mut x = widget.x; + let mut y = widget.y; + if windowed { + let windows: Vec<_> = widgets.iter().filter(|candidate| candidate.widget_type == "Window" && candidate.visible && candidate.window_index == widget.window_index).collect(); + let [window] = windows.as_slice() else { return false; }; + x -= window.x; + y -= window.y; + } + widget.id == row.id && widget.widget_type == row.widget_type && x == row.x && y == row.y + && widget.width == row.width && widget.height == row.height + && selector.id.as_ref().is_none_or(|value| &widget.id == value) + && selector.text.as_ref().is_none_or(|value| widget.text.as_ref() == Some(value)) + && selector.widget_type.as_ref().is_none_or(|value| &widget.widget_type == value) + && selector.window_index.is_none_or(|value| widget.window_index == value) + && selector.value.as_ref().is_none_or(|value| widget.value.as_ref() == Some(value)) + }).collect(); + match matches.as_slice() { + [widget] => Ok(widget), + [] => Err(format!("no visible match for {selector:?}")), + _ => Err(format!("ambiguous selector {selector:?}: {} snapshot matches", matches.len())), + } +} + +pub fn unique_match<'a>(widgets: &'a [WidgetSnapshot], selector: &Selector) -> Result<&'a WidgetSnapshot, String> { + selector.validate()?; + if selector.path.is_some() { + return Err("path selectors require a correlated native Studio query".into()); + } + let region = selector.within.as_ref().map(|parent| unique_match(widgets, parent)).transpose()?; + let matches: Vec<_> = widgets.iter().filter(|widget| { + widget.visible && widget.width > 0 && widget.height > 0 + && selector.id.as_ref().is_none_or(|v| &widget.id == v) + && selector.text.as_ref().is_none_or(|v| widget.text.as_ref() == Some(v)) + && selector.widget_type.as_ref().is_none_or(|v| &widget.widget_type == v) + && selector.window_index.is_none_or(|v| widget.window_index == v) + && selector.value.as_ref().is_none_or(|v| widget.value.as_ref() == Some(v)) + && region.is_none_or(|r| widget.window_index == r.window_index + && widget.x >= r.x && widget.y >= r.y + && widget.x as i128 + widget.width as i128 <= r.x as i128 + r.width as i128 + && widget.y as i128 + widget.height as i128 <= r.y as i128 + r.height as i128) + }).collect(); + match matches.as_slice() { + [widget] => Ok(widget), + [] => Err(format!("no visible match for {selector:?}")), + _ => Err(format!("ambiguous selector {selector:?}: {} matches", matches.len())), + } +} + +pub fn input_center(widgets: &[WidgetSnapshot], target: &WidgetSnapshot, windowed: bool) -> Result<(f64, f64), String> { + let (mut x, mut y) = (target.x as f64 + target.width as f64 / 2.0, target.y as f64 + target.height as f64 / 2.0); + if windowed { + let windows: Vec<_> = widgets.iter().filter(|w| w.widget_type == "Window" && w.visible && w.window_index == target.window_index).collect(); + let [window] = windows.as_slice() else { return Err("windowed input requires exactly one matching Window geometry".into()); }; + x -= window.x as f64; + y -= window.y as f64; + } + Ok((x, y)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn widget() -> WidgetSnapshot { + WidgetSnapshot { id: "send".into(), text: Some("发送".into()), visible: true, enabled: true, width: 80, height: 40, ..Default::default() } + } + + #[test] + fn locator_requires_one_visible_match() { + let selector: Selector = serde_json::from_str(r#"{"text":"发送"}"#).unwrap(); + assert!(unique_match(&[], &selector).is_err()); + assert!(unique_match(&[widget(), widget()], &selector).is_err()); + assert!(unique_match(&[widget()], &selector).is_ok()); + let mut hidden = widget(); hidden.visible = false; + assert!(unique_match(&[hidden], &selector).is_err()); + let empty: Selector = serde_json::from_str("{}").unwrap(); + assert!(unique_match(&[widget()], &empty).is_err()); + } + + #[test] + fn standalone_coordinates_are_relative_to_the_observed_window() { + let window = WidgetSnapshot { widget_type: "Window".into(), visible: true, x: 1080, y: 457, ..Default::default() }; + let target = WidgetSnapshot { x: 1525, y: 741, width: 390, height: 44, ..widget() }; + assert_eq!(input_center(&[window.clone()], &target, true).unwrap(), (640.0, 306.0)); + assert_eq!(input_center(&[window], &target, false).unwrap(), (1720.0, 763.0)); + assert!(input_center(&[], &target, true).is_err()); + } + + #[test] + fn region_selector_distinguishes_sidebar_from_recent_rooms() { + let sidebar = WidgetSnapshot { id: "rooms_list".into(), width: 300, height: 800, ..widget() }; + let left = WidgetSnapshot { id: "room_name".into(), text: Some("project 2".into()), x: 20, y: 40, ..widget() }; + let right = WidgetSnapshot { x: 400, ..left.clone() }; + let widgets = [sidebar, left, right]; + let selector: Selector = serde_json::from_str(r#"{"text":"project 2","within":{"id":"rooms_list"}}"#).unwrap(); + assert_eq!(unique_match(&widgets, &selector).unwrap().x, 20); + } + + #[test] + fn path_match_uses_native_ownership_and_window_translation() { + let selector: Selector = serde_json::from_str(r#"{"path":["info_button","inner_button"],"id":"inner_button"}"#).unwrap(); + let window = WidgetSnapshot { widget_type: "Window".into(), visible: true, x: 1080, y: 457, ..Default::default() }; + let target = WidgetSnapshot { id: "inner_button".into(), widget_type: "Button".into(), visible: true, enabled: true, x: 1228, y: 529, width: 40, height: 40, ..Default::default() }; + let rows = parse_path_query_rects(&["220 inner_button Button 148 72 40 40".into()]).unwrap(); + assert_eq!(unique_path_match(&[window, target], &selector, &rows, true).unwrap().id, "inner_button"); + } + + #[test] + fn path_match_preserves_native_ambiguity_and_rejects_legacy_or_invalid_rows() { + let selector: Selector = serde_json::from_str(r#"{"path":["info_button","inner_button"]}"#).unwrap(); + let widget = WidgetSnapshot { id: "inner_button".into(), widget_type: "Button".into(), visible: true, width: 40, height: 40, ..Default::default() }; + let row = QueryRect { id: "inner_button".into(), widget_type: "Button".into(), x: 0, y: 0, width: 40, height: 40 }; + assert!(unique_path_match(&[widget], &selector, &[], false).unwrap_err().starts_with("no visible match")); + assert!(unique_path_match(&[], &selector, &[row.clone(), row], false).unwrap_err().starts_with("ambiguous selector")); + assert!(parse_path_query_rects(&["DB info_button DockTabs 0 0 40 40".into()]).is_err()); + assert!(serde_json::from_str::(r#"{"path":["info_button","-"]}"#).unwrap().validate().is_err()); + assert!(serde_json::from_str::(r#"{"path":["info_button","inner_button"],"within":{"id":"root"}}"#).unwrap().validate().is_err()); + let wrong_leaf = QueryRect { id: "unrelated".into(), widget_type: "Button".into(), x: 0, y: 0, width: 40, height: 40 }; + assert!(unique_path_match(&[], &selector, &[wrong_leaf], false).unwrap_err().contains("requested leaf")); + } + + #[test] + fn snapshot_only_locator_rejects_paths_at_every_selector_depth() { + let unrelated = widget(); + let direct: Selector = serde_json::from_str(r#"{"path":["info_button","inner_button"]}"#).unwrap(); + assert!(unique_match(&[unrelated.clone()], &direct).unwrap_err().contains("native Studio query")); + let nested: Selector = serde_json::from_str(r#"{"id":"send","within":{"path":["info_button","inner_button"]}}"#).unwrap(); + assert!(unique_match(&[unrelated], &nested).unwrap_err().contains("native Studio query")); + } + + proptest::proptest! { + #[test] + fn prop_locator_never_picks_an_ambiguous_widget(count in 0usize..20) { + let selector: Selector = serde_json::from_str(r#"{"id":"send"}"#).unwrap(); + proptest::prop_assert_eq!(unique_match(&vec![widget(); count], &selector).is_ok(), count == 1); + } + + #[test] + fn prop_windowed_coordinates_are_translation_invariant(dx in -10000i64..10000, dy in -10000i64..10000) { + let window = WidgetSnapshot { widget_type: "Window".into(), visible: true, x: dx, y: dy, ..Default::default() }; + let target = WidgetSnapshot { x: dx + 20, y: dy + 30, ..widget() }; + proptest::prop_assert_eq!(input_center(&[window], &target, true).unwrap(), (60.0, 50.0)); + } + } +} diff --git a/tools/ux-harness/src/main.rs b/tools/ux-harness/src/main.rs index 2f8051c65..f3abb683d 100644 --- a/tools/ux-harness/src/main.rs +++ b/tools/ux-harness/src/main.rs @@ -18,13 +18,16 @@ //! See README.md for the full loop. mod driver; +mod evidence; mod findings; mod frame; mod gate; +mod locator; mod proto; mod rules_runtime; mod rules_static; mod scenes; +mod studio; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -35,6 +38,13 @@ use frame::{contrast_hex, contrast_ratio, parse_hex, relative_luminance}; fn main() { let args: Vec = std::env::args().collect(); let mode = args.get(1).map(|s| s.as_str()).unwrap_or(""); + if mode == "studio" { + if let Err(error) = studio::run_cli(&args[2..]) { + eprintln!("error: {error}"); + std::process::exit(1); + } + return; + } let mut repo = PathBuf::from("."); let mut app: Option = None; @@ -211,20 +221,26 @@ fn run(repo: &Path, app: Option<&Path>, out: &Path, gate_path: Option<&Path>) -> } Some(app_bin) => { let frames_dir = out.join("frames"); - // Point the app at a throwaway HOME so an audit can never read or - // overwrite the developer's real Matrix session, and give the - // headless shader JIT its own cache directory (it shells out to - // rustc, so it needs the toolchain on PATH — inherited). - let home = out.join("home"); + // Isolate application data without changing toolchain or OS homes. + let profile = out.join("profile"); let jit = out.join("jit"); - std::fs::create_dir_all(&home).map_err(|e| format!("create {}: {e}", home.display()))?; + std::fs::create_dir_all(&profile).map_err(|e| format!("create {}: {e}", profile.display()))?; + let profile = profile.canonicalize().map_err(|e| format!("resolve profile: {e}"))?; let env = vec![ - ("HOME".to_string(), home.to_string_lossy().to_string()), + ("ROBRIX_DATA_DIR".to_string(), profile.to_string_lossy().to_string()), ("MAKEPAD_HEADLESS_JIT_DIR".to_string(), jit.to_string_lossy().to_string()), ]; let mut driver = driver::Driver::launch(app_bin, &frames_dir, &env)?; - driver.wait_for_startup()?; - let outcome = scenes::run_all(&mut driver, &frames_dir)?; + let result = driver.wait_for_startup().and_then(|()| scenes::run_all(&mut driver, &frames_dir)); + let diagnostics = driver.diagnostic_text(); + std::fs::write(out.join("app.log"), &diagnostics).map_err(|e| format!("write app diagnostics: {e}"))?; + let outcome = result.map_err(|error| { + let failure = format!("{error}\n\n{diagnostics}"); + if let Err(write_error) = std::fs::write(out.join("failure.txt"), &failure) { + return format!("{failure}\nwrite failure evidence: {write_error}"); + } + failure + })?; driver.shutdown(); all.extend(outcome.findings); scenes_run.extend(outcome.records); diff --git a/tools/ux-harness/src/proto.rs b/tools/ux-harness/src/proto.rs index a623ccfb4..06c8e9f4c 100644 --- a/tools/ux-harness/src/proto.rs +++ b/tools/ux-harness/src/proto.rs @@ -14,7 +14,7 @@ //! We hand-write the subset instead of depending on `makepad_studio_protocol` //! so the harness stays a standalone binary that builds in seconds. -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; /// Mouse button bits as makepad's `MouseButton::from_bits_retain` expects @@ -28,6 +28,7 @@ pub const MOUSE_PRIMARY: u32 = 1; /// a UX scenario actually needs are mapped here. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Key { + A, Escape, Tab, Return, @@ -42,6 +43,7 @@ pub enum Key { impl Key { pub fn wire_index(self) -> u32 { match self { + Key::A => 30, Key::Escape => 0, Key::Backspace => 15, Key::Tab => 16, @@ -56,6 +58,7 @@ impl Key { pub fn parse(name: &str) -> Option { Some(match name.to_ascii_lowercase().as_str() { + "a" => Key::A, "escape" | "esc" => Key::Escape, "tab" => Key::Tab, "return" | "enter" => Key::Return, @@ -190,14 +193,14 @@ impl Msg { /// parsers do not. `WidgetSnapshot` ends in four optional fields, so nearly /// every snapshot line needs this. Quoted strings are skipped so a comma /// inside a label is never touched. -fn strip_trailing_commas(input: &str) -> String { +pub(crate) fn strip_trailing_commas(input: &str) -> String { let mut out = String::with_capacity(input.len()); - let bytes = input.as_bytes(); + let chars: Vec = input.chars().collect(); let mut in_string = false; let mut escaped = false; let mut i = 0; - while i < bytes.len() { - let c = bytes[i] as char; + while i < chars.len() { + let c = chars[i]; if in_string { out.push(c); if escaped { @@ -220,10 +223,10 @@ fn strip_trailing_commas(input: &str) -> String { // Look ahead past whitespace: a comma right before a closer is the // artifact we are removing. let mut j = i + 1; - while j < bytes.len() && (bytes[j] as char).is_ascii_whitespace() { + while j < chars.len() && chars[j].is_ascii_whitespace() { j += 1; } - if j < bytes.len() && (bytes[j] == b'}' || bytes[j] == b']') { + if j < chars.len() && (chars[j] == '}' || chars[j] == ']') { i += 1; continue; } @@ -237,7 +240,7 @@ fn strip_trailing_commas(input: &str) -> String { /// One widget as reported by makepad's `WidgetSnapshot`. This is the /// accessibility-tree-equivalent the harness reasons over: real post-layout /// geometry plus the semantics each widget chooses to expose. -#[derive(Clone, Debug, Deserialize, Default)] +#[derive(Clone, Debug, Deserialize, Serialize, Default)] pub struct WidgetSnapshot { pub id: String, pub widget_type: String, @@ -282,11 +285,26 @@ pub enum Incoming { WidgetSnapshot { request_id: u64, widgets: Vec }, WidgetTreeDump { request_id: u64, dump: String }, KeyFocusRect { x: Option, y: Option, width: Option, height: Option }, - Screenshot { request_ids: Vec, width: u32, height: u32 }, + Screenshot { request_ids: Vec, width: u32, height: u32, png: Vec }, Log(String), + ProtocolError(String), Other(String), } +#[derive(Deserialize)] +struct SnapshotResponse { + request_id: u64, + widgets: Vec, +} + +#[derive(Deserialize)] +struct ScreenshotResponse { + request_ids: Vec, + width: u32, + height: u32, + png: Vec, +} + /// Decode one stdout line. Unknown variants collapse to `Other` — makepad emits /// a lot of chatter (logs, cursor changes, flips) the harness does not care /// about, and new variants must not break an audit run. @@ -295,14 +313,11 @@ pub fn decode(line: &str) -> Option { if line.is_empty() { return None; } - // A Screenshot response inlines the PNG as a JSON array of bytes, so the - // line can run to megabytes. We never read those bytes — the headless - // backend has already written the same frame to MAKEPAD_HEADLESS_OUT_DIR — - // so skip the parse entirely rather than burn time on it. - if line.len() > 64 * 1024 && line.starts_with("{\"Screenshot\"") { - return Some(Incoming::Screenshot { request_ids: Vec::new(), width: 0, height: 0 }); - } - let v: Value = serde_json::from_str(&strip_trailing_commas(line)).ok()?; + let v: Value = match serde_json::from_str(&strip_trailing_commas(line)) { + Ok(value) => value, + Err(error) if line.starts_with('{') => return Some(Incoming::ProtocolError(format!("invalid Studio JSON: {error}"))), + Err(_) => return Some(Incoming::Log(line.to_string())), + }; let obj = v.as_object()?; let (tag, body) = obj.iter().next()?; Some(match tag.as_str() { @@ -310,13 +325,10 @@ pub fn decode(line: &str) -> Option { "AfterStartup" => Incoming::AfterStartup, "RequestAnimationFrame" => Incoming::RequestAnimationFrame, "WidgetSnapshot" => { - let inner = body.get(0)?; - let request_id = inner.get("request_id").and_then(|v| v.as_u64()).unwrap_or(0); - let widgets = inner - .get("widgets") - .and_then(|w| serde_json::from_value::>(w.clone()).ok()) - .unwrap_or_default(); - Incoming::WidgetSnapshot { request_id, widgets } + match serde_json::from_value::(body.get(0).cloned().unwrap_or(Value::Null)) { + Ok(response) => Incoming::WidgetSnapshot { request_id: response.request_id, widgets: response.widgets }, + Err(error) => Incoming::ProtocolError(format!("invalid WidgetSnapshot: {error}")), + } } "WidgetTreeDump" => { let inner = body.get(0)?; @@ -336,19 +348,51 @@ pub fn decode(line: &str) -> Option { } } "Screenshot" => { - let inner = body.get(0)?; - let request_ids = inner - .get("request_ids") - .and_then(|v| v.as_array()) - .map(|a| a.iter().filter_map(|x| x.as_u64()).collect()) - .unwrap_or_default(); - Incoming::Screenshot { - request_ids, - width: inner.get("width").and_then(|v| v.as_u64()).unwrap_or(0) as u32, - height: inner.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as u32, + match serde_json::from_value::(body.get(0).cloned().unwrap_or(Value::Null)) { + Ok(response) => Incoming::Screenshot { request_ids: response.request_ids, width: response.width, height: response.height, png: response.png }, + Err(error) => Incoming::ProtocolError(format!("invalid Screenshot: {error}")), } } "LogItem" => Incoming::Log(body.to_string()), other => Incoming::Other(other.to_string()), }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trailing_comma_cleanup_preserves_chinese() { + let input = "{\"text\":\"项目二,选择 agent\",}"; + assert_eq!(strip_trailing_commas(input), "{\"text\":\"项目二,选择 agent\"}"); + } + + proptest::proptest! { + #[test] + fn prop_protocol_cleanup_preserves_text(text in ".{0,100}") { + let encoded = serde_json::json!({"text":text}).to_string(); + proptest::prop_assert_eq!(strip_trailing_commas(&encoded), encoded); + } + } + + #[test] + fn malformed_widget_snapshot_is_error() { + let message = decode(r#"{"WidgetSnapshot":[{"request_id":17,"widgets":{"invalid":true}}]}"#); + assert!(!matches!(message, Some(Incoming::WidgetSnapshot { .. })), "malformed data must not be accepted as an empty tree"); + assert!(matches!(message, Some(Incoming::ProtocolError(_)))); + } + + #[test] + fn missing_snapshot_request_id_is_error() { + let message = decode(r#"{"WidgetSnapshot":[{"widgets":[]}]}"#); + assert!(!matches!(message, Some(Incoming::WidgetSnapshot { .. })), "missing request identity must not become zero"); + assert!(matches!(message, Some(Incoming::ProtocolError(_)))); + } + + #[test] + fn valid_empty_widget_snapshot_is_preserved() { + let message = decode(r#"{"WidgetSnapshot":[{"request_id":17,"widgets":[]}]}"#); + assert!(matches!(message, Some(Incoming::WidgetSnapshot { request_id: 17, widgets }) if widgets.is_empty())); + } +} diff --git a/tools/ux-harness/src/studio.rs b/tools/ux-harness/src/studio.rs new file mode 100644 index 000000000..3e12e07a2 --- /dev/null +++ b/tools/ux-harness/src/studio.rs @@ -0,0 +1,962 @@ +//! Attach to an explicitly selected local Studio build. This transport does not +//! own that build and never stops, replaces or clears it on disconnect. + +use std::net::{SocketAddr, TcpStream}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use makepad_micro_serde::{DeBin, DeJson, SerBin, SerJson}; +use makepad_studio_protocol::{StudioToApp, StudioToAppVec, hub_protocol::{ClientId, QueryId, ClientToHub, ClientToHubEnvelope, HubToClient}}; +use serde::Deserialize; +use tungstenite::{Message, WebSocket, HandshakeError}; +use crate::{evidence::{Diagnostics, validate_capture, write_capture}, locator::{Selector, input_center, parse_path_query_rects, unique_match, unique_path_match}, proto::{Key, Modifiers, Msg, WidgetSnapshot, strip_trailing_commas}}; + +pub struct StudioDriver { + socket: WebSocket, + client_id: ClientId, + counter: u64, + build_id: QueryId, + diagnostics: Diagnostics, + last_snapshot: Vec, + windowed: bool, +} + +fn transport_wait(deadline: Instant, now: Instant) -> Option { + let remaining = deadline.saturating_duration_since(now); + if remaining.is_zero() { None } else { Some(remaining.min(Duration::from_millis(50))) } +} + +// tungstenite can perform several underlying reads in one handshake/read call. +// Apply the same absolute deadline to each one, including continuous fragments. +#[derive(Debug)] +struct StudioStream { + stream: TcpStream, + deadline: Instant, +} + +impl StudioStream { + fn wait(&self) -> io::Result { + transport_wait(self.deadline, Instant::now()) + .ok_or_else(|| io::Error::new(io::ErrorKind::WouldBlock, "Studio phase deadline expired")) + } + + fn normalize_wait(result: io::Result) -> io::Result { + result.map_err(|error| match error.kind() { + // Platforms differ in the error used for a socket timeout. The + // handshake keeps its buffered state only for WouldBlock. + io::ErrorKind::TimedOut | io::ErrorKind::Interrupted => io::Error::new(io::ErrorKind::WouldBlock, error), + _ => error, + }) + } +} + +impl Read for StudioStream { + fn read(&mut self, bytes: &mut [u8]) -> io::Result { + self.stream.set_read_timeout(Some(self.wait()?))?; + Self::normalize_wait(self.stream.read(bytes)) + } +} + +impl Write for StudioStream { + fn write(&mut self, bytes: &[u8]) -> io::Result { + loop { + self.stream.set_write_timeout(Some(self.wait()?))?; + match Self::normalize_wait(self.stream.write(bytes)) { + // WouldBlock reports no written bytes. Retry this TCP write, + // retaining tungstenite's frame/cursor and the fixed deadline. + Err(error) if error.kind() == io::ErrorKind::WouldBlock => continue, + result => return result, + } + } + } + + fn flush(&mut self) -> io::Result<()> { + self.stream.set_write_timeout(Some(self.wait()?))?; + Self::normalize_wait(self.stream.flush()) + } +} + +fn snapshot_evidence(widgets: &[WidgetSnapshot]) -> Result, String> { + let mut widgets = widgets.to_vec(); + for widget in &mut widgets { + if widget.id.to_ascii_lowercase().contains("password") && widget.widget_type.contains("Input") { + widget.text = Some("[redacted]".into()); + widget.value = Some("[redacted]".into()); + } + } + serde_json::to_vec_pretty(&widgets).map_err(|e| e.to_string()) +} + +impl StudioDriver { + pub fn connect(address: &str, build_id: u64) -> Result { + let address: SocketAddr = address.parse().map_err(|e| format!("Studio address must be IP:port: {e}"))?; + if !address.ip().is_loopback() { return Err("Studio attachment requires a loopback address".into()); } + let stream = TcpStream::connect_timeout(&address, Duration::from_secs(5)).map_err(|e| format!("connect Studio: {e}"))?; + let deadline = Instant::now() + Duration::from_secs(5); + let stream = StudioStream { stream, deadline }; + let mut handshake = tungstenite::client(format!("ws://{address}/ui"), stream); + let socket = loop { + if transport_wait(deadline, Instant::now()).is_none() { return Err("Studio upgrade deadline expired".into()); } + match handshake { + Ok((socket, _)) => break socket, + Err(HandshakeError::Interrupted(partial)) => handshake = partial.handshake(), + Err(HandshakeError::Failure(error)) => return Err(format!("Studio upgrade: {error}")), + } + }; + let mut driver = Self { socket, client_id: ClientId(0), counter: 1, build_id: QueryId(build_id), diagnostics: Diagnostics::new(64 * 1024), last_snapshot: Vec::new(), windowed: false }; + match driver.receive(Instant::now() + Duration::from_secs(5), "Hello")? { + HubToClient::Hello { client_id } => driver.client_id = client_id, + _ => return Err("Studio did not start with Hello".into()), + } + Ok(driver) + } + + fn receive(&mut self, deadline: Instant, phase: &str) -> Result { + self.socket.get_mut().deadline = deadline; + loop { + if transport_wait(deadline, Instant::now()).is_none() { return Err(format!("Studio {phase} deadline expired")); } + let response = match self.socket.read() { + Ok(response) => response, + Err(tungstenite::Error::Io(error)) if error.kind() == io::ErrorKind::WouldBlock => continue, + Err(error) => return Err(format!("Studio {phase} read: {error}")), + }; + let message = match response { + Message::Binary(bytes) => HubToClient::deserialize_bin(&bytes).map_err(|e| format!("Studio {phase}: invalid binary response: {e:?}"))?, + Message::Text(text) => HubToClient::deserialize_json(&text).map_err(|e| format!("Studio {phase}: invalid JSON response: {e:?}"))?, + Message::Ping(_) | Message::Pong(_) => continue, + Message::Close(_) => return Err(format!("Studio {phase}: connection closed")), + _ => return Err(format!("Studio {phase}: unsupported websocket frame")), + }; + if transport_wait(deadline, Instant::now()).is_none() { return Err(format!("Studio {phase} deadline expired")); } + if let HubToClient::Error { message } = message { return Err(format!("Studio {phase}: {message}")); } + return Ok(message); + } + } + + fn send(&mut self, msg: ClientToHub, deadline: Instant, phase: &str) -> Result { + if transport_wait(deadline, Instant::now()).is_none() { return Err(format!("Studio {phase} deadline expired")); } + self.socket.get_mut().deadline = deadline; + let query_id = QueryId::new(self.client_id, self.counter); + self.counter += 1; + let envelope = ClientToHubEnvelope { query_id, msg }; + // A failed write may already have sent bytes. Report uncertainty and + // never recreate or resubmit this command on another connection. + self.socket.send(Message::Binary(envelope.serialize_bin().into())).map_err(|e| format!("Studio {phase} write: {e}"))?; + Ok(query_id) + } + + fn record_unrelated(&mut self, message: HubToClient) { + // A shared hub can send another build's widgets, files or frames. + // Keep correlation metadata, never that build's content in our log. + let line = match message { + HubToClient::WidgetSnapshot { query_id, build_id, widgets } => + format!("ignored snapshot query={} build={} widgets={}", query_id.0, build_id.0, widgets.len()), + HubToClient::Screenshot { query_id, build_id, width, height, .. } => + format!("ignored screenshot query={} build={} size={width}x{height}", query_id.0, build_id.0), + HubToClient::BuildStopped { build_id, exit_code } => + format!("build {} stopped: {exit_code:?}", build_id.0), + _ => "ignored unrelated Studio event".into(), + }; + self.diagnostics.push(line); + } + + fn snapshot_until(&mut self, deadline: Instant) -> Result, String> { + let query = self.send(ClientToHub::WidgetSnapshot { build_id: self.build_id }, deadline, "command")?; + loop { + match self.receive(deadline, "command")? { + HubToClient::WidgetSnapshot { query_id, build_id, widgets } if query_id == query && build_id == self.build_id => { + let json = strip_trailing_commas(&widgets.serialize_json()); + let widgets: Vec = serde_json::from_str(&json).map_err(|e| format!("invalid widget snapshot: {e}"))?; + self.last_snapshot = widgets.clone(); + return Ok(widgets); + } + HubToClient::BuildStopped { build_id, exit_code } if build_id == self.build_id => return Err(format!("selected build stopped: {exit_code:?}")), + other => self.record_unrelated(other), + } + } + } + + fn path_query_until(&mut self, path: &[String], deadline: Instant) -> Result, String> { + let query_text = format!("path:{}", path.join("/")); + let query_id = self.send(ClientToHub::WidgetQuery { build_id: self.build_id, query: query_text.clone() }, deadline, "path query")?; + loop { + match self.receive(deadline, "path query")? { + HubToClient::WidgetQuery { query_id: response_id, build_id, query, rects } + if response_id == query_id && build_id == self.build_id && query == query_text => return Ok(rects), + HubToClient::BuildStopped { build_id, exit_code } if build_id == self.build_id => return Err(format!("selected build stopped: {exit_code:?}")), + other => self.record_unrelated(other), + } + } + } + + fn input(&mut self, window: usize, msg: Msg) -> Result<(), String> { + // This fork's hub ignores RunViewInput.window_id. Until it routes that + // field, reject non-primary windows rather than misdirecting events. + if window != 0 { return Err("this Studio revision cannot route input to a non-primary window".into()); } + let msg = StudioToApp::deserialize_json(&msg.to_line()).map_err(|e| format!("incompatible input protocol: {e:?}"))?; + self.send(ClientToHub::RunViewInput { build_id: self.build_id, window_id: window, msg_bin: StudioToAppVec(vec![msg]).serialize_bin() }, Instant::now() + Duration::from_secs(5), "command")?; + Ok(()) + } + + fn locate(&mut self, selector: &Selector, timeout_ms: u64) -> Result { + if !(1..=60_000).contains(&timeout_ms) { return Err("locator timeout must be 1..60000 ms".into()); } + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + loop { + let path_rows = if let Some(path) = &selector.path { + Some(parse_path_query_rects(&self.path_query_until(path, deadline)?)?) + } else { + None + }; + let widgets = self.snapshot_until(deadline)?; + let result = if let Some(rows) = &path_rows { + unique_path_match(&widgets, selector, rows, self.windowed) + } else { + unique_match(&widgets, selector) + }; + match result { + Ok(widget) => return Ok(widget.clone()), + Err(error) => { + if !error.starts_with("no visible match") || Instant::now() >= deadline { return Err(error); } + self.diagnostics.push(error); + } + } + std::thread::sleep(Duration::from_millis(100).min(deadline.saturating_duration_since(Instant::now()))); + } + } + + fn click(&mut self, selector: &Selector) -> Result<(), String> { + let widget = self.locate(selector, 10_000)?; + if !widget.enabled { return Err(format!("widget {} is disabled", widget.id)); } + let (x, y) = input_center(&self.last_snapshot, &widget, self.windowed)?; + let time = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|e| e.to_string())?.as_secs_f64(); + self.input(widget.window_index, Msg::MouseMove { x, y, time, modifiers: Modifiers::default() })?; + self.input(widget.window_index, Msg::MouseDown { x, y, time, modifiers: Modifiers::default() })?; + self.input(widget.window_index, Msg::MouseUp { x, y, time, modifiers: Modifiers::default() }) + } + + fn scroll(&mut self, selector: &Selector, sx: f64, sy: f64) -> Result<(), String> { + if !sx.is_finite() || !sy.is_finite() { return Err("scroll deltas must be finite".into()); } + let widget = self.locate(selector, 10_000)?; + if !widget.enabled { return Err(format!("widget {} is disabled", widget.id)); } + let (x, y) = input_center(&self.last_snapshot, &widget, self.windowed)?; + let time = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|e| e.to_string())?.as_secs_f64(); + self.input(widget.window_index, Msg::Scroll { x, y, sx, sy, time }) + } + + fn capture(&mut self, out: &Path, label: &str) -> Result { + let deadline = Instant::now() + Duration::from_secs(25); + let query = self.send(ClientToHub::Screenshot { build_id: self.build_id, kind_id: Some(0) }, deadline, "capture")?; + loop { + match self.receive(deadline, "capture")? { + HubToClient::Screenshot { query_id, build_id, path, width, height, .. } if query_id == query && build_id == self.build_id => { + let bytes = std::fs::read(&path).map_err(|e| format!("read correlated Studio capture: {e}"))?; + validate_capture(query.0, &[query_id.0], width, height, &bytes)?; + return write_capture(out, label, &bytes); + } + other => self.record_unrelated(other), + } + } + } +} + +#[derive(Deserialize)] +#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] +enum Step { + Snapshot, + Click { selector: Selector, expect: Selector, #[serde(default = "default_step_timeout")] timeout_ms: u64 }, + Scroll { selector: Selector, sx: f64, sy: f64, expect: Selector, #[serde(default = "default_step_timeout")] timeout_ms: u64 }, + Type { text: String, expect: Selector, #[serde(default = "default_step_timeout")] timeout_ms: u64 }, + Key { key: String, #[serde(default)] logo: bool, #[serde(default)] shift: bool, expect: Selector, #[serde(default = "default_step_timeout")] timeout_ms: u64 }, + Wait { selector: Selector, timeout_ms: u64 }, + Capture { label: String }, +} + +fn default_step_timeout() -> u64 { 10_000 } + +impl Step { + fn validate(&self) -> Result<(), String> { + match self { + Self::Click { expect, timeout_ms, .. } | Self::Scroll { expect, timeout_ms, .. } + | Self::Type { expect, timeout_ms, .. } | Self::Key { expect, timeout_ms, .. } + | Self::Wait { selector: expect, timeout_ms } => { + expect.validate()?; + if !(1..=60_000).contains(timeout_ms) { return Err("timeout must be 1..60000 ms".into()); } + } + _ => {} + } + match self { + Self::Click { selector, .. } => selector.validate()?, + Self::Scroll { selector, sx, sy, .. } => { + selector.validate()?; + if !sx.is_finite() || !sy.is_finite() { return Err("scroll deltas must be finite".into()); } + } + Self::Key { key, .. } => { + if Key::parse(key).is_none() { return Err(format!("unsupported key: {key}")); } + } + Self::Capture { label } => { + if label.is_empty() || !label.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') { + return Err("capture label must contain only letters, digits, '-' or '_'".into()); + } + } + _ => {} + } + Ok(()) + } +} + +pub fn run_cli(args: &[String]) -> Result<(), String> { + let mut address = "127.0.0.1:18001".to_string(); + let mut build = None; + let mut steps = None; + let mut windowed = None; + let mut out = PathBuf::from("target/ux-studio"); + let mut i = 0; + while i < args.len() { + let value = args.get(i + 1).ok_or_else(|| format!("missing value for {}", args[i]))?; + match args[i].as_str() { + "--address" => address = value.clone(), + "--app-mode" => windowed = Some(match value.as_str() { "windowed" => true, "embedded" => false, _ => return Err("--app-mode must be windowed or embedded".into()) }), + "--build-id" => build = Some(value.parse().map_err(|_| "invalid build ID")?), + "--steps" => steps = Some(PathBuf::from(value)), + "--out" => out = PathBuf::from(value), + other => return Err(format!("unknown Studio argument: {other}")), + } + i += 2; + } + let build = build.ok_or("Studio requires explicit --build-id")?; + let windowed = windowed.ok_or("Studio requires --app-mode windowed|embedded for explicit coordinate semantics")?; + let steps = steps.ok_or("Studio requires --steps ")?; + let steps: Vec = serde_json::from_slice(&std::fs::read(steps).map_err(|e| e.to_string())?).map_err(|e| format!("invalid Studio steps: {e}"))?; + if steps.is_empty() { return Err("Studio steps must not be empty".into()); } + for (index, step) in steps.iter().enumerate() { + step.validate().map_err(|e| format!("invalid Studio step {index}: {e}"))?; + } + if let Some(parent) = out.parent().filter(|parent| !parent.as_os_str().is_empty()) { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::create_dir(&out).map_err(|e| { + if e.kind() == std::io::ErrorKind::AlreadyExists { + "Studio output directory already exists; choose a new path for this run".to_string() + } else { format!("create Studio output directory: {e}") } + })?; + let mut driver = match StudioDriver::connect(&address, build) { + Ok(driver) => driver, + Err(error) => { + std::fs::write(out.join("failure.txt"), &error) + .map_err(|e| format!("{error}; evidence write failed: {e}"))?; + return Err(error); + } + }; + driver.windowed = windowed; + let result = (|| { + for (index, step) in steps.into_iter().enumerate() { + let start = Instant::now(); + match step { + Step::Snapshot => { driver.snapshot_until(Instant::now() + Duration::from_secs(10))?; } + Step::Click { selector, expect, timeout_ms } => { + driver.click(&selector)?; + driver.locate(&expect, timeout_ms)?; + } + Step::Scroll { selector, sx, sy, expect, timeout_ms } => { + driver.scroll(&selector, sx, sy)?; + driver.locate(&expect, timeout_ms)?; + } + Step::Type { text, expect, timeout_ms } => { + driver.input(0, Msg::TextInput { input: text })?; + driver.locate(&expect, timeout_ms)?; + } + Step::Key { key, logo, shift, expect, timeout_ms } => { + let key = Key::parse(&key).ok_or_else(|| format!("unsupported key: {key}"))?; + let modifiers = Modifiers { logo, shift, ..Default::default() }; + driver.input(0, Msg::KeyDown { key, time: 0.0, modifiers })?; + driver.input(0, Msg::KeyUp { key, time: 0.0, modifiers })?; + driver.locate(&expect, timeout_ms)?; + } + Step::Wait { selector, timeout_ms } => { driver.locate(&selector, timeout_ms)?; } + Step::Capture { label } => { + driver.capture(&out, &label)?; + driver.snapshot_until(Instant::now() + Duration::from_secs(10))?; + } + } + // Keep the exact correlated snapshot that satisfied the expected + // state. A later frame could already have moved beyond that state. + let widgets = &driver.last_snapshot; + let path = out.join(format!("step-{index:03}.json")); + std::fs::write(path, snapshot_evidence(widgets)?).map_err(|e| e.to_string())?; + eprintln!("Studio step {index}: observed {} widgets in {} ms", widgets.len(), start.elapsed().as_millis()); + } + Ok(()) + })(); + std::fs::write(out.join("studio.log"), driver.diagnostics.text()).map_err(|e| e.to_string())?; + if let Err(error) = &result { + std::fs::write(out.join("failure.txt"), error).map_err(|e| format!("{error}; evidence write failed: {e}"))?; + std::fs::write(out.join("last-snapshot.json"), snapshot_evidence(&driver.last_snapshot)?).map_err(|e| e.to_string())?; + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + #[test] + fn studio_transport_wait_boundary() { + let start = Instant::now(); + let deadline = start + Duration::from_millis(80); + assert_eq!(transport_wait(deadline, start), Some(Duration::from_millis(50))); + assert_eq!(transport_wait(deadline, start + Duration::from_millis(79)), Some(Duration::from_millis(1))); + assert_eq!(transport_wait(deadline, deadline), None); + assert_eq!(transport_wait(deadline, deadline + Duration::from_millis(1)), None); + } + + proptest::proptest! { + #[test] + fn prop_studio_transport_wait_never_extends_deadline(budget_ms in 0u64..60_001, elapsed_ms in 0u64..60_001) { + let start = Instant::now(); + let result = transport_wait(start + Duration::from_millis(budget_ms), start + Duration::from_millis(elapsed_ms)); + if elapsed_ms >= budget_ms { + proptest::prop_assert_eq!(result, None); + } else { + let wait = result.expect("a future deadline has a positive wait"); + proptest::prop_assert!(!wait.is_zero()); + proptest::prop_assert!(wait <= Duration::from_millis(50)); + proptest::prop_assert!(wait <= Duration::from_millis(budget_ms - elapsed_ms)); + } + } + } + + #[test] + fn studio_transport_slow_command_write_keeps_one_frame() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let stream = transport_test_accept(&listener); + let mut socket = tungstenite::accept(stream).unwrap(); + socket.send(Message::Binary(HubToClient::Hello { client_id: ClientId(3) }.serialize_bin().into())).unwrap(); + assert!(socket.get_mut().peek(&mut [0]).unwrap() > 0); + std::thread::sleep(Duration::from_millis(250)); + let mut received = Vec::new(); + while let Ok(Message::Binary(bytes)) = socket.read() { + let envelope = ClientToHubEnvelope::deserialize_bin(&bytes).unwrap(); + let ClientToHub::RunViewInput { msg_bin, .. } = envelope.msg else { panic!("expected input"); }; + let input = StudioToAppVec::deserialize_bin(&msg_bin).unwrap(); + let [StudioToApp::TextInput(input)] = input.0.as_slice() else { panic!("expected one text input"); }; + assert!(input.input.bytes().all(|byte| byte == b'x')); + received.push(input.input.len()); + } + received + }); + let mut driver = StudioDriver::connect(&address, 42).unwrap(); + let length = 8 * 1024 * 1024; + let result = driver.input(0, Msg::TextInput { input: "x".repeat(length) }); + drop(driver); + let received = server.join().unwrap(); + assert!(result.is_ok(), "a temporary write wait within the command budget must preserve the frame: {result:?}"); + assert_eq!(received, [length], "a partial write must neither truncate nor replay input"); + } + + fn transport_test_accept(listener: &TcpListener) -> TcpStream { + listener.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match listener.accept() { + Ok((stream, _)) => { + stream.set_nonblocking(false).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(30))).unwrap(); + stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap(); + return stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock && Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("test Studio did not receive a connection: {error}"), + } + } + } + + // Split the real server's HTTP response without reimplementing its + // protocol or accept-key calculation. Only the first three writes delay. + #[derive(Debug)] + struct FragmentedUpgrade { + stream: TcpStream, + interval: Duration, + started: Instant, + writes: u32, + } + + impl std::io::Read for FragmentedUpgrade { + fn read(&mut self, bytes: &mut [u8]) -> std::io::Result { + std::io::Read::read(&mut self.stream, bytes) + } + } + + impl std::io::Write for FragmentedUpgrade { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if self.writes < 3 { + let due = self.started + self.interval * self.writes; + std::thread::sleep(due.saturating_duration_since(Instant::now())); + self.writes += 1; + return std::io::Write::write(&mut self.stream, &bytes[..bytes.len().min(50)]); + } + std::io::Write::write(&mut self.stream, bytes) + } + + fn flush(&mut self) -> std::io::Result<()> { + std::io::Write::flush(&mut self.stream) + } + } + + fn fragmented_upgrade_probe(interval: Duration) -> (Result<(), String>, Duration) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let stream = transport_test_accept(&listener); + let stream = FragmentedUpgrade { stream, interval, started: Instant::now(), writes: 0 }; + if let Ok(mut socket) = tungstenite::accept(stream) { + let _hello = socket.send(Message::Binary(HubToClient::Hello { client_id: ClientId(3) }.serialize_bin().into())); + } + assert!(matches!(listener.accept(), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock), + "resuming an upgrade must not open a replacement connection"); + }); + let started = Instant::now(); + let result = StudioDriver::connect(&address, 42).map(|_| ()); + let elapsed = started.elapsed(); + server.join().unwrap(); + (result, elapsed) + } + + #[test] + fn studio_transport_upgrade_progress_cannot_extend_deadline() { + let (result, elapsed) = fragmented_upgrade_probe(Duration::from_secs(3)); + assert!(result.is_err(), "fragment progress must not extend the five-second upgrade budget; accepted after {elapsed:?}"); + let error = result.unwrap_err(); + assert!(error.contains("Studio upgrade deadline expired"), "wrong failing phase: {error}"); + assert!(elapsed < Duration::from_millis(5800), "upgrade exceeded its deadline tolerance: {elapsed:?}"); + } + + #[test] + fn studio_transport_fragmented_upgrade_succeeds_on_original_connection() { + let (result, elapsed) = fragmented_upgrade_probe(Duration::from_millis(150)); + assert!(result.is_ok(), "fragments inside the budget must retain handshake state: {result:?}"); + assert!(elapsed >= Duration::from_millis(250), "server must exercise delayed fragments"); + assert!(elapsed < Duration::from_secs(5)); + } + + #[test] + fn studio_transport_hello_timeout_names_phase() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let stream = transport_test_accept(&listener); + let mut socket = tungstenite::accept(stream).unwrap(); + assert!(socket.read().is_err(), "a client waiting for Hello must not submit commands"); + }); + let started = Instant::now(); + let result = StudioDriver::connect(&address, 42).map(|_| ()); + let elapsed = started.elapsed(); + server.join().unwrap(); + let error = result.unwrap_err(); + assert!(error.contains("Studio Hello deadline expired"), "wrong failing phase: {error}"); + assert!(elapsed >= Duration::from_millis(4900) && elapsed < Duration::from_secs(6), "unexpected Hello budget: {elapsed:?}"); + } + + fn silent_response_server(listener: TcpListener, capture: bool) -> usize { + let stream = transport_test_accept(&listener); + let mut socket = tungstenite::accept(stream).unwrap(); + socket.send(Message::Binary(HubToClient::Hello { client_id: ClientId(3) }.serialize_bin().into())).unwrap(); + let mut requests = 0; + while let Ok(Message::Binary(bytes)) = socket.read() { + let envelope = ClientToHubEnvelope::deserialize_bin(&bytes).unwrap(); + if capture { + assert!(matches!(envelope.msg, ClientToHub::Screenshot { build_id: QueryId(42), kind_id: Some(0) })); + } else { + assert!(matches!(envelope.msg, ClientToHub::WidgetSnapshot { build_id: QueryId(42) })); + } + requests += 1; + } + requests + } + + #[test] + fn studio_transport_command_timeout_names_phase_without_replay() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || silent_response_server(listener, false)); + let mut driver = StudioDriver::connect(&address, 42).unwrap(); + let started = Instant::now(); + let error = driver.snapshot_until(started + Duration::from_millis(150)).unwrap_err(); + let elapsed = started.elapsed(); + drop(driver); + let requests = server.join().unwrap(); + assert_eq!(requests, 1, "a response timeout must never resubmit the snapshot"); + assert!(error.contains("Studio command deadline expired"), "wrong failing phase: {error}"); + assert!(elapsed >= Duration::from_millis(140) && elapsed < Duration::from_secs(1), "unexpected command budget: {elapsed:?}"); + } + + #[test] + fn studio_path_query_requires_exact_correlation_before_snapshot_join() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let stream = transport_test_accept(&listener); + let mut socket = tungstenite::accept(stream).unwrap(); + socket.send(Message::Binary(HubToClient::Hello { client_id: ClientId(3) }.serialize_bin().into())).unwrap(); + let Message::Binary(bytes) = socket.read().unwrap() else { panic!("expected path query"); }; + let envelope = ClientToHubEnvelope::deserialize_bin(&bytes).unwrap(); + let ClientToHub::WidgetQuery { build_id, query } = &envelope.msg else { panic!("expected native widget query"); }; + assert_eq!(*build_id, QueryId(42)); + assert_eq!(query, "path:info_button/inner_button"); + socket.send(Message::Binary(HubToClient::WidgetQuery { + query_id: QueryId(envelope.query_id.0 + 1), build_id: QueryId(42), query: query.clone(), rects: vec!["wrong".into()], + }.serialize_bin().into())).unwrap(); + socket.send(Message::Binary(HubToClient::WidgetQuery { + query_id: envelope.query_id, build_id: QueryId(7), query: query.clone(), rects: vec!["wrong".into()], + }.serialize_bin().into())).unwrap(); + socket.send(Message::Binary(HubToClient::WidgetQuery { + query_id: envelope.query_id, build_id: QueryId(42), query: query.clone(), rects: vec!["220 inner_button Button 148 72 40 40".into()], + }.serialize_bin().into())).unwrap(); + }); + let mut driver = StudioDriver::connect(&address, 42).unwrap(); + let rows = driver.path_query_until(&["info_button".into(), "inner_button".into()], Instant::now() + Duration::from_secs(1)).unwrap(); + assert_eq!(rows, ["220 inner_button Button 148 72 40 40"]); + drop(driver); + server.join().unwrap(); + } + + #[test] + fn studio_transport_command_fragments_cannot_extend_deadline() { + #[derive(Debug)] + struct FragmentedResponse { stream: TcpStream, remaining: usize } + impl std::io::Read for FragmentedResponse { + fn read(&mut self, bytes: &mut [u8]) -> std::io::Result { + std::io::Read::read(&mut self.stream, bytes) + } + } + impl std::io::Write for FragmentedResponse { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if self.remaining > 0 { + self.remaining -= 1; + std::thread::sleep(Duration::from_millis(20)); + return std::io::Write::write(&mut self.stream, &bytes[..1]); + } + std::io::Write::write(&mut self.stream, bytes) + } + fn flush(&mut self) -> std::io::Result<()> { std::io::Write::flush(&mut self.stream) } + } + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let stream = transport_test_accept(&listener); + let mut socket = tungstenite::accept(FragmentedResponse { stream, remaining: 0 }).unwrap(); + socket.send(Message::Binary(HubToClient::Hello { client_id: ClientId(3) }.serialize_bin().into())).unwrap(); + let Message::Binary(bytes) = socket.read().unwrap() else { panic!("expected snapshot request"); }; + let envelope = ClientToHubEnvelope::deserialize_bin(&bytes).unwrap(); + assert!(matches!(envelope.msg, ClientToHub::WidgetSnapshot { build_id: QueryId(42) })); + socket.get_mut().remaining = 20; + let reply = HubToClient::WidgetSnapshot { query_id: envelope.query_id, build_id: QueryId(42), widgets: vec![] }; + let _late_reply = socket.send(Message::Binary(reply.serialize_bin().into())); + }); + let mut driver = StudioDriver::connect(&address, 42).unwrap(); + let started = Instant::now(); + let result = driver.snapshot_until(started + Duration::from_millis(150)); + let elapsed = started.elapsed(); + drop(driver); + server.join().unwrap(); + assert!(result.is_err(), "continuous frame progress must not make a late snapshot successful: {elapsed:?}"); + assert!(elapsed < Duration::from_millis(300), "an outer receive-loop deadline is insufficient: {elapsed:?}"); + } + + #[test] + fn studio_transport_capture_timeout_names_phase_without_replay() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let directory = std::env::temp_dir().join(format!("ux-studio-transport-capture-{}", std::process::id())); + std::fs::create_dir(&directory).unwrap(); + let server = std::thread::spawn(move || silent_response_server(listener, true)); + let mut driver = StudioDriver::connect(&address, 42).unwrap(); + let started = Instant::now(); + let result = driver.capture(&directory, "absent"); + let elapsed = started.elapsed(); + drop(driver); + let requests = server.join().unwrap(); + let entries = std::fs::read_dir(&directory).unwrap().count(); + std::fs::remove_dir(&directory).unwrap(); + assert_eq!(requests, 1, "a capture timeout must never submit another screenshot"); + assert_eq!(entries, 0, "a missing frame must not create success evidence"); + let error = result.unwrap_err(); + assert!(error.contains("Studio capture deadline expired"), "wrong failing phase: {error}"); + assert!(elapsed >= Duration::from_millis(24900) && elapsed < Duration::from_secs(27), "unexpected capture budget: {elapsed:?}"); + } + + #[test] + fn studio_mutations_require_explicit_postconditions() { + for json in [ + r#"{"action":"click","selector":{"id":"send"}}"#, + r#"{"action":"scroll","selector":{"id":"threads_list"},"sx":0,"sy":420}"#, + r#"{"action":"type","text":"hello"}"#, + r#"{"action":"key","key":"Return"}"#, + ] { + assert!(serde_json::from_str::(json).is_err(), + "a mutation without an observable postcondition must be rejected: {json}"); + } + } + + #[test] + fn studio_validates_the_complete_plan_before_any_input() { + let directory = std::env::temp_dir().join(format!("ux-studio-invalid-plan-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + for invalid in [ + r#"{"action":"type","text":"hello","expect":{"id":"composer"},"timeout_ms":0}"#, + r#"{"action":"click","selector":{"id":"send"},"expect":{"id":"sent"},"timeout_ms":60001}"#, + r#"{"action":"type","text":"hello","expect":{}}"#, + r#"{"action":"click","selector":{"id":"send","within":{}},"expect":{"id":"sent"}}"#, + r#"{"action":"key","key":"unsupported-key","expect":{"id":"sent"}}"#, + ] { + let steps = directory.join("steps.json"); + std::fs::write(&steps, format!(r#"[{{"action":"type","text":"first","expect":{{"id":"composer","value":"first"}}}},{invalid}]"#)).unwrap(); + let error = run_cli(&["--address".into(), "invalid-address".into(), "--build-id".into(), "42".into(), + "--app-mode".into(), "windowed".into(), "--steps".into(), steps.display().to_string(), + "--out".into(), directory.join("output").display().to_string()]).unwrap_err(); + assert!(error.starts_with("invalid Studio step 1:"), "validate later invalid step before connecting or sending the first input: {error}"); + assert!(!directory.join("output").exists()); + } + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn studio_rejects_existing_output_without_touching_its_evidence() { + let directory = std::env::temp_dir().join(format!("ux-studio-existing-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let steps = directory.join("steps.json"); + std::fs::write(&steps, r#"[{"action":"snapshot"}]"#).unwrap(); + let old_failure = directory.join("failure.txt"); + std::fs::write(&old_failure, "previous run failure").unwrap(); + let result = run_cli(&["--address".into(), "invalid-address".into(), "--build-id".into(), "42".into(), + "--app-mode".into(), "windowed".into(), "--steps".into(), steps.display().to_string(), + "--out".into(), directory.display().to_string()]); + let error = result.unwrap_err(); + assert!(error.contains("output directory already exists"), "must reject output before connecting: {error}"); + assert_eq!(std::fs::read_to_string(&old_failure).unwrap(), "previous run failure"); + std::fs::remove_dir_all(directory).unwrap(); + } + + fn postcondition_probe(delayed: bool) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let directory = std::env::temp_dir().join(format!("ux-studio-postcondition-{}-{delayed}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let steps = directory.join("steps.json"); + std::fs::write(&steps, r#"[{"action":"type","text":"new value","expect":{"id":"composer","value":"new value"},"timeout_ms":1000}]"#).unwrap(); + let server = std::thread::spawn(move || { + listener.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + let stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock && Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)), + Err(error) => panic!("no Studio client: {error}"), + } + }; + stream.set_nonblocking(false).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + ws.send(Message::Binary(HubToClient::Hello { client_id: ClientId(3) }.serialize_bin().into())).unwrap(); + let (mut inputs, mut snapshots) = (0, 0); + while let Ok(Message::Binary(bytes)) = ws.read() { + let envelope = ClientToHubEnvelope::deserialize_bin(&bytes).unwrap(); + match envelope.msg { + ClientToHub::RunViewInput { build_id, msg_bin, .. } => { + assert_eq!(build_id, QueryId(42)); + let events = StudioToAppVec::deserialize_bin(&msg_bin).unwrap(); + assert!(matches!(events.0.as_slice(), [StudioToApp::TextInput(event)] if event.input == "new value")); + inputs += 1; + } + ClientToHub::WidgetSnapshot { build_id } => { + assert_eq!(build_id, QueryId(42)); + snapshots += 1; + let value = if delayed && snapshots >= 3 { "new value" } else { "old value" }; + let widgets = vec![makepad_studio_protocol::WidgetSnapshot { + id: "composer".into(), widget_type: "TextInput".into(), value: Some(value.into()), + visible: true, enabled: true, width: 200, height: 40, ..Default::default() + }]; + if ws.send(Message::Binary(HubToClient::WidgetSnapshot { query_id: envelope.query_id, build_id, widgets }.serialize_bin().into())).is_err() { break; } + } + _ => panic!("unexpected Studio request"), + } + } + (inputs, snapshots) + }); + let started = Instant::now(); + let result = run_cli(&["--address".into(), address, "--build-id".into(), "42".into(), + "--app-mode".into(), "windowed".into(), "--steps".into(), steps.display().to_string(), + "--out".into(), directory.join("output").display().to_string()]); + let (inputs, snapshots) = server.join().unwrap(); + assert_eq!(inputs, 1, "polling a postcondition must never replay input"); + if delayed { + assert!(result.is_ok(), "delayed expected state must pass: {result:?}"); + let widgets: Vec = serde_json::from_slice(&std::fs::read(directory.join("output/step-000.json")).unwrap()).unwrap(); + assert_eq!(widgets[0].value.as_deref(), Some("new value")); + assert!(snapshots >= 3); + } else { + assert!(result.is_err(), "ignored input must not pass on an unchanged snapshot"); + assert!(started.elapsed() < Duration::from_secs(5), "postcondition must have a bounded deadline"); + assert!(directory.join("output/failure.txt").is_file()); + assert!(directory.join("output/last-snapshot.json").is_file()); + assert!(!directory.join("output/step-000.json").exists()); + } + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn studio_ignored_input_cannot_pass_its_postcondition() { postcondition_probe(false); } + + #[test] + fn studio_waits_for_delayed_postcondition_without_replaying_input() { postcondition_probe(true); } + + #[test] + fn studio_scroll_targets_observed_container_and_records_new_state() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let directory = std::env::temp_dir().join(format!("ux-studio-scroll-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let steps = directory.join("steps.json"); + std::fs::write(&steps, r#"[{"action":"scroll","selector":{"id":"threads_list"},"sx":0,"sy":420,"expect":{"id":"threads_list","text":"new thread"}}]"#).unwrap(); + let server = std::thread::spawn(move || { + listener.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + let stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock && Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)), + Err(error) => panic!("no Studio client: {error}"), + } + }; + stream.set_nonblocking(false).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + ws.send(Message::Binary(HubToClient::Hello { client_id: ClientId(3) }.serialize_bin().into())).unwrap(); + for scrolled in [false, true] { + let Message::Binary(bytes) = ws.read().unwrap() else { panic!("expected snapshot request"); }; + let envelope = ClientToHubEnvelope::deserialize_bin(&bytes).unwrap(); + assert!(matches!(envelope.msg, ClientToHub::WidgetSnapshot { build_id: QueryId(42) })); + let widgets = vec![ + makepad_studio_protocol::WidgetSnapshot { widget_type: "Window".into(), visible: true, x: 100, y: 200, width: 800, height: 600, ..Default::default() }, + makepad_studio_protocol::WidgetSnapshot { id: "threads_list".into(), visible: true, enabled: true, x: 600, y: 250, width: 200, height: 400, text: Some(if scrolled { "new thread" } else { "old thread" }.into()), ..Default::default() }, + ]; + ws.send(Message::Binary(HubToClient::WidgetSnapshot { query_id: envelope.query_id, build_id: QueryId(42), widgets }.serialize_bin().into())).unwrap(); + if !scrolled { + let Message::Binary(bytes) = ws.read().unwrap() else { panic!("expected scroll input"); }; + let envelope = ClientToHubEnvelope::deserialize_bin(&bytes).unwrap(); + let ClientToHub::RunViewInput { build_id, window_id, msg_bin } = envelope.msg else { panic!("expected RunViewInput"); }; + assert_eq!(build_id, QueryId(42)); + assert_eq!(window_id, 0); + let events = StudioToAppVec::deserialize_bin(&msg_bin).unwrap(); + assert!(matches!(events.0.as_slice(), [StudioToApp::Scroll(event)] if event.x == 600.0 && event.y == 250.0 && event.sx == 0.0 && event.sy == 420.0)); + } + } + }); + let result = run_cli(&["--address".into(), address, "--build-id".into(), "42".into(), + "--app-mode".into(), "windowed".into(), "--steps".into(), steps.display().to_string(), + "--out".into(), directory.join("output").display().to_string()]); + assert!(result.is_ok(), "scroll must be supported: {result:?}"); + server.join().unwrap(); + let snapshot = std::fs::read_to_string(directory.join("output/step-000.json")).unwrap(); + assert!(snapshot.contains("new thread")); + assert!(!snapshot.contains("old thread")); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn studio_connection_failure_preserves_cli_evidence() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let directory = std::env::temp_dir().join(format!("ux-studio-connect-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let steps = directory.join("steps.json"); + std::fs::write(&steps, r#"[{"action":"snapshot"}]"#).unwrap(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + ws.close(None).unwrap(); + }); + let args = vec!["--address".into(), address, "--build-id".into(), "42".into(), + "--app-mode".into(), "windowed".into(), "--steps".into(), steps.display().to_string(), + "--out".into(), directory.join("output").display().to_string()]; + let error = run_cli(&args).unwrap_err(); + server.join().unwrap(); + assert_eq!(std::fs::read_to_string(directory.join("output/failure.txt")).ok().as_deref(), Some(error.as_str()), + "connection failures must remain reviewable in the output directory"); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn studio_snapshot_requires_matching_build_and_query() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + ws.send(Message::Binary(HubToClient::Hello { client_id: ClientId(3) }.serialize_bin().into())).unwrap(); + let Message::Binary(bytes) = ws.read().unwrap() else { panic!("expected binary request"); }; + let envelope = ClientToHubEnvelope::deserialize_bin(&bytes).unwrap(); + assert_eq!(envelope.query_id.client_id(), ClientId(3)); + assert!(matches!(envelope.msg, ClientToHub::WidgetSnapshot { build_id: QueryId(42) })); + for (query_id, build_id, label) in [ + (QueryId(envelope.query_id.0 + 16), QueryId(42), "stale request"), + (envelope.query_id, QueryId(99), "wrong build"), + (envelope.query_id, QueryId(42), "项目二"), + ] { + let widgets = vec![makepad_studio_protocol::WidgetSnapshot { text: Some(label.into()), ..Default::default() }]; + let reply = HubToClient::WidgetSnapshot { query_id, build_id, widgets }; + ws.send(Message::Binary(reply.serialize_bin().into())).unwrap(); + } + }); + let mut driver = StudioDriver::connect(&address, 42).unwrap(); + let snapshot = driver.snapshot_until(Instant::now() + Duration::from_secs(5)).unwrap(); + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].text.as_deref(), Some("项目二")); + assert!(!driver.diagnostics.text().contains("wrong build"), "foreign widget text must not leak into diagnostics"); + server.join().unwrap(); + } + + #[test] + fn studio_disconnect_is_error_not_empty_state() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + ws.send(Message::Binary(HubToClient::Hello { client_id: ClientId(3) }.serialize_bin().into())).unwrap(); + ws.read().unwrap(); + ws.close(None).unwrap(); + }); + let mut driver = StudioDriver::connect(&address, 42).unwrap(); + assert!(driver.snapshot_until(Instant::now() + Duration::from_secs(5)).is_err()); + server.join().unwrap(); + } + + #[test] + fn studio_input_uses_the_apps_vector_envelope() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut ws = tungstenite::accept(stream).unwrap(); + ws.send(Message::Binary(HubToClient::Hello { client_id: ClientId(3) }.serialize_bin().into())).unwrap(); + let Message::Binary(bytes) = ws.read().unwrap() else { panic!("expected binary input"); }; + let envelope = ClientToHubEnvelope::deserialize_bin(&bytes).unwrap(); + let ClientToHub::RunViewInput { build_id, window_id, msg_bin } = envelope.msg else { panic!("expected RunViewInput"); }; + assert_eq!(build_id, QueryId(42)); + assert_eq!(window_id, 0); + let events = StudioToAppVec::deserialize_bin(&msg_bin).expect("application decodes event vectors"); + assert!(matches!(events.0.as_slice(), [StudioToApp::TextInput(event)] if event.input == "项目二")); + }); + let mut driver = StudioDriver::connect(&address, 42).unwrap(); + driver.input(0, Msg::TextInput { input: "项目二".into() }).unwrap(); + server.join().unwrap(); + } +} diff --git a/tools/ux-harness/tests/spec_guard.rs b/tools/ux-harness/tests/spec_guard.rs new file mode 100644 index 000000000..ee7c72061 --- /dev/null +++ b/tools/ux-harness/tests/spec_guard.rs @@ -0,0 +1,27 @@ +//! Exercise the repository's actual CI shell entry point with an isolated CLI fixture. +use std::path::PathBuf; +use std::process::Command; + +fn run_regressions(args: &[&str]) { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let output = Command::new("python3") + .arg(root.join("scripts/test_spec_guard.py")) + .args(args) + .output() + .expect("Python 3 is required by the spec guard regression tests"); + assert!(output.status.success(), "{}\n{}", + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); +} + +#[test] +fn spec_guard_composition_regressions() { + run_regressions(&[]); +} + +proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(12))] + #[test] + fn prop_spec_guard_composition_keeps_all_owners(count in 2usize..7, unowned in proptest::bool::ANY) { + run_regressions(&["--generated", &count.to_string(), if unowned { "1" } else { "0" }]); + } +}