refactor(backend): abstraire le sink de stream hors de tauri::ipc::Channel (#13)
Lot B2 du chantier server/client mode : le cœur backend émet désormais ses flux via une abstraction de sink agnostique, sans dépendre directement de tauri::ipc::Channel, préalable au futur serveur web + PTY WebSocket. - crates/backend : abstraction de sink (stream.rs) câblée dans lib.rs. - crates/app-tauri : implémentation Tauri du sink (stream.rs) et adaptation des surfaces lib.rs, pty.rs, chat.rs. - Nettoyage clippy des 2 warnings B2. Validé : cargo check --workspace vert, tests backend/app-tauri verts, cœur agnostique Tauri, clippy B2 propre. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1,16 +1,17 @@
|
|||||||
//! Generic **structured reply ↔ Tauri Channel** bridge infrastructure.
|
//! Generic **structured reply ↔ outbound stream sink** bridge infrastructure.
|
||||||
//!
|
//!
|
||||||
//! Twin of [`crate::pty::PtyBridge`] (ARCHITECTURE §17.7). Where `PtyBridge`
|
//! Twin of [`crate::pty::PtyBridge`] (ARCHITECTURE §17.7). Where `PtyBridge`
|
||||||
//! routes raw PTY byte chunks to a per-session [`tauri::ipc::Channel`], this
|
//! routes raw PTY byte chunks to a per-session stream sink, this bridge routes
|
||||||
//! bridge routes typed [`ReplyChunk`]s — the serialised
|
//! typed [`ReplyChunk`]s — the serialised [`domain::ports::ReplyEvent`]s of an
|
||||||
//! [`domain::ports::ReplyEvent`]s of an [`domain::ports::AgentSession`] turn — to
|
//! [`domain::ports::AgentSession`] turn — to the chat cell that owns the
|
||||||
//! the chat cell that owns the session.
|
//! session.
|
||||||
//!
|
//!
|
||||||
//! Design (mirrors the PTY path so the lifecycle guarantees are identical):
|
//! Design (mirrors the PTY path so the lifecycle guarantees are identical):
|
||||||
//! - The frontend opens (or re-attaches) a chat cell and passes a
|
//! - The frontend opens (or re-attaches) a chat cell and passes a
|
||||||
//! [`tauri::ipc::Channel`] for that session. The backend registers it here keyed
|
//! [`tauri::ipc::Channel`] for that session. The adapter wraps it as a sink and
|
||||||
//! by `SessionId`, bumping a monotonic **generation** so a superseded pump can't
|
//! registers it here keyed by `SessionId`, bumping a monotonic **generation**
|
||||||
//! tear down the channel of the attach that replaced it (see [`unregister_if`]).
|
//! so a superseded pump can't tear down the channel of the attach that replaced
|
||||||
|
//! it (see [`unregister_if`]).
|
||||||
//! - The `agent_send` pump drains the session's [`domain::ports::ReplyStream`],
|
//! - The `agent_send` pump drains the session's [`domain::ports::ReplyStream`],
|
||||||
//! maps each event to a [`ReplyChunk`], and forwards it via [`send_output`].
|
//! maps each event to a [`ReplyChunk`], and forwards it via [`send_output`].
|
||||||
//! - Unlike a PTY, an [`domain::ports::AgentSession`] keeps **no** scrollback (the
|
//! - Unlike a PTY, an [`domain::ports::AgentSession`] keeps **no** scrollback (the
|
||||||
@ -23,44 +24,35 @@
|
|||||||
//! [`unregister_if`]: ChatBridge::unregister_if
|
//! [`unregister_if`]: ChatBridge::unregister_if
|
||||||
//! [`send_output`]: ChatBridge::send_output
|
//! [`send_output`]: ChatBridge::send_output
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::sync::Arc;
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
|
use backend::stream::ReplayOutputBridge;
|
||||||
use tauri::ipc::Channel;
|
use tauri::ipc::Channel;
|
||||||
|
|
||||||
use domain::ids::SessionId;
|
use domain::ids::SessionId;
|
||||||
use domain::ports::ReplyEvent;
|
use domain::ports::ReplyEvent;
|
||||||
|
|
||||||
use crate::dto::ReplyChunk;
|
use crate::dto::ReplyChunk;
|
||||||
|
use crate::stream::TauriChannelSink;
|
||||||
|
|
||||||
/// Maximum number of recent chat chunks retained for transport reattach replay.
|
/// Maximum number of recent chat chunks retained for transport reattach replay.
|
||||||
pub const MAX_CHAT_SCROLLBACK_CHUNKS: usize = 2_000;
|
pub const MAX_CHAT_SCROLLBACK_CHUNKS: usize = 2_000;
|
||||||
/// Maximum estimated bytes retained for transport reattach replay.
|
/// Maximum estimated bytes retained for transport reattach replay.
|
||||||
pub const MAX_CHAT_SCROLLBACK_BYTES: usize = 512 * 1024;
|
pub const MAX_CHAT_SCROLLBACK_BYTES: usize = 512 * 1024;
|
||||||
|
|
||||||
/// Per-session transport state: the current attach generation, its output
|
/// Registry mapping live structured (chat) sessions to their reply sink
|
||||||
/// channel, and the conversation scrollback recorded so far.
|
|
||||||
struct ChatEntry {
|
|
||||||
/// Monotonic generation of the current (re-)attach (anti double-pump).
|
|
||||||
generation: u64,
|
|
||||||
/// The output channel of the current attach, if one is registered. `None`
|
|
||||||
/// when the session has been recorded (scrollback kept) but no view is
|
|
||||||
/// currently attached — the pump then only appends to the scrollback.
|
|
||||||
channel: Option<Channel<ReplyChunk>>,
|
|
||||||
/// Recent chunks retained for reattach, bounded by chunk count and estimated
|
|
||||||
/// byte size; older chunks are dropped from the head. This is a transport
|
|
||||||
/// replay buffer, not durable conversation history.
|
|
||||||
scrollback: Vec<ReplyChunk>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Registry mapping live structured (chat) sessions to their reply [`Channel`]
|
|
||||||
/// plus a retained conversation scrollback.
|
/// plus a retained conversation scrollback.
|
||||||
///
|
///
|
||||||
/// Thread-safe; a cloned `Arc<ChatBridge>` is held in [`crate::state::AppState`],
|
/// Thread-safe; a cloned `Arc<ChatBridge>` is held in [`crate::state::AppState`],
|
||||||
/// the twin of [`crate::pty::PtyBridge`].
|
/// the twin of [`crate::pty::PtyBridge`].
|
||||||
#[derive(Default)]
|
|
||||||
pub struct ChatBridge {
|
pub struct ChatBridge {
|
||||||
entries: Mutex<HashMap<SessionId, ChatEntry>>,
|
inner: ReplayOutputBridge<SessionId, ReplyChunk>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ChatBridge {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatBridge {
|
impl ChatBridge {
|
||||||
@ -68,7 +60,11 @@ impl ChatBridge {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
entries: Mutex::new(HashMap::new()),
|
inner: ReplayOutputBridge::new(
|
||||||
|
MAX_CHAT_SCROLLBACK_CHUNKS,
|
||||||
|
MAX_CHAT_SCROLLBACK_BYTES,
|
||||||
|
reply_chunk_bytes,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,28 +76,8 @@ impl ChatBridge {
|
|||||||
/// channel and generation change), mirroring how the PTY ring buffer survives
|
/// channel and generation change), mirroring how the PTY ring buffer survives
|
||||||
/// a `reattach_terminal`.
|
/// a `reattach_terminal`.
|
||||||
pub fn register(&self, session: SessionId, channel: Channel<ReplyChunk>) -> u64 {
|
pub fn register(&self, session: SessionId, channel: Channel<ReplyChunk>) -> u64 {
|
||||||
if let Ok(mut map) = self.entries.lock() {
|
self.inner
|
||||||
match map.get_mut(&session) {
|
.register(session, Arc::new(TauriChannelSink::new(channel)))
|
||||||
Some(entry) => {
|
|
||||||
entry.generation = entry.generation.wrapping_add(1);
|
|
||||||
entry.channel = Some(channel);
|
|
||||||
entry.generation
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
map.insert(
|
|
||||||
session,
|
|
||||||
ChatEntry {
|
|
||||||
generation: 0,
|
|
||||||
channel: Some(channel),
|
|
||||||
scrollback: Vec::new(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the conversation scrollback retained for a session (the chunks
|
/// Returns the conversation scrollback retained for a session (the chunks
|
||||||
@ -112,20 +88,14 @@ impl ChatBridge {
|
|||||||
/// `PtyPort::scrollback`.
|
/// `PtyPort::scrollback`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn scrollback(&self, session: &SessionId) -> Vec<ReplyChunk> {
|
pub fn scrollback(&self, session: &SessionId) -> Vec<ReplyChunk> {
|
||||||
self.entries
|
self.inner.scrollback(session)
|
||||||
.lock()
|
|
||||||
.ok()
|
|
||||||
.and_then(|m| m.get(session).map(|e| e.scrollback.clone()))
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes a session's transport state **and** its retained scrollback
|
/// Removes a session's transport state **and** its retained scrollback
|
||||||
/// unconditionally (chat cell explicitly closed). Twin of
|
/// unconditionally (chat cell explicitly closed). Twin of
|
||||||
/// [`PtyBridge::unregister`](crate::pty::PtyBridge::unregister).
|
/// [`PtyBridge::unregister`](crate::pty::PtyBridge::unregister).
|
||||||
pub fn unregister(&self, session: &SessionId) {
|
pub fn unregister(&self, session: &SessionId) {
|
||||||
if let Ok(mut map) = self.entries.lock() {
|
self.inner.unregister(session);
|
||||||
map.remove(session);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detaches a session's channel **only if** `gen` is still the current
|
/// Detaches a session's channel **only if** `gen` is still the current
|
||||||
@ -139,13 +109,7 @@ impl ChatBridge {
|
|||||||
///
|
///
|
||||||
/// [`unregister`]: ChatBridge::unregister
|
/// [`unregister`]: ChatBridge::unregister
|
||||||
pub fn detach_if(&self, session: &SessionId, gen: u64) {
|
pub fn detach_if(&self, session: &SessionId, gen: u64) {
|
||||||
if let Ok(mut map) = self.entries.lock() {
|
self.inner.detach_if(session, gen);
|
||||||
if let Some(entry) = map.get_mut(session) {
|
|
||||||
if entry.generation == gen {
|
|
||||||
entry.channel = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records a chunk in the session's scrollback and, if a view is attached at
|
/// Records a chunk in the session's scrollback and, if a view is attached at
|
||||||
@ -157,40 +121,16 @@ impl ChatBridge {
|
|||||||
/// The pump keeps draining either way so the turn still completes and the
|
/// The pump keeps draining either way so the turn still completes and the
|
||||||
/// scrollback stays whole.
|
/// scrollback stays whole.
|
||||||
pub fn send_output(&self, session: &SessionId, chunk: ReplyChunk) -> bool {
|
pub fn send_output(&self, session: &SessionId, chunk: ReplyChunk) -> bool {
|
||||||
let Ok(mut map) = self.entries.lock() else {
|
self.inner.send_output(session, chunk)
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Some(entry) = map.get_mut(session) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
entry.scrollback.push(chunk.clone());
|
|
||||||
trim_scrollback(entry);
|
|
||||||
match &entry.channel {
|
|
||||||
Some(channel) => channel.send(chunk).is_ok(),
|
|
||||||
None => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Number of currently-tracked sessions (handy for tests/diagnostics).
|
/// Number of currently-tracked sessions (handy for tests/diagnostics).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn active_sessions(&self) -> usize {
|
pub fn active_sessions(&self) -> usize {
|
||||||
self.entries.lock().map(|m| m.len()).unwrap_or(0)
|
self.inner.active_sessions()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn trim_scrollback(entry: &mut ChatEntry) {
|
|
||||||
while !entry.scrollback.is_empty()
|
|
||||||
&& (entry.scrollback.len() > MAX_CHAT_SCROLLBACK_CHUNKS
|
|
||||||
|| scrollback_bytes(&entry.scrollback) > MAX_CHAT_SCROLLBACK_BYTES)
|
|
||||||
{
|
|
||||||
entry.scrollback.remove(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scrollback_bytes(chunks: &[ReplyChunk]) -> usize {
|
|
||||||
chunks.iter().map(reply_chunk_bytes).sum()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize {
|
fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize {
|
||||||
match chunk {
|
match chunk {
|
||||||
ReplyChunk::TextDelta { text } => text.len(),
|
ReplyChunk::TextDelta { text } => text.len(),
|
||||||
|
|||||||
@ -22,6 +22,7 @@ pub mod mcp_endpoint;
|
|||||||
pub mod openai_tools;
|
pub mod openai_tools;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod stream;
|
||||||
pub mod tickets;
|
pub mod tickets;
|
||||||
|
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|||||||
@ -1,27 +1,27 @@
|
|||||||
//! Generic **PTY ↔ Tauri Channel** bridge infrastructure.
|
//! Generic **PTY ↔ outbound stream sink** bridge infrastructure.
|
||||||
//!
|
//!
|
||||||
//! ARCHITECTURE §2 decides that high-frequency PTY byte streams travel over
|
//! ARCHITECTURE §2 decides that high-frequency PTY byte streams travel over a
|
||||||
//! per-session [`tauri::ipc::Channel`]s rather than global events, for
|
//! per-session stream channel rather than global events, for throughput and
|
||||||
//! throughput and isolation. This module provides the transport-side plumbing
|
//! isolation. This module provides the desktop-facing plumbing that adapts the
|
||||||
//! that L3 will plug a real `PtyPort` into; here there is **no real PTY** yet —
|
//! shared backend sink abstraction to Tauri IPC.
|
||||||
//! only the registry + the abstraction that routes byte chunks to the right
|
|
||||||
//! frontend channel.
|
|
||||||
//!
|
//!
|
||||||
//! Design:
|
//! Design:
|
||||||
//! - The frontend opens a terminal and passes a [`tauri::ipc::Channel`] for that
|
//! - The frontend opens a terminal and passes a [`tauri::ipc::Channel`] for that
|
||||||
//! session. The backend registers it in [`PtyBridge`] keyed by `SessionId`.
|
//! session. The adapter wraps it as a sink and registers it in [`PtyBridge`].
|
||||||
//! - Whatever produces output (the PTY adapter in L3) calls
|
//! - Whatever produces output calls [`PtyBridge::send_output`], which forwards
|
||||||
//! [`PtyBridge::send_output`], which forwards the bytes on the matching
|
//! the bytes through the current sink. Bytes are sent as-is; the frontend
|
||||||
//! channel. Bytes are sent as-is; the frontend xterm wrapper consumes them.
|
//! xterm wrapper consumes them.
|
||||||
//! - [`PtyBridge::unregister`] tears the channel down on terminal close.
|
//! - [`PtyBridge::unregister`] tears the channel down on terminal close.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::sync::Arc;
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
|
use backend::stream::OutputBridge;
|
||||||
use tauri::ipc::Channel;
|
use tauri::ipc::Channel;
|
||||||
|
|
||||||
use domain::ids::SessionId;
|
use domain::ids::SessionId;
|
||||||
|
|
||||||
|
use crate::stream::TauriChannelSink;
|
||||||
|
|
||||||
/// A chunk of PTY output bytes destined for a specific session's channel.
|
/// A chunk of PTY output bytes destined for a specific session's channel.
|
||||||
///
|
///
|
||||||
/// Sent as a raw byte vector; serde encodes it for the IPC channel. Kept as a
|
/// Sent as a raw byte vector; serde encodes it for the IPC channel. Kept as a
|
||||||
@ -29,16 +29,12 @@ use domain::ids::SessionId;
|
|||||||
/// backpressure handling) without touching call sites.
|
/// backpressure handling) without touching call sites.
|
||||||
pub type PtyChunk = Vec<u8>;
|
pub type PtyChunk = Vec<u8>;
|
||||||
|
|
||||||
/// Registry mapping live terminal sessions to their output [`Channel`].
|
/// Registry mapping live terminal sessions to their output sink.
|
||||||
///
|
///
|
||||||
/// Thread-safe; cloned `Arc<PtyBridge>` is held in [`crate::state::AppState`].
|
/// Thread-safe; cloned `Arc<PtyBridge>` is held in [`crate::state::AppState`].
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct PtyBridge {
|
pub struct PtyBridge {
|
||||||
/// Per session: a monotonically-increasing **generation** plus the current
|
inner: OutputBridge<SessionId, PtyChunk>,
|
||||||
/// output channel. The generation distinguishes successive (re-)attaches so a
|
|
||||||
/// superseded pump thread can't tear down the channel of the attach that
|
|
||||||
/// replaced it (see [`PtyBridge::unregister_if`]).
|
|
||||||
channels: Mutex<HashMap<SessionId, (u64, Channel<PtyChunk>)>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PtyBridge {
|
impl PtyBridge {
|
||||||
@ -46,7 +42,7 @@ impl PtyBridge {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
channels: Mutex::new(HashMap::new()),
|
inner: OutputBridge::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,20 +51,13 @@ impl PtyBridge {
|
|||||||
/// generation, so the caller's pump thread can later tear down *only its own*
|
/// generation, so the caller's pump thread can later tear down *only its own*
|
||||||
/// registration via [`unregister_if`](Self::unregister_if).
|
/// registration via [`unregister_if`](Self::unregister_if).
|
||||||
pub fn register(&self, session: SessionId, channel: Channel<PtyChunk>) -> u64 {
|
pub fn register(&self, session: SessionId, channel: Channel<PtyChunk>) -> u64 {
|
||||||
if let Ok(mut map) = self.channels.lock() {
|
self.inner
|
||||||
let gen = map.get(&session).map_or(0, |(g, _)| g.wrapping_add(1));
|
.register(session, Arc::new(TauriChannelSink::new(channel)))
|
||||||
map.insert(session, (gen, channel));
|
|
||||||
gen
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes a session's channel unconditionally (terminal explicitly closed).
|
/// Removes a session's channel unconditionally (terminal explicitly closed).
|
||||||
pub fn unregister(&self, session: &SessionId) {
|
pub fn unregister(&self, session: &SessionId) {
|
||||||
if let Ok(mut map) = self.channels.lock() {
|
self.inner.unregister(session);
|
||||||
map.remove(session);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes a session's channel **only if** `gen` is still the current
|
/// Removes a session's channel **only if** `gen` is still the current
|
||||||
@ -76,11 +65,7 @@ impl PtyBridge {
|
|||||||
/// session has since been re-attached (newer generation), this is a no-op, so
|
/// session has since been re-attached (newer generation), this is a no-op, so
|
||||||
/// the dying thread never unregisters the live channel that superseded it.
|
/// the dying thread never unregisters the live channel that superseded it.
|
||||||
pub fn unregister_if(&self, session: &SessionId, gen: u64) {
|
pub fn unregister_if(&self, session: &SessionId, gen: u64) {
|
||||||
if let Ok(mut map) = self.channels.lock() {
|
self.inner.unregister_if(session, gen);
|
||||||
if matches!(map.get(session), Some((g, _)) if *g == gen) {
|
|
||||||
map.remove(session);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forwards a chunk of output bytes to a session's channel.
|
/// Forwards a chunk of output bytes to a session's channel.
|
||||||
@ -89,18 +74,12 @@ impl PtyBridge {
|
|||||||
/// registered for the session (e.g. already closed). In L3 the PTY adapter's
|
/// registered for the session (e.g. already closed). In L3 the PTY adapter's
|
||||||
/// output stream drives this.
|
/// output stream drives this.
|
||||||
pub fn send_output(&self, session: &SessionId, chunk: PtyChunk) -> bool {
|
pub fn send_output(&self, session: &SessionId, chunk: PtyChunk) -> bool {
|
||||||
let Ok(map) = self.channels.lock() else {
|
self.inner.send_output(session, chunk)
|
||||||
return false;
|
|
||||||
};
|
|
||||||
match map.get(session) {
|
|
||||||
Some((_, channel)) => channel.send(chunk).is_ok(),
|
|
||||||
None => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Number of currently-registered sessions (handy for tests/diagnostics).
|
/// Number of currently-registered sessions (handy for tests/diagnostics).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn active_sessions(&self) -> usize {
|
pub fn active_sessions(&self) -> usize {
|
||||||
self.channels.lock().map(|m| m.len()).unwrap_or(0)
|
self.inner.active_sessions()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
crates/app-tauri/src/stream.rs
Normal file
31
crates/app-tauri/src/stream.rs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
//! Tauri implementation of the shared outbound stream sink contract.
|
||||||
|
//!
|
||||||
|
//! The backend core owns the transport-agnostic [`backend::stream::OutputSink`]
|
||||||
|
//! abstraction. This adapter is the only layer that turns it into a concrete
|
||||||
|
//! [`tauri::ipc::Channel`] send.
|
||||||
|
|
||||||
|
use backend::stream::{OutputSink, OutputSinkError};
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::ipc::Channel;
|
||||||
|
|
||||||
|
/// [`OutputSink`] backed by a per-attach Tauri IPC [`Channel`].
|
||||||
|
pub struct TauriChannelSink<T> {
|
||||||
|
channel: Channel<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> TauriChannelSink<T> {
|
||||||
|
/// Wraps a Tauri IPC channel as a shared backend stream sink.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(channel: Channel<T>) -> Self {
|
||||||
|
Self { channel }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> OutputSink<T> for TauriChannelSink<T>
|
||||||
|
where
|
||||||
|
T: Serialize + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
fn send(&self, item: T) -> Result<(), OutputSinkError> {
|
||||||
|
self.channel.send(item).map_err(|_| OutputSinkError::Closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -85,6 +85,7 @@ use infrastructure::{
|
|||||||
|
|
||||||
pub mod mcp_endpoint;
|
pub mod mcp_endpoint;
|
||||||
pub mod openai_tools;
|
pub mod openai_tools;
|
||||||
|
pub mod stream;
|
||||||
|
|
||||||
use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
|
use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
|
||||||
use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker};
|
use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker};
|
||||||
@ -114,10 +115,8 @@ mod backend_core_build_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_core_builds_without_desktop_runtime() {
|
fn backend_core_builds_without_desktop_runtime() {
|
||||||
let dir = std::env::temp_dir().join(format!(
|
let dir =
|
||||||
"idea-backend-core-build-{}",
|
std::env::temp_dir().join(format!("idea-backend-core-build-{}", uuid::Uuid::new_v4()));
|
||||||
uuid::Uuid::new_v4()
|
|
||||||
));
|
|
||||||
let core = BackendCore::build(dir.clone());
|
let core = BackendCore::build(dir.clone());
|
||||||
|
|
||||||
assert!(!core.home_dir.is_empty());
|
assert!(!core.home_dir.is_empty());
|
||||||
|
|||||||
216
crates/backend/src/stream.rs
Normal file
216
crates/backend/src/stream.rs
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::hash::Hash;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum OutputSinkError {
|
||||||
|
Closed,
|
||||||
|
Full,
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait OutputSink<T>: Send + Sync + 'static {
|
||||||
|
fn send(&self, item: T) -> Result<(), OutputSinkError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SharedOutputSink<T> = Arc<dyn OutputSink<T>>;
|
||||||
|
type SinkRegistry<K, T> = HashMap<K, (u64, SharedOutputSink<T>)>;
|
||||||
|
|
||||||
|
pub struct OutputBridge<K, T>
|
||||||
|
where
|
||||||
|
K: Copy + Eq + Hash,
|
||||||
|
{
|
||||||
|
sinks: Mutex<SinkRegistry<K, T>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<K, T> Default for OutputBridge<K, T>
|
||||||
|
where
|
||||||
|
K: Copy + Eq + Hash,
|
||||||
|
T: 'static,
|
||||||
|
{
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<K, T> OutputBridge<K, T>
|
||||||
|
where
|
||||||
|
K: Copy + Eq + Hash,
|
||||||
|
T: 'static,
|
||||||
|
{
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
sinks: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register(&self, key: K, sink: SharedOutputSink<T>) -> u64 {
|
||||||
|
if let Ok(mut map) = self.sinks.lock() {
|
||||||
|
let gen = map.get(&key).map_or(0, |(g, _)| g.wrapping_add(1));
|
||||||
|
map.insert(key, (gen, sink));
|
||||||
|
gen
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unregister(&self, key: &K) {
|
||||||
|
if let Ok(mut map) = self.sinks.lock() {
|
||||||
|
map.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unregister_if(&self, key: &K, gen: u64) {
|
||||||
|
if let Ok(mut map) = self.sinks.lock() {
|
||||||
|
if matches!(map.get(key), Some((g, _)) if *g == gen) {
|
||||||
|
map.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_send_output(&self, key: &K, item: T) -> Result<(), OutputSinkError> {
|
||||||
|
let sink = {
|
||||||
|
let Ok(map) = self.sinks.lock() else {
|
||||||
|
return Err(OutputSinkError::Closed);
|
||||||
|
};
|
||||||
|
map.get(key)
|
||||||
|
.map(|(_, sink)| Arc::clone(sink))
|
||||||
|
.ok_or(OutputSinkError::Closed)?
|
||||||
|
};
|
||||||
|
sink.send(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_output(&self, key: &K, item: T) -> bool {
|
||||||
|
self.try_send_output(key, item).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_sessions(&self) -> usize {
|
||||||
|
self.sinks.lock().map(|m| m.len()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReplayEntry<T> {
|
||||||
|
generation: u64,
|
||||||
|
sink: Option<SharedOutputSink<T>>,
|
||||||
|
scrollback: Vec<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ReplayOutputBridge<K, T>
|
||||||
|
where
|
||||||
|
K: Copy + Eq + Hash,
|
||||||
|
{
|
||||||
|
entries: Mutex<HashMap<K, ReplayEntry<T>>>,
|
||||||
|
max_chunks: usize,
|
||||||
|
max_bytes: usize,
|
||||||
|
measure: fn(&T) -> usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<K, T> ReplayOutputBridge<K, T>
|
||||||
|
where
|
||||||
|
K: Copy + Eq + Hash,
|
||||||
|
T: Clone + 'static,
|
||||||
|
{
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(max_chunks: usize, max_bytes: usize, measure: fn(&T) -> usize) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: Mutex::new(HashMap::new()),
|
||||||
|
max_chunks,
|
||||||
|
max_bytes,
|
||||||
|
measure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register(&self, key: K, sink: SharedOutputSink<T>) -> u64 {
|
||||||
|
if let Ok(mut map) = self.entries.lock() {
|
||||||
|
match map.get_mut(&key) {
|
||||||
|
Some(entry) => {
|
||||||
|
entry.generation = entry.generation.wrapping_add(1);
|
||||||
|
entry.sink = Some(sink);
|
||||||
|
entry.generation
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
map.insert(
|
||||||
|
key,
|
||||||
|
ReplayEntry {
|
||||||
|
generation: 0,
|
||||||
|
sink: Some(sink),
|
||||||
|
scrollback: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn scrollback(&self, key: &K) -> Vec<T> {
|
||||||
|
self.entries
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.get(key).map(|e| e.scrollback.clone()))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unregister(&self, key: &K) {
|
||||||
|
if let Ok(mut map) = self.entries.lock() {
|
||||||
|
map.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detach_if(&self, key: &K, gen: u64) {
|
||||||
|
if let Ok(mut map) = self.entries.lock() {
|
||||||
|
if let Some(entry) = map.get_mut(key) {
|
||||||
|
if entry.generation == gen {
|
||||||
|
entry.sink = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_send_output(&self, key: &K, item: T) -> Result<(), OutputSinkError> {
|
||||||
|
let sink = {
|
||||||
|
let Ok(mut map) = self.entries.lock() else {
|
||||||
|
return Err(OutputSinkError::Closed);
|
||||||
|
};
|
||||||
|
let Some(entry) = map.get_mut(key) else {
|
||||||
|
return Err(OutputSinkError::Closed);
|
||||||
|
};
|
||||||
|
entry.scrollback.push(item.clone());
|
||||||
|
trim_scrollback(entry, self.max_chunks, self.max_bytes, self.measure);
|
||||||
|
entry.sink.as_ref().map(Arc::clone)
|
||||||
|
};
|
||||||
|
|
||||||
|
match sink {
|
||||||
|
Some(sink) => sink.send(item),
|
||||||
|
None => Err(OutputSinkError::Closed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_output(&self, key: &K, item: T) -> bool {
|
||||||
|
self.try_send_output(key, item).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_sessions(&self) -> usize {
|
||||||
|
self.entries.lock().map(|m| m.len()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_scrollback<T>(
|
||||||
|
entry: &mut ReplayEntry<T>,
|
||||||
|
max_chunks: usize,
|
||||||
|
max_bytes: usize,
|
||||||
|
measure: fn(&T) -> usize,
|
||||||
|
) {
|
||||||
|
while !entry.scrollback.is_empty()
|
||||||
|
&& (entry.scrollback.len() > max_chunks
|
||||||
|
|| entry.scrollback.iter().map(measure).sum::<usize>() > max_bytes)
|
||||||
|
{
|
||||||
|
entry.scrollback.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user