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
105 changes: 99 additions & 6 deletions packages/agent/src/local_agent/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3512,15 +3512,38 @@ impl GraphToolExecutor {
edge_data: None,
};

rel_ops::create_relationship(&ns, input)
let output = rel_ops::create_relationship(&ns, input)
.await
.map_err(|e| ops_error_to_tool(e, "create_relationship"))?;

Ok(ok_result(
tool_call_id,
"create_relationship",
json!({ "from_id": params.from_id, "to_id": params.to_id, "type": params.relationship_type, "created": true }),
))
let mut result = json!({ "from_id": params.from_id, "to_id": params.to_id, "type": params.relationship_type, "created": true });
// A cardinality-one end replaces rather than rejects: "assign this task
// to Bob" unassigns Alice as a side effect. Name the evicted edges so
// the model can tell the user instead of reporting a plain create.
if !output.replaced.is_empty() {
let obj = result.as_object_mut().expect("literal is an object");
obj.insert(
"replaced".into(),
json!(output
.replaced
.iter()
.map(|edge| json!({
"from_id": node_uri(&edge.source_id),
"to_id": node_uri(&edge.target_id),
"type": edge.relationship_name,
}))
.collect::<Vec<_>>()),
);
obj.insert(
"note".into(),
json!(
"This relationship allows only one link, so creating it removed the \
relationship(s) listed in `replaced`. Tell the user which link was removed."
),
);
}

Ok(ok_result(tool_call_id, "create_relationship", result))
}

async fn exec_get_related_nodes(
Expand Down Expand Up @@ -7212,6 +7235,76 @@ mod tests {
}
}

/// A cardinality-one end replaces rather than rejects, so reassigning a
/// task must tell the model who lost it — otherwise "assign this to Bob"
/// silently unassigns Alice and the model reports a plain create.
#[tokio::test]
async fn create_relationship_reports_replaced_edge() {
use nodespace_core::db::SqliteStore;
use nodespace_core::services::{CreateNodeParams, InsertPositionOwned};
use tempfile::TempDir;

let tmp = TempDir::new().unwrap();
let mut store: Arc<SqliteStore> =
Arc::new(SqliteStore::new(tmp.path().join("test.db")).await.unwrap());
let ns = Arc::new(NodeService::new(&mut store).await.unwrap());
let mut ids = Vec::new();
for (node_type, content) in [("person", ""), ("person", ""), ("task", "Ship it")] {
ids.push(
ns.create_node_with_parent(CreateNodeParams {
id: None,
node_type: node_type.to_string(),
content: content.to_string(),
parent_id: None,
position: InsertPositionOwned::End,
properties: json!({}),
lifecycle_status: None,
})
.await
.unwrap(),
);
}
let (alice, bob, task) = (&ids[0], &ids[1], &ids[2]);
let executor = GraphToolExecutor {
node_service: Some(ns),
embedding_service: Arc::new(RwLock::new(None)),
inference_engine: None,
playbook_lifecycle: None,
};

let first = executor
.execute(
"create_relationship",
json!({ "from_id": alice, "to_id": task, "relationship_type": "tasks" }),
)
.await
.unwrap();
assert!(
first.result.get("replaced").is_none(),
"a plain create reports no replacement: {}",
first.result
);

let second = executor
.execute(
"create_relationship",
json!({ "from_id": bob, "to_id": task, "relationship_type": "tasks" }),
)
.await
.unwrap();
assert_eq!(
second.result["replaced"],
json!([{
"from_id": node_uri(alice),
"to_id": node_uri(task),
"type": "tasks",
}]),
"the evicted assignment must be named: {}",
second.result
);
assert!(second.result["note"].is_string());
}

