use serde::Serialize;
use trusty_common::catchup::git::CommitSummary;
use trusty_common::catchup::{CatchupJson, PausedSessionJson, RecentMemoryJson};
pub const CATCHUP_BUDGET_BYTES: usize = 48_000;
const AUX_BUDGET_BYTES: usize = 12_000;
pub struct BoundedCatchup {
pub sessions: Vec<PausedSessionJson>,
pub sessions_total: usize,
pub sessions_offset: usize,
pub recent_commits: Vec<CommitSummary>,
pub recent_commits_total: usize,
pub recent_memory: Vec<RecentMemoryJson>,
pub recent_memory_total: usize,
pub page_bytes: usize,
pub budget: usize,
}
impl BoundedCatchup {
pub fn next_offset(&self) -> Option<usize> {
let next = self.sessions_offset + self.sessions.len();
(next < self.sessions_total).then_some(next)
}
pub fn truncated(&self) -> bool {
self.next_offset().is_some()
|| self.recent_commits.len() < self.recent_commits_total
|| self.recent_memory.len() < self.recent_memory_total
}
pub fn over_budget(&self) -> bool {
self.page_bytes > self.budget
}
pub fn truncation_notice(&self) -> Option<String> {
if !self.truncated() && !self.over_budget() {
return None;
}
let mut parts = Vec::new();
if let Some(next) = self.next_offset() {
parts.push(format!(
"Showing paused sessions {}–{} of {} — yours first, then newest \
first; re-call session_context_catchup with sessions_offset: \
{next} for the rest. The store is live: a snapshot paused \
between calls shifts the indexes, so a later page can repeat a \
record you have already seen.",
self.sessions_offset,
next.saturating_sub(1),
self.sessions_total
));
}
if self.recent_commits.len() < self.recent_commits_total {
parts.push(format!(
"Showing the {} newest of {} recent commits; the older ones were \
dropped to fit the response budget.",
self.recent_commits.len(),
self.recent_commits_total
));
}
if self.recent_memory.len() < self.recent_memory_total {
parts.push(format!(
"Showing {} of {} memory drawers; the rest were dropped to fit \
the response budget.",
self.recent_memory.len(),
self.recent_memory_total
));
}
if self.over_budget() {
parts.push(format!(
"This page is {} bytes, over the {}-byte budget: one record is \
larger than a whole page, so it ships intact rather than being \
cut mid-field. Paging cannot shrink it.",
self.page_bytes, self.budget
));
}
Some(parts.join(" "))
}
}
fn cost_of<T: Serialize>(item: &T, on_error: usize) -> usize {
serde_json::to_string(item).map_or(on_error, |s| s.len() + 1)
}
fn page<T: Serialize + Clone>(items: &[T], from: usize, budget: usize) -> Vec<T> {
let mut out = Vec::new();
let mut used = 0usize;
for item in items.iter().skip(from) {
let cost = cost_of(item, budget);
if !out.is_empty() && used.saturating_add(cost) > budget {
break;
}
used = used.saturating_add(cost);
out.push(item.clone());
}
out
}
pub fn bound_catchup(mut merged: CatchupJson, offset: usize, budget: usize) -> BoundedCatchup {
merged
.sessions
.sort_by_key(|s| (!s.owned, std::cmp::Reverse(s.paused_at)));
merged
.recent_commits
.sort_by_key(|c| std::cmp::Reverse(c.ts));
let aux_budget = AUX_BUDGET_BYTES.min(budget);
let recent_commits = page(&merged.recent_commits, 0, aux_budget);
let commit_bytes: usize = recent_commits.iter().map(|c| cost_of(c, 0)).sum();
let recent_memory = page(
&merged.recent_memory,
0,
aux_budget.saturating_sub(commit_bytes),
);
let memory_bytes: usize = recent_memory.iter().map(|d| cost_of(d, 0)).sum();
let sessions_offset = offset.min(merged.sessions.len());
let sessions = page(
&merged.sessions,
sessions_offset,
budget.saturating_sub(commit_bytes + memory_bytes),
);
let session_bytes: usize = sessions.iter().map(|s| cost_of(s, 0)).sum();
BoundedCatchup {
sessions,
sessions_total: merged.sessions.len(),
sessions_offset,
recent_commits_total: merged.recent_commits.len(),
recent_commits,
recent_memory_total: merged.recent_memory.len(),
recent_memory,
page_bytes: session_bytes + commit_bytes + memory_bytes,
budget,
}
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_common::catchup::{CatchupOptions, generate_catchup_json};
fn commit(msg_bytes: usize) -> CommitSummary {
CommitSummary {
sha: "0".repeat(40),
msg: "m".repeat(msg_bytes),
author: "a".to_string(),
ts: None,
}
}
async fn digest(n: usize, summary_bytes: usize) -> CatchupJson {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join(".trusty-mpm").join("sessions");
std::fs::create_dir_all(&dir).unwrap();
let body = "x".repeat(summary_bytes);
for i in 0..n {
std::fs::write(
dir.join(format!("session-20260801-12{:02}{:02}.md", i / 60, i % 60)),
format!("## Summary\n{i:04}-{body}\n"),
)
.unwrap();
}
generate_catchup_json(&CatchupOptions {
project_dir: tmp.path().to_path_buf(),
memory_socket: std::path::PathBuf::from("/nonexistent/trusty-memory.sock"),
include_git: false,
include_palace: false,
git_limit: 50,
drawer_limit: 15,
full: true,
})
.await
}
#[test]
fn page_stops_at_the_budget() {
let items: Vec<String> = (0..20).map(|_| "y".repeat(100)).collect();
let taken = page(&items, 0, 500);
assert!(
!taken.is_empty() && taken.len() < items.len(),
"took {} of {}",
taken.len(),
items.len()
);
let bytes: usize = taken.iter().map(|s| s.len() + 3).sum();
assert!(
bytes <= 500 + 103,
"page spent {bytes} against a 500 budget"
);
}
#[test]
fn page_always_makes_progress() {
let items = vec!["z".repeat(10_000), "z".repeat(10_000)];
let taken = page(&items, 0, 10);
assert_eq!(taken.len(), 1, "the first record is unconditional");
assert_eq!(taken[0].len(), 10_000, "and is not clamped mid-string");
}
#[tokio::test]
async fn bound_catchup_leaves_a_fitting_digest_alone() {
let merged = CatchupJson {
recent_commits: (0..5).map(|_| commit(40)).collect(),
recent_memory: vec![RecentMemoryJson {
title: "t".to_string(),
tags: vec!["a".to_string()],
}],
undatable_sessions_dropped: 3,
..digest(4, 200).await
};
let before = (
serde_json::to_string(&merged.sessions).unwrap(),
serde_json::to_string(&merged.recent_commits).unwrap(),
serde_json::to_string(&merged.recent_memory).unwrap(),
);
let bounded = bound_catchup(merged, 0, CATCHUP_BUDGET_BYTES);
assert_eq!(serde_json::to_string(&bounded.sessions).unwrap(), before.0);
assert_eq!(
serde_json::to_string(&bounded.recent_commits).unwrap(),
before.1
);
assert_eq!(
serde_json::to_string(&bounded.recent_memory).unwrap(),
before.2
);
assert!(!bounded.truncated());
assert!(bounded.truncation_notice().is_none());
assert!(bounded.next_offset().is_none());
}
#[tokio::test]
async fn bound_catchup_trims_commits_into_their_share() {
let merged = CatchupJson {
recent_commits: (0..400).map(|_| commit(120)).collect(),
..digest(3, 200).await
};
let bounded = bound_catchup(merged, 0, CATCHUP_BUDGET_BYTES);
assert!(bounded.recent_commits.len() < 400);
assert_eq!(bounded.recent_commits_total, 400);
assert_eq!(bounded.sessions.len(), 3, "sessions keep their own share");
let commit_bytes = serde_json::to_string(&bounded.recent_commits)
.unwrap()
.len();
assert!(
commit_bytes <= AUX_BUDGET_BYTES + 200,
"commits spent {commit_bytes}"
);
assert!(bounded.truncated());
}
#[tokio::test]
async fn paging_reaches_every_session_in_order() {
let merged = digest(40, 4_000).await;
let expected: Vec<String> = merged.sessions.iter().map(|s| s.summary.clone()).collect();
let mut seen: Vec<String> = Vec::new();
let mut offset = Some(0usize);
let mut pages = 0;
while let Some(o) = offset {
let bounded = bound_catchup(merged.clone(), o, CATCHUP_BUDGET_BYTES);
assert!(!bounded.sessions.is_empty(), "page at {o} made no progress");
seen.extend(bounded.sessions.iter().map(|s| s.summary.clone()));
offset = bounded.next_offset();
pages += 1;
assert!(pages < 100, "paging did not terminate");
}
assert!(pages > 1, "the fixture must actually need paging");
assert_eq!(seen, expected);
}
#[tokio::test]
async fn truncation_notice_names_the_counts_and_the_recovery() {
let bounded = bound_catchup(digest(40, 4_000).await, 0, CATCHUP_BUDGET_BYTES);
let notice = bounded.truncation_notice().expect("must announce the drop");
let next = bounded.next_offset().unwrap();
assert!(notice.contains("40"), "names the total: {notice}");
assert!(
notice.contains(&format!("sessions_offset: {next}")),
"names the recovery: {notice}"
);
}
#[tokio::test]
async fn over_budget_record_is_announced_even_though_nothing_was_dropped() {
let bounded = bound_catchup(digest(1, 80_000).await, 0, CATCHUP_BUDGET_BYTES);
assert_eq!(bounded.sessions.len(), 1, "the record still ships whole");
assert!(
!bounded.truncated(),
"nothing was withheld, so not truncated"
);
assert!(bounded.over_budget(), "but the page is over budget");
assert!(bounded.page_bytes > CATCHUP_BUDGET_BYTES);
let notice = bounded
.truncation_notice()
.expect("an over-budget page must still announce itself");
assert!(
notice.contains(&bounded.page_bytes.to_string()),
"the notice must name the size: {notice}"
);
}
#[tokio::test]
async fn bound_catchup_puts_owned_sessions_on_page_zero() {
let mut merged = digest(30, 4_000).await;
let oldest = merged.sessions.len() - 1;
for (i, s) in merged.sessions.iter_mut().enumerate() {
s.owned = i == oldest;
s.summary = format!("{i}-{}", s.summary);
}
let mine = merged.sessions[oldest].summary.clone();
let bounded = bound_catchup(merged, 0, CATCHUP_BUDGET_BYTES);
assert!(
bounded.next_offset().is_some(),
"the fixture must actually need paging"
);
assert_eq!(
bounded.sessions.first().map(|s| s.summary.clone()),
Some(mine),
"the caller's own session must lead page 0"
);
}
#[tokio::test]
async fn bound_catchup_clamps_an_out_of_range_offset() {
let bounded = bound_catchup(digest(3, 100).await, 99, CATCHUP_BUDGET_BYTES);
assert!(bounded.sessions.is_empty());
assert_eq!(bounded.sessions_offset, 3);
assert_eq!(bounded.sessions_total, 3);
assert!(bounded.next_offset().is_none());
assert!(!bounded.truncated());
}
}