Survey + fixes so the pipeline passes at the Docker level (no k8s).
- Remove k8s: drop the `sandbox-k8s` job (kind/Calico/--features k8s-tests) and the
"Helm chart lints" gate step. release.yml was already k8s-clean.
- Rust job:
- `cargo fmt --all` — fix pre-existing formatting drift (fmt --check was failing).
- clippy -D warnings: fix 3 lib warnings (cm-brain sort_by_key→Reverse, cm-api
fleet.rs doc list indentation, node_rules map_or→is_none_or).
- Regenerate the .sqlx offline cache (was missing the cm-runtime run_loop test
query → offline compile failed). DB-backed tests use testcontainers at runtime.
- Set SQLX_OFFLINE=true on the rust + e2e jobs so query! macros compile against
the committed cache deterministically (no DB needed at compile time).
- Frontend job:
- Fix the 1 ESLint error (useAgentTelemetry: no setState-synchronously-in-effect;
tag the slice with agentId + derive null on mismatch).
- Fix 2 stale panel-params tests (`terminal` is a valid app id now; assert the
current APP_IDS + use a genuinely-unknown id for the reject case).
Verified locally: fmt clean, clippy --all-targets -D warnings clean (offline),
frontend lint 0 errors, tsc clean, 86/86 frontend tests pass, build OK.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
195 lines
5.9 KiB
Rust
195 lines
5.9 KiB
Rust
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<String, u64>,
|
|
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + 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<String>,
|
|
}
|
|
|
|
/// GET /api/openclaw/files?clawId=&drive= — the agent's personal drives (§7.4).
|
|
pub async fn openclaw_files(
|
|
State(state): State<AppState>,
|
|
Authed(user): Authed,
|
|
Query(query): Query<FilesQuery>,
|
|
) -> Result<Json<Vec<FileNode>>, 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<AppState>,
|
|
Authed(user): Authed,
|
|
Query(query): Query<ContentQuery>,
|
|
) -> Result<String, ApiError> {
|
|
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<AppState>,
|
|
Authed(user): Authed,
|
|
Query(query): Query<SharedQuery>,
|
|
) -> Result<Json<Vec<FileNode>>, 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))
|
|
}
|