Merge branch 'worktree-agent-a2650e91d2bd39ca2' into develop
This commit is contained in:
@ -78,6 +78,18 @@ pub enum DomainEvent {
|
||||
/// The project.
|
||||
project_id: ProjectId,
|
||||
},
|
||||
/// An orchestrator request (dropped under `.ideai/requests/`) was processed
|
||||
/// by IdeA on behalf of a requester agent (ARCHITECTURE §14.3). Relayed so the
|
||||
/// frontend can surface orchestration activity; the resulting cell/tab opens
|
||||
/// off the [`AgentLaunched`](Self::AgentLaunched) event for `spawn_agent`.
|
||||
OrchestratorRequestProcessed {
|
||||
/// Id of the requesting (orchestrator) agent — the request subdirectory.
|
||||
requester_id: String,
|
||||
/// The action that was processed (`spawn_agent`, `stop_agent`, …).
|
||||
action: String,
|
||||
/// Whether IdeA handled it successfully.
|
||||
ok: bool,
|
||||
},
|
||||
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
|
||||
PtyOutput {
|
||||
/// The session.
|
||||
|
||||
@ -37,6 +37,7 @@ pub mod git;
|
||||
pub mod ids;
|
||||
pub mod layout;
|
||||
pub mod markdown;
|
||||
pub mod orchestrator;
|
||||
pub mod ports;
|
||||
pub mod profile;
|
||||
pub mod project;
|
||||
@ -83,6 +84,8 @@ pub use layout::{
|
||||
|
||||
pub use events::DomainEvent;
|
||||
|
||||
pub use orchestrator::{OrchestratorCommand, OrchestratorError, OrchestratorRequest};
|
||||
|
||||
pub use ports::{
|
||||
AgentContextStore, AgentRuntime, Clock, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||
ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
|
||||
|
||||
255
crates/domain/src/orchestrator.rs
Normal file
255
crates/domain/src/orchestrator.rs
Normal file
@ -0,0 +1,255 @@
|
||||
//! 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 `<project_root>/.ideai/requests/<requester-id>/*.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<String>,
|
||||
/// Runtime profile slug/name (required by `spawn_agent`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
},
|
||||
/// 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<OrchestratorCommand, OrchestratorError> {
|
||||
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<String, OrchestratorError> {
|
||||
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<String, OrchestratorError> {
|
||||
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 { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user