#[tokio::test]
async fn get_related_nodes_missing_id() {
let executor = test_executor();
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/commands/relationship.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,19 +83,39 @@ async fn create(client: &mut NodeClient, args: CreateArgs, json_out: bool) -> Re
.into_inner();

if json_out {
let replaced: Vec<_> = response
.replaced
.iter()
.map(|edge| {
json!({
"source_id": edge.source_id,
"relationship_name": edge.relationship_name,
"target_id": edge.target_id,
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&json!({
"source_id": response.source_id,
"relationship_name": response.relationship_name,
"target_id": response.target_id,
"replaced": replaced,
}))?
);
} else {
println!(
"Created relationship: {} --[{}]--> {}",
response.source_id, response.relationship_name, response.target_id
);
// A cardinality-one end replaces rather than rejects, so a create can
// silently unassign someone else — say so.
for edge in &response.replaced {
println!(
"Replaced relationship: {} --[{}]--> {}",
edge.source_id, edge.relationship_name, edge.target_id
);
}
}
Ok(())
}
Expand Down
15 changes: 13 additions & 2 deletions packages/core/src/ops/rel_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::models::schema::{
BUILTIN_RELATIONSHIP_NAMES,
};
use crate::ops::OpsError;
use crate::services::NodeService;
use crate::services::{NodeService, ReplacedEdge};
use serde::Serialize;
use serde_json::{json, Value};
use std::collections::HashMap;
Expand All @@ -30,6 +30,9 @@ pub struct CreateRelOutput {
pub source_id: String,
pub relationship_name: String,
pub target_id: String,
/// Edges evicted to honor a cardinality-one end — see
/// `NodeService::create_relationship`. Empty for a plain create.
pub replaced: Vec<ReplacedEdge>,
}

#[derive(Debug)]
Expand Down Expand Up @@ -80,7 +83,7 @@ pub async fn create_relationship(
) -> Result<CreateRelOutput, OpsError> {
let edge_data = input.edge_data.unwrap_or(json!({}));

node_service
let replaced = node_service
.create_relationship(
&input.source_id,
&input.relationship_name,
Expand All @@ -94,6 +97,7 @@ pub async fn create_relationship(
source_id: input.source_id,
relationship_name: input.relationship_name,
target_id: input.target_id,
replaced,
})
}

Expand Down Expand Up @@ -552,6 +556,11 @@ pub struct RelationshipGroup {
/// the `reverse_cardinality` inbound. Known on both sides, since a
/// declaration must supply both.
pub cardinality: RelationshipCardinality,
/// Effective cardinality for the FAR side — how many edges of this
/// relationship each related node may hold. When `one`, linking a node
/// that already holds such an edge replaces it (see
/// `NodeService::create_relationship`), so the viewer warns before doing so.
pub far_cardinality: RelationshipCardinality,
/// Whether the forward relationship is required (outbound only). The viewer
/// uses this to confirm-on-delete; last-edge removal is enforced server-side
/// in `NodeService::delete_relationship`.
Expand Down Expand Up @@ -685,6 +694,7 @@ pub async fn get_node_relationships(
reverse_name: rel.reverse_name.clone(),
source_type: node_type.clone(),
cardinality: rel.cardinality.clone(),
far_cardinality: rel.reverse_cardinality.clone(),
required: rel.required,
edge_fields: rel.edge_fields.clone(),
description: rel.description.clone(),
Expand Down Expand Up @@ -776,6 +786,7 @@ pub async fn get_node_relationships(
source_type,
// Reading from the inbound side, the reverse cardinality governs.
cardinality: rel.reverse_cardinality.clone(),
far_cardinality: rel.cardinality.clone(),
required: None,
edge_fields: rel.edge_fields.clone(),
description: rel.description.clone(),
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,8 @@ pub use embedding_processor::{
pub use embedding_service::{NodeEmbeddingService, EMBEDDING_DIMENSION};
pub use error::NodeServiceError;
pub use node_service::{
flatten_subtree_content, CompletenessResult, CreateNodeParams, NodeService, SubtreeData,
WriteVerificationFault, DEFAULT_QUERY_LIMIT,
flatten_subtree_content, CompletenessResult, CreateNodeParams, NodeService, ReplacedEdge,
SubtreeData, WriteVerificationFault, DEFAULT_QUERY_LIMIT,
};
pub use query_service::{
FilterOperator, FilterType, QueryDefinition, QueryFilter, QueryService, RelationshipType,
Expand Down
44 changes: 41 additions & 3 deletions packages/core/src/services/node_service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ pub(crate) mod schema;

pub use conflicts::deterministic_conflict_id;
pub use hierarchy::flatten_subtree_content;
pub use relationship::ReplacedEdge;

/// Reserved ID for the DatabaseSettingsNode singleton instance.
///
Expand Down Expand Up @@ -4050,6 +4051,7 @@ mod tests {
service
.create_relationship("p", "has_child", &format!("n{i}"), json!({}))
.await
.map(|_| ())
}));
}
// A concurrent reorder contending on the same parent's sibling order.
Expand Down Expand Up @@ -10367,15 +10369,32 @@ mod tests {
.await
.unwrap();

service
let first = service
.create_relationship(&person1_id, "tasks", &task_id, serde_json::json!({}))
.await
.expect("first assignment must succeed");
assert!(first.is_empty(), "a plain create evicts nothing");

service
let replaced = service
.create_relationship(&person2_id, "tasks", &task_id, serde_json::json!({}))
.await
.expect("a second edge into a reverse-cardinality-one target must replace the first");
assert_eq!(
replaced,
vec![ReplacedEdge {
source_id: person1_id.clone(),
relationship_name: "tasks".to_string(),
target_id: task_id.clone(),
}],
"the evicted edge must be reported so callers can surface the reassignment"
);

// Re-asserting the current assignment is idempotent: nothing to evict.
let repeat = service
.create_relationship(&person2_id, "tasks", &task_id, serde_json::json!({}))
.await
.unwrap();
assert!(repeat.is_empty(), "an idempotent repeat evicts nothing");

// The task must show exactly one assignee — person2, not both.
let inbound = crate::ops::rel_ops::get_node_relationships(&service, &task_id)
Expand All @@ -10391,6 +10410,11 @@ mod tests {
"the replaced edge must not leave the task with two assignees"
);
assert_eq!(in_group.related[0].id, person2_id);
// The far end (the assignee side) holds many; this end holds one.
assert_eq!(
in_group.far_cardinality,
crate::models::schema::RelationshipCardinality::Many
);

// person1's outbound side must no longer show the task.
let person1_outbound = crate::ops::rel_ops::get_node_relationships(&service, &person1_id)
Expand All @@ -10401,6 +10425,12 @@ mod tests {
.iter()
.find(|g| g.relationship_name == "tasks" && g.direction == "out")
.expect("person1 still shows the declared (now empty) tasks group");
// Seen from the person, the far end is the task's single assignee
// slot — what tells the viewer a link here can steal an assignment.
assert_eq!(
person1_group.far_cardinality,
crate::models::schema::RelationshipCardinality::One
);
assert_eq!(
person1_group.count, 0,
"person1's evicted edge must actually be gone, not merely hidden"
Expand Down Expand Up @@ -10504,10 +10534,18 @@ mod tests {
.await
.expect("first edge from a cardinality-one source must succeed");

service
let replaced = service
.create_relationship("g1", "primary_widget", "w2", serde_json::json!({}))
.await
.expect("a second edge from a cardinality-one source must replace the first");
assert_eq!(
replaced,
vec![ReplacedEdge {
source_id: "g1".to_string(),
relationship_name: "primary_widget".to_string(),
target_id: "w1".to_string(),
}]
);

let targets = service
.get_related_nodes("g1", "primary_widget", "out")
Expand Down
Loading