Skip to content

Commit 9bb1f33

Browse files
authored
fix(conversation): partition temp workspaces and logs by date (#560)
## Summary - create conversation/team temp workspaces under conversations/YYYY/MM/DD - delete auto temp conversation workspaces and prune empty date parents when conversations are deleted - write backend app logs under dated log directories ## Related PR - AionUi frontend companion: iOfficeAI/AionUi#3495 ## Test Plan - cargo fmt --check - cargo test -p aionui-conversation - cargo test -p aionui-app - cargo clippy -p aionui-conversation -- -D warnings - cargo clippy -p aionui-app -- -D warnings - git diff --check Co-authored-by: zk <>
1 parent 6c15bb7 commit 9bb1f33

7 files changed

Lines changed: 359 additions & 23 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/aionui-app/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ aionui-assistant.workspace = true
3737
aionui-runtime.workspace = true
3838
aion-config.workspace = true
3939
axum.workspace = true
40+
chrono.workspace = true
4041
dirs.workspace = true
4142
tokio.workspace = true
4243
tower.workspace = true

crates/aionui-app/src/bootstrap/tracing_init.rs

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
//! subscriber registration that should never be invoked from tests or
55
//! external consumers of the library.
66
7-
use std::path::Path;
7+
use std::path::{Path, PathBuf};
88

9+
use chrono::Datelike;
910
use tracing_subscriber::{EnvFilter, Layer, fmt, layer::SubscriberExt, util::SubscriberInitExt};
1011

1112
use super::{BootstrapError, BootstrapErrorCode};
@@ -82,14 +83,16 @@ pub struct LogGuards {
8283
const LOGGING_INIT_MESSAGE: &str = "failed to initialize logging";
8384

8485
pub fn init_tracing(log_dir: &Path, log_level: Option<&str>) -> Result<LogGuards, BootstrapError> {
85-
std::fs::create_dir_all(log_dir).map_err(|e| {
86+
let active_log_dir = dated_log_dir(log_dir);
87+
88+
std::fs::create_dir_all(&active_log_dir).map_err(|e| {
8689
BootstrapError::new(
8790
BootstrapErrorCode::LoggingInitFailed,
8891
"logging.dir",
8992
LOGGING_INIT_MESSAGE,
9093
)
9194
.with_source(e)
92-
.with_field("logDir", log_dir.display().to_string())
95+
.with_field("logDir", active_log_dir.display().to_string())
9396
})?;
9497

9598
let console_layer = fmt::layer().with_target(true).with_filter(build_env_filter(log_level));
@@ -98,15 +101,15 @@ pub fn init_tracing(log_dir: &Path, log_level: Option<&str>) -> Result<LogGuards
98101
let file_appender = tracing_appender::rolling::RollingFileAppender::builder()
99102
.rotation(tracing_appender::rolling::Rotation::DAILY)
100103
.filename_suffix("aioncore.log")
101-
.build(log_dir)
104+
.build(&active_log_dir)
102105
.map_err(|e| {
103106
BootstrapError::new(
104107
BootstrapErrorCode::LoggingInitFailed,
105108
"logging.appender",
106109
LOGGING_INIT_MESSAGE,
107110
)
108111
.with_source(e)
109-
.with_field("logDir", log_dir.display().to_string())
112+
.with_field("logDir", active_log_dir.display().to_string())
110113
})?;
111114
let (non_blocking, backend_guard) = tracing_appender::non_blocking(file_appender);
112115

@@ -122,7 +125,7 @@ pub fn init_tracing(log_dir: &Path, log_level: Option<&str>) -> Result<LogGuards
122125
let aionrs_resolved = aion_config::logging::ResolvedLogging {
123126
enabled: true,
124127
level: aionrs_level,
125-
dir: log_dir.to_path_buf(),
128+
dir: active_log_dir.clone(),
126129
};
127130
let (aionrs_layer, aionrs_guard) = aion_config::logging::create_file_layer(&aionrs_resolved).map_err(|e| {
128131
BootstrapError::new(
@@ -131,7 +134,7 @@ pub fn init_tracing(log_dir: &Path, log_level: Option<&str>) -> Result<LogGuards
131134
LOGGING_INIT_MESSAGE,
132135
)
133136
.with_source(e)
134-
.with_field("logDir", log_dir.display().to_string())
137+
.with_field("logDir", active_log_dir.display().to_string())
135138
})?;
136139

137140
tracing_subscriber::registry()
@@ -146,7 +149,7 @@ pub fn init_tracing(log_dir: &Path, log_level: Option<&str>) -> Result<LogGuards
146149
LOGGING_INIT_MESSAGE,
147150
)
148151
.with_source(e)
149-
.with_field("logDir", log_dir.display().to_string())
152+
.with_field("logDir", active_log_dir.display().to_string())
150153
})?;
151154

152155
Ok(LogGuards {
@@ -155,6 +158,14 @@ pub fn init_tracing(log_dir: &Path, log_level: Option<&str>) -> Result<LogGuards
155158
})
156159
}
157160

161+
fn dated_log_dir(log_root: &Path) -> PathBuf {
162+
let now = chrono::Local::now();
163+
log_root
164+
.join(format!("{:04}", now.year()))
165+
.join(format!("{:02}", now.month()))
166+
.join(format!("{:02}", now.day()))
167+
}
168+
158169
#[cfg(test)]
159170
mod tests {
160171
use super::*;
@@ -216,4 +227,23 @@ mod tests {
216227
assert!(level.contains("aion_providers=info"), "{level}");
217228
assert!(level.contains("aion_tools=debug"), "{level}");
218229
}
230+
231+
#[test]
232+
fn dated_log_dir_appends_date_partition() {
233+
let root = Path::new("/tmp/aionui-logs");
234+
let dated = dated_log_dir(root);
235+
let relative = dated.strip_prefix(root).expect("dated log dir should stay under root");
236+
let parts = relative
237+
.iter()
238+
.map(|part| part.to_str().expect("log dir should be utf-8"))
239+
.collect::<Vec<_>>();
240+
241+
assert_eq!(parts.len(), 3);
242+
assert_eq!(parts[0].len(), 4);
243+
assert_eq!(parts[1].len(), 2);
244+
assert_eq!(parts[2].len(), 2);
245+
assert!(parts[0].chars().all(|ch| ch.is_ascii_digit()));
246+
assert!(parts[1].chars().all(|ch| ch.is_ascii_digit()));
247+
assert!(parts[2].chars().all(|ch| ch.is_ascii_digit()));
248+
}
219249
}

crates/aionui-conversation/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ aionui-mcp.workspace = true
1515
aionui-runtime.workspace = true
1616
axum.workspace = true
1717
base64.workspace = true
18+
chrono.workspace = true
1819
regex.workspace = true
1920
tokio.workspace = true
2021
serde.workspace = true

crates/aionui-conversation/src/service.rs

Lines changed: 157 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use std::future::Future;
2-
use std::path::PathBuf;
2+
use std::path::{Path, PathBuf};
33
use std::pin::Pin;
44
use std::sync::Arc;
55

@@ -37,6 +37,7 @@ use aionui_extension::AssistantRuleDispatcher;
3737
use aionui_mcp::{AcpMcpCapabilities, parse_acp_mcp_capabilities};
3838
use aionui_realtime::EventBroadcaster;
3939
use aionui_runtime::{RuntimeCommandProbe, probe_node_runtime_supported, probe_runtime_command, resolve_command_path};
40+
use chrono::Datelike;
4041
use std::collections::{HashMap, HashSet};
4142
use std::time::Duration;
4243
use tracing::{debug, error, info, warn};
@@ -393,10 +394,7 @@ impl ConversationService {
393394
}
394395

395396
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}"));
400398
std::fs::create_dir_all(&ws_path)
401399
.map_err(|e| ConversationError::internal(format!("Failed to create Team temporary workspace: {e}")))?;
402400
Ok(ws_path.to_string_lossy().into_owned())
@@ -729,7 +727,8 @@ impl ConversationService {
729727
}
730728

