mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
* feat(skills): structured conflict + overwrite path for local skill re-import
Local-skill re-import previously failed (or silently skipped) on a same-name
collision and, on delete+reimport, changed the skill UUID and dropped agent
bindings. This adds a structured conflict result and a creator-only overwrite
write path so a re-import can update the existing skill in place.
- New terminal import status `conflict` carrying { existing_skill_id,
existing_created_by, can_overwrite }; can_overwrite = requester is the
skill creator (canOverwriteSkillByLocalImport — intentionally narrower than
canManageSkill: admins edit in-app, not via re-import).
- Conflict is detected at daemon-report time (the effective name is only known
once the bundle arrives) via GetSkillByWorkspaceAndName, with the unique
constraint as a race backstop.
- Import requests carry action=overwrite + target_skill_id, persisted through
both the in-memory and Redis LocalSkillImportStore (the heartbeat → daemon
payload is unchanged; overwrite is resolved server-side).
- overwriteSkillWithFiles updates by target_skill_id in one tx: re-checks
existence (workspace-scoped) and creator permission, then replaces
description/content/config and fully replaces files (pruning files absent
from the new bundle). Preserves id, created_by, created_at, name, and
agent_skill bindings. Publishes skill:updated (not skill:created).
- Boundaries: target deleted or permission lost → failed (no fallback to
create-by-name); any mid-write error rolls back the tx, leaving the original
skill untouched. Retrying a terminal request is a no-op.
Tests cover: creator/non-creator conflict (can_overwrite), overwrite preserves
UUID + agent binding + prunes removed files, non-creator overwrite fails,
deleted target fails without create fallback, retry idempotency, and Redis
round-trip of the new fields.
Backend half of MUL-2701. Contract change: same-name local imports now return
status `conflict` instead of `failed` — the Desktop/core client must be updated
to consume it (sibling task).
MUL-2800
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): gate structured conflict behind client opt-in; guard overwrite target name
Addresses review feedback on PR #3498 (MUL-2800).
Backward compatibility: a same-name local import now returns the new `conflict`
status only when the initiating client opts in via `supports_conflict` (an
overwrite request implies it). Older clients — already-installed Desktop builds
whose poll loop only understands `failed`/`timeout` — keep the legacy `failed`
+ "a skill with this name already exists" behavior, so upgrading the backend
ahead of the client no longer regresses the import UX. This is the installed-app
API-compat boundary the repo's CLAUDE.md calls out.
Also: the overwrite write path now verifies the incoming effective name matches
the target skill's current name (errSkillOverwriteNameMismatch -> failed),
preventing a stale/wrong target_skill_id from writing one skill's content onto
another. Creator-only + workspace scoping already prevent privilege escalation;
this narrows the API so it can't be misused.
Refactored LocalSkillImportStore.Create to a LocalSkillImportRequestInput params
struct (the signature had grown to 8 positional args; the opt-in flag pushed it
over). supports_conflict is persisted in both the in-memory and Redis stores.
Tests: conflict tests now opt in; added a legacy-client test (no flag ->
failed + legacy message) and an overwrite name-mismatch test.
MUL-2800
Co-authored-by: multica-agent <github@multica.ai>
* feat(skills): resolve local import conflicts in desktop
Co-authored-by: multica-agent <github@multica.ai>
* fix(skills): preserve bulk flow after conflict resolution
Co-authored-by: multica-agent <github@multica.ai>
* feat(cli): add skill import conflict strategies
Co-authored-by: multica-agent <github@multica.ai>
* fix(i18n): sync skill import locale keys
Co-authored-by: multica-agent <github@multica.ai>
* docs: explain skill import conflict handling
Co-authored-by: multica-agent <github@multica.ai>
* docs: refresh skill import source map anchors
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
150 lines
4.6 KiB
Go
150 lines
4.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func TestExistingSkillIdentityByNameReturnsIDAndName(t *testing.T) {
|
|
namePrefix := "duplicate-import-identity"
|
|
name := namePrefix + "-" + t.Name()
|
|
skillID := insertHandlerTestSkill(t, namePrefix, "# Duplicate import identity")
|
|
|
|
existing, ok, err := testHandler.existingSkillIdentityByName(context.Background(), parseUUID(testWorkspaceID), name)
|
|
if err != nil {
|
|
t.Fatalf("existingSkillIdentityByName: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("expected existing skill identity to be found")
|
|
}
|
|
if existing.ID != skillID || existing.Name != name {
|
|
t.Fatalf("existing skill = %#v, want id %s name %s", existing, skillID, name)
|
|
}
|
|
}
|
|
|
|
func TestWriteSkillImportDuplicateConflictIncludesExistingSkill(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
writeSkillImportDuplicateConflict(w, ExistingSkillIdentity{ID: "skill-123", Name: "review-helper"})
|
|
|
|
if w.Code != 409 {
|
|
t.Fatalf("status = %d, want 409: %s", w.Code, w.Body.String())
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if body["error"] != "a skill with this name already exists" {
|
|
t.Fatalf("error = %v", body["error"])
|
|
}
|
|
existing, ok := body["existing_skill"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("existing_skill missing or wrong type: %#v", body["existing_skill"])
|
|
}
|
|
if existing["id"] != "skill-123" || existing["name"] != "review-helper" {
|
|
t.Fatalf("existing_skill = %#v", existing)
|
|
}
|
|
}
|
|
|
|
func withMockClawHubImport(t *testing.T, skillName string) string {
|
|
t.Helper()
|
|
slug := "review-helper"
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/v1/skills/" + slug:
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"skill": map[string]any{
|
|
"slug": slug,
|
|
"displayName": skillName,
|
|
"summary": "Imported test skill",
|
|
"tags": map[string]string{"latest": "1.0.0"},
|
|
},
|
|
})
|
|
case "/api/v1/skills/" + slug + "/versions/1.0.0":
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"version": map[string]any{
|
|
"version": "1.0.0",
|
|
"files": []map[string]any{
|
|
{"path": "SKILL.md", "size": 16},
|
|
},
|
|
},
|
|
})
|
|
case "/api/v1/skills/" + slug + "/file":
|
|
_, _ = w.Write([]byte("# Imported\n"))
|
|
default:
|
|
t.Fatalf("unexpected ClawHub path: %s", r.URL.String())
|
|
}
|
|
}))
|
|
prev := clawHubAPIBase
|
|
clawHubAPIBase = srv.URL + "/api/v1"
|
|
t.Cleanup(func() {
|
|
clawHubAPIBase = prev
|
|
srv.Close()
|
|
})
|
|
return "https://clawhub.ai/acme/" + slug
|
|
}
|
|
|
|
func TestImportSkillOnConflictSkipReturnsStructuredResult(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("handler test DB not configured")
|
|
}
|
|
namePrefix := "url-import-skip"
|
|
skillName := namePrefix + "-" + t.Name()
|
|
existingID := insertHandlerTestSkill(t, namePrefix, "# Existing")
|
|
importURL := withMockClawHubImport(t, skillName)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequestAsUser(testUserID, http.MethodPost, "/api/skills/import", map[string]any{
|
|
"url": importURL,
|
|
"on_conflict": "skip",
|
|
})
|
|
testHandler.ImportSkill(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
|
|
}
|
|
var body SkillImportResult
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if body.Status != "skipped" {
|
|
t.Fatalf("status = %q", body.Status)
|
|
}
|
|
if body.ExistingSkill == nil || body.ExistingSkill.ID != existingID || body.ExistingSkill.Name != skillName {
|
|
t.Fatalf("existing_skill = %#v", body.ExistingSkill)
|
|
}
|
|
}
|
|
|
|
func TestImportSkillOnConflictRenameCreatesSuffixedSkill(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("handler test DB not configured")
|
|
}
|
|
namePrefix := "url-import-rename"
|
|
skillName := namePrefix + "-" + t.Name()
|
|
insertHandlerTestSkill(t, namePrefix, "# Existing")
|
|
importURL := withMockClawHubImport(t, skillName)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequestAsUser(testUserID, http.MethodPost, "/api/skills/import", map[string]any{
|
|
"url": importURL,
|
|
"on_conflict": "rename",
|
|
})
|
|
testHandler.ImportSkill(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201: %s", w.Code, w.Body.String())
|
|
}
|
|
var body SkillImportResult
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if body.Status != "created" || body.Skill == nil {
|
|
t.Fatalf("body = %#v", body)
|
|
}
|
|
if body.Skill.Name != skillName+"-2" {
|
|
t.Fatalf("created skill name = %q, want %q", body.Skill.Name, skillName+"-2")
|
|
}
|
|
}
|