use std::collections::HashMap; use std::path::Path; use axum::extract::{Query, State}; use axum::Json; use cm_domain::{AgentId, FileDrive, FileNode, WorkspaceId}; use serde::Deserialize; use sqlx::PgPool; use crate::routes::claws::workspace_agent; use crate::{ApiError, AppState, Authed}; /// Recursively collect files under `dir` as (path-relative-to-dir → size). fn walk_files<'a>( dir: std::path::PathBuf, base: std::path::PathBuf, out: &'a mut HashMap, ) -> std::pin::Pin + Send + 'a>> { Box::pin(async move { let mut rd = match tokio::fs::read_dir(&dir).await { Ok(rd) => rd, Err(_) => return, }; while let Ok(Some(entry)) = rd.next_entry().await { let path = entry.path(); let Ok(ft) = entry.file_type().await else { continue; }; if ft.is_dir() { walk_files(path, base.clone(), out).await; } else if ft.is_file() { if let Ok(rel) = path.strip_prefix(&base) { let rel = rel.to_string_lossy().replace('\\', "/"); let size = entry.metadata().await.map(|m| m.len()).unwrap_or(0); out.insert(rel, size); } } } }) } /// Reconcile `file_nodes` with what's actually in the drive's blob directory, so /// files the Terminal created/edited/removed are reflected in the Files app /// (Local backend only; the blob layout is `{root}/{ws}/{drive}/{scope}/{path}`). async fn reconcile_drive( pool: &PgPool, root: &Path, ws: WorkspaceId, drive: FileDrive, agent: AgentId, ) { let scope = if drive.is_agent_scoped() { agent.to_string() } else { "shared".to_string() }; let dir = root.join(format!("{ws}/{}/{scope}", drive.as_str())); let mut disk = HashMap::new(); walk_files( dir, root.join(format!("{ws}/{}/{scope}", drive.as_str())), &mut disk, ) .await; let db = match cm_db::repo::files::list(pool, ws, drive, agent).await { Ok(d) => d, Err(_) => return, }; // Upsert files present on disk but new/changed in the index. for (path, size) in &disk { let existing = db.iter().find(|n| &n.path == path); if existing.map(|n| n.size as u64) == Some(*size) { continue; } let node = FileNode { id: existing.map(|n| n.id).unwrap_or_else(uuid::Uuid::now_v7), workspace_id: ws, agent_id: drive.is_agent_scoped().then_some(agent), drive, path: path.clone(), size: *size as i64, blob_ref: format!("{ws}/{}/{scope}/{path}", drive.as_str()), }; let _ = cm_db::repo::files::upsert(pool, &node).await; } // Drop index rows whose blob no longer exists on disk. for n in &db { if n.blob_ref.is_empty() { continue; } if !root.join(&n.blob_ref).exists() { let _ = cm_db::repo::files::delete(pool, n.id).await; } } } #[derive(Deserialize)] pub struct FilesQuery { #[serde(rename = "clawId")] claw_id: AgentId, /// `documents` (default) or `received` for the per-agent drives. drive: Option, } /// GET /api/openclaw/files?clawId=&drive= — the agent's personal drives (§7.4). pub async fn openclaw_files( State(state): State, Authed(user): Authed, Query(query): Query, ) -> Result>, ApiError> { let agent = workspace_agent(&state, &user, query.claw_id).await?; let drive: FileDrive = query .drive .as_deref() .unwrap_or("documents") .parse() .map_err(|_| ApiError::NotFound)?; if !drive.is_agent_scoped() { return Err(ApiError::NotFound); // shared drive has its own route } if let Some(root) = &state.file_root { reconcile_drive(&state.pool, root, user.workspace_id, drive, agent.id).await; } let nodes = cm_db::repo::files::list(&state.pool, user.workspace_id, drive, agent.id).await?; Ok(Json(nodes)) } #[derive(Deserialize)] pub struct SharedQuery { #[serde(rename = "clawId")] claw_id: AgentId, } #[derive(Deserialize)] pub struct ContentQuery { #[serde(rename = "clawId")] claw_id: AgentId, drive: String, path: String, } /// GET /api/openclaw/files/content?clawId=&drive=&path= — a file's text content /// from a drive (used by the Obsidian vault viewer to render a note). pub async fn file_content( State(state): State, Authed(user): Authed, Query(query): Query, ) -> Result { let agent = workspace_agent(&state, &user, query.claw_id).await?; let drive: FileDrive = query.drive.parse().map_err(|_| ApiError::NotFound)?; let scope = if drive.is_agent_scoped() { agent.id.to_string() } else { "shared".to_string() }; let key = format!( "{}/{}/{}/{}", user.workspace_id, drive.as_str(), scope, query.path ); let bytes = state .runtime .blob() .get(&key) .await .map_err(|_| ApiError::NotFound)?; Ok(String::from_utf8_lossy(&bytes).to_string()) } /// GET /api/shared-drive/files?clawId= — the team-wide ClawDrive (§7.4). pub async fn shared_files( State(state): State, Authed(user): Authed, Query(query): Query, ) -> Result>, ApiError> { let agent = workspace_agent(&state, &user, query.claw_id).await?; if let Some(root) = &state.file_root { reconcile_drive( &state.pool, root, user.workspace_id, FileDrive::Shared, agent.id, ) .await; } let nodes = cm_db::repo::files::list(&state.pool, user.workspace_id, FileDrive::Shared, agent.id) .await?; Ok(Json(nodes)) }