mirror of
https://github.com/instructkr/claude-code.git
synced 2026-05-27 07:56:46 +00:00
Merge pull request #3108 from Yeachan-Heo/fix/issue-335-session-created-at-ms
Expose created_at_ms in session list JSON
This commit is contained in:
@@ -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<u64> {
|
||||
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::<u64>().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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3960,18 +3960,7 @@ fn run_resume_command(
|
||||
let session_list_outcome = || -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
|
||||
let sessions = list_managed_sessions().unwrap_or_default();
|
||||
let session_ids: Vec<String> = sessions.iter().map(|s| s.id.clone()).collect();
|
||||
let session_details: Vec<serde_json::Value> = 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<Vec<ManagedSessionSummary>, Box<dyn std::er
|
||||
.map(|session| ManagedSessionSummary {
|
||||
id: session.id,
|
||||
path: session.path,
|
||||
created_at_ms: session.created_at_ms,
|
||||
updated_at_ms: session.updated_at_ms,
|
||||
modified_epoch_millis: session.modified_epoch_millis,
|
||||
message_count: session.message_count,
|
||||
@@ -6386,6 +6377,7 @@ fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error:
|
||||
Ok(ManagedSessionSummary {
|
||||
id: session.id,
|
||||
path: session.path,
|
||||
created_at_ms: session.created_at_ms,
|
||||
updated_at_ms: session.updated_at_ms,
|
||||
modified_epoch_millis: session.modified_epoch_millis,
|
||||
message_count: session.message_count,
|
||||
@@ -6443,6 +6435,7 @@ fn session_details_json(sessions: &[ManagedSessionSummary]) -> Vec<serde_json::V
|
||||
"id": session.id,
|
||||
"path": session.path.display().to_string(),
|
||||
"message_count": session.message_count,
|
||||
"created_at_ms": session.created_at_ms,
|
||||
"updated_at_ms": session.updated_at_ms,
|
||||
"modified_epoch_millis": session.modified_epoch_millis,
|
||||
"parent_session_id": session.parent_session_id,
|
||||
@@ -14270,6 +14263,33 @@ UU conflicted.rs",
|
||||
assert_eq!(missing["session_id"], "missing-session");
|
||||
assert!(missing["candidate_path"].as_str().is_some());
|
||||
|
||||
let list_command = SlashCommand::parse("/session list")
|
||||
.expect("parse should succeed")
|
||||
.expect("command should exist");
|
||||
let list = run_resume_command(&active.path, &active_session, &list_command)
|
||||
.expect("list should run")
|
||||
.json
|
||||
.expect("list should return json");
|
||||
assert_eq!(list["kind"], "sessions");
|
||||
let details = list["session_details"]
|
||||
.as_array()
|
||||
.expect("session_details should be an array");
|
||||
let saved_path = saved.path.display().to_string();
|
||||
let saved_detail = details
|
||||
.iter()
|
||||
.find(|detail| detail["path"] == saved_path)
|
||||
.expect("saved session detail should exist");
|
||||
let created_at_ms = saved_detail["created_at_ms"]
|
||||
.as_u64()
|
||||
.expect("created_at_ms should be present");
|
||||
let updated_at_ms = saved_detail["updated_at_ms"]
|
||||
.as_u64()
|
||||
.expect("updated_at_ms should be present");
|
||||
assert!(
|
||||
created_at_ms <= updated_at_ms,
|
||||
"created_at_ms should not be after updated_at_ms"
|
||||
);
|
||||
|
||||
let delete_command = SlashCommand::parse("/session delete session-saved --force")
|
||||
.expect("parse should succeed")
|
||||
.expect("command should exist");
|
||||
|
||||
Reference in New Issue
Block a user