diff --git a/Cargo.lock b/Cargo.lock index d7991d9dc..62b4d2b7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -419,6 +419,7 @@ dependencies = [ "aionui-runtime", "aionui-session", "aionui-shell", + "aionui-sidebar", "aionui-system", "aionui-team", "aionui-team-prompts", @@ -905,6 +906,31 @@ dependencies = [ "wiremock", ] +[[package]] +name = "aionui-sidebar" +version = "0.1.64" +dependencies = [ + "aionui-api-types", + "aionui-auth", + "aionui-common", + "aionui-conversation", + "aionui-db", + "aionui-project", + "async-trait", + "axum", + "base64", + "http-body-util", + "serde", + "serde_json", + "sqlx", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tower", + "tracing", + "url", +] + [[package]] name = "aionui-system" version = "0.1.64" diff --git a/Cargo.toml b/Cargo.toml index 78bb1c9b3..bf36bd5d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "crates/aionui-team-prompts", "crates/aionui-team", "crates/aionui-project", + "crates/aionui-sidebar", "crates/aionui-cron", "crates/aionui-assistant", "crates/aionui-app", @@ -55,6 +56,7 @@ aionui-channel = { path = "crates/aionui-channel" } aionui-team-prompts = { path = "crates/aionui-team-prompts" } aionui-team = { path = "crates/aionui-team" } aionui-project = { path = "crates/aionui-project" } +aionui-sidebar = { path = "crates/aionui-sidebar" } aionui-cron = { path = "crates/aionui-cron" } aionui-assistant = { path = "crates/aionui-assistant" } aionui-app = { path = "crates/aionui-app" } diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index 2f3f33b08..58cbec816 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -28,6 +28,7 @@ mod remote_agent; mod response; mod runtime; mod shell; +mod sidebar; mod skill; mod system; mod team; @@ -156,6 +157,10 @@ pub use shell::{ OpenExternalRequest, OpenFileRequest, OpenFolderWithRequest, ShowItemInFolderRequest, SpeechToTextConfig, SpeechToTextProvider, SpeechToTextResult, SttStreamClientMessage, SttStreamServerMessage, ToolType, }; +pub use sidebar::{ + MoveOrderRequest, OrderItemRefDto, RemoveProjectItem, RemoveProjectItemKind, RemoveProjectResult, SidebarGroup, + SidebarItem, SidebarItemsResponse, SidebarResponse, SidebarScope, SidebarTeamItem, +}; pub use skill::{ AddExternalPathRequest, DeleteSkillRequest, ExportSkillRequest, ExternalSkillSourceResponse, ImportSkillFailureResponse, ImportSkillRequest, ImportSkillResponse, MaterializeSkillsRequest, diff --git a/crates/aionui-api-types/src/sidebar.rs b/crates/aionui-api-types/src/sidebar.rs new file mode 100644 index 000000000..c80763778 --- /dev/null +++ b/crates/aionui-api-types/src/sidebar.rs @@ -0,0 +1,172 @@ +//! Sidebar read-model DTOs (`GET /api/sidebar`, `GET /api/sidebar/items`). +//! +//! One request renders the whole left panel: the backend classifies every +//! conversation/team into its group (pinned / project / pseudo-dir / chats), +//! windows each group, and hydrates items. The frontend only renders in the +//! given order — it runs no classification. See +//! `feat-project-design/temp/left-panel/api-contract-sidebar.md` §4. + +use serde::{Deserialize, Serialize}; + +use crate::conversation::ConversationResponse; + +/// Root of `GET /api/sidebar`. +/// +/// `groups` order **is** render order: `pinned → project-area (project + dir +/// interleaved) × N → chats`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SidebarResponse { + /// Rendered top to bottom in this exact order. + pub groups: Vec, + /// True when the project area exceeded the 100-group hard cap and was + /// truncated. + pub has_more_groups: bool, +} + +/// Response of `GET /api/sidebar/items` — one more window of a single group. +/// +/// Same shape as [`SidebarGroup`] minus `scope` (the caller already knows which +/// group it paged). See `api-contract-sidebar.md` §3.2. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SidebarItemsResponse { + pub items: Vec, + pub has_more: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +/// One group (a section's window) in the sidebar. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SidebarGroup { + pub scope: SidebarScope, + pub items: Vec, + /// True when this group has items beyond the returned window (paginate via + /// `GET /api/sidebar/items?scope=&cursor=`). + pub has_more: bool, + /// Keyset cursor for the next page; `None` iff `has_more` is false. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +/// Which section a group belongs to; the tag doubles as the group-head shape. +/// +/// The frontend has three sections (pinned / project / chats); both `Project` +/// and `Dir` render into the project area, distinguished only by group-head +/// form (a real project head carries the "+" / remove entry, a dir head does +/// not). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SidebarScope { + /// Pinned group. Pinned rows appear only here. + Pinned, + /// Real project group. `workspace` feeds the project-head "+" entry + /// (resolved server-side from the project source, not scanned from items). + Project { + project_id: String, + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace: Option, + }, + /// Pseudo project group (directory aggregation). `key` is the dir token used + /// for paging / `win`; `name` is the directory's last segment. + Dir { key: String, path: String, name: String }, + /// The flat "chats" group. + Chats, +} + +/// One row in a group: either a full conversation or an aggregated team row. +// `Conversation` is by far the common variant and the hot path (most sidebar +// rows are conversations); boxing it just to shrink the rarer `Team` variant +// would add a heap allocation per row for no real memory win, so the size +// disparity is accepted deliberately. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SidebarItem { + /// A conversation row, reusing the shared conversation DTO. Its `pinned` + /// flag is derived from `user_order` row existence, not any table column. + Conversation { conversation: ConversationResponse }, + /// An aggregated team row (server-side aggregate; the frontend does not + /// reconstruct it from member conversations). + Team(SidebarTeamItem), +} + +/// Result of `DELETE /api/sidebar/project/{id}` (and its `dry_run` preview). +/// +/// Counts are of the units classified into the project's group (BR-19). A live +/// delete reports how many were actually removed; a `dry_run` reports how many +/// *would* be — the two agree when no concurrent deletion races in. Team-member +/// conversations are not counted separately: they are folded into their team and +/// removed by the team cascade, so only the visible rows (`teams_deleted` + +/// `conversations_deleted`) are reported. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoveProjectResult { + /// Teams classified into the project's group. + pub teams_deleted: i64, + /// Independent conversations classified into the project's group. + pub conversations_deleted: i64, + /// The named units in the delete set, so a `dry_run` preview can list *which* + /// items go — not just how many. Pinned members live in the top pinned group + /// (B1 double-render: a project's pinned rows are anti-joined out of its own + /// group), so the frontend cannot reconstruct project membership itself; the + /// names must come from here. Empty on a live delete (the preview already + /// showed them). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec, +} + +/// One named unit in a [`RemoveProjectResult`] preview. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoveProjectItem { + pub name: String, + /// Whether the unit is currently pinned (hoisted into the top pinned group). + pub pinned: bool, + pub kind: RemoveProjectItemKind, +} + +/// Which sidebar unit a [`RemoveProjectItem`] is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RemoveProjectItemKind { + Conversation, + Team, +} + +/// A `(item_type, item_id)` reference in an ordering request body. +/// +/// `item_type` is the raw TEXT enum value (`"conversation"` / `"team"`); the +/// service parses it and returns a 400 on an unknown value, mirroring the +/// pin/unpin path parameters. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OrderItemRefDto { + pub item_type: String, + pub item_id: String, +} + +/// `POST /api/order/{scene}/move` body: drag-drop placement. +/// +/// The frontend sends only anchors — never `order_key` numbers (BR-26). `after` +/// = `null` moves `moved` to the top of the scene; otherwise `moved` is placed +/// directly after `after`. The server computes the key. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MoveOrderRequest { + pub moved: OrderItemRefDto, + #[serde(default)] + pub after: Option, +} + +/// Aggregated team row for the sidebar. +/// +/// Membership grouping is already expressed by the group the row sits in, so +/// (unlike the old draft) there is no `project` back-reference. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SidebarTeamItem { + pub team_id: String, + pub name: String, + /// `MAX(updated_at)` across active member conversations. + pub updated_at: i64, + /// Derived from a `user_order` scene=`pinned` row existing for this team. + pub pinned: bool, + /// Active member conversation ids, `created_at` ascending. + pub member_conversation_ids: Vec, +} diff --git a/crates/aionui-app/Cargo.toml b/crates/aionui-app/Cargo.toml index cf2ffe670..77ce186ad 100644 --- a/crates/aionui-app/Cargo.toml +++ b/crates/aionui-app/Cargo.toml @@ -38,6 +38,7 @@ aionui-team.workspace = true aionui-team-prompts.workspace = true aionui-cron.workspace = true aionui-project.workspace = true +aionui-sidebar.workspace = true aionui-assistant.workspace = true aionui-runtime.workspace = true aionui-process.workspace = true diff --git a/crates/aionui-app/src/router/routes.rs b/crates/aionui-app/src/router/routes.rs index 23604705e..60ca58a1b 100644 --- a/crates/aionui-app/src/router/routes.rs +++ b/crates/aionui-app/src/router/routes.rs @@ -36,6 +36,7 @@ use aionui_office::{office_proxy_routes, office_routes}; use aionui_project::project_routes; use aionui_realtime::{NoopMessageRouter, WsHandlerState, ws_upgrade_handler}; use aionui_shell::shell_routes; +use aionui_sidebar::sidebar_routes; use aionui_system::{ClientPrefService, connection_test_routes, system_routes}; use aionui_team::{TeamSessionService, team_routes}; @@ -283,6 +284,10 @@ pub fn create_router_with_all_state(services: &AppServices, states: ModuleStates let project_authenticated = project_routes(states.project).route_layer(from_fn_with_state(auth_mw_state.clone(), auth_middleware)); + // Sidebar read + ordering routes protected by auth middleware + let sidebar_authenticated = + sidebar_routes(states.sidebar).route_layer(from_fn_with_state(auth_mw_state.clone(), auth_middleware)); + // MCP routes protected by auth middleware let mcp_authenticated = mcp_routes(states.mcp).route_layer(from_fn_with_state(auth_mw_state.clone(), auth_middleware)); @@ -364,6 +369,7 @@ pub fn create_router_with_all_state(services: &AppServices, states: ModuleStates .merge(connection_test_authenticated) .merge(file_authenticated) .merge(project_authenticated) + .merge(sidebar_authenticated) .merge(mcp_authenticated) .merge(extension_authenticated) .merge(hub_authenticated) diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index 0d0b2bec9..570edae25 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -34,9 +34,10 @@ use aionui_mcp::{ McpConfigService, McpConnectionTestService, McpRouterState, McpSyncService, OpencodeAdapter, QwenAdapter, }; use aionui_office::{ConversionService, OfficeRouterState, OfficecliWatchManager, ProxyService}; -use aionui_project::ProjectRouterState; +use aionui_project::{ProjectRouterState, ProjectService}; use aionui_realtime::{MessageRouter, TokenUserResolver, WsHandlerState}; use aionui_shell::ShellRouterState; +use aionui_sidebar::{SidebarRouterState, SidebarService}; use aionui_system::{ ClientPrefService, ConnectionTestRouterState, ConnectionTestService, FeedbackDiagnosticsService, ModelFetchService, ProtocolDetectionService, ProviderService, RuntimePrepareService, SettingsService, SystemRouterState, @@ -129,6 +130,7 @@ pub struct ModuleStates { pub connection_test: ConnectionTestRouterState, pub file: FileRouterState, pub project: ProjectRouterState, + pub sidebar: SidebarRouterState, pub mcp: McpRouterState, pub extension: ExtensionRouterState, pub hub: HubRouterState, @@ -309,6 +311,7 @@ pub async fn build_module_states( connection_test: build_module_state_phase(&boot, "connection_test", build_connection_test_state), file: build_module_state_phase(&boot, "file", || build_file_state(services))?, project: build_module_state_phase(&boot, "project", || build_project_state(services)), + sidebar: build_module_state_phase(&boot, "sidebar", || build_sidebar_state(services)), mcp: build_module_state_phase(&boot, "mcp", || build_mcp_state(services)), extension: ext_state, hub: hub_state, @@ -331,6 +334,17 @@ pub async fn build_module_states( elapsed_ms = boot.elapsed().as_millis(), "startup: module state build completed" ); + // Late-inject the sidebar's remove-project ports: the team service is built + // after the sidebar state above, so the wiring cannot happen inside + // `build_sidebar_state`. `set_remove_project_ports` is set-once. + states + .sidebar + .service + .set_remove_project_ports(Arc::new(RemoveProjectAdapter { + conversation: services.conversation_service.clone(), + team: states.team.service.clone(), + project: services.project_service.clone(), + })); states .conversation .service @@ -503,6 +517,58 @@ pub fn build_project_state(services: &AppServices) -> ProjectRouterState { } } +/// Build the sidebar read/ordering router state from application services. +/// The sidebar store and ordering store share the app-wide pool; `work_dir` is +/// the conversation temp-workspace root used for path classification (must match +/// `ProjectService`'s temp root). +pub fn build_sidebar_state(services: &AppServices) -> SidebarRouterState { + let sidebar_store: Arc = + Arc::new(aionui_db::SqliteSidebarStore::new(services.database.pool().clone())); + let service = SidebarService::new( + sidebar_store, + services.user_order_store.clone(), + services.work_dir.join("conversations"), + ); + SidebarRouterState { + service: Arc::new(service), + } +} + +/// Adapts the concrete conversation / team / project services to the sidebar's +/// [`aionui_sidebar::RemoveProjectPorts`] trait so `remove_project` (BR-19) can +/// reuse the existing per-unit delete paths (`ConversationService::delete`, +/// `TeamSessionService::remove_team`, `ProjectService::delete_project`) without +/// the sidebar crate depending on any of them. +struct RemoveProjectAdapter { + conversation: ConversationService, + team: Arc, + project: ProjectService, +} + +#[async_trait::async_trait] +impl aionui_sidebar::RemoveProjectPorts for RemoveProjectAdapter { + async fn delete_conversation(&self, user_id: &str, conversation_id: &str) -> Result<(), String> { + self.conversation + .delete(user_id, conversation_id) + .await + .map_err(|err| err.to_string()) + } + + async fn remove_team(&self, user_id: &str, team_id: &str) -> Result<(), String> { + self.team + .remove_team(user_id, team_id) + .await + .map_err(|err| err.to_string()) + } + + async fn delete_project_record(&self, user_id: &str, project_id: &str) -> Result<(), String> { + self.project + .delete_project(user_id, project_id) + .await + .map_err(|err| err.to_string()) + } +} + /// Build the default `McpRouterState` from application services. pub fn build_mcp_state(services: &AppServices) -> McpRouterState { let pool = services.database.pool().clone(); @@ -762,6 +828,8 @@ pub fn build_team_state( aionui_team::TeamPromptDumpConfig::from_data_dir(&services.data_dir, services.dump_prompts), ); service.with_project_service(Arc::new(services.project_service.clone())); + // Path-2 cascade: removing a team drops its `user_order` row (sidebar §4.3). + service.with_user_order_store(services.user_order_store.clone()); TeamRouterState { service, active_leases: services.active_lease_registry.clone(), diff --git a/crates/aionui-app/src/services.rs b/crates/aionui-app/src/services.rs index c06c1b3b0..60ee9b51e 100644 --- a/crates/aionui-app/src/services.rs +++ b/crates/aionui-app/src/services.rs @@ -13,13 +13,14 @@ use aionui_common::OnConversationDelete; use aionui_conversation::{ConversationService, runtime_state::ConversationRuntimeStateService}; use aionui_db::{ Database, IAcpSessionRepository, IAgentMetadataRepository, IConversationRepository, IMcpServerRepository, - IProjectStore, ISkillRepository, IUserRepository, SqliteAcpSessionRepository, SqliteAgentMetadataRepository, - SqliteAssistantDefinitionRepository, SqliteAssistantOverlayRepository, SqliteAssistantPreferenceRepository, - SqliteConversationRepository, SqliteMcpServerRepository, SqliteProjectStore, SqliteProviderRepository, - SqliteSkillRepository, SqliteUserRepository, + IProjectStore, ISkillRepository, IUserOrderStore, IUserRepository, SqliteAcpSessionRepository, + SqliteAgentMetadataRepository, SqliteAssistantDefinitionRepository, SqliteAssistantOverlayRepository, + SqliteAssistantPreferenceRepository, SqliteConversationRepository, SqliteMcpServerRepository, SqliteProjectStore, + SqliteProviderRepository, SqliteSkillRepository, SqliteUserOrderStore, SqliteUserRepository, }; use aionui_project::ProjectService; use aionui_realtime::{BroadcastEventBus, WebSocketManager}; +use aionui_sidebar::UserOrderDeleteHook; pub struct AppServices { pub database: Database, @@ -37,6 +38,10 @@ pub struct AppServices { /// Project-bind service (project-bind side branch). Shared by conversation /// and team wiring to bind/backfill project/folder rows. Cheap to clone. pub project_service: ProjectService, + /// Sidebar ordering store (`user_order` table). Shared by the conversation + /// delete hook (path-1 cascade), the team service (path-2 cascade), and the + /// sidebar read state. Cheap to clone (Arc). See sidebar design §4. + pub user_order_store: Arc, /// Same instance as `worker_task_manager`, exposed through the /// `OnConversationDelete` trait so `ConversationService::with_delete_hook` /// can wire it up. Optional because tests construct `AppServices` with a @@ -94,6 +99,7 @@ impl AppServices { runtime_base_url: self.runtime_base_url.clone(), runtime_token_service: self.runtime_token_service.clone(), project_service: self.project_service.clone(), + user_order_store: self.user_order_store.clone(), }); self } @@ -185,6 +191,11 @@ impl AppServices { let project_store: Arc = Arc::new(SqliteProjectStore::new(database.pool().clone())); let project_service = ProjectService::new(project_store, work_dir.join("conversations")); + // Sidebar ordering store (`user_order` table). Built early so it can be + // shared by the conversation delete hook, the team service, and the + // sidebar read state. + let user_order_store: Arc = Arc::new(SqliteUserOrderStore::new(database.pool().clone())); + // Skill paths need app resource dir (for builtin rules) + data dir // (for user skills + materialized views). AcpSkillManager uses these // for first-message skill index/body loading. @@ -274,6 +285,7 @@ impl AppServices { runtime_base_url: runtime_base_url.clone(), runtime_token_service: runtime_token_service.clone(), project_service: project_service.clone(), + user_order_store: user_order_store.clone(), }); Ok(Self { @@ -291,6 +303,7 @@ impl AppServices { conversation_runtime_state, conversation_service, project_service, + user_order_store, task_manager_delete_hook: Some(task_manager_delete_hook), agent_registry, conversation_repo, @@ -325,6 +338,10 @@ struct ConversationServiceDeps<'a> { runtime_base_url: String, runtime_token_service: Arc, project_service: ProjectService, + /// Sidebar ordering store. Wired as a second delete hook so deleting a + /// conversation cascades away its `user_order` rows (sidebar design §4.3, + /// path 1). + user_order_store: Arc, } fn build_conversation_service(deps: ConversationServiceDeps<'_>) -> ConversationService { @@ -357,6 +374,8 @@ fn build_conversation_service(deps: ConversationServiceDeps<'_>) -> Conversation if let Some(hook) = deps.task_manager_delete_hook { service.with_delete_hook(hook); } + // Path-1 cascade: a deleted conversation drops its `user_order` rows. + service.with_delete_hook(Arc::new(UserOrderDeleteHook::new(deps.user_order_store))); service.with_project_service(Arc::new(deps.project_service)); service } diff --git a/crates/aionui-conversation/src/convert.rs b/crates/aionui-conversation/src/convert.rs index 772a3968c..d67f0582a 100644 --- a/crates/aionui-conversation/src/convert.rs +++ b/crates/aionui-conversation/src/convert.rs @@ -8,6 +8,7 @@ use aionui_db::MessageSearchRow; use aionui_db::models::{ConversationArtifactRow, ConversationRow, MessageRow}; use crate::ConversationError; +use crate::service::is_temp_session_workspace; pub(crate) const TOOL_CONTENT_COMPACT_THRESHOLD_BYTES: usize = 64 * 1024; const TOOL_CONTENT_PREVIEW_CHARS: usize = 4096; @@ -29,19 +30,26 @@ pub fn row_to_response(row: ConversationRow, data_dir: &Path) -> Result Result { let is_temporary_workspace = { let ws = extra.get("workspace").and_then(|v| v.as_str()).unwrap_or(""); - !ws.is_empty() && Path::new(ws).starts_with(data_dir) + !ws.is_empty() && is_temp_session_workspace(Path::new(ws)) }; if let Some(obj) = extra.as_object_mut() { obj.remove("preset_context"); @@ -499,6 +507,41 @@ mod tests { assert_eq!(resp.extra["is_temporary_workspace"], false); } + // Historical-debt regression: a temp workspace persisted under a PREVIOUS + // data-dir root (user migrated their conversation directory across + // releases) must still be flagged temporary, even though it no longer sits + // under the current `data_dir`. The `-temp-` leaf marker is what makes this + // root-agnostic recognition safe. + #[test] + fn row_to_response_marks_migrated_root_temp_workspace_as_temporary() { + let row = make_row( + "acp", + "pending", + Some("aionui"), + None, + r#"{"workspace":"/old-data/aionui/conversations/users/u1/2025/01/02/acp-temp-conv-1"}"#, + ); + // Current data_dir differs from the old root the workspace lives under. + let resp = row_to_response(row, Path::new("/srv/aionui-data")).unwrap(); + assert_eq!(resp.extra["is_temporary_workspace"], true); + } + + // Negative guard for the root-agnostic match: a user project that merely + // happens to sit under some `conversations/` ancestor must NOT be flagged + // temporary, because its leaf carries no `-temp-` marker. + #[test] + fn row_to_response_does_not_mark_user_project_under_conversations_as_temporary() { + let row = make_row( + "acp", + "pending", + Some("aionui"), + None, + r#"{"workspace":"/home/me/conversations/myproj"}"#, + ); + let resp = row_to_response(row, Path::new("/srv/aionui-data")).unwrap(); + assert_eq!(resp.extra["is_temporary_workspace"], false); + } + #[test] fn row_with_pinned_at() { let row = ConversationRow { diff --git a/crates/aionui-conversation/src/lib.rs b/crates/aionui-conversation/src/lib.rs index ce75cf73b..1ed684b50 100644 --- a/crates/aionui-conversation/src/lib.rs +++ b/crates/aionui-conversation/src/lib.rs @@ -28,10 +28,12 @@ mod turn_continuation_policy; mod turn_orchestrator; mod turn_recovery_policy; +pub use convert::row_to_response_with_extra; pub use error::ConversationError; pub use response_middleware::{MessageMiddleware, MiddlewareResult, strip_think_tags}; pub use routes::conversation_routes; pub use routes_aux::conversation_ops_routes; +pub use service::is_temp_session_workspace; pub use service::{ ConversationAgentTurnOutcome, ConversationAgentTurnRequest, ConversationAgentTurnStarted, ConversationAgentTurnStartedCallback, ConversationAgentTurnStatus, ConversationService, diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 8e8f01dda..a4486c489 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -4466,6 +4466,15 @@ fn auto_provisioned_workspace_to_delete( Some(workspace_path) } +/// True when `leaf` is an auto-generated workspace directory name. Auto/temp +/// workspace leaves are always `{label}-temp-{id}` (conversations) or +/// `team-temp-{team_id}` (teams); the `-temp-` marker has been stable across +/// every historical layout, so it is the sole signal the root-agnostic read +/// predicate ([`is_temp_session_workspace`]) can rely on. +fn is_temp_leaf(leaf: &str) -> bool { + leaf.contains("-temp-") +} + fn is_auto_workspace_relative_path(relative: &Path) -> bool { let parts = relative.iter().map(|part| part.to_str()).collect::>>(); let Some(parts) = parts else { @@ -4483,14 +4492,47 @@ fn is_auto_workspace_relative_path(relative: &Path) -> bool { match parts.as_slice() { // legacy: bare leaf, or {Y}/{M}/{D}/leaf - [_file_name] => true, - [year, month, day, _file_name] => dated(year, month, day), + [leaf] => is_temp_leaf(leaf), + [year, month, day, leaf] => dated(year, month, day) && is_temp_leaf(leaf), // per-user, type-first: users/{user_dir}/{Y}/{M}/{D}/leaf - ["users", _user_dir, year, month, day, _file_name] => dated(year, month, day), + ["users", _user_dir, year, month, day, leaf] => dated(year, month, day) && is_temp_leaf(leaf), _ => false, } } +/// True when `workspace` is a backend auto-generated temp session directory — +/// the sidebar read model's "temp path" test. +/// +/// Classifies on the workspace *leaf* alone: an auto/temp workspace's final +/// path segment is always `{label}-temp-{id}` or `team-temp-{team_id}`, and the +/// `-temp-` marker has been stable across every layout the backend has ever +/// generated — OS temp dir, bare `/{leaf}`, `/tmp/{leaf}`, +/// and every `/conversations/...` shape (bare, date-partitioned, +/// per-user). None of those share a container segment, so the leaf is the only +/// signal common to all of them. +/// +/// Container-agnostic (and therefore root-agnostic) is deliberate. Users who +/// migrated their conversation directory across releases carry `extra.workspace` +/// values baked under a *previous* root; anchoring on the current `work_dir` (or +/// on a `conversations`/`tmp` container that the earliest layouts lack) would +/// strip-fail on those and misclassify historical temp sessions as projects. +/// Trading that off, a user-selected project directory whose own name literally +/// contains `-temp-` is a false positive here; that is accepted — a project row +/// carries its own `kind` (`standard`/`temp`) as the authoritative signal, and a +/// mislabeled one can be promoted `temp -> standard`. +/// +/// Pure lexical (no filesystem access), so it is safe on the side-effect-free +/// sidebar read path — dead/removed workspaces classify correctly rather than +/// failing an fs probe. Exposed so the sidebar can classify a conversation's +/// `extra.workspace` (or a team's `workspace` column) without duplicating the +/// rule. +pub fn is_temp_session_workspace(workspace: &Path) -> bool { + workspace + .file_name() + .and_then(|leaf| leaf.to_str()) + .is_some_and(is_temp_leaf) +} + async fn cleanup_empty_date_workspace_parents(workspace_root: &Path, workspace_path: &Path) { let Some(date_dirs) = date_workspace_parent_dirs(workspace_root, workspace_path) else { return; diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index a659199cf..8538937cc 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -51,6 +51,7 @@ use serde_json::json; use tokio::sync::{Notify, broadcast}; use crate::service::ConversationService; +use crate::service::is_temp_session_workspace; use crate::skill_resolver::{FixedSkillResolver, ResolvedAgentSkill, SkillResolver}; use crate::{ConversationAgentTurnRequest, ConversationAgentTurnStatus, ConversationError}; @@ -8439,3 +8440,192 @@ async fn a_deferred_cancel_does_not_leak_into_a_later_turn() { // Consumed exactly once. assert!(!svc.runtime_state().take_deferred_cancel(&conv.id, "turn_old")); } + +// --- is_temp_session_workspace: leaf-marker temp-directory judgment --- +// +// A temp workspace is recognized by its LEAF alone: an auto-generated leaf is +// always `{label}-temp-{id}` or `team-temp-{team_id}`, and `-temp-` is the only +// signal shared by every layout the backend has ever emitted (OS temp dir, bare +// `/{leaf}`, `/tmp/{leaf}`, and every `conversations/...` +// shape). Container-/root-agnostic on purpose: migrated data-dirs carry +// workspaces baked under a previous root, and the earliest layouts have no +// container segment at all, so anchoring on a root or a `conversations`/`tmp` +// container would strip-fail on real historical temp rows. A user project whose +// own directory name contains `-temp-` is an accepted false positive here — a +// project row's `kind` (`standard`/`temp`) is the authoritative signal and a +// mislabeled one can be promoted `temp -> standard`. + +#[cfg(unix)] +#[test] +fn is_temp_session_workspace_classifies_every_layout() { + // (workspace, expected_temp, why). One row per historical layout plus the + // negative guards and the one accepted false positive. + let cases: &[(&str, bool, &str)] = &[ + // --- positive: every auto-generated layout the backend has emitted --- + ( + "/srv/aionui-data/conversations/users/u1/2026/08/11/acp-temp-conv-1", + true, + "current per-user dated layout under the live root", + ), + ( + "/old-data/aionui/conversations/users/u1/2025/01/02/acp-temp-conv-1", + true, + "same layout but a migrated (older) root prefix must not defeat it", + ), + ( + "/srv/aionui-data/conversations/users/u1/2026/08/11/team-temp-team_1", + true, + "team temp leaf", + ), + ( + "/old/conversations/claude-temp-xyz", + true, + "early conversations/ layout: bare -temp- leaf, no dated dirs", + ), + ( + "/old/conversations/2025/01/02/acp-temp-conv-1", + true, + "date-partitioned conversations/ layout", + ), + ( + "/Users/me/Library/Application Support/aionui/codex-temp-1776940954621", + true, + "pre-conversations/ bare leaf at the data-dir root (digit-timestamp id)", + ), + ( + "/srv/aionui-data/tmp/acp-temp-1699999999", + true, + "original /tmp/ container layout", + ), + ( + "/old-data/aionui/tmp/claude-temp-1650000000", + true, + "tmp/ container under a migrated root", + ), + ( + "/var/folders/xy/abc/T/codex-temp-1699999999", + true, + "earliest layout: leaf under the OS temp dir, no app container at all", + ), + // --- negative: no -temp- leaf marker --- + ( + "/home/me/conversations/myproj", + false, + "user dir merely under a conversations/ ancestor, no -temp- leaf", + ), + ("/home/me/work/proj", false, "plain project path, no marker"), + ( + "/srv/aionui-data/conversations", + false, + "the container dir itself, no leaf marker", + ), + ( + "/home/me/tmp/scratch", + false, + "plain scratch dir under tmp/, no -temp- leaf marker", + ), + // --- accepted false positive (see is_temp_session_workspace doc) --- + ( + "/home/me/work/my-temp-notes", + true, + "user project whose own leaf contains -temp-; project.kind overrides this", + ), + ]; + + assert_temp_layout_matrix(cases); +} + +/// Shared assertion loop for the temp-layout matrix. `is_temp_session_workspace` +/// leans on `Path::file_name`, whose separator handling is platform-specific, so +/// the matrix is run twice: with POSIX literals on unix and with drive-letter + +/// backslash paths on Windows. +fn assert_temp_layout_matrix(cases: &[(&str, bool, &str)]) { + for (workspace, expected, why) in cases { + assert_eq!( + is_temp_session_workspace(Path::new(workspace)), + *expected, + "{workspace} should classify temp={expected} ({why})", + ); + } +} + +#[cfg(windows)] +#[test] +fn is_temp_session_workspace_classifies_every_layout_windows() { + // Windows-native shapes (drive letter + backslashes) of every row in the + // unix twin. POSIX literals happen to parse on Windows too, so they would + // not actually exercise backslash `file_name` extraction — these do. + let cases: &[(&str, bool, &str)] = &[ + // --- positive: every auto-generated layout the backend has emitted --- + ( + r"C:\srv\aionui-data\conversations\users\u1\2026\08\11\acp-temp-conv-1", + true, + "current per-user dated layout under the live root", + ), + ( + r"D:\old-data\aionui\conversations\users\u1\2025\01\02\acp-temp-conv-1", + true, + "same layout but a migrated (older) root prefix must not defeat it", + ), + ( + r"C:\srv\aionui-data\conversations\users\u1\2026\08\11\team-temp-team_1", + true, + "team temp leaf", + ), + ( + r"C:\old\conversations\claude-temp-xyz", + true, + "early conversations/ layout: bare -temp- leaf, no dated dirs", + ), + ( + r"C:\old\conversations\2025\01\02\acp-temp-conv-1", + true, + "date-partitioned conversations/ layout", + ), + ( + r"C:\Users\me\AppData\Roaming\aionui\codex-temp-1776940954621", + true, + "pre-conversations/ bare leaf at the data-dir root (digit-timestamp id)", + ), + ( + r"C:\srv\aionui-data\tmp\acp-temp-1699999999", + true, + "original /tmp/ container layout", + ), + ( + r"D:\old-data\aionui\tmp\claude-temp-1650000000", + true, + "tmp/ container under a migrated root", + ), + ( + r"C:\Users\me\AppData\Local\Temp\codex-temp-1699999999", + true, + "earliest layout: leaf under the OS temp dir, no app container at all", + ), + // --- negative: no -temp- leaf marker --- + ( + r"C:\Users\me\conversations\myproj", + false, + "user dir merely under a conversations/ ancestor, no -temp- leaf", + ), + (r"C:\Users\me\work\proj", false, "plain project path, no marker"), + ( + r"C:\srv\aionui-data\conversations", + false, + "the container dir itself, no leaf marker", + ), + ( + r"C:\Users\me\tmp\scratch", + false, + "plain scratch dir under tmp/, no -temp- leaf marker", + ), + // --- accepted false positive (see is_temp_session_workspace doc) --- + ( + r"C:\Users\me\work\my-temp-notes", + true, + "user project whose own leaf contains -temp-; project.kind overrides this", + ), + ]; + + assert_temp_layout_matrix(cases); +} diff --git a/crates/aionui-db/migrations/038_sidebar_ordering_and_archive.sql b/crates/aionui-db/migrations/038_sidebar_ordering_and_archive.sql new file mode 100644 index 000000000..e7fce8859 --- /dev/null +++ b/crates/aionui-db/migrations/038_sidebar_ordering_and_archive.sql @@ -0,0 +1,42 @@ +-- Sidebar ordering foundation + archive columns (feature: left-panel grouping). +-- +-- `user_order` is the source of truth for user-defined ordering across the +-- sidebar. It is a generic ordering table keyed by (user_id, scene, item_type, +-- item_id); v1 uses only the 'pinned' scene, where a row's *existence* means +-- the item is pinned (there is no boolean pinned column — the legacy +-- conversations.pinned / pinned_at columns are deprecated and left untouched +-- from here on). order_key drives intra-scene ordering; it is deliberately NOT +-- unique per (user_id, scene): a rebalance transaction may transiently collide +-- an updated key with an as-yet-unchanged row, and cursors tie-break on the +-- full (order_key, item_type, item_id) triple, so uniqueness buys nothing. +-- +-- No pin backfill: pinned state is a user preference and is intentionally not +-- migrated from the deprecated columns or team localStorage (product decision, +-- 2026-08-11) — consistent with team pins never being migrated. +-- +-- archived_at (NULL = not archived) is added here in one shot so PR-B (archive) +-- needs no further migration. The partial indexes cover the selective +-- "archived only" read path; the default sidebar path filters archived_at IS +-- NULL, which is non-selective and needs no index. + +CREATE TABLE IF NOT EXISTS user_order ( + user_id TEXT NOT NULL, + scene TEXT NOT NULL, -- closed enum, v1 only 'pinned' + item_type TEXT NOT NULL, -- 'conversation' | 'team' + item_id TEXT NOT NULL, + order_key INTEGER NOT NULL, -- i64, not unique within a scene + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, scene, item_type, item_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_order_scene + ON user_order(user_id, scene, order_key); + +ALTER TABLE conversations ADD COLUMN archived_at INTEGER; -- NULL = not archived +ALTER TABLE teams ADD COLUMN archived_at INTEGER; -- NULL = not archived + +CREATE INDEX IF NOT EXISTS idx_conversations_archived + ON conversations(user_id, archived_at) WHERE archived_at IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_teams_archived + ON teams(user_id, archived_at) WHERE archived_at IS NOT NULL; diff --git a/crates/aionui-db/src/lib.rs b/crates/aionui-db/src/lib.rs index b73cbe983..cfb1ff5fb 100644 --- a/crates/aionui-db/src/lib.rs +++ b/crates/aionui-db/src/lib.rs @@ -26,11 +26,11 @@ pub use instance_lock::{DataDirInstanceGuard, instance_lock_path}; pub use models::{ AgentMetadataRow, AssistantDefinitionRow, AssistantOverlayRow, AssistantOverrideRow, AssistantPreferenceRow, AssistantRow, ConversationArtifactRow, ConversationAssistantSnapshotRow, CreateAssistantParams, - ExternalUserProjection, FolderRow, ProjectExplorerRow, ProjectKind, ProjectRow, Role, SkillImportRecordRow, - SkillRow, UpdateAgentAvailabilitySnapshotParams, UpdateAgentHandshakeParams, UpdateAssistantParams, - UpsertAgentMetadataParams, UpsertAssistantDefinitionParams, UpsertAssistantOverlayParams, - UpsertAssistantPreferenceParams, UpsertConversationAssistantSnapshotParams, UpsertOverrideParams, UserStatus, - UserType, + ExternalUserProjection, FolderRow, OrderItemType, OrderScene, ProjectExplorerRow, ProjectKind, ProjectRow, Role, + SkillImportRecordRow, SkillRow, UpdateAgentAvailabilitySnapshotParams, UpdateAgentHandshakeParams, + UpdateAssistantParams, UpsertAgentMetadataParams, UpsertAssistantDefinitionParams, UpsertAssistantOverlayParams, + UpsertAssistantPreferenceParams, UpsertConversationAssistantSnapshotParams, UpsertOverrideParams, UserOrderRow, + UserStatus, UserType, }; pub use repository::channel::UpdatePluginStatusParams; pub use repository::conversation::{ @@ -53,14 +53,15 @@ pub use repository::{ IAssistantOverrideRepository, IAssistantPreferenceRepository, IAssistantRepository, IChannelRepository, IClientPreferenceRepository, IConversationRepository, ICronRepository, IFeedbackDiagnosticsRepository, IMcpServerRepository, IOAuthTokenRepository, IProjectStore, IProviderRepository, IRemoteAgentRepository, - ISettingsRepository, ISkillRepository, ITeamRepository, IUserRepository, PageDirection, PersistedSessionState, - SaveRuntimeStateParams, SqliteAcpSessionRepository, SqliteAgentMetadataRepository, - SqliteAssistantDefinitionRepository, SqliteAssistantOverlayRepository, SqliteAssistantOverrideRepository, - SqliteAssistantPreferenceRepository, SqliteAssistantRepository, SqliteChannelRepository, - SqliteClientPreferenceRepository, SqliteConversationRepository, SqliteCronRepository, + ISettingsRepository, ISidebarStore, ISkillRepository, ITeamRepository, IUserOrderStore, IUserRepository, + MoveOutcome, OrderItemRef, PageDirection, PersistedSessionState, PinOutcome, PinnedCursor, SaveRuntimeStateParams, + SidebarConversationThin, SidebarProjectMeta, SidebarTeamThin, SqliteAcpSessionRepository, + SqliteAgentMetadataRepository, SqliteAssistantDefinitionRepository, SqliteAssistantOverlayRepository, + SqliteAssistantOverrideRepository, SqliteAssistantPreferenceRepository, SqliteAssistantRepository, + SqliteChannelRepository, SqliteClientPreferenceRepository, SqliteConversationRepository, SqliteCronRepository, SqliteFeedbackDiagnosticsRepository, SqliteMcpServerRepository, SqliteOAuthTokenRepository, SqliteProjectStore, - SqliteProviderRepository, SqliteRemoteAgentRepository, SqliteSettingsRepository, SqliteSkillRepository, - SqliteTeamRepository, SqliteUserRepository, + SqliteProviderRepository, SqliteRemoteAgentRepository, SqliteSettingsRepository, SqliteSidebarStore, + SqliteSkillRepository, SqliteTeamRepository, SqliteUserOrderStore, SqliteUserRepository, }; // Re-export sqlx pool type for downstream crates diff --git a/crates/aionui-db/src/models/mod.rs b/crates/aionui-db/src/models/mod.rs index 2c5ac5953..a29dd4a19 100644 --- a/crates/aionui-db/src/models/mod.rs +++ b/crates/aionui-db/src/models/mod.rs @@ -16,6 +16,7 @@ mod skill; mod system_settings; mod team; mod user; +mod user_order; pub use acp_session::AcpSessionRow; pub use agent_metadata::{ @@ -41,3 +42,4 @@ pub use skill::{SkillImportRecordRow, SkillRow}; pub use system_settings::SystemSettings; pub use team::{MailboxMessageRow, TeamRow, TeamTaskRow}; pub use user::{ExternalUserProjection, User, UserStatus, UserType}; +pub use user_order::{OrderItemType, OrderScene, UserOrderRow}; diff --git a/crates/aionui-db/src/models/user_order.rs b/crates/aionui-db/src/models/user_order.rs new file mode 100644 index 000000000..0bb43753b --- /dev/null +++ b/crates/aionui-db/src/models/user_order.rs @@ -0,0 +1,77 @@ +use aionui_common::TimestampMs; +use serde::{Deserialize, Serialize}; + +/// Ordering scene, stored as the TEXT `user_order.scene` column. +/// +/// A closed enum; v1 has only `pinned` (a row's existence means the item is +/// pinned). Lives in `aionui-db` so the store trait does not depend upward. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum OrderScene { + /// Pinned items, ordered by `order_key` ascending. + Pinned, +} + +impl OrderScene { + /// The canonical TEXT column value. + pub fn as_str(self) -> &'static str { + match self { + OrderScene::Pinned => "pinned", + } + } + + /// Parse a TEXT column value; `None` for unknown (out-of-enum) values. + pub fn parse(value: &str) -> Option { + match value { + "pinned" => Some(OrderScene::Pinned), + _ => None, + } + } +} + +/// Ordered item kind, stored as the TEXT `user_order.item_type` column. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum OrderItemType { + Conversation, + Team, +} + +impl OrderItemType { + /// The canonical TEXT column value. + pub fn as_str(self) -> &'static str { + match self { + OrderItemType::Conversation => "conversation", + OrderItemType::Team => "team", + } + } + + /// Parse a TEXT column value; `None` for unknown values. + pub fn parse(value: &str) -> Option { + match value { + "conversation" => Some(OrderItemType::Conversation), + "team" => Some(OrderItemType::Team), + _ => None, + } + } +} + +/// Row mapping for the `user_order` table. +/// +/// `scene` / `item_type` are TEXT enum-like columns ([`OrderScene`] / +/// [`OrderItemType`]). `order_key` is not unique within a `(user_id, scene)`; +/// callers tie-break on the full `(order_key, item_type, item_id)` triple. +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct UserOrderRow { + pub user_id: String, + /// One of: "pinned" (see [`OrderScene`]). + pub scene: String, + /// One of: "conversation", "team" (see [`OrderItemType`]). + pub item_type: String, + pub item_id: String, + pub order_key: i64, + pub created_at: TimestampMs, + pub updated_at: TimestampMs, +} + +#[cfg(test)] +#[path = "user_order_test.rs"] +mod user_order_test; diff --git a/crates/aionui-db/src/models/user_order_test.rs b/crates/aionui-db/src/models/user_order_test.rs new file mode 100644 index 000000000..b48e97513 --- /dev/null +++ b/crates/aionui-db/src/models/user_order_test.rs @@ -0,0 +1,16 @@ +use super::{OrderItemType, OrderScene}; + +#[test] +fn order_scene_roundtrips_through_column_value() { + let scene = OrderScene::Pinned; + assert_eq!(OrderScene::parse(scene.as_str()), Some(scene)); + assert_eq!(OrderScene::parse("unknown"), None); +} + +#[test] +fn order_item_type_roundtrips_through_column_value() { + for item_type in [OrderItemType::Conversation, OrderItemType::Team] { + assert_eq!(OrderItemType::parse(item_type.as_str()), Some(item_type)); + } + assert_eq!(OrderItemType::parse("unknown"), None); +} diff --git a/crates/aionui-db/src/repository/mod.rs b/crates/aionui-db/src/repository/mod.rs index 09613c96d..01decc1e1 100644 --- a/crates/aionui-db/src/repository/mod.rs +++ b/crates/aionui-db/src/repository/mod.rs @@ -13,6 +13,7 @@ pub mod project; pub mod provider; pub mod remote_agent; mod settings; +pub mod sidebar; pub mod skill; mod sqlite_acp_session; mod sqlite_agent_metadata; @@ -28,11 +29,14 @@ mod sqlite_project; mod sqlite_provider; mod sqlite_remote_agent; mod sqlite_settings; +mod sqlite_sidebar; mod sqlite_skill; mod sqlite_team; mod sqlite_user; +mod sqlite_user_order; pub mod team; mod user; +pub mod user_order; pub use acp_session::{CreateAcpSessionParams, IAcpSessionRepository, PersistedSessionState, SaveRuntimeStateParams}; pub use agent_metadata::IAgentMetadataRepository; @@ -54,6 +58,7 @@ pub use project::IProjectStore; pub use provider::IProviderRepository; pub use remote_agent::IRemoteAgentRepository; pub use settings::ISettingsRepository; +pub use sidebar::{ISidebarStore, SidebarConversationThin, SidebarProjectMeta, SidebarTeamThin}; pub use skill::ISkillRepository; pub use sqlite_acp_session::SqliteAcpSessionRepository; pub use sqlite_agent_metadata::SqliteAgentMetadataRepository; @@ -72,8 +77,11 @@ pub use sqlite_project::SqliteProjectStore; pub use sqlite_provider::SqliteProviderRepository; pub use sqlite_remote_agent::SqliteRemoteAgentRepository; pub use sqlite_settings::SqliteSettingsRepository; +pub use sqlite_sidebar::SqliteSidebarStore; pub use sqlite_skill::SqliteSkillRepository; pub use sqlite_team::SqliteTeamRepository; pub use sqlite_user::SqliteUserRepository; +pub use sqlite_user_order::SqliteUserOrderStore; pub use team::{ActivityCursor, ITeamRepository, PageDirection}; pub use user::IUserRepository; +pub use user_order::{IUserOrderStore, MoveOutcome, OrderItemRef, PinOutcome, PinnedCursor}; diff --git a/crates/aionui-db/src/repository/project.rs b/crates/aionui-db/src/repository/project.rs index e586e32c4..6e639bba3 100644 --- a/crates/aionui-db/src/repository/project.rs +++ b/crates/aionui-db/src/repository/project.rs @@ -67,6 +67,12 @@ pub trait IProjectStore: Send + Sync { ) -> Result; async fn remove_entry(&self, user_id: &str, pe_id: &str) -> Result<(), DbError>; + + /// Delete a project owned by `user_id` and all of its explorer entries in one + /// transaction. Idempotent: an absent or foreign `project_id` deletes nothing + /// and still succeeds. `folders` are global (no owner) and are never removed + /// here — a folder may still back other projects or be re-adopted later. + async fn delete_project(&self, user_id: &str, project_id: &str) -> Result<(), DbError>; async fn reorder(&self, user_id: &str, project_id: &str, ordered_pe_ids: &[String]) -> Result<(), DbError>; async fn rename_entry( &self, diff --git a/crates/aionui-db/src/repository/sidebar.rs b/crates/aionui-db/src/repository/sidebar.rs new file mode 100644 index 000000000..db73e4df5 --- /dev/null +++ b/crates/aionui-db/src/repository/sidebar.rs @@ -0,0 +1,102 @@ +//! Sidebar read-model store: thin classification rows + batch hydration. +//! +//! The sidebar renders the whole left panel from one read snapshot. To stay +//! cheap at 10k–50k conversations this store returns *thin* rows (just the +//! fields the service needs to classify and order — no message/model blobs), +//! and hydrates only the windowed items via a single `IN (...)` batch. See +//! `api-contract-sidebar.md` §5.1 (single read transaction, no N+1). +//! +//! Base exclusion (conversations): `archived_at IS NULL` + the shared +//! health-check keep-fragment (BR-23, `$.is_health_check` in `extra`). The +//! marker is legacy and has no writer anywhere in the workspace today, so the +//! predicate is a defensive no-op — but it is applied identically by the thin +//! listing and by hydration so the口径 cannot drift if a writer ever appears. +//! Teams have no probe rows, so their thin listing filters `archived_at` only. + +use crate::error::DbError; +use crate::models::ConversationRow; + +/// A conversation reduced to what sidebar classification needs. +/// +/// `workspace` / `team_id` are `json_extract`ed from `extra`; `project_id` is +/// `NULLIF`'d to fold the empty string into `None`. +#[derive(Debug, Clone)] +pub struct SidebarConversationThin { + pub id: String, + /// `conversations.project_id`, empty-string folded to `None`. + pub project_id: Option, + /// `extra.$.workspace` — a plain, un-canonicalized fs path (the write path + /// only fs-validates it; see contract §6①). Empty/absent → `None`. + pub workspace: Option, + /// Team-membership marker, `COALESCE(extra.$.team_id, extra.$.teamId)` with + /// canonical `team_id` winning (BR-22 — production still writes camelCase). + /// Present → this conversation is a team member, not an independent row + /// (contract §3.1). The referenced team may not be live/owned (orphan) — + /// the service downgrades those back to independent rows (BR-8). + pub team_id: Option, + pub updated_at: i64, + pub created_at: i64, +} + +/// A team reduced to what sidebar classification needs. The path source is the +/// `teams.workspace` column (contract §6③), never reconstructed from members. +#[derive(Debug, Clone)] +pub struct SidebarTeamThin { + pub id: String, + pub name: String, + pub project_id: Option, + /// `teams.workspace` column; empty string → `None` (no workspace set). + pub workspace: Option, + pub updated_at: i64, + pub created_at: i64, +} + +/// Project spine metadata: a project plus its workspace folder identity. +/// +/// The service uses one enumeration of the user's projects for everything: +/// `kind` distinguishes standard vs temp for bound-item classification +/// (§2 case 1/2), `workspace_canonical` drives path merge (§2 case 3), and the +/// standard subset is the group spine that surfaces even empty projects (BR-5). +#[derive(Debug, Clone)] +pub struct SidebarProjectMeta { + pub project_id: String, + pub name: String, + /// `projects.kind` — service-layer enum `standard` | `temp`. + pub kind: String, + /// Canonical URI of the workspace folder; `None` if the project has no + /// workspace entry (a bound project may legitimately lack one). + pub workspace_canonical: Option, + /// Raw workspace folder URI, for display in the project-head "+" entry. + pub workspace_uri: Option, + /// `projects.created_at` — group-order tie-break for empty standard + /// projects (BR-6: empty project groups order by created_at DESC). + pub created_at: i64, +} + +/// Read-only sidebar queries. Every method is user-scoped: conversations and +/// teams filter `user_id`, and the project enumeration filters `projects.user_id` +/// (added by migration 030). A conversation may carry a foreign `project_id`, but +/// because the project map is built only from the caller's own projects such a +/// binding resolves to nothing and degrades to a dangling id (BR-24). +#[async_trait::async_trait] +pub trait ISidebarStore: Send + Sync { + /// All active (non-archived) conversations for a user, thin. + async fn list_active_conversations_thin(&self, user_id: &str) -> Result, DbError>; + + /// All active (non-archived) teams for a user, thin. + async fn list_active_teams_thin(&self, user_id: &str) -> Result, DbError>; + + /// Every project owned by the user (standard *and* temp), each joined to its + /// workspace folder identity. This single enumeration is the service's whole + /// project picture: the group spine (standard subset, incl. zero-conversation + /// projects — BR-5), the id→kind map for bound-item classification, and the + /// canonical→project map for path merge. Projects are few per user, so no + /// windowing here — the LIMIT 100 on the project area is applied after + /// activity ordering in the service (BR-5/6). + async fn list_user_projects(&self, user_id: &str) -> Result, DbError>; + + /// Full rows for the windowed conversation ids, for response hydration. + /// Filtered to the user and to non-archived rows (a race that archives a row + /// mid-request simply drops it from the window). + async fn hydrate_conversations(&self, user_id: &str, ids: &[String]) -> Result, DbError>; +} diff --git a/crates/aionui-db/src/repository/sqlite_project.rs b/crates/aionui-db/src/repository/sqlite_project.rs index ca1f20e67..065125864 100644 --- a/crates/aionui-db/src/repository/sqlite_project.rs +++ b/crates/aionui-db/src/repository/sqlite_project.rs @@ -254,6 +254,24 @@ impl IProjectStore for SqliteProjectStore { Ok(()) } + async fn delete_project(&self, user_id: &str, project_id: &str) -> Result<(), DbError> { + // Explorer entries first, then the project row — both owner-scoped, one + // transaction. `folders` are global and intentionally left intact. + let mut tx = self.pool.begin().await?; + sqlx::query("DELETE FROM project_explorer WHERE project_id = ? AND owner_user_id = ?") + .bind(project_id) + .bind(user_id) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM projects WHERE project_id = ? AND user_id = ?") + .bind(project_id) + .bind(user_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + async fn reorder(&self, user_id: &str, project_id: &str, ordered_pe_ids: &[String]) -> Result<(), DbError> { let now = now_ms(); let mut tx = self.pool.begin().await?; diff --git a/crates/aionui-db/src/repository/sqlite_sidebar.rs b/crates/aionui-db/src/repository/sqlite_sidebar.rs new file mode 100644 index 000000000..d2af7d7f6 --- /dev/null +++ b/crates/aionui-db/src/repository/sqlite_sidebar.rs @@ -0,0 +1,148 @@ +use sqlx::{Row, SqlitePool}; + +use crate::error::DbError; +use crate::models::ConversationRow; +use crate::repository::sidebar::{ISidebarStore, SidebarConversationThin, SidebarProjectMeta, SidebarTeamThin}; + +/// SQLite-backed implementation of [`ISidebarStore`]. +#[derive(Clone, Debug)] +pub struct SqliteSidebarStore { + pool: SqlitePool, +} + +impl SqliteSidebarStore { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +/// Shared "keep this conversation" fragment excluding probe / health-check rows +/// (BR-23). The marker is a legacy read-only key in `extra`; the whole workspace +/// currently has no writer for it, so this exclusion is defensive (a no-op until +/// something writes `$.is_health_check`). Both the thin listing and hydration use +/// this single constant so their口径 cannot drift. +const HEALTH_CHECK_KEEP: &str = + "(json_extract(extra, '$.is_health_check') IS NULL OR json_extract(extra, '$.is_health_check') NOT IN (1, 'true'))"; + +/// Fold a stored string into `None` when it is empty or whitespace-only. +/// +/// Applies uniformly to workspace paths (empty `extra.workspace` / `teams.workspace` +/// = "no workspace") and to the `teamId` marker (matches the service-layer +/// `team_id_marker_from_extra_str` trim rule). +fn non_blank(value: Option) -> Option { + value.filter(|s| !s.trim().is_empty()) +} + +/// Build a `?,?,…` placeholder list for a dynamic `IN (...)` clause. +fn placeholders(count: usize) -> String { + std::iter::repeat_n("?", count).collect::>().join(",") +} + +#[async_trait::async_trait] +impl ISidebarStore for SqliteSidebarStore { + async fn list_active_conversations_thin(&self, user_id: &str) -> Result, DbError> { + // Team-membership marker: canonical `team_id` first, camelCase `teamId` + // fallback (BR-22 — production still writes camelCase). + let sql = format!( + "SELECT id, \ + NULLIF(project_id, '') AS project_id, \ + json_extract(extra, '$.workspace') AS workspace, \ + COALESCE(NULLIF(json_extract(extra, '$.team_id'), ''), NULLIF(json_extract(extra, '$.teamId'), '')) AS team_id, \ + updated_at, created_at \ + FROM conversations \ + WHERE user_id = ? AND archived_at IS NULL AND {HEALTH_CHECK_KEEP}" + ); + let rows = sqlx::query(&sql).bind(user_id).fetch_all(&self.pool).await?; + + Ok(rows + .into_iter() + .map(|row| SidebarConversationThin { + id: row.get("id"), + project_id: non_blank(row.get("project_id")), + workspace: non_blank(row.get("workspace")), + team_id: non_blank(row.get("team_id")), + updated_at: row.get("updated_at"), + created_at: row.get("created_at"), + }) + .collect()) + } + + async fn list_active_teams_thin(&self, user_id: &str) -> Result, DbError> { + let rows = sqlx::query( + "SELECT id, name, \ + NULLIF(project_id, '') AS project_id, \ + workspace, updated_at, created_at \ + FROM teams \ + WHERE user_id = ? AND archived_at IS NULL", + ) + .bind(user_id) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .map(|row| SidebarTeamThin { + id: row.get("id"), + name: row.get("name"), + project_id: non_blank(row.get("project_id")), + workspace: non_blank(row.get("workspace")), + updated_at: row.get("updated_at"), + created_at: row.get("created_at"), + }) + .collect()) + } + + async fn list_user_projects(&self, user_id: &str) -> Result, DbError> { + // One user-scoped enumeration of every project (standard + temp), each + // LEFT JOINed to its workspace-root folder. `projects.user_id` (migration + // 030) confines the result to the caller, so a foreign `project_id` carried + // by one of the caller's conversations simply finds no match here and the + // service treats it as dangling (BR-24). At most one workspace folder per + // project (idx_project_explorer_one_workspace_folder), so no row fan-out. + let rows = sqlx::query( + "SELECT p.project_id AS project_id, p.name AS name, p.kind AS kind, p.created_at AS created_at, \ + f.resource_canonical AS workspace_canonical, f.resource_uri AS workspace_uri \ + FROM projects p \ + LEFT JOIN project_explorer pe ON pe.project_id = p.project_id AND pe.role = 'workspace' \ + LEFT JOIN folders f ON f.folder_id = pe.folder_id \ + WHERE p.user_id = ?", + ) + .bind(user_id) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(map_project_meta).collect()) + } + + async fn hydrate_conversations(&self, user_id: &str, ids: &[String]) -> Result, DbError> { + if ids.is_empty() { + return Ok(Vec::new()); + } + let sql = format!( + "SELECT * FROM conversations \ + WHERE user_id = ? AND archived_at IS NULL AND {HEALTH_CHECK_KEEP} AND id IN ({})", + placeholders(ids.len()) + ); + let mut query = sqlx::query_as::<_, ConversationRow>(&sql); + query = query.bind(user_id); + for id in ids { + query = query.bind(id); + } + Ok(query.fetch_all(&self.pool).await?) + } +} + +/// Map a project spine join row into [`SidebarProjectMeta`]. +fn map_project_meta(row: sqlx::sqlite::SqliteRow) -> SidebarProjectMeta { + SidebarProjectMeta { + project_id: row.get("project_id"), + name: row.get("name"), + kind: row.get("kind"), + workspace_canonical: row.get("workspace_canonical"), + workspace_uri: row.get("workspace_uri"), + created_at: row.get("created_at"), + } +} + +#[cfg(test)] +#[path = "sqlite_sidebar_test.rs"] +mod sqlite_sidebar_test; diff --git a/crates/aionui-db/src/repository/sqlite_sidebar_test.rs b/crates/aionui-db/src/repository/sqlite_sidebar_test.rs new file mode 100644 index 000000000..54076ed3c --- /dev/null +++ b/crates/aionui-db/src/repository/sqlite_sidebar_test.rs @@ -0,0 +1,299 @@ +use super::SqliteSidebarStore; +use crate::init_database_memory; +use crate::repository::sidebar::ISidebarStore; +use sqlx::SqlitePool; + +const USER: &str = "user-1"; +const OTHER_USER: &str = "user-2"; + +async fn store() -> (SqliteSidebarStore, crate::Database) { + let db = init_database_memory().await.unwrap(); + seed_user(db.pool(), USER).await; + seed_user(db.pool(), OTHER_USER).await; + let store = SqliteSidebarStore::new(db.pool().clone()); + (store, db) +} + +async fn seed_user(pool: &SqlitePool, id: &str) { + sqlx::query("INSERT INTO users (id, username, password_hash, created_at, updated_at) VALUES (?, ?, 'x', 0, 0)") + .bind(id) + .bind(id) + .execute(pool) + .await + .unwrap(); +} + +/// Insert a conversation with explicit project_id, extra json, and archived_at. +#[allow(clippy::too_many_arguments)] +async fn insert_conv( + pool: &SqlitePool, + user: &str, + id: &str, + project_id: Option<&str>, + extra: &str, + updated_at: i64, + archived_at: Option, +) { + sqlx::query( + "INSERT INTO conversations (id, user_id, name, type, extra, project_id, archived_at, created_at, updated_at) \ + VALUES (?, ?, ?, 'chat', ?, ?, ?, ?, ?)", + ) + .bind(id) + .bind(user) + .bind(id) + .bind(extra) + .bind(project_id) + .bind(archived_at) + .bind(updated_at) + .bind(updated_at) + .execute(pool) + .await + .unwrap(); +} + +async fn insert_team( + pool: &SqlitePool, + user: &str, + id: &str, + workspace: &str, + project_id: Option<&str>, + updated_at: i64, + archived_at: Option, +) { + sqlx::query( + "INSERT INTO teams (id, user_id, name, workspace, project_id, archived_at, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(id) + .bind(user) + .bind(id) + .bind(workspace) + .bind(project_id) + .bind(archived_at) + .bind(updated_at) + .bind(updated_at) + .execute(pool) + .await + .unwrap(); +} + +/// Insert a project plus its workspace-root folder + explorer entry. +#[allow(clippy::too_many_arguments)] +async fn insert_project_with_workspace( + pool: &SqlitePool, + user: &str, + project_id: &str, + name: &str, + kind: &str, + folder_id: &str, + canonical: &str, + uri: &str, +) { + sqlx::query( + "INSERT INTO projects (project_id, user_id, name, kind, created_at, updated_at) VALUES (?, ?, ?, ?, 0, 0)", + ) + .bind(project_id) + .bind(user) + .bind(name) + .bind(kind) + .execute(pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO folders (folder_id, resource_uri, resource_canonical, created_at, updated_at) VALUES (?, ?, ?, 0, 0)", + ) + .bind(folder_id) + .bind(uri) + .bind(canonical) + .execute(pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO project_explorer (pe_id, project_id, folder_id, role, order_index, created_at, updated_at) \ + VALUES (?, ?, ?, 'workspace', 0, 0, 0)", + ) + .bind(format!("pe-{project_id}")) + .bind(project_id) + .bind(folder_id) + .execute(pool) + .await + .unwrap(); +} + +#[tokio::test] +async fn thin_conversations_extract_fields_and_fold_blanks() { + let (store, db) = store().await; + let pool = db.pool(); + // Bound conversation, has workspace + teamId. + insert_conv( + pool, + USER, + "c1", + Some("proj-A"), + r#"{"workspace":"/repo/a","teamId":"t9"}"#, + 100, + None, + ) + .await; + // Unbound (empty project_id folds to None), blank workspace + whitespace teamId fold to None. + insert_conv( + pool, + USER, + "c2", + Some(""), + r#"{"workspace":"","teamId":" "}"#, + 50, + None, + ) + .await; + // No extra keys at all. + insert_conv(pool, USER, "c3", None, "{}", 10, None).await; + + let mut rows = store.list_active_conversations_thin(USER).await.unwrap(); + rows.sort_by(|a, b| a.id.cmp(&b.id)); + assert_eq!(rows.len(), 3); + + assert_eq!(rows[0].id, "c1"); + assert_eq!(rows[0].project_id.as_deref(), Some("proj-A")); + assert_eq!(rows[0].workspace.as_deref(), Some("/repo/a")); + assert_eq!(rows[0].team_id.as_deref(), Some("t9")); + assert_eq!(rows[0].updated_at, 100); + + assert_eq!(rows[1].id, "c2"); + assert_eq!(rows[1].project_id, None, "empty project_id folds to None"); + assert_eq!(rows[1].workspace, None, "empty workspace folds to None"); + assert_eq!(rows[1].team_id, None, "whitespace teamId folds to None"); + + assert_eq!(rows[2].id, "c3"); + assert_eq!(rows[2].workspace, None); + assert_eq!(rows[2].team_id, None); +} + +#[tokio::test] +async fn thin_conversations_exclude_archived_and_other_users() { + let (store, db) = store().await; + let pool = db.pool(); + insert_conv(pool, USER, "live", None, "{}", 10, None).await; + insert_conv(pool, USER, "archived", None, "{}", 20, Some(999)).await; + insert_conv(pool, OTHER_USER, "foreign", None, "{}", 30, None).await; + + let rows = store.list_active_conversations_thin(USER).await.unwrap(); + let ids: Vec<&str> = rows.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, vec!["live"], "archived and cross-user rows excluded"); +} + +#[tokio::test] +async fn thin_teams_fold_blank_workspace_and_exclude_archived() { + let (store, db) = store().await; + let pool = db.pool(); + insert_team(pool, USER, "t1", "/team/ws", Some("proj-A"), 100, None).await; + insert_team(pool, USER, "t2", "", None, 50, None).await; + insert_team(pool, USER, "t3", "/x", None, 10, Some(1)).await; + + let mut rows = store.list_active_teams_thin(USER).await.unwrap(); + rows.sort_by(|a, b| a.id.cmp(&b.id)); + let ids: Vec<&str> = rows.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, vec!["t1", "t2"], "archived team excluded"); + assert_eq!(rows[0].workspace.as_deref(), Some("/team/ws")); + assert_eq!(rows[0].project_id.as_deref(), Some("proj-A")); + assert_eq!(rows[1].workspace, None, "empty team workspace folds to None"); +} + +#[tokio::test] +async fn list_user_projects_joins_workspace_folder_and_carries_kind() { + let (store, db) = store().await; + let pool = db.pool(); + insert_project_with_workspace( + pool, + USER, + "proj-A", + "Alpha", + "standard", + "f-A", + "file:///repo/a", + "file:///repo/a", + ) + .await; + // Temp project with no workspace entry: still enumerated, canonical/uri None. + sqlx::query("INSERT INTO projects (project_id, user_id, name, kind, created_at, updated_at) VALUES ('proj-B', ?, 'Beta', 'temp', 7, 0)") + .bind(USER) + .execute(pool) + .await + .unwrap(); + + let metas = store.list_user_projects(USER).await.unwrap(); + assert_eq!(metas.len(), 2, "both the user's projects enumerated"); + + let a = metas.iter().find(|m| m.project_id == "proj-A").unwrap(); + assert_eq!(a.name, "Alpha"); + assert_eq!(a.kind, "standard"); + assert_eq!(a.workspace_canonical.as_deref(), Some("file:///repo/a")); + + let b = metas.iter().find(|m| m.project_id == "proj-B").unwrap(); + assert_eq!(b.kind, "temp", "temp kind carried for case-2 classification"); + assert_eq!(b.workspace_canonical, None, "no workspace entry => None"); + assert_eq!(b.created_at, 7, "created_at carried for empty-group ordering"); +} + +#[tokio::test] +async fn list_user_projects_is_user_scoped() { + let (store, db) = store().await; + let pool = db.pool(); + // OTHER_USER owns a project whose workspace canonical USER might also point at. + insert_project_with_workspace( + pool, + OTHER_USER, + "proj-foreign", + "Foreign", + "standard", + "f-F", + "file:///repo/shared", + "file:///repo/shared", + ) + .await; + insert_project_with_workspace( + pool, + USER, + "proj-mine", + "Mine", + "standard", + "f-M", + "file:///repo/mine", + "file:///repo/mine", + ) + .await; + + let metas = store.list_user_projects(USER).await.unwrap(); + let ids: Vec<&str> = metas.iter().map(|m| m.project_id.as_str()).collect(); + assert_eq!(ids, vec!["proj-mine"], "another user's project never surfaces (BR-24)"); +} + +#[tokio::test] +async fn hydrate_conversations_is_scoped_and_batched() { + let (store, db) = store().await; + let pool = db.pool(); + insert_conv(pool, USER, "c1", Some("proj-A"), r#"{"workspace":"/a"}"#, 100, None).await; + insert_conv(pool, USER, "c2", None, "{}", 50, None).await; + insert_conv(pool, USER, "archived", None, "{}", 40, Some(1)).await; + insert_conv(pool, OTHER_USER, "foreign", None, "{}", 30, None).await; + + let rows = store + .hydrate_conversations(USER, &["c1".into(), "c2".into(), "archived".into(), "foreign".into()]) + .await + .unwrap(); + let mut ids: Vec<&str> = rows.iter().map(|r| r.id.as_str()).collect(); + ids.sort(); + assert_eq!(ids, vec!["c1", "c2"], "archived + cross-user dropped from window"); + let c1 = rows.iter().find(|r| r.id == "c1").unwrap(); + assert_eq!(c1.project_id.as_deref(), Some("proj-A")); +} + +#[tokio::test] +async fn empty_inputs_short_circuit() { + let (store, _db) = store().await; + assert!( + store.list_user_projects(USER).await.unwrap().is_empty(), + "no projects => empty" + ); + assert!(store.hydrate_conversations(USER, &[]).await.unwrap().is_empty()); +} diff --git a/crates/aionui-db/src/repository/sqlite_user_order.rs b/crates/aionui-db/src/repository/sqlite_user_order.rs new file mode 100644 index 000000000..3826307a8 --- /dev/null +++ b/crates/aionui-db/src/repository/sqlite_user_order.rs @@ -0,0 +1,410 @@ +use aionui_common::now_ms; +use sqlx::{SqliteConnection, SqlitePool}; + +use crate::error::DbError; +use crate::models::{OrderItemType, OrderScene, UserOrderRow}; +use crate::repository::user_order::{IUserOrderStore, MoveOutcome, OrderItemRef, PinOutcome, PinnedCursor}; + +/// Gap between adjacent pins; a fresh top pin claims `min - PIN_GAP`, and a +/// rebalance spaces rows `PIN_GAP` apart. +const PIN_GAP: i64 = 1000; + +/// Minimum neighbour gap a `move` needs to fit an integer midpoint with room to +/// spare (D4). A smaller gap triggers a whole-scene rebalance rather than +/// risking a midpoint that ties an existing key. +const REBALANCE_THRESHOLD: i64 = 16; + +const USER_ORDER_COLS: &str = "user_id, scene, item_type, item_id, order_key, created_at, updated_at"; + +/// SQLite-backed implementation of [`IUserOrderStore`]. +#[derive(Clone, Debug)] +pub struct SqliteUserOrderStore { + pool: SqlitePool, +} + +impl SqliteUserOrderStore { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl IUserOrderStore for SqliteUserOrderStore { + async fn pin(&self, user_id: &str, scene: OrderScene, item: &OrderItemRef) -> Result { + // BEGIN IMMEDIATE claims the writer lock up front so the + // read-min-then-insert is atomic: two concurrent pins can't both read + // the same MIN and insert colliding top rows (the second queues on the + // busy handler). Same pattern as `insert_message_once`. + let mut connection = self.pool.acquire().await?; + sqlx::query("BEGIN IMMEDIATE").execute(&mut *connection).await?; + + let result: Result = async { + let exists: i64 = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM user_order \ + WHERE user_id = ? AND scene = ? AND item_type = ? AND item_id = ?)", + ) + .bind(user_id) + .bind(scene.as_str()) + .bind(item.item_type.as_str()) + .bind(&item.item_id) + .fetch_one(&mut *connection) + .await?; + if exists != 0 { + return Ok(PinOutcome::AlreadyPinned); + } + + // Empty scene → NULL MIN → start at PIN_GAP; otherwise one gap above + // the current top. order_key is not unique, so no collision handling + // is needed here. + let min_key: Option = + sqlx::query_scalar("SELECT MIN(order_key) FROM user_order WHERE user_id = ? AND scene = ?") + .bind(user_id) + .bind(scene.as_str()) + .fetch_one(&mut *connection) + .await?; + let order_key = min_key.map(|min| min - PIN_GAP).unwrap_or(PIN_GAP); + + let now = now_ms(); + sqlx::query( + "INSERT INTO user_order (user_id, scene, item_type, item_id, order_key, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(user_id) + .bind(scene.as_str()) + .bind(item.item_type.as_str()) + .bind(&item.item_id) + .bind(order_key) + .bind(now) + .bind(now) + .execute(&mut *connection) + .await?; + Ok(PinOutcome::Inserted) + } + .await; + + match result { + Ok(outcome) => { + sqlx::query("COMMIT").execute(&mut *connection).await?; + Ok(outcome) + } + Err(error) => { + let _ = sqlx::query("ROLLBACK").execute(&mut *connection).await; + Err(error) + } + } + } + + async fn unpin(&self, user_id: &str, scene: OrderScene, item: &OrderItemRef) -> Result { + let result = sqlx::query( + "DELETE FROM user_order \ + WHERE user_id = ? AND scene = ? AND item_type = ? AND item_id = ?", + ) + .bind(user_id) + .bind(scene.as_str()) + .bind(item.item_type.as_str()) + .bind(&item.item_id) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() > 0) + } + + async fn move_item( + &self, + user_id: &str, + scene: OrderScene, + moved: &OrderItemRef, + after: Option<&OrderItemRef>, + ) -> Result { + // BEGIN IMMEDIATE up front: read neighbours → compute key → write, all + // under the writer lock, so concurrent moves (multi-window / multi- + // instance) cannot both read stale neighbours and race their writes. + let mut connection = self.pool.acquire().await?; + sqlx::query("BEGIN IMMEDIATE").execute(&mut *connection).await?; + + let result: Result = async { + if row_order_key(&mut connection, user_id, scene, moved).await?.is_none() { + return Ok(MoveOutcome::MovedNotFound); + } + + let new_key = match after { + // Move to top: one gap below the current minimum. `moved` exists, + // so MIN is non-null; the result is strictly below every row, + // including `moved` itself, so it becomes the new top. + None => min_order_key(&mut connection, user_id, scene).await?.unwrap_or(PIN_GAP) - PIN_GAP, + Some(after) => { + let Some(left) = row_order_key(&mut connection, user_id, scene, after).await? else { + return Ok(MoveOutcome::AfterNotFound); + }; + // Successor of `after` excluding `moved` (its stale slot must + // not count as the neighbour we squeeze into). + match successor_key(&mut connection, user_id, scene, left, after, moved).await? { + None => left + PIN_GAP, + Some(right) if right - left >= REBALANCE_THRESHOLD => left + (right - left) / 2, + Some(_) => { + // Gap exhausted: respace the whole scene 1000 apart, + // then recompute against the fresh keys. After a + // rebalance neighbour gaps are >= PIN_GAP, so the + // midpoint is guaranteed strictly between them. + rebalance_scene(&mut connection, user_id, scene).await?; + let left = row_order_key(&mut connection, user_id, scene, after) + .await? + .ok_or_else(|| DbError::NotFound("after row vanished during rebalance".into()))?; + match successor_key(&mut connection, user_id, scene, left, after, moved).await? { + None => left + PIN_GAP, + Some(right) => left + (right - left) / 2, + } + } + } + } + }; + + sqlx::query( + "UPDATE user_order SET order_key = ?, updated_at = ? \ + WHERE user_id = ? AND scene = ? AND item_type = ? AND item_id = ?", + ) + .bind(new_key) + .bind(now_ms()) + .bind(user_id) + .bind(scene.as_str()) + .bind(moved.item_type.as_str()) + .bind(&moved.item_id) + .execute(&mut *connection) + .await?; + Ok(MoveOutcome::Moved) + } + .await; + + match result { + Ok(outcome) => { + sqlx::query("COMMIT").execute(&mut *connection).await?; + Ok(outcome) + } + Err(error) => { + let _ = sqlx::query("ROLLBACK").execute(&mut *connection).await; + Err(error) + } + } + } + + async fn list_pinned( + &self, + user_id: &str, + scene: OrderScene, + after: Option<&PinnedCursor>, + limit: i64, + ) -> Result, DbError> { + // Keyset on (order_key, item_type, item_id). Expanded lexicographic form + // (rather than a row-value tuple) so the leading order_key range uses + // idx_user_order_scene. + let rows = match after { + None => { + sqlx::query_as::<_, UserOrderRow>(&format!( + "SELECT {USER_ORDER_COLS} FROM user_order \ + WHERE user_id = ? AND scene = ? \ + ORDER BY order_key ASC, item_type ASC, item_id ASC \ + LIMIT ?" + )) + .bind(user_id) + .bind(scene.as_str()) + .bind(limit) + .fetch_all(&self.pool) + .await? + } + Some(cursor) => { + sqlx::query_as::<_, UserOrderRow>(&format!( + "SELECT {USER_ORDER_COLS} FROM user_order \ + WHERE user_id = ? AND scene = ? AND ( \ + order_key > ? OR \ + (order_key = ? AND (item_type > ? OR (item_type = ? AND item_id > ?))) \ + ) \ + ORDER BY order_key ASC, item_type ASC, item_id ASC \ + LIMIT ?" + )) + .bind(user_id) + .bind(scene.as_str()) + .bind(cursor.order_key) + .bind(cursor.order_key) + .bind(cursor.item_type.as_str()) + .bind(cursor.item_type.as_str()) + .bind(&cursor.item_id) + .bind(limit) + .fetch_all(&self.pool) + .await? + } + }; + Ok(rows) + } + + async fn pinned_refs(&self, user_id: &str, scene: OrderScene) -> Result, DbError> { + let rows: Vec<(String, String)> = + sqlx::query_as("SELECT item_type, item_id FROM user_order WHERE user_id = ? AND scene = ?") + .bind(user_id) + .bind(scene.as_str()) + .fetch_all(&self.pool) + .await?; + // Skip rows whose item_type is out of the enum (defensive; the write + // path only ever stores known values). + Ok(rows + .into_iter() + .filter_map(|(item_type, item_id)| { + OrderItemType::parse(&item_type).map(|item_type| OrderItemRef { item_type, item_id }) + }) + .collect()) + } + + async fn remove_item(&self, user_id: &str, item: &OrderItemRef) -> Result<(), DbError> { + sqlx::query("DELETE FROM user_order WHERE user_id = ? AND item_type = ? AND item_id = ?") + .bind(user_id) + .bind(item.item_type.as_str()) + .bind(&item.item_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn remove_items(&self, user_id: &str, items: &[OrderItemRef]) -> Result<(), DbError> { + if items.is_empty() { + return Ok(()); + } + // One transaction so the cascade is atomic: either every referenced + // row is gone or none are (a mid-batch failure rolls back). + let mut connection = self.pool.acquire().await?; + sqlx::query("BEGIN IMMEDIATE").execute(&mut *connection).await?; + + let result: Result<(), DbError> = async { + for item in items { + sqlx::query("DELETE FROM user_order WHERE user_id = ? AND item_type = ? AND item_id = ?") + .bind(user_id) + .bind(item.item_type.as_str()) + .bind(&item.item_id) + .execute(&mut *connection) + .await?; + } + Ok(()) + } + .await; + + match result { + Ok(()) => { + sqlx::query("COMMIT").execute(&mut *connection).await?; + Ok(()) + } + Err(error) => { + let _ = sqlx::query("ROLLBACK").execute(&mut *connection).await; + Err(error) + } + } + } +} + +/// The `order_key` of one `(item_type, item_id)` row in a scene, or `None` when +/// the row is absent (stale window). Used by `move_item` to resolve the `moved` +/// and `after` anchors under the write lock. +async fn row_order_key( + conn: &mut SqliteConnection, + user_id: &str, + scene: OrderScene, + item: &OrderItemRef, +) -> Result, DbError> { + let key: Option = sqlx::query_scalar( + "SELECT order_key FROM user_order \ + WHERE user_id = ? AND scene = ? AND item_type = ? AND item_id = ?", + ) + .bind(user_id) + .bind(scene.as_str()) + .bind(item.item_type.as_str()) + .bind(&item.item_id) + .fetch_optional(&mut *conn) + .await?; + Ok(key) +} + +/// The smallest `order_key` in a scene, or `None` when the scene is empty. +async fn min_order_key(conn: &mut SqliteConnection, user_id: &str, scene: OrderScene) -> Result, DbError> { + let min_key: Option = + sqlx::query_scalar("SELECT MIN(order_key) FROM user_order WHERE user_id = ? AND scene = ?") + .bind(user_id) + .bind(scene.as_str()) + .fetch_one(&mut *conn) + .await?; + Ok(min_key) +} + +/// The `order_key` of `after`'s immediate successor in `(order_key, item_type, +/// item_id)` order, **excluding `moved`** (its stale slot must not count as the +/// neighbour). `None` when `after` is the last row (ignoring `moved`). +/// +/// The `moved` exclusion is what lets a drag land right after its old neighbour: +/// were `moved` still counted, dragging it one slot down would pick `moved` +/// itself as the successor and wedge the new key against its own stale value. +async fn successor_key( + conn: &mut SqliteConnection, + user_id: &str, + scene: OrderScene, + after_key: i64, + after: &OrderItemRef, + moved: &OrderItemRef, +) -> Result, DbError> { + // Expanded lexicographic ">" on the (order_key, item_type, item_id) triple, + // mirroring the keyset in `list_pinned`, so the leading order_key range uses + // idx_user_order_scene. The trailing NOT(item_type=? AND item_id=?) drops + // `moved` from the candidate set. + let key: Option = sqlx::query_scalar( + "SELECT order_key FROM user_order \ + WHERE user_id = ? AND scene = ? \ + AND (order_key > ? OR (order_key = ? AND (item_type > ? OR (item_type = ? AND item_id > ?)))) \ + AND NOT (item_type = ? AND item_id = ?) \ + ORDER BY order_key ASC, item_type ASC, item_id ASC \ + LIMIT 1", + ) + .bind(user_id) + .bind(scene.as_str()) + .bind(after_key) + .bind(after_key) + .bind(after.item_type.as_str()) + .bind(after.item_type.as_str()) + .bind(&after.item_id) + .bind(moved.item_type.as_str()) + .bind(&moved.item_id) + .fetch_optional(&mut *conn) + .await?; + Ok(key) +} + +/// Respace an entire scene's `order_key`s to `PIN_GAP, 2*PIN_GAP, …` in their +/// current `(order_key, item_type, item_id)` order, restoring uniform gaps when +/// a `move` has exhausted the room between two neighbours. Runs inside the +/// caller's `BEGIN IMMEDIATE` transaction, so the read-then-rewrite is atomic. +async fn rebalance_scene(conn: &mut SqliteConnection, user_id: &str, scene: OrderScene) -> Result<(), DbError> { + let rows: Vec<(String, String)> = sqlx::query_as( + "SELECT item_type, item_id FROM user_order \ + WHERE user_id = ? AND scene = ? \ + ORDER BY order_key ASC, item_type ASC, item_id ASC", + ) + .bind(user_id) + .bind(scene.as_str()) + .fetch_all(&mut *conn) + .await?; + + let now = now_ms(); + for (index, (item_type, item_id)) in rows.into_iter().enumerate() { + let order_key = (index as i64 + 1) * PIN_GAP; + sqlx::query( + "UPDATE user_order SET order_key = ?, updated_at = ? \ + WHERE user_id = ? AND scene = ? AND item_type = ? AND item_id = ?", + ) + .bind(order_key) + .bind(now) + .bind(user_id) + .bind(scene.as_str()) + .bind(&item_type) + .bind(&item_id) + .execute(&mut *conn) + .await?; + } + Ok(()) +} + +#[cfg(test)] +#[path = "sqlite_user_order_test.rs"] +mod sqlite_user_order_test; diff --git a/crates/aionui-db/src/repository/sqlite_user_order_test.rs b/crates/aionui-db/src/repository/sqlite_user_order_test.rs new file mode 100644 index 000000000..e46bf251c --- /dev/null +++ b/crates/aionui-db/src/repository/sqlite_user_order_test.rs @@ -0,0 +1,516 @@ +use super::{PIN_GAP, SqliteUserOrderStore}; +use crate::init_database_memory; +use crate::models::{OrderItemType, OrderScene}; +use crate::repository::user_order::{IUserOrderStore, MoveOutcome, OrderItemRef, PinOutcome, PinnedCursor}; + +const USER: &str = "user-1"; +const OTHER_USER: &str = "user-2"; + +async fn store() -> (SqliteUserOrderStore, crate::Database) { + let db = init_database_memory().await.unwrap(); + let store = SqliteUserOrderStore::new(db.pool().clone()); + (store, db) +} + +fn conv(id: &str) -> OrderItemRef { + OrderItemRef::new(OrderItemType::Conversation, id) +} + +fn team(id: &str) -> OrderItemRef { + OrderItemRef::new(OrderItemType::Team, id) +} + +#[tokio::test] +async fn pin_inserts_first_row_at_base_key() { + let (store, _db) = store().await; + let outcome = store.pin(USER, OrderScene::Pinned, &conv("c1")).await.unwrap(); + assert_eq!(outcome, PinOutcome::Inserted); + + let rows = store.list_pinned(USER, OrderScene::Pinned, None, 10).await.unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].item_id, "c1"); + assert_eq!(rows[0].order_key, PIN_GAP); +} + +#[tokio::test] +async fn pin_stacks_newest_on_top() { + let (store, _db) = store().await; + store.pin(USER, OrderScene::Pinned, &conv("c1")).await.unwrap(); + store.pin(USER, OrderScene::Pinned, &conv("c2")).await.unwrap(); + store.pin(USER, OrderScene::Pinned, &team("t1")).await.unwrap(); + + // Ascending order_key => most-recently pinned first. + let rows = store.list_pinned(USER, OrderScene::Pinned, None, 10).await.unwrap(); + let ids: Vec<&str> = rows.iter().map(|r| r.item_id.as_str()).collect(); + assert_eq!(ids, vec!["t1", "c2", "c1"]); + assert_eq!(rows[0].order_key, PIN_GAP - 2 * PIN_GAP); + assert_eq!(rows[1].order_key, PIN_GAP - PIN_GAP); + assert_eq!(rows[2].order_key, PIN_GAP); +} + +#[tokio::test] +async fn pin_is_idempotent() { + let (store, _db) = store().await; + assert_eq!( + store.pin(USER, OrderScene::Pinned, &conv("c1")).await.unwrap(), + PinOutcome::Inserted + ); + assert_eq!( + store.pin(USER, OrderScene::Pinned, &conv("c1")).await.unwrap(), + PinOutcome::AlreadyPinned + ); + + let rows = store.list_pinned(USER, OrderScene::Pinned, None, 10).await.unwrap(); + assert_eq!(rows.len(), 1, "duplicate pin must not add a second row"); + assert_eq!(rows[0].order_key, PIN_GAP, "order_key preserved on no-op"); +} + +#[tokio::test] +async fn unpin_reports_removal_then_noop() { + let (store, _db) = store().await; + store.pin(USER, OrderScene::Pinned, &conv("c1")).await.unwrap(); + + assert!(store.unpin(USER, OrderScene::Pinned, &conv("c1")).await.unwrap()); + assert!( + !store.unpin(USER, OrderScene::Pinned, &conv("c1")).await.unwrap(), + "second unpin is an idempotent no-op" + ); + assert!( + store + .list_pinned(USER, OrderScene::Pinned, None, 10) + .await + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn list_pinned_paginates_by_keyset() { + let (store, _db) = store().await; + // Pin c1..c5; keys are 1000, 0, -1000, -2000, -3000 (newest lowest). + for id in ["c1", "c2", "c3", "c4", "c5"] { + store.pin(USER, OrderScene::Pinned, &conv(id)).await.unwrap(); + } + + let page1 = store.list_pinned(USER, OrderScene::Pinned, None, 2).await.unwrap(); + let ids1: Vec<&str> = page1.iter().map(|r| r.item_id.as_str()).collect(); + assert_eq!(ids1, vec!["c5", "c4"]); + + let last = page1.last().unwrap(); + let cursor = PinnedCursor { + order_key: last.order_key, + item_type: OrderItemType::parse(&last.item_type).unwrap(), + item_id: last.item_id.clone(), + }; + let page2 = store + .list_pinned(USER, OrderScene::Pinned, Some(&cursor), 2) + .await + .unwrap(); + let ids2: Vec<&str> = page2.iter().map(|r| r.item_id.as_str()).collect(); + assert_eq!( + ids2, + vec!["c3", "c2"], + "keyset resumes strictly after cursor, no repeat" + ); +} + +#[tokio::test] +async fn pinned_reads_ride_the_scene_index_no_full_scan() { + // BR-25: the hot pinned reads must ride `idx_user_order_scene` + // (user_id, scene, order_key) and never degrade into a full-table scan. + // The keyset predicate is deliberately expanded lexicographically (see the + // comment in `list_pinned`) precisely so the leading `order_key` range stays + // index-driven; this test pins that guarantee. + use sqlx::Row; + + let (store, db) = store().await; + for id in ["c1", "c2", "c3"] { + store.pin(USER, OrderScene::Pinned, &conv(id)).await.unwrap(); + } + + let plan_detail = |sql: &'static str, binds: Vec| { + let pool = db.pool().clone(); + async move { + let mut query = sqlx::query(sql); + for bind in &binds { + query = query.bind(bind); + } + let rows = query.fetch_all(&pool).await.unwrap(); + rows.iter() + .map(|row| row.get::("detail")) + .collect::>() + .join(" | ") + } + }; + + // Base (first-screen) read: WHERE user_id, scene + ORDER BY order_key. + let base = plan_detail( + "EXPLAIN QUERY PLAN \ + SELECT user_id, scene, item_type, item_id, order_key FROM user_order \ + WHERE user_id = ? AND scene = ? \ + ORDER BY order_key ASC, item_type ASC, item_id ASC \ + LIMIT ?", + vec![USER.to_owned(), OrderScene::Pinned.as_str().to_owned(), "10".to_owned()], + ) + .await; + assert!( + base.contains("idx_user_order_scene"), + "base pinned read must use idx_user_order_scene, got plan: {base}" + ); + assert!( + !base.contains("SCAN user_order"), + "base pinned read must not full-scan user_order, got plan: {base}" + ); + + // Keyset continuation read: the expanded (order_key > ? OR ...) predicate. + let keyset = plan_detail( + "EXPLAIN QUERY PLAN \ + SELECT user_id, scene, item_type, item_id, order_key FROM user_order \ + WHERE user_id = ? AND scene = ? AND ( \ + order_key > ? OR \ + (order_key = ? AND (item_type > ? OR (item_type = ? AND item_id > ?))) \ + ) \ + ORDER BY order_key ASC, item_type ASC, item_id ASC \ + LIMIT ?", + vec![ + USER.to_owned(), + OrderScene::Pinned.as_str().to_owned(), + "0".to_owned(), + "0".to_owned(), + "conversation".to_owned(), + "conversation".to_owned(), + "c2".to_owned(), + "10".to_owned(), + ], + ) + .await; + assert!( + keyset.contains("idx_user_order_scene"), + "keyset pinned read must use idx_user_order_scene, got plan: {keyset}" + ); + assert!( + !keyset.contains("SCAN user_order"), + "keyset pinned read must not full-scan user_order, got plan: {keyset}" + ); +} + +#[tokio::test] +async fn pinned_refs_returns_all_typed_refs() { + let (store, _db) = store().await; + store.pin(USER, OrderScene::Pinned, &conv("c1")).await.unwrap(); + store.pin(USER, OrderScene::Pinned, &team("t1")).await.unwrap(); + + let mut refs = store.pinned_refs(USER, OrderScene::Pinned).await.unwrap(); + refs.sort_by(|a, b| a.item_id.cmp(&b.item_id)); + assert_eq!(refs, vec![conv("c1"), team("t1")]); +} + +#[tokio::test] +async fn remove_item_deletes_single_ref() { + let (store, _db) = store().await; + store.pin(USER, OrderScene::Pinned, &conv("c1")).await.unwrap(); + store.pin(USER, OrderScene::Pinned, &conv("c2")).await.unwrap(); + + store.remove_item(USER, &conv("c1")).await.unwrap(); + let ids: Vec = store + .pinned_refs(USER, OrderScene::Pinned) + .await + .unwrap() + .into_iter() + .map(|r| r.item_id) + .collect(); + assert_eq!(ids, vec!["c2"]); + // Idempotent: removing a gone item is fine. + store.remove_item(USER, &conv("c1")).await.unwrap(); +} + +#[tokio::test] +async fn remove_items_batch_is_atomic() { + let (store, _db) = store().await; + for id in ["c1", "c2", "c3"] { + store.pin(USER, OrderScene::Pinned, &conv(id)).await.unwrap(); + } + store.pin(USER, OrderScene::Pinned, &team("t1")).await.unwrap(); + + store + .remove_items(USER, &[conv("c1"), conv("c3"), team("t1")]) + .await + .unwrap(); + let ids: Vec = store + .pinned_refs(USER, OrderScene::Pinned) + .await + .unwrap() + .into_iter() + .map(|r| r.item_id) + .collect(); + assert_eq!(ids, vec!["c2"]); + + // Empty batch is a no-op. + store.remove_items(USER, &[]).await.unwrap(); + assert_eq!(store.pinned_refs(USER, OrderScene::Pinned).await.unwrap().len(), 1); +} + +#[tokio::test] +async fn rows_are_scoped_per_user() { + let (store, _db) = store().await; + store.pin(USER, OrderScene::Pinned, &conv("c1")).await.unwrap(); + + // A different user sees nothing and cannot unpin another user's row. + assert!( + store + .list_pinned(OTHER_USER, OrderScene::Pinned, None, 10) + .await + .unwrap() + .is_empty() + ); + assert!(!store.unpin(OTHER_USER, OrderScene::Pinned, &conv("c1")).await.unwrap()); + assert_eq!( + store + .list_pinned(USER, OrderScene::Pinned, None, 10) + .await + .unwrap() + .len(), + 1 + ); +} + +#[tokio::test] +async fn concurrent_pins_serialize_into_distinct_rows() { + let (store, _db) = store().await; + // Two concurrent pins on the same empty scene. BEGIN IMMEDIATE serializes + // the read-min-then-insert, so both land as distinct rows (no lost write, + // no duplicate key) with different order_keys. + let a = { + let store = store.clone(); + tokio::spawn(async move { store.pin(USER, OrderScene::Pinned, &conv("c1")).await }) + }; + let b = { + let store = store.clone(); + tokio::spawn(async move { store.pin(USER, OrderScene::Pinned, &conv("c2")).await }) + }; + a.await.unwrap().unwrap(); + b.await.unwrap().unwrap(); + + let rows = store.list_pinned(USER, OrderScene::Pinned, None, 10).await.unwrap(); + assert_eq!(rows.len(), 2); + assert_ne!(rows[0].order_key, rows[1].order_key, "keys must not collide"); +} + +// -- move_item ----------------------------------------------------------------- + +/// Force one row to a specific `order_key`, so a test can seed a degenerate +/// layout (e.g. an exhausted neighbour gap) that the public write path never +/// produces on its own. +async fn set_key(db: &crate::Database, item: &OrderItemRef, order_key: i64) { + sqlx::query( + "UPDATE user_order SET order_key = ? WHERE user_id = ? AND scene = ? AND item_type = ? AND item_id = ?", + ) + .bind(order_key) + .bind(USER) + .bind(OrderScene::Pinned.as_str()) + .bind(item.item_type.as_str()) + .bind(&item.item_id) + .execute(db.pool()) + .await + .unwrap(); +} + +async fn ids(store: &SqliteUserOrderStore) -> Vec { + store + .list_pinned(USER, OrderScene::Pinned, None, 100) + .await + .unwrap() + .into_iter() + .map(|r| r.item_id) + .collect() +} + +#[tokio::test] +async fn move_to_top_places_below_current_min() { + let (store, _db) = store().await; + // Pin c1,c2,c3 → keys 1000, 0, -1000 → order [c3, c2, c1]. + for id in ["c1", "c2", "c3"] { + store.pin(USER, OrderScene::Pinned, &conv(id)).await.unwrap(); + } + + // Move the bottom row (c1) to the top. + let outcome = store + .move_item(USER, OrderScene::Pinned, &conv("c1"), None) + .await + .unwrap(); + assert_eq!(outcome, MoveOutcome::Moved); + + assert_eq!(ids(&store).await, vec!["c1", "c3", "c2"]); + let rows = store.list_pinned(USER, OrderScene::Pinned, None, 10).await.unwrap(); + // min was -1000, so the new top is -2000, strictly below every other row. + assert_eq!(rows[0].order_key, -2000); +} + +#[tokio::test] +async fn move_after_anchor_lands_at_midpoint() { + let (store, _db) = store().await; + for id in ["c1", "c2", "c3"] { + store.pin(USER, OrderScene::Pinned, &conv(id)).await.unwrap(); + } + // order [c3(-1000), c2(0), c1(1000)]. Move c3 to directly after c2. + store + .move_item(USER, OrderScene::Pinned, &conv("c3"), Some(&conv("c2"))) + .await + .unwrap(); + + // Between c2(0) and c1(1000) → 500. + assert_eq!(ids(&store).await, vec!["c2", "c3", "c1"]); + let rows = store.list_pinned(USER, OrderScene::Pinned, None, 10).await.unwrap(); + assert_eq!(rows[1].item_id, "c3"); + assert_eq!(rows[1].order_key, 500); +} + +#[tokio::test] +async fn move_after_last_row_appends_one_gap_below() { + let (store, _db) = store().await; + for id in ["c1", "c2", "c3"] { + store.pin(USER, OrderScene::Pinned, &conv(id)).await.unwrap(); + } + // order [c3(-1000), c2(0), c1(1000)]. Move c2 to after the last row (c1). + store + .move_item(USER, OrderScene::Pinned, &conv("c2"), Some(&conv("c1"))) + .await + .unwrap(); + + assert_eq!(ids(&store).await, vec!["c3", "c1", "c2"]); + let rows = store.list_pinned(USER, OrderScene::Pinned, None, 10).await.unwrap(); + // No successor of c1(1000) once c2 is excluded → key = 1000 + PIN_GAP. + assert_eq!(rows[2].item_id, "c2"); + assert_eq!(rows[2].order_key, 1000 + PIN_GAP); +} + +#[tokio::test] +async fn move_after_immediate_predecessor_is_a_stable_noop_order() { + let (store, _db) = store().await; + for id in ["c1", "c2", "c3"] { + store.pin(USER, OrderScene::Pinned, &conv(id)).await.unwrap(); + } + // order [c3, c2, c1]. Move c2 to after c3 (its current predecessor). The + // moved-self exclusion means c3's successor is c1, not c2 — so c2 stays + // between them and the visible order is unchanged. + store + .move_item(USER, OrderScene::Pinned, &conv("c2"), Some(&conv("c3"))) + .await + .unwrap(); + assert_eq!(ids(&store).await, vec!["c3", "c2", "c1"]); +} + +#[tokio::test] +async fn move_missing_item_reports_not_found() { + let (store, _db) = store().await; + store.pin(USER, OrderScene::Pinned, &conv("c1")).await.unwrap(); + + let outcome = store + .move_item(USER, OrderScene::Pinned, &conv("ghost"), None) + .await + .unwrap(); + assert_eq!(outcome, MoveOutcome::MovedNotFound); + // Table untouched. + assert_eq!(ids(&store).await, vec!["c1"]); +} + +#[tokio::test] +async fn move_after_missing_anchor_reports_not_found() { + let (store, _db) = store().await; + store.pin(USER, OrderScene::Pinned, &conv("c1")).await.unwrap(); + store.pin(USER, OrderScene::Pinned, &conv("c2")).await.unwrap(); + + let outcome = store + .move_item(USER, OrderScene::Pinned, &conv("c1"), Some(&conv("ghost"))) + .await + .unwrap(); + assert_eq!(outcome, MoveOutcome::AfterNotFound); + // order unchanged [c2, c1]. + assert_eq!(ids(&store).await, vec!["c2", "c1"]); +} + +#[tokio::test] +async fn move_mixes_conversation_and_team_rows() { + let (store, _db) = store().await; + store.pin(USER, OrderScene::Pinned, &conv("c1")).await.unwrap(); + store.pin(USER, OrderScene::Pinned, &team("t1")).await.unwrap(); + // order [t1(0), c1(1000)]. Move t1 to after c1 → t1 goes to the bottom. + store + .move_item(USER, OrderScene::Pinned, &team("t1"), Some(&conv("c1"))) + .await + .unwrap(); + assert_eq!(ids(&store).await, vec!["c1", "t1"]); +} + +#[tokio::test] +async fn move_rebalances_when_neighbour_gap_is_exhausted() { + let (store, db) = store().await; + for id in ["a", "b", "m"] { + store.pin(USER, OrderScene::Pinned, &conv(id)).await.unwrap(); + } + // Seed a degenerate layout: a(1000), b(1001) are adjacent (gap 1), m parked + // at the bottom. Moving m to after a cannot fit an integer midpoint, so the + // whole scene must rebalance first. + set_key(&db, &conv("a"), 1000).await; + set_key(&db, &conv("b"), 1001).await; + set_key(&db, &conv("m"), 5000).await; + + store + .move_item(USER, OrderScene::Pinned, &conv("m"), Some(&conv("a"))) + .await + .unwrap(); + + // Rebalance respaced [a, b, m] → 1000, 2000, 3000; then m lands between the + // fresh a(1000) and b(2000) at 1500. + assert_eq!(ids(&store).await, vec!["a", "m", "b"]); + let rows = store.list_pinned(USER, OrderScene::Pinned, None, 10).await.unwrap(); + let by_id = |id: &str| rows.iter().find(|r| r.item_id == id).unwrap().order_key; + assert_eq!(by_id("a"), 1000); + assert_eq!(by_id("m"), 1500); + assert_eq!(by_id("b"), 2000, "b respaced away from its exhausted 1001 slot"); + // Soft invariant: no two rows share a key after the move. + let mut keys: Vec = rows.iter().map(|r| r.order_key).collect(); + keys.sort_unstable(); + let unique = { + let mut k = keys.clone(); + k.dedup(); + k.len() + }; + assert_eq!(unique, keys.len(), "keys must stay distinct after rebalance"); +} + +#[tokio::test] +async fn concurrent_moves_serialize_without_key_collision() { + let (store, _db) = store().await; + for id in ["c1", "c2", "c3"] { + store.pin(USER, OrderScene::Pinned, &conv(id)).await.unwrap(); + } + // Two concurrent moves on the same scene. BEGIN IMMEDIATE serializes the + // read-neighbours → compute → write, so neither reads a stale layout and + // both commit as Moved. + let a = { + let store = store.clone(); + tokio::spawn(async move { store.move_item(USER, OrderScene::Pinned, &conv("c1"), None).await }) + }; + let b = { + let store = store.clone(); + tokio::spawn(async move { + store + .move_item(USER, OrderScene::Pinned, &conv("c2"), Some(&conv("c3"))) + .await + }) + }; + assert_eq!(a.await.unwrap().unwrap(), MoveOutcome::Moved); + assert_eq!(b.await.unwrap().unwrap(), MoveOutcome::Moved); + + let rows = store.list_pinned(USER, OrderScene::Pinned, None, 10).await.unwrap(); + assert_eq!(rows.len(), 3, "no rows lost"); + let mut keys: Vec = rows.iter().map(|r| r.order_key).collect(); + keys.sort_unstable(); + let deduped = { + let mut k = keys.clone(); + k.dedup(); + k.len() + }; + assert_eq!(deduped, keys.len(), "concurrent moves must not collide keys"); +} diff --git a/crates/aionui-db/src/repository/user_order.rs b/crates/aionui-db/src/repository/user_order.rs new file mode 100644 index 000000000..5ec35cff9 --- /dev/null +++ b/crates/aionui-db/src/repository/user_order.rs @@ -0,0 +1,119 @@ +use async_trait::async_trait; + +use crate::error::DbError; +use crate::models::{OrderItemType, OrderScene, UserOrderRow}; + +/// A `(item_type, item_id)` reference into the `user_order` table, used by pin +/// writes and cascade deletes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OrderItemRef { + pub item_type: OrderItemType, + pub item_id: String, +} + +impl OrderItemRef { + pub fn new(item_type: OrderItemType, item_id: impl Into) -> Self { + Self { + item_type, + item_id: item_id.into(), + } + } +} + +/// Keyset cursor for paginating a scene's rows, ordered by +/// `(order_key, item_type, item_id)`. All three components are non-null, so +/// there is no NULL branch to handle. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PinnedCursor { + pub order_key: i64, + pub item_type: OrderItemType, + pub item_id: String, +} + +/// Result of a [`IUserOrderStore::pin`] call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PinOutcome { + /// A new ordering row was inserted at the top of the scene. + Inserted, + /// The item was already pinned; the call was a no-op (order preserved). + AlreadyPinned, +} + +/// Result of a [`IUserOrderStore::move_item`] call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MoveOutcome { + /// `moved` was repositioned (its `order_key` was recomputed). + Moved, + /// `moved` has no row in the scene — a stale frontend window (→ 404). + MovedNotFound, + /// The `after` anchor has no row in the scene — a stale frontend window + /// (→ 400); the client should refetch the pinned group. + AfterNotFound, +} + +/// Access boundary for the `user_order` table. +/// +/// The store owns SQL and transactions; the `aionui-sidebar` service holds an +/// `Arc` and never opens transactions itself. Every method +/// takes the acting `user_id` and filters/writes the owner column, so a user +/// can neither see nor mutate another user's ordering. +/// +/// Pin state is row existence: [`pin`](Self::pin) inserts, [`unpin`](Self::unpin) +/// deletes. There is no boolean column. +#[async_trait] +pub trait IUserOrderStore: Send + Sync { + /// Pin `item` at the top of `scene`: insert a row with + /// `order_key = (scene min order_key) - 1000`, or `1000` when the scene is + /// empty. Idempotent — if the row already exists it is left unchanged and + /// [`PinOutcome::AlreadyPinned`] is returned. Uses `BEGIN IMMEDIATE` so the + /// read-min-then-insert is atomic under concurrent pins. + async fn pin(&self, user_id: &str, scene: OrderScene, item: &OrderItemRef) -> Result; + + /// Unpin `item` in `scene`: delete the row. Returns `true` if a row was + /// removed, `false` if it was not pinned (idempotent no-op). + async fn unpin(&self, user_id: &str, scene: OrderScene, item: &OrderItemRef) -> Result; + + /// Reposition `moved` within `scene` (drag-drop). `after = None` moves it to + /// the top; otherwise it lands directly after `after`. The `order_key` is + /// computed server-side (midpoint of the neighbours, whole-scene rebalance + /// when the gap is exhausted) — callers never pass a key (BR-26). The whole + /// read-neighbours → compute → write runs in one `BEGIN IMMEDIATE` write + /// transaction so concurrent moves cannot interleave (R1-S6). Returns + /// [`MoveOutcome::MovedNotFound`] / [`MoveOutcome::AfterNotFound`] when an + /// anchor is absent (stale window), leaving the table unchanged. + async fn move_item( + &self, + user_id: &str, + scene: OrderScene, + moved: &OrderItemRef, + after: Option<&OrderItemRef>, + ) -> Result; + + /// One keyset page of a scene's rows, ordered by + /// `(order_key, item_type, item_id)` ascending. `after = None` starts at the + /// top; otherwise rows strictly after the cursor are returned. At most + /// `limit` rows. + async fn list_pinned( + &self, + user_id: &str, + scene: OrderScene, + after: Option<&PinnedCursor>, + limit: i64, + ) -> Result, DbError>; + + /// Every pinned reference in `scene` for `user_id` (unpaged). Used to derive + /// the DTO `pinned` flag and the anti-join exclusion set; the pinned set is + /// bounded by user behavior, so an unpaged read is acceptable. + async fn pinned_refs(&self, user_id: &str, scene: OrderScene) -> Result, DbError>; + + /// Delete every `user_order` row for a single item across all scenes. + /// Cascade for "the item left the sidebar" (conversation/team deletion). + /// Idempotent. + async fn remove_item(&self, user_id: &str, item: &OrderItemRef) -> Result<(), DbError>; + + /// Delete every `user_order` row for a batch of items across all scenes, in + /// one transaction (atomic all-or-nothing). Cascade for project removal, + /// where the removal set (including path-merged items) is computed by the + /// service. Idempotent; empty input is a no-op. + async fn remove_items(&self, user_id: &str, items: &[OrderItemRef]) -> Result<(), DbError>; +} diff --git a/crates/aionui-project/src/service.rs b/crates/aionui-project/src/service.rs index 792fb3e4a..5347b64d3 100644 --- a/crates/aionui-project/src/service.rs +++ b/crates/aionui-project/src/service.rs @@ -231,6 +231,20 @@ impl ProjectService { Ok(()) } + /// Delete a project and all its explorer entries (owner-scoped, one store + /// transaction). Idempotent: removing an absent/foreign project succeeds + /// silently. Folders are global and are never removed here. + /// + /// This drops only the bind-chain rows; the conversations/teams that pointed + /// at the project are removed by the caller (sidebar `remove_project` + /// orchestration, BR-19). A best-effort `repositoriesChanged` notify lets the + /// source-control actor recompute (now-empty) roots for the gone project. + pub async fn delete_project(&self, user_id: &str, project_id: &str) -> Result<(), ProjectError> { + self.store.delete_project(user_id, project_id).await?; + self.notify_roots_changed(project_id, user_id); + Ok(()) + } + pub async fn reorder( &self, user_id: &str, diff --git a/crates/aionui-sidebar/Cargo.toml b/crates/aionui-sidebar/Cargo.toml new file mode 100644 index 000000000..76870e7f6 --- /dev/null +++ b/crates/aionui-sidebar/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "aionui-sidebar" +version.workspace = true +edition.workspace = true + +[dependencies] +aionui-api-types.workspace = true +aionui-auth.workspace = true +aionui-common.workspace = true +aionui-db.workspace = true +# Read-model reuse: row_to_response_with_extra (hydration) + is_temp_session_workspace +# (temp-path classification, same predicate the write side uses). +aionui-conversation.workspace = true +# Lexical path canonicalization for the read-side path-merge match (no fs). +aionui-project.workspace = true +axum.workspace = true +base64.workspace = true +# Repeated `win` query params + percent-decoded cursors: parse the raw query with +# form_urlencoded (serde_urlencoded / axum Query collapses duplicate keys). +url.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true +tokio.workspace = true +async-trait.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } +tempfile.workspace = true +tower.workspace = true +http-body-util.workspace = true +# Service tests seed a real in-memory DB directly (init_database_memory + the +# Sqlite*Store impls), so the raw SQL seed helpers need sqlx. +sqlx.workspace = true diff --git a/crates/aionui-sidebar/src/cascade.rs b/crates/aionui-sidebar/src/cascade.rs new file mode 100644 index 000000000..b20267e75 --- /dev/null +++ b/crates/aionui-sidebar/src/cascade.rs @@ -0,0 +1,51 @@ +//! Cascade that keeps `user_order` in sync when a conversation leaves the +//! sidebar's visible domain (design §4.3, path 1). +//! +//! When a conversation is deleted — directly, or as a team member during +//! `remove_team` (which routes member deletion through +//! `ConversationService::delete`) — its ordering rows must go too, or an orphan +//! pinned row would resurface a ghost entry on the next read. This runs as an +//! `OnConversationDelete` hook, i.e. after the owning delete has already been +//! authorized, so it is best-effort: a failure here degrades to an orphan row +//! that the read side already drops during hydration (page_pinned skips ids that +//! no longer hydrate), never a failed user-facing delete. + +use std::sync::Arc; + +use aionui_common::OnConversationDelete; +use aionui_db::{IUserOrderStore, OrderItemRef, OrderItemType}; +use async_trait::async_trait; + +/// `OnConversationDelete` hook that drops a deleted conversation's `user_order` +/// rows across all scenes. Registered on `ConversationService` via +/// `with_delete_hook` in `aionui-app`. +pub struct UserOrderDeleteHook { + user_order: Arc, +} + +impl UserOrderDeleteHook { + pub fn new(user_order: Arc) -> Self { + Self { user_order } + } +} + +#[async_trait] +impl OnConversationDelete for UserOrderDeleteHook { + async fn on_conversation_deleted(&self, user_id: &str, conversation_id: &str) { + let item = OrderItemRef::new(OrderItemType::Conversation, conversation_id); + if let Err(err) = self.user_order.remove_item(user_id, &item).await { + // Best-effort: the read side drops orphan rows during hydration, so a + // failure here self-heals rather than breaking the delete. + tracing::warn!( + user_id = %user_id, + conversation_id = %conversation_id, + error = %err, + "sidebar: failed to cascade-delete user_order rows for deleted conversation" + ); + } + } +} + +#[cfg(test)] +#[path = "cascade_test.rs"] +mod cascade_test; diff --git a/crates/aionui-sidebar/src/cascade_test.rs b/crates/aionui-sidebar/src/cascade_test.rs new file mode 100644 index 000000000..1327e101d --- /dev/null +++ b/crates/aionui-sidebar/src/cascade_test.rs @@ -0,0 +1,63 @@ +//! Path-1 cascade (design §4.3): deleting a conversation drops its `user_order` +//! rows. The orphan assertion is on the table row count, not API output. + +use std::sync::Arc; + +use aionui_common::OnConversationDelete; +use aionui_db::{ + IUserOrderStore, OrderItemRef, OrderItemType, OrderScene, SqlitePool, SqliteUserOrderStore, init_database_memory, +}; + +use super::UserOrderDeleteHook; + +const USER: &str = "user-1"; +const OTHER: &str = "user-2"; + +async fn seed_user(pool: &SqlitePool, id: &str) { + sqlx::query("INSERT INTO users (id, username, password_hash, created_at, updated_at) VALUES (?, ?, 'x', 0, 0)") + .bind(id) + .bind(id) + .execute(pool) + .await + .unwrap(); +} + +async fn count_rows(pool: &SqlitePool, user: &str, item_id: &str) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM user_order WHERE user_id = ? AND item_id = ?") + .bind(user) + .bind(item_id) + .fetch_one(pool) + .await + .unwrap() +} + +#[tokio::test] +async fn deleting_a_conversation_drops_its_pinned_row_and_is_user_scoped() { + let db = init_database_memory().await.unwrap(); + seed_user(db.pool(), USER).await; + seed_user(db.pool(), OTHER).await; + let store: Arc = Arc::new(SqliteUserOrderStore::new(db.pool().clone())); + + // Both users pin a conversation that happens to share the same id. + let item = OrderItemRef::new(OrderItemType::Conversation, "c1"); + store.pin(USER, OrderScene::Pinned, &item).await.unwrap(); + store.pin(OTHER, OrderScene::Pinned, &item).await.unwrap(); + assert_eq!(count_rows(db.pool(), USER, "c1").await, 1); + assert_eq!(count_rows(db.pool(), OTHER, "c1").await, 1); + + let hook = UserOrderDeleteHook::new(store.clone()); + hook.on_conversation_deleted(USER, "c1").await; + + // USER's row is gone; OTHER's identically-keyed row is untouched (BR-24). + assert_eq!( + count_rows(db.pool(), USER, "c1").await, + 0, + "path-1 cascade removed the pinned row" + ); + assert_eq!(count_rows(db.pool(), OTHER, "c1").await, 1, "cascade is user-scoped"); + + // Idempotent: deleting again (or a never-pinned id) is a silent no-op. + hook.on_conversation_deleted(USER, "c1").await; + hook.on_conversation_deleted(USER, "never-pinned").await; + assert_eq!(count_rows(db.pool(), USER, "c1").await, 0); +} diff --git a/crates/aionui-sidebar/src/lib.rs b/crates/aionui-sidebar/src/lib.rs new file mode 100644 index 000000000..6a475e1ae --- /dev/null +++ b/crates/aionui-sidebar/src/lib.rs @@ -0,0 +1,20 @@ +//! Sidebar read model: one request renders the whole left panel. +//! +//! The backend owns classification (pinned / project / pseudo-dir / chats), +//! windowing, ordering, and pin truth (a `user_order` row's existence). The +//! frontend renders in the given order and runs no classification. See +//! `feat-project-design/temp/left-panel/` (`design.md`, `api-contract-sidebar.md`, +//! `boundary-rules.md`). + +mod cascade; +mod ports; +mod service; +mod types; + +pub mod routes; + +pub use cascade::UserOrderDeleteHook; +pub use ports::RemoveProjectPorts; +pub use routes::{SidebarRouterState, sidebar_routes}; +pub use service::SidebarService; +pub use types::SidebarError; diff --git a/crates/aionui-sidebar/src/ports.rs b/crates/aionui-sidebar/src/ports.rs new file mode 100644 index 000000000..96a5acba0 --- /dev/null +++ b/crates/aionui-sidebar/src/ports.rs @@ -0,0 +1,33 @@ +//! Deletion ports for `remove_project` (BR-19 / D13 "所见即所删"). +//! +//! Removing a project deletes everything classified into its group: independent +//! conversations, whole teams, and the project record itself. Each of those is a +//! heavy, cross-crate orchestration that already lives in a domain service — +//! killing agent processes and cascading member conversations (team delete), +//! running the conversation delete hook (conversation delete), dropping the +//! bind-chain rows (project delete). The sidebar crate must not re-implement or +//! depend on those services directly, so this trait is the seam: `aionui-app` +//! injects an adapter over the concrete conversation / team / project services. +//! +//! Errors are opaque strings on purpose. The orchestration is best-effort per +//! entity (see `SidebarService::remove_project`): the sidebar only needs to know +//! "this one failed" for a warn log, not the error taxonomy of three foreign +//! crates. + +use async_trait::async_trait; + +/// The three deletion primitives `remove_project` drives, one per unit kind. +#[async_trait] +pub trait RemoveProjectPorts: Send + Sync { + /// Delete one independent (non-team-member) conversation. Its `user_order` + /// row is cascaded by the conversation delete hook. + async fn delete_conversation(&self, user_id: &str, conversation_id: &str) -> Result<(), String>; + + /// Remove a whole team: kill its agents, cascade its member conversations, + /// drop the team row and its own `user_order` row (the standalone + /// team-delete path, reused verbatim). + async fn remove_team(&self, user_id: &str, team_id: &str) -> Result<(), String>; + + /// Delete the project record and its explorer entries (owner-scoped). + async fn delete_project_record(&self, user_id: &str, project_id: &str) -> Result<(), String>; +} diff --git a/crates/aionui-sidebar/src/routes.rs b/crates/aionui-sidebar/src/routes.rs new file mode 100644 index 000000000..ff36c3836 --- /dev/null +++ b/crates/aionui-sidebar/src/routes.rs @@ -0,0 +1,240 @@ +// `ApiError` is the intended error type at this HTTP boundary (routes map the +// crate-owned `SidebarError` to it here), so the disallowed_types lint that +// steers service code away from `ApiError` does not apply to this module. +#![allow(clippy::disallowed_types)] + +//! Sidebar read + ordering HTTP routes. +//! +//! - `GET /api/sidebar` — first screen (pinned → project area → chats), with a +//! default `limit` and repeated `win=:` per-scope overrides. +//! - `GET /api/sidebar/items` — one more window of a single group (`scope` + +//! keyset `cursor`). +//! - `PUT`/`DELETE /api/order/{scene}/{item_type}/{item_id}` — pin / unpin. +//! - `POST /api/order/{scene}/move` — reposition a pinned item (drag-drop). +//! +//! Handlers only parse the request and shape the response; all classification +//! and ordering lives in [`SidebarService`]. [`SidebarError`] maps to `ApiError` +//! with stable machine codes (`sidebar_bad_request`, `sidebar_scope_gone`, +//! `sidebar_internal`); DB/project detail never leaks to the client. + +use std::sync::Arc; + +use aionui_api_types::{ApiResponse, MoveOrderRequest, RemoveProjectResult, SidebarItemsResponse, SidebarResponse}; +use aionui_auth::CurrentUser; +use aionui_common::ApiError; +use axum::extract::{Path, RawQuery, State}; +use axum::http::StatusCode; +use axum::routing::{delete, get, post, put}; +use axum::{Extension, Json, Router}; + +use crate::service::SidebarService; +use crate::types::{SidebarError, parse_win, validate_limit}; + +/// Shared state for sidebar route handlers. +#[derive(Clone)] +pub struct SidebarRouterState { + pub service: Arc, +} + +/// Build the sidebar router. All routes require authentication (applied by the +/// caller, matching the project/team route wiring in `aionui-app`). +pub fn sidebar_routes(state: SidebarRouterState) -> Router { + Router::new() + .route("/api/sidebar", get(get_sidebar)) + .route("/api/sidebar/items", get(get_items)) + .route("/api/order/{scene}/move", post(move_order)) + .route( + "/api/order/{scene}/{item_type}/{item_id}", + put(put_order).delete(delete_order), + ) + .route("/api/sidebar/project/{project_id}", delete(delete_project)) + .with_state(state) +} + +/// `GET /api/sidebar?limit=5&win=project:P1:10&win=chats:20` — first screen. +async fn get_sidebar( + State(state): State, + Extension(user): Extension, + RawQuery(query): RawQuery, +) -> Result>, ApiError> { + let params = QueryParams::parse(query.as_deref()); + let limit = params.limit().map_err(to_api_error)?; + if let Some(limit) = limit { + validate_limit(limit).map_err(to_api_error)?; + } + let win = parse_win(¶ms.multi("win")).map_err(to_api_error)?; + + let response = state + .service + .first_screen(&user.id, limit, &win) + .await + .map_err(to_api_error)?; + Ok(Json(ApiResponse::ok(response))) +} + +/// `GET /api/sidebar/items?scope=&cursor=&limit=10` — page one group. +async fn get_items( + State(state): State, + Extension(user): Extension, + RawQuery(query): RawQuery, +) -> Result>, ApiError> { + let params = QueryParams::parse(query.as_deref()); + let scope = params + .single("scope") + .ok_or_else(|| to_api_error(SidebarError::BadRequest("missing scope".into())))?; + let cursor = params.single("cursor"); + let limit = params.limit().map_err(to_api_error)?; + + let response = state + .service + .items(&user.id, &scope, cursor.as_deref(), limit) + .await + .map_err(to_api_error)?; + Ok(Json(ApiResponse::ok(response))) +} + +/// `PUT /api/order/{scene}/{item_type}/{item_id}` — pin (idempotent). +async fn put_order( + State(state): State, + Extension(user): Extension, + Path((scene, item_type, item_id)): Path<(String, String, String)>, +) -> Result>, ApiError> { + state + .service + .pin(&user.id, &scene, &item_type, &item_id) + .await + .map_err(to_api_error)?; + Ok(Json(ApiResponse::ok(()))) +} + +/// `DELETE /api/order/{scene}/{item_type}/{item_id}` — unpin (idempotent). +async fn delete_order( + State(state): State, + Extension(user): Extension, + Path((scene, item_type, item_id)): Path<(String, String, String)>, +) -> Result>, ApiError> { + state + .service + .unpin(&user.id, &scene, &item_type, &item_id) + .await + .map_err(to_api_error)?; + Ok(Json(ApiResponse::ok(()))) +} + +/// `POST /api/order/{scene}/move` — reposition a pinned item by drag-drop. +/// +/// Body is [`MoveOrderRequest`] (`moved` + optional `after` anchor; `after` = +/// `null` moves to the top). The 4-segment path does not collide with the +/// 5-segment pin/unpin route. Stale-window anchors map to 404 (`moved` gone) / +/// 400 (`after` gone) so the frontend refetches the pinned group. +async fn move_order( + State(state): State, + Extension(user): Extension, + Path(scene): Path, + Json(body): Json, +) -> Result>, ApiError> { + state + .service + .move_order(&user.id, &scene, &body) + .await + .map_err(to_api_error)?; + Ok(Json(ApiResponse::ok(()))) +} + +/// `DELETE /api/sidebar/project/{project_id}?dry_run=true` — remove a project +/// and everything classified into its group (BR-19 "所见即所删"). +/// +/// With `dry_run=true` nothing is deleted; the response reports the counts that +/// *would* be removed so the frontend can render a confirmation. A missing or +/// non-standard `project_id` maps to `ScopeGone` → 404. +async fn delete_project( + State(state): State, + Extension(user): Extension, + Path(project_id): Path, + RawQuery(query): RawQuery, +) -> Result>, ApiError> { + let params = QueryParams::parse(query.as_deref()); + let dry_run = params.flag("dry_run"); + + let result = state + .service + .remove_project(&user.id, &project_id, dry_run) + .await + .map_err(to_api_error)?; + Ok(Json(ApiResponse::ok(result))) +} + +/// Parsed query string: percent-decoded `(key, value)` pairs. `axum::Query` +/// (serde_urlencoded) collapses duplicate keys, so `win` — which repeats — is +/// parsed from the raw query instead. +struct QueryParams { + pairs: Vec<(String, String)>, +} + +impl QueryParams { + fn parse(query: Option<&str>) -> Self { + let pairs = query + .map(|q| { + url::form_urlencoded::parse(q.as_bytes()) + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect() + }) + .unwrap_or_default(); + Self { pairs } + } + + fn single(&self, key: &str) -> Option { + self.pairs.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()) + } + + fn multi(&self, key: &str) -> Vec { + self.pairs + .iter() + .filter(|(k, _)| k == key) + .map(|(_, v)| v.clone()) + .collect() + } + + /// A boolean query flag: present with no value / `true` / `1` → true; + /// absent or any other value → false. + fn flag(&self, key: &str) -> bool { + match self.single(key) { + None => false, + Some(v) => v.is_empty() || v == "true" || v == "1", + } + } + + /// `limit` as an `i64`; a present-but-non-numeric value is a 400. + fn limit(&self) -> Result, SidebarError> { + match self.single("limit") { + None => Ok(None), + Some(raw) => raw + .parse::() + .map(Some) + .map_err(|_| SidebarError::BadRequest(format!("limit is not a number: {raw}"))), + } + } +} + +/// Map the crate error to an `ApiError` with a stable code. Internal causes +/// (DB / project / unexpected) are logged and flattened so no detail leaks. +fn to_api_error(err: SidebarError) -> ApiError { + match err { + SidebarError::BadRequest(msg) => ApiError::coded(StatusCode::BAD_REQUEST, "sidebar_bad_request", msg, None), + SidebarError::ScopeGone => ApiError::coded( + StatusCode::NOT_FOUND, + "sidebar_scope_gone", + "scope no longer exists", + None, + ), + SidebarError::Db(_) | SidebarError::Project(_) | SidebarError::Internal(_) => { + tracing::error!(error = %err, "sidebar: internal error"); + ApiError::coded( + StatusCode::INTERNAL_SERVER_ERROR, + "sidebar_internal", + "internal error", + None, + ) + } + } +} diff --git a/crates/aionui-sidebar/src/service.rs b/crates/aionui-sidebar/src/service.rs new file mode 100644 index 000000000..00671f0ca --- /dev/null +++ b/crates/aionui-sidebar/src/service.rs @@ -0,0 +1,1110 @@ +//! Sidebar classification and assembly engine. +//! +//! One request renders the whole left panel. The service loads a thin read +//! snapshot (conversations / teams / projects / pinned refs), classifies every +//! non-pinned unit into its group (5-case matrix, `api-contract-sidebar.md` §2), +//! windows each group, and hydrates only the windowed conversations in one batch +//! (BR-16, no N+1). It opens no transactions — the stores own SQL/txns — and has +//! zero side effects (BR-17/27): path merge and pseudo-dir grouping are +//! display-only, computed by lexical canonicalization with no filesystem access. +//! +//! Pin truth is a `user_order` row's existence: a unit is excluded from its +//! natural group when it appears in the pinned set (anti-join, BR-7), and shows +//! up only in the pinned group; the DTO `pinned` flag is overridden accordingly +//! (never read from the deprecated `conversations.pinned` column, BR — B1). + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +use aionui_api_types::{ + ConversationResponse, MoveOrderRequest, OrderItemRefDto, RemoveProjectItem, RemoveProjectItemKind, + RemoveProjectResult, SidebarGroup, SidebarItem, SidebarItemsResponse, SidebarResponse, SidebarScope, + SidebarTeamItem, +}; +use aionui_conversation::{is_temp_session_workspace, row_to_response_with_extra}; +use aionui_db::models::ConversationRow; +use aionui_db::{ + ISidebarStore, IUserOrderStore, MoveOutcome, OrderItemRef, OrderItemType, OrderScene, PinnedCursor, + SidebarConversationThin, SidebarProjectMeta, SidebarTeamThin, +}; +use aionui_project::canonical; + +use crate::ports::RemoveProjectPorts; +use crate::types::{ + Cursor, DEFAULT_ITEMS_LIMIT, DEFAULT_LIMIT, ScopeToken, SidebarError, canonical_to_dir_key, validate_limit, +}; + +/// Project-area hard cap: the interleaved (project + pseudo-dir) group list is +/// truncated to this many groups, ordered by activity (BR-5). +const MAX_PROJECT_GROUPS: usize = 100; + +/// Standard-project kind marker (`projects.kind`). +const KIND_STANDARD: &str = "standard"; + +/// The sidebar read/assembly service. +pub struct SidebarService { + sidebar: Arc, + user_order: Arc, + /// Backend-managed data dir (conversation workspace root). Used both to + /// detect temp workspaces (classification) and as the hydration `data_dir`. + work_dir: PathBuf, + /// Deletion ports for `remove_project`, injected once after startup (the + /// team service they wrap is built after this service — see + /// `build_module_states`). Empty in the read/pin paths, which never touch it. + remove_project_ports: Arc>>, +} + +impl SidebarService { + pub fn new(sidebar: Arc, user_order: Arc, work_dir: PathBuf) -> Self { + Self { + sidebar, + user_order, + work_dir, + remove_project_ports: Arc::new(OnceLock::new()), + } + } + + /// Install the deletion ports `remove_project` drives. Called once, after the + /// conversation / team / project services exist; later calls are ignored + /// (set-once). Because the field is shared behind `Arc`, setting it on any + /// clone makes it visible everywhere. + pub fn set_remove_project_ports(&self, ports: Arc) { + let _ = self.remove_project_ports.set(ports); + } + + // -- Write side (pin / unpin) -------------------------------------------- + + /// Pin an item at the top of a scene. Idempotent. `scene` / `item_type` are + /// closed enums — an unknown value is a 400, never a silent no-op. + pub async fn pin(&self, user_id: &str, scene: &str, item_type: &str, item_id: &str) -> Result<(), SidebarError> { + let scene = parse_scene(scene)?; + let item = OrderItemRef::new(parse_item_type(item_type)?, item_id.to_owned()); + self.user_order.pin(user_id, scene, &item).await?; + Ok(()) + } + + /// Unpin an item. Idempotent (unpinning an unpinned item is a no-op). + pub async fn unpin(&self, user_id: &str, scene: &str, item_type: &str, item_id: &str) -> Result<(), SidebarError> { + let scene = parse_scene(scene)?; + let item = OrderItemRef::new(parse_item_type(item_type)?, item_id.to_owned()); + self.user_order.unpin(user_id, scene, &item).await?; + Ok(()) + } + + /// Reposition a pinned item by drag-drop (`POST /api/order/{scene}/move`). + /// `after = None` moves it to the top; otherwise it lands right after + /// `after`. The store computes the key server-side (BR-26). + /// + /// Bad-path mapping (all 400/404, never a silent no-op): + /// - unknown `scene` / `item_type` → 400 (parse). + /// - `moved == after` (self-anchor) → 400 (would be a no-op with an + /// ambiguous target; reject so the frontend refetches rather than trust a + /// stale drag). + /// - `moved` not pinned → 404 (stale window; the row it dragged is gone). + /// - `after` not pinned → 400 (stale window; anchor gone — client refetches). + pub async fn move_order(&self, user_id: &str, scene: &str, req: &MoveOrderRequest) -> Result<(), SidebarError> { + let scene = parse_scene(scene)?; + let moved = parse_item_ref(&req.moved)?; + let after = req.after.as_ref().map(parse_item_ref).transpose()?; + + if after.as_ref() == Some(&moved) { + return Err(SidebarError::BadRequest( + "moved and after refer to the same item".into(), + )); + } + + match self + .user_order + .move_item(user_id, scene, &moved, after.as_ref()) + .await? + { + MoveOutcome::Moved => Ok(()), + MoveOutcome::MovedNotFound => Err(SidebarError::ScopeGone), + MoveOutcome::AfterNotFound => Err(SidebarError::BadRequest("after anchor is not pinned".into())), + } + } + + // -- Remove project (BR-19 / D13 "所见即所删") --------------------------- + + /// Remove a standard project: delete every unit that renders into its group, + /// then the project record itself. When `dry_run` is set, nothing is deleted + /// and the returned counts are the preview (what *would* be removed). + /// + /// # Delete set + /// The set is computed with the **same** classifier the renderer uses + /// ([`classify_unit`](Self::classify_unit)) over the active universe: a unit + /// is deleted iff it classifies into `Project(project_id)`. This is why + /// "所见即所删" holds — the delete set *is* the render construct, so it also + /// catches path-merged unbound items (a conversation whose workspace + /// canonicalizes onto this project's root, case 3) that carry no `project_id`. + /// Pinned units are included: pinning only hoists a row into the pinned group + /// for display, it does not change which project the row belongs to. + /// + /// Team-member conversations are not enumerated here — they are folded into + /// their team and removed by the team cascade, "无论成员自身 project_id 为何"; + /// a member whose *team* lands in another project is therefore not touched. + /// + /// # Atomicity + /// Deletion is **best-effort per entity**, mirroring the standalone team + /// delete: killing agent processes, dropping filesystem dirs, and running + /// cross-service delete hooks cannot share one DB transaction, so D13's + /// "单事务原子 + 中途失败全回滚" is not literally achievable. A failed unit is + /// logged and skipped; the project record is dropped last, so a mid-way + /// failure leaves a smaller (self-consistent) project rather than orphaning + /// its contents. Only the archived-inclusion refinement is deferred to PR-B; + /// PR-A has no archiving, so the active universe is the whole universe. + pub async fn remove_project( + &self, + user_id: &str, + project_id: &str, + dry_run: bool, + ) -> Result { + let convs = self.sidebar.list_active_conversations_thin(user_id).await?; + let teams = self.sidebar.list_active_teams_thin(user_id).await?; + let projects = self.sidebar.list_user_projects(user_id).await?; + + // Same project maps `classify` builds: id→meta and standard-canonical→id. + let by_id: HashMap<&str, &SidebarProjectMeta> = projects.iter().map(|p| (p.project_id.as_str(), p)).collect(); + let std_canon: HashMap<&str, &str> = projects + .iter() + .filter(|p| p.kind == KIND_STANDARD) + .filter_map(|p| p.workspace_canonical.as_deref().map(|c| (c, p.project_id.as_str()))) + .collect(); + + // The target must be an owned standard project. Temp projects and + // pseudo-dir groups are not removable through this path → 404. + match by_id.get(project_id) { + Some(meta) if meta.kind == KIND_STANDARD => {} + _ => return Err(SidebarError::ScopeGone), + } + + let (team_by_id, independents) = aggregate_teams(convs, teams.clone()); + let target = GroupKey::Project(project_id.to_owned()); + + let mut team_ids: Vec = Vec::new(); + for team in &teams { + // Skip teams that dropped out of the aggregate (none currently, but + // keeps parity with `classify`'s guard). + if !team_by_id.contains_key(&team.id) { + continue; + } + let key = self.classify_unit( + team.project_id.as_deref(), + team.workspace.as_deref(), + &by_id, + &std_canon, + ); + if key == target { + team_ids.push(team.id.clone()); + } + } + + let mut conv_ids: Vec = Vec::new(); + for conv in &independents { + let key = self.classify_unit( + conv.project_id.as_deref(), + conv.workspace.as_deref(), + &by_id, + &std_canon, + ); + if key == target { + conv_ids.push(conv.id.clone()); + } + } + + if dry_run { + // Name the delete set so the confirm dialog can list *which* items go. + // Pinned members were hoisted into the top pinned group (B1 anti-join), + // so the frontend can't reconstruct project membership — the names and + // pinned flags must come from here. + let pinned_refs = self.user_order.pinned_refs(user_id, OrderScene::Pinned).await?; + let pinned_set: HashSet<(String, String)> = pinned_refs + .iter() + .map(|r| (r.item_type.as_str().to_owned(), r.item_id.clone())) + .collect(); + + let team_name: HashMap<&str, &str> = teams.iter().map(|t| (t.id.as_str(), t.name.as_str())).collect(); + let conv_resp = self.hydrate(user_id, &conv_ids).await?; + + let mut items: Vec = Vec::with_capacity(team_ids.len() + conv_ids.len()); + for team_id in &team_ids { + items.push(RemoveProjectItem { + name: team_name.get(team_id.as_str()).copied().unwrap_or_default().to_owned(), + pinned: pinned_set.contains(&(OrderItemType::Team.as_str().to_owned(), team_id.clone())), + kind: RemoveProjectItemKind::Team, + }); + } + for conv_id in &conv_ids { + items.push(RemoveProjectItem { + name: conv_resp.get(conv_id).map(|c| c.name.clone()).unwrap_or_default(), + pinned: pinned_set.contains(&(OrderItemType::Conversation.as_str().to_owned(), conv_id.clone())), + kind: RemoveProjectItemKind::Conversation, + }); + } + + return Ok(RemoveProjectResult { + teams_deleted: team_ids.len() as i64, + conversations_deleted: conv_ids.len() as i64, + items, + }); + } + + let ports = self + .remove_project_ports + .get() + .ok_or_else(|| SidebarError::Internal("remove_project ports not wired".into()))?; + + let mut teams_deleted = 0i64; + for team_id in &team_ids { + match ports.remove_team(user_id, team_id).await { + Ok(()) => teams_deleted += 1, + Err(err) => { + tracing::warn!(team_id = %team_id, error = %err, "remove_project: team delete failed") + } + } + } + + let mut conversations_deleted = 0i64; + for conv_id in &conv_ids { + match ports.delete_conversation(user_id, conv_id).await { + Ok(()) => conversations_deleted += 1, + Err(err) => { + tracing::warn!(conversation_id = %conv_id, error = %err, "remove_project: conversation delete failed") + } + } + } + + // Project record last: its contents are already gone, so a failure here + // leaves only an empty shell (which a retry finishes off). + if let Err(err) = ports.delete_project_record(user_id, project_id).await { + tracing::warn!(project_id = %project_id, error = %err, "remove_project: project record delete failed"); + } + + tracing::info!( + project_id = %project_id, + teams_deleted, + conversations_deleted, + "Project removed (best-effort)" + ); + + Ok(RemoveProjectResult { + teams_deleted, + conversations_deleted, + items: Vec::new(), + }) + } + + // -- Read side (first screen / paging) ----------------------------------- + + /// First screen: `pinned → project-area (project + dir interleaved) → chats`. + /// `win` is the already-parsed per-scope window overrides; `limit` is the + /// default window for any scope not named in `win`. + pub async fn first_screen( + &self, + user_id: &str, + limit: Option, + win: &[(String, i64)], + ) -> Result { + let default_limit = limit.unwrap_or(DEFAULT_LIMIT); + let win_map: HashMap<&str, i64> = win.iter().map(|(t, l)| (t.as_str(), *l)).collect(); + let snapshot = self.classify(user_id).await?; + + let mut groups: Vec = Vec::new(); + + // Pinned group (only rendered when non-empty). + let pinned_limit = win_map.get("pinned").copied().unwrap_or(default_limit); + let pinned = self + .page_pinned(user_id, None, pinned_limit, &snapshot.team_by_id) + .await?; + if !pinned.items.is_empty() { + groups.push(SidebarGroup { + scope: SidebarScope::Pinned, + items: pinned.items, + has_more: pinned.has_more, + next_cursor: pinned.next_cursor, + }); + } + + // Natural groups: project area (already ordered) then chats. Collect the + // windowed conversation ids across all of them for a single hydration. + let mut natural: Vec<&NaturalGroup> = snapshot.project_area.iter().collect(); + natural.push(&snapshot.chats); + + let mut conv_ids: Vec = Vec::new(); + let mut windows: Vec<(&NaturalGroup, usize, bool)> = Vec::with_capacity(natural.len()); + for group in natural { + let lim = win_map.get(group.token_str.as_str()).copied().unwrap_or(default_limit) as usize; + let has_more = group.items.len() > lim; + let take = lim.min(group.items.len()); + for item in &group.items[..take] { + if let GroupItemRef::Conv { id, .. } = item { + conv_ids.push(id.clone()); + } + } + windows.push((group, take, has_more)); + } + + let hydrated = self.hydrate(user_id, &conv_ids).await?; + + for (group, take, has_more) in windows { + let items = assemble_items(&group.items[..take], &hydrated, &snapshot.team_by_id, false); + let next_cursor = has_more + .then(|| { + group + .items + .get(take - 1) + .map(|i| i.activity_cursor().encode(&group.token)) + }) + .flatten(); + groups.push(SidebarGroup { + scope: group.scope.clone(), + items, + has_more, + next_cursor, + }); + } + + Ok(SidebarResponse { + groups, + has_more_groups: snapshot.has_more_groups, + }) + } + + /// Page one more window of a single group. + pub async fn items( + &self, + user_id: &str, + scope: &str, + cursor: Option<&str>, + limit: Option, + ) -> Result { + let token = + ScopeToken::parse(scope).ok_or_else(|| SidebarError::BadRequest(format!("unknown scope: {scope}")))?; + let limit = limit.unwrap_or(DEFAULT_ITEMS_LIMIT); + validate_limit(limit)?; + + if let ScopeToken::Pinned = token { + let after = match cursor { + Some(raw) => Some(to_pinned_cursor(Cursor::decode(raw, &token)?)?), + None => None, + }; + let team_by_id = self.load_team_aggregates(user_id).await?; + let page = self.page_pinned(user_id, after.as_ref(), limit, &team_by_id).await?; + return Ok(SidebarItemsResponse { + items: page.items, + has_more: page.has_more, + next_cursor: page.next_cursor, + }); + } + + // Natural scope: classify, find the named group (stale → 404), page it. + let snapshot = self.classify(user_id).await?; + let group = match &token { + ScopeToken::Chats => &snapshot.chats, + _ => snapshot + .project_area + .iter() + .find(|g| g.token == token) + .ok_or(SidebarError::ScopeGone)?, + }; + let cursor = match cursor { + Some(raw) => Some(Cursor::decode(raw, &token)?), + None => None, + }; + self.page_natural(user_id, group, cursor.as_ref(), limit).await + } + + /// Window a natural group after `cursor`, hydrate, and assemble. + async fn page_natural( + &self, + user_id: &str, + group: &NaturalGroup, + cursor: Option<&Cursor>, + limit: i64, + ) -> Result { + // Items are sorted later-first, so the "after cursor" set is a suffix. + let after: Vec<&GroupItemRef> = group.items.iter().filter(|i| i.is_after(cursor)).collect(); + let take = (limit as usize).min(after.len()); + let has_more = after.len() > limit as usize; + + let conv_ids: Vec = after[..take] + .iter() + .filter_map(|i| match i { + GroupItemRef::Conv { id, .. } => Some(id.clone()), + GroupItemRef::Team { .. } => None, + }) + .collect(); + let hydrated = self.hydrate(user_id, &conv_ids).await?; + + let window: Vec = after[..take].iter().map(|i| (*i).clone()).collect(); + let items = assemble_items(&window, &hydrated, &group.team_by_id_ref(), false); + let next_cursor = has_more + .then(|| window.last().map(|i| i.activity_cursor().encode(&group.token))) + .flatten(); + Ok(SidebarItemsResponse { + items, + has_more, + next_cursor, + }) + } + + /// Window the pinned scene after `cursor` (order_key ascending) and assemble. + async fn page_pinned( + &self, + user_id: &str, + after: Option<&PinnedCursor>, + limit: i64, + team_by_id: &HashMap, + ) -> Result { + let rows = self + .user_order + .list_pinned(user_id, OrderScene::Pinned, after, limit + 1) + .await?; + let has_more = rows.len() as i64 > limit; + let window = &rows[..(limit as usize).min(rows.len())]; + + // Path-4 read-side defense (design §4.3): a live team member never has an + // independent row — it is folded into its team. The only teamId writer + // (`build_team_extra`) creates fresh conversations, so a member cannot + // carry a pre-existing pinned row through normal flows; this guard closes + // the residual dirty-data / future-write hole and self-heals, so no + // write-side chokepoint is needed. + let member_ids: HashSet<&str> = team_by_id + .values() + .flat_map(|t| t.member_ids.iter()) + .map(String::as_str) + .collect(); + + let conv_ids: Vec = window + .iter() + .filter(|r| r.item_type == OrderItemType::Conversation.as_str() && !member_ids.contains(r.item_id.as_str())) + .map(|r| r.item_id.clone()) + .collect(); + let hydrated = self.hydrate(user_id, &conv_ids).await?; + + let mut items: Vec = Vec::with_capacity(window.len()); + for row in window { + if row.item_type == OrderItemType::Conversation.as_str() { + if let Some(resp) = hydrated.get(&row.item_id) { + items.push(conversation_item(resp.clone(), true)); + } + } else if let Some(agg) = team_by_id.get(&row.item_id) { + items.push(team_item(agg, true)); + } + } + + let next_cursor = if has_more { + window.last().and_then(|row| { + OrderItemType::parse(&row.item_type).map(|t| { + Cursor::Pinned { + order_key: row.order_key, + item_type: t.as_str().to_owned(), + item_id: row.item_id.clone(), + } + .encode(&ScopeToken::Pinned) + }) + }) + } else { + None + }; + + Ok(PinnedPage { + items, + has_more, + next_cursor, + }) + } + + /// One batched hydration of windowed conversations into response DTOs. A row + /// with unparsable `extra` is skipped (warn) rather than failing the request. + async fn hydrate( + &self, + user_id: &str, + ids: &[String], + ) -> Result, SidebarError> { + if ids.is_empty() { + return Ok(HashMap::new()); + } + let rows = self.sidebar.hydrate_conversations(user_id, ids).await?; + let mut out = HashMap::with_capacity(rows.len()); + for row in rows { + let id = row.id.clone(); + match parse_row(row, &self.work_dir) { + Ok(resp) => { + out.insert(id, resp); + } + Err(err) => { + tracing::warn!(conversation_id = %id, error = %err, "sidebar: skipping unhydratable conversation") + } + } + } + Ok(out) + } + + // -- Classification ------------------------------------------------------ + + /// Build team aggregates only (used by the pinned items path, which needs no + /// group classification). Members are folded into their team; orphan members + /// downgrade to independents (BR-8) but independents are discarded here. + async fn load_team_aggregates(&self, user_id: &str) -> Result, SidebarError> { + let convs = self.sidebar.list_active_conversations_thin(user_id).await?; + let teams = self.sidebar.list_active_teams_thin(user_id).await?; + Ok(aggregate_teams(convs, teams).0) + } + + /// Full classification snapshot: team aggregates, independents + teams sorted + /// into groups (5-case matrix), the ordered/truncated project area, and the + /// chats group. + async fn classify(&self, user_id: &str) -> Result { + let convs = self.sidebar.list_active_conversations_thin(user_id).await?; + let teams = self.sidebar.list_active_teams_thin(user_id).await?; + let projects = self.sidebar.list_user_projects(user_id).await?; + let pinned_refs = self.user_order.pinned_refs(user_id, OrderScene::Pinned).await?; + let pinned_set: HashSet<(String, String)> = pinned_refs + .iter() + .map(|r| (r.item_type.as_str().to_owned(), r.item_id.clone())) + .collect(); + + // Project maps: id→meta and standard-canonical→id (path merge, BR case 3). + let by_id: HashMap<&str, &SidebarProjectMeta> = projects.iter().map(|p| (p.project_id.as_str(), p)).collect(); + let std_canon: HashMap<&str, &str> = projects + .iter() + .filter(|p| p.kind == KIND_STANDARD) + .filter_map(|p| p.workspace_canonical.as_deref().map(|c| (c, p.project_id.as_str()))) + .collect(); + + let (team_by_id, independents) = aggregate_teams(convs, teams.clone()); + + let mut builder = GroupBuilder::new(); + + // Teams as units. + for team in &teams { + let agg = match team_by_id.get(&team.id) { + Some(a) => a, + None => continue, + }; + let key = self.classify_unit( + team.project_id.as_deref(), + team.workspace.as_deref(), + &by_id, + &std_canon, + ); + let pinned = pinned_set.contains(&(OrderItemType::Team.as_str().to_owned(), team.id.clone())); + builder.push( + key, + &by_id, + agg.updated_at, + pinned, + GroupItemRef::Team { + team_id: team.id.clone(), + updated_at: agg.updated_at, + }, + ); + } + + // Independent conversations as units. + for conv in &independents { + let key = self.classify_unit( + conv.project_id.as_deref(), + conv.workspace.as_deref(), + &by_id, + &std_canon, + ); + let pinned = pinned_set.contains(&(OrderItemType::Conversation.as_str().to_owned(), conv.id.clone())); + builder.push( + key, + &by_id, + conv.updated_at, + pinned, + GroupItemRef::Conv { + id: conv.id.clone(), + updated_at: conv.updated_at, + }, + ); + } + + // Spine: every standard project surfaces even with zero units (BR-5). + for project in projects.iter().filter(|p| p.kind == KIND_STANDARD) { + builder.ensure_standard_spine(project); + } + + let (project_area, chats, has_more_groups) = builder.finish(team_by_id.clone()); + Ok(Snapshot { + project_area, + chats, + has_more_groups, + team_by_id, + }) + } + + /// Classify one unit (`project_id`, `workspace`) into its group key. Pure and + /// filesystem-free: dangling project ids fall through to the path branch, and + /// any path we cannot lexically canonicalize degrades to chats. + fn classify_unit( + &self, + project_id: Option<&str>, + workspace: Option<&str>, + by_id: &HashMap<&str, &SidebarProjectMeta>, + std_canon: &HashMap<&str, &str>, + ) -> GroupKey { + if let Some(pid) = project_id + && let Some(meta) = by_id.get(pid) + { + return if meta.kind == KIND_STANDARD { + GroupKey::Project(pid.to_owned()) + } else { + GroupKey::Chats + }; + } + // No project_id, or a dangling id (foreign / deleted project): fall + // through to path-based classification. + let ws = match workspace { + Some(ws) if !ws.is_empty() => ws, + _ => return GroupKey::Chats, + }; + let ws_path = Path::new(ws); + if is_temp_session_workspace(ws_path) { + return GroupKey::Chats; + } + let uri = match canonical::to_file_uri(ws_path) { + Ok(u) => u, + Err(_) => return GroupKey::Chats, + }; + let canon = match canonical::canonicalize(&uri) { + Ok(c) => c, + Err(_) => return GroupKey::Chats, + }; + match std_canon.get(canon.as_str()) { + Some(pid) => GroupKey::Project((*pid).to_owned()), + None => GroupKey::Dir(canon.as_str().to_owned()), + } + } +} + +// -- Snapshot / group model -------------------------------------------------- + +struct Snapshot { + project_area: Vec, + chats: NaturalGroup, + has_more_groups: bool, + team_by_id: HashMap, +} + +/// A team collapsed into one sidebar row. +#[derive(Clone)] +struct TeamAgg { + team_id: String, + name: String, + /// `MAX(updated_at)` over active members, else the team's own `updated_at`. + updated_at: i64, + /// Active member conversation ids, `created_at` ascending. + member_ids: Vec, +} + +/// A finished natural group: ordered non-pinned item refs plus the DTO scope. +struct NaturalGroup { + scope: SidebarScope, + token: ScopeToken, + token_str: String, + /// Sorted later-first (updated_at DESC, then item-type ASC, then id ASC). + items: Vec, + /// Shared team aggregates (set at finish); assembly reads names/members here. + team_by_id: Arc>, +} + +impl NaturalGroup { + fn team_by_id_ref(&self) -> Arc> { + self.team_by_id.clone() + } +} + +#[derive(Clone)] +enum GroupItemRef { + Conv { id: String, updated_at: i64 }, + Team { team_id: String, updated_at: i64 }, +} + +impl GroupItemRef { + fn updated_at(&self) -> i64 { + match self { + GroupItemRef::Conv { updated_at, .. } | GroupItemRef::Team { updated_at, .. } => *updated_at, + } + } + + fn type_ord(&self) -> u8 { + match self { + GroupItemRef::Conv { .. } => 0, + GroupItemRef::Team { .. } => 1, + } + } + + fn item_id(&self) -> &str { + match self { + GroupItemRef::Conv { id, .. } => id, + GroupItemRef::Team { team_id, .. } => team_id, + } + } + + fn item_type_str(&self) -> &'static str { + match self { + GroupItemRef::Conv { .. } => OrderItemType::Conversation.as_str(), + GroupItemRef::Team { .. } => OrderItemType::Team.as_str(), + } + } + + fn activity_cursor(&self) -> Cursor { + Cursor::Activity { + updated_at: self.updated_at(), + item_type: self.item_type_str().to_owned(), + item_id: self.item_id().to_owned(), + } + } + + /// Is this item strictly after `cursor` in later-first order? `None` cursor + /// means "from the top" (everything qualifies). + /// + /// Later-first: a comes before b iff `a.updated_at > b.updated_at`, or on a + /// tie iff `(a.type_ord, a.id) < (b.type_ord, b.id)`. So "after the cursor" + /// is: smaller `updated_at`, or equal `updated_at` with a larger + /// `(type_ord, id)` pair. + fn is_after(&self, cursor: Option<&Cursor>) -> bool { + let Some(Cursor::Activity { + updated_at, + item_type, + item_id, + }) = cursor + else { + return true; + }; + let cur = (type_ord_of(item_type), item_id.as_str()); + let this = (self.type_ord(), self.item_id()); + self.updated_at() < *updated_at || (self.updated_at() == *updated_at && this > cur) + } +} + +/// Group key used only during classification (before scope DTOs are built). +#[derive(Clone, PartialEq, Eq, Hash)] +enum GroupKey { + Project(String), + Dir(String), + Chats, +} + +/// Accumulates units into groups, tracking activity for ordering. +struct GroupBuilder { + map: HashMap, +} + +struct GroupAccum { + items: Vec, + /// MAX(updated_at) over ALL units incl pinned (group order, BR-6). + latest_activity: Option, + /// created_at fallback for empty standard-project groups. + created_at: i64, + /// Cached DTO scope + tokens (filled when the group is first created). + scope: SidebarScope, + token: ScopeToken, + token_str: String, +} + +impl GroupBuilder { + fn new() -> Self { + Self { map: HashMap::new() } + } + + fn push( + &mut self, + key: GroupKey, + by_id: &HashMap<&str, &SidebarProjectMeta>, + updated_at: i64, + pinned: bool, + item: GroupItemRef, + ) { + let accum = self + .map + .entry(key.clone()) + .or_insert_with(|| GroupAccum::new(&key, by_id)); + accum.latest_activity = Some(accum.latest_activity.map_or(updated_at, |a| a.max(updated_at))); + if !pinned { + accum.items.push(item); + } + } + + fn ensure_standard_spine(&mut self, project: &SidebarProjectMeta) { + let key = GroupKey::Project(project.project_id.clone()); + self.map.entry(key).or_insert_with(|| GroupAccum { + items: Vec::new(), + latest_activity: None, + created_at: project.created_at, + scope: project_scope(project), + token: ScopeToken::Project(project.project_id.clone()), + token_str: format!("project:{}", project.project_id), + }); + } + + /// Split into ordered project area (truncated to MAX_PROJECT_GROUPS) + chats. + fn finish(self, team_by_id: HashMap) -> (Vec, NaturalGroup, bool) { + let shared = Arc::new(team_by_id); + let mut chats: Option = None; + let mut area: Vec = Vec::new(); + for (key, accum) in self.map { + match key { + GroupKey::Chats => chats = Some(accum), + _ => area.push(accum), + } + } + + area.sort_by(cmp_group); + let has_more_groups = area.len() > MAX_PROJECT_GROUPS; + area.truncate(MAX_PROJECT_GROUPS); + + let project_area: Vec = area.into_iter().map(|a| a.into_group(shared.clone())).collect(); + let chats = chats + .unwrap_or_else(|| GroupAccum { + items: Vec::new(), + latest_activity: None, + created_at: 0, + scope: SidebarScope::Chats, + token: ScopeToken::Chats, + token_str: "chats".to_owned(), + }) + .into_group(shared); + (project_area, chats, has_more_groups) + } +} + +impl GroupAccum { + fn new(key: &GroupKey, by_id: &HashMap<&str, &SidebarProjectMeta>) -> Self { + match key { + GroupKey::Project(pid) => { + // Invariant: a Project key only arises from a resolved standard + // project (direct binding or path merge), so meta is present. + let meta = by_id.get(pid.as_str()).expect("project key without meta"); + GroupAccum { + items: Vec::new(), + latest_activity: None, + created_at: meta.created_at, + scope: project_scope(meta), + token: ScopeToken::Project(pid.clone()), + token_str: format!("project:{pid}"), + } + } + GroupKey::Dir(canonical) => { + let (path, name) = dir_display(canonical); + GroupAccum { + items: Vec::new(), + latest_activity: None, + created_at: 0, + scope: SidebarScope::Dir { + key: canonical_to_dir_key(canonical), + path, + name, + }, + token: ScopeToken::Dir(canonical.clone()), + token_str: format!("dir:{}", canonical_to_dir_key(canonical)), + } + } + GroupKey::Chats => GroupAccum { + items: Vec::new(), + latest_activity: None, + created_at: 0, + scope: SidebarScope::Chats, + token: ScopeToken::Chats, + token_str: "chats".to_owned(), + }, + } + } + + fn into_group(mut self, team_by_id: Arc>) -> NaturalGroup { + self.items.sort_by(cmp_item); + NaturalGroup { + scope: self.scope, + token: self.token, + token_str: self.token_str, + items: self.items, + team_by_id, + } + } +} + +// -- Free helpers ------------------------------------------------------------ + +/// Fold conversations into their live team; return (team aggregates, independents). +/// A member whose `team_id` names no live team is an orphan and downgrades to an +/// independent row (BR-8). +fn aggregate_teams( + convs: Vec, + teams: Vec, +) -> (HashMap, Vec) { + let live: HashSet<&str> = teams.iter().map(|t| t.id.as_str()).collect(); + let mut members: HashMap> = HashMap::new(); + let mut independents: Vec = Vec::new(); + for conv in convs { + match &conv.team_id { + Some(tid) if live.contains(tid.as_str()) => members.entry(tid.clone()).or_default().push(conv), + _ => independents.push(conv), + } + } + + let mut team_by_id = HashMap::with_capacity(teams.len()); + for team in teams { + let mut mem = members.remove(&team.id).unwrap_or_default(); + mem.sort_by(|a, b| a.created_at.cmp(&b.created_at).then_with(|| a.id.cmp(&b.id))); + let updated_at = mem.iter().map(|c| c.updated_at).max().unwrap_or(team.updated_at); + let member_ids = mem.into_iter().map(|c| c.id).collect(); + team_by_id.insert( + team.id.clone(), + TeamAgg { + team_id: team.id, + name: team.name, + updated_at, + member_ids, + }, + ); + } + (team_by_id, independents) +} + +/// Group order (BR-6): active groups by latest_activity DESC; empty standard +/// projects sink below and order by created_at DESC; ties break by token DESC. +fn cmp_group(a: &GroupAccum, b: &GroupAccum) -> std::cmp::Ordering { + use std::cmp::Ordering; + match (a.latest_activity, b.latest_activity) { + (Some(x), Some(y)) => y.cmp(&x).then_with(|| b.token_str.cmp(&a.token_str)), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => b + .created_at + .cmp(&a.created_at) + .then_with(|| b.token_str.cmp(&a.token_str)), + } +} + +/// Item order within a group: later-first (updated_at DESC), tie-break +/// (item_type ASC, id ASC) — matching the keyset cursor's "strictly after". +fn cmp_item(a: &GroupItemRef, b: &GroupItemRef) -> std::cmp::Ordering { + b.updated_at() + .cmp(&a.updated_at()) + .then_with(|| a.type_ord().cmp(&b.type_ord())) + .then_with(|| a.item_id().cmp(b.item_id())) +} + +fn type_ord_of(item_type: &str) -> u8 { + match item_type { + t if t == OrderItemType::Conversation.as_str() => 0, + t if t == OrderItemType::Team.as_str() => 1, + _ => u8::MAX, + } +} + +fn project_scope(meta: &SidebarProjectMeta) -> SidebarScope { + SidebarScope::Project { + project_id: meta.project_id.clone(), + name: meta.name.clone(), + workspace: meta.workspace_uri.clone(), + } +} + +/// Human display path + last-segment name for a pseudo-dir canonical URI. +fn dir_display(canonical: &str) -> (String, String) { + match canonical::uri_to_path(canonical) { + Ok(path) => { + let name = path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + (path.display().to_string(), name) + } + Err(_) => (canonical.to_owned(), canonical.to_owned()), + } +} + +fn assemble_items( + window: &[GroupItemRef], + hydrated: &HashMap, + team_by_id: &HashMap, + pinned: bool, +) -> Vec { + let mut out = Vec::with_capacity(window.len()); + for item in window { + match item { + GroupItemRef::Conv { id, .. } => { + if let Some(resp) = hydrated.get(id) { + out.push(conversation_item(resp.clone(), pinned)); + } + } + GroupItemRef::Team { team_id, .. } => { + if let Some(agg) = team_by_id.get(team_id) { + out.push(team_item(agg, pinned)); + } + } + } + } + out +} + +fn conversation_item(mut resp: ConversationResponse, pinned: bool) -> SidebarItem { + resp.pinned = pinned; + resp.pinned_at = None; + SidebarItem::Conversation { conversation: resp } +} + +fn team_item(agg: &TeamAgg, pinned: bool) -> SidebarItem { + SidebarItem::Team(SidebarTeamItem { + team_id: agg.team_id.clone(), + name: agg.name.clone(), + updated_at: agg.updated_at, + pinned, + member_conversation_ids: agg.member_ids.clone(), + }) +} + +fn parse_row(row: ConversationRow, work_dir: &Path) -> Result { + let extra: serde_json::Value = + serde_json::from_str(&row.extra).map_err(|e| SidebarError::Internal(format!("invalid extra JSON: {e}")))?; + row_to_response_with_extra(row, extra, work_dir).map_err(|e| SidebarError::Internal(e.to_string())) +} + +fn parse_scene(scene: &str) -> Result { + OrderScene::parse(scene).ok_or_else(|| SidebarError::BadRequest(format!("unknown scene: {scene}"))) +} + +fn parse_item_type(item_type: &str) -> Result { + OrderItemType::parse(item_type).ok_or_else(|| SidebarError::BadRequest(format!("unknown item type: {item_type}"))) +} + +/// Parse an `OrderItemRefDto` body field into a validated `OrderItemRef`; an +/// unknown `item_type` is a 400 (mirroring the pin/unpin path params). +fn parse_item_ref(dto: &OrderItemRefDto) -> Result { + Ok(OrderItemRef::new(parse_item_type(&dto.item_type)?, dto.item_id.clone())) +} + +fn to_pinned_cursor(cursor: Cursor) -> Result { + match cursor { + Cursor::Pinned { + order_key, + item_type, + item_id, + } => { + let item_type = OrderItemType::parse(&item_type) + .ok_or_else(|| SidebarError::BadRequest(format!("unknown cursor item type: {item_type}")))?; + Ok(PinnedCursor { + order_key, + item_type, + item_id, + }) + } + Cursor::Activity { .. } => Err(SidebarError::BadRequest("expected a pinned cursor".into())), + } +} + +struct PinnedPage { + items: Vec, + has_more: bool, + next_cursor: Option, +} + +#[cfg(test)] +#[path = "service_test.rs"] +mod service_test; diff --git a/crates/aionui-sidebar/src/service_test.rs b/crates/aionui-sidebar/src/service_test.rs new file mode 100644 index 000000000..392cb29e6 --- /dev/null +++ b/crates/aionui-sidebar/src/service_test.rs @@ -0,0 +1,947 @@ +//! Service-level tests over a real in-memory DB (no mocks): the D11 5-case +//! classification matrix, team folding + orphan downgrade (BR-8), display-only +//! path merge (case 3, asserts no DB write), the pinned anti-join / B1 +//! double-render regression, cross-user scoping (BR-24), dangling project ids, +//! keyset paging continuity, and the ScopeGone 404. + +use std::collections::HashSet; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use aionui_api_types::{MoveOrderRequest, OrderItemRefDto, SidebarItem, SidebarScope}; +use aionui_db::{ + Database, ISidebarStore, IUserOrderStore, OrderItemRef, OrderItemType, SqlitePool, SqliteSidebarStore, + SqliteUserOrderStore, init_database_memory, +}; +use aionui_project::canonical; +use async_trait::async_trait; +use tempfile::TempDir; + +use super::{SidebarError, SidebarService}; +use crate::ports::RemoveProjectPorts; + +const USER: &str = "user-1"; +const OTHER: &str = "user-2"; + +// -- Fixture ----------------------------------------------------------------- + +struct Fixture { + db: Database, + _tmp: TempDir, + service: SidebarService, +} + +impl Fixture { + fn pool(&self) -> &SqlitePool { + self.db.pool() + } + + /// A temp-session workspace path under + /// `work_dir/conversations/