feat: deliver committed Ticket events to plugin services
This commit is contained in:
@@ -118,16 +118,20 @@ pub const RUST_COMPONENT_HTTPS_TOOL_TEMPLATE: &[PluginTemplateResource] = &[
|
||||
},
|
||||
];
|
||||
|
||||
/// The only supported v2 surface; future surfaces are not success stubs.
|
||||
/// Exact supported execution surfaces.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginSurface {
|
||||
Tool,
|
||||
Service,
|
||||
}
|
||||
|
||||
impl fmt::Display for PluginSurface {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("tool")
|
||||
f.write_str(match self {
|
||||
Self::Tool => "tool",
|
||||
Self::Service => "service",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +144,7 @@ pub enum PluginRequestedGrant {
|
||||
slot: String,
|
||||
},
|
||||
TicketRead,
|
||||
TicketEvents,
|
||||
TicketDataRead {
|
||||
collection: String,
|
||||
schema_id: String,
|
||||
@@ -255,7 +260,10 @@ pub struct PluginPackageManifest {
|
||||
pub description: Option<String>,
|
||||
pub surfaces: Vec<PluginSurface>,
|
||||
pub runtime: PluginRuntimeManifest,
|
||||
#[serde(default)]
|
||||
pub tools: Vec<PluginToolManifest>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub services: Vec<PluginServiceManifest>,
|
||||
/// Closed static DCDL type expression, not JSON Schema or guest code.
|
||||
pub config_schema: String,
|
||||
#[serde(default)]
|
||||
@@ -272,6 +280,23 @@ impl PluginPackageManifest {
|
||||
}
|
||||
}
|
||||
|
||||
/// Closed version-1 Ticket event vocabulary; arbitrary subscriptions are rejected.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginTicketEventType {
|
||||
TicketChanged,
|
||||
TicketDeleted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginServiceManifest {
|
||||
pub name: String,
|
||||
pub subscriptions: Vec<PluginTicketEventType>,
|
||||
}
|
||||
|
||||
pub const PLUGIN_COMPONENT_SERVICE_WORLD: &str = "yoi:plugin/ticket-service-plugin@2.2.0";
|
||||
|
||||
pub const PLUGIN_COMPONENT_TICKET_TOOL_WORLD: &str = "yoi:plugin/ticket-tool-plugin@2.1.0";
|
||||
|
||||
pub const PLUGIN_RUNTIME_COMPONENT_KIND: &str = "wasm-component";
|
||||
@@ -1047,10 +1072,43 @@ fn validate_manifest(
|
||||
"safe id, name and version are required".into(),
|
||||
));
|
||||
}
|
||||
if manifest.surfaces != [PluginSurface::Tool] || manifest.tools.is_empty() {
|
||||
let service_world = manifest.runtime.world == PLUGIN_COMPONENT_SERVICE_WORLD;
|
||||
if if service_world {
|
||||
manifest.surfaces != [PluginSurface::Service]
|
||||
|| manifest.services.is_empty()
|
||||
|| !manifest.tools.is_empty()
|
||||
} else {
|
||||
manifest.surfaces != [PluginSurface::Tool]
|
||||
|| manifest.tools.is_empty()
|
||||
|| !manifest.services.is_empty()
|
||||
} {
|
||||
return Err(reject(
|
||||
PluginDiagnosticKind::Surface,
|
||||
"only nonempty Tool packages are supported".into(),
|
||||
"surface and nonempty declarations must match the exact Tool or Service world".into(),
|
||||
));
|
||||
}
|
||||
let mut service_names = BTreeSet::new();
|
||||
for service in &manifest.services {
|
||||
if !is_safe_id(&service.name)
|
||||
|| !service_names.insert(&service.name)
|
||||
|| service.subscriptions.is_empty()
|
||||
|| service.subscriptions.iter().collect::<BTreeSet<_>>().len()
|
||||
!= service.subscriptions.len()
|
||||
{
|
||||
return Err(reject(
|
||||
PluginDiagnosticKind::Malformed,
|
||||
"invalid or duplicate Service/subscription".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if service_world
|
||||
&& !manifest
|
||||
.requested_grants
|
||||
.contains(&PluginRequestedGrant::TicketEvents)
|
||||
{
|
||||
return Err(reject(
|
||||
PluginDiagnosticKind::Api,
|
||||
"Service requires TicketEvents request".into(),
|
||||
));
|
||||
}
|
||||
let runtime = &manifest.runtime;
|
||||
@@ -1114,7 +1172,7 @@ fn validate_manifest(
|
||||
));
|
||||
}
|
||||
}
|
||||
let ticket_world = runtime.world == PLUGIN_COMPONENT_TICKET_TOOL_WORLD;
|
||||
let ticket_world = runtime.world == PLUGIN_COMPONENT_TICKET_TOOL_WORLD || service_world;
|
||||
if !ticket_world
|
||||
&& (!manifest.data_collections.is_empty()
|
||||
|| manifest.requested_grants.iter().any(|g| {
|
||||
@@ -1128,7 +1186,7 @@ fn validate_manifest(
|
||||
{
|
||||
return Err(reject(
|
||||
PluginDiagnosticKind::Api,
|
||||
"Ticket capabilities require the exact @2.1.0 Ticket world".into(),
|
||||
"Ticket capabilities require an exact Ticket world".into(),
|
||||
));
|
||||
}
|
||||
let mut collections = BTreeSet::new();
|
||||
@@ -1154,6 +1212,8 @@ fn validate_manifest(
|
||||
PluginRequestedGrant::Logging if !logging => logging = true,
|
||||
PluginRequestedGrant::Https { slot }
|
||||
if slots.contains(slot) && requested_slots.insert(slot) => {}
|
||||
PluginRequestedGrant::TicketEvents
|
||||
if service_world && ticket_grants.insert(("events", "", "", "")) => {}
|
||||
PluginRequestedGrant::TicketRead
|
||||
if ticket_world && ticket_grants.insert(("read", "", "", "")) => {}
|
||||
PluginRequestedGrant::TicketDataRead {
|
||||
|
||||
@@ -37,6 +37,7 @@ pub fn validate_component(
|
||||
"yoi:plugin/logged-tool-plugin@2.0.0" => "logged-tool-plugin",
|
||||
"yoi:plugin/https-logged-tool-plugin@2.0.0" => "https-logged-tool-plugin",
|
||||
"yoi:plugin/ticket-tool-plugin@2.1.0" => "ticket-tool-plugin",
|
||||
"yoi:plugin/ticket-service-plugin@2.2.0" => "ticket-service-plugin",
|
||||
_ => return Err(format!("unsupported Component world `{world}`")),
|
||||
};
|
||||
if !wasmparser::Parser::is_component(bytes) {
|
||||
@@ -111,7 +112,7 @@ fn contract(world_name: &str) -> Result<(Resolve, WorldId), String> {
|
||||
let old_package = resolve
|
||||
.push_str("yoi-plugin-v2.wit", PLUGIN_WIT)
|
||||
.map_err(|error| format!("invalid embedded Plugin WIT: {error:#}"))?;
|
||||
let package = if world_name == "ticket-tool-plugin" {
|
||||
let package = if matches!(world_name, "ticket-tool-plugin" | "ticket-service-plugin") {
|
||||
resolve
|
||||
.push_str(
|
||||
"yoi-host-v2.1.wit",
|
||||
@@ -129,6 +130,16 @@ fn contract(world_name: &str) -> Result<(Resolve, WorldId), String> {
|
||||
} else {
|
||||
old_package
|
||||
};
|
||||
let package = if world_name == "ticket-service-plugin" {
|
||||
resolve
|
||||
.push_str(
|
||||
"yoi-plugin-v2.2.wit",
|
||||
include_str!("../../../../resources/plugin/wit-service/yoi-plugin-v2.2.wit"),
|
||||
)
|
||||
.map_err(|e| format!("invalid Service WIT: {e:#}"))?
|
||||
} else {
|
||||
package
|
||||
};
|
||||
let world = resolve
|
||||
.select_world(&[package], Some(world_name))
|
||||
.map_err(|error| format!("invalid embedded Plugin world: {error:#}"))?;
|
||||
|
||||
@@ -36,6 +36,10 @@ pub struct PluginProfileSelection {
|
||||
pub installation_id: String,
|
||||
#[serde(default)]
|
||||
pub tools: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub services: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub ticket_events: bool,
|
||||
#[serde(default)]
|
||||
pub logging: bool,
|
||||
#[serde(default)]
|
||||
@@ -281,3 +285,27 @@ strict = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod service_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn service_selection_is_explicit_and_old_profiles_remain_off() {
|
||||
let old: PluginProfileSelection =
|
||||
serde_json::from_value(json!({"installation_id":"i", "tools":["read"]})).unwrap();
|
||||
assert!(old.services.is_empty());
|
||||
assert!(!old.ticket_events);
|
||||
let retained = serde_json::to_value(&old).unwrap();
|
||||
assert!(retained.get("services").is_none());
|
||||
assert!(retained.get("ticket_events").is_none());
|
||||
let selected: PluginProfileSelection = serde_json::from_value(
|
||||
json!({"installation_id":"i", "services":["watch"], "ticket_events":true}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(selected.services, ["watch"]);
|
||||
assert!(selected.ticket_events);
|
||||
assert_ne!(serde_json::to_value(selected).unwrap(), retained);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# plugin-pdk
|
||||
|
||||
Guest-side Rust helpers for the versioned Component Model Tool contract. [`ToolContext`] decodes only non-secret invocation data; [`ToolOutput`] rejects oversized data and separates structured output from presentation text. [`export_component_tool!`] adapts generated WIT bindings and preserves the result's error arm.
|
||||
Guest-side Rust helpers for the versioned Component Model Tool and Service contracts. [`ToolContext`] decodes only non-secret invocation data; [`ToolOutput`] rejects oversized data and separates structured output from presentation text. [`export_component_tool!`] adapts generated WIT bindings and preserves the result's error arm.
|
||||
|
||||
Host authorization, schema validation, effect receipts and execution belong to the Host, not this crate. The PDK does not import WASI, implement a Host, or provide Service/event/Workdir/WebSocket stubs. Old v1 packages require an explicit v2 rebuild rather than a compatibility adapter.
|
||||
Host authorization, schema validation, effect receipts and execution belong to the Host, not this crate. [`export_component_service!`] adapts authored lifecycle/event handlers to the separate exact Service world without a guest task loop. The PDK does not import WASI, implement a Host, or provide Workdir/WebSocket stubs. Old v1 packages require an explicit v2 rebuild rather than a compatibility adapter.
|
||||
|
||||
@@ -18,6 +18,9 @@ pub const TICKET_TOOL_WIT: &str =
|
||||
include_str!("../../../resources/plugin/wit-ticket/yoi-plugin-v2.1.wit");
|
||||
pub const TICKET_HOST_WIT: &str =
|
||||
include_str!("../../../resources/plugin/wit-ticket/deps/yoi-host-v2.1/yoi-host-v2.1.wit");
|
||||
pub const SERVICE_WORLD: &str = "yoi:plugin/ticket-service-plugin@2.2.0";
|
||||
pub const SERVICE_WIT: &str =
|
||||
include_str!("../../../resources/plugin/wit-service/yoi-plugin-v2.2.wit");
|
||||
pub const MAX_EXTENSION_DATA_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Encode a cell without implicit mutation retries or schema conversion.
|
||||
@@ -361,3 +364,40 @@ mod tests {
|
||||
assert!(error.message().ends_with('語'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind authored Service handlers to the generated ABI without a guest task loop.
|
||||
/// Each handler runs in fresh memory; recovery state belongs to Host data/receipts.
|
||||
#[macro_export]
|
||||
macro_rules! export_component_service {
|
||||
($adapter:ident, $start:path, $event:path, $stop:path) => {
|
||||
struct $adapter;
|
||||
impl exports::yoi::plugin::service::Guest for $adapter {
|
||||
fn on_start(
|
||||
config: String,
|
||||
) -> ::std::result::Result<(), exports::yoi::plugin::service::ServiceError> {
|
||||
$start(config).map_err(|e: $crate::ToolError| {
|
||||
exports::yoi::plugin::service::ServiceError {
|
||||
code: e.code_str().into(),
|
||||
message: e.message().into(),
|
||||
}
|
||||
})
|
||||
}
|
||||
fn on_event(
|
||||
input: exports::yoi::plugin::service::Envelope,
|
||||
) -> exports::yoi::plugin::service::EventResult {
|
||||
$event(input)
|
||||
}
|
||||
fn on_stop(
|
||||
reason: String,
|
||||
) -> ::std::result::Result<(), exports::yoi::plugin::service::ServiceError> {
|
||||
$stop(reason).map_err(|e: $crate::ToolError| {
|
||||
exports::yoi::plugin::service::ServiceError {
|
||||
code: e.code_str().into(),
|
||||
message: e.message().into(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
export!($adapter);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
use yoi_plugin_pdk::{ToolError, wit_bindgen};
|
||||
wit_bindgen::generate!({
|
||||
world: "ticket-service-plugin",
|
||||
path: "../../resources/plugin/wit-service",
|
||||
generate_all,
|
||||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
fn start(config: String) -> Result<(), ToolError> {
|
||||
if config == "invalid" {
|
||||
Err(ToolError::invalid_input("bad config"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn event(_: exports::yoi::plugin::service::Envelope) -> exports::yoi::plugin::service::EventResult {
|
||||
exports::yoi::plugin::service::EventResult::RetryAfter(20)
|
||||
}
|
||||
fn stop(_: String) -> Result<(), ToolError> {
|
||||
Ok(())
|
||||
}
|
||||
yoi_plugin_pdk::export_component_service!(ServiceProbe, start, event, stop);
|
||||
|
||||
#[test]
|
||||
fn service_macro_preserves_lifecycle_error_arm() {
|
||||
use exports::yoi::plugin::service::Guest;
|
||||
assert_eq!(
|
||||
ServiceProbe::on_start("invalid".into()).unwrap_err().code,
|
||||
"invalid_input"
|
||||
);
|
||||
ServiceProbe::on_stop("shutdown".into()).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn service_dependencies_preserve_exact_older_pins() {
|
||||
assert_eq!(
|
||||
yoi_plugin_pdk::HOST_WIT,
|
||||
include_str!("../../../resources/plugin/wit-service/deps/yoi-host-v2/yoi-host-v2.wit")
|
||||
);
|
||||
assert_eq!(
|
||||
yoi_plugin_pdk::TICKET_HOST_WIT,
|
||||
include_str!("../../../resources/plugin/wit-service/deps/yoi-host-v2.1/yoi-host-v2.1.wit")
|
||||
);
|
||||
assert_eq!(
|
||||
yoi_plugin_pdk::SERVICE_WORLD,
|
||||
"yoi:plugin/ticket-service-plugin@2.2.0"
|
||||
);
|
||||
assert_eq!(
|
||||
yoi_plugin_pdk::SERVICE_WIT,
|
||||
include_str!("../../../resources/plugin/wit-service/yoi-plugin-v2.2.wit")
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ pub struct ArtifactIdentityProjection {
|
||||
pub world: String,
|
||||
pub required_imports: Vec<String>,
|
||||
pub tools: Vec<String>,
|
||||
pub services: Vec<String>,
|
||||
pub connections: Vec<String>,
|
||||
pub requested_grants: Vec<PluginGrant>,
|
||||
pub config_schema: PluginSchemaIdentity,
|
||||
@@ -89,6 +90,7 @@ pub struct BindingPolicyReference {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginSurface {
|
||||
Tool,
|
||||
Service,
|
||||
}
|
||||
|
||||
/// Exact grant identities; new operations are never implied by old pins.
|
||||
@@ -100,6 +102,7 @@ pub enum PluginGrant {
|
||||
slot: String,
|
||||
},
|
||||
TicketRead,
|
||||
TicketEvents,
|
||||
TicketDataRead {
|
||||
collection: String,
|
||||
schema_id: String,
|
||||
@@ -130,6 +133,8 @@ pub struct PluginPinProjection {
|
||||
pub config: Value,
|
||||
pub surfaces: Vec<PluginSurface>,
|
||||
pub tools: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub services: Vec<String>,
|
||||
pub grants: Vec<PluginGrant>,
|
||||
pub bindings: Vec<BindingPolicyReference>,
|
||||
}
|
||||
@@ -178,6 +183,7 @@ impl WorkerPluginPin {
|
||||
| plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD
|
||||
| plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD
|
||||
| plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD
|
||||
| plugin::PLUGIN_COMPONENT_SERVICE_WORLD
|
||||
)
|
||||
|| !valid_digest(&artifact.config_schema.digest)
|
||||
|| !valid_identity(&artifact.package_id)
|
||||
@@ -193,6 +199,8 @@ impl WorkerPluginPin {
|
||||
projection.surfaces.dedup();
|
||||
projection.tools.sort();
|
||||
projection.tools.dedup();
|
||||
projection.services.sort();
|
||||
projection.services.dedup();
|
||||
projection.grants.sort();
|
||||
projection.grants.dedup();
|
||||
projection.bindings.sort();
|
||||
@@ -201,6 +209,16 @@ impl WorkerPluginPin {
|
||||
|| projection.tools.iter().any(|name| {
|
||||
!valid_identity(name) || !artifact.tools.iter().any(|tool| *tool == *name)
|
||||
})
|
||||
|| (!projection.services.is_empty()
|
||||
&& !projection.surfaces.contains(&PluginSurface::Service))
|
||||
|| (projection.surfaces.contains(&PluginSurface::Service)
|
||||
&& (projection.services.is_empty()
|
||||
|| !projection.grants.contains(&PluginGrant::TicketEvents)))
|
||||
|| projection.services.iter().any(|name| {
|
||||
!valid_identity(name)
|
||||
|| !artifact.services.contains(name)
|
||||
|| artifact.world != plugin::PLUGIN_COMPONENT_SERVICE_WORLD
|
||||
})
|
||||
|| projection
|
||||
.bindings
|
||||
.windows(2)
|
||||
@@ -218,8 +236,11 @@ impl WorkerPluginPin {
|
||||
PluginGrant::TicketRead
|
||||
| PluginGrant::TicketDataRead { .. }
|
||||
| PluginGrant::TicketDataWrite { .. } => {
|
||||
artifact.world != plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD
|
||||
|| !artifact.requested_grants.contains(grant)
|
||||
!matches!(
|
||||
artifact.world.as_str(),
|
||||
plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD
|
||||
| plugin::PLUGIN_COMPONENT_SERVICE_WORLD
|
||||
) || !artifact.requested_grants.contains(grant)
|
||||
|| match grant {
|
||||
PluginGrant::TicketDataRead {
|
||||
collection,
|
||||
@@ -236,6 +257,11 @@ impl WorkerPluginPin {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
PluginGrant::TicketEvents => {
|
||||
artifact.world != plugin::PLUGIN_COMPONENT_SERVICE_WORLD
|
||||
|| !artifact.requested_grants.contains(grant)
|
||||
|| projection.services.is_empty()
|
||||
}
|
||||
PluginGrant::Logging => !artifact.requested_grants.contains(&PluginGrant::Logging),
|
||||
PluginGrant::Https { slot } => {
|
||||
!valid_identity(slot)
|
||||
|
||||
@@ -15,6 +15,7 @@ fn artifact() -> ArtifactIdentityProjection {
|
||||
schema_version: 2,
|
||||
world: plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD.into(),
|
||||
required_imports: vec!["logging".into(), "https".into(), "logging".into()],
|
||||
services: vec![],
|
||||
tools: vec!["other".into(), "echo".into()],
|
||||
connections: vec!["api".into()],
|
||||
requested_grants: vec![
|
||||
@@ -35,6 +36,7 @@ fn projection() -> PluginPinProjection {
|
||||
PluginPinProjection {
|
||||
config: serde_json::from_str(r#"{"z":{"b":2,"a":1},"a":[{"z":0,"a":false}]}"#).unwrap(),
|
||||
surfaces: vec![PluginSurface::Tool, PluginSurface::Tool],
|
||||
services: vec![],
|
||||
tools: vec!["other".into(), "echo".into(), "other".into()],
|
||||
grants: vec![
|
||||
PluginGrant::Https { slot: "api".into() },
|
||||
@@ -238,3 +240,47 @@ fn ticket_grants_require_exact_world_request_and_schema_namespace() {
|
||||
a.requested_grants.retain(|g| g != &write);
|
||||
assert_eq!(exact.matches_artifact(&a), Err(PlanError::Invalid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_selection_needs_exact_new_world_declared_name_and_explicit_event_grant() {
|
||||
let mut artifact = artifact();
|
||||
let mut selected = projection();
|
||||
selected.tools.clear();
|
||||
selected.bindings.clear();
|
||||
selected.grants = vec![PluginGrant::TicketEvents];
|
||||
selected.surfaces = vec![PluginSurface::Service];
|
||||
selected.services = vec!["watch".into()];
|
||||
artifact.services = vec!["watch".into()];
|
||||
artifact.requested_grants.push(PluginGrant::TicketEvents);
|
||||
assert_eq!(pin(&artifact, selected.clone()), Err(PlanError::Invalid));
|
||||
artifact.world = plugin::PLUGIN_COMPONENT_SERVICE_WORLD.into();
|
||||
let exact = pin(&artifact, selected.clone()).unwrap();
|
||||
selected.services.push("undeclared".into());
|
||||
assert_eq!(pin(&artifact, selected), Err(PlanError::Invalid));
|
||||
artifact.services.clear();
|
||||
assert_eq!(exact.matches_artifact(&artifact), Err(PlanError::Invalid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_serialized_pin_does_not_acquire_service_selection_or_event_grant() {
|
||||
let plan: WorkerPluginExecutionPlan =
|
||||
serde_json::from_str(include_str!("fixtures/canonical-plan.json")).unwrap();
|
||||
assert!(plan.plugins()[0].projection().services.is_empty());
|
||||
assert!(
|
||||
!plan.plugins()[0]
|
||||
.projection()
|
||||
.surfaces
|
||||
.contains(&PluginSurface::Service)
|
||||
);
|
||||
assert!(
|
||||
!plan.plugins()[0]
|
||||
.projection()
|
||||
.grants
|
||||
.contains(&PluginGrant::TicketEvents)
|
||||
);
|
||||
// Additive default-off fields cannot mutate the exact old plan fingerprint.
|
||||
assert_eq!(
|
||||
plan.fingerprint().unwrap(),
|
||||
"sha256:00ee15d79098f4afa137da5d4b0b9b78acde48a6157fdda0f3327d8c122bd889"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# yoi-plugin-runtime
|
||||
|
||||
Runtime-owned verification, byte caching and standalone execution of exact v2 Tool Components. [`Executor`] compiles only artifacts reverified through `manifest::plugin`, then creates a fresh Store and instance for each invocation. It does not resolve catalogs, config, Worker plans or repository paths, and does not register Tools with a Worker.
|
||||
Runtime-owned verification, byte caching and standalone execution of exact versioned Tool and Service Components. [`Executor`] compiles only artifacts reverified through `manifest::plugin`, then creates a fresh Store and instance for each invocation. It does not resolve catalogs, config, Worker plans or repository paths, and does not register Tools with a Worker.
|
||||
|
||||
[`delivery`] caches canonical bytes under the authenticated Server authority and artifact digest. Its source adapter must reauthorize the exact saved plan selection on every hit and after transfer. Byte deduplication shares neither metadata nor installation/config/grant authority. A private Runtime directory, bounded staging, process-released locking and atomic publication prevent partial files from becoming executor inputs; abandoned staging is discarded under the same lock. Cache deletion never releases Server pins. The cache is not an offline execution lease, and verified bytes still require the execution Host's fresh admission.
|
||||
|
||||
[`host`] is an explicit asynchronous injection boundary, not an implementation of Server authority or HTTPS. Every invocation (including pure computation) requires live admission and result-publication authorization. Providers must bind their authenticated installation/plan/subject and narrowed grants to [`host::InvocationContext`], authorize every protected operation, and make pending work cancellation-safe. There is no default provider or product allow-all fallback. Dropping a provider future must release temporary resources; an already dispatched external effect is not rolled back. Durable effect receipts, secret resolution, URL/DNS/TLS policy and real network transport belong to the Host provider, not this sandbox.
|
||||
|
||||
Component instantiation is not a Tool export: all Host imports, including logging, are rejected until the export context is installed. Service start/stop semantics are outside this crate. Only exact WIT imports are linked, without WASI, filesystem, environment, process, clock or network ambient authority. Call-local connection resources hold opaque provider objects; guest resource representations never identify a Server resource or issue a grant.
|
||||
Component instantiation is not an export: all Host imports, including logging, are rejected until the export context is installed. Service start/stop exports permit only logging; event exports reuse the same Host and Ticket/data resources as Tools. [`ServiceInvocationResult`] retains Host effect observations independently of guest acknowledgement, including a dispatch whose wait is cancelled. This observation is not a second receipt authority: the driver must pause on unknown effects and commit acknowledgement through its owner. Only exact WIT imports are linked, without WASI, filesystem, environment, process, clock or network ambient authority. Call-local connection resources hold opaque provider objects; guest resource representations never identify a Server resource or issue a grant.
|
||||
|
||||
A single deadline covers admission, instantiation, guest execution, asynchronous Host waits, post-return and publication. Fuel bounds computation and cooperatively yields; epoch interruption cooperatively yields as well. Store memory/table/instance limits, call-local resource and operation counts, serialized payload and diagnostic budgets complement those bounds. Text/JSON overflows fail rather than returning truncated success; excess diagnostic entries are dropped and counted. Guest diagnostics are untrusted, must not contain secrets, and are separate from model output. Host adapters must not log raw request/response or credentials.
|
||||
|
||||
The Tool sandbox accepts only imported Host resources. Wasmtime's Store limiter does not meter guest-defined Component resource tables, so compile rejects private resource definitions and canonical `resource.new` throughout concrete nested Components before any Store exists. This is an additional execution-policy restriction after shared package/ABI verification, not a second package validator. It preserves the WIT connection imports, whose creation is metered by the adapter, without claiming a quota for unmetered internal resource allocators. Unsupported execution policy is distinct from corrupt artifact identity.
|
||||
The sandbox accepts only imported Host resources. Wasmtime's Store limiter does not meter guest-defined Component resource tables, so compile rejects private resource definitions and canonical `resource.new` throughout concrete nested Components before any Store exists. This is an additional execution-policy restriction after shared package/ABI verification, not a second package validator. It preserves the WIT connection imports, whose creation is metered by the adapter, without claiming a quota for unmetered internal resource allocators. Unsupported execution policy is distinct from corrupt artifact identity.
|
||||
|
||||
Compile results may be shared, but invocation memory, resource tables and imported state are never shared. The caller owns finite aggregate admission/concurrency quotas; per-invocation limits alone do not bound an unbounded number of callers. A validated digest is content identity, never admission authority. This standalone core is not connected to normal Worker launch/restore; product integration must supply the live Server provider.
|
||||
Compile results may be shared, but invocation memory, resource tables and imported state are never shared. The caller owns finite aggregate admission/concurrency quotas; per-invocation limits alone do not bound an unbounded number of callers. A validated digest is content identity, never admission authority. Product integration must supply the live Server provider and owns Worker launch/restore and Service driver lifetime.
|
||||
|
||||
@@ -358,6 +358,7 @@ impl ArtifactCache {
|
||||
|| d.world != m.runtime.world
|
||||
|| d.required_imports != m.runtime.required_imports
|
||||
|| !same_json(&d.tools, &m.tools)?
|
||||
|| !same_json(&d.services, &m.services)?
|
||||
|| !same_json(&d.connections, &m.connections)?
|
||||
|| !same_json(&d.requested_grants, &m.requested_grants)?
|
||||
|| !same_json(&d.data_collections, &m.data_collections)?
|
||||
|
||||
@@ -44,6 +44,7 @@ fn fixture() -> (CanonicalPluginArtifact, PluginArtifact) {
|
||||
world: m.runtime.world,
|
||||
required_imports: m.runtime.required_imports,
|
||||
tools: serde_json::from_value(serde_json::to_value(m.tools).unwrap()).unwrap(),
|
||||
services: vec![],
|
||||
connections: vec![],
|
||||
requested_grants: vec![],
|
||||
data_collections: vec![],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
mod service;
|
||||
pub use service::{CompiledService, ServiceInvocationResult, ServiceOutput};
|
||||
mod ticket;
|
||||
use crate::sandbox::SandboxLimits;
|
||||
use crate::{
|
||||
@@ -283,6 +285,8 @@ impl Executor {
|
||||
log_bytes: 0,
|
||||
dropped_logs: 0,
|
||||
store_limits: SandboxLimits::new(limits),
|
||||
logging_only: false,
|
||||
effects: Arc::new(service::Effects::default()),
|
||||
};
|
||||
let mut store = Store::new(&self.engine, state);
|
||||
store.limiter(|state| &mut state.store_limits);
|
||||
@@ -452,6 +456,8 @@ struct State {
|
||||
log_bytes: usize,
|
||||
dropped_logs: u64,
|
||||
store_limits: SandboxLimits,
|
||||
logging_only: bool,
|
||||
effects: Arc<service::Effects>,
|
||||
}
|
||||
fn check_current(context: &InvocationContext) -> Result<(), errors::HostError> {
|
||||
if context.cancellation.is_cancelled() {
|
||||
@@ -470,10 +476,19 @@ impl State {
|
||||
let context = self
|
||||
.context
|
||||
.as_ref()
|
||||
.ok_or_else(|| wasmtime::Error::msg("Host import outside Tool export context"))?;
|
||||
.ok_or_else(|| wasmtime::Error::msg("Host import outside export context"))?;
|
||||
check_current(context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?;
|
||||
Ok(context.clone())
|
||||
}
|
||||
fn protected_current(&self) -> wasmtime::Result<InvocationContext> {
|
||||
let context = self.current()?;
|
||||
if self.logging_only {
|
||||
return Err(wasmtime::Error::msg(
|
||||
"Service lifecycle permits logging only",
|
||||
));
|
||||
}
|
||||
Ok(context)
|
||||
}
|
||||
fn operation(&mut self) -> Result<(), errors::HostError> {
|
||||
if self.operations >= self.limits.host_operations {
|
||||
return Err(host::error(
|
||||
@@ -498,7 +513,7 @@ impl bindings::yoi::host::connections::Host for State {
|
||||
&mut self,
|
||||
slot: String,
|
||||
) -> wasmtime::Result<Result<Resource<Connection>, errors::HostError>> {
|
||||
let context = self.current()?;
|
||||
let context = self.protected_current()?;
|
||||
if let Err(e) = self.operation() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
@@ -539,7 +554,7 @@ impl bindings::yoi::host::http::Host for State {
|
||||
target: Resource<Connection>,
|
||||
mut input: http::Request,
|
||||
) -> wasmtime::Result<Result<http::Response, errors::HostError>> {
|
||||
let context = self.current()?;
|
||||
let context = self.protected_current()?;
|
||||
if let Err(e) = self.operation() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
@@ -628,6 +643,7 @@ impl bindings::yoi::host::http::Host for State {
|
||||
"HTTP deadline exceeded before dispatch",
|
||||
)));
|
||||
}
|
||||
let effect = self.effects.begin();
|
||||
let response = match tokio::time::timeout_at(
|
||||
send_deadline,
|
||||
self.provider.send(&context, binding.as_ref(), input),
|
||||
@@ -642,6 +658,7 @@ impl bindings::yoi::host::http::Host for State {
|
||||
effect: errors::EffectObservation::Indeterminate,
|
||||
}),
|
||||
};
|
||||
effect.finish(&response);
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("late HTTP result"))?;
|
||||
Ok(response.and_then(|response| {
|
||||
if response.body.len() > self.limits.http_body_bytes
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
use super::*;
|
||||
use crate::service_bindings::{self, exports::yoi::plugin::service as abi};
|
||||
use manifest::plugin::{PluginServiceManifest, PluginTicketEventType};
|
||||
use server_api::{PluginServiceEventResult, PluginTicketEventEnvelope};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
/// Shares compiled bytes and the existing Host/resource implementation, not authority.
|
||||
pub struct CompiledService(CompiledTool);
|
||||
impl CompiledService {
|
||||
pub fn identity(&self) -> &ArtifactIdentity {
|
||||
self.0.identity()
|
||||
}
|
||||
pub fn services(&self) -> &[PluginServiceManifest] {
|
||||
&self.0.manifest.services
|
||||
}
|
||||
}
|
||||
|
||||
/// Guest acknowledgement is only a proposal. The driver must persist the cursor
|
||||
/// through its owner and pause on an indeterminate Host effect, even on guest ack.
|
||||
#[derive(Debug)]
|
||||
pub struct ServiceInvocationResult {
|
||||
pub outcome: Result<ServiceOutput, ExecutionError>,
|
||||
pub effect: errors::EffectObservation,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub enum ServiceOutput {
|
||||
Started,
|
||||
Event(PluginServiceEventResult),
|
||||
Stopped,
|
||||
}
|
||||
|
||||
enum Call<'a> {
|
||||
Start(&'a str),
|
||||
Event {
|
||||
envelope: &'a PluginTicketEventEnvelope,
|
||||
config: &'a str,
|
||||
},
|
||||
Stop(&'a str),
|
||||
}
|
||||
|
||||
/// Kept outside the Store so cancellation, timeout and traps cannot erase a
|
||||
/// dispatched-but-unobserved effect. This is observation, not a receipt ledger.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Effects {
|
||||
pending: AtomicUsize,
|
||||
unknown: AtomicBool,
|
||||
response: AtomicBool,
|
||||
}
|
||||
impl Effects {
|
||||
pub(super) fn begin(self: &Arc<Self>) -> EffectWait {
|
||||
self.pending.fetch_add(1, Ordering::SeqCst);
|
||||
EffectWait {
|
||||
effects: self.clone(),
|
||||
completed: false,
|
||||
}
|
||||
}
|
||||
pub(super) fn unresolved(&self) {
|
||||
self.unknown.store(true, Ordering::SeqCst);
|
||||
}
|
||||
pub(super) fn observe(&self, error: &errors::HostError) {
|
||||
if matches!(error.effect, errors::EffectObservation::Indeterminate)
|
||||
|| matches!(
|
||||
error.code,
|
||||
errors::Code::Indeterminate | errors::Code::ReceiptExpired
|
||||
)
|
||||
{
|
||||
self.unknown.store(true, Ordering::SeqCst);
|
||||
}
|
||||
if matches!(error.effect, errors::EffectObservation::ResponseObserved) {
|
||||
self.response.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
fn snapshot(&self) -> errors::EffectObservation {
|
||||
if self.unknown.load(Ordering::SeqCst) || self.pending.load(Ordering::SeqCst) != 0 {
|
||||
errors::EffectObservation::Indeterminate
|
||||
} else if self.response.load(Ordering::SeqCst) {
|
||||
errors::EffectObservation::ResponseObserved
|
||||
} else {
|
||||
errors::EffectObservation::NotDispatched
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) struct EffectWait {
|
||||
effects: Arc<Effects>,
|
||||
completed: bool,
|
||||
}
|
||||
impl EffectWait {
|
||||
pub(super) fn finish<T>(mut self, result: &Result<T, errors::HostError>) {
|
||||
match result {
|
||||
Ok(_) => {
|
||||
self.effects.response.store(true, Ordering::SeqCst);
|
||||
}
|
||||
Err(e) => self.effects.observe(e),
|
||||
}
|
||||
self.completed = true;
|
||||
}
|
||||
}
|
||||
impl Drop for EffectWait {
|
||||
fn drop(&mut self) {
|
||||
if !self.completed {
|
||||
self.effects.unknown.store(true, Ordering::SeqCst);
|
||||
}
|
||||
self.effects.pending.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl Executor {
|
||||
pub fn compile_service(
|
||||
&self,
|
||||
artifact: &[u8],
|
||||
package_digest: &str,
|
||||
artifact_digest: &str,
|
||||
artifact_size: u64,
|
||||
) -> Result<CompiledService, ExecutionError> {
|
||||
let compiled = self.compile(artifact, package_digest, artifact_digest, artifact_size)?;
|
||||
if compiled.manifest.runtime.world != plugin::PLUGIN_COMPONENT_SERVICE_WORLD {
|
||||
return Err(ExecutionError::Artifact(
|
||||
"not the exact Service world".into(),
|
||||
));
|
||||
}
|
||||
Ok(CompiledService(compiled))
|
||||
}
|
||||
|
||||
pub async fn service_start(
|
||||
&self,
|
||||
compiled: &CompiledService,
|
||||
context: InvocationContext,
|
||||
provider: Arc<dyn Host>,
|
||||
service_name: &str,
|
||||
config_json: &str,
|
||||
) -> ServiceInvocationResult {
|
||||
self.service_call(
|
||||
compiled,
|
||||
context,
|
||||
provider,
|
||||
service_name,
|
||||
Call::Start(config_json),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn service_event(
|
||||
&self,
|
||||
compiled: &CompiledService,
|
||||
context: InvocationContext,
|
||||
provider: Arc<dyn Host>,
|
||||
service_name: &str,
|
||||
envelope: &PluginTicketEventEnvelope,
|
||||
config_json: &str,
|
||||
) -> ServiceInvocationResult {
|
||||
self.service_call(
|
||||
compiled,
|
||||
context,
|
||||
provider,
|
||||
service_name,
|
||||
Call::Event {
|
||||
envelope,
|
||||
config: config_json,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn service_stop(
|
||||
&self,
|
||||
compiled: &CompiledService,
|
||||
context: InvocationContext,
|
||||
provider: Arc<dyn Host>,
|
||||
service_name: &str,
|
||||
reason: &str,
|
||||
) -> ServiceInvocationResult {
|
||||
self.service_call(
|
||||
compiled,
|
||||
context,
|
||||
provider,
|
||||
service_name,
|
||||
Call::Stop(reason),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn service_call(
|
||||
&self,
|
||||
compiled: &CompiledService,
|
||||
mut context: InvocationContext,
|
||||
provider: Arc<dyn Host>,
|
||||
service_name: &str,
|
||||
call: Call<'_>,
|
||||
) -> ServiceInvocationResult {
|
||||
let ceiling = match call {
|
||||
Call::Start(_) => Duration::from_secs(5),
|
||||
Call::Stop(_) => Duration::from_secs(2),
|
||||
Call::Event { .. } => self.limits.wall_time,
|
||||
};
|
||||
context.deadline = context
|
||||
.deadline
|
||||
.min(Instant::now() + ceiling.min(self.limits.wall_time));
|
||||
context.cancellation = context.cancellation.child_token();
|
||||
let _lifetime = InvocationLifetime(context.cancellation.clone());
|
||||
let effects = Arc::new(Effects::default());
|
||||
let execution = self.run_service(
|
||||
compiled,
|
||||
&context,
|
||||
provider,
|
||||
service_name,
|
||||
call,
|
||||
effects.clone(),
|
||||
);
|
||||
tokio::pin!(execution);
|
||||
let mut tick = tokio::time::interval(Duration::from_millis(10));
|
||||
let outcome = loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = context.cancellation.cancelled() => break Err(ExecutionError::Cancelled),
|
||||
_ = tokio::time::sleep_until(context.deadline) => break Err(ExecutionError::Deadline),
|
||||
result = &mut execution => break result,
|
||||
_ = tick.tick() => self.engine.increment_epoch(),
|
||||
}
|
||||
};
|
||||
ServiceInvocationResult {
|
||||
outcome,
|
||||
effect: effects.snapshot(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_service(
|
||||
&self,
|
||||
compiled: &CompiledService,
|
||||
context: &InvocationContext,
|
||||
provider: Arc<dyn Host>,
|
||||
service_name: &str,
|
||||
call: Call<'_>,
|
||||
effects: Arc<Effects>,
|
||||
) -> Result<ServiceOutput, ExecutionError> {
|
||||
let compiled = &compiled.0;
|
||||
if !Engine::same(&self.engine, compiled.component.engine())
|
||||
|| context.artifact != compiled.identity
|
||||
|| context.tool_name != service_name
|
||||
{
|
||||
return Err(ExecutionError::InvalidInput(
|
||||
"Service artifact/name does not match Host context".into(),
|
||||
));
|
||||
}
|
||||
let declaration = compiled
|
||||
.manifest
|
||||
.services
|
||||
.iter()
|
||||
.find(|s| s.name == service_name)
|
||||
.ok_or_else(|| ExecutionError::InvalidInput("undeclared Service".into()))?;
|
||||
if context.invocation_id.is_empty() || context.invocation_id.len() > 1024 {
|
||||
return Err(ExecutionError::InvalidInput(
|
||||
"invalid invocation identity".into(),
|
||||
));
|
||||
}
|
||||
let config = match &call {
|
||||
Call::Start(c) | Call::Event { config: c, .. } => Some(*c),
|
||||
Call::Stop(_) => None,
|
||||
};
|
||||
if let Some(config) = config {
|
||||
if config.len() > self.limits.config_bytes {
|
||||
return Err(ExecutionError::InvalidInput("config exceeds limit".into()));
|
||||
}
|
||||
serde_json::from_str::<serde_json::Value>(config)
|
||||
.map_err(|e| ExecutionError::InvalidInput(e.to_string()))?;
|
||||
}
|
||||
if let Call::Event { envelope: e, .. } = &call {
|
||||
let kind = match e.event_type {
|
||||
server_api::PluginTicketEventType::Changed => PluginTicketEventType::TicketChanged,
|
||||
server_api::PluginTicketEventType::Deleted => PluginTicketEventType::TicketDeleted,
|
||||
};
|
||||
if !context.grants.ticket_events
|
||||
|| e.version != 1
|
||||
|| e.attempt == 0
|
||||
|| !declaration.subscriptions.contains(&kind)
|
||||
|| serde_json::to_vec(e)
|
||||
.map_err(|e| ExecutionError::InvalidInput(e.to_string()))?
|
||||
.len()
|
||||
> self.limits.payload_bytes
|
||||
|| [&e.event_id, &e.ticket_id, &e.domain_event_ref]
|
||||
.iter()
|
||||
.any(|s| s.is_empty() || s.len() > 1024)
|
||||
{
|
||||
return Err(ExecutionError::InvalidInput(
|
||||
"invalid, oversized or ungranted Service event".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Call::Stop(reason) = &call {
|
||||
if reason.len() > self.limits.payload_bytes {
|
||||
return Err(ExecutionError::InvalidInput(
|
||||
"stop reason exceeds limit".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
provider
|
||||
.authorize(context, Operation::Admit)
|
||||
.await
|
||||
.map_err(ExecutionError::Host)?;
|
||||
check_current(context).map_err(ExecutionError::Host)?;
|
||||
let limits = &self.limits;
|
||||
let mut store = Store::new(
|
||||
&self.engine,
|
||||
State {
|
||||
context: None,
|
||||
provider: provider.clone(),
|
||||
limits: limits.clone(),
|
||||
manifest: compiled.manifest.clone(),
|
||||
table: ResourceTable::new(),
|
||||
operations: 0,
|
||||
resources: 0,
|
||||
log_bytes: 0,
|
||||
dropped_logs: 0,
|
||||
store_limits: SandboxLimits::new(limits),
|
||||
logging_only: !matches!(call, Call::Event { .. }),
|
||||
effects,
|
||||
},
|
||||
);
|
||||
store.limiter(|s| &mut s.store_limits);
|
||||
store.set_fuel(limits.fuel).map_err(trap)?;
|
||||
store
|
||||
.fuel_async_yield_interval(Some(10_000))
|
||||
.map_err(trap)?;
|
||||
store.set_epoch_deadline(1);
|
||||
store.epoch_deadline_async_yield_and_update(1);
|
||||
let mut linker = Linker::new(&self.engine);
|
||||
service_bindings::TicketServicePlugin::add_to_linker::<_, wasmtime::component::HasSelf<_>>(
|
||||
&mut linker,
|
||||
|s| s,
|
||||
)
|
||||
.map_err(trap)?;
|
||||
let instance = service_bindings::TicketServicePlugin::instantiate_async(
|
||||
&mut store,
|
||||
&compiled.component,
|
||||
&linker,
|
||||
)
|
||||
.await
|
||||
.map_err(trap)?;
|
||||
check_current(context).map_err(ExecutionError::Host)?;
|
||||
// Instantiation/start sections never receive an export context.
|
||||
store.data_mut().context = Some(context.clone());
|
||||
let service = instance.yoi_plugin_service();
|
||||
let output = match call {
|
||||
Call::Start(config) => {
|
||||
service
|
||||
.call_on_start(&mut store, config)
|
||||
.await
|
||||
.map_err(trap)?
|
||||
.map_err(guest_error)?;
|
||||
ServiceOutput::Started
|
||||
}
|
||||
Call::Stop(reason) => {
|
||||
service
|
||||
.call_on_stop(&mut store, reason)
|
||||
.await
|
||||
.map_err(trap)?
|
||||
.map_err(guest_error)?;
|
||||
ServiceOutput::Stopped
|
||||
}
|
||||
Call::Event {
|
||||
envelope: e,
|
||||
config,
|
||||
} => {
|
||||
let input = abi::Envelope {
|
||||
event_id: e.event_id.clone(),
|
||||
event_type: match e.event_type {
|
||||
server_api::PluginTicketEventType::Changed => {
|
||||
abi::TicketEventType::TicketChanged
|
||||
}
|
||||
server_api::PluginTicketEventType::Deleted => {
|
||||
abi::TicketEventType::TicketDeleted
|
||||
}
|
||||
},
|
||||
event_version: e.version,
|
||||
occurred_at: e.occurred_at.clone(),
|
||||
ticket_ref: e.ticket_id.clone(),
|
||||
domain_event_id: e.domain_event_ref.clone(),
|
||||
content_digest: if e.content_digest.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(e.content_digest.clone())
|
||||
},
|
||||
correlation_id: if e.correlation_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(e.correlation_id.clone())
|
||||
},
|
||||
causation_id: e.causation_id.clone(),
|
||||
invocation_id: context.invocation_id.clone(),
|
||||
service_name: service_name.into(),
|
||||
config_json: config.into(),
|
||||
attempt: e.attempt,
|
||||
remaining_ms: context
|
||||
.deadline
|
||||
.saturating_duration_since(Instant::now())
|
||||
.as_millis()
|
||||
.min(u32::MAX as u128) as u32,
|
||||
};
|
||||
let result = match service
|
||||
.call_on_event(&mut store, &input)
|
||||
.await
|
||||
.map_err(trap)?
|
||||
{
|
||||
abi::EventResult::Ack => PluginServiceEventResult::Ack,
|
||||
abi::EventResult::RetryAfter(delay_ms) if delay_ms <= 300_000 => {
|
||||
PluginServiceEventResult::RetryAfter { delay_ms }
|
||||
}
|
||||
abi::EventResult::Reject(reason)
|
||||
if reason.len() <= self.limits.payload_bytes =>
|
||||
{
|
||||
PluginServiceEventResult::Reject
|
||||
}
|
||||
_ => {
|
||||
return Err(ExecutionError::InvalidOutput(
|
||||
"Service result exceeds limit".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
ServiceOutput::Event(result)
|
||||
}
|
||||
};
|
||||
check_current(context).map_err(ExecutionError::Host)?;
|
||||
provider
|
||||
.authorize(context, Operation::Publish)
|
||||
.await
|
||||
.map_err(ExecutionError::Host)?;
|
||||
check_current(context).map_err(ExecutionError::Host)?;
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
fn guest_error(error: abi::ServiceError) -> ExecutionError {
|
||||
if error.code.len().saturating_add(error.message.len()) > 4096 {
|
||||
ExecutionError::InvalidOutput("Service error exceeds limit".into())
|
||||
} else {
|
||||
ExecutionError::Guest {
|
||||
code: "plugin-failed".into(),
|
||||
message: error.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,10 @@ fn receipt(r: server_api::PluginDataReceipt) -> data::Receipt {
|
||||
}
|
||||
impl State {
|
||||
fn ticket_world(&self) -> Result<(), errors::HostError> {
|
||||
if self.manifest.runtime.world != plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD {
|
||||
if !matches!(
|
||||
self.manifest.runtime.world.as_str(),
|
||||
plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD | plugin::PLUGIN_COMPONENT_SERVICE_WORLD
|
||||
) {
|
||||
return Err(denied());
|
||||
}
|
||||
Ok(())
|
||||
@@ -111,7 +114,7 @@ impl State {
|
||||
target: Resource<Ticket>,
|
||||
operation: Op,
|
||||
) -> wasmtime::Result<Result<Reply, errors::HostError>> {
|
||||
let context = self.current()?;
|
||||
let context = self.protected_current()?;
|
||||
let checked = (|| {
|
||||
self.operation()?;
|
||||
self.ticket_world()?;
|
||||
@@ -152,20 +155,37 @@ impl State {
|
||||
None
|
||||
};
|
||||
let mutating = matches!(&operation, Op::Mutate { .. });
|
||||
let lookup = matches!(&operation, Op::LookupMutation { .. });
|
||||
let expected_reply = match &operation {
|
||||
Op::Get(_) => 0,
|
||||
Op::Mutate { .. } => 1,
|
||||
Op::List { .. } => 2,
|
||||
Op::LookupMutation { .. } => 3,
|
||||
};
|
||||
let reply = match self
|
||||
let effect = mutating.then(|| self.effects.begin());
|
||||
let reply = self
|
||||
.provider
|
||||
.ticket_data(&context, binding.as_ref(), operation)
|
||||
.await
|
||||
{
|
||||
.await;
|
||||
if let Some(effect) = effect {
|
||||
effect.finish(&reply);
|
||||
}
|
||||
let reply = match reply {
|
||||
Ok(reply) => reply,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
Err(e) => {
|
||||
self.effects.observe(&e);
|
||||
if lookup {
|
||||
self.effects.unresolved();
|
||||
}
|
||||
return Ok(Err(e));
|
||||
}
|
||||
};
|
||||
if matches!(
|
||||
&reply,
|
||||
Reply::MutationLookup(server_api::PluginDataMutationLookup::Expired)
|
||||
) {
|
||||
self.effects.unresolved();
|
||||
}
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("late Ticket data result"))?;
|
||||
let actual_reply = match &reply {
|
||||
Reply::Cell(_) => 0,
|
||||
@@ -179,6 +199,7 @@ impl State {
|
||||
error.code = errors::Code::Indeterminate;
|
||||
error.effect = errors::EffectObservation::Indeterminate;
|
||||
}
|
||||
self.effects.observe(&error);
|
||||
return Ok(Err(error));
|
||||
}
|
||||
let valid = match &reply {
|
||||
@@ -224,7 +245,7 @@ impl objects::Host for State {
|
||||
ticket_ref: String,
|
||||
requested: objects::Access,
|
||||
) -> wasmtime::Result<Result<Resource<Ticket>, errors::HostError>> {
|
||||
let context = self.current()?;
|
||||
let context = self.protected_current()?;
|
||||
if let Err(e) = self.operation().and_then(|_| self.ticket_world()) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
@@ -288,7 +309,7 @@ impl ticket_read::Host for State {
|
||||
&mut self,
|
||||
target: Resource<Ticket>,
|
||||
) -> wasmtime::Result<Result<ticket_read::Snapshot, errors::HostError>> {
|
||||
let context = self.current()?;
|
||||
let context = self.protected_current()?;
|
||||
if let Err(e) = self.operation().and_then(|_| self.ticket_world()) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ pub struct Grants {
|
||||
pub logging: bool,
|
||||
pub connection_slots: BTreeSet<String>,
|
||||
pub ticket_read: bool,
|
||||
pub ticket_events: bool,
|
||||
pub ticket_data_read: BTreeSet<(String, String, String)>,
|
||||
pub ticket_data_write: BTreeSet<(String, String, String)>,
|
||||
}
|
||||
|
||||
@@ -30,3 +30,22 @@ pub mod ticket_bindings {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// Service exports reuse the existing Host resources and their exact import ABI.
|
||||
pub mod service_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
path: "../../resources/plugin/wit-service",
|
||||
world: "ticket-service-plugin",
|
||||
imports: { default: async | trappable },
|
||||
exports: { default: async },
|
||||
with: {
|
||||
"yoi:host/errors@2.0.0": crate::bindings::yoi::host::errors,
|
||||
"yoi:host/connections@2.0.0": crate::bindings::yoi::host::connections,
|
||||
"yoi:host/http@2.0.0": crate::bindings::yoi::host::http,
|
||||
"yoi:host/logging@2.0.0": crate::bindings::yoi::host::logging,
|
||||
"yoi:host/objects@2.1.0": crate::host::objects,
|
||||
"yoi:host/ticket-read@2.1.0": crate::host::ticket_read,
|
||||
"yoi:host/extension-data@2.1.0": crate::host::extension_data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ impl OwnerHost {
|
||||
|| context.invocation_id != bound.invocation_id
|
||||
|| context.artifact != bound.artifact
|
||||
|| context.tool_name != bound.tool_name
|
||||
|| context.grants.ticket_events != bound.grants.ticket_events
|
||||
|| context.grants.ticket_read != bound.grants.ticket_read
|
||||
|| context.grants.ticket_data_read != bound.grants.ticket_data_read
|
||||
|| context.grants.ticket_data_write != bound.grants.ticket_data_write
|
||||
@@ -609,6 +610,12 @@ mod tests {
|
||||
});
|
||||
}
|
||||
Ok(match request {
|
||||
PluginHostRequest::Service { .. } => {
|
||||
return Err(local_error(
|
||||
PluginHostErrorCode::Unsupported,
|
||||
PluginEffectObservation::NotDispatched,
|
||||
));
|
||||
}
|
||||
PluginHostRequest::Prepare { .. } => PluginHostResponse::Authorized,
|
||||
PluginHostRequest::Begin { .. } => PluginHostResponse::Invocation {
|
||||
invocation_id: "owner-issued".into(),
|
||||
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "service-test-guest"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"yoi-plugin-pdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn 2.0.119",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoi-plugin-pdk"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "service-test-guest"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
yoi-plugin-pdk = { path = "../../../plugin-pdk" }
|
||||
@@ -0,0 +1,47 @@
|
||||
schema_version = 2
|
||||
id = "service-test"
|
||||
name = "Service test"
|
||||
version = "1.0.0"
|
||||
surfaces = ["service"]
|
||||
config_schema = "{ mode = String }"
|
||||
|
||||
[runtime]
|
||||
kind = "wasm-component"
|
||||
component = "plugin.component.wasm"
|
||||
world = "yoi:plugin/ticket-service-plugin@2.2.0"
|
||||
required_imports = ["yoi:host/errors@2.0.0", "yoi:host/connections@2.0.0", "yoi:host/http@2.0.0", "yoi:host/logging@2.0.0", "yoi:host/objects@2.1.0", "yoi:host/ticket-read@2.1.0", "yoi:host/extension-data@2.1.0"]
|
||||
|
||||
[[services]]
|
||||
name = "ticket_events"
|
||||
subscriptions = ["ticket_changed", "ticket_deleted"]
|
||||
|
||||
[[connections]]
|
||||
name = "api"
|
||||
methods = ["POST"]
|
||||
path_prefixes = ["/events"]
|
||||
|
||||
[[data_collections]]
|
||||
collection = "notes"
|
||||
schema_id = "note"
|
||||
schema_version = "1"
|
||||
schema = { type = "string", maxLength = 64 }
|
||||
|
||||
[[requested_grants]]
|
||||
kind = "logging"
|
||||
[[requested_grants]]
|
||||
kind = "https"
|
||||
slot = "api"
|
||||
[[requested_grants]]
|
||||
kind = "ticket_events"
|
||||
[[requested_grants]]
|
||||
kind = "ticket_read"
|
||||
[[requested_grants]]
|
||||
kind = "ticket_data_read"
|
||||
collection = "notes"
|
||||
schema_id = "note"
|
||||
schema_version = "1"
|
||||
[[requested_grants]]
|
||||
kind = "ticket_data_write"
|
||||
collection = "notes"
|
||||
schema_id = "note"
|
||||
schema_version = "1"
|
||||
@@ -0,0 +1,135 @@
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use yoi_plugin_pdk::{ToolError, wit_bindgen};
|
||||
wit_bindgen::generate!({
|
||||
path: "../../../../resources/plugin/wit-service",
|
||||
world: "ticket-service-plugin",
|
||||
generate_all,
|
||||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
use exports::yoi::plugin::service::{Envelope, EventResult, TicketEventType};
|
||||
use yoi::host2_0_0::{connections, errors, http, logging};
|
||||
use yoi::host2_1_0::{extension_data as data, objects, ticket_read};
|
||||
|
||||
static CALLS: AtomicU32 = AtomicU32::new(0);
|
||||
fn fresh() {
|
||||
assert_eq!(CALLS.fetch_add(1, Ordering::SeqCst), 0);
|
||||
}
|
||||
fn failure(e: errors::HostError) -> ToolError {
|
||||
ToolError::failed(format!("{:?}", e.code))
|
||||
}
|
||||
fn address() -> data::Address {
|
||||
data::Address {
|
||||
namespace: data::Collection {
|
||||
collection: "notes".into(),
|
||||
schema_id: "note".into(),
|
||||
schema_version: "1".into(),
|
||||
},
|
||||
key: "main".into(),
|
||||
}
|
||||
}
|
||||
fn request() -> http::Request {
|
||||
http::Request {
|
||||
method: http::Method::Post,
|
||||
path: "/events".into(),
|
||||
query: vec![],
|
||||
headers: vec![],
|
||||
body: b"event".to_vec(),
|
||||
timeout_ms: 15_000,
|
||||
effect_key: "event-hook".into(),
|
||||
}
|
||||
}
|
||||
fn lifecycle(mode: String) -> yoi_plugin_pdk::Result<()> {
|
||||
fresh();
|
||||
match mode.as_str() {
|
||||
"open" => {
|
||||
connections::open("api").map_err(failure)?;
|
||||
}
|
||||
"resolve" => {
|
||||
objects::resolve_ticket("T-796", objects::Access::Read).map_err(failure)?;
|
||||
}
|
||||
"read" => {
|
||||
let t = unsafe { objects::Ticket::from_handle(0) };
|
||||
ticket_read::read(&t).map_err(failure)?;
|
||||
}
|
||||
"data" => {
|
||||
let t = unsafe { objects::Ticket::from_handle(0) };
|
||||
data::get(&t, &address()).map_err(failure)?;
|
||||
}
|
||||
"http" => {
|
||||
let c = unsafe { connections::Connection::from_handle(0) };
|
||||
http::send(&c, &request()).map_err(failure)?;
|
||||
}
|
||||
"loop" => loop {
|
||||
std::hint::spin_loop();
|
||||
},
|
||||
_ => {
|
||||
logging::log(&logging::Entry {
|
||||
level: logging::Level::Info,
|
||||
event_code: "lifecycle".into(),
|
||||
message: "configured".into(),
|
||||
fields: vec![],
|
||||
})
|
||||
.map_err(failure)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn start(config: String) -> yoi_plugin_pdk::Result<()> {
|
||||
let config: std::collections::BTreeMap<String, String> =
|
||||
yoi_plugin_pdk::parse_json_input(&config)?;
|
||||
lifecycle(config.get("mode").cloned().unwrap_or_default())
|
||||
}
|
||||
fn stop(reason: String) -> yoi_plugin_pdk::Result<()> {
|
||||
lifecycle(reason)
|
||||
}
|
||||
fn work(input: &Envelope) -> yoi_plugin_pdk::Result<()> {
|
||||
if matches!(input.event_type, TicketEventType::TicketDeleted) {
|
||||
return Ok(());
|
||||
}
|
||||
let ticket = objects::resolve_ticket(&input.ticket_ref, objects::Access::ExtensionWrite)
|
||||
.map_err(failure)?;
|
||||
let snapshot = ticket_read::read(&ticket).map_err(failure)?;
|
||||
if input.config_json == r#"{"mode":"abi-probe"}"# {
|
||||
data::list(&ticket, &address().namespace, None, 1).map_err(failure)?;
|
||||
data::delete(&ticket, &address(), "observed", "delete").map_err(failure)?;
|
||||
}
|
||||
// A saved mutation is checked before reading the current cell or rebuilding
|
||||
// its CAS request. Failed lookup is never interpreted as not-recorded.
|
||||
match data::lookup_mutation(&ticket, &address(), "event-note").map_err(failure)? {
|
||||
data::MutationLookup::Committed(_) => (),
|
||||
data::MutationLookup::Expired => return Err(ToolError::failed("expired")),
|
||||
data::MutationLookup::NotRecorded => {
|
||||
let expected = match data::get(&ticket, &address()).map_err(failure)? {
|
||||
data::Cell::NeverWritten => data::Expectation::NeverWritten,
|
||||
data::Cell::Deleted(last) => data::Expectation::LastWrite(last),
|
||||
data::Cell::Present(cell) => data::Expectation::LastWrite(cell.last_write),
|
||||
};
|
||||
let value = yoi_plugin_pdk::extension_data_json(&snapshot.title)?;
|
||||
data::put(&ticket, &address(), &expected, "event-note", &value).map_err(failure)?;
|
||||
}
|
||||
}
|
||||
let connection = connections::open("api").map_err(failure)?;
|
||||
// Repeating this exact request uses the Host receipt, not a new effect key.
|
||||
http::send(&connection, &request()).map_err(failure)?;
|
||||
Ok(())
|
||||
}
|
||||
fn event(input: Envelope) -> EventResult {
|
||||
fresh();
|
||||
if input.config_json == r#"{"mode":"loop"}"# {
|
||||
loop {
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
}
|
||||
if input.config_json == r#"{"mode":"reject"}"# {
|
||||
return EventResult::Reject("rejected".into());
|
||||
}
|
||||
if input.config_json == r#"{"mode":"oversize-retry"}"# {
|
||||
return EventResult::RetryAfter(300_001);
|
||||
}
|
||||
if work(&input).is_err() && input.config_json != r#"{"mode":"swallow-unknown"}"# {
|
||||
EventResult::RetryAfter(10)
|
||||
} else {
|
||||
EventResult::Ack
|
||||
}
|
||||
}
|
||||
yoi_plugin_pdk::export_component_service!(ServiceGuest, start, event, stop);
|
||||
@@ -0,0 +1,877 @@
|
||||
//! Authored Rust -> PDK -> validated Component -> fresh Wasmtime Store -> OwnerHost.
|
||||
//! The deterministic owner replaces storage/network, not the execution adapter;
|
||||
//! transaction/lease/live policy guarantees are tested by the domain owner suites.
|
||||
use async_trait::async_trait;
|
||||
use manifest::plugin::{self, PluginPackageLimits, PluginSourceKind};
|
||||
use server_api::*;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::Path,
|
||||
process::Command,
|
||||
sync::{Arc, Mutex, OnceLock},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tokio::time::Instant;
|
||||
use yoi_plugin_runtime::{
|
||||
host::{CancellationToken, Grants, InvocationContext},
|
||||
owner::{OwnerHost, PluginHostOwner},
|
||||
*,
|
||||
};
|
||||
|
||||
fn component() -> &'static [u8] {
|
||||
static COMPONENT: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
COMPONENT.get_or_init(|| {
|
||||
let target =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/plugin-service-fixture/guest");
|
||||
let output = Command::new(env!("CARGO"))
|
||||
.args([
|
||||
"build",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"--release",
|
||||
"--target",
|
||||
"wasm32-unknown-unknown",
|
||||
"--manifest-path",
|
||||
])
|
||||
.arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/service-guest/Cargo.toml"))
|
||||
.arg("--target-dir")
|
||||
.arg(&target)
|
||||
.output()
|
||||
.unwrap();
|
||||
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
fs::write(
|
||||
target.parent().unwrap().join("guest-build.log"),
|
||||
&output.stderr,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Service fixture requires wasm32 std + lld on PATH: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let core = fs::read(target.join("wasm32-unknown-unknown/release/service_test_guest.wasm"))
|
||||
.unwrap();
|
||||
let bytes = wit_component::ComponentEncoder::default()
|
||||
.module(&core)
|
||||
.unwrap()
|
||||
.validate(true)
|
||||
.encode()
|
||||
.unwrap();
|
||||
fs::write(
|
||||
target.parent().unwrap().join("service.component.wasm"),
|
||||
&bytes,
|
||||
)
|
||||
.unwrap();
|
||||
bytes
|
||||
})
|
||||
}
|
||||
const MANIFEST: &str = include_str!("service-guest/plugin.toml");
|
||||
fn package(manifest: &str) -> Result<plugin::CanonicalPluginArtifact, plugin::PluginDiagnostic> {
|
||||
package_bytes(manifest, component())
|
||||
}
|
||||
fn package_bytes(
|
||||
manifest: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<plugin::CanonicalPluginArtifact, plugin::PluginDiagnostic> {
|
||||
let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/plugin-service-fixture");
|
||||
fs::create_dir_all(&target).unwrap();
|
||||
let dir = tempfile::tempdir_in(target).unwrap();
|
||||
fs::write(dir.path().join("plugin.toml"), manifest).unwrap();
|
||||
fs::write(dir.path().join("plugin.component.wasm"), bytes).unwrap();
|
||||
let materialized = plugin::read_plugin_directory(
|
||||
dir.path(),
|
||||
PluginSourceKind::Explicit,
|
||||
&PluginPackageLimits::default(),
|
||||
)?;
|
||||
plugin::pack_plugin_package(&materialized, &PluginPackageLimits::default())
|
||||
}
|
||||
fn compile(ex: &Executor) -> CompiledService {
|
||||
let p = package(MANIFEST).unwrap();
|
||||
ex.compile_service(
|
||||
&p.bytes,
|
||||
&p.package_digest,
|
||||
&p.artifact_digest,
|
||||
p.artifact_size,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
#[derive(Clone, Copy, Default)]
|
||||
enum Fault {
|
||||
#[default]
|
||||
None,
|
||||
HttpUnknown,
|
||||
DataUnknown,
|
||||
HttpWait,
|
||||
DataWait,
|
||||
LookupUnavailable,
|
||||
LookupExpired,
|
||||
ReadDenied,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
calls: Vec<PluginHostRequest>,
|
||||
writes: usize,
|
||||
sends: usize,
|
||||
receipts: BTreeMap<String, PluginDataReceipt>,
|
||||
http: BTreeMap<String, PluginHttpResponse>,
|
||||
}
|
||||
struct Owner {
|
||||
state: Mutex<State>,
|
||||
fault: Fault,
|
||||
waiting: tokio::sync::Notify,
|
||||
}
|
||||
impl Owner {
|
||||
fn new(fault: Fault) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
state: Mutex::new(State::default()),
|
||||
fault,
|
||||
waiting: tokio::sync::Notify::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
fn rejected(code: PluginHostErrorCode, effect: PluginEffectObservation) -> PluginHostError {
|
||||
PluginHostError {
|
||||
code,
|
||||
effect,
|
||||
diagnostic_id: "test".into(),
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl PluginHostOwner for Owner {
|
||||
async fn call(
|
||||
&self,
|
||||
request: PluginHostRequest,
|
||||
) -> Result<PluginHostResponse, PluginHostError> {
|
||||
if (matches!(self.fault, Fault::HttpWait)
|
||||
&& matches!(request, PluginHostRequest::Send { .. }))
|
||||
|| (matches!(self.fault, Fault::DataWait)
|
||||
&& matches!(request, PluginHostRequest::DataMutate { .. }))
|
||||
{
|
||||
self.waiting.notify_one();
|
||||
return std::future::pending().await;
|
||||
}
|
||||
let mut s = self.state.lock().unwrap();
|
||||
s.calls.push(request.clone());
|
||||
Ok(match request {
|
||||
PluginHostRequest::Begin { .. } => PluginHostResponse::Invocation {
|
||||
invocation_id: "stable-service-invocation".into(),
|
||||
expires_at_ms: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64
|
||||
+ 60_000,
|
||||
},
|
||||
PluginHostRequest::Authorize { .. } => PluginHostResponse::Authorized,
|
||||
PluginHostRequest::Log { .. } => PluginHostResponse::Logged,
|
||||
PluginHostRequest::ResolveTicket { ticket_ref, .. } => {
|
||||
assert_eq!(ticket_ref, "T-796");
|
||||
PluginHostResponse::TicketResolved {
|
||||
ticket_id: "canonical-ticket".into(),
|
||||
}
|
||||
}
|
||||
PluginHostRequest::ReadTicket { .. } => {
|
||||
if matches!(self.fault, Fault::ReadDenied) {
|
||||
return Err(rejected(
|
||||
PluginHostErrorCode::Denied,
|
||||
PluginEffectObservation::NotDispatched,
|
||||
));
|
||||
}
|
||||
PluginHostResponse::TicketRead {
|
||||
snapshot: PluginTicketSnapshot {
|
||||
title: "note".into(),
|
||||
case_state: PluginTicketCaseState::Open,
|
||||
content_digest: "digest".into(),
|
||||
body: "body".into(),
|
||||
truncated: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
PluginHostRequest::DataLookupMutation { operation_key, .. } => {
|
||||
if matches!(self.fault, Fault::LookupUnavailable) {
|
||||
return Err(rejected(
|
||||
PluginHostErrorCode::Unavailable,
|
||||
PluginEffectObservation::NotDispatched,
|
||||
));
|
||||
}
|
||||
PluginHostResponse::DataMutationLookup {
|
||||
outcome: if matches!(self.fault, Fault::LookupExpired) {
|
||||
PluginDataMutationLookup::Expired
|
||||
} else {
|
||||
s.receipts
|
||||
.get(&operation_key)
|
||||
.cloned()
|
||||
.map(PluginDataMutationLookup::Committed)
|
||||
.unwrap_or(PluginDataMutationLookup::NotRecorded)
|
||||
},
|
||||
}
|
||||
}
|
||||
PluginHostRequest::DataGet { .. } => PluginHostResponse::DataCell {
|
||||
cell: PluginDataCell::NeverWritten,
|
||||
},
|
||||
PluginHostRequest::DataMutate { operation_key, .. } => {
|
||||
if matches!(self.fault, Fault::DataUnknown) {
|
||||
return Err(rejected(
|
||||
PluginHostErrorCode::Indeterminate,
|
||||
PluginEffectObservation::Indeterminate,
|
||||
));
|
||||
}
|
||||
s.writes += 1;
|
||||
let receipt = PluginDataReceipt {
|
||||
mutation_id: "write-1".into(),
|
||||
result_digest: "result".into(),
|
||||
};
|
||||
assert!(s.receipts.insert(operation_key, receipt.clone()).is_none());
|
||||
PluginHostResponse::DataMutated { receipt }
|
||||
}
|
||||
PluginHostRequest::Open { .. } => PluginHostResponse::Opened,
|
||||
PluginHostRequest::Send { request, .. } => {
|
||||
if matches!(self.fault, Fault::HttpUnknown) {
|
||||
return Err(rejected(
|
||||
PluginHostErrorCode::Indeterminate,
|
||||
PluginEffectObservation::Indeterminate,
|
||||
));
|
||||
}
|
||||
let response = if let Some(response) = s.http.get(&request.effect_key) {
|
||||
response.clone()
|
||||
} else {
|
||||
s.sends += 1;
|
||||
let response = PluginHttpResponse {
|
||||
status: 200,
|
||||
headers: vec![],
|
||||
body: b"ok".to_vec(),
|
||||
};
|
||||
s.http.insert(request.effect_key, response.clone());
|
||||
response
|
||||
};
|
||||
PluginHostResponse::Response { response }
|
||||
}
|
||||
_ => {
|
||||
return Err(rejected(
|
||||
PluginHostErrorCode::Unsupported,
|
||||
PluginEffectObservation::NotDispatched,
|
||||
));
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
fn context(c: &CompiledService) -> InvocationContext {
|
||||
let namespace = ("notes".into(), "note".into(), "1".into());
|
||||
InvocationContext {
|
||||
workspace: "workspace".into(),
|
||||
installation: "installation".into(),
|
||||
plan: "plan".into(),
|
||||
subject: "worker".into(),
|
||||
invocation_id: "ignored-before-begin".into(),
|
||||
artifact: c.identity().clone(),
|
||||
tool_name: "ticket_events".into(),
|
||||
grants: Grants {
|
||||
logging: true,
|
||||
connection_slots: ["api".into()].into(),
|
||||
ticket_events: true,
|
||||
ticket_read: true,
|
||||
ticket_data_read: [namespace.clone()].into(),
|
||||
ticket_data_write: [namespace].into(),
|
||||
},
|
||||
deadline: Instant::now() + Duration::from_secs(10),
|
||||
cancellation: CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
async fn host(c: &CompiledService, owner: Arc<Owner>) -> Arc<OwnerHost> {
|
||||
Arc::new(
|
||||
OwnerHost::begin(
|
||||
owner,
|
||||
context(c),
|
||||
PluginConsumer::Consumer {
|
||||
consumer_id: "consumer".into(),
|
||||
event_id: "event".into(),
|
||||
export_name: "ticket_events".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
fn envelope() -> PluginTicketEventEnvelope {
|
||||
PluginTicketEventEnvelope {
|
||||
event_id: "event".into(),
|
||||
event_type: PluginTicketEventType::Changed,
|
||||
version: 1,
|
||||
occurred_at: "2026-10-30T00:00:00Z".into(),
|
||||
ticket_id: "T-796".into(),
|
||||
domain_event_ref: "domain-event".into(),
|
||||
content_digest: "digest".into(),
|
||||
correlation_id: "correlation".into(),
|
||||
causation_id: None,
|
||||
attempt: 1,
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn authored_service_recovers_with_receipt_lookup_before_rebuilding_a_mutation() {
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
let c = compile(&ex);
|
||||
let owner = Owner::new(Fault::None);
|
||||
let host = host(&c, owner.clone()).await;
|
||||
assert!(matches!(
|
||||
ex.service_start(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
r#"{"mode":"normal"}"#
|
||||
)
|
||||
.await
|
||||
.outcome,
|
||||
Ok(ServiceOutput::Started)
|
||||
));
|
||||
for attempt in 1..=2 {
|
||||
let mut e = envelope();
|
||||
e.attempt = attempt;
|
||||
let result = ex
|
||||
.service_event(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&e,
|
||||
r#"{"mode":"normal"}"#,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
result.outcome,
|
||||
Ok(ServiceOutput::Event(PluginServiceEventResult::Ack))
|
||||
),
|
||||
"{result:?}"
|
||||
);
|
||||
assert!(matches!(
|
||||
result.effect,
|
||||
host::errors::EffectObservation::ResponseObserved
|
||||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
ex.service_stop(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
"normal"
|
||||
)
|
||||
.await
|
||||
.outcome,
|
||||
Ok(ServiceOutput::Stopped)
|
||||
));
|
||||
let state = owner.state.lock().unwrap();
|
||||
assert_eq!(state.writes, 1);
|
||||
assert_eq!(state.sends, 1);
|
||||
assert_eq!(
|
||||
state
|
||||
.calls
|
||||
.iter()
|
||||
.filter(|r| matches!(r, PluginHostRequest::DataGet { .. }))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.calls
|
||||
.iter()
|
||||
.filter(|r| matches!(r, PluginHostRequest::DataLookupMutation { .. }))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn lifecycle_hooks_allow_logging_but_never_open_resolve_read_data_or_http() {
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
let c = compile(&ex);
|
||||
let owner = Owner::new(Fault::None);
|
||||
let host = host(&c, owner.clone()).await;
|
||||
for mode in ["open", "resolve", "read", "data", "http"] {
|
||||
let config = format!(r#"{{"mode":"{mode}"}}"#);
|
||||
let start = ex
|
||||
.service_start(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&config,
|
||||
)
|
||||
.await;
|
||||
let stop = ex
|
||||
.service_stop(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
mode,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(start.outcome, Err(ExecutionError::Trap(_))),
|
||||
"{mode}: {start:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(stop.outcome, Err(ExecutionError::Trap(_))),
|
||||
"{mode}: {stop:?}"
|
||||
);
|
||||
}
|
||||
let s = owner.state.lock().unwrap();
|
||||
assert!(s.calls.iter().all(|r| matches!(
|
||||
r,
|
||||
PluginHostRequest::Begin { .. } | PluginHostRequest::Authorize { .. }
|
||||
)));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn guest_ack_cannot_hide_unknown_http_data_or_failed_receipt_lookup() {
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
let c = compile(&ex);
|
||||
for fault in [
|
||||
Fault::HttpUnknown,
|
||||
Fault::DataUnknown,
|
||||
Fault::LookupUnavailable,
|
||||
Fault::LookupExpired,
|
||||
] {
|
||||
let host = host(&c, Owner::new(fault)).await;
|
||||
let result = ex
|
||||
.service_event(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&envelope(),
|
||||
r#"{"mode":"swallow-unknown"}"#,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
result.outcome,
|
||||
Ok(ServiceOutput::Event(PluginServiceEventResult::Ack))
|
||||
),
|
||||
"{result:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
result.effect,
|
||||
host::errors::EffectObservation::Indeterminate
|
||||
),
|
||||
"{result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn cancellation_during_effect_wait_preserves_unknown_observation() {
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
let c = compile(&ex);
|
||||
for fault in [Fault::HttpWait, Fault::DataWait] {
|
||||
let owner = Owner::new(fault);
|
||||
let host = host(&c, owner.clone()).await;
|
||||
let cancellation = host.context().cancellation.clone();
|
||||
let e = envelope();
|
||||
let (result, ()) = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
tokio::join!(
|
||||
ex.service_event(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&e,
|
||||
r#"{"mode":"normal"}"#
|
||||
),
|
||||
async {
|
||||
owner.waiting.notified().await;
|
||||
cancellation.cancel();
|
||||
}
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("effect was not dispatched");
|
||||
assert!(
|
||||
matches!(result.outcome, Err(ExecutionError::Cancelled)),
|
||||
"{result:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
result.effect,
|
||||
host::errors::EffectObservation::Indeterminate
|
||||
),
|
||||
"{result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn deleted_metadata_never_resolves_a_ticket_and_live_read_denial_remains_a_failure() {
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
let c = compile(&ex);
|
||||
let owner = Owner::new(Fault::ReadDenied);
|
||||
let host = host(&c, owner.clone()).await;
|
||||
let result = ex
|
||||
.service_event(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&envelope(),
|
||||
r#"{"mode":"normal"}"#,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
result.outcome,
|
||||
Ok(ServiceOutput::Event(
|
||||
PluginServiceEventResult::RetryAfter { .. }
|
||||
))
|
||||
));
|
||||
let before = owner.state.lock().unwrap().calls.len();
|
||||
let mut e = envelope();
|
||||
e.event_type = PluginTicketEventType::Deleted;
|
||||
e.content_digest.clear();
|
||||
let result = ex
|
||||
.service_event(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host,
|
||||
"ticket_events",
|
||||
&e,
|
||||
r#"{"mode":"normal"}"#,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
result.outcome,
|
||||
Ok(ServiceOutput::Event(PluginServiceEventResult::Ack))
|
||||
));
|
||||
assert!(
|
||||
owner.state.lock().unwrap().calls[before..]
|
||||
.iter()
|
||||
.all(|r| matches!(r, PluginHostRequest::Authorize { .. }))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn service_manifest_rejects_old_world_subscription_and_grant_mismatches() {
|
||||
package(MANIFEST).unwrap();
|
||||
for bad in [
|
||||
MANIFEST.replace("@2.2.0", "@2.1.0"),
|
||||
MANIFEST.replace("ticket_changed", "arbitrary_event"),
|
||||
MANIFEST.replace("kind = \"ticket_events\"", "kind = \"ticket_read\""),
|
||||
MANIFEST.replace(
|
||||
"ticket_changed\", \"ticket_deleted",
|
||||
"ticket_changed\", \"ticket_changed",
|
||||
),
|
||||
MANIFEST.replace("surfaces = [\"service\"]", "surfaces = [\"tool\"]"),
|
||||
] {
|
||||
assert!(package(&bad).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_host_and_guest_limits_fail_before_publication() {
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
let c = compile(&ex);
|
||||
let host = host(&c, Owner::new(Fault::None)).await;
|
||||
let invalid = ex
|
||||
.service_event(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&envelope(),
|
||||
r#"{"mode":"oversize-retry"}"#,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
invalid.outcome,
|
||||
Err(ExecutionError::InvalidOutput(_))
|
||||
));
|
||||
let mut ctx = host.context().clone();
|
||||
ctx.grants.ticket_events = false;
|
||||
let denied = ex
|
||||
.service_event(
|
||||
&c,
|
||||
ctx,
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&envelope(),
|
||||
r#"{"mode":"normal"}"#,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
denied.outcome,
|
||||
Err(ExecutionError::InvalidInput(_))
|
||||
));
|
||||
let mut e = envelope();
|
||||
e.version = 2;
|
||||
assert!(matches!(
|
||||
ex.service_event(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&e,
|
||||
"{}"
|
||||
)
|
||||
.await
|
||||
.outcome,
|
||||
Err(ExecutionError::InvalidInput(_))
|
||||
));
|
||||
e = envelope();
|
||||
e.correlation_id = "x".repeat(65536);
|
||||
assert!(matches!(
|
||||
ex.service_event(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&e,
|
||||
"{}"
|
||||
)
|
||||
.await
|
||||
.outcome,
|
||||
Err(ExecutionError::InvalidInput(_))
|
||||
));
|
||||
let ctx = host.context().clone();
|
||||
ctx.cancellation.cancel();
|
||||
assert!(matches!(
|
||||
ex.service_start(&c, ctx, host.clone(), "ticket_events", "{}")
|
||||
.await
|
||||
.outcome,
|
||||
Err(ExecutionError::Cancelled)
|
||||
));
|
||||
}
|
||||
|
||||
fn startup_component(logging: bool) -> Vec<u8> {
|
||||
let mut resolve = wit_parser::Resolve::default();
|
||||
let (package, _) = resolve
|
||||
.push_dir(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../resources/plugin/wit-service"))
|
||||
.unwrap();
|
||||
let world = resolve
|
||||
.select_world(&[package], Some("ticket-service-plugin"))
|
||||
.unwrap();
|
||||
let core = wit_component::dummy_module(&resolve, world, wit_parser::ManglingAndAbi::Standard32);
|
||||
let wat = wasmprinter::print_bytes(core).unwrap();
|
||||
let init = wat
|
||||
.lines()
|
||||
.find(|l| l.contains("(export \"cm32p2_initialize\""))
|
||||
.unwrap();
|
||||
let index = init
|
||||
.split("(func ")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split(')')
|
||||
.next()
|
||||
.unwrap();
|
||||
let needle = wat
|
||||
.lines()
|
||||
.find(|l| l.contains(&format!("(func (;{index};)")))
|
||||
.unwrap();
|
||||
assert!(needle.trim_end().ends_with("))"), "{needle}");
|
||||
let import = wat
|
||||
.lines()
|
||||
.find(|l| {
|
||||
l.contains(if logging {
|
||||
"yoi:host/logging@2"
|
||||
} else {
|
||||
"yoi:host/connections@2"
|
||||
}) && l.contains(if logging { "\"log\"" } else { "\"open\"" })
|
||||
})
|
||||
.unwrap();
|
||||
let imported_index = import
|
||||
.split("(func (;")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap();
|
||||
let body = if logging {
|
||||
"i32.const 0 ".repeat(7) + &format!("i32.const 128 call {imported_index}")
|
||||
} else {
|
||||
format!("i32.const 0 i32.const 0 i32.const 128 call {imported_index}")
|
||||
};
|
||||
let replacement = format!("{} {body})", needle.trim_end().strip_suffix(')').unwrap());
|
||||
let wat = wat
|
||||
.replace(needle, &replacement)
|
||||
.replace("(memory (;0;) 0)", "(memory (;0;) 1)");
|
||||
let mut core = wat::parse_str(wat).unwrap();
|
||||
wit_component::embed_component_metadata(
|
||||
&mut core,
|
||||
&resolve,
|
||||
world,
|
||||
wit_component::StringEncoding::UTF8,
|
||||
)
|
||||
.unwrap();
|
||||
wit_component::ComponentEncoder::default()
|
||||
.module(&core)
|
||||
.unwrap()
|
||||
.validate(true)
|
||||
.encode()
|
||||
.unwrap()
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn component_start_sections_never_receive_a_service_export_context() {
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
for logging in [false, true] {
|
||||
let p = package_bytes(MANIFEST, &startup_component(logging)).unwrap();
|
||||
let c = ex
|
||||
.compile_service(
|
||||
&p.bytes,
|
||||
&p.package_digest,
|
||||
&p.artifact_digest,
|
||||
p.artifact_size,
|
||||
)
|
||||
.unwrap();
|
||||
let owner = Owner::new(Fault::None);
|
||||
let host = host(&c, owner.clone()).await;
|
||||
let result = ex
|
||||
.service_start(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
r#"{"mode":"normal"}"#,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result.outcome, Err(ExecutionError::Trap(_))),
|
||||
"{result:?}"
|
||||
);
|
||||
assert!(owner.state.lock().unwrap().calls.iter().all(|r| matches!(
|
||||
r,
|
||||
PluginHostRequest::Begin { .. } | PluginHostRequest::Authorize { .. }
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn artifact_delivery_binds_service_names_and_subscriptions_to_component_package() {
|
||||
use delivery::{
|
||||
ArtifactCache, ArtifactExpectation, ArtifactSource, ArtifactStream, CacheLimits,
|
||||
DeliveryError,
|
||||
};
|
||||
|
||||
struct Source {
|
||||
bytes: Vec<u8>,
|
||||
metadata: PluginArtifactDeliveryMetadata,
|
||||
}
|
||||
#[async_trait]
|
||||
impl ArtifactSource for Source {
|
||||
fn server_authority_identity(&self) -> &str {
|
||||
"service-test-authority"
|
||||
}
|
||||
async fn authorize(
|
||||
&self,
|
||||
_: &PluginArtifactDeliveryRequest,
|
||||
) -> Result<PluginArtifactDeliveryMetadata, DeliveryError> {
|
||||
Ok(self.metadata.clone())
|
||||
}
|
||||
async fn fetch(
|
||||
&self,
|
||||
_: &PluginArtifactDeliveryRequest,
|
||||
) -> Result<ArtifactStream, DeliveryError> {
|
||||
Ok(Box::pin(std::io::Cursor::new(self.bytes.clone())))
|
||||
}
|
||||
}
|
||||
fn metadata<T: serde::Serialize, U: serde::de::DeserializeOwned>(value: &T) -> U {
|
||||
serde_json::from_value(serde_json::to_value(value).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
let artifact = package(MANIFEST).unwrap();
|
||||
let verified = plugin::verify_plugin_artifact(
|
||||
&artifact.bytes,
|
||||
&artifact.package_digest,
|
||||
&artifact.artifact_digest,
|
||||
artifact.artifact_size,
|
||||
&PluginPackageLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let m = &verified.package.manifest;
|
||||
let schema = plugin::plugin_config_schema_identity(m);
|
||||
let descriptor = PluginArtifact {
|
||||
package_id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
version: m.version.clone(),
|
||||
description: m.description.clone(),
|
||||
package_digest: artifact.package_digest.clone(),
|
||||
artifact_digest: artifact.artifact_digest.clone(),
|
||||
artifact_size: artifact.artifact_size.try_into().unwrap(),
|
||||
schema_version: m.schema_version,
|
||||
world: m.runtime.world.clone(),
|
||||
required_imports: m.runtime.required_imports.clone(),
|
||||
tools: metadata(&m.tools),
|
||||
services: metadata(&m.services),
|
||||
connections: metadata(&m.connections),
|
||||
requested_grants: metadata(&m.requested_grants),
|
||||
data_collections: metadata(&m.data_collections),
|
||||
config_schema: PluginSchemaContribution {
|
||||
identity: schema.identity,
|
||||
version: schema.version,
|
||||
digest: schema.digest,
|
||||
namespace: schema.namespace,
|
||||
dcdl: m.config_schema.clone(),
|
||||
},
|
||||
revoked: false,
|
||||
revocation_reason: None,
|
||||
};
|
||||
assert_eq!(
|
||||
descriptor.services[0].subscriptions,
|
||||
["ticket_changed", "ticket_deleted"]
|
||||
);
|
||||
let request = PluginArtifactDeliveryRequest {
|
||||
plan_id: "service-plan".into(),
|
||||
plan_fingerprint: "service-plan-fingerprint".into(),
|
||||
installation_id: "installation".into(),
|
||||
artifact_digest: artifact.artifact_digest.clone(),
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cache = ArtifactCache::new(directory.path().join("cache"), CacheLimits::default()).unwrap();
|
||||
let mut source = Source {
|
||||
bytes: artifact.bytes.clone(),
|
||||
metadata: PluginArtifactDeliveryMetadata {
|
||||
workspace_id: "workspace".into(),
|
||||
request: request.clone(),
|
||||
artifact: descriptor.clone(),
|
||||
},
|
||||
};
|
||||
let resolved = cache
|
||||
.resolve(
|
||||
&source,
|
||||
"workspace",
|
||||
&request,
|
||||
&ArtifactExpectation::new(descriptor.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resolved.bytes(), artifact.bytes);
|
||||
|
||||
let mut renamed = descriptor.clone();
|
||||
renamed.services[0].name = "different_service".into();
|
||||
let mut narrowed = descriptor.clone();
|
||||
narrowed.services[0].subscriptions = vec!["ticket_changed".into()];
|
||||
let mut unknown = descriptor.clone();
|
||||
unknown.services[0].subscriptions = vec!["arbitrary_event".into()];
|
||||
let mut missing = descriptor.clone();
|
||||
missing.services.clear();
|
||||
for tampered in [renamed, narrowed, unknown, missing] {
|
||||
// Source metadata and the trusted descriptor agree; only verification
|
||||
// against the actual package can detect this mismatch, including hits.
|
||||
source.metadata.artifact = tampered.clone();
|
||||
let expected = ArtifactExpectation::new(tampered);
|
||||
assert!(matches!(
|
||||
cache
|
||||
.resolve(&source, "workspace", &request, &expected)
|
||||
.await,
|
||||
Err(DeliveryError::MetadataMismatch)
|
||||
));
|
||||
}
|
||||
source.metadata.artifact = descriptor.clone();
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(
|
||||
&source,
|
||||
"workspace",
|
||||
&request,
|
||||
&ArtifactExpectation::new(descriptor)
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.bytes(),
|
||||
artifact.bytes
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,9 @@ mod notification;
|
||||
mod plugin;
|
||||
pub mod plugin_data;
|
||||
pub mod plugin_host;
|
||||
pub mod plugin_service;
|
||||
pub use plugin_data::*;
|
||||
pub use plugin_service::*;
|
||||
mod plugin_types;
|
||||
pub use notification::*;
|
||||
pub use plugin::*;
|
||||
@@ -3094,6 +3096,22 @@ pub trait ServerApi {
|
||||
#[body] request: PluginDataPurgeRequest,
|
||||
) -> Result<PluginDataPurgeResponse, RepositoryApiError>;
|
||||
|
||||
#[get("/api/w/{workspace_id}/plugins/service-consumers/{consumer_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn plugin_service_diagnostics(
|
||||
&self,
|
||||
#[extension] actor: RequestActor,
|
||||
#[path] workspace_id: String,
|
||||
#[path] consumer_id: String,
|
||||
) -> Result<PluginServiceDiagnostics, RepositoryApiError>;
|
||||
|
||||
#[post("/api/w/{workspace_id}/plugins/service-consumers/recover", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
|
||||
async fn plugin_service_recover(
|
||||
&self,
|
||||
#[extension] actor: RequestActor,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: PluginServiceRecoveryRequest,
|
||||
) -> Result<PluginServiceDiagnostics, RepositoryApiError>;
|
||||
|
||||
#[get("/api/w/{workspace_id}/plugin-operations/{operation_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,429,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn plugin_operation(
|
||||
&self,
|
||||
|
||||
@@ -101,6 +101,7 @@ pub enum PluginGrantMetadata {
|
||||
slot: String,
|
||||
},
|
||||
TicketRead,
|
||||
TicketEvents,
|
||||
TicketDataRead {
|
||||
collection: String,
|
||||
schema_id: String,
|
||||
@@ -124,6 +125,15 @@ pub struct PluginDataSchemaMetadata {
|
||||
pub schema: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Exact artifact subscription declarations; selection and current permission are separate.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginServiceMetadata {
|
||||
pub name: String,
|
||||
pub subscriptions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -139,6 +149,8 @@ pub struct PluginArtifact {
|
||||
pub world: String,
|
||||
pub required_imports: Vec<String>,
|
||||
pub tools: Vec<PluginToolMetadata>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub services: Vec<PluginServiceMetadata>,
|
||||
pub connections: Vec<PluginConnectionMetadata>,
|
||||
pub requested_grants: Vec<PluginGrantMetadata>,
|
||||
#[serde(default)]
|
||||
@@ -160,6 +172,11 @@ impl plugin_plan::ArtifactDescriptor for PluginArtifact {
|
||||
world: self.world.clone(),
|
||||
required_imports: self.required_imports.clone(),
|
||||
tools: self.tools.iter().map(|tool| tool.name.clone()).collect(),
|
||||
services: self
|
||||
.services
|
||||
.iter()
|
||||
.map(|service| service.name.clone())
|
||||
.collect(),
|
||||
connections: self
|
||||
.connections
|
||||
.iter()
|
||||
@@ -174,6 +191,7 @@ impl plugin_plan::ArtifactDescriptor for PluginArtifact {
|
||||
plugin_plan::PluginGrant::Https { slot: slot.clone() }
|
||||
}
|
||||
PluginGrantMetadata::TicketRead => plugin_plan::PluginGrant::TicketRead,
|
||||
PluginGrantMetadata::TicketEvents => plugin_plan::PluginGrant::TicketEvents,
|
||||
PluginGrantMetadata::TicketDataRead {
|
||||
collection,
|
||||
schema_id,
|
||||
@@ -337,6 +355,7 @@ pub enum PluginDiagnosticPinState {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginDiagnosticSurface {
|
||||
Tool,
|
||||
Service,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -351,10 +370,20 @@ pub struct PluginWorkerDiagnostic {
|
||||
pub artifact_size: u32,
|
||||
pub surfaces: Vec<PluginDiagnosticSurface>,
|
||||
pub selected_tools: Vec<String>,
|
||||
pub selected_services: Vec<String>,
|
||||
pub service_consumers: Vec<PluginServiceConsumerDiagnostics>,
|
||||
pub admission: PluginDiagnosticAdmission,
|
||||
pub connections: Vec<PluginDiagnosticConnection>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginServiceConsumerDiagnostics {
|
||||
pub service_name: String,
|
||||
pub diagnostics: crate::PluginServiceDiagnostics,
|
||||
}
|
||||
|
||||
/// A successful observation is not retained admission authority.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
|
||||
@@ -3,25 +3,44 @@ use crate::PluginHttpMethod;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// The same operation variants define Tool transport and the narrower leased
|
||||
// Service channel. Service cannot nest itself or issue Begin/Cancel, and the
|
||||
// wire schema remains finite rather than recursively admitting control requests.
|
||||
macro_rules! invocation_requests {
|
||||
($($variant:ident { $($field:ident: $ty:ty),* $(,)? }),* $(,)?) => {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PluginInvocationRequest { $($variant { $($field: $ty),* }),* }
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PluginHostRequest {
|
||||
/// Fresh export/restore admission only; issues no invocation or effect identity.
|
||||
Prepare {
|
||||
plan_id: String,
|
||||
installation_id: String,
|
||||
tools: Vec<String>,
|
||||
},
|
||||
Begin {
|
||||
plan_id: String,
|
||||
installation_id: String,
|
||||
consumer: PluginConsumer,
|
||||
},
|
||||
/// Irreversible cancellation of this authenticated logical invocation.
|
||||
Cancel {
|
||||
invocation_id: String,
|
||||
},
|
||||
Service { request: crate::PluginServiceRequest },
|
||||
Prepare { plan_id: String, installation_id: String, tools: Vec<String> },
|
||||
Begin { plan_id: String, installation_id: String, consumer: PluginConsumer },
|
||||
Cancel { invocation_id: String },
|
||||
$($variant { $($field: $ty),* }),*
|
||||
}
|
||||
impl TryFrom<PluginHostRequest> for PluginInvocationRequest {
|
||||
type Error = ();
|
||||
fn try_from(request: PluginHostRequest) -> Result<Self, ()> {
|
||||
match request {
|
||||
$(PluginHostRequest::$variant { $($field),* } => Ok(Self::$variant { $($field),* }),)*
|
||||
PluginHostRequest::Service { .. } | PluginHostRequest::Prepare { .. }
|
||||
| PluginHostRequest::Begin { .. } | PluginHostRequest::Cancel { .. } => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<PluginInvocationRequest> for PluginHostRequest {
|
||||
fn from(request: PluginInvocationRequest) -> Self {
|
||||
match request { $(PluginInvocationRequest::$variant { $($field),* } => Self::$variant { $($field),* }),* }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
invocation_requests! {
|
||||
Authorize {
|
||||
invocation_id: String,
|
||||
operation: PluginHostOperation,
|
||||
@@ -83,7 +102,7 @@ pub enum PluginConsumer {
|
||||
tool_call_id: String,
|
||||
tool_name: String,
|
||||
},
|
||||
/// Reserved consumer identity; this does not enable service/event delivery.
|
||||
/// Owner-issued Service delivery identity; never grants admission by itself.
|
||||
Consumer {
|
||||
consumer_id: String,
|
||||
event_id: String,
|
||||
@@ -129,21 +148,42 @@ pub struct PluginHttpResponse {
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
macro_rules! invocation_responses {
|
||||
($($variant:ident $({ $($field:ident: $ty:ty),* $(,)? })?),* $(,)?) => {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(
|
||||
tag = "kind",
|
||||
content = "data",
|
||||
rename_all = "snake_case",
|
||||
deny_unknown_fields
|
||||
)]
|
||||
#[serde(tag = "kind", content = "data", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PluginInvocationResponse { $($variant $({ $($field: $ty),* })?),* }
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "kind", content = "data", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PluginHostResponse {
|
||||
Service { response: crate::PluginServiceResponse },
|
||||
Invocation {
|
||||
invocation_id: String,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_i64))]
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
expires_at_ms: i64,
|
||||
},
|
||||
$($variant $({ $($field: $ty),* })?),*
|
||||
}
|
||||
impl TryFrom<PluginHostResponse> for PluginInvocationResponse {
|
||||
type Error = ();
|
||||
fn try_from(response: PluginHostResponse) -> Result<Self, ()> {
|
||||
match response {
|
||||
$(PluginHostResponse::$variant $({ $($field),* })? => Ok(Self::$variant $({ $($field),* })?),)*
|
||||
PluginHostResponse::Service { .. } | PluginHostResponse::Invocation { .. } => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<PluginInvocationResponse> for PluginHostResponse {
|
||||
fn from(response: PluginInvocationResponse) -> Self {
|
||||
match response { $(PluginInvocationResponse::$variant $({ $($field),* })? => Self::$variant $({ $($field),* })?),* }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
invocation_responses! {
|
||||
Authorized,
|
||||
Opened,
|
||||
Response {
|
||||
@@ -360,6 +400,24 @@ mod tests {
|
||||
assert!(serde_json::from_value::<PluginHostError>(serde_json::json!({"code":"denied","effect":"not_dispatched","diagnostic_id":"d","message":"secret"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_wire_rejects_nested_control_and_identity_claims() {
|
||||
let lease = serde_json::json!({"consumer_id":"consumer", "lease_token":"lease"});
|
||||
for nested in [
|
||||
serde_json::json!({"kind":"service", "request":{"kind":"poll", "lease":lease}}),
|
||||
serde_json::json!({"kind":"prepare", "plan_id":"p", "installation_id":"i", "tools":[]}),
|
||||
serde_json::json!({"kind":"begin", "plan_id":"p", "installation_id":"i", "consumer":{"kind":"consumer", "consumer_id":"c", "event_id":"e", "export_name":"s"}}),
|
||||
serde_json::json!({"kind":"cancel", "invocation_id":"i"}),
|
||||
serde_json::json!({"kind":"authorize", "invocation_id":"i", "operation":{"kind":"admit"}, "subject":"owner"}),
|
||||
] {
|
||||
let wire = serde_json::json!({"kind":"service", "request":{"kind":"host", "lease":lease, "request":nested}});
|
||||
assert!(serde_json::from_value::<PluginHostRequest>(wire).is_err());
|
||||
}
|
||||
let wire = serde_json::json!({"kind":"service", "request":{"kind":"host", "lease":lease, "request":{"kind":"read_ticket", "invocation_id":"invocation", "ticket_id":"ticket"}}});
|
||||
let request: PluginHostRequest = serde_json::from_value(wire.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(request).unwrap(), wire);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_integer_wire_preserves_full_wit_range_without_json_precision_loss() {
|
||||
for scalar in [
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Host-owned Ticket Service delivery. Lease proofs fence a driver, not authorize it.
|
||||
//! All requests still require the authenticated Worker and current retained plan.
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
impl api_macros::openapi::OpenApiSchema for PluginServiceRecoveryRequest {}
|
||||
impl api_macros::openapi::OpenApiSchema for PluginServiceDiagnostics {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginServiceLease {
|
||||
pub consumer_id: String,
|
||||
pub lease_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginTicketEventType {
|
||||
Changed,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginTicketEventEnvelope {
|
||||
pub event_id: String,
|
||||
pub event_type: PluginTicketEventType,
|
||||
pub version: u32,
|
||||
pub occurred_at: String,
|
||||
pub ticket_id: String,
|
||||
pub domain_event_ref: String,
|
||||
pub content_digest: String,
|
||||
pub correlation_id: String,
|
||||
pub causation_id: Option<String>,
|
||||
pub attempt: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PluginServiceEventResult {
|
||||
Ack,
|
||||
RetryAfter {
|
||||
delay_ms: u32,
|
||||
},
|
||||
Reject,
|
||||
/// Host-observed unknown effect; never automatically redelivered.
|
||||
Indeterminate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginServicePauseReason {
|
||||
Rejected,
|
||||
AttemptsExhausted,
|
||||
QueueFull,
|
||||
RetentionGap,
|
||||
Indeterminate,
|
||||
Authorization,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginServiceDiagnostics {
|
||||
pub consumer_id: String,
|
||||
pub pause_reason: Option<PluginServicePauseReason>,
|
||||
pub queued: u32,
|
||||
pub attempt: u32,
|
||||
pub current_event_id: Option<String>,
|
||||
pub lease_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginServiceLifecycleOperation {
|
||||
Admit,
|
||||
Publish,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PluginServiceRequest {
|
||||
/// Live preflight for a reserved or running Worker. Does not create consumer
|
||||
/// state, acquire a lease, or admit event effects.
|
||||
Prepare {
|
||||
plan_id: String,
|
||||
installation_id: String,
|
||||
service_name: String,
|
||||
operation: PluginServiceLifecycleOperation,
|
||||
},
|
||||
/// Lifecycle logging uses live policy but never an event invocation/lease.
|
||||
LifecycleLog {
|
||||
plan_id: String,
|
||||
installation_id: String,
|
||||
service_name: String,
|
||||
entry: crate::PluginLogEntry,
|
||||
},
|
||||
/// Creates a consumer at the current tail, or restores the exact retained one.
|
||||
Acquire {
|
||||
plan_id: String,
|
||||
installation_id: String,
|
||||
service_name: String,
|
||||
},
|
||||
Poll {
|
||||
lease: PluginServiceLease,
|
||||
},
|
||||
Complete {
|
||||
lease: PluginServiceLease,
|
||||
event_id: String,
|
||||
result: PluginServiceEventResult,
|
||||
},
|
||||
Release {
|
||||
lease: PluginServiceLease,
|
||||
},
|
||||
Diagnostics {
|
||||
consumer_id: String,
|
||||
},
|
||||
/// The only path for Service event Host operations. Nested Service requests,
|
||||
/// Tool admission and cancellation are not accepted here.
|
||||
Host {
|
||||
lease: PluginServiceLease,
|
||||
request: Box<crate::PluginInvocationRequest>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(
|
||||
tag = "kind",
|
||||
content = "data",
|
||||
rename_all = "snake_case",
|
||||
deny_unknown_fields
|
||||
)]
|
||||
pub enum PluginServiceResponse {
|
||||
Prepared,
|
||||
Logged,
|
||||
Acquired {
|
||||
lease: PluginServiceLease,
|
||||
},
|
||||
Idle,
|
||||
Delivery {
|
||||
envelope: PluginTicketEventEnvelope,
|
||||
invocation_id: String,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_i64))]
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
expires_at_ms: i64,
|
||||
},
|
||||
Completed,
|
||||
Released,
|
||||
Diagnostics {
|
||||
diagnostics: PluginServiceDiagnostics,
|
||||
},
|
||||
Host {
|
||||
response: Box<crate::PluginInvocationResponse>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Audited recovery is an explicit Workspace management operation, not a guest import.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PluginServiceRecoveryAction {
|
||||
Resume,
|
||||
Skip {
|
||||
event_id: String,
|
||||
},
|
||||
/// A retention gap cannot be skipped by guessing a missing event identity.
|
||||
AcceptGap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginServiceRecoveryRequest {
|
||||
pub consumer_id: String,
|
||||
pub expected_pause: PluginServicePauseReason,
|
||||
pub action: PluginServiceRecoveryAction,
|
||||
pub operation_key: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn prepare_is_lease_free_and_has_no_consumer_or_event_identity() {
|
||||
let request = PluginServiceRequest::Prepare {
|
||||
plan_id: "plan".into(),
|
||||
installation_id: "installation".into(),
|
||||
service_name: "watch".into(),
|
||||
operation: PluginServiceLifecycleOperation::Publish,
|
||||
};
|
||||
let value = serde_json::to_value(&request).unwrap();
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"kind":"prepare","plan_id":"plan","installation_id":"installation","service_name":"watch","operation":"publish"})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<PluginServiceRequest>(value).unwrap(),
|
||||
request
|
||||
);
|
||||
assert!(serde_json::from_value::<PluginServiceRequest>(json!({"kind":"prepare","plan_id":"plan","installation_id":"installation","service_name":"watch","lease":{"consumer_id":"consumer","lease_token":"token"}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leased_host_wire_type_cannot_nest_service_or_admit_tool_calls() {
|
||||
for nested in [
|
||||
json!({"kind":"service","request":{"kind":"diagnostics","consumer_id":"other"}}),
|
||||
json!({"kind":"begin","plan_id":"p","installation_id":"i","consumer":{"kind":"tool_call","tool_call_id":"call","tool_name":"tool"}}),
|
||||
json!({"kind":"cancel","invocation_id":"invocation"}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<PluginServiceRequest>(json!({"kind":"host","lease":{"consumer_id":"consumer","lease_token":"token"},"request":nested})).is_err());
|
||||
}
|
||||
// This must stay a finite schema for OpenAPI/TypeScript generation too.
|
||||
let schema = schemars::schema_for!(PluginServiceRequest);
|
||||
assert!(
|
||||
!serde_json::to_string(&schema)
|
||||
.unwrap()
|
||||
.contains("PluginHostRequest")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_is_typed_management_input_not_a_guest_service_request() {
|
||||
let recovery = json!({"consumer_id":"consumer","expected_pause":"indeterminate","action":{"kind":"skip","event_id":"event"},"operation_key":"reviewed-skip","reason":"effect independently reconciled"});
|
||||
assert!(serde_json::from_value::<PluginServiceRecoveryRequest>(recovery.clone()).is_ok());
|
||||
assert!(serde_json::from_value::<PluginServiceRequest>(recovery).is_err());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,15 @@ pub fn plugin_host_typescript() -> String {
|
||||
use ts_rs::TS;
|
||||
let config = ts_rs::Config::default();
|
||||
let declarations = [
|
||||
PluginServiceLease::decl(&config),
|
||||
PluginTicketEventType::decl(&config),
|
||||
PluginTicketEventEnvelope::decl(&config),
|
||||
PluginServiceEventResult::decl(&config),
|
||||
PluginServicePauseReason::decl(&config),
|
||||
PluginServiceDiagnostics::decl(&config),
|
||||
PluginServiceRequest::decl(&config),
|
||||
PluginServiceResponse::decl(&config),
|
||||
PluginServiceRecoveryAction::decl(&config),
|
||||
PluginHttpMethod::decl(&config),
|
||||
PluginConsumer::decl(&config),
|
||||
PluginTicketAccess::decl(&config),
|
||||
@@ -19,6 +28,8 @@ pub fn plugin_host_typescript() -> String {
|
||||
PluginDataMutationLookup::decl(&config),
|
||||
PluginDataEntry::decl(&config),
|
||||
PluginDataPage::decl(&config),
|
||||
PluginInvocationRequest::decl(&config),
|
||||
PluginInvocationResponse::decl(&config),
|
||||
PluginHostRequest::decl(&config),
|
||||
PluginHostOperation::decl(&config),
|
||||
PluginHttpRequest::decl(&config),
|
||||
@@ -51,6 +62,11 @@ pub fn plugin_api_typescript() -> String {
|
||||
PluginHttpMethod::decl(&config),
|
||||
PluginConnectionMetadata::decl(&config),
|
||||
PluginGrantMetadata::decl(&config),
|
||||
PluginServiceMetadata::decl(&config),
|
||||
PluginServicePauseReason::decl(&config),
|
||||
PluginServiceDiagnostics::decl(&config),
|
||||
PluginServiceRecoveryAction::decl(&config),
|
||||
PluginServiceRecoveryRequest::decl(&config),
|
||||
PluginDataSchemaMetadata::decl(&config),
|
||||
PluginDataPurgeState::decl(&config),
|
||||
PluginDataPurgeRequest::decl(&config),
|
||||
@@ -69,6 +85,7 @@ pub fn plugin_api_typescript() -> String {
|
||||
PluginWorkerDiagnosticSelection::decl(&config),
|
||||
PluginDiagnosticPinState::decl(&config),
|
||||
PluginDiagnosticSurface::decl(&config),
|
||||
PluginServiceConsumerDiagnostics::decl(&config),
|
||||
PluginWorkerDiagnostic::decl(&config),
|
||||
PluginDiagnosticAdmission::decl(&config),
|
||||
PluginDiagnosticConnection::decl(&config),
|
||||
|
||||
@@ -417,7 +417,7 @@ fn migration_preserves_original_records_and_only_reconciles_current_fenced_deleg
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
14
|
||||
15
|
||||
);
|
||||
let value = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
|
||||
@@ -39,11 +39,14 @@ impl ContentChange<'_> {
|
||||
),
|
||||
Self::CommentEdit { event_ref } => ("comment_edit", false, Some(event_ref), false),
|
||||
};
|
||||
let change_id: String = conn
|
||||
.query_row("SELECT lower(hex(randomblob(16)))", [], |row| row.get(0))
|
||||
.map_err(sqlite_err)?;
|
||||
conn.execute(
|
||||
"INSERT INTO typed_ticket_content_changes
|
||||
(change_id, workspace_id, ticket_id, kind, actor, at,
|
||||
title_changed, body_changed, targets_changed, source_event_ref)
|
||||
VALUES (lower(hex(randomblob(16))), ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
VALUES (?10, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
@@ -53,10 +56,19 @@ impl ContentChange<'_> {
|
||||
title_changed,
|
||||
source_event_ref.is_some(),
|
||||
targets_changed,
|
||||
source_event_ref
|
||||
source_event_ref,
|
||||
change_id
|
||||
],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
crate::service_outbox::insert(
|
||||
conn,
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
crate::service_outbox::EventType::Changed,
|
||||
at,
|
||||
&change_id,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ pub use assignment_answer::*;
|
||||
mod assignment_tracking;
|
||||
pub mod config;
|
||||
mod content_changes;
|
||||
pub mod service_outbox;
|
||||
mod sqlite_schema;
|
||||
mod subject_assignment;
|
||||
mod tags;
|
||||
@@ -42,8 +43,8 @@ pub mod tool;
|
||||
pub use sqlite_schema::{
|
||||
HistoricalTicketSchemaTarget, migrate_historical_sqlite_ticket_schema_in_transaction,
|
||||
migrate_sqlite_ticket_schema, migrate_sqlite_ticket_schema_in_transaction,
|
||||
verify_legacy_sqlite_ticket_schema, verify_pre_classification_ticket_schema,
|
||||
verify_sqlite_ticket_schema,
|
||||
verify_historical_sqlite_ticket_schema, verify_legacy_sqlite_ticket_schema,
|
||||
verify_pre_classification_ticket_schema, verify_sqlite_ticket_schema,
|
||||
};
|
||||
|
||||
const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024;
|
||||
@@ -2617,6 +2618,14 @@ impl SqliteTicketBackend {
|
||||
conn.execute("INSERT INTO typed_ticket_event_attributes (workspace_id, ticket_id, event_index, key, value) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![self.workspace_id, ticket_id, next_index, key, value]).map_err(sqlite_err)?;
|
||||
}
|
||||
service_outbox::insert(
|
||||
conn,
|
||||
&self.workspace_id,
|
||||
ticket_id,
|
||||
service_outbox::EventType::Changed,
|
||||
event.at.as_deref().unwrap_or(&now_utc()),
|
||||
&event_ref,
|
||||
)?;
|
||||
if let Some(hook) = &self.mutation_hook {
|
||||
hook(
|
||||
conn,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Transactional, metadata-only facts for Host-owned Service delivery. No guest is
|
||||
//! invoked here. Rows deliberately have no Ticket foreign key: deletion must not
|
||||
//! erase delivery evidence or require resolving the deleted resource.
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
use crate::{Result, TicketError, now_utc, sqlite_err, sqlite_ticket_content_digest};
|
||||
|
||||
/// Compose this schema in the Host's startup migration, not on request paths.
|
||||
/// Installation creates an empty outbox; historical domain facts are not replayed.
|
||||
pub const OUTBOX_SCHEMA_SQL: &str = include_str!("service_outbox.sql");
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum EventType {
|
||||
Changed,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
pub(crate) fn insert(
|
||||
connection: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
event_type: EventType,
|
||||
occurred_at: &str,
|
||||
domain_event_ref: &str,
|
||||
) -> Result<String> {
|
||||
if connection.is_autocommit() {
|
||||
return Err(TicketError::Sqlite(
|
||||
"Ticket Service outbox insertion requires the domain write transaction".into(),
|
||||
));
|
||||
}
|
||||
let digest = sqlite_ticket_content_digest(connection, workspace_id, ticket_id)?
|
||||
.ok_or_else(|| TicketError::NotFound(ticket_id.into()))?;
|
||||
// Identities, rather than arbitrary event attributes, are the only propagation
|
||||
// metadata. Never copy author, body, title or a free-form attribute map here.
|
||||
let event_id: String = connection
|
||||
.query_row("SELECT lower(hex(randomblob(16)))", [], |row| row.get(0))
|
||||
.map_err(sqlite_err)?;
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO typed_ticket_service_outbox
|
||||
(event_id, workspace_id, ticket_id, event_type, occurred_at,
|
||||
domain_event_ref, content_digest, correlation_id, causation_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?1, ?6)",
|
||||
params![
|
||||
event_id,
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
match event_type {
|
||||
EventType::Changed => "changed",
|
||||
EventType::Deleted => "deleted",
|
||||
},
|
||||
occurred_at,
|
||||
domain_event_ref,
|
||||
digest
|
||||
],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
Ok(event_id)
|
||||
}
|
||||
|
||||
/// Record deletion metadata BEFORE removing the Ticket or its canonical initial
|
||||
/// post/targets, in the SAME write transaction. The Host must supply the durable
|
||||
/// deletion operation identity as `domain_event_ref`, then perform its fenced
|
||||
/// delete; any failure must roll back both. This function neither deletes nor
|
||||
/// commits. It fails closed outside a transaction or for an absent Ticket.
|
||||
///
|
||||
/// Direct SQL deletion without this call does not emit an event. There is no SQL
|
||||
/// trigger/cache fallback: the digest must come from the still-live domain data.
|
||||
pub fn record_ticket_deletion_in_transaction(
|
||||
connection: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
domain_event_ref: &str,
|
||||
) -> Result<String> {
|
||||
insert(
|
||||
connection,
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
EventType::Deleted,
|
||||
&now_utc(),
|
||||
domain_event_ref,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE typed_ticket_service_outbox (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id TEXT NOT NULL UNIQUE CHECK(length(event_id) = 32 AND event_id NOT GLOB '*[^0-9a-f]*'),
|
||||
workspace_id TEXT NOT NULL CHECK(length(CAST(workspace_id AS BLOB)) BETWEEN 1 AND 256),
|
||||
ticket_id TEXT NOT NULL CHECK(length(CAST(ticket_id AS BLOB)) BETWEEN 1 AND 256),
|
||||
event_type TEXT NOT NULL CHECK(event_type IN ('changed', 'deleted')),
|
||||
occurred_at TEXT NOT NULL CHECK(length(CAST(occurred_at AS BLOB)) BETWEEN 1 AND 64),
|
||||
domain_event_ref TEXT NOT NULL CHECK(length(CAST(domain_event_ref AS BLOB)) BETWEEN 1 AND 512),
|
||||
content_digest TEXT NOT NULL CHECK(length(content_digest) = 64 AND content_digest NOT GLOB '*[^0-9a-f]*'),
|
||||
correlation_id TEXT NOT NULL CHECK(length(CAST(correlation_id AS BLOB)) BETWEEN 1 AND 512),
|
||||
causation_id TEXT NOT NULL CHECK(length(CAST(causation_id AS BLOB)) BETWEEN 1 AND 512)
|
||||
);
|
||||
CREATE INDEX typed_ticket_service_outbox_workspace_sequence
|
||||
ON typed_ticket_service_outbox(workspace_id, sequence);
|
||||
@@ -0,0 +1,410 @@
|
||||
//! Real backend operations against isolated SQLite storage; no guest or delivery
|
||||
//! driver is needed to prove the domain transaction's durable outbox contract.
|
||||
|
||||
use super::*;
|
||||
use crate::*;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct Fixture {
|
||||
_temp: TempDir,
|
||||
backend: SqliteTicketBackend,
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let path = temp.path().join("tickets.db");
|
||||
let backend = SqliteTicketBackend::open(&path, "workspace").unwrap();
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
|
||||
Self {
|
||||
_temp: temp,
|
||||
backend,
|
||||
conn,
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket(&self) -> Ticket {
|
||||
let mut input = NewTicket::new("Private title");
|
||||
input.body = "Private body".into();
|
||||
input.author = Some("Private actor".into());
|
||||
self.backend.create_with_snapshot(input).unwrap().1
|
||||
}
|
||||
|
||||
fn rows(&self) -> Vec<Row> {
|
||||
self.conn.prepare("SELECT sequence,event_id,workspace_id,ticket_id,event_type,occurred_at,domain_event_ref,content_digest,correlation_id,causation_id FROM typed_ticket_service_outbox ORDER BY sequence").unwrap()
|
||||
.query_map([], |row| Ok(Row {
|
||||
sequence: row.get(0)?, id: row.get(1)?, workspace: row.get(2)?, ticket: row.get(3)?,
|
||||
kind: row.get(4)?, at: row.get(5)?, domain_ref: row.get(6)?, digest: row.get(7)?,
|
||||
correlation: row.get(8)?, causation: row.get(9)?,
|
||||
})).unwrap().collect::<rusqlite::Result<_>>().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Row {
|
||||
sequence: i64,
|
||||
id: String,
|
||||
workspace: String,
|
||||
ticket: String,
|
||||
kind: String,
|
||||
at: String,
|
||||
domain_ref: String,
|
||||
digest: String,
|
||||
correlation: String,
|
||||
causation: String,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_append_edits_and_case_emit_actual_digest_and_exact_domain_identity() {
|
||||
let f = Fixture::new();
|
||||
let initial = f.ticket();
|
||||
let id = initial.meta.id.clone();
|
||||
let created = f.rows().pop().unwrap();
|
||||
assert_eq!(created.domain_ref, initial.events[0].event_ref);
|
||||
assert_eq!(created.digest, ticket_content_digest(&initial));
|
||||
assert_eq!(created.at, initial.events[0].at.as_deref().unwrap());
|
||||
assert_eq!(
|
||||
(
|
||||
created.workspace.as_str(),
|
||||
created.ticket.as_str(),
|
||||
created.kind.as_str()
|
||||
),
|
||||
("workspace", id.as_str(), "changed")
|
||||
);
|
||||
assert_eq!(created.correlation, created.id);
|
||||
assert_eq!(created.causation, created.domain_ref);
|
||||
|
||||
f.backend
|
||||
.add_event(
|
||||
id.clone().into(),
|
||||
NewTicketEvent::new(TicketEventKind::Comment, "Private comment"),
|
||||
)
|
||||
.unwrap();
|
||||
let appended = f.backend.show(id.clone().into()).unwrap();
|
||||
assert_eq!(
|
||||
f.rows()[1].domain_ref,
|
||||
appended.events.last().unwrap().event_ref
|
||||
);
|
||||
assert_eq!(f.rows()[1].digest, ticket_content_digest(&initial));
|
||||
|
||||
let edited = f
|
||||
.backend
|
||||
.edit_item(
|
||||
id.clone().into(),
|
||||
TicketItemEdit {
|
||||
title: Some("Changed private title".into()),
|
||||
body: Some("Changed private body".into()),
|
||||
targets: Some(TicketTargetsEdit::Set {
|
||||
targets: vec![TicketTarget {
|
||||
repository_key: "main".into(),
|
||||
ref_selector: Some("develop".into()),
|
||||
access: TicketTargetAccess::ReadWrite,
|
||||
}],
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let change_id: String = f
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT change_id FROM typed_ticket_content_changes ORDER BY rowid DESC LIMIT 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(f.rows()[2].domain_ref, change_id);
|
||||
assert_eq!(f.rows()[2].digest, ticket_content_digest(&edited));
|
||||
assert_ne!(f.rows()[2].digest, created.digest);
|
||||
|
||||
let post = edited.events.last().unwrap();
|
||||
f.backend
|
||||
.edit_post_with_snapshots(
|
||||
id.clone().into(),
|
||||
TicketPostEdit {
|
||||
event_ref: post.event_ref.clone(),
|
||||
expected_body: post.body.clone(),
|
||||
body: "Edited private comment".into(),
|
||||
author: "Private editor".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let change_id: String = f
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT change_id FROM typed_ticket_content_changes ORDER BY rowid DESC LIMIT 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(f.rows()[3].domain_ref, change_id);
|
||||
assert_eq!(f.rows()[3].digest, ticket_content_digest(&edited));
|
||||
|
||||
let request = TicketCaseUpdate {
|
||||
expected_content_digest: ticket_content_digest(&edited),
|
||||
expected_state: TicketState::Open,
|
||||
expected_closure_operation_key: None,
|
||||
operation_key: "close-once".into(),
|
||||
action: TicketCaseAction::Close {
|
||||
reason: TicketClosureReason::Completed,
|
||||
},
|
||||
explanation: "Private judgment".into(),
|
||||
references: vec![],
|
||||
author: Some("Private judge".into()),
|
||||
};
|
||||
let closed = f.backend.update_case(&id, request.clone()).unwrap();
|
||||
assert_eq!(
|
||||
f.rows()[4].domain_ref,
|
||||
closed.events.last().unwrap().event_ref
|
||||
);
|
||||
assert_eq!(f.rows()[4].digest, ticket_content_digest(&edited));
|
||||
let before = f.rows();
|
||||
f.backend.update_case(&id, request).unwrap();
|
||||
f.backend
|
||||
.edit_item(
|
||||
id.into(),
|
||||
TicketItemEdit {
|
||||
title: Some(edited.meta.title),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
f.rows(),
|
||||
before,
|
||||
"replay and no-op must not publish new events"
|
||||
);
|
||||
assert!(
|
||||
before
|
||||
.windows(2)
|
||||
.all(|rows| rows[0].sequence < rows[1].sequence && rows[0].id != rows[1].id)
|
||||
);
|
||||
let columns: Vec<String> = f
|
||||
.conn
|
||||
.prepare("PRAGMA table_info(typed_ticket_service_outbox)")
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get(1))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<_>>()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
columns,
|
||||
[
|
||||
"sequence",
|
||||
"event_id",
|
||||
"workspace_id",
|
||||
"ticket_id",
|
||||
"event_type",
|
||||
"occurred_at",
|
||||
"domain_event_ref",
|
||||
"content_digest",
|
||||
"correlation_id",
|
||||
"causation_id"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_or_outbox_failure_rolls_back_both_sides_of_real_backend_mutations() {
|
||||
let f = Fixture::new();
|
||||
let ticket = f.ticket();
|
||||
let before = f.rows();
|
||||
let failing = f.backend.clone().with_mutation_hook(Arc::new(|_, _| {
|
||||
Err(TicketError::Conflict("injected after outbox insert".into()))
|
||||
}));
|
||||
assert!(
|
||||
failing
|
||||
.add_event(
|
||||
ticket.meta.id.clone().into(),
|
||||
NewTicketEvent::new(TicketEventKind::Comment, "Must roll back")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(failing.create(NewTicket::new("Must not exist")).is_err());
|
||||
assert_eq!(f.rows(), before);
|
||||
assert_eq!(
|
||||
f.backend
|
||||
.show(ticket.meta.id.clone().into())
|
||||
.unwrap()
|
||||
.events,
|
||||
ticket.events
|
||||
);
|
||||
assert_eq!(f.backend.list(TicketListQuery::default()).unwrap().len(), 1);
|
||||
|
||||
f.conn.execute_batch("CREATE TRIGGER reject_outbox BEFORE INSERT ON typed_ticket_service_outbox BEGIN SELECT RAISE(ABORT,'injected outbox failure'); END;").unwrap();
|
||||
assert!(
|
||||
f.backend
|
||||
.edit_item(
|
||||
ticket.meta.id.clone().into(),
|
||||
TicketItemEdit {
|
||||
title: Some("Rejected edit".into()),
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
f.backend
|
||||
.show(ticket.meta.id.clone().into())
|
||||
.unwrap()
|
||||
.meta
|
||||
.title,
|
||||
ticket.meta.title
|
||||
);
|
||||
assert_eq!(
|
||||
f.conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM typed_ticket_content_changes",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(f.rows(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_captures_live_digest_and_retains_safe_metadata_only_on_commit() {
|
||||
let f = Fixture::new();
|
||||
let ticket = f.ticket();
|
||||
let id = &ticket.meta.id;
|
||||
assert!(
|
||||
record_ticket_deletion_in_transaction(&f.conn, "workspace", id, "delete-operation")
|
||||
.is_err()
|
||||
);
|
||||
let before = f.rows();
|
||||
f.conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
record_ticket_deletion_in_transaction(&f.conn, "workspace", id, "delete-operation").unwrap();
|
||||
f.conn
|
||||
.execute(
|
||||
"DELETE FROM typed_tickets WHERE workspace_id='workspace' AND ticket_id=?1",
|
||||
[id],
|
||||
)
|
||||
.unwrap();
|
||||
f.conn.execute_batch("ROLLBACK").unwrap();
|
||||
assert_eq!(f.rows(), before);
|
||||
f.backend.show(id.clone().into()).unwrap();
|
||||
f.conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
let event_id =
|
||||
record_ticket_deletion_in_transaction(&f.conn, "workspace", id, "delete-operation")
|
||||
.unwrap();
|
||||
f.conn
|
||||
.execute(
|
||||
"DELETE FROM typed_tickets WHERE workspace_id='workspace' AND ticket_id=?1",
|
||||
[id],
|
||||
)
|
||||
.unwrap();
|
||||
f.conn.execute_batch("COMMIT").unwrap();
|
||||
let row = f.rows().pop().unwrap();
|
||||
assert_eq!(row.id, event_id);
|
||||
assert_eq!(row.kind, "deleted");
|
||||
assert_eq!(row.domain_ref, "delete-operation");
|
||||
assert_eq!(row.digest, ticket_content_digest(&ticket));
|
||||
assert!(f.backend.show(id.clone().into()).is_err());
|
||||
f.conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
assert!(
|
||||
record_ticket_deletion_in_transaction(&f.conn, "workspace", id, "missing-delete").is_err()
|
||||
);
|
||||
f.conn.execute_batch("ROLLBACK").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_post_edit_uses_edit_fact_and_rollback_removes_edit_and_outbox() {
|
||||
let f = Fixture::new();
|
||||
let ticket = f.ticket();
|
||||
let edit = TicketPostEdit {
|
||||
event_ref: ticket.events[0].event_ref.clone(),
|
||||
expected_body: ticket.document.body.clone(),
|
||||
body: "Edited initial requirements".into(),
|
||||
author: "Private editor".into(),
|
||||
};
|
||||
let (_, current) = f
|
||||
.backend
|
||||
.edit_post_with_snapshots(ticket.meta.id.clone().into(), edit)
|
||||
.unwrap();
|
||||
let (change_id, at): (String, String) = f
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT change_id,at FROM typed_ticket_content_changes",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
let row = f.rows().pop().unwrap();
|
||||
assert_eq!(row.domain_ref, change_id);
|
||||
assert_eq!(row.at, at);
|
||||
assert_eq!(row.digest, ticket_content_digest(¤t));
|
||||
assert_ne!(row.digest, ticket_content_digest(&ticket));
|
||||
let before = f.rows();
|
||||
f.conn.execute_batch("CREATE TRIGGER reject_outbox BEFORE INSERT ON typed_ticket_service_outbox BEGIN SELECT RAISE(ABORT,'outbox rejected'); END;").unwrap();
|
||||
assert!(
|
||||
f.backend
|
||||
.edit_post_with_snapshots(
|
||||
ticket.meta.id.clone().into(),
|
||||
TicketPostEdit {
|
||||
event_ref: current.events[0].event_ref.clone(),
|
||||
expected_body: current.document.body.clone(),
|
||||
body: "Must roll back".into(),
|
||||
author: "Private editor".into(),
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(f.backend.show(ticket.meta.id.into()).unwrap(), current);
|
||||
assert_eq!(f.rows(), before);
|
||||
assert_eq!(
|
||||
f.conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM typed_ticket_content_changes",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outbox_enforces_typed_bounds_unique_identity_and_no_sequence_reuse_after_retention() {
|
||||
let f = Fixture::new();
|
||||
let ticket = f.ticket();
|
||||
let first = f.rows().pop().unwrap();
|
||||
for mutation in [
|
||||
"event_type='arbitrary-name'",
|
||||
"workspace_id=''",
|
||||
"ticket_id=replace(hex(zeroblob(257)),'0','x')",
|
||||
"domain_event_ref=replace(hex(zeroblob(513)),'0','x')",
|
||||
"occurred_at=''",
|
||||
"content_digest='dummy'",
|
||||
"correlation_id=''",
|
||||
"causation_id=''",
|
||||
"event_id='not-hex'",
|
||||
] {
|
||||
assert!(
|
||||
f.conn
|
||||
.execute(
|
||||
&format!("UPDATE typed_ticket_service_outbox SET {mutation}"),
|
||||
[]
|
||||
)
|
||||
.is_err(),
|
||||
"{mutation}"
|
||||
);
|
||||
}
|
||||
assert!(f.conn.execute("INSERT INTO typed_ticket_service_outbox SELECT NULL,event_id,workspace_id,ticket_id,event_type,occurred_at,domain_event_ref,content_digest,correlation_id,causation_id FROM typed_ticket_service_outbox", []).is_err());
|
||||
f.conn
|
||||
.execute("DELETE FROM typed_ticket_service_outbox", [])
|
||||
.unwrap();
|
||||
f.backend
|
||||
.add_event(
|
||||
ticket.meta.id.into(),
|
||||
NewTicketEvent::new(TicketEventKind::Comment, "After retention"),
|
||||
)
|
||||
.unwrap();
|
||||
let next = f.rows().pop().unwrap();
|
||||
assert!(next.sequence > first.sequence);
|
||||
assert_ne!(next.id, first.id);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ use crate::{Result, TicketError, sqlite_err};
|
||||
|
||||
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
|
||||
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
|
||||
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 14;
|
||||
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 15;
|
||||
const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -334,6 +334,8 @@ pub enum HistoricalTicketSchemaTarget {
|
||||
Posts12,
|
||||
/// Content-change facts, before Subject responsibility constraints.
|
||||
Content13,
|
||||
/// Subject responsibility, before Service outbox facts.
|
||||
Subjects14,
|
||||
}
|
||||
impl HistoricalTicketSchemaTarget {
|
||||
fn shape(self) -> TicketSchemaShape {
|
||||
@@ -342,6 +344,7 @@ impl HistoricalTicketSchemaTarget {
|
||||
Self::Tracking11 => TicketSchemaShape::Tracking11,
|
||||
Self::Posts12 => TicketSchemaShape::Posts12,
|
||||
Self::Content13 => TicketSchemaShape::ContentChanges13,
|
||||
Self::Subjects14 => TicketSchemaShape::Subjects14,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -367,7 +370,7 @@ pub fn migrate_historical_sqlite_ticket_schema_in_transaction(
|
||||
/// A savepoint rolls back this component on failure without committing host work.
|
||||
/// Already-current schemas are verified, never repaired or backfilled.
|
||||
pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> Result<()> {
|
||||
migrate_ticket_schema_to_in_transaction(connection, TicketSchemaShape::Subjects14)
|
||||
migrate_ticket_schema_to_in_transaction(connection, TicketSchemaShape::Outbox15)
|
||||
}
|
||||
|
||||
fn migrate_ticket_schema_to_in_transaction(
|
||||
@@ -434,7 +437,8 @@ fn migrate_ticket_schema_to_in_transaction(
|
||||
| TicketSchemaShape::Tracking11
|
||||
| TicketSchemaShape::Posts12
|
||||
| TicketSchemaShape::ContentChanges13
|
||||
| TicketSchemaShape::Subjects14 => {}
|
||||
| TicketSchemaShape::Subjects14
|
||||
| TicketSchemaShape::Outbox15 => {}
|
||||
}
|
||||
if version < 10 {
|
||||
append_component_receipt(connection, 10)?;
|
||||
@@ -470,6 +474,7 @@ enum TicketSchemaShape {
|
||||
Posts12,
|
||||
ContentChanges13,
|
||||
Subjects14,
|
||||
Outbox15,
|
||||
}
|
||||
impl TicketSchemaShape {
|
||||
fn from_version(version: i64) -> Self {
|
||||
@@ -482,6 +487,7 @@ impl TicketSchemaShape {
|
||||
12 => Self::Posts12,
|
||||
13 => Self::ContentChanges13,
|
||||
14 => Self::Subjects14,
|
||||
15 => Self::Outbox15,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -495,6 +501,7 @@ impl TicketSchemaShape {
|
||||
Self::Posts12 => 12,
|
||||
Self::ContentChanges13 => 13,
|
||||
Self::Subjects14 => 14,
|
||||
Self::Outbox15 => 15,
|
||||
}
|
||||
}
|
||||
fn tags(self) -> bool {
|
||||
@@ -506,6 +513,7 @@ impl TicketSchemaShape {
|
||||
| Self::Posts12
|
||||
| Self::ContentChanges13
|
||||
| Self::Subjects14
|
||||
| Self::Outbox15
|
||||
)
|
||||
}
|
||||
fn cases(self) -> bool {
|
||||
@@ -517,13 +525,30 @@ impl TicketSchemaShape {
|
||||
| Self::Posts12
|
||||
| Self::ContentChanges13
|
||||
| Self::Subjects14
|
||||
| Self::Outbox15
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only verification for a retained Host composition boundary. The actual
|
||||
/// supported shape must be at least the requested boundary; later component
|
||||
/// receipts and their complete constraints are verified without relabelling.
|
||||
pub fn verify_historical_sqlite_ticket_schema(
|
||||
connection: &Connection,
|
||||
target: HistoricalTicketSchemaTarget,
|
||||
) -> Result<()> {
|
||||
let version = recognized_schema_version(&load_applied_migrations(connection)?)?;
|
||||
if version < target.shape().version() {
|
||||
return Err(TicketError::Sqlite(
|
||||
"Ticket schema precedes the required Host composition boundary".into(),
|
||||
));
|
||||
}
|
||||
verify_ticket_schema_shape(connection, TicketSchemaShape::from_version(version))
|
||||
}
|
||||
|
||||
/// Verifies the current Ticket-owned SQLite schema without executing DDL.
|
||||
pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
verify_ticket_schema_shape(connection, TicketSchemaShape::Subjects14)
|
||||
verify_ticket_schema_shape(connection, TicketSchemaShape::Outbox15)
|
||||
}
|
||||
|
||||
/// Retained Workspace schema-66 migration produces the pre-classification v7
|
||||
@@ -642,6 +667,7 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
|
||||
TicketSchemaShape::Posts12
|
||||
| TicketSchemaShape::ContentChanges13
|
||||
| TicketSchemaShape::Subjects14
|
||||
| TicketSchemaShape::Outbox15
|
||||
) {
|
||||
if table == "typed_tickets" {
|
||||
columns.retain(|c| c.name != "body");
|
||||
@@ -887,10 +913,14 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
|
||||
| TicketSchemaShape::Posts12
|
||||
| TicketSchemaShape::ContentChanges13
|
||||
| TicketSchemaShape::Subjects14
|
||||
| TicketSchemaShape::Outbox15
|
||||
) {
|
||||
if let Err(error) = crate::assignment_tracking::verify_assignment_tracking_schema(
|
||||
connection,
|
||||
matches!(shape, TicketSchemaShape::Subjects14),
|
||||
matches!(
|
||||
shape,
|
||||
TicketSchemaShape::Subjects14 | TicketSchemaShape::Outbox15
|
||||
),
|
||||
) {
|
||||
push_diagnostic(&mut diagnostics, error.to_string());
|
||||
}
|
||||
@@ -900,6 +930,7 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
|
||||
TicketSchemaShape::Posts12
|
||||
| TicketSchemaShape::ContentChanges13
|
||||
| TicketSchemaShape::Subjects14
|
||||
| TicketSchemaShape::Outbox15
|
||||
) {
|
||||
collect_index_diagnostics(
|
||||
connection,
|
||||
@@ -912,10 +943,15 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
|
||||
}
|
||||
if matches!(
|
||||
shape,
|
||||
TicketSchemaShape::ContentChanges13 | TicketSchemaShape::Subjects14
|
||||
TicketSchemaShape::ContentChanges13
|
||||
| TicketSchemaShape::Subjects14
|
||||
| TicketSchemaShape::Outbox15
|
||||
) {
|
||||
collect_content_change_diagnostics(connection, &mut diagnostics)?;
|
||||
}
|
||||
if matches!(shape, TicketSchemaShape::Outbox15) {
|
||||
collect_service_outbox_diagnostics(connection, &mut diagnostics)?;
|
||||
}
|
||||
if diagnostics.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -939,6 +975,9 @@ fn apply_component_migration(connection: &Connection, version: i64) -> Result<()
|
||||
12 => migrate_posts(connection),
|
||||
13 => migrate_content_changes(connection),
|
||||
14 => crate::subject_assignment::migrate(connection),
|
||||
15 => connection
|
||||
.execute_batch(crate::service_outbox::OUTBOX_SCHEMA_SQL)
|
||||
.map_err(sqlite_err),
|
||||
_ => unreachable!("bounded component migration"),
|
||||
}
|
||||
}
|
||||
@@ -951,6 +990,7 @@ fn component_migration_name(version: i64) -> &'static str {
|
||||
12 => "Canonical Ticket posts",
|
||||
13 => "Ticket content change facts",
|
||||
14 => "Subject Ticket responsibility",
|
||||
15 => "Ticket Service transactional outbox",
|
||||
_ => unreachable!("bounded component receipt"),
|
||||
}
|
||||
}
|
||||
@@ -1051,6 +1091,39 @@ fn collect_content_change_diagnostics(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_service_outbox_diagnostics(
|
||||
connection: &Connection,
|
||||
diagnostics: &mut Vec<String>,
|
||||
) -> Result<()> {
|
||||
// Check the complete owned DDL, including AUTOINCREMENT, bounds, uniqueness
|
||||
// and absence of a cascading FK. Host-owned additional triggers are allowed.
|
||||
for expected in crate::service_outbox::OUTBOX_SCHEMA_SQL.split(';') {
|
||||
let expected = expected.trim();
|
||||
if expected.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let words: Vec<_> = expected.split_whitespace().collect();
|
||||
let kind = words[1].to_ascii_lowercase();
|
||||
let name = words[2];
|
||||
let actual: Option<String> = connection
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type=?1 AND name=?2",
|
||||
params![kind, name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(sqlite_err)?;
|
||||
let normalize = |sql: &str| sql.split_whitespace().collect::<String>();
|
||||
if actual.as_deref().map(normalize) != Some(normalize(expected)) {
|
||||
push_diagnostic(
|
||||
diagnostics,
|
||||
format!("invalid Ticket Service outbox {kind} {name}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_posts(connection: &Connection) -> Result<()> {
|
||||
connection
|
||||
.execute_batch(include_str!("post_migration.sql"))
|
||||
@@ -1312,7 +1385,7 @@ fn recognized_schema_version(applied: &BTreeMap<i64, String>) -> Result<i64> {
|
||||
}
|
||||
// Versions 8 and 9 were independent baseline generations, not
|
||||
// ordered upgrades. Both compose at10; v6 additionally needs v7.
|
||||
for version in [7, 10, 11, 12, 13, 14] {
|
||||
for version in [7, 10, 11, 12, 13, 14, 15] {
|
||||
if version <= baseline || (version == 7 && baseline != 6) {
|
||||
continue;
|
||||
}
|
||||
@@ -1827,7 +1900,8 @@ mod tests {
|
||||
(11, "Ticket-owned assignment tracking".to_owned()),
|
||||
(12, "Canonical Ticket posts".to_owned()),
|
||||
(13, "Ticket content change facts".to_owned()),
|
||||
(14, "Subject Ticket responsibility".to_owned())
|
||||
(14, "Subject Ticket responsibility".to_owned()),
|
||||
(15, "Ticket Service transactional outbox".to_owned())
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -1970,7 +2044,7 @@ mod tests {
|
||||
|
||||
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
|
||||
assert!(error.to_string().contains(
|
||||
"migration history must contain a canonical baseline and ordered migrations through 14"
|
||||
"migration history must contain a canonical baseline and ordered migrations through 15"
|
||||
));
|
||||
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2);
|
||||
}
|
||||
@@ -1992,7 +2066,7 @@ mod tests {
|
||||
|
||||
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
|
||||
assert!(error.to_string().contains(
|
||||
"migration history must contain a canonical baseline and ordered migrations through 14"
|
||||
"migration history must contain a canonical baseline and ordered migrations through 15"
|
||||
));
|
||||
assert!(!table_exists(&connection, "typed_tickets").unwrap());
|
||||
}
|
||||
|
||||
@@ -998,7 +998,7 @@ fn historical_composition_verifies_actual_current_contract_without_downgrade_or_
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_content13_reopens_after_failed_subject_upgrade_then_migrates_atomically_to14() {
|
||||
fn bounded_content13_reopens_after_failed_subject_upgrade_then_migrates_atomically_to_latest() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let path = temp.path().join("retained-content13.db");
|
||||
let (receipts, history, old_table);
|
||||
@@ -1061,7 +1061,7 @@ fn bounded_content13_reopens_after_failed_subject_upgrade_then_migrates_atomical
|
||||
.unwrap();
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
verify_sqlite_ticket_schema(&conn).unwrap();
|
||||
assert_eq!(max_component(&conn), 14);
|
||||
assert_eq!(max_component(&conn), 15);
|
||||
assert_eq!(
|
||||
&rows(&conn, "ticket_schema_migrations")[..receipts.len()],
|
||||
receipts.as_slice()
|
||||
@@ -1077,3 +1077,72 @@ fn bounded_content13_reopens_after_failed_subject_upgrade_then_migrates_atomical
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), applied14);
|
||||
verify_sqlite_ticket_schema(&conn).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_outbox_upgrade_is_empty_preserves_old_facts_and_matches_fresh_schema() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
compose_historical(&conn, HistoricalTicketSchemaTarget::Subjects14);
|
||||
conn.execute_batch("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) VALUES('workspace','old','old','Private title','open','task','P2','open',1);
|
||||
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('workspace','old',-1,'old:-1','comment','Private body');
|
||||
INSERT INTO typed_ticket_content_changes VALUES('old-change','workspace','old','content_edit','Private actor','old-time',1,0,0,NULL);").unwrap();
|
||||
let events = rows(&conn, "typed_ticket_events");
|
||||
let facts = rows(&conn, "typed_ticket_content_changes");
|
||||
let receipts = rows(&conn, "ticket_schema_migrations");
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
verify_sqlite_ticket_schema(&conn).unwrap();
|
||||
assert_eq!(rows(&conn, "typed_ticket_events"), events);
|
||||
assert_eq!(rows(&conn, "typed_ticket_content_changes"), facts);
|
||||
assert_eq!(
|
||||
&rows(&conn, "ticket_schema_migrations")[..receipts.len()],
|
||||
receipts.as_slice()
|
||||
);
|
||||
assert!(rows(&conn, "typed_ticket_service_outbox").is_empty());
|
||||
let fresh = Connection::open_in_memory().unwrap();
|
||||
migrate_sqlite_ticket_schema(&fresh).unwrap();
|
||||
let ddl = |db: &Connection| {
|
||||
db.prepare("SELECT type,name,sql FROM sqlite_master WHERE name LIKE 'typed_ticket_service_outbox%' ORDER BY name").unwrap()
|
||||
.query_map([], |r| Ok((r.get::<_,String>(0)?, r.get::<_,String>(1)?, r.get::<_,String>(2)?))).unwrap().collect::<rusqlite::Result<Vec<_>>>().unwrap()
|
||||
};
|
||||
assert_eq!(ddl(&conn), ddl(&fresh));
|
||||
let all_receipts = rows(&conn, "ticket_schema_migrations");
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), all_receipts);
|
||||
assert!(rows(&conn, "typed_ticket_service_outbox").is_empty());
|
||||
conn.execute_batch("DROP INDEX typed_ticket_service_outbox_workspace_sequence")
|
||||
.unwrap();
|
||||
assert!(
|
||||
verify_sqlite_ticket_schema(&conn)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Ticket Service outbox")
|
||||
);
|
||||
assert!(
|
||||
migrate_sqlite_ticket_schema(&conn).is_err(),
|
||||
"current schema must not repair missing objects"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_service_outbox_migration_rolls_back_component_and_retains_host_transaction() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
compose_historical(&conn, HistoricalTicketSchemaTarget::Subjects14);
|
||||
let before = rows(&conn, "ticket_schema_migrations");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE typed_ticket_service_outbox_workspace_sequence(value TEXT);
|
||||
BEGIN IMMEDIATE; CREATE TABLE host_work(value TEXT); INSERT INTO host_work VALUES('keep');",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(migrate_sqlite_ticket_schema_in_transaction(&conn).is_err());
|
||||
assert!(!conn.is_autocommit());
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), before);
|
||||
assert!(!conn.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='typed_ticket_service_outbox')", [], |r| r.get::<_,bool>(0)).unwrap());
|
||||
conn.execute_batch("COMMIT; DROP TABLE typed_ticket_service_outbox_workspace_sequence;")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT value FROM host_work", [], |r| r.get::<_, String>(0))
|
||||
.unwrap(),
|
||||
"keep"
|
||||
);
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
verify_sqlite_ticket_schema(&conn).unwrap();
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ fn schema12_migrates_body_authors_archived_history_and_stable_references_then_re
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
14
|
||||
15
|
||||
);
|
||||
backend
|
||||
.add_event("known".into(), comment("Next", "author"))
|
||||
|
||||
@@ -597,9 +597,9 @@ fn verifier_rejects_weakened_fact_constraints_trigger_and_migration_history() {
|
||||
assert!(migrate_sqlite_ticket_schema(&f.conn).is_err(), "{from}");
|
||||
}
|
||||
for mutation in [
|
||||
"UPDATE ticket_schema_migrations SET name='unrecognized' WHERE version=14",
|
||||
"UPDATE ticket_schema_migrations SET name='unrecognized' WHERE version=15",
|
||||
"INSERT INTO ticket_schema_migrations VALUES(12,'Canonical Ticket posts','invalid extra history')",
|
||||
"UPDATE ticket_schema_migrations SET version=15 WHERE version=14",
|
||||
"UPDATE ticket_schema_migrations SET version=16 WHERE version=15",
|
||||
] {
|
||||
let f = Fixture::new();
|
||||
f.conn.execute_batch(mutation).unwrap();
|
||||
|
||||
@@ -64,6 +64,7 @@ pub async fn materialize(
|
||||
for grant in &pin.projection().grants {
|
||||
match grant {
|
||||
plugin_plan::PluginGrant::Logging => grants.logging = true,
|
||||
plugin_plan::PluginGrant::TicketEvents => grants.ticket_events = true,
|
||||
plugin_plan::PluginGrant::Https { slot } => {
|
||||
grants.connection_slots.insert(slot.clone());
|
||||
}
|
||||
@@ -100,6 +101,7 @@ pub async fn materialize(
|
||||
request,
|
||||
expectation: ArtifactExpectation::new(metadata.artifact),
|
||||
tools: pin.projection().tools.clone(),
|
||||
services: pin.projection().services.clone(),
|
||||
config: pin.projection().config.clone(),
|
||||
grants,
|
||||
});
|
||||
@@ -172,6 +174,31 @@ impl PluginToolRuntime for PinnedPluginToolRuntime {
|
||||
.connect(snapshot.plan.workspace_id(), worker)
|
||||
.await?;
|
||||
for pin in snapshot.plan.plugins() {
|
||||
for name in &pin.projection().services {
|
||||
let response = services
|
||||
.owner
|
||||
.call(server_api::PluginHostRequest::Service {
|
||||
request: server_api::PluginServiceRequest::Prepare {
|
||||
plan_id: snapshot.plan_id.clone(),
|
||||
installation_id: pin.installation_id().into(),
|
||||
service_name: name.clone(),
|
||||
operation: server_api::PluginServiceLifecycleOperation::Admit,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Plugin Service preflight refused or unavailable")?;
|
||||
if !matches!(
|
||||
response,
|
||||
server_api::PluginHostResponse::Service {
|
||||
response: server_api::PluginServiceResponse::Prepared
|
||||
}
|
||||
) {
|
||||
return Err("Plugin Service preflight protocol mismatch".into());
|
||||
}
|
||||
}
|
||||
if pin.projection().tools.is_empty() {
|
||||
continue;
|
||||
}
|
||||
services
|
||||
.owner
|
||||
.prepare(
|
||||
@@ -195,6 +222,31 @@ impl PluginToolRuntime for PinnedPluginToolRuntime {
|
||||
// Materialization and compilation can wait on I/O. Recheck at publication
|
||||
// preparation, including pure Tools and cache hits, without making a call.
|
||||
for pin in snapshot.plan.plugins() {
|
||||
for name in &pin.projection().services {
|
||||
let response = services
|
||||
.owner
|
||||
.call(server_api::PluginHostRequest::Service {
|
||||
request: server_api::PluginServiceRequest::Prepare {
|
||||
plan_id: snapshot.plan_id.clone(),
|
||||
installation_id: pin.installation_id().into(),
|
||||
service_name: name.clone(),
|
||||
operation: server_api::PluginServiceLifecycleOperation::Admit,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Plugin Service preflight refused or unavailable")?;
|
||||
if !matches!(
|
||||
response,
|
||||
server_api::PluginHostResponse::Service {
|
||||
response: server_api::PluginServiceResponse::Prepared
|
||||
}
|
||||
) {
|
||||
return Err("Plugin Service preflight protocol mismatch".into());
|
||||
}
|
||||
}
|
||||
if pin.projection().tools.is_empty() {
|
||||
continue;
|
||||
}
|
||||
services
|
||||
.owner
|
||||
.prepare(
|
||||
|
||||
@@ -1068,6 +1068,7 @@ where
|
||||
let notify_buffer = worker.notify_buffer_handle();
|
||||
|
||||
worker.finish_startup_feature_cleanup_capture();
|
||||
worker.start_host_ready_feature_tasks();
|
||||
let controller_task = tokio::spawn(controller_loop(
|
||||
worker,
|
||||
method_rx,
|
||||
@@ -4070,6 +4071,147 @@ mod tests {
|
||||
assert!(requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PublicationTask {
|
||||
entered: Arc<tokio::sync::Notify>,
|
||||
stopped: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::feature::background::FeatureBackgroundTask for PublicationTask {
|
||||
async fn run(
|
||||
&self,
|
||||
_: crate::feature::background::BackgroundTaskContext,
|
||||
_: crate::feature::background::BackgroundTaskCancellation,
|
||||
) -> Result<(), crate::hook::HookError> {
|
||||
self.entered.notify_one();
|
||||
std::future::pending().await
|
||||
}
|
||||
async fn stopped(
|
||||
&self,
|
||||
_: crate::feature::background::BackgroundTaskContext,
|
||||
) -> Result<(), crate::hook::HookError> {
|
||||
self.stopped
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::feature::FeatureModule for PublicationTask {
|
||||
fn descriptor(&self) -> crate::feature::FeatureDescriptor {
|
||||
crate::feature::FeatureDescriptor::builtin("publication-task", "Publication task")
|
||||
.with_background_task(
|
||||
crate::feature::BackgroundTaskDeclaration::worker_managed(
|
||||
"observe", "observe",
|
||||
),
|
||||
)
|
||||
}
|
||||
fn install(
|
||||
&self,
|
||||
context: &mut crate::feature::FeatureInstallContext<'_>,
|
||||
) -> Result<(), crate::feature::FeatureInstallError> {
|
||||
let mut spec = crate::feature::background::BackgroundTaskSpec::single_flight(
|
||||
crate::feature::BackgroundTaskDeclaration::worker_managed("observe", "observe"),
|
||||
Duration::from_secs(30),
|
||||
);
|
||||
spec.trigger =
|
||||
crate::feature::background::BackgroundTaskTrigger::HostReady { interval_ms: 1 };
|
||||
context.background_tasks().register(spec, self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
// Real Worker/Store, required Feature installation and Controller
|
||||
// publication. The test task observes the final gate; the model is inert.
|
||||
// The non-canonical branch is shared with restore after head recovery.
|
||||
#[tokio::test]
|
||||
async fn host_ready_tasks_wait_for_start_in_fresh_and_restore_controller_routes() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
for (canonical, publish) in [(true, false), (true, true), (false, true)] {
|
||||
let root = TempDir::new().unwrap();
|
||||
let manifest = manifest::WorkerManifest::from_toml(
|
||||
"[worker]\nname = 'publication'\n[model]\nscheme = 'anthropic'\nmodel_id = 'test'\n[engine]\n[scope]\n").unwrap();
|
||||
let engine =
|
||||
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(
|
||||
ScriptedClient::new(vec![]),
|
||||
);
|
||||
let store = session_store::CombinedStore::new(
|
||||
session_store::FsStore::new(root.path().join("sessions")).unwrap(),
|
||||
session_store::FsWorkerStore::new(root.path().join("workers")).unwrap(),
|
||||
);
|
||||
let mut worker = Worker::new(
|
||||
manifest,
|
||||
engine,
|
||||
store,
|
||||
crate::worker::WorkerWorkspaceContext::local_filesystem(None),
|
||||
crate::worker::WorkerFilesystemAuthority::None,
|
||||
manifest::Scope::empty(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
worker.set_system_prompt_template(
|
||||
crate::SystemPromptTemplate::parse(
|
||||
"default",
|
||||
crate::PromptCatalogSource::builtins_only(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
if !canonical {
|
||||
worker.materialize_durable_session_head().await.unwrap();
|
||||
}
|
||||
let mut initialized =
|
||||
WorkerController::initialize_inner(worker, root.path(), None, canonical)
|
||||
.await
|
||||
.unwrap();
|
||||
let entered = Arc::new(tokio::sync::Notify::new());
|
||||
let stopped = Arc::new(AtomicBool::new(false));
|
||||
let report = initialized.worker.install_features(
|
||||
crate::feature::FeatureRegistryBuilder::new().with_module(PublicationTask {
|
||||
entered: entered.clone(),
|
||||
stopped: stopped.clone(),
|
||||
}),
|
||||
);
|
||||
assert!(!report.has_errors());
|
||||
let publication = initialized.prepare_publication().await.unwrap();
|
||||
assert!(!stopped.load(Ordering::SeqCst));
|
||||
assert!(
|
||||
futures::FutureExt::now_or_never(entered.notified()).is_none(),
|
||||
"preparation cannot start tasks"
|
||||
);
|
||||
if !publish {
|
||||
drop(publication);
|
||||
drop(initialized);
|
||||
assert!(futures::FutureExt::now_or_never(entered.notified()).is_none());
|
||||
assert!(
|
||||
!stopped.load(Ordering::SeqCst),
|
||||
"unstarted task has no on-stop effects"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let (handle, shutdown, controller) = initialized.start(publication);
|
||||
tokio::time::timeout(Duration::from_secs(2), entered.notified())
|
||||
.await
|
||||
.unwrap();
|
||||
handle
|
||||
.send(Method::Shutdown {
|
||||
command: WorkerCommandEnvelope { command_id: 1 },
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(5), shutdown)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(5), controller)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
stopped.load(Ordering::SeqCst),
|
||||
"Controller shutdown joins task cleanup"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_initialization_failure_does_not_reclaim_saved_children() {
|
||||
let root = TempDir::new().unwrap();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,13 @@
|
||||
//! access: callers bind exact persisted pins and current owner adapters before
|
||||
//! controller publication. Verified bytes and compiled Components are not leases.
|
||||
|
||||
mod service;
|
||||
|
||||
use crate::feature::background::{BackgroundTaskSpec, BackgroundTaskTrigger};
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureHookPoint, FeatureId, FeatureInstallContext, FeatureInstallError,
|
||||
FeatureModule, FeatureRuntimeKind, HookDeclaration, ToolContribution, ToolDeclaration,
|
||||
BackgroundTaskDeclaration, FeatureDescriptor, FeatureHookPoint, FeatureId,
|
||||
FeatureInstallContext, FeatureInstallError, FeatureModule, FeatureRuntimeKind, HookDeclaration,
|
||||
ToolContribution, ToolDeclaration,
|
||||
};
|
||||
use crate::hook::{
|
||||
Hook, HookError, HookErrorCategory, HookExecutionPolicy, WorkerStopping, WorkerStoppingContext,
|
||||
@@ -39,6 +43,7 @@ pub struct PluginToolPin {
|
||||
pub request: server_api::PluginArtifactDeliveryRequest,
|
||||
pub expectation: ArtifactExpectation,
|
||||
pub tools: Vec<String>,
|
||||
pub services: Vec<String>,
|
||||
pub config: Value,
|
||||
pub grants: Grants,
|
||||
}
|
||||
@@ -96,8 +101,19 @@ impl Drop for CallLease {
|
||||
pub struct PluginToolsFeature {
|
||||
tools: Vec<(ToolMeta, Arc<PluginTool>)>,
|
||||
calls: Arc<Mutex<Calls>>,
|
||||
services: Vec<Arc<service::ServiceDriver>>,
|
||||
}
|
||||
impl PluginToolsFeature {
|
||||
/// Called only after the task registry has joined every driver. Retained
|
||||
/// Service proofs keep shutdown ownership here until release is reconciled;
|
||||
/// this never restarts a task or invokes a lifecycle export again.
|
||||
pub(crate) async fn reconcile_service_shutdown(&self) -> Result<(), HookError> {
|
||||
for service in &self.services {
|
||||
service.reconcile_release().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn prepare(
|
||||
pins: Vec<PluginToolPin>,
|
||||
source: &dyn ArtifactSource,
|
||||
@@ -116,6 +132,7 @@ impl PluginToolsFeature {
|
||||
let mut names = BTreeSet::new();
|
||||
let mut installations = BTreeSet::new();
|
||||
let mut tools = Vec::new();
|
||||
let mut services = Vec::new();
|
||||
for pin in pins {
|
||||
let descriptor = pin.expectation.descriptor();
|
||||
if pin.workspace_id.is_empty()
|
||||
@@ -138,6 +155,40 @@ impl PluginToolsFeature {
|
||||
.resolve(source, &pin.workspace_id, &pin.request, &pin.expectation)
|
||||
.await
|
||||
.map_err(|_| Delivery)?;
|
||||
if !pin.services.is_empty() {
|
||||
if !pin.tools.is_empty() || !pin.grants.ticket_events || pin.services.len() > 32 {
|
||||
return Err(InvalidSnapshot);
|
||||
}
|
||||
let compiled = Arc::new(
|
||||
executor
|
||||
.compile_service(
|
||||
artifact.bytes(),
|
||||
&descriptor.package_digest,
|
||||
&descriptor.artifact_digest,
|
||||
u64::from(descriptor.artifact_size),
|
||||
)
|
||||
.map_err(|_| Component)?,
|
||||
);
|
||||
for name in &pin.services {
|
||||
if !compiled
|
||||
.services()
|
||||
.iter()
|
||||
.any(|service| &service.name == name)
|
||||
|| !names.insert(format!("{}__{}", pin.installation_id, name))
|
||||
{
|
||||
return Err(InvalidSnapshot);
|
||||
}
|
||||
services.push(Arc::new(service::ServiceDriver::new(
|
||||
executor.clone(),
|
||||
compiled.clone(),
|
||||
owner.clone(),
|
||||
&pin,
|
||||
name.clone(),
|
||||
config_json.clone(),
|
||||
)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let compiled = Arc::new(
|
||||
executor
|
||||
.compile(
|
||||
@@ -189,7 +240,16 @@ impl PluginToolsFeature {
|
||||
tools.push((meta, tool));
|
||||
}
|
||||
}
|
||||
Ok(Self { tools, calls })
|
||||
// All immutable contributions have been validated before any lifecycle
|
||||
// export. Start is logging-only; failed preparation leaves no consumer.
|
||||
for service in &services {
|
||||
service.prepare_start().await.map_err(|_| Component)?;
|
||||
}
|
||||
Ok(Self {
|
||||
tools,
|
||||
calls,
|
||||
services,
|
||||
})
|
||||
}
|
||||
}
|
||||
impl FeatureModule for PluginToolsFeature {
|
||||
@@ -205,6 +265,14 @@ impl FeatureModule for PluginToolsFeature {
|
||||
.tools
|
||||
.push(ToolDeclaration::new(&meta.name, &meta.description));
|
||||
}
|
||||
for service in &self.services {
|
||||
descriptor
|
||||
.background_tasks
|
||||
.push(BackgroundTaskDeclaration::worker_managed(
|
||||
service.task_name(),
|
||||
"Commit-event Service pump",
|
||||
));
|
||||
}
|
||||
descriptor
|
||||
}
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
@@ -217,6 +285,17 @@ impl FeatureModule for PluginToolsFeature {
|
||||
.tools()
|
||||
.register(ToolContribution::new(name, definition))?;
|
||||
}
|
||||
for service in &self.services {
|
||||
let mut spec = BackgroundTaskSpec::single_flight(
|
||||
BackgroundTaskDeclaration::worker_managed(
|
||||
service.task_name(),
|
||||
"Commit-event Service pump",
|
||||
),
|
||||
std::time::Duration::from_secs(30),
|
||||
);
|
||||
spec.trigger = BackgroundTaskTrigger::HostReady { interval_ms: 100 };
|
||||
context.background_tasks().register(spec, service.clone())?;
|
||||
}
|
||||
context.hooks().add_worker_stopping(
|
||||
"stop",
|
||||
HookExecutionPolicy::fail_closed(),
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
//! Bounded outbox pumps run in the existing Feature task registry. Consumer and
|
||||
//! effect truth stays with the owner; this module retains only an active lease.
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use super::*;
|
||||
use crate::feature::background::{
|
||||
BackgroundTaskCancellation, BackgroundTaskContext, FeatureBackgroundTask,
|
||||
};
|
||||
use server_api::*;
|
||||
use std::{any::Any, time::Duration};
|
||||
use yoi_plugin_runtime::{
|
||||
CompiledService, ServiceOutput,
|
||||
host::{errors, http, logging},
|
||||
};
|
||||
|
||||
pub(super) struct ServiceDriver {
|
||||
pub executor: Arc<Executor>,
|
||||
pub compiled: Arc<CompiledService>,
|
||||
pub owner: Arc<dyn PluginHostOwner>,
|
||||
pub workspace: String,
|
||||
pub installation: String,
|
||||
pub plan: String,
|
||||
pub subject: String,
|
||||
pub grants: Grants,
|
||||
pub name: String,
|
||||
pub config: String,
|
||||
lease: Mutex<Option<PluginServiceLease>>,
|
||||
stop_started: Mutex<Option<PluginServiceLease>>,
|
||||
// A selector for read-only recovery observation, never a mirrored pause state.
|
||||
parked_consumer: Mutex<Option<String>>,
|
||||
// Unconfirmed control commit, not permission to replay an external effect.
|
||||
unconfirmed_indeterminate: Mutex<Option<String>>,
|
||||
prepared_start: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
impl ServiceDriver {
|
||||
pub fn new(
|
||||
executor: Arc<Executor>,
|
||||
compiled: Arc<CompiledService>,
|
||||
owner: Arc<dyn PluginHostOwner>,
|
||||
pin: &PluginToolPin,
|
||||
name: String,
|
||||
config: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
executor,
|
||||
compiled,
|
||||
owner,
|
||||
workspace: pin.workspace_id.clone(),
|
||||
installation: pin.installation_id.clone(),
|
||||
plan: pin.plan_id.clone(),
|
||||
subject: pin.subject.clone(),
|
||||
grants: pin.grants.clone(),
|
||||
name,
|
||||
config,
|
||||
lease: Mutex::new(None),
|
||||
stop_started: Mutex::new(None),
|
||||
parked_consumer: Mutex::new(None),
|
||||
unconfirmed_indeterminate: Mutex::new(None),
|
||||
prepared_start: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
fn lifecycle_host(&self) -> Arc<LifecycleHost> {
|
||||
Arc::new(LifecycleHost {
|
||||
owner: self.owner.clone(),
|
||||
plan: self.plan.clone(),
|
||||
installation: self.installation.clone(),
|
||||
name: self.name.clone(),
|
||||
})
|
||||
}
|
||||
pub async fn prepare_start(&self) -> Result<(), HookError> {
|
||||
let output = self
|
||||
.executor
|
||||
.service_start(
|
||||
&self.compiled,
|
||||
self.context(
|
||||
format!("{}:{}:start", self.installation, self.name),
|
||||
CancellationToken::new(),
|
||||
),
|
||||
self.lifecycle_host(),
|
||||
&self.name,
|
||||
&self.config,
|
||||
)
|
||||
.await;
|
||||
if !matches!(output.outcome, Ok(ServiceOutput::Started)) {
|
||||
return Err(dependency());
|
||||
}
|
||||
self.prepared_start
|
||||
.store(true, std::sync::atomic::Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
pub fn task_name(&self) -> String {
|
||||
format!("service:{}:{}", self.installation, self.name)
|
||||
}
|
||||
fn context(&self, invocation_id: String, cancellation: CancellationToken) -> InvocationContext {
|
||||
InvocationContext {
|
||||
workspace: self.workspace.clone(),
|
||||
installation: self.installation.clone(),
|
||||
plan: self.plan.clone(),
|
||||
subject: self.subject.clone(),
|
||||
invocation_id,
|
||||
artifact: self.compiled.identity().clone(),
|
||||
tool_name: self.name.clone(),
|
||||
grants: self.grants.clone(),
|
||||
deadline: tokio::time::Instant::now()
|
||||
+ self
|
||||
.executor
|
||||
.limits()
|
||||
.wall_time
|
||||
.min(Duration::from_secs(20)),
|
||||
cancellation,
|
||||
}
|
||||
}
|
||||
async fn request(
|
||||
&self,
|
||||
request: PluginServiceRequest,
|
||||
) -> Result<PluginServiceResponse, HookError> {
|
||||
request_service(self.owner.as_ref(), request)
|
||||
.await
|
||||
.map_err(|_| dependency())
|
||||
}
|
||||
pub(super) async fn reconcile_release(&self) -> Result<(), HookError> {
|
||||
let Some(lease) = self.lease.lock().expect("Service lease lock").clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
let released = match request_service(
|
||||
self.owner.as_ref(),
|
||||
PluginServiceRequest::Release {
|
||||
lease: lease.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(PluginServiceResponse::Released) => true,
|
||||
// Release authenticates this consumer's Worker before its exact-token
|
||||
// CAS. Conflict confirms this proof no longer owns the lease; do not
|
||||
// mutate or wait for the successor as though it were our resource.
|
||||
Err(error) if error.code == PluginHostErrorCode::Conflict => true,
|
||||
_ => false,
|
||||
};
|
||||
if !released {
|
||||
return Err(dependency());
|
||||
}
|
||||
let mut retained = self.lease.lock().expect("Service lease lock");
|
||||
if retained.as_ref() == Some(&lease) {
|
||||
*retained = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn acquire(&self) -> Result<PluginServiceLease, HookError> {
|
||||
if let Some(lease) = self.lease.lock().expect("Service lease lock").clone() {
|
||||
if self
|
||||
.stop_started
|
||||
.lock()
|
||||
.expect("Service stop lock")
|
||||
.as_ref()
|
||||
== Some(&lease)
|
||||
{
|
||||
return Err(dependency());
|
||||
}
|
||||
return Ok(lease);
|
||||
}
|
||||
let PluginServiceResponse::Acquired { lease } = self
|
||||
.request(PluginServiceRequest::Acquire {
|
||||
plan_id: self.plan.clone(),
|
||||
installation_id: self.installation.clone(),
|
||||
service_name: self.name.clone(),
|
||||
})
|
||||
.await?
|
||||
else {
|
||||
return Err(dependency());
|
||||
};
|
||||
*self.lease.lock().expect("Service lease lock") = Some(lease.clone());
|
||||
if !self
|
||||
.prepared_start
|
||||
.swap(false, std::sync::atomic::Ordering::AcqRel)
|
||||
{
|
||||
self.prepare_start().await?;
|
||||
self.prepared_start
|
||||
.store(false, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
Ok(lease)
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl FeatureBackgroundTask for Arc<ServiceDriver> {
|
||||
async fn run(
|
||||
&self,
|
||||
context: BackgroundTaskContext,
|
||||
cancellation: BackgroundTaskCancellation,
|
||||
) -> Result<(), HookError> {
|
||||
context.generation_fence.ensure_current()?;
|
||||
if cancellation.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
let parked = self
|
||||
.parked_consumer
|
||||
.lock()
|
||||
.expect("Service observer lock")
|
||||
.clone();
|
||||
if let Some(consumer_id) = parked {
|
||||
let diagnostics = match self
|
||||
.request(PluginServiceRequest::Diagnostics { consumer_id })
|
||||
.await
|
||||
{
|
||||
Ok(PluginServiceResponse::Diagnostics { diagnostics }) => diagnostics,
|
||||
// Observation alone neither retries a guest nor overrides live
|
||||
// revocation. Keep the parked pump inert until its owner returns.
|
||||
_ => return Ok(()),
|
||||
};
|
||||
if !diagnostics.lease_active {
|
||||
// A fresh owner observation confirms any retained proof is now
|
||||
// fenced (release may have committed before its response was lost).
|
||||
// Never reuse that proof after expiry or audited recovery.
|
||||
*self.lease.lock().expect("Service lease lock") = None;
|
||||
}
|
||||
if diagnostics.pause_reason.is_some() || diagnostics.lease_active {
|
||||
return Ok(());
|
||||
}
|
||||
let unconfirmed = self
|
||||
.unconfirmed_indeterminate
|
||||
.lock()
|
||||
.expect("Service completion lock")
|
||||
.clone();
|
||||
if let Some(event_id) = unconfirmed {
|
||||
if diagnostics.current_event_id.as_ref() == Some(&event_id) {
|
||||
// No pause is not evidence that an uncertain completion did
|
||||
// not execute. Reconcile the same control intent under a new
|
||||
// owner lease, without polling or invoking the guest again.
|
||||
if let Ok(lease) = self.acquire().await {
|
||||
if matches!(
|
||||
self.request(PluginServiceRequest::Complete {
|
||||
lease,
|
||||
event_id,
|
||||
result: PluginServiceEventResult::Indeterminate,
|
||||
})
|
||||
.await,
|
||||
Ok(PluginServiceResponse::Completed)
|
||||
) {
|
||||
*self
|
||||
.unconfirmed_indeterminate
|
||||
.lock()
|
||||
.expect("Service completion lock") = None;
|
||||
}
|
||||
if self.stopped(context).await.is_err() {
|
||||
tracing::warn!("Plugin Service stop requires owner reconciliation");
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// The owner has already advanced this head (including an audited
|
||||
// skip). Never recreate it or change its stable event identity.
|
||||
*self
|
||||
.unconfirmed_indeterminate
|
||||
.lock()
|
||||
.expect("Service completion lock") = None;
|
||||
}
|
||||
// Only an audited owner recovery clears a domain pause. Observing
|
||||
// that decision wakes this consumer alone, not the entire registry.
|
||||
*self.parked_consumer.lock().expect("Service observer lock") = None;
|
||||
}
|
||||
if self.lease.lock().expect("Service lease lock").is_none() {
|
||||
self.acquire().await?;
|
||||
return Ok(());
|
||||
}
|
||||
let lease = self.acquire().await?;
|
||||
let response = match self
|
||||
.request(PluginServiceRequest::Poll {
|
||||
lease: lease.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(_) => {
|
||||
*self.parked_consumer.lock().expect("Service observer lock") =
|
||||
Some(lease.consumer_id.clone());
|
||||
if self.stopped(context).await.is_err() {
|
||||
tracing::warn!("Plugin Service stop requires owner reconciliation");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
match response {
|
||||
PluginServiceResponse::Idle => Ok(()),
|
||||
PluginServiceResponse::Diagnostics { diagnostics } => {
|
||||
// Retain only the consumer selector while paused. Stop the guest
|
||||
// lifetime and release its lease so management can recover it.
|
||||
// The registry retains a bounded read-only observation pump.
|
||||
*self.parked_consumer.lock().expect("Service observer lock") =
|
||||
Some(diagnostics.consumer_id);
|
||||
// Revoked logging or a failed stop export must not hide the
|
||||
// audited recovery observer. Release is attempted independently.
|
||||
if self.stopped(context).await.is_err() {
|
||||
tracing::warn!("Plugin Service stop requires owner reconciliation");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
PluginServiceResponse::Delivery {
|
||||
envelope,
|
||||
invocation_id,
|
||||
expires_at_ms,
|
||||
} => {
|
||||
context.generation_fence.ensure_current()?;
|
||||
if cancellation.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
let consumer = PluginConsumer::Consumer {
|
||||
consumer_id: lease.consumer_id.clone(),
|
||||
event_id: envelope.event_id.clone(),
|
||||
export_name: self.name.clone(),
|
||||
};
|
||||
let owner = Arc::new(DeliveryOwner {
|
||||
owner: self.owner.clone(),
|
||||
lease: lease.clone(),
|
||||
consumer: consumer.clone(),
|
||||
invocation_id: invocation_id.clone(),
|
||||
expires_at_ms,
|
||||
});
|
||||
let token = CancellationToken::new();
|
||||
let host = Arc::new(
|
||||
OwnerHost::begin(owner, self.context(invocation_id, token.clone()), consumer)
|
||||
.await
|
||||
.map_err(|_| dependency())?,
|
||||
);
|
||||
let call = self.executor.service_event(
|
||||
&self.compiled,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
&self.name,
|
||||
&envelope,
|
||||
&self.config,
|
||||
);
|
||||
tokio::pin!(call);
|
||||
let output = tokio::select! {
|
||||
result = &mut call => result,
|
||||
_ = cancellation.cancelled() => { token.cancel(); call.await },
|
||||
};
|
||||
let result = if matches!(output.effect, errors::EffectObservation::Indeterminate) {
|
||||
PluginServiceEventResult::Indeterminate
|
||||
} else {
|
||||
match output.outcome {
|
||||
Ok(ServiceOutput::Event(result)) => result,
|
||||
_ => PluginServiceEventResult::RetryAfter { delay_ms: 1_000 },
|
||||
}
|
||||
};
|
||||
context.generation_fence.ensure_current()?;
|
||||
// Guest return is not ack. Only this fenced owner transaction
|
||||
// commits a cursor; transport uncertainty leaves it for recovery.
|
||||
let indeterminate = matches!(result, PluginServiceEventResult::Indeterminate);
|
||||
if !matches!(
|
||||
self.request(PluginServiceRequest::Complete {
|
||||
lease: lease.clone(),
|
||||
event_id: envelope.event_id.clone(),
|
||||
result
|
||||
})
|
||||
.await,
|
||||
Ok(PluginServiceResponse::Completed)
|
||||
) {
|
||||
// The cursor/pause transaction may have committed even if its
|
||||
// reply was lost. Keep the read-only observer alive instead
|
||||
// of permanently deactivating this HostReady registration.
|
||||
if indeterminate {
|
||||
*self
|
||||
.unconfirmed_indeterminate
|
||||
.lock()
|
||||
.expect("Service completion lock") = Some(envelope.event_id.clone());
|
||||
}
|
||||
*self.parked_consumer.lock().expect("Service observer lock") =
|
||||
Some(lease.consumer_id.clone());
|
||||
if self.stopped(context).await.is_err() {
|
||||
tracing::warn!("Plugin Service stop requires owner reconciliation");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(dependency()),
|
||||
}
|
||||
}
|
||||
async fn stopped(&self, _: BackgroundTaskContext) -> Result<(), HookError> {
|
||||
// Inline pause cleanup is cancellable. Retain the exact proof until the
|
||||
// owner confirms release, so protected registry cleanup can finish it if
|
||||
// the run future is dropped during on-stop or transport uncertainty.
|
||||
let lease = self.lease.lock().expect("Service lease lock").clone();
|
||||
let Some(lease) = lease else {
|
||||
return Ok(());
|
||||
};
|
||||
let attempt_stop = {
|
||||
let mut started = self.stop_started.lock().expect("Service stop lock");
|
||||
if started.as_ref() == Some(&lease) {
|
||||
false
|
||||
} else {
|
||||
*started = Some(lease.clone());
|
||||
true
|
||||
}
|
||||
};
|
||||
let stop_failed = if attempt_stop {
|
||||
let output = self
|
||||
.executor
|
||||
.service_stop(
|
||||
&self.compiled,
|
||||
self.context(
|
||||
format!("{}:stop", lease.consumer_id),
|
||||
CancellationToken::new(),
|
||||
),
|
||||
self.lifecycle_host(),
|
||||
&self.name,
|
||||
"Host task stopped",
|
||||
)
|
||||
.await;
|
||||
!matches!(output.outcome, Ok(ServiceOutput::Stopped))
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Marking the attempt before the await prevents an interrupted inline
|
||||
// cleanup from rerunning the stop export in protected registry cleanup.
|
||||
// A failed/revoked/timed-out export never prevents fencing/release.
|
||||
self.reconcile_release().await?;
|
||||
if stop_failed {
|
||||
return Err(dependency());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn dependency() -> HookError {
|
||||
HookError::new(
|
||||
HookErrorCategory::Dependency,
|
||||
"Plugin Service requires owner reconciliation",
|
||||
)
|
||||
}
|
||||
fn unavailable() -> PluginHostError {
|
||||
PluginHostError {
|
||||
code: PluginHostErrorCode::Unavailable,
|
||||
effect: PluginEffectObservation::NotDispatched,
|
||||
diagnostic_id: String::new(),
|
||||
}
|
||||
}
|
||||
async fn request_service(
|
||||
owner: &dyn PluginHostOwner,
|
||||
request: PluginServiceRequest,
|
||||
) -> Result<PluginServiceResponse, PluginHostError> {
|
||||
// Event imports already run under the executor's total invocation deadline.
|
||||
// A shorter transport deadline could abandon a still-running admitted effect
|
||||
// before the owner's HTTP/receipt budgets expire. Control requests remain
|
||||
// separately bounded because they run outside a guest invocation.
|
||||
let budget = if matches!(&request, PluginServiceRequest::Host { .. }) {
|
||||
Duration::from_secs(30)
|
||||
} else {
|
||||
Duration::from_secs(2)
|
||||
};
|
||||
let response = tokio::time::timeout(budget, owner.call(PluginHostRequest::Service { request }))
|
||||
.await
|
||||
.map_err(|_| unavailable())??;
|
||||
if let PluginHostResponse::Service { response } = response {
|
||||
Ok(response)
|
||||
} else {
|
||||
Err(unavailable())
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapts an already owner-issued delivery to the existing receipt-aware Host.
|
||||
/// Begin here only binds that receipt; it cannot create a logical invocation.
|
||||
struct DeliveryOwner {
|
||||
owner: Arc<dyn PluginHostOwner>,
|
||||
lease: PluginServiceLease,
|
||||
consumer: PluginConsumer,
|
||||
invocation_id: String,
|
||||
expires_at_ms: i64,
|
||||
}
|
||||
#[async_trait]
|
||||
impl PluginHostOwner for DeliveryOwner {
|
||||
async fn call(
|
||||
&self,
|
||||
request: PluginHostRequest,
|
||||
) -> Result<PluginHostResponse, PluginHostError> {
|
||||
if let PluginHostRequest::Begin { consumer, .. } = &request {
|
||||
if consumer != &self.consumer {
|
||||
return Err(unavailable());
|
||||
}
|
||||
return Ok(PluginHostResponse::Invocation {
|
||||
invocation_id: self.invocation_id.clone(),
|
||||
expires_at_ms: self.expires_at_ms,
|
||||
});
|
||||
}
|
||||
let may_dispatch = matches!(
|
||||
request,
|
||||
PluginHostRequest::Send { .. } | PluginHostRequest::DataMutate { .. }
|
||||
);
|
||||
let response = request_service(
|
||||
self.owner.as_ref(),
|
||||
PluginServiceRequest::Host {
|
||||
lease: self.lease.clone(),
|
||||
request: Box::new(request.try_into().map_err(|_| unavailable())?),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match response {
|
||||
Ok(PluginServiceResponse::Host { response }) => Ok((*response).into()),
|
||||
Ok(_) => Err(unavailable()),
|
||||
Err(mut error) => {
|
||||
if may_dispatch && matches!(error.code, PluginHostErrorCode::Unavailable) {
|
||||
error.code = PluginHostErrorCode::Indeterminate;
|
||||
error.effect = PluginEffectObservation::Indeterminate;
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lifecycle exports have no object/connection/data resources. Bounded
|
||||
/// logging follows a current owner check, never an event receipt or guest grant.
|
||||
struct LifecycleHost {
|
||||
owner: Arc<dyn PluginHostOwner>,
|
||||
plan: String,
|
||||
installation: String,
|
||||
name: String,
|
||||
}
|
||||
fn denied_host() -> errors::HostError {
|
||||
errors::HostError {
|
||||
code: errors::Code::Denied,
|
||||
message: "lifecycle operation denied".into(),
|
||||
diagnostic_id: String::new(),
|
||||
effect: errors::EffectObservation::NotDispatched,
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl Host for LifecycleHost {
|
||||
async fn authorize(
|
||||
&self,
|
||||
_: &InvocationContext,
|
||||
operation: Operation<'_>,
|
||||
) -> Result<(), errors::HostError> {
|
||||
if !matches!(
|
||||
operation,
|
||||
Operation::Admit | Operation::Publish | Operation::Log
|
||||
) {
|
||||
return Err(denied_host());
|
||||
}
|
||||
if matches!(operation, Operation::Log) {
|
||||
return Ok(());
|
||||
}
|
||||
match request_service(
|
||||
self.owner.as_ref(),
|
||||
PluginServiceRequest::Prepare {
|
||||
plan_id: self.plan.clone(),
|
||||
installation_id: self.installation.clone(),
|
||||
service_name: self.name.clone(),
|
||||
operation: match operation {
|
||||
Operation::Admit => PluginServiceLifecycleOperation::Admit,
|
||||
Operation::Publish => PluginServiceLifecycleOperation::Publish,
|
||||
_ => return Err(denied_host()),
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(PluginServiceResponse::Prepared) => Ok(()),
|
||||
_ => Err(denied_host()),
|
||||
}
|
||||
}
|
||||
async fn open(
|
||||
&self,
|
||||
_: &InvocationContext,
|
||||
_: &str,
|
||||
) -> Result<Arc<dyn Any + Send + Sync>, errors::HostError> {
|
||||
Err(denied_host())
|
||||
}
|
||||
async fn send(
|
||||
&self,
|
||||
_: &InvocationContext,
|
||||
_: &(dyn Any + Send + Sync),
|
||||
_: http::Request,
|
||||
) -> Result<http::Response, errors::HostError> {
|
||||
Err(denied_host())
|
||||
}
|
||||
async fn log(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
entry: logging::Entry,
|
||||
) -> Result<(), errors::HostError> {
|
||||
self.authorize(context, Operation::Log).await?;
|
||||
// This request performs live plugin.log authorization without creating
|
||||
// an event receipt or allowing lifecycle access to effect APIs.
|
||||
let entry = PluginLogEntry {
|
||||
level: match entry.level {
|
||||
logging::Level::Trace => PluginLogLevel::Trace,
|
||||
logging::Level::Debug => PluginLogLevel::Debug,
|
||||
logging::Level::Info => PluginLogLevel::Info,
|
||||
logging::Level::Warn => PluginLogLevel::Warn,
|
||||
logging::Level::Error => PluginLogLevel::Error,
|
||||
},
|
||||
event_code: entry.event_code,
|
||||
message: entry.message,
|
||||
fields: entry
|
||||
.fields
|
||||
.into_iter()
|
||||
.map(|field| {
|
||||
Ok(PluginLogField {
|
||||
name: field.name,
|
||||
value: match field.value {
|
||||
logging::Scalar::Text(v) => PluginLogScalar::Text(v),
|
||||
logging::Scalar::Boolean(v) => PluginLogScalar::Boolean(v),
|
||||
logging::Scalar::Signed(v) => PluginLogScalar::Signed(v),
|
||||
logging::Scalar::Unsigned(v) => PluginLogScalar::Unsigned(v),
|
||||
logging::Scalar::Number(v) if v.is_finite() => {
|
||||
PluginLogScalar::Number(v)
|
||||
}
|
||||
logging::Scalar::Number(_) => return Err(denied_host()),
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, errors::HostError>>()?,
|
||||
};
|
||||
match request_service(
|
||||
self.owner.as_ref(),
|
||||
PluginServiceRequest::LifecycleLog {
|
||||
plan_id: self.plan.clone(),
|
||||
installation_id: self.installation.clone(),
|
||||
service_name: self.name.clone(),
|
||||
entry,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(PluginServiceResponse::Logged) => Ok(()),
|
||||
_ => Err(denied_host()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
//! Real ServiceDriver/FeatureBackgroundTask + canonical authored Component tests.
|
||||
//! Only the PluginHostOwner is scripted; its pause/recovery state is not evidence
|
||||
//! of durable owner transactions (covered by the owner integration suites).
|
||||
//! Run: cargo test -p worker --lib plugin::service::tests::
|
||||
|
||||
use super::*;
|
||||
use crate::feature::background::FeatureBackgroundTaskRegistryBuilder;
|
||||
use crate::hook::HookInvocationContext;
|
||||
use manifest::plugin::{self, PluginPackageLimits, PluginSourceKind};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::{Semaphore, oneshot};
|
||||
|
||||
const SERVICE: &str = "ticket_events";
|
||||
const MANIFEST: &str = include_str!("../../../../plugin-runtime/tests/service-guest/plugin.toml");
|
||||
const COMPONENT: &[u8] =
|
||||
include_bytes!("../../../../workspace-server/tests/fixtures/ticket-service-v2.component.wasm");
|
||||
|
||||
struct Fixture {
|
||||
executor: Arc<Executor>,
|
||||
compiled: Arc<CompiledService>,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
std::fs::write(directory.path().join("plugin.toml"), MANIFEST).unwrap();
|
||||
std::fs::write(directory.path().join("plugin.component.wasm"), COMPONENT).unwrap();
|
||||
let limits = PluginPackageLimits::default();
|
||||
let package =
|
||||
plugin::read_plugin_directory(directory.path(), PluginSourceKind::Explicit, &limits)
|
||||
.unwrap();
|
||||
let artifact = plugin::pack_plugin_package(&package, &limits).unwrap();
|
||||
let executor = Arc::new(Executor::new(Limits::default()).unwrap());
|
||||
let compiled = Arc::new(
|
||||
executor
|
||||
.compile_service(
|
||||
&artifact.bytes,
|
||||
&artifact.package_digest,
|
||||
&artifact.artifact_digest,
|
||||
artifact.artifact_size,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
Self { executor, compiled }
|
||||
}
|
||||
|
||||
fn driver(&self, owner: Arc<Owner>) -> Arc<ServiceDriver> {
|
||||
let namespace = ("notes".into(), "note".into(), "1".into());
|
||||
Arc::new(ServiceDriver {
|
||||
executor: self.executor.clone(),
|
||||
compiled: self.compiled.clone(),
|
||||
workspace: "workspace".into(),
|
||||
installation: owner.installation.clone(),
|
||||
plan: "plan".into(),
|
||||
subject: "worker".into(),
|
||||
grants: Grants {
|
||||
logging: true,
|
||||
connection_slots: ["api".into()].into(),
|
||||
ticket_events: true,
|
||||
ticket_read: true,
|
||||
ticket_data_read: [namespace.clone()].into(),
|
||||
ticket_data_write: [namespace].into(),
|
||||
},
|
||||
owner,
|
||||
name: SERVICE.into(),
|
||||
config: r#"{"mode":"normal"}"#.into(),
|
||||
lease: Mutex::new(None),
|
||||
stop_started: Mutex::new(None),
|
||||
parked_consumer: Mutex::new(None),
|
||||
unconfirmed_indeterminate: Mutex::new(None),
|
||||
prepared_start: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Obtain a real registry-generated generation fence rather than bypassing the
|
||||
// FeatureBackgroundTask boundary or adding a production-only context constructor.
|
||||
struct CaptureContext(Mutex<Option<oneshot::Sender<BackgroundTaskContext>>>);
|
||||
|
||||
#[async_trait]
|
||||
impl FeatureBackgroundTask for CaptureContext {
|
||||
async fn run(
|
||||
&self,
|
||||
context: BackgroundTaskContext,
|
||||
_: BackgroundTaskCancellation,
|
||||
) -> Result<(), HookError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.unwrap()
|
||||
.send(context)
|
||||
.unwrap();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn context() -> BackgroundTaskContext {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
let feature = FeatureId::builtin("service-driver-test");
|
||||
let mut builder = FeatureBackgroundTaskRegistryBuilder::default();
|
||||
builder
|
||||
.register(
|
||||
feature.clone(),
|
||||
BackgroundTaskSpec::single_flight(
|
||||
BackgroundTaskDeclaration::worker_managed("capture", "capture test context"),
|
||||
Duration::from_secs(5),
|
||||
),
|
||||
CaptureContext(Mutex::new(Some(sender))),
|
||||
)
|
||||
.unwrap();
|
||||
let registry = builder.build();
|
||||
registry
|
||||
.start(&feature, "capture", HookInvocationContext::default())
|
||||
.unwrap();
|
||||
let context = receiver.await.unwrap();
|
||||
registry.shutdown().await.unwrap();
|
||||
context.generation_fence.ensure_current().unwrap();
|
||||
context
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum CompleteResponse {
|
||||
Lost,
|
||||
Unexpected,
|
||||
ConfirmIndeterminate,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
requests: Vec<PluginServiceRequest>,
|
||||
lease: Option<PluginServiceLease>,
|
||||
acquisitions: usize,
|
||||
pause: Option<PluginServicePauseReason>,
|
||||
}
|
||||
|
||||
struct Owner {
|
||||
installation: String,
|
||||
state: Mutex<State>,
|
||||
complete_response: CompleteResponse,
|
||||
gate_log: AtomicBool,
|
||||
deny_log: AtomicBool,
|
||||
deny_release: AtomicBool,
|
||||
deny_publish: AtomicBool,
|
||||
log_entered: Semaphore,
|
||||
}
|
||||
|
||||
impl Owner {
|
||||
fn new(installation: &str, complete_response: CompleteResponse) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
installation: installation.into(),
|
||||
state: Mutex::new(State::default()),
|
||||
complete_response,
|
||||
gate_log: AtomicBool::new(false),
|
||||
deny_log: AtomicBool::new(false),
|
||||
deny_release: AtomicBool::new(false),
|
||||
deny_publish: AtomicBool::new(false),
|
||||
log_entered: Semaphore::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
fn consumer(&self) -> String {
|
||||
format!("consumer:{}", self.installation)
|
||||
}
|
||||
|
||||
fn requests(&self) -> Vec<PluginServiceRequest> {
|
||||
self.state.lock().unwrap().requests.clone()
|
||||
}
|
||||
|
||||
fn diagnostics(&self) -> PluginServiceDiagnostics {
|
||||
let state = self.state.lock().unwrap();
|
||||
PluginServiceDiagnostics {
|
||||
consumer_id: self.consumer(),
|
||||
pause_reason: state.pause,
|
||||
queued: 1,
|
||||
attempt: 1,
|
||||
current_event_id: Some("event".into()),
|
||||
lease_active: state.lease.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginHostOwner for Owner {
|
||||
async fn call(
|
||||
&self,
|
||||
request: PluginHostRequest,
|
||||
) -> Result<PluginHostResponse, PluginHostError> {
|
||||
let PluginHostRequest::Service { request } = request else {
|
||||
panic!("Service driver must use only the leased/lifecycle channel: {request:?}");
|
||||
};
|
||||
self.state.lock().unwrap().requests.push(request.clone());
|
||||
let response = match request {
|
||||
PluginServiceRequest::Prepare {
|
||||
plan_id,
|
||||
installation_id,
|
||||
service_name,
|
||||
operation,
|
||||
} => {
|
||||
assert_eq!(plan_id, "plan");
|
||||
assert_eq!(installation_id, self.installation);
|
||||
assert_eq!(service_name, SERVICE);
|
||||
if operation == PluginServiceLifecycleOperation::Publish
|
||||
&& self.deny_publish.load(Ordering::SeqCst)
|
||||
{
|
||||
return Err(unavailable());
|
||||
}
|
||||
PluginServiceResponse::Prepared
|
||||
}
|
||||
PluginServiceRequest::LifecycleLog { .. } => {
|
||||
if self.gate_log.load(Ordering::SeqCst) {
|
||||
self.log_entered.add_permits(1);
|
||||
return std::future::pending().await;
|
||||
}
|
||||
if self.deny_log.load(Ordering::SeqCst) {
|
||||
return Err(unavailable());
|
||||
}
|
||||
PluginServiceResponse::Logged
|
||||
}
|
||||
PluginServiceRequest::Acquire {
|
||||
plan_id,
|
||||
installation_id,
|
||||
service_name,
|
||||
} => {
|
||||
assert_eq!(plan_id, "plan");
|
||||
assert_eq!(installation_id, self.installation);
|
||||
assert_eq!(service_name, SERVICE);
|
||||
let mut state = self.state.lock().unwrap();
|
||||
assert!(state.pause.is_none(), "driver cannot clear the owner pause");
|
||||
assert!(state.lease.is_none(), "driver must await inactive lease");
|
||||
state.acquisitions += 1;
|
||||
let lease = PluginServiceLease {
|
||||
consumer_id: self.consumer(),
|
||||
lease_token: format!("lease-{}", state.acquisitions),
|
||||
};
|
||||
state.lease = Some(lease.clone());
|
||||
PluginServiceResponse::Acquired { lease }
|
||||
}
|
||||
PluginServiceRequest::Poll { lease } => {
|
||||
assert_eq!(self.state.lock().unwrap().lease.as_ref(), Some(&lease));
|
||||
if self.state.lock().unwrap().pause.is_some() {
|
||||
PluginServiceResponse::Diagnostics {
|
||||
diagnostics: self.diagnostics(),
|
||||
}
|
||||
} else {
|
||||
PluginServiceResponse::Delivery {
|
||||
envelope: PluginTicketEventEnvelope {
|
||||
event_id: "event".into(),
|
||||
event_type: PluginTicketEventType::Changed,
|
||||
version: 1,
|
||||
occurred_at: "2026-10-30T00:00:00Z".into(),
|
||||
ticket_id: "T-796".into(),
|
||||
domain_event_ref: "domain-event".into(),
|
||||
content_digest: "digest".into(),
|
||||
correlation_id: "correlation".into(),
|
||||
causation_id: None,
|
||||
attempt: 1,
|
||||
},
|
||||
invocation_id: "invocation".into(),
|
||||
expires_at_ms: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64
|
||||
+ 60_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
PluginServiceRequest::Complete {
|
||||
lease,
|
||||
event_id,
|
||||
result,
|
||||
} => {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
assert_eq!(state.lease.as_ref(), Some(&lease));
|
||||
assert_eq!(event_id, "event");
|
||||
assert_eq!(
|
||||
result,
|
||||
match self.complete_response {
|
||||
CompleteResponse::ConfirmIndeterminate =>
|
||||
PluginServiceEventResult::Indeterminate,
|
||||
_ => PluginServiceEventResult::Ack,
|
||||
}
|
||||
);
|
||||
// Model an owner decision committed before its response is lost.
|
||||
// Audited recovery itself is deliberately outside this unit test.
|
||||
state.pause = Some(PluginServicePauseReason::Indeterminate);
|
||||
match self.complete_response {
|
||||
CompleteResponse::Lost => return Err(unavailable()),
|
||||
CompleteResponse::Unexpected => PluginServiceResponse::Idle,
|
||||
CompleteResponse::ConfirmIndeterminate => PluginServiceResponse::Completed,
|
||||
}
|
||||
}
|
||||
PluginServiceRequest::Release { lease } => {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
assert_eq!(state.lease.as_ref(), Some(&lease));
|
||||
if self.deny_release.load(Ordering::SeqCst) {
|
||||
return Err(unavailable());
|
||||
}
|
||||
state.lease = None;
|
||||
PluginServiceResponse::Released
|
||||
}
|
||||
PluginServiceRequest::Diagnostics { consumer_id } => {
|
||||
assert_eq!(consumer_id, self.consumer());
|
||||
PluginServiceResponse::Diagnostics {
|
||||
diagnostics: self.diagnostics(),
|
||||
}
|
||||
}
|
||||
PluginServiceRequest::Host { lease, request } => {
|
||||
assert_eq!(self.state.lock().unwrap().lease.as_ref(), Some(&lease));
|
||||
let response = match *request {
|
||||
PluginInvocationRequest::Authorize { invocation_id, .. } => {
|
||||
assert_eq!(invocation_id, "invocation");
|
||||
PluginInvocationResponse::Authorized
|
||||
}
|
||||
PluginInvocationRequest::ResolveTicket { ticket_ref, .. } => {
|
||||
assert_eq!(ticket_ref, "T-796");
|
||||
PluginInvocationResponse::TicketResolved {
|
||||
ticket_id: "canonical-ticket".into(),
|
||||
}
|
||||
}
|
||||
PluginInvocationRequest::ReadTicket { .. } => {
|
||||
PluginInvocationResponse::TicketRead {
|
||||
snapshot: PluginTicketSnapshot {
|
||||
title: "note".into(),
|
||||
case_state: PluginTicketCaseState::Open,
|
||||
content_digest: "digest".into(),
|
||||
body: "body".into(),
|
||||
truncated: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
PluginInvocationRequest::DataLookupMutation { .. } => {
|
||||
PluginInvocationResponse::DataMutationLookup {
|
||||
outcome: PluginDataMutationLookup::NotRecorded,
|
||||
}
|
||||
}
|
||||
PluginInvocationRequest::DataGet { .. } => PluginInvocationResponse::DataCell {
|
||||
cell: PluginDataCell::NeverWritten,
|
||||
},
|
||||
PluginInvocationRequest::DataMutate { .. } => {
|
||||
PluginInvocationResponse::DataMutated {
|
||||
receipt: PluginDataReceipt {
|
||||
mutation_id: "mutation".into(),
|
||||
result_digest: "result".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
PluginInvocationRequest::Open { .. } => PluginInvocationResponse::Opened,
|
||||
PluginInvocationRequest::Send { .. } => PluginInvocationResponse::Response {
|
||||
response: PluginHttpResponse {
|
||||
status: 200,
|
||||
headers: vec![],
|
||||
body: b"ok".to_vec(),
|
||||
},
|
||||
},
|
||||
other => panic!("unexpected guest Host request: {other:?}"),
|
||||
};
|
||||
PluginServiceResponse::Host {
|
||||
response: Box::new(response),
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(PluginHostResponse::Service { response })
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(driver: &Arc<ServiceDriver>, context: &BackgroundTaskContext) {
|
||||
driver
|
||||
.run(context.clone(), BackgroundTaskCancellation::default())
|
||||
.await
|
||||
.expect("a parked observer must remain a successful HostReady run");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_inline_stop_retains_lease_for_protected_cleanup() {
|
||||
let fixture = Fixture::new();
|
||||
let owner = Owner::new("selected", CompleteResponse::Lost);
|
||||
let driver = fixture.driver(owner.clone());
|
||||
let context = context().await;
|
||||
run(&driver, &context).await;
|
||||
let lease = driver.lease.lock().unwrap().clone().unwrap();
|
||||
owner.state.lock().unwrap().pause = Some(PluginServicePauseReason::Authorization);
|
||||
owner.gate_log.store(true, Ordering::SeqCst);
|
||||
|
||||
// Poll observes a pause and enters inline stopped(). Drop the *run future*
|
||||
// at a known guest logging await, just as HostReady cancellation does.
|
||||
{
|
||||
let pending = driver.run(context.clone(), BackgroundTaskCancellation::default());
|
||||
tokio::pin!(pending);
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
tokio::select! {
|
||||
result = &mut pending => panic!("stop must wait at LifecycleLog: {result:?}"),
|
||||
permit = owner.log_entered.acquire() => permit.unwrap().forget(),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("real guest on_stop must reach gated lifecycle logging");
|
||||
}
|
||||
assert_eq!(driver.lease.lock().unwrap().as_ref(), Some(&lease));
|
||||
assert_eq!(
|
||||
driver.parked_consumer.lock().unwrap().as_deref(),
|
||||
Some(lease.consumer_id.as_str())
|
||||
);
|
||||
assert!(
|
||||
!owner
|
||||
.requests()
|
||||
.iter()
|
||||
.any(|request| matches!(request, PluginServiceRequest::Release { .. }))
|
||||
);
|
||||
|
||||
let logs_before_cleanup = owner
|
||||
.requests()
|
||||
.iter()
|
||||
.filter(|r| matches!(r, PluginServiceRequest::LifecycleLog { .. }))
|
||||
.count();
|
||||
owner.gate_log.store(false, Ordering::SeqCst);
|
||||
driver.stopped(context.clone()).await.unwrap();
|
||||
assert_eq!(
|
||||
owner
|
||||
.requests()
|
||||
.iter()
|
||||
.filter(|r| matches!(r, PluginServiceRequest::LifecycleLog { .. }))
|
||||
.count(),
|
||||
logs_before_cleanup,
|
||||
"interrupted on-stop is not invoked twice"
|
||||
);
|
||||
assert!(driver.lease.lock().unwrap().is_none());
|
||||
assert!(owner.state.lock().unwrap().lease.is_none());
|
||||
let releases = owner
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter_map(|request| match request {
|
||||
PluginServiceRequest::Release { lease } => Some(lease),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(releases, [lease]);
|
||||
let requests = owner.requests();
|
||||
driver.stopped(context).await.unwrap();
|
||||
assert_eq!(
|
||||
owner.requests(),
|
||||
requests,
|
||||
"confirmed release is idempotent"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_stop_export_still_releases_the_owner_lease() {
|
||||
let fixture = Fixture::new();
|
||||
let owner = Owner::new("selected", CompleteResponse::Lost);
|
||||
let driver = fixture.driver(owner.clone());
|
||||
let context = context().await;
|
||||
run(&driver, &context).await;
|
||||
let lease = driver.lease.lock().unwrap().clone().unwrap();
|
||||
owner.deny_log.store(true, Ordering::SeqCst);
|
||||
assert!(driver.stopped(context).await.is_err());
|
||||
assert!(driver.lease.lock().unwrap().is_none());
|
||||
assert!(owner.state.lock().unwrap().lease.is_none());
|
||||
assert_eq!(
|
||||
owner.requests().last(),
|
||||
Some(&PluginServiceRequest::Release { lease })
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unconfirmed_release_retains_the_exact_proof_until_retry_is_confirmed() {
|
||||
let fixture = Fixture::new();
|
||||
let owner = Owner::new("selected", CompleteResponse::Lost);
|
||||
let driver = fixture.driver(owner.clone());
|
||||
let context = context().await;
|
||||
run(&driver, &context).await;
|
||||
let lease = driver.lease.lock().unwrap().clone().unwrap();
|
||||
owner.deny_release.store(true, Ordering::SeqCst);
|
||||
assert!(driver.stopped(context.clone()).await.is_err());
|
||||
assert_eq!(driver.lease.lock().unwrap().as_ref(), Some(&lease));
|
||||
assert_eq!(owner.state.lock().unwrap().lease.as_ref(), Some(&lease));
|
||||
assert_eq!(
|
||||
owner.requests().last(),
|
||||
Some(&PluginServiceRequest::Release {
|
||||
lease: lease.clone(),
|
||||
})
|
||||
);
|
||||
owner.deny_release.store(false, Ordering::SeqCst);
|
||||
driver.stopped(context).await.unwrap();
|
||||
assert!(driver.lease.lock().unwrap().is_none());
|
||||
assert_eq!(
|
||||
owner.requests().last(),
|
||||
Some(&PluginServiceRequest::Release { lease })
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uncertain_complete_parks_without_retry_and_reacquires_only_recovered_consumer() {
|
||||
let fixture = Fixture::new();
|
||||
let context = context().await;
|
||||
for response in [CompleteResponse::Lost, CompleteResponse::Unexpected] {
|
||||
let owner = Owner::new("selected", response);
|
||||
let driver = fixture.driver(owner.clone());
|
||||
let other_owner = Owner::new("other", response);
|
||||
let other = fixture.driver(other_owner.clone());
|
||||
for (driver, owner) in [(&driver, &owner), (&other, &other_owner)] {
|
||||
run(driver, &context).await; // Acquire + real guest on_start.
|
||||
run(driver, &context).await; // Real guest effects + uncertain Complete.
|
||||
assert!(driver.lease.lock().unwrap().is_none());
|
||||
assert_eq!(
|
||||
driver.parked_consumer.lock().unwrap().as_deref(),
|
||||
Some(owner.consumer().as_str())
|
||||
);
|
||||
let requests = owner.requests();
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|request| matches!(request,
|
||||
PluginServiceRequest::Host { request, .. }
|
||||
if matches!(request.as_ref(), PluginInvocationRequest::DataMutate { .. })
|
||||
))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|request| matches!(request,
|
||||
PluginServiceRequest::Host { request, .. }
|
||||
if matches!(request.as_ref(), PluginInvocationRequest::Send { .. })
|
||||
))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|request| matches!(request, PluginServiceRequest::Complete { .. }))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
owner.diagnostics().pause_reason,
|
||||
Some(PluginServicePauseReason::Indeterminate)
|
||||
);
|
||||
assert!(!owner.diagnostics().lease_active);
|
||||
let before = requests.len();
|
||||
run(driver, &context).await;
|
||||
assert_eq!(
|
||||
&owner.requests()[before..],
|
||||
&[PluginServiceRequest::Diagnostics {
|
||||
consumer_id: owner.consumer(),
|
||||
}],
|
||||
"paused runs must not poll, re-admit the guest, repeat effects, or complete again"
|
||||
);
|
||||
}
|
||||
|
||||
let other_before = other_owner.requests();
|
||||
let original_lease = owner
|
||||
.requests()
|
||||
.into_iter()
|
||||
.find_map(|request| match request {
|
||||
PluginServiceRequest::Release { lease } => Some(lease),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
// Script the result of external owner recovery, not an unaudited driver
|
||||
// resume. A cleared pause alone is insufficient while any lease is live.
|
||||
{
|
||||
let mut state = owner.state.lock().unwrap();
|
||||
state.pause = None;
|
||||
state.lease = Some(original_lease.clone());
|
||||
}
|
||||
let before = owner.requests().len();
|
||||
run(&driver, &context).await;
|
||||
assert_eq!(
|
||||
&owner.requests()[before..],
|
||||
&[PluginServiceRequest::Diagnostics {
|
||||
consumer_id: owner.consumer(),
|
||||
}]
|
||||
);
|
||||
assert!(driver.lease.lock().unwrap().is_none());
|
||||
owner.state.lock().unwrap().lease = None;
|
||||
run(&driver, &context).await;
|
||||
let reacquired = driver.lease.lock().unwrap().clone().unwrap();
|
||||
assert_eq!(reacquired.consumer_id, original_lease.consumer_id);
|
||||
assert_ne!(reacquired.lease_token, original_lease.lease_token);
|
||||
assert!(driver.parked_consumer.lock().unwrap().is_none());
|
||||
assert_eq!(owner.state.lock().unwrap().acquisitions, 2);
|
||||
assert_eq!(
|
||||
other_owner.requests(),
|
||||
other_before,
|
||||
"no global driver rearm"
|
||||
);
|
||||
let before = other_before.len();
|
||||
run(&other, &context).await;
|
||||
assert_eq!(
|
||||
&other_owner.requests()[before..],
|
||||
&[PluginServiceRequest::Diagnostics {
|
||||
consumer_id: other_owner.consumer(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(other_owner.state.lock().unwrap().acquisitions, 1);
|
||||
assert!(other.lease.lock().unwrap().is_none());
|
||||
driver.stopped(context.clone()).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unconfirmed_indeterminate_control_reconciles_pause_without_redelivering_guest() {
|
||||
let fixture = Fixture::new();
|
||||
let owner = Owner::new("selected", CompleteResponse::ConfirmIndeterminate);
|
||||
let driver = fixture.driver(owner.clone());
|
||||
let context = context().await;
|
||||
*driver.parked_consumer.lock().unwrap() = Some(owner.consumer());
|
||||
*driver.unconfirmed_indeterminate.lock().unwrap() = Some("event".into());
|
||||
// A lost Complete(Indeterminate) reply does not prove a durable pause.
|
||||
// Script precisely that observation: same head, no pause, no active lease.
|
||||
run(&driver, &context).await;
|
||||
assert_eq!(
|
||||
owner.diagnostics().pause_reason,
|
||||
Some(PluginServicePauseReason::Indeterminate)
|
||||
);
|
||||
assert!(driver.unconfirmed_indeterminate.lock().unwrap().is_none());
|
||||
assert_eq!(
|
||||
driver.parked_consumer.lock().unwrap().as_ref(),
|
||||
Some(&owner.consumer())
|
||||
);
|
||||
assert!(driver.lease.lock().unwrap().is_none());
|
||||
assert!(!owner.requests().iter().any(|r| matches!(
|
||||
r,
|
||||
PluginServiceRequest::Poll { .. } | PluginServiceRequest::Host { .. }
|
||||
)));
|
||||
assert!(owner.requests().iter().any(|r| matches!(r, PluginServiceRequest::Complete { event_id, result: PluginServiceEventResult::Indeterminate, .. } if event_id == "event")));
|
||||
let before = owner.requests().len();
|
||||
run(&driver, &context).await;
|
||||
assert_eq!(
|
||||
&owner.requests()[before..],
|
||||
&[PluginServiceRequest::Diagnostics {
|
||||
consumer_id: owner.consumer()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn real_lifecycle_sends_exact_admit_and_publish_operations_and_honors_publish_denial() {
|
||||
let fixture = Fixture::new();
|
||||
let owner = Owner::new("selected", CompleteResponse::Lost);
|
||||
let driver = fixture.driver(owner.clone());
|
||||
driver.prepare_start().await.unwrap();
|
||||
let expected = vec![
|
||||
serde_json::json!({"kind":"prepare", "plan_id":"plan", "installation_id":"selected", "service_name":SERVICE, "operation":"admit"}),
|
||||
serde_json::json!({"kind":"prepare", "plan_id":"plan", "installation_id":"selected", "service_name":SERVICE, "operation":"publish"}),
|
||||
];
|
||||
let prepare_wire = |requests: Vec<PluginServiceRequest>| {
|
||||
requests
|
||||
.into_iter()
|
||||
.filter(|request| matches!(request, PluginServiceRequest::Prepare { .. }))
|
||||
.map(|request| serde_json::to_value(request).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(prepare_wire(owner.requests()), expected);
|
||||
assert_eq!(
|
||||
owner.requests().len(),
|
||||
3,
|
||||
"Admit, guest lifecycle log, Publish"
|
||||
);
|
||||
assert!(owner.state.lock().unwrap().lease.is_none());
|
||||
owner.deny_publish.store(true, Ordering::SeqCst);
|
||||
let before = owner.requests().len();
|
||||
assert!(
|
||||
driver.prepare_start().await.is_err(),
|
||||
"Publish is not another Admit check"
|
||||
);
|
||||
assert_eq!(prepare_wire(owner.requests()[before..].to_vec()), expected);
|
||||
}
|
||||
@@ -100,6 +100,7 @@ impl Fixture {
|
||||
world: m.runtime.world,
|
||||
required_imports: m.runtime.required_imports,
|
||||
tools: serde_json::from_value(serde_json::to_value(m.tools).unwrap()).unwrap(),
|
||||
services: vec![],
|
||||
connections: vec![],
|
||||
requested_grants: vec![],
|
||||
data_collections: vec![],
|
||||
@@ -148,6 +149,7 @@ impl Fixture {
|
||||
},
|
||||
expectation: ArtifactExpectation::new(self.source.artifact.clone()),
|
||||
tools: vec!["example_echo".into()],
|
||||
services: vec![],
|
||||
config: json!({}),
|
||||
grants: Grants::default(),
|
||||
})
|
||||
|
||||
@@ -4674,6 +4674,27 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
}
|
||||
|
||||
impl<C: LlmClient + 'static, St: Store + 'static> Worker<C, St> {
|
||||
/// Shared startup/restore boundary, called only by a published Controller.
|
||||
pub(crate) fn start_host_ready_feature_tasks(&self) {
|
||||
if let Err(error) = self
|
||||
.feature_background_tasks
|
||||
.start_host_ready(self.hook_invocation_context(None))
|
||||
{
|
||||
tracing::warn!(error = %error, "HostReady background task start failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Host management may wake one task after domain-owned recovery succeeds.
|
||||
/// This does not authorize recovery or reactivate any other paused task.
|
||||
pub fn reactivate_host_ready_feature_task(
|
||||
&self,
|
||||
feature_id: &crate::feature::FeatureId,
|
||||
task_name: &str,
|
||||
) -> Result<crate::feature::background::BackgroundTaskStart, crate::hook::HookError> {
|
||||
self.feature_background_tasks
|
||||
.reactivate_host_ready(feature_id, task_name)
|
||||
}
|
||||
|
||||
pub async fn stop_feature_runtime(&mut self, reason: impl Into<String>) {
|
||||
if let Some(hooks) = self.hook_registry.clone() {
|
||||
let context = WorkerStoppingContext {
|
||||
@@ -4699,6 +4720,15 @@ impl<C: LlmClient + 'static, St: Store + 'static> Worker<C, St> {
|
||||
if let Err(error) = self.feature_background_tasks.shutdown().await {
|
||||
tracing::warn!(error = %error, "feature background task shutdown failed");
|
||||
}
|
||||
if let Some(plugins) = &self.plugin_tools {
|
||||
// The registry has joined the drivers, including bounded on-stop.
|
||||
// Do not free scoped resources or acknowledge shutdown while an
|
||||
// uncertain Service lease release still belongs to this Host.
|
||||
while let Err(error) = plugins.reconcile_service_shutdown().await {
|
||||
tracing::warn!(error = %error, "Plugin Service lease release pending; retaining cleanup ownership");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -525,7 +525,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
|
||||
(
|
||||
111,
|
||||
"Typed Plugin Ticket extension data and atomic mutation receipts".into()
|
||||
)
|
||||
),
|
||||
(112, "Ticket Service outbox and fenced consumers".into())
|
||||
]
|
||||
);
|
||||
for (table, rows) in retained.iter().zip(original.iter()) {
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod plugin_host;
|
||||
pub mod plugin_https;
|
||||
pub mod plugin_plan;
|
||||
pub mod plugin_receipt;
|
||||
pub mod plugin_service;
|
||||
pub mod plugin_settings;
|
||||
pub mod profile_settings;
|
||||
pub mod prompt_settings;
|
||||
|
||||
@@ -258,6 +258,7 @@ fn artifact_metadata(
|
||||
external_write: t.external_write,
|
||||
})
|
||||
.collect(),
|
||||
services: decode(&encode(&m.services)?)?,
|
||||
connections: decode(&encode(&m.connections)?)?,
|
||||
requested_grants: decode(&encode(&m.requested_grants)?)?,
|
||||
data_collections: decode(&encode(&m.data_collections)?)?,
|
||||
|
||||
@@ -30,7 +30,7 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105()
|
||||
// Plugin catalog remains migration 106 even when startup applies later steps.
|
||||
assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(),
|
||||
vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
|
||||
assert_eq!(after.last().unwrap(), &(111, "Typed Plugin Ticket extension data and atomic mutation receipts".into()));
|
||||
assert_eq!(after.last().unwrap(), &(112, "Ticket Service outbox and fenced consumers".into()));
|
||||
assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION);
|
||||
assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins");
|
||||
assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
|
||||
@@ -81,6 +81,7 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang
|
||||
111,
|
||||
"Typed Plugin Ticket extension data and atomic mutation receipts".into(),
|
||||
));
|
||||
expected.push((112, "Ticket Service outbox and fenced consumers".into()));
|
||||
assert_eq!(workspace_schema_migration_history(c)?, expected);
|
||||
crate::plugin_catalog::verify_with_data(c)?;
|
||||
Ok(())
|
||||
|
||||
@@ -45,6 +45,7 @@ fn plan(workspace: &str, installation: &str, id: &str) -> StoredPluginPlan {
|
||||
PluginPinProjection {
|
||||
config: serde_json::json!({}),
|
||||
surfaces: vec![PluginSurface::Tool],
|
||||
services: vec![],
|
||||
tools: vec!["echo".into()],
|
||||
grants: vec![],
|
||||
bindings: vec![],
|
||||
|
||||
@@ -24,6 +24,7 @@ pub struct AdmittedPluginOperation {
|
||||
pub pin: WorkerPluginPin,
|
||||
pub binding: Option<PluginConnectionBinding>,
|
||||
pub data_collections: Vec<PluginDataSchemaMetadata>,
|
||||
pub services: Vec<PluginServiceMetadata>,
|
||||
}
|
||||
|
||||
/// `execution` is derived by the authenticated Host boundary from current Worker
|
||||
@@ -38,6 +39,47 @@ pub fn authorize(
|
||||
installation: &str,
|
||||
tools: &[String],
|
||||
operation: &PluginHostOperation,
|
||||
) -> Result<AdmittedPluginOperation> {
|
||||
authorize_selected(
|
||||
tx,
|
||||
workspace,
|
||||
execution,
|
||||
plan_id,
|
||||
installation,
|
||||
tools,
|
||||
None,
|
||||
operation,
|
||||
)
|
||||
}
|
||||
pub fn authorize_service(
|
||||
tx: &Transaction<'_>,
|
||||
workspace: &str,
|
||||
execution: &HostExecutionIdentity,
|
||||
plan_id: &str,
|
||||
installation: &str,
|
||||
service: &str,
|
||||
operation: &PluginHostOperation,
|
||||
) -> Result<AdmittedPluginOperation> {
|
||||
authorize_selected(
|
||||
tx,
|
||||
workspace,
|
||||
execution,
|
||||
plan_id,
|
||||
installation,
|
||||
&[],
|
||||
Some(service),
|
||||
operation,
|
||||
)
|
||||
}
|
||||
fn authorize_selected(
|
||||
tx: &Transaction<'_>,
|
||||
workspace: &str,
|
||||
execution: &HostExecutionIdentity,
|
||||
plan_id: &str,
|
||||
installation: &str,
|
||||
tools: &[String],
|
||||
service: Option<&str>,
|
||||
operation: &PluginHostOperation,
|
||||
) -> Result<AdmittedPluginOperation> {
|
||||
let plan = plugin_plan_load(tx, workspace, plan_id)?;
|
||||
if plan.plan.execution() != execution || plan.pin_state == PlanPinState::Released {
|
||||
@@ -50,20 +92,31 @@ pub fn authorize(
|
||||
.find(|pin| pin.installation_id() == installation)
|
||||
.cloned()
|
||||
.ok_or_else(|| failure(PluginHostErrorCode::Denied))?;
|
||||
if tools.is_empty()
|
||||
if service.is_none()
|
||||
&& (tools.is_empty()
|
||||
|| tools.len() > 256
|
||||
|| tools
|
||||
.iter()
|
||||
.any(|tool| tool.is_empty() || tool.len() > 512 || tool.chars().any(char::is_control))
|
||||
|| tools.iter().any(|tool| {
|
||||
tool.is_empty() || tool.len() > 512 || tool.chars().any(char::is_control)
|
||||
})
|
||||
|| tools
|
||||
.iter()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.len()
|
||||
!= tools.len()
|
||||
!= tools.len())
|
||||
{
|
||||
return Err(failure(PluginHostErrorCode::InvalidInput));
|
||||
}
|
||||
if !pin.projection().surfaces.contains(&PluginSurface::Tool)
|
||||
if let Some(service) = service {
|
||||
if service.is_empty()
|
||||
|| service.len() > 512
|
||||
|| service.chars().any(char::is_control)
|
||||
|| !pin.projection().surfaces.contains(&PluginSurface::Service)
|
||||
|| !pin.projection().services.iter().any(|s| s == service)
|
||||
|| !pin.projection().grants.contains(&PluginGrant::TicketEvents)
|
||||
{
|
||||
return Err(failure(PluginHostErrorCode::Denied));
|
||||
}
|
||||
} else if !pin.projection().surfaces.contains(&PluginSurface::Tool)
|
||||
|| tools
|
||||
.iter()
|
||||
.any(|tool| !pin.projection().tools.contains(tool))
|
||||
@@ -137,6 +190,7 @@ pub fn authorize(
|
||||
pin,
|
||||
binding,
|
||||
data_collections: artifact.data_collections,
|
||||
services: artifact.services,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ fn plugin_receipt_tail_migrates_true_predecessor_reopens_and_matches_fresh_schem
|
||||
assert!(!table_exists(&conn, "plugin_invocations").unwrap());
|
||||
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
|
||||
assert_eq!(plan.current_schema_version, 108);
|
||||
assert_eq!(plan.migrations.iter().map(|step| step.version).collect::<Vec<_>>(), [109, 110, 111]);
|
||||
assert_eq!(plan.migrations.iter().map(|step| step.version).collect::<Vec<_>>(), [109, 110, 111, 112]);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 108);
|
||||
drop(conn);
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
|
||||
@@ -39,6 +39,7 @@ fn plugin_plan_migration_marker_failure_preserves_catalog_then_retries_and_match
|
||||
111,
|
||||
"Typed Plugin Ticket extension data and atomic mutation receipts".into(),
|
||||
));
|
||||
expected.push((112, "Ticket Service outbox and fenced consumers".into()));
|
||||
assert_eq!(workspace_schema_migration_history(conn)?, expected);
|
||||
subject_assignment_migration::verify(conn)?;
|
||||
crate::plugin_plan::verify_plan_schema(conn)?;
|
||||
|
||||
@@ -130,6 +130,7 @@ fn projection() -> PluginPinProjection {
|
||||
PluginPinProjection {
|
||||
config: serde_json::json!({}),
|
||||
surfaces: vec![PluginSurface::Tool],
|
||||
services: vec![],
|
||||
tools: vec!["example_echo".into()],
|
||||
grants: vec![],
|
||||
bindings: vec![],
|
||||
|
||||
@@ -46,6 +46,8 @@ pub struct InvocationRecord {
|
||||
pub expires_at_ms: i64,
|
||||
pub cancelled: bool,
|
||||
pub expired: bool,
|
||||
/// Ephemeral proof supplied by the authenticated Service wrapper, never persisted.
|
||||
pub service_lease: Option<server_api::PluginServiceLease>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -222,6 +224,7 @@ pub fn lookup(conn: &Connection, workspace: &str, invocation_id: &str) -> Result
|
||||
expires_at_ms,
|
||||
cancelled,
|
||||
expired,
|
||||
service_lease: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -231,7 +234,7 @@ pub(crate) fn verify_record(
|
||||
conn: &Connection,
|
||||
supplied: &InvocationRecord,
|
||||
) -> Result<InvocationRecord> {
|
||||
let saved = lookup(conn, &supplied.workspace_id, &supplied.invocation_id)?;
|
||||
let mut saved = lookup(conn, &supplied.workspace_id, &supplied.invocation_id)?;
|
||||
if supplied.plan_id != saved.plan_id
|
||||
|| supplied.installation_id != saved.installation_id
|
||||
|| supplied.execution != saved.execution
|
||||
@@ -240,6 +243,13 @@ pub(crate) fn verify_record(
|
||||
{
|
||||
return conflict();
|
||||
}
|
||||
crate::plugin_service::fence_invocation(
|
||||
conn,
|
||||
supplied,
|
||||
supplied.service_lease.as_ref(),
|
||||
chrono::Utc::now().timestamp_millis(),
|
||||
)?;
|
||||
saved.service_lease = supplied.service_lease.clone();
|
||||
Ok(saved)
|
||||
}
|
||||
fn is_expired(conn: &Connection, record: &InvocationRecord) -> Result<bool> {
|
||||
|
||||
@@ -138,6 +138,7 @@ fn make_plan(
|
||||
error_codes: vec![],
|
||||
external_write: false,
|
||||
}],
|
||||
services: vec![],
|
||||
connections: vec![],
|
||||
requested_grants: vec![],
|
||||
data_collections: vec![],
|
||||
@@ -158,6 +159,7 @@ fn make_plan(
|
||||
PluginPinProjection {
|
||||
config: serde_json::json!({}),
|
||||
surfaces: vec![PluginSurface::Tool],
|
||||
services: vec![],
|
||||
tools: vec!["example_echo".into()],
|
||||
grants: vec![],
|
||||
bindings: vec![],
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
//! Durable domain delivery state, not a task registry. The Runtime owns the driver
|
||||
//! task; this owner serializes admission, lease proofs, receipts and cursor commits
|
||||
//! in the catalog database. No guest or network I/O runs inside these transactions.
|
||||
use crate::{Result, plugin_host::failure, plugin_plan::StoredPluginPlan, plugin_receipt};
|
||||
use rusqlite::{Connection, OptionalExtension, Transaction, params};
|
||||
use server_api::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub const SCHEMA_SQL: &str = include_str!("plugin_service_schema.sql");
|
||||
pub const LEASE_MS: i64 = 60_000;
|
||||
pub const MAX_QUEUE: u32 = 256;
|
||||
pub const MAX_ATTEMPTS: u32 = 5;
|
||||
pub const MAX_RETRY_MS: u32 = 300_000;
|
||||
pub const RETENTION_MS: i64 = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Consumer {
|
||||
pub consumer_id: String,
|
||||
pub workspace: String,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub installation: String,
|
||||
pub plan_id: String,
|
||||
pub service: String,
|
||||
pub subscriptions: Vec<PluginTicketEventType>,
|
||||
pub cursor: i64,
|
||||
pub drain_through: i64,
|
||||
pub pause: Option<PluginServicePauseReason>,
|
||||
pub current_sequence: Option<i64>,
|
||||
pub current_event_id: Option<String>,
|
||||
pub invocation_id: Option<String>,
|
||||
pub attempt: u32,
|
||||
pub next_attempt_ms: i64,
|
||||
pub lease_token: Option<String>,
|
||||
pub lease_until_ms: i64,
|
||||
}
|
||||
fn decode<T: serde::de::DeserializeOwned>(json: &str) -> Result<T> {
|
||||
serde_json::from_str(json).map_err(|_| failure(PluginHostErrorCode::Unavailable))
|
||||
}
|
||||
fn encode<T: serde::Serialize>(value: &T) -> Result<String> {
|
||||
serde_json::to_string(value).map_err(|_| failure(PluginHostErrorCode::InvalidInput))
|
||||
}
|
||||
fn valid(s: &str, max: usize) -> bool {
|
||||
!s.is_empty() && s.len() <= max && !s.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
pub fn load(conn: &Connection, workspace: &str, id: &str) -> Result<Consumer> {
|
||||
if !valid(id, 512) {
|
||||
return Err(failure(PluginHostErrorCode::InvalidInput));
|
||||
}
|
||||
let row = conn.query_row("SELECT runtime_id,worker_id,installation_id,plan_id,service_name,subscriptions_json,cursor,pause_reason,current_sequence,current_event_id,invocation_id,attempt,next_attempt_ms,lease_token,lease_until_ms,drain_through FROM plugin_service_consumers WHERE workspace_id=?1 AND consumer_id=?2", params![workspace,id], |r| Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,String>(5)?,r.get::<_,i64>(6)?,r.get::<_,Option<String>>(7)?,r.get::<_,Option<i64>>(8)?,r.get::<_,Option<String>>(9)?,r.get::<_,Option<String>>(10)?,r.get::<_,u32>(11)?,r.get::<_,i64>(12)?,r.get::<_,Option<String>>(13)?,r.get::<_,i64>(14)?,r.get::<_,i64>(15)?))).optional()?.ok_or_else(||failure(PluginHostErrorCode::NotFound))?;
|
||||
Ok(Consumer {
|
||||
consumer_id: id.into(),
|
||||
workspace: workspace.into(),
|
||||
runtime_id: row.0,
|
||||
worker_id: row.1,
|
||||
installation: row.2,
|
||||
plan_id: row.3,
|
||||
service: row.4,
|
||||
subscriptions: decode(&row.5)?,
|
||||
cursor: row.6,
|
||||
drain_through: row.15,
|
||||
pause: row.7.map(|v| decode(&format!("\"{v}\""))).transpose()?,
|
||||
current_sequence: row.8,
|
||||
current_event_id: row.9,
|
||||
invocation_id: row.10,
|
||||
attempt: row.11,
|
||||
next_attempt_ms: row.12,
|
||||
lease_token: row.13,
|
||||
lease_until_ms: row.14,
|
||||
})
|
||||
}
|
||||
fn floor(conn: &Connection, workspace: &str) -> Result<i64> {
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT removed_through FROM plugin_service_retention WHERE workspace_id=?1",
|
||||
[workspace],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or(0))
|
||||
}
|
||||
fn tail(conn: &Connection, workspace: &str) -> Result<i64> {
|
||||
Ok(conn.query_row("SELECT COALESCE(MAX(sequence),0) FROM typed_ticket_service_outbox WHERE workspace_id=?1",[workspace],|r|r.get::<_,i64>(0))?.max(floor(conn,workspace)?))
|
||||
}
|
||||
|
||||
pub fn acquire(
|
||||
tx: &Transaction<'_>,
|
||||
plan: &StoredPluginPlan,
|
||||
installation: &str,
|
||||
service: &str,
|
||||
subscriptions: &[PluginTicketEventType],
|
||||
now: i64,
|
||||
) -> Result<PluginServiceLease> {
|
||||
if !valid(service, 512)
|
||||
|| subscriptions.is_empty()
|
||||
|| subscriptions.len() > 2
|
||||
|| (subscriptions.len() == 2 && subscriptions[0] == subscriptions[1])
|
||||
{
|
||||
return Err(failure(PluginHostErrorCode::InvalidInput));
|
||||
}
|
||||
let workspace = plan.plan.workspace_id();
|
||||
let execution = plan.plan.execution();
|
||||
let existing:Option<String>=tx.query_row("SELECT consumer_id FROM plugin_service_consumers WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND installation_id=?4 AND plan_id=?5 AND service_name=?6",params![workspace,execution.runtime_id,execution.worker_id,installation,plan.plan_id,service],|r|r.get(0)).optional()?;
|
||||
let id = if let Some(id) = existing {
|
||||
let saved = load(tx, workspace, &id)?;
|
||||
if saved.subscriptions != subscriptions {
|
||||
return Err(failure(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
if saved.lease_token.is_some() && saved.lease_until_ms > now {
|
||||
return Err(failure(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
id
|
||||
} else {
|
||||
let count: u32 = tx.query_row(
|
||||
"SELECT count(*) FROM plugin_service_consumers WHERE workspace_id=?1",
|
||||
[workspace],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if count >= 1024 {
|
||||
return Err(failure(PluginHostErrorCode::QuotaExceeded));
|
||||
}
|
||||
let id = uuid::Uuid::now_v7().to_string();
|
||||
tx.execute("INSERT INTO plugin_service_consumers(workspace_id,consumer_id,runtime_id,worker_id,installation_id,plan_id,service_name,subscriptions_json,cursor) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9)",params![workspace,id,execution.runtime_id,execution.worker_id,installation,plan.plan_id,service,encode(&subscriptions)?,tail(tx,workspace)?])?;
|
||||
id
|
||||
};
|
||||
let token = uuid::Uuid::new_v4().to_string();
|
||||
tx.execute("UPDATE plugin_service_consumers SET lease_token=?3,lease_until_ms=?4 WHERE workspace_id=?1 AND consumer_id=?2",params![workspace,id,token,now+LEASE_MS])?;
|
||||
Ok(PluginServiceLease {
|
||||
consumer_id: id,
|
||||
lease_token: token,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn fence(
|
||||
conn: &Connection,
|
||||
workspace: &str,
|
||||
lease: &PluginServiceLease,
|
||||
now: i64,
|
||||
) -> Result<Consumer> {
|
||||
let c = load(conn, workspace, &lease.consumer_id)?;
|
||||
if !valid(&lease.lease_token, 512)
|
||||
|| c.lease_token.as_deref() != Some(lease.lease_token.as_str())
|
||||
|| c.lease_until_ms <= now
|
||||
{
|
||||
return Err(failure(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// Called from receipt verification in the same transaction as every effect/data
|
||||
/// mutation. A stable logical invocation never suffices as an old driver's proof.
|
||||
pub fn fence_invocation(
|
||||
conn: &Connection,
|
||||
record: &plugin_receipt::InvocationRecord,
|
||||
lease: Option<&PluginServiceLease>,
|
||||
now: i64,
|
||||
) -> Result<()> {
|
||||
if let PluginConsumer::Consumer {
|
||||
consumer_id,
|
||||
event_id,
|
||||
export_name,
|
||||
} = &record.consumer
|
||||
{
|
||||
let lease = lease.ok_or_else(|| failure(PluginHostErrorCode::Denied))?;
|
||||
let c = fence(conn, &record.workspace_id, lease, now)?;
|
||||
if &c.consumer_id != consumer_id
|
||||
|| c.current_event_id.as_ref() != Some(event_id)
|
||||
|| &c.service != export_name
|
||||
|| c.invocation_id.as_deref() != Some(record.invocation_id.as_str())
|
||||
|| c.pause.is_some()
|
||||
|| c.plan_id != record.plan_id
|
||||
|| c.installation != record.installation_id
|
||||
{
|
||||
return Err(failure(PluginHostErrorCode::Denied));
|
||||
}
|
||||
} else if lease.is_some() {
|
||||
return Err(failure(PluginHostErrorCode::Denied));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn queued(conn: &Connection, c: &Consumer) -> Result<u32> {
|
||||
let changed = c.subscriptions.contains(&PluginTicketEventType::Changed);
|
||||
let deleted = c.subscriptions.contains(&PluginTicketEventType::Deleted);
|
||||
Ok(conn.query_row("SELECT count(*) FROM (SELECT 1 FROM typed_ticket_service_outbox WHERE workspace_id=?1 AND sequence>?2 AND ((event_type='changed' AND ?3) OR (event_type='deleted' AND ?4)) ORDER BY sequence LIMIT 257)",params![c.workspace,c.cursor,changed,deleted],|r|r.get(0))?)
|
||||
}
|
||||
pub fn diagnostics(conn: &Connection, c: &Consumer, now: i64) -> Result<PluginServiceDiagnostics> {
|
||||
Ok(PluginServiceDiagnostics {
|
||||
consumer_id: c.consumer_id.clone(),
|
||||
pause_reason: c.pause,
|
||||
queued: queued(conn, c)?,
|
||||
attempt: c.attempt,
|
||||
current_event_id: match (&c.current_event_id, c.pause) {
|
||||
(Some(id), _) => Some(id.clone()),
|
||||
(None, Some(PluginServicePauseReason::QueueFull)) => conn.query_row(
|
||||
"SELECT event_id FROM typed_ticket_service_outbox WHERE workspace_id=?1 AND sequence>?2 AND ((event_type='changed' AND ?3) OR (event_type='deleted' AND ?4)) ORDER BY sequence LIMIT 1",
|
||||
params![c.workspace, c.cursor, c.subscriptions.contains(&PluginTicketEventType::Changed), c.subscriptions.contains(&PluginTicketEventType::Deleted)],
|
||||
|row| row.get(0),
|
||||
).optional()?,
|
||||
_ => None,
|
||||
},
|
||||
lease_active: c.lease_token.is_some() && c.lease_until_ms > now,
|
||||
})
|
||||
}
|
||||
pub fn pause(tx: &Transaction<'_>, c: &Consumer, reason: PluginServicePauseReason) -> Result<()> {
|
||||
let reason = encode(&reason)?;
|
||||
tx.execute("UPDATE plugin_service_consumers SET pause_reason=?3 WHERE workspace_id=?1 AND consumer_id=?2",params![c.workspace,c.consumer_id,reason.trim_matches('"')])?;
|
||||
Ok(())
|
||||
}
|
||||
fn unknown_effect(conn: &Connection, c: &Consumer, now: i64) -> Result<bool> {
|
||||
let Some(id) = &c.invocation_id else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(conn.query_row("SELECT EXISTS(SELECT 1 FROM plugin_effect_receipts WHERE workspace_id=?1 AND invocation_id=?2 AND (state IN ('pending','expired') OR json_extract(error_json,'$.effect')='indeterminate')) OR EXISTS(SELECT 1 FROM plugin_invocations WHERE workspace_id=?1 AND invocation_id=?2 AND (expired=1 OR expires_at_ms<=?3))",params![c.workspace,id,now],|r|r.get(0))?)
|
||||
}
|
||||
|
||||
pub fn poll(
|
||||
tx: &Transaction<'_>,
|
||||
workspace: &str,
|
||||
lease: &PluginServiceLease,
|
||||
plan: &StoredPluginPlan,
|
||||
now: i64,
|
||||
) -> Result<PluginServiceResponse> {
|
||||
let c = fence(tx, workspace, lease, now)?;
|
||||
// Retention is a Host operation in the same owner transaction, not a guest
|
||||
// callback. Persisting the floor makes expiry visible to every consumer.
|
||||
prune_outbox(tx, workspace, tail(tx, workspace)?, now)?;
|
||||
if c.pause.is_some() {
|
||||
return Ok(PluginServiceResponse::Diagnostics {
|
||||
diagnostics: diagnostics(tx, &c, now)?,
|
||||
});
|
||||
}
|
||||
let reason = if c.cursor < floor(tx, workspace)? {
|
||||
Some(PluginServicePauseReason::RetentionGap)
|
||||
} else if queued(tx, &c)? >= MAX_QUEUE && c.cursor >= c.drain_through {
|
||||
Some(PluginServicePauseReason::QueueFull)
|
||||
} else if unknown_effect(tx, &c, now)? {
|
||||
Some(PluginServicePauseReason::Indeterminate)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(reason) = reason {
|
||||
pause(tx, &c, reason)?;
|
||||
return Ok(PluginServiceResponse::Diagnostics {
|
||||
diagnostics: diagnostics(tx, &load(tx, workspace, &c.consumer_id)?, now)?,
|
||||
});
|
||||
}
|
||||
tx.execute("UPDATE plugin_service_consumers SET lease_until_ms=?3 WHERE workspace_id=?1 AND consumer_id=?2",params![workspace,c.consumer_id,now+LEASE_MS])?;
|
||||
if c.next_attempt_ms > now {
|
||||
return Ok(PluginServiceResponse::Idle);
|
||||
}
|
||||
if c.current_event_id.is_some() && c.attempt >= MAX_ATTEMPTS {
|
||||
pause(tx, &c, PluginServicePauseReason::AttemptsExhausted)?;
|
||||
return Ok(PluginServiceResponse::Diagnostics {
|
||||
diagnostics: diagnostics(tx, &load(tx, workspace, &c.consumer_id)?, now)?,
|
||||
});
|
||||
}
|
||||
let event=tx.query_row("SELECT sequence,event_id,event_type,occurred_at,ticket_id,domain_event_ref,content_digest,correlation_id,causation_id FROM typed_ticket_service_outbox WHERE workspace_id=?1 AND sequence>?2 AND ((event_type='changed' AND ?3) OR (event_type='deleted' AND ?4)) ORDER BY sequence LIMIT 1",params![workspace,c.cursor,c.subscriptions.contains(&PluginTicketEventType::Changed),c.subscriptions.contains(&PluginTicketEventType::Deleted)],|r|Ok((r.get::<_,i64>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,String>(5)?,r.get::<_,String>(6)?,r.get::<_,String>(7)?,r.get::<_,Option<String>>(8)?))).optional()?;
|
||||
let Some((
|
||||
sequence,
|
||||
event_id,
|
||||
kind,
|
||||
occurred_at,
|
||||
ticket_id,
|
||||
domain_event_ref,
|
||||
content_digest,
|
||||
correlation_id,
|
||||
causation_id,
|
||||
)) = event
|
||||
else {
|
||||
return Ok(PluginServiceResponse::Idle);
|
||||
};
|
||||
if c.current_event_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| id != &event_id)
|
||||
{
|
||||
pause(tx, &c, PluginServicePauseReason::RetentionGap)?;
|
||||
return Ok(PluginServiceResponse::Diagnostics {
|
||||
diagnostics: diagnostics(tx, &load(tx, workspace, &c.consumer_id)?, now)?,
|
||||
});
|
||||
}
|
||||
let record = plugin_receipt::begin(
|
||||
tx,
|
||||
plan,
|
||||
&c.installation,
|
||||
&PluginConsumer::Consumer {
|
||||
consumer_id: c.consumer_id.clone(),
|
||||
event_id: event_id.clone(),
|
||||
export_name: c.service.clone(),
|
||||
},
|
||||
now,
|
||||
RETENTION_MS,
|
||||
)?;
|
||||
let attempt = c.attempt + 1;
|
||||
tx.execute("UPDATE plugin_service_consumers SET current_sequence=?3,current_event_id=?4,invocation_id=?5,attempt=?6,next_attempt_ms=?7 WHERE workspace_id=?1 AND consumer_id=?2",params![workspace,c.consumer_id,sequence,event_id,record.invocation_id,attempt,now+30_000])?;
|
||||
let envelope = PluginTicketEventEnvelope {
|
||||
event_id,
|
||||
event_type: decode(&format!("\"{kind}\""))?,
|
||||
version: 1,
|
||||
occurred_at,
|
||||
ticket_id,
|
||||
domain_event_ref,
|
||||
content_digest,
|
||||
correlation_id,
|
||||
causation_id,
|
||||
attempt,
|
||||
};
|
||||
if encode(&envelope)?.len() > 64 * 1024 {
|
||||
return Err(failure(PluginHostErrorCode::QuotaExceeded));
|
||||
}
|
||||
Ok(PluginServiceResponse::Delivery {
|
||||
envelope,
|
||||
invocation_id: record.invocation_id,
|
||||
expires_at_ms: record.expires_at_ms,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn complete(
|
||||
tx: &Transaction<'_>,
|
||||
workspace: &str,
|
||||
lease: &PluginServiceLease,
|
||||
event: &str,
|
||||
result: &PluginServiceEventResult,
|
||||
now: i64,
|
||||
) -> Result<()> {
|
||||
let c = fence(tx, workspace, lease, now)?;
|
||||
if c.current_event_id.as_deref() != Some(event) {
|
||||
let ack:Option<String>=tx.query_row("SELECT last_ack_event_id FROM plugin_service_consumers WHERE workspace_id=?1 AND consumer_id=?2",params![workspace,c.consumer_id],|r|r.get(0))?;
|
||||
if ack.as_deref() == Some(event) && matches!(result, PluginServiceEventResult::Ack) {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(failure(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
if c.pause.is_some() {
|
||||
return Err(failure(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
if unknown_effect(tx, &c, now)? || matches!(result, PluginServiceEventResult::Indeterminate) {
|
||||
return pause(tx, &c, PluginServicePauseReason::Indeterminate);
|
||||
}
|
||||
match result {
|
||||
PluginServiceEventResult::Ack => {
|
||||
tx.execute("UPDATE plugin_service_consumers SET cursor=current_sequence,last_ack_event_id=current_event_id,current_sequence=NULL,current_event_id=NULL,invocation_id=NULL,attempt=0,next_attempt_ms=0 WHERE workspace_id=?1 AND consumer_id=?2",params![workspace,c.consumer_id])?;
|
||||
}
|
||||
PluginServiceEventResult::RetryAfter { delay_ms } => {
|
||||
if *delay_ms > MAX_RETRY_MS {
|
||||
return Err(failure(PluginHostErrorCode::InvalidInput));
|
||||
}
|
||||
if c.attempt >= MAX_ATTEMPTS {
|
||||
pause(tx, &c, PluginServicePauseReason::AttemptsExhausted)?;
|
||||
} else {
|
||||
tx.execute("UPDATE plugin_service_consumers SET next_attempt_ms=?3 WHERE workspace_id=?1 AND consumer_id=?2",params![workspace,c.consumer_id,now+i64::from(*delay_ms)])?;
|
||||
}
|
||||
}
|
||||
PluginServiceEventResult::Reject => pause(tx, &c, PluginServicePauseReason::Rejected)?,
|
||||
PluginServiceEventResult::Indeterminate => unreachable!(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Cleanup never authorizes work, so an expired matching token may be released.
|
||||
/// The API authenticates the consumer's immutable Worker identity. Exact released
|
||||
/// proofs reconcile lost responses without touching any successor lease.
|
||||
pub fn release(
|
||||
tx: &Transaction<'_>,
|
||||
workspace: &str,
|
||||
lease: &PluginServiceLease,
|
||||
now: i64,
|
||||
) -> Result<()> {
|
||||
if !valid(&lease.consumer_id, 512) || !valid(&lease.lease_token, 512) {
|
||||
return Err(failure(PluginHostErrorCode::InvalidInput));
|
||||
}
|
||||
let released: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM plugin_service_released_proofs WHERE workspace_id=?1 AND consumer_id=?2 AND lease_token=?3)",
|
||||
params![workspace, lease.consumer_id, lease.lease_token],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if released {
|
||||
return Ok(());
|
||||
}
|
||||
// NULL alone is not proof of this release. Only the exact current token may
|
||||
// be cleared, even if it has expired; an unrecorded stale token must fail.
|
||||
let cleared = tx.execute(
|
||||
"UPDATE plugin_service_consumers SET lease_token=NULL,lease_until_ms=0 WHERE workspace_id=?1 AND consumer_id=?2 AND lease_token=?3",
|
||||
params![workspace, lease.consumer_id, lease.lease_token],
|
||||
)?;
|
||||
if cleared != 1 {
|
||||
return Err(failure(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO plugin_service_released_proofs(workspace_id,consumer_id,lease_token,released_at_ms) VALUES(?1,?2,?3,?4)",
|
||||
params![workspace, lease.consumer_id, lease.lease_token, now],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Caller supplies authenticated Workspace management authority. Audit and cursor
|
||||
/// change commit atomically; unknown external effects cannot be resumed blindly.
|
||||
pub fn recover(
|
||||
tx: &Transaction<'_>,
|
||||
workspace: &str,
|
||||
id: &str,
|
||||
expected: PluginServicePauseReason,
|
||||
action: &PluginServiceRecoveryAction,
|
||||
key: &str,
|
||||
actor: &str,
|
||||
reason: &str,
|
||||
now: i64,
|
||||
) -> Result<()> {
|
||||
if !valid(key, 128) || !valid(actor, 512) || !valid(reason, 1024) {
|
||||
return Err(failure(PluginHostErrorCode::InvalidInput));
|
||||
}
|
||||
let hex: String = Sha256::digest(encode(&(id, expected, action, actor, reason))?)
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
let fp = format!("sha256:{hex}");
|
||||
let saved:Option<String>=tx.query_row("SELECT fingerprint FROM plugin_service_recovery_audit WHERE workspace_id=?1 AND operation_key=?2",params![workspace,key],|r|r.get(0)).optional()?;
|
||||
if let Some(saved) = saved {
|
||||
return if saved == fp {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(failure(PluginHostErrorCode::Conflict))
|
||||
};
|
||||
}
|
||||
let c = load(tx, workspace, id)?;
|
||||
if c.pause != Some(expected) || (c.lease_token.is_some() && c.lease_until_ms > now) {
|
||||
return Err(failure(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
let cursor = match action {
|
||||
PluginServiceRecoveryAction::Resume => {
|
||||
if matches!(
|
||||
expected,
|
||||
PluginServicePauseReason::Indeterminate | PluginServicePauseReason::RetentionGap
|
||||
) || unknown_effect(tx, &c, now)?
|
||||
|| c.cursor < floor(tx, workspace)?
|
||||
|| (expected != PluginServicePauseReason::QueueFull && queued(tx, &c)? >= MAX_QUEUE)
|
||||
{
|
||||
return Err(failure(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
c.cursor
|
||||
}
|
||||
PluginServiceRecoveryAction::Skip { event_id } => {
|
||||
if c.current_event_id.as_ref() == Some(event_id) {
|
||||
c.current_sequence
|
||||
.ok_or_else(|| failure(PluginHostErrorCode::Conflict))?
|
||||
} else if c.current_event_id.is_none()
|
||||
&& expected == PluginServicePauseReason::QueueFull
|
||||
{
|
||||
// Queue admission may stop before an invocation exists. Only the
|
||||
// exact observed queue head can be abandoned, never an arbitrary
|
||||
// later event or a guessed missing retention identity.
|
||||
let head: Option<(i64, String)> = tx.query_row(
|
||||
"SELECT sequence,event_id FROM typed_ticket_service_outbox WHERE workspace_id=?1 AND sequence>?2 AND ((event_type='changed' AND ?3) OR (event_type='deleted' AND ?4)) ORDER BY sequence LIMIT 1",
|
||||
params![workspace, c.cursor, c.subscriptions.contains(&PluginTicketEventType::Changed), c.subscriptions.contains(&PluginTicketEventType::Deleted)],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
).optional()?;
|
||||
let Some((sequence, id)) = head else {
|
||||
return Err(failure(PluginHostErrorCode::Conflict));
|
||||
};
|
||||
if &id != event_id {
|
||||
return Err(failure(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
sequence
|
||||
} else {
|
||||
return Err(failure(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
}
|
||||
PluginServiceRecoveryAction::AcceptGap => {
|
||||
if expected != PluginServicePauseReason::RetentionGap {
|
||||
return Err(failure(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
floor(tx, workspace)?
|
||||
}
|
||||
};
|
||||
tx.execute("INSERT INTO plugin_service_recovery_audit(workspace_id,operation_key,consumer_id,fingerprint,actor,reason,action_json,at_ms) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",params![workspace,key,id,fp,actor,reason,encode(action)?,now])?;
|
||||
// Resume preserves the blocked event identity/receipts; skip/gap explicitly
|
||||
// abandons it. Attempts reset only under this audited management decision.
|
||||
if matches!(action, PluginServiceRecoveryAction::Resume) {
|
||||
let drain = if expected == PluginServicePauseReason::QueueFull {
|
||||
tail(tx, workspace)?
|
||||
} else {
|
||||
c.drain_through
|
||||
};
|
||||
tx.execute("UPDATE plugin_service_consumers SET pause_reason=NULL,attempt=0,next_attempt_ms=0,drain_through=?3 WHERE workspace_id=?1 AND consumer_id=?2",params![workspace,id,drain])?;
|
||||
} else {
|
||||
tx.execute("UPDATE plugin_service_consumers SET cursor=?3,pause_reason=NULL,current_sequence=NULL,current_event_id=NULL,invocation_id=NULL,attempt=0,next_attempt_ms=0 WHERE workspace_id=?1 AND consumer_id=?2",params![workspace,id,cursor])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Host retention only: never remove an event before the advertised replay window.
|
||||
/// Missing rows become an explicit consumer gap, not an automatic tail reset.
|
||||
pub fn prune_outbox(
|
||||
tx: &Transaction<'_>,
|
||||
workspace: &str,
|
||||
through: i64,
|
||||
now: i64,
|
||||
) -> Result<usize> {
|
||||
let cutoff = chrono::DateTime::from_timestamp_millis(now - RETENTION_MS)
|
||||
.ok_or_else(|| failure(PluginHostErrorCode::InvalidInput))?
|
||||
.to_rfc3339();
|
||||
let removed:Option<i64>=tx.query_row("SELECT MAX(sequence) FROM typed_ticket_service_outbox WHERE workspace_id=?1 AND sequence<=?2 AND occurred_at<?3",params![workspace,through,cutoff],|r|r.get(0))?;
|
||||
let Some(removed) = removed else { return Ok(0) };
|
||||
// Never create a hole past a newer retained event in the same Workspace.
|
||||
let blocked:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_service_outbox WHERE workspace_id=?1 AND sequence<=?2 AND occurred_at>=?3)",params![workspace,removed,cutoff],|r|r.get(0))?;
|
||||
if blocked {
|
||||
return Err(failure(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
let count = tx.execute(
|
||||
"DELETE FROM typed_ticket_service_outbox WHERE workspace_id=?1 AND sequence<=?2",
|
||||
params![workspace, removed],
|
||||
)?;
|
||||
tx.execute("INSERT INTO plugin_service_retention(workspace_id,removed_through) VALUES(?1,?2) ON CONFLICT(workspace_id) DO UPDATE SET removed_through=MAX(removed_through,excluded.removed_through)",params![workspace,removed])?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "plugin_service_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,53 @@
|
||||
#[test]
|
||||
fn service_migration_preserves_history_has_no_backfill_and_matches_fresh() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.db");
|
||||
prepare_retained_schema(&path, 111);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
seed_migration_workspace(&conn);
|
||||
let before = workspace_schema_migration_history(&conn).unwrap();
|
||||
migrate_plugin_services_v111_to_v112(&conn).unwrap();
|
||||
assert_eq!(
|
||||
&workspace_schema_migration_history(&conn).unwrap()[..before.len()],
|
||||
before.as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM typed_ticket_service_outbox",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
let objects = |c: &Connection| -> Result<Vec<(String, String)>> {
|
||||
Ok(c.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND (name LIKE 'plugin_service_%' OR name LIKE 'typed_ticket_service_outbox%') ORDER BY name")?.query_map([],|r|Ok((r.get(0)?,r.get(1)?)))?.collect::<rusqlite::Result<_>>()?)
|
||||
};
|
||||
assert_eq!(objects(&conn).unwrap(), fresh.with_conn(objects).unwrap());
|
||||
assert!(migrate_plugin_services_v111_to_v112(&conn).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_migration_late_failure_rolls_back_domain_and_consumer_schema() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.db");
|
||||
prepare_retained_schema(&path, 111);
|
||||
let conn = Connection::open(path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
conn.execute_batch("CREATE TABLE plugin_service_recovery_audit(unrelated INTEGER)")
|
||||
.unwrap();
|
||||
let schema = |c: &Connection| {
|
||||
c.query_row("SELECT group_concat(sql,char(10)) FROM (SELECT sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name)",[],|r|r.get::<_,String>(0)).unwrap()
|
||||
};
|
||||
let before = schema(&conn);
|
||||
let history = workspace_schema_migration_history(&conn).unwrap();
|
||||
assert!(migrate_plugin_services_v111_to_v112(&conn).is_err());
|
||||
assert_eq!(schema(&conn), before);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap(), history);
|
||||
conn.execute_batch("DROP TABLE plugin_service_recovery_audit")
|
||||
.unwrap();
|
||||
migrate_plugin_services_v111_to_v112(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 112);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
CREATE TABLE plugin_service_consumers (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
consumer_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL,
|
||||
installation_id TEXT NOT NULL,
|
||||
plan_id TEXT NOT NULL,
|
||||
service_name TEXT NOT NULL,
|
||||
subscriptions_json TEXT NOT NULL CHECK(json_valid(subscriptions_json) AND json_type(subscriptions_json)='array' AND json_array_length(subscriptions_json) BETWEEN 1 AND 2),
|
||||
cursor INTEGER NOT NULL CHECK(cursor>=0),
|
||||
-- Explicit recovery may drain the already observed backlog, one event at a time.
|
||||
-- New backlog beyond this watermark is subject to ordinary backpressure again.
|
||||
drain_through INTEGER NOT NULL DEFAULT 0 CHECK(drain_through>=0),
|
||||
lease_token TEXT,
|
||||
lease_until_ms INTEGER NOT NULL DEFAULT 0,
|
||||
pause_reason TEXT CHECK(pause_reason IN ('rejected','attempts_exhausted','queue_full','retention_gap','indeterminate','authorization')),
|
||||
current_sequence INTEGER,
|
||||
current_event_id TEXT,
|
||||
invocation_id TEXT,
|
||||
attempt INTEGER NOT NULL DEFAULT 0 CHECK(attempt BETWEEN 0 AND 5),
|
||||
next_attempt_ms INTEGER NOT NULL DEFAULT 0,
|
||||
last_ack_event_id TEXT,
|
||||
PRIMARY KEY(workspace_id,consumer_id),
|
||||
UNIQUE(workspace_id,runtime_id,worker_id,installation_id,plan_id,service_name),
|
||||
FOREIGN KEY(workspace_id,plan_id) REFERENCES plugin_execution_plans(workspace_id,plan_id),
|
||||
FOREIGN KEY(workspace_id,installation_id) REFERENCES plugin_installations(workspace_id,installation_id),
|
||||
CHECK((current_sequence IS NULL AND current_event_id IS NULL AND invocation_id IS NULL) OR (current_sequence IS NOT NULL AND current_event_id IS NOT NULL))
|
||||
);
|
||||
-- Release proofs outlive lease expiry/rotation: a lost response can be retried
|
||||
-- for the lifetime of the consumer. Only deleting that owner may discard proofs.
|
||||
CREATE TABLE plugin_service_released_proofs (
|
||||
workspace_id TEXT NOT NULL,
|
||||
consumer_id TEXT NOT NULL,
|
||||
lease_token TEXT NOT NULL CHECK(length(CAST(lease_token AS BLOB)) BETWEEN 1 AND 512),
|
||||
released_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY(workspace_id,consumer_id,lease_token),
|
||||
FOREIGN KEY(workspace_id,consumer_id) REFERENCES plugin_service_consumers(workspace_id,consumer_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TRIGGER plugin_service_released_proof_immutable BEFORE UPDATE ON plugin_service_released_proofs
|
||||
BEGIN SELECT RAISE(ABORT,'Service released proof is immutable'); END;
|
||||
CREATE TRIGGER plugin_service_released_proof_retained BEFORE DELETE ON plugin_service_released_proofs
|
||||
WHEN EXISTS(SELECT 1 FROM plugin_service_consumers WHERE workspace_id=OLD.workspace_id AND consumer_id=OLD.consumer_id)
|
||||
BEGIN SELECT RAISE(ABORT,'Service released proof must be retained'); END;
|
||||
CREATE TABLE plugin_service_retention (
|
||||
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
removed_through INTEGER NOT NULL CHECK(removed_through>=0)
|
||||
);
|
||||
CREATE TABLE plugin_service_recovery_audit (
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
operation_key TEXT NOT NULL CHECK(length(CAST(operation_key AS BLOB)) BETWEEN 1 AND 128),
|
||||
consumer_id TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
reason TEXT NOT NULL CHECK(length(CAST(reason AS BLOB)) BETWEEN 1 AND 1024),
|
||||
action_json TEXT NOT NULL CHECK(json_valid(action_json)),
|
||||
at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY(workspace_id,operation_key),
|
||||
FOREIGN KEY(workspace_id,consumer_id) REFERENCES plugin_service_consumers(workspace_id,consumer_id)
|
||||
);
|
||||
CREATE TRIGGER plugin_service_identity_immutable BEFORE UPDATE OF workspace_id,consumer_id,runtime_id,worker_id,installation_id,plan_id,service_name,subscriptions_json ON plugin_service_consumers
|
||||
BEGIN SELECT RAISE(ABORT,'Service consumer identity is immutable'); END;
|
||||
CREATE TRIGGER plugin_service_cursor_forward BEFORE UPDATE OF cursor ON plugin_service_consumers WHEN NEW.cursor<OLD.cursor
|
||||
BEGIN SELECT RAISE(ABORT,'Service cursor cannot rewind'); END;
|
||||
CREATE TRIGGER plugin_service_audit_immutable BEFORE UPDATE ON plugin_service_recovery_audit
|
||||
BEGIN SELECT RAISE(ABORT,'Service recovery audit is immutable'); END;
|
||||
CREATE TRIGGER plugin_service_audit_retained BEFORE DELETE ON plugin_service_recovery_audit WHEN EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=OLD.workspace_id)
|
||||
BEGIN SELECT RAISE(ABORT,'Service recovery audit must be retained'); END;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,10 @@ pub struct PluginEnablement {
|
||||
pub package_digest: String,
|
||||
pub artifact_digest: String,
|
||||
pub tools: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub services: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub ticket_events: bool,
|
||||
pub logging: bool,
|
||||
#[serde(default)]
|
||||
pub ticket_read: bool,
|
||||
@@ -170,7 +174,7 @@ pub(crate) fn evaluate_catalog_config(
|
||||
// Each alias has its own closed schema. Reusing an artifact with distinct
|
||||
// bindings is intentional and does not manufacture another schema authority.
|
||||
let enabled_fields = artifacts.iter().map(|(alias, artifact)| {
|
||||
format!("{} = {{ installation_id = String; expected_last_operation = String; package_digest = String; artifact_digest = String; tools = [...String]; logging = Bool default false; ticket_read = Bool default false; ticket_data_read = [...{{ collection = String; schema_id = String; schema_version = String; }}] default []; ticket_data_write = [...{{ collection = String; schema_id = String; schema_version = String; }}] default []; connections = {{ ...String }} default {{}}; config = ({}); }};", alias, artifact.config_schema.dcdl)
|
||||
format!("{} = {{ installation_id = String; expected_last_operation = String; package_digest = String; artifact_digest = String; tools = [...String]; services = [...String] default []; ticket_events = Bool default false; logging = Bool default false; ticket_read = Bool default false; ticket_data_read = [...{{ collection = String; schema_id = String; schema_version = String; }}] default []; ticket_data_write = [...{{ collection = String; schema_id = String; schema_version = String; }}] default []; connections = {{ ...String }} default {{}}; config = ({}); }};", alias, artifact.config_schema.dcdl)
|
||||
}).collect::<Vec<_>>().join("\n");
|
||||
let enabled_schema = if enabled_fields.is_empty() {
|
||||
"{} default {}".to_string()
|
||||
@@ -245,6 +249,7 @@ fn validate_enablement(
|
||||
| manifest::plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD
|
||||
| manifest::plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD
|
||||
| "yoi:plugin/ticket-tool-plugin@2.1.0"
|
||||
| "yoi:plugin/ticket-service-plugin@2.2.0"
|
||||
)
|
||||
|| artifact.required_imports.iter().any(|i| {
|
||||
!matches!(
|
||||
@@ -269,6 +274,21 @@ fn validate_enablement(
|
||||
{
|
||||
return Err(config_error("unknown or duplicate Tool selection"));
|
||||
}
|
||||
if enabled.services.iter().collect::<BTreeSet<_>>().len() != enabled.services.len()
|
||||
|| enabled
|
||||
.services
|
||||
.iter()
|
||||
.any(|name| !artifact.services.iter().any(|s| &s.name == name))
|
||||
|| (enabled.ticket_events
|
||||
&& (!artifact
|
||||
.requested_grants
|
||||
.contains(&PluginGrantMetadata::TicketEvents)
|
||||
|| artifact.world != "yoi:plugin/ticket-service-plugin@2.2.0"))
|
||||
{
|
||||
return Err(config_error(
|
||||
"Service selection/grant exceeds package request",
|
||||
));
|
||||
}
|
||||
if enabled.logging
|
||||
&& !artifact
|
||||
.requested_grants
|
||||
@@ -432,6 +452,11 @@ fn project_profile_plugins(
|
||||
.tools
|
||||
.iter()
|
||||
.any(|tool| !enabled.tools.contains(tool))
|
||||
|| selection
|
||||
.services
|
||||
.iter()
|
||||
.any(|s| !enabled.services.contains(s))
|
||||
|| (selection.ticket_events && !enabled.ticket_events)
|
||||
|| (selection.logging && !enabled.logging)
|
||||
|| (selection.ticket_read && !enabled.ticket_read)
|
||||
|| selection
|
||||
@@ -461,6 +486,9 @@ fn project_profile_plugins(
|
||||
let config =
|
||||
evaluate_effective_config(&config, &resolved.artifacts[alias].config_schema.dcdl)?;
|
||||
let mut grants = Vec::new();
|
||||
if selection.ticket_events {
|
||||
grants.push(PluginGrant::TicketEvents);
|
||||
}
|
||||
if selection.logging {
|
||||
grants.push(PluginGrant::Logging);
|
||||
}
|
||||
@@ -498,12 +526,15 @@ fn project_profile_plugins(
|
||||
&resolved.artifacts[alias],
|
||||
PluginPinProjection {
|
||||
config,
|
||||
surfaces: if selection.tools.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![PluginSurface::Tool]
|
||||
},
|
||||
surfaces: [
|
||||
(!selection.tools.is_empty()).then_some(PluginSurface::Tool),
|
||||
(!selection.services.is_empty()).then_some(PluginSurface::Service),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
tools: selection.tools.clone(),
|
||||
services: selection.services.clone(),
|
||||
grants,
|
||||
bindings,
|
||||
},
|
||||
|
||||
@@ -124,6 +124,10 @@ mod schema_convergence {
|
||||
version: 111,
|
||||
name: "Typed Plugin Ticket extension data and atomic mutation receipts".into(),
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 112,
|
||||
name: "Ticket Service outbox and fenced consumers".into(),
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -146,6 +150,7 @@ mod schema_convergence {
|
||||
expected.push((109, "Durable Plugin Host invocations and HTTPS effect receipts".into()));
|
||||
expected.push((110, "Worker Plugin launch selection associations".into()));
|
||||
expected.push((111, "Typed Plugin Ticket extension data and atomic mutation receipts".into()));
|
||||
expected.push((112, "Ticket Service outbox and fenced consumers".into()));
|
||||
assert_eq!(workspace_schema_migration_history(conn)?, expected);
|
||||
for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] {
|
||||
assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}");
|
||||
|
||||
@@ -13742,6 +13742,30 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
plugin::list(api, actor, workspace_id, query).await
|
||||
}
|
||||
|
||||
async fn plugin_service_diagnostics(
|
||||
&self,
|
||||
actor: RequestActor,
|
||||
workspace_id: String,
|
||||
consumer_id: String,
|
||||
) -> std::result::Result<server_api::PluginServiceDiagnostics, server_api::RepositoryApiError>
|
||||
{
|
||||
self.workspace_api()?
|
||||
.plugin_service_diagnostics(workspace_id, actor, consumer_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn plugin_service_recover(
|
||||
&self,
|
||||
actor: RequestActor,
|
||||
workspace_id: String,
|
||||
request: server_api::PluginServiceRecoveryRequest,
|
||||
) -> std::result::Result<server_api::PluginServiceDiagnostics, server_api::RepositoryApiError>
|
||||
{
|
||||
self.workspace_api()?
|
||||
.recover_plugin_service(workspace_id, actor, request)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn plugin_worker_diagnostics(
|
||||
&self,
|
||||
actor: RequestActor,
|
||||
|
||||
@@ -17,6 +17,8 @@ pub(super) fn router(service: Arc<ServerApiContractService>) -> Router {
|
||||
.merge(server_api_axum::plugin_install(service.clone()))
|
||||
.merge(server_api_axum::plugin_list(service.clone()))
|
||||
.merge(server_api_axum::plugin_worker_diagnostics(service.clone()))
|
||||
.merge(server_api_axum::plugin_service_diagnostics(service.clone()))
|
||||
.merge(server_api_axum::plugin_service_recover(service.clone()))
|
||||
.merge(server_api_axum::plugin_show(service.clone()))
|
||||
.merge(server_api_axum::plugin_artifact_add(service.clone()))
|
||||
.merge(server_api_axum::plugin_revoke(service.clone()))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Include directly in server::tests (this file owns its module).
|
||||
// Production Embedded Server assembly -> ProfileRuntimeWorkerFactory -> actual
|
||||
// Controller/Engine -> pinned Wasmtime Tool -> HTTPS -> SQLite and Session store.
|
||||
// Controller/Engine -> pinned Wasmtime Tool/Service -> HTTPS, Ticket outbox,
|
||||
// SQLite receipts/cursors and Session store.
|
||||
// Only the external model and the TLS peer's DNS/socket/trust are substituted.
|
||||
// No FixtureRuntime, manually prepared Plugin Feature, or fabricated Host owner.
|
||||
//
|
||||
@@ -31,6 +32,12 @@ mod plugin_controller_tests {
|
||||
const TOOL: &str = "example_status";
|
||||
const WATCHDOG: Duration = Duration::from_secs(60);
|
||||
|
||||
enum FixtureSurface {
|
||||
Tool,
|
||||
HttpsTool,
|
||||
Service,
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
api: WorkspaceApi,
|
||||
owner: String,
|
||||
@@ -113,23 +120,42 @@ mod plugin_controller_tests {
|
||||
&self,
|
||||
peer: &Peer,
|
||||
https: bool,
|
||||
) -> (server_api::PluginOperationResponse, Vec<u8>) {
|
||||
self.install_surface(
|
||||
peer,
|
||||
if https {
|
||||
FixtureSurface::HttpsTool
|
||||
} else {
|
||||
FixtureSurface::Tool
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn install_surface(
|
||||
&self,
|
||||
peer: &Peer,
|
||||
surface: FixtureSurface,
|
||||
) -> (server_api::PluginOperationResponse, Vec<u8>) {
|
||||
use manifest::plugin::*;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (manifest, component): (&str, &[u8]) = if https {
|
||||
(
|
||||
let (manifest, component): (&str, &[u8]) = match surface {
|
||||
FixtureSurface::Service => (
|
||||
include_str!("../../plugin-runtime/tests/service-guest/plugin.toml"),
|
||||
include_bytes!("../tests/fixtures/ticket-service-v2.component.wasm"),
|
||||
),
|
||||
FixtureSurface::HttpsTool => (
|
||||
include_str!(
|
||||
"../../../resources/plugin/templates/rust-component-https/plugin.toml"
|
||||
),
|
||||
include_bytes!("../../plugin-pdk/tests/fixtures/https-tool-v2.component.wasm"),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
),
|
||||
FixtureSurface::Tool => (
|
||||
include_str!(
|
||||
"../../../resources/plugin/templates/rust-component-tool/plugin.toml"
|
||||
),
|
||||
include_bytes!("../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"),
|
||||
)
|
||||
),
|
||||
};
|
||||
fs::write(directory.path().join("plugin.toml"), manifest).unwrap();
|
||||
fs::write(directory.path().join("plugin.component.wasm"), component).unwrap();
|
||||
@@ -168,11 +194,10 @@ mod plugin_controller_tests {
|
||||
})).unwrap(), false, StatusCode::OK).await;
|
||||
let installed: server_api::PluginOperationResponse =
|
||||
serde_json::from_value(installed).unwrap();
|
||||
*self.model_tool.lock().unwrap() = format!(
|
||||
"{}__{}",
|
||||
installed.installation.installation_id,
|
||||
installed.installation.artifacts[0].tools[0].name
|
||||
);
|
||||
if let Some(tool) = installed.installation.artifacts[0].tools.first() {
|
||||
*self.model_tool.lock().unwrap() =
|
||||
format!("{}__{}", installed.installation.installation_id, tool.name,);
|
||||
}
|
||||
self.configure(&installed, peer, true).await;
|
||||
(installed, package.bytes)
|
||||
}
|
||||
@@ -187,18 +212,26 @@ mod plugin_controller_tests {
|
||||
let mut binding = peer.binding();
|
||||
binding.methods = vec![server_api::PluginHttpMethod::Get];
|
||||
binding.path_prefixes = vec!["/status".into()];
|
||||
let tool = &artifact.tools[0].name;
|
||||
let https = tool == TOOL;
|
||||
let service = artifact.services.first().map(|service| &service.name);
|
||||
let tool = artifact.tools.first().map(|tool| &tool.name);
|
||||
let https = tool.is_some_and(|tool| tool == TOOL);
|
||||
let tools = tool.into_iter().collect::<Vec<_>>();
|
||||
let services = service.into_iter().collect::<Vec<_>>();
|
||||
let config = if service.is_some() {
|
||||
json!({"mode":"normal"})
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
let plugins = json!({"enabled":{"controller":{
|
||||
"installation_id":installed.installation.installation_id,
|
||||
"expected_last_operation":installed.operation_id,
|
||||
"package_digest":artifact.package_digest,"artifact_digest":artifact.artifact_digest,
|
||||
"tools":[tool],"logging":false,"config":{},
|
||||
"tools":tools,"services":services,"ticket_events":service.is_some(),"logging":service.is_some(),"config":config,
|
||||
"connections":if https { json!({"api":"remote"}) } else { json!({}) }
|
||||
}},"bindings":if https { json!({"remote":binding}) } else { json!({}) }});
|
||||
let selection = if selected {
|
||||
json!({"selections":[{"installation_id":installed.installation.installation_id,
|
||||
"tools":[tool],"logging":false,"https_slots":if https { vec!["api"] } else { vec![] }}]})
|
||||
"tools":tools,"services":services,"ticket_events":service.is_some(),"logging":service.is_some(),"https_slots":if https { vec!["api"] } else { vec![] }}]})
|
||||
} else {
|
||||
json!({"selections":[]})
|
||||
};
|
||||
@@ -552,7 +585,12 @@ mod plugin_controller_tests {
|
||||
peer.connection_count(),
|
||||
2,
|
||||
"a new logical call must not replay the first receipt; model requests: {:?}",
|
||||
f.requests.lock().unwrap().iter().map(|r| &r.items).collect::<Vec<_>>()
|
||||
f.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| &r.items)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(f.count("plugin_invocations"), 2);
|
||||
assert_eq!(f.count("plugin_effect_receipts"), 2);
|
||||
@@ -742,6 +780,310 @@ mod plugin_controller_tests {
|
||||
f.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn embedded_service_audited_recovery_wakes_same_controller_and_restore_keeps_cursor() {
|
||||
use crate::plugin_service::Consumer;
|
||||
use rusqlite::OptionalExtension;
|
||||
use server_api::{PluginServiceDiagnostics, PluginServicePauseReason};
|
||||
|
||||
async fn until(
|
||||
phase: &str,
|
||||
observe: impl Fn() -> Option<Consumer>,
|
||||
ready: impl Fn(&Consumer) -> bool,
|
||||
) -> Consumer {
|
||||
tokio::time::timeout(WATCHDOG, async {
|
||||
loop {
|
||||
if let Some(consumer) = observe().filter(&ready) {
|
||||
return consumer;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
let state = observe().map(|c| {
|
||||
(
|
||||
c.consumer_id,
|
||||
c.cursor,
|
||||
c.pause,
|
||||
c.current_event_id,
|
||||
c.attempt,
|
||||
c.next_attempt_ms,
|
||||
c.lease_token.is_some(),
|
||||
)
|
||||
});
|
||||
panic!("Service {phase} did not converge: {state:?}");
|
||||
})
|
||||
}
|
||||
|
||||
let peer = Peer::new(HOST, vec![]).await;
|
||||
let f = Fixture::new(&peer).await;
|
||||
let (installed, _) = f.install_surface(&peer, FixtureSurface::Service).await;
|
||||
let worker = f.create(PROFILE).await;
|
||||
let state = || {
|
||||
f.api.config_store.with_conn(|conn| {
|
||||
let id: Option<String> = conn.query_row(
|
||||
"SELECT consumer_id FROM plugin_service_consumers WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3",
|
||||
rusqlite::params![TEST_WORKSPACE_ID, worker.runtime_id, worker.worker_id],
|
||||
|row| row.get(0),
|
||||
).optional()?;
|
||||
id.map(|id| crate::plugin_service::load(conn, TEST_WORKSPACE_ID, &id)).transpose()
|
||||
}).unwrap()
|
||||
};
|
||||
let before = until("initial factory lease", &state, |c| c.lease_token.is_some()).await;
|
||||
let pin = f.execution_request(&worker)["plugin_execution"].clone();
|
||||
assert_eq!(pin["plan_id"], before.plan_id);
|
||||
assert_eq!(f.model_clients.load(Ordering::SeqCst), 1);
|
||||
let router = build_router(f.api.clone());
|
||||
let prefix = format!("/api/w/{TEST_WORKSPACE_ID}");
|
||||
let path = format!(
|
||||
"{prefix}/plugins/worker-diagnostics/{}/{}",
|
||||
worker.runtime_id, worker.worker_id
|
||||
);
|
||||
let observed = request(
|
||||
router.clone(),
|
||||
Some(&f.owner),
|
||||
"GET",
|
||||
&path,
|
||||
vec![],
|
||||
false,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let observed: server_api::PluginWorkerDiagnostics =
|
||||
serde_json::from_value(observed).unwrap();
|
||||
let server_api::PluginWorkerDiagnosticSelection::Plan {
|
||||
plan_id, plugins, ..
|
||||
} = observed.selection
|
||||
else {
|
||||
panic!("sealed Service launch selection missing");
|
||||
};
|
||||
assert_eq!(plan_id, before.plan_id);
|
||||
assert_eq!(plugins[0].selected_services, ["ticket_events"]);
|
||||
assert_eq!(
|
||||
plugins[0].service_consumers[0].diagnostics.consumer_id,
|
||||
before.consumer_id
|
||||
);
|
||||
assert_eq!(
|
||||
plugins[0].admission,
|
||||
server_api::PluginDiagnosticAdmission::Admissible
|
||||
);
|
||||
let consumer_path = format!("{prefix}/plugins/service-consumers/{}", before.consumer_id);
|
||||
let recover_path = format!("{prefix}/plugins/service-consumers/recover");
|
||||
request(
|
||||
router.clone(),
|
||||
None,
|
||||
"GET",
|
||||
&consumer_path,
|
||||
vec![],
|
||||
false,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
)
|
||||
.await;
|
||||
let observed = request(
|
||||
router.clone(),
|
||||
Some(&f.owner),
|
||||
"GET",
|
||||
&consumer_path,
|
||||
vec![],
|
||||
false,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(observed["consumer_id"], before.consumer_id);
|
||||
assert_eq!(observed["lease_active"], true);
|
||||
assert!(!observed.to_string().contains("lease_token"));
|
||||
request(router.clone(), Some(&f.owner), "POST", &recover_path, serde_json::to_vec(&json!({
|
||||
"consumer_id":before.consumer_id,"expected_pause":"rejected","action":{"kind":"resume"},"operation_key":"not-paused","reason":"must not override an active consumer"
|
||||
})).unwrap(), false, StatusCode::CONFLICT).await;
|
||||
let unchanged = state().unwrap();
|
||||
assert_eq!(unchanged.cursor, before.cursor);
|
||||
assert_eq!(unchanged.lease_token, before.lease_token);
|
||||
assert!(unchanged.pause.is_none());
|
||||
assert_eq!(f.count("plugin_invocations"), 0);
|
||||
assert_eq!(peer.connection_count(), 0);
|
||||
assert!(f.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
installed.installation.artifacts[0].services[0].name,
|
||||
"ticket_events"
|
||||
);
|
||||
|
||||
// The real guest requests extension_write, which this events/logging-only
|
||||
// selection denies. Its RetryAfter exhausts the durable delivery budget.
|
||||
let ticket = browser_ticket_backend(&f.api)
|
||||
.unwrap()
|
||||
.create(ticket::NewTicket::new("Same Controller Service recovery"))
|
||||
.unwrap();
|
||||
let exhausted = until("initial attempts exhausted and released", &state, |c| {
|
||||
c.pause == Some(PluginServicePauseReason::AttemptsExhausted) && c.lease_token.is_none()
|
||||
})
|
||||
.await;
|
||||
let observed = request(
|
||||
router.clone(),
|
||||
Some(&f.owner),
|
||||
"GET",
|
||||
&consumer_path,
|
||||
vec![],
|
||||
false,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let paused: PluginServiceDiagnostics = serde_json::from_value(observed).unwrap();
|
||||
assert_eq!(paused.consumer_id, before.consumer_id);
|
||||
assert_eq!(
|
||||
paused.pause_reason,
|
||||
Some(PluginServicePauseReason::AttemptsExhausted)
|
||||
);
|
||||
assert!(!paused.lease_active);
|
||||
assert_eq!(paused.attempt, crate::plugin_service::MAX_ATTEMPTS);
|
||||
assert_eq!(paused.current_event_id, exhausted.current_event_id);
|
||||
let event_id = paused.current_event_id.as_ref().unwrap();
|
||||
let sequence: i64 = f.api.config_store.with_conn(|conn| {
|
||||
Ok(conn.query_row(
|
||||
"SELECT sequence FROM typed_ticket_service_outbox WHERE workspace_id=?1 AND event_id=?2 AND ticket_id=?3",
|
||||
rusqlite::params![TEST_WORKSPACE_ID, event_id, ticket.id.as_str()],
|
||||
|row| row.get(0),
|
||||
)?)
|
||||
}).unwrap();
|
||||
assert!(sequence > before.cursor);
|
||||
assert_eq!(exhausted.cursor, before.cursor);
|
||||
assert!(exhausted.next_attempt_ms > 0);
|
||||
assert!(exhausted.invocation_id.is_some());
|
||||
assert_eq!(f.count("plugin_invocations"), 1);
|
||||
|
||||
let resume_reason = "Retry the observed denied event on its existing Controller";
|
||||
let resumed = request(router.clone(), Some(&f.owner), "POST", &recover_path, serde_json::to_vec(&json!({
|
||||
"consumer_id":paused.consumer_id,"expected_pause":paused.pause_reason.unwrap(),
|
||||
"action":{"kind":"resume"},"operation_key":"controller-resume","reason":resume_reason
|
||||
})).unwrap(), false, StatusCode::OK).await;
|
||||
assert_eq!(resumed["consumer_id"], before.consumer_id);
|
||||
assert!(resumed["pause_reason"].is_null());
|
||||
assert_eq!(resumed["current_event_id"], event_id.as_str());
|
||||
|
||||
// No stop/restore, Submit, manual Acquire, or replacement owner occurs
|
||||
// here. A later durable retry deadline proves new guest delivery rather
|
||||
// than accidentally accepting the already exhausted pre-recovery state.
|
||||
let retried = until(
|
||||
"same Controller recovery and renewed exhaustion",
|
||||
&state,
|
||||
|c| {
|
||||
c.consumer_id == before.consumer_id
|
||||
&& c.current_event_id.as_ref() == Some(event_id)
|
||||
&& c.pause == paused.pause_reason
|
||||
&& c.lease_token.is_none()
|
||||
&& c.next_attempt_ms > exhausted.next_attempt_ms
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(retried.plan_id, before.plan_id);
|
||||
assert_eq!(retried.cursor, before.cursor);
|
||||
assert_eq!(retried.attempt, crate::plugin_service::MAX_ATTEMPTS);
|
||||
assert_eq!(retried.invocation_id, exhausted.invocation_id);
|
||||
assert_eq!(f.count("plugin_invocations"), 1);
|
||||
assert_eq!(f.model_clients.load(Ordering::SeqCst), 1);
|
||||
let observed = request(
|
||||
router.clone(),
|
||||
Some(&f.owner),
|
||||
"GET",
|
||||
&consumer_path,
|
||||
vec![],
|
||||
false,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let paused_again: PluginServiceDiagnostics = serde_json::from_value(observed).unwrap();
|
||||
assert_eq!(paused_again, paused);
|
||||
|
||||
let skip_reason = "Explicitly abandon the observed denied queue head";
|
||||
let skipped = request(router.clone(), Some(&f.owner), "POST", &recover_path, serde_json::to_vec(&json!({
|
||||
"consumer_id":paused_again.consumer_id,"expected_pause":paused_again.pause_reason.unwrap(),
|
||||
"action":{"kind":"skip","event_id":event_id},"operation_key":"controller-skip","reason":skip_reason
|
||||
})).unwrap(), false, StatusCode::OK).await;
|
||||
assert_eq!(skipped["consumer_id"], before.consumer_id);
|
||||
assert!(skipped["pause_reason"].is_null());
|
||||
assert!(skipped["current_event_id"].is_null());
|
||||
let checkpoint = until(
|
||||
"same Controller reacquisition after audited Skip",
|
||||
&state,
|
||||
|c| {
|
||||
c.consumer_id == before.consumer_id
|
||||
&& c.cursor == sequence
|
||||
&& c.pause.is_none()
|
||||
&& c.current_event_id.is_none()
|
||||
&& c.lease_token.is_some()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(checkpoint.cursor > 0);
|
||||
assert_eq!(checkpoint.plan_id, before.plan_id);
|
||||
assert_ne!(checkpoint.lease_token, before.lease_token);
|
||||
assert_eq!(f.model_clients.load(Ordering::SeqCst), 1);
|
||||
|
||||
let owner = f
|
||||
.api
|
||||
.store
|
||||
.get_workspace(TEST_WORKSPACE_ID)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.owner_account_id;
|
||||
let audit: Vec<(String, String, String, String, String)> = f.api.config_store.with_conn(|conn| {
|
||||
Ok(conn.prepare(
|
||||
"SELECT operation_key,consumer_id,actor,reason,action_json FROM plugin_service_recovery_audit WHERE workspace_id=?1 ORDER BY operation_key",
|
||||
)?.query_map([TEST_WORKSPACE_ID], |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?,row.get(4)?)))?
|
||||
.collect::<rusqlite::Result<_>>()?)
|
||||
}).unwrap();
|
||||
assert_eq!(
|
||||
audit.len(),
|
||||
2,
|
||||
"the rejected no-op must not create an audit"
|
||||
);
|
||||
for (row, (key, reason, action)) in audit.iter().zip([
|
||||
("controller-resume", resume_reason, json!({"kind":"resume"})),
|
||||
(
|
||||
"controller-skip",
|
||||
skip_reason,
|
||||
json!({"kind":"skip","event_id":event_id}),
|
||||
),
|
||||
]) {
|
||||
assert_eq!(row.0, key);
|
||||
assert_eq!(row.1, before.consumer_id);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&row.2).unwrap(),
|
||||
json!([owner, "user-plugin-controller-owner"])
|
||||
);
|
||||
assert_eq!(row.3, reason);
|
||||
assert_eq!(serde_json::from_str::<Value>(&row.4).unwrap(), action);
|
||||
}
|
||||
|
||||
f.stop(&worker).await;
|
||||
assert!(state().unwrap().lease_token.is_none());
|
||||
f.restore(&worker, "restore-service").await;
|
||||
let restored = until("restored factory lease", &state, |c| {
|
||||
c.lease_token.is_some()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(restored.consumer_id, checkpoint.consumer_id);
|
||||
assert_eq!(restored.plan_id, checkpoint.plan_id);
|
||||
assert_eq!(restored.cursor, checkpoint.cursor);
|
||||
assert!(restored.pause.is_none());
|
||||
assert!(restored.current_event_id.is_none());
|
||||
assert_ne!(restored.lease_token, checkpoint.lease_token);
|
||||
assert_eq!(f.execution_request(&worker)["plugin_execution"], pin);
|
||||
let retained = f
|
||||
.api
|
||||
.config_store
|
||||
.plugin_plan_load(TEST_WORKSPACE_ID, &restored.plan_id)
|
||||
.unwrap();
|
||||
assert_eq!(retained.pin_state, PlanPinState::Active);
|
||||
assert_eq!(f.count("plugin_invocations"), 1);
|
||||
assert_eq!(f.count("plugin_effect_receipts"), 0);
|
||||
assert_eq!(peer.connection_count(), 0);
|
||||
assert!(f.requests.lock().unwrap().is_empty());
|
||||
f.stop(&worker).await;
|
||||
f.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn embedded_builtin_create_uses_profile_factory_and_seals_empty_plugin_selection() {
|
||||
let peer = Peer::new(HOST, vec![]).await;
|
||||
|
||||
@@ -66,7 +66,7 @@ fn access_granted(admitted: &policy::AdmittedPluginOperation, access: PluginTick
|
||||
})
|
||||
}
|
||||
|
||||
fn authorize_ticket(
|
||||
pub(super) fn authorize_ticket(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace: &str,
|
||||
@@ -268,11 +268,12 @@ pub(super) async fn execute(
|
||||
let config = api.config_store.clone();
|
||||
let w = workspace.clone();
|
||||
let id = invocation_id.clone();
|
||||
let record = config
|
||||
let mut record = config
|
||||
.db_executor()
|
||||
.run(move || config.with_conn(|conn| receipt::lookup(conn, &w, &id)))
|
||||
.await
|
||||
.map_err(policy::map_error)?;
|
||||
record.service_lease = service_lease();
|
||||
let admitted = check(
|
||||
api.clone(),
|
||||
context.clone(),
|
||||
@@ -298,8 +299,10 @@ pub(super) async fn execute(
|
||||
let ticket_id = authorize_ticket(&api, &context, &workspace, &selector, action.access())?;
|
||||
config.with_immediate_transaction(|tx| {
|
||||
let record = receipt::verify_record(tx, &record)?;
|
||||
let tools = match &record.consumer { PluginConsumer::ToolCall { tool_name, .. } => vec![tool_name.clone()], _ => return Err(policy::failure(PluginHostErrorCode::Unsupported)) };
|
||||
let admitted = policy::authorize(tx, &workspace, &record.execution, &record.plan_id, &record.installation_id, &tools, &PluginHostOperation::Admit)?;
|
||||
let admitted = match &record.consumer {
|
||||
PluginConsumer::ToolCall { tool_name, .. } => policy::authorize(tx, &workspace, &record.execution, &record.plan_id, &record.installation_id, &[tool_name.clone()], &PluginHostOperation::Admit)?,
|
||||
PluginConsumer::Consumer {export_name, ..} => policy::authorize_service(tx, &workspace, &record.execution, &record.plan_id, &record.installation_id, export_name, &PluginHostOperation::Admit)?,
|
||||
};
|
||||
if record.cancelled { return Err(policy::failure(PluginHostErrorCode::Cancelled)); }
|
||||
if !matches!(action, Action::Lookup(..)) && (record.expired || record.expires_at_ms <= now_ms()) { return Err(policy::failure(PluginHostErrorCode::ReceiptExpired)); }
|
||||
if !tx.query_row("SELECT EXISTS(SELECT 1 FROM typed_tickets t JOIN workspaces w USING(workspace_id) WHERE t.workspace_id=?1 AND t.ticket_id=?2 AND w.state='active')",rusqlite::params![workspace,ticket_id],|row|row.get::<_,bool>(0))? { return Err(policy::failure(PluginHostErrorCode::NotFound)); }
|
||||
|
||||
@@ -106,7 +106,7 @@ fn create_ticket_with_slug(f: &Fixture, body: &str, slug: Option<&str>) -> ticke
|
||||
|
||||
// Keep the real installation policy and change only concrete Ticket permissions.
|
||||
// A cached resolve or receipt must not bypass these subsequent live changes.
|
||||
fn ticket_policy(f: &Fixture, tickets: &[(&str, bool, bool)]) {
|
||||
pub(super) fn ticket_policy(f: &Fixture, tickets: &[(&str, bool, bool)]) {
|
||||
let store = open_subjektiv_store(&f.api).unwrap();
|
||||
let previous = store.subject_permissions(&f.subject).unwrap().unwrap();
|
||||
let mut execution: Vec<_> = previous
|
||||
@@ -166,6 +166,7 @@ async fn host_for_plan(
|
||||
context.grants.connection_slots.insert(slot.clone());
|
||||
}
|
||||
PluginGrant::TicketRead => context.grants.ticket_read = true,
|
||||
PluginGrant::TicketEvents => (),
|
||||
PluginGrant::TicketDataRead {
|
||||
collection,
|
||||
schema_id,
|
||||
|
||||
@@ -95,11 +95,12 @@ pub(super) async fn diagnostics(
|
||||
.plugins()
|
||||
.iter()
|
||||
.map(|pin| {
|
||||
let scope = |operation: &str| {
|
||||
subject_authorization::authorize_scope(
|
||||
&api,
|
||||
&worker_ref,
|
||||
&OperationAuthorizationRequest {
|
||||
operation: "plugin.admit".into(),
|
||||
operation: operation.into(),
|
||||
resource: PermissionResource::PluginInstallation {
|
||||
workspace_id: w.clone(),
|
||||
installation_id: pin.installation_id().into(),
|
||||
@@ -108,6 +109,12 @@ pub(super) async fn diagnostics(
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|error| reason(error.error))
|
||||
};
|
||||
scope("plugin.admit")?;
|
||||
if !pin.projection().services.is_empty() {
|
||||
scope("plugin.service.consume")?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.collect();
|
||||
store.with_conn_mut(|conn| {
|
||||
@@ -125,6 +132,7 @@ pub(super) async fn diagnostics(
|
||||
.map_err(|code| *code)
|
||||
.and_then(|execution| {
|
||||
scope?;
|
||||
if !pin.projection().tools.is_empty() || pin.projection().services.is_empty() {
|
||||
policy::authorize(
|
||||
&tx,
|
||||
&w,
|
||||
@@ -133,9 +141,16 @@ pub(super) async fn diagnostics(
|
||||
pin.installation_id(),
|
||||
&pin.projection().tools,
|
||||
&PluginHostOperation::Admit,
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(reason)
|
||||
).map_err(reason)?;
|
||||
}
|
||||
for service in &pin.projection().services {
|
||||
policy::authorize_service(
|
||||
&tx, &w, execution, &saved.plan_id,
|
||||
pin.installation_id(), service,
|
||||
&PluginHostOperation::Admit,
|
||||
).map_err(reason)?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
let admission = match observed {
|
||||
Ok(()) => PluginDiagnosticAdmission::Admissible,
|
||||
@@ -165,6 +180,17 @@ pub(super) async fn diagnostics(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let consumers = tx.prepare(
|
||||
"SELECT consumer_id FROM plugin_service_consumers WHERE workspace_id=?1 AND plan_id=?2 AND installation_id=?3 ORDER BY service_name LIMIT 32"
|
||||
)?.query_map(rusqlite::params![w, saved.plan_id, pin.installation_id()], |row| row.get::<_, String>(0))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
let service_consumers = consumers.into_iter().map(|id| {
|
||||
let consumer = crate::plugin_service::load(&tx, &w, &id)?;
|
||||
Ok(PluginServiceConsumerDiagnostics {
|
||||
service_name: consumer.service.clone(),
|
||||
diagnostics: crate::plugin_service::diagnostics(&tx, &consumer, chrono::Utc::now().timestamp_millis())?,
|
||||
})
|
||||
}).collect::<Result<Vec<_>>>()?;
|
||||
plugins.push(PluginWorkerDiagnostic {
|
||||
installation_id: pin.installation_id().into(),
|
||||
package_id: pin.package_id().into(),
|
||||
@@ -180,9 +206,14 @@ pub(super) async fn diagnostics(
|
||||
crate::plugin_plan::PluginSurface::Tool => {
|
||||
PluginDiagnosticSurface::Tool
|
||||
}
|
||||
crate::plugin_plan::PluginSurface::Service => {
|
||||
PluginDiagnosticSurface::Service
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
selected_tools: pin.projection().tools.clone(),
|
||||
selected_services: pin.projection().services.clone(),
|
||||
service_consumers,
|
||||
admission,
|
||||
connections,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,12 @@ use rusqlite::OptionalExtension;
|
||||
use server_api::*;
|
||||
#[path = "server_plugin_data.rs"]
|
||||
mod data;
|
||||
#[path = "server_plugin_service.rs"]
|
||||
mod service;
|
||||
tokio::task_local! { static SERVICE_LEASE: PluginServiceLease; }
|
||||
fn service_lease() -> Option<PluginServiceLease> {
|
||||
SERVICE_LEASE.try_with(Clone::clone).ok()
|
||||
}
|
||||
|
||||
impl WorkspaceApi {
|
||||
/// Host plan creation after the canonical reservation and grant/Job binding,
|
||||
@@ -92,6 +98,29 @@ pub(super) fn router(service: Arc<ServerApiContractService>) -> Router {
|
||||
fn denied() -> PluginHostError {
|
||||
policy::map_error(policy::failure(PluginHostErrorCode::Denied))
|
||||
}
|
||||
fn validate_log_entry(entry: &PluginLogEntry) -> std::result::Result<(), PluginHostError> {
|
||||
if entry.message.len() > 4096
|
||||
|| entry.event_code.is_empty()
|
||||
|| entry.event_code.len() > 128
|
||||
|| !entry
|
||||
.event_code
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
|
||||
|| entry.fields.len() > 32
|
||||
|| entry.fields.iter().any(|field| {
|
||||
field.name.len() > 128
|
||||
|| field.name.is_empty()
|
||||
|| field.name.chars().any(char::is_control)
|
||||
|| matches!(&field.value, PluginLogScalar::Text(value) if value.len() > 1024)
|
||||
|| matches!(&field.value, PluginLogScalar::Number(value) if !value.is_finite())
|
||||
})
|
||||
{
|
||||
return Err(policy::map_error(policy::failure(
|
||||
PluginHostErrorCode::QuotaExceeded,
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn now_ms() -> i64 {
|
||||
chrono::Utc::now().timestamp_millis()
|
||||
}
|
||||
@@ -114,6 +143,25 @@ async fn check(
|
||||
consumer: PluginConsumer,
|
||||
operation: PluginHostOperation,
|
||||
) -> std::result::Result<policy::AdmittedPluginOperation, PluginHostError> {
|
||||
if let PluginConsumer::Consumer {
|
||||
consumer_id,
|
||||
event_id,
|
||||
export_name,
|
||||
} = &consumer
|
||||
{
|
||||
return service::check_consumer(
|
||||
api,
|
||||
context,
|
||||
workspace,
|
||||
plan_id,
|
||||
installation,
|
||||
consumer_id,
|
||||
event_id,
|
||||
export_name,
|
||||
operation,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let tools = match consumer {
|
||||
PluginConsumer::ToolCall {
|
||||
tool_call_id,
|
||||
@@ -213,11 +261,12 @@ async fn invocation(
|
||||
let store = api.config_store.clone();
|
||||
let w = workspace.to_owned();
|
||||
let id = invocation_id.to_owned();
|
||||
let record = store
|
||||
let mut record = store
|
||||
.db_executor()
|
||||
.run(move || store.with_conn(|conn| receipt::lookup(conn, &w, &id)))
|
||||
.await
|
||||
.map_err(policy::map_error)?;
|
||||
record.service_lease = service_lease();
|
||||
let admitted = check(
|
||||
api,
|
||||
context.clone(),
|
||||
@@ -394,6 +443,14 @@ async fn execute_with_transport(
|
||||
// Human management and a Runtime-only (no Worker) source both fail closed.
|
||||
subject_authorization::source_from_context(&context).map_err(|_| denied())?;
|
||||
match request {
|
||||
PluginHostRequest::Service { request } => {
|
||||
if service_lease().is_some() {
|
||||
return Err(denied());
|
||||
}
|
||||
service::execute(api, context, workspace, request, transport_override)
|
||||
.await
|
||||
.map(|response| PluginHostResponse::Service { response })
|
||||
}
|
||||
request @ (PluginHostRequest::ResolveTicket { .. }
|
||||
| PluginHostRequest::ReadTicket { .. }
|
||||
| PluginHostRequest::DataGet { .. }
|
||||
@@ -425,6 +482,9 @@ async fn execute_with_transport(
|
||||
installation_id,
|
||||
consumer,
|
||||
} => {
|
||||
if matches!(consumer, PluginConsumer::Consumer { .. }) {
|
||||
return Err(denied());
|
||||
}
|
||||
let admitted = check(
|
||||
api.clone(),
|
||||
context,
|
||||
@@ -469,6 +529,9 @@ async fn execute_with_transport(
|
||||
})
|
||||
}
|
||||
PluginHostRequest::Cancel { invocation_id } => {
|
||||
if service_lease().is_some() {
|
||||
return Err(denied());
|
||||
}
|
||||
// Do not use invocation(): exact cancellation replay must work after
|
||||
// the irreversible bit has already been committed.
|
||||
let store = api.config_store.clone();
|
||||
@@ -536,24 +599,7 @@ async fn execute_with_transport(
|
||||
.await?;
|
||||
// Guest text/fields are untrusted and may contain echoed credentials.
|
||||
// Standard diagnostics retain only this safe event, not their payload.
|
||||
if entry.message.len() > 4096
|
||||
|| entry.event_code.is_empty()
|
||||
|| entry.event_code.len() > 128
|
||||
|| !entry
|
||||
.event_code
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
|
||||
|| entry.fields.len() > 32 || entry.fields.iter().any(|field| {
|
||||
field.name.len() > 128
|
||||
|| field.name.is_empty()
|
||||
|| field.name.chars().any(char::is_control)
|
||||
|| matches!(&field.value, PluginLogScalar::Text(value) if value.len() > 1024)
|
||||
|| matches!(&field.value, PluginLogScalar::Number(value) if !value.is_finite())
|
||||
}) {
|
||||
return Err(policy::map_error(policy::failure(
|
||||
PluginHostErrorCode::QuotaExceeded,
|
||||
)));
|
||||
}
|
||||
validate_log_entry(&entry)?;
|
||||
tracing::debug!(invocation = %invocation_id, "plugin log event admitted");
|
||||
Ok(PluginHostResponse::Logged)
|
||||
}
|
||||
|
||||
@@ -332,7 +332,17 @@ impl Fixture {
|
||||
}
|
||||
|
||||
fn count(&self, table: &str) -> i64 {
|
||||
assert!(["plugin_invocations", "plugin_effect_receipts", "plugin_data_cells", "plugin_data_receipts", "plugin_data_purges"].contains(&table));
|
||||
assert!(
|
||||
[
|
||||
"plugin_invocations",
|
||||
"plugin_effect_receipts",
|
||||
"plugin_data_cells",
|
||||
"plugin_data_receipts",
|
||||
"plugin_data_purges",
|
||||
"plugin_service_released_proofs",
|
||||
]
|
||||
.contains(&table)
|
||||
);
|
||||
self.api
|
||||
.config_store
|
||||
.with_conn(|conn| {
|
||||
@@ -379,6 +389,7 @@ fn set_policy(api: &WorkspaceApi, subject: &str, installation: &str, granted: bo
|
||||
"plugin.connection.open",
|
||||
"plugin.http.send",
|
||||
"plugin.log",
|
||||
"plugin.service.consume",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|operation| ScopedPermission {
|
||||
|
||||
@@ -311,6 +311,8 @@ pub(super) mod plugin_runtime_tests {
|
||||
.plugins
|
||||
.selections
|
||||
.push(manifest::PluginProfileSelection {
|
||||
services: vec![],
|
||||
ticket_events: false,
|
||||
ticket_read: false,
|
||||
ticket_data_read: vec![],
|
||||
ticket_data_write: vec![],
|
||||
@@ -837,6 +839,8 @@ pub(super) mod plugin_runtime_tests {
|
||||
.plugins
|
||||
.selections
|
||||
.push(manifest::PluginProfileSelection {
|
||||
services: vec![],
|
||||
ticket_events: false,
|
||||
ticket_read: false,
|
||||
ticket_data_read: vec![],
|
||||
ticket_data_write: vec![],
|
||||
@@ -1188,4 +1192,5 @@ pub(super) mod plugin_runtime_tests {
|
||||
async fn pending_tool_call_restores_its_committed_host_identity_before_first_effect() {
|
||||
https_through_runtime_and_history(false, true).await;
|
||||
}
|
||||
include!("server_plugin_service_runtime_tests.rs");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
//! Authenticated Remote and Embedded Service operations share this owner.
|
||||
use super::*;
|
||||
use crate::plugin_service as store;
|
||||
use server_api::authorization::{OperationAuthorizationRequest, PermissionResource};
|
||||
|
||||
async fn check_service(
|
||||
api: Arc<WorkspaceApi>,
|
||||
context: ServerRequestContext,
|
||||
workspace: String,
|
||||
plan_id: String,
|
||||
installation: String,
|
||||
name: String,
|
||||
operation: PluginHostOperation,
|
||||
preparing: bool,
|
||||
) -> std::result::Result<policy::AdmittedPluginOperation, PluginHostError> {
|
||||
validate_workspace_scope(&api, &workspace).map_err(|e| policy::map_error(e.error))?;
|
||||
let worker = subject_authorization::source_from_context(&context).map_err(|_| denied())?;
|
||||
let execution = if preparing
|
||||
&& api
|
||||
.store
|
||||
.has_active_worker_create_reservation(api.workspace_id(), &worker)
|
||||
.map_err(policy::map_error)?
|
||||
{
|
||||
api.plugin_reserved_identity(&worker, None, None)
|
||||
.map_err(policy::map_error)?
|
||||
} else {
|
||||
api.plugin_execution_identity(&worker)
|
||||
.await
|
||||
.map_err(policy::map_error)?
|
||||
};
|
||||
let executor = api.config_store.db_executor();
|
||||
executor
|
||||
.run(move || {
|
||||
for op in ["plugin.service.consume", operation_name(&operation)] {
|
||||
subject_authorization::authorize_scope(
|
||||
&api,
|
||||
&worker,
|
||||
&OperationAuthorizationRequest {
|
||||
operation: op.into(),
|
||||
resource: PermissionResource::PluginInstallation {
|
||||
workspace_id: workspace.clone(),
|
||||
installation_id: installation.clone(),
|
||||
},
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.error)?;
|
||||
}
|
||||
api.config_store.with_immediate_transaction(|tx| {
|
||||
policy::authorize_service(
|
||||
tx,
|
||||
&workspace,
|
||||
&execution,
|
||||
&plan_id,
|
||||
&installation,
|
||||
&name,
|
||||
&operation,
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(policy::map_error)
|
||||
}
|
||||
|
||||
pub(super) async fn check_consumer(
|
||||
api: Arc<WorkspaceApi>,
|
||||
context: ServerRequestContext,
|
||||
workspace: String,
|
||||
plan: String,
|
||||
installation: String,
|
||||
consumer_id: &str,
|
||||
event_id: &str,
|
||||
name: &str,
|
||||
operation: PluginHostOperation,
|
||||
) -> std::result::Result<policy::AdmittedPluginOperation, PluginHostError> {
|
||||
let lease = service_lease().ok_or_else(denied)?;
|
||||
if lease.consumer_id != consumer_id {
|
||||
return Err(denied());
|
||||
}
|
||||
let admitted = check_service(
|
||||
api.clone(),
|
||||
context,
|
||||
workspace.clone(),
|
||||
plan.clone(),
|
||||
installation.clone(),
|
||||
name.into(),
|
||||
operation,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let config = api.config_store.clone();
|
||||
let name = name.to_owned();
|
||||
let event = event_id.to_owned();
|
||||
let execution = admitted.plan.plan.execution().clone();
|
||||
config
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
config.with_conn(|conn| {
|
||||
let c = store::fence(conn, &workspace, &lease, now_ms())?;
|
||||
if c.plan_id != plan
|
||||
|| c.installation != installation
|
||||
|| c.service != name
|
||||
|| c.current_event_id.as_deref() != Some(&event)
|
||||
|| c.runtime_id != execution.runtime_id
|
||||
|| c.worker_id != execution.worker_id
|
||||
|| c.pause.is_some()
|
||||
{
|
||||
return Err(policy::failure(PluginHostErrorCode::Denied));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(policy::map_error)?;
|
||||
Ok(admitted)
|
||||
}
|
||||
|
||||
async fn checked_consumer(
|
||||
api: Arc<WorkspaceApi>,
|
||||
context: ServerRequestContext,
|
||||
workspace: String,
|
||||
id: String,
|
||||
lease: Option<PluginServiceLease>,
|
||||
) -> std::result::Result<(store::Consumer, policy::AdmittedPluginOperation), PluginHostError> {
|
||||
let config = api.config_store.clone();
|
||||
let w = workspace.clone();
|
||||
let proof = lease.clone();
|
||||
let c = config
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
config.with_conn(|conn| match proof {
|
||||
Some(proof) => store::fence(conn, &w, &proof, now_ms()),
|
||||
None => store::load(conn, &w, &id),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(policy::map_error)?;
|
||||
let worker = subject_authorization::source_from_context(&context).map_err(|_| denied())?;
|
||||
let checked = check_service(
|
||||
api.clone(),
|
||||
context,
|
||||
workspace,
|
||||
c.plan_id.clone(),
|
||||
c.installation.clone(),
|
||||
c.service.clone(),
|
||||
PluginHostOperation::Admit,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
let admitted = match checked {
|
||||
Ok(admitted) => admitted,
|
||||
Err(error) => {
|
||||
if c.runtime_id == worker.runtime_id && c.worker_id == worker.worker_id {
|
||||
if let Some(proof) = lease {
|
||||
let config = api.config_store.clone();
|
||||
// Do not let an old driver's delayed authorization failure
|
||||
// pause a newly leased consumer (or mutate diagnostics reads).
|
||||
let _ = config
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
config.with_immediate_transaction(|tx| {
|
||||
let current = store::fence(tx, &c.workspace, &proof, now_ms())?;
|
||||
store::pause(tx, ¤t, PluginServicePauseReason::Authorization)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let execution = admitted.plan.plan.execution();
|
||||
if c.runtime_id != execution.runtime_id || c.worker_id != execution.worker_id {
|
||||
return Err(denied());
|
||||
}
|
||||
Ok((c, admitted))
|
||||
}
|
||||
|
||||
pub(super) async fn execute(
|
||||
api: Arc<WorkspaceApi>,
|
||||
context: ServerRequestContext,
|
||||
workspace: String,
|
||||
request: PluginServiceRequest,
|
||||
transport_override: Option<Arc<https::HttpsTransport>>,
|
||||
) -> std::result::Result<PluginServiceResponse, PluginHostError> {
|
||||
match request {
|
||||
PluginServiceRequest::Prepare {
|
||||
plan_id,
|
||||
installation_id,
|
||||
service_name,
|
||||
operation,
|
||||
} => {
|
||||
let operation = match operation {
|
||||
PluginServiceLifecycleOperation::Admit => PluginHostOperation::Admit,
|
||||
PluginServiceLifecycleOperation::Publish => PluginHostOperation::Publish,
|
||||
};
|
||||
check_service(
|
||||
api,
|
||||
context,
|
||||
workspace,
|
||||
plan_id,
|
||||
installation_id,
|
||||
service_name,
|
||||
operation,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
Ok(PluginServiceResponse::Prepared)
|
||||
}
|
||||
PluginServiceRequest::LifecycleLog {
|
||||
plan_id,
|
||||
installation_id,
|
||||
service_name,
|
||||
entry,
|
||||
} => {
|
||||
check_service(
|
||||
api,
|
||||
context,
|
||||
workspace,
|
||||
plan_id.clone(),
|
||||
installation_id,
|
||||
service_name,
|
||||
PluginHostOperation::Log,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
validate_log_entry(&entry)?;
|
||||
// Do not retain untrusted guest text/fields, including echoed secrets.
|
||||
tracing::debug!(plan = %plan_id, "plugin service lifecycle log admitted");
|
||||
Ok(PluginServiceResponse::Logged)
|
||||
}
|
||||
PluginServiceRequest::Acquire {
|
||||
plan_id,
|
||||
installation_id,
|
||||
service_name,
|
||||
} => {
|
||||
let admitted = check_service(
|
||||
api.clone(),
|
||||
context,
|
||||
workspace,
|
||||
plan_id,
|
||||
installation_id.clone(),
|
||||
service_name.clone(),
|
||||
PluginHostOperation::Admit,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
let declarations = admitted
|
||||
.services
|
||||
.iter()
|
||||
.find(|s| s.name == service_name)
|
||||
.ok_or_else(denied)?;
|
||||
let subscriptions = declarations
|
||||
.subscriptions
|
||||
.iter()
|
||||
.map(|s| match s.as_str() {
|
||||
"ticket_changed" => Ok(PluginTicketEventType::Changed),
|
||||
"ticket_deleted" => Ok(PluginTicketEventType::Deleted),
|
||||
_ => Err(denied()),
|
||||
})
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
let config = api.config_store.clone();
|
||||
let lease = config
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
config.with_immediate_transaction(|tx| {
|
||||
store::acquire(
|
||||
tx,
|
||||
&admitted.plan,
|
||||
&installation_id,
|
||||
&service_name,
|
||||
&subscriptions,
|
||||
now_ms(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(policy::map_error)?;
|
||||
Ok(PluginServiceResponse::Acquired { lease })
|
||||
}
|
||||
PluginServiceRequest::Poll { lease } => {
|
||||
let (_, admitted) = checked_consumer(
|
||||
api.clone(),
|
||||
context.clone(),
|
||||
workspace.clone(),
|
||||
lease.consumer_id.clone(),
|
||||
Some(lease.clone()),
|
||||
)
|
||||
.await?;
|
||||
let config = api.config_store.clone();
|
||||
let w = workspace.clone();
|
||||
let proof = lease.clone();
|
||||
let response = config
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
config.with_immediate_transaction(|tx| {
|
||||
store::poll(tx, &w, &proof, &admitted.plan, now_ms())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(policy::map_error)?;
|
||||
let PluginServiceResponse::Delivery { envelope, .. } = &response else {
|
||||
return Ok(response);
|
||||
};
|
||||
// Subject authorization re-enters the config store for live Worker
|
||||
// lineage. Never call it while holding the consumer write transaction.
|
||||
// The envelope remains Host-local until authorization and a fresh
|
||||
// lease/event fence both succeed; no guest sees an unchecked delivery.
|
||||
let config = api.config_store.clone();
|
||||
let ticket = envelope.ticket_id.clone();
|
||||
let event = envelope.event_id.clone();
|
||||
let kind = envelope.event_type;
|
||||
config
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
let worker = subject_authorization::source_from_context(&context)
|
||||
.map_err(|e| e.error)?;
|
||||
let allowed = subject_authorization::authorize(
|
||||
&api,
|
||||
&worker,
|
||||
&OperationAuthorizationRequest {
|
||||
operation: "ticket.read".into(),
|
||||
resource: PermissionResource::Ticket {
|
||||
workspace_id: workspace.clone(),
|
||||
ticket_id: ticket.clone(),
|
||||
},
|
||||
},
|
||||
);
|
||||
config.with_immediate_transaction(|tx| {
|
||||
let c = store::fence(tx, &workspace, &lease, now_ms())?;
|
||||
if c.current_event_id.as_deref() != Some(event.as_str()) {
|
||||
return Err(policy::failure(PluginHostErrorCode::Denied));
|
||||
}
|
||||
// Deleted references are metadata only: this does not
|
||||
// resolve or recreate a Ticket capability.
|
||||
let exists: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2)",
|
||||
rusqlite::params![workspace, ticket],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if allowed.is_err() || (kind == PluginTicketEventType::Changed && !exists) {
|
||||
store::pause(tx, &c, PluginServicePauseReason::Authorization)?;
|
||||
return Ok(PluginServiceResponse::Diagnostics {
|
||||
diagnostics: store::diagnostics(
|
||||
tx,
|
||||
&store::load(tx, &workspace, &lease.consumer_id)?,
|
||||
now_ms(),
|
||||
)?,
|
||||
});
|
||||
}
|
||||
Ok(response)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(policy::map_error)
|
||||
}
|
||||
PluginServiceRequest::Complete {
|
||||
lease,
|
||||
event_id,
|
||||
result,
|
||||
} => {
|
||||
checked_consumer(
|
||||
api.clone(),
|
||||
context,
|
||||
workspace.clone(),
|
||||
lease.consumer_id.clone(),
|
||||
Some(lease.clone()),
|
||||
)
|
||||
.await?;
|
||||
let config = api.config_store.clone();
|
||||
config
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
config.with_immediate_transaction(|tx| {
|
||||
store::complete(tx, &workspace, &lease, &event_id, &result, now_ms())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(policy::map_error)?;
|
||||
Ok(PluginServiceResponse::Completed)
|
||||
}
|
||||
PluginServiceRequest::Release { lease } => {
|
||||
// Authenticate the same Worker without an active lease fence or
|
||||
// revoked plugin grants: cleanup and lost-response reconciliation
|
||||
// must remain possible after expiry, release, or lease rotation.
|
||||
validate_workspace_scope(&api, &workspace).map_err(|e| policy::map_error(e.error))?;
|
||||
let worker =
|
||||
subject_authorization::source_from_context(&context).map_err(|_| denied())?;
|
||||
let config = api.config_store.clone();
|
||||
config
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
config.with_immediate_transaction(|tx| {
|
||||
let c = store::load(tx, &workspace, &lease.consumer_id)?;
|
||||
if c.runtime_id != worker.runtime_id || c.worker_id != worker.worker_id {
|
||||
return Err(policy::failure(PluginHostErrorCode::Denied));
|
||||
}
|
||||
store::release(tx, &workspace, &lease, now_ms())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(policy::map_error)?;
|
||||
Ok(PluginServiceResponse::Released)
|
||||
}
|
||||
PluginServiceRequest::Diagnostics { consumer_id } => {
|
||||
let (c, _) =
|
||||
checked_consumer(api.clone(), context, workspace, consumer_id, None).await?;
|
||||
let config = api.config_store.clone();
|
||||
let diagnostics = config
|
||||
.db_executor()
|
||||
.run(move || config.with_conn(|conn| store::diagnostics(conn, &c, now_ms())))
|
||||
.await
|
||||
.map_err(policy::map_error)?;
|
||||
Ok(PluginServiceResponse::Diagnostics { diagnostics })
|
||||
}
|
||||
PluginServiceRequest::Host { lease, request } => {
|
||||
// The finite invocation DTO cannot represent Service/control nesting.
|
||||
checked_consumer(
|
||||
api.clone(),
|
||||
context.clone(),
|
||||
workspace.clone(),
|
||||
lease.consumer_id.clone(),
|
||||
Some(lease.clone()),
|
||||
)
|
||||
.await?;
|
||||
let response = SERVICE_LEASE
|
||||
.scope(
|
||||
lease,
|
||||
Box::pin(execute_with_transport(
|
||||
api,
|
||||
context,
|
||||
workspace,
|
||||
(*request).into(),
|
||||
transport_override,
|
||||
)),
|
||||
)
|
||||
.await?;
|
||||
Ok(PluginServiceResponse::Host {
|
||||
response: Box::new(response.try_into().map_err(|_| denied())?),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn management_failure(error: Error) -> RepositoryApiError {
|
||||
let error = policy::map_error(error);
|
||||
let (status, code) = match error.code {
|
||||
PluginHostErrorCode::InvalidInput | PluginHostErrorCode::Unsupported => {
|
||||
(400, "plugin_service_invalid")
|
||||
}
|
||||
PluginHostErrorCode::Denied => (403, "plugin_service_denied"),
|
||||
PluginHostErrorCode::NotFound => (404, "plugin_service_not_found"),
|
||||
PluginHostErrorCode::Conflict | PluginHostErrorCode::ReceiptExpired => {
|
||||
(409, "plugin_service_conflict")
|
||||
}
|
||||
// A DB/transport failure is not proof that a recovery mutation failed.
|
||||
_ => (503, "plugin_service_outcome_unknown"),
|
||||
};
|
||||
RepositoryApiError::new(status, code, error.message(), vec![])
|
||||
}
|
||||
|
||||
impl WorkspaceApi {
|
||||
/// Management observations never run a guest or require a live Runtime.
|
||||
pub async fn plugin_service_diagnostics(
|
||||
&self,
|
||||
workspace: String,
|
||||
actor: RequestActor,
|
||||
consumer_id: String,
|
||||
) -> std::result::Result<PluginServiceDiagnostics, RepositoryApiError> {
|
||||
super::super::plugin::authorize(self, &workspace, &actor, false).await?;
|
||||
let config = self.config_store.clone();
|
||||
config
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
config.with_conn(|conn| {
|
||||
let consumer = store::load(conn, &workspace, &consumer_id)?;
|
||||
store::diagnostics(conn, &consumer, now_ms())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(management_failure)
|
||||
}
|
||||
|
||||
/// Management adapter for the parent contract dispatcher. This is not a guest
|
||||
/// Host import; the ordinary owner-account verifier precedes the DB owner.
|
||||
pub async fn recover_plugin_service(
|
||||
&self,
|
||||
workspace: String,
|
||||
actor: RequestActor,
|
||||
request: PluginServiceRecoveryRequest,
|
||||
) -> std::result::Result<PluginServiceDiagnostics, RepositoryApiError> {
|
||||
super::super::plugin::authorize(self, &workspace, &actor, true).await?;
|
||||
let actor = serde_json::to_string(&(&actor.account_id, &actor.user_id))
|
||||
.map_err(|_| management_failure(policy::failure(PluginHostErrorCode::InvalidInput)))?;
|
||||
let config = self.config_store.clone();
|
||||
config
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
config.with_immediate_transaction(|tx| {
|
||||
store::recover(
|
||||
tx,
|
||||
&workspace,
|
||||
&request.consumer_id,
|
||||
request.expected_pause,
|
||||
&request.action,
|
||||
&request.operation_key,
|
||||
&actor,
|
||||
&request.reason,
|
||||
now_ms(),
|
||||
)?;
|
||||
store::diagnostics(
|
||||
tx,
|
||||
&store::load(tx, &workspace, &request.consumer_id)?,
|
||||
now_ms(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(management_failure)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -129,7 +129,7 @@ pub use ticket::{
|
||||
};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 111;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 112;
|
||||
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
|
||||
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
@@ -492,6 +492,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "Typed Plugin Ticket extension data and atomic mutation receipts",
|
||||
apply: migrate_plugin_data_v110_to_v111,
|
||||
},
|
||||
Migration {
|
||||
version: 112,
|
||||
name: "Ticket Service outbox and fenced consumers",
|
||||
apply: migrate_plugin_services_v111_to_v112,
|
||||
},
|
||||
];
|
||||
|
||||
// Both schema-103 identities shipped. Keep the alternate implementation and accept
|
||||
@@ -15927,6 +15932,41 @@ fn verify_plugin_data_schema(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_plugin_services_v111_to_v112(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 111 {
|
||||
return Err(Error::Store("Service migration requires schema 111".into()));
|
||||
}
|
||||
verify_schema_history(conn, 111)?;
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
|
||||
.map_err(|e| Error::Store(format!("Ticket outbox migration failed: {e}")))?;
|
||||
tx.execute_batch(crate::plugin_service::SCHEMA_SQL)?;
|
||||
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(112,'Ticket Service outbox and fenced consumers')",[])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_plugin_service_schema(conn: &Connection) -> Result<()> {
|
||||
let expected = Connection::open_in_memory()?;
|
||||
expected.execute_batch(crate::plugin_service::SCHEMA_SQL)?;
|
||||
let mut stmt = expected
|
||||
.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name")?;
|
||||
for entry in stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))? {
|
||||
let (name, sql) = entry?;
|
||||
let actual: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name=?1",
|
||||
[&name],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if actual.as_deref() != Some(&sql) {
|
||||
return Err(Error::Store("Plugin Service schema has drifted".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_plugin_data_v110_to_v111(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 110 {
|
||||
return Err(Error::Store(
|
||||
@@ -16025,6 +16065,7 @@ fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(crate::plugin_plan::LAUNCH_SCHEMA_SQL)?;
|
||||
conn.execute_batch(crate::plugin_data::REFERENCE_MIGRATION_SQL)?;
|
||||
conn.execute_batch(crate::plugin_data::SCHEMA_SQL)?;
|
||||
conn.execute_batch(crate::plugin_service::SCHEMA_SQL)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -17067,6 +17108,7 @@ fn apply_migrations_with_secret_source(
|
||||
verify_schema_history(conn, LATEST_SCHEMA_VERSION)?;
|
||||
crate::plugin_catalog::verify_with_data(conn)?;
|
||||
verify_plugin_data_schema(conn)?;
|
||||
verify_plugin_service_schema(conn)?;
|
||||
crate::plugin_plan::verify_plan_schema(conn)?;
|
||||
crate::plugin_host::verify_receipt_schema(conn)?;
|
||||
crate::plugin_plan::verify_launch_schema(conn)?;
|
||||
@@ -19356,6 +19398,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
include!("migration_entrypoint_tests.rs");
|
||||
include!("plugin_host_migration_tests.rs");
|
||||
include!("plugin_data_migration_tests.rs");
|
||||
include!("plugin_service_migration_tests.rs");
|
||||
include!("legacy_evidence_column_tests.rs");
|
||||
|
||||
#[test]
|
||||
@@ -19774,6 +19817,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
WorkspaceSchemaMigrationStep {version:109, name:"Durable Plugin Host invocations and HTTPS effect receipts".into()},
|
||||
WorkspaceSchemaMigrationStep {version:110, name:"Worker Plugin launch selection associations".into()},
|
||||
WorkspaceSchemaMigrationStep {version:111, name:"Typed Plugin Ticket extension data and atomic mutation receipts".into()},
|
||||
WorkspaceSchemaMigrationStep {version:112, name:"Ticket Service outbox and fenced consumers".into()},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -19895,6 +19939,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(109, "Durable Plugin Host invocations and HTTPS effect receipts".to_string()),
|
||||
(110, "Worker Plugin launch selection associations".to_string()),
|
||||
(111, "Typed Plugin Ticket extension data and atomic mutation receipts".to_string()),
|
||||
(112, "Ticket Service outbox and fenced consumers".to_string()),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -23187,7 +23232,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(108, "Subject Ticket responsibility and current-body notification routing".into()),
|
||||
(109, "Durable Plugin Host invocations and HTTPS effect receipts".into()),
|
||||
(110, "Worker Plugin launch selection associations".into()),
|
||||
(111, "Typed Plugin Ticket extension data and atomic mutation receipts".into())]);
|
||||
(111, "Typed Plugin Ticket extension data and atomic mutation receipts".into()),
|
||||
(112, "Ticket Service outbox and fenced consumers".into())]);
|
||||
for (table, rows) in unchanged.iter().zip(before.iter()) {
|
||||
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
|
||||
}
|
||||
|
||||
@@ -32,7 +32,11 @@ pub(super) fn install(conn: &Connection) -> Result<()> {
|
||||
"Subject assignment migration requires a caller-owned transaction".into(),
|
||||
));
|
||||
}
|
||||
ticket::migrate_sqlite_ticket_schema_in_transaction(conn).map_err(|error| {
|
||||
ticket::migrate_historical_sqlite_ticket_schema_in_transaction(
|
||||
conn,
|
||||
ticket::HistoricalTicketSchemaTarget::Subjects14,
|
||||
)
|
||||
.map_err(|error| {
|
||||
Error::Store(format!(
|
||||
"Ticket Subject component composition failed: {error}"
|
||||
))
|
||||
@@ -58,7 +62,11 @@ pub(super) fn install(conn: &Connection) -> Result<()> {
|
||||
/// Ticket. Workspace verifies only its two complete trigger definitions/targets,
|
||||
/// followed by host database integrity and FK validation. No repair is performed.
|
||||
pub(super) fn verify(conn: &Connection) -> Result<()> {
|
||||
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
|
||||
ticket::verify_historical_sqlite_ticket_schema(
|
||||
conn,
|
||||
ticket::HistoricalTicketSchemaTarget::Subjects14,
|
||||
)
|
||||
.map_err(|error| {
|
||||
Error::Store(format!(
|
||||
"Ticket Subject schema verification failed: {error}"
|
||||
))
|
||||
|
||||
@@ -770,11 +770,11 @@ mod subject_assignment_migration_tests {
|
||||
let old_components = component_history(&conn);
|
||||
{
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx).unwrap();
|
||||
ticket::migrate_historical_sqlite_ticket_schema_in_transaction(&tx, ticket::HistoricalTicketSchemaTarget::Subjects14).unwrap();
|
||||
tx.commit().unwrap();
|
||||
}
|
||||
assert_component14_append(&conn, &old_components);
|
||||
ticket::verify_sqlite_ticket_schema(&conn).unwrap();
|
||||
ticket::verify_historical_sqlite_ticket_schema(&conn, ticket::HistoricalTicketSchemaTarget::Subjects14).unwrap();
|
||||
// The host must not append the owning diagnostic again or issue even a
|
||||
// redundant diagnostic UPDATE for any already-diagnosed legacy row.
|
||||
conn.execute_batch(
|
||||
@@ -1070,7 +1070,14 @@ mod subject_assignment_migration_tests {
|
||||
expected
|
||||
);
|
||||
assert_eq!(history.len(), host.len() + expected.len());
|
||||
assert_component14_append(conn, component);
|
||||
let history = component_history(conn);
|
||||
assert_eq!(&history[..component.len()], component, "owner receipt prefix is preserved");
|
||||
assert_eq!(history.len(), component.len() + 2);
|
||||
let tail: Vec<(i64, String, String)> = conn.prepare("SELECT version,name,applied_at FROM ticket_schema_migrations WHERE version>=14 ORDER BY version").unwrap()
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))).unwrap().collect::<rusqlite::Result<_>>().unwrap();
|
||||
assert_eq!(tail.iter().map(|(v,name,_)| (*v,name.as_str())).collect::<Vec<_>>(), [(14,"Subject Ticket responsibility"), (15,"Ticket Service transactional outbox")]);
|
||||
assert!(tail.iter().all(|(_,_,at)| !at.is_empty()));
|
||||
ticket::verify_sqlite_ticket_schema(conn).unwrap();
|
||||
crate::plugin_catalog::verify_with_data(conn).unwrap();
|
||||
plugin_plan::verify_plan_schema(conn).unwrap();
|
||||
verify(conn).unwrap();
|
||||
@@ -1134,6 +1141,7 @@ mod subject_assignment_migration_tests {
|
||||
111,
|
||||
"Typed Plugin Ticket extension data and atomic mutation receipts".into(),
|
||||
));
|
||||
tail.push((112, "Ticket Service outbox and fenced consumers".into()));
|
||||
tail
|
||||
}
|
||||
|
||||
@@ -1183,7 +1191,7 @@ mod subject_assignment_migration_tests {
|
||||
fn plugin_schema(conn: &Connection) -> Rows {
|
||||
fixture_query_rows(
|
||||
conn,
|
||||
"SELECT type,name,tbl_name,rootpage,sql FROM sqlite_schema WHERE tbl_name LIKE 'plugin_%' AND tbl_name NOT LIKE 'plugin_data_%' AND tbl_name NOT IN ('plugin_invocations','plugin_effect_receipts','plugin_references') ORDER BY type,name",
|
||||
"SELECT type,name,tbl_name,rootpage,sql FROM sqlite_schema WHERE tbl_name LIKE 'plugin_%' AND tbl_name NOT LIKE 'plugin_data_%' AND tbl_name NOT LIKE 'plugin_service_%' AND tbl_name NOT IN ('plugin_invocations','plugin_effect_receipts','plugin_references') ORDER BY type,name",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,15 @@ fn ticket_posts_schema93_preserves_initial_body_creator_comments_and_archived_au
|
||||
assert_eq!(stored_fixture_rows(conn,"typed_ticket_event_references"),old_references);
|
||||
let markers = stored_fixture_rows(conn,"ticket_schema_migrations");
|
||||
assert_eq!(&markers[..old_markers.len()],old_markers.as_slice());
|
||||
assert_eq!(markers.len(),old_markers.len()+3);
|
||||
assert_eq!(markers.len(),old_markers.len()+4);
|
||||
let new_markers: Vec<(i64,String)> = conn.prepare("SELECT version,name FROM ticket_schema_migrations WHERE version>11 ORDER BY version")?.query_map([],|r|Ok((r.get(0)?,r.get(1)?)))?.collect::<rusqlite::Result<_>>()?;
|
||||
assert_eq!(new_markers,vec![
|
||||
(12,"Canonical Ticket posts".into()),
|
||||
(13,"Ticket content change facts".into()),
|
||||
(14,"Subject Ticket responsibility".into()),
|
||||
(15,"Ticket Service transactional outbox".into()),
|
||||
]);
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM typed_ticket_service_outbox",[],|r|r.get::<_,i64>(0))?,0,"migration does not invent Service delivery facts");
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM typed_ticket_content_changes",[],|r|r.get::<_,i64>(0))?,0,"migration does not invent content change facts");
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_content_inputs",[],|r|r.get::<_,i64>(0))?,0,"migration does not turn historical edits into new inputs");
|
||||
let initial: (String,Option<String>,Option<String>,String,String) = conn.query_row("SELECT e.body,t.created_by,e.author,e.kind,e.event_ref FROM typed_tickets t JOIN typed_ticket_events e USING(workspace_id,ticket_id) WHERE t.ticket_id='authored' AND e.event_index=-1",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?,row.get(4)?)))?;
|
||||
@@ -140,6 +148,19 @@ fn ticket_posts_schema93_preserves_initial_body_creator_comments_and_archived_au
|
||||
)?,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT name FROM ticket_schema_migrations WHERE version=15",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)?,
|
||||
"Ticket Service transactional outbox"
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT COUNT(*) FROM typed_ticket_service_outbox", [], |r| r
|
||||
.get::<_, i64>(0))?,
|
||||
0
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -266,7 +266,7 @@ fn assert_retained_classification_upgrade(workspace_version: i64, component: i64
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?,LATEST_SCHEMA_VERSION);
|
||||
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,14);
|
||||
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,15);
|
||||
assert_eq!(fixture_auth_rows(conn),auth_before);
|
||||
for (table, rows) in preserved_tables.iter().zip(&preserved) {
|
||||
let mut migrated = stored_fixture_rows(conn, table);
|
||||
@@ -500,7 +500,7 @@ fn workspace90_cases9_add_classification_without_reinterpreting_reasoned_history
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)?,
|
||||
14
|
||||
15
|
||||
);
|
||||
assert_eq!(
|
||||
stored_fixture_rows(conn, "typed_ticket_case_history"),
|
||||
|
||||
@@ -16,6 +16,13 @@ use crate::{Error, Result};
|
||||
/// Explicit domain-owned purge inventory. The deletion operation tombstone is intentionally
|
||||
/// excluded so retries and audit remain available after the Workspace row is gone.
|
||||
const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"plugin_service_recovery_audit",
|
||||
// Consumer deletion cascades its released proofs. Their retention guard stays
|
||||
// installed: deleting proofs directly while the consumer exists is forbidden.
|
||||
"plugin_service_consumers",
|
||||
"plugin_service_released_proofs",
|
||||
"plugin_service_retention",
|
||||
"typed_ticket_service_outbox",
|
||||
"plugin_data_receipts",
|
||||
"plugin_data_purges",
|
||||
"plugin_data_holders",
|
||||
@@ -507,16 +514,17 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
|
||||
}
|
||||
let workspace_id = operation.response.workspace_id.clone();
|
||||
|
||||
// Ticket case/assignment judgments and their operation receipts are
|
||||
// immutable under ordinary domain operations, including Ticket
|
||||
// deletion and FK cascades. Only this owner-authorized whole-Workspace
|
||||
// purge may discard them.
|
||||
// Service recovery audit, Ticket case/assignment judgments and their
|
||||
// operation receipts are immutable under ordinary domain operations,
|
||||
// including Ticket deletion and FK cascades. Only this owner-authorized
|
||||
// whole-Workspace purge may discard them.
|
||||
// SQLite transactional DDL holds the writer lock: no concurrent writer
|
||||
// can observe the suspended guards, and any error rolls back both
|
||||
// data and schema. Restore the exact schema-owned definitions before
|
||||
// commit; update guards remain installed throughout.
|
||||
let mut ticket_delete_guards = Vec::new();
|
||||
for (table, trigger) in [
|
||||
("plugin_service_recovery_audit", "plugin_service_audit_retained"),
|
||||
("plugin_data_receipts", "plugin_data_receipt_retained"),
|
||||
("plugin_data_purges", "plugin_data_purge_retained"),
|
||||
("plugin_effect_receipts", "plugin_effect_retained"),
|
||||
@@ -1305,6 +1313,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn workspace_purge_removes_plugin_references_and_catalog_without_cross_workspace_loss() {
|
||||
let position = |table| {
|
||||
WORKSPACE_DELETION_PURGE_TABLES
|
||||
.iter()
|
||||
.position(|name| *name == table)
|
||||
.unwrap()
|
||||
};
|
||||
assert!(position("plugin_service_recovery_audit") < position("plugin_service_consumers"));
|
||||
assert!(position("plugin_service_consumers") < position("plugin_service_released_proofs"));
|
||||
assert!(position("plugin_service_consumers") < position("plugin_execution_plans"));
|
||||
assert!(position("plugin_service_consumers") < position("plugin_installations"));
|
||||
let (store, owner, workspace_id) = setup();
|
||||
for workspace in ["workspace-a", "workspace-b"] {
|
||||
store.with_conn(|conn| {
|
||||
@@ -1319,6 +1337,11 @@ mod tests {
|
||||
conn.execute("INSERT INTO plugin_execution_plans VALUES(?1,'plan','actor','create',?2,'{}','active')", params![workspace,digest])?;
|
||||
conn.execute("INSERT INTO plugin_invocations(workspace_id,invocation_id,plan_id,plan_fingerprint,installation_id,runtime_id,worker_id,execution_json,consumer_key,consumer_json,created_at_ms,expires_at_ms) VALUES(?1,'invocation','plan',?2,'installation','runtime','worker','{}','[\"tool_call\",\"call\"]','{}',1,1000)", params![workspace,digest])?;
|
||||
conn.execute("INSERT INTO plugin_effect_receipts(workspace_id,invocation_id,effect_key,fingerprint,admitted_at_ms,state,error_json) VALUES(?1,'invocation','key',?2,2,'pending',?3)", params![workspace,digest,r#"{"code":"indeterminate","effect":"indeterminate","diagnostic_id":"safe"}"#])?;
|
||||
conn.execute("INSERT INTO plugin_service_consumers(workspace_id,consumer_id,runtime_id,worker_id,installation_id,plan_id,service_name,subscriptions_json,cursor) VALUES(?1,'consumer','runtime','worker','installation','plan','service','[\"changed\",\"deleted\"]',1)", [workspace])?;
|
||||
conn.execute("INSERT INTO plugin_service_released_proofs(workspace_id,consumer_id,lease_token,released_at_ms) VALUES(?1,'consumer','released-token',2)", [workspace])?;
|
||||
conn.execute("INSERT INTO plugin_service_recovery_audit(workspace_id,operation_key,consumer_id,fingerprint,actor,reason,action_json,at_ms) VALUES(?1,'recovery','consumer','fingerprint','owner','Explicit recovery','{}',3)", [workspace])?;
|
||||
conn.execute("INSERT INTO plugin_service_retention(workspace_id,removed_through) VALUES(?1,1)", [workspace])?;
|
||||
conn.execute("INSERT INTO typed_ticket_service_outbox(event_id,workspace_id,ticket_id,event_type,occurred_at,domain_event_ref,content_digest,correlation_id,causation_id) VALUES(?1,?2,'deleted-ticket','deleted','2026-01-01T00:00:00Z','deleted-ticket:0',?3,'correlation','causation')", params![if workspace == "workspace-a" { "a".repeat(32) } else { "b".repeat(32) },workspace,"c".repeat(64)])?;
|
||||
for table in ["plugin_effect_receipts", "plugin_invocations", "plugin_execution_plans"] {
|
||||
assert!(conn.execute(&format!("DELETE FROM {table} WHERE workspace_id=?1"), [workspace]).is_err());
|
||||
}
|
||||
@@ -1329,6 +1352,11 @@ mod tests {
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
[
|
||||
"plugin_service_recovery_audit",
|
||||
"plugin_service_consumers",
|
||||
"plugin_service_released_proofs",
|
||||
"plugin_service_retention",
|
||||
"typed_ticket_service_outbox",
|
||||
"plugin_effect_receipts",
|
||||
"plugin_invocations",
|
||||
"plugin_execution_plans",
|
||||
@@ -1350,8 +1378,93 @@ mod tests {
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let service_rows = |workspace: &str| {
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
[
|
||||
"plugin_service_recovery_audit",
|
||||
"plugin_service_consumers",
|
||||
"plugin_service_released_proofs",
|
||||
"plugin_service_retention",
|
||||
"typed_ticket_service_outbox",
|
||||
]
|
||||
.map(|table| {
|
||||
let mut statement = conn.prepare(&format!(
|
||||
"SELECT * FROM {table} WHERE workspace_id=?1 ORDER BY 1"
|
||||
))?;
|
||||
let columns = statement.column_count();
|
||||
let rows = statement
|
||||
.query_map([workspace], |row| {
|
||||
(0..columns)
|
||||
.map(|index| row.get::<_, rusqlite::types::Value>(index))
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
})
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>>>()
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let service_guards = || {
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT name,sql FROM sqlite_schema WHERE type='trigger'
|
||||
AND tbl_name IN ('plugin_service_consumers',
|
||||
'plugin_service_released_proofs','plugin_service_recovery_audit')
|
||||
ORDER BY name",
|
||||
)?;
|
||||
let guards = statement
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(guards)
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let assert_service_retained = |workspace: &str| {
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
for (table, column) in [
|
||||
("plugin_service_recovery_audit", "actor"),
|
||||
("plugin_service_released_proofs", "released_at_ms"),
|
||||
] {
|
||||
assert!(
|
||||
conn.execute(
|
||||
&format!("DELETE FROM {table} WHERE workspace_id=?1"),
|
||||
[workspace],
|
||||
)
|
||||
.is_err(),
|
||||
"ordinary deletion must retain {table}"
|
||||
);
|
||||
assert!(
|
||||
conn.execute(
|
||||
&format!(
|
||||
"UPDATE {table} SET {column}={column} WHERE workspace_id=?1"
|
||||
),
|
||||
[workspace],
|
||||
)
|
||||
.is_err(),
|
||||
"ordinary updates must not rewrite {table}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
};
|
||||
let guards_before = service_guards();
|
||||
let target_service_before = service_rows("workspace-a");
|
||||
let other_service_before = service_rows("workspace-b");
|
||||
assert_service_retained("workspace-a");
|
||||
assert_service_retained("workspace-b");
|
||||
let other = counts("workspace-b");
|
||||
assert_eq!(counts("workspace-a"), vec![1, 1, 1, 4, 1, 1, 1, 1]);
|
||||
assert_eq!(
|
||||
counts("workspace-a"),
|
||||
vec![1, 1, 1, 1, 1, 1, 1, 1, 4, 1, 1, 1, 1]
|
||||
);
|
||||
let preflight = store
|
||||
.workspace_deletion_preflight(&owner, &workspace_id)
|
||||
.unwrap();
|
||||
@@ -1371,13 +1484,23 @@ mod tests {
|
||||
conn.execute_batch("CREATE TRIGGER plugin_purge_failure BEFORE DELETE ON workspaces WHEN OLD.workspace_id='workspace-a' BEGIN SELECT RAISE(ABORT,'injected purge failure'); END;")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert!(
|
||||
store
|
||||
let error = store
|
||||
.finalize_workspace_deletion(&operation.operation_id)
|
||||
.is_err()
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("injected purge failure"),
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(
|
||||
counts("workspace-a"),
|
||||
vec![1, 1, 1, 1, 1, 1, 1, 1, 4, 1, 1, 1, 1]
|
||||
);
|
||||
assert_eq!(counts("workspace-a"), vec![1, 1, 1, 4, 1, 1, 1, 1]);
|
||||
assert_eq!(counts("workspace-b"), other);
|
||||
assert_eq!(service_rows("workspace-a"), target_service_before);
|
||||
assert_eq!(service_rows("workspace-b"), other_service_before);
|
||||
assert_eq!(service_guards(), guards_before);
|
||||
assert_service_retained("workspace-a");
|
||||
assert_service_retained("workspace-b");
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
crate::plugin_host::verify_receipt_schema(conn)?;
|
||||
@@ -1385,15 +1508,21 @@ mod tests {
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let completed = store
|
||||
.finalize_workspace_deletion(&operation.operation_id)
|
||||
.unwrap();
|
||||
assert_eq!(completed.state, WorkspaceDeletionState::Succeeded);
|
||||
assert_eq!(
|
||||
store
|
||||
.finalize_workspace_deletion(&operation.operation_id)
|
||||
.unwrap()
|
||||
.state,
|
||||
WorkspaceDeletionState::Succeeded
|
||||
.unwrap(),
|
||||
completed
|
||||
);
|
||||
assert_eq!(counts("workspace-a"), vec![0; 8]);
|
||||
assert_eq!(counts("workspace-a"), vec![0; 13]);
|
||||
assert_eq!(counts("workspace-b"), other);
|
||||
assert_eq!(service_rows("workspace-b"), other_service_before);
|
||||
assert_eq!(service_guards(), guards_before);
|
||||
assert_service_retained("workspace-b");
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
crate::plugin_host::verify_receipt_schema(conn)?;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Service Component fixture
|
||||
|
||||
`ticket-service-v2.component.wasm` is the authored Rust/PDK guest from
|
||||
`crates/plugin-runtime/tests/service-guest`, not a synthetic Component.
|
||||
Its manifest remains the source manifest there (Service world 2.2, Host imports
|
||||
2.0/2.1). The workspace-server integration suite installs and validates the
|
||||
package, fetches the pinned artifact, and executes the Component through the
|
||||
production Feature Service task/owner adapters.
|
||||
|
||||
Regenerate with wasm32-unknown-unknown std and `lld` available:
|
||||
|
||||
```sh
|
||||
cargo test -p yoi-plugin-runtime --test service_component
|
||||
cp target/plugin-service-fixture/service.component.wasm \
|
||||
crates/workspace-server/tests/fixtures/ticket-service-v2.component.wasm
|
||||
```
|
||||
|
||||
The first command builds the guest source using `--locked --offline --release`,
|
||||
then uses `wit_component::ComponentEncoder` to validate/encode it. No E2E feature
|
||||
flag, external service, or process-global environment mutation is needed for the
|
||||
workspace-server suite; its only HTTPS peer is the isolated shared TLS fixture.
|
||||
Binary file not shown.
+1468
-5
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ let
|
||||
package_digest = String;
|
||||
artifact_digest = String;
|
||||
tools = [...String];
|
||||
services = [...String] default [];
|
||||
ticket_events = Bool default false;
|
||||
logging = Bool default false;
|
||||
ticket_read = Bool default false;
|
||||
ticket_data_read = [...{ collection = String; schema_id = String; schema_version = String; }] default [];
|
||||
|
||||
@@ -78,6 +78,8 @@ let
|
||||
};
|
||||
plugins = { selections = [...{
|
||||
installation_id = String; tools = [...String]; logging = Bool;
|
||||
services = [...String] default [];
|
||||
ticket_events = Bool default false;
|
||||
https_slots = [...String];
|
||||
ticket_read = Bool default false;
|
||||
ticket_data_read = [...{ collection = String; schema_id = String; schema_version = String; }] default [];
|
||||
|
||||
@@ -126,7 +126,7 @@ world tool-plugin {
|
||||
|
||||
Tool worldは呼出しごとにfresh Store/instanceとし、compiled Componentは共有できる。Toolのglobal/static memoryを永続化や他呼出しとの通信に使えない。v2初期Toolは応答一回のbounded callとし、guestがmodelへ直接streamを注入するAPIは設けない。
|
||||
|
||||
Service/event worldには`on-start(config) -> result`、`on-event(envelope) -> event-result`、`on-stop(reason) -> result`を設ける。いずれもfresh Storeで呼び、HostがWorker/installation/planに束縛したService resourceを保持する。ToolとServiceで共有する状態はHost経由の明示dataとし、guest memoryの残存を契約にしない。`status()`を呼んで診断のためにguestを実行せず、Hostが実行・接続・失敗の観測を返す。
|
||||
Service/eventの機械的契約は[独立した`@2.2.0` world](wit-service/yoi-plugin-v2.2.wit)とする。既存`@2.0.0` HTTPS/loggingと`@2.1.0` Ticket/dataをexact importし、旧world/pinへの追加はしない。Service/event worldには`on-start(config) -> result`、`on-event(envelope) -> event-result`、`on-stop(reason) -> result`を設ける。いずれもfresh Storeで呼び、HostがWorker/installation/planに束縛したService resourceを保持する。ToolとServiceで共有する状態はHost経由の明示dataとし、guest memoryの残存を契約にしない。`status()`を呼んで診断のためにguestを実行せず、Hostが実行・接続・失敗の観測を返す。
|
||||
|
||||
`on-start`は設定検証とローカル初期化だけを行い、logging以外のHost作用を認めない。GETも副作用を持ち得るため、汎用HTTPや付随data書込みを初期化中に許可しない。外部event sourceの接続は全required Pluginの検証・導入成功後にHostが開始する。initialization失敗時に外部効果のrollbackを装わない。`on-stop`もloggingだけのbest effort通知で、resource解放の前提にしない。
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package yoi:host@2.1.0;
|
||||
|
||||
/// Handles are invocation-local selectors, not durable authorization.
|
||||
interface objects {
|
||||
use yoi:host/errors@2.0.0.{host-error};
|
||||
enum access { read, extension-write }
|
||||
resource ticket;
|
||||
resolve-ticket: func(ticket-ref: string, requested: access) -> result<ticket, host-error>;
|
||||
}
|
||||
|
||||
interface ticket-read {
|
||||
use yoi:host/errors@2.0.0.{host-error};
|
||||
use objects.{ticket};
|
||||
enum case-state { open, closed }
|
||||
record snapshot {
|
||||
title: string,
|
||||
state: case-state,
|
||||
content-digest: string,
|
||||
body: string,
|
||||
truncated: bool,
|
||||
}
|
||||
read: func(target: borrow<ticket>) -> result<snapshot, host-error>;
|
||||
}
|
||||
|
||||
interface extension-data {
|
||||
use yoi:host/errors@2.0.0.{host-error};
|
||||
use objects.{ticket};
|
||||
record collection {
|
||||
collection: string,
|
||||
schema-id: string,
|
||||
schema-version: string,
|
||||
}
|
||||
record address { namespace: collection, key: string }
|
||||
variant expectation { never-written, last-write(string) }
|
||||
record present-cell { value-json: string, last-write: string }
|
||||
variant cell { never-written, present(present-cell), deleted(string) }
|
||||
record receipt { mutation-id: string, result-digest: string }
|
||||
variant mutation-lookup { not-recorded, committed(receipt), expired }
|
||||
record entry { key: string, value: cell }
|
||||
record page { entries: list<entry>, next-cursor: option<string> }
|
||||
get: func(target: borrow<ticket>, cell-address: address) -> result<cell, host-error>;
|
||||
put: func(target: borrow<ticket>, cell-address: address, expected: expectation, operation-key: string, value-json: string) -> result<receipt, host-error>;
|
||||
delete: func(target: borrow<ticket>, cell-address: address, expected-last-write: string, operation-key: string) -> result<receipt, host-error>;
|
||||
%list: func(target: borrow<ticket>, namespace: collection, cursor: option<string>, limit: u32) -> result<page, host-error>;
|
||||
lookup-mutation: func(target: borrow<ticket>, cell-address: address, operation-key: string) -> result<mutation-lookup, host-error>;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package yoi:host@2.0.0;
|
||||
|
||||
interface errors {
|
||||
enum code {
|
||||
invalid-input, denied, not-found, conflict, unsupported,
|
||||
quota-exceeded, deadline-exceeded, cancelled, unavailable,
|
||||
indeterminate, receipt-expired,
|
||||
}
|
||||
enum effect-observation { not-dispatched, response-observed, indeterminate }
|
||||
record host-error {
|
||||
code: code,
|
||||
message: string,
|
||||
diagnostic-id: string,
|
||||
effect: effect-observation,
|
||||
}
|
||||
}
|
||||
|
||||
interface connections {
|
||||
use errors.{host-error};
|
||||
/// Call-local resource. No serialization, persistence or cross-Worker transfer.
|
||||
resource connection;
|
||||
open: func(slot: string) -> result<connection, host-error>;
|
||||
}
|
||||
|
||||
interface http {
|
||||
use connections.{connection};
|
||||
use errors.{host-error};
|
||||
enum method { get, head, post, put, patch, delete, options }
|
||||
record pair { name: string, value: string }
|
||||
record request {
|
||||
method: method,
|
||||
path: string,
|
||||
query: list<pair>,
|
||||
headers: list<pair>,
|
||||
body: list<u8>,
|
||||
timeout-ms: u32,
|
||||
effect-key: string,
|
||||
}
|
||||
record response {
|
||||
status: u16,
|
||||
headers: list<pair>,
|
||||
body: list<u8>,
|
||||
}
|
||||
/// Host authorizes each operation and does not automatically retry or follow redirects.
|
||||
send: func(target: borrow<connection>, input: request) -> result<response, host-error>;
|
||||
}
|
||||
|
||||
interface logging {
|
||||
use errors.{host-error};
|
||||
enum level { trace, debug, info, warn, error }
|
||||
variant scalar { text(string), boolean(bool), signed(s64), unsigned(u64), number(f64) }
|
||||
record field { name: string, value: scalar }
|
||||
record entry {
|
||||
level: level,
|
||||
event-code: string,
|
||||
message: string,
|
||||
fields: list<field>,
|
||||
}
|
||||
/// Bounded diagnostic data, not model context. Never include secrets or raw payloads.
|
||||
log: func(input: entry) -> result<_, host-error>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package yoi:plugin@2.2.0;
|
||||
|
||||
interface service {
|
||||
enum ticket-event-type { ticket-changed, ticket-deleted }
|
||||
record envelope {
|
||||
event-id: string,
|
||||
event-type: ticket-event-type,
|
||||
event-version: u32,
|
||||
occurred-at: string,
|
||||
ticket-ref: string,
|
||||
domain-event-id: string,
|
||||
content-digest: option<string>,
|
||||
correlation-id: option<string>,
|
||||
causation-id: option<string>,
|
||||
invocation-id: string,
|
||||
service-name: string,
|
||||
config-json: string,
|
||||
attempt: u32,
|
||||
remaining-ms: u32,
|
||||
}
|
||||
variant event-result {
|
||||
ack,
|
||||
retry-after(u32),
|
||||
reject(string),
|
||||
}
|
||||
record service-error { code: string, message: string }
|
||||
on-start: func(config: string) -> result<_, service-error>;
|
||||
on-event: func(input: envelope) -> event-result;
|
||||
on-stop: func(reason: string) -> result<_, service-error>;
|
||||
}
|
||||
|
||||
world ticket-service-plugin {
|
||||
import yoi:host/errors@2.0.0;
|
||||
import yoi:host/connections@2.0.0;
|
||||
import yoi:host/http@2.0.0;
|
||||
import yoi:host/logging@2.0.0;
|
||||
import yoi:host/objects@2.1.0;
|
||||
import yoi:host/ticket-read@2.1.0;
|
||||
import yoi:host/extension-data@2.1.0;
|
||||
export service;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:2373d17776c7822f8289243b5f79b6227bb78eec1ce101c0837e5abbb2af2350)
|
||||
// Input contract: 0.1.0 (sha256:8cc3c45d082e52b1ea9a014721008aeb4c0699b987729d362c0c14a6f485df10)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -19,7 +19,17 @@ export type PluginHttpMethod = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELE
|
||||
|
||||
export type PluginConnectionMetadata = { name: string, methods: Array<PluginHttpMethod>, path_prefixes: Array<string>, };
|
||||
|
||||
export type PluginGrantMetadata = { "kind": "logging" } | { "kind": "https", slot: string, } | { "kind": "ticket_read" } | { "kind": "ticket_data_read", collection: string, schema_id: string, schema_version: string, } | { "kind": "ticket_data_write", collection: string, schema_id: string, schema_version: string, };
|
||||
export type PluginGrantMetadata = { "kind": "logging" } | { "kind": "https", slot: string, } | { "kind": "ticket_read" } | { "kind": "ticket_events" } | { "kind": "ticket_data_read", collection: string, schema_id: string, schema_version: string, } | { "kind": "ticket_data_write", collection: string, schema_id: string, schema_version: string, };
|
||||
|
||||
export type PluginServiceMetadata = { name: string, subscriptions: Array<string>, };
|
||||
|
||||
export type PluginServicePauseReason = "rejected" | "attempts_exhausted" | "queue_full" | "retention_gap" | "indeterminate" | "authorization";
|
||||
|
||||
export type PluginServiceDiagnostics = { consumer_id: string, pause_reason: PluginServicePauseReason | null, queued: number, attempt: number, current_event_id: string | null, lease_active: boolean, };
|
||||
|
||||
export type PluginServiceRecoveryAction = { "kind": "resume" } | { "kind": "skip", event_id: string, } | { "kind": "accept_gap" };
|
||||
|
||||
export type PluginServiceRecoveryRequest = { consumer_id: string, expected_pause: PluginServicePauseReason, action: PluginServiceRecoveryAction, operation_key: string, reason: string, };
|
||||
|
||||
export type PluginDataSchemaMetadata = { collection: string, schema_id: string, schema_version: string, schema: unknown, };
|
||||
|
||||
@@ -29,7 +39,7 @@ export type PluginDataPurgeRequest = { operation_key: string, expected: PluginDa
|
||||
|
||||
export type PluginDataPurgeResponse = { purge_id: string, observed: PluginDataPurgeState, };
|
||||
|
||||
export type PluginArtifact = { package_id: string, name: string, version: string, description: string | null, package_digest: string, artifact_digest: string, artifact_size: number, schema_version: number, world: string, required_imports: Array<string>, tools: Array<PluginToolMetadata>, connections: Array<PluginConnectionMetadata>, requested_grants: Array<PluginGrantMetadata>, data_collections: Array<PluginDataSchemaMetadata>, config_schema: PluginSchemaContribution, revoked: boolean, revocation_reason: string | null, };
|
||||
export type PluginArtifact = { package_id: string, name: string, version: string, description: string | null, package_digest: string, artifact_digest: string, artifact_size: number, schema_version: number, world: string, required_imports: Array<string>, tools: Array<PluginToolMetadata>, services?: Array<PluginServiceMetadata>, connections: Array<PluginConnectionMetadata>, requested_grants: Array<PluginGrantMetadata>, data_collections: Array<PluginDataSchemaMetadata>, config_schema: PluginSchemaContribution, revoked: boolean, revocation_reason: string | null, };
|
||||
|
||||
export type PluginUploadResponse = { upload_id: string, expires_at: string, artifact: PluginArtifact, };
|
||||
|
||||
@@ -55,9 +65,11 @@ export type PluginWorkerDiagnosticSelection = { "kind": "unrecorded" } | { "kind
|
||||
|
||||
export type PluginDiagnosticPinState = "pending" | "active" | "restorable" | "released";
|
||||
|
||||
export type PluginDiagnosticSurface = "tool";
|
||||
export type PluginDiagnosticSurface = "tool" | "service";
|
||||
|
||||
export type PluginWorkerDiagnostic = { installation_id: string, package_id: string, package_version: string, package_digest: string, artifact_digest: string, artifact_size: number, surfaces: Array<PluginDiagnosticSurface>, selected_tools: Array<string>, admission: PluginDiagnosticAdmission, connections: Array<PluginDiagnosticConnection>, };
|
||||
export type PluginServiceConsumerDiagnostics = { service_name: string, diagnostics: PluginServiceDiagnostics, };
|
||||
|
||||
export type PluginWorkerDiagnostic = { installation_id: string, package_id: string, package_version: string, package_digest: string, artifact_digest: string, artifact_size: number, surfaces: Array<PluginDiagnosticSurface>, selected_tools: Array<string>, selected_services: Array<string>, service_consumers: Array<PluginServiceConsumerDiagnostics>, admission: PluginDiagnosticAdmission, connections: Array<PluginDiagnosticConnection>, };
|
||||
|
||||
export type PluginDiagnosticAdmission = { "kind": "admissible" } | { "kind": "refused", reason_code: PluginHostErrorCode, };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:2373d17776c7822f8289243b5f79b6227bb78eec1ce101c0837e5abbb2af2350)
|
||||
// Input contract: 0.1.0 (sha256:8cc3c45d082e52b1ea9a014721008aeb4c0699b987729d362c0c14a6f485df10)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:2373d17776c7822f8289243b5f79b6227bb78eec1ce101c0837e5abbb2af2350)
|
||||
// Input contract: 0.1.0 (sha256:8cc3c45d082e52b1ea9a014721008aeb4c0699b987729d362c0c14a6f485df10)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:2373d17776c7822f8289243b5f79b6227bb78eec1ce101c0837e5abbb2af2350)
|
||||
// Input contract: 0.1.0 (sha256:8cc3c45d082e52b1ea9a014721008aeb4c0699b987729d362c0c14a6f485df10)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:2373d17776c7822f8289243b5f79b6227bb78eec1ce101c0837e5abbb2af2350)
|
||||
// Input contract: 0.1.0 (sha256:8cc3c45d082e52b1ea9a014721008aeb4c0699b987729d362c0c14a6f485df10)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
Reference in New Issue
Block a user