Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions src-tauri/src/flat_term.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,10 @@ fn take_top_row(grid: &mut ActiveGrid) -> Vec<Cell> {
grid.cells[grid.scroll_top].clone()
}

/// Scrollback row cap for FlatTerm's primary-grid history. Finite by
/// design — see the comment in `FlatTerm::new`.
const SCROLLBACK_CAP_ROWS: usize = 100_000;

/// FlatTerm — the full terminal model. Owns:
/// - `primary` active grid (the default screen)
/// - `alt` active grid (swapped in on DECSET 1049)
Expand Down Expand Up @@ -315,18 +319,22 @@ impl FlatTerm {
Self {
primary: ActiveGrid::new(cols, rows),
alt: ActiveGrid::new(cols, rows),
// Unbounded scrollback — every row the agent ever produces
// stays accessible. FlatStorage::with_capacity is safe with
// usize::MAX because it decouples the cap from the initial
// Vec allocation; storage grows dynamically as rows arrive.
scrollback: FlatStorage::with_capacity(usize::MAX),
// Bounded scrollback. FlatStorage::with_capacity decouples
// the cap from the initial Vec allocation, so a large cap
// costs nothing until rows actually arrive — but the cap
// must be FINITE before FlatTerm replaces alacritty:
// usize::MAX here meant every agent pane retained every
// row it ever produced for the app's lifetime. 100k rows
// is days of continuous agent output; older rows live in
// the persisted block history, not the live grid.
scrollback: FlatStorage::with_capacity(SCROLLBACK_CAP_ROWS),
use_alt: false,
app_cursor: false,
bracketed_paste: false,
line_wrap: true,
saved_cursor_for_swap: None,
tab_stops,
scrollback_cap: usize::MAX,
scrollback_cap: SCROLLBACK_CAP_ROWS,
}
}

Expand Down
60 changes: 35 additions & 25 deletions src-tauri/src/persistence/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,12 @@
//! the next slice (add a v2 migration + a new `Event::Snapshot`
//! variant that fills those tables in a transaction, then drop the
//! blob).
//! - **Block history persistence (Warp-parity).** Closed blocks are
//! persisted in full and never evicted by count, so terminal history
//! is never cut off across restarts. `load_blocks` restores the full
//! transcript; the native renderer virtualizes painting so deep
//! history does not make each frame proportional to its row count.
//! - **Block history persistence.** Closed blocks persist across
//! restarts, capped per pty at `BLOCK_DISK_CAP` (writer.rs, 2× the
//! restore window) — rows past the cap were unreachable by any code
//! path (`load_blocks` is the table's only reader) and just grew the
//! DB file forever. `load_blocks` windows the most-recent rows for
//! fast restore.
//! - **No graceful shutdown.** The writer thread relies on macOS
//! tearing it down at app exit; WAL recovers any half-finished
//! transaction on next launch. If we add long-running async writes
Expand Down Expand Up @@ -115,14 +116,16 @@ pub struct SavedBlock {
pub duration_ms: Option<i64>,
}

/// Return every persisted block for a pty_id in insertion order
/// (oldest first), matching how the frontend's `sessionMemory.blocks`
/// array is ordered. There used to be a 500-block read window here with
/// a promise that older rows would page in on scroll-back, but no paging
/// path existed; the 501st-oldest block was therefore permanently
/// unreachable after a restart. The render path already virtualizes
/// deep transcripts, so restoring the source of truth in full is both
/// correct and bounded at paint time. Returns an empty vec for unknown
/// How many of the most-recent blocks `load_blocks` returns on restore.
/// Disk retains up to `BLOCK_DISK_CAP` (2× this) per pty — see
/// writer.rs. Kept in sync with the front-end's `MAX_BLOCKS` in
/// `sessionMemory.ts`.
const HISTORY_LOAD_WINDOW: i64 = 500;

