use crate::Workspace;
use crate::config;
use crate::turso::params;
use anyhow::Result;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
pub(crate) const COMMAND_DUMP_CAP_BYTES: usize = 10 * 1024 * 1024;
const MEDIA_SCAN_BUDGET_BYTES: usize = 10 * 1024 * 1024;
const MEDIA_VIDEO_EXTS: &[&str] = &[".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v"];
#[must_use]
pub(crate) fn research_root_base() -> PathBuf {
std::env::temp_dir().join("mahbot-research")
}
#[must_use]
pub(crate) fn run_root_path(job_id: &str) -> PathBuf {
research_root_base().join(job_id)
}
pub(crate) async fn ensure_run_root(job_id: &str) -> PathBuf {
let path = run_root_path(job_id);
if let Err(e) = tokio::fs::create_dir_all(&path).await {
tracing::warn!(job = %job_id, error = %e, "Failed to create run root — analyst scratch writes may fail");
}
tokio::fs::canonicalize(&path).await.unwrap_or(path)
}
async fn run_folder_exists(job_id: &str) -> bool {
tokio::fs::try_exists(run_root_path(job_id))
.await
.unwrap_or(false)
}
pub(crate) async fn release_run_folder(job_id: &str) {
if crate::search_engine::registry_initialized() {
crate::search_engine::remove_engine(job_id);
}
let path = run_root_path(job_id);
match tokio::fs::remove_dir_all(&path).await {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
tracing::warn!(job = %job_id, error = %e, "Run-folder release: folder removal failed — left for the OS");
}
}
}
pub(crate) async fn write_results_md(job_id: &str, question: &str, result: &str) {
let root = config::CONFIG
.try_storage_root()
.or_else(|| config::default_config_dir().ok());
let Some(root) = root else {
tracing::warn!(job = %job_id, "results.md skipped — no config dir");
return;
};
let dir = root.join("research").join("results");
let path = dir.join(format!("{job_id}.md"));
let content =
format!("# Research {job_id}\n\n## Question\n\n{question}\n\n## Result\n\n{result}\n");
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
tracing::warn!(job = %job_id, error = %e, "results.md: failed to create archive dir");
} else if let Err(e) = tokio::fs::write(&path, content).await {
tracing::warn!(job = %job_id, error = %e, "Failed to write results.md — run result not archived");
}
}
fn commands_from_history(history: &[crate::ChatMessage]) -> Vec<String> {
let mut out = Vec::new();
for msg in history {
let Some(decoded) = crate::session::decode_native_history_message(msg) else {
continue;
};
let crate::session::DecodedNativeHistoryMessage::Assistant {
tool_calls: Some(calls),
..
} = decoded
else {
continue;
};
for call in calls {
if crate::tools::normalize_tool_name(&call.name) != "shell" {
continue;
}
let (_, args) = crate::tools::normalize_tool_call(&call.name, call.arguments.clone());
if let Some(cmd) = args.get("command").and_then(serde_json::Value::as_str) {
out.push(cmd.to_string());
}
}
}
out
}
pub(crate) async fn collect_agent_shell_commands(agent_ids: &[String]) -> Vec<String> {
let store = crate::session::store();
let mut out = Vec::new();
for id in agent_ids {
let history = store.load(id).await;
out.extend(
commands_from_history(&history)
.into_iter()
.map(|c| crate::util::scrub_credentials(&c)),
);
}
out
}
fn dedup_newest_wins(commands: &[String]) -> Vec<String> {
let mut seen = std::collections::HashSet::with_capacity(commands.len());
let mut out = Vec::with_capacity(commands.len());
for cmd in commands.iter().rev() {
if seen.insert(cmd.clone()) {
out.push(cmd.clone());
}
}
out.reverse();
out
}
pub(crate) fn cap_command_dump(commands: &mut Vec<String>, cap: usize) {
commands.retain(|c| c.len() <= cap);
let mut total: usize = commands.iter().map(String::len).sum();
let mut drop = 0usize;
while drop < commands.len() && total > cap {
total -= commands[drop].len();
drop += 1;
}
if drop > 0 {
commands.drain(..drop);
}
}
pub(crate) async fn write_command_dump(run_root: &Path, commands: &[String]) {
let path = run_root.join("commands.dump");
let mut deduped = dedup_newest_wins(commands);
cap_command_dump(&mut deduped, COMMAND_DUMP_CAP_BYTES);
let content = deduped.join("\n") + "\n";
if let Err(e) = tokio::fs::write(&path, content).await {
tracing::warn!(error = %e, "Failed to write command dump — cleanup intent degraded");
}
}
pub(crate) async fn read_command_dump(run_root: &Path) -> Vec<String> {
let path = run_root.join("commands.dump");
match tokio::fs::read_to_string(&path).await {
Ok(content) => content.lines().map(str::to_string).collect(),
Err(_) => Vec::new(),
}
}
const CLEANUP_PROMPT_KEY: &str = "research/cleanup.md";
pub(crate) fn build_cleanup_prompt(
job_id: &str,
run_root: &Path,
dump_path: &Path,
ws: &Workspace,
) -> String {
crate::prompt::substitute(
&crate::prompt::load_prompt(CLEANUP_PROMPT_KEY),
&[
("{{run_id}}", job_id),
("{{run_folder}}", &run_root.to_string_lossy()),
("{{dump_path}}", &dump_path.to_string_lossy()),
("{{workspace}}", &ws.name),
],
)
}
pub(crate) async fn research_cleanup_row_exists(
conn: &crate::turso::Connection,
job_id: &str,
) -> Result<bool> {
Ok(conn
.query_optional(
"SELECT 1 FROM jobs WHERE id = ?1 AND kind = 'research_cleanup'",
params![job_id],
|_| Ok::<(), anyhow::Error>(()),
)
.await?
.is_some())
}
pub(crate) async fn dispatch_cleanup_for_pending_envelope(
job_id: &str,
envelope: &crate::message_router::AgentJob,
) {
let Ok(Some(ws)) = crate::workspace::store()
.get_by_name(&envelope.workspace_name)
.await
else {
tracing::warn!(
job = %job_id,
workspace = %envelope.workspace_name,
"Cleanup dispatch for pending envelope: workspace unresolvable"
);
return;
};
if !run_folder_exists(job_id).await {
tracing::debug!(
job = %job_id,
"Run folder absent — cleanup completed in a previous lifetime; envelope replay only"
);
return;
}
if let Err(e) = create_cleanup_job_row(job_id, &ws).await {
tracing::warn!(job = %job_id, error = %e, "Cleanup row creation for pending envelope failed");
}
}
#[must_use]
pub(crate) fn cleanup_agent_id(job_id: &str) -> String {
format!("cleanup_{job_id}")
}
pub(crate) async fn create_cleanup_job_row(job_id: &str, ws: &Workspace) -> Result<Option<String>> {
if crate::research_cancel::is_cancelled(job_id) {
tracing::info!(job = %job_id, "Research cleanup suppressed by manual cancel");
return Ok(None);
}
let conn = &crate::session::store().conn;
if research_cleanup_row_exists(conn, job_id).await? {
tracing::info!(job = %job_id, "Research cleanup already dispatched — skipping");
return Ok(None);
}
let run_root = ensure_run_root(job_id).await;
let dump_path = run_root.join("commands.dump");
let prompt = build_cleanup_prompt(job_id, &run_root, &dump_path, ws);
crate::jobs::spawn_job(
conn,
job_id,
&prompt,
&ws.name,
"",
"",
crate::Role::Sanitation,
&[crate::jobs::NewAgent {
agent_id: cleanup_agent_id(job_id),
kind: crate::jobs::AgentKind::Sanitation,
idx: None,
task: prompt.clone(),
}],
&crate::jobs::SpawnChild::ResearchCleanup,
)
.await
.map_err(|e| {
tracing::error!(job = %job_id, error = %e, "Failed to spawn research cleanup job");
e
})?;
Ok(Some(prompt))
}
pub(crate) async fn dispatch_research_cleanup(
job_id: &str,
question: &str,
ws: &Workspace,
) -> Result<()> {
let Some(prompt) = create_cleanup_job_row(job_id, ws).await? else {
return Ok(());
};
let ws = ws.clone();
let job_id_log = job_id.to_string();
let agent_id = cleanup_agent_id(job_id);
let agent_id_log = agent_id.clone();
let job_id = job_id.to_string();
let question = question.to_string();
tokio::spawn(async move {
run_cleanup_agent_and_finish(&job_id, Some(&question), &ws, &prompt).await;
});
tracing::info!(job = %job_id_log, agent = %agent_id_log, "Research cleanup dispatched");
Ok(())
}
async fn run_cleanup_agent_and_finish(
job_id: &str,
question: Option<&str>,
ws: &Workspace,
prompt: &str,
) {
let agent_id = cleanup_agent_id(job_id);
let (agent, response) = crate::agent::run_default_agent(
&agent_id,
crate::Role::Sanitation,
ws,
prompt,
None,
Some(crate::registry::ParentKey::Research(job_id.to_string())),
question.map(str::to_string),
)
.await;
let report = if agent.is_cancelled() {
"cancelled (manual research-run cancel)".to_string()
} else {
response.unwrap_or_else(|| {
format!(
"Research cleanup FAILED (job {job_id}): {}",
agent
.failure
.clone()
.unwrap_or_else(|| "no failure detail".to_string())
)
})
};
tracing::info!(
job = %job_id,
agent = %agent_id,
"Research cleanup finished: {}",
crate::util::scrub_credentials(&report)
);
release_run_folder(job_id).await;
let _ = crate::jobs::terminalize_job(&crate::session::store().conn, job_id).await;
}
pub(crate) async fn resume_research_cleanup(job_id: &str, ws: &Workspace) {
let Some((caller, _role)) = crate::jobs::resume_job_preamble(
&crate::session::store().conn,
job_id,
"Research cleanup resume",
"Research cleanup resume",
)
.await
else {
if !research_cleanup_row_exists(&crate::session::store().conn, job_id)
.await
.unwrap_or(true)
{
release_run_folder(job_id).await;
}
return;
};
let prompt = caller.task.clone();
run_cleanup_agent_and_finish(job_id, None, ws, &prompt).await;
}
static MEDIA_CURSORS: tokio::sync::Mutex<Option<HashMap<String, MediaCursor>>> =
tokio::sync::Mutex::const_new(None);
#[derive(Default)]
struct MediaCursor {
scanned: HashMap<String, String>,
session_content: HashMap<String, String>,
covered: bool,
content: String,
overflowed: bool,
}
#[cfg(test)]
pub(crate) async fn reset_media_cursors() {
if let Some(map) = MEDIA_CURSORS.lock().await.as_mut() {
map.clear();
}
}
fn strip_data_uris(content: &str) -> String {
static RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r"data:[A-Za-z0-9+/=:;,._-]+").expect("data URI regex compiles")
});
RE.replace_all(content, "").into_owned()
}
async fn list_files(dir: &Path) -> Vec<PathBuf> {
let dir = dir.to_path_buf();
tokio::task::spawn_blocking(move || {
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
for entry in rd.flatten() {
let Ok(ft) = entry.file_type() else {
continue;
};
let path = entry.path();
if ft.is_dir() {
walk(&path, out);
} else if ft.is_file() {
out.push(path);
}
}
}
let mut out = Vec::new();
walk(&dir, &mut out);
out
})
.await
.unwrap_or_default()
}
async fn artist_session_ids(user_name: &str) -> anyhow::Result<Vec<(String, String)>> {
let rows = crate::session::store()
.conn
.query(
"SELECT agent_id, last_activity FROM session_metadata WHERE role = 'artist' \
AND user_name = ?1 ORDER BY last_activity DESC",
params![user_name],
)
.await?;
let mut out = Vec::with_capacity(rows.len());
for r in &rows {
let agent_id = r.get::<String>(0)?;
let last_activity = r.get::<String>(1)?;
out.push((agent_id, last_activity));
}
Ok(out)
}
async fn artist_session_content(agent_id: &str) -> anyhow::Result<String> {
let rows = crate::session::store()
.conn
.query(
"SELECT content FROM sessions WHERE agent_id = ?1 ORDER BY id ASC",
params![agent_id],
)
.await?;
let mut out = Vec::with_capacity(rows.len());
for r in &rows {
out.push(r.get::<String>(0)?);
}
Ok(out.join("\n"))
}
pub async fn sweep_media() -> Result<u64> {
sweep_media_at(&crate::users::userspaces_root()).await
}
pub(crate) async fn sweep_media_at(userspaces_root: &Path) -> Result<u64> {
sweep_media_at_budgeted(userspaces_root, MEDIA_SCAN_BUDGET_BYTES).await
}
async fn sweep_media_at_budgeted(userspaces_root: &Path, budget_bytes: usize) -> Result<u64> {
let mut deleted = 0u64;
let mut budget = budget_bytes;
let mut guard = MEDIA_CURSORS.lock().await;
let cursor_map = guard.get_or_insert_with(HashMap::new);
let Ok(mut user_dirs) = tokio::fs::read_dir(userspaces_root).await else {
return Ok(0);
};
let mut seen_users: HashSet<String> = HashSet::new();
let mut complete_pass = true;
loop {
let user_entry = match user_dirs.next_entry().await {
Ok(Some(e)) => e,
Ok(None) => break,
Err(e) => {
complete_pass = false;
tracing::warn!(error = %e, "Media sweep: read_dir entry failed — skipping");
continue;
}
};
let user_path = user_entry.path();
let Ok(file_type) = user_entry.file_type().await else {
complete_pass = false;
continue;
};
if !file_type.is_dir() {
continue;
}
let Some(user_name) = user_path
.file_name()
.and_then(|n| n.to_str())
.map(std::string::ToString::to_string)
else {
complete_pass = false;
continue;
};
seen_users.insert(user_name.clone());
if budget == 0 {
complete_pass = false;
break; }
deleted += sweep_user_media(&user_name, &user_path, &mut budget, cursor_map).await;
}
if complete_pass {
cursor_map.retain(|u, _| seen_users.contains(u));
}
Ok(deleted)
}
#[expect(clippy::too_many_lines)] async fn sweep_user_media(
user_name: &str,
user_path: &Path,
budget: &mut usize,
cursors: &mut HashMap<String, MediaCursor>,
) -> u64 {
let cursor = cursors.entry(user_name.to_string()).or_default();
let Ok(session_ids) = artist_session_ids(user_name).await else {
tracing::warn!(user = %user_name, "Media sweep: artist-session query failed — user skipped for this tick");
return 0;
};
let ids_now: HashSet<&str> = session_ids.iter().map(|(id, _)| id.as_str()).collect();
if cursor
.scanned
.keys()
.any(|id| !ids_now.contains(id.as_str()))
{
cursor.scanned.clear();
cursor.session_content.clear();
cursor.content.clear();
cursor.covered = false;
cursor.overflowed = false;
}
if session_ids.is_empty() {
tracing::debug!(user = %user_name, "Media sweep: no artist sessions — files kept");
return 0;
}
if cursor.overflowed {
return 0;
}
let mut total = cursor.content.len();
let mut changed = false;
for (id, activity) in &session_ids {
if cursor
.scanned
.get(id)
.is_some_and(|saved| saved == activity)
{
continue;
}
if *budget == 0 {
break;
}
let Ok(content) = artist_session_content(id).await else {
tracing::warn!(user = %user_name, "Media sweep: session read failed — user skipped for this tick");
return 0;
};
*budget = budget.saturating_sub(content.len());
let stripped = strip_data_uris(&content);
let prior = cursor.session_content.get(id).map_or(0, String::len);
let new_total = total - prior + stripped.len();
if new_total > MEDIA_SCAN_BUDGET_BYTES * 4 {
cursor.overflowed = true;
cursor.scanned.insert(id.clone(), activity.clone());
break;
}
total = new_total;
cursor.session_content.insert(id.clone(), stripped);
cursor.scanned.insert(id.clone(), activity.clone());
changed = true;
}
if changed {
cursor.content = cursor
.session_content
.values()
.cloned()
.collect::<Vec<_>>()
.join("\n");
}
cursor.covered = session_ids
.iter()
.all(|(id, activity)| cursor.scanned.get(id) == Some(activity));
let mut deleted = 0u64;
if cursor.covered && !cursor.overflowed {
let mut base_counts: HashMap<String, usize> = HashMap::new();
let mut files: Vec<PathBuf> = Vec::new();
for dir in ["generated", "uploads"] {
let d = user_path.join(dir);
let Ok(md) = tokio::fs::symlink_metadata(&d).await else {
continue;
};
if md.file_type().is_symlink() {
continue;
}
for f in list_files(&d).await {
if let Some(b) = f.file_name().and_then(|n| n.to_str()) {
*base_counts.entry(b.to_string()).or_default() += 1;
}
files.push(f);
}
}
let content_lower = cursor.content.to_ascii_lowercase();
for f in files {
if is_mentioned(&f, &cursor.content, &content_lower, &base_counts) {
continue;
}
match tokio::fs::remove_file(&f).await {
Ok(()) => deleted += 1,
Err(e) => {
tracing::warn!(file = %f.display(), error = %e, "Media sweep: delete failed");
}
}
}
}
deleted
}
fn is_mentioned(
file: &Path,
content: &str,
content_lower: &str,
base_counts: &HashMap<String, usize>,
) -> bool {
let Some(base) = file.file_name().and_then(|n| n.to_str()) else {
return true; };
if base_counts.get(base) != Some(&1) {
return true; }
if content.contains(base) {
return true;
}
let lower_base = base.to_ascii_lowercase();
MEDIA_VIDEO_EXTS.iter().any(|e| lower_base.ends_with(e)) && content_lower.contains(&lower_base)
}
#[cfg(test)]
mod tests {
use super::*;
async fn init_stores() {
crate::util::test::init_management_test_stores().await;
}
async fn media_fixture(user: &str) -> tempfile::TempDir {
init_stores().await;
let userspaces = tempfile::tempdir().unwrap();
for sub in ["generated", "uploads"] {
tokio::fs::create_dir_all(userspaces.path().join(user).join(sub))
.await
.unwrap();
}
userspaces
}
async fn write_gen_files(userspaces: &Path, user: &str, names: &[&str]) -> Vec<PathBuf> {
let gdir = userspaces.join(user).join("generated");
let mut out = Vec::with_capacity(names.len());
for n in names {
let p = gdir.join(n);
tokio::fs::write(&p, "x").await.unwrap();
out.push(p);
}
out
}
#[tokio::test]
#[serial_test::serial(media_sweep)] async fn sweep_media_keeps_mentioned_and_removes_unmentioned_after_full_scan() {
let userspaces = media_fixture("alice").await;
let up = userspaces.path().join("alice").join("uploads");
let files =
write_gen_files(userspaces.path(), "alice", &["image_1.png", "image_2.png"]).await;
let mentioned = &files[0];
let orphan = &files[1];
let upload_orphan = up.join("photo_1.jpg");
tokio::fs::write(&upload_orphan, "x").await.unwrap();
let session = format!("[IMAGE:{}]", mentioned.canonicalize().unwrap().display());
let conn = &crate::session::store().conn;
let now = crate::turso::now();
conn.execute(
"INSERT INTO session_metadata (agent_id, last_activity, user_name, workspace_name, role) \
VALUES ('artist_a', ?1, 'alice', 'personal:alice', 'artist')",
params![now.clone()],
)
.await
.unwrap();
conn.execute(
"INSERT INTO sessions (agent_id, role, content, created_at) VALUES ('artist_a', 'assistant', ?1, ?2)",
params![session, now],
)
.await
.unwrap();
reset_media_cursors().await;
let n = sweep_media_at(userspaces.path()).await.unwrap();
assert_eq!(n, 2, "both unmentioned files deleted");
assert!(mentioned.exists(), "mentioned file kept");
assert!(!orphan.exists());
assert!(!upload_orphan.exists());
tokio::fs::write(&orphan, "x").await.unwrap();
let image3 = files[0].parent().unwrap().join("image_3.png");
tokio::fs::write(&image3, "x").await.unwrap();
let conn = &crate::session::store().conn;
let now = crate::turso::now();
conn.execute(
"INSERT INTO sessions (agent_id, role, content, created_at) \
VALUES ('artist_a', 'assistant', ?1, ?2)",
params![
format!("[IMAGE:{}]", orphan.canonicalize().unwrap().display()),
now
],
)
.await
.unwrap();
reset_media_cursors().await;
let n = sweep_media_at(userspaces.path()).await.unwrap();
assert_eq!(n, 1, "only the unmentioned image_3 deleted");
assert!(orphan.exists(), "now-mentioned orphan kept");
}
#[tokio::test]
#[serial_test::serial(media_sweep)] async fn sweep_media_deleted_session_rotates_files() {
let userspaces = media_fixture("clr").await;
let files = write_gen_files(userspaces.path(), "clr", &["f_a.png", "f_b.png"]).await;
insert_artist_session(
"artist_clr1",
"clr",
&format!("[IMAGE:{}]", files[0].canonicalize().unwrap().display()),
)
.await;
insert_artist_session(
"artist_clr2",
"clr",
&format!("[IMAGE:{}]", files[1].canonicalize().unwrap().display()),
)
.await;
reset_media_cursors().await;
assert_eq!(sweep_media_at(userspaces.path()).await.unwrap(), 0);
assert!(files[0].exists());
assert!(files[1].exists());
crate::session::store()
.conn
.execute(
"DELETE FROM session_metadata WHERE agent_id = 'artist_clr1'",
(),
)
.await
.unwrap();
let n = sweep_media_at(userspaces.path()).await.unwrap();
assert_eq!(n, 1, "f_a's only mention was in the cleared session");
assert!(!files[0].exists());
assert!(
files[1].exists(),
"f_b still mentioned by the surviving session"
);
}
#[test]
fn command_dump_dedup_newest_wins_and_caps() {
let cmds = vec!["a".to_string(), "b".to_string(), "a".to_string()];
assert_eq!(dedup_newest_wins(&cmds), vec!["b", "a"]);
let mut small = vec!["cat > /tmp/a".to_string(), "cat > /tmp/b".to_string()];
cap_command_dump(&mut small, 50);
assert_eq!(small.len(), 2, "under the cap — untouched");
let mut capped = vec!["x".repeat(100), "y".repeat(100), "z".repeat(100)];
cap_command_dump(&mut capped, 250);
assert_eq!(capped.len(), 2, "oldest dropped to fit the cap");
assert_eq!(capped[0], "y".repeat(100));
assert_eq!(capped[1], "z".repeat(100));
let mut huge = vec!["small".to_string(), "x".repeat(500)];
cap_command_dump(&mut huge, 400);
assert_eq!(huge, vec!["small".to_string()]);
}
#[tokio::test]
async fn command_dump_written_matches_scrubbed_collection() {
let td = tempfile::tempdir().unwrap();
let run_root = td.path();
let secret =
"curl -o /tmp/out.bin \"https://api.example.com/data?api_key=SECRET_API_KEY_123\"";
let commands: Vec<String> = [secret, "cat > /tmp/x"]
.into_iter()
.map(crate::util::scrub_credentials)
.collect();
write_command_dump(run_root, &commands).await;
let content = tokio::fs::read_to_string(run_root.join("commands.dump"))
.await
.unwrap();
assert!(content.contains("/tmp/x"));
assert!(
!content.contains("SECRET_API_KEY_123"),
"the scrubbed collection form is what lands in the dump"
);
assert_eq!(
content,
commands.join("\n") + "\n",
"dump written verbatim from the scrubbed capture"
);
}
#[test]
fn commands_from_history_normalizes_aliased_calls() {
let native = |calls: &str| {
crate::ChatMessage::assistant(format!(r#"{{"content":"","tool_calls":{calls}}}"#))
};
let history = vec![
native(r#"[{"id":"1","name":"bash","arguments":{"cmd":"cat > /tmp/x"}}]"#),
native(r#"[{"id":"2","name":"run_terminal_cmd","arguments":{"script":"tee /tmp/y"}}]"#),
native(r#"[{"id":"3","name":"shell","arguments":{"command":"mkdir -p /tmp/z"}}]"#),
native(r#"[{"id":"4","name":"search","arguments":{"query":"foo"}}]"#),
native(r#"[{"id":"5","name":"shell","arguments":"{\"command\":\"touch /tmp/w\"}"}]"#),
crate::ChatMessage::user("not a tool call"),
];
assert_eq!(
commands_from_history(&history),
vec![
"cat > /tmp/x",
"tee /tmp/y",
"mkdir -p /tmp/z",
"touch /tmp/w",
],
"alias names and arg keys normalized; non-shell calls skipped"
);
}
#[tokio::test]
async fn cleanup_prompt_builds_with_context() {
let td = tempfile::tempdir().unwrap();
let run_root = td.path();
let dump_path = run_root.join("commands.dump");
let ws = crate::workspace::test_ws_named(
run_root.to_str().expect("temp path is utf8"),
"cleanup_ws",
);
let prompt = build_cleanup_prompt("run_abc", run_root, &dump_path, &ws);
assert!(prompt.contains("run_abc"), "run id substituted");
assert!(prompt.contains(&run_root.to_string_lossy().to_string()));
assert!(prompt.contains(&dump_path.to_string_lossy().to_string()));
assert!(prompt.contains("cleanup_ws"));
assert!(prompt.contains("Never touch another run's folder"));
}
#[tokio::test]
async fn dispatch_research_cleanup_deduped_by_jobs_row() {
init_stores().await;
crate::util::test::create_test_workspace("/tmp/test_ws_cleanup", "test_ws").await;
let ws = crate::workspace::test_ws_named("/tmp/test_ws_cleanup", "test_ws");
crate::jobs::spawn_job(
&crate::session::store().conn,
"run_dedup",
"task",
&ws.name,
"",
"",
crate::Role::Sanitation,
&[],
&crate::jobs::SpawnChild::ResearchCleanup,
)
.await
.unwrap();
assert!(
research_cleanup_row_exists(&crate::session::store().conn, "run_dedup")
.await
.unwrap(),
"the pre-created row is the dedup marker"
);
dispatch_research_cleanup("run_dedup", "test question", &ws)
.await
.unwrap();
let rows = crate::session::store()
.conn
.query(
"SELECT COUNT(*) FROM jobs WHERE id = 'run_dedup' AND kind = 'research_cleanup'",
(),
)
.await
.unwrap();
assert_eq!(rows[0].get::<i64>(0).unwrap(), 1, "single cleanup job row");
let sessions = crate::session::store()
.conn
.query(
"SELECT COUNT(*) FROM session_metadata WHERE agent_id = 'cleanup_run_dedup'",
(),
)
.await
.unwrap();
assert_eq!(
sessions[0].get::<i64>(0).unwrap(),
0,
"deduped dispatch must not spawn the cleanup agent"
);
crate::jobs::terminalize_job(&crate::session::store().conn, "run_dedup")
.await
.unwrap();
}
async fn insert_artist_session(agent_id: &str, user: &str, content: &str) {
let conn = &crate::session::store().conn;
let now = crate::turso::now();
conn.execute(
"INSERT INTO session_metadata (agent_id, last_activity, user_name, workspace_name, role) \
VALUES (?1, ?2, ?3, ?4, 'artist')",
params![agent_id, now.clone(), user, format!("personal:{user}")],
)
.await
.unwrap();
conn.execute(
"INSERT INTO sessions (agent_id, role, content, created_at) \
VALUES (?1, 'assistant', ?2, ?3)",
params![agent_id, content, now],
)
.await
.unwrap();
}
#[tokio::test]
#[serial_test::serial(media_sweep)] async fn sweep_media_gates_deletion_on_full_coverage() {
let userspaces = media_fixture("gates").await;
let files = write_gen_files(
userspaces.path(),
"gates",
&["f_a.png", "f_b.png", "f_c.png"],
)
.await;
insert_artist_session(
"artist_g1",
"gates",
&format!("[IMAGE:{}]", files[0].canonicalize().unwrap().display()),
)
.await;
insert_artist_session(
"artist_g2",
"gates",
&format!("[IMAGE:{}]", files[1].canonicalize().unwrap().display()),
)
.await;
reset_media_cursors().await;
let n = sweep_media_at_budgeted(userspaces.path(), 1).await.unwrap();
assert_eq!(n, 0, "no deletion before full coverage");
assert!(files[2].exists());
let n = sweep_media_at_budgeted(userspaces.path(), 1).await.unwrap();
assert_eq!(n, 1, "unmentioned f_c deleted after full coverage");
assert!(files[0].exists());
assert!(files[1].exists());
assert!(!files[2].exists());
}
#[tokio::test]
#[serial_test::serial(media_sweep)] async fn sweep_media_cursor_survives_list_reorder() {
let userspaces = media_fixture("reorder").await;
let files = write_gen_files(userspaces.path(), "reorder", &["f_a.png", "f_b.png"]).await;
insert_artist_session(
"artist_r1",
"reorder",
&format!("[IMAGE:{}]", files[0].canonicalize().unwrap().display()),
)
.await;
insert_artist_session(
"artist_r2",
"reorder",
&format!("[IMAGE:{}]", files[1].canonicalize().unwrap().display()),
)
.await;
reset_media_cursors().await;
assert_eq!(
sweep_media_at_budgeted(userspaces.path(), 1).await.unwrap(),
0
);
let conn = &crate::session::store().conn;
conn.execute(
"UPDATE session_metadata SET last_activity = ?1 WHERE agent_id = 'artist_r1'",
params![crate::turso::now()],
)
.await
.unwrap();
let n = sweep_media_at_budgeted(userspaces.path(), 1).await.unwrap();
assert_eq!(n, 0, "both files mentioned — nothing to delete");
assert!(
files[0].exists(),
"f_a mentioned in the re-ordered scan kept"
);
assert!(files[1].exists());
}
#[tokio::test]
#[serial_test::serial(media_sweep)] async fn sweep_media_strips_data_uris() {
let userspaces = media_fixture("duri").await;
let files = write_gen_files(userspaces.path(), "duri", &["thumb.png"]).await;
insert_artist_session("artist_d1", "duri", "data:image/png;base64,AAAAthumb.png").await;
reset_media_cursors().await;
let n = sweep_media_at(userspaces.path()).await.unwrap();
assert_eq!(n, 1);
assert!(
!files[0].exists(),
"data-URI-embedded name is not a mention"
);
}
#[tokio::test]
#[serial_test::serial(media_sweep)] async fn sweep_media_keep_set_is_per_user() {
let userspaces = media_fixture("pualice").await;
let alice_files = write_gen_files(
userspaces.path(),
"pualice",
&["alice_1.png", "alice_2.png"],
)
.await;
let bgen = userspaces.path().join("pubob").join("generated");
tokio::fs::create_dir_all(&bgen).await.unwrap();
let bf = bgen.join("bob_pic.png");
tokio::fs::write(&bf, "x").await.unwrap();
insert_artist_session("artist_iso1", "pualice", "a log line with no file mentions").await;
insert_artist_session(
"artist_iso2",
"pubob",
&format!(
"[IMAGE:{}]",
alice_files[0].canonicalize().unwrap().display()
),
)
.await;
insert_artist_session(
"artist_iso3",
"pubob",
&format!("[IMAGE:{}]", bf.canonicalize().unwrap().display()),
)
.await;
reset_media_cursors().await;
let n = sweep_media_at(userspaces.path()).await.unwrap();
assert_eq!(
n, 2,
"alice's files deleted — bob's mention is out of zone; bob's own kept"
);
assert!(!alice_files[0].exists());
assert!(!alice_files[1].exists());
assert!(bf.exists(), "bob's mentioned file kept");
}
#[tokio::test]
#[serial_test::serial(media_sweep)] async fn sweep_media_empty_session_base_keeps_files() {
let userspaces = media_fixture("guard").await;
let files = write_gen_files(userspaces.path(), "guard", &["legacy.png"]).await;
reset_media_cursors().await;
let n = sweep_media_at(userspaces.path()).await.unwrap();
assert_eq!(n, 0, "no artist sessions → no deletion");
assert!(files[0].exists(), "unscanned files are never deleted");
}
#[tokio::test]
#[serial_test::serial(media_sweep)] async fn sweep_media_rescans_grown_session_before_deleting() {
let userspaces = media_fixture("grow").await;
let files = write_gen_files(
userspaces.path(),
"grow",
&["f_a.png", "f_b.png", "f_c.png"],
)
.await;
insert_artist_session(
"artist_grow1",
"grow",
&format!("[IMAGE:{}]", files[0].canonicalize().unwrap().display()),
)
.await;
insert_artist_session(
"artist_grow2",
"grow",
&format!("[IMAGE:{}]", files[1].canonicalize().unwrap().display()),
)
.await;
let conn = &crate::session::store().conn;
conn.execute(
"UPDATE session_metadata SET last_activity = ?1 WHERE agent_id = 'artist_grow1'",
params![(chrono::Utc::now() - chrono::Duration::hours(1)).to_rfc3339()],
)
.await
.unwrap();
reset_media_cursors().await;
assert_eq!(
sweep_media_at_budgeted(userspaces.path(), 1).await.unwrap(),
0,
"no deletion before full coverage"
);
assert!(files[2].exists());
conn.execute(
"INSERT INTO sessions (agent_id, role, content, created_at) \
VALUES ('artist_grow2', 'assistant', ?1, ?2)",
params![
format!("[IMAGE:{}]", files[2].canonicalize().unwrap().display()),
crate::turso::now()
],
)
.await
.unwrap();
conn.execute(
"UPDATE session_metadata SET last_activity = ?1 WHERE agent_id = 'artist_grow2'",
params![crate::turso::now()],
)
.await
.unwrap();
assert_eq!(
sweep_media_at_budgeted(userspaces.path(), 1).await.unwrap(),
0
);
assert!(
files[2].exists(),
"mention in the grown session not yet scanned"
);
let cursors = MEDIA_CURSORS.lock().await;
let c = cursors
.as_ref()
.expect("cursor map initialized")
.get("grow")
.expect("grow cursor");
let fb_mention = format!("[IMAGE:{}]", files[1].canonicalize().unwrap().display());
assert_eq!(
c.content.matches(&fb_mention).count(),
1,
"re-scan replaced, not re-appended"
);
drop(cursors);
assert_eq!(
sweep_media_at_budgeted(userspaces.path(), 1).await.unwrap(),
0,
"all three files mentioned — nothing to delete"
);
assert!(files[0].exists());
assert!(files[1].exists());
assert!(
files[2].exists(),
"file mentioned in the grown session kept"
);
}
#[tokio::test]
#[serial_test::serial(media_sweep)] async fn sweep_media_no_rescan_cycle_does_not_starve_later_users() {
let userspaces = media_fixture("starve1").await;
let dirs = ["starve1", "starve2"];
for u in dirs {
if u != "starve1" {
let gdir = userspaces.path().join(u).join("generated");
tokio::fs::create_dir_all(&gdir).await.unwrap();
}
let orphan = format!("{u}_orphan.png");
write_gen_files(userspaces.path(), u, &[&orphan]).await;
insert_artist_session(
&format!("artist_{u}"),
u,
"a log line with no file mentions",
)
.await;
}
reset_media_cursors().await;
assert_eq!(
sweep_media_at_budgeted(userspaces.path(), 1).await.unwrap(),
1
);
assert_eq!(
sweep_media_at_budgeted(userspaces.path(), 1).await.unwrap(),
1,
"second user swept on the next tick — covered user consumed no budget"
);
for u in dirs {
assert!(
!userspaces
.path()
.join(u)
.join("generated")
.join(format!("{u}_orphan.png"))
.exists(),
"{u}'s unmentioned file deleted"
);
}
}
#[tokio::test]
#[serial_test::serial(media_sweep)] async fn sweep_media_basename_unique_across_dirs() {
let userspaces = media_fixture("base").await;
let up = userspaces.path().join("base").join("uploads");
tokio::fs::create_dir_all(&up).await.unwrap();
let g = userspaces
.path()
.join("base")
.join("generated")
.join("pic.png");
let u = up.join("pic.png");
tokio::fs::write(&g, "x").await.unwrap();
tokio::fs::write(&u, "x").await.unwrap();
insert_artist_session("artist_b1", "base", "here is pic.png").await;
reset_media_cursors().await;
let n = sweep_media_at(userspaces.path()).await.unwrap();
assert_eq!(n, 0, "ambiguous basename keeps both files");
assert!(g.exists());
assert!(u.exists());
insert_artist_session(
"artist_b2",
"base",
&format!("[IMAGE:{}]", u.canonicalize().unwrap().display()),
)
.await;
reset_media_cursors().await;
let n = sweep_media_at(userspaces.path()).await.unwrap();
assert_eq!(
n, 0,
"ambiguous duplicate kept even when only one is mentioned"
);
assert!(g.exists());
assert!(u.exists());
}
#[tokio::test]
#[serial_test::serial(media_sweep)] async fn sweep_media_prunes_cursors_of_vanished_users() {
let userspaces = media_fixture("gone").await;
write_gen_files(userspaces.path(), "gone", &["f_a.png"]).await;
insert_artist_session("artist_gone1", "gone", "no file mentions").await;
reset_media_cursors().await;
assert_eq!(sweep_media_at(userspaces.path()).await.unwrap(), 1);
assert!(
MEDIA_CURSORS
.lock()
.await
.as_ref()
.unwrap()
.contains_key("gone")
);
tokio::fs::remove_dir_all(userspaces.path().join("gone"))
.await
.unwrap();
assert_eq!(sweep_media_at(userspaces.path()).await.unwrap(), 0);
assert!(
!MEDIA_CURSORS
.lock()
.await
.as_ref()
.unwrap()
.contains_key("gone")
);
}
#[tokio::test]
#[serial_test::serial(media_sweep)] async fn sweep_media_video_case_insensitive() {
let userspaces = media_fixture("video").await;
let files = write_gen_files(userspaces.path(), "video", &["clip.mp4", "Photo.PNG"]).await;
insert_artist_session("artist_v1", "video", "[VIDEO:CLIP.MP4] photo.png").await;
reset_media_cursors().await;
let n = sweep_media_at(userspaces.path()).await.unwrap();
assert_eq!(n, 1);
assert!(
files[0].exists(),
"video kept via case-insensitive basename"
);
assert!(!files[1].exists(), "case-mismatched non-video not kept");
}
#[tokio::test]
#[serial_test::serial(media_sweep)] async fn sweep_media_overflowed_cleared_by_clear_rotation() {
let userspaces = media_fixture("ovf").await;
let files = write_gen_files(userspaces.path(), "ovf", &["f_a.png", "f_b.png"]).await;
let huge = "x".repeat(MEDIA_SCAN_BUDGET_BYTES * 4 + 1);
insert_artist_session(
"artist_ovf1",
"ovf",
&format!(
"[IMAGE:{}] {huge}",
files[0].canonicalize().unwrap().display()
),
)
.await;
reset_media_cursors().await;
let n = sweep_media_at(userspaces.path()).await.unwrap();
assert_eq!(n, 0, "overflowed keep-set never deletes");
assert!(files[0].exists());
assert!(files[1].exists());
crate::session::store()
.conn
.execute(
"DELETE FROM session_metadata WHERE agent_id = 'artist_ovf1'",
(),
)
.await
.unwrap();
insert_artist_session(
"artist_ovf2",
"ovf",
&format!("[IMAGE:{}]", files[1].canonicalize().unwrap().display()),
)
.await;
let n = sweep_media_at(userspaces.path()).await.unwrap();
assert_eq!(n, 1, "overflowed user re-enabled after /clear rotation");
assert!(!files[0].exists());
assert!(files[1].exists());
}
#[cfg(unix)]
#[tokio::test]
#[serial_test::serial(media_sweep)] async fn sweep_media_does_not_follow_symlinks() {
use std::os::unix::fs::symlink;
let userspaces = media_fixture("sym").await;
let outside = tempfile::tempdir().unwrap();
let victim = outside.path().join("victim.png");
tokio::fs::write(&victim, "x").await.unwrap();
let link = userspaces
.path()
.join("sym")
.join("generated")
.join("linked");
symlink(outside.path(), &link).unwrap();
let files = write_gen_files(userspaces.path(), "sym", &["kept.png", "orphan.png"]).await;
insert_artist_session(
"artist_sym",
"sym",
&format!("[IMAGE:{}]", files[0].canonicalize().unwrap().display()),
)
.await;
reset_media_cursors().await;
let n = sweep_media_at(userspaces.path()).await.unwrap();
assert_eq!(n, 1, "orphan deleted; symlink and outside victim untouched");
assert!(files[0].exists(), "mentioned file kept");
assert!(!files[1].exists(), "unmentioned file deleted");
assert!(victim.exists(), "file outside the userspace root untouched");
assert!(link.exists(), "symlink itself kept");
}
#[cfg(unix)]
#[tokio::test]
#[serial_test::serial(media_sweep)] async fn sweep_media_skips_top_level_symlinked_dir() {
use std::os::unix::fs::symlink;
let userspaces = media_fixture("topsym").await;
let outside = tempfile::tempdir().unwrap();
let victim = outside.path().join("victim.png");
tokio::fs::write(&victim, "x").await.unwrap();
let gen_dir = userspaces.path().join("topsym").join("generated");
tokio::fs::remove_dir_all(&gen_dir).await.unwrap();
symlink(outside.path(), &gen_dir).unwrap();
let up = userspaces.path().join("topsym").join("uploads");
let kept = up.join("kept.png");
let orphan = up.join("orphan.png");
tokio::fs::write(&kept, "x").await.unwrap();
tokio::fs::write(&orphan, "x").await.unwrap();
insert_artist_session(
"artist_topsym",
"topsym",
&format!("[IMAGE:{}]", kept.canonicalize().unwrap().display()),
)
.await;
reset_media_cursors().await;
let n = sweep_media_at(userspaces.path()).await.unwrap();
assert_eq!(n, 1, "orphan deleted; symlinked generated tree untouched");
assert!(kept.exists(), "mentioned uploads file kept");
assert!(!orphan.exists(), "unmentioned uploads file deleted");
assert!(
victim.exists(),
"file behind the top-level symlink untouched"
);
assert!(gen_dir.exists(), "top-level symlink itself kept");
}
}