fix(dashboard): learning-node and skill editor writes honour ?profile=
DELETE/PUT /api/learning/node, POST /api/skills and PUT /api/skills/content read the target profile from the JSON body only. A shared-backend Desktop (global remote, registry connections) scopes these calls with ?profile= and sends a body without one, so the star map's delete archived, and its edit rewrote, the same-named skill of the backend's launch profile while the selected profile's skill stayed as it was. Take the body profile first and fall back to the query, as PUT /api/skills/toggle and the tools routes already do.
This commit is contained in:
committed by
brooklyn!
parent
c82abc309a
commit
0ef09aed76
@@ -416,13 +416,14 @@ async def get_skill_content(name: str, profile: Optional[str] = None):
|
||||
|
||||
|
||||
@router.post("/api/skills")
|
||||
async def create_skill(body: SkillCreate):
|
||||
async def create_skill(body: SkillCreate, profile: Optional[str] = None):
|
||||
"""Create a skill via the agent's ``skill_manage`` write path, minus the
|
||||
write-approval gate — an authenticated dashboard write IS the user."""
|
||||
write-approval gate — an authenticated dashboard write IS the user.
|
||||
Profile from the body or ``?profile=``, like the rest of ``/api/skills``."""
|
||||
from tools.skill_manager_tool import _create_skill
|
||||
|
||||
result = await scoped_to_thread(
|
||||
body.profile, lambda: _create_skill(body.name, body.content, body.category or None))
|
||||
body.profile or profile, lambda: _create_skill(body.name, body.content, body.category or None))
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "Failed to create skill."))
|
||||
_clear_skills_prompt_cache()
|
||||
@@ -430,11 +431,11 @@ async def create_skill(body: SkillCreate):
|
||||
|
||||
|
||||
@router.put("/api/skills/content")
|
||||
async def update_skill_content(body: SkillContentUpdate):
|
||||
async def update_skill_content(body: SkillContentUpdate, profile: Optional[str] = None):
|
||||
"""Replace the SKILL.md of an existing skill (full rewrite) from the editor."""
|
||||
from tools.skill_manager_tool import _edit_skill
|
||||
|
||||
result = await scoped_to_thread(body.profile, lambda: _edit_skill(body.name, body.content))
|
||||
result = await scoped_to_thread(body.profile or profile, lambda: _edit_skill(body.name, body.content))
|
||||
if not result.get("success"):
|
||||
err = result.get("error", "Failed to update skill.")
|
||||
status = 404 if "not found" in str(err).lower() else 400
|
||||
|
||||
@@ -703,19 +703,22 @@ async def get_learning_node(id: str, profile: Optional[str] = None):
|
||||
|
||||
|
||||
@router.delete("/api/learning/node")
|
||||
async def delete_learning_node(body: LearningNodeRef):
|
||||
"""Delete a journey node — skills are archived (restorable), memories removed."""
|
||||
async def delete_learning_node(body: LearningNodeRef, profile: Optional[str] = None):
|
||||
"""Delete a journey node — skills are archived (restorable), memories removed.
|
||||
|
||||
``?profile=`` is honoured too: a shared-backend Desktop scopes this call by query only, and
|
||||
ignoring it archived the same-named skill of the launch profile instead."""
|
||||
from agent.learning_mutations import delete_node
|
||||
return await _learning_mutation(
|
||||
body.profile, lambda: delete_node(body.id), 400, "delete failed")
|
||||
body.profile or profile, lambda: delete_node(body.id), 400, "delete failed")
|
||||
|
||||
|
||||
@router.put("/api/learning/node")
|
||||
async def update_learning_node(body: LearningNodeEdit):
|
||||
"""Rewrite a journey node's content (SKILL.md or memory chunk)."""
|
||||
async def update_learning_node(body: LearningNodeEdit, profile: Optional[str] = None):
|
||||
"""Rewrite a journey node's content (SKILL.md or memory chunk); profile as for DELETE."""
|
||||
from agent.learning_mutations import edit_node
|
||||
return await _learning_mutation(
|
||||
body.profile, lambda: edit_node(body.id, body.content), 400, "edit failed")
|
||||
body.profile or profile, lambda: edit_node(body.id, body.content), 400, "edit failed")
|
||||
|
||||
|
||||
# Portal — Nous Portal auth + Tool Gateway routing status (read-only).
|
||||
|
||||
@@ -92,6 +92,37 @@ class TestProfileScopedSkills:
|
||||
client.get("/api/skills", params={"profile": "worker_alpha"})
|
||||
assert skills_tool.SKILLS_DIR == before
|
||||
|
||||
@pytest.mark.parametrize("method,path,body", [
|
||||
("PUT", "/api/learning/node", {"id": "shared-skill", "content": "EDITED"}),
|
||||
("DELETE", "/api/learning/node", {"id": "shared-skill"}),
|
||||
("PUT", "/api/skills/content", {"name": "shared-skill", "content": "EDITED"}),
|
||||
("POST", "/api/skills", {"name": "shared-skill-2", "content": "EDITED"}),
|
||||
])
|
||||
def test_query_profile_scopes_skill_writes(self, client, isolated_profiles, method, path, body):
|
||||
"""A shared-backend Desktop names the profile in the query only; the write must land in
|
||||
that profile and leave the same-named skill of the dashboard's own profile alone."""
|
||||
skill_md = (
|
||||
"---\nname: {name}\ndescription: edited\n---\n\n# EDITED\n").format(
|
||||
name=body.get("name", body.get("id")))
|
||||
if "content" in body:
|
||||
body = {**body, "content": skill_md}
|
||||
for home in isolated_profiles.values():
|
||||
_write_skill(home / "skills", "shared-skill")
|
||||
default_md = isolated_profiles["default"] / "skills" / "shared-skill" / "SKILL.md"
|
||||
before = default_md.read_text()
|
||||
|
||||
resp = client.request(method, path, params={"profile": "worker_alpha"}, json=body)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
worker_skills = isolated_profiles["worker_alpha"] / "skills"
|
||||
if method == "DELETE":
|
||||
assert not (worker_skills / "shared-skill").exists()
|
||||
else:
|
||||
target = body.get("name", body.get("id"))
|
||||
assert "# EDITED" in (worker_skills / target / "SKILL.md").read_text()
|
||||
assert default_md.read_text() == before
|
||||
assert not (isolated_profiles["default"] / "skills" / "shared-skill-2").exists()
|
||||
|
||||
|
||||
class TestProfileScopedHubActions:
|
||||
def test_hub_install_spawns_with_profile_flag(
|
||||
|
||||
@@ -451,7 +451,7 @@ The management endpoint families — `/api/config`, `/api/env`, `/api/skills`,
|
||||
`/api/tools/toolsets`, `/api/mcp`,
|
||||
`/api/model/{info,options,auxiliary,set,recommended-default}`,
|
||||
`/api/cron/{delivery-targets,blueprints}`, `/api/audio/voice-config`,
|
||||
`/api/ops/debug-share`, `/api/learning/graph`, and `/api/dashboard/plugins/hub` —
|
||||
`/api/ops/debug-share`, `/api/learning/{graph,node}`, and `/api/dashboard/plugins/hub` —
|
||||
accept an optional `?profile=<name>` query parameter (or `"profile"` in the
|
||||
JSON body for writes) that scopes the read/write to that profile's
|
||||
`HERMES_HOME`. Omitted = the dashboard's own profile. Unknown profile names
|
||||
|
||||
Reference in New Issue
Block a user