650 lines
28 KiB
Markdown
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)"
|
|
```
|