diff --git a/Cargo.lock b/Cargo.lock index e81ee0d..7f4c94c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1710,6 +1710,7 @@ dependencies = [ "floatile-store", "floatile-ui-schema", "parking_lot", + "serde", "serde_json", "thiserror 2.0.20", "tokio", diff --git a/conformance/sdk-lifecycle-v1.json b/conformance/sdk-lifecycle-v1.json new file mode 100644 index 0000000..ead4fb8 --- /dev/null +++ b/conformance/sdk-lifecycle-v1.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "engineApiVersion": "1.2.0", + "vectors": [ + { + "id": "start-rejected", + "callback": "start", + "guestError": "rejected", + "message": "conformance start rejection", + "expectedHostOutcome": "rejected" + }, + { + "id": "event-invalid-input", + "callback": "event", + "guestError": "invalid-input", + "message": "conformance event rejection", + "expectedHostOutcome": "rejected" + }, + { + "id": "event-internal", + "callback": "event", + "guestError": "internal", + "message": null, + "expectedHostOutcome": "rejected" + } + ] +} diff --git a/crates/floatile-cli/src/conformance.rs b/crates/floatile-cli/src/conformance.rs new file mode 100644 index 0000000..a1d266e --- /dev/null +++ b/crates/floatile-cli/src/conformance.rs @@ -0,0 +1,127 @@ +//! Versioned SDK conformance kit exposed to language adapters and CI. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::OUTPUT_SCHEMA_VERSION; + +pub const LIFECYCLE_SUITE_JSON: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/sdk-lifecycle-v1.json" +)); + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LifecycleSuite { + pub schema_version: u32, + pub engine_api_version: String, + pub vectors: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LifecycleVector { + pub id: String, + pub callback: String, + pub guest_error: String, + pub message: Option, + pub expected_host_outcome: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConformanceReport { + pub schema_version: u32, + pub status: &'static str, + pub suite: &'static str, + pub contract: LifecycleSuite, + pub warnings: Vec, +} + +#[derive(Debug, Error)] +pub enum ConformanceError { + #[error("内置 conformance JSON 无效")] + InvalidJson, + #[error("不支持的 conformance schemaVersion: {0}")] + UnsupportedSchema(u32), + #[error("conformance 向量无效: {0}")] + InvalidVector(String), +} + +impl ConformanceError { + pub fn code(&self) -> &'static str { + match self { + Self::InvalidJson => "FCONF_JSON", + Self::UnsupportedSchema(_) => "FCONF_SCHEMA_VERSION", + Self::InvalidVector(_) => "FCONF_VECTOR", + } + } +} + +pub fn lifecycle_report() -> Result { + let contract: LifecycleSuite = + serde_json::from_str(LIFECYCLE_SUITE_JSON).map_err(|_| ConformanceError::InvalidJson)?; + validate(&contract)?; + Ok(ConformanceReport { + schema_version: OUTPUT_SCHEMA_VERSION, + status: "ok", + suite: "sdk-lifecycle-v1", + contract, + warnings: Vec::new(), + }) +} + +fn validate(suite: &LifecycleSuite) -> Result<(), ConformanceError> { + if suite.schema_version != 1 { + return Err(ConformanceError::UnsupportedSchema(suite.schema_version)); + } + let mut ids = BTreeSet::new(); + for vector in &suite.vectors { + if !ids.insert(vector.id.as_str()) { + return Err(ConformanceError::InvalidVector("vector id 重复".to_owned())); + } + if !matches!(vector.callback.as_str(), "start" | "event") { + return Err(ConformanceError::InvalidVector( + "callback 不受支持".to_owned(), + )); + } + if !matches!( + vector.guest_error.as_str(), + "invalid-input" | "rejected" | "internal" + ) { + return Err(ConformanceError::InvalidVector( + "guestError 不受支持".to_owned(), + )); + } + if vector.expected_host_outcome != "rejected" { + return Err(ConformanceError::InvalidVector( + "expectedHostOutcome 不受支持".to_owned(), + )); + } + let requires_message = vector.guest_error != "internal"; + if requires_message != vector.message.is_some() { + return Err(ConformanceError::InvalidVector( + "message 与 guestError 不匹配".to_owned(), + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_lifecycle_suite_is_valid_and_versioned() -> Result<(), ConformanceError> { + let report = lifecycle_report()?; + assert_eq!(report.schema_version, OUTPUT_SCHEMA_VERSION); + assert_eq!(report.suite, "sdk-lifecycle-v1"); + assert_eq!(report.contract.schema_version, 1); + assert_eq!(report.contract.engine_api_version, "1.2.0"); + assert_eq!(report.contract.vectors.len(), 3); + Ok(()) + } +} diff --git a/crates/floatile-cli/src/lib.rs b/crates/floatile-cli/src/lib.rs index def48f5..b8ff529 100644 --- a/crates/floatile-cli/src/lib.rs +++ b/crates/floatile-cli/src/lib.rs @@ -5,6 +5,7 @@ pub mod build; pub mod check; +pub mod conformance; pub mod dev; pub mod inspect; pub mod install; @@ -18,6 +19,7 @@ pub mod test; pub use build::{BuildError, build_project, package}; pub use check::{CheckError, CheckPhases, CheckReport, CheckWarning, check_project}; +pub use conformance::{ConformanceError, ConformanceReport, lifecycle_report}; pub use dev::{BuildStatus, build_once, dev_loop, ensure_project}; pub use inspect::{InspectError, InspectReport, inspect_package, inspect_package_bytes}; pub use install::{InstallError, InstalledPackage, install_dir, install_package}; diff --git a/crates/floatile-cli/src/main.rs b/crates/floatile-cli/src/main.rs index db56b9e..e755e77 100644 --- a/crates/floatile-cli/src/main.rs +++ b/crates/floatile-cli/src/main.rs @@ -5,8 +5,8 @@ use std::process::ExitCode; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use floatile_cli::{ - CommandErrorReport, build, check, dev, inspect, install, instance, package, preview, project, - run, test, + CommandErrorReport, build, check, conformance, dev, inspect, install, instance, package, + preview, project, run, test, }; use floatile_core::{InstanceConfig, InstanceDesiredState, InstanceId}; @@ -14,7 +14,7 @@ fn main() -> ExitCode { let args: Vec = std::env::args().collect(); if args.len() < 2 { eprintln!( - "用法: floatile [参数]" + "用法: floatile [参数]" ); return ExitCode::from(2); } @@ -31,6 +31,7 @@ fn main() -> ExitCode { "preview" => cmd_preview(&args[2..]), "run" => cmd_run(&args[2..]), "schema" => cmd_schema(&args[2..]), + "conformance" => cmd_conformance(&args[2..]), other => { eprintln!("未知命令: {other}"); ExitCode::from(2) @@ -38,6 +39,38 @@ fn main() -> ExitCode { } } +fn cmd_conformance(args: &[String]) -> ExitCode { + let json = args.iter().any(|argument| argument == "--json"); + if let Err(detail) = author_positionals(args, &[], &[], 0) { + return render_basic_error("FCONF_ARGUMENT", &detail, json, true); + } + match conformance::lifecycle_report() { + Ok(report) => { + if json { + println!("{}", serialize_json(&report)); + } else { + println!( + "conformance: PASS suite={} engine={} vectors={}", + report.suite, + report.contract.engine_api_version, + report.contract.vectors.len() + ); + for vector in report.contract.vectors { + println!( + " {} callback={} guest-error={} host={}", + vector.id, + vector.callback, + vector.guest_error, + vector.expected_host_outcome + ); + } + } + ExitCode::SUCCESS + } + Err(error) => render_basic_error(error.code(), &error.to_string(), json, false), + } +} + fn cmd_check(args: &[String]) -> ExitCode { let json = args.iter().any(|argument| argument == "--json"); let deny_warnings = args.iter().any(|argument| argument == "--deny-warnings"); diff --git a/crates/floatile-cli/src/project.rs b/crates/floatile-cli/src/project.rs index 06b4725..f5e59fb 100644 --- a/crates/floatile-cli/src/project.rs +++ b/crates/floatile-cli/src/project.rs @@ -260,7 +260,7 @@ floatile-sdk = "0.1" serde = { version = "1", features = ["derive"] } "# .to_owned(); - let lib_rs = r#"use floatile_sdk::{Context, LogLevel, State, Widget, WidgetEvent, view}; + let lib_rs = r#"use floatile_sdk::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, State)] @@ -279,12 +279,14 @@ impl Widget for MyWidget { view::column(vec![view::text_bind("$.message")]) } - fn start(&mut self, ctx: &mut Context) { + fn start(&mut self, ctx: &mut Context) -> WidgetResult { let _ = ctx.timer().schedule(1000); + Ok(()) } - fn event(&mut self, _event: WidgetEvent, ctx: &mut Context) { + fn event(&mut self, _event: WidgetEvent, ctx: &mut Context) -> WidgetResult { let _ = ctx.log(LogLevel::Info, "event received"); + Ok(()) } } diff --git a/crates/floatile-cli/tests/conformance_cmd.rs b/crates/floatile-cli/tests/conformance_cmd.rs new file mode 100644 index 0000000..5dfcd4d --- /dev/null +++ b/crates/floatile-cli/tests/conformance_cmd.rs @@ -0,0 +1,37 @@ +#![allow(clippy::unwrap_used)] + +use std::process::Command; + +#[test] +fn conformance_command_exposes_the_versioned_lifecycle_suite() { + let output = Command::new(env!("CARGO_BIN_EXE_floatile")) + .args([ + "conformance", + "--json", + "--no-interactive", + "--deny-warnings", + ]) + .output() + .unwrap(); + assert!(output.status.success(), "stderr: {:?}", output.stderr); + assert!(output.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["schemaVersion"], 1); + assert_eq!(report["status"], "ok"); + assert_eq!(report["suite"], "sdk-lifecycle-v1"); + assert_eq!(report["contract"]["engineApiVersion"], "1.2.0"); + assert_eq!(report["contract"]["vectors"].as_array().unwrap().len(), 3); + assert_eq!(report["warnings"], serde_json::json!([])); +} + +#[test] +fn conformance_command_rejects_unknown_arguments() { + let output = Command::new(env!("CARGO_BIN_EXE_floatile")) + .args(["conformance", "unexpected", "--json"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(report["code"], "FCONF_ARGUMENT"); +} diff --git a/crates/floatile-runtime/Cargo.toml b/crates/floatile-runtime/Cargo.toml index 7c10429..2b4a43f 100644 --- a/crates/floatile-runtime/Cargo.toml +++ b/crates/floatile-runtime/Cargo.toml @@ -26,3 +26,4 @@ wasmtime-wasi = { workspace = true } [dev-dependencies] floatile-platform = { workspace = true } floatile-store = { workspace = true } +serde = { workspace = true } diff --git a/crates/floatile-runtime/tests/security.rs b/crates/floatile-runtime/tests/security.rs index 66b12b1..e28e801 100644 --- a/crates/floatile-runtime/tests/security.rs +++ b/crates/floatile-runtime/tests/security.rs @@ -28,6 +28,31 @@ use floatile_store::{AuditRecord, Store}; use floatile_ui_schema::schema::JsonSchema; use serde_json::{Value, json}; +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct LifecycleSuite { + schema_version: u64, + engine_api_version: String, + vectors: Vec, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct LifecycleVector { + id: String, + callback: String, + message: Option, + expected_host_outcome: String, +} + +fn lifecycle_suite() -> LifecycleSuite { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/sdk-lifecycle-v1.json" + ))) + .expect("lifecycle conformance vectors must parse") +} + fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") @@ -175,6 +200,53 @@ async fn denied_capability_persists_audit_and_host_survives() { handle2.shutdown().await.expect("新实例 shutdown 正常"); } +/// Rust SDK lifecycle errors must cross WIT as business rejections instead of +/// being swallowed or misclassified as traps. +#[tokio::test(flavor = "multi_thread")] +async fn sdk_lifecycle_errors_reach_the_host_as_rejections() { + let manager = WidgetManager::new().unwrap(); + let suite = lifecycle_suite(); + assert_eq!(suite.schema_version, 1); + assert_eq!( + suite.engine_api_version, + floatile_plugin_api::ENGINE_API_VERSION + ); + for (index, vector) in suite.vectors.iter().enumerate() { + let instance = 81 + u64::try_from(index).expect("bounded vector index"); + let handle = spawn_evil( + &manager, + instance, + evil_grants_none(instance), + json!({"mode": format!("conformance-{}", vector.id)}), + ); + let result = if vector.callback == "start" { + handle.start().await + } else { + handle.start().await.expect("event vector should start"); + handle + .handle_event(WidgetEvent::Ui(UiEvent { + name: "trigger".into(), + payload_json: "{}".into(), + })) + .await + }; + assert_eq!(vector.expected_host_outcome, "rejected"); + assert!( + matches!(result, Err(floatile_runtime::InstanceError::Rejected(ref message)) + if vector.message.as_ref().is_none_or(|expected| message.contains(expected))), + "conformance vector {} should remain a guest rejection, got {result:?}", + vector.id + ); + } + + let survivor = spawn_evil(&manager, 89, evil_grants_none(89), json!({"mode": "deny"})); + survivor + .start() + .await + .expect("host should survive guest rejection"); + survivor.shutdown().await.expect("survivor should stop"); +} + /// 正式 WIT Operation:guest submit → metadata completion → typed one-shot take → State 更新。 #[tokio::test(flavor = "multi_thread")] async fn storage_operation_round_trips_through_guest_contract() { diff --git a/crates/floatile-sdk/src/build.rs b/crates/floatile-sdk/src/build.rs index 15efa4d..0284440 100644 --- a/crates/floatile-sdk/src/build.rs +++ b/crates/floatile-sdk/src/build.rs @@ -46,8 +46,12 @@ mod tests { fn view(_: &S) -> crate::view::View { crate::view::column(vec![crate::view::text_bind("$.time")]) } - fn start(&mut self, _: &mut Context) {} - fn event(&mut self, _: WidgetEvent, _: &mut Context) {} + fn start(&mut self, _: &mut Context) -> crate::WidgetResult { + Ok(()) + } + fn event(&mut self, _: WidgetEvent, _: &mut Context) -> crate::WidgetResult { + Ok(()) + } } impl Default for W { fn default() -> Self { @@ -62,8 +66,12 @@ mod tests { fn view(_: &S) -> crate::view::View { crate::view::button("Go") } - fn start(&mut self, _: &mut Context) {} - fn event(&mut self, _: WidgetEvent, _: &mut Context) {} + fn start(&mut self, _: &mut Context) -> crate::WidgetResult { + Ok(()) + } + fn event(&mut self, _: WidgetEvent, _: &mut Context) -> crate::WidgetResult { + Ok(()) + } } impl Default for WButton { fn default() -> Self { diff --git a/crates/floatile-sdk/src/export.rs b/crates/floatile-sdk/src/export.rs index f6ac12b..9a4ec9a 100644 --- a/crates/floatile-sdk/src/export.rs +++ b/crates/floatile-sdk/src/export.rs @@ -52,8 +52,7 @@ macro_rules! impl_export_widget { ); } let mut ctx = $crate::Context::new(); - self.widget.borrow_mut().start(&mut ctx); - Ok(()) + self.widget.borrow_mut().start(&mut ctx) } fn handle_event( @@ -69,7 +68,7 @@ macro_rules! impl_export_widget { <<$widget as $crate::Widget>::Event as $crate::FromWidgetEvent>::from_widget_event(event) { let mut ctx = $crate::Context::new(); - self.widget.borrow_mut().event(ev, &mut ctx); + self.widget.borrow_mut().event(ev, &mut ctx)?; } Ok(()) } diff --git a/crates/floatile-sdk/src/lib.rs b/crates/floatile-sdk/src/lib.rs index 7c57853..316f15d 100644 --- a/crates/floatile-sdk/src/lib.rs +++ b/crates/floatile-sdk/src/lib.rs @@ -53,9 +53,24 @@ pub mod state; pub mod view; pub mod widget; +/// Stable author-facing imports for Rust widget projects. +/// +/// Raw generated WIT modules remain available at the crate root for adapter +/// and conformance work, while normal plugins should import this prelude. +pub mod prelude { + pub use crate::view; + pub use crate::view::View; + pub use crate::{ + Context, FromWidgetEvent, LogLevel, OperationCapability, OperationCompletion, + OperationTerminal, State, UiEvent, Widget, WidgetError, WidgetEvent, WidgetResult, + impl_export_widget, + }; +} + pub use context::Context; pub use floatile_sdk_macros::State; pub use floatile_ui_schema::{JsonSchema, merge_patch, validate_document}; pub use state::State; pub use widget::FromWidgetEvent; pub use widget::Widget; +pub use widget::WidgetResult; diff --git a/crates/floatile-sdk/src/widget.rs b/crates/floatile-sdk/src/widget.rs index ed88db6..934a76e 100644 --- a/crates/floatile-sdk/src/widget.rs +++ b/crates/floatile-sdk/src/widget.rs @@ -4,7 +4,13 @@ //! 导出为 WASM Component。所有 host 能力调用经 `Context` → WIT → Broker 路径。 use crate::view::View; -use crate::{Context, WidgetEvent}; +use crate::{Context, WidgetError, WidgetEvent}; + +/// Result returned by fallible widget lifecycle callbacks. +/// +/// The error crosses the versioned WIT `widget-error` contract, allowing the +/// host to distinguish a guest rejection from traps and resource limits. +pub type WidgetResult = Result<(), WidgetError>; /// 从宿主级 [`WidgetEvent`] 转换为作者定义的事件类型。 /// @@ -36,8 +42,8 @@ pub trait Widget: Sized + Default { type Event: FromWidgetEvent; fn view(state: &Self::State) -> View; fn init(&mut self, _initial: &Self::State) {} - fn start(&mut self, ctx: &mut Context); - fn event(&mut self, event: Self::Event, ctx: &mut Context); + fn start(&mut self, ctx: &mut Context) -> WidgetResult; + fn event(&mut self, event: Self::Event, ctx: &mut Context) -> WidgetResult; fn stop(&mut self) {} } diff --git a/crates/floatile-sdk/tests/conformance.rs b/crates/floatile-sdk/tests/conformance.rs new file mode 100644 index 0000000..a762cc7 --- /dev/null +++ b/crates/floatile-sdk/tests/conformance.rs @@ -0,0 +1,76 @@ +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::collections::BTreeSet; + +use floatile_sdk::{ENGINE_API_VERSION, WidgetError}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Suite { + schema_version: u64, + engine_api_version: String, + vectors: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Vector { + id: String, + callback: String, + guest_error: String, + message: Option, + expected_host_outcome: String, +} + +fn suite() -> Suite { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/sdk-lifecycle-v1.json" + ))) + .expect("SDK lifecycle conformance vectors must be valid JSON") +} + +fn widget_error(vector: &Vector) -> WidgetError { + match vector.guest_error.as_str() { + "invalid-input" => WidgetError::InvalidInput( + vector + .message + .clone() + .expect("invalid-input vector requires a message"), + ), + "rejected" => WidgetError::Rejected( + vector + .message + .clone() + .expect("rejected vector requires a message"), + ), + "internal" => WidgetError::Internal, + other => panic!("unknown guest error in conformance vector: {other}"), + } +} + +#[test] +fn lifecycle_vectors_match_the_generated_wit_contract() { + let suite = suite(); + assert_eq!(suite.schema_version, 1); + assert_eq!(suite.engine_api_version, ENGINE_API_VERSION); + + let mut ids = BTreeSet::new(); + for vector in &suite.vectors { + assert!(ids.insert(&vector.id), "duplicate vector id: {}", vector.id); + assert!(matches!(vector.callback.as_str(), "start" | "event")); + assert_eq!(vector.expected_host_outcome, "rejected"); + let _ = widget_error(vector); + } + + assert_eq!( + suite + .vectors + .iter() + .map(|vector| vector.guest_error.as_str()) + .collect::>(), + BTreeSet::from(["internal", "invalid-input", "rejected"]), + "vectors must cover every WIT widget-error variant" + ); +} diff --git a/crates/floatile-sdk/tests/public_api.rs b/crates/floatile-sdk/tests/public_api.rs new file mode 100644 index 0000000..df84426 --- /dev/null +++ b/crates/floatile-sdk/tests/public_api.rs @@ -0,0 +1,41 @@ +#![allow(dead_code)] + +use floatile_sdk::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, State)] +struct PublicState { + message: String, +} + +#[derive(Default)] +struct PublicWidget; + +impl Widget for PublicWidget { + type State = PublicState; + type Event = WidgetEvent; + + fn view(_state: &Self::State) -> View { + view::column(vec![view::text_bind("$.message")]) + } + + fn start(&mut self, _ctx: &mut Context) -> WidgetResult { + Ok(()) + } + + fn event(&mut self, _event: Self::Event, _ctx: &mut Context) -> WidgetResult { + Ok(()) + } +} + +#[test] +fn prelude_exposes_the_complete_author_contract() { + let state = PublicState::initial(); + assert_eq!(state.message, ""); + let _ = PublicWidget::view(&state); + let _ = WidgetError::Rejected("expected".into()); + let _ = WidgetEvent::Ui(UiEvent { + name: "refresh".into(), + payload_json: "{}".into(), + }); +} diff --git a/docs/README.md b/docs/README.md index 0504572..263e37e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ | 权限与网络安全 | `security/permission-model.md`、`security/http-broker.md` | 能力、scope、配额、脱敏或网络策略变化 | | 插件系统总体架构 | `plugin-sdk/plugin-system-architecture.md` | 数据流、生命周期、实例隔离、语言/runtime 或版本轴变化 | | 插件 SDK 与开发体验 | `plugin-sdk/sdk-developer-experience.md` | Rust/TypeScript API、组件、CLI、诊断、测试或 Agent 契约变化 | +| SDK 一致性向量 | `plugin-sdk/conformance-kit.md`、`../conformance/` | SDK lifecycle、错误、行为或跨语言 conformance 格式变化 | | 插件契约 | `plugin-sdk/manifest-v1.md`、`plugin-sdk/wit-api-v1.md`、`plugin-sdk/ui-ir-v1.md` | manifest/WIT/UI IR/包格式变化 | | 平台事实 | `platform-matrix/platform-matrix.md` | 获得新的实测证据 | | 工程规则 | `development/` | 本地流程、CI、代码或测试规范变化 | diff --git a/docs/plugin-sdk/conformance-kit.md b/docs/plugin-sdk/conformance-kit.md new file mode 100644 index 0000000..235eef6 --- /dev/null +++ b/docs/plugin-sdk/conformance-kit.md @@ -0,0 +1,31 @@ +# SDK Conformance Kit + +> 状态:Implemented(Rust SDK 与 host runtime 自动化验证;TypeScript 尚未实现) +> 范围:PP-M7、FR-PLUGIN-01、F11、NFR-MAINT-01 + +`conformance/` 保存语言无关、版本化的 JSON 向量。Rust SDK、未来 TypeScript SDK 与宿主 runtime +必须消费同一文件;语言专用测试只能负责把向量映射到本语言 API,不得复制或改写预期语义。 + +## 生命周期向量 + +`sdk-lifecycle-v1.json` 固定以下字段: + +- `schemaVersion`:向量文件格式;不识别的版本必须拒绝; +- `engineApiVersion`:必须与根 WIT、host bindings 和 guest SDK 一致; +- `id`:跨语言稳定用例名,不得重复; +- `callback`:`start` 或 `event`; +- `guestError`:WIT `widget-error` 的 kebab-case variant; +- `message`:该 variant 携带的稳定测试 payload,`internal` 为 `null`; +- `expectedHostOutcome`:宿主错误分类,不是语言专用异常名。 + +当前向量覆盖 `invalid-input`、`rejected` 与 `internal` 全部 WIT guest error。runtime 使用真实 +WASM Component 执行同一批向量,必须把它们分类为 guest `Rejected`,并证明随后仍可启动和停止同行 +实例。trap、fuel、epoch timeout、内存、Broker deny、Operation 和 State Patch 的既有安全测试将在后续 +PP-M7 切片逐步登记为同目录的版本化向量。 + +新增语言 SDK 时,第一步必须解析这些文件并拒绝未知 `schemaVersion`、callback、error 或 outcome; +不能用“等价的本地测试”代替共享向量。 + +`floatile conformance --json --no-interactive` 会校验 CLI 内嵌的当前 suite 并输出稳定的 schema v1 +报告;language adapter、CI 与 Agent 应消费该命令,而不是依赖仓库路径。`--deny-warnings` 与其他作者 +命令语义一致;当前 suite 没有 warning。 diff --git a/docs/plugin-sdk/sdk-developer-experience.md b/docs/plugin-sdk/sdk-developer-experience.md index d34be93..ff0cc07 100644 --- a/docs/plugin-sdk/sdk-developer-experience.md +++ b/docs/plugin-sdk/sdk-developer-experience.md @@ -87,11 +87,11 @@ impl Clock { .gap(8) } - fn start(ctx: &mut Context) -> Result<()> { + fn start(ctx: &mut Context) -> WidgetResult { ctx.timer().every("1s", Event::Refresh) } - async fn event(event: Event, ctx: &mut Context) -> Result<()> { + fn event(event: Event, ctx: &mut Context) -> WidgetResult { if matches!(event, Event::Refresh) { let time = ctx.clock().local_time(); ctx.state().update(|state| state.time = time)?; @@ -104,6 +104,12 @@ impl Clock { 公开 API 可以随原型调整,但以下语义必须一致:组件名、State 字段、event 名、Context capability、 错误码、默认预算和生命周期顺序。 +Rust SDK 的 `Widget::start` 与 `Widget::event` 返回 `WidgetResult`。`WidgetError` 会原样穿过 WIT +`widget-error` 交给宿主,宿主将其分类为 guest 业务拒绝;SDK 不得吞掉错误,也不得把它伪装成 trap、 +fuel、超时或内存错误。 +插件项目必须优先使用 `floatile_sdk::prelude::*`;crate 根的生成 WIT 模块主要供 adapter 与一致性测试 +使用,不作为普通作者需要理解的入口。prelude 的移除或不兼容改名按 SDK major 变更处理。 + ## 3. 项目模板 ### TypeScript @@ -205,6 +211,7 @@ Detected capabilities: | `floatile build` | 可复现地产生 `.floatile`,默认不签名 | | `floatile inspect` | 显示 manifest、版本轴、权限、预算、entry digest | | `floatile migrate` | SDK/UI/manifest 兼容迁移;默认先 dry-run | +| `floatile conformance` | 校验并输出语言无关的 SDK contract vectors | | `floatile instance create/list/get/configure/start/stop/delete` | 按精确安装版本管理持久实例与 desired state | 所有命令必须支持: @@ -285,6 +292,9 @@ TypeScript 提供同名语义。测试默认不启动窗口、不访问真实 SQ 测试由 `floatile preview` 在固定 renderer、字体、DPI 和 theme 下输出 screenshot + 可访问 UI tree; 平台窗口行为仍需真实平台验收,不能用快照替代。 +跨语言 lifecycle 与错误分类使用仓库根 `conformance/` 的版本化 JSON 向量,格式与覆盖状态见 +[`conformance-kit.md`](conformance-kit.md)。Rust/TypeScript 测试不得分别维护同名用例的预期结果。 + 实现状态(P0):Rust 侧 `WidgetHarness` 已在 `floatile-runtime::harness` 落地—— `grant/start/emit_ui/wait_for_state(谓词断言)/advance_time/audit/assert_audit`,所有宿主能力仍走生产 deny-by-default Broker;`floatile test` 用它对已构建 `.floatile` 跑无头生命周期冒烟(build→提取→ diff --git a/docs/product/plugin-platform-roadmap.md b/docs/product/plugin-platform-roadmap.md index 2711105..fadabe1 100644 --- a/docs/product/plugin-platform-roadmap.md +++ b/docs/product/plugin-platform-roadmap.md @@ -173,7 +173,7 @@ exit code。生成项目必须只依赖可获得的 SDK,并能从干净目录 | PP-M4 | Rust 作者闭环 | 已完成(自动化契约与 Xvfb 验证;公开发布受许可门阻断) | 可发布方式待许可决定的 SDK 解析、生成模板修复、dev/test/preview/build/install/run/inspect | 干净目录中的示例插件无需仓库私有路径即可完成全流程;JSON 契约有测试 | SDK、CLI、runtime、shell、docs | | PP-M5 | 外部数据平台 | 已完成(自动化契约验证) | Connection、Credential Vault、HTTPS Broker、调度、缓存、重试、限流和连接健康状态 | AI 余额参考插件只使用通用能力,且 secret 不进入 guest、日志、State 或包 | `core`、`store`、`services`、shell、WIT、SDK | | PP-M6 | UI 平台 | 已完成(自动化契约验证) | loading/empty/error、列表/网格、badge、progress、sparkline/chart、主题与响应式布局 | 参考插件无需第三方 Slint/HTML 即可表达监控型 UI;预算和无障碍语义有契约 | UI schema、renderer、SDK、shell | -| PP-M7 | SDK 与语言生态 | 规划中 | Rust API 稳定化;在工具链可行后接入 TypeScript;生成文档、迁移指南和 conformance kit | 双语言通过相同 contract vectors、恶意输入和端到端示例;不存在宿主语义分叉 | SDK、plugin API、CLI、CI | +| PP-M7 | SDK 与语言生态 | 进行中 | Rust API 稳定化;在工具链可行后接入 TypeScript;生成文档、迁移指南和 conformance kit | 双语言通过相同 contract vectors、恶意输入和端到端示例;不存在宿主语义分叉 | SDK、plugin API、CLI、CI | | PP-M8 | 分发与信任 | 规划中 | publisher/signing、来源与信任、权限 diff、兼容性解析、更新/回滚和可恢复安装 | 安装与升级能解释权限变化和失败原因;篡改/降级/回滚路径有测试 | CLI、core、store、shell、distribution | | PP-M9 | 组合与自动化 | 规划中 | 宿主事件、定时/系统触发、受控 pub/sub、工作流和通知 | 插件间不直接持有句柄;事件有 schema、scope、背压、循环检测和审计 | core、runtime、services、WIT、SDK | | PP-M10 | 产品化与发布门禁 | 规划中 | 跨平台交互证据、性能、许可、安装器、更新器、无障碍、崩溃恢复和运维诊断 | 产品目标平台通过发布矩阵;许可 ADR 和分发门禁完成;关键 SLO 有实测证据 | 全仓库 | @@ -218,6 +218,11 @@ Config Schema 表单、observed 状态和手动 retry;Linux X11/Xvfb 已自动 `widget.ftui`,证明参考插件覆盖上述监控 UI 契约且包内不含第三方 `.slint`/HTML;schema、renderer、 SDK 与 shell 的契约/真实 Slint 无头编译测试均通过。真实屏幕阅读器行为、跨平台视觉与完整无障碍仍属 PP-M10 环境验收,不把它们误记为 PP-M6 自动化契约证据; +- PP-M7 已启动 Rust SDK 契约稳定化:`Widget::start/event` 的 `WidgetResult` 与 WIT + `widget-error` 对齐,`floatile_sdk::prelude` 定义作者入口;仓库根版本化 lifecycle JSON 覆盖全部 + guest error variant,并由 Rust SDK 与真实 host/WASM 共同消费。`floatile conformance --json` + 为后继语言 adapter 和 CI 输出同一 suite。生成 API 文档、`migrate`、完整行为/恶意输入向量与 + TypeScript SDK 仍未完成,不能据此宣称 PP-M7 退出门通过; - TypeScript runtime 的 ADR-0003 spike 结论是 no-go,不能把语言目标标记为完成; - 设置、连接管理、权限解释和开发诊断还没有完整产品入口。 diff --git a/docs/product/requirements.md b/docs/product/requirements.md index 514e6c3..45c29e6 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -62,7 +62,7 @@ HTML/WebView、原生插件和完整无障碍均不在 P0。接口可以预留 | 布局/存储 | 部分验证 | 核心层已有强类型 monitor/DPI/物理坐标模型和平台无关恢复算法;SQLite v2 migration 已持久化物理尺寸、scale factor 与 `lost_monitor`;shell 已接入启动保存/恢复(位置/尺寸/模式)、拖动/缩放/模式/热键/退出保存与显示器变化重恢复,Xvfb+Openbox 与 macOS 单屏重启恢复已实测;真实多屏/DPI/热插拔与 Windows 实机验证待做 | | 插件实例内核(PP-M1) | 已实现,部分验证 | SQLite v4 实例模型和 `floatile instance create/list/get/configure/start/stop/delete` 已打通;创建/配置与 shell 恢复共用安装目录完整性和 Config JSON Schema 复验。shell 控制面提供安装/实例列表、Schema 驱动配置、desired 启停/删除、observed `starting/running/failed/stopped` 与手动 retry;SQLite、安装文件和配置解析均在有界后台 worker。runtime 只有在 WASM `start()` 成功后才报告 running,晚期退出按稳定 code 隔离。Linux X11/Xvfb 自动证据已验证同包双窗口、单实例安装缺失失败隔离和恢复后手动 retry;Windows、macOS、Wayland 控制面交互与真实桌面多窗口仍未验证。 | | Rust 作者闭环(PP-M4) | 已实现,Xvfb 验证 | Rust SDK 的 WIT 发行快照受根事实源 drift 测试约束,生成模板可从三个独立 Cargo 包快照在仓库外目录构建;`new/check/test/dev/preview/build/install/run/inspect` 已串行通过 schema v1 自动化验收,`preview/dev/run` 使用 shell 所属真实 renderer/Slint/Wasmtime/Broker 宿主,`run` 创建精确 Installation 的持久实例并推进 generation。公开 SDK registry 上传继续受 NFR-LEGAL-01/许可 ADR 阻断;Windows、macOS、Wayland 作者窗口交互未验证。 | -| 插件系统/WASM/SDK | 部分实现 | ADR-0001 已确定统一 UI、State Patch、串行 actor 与 Rust/TypeScript 同语义目标;`wit/floatile-widget.wit`、guest/host bindings 与 `clock.wasm` 已迁移到 ADR-0001 目标契约形状并通过 `wasm-tools validate`;`floatile-ui-schema`(IR/registry/schema 校验/绑定解析/预算/契约测试 + `uiApiVersion` 版本轴 contract vectors)已实现;`floatile-renderer`(host-only,IR→宿主控制 Slint 源码 + binding/event 槽位,多层预算/结构复验与结构化转义)已实现;ADR-0002 已决策并经 `floatile-shell::runtime_ui` 落地运行时第三方插件 UI 渲染(`slint-interpreter` 运行时编译 renderer 输出为独立原生窗口:字节/结构/预算复验前置、自窗口挂 `floatile-platform` 置顶、沿 renderer binding/event 槽位 State 投影与输入事件回投;headless F12 恶意 IR 拒绝 + Xvfb 编译/实例化/投影/事件往返全绿;`spawn_runtime_ui` 已接入 shell,按持久实例的真实 ID/Config 启动独立窗口);FTUI 解析/校验/renderer 已移到准备线程,UI event 桥容量 64、非阻塞丢弃并聚合审计;`floatile-runtime` 已实现逐调用 fuel、默认 2 s epoch 墙钟预算、内存限制、串行 actor、State Patch 原子应用与 WIT adapter 接入 Broker,并覆盖无限循环 timeout 与同 Engine peer 存活;生成组件已实例化进 shell 窗口(`build.rs` 写入 gitignore 源路径、宿主 `slint!` import 嵌入 `Clock`,运行时沿 binding 槽位投影权威 State,Xvfb 下参考时钟首帧/1 Hz 更新已实测),宿主凭 `slint!` 编译生成物、不引入 `slint-build`(规避 RUSTSEC advisory);PP-M3 Capability Registry 已在 core 单源化稳定名称、暴露/参数/风险/执行类型、WIT/SDK/CLI/审计映射,并驱动 manifest schema、CLI 与 Broker 固有授权及 drift 测试;Rust 作者 SDK(`Widget`/`View`/`Context`/`#[derive(State)]`/`impl_export_widget!`/`build_ftui`)已实现且 clock-wasm 已改用;CLI `new/validate/build` 命令(模板、`.floatile` 校验、manifest 生成 + 打包)已实现;ADR-0003 的 Linux TypeScript runtime spike 已完成但结论为 no-go(StarlingMonkey 资源门失败、componentize-qjs 0.4.3 契约门失败),因此 TypeScript SDK/F11 仍未完成;动画/asset 预算向量、Slint interpreter 生产编译时延与三平台 UI heartbeat 证据仍缺失 | +| 插件系统/WASM/SDK | 部分实现 | ADR-0001 已确定统一 UI、State Patch、串行 actor 与 Rust/TypeScript 同语义目标;`wit/floatile-widget.wit`、guest/host bindings 与 `clock.wasm` 已迁移到 ADR-0001 目标契约形状并通过 `wasm-tools validate`;`floatile-ui-schema`(IR/registry/schema 校验/绑定解析/预算/契约测试 + `uiApiVersion` 版本轴 contract vectors)已实现;`floatile-renderer`(host-only,IR→宿主控制 Slint 源码 + binding/event 槽位,多层预算/结构复验与结构化转义)已实现;ADR-0002 已决策并经 `floatile-shell::runtime_ui` 落地运行时第三方插件 UI 渲染(`slint-interpreter` 运行时编译 renderer 输出为独立原生窗口:字节/结构/预算复验前置、自窗口挂 `floatile-platform` 置顶、沿 renderer binding/event 槽位 State 投影与输入事件回投;headless F12 恶意 IR 拒绝 + Xvfb 编译/实例化/投影/事件往返全绿;`spawn_runtime_ui` 已接入 shell,按持久实例的真实 ID/Config 启动独立窗口);FTUI 解析/校验/renderer 已移到准备线程,UI event 桥容量 64、非阻塞丢弃并聚合审计;`floatile-runtime` 已实现逐调用 fuel、默认 2 s epoch 墙钟预算、内存限制、串行 actor、State Patch 原子应用与 WIT adapter 接入 Broker,并覆盖无限循环 timeout 与同 Engine peer 存活;生成组件已实例化进 shell 窗口(`build.rs` 写入 gitignore 源路径、宿主 `slint!` import 嵌入 `Clock`,运行时沿 binding 槽位投影权威 State,Xvfb 下参考时钟首帧/1 Hz 更新已实测),宿主凭 `slint!` 编译生成物、不引入 `slint-build`(规避 RUSTSEC advisory);PP-M3 Capability Registry 已在 core 单源化稳定名称、暴露/参数/风险/执行类型、WIT/SDK/CLI/审计映射,并驱动 manifest schema、CLI 与 Broker 固有授权及 drift 测试;Rust 作者 SDK(`Widget`/`View`/`Context`/`#[derive(State)]`/`impl_export_widget!`/`build_ftui`)已实现且 clock-wasm 已改用,PP-M7 已增加 fallible lifecycle、稳定作者 prelude、语言无关 lifecycle conformance JSON 与 `floatile conformance` 自动化入口;CLI `new/validate/build` 命令(模板、`.floatile` 校验、manifest 生成 + 打包)已实现;ADR-0003 的 Linux TypeScript runtime spike 已完成但结论为 no-go(StarlingMonkey 资源门失败、componentize-qjs 0.4.3 契约门失败),因此 TypeScript SDK/F11 仍未完成;生成 API 文档、`migrate`、完整跨语言/恶意输入向量、动画/asset 预算向量、Slint interpreter 生产编译时延与三平台 UI heartbeat 证据仍缺失 | | Permission Broker / Operation(PP-M2) | 已实现,部分验证 | `floatile-services` Broker(deny-by-default 决策、scope/配额、脱敏审计 target `floatile::audit`)与 clock/log/timer/storage/metrics/theme 能力实现已完成并有测试;脱敏审计已落 SQLite `audit_log`(store v3 + shell 运行时经 `with_audit_listener`);恶意插件 fixture(`plugins/evil-wasm`)与安全集成测试已实现(拒绝 + 审计 + 宿主存活)。ADR-0004 的 Operation registry 已实现按实例/generation 的 identity、有界提交/完成/并发/结果预算、deadline、取消、迟到结果与过载;engine API v1.1 从 WIT 单源正式增加通用 cancel、元数据 completion 和首个 `storage:read` typed submit/take,Rust SDK 与真实 guest 往返测试已接通。动态撤权、真实容量数据和后续 capability adapter 仍缺失 | | 包工具链 | 部分实现 | `floatile-cli` 已实现 `.floatile` 包校验核心(zip 预算、路径穿越/碰撞/symlink/zip-bomb 拒绝、manifest/UI IR/WASM world 校验与正反例 corpus)、`build` 打包+自校验、`inspect` 完整复验后输出版本化 manifest/版本轴/权限/预算/entry digest JSON 契约、`check` 在自动清理临时目录中复用正式构建/校验链并输出 metadata/wasm/ui/manifest/package 阶段契约,并按组件实际导入的 WIT function 对照 Registry 与 manifest 权限声明,以及 `install` 原子安装引擎(staging/逐文件 fsync/每文件+聚合 digest/原子 rename/install.json/同版本拒绝/失败零残留,含非法包安装期拒绝);`floatile-core::install` 提供 InstallMeta 与内容 digest 单源,`floatile-shell::plugin_manager` 按 digest 复核后加载已安装 dev 包;config.schema 结构校验、独立 manifest JSON Schema、PP-M4 作者预览/运行闭环已落地;签名仍待做 | | 跨平台/性能证据 | 部分验证 | Windows、Linux Xvfb 与 VMware Xfce/Xorg 已回填 S1/S2 子集,两个 Linux X11 环境的 F3 穿透往返通过;Wayland 协议层(headless weston)首帧/CPU/RSS 与 F3 降级已回填;macOS 15.7.5 已回填 S1 子集(置顶 layer=3、无边框、布局恢复、首帧/RSS/CPU);穿透/拖拽/缩放的交互实测待人工复核 | diff --git a/plugins/ai-balance-wasm/src/lib.rs b/plugins/ai-balance-wasm/src/lib.rs index 4bd94f5..e5675ed 100644 --- a/plugins/ai-balance-wasm/src/lib.rs +++ b/plugins/ai-balance-wasm/src/lib.rs @@ -1,11 +1,9 @@ //! PP-M5 reference widget: a secret-free AI balance monitor using only generic host capabilities. +use floatile_sdk::host_http; #[cfg(target_arch = "wasm32")] use floatile_sdk::impl_export_widget; -use floatile_sdk::{ - Context, FromWidgetEvent, OperationCapability, OperationTerminal, State, Widget, WidgetEvent, - host_http, view, view::View, -}; +use floatile_sdk::prelude::*; use serde::{Deserialize, Serialize}; const CONNECTION_ID: u64 = 1; @@ -92,11 +90,12 @@ impl Widget for AiBalance { ) } - fn start(&mut self, ctx: &mut Context) { + fn start(&mut self, ctx: &mut Context) -> WidgetResult { self.refresh(ctx); + Ok(()) } - fn event(&mut self, event: BalanceEvent, ctx: &mut Context) { + fn event(&mut self, event: BalanceEvent, ctx: &mut Context) -> WidgetResult { match event { BalanceEvent::Refresh => self.refresh(ctx), BalanceEvent::Completed(id, OperationTerminal::Succeeded) => { @@ -111,6 +110,7 @@ impl Widget for AiBalance { mark_error(ctx, "unavailable"); } } + Ok(()) } fn stop(&mut self) {} diff --git a/plugins/clock-wasm/src/lib.rs b/plugins/clock-wasm/src/lib.rs index 32c0647..618e4b5 100644 --- a/plugins/clock-wasm/src/lib.rs +++ b/plugins/clock-wasm/src/lib.rs @@ -8,9 +8,7 @@ #[cfg(target_arch = "wasm32")] use floatile_sdk::impl_export_widget; -use floatile_sdk::{ - Context, FromWidgetEvent, LogLevel, State, Widget, WidgetEvent, view, view::View, -}; +use floatile_sdk::prelude::*; use serde::{Deserialize, Serialize}; // ---- State(derive State 生成 schema + initial)---- @@ -50,7 +48,7 @@ impl Widget for Clock { view::column(vec![view::text_bind("$.time")]) } - fn start(&mut self, ctx: &mut Context) { + fn start(&mut self, ctx: &mut Context) -> WidgetResult { let _ = ctx.log(LogLevel::Info, "clock started"); match ctx.timer().schedule(1000) { Ok(id) => { @@ -60,9 +58,10 @@ impl Widget for Clock { let _ = ctx.log(LogLevel::Warn, &format!("timer denied: {error:?}")); } } + Ok(()) } - fn event(&mut self, event: ClockEvent, ctx: &mut Context) { + fn event(&mut self, event: ClockEvent, ctx: &mut Context) -> WidgetResult { match event { ClockEvent::Start => { let _ = ctx.log(LogLevel::Info, "clock start command received"); @@ -82,6 +81,7 @@ impl Widget for Clock { let _ = ctx.timer().schedule(1000); } } + Ok(()) } fn stop(&mut self) { diff --git a/plugins/evil-wasm/src/lib.rs b/plugins/evil-wasm/src/lib.rs index 74abe12..f3cfb3f 100644 --- a/plugins/evil-wasm/src/lib.rs +++ b/plugins/evil-wasm/src/lib.rs @@ -19,10 +19,7 @@ #[cfg(target_arch = "wasm32")] use floatile_sdk::impl_export_widget; -use floatile_sdk::{ - Context, FromWidgetEvent, LogLevel, OperationCapability, OperationTerminal, State, Widget, - WidgetEvent, view, view::View, -}; +use floatile_sdk::prelude::*; use serde::{Deserialize, Serialize}; /// State 只携带攻击模式;宿主测试用 `initial_state`(已验证 schema)选择行为。 @@ -79,7 +76,10 @@ impl Widget for Evil { self.mode = initial.mode.clone(); } - fn start(&mut self, ctx: &mut Context) { + fn start(&mut self, ctx: &mut Context) -> WidgetResult { + if self.mode == "conformance-start-rejected" { + return Err(WidgetError::Rejected("conformance start rejection".into())); + } match self.mode.as_str() { // loop 不进 start:让实例先成功启动,测试再以事件触发无限循环,fuel 才 trap。 "deny" => self.deny_call(ctx), @@ -92,9 +92,18 @@ impl Widget for Evil { let _ = ctx.log(LogLevel::Info, &format!("start mode {mode}")); } } + Ok(()) } - fn event(&mut self, event: Self::Event, ctx: &mut Context) { + fn event(&mut self, event: Self::Event, ctx: &mut Context) -> WidgetResult { + if self.mode == "conformance-event-invalid-input" { + return Err(WidgetError::InvalidInput( + "conformance event rejection".into(), + )); + } + if self.mode == "conformance-event-internal" { + return Err(WidgetError::Internal); + } match event { EvilEvent::Trigger => match self.mode.as_str() { "loop" => self.loop_forever(), @@ -109,6 +118,7 @@ impl Widget for Evil { } } } + Ok(()) } }