Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Archive** — `void archive <id>` now dismisses the whole context group behind the item (Slack thread, Slack 1-hour channel group, Gmail thread) instead of a single row. The inbox shows one row per context, so archiving only the visible id let an older sibling resurface as the next representative. The response gains `archived_count` (rows newly archived by the call, `0` when it was already archived), and Gmail pushes the group in one `batchModify` request.

### Added

- **Remote** — `void remote status` reports `local_version` and `remote_version` so version skew between the client and the server binary is visible at a glance.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/void-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ rmcp = { workspace = true }
schemars = { workspace = true }

[dev-dependencies]
async-trait = { workspace = true }
assert_cmd = { workspace = true }
predicates = { workspace = true }
tempfile = { workspace = true }
Expand Down
232 changes: 224 additions & 8 deletions crates/void-cli/src/service/writes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,27 +471,88 @@ async fn archive_by_ids(
}
}

let remote_synced = if let Some(conn) = connectors.get(&connector_key) {
conn.archive(&msg.external_id, &conv.external_id)
.await
.is_ok()
} else {
false
let archived = db.mark_message_archived_with_context(&msg.id)?;

let remote_synced = match connectors.get(&connector_key) {
Some(conn) => {
sync_remote_archive(db, conn.as_ref(), &msg, &conv.external_id, &archived).await?
}
None => false,
};

db.mark_message_archived(message_id)?;
cleanup_cached_files(&msg);
if archived.is_empty() {
// Already archived: no row to clean up, but the cached files of the
// resolved message may still be around from an earlier run.
cleanup_cached_files(&msg);
} else {
for archived_msg in &archived {
cleanup_cached_files(archived_msg);
}
}

results.push(json!({
"message_id": message_id,
"is_archived": true,
// Rows newly archived by this call: the message plus its context
// siblings. Zero means it was already archived.
"archived_count": archived.len(),
"remote_synced": remote_synced,
}));
}

Ok(json!({ "data": results, "error": null }))
}

/// Push an archive to the remote service for every row that was just archived,
/// grouped by conversation so connectors with a bulk endpoint issue one call per
/// conversation instead of one per message. Returns whether every call succeeded.
async fn sync_remote_archive(
db: &Database,
conn: &dyn Connector,
msg: &void_core::models::Message,
conv_external_id: &str,
archived: &[void_core::models::Message],
) -> anyhow::Result<bool> {
if archived.is_empty() {
// Nothing newly archived: still push the resolved message so remote
// state converges even when the local row was already archived.
return Ok(conn
.archive(&msg.external_id, conv_external_id)
.await
.is_ok());
}

let mut conv_ext_cache: HashMap<String, String> = HashMap::new();
let mut by_conversation: HashMap<String, Vec<&str>> = HashMap::new();
for archived_msg in archived {
// Prefer each sibling's own conversation external id when available.
let peer_conv_ext = match conv_ext_cache.get(&archived_msg.conversation_id) {
Some(ext) => ext.clone(),
None => {
let ext = db
.get_conversation(&archived_msg.conversation_id)?
.map(|c| c.external_id)
.unwrap_or_else(|| conv_external_id.to_string());
conv_ext_cache.insert(archived_msg.conversation_id.clone(), ext.clone());
ext
}
};
by_conversation
.entry(peer_conv_ext)
.or_default()
.push(archived_msg.external_id.as_str());
}

let mut synced = true;
for (peer_conv_ext, external_ids) in &by_conversation {
if let Err(e) = conn.archive_batch(external_ids, peer_conv_ext).await {
warn!(conversation = %peer_conv_ext, error = %e, "remote archive failed");
synced = false;
}
}
Ok(synced)
}

