fix: safe uploads, AI history continuity, deep search, sudo credentials #55

Merged
sarman merged 19 commits from fix/safe-uploads-history-search-sudo into master 2026-05-31 20:52:32 +00:00
2 changed files with 370 additions and 6 deletions
Showing only changes of commit cd67a09a6a - Show all commits

View File

@ -204,15 +204,19 @@ pub async fn chat_message(
}
};
// Load conversation history (use and_then to keep stmt lifetime within closure)
// Load conversation history across ALL conversations for this issue
let history: Vec<Message> = {
let db = state.db.lock().map_err(|e| e.to_string())?;
let raw: Vec<(String, String)> = db
.prepare(
"SELECT role, content FROM ai_messages WHERE conversation_id = ?1 ORDER BY created_at ASC",
"SELECT am.role, am.content \
FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = ?1 \
ORDER BY am.created_at ASC",
)
.and_then(|mut stmt| {
stmt.query_map([&conversation_id], |row| {
stmt.query_map([&issue_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
@ -1025,4 +1029,194 @@ mod tests {
let list = extract_list(text, "KEY_FINDINGS:");
assert_eq!(list, vec!["Actual item"]);
}
#[test]
fn test_history_query_same_conversation() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["issue-1", "Test", &now, &now],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-1", "conv-1", "user", "Hello", 5, "2025-01-01 10:00:00"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-2", "conv-1", "assistant", "Hi there", 8, "2025-01-01 10:00:01"],
)
.unwrap();
let issue_id = "issue-1";
let raw: Vec<(String, String)> = conn
.prepare(
"SELECT am.role, am.content \
FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = ?1 \
ORDER BY am.created_at ASC",
)
.and_then(|mut stmt| {
stmt.query_map([&issue_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
})
.unwrap();
assert_eq!(raw.len(), 2);
assert_eq!(raw[0], ("user".to_string(), "Hello".to_string()));
assert_eq!(raw[1], ("assistant".to_string(), "Hi there".to_string()));
}
#[test]
fn test_history_query_across_conversations() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["issue-1", "Test", &now, &now],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-2", "issue-1", "anthropic", "claude-3", &now, "Conv 2"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-1", "conv-1", "user", "From conv 1", 5, "2025-01-01 10:00:00"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-2", "conv-2", "user", "From conv 2", 5, "2025-01-01 11:00:00"],
)
.unwrap();
let issue_id = "issue-1";
let raw: Vec<(String, String)> = conn
.prepare(
"SELECT am.role, am.content \
FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = ?1 \
ORDER BY am.created_at ASC",
)
.and_then(|mut stmt| {
stmt.query_map([&issue_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
})
.unwrap();
assert_eq!(raw.len(), 2);
assert_eq!(raw[0].1, "From conv 1");
assert_eq!(raw[1].1, "From conv 2");
}
#[test]
fn test_history_query_empty_for_new_issue() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["issue-new", "Empty", &now, &now],
)
.unwrap();
let issue_id = "issue-new";
let raw: Vec<(String, String)> = conn
.prepare(
"SELECT am.role, am.content \
FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = ?1 \
ORDER BY am.created_at ASC",
)
.and_then(|mut stmt| {
stmt.query_map([&issue_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
})
.unwrap();
assert!(raw.is_empty());
}
#[test]
fn test_history_query_ordered_by_created_at() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)",
rusqlite::params!["issue-1", "Test", &now, &now],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv 1"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-2", "issue-1", "anthropic", "claude-3", &now, "Conv 2"],
)
.unwrap();
// Insert messages out of order: conv-2 message is earlier than conv-1 message
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-1", "conv-1", "user", "Second", 5, "2025-01-01 12:00:00"],
)
.unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-2", "conv-2", "user", "First", 5, "2025-01-01 09:00:00"],
)
.unwrap();
let issue_id = "issue-1";
let raw: Vec<(String, String)> = conn
.prepare(
"SELECT am.role, am.content \
FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = ?1 \
ORDER BY am.created_at ASC",
)
.and_then(|mut stmt| {
stmt.query_map([&issue_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
})
.unwrap();
assert_eq!(raw.len(), 2);
assert_eq!(raw[0].1, "First");
assert_eq!(raw[1].1, "Second");
}
}

View File

@ -347,7 +347,7 @@ pub async fn list_issues(
let offset = filter.offset.unwrap_or(0);
let mut sql = String::from(
"SELECT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \
"SELECT DISTINCT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \
(SELECT COUNT(*) FROM log_files lf WHERE lf.issue_id = i.id) as log_count, \
(SELECT COUNT(*) FROM resolution_steps rs WHERE rs.issue_id = i.id) as step_count \
FROM issues i WHERE 1=1",
@ -384,9 +384,19 @@ pub async fn list_issues(
}
if let Some(ref search) = filter.search {
let pattern = format!("%{search}%");
let idx = params.len() + 1;
sql.push_str(&format!(
" AND (i.title LIKE ?{0} OR i.description LIKE ?{0} OR i.category LIKE ?{0})",
params.len() + 1
" AND (i.title LIKE ?{idx} OR i.description LIKE ?{idx} OR i.category LIKE ?{idx} \
OR EXISTS (SELECT 1 FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = i.id AND am.content LIKE ?{idx}) \
OR EXISTS (SELECT 1 FROM resolution_steps rs \
WHERE rs.issue_id = i.id \
AND (rs.why_question LIKE ?{idx} OR rs.answer LIKE ?{idx} OR rs.evidence LIKE ?{idx})) \
OR EXISTS (SELECT 1 FROM log_files lf \
WHERE lf.issue_id = i.id AND lf.file_name LIKE ?{idx}) \
OR EXISTS (SELECT 1 FROM timeline_events te \
WHERE te.issue_id = i.id AND te.description LIKE ?{idx}))",
));
params.push(Box::new(pattern));
}
@ -635,3 +645,163 @@ pub async fn get_timeline_events(
.collect();
Ok(events)
}
#[cfg(test)]
mod tests {
use rusqlite::Connection;
fn setup_test_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
crate::db::migrations::run_migrations(&conn).unwrap();
conn
}
fn insert_issue(conn: &Connection, id: &str, title: &str, description: &str) {
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO issues (id, title, description, severity, status, category, source, created_at, updated_at, assigned_to, tags) \
VALUES (?1, ?2, ?3, 'medium', 'open', 'general', 'manual', ?4, ?5, '', '[]')",
rusqlite::params![id, title, description, &now, &now],
)
.unwrap();
}
fn run_search_query(conn: &Connection, search: &str) -> Vec<String> {
let pattern = format!("%{search}%");
let idx = 1;
let sql = format!(
"SELECT DISTINCT i.id, i.title, i.severity, i.status, i.category, i.created_at, i.updated_at, \
(SELECT COUNT(*) FROM log_files lf2 WHERE lf2.issue_id = i.id) as log_count, \
(SELECT COUNT(*) FROM resolution_steps rs2 WHERE rs2.issue_id = i.id) as step_count \
FROM issues i WHERE 1=1 \
AND (i.title LIKE ?{idx} OR i.description LIKE ?{idx} OR i.category LIKE ?{idx} \
OR EXISTS (SELECT 1 FROM ai_messages am \
JOIN ai_conversations ac ON ac.id = am.conversation_id \
WHERE ac.issue_id = i.id AND am.content LIKE ?{idx}) \
OR EXISTS (SELECT 1 FROM resolution_steps rs \
WHERE rs.issue_id = i.id \
AND (rs.why_question LIKE ?{idx} OR rs.answer LIKE ?{idx} OR rs.evidence LIKE ?{idx})) \
OR EXISTS (SELECT 1 FROM log_files lf \
WHERE lf.issue_id = i.id AND lf.file_name LIKE ?{idx}) \
OR EXISTS (SELECT 1 FROM timeline_events te \
WHERE te.issue_id = i.id AND te.description LIKE ?{idx})) \
ORDER BY i.updated_at DESC"
);
let mut stmt = conn.prepare(&sql).unwrap();
stmt.query_map([&pattern], |row| row.get::<_, String>(0))
.unwrap()
.filter_map(|r| r.ok())
.collect()
}
#[test]
fn test_search_finds_by_title() {
let conn = setup_test_db();
insert_issue(&conn, "issue-1", "Kubernetes OOM crash", "No details");
insert_issue(&conn, "issue-2", "Network timeout", "No details");
let results = run_search_query(&conn, "Kubernetes");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_finds_by_description() {
let conn = setup_test_db();
insert_issue(
&conn,
"issue-1",
"Generic title",
"The pod was killed due to memory pressure",
);
insert_issue(&conn, "issue-2", "Other", "All fine");
let results = run_search_query(&conn, "memory pressure");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_finds_by_ai_message_content() {
let conn = setup_test_db();
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv"],
).unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-1", "conv-1", "assistant", "The root cause is a deadlock in PostgreSQL", 10, &now],
).unwrap();
let results = run_search_query(&conn, "deadlock in PostgreSQL");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_finds_by_resolution_step_answer() {
let conn = setup_test_db();
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO resolution_steps (id, issue_id, step_order, why_question, answer, evidence, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params!["step-1", "issue-1", 1, "Why did it fail?", "Connection pool exhausted", "metrics dashboard", &now],
).unwrap();
let results = run_search_query(&conn, "pool exhausted");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_finds_by_log_file_name() {
let conn = setup_test_db();
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO log_files (id, issue_id, file_name, file_path, file_size, mime_type, content_hash, uploaded_at, redacted) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params!["lf-1", "issue-1", "nginx-error-2025.log", "/tmp/nginx-error-2025.log", 1024, "text/plain", "abc", &now, 0],
).unwrap();
let results = run_search_query(&conn, "nginx-error");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_finds_by_timeline_description() {
let conn = setup_test_db();
insert_issue(&conn, "issue-1", "Unrelated title", "Unrelated desc");
conn.execute(
"INSERT INTO timeline_events (id, issue_id, event_type, description, metadata, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["te-1", "issue-1", "triage_started", "Engineer started investigating DNS resolution failure", "{}", "2025-01-15 10:00:00"],
).unwrap();
let results = run_search_query(&conn, "DNS resolution");
assert_eq!(results, vec!["issue-1"]);
}
#[test]
fn test_search_no_duplicates_for_multiple_matches() {
let conn = setup_test_db();
insert_issue(
&conn,
"issue-1",
"Kubernetes crash",
"Kubernetes pod killed",
);
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
conn.execute(
"INSERT INTO ai_conversations (id, issue_id, provider, model, created_at, title) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["conv-1", "issue-1", "openai", "gpt-4o", &now, "Conv"],
).unwrap();
conn.execute(
"INSERT INTO ai_messages (id, conversation_id, role, content, token_count, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params!["msg-1", "conv-1", "assistant", "Kubernetes OOM detected", 5, &now],
).unwrap();
let results = run_search_query(&conn, "Kubernetes");
assert_eq!(results.len(), 1);
assert_eq!(results[0], "issue-1");
}
}