diff --git a/crates/alknet-call/src/registry/context.rs b/crates/alknet-call/src/registry/context.rs index a3ccb75..543b978 100644 --- a/crates/alknet-call/src/registry/context.rs +++ b/crates/alknet-call/src/registry/context.rs @@ -1,7 +1,178 @@ -//! Operation context: `OperationContext`, `AbortPolicy`, -//! `CompositionAuthority`, and `ScopedOperationEnv`. -//! -//! See `docs/architecture/crates/call/operation-registry.md` for the full -//! specification. +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Instant; -// TODO: implement +use alknet_core::auth::Identity; +use alknet_core::types::Capabilities; +use serde_json::Value; + +use super::env::OperationEnv; + +pub struct OperationContext { + pub request_id: String, + pub parent_request_id: Option, + pub identity: Option, + pub handler_identity: Option, + pub capabilities: Capabilities, + pub metadata: HashMap, + pub scoped_env: ScopedOperationEnv, + pub env: Arc, + pub abort_policy: AbortPolicy, + pub deadline: Option, + pub(crate) internal: bool, +} + +impl OperationContext { + pub fn is_internal(&self) -> bool { + self.internal + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AbortPolicy { + #[default] + AbortDependents, + ContinueRunning, +} + +#[derive(Debug, Clone)] +pub struct CompositionAuthority { + pub label: String, + pub scopes: Vec, + pub resources: HashMap>, +} + +impl CompositionAuthority { + pub fn none() -> Option { + None + } + + pub fn new(label: &str, scopes: impl IntoIterator) -> Self { + Self { + label: label.to_string(), + scopes: scopes.into_iter().collect(), + resources: HashMap::new(), + } + } + + pub fn as_identity(&self) -> Option { + Some(Identity { + id: self.label.clone(), + scopes: self.scopes.clone(), + resources: self.resources.clone(), + }) + } +} + +#[derive(Debug, Clone)] +pub struct ScopedOperationEnv { + allowed: HashSet, +} + +impl ScopedOperationEnv { + pub fn empty() -> Self { + Self { + allowed: HashSet::new(), + } + } + + pub fn new(ops: impl IntoIterator>) -> Self { + Self { + allowed: ops.into_iter().map(|s| s.into()).collect(), + } + } + + pub fn allows(&self, name: &str) -> bool { + self.allowed.contains(name) + } +} + +impl Default for ScopedOperationEnv { + fn default() -> Self { + Self::empty() + } +} + +#[allow(dead_code)] +pub(crate) fn generate_request_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scoped_env_allows_in_set() { + let env = ScopedOperationEnv::new(["fs/readFile", "agent/chat"]); + assert!(env.allows("fs/readFile")); + assert!(env.allows("agent/chat")); + } + + #[test] + fn scoped_env_disallows_not_in_set() { + let env = ScopedOperationEnv::new(["fs/readFile"]); + assert!(!env.allows("agent/chat")); + assert!(!env.allows("")); + } + + #[test] + fn scoped_env_empty_allows_nothing() { + let env = ScopedOperationEnv::empty(); + assert!(!env.allows("fs/readFile")); + } + + #[test] + fn composition_authority_as_identity_correct() { + let mut resources = HashMap::new(); + resources.insert("service".to_string(), vec!["vastai".to_string()]); + let authority = CompositionAuthority { + label: "agent-chat".to_string(), + scopes: vec!["llm:call".to_string(), "fs:read".to_string()], + resources, + }; + let identity = authority.as_identity().expect("as_identity returns Some"); + assert_eq!(identity.id, "agent-chat"); + assert_eq!( + identity.scopes, + vec!["llm:call".to_string(), "fs:read".to_string()] + ); + assert_eq!( + identity.resources.get("service"), + Some(&vec!["vastai".to_string()]) + ); + } + + #[test] + fn composition_authority_new_populates_label_and_scopes() { + let authority = CompositionAuthority::new( + "agent-chat", + ["llm:call".to_string(), "fs:read".to_string()], + ); + assert_eq!(authority.label, "agent-chat"); + assert_eq!( + authority.scopes, + vec!["llm:call".to_string(), "fs:read".to_string()] + ); + assert!(authority.resources.is_empty()); + } + + #[test] + fn composition_authority_none_is_none() { + assert!(CompositionAuthority::none().is_none()); + } + + #[test] + fn abort_policy_default_is_abort_dependents() { + let policy = AbortPolicy::default(); + assert!(matches!(policy, AbortPolicy::AbortDependents)); + } + + #[test] + fn generate_request_id_is_unique_and_non_deterministic() { + let a = generate_request_id(); + let b = generate_request_id(); + assert_ne!(a, b); + assert!(!a.is_empty()); + } +} diff --git a/crates/alknet-call/src/registry/env.rs b/crates/alknet-call/src/registry/env.rs index 20cb725..b4cca6e 100644 --- a/crates/alknet-call/src/registry/env.rs +++ b/crates/alknet-call/src/registry/env.rs @@ -1,8 +1,32 @@ -//! Operation environment: the `OperationEnv` trait, `LocalOperationEnv`, and -//! `CompositeOperationEnv`. -//! -//! The composition dispatch trait — handlers compose child operations through -//! `OperationContext.env`. See -//! `docs/architecture/crates/call/operation-registry.md` and ADR-024. +use serde_json::Value; -// TODO: implement +use super::context::{AbortPolicy, OperationContext}; +use crate::protocol::wire::ResponseEnvelope; + +#[async_trait::async_trait] +pub trait OperationEnv: Send + Sync { + async fn invoke( + &self, + namespace: &str, + operation: &str, + input: Value, + parent: &OperationContext, + ) -> ResponseEnvelope { + self.invoke_with_policy(namespace, operation, input, parent, parent.abort_policy) + .await + } + + async fn invoke_with_policy( + &self, + namespace: &str, + operation: &str, + input: Value, + parent: &OperationContext, + policy: AbortPolicy, + ) -> ResponseEnvelope; + + fn contains(&self, name: &str) -> bool { + let _ = name; + true + } +}