|
1 | 1 | use std::future::Future; |
2 | | -use std::path::PathBuf; |
| 2 | +use std::path::{Path, PathBuf}; |
3 | 3 | use std::pin::Pin; |
4 | 4 | use std::sync::Arc; |
5 | 5 |
|
@@ -37,6 +37,7 @@ use aionui_extension::AssistantRuleDispatcher; |
37 | 37 | use aionui_mcp::{AcpMcpCapabilities, parse_acp_mcp_capabilities}; |
38 | 38 | use aionui_realtime::EventBroadcaster; |
39 | 39 | use aionui_runtime::{RuntimeCommandProbe, probe_node_runtime_supported, probe_runtime_command, resolve_command_path}; |
| 40 | +use chrono::Datelike; |
40 | 41 | use std::collections::{HashMap, HashSet}; |
41 | 42 | use std::time::Duration; |
42 | 43 | use tracing::{debug, error, info, warn}; |
@@ -393,10 +394,7 @@ impl ConversationService { |
393 | 394 | } |
394 | 395 |
|
395 | 396 | pub fn create_team_temp_workspace(&self, team_id: &str) -> Result<String, ConversationError> { |
396 | | - let ws_path = self |
397 | | - .workspace_root |
398 | | - .join("conversations") |
399 | | - .join(format!("team-temp-{team_id}")); |
| 397 | + let ws_path = auto_workspace_parent(&self.workspace_root).join(format!("team-temp-{team_id}")); |
400 | 398 | std::fs::create_dir_all(&ws_path) |
401 | 399 | .map_err(|e| ConversationError::internal(format!("Failed to create Team temporary workspace: {e}")))?; |
402 | 400 | Ok(ws_path.to_string_lossy().into_owned()) |
@@ -729,7 +727,8 @@ impl ConversationService { |
729 | 727 | } |
730 | 728 |
|
731 | 729 | // Determine whether the user chose this workspace ("custom") or we |
732 | | - // auto-provision one under `{data_dir}/conversations/{label}-temp-{id}/`. |
| 730 | + // auto-provision one under |
| 731 | + // `{data_dir}/conversations/YYYY/MM/DD/{label}-temp-{id}/`. |
733 | 732 | // Skill wiring runs for both kinds so native CLI discovery behaves |
734 | 733 | // consistently. |
735 | 734 | let user_supplied_workspace = match extra |
@@ -758,20 +757,17 @@ impl ConversationService { |
758 | 757 |
|
759 | 758 | let auto_provisioned_workspace = if user_supplied_workspace.is_none() { |
760 | 759 | // Per-conversation temp workspaces live under |
761 | | - // `{data_dir}/conversations/{label}-temp-{id}/`. The label lets |
762 | | - // operators eyeball the agent type; the conversation id keeps |
763 | | - // the mapping back to the DB row unique. |
| 760 | + // `{data_dir}/conversations/YYYY/MM/DD/{label}-temp-{id}/`. |
| 761 | + // The label lets operators eyeball the agent type; the |
| 762 | + // conversation id keeps the mapping back to the DB row unique. |
764 | 763 | let label = conversation_label( |
765 | 764 | &effective_type, |
766 | 765 | effective_backend |
767 | 766 | .as_ref() |
768 | 767 | .map(|backend| serde_json::Value::String(backend.clone())) |
769 | 768 | .as_ref(), |
770 | 769 | ); |
771 | | - let ws_path = self |
772 | | - .workspace_root |
773 | | - .join("conversations") |
774 | | - .join(format!("{label}-temp-{id}")); |
| 770 | + let ws_path = auto_workspace_parent(&self.workspace_root).join(format!("{label}-temp-{id}")); |
775 | 771 | std::fs::create_dir_all(&ws_path) |
776 | 772 | .map_err(|e| ConversationError::internal(format!("Failed to create workspace: {e}")))?; |
777 | 773 | extra["workspace"] = serde_json::Value::String(ws_path.to_string_lossy().into_owned()); |
@@ -1960,6 +1956,7 @@ impl ConversationService { |
1960 | 1956 | .source |
1961 | 1957 | .as_deref() |
1962 | 1958 | .and_then(|s| string_to_enum::<ConversationSource>(s).ok()); |
| 1959 | + let auto_workspace_to_delete = auto_provisioned_workspace_to_delete(&self.workspace_root, &existing, id); |
1963 | 1960 |
|
1964 | 1961 | let had_active_turn = self.runtime_state.mark_deleting(id); |
1965 | 1962 |
|
@@ -1988,6 +1985,31 @@ impl ConversationService { |
1988 | 1985 | "Failed to delete acp_session row on conversation delete" |
1989 | 1986 | ); |
1990 | 1987 | } |
| 1988 | + if let Some(workspace) = auto_workspace_to_delete { |
| 1989 | + let workspace_removed = match tokio::fs::remove_dir_all(&workspace).await { |
| 1990 | + Ok(()) => { |
| 1991 | + info!( |
| 1992 | + conversation_id = %id, |
| 1993 | + workspace = %workspace.display(), |
| 1994 | + "Deleted auto-provisioned conversation workspace" |
| 1995 | + ); |
| 1996 | + true |
| 1997 | + } |
| 1998 | + Err(err) if err.kind() == std::io::ErrorKind::NotFound => true, |
| 1999 | + Err(err) => { |
| 2000 | + warn!( |
| 2001 | + conversation_id = %id, |
| 2002 | + workspace = %workspace.display(), |
| 2003 | + error = %err, |
| 2004 | + "Failed to delete auto-provisioned conversation workspace" |
| 2005 | + ); |
| 2006 | + false |
| 2007 | + } |
| 2008 | + }; |
| 2009 | + if workspace_removed { |
| 2010 | + cleanup_empty_date_workspace_parents(&self.workspace_root, &workspace).await; |
| 2011 | + } |
| 2012 | + } |
1991 | 2013 |
|
1992 | 2014 | info!("Conversation deleted"); |
1993 | 2015 | self.broadcast_list_changed(id, "deleted", source.as_ref()); |
@@ -3299,12 +3321,133 @@ fn expected_auto_workspace_path( |
3299 | 3321 | agent_type: &AgentType, |
3300 | 3322 | backend: Option<&serde_json::Value>, |
3301 | 3323 | ) -> PathBuf { |
3302 | | - workspace_root.join("conversations").join(format!( |
| 3324 | + auto_workspace_parent(workspace_root).join(format!( |
3303 | 3325 | "{}-temp-{conversation_id}", |
3304 | 3326 | conversation_label(agent_type, backend) |
3305 | 3327 | )) |
3306 | 3328 | } |
3307 | 3329 |
|
| 3330 | +fn auto_workspace_parent(workspace_root: &Path) -> PathBuf { |
| 3331 | + let now = chrono::Local::now(); |
| 3332 | + workspace_root |
| 3333 | + .join("conversations") |
| 3334 | + .join(format!("{:04}", now.year())) |
| 3335 | + .join(format!("{:02}", now.month())) |
| 3336 | + .join(format!("{:02}", now.day())) |
| 3337 | +} |
| 3338 | + |
| 3339 | +fn auto_provisioned_workspace_to_delete( |
| 3340 | + workspace_root: &Path, |
| 3341 | + row: &ConversationRow, |
| 3342 | + conversation_id: &str, |
| 3343 | +) -> Option<PathBuf> { |
| 3344 | + let extra = serde_json::from_str::<serde_json::Value>(&row.extra).ok()?; |
| 3345 | + let workspace = extra.get("workspace")?.as_str()?.trim(); |
| 3346 | + if workspace.is_empty() { |
| 3347 | + return None; |
| 3348 | + } |
| 3349 | + |
| 3350 | + let workspace_path = Path::new(workspace); |
| 3351 | + if !workspace_path.is_absolute() { |
| 3352 | + return None; |
| 3353 | + } |
| 3354 | + |
| 3355 | + let conversations_root = workspace_root.join("conversations"); |
| 3356 | + let conversations_root = std::fs::canonicalize(conversations_root).ok()?; |
| 3357 | + let workspace_path = std::fs::canonicalize(workspace_path).ok()?; |
| 3358 | + let file_name = workspace_path.file_name()?.to_str()?; |
| 3359 | + let expected_suffix = format!("-temp-{conversation_id}"); |
| 3360 | + if !file_name.ends_with(&expected_suffix) { |
| 3361 | + return None; |
| 3362 | + } |
| 3363 | + |
| 3364 | + let relative = workspace_path.strip_prefix(&conversations_root).ok()?; |
| 3365 | + if !is_auto_workspace_relative_path(relative) { |
| 3366 | + return None; |
| 3367 | + } |
| 3368 | + |
| 3369 | + Some(workspace_path) |
| 3370 | +} |
| 3371 | + |
| 3372 | +fn is_auto_workspace_relative_path(relative: &Path) -> bool { |
| 3373 | + let parts = relative.iter().map(|part| part.to_str()).collect::<Option<Vec<_>>>(); |
| 3374 | + let Some(parts) = parts else { |
| 3375 | + return false; |
| 3376 | + }; |
| 3377 | + |
| 3378 | + match parts.as_slice() { |
| 3379 | + [_file_name] => true, |
| 3380 | + [year, month, day, _file_name] => { |
| 3381 | + year.len() == 4 |
| 3382 | + && month.len() == 2 |
| 3383 | + && day.len() == 2 |
| 3384 | + && year.chars().all(|ch| ch.is_ascii_digit()) |
| 3385 | + && month.chars().all(|ch| ch.is_ascii_digit()) |
| 3386 | + && day.chars().all(|ch| ch.is_ascii_digit()) |
| 3387 | + } |
| 3388 | + _ => false, |
| 3389 | + } |
| 3390 | +} |
| 3391 | + |
| 3392 | +async fn cleanup_empty_date_workspace_parents(workspace_root: &Path, workspace_path: &Path) { |
| 3393 | + let Some(date_dirs) = date_workspace_parent_dirs(workspace_root, workspace_path) else { |
| 3394 | + return; |
| 3395 | + }; |
| 3396 | + |
| 3397 | + for dir in date_dirs { |
| 3398 | + match tokio::fs::remove_dir(&dir).await { |
| 3399 | + Ok(()) => {} |
| 3400 | + Err(err) |
| 3401 | + if matches!( |
| 3402 | + err.kind(), |
| 3403 | + std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty |
| 3404 | + ) => |
| 3405 | + { |
| 3406 | + break; |
| 3407 | + } |
| 3408 | + Err(err) => { |
| 3409 | + warn!( |
| 3410 | + workspace_parent = %dir.display(), |
| 3411 | + error = %err, |
| 3412 | + "Failed to remove empty auto-provisioned workspace date directory" |
| 3413 | + ); |
| 3414 | + break; |
| 3415 | + } |
| 3416 | + } |
| 3417 | + } |
| 3418 | +} |
| 3419 | + |
| 3420 | +fn date_workspace_parent_dirs(workspace_root: &Path, workspace_path: &Path) -> Option<[PathBuf; 3]> { |
| 3421 | + let conversations_root = std::fs::canonicalize(workspace_root.join("conversations")).ok()?; |
| 3422 | + let relative = workspace_path.strip_prefix(&conversations_root).ok()?; |
| 3423 | + if !is_dated_auto_workspace_relative_path(relative) { |
| 3424 | + return None; |
| 3425 | + } |
| 3426 | + |
| 3427 | + let day_dir = workspace_path.parent()?.to_path_buf(); |
| 3428 | + let month_dir = day_dir.parent()?.to_path_buf(); |
| 3429 | + let year_dir = month_dir.parent()?.to_path_buf(); |
| 3430 | + Some([day_dir, month_dir, year_dir]) |
| 3431 | +} |
| 3432 | + |
| 3433 | +fn is_dated_auto_workspace_relative_path(relative: &Path) -> bool { |
| 3434 | + let parts = relative.iter().map(|part| part.to_str()).collect::<Option<Vec<_>>>(); |
| 3435 | + let Some(parts) = parts else { |
| 3436 | + return false; |
| 3437 | + }; |
| 3438 | + |
| 3439 | + matches!( |
| 3440 | + parts.as_slice(), |
| 3441 | + [year, month, day, _file_name] |
| 3442 | + if year.len() == 4 |
| 3443 | + && month.len() == 2 |
| 3444 | + && day.len() == 2 |
| 3445 | + && year.chars().all(|ch| ch.is_ascii_digit()) |
| 3446 | + && month.chars().all(|ch| ch.is_ascii_digit()) |
| 3447 | + && day.chars().all(|ch| ch.is_ascii_digit()) |
| 3448 | + ) |
| 3449 | +} |
| 3450 | + |
3308 | 3451 | fn context_backend_value(context: &AgentSessionContext) -> Option<serde_json::Value> { |
3309 | 3452 | match &context.kind { |
3310 | 3453 | AgentSessionKind::Acp(acp) => acp |
|
0 commit comments