Files
zesdex/docs/superpowers/plans/2026-07-16-cms-conversation-blob-wiring.md
T

28 KiB

CMS Wiring: Conversation + Rewind Blob Store Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the SQLite-backed messages/archives/blobs tables (crates/zesdex-backend/src/model/msglog/) with zesdex-cms's Conversation/JsonConversationRepository for message content, plus a brand-new file-based blob repository for rewind snapshots (no zesdex-cms equivalent existed before this plan). Fix the ChatMessage type collision between zesdex-cms's local duplicate and the canonical zesdex_entities::seaorm::common::message::ChatMessage used everywhere else in the codebase.

Architecture: Research confirmed the messages table is write-only today (archived but never read back to restore a session) and the archives table is created but never populated by any code path — both can be retired with zero behavior loss. The blobs table is the one genuinely load-bearing piece (rewind feature reads it back) and needs a real, tested replacement — a new RewindBlobRepository trait + FileRewindBlobRepository impl added to zesdex-cms, storing raw bytes as <session_dir>/blobs/<hex(key)>.bin plus an append-only <session_dir>/blobs/index.jsonl for key/mime_type/ordering metadata (mirroring the JSONL-index pattern zesdex-cms's own EditLogRepository already uses).

Tech Stack: Rust, Cargo workspace (zesdex-cms, zesdex-backend, zesdex-entities).

Global Constraints

  • No #[allow(...)] additions beyond what's already in touched files.
  • Tests are inline #[cfg(test)] mod tests.
  • Run cargo test --workspace and cargo clippy --workspace --all-targets -- -D warnings before each commit.
  • This plan deliberately does not attempt to migrate historical data out of any existing messages.sqlite files — since the messages/archives tables were never read back by any code path, there is nothing meaningful to migrate. Existing messages.sqlite files are simply left on disk, unused, after this plan (a future cleanup could delete them, but doing so isn't required for correctness).

Task 1: Reconcile the ChatMessage/Role type collision in zesdex-cms

Context: crates/zesdex-cms/src/domain/conversation.rs currently defines its own Role/ChatMessage (with tool_calls: Option<Vec<serde_json::Value>>, untyped) instead of reusing zesdex_entities::seaorm::common::message::{Role, ChatMessage} (the canonical type used throughout zesdex-backend, with tool_calls: Option<Vec<ToolCall>>, strongly typed). zesdex-cms already depends on zesdex-entities (confirmed in Cargo.toml), so this is a small, surgical fix.

Files:

  • Modify: crates/zesdex-cms/src/domain/conversation.rs
  • Modify: crates/zesdex-cms/src/application/conversation_service.rs (import path only)
  • Modify: crates/zesdex-cms/src/domain/service.rs (import path only)

Interfaces:

  • Produces: zesdex_cms::domain::conversation::{Conversation, ChatMessage, Role} where ChatMessage/Role are now re-exports of the canonical entities type — anything constructing a zesdex_cms::domain::conversation::ChatMessage is now interchangeable with zesdex_entities::seaorm::common::message::ChatMessage used elsewhere in zesdex-backend.

  • Step 1: Write the failing test proving type interchangeability

Add to crates/zesdex-cms/src/domain/conversation.rs's #[cfg(test)] mod tests (create if absent):

    #[test]
    fn chat_message_is_the_canonical_entities_type() {
        // This is a compile-time proof more than a runtime assertion: if
        // `zesdex_cms::domain::conversation::ChatMessage` were still a
        // distinct local type, this line would fail to compile.
        let canonical = zesdex_entities::seaorm::common::message::ChatMessage::user("hi");
        let via_cms: ChatMessage = canonical;
        assert_eq!(via_cms.content.as_deref(), Some("hi"));
    }
  • Step 2: Run the test to verify it fails

Run: cargo test -p zesdex-cms chat_message_is_the_canonical -- --nocapture Expected: compile error — serde_json::Value (cms's old tool_calls field type) vs ChatMessage::user's type won't unify, or a straightforward type mismatch.

  • Step 3: Replace the local Role/ChatMessage with re-exports

In crates/zesdex-cms/src/domain/conversation.rs, delete the entire local Role enum and ChatMessage struct + impl block (the definitions, constructors user/assistant/system/tool), and replace the top of the file with:

//! Pure Conversation entity — in-memory message history plus system prompt
//! and LLM generation parameters.
//!
//! # Architecture
//! This is a pure data structure with **no I/O logic**.  Load/save
//! responsibilities live in [`ConversationRepository`](super::repository::ConversationRepository).
//!
//! `ChatMessage`/`Role` are re-exported from `zesdex-entities` rather than
//! duplicated here, so a `Conversation` built by this crate is
//! interchangeable with the `ChatMessage` type used throughout
//! `zesdex-backend`'s provider/tool-execution layer.

#![allow(
    clippy::cast_possible_truncation,
    clippy::cast_sign_loss,
    clippy::cast_precision_loss,
    clippy::cast_possible_wrap
)]

use serde::{Deserialize, Serialize};

pub use zesdex_entities::seaorm::common::message::{ChatMessage, Role};

Keep the Conversation struct, impl Conversation block (new, push, rebuild_system, to_api_messages, len, is_empty) unchanged below this — they only reference ChatMessage/Role by name, which now resolve to the re-exported canonical types.

  • Step 4: Fix any now-broken references in the same crate

Run: cargo build -p zesdex-cms 2>&1 | head -60

If application/conversation_service.rs or domain/service.rs import ChatMessage/Role via use super::conversation::{ChatMessage, Conversation} or similar — these continue to work unchanged since the names are still exported from domain::conversation, just backed by a different underlying type now. Only fix compile errors that actually appear; do not preemptively touch files the build doesn't flag.

  • Step 5: Run the test to verify it passes

Run: cargo test -p zesdex-cms chat_message_is_the_canonical -- --nocapture Expected: pass.

  • Step 6: Run the crate's full test suite and clippy

Run: cargo test -p zesdex-cms && cargo clippy -p zesdex-cms -- -D warnings Expected: all pass.

  • Step 7: Commit
git add crates/zesdex-cms
git commit -m "fix(cms): satukan ChatMessage/Role Conversation dengan tipe kanonik zesdex-entities"

Task 2: Add RewindBlobRepository to zesdex-cms

Files:

  • Modify: crates/zesdex-cms/src/domain/repository.rs
  • Create: crates/zesdex-cms/src/infrastructure/persistence/rewind_blob_repo.rs
  • Modify: crates/zesdex-cms/src/infrastructure/persistence/mod.rs
  • Modify: crates/zesdex-cms/Cargo.toml (add hex and chrono if not already present — chrono is already a dependency per the crate's existing Cargo.toml; confirm hex with grep hex crates/zesdex-cms/Cargo.toml and add hex.workspace = true if missing)

Interfaces:

  • Produces: pub trait RewindBlobRepository { fn store_blob(&self, session_dir: &Path, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> Result<()>; fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> Result<Option<Vec<u8>>>; fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>>; } and pub struct FileRewindBlobRepository — used by Task 4.

  • Step 1: Add the trait

In crates/zesdex-cms/src/domain/repository.rs, add:

/// Repository for rewind-snapshot binary blobs, keyed by an arbitrary
/// caller-supplied key (e.g. a tool-call id) within a session.
pub trait RewindBlobRepository {
    /// Store (or overwrite) a blob under `blob_key` for this session.
    fn store_blob(&self, session_dir: &Path, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> anyhow::Result<()>;

    /// Retrieve a blob's bytes by key, or `None` if not found.
    fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> anyhow::Result<Option<Vec<u8>>>;

    /// List all blob keys for this session, oldest first.
    fn list_blob_keys(&self, session_dir: &Path) -> anyhow::Result<Vec<String>>;
}
  • Step 2: Write the failing tests

Create crates/zesdex-cms/src/infrastructure/persistence/rewind_blob_repo.rs with:

#[cfg(test)]
mod tests {
    use super::*;

    fn tmp_dir() -> std::path::PathBuf {
        let dir = std::env::temp_dir().join(format!("zesdex-cms-blob-test-{}", uuid::Uuid::new_v4()));
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn store_and_retrieve_roundtrip() {
        let dir = tmp_dir();
        let repo = FileRewindBlobRepository::new();
        repo.store_blob(&dir, "tool-call-1", b"hello world", Some("text/plain")).unwrap();
        let bytes = repo.retrieve_blob(&dir, "tool-call-1").unwrap();
        assert_eq!(bytes, Some(b"hello world".to_vec()));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn retrieve_missing_key_returns_none() {
        let dir = tmp_dir();
        let repo = FileRewindBlobRepository::new();
        assert_eq!(repo.retrieve_blob(&dir, "no-such-key").unwrap(), None);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn list_blob_keys_returns_oldest_first() {
        let dir = tmp_dir();
        let repo = FileRewindBlobRepository::new();
        repo.store_blob(&dir, "first", b"a", None).unwrap();
        std::thread::sleep(std::time::Duration::from_millis(5));
        repo.store_blob(&dir, "second", b"b", None).unwrap();
        let keys = repo.list_blob_keys(&dir).unwrap();
        assert_eq!(keys, vec!["first".to_string(), "second".to_string()]);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn overwriting_a_key_keeps_only_the_latest_entry_in_the_listing() {
        let dir = tmp_dir();
        let repo = FileRewindBlobRepository::new();
        repo.store_blob(&dir, "k", b"v1", None).unwrap();
        repo.store_blob(&dir, "k", b"v2", None).unwrap();
        let keys = repo.list_blob_keys(&dir).unwrap();
        assert_eq!(keys, vec!["k".to_string()], "key must appear exactly once even after being overwritten");
        assert_eq!(repo.retrieve_blob(&dir, "k").unwrap(), Some(b"v2".to_vec()));
        let _ = std::fs::remove_dir_all(&dir);
    }
}

(Requires uuid — already a zesdex-cms dependency per its Cargo.toml.)

  • Step 3: Run the tests to verify they fail

Run: cargo test -p zesdex-cms rewind_blob_repo:: 2>&1 | head -20 Expected: compile error (FileRewindBlobRepository doesn't exist yet).

  • Step 4: Implement FileRewindBlobRepository

Add above the test module in the same file:

//! Filesystem-backed `RewindBlobRepository` implementation.
//!
//! Blob bytes are stored at `<session_dir>/blobs/<hex(key)>.bin` (the key
//! is hex-encoded as the filename to sidestep any path-traversal/invalid-
//! filename-character concerns entirely, mirroring the simplicity of
//! `Memory::slugify` elsewhere in this crate but without needing a
//! human-readable filename). Key/ordering/mime-type metadata lives in an
//! append-only `<session_dir>/blobs/index.jsonl`, one JSON line per
//! `store_blob` call — the same JSONL-index pattern already used by
//! `EditLogRepository`. `list_blob_keys` de-duplicates by keeping each
//! key's *last* index line (so overwriting a key doesn't produce a
//! duplicate listing entry) and returns keys ordered by first-seen
//! `created_at` ascending (oldest first), matching the previous
//! `SQLite`-backed `ORDER BY created_at ASC` behavior.
use std::io::Write;
use std::path::Path;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use crate::domain::repository::RewindBlobRepository;

#[derive(Debug, Clone, Serialize, Deserialize)]
struct BlobIndexEntry {
    key: String,
    mime_type: Option<String>,
    created_at: i64,
}

/// Concrete filesystem rewind-blob repository.
#[derive(Debug, Clone, Default)]
pub struct FileRewindBlobRepository;

impl FileRewindBlobRepository {
    /// Create a new filesystem rewind-blob repository.
    pub fn new() -> Self {
        Self
    }

    fn blobs_dir(session_dir: &Path) -> std::path::PathBuf {
        session_dir.join("blobs")
    }

    fn blob_file_path(session_dir: &Path, blob_key: &str) -> std::path::PathBuf {
        Self::blobs_dir(session_dir).join(format!("{}.bin", hex::encode(blob_key.as_bytes())))
    }

    fn index_path(session_dir: &Path) -> std::path::PathBuf {
        Self::blobs_dir(session_dir).join("index.jsonl")
    }
}

impl RewindBlobRepository for FileRewindBlobRepository {
    fn store_blob(&self, session_dir: &Path, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> Result<()> {
        let blobs_dir = Self::blobs_dir(session_dir);
        std::fs::create_dir_all(&blobs_dir)
            .with_context(|| format!("failed to create blobs dir '{}'", blobs_dir.display()))?;

        let path = Self::blob_file_path(session_dir, blob_key);
        let tmp = path.with_extension("bin.tmp");
        std::fs::write(&tmp, data)?;
        let f = std::fs::File::open(&tmp)?;
        f.sync_all()?;
        std::fs::rename(&tmp, &path)?;

        let entry = BlobIndexEntry {
            key: blob_key.to_string(),
            mime_type: mime_type.map(String::from),
            created_at: chrono::Utc::now().timestamp_millis(),
        };
        let index_path = Self::index_path(session_dir);
        let mut f = std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(&index_path)
            .with_context(|| format!("failed to open blob index '{}'", index_path.display()))?;
        writeln!(f, "{}", serde_json::to_string(&entry)?)?;
        f.sync_all()?;

        Ok(())
    }

    fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> Result<Option<Vec<u8>>> {
        let path = Self::blob_file_path(session_dir, blob_key);
        if !path.exists() {
            return Ok(None);
        }
        Ok(Some(std::fs::read(&path)?))
    }

    fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>> {
        let index_path = Self::index_path(session_dir);
        let Ok(content) = std::fs::read_to_string(&index_path) else {
            return Ok(Vec::new());
        };
        // Keep only the last occurrence of each key (later overwrites win),
        // but remember first-seen order for the final ascending sort.
        let mut first_seen_order: Vec<String> = Vec::new();
        let mut latest_by_key: std::collections::HashMap<String, BlobIndexEntry> = std::collections::HashMap::new();
        for line in content.lines() {
            let Ok(entry) = serde_json::from_str::<BlobIndexEntry>(line) else {
                continue;
            };
            if !latest_by_key.contains_key(&entry.key) {
                first_seen_order.push(entry.key.clone());
            }
            latest_by_key.insert(entry.key.clone(), entry);
        }
        let mut entries: Vec<BlobIndexEntry> = first_seen_order
            .into_iter()
            .filter_map(|k| latest_by_key.get(&k).cloned())
            .collect();
        entries.sort_by_key(|e| e.created_at);
        Ok(entries.into_iter().map(|e| e.key).collect())
    }
}
  • Step 5: Register the module

In crates/zesdex-cms/src/infrastructure/persistence/mod.rs, add:

pub mod rewind_blob_repo;
  • Step 6: Run the tests to verify they pass

Run: cargo test -p zesdex-cms rewind_blob_repo:: -- --nocapture Expected: all 4 tests pass.

  • Step 7: Run clippy

Run: cargo clippy -p zesdex-cms -- -D warnings Expected: no new warnings.

  • Step 8: Commit
git add crates/zesdex-cms/src/domain/repository.rs crates/zesdex-cms/src/infrastructure/persistence/rewind_blob_repo.rs crates/zesdex-cms/src/infrastructure/persistence/mod.rs crates/zesdex-cms/Cargo.toml
git commit -m "feat(cms): tambahkan RewindBlobRepository berbasis file (pengganti tabel blobs SQLite)"

Task 3: Rewire message archiving (archive_message) to Conversation

Files:

  • Modify: crates/zesdex-backend/src/app/runtime/actions/mod.rs (lines ~695-708 spawn_turn, ~727-744 TurnCtx, ~862-873 archive_message, plus all 7 call sites at lines 939, 1105, 1295, 1387, 1421, 1426, 1458 — re-confirm line numbers first since Task 5 of the OAuth/session plan and Task 5 of the settings/appconfig/memory/editlog plan may have shifted this file)

Interfaces:

  • Consumes: zesdex_cms::domain::conversation::{Conversation, ChatMessage} (Task 1), zesdex_cms::infrastructure::persistence::conversation_repo::JsonConversationRepository, zesdex_cms::domain::repository::ConversationRepository.

  • Produces: TurnCtx.conversation: Option<Arc<Mutex<Conversation>>> (replaces TurnCtx.db: Option<Arc<Mutex<rusqlite::Connection>>>).

  • Step 1: Confirm current line numbers

Run: grep -n "fn spawn_turn\|struct TurnCtx\|fn archive_message\|open_or_create\|tc\.db\.as_ref" crates/zesdex-backend/src/app/runtime/actions/mod.rs

  • Step 2: Replace the per-turn connection setup in spawn_turn

Replace:

        let db = crate::model::msglog::open_or_create(&edit_session_dir)
            .ok()
            .map(|c| std::sync::Arc::new(std::sync::Mutex::new(c)));

with:

        let conversation = {
            let repo = zesdex_cms::infrastructure::persistence::conversation_repo::JsonConversationRepository::new();
            let conv = repo.load(&edit_session_dir).unwrap_or_else(|_| {
                zesdex_cms::domain::conversation::Conversation::new(String::new(), session_id.clone())
            });
            Some(std::sync::Arc::new(std::sync::Mutex::new(conv)))
        };

Replace the TurnCtx struct literal's db, field with conversation,.

  • Step 3: Update the TurnCtx struct definition

Replace:

    db: Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,

with:

    conversation: Option<std::sync::Arc<std::sync::Mutex<zesdex_cms::domain::conversation::Conversation>>>,
  • Step 4: Rewrite archive_message

Replace:

fn archive_message(db: Option<&std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) {
    if let Some(arc) = db {
        if let Ok(conn) = arc.lock() {
            let _ = crate::model::msglog::insert_message(&conn, session_id, msg);
        }
    }
}

with:

/// Persist a `ChatMessage` to the session's `Conversation`, if one is
/// available for this turn.
///
/// Flow: if `conversation` is `Some`, lock the mutex, push the message,
/// and rewrite `conversation.json` in full. Errors are silently ignored
/// (matches the previous `SQLite`-backed behavior, which also swallowed
/// insert failures).
fn archive_message(
    conversation: Option<&std::sync::Arc<std::sync::Mutex<zesdex_cms::domain::conversation::Conversation>>>,
    session_dir: &std::path::Path,
    msg: &ChatMessage,
) {
    if let Some(arc) = conversation {
        if let Ok(mut conv) = arc.lock() {
            conv.push(msg.clone());
            let repo = zesdex_cms::infrastructure::persistence::conversation_repo::JsonConversationRepository::new();
            let _ = repo.save(session_dir, &conv);
        }
    }
}
  • Step 5: Update all 7 call sites

Run: grep -n "archive_message(tc.db.as_ref()" crates/zesdex-backend/src/app/runtime/actions/mod.rs

At each of the 7 matched lines, replace archive_message(tc.db.as_ref(), &tc.session_id, &<msg_var>) with archive_message(tc.conversation.as_ref(), &tc.edit_log_session_dir, &<msg_var>) (keep whatever the actual message-variable name is at each site — sys, pipeline_msg, response, review_msg, tool_msg, msg per the research brief — only the first two arguments change).

  • Step 6: Build

Run: cargo check -p zesdex-backend Expected: no errors (beyond anything Task 4's blob work below still needs to touch in the same file — if this task is done independently, store_blob call sites will still fail to compile at this point; that's expected and resolved by Task 4).

  • Step 7: Commit
git add crates/zesdex-backend/src/app/runtime/actions/mod.rs
git commit -m "refactor(backend): alihkan archive_message dari SQLite messages table ke zesdex-cms Conversation"

Task 4: Rewire rewind blob storage to FileRewindBlobRepository

Files:

  • Modify: crates/zesdex-backend/src/app/subagent/engine.rs (2 store_blob call sites, confirmed at line 531-533 and a second one — re-grep to find both)
  • Modify: crates/zesdex-backend/src/app/mode/rewind.rs (rewind_count, rewind_to, plus the open_session_db helper and the still-existing store_blob call site inside execute_one_tool/wherever the second engine.rs call lives)

Interfaces:

  • Consumes: zesdex_cms::infrastructure::persistence::rewind_blob_repo::FileRewindBlobRepository, zesdex_cms::domain::repository::RewindBlobRepository.

  • Produces: nothing new for other tasks — this is the last consumer of the old msglog::blobs module.

  • Step 1: Find both store_blob call sites in engine.rs

Run: grep -n -B6 "store_blob" crates/zesdex-backend/src/app/subagent/engine.rs

  • Step 2: Replace each store_blob call site

Replace (pattern applies to both sites, adjusting the surrounding variable names per the actual code read in Step 1):

let _ = crate::model::msglog::store_blob(
    &conn, session_id, &tool_call.id, &bytes, None,
);

with:

let _ = zesdex_cms::infrastructure::persistence::rewind_blob_repo::FileRewindBlobRepository::new()
    .store_blob(&ctx.session_dir, &tool_call.id, &bytes, None);

(Uses RewindBlobRepository::store_blob — add use zesdex_cms::domain::repository::RewindBlobRepository; to the file's imports. Note this drops the now-unneeded conn/session_id-derived-from-directory-name dance since the new repository takes session_dir directly — if the surrounding code only opened conn for this call, remove the now-dead connection-opening code too after confirming via Step 1's grep that nothing else in the same scope still needs it.)

  • Step 3: Rewrite rewind.rs's open_session_db usage

Read the whole file first: cat crates/zesdex-backend/src/app/mode/rewind.rs

Replace rewind_count:

pub fn rewind_count(state: &AppStateRest) -> usize {
    let Ok(conn) = open_session_db(&state.session_dir) else {
        return 0;
    };
    crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id)
        .ok()
        .map_or(0, |keys| keys.len())
}

with:

pub fn rewind_count(state: &AppStateRest) -> usize {
    zesdex_cms::infrastructure::persistence::rewind_blob_repo::FileRewindBlobRepository::new()
        .list_blob_keys(&state.session_dir)
        .ok()
        .map_or(0, |keys| keys.len())
}

Replace the body of rewind_to (the open_session_db call plus list_blob_keys/retrieve_blob calls):

    let conn = match open_session_db(&state.session_dir) { /* ... */ };
    let keys = match crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id) { /* ... */ };
    /* ... */
    let bytes = match crate::model::msglog::blobs::retrieve_blob(&conn, &state.session_id, blob_key) { /* ... */ };

with (dropping the conn/open_session_db step entirely — the file-based repository needs no connection object, just &state.session_dir):

    let repo = zesdex_cms::infrastructure::persistence::rewind_blob_repo::FileRewindBlobRepository::new();

    let keys = match repo.list_blob_keys(&state.session_dir) {
        Ok(k) => k,
        Err(e) => {
            state.push_toast(crate::app::state::types::Toast::new(
                crate::app::state::types::ToastKind::Error,
                format!("Failed to list snapshots: {e}"),
            ));
            state.dirty = true;
            return;
        }
    };

    if keys.is_empty() || index >= keys.len() {
        state.push_toast(crate::app::state::types::Toast::new(
            crate::app::state::types::ToastKind::Warning,
            "No snapshot available at that index".to_string(),
        ));
        state.dirty = true;
        return;
    }

    let blob_key = &keys[index];
    let bytes = match repo.retrieve_blob(&state.session_dir, blob_key) {
        Ok(Some(b)) => b,
        Ok(None) => {
            state.push_toast(crate::app::state::types::Toast::new(
                crate::app::state::types::ToastKind::Error,
                "Snapshot data not found".to_string(),
            ));
            state.dirty = true;
            return;
        }
        Err(e) => {
            state.push_toast(crate::app::state::types::Toast::new(
                crate::app::state::types::ToastKind::Error,
                format!("Failed to retrieve snapshot: {e}"),
            ));
            state.dirty = true;
            return;
        }
    };

(Keep whatever code follows bytes unchanged — the actual file-restoration logic doesn't depend on how bytes was fetched.)

  • Step 4: Delete the now-unused open_session_db helper

If open_session_db (used only by the two call sites just replaced) has no other callers after Step 3 — verify with grep -n "open_session_db" crates/zesdex-backend/src/app/mode/rewind.rs — delete its definition entirely.

  • Step 5: Update the module doc comment

Replace the file's top doc comment:

//! Rewind mode: restores a file to a pre-edit snapshot stored in the
//! session's `SQLite` blob store.

with:

//! Rewind mode: restores a file to a pre-edit snapshot stored in the
//! session's file-based rewind blob store (`<session_dir>/blobs/`).
  • Step 6: Build

Run: cargo check -p zesdex-backend Expected: no errors.

  • Step 7: Run the workspace test suite

Run: cargo test --workspace Expected: all pass.

  • Step 8: Manual smoke test

In the TUI: perform a file edit (triggers a pre-edit snapshot store), open the Rewind overlay, confirm the snapshot count and list are correct, and restore the file — confirm the restored content matches the pre-edit version exactly.

  • Step 9: Commit
git add crates/zesdex-backend/src/app/subagent/engine.rs crates/zesdex-backend/src/app/mode/rewind.rs
git commit -m "refactor(backend): alihkan penyimpanan blob rewind ke FileRewindBlobRepository"

Task 5: Delete the now-dead msglog SQLite module

Files:

  • Delete: crates/zesdex-backend/src/model/msglog/{mod.rs,schema.rs,query.rs,blobs.rs}
  • Modify: crates/zesdex-backend/src/model/mod.rs (remove pub mod msglog;)
  • Modify: crates/zesdex-backend/src/main.rs (line ~236, edit_count: state.edit_log.len() as u32, — confirm this doesn't reference msglog directly; if it only reads state.edit_log, no change needed here)

Interfaces: none — pure deletion after Tasks 3-4 remove every reference.

  • Step 1: Verify zero remaining references

Run: grep -rln "model::msglog\|msglog::" crates/zesdex-backend/src Expected: no output (only the model/mod.rs declaration itself, addressed in Step 3).

  • Step 2: Delete the files
git rm -r crates/zesdex-backend/src/model/msglog
  • Step 3: Remove the module declaration

In crates/zesdex-backend/src/model/mod.rs, remove:

pub mod msglog;
  • Step 4: Build the whole workspace

Run: cargo build --workspace Expected: no errors. If rusqlite was only pulled into zesdex-backend for this module, cargo build will still succeed since rusqlite remains a workspace dependency used elsewhere (zesdex-libs::database.rs) — no Cargo.toml change needed here; confirm with grep -rln "rusqlite" crates/zesdex-backend/src that no other file in this crate still needs it, and if truly zero remaining uses, remove rusqlite from crates/zesdex-backend/Cargo.toml's [dependencies] as a final cleanup (only if the grep comes back empty).

  • Step 5: Run the full test suite and clippy

Run: cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings Expected: all pass, no new warnings.

  • Step 6: Commit
git add -A
git commit -m "chore: hapus modul msglog SQLite lama (digantikan Conversation + RewindBlobRepository)"