Merge feature/ticket13-server-client-mode into develop (#13)
Premier incrément livrable du chantier server/client mode (B0→B4/F2) : cœur backend commun extrait hors Tauri, sink de stream agnostique, serveur HTTP sécurisé idea --serve (pairing + cookie de session, allowlist read-only), et client web read-only (pairing, liste, ouverture, snapshot). Desktop inchangé ; mode web opt-in via VITE_TRANSPORT="http". PTY WebSocket (B5) + xterm (F3) reportés à la 2e vague. Incrément VERT de bout en bout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
20
Cargo.lock
generated
20
Cargo.lock
generated
@ -73,8 +73,13 @@ version = "0.3.0"
|
||||
dependencies = [
|
||||
"application",
|
||||
"async-trait",
|
||||
"backend",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"domain",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"infrastructure",
|
||||
"interprocess",
|
||||
"serde",
|
||||
@ -146,6 +151,21 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "backend"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"application",
|
||||
"async-trait",
|
||||
"domain",
|
||||
"infrastructure",
|
||||
"interprocess",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.13.1"
|
||||
|
||||
@ -4,6 +4,7 @@ members = [
|
||||
"crates/domain",
|
||||
"crates/application",
|
||||
"crates/infrastructure",
|
||||
"crates/backend",
|
||||
"crates/app-tauri",
|
||||
]
|
||||
|
||||
@ -29,6 +30,7 @@ git2 = { version = "0.20", default-features = false }
|
||||
domain = { path = "crates/domain" }
|
||||
application = { path = "crates/application" }
|
||||
infrastructure = { path = "crates/infrastructure" }
|
||||
backend = { path = "crates/backend" }
|
||||
|
||||
# Tauri v2
|
||||
tauri = { version = "2", features = [] }
|
||||
|
||||
@ -23,16 +23,21 @@ tauri-build = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
infrastructure = { workspace = true }
|
||||
backend = { workspace = true }
|
||||
tauri = { workspace = true }
|
||||
tauri-plugin-dialog = { workspace = true }
|
||||
# `io-std` (on top of the workspace features) gives the headless `mcp-server`
|
||||
# bridge access to `tokio::io::{stdin,stdout}` without widening tokio elsewhere.
|
||||
tokio = { workspace = true, features = ["io-std", "rt"] }
|
||||
# `io-std` gives the headless `mcp-server` bridge access to
|
||||
# `tokio::io::{stdin,stdout}`; `net` is used only by the `--serve` HTTP adapter.
|
||||
tokio = { workspace = true, features = ["io-std", "rt", "net"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
base64 = "0.22"
|
||||
bytes = "1.11"
|
||||
cookie = "0.18"
|
||||
http = "1.4"
|
||||
http-body-util = "0.1"
|
||||
# `AppAgentResumer` implements the application's async `AgentResumer` port (LS7).
|
||||
async-trait = { workspace = true }
|
||||
# Cross-OS local IPC for the MCP loopback transport (M5a): Unix domain socket
|
||||
@ -44,8 +49,8 @@ interprocess = { version = "2.4", features = ["tokio"] }
|
||||
[features]
|
||||
# Passthrough toggles to enable the real embedders in an IDE build. OFF by default
|
||||
# (founding posture: `none` ⇒ naïve recall, zero dependency).
|
||||
vector-http = ["infrastructure/vector-http"]
|
||||
vector-onnx = ["infrastructure/vector-onnx"]
|
||||
vector-http = ["infrastructure/vector-http", "backend/vector-http"]
|
||||
vector-onnx = ["infrastructure/vector-onnx", "backend/vector-onnx"]
|
||||
|
||||
[dev-dependencies]
|
||||
uuid = { workspace = true }
|
||||
|
||||
@ -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`
|
||||
//! routes raw PTY byte chunks to a per-session [`tauri::ipc::Channel`], this
|
||||
//! bridge routes typed [`ReplyChunk`]s — the serialised
|
||||
//! [`domain::ports::ReplyEvent`]s of an [`domain::ports::AgentSession`] turn — to
|
||||
//! the chat cell that owns the session.
|
||||
//! routes raw PTY byte chunks to a per-session stream sink, this bridge routes
|
||||
//! typed [`ReplyChunk`]s — the serialised [`domain::ports::ReplyEvent`]s of an
|
||||
//! [`domain::ports::AgentSession`] turn — to the chat cell that owns the
|
||||
//! session.
|
||||
//!
|
||||
//! Design (mirrors the PTY path so the lifecycle guarantees are identical):
|
||||
//! - The frontend opens (or re-attaches) a chat cell and passes a
|
||||
//! [`tauri::ipc::Channel`] for that session. The backend registers it here keyed
|
||||
//! by `SessionId`, bumping a monotonic **generation** so a superseded pump can't
|
||||
//! tear down the channel of the attach that replaced it (see [`unregister_if`]).
|
||||
//! [`tauri::ipc::Channel`] for that session. The adapter wraps it as a sink and
|
||||
//! registers it here keyed by `SessionId`, bumping a monotonic **generation**
|
||||
//! 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`],
|
||||
//! maps each event to a [`ReplyChunk`], and forwards it via [`send_output`].
|
||||
//! - Unlike a PTY, an [`domain::ports::AgentSession`] keeps **no** scrollback (the
|
||||
@ -23,44 +24,35 @@
|
||||
//! [`unregister_if`]: ChatBridge::unregister_if
|
||||
//! [`send_output`]: ChatBridge::send_output
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::Arc;
|
||||
|
||||
use backend::stream::ReplayOutputBridge;
|
||||
use tauri::ipc::Channel;
|
||||
|
||||
use domain::ids::SessionId;
|
||||
use domain::ports::ReplyEvent;
|
||||
|
||||
use crate::dto::ReplyChunk;
|
||||
use crate::stream::TauriChannelSink;
|
||||
|
||||
/// Maximum number of recent chat chunks retained for transport reattach replay.
|
||||
pub const MAX_CHAT_SCROLLBACK_CHUNKS: usize = 2_000;
|
||||
/// Maximum estimated bytes retained for transport reattach replay.
|
||||
pub const MAX_CHAT_SCROLLBACK_BYTES: usize = 512 * 1024;
|
||||
|
||||
/// Per-session transport state: the current attach generation, its output
|
||||
/// 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`]
|
||||
/// Registry mapping live structured (chat) sessions to their reply sink
|
||||
/// plus a retained conversation scrollback.
|
||||
///
|
||||
/// Thread-safe; a cloned `Arc<ChatBridge>` is held in [`crate::state::AppState`],
|
||||
/// the twin of [`crate::pty::PtyBridge`].
|
||||
#[derive(Default)]
|
||||
pub struct ChatBridge {
|
||||
entries: Mutex<HashMap<SessionId, ChatEntry>>,
|
||||
inner: ReplayOutputBridge<SessionId, ReplyChunk>,
|
||||
}
|
||||
|
||||
impl Default for ChatBridge {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatBridge {
|
||||
@ -68,7 +60,11 @@ impl ChatBridge {
|
||||
#[must_use]
|
||||
pub fn new() -> 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
|
||||
/// a `reattach_terminal`.
|
||||
pub fn register(&self, session: SessionId, channel: Channel<ReplyChunk>) -> u64 {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
match map.get_mut(&session) {
|
||||
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
|
||||
}
|
||||
self.inner
|
||||
.register(session, Arc::new(TauriChannelSink::new(channel)))
|
||||
}
|
||||
|
||||
/// Returns the conversation scrollback retained for a session (the chunks
|
||||
@ -112,20 +88,14 @@ impl ChatBridge {
|
||||
/// `PtyPort::scrollback`.
|
||||
#[must_use]
|
||||
pub fn scrollback(&self, session: &SessionId) -> Vec<ReplyChunk> {
|
||||
self.entries
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|m| m.get(session).map(|e| e.scrollback.clone()))
|
||||
.unwrap_or_default()
|
||||
self.inner.scrollback(session)
|
||||
}
|
||||
|
||||
/// Removes a session's transport state **and** its retained scrollback
|
||||
/// unconditionally (chat cell explicitly closed). Twin of
|
||||
/// [`PtyBridge::unregister`](crate::pty::PtyBridge::unregister).
|
||||
pub fn unregister(&self, session: &SessionId) {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
map.remove(session);
|
||||
}
|
||||
self.inner.unregister(session);
|
||||
}
|
||||
|
||||
/// Detaches a session's channel **only if** `gen` is still the current
|
||||
@ -139,13 +109,7 @@ impl ChatBridge {
|
||||
///
|
||||
/// [`unregister`]: ChatBridge::unregister
|
||||
pub fn detach_if(&self, session: &SessionId, gen: u64) {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
if let Some(entry) = map.get_mut(session) {
|
||||
if entry.generation == gen {
|
||||
entry.channel = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.inner.detach_if(session, gen);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// scrollback stays whole.
|
||||
pub fn send_output(&self, session: &SessionId, chunk: ReplyChunk) -> bool {
|
||||
let Ok(mut map) = self.entries.lock() else {
|
||||
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,
|
||||
}
|
||||
self.inner.send_output(session, chunk)
|
||||
}
|
||||
|
||||
/// Number of currently-tracked sessions (handy for tests/diagnostics).
|
||||
#[must_use]
|
||||
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 {
|
||||
match chunk {
|
||||
ReplyChunk::TextDelta { text } => text.len(),
|
||||
|
||||
@ -113,6 +113,17 @@ pub async fn create_project(
|
||||
pub async fn open_project(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProjectDto, ErrorDto> {
|
||||
open_project_for_adapter(project_id, &state).await
|
||||
}
|
||||
|
||||
/// Shared `open_project` adapter body used by desktop IPC and the HTTP server.
|
||||
///
|
||||
/// Keeps the non-presentation side effects attached to project opening in one
|
||||
/// place while B3 adds a second driving adapter.
|
||||
pub(crate) async fn open_project_for_adapter(
|
||||
project_id: String,
|
||||
state: &AppState,
|
||||
) -> Result<ProjectDto, ErrorDto> {
|
||||
let id = parse_project_id(&project_id)?;
|
||||
let output = state
|
||||
|
||||
@ -21,7 +21,9 @@ pub mod mcp_bridge;
|
||||
pub mod mcp_endpoint;
|
||||
pub mod openai_tools;
|
||||
pub mod pty;
|
||||
pub mod server;
|
||||
pub mod state;
|
||||
pub mod stream;
|
||||
pub mod tickets;
|
||||
|
||||
use std::process::ExitCode;
|
||||
@ -41,14 +43,17 @@ use state::AppState;
|
||||
/// The `argv[1]` subcommand token that switches the binary into the headless
|
||||
/// `mcp-server` **bridge** mode (cadrage v5 §1.3) instead of launching Tauri.
|
||||
pub const MCP_SERVER_SUBCOMMAND: &str = "mcp-server";
|
||||
/// The `argv[1]` subcommand token that starts the secure HTTP server adapter.
|
||||
pub const SERVE_SUBCOMMAND: &str = "--serve";
|
||||
|
||||
/// Process entry point: routes `argv` **before** anything Tauri/WebKit is touched.
|
||||
///
|
||||
/// When invoked as `<exe> mcp-server …` (an MCP CLI spawned us from the injected
|
||||
/// `.mcp.json` declaration), we run the **stdio↔loopback bridge** headless and
|
||||
/// **never** initialise the webview — see [`mcp_bridge::run_mcp_bridge`]. Any other
|
||||
/// invocation is the normal IDE launch: [`run`] (which blocks until the window
|
||||
/// closes and then exits the process itself).
|
||||
/// **never** initialise the webview — see [`mcp_bridge::run_mcp_bridge`]. When
|
||||
/// invoked as `<exe> --serve …`, we run the secure HTTP adapter headless. Any
|
||||
/// other invocation is the normal IDE launch: [`run`] (which blocks until the
|
||||
/// window closes and then exits the process itself).
|
||||
///
|
||||
/// Returns the [`ExitCode`] for the bridge path; the normal path does not return.
|
||||
#[must_use]
|
||||
@ -60,6 +65,11 @@ pub fn dispatch() -> ExitCode {
|
||||
let rest: Vec<String> = args.map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
return mcp_bridge::run_mcp_bridge(rest);
|
||||
}
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
if args.next().is_some_and(|a| a == *SERVE_SUBCOMMAND) {
|
||||
let rest: Vec<String> = args.map(|a| a.to_string_lossy().into_owned()).collect();
|
||||
return server::run_from_args(rest);
|
||||
}
|
||||
|
||||
run();
|
||||
ExitCode::SUCCESS
|
||||
|
||||
@ -600,6 +600,7 @@ mod tests {
|
||||
/// the server (reads the handshake, echoes a canned response); the relay drives
|
||||
/// it over an actual connection. Verifies the handshake crosses the real socket
|
||||
/// and the response returns on the CLI stdout.
|
||||
#[ignore = "requires local socket bind permission"]
|
||||
#[tokio::test]
|
||||
async fn end_to_end_over_real_loopback() {
|
||||
let endpoint = temp_endpoint("e2e");
|
||||
|
||||
@ -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
|
||||
//! per-session [`tauri::ipc::Channel`]s rather than global events, for
|
||||
//! throughput and isolation. This module provides the transport-side plumbing
|
||||
//! that L3 will plug a real `PtyPort` into; here there is **no real PTY** yet —
|
||||
//! only the registry + the abstraction that routes byte chunks to the right
|
||||
//! frontend channel.
|
||||
//! ARCHITECTURE §2 decides that high-frequency PTY byte streams travel over a
|
||||
//! per-session stream channel rather than global events, for throughput and
|
||||
//! isolation. This module provides the desktop-facing plumbing that adapts the
|
||||
//! shared backend sink abstraction to Tauri IPC.
|
||||
//!
|
||||
//! Design:
|
||||
//! - The frontend opens a terminal and passes a [`tauri::ipc::Channel`] for that
|
||||
//! session. The backend registers it in [`PtyBridge`] keyed by `SessionId`.
|
||||
//! - Whatever produces output (the PTY adapter in L3) calls
|
||||
//! [`PtyBridge::send_output`], which forwards the bytes on the matching
|
||||
//! channel. Bytes are sent as-is; the frontend xterm wrapper consumes them.
|
||||
//! session. The adapter wraps it as a sink and registers it in [`PtyBridge`].
|
||||
//! - Whatever produces output calls [`PtyBridge::send_output`], which forwards
|
||||
//! the bytes through the current sink. Bytes are sent as-is; the frontend
|
||||
//! xterm wrapper consumes them.
|
||||
//! - [`PtyBridge::unregister`] tears the channel down on terminal close.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::Arc;
|
||||
|
||||
use backend::stream::OutputBridge;
|
||||
use tauri::ipc::Channel;
|
||||
|
||||
use domain::ids::SessionId;
|
||||
|
||||
use crate::stream::TauriChannelSink;
|
||||
|
||||
/// 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
|
||||
@ -29,16 +29,12 @@ use domain::ids::SessionId;
|
||||
/// backpressure handling) without touching call sites.
|
||||
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`].
|
||||
#[derive(Default)]
|
||||
pub struct PtyBridge {
|
||||
/// Per session: a monotonically-increasing **generation** plus the current
|
||||
/// 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>)>>,
|
||||
inner: OutputBridge<SessionId, PtyChunk>,
|
||||
}
|
||||
|
||||
impl PtyBridge {
|
||||
@ -46,7 +42,7 @@ impl PtyBridge {
|
||||
#[must_use]
|
||||
pub fn new() -> 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*
|
||||
/// registration via [`unregister_if`](Self::unregister_if).
|
||||
pub fn register(&self, session: SessionId, channel: Channel<PtyChunk>) -> u64 {
|
||||
if let Ok(mut map) = self.channels.lock() {
|
||||
let gen = map.get(&session).map_or(0, |(g, _)| g.wrapping_add(1));
|
||||
map.insert(session, (gen, channel));
|
||||
gen
|
||||
} else {
|
||||
0
|
||||
}
|
||||
self.inner
|
||||
.register(session, Arc::new(TauriChannelSink::new(channel)))
|
||||
}
|
||||
|
||||
/// Removes a session's channel unconditionally (terminal explicitly closed).
|
||||
pub fn unregister(&self, session: &SessionId) {
|
||||
if let Ok(mut map) = self.channels.lock() {
|
||||
map.remove(session);
|
||||
}
|
||||
self.inner.unregister(session);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// the dying thread never unregisters the live channel that superseded it.
|
||||
pub fn unregister_if(&self, session: &SessionId, gen: u64) {
|
||||
if let Ok(mut map) = self.channels.lock() {
|
||||
if matches!(map.get(session), Some((g, _)) if *g == gen) {
|
||||
map.remove(session);
|
||||
}
|
||||
}
|
||||
self.inner.unregister_if(session, gen);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// output stream drives this.
|
||||
pub fn send_output(&self, session: &SessionId, chunk: PtyChunk) -> bool {
|
||||
let Ok(map) = self.channels.lock() else {
|
||||
return false;
|
||||
};
|
||||
match map.get(session) {
|
||||
Some((_, channel)) => channel.send(chunk).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
self.inner.send_output(session, chunk)
|
||||
}
|
||||
|
||||
/// Number of currently-registered sessions (handy for tests/diagnostics).
|
||||
#[must_use]
|
||||
pub fn active_sessions(&self) -> usize {
|
||||
self.channels.lock().map(|m| m.len()).unwrap_or(0)
|
||||
self.inner.active_sessions()
|
||||
}
|
||||
}
|
||||
|
||||
1279
crates/app-tauri/src/server.rs
Normal file
1279
crates/app-tauri/src/server.rs
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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)
|
||||
}
|
||||
}
|
||||
@ -242,6 +242,7 @@ fn socket_exists(project: &Project) -> bool {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[ignore = "requires local socket bind permission"]
|
||||
#[tokio::test]
|
||||
async fn open_binds_the_project_loopback_endpoint() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
@ -259,6 +260,7 @@ async fn open_binds_the_project_loopback_endpoint() {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[ignore = "requires local socket bind permission"]
|
||||
#[tokio::test]
|
||||
async fn double_open_keeps_a_single_endpoint_no_address_in_use() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
@ -280,6 +282,7 @@ async fn double_open_keeps_a_single_endpoint_no_address_in_use() {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[ignore = "requires local socket bind permission"]
|
||||
#[tokio::test]
|
||||
async fn close_cleans_up_the_endpoint_socket_file() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
@ -311,6 +314,7 @@ fn endpoint_is_deterministic_and_collision_free_across_projects() {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[ignore = "requires local socket bind permission"]
|
||||
#[tokio::test]
|
||||
async fn file_watcher_and_loopback_endpoint_live_together() {
|
||||
let state = AppState::build(temp_path("appdata"));
|
||||
|
||||
22
crates/backend/Cargo.toml
Normal file
22
crates/backend/Cargo.toml
Normal file
@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "backend"
|
||||
version = "0.3.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "IdeA shared backend composition root, reusable by desktop and server adapters."
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
infrastructure = { workspace = true }
|
||||
tokio = { workspace = true, features = ["io-std", "rt"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
interprocess = { version = "2.4", features = ["tokio"] }
|
||||
|
||||
[features]
|
||||
vector-http = ["infrastructure/vector-http"]
|
||||
vector-onnx = ["infrastructure/vector-onnx"]
|
||||
6398
crates/backend/src/lib.rs
Normal file
6398
crates/backend/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
317
crates/backend/src/mcp_endpoint.rs
Normal file
317
crates/backend/src/mcp_endpoint.rs
Normal file
@ -0,0 +1,317 @@
|
||||
//! Per-project loopback **endpoint** for the IdeA MCP transport (M5a).
|
||||
//!
|
||||
//! ## Where this sits in the bind (cadrage v5 §1, §2)
|
||||
//!
|
||||
//! The S-MCP transport is **stdio-spawn**: an MCP CLI (Claude Code, Codex)
|
||||
//! reads a `{command,args}` declaration and *spawns* a thin `idea mcp-server`
|
||||
//! bridge. That bridge talks JSON-RPC on its stdin/stdout to the CLI and relays
|
||||
//! every line, over a **local loopback**, to the IdeA desktop/server process where the
|
||||
//! real [`OrchestratorService`](application::OrchestratorService) lives. The
|
||||
//! loopback is a **Unix domain socket** (Linux/macOS) or a **Windows named
|
||||
//! pipe** — never a network port, so it stays AppImage- and SSH-remote-safe and
|
||||
//! needs no firewall/permission.
|
||||
//!
|
||||
//! This module owns the **single source of truth** for that loopback address:
|
||||
//! [`mcp_endpoint`]. It is deterministic per [`ProjectId`] and is called by
|
||||
//! **both** sides of the contract (cadrage §2):
|
||||
//! - the side that *listens* — [`ensure_mcp_server`](crate::state::AppState::ensure_mcp_server),
|
||||
//! which binds the listener at project-open and tears it down at close (M5a);
|
||||
//! - the side that *writes the CLI declaration* — `apply_mcp_config` (M5d),
|
||||
//! which must point the spawned bridge at the **exact** same address.
|
||||
//!
|
||||
//! Keeping the derivation here (not duplicated on each side) is what makes the
|
||||
//! M1↔M3 coherence test possible.
|
||||
//!
|
||||
//! ## Transport crate choice — `interprocess`
|
||||
//!
|
||||
//! We use the [`interprocess`] crate's *local socket* abstraction rather than
|
||||
//! `tokio::net::{UnixListener, windows::named_pipe}` directly, because:
|
||||
//! - **One cross-OS API** for UDS (Unix) and named pipes (Windows): a single
|
||||
//! `accept` loop in M5c, no divergent `cfg` branches with two transport types.
|
||||
//! - The workspace `tokio` is built **without** the `net` feature; pulling
|
||||
//! `interprocess` (with its `tokio` feature) into *only* this crate avoids
|
||||
//! widening tokio's surface workspace-wide.
|
||||
//! - On Unix its listener carries a *reclaim guard* that **unlinks the socket
|
||||
//! file on drop** — so closing a project (dropping the handle) cleans the
|
||||
//! filesystem with no leak, satisfying M5a's teardown requirement for free.
|
||||
//!
|
||||
//! We deliberately bind a **filesystem-path** socket on Unix (under a per-user
|
||||
//! runtime dir) rather than an abstract/namespaced one, so that the existence of
|
||||
//! the endpoint is observable as a real path (testable) and cleaned up on close.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use application::{McpRuntime, McpRuntimeProvider};
|
||||
use domain::{AgentId, Project, ProjectId};
|
||||
|
||||
/// The loopback address of a project's MCP endpoint — the value [`mcp_endpoint`]
|
||||
/// returns. Deterministic per [`ProjectId`]; the single source of truth shared by
|
||||
/// the listener (M5a/M3) and the CLI-declaration writer (M5d).
|
||||
///
|
||||
/// On Unix it is a **filesystem path** to a Unix-domain socket (the file is what
|
||||
/// gets bound and, on close, unlinked). On Windows it is a **named pipe** path of
|
||||
/// the form `\\.\pipe\idea-mcp-<id>`. The [`as_cli_arg`](Self::as_cli_arg) form is
|
||||
/// the exact string handed to the spawned bridge via `--endpoint`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpEndpoint {
|
||||
/// The platform-native address string (UDS path / named-pipe path).
|
||||
addr: String,
|
||||
}
|
||||
|
||||
impl McpEndpoint {
|
||||
/// The address as the bridge expects it on the command line (`--endpoint`).
|
||||
/// Identical string on both sides of the contract (cadrage §2).
|
||||
#[must_use]
|
||||
pub fn as_cli_arg(&self) -> &str {
|
||||
&self.addr
|
||||
}
|
||||
|
||||
/// The Unix socket **file path**, when this endpoint is a filesystem-path UDS.
|
||||
/// Used by the listener to ensure the parent runtime dir exists and by tests to
|
||||
/// assert creation/cleanup. `None` on platforms whose address is not a path
|
||||
/// (Windows named pipes have no filesystem entry to manage).
|
||||
#[must_use]
|
||||
pub fn socket_path(&self) -> Option<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
Some(PathBuf::from(&self.addr))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory under which per-project Unix sockets live, derived from the
|
||||
/// per-user runtime dir (`$XDG_RUNTIME_DIR`, falling back to `$TMPDIR`, then
|
||||
/// `/tmp`). Kept short so the full socket path stays well under the ~108-byte
|
||||
/// `sockaddr_un` limit (`/run/user/<uid>/idea-mcp/<32 hex>.sock` ≈ 50 bytes).
|
||||
///
|
||||
/// The candidate bases are tried **in priority order**, returning the first whose
|
||||
/// `idea-mcp` subdir exists or can be created. A set-but-unusable `$XDG_RUNTIME_DIR`
|
||||
/// (e.g. a sandbox/CI where it points at a non-existent or read-only path) must not
|
||||
/// dead-end the loopback bind: without this fallback the socket silently never
|
||||
/// binds and inter-agent delegation dies. Deterministic for a given environment, so
|
||||
/// the bind side and any `socket_path()` reader always agree on the same directory.
|
||||
#[cfg(unix)]
|
||||
fn unix_runtime_dir() -> PathBuf {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let dir = std::env::temp_dir().join("idea-mcp");
|
||||
if dir.is_dir() || std::fs::create_dir_all(&dir).is_ok() {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
let candidates = [
|
||||
std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from),
|
||||
std::env::var_os("TMPDIR").map(PathBuf::from),
|
||||
Some(PathBuf::from("/tmp")),
|
||||
];
|
||||
for base in candidates.into_iter().flatten() {
|
||||
let dir = base.join("idea-mcp");
|
||||
if dir.is_dir() || std::fs::create_dir_all(&dir).is_ok() {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
// Last resort: keep the historical `/tmp` target even if creation just failed
|
||||
// (the bind will surface the real error rather than silently picking nowhere).
|
||||
PathBuf::from("/tmp").join("idea-mcp")
|
||||
}
|
||||
|
||||
/// **Single source of truth.** Computes the deterministic loopback endpoint of a
|
||||
/// project's MCP transport from its [`ProjectId`].
|
||||
///
|
||||
/// Same input ⇒ same output (stable across calls and processes); distinct projects
|
||||
/// ⇒ distinct endpoints (the id's 32-hex *simple* form is unique and collision-free
|
||||
/// by construction). No network port is ever used.
|
||||
///
|
||||
/// - **Unix**: `<runtime-dir>/idea-mcp/<id>.sock` — a UDS file path.
|
||||
/// - **Windows**: `\\.\pipe\idea-mcp-<id>` — a named pipe.
|
||||
///
|
||||
/// where `<id>` is the project UUID in hyphen-free hex (`simple`) form.
|
||||
#[must_use]
|
||||
pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint {
|
||||
// Hyphen-free, lowercase, fixed-width (32 chars): safe in both a filename and
|
||||
// a `\\.\pipe\` name, and unique per project.
|
||||
let id = project_id.as_uuid().simple().to_string();
|
||||
|
||||
#[cfg(unix)]
|
||||
let addr = unix_runtime_dir()
|
||||
.join(format!("{id}.sock"))
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
#[cfg(windows)]
|
||||
let addr = format!(r"\\.\pipe\idea-mcp-{id}");
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let addr = format!("idea-mcp-{id}");
|
||||
|
||||
McpEndpoint { addr }
|
||||
}
|
||||
|
||||
/// Chemin du binaire IdeA à inscrire comme `command` dans la déclaration MCP.
|
||||
///
|
||||
/// Privilégie `$APPIMAGE` (chemin **stable** du `.AppImage`, qui survit aux
|
||||
/// redémarrages) car sous AppImage `current_exe()` pointe sur un montage éphémère
|
||||
/// `/tmp/.mount_*` qui disparaît au redémarrage ⇒ `ENOENT` au respawn du pont MCP.
|
||||
/// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si
|
||||
/// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale.
|
||||
pub(crate) fn idea_exe_path() -> Option<String> {
|
||||
std::env::var("APPIMAGE").ok().or_else(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// Implementation of the [`McpRuntimeProvider`] port: fournit à
|
||||
/// l'orchestrateur (couche `application`) les **faits OS/runtime** nécessaires pour
|
||||
/// écrire la déclaration MCP réelle quand il (re)lance une cible sur le chemin
|
||||
/// `ask` (`ensure_live_pty`).
|
||||
///
|
||||
/// Sans état : tout est dérivé du `Project` et de l'`AgentId` reçus + de
|
||||
/// l'environnement (`$APPIMAGE`/`current_exe`). C'est le **seul** détenteur légitime
|
||||
/// de ces faits (la couche `application` ne doit pas dépendre de `current_exe` /
|
||||
/// `$APPIMAGE` / `mcp_endpoint`, cadrage v5 §0.3 / §7) ; seules des **chaînes**
|
||||
/// traversent la frontière via [`McpRuntime`].
|
||||
pub struct AppMcpRuntimeProvider;
|
||||
|
||||
impl McpRuntimeProvider for AppMcpRuntimeProvider {
|
||||
/// `agent_id` = la cible relancée = le `--requester` (c'est elle qui appellera
|
||||
/// `idea_reply`). Réutilise la **même** logique que le chemin GUI
|
||||
/// (`commands.rs::launch_agent`) : même endpoint (source de vérité unique),
|
||||
/// même forme `simple` 32-hex du `project_id`. `None` (exe introuvable) ⇒
|
||||
/// l'orchestrateur dégrade vers la déclaration minimale, jamais d'échec de
|
||||
/// lancement.
|
||||
fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option<McpRuntime> {
|
||||
Some(McpRuntime {
|
||||
exe: idea_exe_path()?,
|
||||
endpoint: mcp_endpoint(&project.id).as_cli_arg().to_owned(),
|
||||
project_id: project.id.as_uuid().simple().to_string(),
|
||||
requester: agent_id.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn pid(s: &str) -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::parse_str(s).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_is_deterministic_for_the_same_project() {
|
||||
let p = pid("11111111-1111-1111-1111-111111111111");
|
||||
assert_eq!(mcp_endpoint(&p), mcp_endpoint(&p));
|
||||
assert_eq!(mcp_endpoint(&p).as_cli_arg(), mcp_endpoint(&p).as_cli_arg());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_projects_get_distinct_endpoints() {
|
||||
let a = pid("11111111-1111-1111-1111-111111111111");
|
||||
let b = pid("22222222-2222-2222-2222-222222222222");
|
||||
assert_ne!(mcp_endpoint(&a), mcp_endpoint(&b));
|
||||
assert_ne!(mcp_endpoint(&a).as_cli_arg(), mcp_endpoint(&b).as_cli_arg());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_encodes_the_project_id_without_hyphens() {
|
||||
let p = pid("abcdef01-2345-6789-abcd-ef0123456789");
|
||||
let arg = mcp_endpoint(&p).as_cli_arg().to_owned();
|
||||
assert!(
|
||||
arg.contains("abcdef0123456789abcdef0123456789"),
|
||||
"endpoint must embed the hyphen-free id, got {arg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unix_endpoint_is_a_sock_path_under_the_runtime_dir() {
|
||||
let p = pid("11111111-1111-1111-1111-111111111111");
|
||||
let ep = mcp_endpoint(&p);
|
||||
let path = ep
|
||||
.socket_path()
|
||||
.expect("unix endpoint exposes a socket path");
|
||||
assert!(path.extension().is_some_and(|e| e == "sock"));
|
||||
assert_eq!(path.parent().unwrap(), unix_runtime_dir());
|
||||
}
|
||||
|
||||
/// `idea_exe_path()` : `$APPIMAGE` posé ⇒ on renvoie sa valeur (chemin stable du
|
||||
/// `.AppImage`, qui survit aux redémarrages) ; absent ⇒ on retombe sur
|
||||
/// `current_exe()`. Les deux assertions sont regroupées dans **un seul** test pour
|
||||
/// éviter une course inter-tests sur la variable d'env globale du process ; la var
|
||||
/// est sauvegardée/restaurée pour ne pas polluer les autres tests.
|
||||
#[test]
|
||||
fn idea_exe_path_prefers_appimage_then_falls_back_to_current_exe() {
|
||||
let saved = std::env::var_os("APPIMAGE");
|
||||
|
||||
// APPIMAGE posé ⇒ valeur exacte renvoyée.
|
||||
std::env::set_var("APPIMAGE", "/opt/idea/IdeA.AppImage");
|
||||
assert_eq!(
|
||||
idea_exe_path().as_deref(),
|
||||
Some("/opt/idea/IdeA.AppImage"),
|
||||
"$APPIMAGE doit primer"
|
||||
);
|
||||
|
||||
// APPIMAGE absent ⇒ repli sur current_exe() (présent dans un binaire de test).
|
||||
std::env::remove_var("APPIMAGE");
|
||||
let fallback = idea_exe_path();
|
||||
let expected = std::env::current_exe()
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().into_owned());
|
||||
assert_eq!(fallback, expected, "repli attendu sur current_exe()");
|
||||
assert!(fallback.is_some(), "current_exe() résolvable dans le test");
|
||||
|
||||
// Restauration de l'état initial de la variable.
|
||||
match saved {
|
||||
Some(v) => std::env::set_var("APPIMAGE", v),
|
||||
None => std::env::remove_var("APPIMAGE"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `AppMcpRuntimeProvider::runtime_for` : cohérence des champs avec les sources de
|
||||
/// vérité — endpoint identique à `mcp_endpoint(project.id).as_cli_arg()`,
|
||||
/// `project_id` en forme `simple` 32-hex, `requester == agent_id.to_string()`.
|
||||
#[test]
|
||||
fn app_provider_runtime_for_matches_sources_of_truth() {
|
||||
use domain::project::ProjectPath;
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::{AgentId, Project, ProjectId};
|
||||
|
||||
let project = Project::new(
|
||||
ProjectId::from_uuid(Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap()),
|
||||
"demo",
|
||||
ProjectPath::new("/home/me/proj").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap();
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(42));
|
||||
|
||||
// On s'assure que current_exe/$APPIMAGE est résolvable (sinon runtime_for None).
|
||||
let rt = AppMcpRuntimeProvider
|
||||
.runtime_for(&project, agent_id)
|
||||
.expect("runtime_for doit produire un McpRuntime (exe résolvable)");
|
||||
|
||||
// Endpoint = même source de vérité que le listener.
|
||||
assert_eq!(
|
||||
rt.endpoint,
|
||||
mcp_endpoint(&project.id).as_cli_arg(),
|
||||
"endpoint cohérent avec mcp_endpoint()"
|
||||
);
|
||||
// project_id en forme simple 32-hex (hyphen-free), consommée par la garde M5c.
|
||||
assert_eq!(rt.project_id, project.id.as_uuid().simple().to_string());
|
||||
assert_eq!(rt.project_id.len(), 32, "forme simple 32-hex");
|
||||
assert!(!rt.project_id.contains('-'), "pas de tirets");
|
||||
// requester = l'id de la cible relancée.
|
||||
assert_eq!(rt.requester, agent_id.to_string());
|
||||
// exe non vide.
|
||||
assert!(!rt.exe.is_empty(), "exe renseigné");
|
||||
}
|
||||
}
|
||||
149
crates/backend/src/openai_tools.rs
Normal file
149
crates/backend/src/openai_tools.rs
Normal file
@ -0,0 +1,149 @@
|
||||
//! ToolInvoker for the OpenAI-compatible adapter.
|
||||
//!
|
||||
//! C'est la porte locale qui donne aux modèles HTTP la même surface `idea_*` que
|
||||
//! le serveur MCP : même catalogue, même mapping en `OrchestratorCommand`, même
|
||||
//! `OrchestratorService::dispatch`.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::OrchestratorService;
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{ProjectStore, ToolInvocationError, ToolInvoker, ToolSpec};
|
||||
use serde_json::Value;
|
||||
|
||||
const PROJECT_ROOT_ARG: &str = "__ideaProjectRoot";
|
||||
const REQUESTER_ARG: &str = "__ideaRequester";
|
||||
|
||||
/// Invoker d'outils OpenAI-compatible branché sur l'orchestrateur applicatif.
|
||||
pub struct AppOpenAiToolInvoker {
|
||||
orchestrator: Arc<OrchestratorService>,
|
||||
projects: Arc<dyn ProjectStore>,
|
||||
}
|
||||
|
||||
/// Proxy injecté avant que l'orchestrateur soit construit, puis lié dans la
|
||||
/// composition root. Il casse uniquement le cycle de wiring, pas le contrat runtime.
|
||||
#[derive(Default)]
|
||||
pub struct LateBoundOpenAiToolInvoker {
|
||||
inner: Mutex<Option<Arc<dyn ToolInvoker>>>,
|
||||
}
|
||||
|
||||
impl LateBoundOpenAiToolInvoker {
|
||||
/// Construit un proxy vide.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lie l'implémentation réelle. Appelé une fois par la composition root.
|
||||
pub fn bind(&self, inner: Arc<dyn ToolInvoker>) {
|
||||
*self.inner.lock().expect("mutex sain") = Some(inner);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolInvoker for LateBoundOpenAiToolInvoker {
|
||||
fn tools(&self) -> Vec<ToolSpec> {
|
||||
self.inner
|
||||
.lock()
|
||||
.expect("mutex sain")
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |inner| inner.tools())
|
||||
}
|
||||
|
||||
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
|
||||
let inner = self
|
||||
.inner
|
||||
.lock()
|
||||
.expect("mutex sain")
|
||||
.clone()
|
||||
.ok_or_else(|| {
|
||||
ToolInvocationError::Execution("ToolInvoker OpenAI non initialisé".to_owned())
|
||||
})?;
|
||||
inner.call(name, args_json).await
|
||||
}
|
||||
}
|
||||
|
||||
impl AppOpenAiToolInvoker {
|
||||
/// Construit l'invoker depuis le service orchestrateur et le store projet.
|
||||
#[must_use]
|
||||
pub fn new(orchestrator: Arc<OrchestratorService>, projects: Arc<dyn ProjectStore>) -> Self {
|
||||
Self {
|
||||
orchestrator,
|
||||
projects,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolInvoker for AppOpenAiToolInvoker {
|
||||
fn tools(&self) -> Vec<ToolSpec> {
|
||||
infrastructure::orchestrator::mcp::catalogue()
|
||||
.into_iter()
|
||||
.map(|tool| ToolSpec {
|
||||
name: tool.name.to_owned(),
|
||||
description: tool.description.to_owned(),
|
||||
input_schema: tool.input_schema,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
|
||||
let value: Value = serde_json::from_str(args_json)
|
||||
.map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?;
|
||||
let args = value.as_object().ok_or_else(|| {
|
||||
ToolInvocationError::InvalidArguments(
|
||||
"les arguments d'outil doivent être un objet JSON".to_owned(),
|
||||
)
|
||||
})?;
|
||||
let project_root = args
|
||||
.get(PROJECT_ROOT_ARG)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
ToolInvocationError::InvalidArguments(
|
||||
"contexte projet interne absent pour l'outil".to_owned(),
|
||||
)
|
||||
})?;
|
||||
let requester = args
|
||||
.get(REQUESTER_ARG)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| {
|
||||
ToolInvocationError::InvalidArguments(
|
||||
"identité requester interne absente pour l'outil".to_owned(),
|
||||
)
|
||||
})?;
|
||||
let project = self
|
||||
.projects
|
||||
.list_projects()
|
||||
.await
|
||||
.map_err(|e| ToolInvocationError::Execution(e.to_string()))?
|
||||
.into_iter()
|
||||
.find(|project| project.root.as_str() == project_root)
|
||||
.ok_or_else(|| {
|
||||
ToolInvocationError::Execution(format!(
|
||||
"projet introuvable pour root `{project_root}`"
|
||||
))
|
||||
})?;
|
||||
let command = infrastructure::orchestrator::mcp::map_tool_call(name, &value, requester)
|
||||
.map_err(|e| match e {
|
||||
infrastructure::orchestrator::mcp::ToolMapError::UnknownTool(tool) => {
|
||||
ToolInvocationError::NotFound(tool)
|
||||
}
|
||||
infrastructure::orchestrator::mcp::ToolMapError::BadArguments(tool) => {
|
||||
ToolInvocationError::InvalidArguments(format!(
|
||||
"arguments invalides pour `{tool}`"
|
||||
))
|
||||
}
|
||||
infrastructure::orchestrator::mcp::ToolMapError::Invalid(err) => {
|
||||
ToolInvocationError::InvalidArguments(err.to_string())
|
||||
}
|
||||
})?;
|
||||
let outcome = self
|
||||
.orchestrator
|
||||
.dispatch(&project, command)
|
||||
.await
|
||||
.map_err(|e| ToolInvocationError::Execution(e.to_string()))?;
|
||||
Ok(outcome.reply.unwrap_or(outcome.detail))
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
708
docs/ticket13-b0-backend-transport-inventory.md
Normal file
708
docs/ticket13-b0-backend-transport-inventory.md
Normal file
@ -0,0 +1,708 @@
|
||||
# Ticket #13 B0 - Backend transport inventory
|
||||
|
||||
Date: 2026-07-15
|
||||
Branch: `feature/ticket13-server-client-mode`
|
||||
|
||||
Scope: read-only inventory of the current Tauri backend transport surface in
|
||||
`crates/app-tauri`. No production code change is part of B0.
|
||||
|
||||
Architect ticket #13 target: keep desktop Tauri and add server/client mode on a
|
||||
shared backend core. Future web transport is HTTP request/response plus a
|
||||
dedicated WebSocket for PTY/live streams.
|
||||
|
||||
## Source anchors
|
||||
|
||||
- Tauri command registration: `crates/app-tauri/src/lib.rs:153`
|
||||
- Composition root and `AppState`: `crates/app-tauri/src/state.rs:738`,
|
||||
`crates/app-tauri/src/state.rs:1058`
|
||||
- PTY Tauri Channel bridge: `crates/app-tauri/src/pty.rs:1`
|
||||
- Structured chat Tauri Channel bridge: `crates/app-tauri/src/chat.rs:1`
|
||||
- Domain event relay: `crates/app-tauri/src/events.rs:23`,
|
||||
`crates/app-tauri/src/events.rs:1107`
|
||||
- Desktop window commands/events: `crates/app-tauri/src/commands.rs:2325`
|
||||
- Shutdown/window lifecycle wiring: `crates/app-tauri/src/lib.rs:80`
|
||||
|
||||
## Classification rules
|
||||
|
||||
- `request/response`: one Tauri `invoke` maps to one backend result. HTTP
|
||||
candidate.
|
||||
- `stream`: command uses `tauri::ipc::Channel` or controls a live stream/session.
|
||||
WebSocket candidate, sometimes with a small HTTP control equivalent.
|
||||
- `event`: backend push currently emitted through Tauri global events. WebSocket
|
||||
live/event frame candidate unless desktop-specific.
|
||||
- `desktop-only`: OS windows, Tauri path/lifecycle/dialog composition. Not part
|
||||
of the web client transport.
|
||||
|
||||
## Exposed Tauri commands
|
||||
|
||||
### Core/projects/permissions
|
||||
|
||||
| Command | Class | Current boundary |
|
||||
| --- | --- | --- |
|
||||
| `health` | request/response | `State<AppState>.health` |
|
||||
| `create_project` | request/response | `create_project` use case |
|
||||
| `open_project` | request/response | `open_project`, plus open-time reconciliation/wiring |
|
||||
| `close_project` | request/response | `close_project` |
|
||||
| `list_projects` | request/response | `list_projects` |
|
||||
| `read_project_context` | request/response | project FS read use case |
|
||||
| `update_project_context` | request/response | project FS write use case |
|
||||
| `get_project_permissions` | request/response | permission read use case |
|
||||
| `update_project_permissions` | request/response | permission write use case |
|
||||
| `update_agent_permissions` | request/response | permission override write use case |
|
||||
| `resolve_agent_permissions` | request/response | effective permission read use case |
|
||||
|
||||
### PTY terminals
|
||||
|
||||
| Command | Class | Current boundary |
|
||||
| --- | --- | --- |
|
||||
| `open_terminal` | stream | Spawns PTY, registers `Channel<PtyChunk>`, starts output pump |
|
||||
| `write_terminal` | stream/control | Writes bytes to live PTY |
|
||||
| `resize_terminal` | stream/control | Resizes live PTY |
|
||||
| `close_terminal` | stream/control | Kills PTY and unregisters channel |
|
||||
| `reattach_terminal` | stream | Reads scrollback, replaces `Channel<PtyChunk>`, starts new pump |
|
||||
|
||||
Notes:
|
||||
|
||||
- `PtyChunk = Vec<u8>` today (`crates/app-tauri/src/pty.rs:29`).
|
||||
- The transport-specific registry is `PtyBridge`, stored in `AppState`.
|
||||
- High-frequency PTY bytes are explicitly excluded from global Tauri events.
|
||||
|
||||
### Layouts
|
||||
|
||||
| Command | Class | Current boundary |
|
||||
| --- | --- | --- |
|
||||
| `load_layout` | request/response | layout read use case |
|
||||
| `mutate_layout` | request/response | layout mutation use case, emits `LayoutChanged` |
|
||||
| `list_layouts` | request/response | named layout list |
|
||||
| `create_layout` | request/response | named layout create |
|
||||
| `rename_layout` | request/response | named layout rename |
|
||||
| `delete_layout` | request/response | named layout delete |
|
||||
| `set_active_layout` | request/response | active layout write |
|
||||
|
||||
### First-run, profiles, local models, embedders
|
||||
|
||||
| Command | Class | Current boundary |
|
||||
| --- | --- | --- |
|
||||
| `first_run_state` | request/response | first-run read use case |
|
||||
| `reference_profiles` | request/response | reference profile catalog |
|
||||
| `detect_profiles` | request/response | runtime detection use case |
|
||||
| `list_profiles` | request/response | profile store read |
|
||||
| `save_profile` | request/response | profile upsert |
|
||||
| `clone_opencode_profile_from_seed` | request/response | profile clone |
|
||||
| `delete_profile` | request/response | profile delete |
|
||||
| `configure_profiles` | request/response | first-run save |
|
||||
| `list_model_servers` | request/response | local model server config read |
|
||||
| `save_model_server` | request/response | local model server config write |
|
||||
| `preview_model_server_command` | request/response | pure command preview |
|
||||
| `delete_model_server` | request/response | local model server config delete |
|
||||
| `list_embedder_profiles` | request/response | embedder profile read |
|
||||
| `save_embedder_profile` | request/response | embedder profile write |
|
||||
| `delete_embedder_profile` | request/response | embedder profile delete |
|
||||
| `describe_embedder_engines` | request/response | embedder engine capabilities |
|
||||
| `dismiss_embedder_suggestion` | request/response | suggestion state write |
|
||||
|
||||
### Agents, live state, chat and resume
|
||||
|
||||
| Command | Class | Current boundary |
|
||||
| --- | --- | --- |
|
||||
| `create_agent` | request/response | agent manifest/context creation |
|
||||
| `list_agents` | request/response | manifest read |
|
||||
| `get_project_work_state` | request/response | read-model aggregation |
|
||||
| `read_conversation_page` | request/response | paginated transcript read |
|
||||
| `list_live_agents` | request/response | in-memory live registry read |
|
||||
| `attach_live_agent` | request/response | live session rebind use case |
|
||||
| `stop_live_agent` | request/response | live session stop use case |
|
||||
| `read_agent_context` | request/response | agent Markdown read |
|
||||
| `update_agent_context` | request/response | agent Markdown write |
|
||||
| `delete_agent` | request/response | manifest/context delete |
|
||||
| `inspect_conversation` | request/response | best-effort conversation inspection |
|
||||
| `launch_agent` | stream | Spawns/reuses PTY or structured session, may register `Channel<PtyChunk>` |
|
||||
| `change_agent_profile` | request/response | hot-swap use case; may affect live session |
|
||||
| `agent_send` | stream | Structured chat turn, registers `Channel<ReplyChunk>`, pumps reply stream |
|
||||
| `cancel_resume` | request/response | cancels scheduled auto-resume |
|
||||
| `set_resume_at` | request/response | human-set resume time |
|
||||
| `interrupt_agent` | request/response | orchestrator interrupt |
|
||||
| `delegation_delivered` | request/response | frontend write-portal ack |
|
||||
| `set_front_attached` | request/response | frontend mounted/unmounted signal |
|
||||
| `reattach_agent_chat` | stream | Replaces `Channel<ReplyChunk>`, returns chat scrollback |
|
||||
| `close_agent_session` | request/response | closes live agent/structured state |
|
||||
| `list_resumable_agents` | request/response | resumable agent inventory |
|
||||
|
||||
Structured chat stream:
|
||||
|
||||
- `ReplyChunk` is tagged by `kind`: `textDelta`, `toolActivity`, `final`,
|
||||
`error` (`crates/app-tauri/src/dto.rs:1760`).
|
||||
- `ChatBridge` retains bounded scrollback and the current optional channel.
|
||||
- `agent_send` is a turn command plus streaming response; in HTTP/WS terms it
|
||||
should become a WS client frame that starts a turn on an attached session.
|
||||
|
||||
### Templates
|
||||
|
||||
| Command | Class | Current boundary |
|
||||
| --- | --- | --- |
|
||||
| `create_template` | request/response | template create |
|
||||
| `update_template` | request/response | template update, emits drift/update events |
|
||||
| `list_templates` | request/response | template list |
|
||||
| `delete_template` | request/response | template delete |
|
||||
| `create_agent_from_template` | request/response | agent creation from template |
|
||||
| `detect_agent_drift` | request/response | drift inventory |
|
||||
| `sync_agent_with_template` | request/response | sync write |
|
||||
|
||||
### Git
|
||||
|
||||
| Command | Class | Current boundary |
|
||||
| --- | --- | --- |
|
||||
| `git_status` | request/response | git status use case |
|
||||
| `git_stage` | request/response | git stage use case |
|
||||
| `git_unstage` | request/response | git unstage use case |
|
||||
| `git_commit` | request/response | git commit use case |
|
||||
| `git_branches` | request/response | branch list |
|
||||
| `git_checkout` | request/response | branch checkout |
|
||||
| `git_log` | request/response | log read |
|
||||
| `git_init` | request/response | repo init |
|
||||
| `git_graph` | request/response | graph read |
|
||||
|
||||
### Tickets and sprints
|
||||
|
||||
| Command | Class | Current boundary |
|
||||
| --- | --- | --- |
|
||||
| `ticket_create` | request/response | ticket create |
|
||||
| `ticket_read` | request/response | ticket read |
|
||||
| `ticket_delete` | request/response | ticket delete |
|
||||
| `open_ticket_chat` | request/response | ephemeral ticket assistant open |
|
||||
| `close_ticket_chat` | request/response | ephemeral ticket assistant close |
|
||||
| `ticket_list` | request/response | ticket list |
|
||||
| `ticket_update` | request/response | ticket update |
|
||||
| `ticket_read_carnet` | request/response | carnet read |
|
||||
| `ticket_update_carnet` | request/response | carnet write |
|
||||
| `ticket_link` | request/response | link tickets |
|
||||
| `ticket_unlink` | request/response | unlink tickets |
|
||||
| `ticket_assign` | request/response | assign/unassign ticket agent |
|
||||
| `sprint_create` | request/response | sprint create |
|
||||
| `sprint_list` | request/response | sprint list |
|
||||
| `sprint_rename` | request/response | sprint rename |
|
||||
| `sprint_reorder` | request/response | sprint reorder |
|
||||
| `sprint_delete` | request/response | sprint delete |
|
||||
| `ticket_assign_sprint` | request/response | ticket sprint assignment |
|
||||
| `ticket_unassign_sprint` | request/response | ticket sprint removal |
|
||||
|
||||
### Skills and memory
|
||||
|
||||
| Command | Class | Current boundary |
|
||||
| --- | --- | --- |
|
||||
| `create_skill` | request/response | skill create |
|
||||
| `update_skill` | request/response | skill update |
|
||||
| `list_skills` | request/response | skill list |
|
||||
| `delete_skill` | request/response | skill delete |
|
||||
| `assign_skill_to_agent` | request/response | skill assignment |
|
||||
| `unassign_skill_from_agent` | request/response | skill unassignment |
|
||||
| `create_memory` | request/response | memory create |
|
||||
| `update_memory` | request/response | memory update |
|
||||
| `list_memories` | request/response | memory list |
|
||||
| `get_memory` | request/response | memory read |
|
||||
| `delete_memory` | request/response | memory delete |
|
||||
| `read_memory_index` | request/response | memory index read |
|
||||
| `recall_memory` | request/response | memory recall |
|
||||
| `resolve_memory_links` | request/response | memory link resolution |
|
||||
|
||||
### Background tasks
|
||||
|
||||
| Command | Class | Current boundary |
|
||||
| --- | --- | --- |
|
||||
| `spawn_background_command` | request/response | creates command-backed background task |
|
||||
| `cancel_background_task` | request/response | cancels task |
|
||||
| `retry_background_task` | request/response | retries task |
|
||||
| `list_background_tasks` | request/response | task read-model list |
|
||||
|
||||
Lifecycle is pushed through `BackgroundTaskChanged` domain events; initial list is
|
||||
request/response.
|
||||
|
||||
### Desktop windows and focus
|
||||
|
||||
| Command | Class | Current boundary |
|
||||
| --- | --- | --- |
|
||||
| `set_focused_project` | desktop-only + event | Tauri window/panel focus state; emits `focused-project://changed` |
|
||||
| `get_focused_project` | desktop-only | Tauri panel state read |
|
||||
| `list_open_view_windows` | desktop-only | `AppHandle.webview_windows()` |
|
||||
| `open_view_window` | desktop-only | `WebviewWindowBuilder` |
|
||||
| `close_view_window` | desktop-only | closes Tauri OS window |
|
||||
| `move_tab_to_new_window` | desktop-only | use case plus `WebviewWindowBuilder` |
|
||||
|
||||
These do not migrate as-is to web. The shared backend may still keep workspace
|
||||
topology use cases, but the OS-window adapter remains desktop-only.
|
||||
|
||||
## Tauri Channel usage
|
||||
|
||||
| Location | Channel type | Producer | Consumer | Web mapping |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `open_terminal` | `Channel<PtyChunk>` | PTY output pump | xterm view | WS `output` frames after `open_terminal` |
|
||||
| `reattach_terminal` | `Channel<PtyChunk>` | PTY output pump | xterm view | WS `attached` + `output` frames after `attach_terminal` |
|
||||
| `launch_agent` | `Channel<PtyChunk>` | PTY output pump for raw CLI agents | xterm/chat terminal view | WS `output` frames; structured sessions do not use this channel |
|
||||
| `agent_send` | `Channel<ReplyChunk>` | structured reply pump | chat view | WS `chat.output` or generic live frames |
|
||||
| `reattach_agent_chat` | `Channel<ReplyChunk>` | structured reply pump | chat view | WS attached/replay for structured session |
|
||||
|
||||
Transport-owned registries:
|
||||
|
||||
- `PtyBridge`: session id -> `(generation, Channel<PtyChunk>)`.
|
||||
- `ChatBridge`: session id -> `{generation, Option<Channel<ReplyChunk>>, scrollback}`.
|
||||
|
||||
These should not descend into domain/application. Their backend-core equivalent
|
||||
should be a transport-neutral outbound sink/subscription registry.
|
||||
|
||||
## Events emitted today
|
||||
|
||||
### Global domain event relay
|
||||
|
||||
`events::spawn_relay` subscribes to `TokioBroadcastEventBus` and emits:
|
||||
|
||||
- Tauri event name `domain://event`
|
||||
- Payload `DomainEventDto`, tagged by `type`
|
||||
- Skips `DomainEvent::PtyOutput`; PTY bytes use per-session channels
|
||||
|
||||
Current `DomainEventDto` variants:
|
||||
|
||||
- Project/lifecycle: `projectCreated`, `layoutChanged`, `remoteConnected`
|
||||
- Agents: `agentLaunched`, `agentLaunchFailed`, `agentExited`,
|
||||
`agentBusyChanged`, `agentProfileChanged`, `agentLivenessChanged`,
|
||||
`agentRateLimited`, `agentResumeScheduled`, `agentResumeCancelled`,
|
||||
`agentResumed`, `agentRateLimitSuspected`
|
||||
- Delegation/orchestration: `delegationReady`, `agentReplied`,
|
||||
`agentAnnouncement`, `orchestratorRequestProcessed`, `orchestratorChanged`
|
||||
- Background work: `backgroundTaskChanged`, `agentInboxChanged`,
|
||||
`agentWakeChanged`
|
||||
- Templates/skills: `templateUpdated`, `agentDriftDetected`, `agentSynced`,
|
||||
`skillAssigned`
|
||||
- Git: `gitStateChanged`
|
||||
- Tickets/sprints: `issueCreated`, `issueUpdated`, `issueDeleted`,
|
||||
`issueStatusChanged`, `issuePriorityChanged`, `issueCarnetUpdated`,
|
||||
`issueLinked`, `issueUnlinked`, `issueAgentAssigned`,
|
||||
`issueAgentUnassigned`, `sprintCreated`, `sprintRenamed`,
|
||||
`sprintReordered`, `sprintDeleted`, `issueSprintChanged`
|
||||
- Memory/embedder: `memorySaved`, `memoryDeleted`, `memoryIndexRebuilt`,
|
||||
`embedderSuggested`
|
||||
- Model server: `modelServerStatusChanged`
|
||||
- PTY placeholder: `ptyOutput` exists in DTO but is not emitted by the global
|
||||
relay
|
||||
|
||||
### Dedicated global events
|
||||
|
||||
- `model_server_status_changed`: emitted in addition to `domain://event` for
|
||||
`ModelServerStatusChanged`.
|
||||
- `agent_launch_failed`: emitted in addition to `domain://event` for
|
||||
`AgentLaunchFailed`.
|
||||
- `view-window://lifecycle`: desktop-only, payload `{kind, panel, label}`.
|
||||
- `focused-project://changed`: desktop-only, payload `{project}`.
|
||||
|
||||
For web, the domain-event set maps naturally to a low-frequency live WS stream.
|
||||
The duplicate dedicated events should be normalized in the shared core contract
|
||||
or kept as compatibility aliases only in the Tauri adapter.
|
||||
|
||||
## `tauri::State<AppState>` composition root inventory
|
||||
|
||||
`AppState::build(app_data_dir)` is the current composition root. It constructs
|
||||
concrete adapters and use cases, then Tauri stores it through `app.manage`.
|
||||
|
||||
### Concrete adapters and infra state currently built there
|
||||
|
||||
- `TokioBroadcastEventBus`
|
||||
- `SystemClock`
|
||||
- `UuidGenerator`
|
||||
- `LocalFileSystem`
|
||||
- `FsProjectStore`
|
||||
- `FsWindowStateStore`
|
||||
- `PortablePtyAdapter` with sandbox enforcer
|
||||
- `TerminalSessions`
|
||||
- `StructuredSessions`
|
||||
- `StructuredSessionFactory`
|
||||
- `LocalProcessSpawner`
|
||||
- `CliAgentRuntime`
|
||||
- `FsProfileStore`
|
||||
- local model server manager/use cases
|
||||
- issue/ticket stores and providers
|
||||
- `PtyBridge` and `ChatBridge` transport bridges
|
||||
- profile/template/git/skill/memory/embedder stores and use cases
|
||||
- orchestrator service, watchers and per-project MCP servers
|
||||
- session-limit service, resume contexts and turn watcher
|
||||
- background task runner/store
|
||||
- focused project mutex
|
||||
- home/app-data derived paths
|
||||
|
||||
### Boundary that must be extracted
|
||||
|
||||
Current shape:
|
||||
|
||||
```text
|
||||
React -> @tauri invoke/listen/Channel
|
||||
-> app-tauri commands/events/bridges
|
||||
-> AppState concrete composition root
|
||||
-> application use cases + infrastructure adapters
|
||||
```
|
||||
|
||||
Target shape:
|
||||
|
||||
```text
|
||||
React -> TS gateways
|
||||
-> Tauri adapter OR HTTP+WS adapter
|
||||
-> driving adapter calls shared backend core facade
|
||||
-> application use cases + infrastructure adapters
|
||||
```
|
||||
|
||||
The extraction line is inside `app-tauri::state`:
|
||||
|
||||
- Move reusable construction into a shared backend core crate/facade, e.g.
|
||||
`BackendCore::build(config)`.
|
||||
- Keep Tauri-only concerns in `app-tauri`: resolving `app_data_dir` via Tauri
|
||||
path API, `app.manage`, command registration, `AppHandle`/`WebviewWindow`,
|
||||
shutdown hooks, Tauri event emission, Tauri `Channel` bridges, dialog plugin.
|
||||
- The future web server adapter should own HTTP route registration, TLS/pairing
|
||||
integration, WS connection lifecycle, CORS/origin checks and WS sink mapping.
|
||||
- The core should expose transport-neutral methods for the request/response
|
||||
use cases and transport-neutral subscription/sink APIs for PTY/chat/live
|
||||
output.
|
||||
|
||||
Do not move `PtyBridge` or `ChatBridge` as-is. They are Tauri transport
|
||||
adapters. Move the underlying concept: attach/detach a sink to a live
|
||||
session, replay bounded scrollback, and deliver ordered output.
|
||||
|
||||
## HTTP DTO first draft
|
||||
|
||||
Naming below keeps current DTO semantics and camelCase fields. Exact paths are a
|
||||
proposal for B1/B3; they can be namespaced by project where useful.
|
||||
|
||||
### Request/response examples
|
||||
|
||||
```http
|
||||
GET /api/health
|
||||
POST /api/projects
|
||||
GET /api/projects
|
||||
POST /api/projects/{projectId}/open
|
||||
POST /api/projects/{projectId}/close
|
||||
GET /api/projects/{projectId}/context
|
||||
PUT /api/projects/{projectId}/context
|
||||
GET /api/projects/{projectId}/layout
|
||||
POST /api/projects/{projectId}/layout/mutate
|
||||
GET /api/projects/{projectId}/agents
|
||||
POST /api/projects/{projectId}/agents
|
||||
GET /api/projects/{projectId}/agents/{agentId}/context
|
||||
PUT /api/projects/{projectId}/agents/{agentId}/context
|
||||
DELETE /api/projects/{projectId}/agents/{agentId}
|
||||
GET /api/projects/{projectId}/work-state
|
||||
GET /api/projects/{projectId}/conversation-page
|
||||
GET /api/projects/{projectId}/git/status
|
||||
POST /api/projects/{projectId}/git/stage
|
||||
POST /api/projects/{projectId}/git/commit
|
||||
GET /api/tickets
|
||||
POST /api/tickets
|
||||
GET /api/tickets/{ref}
|
||||
PATCH /api/tickets/{ref}
|
||||
GET /api/templates
|
||||
POST /api/templates
|
||||
GET /api/background-tasks
|
||||
POST /api/background-tasks
|
||||
```
|
||||
|
||||
Errors should preserve `ErrorDto`:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "NOT_FOUND",
|
||||
"message": "project ..."
|
||||
}
|
||||
```
|
||||
|
||||
## WebSocket first draft
|
||||
|
||||
One dedicated WS endpoint is enough for B5 V1:
|
||||
|
||||
```text
|
||||
GET /ws/live?token=... (token normally via Authorization/cookie, not URL in prod)
|
||||
```
|
||||
|
||||
Authentication note from ticket #13: pairing/token must travel only over HTTPS
|
||||
and not as a URL secret. The query above is only a sketch; production should use
|
||||
`Authorization: Bearer` during WS upgrade or an HTTPS-only secure cookie.
|
||||
|
||||
### Common envelope
|
||||
|
||||
Client to server:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "client-msg-uuid",
|
||||
"kind": "terminal.input",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
Server to client:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "server-msg-uuid",
|
||||
"replyTo": "client-msg-uuid",
|
||||
"kind": "terminal.status",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
`replyTo` is present for command acknowledgements, absent for unsolicited live
|
||||
events/output.
|
||||
|
||||
### PTY client -> server frames
|
||||
|
||||
`attach_terminal`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "1",
|
||||
"kind": "terminal.attach",
|
||||
"payload": {
|
||||
"sessionId": "uuid",
|
||||
"lastSeq": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`open_terminal`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "2",
|
||||
"kind": "terminal.open",
|
||||
"payload": {
|
||||
"projectId": "uuid",
|
||||
"nodeId": "uuid-or-null",
|
||||
"cwd": "/abs/path",
|
||||
"rows": 30,
|
||||
"cols": 120
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`launch_agent`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "3",
|
||||
"kind": "agent.launch",
|
||||
"payload": {
|
||||
"projectId": "uuid",
|
||||
"agentId": "uuid",
|
||||
"nodeId": "uuid-or-null",
|
||||
"rows": 30,
|
||||
"cols": 120,
|
||||
"conversationId": "engine-conversation-id-or-null"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`input`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "4",
|
||||
"kind": "terminal.input",
|
||||
"payload": {
|
||||
"sessionId": "uuid",
|
||||
"bytesBase64": "DQ=="
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`resize`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "5",
|
||||
"kind": "terminal.resize",
|
||||
"payload": {
|
||||
"sessionId": "uuid",
|
||||
"rows": 40,
|
||||
"cols": 140
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`detach`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "6",
|
||||
"kind": "terminal.detach",
|
||||
"payload": {
|
||||
"sessionId": "uuid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`close`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "7",
|
||||
"kind": "terminal.close",
|
||||
"payload": {
|
||||
"sessionId": "uuid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`ping`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "8",
|
||||
"kind": "ping",
|
||||
"payload": {
|
||||
"atMs": 1784123456789
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PTY server -> client frames
|
||||
|
||||
`attached`
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "terminal.attached",
|
||||
"replyTo": "1",
|
||||
"payload": {
|
||||
"session": { "sessionId": "uuid", "projectId": "uuid", "nodeId": "uuid", "rows": 30, "cols": 120 },
|
||||
"nextSeq": 43,
|
||||
"scrollback": [
|
||||
{ "seq": 1, "bytesBase64": "..." }
|
||||
],
|
||||
"gap": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`output`
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "terminal.output",
|
||||
"payload": {
|
||||
"sessionId": "uuid",
|
||||
"seq": 43,
|
||||
"bytesBase64": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`status`
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "terminal.status",
|
||||
"payload": {
|
||||
"sessionId": "uuid",
|
||||
"status": "running"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Suggested status values: `starting`, `running`, `exited`, `closed`,
|
||||
`detached`, `reattached`.
|
||||
|
||||
`error`
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "error",
|
||||
"replyTo": "7",
|
||||
"payload": {
|
||||
"code": "NOT_FOUND",
|
||||
"message": "terminal session ..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`pong`
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "pong",
|
||||
"replyTo": "8",
|
||||
"payload": {
|
||||
"atMs": 1784123456789
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Structured chat frames
|
||||
|
||||
The ticket #13 PTY contract does not require structured chat in B5, but the
|
||||
current backend already has a second stream. To avoid a later incompatibility,
|
||||
reserve a parallel namespace:
|
||||
|
||||
Client:
|
||||
|
||||
```json
|
||||
{ "id": "9", "kind": "chat.attach", "payload": { "sessionId": "uuid" } }
|
||||
{ "id": "10", "kind": "chat.send", "payload": { "sessionId": "uuid", "prompt": "..." } }
|
||||
{ "id": "11", "kind": "chat.detach", "payload": { "sessionId": "uuid" } }
|
||||
```
|
||||
|
||||
Server:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "chat.attached",
|
||||
"replyTo": "9",
|
||||
"payload": {
|
||||
"sessionId": "uuid",
|
||||
"scrollback": [
|
||||
{ "kind": "textDelta", "text": "..." }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "chat.output",
|
||||
"payload": {
|
||||
"sessionId": "uuid",
|
||||
"chunk": { "kind": "final", "content": "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Low-frequency live events
|
||||
|
||||
Domain events can be pushed on the same WS connection:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "event.domain",
|
||||
"payload": {
|
||||
"type": "agentBusyChanged",
|
||||
"agentId": "uuid",
|
||||
"busy": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This replaces Tauri `listen("domain://event")` for web. Desktop can keep the
|
||||
existing event relay.
|
||||
|
||||
## Plan impact / risks found
|
||||
|
||||
No finding invalidates the ticket #13 lot plan.
|
||||
|
||||
Points to decide early in B1/B2:
|
||||
|
||||
- The duplicate Tauri registration of `detect_agent_drift` in
|
||||
`generate_handler!` is harmless for B0 but should be cleaned in a separate fix.
|
||||
- `open_project` currently also triggers open-time reconciliation and per-project
|
||||
runtime side effects. The shared backend core facade should make those explicit
|
||||
methods, not hide them in transport commands.
|
||||
- `launch_agent` has both raw PTY and structured-session branches. B5 PTY V1 can
|
||||
implement raw PTY first, but the WS namespace should reserve chat/live frames.
|
||||
- `PtyBridge` and `ChatBridge` contain transport scrollback/replay behavior.
|
||||
Extract the behavior as a core subscription contract; do not leak Tauri
|
||||
`Channel` into the future core.
|
||||
- Desktop window/focus commands are cleanly separable as desktop-only. Workspace
|
||||
topology can remain shared, OS-window creation cannot.
|
||||
158
docs/ticket13-f0-frontend-transport-inventory.md
Normal file
158
docs/ticket13-f0-frontend-transport-inventory.md
Normal file
@ -0,0 +1,158 @@
|
||||
# Ticket #13 — Lot B0/F0 : inventaire du transport frontend (TS/React)
|
||||
|
||||
> Livrable d'inventaire. **Aucun code de feature modifié.** Miroir frontend de
|
||||
> l'inventaire backend (`ticket13-b0-backend-transport-inventory.md`).
|
||||
> Objectif : cartographier tous les accès `@tauri-apps/api`, les classer par nature
|
||||
> (request/response · stream · event · desktop-only), et localiser la frontière
|
||||
> « gateways TS transport-neutres » du lot F1 (adapter Tauri desktop | adapter
|
||||
> HTTP+WS web).
|
||||
|
||||
## 0. Constat majeur — la frontière F1 existe déjà (à ~90 %)
|
||||
|
||||
Le frontend est **déjà** structuré exactement comme le plan cible le demande :
|
||||
|
||||
- **Ports transport-neutres** : `src/ports/index.ts` déclare 21 gateways (interfaces
|
||||
TS) décrivant *ce dont l'UI a besoin*, sans aucune référence à Tauri. Toute la
|
||||
couche `features/` + `app/` dépend de ces ports via DI (`useGateways()`).
|
||||
- **Adapters = seul lieu Tauri** : `src/adapters/*` implémente les ports via
|
||||
`@tauri-apps/api`. C'est le **seul** répertoire autorisé à importer Tauri.
|
||||
- **Garde d'architecture L1 active** : `src/app/no-direct-invoke.test.ts` échoue en CI
|
||||
si un fichier hors `src/adapters` importe `@tauri-apps/api` **ou** appelle `invoke(`.
|
||||
Cette garde est notre filet de sécurité pour F1 : elle garantit qu'aucun composant
|
||||
n'appelle Tauri en dur — donc **aucun composant métier à réécrire** pour le web.
|
||||
- **Composition déjà bifurquée** : `src/app/di.tsx` → `resolveGateways()` choisit
|
||||
`createTauriGateways()` vs `createMockGateways()` selon `VITE_USE_MOCK`. F1 ajoute
|
||||
simplement une **3ᵉ implémentation** (`createHttpWsGateways()`) au même seam.
|
||||
|
||||
**Conséquence sur le plan de lots** : F1 n'est *pas* une extraction de frontière
|
||||
(elle est faite), mais l'écriture d'un **second jeu d'adapters** derrière des ports
|
||||
inchangés. Le risque est concentré dans **3 adapters à flux (Channel)** et **3 usages
|
||||
desktop-only**, pas dans les 15 gateways request/response (mécaniques).
|
||||
|
||||
## 1. Inventaire exhaustif des accès `@tauri-apps/api`
|
||||
|
||||
Aucun accès hors `src/adapters`. Détail par fichier adapter :
|
||||
|
||||
| Adapter | API Tauri utilisée | Nature | Commentaire transport |
|
||||
|---|---|---|---|
|
||||
| `system.ts` | `invoke` (`health`) | request/response | trivial HTTP |
|
||||
| `system.ts` | `listen("domain://event")` | **event** | bus d'événements domaine global → **WS** (fan-out serveur→client) |
|
||||
| `system.ts` | `open()` de `@tauri-apps/plugin-dialog` | **desktop-only** | picker dossier natif — **pas de sens sur web** (voir §4) |
|
||||
| `agent.ts` | `invoke` (list/create/change/read/update/delete/…) | request/response | HTTP |
|
||||
| `agent.ts` | `Channel<number[]>` (`launch_agent`, `reattach_terminal`) | **stream** | flux PTY agent → **WS** (contrat PTY du carnet) |
|
||||
| `terminal.ts` | `Channel<number[]>` (`open_terminal`, `reattach_terminal`) | **stream** | flux PTY terminal → **WS** (cœur de F3) |
|
||||
| `terminal.ts` | `invoke` (`write_/resize_/close_terminal`) | request/response (contrôle PTY) | messages WS client→serveur (input/resize/close) |
|
||||
| `ticket.ts` | `Channel<ReplyChunk>` (`agent_send`) | **stream** | flux de réponse chat assistant ticket → **WS** |
|
||||
| `ticket.ts` | `invoke` (~20 commandes `ticket_*`) | request/response | HTTP |
|
||||
| `window.ts` | `invoke` (`open_/close_/list_view_window`) | **desktop-only** | cycle de vie fenêtre OS (WebviewWindow) — voir §4 |
|
||||
| `window.ts` | `listen("view-window://lifecycle")` | **desktop-only / event** | fermeture fenêtre OS — voir §4 |
|
||||
| `focusedProject.ts` | `invoke` + `listen("focused-project://changed")` | **desktop-only / event** | canal fenêtre principale ⇄ fenêtre détachée — voir §4 |
|
||||
| `project.ts`, `layout.ts`, `git.ts`, `profile.ts`, `modelServer.ts`, `template.ts`, `skill.ts`, `memory.ts`, `embedder.ts`, `permission.ts`, `workState.ts`, `conversation.ts`, `input.ts` | `invoke` seul | request/response | HTTP mécanique |
|
||||
| `uiPreferences.ts` | *(aucune)* — `localStorage` | frontend-pur | déjà transport-neutre, marche tel quel sur web |
|
||||
|
||||
## 2. Classement par nature de transport
|
||||
|
||||
### 2a. `request/response` (le gros du volume — mapping HTTP direct)
|
||||
Tous les `invoke()` non-Channel. 15 gateways sur 21 sont **exclusivement**
|
||||
request/response : `project, layout, git, profile, modelServer, template, skill,
|
||||
memory, embedder, permission, workState, conversation, input`, + le `health` de
|
||||
`system` et les ~20 commandes `ticket_*`.
|
||||
→ **Aucune décision d'archi** : un adapter HTTP générique (POST `{command, args}` ou
|
||||
route par commande) suffit. C'est le socle du **premier livrable B4/F2** (web ouvre un
|
||||
projet + read-only), qui n'a besoin d'aucun stream.
|
||||
|
||||
### 2b. `stream` (Channel Tauri → WebSocket) — **le vrai chantier F1/F3**
|
||||
Trois flux, tous modélisés côté port par un **callback `onData`/`onChunk`** (le port
|
||||
ne connaît jamais `Channel`) :
|
||||
|
||||
1. **PTY terminal** — `terminal.ts` `openTerminal`/`reattach` : `Channel<number[]>`,
|
||||
octets bruts → `term.write`. **Cœur de F3 (xterm sur WS).**
|
||||
2. **PTY agent** — `agent.ts` `launchAgent`/`reattach` : même `Channel<number[]>`,
|
||||
réutilise `makeTerminalHandle`. Même transport que le PTY terminal (le carnet dit
|
||||
« agents via WS PTY », lot B6).
|
||||
3. **Chat assistant ticket** — `ticket.ts` `sendTicketChat` : `Channel<ReplyChunk>`,
|
||||
flux de réponse LLM structuré (`agent_send`). Stream applicatif, pas PTY.
|
||||
|
||||
Point de contrat clé : le **handle** (`TerminalHandle`) porte déjà `write/resize/
|
||||
detach/close` **et** la sémantique detach≠close (détacher la vue sans tuer le PTY).
|
||||
Cette sémantique **correspond exactement** au contrat PTY WebSocket du carnet
|
||||
(`attach_terminal`/`detach`, PTY autorité serveur, scrollback borné). L'adapter WS
|
||||
implémentera `makeTerminalHandle` en émettant `input/resize/detach/close` sur la socket
|
||||
et en poussant `output(seq,bytes)` dans `onData`. **Le port n'a pas à changer.**
|
||||
|
||||
### 2c. `event` (listen Tauri → WebSocket serveur→client)
|
||||
Un seul événement *portable* : `system.ts` `onDomainEvent("domain://event")` — le bus
|
||||
d'événements domaine (agent launched, rate-limited, backgroundTaskChanged, issueDeleted,
|
||||
progress modèle…). Sur web ⇒ **canal WS serveur→client** (ou multiplexé sur la même
|
||||
socket que le PTY selon l'arbitrage HTTP+WS séparés vs WS unique, laissé à Architect).
|
||||
Les deux autres `listen` (`view-window://lifecycle`, `focused-project://changed`) sont
|
||||
**desktop-only** (§4), pas des events métier.
|
||||
|
||||
### 2d. `desktop-only` (pas de portage web direct — voir §4)
|
||||
`system.pickFolder` (dialog natif), tout `window.ts` (WebviewWindow OS),
|
||||
`focusedProject.ts` (coordination multi-fenêtres OS).
|
||||
|
||||
## 3. Frontière F1 : où placer les deux implémentations
|
||||
|
||||
**La frontière est déjà `src/ports/` ⇄ `src/adapters/`.** F1 = ajouter un dossier
|
||||
`src/adapters/http/` (ou `webws/`) avec une implémentation par port, et un
|
||||
`createHttpWsGateways()` branché dans `resolveGateways()`.
|
||||
|
||||
Découpage recommandé des adapters web par priorité (aligné sur les lots du carnet) :
|
||||
|
||||
- **F2 (premier livrable, request/response only)** : adapters HTTP pour `system.health`,
|
||||
`project`, `layout` (read), `workState`, `ticket` (read), `agent.listAgents` +
|
||||
`onDomainEvent` sur WS. Suffit pour « ouvrir un projet + état read-only ».
|
||||
- **F3 (xterm sur WS)** : adapter WS pour `terminal` (+ réécriture de
|
||||
`makeTerminalHandle` en variante socket). `TerminalView.tsx` **ne change pas** : il ne
|
||||
connaît que le port et un `open/reattach` injecté.
|
||||
- **B6/F(agents)** : `agent.launchAgent/reattach` sur le même transport WS PTY.
|
||||
- **Chat ticket** : `ticket.sendTicketChat` sur WS applicatif.
|
||||
- **desktop-only** : `window`, `focusedProject`, `pickFolder` → implémentations web
|
||||
dégradées/alternatives (§4).
|
||||
|
||||
**Aucun composant métier (`features/`, `app/`) n'appelle Tauri** — garanti par la garde
|
||||
L1. Donc F1 ne touche **que** `src/adapters/`, `src/app/di.tsx` (3-way resolve) et
|
||||
`vite`/build (cible web sans Tauri). C'est la bonne nouvelle du plan.
|
||||
|
||||
## 4. Points desktop-only — décisions à cadrer (Architect)
|
||||
|
||||
Ces trois surfaces n'ont pas d'équivalent WS trivial ; elles ne bloquent **pas** le
|
||||
premier livrable (read-only) mais doivent être tranchées avant les lots concernés :
|
||||
|
||||
1. **`system.pickFolder`** (créer/ouvrir un projet par un dossier local). Sur le
|
||||
serveur, le « dossier » est **sur la machine serveur**, pas sur le client web. ⇒ Il
|
||||
faut un **file-picker serveur** (browse arborescence serveur via HTTP) au lieu du
|
||||
dialog natif OS. Impacte la création/ouverture de projet côté web. **À cadrer.**
|
||||
2. **`window.ts` (détacher un panneau en fenêtre OS)** : concept purement desktop
|
||||
(Tauri `WebviewWindow`). Équivalent web = nouvel **onglet/`window.open`** navigateur,
|
||||
sémantique différente (pas de registre anti-doublon OS). V1 web : dégrader en
|
||||
**no-op** ou en onglet navigateur, la garde L1 permet une impl web distincte sans
|
||||
toucher les composants. **À cadrer (probablement hors-scope V1 web).**
|
||||
3. **`focusedProject.ts`** : coordination fenêtre principale ⇄ fenêtres détachées. Sans
|
||||
fenêtres détachées (point 2), le canal se réduit à un état local. V1 web :
|
||||
implémentation triviale in-memory / `BroadcastChannel` si multi-onglets. **À cadrer.**
|
||||
|
||||
## 5. Signaux pour le plan de lots / contrat de transport
|
||||
|
||||
- ✅ **Rien ne remet en cause le plan.** La frontière gateways transport-neutres du
|
||||
carnet est **déjà réalisée et testée** (garde L1). F1 est un ajout d'adapters, pas un
|
||||
refactor de composants. Gain de risque majeur.
|
||||
- ✅ **Le contrat PTY WebSocket du carnet colle au port existant** : `TerminalHandle`
|
||||
(write/resize/detach/close, detach≠close, scrollback au reattach) mappe 1-pour-1 sur
|
||||
`attach_terminal/input/resize/detach/close` + `attached(scrollback)/output(seq)`.
|
||||
Aucune évolution de port nécessaire pour F3.
|
||||
- ⚠️ **3 flux Channel** (PTY terminal, PTY agent, chat ticket) partagent la même
|
||||
mécanique `onData`/callback — un **transport WS commun** peut les servir tous les
|
||||
trois. À confirmer avec le choix « HTTP+WS séparés vs WS multiplexé » (délégué
|
||||
Architect).
|
||||
- ⚠️ **`pickFolder`** est le seul point request/response qui **change de sémantique** sur
|
||||
web (dossier serveur ≠ dossier client). À arbitrer avant le lot création/ouverture de
|
||||
projet web.
|
||||
- ⚠️ **`window`/`focusedProject`** (multi-fenêtres OS) : probablement **hors V1 web** ;
|
||||
à confirmer pour ne pas gonfler le périmètre.
|
||||
- ℹ️ **`uiPreferences`** (localStorage) marche déjà tel quel sur web — aucun adapter à
|
||||
écrire.
|
||||
- ℹ️ **Convention DTO** : commandes snake_case, payloads camelCase, souvent enveloppés
|
||||
dans `{ request: {...} }`. L'adapter HTTP doit préserver cette enveloppe pour parler au
|
||||
même cœur backend (le miroir backend confirme ce point de contrat).
|
||||
124
frontend/src/adapters/http/frames.ts
Normal file
124
frontend/src/adapters/http/frames.ts
Normal file
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* WebSocket frame contract for the web (client/server) transport — ticket #13,
|
||||
* lot F1. Mirrors the first-draft frames in
|
||||
* `docs/ticket13-b0-backend-transport-inventory.md` (§ "WebSocket first draft").
|
||||
*
|
||||
* F1 delivers the *types + skeleton* only. The full PTY wiring runs on top of
|
||||
* these in F3/B5 once a server exists (B3/B4). Keeping the frame shapes here — in
|
||||
* the adapters layer — lets the terminal/agent/chat gateways be structurally
|
||||
* correct against the contract today without a live backend.
|
||||
*
|
||||
* Envelope (B0): every frame carries an `id` (client→server) or optional
|
||||
* `replyTo` (server→client, present for acknowledgements, absent for unsolicited
|
||||
* output/events). PTY bytes travel base64-encoded (`bytesBase64`) so binary
|
||||
* survives JSON.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base64 helpers (binary PTY bytes over JSON frames)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Encodes raw bytes to a base64 string for a `bytesBase64` frame field. */
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
// `btoa` exists in browsers and jsdom.
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** Decodes a base64 `bytesBase64` frame field back to raw bytes. */
|
||||
export function base64ToBytes(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
out[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client → server frames
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Client→server frame kinds (B0 § PTY + structured chat + ping). */
|
||||
export type ClientFrameKind =
|
||||
| "terminal.attach"
|
||||
| "terminal.open"
|
||||
| "agent.launch"
|
||||
| "terminal.input"
|
||||
| "terminal.resize"
|
||||
| "terminal.detach"
|
||||
| "terminal.close"
|
||||
| "chat.attach"
|
||||
| "chat.send"
|
||||
| "chat.detach"
|
||||
| "ping";
|
||||
|
||||
/** A client→server frame. `id` correlates the eventual `replyTo`. */
|
||||
export interface ClientFrame {
|
||||
id: string;
|
||||
kind: ClientFrameKind;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server → client frames
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Server→client frame kinds. */
|
||||
export type ServerFrameKind =
|
||||
| "terminal.attached"
|
||||
| "terminal.output"
|
||||
| "terminal.status"
|
||||
| "chat.attached"
|
||||
| "chat.output"
|
||||
| "event.domain"
|
||||
| "error"
|
||||
| "pong";
|
||||
|
||||
/** Suggested lifecycle statuses (B0). */
|
||||
export type TerminalStatus =
|
||||
| "starting"
|
||||
| "running"
|
||||
| "exited"
|
||||
| "closed"
|
||||
| "detached"
|
||||
| "reattached";
|
||||
|
||||
/** A server→client frame. `replyTo` is present only for acknowledgements. */
|
||||
export interface ServerFrame {
|
||||
kind: ServerFrameKind;
|
||||
replyTo?: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Payload of a `terminal.attached` acknowledgement. */
|
||||
export interface AttachedPayload {
|
||||
session: {
|
||||
sessionId: string;
|
||||
projectId?: string;
|
||||
nodeId?: string | null;
|
||||
rows: number;
|
||||
cols: number;
|
||||
};
|
||||
nextSeq: number;
|
||||
scrollback: { seq: number; bytesBase64: string }[];
|
||||
gap: boolean;
|
||||
/** Conversation id minted by an `agent.launch` (mirrors `assignedConversationId`). */
|
||||
assignedConversationId?: string;
|
||||
}
|
||||
|
||||
/** Payload of a `terminal.output` frame. */
|
||||
export interface OutputPayload {
|
||||
sessionId: string;
|
||||
seq: number;
|
||||
bytesBase64: string;
|
||||
}
|
||||
|
||||
/** Payload of a `chat.output` frame (structured assistant stream). */
|
||||
export interface ChatOutputPayload {
|
||||
sessionId: string;
|
||||
chunk: unknown; // ReplyChunk-shaped (kind-tagged); normalized by the caller.
|
||||
}
|
||||
149
frontend/src/adapters/http/httpInvoker.test.ts
Normal file
149
frontend/src/adapters/http/httpInvoker.test.ts
Normal file
@ -0,0 +1,149 @@
|
||||
/**
|
||||
* F1 — the HTTP invoker forwards `{command, args}` to `POST /api/invoke`,
|
||||
* preserves the backend `ErrorDto` on failure, and maps empty bodies to
|
||||
* `undefined`. Also verifies a couple of request/response gateways emit the
|
||||
* exact command + argument envelope of their Tauri siblings.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import type { FetchLike } from "./httpInvoker";
|
||||
import { HttpInvoker } from "./httpInvoker";
|
||||
import { HttpProjectGateway, HttpGitGateway } from "./requestResponseGateways";
|
||||
|
||||
/** Builds a fake fetch that records the request and returns `body`. */
|
||||
function fakeFetch(
|
||||
body: unknown,
|
||||
opts: { ok?: boolean; status?: number } = {},
|
||||
): { fetchImpl: FetchLike; calls: { url: string; init: unknown }[] } {
|
||||
const calls: { url: string; init: unknown }[] = [];
|
||||
const fetchImpl: FetchLike = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return {
|
||||
ok: opts.ok ?? true,
|
||||
status: opts.status ?? 200,
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
};
|
||||
};
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
describe("HttpInvoker", () => {
|
||||
it("POSTs {command, args} to /api/invoke and returns the parsed body", async () => {
|
||||
const { fetchImpl, calls } = fakeFetch({ ok: true });
|
||||
const http = new HttpInvoker({ baseUrl: "https://host:9000/", fetchImpl });
|
||||
|
||||
const result = await http.invoke<{ ok: boolean }>("health", {
|
||||
request: { note: "hi" },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url).toBe("https://host:9000/api/invoke");
|
||||
const init = calls[0].init as { method: string; body: string; headers: Record<string, string> };
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers["Content-Type"]).toBe("application/json");
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
command: "health",
|
||||
args: { request: { note: "hi" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the pairing token as an Authorization header", async () => {
|
||||
const { fetchImpl, calls } = fakeFetch([]);
|
||||
const http = new HttpInvoker({ baseUrl: "https://host", token: "tok-123", fetchImpl });
|
||||
|
||||
await http.invoke("list_projects");
|
||||
|
||||
const init = calls[0].init as { headers: Record<string, string> };
|
||||
expect(init.headers.Authorization).toBe("Bearer tok-123");
|
||||
// No args ⇒ empty object envelope.
|
||||
expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({
|
||||
command: "list_projects",
|
||||
args: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects with the backend ErrorDto on a non-2xx response", async () => {
|
||||
const { fetchImpl } = fakeFetch(
|
||||
{ code: "NOT_FOUND", message: "project x" },
|
||||
{ ok: false, status: 404 },
|
||||
);
|
||||
const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl });
|
||||
|
||||
await expect(http.invoke("open_project", { projectId: "x" })).rejects.toEqual({
|
||||
code: "NOT_FOUND",
|
||||
message: "project x",
|
||||
});
|
||||
});
|
||||
|
||||
it("fires onUnauthorized on a 401 (and still throws)", async () => {
|
||||
const { fetchImpl } = fakeFetch(
|
||||
{ code: "UNAUTHENTICATED", message: "no session" },
|
||||
{ ok: false, status: 401 },
|
||||
);
|
||||
const onUnauthorized = vi.fn();
|
||||
const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl, onUnauthorized });
|
||||
|
||||
await expect(http.invoke("list_projects")).rejects.toMatchObject({
|
||||
code: "UNAUTHENTICATED",
|
||||
});
|
||||
expect(onUnauthorized).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not fire onUnauthorized on a non-401 error", async () => {
|
||||
const { fetchImpl } = fakeFetch({ code: "NOT_FOUND", message: "x" }, { ok: false, status: 404 });
|
||||
const onUnauthorized = vi.fn();
|
||||
const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl, onUnauthorized });
|
||||
|
||||
await expect(http.invoke("open_project", { projectId: "x" })).rejects.toBeTruthy();
|
||||
expect(onUnauthorized).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wraps a network failure in a TRANSPORT_ERROR GatewayError", async () => {
|
||||
const fetchImpl: FetchLike = async () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
const http = new HttpInvoker({ baseUrl: "https://host", fetchImpl });
|
||||
|
||||
await expect(http.invoke("health")).rejects.toMatchObject({
|
||||
code: "TRANSPORT_ERROR",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("request/response gateways preserve the Tauri command contract", () => {
|
||||
it("HttpProjectGateway.createProject maps to create_project with a request envelope", async () => {
|
||||
const http = new HttpInvoker({ baseUrl: "https://h" });
|
||||
const spy = vi.spyOn(http, "invoke").mockResolvedValue({ id: "p1" } as never);
|
||||
const gw = new HttpProjectGateway(http);
|
||||
|
||||
await gw.createProject("My proj", "/abs/root");
|
||||
|
||||
expect(spy).toHaveBeenCalledWith("create_project", {
|
||||
request: { name: "My proj", root: "/abs/root" },
|
||||
});
|
||||
});
|
||||
|
||||
it("HttpGitGateway.stage maps to git_stage with a request envelope", async () => {
|
||||
const http = new HttpInvoker({ baseUrl: "https://h" });
|
||||
const spy = vi.spyOn(http, "invoke").mockResolvedValue(undefined as never);
|
||||
const gw = new HttpGitGateway(http);
|
||||
|
||||
await gw.stage("p1", "src/a.ts");
|
||||
|
||||
expect(spy).toHaveBeenCalledWith("git_stage", {
|
||||
request: { projectId: "p1", path: "src/a.ts" },
|
||||
});
|
||||
});
|
||||
|
||||
it("HttpGitGateway.status maps to git_status with a flat projectId", async () => {
|
||||
const http = new HttpInvoker({ baseUrl: "https://h" });
|
||||
const spy = vi.spyOn(http, "invoke").mockResolvedValue([] as never);
|
||||
const gw = new HttpGitGateway(http);
|
||||
|
||||
await gw.status("p1");
|
||||
|
||||
expect(spy).toHaveBeenCalledWith("git_status", { projectId: "p1" });
|
||||
});
|
||||
});
|
||||
146
frontend/src/adapters/http/httpInvoker.ts
Normal file
146
frontend/src/adapters/http/httpInvoker.ts
Normal file
@ -0,0 +1,146 @@
|
||||
/**
|
||||
* HTTP transport primitive for the web (client/server) adapter set — ticket #13,
|
||||
* lot F1. This is the HTTP analogue of Tauri's `invoke`: it forwards one backend
|
||||
* *command* + its camelCase argument envelope to the shared backend core over
|
||||
* HTTP, and preserves the exact `ErrorDto` shape ({@link GatewayError}) on
|
||||
* failure.
|
||||
*
|
||||
* **Contract choice (F1):** rather than a per-command REST resource tree (the
|
||||
* first-draft routes in `docs/ticket13-b0-backend-transport-inventory.md`), F1
|
||||
* uses a single generic RPC endpoint `POST {baseUrl}/api/invoke` with body
|
||||
* `{ command, args }`. This mirrors the Tauri `invoke(command, args)` seam
|
||||
* one-for-one, so **every** request/response gateway reuses the *identical*
|
||||
* command names and `{ request: { … } }` envelopes already frozen for the Tauri
|
||||
* adapter — no DTO divergence. Switching to REST later (if the backend picks that
|
||||
* in B3/B4) only touches this file + the gateway wiring, never a component.
|
||||
* This divergence from the B0 REST sketch is flagged for DevBackend to confirm.
|
||||
*
|
||||
* Only `src/adapters/**` may own transport code (CI guard
|
||||
* `no-direct-invoke.test.ts`); this file lives there and touches no
|
||||
* `@tauri-apps/api`.
|
||||
*/
|
||||
|
||||
import type { GatewayError } from "@/domain";
|
||||
|
||||
/** The `fetch` surface this invoker needs; injectable so tests pass a stub. */
|
||||
export type FetchLike = (
|
||||
input: string,
|
||||
init?: {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
signal?: AbortSignal;
|
||||
/** Cookie policy — F2 uses `same-origin` so the session cookie rides along. */
|
||||
credentials?: "same-origin" | "include" | "omit";
|
||||
},
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
/** Parsed JSON body (may reject/return undefined for empty bodies). */
|
||||
json(): Promise<unknown>;
|
||||
text(): Promise<string>;
|
||||
}>;
|
||||
|
||||
/** Configuration for the HTTP invoker. */
|
||||
export interface HttpInvokerConfig {
|
||||
/** Absolute base URL of the backend, e.g. `https://host:port`. No trailing slash. */
|
||||
baseUrl: string;
|
||||
/** Bearer token obtained from pairing (ticket #13 auth); sent as `Authorization`. */
|
||||
token?: string;
|
||||
/** Injected fetch (defaults to global `fetch`). */
|
||||
fetchImpl?: FetchLike;
|
||||
/**
|
||||
* Called when the backend answers `401` (the session cookie is missing or
|
||||
* expired). F2 wires this to the web session so the app routes back to the
|
||||
* pairing screen. The `GatewayError` is still thrown to the caller.
|
||||
*/
|
||||
onUnauthorized?: () => void;
|
||||
}
|
||||
|
||||
/** Builds a {@link GatewayError} from an arbitrary thrown/parsed value. */
|
||||
function toGatewayError(value: unknown, fallbackMessage: string): GatewayError {
|
||||
if (value && typeof value === "object") {
|
||||
const rec = value as Record<string, unknown>;
|
||||
const code = typeof rec.code === "string" ? rec.code : undefined;
|
||||
const message = typeof rec.message === "string" ? rec.message : undefined;
|
||||
if (code || message) {
|
||||
return { code: code ?? "ERROR", message: message ?? fallbackMessage };
|
||||
}
|
||||
}
|
||||
return { code: "TRANSPORT_ERROR", message: fallbackMessage };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends backend commands over HTTP. Mirrors the Tauri `invoke` signature so the
|
||||
* web gateways can reuse the exact command + argument envelopes of their Tauri
|
||||
* siblings.
|
||||
*/
|
||||
export class HttpInvoker {
|
||||
private readonly baseUrl: string;
|
||||
private readonly token?: string;
|
||||
private readonly fetchImpl: FetchLike;
|
||||
private readonly onUnauthorized?: () => void;
|
||||
|
||||
constructor(config: HttpInvokerConfig) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
||||
this.token = config.token;
|
||||
this.onUnauthorized = config.onUnauthorized;
|
||||
// `globalThis.fetch` exists in the browser (and jsdom); the cast narrows it
|
||||
// to the minimal shape used here.
|
||||
this.fetchImpl =
|
||||
config.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes a backend command. `args` is the same camelCase argument object the
|
||||
* Tauri adapter passes (frequently `{ request: { … } }`). Resolves with the
|
||||
* parsed result, or rejects with a {@link GatewayError}.
|
||||
*/
|
||||
async invoke<T>(command: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (this.token) headers.Authorization = `Bearer ${this.token}`;
|
||||
|
||||
let res: Awaited<ReturnType<FetchLike>>;
|
||||
try {
|
||||
res = await this.fetchImpl(`${this.baseUrl}/api/invoke`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ command, args }),
|
||||
// Same-origin so the HttpOnly session cookie is sent automatically
|
||||
// (ticket #13: no secret in the URL/headers from JS).
|
||||
credentials: "same-origin",
|
||||
});
|
||||
} catch (networkError) {
|
||||
const err: GatewayError = {
|
||||
code: "TRANSPORT_ERROR",
|
||||
message: `HTTP request for '${command}' failed: ${String(networkError)}`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
// A 401 means the session cookie is missing/expired: signal the app to
|
||||
// route back to pairing (the error is still thrown to the caller).
|
||||
if (res.status === 401) this.onUnauthorized?.();
|
||||
// Preserve the backend `ErrorDto` when present; fall back to the status.
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await res.json();
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
throw toGatewayError(parsed, `command '${command}' failed (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
// A 204 / empty body maps to `undefined` (the void commands).
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = undefined;
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
}
|
||||
118
frontend/src/adapters/http/index.ts
Normal file
118
frontend/src/adapters/http/index.ts
Normal file
@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Web (client/server) adapter set — ticket #13, lot F1.
|
||||
*
|
||||
* `createHttpWsGateways()` is the third gateway implementation, alongside
|
||||
* `createTauriGateways()` (desktop) and `createMockGateways()` (tests/dev). It
|
||||
* wires every UI port to the shared backend core over HTTP request/response and a
|
||||
* single multiplexed WebSocket, exactly behind the *unchanged* ports — no
|
||||
* component is aware of it (selected in `app/di.tsx`).
|
||||
*
|
||||
* Scope reminder (F1): request/response gateways are fully implemented; the WS
|
||||
* PTY/chat streams are a contract-conformant **skeleton** (full round-trip lands
|
||||
* in F3/B5/B6 once a server exists — B3/B4). Desktop-only surfaces (pickFolder,
|
||||
* OS windows, focused-project, remote) fail with an explicit "unsupported on web"
|
||||
* error or an inert stub, never a Tauri call.
|
||||
*/
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
import { LocalStorageUiPreferencesGateway } from "../uiPreferences";
|
||||
import { HttpInvoker } from "./httpInvoker";
|
||||
import { WsLiveClient } from "./wsLiveClient";
|
||||
import { getWebSession } from "./webSession";
|
||||
import {
|
||||
HttpConversationGateway,
|
||||
HttpEmbedderGateway,
|
||||
HttpGitGateway,
|
||||
HttpInputGateway,
|
||||
HttpLayoutGateway,
|
||||
HttpMemoryGateway,
|
||||
HttpModelServerGateway,
|
||||
HttpPermissionGateway,
|
||||
HttpProfileGateway,
|
||||
HttpProjectGateway,
|
||||
HttpSkillGateway,
|
||||
HttpTemplateGateway,
|
||||
HttpWorkStateGateway,
|
||||
} from "./requestResponseGateways";
|
||||
import {
|
||||
HttpAgentGateway,
|
||||
HttpSystemGateway,
|
||||
HttpTerminalGateway,
|
||||
HttpTicketGateway,
|
||||
} from "./streamGateways";
|
||||
import {
|
||||
WebFocusedProjectGateway,
|
||||
WebRemoteGateway,
|
||||
WebWindowGateway,
|
||||
} from "./unsupported";
|
||||
|
||||
/** Endpoint configuration for the web transport. */
|
||||
export interface HttpWsGatewaysConfig {
|
||||
/** Backend HTTP base URL (no trailing slash). Defaults to the page origin. */
|
||||
baseUrl?: string;
|
||||
/** WebSocket base URL. Defaults to the page origin with `http(s)`→`ws(s)`. */
|
||||
wsUrl?: string;
|
||||
/** Bearer token from pairing (ticket #13 auth). */
|
||||
token?: string;
|
||||
}
|
||||
|
||||
/** Derives default `{baseUrl, wsUrl}` from `window.location` when available. */
|
||||
function resolveEndpoints(config: HttpWsGatewaysConfig): {
|
||||
baseUrl: string;
|
||||
wsUrl: string;
|
||||
} {
|
||||
const origin =
|
||||
typeof window !== "undefined" && window.location
|
||||
? window.location.origin
|
||||
: "http://localhost";
|
||||
const baseUrl = config.baseUrl ?? origin;
|
||||
const wsUrl =
|
||||
config.wsUrl ?? baseUrl.replace(/^http(s?):\/\//, (_m, s) => `ws${s}://`);
|
||||
return { baseUrl, wsUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the full set of web (HTTP+WS) gateways. `config` is optional; endpoints
|
||||
* default to the current page origin so a browser build talks to its own server.
|
||||
* The {@link HttpInvoker} and {@link WsLiveClient} are shared across gateways.
|
||||
*/
|
||||
export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateways {
|
||||
const { baseUrl, wsUrl } = resolveEndpoints(config);
|
||||
// Route 401s back to the pairing screen via the shared web session (F2).
|
||||
const session = getWebSession();
|
||||
const http = new HttpInvoker({
|
||||
baseUrl,
|
||||
token: config.token,
|
||||
onUnauthorized: () => session.notifyUnauthorized(),
|
||||
});
|
||||
const ws = new WsLiveClient({ wsUrl, token: config.token });
|
||||
|
||||
return {
|
||||
system: new HttpSystemGateway(http, ws),
|
||||
agent: new HttpAgentGateway(http, ws),
|
||||
input: new HttpInputGateway(http),
|
||||
terminal: new HttpTerminalGateway(ws),
|
||||
project: new HttpProjectGateway(http),
|
||||
layout: new HttpLayoutGateway(http),
|
||||
git: new HttpGitGateway(http),
|
||||
remote: new WebRemoteGateway(),
|
||||
profile: new HttpProfileGateway(http),
|
||||
modelServer: new HttpModelServerGateway(http),
|
||||
template: new HttpTemplateGateway(http),
|
||||
skill: new HttpSkillGateway(http),
|
||||
memory: new HttpMemoryGateway(http),
|
||||
embedder: new HttpEmbedderGateway(http),
|
||||
permission: new HttpPermissionGateway(http),
|
||||
workState: new HttpWorkStateGateway(http),
|
||||
conversation: new HttpConversationGateway(http),
|
||||
ticket: new HttpTicketGateway(http, ws),
|
||||
window: new WebWindowGateway(),
|
||||
focusedProject: new WebFocusedProjectGateway(),
|
||||
// Frontend-owned UI prefs are transport-neutral (localStorage) — reuse as-is.
|
||||
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
export { HttpInvoker } from "./httpInvoker";
|
||||
export { WsLiveClient } from "./wsLiveClient";
|
||||
export { WebSession, getWebSession, setWebSessionForTests } from "./webSession";
|
||||
400
frontend/src/adapters/http/requestResponseGateways.ts
Normal file
400
frontend/src/adapters/http/requestResponseGateways.ts
Normal file
@ -0,0 +1,400 @@
|
||||
/**
|
||||
* HTTP request/response gateways for the web transport — ticket #13, lot F1.
|
||||
*
|
||||
* Each class implements a UI port by forwarding the **exact** backend command
|
||||
* name + camelCase argument envelope its Tauri sibling uses (the shared backend
|
||||
* core, so identical contracts) through the generic {@link HttpInvoker}. No DTO
|
||||
* shape is re-derived here; only the transport changes (Tauri `invoke` → HTTP
|
||||
* `POST /api/invoke`). Normalizers that are transport-neutral (`workState`,
|
||||
* `conversation`) are reused from the sibling adapters.
|
||||
*
|
||||
* Lives in `src/adapters/**`; touches no `@tauri-apps/api`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
AgentDrift,
|
||||
AgentProfile,
|
||||
EffectivePermissions,
|
||||
EmbedderEngines,
|
||||
EmbedderProfile,
|
||||
FirstRunState,
|
||||
GitBranches,
|
||||
GitCommit,
|
||||
GitFileStatus,
|
||||
GraphCommit,
|
||||
LayoutKind,
|
||||
LayoutList,
|
||||
LayoutOperation,
|
||||
LayoutTree,
|
||||
LocalModelServerConfig,
|
||||
Memory,
|
||||
MemoryIndexEntry,
|
||||
MemoryLink,
|
||||
MemoryType,
|
||||
ModelServerCommandPreview,
|
||||
PermissionSet,
|
||||
Project,
|
||||
ProjectPermissions,
|
||||
ProjectWorkState,
|
||||
ProfileAvailability,
|
||||
Skill,
|
||||
SkillScope,
|
||||
Template,
|
||||
TurnPage,
|
||||
} from "@/domain";
|
||||
import type {
|
||||
CloneOpenCodeProfileFromSeedInput,
|
||||
ConversationGateway,
|
||||
ConversationPageRequest,
|
||||
CreateMemoryInput,
|
||||
CreateSkillInput,
|
||||
CreateTemplateInput,
|
||||
EmbedderGateway,
|
||||
GitGateway,
|
||||
InputGateway,
|
||||
LayoutGateway,
|
||||
MemoryGateway,
|
||||
ModelServerGateway,
|
||||
PermissionGateway,
|
||||
ProfileGateway,
|
||||
ProjectGateway,
|
||||
SkillGateway,
|
||||
TemplateGateway,
|
||||
WorkStateGateway,
|
||||
} from "@/ports";
|
||||
import { normalizeProjectWorkState } from "../workStateNormalization";
|
||||
import { normalizeTurnPage } from "../conversationNormalization";
|
||||
import type { HttpInvoker } from "./httpInvoker";
|
||||
|
||||
export class HttpProjectGateway implements ProjectGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
listProjects(): Promise<Project[]> {
|
||||
return this.http.invoke<Project[]>("list_projects");
|
||||
}
|
||||
createProject(name: string, root: string): Promise<Project> {
|
||||
return this.http.invoke<Project>("create_project", { request: { name, root } });
|
||||
}
|
||||
openProject(projectId: string): Promise<Project> {
|
||||
return this.http.invoke<Project>("open_project", { projectId });
|
||||
}
|
||||
async closeProject(projectId: string): Promise<void> {
|
||||
await this.http.invoke("close_project", { projectId });
|
||||
}
|
||||
readProjectContext(projectId: string): Promise<string> {
|
||||
return this.http.invoke<string>("read_project_context", { projectId });
|
||||
}
|
||||
async updateProjectContext(projectId: string, content: string): Promise<void> {
|
||||
await this.http.invoke("update_project_context", { request: { projectId, content } });
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpLayoutGateway implements LayoutGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
loadLayout(projectId: string, layoutId?: string): Promise<LayoutTree> {
|
||||
return this.http.invoke<LayoutTree>("load_layout", { projectId, layoutId });
|
||||
}
|
||||
mutateLayout(
|
||||
projectId: string,
|
||||
operation: LayoutOperation,
|
||||
layoutId?: string,
|
||||
): Promise<LayoutTree> {
|
||||
return this.http.invoke<LayoutTree>("mutate_layout", { projectId, layoutId, operation });
|
||||
}
|
||||
listLayouts(projectId: string): Promise<LayoutList> {
|
||||
return this.http.invoke<LayoutList>("list_layouts", { projectId });
|
||||
}
|
||||
createLayout(projectId: string, name: string, kind?: LayoutKind): Promise<{ layoutId: string }> {
|
||||
return this.http.invoke<{ layoutId: string }>("create_layout", { request: { projectId, name, kind } });
|
||||
}
|
||||
renameLayout(projectId: string, layoutId: string, name: string): Promise<void> {
|
||||
return this.http.invoke<void>("rename_layout", { request: { projectId, layoutId, name } });
|
||||
}
|
||||
deleteLayout(projectId: string, layoutId: string): Promise<{ activeId: string }> {
|
||||
return this.http.invoke<{ activeId: string }>("delete_layout", { request: { projectId, layoutId } });
|
||||
}
|
||||
setActiveLayout(projectId: string, layoutId: string): Promise<{ activeId: string }> {
|
||||
return this.http.invoke<{ activeId: string }>("set_active_layout", { request: { projectId, layoutId } });
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpGitGateway implements GitGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
status(projectId: string): Promise<GitFileStatus[]> {
|
||||
return this.http.invoke<GitFileStatus[]>("git_status", { projectId });
|
||||
}
|
||||
async stage(projectId: string, path: string): Promise<void> {
|
||||
await this.http.invoke("git_stage", { request: { projectId, path } });
|
||||
}
|
||||
async unstage(projectId: string, path: string): Promise<void> {
|
||||
await this.http.invoke("git_unstage", { request: { projectId, path } });
|
||||
}
|
||||
commit(projectId: string, message: string): Promise<GitCommit> {
|
||||
return this.http.invoke<GitCommit>("git_commit", { request: { projectId, message } });
|
||||
}
|
||||
branches(projectId: string): Promise<GitBranches> {
|
||||
return this.http.invoke<GitBranches>("git_branches", { projectId });
|
||||
}
|
||||
async checkout(projectId: string, branch: string): Promise<void> {
|
||||
await this.http.invoke("git_checkout", { request: { projectId, branch } });
|
||||
}
|
||||
log(projectId: string, limit: number): Promise<GitCommit[]> {
|
||||
return this.http.invoke<GitCommit[]>("git_log", { projectId, limit });
|
||||
}
|
||||
async init(projectId: string): Promise<void> {
|
||||
await this.http.invoke("git_init", { projectId });
|
||||
}
|
||||
graph(projectId: string, limit: number): Promise<GraphCommit[]> {
|
||||
return this.http.invoke<GraphCommit[]>("git_graph", { projectId, limit });
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpProfileGateway implements ProfileGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
firstRunState(): Promise<FirstRunState> {
|
||||
return this.http.invoke<FirstRunState>("first_run_state");
|
||||
}
|
||||
referenceProfiles(): Promise<AgentProfile[]> {
|
||||
return this.http.invoke<AgentProfile[]>("reference_profiles");
|
||||
}
|
||||
detectProfiles(candidates: AgentProfile[]): Promise<ProfileAvailability[]> {
|
||||
return this.http.invoke<ProfileAvailability[]>("detect_profiles", { request: { candidates } });
|
||||
}
|
||||
listProfiles(): Promise<AgentProfile[]> {
|
||||
return this.http.invoke<AgentProfile[]>("list_profiles");
|
||||
}
|
||||
saveProfile(profile: AgentProfile): Promise<AgentProfile> {
|
||||
return this.http.invoke<AgentProfile>("save_profile", { request: { profile } });
|
||||
}
|
||||
async deleteProfile(profileId: string): Promise<void> {
|
||||
await this.http.invoke("delete_profile", { profileId });
|
||||
}
|
||||
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
|
||||
return this.http.invoke<AgentProfile[]>("configure_profiles", { request: { profiles } });
|
||||
}
|
||||
cloneOpenCodeProfileFromSeed(
|
||||
input: CloneOpenCodeProfileFromSeedInput = {},
|
||||
): Promise<AgentProfile> {
|
||||
return this.http.invoke<AgentProfile>("clone_opencode_profile_from_seed", {
|
||||
request: { name: input.name, opencode: input.opencode },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpModelServerGateway implements ModelServerGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
listModelServers(): Promise<LocalModelServerConfig[]> {
|
||||
return this.http.invoke<LocalModelServerConfig[]>("list_model_servers");
|
||||
}
|
||||
saveModelServer(config: LocalModelServerConfig): Promise<LocalModelServerConfig> {
|
||||
return this.http.invoke<LocalModelServerConfig>("save_model_server", { request: { config } });
|
||||
}
|
||||
async deleteModelServer(serverId: string): Promise<void> {
|
||||
await this.http.invoke("delete_model_server", { serverId });
|
||||
}
|
||||
previewModelServerCommand(config: LocalModelServerConfig): Promise<ModelServerCommandPreview> {
|
||||
return this.http.invoke<ModelServerCommandPreview>("preview_model_server_command", { config });
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpTemplateGateway implements TemplateGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
listTemplates(): Promise<Template[]> {
|
||||
return this.http.invoke<Template[]>("list_templates");
|
||||
}
|
||||
createTemplate(input: CreateTemplateInput): Promise<Template> {
|
||||
return this.http.invoke<Template>("create_template", {
|
||||
request: { name: input.name, content: input.content, defaultProfileId: input.defaultProfileId },
|
||||
});
|
||||
}
|
||||
updateTemplate(templateId: string, content: string): Promise<Template> {
|
||||
return this.http.invoke<Template>("update_template", { request: { templateId, content } });
|
||||
}
|
||||
async deleteTemplate(templateId: string): Promise<void> {
|
||||
await this.http.invoke("delete_template", { templateId });
|
||||
}
|
||||
createAgentFromTemplate(
|
||||
projectId: string,
|
||||
templateId: string,
|
||||
opts?: { name?: string; synchronized?: boolean },
|
||||
): Promise<Agent> {
|
||||
return this.http.invoke<Agent>("create_agent_from_template", {
|
||||
request: {
|
||||
projectId,
|
||||
templateId,
|
||||
name: opts?.name ?? null,
|
||||
synchronized: opts?.synchronized ?? true,
|
||||
},
|
||||
});
|
||||
}
|
||||
detectDrift(projectId: string): Promise<AgentDrift[]> {
|
||||
return this.http.invoke<AgentDrift[]>("detect_agent_drift", { projectId });
|
||||
}
|
||||
syncAgent(projectId: string, agentId: string): Promise<{ synced: boolean; version: number | null }> {
|
||||
return this.http.invoke<{ synced: boolean; version: number | null }>("sync_agent_with_template", {
|
||||
request: { projectId, agentId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpSkillGateway implements SkillGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
listSkills(projectId: string, scope: SkillScope): Promise<Skill[]> {
|
||||
return this.http.invoke<Skill[]>("list_skills", { projectId, scope });
|
||||
}
|
||||
createSkill(input: CreateSkillInput): Promise<Skill> {
|
||||
return this.http.invoke<Skill>("create_skill", {
|
||||
request: { projectId: input.projectId, name: input.name, content: input.content, scope: input.scope },
|
||||
});
|
||||
}
|
||||
updateSkill(projectId: string, scope: SkillScope, skillId: string, content: string): Promise<Skill> {
|
||||
return this.http.invoke<Skill>("update_skill", { request: { projectId, scope, skillId, content } });
|
||||
}
|
||||
async deleteSkill(projectId: string, scope: SkillScope, skillId: string): Promise<void> {
|
||||
await this.http.invoke("delete_skill", { projectId, scope, skillId });
|
||||
}
|
||||
async assignSkill(projectId: string, agentId: string, skillId: string, scope: SkillScope): Promise<void> {
|
||||
await this.http.invoke("assign_skill_to_agent", { request: { projectId, agentId, skillId, scope } });
|
||||
}
|
||||
async unassignSkill(projectId: string, agentId: string, skillId: string): Promise<void> {
|
||||
await this.http.invoke("unassign_skill_from_agent", { request: { projectId, agentId, skillId } });
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpMemoryGateway implements MemoryGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
listMemories(projectId: string): Promise<Memory[]> {
|
||||
return this.http.invoke<Memory[]>("list_memories", { projectId });
|
||||
}
|
||||
getMemory(projectId: string, slug: string): Promise<Memory> {
|
||||
return this.http.invoke<Memory>("get_memory", { projectId, slug });
|
||||
}
|
||||
createMemory(input: CreateMemoryInput): Promise<Memory> {
|
||||
return this.http.invoke<Memory>("create_memory", {
|
||||
request: {
|
||||
projectId: input.projectId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
type: input.type,
|
||||
content: input.content,
|
||||
},
|
||||
});
|
||||
}
|
||||
updateMemory(
|
||||
projectId: string,
|
||||
slug: string,
|
||||
description: string,
|
||||
type: MemoryType,
|
||||
content: string,
|
||||
): Promise<Memory> {
|
||||
return this.http.invoke<Memory>("update_memory", { request: { projectId, slug, description, type, content } });
|
||||
}
|
||||
async deleteMemory(projectId: string, slug: string): Promise<void> {
|
||||
await this.http.invoke("delete_memory", { projectId, slug });
|
||||
}
|
||||
readIndex(projectId: string): Promise<MemoryIndexEntry[]> {
|
||||
return this.http.invoke<MemoryIndexEntry[]>("read_memory_index", { projectId });
|
||||
}
|
||||
resolveLinks(projectId: string, slug: string): Promise<MemoryLink[]> {
|
||||
return this.http.invoke<MemoryLink[]>("resolve_memory_links", { projectId, slug });
|
||||
}
|
||||
recall(projectId: string, text: string, tokenBudget: number): Promise<MemoryIndexEntry[]> {
|
||||
return this.http.invoke<MemoryIndexEntry[]>("recall_memory", { request: { projectId, text, tokenBudget } });
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpEmbedderGateway implements EmbedderGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
listEmbedderProfiles(): Promise<EmbedderProfile[]> {
|
||||
return this.http.invoke<EmbedderProfile[]>("list_embedder_profiles");
|
||||
}
|
||||
saveEmbedderProfile(profile: EmbedderProfile): Promise<EmbedderProfile> {
|
||||
return this.http.invoke<EmbedderProfile>("save_embedder_profile", { request: { profile } });
|
||||
}
|
||||
async deleteEmbedderProfile(embedderId: string): Promise<void> {
|
||||
await this.http.invoke("delete_embedder_profile", { embedderId });
|
||||
}
|
||||
describeEmbedderEngines(): Promise<EmbedderEngines> {
|
||||
return this.http.invoke<EmbedderEngines>("describe_embedder_engines");
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpPermissionGateway implements PermissionGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
getProjectPermissions(projectId: string): Promise<ProjectPermissions> {
|
||||
return this.http.invoke<ProjectPermissions>("get_project_permissions", { projectId });
|
||||
}
|
||||
updateProjectPermissions(
|
||||
projectId: string,
|
||||
permissions: PermissionSet | null,
|
||||
): Promise<ProjectPermissions> {
|
||||
return this.http.invoke<ProjectPermissions>("update_project_permissions", { request: { projectId, permissions } });
|
||||
}
|
||||
updateAgentPermissions(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
permissions: PermissionSet | null,
|
||||
): Promise<ProjectPermissions> {
|
||||
return this.http.invoke<ProjectPermissions>("update_agent_permissions", {
|
||||
request: { projectId, agentId, permissions },
|
||||
});
|
||||
}
|
||||
resolveAgentPermissions(projectId: string, agentId: string): Promise<EffectivePermissions | null> {
|
||||
return this.http.invoke<EffectivePermissions | null>("resolve_agent_permissions", {
|
||||
request: { projectId, agentId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpWorkStateGateway implements WorkStateGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
async getProjectWorkState(projectId: string): Promise<ProjectWorkState> {
|
||||
const state = await this.http.invoke<unknown>("get_project_work_state", { projectId });
|
||||
return normalizeProjectWorkState(state);
|
||||
}
|
||||
async cancelBackgroundTask(taskId: string): Promise<void> {
|
||||
await this.http.invoke<unknown>("cancel_background_task", { taskId });
|
||||
}
|
||||
async retryBackgroundTask(taskId: string): Promise<void> {
|
||||
await this.http.invoke<unknown>("retry_background_task", { taskId });
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpConversationGateway implements ConversationGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
async readPage(
|
||||
projectId: string,
|
||||
conversationId: string,
|
||||
request?: ConversationPageRequest,
|
||||
): Promise<TurnPage> {
|
||||
const page = await this.http.invoke<unknown>("read_conversation_page", {
|
||||
request: {
|
||||
projectId,
|
||||
conversationId,
|
||||
anchor: request?.anchor,
|
||||
direction: request?.direction ?? "backward",
|
||||
limit: request?.limit,
|
||||
},
|
||||
});
|
||||
return normalizeTurnPage(page);
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpInputGateway implements InputGateway {
|
||||
constructor(private readonly http: HttpInvoker) {}
|
||||
async interrupt(projectId: string, agentId: string): Promise<void> {
|
||||
await this.http.invoke("interrupt_agent", { request: { projectId, agentId } });
|
||||
}
|
||||
async delegationDelivered(projectId: string, agentId: string, ticket: string): Promise<void> {
|
||||
await this.http.invoke("delegation_delivered", { request: { projectId, agentId, ticket } });
|
||||
}
|
||||
async setFrontAttached(agentId: string, attached: boolean): Promise<void> {
|
||||
await this.http.invoke("set_front_attached", { request: { agentId, attached } });
|
||||
}
|
||||
async cancelResume(agentId: string): Promise<boolean> {
|
||||
return this.http.invoke<boolean>("cancel_resume", { agentId });
|
||||
}
|
||||
async setResumeAt(agentId: string, resetsAtMs: number): Promise<void> {
|
||||
await this.http.invoke("set_resume_at", { agentId, resetsAtMs });
|
||||
}
|
||||
}
|
||||
392
frontend/src/adapters/http/streamGateways.ts
Normal file
392
frontend/src/adapters/http/streamGateways.ts
Normal file
@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Stream + mixed gateways for the web transport — ticket #13, lot F1.
|
||||
*
|
||||
* These gateways combine HTTP request/response (via {@link HttpInvoker}) with the
|
||||
* WebSocket live streams (via {@link WsLiveClient}), per the B0 contract:
|
||||
* - {@link HttpSystemGateway}: `health` over HTTP; `onDomainEvent` over the WS
|
||||
* `event.domain` stream; `pickFolder` is desktop-only ⇒ unsupported on web.
|
||||
* - {@link HttpAgentGateway}: all agent request/response over HTTP; `launchAgent`
|
||||
* / `reattach` over the WS PTY stream (**skeleton** — full round-trip in
|
||||
* F3/B5/B6).
|
||||
* - {@link HttpTicketGateway}: all `ticket_*`/`sprint_*` over HTTP; `sendTicketChat`
|
||||
* over the WS `chat.*` stream (**skeleton**).
|
||||
* - {@link HttpTerminalGateway}: PTY open/reattach/close over the WS terminal
|
||||
* stream (**skeleton**).
|
||||
*
|
||||
* "Skeleton" means the frames are wired to the B0 contract and the handles are
|
||||
* structurally correct against the ports, but a live end-to-end terminal needs
|
||||
* the server (B3/B4) and the F3/B5 wiring. Lives in `src/adapters/**`; no
|
||||
* `@tauri-apps/api`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
DomainEvent,
|
||||
HealthReport,
|
||||
ReplyChunk,
|
||||
ResumableAgent,
|
||||
Sprint,
|
||||
TerminalSession,
|
||||
Ticket,
|
||||
TicketCarnet,
|
||||
TicketChat,
|
||||
TicketLinkKind,
|
||||
TicketList,
|
||||
Unsubscribe,
|
||||
} from "@/domain";
|
||||
import type {
|
||||
AgentGateway,
|
||||
ConversationDetails,
|
||||
CreateAgentInput,
|
||||
CreateTicketInput,
|
||||
LiveAgent,
|
||||
OpenTerminalOptions,
|
||||
ReattachResult,
|
||||
StoppedLiveAgent,
|
||||
SystemGateway,
|
||||
TerminalGateway,
|
||||
TerminalHandle,
|
||||
TicketGateway,
|
||||
TicketListQuery,
|
||||
UpdateTicketInput,
|
||||
} from "@/ports";
|
||||
import type { HttpInvoker } from "./httpInvoker";
|
||||
import { WsLiveClient } from "./wsLiveClient";
|
||||
import { base64ToBytes, type AttachedPayload, type ChatOutputPayload } from "./frames";
|
||||
import { unsupportedOnWeb } from "./unsupported";
|
||||
|
||||
/** Concatenates a scrollback frame list into a single byte buffer. */
|
||||
function attachedToScrollback(payload: AttachedPayload): Uint8Array {
|
||||
const chunks = payload.scrollback.map((c) => base64ToBytes(c.bytesBase64));
|
||||
const total = chunks.reduce((n, c) => n + c.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
out.set(c, offset);
|
||||
offset += c.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a {@link TerminalHandle} whose control operations are WS frames. The
|
||||
* output stream is delivered through the sink the gateway registered on the
|
||||
* {@link WsLiveClient} for this `sessionId`.
|
||||
*/
|
||||
export function makeWsTerminalHandle(
|
||||
sessionId: string,
|
||||
ws: WsLiveClient,
|
||||
assignedConversationId?: string,
|
||||
): TerminalHandle {
|
||||
return {
|
||||
sessionId,
|
||||
...(assignedConversationId ? { assignedConversationId } : {}),
|
||||
async write(data: Uint8Array): Promise<void> {
|
||||
await ws.sendFireAndForget("terminal.input", {
|
||||
sessionId,
|
||||
bytesBase64: WsLiveClient.encodeInput(data),
|
||||
});
|
||||
},
|
||||
async resize(rows: number, cols: number): Promise<void> {
|
||||
await ws.sendFireAndForget("terminal.resize", { sessionId, rows, cols });
|
||||
},
|
||||
detach(): void {
|
||||
// Stop delivering output locally and tell the server the view is gone. The
|
||||
// backend PTY keeps running (detach ≠ close), matching the Tauri handle.
|
||||
ws.removeOutputSink(sessionId);
|
||||
void ws.sendFireAndForget("terminal.detach", { sessionId });
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
ws.removeOutputSink(sessionId);
|
||||
await ws.send("terminal.close", { sessionId });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class HttpSystemGateway implements SystemGateway {
|
||||
constructor(
|
||||
private readonly http: HttpInvoker,
|
||||
private readonly ws: WsLiveClient,
|
||||
) {}
|
||||
|
||||
health(note?: string): Promise<HealthReport> {
|
||||
return this.http.invoke<HealthReport>("health", {
|
||||
request: note === undefined ? null : { note },
|
||||
});
|
||||
}
|
||||
|
||||
async onDomainEvent(handler: (event: DomainEvent) => void): Promise<Unsubscribe> {
|
||||
// The low-frequency domain-event stream rides the same WS connection
|
||||
// (`event.domain`), replacing Tauri `listen("domain://event")`.
|
||||
await this.ws.ensureConnected();
|
||||
this.ws.setDomainEventHandler(handler);
|
||||
return () => this.ws.clearDomainEventHandler();
|
||||
}
|
||||
|
||||
pickFolder(): Promise<string | null> {
|
||||
// Desktop-only: the native OS dialog has no web equivalent. A server-side
|
||||
// folder browser would be its own lot (flagged in the F1 report).
|
||||
return unsupportedOnWeb("Native folder picker");
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpTerminalGateway implements TerminalGateway {
|
||||
constructor(private readonly ws: WsLiveClient) {}
|
||||
|
||||
async openTerminal(
|
||||
options: OpenTerminalOptions,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<TerminalHandle> {
|
||||
const ack = await this.ws.send("terminal.open", {
|
||||
projectId: undefined, // TODO(F3/B5): the port lacks projectId; confirm contract.
|
||||
nodeId: options.nodeId ?? null,
|
||||
cwd: options.cwd,
|
||||
rows: options.rows,
|
||||
cols: options.cols,
|
||||
});
|
||||
const payload = ack.payload as unknown as AttachedPayload;
|
||||
const sessionId = payload.session.sessionId;
|
||||
this.ws.setOutputSink(sessionId, onData);
|
||||
const scrollback = attachedToScrollback(payload);
|
||||
if (scrollback.length > 0) onData(scrollback);
|
||||
return makeWsTerminalHandle(sessionId, this.ws);
|
||||
}
|
||||
|
||||
async reattach(
|
||||
sessionId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<ReattachResult> {
|
||||
const ack = await this.ws.send("terminal.attach", { sessionId, lastSeq: null });
|
||||
const payload = ack.payload as unknown as AttachedPayload;
|
||||
this.ws.setOutputSink(sessionId, onData);
|
||||
return {
|
||||
handle: makeWsTerminalHandle(sessionId, this.ws),
|
||||
scrollback: attachedToScrollback(payload),
|
||||
};
|
||||
}
|
||||
|
||||
async closeTerminal(sessionId: string): Promise<void> {
|
||||
this.ws.removeOutputSink(sessionId);
|
||||
await this.ws.send("terminal.close", { sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpAgentGateway implements AgentGateway {
|
||||
constructor(
|
||||
private readonly http: HttpInvoker,
|
||||
private readonly ws: WsLiveClient,
|
||||
) {}
|
||||
|
||||
listAgents(projectId: string): Promise<Agent[]> {
|
||||
return this.http.invoke<Agent[]>("list_agents", { projectId });
|
||||
}
|
||||
listLiveAgents(projectId: string): Promise<LiveAgent[]> {
|
||||
return this.http.invoke<LiveAgent[]>("list_live_agents", { projectId });
|
||||
}
|
||||
listResumableAgents(projectId: string): Promise<ResumableAgent[]> {
|
||||
return this.http
|
||||
.invoke<{ resumable: ResumableAgent[] }>("list_resumable_agents", { projectId })
|
||||
.then((res) => res.resumable);
|
||||
}
|
||||
attachLiveAgent(projectId: string, agentId: string, nodeId: string): Promise<LiveAgent> {
|
||||
return this.http.invoke<LiveAgent>("attach_live_agent", { request: { projectId, agentId, nodeId } });
|
||||
}
|
||||
stopLiveAgent(projectId: string, agentId: string): Promise<StoppedLiveAgent> {
|
||||
return this.http.invoke<StoppedLiveAgent>("stop_live_agent", { request: { projectId, agentId } });
|
||||
}
|
||||
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
|
||||
return this.http.invoke<Agent>("create_agent", {
|
||||
request: {
|
||||
projectId,
|
||||
name: input.name,
|
||||
profileId: input.profileId,
|
||||
initialContent: input.initialContent ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
changeAgentProfile(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
profileId: string,
|
||||
rows: number,
|
||||
cols: number,
|
||||
): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }> {
|
||||
return this.http.invoke<{ agent: Agent; relaunchedSession?: TerminalSession }>("change_agent_profile", {
|
||||
request: { projectId, agentId, profileId, rows, cols },
|
||||
});
|
||||
}
|
||||
readContext(projectId: string, agentId: string): Promise<string> {
|
||||
return this.http.invoke<string>("read_agent_context", { projectId, agentId });
|
||||
}
|
||||
async updateContext(projectId: string, agentId: string, content: string): Promise<void> {
|
||||
await this.http.invoke("update_agent_context", { request: { projectId, agentId, content } });
|
||||
}
|
||||
async deleteAgent(projectId: string, agentId: string): Promise<void> {
|
||||
await this.http.invoke("delete_agent", { projectId, agentId });
|
||||
}
|
||||
inspectConversation(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
conversationId: string,
|
||||
): Promise<ConversationDetails> {
|
||||
return this.http.invoke<ConversationDetails>("inspect_conversation", {
|
||||
request: { projectId, agentId, conversationId },
|
||||
});
|
||||
}
|
||||
|
||||
async launchAgent(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
options: OpenTerminalOptions,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<TerminalHandle> {
|
||||
// Raw-CLI agents stream over the same WS PTY channel (B0). Structured
|
||||
// sessions do not use this channel — that branch is B6.
|
||||
const ack = await this.ws.send("agent.launch", {
|
||||
projectId,
|
||||
agentId,
|
||||
nodeId: options.nodeId ?? null,
|
||||
rows: options.rows,
|
||||
cols: options.cols,
|
||||
conversationId: options.conversationId ?? null,
|
||||
});
|
||||
const payload = ack.payload as unknown as AttachedPayload;
|
||||
const sessionId = payload.session.sessionId;
|
||||
this.ws.setOutputSink(sessionId, onData);
|
||||
const scrollback = attachedToScrollback(payload);
|
||||
if (scrollback.length > 0) onData(scrollback);
|
||||
return makeWsTerminalHandle(sessionId, this.ws, payload.assignedConversationId);
|
||||
}
|
||||
|
||||
async reattach(
|
||||
sessionId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<ReattachResult> {
|
||||
const ack = await this.ws.send("terminal.attach", { sessionId, lastSeq: null });
|
||||
const payload = ack.payload as unknown as AttachedPayload;
|
||||
this.ws.setOutputSink(sessionId, onData);
|
||||
return {
|
||||
handle: makeWsTerminalHandle(sessionId, this.ws),
|
||||
scrollback: attachedToScrollback(payload),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpTicketGateway implements TicketGateway {
|
||||
constructor(
|
||||
private readonly http: HttpInvoker,
|
||||
private readonly ws: WsLiveClient,
|
||||
) {}
|
||||
|
||||
create(projectId: string, input: CreateTicketInput): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_create", { request: { projectId, ...input } });
|
||||
}
|
||||
read(projectId: string, ref: string, includeCarnet = false): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_read", { request: { projectId, ref, includeCarnet } });
|
||||
}
|
||||
list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
|
||||
const { statuses, priorities, ...rest } = query ?? {};
|
||||
return this.http.invoke<TicketList>("ticket_list", {
|
||||
request: { projectId, statuses: statuses ?? [], priorities: priorities ?? [], ...rest },
|
||||
});
|
||||
}
|
||||
update(projectId: string, ref: string, input: UpdateTicketInput): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_update", { request: { projectId, ref, ...input } });
|
||||
}
|
||||
async delete(projectId: string, ref: string): Promise<void> {
|
||||
await this.http.invoke<void>("ticket_delete", { request: { projectId, ref } });
|
||||
}
|
||||
readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
|
||||
return this.http.invoke<TicketCarnet>("ticket_read_carnet", { request: { projectId, ref } });
|
||||
}
|
||||
updateCarnet(projectId: string, ref: string, carnet: string, expectedVersion: number): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_update_carnet", {
|
||||
request: { projectId, ref, carnet, expectedVersion },
|
||||
});
|
||||
}
|
||||
link(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
targetRef: string,
|
||||
kind: TicketLinkKind,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_link", {
|
||||
request: { projectId, ref, targetRef, kind, expectedVersion },
|
||||
});
|
||||
}
|
||||
unlink(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
targetRef: string,
|
||||
expectedVersion: number,
|
||||
kind?: TicketLinkKind,
|
||||
): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_unlink", {
|
||||
request: { projectId, ref, targetRef, expectedVersion, kind },
|
||||
});
|
||||
}
|
||||
assign(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
agentId: string,
|
||||
assigned: boolean,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket> {
|
||||
return this.http.invoke<Ticket>("ticket_assign", {
|
||||
request: { projectId, ref, agentId, assigned, expectedVersion },
|
||||
});
|
||||
}
|
||||
async listSprints(projectId: string): Promise<Sprint[]> {
|
||||
const list = await this.http.invoke<{ items: Sprint[] }>("sprint_list", { request: { projectId } });
|
||||
return list.items;
|
||||
}
|
||||
setTicketSprint(
|
||||
projectId: string,
|
||||
ref: string,
|
||||
sprintId: string | null,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket> {
|
||||
if (sprintId === null) {
|
||||
return this.http.invoke<Ticket>("ticket_unassign_sprint", { request: { projectId, ref, expectedVersion } });
|
||||
}
|
||||
return this.http.invoke<Ticket>("ticket_assign_sprint", {
|
||||
request: { projectId, ref, sprintId, expectedVersion },
|
||||
});
|
||||
}
|
||||
createSprint(projectId: string, name: string): Promise<Sprint> {
|
||||
return this.http.invoke<Sprint>("sprint_create", { request: { projectId, name } });
|
||||
}
|
||||
renameSprint(projectId: string, sprintId: string, name: string, expectedVersion: number): Promise<Sprint> {
|
||||
return this.http.invoke<Sprint>("sprint_rename", { request: { projectId, sprintId, name, expectedVersion } });
|
||||
}
|
||||
async reorderSprints(projectId: string, orderedIds: string[]): Promise<Sprint[]> {
|
||||
const list = await this.http.invoke<{ items: Sprint[] }>("sprint_reorder", {
|
||||
request: { projectId, orderedIds },
|
||||
});
|
||||
return list.items;
|
||||
}
|
||||
async deleteSprint(projectId: string, sprintId: string): Promise<void> {
|
||||
await this.http.invoke<void>("sprint_delete", { request: { projectId, sprintId } });
|
||||
}
|
||||
openTicketChat(projectId: string, issueRef: string, profileId: string): Promise<TicketChat> {
|
||||
return this.http.invoke<TicketChat>("open_ticket_chat", { request: { projectId, issueRef, profileId } });
|
||||
}
|
||||
async closeTicketChat(projectId: string, issueRef: string): Promise<void> {
|
||||
await this.http.invoke<void>("close_ticket_chat", { request: { projectId, issueRef } });
|
||||
}
|
||||
|
||||
async sendTicketChat(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
onChunk: (chunk: ReplyChunk) => void,
|
||||
): Promise<void> {
|
||||
// Structured assistant reply stream over WS (`chat.*`, B0). Skeleton: route
|
||||
// this session's `chat.output` chunks to `onChunk` until the `final` chunk.
|
||||
// TODO(F3/B5): the WsLiveClient currently routes PTY output + domain events;
|
||||
// a per-session chat sink is added alongside the terminal sink in F3.
|
||||
const ack = await this.ws.send("chat.send", { sessionId, prompt: message });
|
||||
const payload = ack.payload as unknown as ChatOutputPayload | undefined;
|
||||
if (payload && payload.chunk) onChunk(payload.chunk as ReplyChunk);
|
||||
}
|
||||
}
|
||||
77
frontend/src/adapters/http/unsupported.ts
Normal file
77
frontend/src/adapters/http/unsupported.ts
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Desktop-only surfaces that have no web equivalent in V1 (ticket #13, lot F1):
|
||||
* the native folder picker, OS view-windows and the main↔detached focused-project
|
||||
* channel, plus SSH/WSL remote. On the web client these must fail with a clear,
|
||||
* explicit error rather than reaching for Tauri (which is absent).
|
||||
*
|
||||
* The B0 inventory classified these as `desktop-only`; F1 does NOT implement them
|
||||
* for web. If/when the product wants an equivalent (e.g. a server-side folder
|
||||
* browser to replace `pickFolder`), it gets its own lot.
|
||||
*/
|
||||
|
||||
import type { GatewayError, Unsubscribe } from "@/domain";
|
||||
import type {
|
||||
FocusedProject,
|
||||
FocusedProjectGateway,
|
||||
RemoteGateway,
|
||||
ViewWindowClosed,
|
||||
ViewWindowSnapshot,
|
||||
WindowGateway,
|
||||
} from "@/ports";
|
||||
|
||||
/** Throws a stable, explicit "unsupported on web" {@link GatewayError}. */
|
||||
export function unsupportedOnWeb(what: string): never {
|
||||
const err: GatewayError = {
|
||||
code: "UNSUPPORTED_ON_WEB",
|
||||
message: `${what} is not available in the web client (desktop-only).`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
|
||||
/** Web stub: OS view-windows are desktop-only (no `WebviewWindow` on the web). */
|
||||
export class WebWindowGateway implements WindowGateway {
|
||||
openViewWindow(): Promise<void> {
|
||||
return unsupportedOnWeb("Detaching a panel into an OS window");
|
||||
}
|
||||
closeViewWindow(): Promise<void> {
|
||||
return unsupportedOnWeb("Closing a detached OS window");
|
||||
}
|
||||
listOpenViewWindows(): Promise<ViewWindowSnapshot[]> {
|
||||
// Best-effort by contract (callers treat rejection as "no reconciliation"),
|
||||
// but there are simply never detached OS windows on web → empty list.
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
onViewWindowClosed(
|
||||
_handler: (event: ViewWindowClosed) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
// No OS windows ⇒ nothing ever fires; return a no-op unsubscribe.
|
||||
return Promise.resolve(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Web stub for the focused-project channel. With no detached windows on web the
|
||||
* channel has no cross-window consumer; F1 keeps it inert (no publish, no focus).
|
||||
* A future multi-tab web build could back this with `BroadcastChannel`.
|
||||
*/
|
||||
export class WebFocusedProjectGateway implements FocusedProjectGateway {
|
||||
setFocusedProject(_project: FocusedProject | null): Promise<void> {
|
||||
// No-op: nothing reads the channel on web V1.
|
||||
return Promise.resolve();
|
||||
}
|
||||
getFocusedProject(): Promise<FocusedProject | null> {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
onFocusedProjectChanged(
|
||||
_handler: (project: FocusedProject | null) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
return Promise.resolve(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Web stub: SSH/WSL remote connection is desktop-only in V1. */
|
||||
export class WebRemoteGateway implements RemoteGateway {
|
||||
connect(): Promise<void> {
|
||||
return unsupportedOnWeb("Remote (SSH/WSL) connection");
|
||||
}
|
||||
}
|
||||
92
frontend/src/adapters/http/webSession.test.ts
Normal file
92
frontend/src/adapters/http/webSession.test.ts
Normal file
@ -0,0 +1,92 @@
|
||||
/**
|
||||
* F2 — the web session: pairing handshake (success marks the flag; wrong code
|
||||
* rejects with a clear message), and the 401 → unauthorized transition that
|
||||
* clears the flag and notifies subscribers.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import type { FetchLike } from "./httpInvoker";
|
||||
import { WebSession, type FlagStore } from "./webSession";
|
||||
|
||||
function memStore(): FlagStore {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => map.get(k) ?? null,
|
||||
setItem: (k, v) => void map.set(k, v),
|
||||
removeItem: (k) => void map.delete(k),
|
||||
};
|
||||
}
|
||||
|
||||
function fetchReturning(status: number, body: unknown): {
|
||||
fetchImpl: FetchLike;
|
||||
calls: { url: string; init: unknown }[];
|
||||
} {
|
||||
const calls: { url: string; init: unknown }[] = [];
|
||||
const fetchImpl: FetchLike = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
};
|
||||
};
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
describe("WebSession pairing", () => {
|
||||
it("POSTs the code to /api/pair and marks the flag on success", async () => {
|
||||
const store = memStore();
|
||||
const { fetchImpl, calls } = fetchReturning(200, { ok: true });
|
||||
const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store });
|
||||
|
||||
expect(session.isPaired()).toBe(false);
|
||||
await session.pair("4821-93");
|
||||
|
||||
expect(session.isPaired()).toBe(true);
|
||||
expect(calls[0].url).toBe("https://host/api/pair");
|
||||
expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({ code: "4821-93" });
|
||||
});
|
||||
|
||||
it("rejects a wrong code with a clear message and stays unpaired", async () => {
|
||||
const store = memStore();
|
||||
const { fetchImpl } = fetchReturning(401, { code: "INVALID", message: "bad code" });
|
||||
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store });
|
||||
|
||||
await expect(session.pair("nope")).rejects.toMatchObject({ code: "INVALID_PAIRING_CODE" });
|
||||
expect(session.isPaired()).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to a default message when the server sends no body", async () => {
|
||||
const { fetchImpl } = fetchReturning(403, undefined);
|
||||
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });
|
||||
|
||||
await expect(session.pair("x")).rejects.toMatchObject({
|
||||
code: "INVALID_PAIRING_CODE",
|
||||
message: "Code d'appairage invalide.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSession unauthorized handling", () => {
|
||||
it("clears the flag and notifies subscribers on notifyUnauthorized", () => {
|
||||
const store = memStore();
|
||||
const session = new WebSession({ baseUrl: "https://host", store });
|
||||
session.markPaired();
|
||||
const handler = vi.fn();
|
||||
session.onUnauthorized(handler);
|
||||
|
||||
session.notifyUnauthorized();
|
||||
|
||||
expect(session.isPaired()).toBe(false);
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("forget() clears the flag (best-effort local sign-out)", () => {
|
||||
const store = memStore();
|
||||
const session = new WebSession({ baseUrl: "https://host", store });
|
||||
session.markPaired();
|
||||
session.forget();
|
||||
expect(session.isPaired()).toBe(false);
|
||||
});
|
||||
});
|
||||
175
frontend/src/adapters/http/webSession.ts
Normal file
175
frontend/src/adapters/http/webSession.ts
Normal file
@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Web client session (pairing + auth routing) — ticket #13, lot F2.
|
||||
*
|
||||
* The real authentication material is a session cookie set by the server at
|
||||
* pairing (`POST /api/pair {code}` → `Set-Cookie: … HttpOnly; Secure;
|
||||
* SameSite=Strict`). Because it is **HttpOnly** the browser sends it
|
||||
* automatically on same-origin requests but JS can never read it. So this module
|
||||
* keeps only a lightweight, JS-visible **"paired" flag** (in `localStorage`) to
|
||||
* decide which screen to render — never the secret itself.
|
||||
*
|
||||
* On a `401` from any `/api/invoke`, the {@link HttpInvoker} calls
|
||||
* {@link WebSession.notifyUnauthorized}, which clears the flag and notifies
|
||||
* subscribers so the app routes back to the pairing screen. "Forget" is a
|
||||
* best-effort local clear (server-side cookie revocation is B8).
|
||||
*
|
||||
* Lives in `src/adapters/**`; touches no `@tauri-apps/api`.
|
||||
*/
|
||||
|
||||
import type { GatewayError, Unsubscribe } from "@/domain";
|
||||
import type { FetchLike } from "./httpInvoker";
|
||||
|
||||
const PAIRED_FLAG_KEY = "idea.web.paired";
|
||||
|
||||
/** Minimal `localStorage`-like surface, injectable for tests. */
|
||||
export interface FlagStore {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
/** Configuration for a {@link WebSession}. */
|
||||
export interface WebSessionConfig {
|
||||
/** Backend base URL (no trailing slash). Defaults to the page origin. */
|
||||
baseUrl?: string;
|
||||
/** Injected fetch (defaults to global `fetch`, with same-origin credentials). */
|
||||
fetchImpl?: FetchLike;
|
||||
/** Injected flag store (defaults to `window.localStorage`, else in-memory). */
|
||||
store?: FlagStore;
|
||||
}
|
||||
|
||||
/** In-memory fallback when `localStorage` is unavailable (SSR/tests). */
|
||||
function memoryStore(): FlagStore {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => map.get(k) ?? null,
|
||||
setItem: (k, v) => void map.set(k, v),
|
||||
removeItem: (k) => void map.delete(k),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultStore(): FlagStore {
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.localStorage) {
|
||||
return window.localStorage;
|
||||
}
|
||||
} catch {
|
||||
/* access can throw in a sandboxed iframe; fall through */
|
||||
}
|
||||
return memoryStore();
|
||||
}
|
||||
|
||||
function defaultBaseUrl(): string {
|
||||
return typeof window !== "undefined" && window.location
|
||||
? window.location.origin
|
||||
: "http://localhost";
|
||||
}
|
||||
|
||||
/**
|
||||
* The web session state machine. One instance is shared between the pairing UI
|
||||
* and the HTTP invoker (via the module singleton below).
|
||||
*/
|
||||
export class WebSession {
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchImpl: FetchLike;
|
||||
private readonly store: FlagStore;
|
||||
private readonly unauthorizedHandlers = new Set<() => void>();
|
||||
|
||||
constructor(config: WebSessionConfig = {}) {
|
||||
this.baseUrl = (config.baseUrl ?? defaultBaseUrl()).replace(/\/+$/, "");
|
||||
this.fetchImpl = config.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
|
||||
this.store = config.store ?? defaultStore();
|
||||
}
|
||||
|
||||
/** Whether the client believes it is paired (a session cookie should exist). */
|
||||
isPaired(): boolean {
|
||||
return this.store.getItem(PAIRED_FLAG_KEY) === "1";
|
||||
}
|
||||
|
||||
/** Records the paired flag (called after a successful pair). */
|
||||
markPaired(): void {
|
||||
this.store.setItem(PAIRED_FLAG_KEY, "1");
|
||||
}
|
||||
|
||||
/** Best-effort local sign-out: clears the flag (server revocation is B8). */
|
||||
forget(): void {
|
||||
this.store.removeItem(PAIRED_FLAG_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the pairing handshake: `POST /api/pair {code}`. On success the
|
||||
* server sets the session cookie and this records the paired flag. A wrong code
|
||||
* rejects with a {@link GatewayError} carrying a clear message.
|
||||
*/
|
||||
async pair(code: string): Promise<void> {
|
||||
let res: Awaited<ReturnType<FetchLike>>;
|
||||
try {
|
||||
res = await this.fetchImpl(`${this.baseUrl}/api/pair`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
} catch (networkError) {
|
||||
const err: GatewayError = {
|
||||
code: "TRANSPORT_ERROR",
|
||||
message: `Impossible de joindre le serveur : ${String(networkError)}`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await res.json();
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
const message =
|
||||
parsed && typeof parsed === "object" && "message" in parsed
|
||||
? String((parsed as GatewayError).message)
|
||||
: res.status === 401 || res.status === 403
|
||||
? "Code d'appairage invalide."
|
||||
: `Échec de l'appairage (HTTP ${res.status}).`;
|
||||
const err: GatewayError = {
|
||||
code: res.status === 401 || res.status === 403 ? "INVALID_PAIRING_CODE" : "PAIRING_FAILED",
|
||||
message,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Cookie is now set by the server (HttpOnly — not read here); record the flag.
|
||||
this.markPaired();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the HTTP invoker on a `401`: the session cookie is missing/expired.
|
||||
* Clears the flag and notifies subscribers so the app returns to pairing.
|
||||
*/
|
||||
notifyUnauthorized(): void {
|
||||
this.forget();
|
||||
for (const handler of this.unauthorizedHandlers) handler();
|
||||
}
|
||||
|
||||
/** Subscribes to unauthorized transitions (returns an unsubscribe). */
|
||||
onUnauthorized(handler: () => void): Unsubscribe {
|
||||
this.unauthorizedHandlers.add(handler);
|
||||
return () => this.unauthorizedHandlers.delete(handler);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module singleton (shared between the invoker and the pairing UI)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let singleton: WebSession | null = null;
|
||||
|
||||
/** Returns the shared web session, creating it on first use. */
|
||||
export function getWebSession(): WebSession {
|
||||
if (!singleton) singleton = new WebSession();
|
||||
return singleton;
|
||||
}
|
||||
|
||||
/** Overrides the singleton (tests). Pass `null` to reset to a fresh default. */
|
||||
export function setWebSessionForTests(session: WebSession | null): void {
|
||||
singleton = session;
|
||||
}
|
||||
123
frontend/src/adapters/http/wsLiveClient.test.ts
Normal file
123
frontend/src/adapters/http/wsLiveClient.test.ts
Normal file
@ -0,0 +1,123 @@
|
||||
/**
|
||||
* F1 — the WS live client skeleton: connects lazily, correlates a command with
|
||||
* its `replyTo` acknowledgement, routes per-session PTY output to the registered
|
||||
* sink, and dispatches `event.domain` frames to the domain-event handler.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import type { WebSocketLike } from "./wsLiveClient";
|
||||
import { WsLiveClient } from "./wsLiveClient";
|
||||
import { bytesToBase64, base64ToBytes } from "./frames";
|
||||
|
||||
/** A controllable fake WebSocket that auto-completes its open handshake. */
|
||||
class FakeSocket implements WebSocketLike {
|
||||
sent: string[] = [];
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onerror: ((event: unknown) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
close(): void {
|
||||
this.onclose?.();
|
||||
}
|
||||
/** Simulate the server pushing a frame. */
|
||||
receive(frame: unknown): void {
|
||||
this.onmessage?.({ data: JSON.stringify(frame) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a client whose socket auto-opens on the next microtask (after the
|
||||
* client has assigned `onopen`), so `ensureConnected()`/`send()` resolve without
|
||||
* manual driving.
|
||||
*/
|
||||
function connectedClient(): { client: WsLiveClient; socket: FakeSocket } {
|
||||
const socket = new FakeSocket();
|
||||
const client = new WsLiveClient({
|
||||
wsUrl: "wss://host",
|
||||
socketFactory: () => {
|
||||
queueMicrotask(() => socket.onopen?.());
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
return { client, socket };
|
||||
}
|
||||
|
||||
describe("base64 helpers round-trip binary", () => {
|
||||
it("encodes and decodes arbitrary bytes", () => {
|
||||
const bytes = new Uint8Array([0, 13, 27, 255, 128, 10]);
|
||||
expect(base64ToBytes(bytesToBase64(bytes))).toEqual(bytes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WsLiveClient", () => {
|
||||
it("connects lazily and resolves a command with its replyTo ack", async () => {
|
||||
const { client, socket } = connectedClient();
|
||||
|
||||
const ackPromise = client.send("terminal.open", { cwd: "/root", rows: 30, cols: 120 });
|
||||
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
|
||||
|
||||
const sent = JSON.parse(socket.sent[0]);
|
||||
expect(sent.kind).toBe("terminal.open");
|
||||
expect(sent.payload).toMatchObject({ cwd: "/root", rows: 30, cols: 120 });
|
||||
|
||||
socket.receive({
|
||||
kind: "terminal.attached",
|
||||
replyTo: sent.id,
|
||||
payload: { session: { sessionId: "s1", rows: 30, cols: 120 }, nextSeq: 1, scrollback: [], gap: false },
|
||||
});
|
||||
|
||||
const ack = await ackPromise;
|
||||
expect(ack.kind).toBe("terminal.attached");
|
||||
});
|
||||
|
||||
it("routes terminal.output to the per-session sink", async () => {
|
||||
const { client, socket } = connectedClient();
|
||||
await client.ensureConnected();
|
||||
|
||||
const received: Uint8Array[] = [];
|
||||
client.setOutputSink("s1", (bytes) => received.push(bytes));
|
||||
|
||||
socket.receive({
|
||||
kind: "terminal.output",
|
||||
payload: { sessionId: "s1", seq: 1, bytesBase64: bytesToBase64(new Uint8Array([104, 105])) },
|
||||
});
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual(new Uint8Array([104, 105]));
|
||||
|
||||
// After removing the sink, further output is dropped.
|
||||
client.removeOutputSink("s1");
|
||||
socket.receive({
|
||||
kind: "terminal.output",
|
||||
payload: { sessionId: "s1", seq: 2, bytesBase64: bytesToBase64(new Uint8Array([106])) },
|
||||
});
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("dispatches event.domain frames to the domain-event handler", async () => {
|
||||
const { client, socket } = connectedClient();
|
||||
await client.ensureConnected();
|
||||
|
||||
const handler = vi.fn();
|
||||
client.setDomainEventHandler(handler);
|
||||
|
||||
socket.receive({ kind: "event.domain", payload: { type: "agentBusyChanged", agentId: "a1", busy: true } });
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ type: "agentBusyChanged", agentId: "a1", busy: true });
|
||||
});
|
||||
|
||||
it("rejects a pending command when an error frame arrives", async () => {
|
||||
const { client, socket } = connectedClient();
|
||||
const promise = client.send("terminal.close", { sessionId: "gone" });
|
||||
await vi.waitFor(() => expect(socket.sent).toHaveLength(1));
|
||||
|
||||
const sent = JSON.parse(socket.sent[0]);
|
||||
socket.receive({ kind: "error", replyTo: sent.id, payload: { code: "NOT_FOUND", message: "no session" } });
|
||||
|
||||
await expect(promise).rejects.toEqual({ code: "NOT_FOUND", message: "no session" });
|
||||
});
|
||||
});
|
||||
232
frontend/src/adapters/http/wsLiveClient.ts
Normal file
232
frontend/src/adapters/http/wsLiveClient.ts
Normal file
@ -0,0 +1,232 @@
|
||||
/**
|
||||
* WebSocket live client skeleton for the web transport — ticket #13, lot F1.
|
||||
*
|
||||
* Owns a single WS connection to `{wsUrl}/ws/live` and multiplexes over it, per
|
||||
* the B0 draft: PTY output, structured chat output and the low-frequency domain
|
||||
* event stream (`event.domain`, replacing Tauri `listen("domain://event")`).
|
||||
*
|
||||
* **F1 scope = skeleton.** The connection, frame envelope, request/reply
|
||||
* correlation by `id`, and the per-session output routing are implemented and
|
||||
* unit-testable (inject a fake WebSocket factory). What is deliberately deferred
|
||||
* to **F3/B5** (when a server exists — B3/B4): reconnection/backpressure, exact
|
||||
* `open`/`launch` reply shape, sequence-gap replay policy, and the full xterm
|
||||
* round-trip. Those are marked `TODO(F3/B5)`.
|
||||
*
|
||||
* Lives in `src/adapters/**`; touches no `@tauri-apps/api`.
|
||||
*/
|
||||
|
||||
import type { DomainEvent } from "@/domain";
|
||||
import {
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
type ClientFrame,
|
||||
type ClientFrameKind,
|
||||
type OutputPayload,
|
||||
type ServerFrame,
|
||||
} from "./frames";
|
||||
|
||||
/** Minimal WebSocket surface used here; injectable so tests pass a fake. */
|
||||
export interface WebSocketLike {
|
||||
send(data: string): void;
|
||||
close(): void;
|
||||
onopen: (() => void) | null;
|
||||
onmessage: ((event: { data: string }) => void) | null;
|
||||
onerror: ((event: unknown) => void) | null;
|
||||
onclose: (() => void) | null;
|
||||
}
|
||||
|
||||
/** Factory building a {@link WebSocketLike} for a URL (defaults to global WS). */
|
||||
export type WebSocketFactory = (url: string) => WebSocketLike;
|
||||
|
||||
/** Configuration for the live client. */
|
||||
export interface WsLiveClientConfig {
|
||||
/** Base WS URL, e.g. `wss://host:port`. No trailing slash. */
|
||||
wsUrl: string;
|
||||
/** Bearer token (ticket #13 auth). B0 note: prefer header/cookie over URL. */
|
||||
token?: string;
|
||||
/** Injected WebSocket factory (defaults to `new WebSocket(url)`). */
|
||||
socketFactory?: WebSocketFactory;
|
||||
}
|
||||
|
||||
let frameCounter = 0;
|
||||
/** Monotonic client frame id (unique per client session). */
|
||||
function nextFrameId(): string {
|
||||
frameCounter += 1;
|
||||
return `c${frameCounter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the single live WebSocket. Callers subscribe an output sink per PTY
|
||||
* session and a single domain-event handler; the client routes inbound frames.
|
||||
*/
|
||||
export class WsLiveClient {
|
||||
private readonly wsUrl: string;
|
||||
private readonly token?: string;
|
||||
private readonly socketFactory: WebSocketFactory;
|
||||
|
||||
private socket: WebSocketLike | null = null;
|
||||
private opening: Promise<void> | null = null;
|
||||
|
||||
/** Per-session PTY output sinks (sessionId → onData). */
|
||||
private readonly outputSinks = new Map<string, (bytes: Uint8Array) => void>();
|
||||
/** Pending command acknowledgements, keyed by client frame id. */
|
||||
private readonly pending = new Map<
|
||||
string,
|
||||
{ resolve: (frame: ServerFrame) => void; reject: (err: unknown) => void }
|
||||
>();
|
||||
/** The single domain-event handler (set by the system gateway). */
|
||||
private domainEventHandler: ((event: DomainEvent) => void) | null = null;
|
||||
|
||||
constructor(config: WsLiveClientConfig) {
|
||||
this.wsUrl = config.wsUrl.replace(/\/+$/, "");
|
||||
this.token = config.token;
|
||||
this.socketFactory =
|
||||
config.socketFactory ??
|
||||
((url: string) => new WebSocket(url) as unknown as WebSocketLike);
|
||||
}
|
||||
|
||||
/** Registers the domain-event handler (replaces any previous one). */
|
||||
setDomainEventHandler(handler: (event: DomainEvent) => void): void {
|
||||
this.domainEventHandler = handler;
|
||||
}
|
||||
|
||||
/** Clears the domain-event handler. */
|
||||
clearDomainEventHandler(): void {
|
||||
this.domainEventHandler = null;
|
||||
}
|
||||
|
||||
/** Registers a per-session PTY output sink. */
|
||||
setOutputSink(sessionId: string, onData: (bytes: Uint8Array) => void): void {
|
||||
this.outputSinks.set(sessionId, onData);
|
||||
}
|
||||
|
||||
/** Drops a per-session PTY output sink (view detached). */
|
||||
removeOutputSink(sessionId: string): void {
|
||||
this.outputSinks.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Ensures the socket is connected, connecting on first use. */
|
||||
async ensureConnected(): Promise<void> {
|
||||
if (this.socket) return;
|
||||
if (this.opening) return this.opening;
|
||||
|
||||
this.opening = new Promise<void>((resolve, reject) => {
|
||||
// B0 auth note: token should ride an `Authorization` header / secure
|
||||
// cookie at upgrade, NOT a URL secret. Browsers can't set WS upgrade
|
||||
// headers, so the token placement is a contract point to confirm (see the
|
||||
// F1 report). The query below is only a placeholder for the skeleton.
|
||||
const url = this.token
|
||||
? `${this.wsUrl}/ws/live?token=${encodeURIComponent(this.token)}`
|
||||
: `${this.wsUrl}/ws/live`;
|
||||
const socket = this.socketFactory(url);
|
||||
socket.onopen = () => {
|
||||
this.socket = socket;
|
||||
resolve();
|
||||
};
|
||||
socket.onerror = (event) => {
|
||||
this.opening = null;
|
||||
reject(event);
|
||||
};
|
||||
socket.onclose = () => {
|
||||
// TODO(F3/B5): reconnection + re-attach of live sessions. For F1 we drop
|
||||
// the socket so a later call reconnects fresh.
|
||||
this.socket = null;
|
||||
this.rejectAllPending({ code: "WS_CLOSED", message: "socket closed" });
|
||||
};
|
||||
socket.onmessage = (event) => this.handleMessage(event.data);
|
||||
});
|
||||
return this.opening;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a client frame and resolves with its acknowledgement frame (the server
|
||||
* frame whose `replyTo` equals this frame's `id`). Fire-and-forget frames
|
||||
* (input/resize/detach) can ignore the returned promise.
|
||||
*/
|
||||
async send(
|
||||
kind: ClientFrameKind,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<ServerFrame> {
|
||||
await this.ensureConnected();
|
||||
const socket = this.socket;
|
||||
if (!socket) {
|
||||
const err = { code: "WS_CLOSED", message: "socket not connected" };
|
||||
throw err;
|
||||
}
|
||||
const id = nextFrameId();
|
||||
const frame: ClientFrame = { id, kind, payload };
|
||||
const ack = new Promise<ServerFrame>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
});
|
||||
socket.send(JSON.stringify(frame));
|
||||
return ack;
|
||||
}
|
||||
|
||||
/** Sends a fire-and-forget frame (no acknowledgement awaited). */
|
||||
async sendFireAndForget(
|
||||
kind: ClientFrameKind,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await this.ensureConnected();
|
||||
const id = nextFrameId();
|
||||
const frame: ClientFrame = { id, kind, payload };
|
||||
this.socket?.send(JSON.stringify(frame));
|
||||
}
|
||||
|
||||
/** Convenience: base64-encode bytes for an `input` frame. */
|
||||
static encodeInput(bytes: Uint8Array): string {
|
||||
return bytesToBase64(bytes);
|
||||
}
|
||||
|
||||
/** Closes the socket and rejects everything pending. */
|
||||
dispose(): void {
|
||||
this.rejectAllPending({ code: "WS_CLOSED", message: "client disposed" });
|
||||
this.outputSinks.clear();
|
||||
this.domainEventHandler = null;
|
||||
this.socket?.close();
|
||||
this.socket = null;
|
||||
this.opening = null;
|
||||
}
|
||||
|
||||
private rejectAllPending(err: unknown): void {
|
||||
for (const { reject } of this.pending.values()) reject(err);
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private handleMessage(data: string): void {
|
||||
let frame: ServerFrame;
|
||||
try {
|
||||
frame = JSON.parse(data) as ServerFrame;
|
||||
} catch {
|
||||
return; // Ignore malformed frames in the skeleton.
|
||||
}
|
||||
|
||||
// Route unsolicited streams first (no replyTo).
|
||||
switch (frame.kind) {
|
||||
case "terminal.output": {
|
||||
const payload = frame.payload as unknown as OutputPayload;
|
||||
const sink = this.outputSinks.get(payload.sessionId);
|
||||
// TODO(F3/B5): honour `seq` ordering + gap detection for precise replay.
|
||||
if (sink) sink(base64ToBytes(payload.bytesBase64));
|
||||
return;
|
||||
}
|
||||
case "event.domain": {
|
||||
// `payload` is a DomainEventDto (kind-tagged); forward as-is.
|
||||
this.domainEventHandler?.(frame.payload as unknown as DomainEvent);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Otherwise it is a reply/ack correlated by `replyTo`.
|
||||
if (frame.replyTo) {
|
||||
const waiter = this.pending.get(frame.replyTo);
|
||||
if (waiter) {
|
||||
this.pending.delete(frame.replyTo);
|
||||
if (frame.kind === "error") waiter.reject(frame.payload);
|
||||
else waiter.resolve(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,16 @@ import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, renderHook } from "@testing-library/react";
|
||||
|
||||
import { MockSystemGateway } from "@/adapters/mock";
|
||||
import { DIProvider, useGateways, resolveGateways, shouldUseMock } from "./di";
|
||||
import { HttpSystemGateway } from "@/adapters/http/streamGateways";
|
||||
import { TauriSystemGateway } from "@/adapters";
|
||||
import {
|
||||
DIProvider,
|
||||
useGateways,
|
||||
resolveGateways,
|
||||
resolveTransport,
|
||||
shouldUseMock,
|
||||
shouldUseHttp,
|
||||
} from "./di";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
@ -26,12 +35,37 @@ describe("shouldUseMock / resolveGateways", () => {
|
||||
|
||||
it("returns real (non-mock) gateways otherwise", () => {
|
||||
vi.stubEnv("VITE_USE_MOCK", "");
|
||||
vi.stubEnv("VITE_TRANSPORT", "");
|
||||
expect(shouldUseMock()).toBe(false);
|
||||
// Real adapter is not the mock implementation.
|
||||
expect(resolveGateways().system).not.toBeInstanceOf(MockSystemGateway);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTransport / web (HTTP+WS) selection (ticket #13, F1)", () => {
|
||||
it("selects the HTTP transport when VITE_TRANSPORT=http", () => {
|
||||
vi.stubEnv("VITE_USE_MOCK", "");
|
||||
vi.stubEnv("VITE_TRANSPORT", "http");
|
||||
expect(shouldUseHttp()).toBe(true);
|
||||
expect(resolveTransport()).toBe("http");
|
||||
expect(resolveGateways().system).toBeInstanceOf(HttpSystemGateway);
|
||||
});
|
||||
|
||||
it("keeps Tauri as the default when no transport env is set", () => {
|
||||
vi.stubEnv("VITE_USE_MOCK", "");
|
||||
vi.stubEnv("VITE_TRANSPORT", "");
|
||||
expect(resolveTransport()).toBe("tauri");
|
||||
expect(resolveGateways().system).toBeInstanceOf(TauriSystemGateway);
|
||||
});
|
||||
|
||||
it("lets mock win over the web transport", () => {
|
||||
vi.stubEnv("VITE_USE_MOCK", "1");
|
||||
vi.stubEnv("VITE_TRANSPORT", "http");
|
||||
expect(resolveTransport()).toBe("mock");
|
||||
expect(resolveGateways().system).toBeInstanceOf(MockSystemGateway);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DIProvider / useGateways", () => {
|
||||
it("provides explicit gateways to consumers", () => {
|
||||
const gateways = resolveGatewaysMock();
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Dependency-injection provider for the UI ports.
|
||||
*
|
||||
* Chooses real Tauri adapters vs mocks based on `VITE_USE_MOCK` (any truthy
|
||||
* value → mocks). Components consume gateways via {@link useGateways} and never
|
||||
* construct adapters or call `invoke()` themselves.
|
||||
* Chooses one of three transport implementations (ticket #13): the mock adapters
|
||||
* (`VITE_USE_MOCK`), the web HTTP+WS adapters (`VITE_TRANSPORT="http"`), or the
|
||||
* default Tauri desktop adapters. Components consume gateways via
|
||||
* {@link useGateways} and never construct adapters or call `invoke()` themselves.
|
||||
*/
|
||||
|
||||
import {
|
||||
@ -16,18 +17,45 @@ import {
|
||||
import type { Gateways } from "@/ports";
|
||||
import { createTauriGateways } from "@/adapters";
|
||||
import { createMockGateways } from "@/adapters/mock";
|
||||
import { createHttpWsGateways } from "@/adapters/http";
|
||||
|
||||
const GatewaysContext = createContext<Gateways | null>(null);
|
||||
|
||||
/** The selected transport backing the gateways. */
|
||||
export type Transport = "mock" | "http" | "tauri";
|
||||
|
||||
/** Whether the mock adapters should be used (env-driven, overridable in tests). */
|
||||
export function shouldUseMock(): boolean {
|
||||
const flag = import.meta.env.VITE_USE_MOCK;
|
||||
return flag === "1" || flag === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the web (client/server) HTTP+WS adapters should be used. Selected
|
||||
* explicitly via `VITE_TRANSPORT="http"` so the desktop path stays the default
|
||||
* and is never affected (ticket #13, lot F1).
|
||||
*/
|
||||
export function shouldUseHttp(): boolean {
|
||||
return import.meta.env.VITE_TRANSPORT === "http";
|
||||
}
|
||||
|
||||
/** Resolves which transport to use. Mock wins, then explicit web, else Tauri. */
|
||||
export function resolveTransport(): Transport {
|
||||
if (shouldUseMock()) return "mock";
|
||||
if (shouldUseHttp()) return "http";
|
||||
return "tauri";
|
||||
}
|
||||
|
||||
/** Resolves the gateway set for the current environment. */
|
||||
export function resolveGateways(): Gateways {
|
||||
return shouldUseMock() ? createMockGateways() : createTauriGateways();
|
||||
switch (resolveTransport()) {
|
||||
case "mock":
|
||||
return createMockGateways();
|
||||
case "http":
|
||||
return createHttpWsGateways();
|
||||
case "tauri":
|
||||
return createTauriGateways();
|
||||
}
|
||||
}
|
||||
|
||||
interface DIProviderProps {
|
||||
|
||||
@ -5,7 +5,8 @@ import ReactDOM from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import { ViewWindow, parseViewWindowParams } from "./ViewWindow";
|
||||
import { DIProvider } from "./di";
|
||||
import { WebApp } from "@/features/web";
|
||||
import { DIProvider, resolveTransport } from "./di";
|
||||
import "@/shared/styles/theme.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
@ -18,10 +19,17 @@ if (!root) {
|
||||
// follows the main window's focused project; otherwise the full app.
|
||||
const viewParams = parseViewWindowParams(window.location.search);
|
||||
|
||||
// Ticket #13, F2: in web (HTTP) transport mode the browser client renders the
|
||||
// pairing-gated, read-only `WebApp`. Desktop (Tauri) is unchanged — it renders
|
||||
// the full `App` (or a detached view window). Detached windows are desktop-only.
|
||||
const isWeb = resolveTransport() === "http";
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<DIProvider>
|
||||
{viewParams ? (
|
||||
{isWeb ? (
|
||||
<WebApp />
|
||||
) : viewParams ? (
|
||||
<ViewWindow panel={viewParams.panel} />
|
||||
) : (
|
||||
<App />
|
||||
|
||||
96
frontend/src/features/web/PairingScreen.tsx
Normal file
96
frontend/src/features/web/PairingScreen.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Web pairing screen — ticket #13, lot F2.
|
||||
*
|
||||
* Shown by {@link WebApp} when the client is not paired. The user types the code
|
||||
* the server printed at first launch; on submit we `POST /api/pair {code}` via
|
||||
* the {@link WebSession}. On success the server sets the HttpOnly session cookie
|
||||
* and we advance to the workspace; a wrong code shows a clear message.
|
||||
*
|
||||
* Transport-neutral at the component seam: it talks to the injected
|
||||
* {@link WebSession}, never to `@tauri-apps/api` (the CI guard `no-direct-invoke`
|
||||
* stays green). Pure web-only UI — desktop never mounts it.
|
||||
*/
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
|
||||
import type { GatewayError } from "@/domain";
|
||||
import { Button, Field, Input, Panel } from "@/shared";
|
||||
import type { WebSession } from "@/adapters/http";
|
||||
|
||||
interface PairingScreenProps {
|
||||
/** The shared web session performing the `POST /api/pair` handshake. */
|
||||
session: WebSession;
|
||||
/** Called once pairing succeeds (cookie set) so the app can advance. */
|
||||
onPaired: () => void;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
export function PairingScreen({ session, onPaired }: PairingScreenProps) {
|
||||
const [code, setCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await session.pair(trimmed);
|
||||
onPaired();
|
||||
} catch (err) {
|
||||
setError(describe(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-canvas p-6">
|
||||
<Panel className="w-full max-w-sm">
|
||||
<form onSubmit={submit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold tracking-tight">Appairer cet appareil</h1>
|
||||
<p className="text-sm text-muted">
|
||||
Saisissez le code affiché par le serveur IdeA pour connecter ce
|
||||
navigateur.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Field label="Code d'appairage">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="p. ex. 4821-93"
|
||||
autoFocus
|
||||
autoComplete="one-time-code"
|
||||
disabled={busy}
|
||||
invalid={!!error}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{error && (
|
||||
<p role="alert" data-testid="pairing-error" className="text-sm text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={busy || code.trim().length === 0}>
|
||||
{busy ? "Appairage…" : "Appairer"}
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
frontend/src/features/web/WebApp.test.tsx
Normal file
120
frontend/src/features/web/WebApp.test.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* F2 — the web client routing + pairing flow:
|
||||
* - not paired ⇒ pairing screen; wrong code ⇒ clear error, stays on pairing;
|
||||
* - successful pair ⇒ read-only workspace (project list + open + snapshot);
|
||||
* - a 401 (session.notifyUnauthorized) ⇒ back to pairing.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { createMockGateways, MockWorkStateGateway } from "@/adapters/mock";
|
||||
import { WebSession } from "@/adapters/http";
|
||||
import type { FetchLike } from "@/adapters/http/httpInvoker";
|
||||
import type { FlagStore } from "@/adapters/http/webSession";
|
||||
import { WebApp } from "./WebApp";
|
||||
|
||||
function memStore(): FlagStore {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => map.get(k) ?? null,
|
||||
setItem: (k, v) => void map.set(k, v),
|
||||
removeItem: (k) => void map.delete(k),
|
||||
};
|
||||
}
|
||||
|
||||
const okFetch: FetchLike = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true }),
|
||||
text: async () => "{}",
|
||||
});
|
||||
|
||||
const rejectFetch: FetchLike = async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ code: "INVALID", message: "bad" }),
|
||||
text: async () => "{}",
|
||||
});
|
||||
|
||||
/** Mock gateways seeded with one project + a work-state snapshot. */
|
||||
async function seededGateways(): Promise<Gateways> {
|
||||
const gateways = createMockGateways();
|
||||
const project = await gateways.project.createProject("Demo", "/srv/demo");
|
||||
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(project.id, {
|
||||
agents: [
|
||||
{ agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" }, tickets: [] },
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
return gateways;
|
||||
}
|
||||
|
||||
function renderWebApp(session: WebSession, gateways: Gateways) {
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<WebApp session={session} />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("WebApp pairing routing", () => {
|
||||
it("shows the pairing screen when not paired", async () => {
|
||||
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||
renderWebApp(session, await seededGateways());
|
||||
|
||||
expect(screen.getByText("Appairer cet appareil")).toBeTruthy();
|
||||
expect(screen.queryByText("Projets")).toBeNull();
|
||||
});
|
||||
|
||||
it("advances to the read-only workspace after a successful pair", async () => {
|
||||
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||
renderWebApp(session, await seededGateways());
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Code d'appairage"), { target: { value: "4821-93" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
|
||||
|
||||
expect(await screen.findByText("Projets")).toBeTruthy();
|
||||
expect(await screen.findByText("Demo")).toBeTruthy();
|
||||
expect(session.isPaired()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows a clear error on a wrong code and stays on pairing", async () => {
|
||||
const session = new WebSession({ baseUrl: "https://h", fetchImpl: rejectFetch, store: memStore() });
|
||||
renderWebApp(session, await seededGateways());
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Code d'appairage"), { target: { value: "nope" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
|
||||
|
||||
expect(await screen.findByTestId("pairing-error")).toBeTruthy();
|
||||
expect(screen.queryByText("Projets")).toBeNull();
|
||||
expect(session.isPaired()).toBe(false);
|
||||
});
|
||||
|
||||
it("opens a project read-only and renders the work-state snapshot", async () => {
|
||||
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||
session.markPaired();
|
||||
renderWebApp(session, await seededGateways());
|
||||
|
||||
// Workspace is shown directly (already paired); open the seeded project.
|
||||
fireEvent.click(await screen.findByText("Demo"));
|
||||
|
||||
const snapshot = await screen.findByTestId("web-workstate");
|
||||
expect(snapshot.textContent).toContain("Archi");
|
||||
expect(snapshot.textContent).toContain("idle");
|
||||
});
|
||||
|
||||
it("returns to pairing when the session reports unauthorized (401)", async () => {
|
||||
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||
session.markPaired();
|
||||
renderWebApp(session, await seededGateways());
|
||||
|
||||
expect(await screen.findByText("Projets")).toBeTruthy();
|
||||
|
||||
act(() => session.notifyUnauthorized());
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Appairer cet appareil")).toBeTruthy());
|
||||
expect(screen.queryByText("Projets")).toBeNull();
|
||||
});
|
||||
});
|
||||
63
frontend/src/features/web/WebApp.tsx
Normal file
63
frontend/src/features/web/WebApp.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Web client root — ticket #13, lot F2. Mounted (instead of the desktop `App`)
|
||||
* only when the HTTP transport is selected (`VITE_TRANSPORT="http"`).
|
||||
*
|
||||
* Routes on the {@link WebSession} paired flag: not paired ⇒ {@link PairingScreen};
|
||||
* paired ⇒ the read-only {@link WebWorkspace}. It subscribes to the session's
|
||||
* `onUnauthorized` signal so a `401` from any `/api/invoke` (expired/missing
|
||||
* cookie) drops back to pairing automatically. A best-effort "Se déconnecter"
|
||||
* clears the local flag (server-side cookie revocation is B8).
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/shared";
|
||||
import { getWebSession, type WebSession } from "@/adapters/http";
|
||||
import { PairingScreen } from "./PairingScreen";
|
||||
import { WebWorkspace } from "./WebWorkspace";
|
||||
|
||||
interface WebAppProps {
|
||||
/** Injectable session (tests); defaults to the shared singleton. */
|
||||
session?: WebSession;
|
||||
}
|
||||
|
||||
export function WebApp({ session }: WebAppProps = {}) {
|
||||
const webSession = session ?? getWebSession();
|
||||
const [paired, setPaired] = useState(() => webSession.isPaired());
|
||||
|
||||
useEffect(() => {
|
||||
// A 401 anywhere clears the flag and fires this: return to pairing.
|
||||
return webSession.onUnauthorized(() => setPaired(false));
|
||||
}, [webSession]);
|
||||
|
||||
function signOut(): void {
|
||||
webSession.forget();
|
||||
setPaired(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-canvas text-content">
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h1 className="text-lg font-semibold tracking-tight">IdeA</h1>
|
||||
<span className="rounded-md bg-raised px-1.5 py-0.5 text-[0.65rem] font-medium uppercase text-muted">
|
||||
web
|
||||
</span>
|
||||
</div>
|
||||
{paired && (
|
||||
<Button variant="ghost" size="sm" onClick={signOut}>
|
||||
Se déconnecter
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{paired ? (
|
||||
<WebWorkspace />
|
||||
) : (
|
||||
<PairingScreen session={webSession} onPaired={() => setPaired(true)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
frontend/src/features/web/WebWorkspace.tsx
Normal file
156
frontend/src/features/web/WebWorkspace.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Read-only web workspace — ticket #13, lot F2 (first shippable increment).
|
||||
*
|
||||
* The minimal post-pairing surface: list the projects, open one **read-only**,
|
||||
* and show a snapshot of its live/work state. No PTY (xterm over WS = F3), no
|
||||
* mutation — every call goes through the existing transport-neutral gateways
|
||||
* (`project`, `workState`) via DI, so no component touches `@tauri-apps/api`.
|
||||
*
|
||||
* It reuses the frozen read-model types (`ProjectWorkState`) and only calls the
|
||||
* commands B4 puts on the read-only allowlist: `list_projects`, `open_project`,
|
||||
* `get_project_work_state`. A deliberately small surface — the full IDE (layout,
|
||||
* agents, terminals) is out of scope until the streaming lots.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { GatewayError, Project, ProjectWorkState } from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { Button, Panel, Spinner } from "@/shared";
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
export function WebWorkspace() {
|
||||
const { project, workState } = useGateways();
|
||||
const [projects, setProjects] = useState<Project[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<ProjectWorkState | null>(null);
|
||||
const [loadingSnapshot, setLoadingSnapshot] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
setProjects(await project.listProjects());
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
}
|
||||
}, [project]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const openReadOnly = useCallback(
|
||||
async (projectId: string) => {
|
||||
setError(null);
|
||||
setLoadingSnapshot(true);
|
||||
setOpenId(projectId);
|
||||
setSnapshot(null);
|
||||
try {
|
||||
// Read-only: open resolves the project server-side, then we read the
|
||||
// live/work-state snapshot. No layout/agents/PTY are mounted.
|
||||
await project.openProject(projectId);
|
||||
setSnapshot(await workState.getProjectWorkState(projectId));
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setLoadingSnapshot(false);
|
||||
}
|
||||
},
|
||||
[project, workState],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4 overflow-y-auto p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold tracking-tight">Projets</h2>
|
||||
<Button variant="ghost" size="sm" onClick={() => void refresh()}>
|
||||
Rafraîchir
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Panel className="border-danger/40">
|
||||
<p className="text-sm text-danger">{error}</p>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{projects === null ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted">
|
||||
<Spinner size={12} /> Chargement des projets…
|
||||
</span>
|
||||
) : projects.length === 0 ? (
|
||||
<p className="text-sm text-muted">Aucun projet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2" data-testid="web-project-list">
|
||||
{projects.map((p) => (
|
||||
<li key={p.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openReadOnly(p.id)}
|
||||
aria-pressed={openId === p.id}
|
||||
className="flex w-full flex-col items-start rounded-md border border-border bg-raised px-3 py-2 text-left transition-colors hover:border-primary aria-pressed:border-primary"
|
||||
>
|
||||
<span className="text-sm font-medium text-content">{p.name}</span>
|
||||
<span className="text-xs text-faint">{p.root}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{openId && (
|
||||
<Panel className="mt-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">
|
||||
État (lecture seule)
|
||||
<span className="ml-2 rounded bg-canvas px-1.5 py-0.5 text-[0.6rem] uppercase text-muted">
|
||||
read-only
|
||||
</span>
|
||||
</h3>
|
||||
{loadingSnapshot && <Spinner size={12} />}
|
||||
</div>
|
||||
{snapshot ? (
|
||||
<WorkStateSnapshot snapshot={snapshot} />
|
||||
) : loadingSnapshot ? (
|
||||
<p className="text-xs text-muted">Chargement de l'état…</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted">Aucun état disponible.</p>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pure render of the read-only work-state snapshot. */
|
||||
function WorkStateSnapshot({ snapshot }: { snapshot: ProjectWorkState }) {
|
||||
if (snapshot.agents.length === 0) {
|
||||
return <p className="text-xs text-muted">Aucun agent actif.</p>;
|
||||
}
|
||||
return (
|
||||
<ul className="flex flex-col gap-1.5" data-testid="web-workstate">
|
||||
{snapshot.agents.map((a) => (
|
||||
<li key={a.agentId} className="flex items-center justify-between text-xs">
|
||||
<span className="text-content">{a.name}</span>
|
||||
<span className="flex items-center gap-2 text-faint">
|
||||
<span>{a.live ? `live · ${a.live.kind}` : "offline"}</span>
|
||||
<span
|
||||
className={
|
||||
a.busy.state === "busy" ? "text-warning" : "text-success"
|
||||
}
|
||||
>
|
||||
{a.busy.state === "busy" ? "busy" : "idle"}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
8
frontend/src/features/web/index.ts
Normal file
8
frontend/src/features/web/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Web client feature (ticket #13, lot F2) — the read-only browser surface used
|
||||
* when the HTTP transport is selected. Public entry is {@link WebApp}.
|
||||
*/
|
||||
|
||||
export { WebApp } from "./WebApp";
|
||||
export { PairingScreen } from "./PairingScreen";
|
||||
export { WebWorkspace } from "./WebWorkspace";
|
||||
2
frontend/src/vite-env.d.ts
vendored
2
frontend/src/vite-env.d.ts
vendored
@ -2,6 +2,8 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_USE_MOCK?: string;
|
||||
/** Selects the web (client/server) HTTP+WS transport when set to `"http"` (ticket #13). */
|
||||
readonly VITE_TRANSPORT?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Reference in New Issue
Block a user