use std::path::PathBuf;
use std::sync::Arc;
use serde_json::{Value, json};
use crate::core::catchup::resolve::{
CallerIdentity, ResolvedSnapshot, redact_sessions_not_owned_by, resolve_snapshot_for_caller,
};
use crate::core::catchup::{CatchupOptions, generate_catchup_json};
use crate::daemon::catchup_bounds::{CATCHUP_BUDGET_BYTES, bound_catchup};
use crate::daemon::state::DaemonState;
fn catchup_payload(
merged: trusty_common::catchup::CatchupJson,
sessions_offset: usize,
resolved: Option<ResolvedSnapshot>,
) -> Value {
let (snapshot, via) = match resolved {
Some(r) => (Some(r.path.display().to_string()), Some(r.via.as_str())),
None => (None, None),
};
let undatable_sessions_dropped = merged.undatable_sessions_dropped;
let page = bound_catchup(merged, sessions_offset, CATCHUP_BUDGET_BYTES);
json!({
"sessions": page.sessions,
"sessions_total": page.sessions_total,
"sessions_offset": page.sessions_offset,
"sessions_next_offset": page.next_offset(),
"recent_commits": page.recent_commits,
"recent_commits_total": page.recent_commits_total,
"recent_memory": page.recent_memory,
"recent_memory_total": page.recent_memory_total,
"truncated": page.truncated(),
"over_budget": page.over_budget(),
"page_bytes": page.page_bytes,
"truncation_notice": page.truncation_notice(),
"resolved_snapshot": snapshot,
"resolved_via": via,
"undatable_sessions_dropped": undatable_sessions_dropped,
"watermark_advanced": false,
})
}
pub async fn session_context_catchup(
project_dir: &str,
session_id: Option<&str>,
tmux_window: Option<&str>,
all_projects: bool,
full: bool,
sessions_offset: usize,
) -> 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::memory_rpc::resolve_memory_base_url_or_unreachable();
let mut merged = trusty_common::catchup::CatchupJson::default();
let caller = CallerIdentity::new(session_id, tmux_window);
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,
};
let mut digest = generate_catchup_json(&opts).await;
redact_sessions_not_owned_by(dir, &caller, &mut digest.sessions);
merged.absorb(digest);
}
let resolved = resolve_snapshot_for_caller(&primary, session_id, tmux_window);
Ok(catchup_payload(merged, sessions_offset, resolved))
}
#[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, None, false, true, 0)
.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, None, false, true, 0)
.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!(result["resolved_via"].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 resolved = ResolvedSnapshot::new(
PathBuf::from("/tmp/snap.md"),
crate::core::catchup::resolve::ResolutionPath::TmuxWindow,
);
let body = catchup_payload(merged, 0, Some(resolved));
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["resolved_via"], "tmux_window",
"a fallback must never be presented as an exact match: {body}"
);
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), None, false, true, 0)
.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), None, false, true, 0)
.await
.unwrap();
assert_eq!(for_a["resolved_snapshot"], a_snapshot);
assert_eq!(for_a["resolved_via"], "session_id");
let anonymous = session_context_catchup(dir, None, None, false, true, 0)
.await
.unwrap();
assert!(anonymous["resolved_snapshot"].is_null());
}
#[tokio::test]
async fn session_context_catchup_resolves_by_tmux_window_after_a_relaunch() {
let tmp = tempfile::TempDir::new().unwrap();
let other = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let state = DaemonState::shared();
let window = "tm-dogfood:0:@230";
let paused = session_context_pause(
&state,
dir,
"e262f4c5-d309-4203-ad3b-e0c29084d87e",
"Work from the previous incarnation.",
vec![],
vec![],
vec![],
Some(window),
false,
)
.await
.unwrap();
let snapshot = paused["snapshot_path"].as_str().unwrap().to_string();
let relaunched = session_context_catchup(
dir,
Some("69895d04-149d-4c31-a640-29048831f9a5"),
Some(window),
false,
true,
0,
)
.await
.unwrap();
assert_eq!(relaunched["resolved_snapshot"], snapshot);
assert_eq!(relaunched["resolved_via"], "tmux_window");
let elsewhere = session_context_catchup(
other.path().to_str().unwrap(),
Some("69895d04-149d-4c31-a640-29048831f9a5"),
Some(window),
false,
true,
0,
)
.await
.unwrap();
assert!(
elsewhere["resolved_snapshot"].is_null(),
"another project's snapshot must not resolve: {}",
elsewhere["resolved_snapshot"]
);
for bad in ["", "tm-dogfood", "tm-dogfood:0"] {
let malformed =
session_context_catchup(dir, Some("never-paused"), Some(bad), false, true, 0)
.await
.unwrap();
assert!(
malformed["resolved_snapshot"].is_null(),
"{bad:?} must resolve nothing"
);
assert!(malformed["resolved_via"].is_null());
}
}
fn summaries(body: &Value) -> Vec<String> {
body["sessions"]
.as_array()
.unwrap()
.iter()
.map(|s| s["summary"].as_str().unwrap_or_default().to_string())
.collect()
}
fn seed_snapshots(project: &std::path::Path, n: usize, body_bytes: usize) {
let dir = project.join(".trusty-mpm").join("sessions");
std::fs::create_dir_all(&dir).unwrap();
let body = "x".repeat(body_bytes);
for i in 0..n {
std::fs::write(
dir.join(format!("session-20260801-12{:02}{:02}.md", i / 60, i % 60)),
format!("## Summary\norig{i:04}-{body}\n\n## Next Steps\n{body}\n"),
)
.unwrap();
}
}
#[tokio::test]
async fn catchup_payload_bounds_an_oversized_store() {
let tmp = tempfile::TempDir::new().unwrap();
seed_snapshots(tmp.path(), 25, 6_000);
let result =
session_context_catchup(tmp.path().to_str().unwrap(), None, None, false, true, 0)
.await
.unwrap();
let fixture_record = 6_000 + 512;
let len = serde_json::to_string(&result).unwrap().len();
assert!(
len <= CATCHUP_BUDGET_BYTES + fixture_record,
"response is {len} chars against a {CATCHUP_BUDGET_BYTES}-byte budget"
);
assert_eq!(result["over_budget"], false);
assert!(
!result["sessions"].as_array().unwrap().is_empty(),
"a bound that returns nothing is not a bound: {result}"
);
}
#[tokio::test]
async fn catchup_payload_announces_what_it_withheld() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
seed_snapshots(tmp.path(), 25, 6_000);
let first = session_context_catchup(dir, None, None, false, true, 0)
.await
.unwrap();
assert_eq!(first["truncated"], true);
assert_eq!(first["sessions_total"], 25);
assert_eq!(first["sessions_offset"], 0);
let notice = first["truncation_notice"].as_str().unwrap().to_string();
assert!(notice.contains("25"), "names the total: {notice}");
let mut seen = first["sessions"].as_array().unwrap().len();
let mut next = first["sessions_next_offset"].as_u64();
assert!(
notice.contains(&format!("sessions_offset: {}", next.unwrap())),
"names the recovery: {notice}"
);
let mut pages = 1;
while let Some(offset) = next {
let body = session_context_catchup(dir, None, None, false, true, offset as usize)
.await
.unwrap();
assert_eq!(body["sessions_offset"], offset);
let n = body["sessions"].as_array().unwrap().len();
assert!(n > 0, "page at {offset} made no progress: {body}");
seen += n;
next = body["sessions_next_offset"].as_u64();
pages += 1;
assert!(pages < 50, "paging did not terminate");
}
assert!(pages > 1, "the fixture must actually need paging");
assert_eq!(seen, 25, "`full` must still deliver the whole history");
}
#[tokio::test]
async fn a_snapshot_written_mid_walk_repeats_a_record_but_never_drops_one() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
seed_snapshots(tmp.path(), 25, 6_000);
let first = session_context_catchup(dir, None, None, false, true, 0)
.await
.unwrap();
let page0: Vec<String> = summaries(&first);
let next = first["sessions_next_offset"].as_u64().unwrap() as usize;
std::fs::write(
tmp.path()
.join(".trusty-mpm")
.join("sessions")
.join("session-20260801-235959.md"),
format!("## Summary\ninjected-{}\n", "n".repeat(6_000)),
)
.unwrap();
let mut seen = page0.clone();
let mut offset = Some(next);
while let Some(o) = offset {
let body = session_context_catchup(dir, None, None, false, true, o)
.await
.unwrap();
seen.extend(summaries(&body));
offset = body["sessions_next_offset"].as_u64().map(|v| v as usize);
}
let originals: std::collections::HashSet<&String> =
seen.iter().filter(|s| s.starts_with("orig")).collect();
assert_eq!(
originals.len(),
25,
"an index shift must not drop a record; got {} distinct of 25",
originals.len()
);
assert!(
seen.len() > originals.len(),
"the shift is expected to repeat at least one record, so the \
disclosure describes something real"
);
}
#[tokio::test]
async fn catchup_payload_leaves_a_normal_store_whole() {
let tmp = tempfile::TempDir::new().unwrap();
seed_snapshots(tmp.path(), 3, 200);
let result =
session_context_catchup(tmp.path().to_str().unwrap(), None, None, false, true, 0)
.await
.unwrap();
assert_eq!(result["sessions"].as_array().unwrap().len(), 3);
assert_eq!(result["sessions_total"], 3);
assert_eq!(result["truncated"], false);
assert!(result["truncation_notice"].is_null());
assert!(result["sessions_next_offset"].is_null());
}
#[tokio::test]
async fn session_context_catchup_withholds_a_non_owners_handles() {
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 window = "tm-dogfood:0:@230";
let paused = session_context_pause(
&state,
dir,
session_a,
"Session A's work.",
vec![],
vec!["halfway through X".to_string()],
vec!["finish X".to_string()],
Some(window),
false,
)
.await
.unwrap();
let a_snapshot = paused["snapshot_path"].as_str().unwrap().to_string();
let for_b = session_context_catchup(dir, Some(session_b), None, false, true, 0)
.await
.unwrap();
let listed = &for_b["sessions"][0];
assert!(
listed["source_file"].is_null(),
"B must not receive A's snapshot path: {listed}"
);
assert!(
listed["tmux_window"].is_null(),
"B must not receive A's window — that is the value it would hand back: {listed}"
);
assert_eq!(listed["owned"], false);
assert_eq!(listed["in_progress"], Value::Null);
assert_eq!(listed["next_steps"], Value::Null);
assert_eq!(listed["summary"], "Session A's work.");
let body = for_b.to_string();
assert!(
!body.contains("@230"),
"nothing in B's response may spell A's window id: {body}"
);
assert!(
!body.contains(&a_snapshot),
"nothing in B's response may spell A's snapshot path: {body}"
);
let for_a = session_context_catchup(dir, Some(session_a), None, false, true, 0)
.await
.unwrap();
let own = &for_a["sessions"][0];
assert_eq!(own["owned"], true);
assert_eq!(own["source_file"], a_snapshot);
assert_eq!(own["tmux_window"], window);
assert_eq!(own["next_steps"], "- finish X");
}
#[tokio::test]
async fn session_context_catchup_digest_agrees_with_the_window_fallback() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let state = DaemonState::shared();
let window = "tm-dogfood:0:@230";
let paused = session_context_pause(
&state,
dir,
"e262f4c5-d309-4203-ad3b-e0c29084d87e",
"Work from the previous incarnation.",
vec![],
vec![],
vec![],
Some(window),
false,
)
.await
.unwrap();
let snapshot = paused["snapshot_path"].as_str().unwrap().to_string();
let relaunched = session_context_catchup(
dir,
Some("69895d04-149d-4c31-a640-29048831f9a5"),
Some(window),
false,
true,
0,
)
.await
.unwrap();
assert_eq!(relaunched["resolved_snapshot"], snapshot);
assert_eq!(relaunched["resolved_via"], "tmux_window");
assert_eq!(
relaunched["sessions"][0]["owned"], true,
"the row resolved_snapshot points at must not be redacted: {}",
relaunched["sessions"][0]
);
assert_eq!(relaunched["sessions"][0]["source_file"], snapshot);
}
#[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");
}
}