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

@ -28,6 +28,7 @@ pub mod skill;
pub mod template;
pub mod terminal;
pub mod window;
pub mod workstate;
pub use agent::{
drain_with_readiness, drain_with_readiness_outcome, reference_profile_id, reference_profiles,
@ -111,8 +112,13 @@ pub use template::{
UpdateTemplateOutput,
};
pub use terminal::{
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveSessions,
OpenTerminal, OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput,
StructuredSessions, TerminalSessions, WriteToTerminal, WriteToTerminalInput,
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveSessionKind,
LiveSessionSnapshot, LiveSessions, OpenTerminal, OpenTerminalInput, OpenTerminalOutput,
ResizeTerminal, ResizeTerminalInput, StructuredSessions, TerminalSessions, WriteToTerminal,
WriteToTerminalInput,
};
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
pub use workstate::{
AgentWorkState, GetProjectWorkState, GetProjectWorkStateInput, LiveWorkSession,
ProjectWorkState,
};

View File

@ -28,7 +28,10 @@
mod registry;
mod usecases;
pub use registry::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions};
pub use registry::{
LiveAgentRegistry, LiveSessionKind, LiveSessionSnapshot, LiveSessions, StructuredSessions,
TerminalSessions,
};
pub use usecases::{
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, WriteToTerminal, WriteToTerminalInput,

View File

@ -13,6 +13,28 @@ use domain::conversation::ConversationId;
use domain::ports::{AgentSession, PtyHandle};
use domain::{AgentId, NodeId, SessionId, SessionKind, TerminalSession};
/// Runtime family of a live agent session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LiveSessionKind {
/// Raw PTY-backed CLI session.
Pty,
/// Structured agent-session backend.
Structured,
}
/// Read-only coordinates of one live agent session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LiveSessionSnapshot {
/// The agent owning the live session.
pub agent_id: AgentId,
/// The layout node currently hosting the session view.
pub node_id: NodeId,
/// The live session id.
pub session_id: SessionId,
/// Which runtime registry owns the session.
pub kind: LiveSessionKind,
}
/// A registered, live terminal: its PTY handle plus the domain snapshot.
#[derive(Debug, Clone)]
struct Entry {
@ -516,6 +538,31 @@ impl LiveSessions {
all.extend(self.structured.live_agents());
all
}
/// Tous les agents vivants avec le type de registre source (PTY puis structuré).
#[must_use]
pub fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
let mut all: Vec<LiveSessionSnapshot> = self
.pty
.live_agents()
.into_iter()
.map(|(agent_id, node_id, session_id)| LiveSessionSnapshot {
agent_id,
node_id,
session_id,
kind: LiveSessionKind::Pty,
})
.collect();
all.extend(self.structured.live_agents().into_iter().map(
|(agent_id, node_id, session_id)| LiveSessionSnapshot {
agent_id,
node_id,
session_id,
kind: LiveSessionKind::Structured,
},
));
all
}
}
impl LiveAgentRegistry for LiveSessions {

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
}

View File

