//! 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}; use crate::ids::NodeId; use crate::skill::SkillScope; /// Errors raised while validating a raw [`OrchestratorRequest`]. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum OrchestratorError { /// The requested action/type is not one of the supported 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 `scope` field is present but not a recognised skill scope. #[error("unknown skill scope `{scope}` for action `{action}`")] UnknownScope { /// The action being validated. action: String, /// The offending scope value. scope: String, }, /// The `visibility` field is present but not recognised. #[error("unknown visibility `{visibility}` for action `{action}`")] UnknownVisibility { /// The action being validated. action: String, /// The offending visibility value. visibility: 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 { /// Legacy v1 action (`spawn_agent`, `stop_agent`, `update_agent_context`). #[serde(default, skip_serializing_if = "Option::is_none")] pub action: Option, /// V2 action type (`agent.run`, `agent.stop`, `agent.message`, /// `agent.update_context`, `skill.create`). `type` is reserved in Rust, so the /// field is renamed. #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")] pub request_type: Option, /// Optional requester id/name, informational at this layer. #[serde(default, skip_serializing_if = "Option::is_none")] pub requested_by: Option, /// Target agent display name (required by every v1 action). #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, /// V2 target agent display name (`agent.run`/`agent.stop`). #[serde(default, skip_serializing_if = "Option::is_none")] pub target_agent: 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` **and** /// `create_skill` this carries the **new Markdown body** to write. #[serde(default, skip_serializing_if = "Option::is_none")] pub context: Option, /// Task/message carried by `agent.run` (fire-and-forget) and required by /// `agent.message` (synchronous ask: the prompt sent to the target agent). #[serde(default, skip_serializing_if = "Option::is_none")] pub task: Option, /// Whether a spawned agent should stay in the background or attach to a cell. /// /// Accepted values are `"background"` (default) and `"visible"`. A visible /// spawn must also provide [`Self::node_id`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub visibility: Option, /// Target layout leaf for `visibility: "visible"`. #[serde(default, skip_serializing_if = "Option::is_none")] pub node_id: Option, /// V2 visible-cell target (`attachToCell`), equivalent to `nodeId`. #[serde(default, skip_serializing_if = "Option::is_none")] pub attach_to_cell: Option, /// Skill scope for `create_skill` (`"global"` or `"project"`, case-insensitive). /// Optional — absent/empty defaults to [`SkillScope::Project`]. Ignored by the /// other actions. #[serde(default, skip_serializing_if = "Option::is_none")] pub scope: 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. Required /// for legacy `spawn_agent`; optional for `agent.run` when the agent /// already exists and its manifest owns the profile id. profile: Option, /// Optional initial `.md` body for a freshly-created agent. context: Option, /// Desired visibility for the launched session. visibility: OrchestratorVisibility, }, /// 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, }, /// Ask a target agent a question/task and **wait for its reply**. /// /// Unlike [`Self::SpawnAgent`] (fire-and-forget lifecycle), this is the /// synchronous inter-agent rendezvous (ARCHITECTURE §17.4): the application /// layer drives the target's structured [`crate::ports::AgentSession`], waits /// for the turn's `Final`, and returns its content to the requester. The /// target is launched in structured mode first if it is not already live. AskAgent { /// Target agent display name (resolved case-insensitively). target: String, /// The task/message to send and await a reply for. task: String, }, /// List the project's agents (discovery) — exactly the data the UI reads from /// the manifest. Carries no argument: the dispatch resolves the agents against /// the project it is dispatched for. ListAgents, /// Create a reusable skill in the given scope — exactly as the UI would. CreateSkill { /// Display name (also the `.md` stem on disk). name: String, /// Markdown body of the skill. content: String, /// Scope the skill is created in (defaults to [`SkillScope::Project`]). scope: SkillScope, }, } /// Where IdeA should place a launched agent session. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OrchestratorVisibility { /// Launch or keep running without attaching to a layout cell. Background, /// Launch or re-attach visibly in one layout leaf. Visible { /// Target layout leaf. node_id: NodeId, }, } 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_name()?; match action { "spawn_agent" => Ok(OrchestratorCommand::SpawnAgent { name: self.require_name(action)?, profile: Some(self.require("profile", action, self.profile.as_deref())?), context: self.context.as_ref().filter(|c| !c.is_empty()).cloned(), visibility: self.parse_visibility(action)?, }), "agent.run" => Ok(OrchestratorCommand::SpawnAgent { name: self.require_target_agent(action)?, profile: self .profile .as_ref() .filter(|p| !p.trim().is_empty()) .cloned(), context: self .context .as_ref() .or(self.task.as_ref()) .filter(|c| !c.is_empty()) .cloned(), visibility: self.parse_visibility(action)?, }), "stop_agent" => Ok(OrchestratorCommand::StopAgent { name: self.require_name(action)?, }), "agent.stop" => Ok(OrchestratorCommand::StopAgent { name: self.require_target_agent(action)?, }), "update_agent_context" => Ok(OrchestratorCommand::UpdateAgentContext { name: self.require_name(action)?, context: self.require("context", action, self.context.as_deref())?, }), "agent.update_context" => Ok(OrchestratorCommand::UpdateAgentContext { name: self.require_target_agent(action)?, context: self.require("context", action, self.context.as_deref())?, }), "agent.message" => Ok(OrchestratorCommand::AskAgent { target: self.require_target_agent(action)?, task: self.require("task", action, self.task.as_deref())?, }), "list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents), "create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill { name: self.require_name(action)?, content: self.require("context", action, self.context.as_deref())?, scope: self.parse_scope(action)?, }), other => Err(OrchestratorError::UnknownAction(other.to_owned())), } } /// Parses the optional `scope` field into a [`SkillScope`], defaulting to /// [`SkillScope::Project`] when absent or empty. /// /// # Errors /// [`OrchestratorError::UnknownScope`] when the value is neither `global` nor /// `project` (case-insensitive). fn parse_scope(&self, action: &str) -> Result { match self.scope.as_deref().map(str::trim) { None | Some("") => Ok(SkillScope::Project), Some(s) if s.eq_ignore_ascii_case("project") => Ok(SkillScope::Project), Some(s) if s.eq_ignore_ascii_case("global") => Ok(SkillScope::Global), Some(other) => Err(OrchestratorError::UnknownScope { action: action.to_owned(), scope: other.to_owned(), }), } } /// Parses `visibility` for `spawn_agent`, defaulting to background. fn parse_visibility(&self, action: &str) -> Result { match self.visibility.as_deref().map(str::trim) { None | Some("") | Some("background") => Ok(OrchestratorVisibility::Background), Some("visible") => { let node_id = self.node_id.or(self.attach_to_cell).ok_or_else(|| { OrchestratorError::MissingField { action: action.to_owned(), field: "nodeId".to_owned(), } })?; Ok(OrchestratorVisibility::Visible { node_id }) } Some(other) => Err(OrchestratorError::UnknownVisibility { action: action.to_owned(), visibility: 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()) } fn require_target_agent(&self, action: &str) -> Result { self.require( "targetAgent", action, self.target_agent.as_deref().or(self.name.as_deref()), ) } fn action_name(&self) -> Result<&str, OrchestratorError> { self.request_type .as_deref() .or(self.action.as_deref()) .map(str::trim) .filter(|a| !a.is_empty()) .ok_or_else(|| OrchestratorError::MissingField { action: "".to_owned(), field: "type".to_owned(), }) } /// 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: Some("claude-code".to_owned()), context: Some("agents/dev-backend.md".to_owned()), visibility: OrchestratorVisibility::Background, } ); } #[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: Some("claude-code".to_owned()), context: None, visibility: OrchestratorVisibility::Background, } ); } #[test] fn spawn_agent_visible_requires_and_carries_node_id() { let node = uuid::Uuid::from_u128(42); let r = req(&format!( r#"{{ "action": "spawn_agent", "name": "a", "profile": "claude-code", "visibility": "visible", "nodeId": "{node}" }}"# )); assert_eq!( r.validate().unwrap(), OrchestratorCommand::SpawnAgent { name: "a".to_owned(), profile: Some("claude-code".to_owned()), context: None, visibility: OrchestratorVisibility::Visible { node_id: NodeId::from_uuid(node) }, } ); let missing = req( r#"{ "action": "spawn_agent", "name": "a", "profile": "claude-code", "visibility": "visible" }"#, ); assert_eq!( missing.validate(), Err(OrchestratorError::MissingField { action: "spawn_agent".to_owned(), field: "nodeId".to_owned(), }) ); } #[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 agent_run_accepts_type_target_agent_and_optional_profile() { let r = req( r#"{ "type": "agent.run", "requestedBy": "Main", "targetAgent": "Architect", "task": "Analyse", "visibility": "background" }"#, ); assert_eq!( r.validate().unwrap(), OrchestratorCommand::SpawnAgent { name: "Architect".to_owned(), profile: None, context: Some("Analyse".to_owned()), visibility: OrchestratorVisibility::Background, } ); } #[test] fn agent_run_visible_accepts_attach_to_cell() { let node = uuid::Uuid::from_u128(77); let r = req(&format!( r#"{{ "type": "agent.run", "targetAgent": "Architect", "profile": "claude-code", "visibility": "visible", "attachToCell": "{node}" }}"# )); assert_eq!( r.validate().unwrap(), OrchestratorCommand::SpawnAgent { name: "Architect".to_owned(), profile: Some("claude-code".to_owned()), context: None, visibility: OrchestratorVisibility::Visible { node_id: NodeId::from_uuid(node) }, } ); } #[test] fn agent_message_validates_target_and_task() { let r = req( r#"{ "type": "agent.message", "requestedBy": "Main", "targetAgent": "Architect", "task": "Analyse §17" }"#, ); assert_eq!( r.validate().unwrap(), OrchestratorCommand::AskAgent { target: "Architect".to_owned(), task: "Analyse §17".to_owned(), } ); } #[test] fn agent_message_missing_task_is_rejected() { let r = req(r#"{ "type": "agent.message", "targetAgent": "Architect" }"#); assert_eq!( r.validate(), Err(OrchestratorError::MissingField { action: "agent.message".to_owned(), field: "task".to_owned(), }) ); } #[test] fn agent_message_missing_target_is_rejected() { let r = req(r#"{ "type": "agent.message", "task": "do it" }"#); assert_eq!( r.validate(), Err(OrchestratorError::MissingField { action: "agent.message".to_owned(), field: "targetAgent".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 create_skill_defaults_to_project_scope() { let r = req(r##"{ "action": "create_skill", "name": "deploy", "context": "# Deploy steps" }"##); assert_eq!( r.validate().unwrap(), OrchestratorCommand::CreateSkill { name: "deploy".to_owned(), content: "# Deploy steps".to_owned(), scope: SkillScope::Project, } ); } #[test] fn create_skill_honours_explicit_scope_case_insensitively() { let r = req( r##"{ "action": "create_skill", "name": "deploy", "context": "body", "scope": "Global" }"##, ); assert_eq!( r.validate().unwrap(), OrchestratorCommand::CreateSkill { name: "deploy".to_owned(), content: "body".to_owned(), scope: SkillScope::Global, } ); } #[test] fn create_skill_missing_body_is_rejected() { let r = req(r#"{ "action": "create_skill", "name": "deploy" }"#); assert_eq!( r.validate(), Err(OrchestratorError::MissingField { action: "create_skill".to_owned(), field: "context".to_owned(), }) ); } #[test] fn create_skill_unknown_scope_is_rejected() { let r = req( r##"{ "action": "create_skill", "name": "deploy", "context": "body", "scope": "team" }"##, ); assert_eq!( r.validate(), Err(OrchestratorError::UnknownScope { action: "create_skill".to_owned(), scope: "team".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 { .. }) )); } }