feat(domain,app,infra): injection « État du projet » + outils MCP workstate (LS4)
Clôt le cold-start de coordination du programme live-state : - domain : WorkStatus::parse/label, commands ReadWorkState/SetWorkState (request status/intent/progress/lastDelegation), erreur UnknownWorkStatus + validate. - application : section bornée « # État du projet » injectée au lancement (port LiveStateLeanProvider, InjectedLiveRow, LIVE_STATE_INJECT_MAX, resolve_live_state) ; LiveStateReadProvider + read_workstate/set_workstate câblés au dispatch. - infrastructure : 2 ToolDef idea_workstate_read / idea_workstate_set (mapping, tool_returns_reply), compteur d'outils MCP 12→14. - app-tauri : providers câblés, garde du nombre d'outils 12→14. - tests (QA, verts) : domain/orchestrator, application lifecycle + orchestrator_service, infrastructure mcp_server, app-tauri state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::conversation::ConversationParty;
|
||||
use crate::ids::{AgentId, NodeId};
|
||||
use crate::live_state::WorkStatus;
|
||||
use crate::mailbox::TicketId;
|
||||
use crate::skill::SkillScope;
|
||||
|
||||
@ -56,6 +57,15 @@ pub enum OrchestratorError {
|
||||
/// The offending id value.
|
||||
value: String,
|
||||
},
|
||||
/// The `status` field of `workstate.set` is present but not a recognised
|
||||
/// [`WorkStatus`] label.
|
||||
#[error("unknown work status `{status}` for action `{action}`")]
|
||||
UnknownWorkStatus {
|
||||
/// The action being validated.
|
||||
action: String,
|
||||
/// The offending status value.
|
||||
status: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// The raw, wire-level orchestrator request as deserialised from a request file.
|
||||
@ -132,6 +142,23 @@ pub struct OrchestratorRequest {
|
||||
/// the aggregated index). Ignored by the other actions.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slug: Option<String>,
|
||||
/// Live-state status label for `workstate.set` (`idle|working|blocked|waiting|done`,
|
||||
/// case-insensitive). Required by that action, ignored otherwise.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
/// Short current-intent free-text for `workstate.set` (domain-bounded downstream).
|
||||
/// Optional, ignored by the other actions.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub intent: Option<String>,
|
||||
/// Optional finer-grained progress note for `workstate.set` (domain-bounded
|
||||
/// downstream). Ignored by the other actions. **Never** exposed on read.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub progress: Option<String>,
|
||||
/// Optional `lastDelegation` ticket id for `workstate.set` (the most recent
|
||||
/// delegation the agent issued). Blank ⇒ none; non-uuid ⇒ rejected. Ignored by
|
||||
/// the other actions.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_delegation: Option<String>,
|
||||
}
|
||||
|
||||
/// A validated orchestrator command — the only thing the application layer acts on.
|
||||
@ -269,6 +296,33 @@ pub enum OrchestratorCommand {
|
||||
/// The writing party (handshake identity).
|
||||
requester: ConversationParty,
|
||||
},
|
||||
/// Read the project's agent **live-state** (`idea_workstate_read`, programme
|
||||
/// live-state, lot LS4): the lean "who is doing what right now" snapshot, enriched
|
||||
/// with each agent's display name. Read-only; `progress` is never exposed.
|
||||
ReadWorkState {
|
||||
/// The party that issued the read (handshake identity). Carried for symmetry
|
||||
/// with the other read tools; the read is project-wide (no per-requester
|
||||
/// filtering).
|
||||
requester: ConversationParty,
|
||||
},
|
||||
/// Publish (insert or replace) the **current agent's** live-state row
|
||||
/// (`idea_workstate_set`, lot LS4). The key is the handshake `requestedBy`
|
||||
/// identity — an agent only ever writes its own row (no agent argument).
|
||||
SetWorkState {
|
||||
/// The agent whose row is written (the handshake identity, keyed
|
||||
/// last-writer-wins). Never a model-managed value.
|
||||
agent: AgentId,
|
||||
/// Coarse status the agent declares.
|
||||
status: WorkStatus,
|
||||
/// Short current-intent free-text (domain-bounded on write); `None` ⇒ cleared.
|
||||
intent: Option<String>,
|
||||
/// Optional finer-grained progress note (domain-bounded on write).
|
||||
progress: Option<String>,
|
||||
/// The ticket the agent is currently handling, if any.
|
||||
ticket: Option<TicketId>,
|
||||
/// The ticket of the most recent delegation it issued, if any.
|
||||
last_delegation: Option<TicketId>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Where IdeA should place a launched agent session.
|
||||
@ -365,6 +419,20 @@ impl OrchestratorRequest {
|
||||
content: self.require("content", action, self.content.as_deref())?,
|
||||
requester: self.requester_party(),
|
||||
}),
|
||||
"workstate.read" => Ok(OrchestratorCommand::ReadWorkState {
|
||||
requester: self.requester_party(),
|
||||
}),
|
||||
"workstate.set" => Ok(OrchestratorCommand::SetWorkState {
|
||||
// The key is the handshake identity (`requestedBy`), exactly like
|
||||
// `agent.reply`: an agent writes only its own row, never a peer's.
|
||||
agent: self.require_from(action)?,
|
||||
status: self.require_work_status(action)?,
|
||||
intent: self.intent.clone(),
|
||||
progress: self.progress.clone(),
|
||||
ticket: self.optional_ticket(action)?,
|
||||
last_delegation: self
|
||||
.parse_optional_ticket(action, self.last_delegation.as_deref())?,
|
||||
}),
|
||||
"list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents),
|
||||
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
|
||||
name: self.require_name(action)?,
|
||||
@ -454,17 +522,40 @@ impl OrchestratorRequest {
|
||||
/// Absent/blank ⇒ `None` (head-of-queue fallback); present-but-non-uuid ⇒
|
||||
/// [`OrchestratorError::InvalidAgentId`] (reused as a generic invalid-id error).
|
||||
fn optional_ticket(&self, action: &str) -> Result<Option<TicketId>, OrchestratorError> {
|
||||
match self.ticket.as_deref().map(str::trim) {
|
||||
self.parse_optional_ticket(action, self.ticket.as_deref())
|
||||
}
|
||||
|
||||
/// Parses an **optional** ticket-id field (`ticket`, `lastDelegation`) into a
|
||||
/// [`TicketId`]. Absent/blank ⇒ `None`; present-but-non-uuid ⇒
|
||||
/// [`OrchestratorError::InvalidAgentId`] (reused as a generic invalid-id error).
|
||||
#[allow(clippy::unused_self)]
|
||||
fn parse_optional_ticket(
|
||||
&self,
|
||||
action: &str,
|
||||
raw: Option<&str>,
|
||||
) -> Result<Option<TicketId>, OrchestratorError> {
|
||||
match raw.map(str::trim) {
|
||||
None | Some("") => Ok(None),
|
||||
Some(raw) => uuid::Uuid::parse_str(raw)
|
||||
Some(value) => uuid::Uuid::parse_str(value)
|
||||
.map(|u| Some(TicketId::from_uuid(u)))
|
||||
.map_err(|_| OrchestratorError::InvalidAgentId {
|
||||
action: action.to_owned(),
|
||||
value: raw.to_owned(),
|
||||
value: value.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the **required** `status` of `workstate.set` into a [`WorkStatus`].
|
||||
/// Missing/blank ⇒ [`OrchestratorError::MissingField`]; present-but-unknown ⇒
|
||||
/// [`OrchestratorError::UnknownWorkStatus`].
|
||||
fn require_work_status(&self, action: &str) -> Result<WorkStatus, OrchestratorError> {
|
||||
let raw = self.require("status", action, self.status.as_deref())?;
|
||||
WorkStatus::parse(&raw).ok_or_else(|| OrchestratorError::UnknownWorkStatus {
|
||||
action: action.to_owned(),
|
||||
status: raw,
|
||||
})
|
||||
}
|
||||
|
||||
/// The optional target agent name for the FileGuard context tools: `targetAgent`
|
||||
/// (or legacy `name`), trimmed; absent/blank ⇒ `None` (the global project context).
|
||||
fn optional_target_agent(&self) -> Option<String> {
|
||||
@ -939,6 +1030,115 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workstate_read_derives_requester_party() {
|
||||
let uid = uuid::Uuid::from_u128(5);
|
||||
let r = req(&format!(
|
||||
r#"{{ "type":"workstate.read", "requestedBy":"{uid}" }}"#
|
||||
));
|
||||
assert_eq!(
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::ReadWorkState {
|
||||
requester: ConversationParty::agent(AgentId::from_uuid(uid)),
|
||||
}
|
||||
);
|
||||
// No requester ⇒ user party (still valid: read is project-wide).
|
||||
assert_eq!(
|
||||
req(r#"{ "type":"workstate.read" }"#).validate().unwrap(),
|
||||
OrchestratorCommand::ReadWorkState {
|
||||
requester: ConversationParty::User,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workstate_set_keys_on_requester_and_parses_fields() {
|
||||
let from = uuid::Uuid::from_u128(3);
|
||||
let tkt = uuid::Uuid::from_u128(9);
|
||||
let last = uuid::Uuid::from_u128(11);
|
||||
let r = req(&format!(
|
||||
r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"Working",
|
||||
"intent":"ship LS4", "progress":"wiring", "ticket":"{tkt}",
|
||||
"lastDelegation":"{last}" }}"#
|
||||
));
|
||||
assert_eq!(
|
||||
r.validate().unwrap(),
|
||||
OrchestratorCommand::SetWorkState {
|
||||
agent: AgentId::from_uuid(from),
|
||||
status: WorkStatus::Working,
|
||||
intent: Some("ship LS4".to_owned()),
|
||||
progress: Some("wiring".to_owned()),
|
||||
ticket: Some(TicketId::from_uuid(tkt)),
|
||||
last_delegation: Some(TicketId::from_uuid(last)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workstate_set_requires_status_and_requester() {
|
||||
let from = uuid::Uuid::from_u128(3);
|
||||
// Missing status ⇒ MissingField.
|
||||
assert_eq!(
|
||||
req(&format!(
|
||||
r#"{{ "type":"workstate.set", "requestedBy":"{from}" }}"#
|
||||
))
|
||||
.validate(),
|
||||
Err(OrchestratorError::MissingField {
|
||||
action: "workstate.set".to_owned(),
|
||||
field: "status".to_owned(),
|
||||
})
|
||||
);
|
||||
// Unknown status ⇒ UnknownWorkStatus.
|
||||
assert_eq!(
|
||||
req(&format!(
|
||||
r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"busy" }}"#
|
||||
))
|
||||
.validate(),
|
||||
Err(OrchestratorError::UnknownWorkStatus {
|
||||
action: "workstate.set".to_owned(),
|
||||
status: "busy".to_owned(),
|
||||
})
|
||||
);
|
||||
// Missing requester ⇒ MissingField on `requestedBy`.
|
||||
assert_eq!(
|
||||
req(r#"{ "type":"workstate.set", "status":"idle" }"#).validate(),
|
||||
Err(OrchestratorError::MissingField {
|
||||
action: "workstate.set".to_owned(),
|
||||
field: "requestedBy".to_owned(),
|
||||
})
|
||||
);
|
||||
// Non-uuid lastDelegation ⇒ InvalidAgentId (generic invalid-id error).
|
||||
assert!(matches!(
|
||||
req(&format!(
|
||||
r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"idle", "lastDelegation":"nope" }}"#
|
||||
))
|
||||
.validate(),
|
||||
Err(OrchestratorError::InvalidAgentId { .. })
|
||||
));
|
||||
// Non-uuid `ticket` ⇒ InvalidAgentId too (same parse_optional_ticket path).
|
||||
assert!(matches!(
|
||||
req(&format!(
|
||||
r#"{{ "type":"workstate.set", "requestedBy":"{from}", "status":"idle", "ticket":"nope" }}"#
|
||||
))
|
||||
.validate(),
|
||||
Err(OrchestratorError::InvalidAgentId { .. })
|
||||
));
|
||||
// Non-uuid `requestedBy` ⇒ InvalidAgentId (the handshake key must be a real id).
|
||||
assert!(matches!(
|
||||
req(r#"{ "type":"workstate.set", "requestedBy":"not-a-uuid", "status":"idle" }"#)
|
||||
.validate(),
|
||||
Err(OrchestratorError::InvalidAgentId { .. })
|
||||
));
|
||||
// Blank `requestedBy` ⇒ MissingField (trimmed-empty is treated as absent).
|
||||
assert_eq!(
|
||||
req(r#"{ "type":"workstate.set", "requestedBy":" ", "status":"idle" }"#).validate(),
|
||||
Err(OrchestratorError::MissingField {
|
||||
action: "workstate.set".to_owned(),
|
||||
field: "requestedBy".to_owned(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_action_is_rejected() {
|
||||
let r = req(r#"{ "action": "delete_everything", "name": "a" }"#);
|
||||
|
||||
Reference in New Issue
Block a user