merge: 00001KVXK0WEA plugin pdk service events
This commit is contained in:
commit
8d4fee231b
|
|
@ -468,15 +468,48 @@ mod tests {
|
|||
assert!(error.message().len() <= MAX_ERROR_MESSAGE_BYTES + "…".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_output_helper_builds_runtime_command_envelope() {
|
||||
let event = PluginIngressEvent {
|
||||
kind: "websocket_text".to_string(),
|
||||
source: "websocket:wss://example.test/socket".to_string(),
|
||||
ingress_name: "example_ws".to_string(),
|
||||
payload: json!({"text":"ping"}),
|
||||
created_at: "2026-06-25T00:00:00Z".to_string(),
|
||||
attempt: 1,
|
||||
correlation_id: "event-1".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(event.websocket_text(), Some("ping"));
|
||||
let output =
|
||||
ServiceOutput::websocket_send(&event, "reply-1", "wss://example.test/socket", "pong")
|
||||
.unwrap();
|
||||
let value = serde_json::to_value(output).unwrap();
|
||||
|
||||
assert_eq!(value["accepted"], true);
|
||||
assert_eq!(value["output_commands"][0]["source_event_id"], "event-1");
|
||||
assert_eq!(value["output_commands"][0]["command_id"], "reply-1");
|
||||
assert_eq!(value["output_commands"][0]["kind"], "websocket_send");
|
||||
assert_eq!(value["output_commands"][0]["payload"]["text"], "pong");
|
||||
assert_eq!(
|
||||
value["output_commands"][0]["requested_at"],
|
||||
"2026-06-25T00:00:00Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wit_constants_match_current_world() {
|
||||
assert!(TOOL_WIT.contains("package yoi:plugin@1.0.0"));
|
||||
assert!(TOOL_WIT.contains("world tool"));
|
||||
assert!(TOOL_WIT.contains("export call"));
|
||||
assert_eq!(TOOL_WORLD, "yoi:plugin/tool@1.0.0");
|
||||
assert!(HOST_WIT.contains("interface https"));
|
||||
assert!(HOST_WIT.contains("interface request"));
|
||||
assert!(HOST_WIT.contains("interface websocket"));
|
||||
assert!(HOST_WIT.contains("interface fs"));
|
||||
assert!(HOST_WIT.contains("%list: func"));
|
||||
assert!(INSTANCE_WIT.contains("world instance"));
|
||||
assert!(INSTANCE_WIT.contains("export handle-ingress"));
|
||||
assert!(INSTANCE_WIT.contains("websocket_send"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -488,12 +521,48 @@ pub const PLUGIN_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0";
|
|||
pub const INSTANCE_WIT: &str =
|
||||
include_str!("../../../resources/plugin/wit/yoi-plugin-instance-v1.wit");
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PluginIngressEvent {
|
||||
pub kind: String,
|
||||
pub source: String,
|
||||
#[serde(default)]
|
||||
pub ingress_name: String,
|
||||
#[serde(default)]
|
||||
pub payload: Value,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
#[serde(default = "default_attempt")]
|
||||
pub attempt: u32,
|
||||
#[serde(default)]
|
||||
pub correlation_id: String,
|
||||
}
|
||||
|
||||
impl PluginIngressEvent {
|
||||
/// Return the text payload carried by a host-owned WebSocket ingress event.
|
||||
pub fn websocket_text(&self) -> Option<&str> {
|
||||
self.payload.get("text").and_then(Value::as_str)
|
||||
}
|
||||
|
||||
/// Build a `websocket_send` output command that replies through the
|
||||
/// host-owned Service WebSocket driver. The host still validates the target
|
||||
/// URL and matching grants before sending.
|
||||
pub fn websocket_send(
|
||||
&self,
|
||||
command_id: impl Into<String>,
|
||||
url: impl Into<String>,
|
||||
text: impl Into<String>,
|
||||
) -> Result<ServiceOutputCommand> {
|
||||
ServiceOutputCommand::new(
|
||||
self,
|
||||
command_id,
|
||||
ServiceOutputCommandKind::WebSocketSend,
|
||||
serde_json::json!({ "url": url.into(), "text": text.into() }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_attempt() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
|
|
@ -519,12 +588,118 @@ impl PluginStatus {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ServiceOutputCommandKind {
|
||||
DiagnosticStatusUpdate,
|
||||
HostRequestDispatch,
|
||||
#[serde(rename = "websocket_send")]
|
||||
WebSocketSend,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ServiceOutputCommand {
|
||||
pub correlation_id: String,
|
||||
pub source_event_id: String,
|
||||
pub command_id: String,
|
||||
pub kind: ServiceOutputCommandKind,
|
||||
pub payload: Value,
|
||||
pub requested_at: String,
|
||||
}
|
||||
|
||||
impl ServiceOutputCommand {
|
||||
pub fn new(
|
||||
event: &PluginIngressEvent,
|
||||
command_id: impl Into<String>,
|
||||
kind: ServiceOutputCommandKind,
|
||||
payload: impl Serialize,
|
||||
) -> Result<Self> {
|
||||
let command_id = sanitize_command_id(command_id.into());
|
||||
if command_id.is_empty() {
|
||||
return Err(ToolError::invalid_output(
|
||||
"service output command_id must not be empty",
|
||||
));
|
||||
}
|
||||
let source_event_id = event.correlation_id.clone();
|
||||
if source_event_id.is_empty() {
|
||||
return Err(ToolError::invalid_output(
|
||||
"service output command requires ingress event correlation_id",
|
||||
));
|
||||
}
|
||||
let requested_at = if event.created_at.is_empty() {
|
||||
"1970-01-01T00:00:00Z".to_string()
|
||||
} else {
|
||||
event.created_at.clone()
|
||||
};
|
||||
Ok(Self {
|
||||
correlation_id: sanitize_command_id(format!("{source_event_id}:{command_id}")),
|
||||
source_event_id,
|
||||
command_id,
|
||||
kind,
|
||||
payload: serde_json::to_value(payload).map_err(ToolError::serialization)?,
|
||||
requested_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ServiceOutput {
|
||||
#[serde(default)]
|
||||
pub accepted: bool,
|
||||
#[serde(default, skip_serializing_if = "Value::is_null")]
|
||||
pub data: Value,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub output_commands: Vec<ServiceOutputCommand>,
|
||||
}
|
||||
|
||||
impl ServiceOutput {
|
||||
pub fn accepted(data: impl Serialize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
accepted: true,
|
||||
data: serde_json::to_value(data).map_err(ToolError::serialization)?,
|
||||
output_commands: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
accepted: true,
|
||||
data: Value::Null,
|
||||
output_commands: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_command(mut self, command: ServiceOutputCommand) -> Self {
|
||||
self.output_commands.push(command);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn websocket_send(
|
||||
event: &PluginIngressEvent,
|
||||
command_id: impl Into<String>,
|
||||
url: impl Into<String>,
|
||||
text: impl Into<String>,
|
||||
) -> Result<Self> {
|
||||
Ok(Self::empty().with_command(event.websocket_send(command_id, url, text)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_command_id(value: String) -> String {
|
||||
bounded_text(
|
||||
value
|
||||
.chars()
|
||||
.map(|ch| if ch.is_control() { '-' } else { ch })
|
||||
.collect(),
|
||||
128,
|
||||
)
|
||||
}
|
||||
|
||||
/// 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 handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> Result<ServiceOutput>;
|
||||
fn status(&self) -> Result<PluginStatus> {
|
||||
Ok(PluginStatus::ready(Value::Null))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ const TEMPLATE_PLUGIN: &str =
|
|||
include_str!("../../../resources/plugin/templates/rust-component-tool/plugin.toml");
|
||||
const TEMPLATE_README: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-tool/README.md");
|
||||
const SERVICE_TEMPLATE_CARGO: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-instance/Cargo.toml");
|
||||
const SERVICE_TEMPLATE_LIB: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-instance/src/lib.rs");
|
||||
const SERVICE_TEMPLATE_PLUGIN: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-instance/plugin.toml");
|
||||
const SERVICE_TEMPLATE_README: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-instance/README.md");
|
||||
const SAMPLE_LIB: &str = include_str!("../../../docs/examples/plugin-component-tool/lib.rs");
|
||||
const PDK_CARGO: &str = include_str!("../Cargo.toml");
|
||||
|
||||
|
|
@ -25,7 +33,7 @@ fn rust_component_tool_template_has_expected_files() {
|
|||
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(),
|
||||
Some("../../../../crates/plugin-pdk")
|
||||
);
|
||||
assert!(TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-revision>\""));
|
||||
assert!(TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-commit-sha>\""));
|
||||
|
||||
let plugin: Value = toml::from_str(TEMPLATE_PLUGIN).expect("template plugin.toml parses");
|
||||
assert_eq!(plugin["schema_version"].as_integer(), Some(1));
|
||||
|
|
@ -45,6 +53,71 @@ fn rust_component_tool_template_has_expected_files() {
|
|||
assert!(TEMPLATE_README.contains("Component Model Tool Plugin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_component_service_template_has_event_output_pattern() {
|
||||
let cargo: Value = toml::from_str(SERVICE_TEMPLATE_CARGO).expect("service Cargo.toml parses");
|
||||
assert_eq!(cargo["package"]["edition"].as_str(), Some("2024"));
|
||||
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
|
||||
assert_eq!(
|
||||
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(),
|
||||
Some("../../../../crates/plugin-pdk")
|
||||
);
|
||||
assert!(SERVICE_TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-commit-sha>\""));
|
||||
|
||||
let plugin: Value =
|
||||
toml::from_str(SERVICE_TEMPLATE_PLUGIN).expect("service plugin.toml parses");
|
||||
assert_eq!(plugin["schema_version"].as_integer(), Some(1));
|
||||
assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component"));
|
||||
assert_eq!(
|
||||
plugin["runtime"]["world"].as_str(),
|
||||
Some("yoi:plugin/instance@1.0.0")
|
||||
);
|
||||
assert!(
|
||||
plugin["permissions"]
|
||||
.as_array()
|
||||
.expect("permissions array")
|
||||
.iter()
|
||||
.any(|permission| permission["kind"].as_str() == Some("host_api")
|
||||
&& permission["api"].as_str() == Some("websocket"))
|
||||
);
|
||||
assert_eq!(
|
||||
plugin["services"].as_array().expect("services array").len(),
|
||||
1
|
||||
);
|
||||
let ingress = &plugin["ingresses"].as_array().expect("ingresses array")[0];
|
||||
assert!(
|
||||
ingress["event_kinds"]
|
||||
.as_array()
|
||||
.expect("event kinds")
|
||||
.iter()
|
||||
.any(|kind| kind.as_str() == Some("websocket_text"))
|
||||
);
|
||||
assert!(
|
||||
ingress["sources"]
|
||||
.as_array()
|
||||
.expect("sources")
|
||||
.iter()
|
||||
.any(|source| source.as_str() == Some("websocket:wss://example.com/socket"))
|
||||
);
|
||||
let websocket = &plugin["websocket"].as_array().expect("websocket targets")[0];
|
||||
assert_eq!(websocket["scheme"].as_str(), Some("wss"));
|
||||
assert_eq!(websocket["host"].as_str(), Some("example.com"));
|
||||
assert!(
|
||||
websocket["path_prefixes"]
|
||||
.as_array()
|
||||
.expect("websocket path prefixes")
|
||||
.iter()
|
||||
.any(|prefix| prefix.as_str() == Some("/socket"))
|
||||
);
|
||||
|
||||
assert!(SERVICE_TEMPLATE_LIB.contains("world: \"instance\""));
|
||||
assert!(SERVICE_TEMPLATE_LIB.contains("PluginIngressEvent"));
|
||||
assert!(SERVICE_TEMPLATE_LIB.contains("ServiceOutput::websocket_send"));
|
||||
assert!(!SERVICE_TEMPLATE_LIB.contains("recv(timeout"));
|
||||
assert!(SERVICE_TEMPLATE_README.contains("output command"));
|
||||
assert!(!SERVICE_TEMPLATE_PLUGIN.contains("kind = \"wasm\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documented_sample_uses_pdk_component_path() {
|
||||
assert!(SAMPLE_LIB.contains("use yoi_plugin_pdk::wit_bindgen"));
|
||||
|
|
@ -57,8 +130,17 @@ fn documented_sample_uses_pdk_component_path() {
|
|||
|
||||
#[test]
|
||||
fn embedded_template_cargo_checks_for_wasm_target() {
|
||||
cargo_check_template("rust-component-tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_service_template_cargo_checks_for_wasm_target() {
|
||||
cargo_check_template("rust-component-instance");
|
||||
}
|
||||
|
||||
fn cargo_check_template(template: &str) {
|
||||
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let template_dir = crate_dir.join("../../resources/plugin/templates/rust-component-tool");
|
||||
let template_dir = crate_dir.join(format!("../../resources/plugin/templates/{template}"));
|
||||
let manifest_path = template_dir.join("Cargo.toml");
|
||||
let lock_path = template_dir.join("Cargo.lock");
|
||||
let _ = fs::remove_file(&lock_path);
|
||||
|
|
|
|||
|
|
@ -727,7 +727,7 @@ fn parse_plugin_pack_args(
|
|||
}
|
||||
|
||||
fn plugin_usage() -> &'static str {
|
||||
"usage: yoi plugin new rust-component-tool <path-or-name> [--json]\n yoi plugin check <path-or-package> [--json]\n yoi plugin pack <path> [--output <file>] [--json]\n yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show <ref> [--workspace PATH] [--profile REF] [--json]"
|
||||
"usage: yoi plugin new <rust-component-tool|rust-component-service> <path-or-name> [--json]\n yoi plugin check <path-or-package> [--json]\n yoi plugin pack <path> [--output <file>] [--json]\n yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show <ref> [--workspace PATH] [--profile REF] [--json]"
|
||||
}
|
||||
|
||||
fn parse_mcp_args(args: &[String]) -> Result<mcp_cli::McpCliCommand, ParseError> {
|
||||
|
|
@ -901,7 +901,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
|
|||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi pod delete <NAME> [--force] [--dry-run]\n yoi pod prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new rust-component-tool <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod <NAME> Open the Pod Console by name (attach/restore/create)\n --socket <PATH> Attach a Pod Console to a specific socket with --pod\n --session <UUID> Resume a specific session segment in the Pod Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||
"yoi\n\nUsage:\n yoi [OPTIONS]\n yoi resume [--workspace <PATH>] [--all]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi pod delete <NAME> [--force] [--dry-run]\n yoi pod prune --older-than <DURATION> [--force] [--dry-run]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi session prune --unreferenced [--older-than <DURATION>] [--force] [--dry-run]\n yoi ticket <COMMAND> [OPTIONS]\n yoi workspace serve [OPTIONS]\n yoi plugin new <rust-component-tool|rust-component-service> <PATH> [--json]\n yoi plugin check <PATH_OR_PACKAGE> [--json]\n yoi plugin pack <PATH> [--output <FILE>] [--json]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp show <SERVER> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi mcp tools|resources|prompts [SERVER] [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nSurfaces:\n Console Single-Pod chat/client surface (default, --pod, yoi resume)\n Dashboard Workspace cockpit/action surface (yoi panel)\n TUI Terminal UI implementation umbrella for Console and Dashboard\n\nOptions:\n --workspace <PATH> Runtime workspace root for default Console/--pod (defaults to cwd)\n --pod <NAME> Open the Pod Console by name (attach/restore/create)\n --socket <PATH> Attach a Pod Console to a specific socket with --pod\n --session <UUID> Resume a specific session segment in the Pod Console\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ use manifest::plugin::{
|
|||
MaterializedPluginPackage, PluginConfig, PluginDiagnostic, PluginDiagnosticKind,
|
||||
PluginDiagnosticPhase, PluginDiscoveryLimits, PluginDiscoveryOptions, PluginDiscoveryReport,
|
||||
PluginExactVersion, PluginGrantConfig, PluginPackageManifest, PluginPermission,
|
||||
PluginResolution, PluginSourceKind, PluginSurface, RUST_COMPONENT_TOOL_TEMPLATE,
|
||||
ResolvedPlugin, ResolvedPluginRecord, SourceQualifiedPluginId, discover_plugins,
|
||||
read_plugin_directory, read_plugin_package_file, resolve_enabled_plugins,
|
||||
write_plugin_package_file,
|
||||
PluginResolution, PluginSourceKind, PluginSurface, PluginTemplateResource,
|
||||
RUST_COMPONENT_INSTANCE_TEMPLATE, RUST_COMPONENT_TOOL_TEMPLATE, ResolvedPlugin,
|
||||
ResolvedPluginRecord, SourceQualifiedPluginId, discover_plugins, read_plugin_directory,
|
||||
read_plugin_package_file, resolve_enabled_plugins, write_plugin_package_file,
|
||||
};
|
||||
use manifest::{ProfileResolveOptions, ProfileResolver, ProfileSelector, paths};
|
||||
use pod::feature::plugin::{PluginStaticInspection, inspect_resolved_plugin_static};
|
||||
|
|
@ -85,27 +85,29 @@ pub(crate) fn run(command: PluginCliCommand) -> Result<()> {
|
|||
}
|
||||
|
||||
fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Result<String> {
|
||||
if template != "rust-component-tool" {
|
||||
return Err(format!(
|
||||
"unsupported plugin template `{template}` (supported: rust-component-tool)"
|
||||
)
|
||||
.into());
|
||||
let (template_name, resources) = embedded_template_resources(template)?;
|
||||
materialize_template(destination, resources)?;
|
||||
let mut next_steps = vec![
|
||||
"Review plugin.toml and generated Rust source.".to_string(),
|
||||
"Replace the placeholder plugin.component.wasm with a real built component before enabling or execution.".to_string(),
|
||||
"Run `yoi plugin check <path>` and then `yoi plugin pack <path>`.".to_string(),
|
||||
];
|
||||
if template == "rust-component-service" {
|
||||
next_steps.insert(
|
||||
1,
|
||||
"Implement Service ingress logic in handle_ingress and return ServiceOutput output_commands for host-owned WebSocket sends.".to_string(),
|
||||
);
|
||||
}
|
||||
materialize_template(destination)?;
|
||||
let report = NewReport {
|
||||
command: "new",
|
||||
template: "rust-component-tool",
|
||||
template: template_name,
|
||||
destination: destination.display().to_string(),
|
||||
files: RUST_COMPONENT_TOOL_TEMPLATE
|
||||
files: resources
|
||||
.iter()
|
||||
.map(|resource| resource.path.to_string())
|
||||
.collect(),
|
||||
safety: AuthoringSafetyReport::default(),
|
||||
next_steps: vec![
|
||||
"Review plugin.toml and generated Rust source.".to_string(),
|
||||
"Replace the placeholder plugin.component.wasm with a real built component before enabling or execution.".to_string(),
|
||||
"Run `yoi plugin check <path>` and then `yoi plugin pack <path>`.".to_string(),
|
||||
],
|
||||
next_steps,
|
||||
};
|
||||
if args.json {
|
||||
return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?));
|
||||
|
|
@ -113,7 +115,23 @@ fn render_new(template: &str, destination: &Path, args: &PluginCliArgs) -> Resul
|
|||
render_new_human(&report)
|
||||
}
|
||||
|
||||
fn materialize_template(destination: &Path) -> Result<()> {
|
||||
fn embedded_template_resources(
|
||||
template: &str,
|
||||
) -> Result<(&'static str, &'static [PluginTemplateResource])> {
|
||||
match template {
|
||||
"rust-component-tool" => Ok(("rust-component-tool", RUST_COMPONENT_TOOL_TEMPLATE)),
|
||||
"rust-component-service" => Ok(("rust-component-service", RUST_COMPONENT_INSTANCE_TEMPLATE)),
|
||||
_ => Err(format!(
|
||||
"unsupported plugin template `{template}` (supported: rust-component-tool, rust-component-service)"
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn materialize_template(
|
||||
destination: &Path,
|
||||
resources: &'static [PluginTemplateResource],
|
||||
) -> Result<()> {
|
||||
match fs::symlink_metadata(destination) {
|
||||
Ok(metadata) => {
|
||||
if metadata.file_type().is_symlink() {
|
||||
|
|
@ -144,7 +162,7 @@ fn materialize_template(destination: &Path) -> Result<()> {
|
|||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
|
||||
for resource in RUST_COMPONENT_TOOL_TEMPLATE {
|
||||
for resource in resources {
|
||||
let relative = safe_template_relative_path(resource.path)?;
|
||||
let path = destination.join(relative);
|
||||
if let Some(parent) = path.parent() {
|
||||
|
|
@ -2136,6 +2154,65 @@ mod tests {
|
|||
let human_check = render_check(&destination, &PluginCliArgs::default()).unwrap();
|
||||
assert!(human_check.contains("[partial]"));
|
||||
assert!(human_check.contains("not ready to enable"));
|
||||
|
||||
let service_destination = dir.path().join("my-service-plugin");
|
||||
let service_json = render_new(
|
||||
"rust-component-service",
|
||||
&service_destination,
|
||||
&PluginCliArgs {
|
||||
json: true,
|
||||
..PluginCliArgs::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let service_value: serde_json::Value = serde_json::from_str(&service_json).unwrap();
|
||||
assert_eq!(service_value["template"], "rust-component-service");
|
||||
assert!(
|
||||
service_value["next_steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|step| step
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("Service ingress"))
|
||||
);
|
||||
for resource in RUST_COMPONENT_INSTANCE_TEMPLATE {
|
||||
assert!(
|
||||
service_destination.join(resource.path).is_file(),
|
||||
"missing service {}",
|
||||
resource.path
|
||||
);
|
||||
}
|
||||
let manifest = fs::read_to_string(service_destination.join("plugin.toml")).unwrap();
|
||||
assert!(manifest.contains("kind = \"wasm-component\""));
|
||||
assert!(manifest.contains("[[services]]"));
|
||||
assert!(manifest.contains("[[ingresses]]"));
|
||||
assert!(manifest.contains("{ kind = \"host_api\", api = \"websocket\" }"));
|
||||
assert!(manifest.contains("[[websocket]]"));
|
||||
assert!(manifest.contains("host = \"example.com\""));
|
||||
assert!(manifest.contains("path_prefixes = [\"/socket\"]"));
|
||||
let source = fs::read_to_string(service_destination.join("src/lib.rs")).unwrap();
|
||||
assert!(source.contains("ServiceOutput::websocket_send"));
|
||||
assert!(!source.contains("recv(timeout"));
|
||||
let service_check = render_check(&service_destination, &PluginCliArgs::default()).unwrap();
|
||||
assert!(service_check.contains("plugin check:"));
|
||||
assert!(service_check.contains("service"));
|
||||
let service_package = dir.path().join("my-service-plugin.yoi-plugin");
|
||||
let service_pack_json = render_pack(
|
||||
&service_destination,
|
||||
Some(&service_package),
|
||||
&PluginCliArgs {
|
||||
json: true,
|
||||
..PluginCliArgs::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let service_pack_value: serde_json::Value =
|
||||
serde_json::from_str(&service_pack_json).unwrap();
|
||||
assert_eq!(service_pack_value["status"], "packed");
|
||||
assert!(service_package.is_file());
|
||||
|
||||
let error = render_new(
|
||||
"rust-component-tool",
|
||||
&destination,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ Yoi's Plugin platform is meant to make extension behavior reviewable before it b
|
|||
|
||||
Keep these layers separate when designing a Plugin. Do not make package discovery imply enablement. Do not make SDK/PDK convenience imply authority. Do not treat Rust helper APIs or host API wrappers as permission grants. The host always re-checks authority at registration/execution/API-call boundaries.
|
||||
|
||||
Yoi's preferred Plugin shape is **Tool first**. A good Tool Plugin has a narrow schema, deterministic input/output behavior, explicit side-effect metadata, and a minimal grant set. Long-running services, inbound events, and autonomous routing are future Service/Ingress work; they should not be hidden inside a Tool package.
|
||||
Yoi's preferred Plugin shapes are **Tool first** for request/response capabilities and **Service/Ingress** for host-dispatched inbound events. A good Tool Plugin has a narrow schema, deterministic input/output behavior, explicit side-effect metadata, and a minimal grant set. A Service Plugin should keep long-lived transport ownership in the host and react to bounded ingress events by returning output commands.
|
||||
|
||||
Component Model authoring is the supported path for Plugins. Legacy raw core-Wasm manifests (`kind = "wasm"` / `abi = "yoi-plugin-wasm-1"`) are retired and rejected by `yoi plugin check`, discovery, `list`, and `show`; use the Rust PDK/template and `kind = "wasm-component"` instead.
|
||||
|
||||
|
|
@ -42,10 +42,10 @@ Implemented foundation:
|
|||
- read-only `yoi plugin list/show` inspection;
|
||||
- local first-party authoring commands: `yoi plugin new`, `yoi plugin check`, and `yoi plugin pack`.
|
||||
|
||||
Still intentionally separate/future work:
|
||||
Still intentionally limited or separate from this guide:
|
||||
|
||||
- multi-language SDK/PDK crates;
|
||||
- Service / Ingress surfaces;
|
||||
- Service / Ingress surfaces, where the host owns transport lifecycle, dispatches bounded ingress events, and consumes output commands such as `websocket_send`;
|
||||
- WebSocket or inbound HTTP for bidirectional external event integrations;
|
||||
- public registry/install/update/signature tooling.
|
||||
|
||||
|
|
@ -78,6 +78,8 @@ Create a Rust Component Tool starter from embedded resources:
|
|||
|
||||
```bash
|
||||
yoi plugin new rust-component-tool ./my-plugin
|
||||
# or, for a host-dispatched Service/Ingress example:
|
||||
yoi plugin new rust-component-service ./my-service-plugin
|
||||
```
|
||||
|
||||
`new` writes only inside the requested destination and refuses an existing non-empty destination or destination symlink. The generated template includes `plugin.toml`, Rust source, Cargo metadata, README next steps, and a placeholder `plugin.component.wasm` artifact so local `check`/`pack` validation can run immediately. Replace the placeholder with a real built component before enabling or executing the Plugin.
|
||||
|
|
@ -115,7 +117,7 @@ For Tool Plugins:
|
|||
- return bounded summaries and content that are useful as Tool results;
|
||||
- avoid hiding long workflows, background daemons, or inbound event handling inside a Tool call.
|
||||
|
||||
A Tool should be a capability the model may choose to call, not a second agent runtime. If the desired behavior needs a long-lived connection, incoming events, or autonomous routing, treat that as future Service/Ingress design rather than stretching the Tool surface.
|
||||
A Tool should be a capability the model may choose to call, not a second agent runtime. If the desired behavior needs a long-lived connection, incoming events, or autonomous routing, put the transport lifecycle behind a Service/Ingress surface and let the host dispatch bounded events; do not stretch the Tool surface into a hidden polling loop.
|
||||
|
||||
Design package permissions as a review surface. A reviewer should be able to read `plugin.toml` plus the enablement grants and understand:
|
||||
|
||||
|
|
@ -163,6 +165,8 @@ Create a starter with:
|
|||
|
||||
```bash
|
||||
yoi plugin new rust-component-tool ./my-plugin
|
||||
# or, for a host-dispatched Service/Ingress example:
|
||||
yoi plugin new rust-component-service ./my-service-plugin
|
||||
```
|
||||
|
||||
The generated package contains:
|
||||
|
|
@ -327,6 +331,62 @@ path_prefixes = ["/v1/"]
|
|||
|
||||
Yoi checks method, scheme, host, optional port, and path prefix against both the manifest declaration and enablement grant before any network I/O. `http://localhost`, loopback, private, and other local targets are never ambient; they require an explicit manifest request target and an explicit matching grant. The explicit request target is the declared URL authority; a granted DNS hostname may resolve to a loopback/private address without requiring a separate literal-IP grant, so reviewers should grant hostnames only when that resolution behavior is intended. Broad targets such as `host = "*"` are supported only as visibly broad request permissions in inspection/diagnostics. Embedded credentials, credential-like headers, oversize requests/responses, WebSocket URLs/upgrades, and SSE/event-stream requests are rejected.
|
||||
|
||||
## Service ingress and output commands
|
||||
|
||||
Service Plugins export the `yoi:plugin/instance@1.0.0` world. The host starts one Plugin instance, owns external ingress transports, and calls `handle_ingress(name, event_json)` with bounded event envelopes. A WebSocket ingress event contains fields such as `kind`, `source`, `ingress_name`, `payload`, `created_at`, `attempt`, and `correlation_id`; the Rust PDK maps this to `PluginIngressEvent`.
|
||||
|
||||
Service handlers return `ServiceOutput`, not ordinary ToolOutput. Side effects are requested through top-level `output_commands`. For a WebSocket reply, use the PDK helper:
|
||||
|
||||
```rust
|
||||
ServiceOutput::websocket_send(
|
||||
&event,
|
||||
"reply-1",
|
||||
event.source.strip_prefix("websocket:").unwrap_or(&event.source),
|
||||
"pong",
|
||||
)
|
||||
```
|
||||
|
||||
This serializes a `websocket_send` command with `source_event_id`, `command_id`, `payload.url`, `payload.text`, and a request timestamp. The host parses, bounds, grant-checks, and dispatches the command through the host-owned WebSocket driver. Do not create a long-running guest receive loop for Service integrations; incoming messages should arrive as ingress events.
|
||||
|
||||
A minimal manifest shape is:
|
||||
|
||||
```toml
|
||||
surfaces = ["tool", "service", "ingress"]
|
||||
permissions = [
|
||||
{ kind = "surface", surface = "service" },
|
||||
{ kind = "service", name = "example_service" },
|
||||
{ kind = "surface", surface = "ingress" },
|
||||
{ kind = "ingress", name = "example_ws" },
|
||||
{ kind = "host_api", api = "websocket" },
|
||||
]
|
||||
|
||||
[runtime]
|
||||
kind = "wasm-component"
|
||||
world = "yoi:plugin/instance@1.0.0"
|
||||
component = "plugin.component.wasm"
|
||||
|
||||
[[services]]
|
||||
name = "example_service"
|
||||
description = "Host-managed service instance."
|
||||
lifecycle = "host-managed"
|
||||
|
||||
[[ingresses]]
|
||||
name = "example_ws"
|
||||
description = "Handles host-owned WebSocket text events."
|
||||
event_kinds = ["websocket_text", "websocket_close", "websocket_error"]
|
||||
sources = ["websocket:wss://gateway.example.com/gateway"]
|
||||
input_schema = { type = "object" }
|
||||
|
||||
[[websocket]]
|
||||
scheme = "wss"
|
||||
host = "gateway.example.com"
|
||||
path_prefixes = ["/gateway"]
|
||||
```
|
||||
|
||||
The `host_api.websocket` permission and `[[websocket]]` target are required for `websocket_send` output commands. Runtime enablement grants must explicitly allow the same WebSocket target; the manifest declaration alone is not authority.
|
||||
|
||||
Generate a fuller example with `yoi plugin new rust-component-service ./my-service-plugin`.
|
||||
|
||||
## `websocket` host API
|
||||
|
||||
The `websocket` host API is a separate grant-gated capability named `host_api.websocket`, not an extension of `host_api.request`. It opens host-owned WebSocket connections only when both the package manifest and enablement config declare matching targets. Tool-style/internal bounded use can still drive the lifecycle explicitly through `open`, `send-text`, `recv`, and `close`; incoming messages are returned only from bounded `recv` calls and are not injected into model context, history, Dashboard state, or Ticket state. Service Plugins should prefer the host-owned Service WebSocket driver instead of running a long-lived guest recv loop: declare a Service ingress source as `websocket:wss://host/path`, include the `websocket_text`/`websocket_close`/`websocket_error` event kinds you want delivered, and emit the Service output command `websocket_send` to send text back through the same grant-checked host connection.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "example-yoi-instance-plugin"
|
||||
name = "yoi-rust-component-service-template"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
# Keep the embedded template checkable in-place without making it a member of
|
||||
# Yoi's root workspace. A copied starter remains a normal standalone package.
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
yoi-plugin-pdk = { path = "../../../../crates/plugin-pdk" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Out-of-tree Plugin packages should replace the local path with a pinned
|
||||
# Yoi source revision. Use rev, not branch, for reproducible builds:
|
||||
# yoi-plugin-pdk = { git = "https://gitea.hareworks.net/Hare/yoi.git", package = "yoi-plugin-pdk", rev = "<pinned-yoi-commit-sha>" }
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
# Yoi instance Plugin template
|
||||
# Rust Service 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.
|
||||
This template targets the Component Model-only runtime (`runtime.kind = "wasm-component"`) and exports the `yoi:plugin/instance@1.0.0` world.
|
||||
|
||||
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.
|
||||
It demonstrates both authoring surfaces supported by a shared Plugin instance:
|
||||
|
||||
- `example_echo` is an ordinary request/response Tool handler.
|
||||
- `example_ws` is a Service ingress handler. The host owns WebSocket receive/reconnect work and dispatches bounded `websocket_text` events into `handle_ingress`. The guest replies by returning a `websocket_send` output command in `ServiceOutput`; do not run a guest-side `recv(timeout)` polling loop. The manifest declares `host_api.websocket` plus a matching `[[websocket]]` target for the example URL. Enablement grants must explicitly allow the same WebSocket target before the host will send output commands.
|
||||
|
||||
Build with `cargo component build --release` (or the project-specific build command used by your Plugin packaging flow), then run `yoi plugin check` / `yoi plugin pack` from the generated Plugin directory.
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
schema_version = 1
|
||||
id = "example.rust_instance_plugin"
|
||||
name = "Rust Instance Plugin Template"
|
||||
id = "example.rust_service_plugin"
|
||||
name = "Rust Service Plugin Template"
|
||||
version = "0.1.0"
|
||||
description = "Example instance-oriented Yoi Plugin with shared Tool/Ingress state."
|
||||
description = "Example Component Model Plugin with Tool and Service ingress handlers."
|
||||
surfaces = ["tool", "service", "ingress"]
|
||||
permissions = [
|
||||
{ kind = "surface", surface = "tool" },
|
||||
{ kind = "tool", name = "example_instance_tool" },
|
||||
{ kind = "tool", name = "example_echo" },
|
||||
{ kind = "surface", surface = "service" },
|
||||
{ kind = "service", name = "example_instance_service" },
|
||||
{ kind = "service", name = "example_service" },
|
||||
{ kind = "surface", surface = "ingress" },
|
||||
{ kind = "ingress", name = "example_instance_ingress" },
|
||||
{ kind = "ingress", name = "example_ws" },
|
||||
{ kind = "host_api", api = "websocket" },
|
||||
]
|
||||
|
||||
[runtime]
|
||||
|
|
@ -19,17 +20,23 @@ 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."
|
||||
name = "example_echo"
|
||||
description = "Echo input text through the shared Plugin instance."
|
||||
input_schema = { type = "object" }
|
||||
|
||||
[[services]]
|
||||
name = "example_instance_service"
|
||||
description = "Reports shared plugin instance lifecycle status."
|
||||
name = "example_service"
|
||||
description = "Host-managed service instance for bounded ingress events."
|
||||
lifecycle = "host-managed"
|
||||
|
||||
[[ingresses]]
|
||||
name = "example_instance_ingress"
|
||||
description = "Accepts bounded in-process ingress events."
|
||||
event_kinds = ["example"]
|
||||
name = "example_ws"
|
||||
description = "Handles host-owned WebSocket text events and returns websocket_send output commands."
|
||||
event_kinds = ["websocket_text"]
|
||||
sources = ["websocket:wss://example.com/socket"]
|
||||
input_schema = { type = "object" }
|
||||
|
||||
[[websocket]]
|
||||
scheme = "wss"
|
||||
host = "example.com"
|
||||
path_prefixes = ["/socket"]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use yoi_plugin_pdk::wit_bindgen;
|
||||
use yoi_plugin_pdk::{export_plugin_instance, Plugin, PluginIngressEvent, PluginStatus, ToolOutput};
|
||||
use yoi_plugin_pdk::{
|
||||
export_plugin_instance, Plugin, PluginIngressEvent, PluginStatus, ServiceOutput, ToolError,
|
||||
ToolOutput,
|
||||
};
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "instance",
|
||||
|
|
@ -9,24 +13,42 @@ wit_bindgen::generate!({
|
|||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
|
||||
#[derive(Default)]
|
||||
struct ExamplePlugin {
|
||||
calls: u64,
|
||||
count: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EchoInput {
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EchoOutput {
|
||||
text: String,
|
||||
count: u64,
|
||||
}
|
||||
|
||||
impl Plugin for ExamplePlugin {
|
||||
fn start(_config: Value) -> yoi_plugin_pdk::Result<Self> {
|
||||
Ok(Self { calls: 0 })
|
||||
fn start(config: Value) -> Result<Self, ToolError> {
|
||||
Ok(Self {
|
||||
count: config.get("start_count").and_then(Value::as_u64).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_tool(&mut self, name: &str, input: Value) -> yoi_plugin_pdk::Result<ToolOutput> {
|
||||
self.calls += 1;
|
||||
fn handle_tool(&mut self, name: &str, input: Value) -> Result<ToolOutput, ToolError> {
|
||||
if name != "example_echo" {
|
||||
return Err(ToolError::invalid_input(format!("unknown tool: {name}")));
|
||||
}
|
||||
let input: EchoInput =
|
||||
serde_json::from_value(input).map_err(|err| ToolError::invalid_input(err.to_string()))?;
|
||||
self.count += 1;
|
||||
ToolOutput::json(
|
||||
format!("{name} handled by shared instance"),
|
||||
json!({
|
||||
"tool": name,
|
||||
"calls": self.calls,
|
||||
"input": input
|
||||
}),
|
||||
format!("echoed {} bytes", input.text.len()),
|
||||
EchoOutput {
|
||||
text: input.text,
|
||||
count: self.count,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -34,18 +56,29 @@ impl Plugin for ExamplePlugin {
|
|||
&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
|
||||
}))
|
||||
) -> Result<ServiceOutput, ToolError> {
|
||||
if name != "example_ws" {
|
||||
return Ok(ServiceOutput::accepted(json!({ "ignored": name }))?);
|
||||
}
|
||||
|
||||
fn status(&self) -> yoi_plugin_pdk::Result<PluginStatus> {
|
||||
Ok(PluginStatus::ready(json!({ "calls": self.calls })))
|
||||
let Some(text) = event.websocket_text() else {
|
||||
return Ok(ServiceOutput::accepted(json!({
|
||||
"accepted": true,
|
||||
"kind": event.kind,
|
||||
}))?);
|
||||
};
|
||||
|
||||
self.count += 1;
|
||||
ServiceOutput::websocket_send(
|
||||
&event,
|
||||
format!("example-reply-{}", self.count),
|
||||
event.source.strip_prefix("websocket:").unwrap_or(&event.source),
|
||||
format!("echo({}): {text}", self.count),
|
||||
)
|
||||
}
|
||||
|
||||
fn status(&self) -> Result<PluginStatus, ToolError> {
|
||||
Ok(PluginStatus::ready(json!({ "count": self.count })))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,55 @@ world instance {
|
|||
import yoi:host/websocket@1.0.0;
|
||||
import yoi:host/fs@1.0.0;
|
||||
|
||||
/// Start one host-managed Plugin instance. `config-json` is the opaque
|
||||
/// enablement config copied from the Profile/plugin grant record. The return
|
||||
/// string is PluginStatus JSON: `{ "state": "ready|running|stopped|...",
|
||||
/// "data": <json> }`.
|
||||
export start: func(config-json: string) -> string;
|
||||
|
||||
/// Execute a manifest-declared Tool on the shared instance. `input-json` is
|
||||
/// ordinary Tool input JSON and the return string is ToolOutput JSON.
|
||||
export handle-tool: func(name: string, input-json: string) -> string;
|
||||
|
||||
/// Handle one host-dispatched Service/Ingress event. `event-json` is an
|
||||
/// ingress event envelope with at least:
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "kind": "websocket_text|websocket_close|websocket_error|...",
|
||||
/// "source": "websocket:wss://host/path|...",
|
||||
/// "ingress_name": "manifest_ingress_name",
|
||||
/// "payload": { "text": "..." },
|
||||
/// "created_at": "RFC3339 timestamp",
|
||||
/// "attempt": 1,
|
||||
/// "correlation_id": "host event id"
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// The return string is ServiceOutput JSON. To request host-mediated side
|
||||
/// effects, return top-level `output_commands`, for example:
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "accepted": true,
|
||||
/// "output_commands": [{
|
||||
/// "correlation_id": "command correlation id",
|
||||
/// "source_event_id": "matching ingress correlation_id",
|
||||
/// "command_id": "guest command id",
|
||||
/// "kind": "websocket_send",
|
||||
/// "payload": { "url": "wss://host/path", "text": "reply" },
|
||||
/// "requested_at": "RFC3339 timestamp"
|
||||
/// }]
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Output commands are parsed, bounded, grant-checked, and executed by the
|
||||
/// host. They are not ordinary ToolOutput and do not inject hidden context.
|
||||
export handle-ingress: func(name: string, event-json: string) -> string;
|
||||
|
||||
/// Return PluginStatus JSON for the shared host-managed instance.
|
||||
export status: func() -> string;
|
||||
|
||||
/// Stop the shared instance and return final PluginStatus JSON.
|
||||
export stop: func() -> string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ world tool {
|
|||
import yoi:host/websocket@1.0.0;
|
||||
import yoi:host/fs@1.0.0;
|
||||
|
||||
/// Execute a manifest-declared Tool. `input-json` is the normal Tool input
|
||||
/// JSON and the returned string is the same ToolOutput JSON accepted by the
|
||||
/// legacy raw-Wasm ABI.
|
||||
/// Execute a manifest-declared Tool. `input-json` is ordinary Tool input JSON
|
||||
/// and the returned string is ToolOutput JSON accepted by the current
|
||||
/// Component Model Plugin runtime.
|
||||
export call: func(tool-name: string, input-json: string) -> string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user