use std::path::PathBuf;
use std::sync::Arc;
use serde_json::{Value, json};
use crate::core::catchup::{CatchupOptions, generate_catchup_json, session_finder};
use crate::daemon::state::DaemonState;
fn catchup_payload(
merged: &trusty_common::catchup::CatchupJson,
resolved_snapshot: Option<String>,
) -> Value {
json!({
"sessions": merged.sessions,
"recent_commits": merged.recent_commits,
"recent_memory": merged.recent_memory,
"resolved_snapshot": resolved_snapshot,
"undatable_sessions_dropped": merged.undatable_sessions_dropped,
"watermark_advanced": false,
})
}
pub async fn session_context_catchup(
project_dir: &str,
session_id: Option<&str>,
all_projects: bool,
full: bool,
) -> Result<Value, String> {
let primary = PathBuf::from(project_dir);
if !primary.is_dir() {
return Err(format!(
"project_dir does not exist or is not a directory: {project_dir}"
));
}
let mut project_dirs = vec![primary.clone()];
if all_projects {
let registry = crate::core::claude_mpm_registry::default_registry_path();
match crate::core::claude_mpm_registry::discover_claude_mpm_projects(®istry) {
Ok(extra) => {
for p in extra {
if !project_dirs.contains(&p) {
project_dirs.push(p);
}
}
}
Err(e) => {
eprintln!(
"session_context_catchup: warning: could not read claude-mpm registry: {e}"
);
}
}
}
let config = crate::core::config::MpmConfig::load_default();
let memory_url = trusty_common::mcp::memory_rpc::resolve_memory_base_url_or_unreachable();
let mut merged = trusty_common::catchup::CatchupJson::default();
for dir in &project_dirs {
let opts = CatchupOptions {
project_dir: dir.clone(),
memory_url: memory_url.clone(),
include_git: config.catchup.include_git,
include_palace: config.catchup.include_palace,
git_limit: config.catchup.git_limit,
drawer_limit: config.catchup.drawer_limit,
full,
};
merged.absorb(generate_catchup_json(&opts).await);
}
let resolved_snapshot = session_finder::latest_trusty_mpm_snapshot(&primary, session_id)
.map(|p| p.display().to_string());
Ok(catchup_payload(&merged, resolved_snapshot))
}
#[allow(clippy::too_many_arguments)]
pub async fn session_context_pause(
state: &Arc<DaemonState>,
project_dir: &str,
session_id: &str,
summary: &str,
completed: Vec<String>,
in_progress: Vec<String>,
next_steps: Vec<String>,
tmux_window: Option<&str>,
prune_worktrees: bool,
) -> Result<Value, String> {
let project_path = PathBuf::from(project_dir);
if !project_path.is_dir() {
return Err(format!(
"project_dir does not exist or is not a directory: {project_dir}"
));
}
if summary.trim().is_empty() {
return Err("`summary` must not be empty".to_string());
}
let input = trusty_common::catchup::pause::PauseSnapshotInput {
session_id,
summary,
completed: &completed,
in_progress: &in_progress,
next_steps: &next_steps,
tmux_window,
};
let outcome = trusty_common::catchup::pause::write_pause_snapshot(&project_path, &input)
.map_err(|e| format!("failed to write pause snapshot: {e}"))?;
let mut skipped_dirty = Vec::new();
let pruned_worktrees: Vec<String> = if prune_worktrees {
let mgr = state.session_manager().await;
let records = mgr.list().await;
let active_workspace_paths: Vec<PathBuf> = records
.iter()
.filter_map(|r| r.workspace_path.clone())
.collect();
let tt_config = crate::core::trusty_tools_config::TrustyToolsConfig::load();
let repos_root = crate::core::trusty_tools_config::workspace_root(&tt_config);
match mgr
.prune_orphaned_worktrees(
&repos_root,
&active_workspace_paths,
false,
crate::session_manager::DirtyWorktreePolicy::Skip,
)
.await
{
Ok(sweep) => {
skipped_dirty = sweep.skipped_dirty;
sweep
.removed
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect()
}
Err(e) => {
tracing::warn!("session_context_pause: worktree prune failed: {e}");
Vec::new()
}
}
} else {
Vec::new()
};
Ok(json!({
"snapshot_path": outcome.snapshot_path.display().to_string(),
"timestamp": outcome.timestamp.to_rfc3339(),
"pruned_worktrees": pruned_worktrees,
"skipped_dirty_worktrees": skipped_dirty,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn session_context_catchup_missing_project_dir_errors() {
let err = session_context_catchup("/nonexistent/does/not/exist", None, false, true)
.await
.unwrap_err();
assert!(err.contains("project_dir"), "{err}");
}
#[tokio::test]
async fn session_context_catchup_returns_expected_shape() {
let tmp = tempfile::TempDir::new().unwrap();
let run = |args: &[&str]| {
std::process::Command::new("git")
.arg("-C")
.arg(tmp.path())
.args(args)
.output()
.unwrap();
};
run(&["init"]);
run(&["config", "user.email", "t@t.com"]);
run(&["config", "user.name", "T"]);
std::fs::write(tmp.path().join("a.txt"), b"a").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "init"]);
let result = session_context_catchup(tmp.path().to_str().unwrap(), None, false, true)
.await
.unwrap();
assert_eq!(result["watermark_advanced"], false);
assert!(result["sessions"].is_array());
assert!(result["recent_commits"].is_array());
assert!(result["recent_memory"].is_array());
assert!(result["resolved_snapshot"].is_null());
assert_eq!(result["undatable_sessions_dropped"], 0);
}
#[test]
fn catchup_payload_carries_the_undatable_drop_count() {
let merged = trusty_common::catchup::CatchupJson {
undatable_sessions_dropped: 4,
..Default::default()
};
let body = catchup_payload(&merged, Some("/tmp/snap.md".to_string()));
assert_eq!(
body["undatable_sessions_dropped"], 4,
"the withheld count must reach the wire: {body}"
);
assert_eq!(body["resolved_snapshot"], "/tmp/snap.md");
assert_eq!(body["watermark_advanced"], false);
}
#[tokio::test]
async fn session_context_catchup_never_resolves_another_sessions_snapshot() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let state = DaemonState::shared();
let session_a = "2eb72dca-de08-481b-8dfa-22ab7f81b1f9";
let session_b = "7bd5c27a-475b-41df-9e9f-a6f630801717";
let paused = session_context_pause(
&state,
dir,
session_a,
"Session A's work.",
vec![],
vec![],
vec![],
None,
false,
)
.await
.unwrap();
let a_snapshot = paused["snapshot_path"].as_str().unwrap().to_string();
let for_b = session_context_catchup(dir, Some(session_b), false, true)
.await
.unwrap();
assert!(
for_b["resolved_snapshot"].is_null(),
"B must not be handed A's snapshot: {}",
for_b["resolved_snapshot"]
);
let for_a = session_context_catchup(dir, Some(session_a), false, true)
.await
.unwrap();
assert_eq!(for_a["resolved_snapshot"], a_snapshot);
let anonymous = session_context_catchup(dir, None, false, true)
.await
.unwrap();
assert!(anonymous["resolved_snapshot"].is_null());
}
#[tokio::test]
async fn session_context_pause_missing_project_dir_errors() {
let state = DaemonState::shared();
let err = session_context_pause(
&state,
"/nonexistent/does/not/exist",
"s1",
"summary",
vec![],
vec![],
vec![],
None,
false,
)
.await
.unwrap_err();
assert!(err.contains("project_dir"), "{err}");
}
#[tokio::test]
async fn session_context_pause_requires_summary() {
let tmp = tempfile::TempDir::new().unwrap();
let state = DaemonState::shared();
let err = session_context_pause(
&state,
tmp.path().to_str().unwrap(),
"s1",
" ",
vec![],
vec![],
vec![],
None,
false,
)
.await
.unwrap_err();
assert!(err.contains("summary"), "{err}");
}
#[tokio::test]
async fn session_context_pause_writes_snapshot_without_pruning() {
let tmp = tempfile::TempDir::new().unwrap();
let state = DaemonState::shared();
let result = session_context_pause(
&state,
tmp.path().to_str().unwrap(),
"s1",
"Did the thing.",
vec![],
vec![],
vec!["ship it".to_string()],
None,
false,
)
.await
.unwrap();
assert!(result["snapshot_path"].as_str().unwrap().ends_with(".md"));
assert_eq!(result["pruned_worktrees"], json!([]));
let sessions =
crate::core::catchup::session_finder::find_paused_sessions(tmp.path()).unwrap();
assert_eq!(sessions.len(), 1, "the written snapshot should round-trip");
}
}