async fn run_slack_scheduled_send(
connection: &void_core::config::ConnectionConfig,
channel: &str,
Expand Down Expand Up @@ -590,6 +651,161 @@ mod tests {
use void_core::config::VoidConfig;
use void_core::models::{Conversation, ConversationKind};

use std::sync::Mutex;
use void_core::db::Database as CoreDatabase;
use void_core::models::{ConnectorType, HealthStatus, Message};
use void_core::test_fixtures::make_message;

/// Records the archive calls it receives so grouping can be asserted.
struct RecordingConnector {
batches: Mutex<Vec<(String, Vec<String>)>>,
singles: Mutex<Vec<(String, String)>>,
}

impl RecordingConnector {
fn new() -> Self {
Self {
batches: Mutex::new(Vec::new()),
singles: Mutex::new(Vec::new()),
}
}
}

#[async_trait::async_trait]
impl Connector for RecordingConnector {
fn connector_type(&self) -> ConnectorType {
ConnectorType::from_static("slack")
}
fn connection_id(&self) -> &str {
"test-slack"
}
async fn authenticate(&mut self) -> anyhow::Result<()> {
Ok(())
}
async fn start_sync(
&self,
_db: std::sync::Arc<CoreDatabase>,
_cancel: tokio_util::sync::CancellationToken,
) -> anyhow::Result<()> {
Ok(())
}
async fn health_check(&self) -> anyhow::Result<HealthStatus> {
anyhow::bail!("not used")
}
async fn send_message(
&self,
_to: &str,
_content: MessageContent,
) -> anyhow::Result<String> {
anyhow::bail!("not used")
}
async fn reply(
&self,
_message_id: &str,
_content: MessageContent,
_in_thread: bool,
) -> anyhow::Result<String> {
anyhow::bail!("not used")
}
async fn archive(
&self,
external_id: &str,
conversation_external_id: &str,
) -> anyhow::Result<()> {
self.singles.lock().unwrap().push((
conversation_external_id.to_string(),
external_id.to_string(),
));
Ok(())
}
async fn archive_batch(
&self,
external_ids: &[&str],
conversation_external_id: &str,
) -> anyhow::Result<()> {
self.batches.lock().unwrap().push((
conversation_external_id.to_string(),
external_ids.iter().map(|s| s.to_string()).collect(),
));
Ok(())
}
}

fn seed_conversation(db: &Database, id: &str, external_id: &str) {
db.upsert_conversation(&Conversation {
id: id.into(),
connection_id: "test-slack".into(),
connector: "slack".into(),
external_id: external_id.into(),
name: None,
kind: ConversationKind::Channel,
last_message_at: None,
unread_count: 0,
is_muted: false,
metadata: None,
})
.expect("seed conversation");
}

fn stub_message(id: &str, conversation_id: &str, external_id: &str) -> Message {
let mut msg = make_message(id, conversation_id, "test-slack", "body", 0);
msg.external_id = external_id.into();
msg
}

#[tokio::test]
async fn sync_remote_archive_groups_siblings_by_conversation() {
let db = test_db();
seed_conversation(&db, "c1", "C111");
seed_conversation(&db, "c2", "C222");
let conn = RecordingConnector::new();

let archived = vec![
stub_message("m1", "c1", "ts-1"),
stub_message("m2", "c1", "ts-2"),
stub_message("m3", "c2", "ts-3"),
];

let synced = sync_remote_archive(&db, &conn, &archived[0], "C111", &archived)
.await
.expect("sync");

assert!(synced);
assert!(conn.singles.lock().unwrap().is_empty());
let mut batches = conn.batches.lock().unwrap().clone();
batches.sort();
assert_eq!(
batches,
vec![
(
"C111".to_string(),
vec!["ts-1".to_string(), "ts-2".to_string()]
),
("C222".to_string(), vec!["ts-3".to_string()]),
],
"one batch call per conversation"
);
}

#[tokio::test]
async fn sync_remote_archive_falls_back_to_single_when_nothing_new() {
let db = test_db();
seed_conversation(&db, "c1", "C111");
let conn = RecordingConnector::new();
let msg = stub_message("m1", "c1", "ts-1");

let synced = sync_remote_archive(&db, &conn, &msg, "C111", &[])
.await
.expect("sync");

assert!(synced);
assert!(conn.batches.lock().unwrap().is_empty());
assert_eq!(
*conn.singles.lock().unwrap(),
vec![("C111".to_string(), "ts-1".to_string())]
);
}

fn test_db() -> Database {
Database::open(std::path::Path::new(":memory:")).expect("in-memory db")
}
Expand Down
30 changes: 30 additions & 0 deletions crates/void-core/src/connector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,36 @@ pub trait Connector: Send + Sync {
Ok(())
}

/// Archive several messages of the same conversation.
///
/// Default implementation archives them one by one and reports an error if
/// any call failed, without aborting the rest. Connectors with a bulk
/// endpoint should override this to issue a single request.
async fn archive_batch(
&self,
external_ids: &[&str],
conversation_external_id: &str,
) -> anyhow::Result<()> {
let mut failed = 0usize;
for external_id in external_ids {
if self
.archive(external_id, conversation_external_id)
.await
.is_err()
{
failed += 1;
}
}
if failed > 0 {
anyhow::bail!(
"{failed}/{} remote archive calls failed for {}",
external_ids.len(),
self.connector_type()
);
}
Ok(())
}

/// Forward a message to another recipient.
/// `external_id` is the platform-specific message identifier.
/// `conversation_external_id` is the platform-specific conversation/channel ID.
Expand Down
5 changes: 5 additions & 0 deletions crates/void-core/src/db/database_access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,11 @@ impl Database {
messages::mark_archived(&*self.conn()?, id)
}

/// Archive a message and all siblings sharing its `context_id` (inbox thread/group).
pub fn mark_message_archived_with_context(&self, id: &str) -> Result<Vec<Message>, DbError> {
messages::mark_archived_with_context(&*self.conn()?, id)
}

pub fn update_message_metadata(
&self,
id: &str,
Expand Down
55 changes: 55 additions & 0 deletions crates/void-core/src/db/messages/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,61 @@ pub fn mark_archived(conn: &Connection, id: &str) -> Result<bool, DbError> {
Ok(updated > 0)
}

/// Archive a message and every sibling sharing its `context_id` (Slack thread /
/// time-window group, Gmail thread, …). Inbox dedup shows one row per context, so
/// archiving only the visible id leaves older siblings to reappear as the next
/// representative (GitHub issue #67).
///
/// Messages with no `context_id` are archived alone. Returns the rows that were
/// newly marked archived (for cache cleanup); an already-archived message yields
/// an empty vector. Select and update run in one transaction so a concurrent
/// insert into the same context cannot be archived without being reported.
pub fn mark_archived_with_context(conn: &Connection, id: &str) -> Result<Vec<Message>, DbError> {
let Some(msg) = super::read::get(conn, id)? else {
return Ok(Vec::new());
};

// Static clauses; the value always travels as a bound parameter.
let (where_clause, param): (&str, &str) = match msg.context_id.as_deref() {
Some(ctx) => ("context_id = ?1", ctx),
None if msg.is_archived => return Ok(Vec::new()),
None => ("id = ?1", id),
};

let tx = conn.unchecked_transaction()?;

let mut stmt = tx.prepare(&format!(
"SELECT id, conversation_id, connection_id, connector, external_id, sender, sender_name, sender_avatar_url, body, timestamp, synced_at, is_archived, reply_to_id, media_type, metadata, context_id, is_saved
FROM messages WHERE {where_clause} AND is_archived = 0"
))?;
let to_archive: Vec<Message> = stmt
.query_map(params![param], row::row_to_message)?
.collect::<Result<_, _>>()?;
drop(stmt);

if to_archive.is_empty() {
return Ok(Vec::new());
}

tx.execute(
&format!("UPDATE messages SET is_archived = 1 WHERE {where_clause} AND is_archived = 0"),
params![param],
)?;
tx.commit()?;

match msg.context_id.as_deref() {
Some(ctx) => debug!(
message_id = %id,
context_id = %ctx,
count = to_archive.len(),
"archived message context group"
),
None => debug!(message_id = %id, "archived single message (no context)"),
}

Ok(to_archive)
}

pub fn update_metadata(
conn: &Connection,
id: &str,
Expand Down
Loading