feat(workstate): read-model live-state minimal des conversations/délégations (Lot A backend)

Introduit le module application `workstate` (modèle de read-model live + snapshots
des conversations/délégations en cours) et l'expose via la couche terminal
(exports mod/registry). Câble la surface Tauri : DTO, commande et state pour
exposer le live-state au frontend (lib + state + commands), avec tests.

QA verte. Réserve environnementale non bloquante : tests loopback socket Unix
réels non exécutables en sandbox (UnixListener::bind PermissionDenied),
alternatives avec skips vertes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:06:22 +02:00
parent 6cfa0b04c6
commit aae18499a9
10 changed files with 663 additions and 28 deletions

View File

@ -0,0 +1,124 @@
//! Read-only project work-state read model.
//!
//! This module composes existing runtime state only: the project agent manifest,
//! live session registries and the input mediator busy state. It performs no I/O
//! beyond loading the manifest through the existing context store and creates no
//! durable projection.
use std::collections::HashMap;
use std::sync::Arc;
use domain::input::AgentBusyState;
use domain::ports::AgentContextStore;
use domain::{AgentId, InputMediator, NodeId, ProfileId, Project, SessionId};
use crate::error::AppError;
use crate::terminal::{LiveSessionKind, LiveSessionSnapshot, LiveSessions};
/// Input for [`GetProjectWorkState::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GetProjectWorkStateInput {
/// Project whose manifest provides the agent order and boundary.
pub project: Project,
}
/// Read model for a project's current agent work state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectWorkState {
/// Agents in manifest order.
pub agents: Vec<AgentWorkState>,
}
/// Read model for one manifest agent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentWorkState {
/// Agent id.
pub agent_id: AgentId,
/// Agent display name.
pub name: String,
/// Runtime profile id currently assigned to the agent.
pub profile_id: ProfileId,
/// Current live session, if the agent is live.
pub live: Option<LiveWorkSession>,
/// Current FIFO/busy state.
pub busy: AgentBusyState,
}
/// Live session coordinates exposed by the work-state read model.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LiveWorkSession {
/// The layout node currently hosting the session view.
pub node_id: NodeId,
/// The live session id.
pub session_id: SessionId,
/// Runtime family that owns the session.
pub kind: LiveSessionKind,
}
/// Read-only use case aggregating manifest agents with live and busy state.
pub struct GetProjectWorkState {
contexts: Arc<dyn AgentContextStore>,
live: Arc<LiveSessions>,
input: Arc<dyn InputMediator>,
}
impl GetProjectWorkState {
/// Builds the read-model use case from existing stores/registries.
#[must_use]
pub fn new(
contexts: Arc<dyn AgentContextStore>,
live: Arc<LiveSessions>,
input: Arc<dyn InputMediator>,
) -> Self {
Self {
contexts,
live,
input,
}
}
/// Executes the read-only aggregation.
///
/// # Errors
/// - [`AppError::Store`] when the manifest cannot be loaded,
/// - [`AppError::Invalid`] if a manifest entry violates agent invariants.
pub async fn execute(
&self,
input: GetProjectWorkStateInput,
) -> Result<ProjectWorkState, AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?;
let live_by_agent = live_by_agent(self.live.live_agent_snapshots());
let agents = manifest
.entries
.iter()
.map(|entry| {
let agent = entry
.to_agent()
.map_err(|err| AppError::Invalid(err.to_string()))?;
let live = live_by_agent
.get(&agent.id)
.map(|snapshot| LiveWorkSession {
node_id: snapshot.node_id,
session_id: snapshot.session_id,
kind: snapshot.kind,
});
Ok(AgentWorkState {
agent_id: agent.id,
name: agent.name,
profile_id: agent.profile_id,
live,
busy: self.input.busy_state(agent.id),
})
})
.collect::<Result<Vec<_>, AppError>>()?;
Ok(ProjectWorkState { agents })
}
}
fn live_by_agent(snapshots: Vec<LiveSessionSnapshot>) -> HashMap<AgentId, LiveSessionSnapshot> {
let mut out = HashMap::new();
for snapshot in snapshots {
out.entry(snapshot.agent_id).or_insert(snapshot);
}
out
}