diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 597aed1bb..b0ba9d19b 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -176,6 +176,25 @@ BitFun 不是只在本地运行的桌面应用:工作区、执行这一轮的 改动说明中要写清楚在哪些远程场景下验证过。只跑本地测试不能作为远程行为的证据。 +### 升级兼容性 + +用户是原地升级的,而上述远程场景经常让两个不同版本的 BitFun 连在同一条链路上。 +任何改动都必须保证已有安装在升级后无需手工修复即可继续工作。 + +- **落盘结构会被新旧两侧代码同时读取。** 配置、设置、会话、连接配置、worktree 和 + dispatch 记录:新增字段要带默认值,反序列化要保持容错,绝不重新定义或收窄已经落盘 + 字段的语义。旧数据给不出的字段,不能变成必填。 +- **不要用删除或重置用户数据的方式来“恢复”解析不了的内容。** 应保留记录、降级功能并 + 给出明确状态。凭证缺失、配置读不出、超时或主机离线,都不构成丢弃会话、工作区或连接 + 的理由;销毁性删除只能是用户的显式操作。 +- **跨版本边界要协商,不能假设。** Peer HostInvoke、dispatch 协议、relay 与 mobile web、 + IM Bot,对面都是你控制不了的构建版本。要先声明 capability 再使用——包版本相同不等于 + 行为相同——并且要让旧版本一侧留在可用路径上,而不是直接判失败。 +- **改名就是一次迁移。** 在所有受支持的对端都不可能再发送旧名称、旧 id 或旧结构之前, + 必须继续兼容读取;被改名对象所引用的数据(vault 条目、工作区指针)要一并迁移。 +- **用测试证明。** 要覆盖旧数据反序列化和旧载荷往返,而不只是新结构。只验证当前代码 + 自己写出的数据,不算升级兼容性覆盖。 + ### Agent loop 行为 - 不要把硬编码限制或模式判断作为处理 agent loop 循环问题的第一反应,例如仅按字符串或次数阻止重复工具调用。 diff --git a/AGENTS.md b/AGENTS.md index 906325923..00781ad2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,6 +206,34 @@ Per-scenario obligations: State which remote scenarios a change was exercised in. Local-only tests are not evidence of remote behavior. +### Upgrade compatibility + +Users upgrade in place, and the remote scenarios above routinely put two +different BitFun versions on the same connection. Every change must keep +existing installs working without manual repair. + +- **Persisted shapes are read by older and newer code.** Config, settings, + sessions, connection profiles, worktree and dispatch records: add fields with + defaults, keep deserialization tolerant, and never repurpose or narrow the + meaning of a field that is already on disk. A field old data cannot supply + must not become required. +- **Never delete or reset user data to recover from something you cannot + parse.** Keep the record, degrade the feature, and surface a clear state. + Missing credentials, an unreadable profile, a timeout, or an offline host are + not reasons to drop a session, workspace, or connection. Destructive removal + stays an explicit user action. +- **Cross-version boundaries negotiate; they do not assume.** Peer HostInvoke, + the dispatch protocol, relay and mobile web, and IM bots all talk to a build + you do not control. Advertise a capability and check it before using it — + package version equality is not evidence of behavior — and keep the older + side on a working path instead of failing it. +- **A rename is a migration.** Keep reading the old name, id, or record shape + until no supported peer can still send it, and migrate referenced data + (vault entries, workspace pointers) together with the thing being renamed. +- **Prove it with tests.** Cover legacy deserialization and an old-payload + round trip, not just the new shape. A test that only exercises data written + by the current code is not upgrade coverage. + ### Agent loop behavior - Do not add hard-coded limits or pattern checks to the agent loop as a first response to looping behavior, such as blocking repeated tool calls by string or count alone. diff --git a/Cargo.lock b/Cargo.lock index f11c798f3..b020c4be3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -257,6 +257,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -494,6 +503,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -721,15 +739,30 @@ dependencies = [ "which 4.4.2", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -750,9 +783,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -991,8 +1024,7 @@ dependencies = [ "bitfun-services-core", "chrono", "clap", - "crossterm", - "dashmap", + "crossterm 0.28.1", "dirs 6.0.0", "dunce", "flate2", @@ -1014,7 +1046,6 @@ dependencies = [ "shlex 1.3.0", "similar", "syntect", - "syntect-tui", "tar", "tempfile", "thiserror 2.0.19", @@ -1022,7 +1053,7 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", "tracing", "tracing-subscriber", - "unicode-width 0.2.0", + "unicode-width", "url", "uuid", "windows 0.61.3", @@ -1107,14 +1138,12 @@ dependencies = [ "terminal-core", "thiserror 2.0.19", "tokio", - "tokio-stream", "tokio-tungstenite", "tokio-util", "tool-runtime", "tower-http", "ts-rs", "unic-langid", - "urlencoding", "uuid", ] @@ -1138,7 +1167,7 @@ dependencies = [ "atspi", "axum", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.1", "bitfun-acp", "bitfun-agent-runtime", "bitfun-agent-tools", @@ -1189,7 +1218,6 @@ dependencies = [ "tauri-plugin-autostart", "tauri-plugin-dialog", "tauri-plugin-fs", - "tauri-plugin-global-shortcut", "tauri-plugin-log", "tauri-plugin-notification", "tauri-plugin-opener", @@ -1283,7 +1311,6 @@ dependencies = [ "tower-http", "tracing", "url", - "urlencoding", "uuid", "zip 4.6.1", ] @@ -1324,7 +1351,6 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.19", - "tokio", ] [[package]] @@ -1853,6 +1879,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.2" @@ -1915,7 +1947,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cairo-sys-rs", "glib", "libc", @@ -1994,12 +2026,6 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - [[package]] name = "castaway" version = "0.2.4" @@ -2271,20 +2297,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "compact_str" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - [[package]] name = "compact_str" version = "0.9.1" @@ -2400,7 +2412,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -2424,7 +2436,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "libc", ] @@ -2477,6 +2489,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "cron" version = "0.15.0" @@ -2537,7 +2555,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crossterm_winapi", "mio", "parking_lot", @@ -2547,6 +2565,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -2585,6 +2621,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf 0.11.3", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -2700,12 +2746,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "custom_error" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f8a51dd197fa6ba5b4dc98a990a43cc13693c23eb0089ebb0fcc1f04152bca6" - [[package]] name = "dark-light" version = "1.1.1" @@ -2871,6 +2911,12 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "der" version = "0.7.10" @@ -3037,7 +3083,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -3106,13 +3152,22 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dom_query" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d9c2e7f1d22d0f2ce07626d259b8a55f4a47cb0938d4006dd8ae037f17d585e" dependencies = [ - "bit-set", + "bit-set 0.8.0", "cssparser 0.36.0", "foldhash 0.2.0", "html5ever 0.36.1", @@ -3127,7 +3182,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" dependencies = [ - "bit-set", + "bit-set 0.8.0", "cssparser 0.37.0", "foldhash 0.2.0", "html5ever 0.39.0", @@ -3499,13 +3554,12 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fancy-regex" -version = "0.16.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", + "bit-set 0.5.3", + "regex", ] [[package]] @@ -3597,6 +3651,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -3800,7 +3866,7 @@ version = "7.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "futures-core", "futures-lite", "pin-project", @@ -4029,7 +4095,7 @@ version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "unicode-width 0.2.0", + "unicode-width", ] [[package]] @@ -4150,7 +4216,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", "libgit2-sys", "log", @@ -4165,7 +4231,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "futures-channel", "futures-core", "futures-executor", @@ -4212,24 +4278,6 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" -[[package]] -name = "global-hotkey" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" -dependencies = [ - "crossbeam-channel", - "keyboard-types", - "objc2 0.6.4", - "objc2-app-kit", - "once_cell", - "serde", - "thiserror 2.0.19", - "windows-sys 0.59.0", - "x11rb", - "xkeysym", -] - [[package]] name = "globset" version = "0.4.19" @@ -4443,6 +4491,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -4994,7 +5044,7 @@ version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "inotify-sys", "libc", ] @@ -5103,9 +5153,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -5365,6 +5415,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.19", +] + [[package]] name = "keepawake" version = "0.6.0" @@ -5386,7 +5447,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "serde", "unicode-segmentation", ] @@ -5416,7 +5477,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", ] @@ -5432,6 +5493,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.0" @@ -5629,10 +5696,13 @@ dependencies = [ ] [[package]] -name = "linked-hash-map" -version = "0.5.6" +name = "line-clipping" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.1", +] [[package]] name = "linux-raw-sys" @@ -5652,6 +5722,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "local-ip-address" version = "0.6.13" @@ -5685,7 +5761,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67513274c50a2b51e5f75d9e682fcf4ab064a8a9c9ae2c3c59309084882bb24d" dependencies = [ "aes", - "bitflags 2.11.1", + "bitflags 2.13.1", "cbc", "chrono", "ecb", @@ -5711,11 +5787,11 @@ dependencies = [ [[package]] name = "lru" -version = "0.12.5" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.17.1", ] [[package]] @@ -5883,6 +5959,12 @@ dependencies = [ "libc", ] +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + [[package]] name = "memoffset" version = "0.6.5" @@ -6006,7 +6088,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -6036,7 +6118,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "byteorder", "derive_builder", "getset", @@ -6098,7 +6180,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -6110,7 +6192,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.2.2", "libc", @@ -6148,7 +6230,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "fsevent-sys", "inotify", "kqueue", @@ -6180,7 +6262,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -6249,6 +6331,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -6380,7 +6473,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -6411,7 +6504,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-foundation", ] @@ -6434,7 +6527,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", ] @@ -6444,7 +6537,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-foundation", ] @@ -6455,7 +6548,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "dispatch2", "libc", @@ -6468,7 +6561,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -6501,7 +6594,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", "objc2-core-audio", @@ -6526,7 +6619,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -6538,7 +6631,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -6566,7 +6659,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -6590,7 +6683,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "dispatch2", "libc", @@ -6604,7 +6697,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -6625,7 +6718,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-app-kit", "objc2-foundation", @@ -6637,7 +6730,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation", @@ -6649,7 +6742,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -6660,7 +6753,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-cloud-kit", @@ -6710,7 +6803,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-app-kit", @@ -6738,7 +6831,7 @@ version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", "once_cell", "onig_sys", @@ -6811,6 +6904,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-multimap" version = "0.4.3" @@ -6902,7 +7004,7 @@ dependencies = [ "textwrap", "thiserror 2.0.19", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width", ] [[package]] @@ -6934,7 +7036,7 @@ version = "0.138.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22bf885a47f8e0562ae73e0487ec8f83358bbbca8aad99a8293a9919d6d9e7fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "oxc_allocator", "oxc_ast_macros", "oxc_data_structures", @@ -6976,7 +7078,7 @@ version = "0.138.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b9feb869721e14ab8484c697372c468a3df3cb96ca8c02bfe34981fc8d614e9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cow-utils", "dragonbox_ecma", "itoa", @@ -7063,7 +7165,7 @@ version = "0.138.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd79d7f27f20413eaeecada4d850aeb0220e70c821d4863d9fb0df115eae62a9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cow-utils", "memchr", "num-bigint", @@ -7087,7 +7189,7 @@ version = "0.138.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95bee883f864fb7c75d92ccae3b7db98afc4dce5d0b8b5165e681d93cb010399" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "oxc_allocator", "oxc_ast_macros", "oxc_diagnostics", @@ -7140,7 +7242,7 @@ version = "0.138.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8faf48e243cdacc96018515701bea560664a4cdef545c1fc50be139c0637db13" dependencies = [ - "compact_str 0.9.1", + "compact_str", "oxc-miette", "oxc_allocator", "oxc_ast_macros", @@ -7154,7 +7256,7 @@ version = "0.138.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7beac249fbb9815b974f1b1c2d22cb59be0c2a4a2ad42f1ee948e2600f4ff04c" dependencies = [ - "compact_str 0.9.1", + "compact_str", "hashbrown 0.17.1", "oxc_allocator", "oxc_estree", @@ -7166,7 +7268,7 @@ version = "0.138.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbe873bf4a3e494a56ec34f2710cb44309a071fd2c8360b893a12347d584c34" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cow-utils", "dragonbox_ecma", "nonmax", @@ -7187,7 +7289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a71b406724d9e1b3bdf8c700207524268a232af7e13302aeea53f98168e05d" dependencies = [ "base64 0.22.1", - "compact_str 0.9.1", + "compact_str", "hmac-sha1-compact", "indexmap 2.14.0", "itoa", @@ -7290,6 +7392,39 @@ dependencies = [ "sha2", ] +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "pango" version = "0.18.3" @@ -7439,6 +7574,58 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.12.1" @@ -7470,6 +7657,16 @@ dependencies = [ "serde", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf_codegen" version = "0.13.1" @@ -7480,6 +7677,16 @@ dependencies = [ "phf_shared 0.13.1", ] +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.7", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -7500,6 +7707,19 @@ dependencies = [ "phf_shared 0.14.0", ] +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "phf_macros" version = "0.13.1" @@ -7526,6 +7746,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.12.1" @@ -7678,7 +7907,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -7907,7 +8136,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "679341d22c78c6c649893cbd6c3278dcbe9fc4faa62fea3a9296ae2b50c14625" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "getopts", "memchr", "pulldown-cmark-escape", @@ -8172,24 +8401,89 @@ checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" [[package]] name = "ratatui" -version = "0.29.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" dependencies = [ - "bitflags 2.11.1", - "cassowary", - "compact_str 0.8.2", - "crossterm", - "indoc", "instability", - "itertools 0.13.0", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools 0.14.0", + "kasuari", "lru", - "paste", - "strum 0.26.3", - "time", + "palette", + "serde", + "strum 0.28.0", + "thiserror 2.0.19", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm 0.29.0", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "strum 0.27.2", + "time", + "unicode-segmentation", + "unicode-width", ] [[package]] @@ -8198,7 +8492,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -8250,7 +8544,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -8598,7 +8892,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink 0.9.1", @@ -8615,7 +8909,7 @@ dependencies = [ "aes", "aes-gcm", "async-trait", - "bitflags 2.11.1", + "bitflags 2.13.1", "byteorder", "cbc", "chacha20 0.9.1", @@ -8716,7 +9010,7 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed8949eca4163c18a8f59ff96d32cf61e9c13b9735e21ef32b3907f4aafa1a9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "bytes", "chrono", "dashmap", @@ -8767,7 +9061,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -8780,7 +9074,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -9049,7 +9343,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -9072,13 +9366,13 @@ version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fdfed56cd634f04fe8b9ddf947ae3dc493483e819593d2ba17df9ad05db8b2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cssparser 0.36.0", "derive_more", "log", "new_debug_unreachable", "phf 0.13.1", - "phf_codegen", + "phf_codegen 0.13.1", "precomputed-hash", "rustc-hash 2.1.3", "servo_arc", @@ -9091,13 +9385,13 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cssparser 0.37.0", "derive_more", "log", "new_debug_unreachable", "phf 0.13.1", - "phf_codegen", + "phf_codegen 0.13.1", "precomputed-hash", "rustc-hash 2.1.3", "servo_arc", @@ -9348,7 +9642,7 @@ dependencies = [ "ioctl-rs", "libc", "serial-core", - "termios", + "termios 0.2.2", ] [[package]] @@ -9920,11 +10214,11 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.26.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.26.4", + "strum_macros 0.27.2", ] [[package]] @@ -9938,14 +10232,13 @@ dependencies = [ [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "rustversion", "syn 2.0.119", ] @@ -10048,30 +10341,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" dependencies = [ "bincode", - "fancy-regex", "flate2", "fnv", "once_cell", "onig", - "plist", "regex-syntax", "serde", "serde_derive", - "serde_json", "thiserror 2.0.19", "walkdir", - "yaml-rust", -] - -[[package]] -name = "syntect-tui" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24486acfb54bfcae77f45784cb59254e14454949a44f9d0b62613a699619c210" -dependencies = [ - "custom_error", - "ratatui", - "syntect", ] [[package]] @@ -10080,7 +10358,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -10114,7 +10392,7 @@ version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9fa4618f999c4249db1681cba0a19b890718f274de7fa93c445d46bd3a8a999" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "core-foundation 0.10.1", "core-graphics 0.25.0", @@ -10362,21 +10640,6 @@ dependencies = [ "url", ] -[[package]] -name = "tauri-plugin-global-shortcut" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" -dependencies = [ - "global-hotkey", - "log", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.19", -] - [[package]] name = "tauri-plugin-log" version = "2.9.0" @@ -10494,7 +10757,7 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "log", "serde", "serde_json", @@ -10681,6 +10944,18 @@ dependencies = [ "win32job", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom 7.1.3", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + [[package]] name = "termios" version = "0.2.2" @@ -10690,6 +10965,57 @@ dependencies = [ "libc", ] +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset 0.4.2", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf 0.11.3", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios 0.3.3", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "tesseract-plumbing" version = "0.8.0" @@ -10721,7 +11047,7 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "smawk", "unicode-linebreak", - "unicode-width 0.2.0", + "unicode-width", ] [[package]] @@ -11134,7 +11460,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "bytes", "futures-core", "futures-util", @@ -11345,6 +11671,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.2.1" @@ -11427,21 +11759,15 @@ checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" -version = "1.1.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools 0.13.0", + "itertools 0.14.0", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width", ] -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - [[package]] name = "unicode-width" version = "0.2.0" @@ -11569,6 +11895,7 @@ version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ + "atomic", "getrandom 0.4.3", "js-sys", "serde_core", @@ -11632,12 +11959,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" dependencies = [ "arrayvec", - "bitflags 2.11.1", + "bitflags 2.13.1", "cursor-icon", "log", "memchr", ] +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -11841,7 +12177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" dependencies = [ "phf 0.13.1", - "phf_codegen", + "phf_codegen 0.13.1", "string_cache", "string_cache_codegen", ] @@ -11959,6 +12295,78 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "which" version = "4.4.2" @@ -12899,15 +13307,6 @@ dependencies = [ "lzma-sys", ] -[[package]] -name = "yaml-rust" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" -dependencies = [ - "linked-hash-map", -] - [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 69db77145..5d48c7347 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,7 +120,7 @@ alloc-stdlib = "=0.2.2" regex = "1" base64 = "0.22" # Keep macOS Tauri's dispatch2/bitflags expansion on the known-good bitflags release. -bitflags = "=2.11.1" +bitflags = "2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] } md5 = "0.7" dashmap = "6" @@ -170,11 +170,10 @@ portable-pty = "0.8" vte = "0.15.0" clap = { version = "4.6.1", features = ["derive"] } crossterm = "0.28" -ratatui = "0.29" +ratatui = "0.30" unicode-width = "0.2" pulldown-cmark = "0.11" -syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "regex-fancy"] } -syntect-tui = "3.0" +syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "regex-onig"] } once_cell = "1" libc = "0.2" arboard = "3" @@ -207,7 +206,6 @@ tauri-plugin-log = "2.8" tauri-plugin-autostart = "2.5" tauri-plugin-notification = "2.3" tauri-plugin-updater = "2.10" -tauri-plugin-global-shortcut = "2.3" tauri-plugin-single-instance = "2.4" tauri-plugin-window-state = "2.4" tauri-build = { version = "2.6", features = [] } diff --git a/deny.toml b/deny.toml index d4f9d0a8b..a09017fd9 100644 --- a/deny.toml +++ b/deny.toml @@ -34,7 +34,6 @@ ignore = [ { id = "RUSTSEC-2026-0195", reason = "quick-xml NsReader OOM advisory; same quick-xml version constraint as RUSTSEC-2026-0194" }, { id = "RUSTSEC-2026-0186", reason = "memmap2 0.7/0.8 pinned by screenshots/enigo on Linux desktop; fix 0.9.11 is a breaking jump" }, { id = "RUSTSEC-2026-0187", reason = "lopdf 0.41 pinned by anydoc 0.1.6 (document conversion); fix 0.42 is a breaking jump" }, - { id = "RUSTSEC-2026-0002", reason = "lru 0.12 pinned by ratatui 0.29 (CLI TUI); fix 0.16.3 is a breaking jump" }, ] [bans] diff --git a/docs/architecture/extensions/plugin-runtime-design.md b/docs/architecture/extensions/plugin-runtime-design.md index bcaada2a7..599263449 100644 --- a/docs/architecture/extensions/plugin-runtime-design.md +++ b/docs/architecture/extensions/plugin-runtime-design.md @@ -281,11 +281,11 @@ plugin、Hook、完整 Client 或 TUI 插件入口。与其独立的 standalone 当前 Rust 边界调整至少运行: -- `cargo test -p bitfun-runtime-ports --test plugin_runtime_contracts` -- `cargo test -p bitfun-runtime-ports --test plugin_runtime_diagnostics_contracts` +- `cargo test -p bitfun-runtime-ports --test runtime_port_contracts plugin_runtime_contracts` +- `cargo test -p bitfun-runtime-ports --test runtime_port_contracts plugin_runtime_diagnostics_contracts` - `cargo test -p bitfun-plugin-runtime-client` - `cargo test -p bitfun-opencode-adapter --test opencode_source_adapter` -- `cargo test -p bitfun-core plugin_runtime::tests --lib` +- `cargo test -p bitfun-core --no-default-features --features plugin-runtime --lib plugin_runtime::tests` - `node scripts/check-core-boundaries.mjs` 目标 Plugin Host 还必须使用固定版本真实 fixture 验证: diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 9c171546b..b475bc124 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -820,6 +820,7 @@ flowchart LR | Desktop | 使用 `product-full`;Settings 从现有来源目录和 integration policy 生成简短应用概览,具体审批与冲突仍进入 Tool、Agent、MCP 或 Hook owner | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | | CLI / TUI | 使用显式 Core owner closure:`agent-runtime` 基线、实际 service owner(包括 Remote Connect、DeepResearch、LSP、external/plugin source 与 SSH)以及九组 `tools-*`;`/extensions` 只提供状态、启停和刷新,`/hooks`、`/tools`、`/agent` 和 `/mcp` 处理各自能力 | `agent-runtime` 不再隐式携带完整 MCP/Remote/Browser/Web/Git/LSP/模型目录闭包;非交互不等待权限输入,生态解析仍在适配器,远程能力未接入时不回退本机 | | ACP | 使用 `DeliveryProfile::Acp`、Runtime Parts、`agent-runtime` 基线、所需 service owner 与九组 `tools-*`,但不选择 CLI 的 plugin runtime 和 Remote Connect owner | load 成功后才发布活动状态;close 排空后再卸载;完整历史、Canvas 工具物化、兼容指令来源和配置仍由 Core/ACP 管理;未选择的能力不得借 Cargo feature union 偶然出现 | +| SDK Host(preview) | 使用 `DeliveryProfile::Sdk`、Runtime Parts 和与当前本机协议能力一致的显式 Core owner closure;TLS provider 由 Host 进程入口安装 | 当前协议不暴露远程 workspace/SSH 执行,因此不选择 Remote Connect、SSH 或 Function Agent owner;未来远程 SDK 必须复用 Server/Remote 的认证和执行域,不能回退到本机执行 | | Peer / Server | Peer Host 执行真实工作区操作;通用 HTTP Server 未绑定可信 workspace owner 时明确返回不支持 | 控制端不替远端发现或执行;loopback 单用户边界不扩展到远程/多用户;SSH Remote 未接入时返回不支持 | | Web / Mobile Web | 依赖现有后端入口 | 不持有插件执行单元,也不能据空 profile 宣称独立能力 | | HarmonyOS 手机 Remote | phone-only ArkTS 远程入口 | 不等于 HarmonyOS PC 本地 Runtime、CLI/TUI 或 GUI | diff --git a/docs/architecture/rust-build-dependency-boundaries.md b/docs/architecture/rust-build-dependency-boundaries.md index fb608230e..d1012a291 100644 --- a/docs/architecture/rust-build-dependency-boundaries.md +++ b/docs/architecture/rust-build-dependency-boundaries.md @@ -54,6 +54,12 @@ Cargo 会统一同一 package 在依赖图中的 feature;workspace dependency 入口应选择真实需要的 owner feature;`product-full` 只能描述确实需要完整产品装配的兼容入口,不能作为尚未完成 feature/owner 分解时的占位解法。缩小某个产品的 capability 集合时必须从实际 construction/command path 反推,并保留行为等价或明确 unsupported-state 测试。 +Core library 的默认 feature 集合为空;完整产品必须显式选择 `product-full`。若 interface crate 以多个 +公开角色复用一条 optional Core dependency,则 dependency 声明本身保持无 feature,每个角色 feature +分别激活 Core 并选择自己的非空 owner 闭包;兼容默认值只能组合这些已评审角色,不能重新引入 +`product-full`。每个角色必须独立编译,产品消费者还要显式关闭该 interface crate 的默认 feature 并选择 +真实使用的角色,避免 workspace feature union 掩盖边界缺口。 + Core 的 `agent-runtime` 只承载 Agent 生命周期基线和明确的基线工具,不得再次把 MCP、Remote Connect、模型目录、Browser/Web、Git/LSP 或产品工具组藏成 capability union。具体 service 由同名 owner feature 选择,内置工具由 `tools-*` 选择;`product-full` 显式相加全部 owner,CLI/ACP 等窄入口则按真实命令与构造路径列出自己的闭包。 Owner feature 不等于“无前置依赖”。当实现确实调用较低层基线时,依赖必须按 `owner → baseline` 显式组合,禁止反向把 owner 藏回基线:例如 Core MCP 工具桥和 Remote Connect 依赖 Agent 生命周期,Workspace Search 依赖本地 Workspace Runtime。每个新增或调整后的 owner 闭包都必须单独 `cargo check`,避免被 Desktop/CLI 的 feature union 偶然补齐。 diff --git a/docs/development/i18n.md b/docs/development/i18n.md index 5e254900a..367efab26 100644 --- a/docs/development/i18n.md +++ b/docs/development/i18n.md @@ -41,7 +41,7 @@ pnpm run i18n:audit | Web UI i18n runtime or namespace loading | `pnpm run i18n:contract:test && pnpm run type-check:web && pnpm --dir src/web-ui run test:run src/infrastructure/i18n/core/I18nService.test.ts` | | Mobile Web i18n runtime | `pnpm --dir src/mobile-web run type-check` | | Installer frontend i18n runtime | `pnpm --dir BitFun-Installer run type-check` | -| Backend i18n runtime | `cargo test -p bitfun-core i18n -- --nocapture` | +| Backend i18n runtime | `cargo test -p bitfun-core --no-default-features --lib i18n -- --nocapture` | Do not add process-only execution plans to version control. Keep durable rules in this file and `docs/architecture/i18n.md`; keep temporary rollout notes out diff --git a/docs/features/session-runtime-usage-report-design.md b/docs/features/session-runtime-usage-report-design.md index 08d32d3cd..2c423febd 100644 --- a/docs/features/session-runtime-usage-report-design.md +++ b/docs/features/session-runtime-usage-report-design.md @@ -689,8 +689,8 @@ Risk and drift controls: Required verification before merging P0: -- `cargo check -p bitfun-core` -- `cargo test -p bitfun-core session_usage -- --nocapture` +- `cargo check -p bitfun-core --no-default-features --features agent-runtime` +- `cargo test -p bitfun-core --no-default-features --features agent-runtime --lib session_usage -- --nocapture` - Focused CLI command tests or manual CLI smoke if no existing helper test harness exists. - `pnpm run lint:web` - `pnpm run type-check:web` @@ -1038,8 +1038,8 @@ Risks and mitigations: Verification: -- `cargo test -p bitfun-core session_usage -- --nocapture` once tests exist. -- `cargo check -p bitfun-core`. +- `cargo test -p bitfun-core --no-default-features --features agent-runtime --lib session_usage -- --nocapture` once tests exist. +- `cargo check -p bitfun-core --no-default-features --features agent-runtime`. - DTO tests for workspace identity, report scope, in-progress reports, cache-unavailable coverage, and redaction metadata. ### Task 2: Non-model-visible local report item diff --git a/docs/performance/01-compile-performance.md b/docs/performance/01-compile-performance.md index 509ed20b2..dd886aa34 100644 --- a/docs/performance/01-compile-performance.md +++ b/docs/performance/01-compile-performance.md @@ -1,8 +1,8 @@ # BitFun 编译与依赖治理计划 -> 最近核实:2026-08-10 +> 最近核实:2026-08-11 > -> 实现复核基线:`gcwing/main@734e5b05f` +> 实现复核基线:`gcwing/main@7345619ac` > > 性能 A/B 基线:`gcwing/main@1f538b96d` > @@ -16,10 +16,13 @@ | 结论 | 说明 | |---|---| -| 服务测试链接拓扑已收敛 | Services 两个 crate 的集成 target 总数从 33 降到 25;选中的 `local-storage`、MCP、基础 SSH 闭包从 16 个集成 executable 降到 8 个 | +| 集成测试链接拓扑已收敛 | Services 两个 crate 的集成 target 总数从 33 降到 25;External Sources 的 adapter/assembly target 从 22 降到 7;五个 Contracts/AI/Assembly crate 又从 28 降到 10,feature、平台和外部系统失败域保持独立 | | Agent Runtime 基线不再隐藏重型 capability | `bitfun-core/agent-runtime` 只保留生命周期和基础工具 owner;文档转换与订阅认证也改为产品显式 modifier。在最新主线 A/B 中,三平台 normal/build 闭包进一步减少 69/64/110 个版本化 package instance | | App Server 不继承未消费能力 | App Server 保持现有 Agent/Git/外部来源 handler 边界,不再因 Core 基线携带文档转换和本地订阅凭据,三平台闭包减少 61/56/78 | -| 完整产品行为和闭包保持 | `product-full` 显式组合全部 owner,Windows normal/build 闭包保持 570;CLI 保持 649。ACP 只退出未选择或未使用的隐含能力,累计在 Windows/macOS/Linux 分别减少 12/15/24 | +| SDK Host 使用显式能力闭包 | SDK Host 保留当前本机协议和工具能力,但不再通过 `product-full` 携带协议未暴露的 Remote Connect、SSH、Function Agent 等能力;Windows/macOS/Linux normal/build 闭包减少 66/68/76 | +| Core 默认值不再代表完整产品 | Core library 的默认 feature 集合为空,四条能力内部工具依赖回到实际 owner;`product-full` 仍由真实产品入口显式选择且三平台闭包不变 | +| ACP 按实际宿主拆分角色 | 兼容默认值仍为 client + server;Desktop 只选择 client,CLI 选择两者。Desktop 独立构建不再编译 ACP 的 4,211 行 server/runtime 源码,产品协议与远程行为不变 | +| 完整产品行为保持 | `product-full` 显式组合全部 owner且三平台闭包不变;CLI 删除未调用适配层时显式保留原先实际生效的 Oniguruma 高亮后端,三平台闭包进一步减少 6/7/7。ACP 默认组合保持原能力 | | Installer 删除未使用的直接能力 | 独立 manifest 的直接 dependency 从 18 降到 10,Windows normal/build 闭包减少 6;不把 Installer 并入根 workspace,本 PR 按要求不提交其生成 lockfile | | focused test 仍保持精确 | 同 owner、feature、平台和进程语义的源文件进入分组 target;使用 `--test ::` 运行单模块 | @@ -45,7 +48,9 @@ ## 3. 当前基线 -### 3.1 服务层测试拓扑 +### 3.1 集成测试链接拓扑 + +#### Services 本轮只合并 owner 和运行边界相同的测试。`session_write_lock_contracts` 依赖当前测试 executable 启动异常退出子进程,因此继续保持独立;不同 feature 的服务测试也不合并。 @@ -73,6 +78,24 @@ clean/rebuild,表中为均值。时间是方向性证据,不是硬阈值。 MCP 的 2→1 candidate 也做过同口径 A/B,但冷构建和 owner 重建均无可区分的提速;streamable HTTP 测试还拥有真实 loopback TCP/SSE/超时失败域,因此最终继续保持两个 target,不计入本轮收益。 +#### External Sources adapters 与 assembly + +同一 crate 内、相同依赖和运行边界的静态来源合同通过 wrapper target 收敛;测试正文逐字迁移,仍可用 +`--test ::` 聚焦到单个来源模块。OpenCode 的 MCP 子进程、受管插件服务和 Node 脚本 +runtime 分别保留独立 target,避免为了减少链接次数混合不同环境、超时和故障语义。 + +| 范围 | 变更前 target | 变更后 target | 集成测试数 | +|---|---:|---:|---:| +| OpenCode adapter | 8 | 4 | 130 | +| Claude Code adapter | 4 | 1 | 51 | +| Codex adapter | 3 | 1 | 47 | +| External Sources assembly | 7 | 1 | 30 | +| 合计 | 22 | 7 | 258 | + +这部分只减少 15 个重复链接的 test executable;未新增 dependency、feature 或 CI 命令,也不以当前证据 +宣称 wall-clock 提速。Cargo 边界检查锁定显式 target、wrapper-only root、leaf 唯一引用和 crate-level cfg, +避免后续新增测试静默绕过分组拓扑。 + 可重复确认的产物变化如下;`test executable` 包含每个 crate 的 lib test harness,因此比 integration target 多 1。PDB 大小会随工具链变化,只比较同次 A/B: @@ -81,6 +104,26 @@ target 多 1。PDB 大小会随工具链变化,只比较同次 A/B: | local-storage | 13 → 6 | 25.2 → 19.2 MiB | 135.7 → 91.9 MiB | | 基础 Remote SSH | 3 → 2 | 3.9 → 2.8 MiB | 53.5 → 43.8 MiB | +#### Contracts、AI adapters 与 Product Assembly + +五个纯合同/组装 owner 使用显式 wrapper target;AI 的纯协议测试与真实 loopback SSE 测试继续分成两个 +失败域,Product Domains 的默认、Plugin Source、External Sources、Function Agent 与 MiniApp 也继续按 +owner feature 分开。270 个 integration tests 不变,模块过滤仍可聚焦单个 leaf: + +| 范围 | 变更前 target | 变更后 target | 集成测试数 | +|---|---:|---:|---:| +| `core-types` | 4 | 1 | 10 | +| `runtime-ports` | 5 | 1 | 21 | +| `product-domains` | 9 | 5 | 179 | +| `ai-adapters` | 7 | 2 | 29 | +| `product-capabilities` | 3 | 1 | 31 | +| 合计 | 28 | 10 | 270 | + +对应五个 lib test harness 的 test executable 总数从 33 降到 15;workspace integration target 从 +91 降到 73。该变化减少 18 次重复链接,但单叶变更会重链所属分组,因此这里只报告确定的拓扑收益, +不在缺少同机多轮 A/B 时宣称 wall-clock 提速。边界检查锁定 exact leaf、owner feature 和空 +`required-features` 的默认 target,避免以后用 `product-full` 扩大测试闭包。 + ### 3.2 依赖与 feature 闭包使用 `cargo tree -e normal,build` 按目标平台统计版本化 package instance;它衡量进入编译图的 @@ -95,8 +138,10 @@ package/version,不等同于实际秒数。路径 package 因 A/B worktree 路 | Desktop | 792 → 792 | 807 → 807 | 892 → 892 | 完整产品继续使用既有跨平台截图行为,本轮不以扩大根 lock 依赖宇宙换取单平台闭包下降 | | Installer | 333 → 327 | — | — | Windows 独立 workspace;直接 dependency 18 → 10 | -在最新实现复核基线 `gcwing/main@734e5b05f` 上,本轮继续把两个重型能力从 Core 基线改为弱 -modifier。计数先移除 Cargo tree 的重复展示标记 `(*)`,再按 package/version 去重: +下表前五项延续 `gcwing/main@734e5b05f` 的已核实 A/B,SDK Host 行以 +`gcwing/main@22f5411e7` 为变更前基线。三平台 target 分别为 `x86_64-pc-windows-msvc`、 +`aarch64-apple-darwin` 和 `x86_64-unknown-linux-gnu`;计数先移除 Cargo tree 的重复展示标记 +`(*)`,再按 package/version 去重: | 本轮闭包 | Windows | macOS | Linux | 行为边界 | |---|---:|---:|---:|---| @@ -105,24 +150,69 @@ modifier。计数先移除 Cargo tree 的重复展示标记 `(*)`,再按 packa | Core `product-full` | 570 → 570 | 557 → 557 | 601 → 601 | 显式恢复 `document-read` 与 `subscription-auth` | | CLI | 649 → 649 | 649 → 649 | 672 → 672 | 显式保持原有能力 | | ACP | 589 → 587 | 574 → 572 | 594 → 592 | 保持原有能力,同时退出 Reqwest 未使用的 `mime_guess`/`unicase` | +| SDK Host | 578 → 512 | 565 → 497 | 609 → 533 | 保留本机 SDK profile、九组工具 owner、外部静态来源和 ring TLS;退出未暴露的 Remote Connect、SSH、Function Agent 与完整产品附属能力 | -本轮没有新增 crate 或第三方 dependency。收益来自两类现有重闭包退出窄入口:`anydoc` 及其 -文档解析/压缩依赖,以及订阅凭据的 keyring/加密/本地存储依赖。完整产品 package 集合不变, +本轮没有新增 crate 或第三方 package;SDK Host 只把已有测试依赖 `rustls` 调整为进程入口实际使用的 +normal dependency,根 lock package 集合不变。前两类收益来自 `anydoc` 及其文档解析/压缩依赖, +以及订阅凭据的 keyring/加密/本地存储依赖;SDK Host 的收益来自未公开远程能力对应的 +SSH、密钥和连接子图退出。完整产品 package 集合不变, 因此这里只报告依赖图收敛,不宣称 `product-full` wall-clock 提速。 +以下是以 `gcwing/main@3d8ee4bc0` 为变更前基线、使用同样三个 target triple 和去重口径复算的最新 A/B: + +| 最新闭包 | Windows | macOS | Linux | 行为边界 | +|---|---:|---:|---:|---| +| Core `--no-default-features` | 104 → 102 | 93 → 91 | 92 → 90 | 删除 Core 不再消费的 `tokio-stream`、`urlencoding` 直接边 | +| Core `product-full` | 570 → 570 | 557 → 557 | 601 → 601 | 完整产品仍从真实 adapter/service owner 获得两项依赖 | +| CLI | 649 → 643 | 649 → 642 | 672 → 665 | 删除未调用的 `syntect-tui`/`dashmap`;显式保留既有 Oniguruma 高亮后端 | +| Desktop | 792 → 790 | 807 → 805 | 892 → 887 | 删除从未注册、没有调用方的 global-shortcut 插件和 ACL | +| MiniApp Market | 205 → 204 | 208 → 207 | 206 → 205 | 删除服务从未消费的 `urlencoding` 直接边 | +| Page Function tests | 38 → 35 | 38 → 35 | 38 → 35 | 删除同步 Rust 测试未使用的 dev-only Tokio 闭包 | + +以下继续以 `gcwing/main@7345619ac` 为变更前基线,记录 Core 默认值与 ACP 角色边界收敛。三平台、 +依赖类型与去重口径与上表一致: + +| 本轮闭包 | Windows | macOS | Linux | 行为边界 | +|---|---:|---:|---:|---| +| Core 隐式默认 | 570 → 93 | 557 → 82 | 601 → 81 | library 默认不再冒充完整产品;只保留 feature-free facade 与 build dependency | +| Core `--no-default-features` | 102 → 93 | 91 → 82 | 90 → 81 | 四条 Core 直接边退出并回到实际 owner;package 集合净减 9 | +| Core `product-full` | 570 → 570 | 557 → 557 | 601 → 601 | Desktop/Server 等完整产品入口仍显式恢复全部能力 | +| ACP 默认兼容组合 | 587 → 587 | 572 → 572 | 592 → 592 | 默认仍精确组合 client + server,独立 ACP 测试与外部兼容行为不缩小 | +| Desktop | 790 → 790 | 805 → 805 | 887 → 887 | package 集合不变;ACP 仅编译 client 模块,server/runtime 4,211 行退出该 package build | +| CLI | 643 → 643 | 642 → 642 | 665 → 665 | 显式选择 ACP client + server,既有 CLI-hosted server 行为不变 | + +ACP 两个新角色的当前独立闭包为 client 397/390/391、server 533/518/539(Windows/macOS/Linux)。 +它们不能直接相加:Cargo 会对共同依赖去重。该拆分的确定收益是 Desktop 独立构建不再编译 ACP server +模块,而不是 Desktop package 数下降;因此不宣称完整 Desktop wall-clock 提速。 + +Core 空闭包减少的 9 个 package instance 主要来自 `base64` 与 `futures` 的独有子图;`regex` 和 +`tokio-util` 的 Core 直接边虽然已经移除,但 package 仍由 feature-free contracts/services 路径传递保留。 +因此本轮证明的是 direct owner 边界收敛,不能把四条 direct edge 都描述成 package 完全退出。 + +Core 的 bare/default 编译契约本轮发生了有意变化:仓内产品消费者此前已经全部关闭默认 feature 并显式 +选择 owner,因此运行行为不变;仓外若有 path/git consumer 依赖旧的隐式完整表面,需要显式选择 +`product-full`,或改为列出实际使用的 owner。该迁移属于编译期契约变化,不能描述成对未知外部 consumer +完全无影响。 + +Syntect 不能机械地只删适配层:旧 feature union 同时启用 `regex-fancy` 与 `regex-onig` 时,实际由 +Oniguruma 后端处理。当前 manifest 直接选择 `regex-onig`,因此运行后端、默认 syntax/theme 和 +Syntect→Ratatui 样式转换保持不变,同时让未生效的 fancy 后端与未消费的 YAML loader 退出。 + Package instance 会低估“同一个大 crate 少编译了多少 feature 代码”。在 Windows `agent-runtime` 闭包中,`bitfun-services-integrations` 的 Cargo active feature 从 61 个降到 6 个, 只保留 `workspace-search` 及其 5 个直接依赖 feature;`bitfun-product-domains` 从 13 个降到 5 个, 只保留 Agent Runtime 实际使用的 external-subagent contract slice。Function Agent、MiniApp、 Plugin Source 由各自 owner 选择,完整产品仍经 `product-full` 显式恢复。 -根 `Cargo.lock` 与实现复核基线保持一致,package 记录不增加;Installer 自己生成的 -`BitFun-Installer/src-tauri/Cargo.lock` 本 PR 不提交。 +上一轮根 `Cargo.lock` 从 1176 降到 1169,精确删除 `syntect-tui`、`custom_error`、`fancy-regex`、 +`yaml-rust`、`linked-hash-map`、`tauri-plugin-global-shortcut` 和 `global-hotkey`;没有新增、升级或 +降级 package。本轮 feature/角色边界调整保持该 lockfile 字节不变,也没有新增第三方 package。 +Installer 自己生成的 `BitFun-Installer/src-tauri/Cargo.lock` 不提交。 | 状态 | 范围 | 处理结论 | |---|---|---| | 已稳定 | 根 `Cargo.lock`、Reqwest Rustls 单栈、workspace Tokio 最小基线 | 不重复治理 | -| 本轮完成 | Core Agent Runtime capability、文档转换与订阅认证 modifier、Installer 未使用直接依赖 | 以真实入口 closure 收敛,不建立新的产品 umbrella,也不扩大根依赖宇宙 | +| 本轮完成 | Core 空默认与 capability-local 工具依赖、ACP client/server 角色、Core Agent Runtime capability、文档转换与订阅认证 modifier、SDK Host 显式 owner closure、Installer/CLI/Desktop/Core/MiniApp Market/Page Function 未使用直接依赖 | 以真实入口 closure 收敛,不建立新的产品 umbrella;根 lock 不增加 package | | 当前不动 | App Server / Server | 只为保持现有 handler 编译显式声明其已消费的 Core owner;不在改造稳定前继续拆其生产路径 | | 明确保留 | Desktop screenshots backend | 替换方案必须同时保持三平台坐标/权限/区域捕获语义且不增加根 lock package;当前候选不满足 | | 明确保留 | `portable-pty 0.8/0.9` | 非 OHOS 与 OHOS 的平台兼容选择,不为去重破坏 | @@ -147,10 +237,15 @@ Plugin Source 由各自 owner 选择,完整产品仍经 `product-full` 显式 | CI 拓扑 | Rust job 不再等待完整前端构建,自建 Tauri 检查所需资源目录 | | 依赖收敛 | Desktop 直接 image 版本和 Reqwest TLS 双栈已治理 | | Agent Runtime 闭包 | Core 基线不再暗带具体 capability;完整产品和 CLI 显式保持原能力,ACP 退出未选择闭包 | +| Core/ACP 默认与角色 | Core 默认 feature 为空;ACP 默认精确保持 client + server,Desktop client-only、CLI 双角色均由现有边界检查锁定 | | 重型可选能力 | 文档转换和本地订阅凭据由弱 modifier 细化已有 runtime owner;Core 基线和 App Server 退出未消费闭包 | | Installer 闭包 | 删除 8 个未使用直接 dependency;独立 workspace 和发布生命周期不变,本 PR 不提交其生成 lockfile | +| SDK Host 闭包 | 从 `product-full` 改为与当前协议/构造路径一致的显式 Core owner closure;保留 ring TLS 初始化,本机 SDK 行为不变,未交付的远程执行能力不再进入构建图 | | Agent Runtime 测试 | 28 个 integration executable 已收敛为 5 个职责/平台 target | | Services 测试 | 两个服务 crate 使用显式 target;选中闭包少 8 个 integration executable,进程/feature/external-system 边界保持独立 | +| External Sources 测试 | 四个 adapter/assembly crate 从 22 个 target 收敛到 7 个;MCP、插件服务和脚本 runtime 继续独立 | +| Contracts/AI/Assembly 测试 | 五个 crate 从 28 个 target 收敛到 10 个;AI loopback 与纯协议、Product Domains 各 owner feature 保持独立 | +| 未使用直接依赖 | 删除 CLI/Desktop/Core/MiniApp Market/Page Function 的失效直接边;保留 Syntect 实际 Oniguruma 后端,根 lock 只减 7 个 package | 内置 Agent 内容已经移到无第三方依赖的 `bitfun-agent-content`,减少了 Core build-script 工作; 但 Core 仍直接依赖该 crate。没有足够产品收益前,不为消除这一编译指纹引入动态 provider、 diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 7aa64e468..481a524b4 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -20,15 +20,21 @@ import { } from './core-boundaries/cargo-dependency-boundaries.mjs'; import { checkCliIntegrationTestTopology, + checkExternalSourceIntegrationTestTopologies, checkServicesCoreIntegrationTestTopology, checkServicesIntegrationsIntegrationTestTopology, + claudeCodeAdapterIntegrationTestTargets, cliIntegrationTestTargets, + codexAdapterIntegrationTestTargets, + externalSourcesIntegrationTestTargets, + opencodeAdapterIntegrationTestTargets, validateExplicitIntegrationTestTopology, } from './core-boundaries/explicit-test-topology.mjs'; import { crateLayoutRules } from './core-boundaries/rules/crate-layout.mjs'; import { coreClosedFeatureProfileRules, coreProductFullFeatureAssemblyRule, + optionalDependencyFeatureOwnerRules, } from './core-boundaries/rules/feature-rules.mjs'; const ENTRYPOINT = new URL('./check-core-boundaries.mjs', import.meta.url); @@ -50,6 +56,39 @@ const MODULES = [ const TEST_ROOT = join('C:', 'repo'); +test('Core and ACP defaults preserve their explicit assembly contracts', async () => { + const [coreManifest, acpManifest] = await Promise.all([ + readFile(new URL('../src/crates/assembly/core/Cargo.toml', import.meta.url), 'utf8'), + readFile(new URL('../src/crates/interfaces/acp/Cargo.toml', import.meta.url), 'utf8'), + ]); + + assert.deepEqual(parseManifestFeatures(coreManifest).default, []); + assert.deepEqual( + new Set(parseManifestFeatures(acpManifest).default), + new Set(['client', 'server']), + ); +}); + +test('Core feature-free dependencies stay attached to their exact runtime owners', () => { + const coreOwnerRule = optionalDependencyFeatureOwnerRules.find( + (rule) => rule.crateName === 'core', + ); + const ownersByDependency = new Map( + coreOwnerRule.dependencies.map((dependency) => [ + dependency.depName, + new Set(dependency.ownerFeatures), + ]), + ); + + assert.deepEqual(ownersByDependency.get('base64'), new Set(['agent-runtime', 'dispatch-store'])); + assert.deepEqual(ownersByDependency.get('futures'), new Set(['agent-runtime'])); + assert.deepEqual(ownersByDependency.get('regex'), new Set(['agent-runtime'])); + assert.deepEqual( + ownersByDependency.get('tokio-util'), + new Set(['agent-runtime', 'debug-log']), + ); +}); + function parseManifestFeatures(manifest) { const section = manifest.match(/^\[features\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/m)?.[1] ?? ''; const features = {}; @@ -255,6 +294,204 @@ test('service integration tests keep their reviewed explicit target topology', ( assert.deepEqual(checkServicesIntegrationsIntegrationTestTopology(repositoryRoot), []); }); +test('contract and AI adapter tests keep reviewed feature and failure-domain topology', async () => { + const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); + const topology = await import('./core-boundaries/explicit-test-topology.mjs'); + + assert.deepEqual(topology.coreTypesIntegrationTestTargets, [ + { + name: 'core_type_contracts', + path: 'tests/core_type_contracts.rs', + leaves: [ + 'tests/core_type_contracts/lsp_contracts.rs', + 'tests/core_type_contracts/session_contracts.rs', + 'tests/core_type_contracts/session_usage_contracts.rs', + 'tests/core_type_contracts/surface_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.runtimePortsIntegrationTestTargets, [ + { + name: 'runtime_port_contracts', + path: 'tests/runtime_port_contracts.rs', + leaves: [ + 'tests/runtime_port_contracts/git_port_contracts.rs', + 'tests/runtime_port_contracts/plugin_runtime_contracts.rs', + 'tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs', + 'tests/runtime_port_contracts/script_tool_port_contracts.rs', + 'tests/runtime_port_contracts/session_store_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.productDomainsIntegrationTestTargets, [ + { + name: 'product_domain_contracts', + path: 'tests/product_domain_contracts.rs', + leaves: [ + 'tests/product_domain_contracts/canvas_contracts.rs', + 'tests/product_domain_contracts/tool_permission_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'external_source_contracts', + path: 'tests/external_source_contracts.rs', + leaves: [ + 'tests/external_source_contracts/external_hook_catalog_contracts.rs', + 'tests/external_source_contracts/external_hook_contribution_contracts.rs', + 'tests/external_source_contracts/external_source_contracts.rs', + 'tests/external_source_contracts/workspace_reference_contracts.rs', + ], + requiredFeatures: ['external-sources'], + }, + { + name: 'function_agent_contracts', + path: 'tests/function_agent_contracts.rs', + requiredFeatures: ['function-agents'], + }, + { + name: 'miniapp_contracts', + path: 'tests/miniapp_contracts.rs', + requiredFeatures: ['miniapp'], + }, + { + name: 'plugin_source_contracts', + path: 'tests/plugin_source_contracts.rs', + requiredFeatures: ['plugin-source'], + }, + ]); + assert.deepEqual(topology.aiAdaptersIntegrationTestTargets, [ + { + name: 'ai_protocol_contracts', + path: 'tests/ai_protocol_contracts.rs', + leaves: [ + 'tests/ai_protocol_contracts/model_selector.rs', + 'tests/ai_protocol_contracts/openai_empty_content_parts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'ai_stream_contracts', + path: 'tests/ai_stream_contracts.rs', + leaves: [ + 'tests/ai_stream_contracts/common.rs', + 'tests/ai_stream_contracts/stream_processor_anthropic.rs', + 'tests/ai_stream_contracts/stream_processor_openai.rs', + 'tests/ai_stream_contracts/stream_processor_tool_arguments.rs', + 'tests/ai_stream_contracts/stream_replay_regressions.rs', + 'tests/ai_stream_contracts/stream_test_harness.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.productCapabilitiesIntegrationTestTargets, [ + { + name: 'product_capability_contracts', + path: 'tests/product_capability_contracts.rs', + leaves: [ + 'tests/product_capability_contracts/plugin_product_shape.rs', + 'tests/product_capability_contracts/product_capabilities.rs', + 'tests/product_capability_contracts/product_sdk_assembly.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.checkBuildGraphContractIntegrationTestTopologies(repositoryRoot), []); + + const widenedOwnerErrors = validateExplicitIntegrationTestTopology({ + manifestText: [ + '[package]', + 'autotests = false', + '[[test]]', + 'name = "external_source_contracts"', + 'path = "tests/external_source_contracts.rs"', + 'required-features = ["product-full"]', + ].join('\n'), + expectedTargets: [{ + name: 'external_source_contracts', + path: 'tests/external_source_contracts.rs', + requiredFeatures: ['external-sources'], + }], + topLevelRustFiles: ['tests/external_source_contracts.rs'], + rootSources: new Map([[ + 'tests/external_source_contracts.rs', + '#![cfg(feature = "product-full")]\n', + ]]), + leafRustFiles: [], + leafSources: new Map(), + }); + assert.match(widenedOwnerErrors.join('\n'), /required-features.*external-sources/); +}); + +test('external source integration tests keep reviewed owner and process boundaries', () => { + const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); + + assert.deepEqual(opencodeAdapterIntegrationTestTargets, [ + { name: 'opencode_mcp_adapter', path: 'tests/opencode_mcp_adapter.rs' }, + { name: 'opencode_source_adapter', path: 'tests/opencode_source_adapter.rs' }, + { + name: 'opencode_static_source_contracts', + path: 'tests/opencode_static_source_contracts.rs', + leaves: [ + 'tests/opencode_static_source_contracts/hook_source.rs', + 'tests/opencode_static_source_contracts/opencode_command_adapter.rs', + 'tests/opencode_static_source_contracts/opencode_skill_roots.rs', + 'tests/opencode_static_source_contracts/opencode_subagent_adapter.rs', + 'tests/opencode_static_source_contracts/opencode_workspace_references.rs', + ], + forbidRequiredFeatures: true, + }, + { name: 'tool_source_contracts', path: 'tests/tool_source_contracts.rs' }, + ]); + assert.deepEqual(claudeCodeAdapterIntegrationTestTargets, [ + { + name: 'claude_code_source_contracts', + path: 'tests/claude_code_source_contracts.rs', + leaves: [ + 'tests/claude_code_source_contracts/command_source.rs', + 'tests/claude_code_source_contracts/hook_source.rs', + 'tests/claude_code_source_contracts/mcp_source.rs', + 'tests/claude_code_source_contracts/subagent_source.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(codexAdapterIntegrationTestTargets, [ + { + name: 'codex_source_contracts', + path: 'tests/codex_source_contracts.rs', + leaves: [ + 'tests/codex_source_contracts/hook_source.rs', + 'tests/codex_source_contracts/mcp_source.rs', + 'tests/codex_source_contracts/subagent_source.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(externalSourcesIntegrationTestTargets, [ + { + name: 'external_source_coordination_contracts', + path: 'tests/external_source_coordination_contracts.rs', + leaves: [ + 'tests/external_source_coordination_contracts/control_plane.rs', + 'tests/external_source_coordination_contracts/coordinator_contracts.rs', + 'tests/external_source_coordination_contracts/hook_coordinator.rs', + 'tests/external_source_coordination_contracts/mcp_coordinator.rs', + 'tests/external_source_coordination_contracts/subagent_coordinator.rs', + 'tests/external_source_coordination_contracts/tool_coordinator_contracts.rs', + 'tests/external_source_coordination_contracts/workspace_reference.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual( + checkExternalSourceIntegrationTestTopologies(repositoryRoot), + [], + ); +}); + test('runtime-services test support is absent from ordinary library builds', async () => { const [manifest, library] = await Promise.all([ readFile( @@ -506,14 +743,278 @@ test('explicit product entrypoint bitfun-core feature selections pass', () => { ); }); -const ACP_REVIEWED_CORE_FEATURES = [ +test('Desktop must select only the ACP client role', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { + default: ['client', 'server'], + client: [], + server: [], + }, + }; + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: false, + features: ['client', 'server'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /Desktop ACP role selection must not include server/); +}); + +test('ACP consumers must disable compatibility default roles', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, + }; + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: true, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /must set default-features = false on every dependency/); +}); + +test('CLI must select both ACP roles explicitly', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { + default: ['client', 'server'], + client: [], + server: [], + }, + }; + const cli = packageAt('bitfun-cli', 'src/apps/cli/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: false, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [cli, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /CLI ACP role selection must include server/); +}); + +test('new product entrypoints must register an explicit ACP role selection', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { + default: ['client', 'server'], + client: [], + server: [], + }, + }; + const newHost = packageAt('bitfun-new-host', 'src/apps/new-host/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: false, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [newHost, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /must register an explicit role selection/); +}); + +test('ACP roles must be selected by an unconditional normal dependency', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { + default: ['client', 'server'], + client: [], + server: [], + }, + }; + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + kind: 'dev', + usesDefaultFeatures: false, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match( + violations[0].message, + /Desktop ACP role selection must keep an unconditional normal bitfun-acp dependency/, + ); +}); + +test('reviewed ACP roles require an unconditional normal dependency', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, + }; + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + target: 'cfg(windows)', + usesDefaultFeatures: false, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /must keep an unconditional normal bitfun-acp dependency/); +}); + +test('target-specific ACP edges cannot expand a reviewed product role', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, + }; + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: false, + features: ['client'], + }), + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + target: 'cfg(windows)', + usesDefaultFeatures: false, + features: ['server'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /Desktop ACP role selection must not include server/); +}); + +test('dev and build ACP edges cannot expand a reviewed product role', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, + }; + + for (const kind of ['dev', 'build']) { + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + usesDefaultFeatures: false, + features: ['client'], + }), + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + kind, + usesDefaultFeatures: false, + features: ['server'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1, `${kind} dependency must not widen Desktop ACP roles`); + assert.match(violations[0].message, /Desktop ACP role selection must not include server/); + } +}); + +test('reviewed ACP product dependencies must not become optional', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, + }; + const desktop = packageAt('bitfun-desktop', 'src/apps/desktop/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + optional: true, + usesDefaultFeatures: false, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [desktop, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 2); + assert.match(violations[0].message, /must keep an unconditional normal bitfun-acp dependency/); + assert.match(violations[1].message, /must not make a bitfun-acp dependency optional/); +}); + +test('target, dev, and build ACP consumers must still register their role selection', () => { + const acp = { + ...packageAt('bitfun-acp', 'src/crates/interfaces/acp/Cargo.toml'), + features: { default: ['client', 'server'], client: [], server: [] }, + }; + for (const dependency of [ + { target: 'cfg(windows)' }, + { kind: 'dev' }, + { kind: 'build' }, + ]) { + const newHost = packageAt('bitfun-new-host', 'src/apps/new-host/Cargo.toml', [ + pathDependency('src/crates/interfaces/acp', { + name: 'bitfun-acp', + ...dependency, + usesDefaultFeatures: false, + features: ['client'], + }), + ]); + + const violations = findProductEntrypointCoreFeatureViolations( + [newHost, acp], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match(violations[0].message, /must register an explicit role selection/); + } +}); + +const SDK_HOST_REVIEWED_CORE_FEATURES = [ 'agent-runtime', 'document-read', 'subscription-auth', 'deep-research', 'lsp', 'external-sources', - 'ssh-remote', 'tools-basic', 'tools-git', 'tools-mcp', @@ -525,6 +1026,11 @@ const ACP_REVIEWED_CORE_FEATURES = [ 'tools-agent-control', ]; +const ACP_REVIEWED_CORE_FEATURES = [ + ...SDK_HOST_REVIEWED_CORE_FEATURES, + 'ssh-remote', +]; + const CLI_REVIEWED_CORE_FEATURES = [ ...ACP_REVIEWED_CORE_FEATURES, 'remote-connect', @@ -537,6 +1043,126 @@ const APP_SERVER_REVIEWED_CORE_FEATURES = [ 'remote-connect', ]; +test('SDK Host Core capability closure keeps every reviewed owner', () => { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + const sdkHost = packageAt( + 'bitfun-sdk-host-app', + 'src/apps/sdk-host/Cargo.toml', + [pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: SDK_HOST_REVIEWED_CORE_FEATURES.filter( + (feature) => feature !== 'external-sources', + ), + })], + ); + + const violations = findProductEntrypointCoreFeatureViolations( + [sdkHost, core], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.deepEqual(violations.map((violation) => violation.message), [ + 'bitfun-sdk-host-app Core capability closure must include external-sources', + ]); +}); + +test('SDK Host closure rejects unreviewed capability owners below Core', () => { + const cases = [ + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'remote-connect'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'remote-ssh'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'remote-ssh-concrete'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'function-agents'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'announcement'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'debug-log'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'product-full'], + ['bitfun-product-domains', 'src/crates/contracts/product-domains/Cargo.toml', 'function-agents'], + ['bitfun-product-domains', 'src/crates/contracts/product-domains/Cargo.toml', 'product-full'], + ['bitfun-services-core', 'src/crates/services/services-core/Cargo.toml', 'dispatch-workspace'], + ]; + + for (const [ownerName, ownerManifest, forbiddenFeature] of cases) { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + const owner = { + ...packageAt(ownerName, ownerManifest), + features: { [forbiddenFeature]: [] }, + }; + const bridge = packageAt('bridge', 'src/crates/assembly/bridge/Cargo.toml', [ + pathDependency(ownerManifest.replace('/Cargo.toml', ''), { + name: ownerName, + usesDefaultFeatures: false, + features: [forbiddenFeature], + }), + ]); + const sdkHost = packageAt( + 'bitfun-sdk-host-app', + 'src/apps/sdk-host/Cargo.toml', + [ + pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: SDK_HOST_REVIEWED_CORE_FEATURES, + }), + pathDependency('src/crates/assembly/bridge', { name: 'bridge' }), + ], + ); + + const violations = findProductEntrypointCoreFeatureViolations( + [sdkHost, bridge, core, owner], + { root: TEST_ROOT, crateLayoutRules }, + ); + + const forbiddenOwner = `${ownerName}/${forbiddenFeature}`; + assert.equal(violations.length, 1, forbiddenOwner); + assert.match( + violations[0].message, + new RegExp(forbiddenOwner), + ); + } +}); + +test('SDK Host closure inspects lower owners forwarded by reviewed Core features', () => { + const ownerManifest = 'src/crates/services/services-integrations/Cargo.toml'; + const core = { + ...packageAt( + 'bitfun-core', + 'src/crates/assembly/core/Cargo.toml', + [pathDependency('src/crates/services/services-integrations', { + name: 'bitfun-services-integrations', + optional: true, + usesDefaultFeatures: false, + })], + ), + features: { + 'external-sources': ['bitfun-services-integrations/remote-connect'], + }, + }; + const owner = { + ...packageAt('bitfun-services-integrations', ownerManifest), + features: { 'remote-connect': [] }, + }; + const sdkHost = packageAt( + 'bitfun-sdk-host-app', + 'src/apps/sdk-host/Cargo.toml', + [pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: SDK_HOST_REVIEWED_CORE_FEATURES, + })], + ); + + const violations = findProductEntrypointCoreFeatureViolations( + [sdkHost, core, owner], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match( + violations[0].message, + /bitfun-services-integrations\/remote-connect/, + ); +}); + test('App Server Core capability closure keeps its production Git owner', () => { const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); const appServer = packageAt( diff --git a/scripts/ci/local-replica.ps1 b/scripts/ci/local-replica.ps1 new file mode 100644 index 000000000..90396ec78 --- /dev/null +++ b/scripts/ci/local-replica.ps1 @@ -0,0 +1,394 @@ +<# +.SYNOPSIS + 本地 CI 全量复刻脚本:按 .github/workflows/ci.yml 逐 job 逐 step 在本地 Windows 上完整预演。 + +.DESCRIPTION + 固化 5 个 job / 28 步(shell-scripts / cli-test / cargo-deny / rust-build-check / frontend-build), + 与远程 CI(ubuntu-latest 主线)对齐。已知 Windows 平台差异项显式标注、不判整体失败, + 其余步骤严格判失败(核心失败 → 退出码非 0)。 + + 环境预处理(关键): + - PATH 前置 Git Bash:系统 bash.exe 可能是 WSL stub(无发行版),会让所有 bash 脚本/契约测试 + 误报失败。本脚本探测 %ProgramFiles%\Git\bin 等常见安装位置,找不到则报错退出。 + - NODE_OPTIONS=--max-old-space-size=6144(对齐 CI frontend-build env)。 + - cargo-deny:已安装则直接使用,未安装则提示安装命令(不自动装)。 + +.PARAMETER SkipFrontend + 跳过 frontend-build job(构建耗时较长,可选)。 + +.EXAMPLE + .\scripts\ci\local-replica.ps1 # 全量 28 步 + .\scripts\ci\local-replica.ps1 -SkipFrontend # 跳过前端 job +#> +[CmdletBinding()] +param( + [switch]$SkipFrontend +) + +$ErrorActionPreference = 'Continue' +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +Set-Location $repoRoot + +# ── 结果记录 ────────────────────────────────────────────────────────────── +$results = [System.Collections.Generic.List[object]]::new() +$global:stepFailed = $false + +function Add-Result { + param([string]$Job, [string]$Step, [string]$Command, [int]$Exit, [string]$Status, [string]$Note = '') + $script:results.Add([pscustomobject]@{ + Job = $Job + Step = $Step + Command = $Command + Exit = $Exit + Status = $Status + Note = $Note + }) +} + +function Invoke-CIStep { + param( + [string]$Job, + [string]$Step, + [string]$Command, + [scriptblock]$Body, + [ValidateSet('strict', 'platform-warn', 'skip')] + [string]$Mode = 'strict' + ) + Write-Host "`n[$Job] $Step" -ForegroundColor Cyan + Write-Host " > $Command" -ForegroundColor DarkGray + + if ($Mode -eq 'skip') { + Add-Result $Job $Step $Command -1 'SKIP' 'Windows 平台限制(CI ubuntu 专属)' + Write-Host " [SKIP] 平台限制:该步骤 CI 在 ubuntu 跑,Windows 无法复刻" -ForegroundColor Yellow + return + } + + $ex = 0 + try { + $ret = & $Body + # Body 显式 `return N`(int)优先作为退出码;否则用外部命令的 $LASTEXITCODE。 + # 兼容 Body 内 pipeline 产生对象输出的情况:从 $ret 提取最后一个 int 值。 + $returnedInt = $null + if ($null -ne $ret) { + if ($ret -is [int]) { $returnedInt = $ret } + elseif ($ret -is [array]) { + foreach ($item in $ret) { if ($item -is [int]) { $returnedInt = $item } } + } + } + if ($null -ne $returnedInt) { + $ex = $returnedInt + } else { + $ex = $LASTEXITCODE + if ($null -eq $ex) { $ex = 0 } + } + } catch { + # PowerShell 5.1:外部命令 stderr 经 2>&1 合并时抛 NativeCommandError, + # 这是"输出流"而非真失败——退出码以 $LASTEXITCODE 为准。 + if ($_.Exception -is [System.Management.Automation.NativeCommandExitException]) { + $ex = $LASTEXITCODE + if ($null -eq $ex) { $ex = 1 } + } else { + $ex = 1 + Write-Host " [EXCEPTION] $_" -ForegroundColor Red + } + } + + if ($ex -eq 0) { + Add-Result $Job $Step $Command 0 'PASS' + Write-Host " [PASS] EXIT=$ex" -ForegroundColor Green + } elseif ($Mode -eq 'platform-warn') { + Add-Result $Job $Step $Command $ex 'WARN' 'Windows 已知平台差异(远程 CI 通过,基线复测证实与改动无关)' + Write-Host " [WARN] EXIT=$ex Windows 已知平台差异,不判整体失败" -ForegroundColor Yellow + } else { + Add-Result $Job $Step $Command $ex 'FAIL' + $script:stepFailed = $true + Write-Host " [FAIL] EXIT=$ex" -ForegroundColor Red + } +} + +# ── 0. 环境预处理 ───────────────────────────────────────────────────────── +Write-Host "`n===== 环境预处理 =====" -ForegroundColor Magenta + +# 0a. 探测 Git Bash +$gitBashCandidates = @( + "$env:ProgramFiles\Git\bin\bash.exe", + "${env:ProgramFiles(x86)}\Git\bin\bash.exe", + "$env:LOCALAPPDATA\Programs\Git\bin\bash.exe", + "$env:USERPROFILE\scoop\apps\git\current\bin\bash.exe" +) +$gitBash = $gitBashCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $gitBash) { + Write-Host " [ERROR] 未找到 Git Bash。请安装 Git for Windows(https://git-scm.com/download/win)" -ForegroundColor Red + Write-Host " 或用 bash 所在目录执行:\$env:PATH = 'C:\Program Files\Git\bin;' + \$env:PATH" -ForegroundColor Red + exit 2 +} +$gitBashDir = Split-Path (Split-Path $gitBash -Parent) -Parent # ...\Git +# 把 Git\bin 和 Git\usr\bin 前置到 PATH(usr\bin 提供 grep/sed 等 coreutils) +$env:PATH = "$gitBashDir\bin;$gitBashDir\usr\bin;$env:PATH" +Write-Host " Git Bash: $gitBash" -ForegroundColor Green +$bashVer = & $gitBash --version 2>&1 | Select-Object -First 1 +Write-Host " 版本: $bashVer" -ForegroundColor DarkGray + +# 0b. NODE_OPTIONS 对齐 CI +$env:NODE_OPTIONS = '--max-old-space-size=6144' +Write-Host " NODE_OPTIONS=$env:NODE_OPTIONS" -ForegroundColor Green + +# 0c. cargo-deny 检查 +$cargoDeny = Get-Command cargo-deny -ErrorAction SilentlyContinue +if ($cargoDeny) { + Write-Host " cargo-deny: $(& cargo-deny --version 2>&1 | Select-Object -First 1)" -ForegroundColor Green +} else { + Write-Host " [WARN] 未安装 cargo-deny。cargo-deny job 将跳过。" -ForegroundColor Yellow + Write-Host " 安装:cargo install cargo-deny --locked --version 0.20.2" -ForegroundColor Yellow +} + +# ── 1. shell-scripts ────────────────────────────────────────────────────── +Write-Host "`n===== Job 1: shell-scripts =====" -ForegroundColor Magenta + +Invoke-CIStep 'shell-scripts' 'CRLF 检查(shell/deploy 资产必须 LF)' ` + "git ls-files '*.sh' '*.bash' Dockerfile* Caddyfile docker-compose* 扫 CR" -Mode strict -Body { + $bad = @() + foreach ($pat in @('*.sh', '*.bash', 'Dockerfile', 'Dockerfile.*', '*.Dockerfile', 'Caddyfile', 'docker-compose.yml', 'docker-compose.*.yml')) { + git ls-files $pat | ForEach-Object { + $f = $_; $bytes = [System.IO.File]::ReadAllBytes((Resolve-Path $f)) + if ($bytes -contains 13) { $bad += $f } + } + } + if ($bad.Count -gt 0) { Write-Host "CRLF FOUND:"; $bad; return 1 } + Write-Host "All shell and deploy assets are LF-only." +} + +Invoke-CIStep 'shell-scripts' 'bash -n 全部跟踪的 shell 脚本' ` + "bash -n (Git Bash)" -Mode strict -Body { + $rc = 0 + foreach ($f in (git ls-files '*.sh' '*.bash')) { + & $gitBash -n $f 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Host "bash syntax error: $f"; $rc = 1 } + } + if ($rc -eq 0) { Write-Host "All shell scripts pass bash -n" } + return $rc +} + +Invoke-CIStep 'shell-scripts' 'release/version 契约测试(node --test)' ` + "node --test scripts/tauri-release-manifest.test.mjs scripts/linux-binaries-manifest.test.mjs scripts/version-generation.test.mjs" -Mode strict -Body { + node --test scripts/tauri-release-manifest.test.mjs scripts/linux-binaries-manifest.test.mjs scripts/version-generation.test.mjs 2>&1 | Select-String -Pattern 'pass |fail ' | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +# minisign fallback:Windows 平台限制(脚本主动拒绝 MINGW64),CI ubuntu 专属 +Invoke-CIStep 'shell-scripts' 'minisign 下载 fallback' ` + "bash scripts/sign-release-assets.sh " -Mode skip -Body { } + +# ── 2. cli-test(Linux 分支 = 主线)─────────────────────────────────────── +Write-Host "`n===== Job 2: cli-test =====" -ForegroundColor Magenta + +Invoke-CIStep 'cli-test' 'CLI + ACP 测试' ` + "cargo test --locked -p bitfun-cli -p bitfun-acp" -Mode platform-warn -Body { + cargo test --locked -p bitfun-cli -p bitfun-acp 2>&1 | Select-String -Pattern 'test result: FAILED|test result: ok\.' | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +Invoke-CIStep 'cli-test' 'agent-runtime 测试' ` + "cargo test --locked -p bitfun-agent-runtime" -Mode strict -Body { + cargo test --locked -p bitfun-agent-runtime 2>&1 | Select-String -Pattern 'test result: FAILED|test result: ok\.' | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +Invoke-CIStep 'cli-test' 'SDK Host 测试' ` + "cargo test --locked -p bitfun-sdk-host -p bitfun-sdk-host-app" -Mode strict -Body { + cargo test --locked -p bitfun-sdk-host -p bitfun-sdk-host-app 2>&1 | Select-String -Pattern 'test result: FAILED|test result: ok\.' | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +Invoke-CIStep 'cli-test' 'SDK Host terminal 清理回归(3 测试)' ` + "cargo test --locked -p terminal-core <3 回归> -- --test-threads=1" -Mode strict -Body { + $names = @( + 'shutdown_returns_only_after_process_exit_is_confirmed', + 'shutdown_evicts_a_process_whose_controller_already_confirmed_exit', + 'background_only_binding_is_owned_by_the_session' + ) + foreach ($n in $names) { + cargo test --locked -p terminal-core $n -- --test-threads=1 2>&1 | Select-String -Pattern 'test result' | ForEach-Object { Write-Host " $_" } + if ($LASTEXITCODE -ne 0) { return $LASTEXITCODE } + } +} + +# ── 3. cargo-deny ───────────────────────────────────────────────────────── +Write-Host "`n===== Job 3: cargo-deny =====" -ForegroundColor Magenta + +if ($cargoDeny) { + Invoke-CIStep 'cargo-deny' 'advisories' 'cargo deny check advisories' -Mode strict -Body { + cargo deny check advisories 2>&1 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + Invoke-CIStep 'cargo-deny' 'licenses' 'cargo deny check licenses' -Mode strict -Body { + cargo deny check licenses 2>&1 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + Invoke-CIStep 'cargo-deny' 'sources' 'cargo deny check sources' -Mode strict -Body { + cargo deny check sources 2>&1 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } +} else { + Write-Host " [SKIP] cargo-deny 未安装,跳过 3 步(不判失败)" -ForegroundColor Yellow + Add-Result 'cargo-deny' 'advisories' 'cargo deny check advisories' -1 'SKIP' 'cargo-deny 未安装' + Add-Result 'cargo-deny' 'licenses' 'cargo deny check licenses' -1 'SKIP' 'cargo-deny 未安装' + Add-Result 'cargo-deny' 'sources' 'cargo deny check sources' -1 'SKIP' 'cargo-deny 未安装' +} + +# ── 4. rust-build-check ─────────────────────────────────────────────────── +Write-Host "`n===== Job 4: rust-build-check =====" -ForegroundColor Magenta + +Invoke-CIStep 'rust-build-check' 'workspace 编译检查' ` + "cargo check --locked --workspace" -Mode strict -Body { + cargo check --locked --workspace 2>&1 | Select-Object -Last 2 + return $LASTEXITCODE +} + +Invoke-CIStep 'rust-build-check' 'installer 编译检查(Windows 专属步骤)' ` + "cargo check --manifest-path BitFun-Installer/src-tauri/Cargo.toml" -Mode strict -Body { + cargo check --manifest-path BitFun-Installer/src-tauri/Cargo.toml 2>&1 | Select-Object -Last 2 + return $LASTEXITCODE +} + +Invoke-CIStep 'rust-build-check' 'core + desktop 库测试' ` + "cargo test --locked -p bitfun-core -p bitfun-desktop --lib" -Mode strict -Body { + cargo test --locked -p bitfun-core -p bitfun-desktop --lib 2>&1 | Select-String -Pattern 'test result: FAILED|test result: ok\.' | Select-Object -Last 4 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +Invoke-CIStep 'rust-build-check' 'page-function-runtime 测试' ` + "cargo test --locked -p bitfun-page-function-runtime" -Mode strict -Body { + cargo test --locked -p bitfun-page-function-runtime 2>&1 | Select-String -Pattern 'test result' | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +Invoke-CIStep 'rust-build-check' 'relay-service 测试' ` + "cargo test --locked -p bitfun-relay-service" -Mode strict -Body { + cargo test --locked -p bitfun-relay-service 2>&1 | Select-String -Pattern 'test result' | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +Invoke-CIStep 'rust-build-check' 'subscription-auth 测试' ` + "cargo test --locked -p bitfun-ai-adapters --features subscription-auth --lib subscription_auth" -Mode strict -Body { + cargo test --locked -p bitfun-ai-adapters --features subscription-auth --lib subscription_auth 2>&1 | Select-String -Pattern 'test result' | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +Invoke-CIStep 'rust-build-check' 'file-watch 契约测试(非 macOS)' ` + "cargo test --locked -p bitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts" -Mode strict -Body { + cargo test --locked -p bitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts 2>&1 | Select-String -Pattern 'test result' | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +Invoke-CIStep 'rust-build-check' 'search 工具测试' ` + "cargo test --locked -p tool-runtime --lib search::" -Mode strict -Body { + cargo test --locked -p tool-runtime --lib search:: 2>&1 | Select-String -Pattern 'test result' | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +# ── 5. frontend-build ───────────────────────────────────────────────────── +if (-not $SkipFrontend) { + Write-Host "`n===== Job 5: frontend-build =====" -ForegroundColor Magenta + + Invoke-CIStep 'frontend-build' 'repo 卫生检查' 'pnpm run check:repo-hygiene' -Mode strict -Body { + pnpm run check:repo-hygiene 2>&1 | Select-String -Pattern 'passed|valid|error' | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'core 边界检查' 'node --test scripts/check-core-boundaries.test.mjs' -Mode strict -Body { + node --test scripts/check-core-boundaries.test.mjs 2>&1 | Select-String -Pattern 'pass |fail ' | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + # PPT Live 契约:Windows 已知平台差异(fixture 字节 hash 依赖 WebKit 渲染确定性) + Invoke-CIStep 'frontend-build' 'PPT Live 生成文件契约' ` + "pnpm run test:ppt-live" -Mode platform-warn -Body { + pnpm run test:ppt-live 2>&1 | Select-String -Pattern 'pass |fail |✖' | Select-Object -Last 6 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'GitHub 配置校验' 'pnpm run check:github-config' -Mode strict -Body { + pnpm run check:github-config 2>&1 | Select-String -Pattern 'pass |fail ' | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'i18n 契约(CI profile)' 'pnpm run i18n:contract:test:ci' -Mode strict -Body { + pnpm run i18n:contract:test:ci 2>&1 | Select-String -Pattern 'pass |fail ' | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'i18n 资源审计' 'pnpm run i18n:audit' -Mode strict -Body { + pnpm run i18n:audit 2>&1 | Select-String -Pattern 'Passed|warning' | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'theme 色彩审计契约' 'pnpm run theme:color-audit:test' -Mode strict -Body { + pnpm run theme:color-audit:test 2>&1 | Select-String -Pattern 'pass |fail ' | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'theme 色彩治理审计' 'pnpm run theme:color-audit:all' -Mode strict -Body { + pnpm run theme:color-audit:all 2>&1 | Select-String -Pattern 'error|FAIL' | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'theme 视觉治理契约' 'pnpm run theme:visual-contract' -Mode strict -Body { + pnpm run theme:visual-contract 2>&1 | Select-String -Pattern 'covered|error' | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'web-ui lint' 'pnpm run lint:web' -Mode strict -Body { + pnpm run lint:web 2>&1 | Select-Object -Last 2 + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'web-ui 测试(vitest)' 'pnpm --dir src/web-ui run test:run' -Mode strict -Body { + pnpm --dir src/web-ui run test:run 2>&1 | Select-String -Pattern 'Test Files|Tests ' | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'web-ui 构建' 'pnpm run build:web' -Mode strict -Body { + pnpm run build:web 2>&1 | Select-String -Pattern 'built in|verified|error' | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'mobile-web type-check' 'pnpm --dir src/mobile-web run type-check' -Mode strict -Body { + pnpm --dir src/mobile-web run type-check 2>&1 | Select-Object -Last 2 + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'mobile-web 构建' 'pnpm run build:mobile-web' -Mode strict -Body { + pnpm run build:mobile-web 2>&1 | Select-String -Pattern 'built in|error' | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } +} else { + Write-Host "`n[SKIP] frontend-build job(-SkipFrontend)" -ForegroundColor Yellow +} + +# ── 汇总矩阵 ───────────────────────────────────────────────────────────── +Write-Host "`n===== 汇总矩阵 =====" -ForegroundColor Magenta +Write-Host ("{0,-14} {1,-38} {2,5} {3,-6} {4}" -f 'JOB', 'STEP', 'EXIT', 'STATUS', 'NOTE') +Write-Host ('-' * 110) +$passCount = 0; $failCount = 0; $warnCount = 0; $skipCount = 0 +foreach ($r in $results) { + Write-Host ("{0,-14} {1,-38} {2,5} {3,-6} {4}" -f $r.Job, $r.Step, $r.Exit, $r.Status, $r.Note) + switch ($r.Status) { + 'PASS' { $passCount++ } + 'FAIL' { $failCount++ } + 'WARN' { $warnCount++ } + 'SKIP' { $skipCount++ } + } +} +Write-Host ('-' * 110) +Write-Host "PASS=$passCount FAIL=$failCount WARN=$warnCount SKIP=$skipCount TOTAL=$($results.Count)" +if ($skipCount -gt 0) { Write-Host "SKIP 项:Windows 平台限制(minisign)或未安装(cargo-deny),远程 CI ubuntu 上通过" -ForegroundColor Yellow } +if ($warnCount -gt 0) { Write-Host "WARN 项:Windows 已知平台差异(cli plugin trust store / ppt-live fixture hash),远程 CI 通过,基线复测证实与改动无关" -ForegroundColor Yellow } + +# ── 退出码 ─────────────────────────────────────────────────────────────── +if ($script:stepFailed) { + Write-Host "`n[RESULT] 核心步骤存在失败(FAIL),本地预演未通过" -ForegroundColor Red + exit 1 +} +Write-Host "`n[RESULT] 本地预演通过(PASS + WARN + SKIP,无核心失败)" -ForegroundColor Green +exit 0 diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index e4c21b01e..aa59a907a 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -2,7 +2,11 @@ import { readFileSync, readdirSync } from 'node:fs'; import { isAbsolute, join, relative, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; -import { servicesReqwestOwnerFeatures } from './rules/feature-rules.mjs'; +import { + acpClientCoreFeatures, + acpServerCoreFeatures, + servicesReqwestOwnerFeatures, +} from './rules/feature-rules.mjs'; const SKIPPED_DIRECTORIES = new Set([ '.git', @@ -809,52 +813,39 @@ export function findProductEntrypointCoreFeatureViolations( packages, { root, crateLayoutRules }, ) { + const coreCompatibilityReviewedFeatures = [ + 'agent-runtime', + 'document-read', + 'subscription-auth', + 'deep-research', + 'lsp', + 'external-sources', + 'tools-basic', + 'tools-git', + 'tools-mcp', + 'tools-browser-web', + 'tools-computer-use', + 'tools-image-analysis', + 'tools-miniapp', + 'tools-canvas', + 'tools-agent-control', + ]; const reviewedCoreFeatureClosures = new Map([ ['bitfun-cli', [ - 'agent-runtime', - 'document-read', - 'subscription-auth', + ...coreCompatibilityReviewedFeatures, 'remote-connect', - 'deep-research', - 'lsp', - 'external-sources', 'plugin-runtime', 'ssh-remote', - 'tools-basic', - 'tools-git', - 'tools-mcp', - 'tools-browser-web', - 'tools-computer-use', - 'tools-image-analysis', - 'tools-miniapp', - 'tools-canvas', - 'tools-agent-control', - ]], - ['bitfun-acp', [ - 'agent-runtime', - 'document-read', - 'subscription-auth', - 'deep-research', - 'lsp', - 'external-sources', - 'ssh-remote', - 'tools-basic', - 'tools-git', - 'tools-mcp', - 'tools-browser-web', - 'tools-computer-use', - 'tools-image-analysis', - 'tools-miniapp', - 'tools-canvas', - 'tools-agent-control', ]], + ['bitfun-acp', [...new Set([...acpClientCoreFeatures, ...acpServerCoreFeatures])]], ['bitfun-app-server', [ 'external-sources', 'git', 'remote-connect', ]], + ['bitfun-sdk-host-app', coreCompatibilityReviewedFeatures], ]); - const acpActiveCoreFeatures = [ + const coreCompatibilityActiveFeatures = [ 'agent-runtime', 'ai-adapter-runtime', 'browser-control', @@ -872,12 +863,10 @@ export function findProductEntrypointCoreFeatureViolations( 'plugin-source', 'process-runtime', 'product-capabilities', - 'remote-workspace', 'review-platform', 'runtime-services', 'scheduled-jobs', 'script-tool-runtime', - 'ssh-remote', 'subscription-auth', 'terminal', 'tool-packs', @@ -895,9 +884,15 @@ export function findProductEntrypointCoreFeatureViolations( 'workspace-runtime', 'workspace-watch', ]; + const acpActiveCoreFeatures = [ + ...coreCompatibilityActiveFeatures, + 'remote-workspace', + 'ssh-remote', + ]; const reviewedActiveCoreFeatureClosures = new Map([ ['bitfun-cli', [...acpActiveCoreFeatures, 'plugin-runtime', 'remote-connect']], ['bitfun-acp', acpActiveCoreFeatures], + ['bitfun-sdk-host-app', coreCompatibilityActiveFeatures], ['bitfun-app-server', [ 'agent-runtime', 'ai-adapter-runtime', @@ -925,11 +920,129 @@ export function findProductEntrypointCoreFeatureViolations( 'workspace-watch', ]], ]); + const reviewedForbiddenDependencyOwnerFeatures = new Map([ + ['bitfun-sdk-host-app', new Map([ + ['bitfun-services-integrations', [ + 'announcement', + 'debug-log', + 'function-agents', + 'product-full', + 'remote-connect', + 'remote-ssh', + 'remote-ssh-concrete', + ]], + ['bitfun-product-domains', ['function-agents', 'product-full']], + ['bitfun-services-core', ['dispatch-workspace']], + ])], + ]); const packageByManifest = new Map( packages.map((pkg) => [normalizedPath(pkg.manifest_path), pkg]), ); const violations = []; + const reviewedAcpRoleSelections = new Map([ + ['bitfun-cli', { + label: 'CLI', + requiredFeatures: ['client', 'server'], + }], + ['bitfun-desktop', { + label: 'Desktop', + requiredFeatures: ['client'], + }], + ]); + const acpPackage = packages.find((pkg) => pkg.name === 'bitfun-acp'); + if (acpPackage) { + const reviewedConsumersFound = new Set(); + for (const sourcePackage of packages) { + const declaredDependencies = (sourcePackage.dependencies ?? []).filter((candidate) => { + if (!candidate.path) { + return false; + } + return packageByManifest.get( + normalizedPath(join(candidate.path, 'Cargo.toml')), + )?.name === 'bitfun-acp'; + }); + const normalDependencies = declaredDependencies.filter( + (dependency) => dependency.kind === null, + ); + const rule = reviewedAcpRoleSelections.get(sourcePackage.name); + if (!rule) { + if (declaredDependencies.length > 0) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `bitfun-acp consumer ${sourcePackage.name} must register an explicit role selection`, + }); + } + continue; + } + if (declaredDependencies.length === 0) { + continue; + } + reviewedConsumersFound.add(sourcePackage.name); + const unconditionalDependencies = normalDependencies.filter( + (dependency) => dependency.target === null && dependency.optional !== true, + ); + if (unconditionalDependencies.length === 0) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must keep an unconditional normal bitfun-acp dependency`, + }); + } + if (declaredDependencies.some((dependency) => dependency.optional === true)) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must not make a bitfun-acp dependency optional`, + }); + } + if (declaredDependencies.some((dependency) => dependency.uses_default_features !== false)) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must set default-features = false on every dependency`, + }); + } + const unconditionalFeatures = new Set( + unconditionalDependencies.flatMap((dependency) => dependency.features ?? []), + ); + const selectedFeatures = new Set( + declaredDependencies.flatMap((dependency) => dependency.features ?? []), + ); + if (unconditionalDependencies.length > 0) { + for (const requiredFeature of rule.requiredFeatures) { + if (!unconditionalFeatures.has(requiredFeature)) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must include ${requiredFeature}`, + }); + } + } + } + for (const selectedFeature of selectedFeatures) { + if (!rule.requiredFeatures.includes(selectedFeature)) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must not include ${selectedFeature}`, + }); + } + } + } + for (const [sourceName, rule] of reviewedAcpRoleSelections) { + const sourcePackage = packages.find((pkg) => pkg.name === sourceName); + if (sourcePackage && !reviewedConsumersFound.has(sourceName)) { + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rule.label} ACP role selection must keep the bitfun-acp dependency`, + }); + } + } + } + for (const sourcePackage of packages) { const sourceLayer = layerForManifest(sourcePackage.manifest_path, { root, @@ -956,14 +1069,21 @@ export function findProductEntrypointCoreFeatureViolations( message: `product entrypoint ${sourcePackage.name} must set default-features = false for its bitfun-core ${dependencyDescription(dependency)}`, }); } - if (!Array.isArray(dependency.features) || dependency.features.length === 0) { + const roleOwnedAcpDependency = + sourcePackage.name === 'bitfun-acp' && dependency.optional === true; + if ( + !roleOwnedAcpDependency + && (!Array.isArray(dependency.features) || dependency.features.length === 0) + ) { violations.push({ path: sourcePackage.manifest_path, line: 1, message: `product entrypoint ${sourcePackage.name} must select at least one explicit feature for its bitfun-core ${dependencyDescription(dependency)}`, }); } - const reviewedClosure = reviewedCoreFeatureClosures.get(sourcePackage.name); + const reviewedClosure = roleOwnedAcpDependency + ? undefined + : reviewedCoreFeatureClosures.get(sourcePackage.name); if (reviewedClosure) { const selectedFeatures = new Set(dependency.features ?? []); for (const requiredFeature of reviewedClosure) { @@ -1012,7 +1132,10 @@ export function findProductEntrypointCoreFeatureViolations( ['bitfun-cli', 'CLI'], ['bitfun-acp', 'ACP'], ['bitfun-app-server', 'App Server'], + ['bitfun-sdk-host-app', 'SDK Host'], ]).get(rootName) ?? rootName; + const forbiddenOwnerFeatures = + reviewedForbiddenDependencyOwnerFeatures.get(rootName); const packageStates = new Map(); const pending = []; @@ -1151,6 +1274,30 @@ export function findProductEntrypointCoreFeatureViolations( } continue; } + + const forbiddenOwnerFeature = ( + forbiddenOwnerFeatures?.get(targetPackage.name) ?? [] + ).find((feature) => targetState.featureState.active.has(feature)); + if (forbiddenOwnerFeature) { + const forbiddenOwner = `${targetPackage.name}/${forbiddenOwnerFeature}`; + const reportKey = [ + rootName, + targetDependencyKindContext, + forbiddenOwner, + ].join('|'); + if (!reportedUnexpectedFeatures.has(reportKey)) { + reportedUnexpectedFeatures.add(reportKey); + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rootLabel} dependency closure must not enable ${forbiddenOwner}: ${[ + ...packagePath, + forbiddenOwner, + ].join(' -> ')}`, + }); + } + continue; + } } } } diff --git a/scripts/core-boundaries/checker.mjs b/scripts/core-boundaries/checker.mjs index a9081ca1c..7c8206fa1 100644 --- a/scripts/core-boundaries/checker.mjs +++ b/scripts/core-boundaries/checker.mjs @@ -15,6 +15,7 @@ import { } from './rules/crate-layout.mjs'; import { checkTuiLegacyBackendRatchet } from './tui-boundary-ratchet.mjs'; import { + acpClosedFeatureProfileRules, coreClosedFeatureProfileRules, coreProductFullFeatureAssemblyRule, optionalDependencyFeatureOwnerRules, @@ -37,11 +38,13 @@ import { unexpectedReachableLocalFeatures, } from './manifest-feature-helpers.mjs'; import { checkCargoDependencyBoundariesSafely } from './cargo-dependency-boundaries.mjs'; +import { checkPeerCommandPolicySync } from './peer-command-policy.mjs'; import { agentRuntimeIntegrationTestTargets, checkAgentRuntimeIntegrationTestTopology, checkCliIntegrationTestTopology, - checkServiceIntegrationTestTopologies, + checkExternalSourceIntegrationTestTopologies, + checkReviewedIntegrationTestTopologies, cliIntegrationTestTargets, validateExplicitIntegrationTestTopology, } from './explicit-test-topology.mjs'; @@ -583,19 +586,6 @@ function checkOptionalDependencyFeatureOwners(crateDir, rule) { } } -function checkCoreDefaultProductFullFeature() { - const manifestPath = join(crateDirForName('core'), 'Cargo.toml'); - const features = parseManifestFeatures(readText(manifestPath).split(/\r?\n/)); - if (!featureReferencesFeature(features.get('default'), 'product-full')) { - failures.push({ - path: manifestPath, - line: features.get('default')?.line ?? 1, - message: - 'bitfun-core default feature must remain product-full until a separate product matrix review changes it', - }); - } -} - function checkCoreProductFullFeatureAssembly(rule) { const manifestPath = repoPathToFsPath(rule.manifestPath); const features = parseManifestFeatures(readText(manifestPath).split(/\r?\n/)); @@ -1090,6 +1080,7 @@ export function runCoreBoundaryCheck() { manifestDependencyMatches, matchingForbiddenDependency, coreClosedFeatureProfileRules, + acpClosedFeatureProfileRules, coreProductFullFeatureAssemblyRule, ownerCrateFeatureAssemblyRules, parseManifestFeatures, @@ -1124,7 +1115,8 @@ export function runCoreBoundaryCheck() { failures.push(...checkCargoDependencyBoundariesSafely({ root: ROOT, crateLayoutRules })); failures.push(...checkAgentRuntimeIntegrationTestTopology(ROOT)); failures.push(...checkCliIntegrationTestTopology(ROOT)); - failures.push(...checkServiceIntegrationTestTopologies(ROOT)); + failures.push(...checkExternalSourceIntegrationTestTopologies(ROOT), ...checkReviewedIntegrationTestTopologies(ROOT)); + failures.push(...checkPeerCommandPolicySync(ROOT)); for (const rule of forbiddenManifestDependencyRules) { checkForbiddenManifestDependencyRule(rule); @@ -1155,11 +1147,13 @@ export function runCoreBoundaryCheck() { checkOptionalDependencyFeatureOwners(crateDir, rule); } - checkCoreDefaultProductFullFeature(); checkCoreProductFullFeatureAssembly(coreProductFullFeatureAssemblyRule); for (const rule of coreClosedFeatureProfileRules) { checkClosedFeatureProfile(rule); } + for (const rule of acpClosedFeatureProfileRules) { + checkClosedFeatureProfile(rule); + } for (const rule of ownerCrateFeatureAssemblyRules) { checkOwnerCrateFeatureAssembly(rule); } diff --git a/scripts/core-boundaries/explicit-test-topology.mjs b/scripts/core-boundaries/explicit-test-topology.mjs index 9a157af1d..e5b14cc36 100644 --- a/scripts/core-boundaries/explicit-test-topology.mjs +++ b/scripts/core-boundaries/explicit-test-topology.mjs @@ -46,6 +46,248 @@ export const servicesIntegrationsIntegrationTestTargets = [ { name: 'workspace_search_contracts', path: 'tests/workspace_search_contracts.rs' }, ]; +export const opencodeAdapterIntegrationTestTargets = [ + { name: 'opencode_mcp_adapter', path: 'tests/opencode_mcp_adapter.rs' }, + { name: 'opencode_source_adapter', path: 'tests/opencode_source_adapter.rs' }, + { + name: 'opencode_static_source_contracts', + path: 'tests/opencode_static_source_contracts.rs', + leaves: [ + 'tests/opencode_static_source_contracts/hook_source.rs', + 'tests/opencode_static_source_contracts/opencode_command_adapter.rs', + 'tests/opencode_static_source_contracts/opencode_skill_roots.rs', + 'tests/opencode_static_source_contracts/opencode_subagent_adapter.rs', + 'tests/opencode_static_source_contracts/opencode_workspace_references.rs', + ], + forbidRequiredFeatures: true, + }, + { name: 'tool_source_contracts', path: 'tests/tool_source_contracts.rs' }, +]; + +export const claudeCodeAdapterIntegrationTestTargets = [ + { + name: 'claude_code_source_contracts', + path: 'tests/claude_code_source_contracts.rs', + leaves: [ + 'tests/claude_code_source_contracts/command_source.rs', + 'tests/claude_code_source_contracts/hook_source.rs', + 'tests/claude_code_source_contracts/mcp_source.rs', + 'tests/claude_code_source_contracts/subagent_source.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const codexAdapterIntegrationTestTargets = [ + { + name: 'codex_source_contracts', + path: 'tests/codex_source_contracts.rs', + leaves: [ + 'tests/codex_source_contracts/hook_source.rs', + 'tests/codex_source_contracts/mcp_source.rs', + 'tests/codex_source_contracts/subagent_source.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const externalSourcesIntegrationTestTargets = [ + { + name: 'external_source_coordination_contracts', + path: 'tests/external_source_coordination_contracts.rs', + leaves: [ + 'tests/external_source_coordination_contracts/control_plane.rs', + 'tests/external_source_coordination_contracts/coordinator_contracts.rs', + 'tests/external_source_coordination_contracts/hook_coordinator.rs', + 'tests/external_source_coordination_contracts/mcp_coordinator.rs', + 'tests/external_source_coordination_contracts/subagent_coordinator.rs', + 'tests/external_source_coordination_contracts/tool_coordinator_contracts.rs', + 'tests/external_source_coordination_contracts/workspace_reference.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const coreTypesIntegrationTestTargets = [ + { + name: 'core_type_contracts', + path: 'tests/core_type_contracts.rs', + leaves: [ + 'tests/core_type_contracts/lsp_contracts.rs', + 'tests/core_type_contracts/session_contracts.rs', + 'tests/core_type_contracts/session_usage_contracts.rs', + 'tests/core_type_contracts/surface_contracts.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const runtimePortsIntegrationTestTargets = [ + { + name: 'runtime_port_contracts', + path: 'tests/runtime_port_contracts.rs', + leaves: [ + 'tests/runtime_port_contracts/git_port_contracts.rs', + 'tests/runtime_port_contracts/plugin_runtime_contracts.rs', + 'tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs', + 'tests/runtime_port_contracts/script_tool_port_contracts.rs', + 'tests/runtime_port_contracts/session_store_contracts.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const productDomainsIntegrationTestTargets = [ + { + name: 'product_domain_contracts', + path: 'tests/product_domain_contracts.rs', + leaves: [ + 'tests/product_domain_contracts/canvas_contracts.rs', + 'tests/product_domain_contracts/tool_permission_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'external_source_contracts', + path: 'tests/external_source_contracts.rs', + leaves: [ + 'tests/external_source_contracts/external_hook_catalog_contracts.rs', + 'tests/external_source_contracts/external_hook_contribution_contracts.rs', + 'tests/external_source_contracts/external_source_contracts.rs', + 'tests/external_source_contracts/workspace_reference_contracts.rs', + ], + requiredFeatures: ['external-sources'], + }, + { + name: 'function_agent_contracts', + path: 'tests/function_agent_contracts.rs', + requiredFeatures: ['function-agents'], + }, + { + name: 'miniapp_contracts', + path: 'tests/miniapp_contracts.rs', + requiredFeatures: ['miniapp'], + }, + { + name: 'plugin_source_contracts', + path: 'tests/plugin_source_contracts.rs', + requiredFeatures: ['plugin-source'], + }, +]; + +export const aiAdaptersIntegrationTestTargets = [ + { + name: 'ai_protocol_contracts', + path: 'tests/ai_protocol_contracts.rs', + leaves: [ + 'tests/ai_protocol_contracts/model_selector.rs', + 'tests/ai_protocol_contracts/openai_empty_content_parts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'ai_stream_contracts', + path: 'tests/ai_stream_contracts.rs', + leaves: [ + 'tests/ai_stream_contracts/common.rs', + 'tests/ai_stream_contracts/stream_processor_anthropic.rs', + 'tests/ai_stream_contracts/stream_processor_openai.rs', + 'tests/ai_stream_contracts/stream_processor_tool_arguments.rs', + 'tests/ai_stream_contracts/stream_replay_regressions.rs', + 'tests/ai_stream_contracts/stream_test_harness.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const productCapabilitiesIntegrationTestTargets = [ + { + name: 'product_capability_contracts', + path: 'tests/product_capability_contracts.rs', + leaves: [ + 'tests/product_capability_contracts/plugin_product_shape.rs', + 'tests/product_capability_contracts/product_capabilities.rs', + 'tests/product_capability_contracts/product_sdk_assembly.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +function decodeBasicTomlKey(token) { + let decoded = ''; + const simpleEscapes = new Map([ + ['b', '\b'], ['t', '\t'], ['n', '\n'], ['f', '\f'], ['r', '\r'], + ['"', '"'], ['\\', '\\'], + ]); + for (let index = 1; index < token.length - 1; index += 1) { + if (token[index] !== '\\') { + decoded += token[index]; + continue; + } + index += 1; + const escape = token[index]; + if (simpleEscapes.has(escape)) { + decoded += simpleEscapes.get(escape); + continue; + } + if (escape !== 'u' && escape !== 'U') { + return null; + } + const digitCount = escape === 'u' ? 4 : 8; + const hex = token.slice(index + 1, index + 1 + digitCount); + if (!new RegExp(`^[0-9a-fA-F]{${digitCount}}$`).test(hex)) { + return null; + } + const codePoint = Number.parseInt(hex, 16); + if (codePoint > 0x10FFFF || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) { + return null; + } + decoded += String.fromCodePoint(codePoint); + index += digitCount; + } + return decoded; +} + +function tomlFieldName(line) { + const match = line.match(/^([A-Za-z0-9_-]+|'[^']*'|"(?:[^"\\]|\\.)*")\s*=/); + if (!match) { + return null; + } + const token = match[1]; + if (token.startsWith("'")) { + return token.slice(1, -1); + } + return token.startsWith('"') ? decodeBasicTomlKey(token) : token; +} + +function parseTomlStringArrayValue(line) { + const equalsIndex = line.indexOf('='); + const value = equalsIndex === -1 ? '' : line.slice(equalsIndex + 1).trim(); + const array = value.match(/^\[(.*)\]\s*(?:#.*)?$/); + if (!array) { + return null; + } + const inner = array[1]; + const values = []; + const stringPattern = /'[^']*'|"(?:[^"\\]|\\.)*"/g; + let cursor = 0; + for (const match of inner.matchAll(stringPattern)) { + if (!/^[\s,]*$/.test(inner.slice(cursor, match.index))) { + return null; + } + const token = match[0]; + const decoded = token.startsWith("'") + ? token.slice(1, -1) + : decodeBasicTomlKey(token); + if (decoded === null) { + return null; + } + values.push(decoded); + cursor = match.index + token.length; + } + return /^[\s,]*$/.test(inner.slice(cursor)) ? values : null; +} + function parseExplicitTestTargets(manifestText) { const targets = []; let current = null; @@ -67,6 +309,10 @@ function parseExplicitTestTargets(manifestText) { finishCurrent(); continue; } + if (current && tomlFieldName(trimmed) === 'required-features') { + current.hasRequiredFeatures = true; + current.requiredFeatures = parseTomlStringArrayValue(trimmed); + } const field = current && trimmed.match(/^(name|path)\s*=\s*"([^"]+)"\s*$/); if (field) { current[field[1]] = field[2]; @@ -386,12 +632,41 @@ export function validateExplicitIntegrationTestTopology({ } const expectedTargetEntries = expectedTargets.map(({ name, path }) => `${name}=${path}`).sort(); - const actualTargetEntries = parseExplicitTestTargets(manifestText) + const actualTargets = parseExplicitTestTargets(manifestText); + const actualTargetEntries = actualTargets .map(({ name, path }) => `${name ?? ''}=${path ?? ''}`) .sort(); if (actualTargetEntries.join('\n') !== expectedTargetEntries.join('\n')) { errors.push(`explicit test targets must be exactly: ${expectedTargetEntries.join(', ')}`); } + const targetsWithoutRequiredFeatures = new Set( + expectedTargets + .filter(({ forbidRequiredFeatures }) => forbidRequiredFeatures) + .map(({ name, path }) => `${name}=${path}`), + ); + for (const { name, path, hasRequiredFeatures } of actualTargets) { + if (hasRequiredFeatures && targetsWithoutRequiredFeatures.has(`${name}=${path}`)) { + errors.push(`explicit test target ${name} must not declare required-features`); + } + } + for (const { name, path, requiredFeatures } of expectedTargets) { + if (requiredFeatures === undefined) { + continue; + } + const actual = actualTargets.find( + (target) => target.name === name && target.path === path, + ); + const actualRequiredFeatures = actual?.requiredFeatures; + if ( + actualRequiredFeatures === null + || actualRequiredFeatures === undefined + || [...actualRequiredFeatures].sort().join('\n') !== [...requiredFeatures].sort().join('\n') + ) { + errors.push( + `explicit test target ${name} required-features must be exactly: ${requiredFeatures.join(', ')}`, + ); + } + } const expectedRoots = expectedTargets.map(({ path }) => path).sort(); if ([...topLevelRustFiles].sort().join('\n') !== expectedRoots.join('\n')) { @@ -399,6 +674,13 @@ export function validateExplicitIntegrationTestTopology({ } const leaves = new Set(leafRustFiles); + const expectedLeaves = expectedTargets.flatMap(({ leaves: targetLeaves = [] }) => targetLeaves).sort(); + if ( + expectedLeaves.length > 0 + && [...leaves].sort().join('\n') !== expectedLeaves.join('\n') + ) { + errors.push(`grouped test leaves must be exactly: ${expectedLeaves.join(', ')}`); + } const referenceCounts = new Map(); for (const root of expectedRoots) { const source = rootSources.get(root); @@ -543,9 +825,75 @@ export function checkServicesIntegrationsIntegrationTestTopology(root) { }); } +export function checkOpencodeAdapterIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/adapters/opencode-adapter', + expectedTargets: opencodeAdapterIntegrationTestTargets, + ignoredDirectories: ['tests/fixtures'], + }); +} + +export function checkClaudeCodeAdapterIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/adapters/claude-code-adapter', + expectedTargets: claudeCodeAdapterIntegrationTestTargets, + }); +} + +export function checkCodexAdapterIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/adapters/codex-adapter', + expectedTargets: codexAdapterIntegrationTestTargets, + }); +} + +export function checkExternalSourcesIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/assembly/external-sources', + expectedTargets: externalSourcesIntegrationTestTargets, + }); +} + +export function checkExternalSourceIntegrationTestTopologies(root) { + return [ + ...checkOpencodeAdapterIntegrationTestTopology(root), + ...checkClaudeCodeAdapterIntegrationTestTopology(root), + ...checkCodexAdapterIntegrationTestTopology(root), + ...checkExternalSourcesIntegrationTestTopology(root), + ]; +} + export function checkServiceIntegrationTestTopologies(root) { return [ ...checkServicesCoreIntegrationTestTopology(root), ...checkServicesIntegrationsIntegrationTestTopology(root), ]; } + +export function checkBuildGraphContractIntegrationTestTopologies(root) { + const topologies = [ + ['src/crates/contracts/core-types', coreTypesIntegrationTestTargets], + ['src/crates/contracts/runtime-ports', runtimePortsIntegrationTestTargets], + ['src/crates/contracts/product-domains', productDomainsIntegrationTestTargets], + [ + 'src/crates/adapters/ai-adapters', + aiAdaptersIntegrationTestTargets, + ['tests/common', 'tests/fixtures'], + ], + ['src/crates/assembly/product-capabilities', productCapabilitiesIntegrationTestTargets], + ]; + return topologies.flatMap(([cratePath, expectedTargets, ignoredDirectories]) => ( + checkExplicitIntegrationTestTopology(root, { + cratePath, + expectedTargets, + ignoredDirectories, + }) + )); +} + +export function checkReviewedIntegrationTestTopologies(root) { + return [ + ...checkServiceIntegrationTestTopologies(root), + ...checkBuildGraphContractIntegrationTestTopologies(root), + ]; +} diff --git a/scripts/core-boundaries/peer-command-policy.mjs b/scripts/core-boundaries/peer-command-policy.mjs new file mode 100644 index 000000000..7be725188 --- /dev/null +++ b/scripts/core-boundaries/peer-command-policy.mjs @@ -0,0 +1,137 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +/** + * Peer Device Mode controller/peer command ownership boundary. + * + * The controller-side deny list in the Web UI transport adapter is an + * optimization: it keeps a controller-owned command on the controller without + * a round trip. It is not the boundary. A controller running an older build, + * or any non-Web-UI controller, still reaches a peer host over HostInvoke, so + * each peer host must independently refuse every controller-owned command. + * + * The enforced direction is therefore one-way: whatever the controller refuses + * to send, a peer host must also refuse to run. A host denying more than the + * controller is safe and stays allowed. + */ + +const FE_ADAPTER = 'src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts'; +const DESKTOP_HOST = 'src/apps/desktop/src/api/peer_host_invoke.rs'; +const CLI_HOST = 'src/apps/cli/src/peer_host/deny.rs'; + +/** + * Commands the CLI peer host answers before the deny-list check, so they are + * intentionally absent from its list. See `src/apps/cli/src/peer_host/dispatch.rs`. + */ +const CLI_PRE_HANDLED_COMMANDS = new Set([ + 'peer_control_attach', + 'peer_control_detach', + 'peer_mode_ping', + 'account_cancel_pending_login', +]); + +function stripLineComments(text) { + return text.replace(/\/\/[^\n]*/g, ''); +} + +function parseTypeScriptSet(source, name) { + const match = new RegExp(`const ${name}\\s*=\\s*new Set\\(\\[(.*?)\\n\\]\\);`, 's').exec(source); + if (!match) { + return null; + } + return new Set(Array.from(stripLineComments(match[1]).matchAll(/'([^']+)'/g), m => m[1])); +} + +function parseRustSlice(source, name) { + const match = new RegExp(`static ${name}[^=]*=\\s*&\\[(.*?)\\n\\];`, 's').exec(source); + if (!match) { + return null; + } + return new Set(Array.from(stripLineComments(match[1]).matchAll(/"([^"]+)"/g), m => m[1])); +} + +export function checkPeerCommandPolicySync(root) { + const failures = []; + + const read = (relativePath) => { + try { + return readFileSync(join(root, relativePath), 'utf8'); + } catch { + failures.push({ + path: relativePath, + line: 1, + message: + 'Peer command policy check could not read this file; update scripts/core-boundaries/peer-command-policy.mjs if it moved', + }); + return null; + } + }; + + const feSource = read(FE_ADAPTER); + const desktopSource = read(DESKTOP_HOST); + const cliSource = read(CLI_HOST); + if (!feSource || !desktopSource || !cliSource) { + return failures; + } + + const controllerDenied = parseTypeScriptSet(feSource, 'LOCAL_ONLY_COMMANDS'); + const desktopDenied = parseRustSlice(desktopSource, 'LOCAL_ONLY_COMMANDS'); + const cliDenied = parseRustSlice(cliSource, 'LOCAL_ONLY_COMMANDS'); + + for (const [path, parsed] of [ + [FE_ADAPTER, controllerDenied], + [DESKTOP_HOST, desktopDenied], + [CLI_HOST, cliDenied], + ]) { + if (!parsed) { + failures.push({ + path, + line: 1, + message: + 'Could not parse LOCAL_ONLY_COMMANDS; keep the declaration shape the peer command policy check expects', + }); + } + } + if (!controllerDenied || !desktopDenied || !cliDenied) { + return failures; + } + + const missingOnDesktop = [...controllerDenied].filter(command => !desktopDenied.has(command)); + if (missingOnDesktop.length > 0) { + failures.push({ + path: DESKTOP_HOST, + line: 1, + message: + `Desktop peer host must refuse every controller-owned command. Missing from LOCAL_ONLY_COMMANDS: ${missingOnDesktop.sort().join(', ')}. ` + + 'An older or non-Web-UI controller can still HostInvoke these onto this peer', + }); + } + + const missingOnCli = [...controllerDenied].filter( + command => !cliDenied.has(command) && !CLI_PRE_HANDLED_COMMANDS.has(command), + ); + if (missingOnCli.length > 0) { + failures.push({ + path: CLI_HOST, + line: 1, + message: + `CLI peer host must refuse every controller-owned command. Missing from LOCAL_ONLY_COMMANDS: ${missingOnCli.sort().join(', ')}. ` + + 'An older or non-Web-UI controller can still HostInvoke these onto this peer', + }); + } + + const staleCliExceptions = [...CLI_PRE_HANDLED_COMMANDS].filter( + command => !controllerDenied.has(command), + ); + if (staleCliExceptions.length > 0) { + failures.push({ + path: CLI_HOST, + line: 1, + message: + `Stale CLI pre-handled exception(s) in scripts/core-boundaries/peer-command-policy.mjs: ${staleCliExceptions.sort().join(', ')}. ` + + 'Remove the exception once the controller no longer treats the command as controller-owned', + }); + } + + return failures; +} diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 7e69f9b97..95d8f6b12 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -67,6 +67,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'bitfun-core product/runtime optional dependencies must stay owned by explicit feature gates', dependencies: [ { depName: 'axum', ownerFeatures: ['debug-log', 'mcp-runtime'] }, + { depName: 'base64', ownerFeatures: ['agent-runtime', 'dispatch-store'] }, { depName: 'bitfun-ai-adapters', ownerFeatures: ['ai-adapter-runtime', 'subscription-auth'], @@ -139,6 +140,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'filetime', ownerFeatures: ['agent-runtime'] }, { depName: 'flate2', ownerFeatures: ['agent-runtime'] }, { depName: 'fs2', ownerFeatures: ['agent-runtime'] }, + { depName: 'futures', ownerFeatures: ['agent-runtime'] }, { depName: 'image', ownerFeatures: ['agent-runtime'] }, { depName: 'include_dir', ownerFeatures: ['agent-runtime'] }, { depName: 'indexmap', ownerFeatures: ['agent-runtime'] }, @@ -148,6 +150,7 @@ export const optionalDependencyFeatureOwnerRules = [ // over upstream, which moved rand to services-integrations). { depName: 'rand', ownerFeatures: ['agent-runtime'] }, { depName: 'reqwest', ownerFeatures: ['mcp-runtime', 'tools-miniapp'] }, + { depName: 'regex', ownerFeatures: ['agent-runtime'] }, { depName: 'rusqlite', ownerFeatures: ['agent-runtime'] }, { depName: 'semver', ownerFeatures: ['tools-miniapp'] }, { depName: 'serde_yaml', ownerFeatures: ['workspace-runtime'] }, @@ -155,6 +158,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'terminal-core', ownerFeatures: ['terminal'] }, { depName: 'notify', ownerFeatures: ['lsp', 'workspace-watch'] }, { depName: 'tokio-tungstenite', ownerFeatures: ['browser-control'] }, + { depName: 'tokio-util', ownerFeatures: ['agent-runtime', 'debug-log'] }, { depName: 'tower-http', ownerFeatures: ['debug-log'] }, { depName: 'tool-runtime', @@ -296,7 +300,72 @@ export const coreProductFullFeatureAssemblyRule = { reason: 'bitfun-core product-full must explicitly assemble current owner feature groups', }; +export const acpClientCoreFeatures = [ + 'agent-runtime', + 'ssh-remote', +]; + +export const acpServerCoreFeatures = [ + 'agent-runtime', + 'document-read', + 'subscription-auth', + 'deep-research', + 'lsp', + 'external-sources', + 'tools-basic', + 'tools-git', + 'tools-mcp', + 'tools-browser-web', + 'tools-computer-use', + 'tools-image-analysis', + 'tools-miniapp', + 'tools-canvas', + 'tools-agent-control', +]; + +export const acpClosedFeatureProfileRules = [ + { + manifestPath: 'src/crates/interfaces/acp/Cargo.toml', + featureName: 'default', + requiredFeatureRefs: ['client', 'server'], + exact: true, + reason: 'bitfun-acp default must preserve its complete client and server compatibility surface', + }, + { + manifestPath: 'src/crates/interfaces/acp/Cargo.toml', + featureName: 'client', + requiredFeatureRefs: [ + 'dep:futures', + 'dep:serde', + 'dep:bitfun-core', + ...acpClientCoreFeatures.map((feature) => `bitfun-core/${feature}`), + ], + exact: true, + reason: 'bitfun-acp client must own only external ACP agent and SSH transport capabilities', + }, + { + manifestPath: 'src/crates/interfaces/acp/Cargo.toml', + featureName: 'server', + requiredFeatureRefs: [ + 'dep:bitfun-agent-runtime', + 'dep:bitfun-core-types', + 'dep:bitfun-core', + 'dep:sha2', + ...acpServerCoreFeatures.map((feature) => `bitfun-core/${feature}`), + ], + exact: true, + reason: 'bitfun-acp server must preserve the reviewed Agent Runtime capability surface without SSH transport', + }, +]; + export const coreClosedFeatureProfileRules = [ + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'default', + requiredFeatureRefs: [], + exact: true, + reason: 'bitfun-core default must stay empty so product entrypoints select capabilities explicitly', + }, { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'agent-runtime', @@ -305,11 +374,13 @@ export const coreClosedFeatureProfileRules = [ 'dep:bitfun-agent-runtime', 'dep:bitfun-agent-content', 'dep:bitfun-agent-stream', + 'dep:base64', 'dep:bitfun-harness', 'dep:dashmap', 'dep:filetime', 'dep:flate2', 'dep:fs2', + 'dep:futures', 'dep:include_dir', 'dep:indexmap', 'dep:image', @@ -318,7 +389,9 @@ export const coreClosedFeatureProfileRules = [ // unconditionally under agent-runtime (customization over upstream). 'dep:rand', 'dep:rusqlite', + 'dep:regex', 'dep:similar', + 'dep:tokio-util', 'dep:tool-runtime', 'bitfun-services-core/permission', 'bitfun-services-core/runtime-ownership', @@ -704,6 +777,7 @@ export const coreClosedFeatureProfileRules = [ featureName: 'debug-log', requiredFeatureRefs: [ 'dep:axum', + 'dep:tokio-util', 'dep:tower-http', 'bitfun-services-integrations/debug-log', ], @@ -830,7 +904,11 @@ export const coreClosedFeatureProfileRules = [ { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'dispatch-store', - requiredFeatureRefs: ['local-storage', 'bitfun-services-core/dispatch-workspace'], + requiredFeatureRefs: [ + 'dep:base64', + 'local-storage', + 'bitfun-services-core/dispatch-workspace', + ], exact: true, reason: 'bitfun-core dispatch-store must expose only the durable dispatch index facade', }, diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index d6eaab22e..1ed5f3cc5 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -216,7 +216,7 @@ export const forbiddenContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs', patterns: [ { regex: /\bbitfun_core\b/, @@ -4135,13 +4135,13 @@ export const forbiddenContentUnderRules = [ /\b(?:use\s+bitfun_opencode_adapter\b|extern\s+crate\s+bitfun_opencode_adapter\b|bitfun_opencode_adapter::)/, allowPaths: [ 'src/crates/adapters/opencode-adapter/tests/opencode_source_adapter.rs', - 'src/crates/adapters/opencode-adapter/tests/opencode_command_adapter.rs', - 'src/crates/adapters/opencode-adapter/tests/opencode_skill_roots.rs', - 'src/crates/adapters/opencode-adapter/tests/opencode_workspace_references.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_command_adapter.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_skill_roots.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_workspace_references.rs', 'src/crates/adapters/opencode-adapter/tests/tool_source_contracts.rs', - 'src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_subagent_adapter.rs', 'src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs', - 'src/crates/adapters/opencode-adapter/tests/hook_source.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/hook_source.rs', 'src/crates/assembly/core/src/plugin_runtime.rs', 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', @@ -4171,10 +4171,10 @@ export const forbiddenContentUnderRules = [ patterns: [{ regex: /\b(?:use\s+bitfun_claude_code_adapter\b|extern\s+crate\s+bitfun_claude_code_adapter\b|bitfun_claude_code_adapter::)/, allowPaths: [ - 'src/crates/adapters/claude-code-adapter/tests/hook_source.rs', - 'src/crates/adapters/claude-code-adapter/tests/command_source.rs', - 'src/crates/adapters/claude-code-adapter/tests/subagent_source.rs', - 'src/crates/adapters/claude-code-adapter/tests/mcp_source.rs', + 'src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/hook_source.rs', + 'src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/command_source.rs', + 'src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/subagent_source.rs', + 'src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/mcp_source.rs', 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', 'src/crates/assembly/core/src/instruction_sources.rs', @@ -4188,9 +4188,9 @@ export const forbiddenContentUnderRules = [ patterns: [{ regex: /\b(?:use\s+bitfun_codex_adapter\b|extern\s+crate\s+bitfun_codex_adapter\b|bitfun_codex_adapter::)/, allowPaths: [ - 'src/crates/adapters/codex-adapter/tests/hook_source.rs', - 'src/crates/adapters/codex-adapter/tests/subagent_source.rs', - 'src/crates/adapters/codex-adapter/tests/mcp_source.rs', + 'src/crates/adapters/codex-adapter/tests/codex_source_contracts/hook_source.rs', + 'src/crates/adapters/codex-adapter/tests/codex_source_contracts/subagent_source.rs', + 'src/crates/adapters/codex-adapter/tests/codex_source_contracts/mcp_source.rs', 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', 'src/crates/assembly/core/src/instruction_sources.rs', diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 8b4dc744d..b0aa4ce73 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -432,7 +432,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/contracts/core-types/tests/lsp_contracts.rs', + path: 'src/crates/contracts/core-types/tests/core_type_contracts/lsp_contracts.rs', reason: 'core-types must keep LSP manifest serialization, default-value, and placeholder regressions', patterns: [ @@ -1513,7 +1513,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_capabilities.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs', reason: 'product-capabilities tests must protect product shape facts, runtime service gap reporting, and legacy harness routing', patterns: [ @@ -1544,7 +1544,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/plugin_product_shape.rs', reason: 'product-capabilities plugin shape tests must protect P0 plugin-capable profiles, non-P0 rejection, default availability reasons, and runtime handoff', patterns: [ @@ -1571,7 +1571,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs', reason: 'product-capabilities must prove product runtime parts can feed the SDK runtime without bitfun-core', patterns: [ @@ -4843,7 +4843,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/contracts/runtime-ports/tests/plugin_runtime_contracts.rs', + path: 'src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_contracts.rs', reason: 'runtime-ports plugin contract tests must cover typed envelopes, candidate effects, and disabled/projection-only behavior', patterns: [ @@ -4900,7 +4900,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/contracts/runtime-ports/tests/plugin_runtime_diagnostics_contracts.rs', + path: 'src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs', reason: 'runtime-ports plugin diagnostics contract tests must cover permission prompts, diagnostics, and quarantine facts', patterns: [ diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 2f4e8d9b3..51a4b9416 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -7,6 +7,7 @@ export function runManifestParserSelfTest({ parseManifestDependencies, manifestDependencyMatches, matchingForbiddenDependency, + acpClosedFeatureProfileRules, coreClosedFeatureProfileRules, coreProductFullFeatureAssemblyRule, ownerCrateFeatureAssemblyRules, @@ -152,6 +153,53 @@ export function runManifestParserSelfTest({ if (!orphanErrors.some((error) => error.includes('orphan_contracts.rs'))) { throw new Error('explicit integration-test topology must reject an orphan leaf test'); } + const reviewedLeafTargets = agentRuntimeIntegrationTestTargets.map((target) => ( + target.path === 'tests/agent_definition_contracts.rs' + ? { + ...target, + leaves: ['tests/agent_definition_contracts/prompt_contracts.rs'], + forbidRequiredFeatures: true, + } + : target + )); + const missingReviewedLeafErrors = validateExplicitIntegrationTestTopology({ + ...explicitTestFixture, + expectedTargets: reviewedLeafTargets, + leafRustFiles: [], + leafSources: new Map(), + }); + if (!missingReviewedLeafErrors.some((error) => error.includes('grouped test leaves'))) { + throw new Error('explicit integration-test topology must reject a removed reviewed leaf'); + } + for (const requiredFeaturesDeclaration of [ + 'required-features = [\n "opt-in",\n]', + '"required\\u002dfeatures" = ["opt-in"]', + ]) { + const unexpectedRequiredFeaturesErrors = validateExplicitIntegrationTestTopology({ + ...explicitTestFixture, + expectedTargets: reviewedLeafTargets, + manifestText: explicitTestManifest.replace( + 'path = "tests/agent_definition_contracts.rs"', + `path = "tests/agent_definition_contracts.rs"\n${requiredFeaturesDeclaration}`, + ), + }); + if (!unexpectedRequiredFeaturesErrors.some((error) => error.includes('required-features'))) { + throw new Error(`ungated explicit test topology accepted: ${requiredFeaturesDeclaration}`); + } + } + const independentRequiredFeaturesErrors = validateExplicitIntegrationTestTopology({ + ...explicitTestFixture, + expectedTargets: reviewedLeafTargets, + manifestText: explicitTestManifest.replace( + 'path = "tests/native_hook_execution_contracts.rs"', + 'path = "tests/native_hook_execution_contracts.rs"\nrequired-features = ["native-hooks"]', + ), + }); + if (independentRequiredFeaturesErrors.length > 0) { + throw new Error( + `target-scoped required-features contract rejected an independent target: ${independentRequiredFeaturesErrors.join('; ')}`, + ); + } const reviewedLeafCfgFixture = { ...explicitTestFixture, leafSources: new Map([[ @@ -352,6 +400,7 @@ export function runManifestParserSelfTest({ const coreManifest = 'src/crates/assembly/core/Cargo.toml'; const servicesCoreManifest = 'src/crates/services/services-core/Cargo.toml'; const expectedClosedCoreProfiles = [ + [coreManifest, 'default', []], [servicesCoreManifest, 'default', []], [ servicesCoreManifest, @@ -425,7 +474,11 @@ export function runManifestParserSelfTest({ ], [servicesCoreManifest, 'session-git', ['local-storage', 'dep:git2']], [servicesCoreManifest, 'workspace-identity', ['dep:dunce', 'dep:sha2']], - [coreManifest, 'dispatch-store', ['local-storage', 'bitfun-services-core/dispatch-workspace']], + [ + coreManifest, + 'dispatch-store', + ['dep:base64', 'local-storage', 'bitfun-services-core/dispatch-workspace'], + ], [coreManifest, 'filesystem', ['bitfun-services-core/filesystem']], [coreManifest, 'local-storage', ['bitfun-services-core/local-storage']], [coreManifest, 'process-runtime', ['bitfun-services-core/process-runtime']], @@ -492,6 +545,61 @@ export function runManifestParserSelfTest({ throw new Error(`core closed feature profile must not reach product-full in ${featureName}`); } } + const acpProfiles = new Map( + acpClosedFeatureProfileRules.map((rule) => [rule.featureName, rule]), + ); + const expectedAcpProfiles = new Map([ + ['default', ['client', 'server']], + [ + 'client', + [ + 'dep:futures', + 'dep:serde', + 'dep:bitfun-core', + 'bitfun-core/agent-runtime', + 'bitfun-core/ssh-remote', + ], + ], + [ + 'server', + [ + 'dep:bitfun-agent-runtime', + 'dep:bitfun-core-types', + 'dep:bitfun-core', + 'dep:sha2', + 'bitfun-core/agent-runtime', + 'bitfun-core/document-read', + 'bitfun-core/subscription-auth', + 'bitfun-core/deep-research', + 'bitfun-core/lsp', + 'bitfun-core/external-sources', + 'bitfun-core/tools-basic', + 'bitfun-core/tools-git', + 'bitfun-core/tools-mcp', + 'bitfun-core/tools-browser-web', + 'bitfun-core/tools-computer-use', + 'bitfun-core/tools-image-analysis', + 'bitfun-core/tools-miniapp', + 'bitfun-core/tools-canvas', + 'bitfun-core/tools-agent-control', + ], + ], + ]); + for (const [featureName, expectedReferences] of expectedAcpProfiles) { + const rule = acpProfiles.get(featureName); + if (!rule?.exact) { + throw new Error(`ACP closed feature profile must cover ${featureName} exactly`); + } + if ( + rule.requiredFeatureRefs.length !== expectedReferences.length + || expectedReferences.some((reference) => !rule.requiredFeatureRefs.includes(reference)) + ) { + throw new Error(`ACP closed feature profile has stale references for ${featureName}`); + } + if (rule.requiredFeatureRefs.some((reference) => reference.includes('product-full'))) { + throw new Error(`ACP closed feature profile must not reach product-full in ${featureName}`); + } + } const ownerFeatureRulePaths = new Set( ownerCrateFeatureAssemblyRules.map((rule) => rule.manifestPath), ); @@ -3191,7 +3299,7 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_capabilities.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs', contracts: [ 'product_assembly_plan_exposes_build_feature_groups_explicitly', 'product_runtime_assembly_reports_runtime_service_capability_gaps', @@ -3199,7 +3307,7 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/plugin_product_shape.rs', contracts: [ 'executable_plugin_runtime_is_limited_to_product_full_desktop_and_cli', 'executable_plugin_runtime_client_builds_agent_runtime_parts', @@ -3238,7 +3346,7 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs', contracts: [ 'product_runtime_parts_can_build_agent_runtime_sdk_without_core', 'sdk_delivery_profile_builds_shared_runtime_owner_ceiling_without_bitfun_core', diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index b6a07afd6..8963cf5d5 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -53,7 +53,7 @@ bitfun-core = { path = "../../crates/assembly/core", default-features = false, f ] } bitfun-events = { path = "../../crates/contracts/events" } bitfun-core-types = { path = "../../crates/contracts/core-types" } -bitfun-acp = { path = "../../crates/interfaces/acp" } +bitfun-acp = { path = "../../crates/interfaces/acp", default-features = false, features = ["client", "server"] } bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } bitfun-agent-runtime-ipc = { path = "../../crates/adapters/agent-runtime-ipc" } bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports" } @@ -79,7 +79,6 @@ toml = { workspace = true } # Session management uuid = { workspace = true } chrono = { workspace = true } -dashmap = { workspace = true } # Async trait async-trait = { workspace = true } @@ -98,7 +97,6 @@ similar = { workspace = true } # Syntax highlighting for code blocks and tool cards syntect = { workspace = true } -syntect-tui = { workspace = true } # Lazy initialization for syntax highlighter singleton once_cell = { workspace = true } diff --git a/src/apps/cli/src/agent/tui_client.rs b/src/apps/cli/src/agent/tui_client.rs index eedd9822e..672fd3d5e 100644 --- a/src/apps/cli/src/agent/tui_client.rs +++ b/src/apps/cli/src/agent/tui_client.rs @@ -817,15 +817,21 @@ impl TuiAgentClient { &self, workspace_path: String, ) -> Result { - let paths = self - .workspace_paths - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); + let (remote_connection_id, remote_ssh_host) = { + let paths = self + .workspace_paths + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + ( + paths.remote_connection_id.clone(), + paths.remote_ssh_host.clone(), + ) + }; self.backend .worktree_repository_status(WorktreeRepositoryStatusRequest { workspace_path, - remote_connection_id: paths.remote_connection_id.clone(), - remote_ssh_host: paths.remote_ssh_host.clone(), + remote_connection_id, + remote_ssh_host, }) .await .map_err(Into::into) diff --git a/src/apps/cli/src/config.rs b/src/apps/cli/src/config.rs index 4925b2a72..7562d312c 100644 --- a/src/apps/cli/src/config.rs +++ b/src/apps/cli/src/config.rs @@ -274,6 +274,7 @@ impl CliConfig { let lock_path = config_path.with_extension("toml.lock"); let lock_file = OpenOptions::new() .create(true) + .truncate(true) .read(true) .write(true) .open(lock_path)?; diff --git a/src/apps/cli/src/dispatch/runner.rs b/src/apps/cli/src/dispatch/runner.rs index c31d7ab7b..85be4d3ff 100644 --- a/src/apps/cli/src/dispatch/runner.rs +++ b/src/apps/cli/src/dispatch/runner.rs @@ -221,27 +221,55 @@ pub(crate) fn process_alive(pid: u32) -> bool { return false; }; // SAFETY: signal 0 performs liveness/permission checking only. - if unsafe { libc::kill(pid, 0) } == 0 { - #[cfg(target_os = "linux")] - { - // A zombie still answers to kill(0), but it has already exited and - // must not be treated as an authenticated leader for escalation. - if let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) { - if stat - .rsplit_once(") ") - .and_then(|(_, fields)| fields.split_whitespace().next()) - == Some("Z") - { - return false; - } + if unsafe { libc::kill(pid, 0) } != 0 + && !matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EPERM) + ) + { + return false; + } + + #[cfg(target_os = "linux")] + { + // A zombie still answers to kill(0), but it has already exited and + // must not be treated as an authenticated leader for escalation. + if let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) { + if stat + .rsplit_once(") ") + .and_then(|(_, fields)| fields.split_whitespace().next()) + == Some("Z") + { + return false; } } - return true; } - matches!( - std::io::Error::last_os_error().raw_os_error(), - Some(libc::EPERM) - ) + + #[cfg(target_os = "macos")] + { + // macOS also reports zombies as present to kill(0). Query the process + // state before using a leader PID to authenticate SIGKILL escalation; + // a failed/empty query means the process disappeared during the check. + let output = Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "stat="]) + .output(); + let Ok(output) = output else { + return false; + }; + if !output.status.success() { + return false; + } + return String::from_utf8_lossy(&output.stdout) + .trim_start() + .chars() + .next() + .is_some_and(|state| state != 'Z'); + } + + #[cfg(not(target_os = "macos"))] + { + true + } } #[cfg(not(unix))] diff --git a/src/apps/cli/src/dispatch/workspace.rs b/src/apps/cli/src/dispatch/workspace.rs index c8716d250..3383759c7 100644 --- a/src/apps/cli/src/dispatch/workspace.rs +++ b/src/apps/cli/src/dispatch/workspace.rs @@ -735,8 +735,11 @@ fn bundle_commit_in_store( // `git bundle verify` checks the bundle's own integrity and that every // prerequisite commit is already present, so a bundle that would leave // a broken history is rejected before it touches the object store. - git(&repo, &["bundle", "verify", path_arg(&bundle_path)?.as_str()]) - .context("verify dispatch bundle")?; + git( + &repo, + &["bundle", "verify", path_arg(&bundle_path)?.as_str()], + ) + .context("verify dispatch bundle")?; git( &repo, &[ @@ -1142,7 +1145,12 @@ fn sync_in_store( let bundle_range = format!("{sync_base}..{}", provision.branch); git( &worktree, - &["bundle", "create", path_arg(&bundle_path)?.as_str(), &bundle_range], + &[ + "bundle", + "create", + path_arg(&bundle_path)?.as_str(), + &bundle_range, + ], ) .context("package dispatch result bundle")?; set_private_file_permissions(&bundle_path)?; @@ -1491,8 +1499,11 @@ fn create_worktree( git(repo, &["update-ref", &branch_ref, base_commit]) .context("point the dispatch branch at the requested base commit")?; } - git(repo, &["worktree", "add", path_arg(worktree_path)?.as_str(), branch]) - .context("create the dispatch worktree")?; + git( + repo, + &["worktree", "add", path_arg(worktree_path)?.as_str(), branch], + ) + .context("create the dispatch worktree")?; canonical_utf8(worktree_path) } @@ -1933,7 +1944,12 @@ mod tests { fn bundle_everything(source: &Path, bundle: &Path) { git( source, - &["bundle", "create", path_arg(bundle).expect("path").as_str(), "main"], + &[ + "bundle", + "create", + path_arg(bundle).expect("path").as_str(), + "main", + ], ) .expect("bundle"); } diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 9ea499180..2535fdc94 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -864,15 +864,14 @@ async fn initialize_core_services_for_deployment( // here so every model tool call sees it. An explicit environment value // wins over the config value when both exist (explicit env is the escape // hatch) — matching the desktop behavior exactly. - let configured_knowledge_base_root = match bitfun_core::service::config::get_global_config_service() - .await - { - Ok(service) => service - .get_config::(Some("ai.knowledge_base_root")) - .await - .ok(), - Err(_) => None, - }; + let configured_knowledge_base_root = + match bitfun_core::service::config::get_global_config_service().await { + Ok(service) => service + .get_config::(Some("ai.knowledge_base_root")) + .await + .ok(), + Err(_) => None, + }; inject_knowledge_base_root_if_needed(configured_knowledge_base_root).await; let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 4e6770864..b4c702e67 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -96,8 +96,18 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_list_jobs", "dispatch_answer", "dispatch_append", + "dispatch_continue", "dispatch_load_transcript", "dispatch_save_transcript", + "speech_list_models", + "speech_download_model", + "speech_cancel_model_download", + "speech_delete_model", + "speech_verify_model", + "speech_start_input_session", + "speech_append_audio_chunk", + "speech_finish_input_session", + "speech_cancel_input_session", ]; /// Desktop IDE surfaces that CLI Peer Host does not implement. @@ -158,10 +168,30 @@ mod tests { "dispatch_list_jobs", "dispatch_answer", "dispatch_append", + "dispatch_continue", "dispatch_load_transcript", "dispatch_save_transcript", ] { assert!(is_local_only_command(command), "{command}"); } } + + /// The controller-side FE deny list is an optimization, not the boundary. + /// An older or non-FE controller still reaches this host. + #[test] + fn speech_capture_stays_on_the_controller_device() { + for command in [ + "speech_list_models", + "speech_download_model", + "speech_cancel_model_download", + "speech_delete_model", + "speech_verify_model", + "speech_start_input_session", + "speech_append_audio_chunk", + "speech_finish_input_session", + "speech_cancel_input_session", + ] { + assert!(is_local_only_command(command), "{command}"); + } + } } diff --git a/src/apps/cli/src/prompt_stash.rs b/src/apps/cli/src/prompt_stash.rs index b4a6b457c..4ee61b2bc 100644 --- a/src/apps/cli/src/prompt_stash.rs +++ b/src/apps/cli/src/prompt_stash.rs @@ -177,6 +177,7 @@ impl PromptStashStore { let lock_path = self.path.with_extension("jsonl.lock"); let lock = OpenOptions::new() .create(true) + .truncate(true) .read(true) .write(true) .open(lock_path)?; @@ -398,6 +399,7 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); let lock = std::fs::OpenOptions::new() .create(true) + .truncate(true) .read(true) .write(true) .open(path.with_extension("jsonl.lock")) diff --git a/src/apps/cli/src/ui/markdown.rs b/src/apps/cli/src/ui/markdown.rs index 07692ded6..ff5fa804d 100644 --- a/src/apps/cli/src/ui/markdown.rs +++ b/src/apps/cli/src/ui/markdown.rs @@ -195,12 +195,7 @@ impl MarkdownRenderer { lines.push(Line::from(std::mem::take(&mut current_line_spans))); } TagEnd::Paragraph if !in_code_block && !table_state.in_table => { - flush_with_wrap( - &mut current_line_spans, - &mut lines, - wrap_width, - true, - ); + flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); lines.push(Line::from("")); } TagEnd::BlockQuote => { @@ -306,9 +301,7 @@ impl MarkdownRenderer { } } - Event::SoftBreak | Event::HardBreak - if !in_code_block && !table_state.in_table => - { + Event::SoftBreak | Event::HardBreak if !in_code_block && !table_state.in_table => { flush_with_wrap(&mut current_line_spans, &mut lines, wrap_width, true); } diff --git a/src/apps/cli/src/ui/mod.rs b/src/apps/cli/src/ui/mod.rs index 0d63333b4..2be1febaa 100644 --- a/src/apps/cli/src/ui/mod.rs +++ b/src/apps/cli/src/ui/mod.rs @@ -48,7 +48,7 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{ - backend::CrosstermBackend, + backend::{Backend, CrosstermBackend}, layout::{Alignment, Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span}, @@ -96,7 +96,10 @@ impl TerminalGuard { let operation_result = operation(); let mut resumed = init_terminal()?; - if let Err(error) = resumed.clear() { + // Same as startup.rs: `Terminal::clear()` in ratatui 0.30 queries the cursor + // position (DSR `ESC[6n`), which can time out in PTY test environments; clear the + // backend directly instead. + if let Err(error) = resumed.backend_mut().clear() { drop(resumed); return Err(error.into()); } diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 3c9f34dde..0d96d6fa1 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -361,8 +361,15 @@ impl StartupPage { || self.login_form.is_visible() } - pub(crate) fn run(&mut self, terminal: &mut Terminal) -> Result { - terminal.clear()?; + pub(crate) fn run(&mut self, terminal: &mut Terminal) -> Result + where + B::Error: Send + Sync + 'static, + { + // ratatui 0.30 的 `Terminal::clear()` 会先查询光标位置(crossterm 发 DSR + // `ESC[6n` 等待应答),在无人应答的 PTY 测试环境中会超时失败。 + // 直接清后端(`clear_region(All)`,语义与 0.29 的 `Terminal::clear()` 一致) + // 不查询光标位置;随后的首个 `terminal.draw` 即全量重绘,无需 back-buffer reset。 + terminal.backend_mut().clear()?; let mut event_reader = crate::ui::input::EventReader::default(); loop { diff --git a/src/apps/cli/src/ui/syntax_highlight.rs b/src/apps/cli/src/ui/syntax_highlight.rs index 1652fea12..1d2e7de5f 100644 --- a/src/apps/cli/src/ui/syntax_highlight.rs +++ b/src/apps/cli/src/ui/syntax_highlight.rs @@ -1,7 +1,7 @@ /// Syntax highlighting module for TUI /// -/// Uses `syntect` for syntax analysis and `syntect-tui` to convert -/// highlighted output into ratatui `Span`s. +/// Uses `syntect` for syntax analysis and converts highlighted output directly +/// into ratatui `Span`s. use once_cell::sync::Lazy; use ratatui::{ style::Style, diff --git a/src/apps/cli/src/ui/workspace_reference.rs b/src/apps/cli/src/ui/workspace_reference.rs index 6e5a41b50..5651027e2 100644 --- a/src/apps/cli/src/ui/workspace_reference.rs +++ b/src/apps/cli/src/ui/workspace_reference.rs @@ -73,7 +73,6 @@ fn parse_line_range(raw: &str) -> (String, Option, Option) { } } - #[derive(Debug, Default)] pub(crate) struct WorkspaceReferencePopupState { pub(crate) query: Option, diff --git a/src/apps/cli/tests/cli_command_contracts/exec_cli_contracts.rs b/src/apps/cli/tests/cli_command_contracts/exec_cli_contracts.rs index 1f3f9defe..6c6bba56d 100644 --- a/src/apps/cli/tests/cli_command_contracts/exec_cli_contracts.rs +++ b/src/apps/cli/tests/cli_command_contracts/exec_cli_contracts.rs @@ -367,6 +367,48 @@ fn stream_json_patch_success_emits_one_success_terminal() { ); } +#[test] +fn stream_json_malformed_sse_retries_then_completes() { + let server = MockOpenAiServer::malformed_sse_then_immediate(); + let environment = CliTestEnvironment::new(); + environment.configure_mock_model(server.base_url()); + let mut command = environment.std_command(); + command.args([ + "exec", + "exercise malformed provider stream retry", + "--output-format", + "stream-json", + ]); + let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30)); + server.assert_chat_completion_requests(2); + + let stdout = stdout(&output); + assert!(output.status.success(), "{}\n{stdout}", stderr(&output)); + let events = jsonl_events(&stdout); + assert!( + events.iter().any(|value| { + value["event"]["type"] == "TextChunk" + && value["event"]["text"] + .as_str() + .is_some_and(|text| text.contains(STREAM_COMPLETED_MARKER)) + }), + "retried model stream did not complete: {stdout}" + ); + assert_eq!( + events + .iter() + .filter(|value| is_terminal_event(value)) + .count(), + 1, + "retried stream must emit exactly one terminal envelope: {stdout}" + ); + assert_eq!( + events.last().expect("retried stream terminal event")["event"]["type"], + "DialogTurnCompleted", + "retried stream terminal must be last: {stdout}" + ); +} + #[test] fn stream_json_provider_http_403_emits_one_error_terminal() { let server = MockOpenAiServer::http_403("provider authorization denied"); @@ -493,7 +535,7 @@ fn stream_json_disconnect_then_exhausted_retry_failure_emits_one_error_terminal( "stream-json", ]); let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30)); - server.assert_chat_completion_requests(11); + server.assert_chat_completion_requests(10); let stdout = stdout(&output); assert!(!output.status.success(), "{stdout}"); diff --git a/src/apps/cli/tests/support/mod.rs b/src/apps/cli/tests/support/mod.rs index 0fd491a80..03fcd71dc 100644 --- a/src/apps/cli/tests/support/mod.rs +++ b/src/apps/cli/tests/support/mod.rs @@ -303,6 +303,7 @@ enum MockModelResponse { Gated, Http403 { reason: String }, DisconnectThenHttp403, + MalformedSseThenImmediate, } impl MockOpenAiServer { @@ -324,6 +325,10 @@ impl MockOpenAiServer { Self::spawn(MockModelResponse::DisconnectThenHttp403) } + pub(crate) fn malformed_sse_then_immediate() -> Self { + Self::spawn(MockModelResponse::MalformedSseThenImmediate) + } + pub(crate) fn base_url(&self) -> &str { &self.base_url } @@ -405,11 +410,14 @@ impl MockOpenAiServer { &disconnect_tx, ); attempt += 1; - if matches!( - response, - MockModelResponse::Http403 { .. } - | MockModelResponse::DisconnectThenHttp403 - ) { + let accepts_more_requests = + matches!( + response, + MockModelResponse::Http403 { .. } + | MockModelResponse::DisconnectThenHttp403 + ) || (matches!(response, MockModelResponse::MalformedSseThenImmediate) + && attempt < 2); + if accepts_more_requests { continue; } break; @@ -473,6 +481,13 @@ fn serve_model_response( ) .expect("write mock response headers"); + if matches!(response, MockModelResponse::MalformedSseThenImmediate) && attempt == 0 { + write_chunk(stream, b"data: not-json\n\n").expect("write malformed SSE frame"); + let _ = stream.write_all(b"0\r\n\r\n"); + let _ = stream.flush(); + return; + } + write_sse_chunk( stream, &json!({ diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 6798bbb61..a0a7e8224 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -31,7 +31,7 @@ bitfun-agent-tools = { path = "../../crates/execution/tool-contracts" } bitfun-transport = { path = "../../crates/adapters/transport", features = ["tauri-adapter"] } bitfun-events = { path = "../../crates/contracts/events" } bitfun-webdriver = { path = "../../crates/adapters/webdriver" } -bitfun-acp = { path = "../../crates/interfaces/acp" } +bitfun-acp = { path = "../../crates/interfaces/acp", default-features = false, features = ["client"] } # Tauri tauri = { workspace = true } @@ -42,7 +42,6 @@ tauri-plugin-log = { workspace = true } tauri-plugin-autostart = { workspace = true } tauri-plugin-notification = { workspace = true } tauri-plugin-updater = { workspace = true } -tauri-plugin-global-shortcut = { workspace = true } tauri-plugin-single-instance = { workspace = true } tauri-plugin-window-state = { workspace = true } keepawake = { workspace = true } diff --git a/src/apps/desktop/capabilities/default.json b/src/apps/desktop/capabilities/default.json index ff6229e4b..2ecc97db9 100644 --- a/src/apps/desktop/capabilities/default.json +++ b/src/apps/desktop/capabilities/default.json @@ -105,11 +105,6 @@ "notification:allow-request-permission", "notification:allow-check-permissions", "notification:allow-permission-state", - "notification:allow-is-permission-granted", - "global-shortcut:default", - "global-shortcut:allow-register", - "global-shortcut:allow-unregister", - "global-shortcut:allow-unregister-all", - "global-shortcut:allow-is-registered" + "notification:allow-is-permission-granted" ] } diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs index 162423944..819e338f8 100644 --- a/src/apps/desktop/src/api/acp_client_api.rs +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -296,8 +296,7 @@ fn acp_tool_event_to_tool_item(event: &ToolEventData) -> Option { id: identity.tool_id.clone(), tool_name: identity.effective_name().to_string(), tool_call: ToolCallData { - input: acp_tool_event_started_input(event) - .unwrap_or_else(|| serde_json::json!({})), + input: acp_tool_event_started_input(event).unwrap_or_else(|| serde_json::json!({})), id: identity.tool_id.clone(), }, tool_result, @@ -502,7 +501,10 @@ async fn persist_acp_dialog_turn_backend( status, error, ); - if let Err(error) = persistence.save_dialog_turn(session_storage_path, &turn).await { + if let Err(error) = persistence + .save_dialog_turn(session_storage_path, &turn) + .await + { log::warn!( "Failed to persist ACP dialog turn: session_id={} turn_id={} error={}", session_id, @@ -1314,8 +1316,8 @@ mod tests { #[test] fn acp_tool_event_maps_lifecycle_variants() { - let started = acp_tool_event_to_tool_item(&started_event("tool-1")) - .expect("started maps to an item"); + let started = + acp_tool_event_to_tool_item(&started_event("tool-1")).expect("started maps to an item"); assert_eq!(started.id, "tool-1"); assert_eq!(started.tool_name, "Bash"); assert_eq!(started.status.as_deref(), Some("in_progress")); @@ -1329,8 +1331,8 @@ mod tests { assert!(result.success); assert_eq!(result.duration_ms, Some(12)); - let failed = acp_tool_event_to_tool_item(&failed_event("tool-1")) - .expect("failed maps to an item"); + let failed = + acp_tool_event_to_tool_item(&failed_event("tool-1")).expect("failed maps to an item"); assert_eq!(failed.status.as_deref(), Some("failed")); let result = failed.tool_result.expect("failed has a result"); assert!(!result.success); @@ -1366,7 +1368,10 @@ mod tests { accumulator.apply_tool_event(&failed_event("tool-2")); accumulator.finish_current_round(); assert_eq!(accumulator.rounds[1].tool_items.len(), 1); - assert_eq!(accumulator.rounds[1].tool_items[0].status.as_deref(), Some("failed")); + assert_eq!( + accumulator.rounds[1].tool_items[0].status.as_deref(), + Some("failed") + ); } #[test] diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 9c35f540a..78e91388d 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -3199,7 +3199,9 @@ pub async fn delete_session_tree( session_id, deleted_session_ids.len() ); - Ok(DeleteSessionTreeResponse { deleted_session_ids }) + Ok(DeleteSessionTreeResponse { + deleted_session_ids, + }) } #[tauri::command] diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 777f253c3..1118d8502 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -5365,10 +5365,8 @@ pub async fn create_legion_preset( /// previously the component and its appearance descriptor existed but no /// consumer rendered them, so the registry entry was a no-op contract). #[tauri::command] -pub async fn list_legion_presets() -> Result< - Vec, - String, -> { +pub async fn list_legion_presets( +) -> Result, String> { bitfun_core::agentic::agents::team_presets::list_presets() .map_err(|e| format!("Failed to list legion presets: {e}")) } diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index bac4f2025..22ef02262 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -119,6 +119,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_list_jobs", "dispatch_answer", "dispatch_append", + "dispatch_continue", "dispatch_load_transcript", "dispatch_save_transcript", // One-click relay deploy SSHes from the controller to a user host @@ -129,6 +130,16 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "relay_deploy_cancel", "relay_deploy_register", "relay_deploy_verify", + // Speech capture and model files belong to the machine the user speaks at. + "speech_list_models", + "speech_download_model", + "speech_cancel_model_download", + "speech_delete_model", + "speech_verify_model", + "speech_start_input_session", + "speech_append_audio_chunk", + "speech_finish_input_session", + "speech_cancel_input_session", ]; static PENDING: OnceLock>>> = @@ -476,6 +487,30 @@ mod tests { } } + /// The controller-side FE deny list is an optimization, not the boundary. + /// A controller on an older build (or a non-FE controller) still reaches + /// this host, so every controller-owned command must be refused here too. + #[test] + fn controller_owned_capture_and_dispatch_commands_are_refused_on_the_peer() { + for command in [ + // Capture and model files belong to the machine the user speaks at. + "speech_list_models", + "speech_download_model", + "speech_cancel_model_download", + "speech_delete_model", + "speech_verify_model", + "speech_start_input_session", + "speech_append_audio_chunk", + "speech_finish_input_session", + "speech_cancel_input_session", + // Same controller-owned observer/credential family as the other + // dispatch verbs already denied here. + "dispatch_continue", + ] { + assert!(is_local_only_command(command), "{command}"); + } + } + #[test] fn only_the_final_detach_drains_peer_permission_requests() { let mut state = control_state(&["controller-a", "controller-b"], &["request-1"]); diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 793fb8da3..6586b364b 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -1240,11 +1240,10 @@ async fn register_delegated_identity_providers() { let account_lease = lock_account_sync(generation) .await .map_err(|_| "Desktop account changed; try again".to_string())?; - let context = account_context - .read() - .await - .clone() - .ok_or_else(|| "Desktop is not logged into a BitFun account".to_string())?; + let context = + account_context.read().await.clone().ok_or_else(|| { + "Desktop is not logged into a BitFun account".to_string() + })?; if !account_context_matches(generation, &context.session.token).await { return Err("Desktop account changed; try again".to_string()); } diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 0b0e30c53..e70a25522 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -349,10 +349,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ("create_miniapp", RemoteWorkspacePolicy::LegacyUnaudited), ("create_session", RemoteWorkspacePolicy::LegacyUnaudited), ("create_subagent", RemoteWorkspacePolicy::LegacyUnaudited), - ( - "create_legion_preset", - RemoteWorkspacePolicy::LocalOnly, - ), + ("create_legion_preset", RemoteWorkspacePolicy::LocalOnly), ("debug_close_devtools", RemoteWorkspacePolicy::LocalOnly), ("debug_devtools_available", RemoteWorkspacePolicy::LocalOnly), ("debug_element_picked", RemoteWorkspacePolicy::LocalOnly), @@ -511,6 +508,24 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "generate_session_title", RemoteWorkspacePolicy::LegacyUnaudited, ), + ("group_chat_create", RemoteWorkspacePolicy::RemoteRouted), + ("group_chat_delete", RemoteWorkspacePolicy::RemoteRouted), + ( + "group_chat_ingest_reply", + RemoteWorkspacePolicy::RemoteRouted, + ), + ("group_chat_join", RemoteWorkspacePolicy::RemoteRouted), + ("group_chat_leave", RemoteWorkspacePolicy::RemoteRouted), + ("group_chat_list", RemoteWorkspacePolicy::RemoteRouted), + ("group_chat_load", RemoteWorkspacePolicy::RemoteRouted), + ("group_chat_members", RemoteWorkspacePolicy::RemoteRouted), + ("group_chat_messages", RemoteWorkspacePolicy::RemoteRouted), + ( + "group_chat_scan_timeouts", + RemoteWorkspacePolicy::RemoteRouted, + ), + ("group_chat_send", RemoteWorkspacePolicy::RemoteRouted), + ("group_chat_set_mode", RemoteWorkspacePolicy::RemoteRouted), ("get_acp_clients", RemoteWorkspacePolicy::LegacyUnaudited), ( "get_acp_session_commands", @@ -903,10 +918,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "list_agent_companion_pets", RemoteWorkspacePolicy::LegacyUnaudited, ), - ( - "list_legion_presets", - RemoteWorkspacePolicy::LocalOnly, - ), + ("list_legion_presets", RemoteWorkspacePolicy::LocalOnly), ( "list_agent_tool_names", RemoteWorkspacePolicy::LegacyUnaudited, diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index 7431a9998..06974ce43 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -881,3 +881,356 @@ pub async fn delete_all_archived_sessions( Ok(deleted_count) } + +// --------------------------------------------------------------------------- +// Group chat commands (R-GC-12, P2-1: 11 commands unified naming) +// --------------------------------------------------------------------------- +// P0-2/P1-4: every command is a thin wrapper over the shared GroupChatTool +// pipeline (create_room_impl / join_room_impl / leave_room_impl / +// delete_room_impl / set_mode_impl / send_message_impl), so the UI path shares +// validation, back-index (S-38), dispatch routing, and error codes with the +// tool path — no parallel implementation. + +use bitfun_core::agentic::coordination::{get_global_coordinator, ConversationCoordinator}; +use bitfun_core::agentic::session::session_store_port::CoreSessionStorePort; +use bitfun_core::agentic::tools::implementations::group_chat_tool::{ + parse_group_chat_error_code, GroupChatTool, +}; +use bitfun_core::service::session::GroupChatStore; +use bitfun_core::util::errors::BitFunError; +use bitfun_runtime_ports::{ + GroupChatActor, GroupChatError, GroupChatErrorCode, GroupChatMember, GroupChatMessage, + GroupChatMessagesResponse, GroupChatMode, GroupChatRoom, GroupChatSendResult, + SessionStoragePathRequest, SessionStorePort, +}; +use std::sync::Arc; + +/// Resolves the group-chats root (sibling of the sessions root) for a workspace. +async fn group_chats_root(workspace_path: &str) -> Result { + let request = SessionStoragePathRequest { + workspace_path: std::path::PathBuf::from(workspace_path), + remote_connection_id: None, + remote_ssh_host: None, + }; + let resolution = CoreSessionStorePort::default() + .resolve_session_storage_path(request) + .await + .map_err(|error| format!("Failed to resolve sessions root: {error}"))?; + let sessions_root = resolution.effective_storage_path; + let parent = sessions_root + .parent() + .ok_or_else(|| "sessions root has no parent directory".to_string())?; + Ok(parent.join("group-chats")) +} + +async fn group_chat_store(workspace_path: &str) -> Result { + let root = group_chats_root(workspace_path).await?; + Ok(GroupChatStore::new(root)) +} + +/// Converts a tool error into a structured `GroupChatError` so the frontend +/// can branch on the contract error code (P1-5). Legacy plain errors degrade +/// to a generic message with no code. +fn group_chat_command_error(error: BitFunError) -> GroupChatError { + let message = error.to_string(); + let code = parse_group_chat_error_code(&message).unwrap_or(GroupChatErrorCode::NotFound); + GroupChatError { code, message } +} + +/// Resolves the global coordinator (the shared pipeline entry). Falls back to +/// an error string when the coordinator is not initialized. +fn require_coordinator() -> Result, GroupChatError> { + get_global_coordinator().ok_or_else(|| GroupChatError { + code: GroupChatErrorCode::NotFound, + message: "coordinator not initialized".to_string(), + }) +} + +#[tauri::command] +pub async fn group_chat_list(workspace_path: String) -> Result, GroupChatError> { + let store = group_chat_store(&workspace_path) + .await + .map_err(|message| GroupChatError { + code: GroupChatErrorCode::NotFound, + message, + })?; + let (rooms, _) = store.list_rooms().await.map_err(|error| GroupChatError { + code: group_chat_store_error_code(&error), + message: error.to_string(), + })?; + Ok(rooms) +} + +#[tauri::command] +pub async fn group_chat_load( + workspace_path: String, + room_id: String, +) -> Result { + let store = group_chat_store(&workspace_path) + .await + .map_err(|message| GroupChatError { + code: GroupChatErrorCode::NotFound, + message, + })?; + store + .load_room(&room_id) + .await + .map_err(|error| GroupChatError { + code: group_chat_store_error_code(&error), + message: error.to_string(), + }) +} + +#[tauri::command] +pub async fn group_chat_members( + workspace_path: String, + room_id: String, +) -> Result, GroupChatError> { + let store = group_chat_store(&workspace_path) + .await + .map_err(|message| GroupChatError { + code: GroupChatErrorCode::NotFound, + message, + })?; + store + .list_members(&room_id) + .await + .map_err(|error| GroupChatError { + code: group_chat_store_error_code(&error), + message: error.to_string(), + }) +} + +#[tauri::command] +pub async fn group_chat_create( + workspace_path: String, + name: String, + owner: GroupChatActor, + members: Vec, + mode: Option, +) -> Result { + let coordinator = require_coordinator()?; + GroupChatTool::create_room_impl( + &coordinator, + &workspace_path, + &name, + owner, + &members, + mode.unwrap_or(GroupChatMode::Free), + ) + .await + .map_err(group_chat_command_error) +} + +#[tauri::command] +pub async fn group_chat_join( + workspace_path: String, + room_id: String, + session_id: String, + actor: GroupChatActor, +) -> Result { + let coordinator = require_coordinator()?; + GroupChatTool::join_room_impl(&coordinator, &workspace_path, &room_id, &session_id, actor) + .await + .map_err(group_chat_command_error) +} + +#[tauri::command] +pub async fn group_chat_leave( + workspace_path: String, + room_id: String, + session_id: String, + actor: GroupChatActor, +) -> Result { + let coordinator = require_coordinator()?; + GroupChatTool::leave_room_impl(&coordinator, &workspace_path, &room_id, &session_id, actor) + .await + .map_err(group_chat_command_error) +} + +#[tauri::command] +pub async fn group_chat_delete( + workspace_path: String, + room_id: String, + actor: GroupChatActor, +) -> Result<(), GroupChatError> { + let coordinator = require_coordinator()?; + GroupChatTool::delete_room_impl(&coordinator, &workspace_path, &room_id, actor) + .await + .map_err(group_chat_command_error) +} + +#[tauri::command] +pub async fn group_chat_set_mode( + workspace_path: String, + room_id: String, + mode: GroupChatMode, + actor: GroupChatActor, +) -> Result { + GroupChatTool::set_mode_impl(&workspace_path, &room_id, mode, actor) + .await + .map_err(group_chat_command_error) +} + +#[tauri::command] +pub async fn group_chat_send( + workspace_path: String, + room_id: String, + author: GroupChatActor, + content: String, + mention_targets: Vec, + urgent: bool, +) -> Result { + let coordinator = require_coordinator()?; + let (message_id, delivered_to, failed_to) = GroupChatTool::send_message_impl( + &coordinator, + &workspace_path, + &room_id, + &author, + &content, + &mention_targets, + urgent, + ) + .await + .map_err(group_chat_command_error)?; + let failed_to = failed_to + .into_iter() + .filter_map(|value| serde_json::from_value(value).ok()) + .collect(); + Ok(GroupChatSendResult { + message_id, + delivered_to, + failed_to, + }) +} + +#[tauri::command] +pub async fn group_chat_messages( + workspace_path: String, + room_id: String, + limit: Option, + cursor: Option, +) -> Result { + let store = group_chat_store(&workspace_path) + .await + .map_err(|message| GroupChatError { + code: GroupChatErrorCode::NotFound, + message, + })?; + let window = store + .list_messages(&room_id, limit, cursor) + .await + .map_err(|error| GroupChatError { + code: group_chat_store_error_code(&error), + message: error.to_string(), + })?; + Ok(GroupChatMessagesResponse { + messages: window.messages, + next_cursor: window.next_cursor.map(|index| index.to_string()), + }) +} + +#[tauri::command] +pub async fn group_chat_ingest_reply( + workspace_path: String, + room_id: String, + message_id: String, + reply_content: String, + author: GroupChatActor, + timestamp: i64, +) -> Result<(), GroupChatError> { + // P0-3/P2-1: mark the original message Replied AND persist the reply body + // into the room stream so the group shows the reply text. + let store = group_chat_store(&workspace_path) + .await + .map_err(|message| GroupChatError { + code: GroupChatErrorCode::NotFound, + message, + })?; + store + .update_message_status( + &room_id, + &message_id, + bitfun_runtime_ports::GroupChatMessageStatus::Replied, + ) + .await + .map_err(|error| GroupChatError { + code: group_chat_store_error_code(&error), + message: error.to_string(), + })?; + if !reply_content.trim().is_empty() { + let reply = GroupChatMessage { + message_id: format!("msg-reply-{message_id}-{timestamp}"), + room_id: room_id.clone(), + author, + kind: bitfun_runtime_ports::GroupChatMessageKind::Agent, + content: reply_content, + mention_targets: Vec::new(), + reply_to_message_id: Some(message_id), + timestamp, + status: bitfun_runtime_ports::GroupChatMessageStatus::Delivered, + }; + store + .append_message(&room_id, &reply) + .await + .map_err(|error| GroupChatError { + code: group_chat_store_error_code(&error), + message: error.to_string(), + })?; + } + Ok(()) +} + +/// Maps a store error to the closest contract error code (P1-5). +fn group_chat_store_error_code( + error: &bitfun_core::service::session::GroupChatStoreError, +) -> GroupChatErrorCode { + use bitfun_core::service::session::GroupChatStoreError; + match error { + GroupChatStoreError::RoomNotFound(_) => GroupChatErrorCode::NotFound, + GroupChatStoreError::MessageNotFound(_) => GroupChatErrorCode::NotFound, + _ => GroupChatErrorCode::NotFound, + } +} + +/// 超时提醒消费端(P2-3/P2-4):只扫描 `room_id`(传入时)或全表(None), +/// 消费 group_chat.reply_timeout_secs(R-GC-26),返回超时提醒列表。 +/// room_id 参数让每个 Pane 只扫自己的房间,避免 N 个 Pane = N 倍全表 IO。 +#[tauri::command] +pub async fn group_chat_scan_timeouts( + workspace_path: String, + reply_timeout_secs: u64, + room_id: Option, +) -> Result, String> { + let store = group_chat_store(&workspace_path).await?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + let mut reminders = Vec::new(); + let rooms = match &room_id { + Some(room_id) => { + let room = store.load_room(room_id).await.map_err(|e| e.to_string())?; + vec![room] + } + None => { + let (rooms, _) = store.list_rooms().await.map_err(|e| e.to_string())?; + rooms + } + }; + for room in rooms { + let timed_out = store + .scan_timed_out_messages(&room.room_id, reply_timeout_secs, now) + .await + .map_err(|error| error.to_string())?; + for message in timed_out { + reminders.push(serde_json::json!({ + "roomId": room.room_id, + "messageId": message.message_id, + "content": message.content, + "status": "failed", + })); + } + } + Ok(reminders) +} diff --git a/src/apps/desktop/src/api/ssh_api.rs b/src/apps/desktop/src/api/ssh_api.rs index 2d72ed00e..779bf3820 100644 --- a/src/apps/desktop/src/api/ssh_api.rs +++ b/src/apps/desktop/src/api/ssh_api.rs @@ -540,8 +540,7 @@ fn validate_remote_name_for_local_download(name: &str) -> Result<(), String> { fn local_download_name_key(name: &str) -> String { #[cfg(any(windows, target_os = "macos"))] { - name.trim_end_matches(['.', ' ']) - .to_lowercase() + name.trim_end_matches(['.', ' ']).to_lowercase() } #[cfg(not(any(windows, target_os = "macos")))] { diff --git a/src/apps/desktop/src/computer_use/desktop_host/mod.rs b/src/apps/desktop/src/computer_use/desktop_host/mod.rs index b8bb4ae68..a188abcf5 100644 --- a/src/apps/desktop/src/computer_use/desktop_host/mod.rs +++ b/src/apps/desktop/src/computer_use/desktop_host/mod.rs @@ -36,6 +36,86 @@ const STALE_CAPTURE_TOOL_MESSAGE: &str = "Computer use refused: call **`screensh static SCREENSHOT_ID_COUNTER: AtomicU64 = AtomicU64::new(1); +/// How long `open_app` waits for a freshly activated app to show up in +/// LaunchServices before giving up on resolving its pid. +#[cfg(target_os = "macos")] +const OPEN_APP_SETTLE_MS: u64 = 3_000; +/// How long `open_app` waits for the app to put a window on screen. Cold +/// Electron launches routinely need several seconds; reporting `window_count: +/// 0` too early would send the agent down a false "app is broken" path. +#[cfg(target_os = "macos")] +const OPEN_APP_WINDOW_WAIT_MS: u64 = 8_000; +#[cfg(target_os = "macos")] +const OPEN_APP_POLL_INTERVAL_MS: u64 = 150; + +/// How long an `open_app` AppleScript may run before it is killed. +/// +/// `activate` sends an AppleEvent to the target app and waits for it to answer. +/// A hung or busy app simply does not answer, and macOS's default AppleEvent +/// timeout is **120 seconds** — during which `open_app` occupies a blocking +/// thread and the agent has no idea anything is wrong. An app that has not +/// acknowledged activation in a few seconds is not going to. +#[cfg(target_os = "macos")] +const OSASCRIPT_TIMEOUT_MS: u64 = 10_000; + +/// Run `osascript -e