chore: hapus implementasi OAuth/session lama yang sudah digantikan zesdex-iam
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
add6845edf
commit
f51a32569f
@@ -1,4 +1,3 @@
|
||||
//! Service layer: LLM provider HTTP client and OAuth flows.
|
||||
//! Service layer: LLM provider HTTP client.
|
||||
|
||||
pub mod oauth;
|
||||
pub mod provider;
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects.
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
|
||||
/// A single-use HTTP listener on `127.0.0.1` that receives the OAuth
|
||||
/// `?code=...` redirect and serves back a static confirmation page.
|
||||
pub struct LoopbackServer {
|
||||
listener: TcpListener,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl LoopbackServer {
|
||||
/// Bind to an OS-assigned free port on localhost.
|
||||
///
|
||||
/// Return: `Err` if the loopback interface can't be bound.
|
||||
pub fn bind() -> std::io::Result<Self> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
Ok(LoopbackServer { listener, port })
|
||||
}
|
||||
|
||||
/// The redirect URI to hand to the OAuth authorization endpoint.
|
||||
pub fn redirect_uri(&self) -> String {
|
||||
format!("http://127.0.0.1:{}/callback", self.port)
|
||||
}
|
||||
|
||||
/// Block until one HTTP request arrives, then extract the `code` query param
|
||||
/// and validate that the `state` param matches the expected value.
|
||||
///
|
||||
/// Flow: accept one connection → apply read timeout → parse request line
|
||||
/// → verify state matches → respond 200/400 depending on whether the code
|
||||
/// was found and state matched.
|
||||
///
|
||||
/// Return: `Err(InvalidData)` if no `code` param is present or the state
|
||||
/// doesn't match `expected_state`.
|
||||
pub fn wait_for_code(&self, timeout_ms: u64, expected_state: &str) -> std::io::Result<String> {
|
||||
let (mut stream, _) = self.listener.accept()?;
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
|
||||
Self::read_callback(&mut stream, expected_state)
|
||||
}
|
||||
|
||||
/// Read and parse a single HTTP callback request off `stream`, replying with a status page.
|
||||
///
|
||||
/// Why: writes the HTTP response before returning so the browser tab
|
||||
/// shows a result regardless of whether the code was found.
|
||||
fn read_callback(stream: &mut TcpStream, expected_state: &str) -> std::io::Result<String> {
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = stream.read(&mut buf)?;
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
let code = Self::extract_code(&request);
|
||||
let state = Self::extract_state(&request);
|
||||
let state_ok = state.as_deref() == Some(expected_state);
|
||||
let response = match (code.as_ref(), state_ok) {
|
||||
(Some(_), true) => "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nAuthorization complete. You may close this tab.",
|
||||
(Some(_), false) => "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nState mismatch — possible CSRF attack.",
|
||||
(None, _) => "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nMissing authorization code.",
|
||||
};
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.flush();
|
||||
if !state_ok {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"state mismatch",
|
||||
));
|
||||
}
|
||||
code.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"code not found in callback",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract and percent-decode the `code` query parameter from an HTTP request line.
|
||||
///
|
||||
/// Return: `None` if the request is malformed or has no `code` param.
|
||||
fn extract_code(request: &str) -> Option<String> {
|
||||
let line = request.lines().next()?;
|
||||
let path = line.split(' ').nth(1)?;
|
||||
let query = path.split('?').nth(1)?;
|
||||
for pair in query.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
if parts.next()? == "code" {
|
||||
return parts.next().map(urlencoding);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the `state` query parameter from an HTTP request line.
|
||||
///
|
||||
/// Return: `None` if the request is malformed or has no `state` param.
|
||||
fn extract_state(request: &str) -> Option<String> {
|
||||
let line = request.lines().next()?;
|
||||
let path = line.split(' ').nth(1)?;
|
||||
let query = path.split('?').nth(1)?;
|
||||
for pair in query.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
if parts.next()? == "state" {
|
||||
return parts.next().map(urlencoding);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-decode a string (e.g. `%20` -> space).
|
||||
///
|
||||
/// Why: invalid escape sequences (missing/non-hex digits) are passed through
|
||||
/// literally as `%` rather than erroring, since this only handles a redirect
|
||||
/// query param, not untrusted binary data.
|
||||
fn urlencoding(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%' {
|
||||
match (
|
||||
chars.next().and_then(|c| c.to_digit(16)),
|
||||
chars.next().and_then(|c| c.to_digit(16)),
|
||||
) {
|
||||
(Some(hi), Some(lo)) => result.push(char::from((hi * 16 + lo) as u8)),
|
||||
_ => {
|
||||
result.push('%');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
//! OAuth 2.0 authorization-code + PKCE flow: token exchange and authorization URL building.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// An OAuth access token plus its refresh token and absolute expiry (unix seconds).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthToken {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_at: u64,
|
||||
pub token_type: String,
|
||||
}
|
||||
|
||||
impl OAuthToken {}
|
||||
|
||||
/// Static configuration for an OAuth provider: endpoints, client identity, and requested scopes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthConfig {
|
||||
pub auth_url: String,
|
||||
pub token_url: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: Option<String>,
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for OAuthConfig {
|
||||
fn default() -> Self {
|
||||
OAuthConfig {
|
||||
auth_url: String::new(),
|
||||
token_url: String::new(),
|
||||
client_id: String::new(),
|
||||
client_secret: None,
|
||||
scopes: vec![
|
||||
"openid".to_string(),
|
||||
"profile".to_string(),
|
||||
"email".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives one OAuth flow: holds config, the current token (if any), and an HTTP client.
|
||||
pub struct OAuthManager {
|
||||
pub config: OAuthConfig,
|
||||
pub token: Option<OAuthToken>,
|
||||
client: reqwest::blocking::Client,
|
||||
}
|
||||
|
||||
impl OAuthManager {
|
||||
/// Create a manager for the given provider config with no token yet acquired.
|
||||
pub fn new(config: OAuthConfig) -> Self {
|
||||
OAuthManager {
|
||||
config,
|
||||
token: None,
|
||||
client: reqwest::blocking::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchange an authorization code for an access token via the provider's token endpoint.
|
||||
///
|
||||
/// Flow: POST form-encoded grant to `token_url` → parse JSON body →
|
||||
/// compute absolute `expires_at` from `expires_in` → store on `self.token`.
|
||||
///
|
||||
/// Return: `Err(String)` on network failure, non-2xx status, or a missing `access_token` field.
|
||||
pub fn exchange_code(
|
||||
&mut self,
|
||||
code: &str,
|
||||
redirect_uri: &str,
|
||||
code_verifier: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut params = std::collections::HashMap::new();
|
||||
params.insert("grant_type", "authorization_code");
|
||||
params.insert("code", code);
|
||||
params.insert("redirect_uri", redirect_uri);
|
||||
params.insert("client_id", &self.config.client_id);
|
||||
params.insert("code_verifier", code_verifier);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&self.config.token_url)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.map_err(|e| format!("token request failed: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().map_err(|e| format!("parse failed: {e}"))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!("token endpoint returned {status}: {body}"));
|
||||
}
|
||||
|
||||
let access_token = body["access_token"]
|
||||
.as_str()
|
||||
.ok_or("missing access_token")?
|
||||
.to_string();
|
||||
let expires_in = body["expires_in"].as_u64().unwrap_or(3600);
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
self.token = Some(OAuthToken {
|
||||
access_token,
|
||||
refresh_token: body["refresh_token"]
|
||||
.as_str()
|
||||
.map(std::string::ToString::to_string),
|
||||
expires_at: now + expires_in,
|
||||
token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the provider's authorization URL with PKCE and state params attached.
|
||||
///
|
||||
/// Why: refuses to build a URL if `auth_url` is missing or invalid. Previously
|
||||
/// this silently fell back to <https://example.com>, which produced a valid-looking
|
||||
/// auth URL pointing at the wrong server and leaked client credentials in
|
||||
/// query params. Returning an empty string signals failure to callers, who
|
||||
/// can prompt the user to fix the OAuth config instead of starting a flow
|
||||
/// against a wrong host.
|
||||
///
|
||||
/// Return: the full authorization URL, or `""` if `auth_url` is empty/unparseable.
|
||||
pub fn build_auth_url(&self, redirect_uri: &str, state: &str, code_challenge: &str) -> String {
|
||||
let mut url = match url::Url::parse(&self.config.auth_url) {
|
||||
Ok(u) if !self.config.auth_url.is_empty() => u,
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"warning: OAuth auth_url is missing or invalid ('{}'); aborting build_auth_url",
|
||||
self.config.auth_url
|
||||
);
|
||||
return String::new();
|
||||
}
|
||||
};
|
||||
url.query_pairs_mut()
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("client_id", &self.config.client_id)
|
||||
.append_pair("redirect_uri", redirect_uri)
|
||||
.append_pair("scope", &self.config.scopes.join(" "))
|
||||
.append_pair("state", state)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("code_challenge", code_challenge);
|
||||
url.to_string()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
//! OAuth 2.0 authorization-code + PKCE flow: local HTTP callback server,
|
||||
//! token exchange, and code verifier/challenge generation.
|
||||
|
||||
pub mod loopback;
|
||||
pub mod manager;
|
||||
pub mod pkce;
|
||||
@@ -1,68 +0,0 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! PKCE (Proof Key for Code Exchange) verifier/challenge pair generation for OAuth flows.
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const VERIFIER_LENGTH: usize = 64;
|
||||
|
||||
/// A randomly generated, base64url-encoded PKCE code verifier.
|
||||
pub struct CodeVerifier(String);
|
||||
|
||||
impl CodeVerifier {
|
||||
/// Generate a fresh random code verifier.
|
||||
pub fn new() -> Self {
|
||||
let bytes: Vec<u8> = (0..VERIFIER_LENGTH).map(|_| rand_byte()).collect();
|
||||
CodeVerifier(URL_SAFE_NO_PAD.encode(&bytes))
|
||||
}
|
||||
|
||||
/// Borrow the verifier as a string, to send in the token exchange request.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Derive the S256 code challenge (SHA-256 hash, base64url-encoded) to send
|
||||
/// in the authorization request.
|
||||
pub fn challenge(&self) -> CodeChallenge {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(self.0.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
CodeChallenge(URL_SAFE_NO_PAD.encode(digest))
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce one pseudo-random byte from the system clock mixed with a monotonic
|
||||
/// counter, providing ~64 bits of per-call unpredictability without a `rand`
|
||||
/// dependency.
|
||||
///
|
||||
/// Why: avoids pulling in a `rand` dependency for a short-lived verifier; the
|
||||
/// monotonic counter ensures that calls within the same clock tick produce
|
||||
/// different values, which is sufficient to prevent OAuth code interception.
|
||||
fn rand_byte() -> u8 {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let seed = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_else(|_| {
|
||||
tracing::warn!("[pkce] system time before UNIX_EPOCH, using 0 for random byte");
|
||||
std::time::Duration::default()
|
||||
})
|
||||
.as_nanos() as u64;
|
||||
((seed ^ counter) & 0xFF) as u8
|
||||
}
|
||||
|
||||
/// The S256-derived code challenge sent in the authorization request URL.
|
||||
pub struct CodeChallenge(String);
|
||||
|
||||
impl CodeChallenge {
|
||||
/// Borrow the challenge as a string.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
//! Authentication entities: session metadata, PID-file lock, and OAuth
|
||||
//! 2.0 PKCE flow types.
|
||||
//! Authentication entities: session metadata and PID-file lock.
|
||||
|
||||
pub mod oauth;
|
||||
pub mod session;
|
||||
pub mod session_lock;
|
||||
|
||||
pub use oauth::{OAuthConfig, OAuthManager, OAuthToken};
|
||||
pub use session::Session;
|
||||
pub use session_lock::SessionLock;
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! OAuth 2.0 authorization-code + PKCE flow: token exchange, authorization
|
||||
//! URL building, and the PKCE verifier/challenge pair.
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const VERIFIER_LENGTH: usize = 64;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PKCE primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A randomly generated, base64url-encoded PKCE code verifier.
|
||||
pub struct CodeVerifier(String);
|
||||
|
||||
impl CodeVerifier {
|
||||
/// Generate a fresh random code verifier.
|
||||
pub fn new() -> Self {
|
||||
let bytes: Vec<u8> = (0..VERIFIER_LENGTH).map(|_| rand_byte()).collect();
|
||||
CodeVerifier(URL_SAFE_NO_PAD.encode(&bytes))
|
||||
}
|
||||
|
||||
/// Borrow the verifier as a string, to send in the token exchange request.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Derive the S256 code challenge (SHA-256 hash, base64url-encoded) to send
|
||||
/// in the authorization request.
|
||||
pub fn challenge(&self) -> CodeChallenge {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(self.0.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
CodeChallenge(URL_SAFE_NO_PAD.encode(digest))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CodeVerifier {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The S256-derived code challenge sent in the authorization request URL.
|
||||
pub struct CodeChallenge(String);
|
||||
|
||||
impl CodeChallenge {
|
||||
/// Borrow the challenge as a string.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce one pseudo-random byte from the system clock mixed with a monotonic
|
||||
/// counter, providing ~64 bits of per-call unpredictability without a `rand`
|
||||
/// dependency.
|
||||
///
|
||||
/// Why: avoids pulling in a `rand` dependency for a short-lived verifier; the
|
||||
/// monotonic counter ensures that calls within the same clock tick produce
|
||||
/// different values, which is sufficient to prevent OAuth code interception.
|
||||
fn rand_byte() -> u8 {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let seed = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos() as u64;
|
||||
((seed ^ counter) & 0xFF) as u8
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OAuth token / config / manager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// An OAuth access token plus its refresh token and absolute expiry (unix seconds).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthToken {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_at: u64,
|
||||
pub token_type: String,
|
||||
}
|
||||
|
||||
/// Static configuration for an OAuth provider: endpoints, client identity, and
|
||||
/// requested scopes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthConfig {
|
||||
pub auth_url: String,
|
||||
pub token_url: String,
|
||||
pub client_id: String,
|
||||
pub client_secret: Option<String>,
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for OAuthConfig {
|
||||
fn default() -> Self {
|
||||
OAuthConfig {
|
||||
auth_url: String::new(),
|
||||
token_url: String::new(),
|
||||
client_id: String::new(),
|
||||
client_secret: None,
|
||||
scopes: vec![
|
||||
"openid".to_string(),
|
||||
"profile".to_string(),
|
||||
"email".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives one OAuth flow: holds config, the current token (if any), and an
|
||||
/// HTTP client.
|
||||
pub struct OAuthManager {
|
||||
pub config: OAuthConfig,
|
||||
pub token: Option<OAuthToken>,
|
||||
client: reqwest::blocking::Client,
|
||||
}
|
||||
|
||||
impl OAuthManager {
|
||||
/// Create a manager for the given provider config with no token yet acquired.
|
||||
pub fn new(config: OAuthConfig) -> Self {
|
||||
OAuthManager {
|
||||
config,
|
||||
token: None,
|
||||
client: reqwest::blocking::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchange an authorization code for an access token via the provider's
|
||||
/// token endpoint.
|
||||
///
|
||||
/// Flow: POST form-encoded grant to `token_url` → parse JSON body →
|
||||
/// compute absolute `expires_at` from `expires_in` → store on `self.token`.
|
||||
///
|
||||
/// Return: `Err(String)` on network failure, non-2xx status, or a missing
|
||||
/// `access_token` field.
|
||||
pub fn exchange_code(
|
||||
&mut self,
|
||||
code: &str,
|
||||
redirect_uri: &str,
|
||||
code_verifier: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut params = std::collections::HashMap::new();
|
||||
params.insert("grant_type", "authorization_code");
|
||||
params.insert("code", code);
|
||||
params.insert("redirect_uri", redirect_uri);
|
||||
params.insert("client_id", &self.config.client_id);
|
||||
params.insert("code_verifier", code_verifier);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&self.config.token_url)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.map_err(|e| format!("token request failed: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().map_err(|e| format!("parse failed: {e}"))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!("token endpoint returned {status}: {body}"));
|
||||
}
|
||||
|
||||
let access_token = body["access_token"]
|
||||
.as_str()
|
||||
.ok_or("missing access_token")?
|
||||
.to_string();
|
||||
let expires_in = body["expires_in"].as_u64().unwrap_or(3600);
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
self.token = Some(OAuthToken {
|
||||
access_token,
|
||||
refresh_token: body["refresh_token"]
|
||||
.as_str()
|
||||
.map(std::string::ToString::to_string),
|
||||
expires_at: now + expires_in,
|
||||
token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the provider's authorization URL with PKCE and state params attached.
|
||||
///
|
||||
/// Why: refuses to build a URL if `auth_url` is missing or invalid. Previously
|
||||
/// this silently fell back to <https://example.com>, which produced a
|
||||
/// valid-looking auth URL pointing at the wrong server and leaked client
|
||||
/// credentials in query params. Returning an empty string signals failure
|
||||
/// to callers, who can prompt the user to fix the OAuth config instead of
|
||||
/// starting a flow against a wrong host.
|
||||
///
|
||||
/// Return: the full authorization URL, or `""` if `auth_url` is
|
||||
/// empty/unparseable.
|
||||
pub fn build_auth_url(
|
||||
&self,
|
||||
redirect_uri: &str,
|
||||
state: &str,
|
||||
code_challenge: &str,
|
||||
) -> String {
|
||||
let mut url = match url::Url::parse(&self.config.auth_url) {
|
||||
Ok(u) if !self.config.auth_url.is_empty() => u,
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"warning: OAuth auth_url is missing or invalid ('{}'); aborting build_auth_url",
|
||||
self.config.auth_url
|
||||
);
|
||||
return String::new();
|
||||
}
|
||||
};
|
||||
url.query_pairs_mut()
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("client_id", &self.config.client_id)
|
||||
.append_pair("redirect_uri", redirect_uri)
|
||||
.append_pair("scope", &self.config.scopes.join(" "))
|
||||
.append_pair("state", state)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("code_challenge", code_challenge);
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
/// Persist the current token to a JSON file at the given path.
|
||||
///
|
||||
/// Flow: serialise `self.token` to pretty JSON → write to temp file →
|
||||
/// fsync → rename → fsync parent directory.
|
||||
pub fn save_token(&self, path: &std::path::Path) -> std::io::Result<()> {
|
||||
if let Some(token) = &self.token {
|
||||
let data = serde_json::to_string_pretty(token)?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, data)?;
|
||||
let f = std::fs::File::open(&tmp)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a token from a JSON file at the given path, replacing the
|
||||
/// in-memory token.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or an `io::Error` if the file is missing
|
||||
/// or malformed.
|
||||
pub fn load_token(&mut self, path: &std::path::Path) -> std::io::Result<()> {
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
let token: OAuthToken = serde_json::from_str(&data)?;
|
||||
self.token = Some(token);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loopback server for capturing the OAuth authorization-code redirect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
|
||||
/// A single-use HTTP listener on `127.0.0.1` that receives the OAuth
|
||||
/// `?code=...` redirect and serves back a static confirmation page.
|
||||
pub struct LoopbackServer {
|
||||
listener: TcpListener,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl LoopbackServer {
|
||||
/// Bind to an OS-assigned free port on localhost.
|
||||
///
|
||||
/// Return: `Err` if the loopback interface can't be bound.
|
||||
pub fn bind() -> std::io::Result<Self> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
Ok(LoopbackServer { listener, port })
|
||||
}
|
||||
|
||||
/// The redirect URI to hand to the OAuth authorization endpoint.
|
||||
pub fn redirect_uri(&self) -> String {
|
||||
format!("http://127.0.0.1:{}/callback", self.port)
|
||||
}
|
||||
|
||||
/// Block until one HTTP request arrives, then extract the `code` query
|
||||
/// param and validate that the `state` param matches the expected value.
|
||||
///
|
||||
/// Flow: accept one connection → apply read timeout → parse request line
|
||||
/// → verify state matches → respond 200/400 depending on whether the code
|
||||
/// was found and state matched.
|
||||
///
|
||||
/// Return: `Err(InvalidData)` if no `code` param is present or the state
|
||||
/// doesn't match `expected_state`.
|
||||
pub fn wait_for_code(
|
||||
&self,
|
||||
timeout_ms: u64,
|
||||
expected_state: &str,
|
||||
) -> std::io::Result<String> {
|
||||
let (mut stream, _) = self.listener.accept()?;
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
|
||||
Self::read_callback(&mut stream, expected_state)
|
||||
}
|
||||
|
||||
/// Read and parse a single HTTP callback request off `stream`, replying
|
||||
/// with a status page.
|
||||
///
|
||||
/// Why: writes the HTTP response before returning so the browser tab
|
||||
/// shows a result regardless of whether the code was found.
|
||||
fn read_callback(
|
||||
stream: &mut TcpStream,
|
||||
expected_state: &str,
|
||||
) -> std::io::Result<String> {
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = stream.read(&mut buf)?;
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
let code = Self::extract_code(&request);
|
||||
let state = Self::extract_state(&request);
|
||||
let state_ok = state.as_deref() == Some(expected_state);
|
||||
let response = match (code.as_ref(), state_ok) {
|
||||
(Some(_), true) => {
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n\
|
||||
Authorization complete. You may close this tab."
|
||||
}
|
||||
(Some(_), false) => {
|
||||
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
|
||||
State mismatch — possible CSRF attack."
|
||||
}
|
||||
(None, _) => {
|
||||
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
|
||||
Missing authorization code."
|
||||
}
|
||||
};
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.flush();
|
||||
if !state_ok {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"state mismatch",
|
||||
));
|
||||
}
|
||||
code.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"code not found in callback",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract and percent-decode the `code` query parameter from an HTTP
|
||||
/// request line.
|
||||
fn extract_code(request: &str) -> Option<String> {
|
||||
let line = request.lines().next()?;
|
||||
let path = line.split(' ').nth(1)?;
|
||||
let query = path.split('?').nth(1)?;
|
||||
for pair in query.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
if parts.next()? == "code" {
|
||||
return parts.next().map(urlencoding);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the `state` query parameter from an HTTP request line.
|
||||
fn extract_state(request: &str) -> Option<String> {
|
||||
let line = request.lines().next()?;
|
||||
let path = line.split(' ').nth(1)?;
|
||||
let query = path.split('?').nth(1)?;
|
||||
for pair in query.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
if parts.next()? == "state" {
|
||||
return parts.next().map(urlencoding);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-decode a string (e.g. `%20` -> space).
|
||||
fn urlencoding(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%' {
|
||||
match (
|
||||
chars.next().and_then(|c| c.to_digit(16)),
|
||||
chars.next().and_then(|c| c.to_digit(16)),
|
||||
) {
|
||||
(Some(hi), Some(lo)) => result.push(char::from((hi * 16 + lo) as u8)),
|
||||
_ => {
|
||||
result.push('%');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
# 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)"
|
||||
```
|
||||
@@ -0,0 +1,737 @@
|
||||
# CMS Wiring: Settings, AppConfig, Memory, EditLog 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 `zesdex-backend`'s use of `zesdex_entities::seaorm::common::{settings,app_config,memory,edit_log}` (inherent-method I/O) with the previously-orphaned `zesdex-cms` crate's repository-based equivalents (`JsonSettingsRepository`, `JsonAppConfigRepository`, `MarkdownMemoryRepository`, `JsonlEditLogRepository`), which are on-disk-format-compatible drop-ins. Fix the one real defect found in `zesdex-cms::Settings` along the way (a missing `#[serde(default)]` that would hard-fail loading any pre-existing `settings.json`).
|
||||
|
||||
**Architecture:** `zesdex-backend` call sites move from `Type::static_method()` to `repository_instance.method(&base_dir, ...)`. Since `zesdex-cms`'s application-service layer (`SettingsServiceImpl`, `MemoryServiceImpl`) doesn't cover every read pattern the backend needs (no bare `AppConfig` getter, no single-memory read, no `EditLogService` at all), most call sites construct and use the concrete `Json*Repository`/`MarkdownMemoryRepository`/`JsonlEditLogRepository` types directly rather than going through the service traits — this matches the actual usage shape better than forcing everything through an ill-fitting service abstraction.
|
||||
|
||||
**Tech Stack:** Rust, Cargo workspace (`zesdex-cms`, `zesdex-backend`, `zesdex-entities`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- On-disk formats are confirmed compatible for all four entities (same JSON/markdown/JSONL shape and file paths) — this plan is a call-site migration, **not** a data migration. No existing user `settings.json`/`app_config.json`/`*.md` memory files/`edits.jsonl` need to change.
|
||||
- `EditLog`'s domain shape genuinely differs between the old (`entries` + `path`, disk I/O on construction) and new (`entries` only, disk I/O via `EditLogRepository::open`) versions — every call site becomes fallible (`Result`) where it was previously infallible.
|
||||
- No new `#[allow(...)]` attributes. Existing ones in touched files are out of scope (handled by `2026-07-16-convention-cleanup-docs.md`).
|
||||
- Tests are inline `#[cfg(test)] mod tests`, per CLAUDE.md.
|
||||
- Run `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings` before each commit.
|
||||
- Base directory resolution: everywhere the old code called the zero-argument `Settings::load()`/`Settings::save()`/`AppConfig::load()` (which internally called `zesdex_entities::seaorm::common::store::Store::new()` to get `base_dir`), the replacement must call `zesdex_entities::seaorm::common::store::Store::new().base_dir` explicitly and pass it to the `zesdex-cms` repository — this reproduces the exact same directory, verified identical during planning.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Fix the `hive_mind_node_timeout_ms` serde-default gap in `zesdex-cms::Settings`
|
||||
|
||||
**Context:** `crates/zesdex-entities/src/seaorm/common/settings.rs:75` has `#[serde(default = "default_hive_mind_node_timeout_ms")]` on this field; `crates/zesdex-cms/src/domain/settings.rs`'s copy does not. Without this, any settings.json written before this field existed (or any settings.json missing it for any reason) will hard-fail `JsonSettingsRepository::load` with a parse error, whereas the old `Settings::load()` silently defaulted on **any** failure. Fix both the missing default and restore full parse-failure tolerance to avoid a behavior regression for existing users.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/zesdex-cms/src/domain/settings.rs`
|
||||
- Modify: `crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `Settings::default()` unchanged in value; `JsonSettingsRepository::load` becomes tolerant of parse failures (still `Result`-returning, but only returns `Err` for I/O errors other than "not found" or "malformed JSON" — matching the old infallible-except-I/O-permission-errors behavior as closely as a `Result`-based API can).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs`'s `#[cfg(test)] mod tests` (create it if absent — check first: `grep -n "mod tests" crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs`):
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn load_defaults_hive_mind_timeout_when_field_missing_from_old_settings_json() {
|
||||
let dir = std::env::temp_dir().join(format!("zesdex-cms-settings-test-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Simulate a settings.json written before `hive_mind_node_timeout_ms` existed.
|
||||
std::fs::write(
|
||||
dir.join("settings.json"),
|
||||
r#"{"internet_mode":"Off","provider":"zen","model":"m","api_keys":{},"max_tokens":null,"temperature":null,"review_max_lessons_per_run":5,"adaptive_review_max_skip":3,"verify_command":null,"verify_timeout_ms":30000,"workflow_max_concurrency":5,"review_enabled":true,"session_archive_enabled":true,"lsp_auto_provision":true,"lsp_languages":[]}"#,
|
||||
).unwrap();
|
||||
|
||||
let repo = JsonSettingsRepository::new();
|
||||
let settings = repo.load(&dir).expect("load must not fail on a pre-existing settings.json missing the new field");
|
||||
assert_eq!(settings.hive_mind_node_timeout_ms, 600_000);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
```
|
||||
|
||||
(Requires `uuid` as a dev-dependency of `zesdex-cms` — check first: `grep uuid crates/zesdex-cms/Cargo.toml`; add `uuid = { workspace = true }` under `[dev-dependencies]` if missing, creating that section if it doesn't exist.)
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `cargo test -p zesdex-cms load_defaults_hive_mind -- --nocapture`
|
||||
Expected: fails with a JSON parse/missing-field error.
|
||||
|
||||
- [ ] **Step 3: Add the serde default to the domain type**
|
||||
|
||||
In `crates/zesdex-cms/src/domain/settings.rs`, add above the `Settings` struct:
|
||||
|
||||
```rust
|
||||
fn default_hive_mind_node_timeout_ms() -> u64 {
|
||||
600_000
|
||||
}
|
||||
```
|
||||
|
||||
And annotate the field:
|
||||
|
||||
```rust
|
||||
#[serde(default = "default_hive_mind_node_timeout_ms")]
|
||||
pub hive_mind_node_timeout_ms: u64,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Restore full parse-failure tolerance in the repository**
|
||||
|
||||
In `crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs`, update `load`:
|
||||
|
||||
```rust
|
||||
fn load(&self, base_dir: &Path) -> Result<Settings> {
|
||||
let path = base_dir.join("settings.json");
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(s) => match serde_json::from_str(&s) {
|
||||
Ok(settings) => Ok(settings),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"settings.json at '{}' failed to parse ({e}); falling back to defaults",
|
||||
path.display()
|
||||
);
|
||||
Ok(Settings::default())
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
tracing::info!("settings.json not found, using defaults");
|
||||
Ok(Settings::default())
|
||||
}
|
||||
Err(e) => Err(anyhow::anyhow!("failed to read settings.json: {e}")),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the test to verify it passes**
|
||||
|
||||
Run: `cargo test -p zesdex-cms load_defaults_hive_mind -- --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, no new warnings.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/zesdex-cms/src/domain/settings.rs crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs
|
||||
git commit -m "fix(cms): perbaiki serde default hive_mind_node_timeout_ms & toleransi parse gagal di Settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Swap `Settings` call sites to `JsonSettingsRepository`
|
||||
|
||||
**Files (every one confirmed by research — swap all):**
|
||||
- `crates/zesdex-backend/src/app/state/rest.rs` (lines 20, 49, 89, 103, 170)
|
||||
- `crates/zesdex-backend/src/bin/seed.rs` (line 12)
|
||||
- `crates/zesdex-backend/src/app/subagent/engine.rs` (lines 61, 64-84, 353 doc comment)
|
||||
- `crates/zesdex-backend/src/app/workflow/hive_mind.rs` (lines 255, 281-283, 367 doc comments)
|
||||
- `crates/zesdex-backend/src/app/runtime/context/window.rs` (lines 9, 19-25, 46/56/79 test-only)
|
||||
- `crates/zesdex-backend/src/app/mode/settings.rs` (lines 6, 16-22)
|
||||
- `crates/zesdex-backend/src/controller/input.rs` (lines 339, 354, 357-361, 393-407)
|
||||
- `crates/zesdex-backend/src/view/mod.rs` (lines 152-174, 701-713 — read-only, no method-call change needed beyond the type import)
|
||||
- `crates/zesdex-backend/src/view/status.rs` (lines 68, 86-93 — read-only)
|
||||
- `crates/zesdex-backend/src/app/review/mod.rs` (lines 79, 85, 405-406 — read-only)
|
||||
- `crates/zesdex-backend/src/app/runtime/actions/mod.rs` (lines 549, 633-671, 1615, 1758 — read-only)
|
||||
- `crates/zesdex-backend/src/main.rs` (lines 141, 474, 641 — `.save()` calls)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `zesdex_cms::domain::settings::Settings`, `zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository`, `zesdex_cms::domain::repository::SettingsRepository` (trait, for method resolution), `zesdex_entities::seaorm::common::store::Store` (for `base_dir` resolution).
|
||||
- Produces: `AppStateRest.settings: zesdex_cms::domain::settings::Settings` (type changed from the old entities type — field-identical, so every **read-only** call site above needs only an import-path change, not a logic change).
|
||||
|
||||
- [ ] **Step 1: Update the type import everywhere it's read-only**
|
||||
|
||||
In each of `rest.rs`, `view/mod.rs`, `view/status.rs`, `app/review/mod.rs`, `app/runtime/actions/mod.rs`, replace:
|
||||
|
||||
```rust
|
||||
use crate::model::settings::Settings;
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```rust
|
||||
use zesdex_cms::domain::settings::Settings;
|
||||
```
|
||||
|
||||
(For files that reference `Settings` only via `state.settings.<field>` without an explicit `use` for the type itself — confirm per-file with `grep -n "use.*settings::Settings\|model::settings" <file>` before editing — skip files where no explicit import exists, since `AppStateRest.settings`'s type change alone (Step 3 below) is what they actually depend on.)
|
||||
|
||||
- [ ] **Step 2: Update `app/mode/settings.rs` (mutates `Settings` in place, no I/O)**
|
||||
|
||||
Read the file first: `cat crates/zesdex-backend/src/app/mode/settings.rs`. Replace:
|
||||
|
||||
```rust
|
||||
use crate::model::settings::{InternetMode, Settings};
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```rust
|
||||
use zesdex_cms::domain::settings::{InternetMode, Settings};
|
||||
```
|
||||
|
||||
`cycle_internet_mode(settings: &mut Settings)`'s body (mutating `settings.internet_mode` in a cycle) needs no logic change — `InternetMode` is field-identical between old and new.
|
||||
|
||||
- [ ] **Step 3: Update `AppStateRest` construction and field type**
|
||||
|
||||
In `crates/zesdex-backend/src/app/state/rest.rs`:
|
||||
|
||||
Replace the import (line 20):
|
||||
```rust
|
||||
use crate::model::settings::Settings;
|
||||
```
|
||||
with:
|
||||
```rust
|
||||
use zesdex_cms::domain::settings::Settings;
|
||||
use zesdex_cms::domain::repository::SettingsRepository;
|
||||
use zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository;
|
||||
```
|
||||
|
||||
Replace the field's type comment reference (line 49) — no change needed, `pub settings: Settings,` already resolves to the new import.
|
||||
|
||||
Replace construction (line 89):
|
||||
```rust
|
||||
let settings = Settings::load();
|
||||
```
|
||||
with:
|
||||
```rust
|
||||
let store_base_dir = zesdex_entities::seaorm::common::store::Store::new().base_dir;
|
||||
let settings = JsonSettingsRepository::new()
|
||||
.load(&store_base_dir)
|
||||
.unwrap_or_default();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `controller/input.rs`'s `.save()` call sites**
|
||||
|
||||
Read the file first: `grep -n -B3 "\.settings\.save()" crates/zesdex-backend/src/controller/input.rs`
|
||||
|
||||
Both call sites (previously lines 361 and 407) currently do `let _ = state.settings.save();`. Since `Settings` no longer carries an inherent `save()` method, replace each with:
|
||||
|
||||
```rust
|
||||
let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
|
||||
.save(&state.store_base_dir(), &state.settings);
|
||||
```
|
||||
|
||||
(Uses `state.store_base_dir()` — the existing helper on `AppStateRest`, confirmed to resolve to the same directory as `Store::new().base_dir` in normal operation — since these call sites already have `state: &mut AppStateRest` in scope, unlike `rest.rs::new()` which doesn't yet have a constructed `state` to call `.store_base_dir()` on.)
|
||||
|
||||
Add `use zesdex_cms::domain::repository::SettingsRepository;` to this file's imports if not already present after Step 1.
|
||||
|
||||
- [ ] **Step 5: Update `main.rs`'s three `.save()` call sites**
|
||||
|
||||
Read the file first: `grep -n -B2 "\.settings\.save()" crates/zesdex-backend/src/main.rs`
|
||||
|
||||
Replace each `let _ = state.settings.save();` (or `client_state.settings.save()`) with the same pattern as Step 4, substituting the correct state variable name at each site:
|
||||
|
||||
```rust
|
||||
let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
|
||||
.save(&state.store_base_dir(), &state.settings);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update `app/subagent/engine.rs`'s `resolve_provider_config()`**
|
||||
|
||||
Read the function first: `grep -n -A 30 "fn resolve_provider_config" crates/zesdex-backend/src/app/subagent/engine.rs`
|
||||
|
||||
Replace:
|
||||
```rust
|
||||
let settings = crate::model::settings::Settings::load();
|
||||
```
|
||||
with:
|
||||
```rust
|
||||
let store_base_dir = zesdex_entities::seaorm::common::store::Store::new().base_dir;
|
||||
let settings = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
|
||||
.load(&store_base_dir)
|
||||
.unwrap_or_default();
|
||||
```
|
||||
|
||||
(Add `use zesdex_cms::domain::repository::SettingsRepository;` to this file's imports.) The subsequent field reads (`settings.api_keys`, `settings.provider`, `settings.model` at lines 64-84) need no change — same field names/types.
|
||||
|
||||
- [ ] **Step 7: Update `app/workflow/hive_mind.rs`'s `run_hive_mind()`**
|
||||
|
||||
Read the function first: `grep -n -A 5 "let settings = crate::model::settings::Settings::load" crates/zesdex-backend/src/app/workflow/hive_mind.rs`
|
||||
|
||||
Apply the identical substitution pattern from Step 6. Field reads `settings.hive_mind_node_timeout_ms`/`settings.workflow_max_concurrency` need no change.
|
||||
|
||||
- [ ] **Step 8: Update `app/runtime/context/window.rs`**
|
||||
|
||||
Read the file first: `cat crates/zesdex-backend/src/app/runtime/context/window.rs`
|
||||
|
||||
Replace the import (line 9):
|
||||
```rust
|
||||
use crate::model::settings::Settings;
|
||||
```
|
||||
with:
|
||||
```rust
|
||||
use zesdex_cms::domain::settings::Settings;
|
||||
```
|
||||
|
||||
The `resolve(app_config: &AppConfig, settings: &Settings)` function signature/body and the three test-only `Settings::default()` constructions need no logic change — same type shape, `Default` still works identically after Task 1.
|
||||
|
||||
- [ ] **Step 9: Update `bin/seed.rs`**
|
||||
|
||||
Read the file first: `cat crates/zesdex-backend/src/bin/seed.rs`
|
||||
|
||||
Replace:
|
||||
```rust
|
||||
zesdex_entities::seaorm::common::settings::Settings::default()
|
||||
```
|
||||
with:
|
||||
```rust
|
||||
zesdex_cms::domain::settings::Settings::default()
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Remove the now-unused `model::settings` re-export**
|
||||
|
||||
In `crates/zesdex-backend/src/model/mod.rs`, remove:
|
||||
```rust
|
||||
pub mod settings {
|
||||
pub use zesdex_entities::seaorm::common::settings::*;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 11: Verify no remaining references**
|
||||
|
||||
Run: `grep -rn "model::settings::" crates/zesdex-backend/src`
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 12: Build and test**
|
||||
|
||||
Run: `cargo build --workspace && cargo test --workspace`
|
||||
Expected: no errors, all tests pass.
|
||||
|
||||
- [ ] **Step 13: Manual smoke test**
|
||||
|
||||
Run: `cargo run -p zesdex-backend`. Confirm the TUI starts, the Settings overlay (per `view/mod.rs`) displays the current provider/model/flags correctly, and changing internet mode / API key / provider / model persists correctly across a restart (settings.json is written and re-read with the same values).
|
||||
|
||||
- [ ] **Step 14: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor(backend): alihkan Settings ke zesdex-cms JsonSettingsRepository"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Swap `AppConfig` call sites to `JsonAppConfigRepository`
|
||||
|
||||
**Files:**
|
||||
- `crates/zesdex-backend/src/app/state/rest.rs` (lines 18, 50, 90, 104)
|
||||
- `crates/zesdex-backend/src/bin/seed.rs` (line 27)
|
||||
- `crates/zesdex-backend/src/app/subagent/engine.rs` (lines 62, 69, 73)
|
||||
- `crates/zesdex-backend/src/app/runtime/context/window.rs` (lines 8, 19-25, test sites)
|
||||
- `crates/zesdex-backend/src/controller/input.rs` (lines 220, 252, 383, 385)
|
||||
- `crates/zesdex-backend/src/view/mod.rs` (lines 710-711)
|
||||
- `crates/zesdex-backend/src/view/status.rs` (line 68)
|
||||
- `crates/zesdex-backend/src/app/runtime/actions/mod.rs` (lines 548, 551, 635-659, 1755-1758)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `zesdex_cms::domain::app_config::{AppConfig, ProviderConfig, ModelRole}`, `zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository`, `zesdex_cms::domain::repository::AppConfigRepository`.
|
||||
- Produces: `AppStateRest.app_config: zesdex_cms::domain::app_config::AppConfig` (type changed, field-identical). `AppConfig` is never saved anywhere in `zesdex-backend` today (confirmed by research) — this task only needs `load`, no `save` call sites.
|
||||
|
||||
- [ ] **Step 1: Update read-only imports**
|
||||
|
||||
Same pattern as Task 2 Step 1: in each file that imports `crate::model::app_config::AppConfig`/`ProviderConfig`, replace with `zesdex_cms::domain::app_config::{AppConfig, ProviderConfig}` (add `ModelRole` too where `view/mod.rs`/`window.rs` need it).
|
||||
|
||||
- [ ] **Step 2: Update `AppStateRest` construction**
|
||||
|
||||
In `crates/zesdex-backend/src/app/state/rest.rs`, add to the import block from Task 2 Step 3:
|
||||
|
||||
```rust
|
||||
use zesdex_cms::domain::app_config::AppConfig;
|
||||
use zesdex_cms::domain::repository::AppConfigRepository;
|
||||
use zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository;
|
||||
```
|
||||
|
||||
Replace construction (line 90):
|
||||
```rust
|
||||
let app_config = AppConfig::load();
|
||||
```
|
||||
with:
|
||||
```rust
|
||||
let app_config = JsonAppConfigRepository::new()
|
||||
.load(&store_base_dir)
|
||||
.unwrap_or_default();
|
||||
```
|
||||
|
||||
(Reuses the `store_base_dir` local variable already introduced in Task 2 Step 3 — both `Settings` and `AppConfig` load from the same base directory.)
|
||||
|
||||
- [ ] **Step 3: Update `app/subagent/engine.rs`**
|
||||
|
||||
Apply the same substitution as Task 2 Step 6, adding the `AppConfig` load right after the `Settings` load using the same `store_base_dir`:
|
||||
|
||||
```rust
|
||||
let app_config = zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository::new()
|
||||
.load(&store_base_dir)
|
||||
.unwrap_or_default();
|
||||
```
|
||||
|
||||
(Add `use zesdex_cms::domain::repository::AppConfigRepository;`.)
|
||||
|
||||
- [ ] **Step 4: Update `bin/seed.rs`**
|
||||
|
||||
Replace `zesdex_entities::seaorm::common::app_config::AppConfig::default()` with `zesdex_cms::domain::app_config::AppConfig::default()`.
|
||||
|
||||
- [ ] **Step 5: Remove the now-unused `model::app_config` re-export**
|
||||
|
||||
In `crates/zesdex-backend/src/model/mod.rs`, remove:
|
||||
```rust
|
||||
pub mod app_config {
|
||||
pub use zesdex_entities::seaorm::common::app_config::*;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify no remaining references, build, and test**
|
||||
|
||||
Run: `grep -rn "model::app_config::" crates/zesdex-backend/src` — expected no output.
|
||||
Run: `cargo build --workspace && cargo test --workspace` — expected all pass.
|
||||
|
||||
- [ ] **Step 7: Manual smoke test**
|
||||
|
||||
Run the TUI, open the Model Selector overlay (Ctrl+P or equivalent per `resources.rs`), confirm the provider/model list still populates correctly from `app_config.json`, and that Claude-credential auto-detection (if `~/.claude/settings.json` exists on the test machine) still merges in correctly.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor(backend): alihkan AppConfig ke zesdex-cms JsonAppConfigRepository"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Swap `Memory` call sites to `MarkdownMemoryRepository`
|
||||
|
||||
**Files:**
|
||||
- `crates/zesdex-backend/src/tool/memory/recall.rs` (lines 4, 44, 63, 69)
|
||||
- `crates/zesdex-backend/src/tool/memory/remember.rs` (lines 4, 74, 79-96)
|
||||
- `crates/zesdex-backend/src/tool/memory/forget.rs` (lines 4, 45)
|
||||
- `crates/zesdex-backend/src/app/mode/learning.rs` (lines 56, 58)
|
||||
- `crates/zesdex-backend/src/app/workflow/docs.rs` (line 34 — `slugify` only, no I/O)
|
||||
- `crates/zesdex-backend/src/app/review/mod.rs` (lines 487, 491, 493-494)
|
||||
- `crates/zesdex-backend/src/app/runtime/actions/mod.rs` (lines 591, 797, 810, 833, 843)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `zesdex_cms::domain::memory::Memory`, `zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository`, `zesdex_cms::domain::repository::MemoryRepository`.
|
||||
- Produces: nothing new for other tasks — `Memory` is a leaf entity with no state-struct field.
|
||||
|
||||
- [ ] **Step 1: `tool/memory/recall.rs`**
|
||||
|
||||
Read the file: `cat crates/zesdex-backend/src/tool/memory/recall.rs`
|
||||
|
||||
Replace:
|
||||
```rust
|
||||
use crate::model::memory::Memory;
|
||||
```
|
||||
with:
|
||||
```rust
|
||||
use zesdex_cms::domain::memory::Memory;
|
||||
use zesdex_cms::domain::repository::MemoryRepository;
|
||||
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
|
||||
```
|
||||
|
||||
Replace (line 44): `Memory::read(&ctx.memory_dir, name)` → `MarkdownMemoryRepository::new().load(&ctx.memory_dir, name)`
|
||||
Replace (line 63): `Memory::list(&ctx.memory_dir)` → `MarkdownMemoryRepository::new().list(&ctx.memory_dir)`
|
||||
Replace (line 69): `Memory::read(&ctx.memory_dir, name)` → `MarkdownMemoryRepository::new().load(&ctx.memory_dir, name)`
|
||||
|
||||
(Both old inherent methods and the new repository methods return `Result<Memory>`/`Result<Vec<String>>` respectively — signature shape at the call site is unchanged beyond the receiver.)
|
||||
|
||||
- [ ] **Step 2: `tool/memory/remember.rs`**
|
||||
|
||||
Read the file: `cat crates/zesdex-backend/src/tool/memory/remember.rs`
|
||||
|
||||
Same import swap as Step 1. Replace (line 74): `Memory::slugify(name)` → `Memory::slugify(name)` (unchanged — `slugify` remains an inherent method on the domain struct in `zesdex-cms`, per research). The struct-literal construction (lines 79-92) needs no change (field-identical). Replace (lines 94-96):
|
||||
```rust
|
||||
memory.write(&ctx.memory_dir)?;
|
||||
```
|
||||
with:
|
||||
```rust
|
||||
MarkdownMemoryRepository::new().save(&ctx.memory_dir, &memory)?;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `tool/memory/forget.rs`**
|
||||
|
||||
Same import swap. Replace (line 45): `Memory::remove(&ctx.memory_dir, name)` → `MarkdownMemoryRepository::new().delete(&ctx.memory_dir, name)`.
|
||||
|
||||
- [ ] **Step 4: `app/mode/learning.rs`**
|
||||
|
||||
Read the file: `cat crates/zesdex-backend/src/app/mode/learning.rs`
|
||||
|
||||
Replace (line 56): `crate::model::memory::Memory::list(&state.memory_dir)` → `zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new().list(&state.memory_dir)`
|
||||
Replace (line 58): `crate::model::memory::Memory::read(&state.memory_dir, &name)` → `zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new().load(&state.memory_dir, &name)`
|
||||
|
||||
(Add `use zesdex_cms::domain::repository::MemoryRepository;` at the top of the file.)
|
||||
|
||||
- [ ] **Step 5: `app/workflow/docs.rs`**
|
||||
|
||||
Replace (line 34): `crate::model::memory::Memory::slugify(user_request)` → `zesdex_cms::domain::memory::Memory::slugify(user_request)`. (Pure filename-generation helper, no repository/I/O involved — no other change needed.)
|
||||
|
||||
- [ ] **Step 6: `app/review/mod.rs`**
|
||||
|
||||
Read the surrounding code: `grep -n -B2 -A8 "model::memory::Memory::list(memory_dir)" crates/zesdex-backend/src/app/review/mod.rs`
|
||||
|
||||
Replace (line 487): `crate::model::memory::Memory::list(memory_dir)` → `zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new().list(memory_dir)`
|
||||
Replace (line 491): `crate::model::memory::Memory::read(memory_dir, &name)` → `zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new().load(memory_dir, &name)`
|
||||
Replace (lines 493-494):
|
||||
```rust
|
||||
mem.lifecycle = "stale".to_string();
|
||||
mem.write(memory_dir)?;
|
||||
```
|
||||
with:
|
||||
```rust
|
||||
mem.lifecycle = "stale".to_string();
|
||||
zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new().save(memory_dir, &mem)?;
|
||||
```
|
||||
|
||||
- [ ] **Step 7: `app/runtime/actions/mod.rs`**
|
||||
|
||||
Read each site first: `grep -n -B2 -A2 "model::memory::Memory::" crates/zesdex-backend/src/app/runtime/actions/mod.rs`
|
||||
|
||||
Apply the same `Memory::method(dir, ...)` → `MarkdownMemoryRepository::new().method(dir, ...)` substitution at all 5 remaining sites (lines 591, 797, 810, 833, 843), matching the method-name mapping: `remove`→`delete`, `list`→`list`, `read`→`load`.
|
||||
|
||||
- [ ] **Step 8: Add `use` statements**
|
||||
|
||||
Add `use zesdex_cms::domain::repository::MemoryRepository;` and `use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;` to `app/review/mod.rs` and `app/runtime/actions/mod.rs` (both already import many things — add alongside existing `use` block).
|
||||
|
||||
- [ ] **Step 9: Remove the now-unused `model::memory` re-export**
|
||||
|
||||
In `crates/zesdex-backend/src/model/mod.rs`, remove:
|
||||
```rust
|
||||
pub mod memory {
|
||||
pub use zesdex_entities::seaorm::common::memory::*;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 10: Verify no remaining references, build, test**
|
||||
|
||||
Run: `grep -rn "model::memory::" crates/zesdex-backend/src` — expected no output.
|
||||
Run: `cargo build --workspace && cargo test --workspace` — expected all pass.
|
||||
|
||||
- [ ] **Step 11: Manual smoke test**
|
||||
|
||||
In the TUI, invoke the `remember` tool to create a memory, `recall` it back, confirm the Learning overlay lists it, then `forget` it and confirm it's gone. Also confirm existing `.md` memory files from before this change (if any test fixtures exist) still load correctly (format compatibility check).
|
||||
|
||||
- [ ] **Step 12: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor(backend): alihkan Memory ke zesdex-cms MarkdownMemoryRepository"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Swap `EditLog` call sites to `JsonlEditLogRepository`
|
||||
|
||||
**Context:** This is the most invasive of the four because every call site currently does `EditLog::new(dir)` (infallible, eager full-file read) and the new equivalent `JsonlEditLogRepository::open(dir)` returns `Result`. There are 7 distinct call sites plus one persistent field on `AppStateRest`.
|
||||
|
||||
**Files:**
|
||||
- `crates/zesdex-backend/src/app/state/rest.rs` (lines 19, 58, 115)
|
||||
- `crates/zesdex-backend/src/main.rs` (line 236 — reads `state.edit_log.len()`, no change needed beyond the type)
|
||||
- `crates/zesdex-backend/src/app/subagent/engine.rs` (lines 571-580)
|
||||
- `crates/zesdex-backend/src/app/mode/rewind.rs` (lines 103-114, 128-134)
|
||||
- `crates/zesdex-backend/src/app/runtime/actions/mod.rs` (lines 921, 1473-1475, 1487-1489, 1586-1597)
|
||||
- `crates/zesdex-backend/src/model/mod.rs` (remove re-export)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `zesdex_cms::domain::edit_log::{EditLog, EditLogEntry}`, `zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository`, `zesdex_cms::domain::repository::EditLogRepository`.
|
||||
- Produces: `AppStateRest.edit_log: zesdex_cms::domain::edit_log::EditLog` (type changed — no `path` field this time, so anything reading `state.edit_log.path` would break; confirmed by research that no call site does this).
|
||||
|
||||
- [ ] **Step 1: Update `AppStateRest`**
|
||||
|
||||
In `crates/zesdex-backend/src/app/state/rest.rs`, replace the import (line 19):
|
||||
```rust
|
||||
use crate::model::editlog::EditLog;
|
||||
```
|
||||
with:
|
||||
```rust
|
||||
use zesdex_cms::domain::edit_log::EditLog;
|
||||
use zesdex_cms::domain::repository::EditLogRepository;
|
||||
use zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository;
|
||||
```
|
||||
|
||||
Replace construction (line 115):
|
||||
```rust
|
||||
edit_log: EditLog::new(session_dir),
|
||||
```
|
||||
with:
|
||||
```rust
|
||||
edit_log: JsonlEditLogRepository::new().open(session_dir).unwrap_or_else(|e| {
|
||||
tracing::warn!("[state] failed to open edit log at '{}': {e}", session_dir.display());
|
||||
EditLog::new()
|
||||
}),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `app/subagent/engine.rs`**
|
||||
|
||||
Read the site: `grep -n -B3 -A3 "EditLog::new(&ctx.session_dir)" crates/zesdex-backend/src/app/subagent/engine.rs`
|
||||
|
||||
Replace:
|
||||
```rust
|
||||
let mut el = crate::model::editlog::EditLog::new(&ctx.session_dir);
|
||||
el.append(entry).ok();
|
||||
```
|
||||
with:
|
||||
```rust
|
||||
let repo = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
|
||||
if let Ok(mut el) = repo.open(&ctx.session_dir) {
|
||||
let _ = repo.append(&ctx.session_dir, &mut el, entry);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `app/mode/rewind.rs` — logging the rewind operation**
|
||||
|
||||
Read the site: `grep -n -B3 -A10 "EditLog::new(&state.session_dir)" crates/zesdex-backend/src/app/mode/rewind.rs`
|
||||
|
||||
Replace the first site (previously lines 103-114):
|
||||
```rust
|
||||
let mut el = crate::model::editlog::EditLog::new(&state.session_dir);
|
||||
let entry = crate::model::editlog::EditLogEntry { /* ... */ };
|
||||
let _ = el.append(entry);
|
||||
```
|
||||
with:
|
||||
```rust
|
||||
let repo = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
|
||||
if let Ok(mut el) = repo.open(&state.session_dir) {
|
||||
let entry = zesdex_cms::domain::edit_log::EditLogEntry { /* same field values as before */ };
|
||||
let _ = repo.append(&state.session_dir, &mut el, entry);
|
||||
}
|
||||
```
|
||||
|
||||
(Keep the exact same `EditLogEntry` field values from the original code — only the type path and the append mechanism change.)
|
||||
|
||||
- [ ] **Step 4: `app/mode/rewind.rs` — `find_edit_path`**
|
||||
|
||||
Read the site: `grep -n -B3 -A8 "fn find_edit_path" crates/zesdex-backend/src/app/mode/rewind.rs`
|
||||
|
||||
Replace:
|
||||
```rust
|
||||
let el = crate::model::editlog::EditLog::new(&state.session_dir);
|
||||
```
|
||||
with:
|
||||
```rust
|
||||
let el = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new()
|
||||
.open(&state.session_dir)
|
||||
.unwrap_or_else(|_| zesdex_cms::domain::edit_log::EditLog::new());
|
||||
```
|
||||
|
||||
The subsequent `el.entries.iter().rev().find(...)` (unchanged — `entries` is a public field on both old and new `EditLog`) needs no further change.
|
||||
|
||||
- [ ] **Step 5: `app/runtime/actions/mod.rs` — turn-start snapshot**
|
||||
|
||||
Read the site: `grep -n -B2 -A2 "let initial_edits" crates/zesdex-backend/src/app/runtime/actions/mod.rs`
|
||||
|
||||
Replace:
|
||||
```rust
|
||||
let initial_edits = crate::model::editlog::EditLog::new(&tc.edit_log_session_dir).len();
|
||||
```
|
||||
with:
|
||||
```rust
|
||||
let initial_edits = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new()
|
||||
.open(&tc.edit_log_session_dir)
|
||||
.map(|el| el.entries.len())
|
||||
.unwrap_or(0);
|
||||
```
|
||||
|
||||
(`EditLog` in `zesdex-cms` has no `.len()` inherent method — check first: `grep -n "fn len\|impl EditLog" crates/zesdex-cms/src/domain/edit_log.rs`. If `.len()` doesn't exist, use `.entries.len()` as shown; if it does exist, use `el.len()` instead for consistency with the rest of the codebase's naming.)
|
||||
|
||||
- [ ] **Step 6: `app/runtime/actions/mod.rs` — turn-end diff**
|
||||
|
||||
Read the site: `grep -n -B2 -A10 "let final_edits" crates/zesdex-backend/src/app/runtime/actions/mod.rs`
|
||||
|
||||
Replace:
|
||||
```rust
|
||||
let el = crate::model::editlog::EditLog::new(&tc.edit_log_session_dir);
|
||||
let final_edits = el.len();
|
||||
let total_edits_this_turn = final_edits.saturating_sub(initial_edits);
|
||||
```
|
||||
with:
|
||||
```rust
|
||||
let el = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new()
|
||||
.open(&tc.edit_log_session_dir)
|
||||
.unwrap_or_else(|_| zesdex_cms::domain::edit_log::EditLog::new());
|
||||
let final_edits = el.entries.len();
|
||||
let total_edits_this_turn = final_edits.saturating_sub(initial_edits);
|
||||
```
|
||||
|
||||
And immediately after (previously lines 1487-1489), the `.entries.iter().skip(initial_edits)` loop needs no change — same field access.
|
||||
|
||||
- [ ] **Step 7: `app/runtime/actions/mod.rs` — `execute_one_tool`'s primary write path**
|
||||
|
||||
Read the site: `grep -n -B3 -A5 "EditLog::new(session_dir)" crates/zesdex-backend/src/app/runtime/actions/mod.rs`
|
||||
|
||||
Apply the same pattern as Step 2 (open + conditional append via the repository).
|
||||
|
||||
- [ ] **Step 8: Remove the now-unused `model::editlog` re-export**
|
||||
|
||||
In `crates/zesdex-backend/src/model/mod.rs`, remove:
|
||||
```rust
|
||||
pub mod editlog {
|
||||
pub use zesdex_entities::seaorm::common::edit_log::*;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Verify no remaining references**
|
||||
|
||||
Run: `grep -rn "model::editlog::" crates/zesdex-backend/src`
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 10: Build and test**
|
||||
|
||||
Run: `cargo build --workspace && cargo test --workspace`
|
||||
Expected: no errors, all tests pass.
|
||||
|
||||
- [ ] **Step 11: Manual smoke test**
|
||||
|
||||
In the TUI, make a file edit via the `edit`/`write` tool, confirm `state.edit_log` grows and `main.rs`'s IPC `edit_count` payload reflects it, then use the rewind overlay to confirm `find_edit_path` still correctly recovers the edited file's path and the rewind itself works end-to-end.
|
||||
|
||||
- [ ] **Step 12: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor(backend): alihkan EditLog ke zesdex-cms JsonlEditLogRepository"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Delete the now-dead `zesdex_entities::seaorm::common::{settings,app_config,memory,edit_log}` modules
|
||||
|
||||
**Files:**
|
||||
- Delete: `crates/zesdex-entities/src/seaorm/common/settings.rs`, `app_config.rs`, `memory.rs`, `edit_log.rs`
|
||||
- Modify: `crates/zesdex-entities/src/seaorm/common/mod.rs` (remove their module declarations)
|
||||
|
||||
**Interfaces:** none — pure deletion after Tasks 2-5 have removed every reference.
|
||||
|
||||
- [ ] **Step 1: Verify zero remaining references across the whole workspace**
|
||||
|
||||
Run: `grep -rln "seaorm::common::settings\|seaorm::common::app_config\|seaorm::common::memory\b\|seaorm::common::edit_log" crates --include='*.rs'`
|
||||
Expected: no output. (If anything other than the `mod.rs` declaration itself shows up, stop and investigate before deleting — it means a call site was missed in Tasks 2-5.)
|
||||
|
||||
- [ ] **Step 2: Delete the files**
|
||||
|
||||
```bash
|
||||
git rm crates/zesdex-entities/src/seaorm/common/settings.rs
|
||||
git rm crates/zesdex-entities/src/seaorm/common/app_config.rs
|
||||
git rm crates/zesdex-entities/src/seaorm/common/memory.rs
|
||||
git rm crates/zesdex-entities/src/seaorm/common/edit_log.rs
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove their module declarations**
|
||||
|
||||
In `crates/zesdex-entities/src/seaorm/common/mod.rs`, remove the corresponding `pub mod settings;`, `pub mod app_config;`, `pub mod memory;`, `pub mod edit_log;` lines (check first: `cat crates/zesdex-entities/src/seaorm/common/mod.rs`).
|
||||
|
||||
- [ ] **Step 4: Build the whole workspace**
|
||||
|
||||
Run: `cargo build --workspace`
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **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 entitas settings/app_config/memory/edit_log lama di zesdex-entities yang sudah digantikan zesdex-cms"
|
||||
```
|
||||
@@ -0,0 +1,373 @@
|
||||
# Convention Cleanup + Documentation Repair 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:** Bring the codebase into compliance with `CLAUDE.md`'s own stated rules that the audit found violated — 110 `#[allow(...)]` lint-bypass attributes (10 of them silencing `dead_code`, which the workspace `Cargo.toml` explicitly `deny`s), a custom error type where only `anyhow` is supposed to be used, small doc-comment gaps — and repair the five `docs/CODEMAPS/*.md` files plus `CLAUDE.md` itself, which reference pre-workspace-migration paths that no longer exist.
|
||||
|
||||
**Architecture:** No structural changes to running code beyond what's needed to satisfy the lints without suppressing them. Documentation tasks are pure text corrections against the now-accurate `crates/` layout (this plan should run **after** the other four plans in this series, since they change many of the exact file paths the docs need to describe correctly).
|
||||
|
||||
**Tech Stack:** Rust, Markdown.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No **new** `#[allow(...)]` may be introduced by this plan's own changes.
|
||||
- Every dead-code removal must be verified by letting the compiler/clippy confirm the item has zero remaining callers — never delete on assumption.
|
||||
- Tests are inline `#[cfg(test)] mod tests`.
|
||||
- Run `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings` after each task.
|
||||
- **Run this plan last**, after `2026-07-16-security-quickfixes.md`, `2026-07-16-oauth-session-iam-wiring.md`, `2026-07-16-cms-settings-appconfig-memory-editlog-wiring.md`, `2026-07-16-cms-conversation-blob-wiring.md`, and `2026-07-16-middleware-axum-server.md` — the documentation tasks (Task 6) describe the *end state* of all five, and several files this plan touches for lint cleanup (`app/runtime/actions/mod.rs`, `app/runtime/context/*.rs`) are also touched by those plans.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Delete the unused custom `Error` type in `zesdex-utils`
|
||||
|
||||
**Context:** `crates/zesdex-utils/src/error.rs` defines a hand-rolled `pub enum Error` + `impl std::error::Error` + a `Result<T>` alias, directly contradicting CLAUDE.md's "anyhow::Result and anyhow::bail! throughout... No custom error types" rule. Confirmed via workspace-wide grep: **zero call sites reference it outside the file itself** — it's simply dead code, not something anything depends on. `thiserror` is declared as a `zesdex-utils` dependency but never imported anywhere in the crate either.
|
||||
|
||||
**Files:**
|
||||
- Delete: `crates/zesdex-utils/src/error.rs`
|
||||
- Modify: `crates/zesdex-utils/src/lib.rs` (remove `pub mod error;`)
|
||||
- Modify: `crates/zesdex-utils/Cargo.toml` (remove the unused `thiserror` dependency)
|
||||
|
||||
**Interfaces:** none — pure deletion.
|
||||
|
||||
- [ ] **Step 1: Verify zero remaining references**
|
||||
|
||||
Run: `grep -rln "zesdex_utils::error\|zesdex_utils::Error\|utils::error::" crates --include='*.rs'`
|
||||
Expected: only `crates/zesdex-utils/src/error.rs` itself (or no output once the file is deleted).
|
||||
|
||||
- [ ] **Step 2: Delete the file**
|
||||
|
||||
```bash
|
||||
git rm crates/zesdex-utils/src/error.rs
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove the module declaration**
|
||||
|
||||
In `crates/zesdex-utils/src/lib.rs`, remove:
|
||||
```rust
|
||||
pub mod error;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Remove the unused `thiserror` dependency**
|
||||
|
||||
In `crates/zesdex-utils/Cargo.toml`, remove:
|
||||
```toml
|
||||
thiserror = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build and test**
|
||||
|
||||
Run: `cargo build --workspace && cargo test -p zesdex-utils`
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore(utils): hapus custom Error type yang tidak dipakai (melanggar aturan anyhow-only)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Resolve the 10 `dead_code` allow-bypasses
|
||||
|
||||
**Context:** These directly contradict the workspace's own `dead_code = "deny"` lint. For each, remove the `#[allow(dead_code)]`/`#![allow(dead_code)]`, run the compiler, and act on its verdict: if genuinely unused, delete; if actually reachable through a path the lint can't see (e.g. only used in `#[cfg(test)]` or behind a feature), wire it into real production code instead of re-suppressing.
|
||||
|
||||
**Files:**
|
||||
- `crates/zesdex-backend/src/app/runtime/context/dedup.rs:1`
|
||||
- `crates/zesdex-backend/src/app/runtime/context/squash.rs:1`
|
||||
- `crates/zesdex-backend/src/app/runtime/context/window.rs:1`
|
||||
- `crates/zesdex-backend/src/app/runtime/context/tokens.rs:32`
|
||||
- `crates/zesdex-backend/src/app/subagent/spawn.rs:44,51`
|
||||
- `crates/zesdex-backend/src/app/state/misc.rs:409`
|
||||
- `crates/zesdex-backend/src/model/agent_def/{global.rs,builtin.rs,session.rs}:1`
|
||||
|
||||
**Interfaces:** varies per site — resolved during the investigation step, not fixed in advance (this is a "read what the compiler says, then act" task, not a hand-wave — see Step 1 of each site).
|
||||
|
||||
- [ ] **Step 1: `dedup.rs`, `squash.rs`, `window.rs` (module-level)**
|
||||
|
||||
Remove the `#![allow(dead_code)]` line from each of the three files. Run: `cargo build -p zesdex-backend 2>&1 | grep -A3 "never used"`
|
||||
|
||||
For each item the compiler flags as unused: check whether it's covered by a test in the same file's `#[cfg(test)] mod tests` (a test-only user doesn't count as a real caller and doesn't justify keeping the item) — if the item has zero non-test callers, delete it; if grepping the item's name elsewhere in `crates/zesdex-backend/src` (outside the file and outside `#[cfg(test)]` blocks) turns up a real caller the compiler somehow didn't connect (e.g. it's `pub` and meant for a different module that has a typo'd import), fix the import instead of deleting.
|
||||
|
||||
- [ ] **Step 2: `tokens.rs:32` (`count_message_tokens`)**
|
||||
|
||||
Read the function and its context: `grep -n -B5 -A15 "fn count_message_tokens" crates/zesdex-backend/src/app/runtime/context/tokens.rs`
|
||||
|
||||
Remove `#[allow(dead_code)]`. Run: `cargo build -p zesdex-backend 2>&1 | grep -A3 "count_message_tokens"`. If genuinely unused, delete the function (and any now-orphaned helper it alone called). If it looks like it *should* be called from the context-window-shaping logic in the same module (a token-counting function not being used by the token-budget code would itself be a functional gap worth flagging, not just a lint issue) — check `window.rs`'s `resolve()` and any `shaping`/`dedup` call sites for where a token count is needed but computed some other way, and wire `count_message_tokens` in there if that's the case; otherwise delete.
|
||||
|
||||
- [ ] **Step 3: `spawn.rs:44,51` (`with_max_steps`, `with_temperature` builder methods)**
|
||||
|
||||
Read the full builder struct: `grep -n -B20 "fn with_max_steps" crates/zesdex-backend/src/app/subagent/spawn.rs`
|
||||
|
||||
Remove both `#[allow(dead_code)]` lines. Run: `cargo build -p zesdex-backend 2>&1 | grep -A3 "with_max_steps\|with_temperature"`.
|
||||
|
||||
These configure per-agent `max_steps`/`temperature` on a subagent-spawn builder — check every call site that constructs this builder (`grep -rn "AgentSpawnBuilder\|::new()" crates/zesdex-backend/src/app/subagent/` — use the builder's actual type name found in Step 3's read) to see whether any caller *should* be setting these (e.g. does `hive_mind.rs`'s node-spawning code hardcode a default that should instead come from `Settings`/`NodeDirective` and isn't?). If a real caller needs them, wire them in (this may surface an actual functional gap, not just unused code — document what you find). If truly no caller has a legitimate need for per-agent overrides today, delete both methods and their backing struct fields if those fields are then also unused.
|
||||
|
||||
- [ ] **Step 4: `state/misc.rs:409` (`api_context_length` field)**
|
||||
|
||||
Read the surrounding struct: `grep -n -B15 -A5 "api_context_length" crates/zesdex-backend/src/app/state/misc.rs`
|
||||
|
||||
Remove `#[allow(dead_code)]`. Run: `cargo build -p zesdex-backend 2>&1 | grep -A3 "api_context_length"`. Check whether the status bar (`view/status.rs`) or connectivity-check code (`spawn_api_connectivity_check` in `actions/mod.rs`) should be displaying/using the model's context length but currently isn't — if so, wire it in; if the field was superseded by `app_config.model_roles[...].context_window` (per the CMS wiring plan) and is now genuinely redundant, delete the field.
|
||||
|
||||
- [ ] **Step 5: `model/agent_def/{global.rs,builtin.rs,session.rs}` (module-level)**
|
||||
|
||||
Same procedure as Step 1: remove each `#![allow(dead_code)]`, build, and either delete unused items or wire in real callers based on what the compiler reports.
|
||||
|
||||
- [ ] **Step 6: Full workspace build and test after all 10 sites are resolved**
|
||||
|
||||
Run: `cargo build --workspace && cargo test --workspace`
|
||||
Expected: no errors, no `dead_code` warnings anywhere (the workspace `deny` will turn any remaining one into a hard build failure, which is the actual verification that every site was genuinely resolved).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: hapus 10 allow(dead_code) - hapus kode mati atau sambungkan ke pemanggil nyata"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Resolve the 7 item-level clippy allows
|
||||
|
||||
**Files:**
|
||||
- `crates/zesdex-backend/src/app/review/mod.rs:371` (`clippy::unnecessary_debug_formatting`)
|
||||
- `crates/zesdex-backend/src/app/runtime/actions/mod.rs:1532` (`clippy::too_many_arguments`)
|
||||
- `crates/zesdex-backend/src/app/runtime/actions/mod.rs:99,912` (`clippy::too_many_lines`, x2)
|
||||
- `crates/zesdex-backend/src/app/subagent/engine.rs:326` (`clippy::too_many_lines`)
|
||||
- `crates/zesdex-backend/src/view/markdown.rs:62` (`clippy::too_many_lines`)
|
||||
- `crates/zesdex-backend/src/view/mod.rs:112` (`clippy::too_many_lines`)
|
||||
|
||||
**Interfaces:** none shared across sites — each is an independent, local fix.
|
||||
|
||||
- [ ] **Step 1: `clippy::unnecessary_debug_formatting` (easiest — do first)**
|
||||
|
||||
Read the flagged line: `grep -n -B3 -A3 "unnecessary_debug_formatting" crates/zesdex-backend/src/app/review/mod.rs`
|
||||
|
||||
Remove the `#[allow(clippy::unnecessary_debug_formatting)]` line. Run: `cargo clippy -p zesdex-backend 2>&1 | grep -A5 "unnecessary_debug_formatting"` to see the exact suggestion (clippy always proposes the fix inline — typically replacing a `format!("{:?}", x)` with `x.to_string()` or a `Display` impl call). Apply the suggested fix exactly.
|
||||
|
||||
- [ ] **Step 2: `clippy::too_many_arguments` on `actions/mod.rs:1532`**
|
||||
|
||||
Read the flagged function's full signature: `grep -n -B2 -A15 "clippy::too_many_arguments" crates/zesdex-backend/src/app/runtime/actions/mod.rs`
|
||||
|
||||
Bundle the excess parameters into a purpose-named struct (the standard fix for this lint). For example, if the function is `fn foo(a: X, b: Y, c: Z, d: W, ...) -> R`, introduce:
|
||||
```rust
|
||||
struct FooParams {
|
||||
a: X,
|
||||
b: Y,
|
||||
c: Z,
|
||||
d: W,
|
||||
// ...
|
||||
}
|
||||
```
|
||||
and change the signature to `fn foo(params: FooParams) -> R`, updating the function body to read `params.a`/`params.b`/etc., and updating the single call site to construct `FooParams { a, b, c, d, ... }`. (Exact field names/types depend on the actual signature found in this step's read — do not guess, use the literal parameter list.)
|
||||
|
||||
- [ ] **Step 3: `clippy::too_many_lines` — `actions/mod.rs:99` and `:912` (`run_agent_turn`)**
|
||||
|
||||
Read the full function: `grep -n -A 250 "^fn run_agent_turn" crates/zesdex-backend/src/app/runtime/actions/mod.rs | head -260`
|
||||
|
||||
This is the core per-turn agent loop — do not split it mechanically by line count; split along its own documented phase boundaries (the function's doc comment already describes them: "build system prompt → shape messages → call `chat_with_tools_streaming` → handle tool calls or unwrap final message → check unfinished todos → finalize"). Extract each phase that doesn't need to mutate more than 2-3 local variables into its own well-named private function, threading only what each phase actually needs as parameters (not the whole `TurnCtx` if a phase only reads one field). After extraction, re-add doc comments to each new function per CLAUDE.md's Code Documentation rules. Do this incrementally: extract one phase, build, test, commit; repeat rather than one giant rewrite, so a regression is easy to bisect.
|
||||
|
||||
Run after each extraction: `cargo build -p zesdex-backend && cargo test -p zesdex-backend`
|
||||
|
||||
Once the function is under clippy's threshold, remove the `#[allow(clippy::too_many_lines)]` at both flagged lines (99 and 912 — confirm both are on `run_agent_turn` or its immediate helper via the Step 1 grep; if they're on two different functions, repeat this decomposition process for each independently).
|
||||
|
||||
- [ ] **Step 4: `clippy::too_many_lines` — `app/subagent/engine.rs:326`**
|
||||
|
||||
Read the flagged function in full: `grep -n -B2 -A 200 "clippy::too_many_lines" crates/zesdex-backend/src/app/subagent/engine.rs | head -210`
|
||||
|
||||
Apply the same phase-based extraction approach as Step 3, scaled to this function's actual structure (read it first — do not assume it mirrors `run_agent_turn`'s shape).
|
||||
|
||||
- [ ] **Step 5: `clippy::too_many_lines` — `view/markdown.rs:62` and `view/mod.rs:112`**
|
||||
|
||||
Read both flagged functions in full first (`grep -n -A 150 "clippy::too_many_lines" crates/zesdex-backend/src/view/markdown.rs` and the equivalent for `view/mod.rs`). These are rendering functions — split along rendering sub-sections (e.g. one function per overlay/pane already rendered inline in a big `match`), extracting each `match` arm's body over some line-count threshold into its own `fn render_<thing>(f: &mut Frame, area: Rect, state: &AppStateRest)`-shaped helper, matching the existing `view/` module's established per-pane function naming convention (check `view/chat.rs`/`view/status.rs` for the naming pattern already in use and follow it).
|
||||
|
||||
- [ ] **Step 6: Full workspace verification**
|
||||
|
||||
Run: `cargo build --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings`
|
||||
Expected: all pass with zero `too_many_lines`/`too_many_arguments`/`unnecessary_debug_formatting` warnings and no remaining `#[allow]` for any of them.
|
||||
|
||||
- [ ] **Step 7: Commit each function's decomposition separately as you go (already instructed inline above) — final wrap-up commit if anything remains uncommitted**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor: pecah fungsi yang melanggar clippy::too_many_lines/too_many_arguments, hapus allow-nya"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Reduce the 93 module-level cast-quad allows
|
||||
|
||||
**Context:** `#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]` appears at the top of 93 files, evidently copy-pasted as workspace-wide boilerplate rather than justified per-file. This is the largest item in this plan by file count and — because it's the same mechanical recipe repeated 93 times — is best executed via `superpowers:subagent-driven-development` dispatching one subagent per file (or small batch of related files within the same crate) using the worked recipe below, rather than as one sequential task list here.
|
||||
|
||||
**Files:** all 93 listed in the audit's inventory (re-derive the authoritative current list before starting, since Tasks 1-3 and the other four plans in this series may have deleted or renamed some of them):
|
||||
|
||||
Run: `grep -rln "cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap" crates --include='*.rs'`
|
||||
|
||||
**Interfaces:** none shared — each file's fix is independent and self-contained.
|
||||
|
||||
- [ ] **Step 1: Worked example — pick one small, representative file first**
|
||||
|
||||
Read a small file from the list, e.g. `crates/zesdex-backend/src/app/mode/effort.rs` (confirm it's still in the current list from this task's Step-1 grep before using it as the example). Remove its `#![allow(clippy::cast_*...)]` line. Run:
|
||||
|
||||
Run: `cargo clippy -p zesdex-backend -- -D warnings 2>&1 | grep -B2 -A8 "effort.rs"`
|
||||
|
||||
For each flagged cast, apply the narrowest correct fix:
|
||||
- `x as u32` where `x: usize` and the value is a count/length that can't realistically exceed `u32::MAX` → `u32::try_from(x).unwrap_or(u32::MAX)` (saturating, since these are almost always display/telemetry values where saturating is safe) or, if the call site already returns `Result`, `u32::try_from(x)?`.
|
||||
- `x as i64` where `x: u64` timestamp (milliseconds since epoch) → these are safe until year 292471247, so `TryFrom` is technically correct but arguably pedantic; use `i64::try_from(x).unwrap_or(i64::MAX)` for consistency with the rule above rather than special-casing "this one's fine."
|
||||
- `x as f32`/`x as f64` (precision loss) on values already known to fit (e.g. small counters) → keep the cast but make it explicit and document why it's lossless in context: `#[expect(clippy::cast_precision_loss, reason = "...")]` is still a bypass and NOT allowed by CLAUDE.md — instead, if the value truly can't lose precision (e.g. casting a `u8` to `f32`), the lint won't even fire once the blanket module-level allow is removed, since clippy's precision-loss lint only fires above the point where precision loss is actually possible for the source type; if it does fire, use the same `try_from`-then-`as` pattern, or restructure to avoid the float conversion entirely if it's just for display (`format!("{x}")` instead of casting to display as a percentage, etc.).
|
||||
|
||||
Run: `cargo build -p zesdex-backend && cargo test -p zesdex-backend`
|
||||
Expected: no errors, no new warnings for this file.
|
||||
|
||||
- [ ] **Step 2: Commit the worked example**
|
||||
|
||||
```bash
|
||||
git add crates/zesdex-backend/src/app/mode/effort.rs
|
||||
git commit -m "fix: hapus allow cast-quad di effort.rs, ganti cast lossy dengan try_from"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Dispatch the remaining files via subagent-driven-development**
|
||||
|
||||
For the remaining files from Step 1's grep (minus the one just fixed), use `superpowers:subagent-driven-development` with one task per file (or per small group of 3-5 files within the same module, where that reads more naturally), each task instructing: "remove the `#![allow(clippy::cast_*)]` header from `<file>`, run `cargo clippy -p <crate> -- -D warnings` scoped to that file, and fix every flagged cast using the recipe demonstrated in `2026-07-16-convention-cleanup-docs.md` Task 4 Step 1 (prefer `TryFrom`/`try_from` with a saturating fallback for lossy integer casts; restructure to avoid unnecessary float casts where the value is just being displayed)." Review each file's diff before merging — this is exactly the kind of large, repetitive, low-per-item-risk task the subagent-driven workflow is for.
|
||||
|
||||
- [ ] **Step 4: Final workspace-wide verification**
|
||||
|
||||
Run: `grep -rln "cast_possible_truncation, clippy::cast_sign_loss" crates --include='*.rs'`
|
||||
Expected: no output (or, if a small number of files remain and are judged genuinely fine to leave as a future increment, that's a call for whoever is running this plan to make explicitly and document — not silently left as-is).
|
||||
|
||||
Run: `cargo build --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings`
|
||||
Expected: all pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Fill the remaining doc-comment gaps and remove dead scaffolding
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/zesdex-backend/src/view/mod.rs` (lines 10-15 — `pub mod chat/markdown/sidebar/status/theme/workflow`, 5 of 7 missing doc comments)
|
||||
- Modify: `crates/zesdex-backend/src/app/state/misc.rs` (line 340 `pub fn submit`, and the second gap the audit found around line 438)
|
||||
- Delete: `/mnt/code/zesdex/tests/` (confirmed empty and untracked — safe to remove; re-verify emptiness before deleting since time has passed since the original audit)
|
||||
|
||||
**Interfaces:** none — doc comments and a directory deletion, no behavior change.
|
||||
|
||||
- [ ] **Step 1: Add doc comments to `view/mod.rs`'s module declarations**
|
||||
|
||||
Read the current lines: `grep -n -B1 "^pub mod" crates/zesdex-backend/src/view/mod.rs`
|
||||
|
||||
For each of `chat`, `markdown`, `sidebar`, `status`, `theme`, `workflow` that lacks a one-line doc comment above it, add one describing what that view submodule renders — e.g.:
|
||||
|
||||
```rust
|
||||
/// Chat transcript pane: renders the scrollback of user/assistant/tool messages.
|
||||
pub mod chat;
|
||||
/// Markdown-to-styled-text rendering for assistant message content.
|
||||
pub mod markdown;
|
||||
/// Session sidebar: file tree / workspace navigation pane.
|
||||
pub mod sidebar;
|
||||
/// Status bar: provider/model, token usage, connectivity indicator.
|
||||
pub mod status;
|
||||
/// Color theme definitions for the TUI.
|
||||
pub mod theme;
|
||||
/// Hive-mind workflow panel: live node progress display.
|
||||
pub mod workflow;
|
||||
```
|
||||
|
||||
(Read each module's actual top-of-file doc comment first — `head -5 crates/zesdex-backend/src/view/{chat,markdown,sidebar,status,theme,workflow}.rs` — and base the one-liner on what that file's own doc comment says, rather than guessing, so the two stay consistent.)
|
||||
|
||||
- [ ] **Step 2: Add doc comments to the two flagged functions in `state/misc.rs`**
|
||||
|
||||
Read both: `grep -n -B2 -A8 "pub fn submit" crates/zesdex-backend/src/app/state/misc.rs` and the second flagged line (re-locate it — the original audit found it around line 438, but Task 2's dead-code cleanup on this same file may have shifted line numbers; search for the nearest undocumented `pub fn` instead of trusting the stale line number).
|
||||
|
||||
Add a doc comment to `submit` describing what it does (flow: clone the input buffer as the result, push to history if non-empty and not a duplicate of the last entry, persist history to disk if a history file is configured; return the submitted text) following CLAUDE.md's What/Flow/Why/Return structure, and do the same for the second flagged function once located.
|
||||
|
||||
- [ ] **Step 3: Remove the empty `tests/` directory**
|
||||
|
||||
Run: `find /mnt/code/zesdex/tests -mindepth 1` to confirm it's still empty.
|
||||
Expected: no output.
|
||||
|
||||
If confirmed empty:
|
||||
```bash
|
||||
rmdir /mnt/code/zesdex/tests
|
||||
```
|
||||
|
||||
(Use `rmdir`, not `rm -rf` — it only succeeds if the directory is genuinely empty, which is the safety property we want here.)
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
Run: `cargo build --workspace && cargo doc --workspace --no-deps 2>&1 | grep -i warn`
|
||||
Expected: no new warnings from `cargo doc` (missing-docs isn't a workspace lint here, but this is a quick sanity pass).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/zesdex-backend/src/view/mod.rs crates/zesdex-backend/src/app/state/misc.rs
|
||||
git rm -r --cached tests 2>/dev/null || true
|
||||
git commit -m "docs: lengkapi doc comment view/mod.rs & state/misc.rs, hapus dir tests/ kosong"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Repair `docs/CODEMAPS/*.md` and `CLAUDE.md` to match the post-migration + post-wiring layout
|
||||
|
||||
**Context:** All five CODEMAPS files and CLAUDE.md itself reference pre-workspace-migration paths (`src/main.rs` instead of `crates/zesdex-backend/src/main.rs`, etc.), and `dependencies.md` describes a monolithic-crate dependency list that predates the workspace split entirely. Run this task **last**, after the other four plans in this series have landed, since many paths this task documents (OAuth location, Settings/AppConfig/Memory/EditLog/Conversation persistence, the new HTTP bridge) only exist once those plans are applied.
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/CODEMAPS/architecture.md`
|
||||
- Modify: `docs/CODEMAPS/backend.md`
|
||||
- Modify: `docs/CODEMAPS/frontend.md`
|
||||
- Modify: `docs/CODEMAPS/data.md`
|
||||
- Modify: `docs/CODEMAPS/dependencies.md`
|
||||
- Modify: `/mnt/code/zesdex/CLAUDE.md`
|
||||
|
||||
**Interfaces:** none — documentation only.
|
||||
|
||||
- [ ] **Step 1: Regenerate the authoritative file-path list**
|
||||
|
||||
Run: `find crates -name '*.rs' -not -path '*/target/*' | sort > /tmp/current-rust-files.txt` and keep this alongside the docs while editing, so every path cited is checked against a real file, not memory.
|
||||
|
||||
- [ ] **Step 2: Fix `architecture.md`**
|
||||
|
||||
For every code path mentioned (the Key Files table and inline references), prepend the correct crate prefix — e.g. `src/main.rs` → `crates/zesdex-backend/src/main.rs`, `src/app/harness.rs` → `crates/zesdex-backend/src/app/harness.rs`, and so on for every row. Cross-check each against `/tmp/current-rust-files.txt` from Step 1 before writing it. Update the ASCII system-layout diagram's "Tool/Subagents/Workflow" box if the OAuth rewiring (from `2026-07-16-oauth-session-iam-wiring.md`) or the HTTP bridge (from `2026-07-16-middleware-axum-server.md`) changed anything structurally significant enough to belong in a top-level diagram (the HTTP bridge, being an alternate IPC transport, is worth one added line: "IPC (Unix domain socket, or optional HTTP bridge via `--http-port`)").
|
||||
|
||||
- [ ] **Step 3: Fix `backend.md`**
|
||||
|
||||
Correct every path (`dto/provider/` → `crates/zesdex-dto/src/provider/`, `service/oauth/` → now `crates/zesdex-iam/src/{application/oauth_service.rs,infrastructure/oauth_loopback.rs}` per the OAuth rewiring plan, `src/ipc/*.rs` → `crates/zesdex-ipc/src/{protocol,conn,client,server}.rs`, etc.). Add a new subsection documenting the OAuth/session/CMS wiring: which crate now owns each concern (`zesdex-iam` for OAuth+session, `zesdex-cms` for Settings/AppConfig/Memory/EditLog/Conversation, `zesdex-middleware` for the optional HTTP bridge's auth/CORS/rate-limiting), replacing any stale description of the old monolithic `service::oauth`/`model::{settings,app_config,memory,edit_log,msglog}` modules (which this plan's sibling plans delete).
|
||||
|
||||
- [ ] **Step 4: Fix `frontend.md`**
|
||||
|
||||
Correct every `main.rs`/`controller/input.rs`/`view/*.rs`/`app/mode/*.rs` reference to include the `crates/zesdex-backend/src/` prefix.
|
||||
|
||||
- [ ] **Step 5: Fix `data.md`**
|
||||
|
||||
Correct `src/app/state/rest.rs` → `crates/zesdex-backend/src/app/state/rest.rs`. Replace the section describing `src/model/{settings,app_config,memory,edit_log}.rs` (all deleted by the CMS wiring plans) with a description of `zesdex-cms`'s repository-based persistence (`JsonSettingsRepository`, `JsonAppConfigRepository`, `MarkdownMemoryRepository`, `JsonlEditLogRepository`, `JsonConversationRepository`, `FileRewindBlobRepository`) and where each writes on disk. Replace the `msglog` SQLite description with the new `Conversation`/`conversation.json` + file-based rewind blob store description.
|
||||
|
||||
- [ ] **Step 6: Rewrite `dependencies.md`**
|
||||
|
||||
Replace the header claim ("23 Rust crates" per CLAUDE.md / "30+ direct" per this file's own header — pick neither, state the actual count) with an accurate summary: list all 9 internal workspace crates (`zesdex-entities`, `zesdex-utils`, `zesdex-dto`, `zesdex-ipc`, `zesdex-iam`, `zesdex-cms`, `zesdex-middleware`, `zesdex-libs`, `zesdex-backend`) with a one-line purpose each, then the external dependency list — regenerate this list from the actual `[workspace.dependencies]` table in the root `Cargo.toml` rather than editing the existing prose by hand:
|
||||
|
||||
Run: `grep -A100 "\[workspace.dependencies\]" Cargo.toml`
|
||||
|
||||
Explicitly call out the dependencies added by the workspace migration that the current doc omits entirely: `axum`, `tower`, `tower-http`, `argon2`, `jsonwebtoken`, `thiserror` (note: `thiserror` may be removed from the workspace entirely by Task 1 of this plan if `zesdex-utils` was its only consumer — check with `grep -rln "thiserror" crates --include='*.rs' crates/*/Cargo.toml` before listing it as a current dependency).
|
||||
|
||||
- [ ] **Step 7: Fix `CLAUDE.md`**
|
||||
|
||||
Update every path in the "Key Files"-equivalent references (`src/main.rs`, `src/app/harness.rs`, `src/app/subagent/division.rs`, `src/app/workflow/hive_mind.rs`, `src/tool/workflow.rs`, `src/app/workflow/docs.rs`, `src/view/workflow.rs`, `src/app/subagent/auto.rs`) to their `crates/zesdex-backend/src/...` equivalents. Update the "Shell safety" line per `2026-07-16-security-quickfixes.md` Task 1 Step 4 if that plan hasn't already been applied. Update the "No custom error types" line's context if useful (it's now fully true rather than aspirational, per this plan's Task 1). Add one line under "Key Patterns" noting the optional HTTP daemon transport if `2026-07-16-middleware-axum-server.md` has been applied: "**Daemon transports** — Unix domain socket (default) or, with `--http-port`, an axum HTTP bridge (`ipc_http.rs`) speaking the same `ClientRequest`/`DaemonFrame` protocol, gated by `zesdex-middleware`'s session-auth/CORS/rate-limit layers."
|
||||
|
||||
- [ ] **Step 8: Verify every path cited resolves to a real file**
|
||||
|
||||
Run a small verification script for each doc — for every backtick-quoted path matching `src/` or `crates/`, confirm it exists:
|
||||
|
||||
```bash
|
||||
for f in docs/CODEMAPS/*.md CLAUDE.md; do
|
||||
grep -oE '`[a-zA-Z0-9_/.-]+\.rs`' "$f" | tr -d '`' | while read -r path; do
|
||||
[ -f "$path" ] || echo "MISSING in $f: $path"
|
||||
done
|
||||
done
|
||||
```
|
||||
|
||||
Expected: no `MISSING` lines. Fix any that appear.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/CODEMAPS CLAUDE.md
|
||||
git commit -m "docs: perbaiki path stale di CODEMAPS dan CLAUDE.md pasca migrasi workspace + wiring zesdex-iam/cms/middleware"
|
||||
```
|
||||
@@ -0,0 +1,523 @@
|
||||
# Middleware Axum Server 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:** Give the previously-orphaned `zesdex-middleware` crate (`SessionAuthLayer`, `default_cors_layer`, `RateLimitLayer`) a genuine integration point by adding an optional HTTP transport for the existing daemon, alongside (not replacing) the current Unix-socket transport.
|
||||
|
||||
**Important scope note — read before implementing:** unlike the OAuth/session/CMS wiring plans, there is **no existing HTTP server to fix or complete** — research confirmed zero axum usage anywhere in `zesdex-backend` and no design doc describing what one should do. This plan is therefore new-feature work, deliberately scoped as narrowly as possible: it exposes the *exact same* `ClientRequest`/`DaemonFrame` protocol the Unix-socket daemon already speaks, over HTTP, gated by the three middlewares. It does **not** invent a new REST API surface (no per-resource endpoints for settings/sessions/memory) — that would be scope creep beyond "give this crate a caller."
|
||||
|
||||
**Architecture:** Extract the daemon's per-request handling logic (`handle_daemon_client`'s match-on-`ClientRequest` body plus `send_daemon_update`) into transport-agnostic functions shared by both the existing Unix-socket loop and a new axum route. The state-owning thread gains an `mpsc` channel; the axum handler sends `(ClientRequest, oneshot::Sender<Vec<DaemonFrame>>)` and awaits the reply. `--http-port <PORT>` is a new opt-in CLI flag on `--daemon` — when absent, behavior is byte-for-byte identical to today (Unix socket only).
|
||||
|
||||
**Tech Stack:** Rust, `axum`, `tokio` (already workspace deps), `zesdex-middleware`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- The Unix-socket transport's behavior must be provably unchanged — the refactor in Task 1 extracts logic without altering it, verified by the existing (or newly added, if none exist) daemon tests passing identically before and after.
|
||||
- No new `#[allow(...)]` attributes.
|
||||
- Tests are inline `#[cfg(test)] mod tests`.
|
||||
- Run `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings` before each commit.
|
||||
- The HTTP transport is opt-in (`--http-port`) and OFF by default — it must not change any existing invocation's behavior.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extract transport-agnostic request handling from `handle_daemon_client`
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/zesdex-backend/src/main.rs` (functions `handle_daemon_client` ~line 336, `send_daemon_update` ~line 207)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `fn build_state_update_frame(state: &AppStateRest) -> ipc::protocol::DaemonFrame` (pure builder, extracted from `send_daemon_update`) and `fn process_client_request(state: &mut AppStateRest, req: ipc::protocol::ClientRequest) -> (bool, Vec<ipc::protocol::DaemonFrame>)` (pure state-mutation + frame-collection, extracted from `handle_daemon_client`'s match body) — both consumed by Task 3's axum handler and Task 2's refactored Unix-socket loop.
|
||||
|
||||
- [ ] **Step 1: Extract `build_state_update_frame`**
|
||||
|
||||
In `crates/zesdex-backend/src/main.rs`, split `send_daemon_update` (current body at line ~207-238) into a pure builder plus a thin I/O wrapper:
|
||||
|
||||
```rust
|
||||
/// Flatten the daemon's `AppStateRest` into a `StatePayload` wrapped in a
|
||||
/// `DaemonFrame::StateUpdate` — the pure, transport-agnostic half of what
|
||||
/// was previously `send_daemon_update`.
|
||||
///
|
||||
/// Why: the client never shares memory with the daemon, so every action
|
||||
/// on the daemon side is followed by a full state push rather than a diff.
|
||||
fn build_state_update_frame(state: &app::state::rest::AppStateRest) -> ipc::protocol::DaemonFrame {
|
||||
use ipc::protocol::{DaemonFrame, MessageEntry, ToastEntry, StatePayload};
|
||||
|
||||
let messages: Vec<MessageEntry> = state.transcript_cache.messages.iter().map(|m| {
|
||||
MessageEntry {
|
||||
role: format!("{:?}", m.role),
|
||||
content: m.content.clone(),
|
||||
timestamp: m.timestamp,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
let toasts: Vec<ToastEntry> = state.misc.toasts.iter().map(|t| {
|
||||
ToastEntry {
|
||||
kind: format!("{:?}", t.kind),
|
||||
message: t.message.clone(),
|
||||
created_at: t.created_at,
|
||||
lifetime_ms: t.lifetime_ms,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
let overlay = if state.misc.overlay.is_active() {
|
||||
Some(format!("{:?}", state.misc.overlay))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Keep every remaining `StatePayload` field exactly as the original
|
||||
// `send_daemon_update` built it (input buffer/cursor, etc.) — copy the
|
||||
// rest of the struct-literal body unchanged from the pre-refactor code.
|
||||
DaemonFrame::StateUpdate(Box::new(StatePayload {
|
||||
session_id: state.session_id.clone(),
|
||||
messages,
|
||||
toasts,
|
||||
overlay,
|
||||
// ...(remaining fields copied verbatim from the original function)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Send a `DaemonFrame::StateUpdate` to an attached Unix-socket client.
|
||||
fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &app::state::rest::AppStateRest) -> Result<()> {
|
||||
conn.send(&build_state_update_frame(state))
|
||||
}
|
||||
```
|
||||
|
||||
(Read the full original `send_daemon_update` body first — `sed`/`grep -n -A 45 "fn send_daemon_update" crates/zesdex-backend/src/main.rs` — and carry over every `StatePayload` field exactly; the excerpt above only shows the fields already visible in this plan's earlier research, do not drop any field the original builds.)
|
||||
|
||||
- [ ] **Step 2: Extract `process_client_request`**
|
||||
|
||||
Replace `handle_daemon_client`'s inner `match req { ... }` block with a new standalone function that returns frames instead of writing to a `Connection`:
|
||||
|
||||
```rust
|
||||
/// Apply one `ClientRequest` to `state` and collect the `DaemonFrame`(s) it
|
||||
/// produces — the pure, transport-agnostic half of what was previously
|
||||
/// inlined in `handle_daemon_client`'s read loop.
|
||||
///
|
||||
/// Return: `(keep_running, frames)` — `keep_running` is `false` only for
|
||||
/// `ClientRequest::Close`; `frames` always ends with a `StateUpdate` frame,
|
||||
/// preceded by a `ClipboardCopy` frame if a copy was pending.
|
||||
fn process_client_request(
|
||||
state: &mut app::state::rest::AppStateRest,
|
||||
req: ipc::protocol::ClientRequest,
|
||||
) -> (bool, Vec<ipc::protocol::DaemonFrame>) {
|
||||
use app::runtime::actions::{Action, apply_action};
|
||||
use ipc::protocol::ClientRequest;
|
||||
|
||||
let mut running = true;
|
||||
match req {
|
||||
ClientRequest::Tick => {
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::KeyPress { key, ctrl, alt, shift } => {
|
||||
let mut modifiers = crossterm::event::KeyModifiers::NONE;
|
||||
if ctrl { modifiers |= crossterm::event::KeyModifiers::CONTROL; }
|
||||
if alt { modifiers |= crossterm::event::KeyModifiers::ALT; }
|
||||
if shift { modifiers |= crossterm::event::KeyModifiers::SHIFT; }
|
||||
let key_event = crossterm::event::KeyEvent::new(key_action_to_code(&key), modifiers);
|
||||
let actions = controller::input::handle_key(key_event, state);
|
||||
for action in actions {
|
||||
apply_action(state, action);
|
||||
}
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Submit(text) => {
|
||||
state.input.buffer = text;
|
||||
let enter_event = crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::NONE);
|
||||
let actions = controller::input::handle_key(enter_event, state);
|
||||
for action in actions {
|
||||
apply_action(state, action);
|
||||
}
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Paste(text) => {
|
||||
state.input.buffer.insert_str(state.input.cursor, &text);
|
||||
state.input.cursor += text.len();
|
||||
state.dirty = true;
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Resize(w, h) => {
|
||||
apply_action(state, Action::Resize(w, h));
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::ScrollUp => {
|
||||
apply_action(state, Action::ScrollUp);
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::ScrollDown => {
|
||||
apply_action(state, Action::ScrollDown);
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Close => {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
let mut frames = Vec::new();
|
||||
if let Some(text) = state.misc.pending_clipboard_copy.take() {
|
||||
frames.push(ipc::protocol::DaemonFrame::ClipboardCopy(text));
|
||||
}
|
||||
frames.push(build_state_update_frame(state));
|
||||
(running, frames)
|
||||
}
|
||||
```
|
||||
|
||||
(Every match arm's body is copied verbatim from the pre-refactor `handle_daemon_client` — no logic changes, only relocation.)
|
||||
|
||||
- [ ] **Step 3: Rewrite `handle_daemon_client` as a thin wrapper**
|
||||
|
||||
```rust
|
||||
fn handle_daemon_client(
|
||||
mut conn: ipc::conn::Connection,
|
||||
state: &mut app::state::rest::AppStateRest,
|
||||
) -> Result<()> {
|
||||
use ipc::protocol::ClientRequest;
|
||||
|
||||
loop {
|
||||
match conn.receive::<ClientRequest>()? {
|
||||
Some(req) => {
|
||||
let (running, frames) = process_client_request(state, req);
|
||||
for frame in frames {
|
||||
conn.send(&frame)?;
|
||||
}
|
||||
if !running {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
(Note: the original sent `ClipboardCopy` then a `StateUpdate` as two separate `conn.send` calls per request — the `for frame in frames` loop preserves that exact ordering since `process_client_request` pushes them in the same order.)
|
||||
|
||||
- [ ] **Step 4: Build and test**
|
||||
|
||||
Run: `cargo build -p zesdex-backend && cargo test -p zesdex-backend`
|
||||
Expected: no errors, all existing tests pass.
|
||||
|
||||
- [ ] **Step 5: Manual regression check on the Unix-socket path**
|
||||
|
||||
Run the daemon + attach flow manually (`cargo run -p zesdex-backend -- --daemon` in one terminal, `cargo run -p zesdex-backend -- --attach <session-id>` in another) and confirm keypresses, submit, resize, scroll, and clean close all behave exactly as before this refactor.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/zesdex-backend/src/main.rs
|
||||
git commit -m "refactor(backend): ekstrak process_client_request/build_state_update_frame agar transport-agnostic"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add an `mpsc`-bridged worker so the state owner can serve two transports
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/zesdex-backend/src/main.rs` (`run_daemon`, ~line 427)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `run_daemon` spawns the existing Unix-socket accept loop on the calling thread as today, but if `--http-port` is set (Task 4), a second axum server (Task 3) sends requests into the same state via a shared `std::sync::mpsc::Sender<(ClientRequest, std::sync::mpsc::Sender<Vec<DaemonFrame>>)>` that the daemon's main loop polls alongside the Unix-socket `accept()`.
|
||||
|
||||
- [ ] **Step 1: Add a request channel to the daemon loop**
|
||||
|
||||
Read the current `run_daemon` in full first: `grep -n -A 60 "fn run_daemon" crates/zesdex-backend/src/main.rs`
|
||||
|
||||
Introduce, near the top of `run_daemon` (after `state` is constructed, before the accept loop):
|
||||
|
||||
```rust
|
||||
// Bridge channel: lets an (optional) HTTP transport submit
|
||||
// `ClientRequest`s into this thread's owned `AppStateRest`, exactly as
|
||||
// the Unix-socket accept loop does. `bridge_rx` is polled with a
|
||||
// short timeout alongside `server.accept()` so both transports can
|
||||
// make progress on the single thread that owns `state`.
|
||||
let (bridge_tx, bridge_rx) = std::sync::mpsc::channel::<(
|
||||
ipc::protocol::ClientRequest,
|
||||
std::sync::mpsc::Sender<Vec<ipc::protocol::DaemonFrame>>,
|
||||
)>();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Poll the bridge channel in the accept loop**
|
||||
|
||||
Locate the existing `loop { match server.accept() { ... } }` (or equivalent) in `run_daemon`. Since `UnixListener::accept()` blocks, switch it to non-blocking with a short poll interval so the bridge channel also gets serviced:
|
||||
|
||||
```rust
|
||||
server.set_nonblocking(true)?; // confirm `IpcServer` exposes this — if not, add a thin `set_nonblocking` passthrough to `zesdex-ipc`'s `IpcServer` in this same task
|
||||
loop {
|
||||
// Drain any pending HTTP-bridged requests first.
|
||||
while let Ok((req, reply_tx)) = bridge_rx.try_recv() {
|
||||
let (_running, frames) = process_client_request(&mut state, req);
|
||||
let _ = reply_tx.send(frames);
|
||||
}
|
||||
|
||||
match server.accept() {
|
||||
Ok(conn) => {
|
||||
handle_daemon_client(conn, &mut state)?;
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("daemon: accept error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(If `IpcServer` doesn't currently expose `set_nonblocking`, add it to `crates/zesdex-ipc/src/server.rs` as a one-line passthrough to the underlying `UnixListener::set_nonblocking`, with a doc comment explaining why: enables polling the HTTP bridge channel on the same thread without blocking indefinitely on Unix-socket `accept()`.)
|
||||
|
||||
- [ ] **Step 3: Thread `bridge_tx` out to Task 3**
|
||||
|
||||
Have `run_daemon` pass a clone of `bridge_tx` to the HTTP-server-spawning code added in Task 4 (only reached when `--http-port` is set).
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `cargo check -p zesdex-backend`
|
||||
Expected: no errors (Task 4 hasn't added the HTTP server yet, so `bridge_tx` may show an "unused" warning until then — acceptable transiently within this plan's own task sequence, but must be resolved by the time Task 4 finishes; do not leave an `#[allow(dead_code)]` on it in the interim).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/zesdex-backend/src/main.rs crates/zesdex-ipc/src/server.rs
|
||||
git commit -m "feat(backend): tambahkan channel jembatan mpsc di run_daemon untuk transport HTTP opsional"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add the axum HTTP bridge endpoint using `zesdex-middleware`
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/zesdex-backend/src/ipc_http.rs`
|
||||
- Modify: `crates/zesdex-backend/src/main.rs` (module declaration + call site)
|
||||
- Modify: `crates/zesdex-backend/Cargo.toml` (confirm `axum`/`tokio` already present — they are, per workspace deps; no change needed, just verify with `grep -E "^axum|^tokio" crates/zesdex-backend/Cargo.toml`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `zesdex_middleware::auth::{SessionAuthLayer, SessionIdentity}`, `zesdex_middleware::cors::default_cors_layer`, `zesdex_middleware::rate_limit::{RateLimiter, RateLimitLayer}` (with `trust_proxy_headers: false` per the `2026-07-16-security-quickfixes.md` plan's Task 2), the `bridge_tx` sender from Task 2.
|
||||
- Produces: `pub async fn serve_http_bridge(port: u16, store: zesdex_entities::seaorm::common::store::Store, bridge_tx: std::sync::mpsc::Sender<(ClientRequest, std::sync::mpsc::Sender<Vec<DaemonFrame>>)>) -> anyhow::Result<()>` — spawned as a tokio task by `run_daemon` when `--http-port` is set.
|
||||
|
||||
- [ ] **Step 1: Write the route handler**
|
||||
|
||||
Create `crates/zesdex-backend/src/ipc_http.rs`:
|
||||
|
||||
```rust
|
||||
//! Optional HTTP transport for the daemon, bridging to the same
|
||||
//! `ClientRequest`/`DaemonFrame` protocol the Unix-socket transport uses.
|
||||
//!
|
||||
//! Exists solely to give `zesdex-middleware`'s `SessionAuthLayer`,
|
||||
//! `default_cors_layer`, and `RateLimitLayer` a real caller — it
|
||||
//! deliberately does NOT introduce a new REST API surface; the one route
|
||||
//! below is a thin bridge onto the pre-existing IPC protocol.
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use axum::routing::post;
|
||||
use axum::Router;
|
||||
|
||||
use ipc::protocol::{ClientRequest, DaemonFrame};
|
||||
|
||||
type BridgeSender = std::sync::mpsc::Sender<(ClientRequest, std::sync::mpsc::Sender<Vec<DaemonFrame>>)>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HttpBridgeState {
|
||||
bridge_tx: std::sync::Arc<std::sync::Mutex<BridgeSender>>,
|
||||
}
|
||||
|
||||
/// Handle one bridged `ClientRequest`, blocking (on a blocking-safe tokio
|
||||
/// task) until the daemon's state-owning thread replies.
|
||||
///
|
||||
/// Flow: build a one-shot `std::sync::mpsc` reply channel → send
|
||||
/// `(req, reply_tx)` into the daemon's bridge channel → block on
|
||||
/// `reply_rx.recv()` via `tokio::task::spawn_blocking` (since the daemon
|
||||
/// thread's reply is synchronous, not a future) → return the frames as
|
||||
/// JSON.
|
||||
///
|
||||
/// Return: `200` with the frame list on success, `500` if the daemon
|
||||
/// thread is gone (channel send/receive failed) or the bridge send failed.
|
||||
async fn handle_request(
|
||||
State(state): State<HttpBridgeState>,
|
||||
Json(req): Json<ClientRequest>,
|
||||
) -> impl IntoResponse {
|
||||
let (reply_tx, reply_rx) = std::sync::mpsc::channel();
|
||||
let send_result = state
|
||||
.bridge_tx
|
||||
.lock()
|
||||
.map_err(|_| ())
|
||||
.and_then(|tx| tx.send((req, reply_tx)).map_err(|_| ()));
|
||||
|
||||
if send_result.is_err() {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(Vec::<DaemonFrame>::new()));
|
||||
}
|
||||
|
||||
let frames = tokio::task::spawn_blocking(move || reply_rx.recv().unwrap_or_default())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
(StatusCode::OK, Json(frames))
|
||||
}
|
||||
|
||||
/// Serve the HTTP bridge on `127.0.0.1:<port>`, gated by session auth,
|
||||
/// CORS, and rate limiting from `zesdex-middleware`.
|
||||
///
|
||||
/// Why 127.0.0.1 only: this bridge is meant for local attach clients that
|
||||
/// prefer HTTP over a Unix socket (e.g. a browser-based frontend on the
|
||||
/// same machine), not a remote API — it is never exposed beyond loopback.
|
||||
///
|
||||
/// Return: `Err` if the port can't be bound; otherwise runs until the
|
||||
/// process exits (mirrors the Unix-socket daemon's lifetime).
|
||||
pub async fn serve_http_bridge(
|
||||
port: u16,
|
||||
store: zesdex_entities::seaorm::common::store::Store,
|
||||
bridge_tx: BridgeSender,
|
||||
) -> anyhow::Result<()> {
|
||||
let http_state = HttpBridgeState {
|
||||
bridge_tx: std::sync::Arc::new(std::sync::Mutex::new(bridge_tx)),
|
||||
};
|
||||
|
||||
let rate_limiter = zesdex_middleware::rate_limit::RateLimiter::new(/* existing constructor args, e.g. window/limit — read crates/zesdex-middleware/src/rate_limit.rs's `RateLimiter::new` signature first */);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/ipc/request", post(handle_request))
|
||||
.layer(zesdex_middleware::auth::SessionAuthLayer::new(store))
|
||||
.layer(zesdex_middleware::cors::default_cors_layer())
|
||||
.layer(zesdex_middleware::rate_limit::RateLimitLayer::new(rate_limiter))
|
||||
.with_state(http_state);
|
||||
|
||||
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
tracing::info!("[http-bridge] listening on {addr}");
|
||||
axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn serve_http_bridge_rejects_requests_without_session_header() {
|
||||
let store = zesdex_entities::seaorm::common::store::Store::new();
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
// Bind on port 0 equivalent isn't directly expressible via this
|
||||
// function's fixed-port signature — for this test, spawn the
|
||||
// server on an ephemeral high port and hit it with `reqwest`,
|
||||
// asserting a 401 when `X-Session-Id` is absent. Pick a
|
||||
// collision-unlikely test port derived from the process id to
|
||||
// avoid flaky parallel-test port clashes:
|
||||
let port = 20000 + (std::process::id() % 10000) as u16;
|
||||
let server = tokio::spawn(serve_http_bridge(port, store, tx));
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(format!("http://127.0.0.1:{port}/ipc/request"))
|
||||
.json(&ClientRequest::Tick)
|
||||
.send()
|
||||
.await
|
||||
.expect("request should reach the server");
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED);
|
||||
|
||||
server.abort();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(The `RateLimiter::new` call needs its actual constructor arguments — read `crates/zesdex-middleware/src/rate_limit.rs` first to fill these in precisely; after applying `2026-07-16-security-quickfixes.md`'s Task 2, prefer `RateLimiter::new(...)` — the safe, non-proxy-trusting constructor — over `with_proxy_trust`, since this bridge sits directly on loopback with no fronting proxy.)
|
||||
|
||||
- [ ] **Step 2: Register the module**
|
||||
|
||||
In `crates/zesdex-backend/src/main.rs`, add near the other `mod`/`use` declarations:
|
||||
|
||||
```rust
|
||||
mod ipc_http;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the test**
|
||||
|
||||
Run: `cargo test -p zesdex-backend serve_http_bridge_rejects -- --nocapture`
|
||||
Expected: pass (needs `SessionAuthLayer` to actually reject unauthenticated requests — if the test fails because `SessionAuthLayer`'s validation logic doesn't match this expectation, read `crates/zesdex-middleware/src/auth.rs`'s `validate_session`/`SessionAuthMiddleware::call` in full and adjust the test to match its actual documented rejection behavior rather than changing the middleware itself, since that's pre-existing, previously-audited code out of this plan's scope).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/zesdex-backend/src/ipc_http.rs crates/zesdex-backend/src/main.rs
|
||||
git commit -m "feat(backend): tambahkan HTTP bridge axum untuk IPC, memakai zesdex-middleware"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Add the `--http-port` CLI flag
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/zesdex-backend/src/main.rs` (argument parsing near the `--daemon`/`--attach` flags, and the end of `run_daemon` where the bridge is spawned)
|
||||
|
||||
**Interfaces:** none new — wires Task 2's `bridge_tx` and Task 3's `serve_http_bridge` together, gated by the flag.
|
||||
|
||||
- [ ] **Step 1: Add flag parsing**
|
||||
|
||||
Read the existing flag-parsing code first: `grep -n -B2 -A10 "\-\-daemon\|\-\-attach" crates/zesdex-backend/src/main.rs | head -40`
|
||||
|
||||
Add a `--http-port <PORT>` flag using the same parsing style already present (whatever library/manual parsing the existing flags use), defaulting to `None` (HTTP transport disabled) when absent.
|
||||
|
||||
- [ ] **Step 2: Spawn the HTTP bridge conditionally in `run_daemon`**
|
||||
|
||||
After Task 2's `bridge_tx`/`bridge_rx` setup, add:
|
||||
|
||||
```rust
|
||||
if let Some(port) = http_port {
|
||||
let store_for_http = zesdex_entities::seaorm::common::store::Store::new();
|
||||
let bridge_tx_for_http = bridge_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime for HTTP bridge");
|
||||
if let Err(e) = rt.block_on(ipc_http::serve_http_bridge(port, store_for_http, bridge_tx_for_http)) {
|
||||
tracing::error!("[http-bridge] server error: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build and test**
|
||||
|
||||
Run: `cargo build --workspace && cargo test --workspace`
|
||||
Expected: no errors, all pass.
|
||||
|
||||
- [ ] **Step 4: Manual smoke test — HTTP transport off by default**
|
||||
|
||||
Run: `cargo run -p zesdex-backend -- --daemon` (no `--http-port`). Confirm the daemon starts and the Unix-socket path works exactly as before (attach a client, verify interaction).
|
||||
|
||||
- [ ] **Step 5: Manual smoke test — HTTP transport enabled**
|
||||
|
||||
Run: `cargo run -p zesdex-backend -- --daemon --http-port 18080`. From another terminal, `curl -X POST http://127.0.0.1:18080/ipc/request -H 'Content-Type: application/json' -H 'X-Session-Id: <a-real-session-id>' -d '"Tick"'` and confirm a `200` with a JSON frame list; retry without the `X-Session-Id` header and confirm `401`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/zesdex-backend/src/main.rs
|
||||
git commit -m "feat(backend): tambahkan flag --http-port opsional untuk daemon HTTP bridge"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Run the full workspace verification
|
||||
|
||||
- [ ] **Step 1: Full build**
|
||||
|
||||
Run: `cargo build --workspace`
|
||||
|
||||
- [ ] **Step 2: Full test suite**
|
||||
|
||||
Run: `cargo test --workspace`
|
||||
|
||||
- [ ] **Step 3: Full clippy**
|
||||
|
||||
Run: `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
|
||||
- [ ] **Step 4: Confirm `zesdex-middleware` is no longer orphaned**
|
||||
|
||||
Run: `grep -rln "zesdex_middleware::" crates/zesdex-backend/src`
|
||||
Expected: `crates/zesdex-backend/src/ipc_http.rs` (this plan's new file).
|
||||
|
||||
- [ ] **Step 5: Commit (if any cleanup was needed)**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: verifikasi akhir wiring zesdex-middleware ke daemon HTTP bridge"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
||||
# Security Quick-Fixes 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:** Fix the two standalone, low-risk findings from the 2026-07-16 audit that don't require touching the OAuth/session/CMS architecture: the misleading doc-comment on the `bash` tool's credential-read behavior, and the rate limiter trusting client-controlled `X-Forwarded-For`/`X-Real-IP` headers.
|
||||
|
||||
**Architecture:** No structural changes. Both fixes are localized to a single file each.
|
||||
|
||||
**Tech Stack:** Rust, Cargo workspace (`zesdex-backend`, `zesdex-middleware`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No `#[allow(...)]` lint-bypass attributes may be introduced (workspace `Cargo.toml` denies `dead_code`/`unused`; CLAUDE.md forbids bypass annotations outright).
|
||||
- Every new/changed `pub fn` needs an accurate doc comment (What/Flow/Why/Return per CLAUDE.md's Code Documentation section).
|
||||
- Tests are inline `#[cfg(test)] mod tests` blocks in the same file, per CLAUDE.md.
|
||||
- Run `cargo clippy --workspace --all-targets -- -D warnings` and `cargo test --workspace` before each commit in this plan.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Fix misleading doc comment on `Bash::run` re: credential reads
|
||||
|
||||
**Context:** The audit flagged `crates/zesdex-backend/src/tool/shell_filter/credentials.rs`'s `check_credential_read` as "dead code that contradicts CLAUDE.md's claim that shell_filter blocks credential leaks." On closer reading, this is **not a behavior bug** — `crates/zesdex-backend/src/tool/shell.rs` has a deliberate, reasoned inline comment (lines 71-73) explaining that credential reads are intentionally allowed locally (the AI needs access; the real threat is committing secrets to a public repo, handled elsewhere). The actual defect is narrower: the doc comment on `run()` (lines 49-51) claims it calls `check_credential_read` when it doesn't, and CLAUDE.md's "Key Patterns" section overstates what `shell_filter` does. This task corrects both to match actual (intentional) behavior — it does **not** change runtime behavior.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/zesdex-backend/src/tool/shell.rs:47-59`
|
||||
- Modify: `/mnt/code/zesdex/CLAUDE.md` (the "Shell safety" line under "Key Patterns")
|
||||
- Modify: `crates/zesdex-backend/src/tool/shell_filter/credentials.rs` (module doc comment, to mark it as intentionally unused-by-`shell.rs` rather than implying it's wired in)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing new.
|
||||
- Produces: nothing new (doc-only change). No downstream task depends on this.
|
||||
|
||||
- [ ] **Step 1: Read the current state to confirm line numbers haven't drifted**
|
||||
|
||||
Run: `grep -n "check_credential_read\|Only gate destructive" crates/zesdex-backend/src/tool/shell.rs`
|
||||
Expected output includes the doc comment around line 49 and the inline comment around line 71.
|
||||
|
||||
- [ ] **Step 2: Fix the stale doc comment on `run()`**
|
||||
|
||||
In `crates/zesdex-backend/src/tool/shell.rs`, replace:
|
||||
|
||||
```rust
|
||||
/// Run a bash command (foreground or background) with safety filters and a timeout.
|
||||
///
|
||||
/// Flow: extract args → run `check_credential_read` then `check_git_destructive`
|
||||
/// (bail if either rejects) → branch on `run_in_background`: if true, hand off
|
||||
/// to the bg-bash subsystem and return the job ID; else spawn `bash -c`,
|
||||
/// poll with `try_wait`, kill on timeout, format combined stdout+stderr.
|
||||
///
|
||||
/// Why: the safety filters run unconditionally so background jobs are also gated;
|
||||
/// the timeout is enforced by polling the child rather than relying on a libc alarm
|
||||
/// so cleanup stays in Rust.
|
||||
///
|
||||
/// Return: exit-code + elapsed-seconds summary line (plus captured output) for
|
||||
/// foreground runs, or the job ID for background runs.
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```rust
|
||||
/// Run a bash command (foreground or background) with a safety filter and a timeout.
|
||||
///
|
||||
/// Flow: extract args → run `check_git_destructive` (bail if it rejects) → branch on
|
||||
/// `run_in_background`: if true, hand off to the bg-bash subsystem and return the
|
||||
/// job ID; else spawn `bash -c`, poll with `try_wait`, kill on timeout, format
|
||||
/// combined stdout+stderr.
|
||||
///
|
||||
/// Why: only destructive git operations are gated here — credential-file reads
|
||||
/// (`~/.ssh/id_rsa`, `.netrc`, etc.) are deliberately NOT blocked, since the agent
|
||||
/// often needs to read local config for legitimate debugging; the real leak vector
|
||||
/// (committing secrets to a remote) is handled by git hooks/user review, not this
|
||||
/// tool. `shell_filter::credentials::check_credential_read` exists but is
|
||||
/// intentionally not called from here — see its module doc comment. The safety
|
||||
/// filter runs unconditionally so background jobs are also gated; the timeout is
|
||||
/// enforced by polling the child rather than relying on a libc alarm so cleanup
|
||||
/// stays in Rust.
|
||||
///
|
||||
/// Return: exit-code + elapsed-seconds summary line (plus captured output) for
|
||||
/// foreground runs, or the job ID for background runs.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Mark `check_credential_read` as intentionally unused, not dead**
|
||||
|
||||
Read `crates/zesdex-backend/src/tool/shell_filter/credentials.rs` in full first:
|
||||
|
||||
Run: `cat crates/zesdex-backend/src/tool/shell_filter/credentials.rs`
|
||||
|
||||
Add a module-level doc comment at the top of the file (before any existing doc comment on `check_credential_read` itself — do not remove the existing function-level doc, just add context above it):
|
||||
|
||||
```rust
|
||||
//! Credential-file-read detection.
|
||||
//!
|
||||
//! Not currently called from `tool::shell::Bash::run` — see that function's
|
||||
//! doc comment for why credential reads are intentionally allowed. This
|
||||
//! module is kept for callers that DO want to block credential reads (e.g.
|
||||
//! a future sandboxed/untrusted-tool execution path) and is covered by its
|
||||
//! own inline tests below.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Fix CLAUDE.md's overstated claim**
|
||||
|
||||
In `/mnt/code/zesdex/CLAUDE.md`, find the line under "Key Patterns":
|
||||
|
||||
```
|
||||
- **Shell safety** — `tool/shell_filter/` blocks credential leaks and destructive git commands.
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```
|
||||
- **Shell safety** — `tool/shell_filter/` blocks destructive git commands (`shell_filter::git::check_git_destructive`, called from `tool/shell.rs::Bash::run`). It also contains a `check_credential_read` detector for credential-file reads, but that one is intentionally NOT wired into `Bash::run` today — see the doc comment on `Bash::run` for why.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify the crate still builds and lints clean**
|
||||
|
||||
Run: `cargo check -p zesdex-backend`
|
||||
Expected: no errors (doc-only + comment changes).
|
||||
|
||||
Run: `cargo clippy -p zesdex-backend -- -D warnings`
|
||||
Expected: no new warnings.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/zesdex-backend/src/tool/shell.rs crates/zesdex-backend/src/tool/shell_filter/credentials.rs CLAUDE.md
|
||||
git commit -m "docs(shell): perbaiki doc comment shell_filter yang menyesatkan soal credential-read"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Stop trusting client-supplied `X-Forwarded-For`/`X-Real-IP` in the rate limiter
|
||||
|
||||
**Context:** `crates/zesdex-middleware/src/rate_limit.rs` derives its per-client bucket key from `X-Forwarded-For`/`X-Real-IP` headers before falling back to the real socket address. Since this middleware isn't behind a trusted reverse proxy today (confirmed: no proxy config anywhere in the workspace), any direct caller can forge these headers to get a fresh rate-limit bucket on every request. This crate is currently unused/orphaned (no axum server exists yet to mount it on — see the separate `2026-07-16-middleware-axum-server.md` plan for that), but the fix belongs here as a standalone code-correctness task since it doesn't depend on that server existing.
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/zesdex-middleware/src/rate_limit.rs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing new.
|
||||
- Produces: `RateLimiter`/`RateLimitLayer` public API unchanged in shape; only the client-id derivation logic changes. Any future caller (including the axum-server plan) must pass `ConnectInfo<SocketAddr>` — note this for that plan.
|
||||
|
||||
- [ ] **Step 1: Read the current implementation**
|
||||
|
||||
Run: `cat crates/zesdex-middleware/src/rate_limit.rs`
|
||||
|
||||
Confirm the client-id extraction logic (around lines 190-210 per the audit) checks `X-Forwarded-For` first, then `X-Real-IP`, then falls back to the connection's socket address.
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
Add to the `#[cfg(test)] mod tests` block at the bottom of `crates/zesdex-middleware/src/rate_limit.rs` (create the block if none exists yet — confirm via the Step 1 read):
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn client_id_ignores_spoofed_forwarded_headers_by_default() {
|
||||
// A request carrying a spoofed X-Forwarded-For must NOT be treated
|
||||
// as a distinct client from one with a different spoofed value —
|
||||
// both should resolve to the same real socket address.
|
||||
let socket_addr: std::net::SocketAddr = "127.0.0.1:9999".parse().unwrap();
|
||||
let mut headers_a = axum::http::HeaderMap::new();
|
||||
headers_a.insert("x-forwarded-for", "1.2.3.4".parse().unwrap());
|
||||
let mut headers_b = axum::http::HeaderMap::new();
|
||||
headers_b.insert("x-forwarded-for", "5.6.7.8".parse().unwrap());
|
||||
|
||||
let id_a = client_id(&headers_a, socket_addr, false);
|
||||
let id_b = client_id(&headers_b, socket_addr, false);
|
||||
|
||||
assert_eq!(
|
||||
id_a, id_b,
|
||||
"client_id must key on the real socket address when trust_proxy_headers is false, \
|
||||
not on attacker-controlled X-Forwarded-For"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_id_uses_forwarded_header_when_trust_enabled() {
|
||||
// When explicitly told to trust a fronting proxy, the header value
|
||||
// should be used (this is the opt-in, documented-risk path).
|
||||
let socket_addr: std::net::SocketAddr = "127.0.0.1:9999".parse().unwrap();
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", "1.2.3.4".parse().unwrap());
|
||||
|
||||
let id = client_id(&headers, socket_addr, true);
|
||||
assert_eq!(id, "1.2.3.4");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the test to verify it fails**
|
||||
|
||||
Run: `cargo test -p zesdex-middleware client_id_ignores_spoofed -- --nocapture`
|
||||
Expected: compile error (`client_id` doesn't yet take a `trust_proxy_headers: bool` parameter) or, if the function already exists without that parameter, a straightforward assertion failure since headers are currently trusted unconditionally.
|
||||
|
||||
- [ ] **Step 4: Add a `trust_proxy_headers` flag and make header-trust opt-in**
|
||||
|
||||
Locate the existing client-id derivation function (from Step 1) and change its signature to take an explicit trust flag, defaulting callers to `false`. Replace the header-first logic with:
|
||||
|
||||
```rust
|
||||
/// Derive the rate-limit bucket key for one request.
|
||||
///
|
||||
/// Flow: if `trust_proxy_headers` is true, use `X-Forwarded-For` (first
|
||||
/// hop) then `X-Real-IP`; otherwise always use the real connection
|
||||
/// socket address, ignoring any client-supplied headers.
|
||||
///
|
||||
/// Why: without a trusted reverse proxy stripping/overwriting these
|
||||
/// headers, they are attacker-controlled — trusting them by default lets
|
||||
/// any direct caller reset their own rate-limit bucket on every request.
|
||||
/// `trust_proxy_headers` must only be set to `true` when this middleware
|
||||
/// sits behind a proxy that is known to overwrite (not merge) these headers.
|
||||
fn client_id(
|
||||
headers: &axum::http::HeaderMap,
|
||||
socket_addr: std::net::SocketAddr,
|
||||
trust_proxy_headers: bool,
|
||||
) -> String {
|
||||
if trust_proxy_headers {
|
||||
if let Some(fwd) = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.split(',').next())
|
||||
.map(str::trim)
|
||||
{
|
||||
if !fwd.is_empty() {
|
||||
return fwd.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
|
||||
if !real_ip.is_empty() {
|
||||
return real_ip.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
socket_addr.ip().to_string()
|
||||
}
|
||||
```
|
||||
|
||||
Update every call site of the old client-id function within `rate_limit.rs` (the `Service::call`/`poll_ready` implementation that extracts headers and the socket address from the incoming `Request`) to pass `false` for `trust_proxy_headers` for now, with a `// TODO` is NOT allowed per project convention — instead add it as a named constructor parameter on `RateLimiter`/`RateLimitLayer` so callers decide explicitly:
|
||||
|
||||
```rust
|
||||
impl RateLimiter {
|
||||
/// Construct a rate limiter that keys strictly on the real connection
|
||||
/// socket address (default, safe when not behind a trusted proxy).
|
||||
pub fn new(/* existing params */) -> Self {
|
||||
Self::with_proxy_trust(/* existing args */, false)
|
||||
}
|
||||
|
||||
/// Construct a rate limiter that additionally trusts
|
||||
/// `X-Forwarded-For`/`X-Real-IP` headers — only use this when the
|
||||
/// middleware is mounted behind a reverse proxy known to overwrite
|
||||
/// (not merge) these headers before they reach this service.
|
||||
pub fn with_proxy_trust(/* existing params */, trust_proxy_headers: bool) -> Self {
|
||||
// existing construction logic, storing trust_proxy_headers on self
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(Exact existing constructor parameters depend on `RateLimiter`'s current fields, visible from the Step 1 read — thread `trust_proxy_headers: bool` through as an additional stored field alongside them.)
|
||||
|
||||
- [ ] **Step 5: Run the tests to verify they pass**
|
||||
|
||||
Run: `cargo test -p zesdex-middleware client_id -- --nocapture`
|
||||
Expected: both new tests pass.
|
||||
|
||||
- [ ] **Step 6: Run the full middleware test suite and clippy**
|
||||
|
||||
Run: `cargo test -p zesdex-middleware && cargo clippy -p zesdex-middleware -- -D warnings`
|
||||
Expected: all pass, no new warnings.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/zesdex-middleware/src/rate_limit.rs
|
||||
git commit -m "fix(middleware): jangan percaya header X-Forwarded-For/X-Real-IP secara default di rate limiter"
|
||||
```
|
||||
Reference in New Issue
Block a user