From 794593e6242e57d1405b37f1a94bc6790e2e0d98 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 30 Aug 2026 01:02:16 +0200 Subject: [PATCH 1/6] feat: add observable resource profiles agent-identity: dev3.direct.omp.2cshu64q agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.9 agent-runtime: OMP 18.0.9 tooling-profile: dotfiles@b607597 --- crates/agent-spec/src/kdl_format.rs | 13 + crates/agent-spec/src/lib.rs | 7 +- crates/agent-spec/src/profile.rs | 750 +++++- crates/agent-spec/src/profile_wasm.rs | 78 +- crates/agent-spec/src/spec.rs | 42 +- crates/agent-spec/tests/discovery.rs | 27 +- crates/agent-spec/tests/profile_wasm.rs | 152 ++ ...ate-first-read-and-observe-capabilities.md | 77 + ...08-29-github-attention-filter-prototype.md | 45 + ...selector-and-runtime-protocol-prototype.md | 67 + ...8-29-smart-resource-lifecycle-prototype.md | 63 + .../vrs/07-resource-profile/open-questions.md | 40 +- docs/vrs/07-resource-profile/requirements.md | 111 + docs/vrs/07-resource-profile/spec.md | 285 ++- docs/vrs/07-resource/spec.md | 21 +- docs/vrs/ontology.md | 42 + flake.nix | 2 + src/agent_author.rs | 103 +- src/agents.rs | 3 + src/catalog.rs | 218 +- src/catalog_transaction.rs | 11 + src/lib.rs | 2 + src/main.rs | 15 +- src/resource_profile.rs | 2153 +++++++++++++++++ src/resource_profile_supervisor.rs | 1182 +++++++++ src/run.rs | 226 +- tests/agent_resource.rs | 25 +- tests/resource_profile_supervisor_e2e.rs | 490 ++++ 28 files changed, 6053 insertions(+), 197 deletions(-) create mode 100644 docs/vrs/.decisions/0014-resource-profiles-are-state-first-read-and-observe-capabilities.md create mode 100644 docs/vrs/07-resource-profile/.experiments/2026-08-29-github-attention-filter-prototype.md create mode 100644 docs/vrs/07-resource-profile/.experiments/2026-08-29-selector-and-runtime-protocol-prototype.md create mode 100644 docs/vrs/07-resource-profile/.experiments/2026-08-29-smart-resource-lifecycle-prototype.md create mode 100644 src/resource_profile.rs create mode 100644 src/resource_profile_supervisor.rs create mode 100755 tests/resource_profile_supervisor_e2e.rs diff --git a/crates/agent-spec/src/kdl_format.rs b/crates/agent-spec/src/kdl_format.rs index e9911254..bf824c24 100644 --- a/crates/agent-spec/src/kdl_format.rs +++ b/crates/agent-spec/src/kdl_format.rs @@ -413,6 +413,7 @@ fn resource_node_to_raw(node: &DeclaredNode) -> anyhow::Result<(String, RawResou let mut uri = None; let mut reason = None; let mut inactive_reason = None; + let mut selector = None; for entry in &node.entries { let Some(property) = entry.name.as_deref() else { if name.is_some() { @@ -454,6 +455,17 @@ fn resource_node_to_raw(node: &DeclaredNode) -> anyhow::Result<(String, RawResou anyhow::bail!("resource binding needs string `inactive-reason`"); } } + "selector" => { + if selector.is_some() { + anyhow::bail!("resource binding has duplicate `selector`"); + } + let encoded = value + .ok_or_else(|| anyhow::anyhow!("resource binding needs string `selector`"))?; + selector = Some( + serde_json::from_str(&encoded) + .map_err(|error| anyhow::anyhow!("resource binding `selector` is not valid JSON: {error}"))?, + ); + } other => anyhow::bail!("resource binding has unsupported property `{other}`"), } } @@ -465,6 +477,7 @@ fn resource_node_to_raw(node: &DeclaredNode) -> anyhow::Result<(String, RawResou reason: reason .ok_or_else(|| anyhow::anyhow!("resource binding needs string `reason`"))?, inactive_reason, + selector, }, )) } diff --git a/crates/agent-spec/src/lib.rs b/crates/agent-spec/src/lib.rs index 83ca15c4..955f82d6 100644 --- a/crates/agent-spec/src/lib.rs +++ b/crates/agent-spec/src/lib.rs @@ -51,4 +51,9 @@ pub use spec::{ StreamLaunch, Task, TaskKind, TaskLifecycle, parse_duration, stream_name_of_task, validate_desired_state_reason, }; -pub use profile::{ProfileClass, ProfileSource, ResourceProfile, ResourceProfileRegistry}; +pub use profile::{ + DEFAULT_SELECTOR_LIMIT_BYTES, DescriptorValidationError, PROFILE_DESCRIPTOR_ABI_VERSION, + ProfileCapability, ProfileClass, ProfileDescriptor, ProfileRuntime, ProfileSnapshot, + ProfileSource, ProfileTopic, Resolution, ResourceProfile, ResourceProfileRefresh, + ResourceProfileRegistry, RuntimeTopology, SelectorSchema, SelectorValidationError, +}; diff --git a/crates/agent-spec/src/profile.rs b/crates/agent-spec/src/profile.rs index 40fa60ff..a21affc1 100644 --- a/crates/agent-spec/src/profile.rs +++ b/crates/agent-spec/src/profile.rs @@ -13,7 +13,7 @@ //! //! The registry is injectable so a catalog can extend or override the built-in set. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; #[cfg(feature = "wasm-resolver")] use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -25,6 +25,8 @@ use parking_lot::Mutex; #[cfg(feature = "wasm-resolver")] use sha2::{Digest as _, Sha256}; +use serde::Deserialize; +use serde_json::Value; /// Scheme of the standing-seat goal carrier: `dev.schickling.agent-goal:///`. /// The authority names a logical host and identity; a resolver module decides what the URI @@ -33,6 +35,481 @@ pub const AGENT_GOAL_SCHEME: &str = "dev.schickling.agent-goal"; /// Maximum resolver module bytes admitted by both catalog transactions and the wasm runtime. pub const DEFAULT_MODULE_LIMIT_BYTES: usize = 16 * 1024 * 1024; +/// Descriptor ABI implemented by this host. +pub const PROFILE_DESCRIPTOR_ABI_VERSION: u32 = 2; +/// Maximum canonical compact JSON bytes accepted for one binding selector. +pub const DEFAULT_SELECTOR_LIMIT_BYTES: usize = 16 * 1024; + +/// Closed capability vocabulary for descriptor ABI v2. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ProfileCapability { + Resolve, + Read, + Observe, +} + +/// The runtime process topology selected by a profile. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub enum RuntimeTopology { + #[serde(rename = "shared")] + Shared, + #[serde(rename = "perBinding")] + PerBinding, +} + +/// One profile-owned semantic invalidation topic. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProfileTopic { + pub name: String, +} + +/// Process topology portion of a profile descriptor. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProfileRuntime { + pub topology: RuntimeTopology, +} + +/// Snapshot identity portion of a profile descriptor. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProfileSnapshot { + pub media_type: String, + pub schema_id: String, +} + +/// The deliberately small JSON-Schema subset accepted for selectors. +/// +/// It supports recursively composed objects and arrays plus JSON scalar type checks. Object +/// required-properties and array uniqueness are the only structural assertions needed by the +/// selector contract. Unknown schema keywords fail descriptor decoding rather than being ignored. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SelectorSchema { + Object { + properties: BTreeMap, + required: Vec, + additional_properties: bool, + }, + Array { + items: Box, + unique_items: bool, + }, + String, + Boolean, + Number, + Integer, + Null, +} + +/// A validated ABI v2 descriptor returned by `describe`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProfileDescriptor { + pub abi_version: u32, + pub capabilities: Vec, + pub selector_schema: SelectorSchema, + pub default_selector: Value, + pub topics: Vec, + pub runtime: ProfileRuntime, + pub snapshot: ProfileSnapshot, +} + +/// A selector failure with a JSON-path-like location. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectorValidationError { + pub path: String, + pub message: String, +} + +impl std::fmt::Display for SelectorValidationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "selector {}: {}", self.path, self.message) + } +} + +impl std::error::Error for SelectorValidationError {} + +/// Why a syntactically decoded descriptor is not a valid ABI v2 contract. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DescriptorValidationError { + UnsupportedAbiVersion(u32), + MissingResolveCapability, + DuplicateCapability(ProfileCapability), + EmptyTopic, + DuplicateTopic(String), + InvalidDefaultSelector(SelectorValidationError), + UnknownDefaultTopic(String), + InvalidSnapshotMediaType, + EmptySnapshotSchemaId, + InvalidSelectorSchema(String), +} + +impl std::fmt::Display for DescriptorValidationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnsupportedAbiVersion(version) => { + write!(formatter, "unsupported profile descriptor ABI version {version}") + } + Self::MissingResolveCapability => { + formatter.write_str("descriptor does not declare the `resolve` capability") + } + Self::DuplicateCapability(capability) => { + write!(formatter, "descriptor repeats capability {capability:?}") + } + Self::EmptyTopic => formatter.write_str("descriptor topic names must be non-empty"), + Self::DuplicateTopic(topic) => write!(formatter, "descriptor repeats topic `{topic}`"), + Self::InvalidDefaultSelector(error) => { + write!(formatter, "default {error}") + } + Self::UnknownDefaultTopic(topic) => { + write!(formatter, "default selector names unpublished topic `{topic}`") + } + Self::InvalidSnapshotMediaType => { + formatter.write_str("snapshot mediaType must be a non-empty type/subtype") + } + Self::EmptySnapshotSchemaId => { + formatter.write_str("snapshot schemaId must be non-empty") + } + Self::InvalidSelectorSchema(message) => { + write!(formatter, "invalid selector schema: {message}") + } + } + } +} + +impl std::error::Error for DescriptorValidationError {} + +impl<'de> Deserialize<'de> for SelectorSchema { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = Value::deserialize(deserializer)?; + Self::decode(value).map_err(::custom) + } +} + +impl SelectorSchema { + fn decode(value: Value) -> Result { + let Value::Object(mut fields) = value else { + return Err("selector schema must be an object".to_owned()); + }; + let kind = match fields.remove("type") { + Some(Value::String(kind)) => kind, + Some(_) => return Err("selector schema `type` must be a string".to_owned()), + None => return Err("selector schema is missing `type`".to_owned()), + }; + let schema = match kind.as_str() { + "object" => { + let properties = match fields.remove("properties") { + None => BTreeMap::new(), + Some(Value::Object(properties)) => properties + .into_iter() + .map(|(name, schema)| Self::decode(schema).map(|schema| (name, schema))) + .collect::>()?, + Some(_) => { + return Err("object schema `properties` must be an object".to_owned()); + } + }; + let required = match fields.remove("required") { + None => Vec::new(), + Some(Value::Array(required)) => required + .into_iter() + .map(|name| match name { + Value::String(name) => Ok(name), + _ => Err( + "object schema `required` entries must be strings".to_owned(), + ), + }) + .collect::>()?, + Some(_) => return Err("object schema `required` must be an array".to_owned()), + }; + let additional_properties = match fields.remove("additionalProperties") { + None => true, + Some(Value::Bool(value)) => value, + Some(_) => { + return Err( + "object schema `additionalProperties` must be a boolean".to_owned(), + ); + } + }; + Self::Object { + properties, + required, + additional_properties, + } + } + "array" => { + let items = fields + .remove("items") + .ok_or_else(|| "array schema is missing `items`".to_owned()) + .and_then(Self::decode)?; + let unique_items = match fields.remove("uniqueItems") { + None => false, + Some(Value::Bool(value)) => value, + Some(_) => { + return Err("array schema `uniqueItems` must be a boolean".to_owned()); + } + }; + Self::Array { + items: Box::new(items), + unique_items, + } + } + "string" => Self::String, + "boolean" => Self::Boolean, + "number" => Self::Number, + "integer" => Self::Integer, + "null" => Self::Null, + other => return Err(format!("unknown selector schema type `{other}`")), + }; + if let Some(keyword) = fields.keys().next() { + return Err(format!("unknown selector schema keyword `{keyword}`")); + } + Ok(schema) + } + + fn validate_definition(&self, path: &str) -> Result<(), DescriptorValidationError> { + match self { + Self::Object { + properties, + required, + .. + } => { + let mut seen = BTreeSet::new(); + for name in required { + if !seen.insert(name) { + return Err(DescriptorValidationError::InvalidSelectorSchema(format!( + "{path}.required repeats `{name}`" + ))); + } + if !properties.contains_key(name) { + return Err(DescriptorValidationError::InvalidSelectorSchema(format!( + "{path}.required names unknown property `{name}`" + ))); + } + } + for (name, schema) in properties { + schema.validate_definition(&format!("{path}.properties.{name}"))?; + } + } + Self::Array { items, .. } => { + items.validate_definition(&format!("{path}.items"))?; + } + Self::String + | Self::Boolean + | Self::Number + | Self::Integer + | Self::Null => {} + } + Ok(()) + } + + fn validate_value(&self, value: &Value, path: &str) -> Result<(), SelectorValidationError> { + let fail = |message: String| SelectorValidationError { + path: path.to_owned(), + message, + }; + match self { + Self::Object { + properties, + required, + additional_properties, + } => { + let object = value + .as_object() + .ok_or_else(|| fail("must be an object".to_owned()))?; + for name in required { + if !object.contains_key(name) { + return Err(fail(format!("is missing required property `{name}`"))); + } + } + for (name, child) in object { + match properties.get(name) { + Some(schema) => { + schema.validate_value(child, &format!("{path}.{name}"))?; + } + None if !additional_properties => { + return Err(fail(format!("contains unknown property `{name}`"))); + } + None => {} + } + } + } + Self::Array { + items, + unique_items, + } => { + let array = value + .as_array() + .ok_or_else(|| fail("must be an array".to_owned()))?; + for (index, item) in array.iter().enumerate() { + items.validate_value(item, &format!("{path}[{index}]"))?; + if *unique_items && array[..index].contains(item) { + return Err(SelectorValidationError { + path: format!("{path}[{index}]"), + message: "duplicates an earlier array item".to_owned(), + }); + } + } + } + Self::String if !value.is_string() => return Err(fail("must be a string".to_owned())), + Self::Boolean if !value.is_boolean() => { + return Err(fail("must be a boolean".to_owned())); + } + Self::Number if !value.is_number() => return Err(fail("must be a number".to_owned())), + Self::Integer + if !(value.as_i64().is_some() || value.as_u64().is_some()) => + { + return Err(fail("must be an integer".to_owned())); + } + Self::Null if !value.is_null() => return Err(fail("must be null".to_owned())), + _ => {} + } + Ok(()) + } +} + +impl ProfileDescriptor { + /// Decode and fully validate one bounded descriptor JSON document. + pub fn from_json(bytes: &[u8]) -> Result { + let descriptor: Self = + serde_json::from_slice(bytes).map_err(|error| format!("invalid descriptor JSON: {error}"))?; + descriptor.validate().map_err(|error| error.to_string())?; + Ok(descriptor) + } + + /// Validate ABI, closed capabilities, topic vocabulary, defaults, topology, and snapshot + /// identity. Closed enum decoding has already rejected unknown capabilities and topology. + pub fn validate(&self) -> Result<(), DescriptorValidationError> { + if self.abi_version != PROFILE_DESCRIPTOR_ABI_VERSION { + return Err(DescriptorValidationError::UnsupportedAbiVersion( + self.abi_version, + )); + } + let mut capabilities = BTreeSet::new(); + for capability in &self.capabilities { + if !capabilities.insert(*capability) { + return Err(DescriptorValidationError::DuplicateCapability(*capability)); + } + } + if !capabilities.contains(&ProfileCapability::Resolve) { + return Err(DescriptorValidationError::MissingResolveCapability); + } + self.selector_schema.validate_definition("$")?; + + let mut topics = BTreeSet::new(); + for topic in &self.topics { + if topic.name.trim().is_empty() { + return Err(DescriptorValidationError::EmptyTopic); + } + if !topics.insert(topic.name.as_str()) { + return Err(DescriptorValidationError::DuplicateTopic( + topic.name.clone(), + )); + } + } + Self::validate_selector_size(&self.default_selector) + .map_err(DescriptorValidationError::InvalidDefaultSelector)?; + self.validate_selector_schema_only(&self.default_selector) + .map_err(DescriptorValidationError::InvalidDefaultSelector)?; + for topic in selector_topics(&self.default_selector)? + .iter() + .filter_map(Value::as_str) + { + if !topics.contains(topic) { + return Err(DescriptorValidationError::UnknownDefaultTopic( + topic.to_owned(), + )); + } + } + if !valid_media_type(&self.snapshot.media_type) { + return Err(DescriptorValidationError::InvalidSnapshotMediaType); + } + if self.snapshot.schema_id.trim().is_empty() { + return Err(DescriptorValidationError::EmptySnapshotSchemaId); + } + Ok(()) + } + + /// Validate one binding selector against the descriptor schema, topic vocabulary, and 16 KiB + /// compact-JSON bound. + pub fn validate_selector(&self, selector: &Value) -> Result<(), SelectorValidationError> { + Self::validate_selector_size(selector)?; + self.validate_selector_schema_only(selector)?; + for topic in selector_topics(selector) + .map_err(|error| SelectorValidationError { + path: "$.topics".to_owned(), + message: error.to_string(), + })? + .iter() + .filter_map(Value::as_str) + { + if !self.topics.iter().any(|published| published.name == topic) { + return Err(SelectorValidationError { + path: "$.topics".to_owned(), + message: format!("names unpublished topic `{topic}`"), + }); + } + } + Ok(()) + } + fn validate_selector_size(selector: &Value) -> Result<(), SelectorValidationError> { + let encoded = serde_json::to_vec(selector).map_err(|error| SelectorValidationError { + path: "$".to_owned(), + message: format!("cannot be encoded as compact JSON: {error}"), + })?; + if encoded.len() > DEFAULT_SELECTOR_LIMIT_BYTES { + return Err(SelectorValidationError { + path: "$".to_owned(), + message: format!( + "compact JSON is {} bytes; limit is {} bytes", + encoded.len(), + DEFAULT_SELECTOR_LIMIT_BYTES + ), + }); + } + Ok(()) + } + + + fn validate_selector_schema_only( + &self, + selector: &Value, + ) -> Result<(), SelectorValidationError> { + self.selector_schema.validate_value(selector, "$") + } +} + +fn selector_topics(selector: &Value) -> Result<&[Value], DescriptorValidationError> { + let Some(topics) = selector.as_object().and_then(|object| object.get("topics")) else { + return Ok(&[]); + }; + let topics = topics.as_array().ok_or_else(|| { + DescriptorValidationError::InvalidSelectorSchema( + "selector `topics` must be an array".to_owned(), + ) + })?; + if topics.iter().any(|topic| !topic.is_string()) { + return Err(DescriptorValidationError::InvalidSelectorSchema( + "selector `topics` entries must be strings".to_owned(), + )); + } + Ok(topics) +} + +fn valid_media_type(media_type: &str) -> bool { + let Some((kind, subtype)) = media_type.split_once('/') else { + return false; + }; + !kind.is_empty() + && !subtype.is_empty() + && !subtype.contains('/') + && !media_type.chars().any(char::is_whitespace) + && media_type.chars().all(|character| !character.is_control()) +} /// How carriers resolved through one profile notify (`RESYNC-R04`). Declared alongside the /// resolver module instead of sniffed from path basenames. @@ -304,6 +781,56 @@ impl ResourceProfileRefresh<'_> { pub fn get(&self, scheme: &str) -> Option<&ResourceProfile> { self.registry.get(scheme) } + /// Look up and execute the optional descriptor for one exact registered scheme. + /// + /// `Ok(None)` means either no registration or a passive resolve-only module. An exported + /// descriptor is returned only after complete ABI v2 validation. + pub fn try_descriptor(&self, scheme: &str) -> Result, String> { + let Some(profile) = self.registry.profiles.get(scheme) else { + return Ok(None); + }; + let ProfileSource::Wasm { + module, + containment_root, + .. + } = &profile.source; + + #[cfg(not(feature = "wasm-resolver"))] + { + let _ = (module, containment_root); + Err("profile descriptor unavailable: st2 was built without the `wasm-resolver` feature" + .to_owned()) + } + #[cfg(feature = "wasm-resolver")] + { + self.compiled_resolver(module, containment_root.as_deref())? + .describe_once() + .map_err(|error| error.to_string()) + } + } + + /// Descriptor lookup that folds an invalid or unavailable descriptor into absence. + pub fn descriptor(&self, scheme: &str) -> Option { + self.try_descriptor(scheme).ok().flatten() + } + + #[cfg(feature = "wasm-resolver")] + fn compiled_resolver( + &self, + module: &Path, + containment_root: Option<&Path>, + ) -> Result, String> { + let key = ModuleCacheKey::new(module, containment_root); + let mut modules = self.modules.lock(); + if let Some(result) = modules.get(&key) { + return result.clone(); + } + let result = self + .registry + .compiled(&key, module, containment_root); + modules.insert(key, result.clone()); + result + } /// Resolve one binding against the registry definitions captured by this refresh. pub fn try_resolve(&self, agent_dir: &Path, uri: &str) -> Result, String> { @@ -330,19 +857,7 @@ impl ResourceProfileRefresh<'_> { } #[cfg(feature = "wasm-resolver")] { - let key = ModuleCacheKey::new(module, containment_root.as_deref()); - let resolver = { - let mut modules = self.modules.lock(); - if let Some(result) = modules.get(&key) { - result.clone() - } else { - let result = - self.registry - .compiled(&key, module, containment_root.as_deref()); - modules.insert(key, result.clone()); - result - } - }?; + let resolver = self.compiled_resolver(module, containment_root.as_deref())?; let contained = resolver .resolve_contained(uri, agent_dir) .map_err(|error| error.to_string())?; @@ -490,6 +1005,17 @@ impl ResourceProfileRegistry { pub fn get(&self, scheme: &str) -> Option<&ResourceProfile> { self.profiles.get(scheme) } + /// Execute and validate the optional descriptor for an exact registered scheme. + /// + /// A missing `describe` export remains a valid passive profile and returns `Ok(None)`. + pub fn try_descriptor(&self, scheme: &str) -> Result, String> { + self.begin_refresh().try_descriptor(scheme) + } + + /// Descriptor lookup that folds execution or validation failures into absence. + pub fn descriptor(&self, scheme: &str) -> Option { + self.try_descriptor(scheme).ok().flatten() + } /// Resolve `uri` when its scheme has a registered profile. No scheme or an unregistered /// scheme is not this registry's business (`Ok(None)`); the caller's legacy local-path rules @@ -564,6 +1090,48 @@ fn is_uri_scheme(scheme: &str) -> bool { mod tests { use super::*; + const VALID_DESCRIPTOR_JSON: &str = r#"{ + "abiVersion": 2, + "capabilities": ["resolve", "read", "observe"], + "selectorSchema": { + "type": "object", + "properties": { + "topics": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, + "filter": { + "type": "object", + "properties": { + "draft": { "type": "boolean" } + }, + "additionalProperties": false + } + }, + "required": ["topics"], + "additionalProperties": false + }, + "defaultSelector": { + "topics": ["ci.failure", "review.requested"], + "filter": { "draft": false } + }, + "topics": [ + { "name": "ci.failure" }, + { "name": "ci.success" }, + { "name": "review.requested" } + ], + "runtime": { "topology": "shared" }, + "snapshot": { + "mediaType": "application/json", + "schemaId": "dev.example.pull.snapshot.v1" + } + }"#; + + fn valid_descriptor() -> ProfileDescriptor { + ProfileDescriptor::from_json(VALID_DESCRIPTOR_JSON.as_bytes()) + .expect("valid ABI v2 descriptor") + } fn demo_profile(class: ProfileClass) -> ResourceProfile { ResourceProfile::wasm( AGENT_GOAL_SCHEME, @@ -588,6 +1156,160 @@ mod tests { .unwrap(); } + #[test] + fn valid_v2_descriptor_and_nested_selector_validate() { + let descriptor = valid_descriptor(); + assert_eq!(descriptor.abi_version, PROFILE_DESCRIPTOR_ABI_VERSION); + assert_eq!(descriptor.runtime.topology, RuntimeTopology::Shared); + descriptor + .validate_selector(&serde_json::json!({ + "topics": ["ci.success"], + "filter": { "draft": true } + })) + .expect("published topic and nested boolean satisfy the schema"); + } + + #[test] + fn descriptor_rejects_unknown_abi_capability_and_fields() { + let unknown_abi = VALID_DESCRIPTOR_JSON.replace("\"abiVersion\": 2", "\"abiVersion\": 9"); + assert!( + ProfileDescriptor::from_json(unknown_abi.as_bytes()) + .unwrap_err() + .contains("unsupported profile descriptor ABI version 9") + ); + + let unknown_capability = + VALID_DESCRIPTOR_JSON.replace("\"observe\"", "\"write\""); + assert!( + ProfileDescriptor::from_json(unknown_capability.as_bytes()) + .unwrap_err() + .contains("unknown variant") + ); + + let unknown_field = + VALID_DESCRIPTOR_JSON.replace("\"abiVersion\": 2,", "\"abiVersion\": 2, \"extra\": true,"); + assert!( + ProfileDescriptor::from_json(unknown_field.as_bytes()) + .unwrap_err() + .contains("unknown field") + ); + let unknown_topology = + VALID_DESCRIPTOR_JSON.replace(r#""topology": "shared""#, r#""topology": "isolated""#); + assert!( + ProfileDescriptor::from_json(unknown_topology.as_bytes()) + .unwrap_err() + .contains("unknown variant") + ); + + let unknown_schema_keyword = VALID_DESCRIPTOR_JSON.replace( + r#""draft": { "type": "boolean" }"#, + r#""draft": { "type": "boolean", "const": true }"#, + ); + assert!( + ProfileDescriptor::from_json(unknown_schema_keyword.as_bytes()) + .unwrap_err() + .contains("unknown selector schema keyword `const`") + ); + } + + #[test] + fn descriptor_rejects_malformed_json_and_invalid_topic_defaults() { + assert!(ProfileDescriptor::from_json(br#"{"abiVersion":"#).is_err()); + + let duplicate_topic = VALID_DESCRIPTOR_JSON.replace( + r#"{ "name": "ci.success" }"#, + r#"{ "name": "ci.failure" }"#, + ); + assert!(matches!( + serde_json::from_str::(&duplicate_topic) + .expect("descriptor shape decodes") + .validate(), + Err(DescriptorValidationError::DuplicateTopic(topic)) if topic == "ci.failure" + )); + + let unknown_default = VALID_DESCRIPTOR_JSON.replace( + r#""topics": ["ci.failure", "review.requested"]"#, + r#""topics": ["ci.failure", "unpublished"]"#, + ); + assert!(matches!( + serde_json::from_str::(&unknown_default) + .expect("descriptor shape decodes") + .validate(), + Err(DescriptorValidationError::UnknownDefaultTopic(topic)) if topic == "unpublished" + )); + + let empty_topic = VALID_DESCRIPTOR_JSON.replace( + r#"{ "name": "ci.success" }"#, + r#"{ "name": "" }"#, + ); + assert!(matches!( + serde_json::from_str::(&empty_topic) + .expect("descriptor shape decodes") + .validate(), + Err(DescriptorValidationError::EmptyTopic) + )); + let invalid_media_type = VALID_DESCRIPTOR_JSON.replace( + r#""mediaType": "application/json""#, + r#""mediaType": """#, + ); + assert!(matches!( + serde_json::from_str::(&invalid_media_type) + .expect("descriptor shape decodes") + .validate(), + Err(DescriptorValidationError::InvalidSnapshotMediaType) + )); + + let empty_schema_id = VALID_DESCRIPTOR_JSON.replace( + r#""schemaId": "dev.example.pull.snapshot.v1""#, + r#""schemaId": " ""#, + ); + assert!(matches!( + serde_json::from_str::(&empty_schema_id) + .expect("descriptor shape decodes") + .validate(), + Err(DescriptorValidationError::EmptySnapshotSchemaId) + )); + } + + #[test] + fn selector_validation_enforces_schema_topics_uniqueness_and_size() { + let descriptor = valid_descriptor(); + assert!(descriptor + .validate_selector(&serde_json::json!({ "topics": ["ci.failure"], "extra": true })) + .unwrap_err() + .message + .contains("unknown property")); + assert!(descriptor + .validate_selector(&serde_json::json!({ "topics": ["ci.failure", "ci.failure"] })) + .unwrap_err() + .message + .contains("duplicates")); + assert!(descriptor + .validate_selector(&serde_json::json!({ "topics": ["not.published"] })) + .unwrap_err() + .message + .contains("unpublished topic")); + assert!(descriptor + .validate_selector(&serde_json::json!({ + "topics": ["ci.failure"], + "filter": { "draft": "yes" } + })) + .unwrap_err() + .message + .contains("boolean")); + + let oversized = serde_json::json!({ + "topics": ["ci.failure"], + "filter": { "draft": false }, + "padding": "x".repeat(DEFAULT_SELECTOR_LIMIT_BYTES) + }); + let error = descriptor + .validate_selector(&oversized) + .expect_err("oversized selector is rejected before schema activation"); + assert!(error.message.contains("compact JSON")); + assert!(error.message.contains("limit")); + } + #[test] fn classes_round_trip_through_their_catalog_spelling() { for (text, expected) in [ diff --git a/crates/agent-spec/src/profile_wasm.rs b/crates/agent-spec/src/profile_wasm.rs index 3bc2a1be..d6a8cc29 100644 --- a/crates/agent-spec/src/profile_wasm.rs +++ b/crates/agent-spec/src/profile_wasm.rs @@ -3,18 +3,19 @@ //! //! ABI (deliberately minimal, no WASI, no component model): //! -//! - guest exports `memory`, `alloc(len: i32) -> i32`, and -//! `resolve(uri_ptr: i32, uri_len: i32, dir_ptr: i32, dir_len: i32) -> i64`. -//! - the host copies `uri` and `agent_dir` into linear memory through `alloc`; the return value -//! packs `(ptr << 32) | len` of a UTF-8 JSON document `{"path": "...", "class": "..."}`. -//! - the agent directory reaches the guest as a second argument (a global would need mutable -//! globals + import plumbing for no gain). +//! - every guest exports `memory`, `alloc(len: i32) -> i32`, and +//! `resolve(uri_ptr: i32, uri_len: i32, dir_ptr: i32, dir_len: i32) -> i64`; +//! - observable ABI v2 guests additionally export `describe() -> i64`; +//! - both calls return packed `(ptr << 32) | len` ranges containing bounded UTF-8 JSON; +//! - the host copies the preserved `uri` and `agent_dir` into linear memory through `alloc`. //! //! Containment story: traps, fuel exhaustion, and memory-limit breaches are caught here and //! surfaced as typed errors; they never unwind into the supervisor. Semantic containment //! (the guest returning *which* path to watch) stays the host's job — see //! [`WasmResolver::resolve_contained`]. +use crate::profile::ProfileDescriptor; +use serde::de::DeserializeOwned; use serde::Deserialize; use wasmtime::{ Config, Engine, Instance, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, Trap, @@ -166,6 +167,12 @@ impl WasmResolver { self.instantiate()?.resolve(uri, agent_dir) } + /// Read and validate one optional ABI v2 descriptor from a fresh instance. A module without a + /// `describe` export is a passive v1 resolver and remains compatible. + pub fn describe_once(&self) -> Result, WasmResolveError> { + self.instantiate()?.describe() + } + /// Resolve with semantic containment: the returned path must stay inside `agent_dir`. /// This is the boundary the wasm sandbox cannot enforce by itself — the guest chooses what /// string to return, so the host decides which strings it will act on. The returned root must @@ -400,6 +407,7 @@ fn normalize(path: &std::path::Path) -> std::path::PathBuf { struct GuestFuncs { alloc: TypedFunc, resolve: TypedFunc<(i32, i32, i32, i32), i64>, + describe: Option>, memory: Memory, } @@ -438,12 +446,30 @@ impl WasmInstance { let resolve = instance .get_typed_func::<(i32, i32, i32, i32), i64>(&mut store, "resolve") .map_err(|_| WasmResolveError::MissingExport("resolve"))?; + let describe = if instance.get_export(&mut store, "describe").is_some() { + Some( + instance + .get_typed_func::<(), i64>(&mut store, "describe") + .map_err(|_| { + WasmResolveError::BadReturn( + "`describe` export must have type () -> i64".to_owned(), + ) + })?, + ) + } else { + None + }; let memory = instance .get_memory(&mut store, "memory") .ok_or(WasmResolveError::MissingExport("memory"))?; Ok(Self { store, - funcs: GuestFuncs { alloc, resolve, memory }, + funcs: GuestFuncs { + alloc, + resolve, + describe, + memory, + }, fuel_per_call: resolver.fuel_per_call, first_call_uses_start_fuel: true, }) @@ -466,11 +492,7 @@ impl WasmInstance { uri: &str, agent_dir: &str, ) -> Result { - if self.first_call_uses_start_fuel { - self.first_call_uses_start_fuel = false; - } else { - self.charge_fuel()?; - } + self.begin_call()?; let uri_ptr = self.write_guest_bytes(uri.as_bytes())?; let dir_ptr = self.write_guest_bytes(agent_dir.as_bytes())?; @@ -482,7 +504,39 @@ impl WasmInstance { (uri_ptr, uri.len() as i32, dir_ptr, agent_dir.len() as i32), ) .map_err(|e| self.classify_call_error(e))?; + self.decode_packed_json(packed) + } + /// Read and validate the optional descriptor. Missing `describe` denotes a passive profile; + /// an exported descriptor must conform completely to ABI v2. + pub fn describe(&mut self) -> Result, WasmResolveError> { + let Some(describe) = self.funcs.describe.clone() else { + return Ok(None); + }; + self.begin_call()?; + let packed = describe + .call(&mut self.store, ()) + .map_err(|error| self.classify_call_error(error))?; + let descriptor: ProfileDescriptor = self.decode_packed_json(packed)?; + descriptor + .validate() + .map_err(|error| WasmResolveError::BadReturn(error.to_string()))?; + Ok(Some(descriptor)) + } + + fn begin_call(&mut self) -> Result<(), WasmResolveError> { + if self.first_call_uses_start_fuel { + self.first_call_uses_start_fuel = false; + Ok(()) + } else { + self.charge_fuel() + } + } + + fn decode_packed_json( + &self, + packed: i64, + ) -> Result { let ret_ptr = (packed >> 32) as u32 as usize; let ret_len = (packed as u32) as usize; if ret_len > DEFAULT_OUTPUT_LIMIT_BYTES { diff --git a/crates/agent-spec/src/spec.rs b/crates/agent-spec/src/spec.rs index 5130f744..3c7e6627 100644 --- a/crates/agent-spec/src/spec.rs +++ b/crates/agent-spec/src/spec.rs @@ -230,6 +230,15 @@ pub struct AgentSpec { pub path: PathBuf, } +fn deserialize_optional_selector<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + serde_json::Value::deserialize(deserializer).map(Some) +} + /// One agent-local semantic binding to an externally identified resource. /// /// `name` is an agent-local label and `uri` is the exact absolute identity. `reason` explains why @@ -242,6 +251,8 @@ pub struct Resource { reason: String, #[serde(skip_serializing_if = "Option::is_none")] inactive_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + selector: Option, } #[derive(Deserialize)] @@ -251,6 +262,8 @@ struct ResourceDescriptor { uri: String, reason: String, inactive_reason: Option, + #[serde(default, deserialize_with = "deserialize_optional_selector")] + selector: Option, } impl Resource { @@ -278,6 +291,7 @@ impl Resource { uri, reason, inactive_reason: None, + selector: None, }) } @@ -309,6 +323,15 @@ impl Resource { pub fn inactive_reason(&self) -> Option<&str> { self.inactive_reason.as_deref() } + + pub fn selector(&self) -> Option<&serde_json::Value> { + self.selector.as_ref() + } + + pub fn with_selector(mut self, selector: serde_json::Value) -> Self { + self.selector = Some(selector); + self + } } impl<'de> Deserialize<'de> for Resource { @@ -317,6 +340,7 @@ impl<'de> Deserialize<'de> for Resource { D: serde::Deserializer<'de>, { let descriptor = ResourceDescriptor::deserialize(deserializer)?; + let selector = descriptor.selector; let resource = match descriptor.inactive_reason { None => Self::new(descriptor.name, descriptor.uri, descriptor.reason), Some(inactive_reason) => Self::new_inactive( @@ -326,7 +350,12 @@ impl<'de> Deserialize<'de> for Resource { inactive_reason, ), }; - resource.map_err(de::Error::custom) + resource + .map(|resource| match selector { + Some(selector) => resource.with_selector(selector), + None => resource, + }) + .map_err(de::Error::custom) } } @@ -655,6 +684,8 @@ pub(crate) struct RawResource { pub(crate) uri: String, pub(crate) reason: String, pub(crate) inactive_reason: Option, + #[serde(default, deserialize_with = "deserialize_optional_selector")] + pub(crate) selector: Option, } #[derive(Debug, Default, Deserialize)] @@ -714,13 +745,18 @@ impl RawResources { self.0 .into_iter() .map(|(name, resource)| { - match resource.inactive_reason { + let selector = resource.selector; + let resource = match resource.inactive_reason { None => Resource::new(name, resource.uri, resource.reason), Some(inactive_reason) => { Resource::new_inactive(name, resource.uri, resource.reason, inactive_reason) } } - .map_err(anyhow::Error::msg) + .map_err(anyhow::Error::msg)?; + Ok(match selector { + Some(selector) => resource.with_selector(selector), + None => resource, + }) }) .collect() } diff --git a/crates/agent-spec/tests/discovery.rs b/crates/agent-spec/tests/discovery.rs index d30619c6..ef4678d2 100644 --- a/crates/agent-spec/tests/discovery.rs +++ b/crates/agent-spec/tests/discovery.rs @@ -1010,12 +1010,12 @@ fn named_resource_bindings_are_uri_identities_and_order_independent() { write( tmp.path(), "agents/h/kdl/agent.kdl", - r#"agent "kdl" { + r##"agent "kdl" { host "h" resource "source" uri="worktree://github.com/example/project/main" reason="Primary checkout." - resource "work" uri="github-issue://example/project/41" reason="Current implementation task." inactive-reason="Merged and retained for traceability." + resource "work" uri="github-issue://example/project/41" reason="Current implementation task." inactive-reason="Merged and retained for traceability." selector=#"{"topics":["ci.failure","review.requested"]}"# command "true" -}"#, +}"##, ); write( tmp.path(), @@ -1024,7 +1024,7 @@ fn named_resource_bindings_are_uri_identities_and_order_independent() { "identity": "json", "host": "h", "resource": { - "work": {"uri": "github-issue://example/project/41", "reason": "Current implementation task.", "inactive_reason": "Merged and retained for traceability."}, + "work": {"uri": "github-issue://example/project/41", "reason": "Current implementation task.", "inactive_reason": "Merged and retained for traceability.", "selector": {"topics": ["ci.failure", "review.requested"]}}, "source": {"uri": "worktree://github.com/example/project/main", "reason": "Primary checkout."} }, "command": "true" @@ -1041,6 +1041,7 @@ command = "true" uri = "github-issue://example/project/41" reason = "Current implementation task." inactive_reason = "Merged and retained for traceability." +selector = { topics = ["ci.failure", "review.requested"] } [resource.source] uri = "worktree://github.com/example/project/main" @@ -1063,7 +1064,10 @@ reason = "Primary checkout." "Current implementation task.".into(), "Merged and retained for traceability.".into(), ) - .unwrap(), + .unwrap() + .with_selector(serde_json::json!({ + "topics": ["ci.failure", "review.requested"] + })), ]; for identity in ["json", "kdl", "toml"] { assert_eq!(find(&found.specs, identity).resources, expected); @@ -1072,12 +1076,23 @@ reason = "Primary checkout." let json = serde_json::to_string(&expected).unwrap(); assert_eq!( json, - r#"[{"name":"source","uri":"worktree://github.com/example/project/main","reason":"Primary checkout."},{"name":"work","uri":"github-issue://example/project/41","reason":"Current implementation task.","inactive_reason":"Merged and retained for traceability."}]"# + r#"[{"name":"source","uri":"worktree://github.com/example/project/main","reason":"Primary checkout."},{"name":"work","uri":"github-issue://example/project/41","reason":"Current implementation task.","inactive_reason":"Merged and retained for traceability.","selector":{"topics":["ci.failure","review.requested"]}}]"# ); assert_eq!( serde_json::from_str::>(&json).unwrap(), expected ); + + let explicit_null = Resource::new( + "null-selector".into(), + "issue://one".into(), + "Null selector probe.".into(), + ) + .unwrap() + .with_selector(serde_json::Value::Null); + let json = serde_json::to_string(&explicit_null).unwrap(); + assert!(json.contains(r#""selector":null"#), "{json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), explicit_null); } #[test] diff --git a/crates/agent-spec/tests/profile_wasm.rs b/crates/agent-spec/tests/profile_wasm.rs index 2b400aeb..61aef4e1 100644 --- a/crates/agent-spec/tests/profile_wasm.rs +++ b/crates/agent-spec/tests/profile_wasm.rs @@ -36,6 +36,66 @@ const HOSTILE_PRELUDE: &str = r#" (memory (export "memory") 1) (func (export "alloc") (param i32) (result i32) (i32.const 1024)) "#; +fn wat_bytes(bytes: &[u8]) -> String { + bytes + .iter() + .map(|byte| format!(r"\{byte:02x}")) + .collect() +} + +fn descriptor_wat(payload: &[u8], reported_len: usize, resolution_path: &str) -> String { + const DESCRIPTOR_PTR: usize = 8; + const RESOLUTION_PTR: usize = 70_000; + let resolution = format!(r#"{{"path":"{resolution_path}"}}"#); + let descriptor_packed = ((DESCRIPTOR_PTR as u64) << 32) | reported_len as u64; + let resolution_packed = ((RESOLUTION_PTR as u64) << 32) | resolution.len() as u64; + format!( + r#"(module + (memory (export "memory") 2) + (func (export "alloc") (param i32) (result i32) (i32.const 120000)) + (data (i32.const {DESCRIPTOR_PTR}) "{}") + (data (i32.const {RESOLUTION_PTR}) "{}") + (func (export "describe") (result i64) (i64.const {descriptor_packed})) + (func (export "resolve") (param i32 i32 i32 i32) (result i64) + (i64.const {resolution_packed})) +)"#, + wat_bytes(payload), + wat_bytes(resolution.as_bytes()), + ) +} + +fn descriptor_module(payload: &[u8], reported_len: usize) -> WasmResolver { + WasmResolver::from_wat(&descriptor_wat( + payload, + reported_len, + "resources/current.json", + )) + .expect("descriptor module compiles") +} + +const VALID_DESCRIPTOR: &str = r#"{ + "abiVersion": 2, + "capabilities": ["resolve", "read", "observe"], + "selectorSchema": { + "type": "object", + "properties": { + "topics": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + } + }, + "required": ["topics"], + "additionalProperties": false + }, + "defaultSelector": { "topics": ["state.changed"] }, + "topics": [{ "name": "state.changed" }], + "runtime": { "topology": "perBinding" }, + "snapshot": { + "mediaType": "application/json", + "schemaId": "dev.example.state.v1" + } +}"#; fn current_rss_bytes() -> u64 { // /proc/self/status VmRSS is reported in kB. @@ -51,6 +111,98 @@ fn current_rss_bytes() -> u64 { .unwrap_or(0) } +#[test] +fn valid_v2_descriptor_executes_and_resolve_only_module_stays_passive() { + let descriptor = descriptor_module(VALID_DESCRIPTOR.as_bytes(), VALID_DESCRIPTOR.len()) + .describe_once() + .expect("describe call succeeds") + .expect("descriptor is present"); + assert_eq!(descriptor.abi_version, 2); + + assert!( + WasmResolver::load(Path::new(DEMO_WASM_PATH)) + .expect("passive resolver loads") + .describe_once() + .expect("missing describe is compatible") + .is_none() + ); + assert!( + demo_registry() + .try_descriptor("dev.schickling.agent-goal") + .expect("passive registry descriptor lookup succeeds") + .is_none() + ); +} + +#[test] +fn refresh_descriptor_lookup_and_resolution_share_one_module_snapshot() { + let directory = tempfile::tempdir().expect("temporary module directory"); + let path = directory.path().join("smart.wasm"); + std::fs::write( + &path, + descriptor_wat( + VALID_DESCRIPTOR.as_bytes(), + VALID_DESCRIPTOR.len(), + "resources/current.json", + ), + ) + .expect("module is written"); + + let registry = ResourceProfileRegistry::empty().with_profile(ResourceProfile::wasm( + "smart", + &path, + ProfileClass::Coalesced, + )); + let refresh = registry.begin_refresh(); + assert!(refresh + .try_descriptor("smart") + .expect("descriptor validates") + .is_some()); + std::fs::write( + &path, + descriptor_wat( + VALID_DESCRIPTOR.as_bytes(), + VALID_DESCRIPTOR.len(), + "resources/replaced.json", + ), + ) + .expect("module generation is replaced after descriptor lookup"); + assert_eq!( + refresh + .try_resolve(Path::new("/agent"), "smart://resource") + .expect("resolution succeeds") + .expect("registered URI resolves") + .path, + PathBuf::from("/agent/resources/current.json") + ); + assert_eq!( + registry + .try_resolve(Path::new("/agent"), "smart://resource") + .expect("a new refresh sees the replacement") + .expect("registered URI resolves") + .path, + PathBuf::from("/agent/resources/replaced.json") + ); +} + +#[test] +fn malformed_and_oversized_descriptors_are_contained() { + match descriptor_module(b"{not-json", 9).describe_once() { + Err(WasmResolveError::BadReturn(error)) => { + assert!(error.contains("key must be a string"), "got: {error}"); + } + other => panic!("expected malformed descriptor rejection, got {other:?}"), + } + + match descriptor_module(b"{}", DEFAULT_OUTPUT_LIMIT_BYTES + 1).describe_once() { + Err(WasmResolveError::BadReturn(error)) => { + assert!(error.contains("return payload"), "got: {error}"); + assert!(error.contains("limit"), "got: {error}"); + } + other => panic!("expected oversized descriptor rejection, got {other:?}"), + } +} + #[test] fn demo_module_resolves_through_the_registry_seam_with_its_declared_class() { let resolved = demo_registry() diff --git a/docs/vrs/.decisions/0014-resource-profiles-are-state-first-read-and-observe-capabilities.md b/docs/vrs/.decisions/0014-resource-profiles-are-state-first-read-and-observe-capabilities.md new file mode 100644 index 00000000..e15049e9 --- /dev/null +++ b/docs/vrs/.decisions/0014-resource-profiles-are-state-first-read-and-observe-capabilities.md @@ -0,0 +1,77 @@ +# Resource Profiles are state-first read-and-observe capabilities + +Status: accepted + +Johannes selected this direction through ten recorded interview rounds on 2026-08-29 and confirmed the combined design before VRS work began. Event-first authority remains a separate exploration in [issue #376](https://github.com/compoundingtech/st2/issues/376). + +## Context + +Resource Profiles previously mapped an opaque URI to one contained local carrier. Filesystem resync could notify a live agent after local bytes changed, but the profile had no way to observe a remote Resource, publish current state, validate profile-specific attention settings, report runtime health, or catch an agent up after delivery was unavailable. + +GitHub pull requests and issues made the gap concrete. Mergeability, CI, reviews, comments, and lifecycle state change independently. Raw webhook or check events can be duplicated, reordered, missed, or noisy. GitHub does not automatically redeliver failed webhook deliveries. Existing st2 stream ingress intentionally has bounded deduplication, and filesystem resync intentionally has no catch-up. Treating either delivery path as a complete canonical event log would claim guarantees that neither substrate provides. + +Two prototypes bounded the design. A synthetic 16-observation pull-request sequence showed that equal-state suppression removed only two candidate wakes, while provider-aware semantic filtering reduced the sequence to seven actionable or four blocking/terminal wakes. A Rust state-space model then checked 97,656 lifecycle sequences and found that storing a pending historical digest can point a resumed agent at stale state; one pending-relevance bit plus the current snapshot digest is sufficient. + +## Decision + +1. One atomic current snapshot per Resource binding is canonical. Provider events, polls, and webhooks are observations used to reconcile that snapshot. Notifications are invalidations, not a complete event log. +2. st2 owns a provider-neutral read-and-observe contract: descriptor execution, selector validation, runtime lifecycle, host-owned snapshot publication, health, bounded delivery, and current-state catch-up. Downstream profiles own URI semantics, provider authentication, provider observation, reconciliation, snapshot schema, semantic topics, and defaults. +3. The bounded profile module exports a versioned descriptor containing capabilities, selector schema, default selector, published topics, runtime topology, snapshot media type, and snapshot schema identity. A binding may provide validated selector configuration; omission uses the profile default. +4. The first contract exposes one atomic snapshot rather than named facets or a generation manifest. A changed snapshot emits a thin invalidation containing binding identity, current digest, and selected semantic topics. It carries no snapshot bytes or rendered summary. +5. The profile implementation chooses the most efficient provider-native observation mechanism. It may use push, polling, or a hybrid and retains any provider cursor or repair state. The closed resolver module gains no ambient network or credential imports. +6. The descriptor declares shared or per-binding runtime topology. Both use one normalized host protocol and the same per-binding delivery state. +7. When delivery is unavailable, st2 retains only `pending_relevant_change` beside current and last-delivered digests. Resume emits at most one invalidation for current state. +8. The initial capability set stops at read and observe. Provider mutations, actions, approvals, and a canonical event log require separate research and design. + +## Options + +| Option | Why it was not selected | +| --- | --- | +| Canonical Resource event log | Most principled long-term possibility, but current delivery substrates do not provide complete replay, ordering, retention, cursor recovery, or gap repair. Tracked separately in issue #376. | +| Hybrid snapshot plus several semantic streams | Provides transition fidelity and independent policies, but adds public stream contracts before notification-volume evidence proves that need. | +| Keep st2 resolver-only | Existing primitives can be composed downstream, but every profile would reimplement lifecycle, validation, health, catch-up, and publication. | +| One event or snapshot facet per semantic topic | Enables selective reads but introduces cross-facet consistency and generation lifecycle. One snapshot is sufficient for the first evidence-backed use case. | +| Deliver profile-rendered summaries | May save a read, but increases prompt noise and creates redaction/rendering obligations without measured token savings. | +| Preserve every update while delivery is unavailable | Produces a backlog that conflicts with state-first authority and the explicit noise constraint. | +| Standardize provider actions now | No concrete action workflow, authority model, approval contract, or idempotency prototype grounds a generic action API. | + +## Evidence and Argument + +- [GitHub attention-filter prototype](../07-resource-profile/.experiments/2026-08-29-github-attention-filter-prototype.md) +- [Smart Resource lifecycle state-space prototype](../07-resource-profile/.experiments/2026-08-29-smart-resource-lifecycle-prototype.md) +- [Existing stream differentiation experiment](../04-stream/.experiments/2026-08-20-pipes-event-model-differentiation.md) +- [Existing Resource Profile boundary comparison](../07-resource-profile/.experiments/2026-08-26-plugin-boundary-comparison.md) +- [GitHub webhook best practices](https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks) +- [GitHub webhook redelivery](https://docs.github.com/en/webhooks/testing-and-troubleshooting-webhooks/redelivering-webhooks) + +State-first authority makes delivery loss and coalescing safe: reconciliation repairs current state, and an invalidation only prompts a read. Provider-aware classification is necessary because byte equality cannot distinguish actionable CI failure from routine progress. Binding selectors preserve consumer control, while descriptor defaults avoid declaration noise. Host-owned publication prevents a network-capable runtime from bypassing containment or replacing the carrier through an unsafe filesystem path. + +The lifecycle prototype supplies the main complexity reduction. A pending notification is a level condition, not an event identity. If a relevant change occurred while delivery was unavailable, any later snapshot update advances the current digest. Resume therefore points at current state by combining one pending bit with the authoritative digest. Shared and per-binding runtime topology do not need separate delivery reducers. + +## Consequences + +- Resource Profile ABI evolution becomes immediate rather than hypothetical. Descriptor and host-protocol compatibility require conformance fixtures before third-party modules or runtimes are supported. +- Agent Spec needs one profile-specific selector encoding. Its exact KDL-to-schema representation remains open and blocks implementation of binding overrides, not profile defaults. +- st2 gains a trusted host-process boundary for observable runtimes. The catalog operator, not the wasm guest or Resource URI, selects the executable and its deployed credentials and egress. +- The existing built-in `resync` stream can carry smart Resource invalidations keyed by binding; no new stream family or delivery plane is required. +- Profile implementations may optimize aggressively for native real-time observation, but st2 judges only convergent publication and explicit health. It does not prescribe polling intervals or webhook repair. +- Actions and event-first authority remain out of scope rather than being smuggled into descriptor extensibility. + +## Amendment 1: selector encoding and runtime fencing + +On 2026-08-29 Johannes selected raw JSON in a KDL `selector` property (Q12) +after a runnable comparison of concise, nested, and adversarial selectors. +Normalized JSON remains the cross-format and runtime value. KDL is only an +authoring representation; its canonical renderer chooses the smallest safe raw +string hash fence. This resolves the open selector-encoding consequence above. + +The same experiment resolved runtime restart ownership without adding another +lifecycle protocol. Each process incarnation receives a directional owner +claim, each binding registration receives a token, and host acceptance requires +both to match. A new claim fences all prior output and clears registrations. +Shared and per-binding topologies use the same reducer. + +The normalized wire protocol contains only `register`, `unregister`, `publish`, +and `health`. EOF and existing supervisor lifecycle replace `shutdown`; +implementation-owned observation replaces host `reconcile`. Evidence: +[selector and runtime protocol prototype](../07-resource-profile/.experiments/2026-08-29-selector-and-runtime-protocol-prototype.md). diff --git a/docs/vrs/07-resource-profile/.experiments/2026-08-29-github-attention-filter-prototype.md b/docs/vrs/07-resource-profile/.experiments/2026-08-29-github-attention-filter-prototype.md new file mode 100644 index 00000000..278d2413 --- /dev/null +++ b/docs/vrs/07-resource-profile/.experiments/2026-08-29-github-attention-filter-prototype.md @@ -0,0 +1,45 @@ +# GitHub Resource attention-filter prototype + +Date: 2026-08-29 + +## Question + +How much notification noise can state-based filtering remove before delivery to an agent, and which policy choices remain semantic rather than mechanical? + +## Method + +A deterministic 16-observation sequence modeled one pull request moving through CI queue and execution states, duplicate provider observations, review activity, mergeability changes, a CI rerun, and merge. Four filters consumed the same sequence: + +1. deliver every provider observation; +2. deliver each change to a facet's current state; +3. deliver changes classified as actionable, including CI success; +4. deliver only blocking or terminal changes. + +The prototype counted candidate wakes. It did not call GitHub or an agent harness. + +## Evidence + +| Policy | Candidate wakes | Reduction | Delivered state classes | +| --- | ---: | ---: | --- | +| Raw observations | 16 | 0% | Every observation, including duplicates | +| State changes | 14 | 12.5% | Every distinct facet transition | +| Actionable changes | 7 | 56.25% | Review comment, CI failure/success, conflict, approval, merge | +| Blocking or terminal changes | 4 | 75% | CI failure, conflict, approval, merge | + +Equal-state suppression removed only two duplicate observations. Most noise reduction came from semantic classification. The difference between seven and four wakes depended on whether CI success and a new review comment merit attention. A generic transport cannot infer that from byte changes alone. + +## Result + +State reconciliation and equal-state suppression are necessary but insufficient noise controls. A provider-aware layer must classify semantic changes before st2 applies generic coalescing, supersession, and delivery bounds. The experiment does not establish whether profile defaults alone are sufficient or whether each Resource binding needs an override. + +## Conclusion + +Provider-aware semantic classification is the load-bearing noise control. st2 should own only the mechanical coalescing, supersession, and delivery bounds that do not require provider semantics. + +## Limits + +The sequence is synthetic and intentionally small. It establishes mechanism boundaries, not production thresholds. Real GitHub event volume, agent wake behavior, and token cost remain unmeasured. CI systems with many parallel checks will amplify the difference between raw and semantic policies. + +## VRS Impact + +The Resource Profile design must separate provider-aware semantic classification from st2-owned mechanical delivery bounds. The interview must still decide whether the profile's default classification is final or whether a Resource binding can narrow or widen it. diff --git a/docs/vrs/07-resource-profile/.experiments/2026-08-29-selector-and-runtime-protocol-prototype.md b/docs/vrs/07-resource-profile/.experiments/2026-08-29-selector-and-runtime-protocol-prototype.md new file mode 100644 index 00000000..3374992e --- /dev/null +++ b/docs/vrs/07-resource-profile/.experiments/2026-08-29-selector-and-runtime-protocol-prototype.md @@ -0,0 +1,67 @@ +# Resource selector and runtime protocol prototype + +Date: 2026-08-29 + +## Question + +Can the remaining selector-encoding and runtime-restart blockers be resolved with existing KDL, JSON, channel framing, and ownership-fencing patterns rather than introducing a KDL-to-JSON mapping or a second lifecycle model? + +## Method + +Three disposable Rust drivers used the repository's `kdl`, `serde`, and `serde_json` dependencies. + +The selector driver serialized normalized JSON, selected the smallest KDL raw-string hash fence that did not collide with the payload, parsed the generated Resource node with KDL 6, decoded the property as JSON, and compared the resulting value with the input. Inputs covered: + +- a concise GitHub topic selector; +- a nested non-GitHub path/event selector; +- an adversarial string containing one- and two-hash raw-string terminators. + +The runtime wire driver round-tripped length-bounded newline-delimited JSON shapes for `register`, `unregister`, `publish`, and `health`. Each message carried a runtime owner claim. Binding-scoped messages also carried a registration token. + +The ownership driver modeled runtime claims, binding registrations, publications, and restarts. It exhaustively enumerated every action sequence through depth seven over two runtime incarnations, two bindings, registration, and stale publication attempts. + +## Evidence + +All selector values round-tripped exactly. Representative canonical KDL was: + +```kdl +resource "work" uri="github-pr://example/1" reason="Review." selector=#"{"topics":["ci.failure","mergeability.conflict","review.requested"]}"# +``` + +The adversarial JSON automatically selected a three-hash fence: + +```kdl +selector=###"{"literal":"a\"#b\"##c"}"### +``` + +Dotfiles already has the matching generator precedent: `kdlRawStr context (builtins.toJSON value)` in `nixpkgs/st2/catalog.nix`. Normal quoted KDL strings are not viable there because `kdlStr` rejects embedded quotes and backslashes. + +The wire messages round-tripped as tagged JSON lines without a new framing format. The ownership model checked 335,923 sequences. A publication was accepted only when both conditions held: + +1. its `(incarnation, claim)` named the current runtime owner; +2. its registration token matched the current registration for that binding. + +A new runtime claim cleared registrations and fenced every prior process and binding token. Shared and per-binding topology used the same model; a per-binding runtime is a shared runtime with one registration. + +## Result + +Agent Spec KDL can encode the descriptor-validated selector as one raw JSON string property. The canonical renderer must choose the smallest safe raw-string hash fence. The in-memory and JSON/TOML projections retain the normalized JSON value, not its KDL spelling. + +The observable runtime protocol needs only four semantic messages: `register`, `unregister`, `publish`, and `health`. Process EOF is termination. The runtime owns observation and therefore needs no host `reconcile` command. Supervisor process lifecycle owns shutdown and therefore needs no protocol `shutdown` command. + +Every runtime-to-host message is fenced by the current runtime owner claim. Every binding-scoped message is additionally fenced by the current registration token. This reuses the directional ownership pattern from harness state and the JSON-line framing pattern from native harness channels. + +## Conclusion + +Two complexity reductions survive the prototypes: + +- use raw JSON in KDL instead of inventing a generic KDL-to-JSON type system; +- reduce the runtime protocol from six messages to four and use existing process EOF, supervision, and directional ownership rather than protocol-level shutdown, reconcile, or separate shared/per-binding reducers. + +## VRS Impact + +- Resolve DQ-P3 in favor of `selector=` with canonical minimum safe hash fencing. +- Resolve DQ-P6's ownership and topology question with one owner claim plus per-binding registration tokens. +- Remove `reconcile` and `shutdown` from the normative runtime protocol. +- Specify EOF as runtime termination and supervisor lifecycle as the only shutdown authority. +- Keep restart/backoff policy in existing task lifecycle machinery rather than the profile protocol. diff --git a/docs/vrs/07-resource-profile/.experiments/2026-08-29-smart-resource-lifecycle-prototype.md b/docs/vrs/07-resource-profile/.experiments/2026-08-29-smart-resource-lifecycle-prototype.md new file mode 100644 index 00000000..957979a9 --- /dev/null +++ b/docs/vrs/07-resource-profile/.experiments/2026-08-29-smart-resource-lifecycle-prototype.md @@ -0,0 +1,63 @@ +# Smart Resource lifecycle state-space prototype + +Date: 2026-08-29 + +## Question + +Can one small state model support profile defaults, validated binding selectors, atomic snapshots, thin invalidations, delivery unavailability, and one latest-state catch-up without an event ledger or per-transition backlog? + +## Method + +A disposable Rust model represented: + +- a descriptor with published topics and default topics; +- a binding with an optional selector; +- one canonical snapshot digest; +- last-delivered digest, delivery availability, and pending relevance; +- observations carrying a new digest and one semantic topic; +- thin invalidation effects. + +The driver exhaustively enumerated every sequence through depth seven over five actions: relevant failure, non-default success, relevant conflict, delivery unavailable, and delivery available. An independent oracle tracked expected current snapshot and pending relevance after every transition. A separate case proved that a selector naming an unpublished topic fails validation. + +## Evidence + +The first model stored a `pending_digest`. Enumeration found this shortest class of counterexample: + +```text +delivery unavailable +relevant observation at digest 1 +irrelevant observation at digest 2 +``` + +The binding retained pending digest 1 while canonical state had advanced to digest 2. Delivering digest 1 on resume would point the agent at a stale generation even though the relevance trigger remained valid. + +The corrected model stores only `pending_relevant_change: bool`. The current snapshot digest remains the single source of truth. If any relevant change occurred while delivery was unavailable, resume emits one invalidation for the then-current digest, including later irrelevant state changes. + +The corrected model exhaustively checked 97,656 action sequences. All snapshot, selector, and catch-up invariants held. + +## Result + +One compact per-binding state is sufficient: + +```text +currentSnapshotDigest: Digest? +lastDeliveredDigest: Digest? +pendingRelevantChange: boolean +deliverable: boolean +``` + +No pending digest, event backlog, provider cursor, or transition ledger is needed for st2 delivery semantics. Provider implementations may retain their own cursor when their native observation mechanism requires one. + +## Conclusion + +The state-first design becomes simpler when pending delivery is level-triggered. st2 records that relevant current state is unseen, not which historical event caused it. Resume always references the current canonical snapshot. + +The same lifecycle reducer is independent of shared versus per-binding runtime topology. Topology changes how observations arrive, not how a binding validates, publishes, filters, or catches up. + +## VRS Impact + +- Define one built-in Resource-update delivery path keyed by binding rather than profile-defined multiple streams. +- Store a pending-relevance bit and current snapshot digest, not a pending event or digest. +- Reuse existing event supersession and DING transport for thin invalidations. +- Keep provider cursors, webhook delivery identifiers, polling intervals, and observation repair inside the profile implementation. +- Specify shared and per-binding runtimes behind one normalized host protocol; do not duplicate delivery state machines. diff --git a/docs/vrs/07-resource-profile/open-questions.md b/docs/vrs/07-resource-profile/open-questions.md index 50ded65d..c6a57286 100644 --- a/docs/vrs/07-resource-profile/open-questions.md +++ b/docs/vrs/07-resource-profile/open-questions.md @@ -1,16 +1,32 @@ # Resource Profile open questions -The registry/SDK boundary (Q8), wasm-only foundation (Q10), and transactional -ownership of catalog-relative modules (Q14) are accepted and therefore are not -open questions. +The resolver registry, wasm-only pure module boundary, transactional ownership +of catalog-relative modules, and state-first read-and-observe direction are +accepted and therefore are not open questions. -- **DQ-P1 ABI compatibility.** The core-wasm ABI has three exports but no - version negotiation. Before independently released third-party modules need - compatibility guarantees, define old-guest/new-host and new-guest/old-host - behavior and prove it with a compatibility matrix. Tracked in the +DQ-P3 is resolved by the raw JSON `selector` property selected in Q12 and +proved by the selector round-trip prototype. DQ-P4 is resolved by treating +first readable state as a relevant state transition when the publication names +a selected topic. DQ-P5 is resolved by explicit 16 KiB selector, 2 MiB protocol +line, 1 MiB snapshot, and 16 KiB health-detail bounds; representative st2 issue +and pull payloads remained below 41 KiB per item, leaving substantial space for +normalized reviews and check state without permitting unbounded allocation. +DQ-P6 is resolved by one directional owner claim per runtime incarnation, one +token per binding registration, EOF-owned termination, and the shared ownership +reducer proved by the restart state-space prototype. Evidence for selector and +runtime ownership is in the +[selector and runtime protocol experiment](./.experiments/2026-08-29-selector-and-runtime-protocol-prototype.md). + +- **DQ-P1 ABI compatibility.** Descriptor ABI v2 makes version selection + explicit, but old-module/new-host, new-module/old-host, and runtime-protocol + compatibility are not yet proven. Resolve before independently released + third-party modules or runtimes with a compatibility matrix, frozen fixtures, + and cross-version conformance tests. Tracked in the [spec](./spec.md#design-questions). -- **DQ-P2 Runtime observability.** Reconciliation now reports a warning naming - each registered-profile binding that degraded to unwatchable, so the - production path is no longer silent. Resolve from dogfood evidence whether - low-volume spans/logs/metrics must further separate feature-disabled builds, - module defects, traps/fuel, malformed returns, and containment violations. +- **DQ-P2 Runtime observability.** The design separates descriptor, selector, + runtime, observation, publication, and delivery health, but has no dogfood + evidence for the minimum low-noise logs, spans, metrics, freshness display, + or operator commands. Resolve by operating one GitHub PR/issue profile and + proving that an operator can distinguish credential failure, provider outage, + runtime crash, invalid publication, stale snapshot, and undeliverable agent + without hot-path noise. diff --git a/docs/vrs/07-resource-profile/requirements.md b/docs/vrs/07-resource-profile/requirements.md index f797f9d9..7d1bbe37 100644 --- a/docs/vrs/07-resource-profile/requirements.md +++ b/docs/vrs/07-resource-profile/requirements.md @@ -15,6 +15,14 @@ transactional ownership of catalog-relative modules (decision Q14). The accepted rationale is recorded in [decision 0009](../.decisions/0009-resource-profiles-use-a-feature-gated-wasm-boundary.md). +On 2026-08-29 Johannes extended the subsystem from passive local resolution to +a generic read-and-observe lifecycle for remotely changing Resources. Ten +recorded interview rounds selected state-first authority, profile-defined +schemas and defaults with binding selectors, one atomic snapshot, thin +invalidations, implementation-selected observation and runtime topology, and +one latest-state catch-up. The direction is recorded in +[decision 0014](../.decisions/0014-resource-profiles-are-state-first-read-and-observe-capabilities.md). + ## Assumptions - **PROFILE-A01 Downstream scheme ownership:** URI schemes and their semantic @@ -26,6 +34,17 @@ accepted rationale is recorded in - **PROFILE-A03 Local denotation:** A successful profile resolution denotes a path inside the bound agent's directory. It does not grant authority over the URI, establish remote access, or change agent/task lifecycle semantics. +- **PROFILE-A04 State-first authority:** For an observable profile, one atomic + current snapshot is authoritative. Notifications are invalidations, not a + complete event log, and no consumer may require every provider transition. +- **PROFILE-A05 Downstream observation semantics:** A profile implementation + owns provider authentication, observation, reconciliation, semantic topics, + snapshot schema, and selector defaults. st2 owns only the generic lifecycle, + validation, publication, delivery, health, and containment contracts. +- **PROFILE-A06 Read-and-observe scope:** The first capability contract does + not mutate provider state or standardize actions. Comments, CI reruns, label + changes, close, merge, approval, and other provider writes require a separate + authority, approval, idempotency, audit, and result-delivery design. ## Acceptable Tradeoffs @@ -46,6 +65,18 @@ accepted rationale is recorded in already fenced by the transaction marker and recovery can remove the harmless superset; the catalog declaration must never point to a missing new catalog-owned module. +- **PROFILE-T05 Provider-native observation:** st2 does not require polling, + webhooks, or a hybrid. The profile implementation may use the most efficient + provider-native mechanism, accepting responsibility for convergence, + backpressure, rate limits, and any provider cursor or repair state. +- **PROFILE-T06 One snapshot rather than facets:** The first contract rewrites + one atomic profile-defined snapshot even when provider facets change + independently. This avoids generation manifests, facet consistency, and + retention machinery until measured payload or read costs justify them. +- **PROFILE-T07 Schema execution:** Discovering profile capabilities, selector + vocabulary, defaults, and validation requires executing the same bounded + module chosen by the catalog. This keeps the contract and implementation + atomic at the cost of making descriptor execution part of validation. ## Requirements @@ -129,9 +160,89 @@ accepted rationale is recorded in not change path containment or task launch, and an invalid supervisor chain is reported rather than silently approximated. +### Must describe and validate observable capabilities + +- **PROFILE-R12 Versioned profile descriptor:** A profile module exposes one + bounded, versioned descriptor in addition to resolution. The descriptor + declares supported capabilities, selector schema, semantic topic vocabulary, + default selector value, runtime topology, snapshot media type and schema + identity, and ABI version. The host validates the descriptor under the same + fuel, memory, output, import, and failure isolation as resolution. Unknown + required capabilities or ABI versions fail that profile locally. +- **PROFILE-R13 Validated binding selectors:** An observable Resource binding + may carry profile-specific selector configuration. Absence means the + descriptor's default. KDL encodes the value as compact JSON in a `selector` + raw-string property whose canonical renderer chooses the smallest safe hash + fence; JSON and TOML forms carry a native JSON value. All forms lower to one + normalized value that st2 validates against the descriptor before activating + observation. A selector may choose only profile-published topics; it changes + attention, never Resource URI identity, access authority, snapshot contents, + or provider observation. + +### Must publish one canonical current snapshot + +- **PROFILE-R14 Atomic snapshot authority:** Each active observable binding has + at most one profile-defined canonical current snapshot. Publication replaces + the snapshot atomically, records its content digest and schema identity, and + never exposes partial bytes. Equal-byte publication is a no-op. A first + successful publication with at least one selected topic schedules the same + superseding invalidation as a later relevant change. The snapshot remains the + authority after missed, duplicated, reordered, or coalesced provider + observations. +- **PROFILE-R15 Implementation-owned observation:** A profile implementation + chooses polling, push, native subscription, or a hybrid and may retain its own + provider cursor. st2 standardizes registration, atomic snapshot publication, + backpressure, cancellation, and health outcomes but does not send + observation-specific reconcile commands or prescribe the provider mechanism. + Provider payloads never bypass snapshot publication to become canonical + delivery records. +- **PROFILE-R16 Declared runtime topology:** The descriptor declares either one + shared runtime per catalog and exact scheme or one runtime per active binding. + Both modes use one host protocol and per-binding lifecycle state. Each runtime + incarnation receives a directional owner claim; each binding registration + receives a token. The host rejects output unless both still match current + state. EOF and existing supervisor process lifecycle own termination and + restart. Shared-runtime failure may affect observation for many bindings but + must report health per binding; per-binding failure remains local. +- **PROFILE-R16A Finite protocol and publication bounds:** A selector's + canonical compact JSON is at most 16 KiB. One encoded runtime-protocol line is + at most 2 MiB including its newline. Decoded snapshot bytes are at most 1 MiB. + Health detail is at most 16 KiB of UTF-8. st2 rejects an oversized value + without truncation and contains the failure to the affected runtime or + binding. + +### Must bound attention and catch up to current state + +- **PROFILE-R17 Semantic invalidation:** When snapshot bytes change, including + on the first successful publication, the profile classifies the change with + zero or more descriptor-published semantic topics. st2 applies the binding + selector before delivery. A selected change emits one thin invalidation + carrying binding identity, current snapshot digest, and selected topics. It + does not copy snapshot bytes or a profile-rendered summary into the event. +- **PROFILE-R18 Built-in superseding delivery:** Smart Resource invalidations + reuse one built-in per-agent delivery stream and the existing inbox, DING, + deduplication, and producer-side supersession machinery. The binding name is + the supersession key. Profiles do not create one stream per topic or a third + delivery plane. +- **PROFILE-R19 Level-triggered catch-up:** Snapshot reconciliation continues + while delivery is unavailable. Per binding, st2 retains the current snapshot + digest, the last-delivered digest, and one pending-relevance bit, not a + transition backlog or pending historical digest. When delivery becomes + available, a pending relevant change emits at most one invalidation for the + then-current snapshot digest. +- **PROFILE-R20 Observable health:** st2 reports descriptor, selector, + observation, reconciliation, publication, and delivery health separately. + Failure degrades only the affected profile runtime or binding, preserves the + last proven snapshot with explicit freshness, and never presents stale bytes + as newly observed state. + ## Evidence The mechanism choice and sandbox bounds are supported by the [plugin-boundary comparison](./.experiments/2026-08-26-plugin-boundary-comparison.md). Composition against the real Nix-generated standing-seat shape is supported by the [real-shape end-to-end experiment](./.experiments/2026-08-26-dotfiles-real-shape-e2e.md). +The state-first attention boundary is supported by the +[GitHub attention-filter prototype](./.experiments/2026-08-29-github-attention-filter-prototype.md). +The minimal catch-up state and topology-independent lifecycle are supported by +the [smart Resource lifecycle state-space prototype](./.experiments/2026-08-29-smart-resource-lifecycle-prototype.md). diff --git a/docs/vrs/07-resource-profile/spec.md b/docs/vrs/07-resource-profile/spec.md index 4c2f0a6a..8e0fa7fc 100644 --- a/docs/vrs/07-resource-profile/spec.md +++ b/docs/vrs/07-resource-profile/spec.md @@ -8,14 +8,15 @@ wasm execution contract. It builds on ## Scope -This subsystem owns scheme-to-resolver registration, the guest ABI, sandbox -budgets, host path containment, transactional ownership of catalog-relative -modules, and the handoff of resolved carriers to -[`06-resync`](../06-resync/spec.md). It does not own Resource URI semantics, -remote access, Agent Spec binding grammar, event delivery, or task lifecycle. -Those remain downstream profile concerns or existing root contracts. +This subsystem owns scheme-to-profile registration, the guest ABI, descriptor +and selector validation, sandbox budgets, host path containment, observable +runtime lifecycle, atomic snapshot publication, and the handoff of passive and +observable carriers to [`06-resync`](../06-resync/spec.md). It does not own +Resource URI semantics, provider authentication, provider observation strategy, +provider mutation, task launch, or a canonical provider event log. Those remain +downstream profile concerns or explicit non-goals. -## Architecture (PROFILE-R01..R11) +## Architecture (PROFILE-R01..R20) ```text Agent Spec resource URI (opaque, byte-preserved) @@ -40,6 +41,27 @@ Agent Spec resource URI (opaque, byte-preserved) resync watch set and existing event pipeline ``` +An observable profile extends the same contained carrier without changing URI +identity or introducing another delivery plane: + +```text +closed wasm describe() -> capabilities + selector schema/default + topology + | +catalog-trusted host runtime argv ----+ + | + v provider-native observation +publish(binding-id, bytes, topics) + | + v host validation + contained atomic replacement +canonical snapshot + current digest + | + v selector + pending-relevance reducer +built-in resync event (key=binding, supersede=true) + | + v existing inbox + DING +agent rereads canonical snapshot +``` + The SDK is a typed, trait-shaped boundary rather than a set of scheme-specific branches. Its concrete public surface is `ResourceProfile`, `ProfileSource`, `ResourceProfileRegistry`, `Resolution`, `WasmResolver`, `WasmInstance`, and @@ -287,7 +309,7 @@ For each active Resource binding, resync applies this precedence: 3. A schemeless path uses the existing agent-directory-relative rule. 4. Every other unregistered scheme remains opaque and unwatchable. -After a path enters the watch set, Resource Profiles add no event semantics. +For a passive resolved carrier, Resource Profiles add no event semantics. Parent-directory observation, rename replacement, digest seeding, equal-byte deduplication, deterministic transition identity, bounded windows, and built-in `resync` delivery remain the [`06-resync`](../06-resync/spec.md) pipeline. @@ -305,13 +327,244 @@ agent-local behavior above. Profile resolution is observation metadata only and never enters task launch targets. +## Observable profile descriptor (PROFILE-R12..R13) + +An observable profile retains the resolver ABI and adds one bounded descriptor +export. The descriptor is the single source of truth for the profile contract: + +```text +describe() -> packed(ptr, len) +``` + +The returned UTF-8 JSON uses the same 64 KiB output bound, pointer checks, +fresh-instance policy, fuel budget, and no-import rule as `resolve`: + +```json +{ + "abiVersion": 2, + "capabilities": ["resolve", "read", "observe"], + "selectorSchema": { + "type": "object", + "properties": { + "topics": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + } + }, + "additionalProperties": false + }, + "defaultSelector": { + "topics": ["ci.failure", "mergeability.conflict", "review.requested"] + }, + "topics": [ + { "name": "ci.failure" }, + { "name": "ci.success" }, + { "name": "mergeability.conflict" }, + { "name": "review.requested" } + ], + "runtime": { "topology": "shared" }, + "snapshot": { + "mediaType": "application/json", + "schemaId": "dev.example.github-pr.snapshot.v1" + } +} +``` + +`abiVersion` governs the complete descriptor and host protocol. Capabilities +are closed strings known by that ABI version; v2 accepts `resolve`, `read`, and +`observe`. `topics[].name` values are unique, non-empty profile-owned semantic +identifiers. `defaultSelector` must validate against `selectorSchema` and name +only published topics. A binding selector is validated against the same schema +and topic set before registration. Selector configuration is observation +metadata: it is not part of the Resource URI and cannot change resolution, +snapshot bytes, credentials, or provider access. + +Agent Spec KDL carries the normalized selector JSON as a `selector` raw-string +property on the Resource node: + +```kdl +resource "pr" uri="github-pr://example/1" reason="Review." \ + selector=#"{"topics":["ci.failure","review.requested"]}"# +``` + +The canonical renderer serializes normalized compact JSON and chooses the +smallest raw-string hash fence whose closing delimiter does not occur in the +payload. JSON and TOML Agent Spec forms carry the selector as a native JSON +value. All forms lower to the same `serde_json::Value`; KDL spelling is not +preserved and cannot change selector semantics. + +## Observable runtime declaration and protocol (PROFILE-R15..R16) + +The closed wasm module never receives network, credential, filesystem, process, +or clock imports. A profile with `observe` therefore also has one +catalog-trusted host runtime declaration: + +```kdl +profile "github-pr" { + wasm "resolvers/github-pr.wasm" + class "coalesced" + runtime { + argv "github-resource-runtime" "pr" + } +} +``` + +`runtime` is forbidden unless the descriptor declares `observe`, and +`observe` is unusable without `runtime`. The block accepts exactly one +non-empty `argv` child and never invokes a shell. The executable is an external +operator-trusted input; the guest cannot choose or rewrite it. Environment, +credentials, egress, and provider permissions belong to the downstream runtime +deployment and are not inferred from URI possession. + +The descriptor selects `shared` or `perBinding` topology. `shared` starts one +runtime for the exact `(catalog, scheme, profile generation)` and multiplexes +bindings. `perBinding` starts one instance for each active binding. A +per-binding runtime is the same protocol with one registration; topology does +not select another lifecycle model. + +Both modes speak the same versioned, newline-delimited JSON protocol over +supervisor-owned stdin/stdout: + +```text +host -> register { + owner: { incarnation, claim }, + bindingId, registration, uri, selector, carrierPath, previousDigest? +} +host -> unregister { + owner: { incarnation, claim }, + bindingId, registration +} + +runtime -> publish { + owner: { incarnation, claim }, + bindingId, registration, + schemaId, mediaType, bytes, topics, observedAt? +} +runtime -> health { + owner: { incarnation, claim }, + bindingId?, registration?, + state: starting|ready|degraded|failed, detail? +} +``` + +The supervisor assigns a fresh directional owner claim to every runtime +incarnation. A new claim atomically fences the prior process and clears its +binding registrations. Every `publish` and binding-scoped `health` is accepted +only when both the owner claim and the host-generated registration token match +current state. `bindingId` is an opaque incarnation-scoped address, never the +binding name or URI. + +EOF ends the runtime protocol. The supervisor's existing process lifecycle is +the only shutdown and restart authority; there is no protocol `shutdown` +message. Observation belongs to the implementation, so there is no host +`reconcile` message. The runtime begins or resumes provider-native observation +after `register` and may use `previousDigest` to avoid redundant publication. + +Each encoded protocol line is at most 2 MiB, including the newline. `publish` +encodes `bytes` as a padded RFC 4648 base64 string; the decoded snapshot is +opaque. Selectors are at most 16 KiB when encoded as canonical compact JSON. +Decoded snapshot bytes are at most 1 MiB. Health `detail` is at most 16 KiB of +UTF-8. These bounds are checked before allocation or decoding where the +transport permits and fail only the affected binding or runtime. st2 never +truncates canonical snapshot bytes or health text to satisfy a bound. + +The host rejects unknown bindings, stale owners or registrations, mismatched +schema or media type, unpublished topics, invalid messages, output after +unregister, and messages exceeding protocol bounds. A shared-runtime protocol +failure degrades every registered binding honestly but cannot publish across +schemes, profile generations, runtime incarnations, or binding registrations. + +Any provider cursor, webhook delivery identity, redelivery, polling interval, +rate-limit state, and repair strategy remain runtime-private. + +## Snapshot publication (PROFILE-R14) + +The resolver's contained carrier path is the observable snapshot path. A +successful `publish` follows one host-owned transaction: + +```text +validate binding + schema + topics + size + | + v +write new bytes to contained sibling temporary file + | + v fsync file + atomic rename + parent sync + | + v +compute/record current sha256 digest and freshness + | + `-> equal digest: no invalidation + changed digest: apply binding selector +``` + +The runtime never writes the carrier directly. Descriptor-relative no-follow +containment from the existing resolver contract applies to the temporary file, +final file, and replacement. Publication failure preserves the last proven +snapshot and marks freshness/health degraded. The first successful publication +is a state transition from unavailable to readable. If the publication carries +at least one selected topic, st2 schedules the same superseding invalidation as +for every later changed digest. This explicit first-publication wake prevents a +live agent from retaining an unreadable view after delayed startup or recovery. +Equal publications and publications without selected topics remain silent. + +Snapshot bytes are profile-defined and opaque to st2. `schemaId` and +`mediaType` make the bytes interpretable without making st2 own their semantics. +The first contract has one snapshot per binding: no named facets, generation +manifest, profile event log, or host retention policy. + +## Semantic invalidation and catch-up (PROFILE-R17..R20) + +For every changed digest, including the first successful publication, the host +intersects `publish.topics` with the normalized binding selector. An empty +intersection updates the canonical snapshot and freshness without scheduling +delivery. A non-empty intersection updates this bounded per-binding state: + +```text +current_snapshot_digest: Digest? +last_delivered_digest: Digest? +pending_relevant_change: bool +deliverable: bool +``` + +If delivery is available, st2 emits one event on the existing built-in +`resync` stream: + +```text +stream = resync +key = binding name +supersede = true +subject = resource changed +body = { binding, snapshotDigest, topics } +``` + +The body is a thin invalidation. It contains no snapshot bytes, provider +payload, rendered summary, credential, or provider cursor. Existing event +deduplication, inbox storage, DING rendering, and supersession apply unchanged. +Multiple topics for one atomic publication produce one invalidation, not one +stream or record per topic. + +If delivery is unavailable, a relevant publication sets +`pending_relevant_change = true`. Later irrelevant publications may advance +`current_snapshot_digest` but do not clear the bit. When delivery becomes +available, st2 emits at most one invalidation for the then-current digest and +clears the bit only after event ingress accepts the record. No pending digest +or transition backlog exists. This is level-triggered current-state catch-up, +not event replay. + +Health has separate descriptor, selector, runtime, observation, publication, +and delivery stages. Every stage reports affected scheme and binding without +including URI credentials or provider payloads. The last proven snapshot stays +readable with explicit freshness when observation fails; failure never relabels +old bytes as a newly observed snapshot. + ## Design questions -- **DQ-P1 ABI compatibility:** What explicit version negotiation replaces the - current unversioned three-export ABI before independently released third-party - modules need compatibility guarantees? Resolve with a compatibility matrix - and an old-guest/new-host conformance test. -- **DQ-P2 Runtime observability:** Which structured log/span/metric surface must - report registered-profile failures without making hot-path cache hits noisy? - Resolve by dogfood evidence that distinguishes module defects, hostile input, - and feature-disabled builds. +- **DQ-P1 ABI compatibility:** Prove descriptor and host-protocol compatibility + with frozen fixtures and cross-version conformance tests before third-party + implementations. +- **DQ-P2 Runtime observability:** Derive the minimum low-noise health, freshness, + log, span, metric, and operator surfaces from GitHub-profile dogfood. + +Resolved design questions and their evidence remain recorded in +[`open-questions.md`](./open-questions.md). diff --git a/docs/vrs/07-resource/spec.md b/docs/vrs/07-resource/spec.md index 826a4dde..4453c380 100644 --- a/docs/vrs/07-resource/spec.md +++ b/docs/vrs/07-resource/spec.md @@ -11,7 +11,8 @@ Terms are defined in [ontology.md](../ontology.md): [Resource](../ontology.md#re Decisions: [0011](../.decisions/0011-the-linked-record-plane-is-retired.md), [0012](../.decisions/0012-working-state-is-a-declared-carrier.md), -[0013](../.decisions/0013-resource-is-a-mediated-write-surface.md). +[0013](../.decisions/0013-resource-is-a-mediated-write-surface.md), and +[0014](../.decisions/0014-resource-profiles-are-state-first-read-and-observe-capabilities.md). ## The edge @@ -28,6 +29,20 @@ Resource's identity. `reason` is required prose saying why this agent carries it. `inactive-reason`, when present, retains a reference that is no longer current and explains why. +`selector`, when present, is profile-specific observation configuration. KDL +stores normalized compact JSON in a raw string: + +```kdl +resource "work" reason="PR this agent is preparing." \ + uri="github-pr://github.com/example/project/42" \ + selector=#"{"topics":["ci.failure","review.requested"]}"# +``` + +The canonical renderer chooses the smallest safe raw-string hash fence. JSON +and TOML Agent Spec forms carry the selector as a native JSON value. A selector +is not Resource identity or authority, and an observable profile must validate +it before registration; omission selects the profile default. + The URI is never normalized, and its scheme is the exact lookup key for an optional, catalog-declared Resource Profile ([`07-resource-profile`](../07-resource-profile/requirements.md), landed in @@ -43,7 +58,7 @@ healthy work ([R21](../requirements.md)). The Rust type is [`crates/agent-spec/src/spec.rs`](../../../crates/agent-spec/src/spec.rs) -`Resource { name, uri, reason, inactive_reason }`. +`Resource { name, uri, reason, inactive_reason, selector }`. ## Identity and realization @@ -75,7 +90,7 @@ Before it, 605 of 655 declarations on one live catalog had a ```text st2 resource ls [] [--json] st2 resource read [] [--json] -st2 resource add --uri --reason [--inactive-reason ] [--agent ] [--json] +st2 resource add --uri --reason [--inactive-reason ] [--selector-json ] [--agent ] [--json] st2 resource remove [--agent ] [--json] st2 resource rename [--agent ] [--json] ``` diff --git a/docs/vrs/ontology.md b/docs/vrs/ontology.md index ebdb9291..29c26c55 100644 --- a/docs/vrs/ontology.md +++ b/docs/vrs/ontology.md @@ -192,6 +192,48 @@ The canonical [Agent Spec Resource bindings](https://github.com/compoundingtech/ anchor still describes the pre-#307 envelope of name and `uri` only, and would reject the required `reason`; it is pending sync (07-resource DQ-R8). +### Resource snapshot + +The one atomic profile-defined representation of a Resource binding's current +observed state. Its bytes, media type, schema identity, content digest, and +freshness form the state-first read contract. Provider webhooks, polls, and +native subscriptions are observations used to reconcile the snapshot; none is +canonical by itself. + +Authority: [PROFILE-R14 atomic snapshot authority](07-resource-profile/requirements.md); +[decision 0014](.decisions/0014-resource-profiles-are-state-first-read-and-observe-capabilities.md) + +### Resource invalidation + +A thin, superseding notice that a binding's canonical +[Resource snapshot](#resource-snapshot) changed in a way selected for agent +attention. It carries the binding identity, current snapshot digest, and +semantic topics. It does not carry canonical snapshot bytes, a rendered +summary, or a complete provider transition. + +Authority: [PROFILE-R17 semantic invalidation](07-resource-profile/requirements.md); +[Resource Profile spec](07-resource-profile/spec.md#semantic-invalidation-and-catch-up-profile-r17r20) + +### semantic topic + +A profile-owned stable identifier that classifies why a Resource snapshot +changed, such as `ci.failure` or `mergeability.conflict`. The profile descriptor +publishes the vocabulary and defaults. A Resource binding selector can choose +from that vocabulary but cannot create topics or change provider authority. + +Authority: [PROFILE-R12 versioned profile descriptor](07-resource-profile/requirements.md); +[PROFILE-R13 validated binding selectors](07-resource-profile/requirements.md) + +### pending relevance + +The level-triggered fact that at least one selected Resource snapshot change has +not been delivered while delivery was unavailable. It is one boolean beside +the current and last-delivered digests, not a pending event, historical digest, +cursor, or backlog. Resume invalidates the then-current snapshot. + +Authority: [PROFILE-R19 level-triggered catch-up](07-resource-profile/requirements.md); +[lifecycle prototype](07-resource-profile/.experiments/2026-08-29-smart-resource-lifecycle-prototype.md) + ### linked record (retired) An agent-owned record of something an agent produced, stored as one markdown diff --git a/flake.nix b/flake.nix index 77f3d5fe..f7813bc5 100644 --- a/flake.nix +++ b/flake.nix @@ -174,6 +174,8 @@ "resync_notify_chain" "--test" "profile_wasm" + "--test" + "resource_profile_supervisor_e2e" ]; }); diff --git a/src/agent_author.rs b/src/agent_author.rs index 5152a8ec..566d757b 100644 --- a/src/agent_author.rs +++ b/src/agent_author.rs @@ -144,6 +144,8 @@ pub struct ResourceAddReceipt { pub uri: String, pub reason: String, pub inactive_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub selector: Option, } /// Stable machine-readable receipt from removing one Resource binding. @@ -358,6 +360,31 @@ pub fn add_resource( uri: &str, reason: &str, inactive_reason: Option<&str>, +) -> Result { + add_resource_with_selector( + catalog_root, + selector, + this_host, + actor, + name, + uri, + reason, + inactive_reason, + None, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn add_resource_with_selector( + catalog_root: &Path, + selector: &str, + this_host: &str, + actor: Option<&str>, + name: &str, + uri: &str, + reason: &str, + inactive_reason: Option<&str>, + resource_selector: Option<&serde_json::Value>, ) -> Result { author_resource( catalog_root, @@ -369,6 +396,7 @@ pub fn add_resource( uri, reason, inactive_reason, + selector: resource_selector, }, ) .map(|(result, identity)| ResourceAddReceipt { @@ -378,6 +406,7 @@ pub fn add_resource( uri: uri.to_owned(), reason: reason.to_owned(), inactive_reason: inactive_reason.map(str::to_owned), + selector: resource_selector.cloned(), }) } @@ -438,6 +467,7 @@ enum ResourceIntent<'a> { uri: &'a str, reason: &'a str, inactive_reason: Option<&'a str>, + selector: Option<&'a serde_json::Value>, }, Remove { name: &'a str, @@ -1168,8 +1198,9 @@ fn resource_edit( uri, reason, inactive_reason, + selector, } => { - let authored = declared_resource(name, uri, reason, inactive_reason)?; + let authored = declared_resource(name, uri, reason, inactive_reason, selector)?; let replacement = match declaring(name)? { Some(node) if parsed_resource(node)? == authored => return Ok(None), Some(node) => replace_node(text, node, &render_resource(&authored)?)?, @@ -1219,6 +1250,7 @@ fn resource_edit( carried.uri(), carried.reason(), carried.inactive_reason(), + carried.selector(), )?; Ok(Some(( replace_node(text, node, &render_resource(&renamed)?)?, @@ -1237,8 +1269,9 @@ fn declared_resource( uri: &str, reason: &str, inactive_reason: Option<&str>, + selector: Option<&serde_json::Value>, ) -> Result { - match inactive_reason { + let resource = match inactive_reason { None => Resource::new(name.to_owned(), uri.to_owned(), reason.to_owned()), Some(inactive_reason) => Resource::new_inactive( name.to_owned(), @@ -1247,7 +1280,11 @@ fn declared_resource( inactive_reason.to_owned(), ), } - .map_err(|error| AuthorError::new("invalid-resource", error)) + .map_err(|error| AuthorError::new("invalid-resource", error))?; + Ok(match selector { + Some(selector) => resource.with_selector(selector.clone()), + None => resource, + }) } fn parsed_resource(node: &KdlNode) -> Result { @@ -1260,26 +1297,48 @@ fn parsed_resource(node: &KdlNode) -> Result { let mut uri = None; let mut reason = None; let mut inactive_reason = None; + let mut selector = None; for entry in node.entries() { let value = entry .value() .as_string() .ok_or_else(|| malformed("accepts only string values"))?; - let slot = match entry.name().map(|name| name.value()) { - None => &mut name, - Some("uri") => &mut uri, - Some("reason") => &mut reason, - Some("inactive-reason") => &mut inactive_reason, + match entry.name().map(|name| name.value()) { + None => { + if name.replace(value).is_some() { + return Err(malformed("declares one of its fields more than once")); + } + } + Some("uri") => { + if uri.replace(value).is_some() { + return Err(malformed("declares one of its fields more than once")); + } + } + Some("reason") => { + if reason.replace(value).is_some() { + return Err(malformed("declares one of its fields more than once")); + } + } + Some("inactive-reason") => { + if inactive_reason.replace(value).is_some() { + return Err(malformed("declares one of its fields more than once")); + } + } + Some("selector") => { + if selector.is_some() { + return Err(malformed("declares one of its fields more than once")); + } + selector = Some(serde_json::from_str(value).map_err(|error| { + malformed(&format!("has invalid JSON `selector`: {error}")) + })?); + } Some(other) => return Err(malformed(&format!("has unsupported property `{other}`"))), - }; - if slot.replace(value).is_some() { - return Err(malformed("declares one of its fields more than once")); } } let (Some(name), Some(uri), Some(reason)) = (name, uri, reason) else { return Err(malformed("needs a name, a `uri`, and a `reason`")); }; - declared_resource(name, uri, reason, inactive_reason) + declared_resource(name, uri, reason, inactive_reason, selector.as_ref()) } fn render_resource(resource: &Resource) -> Result { @@ -1292,9 +1351,29 @@ fn render_resource(resource: &Resource) -> Result { if let Some(inactive_reason) = resource.inactive_reason() { authored.push_str(&format!(" inactive-reason={}", quoted(inactive_reason)?)); } + if let Some(selector) = resource.selector() { + authored.push_str(" selector="); + authored.push_str(&raw_json(selector)?); + } Ok(authored) } +fn raw_json(value: &serde_json::Value) -> Result { + let json = serde_json::to_string(value).map_err(|error| { + AuthorError::new( + "invalid-resource", + format!("serialize Resource selector as canonical JSON: {error}"), + ) + })?; + for hashes in 1..=json.len() + 1 { + let fence = "#".repeat(hashes); + if !json.contains(&format!("\"{fence}")) { + return Ok(format!("{fence}\"{json}\"{fence}")); + } + } + unreachable!("a delimiter longer than the JSON payload cannot occur in the payload") +} + /// Replace exactly one node's source span. A KDL node span carries neither the leading trivia nor /// the trailing terminator, so the surrounding line survives untouched. fn replace_node(text: &str, node: &KdlNode, authored: &str) -> Result { diff --git a/src/agents.rs b/src/agents.rs index 63b38adf..8347ed85 100644 --- a/src/agents.rs +++ b/src/agents.rs @@ -224,6 +224,8 @@ struct ResourceJson<'a> { reason: &'a str, #[serde(skip_serializing_if = "Option::is_none")] inactive_reason: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + selector: Option<&'a serde_json::Value>, resync: &'static str, } @@ -236,6 +238,7 @@ fn resource_json(row: &AgentRow) -> Vec> { uri: resource.uri(), reason: resource.reason(), inactive_reason: resource.inactive_reason(), + selector: resource.selector(), resync: coverage.as_str(), }) .collect() diff --git a/src/catalog.rs b/src/catalog.rs index 9de7e1e7..9be05283 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -26,11 +26,13 @@ use std::path::{Component, Path, PathBuf}; use anyhow::Context as _; use agent_spec::profile::{ProfileClass, ResourceProfile, ResourceProfileRegistry}; +#[cfg(feature = "wasm-resolver")] +use agent_spec::profile::ProfileCapability; use kdl::KdlDocument; /// The catalog-level declaration, read from the catalog root. pub const CONFIG_FILE: &str = "catalog.kdl"; -/// One declared resource profile: `profile "" { wasm "" class "..." }`. +/// One declared resource profile: `profile "" { wasm "" runtime { argv "..." } }`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeclaredProfile { /// The URI scheme this profile resolves. @@ -42,6 +44,14 @@ pub struct DeclaredProfile { /// Whether a binding through this profile also subscribes to its ancestors' same-scheme /// carriers; defaults to off. pub notify_chain: bool, + /// Trusted direct process invocation for an observable profile. + pub runtime: Option, +} + +/// The closed host-runtime declaration. `argv[0]` is executed directly; no shell is involved. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeclaredProfileRuntime { + pub argv: Vec, } /// What `/catalog.kdl` declares. An absent file leaves every field empty. @@ -164,16 +174,57 @@ fn parse_profile(node: &kdl::KdlNode) -> anyhow::Result { let mut seen_class = false; let mut notify_chain = false; let mut seen_notify_chain = false; + let mut runtime = None; for child in children.nodes() { + if child.name().value() == "runtime" { + if runtime.is_some() { + anyhow::bail!("profile '{scheme}' declares runtime more than once"); + } + if !child.entries().is_empty() { + anyhow::bail!("profile '{scheme}': runtime takes no values or properties"); + } + let runtime_children = child.children().ok_or_else(|| { + anyhow::anyhow!("profile '{scheme}': runtime needs exactly one argv child") + })?; + if runtime_children.nodes().len() != 1 + || runtime_children.nodes()[0].name().value() != "argv" + { + anyhow::bail!( + "profile '{scheme}': runtime accepts exactly one argv child" + ); + } + let argv_node = &runtime_children.nodes()[0]; + if argv_node.children().is_some() || argv_node.entries().is_empty() { + anyhow::bail!( + "profile '{scheme}': runtime argv needs one or more non-empty quoted arguments" + ); + } + let argv = argv_node + .entries() + .iter() + .map(|entry| { + (entry.name().is_none()) + .then(|| entry.value().as_string()) + .flatten() + .filter(|value| !value.is_empty()) + .map(str::to_owned) + }) + .collect::>>() + .ok_or_else(|| { + anyhow::anyhow!( + "profile '{scheme}': runtime argv accepts only non-empty quoted arguments" + ) + })?; + runtime = Some(DeclaredProfileRuntime { argv }); + continue; + } if child.children().is_some() { anyhow::bail!( "profile '{scheme}': '{}' does not accept a child block", child.name().value() ); } - // KDL folds `wasm "a.wasm" class "immediate"` written without separators into ONE node - // with extra positional entries — reject anything beyond the single expected argument - // so a run-on line fails loudly instead of parsing as something else. + // KDL folds value fields written without separators into one node. Reject extra entries. if child.entries().len() != 1 { anyhow::bail!( "profile '{scheme}': '{}' takes exactly one quoted value", @@ -222,7 +273,7 @@ fn parse_profile(node: &kdl::KdlNode) -> anyhow::Result { } other => anyhow::bail!( "unknown profile field '{other}' in profile '{scheme}' \ - (expected wasm, class, or notify-chain)" + (expected wasm, class, notify-chain, or runtime)" ), } } @@ -234,6 +285,7 @@ fn parse_profile(node: &kdl::KdlNode) -> anyhow::Result { wasm, class, notify_chain, + runtime, }) } @@ -376,27 +428,25 @@ pub fn pty_root(catalog_root: &Path) -> PathBuf { } } -/// The resource profiles the CATALOG itself declares, as an injectable registry for the resync -/// supervisor. Relative `wasm` paths anchor at the catalog root; `$CATALOG`/`$VAR` expand like -/// every catalog-anchored declaration. -/// -/// Unlike [`pty_root`], a malformed declaration is an ERROR here, not a fallback: profile blocks -/// gate watchability of agent resources, and silently dropping one would hide the misconfiguration -/// behind "nothing fires". `st2 up` surfaces this before spawning; `st2 validate` reports it. -pub fn declared_profiles(catalog_root: &Path) -> anyhow::Result { +/// Load the catalog profile declaration and construct its registry from one coherent caller-held +/// catalog read fence. Descriptor/runtime compatibility is checked here so `st2 up` cannot silently +/// start a profile with half of the observable contract. +pub fn declared_profile_catalog( + catalog_root: &Path, +) -> anyhow::Result<(CatalogConfig, ResourceProfileRegistry)> { let config = load(catalog_root)?; let absolute_root = if catalog_root.is_absolute() { lexical_absolute(catalog_root)? } else { lexical_absolute(&std::env::current_dir()?.join(catalog_root))? }; - config.profiles.into_iter().try_fold( + let registry = config.profiles.iter().try_fold( ResourceProfileRegistry::empty(), |registry, declared| -> anyhow::Result { let profile = match resolve_profile_module(&absolute_root, &declared.wasm)? { ResolvedProfileModule::CatalogRelative(relative) => { ResourceProfile::wasm_contained( - declared.scheme, + declared.scheme.clone(), &absolute_root, relative, declared.class, @@ -404,13 +454,112 @@ pub fn declared_profiles(catalog_root: &Path) -> anyhow::Result { - ResourceProfile::wasm(declared.scheme, module, declared.class) + ResourceProfile::wasm(declared.scheme.clone(), module, declared.class) .with_notify_chain(declared.notify_chain) } }; Ok(registry.with_profile(profile)) }, - ) + )?; + validate_runtime_contracts(&config, ®istry)?; + Ok((config, registry)) +} + +/// The resource profiles declared by this catalog. +pub fn declared_profiles(catalog_root: &Path) -> anyhow::Result { + declared_profile_catalog(catalog_root).map(|(_, registry)| registry) +} + +/// Build the registry passed to passive resync. Observable carriers are supervisor-authored +/// snapshots and must never also be watched as ordinary filesystem carriers. +pub fn passive_profiles( + config: &CatalogConfig, + registry: &ResourceProfileRegistry, +) -> anyhow::Result { + #[cfg(not(feature = "wasm-resolver"))] + { + let _ = config; + return Ok(registry.clone()); + } + #[cfg(feature = "wasm-resolver")] + let refresh = registry.begin_refresh(); + #[cfg(feature = "wasm-resolver")] + { + config.profiles.iter().try_fold( + ResourceProfileRegistry::empty(), + |passive, declared| { + let observable = refresh + .try_descriptor(&declared.scheme) + .ok() + .flatten() + .is_some_and(|descriptor| { + descriptor.capabilities.contains(&ProfileCapability::Observe) + }); + if observable { + Ok(passive) + } else { + Ok(passive.with_profile( + registry + .get(&declared.scheme) + .expect("registry was built from this declaration") + .clone(), + )) + } + }, + ) + } +} + +fn validate_runtime_contracts( + config: &CatalogConfig, + registry: &ResourceProfileRegistry, +) -> anyhow::Result<()> { + #[cfg(not(feature = "wasm-resolver"))] + { + if let Some(profile) = config.profiles.iter().find(|profile| profile.runtime.is_some()) { + anyhow::bail!( + "profile '{}': observable runtime unavailable because st2 was built without the `wasm-resolver` feature", + profile.scheme + ); + } + let _ = registry; + return Ok(()); + } + #[cfg(feature = "wasm-resolver")] + { + let refresh = registry.begin_refresh(); + for profile in &config.profiles { + let descriptor = match refresh.try_descriptor(&profile.scheme) { + Ok(descriptor) => descriptor, + Err(error) if profile.runtime.is_none() => { + // Passive resolver failures remain binding-local. Requiring every legacy + // module to instantiate during catalog admission would turn one unwatchable + // Resource into a catalog-wide supervisor outage. + let _ = error; + continue; + } + Err(error) => { + return Err(anyhow::Error::msg(error)) + .with_context(|| format!("profile '{}': describe", profile.scheme)); + } + }; + let observes = descriptor.as_ref().is_some_and(|descriptor| { + descriptor.capabilities.contains(&ProfileCapability::Observe) + }); + match (observes, profile.runtime.is_some()) { + (true, false) => anyhow::bail!( + "profile '{}': descriptor declares observe but catalog runtime is missing", + profile.scheme + ), + (false, true) => anyhow::bail!( + "profile '{}': catalog runtime is forbidden unless descriptor declares observe", + profile.scheme + ), + _ => {} + } + } + Ok(()) + } } #[cfg(test)] @@ -518,17 +667,52 @@ mod tests { wasm: "resolvers/goal.wasm".into(), class: ProfileClass::Coalesced, notify_chain: false, + runtime: None, }, DeclaredProfile { scheme: "dev.example.tree".into(), wasm: "/abs/resolvers/tree.wasm".into(), class: ProfileClass::Silent, notify_chain: false, + runtime: None, }, ] ); } + #[test] + fn runtime_grammar_is_closed_and_argv_is_direct() { + let config = parse( + r#" + profile "dev.example.observe" { + wasm "observe.wasm" + runtime { + argv "github-resource-runtime" "pr" + } + } + "#, + ) + .unwrap(); + assert_eq!( + config.profiles[0].runtime, + Some(DeclaredProfileRuntime { + argv: vec!["github-resource-runtime".into(), "pr".into()], + }) + ); + for malformed in [ + r#"profile "dev.x" { wasm "x.wasm" runtime }"#, + r#"profile "dev.x" { wasm "x.wasm" runtime "shell" { argv "x" } }"#, + r#"profile "dev.x" { wasm "x.wasm" runtime { } }"#, + r#"profile "dev.x" { wasm "x.wasm" runtime { argv } }"#, + r#"profile "dev.x" { wasm "x.wasm" runtime { argv "" } }"#, + r#"profile "dev.x" { wasm "x.wasm" runtime { argv "x" argv "y" } }"#, + r#"profile "dev.x" { wasm "x.wasm" runtime { command "x" } }"#, + r#"profile "dev.x" { wasm "x.wasm" runtime { argv "x" } runtime { argv "y" } }"#, + ] { + assert!(parse(malformed).is_err(), "expected error for: {malformed}"); + } + } + #[test] fn malformed_profile_blocks_fail_validation_loudly() { let loud = [ diff --git a/src/catalog_transaction.rs b/src/catalog_transaction.rs index 2fe51229..23f88025 100644 --- a/src/catalog_transaction.rs +++ b/src/catalog_transaction.rs @@ -782,6 +782,17 @@ fn normalize_agent(spec: &agent_spec::AgentSpec) -> Result, + /// Profile-specific observation selector as JSON. + #[arg(long = "selector-json", value_name = "JSON")] + selector_json: Option, /// Exact target agent; defaults to --as / $ST_AGENT. #[arg(long)] agent: Option, @@ -3276,6 +3279,9 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { if let Some(inactive_reason) = binding.inactive_reason() { println!("{:<17}{}", "inactive-reason:", inactive_reason); } + if let Some(selector) = binding.selector() { + println!("{:<17}{}", "selector:", serde_json::to_string(selector)?); + } Ok(()) } ResourceCmd::Add { @@ -3283,12 +3289,18 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { uri, reason, inactive_reason, + selector_json, agent, json, ctx, } => { + let selector = selector_json + .as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|error| anyhow::anyhow!("--selector-json is not valid JSON: {error}"))?; let (root, host, actor, target) = resource_author_target(agent, &ctx)?; - let receipt = st2::agent_author::add_resource( + let receipt = st2::agent_author::add_resource_with_selector( &root, &target, &host, @@ -3297,6 +3309,7 @@ fn resource_cmd(cmd: ResourceCmd) -> Result<()> { &uri, &reason, inactive_reason.as_deref(), + selector.as_ref(), )?; if json { println!("{}", serde_json::to_string(&receipt)?); diff --git a/src/resource_profile.rs b/src/resource_profile.rs new file mode 100644 index 00000000..d171d710 --- /dev/null +++ b/src/resource_profile.rs @@ -0,0 +1,2153 @@ +//! Observable Resource Profile runtime protocol, ownership, publication, and catch-up core. +//! +//! This module deliberately stops at the supervisor integration boundary: it does not spawn a +//! runtime or enqueue resync records. It validates and fences runtime output, publishes the one +//! canonical snapshot, and exposes level-triggered delivery work for the existing event ingress. + +use std::collections::{BTreeSet, HashMap}; +use std::ffi::{CString, OsStr}; +use std::fmt; +use std::fs::File; +use std::io::{self, Read, Write}; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::os::unix::ffi::OsStrExt as _; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; +use sha2::{Digest as _, Sha256}; + +pub const MAX_PROTOCOL_LINE_BYTES: usize = 2 * 1024 * 1024; +pub const MAX_SNAPSHOT_BYTES: usize = 1024 * 1024; +pub const MAX_SELECTOR_BYTES: usize = 16 * 1024; +pub const MAX_HEALTH_DETAIL_BYTES: usize = 16 * 1024; +const MAX_OPAQUE_ID_BYTES: usize = 16 * 1024; +const MAX_CATCH_UP_FILE_BYTES: usize = 16 * 1024; +const CATCH_UP_FILE: &str = "resource-profile-catch-up.json"; +const PUBLICATION_INTENT_FILE: &str = "resource-profile-publication-intent.json"; +static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpaqueIdError { + kind: &'static str, + reason: &'static str, +} + +impl fmt::Display for OpaqueIdError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{} {}", self.kind, self.reason) + } +} + +impl std::error::Error for OpaqueIdError {} + +macro_rules! opaque_id { + ($name:ident, $kind:literal) => { + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] + #[serde(transparent)] + pub struct $name(String); + + impl $name { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(OpaqueIdError { + kind: $kind, + reason: "must not be empty", + }); + } + if value.len() > MAX_OPAQUE_ID_BYTES { + return Err(OpaqueIdError { + kind: $kind, + reason: "is too large", + }); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(de::Error::custom) + } + } + }; +} + +opaque_id!(RuntimeIncarnation, "runtime incarnation"); +opaque_id!(OwnerClaim, "owner claim"); +opaque_id!(BindingId, "binding id"); +opaque_id!(RegistrationToken, "registration token"); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeOwner { + incarnation: RuntimeIncarnation, + claim: OwnerClaim, +} + +impl RuntimeOwner { + pub fn new(incarnation: RuntimeIncarnation, claim: OwnerClaim) -> Self { + Self { incarnation, claim } + } + + pub fn incarnation(&self) -> &RuntimeIncarnation { + &self.incarnation + } + + pub fn claim(&self) -> &OwnerClaim { + &self.claim + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct SnapshotBytes(Vec); + +impl fmt::Debug for SnapshotBytes { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SnapshotBytes") + .field("len", &self.0.len()) + .finish() + } +} + +impl SnapshotBytes { + pub fn new(bytes: Vec) -> Result { + if bytes.len() > MAX_SNAPSHOT_BYTES { + return Err(SnapshotSizeError { actual: bytes.len() }); + } + Ok(Self(bytes)) + } + + pub fn as_slice(&self) -> &[u8] { + &self.0 + } + + pub fn into_vec(self) -> Vec { + self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SnapshotSizeError { + pub actual: usize, +} + +impl fmt::Display for SnapshotSizeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "decoded snapshot is {} bytes; maximum is {MAX_SNAPSHOT_BYTES}", + self.actual + ) + } +} + +impl std::error::Error for SnapshotSizeError {} + +impl Serialize for SnapshotBytes { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&encode_base64(&self.0)) + } +} + +impl<'de> Deserialize<'de> for SnapshotBytes { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct SnapshotBytesVisitor; + + impl Visitor<'_> for SnapshotBytesVisitor { + type Value = SnapshotBytes; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an RFC 4648 padded base64 snapshot") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + let bytes = decode_base64(value).map_err(E::custom)?; + SnapshotBytes::new(bytes).map_err(E::custom) + } + } + + deserializer.deserialize_str(SnapshotBytesVisitor) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Base64Error(&'static str); + +impl fmt::Display for Base64Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.0) + } +} + +fn encode_base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let first = chunk[0]; + let second = chunk.get(1).copied().unwrap_or(0); + let third = chunk.get(2).copied().unwrap_or(0); + encoded.push(ALPHABET[(first >> 2) as usize] as char); + encoded.push(ALPHABET[(((first & 0x03) << 4) | (second >> 4)) as usize] as char); + if chunk.len() > 1 { + encoded.push(ALPHABET[(((second & 0x0f) << 2) | (third >> 6)) as usize] as char); + } else { + encoded.push('='); + } + if chunk.len() > 2 { + encoded.push(ALPHABET[(third & 0x3f) as usize] as char); + } else { + encoded.push('='); + } + } + encoded +} + +fn decode_base64(encoded: &str) -> Result, Base64Error> { + if encoded.len() % 4 != 0 { + return Err(Base64Error("base64 length is not a multiple of four")); + } + let maximum_encoded = MAX_SNAPSHOT_BYTES.div_ceil(3) * 4; + if encoded.len() > maximum_encoded { + return Err(Base64Error("decoded snapshot exceeds the size limit")); + } + if encoded.is_empty() { + return Ok(Vec::new()); + } + + fn value(byte: u8) -> Result { + match byte { + b'A'..=b'Z' => Ok(byte - b'A'), + b'a'..=b'z' => Ok(byte - b'a' + 26), + b'0'..=b'9' => Ok(byte - b'0' + 52), + b'+' => Ok(62), + b'/' => Ok(63), + _ => Err(Base64Error("base64 contains an invalid character")), + } + } + + let input = encoded.as_bytes(); + let padding = usize::from(input[input.len() - 1] == b'=') + + usize::from(input[input.len() - 2] == b'='); + let decoded_len = input.len() / 4 * 3 - padding; + if decoded_len > MAX_SNAPSHOT_BYTES { + return Err(Base64Error("decoded snapshot exceeds the size limit")); + } + let mut decoded = Vec::with_capacity(decoded_len); + let chunks = input.chunks_exact(4); + let chunk_count = chunks.len(); + for (index, chunk) in chunks.enumerate() { + let last = index + 1 == chunk_count; + let a = value(chunk[0])?; + let b = value(chunk[1])?; + decoded.push((a << 2) | (b >> 4)); + match (chunk[2], chunk[3]) { + (b'=', b'=') if last => { + if b & 0x0f != 0 { + return Err(Base64Error("base64 has non-canonical trailing bits")); + } + } + (third, b'=') if last => { + let c = value(third)?; + if c & 0x03 != 0 { + return Err(Base64Error("base64 has non-canonical trailing bits")); + } + decoded.push((b << 4) | (c >> 2)); + } + (b'=', _) => return Err(Base64Error("base64 padding is misplaced")), + (third, fourth) => { + let c = value(third)?; + let d = value(fourth)?; + decoded.push((b << 4) | (c >> 2)); + decoded.push((c << 6) | d); + } + } + } + Ok(decoded) +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum HostMessage { + Register { + owner: RuntimeOwner, + binding_id: BindingId, + registration: RegistrationToken, + uri: String, + selector: Value, + carrier_path: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + previous_digest: Option, + }, + Unregister { + owner: RuntimeOwner, + binding_id: BindingId, + registration: RegistrationToken, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RuntimeHealthState { + Starting, + Ready, + Degraded, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum RuntimeMessage { + Publish { + owner: RuntimeOwner, + binding_id: BindingId, + registration: RegistrationToken, + schema_id: String, + media_type: String, + bytes: SnapshotBytes, + topics: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + observed_at: Option, + }, + Health { + owner: RuntimeOwner, + #[serde(skip_serializing_if = "Option::is_none")] + binding_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + registration: Option, + state: RuntimeHealthState, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + }, +} + +#[derive(Debug)] +pub enum ProtocolError { + MissingNewline, + MultipleLines, + EmptyLine, + LineTooLarge { actual: usize }, + SelectorTooLarge { actual: usize }, + HealthDetailTooLarge { actual: usize }, + InvalidTopics(&'static str), + InvalidHealthScope, + Json(serde_json::Error), +} + +impl fmt::Display for ProtocolError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingNewline => formatter.write_str("protocol frame is missing its newline"), + Self::MultipleLines => formatter.write_str("protocol frame contains multiple lines"), + Self::EmptyLine => formatter.write_str("protocol frame is empty"), + Self::LineTooLarge { actual } => write!( + formatter, + "protocol line is {actual} bytes; maximum is {MAX_PROTOCOL_LINE_BYTES}" + ), + Self::SelectorTooLarge { actual } => write!( + formatter, + "selector is {actual} bytes; maximum is {MAX_SELECTOR_BYTES}" + ), + Self::HealthDetailTooLarge { actual } => write!( + formatter, + "health detail is {actual} bytes; maximum is {MAX_HEALTH_DETAIL_BYTES}" + ), + Self::InvalidTopics(reason) => write!(formatter, "invalid topics: {reason}"), + Self::InvalidHealthScope => formatter.write_str( + "binding-scoped health must carry both bindingId and registration", + ), + Self::Json(error) => write!(formatter, "invalid protocol JSON: {error}"), + } + } +} + +impl std::error::Error for ProtocolError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Json(error) => Some(error), + _ => None, + } + } +} + +pub fn decode_host_line(line: &[u8]) -> Result { + let payload = protocol_payload(line)?; + let message = serde_json::from_slice(payload).map_err(ProtocolError::Json)?; + validate_host_message(&message)?; + Ok(message) +} + +pub fn decode_runtime_line(line: &[u8]) -> Result { + let payload = protocol_payload(line)?; + let message = serde_json::from_slice(payload).map_err(ProtocolError::Json)?; + validate_runtime_message(&message)?; + Ok(message) +} + +pub fn encode_host_line(message: &HostMessage) -> Result, ProtocolError> { + validate_host_message(message)?; + encode_protocol_line(message) +} + +pub fn encode_runtime_line(message: &RuntimeMessage) -> Result, ProtocolError> { + validate_runtime_message(message)?; + encode_protocol_line(message) +} + +fn protocol_payload(line: &[u8]) -> Result<&[u8], ProtocolError> { + if line.len() > MAX_PROTOCOL_LINE_BYTES { + return Err(ProtocolError::LineTooLarge { actual: line.len() }); + } + let Some(payload) = line.strip_suffix(b"\n") else { + return Err(ProtocolError::MissingNewline); + }; + if payload.is_empty() { + return Err(ProtocolError::EmptyLine); + } + if payload.contains(&b'\n') || payload.contains(&b'\r') { + return Err(ProtocolError::MultipleLines); + } + Ok(payload) +} + +fn encode_protocol_line(message: &impl Serialize) -> Result, ProtocolError> { + let mut line = serde_json::to_vec(message).map_err(ProtocolError::Json)?; + line.push(b'\n'); + if line.len() > MAX_PROTOCOL_LINE_BYTES { + return Err(ProtocolError::LineTooLarge { actual: line.len() }); + } + Ok(line) +} + +fn validate_host_message(message: &HostMessage) -> Result<(), ProtocolError> { + if let HostMessage::Register { selector, .. } = message { + let actual = serde_json::to_vec(selector) + .map_err(ProtocolError::Json)? + .len(); + if actual > MAX_SELECTOR_BYTES { + return Err(ProtocolError::SelectorTooLarge { actual }); + } + } + Ok(()) +} + +fn validate_runtime_message(message: &RuntimeMessage) -> Result<(), ProtocolError> { + match message { + RuntimeMessage::Publish { topics, .. } => validate_topics(topics), + RuntimeMessage::Health { + binding_id, + registration, + detail, + .. + } => { + if binding_id.is_some() != registration.is_some() { + return Err(ProtocolError::InvalidHealthScope); + } + if let Some(detail) = detail + && detail.len() > MAX_HEALTH_DETAIL_BYTES + { + return Err(ProtocolError::HealthDetailTooLarge { + actual: detail.len(), + }); + } + Ok(()) + } + } +} + +fn validate_topics(topics: &[String]) -> Result<(), ProtocolError> { + let mut unique = BTreeSet::new(); + for topic in topics { + if topic.is_empty() { + return Err(ProtocolError::InvalidTopics("topic names must not be empty")); + } + if !unique.insert(topic.as_str()) { + return Err(ProtocolError::InvalidTopics("topic names must be unique")); + } + } + Ok(()) +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SnapshotDigest([u8; 32]); + +impl SnapshotDigest { + pub fn of(bytes: &[u8]) -> Self { + Self(Sha256::digest(bytes).into()) + } + + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Debug for SnapshotDigest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, formatter) + } +} + +impl fmt::Display for SnapshotDigest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.0 { + write!(formatter, "{byte:02x}")?; + } + Ok(()) + } +} + +impl Serialize for SnapshotDigest { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for SnapshotDigest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct DigestVisitor; + + impl Visitor<'_> for DigestVisitor { + type Value = SnapshotDigest; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a lowercase 64-character SHA-256 digest") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + if value.len() != 64 || value.bytes().any(|byte| !byte.is_ascii_hexdigit()) { + return Err(E::custom("invalid SHA-256 digest")); + } + if value.bytes().any(|byte| byte.is_ascii_uppercase()) { + return Err(E::custom("SHA-256 digest must use lowercase hex")); + } + let mut digest = [0_u8; 32]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + let pair = std::str::from_utf8(pair).map_err(E::custom)?; + digest[index] = u8::from_str_radix(pair, 16).map_err(E::custom)?; + } + Ok(SnapshotDigest(digest)) + } + } + + deserializer.deserialize_str(DigestVisitor) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TopicSelection { + topics: BTreeSet, +} + +impl TopicSelection { + pub fn new(topics: impl IntoIterator) -> Result { + let mut normalized = BTreeSet::new(); + for topic in topics { + if topic.is_empty() { + return Err(ContractError::EmptyTopic); + } + if !normalized.insert(topic) { + return Err(ContractError::DuplicateTopic); + } + } + Ok(Self { topics: normalized }) + } + + pub fn contains(&self, topic: &str) -> bool { + self.topics.contains(topic) + } + + pub fn topics(&self) -> impl Iterator { + self.topics.iter().map(String::as_str) + } + + fn select(&self, published: &[String]) -> Vec { + published + .iter() + .filter(|topic| self.contains(topic)) + .cloned() + .collect() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ContractError { + EmptySchemaId, + EmptyMediaType, + EmptyTopic, + DuplicateTopic, + SelectedTopicNotPublished(String), +} + +impl fmt::Display for ContractError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptySchemaId => formatter.write_str("schema id must not be empty"), + Self::EmptyMediaType => formatter.write_str("media type must not be empty"), + Self::EmptyTopic => formatter.write_str("topic names must not be empty"), + Self::DuplicateTopic => formatter.write_str("topic names must be unique"), + Self::SelectedTopicNotPublished(topic) => { + write!(formatter, "selected topic is not published: {topic}") + } + } + } +} + +impl std::error::Error for ContractError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublicationContract { + schema_id: String, + media_type: String, + published_topics: BTreeSet, + selection: TopicSelection, +} + +impl PublicationContract { + pub fn new( + schema_id: impl Into, + media_type: impl Into, + published_topics: impl IntoIterator, + selection: TopicSelection, + ) -> Result { + let schema_id = schema_id.into(); + if schema_id.is_empty() { + return Err(ContractError::EmptySchemaId); + } + let media_type = media_type.into(); + if media_type.is_empty() { + return Err(ContractError::EmptyMediaType); + } + let mut normalized = BTreeSet::new(); + for topic in published_topics { + if topic.is_empty() { + return Err(ContractError::EmptyTopic); + } + if !normalized.insert(topic) { + return Err(ContractError::DuplicateTopic); + } + } + for topic in selection.topics() { + if !normalized.contains(topic) { + return Err(ContractError::SelectedTopicNotPublished(topic.to_owned())); + } + } + Ok(Self { + schema_id, + media_type, + published_topics: normalized, + selection, + }) + } + + pub fn schema_id(&self) -> &str { + &self.schema_id + } + + pub fn media_type(&self) -> &str { + &self.media_type + } + + pub fn published_topics(&self) -> impl Iterator { + self.published_topics.iter().map(String::as_str) + } + + pub fn selection(&self) -> &TopicSelection { + &self.selection + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotTarget { + root: PathBuf, + relative: PathBuf, +} + +impl SnapshotTarget { + pub fn new(root: impl Into, carrier_path: impl AsRef) -> Result { + let root = root.into(); + validate_absolute_path(&root).map_err(PathError::UnsafeRoot)?; + let carrier_path = carrier_path.as_ref(); + let relative = if carrier_path.is_absolute() { + carrier_path + .strip_prefix(&root) + .map_err(|_| PathError::EscapesRoot)? + .to_path_buf() + } else { + carrier_path.to_path_buf() + }; + validate_relative_path(&relative).map_err(PathError::UnsafeCarrier)?; + Ok(Self { root, relative }) + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn relative_path(&self) -> &Path { + &self.relative + } + + pub fn path(&self) -> PathBuf { + self.root.join(&self.relative) + } + + /// Digest the currently published contained snapshot, if present. + pub fn current_digest(&self) -> Result, PublicationError> { + let parent = self.relative.parent().unwrap_or_else(|| Path::new("")); + let leaf = self + .relative + .file_name() + .ok_or_else(|| PublicationError::UnsafeTarget(PathError::UnsafeCarrier("missing leaf")))?; + let directory = + open_absolute_dir_beneath(&self.root, parent).map_err(PublicationError::Io)?; + read_regular_optional_at(&directory, leaf, MAX_SNAPSHOT_BYTES) + .map_err(|error| match error { + BoundedReadError::TooLarge => PublicationError::ExistingSnapshotTooLarge, + BoundedReadError::NotRegular => PublicationError::SnapshotNotRegular, + BoundedReadError::Io(error) => PublicationError::Io(error), + }) + .map(|bytes| bytes.map(|bytes| SnapshotDigest::of(&bytes))) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PathError { + EscapesRoot, + UnsafeRoot(&'static str), + UnsafeCarrier(&'static str), +} + +impl fmt::Display for PathError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EscapesRoot => formatter.write_str("snapshot path escapes its containment root"), + Self::UnsafeRoot(reason) => write!(formatter, "unsafe containment root: {reason}"), + Self::UnsafeCarrier(reason) => write!(formatter, "unsafe snapshot path: {reason}"), + } + } +} + +impl std::error::Error for PathError {} + +fn validate_absolute_path(path: &Path) -> Result<(), &'static str> { + if !path.is_absolute() { + return Err("path is not absolute"); + } + for component in path.components() { + if !matches!(component, Component::RootDir | Component::Normal(_)) { + return Err("path is not lexically normalized"); + } + } + Ok(()) +} + +fn validate_relative_path(path: &Path) -> Result<(), &'static str> { + let mut any = false; + for component in path.components() { + let Component::Normal(_) = component else { + return Err("path contains a non-normal component"); + }; + any = true; + } + if !any { + return Err("path must name a file beneath the root"); + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BindingRegistration { + binding_id: BindingId, + registration: RegistrationToken, + target: SnapshotTarget, + contract: PublicationContract, +} + +impl BindingRegistration { + pub fn new( + binding_id: BindingId, + registration: RegistrationToken, + target: SnapshotTarget, + contract: PublicationContract, + ) -> Self { + Self { + binding_id, + registration, + target, + contract, + } + } + + pub fn binding_id(&self) -> &BindingId { + &self.binding_id + } + + pub fn registration(&self) -> &RegistrationToken { + &self.registration + } + + pub fn target(&self) -> &SnapshotTarget { + &self.target + } + + pub fn contract(&self) -> &PublicationContract { + &self.contract + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegistrationChange { + Added, + Replaced, +} + +#[derive(Debug, Default)] +pub struct RuntimeLifecycle { + owner: Option, + bindings: HashMap, +} + +impl RuntimeLifecycle { + pub fn new() -> Self { + Self::default() + } + + pub fn owner(&self) -> Option<&RuntimeOwner> { + self.owner.as_ref() + } + + pub fn claim(&mut self, owner: RuntimeOwner) -> bool { + if self.owner.as_ref() == Some(&owner) { + return false; + } + self.owner = Some(owner); + self.bindings.clear(); + true + } + + pub fn register( + &mut self, + owner: &RuntimeOwner, + registration: BindingRegistration, + ) -> Result { + self.require_owner(owner)?; + let change = if self + .bindings + .insert(registration.binding_id.clone(), registration) + .is_some() + { + RegistrationChange::Replaced + } else { + RegistrationChange::Added + }; + Ok(change) + } + + pub fn unregister( + &mut self, + owner: &RuntimeOwner, + binding_id: &BindingId, + registration: &RegistrationToken, + ) -> Result { + self.require_registration(owner, binding_id, registration)?; + Ok(self + .bindings + .remove(binding_id) + .expect("registration was checked immediately before removal")) + } + + pub fn accept_output<'a>( + &'a self, + message: &'a RuntimeMessage, + ) -> Result, FenceError> { + match message { + RuntimeMessage::Publish { + owner, + binding_id, + registration, + schema_id, + media_type, + bytes, + topics, + observed_at, + } => { + let binding = self.require_registration(owner, binding_id, registration)?; + if schema_id != binding.contract.schema_id() { + return Err(FenceError::ContractMismatch { field: "schemaId" }); + } + if media_type != binding.contract.media_type() { + return Err(FenceError::ContractMismatch { field: "mediaType" }); + } + let mut unique = BTreeSet::new(); + for topic in topics { + if topic.is_empty() || !unique.insert(topic.as_str()) { + return Err(FenceError::InvalidTopics); + } + if !binding.contract.published_topics.contains(topic) { + return Err(FenceError::UnpublishedTopic(topic.clone())); + } + } + Ok(AcceptedOutput::Publication(AcceptedPublication { + target: &binding.target, + bytes, + selected_topics: binding.contract.selection.select(topics), + observed_at: observed_at.as_deref(), + })) + } + RuntimeMessage::Health { + owner, + binding_id, + registration, + state, + detail, + } => { + self.require_owner(owner)?; + match (binding_id, registration) { + (Some(binding_id), Some(registration)) => { + self.require_registration(owner, binding_id, registration)?; + } + (None, None) => {} + _ => return Err(FenceError::InvalidHealthScope), + } + if detail + .as_ref() + .is_some_and(|detail| detail.len() > MAX_HEALTH_DETAIL_BYTES) + { + return Err(FenceError::HealthDetailTooLarge); + } + Ok(AcceptedOutput::Health(AcceptedHealth { + binding_id: binding_id.as_ref(), + state: *state, + detail: detail.as_deref(), + })) + } + } + } + + fn require_owner(&self, owner: &RuntimeOwner) -> Result<(), FenceError> { + match self.owner.as_ref() { + None => Err(FenceError::NoOwner), + Some(current) if current != owner => Err(FenceError::StaleOwner), + Some(_) => Ok(()), + } + } + + fn require_registration( + &self, + owner: &RuntimeOwner, + binding_id: &BindingId, + registration: &RegistrationToken, + ) -> Result<&BindingRegistration, FenceError> { + self.require_owner(owner)?; + let binding = self + .bindings + .get(binding_id) + .ok_or(FenceError::UnknownBinding)?; + if &binding.registration != registration { + return Err(FenceError::StaleRegistration); + } + Ok(binding) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FenceError { + NoOwner, + StaleOwner, + UnknownBinding, + StaleRegistration, + ContractMismatch { field: &'static str }, + UnpublishedTopic(String), + InvalidTopics, + InvalidHealthScope, + HealthDetailTooLarge, +} + +impl fmt::Display for FenceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoOwner => formatter.write_str("runtime has no current owner"), + Self::StaleOwner => formatter.write_str("runtime output has a stale owner claim"), + Self::UnknownBinding => formatter.write_str("runtime output names an unknown binding"), + Self::StaleRegistration => { + formatter.write_str("runtime output has a stale registration token") + } + Self::ContractMismatch { field } => { + write!(formatter, "runtime output has a mismatched {field}") + } + Self::UnpublishedTopic(topic) => { + write!(formatter, "runtime output names unpublished topic {topic}") + } + Self::InvalidTopics => formatter.write_str("runtime output has invalid topics"), + Self::InvalidHealthScope => formatter.write_str("runtime health has an invalid scope"), + Self::HealthDetailTooLarge => formatter.write_str("runtime health detail is too large"), + } + } +} + +impl std::error::Error for FenceError {} + +#[derive(Debug)] +pub enum AcceptedOutput<'a> { + Publication(AcceptedPublication<'a>), + Health(AcceptedHealth<'a>), +} + +#[derive(Debug)] +pub struct AcceptedPublication<'a> { + target: &'a SnapshotTarget, + bytes: &'a SnapshotBytes, + selected_topics: Vec, + observed_at: Option<&'a str>, +} + +impl<'a> AcceptedPublication<'a> { + pub fn target(&self) -> &SnapshotTarget { + self.target + } + + pub fn selected_topics(&self) -> &[String] { + &self.selected_topics + } + + pub fn observed_at(&self) -> Option<&str> { + self.observed_at + } + + fn prepare(self) -> Result, PublicationError> { + prepare_snapshot(self.target, self.bytes.as_slice(), self.selected_topics) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AcceptedHealth<'a> { + binding_id: Option<&'a BindingId>, + state: RuntimeHealthState, + detail: Option<&'a str>, +} + +impl<'a> AcceptedHealth<'a> { + pub fn binding_id(&self) -> Option<&'a BindingId> { + self.binding_id + } + + pub fn state(&self) -> RuntimeHealthState { + self.state + } + + pub fn detail(&self) -> Option<&'a str> { + self.detail + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SnapshotChange { + First, + Equal, + Changed { previous: SnapshotDigest }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublicationOutcome { + digest: SnapshotDigest, + change: SnapshotChange, + selected_topics: Vec, +} + +impl PublicationOutcome { + pub fn digest(&self) -> SnapshotDigest { + self.digest + } + + pub fn change(&self) -> SnapshotChange { + self.change + } + + pub fn selected_topics(&self) -> &[String] { + &self.selected_topics + } + + pub fn invalidating(&self) -> bool { + self.change != SnapshotChange::Equal && !self.selected_topics.is_empty() + } +} + +#[derive(Debug)] +pub enum PublicationError { + SnapshotTooLarge { actual: usize }, + UnsafeTarget(PathError), + ExistingSnapshotTooLarge, + SnapshotNotRegular, + Io(io::Error), +} + +impl fmt::Display for PublicationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SnapshotTooLarge { actual } => write!( + formatter, + "snapshot is {actual} bytes; maximum is {MAX_SNAPSHOT_BYTES}" + ), + Self::UnsafeTarget(error) => write!(formatter, "unsafe snapshot target: {error}"), + Self::ExistingSnapshotTooLarge => formatter.write_str("existing snapshot is too large"), + Self::SnapshotNotRegular => { + formatter.write_str("snapshot target is not a real regular file") + } + Self::Io(error) => write!(formatter, "snapshot publication failed: {error}"), + } + } +} + +impl std::error::Error for PublicationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::UnsafeTarget(error) => Some(error), + Self::Io(error) => Some(error), + _ => None, + } + } +} + +#[derive(Debug)] +struct PreparedPublication<'a> { + target: &'a SnapshotTarget, + bytes: &'a [u8], + outcome: PublicationOutcome, +} + +impl PreparedPublication<'_> { + fn commit(&self) -> Result<(), PublicationError> { + if self.outcome.change == SnapshotChange::Equal { + return Ok(()); + } + let parent = self + .target + .relative + .parent() + .unwrap_or_else(|| Path::new("")); + let leaf = self.target.relative.file_name().ok_or_else(|| { + PublicationError::UnsafeTarget(PathError::UnsafeCarrier("missing leaf")) + })?; + let directory = open_absolute_dir_beneath(&self.target.root, parent) + .map_err(PublicationError::Io)?; + atomic_replace_at(&directory, leaf, self.bytes).map_err(PublicationError::Io) + } +} + +fn prepare_snapshot<'a>( + target: &'a SnapshotTarget, + bytes: &'a [u8], + selected_topics: Vec, +) -> Result, PublicationError> { + if bytes.len() > MAX_SNAPSHOT_BYTES { + return Err(PublicationError::SnapshotTooLarge { actual: bytes.len() }); + } + let previous = target.current_digest()?; + let digest = SnapshotDigest::of(bytes); + let change = match previous { + None => SnapshotChange::First, + Some(previous) if previous == digest => SnapshotChange::Equal, + Some(previous) => SnapshotChange::Changed { previous }, + }; + Ok(PreparedPublication { + target, + bytes, + outcome: PublicationOutcome { + digest, + change, + selected_topics, + }, + }) +} + +fn publish_snapshot( + target: &SnapshotTarget, + bytes: &[u8], + selected_topics: Vec, +) -> Result { + let prepared = prepare_snapshot(target, bytes, selected_topics)?; + prepared.commit()?; + Ok(prepared.outcome) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CatchUpState { + current_snapshot_digest: Option, + last_delivered_digest: Option, + pending_relevant_change: bool, + #[serde(default)] + pending_selected_topics: Vec, + deliverable: bool, +} + +impl CatchUpState { + pub fn current_snapshot_digest(&self) -> Option { + self.current_snapshot_digest + } + + pub fn last_delivered_digest(&self) -> Option { + self.last_delivered_digest + } + + pub fn pending_relevant_change(&self) -> bool { + self.pending_relevant_change + } + + pub fn pending_selected_topics(&self) -> &[String] { + &self.pending_selected_topics + } + + pub fn deliverable(&self) -> bool { + self.deliverable + } + + fn validate(&self) -> Result<(), CatchUpError> { + if self.pending_relevant_change && self.current_snapshot_digest.is_none() { + return Err(CatchUpError::InvalidState( + "pending relevance requires a current snapshot digest", + )); + } + if self.pending_relevant_change != !self.pending_selected_topics.is_empty() { + return Err(CatchUpError::InvalidState( + "pending relevance and selected topics disagree", + )); + } + validate_persisted_topics(&self.pending_selected_topics)?; + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeliveryRequest { + digest: SnapshotDigest, + selected_topics: Vec, +} + +impl DeliveryRequest { + pub fn digest(&self) -> SnapshotDigest { + self.digest + } + + pub fn selected_topics(&self) -> &[String] { + &self.selected_topics + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PublicationIntent { + digest: SnapshotDigest, + selected_topics: Vec, +} + +impl PublicationIntent { + fn from_outcome(outcome: &PublicationOutcome) -> Self { + Self { + digest: outcome.digest, + selected_topics: outcome.selected_topics.clone(), + } + } + + fn validate(&self) -> Result<(), CatchUpError> { + validate_persisted_topics(&self.selected_topics) + } +} + +fn validate_persisted_topics(topics: &[String]) -> Result<(), CatchUpError> { + let mut unique = BTreeSet::new(); + for topic in topics { + if topic.is_empty() || !unique.insert(topic.as_str()) { + return Err(CatchUpError::InvalidState( + "persisted selected topics must be non-empty and unique", + )); + } + } + Ok(()) +} + +#[derive(Debug)] +pub struct CatchUp { + directory: File, + state: CatchUpState, +} + +impl CatchUp { + pub fn open(state_directory: &Path) -> Result { + validate_absolute_path(state_directory) + .map_err(CatchUpError::UnsafeStateDirectory)?; + let directory = open_absolute_dir(state_directory).map_err(CatchUpError::Io)?; + let state = match read_regular_optional_at( + &directory, + OsStr::new(CATCH_UP_FILE), + MAX_CATCH_UP_FILE_BYTES, + ) { + Ok(Some(bytes)) => { + serde_json::from_slice::(&bytes).map_err(CatchUpError::Json)? + } + Ok(None) => CatchUpState::default(), + Err(BoundedReadError::TooLarge) => return Err(CatchUpError::StateTooLarge), + Err(BoundedReadError::NotRegular) => return Err(CatchUpError::StateNotRegular), + Err(BoundedReadError::Io(error)) => return Err(CatchUpError::Io(error)), + }; + state.validate()?; + Ok(Self { directory, state }) + } + + pub fn open_for_snapshot( + state_directory: &Path, + target: &SnapshotTarget, + ) -> Result { + let mut catch_up = Self::open(state_directory)?; + catch_up.reconcile_snapshot(target)?; + Ok(catch_up) + } + + pub fn state(&self) -> &CatchUpState { + &self.state + } + + pub fn publish( + &mut self, + publication: AcceptedPublication<'_>, + ) -> Result<(PublicationOutcome, Option), PublicationTransactionError> { + let target = publication.target; + self.reconcile_snapshot(target) + .map_err(PublicationTransactionError::CatchUp)?; + let prepared = publication + .prepare() + .map_err(PublicationTransactionError::Publication)?; + let outcome = prepared.outcome.clone(); + if outcome.change != SnapshotChange::Equal { + self.write_publication_intent(&PublicationIntent::from_outcome(&outcome)) + .map_err(PublicationTransactionError::CatchUp)?; + } + prepared + .commit() + .map_err(PublicationTransactionError::Publication)?; + let delivery = self + .record_publication(&outcome) + .map_err(PublicationTransactionError::CatchUp)?; + if outcome.change != SnapshotChange::Equal { + self.clear_publication_intent() + .map_err(PublicationTransactionError::CatchUp)?; + } + Ok((outcome, delivery)) + } + + pub fn reconcile_snapshot( + &mut self, + target: &SnapshotTarget, + ) -> Result, CatchUpError> { + let observed = target.current_digest().map_err(CatchUpError::Publication)?; + let intent = self.read_publication_intent()?; + let mut next = self.state.clone(); + + match intent.as_ref() { + Some(intent) if observed == Some(intent.digest) => { + next.current_snapshot_digest = observed; + if !intent.selected_topics.is_empty() { + next.pending_relevant_change = true; + next.pending_selected_topics = intent.selected_topics.clone(); + } + } + Some(_) | None => { + if observed.is_none() && next.pending_relevant_change { + return Err(CatchUpError::InvalidState( + "a pending invalidation has no readable canonical snapshot", + )); + } + next.current_snapshot_digest = observed; + } + } + + if next != self.state { + self.commit(next)?; + } + if intent.is_some() { + self.clear_publication_intent()?; + } + Ok(self.pending_delivery()) + } + + pub fn set_deliverable( + &mut self, + deliverable: bool, + ) -> Result, CatchUpError> { + if self.state.deliverable != deliverable { + let mut next = self.state.clone(); + next.deliverable = deliverable; + self.commit(next)?; + } + Ok(self.pending_delivery()) + } + + pub fn pending_delivery(&self) -> Option { + if !self.state.deliverable || !self.state.pending_relevant_change { + return None; + } + Some(DeliveryRequest { + digest: self.state.current_snapshot_digest?, + selected_topics: self.state.pending_selected_topics.clone(), + }) + } + + pub fn acknowledge_delivery( + &mut self, + digest: SnapshotDigest, + ) -> Result { + if !self.state.pending_relevant_change + || self.state.current_snapshot_digest != Some(digest) + { + return Ok(false); + } + let mut next = self.state.clone(); + next.last_delivered_digest = Some(digest); + next.pending_relevant_change = false; + next.pending_selected_topics.clear(); + self.commit(next)?; + Ok(true) + } + + fn record_publication( + &mut self, + outcome: &PublicationOutcome, + ) -> Result, CatchUpError> { + let mut next = self.state.clone(); + next.current_snapshot_digest = Some(outcome.digest); + if outcome.invalidating() { + next.pending_relevant_change = true; + next.pending_selected_topics = outcome.selected_topics.clone(); + } + self.commit(next)?; + Ok(self.pending_delivery()) + } + + fn read_publication_intent(&self) -> Result, CatchUpError> { + match read_regular_optional_at( + &self.directory, + OsStr::new(PUBLICATION_INTENT_FILE), + MAX_CATCH_UP_FILE_BYTES, + ) { + Ok(Some(bytes)) => { + let intent = serde_json::from_slice::(&bytes) + .map_err(CatchUpError::Json)?; + intent.validate()?; + Ok(Some(intent)) + } + Ok(None) => Ok(None), + Err(BoundedReadError::TooLarge) => Err(CatchUpError::IntentTooLarge), + Err(BoundedReadError::NotRegular) => Err(CatchUpError::IntentNotRegular), + Err(BoundedReadError::Io(error)) => Err(CatchUpError::Io(error)), + } + } + + fn write_publication_intent( + &self, + intent: &PublicationIntent, + ) -> Result<(), CatchUpError> { + intent.validate()?; + let mut bytes = serde_json::to_vec(intent).map_err(CatchUpError::Json)?; + bytes.push(b'\n'); + if bytes.len() > MAX_CATCH_UP_FILE_BYTES { + return Err(CatchUpError::IntentTooLarge); + } + atomic_replace_at( + &self.directory, + OsStr::new(PUBLICATION_INTENT_FILE), + &bytes, + ) + .map_err(CatchUpError::Io) + } + + fn clear_publication_intent(&self) -> Result<(), CatchUpError> { + remove_optional_at(&self.directory, OsStr::new(PUBLICATION_INTENT_FILE)) + .map_err(CatchUpError::Io) + } + + fn commit(&mut self, state: CatchUpState) -> Result<(), CatchUpError> { + state.validate()?; + let mut bytes = serde_json::to_vec(&state).map_err(CatchUpError::Json)?; + bytes.push(b'\n'); + if bytes.len() > MAX_CATCH_UP_FILE_BYTES { + return Err(CatchUpError::StateTooLarge); + } + atomic_replace_at(&self.directory, OsStr::new(CATCH_UP_FILE), &bytes) + .map_err(CatchUpError::Io)?; + self.state = state; + Ok(()) + } +} + +#[derive(Debug)] +pub enum PublicationTransactionError { + Publication(PublicationError), + CatchUp(CatchUpError), +} + +impl fmt::Display for PublicationTransactionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Publication(error) => fmt::Display::fmt(error, formatter), + Self::CatchUp(error) => fmt::Display::fmt(error, formatter), + } + } +} + +impl std::error::Error for PublicationTransactionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Publication(error) => Some(error), + Self::CatchUp(error) => Some(error), + } + } +} + +#[derive(Debug)] +pub enum CatchUpError { + UnsafeStateDirectory(&'static str), + StateTooLarge, + StateNotRegular, + IntentTooLarge, + IntentNotRegular, + InvalidState(&'static str), + Publication(PublicationError), + Json(serde_json::Error), + Io(io::Error), +} + +impl fmt::Display for CatchUpError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsafeStateDirectory(reason) => { + write!(formatter, "unsafe catch-up state directory: {reason}") + } + Self::StateTooLarge => formatter.write_str("catch-up state file is too large"), + Self::StateNotRegular => { + formatter.write_str("catch-up state path is not a real regular file") + } + Self::IntentTooLarge => formatter.write_str("publication intent file is too large"), + Self::IntentNotRegular => { + formatter.write_str("publication intent path is not a real regular file") + } + Self::InvalidState(reason) => write!(formatter, "invalid catch-up state: {reason}"), + Self::Publication(error) => write!(formatter, "snapshot reconciliation failed: {error}"), + Self::Json(error) => write!(formatter, "invalid catch-up state JSON: {error}"), + Self::Io(error) => write!(formatter, "catch-up state I/O failed: {error}"), + } + } +} + +impl std::error::Error for CatchUpError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Publication(error) => Some(error), + Self::Json(error) => Some(error), + Self::Io(error) => Some(error), + _ => None, + } + } +} + +enum BoundedReadError { + TooLarge, + NotRegular, + Io(io::Error), +} + +fn open_absolute_dir(path: &Path) -> io::Result { + open_absolute_dir_beneath(path, Path::new("")) +} + +fn open_absolute_dir_beneath(root: &Path, relative: &Path) -> io::Result { + validate_absolute_path(root).map_err(invalid_input)?; + validate_empty_or_relative_path(relative).map_err(invalid_input)?; + let slash = c"/"; + let descriptor = unsafe { + libc::open( + slash.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + if descriptor < 0 { + return Err(io::Error::last_os_error()); + } + let mut directory = unsafe { File::from_raw_fd(descriptor) }; + for component in root + .components() + .chain(relative.components()) + .filter_map(|component| match component { + Component::RootDir => None, + Component::Normal(name) => Some(name), + _ => None, + }) + { + directory = openat_directory(&directory, component)?; + } + Ok(directory) +} + +fn validate_empty_or_relative_path(path: &Path) -> Result<(), &'static str> { + for component in path.components() { + if !matches!(component, Component::Normal(_)) { + return Err("path contains a non-normal component"); + } + } + Ok(()) +} + +fn invalid_input(reason: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, reason) +} + +fn openat_directory(parent: &File, name: &OsStr) -> io::Result { + let name = c_string(name)?; + let descriptor = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + if descriptor < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(unsafe { File::from_raw_fd(descriptor) }) + } +} + +fn read_regular_optional_at( + directory: &File, + name: &OsStr, + maximum: usize, +) -> Result>, BoundedReadError> { + let name = c_string(name).map_err(BoundedReadError::Io)?; + let descriptor = unsafe { + libc::openat( + directory.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK, + ) + }; + if descriptor < 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::NotFound { + return Ok(None); + } + return Err(BoundedReadError::Io(error)); + } + let file = unsafe { File::from_raw_fd(descriptor) }; + let metadata = file.metadata().map_err(BoundedReadError::Io)?; + if !metadata.is_file() { + return Err(BoundedReadError::NotRegular); + } + if metadata.len() > maximum as u64 { + return Err(BoundedReadError::TooLarge); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(maximum as u64 + 1) + .read_to_end(&mut bytes) + .map_err(BoundedReadError::Io)?; + if bytes.len() > maximum { + return Err(BoundedReadError::TooLarge); + } + Ok(Some(bytes)) +} + +fn atomic_replace_at(directory: &File, leaf: &OsStr, bytes: &[u8]) -> io::Result<()> { + ensure_regular_or_absent_at(directory, leaf)?; + let leaf = c_string(leaf)?; + let sequence = TEMPORARY_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let temporary = CString::new(format!( + ".resource-profile.tmp-{}-{sequence}", + std::process::id() + )) + .expect("generated temporary name contains no NUL"); + let descriptor = unsafe { + libc::openat( + directory.as_raw_fd(), + temporary.as_ptr(), + libc::O_WRONLY + | libc::O_CREAT + | libc::O_EXCL + | libc::O_NOFOLLOW + | libc::O_CLOEXEC, + 0o600, + ) + }; + if descriptor < 0 { + return Err(io::Error::last_os_error()); + } + let mut file = unsafe { File::from_raw_fd(descriptor) }; + let result = (|| { + file.write_all(bytes)?; + file.sync_all()?; + let renamed = unsafe { + libc::renameat( + directory.as_raw_fd(), + temporary.as_ptr(), + directory.as_raw_fd(), + leaf.as_ptr(), + ) + }; + if renamed < 0 { + return Err(io::Error::last_os_error()); + } + directory.sync_all() + })(); + if result.is_err() { + unsafe { + libc::unlinkat(directory.as_raw_fd(), temporary.as_ptr(), 0); + } + } + result +} +fn remove_optional_at(directory: &File, leaf: &OsStr) -> io::Result<()> { + let leaf = c_string(leaf)?; + let removed = unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) }; + if removed < 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::NotFound { + return Ok(()); + } + return Err(error); + } + directory.sync_all() +} + + +fn ensure_regular_or_absent_at(directory: &File, leaf: &OsStr) -> io::Result<()> { + match read_regular_optional_at(directory, leaf, 0) { + Ok(None) | Ok(Some(_)) | Err(BoundedReadError::TooLarge) => Ok(()), + Err(BoundedReadError::NotRegular) => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "target is not a regular file", + )), + Err(BoundedReadError::Io(error)) => Err(error), + } +} + +fn c_string(value: &OsStr) -> io::Result { + CString::new(value.as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path component contains NUL")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::os::unix::fs::symlink; + + fn id(value: &str, make: impl FnOnce(String) -> Result) -> T { + make(value.to_owned()).unwrap() + } + + fn owner(value: &str) -> RuntimeOwner { + RuntimeOwner::new( + id(value, RuntimeIncarnation::new), + id(&format!("claim-{value}"), OwnerClaim::new), + ) + } + + fn binding_id(value: &str) -> BindingId { + id(value, BindingId::new) + } + + fn token(value: &str) -> RegistrationToken { + id(value, RegistrationToken::new) + } + + fn target(root: &Path) -> SnapshotTarget { + SnapshotTarget::new(fs::canonicalize(root).unwrap(), "snapshot.json").unwrap() + } + + fn contract(selected: &[&str]) -> PublicationContract { + PublicationContract::new( + "schema.v1", + "application/json", + ["selected", "ignored"].map(str::to_owned), + TopicSelection::new(selected.iter().map(|topic| (*topic).to_owned())).unwrap(), + ) + .unwrap() + } + + fn registration(root: &Path, registration: &str) -> BindingRegistration { + BindingRegistration::new( + binding_id("binding"), + token(registration), + target(root), + contract(&["selected"]), + ) + } + + fn publication( + owner: RuntimeOwner, + registration: &str, + bytes: &[u8], + topics: &[&str], + ) -> RuntimeMessage { + RuntimeMessage::Publish { + owner, + binding_id: binding_id("binding"), + registration: token(registration), + schema_id: "schema.v1".to_owned(), + media_type: "application/json".to_owned(), + bytes: SnapshotBytes::new(bytes.to_vec()).unwrap(), + topics: topics.iter().map(|topic| (*topic).to_owned()).collect(), + observed_at: None, + } + } + + fn accepted_publication<'a>( + lifecycle: &'a RuntimeLifecycle, + message: &'a RuntimeMessage, + ) -> AcceptedPublication<'a> { + match lifecycle.accept_output(message).unwrap() { + AcceptedOutput::Publication(publication) => publication, + AcceptedOutput::Health(_) => panic!("expected publication"), + } + } + + #[test] + fn protocol_round_trips_padded_base64_and_rejects_malformed_and_oversized_lines() { + let message = publication(owner("one"), "registration", b"one byte?", &["selected"]); + let encoded = encode_runtime_line(&message).unwrap(); + assert!(encoded.ends_with(b"\n")); + assert_eq!(decode_runtime_line(&encoded).unwrap(), message); + + assert!(matches!( + decode_runtime_line(b"{\"type\":\"publish\"}\n"), + Err(ProtocolError::Json(_)) + )); + let oversized = vec![b'x'; MAX_PROTOCOL_LINE_BYTES + 1]; + assert!(matches!( + decode_runtime_line(&oversized), + Err(ProtocolError::LineTooLarge { .. }) + )); + } + + #[test] + fn protocol_rejects_oversized_decoded_snapshot_before_publication() { + let encoded = encode_base64(&vec![0_u8; MAX_SNAPSHOT_BYTES + 1]); + let line = format!( + "{{\"type\":\"publish\",\"owner\":{{\"incarnation\":\"i\",\"claim\":\"c\"}},\"bindingId\":\"b\",\"registration\":\"r\",\"schemaId\":\"s\",\"mediaType\":\"m\",\"bytes\":\"{encoded}\",\"topics\":[]}}\n" + ); + assert!(line.len() < MAX_PROTOCOL_LINE_BYTES); + assert!(matches!( + decode_runtime_line(line.as_bytes()), + Err(ProtocolError::Json(_)) + )); + assert!(SnapshotBytes::new(vec![0_u8; MAX_SNAPSHOT_BYTES + 1]).is_err()); + } + + #[test] + fn stale_owner_and_registration_are_fenced() { + let directory = tempfile::tempdir().unwrap(); + let current = owner("current"); + let stale = owner("stale"); + let mut lifecycle = RuntimeLifecycle::new(); + lifecycle.claim(current.clone()); + lifecycle + .register(¤t, registration(directory.path(), "current-token")) + .unwrap(); + + let stale_owner = publication(stale, "current-token", b"bytes", &["selected"]); + assert!(matches!( + lifecycle.accept_output(&stale_owner), + Err(FenceError::StaleOwner) + )); + let stale_token = publication(current, "stale-token", b"bytes", &["selected"]); + assert!(matches!( + lifecycle.accept_output(&stale_token), + Err(FenceError::StaleRegistration) + )); + assert!(!directory.path().join("snapshot.json").exists()); + } + + #[test] + fn unregister_and_reregister_fence_every_old_token() { + let directory = tempfile::tempdir().unwrap(); + let current = owner("current"); + let mut lifecycle = RuntimeLifecycle::new(); + lifecycle.claim(current.clone()); + lifecycle + .register(¤t, registration(directory.path(), "first")) + .unwrap(); + lifecycle + .unregister(¤t, &binding_id("binding"), &token("first")) + .unwrap(); + + let first = publication(current.clone(), "first", b"old", &["selected"]); + assert!(matches!( + lifecycle.accept_output(&first), + Err(FenceError::UnknownBinding) + )); + lifecycle + .register(¤t, registration(directory.path(), "second")) + .unwrap(); + assert!(matches!( + lifecycle.accept_output(&first), + Err(FenceError::StaleRegistration) + )); + let second = publication(current, "second", b"new", &["selected"]); + assert!(matches!( + lifecycle.accept_output(&second), + Ok(AcceptedOutput::Publication(_)) + )); + } + + #[test] + fn new_owner_claim_clears_all_registrations() { + let directory = tempfile::tempdir().unwrap(); + let first_owner = owner("first"); + let second_owner = owner("second"); + let mut lifecycle = RuntimeLifecycle::new(); + lifecycle.claim(first_owner.clone()); + lifecycle + .register(&first_owner, registration(directory.path(), "token")) + .unwrap(); + assert!(lifecycle.claim(second_owner.clone())); + + let output = publication(second_owner, "token", b"bytes", &["selected"]); + assert!(matches!( + lifecycle.accept_output(&output), + Err(FenceError::UnknownBinding) + )); + } + + #[test] + fn first_publication_invalidates_but_equal_publication_is_silent() { + let directory = tempfile::tempdir().unwrap(); + let current = owner("current"); + let mut lifecycle = RuntimeLifecycle::new(); + lifecycle.claim(current.clone()); + lifecycle + .register(¤t, registration(directory.path(), "token")) + .unwrap(); + let message = publication(current, "token", br#"{"state":1}"#, &["selected"]); + let state_directory = fs::canonicalize(directory.path()).unwrap(); + let mut catch_up = CatchUp::open(&state_directory).unwrap(); + + let (first, _) = catch_up + .publish(accepted_publication(&lifecycle, &message)) + .unwrap(); + assert_eq!(first.change(), SnapshotChange::First); + assert!(first.invalidating()); + assert_eq!(fs::read(directory.path().join("snapshot.json")).unwrap(), br#"{"state":1}"#); + + let (equal, _) = catch_up + .publish(accepted_publication(&lifecycle, &message)) + .unwrap(); + assert_eq!(equal.change(), SnapshotChange::Equal); + assert!(!equal.invalidating()); + assert_eq!(equal.digest(), first.digest()); + } + + #[test] + fn topic_filtering_updates_snapshot_without_invalidating() { + let directory = tempfile::tempdir().unwrap(); + let current = owner("current"); + let mut lifecycle = RuntimeLifecycle::new(); + lifecycle.claim(current.clone()); + lifecycle + .register(¤t, registration(directory.path(), "token")) + .unwrap(); + let state_directory = fs::canonicalize(directory.path()).unwrap(); + let mut catch_up = CatchUp::open(&state_directory).unwrap(); + let ignored = publication(current.clone(), "token", b"ignored", &["ignored"]); + let (outcome, _) = catch_up + .publish(accepted_publication(&lifecycle, &ignored)) + .unwrap(); + assert_eq!(outcome.change(), SnapshotChange::First); + assert!(outcome.selected_topics().is_empty()); + assert!(!outcome.invalidating()); + + let selected = publication( + current, + "token", + b"selected", + &["ignored", "selected"], + ); + let (outcome, _) = catch_up + .publish(accepted_publication(&lifecycle, &selected)) + .unwrap(); + assert!(matches!(outcome.change(), SnapshotChange::Changed { .. })); + assert_eq!(outcome.selected_topics(), ["selected"]); + assert!(outcome.invalidating()); + } + + #[test] + fn unavailable_delivery_catches_up_to_the_latest_digest_and_acks_by_level() { + let directory = tempfile::tempdir().unwrap(); + let state_directory = fs::canonicalize(directory.path()).unwrap(); + let relevant = PublicationOutcome { + digest: SnapshotDigest::of(b"relevant"), + change: SnapshotChange::First, + selected_topics: vec!["selected".to_owned()], + }; + let irrelevant = PublicationOutcome { + digest: SnapshotDigest::of(b"later but irrelevant"), + change: SnapshotChange::Changed { + previous: relevant.digest(), + }, + selected_topics: Vec::new(), + }; + let mut catch_up = CatchUp::open(&state_directory).unwrap(); + assert_eq!(catch_up.record_publication(&relevant).unwrap(), None); + assert_eq!(catch_up.record_publication(&irrelevant).unwrap(), None); + assert!(catch_up.state().pending_relevant_change()); + + let request = catch_up.set_deliverable(true).unwrap().unwrap(); + assert_eq!(request.digest(), irrelevant.digest()); + assert_eq!(request.selected_topics(), ["selected"]); + assert!(!catch_up.acknowledge_delivery(relevant.digest()).unwrap()); + assert!(catch_up.state().pending_relevant_change()); + assert!(catch_up.acknowledge_delivery(irrelevant.digest()).unwrap()); + assert!(!catch_up.state().pending_relevant_change()); + assert_eq!( + catch_up.state().last_delivered_digest(), + Some(irrelevant.digest()) + ); + } + + #[test] + fn catch_up_state_survives_reload_and_ignores_crash_temporary() { + let directory = tempfile::tempdir().unwrap(); + let state_directory = fs::canonicalize(directory.path()).unwrap(); + let outcome = PublicationOutcome { + digest: SnapshotDigest::of(b"snapshot"), + change: SnapshotChange::First, + selected_topics: vec!["selected".to_owned()], + }; + { + let mut catch_up = CatchUp::open(&state_directory).unwrap(); + catch_up.record_publication(&outcome).unwrap(); + } + fs::write( + state_directory.join(".resource-profile.tmp-crash"), + b"not committed", + ) + .unwrap(); + + let catch_up = CatchUp::open(&state_directory).unwrap(); + assert_eq!( + catch_up.state().current_snapshot_digest(), + Some(outcome.digest()) + ); + assert!(catch_up.state().pending_relevant_change()); + assert_eq!( + catch_up.state().pending_selected_topics(), + ["selected"] + ); + } + + #[test] + fn durable_intent_recovers_relevance_before_an_equal_republish() { + let directory = tempfile::tempdir().unwrap(); + let state_directory = fs::canonicalize(directory.path()).unwrap(); + let current = owner("current"); + let mut lifecycle = RuntimeLifecycle::new(); + lifecycle.claim(current.clone()); + lifecycle + .register(¤t, registration(directory.path(), "token")) + .unwrap(); + let message = publication(current, "token", b"committed", &["selected"]); + let accepted = accepted_publication(&lifecycle, &message); + + { + let catch_up = CatchUp::open(&state_directory).unwrap(); + let prepared = accepted.prepare().unwrap(); + catch_up + .write_publication_intent(&PublicationIntent::from_outcome(&prepared.outcome)) + .unwrap(); + prepared.commit().unwrap(); + } + + let snapshot_target = target(directory.path()); + let mut catch_up = + CatchUp::open_for_snapshot(&state_directory, &snapshot_target).unwrap(); + assert!(catch_up.state().pending_relevant_change()); + assert_eq!( + catch_up.state().pending_selected_topics(), + ["selected"] + ); + + let (equal, _) = catch_up + .publish(accepted_publication(&lifecycle, &message)) + .unwrap(); + assert_eq!(equal.change(), SnapshotChange::Equal); + assert!(catch_up.state().pending_relevant_change()); + let request = catch_up.set_deliverable(true).unwrap().unwrap(); + assert_eq!(request.digest(), equal.digest()); + assert_eq!(request.selected_topics(), ["selected"]); + } + + #[test] + fn snapshot_and_state_paths_reject_escape_and_symlinks() { + let directory = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(directory.path()).unwrap(); + assert!(matches!( + SnapshotTarget::new(&root, "../escape"), + Err(PathError::UnsafeCarrier(_)) + )); + assert!(matches!( + SnapshotTarget::new(&root, root.parent().unwrap().join("escape")), + Err(PathError::EscapesRoot) + )); + + let outside = tempfile::tempdir().unwrap(); + symlink(outside.path(), root.join("linked-parent")).unwrap(); + let target = SnapshotTarget::new(&root, "linked-parent/snapshot.json").unwrap(); + assert!(matches!( + publish_snapshot(&target, b"bytes", vec!["selected".to_owned()]), + Err(PublicationError::Io(_)) + )); + + symlink(outside.path().join("state.json"), root.join(CATCH_UP_FILE)).unwrap(); + assert!(matches!( + CatchUp::open(&root), + Err(CatchUpError::Io(_)) | Err(CatchUpError::StateNotRegular) + )); + } +} diff --git a/src/resource_profile_supervisor.rs b/src/resource_profile_supervisor.rs new file mode 100644 index 00000000..043d4b7b --- /dev/null +++ b/src/resource_profile_supervisor.rs @@ -0,0 +1,1182 @@ +//! Resident process supervision for observable Resource Profiles. +//! +//! The worker is the sole owner of runtime processes and their protocol state. Reconcile callers +//! submit complete desired binding sets; removals are acknowledged only after registrations have +//! been fenced and processes no longer owned by the desired generation have been stopped. + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{self, BufRead, BufReader, Write as _}; +use std::path::{Component, Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError}; +use std::thread::{self, JoinHandle}; + +use agent_spec::profile::{ + ProfileCapability, ProfileDescriptor, ResourceProfileRegistry, RuntimeTopology, +}; +use agent_spec::spec::AgentSpec; +use anyhow::Context as _; +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest as _, Sha256}; + +use crate::catalog::CatalogConfig; +use crate::resource_profile::{ + AcceptedOutput, BindingId, BindingRegistration, CatchUp, HostMessage, OwnerClaim, + PublicationContract, RegistrationToken, RuntimeHealthState, RuntimeIncarnation, + RuntimeLifecycle, RuntimeMessage, RuntimeOwner, SnapshotDigest, SnapshotTarget, TopicSelection, + MAX_PROTOCOL_LINE_BYTES, decode_runtime_line, encode_host_line, +}; + +const MAILBOX_CAPACITY: usize = 64; +const WRITER_CAPACITY: usize = 64; +static ID_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResourceProfileHealth { + pub scheme: String, + pub binding: Option, + pub state: RuntimeHealthState, + pub detail: Option, +} + +#[derive(Debug)] +pub struct ResourceProfileRefreshReport { + pub warnings: Vec, +} + +pub struct ResourceProfileSupervisor { + tx: SyncSender, + worker: Option>, + catalog_root: PathBuf, + this_host: String, +} + +impl ResourceProfileSupervisor { + pub fn new(catalog_root: PathBuf, this_host: String) -> anyhow::Result { + let catalog_root = lexical_absolute(&catalog_root)?; + let (tx, rx) = mpsc::sync_channel(MAILBOX_CAPACITY); + let worker_tx = tx.clone(); + let worker_root = catalog_root.clone(); + let worker_host = this_host.clone(); + let worker = thread::Builder::new() + .name("st2-resource-profile".to_owned()) + .spawn(move || Worker::new(worker_root, worker_host, worker_tx).run(rx)) + .context("spawn Resource Profile supervisor")?; + Ok(Self { + tx, + worker: Some(worker), + catalog_root, + this_host, + }) + } + + /// Reconcile the exact set of bindings owned by canonical agent seats proven live this pass. + /// The caller holds the catalog read lock while deriving `config`, `profiles`, and `generation`. + pub fn refresh( + &self, + config: &CatalogConfig, + profiles: &ResourceProfileRegistry, + generation: Option, + live_specs: &[AgentSpec], + ) -> ResourceProfileRefreshReport { + let (desired, mut warnings) = desired_bindings( + &self.catalog_root, + &self.this_host, + config, + profiles, + generation, + live_specs, + ); + let (reply_tx, reply_rx) = mpsc::sync_channel(1); + if self + .tx + .send(Msg::Refresh { + desired, + reply: reply_tx, + }) + .is_err() + { + warnings.push("Resource Profile supervisor worker stopped".to_owned()); + } else if let Ok(worker_warnings) = reply_rx.recv() { + warnings.extend(worker_warnings); + } else { + warnings.push("Resource Profile supervisor refresh acknowledgement failed".to_owned()); + } + ResourceProfileRefreshReport { warnings } + } + + /// Fence one agent before its canonical seat is replaced. Completion is synchronous. + pub fn deactivate(&self, spec: &AgentSpec) { + let recipient = spec.bus_id(&self.this_host); + let (reply_tx, reply_rx) = mpsc::sync_channel(1); + if self + .tx + .send(Msg::Deactivate { + recipient, + reply: reply_tx, + }) + .is_ok() + { + let _ = reply_rx.recv(); + } + } + + pub fn health(&self) -> Vec { + let (reply_tx, reply_rx) = mpsc::sync_channel(1); + if self.tx.send(Msg::Health { reply: reply_tx }).is_err() { + return Vec::new(); + } + reply_rx.recv().unwrap_or_default() + } +} + +impl Drop for ResourceProfileSupervisor { + fn drop(&mut self) { + let (reply_tx, reply_rx) = mpsc::sync_channel(1); + if self.tx.send(Msg::Shutdown { reply: reply_tx }).is_ok() { + let _ = reply_rx.recv(); + } + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +#[derive(Debug)] +enum Msg { + Refresh { + desired: BTreeMap, + reply: SyncSender>, + }, + Deactivate { + recipient: String, + reply: SyncSender<()>, + }, + Health { + reply: SyncSender>, + }, + RuntimeOutput { + key: RuntimeKey, + owner: RuntimeOwner, + output: Result, + }, + RuntimeEof { + key: RuntimeKey, + owner: RuntimeOwner, + }, + WriterFailed { + key: RuntimeKey, + owner: RuntimeOwner, + error: String, + }, + Shutdown { + reply: SyncSender<()>, + }, +} + +#[derive(Debug, Clone, PartialEq)] +struct DesiredBinding { + stable_key: String, + catalog_root: PathBuf, + this_host: String, + recipient: String, + binding_name: String, + scheme: String, + generation: u64, + topology: RuntimeTopology, + argv: Vec, + uri: String, + selector: Value, + descriptor: ProfileDescriptor, + target: SnapshotTarget, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum RuntimeKey { + Shared { + scheme: String, + generation: u64, + }, + PerBinding { + scheme: String, + generation: u64, + binding: String, + }, +} + +impl DesiredBinding { + fn runtime_key(&self) -> RuntimeKey { + match self.topology { + RuntimeTopology::Shared => RuntimeKey::Shared { + scheme: self.scheme.clone(), + generation: self.generation, + }, + RuntimeTopology::PerBinding => RuntimeKey::PerBinding { + scheme: self.scheme.clone(), + generation: self.generation, + binding: hash_text(&self.stable_key), + }, + } + } +} + +fn desired_bindings( + catalog_root: &Path, + this_host: &str, + config: &CatalogConfig, + profiles: &ResourceProfileRegistry, + generation: Option, + live_specs: &[AgentSpec], +) -> (BTreeMap, Vec) { + let catalog_generation = generation.unwrap_or(0); + let refresh = profiles.begin_refresh(); + let mut descriptors = BTreeMap::new(); + let mut runtimes = BTreeMap::new(); + let mut generations = BTreeMap::new(); + let mut warnings = Vec::new(); + for declared in &config.profiles { + let Some(runtime) = declared.runtime.as_ref() else { + continue; + }; + match refresh.try_descriptor(&declared.scheme) { + Ok(Some(descriptor)) + if descriptor.capabilities.contains(&ProfileCapability::Observe) => + { + generations.insert( + declared.scheme.clone(), + profile_generation(catalog_generation, declared, &descriptor, profiles), + ); + descriptors.insert(declared.scheme.clone(), descriptor); + runtimes.insert(declared.scheme.clone(), runtime.clone()); + } + Ok(_) => warnings.push(format!( + "Resource Profile '{}': runtime has no observable descriptor", + declared.scheme + )), + Err(error) => warnings.push(format!( + "Resource Profile '{}': descriptor unavailable: {error}", + declared.scheme + )), + } + } + + let mut desired = BTreeMap::new(); + for spec in live_specs { + let declaration = lexical_absolute(&spec.path).unwrap_or_else(|_| spec.path.clone()); + let agent_dir = declaration.parent().unwrap_or(Path::new("/")); + for resource in &spec.resources { + if resource.inactive_reason().is_some() { + continue; + } + let Some((scheme, _)) = resource.uri().split_once(':') else { + continue; + }; + let (Some(descriptor), Some(runtime), Some(generation)) = ( + descriptors.get(scheme), + runtimes.get(scheme), + generations.get(scheme), + ) else { + continue; + }; + let selector = resource + .selector() + .cloned() + .unwrap_or_else(|| descriptor.default_selector.clone()); + if let Err(error) = descriptor.validate_selector(&selector) { + warnings.push(format!( + "Resource Profile '{}' binding {} resource '{}': {error}", + scheme, + spec.bus_id(this_host), + resource.name() + )); + continue; + } + let resolution = match refresh.try_resolve(agent_dir, resource.uri()) { + Ok(Some(resolution)) => resolution, + Ok(None) => { + warnings.push(format!( + "Resource Profile '{}' binding {} resource '{}': resolver returned no carrier", + scheme, + spec.bus_id(this_host), + resource.name() + )); + continue; + } + Err(error) => { + warnings.push(format!( + "Resource Profile '{}' binding {} resource '{}': {error}", + scheme, + spec.bus_id(this_host), + resource.name() + )); + continue; + } + }; + let target = match SnapshotTarget::new( + resolution.containment_root, + &resolution.path, + ) { + Ok(target) => target, + Err(error) => { + warnings.push(format!( + "Resource Profile '{}' binding {} resource '{}': {error}", + scheme, + spec.bus_id(this_host), + resource.name() + )); + continue; + } + }; + let recipient = spec.bus_id(this_host); + let stable_key = format!("{recipient}\0{}", resource.name()); + desired.insert( + stable_key.clone(), + DesiredBinding { + stable_key, + catalog_root: catalog_root.to_path_buf(), + this_host: this_host.to_owned(), + recipient, + binding_name: resource.name().to_owned(), + scheme: scheme.to_owned(), + generation: *generation, + topology: descriptor.runtime.topology, + argv: runtime.argv.clone(), + uri: resource.uri().to_owned(), + selector, + descriptor: descriptor.clone(), + target, + }, + ); + } + } + (desired, warnings) +} + +struct Worker { + catalog_root: PathBuf, + this_host: String, + tx: SyncSender, + runtimes: BTreeMap, +} + +impl Worker { + fn new(catalog_root: PathBuf, this_host: String, tx: SyncSender) -> Self { + Self { + catalog_root, + this_host, + tx, + runtimes: BTreeMap::new(), + } + } + + fn run(mut self, rx: Receiver) { + while let Ok(message) = rx.recv() { + match message { + Msg::Refresh { desired, reply } => { + let warnings = self.reconcile(desired); + let _ = reply.send(warnings); + } + Msg::Deactivate { recipient, reply } => { + self.deactivate_recipient(&recipient); + let _ = reply.send(()); + } + Msg::Health { reply } => { + let health = self + .runtimes + .values() + .flat_map(RuntimeProcess::health) + .collect(); + let _ = reply.send(health); + } + Msg::RuntimeOutput { key, owner, output } => { + self.runtime_output(&key, &owner, output); + } + Msg::RuntimeEof { key, owner } => { + self.runtime_failed(&key, &owner, "runtime protocol reached EOF"); + } + Msg::WriterFailed { key, owner, error } => { + self.runtime_failed(&key, &owner, &error); + } + Msg::Shutdown { reply } => { + self.stop_all(); + let _ = reply.send(()); + break; + } + } + } + self.stop_all(); + } + + fn reconcile(&mut self, desired: BTreeMap) -> Vec { + let mut warnings = Vec::new(); + let desired_runtime_keys = desired + .values() + .map(DesiredBinding::runtime_key) + .collect::>(); + let obsolete = self + .runtimes + .keys() + .filter(|key| !desired_runtime_keys.contains(*key)) + .cloned() + .collect::>(); + for key in obsolete { + if let Some(mut runtime) = self.runtimes.remove(&key) { + runtime.deactivate_all(); + runtime.stop(); + } + } + + let mut grouped: BTreeMap> = BTreeMap::new(); + for binding in desired.into_values() { + grouped.entry(binding.runtime_key()).or_default().push(binding); + } + for (key, bindings) in grouped { + if !self.runtimes.contains_key(&key) { + match RuntimeProcess::spawn( + key.clone(), + &bindings[0], + self.tx.clone(), + &self.catalog_root, + &self.this_host, + ) { + Ok(runtime) => { + self.runtimes.insert(key.clone(), runtime); + } + Err(error) => { + warnings.push(format!( + "Resource Profile '{}': runtime spawn failed: {error:#}", + bindings[0].scheme + )); + continue; + } + } + } + let Some(runtime) = self.runtimes.get_mut(&key) else { + continue; + }; + if let Err(error) = runtime.reconcile_bindings(bindings) { + warnings.push(format!( + "Resource Profile '{}': runtime registration failed: {error:#}", + runtime.scheme + )); + if let Some(mut failed) = self.runtimes.remove(&key) { + failed.stop(); + } + } + } + warnings + } + + fn deactivate_recipient(&mut self, recipient: &str) { + for runtime in self.runtimes.values_mut() { + runtime.deactivate_recipient(recipient); + } + let empty = self + .runtimes + .iter() + .filter_map(|(key, runtime)| runtime.bindings.is_empty().then_some(key.clone())) + .collect::>(); + for key in empty { + if let Some(mut runtime) = self.runtimes.remove(&key) { + runtime.stop(); + } + } + } + + fn runtime_output( + &mut self, + key: &RuntimeKey, + owner: &RuntimeOwner, + output: Result, + ) { + let Some(runtime) = self.runtimes.get_mut(key) else { + return; + }; + if !owner_matches(Some(&runtime.owner), owner) { + return; + } + let failure = match output { + Ok(message) => runtime + .accept(message, &self.catalog_root, &self.this_host) + .err() + .map(|error| format!("runtime output rejected: {error:#}")), + Err(error) => Some(format!("runtime protocol error: {error}")), + }; + if let Some(detail) = failure { + self.runtime_failed(key, owner, &detail); + } + } + + fn runtime_failed(&mut self, key: &RuntimeKey, owner: &RuntimeOwner, detail: &str) { + if !owner_matches(self.runtimes.get(key).map(|runtime| &runtime.owner), owner) { + return; + } + if let Some(mut runtime) = self.runtimes.remove(key) { + eprintln!("st2: Resource Profile '{}': {detail}", runtime.scheme); + runtime.stop(); + } + } + + fn stop_all(&mut self) { + for (_, mut runtime) in std::mem::take(&mut self.runtimes) { + runtime.deactivate_all(); + runtime.stop(); + } + } +} + +struct RuntimeProcess { + scheme: String, + owner: RuntimeOwner, + lifecycle: RuntimeLifecycle, + child: Child, + writer: Option>>, + writer_thread: Option>, + reader_thread: Option>, + bindings: BTreeMap, + process_health: ResourceProfileHealth, +} + +struct ActiveBinding { + desired: DesiredBinding, + binding_id: BindingId, + registration: RegistrationToken, + catch_up: CatchUp, + health: ResourceProfileHealth, +} + +impl RuntimeProcess { + fn spawn( + key: RuntimeKey, + sample: &DesiredBinding, + supervisor_tx: SyncSender, + catalog_root: &Path, + this_host: &str, + ) -> anyhow::Result { + let executable = sample + .argv + .first() + .context("runtime argv is unexpectedly empty")?; + let mut command = Command::new(executable); + command + .args(&sample.argv[1..]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + let mut child = command.spawn().with_context(|| format!("spawn {executable:?}"))?; + let stdin = child.stdin.take().context("capture runtime stdin")?; + let stdout = child.stdout.take().context("capture runtime stdout")?; + let sequence = ID_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let incarnation = RuntimeIncarnation::new(format!("{}-{sequence}", sample.generation))?; + let claim = OwnerClaim::new(hash_text(&format!( + "{}\0{}\0{}\0{sequence}", + catalog_root.display(), + this_host, + sample.scheme + )))?; + let owner = RuntimeOwner::new(incarnation, claim); + let mut lifecycle = RuntimeLifecycle::new(); + lifecycle.claim(owner.clone()); + + let (writer_tx, writer_rx) = mpsc::sync_channel::>(WRITER_CAPACITY); + let writer_key = key.clone(); + let writer_owner = owner.clone(); + let writer_supervisor = supervisor_tx.clone(); + let writer_thread = thread::Builder::new() + .name("st2-resource-profile-stdin".to_owned()) + .spawn(move || { + runtime_writer(stdin, writer_rx, writer_key, writer_owner, writer_supervisor) + })?; + let reader_key = key.clone(); + let reader_owner = owner.clone(); + let reader_thread = thread::Builder::new() + .name("st2-resource-profile-stdout".to_owned()) + .spawn(move || runtime_reader(stdout, reader_key, reader_owner, supervisor_tx))?; + + Ok(Self { + scheme: sample.scheme.clone(), + owner, + lifecycle, + child, + writer: Some(writer_tx), + writer_thread: Some(writer_thread), + reader_thread: Some(reader_thread), + bindings: BTreeMap::new(), + process_health: ResourceProfileHealth { + scheme: sample.scheme.clone(), + binding: None, + state: RuntimeHealthState::Starting, + detail: None, + }, + }) + } + + fn reconcile_bindings(&mut self, desired: Vec) -> anyhow::Result<()> { + let desired_keys = desired + .iter() + .map(|binding| binding.stable_key.clone()) + .collect::>(); + let removed = self + .bindings + .keys() + .filter(|key| !desired_keys.contains(*key)) + .cloned() + .collect::>(); + for key in removed { + self.unregister(&key); + } + for binding in desired { + if self + .bindings + .get(&binding.stable_key) + .is_some_and(|active| active.desired == binding) + { + if let Some(active) = self.bindings.get_mut(&binding.stable_key) { + let _ = emit_pending_for(active); + } + continue; + } + self.unregister(&binding.stable_key); + self.register(binding)?; + } + Ok(()) + } + + fn register(&mut self, desired: DesiredBinding) -> anyhow::Result<()> { + let sequence = ID_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let binding_id = BindingId::new(hash_text(&format!( + "{}\0{}\0{}", + desired.stable_key, desired.generation, sequence + )))?; + let registration = RegistrationToken::new(hash_text(&format!( + "{}\0{}\0{sequence}", + self.owner.claim().as_str(), desired.stable_key + )))?; + let selection = selector_topics(&desired.selector)?; + let contract = PublicationContract::new( + desired.descriptor.snapshot.schema_id.clone(), + desired.descriptor.snapshot.media_type.clone(), + desired + .descriptor + .topics + .iter() + .map(|topic| topic.name.clone()), + selection, + )?; + let registration_state = BindingRegistration::new( + binding_id.clone(), + registration.clone(), + desired.target.clone(), + contract, + ); + self.lifecycle.register(&self.owner, registration_state)?; + + let state_directory = binding_state_directory(&desired)?; + std::fs::create_dir_all(&state_directory) + .with_context(|| format!("create {}", state_directory.display()))?; + let mut catch_up = CatchUp::open_for_snapshot(&state_directory, &desired.target)?; + let pending = catch_up.set_deliverable(true)?; + let previous_digest = catch_up.state().current_snapshot_digest(); + let message = HostMessage::Register { + owner: self.owner.clone(), + binding_id: binding_id.clone(), + registration: registration.clone(), + uri: desired.uri.clone(), + selector: desired.selector.clone(), + carrier_path: desired.target.path(), + previous_digest, + }; + self.send(message)?; + let mut active = ActiveBinding { + health: ResourceProfileHealth { + scheme: desired.scheme.clone(), + binding: Some(desired.binding_name.clone()), + state: RuntimeHealthState::Starting, + detail: None, + }, + desired, + binding_id, + registration, + catch_up, + }; + if pending.is_some() { + let _ = emit_pending_for(&mut active); + } + self.bindings + .insert(active.desired.stable_key.clone(), active); + Ok(()) + } + + fn unregister(&mut self, stable_key: &str) { + let Some(mut active) = self.bindings.remove(stable_key) else { + return; + }; + let _ = active.catch_up.set_deliverable(false); + let message = HostMessage::Unregister { + owner: self.owner.clone(), + binding_id: active.binding_id.clone(), + registration: active.registration.clone(), + }; + let _ = self.send(message); + let _ = self.lifecycle.unregister( + &self.owner, + &active.binding_id, + &active.registration, + ); + } + + fn deactivate_recipient(&mut self, recipient: &str) { + let removed = self + .bindings + .iter() + .filter_map(|(key, active)| { + (active.desired.recipient == recipient).then_some(key.clone()) + }) + .collect::>(); + for key in removed { + self.unregister(&key); + } + } + + fn deactivate_all(&mut self) { + let keys = self.bindings.keys().cloned().collect::>(); + for key in keys { + self.unregister(&key); + } + } + + fn accept( + &mut self, + message: RuntimeMessage, + catalog_root: &Path, + this_host: &str, + ) -> anyhow::Result<()> { + let binding_id = match &message { + RuntimeMessage::Publish { binding_id, .. } => Some(binding_id.clone()), + RuntimeMessage::Health { binding_id, .. } => binding_id.clone(), + }; + match self.lifecycle.accept_output(&message)? { + AcceptedOutput::Publication(publication) => { + let active = self + .bindings + .values_mut() + .find(|active| Some(&active.binding_id) == binding_id.as_ref()) + .context("accepted publication has no active binding")?; + let (_, pending) = active.catch_up.publish(publication)?; + if pending.is_some() { + emit_pending(catalog_root, this_host, active)?; + } + } + AcceptedOutput::Health(health) => { + if let Some(binding_id) = health.binding_id() { + if let Some(active) = self + .bindings + .values_mut() + .find(|active| &active.binding_id == binding_id) + { + active.health.state = health.state(); + active.health.detail = health.detail().map(str::to_owned); + } + } else { + self.process_health.state = health.state(); + self.process_health.detail = health.detail().map(str::to_owned); + } + } + } + Ok(()) + } + + fn send(&self, message: HostMessage) -> anyhow::Result<()> { + let line = encode_host_line(&message)?; + let writer = self.writer.as_ref().context("runtime stdin is closed")?; + match writer.try_send(line) { + Ok(()) => Ok(()), + Err(TrySendError::Full(_)) => anyhow::bail!("runtime stdin queue is full"), + Err(TrySendError::Disconnected(_)) => anyhow::bail!("runtime stdin is disconnected"), + } + } + + fn health(&self) -> Vec { + std::iter::once(self.process_health.clone()) + .chain(self.bindings.values().map(|active| active.health.clone())) + .collect() + } + + fn stop(&mut self) { + self.writer.take(); + let _ = self.child.kill(); + let _ = self.child.wait(); + if let Some(thread) = self.writer_thread.take() { + let _ = thread.join(); + } + if let Some(thread) = self.reader_thread.take() { + let _ = thread.join(); + } + } +} + +fn runtime_writer( + mut stdin: std::process::ChildStdin, + rx: Receiver>, + key: RuntimeKey, + owner: RuntimeOwner, + supervisor: SyncSender, +) { + while let Ok(line) = rx.recv() { + if let Err(error) = stdin.write_all(&line).and_then(|_| stdin.flush()) { + let _ = supervisor.send(Msg::WriterFailed { + key, + owner, + error: format!("runtime stdin failed: {error}"), + }); + return; + } + } +} + +fn runtime_reader( + stdout: std::process::ChildStdout, + key: RuntimeKey, + owner: RuntimeOwner, + supervisor: SyncSender, +) { + let mut reader = BufReader::new(stdout); + loop { + match read_bounded_line(&mut reader) { + Ok(Some(line)) => { + let output = decode_runtime_line(&line).map_err(|error| error.to_string()); + if supervisor + .send(Msg::RuntimeOutput { + key: key.clone(), + owner: owner.clone(), + output, + }) + .is_err() + { + return; + } + } + Ok(None) => { + let _ = supervisor.send(Msg::RuntimeEof { key, owner }); + return; + } + Err(error) => { + let _ = supervisor.send(Msg::RuntimeOutput { + key, + owner, + output: Err(error.to_string()), + }); + return; + } + } + } +} + +fn read_bounded_line(reader: &mut impl BufRead) -> io::Result>> { + let mut line = Vec::new(); + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + return if line.is_empty() { Ok(None) } else { Ok(Some(line)) }; + } + if let Some(newline) = available.iter().position(|byte| *byte == b'\n') { + let consumed = newline + 1; + if line.len() + consumed > MAX_PROTOCOL_LINE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "runtime protocol line exceeds 2 MiB", + )); + } + line.extend_from_slice(&available[..consumed]); + reader.consume(consumed); + return Ok(Some(line)); + } + if line.len() + available.len() >= MAX_PROTOCOL_LINE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "runtime protocol line exceeds 2 MiB", + )); + } + let consumed = available.len(); + line.extend_from_slice(available); + reader.consume(consumed); + } +} + +fn selector_topics(selector: &Value) -> anyhow::Result { + let topics = selector + .as_object() + .and_then(|object| object.get("topics")) + .and_then(Value::as_array) + .map(|topics| { + topics + .iter() + .map(|topic| { + topic + .as_str() + .map(str::to_owned) + .context("selector topic is not a string") + }) + .collect::>>() + }) + .transpose()? + .unwrap_or_default(); + Ok(TopicSelection::new(topics)?) +} + +fn profile_generation( + catalog_generation: u64, + declared: &crate::catalog::DeclaredProfile, + descriptor: &ProfileDescriptor, + profiles: &ResourceProfileRegistry, +) -> u64 { + let module_identity = profiles + .get(&declared.scheme) + .and_then(|profile| profile.module()) + .and_then(|module| std::fs::metadata(module).ok()) + .map(|metadata| { + format!( + "{}:{:?}:{:?}", + metadata.len(), + metadata.modified().ok(), + metadata.created().ok() + ) + }) + .unwrap_or_default(); + let input = format!( + "{catalog_generation}\0{}\0{}\0{}\0{}\0{:?}\0{:?}\0{module_identity}", + declared.scheme, + declared.wasm, + declared.class, + declared.notify_chain, + declared.runtime.as_ref().map(|runtime| &runtime.argv), + descriptor, + ); + let digest = Sha256::digest(input.as_bytes()); + u64::from_be_bytes(digest[..8].try_into().expect("SHA-256 prefix is eight bytes")) +} + +fn emit_pending(catalog_root: &Path, this_host: &str, active: &mut ActiveBinding) -> anyhow::Result<()> { + emit_pending_at(catalog_root, this_host, active) +} + +fn emit_pending_for(active: &mut ActiveBinding) -> anyhow::Result<()> { + let root = active.desired.catalog_root.clone(); + let host = active.desired.this_host.clone(); + emit_pending_at(&root, &host, active) +} + +fn emit_pending_at( + catalog_root: &Path, + this_host: &str, + active: &mut ActiveBinding, +) -> anyhow::Result<()> { + let Some(delivery) = active.catch_up.pending_delivery() else { + return Ok(()); + }; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Body<'a> { + binding: &'a str, + snapshot_digest: String, + topics: &'a [String], + } + let digest = delivery.digest(); + let topics = delivery.selected_topics().to_vec(); + let body = serde_json::to_string(&Body { + binding: &active.desired.binding_name, + snapshot_digest: digest.to_string(), + topics: &topics, + })?; + let event_id = publication_event_id( + &active.desired.recipient, + &active.desired.binding_name, + digest, + ); + let subject = format!("resource {} changed", active.desired.binding_name); + crate::event::emit_builtin_resync( + catalog_root, + this_host, + &active.desired.recipient, + &event_id, + Some(&active.desired.binding_name), + Some(&subject), + &body, + true, + )?; + active.catch_up.acknowledge_delivery(digest)?; + Ok(()) +} + +fn publication_event_id(recipient: &str, binding: &str, digest: SnapshotDigest) -> String { + hash_text(&format!("resource-profile\0{recipient}\0{binding}\0{digest}")) +} + +fn binding_state_directory(desired: &DesiredBinding) -> anyhow::Result { + let state = lexical_absolute(&crate::run::state_root())?; + Ok(state + .join("st2") + .join("resource-profiles") + .join(hash_path(&desired.catalog_root)) + .join(hash_text(&desired.this_host)) + .join(hash_text(&format!( + "{}\0{}\0{}", + desired.recipient, desired.scheme, desired.binding_name + )))) +} + +fn lexical_absolute(path: &Path) -> anyhow::Result { + let path = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::RootDir | Component::Prefix(_) | Component::Normal(_) => { + normalized.push(component.as_os_str()); + } + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + } + } + Ok(normalized) +} + +fn hash_path(path: &Path) -> String { + hash_text(&path.to_string_lossy()) +} + +fn owner_matches(current: Option<&RuntimeOwner>, message: &RuntimeOwner) -> bool { + current == Some(message) +} + +fn hash_text(value: &str) -> String { + format!("{:x}", Sha256::digest(value.as_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bounded_reader_accepts_one_maximal_line_and_rejects_overflow() { + let mut maximal = vec![b'x'; MAX_PROTOCOL_LINE_BYTES - 1]; + maximal.push(b'\n'); + assert_eq!( + read_bounded_line(&mut maximal.as_slice()).unwrap().unwrap().len(), + MAX_PROTOCOL_LINE_BYTES + ); + let mut overflow = vec![b'x'; MAX_PROTOCOL_LINE_BYTES]; + overflow.push(b'\n'); + assert!(read_bounded_line(&mut overflow.as_slice()).is_err()); + } + + #[test] + fn runtime_keys_enforce_shared_and_per_binding_topology() { + let shared_a = RuntimeKey::Shared { + scheme: "dev.x".into(), + generation: 7, + }; + let shared_b = RuntimeKey::Shared { + scheme: "dev.x".into(), + generation: 7, + }; + assert_eq!(shared_a, shared_b); + let per_a = RuntimeKey::PerBinding { + scheme: "dev.x".into(), + generation: 7, + binding: hash_text("a"), + }; + let per_b = RuntimeKey::PerBinding { + scheme: "dev.x".into(), + generation: 7, + binding: hash_text("b"), + }; + assert_ne!(per_a, per_b); + } + + #[test] + fn stale_output_key_cannot_alias_a_hot_reloaded_generation() { + assert_ne!( + RuntimeKey::Shared { + scheme: "dev.x".into(), + generation: 1, + }, + RuntimeKey::Shared { + scheme: "dev.x".into(), + generation: 2, + } + ); + } + + #[test] + fn stale_owner_envelopes_cannot_target_a_replacement_with_the_same_runtime_key() { + let old = RuntimeOwner::new( + RuntimeIncarnation::new("incarnation-1").unwrap(), + OwnerClaim::new("claim-1").unwrap(), + ); + let replacement = RuntimeOwner::new( + RuntimeIncarnation::new("incarnation-2").unwrap(), + OwnerClaim::new("claim-2").unwrap(), + ); + assert!(owner_matches(Some(&replacement), &replacement)); + assert!(!owner_matches(Some(&replacement), &old)); + assert!(!owner_matches(None, &old)); + } + + #[test] + fn stale_protocol_failure_does_not_remove_the_replacement_process() { + let old = RuntimeOwner::new( + RuntimeIncarnation::new("incarnation-1").unwrap(), + OwnerClaim::new("claim-1").unwrap(), + ); + let replacement = RuntimeOwner::new( + RuntimeIncarnation::new("incarnation-2").unwrap(), + OwnerClaim::new("claim-2").unwrap(), + ); + let mut lifecycle = RuntimeLifecycle::new(); + lifecycle.claim(replacement.clone()); + let child = Command::new(std::env::current_exe().unwrap()) + .arg("--help") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let key = RuntimeKey::Shared { + scheme: "dev.x".into(), + generation: 1, + }; + let (tx, _rx) = mpsc::sync_channel(1); + let mut worker = Worker::new(PathBuf::from("/"), "host".into(), tx); + worker.runtimes.insert( + key.clone(), + RuntimeProcess { + scheme: "dev.x".into(), + owner: replacement, + lifecycle, + child, + writer: None, + writer_thread: None, + reader_thread: None, + bindings: BTreeMap::new(), + process_health: ResourceProfileHealth { + scheme: "dev.x".into(), + binding: None, + state: RuntimeHealthState::Starting, + detail: None, + }, + }, + ); + + worker.runtime_output(&key, &old, Err("stale malformed output".into())); + assert!(worker.runtimes.contains_key(&key)); + worker.stop_all(); + } +} diff --git a/src/run.rs b/src/run.rs index c671be68..7f1fcd6e 100644 --- a/src/run.rs +++ b/src/run.rs @@ -1658,6 +1658,7 @@ fn reconcile_pass( debounce: &mut LivenessDebounce, presentation_cursor: &mut PresentationPatchCursor, resync: Option<&crate::resync::ResyncSupervisor>, + resource_profiles: Option<&crate::resource_profile_supervisor::ResourceProfileSupervisor>, ) -> UpReport { let _catalog_lock = { let span = catalog_lock_span(); @@ -1853,6 +1854,13 @@ fn reconcile_pass( } } } + if let Some(resource_profiles) = resource_profiles { + for launch in &plan.launch { + if launch.tasks.iter().any(|task| task.name == "agent") { + resource_profiles.deactivate(launch.spec); + } + } + } gate_harness_launches_on_hooks(&mut plan, root, &mut report, |_| match &hook_error { Some(error) => anyhow::bail!("{error}"), None => Ok(()), @@ -1883,37 +1891,65 @@ fn reconcile_pass( &mut install_new_live_seat, ); report.warnings.extend(boundary_warnings); - if let Some(resync) = resync { - let profiles = crate::catalog::declared_profiles(root) + if resync.is_some() || resource_profiles.is_some() { + let loaded = crate::catalog::declared_profile_catalog(root) .context("parse resource profiles in catalog.kdl"); - let catalog_profile_error = profiles.is_err(); + let catalog_profile_error = loaded.is_err(); let malformed_declarations = found .errors .iter() .map(|error| error.path.clone()) .filter(|path| { - // An invalid profile envelope must drop profile-resolved carriers rather than - // preserve their stale semantics through malformed-declaration retention. !catalog_profile_error || *path != crate::catalog::config_path(root) }) .collect::>(); - let profiles = match profiles { - Ok(profiles) => profiles, + let (config, profiles) = match loaded { + Ok(loaded) => loaded, Err(error) => { report.errors.push(format!("{error:#}")); - agent_spec::profile::ResourceProfileRegistry::empty() + ( + crate::catalog::CatalogConfig::default(), + agent_spec::profile::ResourceProfileRegistry::empty(), + ) } }; let live_subscription_specs = live_resync_specs(&compiled_specs, this_host, &sessions, &report); - report.warnings.extend(resync.refresh_with_profiles( - profiles, - &found.specs, - &live_subscription_specs, - this_host, - &sessions, - &malformed_declarations, - )); + if let Some(resource_profiles) = resource_profiles { + let generation = match crate::catalog_lock::read_generation_token(root) { + Ok(generation) => generation, + Err(error) => { + report.errors.push(format!( + "read catalog generation for Resource Profiles: {error:#}" + )); + None + } + }; + report.warnings.extend( + resource_profiles + .refresh(&config, &profiles, generation, &live_subscription_specs) + .warnings, + ); + } + if let Some(resync) = resync { + let passive = match crate::catalog::passive_profiles(&config, &profiles) { + Ok(passive) => passive, + Err(error) => { + report.errors.push(format!( + "derive passive Resource Profile registry: {error:#}" + )); + agent_spec::profile::ResourceProfileRegistry::empty() + } + }; + report.warnings.extend(resync.refresh_with_profiles( + passive, + &found.specs, + &live_subscription_specs, + this_host, + &sessions, + &malformed_declarations, + )); + } } report } @@ -2161,16 +2197,14 @@ pub fn up_once(root: &Path, this_host: &str, runner: &dyn Runner) -> anyhow::Res let span = reconcile_span(this_host, "catalog"); let report = { let _entered = span.enter(); - let report = reconcile_pass( - root, - this_host, - &task_context, - runner, - &mut FlappingCap::default(), - &mut debounce, - &mut PresentationPatchCursor::default(), - None, - ); + let report = reconcile_pass(root, + this_host, + &task_context, + runner, + &mut FlappingCap::default(), + &mut debounce, + &mut PresentationPatchCursor::default(), + None, None); finish_reconcile_pass(&span, &report); report }; @@ -2804,6 +2838,7 @@ fn up_loop_until( // Once initialized, every reconcile pass reloads profiles and atomically replaces the registry // with the watch set; malformed later edits install an empty, fail-closed profile set. let mut resync = None; + let mut resource_profiles = None; let mut reported_flapping: HashSet = HashSet::new(); let mut recurring_warnings = RecurringWarnings::default(); let park_channel = ParkChannel::for_supervisor(root, this_host); @@ -2828,31 +2863,38 @@ fn up_loop_until( continue; } }; - let profiles = crate::catalog::declared_profiles(root) + let (config, profiles) = crate::catalog::declared_profile_catalog(root) .context("parse resource profiles in catalog.kdl")?; + let passive_profiles = crate::catalog::passive_profiles(&config, &profiles) + .context("derive passive Resource Profile registry")?; crate::event::publish_owner_binding_under_lock(root, this_host, &catalog_lock) .context("publish machine-local stream owner binding")?; resync = Some(crate::resync::ResyncSupervisor::with_profiles( root.to_path_buf(), this_host.to_owned(), - profiles, + passive_profiles, )); + resource_profiles = Some( + crate::resource_profile_supervisor::ResourceProfileSupervisor::new( + root.to_path_buf(), + this_host.to_owned(), + )?, + ); } let mut report = { let started = Instant::now(); let span = reconcile_span(this_host, "catalog"); let pass = { let _entered = span.enter(); - let pass = reconcile_pass( - root, - this_host, - &task_context, - runner, - &mut cap, - &mut debounce, - &mut presentation_cursor, - resync.as_ref(), - ); + let pass = reconcile_pass(root, + this_host, + &task_context, + runner, + &mut cap, + &mut debounce, + &mut presentation_cursor, + resync.as_ref(), + resource_profiles.as_ref()); finish_reconcile_pass(&span, &pass); pass }; @@ -4411,16 +4453,14 @@ mod tests { let mut debounce = LivenessDebounce::new(DEBOUNCE_GRACE); let mut presentation_cursor = PresentationPatchCursor::default(); - let first = reconcile_pass( - catalog.path(), - "hetz", - &task_context, - &runner, - &mut cap, - &mut debounce, - &mut presentation_cursor, - Some(&resync), - ); + let first = reconcile_pass(catalog.path(), + "hetz", + &task_context, + &runner, + &mut cap, + &mut debounce, + &mut presentation_cursor, + Some(&resync), None); assert!( first.errors.iter().any(|error| { error.contains("compile generated tasks") @@ -4458,16 +4498,14 @@ mod tests { }"#, ) .unwrap(); - let corrected = reconcile_pass( - catalog.path(), - "hetz", - &task_context, - &runner, - &mut cap, - &mut debounce, - &mut presentation_cursor, - Some(&resync), - ); + let corrected = reconcile_pass(catalog.path(), + "hetz", + &task_context, + &runner, + &mut cap, + &mut debounce, + &mut presentation_cursor, + Some(&resync), None); assert!( corrected .errors @@ -4525,16 +4563,14 @@ mod tests { let mut debounce = LivenessDebounce::new(DEBOUNCE_GRACE); let mut presentation_cursor = PresentationPatchCursor::default(); - let failed = reconcile_pass( - catalog.path(), - "hetz", - &task_context, - &runner, - &mut cap, - &mut debounce, - &mut presentation_cursor, - Some(&resync), - ); + let failed = reconcile_pass(catalog.path(), + "hetz", + &task_context, + &runner, + &mut cap, + &mut debounce, + &mut presentation_cursor, + Some(&resync), None); assert!( failed .errors @@ -4563,16 +4599,14 @@ mod tests { std::fs::write(&live_goal, "changed immediately before recovery\n").unwrap(); std::fs::create_dir_all(catalog.path().join("_templates")).unwrap(); std::fs::write(catalog.path().join("_templates/live.md"), "rendered\n").unwrap(); - let recovered = reconcile_pass( - catalog.path(), - "hetz", - &task_context, - &runner, - &mut cap, - &mut debounce, - &mut presentation_cursor, - Some(&resync), - ); + let recovered = reconcile_pass(catalog.path(), + "hetz", + &task_context, + &runner, + &mut cap, + &mut debounce, + &mut presentation_cursor, + Some(&resync), None); assert!( recovered .errors @@ -4818,16 +4852,14 @@ mod tests { let mut debounce = LivenessDebounce::new(Duration::ZERO); let mut presentation_cursor = PresentationPatchCursor::default(); - let seeded = reconcile_pass( - catalog.path(), - "hetz", - &task_context, - &runner, - &mut cap, - &mut debounce, - &mut presentation_cursor, - Some(&resync), - ); + let seeded = reconcile_pass(catalog.path(), + "hetz", + &task_context, + &runner, + &mut cap, + &mut debounce, + &mut presentation_cursor, + Some(&resync), None); assert!(seeded.adopted.iter().any(|identity| identity == "worker")); *runner.sessions.borrow_mut() = vec![sess("hetz.worker", false)]; let blocked_goal = goal.clone(); @@ -4839,16 +4871,14 @@ mod tests { std::thread::sleep(Duration::from_secs(1)); release_tx.send(()).unwrap(); }); - reconcile_pass( - catalog.path(), - "hetz", - &task_context, - &runner, - &mut cap, - &mut debounce, - &mut presentation_cursor, - Some(&resync), - ) + reconcile_pass(catalog.path(), + "hetz", + &task_context, + &runner, + &mut cap, + &mut debounce, + &mut presentation_cursor, + Some(&resync), None) }); assert_eq!(relaunched.restarted, ["hetz.worker"]); std::thread::sleep(Duration::from_millis(750)); diff --git a/tests/agent_resource.rs b/tests/agent_resource.rs index 19d148ec..91a9cb53 100644 --- a/tests/agent_resource.rs +++ b/tests/agent_resource.rs @@ -101,7 +101,7 @@ fn ls_json_and_read_expose_every_declared_field() { &declaration( "worker", "catalog", - " resource \"work\" reason=\"PR under preparation.\" uri=\"github-pr://github.com/o/r/pull/42\" inactive-reason=\"Superseded by #43.\"\n", + " resource \"work\" reason=\"PR under preparation.\" uri=\"github-pr://github.com/o/r/pull/42\" inactive-reason=\"Superseded by #43.\" selector=#\"{\"topics\":[\"ci.failure\"]}\"#\n", ), ); @@ -115,6 +115,7 @@ fn ls_json_and_read_expose_every_declared_field() { // already emits, and INVARIANTS.md pins that surface to preserve its field names. The // snake_case is inconsistent with sibling roster fields but predates this change. assert_eq!(row["inactive_reason"], "Superseded by #43."); + assert_eq!(row["selector"], serde_json::json!({"topics": ["ci.failure"]})); let read = ok(root, &["resource", "read", "worker", "work"]); assert!( @@ -123,6 +124,7 @@ fn ls_json_and_read_expose_every_declared_field() { ); assert!(read.contains("PR under preparation."), "got: {read}"); assert!(read.contains("Superseded by #43."), "got: {read}"); + assert!(read.contains(r#"{"topics":["ci.failure"]}"#), "got: {read}"); } #[test] @@ -147,14 +149,27 @@ fn add_publishes_one_binding_and_is_idempotent_on_identical_bytes() { "github-pr://github.com/o/r/pull/42", "--reason", "PR under preparation.", + "--selector-json", + r####"{"literal":"a\"#b\"##c","topics":["ci.failure"]}"####, "--json", ], ); let receipt: serde_json::Value = serde_json::from_str(&added).unwrap(); assert_eq!(receipt["result"], "changed"); + assert_eq!( + receipt["selector"], + serde_json::json!({ + "literal": "a\"#b\"##c", + "topics": ["ci.failure"] + }) + ); let after = spec(root); assert!(after.contains("resource \"work\""), "got:\n{after}"); + assert!( + after.contains(r####"selector=###"{"literal":"a\"#b\"##c","topics":["ci.failure"]}"###"####), + "selector must use the smallest safe raw-string fence:\n{after}" + ); assert!( after.contains("// unrelated comment"), "unrelated bytes must survive:\n{after}" @@ -176,6 +191,8 @@ fn add_publishes_one_binding_and_is_idempotent_on_identical_bytes() { "github-pr://github.com/o/r/pull/42", "--reason", "PR under preparation.", + "--selector-json", + r####"{"literal":"a\"#b\"##c","topics":["ci.failure"]}"####, "--json", ], ); @@ -236,7 +253,7 @@ fn remove_is_idempotent_and_rename_refuses_absent_and_colliding_names() { "worker", "catalog", " resource \"notes\" reason=\"Durable notes.\" uri=\"agent-notes://h/worker\"\n\ - resource \"work\" reason=\"PR under preparation.\" uri=\"github-pr://github.com/o/r/pull/42\"\n", + resource \"work\" reason=\"PR under preparation.\" uri=\"github-pr://github.com/o/r/pull/42\" selector=#\"{\"topics\":[\"ci.failure\"]}\"#\n", ), ); @@ -256,6 +273,10 @@ fn remove_is_idempotent_and_rename_refuses_absent_and_colliding_names() { assert_eq!(receipt["result"], "changed"); assert!(spec(root).contains("resource \"current-work\"")); assert!(!spec(root).contains("resource \"work\"")); + assert!( + spec(root).contains(r##"selector=#"{"topics":["ci.failure"]}"#"##), + "rename must preserve selector configuration" + ); let absent = run( root, diff --git a/tests/resource_profile_supervisor_e2e.rs b/tests/resource_profile_supervisor_e2e.rs new file mode 100755 index 00000000..1f976854 --- /dev/null +++ b/tests/resource_profile_supervisor_e2e.rs @@ -0,0 +1,490 @@ +#![cfg(all(unix, feature = "wasm-resolver"))] + +use std::ffi::CString; +use std::fs::{self, File, OpenOptions}; +use std::io::Write as _; +use std::os::unix::ffi::OsStrExt as _; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use st2::resource_profile::{ + BindingId, HostMessage, RegistrationToken, RuntimeHealthState, RuntimeMessage, RuntimeOwner, + SnapshotBytes, decode_host_line, encode_runtime_line, +}; +use st2::resource_profile_supervisor::ResourceProfileSupervisor; + +const SCHEME: &str = "dev.example.observable"; +const SCHEMA_ID: &str = "dev.example.observable.snapshot.v1"; +const MEDIA_TYPE: &str = "application/json"; + +#[derive(Clone)] +struct Registration { + owner: RuntimeOwner, + binding_id: BindingId, + registration: RegistrationToken, +} + +struct RuntimeControl { + register_path: PathBuf, + output: File, +} + +impl RuntimeControl { + fn registration(&self) -> Registration { + wait_until("runtime register message", || { + let line = fs::read(&self.register_path).ok()?; + let message = decode_host_line(&line).ok()?; + let HostMessage::Register { + owner, + binding_id, + registration, + .. + } = message + else { + return None; + }; + Some(Registration { + owner, + binding_id, + registration, + }) + }) + } + + fn publish( + &self, + registration: &Registration, + bytes: &[u8], + topics: &[&str], + health_marker: &str, + ) { + let publication = RuntimeMessage::Publish { + owner: registration.owner.clone(), + binding_id: registration.binding_id.clone(), + registration: registration.registration.clone(), + schema_id: SCHEMA_ID.to_owned(), + media_type: MEDIA_TYPE.to_owned(), + bytes: SnapshotBytes::new(bytes.to_vec()).unwrap(), + topics: topics.iter().map(|topic| (*topic).to_owned()).collect(), + observed_at: None, + }; + let health = RuntimeMessage::Health { + owner: registration.owner.clone(), + binding_id: Some(registration.binding_id.clone()), + registration: Some(registration.registration.clone()), + state: RuntimeHealthState::Ready, + detail: Some(health_marker.to_owned()), + }; + let mut output = &self.output; + output + .write_all(&encode_runtime_line(&publication).unwrap()) + .unwrap(); + output + .write_all(&encode_runtime_line(&health).unwrap()) + .unwrap(); + output.flush().unwrap(); + } +} + +struct CatalogFixture { + root: PathBuf, + host: String, + agent_dir: PathBuf, + runtime: RuntimeControl, +} + +impl CatalogFixture { + fn new(root: PathBuf, host: &str) -> Self { + fs::create_dir_all(&root).unwrap(); + let agent_dir = root.join("agents").join(host).join("worker"); + fs::create_dir_all(&agent_dir).unwrap(); + fs::create_dir_all(agent_dir.join("resources")).unwrap(); + fs::write( + agent_dir.join("agent.kdl"), + format!( + r##"agent "worker" {{ + host "{host}" + command "true" + resource "observed" uri="{SCHEME}://subject" reason="Observed state." selector=#"{{"topics":["selected"]}}"# +}} +"##, + ), + ) + .unwrap(); + + let resolver = root.join("observable-resolver.wasm"); + fs::write(&resolver, observable_resolver_wasm()).unwrap(); + let control_dir = root.join("runtime-control"); + fs::create_dir_all(&control_dir).unwrap(); + let register_path = control_dir.join("register.ndjson"); + let fifo_path = control_dir.join("runtime-output.fifo"); + let fifo = CString::new(fifo_path.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0); + let output = OpenOptions::new() + .read(true) + .write(true) + .open(&fifo_path) + .unwrap(); + + let runtime = root.join("fake-observable-runtime"); + fs::write( + &runtime, + "#!/bin/sh\nset -eu\nIFS= read -r registration\nprintf '%s\\n' \"$registration\" > \"$1/register.ndjson\"\nexec cat \"$1/runtime-output.fifo\"\n", + ) + .unwrap(); + fs::set_permissions(&runtime, fs::Permissions::from_mode(0o755)).unwrap(); + fs::write( + st2::catalog::config_path(&root), + format!( + r#"profile "{SCHEME}" {{ + wasm "observable-resolver.wasm" + class "immediate" + runtime {{ + argv "{}" "{}" + }} +}} +"#, + runtime.display(), + control_dir.display() + ), + ) + .unwrap(); + + Self { + root, + host: host.to_owned(), + agent_dir, + runtime: RuntimeControl { + register_path, + output, + }, + } + } + + fn supervisor(&self) -> ResourceProfileSupervisor { + let supervisor = + ResourceProfileSupervisor::new(self.root.clone(), self.host.clone()).unwrap(); + self.refresh(&supervisor); + supervisor + } + + fn refresh(&self, supervisor: &ResourceProfileSupervisor) { + let (config, profiles) = st2::catalog::declared_profile_catalog(&self.root).unwrap(); + let discovery = st2::discover_strict(&self.root); + assert!( + discovery.errors.is_empty(), + "fixture catalog must be valid: {:?}", + discovery.errors + ); + let report = supervisor.refresh(&config, &profiles, Some(1), &discovery.specs); + assert!( + report.warnings.is_empty(), + "Resource Profile refresh warnings: {:?}", + report.warnings + ); + } + + fn snapshot_path(&self) -> PathBuf { + self.agent_dir.join("resources/snapshot.json") + } + + fn owner_binding_path(&self) -> PathBuf { + let park_dir = st2::park::SupervisorScope::current(&self.root, &self.host) + .unwrap() + .park_dir(); + park_dir.parent().unwrap().join("stream-owner.json") + } +} + +#[test] +fn observable_publication_reaches_builtin_resync_with_filter_catch_up_and_scope_isolation() { + let temporary = tempfile::tempdir().unwrap(); + let state = temporary.path().join("state"); + unsafe { std::env::set_var("XDG_STATE_HOME", &state) }; + + let primary = CatalogFixture::new(temporary.path().join("catalog-primary"), "alpha"); + st2::event::publish_owner_binding_for_test(&primary.root, &primary.host).unwrap(); + let primary_supervisor = primary.supervisor(); + let primary_registration = primary.runtime.registration(); + + let first = br#"{"revision":1}"#; + primary + .runtime + .publish(&primary_registration, first, &["selected"], "primary-first"); + wait_for_health(&primary_supervisor, "primary-first"); + assert_eq!(fs::read(primary.snapshot_path()).unwrap(), first); + let first_inbox = resync_inbox(&primary.agent_dir); + assert_eq!( + first_inbox.len(), + 1, + "the first selected publication must create one built-in resync record" + ); + + primary + .runtime + .publish(&primary_registration, first, &["selected"], "primary-equal"); + wait_for_health(&primary_supervisor, "primary-equal"); + assert_eq!(fs::read(primary.snapshot_path()).unwrap(), first); + assert_eq!( + resync_inbox(&primary.agent_dir), + first_inbox, + "an equal publication must not invalidate the inbox" + ); + + let filtered = br#"{"revision":2}"#; + primary.runtime.publish( + &primary_registration, + filtered, + &["ignored"], + "primary-filtered", + ); + wait_for_health(&primary_supervisor, "primary-filtered"); + assert_eq!(fs::read(primary.snapshot_path()).unwrap(), filtered); + assert_eq!( + resync_inbox(&primary.agent_dir), + first_inbox, + "an unselected topic must update the canonical snapshot without invalidation" + ); + + fs::remove_file(primary.owner_binding_path()).unwrap(); + let caught_up = br#"{"revision":3}"#; + primary.runtime.publish( + &primary_registration, + caught_up, + &["selected"], + "delivery-unavailable", + ); + wait_until("failed runtime after unavailable delivery", || { + (fs::read(primary.snapshot_path()).ok().as_deref() == Some(caught_up) + && primary_supervisor.health().is_empty()) + .then_some(()) + }); + assert_eq!( + resync_inbox(&primary.agent_dir), + first_inbox, + "failed delivery must remain pending rather than forging a local inbox write" + ); + + st2::event::publish_owner_binding_for_test(&primary.root, &primary.host).unwrap(); + primary.refresh(&primary_supervisor); + let caught_up_inbox = resync_inbox(&primary.agent_dir); + assert_eq!(caught_up_inbox.len(), 1, "supersession keeps one unread head"); + assert_ne!( + caught_up_inbox, first_inbox, + "restoring delivery must replace the old head with the pending digest" + ); + let caught_up_projection = file_tree(&primary.agent_dir.join("resources")); + primary.refresh(&primary_supervisor); + assert_eq!( + file_tree(&primary.agent_dir.join("resources")), + caught_up_projection, + "an acknowledged catch-up must not replay on a later equal refresh" + ); + + let isolated = CatalogFixture::new(temporary.path().join("catalog-isolated"), "beta"); + st2::event::publish_owner_binding_for_test(&isolated.root, &isolated.host).unwrap(); + let isolated_supervisor = isolated.supervisor(); + let isolated_registration = isolated.runtime.registration(); + let isolated_first = br#"{"catalog":"isolated","revision":1}"#; + isolated.runtime.publish( + &isolated_registration, + isolated_first, + &["selected"], + "isolated-first", + ); + wait_for_health(&isolated_supervisor, "isolated-first"); + assert_eq!(fs::read(isolated.snapshot_path()).unwrap(), isolated_first); + assert_eq!( + resync_inbox(&isolated.agent_dir).len(), + 1, + "the isolated scope must own a real snapshot and inbox head before teardown" + ); + let isolated_before_drop = file_tree(&isolated.agent_dir.join("resources")); + + drop(primary_supervisor); + assert_eq!( + file_tree(&isolated.agent_dir.join("resources")), + isolated_before_drop, + "tearing down one catalog+host supervisor must not mutate another scope" + ); + + let isolated_second = br#"{"catalog":"isolated","revision":2}"#; + isolated.runtime.publish( + &isolated_registration, + isolated_second, + &["selected"], + "isolated-after-primary-drop", + ); + wait_for_health(&isolated_supervisor, "isolated-after-primary-drop"); + assert_eq!( + fs::read(isolated.snapshot_path()).unwrap(), + isolated_second, + "the isolated supervisor must remain live after the other scope tears down" + ); + assert_ne!( + file_tree(&isolated.agent_dir.join("resources")), + isolated_before_drop, + "the surviving scope must still publish and invalidate" + ); +} + +fn wait_for_health(supervisor: &ResourceProfileSupervisor, marker: &str) { + wait_until(marker, || { + supervisor + .health() + .iter() + .any(|health| health.detail.as_deref() == Some(marker)) + .then_some(()) + }); +} + +fn wait_until(description: &str, mut probe: impl FnMut() -> Option) -> T { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(value) = probe() { + return value; + } + assert!( + Instant::now() < deadline, + "timed out waiting for {description}" + ); + std::thread::yield_now(); + } +} + +fn resync_inbox(agent_dir: &Path) -> Vec { + let inbox = agent_dir.join("resources/inbox"); + let mut records = fs::read_dir(inbox) + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| fs::read_to_string(entry.path()).ok()) + .filter(|contents| contents.contains("stream: resync")) + .collect::>(); + records.sort(); + records +} + +fn file_tree(root: &Path) -> Vec<(PathBuf, Vec)> { + fn visit(base: &Path, directory: &Path, files: &mut Vec<(PathBuf, Vec)>) { + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + visit(base, &path, files); + } else if file_type.is_file() { + files.push((path.strip_prefix(base).unwrap().to_path_buf(), fs::read(path).unwrap())); + } + } + } + + let mut files = Vec::new(); + visit(root, root, &mut files); + files.sort_by(|left, right| left.0.cmp(&right.0)); + files +} + +fn observable_resolver_wasm() -> Vec { + const DESCRIPTOR: &[u8] = br#"{"abiVersion":2,"capabilities":["resolve","read","observe"],"selectorSchema":{"type":"object","properties":{"topics":{"type":"array","items":{"type":"string"},"uniqueItems":true}},"required":["topics"],"additionalProperties":false},"defaultSelector":{"topics":["selected"]},"topics":[{"name":"selected"},{"name":"ignored"}],"runtime":{"topology":"shared"},"snapshot":{"mediaType":"application/json","schemaId":"dev.example.observable.snapshot.v1"}}"#; + const RESOLUTION: &[u8] = br#"{"path":"resources/snapshot.json","class":"observable"}"#; + const DESCRIPTOR_PTR: i64 = 1024; + const RESOLUTION_PTR: i64 = 4096; + + let mut module = b"\0asm\x01\0\0\0".to_vec(); + + let mut types = vec![3, 0x60, 1, 0x7f, 1, 0x7f, 0x60, 4]; + types.extend([0x7f, 0x7f, 0x7f, 0x7f, 1, 0x7e]); + types.extend([0x60, 0, 1, 0x7e]); + push_section(&mut module, 1, &types); + push_section(&mut module, 3, &[3, 0, 1, 2]); + push_section(&mut module, 5, &[1, 0, 1]); + + let mut exports = vec![4]; + push_export(&mut exports, "memory", 0x02, 0); + push_export(&mut exports, "alloc", 0x00, 0); + push_export(&mut exports, "resolve", 0x00, 1); + push_export(&mut exports, "describe", 0x00, 2); + push_section(&mut module, 7, &exports); + + let mut code = vec![3]; + push_body(&mut code, 0x41, 8192); + push_body( + &mut code, + 0x42, + (RESOLUTION_PTR << 32) | RESOLUTION.len() as i64, + ); + push_body( + &mut code, + 0x42, + (DESCRIPTOR_PTR << 32) | DESCRIPTOR.len() as i64, + ); + push_section(&mut module, 10, &code); + + let mut data = vec![2]; + push_data(&mut data, DESCRIPTOR_PTR, DESCRIPTOR); + push_data(&mut data, RESOLUTION_PTR, RESOLUTION); + push_section(&mut module, 11, &data); + module +} + +fn push_section(module: &mut Vec, id: u8, payload: &[u8]) { + module.push(id); + push_u32(module, payload.len() as u32); + module.extend_from_slice(payload); +} + +fn push_export(section: &mut Vec, name: &str, kind: u8, index: u32) { + push_u32(section, name.len() as u32); + section.extend_from_slice(name.as_bytes()); + section.push(kind); + push_u32(section, index); +} + +fn push_body(section: &mut Vec, constant_opcode: u8, value: i64) { + let mut body = vec![0, constant_opcode]; + push_i64(&mut body, value); + body.push(0x0b); + push_u32(section, body.len() as u32); + section.extend(body); +} + +fn push_data(section: &mut Vec, offset: i64, bytes: &[u8]) { + section.push(0); + section.push(0x41); + push_i64(section, offset); + section.push(0x0b); + push_u32(section, bytes.len() as u32); + section.extend_from_slice(bytes); +} + +fn push_u32(bytes: &mut Vec, mut value: u32) { + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + bytes.push(byte); + if value == 0 { + return; + } + } +} + +fn push_i64(bytes: &mut Vec, mut value: i64) { + loop { + let byte = (value as u8) & 0x7f; + value >>= 7; + let done = (value == 0 && byte & 0x40 == 0) || (value == -1 && byte & 0x40 != 0); + bytes.push(if done { byte } else { byte | 0x80 }); + if done { + return; + } + } +} From b8346e10b39912fc3fd2d8583cbf4340a3f243aa Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 30 Aug 2026 01:52:39 +0200 Subject: [PATCH 2/6] fix(resource): create snapshot parent paths securely agent-identity: dev3.direct.omp.2cshu64q agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.9 agent-runtime: OMP 18.0.9 tooling-profile: dotfiles@b607597 --- src/resource_profile.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/resource_profile.rs b/src/resource_profile.rs index d171d710..2e31a325 100644 --- a/src/resource_profile.rs +++ b/src/resource_profile.rs @@ -1183,6 +1183,8 @@ fn prepare_snapshot<'a>( if bytes.len() > MAX_SNAPSHOT_BYTES { return Err(PublicationError::SnapshotTooLarge { actual: bytes.len() }); } + let parent = target.relative.parent().unwrap_or_else(|| Path::new("")); + ensure_absolute_dir_beneath(&target.root, parent).map_err(PublicationError::Io)?; let previous = target.current_digest()?; let digest = SnapshotDigest::of(bytes); let change = match previous { @@ -1628,6 +1630,27 @@ fn open_absolute_dir_beneath(root: &Path, relative: &Path) -> io::Result { Ok(directory) } +fn ensure_absolute_dir_beneath(root: &Path, relative: &Path) -> io::Result { + validate_absolute_path(root).map_err(invalid_input)?; + validate_empty_or_relative_path(relative).map_err(invalid_input)?; + let mut directory = open_absolute_dir(root)?; + for component in relative.components().filter_map(|component| match component { + Component::Normal(name) => Some(name), + _ => None, + }) { + let name = c_string(component)?; + let result = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) }; + if result < 0 { + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::AlreadyExists { + return Err(error); + } + } + directory = openat_directory(&directory, component)?; + } + Ok(directory) +} + fn validate_empty_or_relative_path(path: &Path) -> Result<(), &'static str> { for component in path.components() { if !matches!(component, Component::Normal(_)) { @@ -1984,6 +2007,21 @@ mod tests { assert_eq!(equal.digest(), first.digest()); } + #[test] + fn first_publication_creates_missing_contained_parent_directories() { + let directory = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(directory.path()).unwrap(); + let target = SnapshotTarget::new(&root, "resources/github-pr/owner/repo/389.json").unwrap(); + + let outcome = publish_snapshot(&target, b"bytes", vec!["selected".to_owned()]).unwrap(); + + assert_eq!(outcome.change(), SnapshotChange::First); + assert_eq!( + fs::read(root.join("resources/github-pr/owner/repo/389.json")).unwrap(), + b"bytes" + ); + } + #[test] fn topic_filtering_updates_snapshot_without_invalidating() { let directory = tempfile::tempdir().unwrap(); From d8e0b26eb61e0aa5cc8d55b25bb6f724d627b889 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:02:59 +0200 Subject: [PATCH 3/6] fix(codex): accept handshake failure during shutdown agent-identity: dev3.direct.omp.2cshu64q agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.9 agent-runtime: OMP 18.0.9 tooling-profile: dotfiles@b607597 --- src/codex_app_server.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index 99258846..b1ca094e 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -2072,6 +2072,9 @@ fn initialize_control(stream: UnixStream) -> Result pending = resumable.handshake(); } Err(tungstenite::HandshakeError::Failure(error)) => { + if crate::provider_session::STOP.load(std::sync::atomic::Ordering::SeqCst) { + return Ok(None); + } anyhow::bail!("Codex WebSocket handshake failed: {error}") } } @@ -5367,8 +5370,7 @@ mod tests { std::thread::sleep(Duration::from_millis(10)); } } - let descendant = - descendant.expect("the launcher did not create its native descendant"); + let descendant = descendant.expect("the launcher did not create its native descendant"); assert!( process_can_retain_cleanup_resources(descendant), "the native descendant was not alive before cleanup" From 185278b570dddc40e514e5186328fcf9010ffa74 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:10:15 +0200 Subject: [PATCH 4/6] fix(resource): reconcile absent nested snapshots agent-identity: dev3.direct.omp.2cshu64q agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.9 agent-runtime: OMP 18.0.9 tooling-profile: dotfiles@b607597 --- src/resource_profile.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/resource_profile.rs b/src/resource_profile.rs index 2e31a325..f7ad9b78 100644 --- a/src/resource_profile.rs +++ b/src/resource_profile.rs @@ -739,8 +739,11 @@ impl SnapshotTarget { .relative .file_name() .ok_or_else(|| PublicationError::UnsafeTarget(PathError::UnsafeCarrier("missing leaf")))?; - let directory = - open_absolute_dir_beneath(&self.root, parent).map_err(PublicationError::Io)?; + let directory = match open_absolute_dir_beneath(&self.root, parent) { + Ok(directory) => directory, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(PublicationError::Io(error)), + }; read_regular_optional_at(&directory, leaf, MAX_SNAPSHOT_BYTES) .map_err(|error| match error { BoundedReadError::TooLarge => PublicationError::ExistingSnapshotTooLarge, @@ -2012,6 +2015,7 @@ mod tests { let directory = tempfile::tempdir().unwrap(); let root = fs::canonicalize(directory.path()).unwrap(); let target = SnapshotTarget::new(&root, "resources/github-pr/owner/repo/389.json").unwrap(); + assert_eq!(target.current_digest().unwrap(), None); let outcome = publish_snapshot(&target, b"bytes", vec!["selected".to_owned()]).unwrap(); From f700d1f337ae460d786da0229e4b69c55617f37a Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:15:13 +0200 Subject: [PATCH 5/6] fix(codex): poll handshake without blocking reads agent-identity: dev3.direct.omp.2cshu64q agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.9 agent-runtime: OMP 18.0.9 tooling-profile: dotfiles@b607597 --- src/codex_app_server.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/codex_app_server.rs b/src/codex_app_server.rs index b1ca094e..3d7358ad 100644 --- a/src/codex_app_server.rs +++ b/src/codex_app_server.rs @@ -2050,12 +2050,10 @@ fn connect_control( /// `Ok(None)` = a stop was raised mid-initialize; the caller exits gracefully. fn initialize_control(stream: UnixStream) -> Result>> { - // A short read timeout surfaces the handshake's blocking reads as resumable - // `Interrupted` states (a timed-out socket read is `WouldBlock`, which the - // handshake machine parks on), so a stop raised while the app-server sits - // silent mid-handshake unblocks within a poll interval instead of holding - // the launch for the whole startup timeout. - stream.set_read_timeout(Some(CONTROL_POLL))?; + // Nonblocking handshake reads produce resumable `Interrupted` states. This + // avoids treating unrelated process signals as fatal socket I/O while + // retaining a bounded stop-check cadence during a silent handshake. + stream.set_nonblocking(true)?; let handshake_deadline = Instant::now() + STARTUP_TIMEOUT; let mut pending = tungstenite::client("ws://localhost/", stream); let (mut websocket, response) = loop { @@ -2069,6 +2067,7 @@ fn initialize_control(stream: UnixStream) -> Result Instant::now() < handshake_deadline, "Codex WebSocket handshake timed out" ); + std::thread::sleep(CONTROL_POLL); pending = resumable.handshake(); } Err(tungstenite::HandshakeError::Failure(error)) => { @@ -2079,6 +2078,8 @@ fn initialize_control(stream: UnixStream) -> Result } } }; + websocket.get_mut().set_nonblocking(false)?; + websocket.get_mut().set_read_timeout(Some(CONTROL_POLL))?; anyhow::ensure!( response.status().as_u16() == 101, "Codex WebSocket handshake returned {}", From 9d766557dd89a4d964339e69e8b1da0ecdd7440e Mon Sep 17 00:00:00 2001 From: "AI assistant of @schickling" <261620128+schickling-assistant@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:40:00 +0200 Subject: [PATCH 6/6] feat(resource): add runtime protocol SDK and semantic invalidations (#392) * refactor(resource): extract runtime protocol crate agent-identity: dev3.direct.omp.2cshu64q agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.9 agent-runtime: OMP 18.0.9 tooling-profile: dotfiles@b607597 * feat(resource): make resync DING subjects actionable agent-identity: dev3.direct.omp.2cshu64q agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.9 agent-runtime: OMP 18.0.9 tooling-profile: dotfiles@b607597 * feat(resource): publish semantic invalidation facts agent-identity: dev3.direct.omp.2cshu64q agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.9 agent-runtime: OMP 18.0.9 tooling-profile: dotfiles@b607597 * test(resource): assert structured invalidations agent-identity: dev3.direct.omp.2cshu64q agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.9 agent-runtime: OMP 18.0.9 tooling-profile: dotfiles@b607597 * test(resource): assert semantic resync messages agent-identity: dev3.direct.omp.2cshu64q agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.9 agent-runtime: OMP 18.0.9 tooling-profile: dotfiles@b607597 --- Cargo.lock | 10 + Cargo.toml | 5 +- crates/agent-spec/src/profile.rs | 10 +- crates/agent-spec/tests/profile_wasm.rs | 6 +- crates/st2-resource-protocol/Cargo.toml | 11 + crates/st2-resource-protocol/src/lib.rs | 1053 ++++++++++++++++++ docs/vrs/07-resource-profile/requirements.md | 24 +- docs/vrs/07-resource-profile/spec.md | 75 +- src/resource_profile.rs | 698 ++---------- src/resource_profile_supervisor.rs | 120 +- src/resync.rs | 587 +++++++--- src/run.rs | 14 +- tests/resource_profile_supervisor_e2e.rs | 24 +- tests/resync.rs | 10 +- 14 files changed, 1861 insertions(+), 786 deletions(-) create mode 100644 crates/st2-resource-protocol/Cargo.toml create mode 100644 crates/st2-resource-protocol/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 93cd29fb..7d7e48e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1796,6 +1796,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "st2-resource-protocol", "st2-wire", "tempfile", "toml", @@ -1805,6 +1806,15 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "st2-resource-protocol" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2", +] + [[package]] name = "st2-wire" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a99eb77e..167ea193 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,8 @@ # `package.version` out of this file as the single source of truth for the # build, and a virtual root has no `[package]` to read. [workspace] -members = ["crates/agent-spec", "crates/st2-wire", "crates/demo-resolver-wasm"] -default-members = [".", "crates/agent-spec", "crates/st2-wire"] +members = ["crates/agent-spec", "crates/st2-resource-protocol", "crates/st2-wire", "crates/demo-resolver-wasm"] +default-members = [".", "crates/agent-spec", "crates/st2-resource-protocol", "crates/st2-wire"] [package] name = "st2" @@ -33,6 +33,7 @@ notify = "8" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" +st2-resource-protocol = { path = "crates/st2-resource-protocol" } st2-wire = { path = "crates/st2-wire" } tempfile = "3" toml = "0.9" diff --git a/crates/agent-spec/src/profile.rs b/crates/agent-spec/src/profile.rs index a21affc1..be30bc4e 100644 --- a/crates/agent-spec/src/profile.rs +++ b/crates/agent-spec/src/profile.rs @@ -36,7 +36,7 @@ pub const AGENT_GOAL_SCHEME: &str = "dev.schickling.agent-goal"; /// Maximum resolver module bytes admitted by both catalog transactions and the wasm runtime. pub const DEFAULT_MODULE_LIMIT_BYTES: usize = 16 * 1024 * 1024; /// Descriptor ABI implemented by this host. -pub const PROFILE_DESCRIPTOR_ABI_VERSION: u32 = 2; +pub const PROFILE_DESCRIPTOR_ABI_VERSION: u32 = 3; /// Maximum canonical compact JSON bytes accepted for one binding selector. pub const DEFAULT_SELECTOR_LIMIT_BYTES: usize = 16 * 1024; @@ -1091,7 +1091,7 @@ mod tests { use super::*; const VALID_DESCRIPTOR_JSON: &str = r#"{ - "abiVersion": 2, + "abiVersion": 3, "capabilities": ["resolve", "read", "observe"], "selectorSchema": { "type": "object", @@ -1157,7 +1157,7 @@ mod tests { } #[test] - fn valid_v2_descriptor_and_nested_selector_validate() { + fn valid_v3_descriptor_and_nested_selector_validate() { let descriptor = valid_descriptor(); assert_eq!(descriptor.abi_version, PROFILE_DESCRIPTOR_ABI_VERSION); assert_eq!(descriptor.runtime.topology, RuntimeTopology::Shared); @@ -1171,7 +1171,7 @@ mod tests { #[test] fn descriptor_rejects_unknown_abi_capability_and_fields() { - let unknown_abi = VALID_DESCRIPTOR_JSON.replace("\"abiVersion\": 2", "\"abiVersion\": 9"); + let unknown_abi = VALID_DESCRIPTOR_JSON.replace("\"abiVersion\": 3", "\"abiVersion\": 9"); assert!( ProfileDescriptor::from_json(unknown_abi.as_bytes()) .unwrap_err() @@ -1187,7 +1187,7 @@ mod tests { ); let unknown_field = - VALID_DESCRIPTOR_JSON.replace("\"abiVersion\": 2,", "\"abiVersion\": 2, \"extra\": true,"); + VALID_DESCRIPTOR_JSON.replace("\"abiVersion\": 3,", "\"abiVersion\": 3, \"extra\": true,"); assert!( ProfileDescriptor::from_json(unknown_field.as_bytes()) .unwrap_err() diff --git a/crates/agent-spec/tests/profile_wasm.rs b/crates/agent-spec/tests/profile_wasm.rs index 61aef4e1..5335c1bf 100644 --- a/crates/agent-spec/tests/profile_wasm.rs +++ b/crates/agent-spec/tests/profile_wasm.rs @@ -74,7 +74,7 @@ fn descriptor_module(payload: &[u8], reported_len: usize) -> WasmResolver { } const VALID_DESCRIPTOR: &str = r#"{ - "abiVersion": 2, + "abiVersion": 3, "capabilities": ["resolve", "read", "observe"], "selectorSchema": { "type": "object", @@ -112,12 +112,12 @@ fn current_rss_bytes() -> u64 { } #[test] -fn valid_v2_descriptor_executes_and_resolve_only_module_stays_passive() { +fn valid_v3_descriptor_executes_and_resolve_only_module_stays_passive() { let descriptor = descriptor_module(VALID_DESCRIPTOR.as_bytes(), VALID_DESCRIPTOR.len()) .describe_once() .expect("describe call succeeds") .expect("descriptor is present"); - assert_eq!(descriptor.abi_version, 2); + assert_eq!(descriptor.abi_version, 3); assert!( WasmResolver::load(Path::new(DEMO_WASM_PATH)) diff --git a/crates/st2-resource-protocol/Cargo.toml b/crates/st2-resource-protocol/Cargo.toml new file mode 100644 index 00000000..371772cf --- /dev/null +++ b/crates/st2-resource-protocol/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "st2-resource-protocol" +version = "0.1.0" +edition = "2024" +description = "Resource Profile runtime wire types, framing, and codecs shared by st2 hosts and runtimes." +license = "MIT" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" diff --git a/crates/st2-resource-protocol/src/lib.rs b/crates/st2-resource-protocol/src/lib.rs new file mode 100644 index 00000000..0c5e60b4 --- /dev/null +++ b/crates/st2-resource-protocol/src/lib.rs @@ -0,0 +1,1053 @@ +//! Resource Profile runtime wire types, framing, and codecs. + +use std::collections::BTreeSet; +use std::fmt; +use std::path::PathBuf; + +use serde::de::{self, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; +use sha2::{Digest as _, Sha256}; + +pub const MAX_PROTOCOL_LINE_BYTES: usize = 2 * 1024 * 1024; +pub const MAX_SNAPSHOT_BYTES: usize = 1024 * 1024; +pub const MAX_SELECTOR_BYTES: usize = 16 * 1024; +pub const MAX_HEALTH_DETAIL_BYTES: usize = 16 * 1024; +const MAX_OPAQUE_ID_BYTES: usize = 16 * 1024; + +/// Fact bounds are deliberately small relative to the 2 MiB frame: even 32 facts whose strings +/// all require JSON escaping leave ample room beside a maximal base64-encoded 1 MiB snapshot. +pub const MAX_FACTS: usize = 32; +pub const MAX_FACT_KEY_BYTES: usize = 128; +pub const MAX_FACT_VALUE_BYTES: usize = 1024; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum FactValue { + #[default] + Omitted, + Null, + Value(String), +} + +impl FactValue { + pub fn value(value: impl Into) -> Self { + Self::Value(value.into()) + } + + pub fn as_option(&self) -> Option> { + match self { + Self::Omitted => None, + Self::Null => Some(None), + Self::Value(value) => Some(Some(value)), + } + } + + fn is_omitted(&self) -> bool { + matches!(self, Self::Omitted) + } +} + +impl Serialize for FactValue { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Omitted => serializer.serialize_unit(), + Self::Null => serializer.serialize_none(), + Self::Value(value) => serializer.serialize_str(value), + } + } +} + +impl<'de> Deserialize<'de> for FactValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Option::::deserialize(deserializer) + .map(|value| value.map_or(Self::Null, Self::Value)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ResourceFact { + key: String, + #[serde(default, skip_serializing_if = "FactValue::is_omitted")] + before: FactValue, + #[serde(default, skip_serializing_if = "FactValue::is_omitted")] + after: FactValue, +} + +impl ResourceFact { + pub fn new( + key: impl Into, + before: FactValue, + after: FactValue, + ) -> Result { + let fact = Self { + key: key.into(), + before, + after, + }; + fact.validate()?; + Ok(fact) + } + + pub fn current( + key: impl Into, + value: impl Into, + ) -> Result { + Self::new(key, FactValue::Omitted, FactValue::value(value)) + } + + pub fn transition( + key: impl Into, + before: Option>, + after: Option>, + ) -> Result { + Self::new( + key, + before.map_or(FactValue::Null, |value| FactValue::value(value)), + after.map_or(FactValue::Null, |value| FactValue::value(value)), + ) + } + + pub fn key(&self) -> &str { + &self.key + } + + pub fn before(&self) -> Option> { + self.before.as_option() + } + + pub fn after(&self) -> Option> { + self.after.as_option() + } + + pub fn validate(&self) -> Result<(), FactError> { + validate_fact_string("key", &self.key, MAX_FACT_KEY_BYTES, true)?; + if self.before.is_omitted() && self.after.is_omitted() { + return Err(FactError::MissingValue); + } + for (field, value) in [("before", &self.before), ("after", &self.after)] { + if let FactValue::Value(value) = value { + validate_fact_string(field, value, MAX_FACT_VALUE_BYTES, false)?; + } + } + Ok(()) + } +} + +fn validate_fact_string( + field: &'static str, + value: &str, + maximum: usize, + nonempty: bool, +) -> Result<(), FactError> { + if nonempty && value.is_empty() { + return Err(FactError::Empty { field }); + } + if value.len() > maximum { + return Err(FactError::TooLarge { + field, + actual: value.len(), + maximum, + }); + } + if value + .chars() + .any(|character| character.is_control() || matches!(character, '\u{2028}' | '\u{2029}')) + { + return Err(FactError::NotPrintable { field }); + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FactError { + TooMany { actual: usize }, + Empty { + field: &'static str, + }, + TooLarge { + field: &'static str, + actual: usize, + maximum: usize, + }, + NotPrintable { + field: &'static str, + }, + MissingValue, +} + +impl fmt::Display for FactError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TooMany { actual } => { + write!(formatter, "fact list has {actual} entries; maximum is {MAX_FACTS}") + } + Self::Empty { field } => write!(formatter, "fact {field} must not be empty"), + Self::TooLarge { + field, + actual, + maximum, + } => write!( + formatter, + "fact {field} is {actual} bytes; maximum is {maximum}" + ), + Self::NotPrintable { field } => { + write!(formatter, "fact {field} must be one printable line") + } + Self::MissingValue => { + formatter.write_str("fact must include before, after, or both") + } + } + } +} + +impl std::error::Error for FactError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpaqueIdError { + kind: &'static str, + reason: &'static str, +} + +impl fmt::Display for OpaqueIdError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{} {}", self.kind, self.reason) + } +} + +impl std::error::Error for OpaqueIdError {} + +macro_rules! opaque_id { + ($name:ident, $kind:literal) => { + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] + #[serde(transparent)] + pub struct $name(String); + + impl $name { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(OpaqueIdError { + kind: $kind, + reason: "must not be empty", + }); + } + if value.len() > MAX_OPAQUE_ID_BYTES { + return Err(OpaqueIdError { + kind: $kind, + reason: "is too large", + }); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(de::Error::custom) + } + } + }; +} + +opaque_id!(RuntimeIncarnation, "runtime incarnation"); +opaque_id!(OwnerClaim, "owner claim"); +opaque_id!(BindingId, "binding id"); +opaque_id!(RegistrationToken, "registration token"); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeOwner { + incarnation: RuntimeIncarnation, + claim: OwnerClaim, +} + +impl RuntimeOwner { + pub fn new(incarnation: RuntimeIncarnation, claim: OwnerClaim) -> Self { + Self { incarnation, claim } + } + + pub fn incarnation(&self) -> &RuntimeIncarnation { + &self.incarnation + } + + pub fn claim(&self) -> &OwnerClaim { + &self.claim + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct SnapshotBytes(Vec); + +impl fmt::Debug for SnapshotBytes { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SnapshotBytes") + .field("len", &self.0.len()) + .finish() + } +} + +impl SnapshotBytes { + pub fn new(bytes: Vec) -> Result { + if bytes.len() > MAX_SNAPSHOT_BYTES { + return Err(SnapshotSizeError { + actual: bytes.len(), + }); + } + Ok(Self(bytes)) + } + + pub fn as_slice(&self) -> &[u8] { + &self.0 + } + + pub fn into_vec(self) -> Vec { + self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SnapshotSizeError { + pub actual: usize, +} + +impl fmt::Display for SnapshotSizeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "decoded snapshot is {} bytes; maximum is {MAX_SNAPSHOT_BYTES}", + self.actual + ) + } +} + +impl std::error::Error for SnapshotSizeError {} + +impl Serialize for SnapshotBytes { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&encode_base64(&self.0)) + } +} + +impl<'de> Deserialize<'de> for SnapshotBytes { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct SnapshotBytesVisitor; + + impl Visitor<'_> for SnapshotBytesVisitor { + type Value = SnapshotBytes; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("an RFC 4648 padded base64 snapshot") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + let bytes = decode_base64(value).map_err(E::custom)?; + SnapshotBytes::new(bytes).map_err(E::custom) + } + } + + deserializer.deserialize_str(SnapshotBytesVisitor) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Base64Error(&'static str); + +impl fmt::Display for Base64Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.0) + } +} + +fn encode_base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let first = chunk[0]; + let second = chunk.get(1).copied().unwrap_or(0); + let third = chunk.get(2).copied().unwrap_or(0); + encoded.push(ALPHABET[(first >> 2) as usize] as char); + encoded.push(ALPHABET[(((first & 0x03) << 4) | (second >> 4)) as usize] as char); + if chunk.len() > 1 { + encoded.push(ALPHABET[(((second & 0x0f) << 2) | (third >> 6)) as usize] as char); + } else { + encoded.push('='); + } + if chunk.len() > 2 { + encoded.push(ALPHABET[(third & 0x3f) as usize] as char); + } else { + encoded.push('='); + } + } + encoded +} + +fn decode_base64(encoded: &str) -> Result, Base64Error> { + if encoded.len() % 4 != 0 { + return Err(Base64Error("base64 length is not a multiple of four")); + } + let maximum_encoded = MAX_SNAPSHOT_BYTES.div_ceil(3) * 4; + if encoded.len() > maximum_encoded { + return Err(Base64Error("decoded snapshot exceeds the size limit")); + } + if encoded.is_empty() { + return Ok(Vec::new()); + } + + fn value(byte: u8) -> Result { + match byte { + b'A'..=b'Z' => Ok(byte - b'A'), + b'a'..=b'z' => Ok(byte - b'a' + 26), + b'0'..=b'9' => Ok(byte - b'0' + 52), + b'+' => Ok(62), + b'/' => Ok(63), + _ => Err(Base64Error("base64 contains an invalid character")), + } + } + + let input = encoded.as_bytes(); + let padding = + usize::from(input[input.len() - 1] == b'=') + usize::from(input[input.len() - 2] == b'='); + let decoded_len = input.len() / 4 * 3 - padding; + if decoded_len > MAX_SNAPSHOT_BYTES { + return Err(Base64Error("decoded snapshot exceeds the size limit")); + } + let mut decoded = Vec::with_capacity(decoded_len); + let chunks = input.chunks_exact(4); + let chunk_count = chunks.len(); + for (index, chunk) in chunks.enumerate() { + let last = index + 1 == chunk_count; + let a = value(chunk[0])?; + let b = value(chunk[1])?; + decoded.push((a << 2) | (b >> 4)); + match (chunk[2], chunk[3]) { + (b'=', b'=') if last => { + if b & 0x0f != 0 { + return Err(Base64Error("base64 has non-canonical trailing bits")); + } + } + (third, b'=') if last => { + let c = value(third)?; + if c & 0x03 != 0 { + return Err(Base64Error("base64 has non-canonical trailing bits")); + } + decoded.push((b << 4) | (c >> 2)); + } + (b'=', _) => return Err(Base64Error("base64 padding is misplaced")), + (third, fourth) => { + let c = value(third)?; + let d = value(fourth)?; + decoded.push((b << 4) | (c >> 2)); + decoded.push((c << 6) | d); + } + } + } + Ok(decoded) +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SnapshotDigest([u8; 32]); + +impl SnapshotDigest { + pub fn of(bytes: &[u8]) -> Self { + Self(Sha256::digest(bytes).into()) + } + + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Debug for SnapshotDigest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, formatter) + } +} + +impl fmt::Display for SnapshotDigest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.0 { + write!(formatter, "{byte:02x}")?; + } + Ok(()) + } +} + +impl Serialize for SnapshotDigest { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for SnapshotDigest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct DigestVisitor; + + impl Visitor<'_> for DigestVisitor { + type Value = SnapshotDigest; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a lowercase 64-character SHA-256 digest") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + if value.len() != 64 || value.bytes().any(|byte| !byte.is_ascii_hexdigit()) { + return Err(E::custom("invalid SHA-256 digest")); + } + if value.bytes().any(|byte| byte.is_ascii_uppercase()) { + return Err(E::custom("SHA-256 digest must use lowercase hex")); + } + let mut digest = [0_u8; 32]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + let pair = std::str::from_utf8(pair).map_err(E::custom)?; + digest[index] = u8::from_str_radix(pair, 16).map_err(E::custom)?; + } + Ok(SnapshotDigest(digest)) + } + } + + deserializer.deserialize_str(DigestVisitor) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum HostMessage { + Register { + owner: RuntimeOwner, + binding_id: BindingId, + registration: RegistrationToken, + uri: String, + selector: Value, + carrier_path: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + previous_digest: Option, + }, + Unregister { + owner: RuntimeOwner, + binding_id: BindingId, + registration: RegistrationToken, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RuntimeHealthState { + Starting, + Ready, + Degraded, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum RuntimeMessage { + Publish { + owner: RuntimeOwner, + binding_id: BindingId, + registration: RegistrationToken, + schema_id: String, + media_type: String, + bytes: SnapshotBytes, + topics: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + facts: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + observed_at: Option, + }, + Health { + owner: RuntimeOwner, + #[serde(skip_serializing_if = "Option::is_none")] + binding_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + registration: Option, + state: RuntimeHealthState, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, + }, +} + +#[derive(Debug)] +pub enum ProtocolError { + MissingNewline, + MultipleLines, + EmptyLine, + LineTooLarge { actual: usize }, + SelectorTooLarge { actual: usize }, + HealthDetailTooLarge { actual: usize }, + InvalidTopics(&'static str), + InvalidFacts(FactError), + InvalidHealthScope, + Json(serde_json::Error), +} + +impl fmt::Display for ProtocolError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingNewline => formatter.write_str("protocol frame is missing its newline"), + Self::MultipleLines => formatter.write_str("protocol frame contains multiple lines"), + Self::EmptyLine => formatter.write_str("protocol frame is empty"), + Self::LineTooLarge { actual } => write!( + formatter, + "protocol line is {actual} bytes; maximum is {MAX_PROTOCOL_LINE_BYTES}" + ), + Self::SelectorTooLarge { actual } => write!( + formatter, + "selector is {actual} bytes; maximum is {MAX_SELECTOR_BYTES}" + ), + Self::HealthDetailTooLarge { actual } => write!( + formatter, + "health detail is {actual} bytes; maximum is {MAX_HEALTH_DETAIL_BYTES}" + ), + Self::InvalidTopics(reason) => write!(formatter, "invalid topics: {reason}"), + Self::InvalidFacts(error) => write!(formatter, "invalid facts: {error}"), + Self::InvalidHealthScope => formatter + .write_str("binding-scoped health must carry both bindingId and registration"), + Self::Json(error) => write!(formatter, "invalid protocol JSON: {error}"), + } + } +} + +impl std::error::Error for ProtocolError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Json(error) => Some(error), + Self::InvalidFacts(error) => Some(error), + _ => None, + } + } +} + +pub fn decode_host_line(line: &[u8]) -> Result { + let payload = protocol_payload(line)?; + let message = serde_json::from_slice(payload).map_err(ProtocolError::Json)?; + validate_host_message(&message)?; + Ok(message) +} + +pub fn decode_runtime_line(line: &[u8]) -> Result { + let payload = protocol_payload(line)?; + let message = serde_json::from_slice(payload).map_err(ProtocolError::Json)?; + validate_runtime_message(&message)?; + Ok(message) +} + +pub fn encode_host_line(message: &HostMessage) -> Result, ProtocolError> { + validate_host_message(message)?; + encode_protocol_line(message) +} + +pub fn encode_runtime_line(message: &RuntimeMessage) -> Result, ProtocolError> { + validate_runtime_message(message)?; + encode_protocol_line(message) +} + +fn protocol_payload(line: &[u8]) -> Result<&[u8], ProtocolError> { + if line.len() > MAX_PROTOCOL_LINE_BYTES { + return Err(ProtocolError::LineTooLarge { actual: line.len() }); + } + let Some(payload) = line.strip_suffix(b"\n") else { + return Err(ProtocolError::MissingNewline); + }; + if payload.is_empty() { + return Err(ProtocolError::EmptyLine); + } + if payload.contains(&b'\n') || payload.contains(&b'\r') { + return Err(ProtocolError::MultipleLines); + } + Ok(payload) +} + +fn encode_protocol_line(message: &impl Serialize) -> Result, ProtocolError> { + let mut line = serde_json::to_vec(message).map_err(ProtocolError::Json)?; + line.push(b'\n'); + if line.len() > MAX_PROTOCOL_LINE_BYTES { + return Err(ProtocolError::LineTooLarge { actual: line.len() }); + } + Ok(line) +} + +fn validate_host_message(message: &HostMessage) -> Result<(), ProtocolError> { + if let HostMessage::Register { selector, .. } = message { + let actual = serde_json::to_vec(selector) + .map_err(ProtocolError::Json)? + .len(); + if actual > MAX_SELECTOR_BYTES { + return Err(ProtocolError::SelectorTooLarge { actual }); + } + } + Ok(()) +} + +fn validate_runtime_message(message: &RuntimeMessage) -> Result<(), ProtocolError> { + match message { + RuntimeMessage::Publish { topics, facts, .. } => { + validate_topics(topics)?; + validate_facts(facts.as_deref().unwrap_or_default()) + } + RuntimeMessage::Health { + binding_id, + registration, + detail, + .. + } => { + if binding_id.is_some() != registration.is_some() { + return Err(ProtocolError::InvalidHealthScope); + } + if let Some(detail) = detail + && detail.len() > MAX_HEALTH_DETAIL_BYTES + { + return Err(ProtocolError::HealthDetailTooLarge { + actual: detail.len(), + }); + } + Ok(()) + } + } +} +fn validate_facts(facts: &[ResourceFact]) -> Result<(), ProtocolError> { + if facts.len() > MAX_FACTS { + return Err(ProtocolError::InvalidFacts(FactError::TooMany { + actual: facts.len(), + })); + } + facts + .iter() + .try_for_each(ResourceFact::validate) + .map_err(ProtocolError::InvalidFacts) +} + + +fn validate_topics(topics: &[String]) -> Result<(), ProtocolError> { + let mut unique = BTreeSet::new(); + for topic in topics { + if topic.is_empty() { + return Err(ProtocolError::InvalidTopics( + "topic names must not be empty", + )); + } + if !unique.insert(topic.as_str()) { + return Err(ProtocolError::InvalidTopics("topic names must be unique")); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn owner() -> RuntimeOwner { + RuntimeOwner::new( + RuntimeIncarnation::new("incarnation").unwrap(), + OwnerClaim::new("claim").unwrap(), + ) + } + + fn publish(bytes: &[u8]) -> RuntimeMessage { + RuntimeMessage::Publish { + owner: owner(), + binding_id: BindingId::new("binding").unwrap(), + registration: RegistrationToken::new("registration").unwrap(), + schema_id: "schema.v1".to_owned(), + media_type: "application/json".to_owned(), + bytes: SnapshotBytes::new(bytes.to_vec()).unwrap(), + topics: vec!["selected".to_owned()], + facts: None, + observed_at: Some("2026-08-30T00:00:00Z".to_owned()), + } + } + + #[test] + fn host_frames_have_exact_json_shape_and_newline() { + let register = HostMessage::Register { + owner: owner(), + binding_id: BindingId::new("binding").unwrap(), + registration: RegistrationToken::new("registration").unwrap(), + uri: "resource://one".to_owned(), + selector: json!({"kind": "one"}), + carrier_path: PathBuf::from("resources/one.json"), + previous_digest: Some(SnapshotDigest::of(b"previous")), + }; + assert_eq!( + encode_host_line(®ister).unwrap(), + b"{\"type\":\"register\",\"owner\":{\"incarnation\":\"incarnation\",\"claim\":\"claim\"},\"bindingId\":\"binding\",\"registration\":\"registration\",\"uri\":\"resource://one\",\"selector\":{\"kind\":\"one\"},\"carrierPath\":\"resources/one.json\",\"previousDigest\":\"6da0633528deaa0144e7b058315f0b753ec0b945163a72bf96a0d18180f9de0d\"}\n" + ); + let unregister = HostMessage::Unregister { + owner: owner(), + binding_id: BindingId::new("binding").unwrap(), + registration: RegistrationToken::new("registration").unwrap(), + }; + assert_eq!( + encode_host_line(&unregister).unwrap(), + b"{\"type\":\"unregister\",\"owner\":{\"incarnation\":\"incarnation\",\"claim\":\"claim\"},\"bindingId\":\"binding\",\"registration\":\"registration\"}\n" + ); + } + + #[test] + fn runtime_frames_have_exact_json_shape_and_padded_base64() { + assert_eq!( + encode_runtime_line(&publish(b"one byte")).unwrap(), + b"{\"type\":\"publish\",\"owner\":{\"incarnation\":\"incarnation\",\"claim\":\"claim\"},\"bindingId\":\"binding\",\"registration\":\"registration\",\"schemaId\":\"schema.v1\",\"mediaType\":\"application/json\",\"bytes\":\"b25lIGJ5dGU=\",\"topics\":[\"selected\"],\"observedAt\":\"2026-08-30T00:00:00Z\"}\n" + ); + let health = RuntimeMessage::Health { + owner: owner(), + binding_id: None, + registration: None, + state: RuntimeHealthState::Ready, + detail: None, + }; + assert_eq!( + encode_runtime_line(&health).unwrap(), + b"{\"type\":\"health\",\"owner\":{\"incarnation\":\"incarnation\",\"claim\":\"claim\"},\"state\":\"ready\"}\n" + ); + assert_eq!( + decode_runtime_line(&encode_runtime_line(&publish(b"one byte")).unwrap()).unwrap(), + publish(b"one byte") + ); + } + + #[test] + fn fact_wire_shape_distinguishes_omission_from_explicit_null() { + let mut message = publish(b"fact"); + let RuntimeMessage::Publish { facts, .. } = &mut message else { + unreachable!(); + }; + *facts = Some(vec![ + ResourceFact::current("state", "ready").unwrap(), + ResourceFact::transition("label", None::, Some("added")).unwrap(), + ResourceFact::transition("removed", Some("old"), None::).unwrap(), + ]); + let encoded = encode_runtime_line(&message).unwrap(); + let json: Value = serde_json::from_slice(encoded.strip_suffix(b"\n").unwrap()).unwrap(); + assert_eq!( + json["facts"], + json!([ + {"key": "state", "after": "ready"}, + {"key": "label", "before": null, "after": "added"}, + {"key": "removed", "before": "old", "after": null} + ]) + ); + assert_eq!(decode_runtime_line(&encoded).unwrap(), message); + } + + #[test] + fn invalid_fact_shapes_and_bounds_are_rejected() { + let missing_values = b"{\"type\":\"publish\",\"owner\":{\"incarnation\":\"i\",\"claim\":\"c\"},\"bindingId\":\"b\",\"registration\":\"r\",\"schemaId\":\"s\",\"mediaType\":\"m\",\"bytes\":\"\",\"topics\":[],\"facts\":[{\"key\":\"state\"}]}\n"; + assert!(matches!( + decode_runtime_line(missing_values), + Err(ProtocolError::InvalidFacts(FactError::MissingValue)) + )); + assert!(ResourceFact::current("", "value").is_err()); + assert!(ResourceFact::current("state", "two\nlines").is_err()); + assert!(ResourceFact::current("x".repeat(MAX_FACT_KEY_BYTES + 1), "value").is_err()); + assert!(ResourceFact::current("state", "x".repeat(MAX_FACT_VALUE_BYTES + 1)).is_err()); + + let mut message = publish(b"facts"); + let RuntimeMessage::Publish { facts, .. } = &mut message else { + unreachable!(); + }; + *facts = Some( + (0..=MAX_FACTS) + .map(|index| ResourceFact::current(format!("key-{index}"), "value").unwrap()) + .collect(), + ); + assert!(matches!( + encode_runtime_line(&message), + Err(ProtocolError::InvalidFacts(FactError::TooMany { .. })) + )); + } + + #[test] + fn decoding_is_strict_about_fields_ids_and_digest_encoding() { + let unknown_message_field = b"{\"type\":\"health\",\"owner\":{\"incarnation\":\"i\",\"claim\":\"c\"},\"state\":\"ready\",\"extra\":true}\n"; + assert!(matches!( + decode_runtime_line(unknown_message_field), + Err(ProtocolError::Json(_)) + )); + let unknown_owner_field = b"{\"type\":\"health\",\"owner\":{\"incarnation\":\"i\",\"claim\":\"c\",\"extra\":true},\"state\":\"ready\"}\n"; + assert!(matches!( + decode_runtime_line(unknown_owner_field), + Err(ProtocolError::Json(_)) + )); + let empty_id = b"{\"type\":\"unregister\",\"owner\":{\"incarnation\":\"\",\"claim\":\"c\"},\"bindingId\":\"b\",\"registration\":\"r\"}\n"; + assert!(matches!( + decode_host_line(empty_id), + Err(ProtocolError::Json(_)) + )); + let uppercase_digest = b"{\"type\":\"register\",\"owner\":{\"incarnation\":\"i\",\"claim\":\"c\"},\"bindingId\":\"b\",\"registration\":\"r\",\"uri\":\"u\",\"selector\":{},\"carrierPath\":\"p\",\"previousDigest\":\"AFA459DEEC028BA69A538AB3DF3ED61A63F9C8383C2FDAFA95ED544547FB675D\"}\n"; + assert!(matches!( + decode_host_line(uppercase_digest), + Err(ProtocolError::Json(_)) + )); + } + + #[test] + fn decoding_rejects_malformed_frames() { + assert!(matches!( + decode_runtime_line(b"{}"), + Err(ProtocolError::MissingNewline) + )); + assert!(matches!( + decode_runtime_line(b"\n"), + Err(ProtocolError::EmptyLine) + )); + assert!(matches!( + decode_runtime_line(b"{}\n{}\n"), + Err(ProtocolError::MultipleLines) + )); + assert!(matches!( + decode_runtime_line(b"{}\r\n"), + Err(ProtocolError::MultipleLines) + )); + assert!(matches!( + decode_runtime_line(b"not json\n"), + Err(ProtocolError::Json(_)) + )); + } + + #[test] + fn snapshot_base64_rejects_malformed_and_noncanonical_values() { + for encoded in ["A", "!!!!", "A===", "AB==", "AAB=", "AA=A", "AA==AAAA"] { + let line = format!( + "{{\"type\":\"publish\",\"owner\":{{\"incarnation\":\"i\",\"claim\":\"c\"}},\"bindingId\":\"b\",\"registration\":\"r\",\"schemaId\":\"s\",\"mediaType\":\"m\",\"bytes\":\"{encoded}\",\"topics\":[]}}\n" + ); + assert!( + matches!( + decode_runtime_line(line.as_bytes()), + Err(ProtocolError::Json(_)) + ), + "accepted {encoded}" + ); + } + } + + #[test] + fn protocol_enforces_all_byte_limits() { + assert!(matches!( + decode_runtime_line(&vec![b'x'; MAX_PROTOCOL_LINE_BYTES + 1]), + Err(ProtocolError::LineTooLarge { actual }) if actual == MAX_PROTOCOL_LINE_BYTES + 1 + )); + assert_eq!( + SnapshotBytes::new(vec![0; MAX_SNAPSHOT_BYTES + 1]).unwrap_err(), + SnapshotSizeError { + actual: MAX_SNAPSHOT_BYTES + 1 + } + ); + let oversized_snapshot = encode_base64(&vec![0; MAX_SNAPSHOT_BYTES + 1]); + let snapshot_line = format!( + "{{\"type\":\"publish\",\"owner\":{{\"incarnation\":\"i\",\"claim\":\"c\"}},\"bindingId\":\"b\",\"registration\":\"r\",\"schemaId\":\"s\",\"mediaType\":\"m\",\"bytes\":\"{oversized_snapshot}\",\"topics\":[]}}\n" + ); + assert!(matches!( + decode_runtime_line(snapshot_line.as_bytes()), + Err(ProtocolError::Json(_)) + )); + + let register = HostMessage::Register { + owner: owner(), + binding_id: BindingId::new("binding").unwrap(), + registration: RegistrationToken::new("registration").unwrap(), + uri: "u".to_owned(), + selector: Value::String("x".repeat(MAX_SELECTOR_BYTES)), + carrier_path: PathBuf::from("p"), + previous_digest: None, + }; + assert!(matches!( + encode_host_line(®ister), + Err(ProtocolError::SelectorTooLarge { .. }) + )); + let mut register_line = serde_json::to_vec(®ister).unwrap(); + register_line.push(b'\n'); + assert!(matches!( + decode_host_line(®ister_line), + Err(ProtocolError::SelectorTooLarge { .. }) + )); + + let health = RuntimeMessage::Health { + owner: owner(), + binding_id: None, + registration: None, + state: RuntimeHealthState::Degraded, + detail: Some("x".repeat(MAX_HEALTH_DETAIL_BYTES + 1)), + }; + assert!(matches!( + encode_runtime_line(&health), + Err(ProtocolError::HealthDetailTooLarge { .. }) + )); + let mut health_line = serde_json::to_vec(&health).unwrap(); + health_line.push(b'\n'); + assert!(matches!( + decode_runtime_line(&health_line), + Err(ProtocolError::HealthDetailTooLarge { .. }) + )); + assert!(RuntimeIncarnation::new("x".repeat(MAX_OPAQUE_ID_BYTES + 1)).is_err()); + } + + #[test] + fn runtime_semantic_validation_applies_on_encode_and_decode() { + let invalid_health = RuntimeMessage::Health { + owner: owner(), + binding_id: Some(BindingId::new("binding").unwrap()), + registration: None, + state: RuntimeHealthState::Failed, + detail: None, + }; + assert!(matches!( + encode_runtime_line(&invalid_health), + Err(ProtocolError::InvalidHealthScope) + )); + let mut duplicate_topics = publish(b"bytes"); + let RuntimeMessage::Publish { topics, .. } = &mut duplicate_topics else { + unreachable!(); + }; + *topics = vec!["same".to_owned(), "same".to_owned()]; + assert!(matches!( + encode_runtime_line(&duplicate_topics), + Err(ProtocolError::InvalidTopics(_)) + )); + } +} diff --git a/docs/vrs/07-resource-profile/requirements.md b/docs/vrs/07-resource-profile/requirements.md index 7d1bbe37..e5dfbbe9 100644 --- a/docs/vrs/07-resource-profile/requirements.md +++ b/docs/vrs/07-resource-profile/requirements.md @@ -207,18 +207,26 @@ one latest-state catch-up. The direction is recorded in - **PROFILE-R16A Finite protocol and publication bounds:** A selector's canonical compact JSON is at most 16 KiB. One encoded runtime-protocol line is at most 2 MiB including its newline. Decoded snapshot bytes are at most 1 MiB. - Health detail is at most 16 KiB of UTF-8. st2 rejects an oversized value - without truncation and contains the failure to the affected runtime or + Health detail is at most 16 KiB of UTF-8. One publication carries at most 32 + ordered facts; each fact key is at most 128 bytes and each before/after value + is at most 1 KiB of printable single-line UTF-8. st2 rejects an oversized + value without truncation and contains the failure to the affected runtime or binding. ### Must bound attention and catch up to current state -- **PROFILE-R17 Semantic invalidation:** When snapshot bytes change, including - on the first successful publication, the profile classifies the change with - zero or more descriptor-published semantic topics. st2 applies the binding - selector before delivery. A selected change emits one thin invalidation - carrying binding identity, current snapshot digest, and selected topics. It - does not copy snapshot bytes or a profile-rendered summary into the event. +- **PROFILE-R17 Semantic invalidation:** Every Resource invalidation carries the + same bounded ordered fact envelope in its durable body and renders at most + three whole facts into a subject of at most 96 Unicode scalars. Observable + profiles may publish facts and semantic topics beside changed snapshot bytes; + st2 validates the facts, applies the binding selector to topics, and retains + both through catch-up. Passive carrier changes publish one `content` topic + and a short digest transition fact. Agent Spec declaration changes publish + ordered binding-label facts for added, removed, and semantically changed + Resource declarations without exposing URIs or reasons; unavailable + declaration parsing falls back to a digest transition fact rather than + dropping the invalidation. Snapshot bytes and provider payloads remain out of + the event. - **PROFILE-R18 Built-in superseding delivery:** Smart Resource invalidations reuse one built-in per-agent delivery stream and the existing inbox, DING, deduplication, and producer-side supersession machinery. The binding name is diff --git a/docs/vrs/07-resource-profile/spec.md b/docs/vrs/07-resource-profile/spec.md index 8e0fa7fc..fcb367a0 100644 --- a/docs/vrs/07-resource-profile/spec.md +++ b/docs/vrs/07-resource-profile/spec.md @@ -50,12 +50,12 @@ closed wasm describe() -> capabilities + selector schema/default + topology catalog-trusted host runtime argv ----+ | v provider-native observation -publish(binding-id, bytes, topics) +publish(binding-id, bytes, topics, facts) | v host validation + contained atomic replacement canonical snapshot + current digest | - v selector + pending-relevance reducer + v selector + pending-relevance reducer retaining topics + facts built-in resync event (key=binding, supersede=true) | v existing inbox + DING @@ -309,10 +309,19 @@ For each active Resource binding, resync applies this precedence: 3. A schemeless path uses the existing agent-directory-relative rule. 4. Every other unregistered scheme remains opaque and unwatchable. -For a passive resolved carrier, Resource Profiles add no event semantics. -Parent-directory observation, rename replacement, digest seeding, equal-byte -deduplication, deterministic transition identity, bounded windows, and built-in -`resync` delivery remain the [`06-resync`](../06-resync/spec.md) pipeline. +Passive resolved carriers use the same Resource fact envelope as observable +publications. A content transition emits topic `content` and one +`digest=` fact. Declaration subscriptions retain a +bounded summary keyed by binding label whose values digest URI, reason, +inactive state, and selector. On a declaration flush, one bounded catalog parse +derives the current summary: added labels transition absent→`declared`, removed +labels transition `declared`→absent, and changed summary digests publish +`label=changed`, ordered by label. URIs and reasons never enter the event. A +parse failure, unchanged Resource summary, or summary outside fact bounds falls +back to the declaration carrier's digest transition. Parent-directory +observation, rename replacement, digest seeding, equal-byte deduplication, +deterministic transition identity, and bounded windows otherwise remain the +[`06-resync`](../06-resync/spec.md) pipeline. `notify-chain #true` extends only subscription selection. For each active binding through that profile, resync validates the bound agent's supervisor @@ -341,7 +350,7 @@ fresh-instance policy, fuel budget, and no-import rule as `resolve`: ```json { - "abiVersion": 2, + "abiVersion": 3, "capabilities": ["resolve", "read", "observe"], "selectorSchema": { "type": "object", @@ -439,7 +448,7 @@ host -> unregister { runtime -> publish { owner: { incarnation, claim }, bindingId, registration, - schemaId, mediaType, bytes, topics, observedAt? + schemaId, mediaType, bytes, topics, facts?, observedAt? } runtime -> health { owner: { incarnation, claim }, @@ -465,9 +474,12 @@ Each encoded protocol line is at most 2 MiB, including the newline. `publish` encodes `bytes` as a padded RFC 4648 base64 string; the decoded snapshot is opaque. Selectors are at most 16 KiB when encoded as canonical compact JSON. Decoded snapshot bytes are at most 1 MiB. Health `detail` is at most 16 KiB of -UTF-8. These bounds are checked before allocation or decoding where the -transport permits and fail only the affected binding or runtime. st2 never -truncates canonical snapshot bytes or health text to satisfy a bound. +UTF-8. A publication has at most 32 ordered facts; keys are at most 128 bytes +and values at most 1 KiB of printable single-line UTF-8. A fact carries +`key` plus `before`, `after`, or both; explicit JSON null denotes absence. +These bounds are checked before allocation or decoding where the transport +permits and fail only the affected binding or runtime. st2 never truncates +canonical snapshot bytes, facts, or health text to satisfy a bound. The host rejects unknown bindings, stale owners or registrations, mismatched schema or media type, unpublished topics, invalid messages, output after @@ -484,7 +496,7 @@ The resolver's contained carrier path is the observable snapshot path. A successful `publish` follows one host-owned transaction: ```text -validate binding + schema + topics + size +validate binding + schema + topics + facts + size | v write new bytes to contained sibling temporary file @@ -495,8 +507,7 @@ write new bytes to contained sibling temporary file compute/record current sha256 digest and freshness | `-> equal digest: no invalidation - changed digest: apply binding selector -``` + changed digest: apply binding selector and retain selected topics + facts The runtime never writes the carrier directly. Descriptor-relative no-follow containment from the existing resolver contract applies to the temporary file, @@ -516,14 +527,17 @@ manifest, profile event log, or host retention policy. ## Semantic invalidation and catch-up (PROFILE-R17..R20) For every changed digest, including the first successful publication, the host -intersects `publish.topics` with the normalized binding selector. An empty -intersection updates the canonical snapshot and freshness without scheduling -delivery. A non-empty intersection updates this bounded per-binding state: +validates and preserves the runtime's ordered facts and intersects +`publish.topics` with the normalized binding selector. An empty intersection +updates the canonical snapshot and freshness without scheduling delivery. A +non-empty intersection updates this bounded per-binding state: ```text current_snapshot_digest: Digest? last_delivered_digest: Digest? pending_relevant_change: bool +pending_selected_topics: Topic[] +pending_facts: ResourceFact[] deliverable: bool ``` @@ -534,23 +548,26 @@ If delivery is available, st2 emits one event on the existing built-in stream = resync key = binding name supersede = true -subject = resource changed -body = { binding, snapshotDigest, topics } +subject = · [] +body = { binding, snapshotDigest, topics, facts } ``` -The body is a thin invalidation. It contains no snapshot bytes, provider -payload, rendered summary, credential, or provider cursor. Existing event +Subjects are at most 96 Unicode scalars. Facts retain publication order and are +included only whole; topic space is reserved before facts are admitted. If no +fact fits, a compatible bounded fallback remains. The durable body always +retains the complete bounded fact list. It contains no snapshot bytes, provider +payload, credential, URI, reason, or provider cursor. Existing event deduplication, inbox storage, DING rendering, and supersession apply unchanged. -Multiple topics for one atomic publication produce one invalidation, not one -stream or record per topic. +Multiple topics for one atomic publication produce one invalidation. -If delivery is unavailable, a relevant publication sets +If delivery is unavailable, a relevant publication replaces the pending +selected topics and facts with the latest relevant publication and sets `pending_relevant_change = true`. Later irrelevant publications may advance -`current_snapshot_digest` but do not clear the bit. When delivery becomes -available, st2 emits at most one invalidation for the then-current digest and -clears the bit only after event ingress accepts the record. No pending digest -or transition backlog exists. This is level-triggered current-state catch-up, -not event replay. +`current_snapshot_digest` but do not clear that state. When delivery becomes +available, st2 emits at most one invalidation for the then-current digest with +the retained latest relevant fact envelope, and clears it only after event +ingress accepts the record. No transition backlog exists. This is +level-triggered current-state catch-up, not event replay. Health has separate descriptor, selector, runtime, observation, publication, and delivery stages. Every stage reports affected scheme and binding without diff --git a/src/resource_profile.rs b/src/resource_profile.rs index f7ad9b78..55f588d3 100644 --- a/src/resource_profile.rs +++ b/src/resource_profile.rs @@ -14,566 +14,23 @@ use std::os::unix::ffi::OsStrExt as _; use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; -use serde::de::{self, Visitor}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use serde_json::Value; -use sha2::{Digest as _, Sha256}; - -pub const MAX_PROTOCOL_LINE_BYTES: usize = 2 * 1024 * 1024; -pub const MAX_SNAPSHOT_BYTES: usize = 1024 * 1024; -pub const MAX_SELECTOR_BYTES: usize = 16 * 1024; -pub const MAX_HEALTH_DETAIL_BYTES: usize = 16 * 1024; -const MAX_OPAQUE_ID_BYTES: usize = 16 * 1024; -const MAX_CATCH_UP_FILE_BYTES: usize = 16 * 1024; +use serde::{Deserialize, Serialize}; + +pub use st2_resource_protocol::{ + BindingId, FactError, FactValue, HostMessage, OpaqueIdError, OwnerClaim, ProtocolError, + RegistrationToken, ResourceFact, RuntimeHealthState, RuntimeIncarnation, RuntimeMessage, + RuntimeOwner, SnapshotBytes, SnapshotDigest, SnapshotSizeError, MAX_FACTS, MAX_FACT_KEY_BYTES, + MAX_FACT_VALUE_BYTES, MAX_HEALTH_DETAIL_BYTES, MAX_PROTOCOL_LINE_BYTES, MAX_SELECTOR_BYTES, + MAX_SNAPSHOT_BYTES, decode_host_line, decode_runtime_line, encode_host_line, + encode_runtime_line, +}; + +// Covers the prior state envelope plus 32 maximally sized facts after worst-case JSON escaping. +const MAX_CATCH_UP_FILE_BYTES: usize = 256 * 1024; const CATCH_UP_FILE: &str = "resource-profile-catch-up.json"; const PUBLICATION_INTENT_FILE: &str = "resource-profile-publication-intent.json"; static TEMPORARY_SEQUENCE: AtomicU64 = AtomicU64::new(0); -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OpaqueIdError { - kind: &'static str, - reason: &'static str, -} - -impl fmt::Display for OpaqueIdError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "{} {}", self.kind, self.reason) - } -} - -impl std::error::Error for OpaqueIdError {} - -macro_rules! opaque_id { - ($name:ident, $kind:literal) => { - #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] - #[serde(transparent)] - pub struct $name(String); - - impl $name { - pub fn new(value: impl Into) -> Result { - let value = value.into(); - if value.is_empty() { - return Err(OpaqueIdError { - kind: $kind, - reason: "must not be empty", - }); - } - if value.len() > MAX_OPAQUE_ID_BYTES { - return Err(OpaqueIdError { - kind: $kind, - reason: "is too large", - }); - } - Ok(Self(value)) - } - - pub fn as_str(&self) -> &str { - &self.0 - } - } - - impl<'de> Deserialize<'de> for $name { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::new(value).map_err(de::Error::custom) - } - } - }; -} - -opaque_id!(RuntimeIncarnation, "runtime incarnation"); -opaque_id!(OwnerClaim, "owner claim"); -opaque_id!(BindingId, "binding id"); -opaque_id!(RegistrationToken, "registration token"); - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct RuntimeOwner { - incarnation: RuntimeIncarnation, - claim: OwnerClaim, -} - -impl RuntimeOwner { - pub fn new(incarnation: RuntimeIncarnation, claim: OwnerClaim) -> Self { - Self { incarnation, claim } - } - - pub fn incarnation(&self) -> &RuntimeIncarnation { - &self.incarnation - } - - pub fn claim(&self) -> &OwnerClaim { - &self.claim - } -} - -#[derive(Clone, PartialEq, Eq)] -pub struct SnapshotBytes(Vec); - -impl fmt::Debug for SnapshotBytes { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("SnapshotBytes") - .field("len", &self.0.len()) - .finish() - } -} - -impl SnapshotBytes { - pub fn new(bytes: Vec) -> Result { - if bytes.len() > MAX_SNAPSHOT_BYTES { - return Err(SnapshotSizeError { actual: bytes.len() }); - } - Ok(Self(bytes)) - } - - pub fn as_slice(&self) -> &[u8] { - &self.0 - } - - pub fn into_vec(self) -> Vec { - self.0 - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SnapshotSizeError { - pub actual: usize, -} - -impl fmt::Display for SnapshotSizeError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - formatter, - "decoded snapshot is {} bytes; maximum is {MAX_SNAPSHOT_BYTES}", - self.actual - ) - } -} - -impl std::error::Error for SnapshotSizeError {} - -impl Serialize for SnapshotBytes { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&encode_base64(&self.0)) - } -} - -impl<'de> Deserialize<'de> for SnapshotBytes { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - struct SnapshotBytesVisitor; - - impl Visitor<'_> for SnapshotBytesVisitor { - type Value = SnapshotBytes; - - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("an RFC 4648 padded base64 snapshot") - } - - fn visit_str(self, value: &str) -> Result - where - E: de::Error, - { - let bytes = decode_base64(value).map_err(E::custom)?; - SnapshotBytes::new(bytes).map_err(E::custom) - } - } - - deserializer.deserialize_str(SnapshotBytesVisitor) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct Base64Error(&'static str); - -impl fmt::Display for Base64Error { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.0) - } -} - -fn encode_base64(bytes: &[u8]) -> String { - const ALPHABET: &[u8; 64] = - b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4); - for chunk in bytes.chunks(3) { - let first = chunk[0]; - let second = chunk.get(1).copied().unwrap_or(0); - let third = chunk.get(2).copied().unwrap_or(0); - encoded.push(ALPHABET[(first >> 2) as usize] as char); - encoded.push(ALPHABET[(((first & 0x03) << 4) | (second >> 4)) as usize] as char); - if chunk.len() > 1 { - encoded.push(ALPHABET[(((second & 0x0f) << 2) | (third >> 6)) as usize] as char); - } else { - encoded.push('='); - } - if chunk.len() > 2 { - encoded.push(ALPHABET[(third & 0x3f) as usize] as char); - } else { - encoded.push('='); - } - } - encoded -} - -fn decode_base64(encoded: &str) -> Result, Base64Error> { - if encoded.len() % 4 != 0 { - return Err(Base64Error("base64 length is not a multiple of four")); - } - let maximum_encoded = MAX_SNAPSHOT_BYTES.div_ceil(3) * 4; - if encoded.len() > maximum_encoded { - return Err(Base64Error("decoded snapshot exceeds the size limit")); - } - if encoded.is_empty() { - return Ok(Vec::new()); - } - - fn value(byte: u8) -> Result { - match byte { - b'A'..=b'Z' => Ok(byte - b'A'), - b'a'..=b'z' => Ok(byte - b'a' + 26), - b'0'..=b'9' => Ok(byte - b'0' + 52), - b'+' => Ok(62), - b'/' => Ok(63), - _ => Err(Base64Error("base64 contains an invalid character")), - } - } - - let input = encoded.as_bytes(); - let padding = usize::from(input[input.len() - 1] == b'=') - + usize::from(input[input.len() - 2] == b'='); - let decoded_len = input.len() / 4 * 3 - padding; - if decoded_len > MAX_SNAPSHOT_BYTES { - return Err(Base64Error("decoded snapshot exceeds the size limit")); - } - let mut decoded = Vec::with_capacity(decoded_len); - let chunks = input.chunks_exact(4); - let chunk_count = chunks.len(); - for (index, chunk) in chunks.enumerate() { - let last = index + 1 == chunk_count; - let a = value(chunk[0])?; - let b = value(chunk[1])?; - decoded.push((a << 2) | (b >> 4)); - match (chunk[2], chunk[3]) { - (b'=', b'=') if last => { - if b & 0x0f != 0 { - return Err(Base64Error("base64 has non-canonical trailing bits")); - } - } - (third, b'=') if last => { - let c = value(third)?; - if c & 0x03 != 0 { - return Err(Base64Error("base64 has non-canonical trailing bits")); - } - decoded.push((b << 4) | (c >> 2)); - } - (b'=', _) => return Err(Base64Error("base64 padding is misplaced")), - (third, fourth) => { - let c = value(third)?; - let d = value(fourth)?; - decoded.push((b << 4) | (c >> 2)); - decoded.push((c << 6) | d); - } - } - } - Ok(decoded) -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde( - tag = "type", - rename_all = "camelCase", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -pub enum HostMessage { - Register { - owner: RuntimeOwner, - binding_id: BindingId, - registration: RegistrationToken, - uri: String, - selector: Value, - carrier_path: PathBuf, - #[serde(skip_serializing_if = "Option::is_none")] - previous_digest: Option, - }, - Unregister { - owner: RuntimeOwner, - binding_id: BindingId, - registration: RegistrationToken, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum RuntimeHealthState { - Starting, - Ready, - Degraded, - Failed, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde( - tag = "type", - rename_all = "camelCase", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -pub enum RuntimeMessage { - Publish { - owner: RuntimeOwner, - binding_id: BindingId, - registration: RegistrationToken, - schema_id: String, - media_type: String, - bytes: SnapshotBytes, - topics: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - observed_at: Option, - }, - Health { - owner: RuntimeOwner, - #[serde(skip_serializing_if = "Option::is_none")] - binding_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - registration: Option, - state: RuntimeHealthState, - #[serde(skip_serializing_if = "Option::is_none")] - detail: Option, - }, -} - -#[derive(Debug)] -pub enum ProtocolError { - MissingNewline, - MultipleLines, - EmptyLine, - LineTooLarge { actual: usize }, - SelectorTooLarge { actual: usize }, - HealthDetailTooLarge { actual: usize }, - InvalidTopics(&'static str), - InvalidHealthScope, - Json(serde_json::Error), -} - -impl fmt::Display for ProtocolError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::MissingNewline => formatter.write_str("protocol frame is missing its newline"), - Self::MultipleLines => formatter.write_str("protocol frame contains multiple lines"), - Self::EmptyLine => formatter.write_str("protocol frame is empty"), - Self::LineTooLarge { actual } => write!( - formatter, - "protocol line is {actual} bytes; maximum is {MAX_PROTOCOL_LINE_BYTES}" - ), - Self::SelectorTooLarge { actual } => write!( - formatter, - "selector is {actual} bytes; maximum is {MAX_SELECTOR_BYTES}" - ), - Self::HealthDetailTooLarge { actual } => write!( - formatter, - "health detail is {actual} bytes; maximum is {MAX_HEALTH_DETAIL_BYTES}" - ), - Self::InvalidTopics(reason) => write!(formatter, "invalid topics: {reason}"), - Self::InvalidHealthScope => formatter.write_str( - "binding-scoped health must carry both bindingId and registration", - ), - Self::Json(error) => write!(formatter, "invalid protocol JSON: {error}"), - } - } -} - -impl std::error::Error for ProtocolError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Json(error) => Some(error), - _ => None, - } - } -} - -pub fn decode_host_line(line: &[u8]) -> Result { - let payload = protocol_payload(line)?; - let message = serde_json::from_slice(payload).map_err(ProtocolError::Json)?; - validate_host_message(&message)?; - Ok(message) -} - -pub fn decode_runtime_line(line: &[u8]) -> Result { - let payload = protocol_payload(line)?; - let message = serde_json::from_slice(payload).map_err(ProtocolError::Json)?; - validate_runtime_message(&message)?; - Ok(message) -} - -pub fn encode_host_line(message: &HostMessage) -> Result, ProtocolError> { - validate_host_message(message)?; - encode_protocol_line(message) -} - -pub fn encode_runtime_line(message: &RuntimeMessage) -> Result, ProtocolError> { - validate_runtime_message(message)?; - encode_protocol_line(message) -} - -fn protocol_payload(line: &[u8]) -> Result<&[u8], ProtocolError> { - if line.len() > MAX_PROTOCOL_LINE_BYTES { - return Err(ProtocolError::LineTooLarge { actual: line.len() }); - } - let Some(payload) = line.strip_suffix(b"\n") else { - return Err(ProtocolError::MissingNewline); - }; - if payload.is_empty() { - return Err(ProtocolError::EmptyLine); - } - if payload.contains(&b'\n') || payload.contains(&b'\r') { - return Err(ProtocolError::MultipleLines); - } - Ok(payload) -} - -fn encode_protocol_line(message: &impl Serialize) -> Result, ProtocolError> { - let mut line = serde_json::to_vec(message).map_err(ProtocolError::Json)?; - line.push(b'\n'); - if line.len() > MAX_PROTOCOL_LINE_BYTES { - return Err(ProtocolError::LineTooLarge { actual: line.len() }); - } - Ok(line) -} - -fn validate_host_message(message: &HostMessage) -> Result<(), ProtocolError> { - if let HostMessage::Register { selector, .. } = message { - let actual = serde_json::to_vec(selector) - .map_err(ProtocolError::Json)? - .len(); - if actual > MAX_SELECTOR_BYTES { - return Err(ProtocolError::SelectorTooLarge { actual }); - } - } - Ok(()) -} - -fn validate_runtime_message(message: &RuntimeMessage) -> Result<(), ProtocolError> { - match message { - RuntimeMessage::Publish { topics, .. } => validate_topics(topics), - RuntimeMessage::Health { - binding_id, - registration, - detail, - .. - } => { - if binding_id.is_some() != registration.is_some() { - return Err(ProtocolError::InvalidHealthScope); - } - if let Some(detail) = detail - && detail.len() > MAX_HEALTH_DETAIL_BYTES - { - return Err(ProtocolError::HealthDetailTooLarge { - actual: detail.len(), - }); - } - Ok(()) - } - } -} - -fn validate_topics(topics: &[String]) -> Result<(), ProtocolError> { - let mut unique = BTreeSet::new(); - for topic in topics { - if topic.is_empty() { - return Err(ProtocolError::InvalidTopics("topic names must not be empty")); - } - if !unique.insert(topic.as_str()) { - return Err(ProtocolError::InvalidTopics("topic names must be unique")); - } - } - Ok(()) -} - -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct SnapshotDigest([u8; 32]); - -impl SnapshotDigest { - pub fn of(bytes: &[u8]) -> Self { - Self(Sha256::digest(bytes).into()) - } - - pub fn as_bytes(&self) -> &[u8; 32] { - &self.0 - } -} - -impl fmt::Debug for SnapshotDigest { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt::Display::fmt(self, formatter) - } -} - -impl fmt::Display for SnapshotDigest { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - for byte in self.0 { - write!(formatter, "{byte:02x}")?; - } - Ok(()) - } -} - -impl Serialize for SnapshotDigest { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.collect_str(self) - } -} - -impl<'de> Deserialize<'de> for SnapshotDigest { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - struct DigestVisitor; - - impl Visitor<'_> for DigestVisitor { - type Value = SnapshotDigest; - - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("a lowercase 64-character SHA-256 digest") - } - - fn visit_str(self, value: &str) -> Result - where - E: de::Error, - { - if value.len() != 64 || value.bytes().any(|byte| !byte.is_ascii_hexdigit()) { - return Err(E::custom("invalid SHA-256 digest")); - } - if value.bytes().any(|byte| byte.is_ascii_uppercase()) { - return Err(E::custom("SHA-256 digest must use lowercase hex")); - } - let mut digest = [0_u8; 32]; - for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { - let pair = std::str::from_utf8(pair).map_err(E::custom)?; - digest[index] = u8::from_str_radix(pair, 16).map_err(E::custom)?; - } - Ok(SnapshotDigest(digest)) - } - } - - deserializer.deserialize_str(DigestVisitor) - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct TopicSelection { topics: BTreeSet, @@ -913,6 +370,7 @@ impl RuntimeLifecycle { media_type, bytes, topics, + facts, observed_at, } => { let binding = self.require_registration(owner, binding_id, registration)?; @@ -935,6 +393,7 @@ impl RuntimeLifecycle { target: &binding.target, bytes, selected_topics: binding.contract.selection.select(topics), + facts: facts.as_deref().unwrap_or_default(), observed_at: observed_at.as_deref(), })) } @@ -1042,6 +501,7 @@ pub struct AcceptedPublication<'a> { target: &'a SnapshotTarget, bytes: &'a SnapshotBytes, selected_topics: Vec, + facts: &'a [ResourceFact], observed_at: Option<&'a str>, } @@ -1054,12 +514,21 @@ impl<'a> AcceptedPublication<'a> { &self.selected_topics } + pub fn facts(&self) -> &[ResourceFact] { + self.facts + } + pub fn observed_at(&self) -> Option<&str> { self.observed_at } fn prepare(self) -> Result, PublicationError> { - prepare_snapshot(self.target, self.bytes.as_slice(), self.selected_topics) + prepare_snapshot( + self.target, + self.bytes.as_slice(), + self.selected_topics, + self.facts.to_vec(), + ) } } @@ -1096,6 +565,7 @@ pub struct PublicationOutcome { digest: SnapshotDigest, change: SnapshotChange, selected_topics: Vec, + facts: Vec, } impl PublicationOutcome { @@ -1111,6 +581,10 @@ impl PublicationOutcome { &self.selected_topics } + pub fn facts(&self) -> &[ResourceFact] { + &self.facts + } + pub fn invalidating(&self) -> bool { self.change != SnapshotChange::Equal && !self.selected_topics.is_empty() } @@ -1182,6 +656,7 @@ fn prepare_snapshot<'a>( target: &'a SnapshotTarget, bytes: &'a [u8], selected_topics: Vec, + facts: Vec, ) -> Result, PublicationError> { if bytes.len() > MAX_SNAPSHOT_BYTES { return Err(PublicationError::SnapshotTooLarge { actual: bytes.len() }); @@ -1202,6 +677,7 @@ fn prepare_snapshot<'a>( digest, change, selected_topics, + facts, }, }) } @@ -1210,8 +686,9 @@ fn publish_snapshot( target: &SnapshotTarget, bytes: &[u8], selected_topics: Vec, + facts: Vec, ) -> Result { - let prepared = prepare_snapshot(target, bytes, selected_topics)?; + let prepared = prepare_snapshot(target, bytes, selected_topics, facts)?; prepared.commit()?; Ok(prepared.outcome) } @@ -1224,6 +701,8 @@ pub struct CatchUpState { pending_relevant_change: bool, #[serde(default)] pending_selected_topics: Vec, + #[serde(default)] + pending_facts: Vec, deliverable: bool, } @@ -1244,6 +723,10 @@ impl CatchUpState { &self.pending_selected_topics } + pub fn pending_facts(&self) -> &[ResourceFact] { + &self.pending_facts + } + pub fn deliverable(&self) -> bool { self.deliverable } @@ -1260,6 +743,12 @@ impl CatchUpState { )); } validate_persisted_topics(&self.pending_selected_topics)?; + validate_persisted_facts(&self.pending_facts)?; + if !self.pending_relevant_change && !self.pending_facts.is_empty() { + return Err(CatchUpError::InvalidState( + "pending facts require a pending relevant change", + )); + } Ok(()) } } @@ -1268,6 +757,7 @@ impl CatchUpState { pub struct DeliveryRequest { digest: SnapshotDigest, selected_topics: Vec, + facts: Vec, } impl DeliveryRequest { @@ -1278,6 +768,10 @@ impl DeliveryRequest { pub fn selected_topics(&self) -> &[String] { &self.selected_topics } + + pub fn facts(&self) -> &[ResourceFact] { + &self.facts + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1285,6 +779,8 @@ impl DeliveryRequest { struct PublicationIntent { digest: SnapshotDigest, selected_topics: Vec, + #[serde(default)] + facts: Vec, } impl PublicationIntent { @@ -1292,11 +788,13 @@ impl PublicationIntent { Self { digest: outcome.digest, selected_topics: outcome.selected_topics.clone(), + facts: outcome.facts.clone(), } } fn validate(&self) -> Result<(), CatchUpError> { - validate_persisted_topics(&self.selected_topics) + validate_persisted_topics(&self.selected_topics)?; + validate_persisted_facts(&self.facts) } } @@ -1311,6 +809,13 @@ fn validate_persisted_topics(topics: &[String]) -> Result<(), CatchUpError> { } Ok(()) } +fn validate_persisted_facts(facts: &[ResourceFact]) -> Result<(), CatchUpError> { + if facts.len() > MAX_FACTS || facts.iter().any(|fact| fact.validate().is_err()) { + return Err(CatchUpError::InvalidState("persisted facts are invalid")); + } + Ok(()) +} + #[derive(Debug)] pub struct CatchUp { @@ -1395,6 +900,7 @@ impl CatchUp { if !intent.selected_topics.is_empty() { next.pending_relevant_change = true; next.pending_selected_topics = intent.selected_topics.clone(); + next.pending_facts = intent.facts.clone(); } } Some(_) | None => { @@ -1435,6 +941,7 @@ impl CatchUp { Some(DeliveryRequest { digest: self.state.current_snapshot_digest?, selected_topics: self.state.pending_selected_topics.clone(), + facts: self.state.pending_facts.clone(), }) } @@ -1451,6 +958,7 @@ impl CatchUp { next.last_delivered_digest = Some(digest); next.pending_relevant_change = false; next.pending_selected_topics.clear(); + next.pending_facts.clear(); self.commit(next)?; Ok(true) } @@ -1464,6 +972,7 @@ impl CatchUp { if outcome.invalidating() { next.pending_relevant_change = true; next.pending_selected_topics = outcome.selected_topics.clone(); + next.pending_facts = outcome.facts.clone(); } self.commit(next)?; Ok(self.pending_delivery()) @@ -1861,6 +1370,7 @@ mod tests { media_type: "application/json".to_owned(), bytes: SnapshotBytes::new(bytes.to_vec()).unwrap(), topics: topics.iter().map(|topic| (*topic).to_owned()).collect(), + facts: None, observed_at: None, } } @@ -1875,37 +1385,6 @@ mod tests { } } - #[test] - fn protocol_round_trips_padded_base64_and_rejects_malformed_and_oversized_lines() { - let message = publication(owner("one"), "registration", b"one byte?", &["selected"]); - let encoded = encode_runtime_line(&message).unwrap(); - assert!(encoded.ends_with(b"\n")); - assert_eq!(decode_runtime_line(&encoded).unwrap(), message); - - assert!(matches!( - decode_runtime_line(b"{\"type\":\"publish\"}\n"), - Err(ProtocolError::Json(_)) - )); - let oversized = vec![b'x'; MAX_PROTOCOL_LINE_BYTES + 1]; - assert!(matches!( - decode_runtime_line(&oversized), - Err(ProtocolError::LineTooLarge { .. }) - )); - } - - #[test] - fn protocol_rejects_oversized_decoded_snapshot_before_publication() { - let encoded = encode_base64(&vec![0_u8; MAX_SNAPSHOT_BYTES + 1]); - let line = format!( - "{{\"type\":\"publish\",\"owner\":{{\"incarnation\":\"i\",\"claim\":\"c\"}},\"bindingId\":\"b\",\"registration\":\"r\",\"schemaId\":\"s\",\"mediaType\":\"m\",\"bytes\":\"{encoded}\",\"topics\":[]}}\n" - ); - assert!(line.len() < MAX_PROTOCOL_LINE_BYTES); - assert!(matches!( - decode_runtime_line(line.as_bytes()), - Err(ProtocolError::Json(_)) - )); - assert!(SnapshotBytes::new(vec![0_u8; MAX_SNAPSHOT_BYTES + 1]).is_err()); - } #[test] fn stale_owner_and_registration_are_fenced() { @@ -2017,7 +1496,8 @@ mod tests { let target = SnapshotTarget::new(&root, "resources/github-pr/owner/repo/389.json").unwrap(); assert_eq!(target.current_digest().unwrap(), None); - let outcome = publish_snapshot(&target, b"bytes", vec!["selected".to_owned()]).unwrap(); + let outcome = + publish_snapshot(&target, b"bytes", vec!["selected".to_owned()], Vec::new()).unwrap(); assert_eq!(outcome.change(), SnapshotChange::First); assert_eq!( @@ -2067,6 +1547,7 @@ mod tests { digest: SnapshotDigest::of(b"relevant"), change: SnapshotChange::First, selected_topics: vec!["selected".to_owned()], + facts: vec![ResourceFact::current("state", "ready").unwrap()], }; let irrelevant = PublicationOutcome { digest: SnapshotDigest::of(b"later but irrelevant"), @@ -2074,6 +1555,7 @@ mod tests { previous: relevant.digest(), }, selected_topics: Vec::new(), + facts: Vec::new(), }; let mut catch_up = CatchUp::open(&state_directory).unwrap(); assert_eq!(catch_up.record_publication(&relevant).unwrap(), None); @@ -2083,6 +1565,7 @@ mod tests { let request = catch_up.set_deliverable(true).unwrap().unwrap(); assert_eq!(request.digest(), irrelevant.digest()); assert_eq!(request.selected_topics(), ["selected"]); + assert_eq!(request.facts(), relevant.facts()); assert!(!catch_up.acknowledge_delivery(relevant.digest()).unwrap()); assert!(catch_up.state().pending_relevant_change()); assert!(catch_up.acknowledge_delivery(irrelevant.digest()).unwrap()); @@ -2101,6 +1584,7 @@ mod tests { digest: SnapshotDigest::of(b"snapshot"), change: SnapshotChange::First, selected_topics: vec!["selected".to_owned()], + facts: vec![ResourceFact::current("state", "ready").unwrap()], }; { let mut catch_up = CatchUp::open(&state_directory).unwrap(); @@ -2122,6 +1606,36 @@ mod tests { catch_up.state().pending_selected_topics(), ["selected"] ); + assert_eq!(catch_up.state().pending_facts(), outcome.facts()); + } + + #[test] + fn catch_up_persists_a_maximal_worst_case_escaped_fact_envelope() { + let directory = tempfile::tempdir().unwrap(); + let state_directory = fs::canonicalize(directory.path()).unwrap(); + let facts = (0..MAX_FACTS) + .map(|_| { + ResourceFact::new( + "\"".repeat(MAX_FACT_KEY_BYTES), + FactValue::value("\"".repeat(MAX_FACT_VALUE_BYTES)), + FactValue::value("\\".repeat(MAX_FACT_VALUE_BYTES)), + ) + .unwrap() + }) + .collect::>(); + let outcome = PublicationOutcome { + digest: SnapshotDigest::of(b"snapshot"), + change: SnapshotChange::First, + selected_topics: vec!["selected".to_owned()], + facts, + }; + + { + let mut catch_up = CatchUp::open(&state_directory).unwrap(); + catch_up.record_publication(&outcome).unwrap(); + } + let catch_up = CatchUp::open(&state_directory).unwrap(); + assert_eq!(catch_up.state().pending_facts(), outcome.facts()); } #[test] @@ -2182,7 +1696,7 @@ mod tests { symlink(outside.path(), root.join("linked-parent")).unwrap(); let target = SnapshotTarget::new(&root, "linked-parent/snapshot.json").unwrap(); assert!(matches!( - publish_snapshot(&target, b"bytes", vec!["selected".to_owned()]), + publish_snapshot(&target, b"bytes", vec!["selected".to_owned()], Vec::new()), Err(PublicationError::Io(_)) )); diff --git a/src/resource_profile_supervisor.rs b/src/resource_profile_supervisor.rs index 043d4b7b..84beb250 100644 --- a/src/resource_profile_supervisor.rs +++ b/src/resource_profile_supervisor.rs @@ -24,7 +24,7 @@ use sha2::{Digest as _, Sha256}; use crate::catalog::CatalogConfig; use crate::resource_profile::{ AcceptedOutput, BindingId, BindingRegistration, CatchUp, HostMessage, OwnerClaim, - PublicationContract, RegistrationToken, RuntimeHealthState, RuntimeIncarnation, + PublicationContract, RegistrationToken, ResourceFact, RuntimeHealthState, RuntimeIncarnation, RuntimeLifecycle, RuntimeMessage, RuntimeOwner, SnapshotDigest, SnapshotTarget, TopicSelection, MAX_PROTOCOL_LINE_BYTES, decode_runtime_line, encode_host_line, }; @@ -982,20 +982,28 @@ fn emit_pending_at( binding: &'a str, snapshot_digest: String, topics: &'a [String], + facts: &'a [ResourceFact], } let digest = delivery.digest(); let topics = delivery.selected_topics().to_vec(); + let facts = delivery.facts(); let body = serde_json::to_string(&Body { binding: &active.desired.binding_name, snapshot_digest: digest.to_string(), topics: &topics, + facts, })?; let event_id = publication_event_id( &active.desired.recipient, &active.desired.binding_name, digest, ); - let subject = format!("resource {} changed", active.desired.binding_name); + let subject = resource_change_subject( + &active.desired.binding_name, + facts, + &topics, + "snapshot updated", + ); crate::event::emit_builtin_resync( catalog_root, this_host, @@ -1014,6 +1022,71 @@ fn publication_event_id(recipient: &str, binding: &str, digest: SnapshotDigest) hash_text(&format!("resource-profile\0{recipient}\0{binding}\0{digest}")) } +pub(crate) fn resource_change_subject( + binding: &str, + facts: &[ResourceFact], + topics: &[String], + fallback: &str, +) -> String { + const SUBJECT_MAX_SCALARS: usize = 96; + const MAX_RENDERED_FACTS: usize = 3; + + let topic_suffix = if topics.is_empty() { + String::new() + } else { + format!(" [{}]", topics.join(", ")) + }; + let base = format!("{binding} · "); + let mut rendered = Vec::new(); + for fact in facts.iter().take(MAX_RENDERED_FACTS) { + let fact = render_fact(fact); + let candidate = format!("{base}{}{topic_suffix}", { + let mut candidate_facts = rendered.clone(); + candidate_facts.push(fact.clone()); + candidate_facts.join("; ") + }); + if candidate.chars().count() > SUBJECT_MAX_SCALARS { + break; + } + rendered.push(fact); + } + + let detail = if rendered.is_empty() { + fallback.to_owned() + } else { + rendered.join("; ") + }; + let subject = format!("{base}{detail}{topic_suffix}"); + if subject.chars().count() <= SUBJECT_MAX_SCALARS { + return subject; + } + + // Facts and topic names are never clipped. A pathological oversized binding or topic suffix + // falls back to the binding and bounded generic detail; the durable body remains complete. + let fallback = format!("{binding} · {fallback}"); + if fallback.chars().count() <= SUBJECT_MAX_SCALARS { + fallback + } else { + fallback.chars().take(SUBJECT_MAX_SCALARS).collect() + } +} + +fn render_fact(fact: &ResourceFact) -> String { + match (fact.before(), fact.after()) { + (None, Some(Some(after))) => format!("{}={after}", fact.key()), + (None, Some(None)) => format!("{}=removed", fact.key()), + (Some(None), Some(Some(after))) => format!("{}=+{after}", fact.key()), + (Some(Some(before)), Some(None)) => format!("{}=-{before}", fact.key()), + (Some(Some(before)), Some(Some(after))) => { + format!("{}={before}→{after}", fact.key()) + } + (Some(None), Some(None)) => format!("{}=absent", fact.key()), + (Some(Some(before)), None) => format!("{} was {before}", fact.key()), + (Some(None), None) => format!("{} was absent", fact.key()), + (None, None) => unreachable!("validated facts always carry a value"), + } +} + fn binding_state_directory(desired: &DesiredBinding) -> anyhow::Result { let state = lexical_absolute(&crate::run::state_root())?; Ok(state @@ -1130,6 +1203,49 @@ mod tests { assert!(!owner_matches(None, &old)); } + #[test] + fn publication_subject_renders_ordered_facts_and_reserves_topics() { + let facts = vec![ + ResourceFact::current("state", "ready").unwrap(), + ResourceFact::transition("label", None::, Some("bug")).unwrap(), + ResourceFact::transition("owner", Some("alice"), Some("bob")).unwrap(), + ResourceFact::current("fourth", "omitted").unwrap(), + ]; + assert_eq!( + resource_change_subject( + "review", + &facts, + &["ci.failure".to_owned()], + "snapshot updated" + ), + "review · state=ready; label=+bug; owner=alice→bob [ci.failure]" + ); + } + + #[test] + fn publication_subject_drops_low_priority_facts_atomically_within_96_scalars() { + let facts = vec![ + ResourceFact::current("priority", "x".repeat(80)).unwrap(), + ResourceFact::current("lower", "must-not-leapfrog").unwrap(), + ]; + let subject = resource_change_subject( + "review", + &facts, + &["selected.topic".to_owned()], + "snapshot updated", + ); + assert_eq!(subject, "review · snapshot updated [selected.topic]"); + assert!(subject.chars().count() <= 96); + } + + #[test] + fn publication_subject_without_facts_has_a_useful_compatible_fallback() { + assert_eq!( + resource_change_subject("review", &[], &[], "snapshot updated"), + "review · snapshot updated" + ); + } + #[test] fn stale_protocol_failure_does_not_remove_the_replacement_process() { let old = RuntimeOwner::new( diff --git a/src/resync.rs b/src/resync.rs index 4e01854e..3f5707e0 100644 --- a/src/resync.rs +++ b/src/resync.rs @@ -16,11 +16,14 @@ use std::thread::JoinHandle; use std::time::{Duration, Instant}; use notify::Watcher as _; +use serde::Serialize; use sha2::{Digest as _, Sha256}; use agent_spec::profile::{ ProfileClass, ResourceProfileRefresh, ResourceProfileRegistry, }; -use agent_spec::spec::{AgentSpec, decode_percent_path}; +use agent_spec::spec::{AgentSpec, Resource, decode_percent_path}; + +use crate::resource_profile::{MAX_FACTS, MAX_FACT_KEY_BYTES, ResourceFact}; /// The reserved stream used only by the supervisor's crate-internal resync publisher. pub const RESYNC_STREAM: &str = "resync"; @@ -85,6 +88,57 @@ pub struct WatchableCarrier { pub class: CarrierClass, pub containment_root: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct DeclarationSummary { + bindings: BTreeMap, + complete: bool, +} + +fn declaration_summary(spec: &AgentSpec) -> DeclarationSummary { + let mut bindings = spec + .resources + .iter() + .map(|resource| { + ( + resource.name().to_owned(), + declaration_resource_digest(resource), + ) + }) + .collect::>(); + bindings.sort_by(|left, right| left.0.cmp(&right.0)); + let complete = bindings.len() <= MAX_FACTS + && bindings + .iter() + .all(|(label, _)| label.len() <= MAX_FACT_KEY_BYTES); + DeclarationSummary { + bindings: bindings.into_iter().take(MAX_FACTS).collect(), + complete, + } +} + +fn declaration_resource_digest(resource: &Resource) -> String { + let mut digest = Sha256::new(); + digest.update(b"st2.resync.resource-declaration.v1\0"); + update_digest_field(&mut digest, resource.uri().as_bytes()); + update_digest_field(&mut digest, resource.reason().as_bytes()); + match resource.inactive_reason() { + Some(reason) => { + digest.update([1]); + update_digest_field(&mut digest, reason.as_bytes()); + } + None => digest.update([0]), + } + let selector = serde_json::to_vec(&resource.selector()) + .expect("a parsed JSON selector always serializes"); + update_digest_field(&mut digest, &selector); + format!("{:x}", digest.finalize()) +} + +fn update_digest_field(digest: &mut Sha256, value: &[u8]) { + let length = u64::try_from(value.len()).expect("declaration fields fit in u64"); + digest.update(length.to_be_bytes()); + digest.update(value); +} /// The watchable carriers of one agent, keyed by its declaration path with current routing IDs. #[derive(Debug, Clone, PartialEq, Eq)] @@ -93,6 +147,7 @@ pub struct AgentWatchSet { pub bus_id: String, pub seat_id: Option, pub carriers: Vec, + declaration_summary: Option, } /// Resolve one spec's watch set: the declaration file plus every active resource binding whose @@ -207,6 +262,7 @@ fn resolve_watch_set( .unwrap_or_else(|| format!("{}.{}", spec.bus_id(this_host), task.name)) }), carriers, + declaration_summary: Some(declaration_summary(spec)), }, diagnostics, ) @@ -660,13 +716,72 @@ enum CarrierState { } impl CarrierState { - fn render(&self) -> &str { + fn fact_value(&self) -> String { match self { - Self::Present(digest) => digest, - Self::Missing => "missing", + Self::Present(digest) => digest.chars().take(12).collect(), + Self::Missing => "missing".to_owned(), } } } +fn digest_transition_fact(old: &CarrierState, new: &CarrierState) -> Vec { + vec![ + ResourceFact::transition("digest", Some(old.fact_value()), Some(new.fact_value())) + .expect("short carrier digests are valid facts"), + ] +} + +fn declaration_transition_facts( + old: Option<&DeclarationSummary>, + new: Option<&DeclarationSummary>, + old_state: &CarrierState, + new_state: &CarrierState, +) -> Vec { + let (Some(old), Some(new)) = (old, new) else { + return digest_transition_fact(old_state, new_state); + }; + if !old.complete || !new.complete { + return digest_transition_fact(old_state, new_state); + } + + let labels = old + .bindings + .keys() + .chain(new.bindings.keys()) + .cloned() + .collect::>(); + let facts = labels + .into_iter() + .filter_map(|label| match (old.bindings.get(&label), new.bindings.get(&label)) { + (None, Some(_)) => Some(ResourceFact::transition( + label, + None::, + Some("declared".to_owned()), + )), + (Some(_), None) => Some(ResourceFact::transition( + label, + Some("declared".to_owned()), + None::, + )), + (Some(before), Some(after)) if before != after => { + Some(ResourceFact::current(label, "changed")) + } + _ => None, + }) + .collect::, _>>(); + match facts { + Ok(facts) if !facts.is_empty() => facts, + Ok(_) | Err(_) => digest_transition_fact(old_state, new_state), + } +} + +fn current_declaration_summary(root: &Path, path: &Path) -> Option { + crate::discover_strict(root) + .specs + .into_iter() + .find(|spec| lexical_clean(&spec.path) == path) + .map(|spec| declaration_summary(&spec)) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct PendingTransition { @@ -674,8 +789,11 @@ struct PendingTransition { path: PathBuf, old_state: CarrierState, new_state: CarrierState, + facts: Vec, + topics: Vec, body: String, event_id: String, + new_declaration_summary: Option, } impl PendingTransition { @@ -686,17 +804,73 @@ impl PendingTransition { new_state: &CarrierState, incarnation: crate::event::StreamOwnerIncarnation, sequence: u64, + ) -> Self { + Self::capture( + binding, + path, + old_state, + new_state, + digest_transition_fact(old_state, new_state), + vec!["content".to_owned()], + None, + incarnation, + sequence, + ) + } + + fn declaration( + path: &Path, + old_state: &CarrierState, + new_state: &CarrierState, + old_summary: Option<&DeclarationSummary>, + new_summary: Option, + incarnation: crate::event::StreamOwnerIncarnation, + sequence: u64, + ) -> Self { + let facts = declaration_transition_facts( + old_summary, + new_summary.as_ref(), + old_state, + new_state, + ); + Self::capture( + "declaration", + path, + old_state, + new_state, + facts, + vec!["declaration".to_owned()], + new_summary, + incarnation, + sequence, + ) + } + + #[allow(clippy::too_many_arguments)] + fn capture( + binding: &str, + path: &Path, + old_state: &CarrierState, + new_state: &CarrierState, + facts: Vec, + topics: Vec, + new_declaration_summary: Option, + incarnation: crate::event::StreamOwnerIncarnation, + sequence: u64, ) -> Self { let occurrence = incarnation.occurrence_token(sequence); - let body = render_body(binding, path, old_state, new_state, &occurrence); + let body = render_body(binding, &topics, &facts, &occurrence); let event_id = transition_identity(&body); Self { binding: binding.to_owned(), path: path.to_path_buf(), old_state: old_state.clone(), new_state: new_state.clone(), + facts, + topics, body, event_id, + new_declaration_summary, } } } @@ -708,6 +882,7 @@ struct Entry { class: CarrierClass, containment_root: Option, state: Option, + declaration_summary: Option, /// Last occurrence sequence reserved by this retained subscription. Sequence zero is the /// silent seeded state; only capturing a new immutable transition advances it. occurrence_sequence: u64, @@ -843,6 +1018,7 @@ fn rebuild_carriers( ) -> BTreeMap> { let mut next: BTreeMap> = BTreeMap::new(); for set in refresh.sets { + let seeded_declaration_summary = set.declaration_summary.clone(); for carrier in set.carriers { // The canonical recipient and binding label identify one subscription across // declaration and carrier relocation. Rebuild every retained entry from the current @@ -852,32 +1028,37 @@ fn rebuild_carriers( // recipient-scoped namespace. let identity = (set.bus_id.clone(), carrier.label.clone()); let retained = take_retained_entry(&mut previous, &set.bus_id, &carrier.label); - let (state, occurrence_sequence, pending_transition, dirty) = retained.map_or_else( - || { - let (state, dirty) = - match read_state(&carrier.path, carrier.containment_root.as_deref()) { - Ok(state) => (Some(state), false), - Err(error) => { - diagnose_read_error(&carrier.path, &error); - (None, true) - } - }; - ( - state, - subscription_sequences.get(&identity).copied().unwrap_or(0), - None, - dirty, - ) - }, - |entry| { - ( - entry.state, - entry.occurrence_sequence, - entry.pending_transition, - entry.dirty, - ) - }, - ); + let (state, declaration_summary, occurrence_sequence, pending_transition, dirty) = + retained.map_or_else( + || { + let (state, dirty) = + match read_state(&carrier.path, carrier.containment_root.as_deref()) { + Ok(state) => (Some(state), false), + Err(error) => { + diagnose_read_error(&carrier.path, &error); + (None, true) + } + }; + ( + state, + (carrier.label == "declaration") + .then(|| seeded_declaration_summary.clone()) + .flatten(), + subscription_sequences.get(&identity).copied().unwrap_or(0), + None, + dirty, + ) + }, + |entry| { + ( + entry.state, + entry.declaration_summary, + entry.occurrence_sequence, + entry.pending_transition, + entry.dirty, + ) + }, + ); let entry = Entry { bus_id: set.bus_id.clone(), seat_id: set.seat_id.clone(), @@ -885,6 +1066,7 @@ fn rebuild_carriers( class: carrier.class, containment_root: carrier.containment_root.clone(), state, + declaration_summary, occurrence_sequence, pending_transition, dirty, @@ -1235,6 +1417,11 @@ impl Worker { fn flush_path(&mut self, path: &Path, due_class: Option) { let occurrence_incarnation = crate::event::current_stream_owner_incarnation(&self.root, &self.this_host).ok(); + let observed_declaration_summary = self + .carriers + .get(path) + .is_some_and(|entries| entries.iter().any(|entry| entry.label == "declaration")) + .then(|| current_declaration_summary(&self.root, path)); let Some(entries) = self.carriers.get_mut(path) else { return; }; @@ -1253,6 +1440,9 @@ impl Worker { .take() .expect("pending transition was just observed"); entry.state = Some(completed.new_state); + if completed.binding == "declaration" { + entry.declaration_summary = completed.new_declaration_summary; + } // The current carrier may have advanced or rebound while the immutable // transition was pending. Complete it first, then schedule current state. match observed { @@ -1305,17 +1495,32 @@ impl Worker { retries.push(entry.class); continue; }; - let transition = PendingTransition::new( - &entry.label, - path, - old_state, - &target_state, - incarnation, - sequence, - ); + let transition = if entry.label == "declaration" { + PendingTransition::declaration( + path, + old_state, + &target_state, + entry.declaration_summary.as_ref(), + observed_declaration_summary.clone().flatten(), + incarnation, + sequence, + ) + } else { + PendingTransition::new( + &entry.label, + path, + old_state, + &target_state, + incarnation, + sequence, + ) + }; entry.occurrence_sequence = sequence; if emit_resync(&self.root, &self.this_host, &entry.bus_id, &transition) { entry.state = Some(target_state); + if transition.binding == "declaration" { + entry.declaration_summary = transition.new_declaration_summary; + } } else { entry.pending_transition = Some(transition); entry.dirty = true; @@ -1346,7 +1551,12 @@ fn emit_resync( bus_id: &str, transition: &PendingTransition, ) -> bool { - let subject = format!("resource {} changed", transition.binding); + let subject = crate::resource_profile_supervisor::resource_change_subject( + &transition.binding, + &transition.facts, + &transition.topics, + "content changed", + ); match crate::event::emit_builtin_resync( root, this_host, @@ -1368,6 +1578,7 @@ fn emit_resync( } } + fn read_state(path: &Path, containment_root: Option<&Path>) -> std::io::Result { match containment_root { Some(root) => read_confined(path, root), @@ -1539,19 +1750,28 @@ fn read_confined(_path: &Path, _root: &Path) -> std::io::Result { )) } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ResyncBody<'a> { + binding: &'a str, + topics: &'a [String], + facts: &'a [ResourceFact], + occurrence: &'a str, +} + fn render_body( - label: &str, - path: &Path, - old: &CarrierState, - new: &CarrierState, + binding: &str, + topics: &[String], + facts: &[ResourceFact], occurrence: &str, ) -> String { - format!( - "resource `{label}` changed\n\nbinding: {label}\npath: {}\nold: {}\nnew: {}\noccurrence: {occurrence}\n", - path.display(), - old.render(), - new.render(), - ) + serde_json::to_string(&ResyncBody { + binding, + topics, + facts, + occurrence, + }) + .expect("validated resync facts always serialize") } #[cfg(test)] mod tests { @@ -1565,6 +1785,28 @@ mod tests { } } + #[test] + fn resync_subject_uses_the_shared_three_fact_and_96_scalar_renderer() { + let facts = vec![ + ResourceFact::transition("alpha", None::, Some("declared")).unwrap(), + ResourceFact::current("beta", "changed").unwrap(), + ResourceFact::transition("charlie", Some("declared"), None::).unwrap(), + ResourceFact::current("delta", "omitted").unwrap(), + ]; + let subject = crate::resource_profile_supervisor::resource_change_subject( + "declaration", + &facts, + &["declaration".to_owned()], + "content changed", + ); + assert_eq!( + subject, + "declaration · alpha=+declared; beta=changed; charlie=-declared [declaration]" + ); + assert!(subject.chars().count() <= 96); + assert!(!subject.contains("delta")); + } + fn owner_incarnation(seed: u64) -> crate::event::StreamOwnerIncarnation { crate::event::StreamOwnerIncarnation::for_test(seed, seed + 1, 42, seed + 2) } @@ -1592,13 +1834,135 @@ mod tests { .collect() } + fn event_body(event: &str) -> serde_json::Value { + let body = event + .lines() + .rev() + .find(|line| line.starts_with('{')) + .expect("JSON resync body"); + serde_json::from_str(body).expect("valid JSON resync body") + } + fn event_field(event: &str, field: &str) -> String { - event + if let Some(value) = event .lines() .find_map(|line| line.strip_prefix(&format!("{field}: "))) + { + return value.to_owned(); + } + let body = event_body(event); + if matches!(field, "old" | "new") { + let digest = body["facts"] + .as_array() + .and_then(|facts| facts.iter().find(|fact| fact["key"] == "digest")) + .expect("digest transition fact"); + let value = if field == "old" { + &digest["before"] + } else { + &digest["after"] + }; + return value.as_str().expect("digest fact value").to_owned(); + } + body[field] + .as_str() .unwrap_or_else(|| panic!("missing {field} in event")) .to_owned() } + #[test] + fn declaration_facts_are_ordered_added_removed_and_semantically_changed_labels() { + let root = tempfile::tempdir().unwrap(); + let declaration = root.path().join("agent.kdl"); + std::fs::write( + &declaration, + r#"agent "worker" { + host "host" + command "true" + resource "inactive" uri="file:///inactive" reason="kept" + resource "reason" uri="file:///reason" reason="before" + resource "removed" uri="file:///removed" reason="gone" + resource "uri" uri="file:///before" reason="same" +}"#, + ) + .unwrap(); + let before = declaration_summary(&discover(root.path())); + std::fs::write( + &declaration, + r#"agent "worker" { + host "host" + command "true" + resource "added" uri="file:///added" reason="new" + resource "inactive" uri="file:///inactive" reason="kept" inactive-reason="paused" + resource "reason" uri="file:///reason" reason="after" + resource "uri" uri="file:///after" reason="same" +}"#, + ) + .unwrap(); + let after = declaration_summary(&discover(root.path())); + let facts = declaration_transition_facts( + Some(&before), + Some(&after), + &CarrierState::Present("before-digest".to_owned()), + &CarrierState::Present("after-digest".to_owned()), + ); + assert_eq!( + facts.iter().map(ResourceFact::key).collect::>(), + vec!["added", "inactive", "reason", "removed", "uri"] + ); + assert_eq!(facts[0].before(), Some(None)); + assert_eq!(facts[0].after(), Some(Some("declared"))); + for index in [1, 2, 4] { + assert_eq!(facts[index].before(), None); + assert_eq!(facts[index].after(), Some(Some("changed"))); + } + assert_eq!(facts[3].before(), Some(Some("declared"))); + assert_eq!(facts[3].after(), Some(None)); + } + + #[test] + fn declaration_parse_failure_retains_a_digest_fact_for_later_delivery() { + let root = tempfile::tempdir().unwrap(); + let agent_dir = root.path().join("agents/host/worker"); + std::fs::create_dir_all(&agent_dir).unwrap(); + let declaration = agent_dir.join("agent.kdl"); + let valid = r#"agent "worker" { + host "host" + command "true" + resource "goal" uri="resources/goal.md" reason="Mission." +}"#; + std::fs::write(&declaration, valid).unwrap(); + crate::event::publish_owner_binding_for_test(root.path(), "host").unwrap(); + let set = watch_set_for(&discover(root.path()), "host", &Default::default()); + let mut worker = Worker { + root: root.path().to_path_buf(), + this_host: "host".to_owned(), + carriers: BTreeMap::new(), + subscription_sequences: BTreeMap::new(), + deadlines: BTreeMap::new(), + watched: BTreeMap::new(), + watcher: None, + }; + worker.apply_watch_sets(refresh_for(vec![set])); + std::fs::write(&declaration, "not an Agent Spec").unwrap(); + worker.flush_path(&declaration, None); + let pending = worker.carriers[&declaration][0] + .pending_transition + .as_ref() + .expect("malformed declaration keeps its digest fallback"); + assert_eq!(pending.facts.len(), 1); + assert_eq!(pending.facts[0].key(), "digest"); + assert_eq!(pending.topics, ["declaration"]); + assert_eq!(event_body(&pending.body)["facts"].as_array().unwrap().len(), 1); + std::fs::write(&declaration, valid).unwrap(); + worker.flush_path(&declaration, None); + let delivered = resync_inbox_event(&agent_dir); + let body = event_body(&delivered); + assert_eq!(body["binding"], "declaration"); + assert_eq!(body["topics"], serde_json::json!(["declaration"])); + assert_eq!(body["facts"][0]["key"], "digest"); + assert!(!delivered.contains("file:///")); + assert!(!delivered.contains("Mission.")); + } + #[test] fn watch_set_covers_declaration_and_local_bindings_only() { @@ -1735,6 +2099,7 @@ mod tests { class: CarrierClass::Immediate, containment_root: None, state: Some(CarrierState::Present("alpha-before".to_owned())), + declaration_summary: None, occurrence_sequence: 4, pending_transition: None, dirty: true, @@ -1746,6 +2111,7 @@ mod tests { class: CarrierClass::Coalesced, containment_root: None, state: Some(CarrierState::Present("beta-before".to_owned())), + declaration_summary: None, occurrence_sequence: 9, dirty: true, pending_transition: None, @@ -1763,6 +2129,7 @@ mod tests { class: CarrierClass::Immediate, containment_root: None, }], + declaration_summary: None, }, AgentWatchSet { declaration_path: PathBuf::from("/catalog/beta/agent.kdl"), @@ -1774,6 +2141,7 @@ mod tests { class: CarrierClass::Coalesced, containment_root: None, }], + declaration_summary: None, }, ]; @@ -1830,6 +2198,7 @@ mod tests { class: CarrierClass::Coalesced, containment_root: None, state: Some(CarrierState::Present("old-digest".to_owned())), + declaration_summary: None, occurrence_sequence: 3, pending_transition: None, dirty: true, @@ -1899,6 +2268,7 @@ mod tests { class: CarrierClass::Immediate, containment_root: None, state: Some(CarrierState::Present("old-digest".to_owned())), + declaration_summary: None, occurrence_sequence: 0, pending_transition: None, dirty: true, @@ -1937,11 +2307,11 @@ mod tests { .expect("the retry routes through the refreshed recipient"); assert!(event.contains(&format!("event-id: {}", pending.event_id)), "{event}"); assert!(event.contains(&pending.body), "{event}"); - assert!(pending.body.contains(&format!("path: {}", old_path.display()))); + let pending_body: serde_json::Value = serde_json::from_str(&pending.body).unwrap(); + assert_eq!(pending_body["binding"], "goal"); + assert_eq!(pending_body["facts"][0]["before"], "old-digest"); assert!( - !pending - .body - .contains(&format!("path: {}", current_path.display())), + !pending.body.contains(¤t_path.display().to_string()), "rebinding must not rewrite bytes reserved under the pending event identity" ); let entry = &worker.carriers[¤t_path][0]; @@ -1975,6 +2345,7 @@ mod tests { class: CarrierClass::Immediate, containment_root: None, state: read_state(&old_path, None).ok(), + declaration_summary: None, occurrence_sequence: 0, pending_transition: None, dirty: false, @@ -1996,6 +2367,7 @@ mod tests { class: CarrierClass::Immediate, containment_root: None, }], + declaration_summary: None, }])); assert!(!worker.carriers.contains_key(&old_path)); @@ -2025,6 +2397,7 @@ mod tests { class: CarrierClass::Immediate, containment_root: None, state: read_state(&carrier, None).ok(), + declaration_summary: None, occurrence_sequence: 0, pending_transition: None, dirty: true, @@ -2046,6 +2419,7 @@ mod tests { class, containment_root: None, }], + declaration_summary: None, }]) }; @@ -2079,6 +2453,7 @@ mod tests { class: CarrierClass::Immediate, containment_root: None, state: Some(CarrierState::Present("before".to_owned())), + declaration_summary: None, occurrence_sequence: 1, pending_transition: Some(PendingTransition::new( "declaration", @@ -2163,6 +2538,7 @@ mod tests { class: CarrierClass::Immediate, containment_root: None, state: Some(CarrierState::Present("old-digest".to_owned())), + declaration_summary: None, occurrence_sequence: 1, pending_transition: Some(PendingTransition::new( "goal", @@ -2193,8 +2569,8 @@ mod tests { .map(|entry| std::fs::read_to_string(entry.unwrap().path()).unwrap()) .find(|body| body.contains("stream: resync")) .expect("pending transition is replayed"); - assert!(event.contains("old: old-digest"), "{event}"); - assert!(event.contains("new: pending-target"), "{event}"); + assert_eq!(event_field(&event, "old"), "old-digest"); + assert_eq!(event_field(&event, "new"), "pending-targ"); let entry = &worker.carriers[&carrier][0]; assert_eq!( entry.state, @@ -2226,6 +2602,7 @@ mod tests { class: CarrierClass::Coalesced, containment_root: None, state: baseline.clone(), + declaration_summary: None, occurrence_sequence: 0, pending_transition: None, dirty: false, @@ -2248,6 +2625,7 @@ mod tests { class: CarrierClass::Coalesced, containment_root: None, }], + declaration_summary: None, }])); worker.flush_due(now + IMMEDIATE_WINDOW + Duration::from_secs(1)); @@ -2312,7 +2690,7 @@ mod tests { .collect::>(); assert_eq!(events.len(), 1); assert!(events[0].contains("stream: resync")); - assert!(events[0].contains("binding: goal")); + assert!(events[0].contains(r#""binding":"goal""#)); } #[test] @@ -2433,6 +2811,7 @@ mod tests { class, containment_root: None, state: state.clone(), + declaration_summary: None, occurrence_sequence: 0, pending_transition: None, dirty: true, @@ -2548,7 +2927,10 @@ mod tests { worker.flush_path(&carrier, None); let deletion = resync_inbox_events(&agent_dir); assert_eq!(deletion.len(), 1); - assert_eq!(event_field(&deletion[0], "old"), original_digest); + assert_eq!( + event_field(&deletion[0], "old"), + original_digest.chars().take(12).collect::() + ); assert_eq!(event_field(&deletion[0], "new"), "missing"); assert!(event_field(&deletion[0], "occurrence").ends_with(":1")); assert_eq!( @@ -2864,6 +3246,7 @@ mod tests { class: CarrierClass::Immediate, containment_root: None, state: Some(CarrierState::Present("old-digest".to_owned())), + declaration_summary: None, occurrence_sequence: 0, pending_transition: None, dirty: true, @@ -2932,6 +3315,7 @@ mod tests { class: CarrierClass::Immediate, containment_root: None, state: Some(CarrierState::Present("old-digest".to_owned())), + declaration_summary: None, occurrence_sequence: 0, pending_transition: None, dirty: true, @@ -2950,8 +3334,8 @@ mod tests { .clone() .expect("failed transition snapshot is retained"); assert_eq!(pending_transition.new_state, CarrierState::Missing); - assert!(pending_transition.body.contains("old: old-digest")); - assert!(pending_transition.body.contains("new: missing")); + assert_eq!(event_field(&pending_transition.body, "old"), "old-digest"); + assert_eq!(event_field(&pending_transition.body, "new"), "missing"); assert_eq!(worker.carriers[&carrier][0].occurrence_sequence, 1); std::fs::write(&carrier, "old bytes").unwrap(); worker.flush_path(&carrier, None); @@ -2972,91 +3356,34 @@ mod tests { #[test] fn transition_identity_covers_every_rendered_transition_dimension() { - let old = CarrierState::Present("old-digest".to_owned()); - let new = CarrierState::Present("new-digest".to_owned()); - let baseline = render_body( - "goal", - Path::new("/agent/goal.md"), - &old, - &new, - "v1:1:2:42:3:1", - ); + let topics = vec!["content".to_owned()]; + let facts = + vec![ResourceFact::transition("digest", Some("old"), Some("new")).unwrap()]; + let baseline = render_body("goal", &topics, &facts, "v1:1:2:42:3:1"); assert_eq!( transition_identity(&baseline), transition_identity(&baseline), "replaying one canonical body must reproduce its identity" ); + let changed_facts = + vec![ResourceFact::transition("digest", Some("old"), Some("other")).unwrap()]; for (dimension, changed) in [ ( "binding", - render_body( - "spec", - Path::new("/agent/goal.md"), - &old, - &new, - "v1:1:2:42:3:1", - ), - ), - ( - "path", - render_body( - "goal", - Path::new("/other/goal.md"), - &old, - &new, - "v1:1:2:42:3:1", - ), + render_body("spec", &topics, &facts, "v1:1:2:42:3:1"), ), ( - "old state", - render_body( - "goal", - Path::new("/agent/goal.md"), - &CarrierState::Present("other-old".to_owned()), - &new, - "v1:1:2:42:3:1", - ), + "topic", + render_body("goal", &["other".to_owned()], &facts, "v1:1:2:42:3:1"), ), ( - "missing old state", - render_body( - "goal", - Path::new("/agent/goal.md"), - &CarrierState::Missing, - &new, - "v1:1:2:42:3:1", - ), - ), - ( - "new state", - render_body( - "goal", - Path::new("/agent/goal.md"), - &old, - &CarrierState::Present("other-new".to_owned()), - "v1:1:2:42:3:1", - ), - ), - ( - "missing new state", - render_body( - "goal", - Path::new("/agent/goal.md"), - &old, - &CarrierState::Missing, - "v1:1:2:42:3:1", - ), + "fact", + render_body("goal", &topics, &changed_facts, "v1:1:2:42:3:1"), ), ( "occurrence", - render_body( - "goal", - Path::new("/agent/goal.md"), - &old, - &new, - "v1:1:2:42:3:2", - ), + render_body("goal", &topics, &facts, "v1:1:2:42:3:2"), ), ] { assert_ne!( diff --git a/src/run.rs b/src/run.rs index 7f1fcd6e..241a4a88 100644 --- a/src/run.rs +++ b/src/run.rs @@ -4477,7 +4477,7 @@ mod tests { std::fs::write(&live_goal, "changed while compile failed\n").unwrap(); let first_event = wait_for_resync_event(&live_dir) .expect("the already-live valid seat must stay watched across the compile error"); - assert!(first_event.contains("binding: goal"), "{first_event}"); + assert!(first_event.contains(r#""binding":"goal""#), "{first_event}"); std::fs::write(&broken_goal, "invalid seat changed\n").unwrap(); std::thread::sleep(Duration::from_millis(750)); @@ -4520,7 +4520,7 @@ mod tests { ); let corrected_event = wait_for_resync_event_change(&live_dir, &first_event) .expect("correcting another declaration must not reseed and hide the live transition"); - assert!(corrected_event.contains("binding: goal"), "{corrected_event}"); + assert!(corrected_event.contains(r#""binding":"goal""#), "{corrected_event}"); } #[test] @@ -4589,7 +4589,7 @@ mod tests { std::fs::write(&dormant_goal, "unwatched while materialization failed\n").unwrap(); let first_event = wait_for_resync_event(&live_dir) .expect("the observed live seat must remain watched through materialization failure"); - assert!(first_event.contains("binding: goal"), "{first_event}"); + assert!(first_event.contains(r#""binding":"goal""#), "{first_event}"); std::thread::sleep(Duration::from_millis(750)); assert!( current_resync_event(&dormant_dir).is_none(), @@ -4618,7 +4618,7 @@ mod tests { let recovered_event = wait_for_resync_event_change(&live_dir, &first_event) .expect("recovery must preserve the pending transition instead of silently reseeding"); assert!( - recovered_event.contains("binding: goal"), + recovered_event.contains(r#""binding":"goal""#), "{recovered_event}" ); } @@ -4771,7 +4771,7 @@ mod tests { let event = wait_for_resync_event(&first_dir) .expect("the first seat must observe a carrier transition during the later launch"); - assert!(event.contains("binding: goal"), "{event}"); + assert!(event.contains(r#""binding":"goal""#), "{event}"); } #[test] @@ -4890,7 +4890,7 @@ mod tests { std::fs::write(&goal, "changed after replacement launch\n").unwrap(); let event = wait_for_resync_event(&agent_dir) .expect("the successful replacement must receive a fresh silent baseline"); - assert!(event.contains("binding: goal"), "{event}"); + assert!(event.contains(r#""binding":"goal""#), "{event}"); } #[test] @@ -4963,7 +4963,7 @@ mod tests { ); let event = wait_for_resync_event(&agent_dir) .expect("the companion launch and final refresh must preserve the canonical baseline"); - assert!(event.contains("binding: goal"), "{event}"); + assert!(event.contains(r#""binding":"goal""#), "{event}"); } #[test] diff --git a/tests/resource_profile_supervisor_e2e.rs b/tests/resource_profile_supervisor_e2e.rs index 1f976854..8a980597 100755 --- a/tests/resource_profile_supervisor_e2e.rs +++ b/tests/resource_profile_supervisor_e2e.rs @@ -9,8 +9,8 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use st2::resource_profile::{ - BindingId, HostMessage, RegistrationToken, RuntimeHealthState, RuntimeMessage, RuntimeOwner, - SnapshotBytes, decode_host_line, encode_runtime_line, + BindingId, HostMessage, RegistrationToken, ResourceFact, RuntimeHealthState, RuntimeMessage, + RuntimeOwner, SnapshotBytes, decode_host_line, encode_runtime_line, }; use st2::resource_profile_supervisor::ResourceProfileSupervisor; @@ -67,6 +67,9 @@ impl RuntimeControl { media_type: MEDIA_TYPE.to_owned(), bytes: SnapshotBytes::new(bytes.to_vec()).unwrap(), topics: topics.iter().map(|topic| (*topic).to_owned()).collect(), + facts: Some(vec![ + ResourceFact::current("revision", health_marker).unwrap(), + ]), observed_at: None, }; let health = RuntimeMessage::Health { @@ -220,6 +223,16 @@ fn observable_publication_reaches_builtin_resync_with_filter_catch_up_and_scope_ 1, "the first selected publication must create one built-in resync record" ); + assert!( + first_inbox[0].contains("subject: observed · revision=primary-first [selected]"), + "{}", + first_inbox[0] + ); + assert!( + first_inbox[0].contains(r#""facts":[{"key":"revision","after":"primary-first"}]"#), + "{}", + first_inbox[0] + ); primary .runtime @@ -274,6 +287,11 @@ fn observable_publication_reaches_builtin_resync_with_filter_catch_up_and_scope_ caught_up_inbox, first_inbox, "restoring delivery must replace the old head with the pending digest" ); + assert!( + caught_up_inbox[0].contains("revision=delivery-unavailable"), + "{}", + caught_up_inbox[0] + ); let caught_up_projection = file_tree(&primary.agent_dir.join("resources")); primary.refresh(&primary_supervisor); assert_eq!( @@ -391,7 +409,7 @@ fn file_tree(root: &Path) -> Vec<(PathBuf, Vec)> { } fn observable_resolver_wasm() -> Vec { - const DESCRIPTOR: &[u8] = br#"{"abiVersion":2,"capabilities":["resolve","read","observe"],"selectorSchema":{"type":"object","properties":{"topics":{"type":"array","items":{"type":"string"},"uniqueItems":true}},"required":["topics"],"additionalProperties":false},"defaultSelector":{"topics":["selected"]},"topics":[{"name":"selected"},{"name":"ignored"}],"runtime":{"topology":"shared"},"snapshot":{"mediaType":"application/json","schemaId":"dev.example.observable.snapshot.v1"}}"#; + const DESCRIPTOR: &[u8] = br#"{"abiVersion":3,"capabilities":["resolve","read","observe"],"selectorSchema":{"type":"object","properties":{"topics":{"type":"array","items":{"type":"string"},"uniqueItems":true}},"required":["topics"],"additionalProperties":false},"defaultSelector":{"topics":["selected"]},"topics":[{"name":"selected"},{"name":"ignored"}],"runtime":{"topology":"shared"},"snapshot":{"mediaType":"application/json","schemaId":"dev.example.observable.snapshot.v1"}}"#; const RESOLUTION: &[u8] = br#"{"path":"resources/snapshot.json","class":"observable"}"#; const DESCRIPTOR_PTR: i64 = 1024; const RESOLUTION_PTR: i64 = 4096; diff --git a/tests/resync.rs b/tests/resync.rs index 6cde6bb7..81a27e3d 100644 --- a/tests/resync.rs +++ b/tests/resync.rs @@ -95,8 +95,8 @@ fn carrier_change_emits_one_superseded_resync_event_and_silent_stores_stay_quiet "goal change must produce exactly one resync event within the immediate window" ); let body = &resync_events(&agent_dir)[0]; - assert!(body.contains("resource goal changed"), "{body}"); - assert!(body.contains("binding: goal"), "{body}"); + assert!(body.contains("subject: goal · digest="), "{body}"); + assert!(body.contains(r#""binding":"goal""#), "{body}"); let legitimate_event_id = body .lines() .find(|line| line.starts_with("event-id:")) @@ -153,7 +153,7 @@ fn carrier_change_emits_one_superseded_resync_event_and_silent_stores_stay_quiet || { resync_events(&agent_dir) .iter() - .filter(|b| !b.contains(&first_event_id) && b.contains("binding: goal")) + .filter(|b| !b.contains(&first_event_id) && b.contains(r#""binding":"goal""#)) .count() }, 1 @@ -189,7 +189,7 @@ fn whole_file_declaration_replacement_by_rename_notifies_immediately() { || { resync_events(&agent_dir) .iter() - .filter(|b| b.contains("binding: declaration")) + .filter(|b| b.contains(r#""binding":"declaration""#)) .count() }, 1 @@ -283,7 +283,7 @@ fn declared_wasm_profile_resolves_a_scheme_uri_goal_binding_and_fires_on_change( resync_events(&agent_dir) ); let body = &resync_events(&agent_dir)[0]; - assert!(body.contains("resource goal changed"), "{body}"); + assert!(body.contains("subject: goal · digest="), "{body}"); } #[test]