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

@ -45,7 +45,7 @@ use crate::orchestrator::{
};
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
use crate::workstate::{UpdateLiveState, UpdateLiveStateInput};
use crate::workstate::{GetLiveStateLean, UpdateLiveState, UpdateLiveStateInput};
/// Default terminal geometry for an orchestrator-launched agent cell. The UI
/// resizes the PTY to the real cell size on attach; these are sane starting rows
@ -235,6 +235,23 @@ pub trait LiveStateProvider: Send + Sync {
fn live_state_for(&self, root: &ProjectPath) -> Option<Arc<UpdateLiveState>>;
}
/// Provider **par project root** d'un [`GetLiveStateLean`] (lot LS4), pour servir
/// l'outil de lecture `idea_workstate_read`.
///
/// Même tension/réponse que [`LiveStateProvider`] : l'[`OrchestratorService`] est
/// **unique** pour tous les projets, mais le live-state est **par project root** et le
/// port [`domain::ports::LiveStateStore`] fixe son root à la construction. `read_workstate`
/// connaît le `project.root` et demande au provider un `GetLiveStateLean` ciblant le
/// **bon** dossier (prune-on-read + snapshot lean).
///
/// `None` ⇒ l'outil renvoie « not configured » (call sites/tests legacy restent verts).
/// Implémenté dans app-tauri (seul détenteur des adapters `Fs*` et de l'horloge).
pub trait LiveStateReadProvider: Send + Sync {
/// Construit le [`GetLiveStateLean`] dont le store cible `root`. `None` ⇒ l'outil
/// `idea_workstate_read` n'est pas servi pour ce root.
fn live_state_lean_for(&self, root: &ProjectPath) -> Option<Arc<GetLiveStateLean>>;
}
/// Dispatches validated orchestrator commands to the agent/terminal use cases.
pub struct OrchestratorService {
create_agent: Arc<CreateAgentFromScratch>,
@ -337,6 +354,12 @@ pub struct OrchestratorService {
/// régression). **Best-effort strict** : un échec de persistance ne transforme
/// **jamais** un succès de délégation/reply en erreur.
live_state: Option<Arc<dyn LiveStateProvider>>,
/// Provider de lecture du **live-state** (lot LS4) pour l'outil
/// `idea_workstate_read`. Distinct de [`Self::live_state`] (écriture) : la lecture
/// applique le prune-on-read et renvoie le snapshot lean enrichi du nom d'agent.
/// Injecté via [`Self::with_live_state_read`] ; `None` ⇒ l'outil renvoie une erreur
/// typée « not configured » (call sites/tests legacy restent verts).
live_state_read: Option<Arc<dyn LiveStateReadProvider>>,
}
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
@ -403,6 +426,7 @@ impl OrchestratorService {
memory_harvest: None,
read_skill: None,
live_state: None,
live_state_read: None,
}
}
@ -529,6 +553,16 @@ impl OrchestratorService {
self
}
/// Branche le provider de **lecture** du live-state (lot LS4) pour l'outil
/// `idea_workstate_read`. Builder additif (signature de [`Self::new`] inchangée) ;
/// `None` (défaut) ⇒ l'outil renvoie « not configured ». Provider **par root** car
/// le live-state est par project root et le port fixe son root à la construction.
#[must_use]
pub fn with_live_state_read(mut self, provider: Arc<dyn LiveStateReadProvider>) -> Self {
self.live_state_read = Some(provider);
self
}
/// Distille l'objectif d'un ticket en un `intent` court pour le live-state :
/// whitespace normalisé puis tronqué à [`domain::live_state::FIELD_PREVIEW_MAX_CHARS`]
/// (même mécanique que la preview du workstate). Borner **ici** garantit qu'on ne
@ -729,6 +763,28 @@ impl OrchestratorService {
content,
requester,
} => self.write_memory(project, slug, content, requester).await,
OrchestratorCommand::ReadWorkState { requester } => {
self.read_workstate(project, requester).await
}
OrchestratorCommand::SetWorkState {
agent,
status,
intent,
progress,
ticket,
last_delegation,
} => {
self.set_workstate(
project,
agent,
status,
intent,
progress,
ticket,
last_delegation,
)
.await
}
}
}
@ -843,6 +899,105 @@ impl OrchestratorService {
})
}
/// `workstate.read` / `idea_workstate_read` → the project's agent **live-state**
/// (lot LS4): the lean snapshot (prune-on-read) enriched with each agent's display
/// name, returned inline as a JSON array. Entries whose agent is no longer in the
/// manifest are dropped; `progress` is **never** exposed (the lean DTO has none).
///
/// Shape: `[{"agent","status","intent","ticket?","lastDelegation?"}]`.
async fn read_workstate(
&self,
project: &Project,
_requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
let provider = self
.live_state_read
.as_deref()
.ok_or_else(|| AppError::Invalid("idea_workstate_read not configured".to_owned()))?;
let getter = provider
.live_state_lean_for(&project.root)
.ok_or_else(|| AppError::Invalid("idea_workstate_read not configured".to_owned()))?;
let lean = getter.execute().await?;
// Cross with the manifest to enrich each row with the agent's display name and
// to drop rows whose agent left the manifest (boundary preserved).
let listed = self
.list_agents
.execute(ListAgentsInput {
project: project.clone(),
})
.await?;
let names: HashMap<AgentId, String> =
listed.agents.into_iter().map(|a| (a.id, a.name)).collect();
let rows: Vec<serde_json::Value> = lean
.entries
.into_iter()
.filter_map(|entry| {
let name = names.get(&entry.agent_id)?;
let mut row = serde_json::json!({
"agent": name,
"status": entry.status,
"intent": entry.intent,
});
if let Some(ticket) = entry.ticket {
row["ticket"] = serde_json::json!(ticket);
}
if let Some(last) = entry.last_delegation {
row["lastDelegation"] = serde_json::json!(last);
}
Some(row)
})
.collect();
let reply = serde_json::to_string(&rows)
.map_err(|e| AppError::Invalid(format!("failed to serialise work-state: {e}")))?;
Ok(OrchestratorOutcome {
detail: format!("read work-state ({} agent rows)", rows.len()),
reply: Some(reply),
})
}
/// `workstate.set` / `idea_workstate_set` → publishes (insert/replace) the **current
/// agent's** live-state row (lot LS4). The key is the handshake identity (`agent`),
/// so an agent only ever writes its own row. Reuses the LS3 write provider
/// ([`Self::live_state`]); `updated_at_ms` is stamped by the use case's `Clock`, and
/// `intent`/`progress` are domain-bounded ([`UpdateLiveState`] via `LiveEntry::new`).
/// **ACK only** (no inline reply).
#[allow(clippy::too_many_arguments)]
async fn set_workstate(
&self,
project: &Project,
agent: AgentId,
status: domain::live_state::WorkStatus,
intent: Option<String>,
progress: Option<String>,
ticket: Option<TicketId>,
last_delegation: Option<TicketId>,
) -> Result<OrchestratorOutcome, AppError> {
let provider = self
.live_state
.as_deref()
.ok_or_else(|| AppError::Invalid("idea_workstate_set not configured".to_owned()))?;
let update = provider
.live_state_for(&project.root)
.ok_or_else(|| AppError::Invalid("idea_workstate_set not configured".to_owned()))?;
update
.execute(UpdateLiveStateInput {
agent_id: agent,
ticket,
intent: intent.unwrap_or_default(),
status,
progress,
last_delegation,
})
.await?;
Ok(OrchestratorOutcome {
detail: format!("set work-state for agent {agent}"),
reply: None,
})
}
/// `memory.write` → writes a note under an exclusive write-lease.
async fn write_memory(
&self,