From a30624d6d411cd0b56307eb885c37e3e02838004 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Mon, 25 May 2026 17:06:00 +0000 Subject: [PATCH] Expose creation time in session list metadata Preserve session-list consumers from depending on encoded session IDs by carrying persisted creation timestamps through managed summaries and JSON detail output, with an ID timestamp fallback only for legacy metadata that lacks created_at_ms.\n\nConstraint: ROADMAP #335 requires created_at_ms in session_details with the same millisecond unit as updated_at_ms.\nRejected: Making callers parse session IDs | undocumented ID structure is brittle and was the issue being fixed.\nRejected: Session storage redesign | scope is limited to detail metadata propagation and legacy compatibility.\nConfidence: high\nScope-risk: narrow\nDirective: Keep ID timestamp parsing fallback-only; persisted session_meta.created_at_ms remains the source of truth when present.\nTested: cargo fmt --manifest-path rust/Cargo.toml --all -- --check; cargo check --manifest-path rust/Cargo.toml --workspace; cargo build --manifest-path rust/Cargo.toml --workspace; cargo test --manifest-path rust/Cargo.toml --workspace; cargo clippy --manifest-path rust/Cargo.toml -p runtime -p rusty-claude-cli --all-targets\nNot-tested: live interactive /session list against a real provider-backed REPL --- rust/crates/runtime/src/session.rs | 68 ++++++++++++++++++++-- rust/crates/runtime/src/session_control.rs | 27 +++++---- rust/crates/rusty-claude-cli/src/main.rs | 44 ++++++++++---- 3 files changed, 110 insertions(+), 29 deletions(-) diff --git a/rust/crates/runtime/src/session.rs b/rust/crates/runtime/src/session.rs index 2a8d1e60..0cc32c03 100644 --- a/rust/crates/runtime/src/session.rs +++ b/rust/crates/runtime/src/session.rs @@ -413,6 +413,7 @@ impl Session { .get("created_at_ms") .map(|value| required_u64_from_value(value, "created_at_ms")) .transpose()? + .or_else(|| parse_created_at_ms_from_session_id(&session_id)) .unwrap_or(now); let updated_at_ms = object .get("updated_at_ms") @@ -500,7 +501,10 @@ impl Session { "session_meta" => { version = required_u32(object, "version")?; session_id = Some(required_string(object, "session_id")?); - created_at_ms = Some(required_u64(object, "created_at_ms")?); + created_at_ms = object + .get("created_at_ms") + .map(|value| required_u64_from_value(value, "created_at_ms")) + .transpose()?; updated_at_ms = Some(required_u64(object, "updated_at_ms")?); fork = object.get("fork").map(SessionFork::from_json).transpose()?; workspace_root = object @@ -543,11 +547,15 @@ impl Session { } let now = current_time_millis(); + let session_id = session_id.unwrap_or_else(generate_session_id); + let created_at_ms = created_at_ms + .or_else(|| parse_created_at_ms_from_session_id(&session_id)) + .unwrap_or(now); Ok(Self { version, - session_id: session_id.unwrap_or_else(generate_session_id), - created_at_ms: created_at_ms.unwrap_or(now), - updated_at_ms: updated_at_ms.unwrap_or(created_at_ms.unwrap_or(now)), + session_id, + created_at_ms, + updated_at_ms: updated_at_ms.unwrap_or(created_at_ms), messages, compaction, fork, @@ -1291,6 +1299,15 @@ fn current_time_millis() -> u64 { } } +pub(crate) fn parse_created_at_ms_from_session_id(session_id: &str) -> Option { + let timestamp_and_suffix = session_id.strip_prefix("session-")?; + let (timestamp, suffix) = timestamp_and_suffix.split_once('-')?; + if suffix.is_empty() { + return None; + } + timestamp.parse::().ok() +} + fn generate_session_id() -> String { let millis = current_time_millis(); let counter = SESSION_ID_COUNTER.fetch_add(1, Ordering::Relaxed); @@ -1380,8 +1397,9 @@ fn cleanup_rotated_logs(path: &Path) -> Result<(), SessionError> { #[cfg(test)] mod tests { use super::{ - cleanup_rotated_logs, current_time_millis, rotate_session_file_if_needed, ContentBlock, - ConversationMessage, MessageRole, Session, SessionFork, + cleanup_rotated_logs, current_time_millis, parse_created_at_ms_from_session_id, + rotate_session_file_if_needed, ContentBlock, ConversationMessage, MessageRole, Session, + SessionFork, }; use crate::json::JsonValue; use crate::usage::TokenUsage; @@ -1502,6 +1520,44 @@ mod tests { assert!(!restored.session_id.is_empty()); } + #[test] + fn created_at_parser_requires_full_session_id_shape() { + assert_eq!( + parse_created_at_ms_from_session_id("session-1743724800123-0"), + Some(1_743_724_800_123) + ); + assert_eq!( + parse_created_at_ms_from_session_id("session-1743724800123"), + None + ); + assert_eq!( + parse_created_at_ms_from_session_id("session-1743724800123-"), + None + ); + assert_eq!( + parse_created_at_ms_from_session_id("other-1743724800123-0"), + None + ); + } + + #[test] + fn loads_legacy_jsonl_created_at_from_session_id_when_meta_omits_it() { + let path = temp_session_path("legacy-jsonl-created-at"); + fs::write( + &path, + r#"{"type":"session_meta","version":3,"session_id":"session-1743724800123-0","updated_at_ms":1743724800456} +"#, + ) + .expect("legacy jsonl should write"); + + let restored = Session::load_from_path(&path).expect("legacy jsonl should load"); + fs::remove_file(&path).expect("temp file should be removable"); + + assert_eq!(restored.session_id, "session-1743724800123-0"); + assert_eq!(restored.created_at_ms, 1_743_724_800_123); + assert_eq!(restored.updated_at_ms, 1_743_724_800_456); + } + #[test] fn appends_messages_to_persisted_jsonl_session() { let path = temp_session_path("append"); diff --git a/rust/crates/runtime/src/session_control.rs b/rust/crates/runtime/src/session_control.rs index 362a0b54..e6c3f6c0 100644 --- a/rust/crates/runtime/src/session_control.rs +++ b/rust/crates/runtime/src/session_control.rs @@ -5,7 +5,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::UNIX_EPOCH; -use crate::session::{Session, SessionError}; +use crate::session::{parse_created_at_ms_from_session_id, Session, SessionError}; /// Per-worktree session store that namespaces on-disk session files by /// workspace fingerprint so that parallel `opencode serve` instances never @@ -345,6 +345,9 @@ impl SessionStore { .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .map(|duration| duration.as_millis()) .unwrap_or_default(); + let fallback_id = session_id_from_path(&path).unwrap_or_else(|| "unknown".to_string()); + let fallback_created_at_ms = + parse_created_at_ms_from_session_id(&fallback_id).unwrap_or(0); let summary = match Session::load_from_path(&path) { Ok(session) => { if self.validate_loaded_session(&path, &session).is_err() { @@ -353,6 +356,7 @@ impl SessionStore { ManagedSessionSummary { id: session.session_id, path, + created_at_ms: session.created_at_ms, updated_at_ms: session.updated_at_ms, modified_epoch_millis, message_count: session.messages.len(), @@ -367,12 +371,9 @@ impl SessionStore { } } Err(_) => ManagedSessionSummary { - id: path - .file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("unknown") - .to_string(), + id: fallback_id, path, + created_at_ms: fallback_created_at_ms, updated_at_ms: 0, modified_epoch_millis, message_count: 0, @@ -409,10 +410,14 @@ impl SessionStore { .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .map(|duration| duration.as_millis()) .unwrap_or_default(); + let fallback_id = session_id_from_path(&path).unwrap_or_else(|| "unknown".to_string()); + let fallback_created_at_ms = + parse_created_at_ms_from_session_id(&fallback_id).unwrap_or(0); let summary = match Session::load_from_path(&path) { Ok(session) => ManagedSessionSummary { id: session.session_id, path, + created_at_ms: session.created_at_ms, updated_at_ms: session.updated_at_ms, modified_epoch_millis, message_count: session.messages.len(), @@ -426,12 +431,9 @@ impl SessionStore { .and_then(|fork| fork.branch_name.clone()), }, Err(_) => ManagedSessionSummary { - id: path - .file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("unknown") - .to_string(), + id: fallback_id, path, + created_at_ms: fallback_created_at_ms, updated_at_ms: 0, modified_epoch_millis, message_count: 0, @@ -483,6 +485,7 @@ pub struct SessionHandle { pub struct ManagedSessionSummary { pub id: String, pub path: PathBuf, + pub created_at_ms: u64, pub updated_at_ms: u64, pub modified_epoch_millis: u128, pub message_count: usize, @@ -810,6 +813,7 @@ mod tests { ManagedSessionSummary { id: "older-file-newer-session".to_string(), path: PathBuf::from("/tmp/older"), + created_at_ms: 100, updated_at_ms: 200, modified_epoch_millis: 100, message_count: 2, @@ -819,6 +823,7 @@ mod tests { ManagedSessionSummary { id: "newer-file-older-session".to_string(), path: PathBuf::from("/tmp/newer"), + created_at_ms: 50, updated_at_ms: 100, modified_epoch_millis: 200, message_count: 1, diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 97cece06..7e70ea14 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -3960,18 +3960,7 @@ fn run_resume_command( let session_list_outcome = || -> Result> { let sessions = list_managed_sessions().unwrap_or_default(); let session_ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); - let session_details: Vec = sessions - .iter() - .map(|session| { - serde_json::json!({ - "id": session.id, - "path": session.path.display().to_string(), - "message_count": session.message_count, - "updated_at_ms": session.updated_at_ms, - "lifecycle": session.lifecycle.json_value(), - }) - }) - .collect(); + let session_details = session_details_json(&sessions); let active_id = session.session_id.clone(); let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}")); Ok(ResumeCommandOutcome { @@ -4550,6 +4539,7 @@ struct SessionHandle { struct ManagedSessionSummary { id: String, path: PathBuf, + created_at_ms: u64, updated_at_ms: u64, modified_epoch_millis: u128, message_count: usize, @@ -6367,6 +6357,7 @@ fn list_managed_sessions() -> Result, Box Result Vec