//! Orchestrator request model (ARCHITECTURE §14.3). //! //! An *orchestrator* agent does not spawn child processes itself: it **delegates** //! agent lifecycle to IdeA (the single source of truth) by dropping a JSON request //! file under `/.ideai/requests//*.json`. This module //! owns the **pure** request model: the wire-level [`OrchestratorRequest`] (serde, //! camelCase) and its validation into a well-formed [`OrchestratorCommand`]. //! //! It is I/O-free: parsing the file, dispatching to use cases and writing the //! response are infrastructure/application concerns. Keeping the model here means //! validation invariants (known action, required fields present) are unit-testable //! without touching the filesystem. use serde::{Deserialize, Serialize}; /// Errors raised while validating a raw [`OrchestratorRequest`]. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum OrchestratorError { /// The `action` field is not one of the supported v1 actions. #[error("unknown orchestrator action: {0}")] UnknownAction(String), /// A field required by the chosen action is missing or empty. #[error("missing required field `{field}` for action `{action}`")] MissingField { /// The action being validated. action: String, /// The required field that was absent or empty. field: String, }, } /// The raw, wire-level orchestrator request as deserialised from a request file. /// /// All payload fields are optional at this layer; which ones are *required* /// depends on `action` and is enforced by [`OrchestratorRequest::validate`]. This /// keeps deserialisation total (any JSON object shape parses) and pushes the /// metier invariants into one explicit, tested place. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OrchestratorRequest { /// The requested action (`spawn_agent`, `stop_agent`, `update_agent_context`). pub action: String, /// Target agent display name (required by every v1 action). #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, /// Runtime profile slug/name (required by `spawn_agent`). #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, /// Context reference: for `spawn_agent` the relative `.md` path is informative /// (the manifest owns the real path); for `update_agent_context` this carries /// the **new Markdown body** to write. #[serde(default, skip_serializing_if = "Option::is_none")] pub context: Option, } /// A validated orchestrator command — the only thing the application layer acts on. /// /// Each variant carries exactly the fields its action needs; constructing one is /// proof the request was well-formed (Parse, don't validate). #[derive(Debug, Clone, PartialEq, Eq)] pub enum OrchestratorCommand { /// Create the agent if unknown (with `profile` + optional initial context), /// then launch it — exactly as the UI would. SpawnAgent { /// Target agent display name. name: String, /// Profile slug/name to resolve against the configured profiles. profile: String, /// Optional initial `.md` body for a freshly-created agent. context: Option, }, /// Stop a running agent by killing its terminal session. StopAgent { /// Target agent display name. name: String, }, /// Overwrite an agent's `.md` context with a new body. UpdateAgentContext { /// Target agent display name. name: String, /// New Markdown body. context: String, }, } impl OrchestratorRequest { /// Validates the raw request into a well-formed [`OrchestratorCommand`]. /// /// Invariants enforced here (ARCHITECTURE §14.3): /// - `action` must be a known v1 action, /// - `name` is required (non-empty) for every action, /// - `spawn_agent` additionally requires a non-empty `profile`, /// - `update_agent_context` additionally requires a `context` body. /// /// # Errors /// [`OrchestratorError::UnknownAction`] for an unsupported action; /// [`OrchestratorError::MissingField`] when a required field is absent/empty. pub fn validate(&self) -> Result { let action = self.action.trim(); match action { "spawn_agent" => Ok(OrchestratorCommand::SpawnAgent { name: self.require_name(action)?, profile: self.require("profile", action, self.profile.as_deref())?, context: self .context .as_ref() .filter(|c| !c.is_empty()) .cloned(), }), "stop_agent" => Ok(OrchestratorCommand::StopAgent { name: self.require_name(action)?, }), "update_agent_context" => Ok(OrchestratorCommand::UpdateAgentContext { name: self.require_name(action)?, context: self.require("context", action, self.context.as_deref())?, }), other => Err(OrchestratorError::UnknownAction(other.to_owned())), } } /// Requires a non-empty `name`, shared by all actions. fn require_name(&self, action: &str) -> Result { self.require("name", action, self.name.as_deref()) } /// Requires `value` to be present and non-empty (after trimming), else a /// [`OrchestratorError::MissingField`] naming `field`/`action`. fn require( &self, field: &str, action: &str, value: Option<&str>, ) -> Result { match value { Some(v) if !v.trim().is_empty() => Ok(v.trim().to_owned()), _ => Err(OrchestratorError::MissingField { action: action.to_owned(), field: field.to_owned(), }), } } } #[cfg(test)] mod tests { use super::*; fn req(json: &str) -> OrchestratorRequest { serde_json::from_str(json).expect("valid json") } #[test] fn spawn_agent_parses_and_validates() { let r = req( r#"{ "action": "spawn_agent", "name": "dev-backend", "profile": "claude-code", "context": "agents/dev-backend.md" }"#, ); assert_eq!( r.validate().unwrap(), OrchestratorCommand::SpawnAgent { name: "dev-backend".to_owned(), profile: "claude-code".to_owned(), context: Some("agents/dev-backend.md".to_owned()), } ); } #[test] fn spawn_agent_without_context_is_valid() { let r = req(r#"{ "action": "spawn_agent", "name": "a", "profile": "claude-code" }"#); assert_eq!( r.validate().unwrap(), OrchestratorCommand::SpawnAgent { name: "a".to_owned(), profile: "claude-code".to_owned(), context: None, } ); } #[test] fn spawn_agent_missing_profile_is_rejected() { let r = req(r#"{ "action": "spawn_agent", "name": "a" }"#); assert_eq!( r.validate(), Err(OrchestratorError::MissingField { action: "spawn_agent".to_owned(), field: "profile".to_owned(), }) ); } #[test] fn stop_agent_validates() { let r = req(r#"{ "action": "stop_agent", "name": "dev-backend" }"#); assert_eq!( r.validate().unwrap(), OrchestratorCommand::StopAgent { name: "dev-backend".to_owned() } ); } #[test] fn stop_agent_missing_name_is_rejected() { let r = req(r#"{ "action": "stop_agent" }"#); assert_eq!( r.validate(), Err(OrchestratorError::MissingField { action: "stop_agent".to_owned(), field: "name".to_owned(), }) ); } #[test] fn update_context_requires_a_body() { let ok = req( r##"{ "action": "update_agent_context", "name": "a", "context": "# new body" }"##, ); assert_eq!( ok.validate().unwrap(), OrchestratorCommand::UpdateAgentContext { name: "a".to_owned(), context: "# new body".to_owned(), } ); let missing = req(r#"{ "action": "update_agent_context", "name": "a" }"#); assert_eq!( missing.validate(), Err(OrchestratorError::MissingField { action: "update_agent_context".to_owned(), field: "context".to_owned(), }) ); } #[test] fn unknown_action_is_rejected() { let r = req(r#"{ "action": "delete_everything", "name": "a" }"#); assert_eq!( r.validate(), Err(OrchestratorError::UnknownAction("delete_everything".to_owned())) ); } #[test] fn blank_name_is_treated_as_missing() { let r = req(r#"{ "action": "stop_agent", "name": " " }"#); assert!(matches!( r.validate(), Err(OrchestratorError::MissingField { .. }) )); } }