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:
2026-06-22 08:09:57 +02:00
parent 9d46e6cd21
commit 533a9c57f3
12 changed files with 1503 additions and 68 deletions

View File

@ -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" }"#);