merge: plugin instance lifecycle surface

This commit is contained in:
Keisuke Hirata 2026-06-21 00:18:43 +09:00
commit 43c9216ef8
No known key found for this signature in database
12 changed files with 2076 additions and 98 deletions

View File

@ -57,6 +57,40 @@ pub const RUST_COMPONENT_TOOL_TEMPLATE: &[PluginTemplateResource] = &[
},
];
/// Embedded starter template for Rust Component Model instance Plugins.
pub const RUST_COMPONENT_INSTANCE_TEMPLATE: &[PluginTemplateResource] = &[
PluginTemplateResource {
path: "Cargo.toml",
contents: include_str!(
"../../../resources/plugin/templates/rust-component-instance/Cargo.toml"
),
},
PluginTemplateResource {
path: "src/lib.rs",
contents: include_str!(
"../../../resources/plugin/templates/rust-component-instance/src/lib.rs"
),
},
PluginTemplateResource {
path: "plugin.toml",
contents: include_str!(
"../../../resources/plugin/templates/rust-component-instance/plugin.toml"
),
},
PluginTemplateResource {
path: "plugin.component.wasm",
contents: include_str!(
"../../../resources/plugin/templates/rust-component-instance/plugin.component.wasm"
),
},
PluginTemplateResource {
path: "README.md",
contents: include_str!(
"../../../resources/plugin/templates/rust-component-instance/README.md"
),
},
];
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PluginConfig {
@ -170,6 +204,8 @@ pub enum PluginPermission {
Surface { surface: PluginSurface },
Tool { name: String },
ToolNamespace { namespace: String },
Service { name: String },
Ingress { name: String },
ExternalWrite,
HostApi { api: PluginHostApi },
}
@ -249,6 +285,8 @@ impl PluginPermission {
Self::Surface { surface } => format!("surfaces.{surface}"),
Self::Tool { name } => format!("tool.{name}"),
Self::ToolNamespace { namespace } => format!("tool_namespace.{namespace}"),
Self::Service { name } => format!("service.{name}"),
Self::Ingress { name } => format!("ingress.{name}"),
Self::ExternalWrite => "external_write".to_string(),
Self::HostApi { api } => format!("host_api.{api}"),
}
@ -268,6 +306,14 @@ impl PluginPermission {
}
}
pub fn service(name: impl Into<String>) -> Self {
Self::Service { name: name.into() }
}
pub fn ingress(name: impl Into<String>) -> Self {
Self::Ingress { name: name.into() }
}
pub fn host_api(api: PluginHostApi) -> Self {
Self::HostApi { api }
}
@ -382,7 +428,7 @@ pub enum PluginIdParseError {
InvalidLocalId,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginPackageManifest {
pub schema_version: u32,
@ -398,6 +444,10 @@ pub struct PluginPackageManifest {
pub hooks: Vec<PluginHookManifest>,
#[serde(default)]
pub tools: Vec<PluginToolManifest>,
#[serde(default)]
pub services: Vec<PluginServiceManifest>,
#[serde(default)]
pub ingresses: Vec<PluginIngressManifest>,
/// Permission requests declared by the package. These are requests only;
/// enablement grants must match them before runtime surfaces are exposed.
#[serde(default)]
@ -413,6 +463,12 @@ impl PluginPackageManifest {
if !self.tools.is_empty() {
surfaces.insert(PluginSurface::Tool);
}
if !self.services.is_empty() {
surfaces.insert(PluginSurface::Service);
}
if !self.ingresses.is_empty() {
surfaces.insert(PluginSurface::Ingress);
}
if self.runtime.is_some() {
surfaces.insert(PluginSurface::Wasm);
}
@ -429,6 +485,7 @@ pub const PLUGIN_RUNTIME_WASM_ABI: &str = "yoi-plugin-wasm-1";
/// packages remain explicit `kind = "wasm"` plus `abi = "yoi-plugin-wasm-1"`.
pub const PLUGIN_RUNTIME_COMPONENT_KIND: &str = "wasm-component";
pub const PLUGIN_COMPONENT_TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0";
pub const PLUGIN_COMPONENT_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@ -464,6 +521,34 @@ pub struct PluginToolManifest {
pub external_write: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginServiceManifest {
pub name: String,
pub description: String,
#[serde(default)]
pub lifecycle: String,
#[serde(default)]
pub status_schema: Option<serde_json::Value>,
#[serde(default)]
pub side_effects: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginIngressManifest {
pub name: String,
pub description: String,
#[serde(default)]
pub event_kinds: Vec<String>,
#[serde(default)]
pub input_schema: Option<serde_json::Value>,
#[serde(default)]
pub sources: Vec<String>,
#[serde(default)]
pub side_effects: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PluginDiscoveryLimits {
pub max_packages_per_store: usize,
@ -514,7 +599,7 @@ impl PluginDiscoveryOptions {
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq)]
pub struct DiscoveredPluginPackage {
pub identity: SourceQualifiedPluginId,
pub package_path: PathBuf,
@ -529,19 +614,19 @@ pub struct DiscoveredPluginPackage {
/// This is data-only metadata and bytes. Constructing it parses manifests and
/// validates package/archive shape, but it does not load, instantiate, or
/// execute Plugin code.
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq)]
pub struct MaterializedPluginPackage {
pub package: DiscoveredPluginPackage,
pub files: BTreeMap<String, Vec<u8>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq)]
pub struct PackedPluginPackage {
pub output_path: PathBuf,
pub package: DiscoveredPluginPackage,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PluginDiscoveryReport {
pub packages: Vec<DiscoveredPluginPackage>,
pub diagnostics: Vec<PluginDiagnostic>,
@ -1072,7 +1157,10 @@ pub fn read_resolved_plugin_runtime_component(
.with_package(&record.package_label)
.with_digest(&record.digest));
}
if runtime.world.as_deref() != Some(PLUGIN_COMPONENT_TOOL_WORLD) {
if !matches!(
runtime.world.as_deref(),
Some(PLUGIN_COMPONENT_TOOL_WORLD) | Some(PLUGIN_COMPONENT_INSTANCE_WORLD)
) {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Api,
PluginDiagnosticPhase::Manifest,
@ -1918,7 +2006,10 @@ fn validate_manifest(
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
.with_package(label));
}
if runtime.world.as_deref() != Some(PLUGIN_COMPONENT_TOOL_WORLD) {
if !matches!(
runtime.world.as_deref(),
Some(PLUGIN_COMPONENT_TOOL_WORLD) | Some(PLUGIN_COMPONENT_INSTANCE_WORLD)
) {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Api,
PluginDiagnosticPhase::Manifest,
@ -1952,6 +2043,44 @@ fn validate_manifest(
}
}
}
let instance_capable = manifest.runtime.as_ref().is_some_and(|runtime| {
runtime.kind == PLUGIN_RUNTIME_COMPONENT_KIND
&& runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD)
});
if (!manifest.services.is_empty() || !manifest.ingresses.is_empty()) && !instance_capable {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Surface,
PluginDiagnosticPhase::Manifest,
"plugin service/ingress declarations require the yoi:plugin/instance@1.0.0 component world",
)
.with_source(source)
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
.with_package(label));
}
for service in &manifest.services {
if !is_safe_id(&service.name) {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Malformed,
PluginDiagnosticPhase::Manifest,
"plugin service name is not safe",
)
.with_source(source)
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
.with_package(label));
}
}
for ingress in &manifest.ingresses {
if !is_safe_id(&ingress.name) {
return Err(PluginDiagnostic::new(
PluginDiagnosticKind::Malformed,
PluginDiagnosticPhase::Manifest,
"plugin ingress name is not safe",
)
.with_source(source)
.with_identity(SourceQualifiedPluginId::new(source, manifest.id.clone()))
.with_package(label));
}
}
for hook in &manifest.hooks {
if !is_safe_id(&hook.id) {
return Err(PluginDiagnostic::new(
@ -2425,7 +2554,13 @@ mod tests {
.collect();
assert_eq!(
paths,
BTreeSet::from(["Cargo.toml", "src/lib.rs", "plugin.toml", "README.md"])
BTreeSet::from([
"Cargo.toml",
"src/lib.rs",
"plugin.toml",
"plugin.component.wasm",
"README.md",
])
);
assert!(
RUST_COMPONENT_TOOL_TEMPLATE
@ -2451,6 +2586,86 @@ mod tests {
assert_eq!(manifest.tools.len(), 1);
}
#[test]
fn embedded_rust_component_instance_template_is_valid_package_shape() {
let paths: BTreeSet<_> = RUST_COMPONENT_INSTANCE_TEMPLATE
.iter()
.map(|file| file.path)
.collect();
assert_eq!(
paths,
BTreeSet::from([
"Cargo.toml",
"src/lib.rs",
"plugin.toml",
"plugin.component.wasm",
"README.md"
])
);
assert!(
RUST_COMPONENT_INSTANCE_TEMPLATE
.iter()
.all(|file| !file.path.starts_with('/') && !file.path.contains(".."))
);
let manifest_text = RUST_COMPONENT_INSTANCE_TEMPLATE
.iter()
.find(|file| file.path == "plugin.toml")
.unwrap()
.contents;
let manifest: PluginPackageManifest = toml::from_str(manifest_text).unwrap();
assert_eq!(
manifest.runtime.as_ref().unwrap().world.as_deref(),
Some(PLUGIN_COMPONENT_INSTANCE_WORLD)
);
assert_eq!(manifest.services.len(), 1);
assert_eq!(manifest.ingresses.len(), 1);
assert!(
manifest
.declared_surfaces()
.contains(&PluginSurface::Service)
);
assert!(
manifest
.declared_surfaces()
.contains(&PluginSurface::Ingress)
);
}
#[test]
fn service_ingress_require_instance_component_world() {
let manifest: PluginPackageManifest = toml::from_str(
r#"
schema_version = 1
id = "bad.service"
name = "Bad Service"
version = "0.1.0"
surfaces = ["service"]
permissions = [{ kind = "surface", surface = "service" }, { kind = "service", name = "svc" }]
[runtime]
kind = "wasm-component"
world = "yoi:plugin/tool@1.0.0"
component = "plugin.component.wasm"
[[services]]
name = "svc"
description = "bad"
"#,
)
.unwrap();
let archive = StoredArchive {
files: BTreeMap::from([("plugin.component.wasm".to_string(), b"placeholder".to_vec())]),
};
let err = validate_manifest(
&manifest,
&archive,
"bad.service",
PluginSourceKind::Project,
)
.unwrap_err();
assert!(err.message.contains("service/ingress"));
}
#[test]
fn discovers_valid_user_and_workspace_packages() {
let temp = TempDir::new().unwrap();

View File

@ -38,6 +38,8 @@ use serde_json::Value;
pub use wit_bindgen;
pub type Result<T> = std::result::Result<T, ToolError>;
/// Current Yoi Component Model Tool world targeted by this PDK.
pub const TOOL_WORLD: &str = "yoi:plugin/tool@1.0.0";
@ -98,7 +100,10 @@ impl ToolOutput {
}
/// Create a Tool output whose content is typed JSON.
pub fn json(summary: impl Into<String>, value: impl Serialize) -> Result<Self, ToolError> {
pub fn json(
summary: impl Into<String>,
value: impl Serialize,
) -> std::result::Result<Self, ToolError> {
let content = serde_json::to_string(&value).map_err(ToolError::serialization)?;
let output = Self {
summary: normalize_summary(summary.into()),
@ -292,7 +297,7 @@ impl ToolError {
}
/// Parse the WIT `input-json` string into a typed input value.
pub fn parse_json_input<T>(input_json: &str) -> Result<T, ToolError>
pub fn parse_json_input<T>(input_json: &str) -> std::result::Result<T, ToolError>
where
T: DeserializeOwned,
{
@ -311,7 +316,7 @@ where
pub fn run_json_tool<I, F>(tool_name: &str, input_json: &str, handler: F) -> String
where
I: DeserializeOwned,
F: FnOnce(ToolContext, I) -> Result<ToolOutput, ToolError>,
F: FnOnce(ToolContext, I) -> std::result::Result<ToolOutput, ToolError>,
{
let result = parse_json_input::<I>(input_json).and_then(|input| {
let context = ToolContext::new(tool_name);
@ -474,3 +479,166 @@ mod tests {
assert!(HOST_WIT.contains("%list: func"));
}
}
/// Versioned Component Model instance world handled by the host-managed
/// PluginInstanceRegistry.
pub const PLUGIN_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0";
/// Repository WIT for the current instance world.
pub const INSTANCE_WIT: &str =
include_str!("../../../resources/plugin/wit/yoi-plugin-instance-v1.wit");
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct PluginIngressEvent {
pub kind: String,
pub source: String,
#[serde(default)]
pub payload: Value,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct PluginStatus {
pub state: String,
#[serde(default)]
pub data: Value,
}
impl PluginStatus {
pub fn ready(data: Value) -> Self {
Self {
state: "ready".to_string(),
data,
}
}
pub fn stopped() -> Self {
Self {
state: "stopped".to_string(),
data: Value::Null,
}
}
}
/// Rust-facing instance Plugin contract. Hosts call `start` once, then route
/// Tool/Ingress surfaces through the same mutable instance.
pub trait Plugin: Sized + 'static {
fn start(config: Value) -> Result<Self>;
fn handle_tool(&mut self, name: &str, input: Value) -> Result<ToolOutput>;
fn handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> Result<Value>;
fn status(&self) -> Result<PluginStatus> {
Ok(PluginStatus::ready(Value::Null))
}
fn stop(&mut self) -> Result<PluginStatus> {
Ok(PluginStatus::stopped())
}
}
#[doc(hidden)]
pub fn plugin_instance_error(message: impl Into<String>) -> String {
serde_json::json!({ "error": { "message": message.into() } }).to_string()
}
#[doc(hidden)]
pub fn plugin_instance_status(status: &PluginStatus) -> String {
serde_json::to_string(status).unwrap_or_else(|error| plugin_instance_error(error.to_string()))
}
/// Implement the generated Component Model `Guest` trait for an instance Plugin
/// and export it with the `wit-bindgen` generated `export!` macro.
///
/// The caller must invoke `wit_bindgen::generate!` for the `instance` world
/// first, with `runtime_path: "yoi_plugin_pdk::wit_bindgen::rt"`. That defines
/// the `Guest` trait and `export!` macro in the current module.
#[macro_export]
macro_rules! export_plugin_instance {
($adapter:ident, $plugin:ty) => {
struct $adapter;
thread_local! {
static YOI_PLUGIN_INSTANCE: ::std::cell::RefCell<::std::option::Option<$plugin>> = const { ::std::cell::RefCell::new(None) };
}
impl Guest for $adapter {
fn start(config_json: ::std::string::String) -> ::std::string::String {
let config = serde_json::from_str(&config_json).unwrap_or(serde_json::Value::Null);
match <$plugin as $crate::Plugin>::start(config) {
Ok(plugin) => {
YOI_PLUGIN_INSTANCE.with(|slot| *slot.borrow_mut() = Some(plugin));
$crate::plugin_instance_status(&$crate::PluginStatus::ready(serde_json::Value::Null))
}
Err(error) => $crate::plugin_instance_error(error.to_string()),
}
}
fn handle_tool(
name: ::std::string::String,
input_json: ::std::string::String,
) -> ::std::string::String {
let input = serde_json::from_str(&input_json).unwrap_or(serde_json::Value::Null);
YOI_PLUGIN_INSTANCE.with(|slot| {
let mut slot = slot.borrow_mut();
let Some(plugin) = slot.as_mut() else {
return $crate::plugin_instance_error("plugin instance has not been started");
};
match plugin.handle_tool(&name, input) {
Ok(output) => output.to_json_string(),
Err(error) => error.into_tool_output().to_json_string(),
}
})
}
fn handle_ingress(
name: ::std::string::String,
event_json: ::std::string::String,
) -> ::std::string::String {
let event = match serde_json::from_str::<$crate::PluginIngressEvent>(&event_json) {
Ok(event) => event,
Err(error) => return $crate::plugin_instance_error(error.to_string()),
};
YOI_PLUGIN_INSTANCE.with(|slot| {
let mut slot = slot.borrow_mut();
let Some(plugin) = slot.as_mut() else {
return $crate::plugin_instance_error("plugin instance has not been started");
};
match plugin.handle_ingress(&name, event) {
Ok(output) => serde_json::to_string(&output)
.unwrap_or_else(|error| $crate::plugin_instance_error(error.to_string())),
Err(error) => $crate::plugin_instance_error(error.to_string()),
}
})
}
fn status() -> ::std::string::String {
YOI_PLUGIN_INSTANCE.with(|slot| {
let slot = slot.borrow();
let Some(plugin) = slot.as_ref() else {
return $crate::plugin_instance_error("plugin instance has not been started");
};
match plugin.status() {
Ok(status) => $crate::plugin_instance_status(&status),
Err(error) => $crate::plugin_instance_error(error.to_string()),
}
})
}
fn stop() -> ::std::string::String {
YOI_PLUGIN_INSTANCE.with(|slot| {
let mut slot = slot.borrow_mut();
let Some(plugin) = slot.as_mut() else {
return $crate::plugin_instance_error("plugin instance has not been started");
};
match plugin.stop() {
Ok(status) => {
let output = $crate::plugin_instance_status(&status);
*slot = None;
output
}
Err(error) => $crate::plugin_instance_error(error.to_string()),
}
})
}
}
export!($adapter);
};
}

File diff suppressed because it is too large Load Diff

View File

@ -5375,6 +5375,8 @@ permission = "read"
runtime: None,
hooks: vec![],
tools: vec![],
services: vec![],
ingresses: vec![],
permissions: vec![],
},
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],

View File

@ -329,6 +329,24 @@ fn static_inspection_diagnostics(
});
}
}
for service in &inspection.services {
if let Some(message) = &service.diagnostic {
diagnostics.push(PluginDiagnosticReport {
kind: "grant".to_string(),
phase: "resolution".to_string(),
message: bound_text(format!("service `{}`: {message}", service.name)),
});
}
}
for ingress in &inspection.ingresses {
if let Some(message) = &ingress.diagnostic {
diagnostics.push(PluginDiagnosticReport {
kind: "grant".to_string(),
phase: "resolution".to_string(),
message: bound_text(format!("ingress `{}`: {message}", ingress.name)),
});
}
}
diagnostics
}
@ -1072,6 +1090,18 @@ fn fill_resolved(builder: &mut ItemBuilder, resolved: &ResolvedPlugin) {
.iter()
.filter_map(|tool| tool.diagnostic.as_ref()),
)
.chain(
static_runtime
.services
.iter()
.filter_map(|service| service.diagnostic.as_ref()),
)
.chain(
static_runtime
.ingresses
.iter()
.filter_map(|ingress| ingress.diagnostic.as_ref()),
)
{
builder.diagnostics.push(DiagnosticSummary {
kind: "static_eligibility".to_string(),
@ -1473,6 +1503,58 @@ mod tests {
assert!(show.contains("configured_grants: surfaces.tool, tool.Echo"));
}
#[test]
fn service_only_enablement_ignores_unselected_tool_static_grants() {
let dir = tempdir().unwrap();
let workspace = dir.path();
let digest = write_mixed_tool_service_package(workspace, "mixed");
let mut config = PluginConfig::default();
config.enabled.push(PluginEnablementConfig {
id: "project:mixed".to_string(),
digest: Some(digest.clone()),
version: Some(PluginExactVersion("0.1.0".to_string())),
surfaces: vec![PluginSurface::Service],
grants: PluginGrantConfig {
id: Some("project:mixed".to_string()),
version: Some(PluginExactVersion("0.1.0".to_string())),
digest: Some(digest),
permissions: vec![
PluginPermission::surface(PluginSurface::Service),
PluginPermission::service("svc"),
],
https: Vec::new(),
fs: Vec::new(),
},
config: None,
});
let snapshot = inspect_snapshot(workspace, &config);
let item = select_item(&snapshot, "project:mixed").unwrap();
assert_eq!(item.status, "active");
assert!(item.static_eligible);
assert_eq!(item.enabled_surfaces, vec!["service"]);
assert!(
item.tools.is_empty(),
"unselected Tool must not be reported"
);
assert!(
item.diagnostics
.iter()
.all(|diagnostic| !diagnostic.message.contains("tool.Echo")),
"unselected Tool grant diagnostics must not affect service-only enablement: {:#?}",
item.diagnostics
);
let show_json = serde_json::to_value(item).unwrap();
assert_eq!(show_json["status"], "active");
assert_eq!(
show_json["enabled_surfaces"],
serde_json::json!(["service"])
);
assert_eq!(show_json["tools"], serde_json::json!([]));
}
#[test]
fn human_list_uses_required_status_vocabulary() {
let dir = tempdir().unwrap();
@ -2080,6 +2162,61 @@ mod tests {
assert!(error.len() < 160);
}
fn write_mixed_tool_service_package(workspace: &Path, id: &str) -> String {
let package_dir = workspace.join(".yoi/plugins");
fs::create_dir_all(&package_dir).unwrap();
let package = package_dir.join(format!("{id}.yoi-plugin"));
let manifest = format!(
r#"schema_version = 1
id = "{id}"
name = "{id}"
version = "0.1.0"
description = "mixed surface package"
surfaces = ["tool", "service"]
permissions = [
{{ kind = "surface", surface = "tool" }},
{{ kind = "tool", name = "Echo" }},
{{ kind = "surface", surface = "service" }},
{{ kind = "service", name = "svc" }},
]
[runtime]
kind = "wasm-component"
world = "yoi:plugin/instance@1.0.0"
component = "plugin.component.wasm"
[[tools]]
name = "Echo"
description = "unselected tool"
input_schema = {{ type = "object" }}
[[services]]
name = "svc"
description = "selected service"
lifecycle = "host-managed"
"#,
);
write_stored_zip(
&package,
&[
("plugin.toml", manifest.as_bytes()),
("plugin.component.wasm", b"placeholder component bytes"),
],
);
let discovery = discover_plugins(&PluginDiscoveryOptions {
workspace_root: workspace.to_path_buf(),
user_data_home: None,
limits: PluginDiscoveryLimits::default(),
});
discovery
.packages
.iter()
.find(|package| package.identity.local_id == id)
.unwrap()
.digest
.clone()
}
fn inspect_snapshot(workspace: &Path, config: &PluginConfig) -> PluginInspectionSnapshot {
let discovery = discover_plugins(&PluginDiscoveryOptions {
workspace_root: workspace.to_path_buf(),

View File

@ -179,3 +179,26 @@ semantics while moving package authors onto WIT/canonical ABI bindings.
Structured WIT records for Tool requests/responses/errors and host HTTPS/FS
payloads are deferred to a follow-up API-design step rather than accidentally
omitted.
## Instance lifecycle surface
The first instance-capable world is `yoi:plugin/instance@1.0.0`. It moves
runtime ownership from per-Tool artifact execution to a host-managed
`PluginInstance`. The same instance handles Tool, Service, and Ingress surfaces,
so Plugin state/config/diagnostics can be shared without bypassing Yoi's normal
authority model.
Important boundaries:
- Tool calls still enter through `ToolRegistry` and return ordinary `ToolOutput`
that is visible in the Worker history path.
- Service and Ingress grants are separate from Tool grants. Sharing an instance
does not authorize a surface that lacks its own `surface.*` and per-surface
permission/grant.
- Ingress delivery accepts bounded typed untrusted events and returns explicit
JSON to the host. It does not call model Tools or mutate LLM context/history.
- Legacy raw-wasm and `yoi:plugin/tool@1.0.0` component packages are adapted
behind `PluginInstanceRegistry` for compatibility rather than executed through
a separate authority path.
- Host APIs such as `https` and `fs` remain independently grant-gated and still
reject ambient filesystem/network authority.

View File

@ -0,0 +1,14 @@
[workspace]
[package]
name = "example-yoi-instance-plugin"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@ -0,0 +1,9 @@
# Yoi instance Plugin template
This template targets `yoi:plugin/instance@1.0.0`. The host creates one
`PluginInstance` for the package; Tool, Service, and Ingress surfaces share that
instance state while each surface keeps separate permissions/grants.
Tools still run only through ordinary model/user-initiated Tool calls. Ingress
handlers receive bounded typed untrusted events and must return explicit JSON
for host-mediated visible/durable paths.

View File

@ -0,0 +1,3 @@
# Build with:
# cargo component build --release
# cp target/wasm32-wasip1/release/example_yoi_instance_plugin.wasm plugin.component.wasm

View File

@ -0,0 +1,35 @@
schema_version = 1
id = "example.rust_instance_plugin"
name = "Rust Instance Plugin Template"
version = "0.1.0"
description = "Example instance-oriented Yoi Plugin with shared Tool/Ingress state."
surfaces = ["tool", "service", "ingress"]
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "example_instance_tool" },
{ kind = "surface", surface = "service" },
{ kind = "service", name = "example_instance_service" },
{ kind = "surface", surface = "ingress" },
{ kind = "ingress", name = "example_instance_ingress" },
]
[runtime]
kind = "wasm-component"
world = "yoi:plugin/instance@1.0.0"
component = "plugin.component.wasm"
[[tools]]
name = "example_instance_tool"
description = "Return the input and increment shared instance state."
input_schema = { type = "object" }
[[services]]
name = "example_instance_service"
description = "Reports shared plugin instance lifecycle status."
lifecycle = "host-managed"
[[ingresses]]
name = "example_instance_ingress"
description = "Accepts bounded in-process ingress events."
event_kinds = ["example"]
input_schema = { type = "object" }

View File

@ -0,0 +1,52 @@
use serde_json::{json, Value};
use yoi_plugin_pdk::wit_bindgen;
use yoi_plugin_pdk::{export_plugin_instance, Plugin, PluginIngressEvent, PluginStatus, ToolOutput};
wit_bindgen::generate!({
world: "instance",
path: "../../../../resources/plugin/wit",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
struct ExamplePlugin {
calls: u64,
}
impl Plugin for ExamplePlugin {
fn start(_config: Value) -> yoi_plugin_pdk::Result<Self> {
Ok(Self { calls: 0 })
}
fn handle_tool(&mut self, name: &str, input: Value) -> yoi_plugin_pdk::Result<ToolOutput> {
self.calls += 1;
ToolOutput::json(
format!("{name} handled by shared instance"),
json!({
"tool": name,
"calls": self.calls,
"input": input
}),
)
}
fn handle_ingress(
&mut self,
name: &str,
event: PluginIngressEvent,
) -> yoi_plugin_pdk::Result<Value> {
Ok(json!({
"ingress": name,
"kind": event.kind,
"source": event.source,
"calls": self.calls,
"accepted": true
}))
}
fn status(&self) -> yoi_plugin_pdk::Result<PluginStatus> {
Ok(PluginStatus::ready(json!({ "calls": self.calls })))
}
}
export_plugin_instance!(ExamplePluginComponent, ExamplePlugin);

View File

@ -0,0 +1,12 @@
package yoi:plugin@1.0.0;
world instance {
import yoi:host/https@1.0.0;
import yoi:host/fs@1.0.0;
export start: func(config-json: string) -> string;
export handle-tool: func(name: string, input-json: string) -> string;
export handle-ingress: func(name: string, event-json: string) -> string;
export status: func() -> string;
export stop: func() -> string;
}