Enforce unique MCP proposal identities
This commit is contained in:
@@ -17,7 +17,7 @@ The project is under active development. Core blogging workflows are broadly ava
|
|||||||
- A localized OPML menu editor manages pages, submenus, and category archives with protected Home ordering, keyboard-accessible tree controls, drag-and-drop, and bDS2-compatible persistence.
|
- A localized OPML menu editor manages pages, submenus, and category archives with protected Home ordering, keyboard-accessible tree controls, drag-and-drop, and bDS2-compatible persistence.
|
||||||
- Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings.
|
- Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings.
|
||||||
- Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, effective shared settings with secret-presence redaction, projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`.
|
- Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, effective shared settings with secret-presence redaction, projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`.
|
||||||
- Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, inert write proposals, explicit desktop approval, and opt-in Claude Code/Copilot configuration.
|
- Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, uniquely identified inert write proposals, clean duplicate-pending rejection, explicit desktop approval, and opt-in Claude Code/Copilot configuration.
|
||||||
- A fully localized Ratatui terminal workspace, available locally through `bds-cli tui`/`BDS_MODE=tui` and remotely through authenticated SSH shell sessions, with shared post/template/script editing and publishing, project/search/command overlays, settings, tags, Git, reports, task progress, live multi-client locale updates, and airplane-mode AI gating.
|
- A fully localized Ratatui terminal workspace, available locally through `bds-cli tui`/`BDS_MODE=tui` and remotely through authenticated SSH shell sessions, with shared post/template/script editing and publishing, project/search/command overlays, settings, tags, Git, reports, task progress, live multi-client locale updates, and airplane-mode AI gating.
|
||||||
- `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection.
|
- `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection.
|
||||||
- bDS2-compatible Markdown/Liquid rendering with built-in macros rendered from bundled Liquid templates in isolated scopes (customizable with `macros/*` partial-template slugs), descriptive category archive titles, canonical multilingual and flat page routes for every configured blog language, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, and incremental site generation and validation repair through cancellable section task groups.
|
- bDS2-compatible Markdown/Liquid rendering with built-in macros rendered from bundled Liquid templates in isolated scopes (customizable with `macros/*` partial-template slugs), descriptive category archive titles, canonical multilingual and flat page routes for every configured blog language, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, and incremental site generation and validation repair through cancellable section task groups.
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
DROP INDEX mcp_proposals_entity_idx;
|
||||||
|
|
||||||
|
CREATE TABLE mcp_proposals_old (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN (
|
||||||
|
'draft_post',
|
||||||
|
'propose_script',
|
||||||
|
'propose_template',
|
||||||
|
'propose_media_translation',
|
||||||
|
'propose_media_metadata',
|
||||||
|
'propose_post_metadata'
|
||||||
|
)),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
|
||||||
|
'pending', 'executing', 'accepted', 'rejected', 'expired'
|
||||||
|
)),
|
||||||
|
entity_id TEXT,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
result TEXT,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL,
|
||||||
|
resolved_at BIGINT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO mcp_proposals_old (
|
||||||
|
id, project_id, kind, status, entity_id, data, result,
|
||||||
|
created_at, expires_at, resolved_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, project_id, kind, status, entity_id, data, result,
|
||||||
|
created_at, expires_at, resolved_at
|
||||||
|
FROM mcp_proposals;
|
||||||
|
|
||||||
|
DROP TABLE mcp_proposals;
|
||||||
|
ALTER TABLE mcp_proposals_old RENAME TO mcp_proposals;
|
||||||
|
|
||||||
|
CREATE INDEX mcp_proposals_project_status_idx
|
||||||
|
ON mcp_proposals(project_id, status, created_at);
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
CREATE TABLE mcp_proposals_new (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN (
|
||||||
|
'draft_post',
|
||||||
|
'propose_script',
|
||||||
|
'propose_template',
|
||||||
|
'propose_media_translation',
|
||||||
|
'propose_media_metadata',
|
||||||
|
'propose_post_metadata'
|
||||||
|
)),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
|
||||||
|
'pending', 'executing', 'accepted', 'rejected', 'expired'
|
||||||
|
)),
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
result TEXT,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL,
|
||||||
|
resolved_at BIGINT
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO mcp_proposals_new (
|
||||||
|
id, project_id, kind, status, entity_id, data, result,
|
||||||
|
created_at, expires_at, resolved_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, project_id, kind, status, COALESCE(entity_id, id), data, result,
|
||||||
|
created_at, expires_at, resolved_at
|
||||||
|
FROM mcp_proposals;
|
||||||
|
|
||||||
|
DROP TABLE mcp_proposals;
|
||||||
|
ALTER TABLE mcp_proposals_new RENAME TO mcp_proposals;
|
||||||
|
|
||||||
|
-- Keep the newest record if a pre-migration database already contains an
|
||||||
|
-- invalid duplicate. NULL-backed proposals were assigned their own IDs above
|
||||||
|
-- and therefore remain distinct.
|
||||||
|
DELETE FROM mcp_proposals AS duplicate
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM mcp_proposals AS keeper
|
||||||
|
WHERE keeper.kind = duplicate.kind
|
||||||
|
AND keeper.entity_id = duplicate.entity_id
|
||||||
|
AND keeper.status = duplicate.status
|
||||||
|
AND (
|
||||||
|
keeper.created_at > duplicate.created_at
|
||||||
|
OR (keeper.created_at = duplicate.created_at AND keeper.id > duplicate.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX mcp_proposals_project_status_idx
|
||||||
|
ON mcp_proposals(project_id, status, created_at);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX mcp_proposals_entity_idx
|
||||||
|
ON mcp_proposals(kind, entity_id, status);
|
||||||
@@ -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
|
/// Filesystem database path for sibling surfaces that must open their own
|
||||||
/// short-lived connection (gallery workers, preview servers, Lua hosts).
|
/// short-lived connection (gallery workers, preview servers, Lua hosts).
|
||||||
pub fn database_path(&self) -> diesel::QueryResult<std::path::PathBuf> {
|
pub fn database_path(&self) -> diesel::QueryResult<std::path::PathBuf> {
|
||||||
|
|||||||
@@ -36,7 +36,60 @@ mod tests {
|
|||||||
let applied = db
|
let applied = db
|
||||||
.conn()
|
.conn()
|
||||||
.with_migrations(|conn| conn.applied_migrations().unwrap().len());
|
.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]
|
#[test]
|
||||||
|
|||||||
@@ -48,6 +48,21 @@ pub fn list_pending_proposals(
|
|||||||
|
|
||||||
pub fn expire_pending(conn: &DbConnection, now: i64) -> QueryResult<usize> {
|
pub fn expire_pending(conn: &DbConnection, now: i64) -> QueryResult<usize> {
|
||||||
conn.with(|connection| {
|
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(
|
diesel::update(
|
||||||
mcp_proposals::table
|
mcp_proposals::table
|
||||||
.filter(mcp_proposals::status.eq(ProposalStatus::Pending))
|
.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> {
|
pub fn claim_pending(conn: &DbConnection, id: &str, now: i64) -> QueryResult<bool> {
|
||||||
conn.with(|connection| {
|
conn.with(|connection| {
|
||||||
diesel::update(
|
diesel::update(
|
||||||
@@ -123,7 +155,7 @@ mod tests {
|
|||||||
project_id: "p1".into(),
|
project_id: "p1".into(),
|
||||||
kind: ProposalKind::DraftPost,
|
kind: ProposalKind::DraftPost,
|
||||||
status: ProposalStatus::Pending,
|
status: ProposalStatus::Pending,
|
||||||
entity_id: None,
|
entity_id: id.into(),
|
||||||
data: "{}".into(),
|
data: "{}".into(),
|
||||||
result: None,
|
result: None,
|
||||||
created_at: 1,
|
created_at: 1,
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ diesel::table! {
|
|||||||
project_id -> Text,
|
project_id -> Text,
|
||||||
kind -> Text,
|
kind -> Text,
|
||||||
status -> Text,
|
status -> Text,
|
||||||
entity_id -> Nullable<Text>,
|
entity_id -> Text,
|
||||||
data -> Text,
|
data -> Text,
|
||||||
result -> Nullable<Text>,
|
result -> Nullable<Text>,
|
||||||
created_at -> BigInt,
|
created_at -> BigInt,
|
||||||
|
|||||||
@@ -98,7 +98,17 @@ pub fn list_pending_proposals(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn expire_proposals(conn: &DbConnection) -> EngineResult<usize> {
|
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 {
|
if expired > 0 {
|
||||||
notify_proposals_changed();
|
notify_proposals_changed();
|
||||||
}
|
}
|
||||||
@@ -114,19 +124,37 @@ pub(crate) fn create_proposal(
|
|||||||
) -> EngineResult<McpProposal> {
|
) -> EngineResult<McpProposal> {
|
||||||
expire_proposals(conn)?;
|
expire_proposals(conn)?;
|
||||||
let now = now_unix_ms();
|
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 {
|
let proposal = McpProposal {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
project_id: project_id.to_string(),
|
project_id: project_id.to_string(),
|
||||||
kind,
|
kind,
|
||||||
status: ProposalStatus::Pending,
|
status: ProposalStatus::Pending,
|
||||||
entity_id: entity_id.map(str::to_string),
|
entity_id,
|
||||||
data: serde_json::to_string(data)?,
|
data: serde_json::to_string(data)?,
|
||||||
result: None,
|
result: None,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
expires_at: now + PROPOSAL_TTL_MS,
|
expires_at: now + PROPOSAL_TTL_MS,
|
||||||
resolved_at: None,
|
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(
|
cli_sync::record_cli_event(
|
||||||
conn,
|
conn,
|
||||||
&DomainEvent::SettingsChanged {
|
&DomainEvent::SettingsChanged {
|
||||||
@@ -182,6 +210,7 @@ fn resolve_proposal(
|
|||||||
} else {
|
} else {
|
||||||
ProposalStatus::Rejected
|
ProposalStatus::Rejected
|
||||||
};
|
};
|
||||||
|
proposal_q::delete_status_collision(conn, &proposal, status)?;
|
||||||
if !proposal_q::resolve_claimed(
|
if !proposal_q::resolve_claimed(
|
||||||
conn,
|
conn,
|
||||||
proposal_id,
|
proposal_id,
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ pub struct McpProposal {
|
|||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
pub kind: ProposalKind,
|
pub kind: ProposalKind,
|
||||||
pub status: ProposalStatus,
|
pub status: ProposalStatus,
|
||||||
pub entity_id: Option<String>,
|
pub entity_id: String,
|
||||||
pub data: String,
|
pub data: String,
|
||||||
pub result: Option<String>,
|
pub result: Option<String>,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::net::{IpAddr, Ipv4Addr};
|
|||||||
use std::sync::{Arc, Barrier};
|
use std::sync::{Arc, Barrier};
|
||||||
|
|
||||||
use bds_core::db::Database;
|
use bds_core::db::Database;
|
||||||
use bds_core::engine::{mcp, project};
|
use bds_core::engine::{EngineError, mcp, project};
|
||||||
use bds_core::model::{DomainEntity, DomainEvent, McpProposal, ProposalKind, ProposalStatus};
|
use bds_core::model::{DomainEntity, DomainEvent, McpProposal, ProposalKind, ProposalStatus};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
@@ -523,7 +523,7 @@ fn expiry_invalid_ids_unavailable_projects_and_concurrent_acceptance_are_safe()
|
|||||||
project_id: fixture.project_id.clone(),
|
project_id: fixture.project_id.clone(),
|
||||||
kind: ProposalKind::DraftPost,
|
kind: ProposalKind::DraftPost,
|
||||||
status: ProposalStatus::Pending,
|
status: ProposalStatus::Pending,
|
||||||
entity_id: None,
|
entity_id: "expired-entity".into(),
|
||||||
data: json!({"title":"Expired","content":"Body"}).to_string(),
|
data: json!({"title":"Expired","content":"Body"}).to_string(),
|
||||||
result: None,
|
result: None,
|
||||||
created_at: 1,
|
created_at: 1,
|
||||||
@@ -587,6 +587,130 @@ fn expiry_invalid_ids_unavailable_projects_and_concurrent_acceptance_are_safe()
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_pending_entity_proposals_are_rejected_cleanly() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let post = fixture.post("One target", "Body");
|
||||||
|
let context = fixture.context();
|
||||||
|
let arguments = json!({"postId": post.id, "title": "First change"});
|
||||||
|
|
||||||
|
context
|
||||||
|
.call_tool("propose_post_metadata", arguments.clone())
|
||||||
|
.unwrap();
|
||||||
|
let error = context
|
||||||
|
.call_tool("propose_post_metadata", arguments)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, EngineError::Conflict(_)));
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
format!(
|
||||||
|
"conflict: proposal already pending for propose_post_metadata entity {}",
|
||||||
|
post.id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let db = fixture.database();
|
||||||
|
assert_eq!(
|
||||||
|
mcp::list_pending_proposals(db.conn(), &fixture.project_id)
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn untargeted_proposals_receive_distinct_entity_ids() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let context = fixture.context();
|
||||||
|
|
||||||
|
context
|
||||||
|
.call_tool("draft_post", json!({"title": "First", "content": "Body"}))
|
||||||
|
.unwrap();
|
||||||
|
context
|
||||||
|
.call_tool("draft_post", json!({"title": "Second", "content": "Body"}))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let db = fixture.database();
|
||||||
|
let proposals = mcp::list_pending_proposals(db.conn(), &fixture.project_id).unwrap();
|
||||||
|
assert_eq!(proposals.len(), 2);
|
||||||
|
assert_ne!(proposals[0].entity_id, proposals[1].entity_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newer_terminal_proposals_replace_older_status_history() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let post = fixture.post("Original", "Body");
|
||||||
|
let context = fixture.context();
|
||||||
|
|
||||||
|
let first = context
|
||||||
|
.call_tool(
|
||||||
|
"propose_post_metadata",
|
||||||
|
json!({"postId": post.id, "title": "First change"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let db = fixture.database();
|
||||||
|
mcp::accept_proposal(
|
||||||
|
db.conn(),
|
||||||
|
&fixture.data_dir,
|
||||||
|
first["proposalId"].as_str().unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let second = context
|
||||||
|
.call_tool(
|
||||||
|
"propose_post_metadata",
|
||||||
|
json!({"postId": post.id, "title": "Second change"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
mcp::accept_proposal(
|
||||||
|
db.conn(),
|
||||||
|
&fixture.data_dir,
|
||||||
|
second["proposalId"].as_str().unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let accepted = mcp::list_proposals(db.conn(), &fixture.project_id)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|proposal| proposal.status == ProposalStatus::Accepted)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(accepted.len(), 1);
|
||||||
|
assert_eq!(accepted[0].id, second["proposalId"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newer_expired_proposals_replace_older_expired_history() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let db = fixture.database();
|
||||||
|
for (id, status, expires_at) in [
|
||||||
|
("old-expired", ProposalStatus::Expired, 1),
|
||||||
|
("new-expiring", ProposalStatus::Pending, 2),
|
||||||
|
] {
|
||||||
|
bds_core::db::queries::mcp_proposal::insert_proposal(
|
||||||
|
db.conn(),
|
||||||
|
&McpProposal {
|
||||||
|
id: id.into(),
|
||||||
|
project_id: fixture.project_id.clone(),
|
||||||
|
kind: ProposalKind::ProposePostMetadata,
|
||||||
|
status,
|
||||||
|
entity_id: "same-post".into(),
|
||||||
|
data: "{}".into(),
|
||||||
|
result: None,
|
||||||
|
created_at: expires_at,
|
||||||
|
expires_at,
|
||||||
|
resolved_at: (status == ProposalStatus::Expired).then_some(1),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(mcp::expire_proposals(db.conn()).unwrap(), 1);
|
||||||
|
let proposals = mcp::list_proposals(db.conn(), &fixture.project_id).unwrap();
|
||||||
|
assert_eq!(proposals.len(), 1);
|
||||||
|
assert_eq!(proposals[0].id, "new-expiring");
|
||||||
|
assert_eq!(proposals[0].status, ProposalStatus::Expired);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn protocol_routes_every_family_and_http_enforces_loopback_origin_and_cors() {
|
fn protocol_routes_every_family_and_http_enforces_loopback_origin_and_cors() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
|
|||||||
@@ -13196,7 +13196,7 @@ mod tests {
|
|||||||
project_id: project.id.clone(),
|
project_id: project.id.clone(),
|
||||||
kind: bds_core::model::ProposalKind::DraftPost,
|
kind: bds_core::model::ProposalKind::DraftPost,
|
||||||
status: bds_core::model::ProposalStatus::Pending,
|
status: bds_core::model::ProposalStatus::Pending,
|
||||||
entity_id: None,
|
entity_id: "accept-entity".into(),
|
||||||
data: serde_json::json!({"title":"Approved","content":"Body"}).to_string(),
|
data: serde_json::json!({"title":"Approved","content":"Body"}).to_string(),
|
||||||
result: None,
|
result: None,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -13205,6 +13205,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let mut rejected = accepted.clone();
|
let mut rejected = accepted.clone();
|
||||||
rejected.id = "reject-me".into();
|
rejected.id = "reject-me".into();
|
||||||
|
rejected.entity_id = "reject-entity".into();
|
||||||
rejected.data = serde_json::json!({"title":"Rejected","content":"Body"}).to_string();
|
rejected.data = serde_json::json!({"title":"Rejected","content":"Body"}).to_string();
|
||||||
bds_core::db::queries::mcp_proposal::insert_proposal(db.conn(), &accepted).unwrap();
|
bds_core::db::queries::mcp_proposal::insert_proposal(db.conn(), &accepted).unwrap();
|
||||||
bds_core::db::queries::mcp_proposal::insert_proposal(db.conn(), &rejected).unwrap();
|
bds_core::db::queries::mcp_proposal::insert_proposal(db.conn(), &rejected).unwrap();
|
||||||
|
|||||||
@@ -1064,10 +1064,7 @@ fn section_mcp<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a
|
|||||||
} else {
|
} else {
|
||||||
column(state.mcp_proposals.iter().map(|proposal| {
|
column(state.mcp_proposals.iter().map(|proposal| {
|
||||||
let kind = t(locale, proposal_kind_i18n_key(proposal.kind));
|
let kind = t(locale, proposal_kind_i18n_key(proposal.kind));
|
||||||
let summary = proposal
|
let summary = format!("{kind} · {}", proposal.entity_id);
|
||||||
.entity_id
|
|
||||||
.as_deref()
|
|
||||||
.map_or(kind.clone(), |entity| format!("{kind} · {entity}"));
|
|
||||||
inputs::card(
|
inputs::card(
|
||||||
row![
|
row![
|
||||||
column![
|
column![
|
||||||
|
|||||||
Reference in New Issue
Block a user