Require identity for tee bulk updates
This commit is contained in:
@@ -7784,9 +7784,27 @@ def resolve_existing_node_ref(args, view="render"):
|
|||||||
|
|
||||||
|
|
||||||
def upsert_node(args):
|
def upsert_node(args):
|
||||||
reject_agent_namespace_path(args["path"])
|
|
||||||
project_id = resolve_project_id(args)
|
project_id = resolve_project_id(args)
|
||||||
|
if args.get("node_id") or args.get("id") or args.get("uri"):
|
||||||
|
existing = resolve_existing_node_ref(args)
|
||||||
|
updated = request_project_write(
|
||||||
|
"PATCH",
|
||||||
|
f"/v1/projects/{project_id}/nodes/{existing['id']}",
|
||||||
|
encrypt_node_payload_for_write(project_id, node_payload_from_args(args, existing=existing), existing=existing),
|
||||||
|
project_id,
|
||||||
|
f"update node {existing['full_path']}",
|
||||||
|
"node",
|
||||||
|
existing["id"],
|
||||||
|
)
|
||||||
|
index_node(updated)
|
||||||
|
node = decrypt_node_for_return(updated, project_id=project_id)
|
||||||
|
return {"ok": True, "changed": True, "created": False, "updated": True, **node_write_reference(node)}
|
||||||
|
if not args.get("path"):
|
||||||
|
raise RuntimeError("path is required when creating a node; use node_id or uri to update an existing node")
|
||||||
|
reject_agent_namespace_path(args["path"])
|
||||||
existing = try_get_node_by_path(args["path"])
|
existing = try_get_node_by_path(args["path"])
|
||||||
|
if existing and not args.get("_internal_path_ref"):
|
||||||
|
raise RuntimeError("path already resolves to a node; use node_id or uri to update it")
|
||||||
if existing:
|
if existing:
|
||||||
updated = request_project_write(
|
updated = request_project_write(
|
||||||
"PATCH",
|
"PATCH",
|
||||||
@@ -7794,8 +7812,8 @@ def upsert_node(args):
|
|||||||
encrypt_node_payload_for_write(project_id, node_payload_from_args(args, existing=existing), existing=existing),
|
encrypt_node_payload_for_write(project_id, node_payload_from_args(args, existing=existing), existing=existing),
|
||||||
project_id,
|
project_id,
|
||||||
f"update node {existing['full_path']}",
|
f"update node {existing['full_path']}",
|
||||||
"path",
|
"node",
|
||||||
existing["full_path"],
|
existing["id"],
|
||||||
)
|
)
|
||||||
index_node(updated)
|
index_node(updated)
|
||||||
node = decrypt_node_for_return(updated, project_id=project_id)
|
node = decrypt_node_for_return(updated, project_id=project_id)
|
||||||
@@ -8628,7 +8646,7 @@ def create_kanban_card(args):
|
|||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
"content_text": args.get("content_text") or args.get("body") or "",
|
"content_text": args.get("content_text") or args.get("body") or "",
|
||||||
}
|
}
|
||||||
result = upsert_node(payload) if as_bool(args.get("upsert"), False) else None
|
result = upsert_node({**payload, "_internal_path_ref": True}) if as_bool(args.get("upsert"), False) else None
|
||||||
if result is None:
|
if result is None:
|
||||||
project_id = resolve_project_id(args)
|
project_id = resolve_project_id(args)
|
||||||
created = request_project_write(
|
created = request_project_write(
|
||||||
@@ -9628,10 +9646,16 @@ def bulk_upsert_nodes(args):
|
|||||||
headers = lock_headers(lock)
|
headers = lock_headers(lock)
|
||||||
for item in nodes:
|
for item in nodes:
|
||||||
path = item.get("path")
|
path = item.get("path")
|
||||||
if not path:
|
existing = None
|
||||||
raise RuntimeError("each node requires path")
|
if item.get("node_id") or item.get("id") or item.get("uri"):
|
||||||
reject_agent_namespace_path(path)
|
existing = resolve_existing_node_ref(item)
|
||||||
existing = try_get_node_by_path(path)
|
else:
|
||||||
|
if not path:
|
||||||
|
raise RuntimeError("each new node requires path; updates require node_id or uri")
|
||||||
|
reject_agent_namespace_path(path)
|
||||||
|
existing = try_get_node_by_path(path)
|
||||||
|
if existing and not item.get("_internal_path_ref"):
|
||||||
|
raise RuntimeError(f"path already resolves to a node; use node_id or uri to update it: {normalize_node_path(path)}")
|
||||||
payload = node_payload_from_args(item, existing=existing)
|
payload = node_payload_from_args(item, existing=existing)
|
||||||
payload = encrypt_node_payload_for_write(project_id, payload, existing=existing)
|
payload = encrypt_node_payload_for_write(project_id, payload, existing=existing)
|
||||||
if existing:
|
if existing:
|
||||||
@@ -11234,7 +11258,7 @@ def raw_tools():
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "nap_bulk",
|
"name": "nap_bulk",
|
||||||
"description": "Create or update multiple path-addressed nodes using one auto-acquired project lock. Prefer this over raw REST writes when operating through the local wrapper.",
|
"description": "Create multiple route-addressed nodes or update existing nodes by node_id/uri using one auto-acquired project lock.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["nodes"],
|
"required": ["nodes"],
|
||||||
@@ -11245,9 +11269,10 @@ def raw_tools():
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["path"],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"path": {"type": "string"},
|
"path": {"type": "string", "description": "Route path for creating a new node. Existing-node updates require node_id or uri."},
|
||||||
|
"node_id": {"type": "string", "description": "Canonical node UUID for updating an existing node."},
|
||||||
|
"uri": {"type": "string", "description": "Canonical nap://project-name/project-id/node-id URI for updating an existing node."},
|
||||||
"content_text": {"type": "string"},
|
"content_text": {"type": "string"},
|
||||||
"type": {"type": "string"},
|
"type": {"type": "string"},
|
||||||
"tags": {"type": "array", "items": {"type": "string"}},
|
"tags": {"type": "array", "items": {"type": "string"}},
|
||||||
@@ -11307,12 +11332,13 @@ def raw_tools():
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "nap_tee",
|
"name": "nap_tee",
|
||||||
"description": "Create or update a node by full path using an auto-acquired project lock. Use this for canonical task lists, current state, and durable project memory.",
|
"description": "Create a new route-addressed node by path, or update an existing node by canonical node_id or nap:// URI.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["path"],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"path": {"type": "string"},
|
"path": {"type": "string", "description": "Route path for creating a new node. Existing-node updates require node_id or uri."},
|
||||||
|
"node_id": {"type": "string", "description": "Canonical node UUID for updating an existing node."},
|
||||||
|
"uri": {"type": "string", "description": "Canonical nap://project-name/project-id/node-id URI for updating an existing node."},
|
||||||
"content_text": {"type": "string"},
|
"content_text": {"type": "string"},
|
||||||
"type": {"type": "string"},
|
"type": {"type": "string"},
|
||||||
"tags": {"type": "array", "items": {"type": "string"}},
|
"tags": {"type": "array", "items": {"type": "string"}},
|
||||||
|
|||||||
@@ -278,6 +278,18 @@ def test_tools_include_node_by_path_descriptor(mod):
|
|||||||
assert "uri" in schema["properties"]
|
assert "uri" in schema["properties"]
|
||||||
assert "path" not in schema["properties"]
|
assert "path" not in schema["properties"]
|
||||||
|
|
||||||
|
tee_schema = next(item for item in mod.tools() if item["name"] == "nap_tee")["inputSchema"]
|
||||||
|
assert tee_schema.get("required") is None
|
||||||
|
assert "path" in tee_schema["properties"]
|
||||||
|
assert "node_id" in tee_schema["properties"]
|
||||||
|
assert "uri" in tee_schema["properties"]
|
||||||
|
|
||||||
|
bulk_node_schema = next(item for item in mod.tools() if item["name"] == "nap_bulk")["inputSchema"]["properties"]["nodes"]["items"]
|
||||||
|
assert bulk_node_schema.get("required") is None
|
||||||
|
assert "path" in bulk_node_schema["properties"]
|
||||||
|
assert "node_id" in bulk_node_schema["properties"]
|
||||||
|
assert "uri" in bulk_node_schema["properties"]
|
||||||
|
|
||||||
for name in [
|
for name in [
|
||||||
"nap_kanban_start",
|
"nap_kanban_start",
|
||||||
"nap_kanban_send_review",
|
"nap_kanban_send_review",
|
||||||
@@ -329,6 +341,48 @@ def test_mutation_tools_reject_path_and_patch_by_node_id(mod):
|
|||||||
mod.update_node_by_path({"path": "/notes/current", "content_text": "bad"})
|
mod.update_node_by_path({"path": "/notes/current", "content_text": "bad"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_tee_and_bulk_create_by_path_update_by_identity(mod):
|
||||||
|
existing = node("/notes/current", "active", node_type="note")
|
||||||
|
existing["id"] = "node-123"
|
||||||
|
writes = []
|
||||||
|
|
||||||
|
mod.try_get_node_by_path = lambda path, allow_agent=False: existing if path == "/notes/current" else None
|
||||||
|
mod.get_node_by_id = lambda args: existing
|
||||||
|
mod.request_project_write = lambda method, path, payload, *args, **kwargs: writes.append((method, path, payload, args)) or {
|
||||||
|
**existing,
|
||||||
|
"id": "node-123" if method == "PATCH" else "node-new",
|
||||||
|
"full_path": "/notes/new" if method == "POST" else existing["full_path"],
|
||||||
|
"content_text": payload.get("content_text", existing["content_text"]),
|
||||||
|
}
|
||||||
|
mod.index_node = lambda node: None
|
||||||
|
mod.acquire_project_lock = lambda args: {"id": "lock-1", "lease_token": "lease-1"}
|
||||||
|
mod.lock_headers = lambda lock: {"X-Lock": lock["id"]}
|
||||||
|
mod.release_project_lock = lambda args: None
|
||||||
|
mod.request_json = lambda method, path, payload=None, **kwargs: writes.append((method, path, payload, ())) or {
|
||||||
|
**existing,
|
||||||
|
"content_text": payload.get("content_text", existing["content_text"]) if payload else existing["content_text"],
|
||||||
|
}
|
||||||
|
|
||||||
|
created = mod.upsert_node({"path": "/notes/new", "content_text": "new"})
|
||||||
|
assert created["created"] is True
|
||||||
|
assert writes[-1][0] == "POST"
|
||||||
|
|
||||||
|
updated = mod.upsert_node({"node_id": "node-123", "content_text": "updated"})
|
||||||
|
assert updated["updated"] is True
|
||||||
|
assert writes[-1][0] == "PATCH"
|
||||||
|
assert writes[-1][1] == "/v1/projects/project-1/nodes/node-123"
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="path already resolves"):
|
||||||
|
mod.upsert_node({"path": "/notes/current", "content_text": "bad"})
|
||||||
|
|
||||||
|
bulk = mod.bulk_upsert_nodes({"nodes": [{"node_id": "node-123", "content_text": "bulk update"}]})
|
||||||
|
assert bulk["items"][0]["created"] is False
|
||||||
|
assert writes[-1][1] == "/v1/projects/project-1/nodes/node-123"
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="path already resolves"):
|
||||||
|
mod.bulk_upsert_nodes({"nodes": [{"path": "/notes/current", "content_text": "bad"}]})
|
||||||
|
|
||||||
|
|
||||||
def test_discovery_rejects_full_content_view(mod):
|
def test_discovery_rejects_full_content_view(mod):
|
||||||
with pytest.raises(RuntimeError, match="full node bodies require nap_node_get"):
|
with pytest.raises(RuntimeError, match="full node bodies require nap_node_get"):
|
||||||
mod.list_project_nodes({"view": "full"})
|
mod.list_project_nodes({"view": "full"})
|
||||||
|
|||||||
Reference in New Issue
Block a user