Au redémarrage, les agents qui étaient `working` lors de la fermeture restaient figés en statut fantôme `working` alors qu'aucun tour n'est plus en cours (bug reproductible en live : QA et DevBackend eux-mêmes sont restés `working` fantôme après leurs tâches). On réconcilie l'état persistant à l'ouverture du projet : les statuts orphelins sont ramenés à la cible `idle`. - domain/live_state.rs : const STALE_AT_RESTART_MARKER + `reconcile_orphans` (+ tests). - application/workstate/reconcile.rs (nouveau) : use case ReconcileLiveState (pas de nouveau port), best-effort, no-op si store vide (+ tests, dont empty_store_is_a_noop_at_boot). - application/workstate/mod.rs + lib.rs : module + re-export. - app-tauri/state.rs : provider par-root + champ AppState + wiring. - app-tauri/commands.rs : hook best-effort dans open_project. QA VERT par commande réelle : cargo test --workspace 1624 passed / 0 failed, cargo clippy --workspace --all-targets 0 erreur sur le code ajouté. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
529 lines
20 KiB
Rust
529 lines
20 KiB
Rust
//! 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.
|
|
|
|
mod actions;
|
|
mod live;
|
|
mod reconcile;
|
|
|
|
pub use actions::{
|
|
AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, StopLiveAgent,
|
|
StopLiveAgentInput, StopLiveAgentOutput,
|
|
};
|
|
pub use reconcile::{ReconcileLiveState, ReconcileLiveStateInput};
|
|
pub use live::{
|
|
GetLiveStateLean, LeanLiveEntry, LeanLiveState, UpdateLiveState, UpdateLiveStateInput,
|
|
LIVE_STATE_MAX_ENTRIES, LIVE_STATE_TTL_MS,
|
|
};
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::Arc;
|
|
|
|
use domain::input::{AgentBusyState, InputSource};
|
|
use domain::ports::AgentContextStore;
|
|
use domain::{
|
|
AgentId, AgentQueueSnapshot, ConversationId, ConversationLog, ConversationTurn, Handoff,
|
|
HandoffStore, InputMediator, NodeId, ProfileId, Project, ProjectPath, QueuedTicketSnapshot,
|
|
SessionId, TicketId, TurnId, TurnRole,
|
|
};
|
|
|
|
use crate::error::AppError;
|
|
use crate::terminal::{LiveSessionKind, LiveSessionSnapshot, LiveSessions};
|
|
use crate::HandoffProvider;
|
|
|
|
/// Maximum length (in characters) of a derived [`AgentTicketState::task_preview`].
|
|
///
|
|
/// The full task can be a very long prompt; the panel only needs a scannable
|
|
/// excerpt, with [`AgentTicketState::task_len`] signalling there is more.
|
|
const TASK_PREVIEW_MAX_CHARS: usize = 160;
|
|
|
|
/// Maximum length (in characters) of a conversation [`ConversationWorkSummary::summary_preview`].
|
|
const HANDOFF_PREVIEW_MAX_CHARS: usize = 480;
|
|
|
|
/// Maximum length (in characters) of a conversation [`ConversationWorkSummary::objective_preview`].
|
|
const OBJECTIVE_PREVIEW_MAX_CHARS: usize = 160;
|
|
|
|
/// Maximum number of recent turns surfaced in a [`ConversationWorkSummary::recent_turns`] fallback.
|
|
const RECENT_TURNS_MAX: usize = 3;
|
|
|
|
/// Maximum length (in characters) of a [`ConversationTurnWorkPreview::text_preview`].
|
|
const TURN_PREVIEW_MAX_CHARS: usize = 220;
|
|
|
|
/// 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>,
|
|
/// Best-effort summaries of the conversations visible through the agents'
|
|
/// tickets, joined frontend-side via `tickets[].conversation_id`. Built from
|
|
/// the [`HandoffStore`] (primary) with a bounded [`ConversationLog`] fallback;
|
|
/// a preview failure never blocks `agents`.
|
|
pub conversations: Vec<ConversationWorkSummary>,
|
|
}
|
|
|
|
/// Best-effort, read-only summary of one conversation visible through the tickets.
|
|
///
|
|
/// Derived live from the [`HandoffStore`] (primary source) with a bounded
|
|
/// [`ConversationLog`] fallback — never a durable projection, never a repair of the
|
|
/// underlying log/handoff. Joined frontend-side to tickets via
|
|
/// [`ConversationWorkSummary::conversation_id`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ConversationWorkSummary {
|
|
/// The conversation (pair) this summary describes.
|
|
pub conversation_id: ConversationId,
|
|
/// How much could be derived (see [`ConversationPreviewStatus`]).
|
|
pub status: ConversationPreviewStatus,
|
|
/// Bounded excerpt of the handoff objective, when present.
|
|
pub objective_preview: Option<String>,
|
|
/// Bounded excerpt of the handoff summary, when a handoff is readable.
|
|
pub summary_preview: Option<String>,
|
|
/// Character length of the **original** (un-truncated) handoff summary (`0` when none).
|
|
pub summary_len: usize,
|
|
/// Cursor (last [`TurnId`]) covered by the handoff summary, when present.
|
|
pub up_to: Option<TurnId>,
|
|
/// Bounded, recent turns from the log fallback (empty when a handoff is `Ready`).
|
|
pub recent_turns: Vec<ConversationTurnWorkPreview>,
|
|
}
|
|
|
|
/// How much of a [`ConversationWorkSummary`] could be derived, best-effort.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ConversationPreviewStatus {
|
|
/// A handoff was present and usable: summary/objective/cursor are filled.
|
|
Ready,
|
|
/// No handoff yet; `recent_turns` carries the log fallback if any.
|
|
Missing,
|
|
/// The handoff was unreadable but the log fallback was readable.
|
|
Partial,
|
|
/// Neither the handoff nor the log could be read.
|
|
Unavailable,
|
|
}
|
|
|
|
/// One recent turn, projected for the conversation-summary fallback.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ConversationTurnWorkPreview {
|
|
/// Nature of the turn (prompt, response, tool activity).
|
|
pub role: TurnRole,
|
|
/// Origin of the turn (human operator or a delegating agent).
|
|
pub source: TicketWorkSource,
|
|
/// Timestamp (epoch milliseconds) of the turn.
|
|
pub at_ms: u64,
|
|
/// Bounded, whitespace-normalised excerpt of the turn text.
|
|
pub text_preview: String,
|
|
/// Character length of the **original** (un-truncated) turn text.
|
|
pub text_len: usize,
|
|
}
|
|
|
|
/// Builds the [`ConversationLog`] bound to a given project `root`, best-effort.
|
|
///
|
|
/// Twin of [`HandoffProvider`]: the work-state read model is a singleton (the project
|
|
/// root arrives per call via [`GetProjectWorkStateInput`]) while the `Fs*` adapters fix
|
|
/// their root at construction. This port materialises the log targeting the **right**
|
|
/// folder on each call; `None` ⇒ no log fallback (zero regression for call
|
|
/// sites/tests that do not wire it). Implemented in `app-tauri` (sole owner of `Fs*`).
|
|
pub trait ConversationLogProvider: Send + Sync {
|
|
/// Builds the [`ConversationLog`] whose persistence targets `root`, or `None`.
|
|
fn conversation_log_for(&self, root: &ProjectPath) -> Option<Arc<dyn ConversationLog>>;
|
|
}
|
|
|
|
/// 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,
|
|
/// Pending/in-progress delegation tickets, in FIFO order.
|
|
pub tickets: Vec<AgentTicketState>,
|
|
}
|
|
|
|
/// One queued delegation ticket, projected for the work-state read model.
|
|
///
|
|
/// A read-only, presentation-oriented view of a [`QueuedTicketSnapshot`]: the
|
|
/// status is **derived** by crossing the FIFO snapshot with the agent's busy state
|
|
/// (never stored), and `task_preview` is a bounded excerpt of the full task (the
|
|
/// full text is never sent to the panel; `task_len` signals there is more).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct AgentTicketState {
|
|
/// Stable id of the queued ticket.
|
|
pub ticket_id: TicketId,
|
|
/// Conversation thread this task enters.
|
|
pub conversation_id: ConversationId,
|
|
/// FIFO position at snapshot time (`0` = head).
|
|
pub position: u32,
|
|
/// Derived status (in-progress = the agent's current busy ticket).
|
|
pub status: TicketWorkStatus,
|
|
/// Origin of the ticket (human operator or a delegating agent).
|
|
pub source: TicketWorkSource,
|
|
/// Display label of the requester.
|
|
pub requester_label: String,
|
|
/// Bounded, whitespace-normalised excerpt of the task.
|
|
pub task_preview: String,
|
|
/// Character length of the **original** (un-truncated) task.
|
|
pub task_len: usize,
|
|
}
|
|
|
|
/// Derived processing status of a queued ticket.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum TicketWorkStatus {
|
|
/// The agent's current busy turn is running this ticket.
|
|
InProgress,
|
|
/// Waiting behind the head / the agent is idle.
|
|
Queued,
|
|
}
|
|
|
|
/// Origin of a queued ticket, mirroring [`InputSource`] for the read model.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum TicketWorkSource {
|
|
/// The human operator.
|
|
Human,
|
|
/// Another agent delegating via `idea_ask_agent`.
|
|
Agent {
|
|
/// The delegating agent.
|
|
agent_id: AgentId,
|
|
},
|
|
}
|
|
|
|
impl From<InputSource> for TicketWorkSource {
|
|
fn from(source: InputSource) -> Self {
|
|
match source {
|
|
InputSource::Human => Self::Human,
|
|
InputSource::Agent { agent_id } => Self::Agent { agent_id },
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
queue: Arc<dyn AgentQueueSnapshot>,
|
|
/// Per-root handoff source (primary), wired best-effort; `None` ⇒ no summaries.
|
|
handoffs: Option<Arc<dyn HandoffProvider>>,
|
|
/// Per-root log source (fallback), wired best-effort; `None` ⇒ no fallback turns.
|
|
logs: Option<Arc<dyn ConversationLogProvider>>,
|
|
}
|
|
|
|
impl GetProjectWorkState {
|
|
/// Builds the read-model use case from existing stores/registries.
|
|
///
|
|
/// Conversation summaries are off by default; wire them with
|
|
/// [`Self::with_conversation_sources`].
|
|
#[must_use]
|
|
pub fn new(
|
|
contexts: Arc<dyn AgentContextStore>,
|
|
live: Arc<LiveSessions>,
|
|
input: Arc<dyn InputMediator>,
|
|
queue: Arc<dyn AgentQueueSnapshot>,
|
|
) -> Self {
|
|
Self {
|
|
contexts,
|
|
live,
|
|
input,
|
|
queue,
|
|
handoffs: None,
|
|
logs: None,
|
|
}
|
|
}
|
|
|
|
/// Wires the best-effort conversation-summary sources (Lot C): the handoff
|
|
/// store (primary) and the conversation log (bounded fallback), both resolved
|
|
/// per project root. Builder so existing call sites/tests stay unchanged.
|
|
#[must_use]
|
|
pub fn with_conversation_sources(
|
|
mut self,
|
|
handoffs: Arc<dyn HandoffProvider>,
|
|
logs: Arc<dyn ConversationLogProvider>,
|
|
) -> Self {
|
|
self.handoffs = Some(handoffs);
|
|
self.logs = Some(logs);
|
|
self
|
|
}
|
|
|
|
/// 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,
|
|
});
|
|
// Tickets are crossed with the busy state: only an agent absent from
|
|
// the manifest is dropped (this loop only visits manifest entries), so
|
|
// the manifest boundary is naturally preserved.
|
|
let busy_ticket = self.input.busy_state(agent.id).ticket();
|
|
let tickets = self
|
|
.queue
|
|
.queue_for(agent.id)
|
|
.into_iter()
|
|
.map(|snapshot| ticket_state(snapshot, busy_ticket))
|
|
.collect();
|
|
Ok(AgentWorkState {
|
|
agent_id: agent.id,
|
|
name: agent.name,
|
|
profile_id: agent.profile_id,
|
|
live,
|
|
busy: self.input.busy_state(agent.id),
|
|
tickets,
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>, AppError>>()?;
|
|
|
|
// Lot C — best-effort conversation summaries. Only the conversations
|
|
// visible through the projected tickets are summarised, deduplicated in
|
|
// first-seen order. A preview failure degrades the per-conversation status
|
|
// (never an `AppError`): `agents`/live/busy/tickets stay intact above.
|
|
let conversations = self
|
|
.summarise_conversations(&input.project.root, &agents)
|
|
.await;
|
|
|
|
Ok(ProjectWorkState {
|
|
agents,
|
|
conversations,
|
|
})
|
|
}
|
|
|
|
/// Derives the best-effort summaries of the conversations referenced by the
|
|
/// projected tickets. Resolves the per-root handoff/log stores once, then folds
|
|
/// each distinct conversation id through [`conversation_summary`].
|
|
async fn summarise_conversations(
|
|
&self,
|
|
root: &ProjectPath,
|
|
agents: &[AgentWorkState],
|
|
) -> Vec<ConversationWorkSummary> {
|
|
let conversation_ids = distinct_conversation_ids(agents);
|
|
if conversation_ids.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
let handoff_store = self
|
|
.handoffs
|
|
.as_ref()
|
|
.and_then(|provider| provider.handoff_store_for(root));
|
|
let log_store = self
|
|
.logs
|
|
.as_ref()
|
|
.and_then(|provider| provider.conversation_log_for(root));
|
|
|
|
let mut summaries = Vec::with_capacity(conversation_ids.len());
|
|
for conversation in conversation_ids {
|
|
summaries.push(conversation_summary(conversation, &handoff_store, &log_store).await);
|
|
}
|
|
summaries
|
|
}
|
|
}
|
|
|
|
/// Collects the conversation ids referenced by the agents' tickets, deduplicated in
|
|
/// first-seen (FIFO) order so the summary list mirrors the panel's ordering.
|
|
fn distinct_conversation_ids(agents: &[AgentWorkState]) -> Vec<ConversationId> {
|
|
let mut seen = HashSet::new();
|
|
let mut ids = Vec::new();
|
|
for agent in agents {
|
|
for ticket in &agent.tickets {
|
|
if seen.insert(ticket.conversation_id) {
|
|
ids.push(ticket.conversation_id);
|
|
}
|
|
}
|
|
}
|
|
ids
|
|
}
|
|
|
|
/// Derives one conversation summary, best-effort, following the Lot C algorithm:
|
|
/// handoff present ⇒ `Ready`; absent ⇒ `Missing` (+ log fallback); unreadable but
|
|
/// log readable ⇒ `Partial`; both KO ⇒ `Unavailable`. Never returns an error.
|
|
async fn conversation_summary(
|
|
conversation: ConversationId,
|
|
handoff_store: &Option<Arc<dyn HandoffStore>>,
|
|
log_store: &Option<Arc<dyn ConversationLog>>,
|
|
) -> ConversationWorkSummary {
|
|
// A handoff store absent from the wiring is treated as "no handoff yet"
|
|
// (`Missing` path), never as an error: the read model stays best-effort.
|
|
let handoff = match handoff_store {
|
|
Some(store) => Some(store.load(conversation).await),
|
|
None => None,
|
|
};
|
|
match handoff {
|
|
Some(Ok(Some(handoff))) => ready_summary(conversation, &handoff),
|
|
Some(Ok(None)) | None => {
|
|
// Handoff legitimately absent: status stays Missing even if the log
|
|
// fallback is itself unreadable (then simply no recent turns).
|
|
let recent_turns = recent_turns(log_store, conversation)
|
|
.await
|
|
.unwrap_or_default();
|
|
ConversationWorkSummary {
|
|
conversation_id: conversation,
|
|
status: ConversationPreviewStatus::Missing,
|
|
objective_preview: None,
|
|
summary_preview: None,
|
|
summary_len: 0,
|
|
up_to: None,
|
|
recent_turns,
|
|
}
|
|
}
|
|
Some(Err(_)) => match recent_turns(log_store, conversation).await {
|
|
// Handoff unreadable but the log answered: partial view from the turns.
|
|
Some(recent_turns) => ConversationWorkSummary {
|
|
conversation_id: conversation,
|
|
status: ConversationPreviewStatus::Partial,
|
|
objective_preview: None,
|
|
summary_preview: None,
|
|
summary_len: 0,
|
|
up_to: None,
|
|
recent_turns,
|
|
},
|
|
// Both sources failed: nothing usable to show.
|
|
None => ConversationWorkSummary {
|
|
conversation_id: conversation,
|
|
status: ConversationPreviewStatus::Unavailable,
|
|
objective_preview: None,
|
|
summary_preview: None,
|
|
summary_len: 0,
|
|
up_to: None,
|
|
recent_turns: Vec::new(),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Projects a readable [`Handoff`] into a `Ready` summary with bounded previews.
|
|
fn ready_summary(conversation: ConversationId, handoff: &Handoff) -> ConversationWorkSummary {
|
|
ConversationWorkSummary {
|
|
conversation_id: conversation,
|
|
status: ConversationPreviewStatus::Ready,
|
|
objective_preview: handoff
|
|
.objective
|
|
.as_deref()
|
|
.map(|objective| preview(objective, OBJECTIVE_PREVIEW_MAX_CHARS)),
|
|
summary_preview: Some(preview(&handoff.summary_md, HANDOFF_PREVIEW_MAX_CHARS)),
|
|
summary_len: handoff.summary_md.chars().count(),
|
|
up_to: Some(handoff.up_to),
|
|
recent_turns: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Reads the bounded recent-turns fallback for `conversation`.
|
|
///
|
|
/// Returns `Some(turns)` (possibly empty) when a log store is wired and answers,
|
|
/// `None` when there is no log store or the read failed — the caller uses that
|
|
/// distinction to separate `Partial` from `Unavailable`. Never reads the full thread
|
|
/// (only [`ConversationLog::last`]) and re-caps defensively to [`RECENT_TURNS_MAX`].
|
|
async fn recent_turns(
|
|
log_store: &Option<Arc<dyn ConversationLog>>,
|
|
conversation: ConversationId,
|
|
) -> Option<Vec<ConversationTurnWorkPreview>> {
|
|
let store = log_store.as_ref()?;
|
|
match store.last(conversation, RECENT_TURNS_MAX).await {
|
|
Ok(turns) => Some(
|
|
turns
|
|
.into_iter()
|
|
.take(RECENT_TURNS_MAX)
|
|
.map(turn_preview)
|
|
.collect(),
|
|
),
|
|
Err(_) => None,
|
|
}
|
|
}
|
|
|
|
/// Projects one [`ConversationTurn`] into its bounded read-model preview.
|
|
fn turn_preview(turn: ConversationTurn) -> ConversationTurnWorkPreview {
|
|
ConversationTurnWorkPreview {
|
|
role: turn.role,
|
|
source: turn.source.into(),
|
|
at_ms: turn.at_ms,
|
|
text_preview: preview(&turn.text, TURN_PREVIEW_MAX_CHARS),
|
|
text_len: turn.text.chars().count(),
|
|
}
|
|
}
|
|
|
|
/// Projects one FIFO snapshot into a read-model ticket, deriving status from the
|
|
/// agent's current busy ticket (the one running its turn = `InProgress`).
|
|
fn ticket_state(snapshot: QueuedTicketSnapshot, busy_ticket: Option<TicketId>) -> AgentTicketState {
|
|
let status = if busy_ticket == Some(snapshot.id) {
|
|
TicketWorkStatus::InProgress
|
|
} else {
|
|
TicketWorkStatus::Queued
|
|
};
|
|
AgentTicketState {
|
|
ticket_id: snapshot.id,
|
|
conversation_id: snapshot.conversation,
|
|
position: snapshot.position,
|
|
status,
|
|
source: snapshot.source.into(),
|
|
requester_label: snapshot.requester,
|
|
task_preview: task_preview(&snapshot.task),
|
|
task_len: snapshot.task.chars().count(),
|
|
}
|
|
}
|
|
|
|
/// Builds the bounded task excerpt for the UI panel (see [`preview`]).
|
|
///
|
|
/// The original length is reported separately as [`AgentTicketState::task_len`].
|
|
fn task_preview(task: &str) -> String {
|
|
preview(task, TASK_PREVIEW_MAX_CHARS)
|
|
}
|
|
|
|
/// Builds a bounded, whitespace-normalised excerpt of `text` for the UI.
|
|
///
|
|
/// Trims, collapses any run of whitespace to a single space, then truncates to
|
|
/// `max_chars` characters (never mid-codepoint). The original length is reported
|
|
/// separately by each caller's `*_len` field.
|
|
fn preview(text: &str, max_chars: usize) -> String {
|
|
let normalised = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
if normalised.chars().count() <= max_chars {
|
|
normalised
|
|
} else {
|
|
normalised.chars().take(max_chars).collect()
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|