Enforce unique MCP proposal identities

This commit is contained in:
2026-07-22 18:25:19 +02:00
parent 2dcaf7f7f7
commit 70503c8bc2
12 changed files with 353 additions and 15 deletions

View File

@@ -62,6 +62,15 @@ impl DbConnection {
)
}
#[cfg(test)]
pub(crate) fn insert_legacy_null_mcp_proposal_for_test(&self) -> diesel::QueryResult<()> {
self.0.borrow_mut().batch_execute(
"INSERT INTO mcp_proposals \
(id, project_id, kind, status, entity_id, data, created_at, expires_at) \
VALUES ('null-entity', 'p1', 'draft_post', 'pending', NULL, '{}', 3, 99)",
)
}
/// Filesystem database path for sibling surfaces that must open their own
/// short-lived connection (gallery workers, preview servers, Lua hosts).
pub fn database_path(&self) -> diesel::QueryResult<std::path::PathBuf> {

View File

@@ -36,7 +36,60 @@ mod tests {
let applied = db
.conn()
.with_migrations(|conn| conn.applied_migrations().unwrap().len());
assert_eq!(applied, 8);
assert_eq!(applied, 9);
}
#[test]
fn mcp_proposal_upgrade_normalizes_entities_and_enforces_uniqueness() {
use crate::db::queries::{mcp_proposal, project};
use crate::model::{McpProposal, ProposalKind, ProposalStatus};
let db = Database::open_in_memory().unwrap();
db.conn().with_migrations(|conn| {
for _ in 0..8 {
conn.run_next_migration(MIGRATIONS).unwrap();
}
});
project::insert_project(db.conn(), &project::make_test_project("p1", "blog")).unwrap();
let targeted = |id: &str, created_at| McpProposal {
id: id.into(),
project_id: "p1".into(),
kind: ProposalKind::ProposePostMetadata,
status: ProposalStatus::Pending,
entity_id: "post-1".into(),
data: "{}".into(),
result: None,
created_at,
expires_at: 99,
resolved_at: None,
};
mcp_proposal::insert_proposal(db.conn(), &targeted("older", 1)).unwrap();
mcp_proposal::insert_proposal(db.conn(), &targeted("newer", 2)).unwrap();
db.conn()
.insert_legacy_null_mcp_proposal_for_test()
.unwrap();
run_migrations(db.conn()).unwrap();
let proposals = mcp_proposal::list_proposals(db.conn(), "p1").unwrap();
assert_eq!(proposals.len(), 2);
assert!(proposals.iter().any(|proposal| proposal.id == "newer"));
assert_eq!(
proposals
.iter()
.find(|proposal| proposal.id == "null-entity")
.unwrap()
.entity_id,
"null-entity"
);
assert!(matches!(
mcp_proposal::insert_proposal(db.conn(), &targeted("duplicate", 4)),
Err(diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
_
))
));
}
#[test]

View File

@@ -48,6 +48,21 @@ pub fn list_pending_proposals(
pub fn expire_pending(conn: &DbConnection, now: i64) -> QueryResult<usize> {
conn.with(|connection| {
let expiring = mcp_proposals::table
.filter(mcp_proposals::status.eq(ProposalStatus::Pending))
.filter(mcp_proposals::expires_at.le(now))
.select(McpProposal::as_select())
.load::<McpProposal>(connection)?;
for proposal in &expiring {
diesel::delete(
mcp_proposals::table
.filter(mcp_proposals::id.ne(&proposal.id))
.filter(mcp_proposals::kind.eq(proposal.kind))
.filter(mcp_proposals::entity_id.eq(&proposal.entity_id))
.filter(mcp_proposals::status.eq(ProposalStatus::Expired)),
)
.execute(connection)?;
}
diesel::update(
mcp_proposals::table
.filter(mcp_proposals::status.eq(ProposalStatus::Pending))
@@ -62,6 +77,23 @@ pub fn expire_pending(conn: &DbConnection, now: i64) -> QueryResult<usize> {
})
}
pub fn delete_status_collision(
conn: &DbConnection,
proposal: &McpProposal,
status: ProposalStatus,
) -> QueryResult<usize> {
conn.with(|connection| {
diesel::delete(
mcp_proposals::table
.filter(mcp_proposals::id.ne(&proposal.id))
.filter(mcp_proposals::kind.eq(proposal.kind))
.filter(mcp_proposals::entity_id.eq(&proposal.entity_id))
.filter(mcp_proposals::status.eq(status)),
)
.execute(connection)
})
}
pub fn claim_pending(conn: &DbConnection, id: &str, now: i64) -> QueryResult<bool> {
conn.with(|connection| {
diesel::update(
@@ -123,7 +155,7 @@ mod tests {
project_id: "p1".into(),
kind: ProposalKind::DraftPost,
status: ProposalStatus::Pending,
entity_id: None,
entity_id: id.into(),
data: "{}".into(),
result: None,
created_at: 1,

View File

@@ -161,7 +161,7 @@ diesel::table! {
project_id -> Text,
kind -> Text,
status -> Text,
entity_id -> Nullable<Text>,
entity_id -> Text,
data -> Text,
result -> Nullable<Text>,
created_at -> BigInt,

View File

@@ -98,7 +98,17 @@ pub fn list_pending_proposals(
}
pub fn expire_proposals(conn: &DbConnection) -> EngineResult<usize> {
let expired = proposal_q::expire_pending(conn, now_unix_ms())?;
conn.begin_savepoint()?;
let expired = match proposal_q::expire_pending(conn, now_unix_ms()) {
Ok(expired) => {
conn.release_savepoint()?;
expired
}
Err(error) => {
let _ = conn.rollback_savepoint();
return Err(error.into());
}
};
if expired > 0 {
notify_proposals_changed();
}
@@ -114,19 +124,37 @@ pub(crate) fn create_proposal(
) -> EngineResult<McpProposal> {
expire_proposals(conn)?;
let now = now_unix_ms();
let entity_id = entity_id
.map(str::to_string)
.unwrap_or_else(|| Uuid::new_v4().to_string());
let proposal = McpProposal {
id: Uuid::new_v4().to_string(),
project_id: project_id.to_string(),
kind,
status: ProposalStatus::Pending,
entity_id: entity_id.map(str::to_string),
entity_id,
data: serde_json::to_string(data)?,
result: None,
created_at: now,
expires_at: now + PROPOSAL_TTL_MS,
resolved_at: None,
};
proposal_q::insert_proposal(conn, &proposal)?;
if let Err(error) = proposal_q::insert_proposal(conn, &proposal) {
if matches!(
error,
diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
_
)
) {
return Err(EngineError::Conflict(format!(
"proposal already pending for {} entity {}",
proposal.kind.as_str(),
proposal.entity_id
)));
}
return Err(error.into());
}
cli_sync::record_cli_event(
conn,
&DomainEvent::SettingsChanged {
@@ -182,6 +210,7 @@ fn resolve_proposal(
} else {
ProposalStatus::Rejected
};
proposal_q::delete_status_collision(conn, &proposal, status)?;
if !proposal_q::resolve_claimed(
conn,
proposal_id,

View File

@@ -119,7 +119,7 @@ pub struct McpProposal {
pub project_id: String,
pub kind: ProposalKind,
pub status: ProposalStatus,
pub entity_id: Option<String>,
pub entity_id: String,
pub data: String,
pub result: Option<String>,
pub created_at: i64,