feat(workstate): actions contrôlées sur le work-state (Lot D backend)
Ajoute un module d'actions contrôlées (`workstate/actions.rs`) côté application et l'expose via des commandes Tauri : DTO camelCase, câblage commands/state/lib. Les actions valident leurs invariants avant d'agir sur le read-model. - application : use cases d'actions contrôlées + intégration au work-state. - app-tauri : commandes dédiées, DTO et enregistrement dans le handler. Tests verts : application workstate_actions (7), workstate (21), app-tauri dto_agents (25), list_live_agents_r0b (5), cargo check OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -119,8 +119,9 @@ pub use terminal::{
|
||||
};
|
||||
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
|
||||
pub use workstate::{
|
||||
AgentTicketState, AgentWorkState, ConversationLogProvider, ConversationPreviewStatus,
|
||||
ConversationTurnWorkPreview, ConversationWorkSummary, GetProjectWorkState,
|
||||
GetProjectWorkStateInput, LiveWorkSession, ProjectWorkState, TicketWorkSource,
|
||||
AgentTicketState, AgentWorkState, AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput,
|
||||
ConversationLogProvider, ConversationPreviewStatus, ConversationTurnWorkPreview,
|
||||
ConversationWorkSummary, GetProjectWorkState, GetProjectWorkStateInput, LiveWorkSession,
|
||||
ProjectWorkState, StopLiveAgent, StopLiveAgentInput, StopLiveAgentOutput, TicketWorkSource,
|
||||
TicketWorkStatus,
|
||||
};
|
||||
|
||||
177
crates/application/src/workstate/actions.rs
Normal file
177
crates/application/src/workstate/actions.rs
Normal file
@ -0,0 +1,177 @@
|
||||
//! Agent-level controlled actions over live sessions (Lot D).
|
||||
//!
|
||||
//! Companion to the read-only work-state model: these use cases let the UI drive
|
||||
//! an **already-running** agent's live session by **agent id**, never by spawning a
|
||||
//! new process. [`AttachLiveAgent`] rebinds the hosting cell ("a cell is a view");
|
||||
//! [`StopLiveAgent`] tears the live session down polymorphically (PTY kill vs
|
||||
//! structured shutdown). Neither touches the agent entity, its tickets, the
|
||||
//! conversation summaries, the handoff or the provider sessions.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{AgentId, NodeId, Project, SessionId};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::terminal::{CloseTerminal, CloseTerminalInput, LiveSessionKind, LiveSessions};
|
||||
|
||||
/// Input for [`AttachLiveAgent::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AttachLiveAgentInput {
|
||||
/// The owning project (boundary/symmetry; the registries are global by agent).
|
||||
pub project: Project,
|
||||
/// The already-running agent whose live session is rebound.
|
||||
pub agent_id: AgentId,
|
||||
/// The layout leaf that should host the live session view.
|
||||
pub node_id: NodeId,
|
||||
}
|
||||
|
||||
/// Output of [`AttachLiveAgent::execute`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AttachLiveAgentOutput {
|
||||
/// The rebound agent.
|
||||
pub agent_id: AgentId,
|
||||
/// The node now hosting the session view.
|
||||
pub node_id: NodeId,
|
||||
/// The unchanged live session id.
|
||||
pub session_id: SessionId,
|
||||
/// Runtime family that owns the session.
|
||||
pub kind: LiveSessionKind,
|
||||
}
|
||||
|
||||
/// Rebinds an already-running agent session to a visible layout cell, never
|
||||
/// spawning a process ("a cell is a view").
|
||||
///
|
||||
/// Resolves the live session by agent in the PTY registry first, then the
|
||||
/// structured one, and updates only the hosting `node_id` (the PTY handle / agent
|
||||
/// session, its id and scrollback stay intact). Idempotent when the agent is
|
||||
/// already attached to the same node (the rebind is a no-op that returns the same
|
||||
/// session). No log/handoff/provider-session is touched.
|
||||
pub struct AttachLiveAgent {
|
||||
live: Arc<LiveSessions>,
|
||||
}
|
||||
|
||||
impl AttachLiveAgent {
|
||||
/// Builds the use case from the aggregated live-session registry.
|
||||
#[must_use]
|
||||
pub fn new(live: Arc<LiveSessions>) -> Self {
|
||||
Self { live }
|
||||
}
|
||||
|
||||
/// Rebinds the agent's live session onto `node_id`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::NotFound`] when the agent has no live session in either registry.
|
||||
pub fn execute(&self, input: AttachLiveAgentInput) -> Result<AttachLiveAgentOutput, AppError> {
|
||||
// PTY first, then structured (one-live-session-per-agent ⇒ at most one match).
|
||||
if let Some(session) = self
|
||||
.live
|
||||
.pty
|
||||
.rebind_agent_node(&input.agent_id, input.node_id)
|
||||
{
|
||||
return Ok(AttachLiveAgentOutput {
|
||||
agent_id: input.agent_id,
|
||||
node_id: session.node_id,
|
||||
session_id: session.id,
|
||||
kind: LiveSessionKind::Pty,
|
||||
});
|
||||
}
|
||||
if let Some(session) = self
|
||||
.live
|
||||
.structured
|
||||
.rebind_agent_node(&input.agent_id, input.node_id)
|
||||
{
|
||||
return Ok(AttachLiveAgentOutput {
|
||||
agent_id: input.agent_id,
|
||||
node_id: input.node_id,
|
||||
session_id: session.id(),
|
||||
kind: LiveSessionKind::Structured,
|
||||
});
|
||||
}
|
||||
Err(AppError::NotFound(format!(
|
||||
"running session for agent {}",
|
||||
input.agent_id
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`StopLiveAgent::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StopLiveAgentInput {
|
||||
/// The owning project (boundary/symmetry; the registries are global by agent).
|
||||
pub project: Project,
|
||||
/// The agent whose live session is stopped.
|
||||
pub agent_id: AgentId,
|
||||
}
|
||||
|
||||
/// Output of [`StopLiveAgent::execute`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct StopLiveAgentOutput {
|
||||
/// The stopped agent.
|
||||
pub agent_id: AgentId,
|
||||
/// The id of the session that was torn down.
|
||||
pub session_id: SessionId,
|
||||
/// Runtime family that owned the session.
|
||||
pub kind: LiveSessionKind,
|
||||
}
|
||||
|
||||
/// Stops an already-running agent's live session, polymorphically.
|
||||
///
|
||||
/// Resolves the session by agent (PTY first, then structured). A PTY session is
|
||||
/// torn down through the existing [`CloseTerminal`] primitive (registry removal +
|
||||
/// process kill); a structured session is removed then `shutdown()`. The agent
|
||||
/// entity, its tickets, conversation summaries and handoff are **not** removed. Any
|
||||
/// exit event is left to the existing infrastructure (the PTY pump / session
|
||||
/// backend) — no bus is invented here.
|
||||
pub struct StopLiveAgent {
|
||||
live: Arc<LiveSessions>,
|
||||
close: Arc<CloseTerminal>,
|
||||
}
|
||||
|
||||
impl StopLiveAgent {
|
||||
/// Builds the use case from the live-session registry and the close primitive.
|
||||
#[must_use]
|
||||
pub fn new(live: Arc<LiveSessions>, close: Arc<CloseTerminal>) -> Self {
|
||||
Self { live, close }
|
||||
}
|
||||
|
||||
/// Tears down the agent's live session.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] when the agent has no live session,
|
||||
/// - [`AppError::Process`] when the PTY kill or structured shutdown fails.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: StopLiveAgentInput,
|
||||
) -> Result<StopLiveAgentOutput, AppError> {
|
||||
// PTY first: delegate to the existing close primitive (removes + kills).
|
||||
if let Some(session_id) = self.live.pty.session_for_agent(&input.agent_id) {
|
||||
self.close
|
||||
.execute(CloseTerminalInput { session_id })
|
||||
.await?;
|
||||
return Ok(StopLiveAgentOutput {
|
||||
agent_id: input.agent_id,
|
||||
session_id,
|
||||
kind: LiveSessionKind::Pty,
|
||||
});
|
||||
}
|
||||
// Structured: remove from the registry first (so the uniqueness guard no
|
||||
// longer sees a live session), then shut the session down out of the lock.
|
||||
if let Some(session_id) = self.live.structured.session_id_for_agent(&input.agent_id) {
|
||||
if let Some(session) = self.live.structured.remove(&session_id) {
|
||||
session
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(|e| AppError::Process(e.to_string()))?;
|
||||
}
|
||||
return Ok(StopLiveAgentOutput {
|
||||
agent_id: input.agent_id,
|
||||
session_id,
|
||||
kind: LiveSessionKind::Structured,
|
||||
});
|
||||
}
|
||||
Err(AppError::NotFound(format!(
|
||||
"running session for agent {}",
|
||||
input.agent_id
|
||||
)))
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,13 @@
|
||||
//! beyond loading the manifest through the existing context store and creates no
|
||||
//! durable projection.
|
||||
|
||||
mod actions;
|
||||
|
||||
pub use actions::{
|
||||
AttachLiveAgent, AttachLiveAgentInput, AttachLiveAgentOutput, StopLiveAgent,
|
||||
StopLiveAgentInput, StopLiveAgentOutput,
|
||||
};
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
351
crates/application/tests/workstate_actions.rs
Normal file
351
crates/application/tests/workstate_actions.rs
Normal file
@ -0,0 +1,351 @@
|
||||
//! Tests for the Lot D agent-level controlled actions (`AttachLiveAgent`,
|
||||
//! `StopLiveAgent`) over the live-session registries.
|
||||
//!
|
||||
//! Every port is faked in-memory so the use cases run without a real PTY or
|
||||
//! agent-session backend: [`FakePty`] records `kill`s, [`FakeSession`] records
|
||||
//! `shutdown`s.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::{
|
||||
AttachLiveAgent, AttachLiveAgentInput, CloseTerminal, LiveSessionKind, LiveSessions,
|
||||
StopLiveAgent, StopLiveAgentInput, StructuredSessions, TerminalSessions,
|
||||
};
|
||||
use domain::ports::{
|
||||
AgentSession, AgentSessionError, ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort,
|
||||
ReplyStream, SpawnSpec,
|
||||
};
|
||||
use domain::Project;
|
||||
use domain::{
|
||||
AgentId, NodeId, ProjectId, ProjectPath, PtySize, RemoteRef, SessionId, SessionKind,
|
||||
TerminalSession,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ids / fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn sid(n: u128) -> SessionId {
|
||||
SessionId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(1)),
|
||||
"demo",
|
||||
ProjectPath::new("/tmp/idea-workstate-actions").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A recording [`PtyPort`] whose `kill` records the killed session id.
|
||||
#[derive(Default)]
|
||||
struct FakePty {
|
||||
kills: Mutex<Vec<SessionId>>,
|
||||
}
|
||||
|
||||
impl FakePty {
|
||||
fn kills(&self) -> Vec<SessionId> {
|
||||
self.kills.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PtyPort for FakePty {
|
||||
async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
|
||||
unreachable!("Lot D never spawns")
|
||||
}
|
||||
fn write(&self, _handle: &PtyHandle, _data: &[u8]) -> Result<(), PtyError> {
|
||||
Ok(())
|
||||
}
|
||||
fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> {
|
||||
Ok(())
|
||||
}
|
||||
fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
||||
Ok(Box::new(std::iter::empty()))
|
||||
}
|
||||
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
||||
self.kills.lock().unwrap().push(handle.session_id);
|
||||
Ok(ExitStatus { code: Some(0) })
|
||||
}
|
||||
}
|
||||
|
||||
/// A fake structured [`AgentSession`] recording whether `shutdown` was called.
|
||||
struct FakeSession {
|
||||
id: SessionId,
|
||||
shutdown_called: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentSession for FakeSession {
|
||||
fn id(&self) -> SessionId {
|
||||
self.id
|
||||
}
|
||||
fn conversation_id(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
Ok(Box::new(std::iter::empty()))
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
self.shutdown_called.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wiring helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Fixture {
|
||||
attach: AttachLiveAgent,
|
||||
stop: StopLiveAgent,
|
||||
pty: Arc<TerminalSessions>,
|
||||
structured: Arc<StructuredSessions>,
|
||||
pty_port: Arc<FakePty>,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let pty = Arc::new(TerminalSessions::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let live = Arc::new(LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured)));
|
||||
let pty_port = Arc::new(FakePty::default());
|
||||
let close = Arc::new(CloseTerminal::new(
|
||||
Arc::clone(&pty_port) as Arc<dyn PtyPort>,
|
||||
Arc::clone(&pty),
|
||||
));
|
||||
Fixture {
|
||||
attach: AttachLiveAgent::new(Arc::clone(&live)),
|
||||
stop: StopLiveAgent::new(Arc::clone(&live), close),
|
||||
pty,
|
||||
structured,
|
||||
pty_port,
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_pty(
|
||||
sessions: &TerminalSessions,
|
||||
session_id: SessionId,
|
||||
agent_id: AgentId,
|
||||
node_id: NodeId,
|
||||
) {
|
||||
sessions.insert(
|
||||
PtyHandle { session_id },
|
||||
TerminalSession::starting(
|
||||
session_id,
|
||||
node_id,
|
||||
ProjectPath::new("/tmp/idea-workstate-actions").unwrap(),
|
||||
SessionKind::Agent { agent_id },
|
||||
PtySize { rows: 24, cols: 80 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn insert_structured(
|
||||
sessions: &StructuredSessions,
|
||||
session_id: SessionId,
|
||||
agent_id: AgentId,
|
||||
node_id: NodeId,
|
||||
) -> Arc<AtomicBool> {
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
sessions.insert(
|
||||
Arc::new(FakeSession {
|
||||
id: session_id,
|
||||
shutdown_called: Arc::clone(&flag),
|
||||
}),
|
||||
agent_id,
|
||||
node_id,
|
||||
);
|
||||
flag
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AttachLiveAgent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn attach_pty_rebinds_node_without_changing_session() {
|
||||
let f = fixture();
|
||||
let a = aid(10);
|
||||
insert_pty(&f.pty, sid(1), a, nid(100));
|
||||
|
||||
let out = f
|
||||
.attach
|
||||
.execute(AttachLiveAgentInput {
|
||||
project: project(),
|
||||
agent_id: a,
|
||||
node_id: nid(200),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.agent_id, a);
|
||||
assert_eq!(out.session_id, sid(1), "session id is preserved on rebind");
|
||||
assert_eq!(out.node_id, nid(200), "view rebound to the new node");
|
||||
assert_eq!(out.kind, LiveSessionKind::Pty);
|
||||
// The registry reflects the new host node, same session.
|
||||
assert_eq!(f.pty.node_for_agent(&a), Some(nid(200)));
|
||||
assert_eq!(f.pty.session_for_agent(&a), Some(sid(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_structured_rebinds_node() {
|
||||
let f = fixture();
|
||||
let a = aid(10);
|
||||
let _flag = insert_structured(&f.structured, sid(2), a, nid(100));
|
||||
|
||||
let out = f
|
||||
.attach
|
||||
.execute(AttachLiveAgentInput {
|
||||
project: project(),
|
||||
agent_id: a,
|
||||
node_id: nid(300),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.session_id, sid(2));
|
||||
assert_eq!(out.node_id, nid(300));
|
||||
assert_eq!(out.kind, LiveSessionKind::Structured);
|
||||
assert_eq!(f.structured.node_for_agent(&a), Some(nid(300)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_unknown_agent_is_not_found() {
|
||||
let f = fixture();
|
||||
|
||||
let err = f
|
||||
.attach
|
||||
.execute(AttachLiveAgentInput {
|
||||
project: project(),
|
||||
agent_id: aid(999),
|
||||
node_id: nid(1),
|
||||
})
|
||||
.expect_err("absent agent ⇒ NOT_FOUND");
|
||||
|
||||
assert!(
|
||||
matches!(err, application::AppError::NotFound(_)),
|
||||
"got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_same_node_is_idempotent() {
|
||||
let f = fixture();
|
||||
let a = aid(10);
|
||||
insert_pty(&f.pty, sid(1), a, nid(100));
|
||||
|
||||
let first = f
|
||||
.attach
|
||||
.execute(AttachLiveAgentInput {
|
||||
project: project(),
|
||||
agent_id: a,
|
||||
node_id: nid(100),
|
||||
})
|
||||
.unwrap();
|
||||
let second = f
|
||||
.attach
|
||||
.execute(AttachLiveAgentInput {
|
||||
project: project(),
|
||||
agent_id: a,
|
||||
node_id: nid(100),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Re-attaching to the same node is a no-op: same session, same node, no spawn.
|
||||
assert_eq!(first.session_id, second.session_id);
|
||||
assert_eq!(first.node_id, nid(100));
|
||||
assert_eq!(second.node_id, nid(100));
|
||||
assert_eq!(f.pty.len(), 1, "no extra session created");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StopLiveAgent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_pty_kills_and_removes_session() {
|
||||
let f = fixture();
|
||||
let a = aid(10);
|
||||
insert_pty(&f.pty, sid(1), a, nid(100));
|
||||
|
||||
let out = f
|
||||
.stop
|
||||
.execute(StopLiveAgentInput {
|
||||
project: project(),
|
||||
agent_id: a,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.agent_id, a);
|
||||
assert_eq!(out.session_id, sid(1));
|
||||
assert_eq!(out.kind, LiveSessionKind::Pty);
|
||||
// Delegated to the close primitive: process killed and registry emptied.
|
||||
assert_eq!(f.pty_port.kills(), vec![sid(1)]);
|
||||
assert!(f.pty.is_empty(), "live session removed from the registry");
|
||||
assert_eq!(f.pty.session_for_agent(&a), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_structured_shuts_down_and_removes_session() {
|
||||
let f = fixture();
|
||||
let a = aid(10);
|
||||
let flag = insert_structured(&f.structured, sid(2), a, nid(100));
|
||||
|
||||
let out = f
|
||||
.stop
|
||||
.execute(StopLiveAgentInput {
|
||||
project: project(),
|
||||
agent_id: a,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.session_id, sid(2));
|
||||
assert_eq!(out.kind, LiveSessionKind::Structured);
|
||||
assert!(flag.load(Ordering::SeqCst), "session.shutdown() was called");
|
||||
assert!(f.structured.is_empty(), "live session removed");
|
||||
assert_eq!(f.structured.session_id_for_agent(&a), None);
|
||||
// No PTY was touched.
|
||||
assert!(f.pty_port.kills().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_unknown_agent_is_not_found() {
|
||||
let f = fixture();
|
||||
|
||||
let err = f
|
||||
.stop
|
||||
.execute(StopLiveAgentInput {
|
||||
project: project(),
|
||||
agent_id: aid(999),
|
||||
})
|
||||
.await
|
||||
.expect_err("absent agent ⇒ NOT_FOUND");
|
||||
|
||||
assert!(
|
||||
matches!(err, application::AppError::NotFound(_)),
|
||||
"got {err:?}"
|
||||
);
|
||||
assert!(f.pty_port.kills().is_empty(), "nothing killed");
|
||||
}
|
||||
Reference in New Issue
Block a user