@ -0,0 +1,307 @@
//! Tests for the project work-state read model.
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use application::{
GetProjectWorkState, GetProjectWorkStateInput, LiveSessionKind, LiveSessions,
StructuredSessions, TerminalSessions,
};
use domain::mailbox::{MailboxError, PendingReply, Ticket};
use domain::ports::{
AgentContextStore, AgentSession, AgentSessionError, PtyHandle, ReplyStream, StoreError,
};
use domain::{
Agent, AgentBusyState, AgentId, AgentManifest, AgentOrigin, InputMediator, ManifestEntry,
MarkdownDoc, NodeId, ProfileId, Project, ProjectId, ProjectPath, PtySize, RemoteRef, SessionId,
SessionKind, TerminalSession,
};
use uuid::Uuid;
fn aid(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n))
}
fn pid(n: u128) -> ProfileId {
ProfileId::from_uuid(Uuid::from_u128(n))
}
fn sid(n: u128) -> SessionId {
SessionId::from_uuid(Uuid::from_u128(n))
}
fn nid(n: u128) -> NodeId {
NodeId::from_uuid(Uuid::from_u128(n))
}
fn ticket_id(n: u128) -> domain::TicketId {
domain::TicketId::from_uuid(Uuid::from_u128(n))
}
fn project() -> Project {
Project::new(
ProjectId::from_uuid(Uuid::from_u128(1)),
"demo",
ProjectPath::new("/tmp/idea-workstate-test").unwrap(),
RemoteRef::local(),
1_700_000_000_000,
)
.unwrap()
}
fn agent(n: u128, name: &str) -> Agent {
Agent::new(
aid(n),
name,
format!("agents/{name}.md"),
pid(100 + n),
AgentOrigin::Scratch,
false,
)
.unwrap()
}
fn manifest(agents: &[Agent]) -> AgentManifest {
AgentManifest::new(1, agents.iter().map(ManifestEntry::from_agent).collect()).unwrap()
}
#[derive(Clone)]
struct FakeContexts {
manifest: AgentManifest,
}
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_project: &Project,
_agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
Err(StoreError::NotFound)
}
async fn write_context(
&self,
_project: &Project,
_agent: &AgentId,
_md: &MarkdownDoc,
) -> Result<(), StoreError> {
Ok(())
}
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
Ok(self.manifest.clone())
}
async fn save_manifest(
&self,
_project: &Project,
_manifest: &AgentManifest,
) -> Result<(), StoreError> {
Ok(())
}
}
#[derive(Default)]
struct FakeInput {
busy: Mutex<HashMap<AgentId, AgentBusyState>>,
}
impl FakeInput {
fn set_busy(&self, agent: AgentId, busy: AgentBusyState) {
self.busy.lock().unwrap().insert(agent, busy);
}
}
impl InputMediator for FakeInput {
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply {
let fut: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>> =
Box::pin(async { Err(MailboxError::Cancelled) });
PendingReply::new(fut)
}
fn preempt(&self, _agent: AgentId) {}
fn mark_idle(&self, _agent: AgentId) {}
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
self.busy
.lock()
.unwrap()
.get(&agent)
.copied()
.unwrap_or(AgentBusyState::Idle)
}
}
struct FakeSession {
id: SessionId,
}
#[async_trait]
impl AgentSession for FakeSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
Ok(Box::new(std::iter::empty()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
fn fake_session(id: SessionId) -> Arc<dyn AgentSession> {
Arc::new(FakeSession { id })
}
fn insert_pty(
sessions: &TerminalSessions,
session_id: SessionId,
agent_id: AgentId,
node_id: NodeId,
) {
sessions.insert(
PtyHandle { session_id },
TerminalSession::starting(
session_id,
node_id,
ProjectPath::new("/tmp/idea-workstate-test").unwrap(),
SessionKind::Agent { agent_id },
PtySize { rows: 24, cols: 80 },
),
);
}
struct Fixture {
usecase: GetProjectWorkState,
pty: Arc<TerminalSessions>,
structured: Arc<StructuredSessions>,
input: Arc<FakeInput>,
project: Project,
}
fn fixture(agents: &[Agent]) -> Fixture {
let pty = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let live = Arc::new(LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured)));
let input = Arc::new(FakeInput::default());
let input_port = Arc::clone(&input) as Arc<dyn InputMediator>;
let usecase = GetProjectWorkState::new(
Arc::new(FakeContexts {
manifest: manifest(agents),
}),
live,
input_port,
);
Fixture {
usecase,
pty,
structured,
input,
project: project(),
}
}
#[tokio::test]
async fn workstate_lists_manifest_agents_idle_without_live_sessions() {
let a = agent(10, "alpha");
let b = agent(20, "beta");
let f = fixture(&[a.clone(), b.clone()]);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert_eq!(out.agents.len(), 2);
assert_eq!(out.agents[0].agent_id, a.id);
assert_eq!(out.agents[0].name, "alpha");
assert_eq!(out.agents[0].profile_id, a.profile_id);
assert_eq!(out.agents[0].live, None);
assert_eq!(out.agents[0].busy, AgentBusyState::Idle);
assert_eq!(out.agents[1].agent_id, b.id);
}
#[tokio::test]
async fn workstate_attaches_live_pty_session_to_manifest_agent() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
insert_pty(&f.pty, sid(1), a.id, nid(100));
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let live = out.agents[0].live.unwrap();
assert_eq!(live.session_id, sid(1));
assert_eq!(live.node_id, nid(100));
assert_eq!(live.kind, LiveSessionKind::Pty);
}
#[tokio::test]
async fn workstate_attaches_live_structured_session_to_manifest_agent() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
f.structured.insert(fake_session(sid(2)), a.id, nid(200));
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
let live = out.agents[0].live.unwrap();
assert_eq!(live.session_id, sid(2));
assert_eq!(live.node_id, nid(200));
assert_eq!(live.kind, LiveSessionKind::Structured);
}
#[tokio::test]
async fn workstate_includes_busy_state_from_input_mediator() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
let busy = AgentBusyState::Busy {
ticket: ticket_id(7),
since_ms: 1_234,
};
f.input.set_busy(a.id, busy);
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert_eq!(out.agents[0].busy, busy);
}
#[tokio::test]
async fn workstate_ignores_live_agents_absent_from_manifest() {
let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a));
insert_pty(&f.pty, sid(99), aid(999), nid(999));
let out = f
.usecase
.execute(GetProjectWorkStateInput { project: f.project })
.await
.unwrap();
assert_eq!(out.agents.len(), 1);
assert_eq!(out.agents[0].agent_id, a.id);
assert_eq!(out.agents[0].live, None);
}