/// Return the most-recent `HISTORY_LOAD_WINDOW` persisted blocks for a
/// pty_id in insertion order (oldest first), matching how the
/// frontend's `sessionMemory.blocks` array is ordered. Older blocks
/// stay on disk for scroll-back. Returns an empty vec for unknown
/// pty_ids — no error.
pub fn load_blocks(
db_path: &std::path::Path,
Expand All @@ -132,13 +135,19 @@ pub fn load_blocks(
.map_err(|e| format!("open RO at {}: {e}", db_path.display()))?;
let mut stmt = conn
.prepare(
// Window to the most-recent rows (newest-first inner, then
// re-sorted oldest-first). Restore stays fast even when a
// pty has accumulated huge history; older blocks remain on
// disk and page in on scroll-back.
"SELECT block_id, input, transcript, block_rows, \
exit_code, cwd, duration_ms \
FROM blocks WHERE pty_id = ?1 ORDER BY id ASC",
exit_code, cwd, duration_ms FROM (\
SELECT * FROM blocks WHERE pty_id = ?1 \
ORDER BY id DESC LIMIT ?2\
) ORDER BY id ASC",
)
.map_err(|e| format!("prepare load_blocks: {e}"))?;
let rows = stmt
.query_map(rusqlite::params![pty_id], |row| {
.query_map(rusqlite::params![pty_id, HISTORY_LOAD_WINDOW], |row| {
let rows_text: String = row.get(3)?;
// Stored as a known-shape JSON literal we wrote ourselves
// last save — parse failures here are real corruption and
Expand Down Expand Up @@ -383,14 +392,15 @@ mod tests {
}

#[test]
fn history_is_retained_and_restored_in_full() {
fn history_is_retained_on_disk_and_load_is_windowed() {
let (_dir, path) = fresh_db();
let conn = db::open_rw(&path).unwrap();
let w = writer::start(conn, path.clone());

// Insert past the old 500-block load window. Both storage and
// restore must remain complete: keeping old rows in SQLite is
// not useful if the UI can never load or scroll to them.
// Insert past the load window but under the disk cap
// (`BLOCK_DISK_CAP` = 1000). Nothing is evicted on save yet — the
// read path windows instead, so restore stays fast while recent
// history remains complete on disk.
const WINDOW: i64 = 500;
for i in 1..=(WINDOW + 5) {
w.save_block(mk_block(i, &format!("cmd-{i}")));
Expand All @@ -401,7 +411,7 @@ mod tests {
.unwrap_or(false)
});

// Every row is retained on disk — history is never cut off.
// All 505 rows are still on disk (below the 1000-row disk cap).
let total: i64 = db::open_ro(&path)
.unwrap()
.query_row(
Expand All @@ -410,12 +420,12 @@ mod tests {
|r| r.get(0),
)
.unwrap();
assert_eq!(total, WINDOW + 5, "history must be retained in full");
assert_eq!(total, WINDOW + 5, "recent history must be retained on disk");

// load_blocks returns the entire transcript, oldest-first.
// ...but load_blocks returns only the most-recent WINDOW, oldest-first.
let blocks = load_blocks(&path, "pty-A").unwrap();
assert_eq!(blocks.len(), (WINDOW + 5) as usize);
assert_eq!(blocks[0].block_id, 1);
assert_eq!(blocks.len(), WINDOW as usize);
assert_eq!(blocks[0].block_id, 6); // 1..=5 fall outside the window
assert_eq!(blocks.last().unwrap().block_id, WINDOW + 5);
}

Expand Down
24 changes: 21 additions & 3 deletions src-tauri/src/persistence/writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ use rusqlite::Connection;
/// queue."
const CAPACITY: usize = 1024;

/// Per-pty on-disk block cap: 2× the restore window
/// (`HISTORY_LOAD_WINDOW = 500` in `super::mod`), so restore always
/// has a full window even mid-eviction, while the table stops growing
/// without bound. Applied on every SaveBlock insert.
const BLOCK_DISK_CAP: i64 = 1000;

/// Messages the writer thread can process.
#[derive(Debug)]
enum Event {
Expand Down Expand Up @@ -277,9 +283,21 @@ fn apply(
now,
],
)?;
// Warp-parity: never evict by count. Full block history is
// retained on disk and restored in full; the native painter
// virtualizes deep transcripts.
// Per-pty eviction: keep the newest BLOCK_DISK_CAP rows.
// Restore only ever reads the most-recent
// HISTORY_LOAD_WINDOW (500) blocks per pty, so rows past
// 2× that window are unreachable by any code path — they
// only grew the DB file forever (full transcript + JSON
// grid per block, per pty, across restarts). The
// `blocks_by_pty (pty_id, id)` index makes both the
// subquery and the delete cheap.
tx.execute(
"DELETE FROM blocks WHERE pty_id = ?1 AND id NOT IN (\
SELECT id FROM blocks WHERE pty_id = ?1 \
ORDER BY id DESC LIMIT ?2\
)",
rusqlite::params![p.pty_id, BLOCK_DISK_CAP],
)?;
tx.commit()?;
}
Event::ForgetPty(pty_id) => {
Expand Down
Loading
Loading