mirror of
https://github.com/openai/codex.git
synced 2026-11-13 06:45:07 +00:00
Move external agent migration into its crate (#33456)
## What changed - Move migration detection, import orchestration, models, and reporting from `codex-app-server` into `codex-external-agent-migration`. - Keep app-server protocol conversion in a dedicated boundary module. - Organize source-specific configuration, sessions, hooks, plugins, commands, subagents, and memory handling behind shared migration adapters. - Relocate and split the migration tests alongside the extracted service. GitOrigin-RevId: 3378ec2064806a28136d6ac98664ca1d75414a4e
This commit is contained in:
1 parent
2edad72de3
commit
e7efc5b04b
57 files changed
+7713
-7471
No files matched your search
@@ -0,0 +1,33 @@
|
||||
use crate::discover_external_memory_files;
|
||||
use crate::memory_import::projects_needing_import;
|
||||
use crate::memory_import::resources_root;
|
||||
use crate::model::ExternalAgentConfigMigrationItem;
|
||||
use crate::model::ExternalAgentConfigMigrationItemType;
|
||||
use crate::model::MigrationDetails;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
pub(super) fn detect(
|
||||
codex_home: &Path,
|
||||
external_agent_home: &Path,
|
||||
) -> io::Result<Option<ExternalAgentConfigMigrationItem>> {
|
||||
let memory_files = discover_external_memory_files(external_agent_home)?;
|
||||
let memory = projects_needing_import(codex_home, &memory_files)?;
|
||||
if memory.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Memory,
|
||||
description: format!(
|
||||
"Import memory files from {} to {}",
|
||||
external_agent_home.join("projects").display(),
|
||||
resources_root(codex_home).display()
|
||||
),
|
||||
cwd: None,
|
||||
details: Some(MigrationDetails {
|
||||
memory: memory.into_iter().collect(),
|
||||
..Default::default()
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
mod memory;
|
||||
pub(crate) mod plugins;
|
||||
pub(crate) mod sessions;
|
||||
|
||||
use crate::config_values::is_empty_toml_table;
|
||||
use crate::config_values::merge_missing_mcp_servers;
|
||||
use crate::config_values::merge_missing_toml_values;
|
||||
use crate::config_values::migrated_mcp_server_names;
|
||||
use crate::count_missing_subagents;
|
||||
use crate::migration_source::InstructionSourceGroup;
|
||||
use crate::migration_source::PluginDetectionContext;
|
||||
use crate::missing_subagent_names;
|
||||
use crate::model::ExternalAgentConfigDetectOptions;
|
||||
use crate::model::ExternalAgentConfigMigrationItem;
|
||||
use crate::model::ExternalAgentConfigMigrationItemType;
|
||||
use crate::model::MigrationDetails;
|
||||
use crate::reporting::emit_migration_metric;
|
||||
use crate::scope::MigrationScope;
|
||||
use crate::service::ExternalAgentConfigService;
|
||||
use crate::service::configured_marketplace_plugins;
|
||||
use crate::service::missing_subdirectory_names;
|
||||
use crate::service::named_migrations;
|
||||
use crate::utils::display_source_paths;
|
||||
use crate::utils::invalid_data_error;
|
||||
use crate::utils::is_missing_or_empty_text_file;
|
||||
use codex_config::types::PluginConfig;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core_plugins::PluginsManager;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
const EXTERNAL_AGENT_CONFIG_DETECT_METRIC: &str = "codex.external_agent_config.detect";
|
||||
|
||||
impl ExternalAgentConfigService {
|
||||
pub async fn detect(
|
||||
&self,
|
||||
params: ExternalAgentConfigDetectOptions,
|
||||
) -> io::Result<Vec<ExternalAgentConfigMigrationItem>> {
|
||||
let mut items = Vec::new();
|
||||
if params.include_home {
|
||||
self.detect_migrations(&MigrationScope::home(), &mut items)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for cwd in params.cwds.as_deref().unwrap_or(&[]) {
|
||||
let Some(scope) = MigrationScope::from_cwd(Some(cwd))? else {
|
||||
continue;
|
||||
};
|
||||
if scope.is_home() {
|
||||
continue;
|
||||
}
|
||||
self.detect_migrations(&scope, &mut items).await?;
|
||||
}
|
||||
|
||||
if params.include_home
|
||||
&& params.include_memory
|
||||
&& self.source.supports_memory()
|
||||
&& let Some(item) = memory::detect(&self.codex_home, &self.external_agent_home)?
|
||||
{
|
||||
items.push(item);
|
||||
emit_migration_metric(
|
||||
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
|
||||
ExternalAgentConfigMigrationItemType::Memory,
|
||||
/*skills_count*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
async fn detect_migrations(
|
||||
&self,
|
||||
scope: &MigrationScope,
|
||||
items: &mut Vec<ExternalAgentConfigMigrationItem>,
|
||||
) -> io::Result<()> {
|
||||
let repo_root = scope.repo_root();
|
||||
let cwd = scope.cwd();
|
||||
let source_settings = self.source_settings(scope);
|
||||
let settings = self.effective_source_settings(scope)?;
|
||||
let target_config = repo_root.map_or_else(
|
||||
|| self.codex_home.join("config.toml"),
|
||||
|repo_root| repo_root.join(".codex").join("config.toml"),
|
||||
);
|
||||
if let Some(settings) = settings.as_ref() {
|
||||
let migrated = self.source.build_config(settings)?;
|
||||
if !is_empty_toml_table(&migrated) {
|
||||
let mut should_include = true;
|
||||
if target_config.exists() {
|
||||
let existing_raw = fs::read_to_string(&target_config)?;
|
||||
let mut existing = if existing_raw.trim().is_empty() {
|
||||
TomlValue::Table(Default::default())
|
||||
} else {
|
||||
toml::from_str::<TomlValue>(&existing_raw).map_err(|err| {
|
||||
invalid_data_error(format!("invalid existing config.toml: {err}"))
|
||||
})?
|
||||
};
|
||||
should_include = merge_missing_toml_values(&mut existing, &migrated)?;
|
||||
}
|
||||
|
||||
if should_include {
|
||||
items.push(ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Config,
|
||||
description: format!(
|
||||
"Migrate {} into {}",
|
||||
source_settings.display(),
|
||||
target_config.display()
|
||||
),
|
||||
cwd: cwd.clone(),
|
||||
details: None,
|
||||
});
|
||||
emit_migration_metric(
|
||||
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
|
||||
ExternalAgentConfigMigrationItemType::Config,
|
||||
/*skills_count*/ None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mcp_source_path = self
|
||||
.source
|
||||
.mcp_source_path(self.source_root(scope), self.source_config_dir(scope));
|
||||
let migrated_mcp = self.build_mcp_config(scope, settings.clone())?;
|
||||
let mut mcp_server_names = migrated_mcp_server_names(&migrated_mcp);
|
||||
if !is_empty_toml_table(&migrated_mcp) {
|
||||
if target_config.exists() {
|
||||
let existing_raw = fs::read_to_string(&target_config)?;
|
||||
let mut existing = if existing_raw.trim().is_empty() {
|
||||
TomlValue::Table(Default::default())
|
||||
} else {
|
||||
toml::from_str::<TomlValue>(&existing_raw).map_err(|err| {
|
||||
invalid_data_error(format!("invalid existing config.toml: {err}"))
|
||||
})?
|
||||
};
|
||||
mcp_server_names = merge_missing_mcp_servers(&mut existing, &migrated_mcp)?;
|
||||
}
|
||||
|
||||
if !mcp_server_names.is_empty() {
|
||||
items.push(ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
|
||||
description: format!(
|
||||
"Migrate MCP servers from {} into {}",
|
||||
mcp_source_path.display(),
|
||||
target_config.display()
|
||||
),
|
||||
cwd: cwd.clone(),
|
||||
details: Some(MigrationDetails {
|
||||
mcp_servers: named_migrations(mcp_server_names),
|
||||
..Default::default()
|
||||
}),
|
||||
});
|
||||
emit_migration_metric(
|
||||
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
|
||||
ExternalAgentConfigMigrationItemType::McpServerConfig,
|
||||
/*skills_count*/ None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let source_external_agent_dir = self.source_config_dir(scope);
|
||||
let target_hooks = repo_root.map_or_else(
|
||||
|| self.codex_home.join("hooks.json"),
|
||||
|repo_root| repo_root.join(".codex").join("hooks.json"),
|
||||
);
|
||||
let hook_event_names = self
|
||||
.source
|
||||
.hook_event_names(source_external_agent_dir.as_path(), &target_hooks)?;
|
||||
if !hook_event_names.is_empty() && is_missing_or_empty_text_file(&target_hooks)? {
|
||||
items.push(ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Hooks,
|
||||
description: format!(
|
||||
"Migrate hooks from {} to {}",
|
||||
source_external_agent_dir.display(),
|
||||
target_hooks.display()
|
||||
),
|
||||
cwd: cwd.clone(),
|
||||
details: Some(MigrationDetails {
|
||||
hooks: named_migrations(hook_event_names),
|
||||
..Default::default()
|
||||
}),
|
||||
});
|
||||
emit_migration_metric(
|
||||
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
|
||||
ExternalAgentConfigMigrationItemType::Hooks,
|
||||
/*skills_count*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
let source_skills = repo_root.map_or_else(
|
||||
|| self.external_agent_home.join("skills"),
|
||||
|repo_root| repo_root.join(self.source.config_dir()).join("skills"),
|
||||
);
|
||||
let target_skills = repo_root.map_or_else(
|
||||
|| self.home_target_skills_dir(),
|
||||
|repo_root| repo_root.join(".agents").join("skills"),
|
||||
);
|
||||
let skill_names = missing_subdirectory_names(&source_skills, &target_skills)?;
|
||||
let skills_count = skill_names.len();
|
||||
if skills_count > 0 {
|
||||
items.push(ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Skills,
|
||||
description: format!(
|
||||
"Migrate skills from {} to {}",
|
||||
source_skills.display(),
|
||||
target_skills.display()
|
||||
),
|
||||
cwd: cwd.clone(),
|
||||
details: Some(MigrationDetails {
|
||||
skills: named_migrations(skill_names),
|
||||
..Default::default()
|
||||
}),
|
||||
});
|
||||
emit_migration_metric(
|
||||
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
|
||||
ExternalAgentConfigMigrationItemType::Skills,
|
||||
Some(skills_count),
|
||||
);
|
||||
}
|
||||
|
||||
let source_commands = source_external_agent_dir.join("commands");
|
||||
let target_command_skills = repo_root.map_or_else(
|
||||
|| self.home_target_skills_dir(),
|
||||
|repo_root| repo_root.join(".agents").join("skills"),
|
||||
);
|
||||
let commands_count = self
|
||||
.source
|
||||
.count_missing_commands(&source_commands, &target_command_skills)?;
|
||||
if commands_count > 0 {
|
||||
let command_names = self
|
||||
.source
|
||||
.missing_command_names(&source_commands, &target_command_skills)?;
|
||||
items.push(ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Commands,
|
||||
description: format!(
|
||||
"Migrate commands from {} to {}",
|
||||
source_commands.display(),
|
||||
target_command_skills.display()
|
||||
),
|
||||
cwd: cwd.clone(),
|
||||
details: Some(MigrationDetails {
|
||||
commands: named_migrations(command_names),
|
||||
..Default::default()
|
||||
}),
|
||||
});
|
||||
emit_migration_metric(
|
||||
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
|
||||
ExternalAgentConfigMigrationItemType::Commands,
|
||||
Some(commands_count),
|
||||
);
|
||||
}
|
||||
|
||||
let source_subagents = source_external_agent_dir.join("agents");
|
||||
let target_subagents = repo_root.map_or_else(
|
||||
|| self.codex_home.join("agents"),
|
||||
|repo_root| repo_root.join(".codex").join("agents"),
|
||||
);
|
||||
let subagents_count = count_missing_subagents(&source_subagents, &target_subagents)?;
|
||||
if subagents_count > 0 {
|
||||
let subagent_names = missing_subagent_names(&source_subagents, &target_subagents)?;
|
||||
items.push(ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Subagents,
|
||||
description: format!(
|
||||
"Migrate subagents from {} to {}",
|
||||
source_subagents.display(),
|
||||
target_subagents.display()
|
||||
),
|
||||
cwd: cwd.clone(),
|
||||
details: Some(MigrationDetails {
|
||||
subagents: named_migrations(subagent_names),
|
||||
..Default::default()
|
||||
}),
|
||||
});
|
||||
emit_migration_metric(
|
||||
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
|
||||
ExternalAgentConfigMigrationItemType::Subagents,
|
||||
Some(subagents_count),
|
||||
);
|
||||
}
|
||||
|
||||
let instruction_source_groups = if let Some(repo_root) = repo_root {
|
||||
self.repo_agents_md_source_groups(repo_root)?
|
||||
} else {
|
||||
let sources = self.home_agents_md_sources()?;
|
||||
(!sources.is_empty())
|
||||
.then(|| InstructionSourceGroup {
|
||||
scope: self.codex_home.clone(),
|
||||
sources,
|
||||
})
|
||||
.into_iter()
|
||||
.collect()
|
||||
};
|
||||
for group in instruction_source_groups {
|
||||
let target_agents_md = group.scope.join("AGENTS.md");
|
||||
if !is_missing_or_empty_text_file(&target_agents_md)? {
|
||||
continue;
|
||||
}
|
||||
let item_cwd = repo_root.is_some().then(|| group.scope.clone());
|
||||
items.push(ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
description: format!(
|
||||
"Migrate {} to {}",
|
||||
display_source_paths(&group.sources),
|
||||
target_agents_md.display()
|
||||
),
|
||||
cwd: item_cwd,
|
||||
details: None,
|
||||
});
|
||||
emit_migration_metric(
|
||||
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
|
||||
ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
/*skills_count*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
if self.source.supports_plugin_migration(settings.as_ref()) {
|
||||
match ConfigBuilder::default()
|
||||
.codex_home(self.codex_home.clone())
|
||||
.fallback_cwd(Some(self.codex_home.clone()))
|
||||
.build()
|
||||
.await
|
||||
{
|
||||
Ok(config) => {
|
||||
let configured_plugin_ids = config
|
||||
.config_layer_stack
|
||||
.get_active_user_layer()
|
||||
.and_then(|user_layer| user_layer.config.get("plugins"))
|
||||
.and_then(|plugins| {
|
||||
match plugins.clone().try_into::<HashMap<String, PluginConfig>>() {
|
||||
Ok(plugins) => Some(plugins),
|
||||
Err(err) => {
|
||||
tracing::warn!("invalid plugins config: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.map(|plugins| plugins.into_keys().collect::<HashSet<_>>())
|
||||
.unwrap_or_default();
|
||||
let configured_marketplace_plugins = configured_marketplace_plugins(
|
||||
&config,
|
||||
&PluginsManager::new(self.codex_home.clone()),
|
||||
)?;
|
||||
let source_root = repo_root.unwrap_or(self.external_agent_home.as_path());
|
||||
if let Some(detected) =
|
||||
self.source.plugin_migration(PluginDetectionContext {
|
||||
external_agent_home: self.external_agent_home.as_path(),
|
||||
source_settings: source_settings.as_path(),
|
||||
source_root,
|
||||
repo_root,
|
||||
settings: settings.as_ref(),
|
||||
configured_plugin_ids: &configured_plugin_ids,
|
||||
configured_marketplace_plugins: &configured_marketplace_plugins,
|
||||
})?
|
||||
{
|
||||
emit_migration_metric(
|
||||
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
|
||||
ExternalAgentConfigMigrationItemType::Plugins,
|
||||
/*skills_count*/ None,
|
||||
);
|
||||
items.push(ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||
description: detected.description,
|
||||
cwd: cwd.clone(),
|
||||
details: Some(detected.details),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
settings_path = %source_settings.display(),
|
||||
"skipping external agent plugin migration detection because config load failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if scope.is_home() {
|
||||
let sessions = self
|
||||
.source
|
||||
.recent_sessions(&self.external_agent_home, &self.codex_home)?;
|
||||
if !sessions.is_empty() {
|
||||
items.push(ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Sessions,
|
||||
description: format!(
|
||||
"Migrate recent sessions from {}",
|
||||
self.external_agent_home.join("projects").display()
|
||||
),
|
||||
cwd: None,
|
||||
details: Some(MigrationDetails {
|
||||
sessions,
|
||||
..Default::default()
|
||||
}),
|
||||
});
|
||||
emit_migration_metric(
|
||||
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
|
||||
ExternalAgentConfigMigrationItemType::Sessions,
|
||||
/*skills_count*/ None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use crate::migration_source::DetectedSourcePlugins;
|
||||
use crate::migration_source::PluginDetectionContext;
|
||||
use crate::model::MigrationDetails;
|
||||
use crate::model::PluginsMigration;
|
||||
use crate::source_cla;
|
||||
use crate::source_cur;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::io;
|
||||
|
||||
pub(crate) fn detect_cla_plugins(
|
||||
context: &PluginDetectionContext<'_>,
|
||||
) -> Option<DetectedSourcePlugins> {
|
||||
let settings = context.settings?;
|
||||
let import_sources = source_cla::marketplace_import_sources(
|
||||
settings,
|
||||
context.external_agent_home,
|
||||
context.source_root,
|
||||
);
|
||||
let details = source_cla::extract_plugin_migration_details(
|
||||
settings,
|
||||
&import_sources,
|
||||
context.configured_plugin_ids,
|
||||
context.configured_marketplace_plugins,
|
||||
)?;
|
||||
Some(DetectedSourcePlugins {
|
||||
description: format!(
|
||||
"Migrate enabled plugins from {}",
|
||||
context.source_settings.display()
|
||||
),
|
||||
details,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn can_detect_cla_plugins(settings: Option<&JsonValue>) -> bool {
|
||||
settings.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn detect_cur_plugins(
|
||||
context: &PluginDetectionContext<'_>,
|
||||
) -> io::Result<Option<DetectedSourcePlugins>> {
|
||||
let mut plugins = Vec::new();
|
||||
for marketplace in source_cur::cached_marketplace_plugins(context.external_agent_home)? {
|
||||
let configured_marketplace = context
|
||||
.configured_marketplace_plugins
|
||||
.get(&marketplace.name);
|
||||
let plugin_names = marketplace
|
||||
.plugin_names
|
||||
.into_iter()
|
||||
.filter(|plugin_name| {
|
||||
!context
|
||||
.configured_plugin_ids
|
||||
.contains(&format!("{plugin_name}@{}", marketplace.name))
|
||||
&& configured_marketplace.is_none_or(|plugins| plugins.contains(plugin_name))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !plugin_names.is_empty() {
|
||||
plugins.push(PluginsMigration {
|
||||
marketplace_name: marketplace.name,
|
||||
plugin_names,
|
||||
});
|
||||
}
|
||||
}
|
||||
if plugins.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(DetectedSourcePlugins {
|
||||
description: format!(
|
||||
"Migrate cached plugins from {}",
|
||||
context.external_agent_home.join("plugins/cache").display()
|
||||
),
|
||||
details: MigrationDetails {
|
||||
plugins,
|
||||
..Default::default()
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
#[cfg(test)]
|
||||
use super::common::SESSION_IMPORT_MAX_COUNT;
|
||||
use super::common::SessionFileCandidate;
|
||||
use super::common::detect_recent_sessions;
|
||||
use crate::sessions::ExternalAgentSessionMigration;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn detect_recent_cla_sessions(
|
||||
external_agent_home: &Path,
|
||||
codex_home: &Path,
|
||||
) -> io::Result<Vec<ExternalAgentSessionMigration>> {
|
||||
let projects_root = external_agent_home.join("projects");
|
||||
if !projects_root.is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
for project_entry in fs::read_dir(projects_root)? {
|
||||
let Ok(project_entry) = project_entry else {
|
||||
continue;
|
||||
};
|
||||
let project_path = project_entry.path();
|
||||
if !project_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(project_path) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries {
|
||||
let Ok(entry) = entry else {
|
||||
continue;
|
||||
};
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
candidates.push(SessionFileCandidate {
|
||||
path,
|
||||
fallback_cwd: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
detect_recent_sessions(codex_home, candidates, /*require_existing_cwd*/ true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sessions::ledger::record_imported_session;
|
||||
use codex_protocol::ThreadId;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs::FileTimes;
|
||||
use std::fs::OpenOptions;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use std::time::SystemTime;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn detects_recent_sessions_with_existing_roots() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let external_agent_home = root.path().join(".external");
|
||||
let project_root = root.path().join("repo");
|
||||
let session_path = write_session(
|
||||
&external_agent_home,
|
||||
&project_root,
|
||||
"session.jsonl",
|
||||
&[
|
||||
record("user", "hello there", project_root.as_path()),
|
||||
record("assistant", "ack", project_root.as_path()),
|
||||
],
|
||||
);
|
||||
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(
|
||||
sessions,
|
||||
vec![ExternalAgentSessionMigration {
|
||||
path: session_path,
|
||||
cwd: project_root,
|
||||
title: Some("hello there".to_string()),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_latest_custom_title_over_first_user_message() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let external_agent_home = root.path().join(".external");
|
||||
let project_root = root.path().join("repo");
|
||||
let session_path = write_session(
|
||||
&external_agent_home,
|
||||
&project_root,
|
||||
"session.jsonl",
|
||||
&[
|
||||
record("user", "hello there", project_root.as_path()),
|
||||
custom_title_record("first title"),
|
||||
custom_title_record("final title"),
|
||||
],
|
||||
);
|
||||
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(
|
||||
sessions,
|
||||
vec![ExternalAgentSessionMigration {
|
||||
path: session_path,
|
||||
cwd: project_root,
|
||||
title: Some("final title".to_string()),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_ai_title_over_first_user_message() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let external_agent_home = root.path().join(".external");
|
||||
let project_root = root.path().join("repo");
|
||||
let session_path = write_session(
|
||||
&external_agent_home,
|
||||
&project_root,
|
||||
"session.jsonl",
|
||||
&[
|
||||
record("user", "hello there", project_root.as_path()),
|
||||
ai_title_record("generated by source app"),
|
||||
],
|
||||
);
|
||||
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(
|
||||
sessions,
|
||||
vec![ExternalAgentSessionMigration {
|
||||
path: session_path,
|
||||
cwd: project_root,
|
||||
title: Some("generated by source app".to_string()),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_custom_title_over_later_ai_title() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let external_agent_home = root.path().join(".external");
|
||||
let project_root = root.path().join("repo");
|
||||
let session_path = write_session(
|
||||
&external_agent_home,
|
||||
&project_root,
|
||||
"session.jsonl",
|
||||
&[
|
||||
record("user", "hello there", project_root.as_path()),
|
||||
custom_title_record("custom title"),
|
||||
ai_title_record("generated title"),
|
||||
],
|
||||
);
|
||||
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(
|
||||
sessions,
|
||||
vec![ExternalAgentSessionMigration {
|
||||
path: session_path,
|
||||
cwd: project_root,
|
||||
title: Some("custom title".to_string()),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_file_modification_time_for_recency() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let external_agent_home = root.path().join(".external");
|
||||
let project_root = root.path().join("repo");
|
||||
let session_path = write_session(
|
||||
&external_agent_home,
|
||||
&project_root,
|
||||
"session.jsonl",
|
||||
&[record_at(
|
||||
"user",
|
||||
"hello",
|
||||
&project_root,
|
||||
"2020-01-01T00:00:00Z",
|
||||
)],
|
||||
);
|
||||
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(
|
||||
sessions,
|
||||
vec![ExternalAgentSessionMigration {
|
||||
path: session_path,
|
||||
cwd: project_root,
|
||||
title: Some("hello".to_string()),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_sessions_with_old_file_modification_time() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let external_agent_home = root.path().join(".external");
|
||||
let project_root = root.path().join("repo");
|
||||
let session_path = write_session(
|
||||
&external_agent_home,
|
||||
&project_root,
|
||||
"session.jsonl",
|
||||
&[record("user", "hello", &project_root)],
|
||||
);
|
||||
set_modified_at(
|
||||
&session_path,
|
||||
SystemTime::UNIX_EPOCH + Duration::from_secs(/*secs*/ 1),
|
||||
);
|
||||
|
||||
assert!(
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path())
|
||||
.expect("detect")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_sessions_in_batches() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let external_agent_home = root.path().join(".external");
|
||||
let project_root = root.path().join("repo");
|
||||
let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
let modified_at = SystemTime::now();
|
||||
let mut expected = Vec::new();
|
||||
for index in 0..=SESSION_IMPORT_MAX_COUNT {
|
||||
let file_name = format!("{index:02}-session.jsonl");
|
||||
let title = format!("session {index}");
|
||||
let path = write_session(
|
||||
&external_agent_home,
|
||||
&project_root,
|
||||
&file_name,
|
||||
&[record_at("user", &title, &project_root, ×tamp)],
|
||||
);
|
||||
set_modified_at(
|
||||
&path,
|
||||
modified_at - Duration::from_secs(/*secs*/ index as u64),
|
||||
);
|
||||
expected.push(ExternalAgentSessionMigration {
|
||||
path,
|
||||
cwd: project_root.clone(),
|
||||
title: Some(title),
|
||||
});
|
||||
}
|
||||
let oldest_session = expected.pop().expect("oldest session");
|
||||
let mut all_sessions = expected.clone();
|
||||
all_sessions.push(oldest_session.clone());
|
||||
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(sessions, expected);
|
||||
for session in sessions {
|
||||
record_imported_session(root.path(), &session.path, ThreadId::new())
|
||||
.expect("record import");
|
||||
}
|
||||
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(sessions, vec![oldest_session.clone()]);
|
||||
for session in sessions {
|
||||
record_imported_session(root.path(), &session.path, ThreadId::new())
|
||||
.expect("record import");
|
||||
}
|
||||
|
||||
let changed_at = SystemTime::now()
|
||||
+ Duration::from_secs(/*secs*/ SESSION_IMPORT_MAX_COUNT as u64 + 1);
|
||||
for (index, session) in all_sessions.iter().enumerate() {
|
||||
let title = session.title.as_deref().expect("session title");
|
||||
std::fs::write(
|
||||
&session.path,
|
||||
jsonl(&[
|
||||
record("user", title, &project_root),
|
||||
record("assistant", "updated", &project_root),
|
||||
]),
|
||||
)
|
||||
.expect("update session");
|
||||
set_modified_at(
|
||||
&session.path,
|
||||
changed_at - Duration::from_secs(/*secs*/ index as u64),
|
||||
);
|
||||
}
|
||||
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(sessions, expected);
|
||||
for session in sessions {
|
||||
record_imported_session(root.path(), &session.path, ThreadId::new())
|
||||
.expect("record import");
|
||||
}
|
||||
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
|
||||
assert_eq!(sessions, vec![oldest_session]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_already_imported_current_session_versions() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let external_agent_home = root.path().join(".external");
|
||||
let project_root = root.path().join("repo");
|
||||
let session_path = write_session(
|
||||
&external_agent_home,
|
||||
&project_root,
|
||||
"session.jsonl",
|
||||
&[record("user", "hello there", project_root.as_path())],
|
||||
);
|
||||
|
||||
record_imported_session(root.path(), &session_path, ThreadId::new())
|
||||
.expect("record import");
|
||||
|
||||
assert!(
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path())
|
||||
.expect("detect")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redetects_sessions_when_source_contents_change_after_import() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let external_agent_home = root.path().join(".external");
|
||||
let project_root = root.path().join("repo");
|
||||
let session_path = write_session(
|
||||
&external_agent_home,
|
||||
&project_root,
|
||||
"session.jsonl",
|
||||
&[record("user", "hello there", project_root.as_path())],
|
||||
);
|
||||
record_imported_session(root.path(), &session_path, ThreadId::new())
|
||||
.expect("record import");
|
||||
|
||||
std::fs::write(
|
||||
&session_path,
|
||||
jsonl(&[
|
||||
record("user", "hello there", project_root.as_path()),
|
||||
record("assistant", "new reply", project_root.as_path()),
|
||||
]),
|
||||
)
|
||||
.expect("update session");
|
||||
set_modified_at(
|
||||
&session_path,
|
||||
SystemTime::now() + Duration::from_secs(/*secs*/ 1),
|
||||
);
|
||||
|
||||
let sessions =
|
||||
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
|
||||
assert_eq!(
|
||||
sessions,
|
||||
vec![ExternalAgentSessionMigration {
|
||||
path: session_path,
|
||||
cwd: project_root,
|
||||
title: Some("hello there".to_string()),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
fn write_session(
|
||||
external_agent_home: &Path,
|
||||
project_root: &Path,
|
||||
file_name: &str,
|
||||
records: &[JsonValue],
|
||||
) -> std::path::PathBuf {
|
||||
let projects_dir = external_agent_home.join("projects").join("repo");
|
||||
std::fs::create_dir_all(project_root).expect("project root");
|
||||
std::fs::create_dir_all(&projects_dir).expect("projects dir");
|
||||
let session_path = projects_dir.join(file_name);
|
||||
std::fs::write(&session_path, jsonl(records)).expect("session");
|
||||
session_path
|
||||
}
|
||||
|
||||
fn set_modified_at(path: &Path, modified_at: SystemTime) {
|
||||
OpenOptions::new()
|
||||
.write(true)
|
||||
.open(path)
|
||||
.expect("open session")
|
||||
.set_times(FileTimes::new().set_modified(modified_at))
|
||||
.expect("set session modified time");
|
||||
}
|
||||
|
||||
fn record(role: &str, text: &str, cwd: &Path) -> JsonValue {
|
||||
let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
record_at(role, text, cwd, ×tamp)
|
||||
}
|
||||
|
||||
fn record_at(role: &str, text: &str, cwd: &Path, timestamp: &str) -> JsonValue {
|
||||
serde_json::json!({
|
||||
"type": role,
|
||||
"cwd": cwd,
|
||||
"timestamp": timestamp,
|
||||
"message": { "content": text }
|
||||
})
|
||||
}
|
||||
|
||||
fn custom_title_record(title: &str) -> JsonValue {
|
||||
serde_json::json!({
|
||||
"type": "custom-title",
|
||||
"customTitle": title,
|
||||
})
|
||||
}
|
||||
|
||||
fn ai_title_record(title: &str) -> JsonValue {
|
||||
serde_json::json!({
|
||||
"type": "ai-title",
|
||||
"aiTitle": title,
|
||||
})
|
||||
}
|
||||
|
||||
fn jsonl(records: &[JsonValue]) -> String {
|
||||
records
|
||||
.iter()
|
||||
.map(JsonValue::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use crate::sessions::ExternalAgentSessionMigration;
|
||||
use crate::sessions::ledger::load_import_ledger;
|
||||
use crate::sessions::ledger::save_import_ledger;
|
||||
use crate::sessions::now_unix_seconds;
|
||||
use crate::sessions::records::summarize_session_with_cwd;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::BinaryHeap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(super) const SESSION_IMPORT_MAX_COUNT: usize = 50;
|
||||
const SESSION_IMPORT_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
|
||||
|
||||
pub(super) struct SessionFileCandidate {
|
||||
pub path: PathBuf,
|
||||
pub fallback_cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub(super) fn detect_recent_sessions(
|
||||
codex_home: &Path,
|
||||
candidates: impl IntoIterator<Item = SessionFileCandidate>,
|
||||
require_existing_cwd: bool,
|
||||
) -> io::Result<Vec<ExternalAgentSessionMigration>> {
|
||||
let now = now_unix_seconds();
|
||||
let mut ledger = load_import_ledger(codex_home)?;
|
||||
let source_states = ledger.source_states();
|
||||
let mut recent = BinaryHeap::with_capacity(SESSION_IMPORT_MAX_COUNT + 1);
|
||||
|
||||
for candidate in candidates {
|
||||
let Ok(metadata) = fs::metadata(&candidate.path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(modified_at) = metadata.modified() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(modified_at) = modified_at.duration_since(std::time::UNIX_EPOCH) else {
|
||||
continue;
|
||||
};
|
||||
if (modified_at.as_secs() as i64)
|
||||
< now.saturating_sub(SESSION_IMPORT_MAX_AGE.as_secs() as i64)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(modified_at_nanos) = i64::try_from(modified_at.as_nanos()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(source_path) = fs::canonicalize(&candidate.path) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(state) = source_states.get(source_path.as_path())
|
||||
&& (state.source_modified_at == Some(modified_at_nanos)
|
||||
|| state.source_modified_at.is_none()
|
||||
&& modified_at.as_secs() as i64 <= state.imported_at)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
recent.push((
|
||||
Reverse(modified_at_nanos),
|
||||
candidate.path,
|
||||
candidate.fallback_cwd,
|
||||
));
|
||||
if recent.len() > SESSION_IMPORT_MAX_COUNT {
|
||||
recent.pop();
|
||||
}
|
||||
}
|
||||
|
||||
drop(source_states);
|
||||
let mut migrations = Vec::new();
|
||||
let mut ledger_changed = false;
|
||||
for (modified_at, path, fallback_cwd) in recent.into_sorted_vec() {
|
||||
match ledger.refresh_current_source(&path, modified_at.0) {
|
||||
Ok(false) => {}
|
||||
Ok(true) => {
|
||||
ledger_changed = true;
|
||||
continue;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
let Ok(Some(summary)) = summarize_session_with_cwd(&path, fallback_cwd.as_deref()) else {
|
||||
continue;
|
||||
};
|
||||
if require_existing_cwd && !summary.migration.cwd.is_dir() {
|
||||
continue;
|
||||
}
|
||||
migrations.push(summary.migration);
|
||||
}
|
||||
if ledger_changed {
|
||||
save_import_ledger(codex_home, &ledger)?;
|
||||
}
|
||||
|
||||
Ok(migrations)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const SESSION_MANIFESTS_DIR: &str = "claude-code-sessions";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportedSessionConnectorAttribution {
|
||||
pub session_id: String,
|
||||
pub server_ids: BTreeSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SessionManifest {
|
||||
cli_session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
remote_mcp_servers_config: Vec<RemoteMcpServerConfig>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RemoteMcpServerConfig {
|
||||
name: Option<String>,
|
||||
uuid: Option<String>,
|
||||
}
|
||||
|
||||
pub fn detect_imported_cla_session_connectors(
|
||||
session_attributions: &[ImportedSessionConnectorAttribution],
|
||||
connector_metadata_roots: &[PathBuf],
|
||||
) -> BTreeMap<String, Vec<String>> {
|
||||
if session_attributions.is_empty() {
|
||||
return BTreeMap::new();
|
||||
}
|
||||
|
||||
let attributed_server_ids_by_session = session_attributions
|
||||
.iter()
|
||||
.map(|attribution| {
|
||||
(
|
||||
attribution.session_id.clone(),
|
||||
attribution.server_ids.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut connector_names_by_session = BTreeMap::<String, BTreeMap<String, String>>::new();
|
||||
|
||||
for metadata_root in connector_metadata_roots {
|
||||
let manifests_root = metadata_root.join(SESSION_MANIFESTS_DIR);
|
||||
for manifest_path in json_files_recursively(&manifests_root) {
|
||||
let Some(manifest) = read_session_manifest(&manifest_path) else {
|
||||
continue;
|
||||
};
|
||||
let Some(session_id) = manifest.cli_session_id else {
|
||||
continue;
|
||||
};
|
||||
let Some(attributed_server_ids) = attributed_server_ids_by_session.get(&session_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if attributed_server_ids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let connector_names = connector_names_by_session.entry(session_id).or_default();
|
||||
for server in manifest.remote_mcp_servers_config {
|
||||
let Some(uuid) = server.uuid else {
|
||||
continue;
|
||||
};
|
||||
if !attributed_server_ids.contains(&uuid) {
|
||||
continue;
|
||||
}
|
||||
let Some(name) =
|
||||
crate::sessions::normalized_connector_display_name(server.name.as_deref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
connector_names.entry(name.to_lowercase()).or_insert(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connector_names_by_session
|
||||
.into_iter()
|
||||
.map(|(session_id, names)| (session_id, names.into_values().collect()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_session_manifest(path: &Path) -> Option<SessionManifest> {
|
||||
let contents = fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&contents).ok()
|
||||
}
|
||||
|
||||
fn json_files_recursively(root: &Path) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
let mut pending = vec![root.to_path_buf()];
|
||||
while let Some(directory) = pending.pop() {
|
||||
let Ok(entries) = fs::read_dir(directory) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if file_type.is_dir() {
|
||||
pending.push(entry.path());
|
||||
} else if file_type.is_file()
|
||||
&& entry.path().extension().and_then(|value| value.to_str()) == Some("json")
|
||||
{
|
||||
files.push(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
#[cfg(test)]
|
||||
use super::common::SESSION_IMPORT_MAX_COUNT;
|
||||
use super::common::SessionFileCandidate;
|
||||
use super::common::detect_recent_sessions;
|
||||
use crate::sessions::ExternalAgentSessionMigration;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn detect_recent_cur_sessions(
|
||||
external_agent_home: &Path,
|
||||
codex_home: &Path,
|
||||
) -> io::Result<Vec<ExternalAgentSessionMigration>> {
|
||||
let projects_root = external_agent_home.join("projects");
|
||||
if !projects_root.is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
for project_entry in fs::read_dir(projects_root)? {
|
||||
let Ok(project_entry) = project_entry else {
|
||||
continue;
|
||||
};
|
||||
let project_storage = project_entry.path();
|
||||
if !project_storage.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let fallback_cwd = cur_project_cwd(&project_storage);
|
||||
for path in cur_transcript_files(&project_storage.join("agent-transcripts")) {
|
||||
candidates.push(SessionFileCandidate {
|
||||
path,
|
||||
fallback_cwd: fallback_cwd.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
detect_recent_sessions(codex_home, candidates, /*require_existing_cwd*/ false)
|
||||
}
|
||||
|
||||
fn cur_transcript_files(transcripts_root: &Path) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
let mut pending = vec![transcripts_root.to_path_buf()];
|
||||
while let Some(directory) = pending.pop() {
|
||||
let Ok(entries) = fs::read_dir(directory) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if file_type.is_dir() {
|
||||
if entry.file_name() != "subagents" {
|
||||
pending.push(path);
|
||||
}
|
||||
} else if file_type.is_file()
|
||||
&& path.extension().and_then(|extension| extension.to_str()) == Some("jsonl")
|
||||
{
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
fn cur_project_cwd(project_storage: &Path) -> Option<PathBuf> {
|
||||
let encoded = project_storage.file_name()?.to_str()?;
|
||||
decode_cur_project_path(encoded)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn decode_cur_project_path(encoded: &str) -> Option<PathBuf> {
|
||||
let root = Path::new("/");
|
||||
let mut matches = Vec::new();
|
||||
collect_cur_project_paths(encoded, root, root, /*depth*/ 0, &mut matches);
|
||||
if let Some(encoded) = encoded.strip_prefix('-') {
|
||||
collect_cur_project_paths(encoded, root, root, /*depth*/ 0, &mut matches);
|
||||
}
|
||||
unique_path(matches)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn decode_cur_project_path(encoded: &str) -> Option<PathBuf> {
|
||||
let drive = encoded.as_bytes().first().copied()?;
|
||||
if !drive.is_ascii_alphabetic() || encoded.as_bytes().get(1) != Some(&b'-') {
|
||||
return None;
|
||||
}
|
||||
let encoded = encoded.get(2..)?;
|
||||
let base = PathBuf::from(format!("{}:\\", char::from(drive)));
|
||||
let mut matches = Vec::new();
|
||||
collect_cur_project_paths(encoded, &base, &base, /*depth*/ 0, &mut matches);
|
||||
unique_path(matches)
|
||||
}
|
||||
|
||||
fn collect_cur_project_paths(
|
||||
encoded: &str,
|
||||
base: &Path,
|
||||
root: &Path,
|
||||
depth: usize,
|
||||
matches: &mut Vec<PathBuf>,
|
||||
) {
|
||||
if encoded.is_empty() || depth > 32 || matches.len() > 1 {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(base) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
if matches.len() > 1 {
|
||||
break;
|
||||
}
|
||||
let candidate = entry.path();
|
||||
if !candidate.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Ok(candidate_from_root) = candidate.strip_prefix(root) else {
|
||||
continue;
|
||||
};
|
||||
let candidate_slug = cur_project_path_slug(candidate_from_root);
|
||||
if candidate_slug == encoded {
|
||||
if !matches.contains(&candidate) {
|
||||
matches.push(candidate);
|
||||
}
|
||||
} else if encoded
|
||||
.strip_prefix(&candidate_slug)
|
||||
.is_some_and(|remaining| remaining.starts_with('-'))
|
||||
{
|
||||
collect_cur_project_paths(encoded, &candidate, root, depth + 1, matches);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cur_project_path_slug(path: &Path) -> String {
|
||||
path.to_string_lossy()
|
||||
.trim_start_matches(['/', '\\'])
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_ascii_alphanumeric() {
|
||||
character
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn unique_path(mut matches: Vec<PathBuf>) -> Option<PathBuf> {
|
||||
(matches.len() == 1).then(|| matches.swap_remove(0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cur_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,304 @@
|
||||
use super::*;
|
||||
use codex_protocol::ThreadId;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs::FileTimes;
|
||||
use std::fs::OpenOptions;
|
||||
use std::time::Duration;
|
||||
use std::time::SystemTime;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn detects_cur_transcript_with_project_cwd() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let project_root = root.path().join("workspace with.dots_and-dashes");
|
||||
fs::create_dir_all(&project_root).expect("project root");
|
||||
let external_agent_home = root.path().join(".external");
|
||||
let encoded_project = encode_project_path(&project_root);
|
||||
let transcript = write_transcript(
|
||||
&external_agent_home,
|
||||
&encoded_project,
|
||||
"a-session",
|
||||
"first request",
|
||||
);
|
||||
|
||||
let sessions =
|
||||
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions");
|
||||
|
||||
assert_eq!(
|
||||
sessions,
|
||||
vec![ExternalAgentSessionMigration {
|
||||
path: transcript,
|
||||
cwd: project_root,
|
||||
title: Some("first request".to_string()),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_cur_transcript_with_embedded_unc_cwd() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let external_agent_home = root.path().join(".external");
|
||||
let encoded_project = "server-share-repo";
|
||||
let unc_cwd = PathBuf::from(r"\\server\share\repo");
|
||||
let transcript = external_agent_home
|
||||
.join("projects")
|
||||
.join(encoded_project)
|
||||
.join("agent-transcripts")
|
||||
.join("unc-session/unc-session.jsonl");
|
||||
fs::create_dir_all(transcript.parent().expect("transcript parent"))
|
||||
.expect("transcript directory");
|
||||
fs::write(
|
||||
&transcript,
|
||||
[
|
||||
serde_json::json!({
|
||||
"cwd": unc_cwd,
|
||||
"role": "user",
|
||||
"timestamp_ms": 1_800_000_000_000_i64,
|
||||
"message": {
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "<user_query>first request</user_query>",
|
||||
}],
|
||||
},
|
||||
})
|
||||
.to_string(),
|
||||
serde_json::json!({
|
||||
"role": "assistant",
|
||||
"message": {
|
||||
"content": [{"type": "text", "text": "first answer"}],
|
||||
},
|
||||
})
|
||||
.to_string(),
|
||||
]
|
||||
.join("\n"),
|
||||
)
|
||||
.expect("transcript");
|
||||
|
||||
assert_eq!(
|
||||
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions"),
|
||||
vec![ExternalAgentSessionMigration {
|
||||
path: transcript,
|
||||
cwd: unc_cwd,
|
||||
title: Some("first request".to_string()),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_cur_subagent_transcripts() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let project_root = root.path().join("workspace");
|
||||
fs::create_dir_all(&project_root).expect("project root");
|
||||
let external_agent_home = root.path().join(".external");
|
||||
let encoded_project = encode_project_path(&project_root);
|
||||
let transcript = write_transcript(
|
||||
&external_agent_home,
|
||||
&encoded_project,
|
||||
"main-session",
|
||||
"first request",
|
||||
);
|
||||
let subagent_transcript = external_agent_home
|
||||
.join("projects")
|
||||
.join(&encoded_project)
|
||||
.join("agent-transcripts")
|
||||
.join("main-session/subagents/worker/worker.jsonl");
|
||||
fs::create_dir_all(
|
||||
subagent_transcript
|
||||
.parent()
|
||||
.expect("subagent transcript parent"),
|
||||
)
|
||||
.expect("subagent transcript directory");
|
||||
fs::write(&subagent_transcript, transcript_contents("first request"))
|
||||
.expect("subagent transcript");
|
||||
|
||||
let sessions =
|
||||
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions");
|
||||
|
||||
assert_eq!(
|
||||
sessions,
|
||||
vec![ExternalAgentSessionMigration {
|
||||
path: transcript,
|
||||
cwd: project_root,
|
||||
title: Some("first request".to_string()),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ambiguous_encoded_project_cwd() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let nested_project = root.path().join("workspace").join("nested");
|
||||
let hyphenated_project = root.path().join("workspace-nested");
|
||||
fs::create_dir_all(&nested_project).expect("nested project");
|
||||
fs::create_dir_all(&hyphenated_project).expect("hyphenated project");
|
||||
|
||||
assert_eq!(
|
||||
decode_cur_project_path(&encode_project_path(&nested_project)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_cur_sessions_older_than_import_window() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let project_root = root.path().join("workspace");
|
||||
fs::create_dir_all(&project_root).expect("project root");
|
||||
let external_agent_home = root.path().join(".external");
|
||||
let transcript = write_transcript(
|
||||
&external_agent_home,
|
||||
&encode_project_path(&project_root),
|
||||
"old-session",
|
||||
"old request",
|
||||
);
|
||||
set_modified_at(
|
||||
&transcript,
|
||||
SystemTime::UNIX_EPOCH + Duration::from_secs(/*secs*/ 1),
|
||||
);
|
||||
|
||||
assert!(
|
||||
detect_recent_cur_sessions(&external_agent_home, root.path())
|
||||
.expect("detect sessions")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_cur_sessions_in_batches_and_redetects_modified_imports() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let project_root = root.path().join("workspace");
|
||||
fs::create_dir_all(&project_root).expect("project root");
|
||||
let external_agent_home = root.path().join(".external");
|
||||
let encoded_project = encode_project_path(&project_root);
|
||||
let modified_at = SystemTime::now();
|
||||
let mut expected = Vec::new();
|
||||
for index in 0..=SESSION_IMPORT_MAX_COUNT {
|
||||
let session_id = format!("session-{index:02}");
|
||||
let title = format!("request {index}");
|
||||
let path = write_transcript(&external_agent_home, &encoded_project, &session_id, &title);
|
||||
set_modified_at(
|
||||
&path,
|
||||
modified_at - Duration::from_secs(/*secs*/ index as u64),
|
||||
);
|
||||
expected.push(ExternalAgentSessionMigration {
|
||||
path,
|
||||
cwd: project_root.clone(),
|
||||
title: Some(title),
|
||||
});
|
||||
}
|
||||
let oldest_session = expected.pop().expect("oldest session");
|
||||
|
||||
let sessions =
|
||||
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions");
|
||||
|
||||
assert_eq!(sessions, expected);
|
||||
for session in &sessions {
|
||||
crate::sessions::ledger::record_imported_session(
|
||||
root.path(),
|
||||
&session.path,
|
||||
ThreadId::new(),
|
||||
)
|
||||
.expect("record import");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions"),
|
||||
vec![oldest_session.clone()]
|
||||
);
|
||||
crate::sessions::ledger::record_imported_session(
|
||||
root.path(),
|
||||
&oldest_session.path,
|
||||
ThreadId::new(),
|
||||
)
|
||||
.expect("record oldest import");
|
||||
assert!(
|
||||
detect_recent_cur_sessions(&external_agent_home, root.path())
|
||||
.expect("detect sessions")
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let modified_session = &expected[0];
|
||||
let updated_record = serde_json::json!({
|
||||
"role": "assistant",
|
||||
"message": {
|
||||
"content": [{"type": "text", "text": "updated answer"}],
|
||||
},
|
||||
})
|
||||
.to_string();
|
||||
fs::write(
|
||||
&modified_session.path,
|
||||
format!(
|
||||
"{}\n{updated_record}",
|
||||
transcript_contents(modified_session.title.as_deref().expect("session title"))
|
||||
),
|
||||
)
|
||||
.expect("update transcript");
|
||||
set_modified_at(
|
||||
&modified_session.path,
|
||||
SystemTime::now() + Duration::from_secs(/*secs*/ 1),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions"),
|
||||
vec![modified_session.clone()]
|
||||
);
|
||||
}
|
||||
|
||||
fn write_transcript(
|
||||
external_agent_home: &Path,
|
||||
encoded_project: &str,
|
||||
session_id: &str,
|
||||
first_request: &str,
|
||||
) -> PathBuf {
|
||||
let transcript = external_agent_home
|
||||
.join("projects")
|
||||
.join(encoded_project)
|
||||
.join("agent-transcripts")
|
||||
.join(session_id)
|
||||
.join(format!("{session_id}.jsonl"));
|
||||
fs::create_dir_all(transcript.parent().expect("transcript parent"))
|
||||
.expect("transcript directory");
|
||||
fs::write(&transcript, transcript_contents(first_request)).expect("transcript");
|
||||
transcript
|
||||
}
|
||||
|
||||
fn transcript_contents(first_request: &str) -> String {
|
||||
[
|
||||
serde_json::json!({
|
||||
"role": "user",
|
||||
"message": {
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("<user_query>{first_request}</user_query>"),
|
||||
}],
|
||||
},
|
||||
})
|
||||
.to_string(),
|
||||
serde_json::json!({
|
||||
"role": "assistant",
|
||||
"message": {
|
||||
"content": [{"type": "text", "text": "first answer"}],
|
||||
},
|
||||
})
|
||||
.to_string(),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn set_modified_at(path: &Path, modified_at: SystemTime) {
|
||||
OpenOptions::new()
|
||||
.write(true)
|
||||
.open(path)
|
||||
.expect("open transcript")
|
||||
.set_times(FileTimes::new().set_modified(modified_at))
|
||||
.expect("set transcript modified time");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn encode_project_path(path: &Path) -> String {
|
||||
cur_project_path_slug(path).replacen("--", "-", 1)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn encode_project_path(path: &Path) -> String {
|
||||
cur_project_path_slug(path)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod cla;
|
||||
mod common;
|
||||
mod connectors_cla;
|
||||
mod cur;
|
||||
|
||||
pub use cla::detect_recent_cla_sessions;
|
||||
pub use connectors_cla::ImportedSessionConnectorAttribution;
|
||||
pub use connectors_cla::detect_imported_cla_session_connectors;
|
||||
pub use cur::detect_recent_cur_sessions;
|
||||
Reference in new issue
Block a user