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

650 lines
28 KiB
Markdown

# 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):
```rust
#[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:
```rust
//! 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**
```bash
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:
```rust
/// 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:
```rust
#[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:
```rust
//! 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:
```rust
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**
```bash
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:
```rust
let db = crate::model::msglog::open_or_create(&edit_session_dir)
.ok()
.map(|c| std::sync::Arc::new(std::sync::Mutex::new(c)));
```
with:
```rust
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:
```rust
db: Option<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
```
with:
```rust
conversation: Option<std::sync::Arc<std::sync::Mutex<zesdex_cms::domain::conversation::Conversation>>>,
```
- [ ] **Step 4: Rewrite `archive_message`**
Replace:
```rust
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:
```rust
/// 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**
```bash
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):
```rust
let _ = crate::model::msglog::store_blob(
&conn, session_id, &tool_call.id, &bytes, None,
);
```
with:
```rust
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`:
```rust
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:
```rust
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):
```rust
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`):
```rust
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:
```rust
//! Rewind mode: restores a file to a pre-edit snapshot stored in the
//! session's `SQLite` blob store.
```
with:
```rust
//! 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**
```bash
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**
```bash
git rm -r crates/zesdex-backend/src/model/msglog
```
- [ ] **Step 3: Remove the module declaration**
In `crates/zesdex-backend/src/model/mod.rs`, remove:
```rust
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**
```bash
git add -A
git commit -m "chore: hapus modul msglog SQLite lama (digantikan Conversation + RewindBlobRepository)"
```