diff --git a/Cargo.lock b/Cargo.lock index d9ea728da..2c22a41db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -677,6 +677,7 @@ version = "0.1.53" dependencies = [ "aionui-api-types", "aionui-common", + "aionui-project", "aionui-realtime", "async-trait", "axum", @@ -776,13 +777,21 @@ dependencies = [ name = "aionui-project" version = "0.1.53" dependencies = [ + "aionui-api-types", "aionui-common", "aionui-db", + "async-trait", + "axum", + "base64", "chrono", + "http-body-util", + "notify", "serde", + "serde_json", "tempfile", "thiserror 2.0.18", "tokio", + "tower", "tracing", "url", ] diff --git a/crates/aionui-api-types/src/chat_file.rs b/crates/aionui-api-types/src/chat_file.rs new file mode 100644 index 000000000..99507cb05 --- /dev/null +++ b/crates/aionui-api-types/src/chat_file.rs @@ -0,0 +1,33 @@ +//! Chat-message file attachments. +//! +//! A message's attachments are a tagged union discriminated by `kind`, decided +//! purely by *source* (not by any save-to-workspace setting): +//! - explorer tree selections → [`ChatFileRef::Project`] (resolved server-side +//! via `resolve_reference(op = Read)`), +//! - upload-button files → [`ChatFileRef::Upload`] (always `upload`, carrying +//! the absolute path returned by `POST /api/fs/upload`), +//! - host-filesystem picker selections → [`ChatFileRef::Local`] (an absolute +//! path the user explicitly chose in the backend-machine file browser). + +use serde::{Deserialize, Serialize}; + +/// A single file attached to a chat message. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ChatFileRef { + /// A file inside a bound project folder, addressed by explorer identity + /// (`pe_id` + `relative_path`). The backend resolves it to an absolute path + /// via `resolve_reference` with lexical + realpath containment. + Project { pe_id: String, relative_path: String }, + /// An uploaded file, carried as the absolute path returned by + /// `POST /api/fs/upload`. The backend requires it to live under the managed + /// upload directory before use. + Upload { path: String }, + /// A file on the backend machine's filesystem, chosen by the user in the + /// host-file browser (`/api/fs/browse`, which already exposes the whole + /// filesystem). Carries an absolute path; the backend only checks it exists + /// and is a regular file — no managed-directory restriction, since the + /// picker that produced it already exposes this surface and the agent reads + /// the path through its own filesystem tools. + Local { path: String }, +} diff --git a/crates/aionui-api-types/src/conversation.rs b/crates/aionui-api-types/src/conversation.rs index 3f868750b..4c5cbf9fa 100644 --- a/crates/aionui-api-types/src/conversation.rs +++ b/crates/aionui-api-types/src/conversation.rs @@ -5,6 +5,7 @@ use aionui_common::{ use serde::{Deserialize, Serialize}; use crate::acp::AcpConfigOptionDto; +use crate::chat_file::ChatFileRef; /// Per-MCP snapshot status stored in `conversation.extra`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -94,7 +95,7 @@ pub struct CloneConversationRequest { pub struct SendMessageRequest { pub content: String, #[serde(default)] - pub files: Vec, + pub files: Vec, #[serde(default)] pub inject_skills: Vec, #[serde(default)] @@ -227,6 +228,8 @@ pub struct ConversationResponse { pub channel_chat_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub assistant: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_id: Option, pub created_at: TimestampMs, pub modified_at: TimestampMs, pub extra: serde_json::Value, @@ -585,6 +588,7 @@ mod tests { pinned_at: None, channel_chat_id: None, assistant: None, + project_id: None, created_at: 1712345678000, modified_at: 1712345678000, extra: json!({ "workspace": "/project" }), @@ -623,6 +627,7 @@ mod tests { pinned_at: None, channel_chat_id: None, assistant: None, + project_id: None, created_at: 1, modified_at: 1, extra: json!({}), @@ -655,6 +660,7 @@ mod tests { pinned_at: Some(1712345678000), channel_chat_id: Some("group:42".into()), assistant: None, + project_id: None, created_at: 1000, modified_at: 2000, extra: json!({}), @@ -739,6 +745,7 @@ mod tests { pinned_at: None, channel_chat_id: None, assistant: None, + project_id: None, created_at: 1712345678000, modified_at: 1712345678000, extra: json!({}), @@ -776,6 +783,7 @@ mod tests { pinned_at: None, channel_chat_id: None, assistant: None, + project_id: None, created_at: 9000, modified_at: 9000, extra: json!({}), @@ -795,13 +803,31 @@ mod tests { fn deserialize_send_message_full() { let raw = json!({ "content": "Review this code", - "files": ["/tmp/a.rs"], + "files": [ + { "kind": "project", "pe_id": "pe1", "relative_path": "src/a.rs" }, + { "kind": "upload", "path": "/tmp/a.rs" }, + { "kind": "local", "path": "/Users/me/notes.txt" } + ], "inject_skills": ["security-review"], "hidden": true }); let req: SendMessageRequest = serde_json::from_value(raw).unwrap(); assert_eq!(req.content, "Review this code"); - assert_eq!(req.files, vec!["/tmp/a.rs"]); + assert_eq!( + req.files, + vec![ + ChatFileRef::Project { + pe_id: "pe1".into(), + relative_path: "src/a.rs".into() + }, + ChatFileRef::Upload { + path: "/tmp/a.rs".into() + }, + ChatFileRef::Local { + path: "/Users/me/notes.txt".into() + }, + ] + ); assert_eq!(req.inject_skills, vec!["security-review"]); assert!(req.hidden); } @@ -847,6 +873,7 @@ mod tests { pinned_at: None, channel_chat_id: None, assistant: None, + project_id: None, created_at: 1000, modified_at: 1000, extra: json!({}), @@ -899,6 +926,7 @@ mod tests { pinned_at: None, channel_chat_id: None, assistant: None, + project_id: None, created_at: 5000, modified_at: 5000, extra: json!({}), diff --git a/crates/aionui-api-types/src/file.rs b/crates/aionui-api-types/src/file.rs index f6eef3bc8..22a55cd73 100644 --- a/crates/aionui-api-types/src/file.rs +++ b/crates/aionui-api-types/src/file.rs @@ -54,11 +54,23 @@ pub struct WriteFileRequest { pub workspace: Option, } -/// Request body for `POST /api/fs/copy` — copy files to workspace. +/// Copy destination, addressed by explorer identity (a project folder + a +/// relative subdirectory). The backend resolves it to an absolute directory +/// via `resolve_reference`; device file paths are copied into it. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CopyTarget { + pub pe_id: String, + /// Relative directory under the pe's folder root (`""` = the root itself). + pub relative_path: String, +} + +/// Request body for `POST /api/fs/copy` — copy device files into a project +/// folder (add-to-chat "paste into workspace", pe-addressed). #[derive(Debug, Deserialize)] pub struct CopyFilesRequest { + /// Absolute device paths of external OS files to copy in. pub file_paths: Vec, - pub workspace: String, + pub target: CopyTarget, #[serde(default)] pub source_root: Option, } @@ -227,11 +239,19 @@ pub struct FileMetadataResponse { pub is_directory: Option, } +/// One file that could not be copied, with a human-readable reason +/// (e.g. name collision — the backend never silently overwrites). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CopyFailure { + pub path: String, + pub reason: String, +} + /// Result of a batch copy operation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CopyFilesResponse { pub copied_files: Vec, - pub failed_files: Vec, + pub failed_files: Vec, } /// Result of a rename operation. @@ -352,12 +372,13 @@ mod tests { fn copy_files_request_snake_case() { let raw = json!({ "file_paths": ["/a.txt", "/b.txt"], - "workspace": "/ws", + "target": { "pe_id": "pe1", "relative_path": "sub" }, "source_root": "/src" }); let req: CopyFilesRequest = serde_json::from_value(raw).unwrap(); assert_eq!(req.file_paths, vec!["/a.txt", "/b.txt"]); - assert_eq!(req.workspace, "/ws"); + assert_eq!(req.target.pe_id, "pe1"); + assert_eq!(req.target.relative_path, "sub"); assert_eq!(req.source_root.as_deref(), Some("/src")); } @@ -365,7 +386,7 @@ mod tests { fn copy_files_request_optional_source_root() { let raw = json!({ "file_paths": ["/a.txt"], - "workspace": "/ws" + "target": { "pe_id": "pe1", "relative_path": "" } }); let req: CopyFilesRequest = serde_json::from_value(raw).unwrap(); assert!(req.source_root.is_none()); @@ -526,11 +547,15 @@ mod tests { fn copy_files_response_serialization() { let resp = CopyFilesResponse { copied_files: vec!["/ws/a.txt".into()], - failed_files: vec!["/missing.txt".into()], + failed_files: vec![CopyFailure { + path: "/missing.txt".into(), + reason: "not a file".into(), + }], }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["copied_files"][0], "/ws/a.txt"); - assert_eq!(json["failed_files"][0], "/missing.txt"); + assert_eq!(json["failed_files"][0]["path"], "/missing.txt"); + assert_eq!(json["failed_files"][0]["reason"], "not a file"); } #[test] diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index 3175f1279..3259ca0f5 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -9,6 +9,7 @@ mod agent_error; mod assistant; mod auth; mod channel; +mod chat_file; mod confirmation; mod connection_test; mod conversation; @@ -19,6 +20,7 @@ mod file; mod lifecycle; mod mcp; mod office; +mod project; mod provider; mod remote_agent; mod response; @@ -72,6 +74,7 @@ pub use channel::{ PluginStatusChangedPayload, PluginStatusResponse, RejectPairingRequest, RevokeUserRequest, SyncChannelSettingsRequest, TestPluginExtraConfig, TestPluginRequest, TestPluginResponse, UserAuthorizedPayload, }; +pub use chat_file::ChatFileRef; pub use confirmation::{ApprovalCheckQuery, ApprovalCheckResponse, ConfirmRequest, ConfirmationListResponse}; pub use connection_test::TestBedrockConnectionRequest; pub use conversation::{ @@ -100,13 +103,13 @@ pub use extension::{ InstallExtensionRequest, PermissionDetailResponse, PermissionSummaryResponse, }; pub use file::{ - BrowseDirectoryQuery, BrowseDirectoryResponse, BrowseEntry, CancelZipRequest, CopyFilesRequest, CopyFilesResponse, - CreateTempFileRequest, DirOrFileResponse, FetchRemoteImageRequest, FileChangeInfoResponse, FileMetadataResponse, - FileWatchRequest, GetFileMetadataRequest, GetFilesByDirRequest, GetImageBase64Request, ListWorkspaceFilesRequest, - ReadFileBufferRequest, ReadFileRequest, RemoveEntryRequest, RenameRequest, RenameResponse, SnapshotBaselineRequest, - SnapshotCompareResponse, SnapshotDiscardRequest, SnapshotInfoResponse, SnapshotMode, SnapshotStageRequest, - SnapshotWorkspaceRequest, WorkspaceFlatFileResponse, WorkspaceOfficeWatchRequest, WriteFileRequest, ZipFileEntry, - ZipRequest, + BrowseDirectoryQuery, BrowseDirectoryResponse, BrowseEntry, CancelZipRequest, CopyFailure, CopyFilesRequest, + CopyFilesResponse, CopyTarget, CreateTempFileRequest, DirOrFileResponse, FetchRemoteImageRequest, + FileChangeInfoResponse, FileMetadataResponse, FileWatchRequest, GetFileMetadataRequest, GetFilesByDirRequest, + GetImageBase64Request, ListWorkspaceFilesRequest, ReadFileBufferRequest, ReadFileRequest, RemoveEntryRequest, + RenameRequest, RenameResponse, SnapshotBaselineRequest, SnapshotCompareResponse, SnapshotDiscardRequest, + SnapshotInfoResponse, SnapshotMode, SnapshotStageRequest, SnapshotWorkspaceRequest, WorkspaceFlatFileResponse, + WorkspaceOfficeWatchRequest, WriteFileRequest, ZipFileEntry, ZipRequest, }; pub use lifecycle::{GitHubReleaseAsset, SystemInfoResponse, UpdateCheckRequest, UpdateCheckResult, UpdateReleaseInfo}; pub use mcp::{ @@ -121,6 +124,7 @@ pub use office::{ PptSlideData, PreviewHistoryTargetDto, PreviewSnapshotInfoDto, PreviewState, PreviewStatusEvent, PreviewUrlResponse, SaveSnapshotRequest, SnapshotContentResponse, StartPreviewRequest, StopPreviewRequest, }; +pub use project::{AttachFolderRequest, ProjectDetailResponse, ProjectEntry, ProjectExplorer}; pub use provider::{ BedrockAuthMethod, BedrockConfig, CreateProviderRequest, DetectProtocolRequest, DetectionSuggestion, FetchModelsAnonymousRequest, FetchModelsRequest, FetchModelsResponse, HealthStatus, KeyTestResult, ModelCapability, diff --git a/crates/aionui-api-types/src/project.rs b/crates/aionui-api-types/src/project.rs new file mode 100644 index 000000000..450cb0892 --- /dev/null +++ b/crates/aionui-api-types/src/project.rs @@ -0,0 +1,66 @@ +//! Project Explorer control-plane HTTP DTOs. +//! +//! The wire contract the explorer frontend consumes to fetch a project's +//! roots (`GET /api/projects/{id}`) and mutate its attached folders +//! (`POST`/`DELETE .../folders`). The filesystem *content* of each root is +//! carried separately over the `fs/*` WebSocket protocol, keyed by `pe_id` — +//! these DTOs only describe the project shell and its root list. +//! +//! Deliberately excludes absolute paths / canonical URIs: the frontend +//! identifies resources purely by `{pe_id, relative_path}`. `display_path` +//! is a human-facing, read-only rendering of the folder location. + +use serde::{Deserialize, Serialize}; + +/// Aggregated project detail — everything the explorer needs in one call, +/// so the frontend never fans out one request per root. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectDetailResponse { + pub project_id: String, + /// Project display name (explorer header). + pub name: String, + pub explorer: ProjectExplorer, +} + +/// The explorer view of a project: its pinned workspace root plus every +/// attached root, in display order. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectExplorer { + /// The `pe_id` of the immutable workspace root (pinned first, not + /// removable). The frontend uses it to pin + lock that row. + pub workspace_pe_id: String, + /// Roots ordered by `order_index` ascending (backend-sorted). + pub entries: Vec, +} + +/// One explorer root. Also returned singly by `attach_folder` so the +/// frontend can splice it into the tree without re-fetching the project. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectEntry { + /// Stable root identity (= frontend `PeKey` prefix). Content subscriptions + /// key off this. + pub pe_id: String, + /// `"workspace"` (pinned, immutable) or `"attached"` (removable). + pub role: String, + /// Optional user-assigned label; `null` → frontend falls back to the + /// `display_path` basename. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Human-facing path rendering of the folder location (read-only). + pub display_path: String, + /// Position among roots (ascending). + pub order_index: i64, + /// Folder availability: `available` | `missing` | `permission_denied` | + /// `disconnected`. Drives the greyed-out / stale root indicator. + pub runtime_status: String, +} + +/// `POST /api/projects/{project_id}/folders` body — attach an additional +/// (non-workspace) folder. `uri` is a `file://` URI (same form as project +/// creation). +#[derive(Debug, Clone, Deserialize)] +pub struct AttachFolderRequest { + pub uri: String, + #[serde(default)] + pub display_name: Option, +} diff --git a/crates/aionui-api-types/src/team.rs b/crates/aionui-api-types/src/team.rs index 68e5ea4f9..0544fb98b 100644 --- a/crates/aionui-api-types/src/team.rs +++ b/crates/aionui-api-types/src/team.rs @@ -2,6 +2,7 @@ use aionui_common::TimestampMs; use serde::{Deserialize, Deserializer, Serialize}; use crate::TeamMcpStdioConfig; +use crate::chat_file::ChatFileRef; // --------------------------------------------------------------------------- // A. Team management — Request DTOs @@ -252,7 +253,7 @@ pub struct TeamMcpRuntimeConfig { pub struct SendTeamMessageRequest { pub content: String, #[serde(default)] - pub files: Option>, + pub files: Option>, } /// Request body for `POST /api/teams/:id/agents/:slotId/messages`. @@ -263,7 +264,7 @@ pub struct SendTeamMessageRequest { pub struct SendAgentMessageRequest { pub content: String, #[serde(default)] - pub files: Option>, + pub files: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/aionui-app/src/router/fs_monitor.rs b/crates/aionui-app/src/router/fs_monitor.rs new file mode 100644 index 000000000..499f88151 --- /dev/null +++ b/crates/aionui-app/src/router/fs_monitor.rs @@ -0,0 +1,170 @@ +//! Composition wiring for the Project Explorer filesystem monitor. +//! +//! Adapts the transport-agnostic [`FsMonitorActor`] (in `aionui-project`) to the +//! realtime WebSocket layer: an [`FsMessageRouter`] forwards inbound `fs` frames +//! (and disconnects) into the actor's channel, and [`WsManagerPush`] implements +//! the actor's outbound port over the WS manager's unicast, wrapping each inner +//! JSON-RPC frame in the transport envelope (`{ name: "fs", data }`). The actor +//! runs as one background task (desktop N = 1). + +use std::sync::Arc; + +use aionui_api_types::WebSocketMessage; +use aionui_project::ProjectService; +use aionui_project::monitor::{FsInbound, FsMonitorActor, FsWirePush}; +use aionui_realtime::{ConnectionId, MessageRouter, NoopMessageRouter, WebSocketManager}; +use serde_json::Value; +use tokio::sync::mpsc::UnboundedSender; + +/// Warm-node watch budget for the single desktop shard. Generous — desktop +/// observes only the directories the user has expanded. +const WARM_BUDGET: usize = 4096; + +/// Inbound adapter: routes outer-envelope `fs` frames to the monitor actor. +/// +/// The realtime layer has already unwrapped the envelope, so `route` receives +/// `name == "fs"` and `data == `. Returns `true` to claim +/// the message; the actual reply/notification is delivered asynchronously by the +/// actor via [`WsManagerPush`]. +struct FsMessageRouter { + inbound: UnboundedSender, +} + +impl MessageRouter for FsMessageRouter { + fn route(&self, conn_id: ConnectionId, name: &str, data: Value) -> bool { + if name != "fs" { + return false; + } + // A closed channel means the actor task has stopped; the frame is + // dropped (the connection will observe silence and can reconnect). + let _ = self.inbound.send(FsInbound::Frame { + session: conn_id.0.to_string(), + frame: data, + }); + true + } + + fn on_disconnect(&self, conn_id: ConnectionId) { + let _ = self.inbound.send(FsInbound::Disconnect { + session: conn_id.0.to_string(), + }); + } +} + +/// Outbound adapter: deliver one inner JSON-RPC frame to a connection, wrapped +/// in the transport envelope, via the WS manager's unicast. +struct WsManagerPush { + manager: Arc, +} + +impl FsWirePush for WsManagerPush { + fn push(&self, session: &str, frame: Value) { + // `session` is a stringified ConnectionId (see FsMessageRouter). + if let Ok(id) = session.parse::() { + // Ordered stream: on backpressure the connection is dropped (the + // client reconnects + re-subscribes) rather than silently dropping a + // frame, which would desync the tree with no gap detection. See + // protocol.md §契约规则 ("背压=关连接、不静默丢帧"). + self.manager + .send_to_or_disconnect(ConnectionId(id), WebSocketMessage::new("fs", frame)); + } + } +} + +/// Spawn the monitor actor as a background task and return the inbound router to +/// install on the WS handler. On init failure the fs feature degrades to a no-op +/// router (the rest of the WS layer stays up). +/// +/// Must be called from within a Tokio runtime (spawns the actor loop). +pub fn spawn_fs_monitor(project: Arc, manager: Arc) -> Arc { + let push: Arc = Arc::new(WsManagerPush { manager }); + match FsMonitorActor::new(project, push, WARM_BUDGET) { + Ok((actor, raw_rx)) => { + let (inbound, inbound_rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(actor.run(inbound_rx, raw_rx)); + Arc::new(FsMessageRouter { inbound }) + } + Err(err) => { + tracing::error!(error = %err, "fs monitor init failed; filesystem protocol disabled"); + Arc::new(NoopMessageRouter) + } + } +} + +#[cfg(test)] +mod tests { + use aionui_realtime::{PER_CONNECTION_BUFFER, WsOutbound}; + use serde_json::json; + use tokio::sync::mpsc; + + use super::*; + + #[test] + fn router_forwards_fs_frame_with_stringified_session() { + let (inbound, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let router = FsMessageRouter { inbound }; + + let handled = router.route(ConnectionId(5), "fs", json!({"method": "initialize"})); + assert!(handled, "fs frames are claimed"); + + match rx.try_recv().unwrap() { + FsInbound::Frame { session, frame } => { + assert_eq!(session, "5", "ConnectionId is stringified into the session id"); + assert_eq!(frame["method"], "initialize"); + } + other => panic!("expected Frame, got {other:?}"), + } + } + + #[test] + fn router_ignores_non_fs_messages() { + let (inbound, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let router = FsMessageRouter { inbound }; + + let handled = router.route(ConnectionId(1), "conversation.send-message", json!({})); + assert!(!handled, "non-fs messages fall through to other routing"); + assert!(rx.try_recv().is_err(), "nothing forwarded for non-fs"); + } + + #[test] + fn router_on_disconnect_forwards_disconnect() { + let (inbound, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let router = FsMessageRouter { inbound }; + + router.on_disconnect(ConnectionId(9)); + match rx.try_recv().unwrap() { + FsInbound::Disconnect { session } => assert_eq!(session, "9"), + other => panic!("expected Disconnect, got {other:?}"), + } + } + + #[test] + fn push_wraps_frame_in_fs_envelope_and_unicasts() { + let manager = Arc::new(WebSocketManager::new()); + let (tx, mut rx) = mpsc::channel::(PER_CONNECTION_BUFFER); + let conn = manager.add_client("tok".to_owned(), tx); + + let push = WsManagerPush { + manager: Arc::clone(&manager), + }; + push.push(&conn.0.to_string(), json!({"result": {"ok": true}})); + + match rx.try_recv().unwrap() { + WsOutbound::Text(text) => { + let parsed: Value = serde_json::from_str(&text).unwrap(); + // Outer transport envelope carries name "fs"; inner is the frame. + assert_eq!(parsed["name"], "fs"); + assert_eq!(parsed["data"]["result"]["ok"], true); + } + other => panic!("expected Text, got {other:?}"), + } + } + + #[test] + fn push_to_unparseable_session_is_noop() { + let manager = Arc::new(WebSocketManager::new()); + let push = WsManagerPush { manager }; + // Must not panic on a non-numeric session id. + push.push("not-a-number", json!({})); + } +} diff --git a/crates/aionui-app/src/router/mod.rs b/crates/aionui-app/src/router/mod.rs index d16c5972d..623814737 100644 --- a/crates/aionui-app/src/router/mod.rs +++ b/crates/aionui-app/src/router/mod.rs @@ -1,5 +1,6 @@ //! HTTP router assembly for the application. +mod fs_monitor; mod health; mod routes; mod runtime_team_tools; diff --git a/crates/aionui-app/src/router/routes.rs b/crates/aionui-app/src/router/routes.rs index ed88d8b7d..c3f6a8e61 100644 --- a/crates/aionui-app/src/router/routes.rs +++ b/crates/aionui-app/src/router/routes.rs @@ -30,13 +30,15 @@ use aionui_extension::{extension_routes, hub_routes, skill_routes}; use aionui_file::file_routes; use aionui_mcp::mcp_routes; use aionui_office::{office_proxy_routes, office_routes}; -use aionui_realtime::{WsHandlerState, ws_upgrade_handler}; +use aionui_project::project_routes; +use aionui_realtime::{NoopMessageRouter, WsHandlerState, ws_upgrade_handler}; use aionui_shell::shell_routes; use aionui_system::{ClientPrefService, connection_test_routes, system_routes}; use aionui_team::{TeamSessionService, team_routes}; use crate::services::AppServices; +use super::fs_monitor::spawn_fs_monitor; use super::health::health_check; use super::runtime_team_tools::{RuntimeTeamToolsState, runtime_team_tools_routes}; use super::state::{ModuleStates, RouterBuildError, build_module_states, build_ws_state}; @@ -112,7 +114,12 @@ pub async fn create_router_with_runtime(services: &AppServices) -> Result<(Route elapsed_ms = boot.elapsed().as_millis(), "startup: route tree build started" ); - let router = create_router_with_states(services, states); + // Spawn the Project Explorer filesystem monitor and install its inbound + // router (fs/* frames). Built here — inside the runtime — because the actor + // runs as a background task. The sync test-only assembly path keeps a no-op. + let fs_router = spawn_fs_monitor(Arc::new(services.project_service.clone()), services.ws_manager.clone()); + let ws_state = build_ws_state(services, fs_router); + let router = create_router_with_all_state(services, states, ws_state); tracing::info!( elapsed_ms = boot.elapsed().as_millis(), "startup: router assembly completed" @@ -131,7 +138,9 @@ pub async fn create_router_with_runtime(services: &AppServices) -> Result<(Route /// Used for testing when specific service overrides are needed /// (e.g. injecting a mock HTTP server URL for version check). pub fn create_router_with_states(services: &AppServices, states: ModuleStates) -> Router { - let ws_state = build_ws_state(services); + // No-op inbound router: this sync assembly path is for HTTP-focused tests and + // does not spawn the fs monitor (which requires a runtime task). + let ws_state = build_ws_state(services, Arc::new(NoopMessageRouter)); create_router_with_all_state(services, states, ws_state) } @@ -184,6 +193,10 @@ pub fn create_router_with_all_state(services: &AppServices, states: ModuleStates let file_authenticated = file_routes(states.file).route_layer(from_fn_with_state(auth_mw_state.clone(), auth_middleware)); + // Project control-plane routes protected by auth middleware + let project_authenticated = + project_routes(states.project).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)); @@ -251,6 +264,7 @@ pub fn create_router_with_all_state(services: &AppServices, states: ModuleStates .merge(agent_authenticated) .merge(connection_test_authenticated) .merge(file_authenticated) + .merge(project_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 79461e5c3..bdfba158b 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -36,7 +36,8 @@ use aionui_mcp::{ use aionui_office::{ ConversionService, OfficeRouterState, OfficecliWatchManager, ProxyService, SnapshotService as OfficeSnapshotService, }; -use aionui_realtime::{NoopMessageRouter, WsHandlerState}; +use aionui_project::ProjectRouterState; +use aionui_realtime::{MessageRouter, WsHandlerState}; use aionui_shell::ShellRouterState; use aionui_system::{ ClientPrefService, ConnectionTestRouterState, ConnectionTestService, FeedbackDiagnosticsService, ModelFetchService, @@ -129,6 +130,7 @@ pub struct ModuleStates { pub connection_test: ConnectionTestRouterState, pub file: FileRouterState, + pub project: ProjectRouterState, pub mcp: McpRouterState, pub extension: ExtensionRouterState, pub hub: HubRouterState, @@ -289,6 +291,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)), mcp: build_module_state_phase(&boot, "mcp", || build_mcp_state(services)), extension: ext_state, hub: hub_state, @@ -446,6 +449,7 @@ pub fn build_file_state(services: &AppServices) -> Result RouterBuildError { RouterBuildError::new("router.file_watch", "failed to initialize file watch service").with_source(error) } +/// Build the project control-plane router state from application services. +pub fn build_project_state(services: &AppServices) -> ProjectRouterState { + ProjectRouterState { + project: Arc::new(services.project_service.clone()), + } +} + /// Build the default `McpRouterState` from application services. pub fn build_mcp_state(services: &AppServices) -> McpRouterState { let pool = services.database.pool().clone(); @@ -848,12 +859,14 @@ pub async fn build_extension_states( (ext_state, hub_state, skill_state) } -/// Build the default `WsHandlerState` from application services. -pub fn build_ws_state(services: &AppServices) -> WsHandlerState { +/// Build the `WsHandlerState` from application services with an explicit inbound +/// `router`. Callers supply the filesystem-monitor router in production and a +/// no-op router for router-only/test assembly. +pub fn build_ws_state(services: &AppServices, router: Arc) -> WsHandlerState { if services.local { return WsHandlerState { manager: services.ws_manager.clone(), - router: Arc::new(NoopMessageRouter), + router, token_validator: Arc::new(|_| true), token_extractor: Arc::new(|_| Some("local".into())), }; @@ -866,7 +879,7 @@ pub fn build_ws_state(services: &AppServices) -> WsHandlerState { WsHandlerState { manager: services.ws_manager.clone(), - router: Arc::new(NoopMessageRouter), + router, token_validator, token_extractor, } diff --git a/crates/aionui-app/tests/file_e2e.rs b/crates/aionui-app/tests/file_e2e.rs index b65105768..cb75a9bb8 100644 --- a/crates/aionui-app/tests/file_e2e.rs +++ b/crates/aionui-app/tests/file_e2e.rs @@ -487,13 +487,21 @@ async fn copy_files_to_workspace() { std::fs::write(src_dir.path().join("source.txt"), "content").unwrap(); let ws_dir = tempfile::tempdir().unwrap(); + // The copy destination is now pe-addressed: register ws_dir as a project so + // it has a pe_id the backend resolves to the absolute directory. + let created = services + .project_service + .create_standard(aionui_project::canonical::to_file_uri(ws_dir.path()).unwrap()) + .await + .unwrap(); + let pe_id = created.project_explorer.pe_id; let req = json_with_token( "POST", "/api/fs/copy", json!({ "file_paths": [src_dir.path().join("source.txt").to_str().unwrap()], - "workspace": ws_dir.path().to_str().unwrap() + "target": { "pe_id": pe_id, "relative_path": "" } }), &token, &csrf, @@ -518,12 +526,20 @@ async fn copy_files_to_workspace_accepts_non_sandbox_source_and_target_roots() { let source_file = source_root.path().join("nested").join("source.txt"); std::fs::write(&source_file, "copy me").unwrap(); + // pe-addressed destination (the workspace dir need not be under file roots). + let created = services + .project_service + .create_standard(aionui_project::canonical::to_file_uri(workspace.path()).unwrap()) + .await + .unwrap(); + let pe_id = created.project_explorer.pe_id; + let req = json_with_token( "POST", "/api/fs/copy", json!({ "file_paths": [source_file.to_str().unwrap()], - "workspace": workspace.path().to_str().unwrap(), + "target": { "pe_id": pe_id, "relative_path": "" }, "source_root": source_root.path().to_str().unwrap() }), &token, diff --git a/crates/aionui-conversation/src/convert.rs b/crates/aionui-conversation/src/convert.rs index 8feade06f..98890181b 100644 --- a/crates/aionui-conversation/src/convert.rs +++ b/crates/aionui-conversation/src/convert.rs @@ -74,6 +74,7 @@ pub fn row_to_response_with_extra( pinned_at: row.pinned_at, channel_chat_id: row.channel_chat_id, assistant: None, + project_id: row.project_id, created_at: row.created_at, modified_at: row.updated_at, extra, @@ -368,6 +369,22 @@ mod tests { assert!(resp.model.is_none()); } + #[test] + fn row_to_response_carries_project_id() { + let row = ConversationRow { + project_id: Some("prj_abc".into()), + ..make_row("acp", "pending", None, None, "{}") + }; + let resp = row_to_response(row, Path::new("/tmp/data")).unwrap(); + assert_eq!(resp.project_id.as_deref(), Some("prj_abc")); + } + + #[test] + fn row_to_response_project_id_none_when_unbound() { + let resp = row_to_response(make_row("acp", "pending", None, None, "{}"), Path::new("/tmp/data")).unwrap(); + assert_eq!(resp.project_id, None); + } + #[test] fn row_to_response_invalid_type() { let row = make_row("invalid", "pending", None, None, "{}"); diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index a1370c0e0..a91911352 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -14,6 +14,7 @@ use crate::message_cursor::{decode_message_cursor, encode_message_cursor}; use crate::runtime_completion::RuntimeCompletionPublisher; use crate::runtime_persistence::{RuntimePersistenceCoordinator, RuntimeWriteKind}; use crate::runtime_state::ConversationRuntimeStateService; +use aionui_api_types::ChatFileRef; use aionui_api_types::{ ApprovalCheckResponse, AssistantConversationOverridesRequest, CancelConversationResponse, CloneConversationRequest, ConfirmRequest, ConfirmationListResponse, ConversationArtifactKind, ConversationArtifactListResponse, @@ -38,7 +39,7 @@ use aionui_db::{ }; use aionui_extension::AssistantRuleDispatcher; use aionui_mcp::{AcpMcpCapabilities, parse_acp_mcp_capabilities}; -use aionui_project::{ProjectService, canonical}; +use aionui_project::{ProjectService, ResolvedChatMessage, canonical}; use aionui_realtime::EventBroadcaster; use aionui_runtime::{RuntimeCommandProbe, probe_node_runtime_supported, probe_runtime_command, resolve_command_path}; use chrono::Datelike; @@ -450,16 +451,20 @@ impl ConversationService { /// Best-effort by contract: a missing service, a bad URI, a resolve /// failure, or an update failure are all logged at `warn` and swallowed. /// This must NEVER affect conversation creation or reads. - async fn bind_project_best_effort(&self, conversation_id: &str, workspace_path: &str) { + /// Returns `true` iff a project binding was actually applied (project_id + /// backfilled + the row update succeeded), so a lazy-read caller can emit a + /// `conversation.listChanged` and let the client refetch the now-bound id. + /// All failure modes return `false` and are swallowed (best-effort contract). + async fn bind_project_best_effort(&self, conversation_id: &str, workspace_path: &str) -> bool { let project_service = self.project_service.read().ok().and_then(|guard| guard.clone()); let Some(project_service) = project_service else { - return; + return false; }; let uri = match canonical::to_file_uri(Path::new(workspace_path)) { Ok(uri) => uri, Err(err) => { warn!(conversation_id = %conversation_id, error = err.code(), "project bind skipped: bad workspace uri"); - return; + return false; } }; match project_service.resolve_existing(uri).await { @@ -470,16 +475,54 @@ impl ConversationService { updated_at: Some(now_ms()), ..Default::default() }; - if let Err(err) = self.conversation_repo.update(conversation_id, &update).await { - warn!(conversation_id = %conversation_id, error = %ErrorChain(&err), "project bind: backfill update failed"); + match self.conversation_repo.update(conversation_id, &update).await { + Ok(_) => true, + Err(err) => { + warn!(conversation_id = %conversation_id, error = %ErrorChain(&err), "project bind: backfill update failed"); + false + } } } Err(err) => { warn!(conversation_id = %conversation_id, error = err.code(), "project bind skipped"); + false } } } + /// Resolve a send's file attachments to absolute paths and re-inline them + /// into the message content (`[[AION_FILES]]` form) at the send boundary. + /// Atomic — a bad reference fails the whole send. Empty `files` is a no-op + /// (content unchanged), so callers without attachments never need the + /// project service. + async fn resolve_message_attachments( + &self, + content: &str, + files: &[ChatFileRef], + ) -> Result { + if files.is_empty() { + return Ok(ResolvedChatMessage { + content: content.to_owned(), + files: Vec::new(), + }); + } + let project = self + .project_service + .read() + .ok() + .and_then(|guard| guard.clone()) + .ok_or_else(|| ConversationError::BadRequest { + reason: "project service unavailable; cannot resolve file attachments".to_owned(), + })?; + let upload_root = std::env::temp_dir().join("aionui"); + project + .resolve_chat_message(content, files, &upload_root) + .await + .map_err(|err| ConversationError::BadRequest { + reason: err.to_string(), + }) + } + pub fn with_assistant_definition_repo(&self, repo: Arc) { if let Ok(mut guard) = self.assistant_definition_repo.write() { *guard = Some(repo); @@ -1806,18 +1849,27 @@ impl ConversationService { let mut extra: serde_json::Value = serde_json::from_str(&row.extra) .map_err(|e| ConversationError::internal(format!("Invalid extra JSON: {e}")))?; self.backfill_extra_inplace(&row.id, &mut extra).await; - // Project-bind side branch: lazily backfill owner binding on read. - if row.project_id.is_none() + // Project-bind side branch: lazily backfill owner binding on read. The + // `row` snapshot predates the backfill, so this response still carries + // the old (null) project_id; on a real None→Some backfill we broadcast + // `conversation.listChanged(updated)` so the client refetches and picks + // up the now-bound project_id (parity with create-time delivery). + let project_backfilled = if row.project_id.is_none() && let Some(workspace) = extra .get("workspace") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) { - self.bind_project_best_effort(&row.id, workspace).await; - } + self.bind_project_best_effort(&row.id, workspace).await + } else { + false + }; let mut response = row_to_response_with_extra(row, extra, &self.workspace_root)?; self.attach_assistant_identity(&mut response).await?; response.runtime = Some(self.runtime_summary_for(id).await); + if project_backfilled { + self.broadcast_list_changed(id, "updated", response.source.as_ref()); + } Ok(response) } @@ -2681,6 +2733,11 @@ impl ConversationService { reject_deprecated_runtime_row(&row)?; + // Resolve file attachments at the send boundary before any persist/claim + // (atomic: a bad reference fails the whole send). Produces the inlined + // `[[AION_FILES]]` content used for persistence, broadcast, and the turn. + let resolved = self.resolve_message_attachments(&req.content, &req.files).await?; + let turn_id = Self::mint_turn_id(); let turn_claim = self.runtime_state.try_claim_turn(conversation_id, &turn_id)?; @@ -2694,7 +2751,7 @@ impl ConversationService { conversation_id: conversation_id.to_owned(), msg_id: Some(user_msg_id.clone()), r#type: "text".into(), - content: serde_json::json!({ "content": req.content }).to_string(), + content: serde_json::json!({ "content": resolved.content }).to_string(), position: Some("right".into()), status: Some("finish".into()), hidden: req.hidden, @@ -2722,7 +2779,7 @@ impl ConversationService { serde_json::json!({ "conversation_id": conversation_id, "msg_id": &user_msg_id, - "content": &req.content, + "content": &resolved.content, "position": "right", "status": "finish", "hidden": req.hidden, @@ -2763,7 +2820,9 @@ impl ConversationService { ConversationTurnOrchestrator::new(self.clone(), Arc::clone(task_manager)).spawn_user_turn(TurnStartInput { user_id: user_id.to_owned(), conversation: row, - request: req, + content: resolved.content, + files: resolved.files, + inject_skills: req.inject_skills, required_runtime_mode: None, build_options: build_opts, stored_workspace, @@ -2882,12 +2941,9 @@ impl ConversationService { .run_user_turn(TurnStartInput { user_id: request.user_id, conversation: row, - request: SendMessageRequest { - content: request.content, - files: request.files, - inject_skills: request.inject_skills, - hidden: request.user_message_hidden, - }, + content: request.content, + files: request.files, + inject_skills: request.inject_skills, required_runtime_mode: request.required_runtime_mode, build_options: build_opts, stored_workspace, @@ -4258,6 +4314,7 @@ mod tests { pinned_at: None, channel_chat_id: None, assistant: None, + project_id: None, created_at: 0, modified_at: 0, extra: json!({}), diff --git a/crates/aionui-conversation/src/turn_orchestrator.rs b/crates/aionui-conversation/src/turn_orchestrator.rs index 05d5b3005..5f6c2054b 100644 --- a/crates/aionui-conversation/src/turn_orchestrator.rs +++ b/crates/aionui-conversation/src/turn_orchestrator.rs @@ -18,7 +18,7 @@ use crate::service::{ use crate::stream_relay::{RelayOutcome, StreamRelay, TurnAttemptSummary}; use crate::turn_continuation_policy::{ContinuationDecision, TurnContinuationPolicy}; use crate::turn_recovery_policy::{TurnRecoveryDecision, TurnRecoveryPolicy}; -use aionui_api_types::{AgentErrorCode, SendMessageRequest}; +use aionui_api_types::AgentErrorCode; fn acp_backend_from_build_options(options: &BuildTaskOptions) -> Option<&str> { match &options.context.kind { @@ -30,7 +30,13 @@ fn acp_backend_from_build_options(options: &BuildTaskOptions) -> Option<&str> { pub(crate) struct TurnStartInput { pub user_id: String, pub conversation: ConversationRow, - pub request: SendMessageRequest, + /// User message content, already resolved to the inlined `[[AION_FILES]]` + /// form (HTTP path resolves `ChatFileRef`s; internal agent turns pass a + /// pre-formed string). + pub content: String, + /// Attachment absolute paths, already resolved. + pub files: Vec, + pub inject_skills: Vec, pub required_runtime_mode: Option, pub build_options: BuildTaskOptions, pub stored_workspace: String, @@ -380,11 +386,11 @@ impl ConversationTurnOrchestrator { let allowed_skill_names = input.build_options.context.skills.clone(); let first_turn_msg_id = ConversationService::mint_msg_id(); let initial_send = SendMessageData { - content: input.request.content, + content: input.content, msg_id: first_turn_msg_id.clone(), turn_id: Some(turn_id.clone()), - files: input.request.files, - inject_skills: input.request.inject_skills, + files: input.files, + inject_skills: input.inject_skills, }; let mut replayed = false; let mut replay_started_at = None; diff --git a/crates/aionui-file/Cargo.toml b/crates/aionui-file/Cargo.toml index fc3c37039..ba199f3f9 100644 --- a/crates/aionui-file/Cargo.toml +++ b/crates/aionui-file/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true [dependencies] aionui-api-types.workspace = true aionui-common.workspace = true +aionui-project.workspace = true aionui-realtime.workspace = true async-trait.workspace = true axum.workspace = true diff --git a/crates/aionui-file/src/routes.rs b/crates/aionui-file/src/routes.rs index ddc0658b8..bdc0ff0c1 100644 --- a/crates/aionui-file/src/routes.rs +++ b/crates/aionui-file/src/routes.rs @@ -93,6 +93,8 @@ pub struct FileRouterState { pub file_service: FileServiceRef, pub watch_service: FileWatchServiceRef, pub snapshot_service: SnapshotServiceRef, + /// Resolves pe-addressed copy targets (`/api/fs/copy`) to absolute dirs. + pub project: Arc, pub allowed_roots: Vec, /// Roots permitted by the shallow `/api/fs/browse` endpoint. This is /// typically wider than `allowed_roots` (it includes `cwd`, Windows @@ -277,9 +279,23 @@ async fn copy_files( body: Result, JsonRejection>, ) -> Result>, ApiError> { let Json(req) = body.map_err(ApiError::from)?; + // Resolve the pe-addressed target to an absolute directory (containment + + // identity via the project service); device file paths are copied into it. + let resolved = state + .project + .resolve_reference(aionui_project::ReferenceInput { + pe_id: req.target.pe_id, + relative_path: req.target.relative_path, + op: aionui_project::FileOp::Write, + }) + .await + .map_err(ApiError::from)?; + let dir = resolved + .absolute_path + .ok_or_else(|| ApiError::BadRequest("copy target is not a local path".to_owned()))?; let result = state .file_service - .copy_files_to_workspace(&req.file_paths, &req.workspace, req.source_root.as_deref()) + .copy_files_to_workspace(&req.file_paths, &dir, req.source_root.as_deref()) .await?; Ok(Json(ApiResponse::ok(to_copy_response(result)))) } diff --git a/crates/aionui-file/src/service.rs b/crates/aionui-file/src/service.rs index 421fc4539..59fe850d2 100644 --- a/crates/aionui-file/src/service.rs +++ b/crates/aionui-file/src/service.rs @@ -765,12 +765,24 @@ impl crate::traits::IFileService for FileService { let mut copied = Vec::new(); let mut failed = Vec::new(); + let mut fail = |fp: &str, reason: &str| { + failed.push(aionui_api_types::CopyFailure { + path: fp.to_owned(), + reason: reason.to_owned(), + }); + }; + for fp in &file_paths_owned { let source_extra = source_root_owned.as_deref().or_else(|| Path::new(fp).parent()); let src = match validate_path_with_extra_root(fp, &roots_refs, source_extra) { Ok(p) if p.is_file() => p, - _ => { - failed.push(fp.clone()); + Ok(_) => { + // Directories are not copied this round (files-only). + fail(fp, "not a file (directories are not supported yet)"); + continue; + } + Err(_) => { + fail(fp, "source is not accessible or outside the allowed roots"); continue; } }; @@ -784,9 +796,14 @@ impl crate::traits::IFileService for FileService { }; let dest = ws_canonical.join(&relative); + // Never silently overwrite: a name collision is a reported failure. + if dest.exists() { + fail(fp, "a file with the same name already exists at the destination"); + continue; + } match copy_single_file_sync(&src, &dest) { Ok(()) => copied.push(fp.clone()), - Err(_) => failed.push(fp.clone()), + Err(_) => fail(fp, "copy failed"), } } diff --git a/crates/aionui-file/src/types.rs b/crates/aionui-file/src/types.rs index 99ff94a31..6cce8f362 100644 --- a/crates/aionui-file/src/types.rs +++ b/crates/aionui-file/src/types.rs @@ -143,7 +143,7 @@ pub enum ZipEntry { #[derive(Debug, Clone)] pub struct CopyResult { pub copied_files: Vec, - pub failed_files: Vec, + pub failed_files: Vec, } #[cfg(test)] diff --git a/crates/aionui-file/tests/file_management.rs b/crates/aionui-file/tests/file_management.rs index 50328aa6a..88a24e13d 100644 --- a/crates/aionui-file/tests/file_management.rs +++ b/crates/aionui-file/tests/file_management.rs @@ -130,7 +130,7 @@ async fn copy_files_partial_failure() { assert_eq!(result.copied_files.len(), 1); assert_eq!(result.failed_files.len(), 1); - assert!(result.failed_files[0].contains("missing.txt")); + assert!(result.failed_files[0].path.contains("missing.txt")); } #[tokio::test] diff --git a/crates/aionui-project/Cargo.toml b/crates/aionui-project/Cargo.toml index 239854323..ab63fe9fb 100644 --- a/crates/aionui-project/Cargo.toml +++ b/crates/aionui-project/Cargo.toml @@ -4,14 +4,24 @@ version.workspace = true edition.workspace = true [dependencies] +aionui-api-types.workspace = true aionui-common.workspace = true aionui-db.workspace = true +axum.workspace = true url.workspace = true chrono.workspace = true thiserror.workspace = true serde.workspace = true +serde_json.workspace = true +base64.workspace = true tracing.workspace = true +# Runtime link (Project Explorer fs runtime): async provider IO + non-recursive watch. +tokio.workspace = true +async-trait.workspace = true +notify.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } tempfile.workspace = true +tower.workspace = true +http-body-util.workspace = true diff --git a/crates/aionui-project/src/canonical.rs b/crates/aionui-project/src/canonical.rs index 693caf5a3..b426af8ea 100644 --- a/crates/aionui-project/src/canonical.rs +++ b/crates/aionui-project/src/canonical.rs @@ -89,12 +89,17 @@ pub fn to_file_uri(path: &Path) -> Result { /// Derive the absolute filesystem path from a `file:` canonical URI. pub fn fs_path(canonical: &Canonical) -> Result { - Url::parse(canonical.as_str()) + uri_to_path(canonical.as_str()) +} + +/// Derive the absolute filesystem path from a raw `file:` URI string. Used by +/// the runtime provider, which receives already-resolved URIs as `&str` (a +/// [`Canonical`] cannot be constructed outside [`canonicalize`]). +pub fn uri_to_path(uri: &str) -> Result { + Url::parse(uri) .ok() .and_then(|u| u.to_file_path().ok()) - .ok_or_else(|| ProjectError::FolderCanonicalizeFailed { - uri: canonical.as_str().to_owned(), - }) + .ok_or_else(|| ProjectError::FolderCanonicalizeFailed { uri: uri.to_owned() }) } /// Lexically resolve `.` / `..` and collapse redundant separators without diff --git a/crates/aionui-project/src/chat_files.rs b/crates/aionui-project/src/chat_files.rs new file mode 100644 index 000000000..3bdf97187 --- /dev/null +++ b/crates/aionui-project/src/chat_files.rs @@ -0,0 +1,112 @@ +//! Resolve chat-message file attachments to absolute device paths. +//! +//! The single shared point where a message's [`ChatFileRef`] list becomes +//! concrete paths, called at the send boundary by conversation + team. It +//! reproduces the legacy `[[AION_FILES]]` inlined-attachment content form so +//! every downstream consumer — aionrs `build_content_blocks`, the ACP prompt +//! (content-only), message persistence, and the user-message file chips in the +//! UI — is unchanged: only the *origin* of the paths moves from the client to +//! this backend edge. + +use std::path::Path; + +use aionui_api_types::ChatFileRef; +use aionui_common::constants::AIONUI_FILES_MARKER; + +use crate::service::ProjectService; +use crate::types::{FileOp, ProjectError, ReferenceInput}; + +/// A chat message whose attachments have been resolved to absolute paths and +/// re-inlined into [`content`](Self::content) via the `[[AION_FILES]]` marker. +#[derive(Debug)] +pub struct ResolvedChatMessage { + /// User text with the attachment block appended (marker + one absolute path + /// per line). Used verbatim for persistence, broadcast, and agent input. + pub content: String, + /// The resolved absolute paths, in order. Kept alongside `content` so + /// aionrs's `build_content_blocks` strips the marker (files match) and + /// re-adds its own; clearing this would leak the raw marker to aionrs. + pub files: Vec, +} + +impl ProjectService { + /// Resolve a message's `files` to absolute paths and return the inlined + /// content. Atomic: any bad reference (unknown pe, escape, missing file, + /// out-of-root upload, unreadable local path) fails the whole message. + /// + /// `upload_root` is the managed upload directory (`temp_dir()/aionui`); + /// `Upload` paths must live under it. + pub async fn resolve_chat_message( + &self, + content: &str, + files: &[ChatFileRef], + upload_root: &Path, + ) -> Result { + let mut paths = Vec::with_capacity(files.len()); + for file in files { + match file { + ChatFileRef::Project { pe_id, relative_path } => { + let resolved = self + .resolve_reference(ReferenceInput { + pe_id: pe_id.clone(), + relative_path: relative_path.clone(), + op: FileOp::Read, + }) + .await?; + let abs = resolved.absolute_path.ok_or_else(|| ProjectError::ChatFileMissing { + path: relative_path.clone(), + })?; + // A project ref may point at a file or a folder (the tree + // allows attaching a directory); require only that it exists. + if !Path::new(&abs).exists() { + return Err(ProjectError::ChatFileMissing { path: abs }); + } + paths.push(abs); + } + ChatFileRef::Upload { path } => { + let candidate = Path::new(path); + if !candidate.is_file() { + return Err(ProjectError::ChatFileMissing { path: path.clone() }); + } + if !path_within(upload_root, candidate) { + return Err(ProjectError::UploadPathOutsideRoot { path: path.clone() }); + } + paths.push(path.clone()); + } + ChatFileRef::Local { path } => { + // A path the user explicitly picked in the host-file browser, + // which already exposes the whole filesystem. No managed-root + // restriction (that is the upload channel's D2 invariant only); + // just canonicalize (collapsing `..`/symlinks) and require an + // existing regular file. + let canonical = std::fs::canonicalize(path) + .map_err(|_| ProjectError::LocalPathNotReadable { path: path.clone() })?; + if !canonical.is_file() { + return Err(ProjectError::LocalPathNotReadable { path: path.clone() }); + } + paths.push(canonical.to_string_lossy().into_owned()); + } + } + } + + let content = if paths.is_empty() { + content.to_owned() + } else { + format!("{content}\n\n{AIONUI_FILES_MARKER}\n{}", paths.join("\n")) + }; + Ok(ResolvedChatMessage { content, files: paths }) + } +} + +/// Whether `target` resolves inside `root` (both canonicalized, so `..` and +/// symlinks cannot escape). `target` is expected to exist (checked before). +fn path_within(root: &Path, target: &Path) -> bool { + let (Ok(root), Ok(target)) = (std::fs::canonicalize(root), std::fs::canonicalize(target)) else { + return false; + }; + target.starts_with(root) +} + +#[cfg(test)] +#[path = "chat_files_test.rs"] +mod chat_files_test; diff --git a/crates/aionui-project/src/chat_files_test.rs b/crates/aionui-project/src/chat_files_test.rs new file mode 100644 index 000000000..9b9c6f7f0 --- /dev/null +++ b/crates/aionui-project/src/chat_files_test.rs @@ -0,0 +1,221 @@ +use std::sync::Arc; + +use aionui_common::constants::AIONUI_FILES_MARKER; +use aionui_db::{IProjectStore, SqliteProjectStore, init_database_memory}; +use tempfile::TempDir; + +use crate::ProjectService; +use crate::canonical::to_file_uri; +use crate::types::ProjectError; +use aionui_api_types::ChatFileRef; + +/// Build a service with a tempdir standard project. Returns (service, pe_id, +/// workspace dir, upload_root dir). +async fn setup() -> (Arc, String, TempDir, TempDir) { + let db = init_database_memory().await.unwrap(); + let store: Arc = Arc::new(SqliteProjectStore::new(db.pool().clone())); + let service = Arc::new(ProjectService::new(Arc::clone(&store), std::env::temp_dir())); + let dir = tempfile::tempdir().unwrap(); + let created = service.create_standard(to_file_uri(dir.path()).unwrap()).await.unwrap(); + let upload_root = tempfile::tempdir().unwrap(); + (service, created.project_explorer.pe_id, dir, upload_root) +} + +#[tokio::test] +async fn resolves_project_file_and_inlines_marker() { + let (service, pe_id, dir, upload_root) = setup().await; + std::fs::write(dir.path().join("note.txt"), b"hi").unwrap(); + + let out = service + .resolve_chat_message( + "please review", + &[ChatFileRef::Project { + pe_id: pe_id.clone(), + relative_path: "note.txt".into(), + }], + upload_root.path(), + ) + .await + .unwrap(); + + // The resolved path is the canonicalized absolute path (case-folded on + // case-insensitive platforms), so assert on shape/resolution rather than a + // byte-equal path, and that content re-inlines exactly that path. + assert_eq!(out.files.len(), 1); + let abs = &out.files[0]; + assert!(std::path::Path::new(abs).is_file()); + assert!(abs.ends_with("note.txt")); + assert_eq!(out.content, format!("please review\n\n{AIONUI_FILES_MARKER}\n{abs}")); +} + +#[tokio::test] +async fn resolves_project_directory_ref() { + let (service, pe_id, dir, upload_root) = setup().await; + std::fs::create_dir(dir.path().join("sub")).unwrap(); + + // A folder attachment (tree right-click on a directory) must resolve, not + // be rejected as a missing file. + let out = service + .resolve_chat_message( + "look here", + &[ChatFileRef::Project { + pe_id, + relative_path: "sub".into(), + }], + upload_root.path(), + ) + .await + .unwrap(); + assert_eq!(out.files.len(), 1); + assert!(std::path::Path::new(&out.files[0]).is_dir()); +} + +#[tokio::test] +async fn empty_files_leaves_content_unchanged() { + let (service, _pe, _dir, upload_root) = setup().await; + let out = service + .resolve_chat_message("hi", &[], upload_root.path()) + .await + .unwrap(); + assert_eq!(out.content, "hi"); + assert!(out.files.is_empty()); +} + +#[tokio::test] +async fn missing_project_file_is_atomic_error() { + let (service, pe_id, _dir, upload_root) = setup().await; + let err = service + .resolve_chat_message( + "x", + &[ChatFileRef::Project { + pe_id, + relative_path: "nope.txt".into(), + }], + upload_root.path(), + ) + .await + .unwrap_err(); + assert!(matches!(err, ProjectError::ChatFileMissing { .. }), "got {err:?}"); +} + +#[tokio::test] +async fn upload_under_root_is_accepted() { + let (service, _pe, _dir, upload_root) = setup().await; + let up = upload_root.path().join("u.png"); + std::fs::write(&up, b"x").unwrap(); + let path = up.to_string_lossy().into_owned(); + + let out = service + .resolve_chat_message("", &[ChatFileRef::Upload { path: path.clone() }], upload_root.path()) + .await + .unwrap(); + assert_eq!(out.files, vec![path]); +} + +#[tokio::test] +async fn local_readable_file_resolves_and_inlines_marker() { + let (service, _pe, _dir, upload_root) = setup().await; + // A file anywhere on disk (outside the managed upload root) — `local` has no + // managed-directory restriction, only existence + is-file. + let outside = tempfile::tempdir().unwrap(); + let f = outside.path().join("host.txt"); + std::fs::write(&f, b"hi").unwrap(); + let path = f.to_string_lossy().into_owned(); + + let out = service + .resolve_chat_message("see this", &[ChatFileRef::Local { path }], upload_root.path()) + .await + .unwrap(); + + assert_eq!(out.files.len(), 1); + let abs = &out.files[0]; + // Resolved to the canonicalized absolute path (symlinks/`..` collapsed). + assert!(std::path::Path::new(abs).is_file()); + assert!(abs.ends_with("host.txt")); + assert_eq!(out.content, format!("see this\n\n{AIONUI_FILES_MARKER}\n{abs}")); +} + +#[cfg(unix)] +#[tokio::test] +async fn local_canonicalizes_symlink_to_target_path() { + let (service, _pe, _dir, upload_root) = setup().await; + // A symlink whose name differs from its target, so we can prove the + // resolved path is the *target* (canonicalized), not the link we were given. + let d = tempfile::tempdir().unwrap(); + let target = d.path().join("real_target.txt"); + std::fs::write(&target, b"hi").unwrap(); + let link = d.path().join("link_name.txt"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let link_path = link.to_string_lossy().into_owned(); + + let out = service + .resolve_chat_message( + "x", + &[ChatFileRef::Local { + path: link_path.clone(), + }], + upload_root.path(), + ) + .await + .unwrap(); + + assert_eq!(out.files.len(), 1); + let resolved = &out.files[0]; + // `canonicalize` collapses the symlink to the target's real path — this is + // the behavior a `PathBuf::from(path)` mutation would break. + let expected = std::fs::canonicalize(&target).unwrap().to_string_lossy().into_owned(); + assert_eq!(resolved, &expected, "expected canonicalized target, got {resolved}"); + assert!( + resolved.ends_with("real_target.txt"), + "should be target name, not link name" + ); + assert_ne!(resolved, &link_path, "must not echo back the raw symlink path"); + assert_eq!(out.content, format!("x\n\n{AIONUI_FILES_MARKER}\n{resolved}")); +} + +#[tokio::test] +async fn local_nonexistent_is_rejected() { + let (service, _pe, _dir, upload_root) = setup().await; + let missing = upload_root.path().join("nope.txt").to_string_lossy().into_owned(); + + let err = service + .resolve_chat_message("x", &[ChatFileRef::Local { path: missing }], upload_root.path()) + .await + .unwrap_err(); + assert!(matches!(err, ProjectError::LocalPathNotReadable { .. }), "got {err:?}"); +} + +#[tokio::test] +async fn local_directory_is_rejected() { + let (service, _pe, _dir, upload_root) = setup().await; + // A real directory is not a regular file → rejected. + let d = tempfile::tempdir().unwrap(); + let path = d.path().to_string_lossy().into_owned(); + + let err = service + .resolve_chat_message("x", &[ChatFileRef::Local { path }], upload_root.path()) + .await + .unwrap_err(); + assert!(matches!(err, ProjectError::LocalPathNotReadable { .. }), "got {err:?}"); +} + +#[tokio::test] +async fn upload_outside_root_is_rejected() { + let (service, _pe, _dir, upload_root) = setup().await; + // A real file, but outside the managed upload dir. + let outside = tempfile::tempdir().unwrap(); + let ext = outside.path().join("secret.txt"); + std::fs::write(&ext, b"x").unwrap(); + + let err = service + .resolve_chat_message( + "x", + &[ChatFileRef::Upload { + path: ext.to_string_lossy().into_owned(), + }], + upload_root.path(), + ) + .await + .unwrap_err(); + assert!(matches!(err, ProjectError::UploadPathOutsideRoot { .. }), "got {err:?}"); +} diff --git a/crates/aionui-project/src/lib.rs b/crates/aionui-project/src/lib.rs index 99b675c95..01e8b70c2 100644 --- a/crates/aionui-project/src/lib.rs +++ b/crates/aionui-project/src/lib.rs @@ -9,10 +9,16 @@ //! critical and filesystem-free; the rest is service orchestration. pub mod canonical; +pub mod chat_files; pub mod containment; +pub mod monitor; +pub mod routes; +pub mod runtime; mod service; pub mod types; +pub use chat_files::ResolvedChatMessage; +pub use routes::{ProjectRouterState, project_routes}; pub use service::ProjectService; pub use types::{ AttachInput, FileOp, FolderDto, ProjectDetail, ProjectError, ProjectExplorerEntry, ProjectExplorerView, diff --git a/crates/aionui-project/src/monitor/actor.rs b/crates/aionui-project/src/monitor/actor.rs new file mode 100644 index 000000000..f08ceeab9 --- /dev/null +++ b/crates/aionui-project/src/monitor/actor.rs @@ -0,0 +1,208 @@ +//! The monitor actor — a single sequential worker owning the runtime [`Shard`]. +//! +//! Desktop runs one actor (N = 1): it owns the shard (tree + subscriptions), +//! the debounce buffer, and the `file:` runtime, and drives every state change +//! serially. Its event loop multiplexes four sources — inbound WS frames, +//! watcher raw events, the debounce-flush timer, and the grace-reap timer — so +//! the stage-0 pure cores (`Shard::handle` / `Debouncer` / `raw_to_command`) +//! run against a real clock without any timers living in their unit tests. +//! +//! Request dispatch (initialize / fs commands) lives in [`super::dispatch`]. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::time::{Interval, interval}; + +use crate::ProjectService; +use crate::runtime::{ + Command, Debouncer, FsError, FsRuntimeRegistry, IFsRuntime, LocalFsRuntime, RawEvent, Shard, ShardOutput, TreeModel, +}; + +use super::port::{FsInbound, FsWirePush}; +use super::wire; + +/// Debounce-flush cadence: a burst of watcher events within this window +/// coalesces into one apply per canonical (see `runtime.md` pipeline). +const DEBOUNCE_FLUSH_MS: u64 = 200; + +/// Grace-reap cadence: how often expired warm nodes are swept. Coarser than the +/// grace TTL (`GRACE_TTL_MS`, 5 min) — a minute of slack on eviction is fine. +const REAP_INTERVAL_MS: u64 = 60_000; + +/// The monitor actor: owns the shard, debounce buffer, `file:` runtime handle +/// (for command IO), the resolve-reference service, and the outbound push port. +pub struct FsMonitorActor { + shard: Shard, + debouncer: Debouncer, + /// Kept alongside the shard's registry-owned copy so commands + /// (read/write/...) reach the provider without going through the tree. + runtime: Arc, + project: Arc, + push: Arc, + /// Monotonic origin; `now()` is elapsed millis (immune to wall-clock jumps). + clock: Instant, +} + +impl FsMonitorActor { + /// Build the actor and the watcher raw-event receiver its loop consumes. + /// Registers a single `file:` runtime (desktop N = 1). + pub fn new( + project: Arc, + push: Arc, + warm_budget: usize, + ) -> Result<(Self, UnboundedReceiver), FsError> { + let (runtime, raw_rx) = LocalFsRuntime::new()?; + let runtime: Arc = Arc::new(runtime); + let mut registry = FsRuntimeRegistry::new(); + registry.register("file", Arc::clone(&runtime)); + let shard = Shard::new(TreeModel::new(registry), warm_budget); + let actor = Self { + shard, + debouncer: Debouncer::new(), + runtime, + project, + push, + clock: Instant::now(), + }; + Ok((actor, raw_rx)) + } + + /// Provider handle for command-path IO (read/write/mkdir/remove/rename). + pub(super) fn runtime(&self) -> &dyn IFsRuntime { + self.runtime.as_ref() + } + + /// Resolve-reference service (identity + lexical containment). + pub(super) fn project(&self) -> &ProjectService { + &self.project + } + + /// Outbound push port. + pub(super) fn push(&self, session: &str, frame: serde_json::Value) { + self.push.push(session, frame); + } + + /// Current logical time: monotonic millis since actor start. + pub(super) fn now(&self) -> u64 { + self.clock.elapsed().as_millis() as u64 + } + + /// Feed a command through the shard and return its raw outputs (no fan-out). + /// Used by request dispatch that must place snapshots in a reply rather than + /// push them as notifications (e.g. `fs/subscribe`). + pub(super) async fn shard_handle(&mut self, command: Command) -> Result, FsError> { + self.shard.handle(command).await + } + + /// Feed a command through the shard and fan any outputs out to the wire. + /// Errors are logged, never fatal to the loop. + pub(super) async fn drive(&mut self, command: Command) { + // Lifecycle/flow trace. Overflow (kernel dropped events → rescan) is a + // low-volume, production-diagnostic boundary → info; the affected watched + // dir (an absolute uri) stays at debug. High-frequency apply → debug. + match &command { + Command::Overflow { canonical } => { + tracing::info!("fs overflow: watcher dropped events, rescanning watched dir"); + tracing::debug!(canonical = %canonical, "fs overflow rescan target"); + } + Command::Apply { canonical, .. } => tracing::debug!(canonical = %canonical, "fs apply"), + _ => {} + } + match self.shard.handle(command).await { + Ok(outputs) => self.fan_out(outputs), + Err(err) => tracing::warn!(error = %err, "fs monitor: shard command failed"), + } + } + + /// Run the event loop until the inbound channel closes. Consumes `self`. + pub async fn run(mut self, mut inbound: UnboundedReceiver, mut raw_rx: UnboundedReceiver) { + let mut flush: Interval = interval(Duration::from_millis(DEBOUNCE_FLUSH_MS)); + let mut reap: Interval = interval(Duration::from_millis(REAP_INTERVAL_MS)); + loop { + tokio::select! { + inbound_event = inbound.recv() => match inbound_event { + Some(event) => self.on_inbound(event).await, + // All senders dropped (router + app shutdown) → stop. + None => break, + }, + // The watcher's sender lives inside `self.runtime`, so this + // receiver stays open for the actor's whole life. + raw = raw_rx.recv() => if let Some(raw) = raw { + self.debouncer.push(raw); + }, + _ = flush.tick() => self.flush_debounced().await, + _ = reap.tick() => self.drive(Command::ReapTick { now: self.now() }).await, + } + } + } + + /// Handle one inbound transport event. + async fn on_inbound(&mut self, event: FsInbound) { + match event { + FsInbound::Frame { session, frame } => self.dispatch_frame(&session, frame).await, + FsInbound::Disconnect { session } => { + // Connection teardown — lifecycle boundary; releases the session's + // subscriptions (nodes go warm, reaper unmounts later). + tracing::info!(session = %session, "fs session disconnect"); + let now = self.now(); + self.drive(Command::DropSession { session, now }).await; + } + } + } + + /// Drain the debounce buffer into coalesced apply/overflow commands. + async fn flush_debounced(&mut self) { + if self.debouncer.is_empty() { + return; + } + for command in self.debouncer.drain() { + self.drive(command).await; + } + } + + /// Translate canonical-domain shard outputs into pe-keyed notifications and + /// unicast each to its subscriber (scoped push — never a broadcast). + fn fan_out(&self, outputs: Vec) { + for output in outputs { + match output { + ShardOutput::Snapshot { subscribers, snapshot } => { + // High-frequency fan-out → debug; counts + subscriber count only. + tracing::debug!( + subscribers = subscribers.len(), + entries = snapshot.entries.len(), + "fs snapshot fan-out" + ); + for sub in &subscribers { + let target = wire::ResourceRef { + pe_id: sub.pe_id.clone(), + relative_path: sub.rel.clone(), + }; + let params = wire::snapshot_params(&snapshot, &target); + self.push.push(&sub.session, wire::notification("fs/snapshot", params)); + } + } + ShardOutput::Delta { subscribers, delta } => { + tracing::debug!( + subscribers = subscribers.len(), + changes = delta.changes.len(), + "fs delta fan-out" + ); + for sub in &subscribers { + let target = wire::ResourceRef { + pe_id: sub.pe_id.clone(), + relative_path: sub.rel.clone(), + }; + let params = wire::delta_params(&delta, &target); + self.push.push(&sub.session, wire::notification("fs/delta", params)); + } + } + } + } + } +} + +#[cfg(test)] +#[path = "actor_test.rs"] +mod actor_test; diff --git a/crates/aionui-project/src/monitor/actor_test.rs b/crates/aionui-project/src/monitor/actor_test.rs new file mode 100644 index 000000000..ae77c0bf5 --- /dev/null +++ b/crates/aionui-project/src/monitor/actor_test.rs @@ -0,0 +1,575 @@ +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use aionui_db::{Database, IProjectStore, SqliteProjectStore, init_database_memory}; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel}; + +use crate::ProjectService; +use crate::canonical::to_file_uri; +use crate::monitor::{FsInbound, FsMonitorActor, FsWirePush}; +use crate::runtime::{EntryFact, Kind, RawEvent, ShardOutput, Snapshot, Subscriber}; + +// ── recording push port ──────────────────────────────────────────────────── + +/// An [`FsWirePush`] that records every `(session, frame)` for assertions. +#[derive(Clone, Default)] +struct RecordingPush { + sent: Arc>>, +} + +impl FsWirePush for RecordingPush { + fn push(&self, session: &str, frame: Value) { + self.sent.lock().unwrap().push((session.to_owned(), frame)); + } +} + +impl RecordingPush { + fn frames(&self) -> Vec<(String, Value)> { + self.sent.lock().unwrap().clone() + } + /// Last frame delivered to `session`. + fn last_for(&self, session: &str) -> Option { + self.sent + .lock() + .unwrap() + .iter() + .rev() + .find(|(s, _)| s == session) + .map(|(_, f)| f.clone()) + } +} + +// ── harness ───────────────────────────────────────────────────────────────── + +/// Build an actor over a real in-memory-DB `ProjectService` + a fresh tempdir +/// registered as a standard project. Returns the pe_id of that workspace root. +async fn setup() -> ( + FsMonitorActor, + UnboundedReceiver, + RecordingPush, + String, + TempDir, + Database, +) { + let db = init_database_memory().await.unwrap(); + let store: Arc = Arc::new(SqliteProjectStore::new(db.pool().clone())); + let service = Arc::new(ProjectService::new(Arc::clone(&store), std::env::temp_dir())); + + let dir = tempfile::tempdir().unwrap(); + let created = service.create_standard(to_file_uri(dir.path()).unwrap()).await.unwrap(); + let pe_id = created.project_explorer.pe_id; + + let push = RecordingPush::default(); + let (actor, raw_rx) = FsMonitorActor::new(service, Arc::new(push.clone()), 4096).unwrap(); + (actor, raw_rx, push, pe_id, dir, db) +} + +fn request(id: i64, method: &str, params: Value) -> Value { + json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}) +} + +fn dir_ref(pe_id: &str, rel: &str) -> Value { + json!({"pe_id":pe_id,"relative_path":rel}) +} + +/// The folded canonical the actor keys a directory on (matches what Subscribe +/// derives from `resolve_reference` → `canonicalize`). Lets a test inject a +/// synthetic watcher event for a mounted node. +fn canon(path: &std::path::Path) -> String { + let uri = to_file_uri(path).unwrap(); + crate::canonical::canonicalize(&uri).unwrap().as_str().to_owned() +} + +// ══ dispatch-level tests (deterministic, no timers, cross-platform) ══════════ + +#[tokio::test] +async fn initialize_negotiates_version() { + let (mut actor, _rx, push, _pe, _dir, _db) = setup().await; + actor + .dispatch_frame("1", request(0, "initialize", json!({"protocol_version": 1}))) + .await; + let reply = push.last_for("1").unwrap(); + assert_eq!(reply["id"], 0); + assert_eq!(reply["result"]["protocol_version"], 1); +} + +#[tokio::test] +async fn initialize_rejects_unsupported_version() { + let (mut actor, _rx, push, _pe, _dir, _db) = setup().await; + actor + .dispatch_frame("1", request(0, "initialize", json!({"protocol_version": 0}))) + .await; + let reply = push.last_for("1").unwrap(); + assert_eq!(reply["error"]["code"], -32010); + assert_eq!(reply["error"]["message"], "protocol_version_unsupported"); +} + +#[tokio::test] +async fn subscribe_root_returns_baseline_snapshot() { + let (mut actor, _rx, push, pe, dir, _db) = setup().await; + std::fs::create_dir(dir.path().join("src")).unwrap(); + std::fs::write(dir.path().join("README.md"), b"x").unwrap(); + + actor + .dispatch_frame("1", request(1, "fs/subscribe", json!({"targets":[dir_ref(&pe, "")]}))) + .await; + + let reply = push.last_for("1").unwrap(); + assert_eq!(reply["id"], 1); + let snaps = reply["result"]["snapshots"].as_array().unwrap(); + assert_eq!(snaps.len(), 1); + assert_eq!(snaps[0]["target"], dir_ref(&pe, "")); + let names: Vec<&str> = snaps[0]["entries"] + .as_array() + .unwrap() + .iter() + .map(|e| e["name"].as_str().unwrap()) + .collect(); + assert!(names.contains(&"src")); + assert!(names.contains(&"README.md")); + // canonical / absolute path must never leak. + assert!( + reply.to_string().find("file://").is_none(), + "no canonical uri on the wire: {reply}" + ); +} + +#[tokio::test] +async fn subscribe_multiple_targets_returns_snapshot_per_target() { + let (mut actor, _rx, push, pe, dir, _db) = setup().await; + std::fs::create_dir(dir.path().join("src")).unwrap(); + std::fs::write(dir.path().join("src").join("main.ts"), b"x").unwrap(); + + // Array subscribe (root + a child dir) → one snapshot per target, in order. + actor + .dispatch_frame( + "1", + request( + 1, + "fs/subscribe", + json!({"targets":[dir_ref(&pe, ""), dir_ref(&pe, "src")]}), + ), + ) + .await; + + let reply = push.last_for("1").unwrap(); + let snaps = reply["result"]["snapshots"].as_array().unwrap(); + assert_eq!(snaps.len(), 2); + assert_eq!(snaps[0]["target"], dir_ref(&pe, "")); + assert_eq!(snaps[1]["target"], dir_ref(&pe, "src")); + let src_names: Vec<&str> = snaps[1]["entries"] + .as_array() + .unwrap() + .iter() + .map(|e| e["name"].as_str().unwrap()) + .collect(); + assert_eq!(src_names, vec!["main.ts"]); +} + +#[tokio::test] +async fn subscribe_unknown_pe_is_out_of_scope() { + let (mut actor, _rx, push, _pe, _dir, _db) = setup().await; + actor + .dispatch_frame( + "1", + request(2, "fs/subscribe", json!({"targets":[dir_ref("pe-nope", "")]})), + ) + .await; + let reply = push.last_for("1").unwrap(); + assert_eq!(reply["error"]["code"], -32000); + assert_eq!(reply["error"]["message"], "out_of_scope"); + assert_eq!(reply["error"]["data"]["pe_id"], "pe-nope"); +} + +#[tokio::test] +async fn subscribe_parent_escape_is_invalid_relative_path() { + let (mut actor, _rx, push, pe, _dir, _db) = setup().await; + actor + .dispatch_frame( + "1", + request(3, "fs/subscribe", json!({"targets":[dir_ref(&pe, "../escape")]})), + ) + .await; + let reply = push.last_for("1").unwrap(); + assert_eq!(reply["error"]["code"], -32004); + assert_eq!(reply["error"]["message"], "invalid_relative_path"); +} + +#[tokio::test] +async fn read_existing_file_returns_utf8() { + let (mut actor, _rx, push, pe, dir, _db) = setup().await; + std::fs::write(dir.path().join("a.txt"), b"hello").unwrap(); + + actor + .dispatch_frame("1", request(4, "fs/read", json!({"file":dir_ref(&pe, "a.txt")}))) + .await; + let reply = push.last_for("1").unwrap(); + assert_eq!(reply["result"]["content"], "hello"); + assert_eq!(reply["result"]["encoding"], "utf-8"); +} + +#[tokio::test] +async fn read_missing_file_is_resource_not_found() { + let (mut actor, _rx, push, pe, _dir, _db) = setup().await; + actor + .dispatch_frame("1", request(5, "fs/read", json!({"file":dir_ref(&pe, "missing.txt")}))) + .await; + let reply = push.last_for("1").unwrap(); + assert_eq!(reply["error"]["code"], -32002); + assert_eq!(reply["error"]["message"], "resource_not_found"); + assert_eq!(reply["error"]["data"]["relative_path"], "missing.txt"); +} + +#[tokio::test] +async fn read_non_utf8_falls_back_to_base64() { + let (mut actor, _rx, push, pe, dir, _db) = setup().await; + std::fs::write(dir.path().join("bin"), [0xff, 0xfe, 0x00]).unwrap(); + actor + .dispatch_frame("1", request(6, "fs/read", json!({"file":dir_ref(&pe, "bin")}))) + .await; + let reply = push.last_for("1").unwrap(); + assert_eq!(reply["result"]["encoding"], "base64"); + assert!(!reply["result"]["content"].as_str().unwrap().is_empty()); +} + +#[tokio::test] +async fn write_then_read_roundtrip() { + let (mut actor, _rx, push, pe, _dir, _db) = setup().await; + actor + .dispatch_frame( + "1", + request( + 7, + "fs/write", + json!({"file":dir_ref(&pe, "new.txt"),"content":"written"}), + ), + ) + .await; + assert!(push.last_for("1").unwrap()["result"].is_object()); + + actor + .dispatch_frame("1", request(8, "fs/read", json!({"file":dir_ref(&pe, "new.txt")}))) + .await; + assert_eq!(push.last_for("1").unwrap()["result"]["content"], "written"); +} + +#[tokio::test] +async fn write_base64_decodes_to_bytes() { + let (mut actor, _rx, push, pe, dir, _db) = setup().await; + // base64("hi") = "aGk=" + actor + .dispatch_frame( + "1", + request( + 9, + "fs/write", + json!({"file":dir_ref(&pe, "b.bin"),"content":"aGk=","encoding":"base64"}), + ), + ) + .await; + assert!(push.last_for("1").unwrap()["result"].is_object()); + assert_eq!(std::fs::read(dir.path().join("b.bin")).unwrap(), b"hi"); +} + +#[tokio::test] +async fn mkdir_then_remove_roundtrip() { + let (mut actor, _rx, push, pe, dir, _db) = setup().await; + actor + .dispatch_frame("1", request(10, "fs/mkdir", json!({"dir":dir_ref(&pe, "sub")}))) + .await; + assert!(dir.path().join("sub").is_dir()); + + actor + .dispatch_frame("1", request(11, "fs/remove", json!({"target":dir_ref(&pe, "sub")}))) + .await; + assert!(push.last_for("1").unwrap()["result"].is_object()); + assert!(!dir.path().join("sub").exists()); +} + +#[tokio::test] +async fn rename_moves_entry() { + let (mut actor, _rx, push, pe, dir, _db) = setup().await; + std::fs::write(dir.path().join("old.txt"), b"x").unwrap(); + actor + .dispatch_frame( + "1", + request( + 12, + "fs/rename", + json!({"from":dir_ref(&pe, "old.txt"),"to":dir_ref(&pe, "renamed.txt")}), + ), + ) + .await; + assert!(push.last_for("1").unwrap()["result"].is_object()); + assert!(!dir.path().join("old.txt").exists()); + assert!(dir.path().join("renamed.txt").exists()); +} + +#[tokio::test] +async fn mkdir_existing_dir_is_provider_unavailable() { + let (mut actor, _rx, push, pe, dir, _db) = setup().await; + std::fs::create_dir(dir.path().join("sub")).unwrap(); + + // mkdir over an existing dir → AlreadyExists → provider_unavailable (-32006). + // Platform-independent trigger of the command→FsError→code wiring. + actor + .dispatch_frame("1", request(30, "fs/mkdir", json!({"dir":dir_ref(&pe, "sub")}))) + .await; + let reply = push.last_for("1").unwrap(); + assert_eq!(reply["error"]["code"], -32006); + assert_eq!(reply["error"]["message"], "provider_unavailable"); + assert_eq!(reply["error"]["data"]["relative_path"], "sub"); +} + +#[tokio::test] +async fn initialize_bad_params_is_invalid_params() { + let (mut actor, _rx, push, _pe, _dir, _db) = setup().await; + actor + .dispatch_frame("1", request(31, "initialize", json!({"wrong": "shape"}))) + .await; + let reply = push.last_for("1").unwrap(); + assert_eq!(reply["error"]["code"], -32602); +} + +#[tokio::test] +async fn unknown_method_is_method_not_found() { + let (mut actor, _rx, push, _pe, _dir, _db) = setup().await; + actor.dispatch_frame("1", request(13, "fs/teleport", json!({}))).await; + let reply = push.last_for("1").unwrap(); + assert_eq!(reply["error"]["code"], -32601); +} + +#[tokio::test] +async fn malformed_frame_is_invalid_request() { + let (mut actor, _rx, push, _pe, _dir, _db) = setup().await; + // No `method` field → not a valid JSON-RPC request. + actor.dispatch_frame("1", json!({"jsonrpc":"2.0","id":1})).await; + let reply = push.last_for("1").unwrap(); + assert_eq!(reply["error"]["code"], -32600); +} + +#[tokio::test] +async fn unsubscribe_is_notification_no_reply() { + let (mut actor, _rx, push, pe, _dir, _db) = setup().await; + actor + .dispatch_frame("1", request(0, "fs/subscribe", json!({"targets":[dir_ref(&pe, "")]}))) + .await; + let before = push.frames().len(); + // notification (the id is ignored by unsubscribe; it emits no response) + actor + .dispatch_frame( + "1", + json!({"jsonrpc":"2.0","method":"fs/unsubscribe","params":{"targets":[dir_ref(&pe, "")]}}), + ) + .await; + assert_eq!(push.frames().len(), before, "unsubscribe must not reply"); +} + +/// realpath containment: a symlink escaping the folder root is rejected before +/// IO. Unix-only — creating a symlink on Windows needs elevated privilege; the +/// `realpath_within` logic itself is platform-agnostic (walks the deepest +/// existing ancestor), exercised on unix here and noted in the test report. +#[cfg(unix)] +#[tokio::test] +async fn command_symlink_escape_is_resource_outside_folder() { + let (mut actor, _rx, push, pe, dir, _db) = setup().await; + let outside = tempfile::tempdir().unwrap(); + std::fs::write(outside.path().join("secret.txt"), b"top secret").unwrap(); + // A symlink inside the root pointing at the outside dir. + std::os::unix::fs::symlink(outside.path(), dir.path().join("link")).unwrap(); + + actor + .dispatch_frame( + "1", + request(20, "fs/read", json!({"file":dir_ref(&pe, "link/secret.txt")})), + ) + .await; + let reply = push.last_for("1").unwrap(); + assert_eq!(reply["error"]["code"], -32003); + assert_eq!(reply["error"]["message"], "resource_outside_folder"); +} + +/// The overflow rescan path emits `ShardOutput::Snapshot` fanned out (not placed +/// in a reply, unlike subscribe). Drive `fan_out` directly with a synthetic +/// snapshot to two subscribers on different sessions and assert each receives an +/// `fs/snapshot` keyed to *its own* pe-relative target (scoped translation). +#[tokio::test] +async fn fan_out_snapshot_is_scoped_and_pe_keyed_per_subscriber() { + let (actor, _rx, push, _pe, _dir, _db) = setup().await; + let snapshot = Snapshot { + canonical: "file:///backend/only".to_owned(), + entries: vec![( + "a.txt".to_owned(), + EntryFact { + kind: Kind::File, + inode: 1, + symlink_target: None, + }, + )], + }; + let outputs = vec![ShardOutput::Snapshot { + subscribers: vec![ + Subscriber { + session: "1".to_owned(), + pe_id: "pe1".to_owned(), + rel: "src".to_owned(), + }, + Subscriber { + session: "2".to_owned(), + pe_id: "pe9".to_owned(), + rel: String::new(), + }, + ], + snapshot, + }]; + + actor.fan_out(outputs); + + let f1 = push.last_for("1").unwrap(); + assert_eq!(f1["method"], "fs/snapshot"); + assert_eq!(f1["params"]["target"], json!({"pe_id":"pe1","relative_path":"src"})); + assert_eq!(f1["params"]["entries"][0]["name"], "a.txt"); + // Same canonical fact, but session 2 sees its own pe-relative identity. + let f2 = push.last_for("2").unwrap(); + assert_eq!(f2["params"]["target"], json!({"pe_id":"pe9","relative_path":""})); + // Backend canonical never crosses the wire. + assert!(!f1.to_string().contains("backend/only")); +} + +// ══ event-loop tests (timed, real watcher) ═══════════════════════════════════ + +/// Poll `pred` against recorded frames until it holds or the deadline elapses. +async fn wait_until(push: &RecordingPush, within: Duration, pred: impl Fn(&[(String, Value)]) -> bool) -> bool { + tokio::time::timeout(within, async { + loop { + if pred(&push.frames()) { + return true; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .unwrap_or(false) +} + +fn has_delta_adding(frames: &[(String, Value)], session: &str, name: &str) -> bool { + frames.iter().any(|(s, f)| { + s == session + && f["method"] == "fs/delta" + && f["params"]["changes"] + .as_array() + .map(|cs| cs.iter().any(|c| c["op"] == "added" && c["name"] == name)) + .unwrap_or(false) + }) +} + +#[tokio::test] +async fn live_change_fans_delta_to_subscriber_only() { + let (actor, raw_rx, push, pe, dir, _db) = setup().await; + let (tx, rx) = unbounded_channel(); + let handle = tokio::spawn(actor.run(rx, raw_rx)); + + // Session 1 subscribes the root; session 2 stays silent (scoped-push check). + tx.send(FsInbound::Frame { + session: "1".to_owned(), + frame: request(1, "fs/subscribe", json!({"targets":[dir_ref(&pe, "")]})), + }) + .unwrap(); + // Let subscribe mount + arm the watch. + tokio::time::sleep(Duration::from_millis(250)).await; + + std::fs::write(dir.path().join("live.ts"), b"x").unwrap(); + + assert!( + wait_until(&push, Duration::from_secs(5), |f| has_delta_adding(f, "1", "live.ts")).await, + "subscriber 1 must receive an fs/delta adding live.ts" + ); + // Scoped push: session 2 (never subscribed) must have received nothing. + assert!( + !push.frames().iter().any(|(s, _)| s == "2"), + "non-subscriber must receive no push" + ); + + drop(tx); + let _ = handle.await; +} + +#[tokio::test] +async fn overflow_fans_full_snapshot_through_event_loop() { + // Drive a real event loop, but feed the raw-event channel ourselves (ignore + // the watcher's) so we can inject a synthetic kernel overflow deterministically. + let (actor, _watcher_rx, push, pe, dir, _db) = setup().await; + let (tx, rx) = unbounded_channel(); + let (raw_tx, raw_rx) = unbounded_channel::(); + let handle = tokio::spawn(actor.run(rx, raw_rx)); + + tx.send(FsInbound::Frame { + session: "1".to_owned(), + frame: request(1, "fs/subscribe", json!({"targets":[dir_ref(&pe, "")]})), + }) + .unwrap(); + tokio::time::sleep(Duration::from_millis(250)).await; + + // Files a rescan (apply All) will pick up. + std::fs::write(dir.path().join("x.ts"), b"x").unwrap(); + std::fs::write(dir.path().join("y.ts"), b"y").unwrap(); + + // Inject a kernel overflow for the subscribed root → rescan → full snapshot. + raw_tx + .send(RawEvent::Overflow { + canonical: canon(dir.path()), + }) + .unwrap(); + + let got_snapshot = wait_until(&push, Duration::from_secs(5), |frames| { + frames.iter().any(|(s, m)| { + s == "1" + && m["method"] == "fs/snapshot" + && m["params"]["entries"] + .as_array() + .map(|es| es.iter().any(|e| e["name"] == "x.ts")) + .unwrap_or(false) + }) + }) + .await; + assert!(got_snapshot, "overflow must push a full fs/snapshot through the loop"); + + drop(tx); + let _ = handle.await; +} + +#[tokio::test] +async fn disconnect_drops_session_subscriptions() { + let (actor, raw_rx, push, pe, dir, _db) = setup().await; + let (tx, rx) = unbounded_channel(); + let handle = tokio::spawn(actor.run(rx, raw_rx)); + + tx.send(FsInbound::Frame { + session: "1".to_owned(), + frame: request(1, "fs/subscribe", json!({"targets":[dir_ref(&pe, "")]})), + }) + .unwrap(); + tokio::time::sleep(Duration::from_millis(250)).await; + + // Disconnect drops all of session 1's subscriptions (node enters grace). + tx.send(FsInbound::Disconnect { + session: "1".to_owned(), + }) + .unwrap(); + tokio::time::sleep(Duration::from_millis(150)).await; + + let count_before = push.frames().len(); + // A change now must not fan out to the disconnected session. + std::fs::write(dir.path().join("after.ts"), b"x").unwrap(); + let no_new_delta = !wait_until(&push, Duration::from_millis(800), |f| { + has_delta_adding(f, "1", "after.ts") + }) + .await; + assert!(no_new_delta, "no delta after disconnect"); + assert_eq!(push.frames().len(), count_before, "no push to a dropped session"); + + drop(tx); + let _ = handle.await; +} diff --git a/crates/aionui-project/src/monitor/dispatch.rs b/crates/aionui-project/src/monitor/dispatch.rs new file mode 100644 index 000000000..729cfa238 --- /dev/null +++ b/crates/aionui-project/src/monitor/dispatch.rs @@ -0,0 +1,434 @@ +//! Inbound JSON-RPC dispatch for the monitor actor. +//! +//! Parses one inner frame, routes by `method`, and drives the runtime: +//! `initialize` handshakes; `fs/subscribe`/`fs/unsubscribe` go through the shard +//! (identity resolved via [`ProjectService::resolve_reference`]); the file +//! commands (`fs/read|write|mkdir|remove|rename`) resolve + realpath-guard, then +//! hit the provider directly. Responses/notifications go out via the actor's +//! push port. Errors map to protocol codes ([`wire`]) with `pe_id`/`relative_path` +//! context in `error.data`. +//! +//! [`ProjectService::resolve_reference`]: crate::ProjectService::resolve_reference + +use std::path::Path; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use serde_json::{Value, json}; + +use crate::canonical; +use crate::runtime::{Command, ShardOutput, Subscriber}; +use crate::types::{FileOp, ReferenceInput, ResolvedResource}; + +use super::actor::FsMonitorActor; +use super::wire::{ + self, Encoding, InitializeParams, MkdirParams, ReadParams, RemoveParams, RenameParams, ResourceRef, + SubscribeParams, UnsubscribeParams, WriteParams, +}; + +impl FsMonitorActor { + /// Decode one inbound frame and route it by method. Malformed frames get a + /// JSON-RPC error; unknown methods get `method_not_found`. + pub(super) async fn dispatch_frame(&mut self, session: &str, frame: Value) { + let parsed = serde_json::from_value::(frame); + let Ok(incoming) = parsed else { + // Malformed inbound frame — safely handled (client bug / protocol drift). + tracing::warn!(session, "fs dispatch: malformed frame"); + self.push( + session, + wire::error(None, wire::CODE_INVALID_REQUEST, "invalid_request", Value::Null), + ); + return; + }; + let id = incoming.id; + let params = incoming.params; + // High-frequency per-frame trace: method + session only (dev diagnostics). + tracing::debug!(session, method = %incoming.method, "fs dispatch"); + match incoming.method.as_str() { + "initialize" => self.handle_initialize(session, id, params), + "fs/subscribe" => self.handle_subscribe(session, id, params).await, + "fs/unsubscribe" => self.handle_unsubscribe(session, params).await, + "fs/read" => self.handle_read(session, id, params).await, + "fs/write" => self.handle_write(session, id, params).await, + "fs/mkdir" => self.handle_mkdir(session, id, params).await, + "fs/remove" => self.handle_remove(session, id, params).await, + "fs/rename" => self.handle_rename(session, id, params).await, + other => { + tracing::warn!(session, method = %other, "fs dispatch: unknown method"); + self.push( + session, + wire::error(id, wire::CODE_METHOD_NOT_FOUND, "method_not_found", Value::Null), + ) + } + } + } + + // ── handshake ───────────────────────────────────────────────────────── + + fn handle_initialize(&self, session: &str, id: Option, params: Value) { + match serde_json::from_value::(params) { + // We speak exactly v1; a client offering >= 1 negotiates down to 1. + Ok(p) if p.protocol_version >= wire::PROTOCOL_VERSION => { + self.push( + session, + wire::success(id, json!({ "protocol_version": wire::PROTOCOL_VERSION })), + ); + } + Ok(_) => self.push( + session, + wire::error( + id, + wire::CODE_PROTOCOL_VERSION_UNSUPPORTED, + "protocol_version_unsupported", + Value::Null, + ), + ), + Err(_) => self.push(session, invalid_params(id)), + } + } + + // ── subscribe / unsubscribe ───────────────────────────────────────────── + + async fn handle_subscribe(&mut self, session: &str, id: Option, params: Value) { + let Ok(parsed) = serde_json::from_value::(params) else { + self.push(session, invalid_params(id)); + return; + }; + + let target_count = parsed.targets.len(); + + // Phase 1: resolve + canonicalize every target before mutating the shard, + // so a bad target fails the whole request atomically (no partial mount). + let mut plan: Vec<(ResourceRef, String)> = Vec::new(); + for target in parsed.targets { + let resolved = match self.resolve(&target, FileOp::Browse).await { + Ok(r) => r, + Err((code, message)) => { + tracing::warn!(session, code = message, pe_id = %target.pe_id, "fs subscribe rejected"); + self.push(session, wire::error(id.clone(), code, message, ref_data(&target))); + return; + } + }; + // Fold to the identity the watcher/apply chain keys on (case-folding + // platforms differ) — otherwise events would fail to attribute back. + let canonical = match canonical::canonicalize(&resolved.resource_uri) { + Ok(c) => c.as_str().to_owned(), + Err(_) => { + tracing::warn!(session, code = "provider_unavailable", pe_id = %target.pe_id, "fs subscribe rejected"); + self.push( + session, + wire::error( + id.clone(), + wire::CODE_PROVIDER_UNAVAILABLE, + "provider_unavailable", + ref_data(&target), + ), + ); + return; + } + }; + plan.push((target, canonical)); + } + + // Phase 2: subscribe each; the subscribe reply carries every snapshot. + let now = self.now(); + let mut snapshots: Vec = Vec::new(); + for (target, canonical) in plan { + let sub = Subscriber { + session: session.to_owned(), + pe_id: target.pe_id.clone(), + rel: target.relative_path.clone(), + }; + match self.shard_handle(Command::Subscribe { sub, canonical, now }).await { + Ok(outputs) => { + for output in outputs { + if let ShardOutput::Snapshot { snapshot, .. } = output { + snapshots.push(wire::snapshot_params(&snapshot, &target)); + } + } + } + Err(err) => { + let (code, message) = wire::fs_error_to_rpc(&err); + tracing::warn!(session, code = message, pe_id = %target.pe_id, "fs subscribe rejected"); + self.push(session, wire::error(id.clone(), code, message, ref_data(&target))); + return; + } + } + } + // Subscription registration succeeded — lifecycle boundary (low volume). + tracing::info!( + session, + targets = target_count, + snapshots = snapshots.len(), + "fs subscribe" + ); + self.push(session, wire::success(id, json!({ "snapshots": snapshots }))); + } + + /// `fs/unsubscribe` is a notification: best-effort, no reply. A target that + /// no longer resolves is silently ignored (the live subscription, if any, + /// self-heals on the next full re-declare). + async fn handle_unsubscribe(&mut self, session: &str, params: Value) { + let Ok(parsed) = serde_json::from_value::(params) else { + return; + }; + // Subscription de-registration — lifecycle boundary (low volume). + tracing::info!(session, targets = parsed.targets.len(), "fs unsubscribe"); + let now = self.now(); + for target in parsed.targets { + let Ok(resolved) = self.resolve(&target, FileOp::Browse).await else { + continue; + }; + let Ok(canonical) = canonical::canonicalize(&resolved.resource_uri) else { + continue; + }; + let sub = Subscriber { + session: session.to_owned(), + pe_id: target.pe_id, + rel: target.relative_path, + }; + let _ = self + .shard_handle(Command::Unsubscribe { + sub, + canonical: canonical.as_str().to_owned(), + now, + }) + .await; + } + } + + // ── file commands ─────────────────────────────────────────────────────── + + async fn handle_read(&mut self, session: &str, id: Option, params: Value) { + let Ok(p) = serde_json::from_value::(params) else { + self.push(session, invalid_params(id)); + return; + }; + let resolved = match self.resolve_guarded(&p.file, FileOp::Read).await { + Ok(r) => r, + Err((code, message)) => { + self.push(session, wire::error(id, code, message, ref_data(&p.file))); + return; + } + }; + match self.runtime().provider().read(&resolved.resource_uri).await { + Ok(bytes) => { + // Byte count only — never the content itself. + tracing::info!(session, op = "read", pe_id = %p.file.pe_id, rel = %p.file.relative_path, bytes = bytes.len(), "fs command ok"); + let (content, encoding) = encode_content(bytes, p.encoding.unwrap_or_default()); + self.push( + session, + wire::success(id, json!({ "content": content, "encoding": encoding })), + ); + } + Err(err) => { + let (code, message) = wire::fs_error_to_rpc(&err); + tracing::warn!(session, op = "read", pe_id = %p.file.pe_id, rel = %p.file.relative_path, code = message, "fs command failed"); + self.push(session, wire::error(id, code, message, ref_data(&p.file))); + } + } + } + + async fn handle_write(&mut self, session: &str, id: Option, params: Value) { + let Ok(p) = serde_json::from_value::(params) else { + self.push(session, invalid_params(id)); + return; + }; + let bytes = match decode_content(&p.content, p.encoding.unwrap_or_default()) { + Ok(b) => b, + Err(()) => { + self.push(session, invalid_params(id)); + return; + } + }; + let resolved = match self.resolve_guarded(&p.file, FileOp::Write).await { + Ok(r) => r, + Err((code, message)) => { + self.push(session, wire::error(id, code, message, ref_data(&p.file))); + return; + } + }; + let outcome = self.runtime().provider().write(&resolved.resource_uri, &bytes).await; + self.reply_unit(session, id, "write", &p.file, outcome); + } + + async fn handle_mkdir(&mut self, session: &str, id: Option, params: Value) { + let Ok(p) = serde_json::from_value::(params) else { + self.push(session, invalid_params(id)); + return; + }; + let resolved = match self.resolve_guarded(&p.dir, FileOp::Write).await { + Ok(r) => r, + Err((code, message)) => { + self.push(session, wire::error(id, code, message, ref_data(&p.dir))); + return; + } + }; + let outcome = self.runtime().provider().mkdir(&resolved.resource_uri).await; + self.reply_unit(session, id, "mkdir", &p.dir, outcome); + } + + async fn handle_remove(&mut self, session: &str, id: Option, params: Value) { + let Ok(p) = serde_json::from_value::(params) else { + self.push(session, invalid_params(id)); + return; + }; + let resolved = match self.resolve_guarded(&p.target, FileOp::Remove).await { + Ok(r) => r, + Err((code, message)) => { + self.push(session, wire::error(id, code, message, ref_data(&p.target))); + return; + } + }; + let outcome = self + .runtime() + .provider() + .remove(&resolved.resource_uri, p.recursive) + .await; + self.reply_unit(session, id, "remove", &p.target, outcome); + } + + async fn handle_rename(&mut self, session: &str, id: Option, params: Value) { + let Ok(p) = serde_json::from_value::(params) else { + self.push(session, invalid_params(id)); + return; + }; + let from = match self.resolve_guarded(&p.from, FileOp::Rename).await { + Ok(r) => r, + Err((code, message)) => { + self.push(session, wire::error(id, code, message, ref_data(&p.from))); + return; + } + }; + let to = match self.resolve_guarded(&p.to, FileOp::Rename).await { + Ok(r) => r, + Err((code, message)) => { + self.push(session, wire::error(id, code, message, ref_data(&p.to))); + return; + } + }; + let outcome = self + .runtime() + .provider() + .rename(&from.resource_uri, &to.resource_uri) + .await; + self.reply_unit(session, id, "rename", &p.from, outcome); + } + + // ── helpers ─────────────────────────────────────────────────────────── + + /// Resolve a reference to identity + lexical containment, mapping the + /// bind-domain error to a protocol `(code, message)`. + async fn resolve(&self, target: &ResourceRef, op: FileOp) -> Result { + let input = ReferenceInput { + pe_id: target.pe_id.clone(), + relative_path: target.relative_path.clone(), + op, + }; + self.project() + .resolve_reference(input) + .await + .map_err(|e| wire::project_error_to_rpc(&e)) + } + + /// Resolve + realpath-guard: identity/lexical containment first, then the + /// access-time symlink/alias escape check before any command IO. + async fn resolve_guarded(&self, target: &ResourceRef, op: FileOp) -> Result { + let resolved = self.resolve(target, op).await?; + guard_realpath(&resolved)?; + Ok(resolved) + } + + /// Reply `{}` on success, or map a provider error to a protocol error. + /// `op` is the command label used for structured logging (identifier only). + fn reply_unit( + &self, + session: &str, + id: Option, + op: &'static str, + target: &ResourceRef, + outcome: Result<(), crate::runtime::FsError>, + ) { + match outcome { + Ok(()) => { + tracing::info!(session, op, pe_id = %target.pe_id, rel = %target.relative_path, "fs command ok"); + self.push(session, wire::success(id, json!({}))); + } + Err(err) => { + let (code, message) = wire::fs_error_to_rpc(&err); + tracing::warn!(session, op, pe_id = %target.pe_id, rel = %target.relative_path, code = message, "fs command failed"); + self.push(session, wire::error(id, code, message, ref_data(target))); + } + } + } +} + +/// Build a JSON-RPC `invalid_params` error for request `id`. +fn invalid_params(id: Option) -> Value { + wire::error(id, wire::CODE_INVALID_PARAMS, "invalid_params", Value::Null) +} + +/// `error.data` context for a reference. +fn ref_data(target: &ResourceRef) -> Value { + json!({ "pe_id": target.pe_id, "relative_path": target.relative_path }) +} + +/// Encode file bytes for the wire, honoring the requested encoding. A utf-8 +/// request over non-utf-8 bytes falls back to base64 (the `encoding` field in +/// the result tells the client what it actually got). +fn encode_content(bytes: Vec, requested: Encoding) -> (String, Encoding) { + match requested { + Encoding::Base64 => (STANDARD.encode(&bytes), Encoding::Base64), + Encoding::Utf8 => match String::from_utf8(bytes) { + Ok(text) => (text, Encoding::Utf8), + Err(err) => (STANDARD.encode(err.as_bytes()), Encoding::Base64), + }, + } +} + +/// Decode wire content to bytes per its declared encoding. +fn decode_content(content: &str, encoding: Encoding) -> Result, ()> { + match encoding { + Encoding::Utf8 => Ok(content.as_bytes().to_vec()), + Encoding::Base64 => STANDARD.decode(content.as_bytes()).map_err(|_| ()), + } +} + +/// Realpath containment: the access-time symlink/alias escape guard that stage 0 +/// deferred. `resolve_reference` already did lexical containment; here the target +/// (or its deepest existing ancestor, for not-yet-created paths) is realpath'd +/// and required to stay within the folder root's realpath. Fails closed. +fn guard_realpath(resolved: &ResolvedResource) -> Result<(), (i64, &'static str)> { + let Some(absolute) = resolved.absolute_path.as_ref() else { + // No filesystem path (non-file scheme) → realpath containment N/A here. + return Ok(()); + }; + if realpath_within(&resolved.root_resource_canonical, Path::new(absolute)) { + Ok(()) + } else { + Err((wire::CODE_RESOURCE_OUTSIDE_FOLDER, "resource_outside_folder")) + } +} + +/// Whether `target`'s deepest existing ancestor realpath is inside `root`'s +/// realpath. Walking to the deepest existing ancestor lets not-yet-created +/// targets (write/mkdir/rename-to) be validated by their parent while still +/// catching a symlinked parent that escapes the root. +fn realpath_within(root_uri: &str, target: &Path) -> bool { + let Ok(root_path) = canonical::uri_to_path(root_uri) else { + return false; + }; + let Ok(root_real) = std::fs::canonicalize(&root_path) else { + return false; + }; + let mut probe = target; + loop { + if let Ok(real) = std::fs::canonicalize(probe) { + return real.starts_with(&root_real); + } + match probe.parent() { + Some(parent) => probe = parent, + None => return false, + } + } +} diff --git a/crates/aionui-project/src/monitor/mod.rs b/crates/aionui-project/src/monitor/mod.rs new file mode 100644 index 000000000..fd027c391 --- /dev/null +++ b/crates/aionui-project/src/monitor/mod.rs @@ -0,0 +1,22 @@ +//! Project Explorer monitor — the WS-facing protocol surface over the runtime. +//! +//! Bridges the JSON-RPC monitor protocol (`formal/runtime/protocol.md`) to the +//! stage-0 runtime core: a single actor event loop owns the [`Shard`] and drives +//! subscribe/unsubscribe/commands + watcher-driven fan-out. The actor depends +//! only on a narrow outbound port ([`FsWirePush`]) and never on a concrete WS +//! transport, so the composition layer supplies the socket adapter. +//! +//! [`Shard`]: crate::runtime::Shard +//! +//! Module files hold implementation; this file only declares and re-exports. + +// Wire types are consumed only within this crate (dispatch/actor); the app layer +// speaks the transport envelope, not these payload types. Keep them crate-local. +pub(crate) mod wire; + +mod actor; +mod dispatch; +mod port; + +pub use actor::FsMonitorActor; +pub use port::{FsInbound, FsWirePush, SessionId}; diff --git a/crates/aionui-project/src/monitor/port.rs b/crates/aionui-project/src/monitor/port.rs new file mode 100644 index 000000000..e3cb7a75c --- /dev/null +++ b/crates/aionui-project/src/monitor/port.rs @@ -0,0 +1,32 @@ +//! Outbound push port + inbound command envelope for the monitor actor. +//! +//! These keep the actor transport-agnostic: it consumes [`FsInbound`] events and +//! emits frames through [`FsWirePush`], so the composition layer owns the actual +//! WS socket (envelope wrapping + unicast) without the domain depending on it. + +use serde_json::Value; + +/// Identifies one WS connection (= one session). A string mirroring the +/// transport's connection id (matches the runtime [`Subscriber::session`] type), +/// so the domain does not depend on the realtime crate's `ConnectionId`. +/// +/// [`Subscriber::session`]: crate::runtime::Subscriber::session +pub type SessionId = String; + +/// Narrow outbound port: deliver one inner JSON-RPC frame to a connection. +/// +/// The composition layer implements this over the WS manager's unicast, wrapping +/// each frame in the transport envelope (`{ name: "fs", data: }`). +pub trait FsWirePush: Send + Sync { + /// Push one JSON-RPC frame (response or server notification) to `session`. + fn push(&self, session: &str, frame: Value); +} + +/// An inbound event delivered to the actor from the transport layer. +#[derive(Debug, Clone)] +pub enum FsInbound { + /// An outer-envelope `fs` frame's payload (the inner JSON-RPC value). + Frame { session: SessionId, frame: Value }, + /// The connection closed — release its subscriptions (`drop_session`). + Disconnect { session: SessionId }, +} diff --git a/crates/aionui-project/src/monitor/wire.rs b/crates/aionui-project/src/monitor/wire.rs new file mode 100644 index 000000000..bbfc7ed76 --- /dev/null +++ b/crates/aionui-project/src/monitor/wire.rs @@ -0,0 +1,281 @@ +//! Monitor protocol wire types (JSON-RPC 2.0 payload layer). +//! +//! The outer transport envelope (`WebSocketMessage { name: "fs", data }`) is the +//! composition layer's concern; this module models only the inner JSON-RPC frame +//! (`protocol.md`): request/response/notification builders, method params, the +//! outward `Entry`/`Snapshot`/`Delta` shapes, and error-code mapping from the +//! backend error taxonomies (`ProjectError` / `FsError`) to the protocol's +//! `-32000..` codes. Pure and filesystem-free. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::runtime::{Change, DeltaBatch, EntryFact, FsError, Kind, Snapshot}; +use crate::types::ProjectError; + +/// JSON-RPC version string carried on every frame. +pub const JSONRPC_VERSION: &str = "2.0"; + +/// Highest monitor protocol version this server implements. +pub const PROTOCOL_VERSION: u32 = 1; + +// ── Protocol error codes (protocol.md error table) ──────────────────────── +// Only codes stage 1 actually emits are defined here. Protocol also reserves +// `-32700 parse_error` (JSON framing — handled at the realtime transport layer, +// never reaches this payload dispatch) and `-32005 file_operation_denied` +// (conversation/team mode write-guards — a stage-2 capability). Both are +// intentionally NOT defined until wired, per the workspace no-unwired-placeholder +// rule; see the stage-1 test report §6. +pub const CODE_INVALID_REQUEST: i64 = -32600; +pub const CODE_METHOD_NOT_FOUND: i64 = -32601; +pub const CODE_INVALID_PARAMS: i64 = -32602; +pub const CODE_OUT_OF_SCOPE: i64 = -32000; +pub const CODE_UNSUPPORTED_RESOURCE_SCHEME: i64 = -32001; +pub const CODE_RESOURCE_NOT_FOUND: i64 = -32002; +pub const CODE_RESOURCE_OUTSIDE_FOLDER: i64 = -32003; +pub const CODE_INVALID_RELATIVE_PATH: i64 = -32004; +pub const CODE_PROVIDER_UNAVAILABLE: i64 = -32006; +pub const CODE_PROTOCOL_VERSION_UNSUPPORTED: i64 = -32010; + +// ── Incoming frame ──────────────────────────────────────────────────────── + +/// A decoded inbound JSON-RPC frame. `id` is absent for notifications +/// (`fs/unsubscribe`); `params` defaults to null when omitted. +#[derive(Debug, Clone, Deserialize)] +pub struct IncomingFrame { + #[serde(default)] + pub id: Option, + pub method: String, + #[serde(default)] + pub params: Value, +} + +/// A resource reference `{ pe_id, relative_path }`. One shape serves every +/// method's `DirRef`/`FileRef` (identity is the same; op intent is per-method). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ResourceRef { + pub pe_id: String, + pub relative_path: String, +} + +/// Content encoding for `fs/read` / `fs/write`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Encoding { + #[serde(rename = "utf-8")] + #[default] + Utf8, + Base64, +} + +// ── Method params ───────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Deserialize)] +pub struct InitializeParams { + pub protocol_version: u32, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SubscribeParams { + pub targets: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UnsubscribeParams { + pub targets: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ReadParams { + pub file: ResourceRef, + #[serde(default)] + pub encoding: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WriteParams { + pub file: ResourceRef, + pub content: String, + #[serde(default)] + pub encoding: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MkdirParams { + pub dir: ResourceRef, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RemoveParams { + pub target: ResourceRef, + #[serde(default)] + pub recursive: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RenameParams { + pub from: ResourceRef, + pub to: ResourceRef, +} + +// ── Outward entry / snapshot / delta ────────────────────────────────────── + +/// Entry kind on the wire (`protocol.md` `Entry.kind`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum WireKind { + File, + Dir, + Symlink, +} + +impl From for WireKind { + fn from(k: Kind) -> Self { + match k { + Kind::File => WireKind::File, + Kind::Dir => WireKind::Dir, + Kind::Symlink => WireKind::Symlink, + } + } +} + +/// One directory entry as the client sees it: name + kind (+ symlink target). +/// `excluded` (excludes-set membership, e.g. `node_modules`) is not wired in +/// stage 1 — the excludes source is not yet plumbed; omitted from output. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WireEntry { + pub name: String, + pub kind: WireKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub symlink_target: Option, +} + +impl WireEntry { + /// Project a backend `(name, EntryFact)` to the outward entry (drops inode). + pub fn from_fact(name: &str, fact: &EntryFact) -> Self { + WireEntry { + name: name.to_owned(), + kind: fact.kind.into(), + symlink_target: fact.symlink_target.clone(), + } + } +} + +/// Build the `fs/snapshot` params for one target from a canonical-domain +/// [`Snapshot`]. The `target` is the subscriber's pe-relative identity. +pub fn snapshot_params(snapshot: &Snapshot, target: &ResourceRef) -> Value { + let entries: Vec = snapshot + .entries + .iter() + .map(|(name, fact)| WireEntry::from_fact(name, fact)) + .collect(); + json!({ + "target": target, + "entries": entries, + }) +} + +/// Build the `fs/delta` params for one target from a canonical-domain +/// [`DeltaBatch`], translating each [`Change`] to its wire shape. +pub fn delta_params(delta: &DeltaBatch, target: &ResourceRef) -> Value { + let changes: Vec = delta.changes.iter().map(change_to_wire).collect(); + json!({ + "target": target, + "changes": changes, + }) +} + +/// One reconciled change → tagged wire object (`op` = added/removed/renamed). +fn change_to_wire(change: &Change) -> Value { + match change { + Change::Added { name, kind } => json!({ + "op": "added", + "name": name, + "kind": WireKind::from(*kind), + }), + Change::Removed { name } => json!({ + "op": "removed", + "name": name, + }), + Change::Renamed { from, to } => json!({ + "op": "renamed", + "from": from, + "to": to, + }), + } +} + +// ── Frame builders ──────────────────────────────────────────────────────── + +/// A JSON-RPC success response carrying `result` for request `id`. +pub fn success(id: Option, result: Value) -> Value { + json!({ + "jsonrpc": JSONRPC_VERSION, + "id": id, + "result": result, + }) +} + +/// A JSON-RPC error response for request `id`. `data` carries UI context +/// (e.g. `pe_id`/`relative_path`); pass `Value::Null` when there is none. +pub fn error(id: Option, code: i64, message: &str, data: Value) -> Value { + let mut err = json!({ "code": code, "message": message }); + if !data.is_null() { + err["data"] = data; + } + json!({ + "jsonrpc": JSONRPC_VERSION, + "id": id, + "error": err, + }) +} + +/// A server-initiated JSON-RPC notification (no `id`). +pub fn notification(method: &str, params: Value) -> Value { + json!({ + "jsonrpc": JSONRPC_VERSION, + "method": method, + "params": params, + }) +} + +// ── Error mapping ───────────────────────────────────────────────────────── + +/// Map a bind-domain [`ProjectError`] (identity/containment resolution) to a +/// protocol `(code, message)`. `message` is the stable protocol name. +pub fn project_error_to_rpc(err: &ProjectError) -> (i64, &'static str) { + match err { + ProjectError::ProjectExplorerNotFound { .. } | ProjectError::ProjectNotFound { .. } => { + (CODE_OUT_OF_SCOPE, "out_of_scope") + } + ProjectError::InvalidRelativePath { .. } => (CODE_INVALID_RELATIVE_PATH, "invalid_relative_path"), + ProjectError::ResourceOutsideFolder { .. } => (CODE_RESOURCE_OUTSIDE_FOLDER, "resource_outside_folder"), + ProjectError::UnsupportedResourceScheme { .. } => { + (CODE_UNSUPPORTED_RESOURCE_SCHEME, "unsupported_resource_scheme") + } + ProjectError::FolderNotFound { .. } => (CODE_RESOURCE_NOT_FOUND, "resource_not_found"), + ProjectError::FolderPermissionDenied { .. } => (CODE_PROVIDER_UNAVAILABLE, "provider_unavailable"), + // Remaining bind-chain variants are not reachable from reference + // resolution on the monitor path; surface as provider_unavailable rather + // than leaking internal bind semantics onto the fs wire. + _ => (CODE_PROVIDER_UNAVAILABLE, "provider_unavailable"), + } +} + +/// Map a runtime-link [`FsError`] (provider IO) to a protocol `(code, message)`. +pub fn fs_error_to_rpc(err: &FsError) -> (i64, &'static str) { + match err { + FsError::NotFound { .. } => (CODE_RESOURCE_NOT_FOUND, "resource_not_found"), + FsError::UnsupportedScheme { .. } => (CODE_UNSUPPORTED_RESOURCE_SCHEME, "unsupported_resource_scheme"), + // AlreadyExists / PermissionDenied / NotADirectory / Io are all provider + // availability/operation failures from the client's perspective. + FsError::AlreadyExists { .. } + | FsError::PermissionDenied { .. } + | FsError::NotADirectory { .. } + | FsError::Io { .. } => (CODE_PROVIDER_UNAVAILABLE, "provider_unavailable"), + } +} + +#[cfg(test)] +#[path = "wire_test.rs"] +mod wire_test; diff --git a/crates/aionui-project/src/monitor/wire_test.rs b/crates/aionui-project/src/monitor/wire_test.rs new file mode 100644 index 000000000..3d718a3cc --- /dev/null +++ b/crates/aionui-project/src/monitor/wire_test.rs @@ -0,0 +1,258 @@ +use serde_json::{Value, json}; + +use crate::runtime::{Change, DeltaBatch, EntryFact, FsError, Kind, Snapshot}; +use crate::types::ProjectError; + +use super::*; + +fn fact(kind: Kind) -> EntryFact { + EntryFact { + kind, + inode: 7, + symlink_target: None, + } +} + +// ── IncomingFrame ───────────────────────────────────────────────────────── + +#[test] +fn incoming_request_parses_id_method_params() { + let v = + json!({"jsonrpc":"2.0","id":3,"method":"fs/read","params":{"file":{"pe_id":"pe1","relative_path":"a.txt"}}}); + let frame: IncomingFrame = serde_json::from_value(v).unwrap(); + assert_eq!(frame.id, Some(json!(3))); + assert_eq!(frame.method, "fs/read"); + assert_eq!(frame.params["file"]["pe_id"], "pe1"); +} + +#[test] +fn incoming_notification_has_no_id() { + let v = json!({"jsonrpc":"2.0","method":"fs/unsubscribe","params":{"targets":[]}}); + let frame: IncomingFrame = serde_json::from_value(v).unwrap(); + assert_eq!(frame.id, None); + assert_eq!(frame.method, "fs/unsubscribe"); +} + +#[test] +fn incoming_params_default_null_when_absent() { + let v = json!({"jsonrpc":"2.0","id":0,"method":"initialize"}); + let frame: IncomingFrame = serde_json::from_value(v).unwrap(); + assert!(frame.params.is_null()); +} + +#[test] +fn incoming_missing_method_is_error() { + let v = json!({"jsonrpc":"2.0","id":1,"params":{}}); + assert!(serde_json::from_value::(v).is_err()); +} + +// ── params / encoding ───────────────────────────────────────────────────── + +#[test] +fn subscribe_params_parse_targets() { + let v = json!({"targets":[{"pe_id":"pe1","relative_path":""},{"pe_id":"pe2","relative_path":"src"}]}); + let p: SubscribeParams = serde_json::from_value(v).unwrap(); + assert_eq!(p.targets.len(), 2); + assert_eq!(p.targets[1].relative_path, "src"); +} + +#[test] +fn encoding_parses_utf8_and_base64_and_defaults() { + assert_eq!( + serde_json::from_value::(json!("utf-8")).unwrap(), + Encoding::Utf8 + ); + assert_eq!( + serde_json::from_value::(json!("base64")).unwrap(), + Encoding::Base64 + ); + assert_eq!(Encoding::default(), Encoding::Utf8); +} + +#[test] +fn remove_params_recursive_defaults_false() { + let p: RemoveParams = serde_json::from_value(json!({"target":{"pe_id":"pe1","relative_path":"d"}})).unwrap(); + assert!(!p.recursive); +} + +// ── entry / kind ────────────────────────────────────────────────────────── + +#[test] +fn wire_kind_serializes_lowercase() { + assert_eq!(serde_json::to_value(WireKind::File).unwrap(), json!("file")); + assert_eq!(serde_json::to_value(WireKind::Dir).unwrap(), json!("dir")); + assert_eq!(serde_json::to_value(WireKind::Symlink).unwrap(), json!("symlink")); +} + +#[test] +fn wire_entry_from_fact_maps_kind_and_drops_inode() { + let e = WireEntry::from_fact("a.txt", &fact(Kind::File)); + let v = serde_json::to_value(&e).unwrap(); + assert_eq!(v, json!({"name":"a.txt","kind":"file"})); + // inode is internal and must never appear on the wire. + assert!(v.get("inode").is_none()); +} + +#[test] +fn wire_entry_symlink_includes_target() { + let ef = EntryFact { + kind: Kind::Symlink, + inode: 1, + symlink_target: Some("target".to_owned()), + }; + let v = serde_json::to_value(WireEntry::from_fact("link", &ef)).unwrap(); + assert_eq!(v["kind"], "symlink"); + assert_eq!(v["symlink_target"], "target"); +} + +// ── snapshot / delta params ─────────────────────────────────────────────── + +fn target() -> ResourceRef { + ResourceRef { + pe_id: "pe1".to_owned(), + relative_path: "src".to_owned(), + } +} + +#[test] +fn snapshot_params_carries_target_and_entries() { + let snap = Snapshot { + canonical: "file:///x".to_owned(), + entries: vec![ + ("main.ts".to_owned(), fact(Kind::File)), + ("sub".to_owned(), fact(Kind::Dir)), + ], + }; + let v = snapshot_params(&snap, &target()); + assert_eq!(v["target"], json!({"pe_id":"pe1","relative_path":"src"})); + assert_eq!(v["entries"][0], json!({"name":"main.ts","kind":"file"})); + assert_eq!(v["entries"][1], json!({"name":"sub","kind":"dir"})); + // canonical must never leak to the wire. + assert!(v.get("canonical").is_none()); +} + +#[test] +fn delta_params_tags_each_change_op() { + let delta = DeltaBatch { + canonical: "file:///x".to_owned(), + changes: vec![ + Change::Added { + name: "new.ts".to_owned(), + kind: Kind::File, + }, + Change::Removed { + name: "old.ts".to_owned(), + }, + Change::Renamed { + from: "a".to_owned(), + to: "b".to_owned(), + }, + ], + }; + let v = delta_params(&delta, &target()); + assert_eq!(v["target"]["pe_id"], "pe1"); + assert_eq!(v["changes"][0], json!({"op":"added","name":"new.ts","kind":"file"})); + assert_eq!(v["changes"][1], json!({"op":"removed","name":"old.ts"})); + assert_eq!(v["changes"][2], json!({"op":"renamed","from":"a","to":"b"})); +} + +// ── frame builders ──────────────────────────────────────────────────────── + +#[test] +fn success_frame_shape() { + let v = success(Some(json!(5)), json!({"ok":true})); + assert_eq!(v, json!({"jsonrpc":"2.0","id":5,"result":{"ok":true}})); +} + +#[test] +fn error_frame_includes_data_when_present() { + let v = error( + Some(json!(6)), + CODE_RESOURCE_NOT_FOUND, + "resource_not_found", + json!({"pe_id":"pe1"}), + ); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], 6); + assert_eq!(v["error"]["code"], -32002); + assert_eq!(v["error"]["message"], "resource_not_found"); + assert_eq!(v["error"]["data"]["pe_id"], "pe1"); +} + +#[test] +fn error_frame_omits_data_when_null() { + let v = error(None, CODE_INVALID_REQUEST, "invalid_request", Value::Null); + assert_eq!(v["error"]["code"], -32600); + assert!(v["error"].get("data").is_none()); + assert!(v["id"].is_null()); +} + +#[test] +fn notification_frame_has_no_id() { + let v = notification("fs/delta", json!({"target":{}})); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["method"], "fs/delta"); + assert!(v.get("id").is_none()); +} + +// ── error mapping ───────────────────────────────────────────────────────── + +#[test] +fn project_error_maps_to_protocol_codes() { + let cases: Vec<(ProjectError, i64, &str)> = vec![ + ( + ProjectError::ProjectExplorerNotFound { pe_id: "pe1".into() }, + CODE_OUT_OF_SCOPE, + "out_of_scope", + ), + ( + ProjectError::InvalidRelativePath { + relative_path: "/x".into(), + }, + CODE_INVALID_RELATIVE_PATH, + "invalid_relative_path", + ), + ( + ProjectError::ResourceOutsideFolder { + relative_path: "../x".into(), + }, + CODE_RESOURCE_OUTSIDE_FOLDER, + "resource_outside_folder", + ), + ( + ProjectError::UnsupportedResourceScheme { scheme: "ssh".into() }, + CODE_UNSUPPORTED_RESOURCE_SCHEME, + "unsupported_resource_scheme", + ), + ]; + for (err, code, msg) in cases { + assert_eq!(project_error_to_rpc(&err), (code, msg), "for {err:?}"); + } +} + +#[test] +fn fs_error_maps_to_protocol_codes() { + assert_eq!( + fs_error_to_rpc(&FsError::NotFound { + uri: "file:///x".into() + }), + (CODE_RESOURCE_NOT_FOUND, "resource_not_found") + ); + assert_eq!( + fs_error_to_rpc(&FsError::UnsupportedScheme { scheme: "ssh".into() }), + (CODE_UNSUPPORTED_RESOURCE_SCHEME, "unsupported_resource_scheme") + ); + assert_eq!( + fs_error_to_rpc(&FsError::PermissionDenied { + uri: "file:///x".into() + }), + (CODE_PROVIDER_UNAVAILABLE, "provider_unavailable") + ); + assert_eq!( + fs_error_to_rpc(&FsError::Io { + uri: "file:///x".into(), + message: "boom".into(), + }), + (CODE_PROVIDER_UNAVAILABLE, "provider_unavailable") + ); +} diff --git a/crates/aionui-project/src/routes.rs b/crates/aionui-project/src/routes.rs new file mode 100644 index 000000000..28b6e08e8 --- /dev/null +++ b/crates/aionui-project/src/routes.rs @@ -0,0 +1,211 @@ +// `ApiError` is the intended error type at this HTTP boundary (routes map the +// crate-owned `ProjectError` 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)] + +//! Project Explorer control-plane HTTP routes. +//! +//! Read a project's roots (`GET /api/projects/{id}`) and mutate its attached +//! folders (`POST`/`DELETE .../folders`). Filesystem content is served +//! separately over the `fs/*` WebSocket protocol; these routes only expose the +//! project shell + root list the explorer needs to open subscriptions. +//! +//! Handlers do request/response transformation only; all business logic lives +//! in [`ProjectService`]. Domain [`ProjectError`]s map to `ApiError` with +//! stable, machine-readable codes (`project_explorer_duplicate`, +//! `project_explorer_overlap`, …) so the frontend can branch without parsing +//! human messages. + +use std::sync::Arc; + +use aionui_api_types::{ApiResponse, AttachFolderRequest, ProjectDetailResponse, ProjectEntry, ProjectExplorer}; +use aionui_common::ApiError; +use axum::Router; +use axum::extract::rejection::JsonRejection; +use axum::extract::{Json, Path, State}; +use axum::http::StatusCode; +use axum::routing::{delete, get, post}; +use serde_json::json; + +use crate::canonical; +use crate::service::ProjectService; +use crate::types::{AttachInput, ProjectDetail, ProjectError, ProjectExplorerEntry}; + +/// Shared state for project route handlers. +#[derive(Clone)] +pub struct ProjectRouterState { + pub project: Arc, +} + +/// Build the project control-plane router (`/api/projects/*`). +/// +/// All routes require authentication (applied by the caller). +pub fn project_routes(state: ProjectRouterState) -> Router { + Router::new() + .route("/api/projects/{project_id}", get(get_project)) + .route("/api/projects/{project_id}/folders", post(attach_folder)) + .route("/api/projects/{project_id}/folders/{pe_id}", delete(remove_folder)) + .with_state(state) +} + +/// `GET /api/projects/{project_id}` — full project detail + all roots in one +/// call (frontend does not fan out per root). +async fn get_project( + State(state): State, + Path(project_id): Path, +) -> Result>, ApiError> { + let detail = state.project.get_project(&project_id).await?; + Ok(Json(ApiResponse::ok(to_detail_response(detail)))) +} + +/// `POST /api/projects/{project_id}/folders` — attach a folder. Returns the +/// single new (or focused-existing) entry so the frontend can splice it in +/// without re-fetching the project. +async fn attach_folder( + State(state): State, + Path(project_id): Path, + body: Result, JsonRejection>, +) -> Result>, ApiError> { + let Json(req) = body.map_err(ApiError::from)?; + let row = state + .project + .attach_folder(AttachInput { + project_id: project_id.clone(), + uri: req.uri, + display_name: req.display_name, + }) + .await?; + + // `attach_folder` returns the bare explorer row (no folder metadata). Re-read + // the project to build the fully-shaped entry (display_path + runtime_status). + // A descendant-attach focuses an existing entry, so match on the returned + // pe_id rather than assuming the last row. + let detail = state.project.get_project(&project_id).await?; + let entry = detail + .explorer + .entries + .into_iter() + .find(|e| e.pe_id == row.pe_id) + .ok_or_else(|| ApiError::Internal("attached entry missing after insert".to_owned()))?; + Ok(Json(ApiResponse::ok(to_entry(entry)))) +} + +/// `DELETE /api/projects/{project_id}/folders/{pe_id}` — detach an attached +/// folder. The workspace root is immutable (`workspace_entry_immutable`). +async fn remove_folder( + State(state): State, + Path((_project_id, pe_id)): Path<(String, String)>, +) -> Result { + state.project.remove_attached(&pe_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +// ── mapping: domain → wire DTO ─────────────────────────────────────────────── + +fn to_detail_response(detail: ProjectDetail) -> ProjectDetailResponse { + ProjectDetailResponse { + project_id: detail.id, + name: detail.name, + explorer: ProjectExplorer { + workspace_pe_id: detail.explorer.workspace_pe_id, + // `get_project` yields entries ordered by order_index ASC. + entries: detail.explorer.entries.into_iter().map(to_entry).collect(), + }, + } +} + +fn to_entry(entry: ProjectExplorerEntry) -> ProjectEntry { + // `display_path` is a human-facing rendering of the folder's original + // resource_uri; fall back to the raw uri if it is not a decodable path. + let display_path = canonical::uri_to_path(&entry.folder.resource_uri) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| entry.folder.resource_uri.clone()); + ProjectEntry { + pe_id: entry.pe_id, + role: entry.role, + display_name: entry.display_name, + display_path, + order_index: entry.order_index, + runtime_status: entry.folder.runtime_status.as_str().to_owned(), + } +} + +// ── error mapping: ProjectError → ApiError (stable domain codes) ───────────── + +impl From for ApiError { + fn from(err: ProjectError) -> Self { + let (status, code, details) = match &err { + ProjectError::ProjectNotFound { project_id } => ( + StatusCode::NOT_FOUND, + "project_not_found", + Some(json!({ "project_id": project_id })), + ), + ProjectError::ProjectExplorerNotFound { pe_id } => ( + StatusCode::NOT_FOUND, + "project_explorer_not_found", + Some(json!({ "pe_id": pe_id })), + ), + ProjectError::ProjectExplorerDuplicate { project_id, folder_id } => ( + StatusCode::CONFLICT, + "project_explorer_duplicate", + Some(json!({ "project_id": project_id, "folder_id": folder_id })), + ), + ProjectError::ProjectExplorerOverlap { project_id } => ( + StatusCode::CONFLICT, + "project_explorer_overlap", + Some(json!({ "project_id": project_id })), + ), + ProjectError::WorkspaceEntryImmutable { pe_id } => ( + StatusCode::CONFLICT, + "workspace_entry_immutable", + Some(json!({ "pe_id": pe_id })), + ), + ProjectError::StandardProjectConflict { folder_id } => ( + StatusCode::CONFLICT, + "standard_project_conflict", + Some(json!({ "folder_id": folder_id })), + ), + ProjectError::WorkspaceFolderMismatch { project_id, folder_id } => ( + StatusCode::CONFLICT, + "workspace_folder_mismatch", + Some(json!({ "project_id": project_id, "folder_id": folder_id })), + ), + ProjectError::FolderNotFound { .. } => (StatusCode::NOT_FOUND, "folder_not_found", None), + ProjectError::FolderNotDirectory { .. } => (StatusCode::BAD_REQUEST, "folder_not_directory", None), + ProjectError::FolderPermissionDenied { .. } => (StatusCode::FORBIDDEN, "folder_permission_denied", None), + ProjectError::FolderCanonicalizeFailed { .. } + | ProjectError::UnsupportedResourceScheme { .. } + | ProjectError::InvalidRelativePath { .. } + | ProjectError::ResourceOutsideFolder { .. } => (StatusCode::BAD_REQUEST, "invalid_resource", None), + ProjectError::TempDirExists { .. } | ProjectError::WorkspaceMissing => { + (StatusCode::BAD_REQUEST, "invalid_request", None) + } + ProjectError::UploadPathOutsideRoot { path } => ( + StatusCode::BAD_REQUEST, + "upload_path_outside_root", + Some(json!({ "path": path })), + ), + ProjectError::ChatFileMissing { path } => ( + StatusCode::NOT_FOUND, + "chat_file_missing", + Some(json!({ "path": path })), + ), + ProjectError::LocalPathNotReadable { path } => ( + StatusCode::BAD_REQUEST, + "local_path_not_readable", + Some(json!({ "path": path })), + ), + ProjectError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error", None), + }; + // Never leak internal DB detail to clients (Security: no internal leakage). + let message = match &err { + ProjectError::Database(_) => "internal error".to_owned(), + other => other.to_string(), + }; + ApiError::coded(status, code, message, details) + } +} + +#[cfg(test)] +#[path = "routes_test.rs"] +mod routes_test; diff --git a/crates/aionui-project/src/routes_test.rs b/crates/aionui-project/src/routes_test.rs new file mode 100644 index 000000000..2476c140c --- /dev/null +++ b/crates/aionui-project/src/routes_test.rs @@ -0,0 +1,251 @@ +//! Route-level tests for the project control plane: response shape, +//! attach idempotency (focus / duplicate / overlap), remove semantics, and +//! error-code mapping. Uses a real in-memory DB store + a fresh tempdir +//! workspace, exercised through the axum router via `oneshot`. + +// `ApiError` is the type under test in the wire-mapping cases below. +#![allow(clippy::disallowed_types)] + +use std::sync::Arc; + +use aionui_common::ApiError; +use aionui_db::{Database, IProjectStore, SqliteProjectStore, init_database_memory}; +use axum::Router; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use axum::response::IntoResponse; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tower::ServiceExt; + +use super::{ProjectRouterState, project_routes}; +use crate::ProjectService; +use crate::canonical::to_file_uri; +use crate::types::ProjectError; + +/// Build a router over an in-memory-DB `ProjectService` with a fresh tempdir +/// registered as the standard (workspace) project. Returns the project_id, the +/// workspace pe_id, and the tempdir + Database (kept alive for the test). +async fn setup() -> (Router, String, String, TempDir, Database) { + let db = init_database_memory().await.unwrap(); + let store: Arc = Arc::new(SqliteProjectStore::new(db.pool().clone())); + let service = Arc::new(ProjectService::new(Arc::clone(&store), std::env::temp_dir())); + + let dir = tempfile::tempdir().unwrap(); + let created = service.create_standard(to_file_uri(dir.path()).unwrap()).await.unwrap(); + let project_id = created.project.project_id; + let workspace_pe_id = created.project_explorer.pe_id; + + let router = project_routes(ProjectRouterState { project: service }); + (router, project_id, workspace_pe_id, dir, db) +} + +/// Fire one request through the router and return `(status, parsed_body)`. +/// An empty body (e.g. 204) parses to `Value::Null`. +async fn send(router: &Router, method: &str, uri: &str, body: Option) -> (StatusCode, Value) { + let builder = Request::builder().method(method).uri(uri); + let request = match body { + Some(v) => builder + .header("content-type", "application/json") + .body(Body::from(v.to_string())) + .unwrap(), + None => builder.body(Body::empty()).unwrap(), + }; + let response = router.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let parsed = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + }; + (status, parsed) +} + +fn folders_url(project_id: &str) -> String { + format!("/api/projects/{project_id}/folders") +} + +/// Render the `From for ApiError` wire mapping to `(status, +/// body)`. These errors are produced by `resolve_chat_message` (called from +/// conversation/team), not by any route in this crate's router, so the mapping +/// arc — HTTP status + stable `code` string the frontend branches on — must be +/// asserted directly rather than through a request. +async fn map_error(err: ProjectError) -> (StatusCode, Value) { + let response = ApiError::from(err).into_response(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body: Value = serde_json::from_slice(&bytes).unwrap(); + (status, body) +} + +#[tokio::test] +async fn local_path_not_readable_maps_to_400_with_stable_code() { + let (status, body) = map_error(ProjectError::LocalPathNotReadable { + path: "/host/file".into(), + }) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "local_path_not_readable"); + assert_eq!(body["success"], false); +} + +#[tokio::test] +async fn upload_path_outside_root_maps_to_400_with_stable_code() { + let (status, body) = map_error(ProjectError::UploadPathOutsideRoot { + path: "/outside/root".into(), + }) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "upload_path_outside_root"); + assert_eq!(body["success"], false); +} + +#[tokio::test] +async fn get_project_returns_workspace_root() { + let (router, project_id, workspace_pe_id, _dir, _db) = setup().await; + + let (status, body) = send(&router, "GET", &format!("/api/projects/{project_id}"), None).await; + assert_eq!(status, StatusCode::OK); + + let data = &body["data"]; + assert_eq!(data["project_id"], project_id); + let entries = data["explorer"]["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["role"], "workspace"); + assert_eq!(entries[0]["pe_id"], workspace_pe_id); + assert_eq!(data["explorer"]["workspace_pe_id"], workspace_pe_id); + assert_eq!(entries[0]["runtime_status"], "available"); + // display_path is derived + non-empty; absolute path / canonical are absent. + assert!(!entries[0]["display_path"].as_str().unwrap().is_empty()); + assert!(entries[0].get("resource_canonical").is_none()); + assert!(entries[0].get("resource_uri").is_none()); + assert!(entries[0].get("folder_id").is_none()); +} + +#[tokio::test] +async fn get_project_not_found_returns_domain_code() { + let (router, _pid, _ws, _dir, _db) = setup().await; + + let (status, body) = send(&router, "GET", "/api/projects/does-not-exist", None).await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(body["code"], "project_not_found"); + assert_eq!(body["success"], false); +} + +#[tokio::test] +async fn attach_new_folder_returns_attached_entry() { + let (router, project_id, _ws, _dir, _db) = setup().await; + let other = tempfile::tempdir().unwrap(); + + let (status, body) = send( + &router, + "POST", + &folders_url(&project_id), + Some(json!({ "uri": to_file_uri(other.path()).unwrap() })), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"]["role"], "attached"); + assert!(!body["data"]["pe_id"].as_str().unwrap().is_empty()); + assert_eq!(body["data"]["order_index"], 1); + + // Now visible as a second root via GET. + let (_s, detail) = send(&router, "GET", &format!("/api/projects/{project_id}"), None).await; + assert_eq!(detail["data"]["explorer"]["entries"].as_array().unwrap().len(), 2); +} + +#[tokio::test] +async fn attach_idempotency_focus_duplicate_overlap() { + let (router, project_id, _ws, _dir, _db) = setup().await; + + // A controlled parent/child hierarchy independent of the workspace dir. + let parent = tempfile::tempdir().unwrap(); + let child = parent.path().join("child"); + std::fs::create_dir(&child).unwrap(); + let grandchild = child.join("g"); + std::fs::create_dir(&grandchild).unwrap(); + + // Attach `child` → new attached entry. + let (status, body) = send( + &router, + "POST", + &folders_url(&project_id), + Some(json!({ "uri": to_file_uri(&child).unwrap() })), + ) + .await; + assert_eq!(status, StatusCode::OK); + let child_pe = body["data"]["pe_id"].as_str().unwrap().to_string(); + + // Attach a descendant of `child` → focus-in-place: 200 returning the SAME entry. + let (status, body) = send( + &router, + "POST", + &folders_url(&project_id), + Some(json!({ "uri": to_file_uri(&grandchild).unwrap() })), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["data"]["pe_id"].as_str().unwrap(), child_pe); + + // Attach the exact same folder again → 409 duplicate. + let (status, body) = send( + &router, + "POST", + &folders_url(&project_id), + Some(json!({ "uri": to_file_uri(&child).unwrap() })), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body["code"], "project_explorer_duplicate"); + + // Attach an ancestor of an existing entry → 409 overlap. + let (status, body) = send( + &router, + "POST", + &folders_url(&project_id), + Some(json!({ "uri": to_file_uri(parent.path()).unwrap() })), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body["code"], "project_explorer_overlap"); +} + +#[tokio::test] +async fn remove_attached_then_workspace_immutable() { + let (router, project_id, workspace_pe_id, _dir, _db) = setup().await; + let other = tempfile::tempdir().unwrap(); + + let (_s, body) = send( + &router, + "POST", + &folders_url(&project_id), + Some(json!({ "uri": to_file_uri(other.path()).unwrap() })), + ) + .await; + let attached_pe = body["data"]["pe_id"].as_str().unwrap().to_string(); + + // Remove the attached root → 204, gone from the project. + let (status, _b) = send( + &router, + "DELETE", + &format!("/api/projects/{project_id}/folders/{attached_pe}"), + None, + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT); + let (_s, detail) = send(&router, "GET", &format!("/api/projects/{project_id}"), None).await; + assert_eq!(detail["data"]["explorer"]["entries"].as_array().unwrap().len(), 1); + + // The workspace root cannot be removed → 409 with a stable code. + let (status, body) = send( + &router, + "DELETE", + &format!("/api/projects/{project_id}/folders/{workspace_pe_id}"), + None, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body["code"], "workspace_entry_immutable"); +} diff --git a/crates/aionui-project/src/runtime/actor.rs b/crates/aionui-project/src/runtime/actor.rs new file mode 100644 index 000000000..73e213351 --- /dev/null +++ b/crates/aionui-project/src/runtime/actor.rs @@ -0,0 +1,293 @@ +//! Shard — a sequential worker owning one canonical-hash slice of the runtime. +//! +//! Concurrency model (see `formal/runtime/runtime.md`): WS handlers, watcher +//! callbacks, the grace reaper, and disconnects all touch the tree + registry. +//! Rather than locking, a shard is a **sequential worker** (a task draining an +//! mpsc command queue) that exclusively owns its `Node`s, the `reverse` +//! subscriber index, watch registration, and grace/reaper. Same-canonical +//! mount/apply/unmount are serial for free; fan-out reads local state; cross- +//! shard work runs in parallel. Desktop uses **N = 1** (a single shard, all +//! serial — simplest). `forward` (session → canonicals) is global; this stage-0 +//! core models one shard. +//! +//! [`Shard::handle`] is the whole decision core — the spawned worker is just a +//! loop over it — so command sequences unit-test every lifecycle and edge-race +//! guard deterministically, with no real tasks, timers, or channel races. + +use std::collections::BTreeSet; +use std::collections::HashMap; + +use crate::runtime::subscription::Millis; + +use super::error::FsError; +use super::subscription::{SubscribeOutcome, Subscriber, SubscriptionRegistry}; +use super::tree_model::{DeltaBatch, Hint, Snapshot, TreeModel}; +use super::watcher::RawEvent; + +/// A command processed serially by a shard worker. `Mount`/`Unmount` are not +/// external commands — they are internal effects of subscribe/reap. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + Subscribe { + sub: Subscriber, + canonical: String, + now: Millis, + }, + Unsubscribe { + sub: Subscriber, + canonical: String, + now: Millis, + }, + Apply { + canonical: String, + hint: Hint, + }, + Overflow { + canonical: String, + }, + DropSession { + session: String, + now: Millis, + }, + ReapTick { + now: Millis, + }, +} + +/// A canonical-domain output. Fan-out to the pe-keyed wire is the WS handler's +/// job (stage 1); here each output carries the recipient subscribers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShardOutput { + /// Full listing (subscribe reply, or overflow rescan) to these subscribers. + Snapshot { + subscribers: Vec, + snapshot: Snapshot, + }, + /// Incremental changes fanned out to current subscribers. + Delta { + subscribers: Vec, + delta: DeltaBatch, + }, +} + +/// One shard: its tree slice, its subscription state, and its warm-node budget. +pub struct Shard { + tree: TreeModel, + subs: SubscriptionRegistry, + warm_budget: usize, +} + +/// Map a raw watcher event to the command the debounce stage would enqueue. +/// `Changed` → re-check the affected child names; `Overflow` → rescan. +pub fn raw_to_command(event: RawEvent) -> Command { + match event { + RawEvent::Changed { canonical, paths } => { + // Non-recursive watch → each path is a direct child; the hint is its + // file name (apply re-stats those children against the fresh fs). + let child_names = paths + .iter() + .filter_map(|p| { + std::path::Path::new(p) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + }) + .collect(); + Command::Apply { + canonical, + hint: Hint::ChildNames(child_names), + } + } + RawEvent::Overflow { canonical } => Command::Overflow { canonical }, + } +} + +/// Per-canonical pending state while coalescing raw events within a debounce +/// window. `Overflow` supersedes `Changed` — a rescan already covers any child. +enum Pending { + Changed(BTreeSet), + Overflow, +} + +/// Debounce/coalesce stage of the apply pipeline: collapses a burst of raw +/// events (editor saves, bulk writes) per canonical into one command, so the +/// tree applies once and one delta carries many changes. Pure and clock-free — +/// the caller flushes on its own timer (the worker's ~200ms window); this keeps +/// coalescing deterministically testable. See stage-0 impl notes for why the +/// window lives here (pipeline stage) rather than inside the watcher. +#[derive(Default)] +pub struct Debouncer { + pending: HashMap, +} + +impl Debouncer { + pub fn new() -> Self { + Self::default() + } + + /// Accumulate one raw event into the pending set. + pub fn push(&mut self, event: RawEvent) { + match event { + RawEvent::Overflow { canonical } => { + self.pending.insert(canonical, Pending::Overflow); + } + RawEvent::Changed { canonical, paths } => { + let names = paths.iter().filter_map(|p| { + std::path::Path::new(p) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + }); + match self + .pending + .entry(canonical) + .or_insert_with(|| Pending::Changed(BTreeSet::new())) + { + // Overflow already pending → rescan subsumes these children. + Pending::Overflow => {} + Pending::Changed(set) => set.extend(names), + } + } + } + } + + /// Drain all pending state into coalesced commands (order-stable by + /// canonical), clearing the buffer. + pub fn drain(&mut self) -> Vec { + let mut entries: Vec<(String, Pending)> = self.pending.drain().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + entries + .into_iter() + .map(|(canonical, pending)| match pending { + Pending::Overflow => Command::Overflow { canonical }, + Pending::Changed(names) => Command::Apply { + canonical, + hint: Hint::ChildNames(names.into_iter().collect()), + }, + }) + .collect() + } + + /// Whether any events are buffered. + pub fn is_empty(&self) -> bool { + self.pending.is_empty() + } +} + +impl Shard { + pub fn new(tree: TreeModel, warm_budget: usize) -> Self { + Self { + tree, + subs: SubscriptionRegistry::new(), + warm_budget, + } + } + + /// Process one command, mutating owned state and returning any outputs. + pub async fn handle(&mut self, command: Command) -> Result, FsError> { + match command { + Command::Subscribe { sub, canonical, now } => { + let outcome = self.subs.subscribe(sub.clone(), &canonical, now); + // Deliver the current listing to the subscribing session. On a + // cold canonical this mounts (arm watch → baseline); a warm node + // rescued from grace, or an already-live node, serves its node. + let snapshot = match outcome { + SubscribeOutcome::Mount => self.tree.mount(&canonical).await?, + SubscribeOutcome::RescuedFromGrace | SubscribeOutcome::AlreadyLive => { + match self.tree.snapshot(&canonical) { + Some(snap) => snap, + // Warm in subs but node gone (shouldn't happen while + // watch is held) → re-mount defensively. + None => self.tree.mount(&canonical).await?, + } + } + }; + Ok(vec![ShardOutput::Snapshot { + subscribers: vec![sub], + snapshot, + }]) + } + + Command::Unsubscribe { sub, canonical, now } => { + // Emptying enters grace: the node stays mounted + watched for the + // TTL; the reaper unmounts later. No immediate output. + let _ = self.subs.unsubscribe(&sub, &canonical, now); + Ok(Vec::new()) + } + + Command::Apply { canonical, hint } => { + // Reconcile; unmounted node (in-flight event after unmount) → + // None → drop silently. + match self.tree.apply(&canonical, hint).await? { + Some(delta) => { + let subscribers = self.recipients(&canonical); + if subscribers.is_empty() { + Ok(Vec::new()) + } else { + Ok(vec![ShardOutput::Delta { subscribers, delta }]) + } + } + None => Ok(Vec::new()), + } + } + + Command::Overflow { canonical } => { + // Kernel dropped events → node facts are stale. Rescan (apply All + // to refresh) and push the full snapshot; subscribers replace the + // whole level. Unmounted → drop. + if self.tree.snapshot(&canonical).is_none() { + return Ok(Vec::new()); + } + self.tree.apply(&canonical, Hint::All).await?; + let subscribers = self.recipients(&canonical); + match (subscribers.is_empty(), self.tree.snapshot(&canonical)) { + (false, Some(snapshot)) => Ok(vec![ShardOutput::Snapshot { subscribers, snapshot }]), + _ => Ok(Vec::new()), + } + } + + Command::DropSession { session, now } => { + // Disconnect: remove all of this session's subscriptions. Emptied + // nodes go warm (grace); reaper unmounts later. No output. + let _ = self.subs.drop_session(&session, now); + Ok(Vec::new()) + } + + Command::ReapTick { now } => { + // TTL-expired warm nodes, then over-budget warm nodes (warm-LRU), + // are unmounted + unwatched. + let mut grace_expired = 0usize; + for canonical in self.subs.reap(now) { + self.tree.unmount(&canonical); + grace_expired += 1; + } + let mut budget_evicted = 0usize; + for canonical in self.subs.enforce_watch_budget(self.warm_budget) { + self.tree.unmount(&canonical); + budget_evicted += 1; + } + // Low-volume lifecycle boundary (only when something was evicted); + // counts only, no paths. + if grace_expired + budget_evicted > 0 { + tracing::info!(grace_expired, budget_evicted, "fs grace evict: unmounted warm nodes"); + } + Ok(Vec::new()) + } + } + } + + /// Whether `canonical` is currently mounted+watched (live or warm-in-grace). + pub fn is_watched(&self, canonical: &str) -> bool { + self.subs.is_watched(canonical) + } + + /// Current subscribers of a canonical as an owned vec (fan-out recipients). + fn recipients(&self, canonical: &str) -> Vec { + self.subs + .subscribers_of(canonical) + .map(|set| set.iter().cloned().collect()) + .unwrap_or_default() + } +} + +#[cfg(test)] +#[path = "actor_test.rs"] +mod actor_test; diff --git a/crates/aionui-project/src/runtime/actor_test.rs b/crates/aionui-project/src/runtime/actor_test.rs new file mode 100644 index 000000000..670d7be04 --- /dev/null +++ b/crates/aionui-project/src/runtime/actor_test.rs @@ -0,0 +1,447 @@ +use std::path::Path; +use std::sync::Arc; + +use tempfile::{TempDir, tempdir}; + +use crate::canonical::{self, Canonical}; +use crate::runtime::LocalFsRuntime; +use crate::runtime::fs_runtime::FsRuntimeRegistry; +use crate::runtime::subscription::{GRACE_TTL_MS, Subscriber}; +use crate::runtime::tree_model::{Change, Hint, TreeModel}; +use crate::runtime::watcher::RawEvent; + +use super::{Command, Shard, ShardOutput, raw_to_command}; + +fn canon(path: &Path) -> Canonical { + let uri = canonical::to_file_uri(path).expect("to_file_uri"); + canonical::canonicalize(&uri).expect("canonicalize") +} + +fn real_shard(budget: usize) -> (Shard, TempDir) { + let (runtime, _rx) = LocalFsRuntime::new().unwrap(); + let mut registry = FsRuntimeRegistry::new(); + registry.register("file", Arc::new(runtime)); + (Shard::new(TreeModel::new(registry), budget), tempdir().unwrap()) +} + +fn sub(session: &str) -> Subscriber { + Subscriber { + session: session.to_owned(), + pe_id: "pe1".to_owned(), + rel: String::new(), + } +} + +#[tokio::test] +async fn subscribe_mounts_and_returns_snapshot_to_subscriber() { + let (mut shard, dir) = real_shard(100); + std::fs::write(dir.path().join("a.txt"), b"x").unwrap(); + let c = canon(dir.path()).as_str().to_owned(); + + let out = shard + .handle(Command::Subscribe { + sub: sub("s1"), + canonical: c.clone(), + now: 0, + }) + .await + .unwrap(); + + match out.as_slice() { + [ShardOutput::Snapshot { subscribers, snapshot }] => { + assert_eq!(subscribers, &vec![sub("s1")]); + assert_eq!( + snapshot.entries.iter().map(|(n, _)| n.as_str()).collect::>(), + vec!["a.txt"] + ); + } + other => panic!("expected one Snapshot, got {other:?}"), + } + assert!(shard.is_watched(&c)); +} + +#[tokio::test] +async fn already_live_subscriber_gets_snapshot_to_self_only() { + let (mut shard, dir) = real_shard(100); + std::fs::write(dir.path().join("a.txt"), b"x").unwrap(); + let c = canon(dir.path()).as_str().to_owned(); + + // First subscriber mounts. + shard + .handle(Command::Subscribe { + sub: sub("s1"), + canonical: c.clone(), + now: 0, + }) + .await + .unwrap(); + + // A second, later subscriber on the same live canonical must immediately get + // the current listing — delivered only to itself, never re-sent to s1. + let out = shard + .handle(Command::Subscribe { + sub: sub("s2"), + canonical: c.clone(), + now: 0, + }) + .await + .unwrap(); + + match out.as_slice() { + [ShardOutput::Snapshot { subscribers, snapshot }] => { + assert_eq!(subscribers, &vec![sub("s2")], "snapshot goes to s2 alone"); + assert_eq!( + snapshot.entries.iter().map(|(n, _)| n.as_str()).collect::>(), + vec!["a.txt"] + ); + } + other => panic!("expected one Snapshot to s2, got {other:?}"), + } +} + +#[tokio::test] +async fn apply_fans_delta_to_current_subscribers() { + let (mut shard, dir) = real_shard(100); + let c = canon(dir.path()).as_str().to_owned(); + shard + .handle(Command::Subscribe { + sub: sub("s1"), + canonical: c.clone(), + now: 0, + }) + .await + .unwrap(); + + std::fs::write(dir.path().join("new.txt"), b"x").unwrap(); + let out = shard + .handle(Command::Apply { + canonical: c.clone(), + hint: Hint::ChildNames(vec!["new.txt".to_owned()]), + }) + .await + .unwrap(); + + match out.as_slice() { + [ShardOutput::Delta { subscribers, delta }] => { + assert_eq!(subscribers, &vec![sub("s1")]); + assert_eq!( + delta.changes, + vec![Change::Added { + name: "new.txt".to_owned(), + kind: crate::runtime::provider::Kind::File + }] + ); + } + other => panic!("expected one Delta, got {other:?}"), + } +} + +#[tokio::test] +async fn overflow_rescans_and_pushes_full_snapshot() { + let (mut shard, dir) = real_shard(100); + let c = canon(dir.path()).as_str().to_owned(); + shard + .handle(Command::Subscribe { + sub: sub("s1"), + canonical: c.clone(), + now: 0, + }) + .await + .unwrap(); + + // Bulk change while "kernel dropped events" → overflow forces a full rescan. + std::fs::write(dir.path().join("x.txt"), b"x").unwrap(); + std::fs::write(dir.path().join("y.txt"), b"y").unwrap(); + + let out = shard.handle(Command::Overflow { canonical: c.clone() }).await.unwrap(); + match out.as_slice() { + [ShardOutput::Snapshot { subscribers, snapshot }] => { + assert_eq!(subscribers, &vec![sub("s1")]); + let mut names: Vec<&str> = snapshot.entries.iter().map(|(n, _)| n.as_str()).collect(); + names.sort(); + assert_eq!(names, vec!["x.txt", "y.txt"]); + } + other => panic!("expected one Snapshot, got {other:?}"), + } +} + +#[tokio::test] +async fn guard_apply_on_unmounted_is_silent() { + let (mut shard, dir) = real_shard(100); + let c = canon(dir.path()).as_str().to_owned(); + // Never subscribed → never mounted. An in-flight event must produce nothing. + let out = shard + .handle(Command::Apply { + canonical: c, + hint: Hint::All, + }) + .await + .unwrap(); + assert!(out.is_empty()); +} + +#[tokio::test] +async fn guard_overflow_on_unmounted_is_silent() { + let (mut shard, dir) = real_shard(100); + let c = canon(dir.path()).as_str().to_owned(); + // Never subscribed → never mounted. An in-flight overflow (kernel drop after + // unwatch) must be dropped silently — symmetric to the Apply guard. + let out = shard.handle(Command::Overflow { canonical: c }).await.unwrap(); + assert!(out.is_empty()); +} + +#[tokio::test] +async fn guard_mount_then_disconnect_graces_then_reap_unmounts() { + let (mut shard, dir) = real_shard(100); + let c = canon(dir.path()).as_str().to_owned(); + shard + .handle(Command::Subscribe { + sub: sub("s1"), + canonical: c.clone(), + now: 0, + }) + .await + .unwrap(); + assert!(shard.is_watched(&c)); + + // Disconnect before anyone else subscribed → node kept warm (grace). + shard + .handle(Command::DropSession { + session: "s1".to_owned(), + now: 0, + }) + .await + .unwrap(); + assert!(shard.is_watched(&c), "still warm during grace"); + + // TTL elapses → reap unmounts + unwatches. + shard.handle(Command::ReapTick { now: GRACE_TTL_MS }).await.unwrap(); + assert!(!shard.is_watched(&c), "unmounted after grace TTL"); + + // Post-unmount in-flight event is dropped (no panic, no output). + let out = shard + .handle(Command::Apply { + canonical: c, + hint: Hint::All, + }) + .await + .unwrap(); + assert!(out.is_empty()); +} + +#[tokio::test] +async fn grace_rescue_keeps_node_and_cancels_reap() { + let (mut shard, dir) = real_shard(100); + let c = canon(dir.path()).as_str().to_owned(); + shard + .handle(Command::Subscribe { + sub: sub("s1"), + canonical: c.clone(), + now: 0, + }) + .await + .unwrap(); + shard + .handle(Command::Unsubscribe { + sub: sub("s1"), + canonical: c.clone(), + now: 0, + }) + .await + .unwrap(); + // Rescue within TTL: the rescued subscriber must also immediately receive the + // current listing (RescuedFromGrace serves the kept-warm node's snapshot). + let out = shard + .handle(Command::Subscribe { + sub: sub("s1"), + canonical: c.clone(), + now: 100, + }) + .await + .unwrap(); + match out.as_slice() { + [ShardOutput::Snapshot { subscribers, .. }] => { + assert_eq!(subscribers, &vec![sub("s1")], "rescued subscriber gets a snapshot"); + } + other => panic!("expected one Snapshot on rescue, got {other:?}"), + } + + // Well past original TTL: still watched (reap finds nothing to evict). + shard + .handle(Command::ReapTick { now: GRACE_TTL_MS * 10 }) + .await + .unwrap(); + assert!(shard.is_watched(&c)); +} + +#[tokio::test] +async fn warm_lru_evicts_oldest_over_budget_on_reap() { + let (mut shard, dir_a) = real_shard(1); + let dir_b = tempdir().unwrap(); + let ca = canon(dir_a.path()).as_str().to_owned(); + let cb = canon(dir_b.path()).as_str().to_owned(); + + shard + .handle(Command::Subscribe { + sub: sub("s1"), + canonical: ca.clone(), + now: 0, + }) + .await + .unwrap(); + shard + .handle(Command::Subscribe { + sub: sub("s1"), + canonical: cb.clone(), + now: 0, + }) + .await + .unwrap(); + shard + .handle(Command::Unsubscribe { + sub: sub("s1"), + canonical: ca.clone(), + now: 10, + }) + .await + .unwrap(); + shard + .handle(Command::Unsubscribe { + sub: sub("s1"), + canonical: cb.clone(), + now: 20, + }) + .await + .unwrap(); + + // Budget 1, both warm → reap enforces budget, evicts oldest (ca). + shard.handle(Command::ReapTick { now: 0 }).await.unwrap(); + assert!(!shard.is_watched(&ca), "oldest warm node evicted"); + assert!(shard.is_watched(&cb), "newest warm node kept"); +} + +#[test] +fn raw_changed_maps_to_apply_child_names() { + let event = RawEvent::Changed { + canonical: "file:///work/app".to_owned(), + paths: vec!["/work/app/a.txt".to_owned(), "/work/app/b.txt".to_owned()], + }; + assert_eq!( + raw_to_command(event), + Command::Apply { + canonical: "file:///work/app".to_owned(), + hint: Hint::ChildNames(vec!["a.txt".to_owned(), "b.txt".to_owned()]), + } + ); +} + +#[test] +fn raw_overflow_maps_to_overflow_command() { + let event = RawEvent::Overflow { + canonical: "file:///work/app".to_owned(), + }; + assert_eq!( + raw_to_command(event), + Command::Overflow { + canonical: "file:///work/app".to_owned() + } + ); +} + +// ── Debounce coalescing (pure, clock-free) ──────────────────────────────── + +#[test] +fn debouncer_coalesces_changed_by_canonical() { + let mut d = super::Debouncer::new(); + d.push(RawEvent::Changed { + canonical: "file:///c".to_owned(), + paths: vec!["/c/a.txt".to_owned()], + }); + d.push(RawEvent::Changed { + canonical: "file:///c".to_owned(), + paths: vec!["/c/b.txt".to_owned(), "/c/a.txt".to_owned()], + }); + + assert_eq!( + d.drain(), + vec![Command::Apply { + canonical: "file:///c".to_owned(), + hint: Hint::ChildNames(vec!["a.txt".to_owned(), "b.txt".to_owned()]), + }] + ); + assert!(d.is_empty()); +} + +#[test] +fn debouncer_overflow_supersedes_changed() { + let mut d = super::Debouncer::new(); + d.push(RawEvent::Changed { + canonical: "file:///c".to_owned(), + paths: vec!["/c/a.txt".to_owned()], + }); + d.push(RawEvent::Overflow { + canonical: "file:///c".to_owned(), + }); + d.push(RawEvent::Changed { + canonical: "file:///c".to_owned(), + paths: vec!["/c/b.txt".to_owned()], + }); + + assert_eq!( + d.drain(), + vec![Command::Overflow { + canonical: "file:///c".to_owned() + }] + ); +} + +// ── Real end-to-end wiring: watcher → map → shard → output ───────────────── + +#[tokio::test] +async fn end_to_end_watch_change_produces_delta() { + use std::time::Duration; + use tokio::sync::mpsc::UnboundedReceiver; + + // Build a shard sharing the SAME runtime whose watcher feeds `rx`. + let (runtime, mut rx): (_, UnboundedReceiver) = LocalFsRuntime::new().unwrap(); + let mut registry = FsRuntimeRegistry::new(); + registry.register("file", Arc::new(runtime)); + let mut shard = Shard::new(TreeModel::new(registry), 100); + + let dir = tempdir().unwrap(); + let c = canon(dir.path()).as_str().to_owned(); + + // Subscribe arms the watch (via mount) and returns the baseline. + shard + .handle(Command::Subscribe { + sub: sub("s1"), + canonical: c.clone(), + now: 0, + }) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(150)).await; + + // Change the directory; drive one raw event through the real pipeline. + std::fs::write(dir.path().join("live.ts"), b"x").unwrap(); + + let delta = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let ev = rx.recv().await.expect("watcher event"); + let cmd = raw_to_command(ev); + let out = shard.handle(cmd).await.unwrap(); + if let Some(ShardOutput::Delta { delta, .. }) = out.into_iter().next() + && delta + .changes + .iter() + .any(|ch| matches!(ch, Change::Added { name, .. } if name == "live.ts")) + { + return delta; + } + } + }) + .await + .expect("a delta adding live.ts"); + + assert_eq!(delta.canonical, c); +} diff --git a/crates/aionui-project/src/runtime/error.rs b/crates/aionui-project/src/runtime/error.rs new file mode 100644 index 000000000..bcfb3a128 --- /dev/null +++ b/crates/aionui-project/src/runtime/error.rs @@ -0,0 +1,28 @@ +//! Runtime-link filesystem errors. +//! +//! Distinct from the bind-domain [`crate::types::ProjectError`]: these describe +//! provider IO outcomes (read_dir/stat/read/write/...). Wire-layer mapping to +//! JSON-RPC protocol codes (`resource_not_found`, `provider_unavailable`, ...) +//! lives in the WS handler (runtime link stage 1), not here. + +/// A filesystem provider operation error. +#[derive(Debug, thiserror::Error)] +pub enum FsError { + #[error("resource not found: {uri}")] + NotFound { uri: String }, + + #[error("resource already exists: {uri}")] + AlreadyExists { uri: String }, + + #[error("permission denied: {uri}")] + PermissionDenied { uri: String }, + + #[error("not a directory: {uri}")] + NotADirectory { uri: String }, + + #[error("unsupported resource scheme: {scheme}")] + UnsupportedScheme { scheme: String }, + + #[error("io error on {uri}: {message}")] + Io { uri: String, message: String }, +} diff --git a/crates/aionui-project/src/runtime/fs_runtime.rs b/crates/aionui-project/src/runtime/fs_runtime.rs new file mode 100644 index 000000000..3c2802160 --- /dev/null +++ b/crates/aionui-project/src/runtime/fs_runtime.rs @@ -0,0 +1,97 @@ +//! `IFsRuntime` — a provider + watcher pair for one scheme, and the +//! scheme → runtime registry the tree model dispatches through. +//! +//! `LocalFsRuntime` composes [`LocalFsProvider`] + [`LocalWatcher`] for `file:` +//! and declares `Inline` IO (local disk IO is ~ms, safe to `await` on the shard +//! worker). `Offload` is reserved for slow/remote providers. + +use std::collections::HashMap; +use std::sync::Arc; + +use tokio::sync::mpsc::UnboundedReceiver; + +use super::error::FsError; +use super::local_provider::LocalFsProvider; +use super::local_watcher::LocalWatcher; +use super::provider::IFsProvider; +use super::watcher::{RawEvent, Watcher}; + +/// How a provider's `read_dir`/`stat` IO is executed on the shard worker. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IoDispatch { + /// `await` inline on the worker (local, ~ms). Blocks the shard briefly. + Inline, + /// Spawn IO off-worker with a per-canonical busy flag (slow/remote). + Offload, +} + +/// A filesystem runtime for one scheme: its data-operation provider, its watch +/// half, and its IO dispatch strategy. +pub trait IFsRuntime: Send + Sync { + fn provider(&self) -> &dyn IFsProvider; + fn watcher(&self) -> &dyn Watcher; + fn io_dispatch(&self) -> IoDispatch; +} + +/// `file:` runtime: local provider + non-recursive local watcher, `Inline` IO. +pub struct LocalFsRuntime { + provider: LocalFsProvider, + watcher: LocalWatcher, +} + +impl LocalFsRuntime { + /// Build the runtime and the raw-event receiver its watcher feeds. The + /// caller (actor layer) owns the receiver and pumps it into apply. + pub fn new() -> Result<(Self, UnboundedReceiver), FsError> { + let (watcher, rx) = LocalWatcher::new()?; + Ok(( + Self { + provider: LocalFsProvider::new(), + watcher, + }, + rx, + )) + } +} + +impl IFsRuntime for LocalFsRuntime { + fn provider(&self) -> &dyn IFsProvider { + &self.provider + } + + fn watcher(&self) -> &dyn Watcher { + &self.watcher + } + + fn io_dispatch(&self) -> IoDispatch { + IoDispatch::Inline + } +} + +/// scheme → runtime. The tree model derives a canonical's scheme and dispatches +/// provider/watcher calls through the matching runtime. +#[derive(Default)] +pub struct FsRuntimeRegistry { + by_scheme: HashMap>, +} + +impl FsRuntimeRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Register the runtime serving `scheme` (e.g. `"file"`). + pub fn register(&mut self, scheme: impl Into, runtime: Arc) { + self.by_scheme.insert(scheme.into(), runtime); + } + + /// Look up the runtime for `scheme`; `None` if unregistered (the wire layer + /// maps that to `unsupported_resource_scheme`). + pub fn get(&self, scheme: &str) -> Option> { + self.by_scheme.get(scheme).map(Arc::clone) + } +} + +#[cfg(test)] +#[path = "fs_runtime_test.rs"] +mod fs_runtime_test; diff --git a/crates/aionui-project/src/runtime/fs_runtime_test.rs b/crates/aionui-project/src/runtime/fs_runtime_test.rs new file mode 100644 index 000000000..77e3a155a --- /dev/null +++ b/crates/aionui-project/src/runtime/fs_runtime_test.rs @@ -0,0 +1,21 @@ +use std::sync::Arc; + +use super::{FsRuntimeRegistry, IFsRuntime, IoDispatch, LocalFsRuntime}; + +#[tokio::test] +async fn local_runtime_is_inline_and_serves_file_scheme() { + let (runtime, _rx) = LocalFsRuntime::new().unwrap(); + assert_eq!(runtime.io_dispatch(), IoDispatch::Inline); + assert_eq!(runtime.provider().scheme(), "file"); +} + +#[tokio::test] +async fn registry_dispatches_by_scheme() { + let (runtime, _rx) = LocalFsRuntime::new().unwrap(); + let mut registry = FsRuntimeRegistry::new(); + registry.register("file", Arc::new(runtime)); + + let got = registry.get("file").expect("file runtime registered"); + assert_eq!(got.provider().scheme(), "file"); + assert!(registry.get("ssh").is_none()); +} diff --git a/crates/aionui-project/src/runtime/local_provider.rs b/crates/aionui-project/src/runtime/local_provider.rs new file mode 100644 index 000000000..eba45b6be --- /dev/null +++ b/crates/aionui-project/src/runtime/local_provider.rs @@ -0,0 +1,208 @@ +//! `LocalFsProvider` — `file:` scheme [`IFsProvider`] over the local disk. +//! +//! Canonical `file:` URIs are turned into filesystem paths via +//! [`crate::canonical::fs_path`]; all IO goes through `tokio::fs`. +//! +//! TODO(stage-1): realpath containment. Lexical containment already lives in +//! [`crate::containment`] (the reference layer). The access-time boundary — +//! realpath the target before IO and reject symlink/alias escapes out of the +//! Folder root — belongs on the command path and is deferred to the WS handler +//! stage. This provider currently performs no realpath containment. + +use std::io; +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; + +use crate::canonical; + +use super::error::FsError; +use super::provider::{EntryFact, IFsProvider, Kind}; + +/// Local-disk provider for the `file:` scheme. +#[derive(Debug, Default, Clone)] +pub(crate) struct LocalFsProvider; + +impl LocalFsProvider { + pub fn new() -> Self { + Self + } +} + +/// Resolve a `file:` URI to a filesystem path, mapping parse failure to +/// [`FsError::Io`] (a malformed URI is a caller/plumbing fault, not a fs state). +fn path_of(uri: &str) -> Result { + canonical::uri_to_path(uri).map_err(|_| FsError::Io { + uri: uri.to_owned(), + message: "invalid file uri".to_owned(), + }) +} + +/// Map a std IO error against `uri` to the provider error taxonomy. +fn map_io(uri: &str, err: &io::Error) -> FsError { + match err.kind() { + io::ErrorKind::NotFound => FsError::NotFound { uri: uri.to_owned() }, + io::ErrorKind::PermissionDenied => FsError::PermissionDenied { uri: uri.to_owned() }, + io::ErrorKind::AlreadyExists => FsError::AlreadyExists { uri: uri.to_owned() }, + io::ErrorKind::NotADirectory => FsError::NotADirectory { uri: uri.to_owned() }, + _ => FsError::Io { + uri: uri.to_owned(), + message: err.to_string(), + }, + } +} + +/// Inode of a file's metadata (0 on platforms without a stable inode). +#[cfg(unix)] +fn inode_of(meta: &std::fs::Metadata) -> u64 { + std::os::unix::fs::MetadataExt::ino(meta) +} +#[cfg(not(unix))] +fn inode_of(_meta: &std::fs::Metadata) -> u64 { + 0 +} + +/// Recursively copy a directory tree using an explicit work stack (avoids +/// boxing an async-recursive fn). Symlinks are copied as their link target +/// content via `fs::copy`, matching shallow-copy semantics. +async fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> { + let mut stack = vec![(src.to_path_buf(), dst.to_path_buf())]; + while let Some((from, to)) = stack.pop() { + tokio::fs::create_dir_all(&to).await?; + let mut rd = tokio::fs::read_dir(&from).await?; + while let Some(entry) = rd.next_entry().await? { + let child_from = entry.path(); + let child_to = to.join(entry.file_name()); + if entry.file_type().await?.is_dir() { + stack.push((child_from, child_to)); + } else { + tokio::fs::copy(&child_from, &child_to).await?; + } + } + } + Ok(()) +} + +/// Build an [`EntryFact`] from a path via `symlink_metadata` (does not follow +/// symlinks — a symlink is its own kind, matching the folder-identity rule that +/// realpath folding is deferred to the access-time containment boundary). +async fn fact_of(uri: &str, path: &Path) -> Result { + let meta = tokio::fs::symlink_metadata(path).await.map_err(|e| map_io(uri, &e))?; + let ft = meta.file_type(); + let (kind, symlink_target) = if ft.is_symlink() { + let target = tokio::fs::read_link(path) + .await + .ok() + .map(|p| p.to_string_lossy().into_owned()); + (Kind::Symlink, target) + } else if ft.is_dir() { + (Kind::Dir, None) + } else { + (Kind::File, None) + }; + Ok(EntryFact { + kind, + inode: inode_of(&meta), + symlink_target, + }) +} + +#[async_trait] +impl IFsProvider for LocalFsProvider { + fn scheme(&self) -> &str { + "file" + } + + async fn read_dir(&self, uri: &str) -> Result, FsError> { + let dir = path_of(uri)?; + let mut rd = tokio::fs::read_dir(&dir).await.map_err(|e| map_io(uri, &e))?; + let mut out = Vec::new(); + while let Some(entry) = rd.next_entry().await.map_err(|e| map_io(uri, &e))? { + let name = entry.file_name().to_string_lossy().into_owned(); + let child = entry.path(); + let child_uri = canonical::to_file_uri(&child).unwrap_or_else(|_| uri.to_owned()); + let fact = fact_of(&child_uri, &child).await?; + out.push((name, fact)); + } + Ok(out) + } + + async fn stat(&self, uri: &str) -> Result, FsError> { + let path = path_of(uri)?; + match fact_of(uri, &path).await { + Ok(fact) => Ok(Some(fact)), + Err(FsError::NotFound { .. }) => Ok(None), + Err(e) => Err(e), + } + } + + async fn read(&self, uri: &str) -> Result, FsError> { + let path = path_of(uri)?; + tokio::fs::read(&path).await.map_err(|e| map_io(uri, &e)) + } + + async fn write(&self, uri: &str, data: &[u8]) -> Result<(), FsError> { + let path = path_of(uri)?; + tokio::fs::write(&path, data).await.map_err(|e| map_io(uri, &e)) + } + + async fn create_file(&self, uri: &str) -> Result<(), FsError> { + let path = path_of(uri)?; + // create_new fails with AlreadyExists rather than truncating. + tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .await + .map(|_| ()) + .map_err(|e| map_io(uri, &e)) + } + + async fn mkdir(&self, uri: &str) -> Result<(), FsError> { + let path = path_of(uri)?; + tokio::fs::create_dir(&path).await.map_err(|e| map_io(uri, &e)) + } + + async fn remove(&self, uri: &str, recursive: bool) -> Result<(), FsError> { + let path = path_of(uri)?; + let meta = tokio::fs::symlink_metadata(&path).await.map_err(|e| map_io(uri, &e))?; + let res = if meta.is_dir() { + if recursive { + tokio::fs::remove_dir_all(&path).await + } else { + tokio::fs::remove_dir(&path).await + } + } else { + tokio::fs::remove_file(&path).await + }; + res.map_err(|e| map_io(uri, &e)) + } + + async fn rename(&self, from: &str, to: &str) -> Result<(), FsError> { + let (src, dst) = (path_of(from)?, path_of(to)?); + tokio::fs::rename(&src, &dst).await.map_err(|e| map_io(from, &e)) + } + + async fn copy(&self, from: &str, to: &str, recursive: bool) -> Result<(), FsError> { + let (src, dst) = (path_of(from)?, path_of(to)?); + let meta = tokio::fs::symlink_metadata(&src).await.map_err(|e| map_io(from, &e))?; + if meta.is_dir() { + if !recursive { + return Err(FsError::Io { + uri: from.to_owned(), + message: "cannot copy directory without recursive".to_owned(), + }); + } + copy_dir_recursive(&src, &dst).await.map_err(|e| map_io(from, &e)) + } else { + tokio::fs::copy(&src, &dst) + .await + .map(|_| ()) + .map_err(|e| map_io(from, &e)) + } + } +} + +#[cfg(test)] +#[path = "local_provider_test.rs"] +mod local_provider_test; diff --git a/crates/aionui-project/src/runtime/local_provider_test.rs b/crates/aionui-project/src/runtime/local_provider_test.rs new file mode 100644 index 000000000..531e997b5 --- /dev/null +++ b/crates/aionui-project/src/runtime/local_provider_test.rs @@ -0,0 +1,219 @@ +use std::path::Path; + +use tempfile::tempdir; + +use crate::canonical::{self, Canonical}; +use crate::runtime::error::FsError; +use crate::runtime::provider::{IFsProvider, Kind}; + +use super::LocalFsProvider; + +/// Canonical `file:` URI for a filesystem path (test helper). +fn canon(path: &Path) -> Canonical { + let uri = canonical::to_file_uri(path).expect("to_file_uri"); + canonical::canonicalize(&uri).expect("canonicalize") +} + +/// `file:` URI for a path joined under `root` (child, not necessarily folded). +fn uri(path: &Path) -> String { + canonical::to_file_uri(path).expect("to_file_uri") +} + +#[tokio::test] +async fn read_dir_lists_immediate_children_with_kind() { + let dir = tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir(root.join("src")).unwrap(); + std::fs::write(root.join("README.md"), b"hi").unwrap(); + + let provider = LocalFsProvider::new(); + let mut entries = provider.read_dir(canon(root).as_str()).await.unwrap(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + let names: Vec<&str> = entries.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(names, vec!["README.md", "src"]); + assert_eq!(entries[0].1.kind, Kind::File); + assert_eq!(entries[1].1.kind, Kind::Dir); +} + +#[tokio::test] +async fn read_dir_missing_dir_errors_not_found() { + let dir = tempdir().unwrap(); + let missing = dir.path().join("nope"); + let provider = LocalFsProvider::new(); + let err = provider.read_dir(&uri(&missing)).await.unwrap_err(); + assert!(matches!(err, FsError::NotFound { .. }), "got {err:?}"); +} + +#[tokio::test] +async fn stat_returns_fact_for_file_and_none_for_missing() { + let dir = tempdir().unwrap(); + let f = dir.path().join("a.txt"); + std::fs::write(&f, b"x").unwrap(); + let provider = LocalFsProvider::new(); + + let fact = provider.stat(&uri(&f)).await.unwrap().expect("some"); + assert_eq!(fact.kind, Kind::File); + + let missing = dir.path().join("gone.txt"); + assert!(provider.stat(&uri(&missing)).await.unwrap().is_none()); +} + +#[tokio::test] +async fn read_missing_file_errors_not_found() { + let dir = tempdir().unwrap(); + let missing = dir.path().join("nope.txt"); + let provider = LocalFsProvider::new(); + let err = provider.read(&uri(&missing)).await.unwrap_err(); + assert!(matches!(err, FsError::NotFound { .. }), "got {err:?}"); +} + +#[tokio::test] +async fn write_then_read_roundtrip_and_overwrite() { + let dir = tempdir().unwrap(); + let f = dir.path().join("data.bin"); + let provider = LocalFsProvider::new(); + + provider.write(&uri(&f), b"hello").await.unwrap(); + assert_eq!(provider.read(&uri(&f)).await.unwrap(), b"hello"); + + provider.write(&uri(&f), b"world!").await.unwrap(); + assert_eq!(provider.read(&uri(&f)).await.unwrap(), b"world!"); +} + +#[tokio::test] +async fn create_file_new_then_already_exists_errors() { + let dir = tempdir().unwrap(); + let f = dir.path().join("new.txt"); + let provider = LocalFsProvider::new(); + + provider.create_file(&uri(&f)).await.unwrap(); + assert!(f.exists()); + + let err = provider.create_file(&uri(&f)).await.unwrap_err(); + assert!(matches!(err, FsError::AlreadyExists { .. }), "got {err:?}"); +} + +#[tokio::test] +async fn mkdir_creates_directory() { + let dir = tempdir().unwrap(); + let d = dir.path().join("sub"); + let provider = LocalFsProvider::new(); + provider.mkdir(&uri(&d)).await.unwrap(); + assert!(d.is_dir()); +} + +#[tokio::test] +async fn remove_file_and_recursive_dir() { + let dir = tempdir().unwrap(); + let provider = LocalFsProvider::new(); + + let f = dir.path().join("f.txt"); + std::fs::write(&f, b"x").unwrap(); + provider.remove(&uri(&f), false).await.unwrap(); + assert!(!f.exists()); + + let d = dir.path().join("tree"); + std::fs::create_dir(&d).unwrap(); + std::fs::write(d.join("inner.txt"), b"y").unwrap(); + provider.remove(&uri(&d), true).await.unwrap(); + assert!(!d.exists()); +} + +#[tokio::test] +async fn remove_nonempty_dir_without_recursive_errors() { + let dir = tempdir().unwrap(); + let d = dir.path().join("tree"); + std::fs::create_dir(&d).unwrap(); + std::fs::write(d.join("inner.txt"), b"y").unwrap(); + let provider = LocalFsProvider::new(); + assert!(provider.remove(&uri(&d), false).await.is_err()); + assert!(d.exists()); +} + +#[tokio::test] +async fn rename_moves_entry() { + let dir = tempdir().unwrap(); + let from = dir.path().join("old.txt"); + let to = dir.path().join("renamed.txt"); + std::fs::write(&from, b"x").unwrap(); + let provider = LocalFsProvider::new(); + provider.rename(&uri(&from), &uri(&to)).await.unwrap(); + assert!(!from.exists() && to.exists()); +} + +#[tokio::test] +async fn copy_file_duplicates_content() { + let dir = tempdir().unwrap(); + let from = dir.path().join("src.txt"); + let to = dir.path().join("dst.txt"); + std::fs::write(&from, b"payload").unwrap(); + let provider = LocalFsProvider::new(); + provider.copy(&uri(&from), &uri(&to), false).await.unwrap(); + assert_eq!(std::fs::read(&to).unwrap(), b"payload"); + assert!(from.exists()); +} + +#[tokio::test] +async fn copy_dir_recursive_duplicates_tree() { + let dir = tempdir().unwrap(); + let src = dir.path().join("d"); + std::fs::create_dir(&src).unwrap(); + std::fs::write(src.join("a.txt"), b"top").unwrap(); + std::fs::create_dir(src.join("sub")).unwrap(); + std::fs::write(src.join("sub").join("b.txt"), b"nested").unwrap(); + let dst = dir.path().join("d2"); + + let provider = LocalFsProvider::new(); + provider.copy(&uri(&src), &uri(&dst), true).await.unwrap(); + + // Whole tree duplicated, contents intact. + assert_eq!(std::fs::read(dst.join("a.txt")).unwrap(), b"top"); + assert_eq!(std::fs::read(dst.join("sub").join("b.txt")).unwrap(), b"nested"); + // Source left in place. + assert!(src.join("a.txt").exists()); + assert!(src.join("sub").join("b.txt").exists()); +} + +#[tokio::test] +async fn copy_dir_without_recursive_errors() { + let dir = tempdir().unwrap(); + let src = dir.path().join("d"); + std::fs::create_dir(&src).unwrap(); + std::fs::write(src.join("a.txt"), b"x").unwrap(); + let dst = dir.path().join("d2"); + + let provider = LocalFsProvider::new(); + let err = provider.copy(&uri(&src), &uri(&dst), false).await.unwrap_err(); + // Directory copy without recursive is rejected before any IO. + assert!( + matches!(&err, FsError::Io { message, .. } if message.contains("recursive")), + "got {err:?}" + ); + assert!(!dst.exists(), "nothing copied"); +} + +#[cfg(unix)] +#[tokio::test] +async fn symlink_reports_kind_and_target() { + let dir = tempdir().unwrap(); + let target = dir.path().join("target.txt"); + std::fs::write(&target, b"x").unwrap(); + let link = dir.path().join("link.txt"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let provider = LocalFsProvider::new(); + let fact = provider.stat(&uri(&link)).await.unwrap().expect("some"); + assert_eq!(fact.kind, Kind::Symlink); + assert!(fact.symlink_target.is_some()); +} + +#[cfg(unix)] +#[tokio::test] +async fn read_dir_populates_inode() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), b"x").unwrap(); + let provider = LocalFsProvider::new(); + let entries = provider.read_dir(canon(dir.path()).as_str()).await.unwrap(); + assert!(entries[0].1.inode != 0); +} diff --git a/crates/aionui-project/src/runtime/local_watcher.rs b/crates/aionui-project/src/runtime/local_watcher.rs new file mode 100644 index 000000000..06ba577e6 --- /dev/null +++ b/crates/aionui-project/src/runtime/local_watcher.rs @@ -0,0 +1,159 @@ +//! `LocalWatcher` — `file:` [`Watcher`] backed by `notify` (non-recursive). +//! +//! One shared `RecommendedWatcher` instance backs all watches so macOS uses a +//! single FSEvents stream/thread (O(1) regardless of watch count). Each +//! `watch()` registers a directory `NonRecursive`; the shared callback maps raw +//! `notify` events to [`RawEvent`]s (via [`super::watcher::map_event`]) and +//! pushes them onto the out channel returned by [`LocalWatcher::new`]. +//! +//! Event-path attribution folds case through [`crate::canonical`] rather than +//! comparing raw paths: on case-insensitive platforms the watched canonical is +//! folded while `notify` reports real-case paths, so a lexical canonicalize on +//! both sides is the only correct match. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use notify::{RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher}; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; + +use crate::canonical; + +use super::error::FsError; +use super::watcher::{RawEvent, WatchHandle, Watcher, map_event}; + +/// A watched directory. `notify_path` is the path handed to `notify` (needed to +/// `unwatch`); `lexical` is the identity canonical the tree model keys on. +#[derive(Clone)] +struct WatchEntry { + lexical: String, + notify_path: PathBuf, +} + +/// Watched directories keyed by *reported* canonical. A single watch registers +/// two keys — its lexical canonical and its realpath-folded canonical — so +/// events can be attributed whether the OS reports lexical (Linux inotify) or +/// realpath'd (macOS FSEvents resolves `/var` → `/private/var`) paths. Both map +/// back to the same lexical identity. Shared with the event callback. +type Watched = Arc>>; + +/// Local-disk non-recursive watcher for the `file:` scheme. +pub(crate) struct LocalWatcher { + inner: Mutex, + watched: Watched, +} + +/// Canonical string of a path with symlinks resolved (realpath), or `None` if +/// the path cannot be realpath'd (e.g. does not exist). +fn realpath_canonical(path: &Path) -> Option { + let real = std::fs::canonicalize(path).ok()?; + canonical_of(&real) +} + +/// Canonical string of a path (lexical, case-folded per platform); `None` if it +/// is not a representable `file:` resource. +fn canonical_of(path: &Path) -> Option { + let uri = canonical::to_file_uri(path).ok()?; + canonical::canonicalize(&uri).ok().map(|c| c.as_str().to_owned()) +} + +/// Attribute an event path to a watched directory's lexical canonical: the path +/// itself (event on the directory) or its parent (event on a child), matched by +/// reported canonical against the watched keys. +fn resolve_owner(watched: &HashMap, path: &Path) -> Option { + if let Some(c) = canonical_of(path) + && let Some(entry) = watched.get(&c) + { + return Some(entry.lexical.clone()); + } + let parent = path.parent()?; + let c = canonical_of(parent)?; + watched.get(&c).map(|entry| entry.lexical.clone()) +} + +impl LocalWatcher { + /// Build a watcher and its out channel. Raw events arrive on the receiver. + pub fn new() -> Result<(Self, UnboundedReceiver), FsError> { + let (tx, rx): (UnboundedSender, UnboundedReceiver) = unbounded_channel(); + let watched: Watched = Arc::new(Mutex::new(HashMap::new())); + let cb_watched = Arc::clone(&watched); + + let watcher = notify::recommended_watcher(move |res: Result| { + let Ok(event) = res else { return }; + let snapshot = { + let guard = cb_watched.lock().unwrap(); + guard.clone() + }; + let resolve = |p: &Path| resolve_owner(&snapshot, p); + for raw in map_event(&event, &resolve) { + // Unbounded, non-blocking: the callback must never block the + // watcher thread. A closed receiver means shutdown → drop. + let _ = tx.send(raw); + } + }) + .map_err(|e| FsError::Io { + uri: String::new(), + message: e.to_string(), + })?; + + Ok(( + Self { + inner: Mutex::new(watcher), + watched, + }, + rx, + )) + } +} + +impl Watcher for LocalWatcher { + fn watch(&self, canonical: &str) -> Result { + let path = canonical::uri_to_path(canonical).map_err(|_| FsError::Io { + uri: canonical.to_owned(), + message: "invalid file uri".to_owned(), + })?; + self.inner + .lock() + .unwrap() + .watch(&path, RecursiveMode::NonRecursive) + .map_err(|e| FsError::Io { + uri: canonical.to_owned(), + message: e.to_string(), + })?; + let entry = WatchEntry { + lexical: canonical.to_owned(), + notify_path: path.clone(), + }; + let mut watched = self.watched.lock().unwrap(); + // Register the lexical canonical and (if different) the realpath-folded + // canonical so both inotify- and FSEvents-reported paths attribute back. + watched.insert(canonical.to_owned(), entry.clone()); + if let Some(real) = realpath_canonical(&path) { + watched.entry(real).or_insert(entry); + } + drop(watched); + Ok(WatchHandle { + canonical: canonical.to_owned(), + }) + } + + fn unwatch(&self, handle: &WatchHandle) { + // Best-effort: idempotent, tolerant of an already-removed watch. Remove + // every alias key pointing at this lexical canonical. + let mut watched = self.watched.lock().unwrap(); + let notify_path = watched + .values() + .find(|e| e.lexical == handle.canonical) + .map(|e| e.notify_path.clone()); + watched.retain(|_, e| e.lexical != handle.canonical); + drop(watched); + if let Some(path) = notify_path { + let _ = self.inner.lock().unwrap().unwatch(&path); + } + } +} + +#[cfg(test)] +#[path = "local_watcher_test.rs"] +mod local_watcher_test; diff --git a/crates/aionui-project/src/runtime/local_watcher_test.rs b/crates/aionui-project/src/runtime/local_watcher_test.rs new file mode 100644 index 000000000..d80525b8d --- /dev/null +++ b/crates/aionui-project/src/runtime/local_watcher_test.rs @@ -0,0 +1,198 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use tempfile::tempdir; +use tokio::sync::mpsc::UnboundedReceiver; + +use crate::canonical::{self, Canonical}; +use crate::runtime::watcher::{RawEvent, Watcher}; + +use super::{LocalWatcher, WatchEntry, resolve_owner}; + +fn canon(path: &Path) -> Canonical { + let uri = canonical::to_file_uri(path).expect("to_file_uri"); + canonical::canonicalize(&uri).expect("canonicalize") +} + +/// Receive events until one is a `Changed` for `canonical` mentioning a path +/// whose file name is `child`, or the deadline elapses (returns false). +async fn saw_change(rx: &mut UnboundedReceiver, canonical: &str, child: &str, within: Duration) -> bool { + tokio::time::timeout(within, async { + loop { + match rx.recv().await { + Some(RawEvent::Changed { canonical: c, paths }) if c == canonical => { + if paths.iter().any(|p| p.ends_with(child)) { + return true; + } + } + Some(_) => continue, + None => return false, + } + } + }) + .await + .unwrap_or(false) +} + +#[tokio::test] +async fn watch_emits_changed_on_child_create() { + let dir = tempdir().unwrap(); + let (watcher, mut rx) = LocalWatcher::new().unwrap(); + let c = canon(dir.path()); + + watcher.watch(c.as_str()).unwrap(); + // Let the OS watch arm before mutating. + tokio::time::sleep(Duration::from_millis(150)).await; + std::fs::write(dir.path().join("new.ts"), b"x").unwrap(); + + assert!( + saw_change(&mut rx, c.as_str(), "new.ts", Duration::from_secs(5)).await, + "expected a Changed event mentioning new.ts" + ); +} + +// ── Event-path attribution: the realpath double-key fold (pure, all platforms) +// +// FSEvents delivers realpath'd paths (macOS `/var` → `/private/var`); `watch` +// registers both the lexical canonical and a second (realpath-folded) canonical +// so an event reported under either name attributes back to the one lexical +// identity the tree model keys on. This test drives the private `resolve_owner` +// with synthetic paths — no real symlink, no FSEvents — so it runs and asserts +// on every platform. Paths are built platform-absolute (Windows needs a drive +// letter; `Url::from_file_path` rejects non-absolute paths), and the base dirs +// are the source of truth for the expected keys, so the assertion is +// self-consistent regardless of per-platform case folding. + +/// A pair of distinct absolute dirs (a "lexical" dir and a second "alias" dir) +/// that are valid on the host platform. +#[cfg(windows)] +fn alias_dirs() -> (PathBuf, PathBuf) { + (PathBuf::from(r"C:\a"), PathBuf::from(r"C:\private\a")) +} +#[cfg(not(windows))] +fn alias_dirs() -> (PathBuf, PathBuf) { + (PathBuf::from("/a"), PathBuf::from("/private/a")) +} + +#[test] +fn resolve_owner_folds_realpath_alias_back_to_lexical() { + let (lex_dir, alias_dir) = alias_dirs(); + let lexical = canon(&lex_dir); + let alias = canon(&alias_dir); + let entry = WatchEntry { + lexical: lexical.as_str().to_owned(), + notify_path: lex_dir.clone(), + }; + let mut watched: HashMap = HashMap::new(); + watched.insert(lexical.as_str().to_owned(), entry.clone()); + watched.insert(alias.as_str().to_owned(), entry); + + let want = Some(lexical.as_str().to_owned()); + // Alias'd child path (as macOS FSEvents reports) attributes to the lexical id. + assert_eq!(resolve_owner(&watched, &alias_dir.join("child.txt")), want); + // Lexical child path (as Linux inotify reports) attributes to the same id. + assert_eq!(resolve_owner(&watched, &lex_dir.join("child.txt")), want); + + // A path under no watched directory is unowned (platform-absolute). + #[cfg(windows)] + let unrelated = PathBuf::from(r"C:\other\x.txt"); + #[cfg(not(windows))] + let unrelated = PathBuf::from("/other/x.txt"); + assert_eq!(resolve_owner(&watched, &unrelated), None); +} + +#[test] +fn resolve_owner_case_folding_is_platform_relative() { + // The canonical layer folds path case on macOS/Windows and preserves it on + // Linux (`canonical::IGNORE_PATH_CASING`). resolve_owner inherits that, so a + // differently-cased event path resolves on case-insensitive platforms and is + // unowned on case-sensitive ones. Assert each platform's real behavior. + let (lex_dir, _) = alias_dirs(); + let lexical = canon(&lex_dir); + let entry = WatchEntry { + lexical: lexical.as_str().to_owned(), + notify_path: lex_dir.clone(), + }; + let mut watched: HashMap = HashMap::new(); + watched.insert(lexical.as_str().to_owned(), entry); + + // Upper-cased sibling of the watched dir. + let upper = PathBuf::from(lex_dir.to_string_lossy().to_uppercase()); + let child = upper.join("child.txt"); + + #[cfg(any(target_os = "macos", target_os = "windows"))] + assert_eq!( + resolve_owner(&watched, &child), + Some(lexical.as_str().to_owned()), + "case-insensitive platform folds the cased path back to the watched id" + ); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + assert_eq!( + resolve_owner(&watched, &child), + None, + "case-sensitive platform treats the cased path as a different directory" + ); +} + +#[tokio::test] +async fn unwatch_stops_events() { + let dir = tempdir().unwrap(); + let (watcher, mut rx) = LocalWatcher::new().unwrap(); + let c = canon(dir.path()); + + let handle = watcher.watch(c.as_str()).unwrap(); + tokio::time::sleep(Duration::from_millis(150)).await; + watcher.unwatch(&handle); + // Drain anything already queued from arming. + tokio::time::sleep(Duration::from_millis(150)).await; + while rx.try_recv().is_ok() {} + + std::fs::write(dir.path().join("after.ts"), b"x").unwrap(); + assert!( + !saw_change(&mut rx, c.as_str(), "after.ts", Duration::from_millis(800)).await, + "expected no events after unwatch" + ); +} + +#[tokio::test] +async fn two_watches_share_instance_and_unwatch_is_isolated() { + let dir_a = tempdir().unwrap(); + let dir_b = tempdir().unwrap(); + let (watcher, mut rx) = LocalWatcher::new().unwrap(); + let ca = canon(dir_a.path()); + let cb = canon(dir_b.path()); + + // One shared watcher instance backs both directories (macOS: one FSEvents + // stream). Both must report changes independently. + let ha = watcher.watch(ca.as_str()).unwrap(); + watcher.watch(cb.as_str()).unwrap(); + tokio::time::sleep(Duration::from_millis(150)).await; + + std::fs::write(dir_a.path().join("x.ts"), b"x").unwrap(); + assert!( + saw_change(&mut rx, ca.as_str(), "x.ts", Duration::from_secs(5)).await, + "dir_a change observed on the shared watcher" + ); + std::fs::write(dir_b.path().join("y.ts"), b"y").unwrap(); + assert!( + saw_change(&mut rx, cb.as_str(), "y.ts", Duration::from_secs(5)).await, + "dir_b change observed on the shared watcher" + ); + + // Unwatching one directory must not disturb the other. + watcher.unwatch(&ha); + tokio::time::sleep(Duration::from_millis(150)).await; + while rx.try_recv().is_ok() {} + + std::fs::write(dir_b.path().join("more.ts"), b"m").unwrap(); + assert!( + saw_change(&mut rx, cb.as_str(), "more.ts", Duration::from_secs(5)).await, + "dir_b still live after unwatching dir_a" + ); + std::fs::write(dir_a.path().join("again.ts"), b"a").unwrap(); + assert!( + !saw_change(&mut rx, ca.as_str(), "again.ts", Duration::from_millis(800)).await, + "dir_a silent after unwatch, not affected by dir_b's watch" + ); +} diff --git a/crates/aionui-project/src/runtime/mod.rs b/crates/aionui-project/src/runtime/mod.rs new file mode 100644 index 000000000..e498cd6e5 --- /dev/null +++ b/crates/aionui-project/src/runtime/mod.rs @@ -0,0 +1,32 @@ +//! Project Explorer filesystem runtime (runtime link). +//! +//! Backend file-fact runtime kept for the app lifetime: single-level provider +//! data ops, lazy non-recursive watch, a sparse canonical-keyed tree model, a +//! set-reconciled subscription registry, and actor/shard concurrency. This +//! layer produces canonical-domain deltas/snapshots; fan-out to the pe-keyed +//! wire is the WS handler's job (stage 1). See `formal/runtime/*`. +//! +//! Module files hold implementation; this file only declares and re-exports. + +mod actor; +mod error; +mod fs_runtime; +mod local_provider; +mod local_watcher; +mod provider; +mod subscription; +mod tree_model; +mod watcher; + +pub use actor::{Command, Debouncer, Shard, ShardOutput, raw_to_command}; +pub use error::FsError; +pub use fs_runtime::{FsRuntimeRegistry, IFsRuntime, IoDispatch, LocalFsRuntime}; +pub use provider::{EntryFact, IFsProvider, Kind}; +// Note: the concrete `file:` impls `LocalFsProvider` / `LocalWatcher` are +// `pub(crate)` (internal) — external callers build via `LocalFsRuntime` and see +// only the trait objects it returns, so they are not re-exported here. +// `SubscriptionRegistry` and its outcome enums are internal to `Shard`; only the +// boundary types that appear in `Command`'s public API are exported. +pub use subscription::{GRACE_TTL_MS, Millis, Subscriber}; +pub use tree_model::{Change, DeltaBatch, Hint, Snapshot, TreeModel}; +pub use watcher::{RawEvent, WatchHandle, Watcher}; diff --git a/crates/aionui-project/src/runtime/provider.rs b/crates/aionui-project/src/runtime/provider.rs new file mode 100644 index 000000000..c8f50da15 --- /dev/null +++ b/crates/aionui-project/src/runtime/provider.rs @@ -0,0 +1,68 @@ +//! `IFsProvider` — the data-operation half of a filesystem runtime. +//! +//! Single-level, non-recursive operations over a provider scheme. The watch +//! half lives in [`super::watcher`]; the two are composed by an `IFsRuntime`. +//! Lexical canonicalization stays in [`crate::canonical`]; this trait consumes +//! already-canonical URIs. + +use async_trait::async_trait; + +use super::error::FsError; + +/// Kind of a directory entry, as the tree model cares about it (name + kind). +/// Size/mtime are intentionally absent — the tree does not display them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + File, + Dir, + Symlink, +} + +/// Backend-internal fact about a single entry. Carries `inode` for same-inode +/// rename synthesis in the tree model; the outward wire `Entry` (protocol.md) +/// exposes only name + kind + symlink_target + excluded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntryFact { + pub kind: Kind, + /// Filesystem inode (0 when the provider cannot supply one; rename + /// synthesis then degrades to removed + added). + pub inode: u64, + /// Link target when `kind == Symlink`. + pub symlink_target: Option, +} + +/// Data operations for one provider scheme. Non-recursive: `read_dir` lists a +/// single level. Create is split (`create_file` / `mkdir` / `write`-overwrite); +/// remove/rename are kind-agnostic (filesystem generic). +#[async_trait] +pub trait IFsProvider: Send + Sync { + /// The URI scheme this provider serves (e.g. `"file"`). + fn scheme(&self) -> &str; + + /// List one directory level: `(name, fact)` per immediate child. + async fn read_dir(&self, uri: &str) -> Result, FsError>; + + /// Stat a single entry; `None` when it does not exist. + async fn stat(&self, uri: &str) -> Result, FsError>; + + /// Read whole file contents. + async fn read(&self, uri: &str) -> Result, FsError>; + + /// Create-or-overwrite a file with `data`. + async fn write(&self, uri: &str, data: &[u8]) -> Result<(), FsError>; + + /// Create a new empty file; errors if it already exists. + async fn create_file(&self, uri: &str) -> Result<(), FsError>; + + /// Create a directory. + async fn mkdir(&self, uri: &str) -> Result<(), FsError>; + + /// Remove a file or directory (recursive for directories when `recursive`). + async fn remove(&self, uri: &str, recursive: bool) -> Result<(), FsError>; + + /// Rename/move a file or directory. + async fn rename(&self, from: &str, to: &str) -> Result<(), FsError>; + + /// Copy a file or directory (recursive for directories when `recursive`). + async fn copy(&self, from: &str, to: &str, recursive: bool) -> Result<(), FsError>; +} diff --git a/crates/aionui-project/src/runtime/subscription.rs b/crates/aionui-project/src/runtime/subscription.rs new file mode 100644 index 000000000..537e8a802 --- /dev/null +++ b/crates/aionui-project/src/runtime/subscription.rs @@ -0,0 +1,187 @@ +//! `SubscriptionRegistry` — per-connection subscription state driving what the +//! tree model mounts/watches, via **set reconciliation** (not an integer +//! refcount). +//! +//! Two indexes: `forward` (session → canonicals, for disconnect cleanup) and +//! `reverse` (canonical → subscribers, for fan-out with pe context). A canonical +//! is watched ⟺ it has subscribers OR is being kept warm in `grace`. When the +//! last subscriber leaves, the canonical enters `grace` (watch stays live for a +//! TTL) so debounced expand/collapse, overlap switches, and reconnect windows +//! reuse it; a `warm-LRU` budget caps total warm nodes. +//! +//! Time is a logical millisecond clock injected by the caller (the actor stamps +//! `now`), keeping grace fully deterministic and testable without a real clock. + +use std::collections::HashMap; +use std::collections::HashSet; + +/// Logical millisecond timestamp (injected; not wall-clock here). +pub type Millis = u64; + +/// Grace keep-warm TTL. A placeholder knob (design: 5 min); centralized so it +/// is easy to retune. See the stage-0 impl notes — value is deferred. +pub const GRACE_TTL_MS: Millis = 5 * 60 * 1000; + +/// One subscriber: a connection's interest in a canonical, tagged with the pe +/// context needed to translate canonical-domain changes back to the pe-keyed +/// wire on fan-out. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Subscriber { + pub session: String, + pub pe_id: String, + pub rel: String, +} + +/// Result of a subscribe: does the caller need to mount, was a warm node +/// rescued from grace, or was it already live? +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SubscribeOutcome { + /// First subscriber for a cold canonical → caller must mount + watch. + Mount, + /// Node was warm in grace → grace cancelled, already mounted. + RescuedFromGrace, + /// Already had subscribers → nothing to do. + AlreadyLive, +} + +/// Result of an unsubscribe. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UnsubscribeOutcome { + /// Last subscriber left → node entered grace (still watched for TTL). + EnteredGrace, + /// Other subscribers remain → node stays live. + StillLive, + /// The canonical had no subscribers to remove. + NotSubscribed, +} + +/// Subscription state. Empty `reverse` entries are never kept — an emptied +/// canonical is moved to `grace`, so `reverse` and `grace` key sets are disjoint. +#[derive(Default)] +pub(crate) struct SubscriptionRegistry { + forward: HashMap>, + reverse: HashMap>, + grace: HashMap, +} + +impl SubscriptionRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Record `sub`'s interest in `canonical`, updating both indexes. + pub fn subscribe(&mut self, sub: Subscriber, canonical: &str, _now: Millis) -> SubscribeOutcome { + self.forward + .entry(sub.session.clone()) + .or_default() + .insert(canonical.to_owned()); + + let rescued = self.grace.remove(canonical).is_some(); + let set = self.reverse.entry(canonical.to_owned()).or_default(); + let was_empty = set.is_empty(); + set.insert(sub); + + if rescued { + SubscribeOutcome::RescuedFromGrace + } else if was_empty { + SubscribeOutcome::Mount + } else { + SubscribeOutcome::AlreadyLive + } + } + + /// Drop `sub`'s interest in `canonical`. Empties → grace at `now + TTL`. + pub fn unsubscribe(&mut self, sub: &Subscriber, canonical: &str, now: Millis) -> UnsubscribeOutcome { + if let Some(cs) = self.forward.get_mut(&sub.session) { + cs.remove(canonical); + if cs.is_empty() { + self.forward.remove(&sub.session); + } + } + + let Some(set) = self.reverse.get_mut(canonical) else { + return UnsubscribeOutcome::NotSubscribed; + }; + set.remove(sub); + if set.is_empty() { + self.reverse.remove(canonical); + self.grace.insert(canonical.to_owned(), now + GRACE_TTL_MS); + UnsubscribeOutcome::EnteredGrace + } else { + UnsubscribeOutcome::StillLive + } + } + + /// Remove all of `session`'s subscriptions (disconnect). Returns canonicals + /// that became empty and entered grace. + pub fn drop_session(&mut self, session: &str, now: Millis) -> Vec { + let canonicals = self.forward.remove(session).unwrap_or_default(); + let mut graced = Vec::new(); + for canonical in canonicals { + if let Some(set) = self.reverse.get_mut(&canonical) { + set.retain(|s| s.session != session); + if set.is_empty() { + self.reverse.remove(&canonical); + self.grace.insert(canonical.clone(), now + GRACE_TTL_MS); + graced.push(canonical); + } + } + } + graced.sort(); + graced + } + + /// Subscribers of `canonical` for fan-out (`None` if none). + pub fn subscribers_of(&self, canonical: &str) -> Option<&HashSet> { + self.reverse.get(canonical) + } + + /// Evict grace entries whose TTL has elapsed at `now`. Returns canonicals to + /// unmount + unwatch. + pub fn reap(&mut self, now: Millis) -> Vec { + let mut expired: Vec = self + .grace + .iter() + .filter(|(_, evict_at)| **evict_at <= now) + .map(|(c, _)| c.clone()) + .collect(); + for c in &expired { + self.grace.remove(c); + } + expired.sort(); + expired + } + + /// Enforce a warm-node budget: while total watched (live + grace) exceeds + /// `max`, evict the oldest grace entry. Returns canonicals to unmount. + pub fn enforce_watch_budget(&mut self, max: usize) -> Vec { + let mut evicted = Vec::new(); + while self.watched_count() > max && !self.grace.is_empty() { + // Oldest-entered = smallest evict_at (TTL is constant). + let oldest = self + .grace + .iter() + .min_by_key(|(c, evict_at)| (**evict_at, (*c).clone())) + .map(|(c, _)| c.clone()) + .expect("grace non-empty"); + self.grace.remove(&oldest); + evicted.push(oldest); + } + evicted + } + + /// Count of currently-watched canonicals (live + warm-in-grace). + pub fn watched_count(&self) -> usize { + self.reverse.len() + self.grace.len() + } + + /// Whether `canonical` is currently watched: it has subscribers or is being + /// kept warm in grace. This is the derived `watch ⟺ ...` predicate. + pub fn is_watched(&self, canonical: &str) -> bool { + self.reverse.contains_key(canonical) || self.grace.contains_key(canonical) + } +} + +#[cfg(test)] +#[path = "subscription_test.rs"] +mod subscription_test; diff --git a/crates/aionui-project/src/runtime/subscription_test.rs b/crates/aionui-project/src/runtime/subscription_test.rs new file mode 100644 index 000000000..fd259f69f --- /dev/null +++ b/crates/aionui-project/src/runtime/subscription_test.rs @@ -0,0 +1,120 @@ +use super::{GRACE_TTL_MS, SubscribeOutcome, Subscriber, SubscriptionRegistry, UnsubscribeOutcome}; + +fn sub(session: &str, pe: &str, rel: &str) -> Subscriber { + Subscriber { + session: session.to_owned(), + pe_id: pe.to_owned(), + rel: rel.to_owned(), + } +} + +const C: &str = "file:///work/app"; + +#[test] +fn first_subscriber_mounts_second_is_already_live() { + let mut reg = SubscriptionRegistry::new(); + assert_eq!(reg.subscribe(sub("s1", "pe1", ""), C, 0), SubscribeOutcome::Mount); + // A different pe on the same session pointing at the same canonical shares. + assert_eq!(reg.subscribe(sub("s1", "pe2", ""), C, 0), SubscribeOutcome::AlreadyLive); + assert_eq!(reg.subscribers_of(C).unwrap().len(), 2); +} + +#[test] +fn multi_session_shares_one_canonical() { + let mut reg = SubscriptionRegistry::new(); + reg.subscribe(sub("s1", "pe1", ""), C, 0); + assert_eq!(reg.subscribe(sub("s2", "pe1", ""), C, 0), SubscribeOutcome::AlreadyLive); + assert_eq!(reg.subscribers_of(C).unwrap().len(), 2); + + assert_eq!( + reg.unsubscribe(&sub("s1", "pe1", ""), C, 0), + UnsubscribeOutcome::StillLive + ); + assert_eq!( + reg.unsubscribe(&sub("s2", "pe1", ""), C, 0), + UnsubscribeOutcome::EnteredGrace + ); +} + +#[test] +fn empty_enters_grace_and_resubscribe_rescues() { + let mut reg = SubscriptionRegistry::new(); + reg.subscribe(sub("s1", "pe1", ""), C, 0); + assert_eq!( + reg.unsubscribe(&sub("s1", "pe1", ""), C, 100), + UnsubscribeOutcome::EnteredGrace + ); + assert_eq!(reg.watched_count(), 1); // still watched (warm) + + // Re-subscribe within TTL → rescued, no fresh mount. + assert_eq!( + reg.subscribe(sub("s1", "pe1", ""), C, 200), + SubscribeOutcome::RescuedFromGrace + ); + assert_eq!(reg.subscribers_of(C).unwrap().len(), 1); +} + +#[test] +fn unsubscribe_unknown_canonical_is_not_subscribed() { + let mut reg = SubscriptionRegistry::new(); + // Unsubscribing a canonical no one ever subscribed to is a well-defined no-op. + assert_eq!( + reg.unsubscribe(&sub("s1", "pe1", ""), "file:///never", 0), + UnsubscribeOutcome::NotSubscribed + ); + assert_eq!(reg.watched_count(), 0); +} + +#[test] +fn reap_evicts_only_after_ttl() { + let mut reg = SubscriptionRegistry::new(); + reg.subscribe(sub("s1", "pe1", ""), C, 0); + reg.unsubscribe(&sub("s1", "pe1", ""), C, 1000); + + assert!(reg.reap(1000 + GRACE_TTL_MS - 1).is_empty()); + assert_eq!(reg.reap(1000 + GRACE_TTL_MS), vec![C.to_owned()]); + assert_eq!(reg.watched_count(), 0); +} + +#[test] +fn rescue_cancels_pending_eviction() { + let mut reg = SubscriptionRegistry::new(); + reg.subscribe(sub("s1", "pe1", ""), C, 0); + reg.unsubscribe(&sub("s1", "pe1", ""), C, 0); + reg.subscribe(sub("s1", "pe1", ""), C, 100); // rescued + + // Well past the original TTL: nothing to evict, it's live again. + assert!(reg.reap(GRACE_TTL_MS * 10).is_empty()); + assert_eq!(reg.subscribers_of(C).unwrap().len(), 1); +} + +#[test] +fn drop_session_removes_all_its_subscriptions() { + let mut reg = SubscriptionRegistry::new(); + let c2 = "file:///work/lib"; + reg.subscribe(sub("s1", "pe1", ""), C, 0); + reg.subscribe(sub("s1", "pe2", ""), c2, 0); + reg.subscribe(sub("s2", "pe1", ""), C, 0); // s2 also on C + + let graced = reg.drop_session("s1", 500); + + // C still live (s2 remains); c2 emptied → grace. + assert_eq!(reg.subscribers_of(C).unwrap().len(), 1); + assert_eq!(graced, vec![c2.to_owned()]); +} + +#[test] +fn warm_lru_evicts_oldest_over_budget() { + let mut reg = SubscriptionRegistry::new(); + // Two canonicals put into grace at different times. + reg.subscribe(sub("s1", "pe1", ""), "file:///a", 0); + reg.unsubscribe(&sub("s1", "pe1", ""), "file:///a", 10); // grace evict_at = 10+TTL + reg.subscribe(sub("s1", "pe2", ""), "file:///b", 0); + reg.unsubscribe(&sub("s1", "pe2", ""), "file:///b", 20); // grace evict_at = 20+TTL + assert_eq!(reg.watched_count(), 2); + + // Budget 1 → evict the oldest-entered (a, smallest evict_at). + let evicted = reg.enforce_watch_budget(1); + assert_eq!(evicted, vec!["file:///a".to_owned()]); + assert_eq!(reg.watched_count(), 1); +} diff --git a/crates/aionui-project/src/runtime/tree_model.rs b/crates/aionui-project/src/runtime/tree_model.rs new file mode 100644 index 000000000..35b328709 --- /dev/null +++ b/crates/aionui-project/src/runtime/tree_model.rs @@ -0,0 +1,251 @@ +//! `TreeModel` — the app-lifetime, canonical-keyed, sparse file-fact tree. +//! +//! Each node is one directory level's listing (name → fact) plus its watch +//! handle. There is **no gen/version**: the WS transport is ordered, so applying +//! snapshots/deltas in arrival order is sufficient. Nodes produce canonical- +//! domain [`Snapshot`] / [`DeltaBatch`]; fan-out to the pe-keyed wire is the WS +//! handler's job (stage 1). +//! +//! `apply` never trusts a `notify` event's kind — it takes the affected child +//! names (or `All`) as a hint and reconciles against a fresh provider read, so +//! it is idempotent (repeated/stale events are no-ops) and absorbs coarse macOS +//! events (degrade to a full `read_dir`). + +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::sync::Arc; + +use crate::canonical; + +use super::error::FsError; +use super::fs_runtime::{FsRuntimeRegistry, IFsRuntime}; +use super::provider::{EntryFact, Kind}; +use super::watcher::WatchHandle; + +/// A directory's full one-level listing (canonical domain). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Snapshot { + pub canonical: String, + /// Sorted by name for deterministic output. + pub entries: Vec<(String, EntryFact)>, +} + +/// One reconciled change to a directory level. The tree tracks only name+kind +/// (no size/mtime → no `modified`); a kind change surfaces as removed + added. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Change { + Added { name: String, kind: Kind }, + Removed { name: String }, + Renamed { from: String, to: String }, +} + +/// A batch of reconciled changes for one canonical (canonical domain). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeltaBatch { + pub canonical: String, + pub changes: Vec, +} + +/// What to re-check on `apply`: specific child names (precise Linux/Windows +/// events) or the whole level (coarse macOS events / rescan). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Hint { + ChildNames(Vec), + All, +} + +/// One mounted directory: its listing and the watch keeping it live. +struct Node { + entries: BTreeMap, + watch: WatchHandle, +} + +/// The sparse canonical-keyed fact tree. +pub struct TreeModel { + nodes: HashMap, + runtimes: FsRuntimeRegistry, +} + +/// Resolve the runtime serving `canonical`'s scheme. +fn runtime_for(runtimes: &FsRuntimeRegistry, canonical: &str) -> Result, FsError> { + let scheme = match canonical::parse_scheme(canonical) { + Ok(canonical::Scheme::File) => "file", + Err(_) => { + return Err(FsError::Io { + uri: canonical.to_owned(), + message: "cannot parse scheme".to_owned(), + }); + } + }; + runtimes.get(scheme).ok_or_else(|| FsError::UnsupportedScheme { + scheme: scheme.to_owned(), + }) +} + +/// Build the child `file:` URI for `name` under directory `canonical`. +fn child_uri(canonical: &str, name: &str) -> Result { + let dir = canonical::uri_to_path(canonical).map_err(|_| FsError::Io { + uri: canonical.to_owned(), + message: "invalid canonical".to_owned(), + })?; + let child = dir.join(name); + canonical::to_file_uri(&child).map_err(|_| FsError::Io { + uri: canonical.to_owned(), + message: "cannot build child uri".to_owned(), + }) +} + +/// Reconcile `old` → `fresh` into added/removed/renamed changes. A same-inode +/// removed+added pair is synthesized into a rename (falls back to removed+added +/// when the provider cannot supply an inode). +fn diff(old: &BTreeMap, fresh: &BTreeMap) -> Vec { + // Candidate removals/additions by name; a same-name kind change counts as + // both (removed old kind + added new kind). + let mut removed: Vec<(String, EntryFact)> = Vec::new(); + let mut added: Vec<(String, EntryFact)> = Vec::new(); + for (name, of) in old { + match fresh.get(name) { + None => removed.push((name.clone(), of.clone())), + Some(nf) if nf.kind != of.kind => { + removed.push((name.clone(), of.clone())); + added.push((name.clone(), nf.clone())); + } + Some(_) => {} + } + } + for (name, nf) in fresh { + // Same-name kind change already pushed above; same kind = unchanged. + if !old.contains_key(name) { + added.push((name.clone(), nf.clone())); + } + } + + // Synthesize renames from same-inode removed+added pairs (inode 0 = unknown, + // never matched → degrades to removed+added). + let mut changes = Vec::new(); + let mut used_added = vec![false; added.len()]; + removed.retain(|(rname, rf)| { + if rf.inode != 0 + && let Some(idx) = added + .iter() + .enumerate() + // A rename preserves kind. Requiring `af.kind == rf.kind` stops a + // same-name file→dir kind change from being read as a rename when + // the OS reuses the freed inode for the new entry (Linux does; + // macOS assigns a fresh inode) — that case is remove + add. + .position(|(i, (_, af))| !used_added[i] && af.inode == rf.inode && af.kind == rf.kind) + { + used_added[idx] = true; + changes.push(Change::Renamed { + from: rname.clone(), + to: added[idx].0.clone(), + }); + false + } else { + true + } + }); + + for (i, (name, af)) in added.into_iter().enumerate() { + if !used_added[i] { + changes.push(Change::Added { name, kind: af.kind }); + } + } + for (name, _) in removed { + changes.push(Change::Removed { name }); + } + changes +} + +impl TreeModel { + pub fn new(runtimes: FsRuntimeRegistry) -> Self { + Self { + nodes: HashMap::new(), + runtimes, + } + } + + /// Mount a canonical: arm watch, read baseline, store node. Ordering is the + /// TOCTOU guard — watch is armed *before* the baseline read so no change + /// between the two is lost (events buffered by the actor are replayed via + /// idempotent `apply`). Returns the baseline snapshot. Re-mount is a no-op + /// that returns the current snapshot (subscription layer guards duplicates). + pub async fn mount(&mut self, canonical: &str) -> Result { + if self.nodes.contains_key(canonical) { + return Ok(self.snapshot(canonical).expect("mounted node has snapshot")); + } + let runtime = runtime_for(&self.runtimes, canonical)?; + // 1. Arm watch first — any change from here on is buffered by the actor. + let watch = runtime.watcher().watch(canonical)?; + // 2. Read the baseline listing. + let entries: BTreeMap = runtime.provider().read_dir(canonical).await?.into_iter().collect(); + // 3. Store the node. (Buffered events are replayed later via idempotent apply.) + self.nodes.insert(canonical.to_owned(), Node { entries, watch }); + Ok(self.snapshot(canonical).expect("just-mounted node has snapshot")) + } + + /// Unmount: drop the node and stop its watch. + pub fn unmount(&mut self, canonical: &str) { + if let Some(node) = self.nodes.remove(canonical) + && let Ok(runtime) = runtime_for(&self.runtimes, canonical) + { + runtime.watcher().unwatch(&node.watch); + } + } + + /// Reconcile the node against a fresh provider read per `hint`. `None` when + /// nothing changed (idempotent) or the node is not mounted (in-flight event + /// after unmount). + pub async fn apply(&mut self, canonical: &str, hint: Hint) -> Result, FsError> { + // In-flight event after unmount (or never mounted) → drop silently. + let Some(old) = self.nodes.get(canonical).map(|n| n.entries.clone()) else { + return Ok(None); + }; + let runtime = runtime_for(&self.runtimes, canonical)?; + + let fresh: BTreeMap = match hint { + // Coarse event / rescan: re-read the whole level. + Hint::All => runtime.provider().read_dir(canonical).await?.into_iter().collect(), + // Precise event: re-stat only the named children, over the old base. + Hint::ChildNames(child_names) => { + let mut map = old.clone(); + for name in child_names { + let uri = child_uri(canonical, &name)?; + match runtime.provider().stat(&uri).await? { + Some(fact) => { + map.insert(name, fact); + } + None => { + map.remove(&name); + } + } + } + map + } + }; + + let changes = diff(&old, &fresh); + if changes.is_empty() { + return Ok(None); + } + if let Some(node) = self.nodes.get_mut(canonical) { + node.entries = fresh; + } + Ok(Some(DeltaBatch { + canonical: canonical.to_owned(), + changes, + })) + } + + /// Current snapshot of a mounted node, or `None` if not mounted. + pub fn snapshot(&self, canonical: &str) -> Option { + self.nodes.get(canonical).map(|node| Snapshot { + canonical: canonical.to_owned(), + entries: node.entries.iter().map(|(n, f)| (n.clone(), f.clone())).collect(), + }) + } +} + +#[cfg(test)] +#[path = "tree_model_test.rs"] +mod tree_model_test; diff --git a/crates/aionui-project/src/runtime/tree_model_test.rs b/crates/aionui-project/src/runtime/tree_model_test.rs new file mode 100644 index 000000000..7acab8ecd --- /dev/null +++ b/crates/aionui-project/src/runtime/tree_model_test.rs @@ -0,0 +1,359 @@ +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use tempfile::{TempDir, tempdir}; + +use crate::canonical::{self, Canonical}; +use crate::runtime::error::FsError; +use crate::runtime::fs_runtime::{FsRuntimeRegistry, IFsRuntime, IoDispatch}; +use crate::runtime::provider::{EntryFact, IFsProvider, Kind}; +use crate::runtime::watcher::{WatchHandle, Watcher}; + +use super::{Change, Hint, TreeModel, diff}; + +fn canon(path: &Path) -> Canonical { + let uri = canonical::to_file_uri(path).expect("to_file_uri"); + canonical::canonicalize(&uri).expect("canonicalize") +} + +/// A tree model over a real local runtime rooted at a fresh tempdir. +fn real_tree() -> (TreeModel, TempDir) { + let (runtime, _rx) = crate::runtime::LocalFsRuntime::new().unwrap(); + let mut registry = FsRuntimeRegistry::new(); + registry.register("file", Arc::new(runtime)); + (TreeModel::new(registry), tempdir().unwrap()) +} + +fn names(entries: &[(String, EntryFact)]) -> Vec<&str> { + entries.iter().map(|(n, _)| n.as_str()).collect() +} + +#[tokio::test] +async fn mount_returns_baseline_snapshot() { + let (mut tree, dir) = real_tree(); + std::fs::create_dir(dir.path().join("src")).unwrap(); + std::fs::write(dir.path().join("README.md"), b"x").unwrap(); + + let snap = tree.mount(canon(dir.path()).as_str()).await.unwrap(); + assert_eq!(names(&snap.entries), vec!["README.md", "src"]); +} + +#[tokio::test] +async fn apply_all_detects_added_and_removed() { + let (mut tree, dir) = real_tree(); + std::fs::write(dir.path().join("keep.txt"), b"x").unwrap(); + std::fs::write(dir.path().join("gone.txt"), b"x").unwrap(); + let c = canon(dir.path()); + tree.mount(c.as_str()).await.unwrap(); + + std::fs::write(dir.path().join("new.txt"), b"x").unwrap(); + std::fs::remove_file(dir.path().join("gone.txt")).unwrap(); + + let delta = tree.apply(c.as_str(), Hint::All).await.unwrap().expect("changes"); + let mut changes = delta.changes; + changes.sort_by_key(|c| format!("{c:?}")); + assert_eq!( + changes, + vec![ + Change::Added { + name: "new.txt".to_owned(), + kind: Kind::File + }, + Change::Removed { + name: "gone.txt".to_owned() + }, + ] + ); +} + +#[tokio::test] +async fn apply_child_names_stats_only_named_children() { + let (mut tree, dir) = real_tree(); + let c = canon(dir.path()); + tree.mount(c.as_str()).await.unwrap(); + + std::fs::write(dir.path().join("added.txt"), b"x").unwrap(); + // Also create an unrelated file, but do NOT name it in the hint → ignored. + std::fs::write(dir.path().join("unhinted.txt"), b"x").unwrap(); + + let delta = tree + .apply(c.as_str(), Hint::ChildNames(vec!["added.txt".to_owned()])) + .await + .unwrap() + .expect("changes"); + assert_eq!( + delta.changes, + vec![Change::Added { + name: "added.txt".to_owned(), + kind: Kind::File + }] + ); +} + +#[tokio::test] +async fn apply_is_idempotent_when_nothing_changed() { + let (mut tree, dir) = real_tree(); + std::fs::write(dir.path().join("a.txt"), b"x").unwrap(); + let c = canon(dir.path()); + tree.mount(c.as_str()).await.unwrap(); + + assert!(tree.apply(c.as_str(), Hint::All).await.unwrap().is_none()); + // Re-applying a stale hint for a child that did not change is also a no-op. + assert!( + tree.apply(c.as_str(), Hint::ChildNames(vec!["a.txt".to_owned()])) + .await + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn apply_synthesizes_rename_for_same_inode() { + let (mut tree, dir) = real_tree(); + std::fs::write(dir.path().join("old.txt"), b"x").unwrap(); + let c = canon(dir.path()); + tree.mount(c.as_str()).await.unwrap(); + + std::fs::rename(dir.path().join("old.txt"), dir.path().join("new.txt")).unwrap(); + + let delta = tree.apply(c.as_str(), Hint::All).await.unwrap().expect("changes"); + assert_eq!( + delta.changes, + vec![Change::Renamed { + from: "old.txt".to_owned(), + to: "new.txt".to_owned() + }] + ); +} + +#[tokio::test] +async fn apply_kind_change_is_remove_plus_add() { + let (mut tree, dir) = real_tree(); + std::fs::write(dir.path().join("x"), b"data").unwrap(); + let c = canon(dir.path()); + tree.mount(c.as_str()).await.unwrap(); + + std::fs::remove_file(dir.path().join("x")).unwrap(); + std::fs::create_dir(dir.path().join("x")).unwrap(); + + let delta = tree.apply(c.as_str(), Hint::All).await.unwrap().expect("changes"); + let mut changes = delta.changes; + changes.sort_by_key(|c| format!("{c:?}")); + assert_eq!( + changes, + vec![ + Change::Added { + name: "x".to_owned(), + kind: Kind::Dir + }, + Change::Removed { name: "x".to_owned() }, + ] + ); +} + +// ── Pure `diff` reconciliation: rename synthesis vs inode=0 degradation ──── +// +// `diff` is the private reconciliation core. The rename-synthesis guard keys on +// a stable non-zero inode; when the provider cannot supply one (`inode == 0` — +// the entire Windows rename path, `local_provider::inode_of` on `not(unix)`), +// synthesis must degrade to removed+added. This branch is distinct from the +// same-name kind-change branch (covered by `apply_kind_change_is_remove_plus_add`) +// and requires its own coverage. + +fn file_fact(inode: u64) -> EntryFact { + EntryFact { + kind: Kind::File, + inode, + symlink_target: None, + } +} + +fn dir_fact(inode: u64) -> EntryFact { + EntryFact { + kind: Kind::Dir, + inode, + symlink_target: None, + } +} + +#[test] +fn diff_same_inode_kind_change_is_remove_add_not_rename() { + // Same name "x", same inode, but File→Dir. Reproduces the Linux inode-reuse + // case (freed file inode reassigned to the new dir) deterministically, with + // no dependency on real-FS inode behavior. A rename preserves kind, so a + // kind change must be Removed + Added even when the inode collides — never a + // (nonsensical) self-rename `Renamed { from: "x", to: "x" }`. + let old = BTreeMap::from([("x".to_owned(), file_fact(7))]); + let fresh = BTreeMap::from([("x".to_owned(), dir_fact(7))]); + + let mut changes = diff(&old, &fresh); + changes.sort_by_key(|c| format!("{c:?}")); + assert_eq!( + changes, + vec![ + Change::Added { + name: "x".to_owned(), + kind: Kind::Dir + }, + Change::Removed { name: "x".to_owned() }, + ] + ); +} + +#[test] +fn diff_inode_zero_rename_degrades_to_remove_add() { + // Same content moved a→b, but the provider reports inode 0 (unknown) for + // both — as on Windows. Without a stable inode there is nothing to match, + // so this must be Removed{a} + Added{b}, NOT a synthesized Renamed. + let old = BTreeMap::from([("a".to_owned(), file_fact(0))]); + let fresh = BTreeMap::from([("b".to_owned(), file_fact(0))]); + + let mut changes = diff(&old, &fresh); + changes.sort_by_key(|c| format!("{c:?}")); + assert_eq!( + changes, + vec![ + Change::Added { + name: "b".to_owned(), + kind: Kind::File + }, + Change::Removed { name: "a".to_owned() }, + ] + ); +} + +#[test] +fn diff_same_nonzero_inode_synthesizes_rename() { + // Contrast case: identical non-zero inode on both sides → the removed+added + // pair is coalesced into one Renamed. Locks in "only a real inode synthesizes". + let old = BTreeMap::from([("a".to_owned(), file_fact(42))]); + let fresh = BTreeMap::from([("b".to_owned(), file_fact(42))]); + + assert_eq!( + diff(&old, &fresh), + vec![Change::Renamed { + from: "a".to_owned(), + to: "b".to_owned() + }] + ); +} + +#[tokio::test] +async fn unmount_removes_node_and_snapshot_is_none() { + let (mut tree, dir) = real_tree(); + let c = canon(dir.path()); + tree.mount(c.as_str()).await.unwrap(); + assert!(tree.snapshot(c.as_str()).is_some()); + + tree.unmount(c.as_str()); + assert!(tree.snapshot(c.as_str()).is_none()); +} + +#[tokio::test] +async fn apply_on_unmounted_canonical_is_none() { + let (mut tree, dir) = real_tree(); + // Never mounted → in-flight-after-unmount guard returns None, not an error. + let c = canon(dir.path()); + assert!(tree.apply(c.as_str(), Hint::All).await.unwrap().is_none()); +} + +// ── TOCTOU ordering: a fake runtime that logs call order ────────────────── + +#[derive(Clone, Default)] +struct CallLog(Arc>>); +impl CallLog { + fn push(&self, s: &'static str) { + self.0.lock().unwrap().push(s); + } + fn calls(&self) -> Vec<&'static str> { + self.0.lock().unwrap().clone() + } +} + +struct FakeProvider { + log: CallLog, +} +#[async_trait] +impl IFsProvider for FakeProvider { + fn scheme(&self) -> &str { + "file" + } + async fn read_dir(&self, _uri: &str) -> Result, FsError> { + self.log.push("read_dir"); + Ok(vec![]) + } + async fn stat(&self, _uri: &str) -> Result, FsError> { + Ok(None) + } + async fn read(&self, _uri: &str) -> Result, FsError> { + Ok(vec![]) + } + async fn write(&self, _uri: &str, _data: &[u8]) -> Result<(), FsError> { + Ok(()) + } + async fn create_file(&self, _uri: &str) -> Result<(), FsError> { + Ok(()) + } + async fn mkdir(&self, _uri: &str) -> Result<(), FsError> { + Ok(()) + } + async fn remove(&self, _uri: &str, _recursive: bool) -> Result<(), FsError> { + Ok(()) + } + async fn rename(&self, _from: &str, _to: &str) -> Result<(), FsError> { + Ok(()) + } + async fn copy(&self, _from: &str, _to: &str, _recursive: bool) -> Result<(), FsError> { + Ok(()) + } +} + +struct FakeWatcher { + log: CallLog, +} +impl Watcher for FakeWatcher { + fn watch(&self, canonical: &str) -> Result { + self.log.push("watch"); + Ok(WatchHandle { + canonical: canonical.to_owned(), + }) + } + fn unwatch(&self, _handle: &WatchHandle) { + self.log.push("unwatch"); + } +} + +struct FakeRuntime { + provider: FakeProvider, + watcher: FakeWatcher, +} +impl IFsRuntime for FakeRuntime { + fn provider(&self) -> &dyn IFsProvider { + &self.provider + } + fn watcher(&self) -> &dyn Watcher { + &self.watcher + } + fn io_dispatch(&self) -> IoDispatch { + IoDispatch::Inline + } +} + +#[tokio::test] +async fn mount_arms_watch_before_reading_baseline() { + let log = CallLog::default(); + let runtime = FakeRuntime { + provider: FakeProvider { log: log.clone() }, + watcher: FakeWatcher { log: log.clone() }, + }; + let mut registry = FsRuntimeRegistry::new(); + registry.register("file", Arc::new(runtime)); + let mut tree = TreeModel::new(registry); + + tree.mount("file:///tmp/x").await.unwrap(); + + // Watch armed strictly before the baseline read — no TOCTOU gap. + assert_eq!(log.calls(), vec!["watch", "read_dir"]); +} diff --git a/crates/aionui-project/src/runtime/watcher.rs b/crates/aionui-project/src/runtime/watcher.rs new file mode 100644 index 000000000..52c41ecd3 --- /dev/null +++ b/crates/aionui-project/src/runtime/watcher.rs @@ -0,0 +1,88 @@ +//! `Watcher` — the watch half of a filesystem runtime. +//! +//! Lazy, non-recursive, per-canonical: one watch per observed directory, no +//! recursive watches (watch count is O(observed), independent of tree size). +//! The watcher only starts/stops watches and emits raw change / overflow +//! signals onto a channel; it does not read directories, produce deltas, or +//! know about projects/sessions. Delta reconciliation is the tree model's job. +//! +//! Debounce/coalesce is intentionally NOT here: per `formal/runtime/runtime.md` +//! the debounce stage sits between the watcher callback and the shard worker in +//! the apply pipeline, so it lives in the actor layer where its window is +//! testable with a paused clock. This watcher emits raw, un-debounced events. + +use std::path::Path; + +use super::error::FsError; + +/// Opaque handle for an active watch, returned by [`Watcher::watch`] and passed +/// back to [`Watcher::unwatch`]. Carries the canonical it watches. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WatchHandle { + pub canonical: String, +} + +/// A raw filesystem signal, pre-reconciliation. `paths` are absolute fs path +/// strings the OS reported changed under `canonical` (used only as a "which +/// children to re-check" hint; the tree model re-reads to find the truth). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RawEvent { + Changed { canonical: String, paths: Vec }, + Overflow { canonical: String }, +} + +/// Start/stop non-recursive watches. Concrete impls push [`RawEvent`]s onto an +/// out channel obtained at construction. +pub trait Watcher: Send + Sync { + /// Begin watching `canonical` (a `file:` URI) non-recursively. + fn watch(&self, canonical: &str) -> Result; + /// Stop watching the directory `handle` refers to. + fn unwatch(&self, handle: &WatchHandle); +} + +/// Translate one `notify` event into zero or more [`RawEvent`]s, attributing +/// each affected path to a watched canonical via `resolve`. +/// +/// `resolve(path)` returns the watched canonical that owns `path` (an OS event +/// path is a child of a watched directory, or the directory itself), or `None` +/// if no watched directory owns it. Pure and deterministic — the concurrency- +/// free core of event mapping, unit-tested without a real filesystem. +pub fn map_event(event: ¬ify::Event, resolve: &dyn Fn(&Path) -> Option) -> Vec { + // Kernel dropped events (inotify IN_Q_OVERFLOW / RDCW buffer / FSEvents + // must-rescan): the node's facts are stale → one Overflow per affected + // canonical, deduped and order-stable. + if event.need_rescan() { + let mut seen: Vec = Vec::new(); + for path in &event.paths { + if let Some(c) = resolve(path) + && !seen.contains(&c) + { + seen.push(c); + } + } + return seen + .into_iter() + .map(|canonical| RawEvent::Overflow { canonical }) + .collect(); + } + + // Group changed paths under their owning canonical, preserving first-seen + // canonical order and per-canonical path order. + let mut groups: Vec<(String, Vec)> = Vec::new(); + for path in &event.paths { + let Some(canonical) = resolve(path) else { continue }; + let path_str = path.to_string_lossy().into_owned(); + match groups.iter_mut().find(|(c, _)| *c == canonical) { + Some((_, paths)) => paths.push(path_str), + None => groups.push((canonical, vec![path_str])), + } + } + groups + .into_iter() + .map(|(canonical, paths)| RawEvent::Changed { canonical, paths }) + .collect() +} + +#[cfg(test)] +#[path = "watcher_test.rs"] +mod watcher_test; diff --git a/crates/aionui-project/src/runtime/watcher_test.rs b/crates/aionui-project/src/runtime/watcher_test.rs new file mode 100644 index 000000000..8c177da46 --- /dev/null +++ b/crates/aionui-project/src/runtime/watcher_test.rs @@ -0,0 +1,127 @@ +use std::path::{Path, PathBuf}; + +use notify::event::{Flag, ModifyKind}; +use notify::{Event, EventKind}; + +use super::{RawEvent, map_event}; + +/// Resolve any path under `/root` to the fixed canonical, everything else None. +fn resolve_root(p: &Path) -> Option { + if p.starts_with("/root") { + Some("file:///root".to_owned()) + } else { + None + } +} + +#[test] +fn map_event_groups_changed_paths_by_canonical() { + let event = Event::new(EventKind::Modify(ModifyKind::Any)) + .add_path(PathBuf::from("/root/a.txt")) + .add_path(PathBuf::from("/root/b.txt")); + + let out = map_event(&event, &resolve_root); + + assert_eq!( + out, + vec![RawEvent::Changed { + canonical: "file:///root".to_owned(), + paths: vec!["/root/a.txt".to_owned(), "/root/b.txt".to_owned()], + }] + ); +} + +#[test] +fn map_event_drops_unresolved_paths() { + let event = Event::new(EventKind::Modify(ModifyKind::Any)) + .add_path(PathBuf::from("/root/a.txt")) + .add_path(PathBuf::from("/elsewhere/x.txt")); + + let out = map_event(&event, &resolve_root); + + assert_eq!( + out, + vec![RawEvent::Changed { + canonical: "file:///root".to_owned(), + paths: vec!["/root/a.txt".to_owned()], + }] + ); +} + +/// Resolve `/root` and `/lib` to distinct canonicals, everything else None. +fn resolve_two(p: &Path) -> Option { + if p.starts_with("/root") { + Some("file:///root".to_owned()) + } else if p.starts_with("/lib") { + Some("file:///lib".to_owned()) + } else { + None + } +} + +#[test] +fn map_event_groups_multiple_canonicals_first_seen_order() { + // Paths under two watched dirs, interleaved → one Changed per canonical, + // first-seen canonical order preserved, per-canonical path order preserved. + let event = Event::new(EventKind::Modify(ModifyKind::Any)) + .add_path(PathBuf::from("/root/a.txt")) + .add_path(PathBuf::from("/lib/x.txt")) + .add_path(PathBuf::from("/root/b.txt")); + + let out = map_event(&event, &resolve_two); + + assert_eq!( + out, + vec![ + RawEvent::Changed { + canonical: "file:///root".to_owned(), + paths: vec!["/root/a.txt".to_owned(), "/root/b.txt".to_owned()], + }, + RawEvent::Changed { + canonical: "file:///lib".to_owned(), + paths: vec!["/lib/x.txt".to_owned()], + }, + ] + ); +} + +#[test] +fn map_event_rescan_dedups_overflow_per_canonical() { + // A rescan touching a canonical more than once emits one Overflow for it, + // one per distinct canonical, order-stable by first appearance. + let event = Event::new(EventKind::Any) + .add_path(PathBuf::from("/root/a.txt")) + .add_path(PathBuf::from("/lib/x.txt")) + .add_path(PathBuf::from("/root/b.txt")) + .set_flag(Flag::Rescan); + + let out = map_event(&event, &resolve_two); + + assert_eq!( + out, + vec![ + RawEvent::Overflow { + canonical: "file:///root".to_owned() + }, + RawEvent::Overflow { + canonical: "file:///lib".to_owned() + }, + ] + ); +} + +#[test] +fn map_event_rescan_flag_yields_overflow() { + let event = Event::new(EventKind::Any) + .add_path(PathBuf::from("/root/a.txt")) + .set_flag(Flag::Rescan); + + let out = map_event(&event, &resolve_root); + + assert_eq!( + out, + vec![RawEvent::Overflow { + canonical: "file:///root".to_owned() + }] + ); +} diff --git a/crates/aionui-project/src/types.rs b/crates/aionui-project/src/types.rs index 5d53174ce..35268d0b3 100644 --- a/crates/aionui-project/src/types.rs +++ b/crates/aionui-project/src/types.rs @@ -173,6 +173,15 @@ pub enum ProjectError { #[error("unsupported resource scheme: {scheme}")] UnsupportedResourceScheme { scheme: String }, + #[error("uploaded file path is outside the managed upload directory: {path}")] + UploadPathOutsideRoot { path: String }, + + #[error("attached file does not exist: {path}")] + ChatFileMissing { path: String }, + + #[error("local file path is not a readable file: {path}")] + LocalPathNotReadable { path: String }, + #[error(transparent)] Database(#[from] DbError), } @@ -197,6 +206,9 @@ impl ProjectError { ProjectError::InvalidRelativePath { .. } => "invalid_relative_path", ProjectError::ResourceOutsideFolder { .. } => "resource_outside_folder", ProjectError::UnsupportedResourceScheme { .. } => "unsupported_resource_scheme", + ProjectError::UploadPathOutsideRoot { .. } => "upload_path_outside_root", + ProjectError::ChatFileMissing { .. } => "chat_file_missing", + ProjectError::LocalPathNotReadable { .. } => "local_path_not_readable", ProjectError::Database(_) => "internal_db_error", } } diff --git a/crates/aionui-realtime/src/handler.rs b/crates/aionui-realtime/src/handler.rs index 16ccc5e38..923e0af36 100644 --- a/crates/aionui-realtime/src/handler.rs +++ b/crates/aionui-realtime/src/handler.rs @@ -88,6 +88,8 @@ async fn handle_socket(socket: WebSocket, token: Option, state: WsHandle // Recv loop exited — client disconnected or errored. send_handle.abort(); state.manager.remove_client(conn_id); + // Let stateful routers release per-connection state (e.g. fs subscriptions). + state.router.on_disconnect(conn_id); info!(%conn_id, "websocket connection closed"); } diff --git a/crates/aionui-realtime/src/manager.rs b/crates/aionui-realtime/src/manager.rs index 4d5153ba6..723e5b903 100644 --- a/crates/aionui-realtime/src/manager.rs +++ b/crates/aionui-realtime/src/manager.rs @@ -119,6 +119,44 @@ impl WebSocketManager { self.send_raw_to(conn_id, WsOutbound::Text(text)); } + /// Strict scoped delivery for **ordered** streams: on backpressure the + /// connection is dropped rather than the frame. + /// + /// For a feed whose consumer applies frames in arrival order with no + /// gap/version detection (the fs monitor protocol), silently dropping one + /// frame on a full channel desyncs the client with no way to notice or + /// recover. So a `Full` (or `Closed`) channel removes the client instead — + /// the connection tears down and the client reconnects and re-declares its + /// subscriptions, restoring consistency. Unlike [`Self::send_to`], which + /// keeps the connection alive and logs the drop (fine for the fire-and-forget + /// broadcast path, not for an ordered stream). + pub fn send_to_or_disconnect(&self, conn_id: ConnectionId, msg: WebSocketMessage) { + let text = match serde_json::to_string(&msg) { + Ok(t) => t, + Err(e) => { + warn!(%conn_id, error = %e, "failed to serialize ordered unicast message"); + return; + } + }; + + // Determine the outcome without holding the map guard across removal. + let drop_connection = match self.connections.get(&conn_id) { + Some(client) => matches!( + client.tx.try_send(WsOutbound::Text(text)), + Err(mpsc::error::TrySendError::Full(_)) | Err(mpsc::error::TrySendError::Closed(_)) + ), + None => false, + }; + if drop_connection { + warn!( + %conn_id, + code = RealtimeError::Backpressure.code(), + "ordered stream backpressure, closing connection to force resync" + ); + self.remove_client(conn_id); + } + } + /// Send a raw outbound message to a specific connection. /// /// Used for non-`WebSocketMessage` payloads (e.g. error responses). A full @@ -434,6 +472,48 @@ mod tests { assert_eq!(mgr.client_count(), 0); } + #[test] + fn send_to_or_disconnect_delivers_when_capacity_available() { + let mgr = WebSocketManager::new(); + let (tx, mut rx) = new_client_tx(); + let id = mgr.add_client("tok".into(), tx); + + mgr.send_to_or_disconnect(id, WebSocketMessage::new("fs", json!({"ok": true}))); + + assert!(rx.try_recv().is_ok(), "frame delivered"); + assert_eq!(mgr.client_count(), 1, "connection kept when it fits"); + } + + #[test] + fn send_to_or_disconnect_closes_connection_on_full() { + let mgr = WebSocketManager::new(); + // Capacity 1 so the second send saturates the channel. + let (tx, _rx) = mpsc::channel(1); + let id = mgr.add_client("tok".into(), tx); + + mgr.send_to_or_disconnect(id, WebSocketMessage::new("fs", json!({}))); + assert_eq!(mgr.client_count(), 1, "first send fills the buffer, connection alive"); + + // Second send hits a full channel → backpressure close (no silent drop). + mgr.send_to_or_disconnect(id, WebSocketMessage::new("fs", json!({}))); + assert_eq!( + mgr.client_count(), + 0, + "ordered-stream backpressure closes the connection" + ); + } + + #[test] + fn send_to_or_disconnect_removes_on_closed() { + let mgr = WebSocketManager::new(); + let (tx, rx) = new_client_tx(); + let id = mgr.add_client("tok".into(), tx); + drop(rx); + + mgr.send_to_or_disconnect(id, WebSocketMessage::new("fs", json!({}))); + assert_eq!(mgr.client_count(), 0); + } + #[test] fn heartbeat_tick_sends_ping_to_healthy_connection() { let connections = Arc::new(DashMap::new()); diff --git a/crates/aionui-realtime/src/router.rs b/crates/aionui-realtime/src/router.rs index dd5d479d3..b8cc18dbc 100644 --- a/crates/aionui-realtime/src/router.rs +++ b/crates/aionui-realtime/src/router.rs @@ -11,6 +11,16 @@ pub trait MessageRouter: Send + Sync { /// Called for any message whose `name` is not handled internally /// by the WebSocket layer (i.e. not `pong` or `subscribe-show-open`). fn route(&self, conn_id: ConnectionId, name: &str, data: serde_json::Value) -> bool; + + /// Notify the router that a connection has closed. + /// + /// Called once by the WebSocket layer after the receive loop for `conn_id` + /// exits (client disconnect, transport error, or backpressure close), so + /// stateful routers can release per-connection state (e.g. drop a session's + /// filesystem subscriptions). Default is a no-op for stateless routers. + fn on_disconnect(&self, conn_id: ConnectionId) { + let _ = conn_id; + } } /// A no-op message router that reports every message as unhandled. @@ -51,4 +61,34 @@ mod tests { assert!(!handled); } + + #[test] + fn on_disconnect_default_is_noop() { + // Stateless routers inherit the default no-op; must not panic. + let router: Box = Box::new(NoopMessageRouter); + router.on_disconnect(ConnectionId(7)); + } + + #[test] + fn on_disconnect_override_receives_conn_id() { + use std::sync::Mutex; + + struct RecordingRouter { + disconnected: Mutex>, + } + impl MessageRouter for RecordingRouter { + fn route(&self, _conn_id: ConnectionId, _name: &str, _data: serde_json::Value) -> bool { + false + } + fn on_disconnect(&self, conn_id: ConnectionId) { + self.disconnected.lock().unwrap().push(conn_id); + } + } + + let router = RecordingRouter { + disconnected: Mutex::new(Vec::new()), + }; + router.on_disconnect(ConnectionId(99)); + assert_eq!(router.disconnected.lock().unwrap().as_slice(), &[ConnectionId(99)]); + } } diff --git a/crates/aionui-team/src/service.rs b/crates/aionui-team/src/service.rs index 0754566b9..2ae6f0683 100644 --- a/crates/aionui-team/src/service.rs +++ b/crates/aionui-team/src/service.rs @@ -7,6 +7,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock, Weak}; use aionui_ai_agent::{ActiveLeaseRegistry, AgentError, AgentInstance, IWorkerTaskManager, IdleCleanupCoordinator}; +use aionui_api_types::ChatFileRef; use aionui_api_types::{ AddAgentRequest, CreateTeamRequest, GetConfigOptionsResponse, TeamAgentResponse, TeamAgentRuntimeStatus, TeamResponse, TeamRunAckResponse, TeamRunStateResponse, TeamSessionBinding, TeamSessionPhase, TeamSessionStatus, @@ -1779,10 +1780,11 @@ impl TeamSessionService { user_id: &str, team_id: &str, content: &str, - files: Option>, + files: Option>, ) -> Result { self.load_owned_team(user_id, team_id).await?; self.ensure_session_inner(team_id).await?; + let (content, files) = self.resolve_message_attachments(content, files).await?; let session = { let entry = self .sessions @@ -1790,7 +1792,7 @@ impl TeamSessionService { .ok_or_else(|| TeamError::SessionNotFound(team_id.into()))?; Arc::clone(&entry.session) }; - session.send_message(content, files).await + session.send_message(&content, files).await } pub async fn send_message_to_agent( @@ -1799,10 +1801,11 @@ impl TeamSessionService { team_id: &str, slot_id: &str, content: &str, - files: Option>, + files: Option>, ) -> Result { self.load_owned_team(user_id, team_id).await?; self.ensure_session_inner(team_id).await?; + let (content, files) = self.resolve_message_attachments(content, files).await?; let session = { let entry = self .sessions @@ -1810,7 +1813,35 @@ impl TeamSessionService { .ok_or_else(|| TeamError::SessionNotFound(team_id.into()))?; Arc::clone(&entry.session) }; - session.send_message_to_agent(slot_id, content, files).await + session.send_message_to_agent(slot_id, &content, files).await + } + + /// Resolve a send's attachments to absolute paths and re-inline them into + /// the content (`[[AION_FILES]]` form) at the team send boundary. Atomic; + /// empty/absent `files` is a no-op needing no project service. + async fn resolve_message_attachments( + &self, + content: &str, + files: Option>, + ) -> Result<(String, Option>), TeamError> { + let files = match files { + Some(files) if !files.is_empty() => files, + _ => return Ok((content.to_owned(), None)), + }; + let project = self + .project_service + .read() + .ok() + .and_then(|guard| guard.clone()) + .ok_or_else(|| { + TeamError::InvalidRequest("project service unavailable; cannot resolve file attachments".into()) + })?; + let upload_root = std::env::temp_dir().join("aionui"); + let resolved = project + .resolve_chat_message(content, &files, &upload_root) + .await + .map_err(|err| TeamError::InvalidRequest(err.to_string()))?; + Ok((resolved.content, Some(resolved.files))) } /// Directed retry/wakeup for a single member runtime (dormant or failed),