use chrono::{DateTime, Utc};
use serde::Serialize;
use super::CatchupOptions;
use super::derive_palace_id_for;
use super::git::{CommitSummary, git_commits_since};
use super::palace::fetch_recent_palace_drawers;
use super::session_finder::{
FilteredSessions, PausedSession, filter_sessions_since, find_paused_sessions,
};
use super::state::load_catchup_state_in;
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct PausedSessionJson {
pub format: String,
pub paused_at: Option<DateTime<Utc>>,
pub summary: String,
pub in_progress: Option<String>,
pub next_steps: Option<String>,
pub git_context: Option<String>,
pub tmux_window: Option<String>,
pub source_file: Option<String>,
pub owned: bool,
}
fn paused_session_to_json(session: &PausedSession) -> PausedSessionJson {
match session {
PausedSession::TrustyMpm {
path,
paused_at,
summary,
git_context,
in_progress,
next_steps,
tmux_window,
} => PausedSessionJson {
format: "trusty-mpm".to_string(),
paused_at: *paused_at,
summary: summary.clone(),
in_progress: in_progress.clone(),
next_steps: next_steps.clone(),
git_context: git_context.clone(),
tmux_window: tmux_window.clone(),
source_file: Some(path.display().to_string()),
owned: true,
},
PausedSession::ClaudeMpm { session: s } => {
let in_progress = {
let mut items: Vec<String> = Vec::new();
if let Some(t) = &s.todos {
items.extend(t.iter().cloned());
}
if let Some(t) = &s.task_list {
items.extend(t.iter().cloned());
}
if items.is_empty() {
None
} else {
Some(items.join("\n"))
}
};
PausedSessionJson {
format: "claude-mpm".to_string(),
paused_at: s
.paused_at
.as_deref()
.and_then(|ts| ts.parse::<DateTime<Utc>>().ok()),
summary: s.resume_instructions.clone().unwrap_or_default(),
in_progress,
next_steps: s
.open_questions
.as_ref()
.filter(|q| !q.is_empty())
.map(|q| q.join("\n")),
git_context: s.git_context.clone(),
tmux_window: None,
source_file: None,
owned: true,
}
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct RecentMemoryJson {
pub title: String,
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct CatchupJson {
pub sessions: Vec<PausedSessionJson>,
pub recent_commits: Vec<CommitSummary>,
pub recent_memory: Vec<RecentMemoryJson>,
pub undatable_sessions_dropped: usize,
}
impl CatchupJson {
pub fn absorb(&mut self, other: CatchupJson) {
self.sessions.extend(other.sessions);
self.recent_commits.extend(other.recent_commits);
self.recent_memory.extend(other.recent_memory);
self.undatable_sessions_dropped = self
.undatable_sessions_dropped
.saturating_add(other.undatable_sessions_dropped);
}
}
fn sessions_payload(filtered: FilteredSessions) -> (Vec<PausedSessionJson>, usize) {
(
filtered.kept.iter().map(paused_session_to_json).collect(),
filtered.dropped_undatable,
)
}
pub async fn generate_catchup_json(opts: &CatchupOptions) -> CatchupJson {
generate_catchup_json_in(opts, None).await
}
pub async fn generate_catchup_json_in(
opts: &CatchupOptions,
state_root: Option<&std::path::Path>,
) -> CatchupJson {
let palace_id = match derive_palace_id_for(&opts.project_dir) {
Ok(id) => Some(id),
Err(e) => {
eprintln!(
"catchup: warning: palace resolution failed ({e}); \
reporting full history and omitting recent memory"
);
None
}
};
let watermark: Option<DateTime<Utc>> = match (opts.full, palace_id.as_deref()) {
(false, Some(id)) => load_catchup_state_in(id, state_root).map(|s| s.last_catchup_at),
_ => None,
};
let (sessions, undatable_sessions_dropped) = match find_paused_sessions(&opts.project_dir) {
Ok(found) => sessions_payload(filter_sessions_since(found, watermark)),
Err(e) => {
eprintln!("catchup: warning: could not scan paused sessions: {e}");
(Vec::new(), 0)
}
};
let recent_commits = if opts.include_git {
git_commits_since(&opts.project_dir, watermark)
.into_iter()
.take(opts.git_limit)
.collect()
} else {
Vec::new()
};
let recent_memory = if let (true, Some(palace_id)) = (opts.include_palace, palace_id.as_deref())
{
match fetch_recent_palace_drawers(
&opts.memory_socket,
palace_id,
opts.drawer_limit,
watermark,
)
.await
{
Some(drawers) => drawers
.iter()
.map(|d| RecentMemoryJson {
title: d.title.clone(),
tags: d.tags.clone(),
})
.collect(),
None => Vec::new(),
}
} else {
Vec::new()
};
CatchupJson {
sessions,
recent_commits,
recent_memory,
undatable_sessions_dropped,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catchup::CatchupOptions;
use crate::catchup::state::{CatchupState, save_catchup_state_in};
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
fn init_git_repo(tmp: &TempDir) {
let p = tmp.path();
std::process::Command::new("git")
.arg("-C")
.arg(p)
.args(["init"])
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(p)
.args(["config", "user.email", "t@t.com"])
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(p)
.args(["config", "user.name", "T"])
.output()
.unwrap();
fs::write(p.join("README.md"), b"test").unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(p)
.args(["add", "."])
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(p)
.args(["commit", "-m", "init"])
.output()
.unwrap();
}
#[tokio::test]
async fn generate_catchup_json_returns_structured_fields() {
let tmp = TempDir::new().unwrap();
init_git_repo(&tmp);
let sessions_dir = tmp.path().join(".trusty-mpm").join("sessions");
fs::create_dir_all(&sessions_dir).unwrap();
fs::write(
sessions_dir.join("session-20260627-100000.md"),
"## Summary\nDid the thing.\n\n## Next Steps\nShip it.\n",
)
.unwrap();
let opts = CatchupOptions {
project_dir: tmp.path().to_path_buf(),
memory_socket: std::path::PathBuf::from("/nonexistent/catchup-test.sock"),
include_git: true,
include_palace: true,
git_limit: 50,
drawer_limit: 15,
full: true,
};
let json = generate_catchup_json(&opts).await;
assert_eq!(json.sessions.len(), 1, "fixture snapshot should be found");
assert_eq!(json.sessions[0].format, "trusty-mpm");
assert_eq!(json.sessions[0].summary, "Did the thing.");
assert_eq!(json.sessions[0].next_steps.as_deref(), Some("Ship it."));
assert!(
json.sessions[0].source_file.is_some(),
"trusty-mpm sessions carry a source_file"
);
assert!(
json.recent_commits.iter().any(|c| c.msg == "init"),
"commit summary should be structured, not prose: {:?}",
json.recent_commits
);
assert!(json.recent_memory.is_empty());
}
#[tokio::test]
async fn generate_catchup_json_respects_watermark() {
let tmp = TempDir::new().unwrap();
init_git_repo(&tmp);
let palace_id = derive_palace_id_for(tmp.path()).expect("a temp dir resolves to a palace");
let state = CatchupState {
last_catchup_at: "2099-01-01T00:00:00Z".parse().unwrap(),
palace_id: palace_id.clone(),
last_git_sha: None,
};
let state_root = tmp.path().join("state");
save_catchup_state_in(&palace_id, &state, Some(&state_root)).unwrap();
let opts = CatchupOptions {
project_dir: tmp.path().to_path_buf(),
memory_socket: std::path::PathBuf::from("/nonexistent/catchup-test.sock"),
include_git: true,
include_palace: false,
git_limit: 50,
drawer_limit: 15,
full: false,
};
let json = generate_catchup_json_in(&opts, Some(&state_root)).await;
assert!(
json.recent_commits.is_empty(),
"future watermark should exclude all commits: {:?}",
json.recent_commits
);
}
#[tokio::test]
async fn generate_catchup_json_excludes_undatable_session_behind_watermark() {
let tmp = TempDir::new().unwrap();
init_git_repo(&tmp);
let sessions_dir = tmp.path().join(".trusty-mpm").join("sessions");
fs::create_dir_all(&sessions_dir).unwrap();
fs::write(
sessions_dir.join("session-20260806-211305.md"),
"## Summary\nReal work.\n\n## Next Steps\nShip it.\n",
)
.unwrap();
fs::write(
sessions_dir.join("session-20260730-bounce.md"),
"# Session snapshot — pre-daemon-bounce\n\n## What landed this leg\n- stuff\n",
)
.unwrap();
let palace_id = derive_palace_id_for(tmp.path()).expect("a temp dir resolves to a palace");
let state_root = tmp.path().join("state");
save_catchup_state_in(
&palace_id,
&CatchupState {
last_catchup_at: "2099-01-01T00:00:00Z".parse().unwrap(),
palace_id: palace_id.clone(),
last_git_sha: None,
},
Some(&state_root),
)
.unwrap();
let opts = CatchupOptions {
project_dir: tmp.path().to_path_buf(),
memory_socket: std::path::PathBuf::from("/nonexistent/catchup-test.sock"),
include_git: false,
include_palace: false,
git_limit: 50,
drawer_limit: 15,
full: false,
};
let json = generate_catchup_json_in(&opts, Some(&state_root)).await;
assert!(
json.sessions.is_empty(),
"a snapshot with no derivable timestamp must not survive a watermark \
that every datable snapshot fails: {:?}",
json.sessions
.iter()
.map(|s| s.source_file.clone())
.collect::<Vec<_>>()
);
assert_eq!(json.undatable_sessions_dropped, 0);
}
#[tokio::test]
async fn generate_catchup_json_reports_undatable_drop_count() {
let tmp = TempDir::new().unwrap();
init_git_repo(&tmp);
let sessions_dir = tmp.path().join(".trusty-mpm").join("sessions");
fs::create_dir_all(&sessions_dir).unwrap();
fs::write(
sessions_dir.join("session-20260101-000000.md"),
"## Summary\nOld.\n",
)
.unwrap();
let palace_id = derive_palace_id_for(tmp.path()).expect("a temp dir resolves to a palace");
let state_root = tmp.path().join("state");
save_catchup_state_in(
&palace_id,
&CatchupState {
last_catchup_at: "2099-01-01T00:00:00Z".parse().unwrap(),
palace_id: palace_id.clone(),
last_git_sha: None,
},
Some(&state_root),
)
.unwrap();
let opts = CatchupOptions {
project_dir: tmp.path().to_path_buf(),
memory_socket: std::path::PathBuf::from("/nonexistent/catchup-test.sock"),
include_git: false,
include_palace: false,
git_limit: 50,
drawer_limit: 15,
full: false,
};
let json = generate_catchup_json_in(&opts, Some(&state_root)).await;
assert!(json.sessions.is_empty());
assert_eq!(
json.undatable_sessions_dropped, 0,
"a merely-too-old session is filtered, not withheld — the count \
must not conflate the two"
);
}
fn claude_session(resume: &str) -> PausedSession {
use crate::catchup::mpm_session::ClaudeMpmSession;
PausedSession::ClaudeMpm {
session: ClaudeMpmSession {
resume_instructions: Some(resume.to_string()),
..Default::default()
},
}
}
#[test]
fn sessions_payload_propagates_dropped_count() {
let (sessions, dropped) = sessions_payload(FilteredSessions {
kept: vec![claude_session("a"), claude_session("b")],
dropped_undatable: 7,
});
assert_eq!(sessions.len(), 2);
assert_eq!(
dropped, 7,
"the withheld count must not be reset in transit"
);
}
#[test]
fn absorb_sums_dropped_counts_and_concatenates_the_rest() {
let mut a = CatchupJson {
sessions: vec![paused_session_to_json(&claude_session("a"))],
recent_memory: vec![RecentMemoryJson {
title: "t1".into(),
tags: vec![],
}],
undatable_sessions_dropped: 2,
..Default::default()
};
a.absorb(CatchupJson {
sessions: vec![paused_session_to_json(&claude_session("b"))],
recent_memory: vec![RecentMemoryJson {
title: "t2".into(),
tags: vec![],
}],
undatable_sessions_dropped: 3,
..Default::default()
});
assert_eq!(a.sessions.len(), 2);
assert_eq!(a.recent_memory.len(), 2);
assert_eq!(
a.undatable_sessions_dropped, 5,
"withheld counts sum across projects, never overwrite"
);
}
#[test]
fn paused_session_to_json_maps_trusty_mpm_fields() {
let session = PausedSession::TrustyMpm {
path: PathBuf::from("/tmp/session-1.md"),
paused_at: None,
summary: "Summary text".to_string(),
git_context: Some("branch: main".to_string()),
in_progress: Some("doing X".to_string()),
next_steps: Some("do Y".to_string()),
tmux_window: Some("main:2:@7".to_string()),
};
let json = paused_session_to_json(&session);
assert_eq!(json.format, "trusty-mpm");
assert_eq!(json.summary, "Summary text");
assert_eq!(json.git_context.as_deref(), Some("branch: main"));
assert_eq!(json.tmux_window.as_deref(), Some("main:2:@7"));
assert_eq!(json.source_file.as_deref(), Some("/tmp/session-1.md"));
}
#[test]
fn paused_session_to_json_maps_claude_mpm_fields() {
use crate::catchup::mpm_session::ClaudeMpmSession;
let session = ClaudeMpmSession {
resume_instructions: Some("Resume from step 3".to_string()),
todos: Some(vec!["todo 1".to_string()]),
open_questions: Some(vec!["q1".to_string()]),
git_context: Some("branch: main".to_string()),
..Default::default()
};
let json = paused_session_to_json(&PausedSession::ClaudeMpm { session });
assert_eq!(json.format, "claude-mpm");
assert_eq!(json.summary, "Resume from step 3");
assert_eq!(json.in_progress.as_deref(), Some("todo 1"));
assert_eq!(json.next_steps.as_deref(), Some("q1"));
assert!(json.tmux_window.is_none());
assert!(json.source_file.is_none());
}
}