731729
// 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}/`.
733732
// Skill wiring runs for both kinds so native CLI discovery behaves
734733
// consistently.
735734
let user_supplied_workspace = match extra
@@ -758,20 +757,17 @@ impl ConversationService {
758757

759758
let auto_provisioned_workspace = if user_supplied_workspace.is_none() {
760759
// 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.
764763
let label = conversation_label(
765764
&effective_type,
766765
effective_backend
767766
.as_ref()
768767
.map(|backend| serde_json::Value::String(backend.clone()))
769768
.as_ref(),
770769
);
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}"));
775771
std::fs::create_dir_all(&ws_path)
776772
.map_err(|e| ConversationError::internal(format!("Failed to create workspace: {e}")))?;
777773
extra["workspace"] = serde_json::Value::String(ws_path.to_string_lossy().into_owned());
@@ -1960,6 +1956,7 @@ impl ConversationService {
19601956
.source
19611957
.as_deref()
19621958
.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);
19631960

19641961
let had_active_turn = self.runtime_state.mark_deleting(id);
19651962

@@ -1988,6 +1985,31 @@ impl ConversationService {
19881985
"Failed to delete acp_session row on conversation delete"
19891986
);
19901987
}
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+
}
19912013

19922014
info!("Conversation deleted");
19932015
self.broadcast_list_changed(id, "deleted", source.as_ref());
@@ -3299,12 +3321,133 @@ fn expected_auto_workspace_path(
32993321
agent_type: &AgentType,
33003322
backend: Option<&serde_json::Value>,
33013323
) -> PathBuf {
3302-
workspace_root.join("conversations").join(format!(
3324+
auto_workspace_parent(workspace_root).join(format!(
33033325
"{}-temp-{conversation_id}",
33043326
conversation_label(agent_type, backend)
33053327
))
33063328
}
33073329

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+
33083451
fn context_backend_value(context: &AgentSessionContext) -> Option<serde_json::Value> {
33093452
match &context.kind {
33103453
AgentSessionKind::Acp(acp) => acp

0 commit comments

Comments
 (0)