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
@@ -1,115 +0,0 @@
|
||||
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) = super::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
|
||||
}
|
||||
@@ -1,491 +0,0 @@
|
||||
use super::ExternalAgentSessionMigration;
|
||||
use super::ledger::load_import_ledger;
|
||||
use super::ledger::save_import_ledger;
|
||||
use super::now_unix_seconds;
|
||||
use super::summarize_session;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::BinaryHeap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
const SESSION_IMPORT_MAX_COUNT: usize = 50;
|
||||
const SESSION_IMPORT_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
|
||||
|
||||
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 now = now_unix_seconds();
|
||||
let mut ledger = load_import_ledger(codex_home)?;
|
||||
let source_states = ledger.source_states();
|
||||
let mut file_candidates = BinaryHeap::with_capacity(SESSION_IMPORT_MAX_COUNT + 1);
|
||||
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;
|
||||
}
|
||||
let Ok(metadata) = entry.metadata() 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(&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;
|
||||
}
|
||||
file_candidates.push((Reverse(modified_at_nanos), path));
|
||||
if file_candidates.len() > SESSION_IMPORT_MAX_COUNT {
|
||||
file_candidates.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drop(source_states);
|
||||
let file_candidates = file_candidates.into_sorted_vec();
|
||||
let mut migrations = Vec::new();
|
||||
let mut ledger_changed = false;
|
||||
for (modified_at, path) in file_candidates {
|
||||
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(&path) else {
|
||||
continue;
|
||||
};
|
||||
let migration = summary.migration;
|
||||
if !migration.cwd.is_dir() {
|
||||
continue;
|
||||
}
|
||||
migrations.push(migration);
|
||||
}
|
||||
if ledger_changed {
|
||||
save_import_ledger(codex_home, &ledger)?;
|
||||
}
|
||||
|
||||
Ok(migrations)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::ledger::record_imported_session;
|
||||
use super::*;
|
||||
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::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")
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
use super::ExternalAgentSessionMigration;
|
||||
use super::ledger::load_import_ledger;
|
||||
use super::ledger::save_import_ledger;
|
||||
use super::now_unix_seconds;
|
||||
use super::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;
|
||||
|
||||
const SESSION_IMPORT_MAX_COUNT: usize = 50;
|
||||
const SESSION_IMPORT_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
|
||||
|
||||
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 now = now_unix_seconds();
|
||||
let mut ledger = load_import_ledger(codex_home)?;
|
||||
let source_states = ledger.source_states();
|
||||
let mut candidates = BinaryHeap::with_capacity(SESSION_IMPORT_MAX_COUNT + 1);
|
||||
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")) {
|
||||
let Ok(metadata) = fs::metadata(&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(&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;
|
||||
}
|
||||
candidates.push((Reverse(modified_at_nanos), path, fallback_cwd.clone()));
|
||||
if candidates.len() > SESSION_IMPORT_MAX_COUNT {
|
||||
candidates.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drop(source_states);
|
||||
let mut migrations = Vec::new();
|
||||
let mut ledger_changed = false;
|
||||
for (modified_at, path, fallback_cwd) in candidates.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;
|
||||
};
|
||||
migrations.push(summary.migration);
|
||||
}
|
||||
if ledger_changed {
|
||||
save_import_ledger(codex_home, &ledger)?;
|
||||
}
|
||||
Ok(migrations)
|
||||
}
|
||||
|
||||
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 = "detect_cur_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,299 +0,0 @@
|
||||
use super::*;
|
||||
use codex_protocol::ThreadId;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs::FileTimes;
|
||||
use std::fs::OpenOptions;
|
||||
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 {
|
||||
super::super::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()]
|
||||
);
|
||||
super::super::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)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ const SESSION_IMPORT_LEDGER_FILE: &str = "external_agent_session_imports.json";
|
||||
const SESSION_HASH_BUFFER_SIZE: usize = 64 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub(super) struct ImportedExternalAgentSessionLedger {
|
||||
pub(crate) struct ImportedExternalAgentSessionLedger {
|
||||
records: Vec<ImportedExternalAgentSessionRecord>,
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ pub struct ImportedConnectorCandidate {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct ImportedSourceState {
|
||||
pub(crate) struct ImportedSourceState {
|
||||
pub source_modified_at: Option<i64>,
|
||||
pub imported_at: i64,
|
||||
}
|
||||
@@ -144,7 +144,7 @@ pub fn read_imported_connector_candidates(
|
||||
}
|
||||
|
||||
impl ImportedExternalAgentSessionLedger {
|
||||
pub(super) fn source_states(&self) -> HashMap<&Path, ImportedSourceState> {
|
||||
pub(crate) fn source_states(&self) -> HashMap<&Path, ImportedSourceState> {
|
||||
let mut states = HashMap::new();
|
||||
for record in &self.records {
|
||||
states.insert(
|
||||
@@ -158,7 +158,7 @@ impl ImportedExternalAgentSessionLedger {
|
||||
states
|
||||
}
|
||||
|
||||
pub(super) fn contains_current_source(&self, source_path: &Path) -> io::Result<bool> {
|
||||
pub(crate) fn contains_current_source(&self, source_path: &Path) -> io::Result<bool> {
|
||||
if self.records.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -176,7 +176,7 @@ impl ImportedExternalAgentSessionLedger {
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn refresh_current_source(
|
||||
pub(crate) fn refresh_current_source(
|
||||
&mut self,
|
||||
source_path: &Path,
|
||||
source_modified_at: i64,
|
||||
@@ -203,7 +203,7 @@ impl ImportedExternalAgentSessionLedger {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn load_import_ledger(
|
||||
pub(crate) fn load_import_ledger(
|
||||
codex_home: &Path,
|
||||
) -> io::Result<ImportedExternalAgentSessionLedger> {
|
||||
let path = import_ledger_path(codex_home);
|
||||
@@ -222,7 +222,7 @@ pub(super) fn load_import_ledger(
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn save_import_ledger(
|
||||
pub(crate) fn save_import_ledger(
|
||||
codex_home: &Path,
|
||||
ledger: &ImportedExternalAgentSessionLedger,
|
||||
) -> io::Result<()> {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
//! Parsing and export helpers for external-agent session histories.
|
||||
|
||||
mod connectors_cla;
|
||||
mod detect_cla;
|
||||
mod detect_cur;
|
||||
mod export;
|
||||
mod ledger;
|
||||
mod records;
|
||||
pub(crate) mod ledger;
|
||||
pub(crate) mod records;
|
||||
mod title;
|
||||
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
@@ -14,10 +11,10 @@ use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub use connectors_cla::ImportedSessionConnectorAttribution;
|
||||
pub use connectors_cla::detect_imported_cla_session_connectors;
|
||||
pub use detect_cla::detect_recent_cla_sessions;
|
||||
pub use detect_cur::detect_recent_cur_sessions;
|
||||
pub use crate::detect::sessions::ImportedSessionConnectorAttribution;
|
||||
pub use crate::detect::sessions::detect_imported_cla_session_connectors;
|
||||
pub use crate::detect::sessions::detect_recent_cla_sessions;
|
||||
pub use crate::detect::sessions::detect_recent_cur_sessions;
|
||||
use export::load_session_for_import_with_content_sha256;
|
||||
pub use ledger::CompletedExternalAgentSessionImport;
|
||||
pub use ledger::ImportedConnectorCandidate;
|
||||
@@ -29,7 +26,7 @@ pub use records::summarize_session;
|
||||
|
||||
const SESSION_TITLE_MAX_LEN: usize = 120;
|
||||
|
||||
fn normalized_connector_display_name(name: Option<&str>) -> Option<String> {
|
||||
pub(crate) fn normalized_connector_display_name(name: Option<&str>) -> Option<String> {
|
||||
name.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
@@ -145,7 +142,7 @@ fn truncate(text: &str, max_len: usize) -> String {
|
||||
format!("{prefix}...")
|
||||
}
|
||||
|
||||
fn now_unix_seconds() -> i64 {
|
||||
pub(crate) fn now_unix_seconds() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs() as i64)
|
||||
|
||||
Reference in new issue
Block a user