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:
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user