mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-30 10:59:31 +02:00
Compare commits
9 Commits
agent/lamb
...
feat/skill
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf528928b8 | ||
|
|
97b478571e | ||
|
|
ee44396094 | ||
|
|
bcfbee32c2 | ||
|
|
3d91e6fd58 | ||
|
|
f810e9bc2b | ||
|
|
c8bf74c1f3 | ||
|
|
fe15052b49 | ||
|
|
b1431b9056 |
@@ -79,6 +79,19 @@ CI やヘッドレス環境では、ブラウザフローをスキップでき
|
||||
| `multica skill import ...` | GitHub、ClawHub、またはローカルマシンからスキルをインポート |
|
||||
| `multica skill files ...` | ネスト: スキルのファイルを管理 |
|
||||
|
||||
### スキルインポートの競合
|
||||
|
||||
`multica skill import --url <url>` の既定値は `--on-conflict fail` です。同じ名前のスキルがすでに存在する場合、コマンドは構造化された `conflict` 結果で終了し、ワークスペースは変更されません。
|
||||
|
||||
既存スキルの作成者で、スキル ID とエージェントの紐付けを維持したまま内容を置き換える場合は `--on-conflict overwrite` を使います。既存スキルを残してコピーを取り込む場合は `--on-conflict rename` を使うと、`-2` のような接尾辞が自動で付きます。同名の項目を単に飛ばす場合は `--on-conflict skip` を使います。
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## スクワッド
|
||||
|
||||
| コマンド | 用途 |
|
||||
|
||||
@@ -79,6 +79,19 @@ CI나 headless 환경에서는 브라우저 플로우를 건너뛰세요. 웹
|
||||
| `multica skill import ...` | GitHub, ClawHub, 또는 로컬 기기에서 스킬 가져오기 |
|
||||
| `multica skill files ...` | 중첩: 스킬의 파일 관리 |
|
||||
|
||||
### 스킬 가져오기 충돌
|
||||
|
||||
`multica skill import --url <url>`의 기본값은 `--on-conflict fail`입니다. 같은 이름의 스킬이 이미 있으면 명령은 구조화된 `conflict` 결과로 종료되며 워크스페이스를 변경하지 않습니다.
|
||||
|
||||
기존 스킬을 만든 사용자이고, 스킬 ID와 에이전트 연결은 유지한 채 내용을 바꾸려면 `--on-conflict overwrite`를 사용하세요. 기존 스킬을 그대로 두고 복사본을 가져오려면 `--on-conflict rename`을 사용하면 `-2` 같은 접미사가 자동으로 붙습니다. 같은 이름의 항목을 그냥 건너뛰려면 `--on-conflict skip`을 사용하세요.
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## 스쿼드
|
||||
|
||||
| 명령어 | 용도 |
|
||||
|
||||
@@ -79,6 +79,25 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|
||||
| `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine |
|
||||
| `multica skill files ...` | Nested: manage a skill's files |
|
||||
|
||||
### Skill import conflicts
|
||||
|
||||
`multica skill import --url <url>` defaults to `--on-conflict fail`. If a skill
|
||||
with the same name already exists, the command exits with a structured
|
||||
`conflict` result and does not change the workspace.
|
||||
|
||||
Use `--on-conflict overwrite` when you created the existing skill and want to
|
||||
replace its content while preserving its ID and agent bindings. Use
|
||||
`--on-conflict rename` to import a copy with an automatic suffix such as `-2`.
|
||||
Use `--on-conflict skip` to leave the existing skill untouched and report
|
||||
`skipped`.
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## Squads
|
||||
|
||||
| Command | Purpose |
|
||||
|
||||
@@ -79,6 +79,19 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
|
||||
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
|
||||
|
||||
### Skill 导入冲突
|
||||
|
||||
`multica skill import --url <url>` 默认等同于 `--on-conflict fail`。如果工作区里已经有同名 Skill,命令会返回结构化 `conflict` 结果并退出,不会修改工作区。
|
||||
|
||||
如果你是已有 Skill 的 creator,并且想用新导入内容覆盖它,同时保留原 Skill 的 ID 和 agent 绑定,用 `--on-conflict overwrite`。如果想保留已有 Skill、另存一份,用 `--on-conflict rename`,系统会自动加 `-2` 这类后缀。如果只是批量导入时遇到同名项就跳过,用 `--on-conflict skip`。
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## 小队
|
||||
|
||||
| 命令 | 用途 |
|
||||
|
||||
@@ -67,6 +67,16 @@ export async function resolveRuntimeLocalSkillImport(
|
||||
current = await api.getImportLocalSkillResult(runtimeId, initial.id);
|
||||
}
|
||||
|
||||
if (current.status === "conflict") {
|
||||
if (!current.conflict) {
|
||||
throw new Error("runtime local skill import conflict missing details");
|
||||
}
|
||||
return {
|
||||
status: "conflict",
|
||||
conflict: current.conflict,
|
||||
};
|
||||
}
|
||||
|
||||
if (current.status === "failed" || current.status === "timeout") {
|
||||
throw new Error(current.error || "runtime local skill import failed");
|
||||
}
|
||||
@@ -74,7 +84,10 @@ export async function resolveRuntimeLocalSkillImport(
|
||||
throw new Error("runtime local skill import did not return a skill");
|
||||
}
|
||||
|
||||
return { skill: current.skill };
|
||||
return {
|
||||
status: current.action === "overwrite" ? "updated" : "created",
|
||||
skill: current.skill,
|
||||
};
|
||||
}
|
||||
|
||||
export function runtimeLocalSkillsOptions(runtimeId: string | null | undefined) {
|
||||
|
||||
@@ -617,9 +617,18 @@ export type RuntimeLocalSkillStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "conflict"
|
||||
| "failed"
|
||||
| "timeout";
|
||||
|
||||
export type RuntimeLocalSkillImportAction = "overwrite";
|
||||
|
||||
export interface RuntimeLocalSkillImportConflict {
|
||||
existing_skill_id: string;
|
||||
existing_created_by?: string;
|
||||
can_overwrite: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeLocalSkillSummary {
|
||||
key: string;
|
||||
name: string;
|
||||
@@ -644,6 +653,9 @@ export interface CreateRuntimeLocalSkillImportRequest {
|
||||
skill_key: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
action?: RuntimeLocalSkillImportAction;
|
||||
target_skill_id?: string;
|
||||
supports_conflict?: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeLocalSkillImportRequest {
|
||||
@@ -652,8 +664,12 @@ export interface RuntimeLocalSkillImportRequest {
|
||||
skill_key: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
action?: RuntimeLocalSkillImportAction;
|
||||
target_skill_id?: string;
|
||||
supports_conflict?: boolean;
|
||||
status: RuntimeLocalSkillStatus;
|
||||
skill?: Skill;
|
||||
conflict?: RuntimeLocalSkillImportConflict;
|
||||
error?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -665,5 +681,7 @@ export interface RuntimeLocalSkillsResult {
|
||||
}
|
||||
|
||||
export interface RuntimeLocalSkillImportResult {
|
||||
skill: Skill;
|
||||
status: "created" | "updated" | "conflict";
|
||||
skill?: Skill;
|
||||
conflict?: RuntimeLocalSkillImportConflict;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ export type {
|
||||
RuntimeModelListStatus,
|
||||
RuntimeModelsResult,
|
||||
RuntimeLocalSkillStatus,
|
||||
RuntimeLocalSkillImportAction,
|
||||
RuntimeLocalSkillImportConflict,
|
||||
RuntimeLocalSkillSummary,
|
||||
RuntimeLocalSkillListRequest,
|
||||
CreateRuntimeLocalSkillImportRequest,
|
||||
|
||||
@@ -231,9 +231,27 @@
|
||||
"bulk_complete_hint": "Import complete.",
|
||||
"bulk_cancelled_hint": "Import cancelled.",
|
||||
"bulk_done_button": "Done",
|
||||
"bulk_summary_imported": "Imported",
|
||||
"bulk_summary_created": "Created",
|
||||
"bulk_summary_updated": "Updated",
|
||||
"bulk_summary_conflicts": "Conflicts",
|
||||
"bulk_summary_skipped": "Skipped",
|
||||
"bulk_summary_failed": "Failed"
|
||||
"bulk_summary_failed": "Failed",
|
||||
"conflict_single_title": "A skill with this name already exists",
|
||||
"conflict_bulk_title": "{{count}} skills need a decision",
|
||||
"conflict_hint": "Choose whether to overwrite the existing workspace skill, import with a new name, or skip it.",
|
||||
"conflict_locked_creator": "This skill was created by {{creator}}. Only the creator can overwrite it from local import; edit it from the Skill detail page if you need to change it.",
|
||||
"conflict_locked": "Only the creator can overwrite this skill from local import; edit it from the Skill detail page if you need to change it.",
|
||||
"conflict_overwrite": "Overwrite",
|
||||
"conflict_rename": "Rename",
|
||||
"conflict_skip": "Skip",
|
||||
"conflict_cancel": "Cancel",
|
||||
"conflict_overwrite_all": "Overwrite all",
|
||||
"conflict_skip_all": "Skip all",
|
||||
"conflict_rename_label": "New skill name",
|
||||
"conflict_footer": "{{count}} conflict decisions pending.",
|
||||
"conflict_apply_button": "Apply decisions",
|
||||
"conflict_missing_resolution": "Choose how to resolve this conflict.",
|
||||
"conflict_name_still_exists": "That name already exists. Choose another name or skip."
|
||||
},
|
||||
"file_tree": {
|
||||
"no_files": "No files"
|
||||
|
||||
@@ -228,9 +228,27 @@
|
||||
"bulk_complete_hint": "インポートが完了しました。",
|
||||
"bulk_cancelled_hint": "インポートをキャンセルしました。",
|
||||
"bulk_done_button": "完了",
|
||||
"bulk_summary_imported": "インポート済み",
|
||||
"bulk_summary_created": "作成済み",
|
||||
"bulk_summary_updated": "更新済み",
|
||||
"bulk_summary_conflicts": "競合",
|
||||
"bulk_summary_skipped": "スキップ",
|
||||
"bulk_summary_failed": "失敗"
|
||||
"bulk_summary_failed": "失敗",
|
||||
"conflict_single_title": "同じ名前のスキルがすでに存在します",
|
||||
"conflict_bulk_title": "{{count}} 個のスキルに判断が必要です",
|
||||
"conflict_hint": "既存のワークスペーススキルを上書きするか、新しい名前でインポートするか、スキップするかを選択してください。",
|
||||
"conflict_locked_creator": "このスキルは {{creator}} が作成しました。ローカルインポートで上書きできるのは作成者だけです。変更が必要な場合は、スキル詳細ページで編集してください。",
|
||||
"conflict_locked": "ローカルインポートでこのスキルを上書きできるのは作成者だけです。変更が必要な場合は、スキル詳細ページで編集してください。",
|
||||
"conflict_overwrite": "上書き",
|
||||
"conflict_rename": "名前を変更",
|
||||
"conflict_skip": "スキップ",
|
||||
"conflict_cancel": "キャンセル",
|
||||
"conflict_overwrite_all": "すべて上書き",
|
||||
"conflict_skip_all": "すべてスキップ",
|
||||
"conflict_rename_label": "新しいスキル名",
|
||||
"conflict_footer": "{{count}} 件の競合判断が残っています。",
|
||||
"conflict_apply_button": "決定を適用",
|
||||
"conflict_missing_resolution": "この競合の処理方法を選択してください。",
|
||||
"conflict_name_still_exists": "その名前はすでに存在します。別の名前を選択するか、スキップしてください。"
|
||||
},
|
||||
"file_tree": {
|
||||
"no_files": "ファイルなし"
|
||||
|
||||
@@ -231,9 +231,27 @@
|
||||
"bulk_complete_hint": "가져오기가 완료되었습니다.",
|
||||
"bulk_cancelled_hint": "가져오기가 취소되었습니다.",
|
||||
"bulk_done_button": "완료",
|
||||
"bulk_summary_imported": "가져옴",
|
||||
"bulk_summary_created": "생성됨",
|
||||
"bulk_summary_updated": "업데이트됨",
|
||||
"bulk_summary_conflicts": "충돌",
|
||||
"bulk_summary_skipped": "건너뜀",
|
||||
"bulk_summary_failed": "실패"
|
||||
"bulk_summary_failed": "실패",
|
||||
"conflict_single_title": "이 이름의 스킬이 이미 있습니다",
|
||||
"conflict_bulk_title": "{{count}}개의 스킬에 결정이 필요합니다",
|
||||
"conflict_hint": "기존 워크스페이스 스킬을 덮어쓸지, 새 이름으로 가져올지, 건너뛸지 선택하세요.",
|
||||
"conflict_locked_creator": "이 스킬은 {{creator}}님이 만들었습니다. 로컬 가져오기에서 덮어쓰기는 생성자만 할 수 있습니다. 변경이 필요하면 스킬 상세 페이지에서 편집하세요.",
|
||||
"conflict_locked": "로컬 가져오기에서 이 스킬은 생성자만 덮어쓸 수 있습니다. 변경이 필요하면 스킬 상세 페이지에서 편집하세요.",
|
||||
"conflict_overwrite": "덮어쓰기",
|
||||
"conflict_rename": "이름 변경",
|
||||
"conflict_skip": "건너뛰기",
|
||||
"conflict_cancel": "취소",
|
||||
"conflict_overwrite_all": "모두 덮어쓰기",
|
||||
"conflict_skip_all": "모두 건너뛰기",
|
||||
"conflict_rename_label": "새 스킬 이름",
|
||||
"conflict_footer": "{{count}}개의 충돌 결정이 남아 있습니다.",
|
||||
"conflict_apply_button": "결정 적용",
|
||||
"conflict_missing_resolution": "이 충돌을 처리할 방법을 선택하세요.",
|
||||
"conflict_name_still_exists": "해당 이름이 이미 있습니다. 다른 이름을 선택하거나 건너뛰세요."
|
||||
},
|
||||
"file_tree": {
|
||||
"no_files": "파일 없음"
|
||||
|
||||
@@ -243,9 +243,27 @@
|
||||
"bulk_complete_hint": "导入完成。",
|
||||
"bulk_cancelled_hint": "导入已取消。",
|
||||
"bulk_done_button": "完成",
|
||||
"bulk_summary_imported": "已导入",
|
||||
"bulk_summary_created": "已创建",
|
||||
"bulk_summary_updated": "已更新",
|
||||
"bulk_summary_conflicts": "冲突",
|
||||
"bulk_summary_skipped": "已跳过",
|
||||
"bulk_summary_failed": "失败"
|
||||
"bulk_summary_failed": "失败",
|
||||
"conflict_single_title": "工作区里已存在同名 skill",
|
||||
"conflict_bulk_title": "{{count}} 个 skill 需要处理冲突",
|
||||
"conflict_hint": "选择覆盖现有工作区 skill、改名导入,或跳过。",
|
||||
"conflict_locked_creator": "该 skill 由 {{creator}} 创建,只能由创建者通过本地导入覆盖;如需修改请到 Skill 详情页编辑。",
|
||||
"conflict_locked": "该 skill 只能由创建者通过本地导入覆盖;如需修改请到 Skill 详情页编辑。",
|
||||
"conflict_overwrite": "覆盖",
|
||||
"conflict_rename": "改名",
|
||||
"conflict_skip": "跳过",
|
||||
"conflict_cancel": "取消",
|
||||
"conflict_overwrite_all": "全部覆盖",
|
||||
"conflict_skip_all": "全部跳过",
|
||||
"conflict_rename_label": "新的 skill 名称",
|
||||
"conflict_footer": "还有 {{count}} 个冲突决策待处理。",
|
||||
"conflict_apply_button": "应用决策",
|
||||
"conflict_missing_resolution": "请选择如何处理这个冲突。",
|
||||
"conflict_name_still_exists": "这个名称仍然已存在。请换一个名称或跳过。"
|
||||
},
|
||||
"file_tree": {
|
||||
"no_files": "无文件"
|
||||
|
||||
@@ -148,6 +148,7 @@ describe("RuntimeLocalSkillImportPanel", () => {
|
||||
}),
|
||||
});
|
||||
mockResolveRuntimeLocalSkillImport.mockResolvedValue({
|
||||
status: "created",
|
||||
skill: MOCK_IMPORTED_SKILL_A,
|
||||
});
|
||||
});
|
||||
@@ -183,6 +184,7 @@ describe("RuntimeLocalSkillImportPanel", () => {
|
||||
skill_key: "review-helper",
|
||||
name: "Review Helper",
|
||||
description: "Review pull requests",
|
||||
supports_conflict: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -200,8 +202,8 @@ describe("RuntimeLocalSkillImportPanel", () => {
|
||||
}),
|
||||
});
|
||||
mockResolveRuntimeLocalSkillImport
|
||||
.mockResolvedValueOnce({ skill: MOCK_IMPORTED_SKILL_A })
|
||||
.mockResolvedValueOnce({ skill: MOCK_IMPORTED_SKILL_B });
|
||||
.mockResolvedValueOnce({ status: "created", skill: MOCK_IMPORTED_SKILL_A })
|
||||
.mockResolvedValueOnce({ status: "created", skill: MOCK_IMPORTED_SKILL_B });
|
||||
|
||||
renderPanel();
|
||||
|
||||
@@ -240,8 +242,8 @@ describe("RuntimeLocalSkillImportPanel", () => {
|
||||
|
||||
expect(mockResolveRuntimeLocalSkillImport).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Verify summary shows both as imported
|
||||
expect(screen.getByText("Imported")).toBeInTheDocument();
|
||||
// Verify summary shows both as created
|
||||
expect(screen.getByText("Created")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles partial failures gracefully", async () => {
|
||||
@@ -254,7 +256,7 @@ describe("RuntimeLocalSkillImportPanel", () => {
|
||||
}),
|
||||
});
|
||||
mockResolveRuntimeLocalSkillImport
|
||||
.mockResolvedValueOnce({ skill: MOCK_IMPORTED_SKILL_A })
|
||||
.mockResolvedValueOnce({ status: "created", skill: MOCK_IMPORTED_SKILL_A })
|
||||
.mockRejectedValueOnce(new Error("409 conflict: already exists"));
|
||||
|
||||
renderPanel();
|
||||
@@ -291,8 +293,8 @@ describe("RuntimeLocalSkillImportPanel", () => {
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
// Summary should show imported and skipped
|
||||
expect(screen.getByText("Imported")).toBeInTheDocument();
|
||||
// Summary should show created and skipped
|
||||
expect(screen.getByText("Created")).toBeInTheDocument();
|
||||
expect(screen.getByText("Skipped")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -344,8 +346,8 @@ describe("RuntimeLocalSkillImportPanel", () => {
|
||||
}),
|
||||
});
|
||||
mockResolveRuntimeLocalSkillImport
|
||||
.mockResolvedValueOnce({ skill: MOCK_IMPORTED_SKILL_A })
|
||||
.mockResolvedValueOnce({ skill: MOCK_IMPORTED_SKILL_B });
|
||||
.mockResolvedValueOnce({ status: "created", skill: MOCK_IMPORTED_SKILL_A })
|
||||
.mockResolvedValueOnce({ status: "created", skill: MOCK_IMPORTED_SKILL_B });
|
||||
|
||||
const onImported = vi.fn();
|
||||
const onBulkDone = vi.fn();
|
||||
@@ -385,4 +387,145 @@ describe("RuntimeLocalSkillImportPanel", () => {
|
||||
expect(onBulkDone).toHaveBeenCalledTimes(1);
|
||||
expect(onImported).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves a creator conflict by overwriting the existing skill", async () => {
|
||||
mockResolveRuntimeLocalSkillImport
|
||||
.mockResolvedValueOnce({
|
||||
status: "conflict",
|
||||
conflict: {
|
||||
existing_skill_id: "existing-skill-1",
|
||||
existing_created_by: "user-1",
|
||||
can_overwrite: true,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: "updated",
|
||||
skill: {
|
||||
...MOCK_IMPORTED_SKILL_A,
|
||||
id: "existing-skill-1",
|
||||
},
|
||||
});
|
||||
|
||||
renderPanel();
|
||||
|
||||
expect(
|
||||
await screen.findByText("Review Helper", {}, { timeout: 5000 }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Review Helper/i }));
|
||||
|
||||
const importButton = screen.getByRole("button", {
|
||||
name: /Import to Workspace/i,
|
||||
});
|
||||
await waitFor(() => expect(importButton).not.toBeDisabled(), {
|
||||
timeout: 5000,
|
||||
});
|
||||
fireEvent.click(importButton);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/A skill with this name already exists/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const applyButton = screen.getByRole("button", {
|
||||
name: /Apply decisions/i,
|
||||
});
|
||||
await waitFor(() => expect(applyButton).not.toBeDisabled(), {
|
||||
timeout: 5000,
|
||||
});
|
||||
fireEvent.click(applyButton);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockResolveRuntimeLocalSkillImport).toHaveBeenLastCalledWith(
|
||||
"runtime-1",
|
||||
{
|
||||
skill_key: "review-helper",
|
||||
name: "Review Helper",
|
||||
description: "Review pull requests",
|
||||
supports_conflict: true,
|
||||
action: "overwrite",
|
||||
target_skill_id: "existing-skill-1",
|
||||
},
|
||||
);
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Updated")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps bulk completion behavior when conflict resolution leaves one success", async () => {
|
||||
mockRuntimeLocalSkillsOptions.mockReturnValue({
|
||||
queryKey: ["runtimes", "local-skills", "runtime-1"],
|
||||
queryFn: () =>
|
||||
Promise.resolve({
|
||||
supported: true,
|
||||
skills: [MOCK_SKILL_A, MOCK_SKILL_B],
|
||||
}),
|
||||
});
|
||||
mockResolveRuntimeLocalSkillImport
|
||||
.mockResolvedValueOnce({
|
||||
status: "conflict",
|
||||
conflict: {
|
||||
existing_skill_id: "existing-skill-1",
|
||||
existing_created_by: "user-1",
|
||||
can_overwrite: true,
|
||||
},
|
||||
})
|
||||
.mockRejectedValueOnce(new Error("daemon failed"))
|
||||
.mockResolvedValueOnce({
|
||||
status: "updated",
|
||||
skill: {
|
||||
...MOCK_IMPORTED_SKILL_A,
|
||||
id: "existing-skill-1",
|
||||
},
|
||||
});
|
||||
|
||||
const onImported = vi.fn();
|
||||
const onBulkDone = vi.fn();
|
||||
renderPanel({ onImported, onBulkDone });
|
||||
|
||||
expect(
|
||||
await screen.findByText("Review Helper", {}, { timeout: 5000 }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const selectAllLabel = screen.getByText(/Select all/i);
|
||||
const selectAllCheckbox = selectAllLabel
|
||||
.closest("label")!
|
||||
.querySelector("input[type='checkbox']")!;
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
|
||||
const importButton = screen.getByRole("button", {
|
||||
name: /Import 2 Skills/i,
|
||||
});
|
||||
await waitFor(() => expect(importButton).not.toBeDisabled(), {
|
||||
timeout: 5000,
|
||||
});
|
||||
fireEvent.click(importButton);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/A skill with this name already exists/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const applyButton = screen.getByRole("button", {
|
||||
name: /Apply decisions/i,
|
||||
});
|
||||
await waitFor(() => expect(applyButton).not.toBeDisabled(), {
|
||||
timeout: 5000,
|
||||
});
|
||||
fireEvent.click(applyButton);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Done/i }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Done/i }));
|
||||
expect(onBulkDone).toHaveBeenCalledTimes(1);
|
||||
expect(onImported).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,14 +4,18 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
SkipForward,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
AgentRuntime,
|
||||
RuntimeLocalSkillImportConflict,
|
||||
RuntimeLocalSkillSummary,
|
||||
Skill,
|
||||
} from "@multica/core/types";
|
||||
@@ -53,22 +57,33 @@ import { isNameConflictError } from "../lib/utils";
|
||||
type BulkImportResult = {
|
||||
key: string;
|
||||
name: string;
|
||||
status: "success" | "skipped" | "failed";
|
||||
description?: string;
|
||||
status: "created" | "updated" | "conflict" | "skipped" | "failed";
|
||||
conflict?: RuntimeLocalSkillImportConflict;
|
||||
error?: string;
|
||||
skill?: Skill;
|
||||
};
|
||||
|
||||
type BulkImportState = {
|
||||
phase: "idle" | "importing" | "done" | "cancelled";
|
||||
phase: "idle" | "importing" | "resolving" | "done" | "cancelled";
|
||||
total: number;
|
||||
completed: number;
|
||||
selectedCount: number;
|
||||
results: BulkImportResult[];
|
||||
};
|
||||
|
||||
type ConflictResolutionAction = "overwrite" | "rename" | "skip";
|
||||
|
||||
type ConflictResolutionState = {
|
||||
action: ConflictResolutionAction;
|
||||
renameName: string;
|
||||
};
|
||||
|
||||
const INITIAL_BULK_STATE: BulkImportState = {
|
||||
phase: "idle",
|
||||
total: 0,
|
||||
completed: 0,
|
||||
selectedCount: 0,
|
||||
results: [],
|
||||
};
|
||||
|
||||
@@ -90,6 +105,25 @@ function runtimeLabel(runtime: AgentRuntime): string {
|
||||
return `${runtime.name} (${runtime.provider})`;
|
||||
}
|
||||
|
||||
function defaultRenameName(name: string): string {
|
||||
return `${name} copy`;
|
||||
}
|
||||
|
||||
function ResultIcon({ status }: { status: BulkImportResult["status"] }) {
|
||||
switch (status) {
|
||||
case "created":
|
||||
return <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-green-600" />;
|
||||
case "updated":
|
||||
return <RefreshCw className="h-3.5 w-3.5 shrink-0 text-blue-600" />;
|
||||
case "conflict":
|
||||
return <AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-600" />;
|
||||
case "skipped":
|
||||
return <SkipForward className="h-3.5 w-3.5 shrink-0 text-yellow-600" />;
|
||||
case "failed":
|
||||
return <AlertCircle className="h-3.5 w-3.5 shrink-0 text-destructive" />;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill row with checkbox
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -195,20 +229,38 @@ function SkillItem({
|
||||
|
||||
function BulkImportSummary({ results }: { results: BulkImportResult[] }) {
|
||||
const { t } = useT("skills");
|
||||
const succeeded = results.filter((r) => r.status === "success");
|
||||
const created = results.filter((r) => r.status === "created");
|
||||
const updated = results.filter((r) => r.status === "updated");
|
||||
const conflicts = results.filter((r) => r.status === "conflict");
|
||||
const skipped = results.filter((r) => r.status === "skipped");
|
||||
const failed = results.filter((r) => r.status === "failed");
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Summary counts */}
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="grid grid-cols-2 gap-2 text-center sm:grid-cols-5">
|
||||
<div className="rounded-md bg-green-50 px-3 py-2 dark:bg-green-950/30">
|
||||
<div className="text-lg font-semibold text-green-700 dark:text-green-400">
|
||||
{succeeded.length}
|
||||
{created.length}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t(($) => $.runtime_import.bulk_summary_imported)}
|
||||
{t(($) => $.runtime_import.bulk_summary_created)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-blue-50 px-3 py-2 dark:bg-blue-950/30">
|
||||
<div className="text-lg font-semibold text-blue-700 dark:text-blue-400">
|
||||
{updated.length}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t(($) => $.runtime_import.bulk_summary_updated)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-amber-50 px-3 py-2 dark:bg-amber-950/30">
|
||||
<div className="text-lg font-semibold text-amber-700 dark:text-amber-400">
|
||||
{conflicts.length}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t(($) => $.runtime_import.bulk_summary_conflicts)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-yellow-50 px-3 py-2 dark:bg-yellow-950/30">
|
||||
@@ -236,15 +288,7 @@ function BulkImportSummary({ results }: { results: BulkImportResult[] }) {
|
||||
key={r.key}
|
||||
className="flex items-center gap-2 rounded px-2 py-1.5 text-xs"
|
||||
>
|
||||
{r.status === "success" && (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-green-600" />
|
||||
)}
|
||||
{r.status === "skipped" && (
|
||||
<SkipForward className="h-3.5 w-3.5 shrink-0 text-yellow-600" />
|
||||
)}
|
||||
{r.status === "failed" && (
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-destructive" />
|
||||
)}
|
||||
<ResultIcon status={r.status} />
|
||||
<span className="min-w-0 flex-1 truncate">{r.name}</span>
|
||||
{r.error && (
|
||||
<span className="max-w-[200px] shrink-0 truncate text-muted-foreground">
|
||||
@@ -258,6 +302,166 @@ function BulkImportSummary({ results }: { results: BulkImportResult[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ConflictResolutionPanel({
|
||||
conflicts,
|
||||
resolutions,
|
||||
onChange,
|
||||
onOverwriteAll,
|
||||
onSkipAll,
|
||||
}: {
|
||||
conflicts: BulkImportResult[];
|
||||
resolutions: Record<string, ConflictResolutionState>;
|
||||
onChange: (key: string, next: ConflictResolutionState) => void;
|
||||
onOverwriteAll: () => void;
|
||||
onSkipAll: () => void;
|
||||
}) {
|
||||
const { t } = useT("skills");
|
||||
const single = conflicts.length === 1;
|
||||
const canOverwriteAny = conflicts.some((r) => r.conflict?.can_overwrite);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-950 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">
|
||||
{single
|
||||
? t(($) => $.runtime_import.conflict_single_title)
|
||||
: t(($) => $.runtime_import.conflict_bulk_title, {
|
||||
count: conflicts.length,
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-xs opacity-85">
|
||||
{t(($) => $.runtime_import.conflict_hint)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!single && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onOverwriteAll}
|
||||
disabled={!canOverwriteAny}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
{t(($) => $.runtime_import.conflict_overwrite_all)}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onSkipAll}>
|
||||
<SkipForward className="h-3 w-3" />
|
||||
{t(($) => $.runtime_import.conflict_skip_all)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{conflicts.map((r) => {
|
||||
const resolution =
|
||||
resolutions[r.key] ??
|
||||
({
|
||||
action: r.conflict?.can_overwrite ? "overwrite" : "rename",
|
||||
renameName: defaultRenameName(r.name),
|
||||
} satisfies ConflictResolutionState);
|
||||
const creator = r.conflict?.existing_created_by;
|
||||
return (
|
||||
<div key={r.key} className="rounded-lg border bg-card p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{r.name}</div>
|
||||
{r.error && (
|
||||
<p className="mt-1 text-xs text-destructive">{r.error}</p>
|
||||
)}
|
||||
{!r.conflict?.can_overwrite && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{creator
|
||||
? t(($) => $.runtime_import.conflict_locked_creator, {
|
||||
creator,
|
||||
})
|
||||
: t(($) => $.runtime_import.conflict_locked)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
resolution.action === "overwrite" ? "default" : "outline"
|
||||
}
|
||||
onClick={() =>
|
||||
onChange(r.key, {
|
||||
action: "overwrite",
|
||||
renameName: resolution.renameName,
|
||||
})
|
||||
}
|
||||
disabled={!r.conflict?.can_overwrite}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
{t(($) => $.runtime_import.conflict_overwrite)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={resolution.action === "rename" ? "default" : "outline"}
|
||||
onClick={() =>
|
||||
onChange(r.key, {
|
||||
action: "rename",
|
||||
renameName: resolution.renameName,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(($) => $.runtime_import.conflict_rename)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={resolution.action === "skip" ? "default" : "outline"}
|
||||
onClick={() =>
|
||||
onChange(r.key, {
|
||||
action: "skip",
|
||||
renameName: resolution.renameName,
|
||||
})
|
||||
}
|
||||
>
|
||||
<XCircle className="h-3 w-3" />
|
||||
{single
|
||||
? t(($) => $.runtime_import.conflict_cancel)
|
||||
: t(($) => $.runtime_import.conflict_skip)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{resolution.action === "rename" && (
|
||||
<div className="mt-3 space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t(($) => $.runtime_import.conflict_rename_label)}
|
||||
</Label>
|
||||
<Input
|
||||
value={resolution.renameName}
|
||||
onChange={(e) =>
|
||||
onChange(r.key, {
|
||||
action: "rename",
|
||||
renameName: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -288,12 +492,17 @@ export function RuntimeLocalSkillImportPanel({
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState<string>("");
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
const [bulkState, setBulkState] = useState<BulkImportState>(INITIAL_BULK_STATE);
|
||||
const [conflictResolutions, setConflictResolutions] = useState<
|
||||
Record<string, ConflictResolutionState>
|
||||
>({});
|
||||
const cancelRef = useRef(false);
|
||||
// Single-select inline edit fields (shown when exactly 1 skill is checked)
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
|
||||
const importing = bulkState.phase === "importing";
|
||||
const resolvingConflicts = bulkState.phase === "resolving";
|
||||
const busy = importing || resolvingConflicts;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRuntimeId((prev) => prev || localRuntimes[0]?.id || "");
|
||||
@@ -302,6 +511,7 @@ export function RuntimeLocalSkillImportPanel({
|
||||
useEffect(() => {
|
||||
setSelectedKeys(new Set());
|
||||
setBulkState(INITIAL_BULK_STATE);
|
||||
setConflictResolutions({});
|
||||
setEditName("");
|
||||
setEditDescription("");
|
||||
}, [selectedRuntimeId]);
|
||||
@@ -354,6 +564,56 @@ export function RuntimeLocalSkillImportPanel({
|
||||
const allSelected =
|
||||
runtimeSkills.length > 0 && selectedKeys.size === runtimeSkills.length;
|
||||
const someSelected = selectedKeys.size > 0 && !allSelected;
|
||||
const pendingConflicts = bulkState.results.filter(
|
||||
(r) => r.status === "conflict" && r.conflict,
|
||||
);
|
||||
const canApplyConflictResolutions =
|
||||
pendingConflicts.length > 0 &&
|
||||
pendingConflicts.every((r) => {
|
||||
const resolution = conflictResolutions[r.key];
|
||||
if (!resolution) return false;
|
||||
if (resolution.action === "overwrite") return !!r.conflict?.can_overwrite;
|
||||
if (resolution.action === "rename") return !!resolution.renameName.trim();
|
||||
return true;
|
||||
});
|
||||
|
||||
const setConflictResolution = (
|
||||
key: string,
|
||||
next: ConflictResolutionState,
|
||||
) => {
|
||||
setConflictResolutions((prev) => ({ ...prev, [key]: next }));
|
||||
};
|
||||
|
||||
const seedConflictResolutions = (results: BulkImportResult[]) => {
|
||||
const next: Record<string, ConflictResolutionState> = {};
|
||||
for (const r of results) {
|
||||
if (r.status !== "conflict" || !r.conflict) continue;
|
||||
next[r.key] = {
|
||||
action: r.conflict.can_overwrite ? "overwrite" : "rename",
|
||||
renameName: defaultRenameName(r.name),
|
||||
};
|
||||
}
|
||||
setConflictResolutions(next);
|
||||
};
|
||||
|
||||
const refreshImportedSkills = async (results: BulkImportResult[]) => {
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({
|
||||
queryKey: runtimeLocalSkillsKeys.forRuntime(selectedRuntimeId),
|
||||
}),
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) }),
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }),
|
||||
]);
|
||||
|
||||
for (const r of results) {
|
||||
if ((r.status === "created" || r.status === "updated") && r.skill) {
|
||||
qc.setQueryData(
|
||||
skillDetailOptions(wsId, r.skill.id).queryKey,
|
||||
r.skill,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// -- Bulk import handler --
|
||||
|
||||
@@ -364,7 +624,13 @@ export function RuntimeLocalSkillImportPanel({
|
||||
const total = skillsToImport.length;
|
||||
|
||||
cancelRef.current = false;
|
||||
setBulkState({ phase: "importing", total, completed: 0, results: [] });
|
||||
setBulkState({
|
||||
phase: "importing",
|
||||
total,
|
||||
completed: 0,
|
||||
selectedCount: total,
|
||||
results: [],
|
||||
});
|
||||
|
||||
const results: BulkImportResult[] = [];
|
||||
|
||||
@@ -381,19 +647,32 @@ export function RuntimeLocalSkillImportPanel({
|
||||
skill_key: skill.key,
|
||||
name: importName,
|
||||
description: importDescription,
|
||||
supports_conflict: true,
|
||||
});
|
||||
results.push({
|
||||
key: skill.key,
|
||||
name: importName,
|
||||
status: "success",
|
||||
skill: result.skill,
|
||||
});
|
||||
if (result.status === "conflict") {
|
||||
results.push({
|
||||
key: skill.key,
|
||||
name: importName,
|
||||
description: importDescription,
|
||||
status: "conflict",
|
||||
conflict: result.conflict,
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
key: skill.key,
|
||||
name: result.skill?.name ?? importName,
|
||||
description: importDescription,
|
||||
status: result.status,
|
||||
skill: result.skill,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "";
|
||||
const isSkipped = isNameConflictError(msg);
|
||||
results.push({
|
||||
key: skill.key,
|
||||
name: skill.name,
|
||||
description: importDescription,
|
||||
status: isSkipped ? "skipped" : "failed",
|
||||
error: msg || t(($) => $.runtime_import.toast_import_failed),
|
||||
});
|
||||
@@ -420,23 +699,16 @@ export function RuntimeLocalSkillImportPanel({
|
||||
}
|
||||
await Promise.all(executing);
|
||||
|
||||
// Invalidate queries ONCE at the end
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({
|
||||
queryKey: runtimeLocalSkillsKeys.forRuntime(selectedRuntimeId),
|
||||
}),
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) }),
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) }),
|
||||
]);
|
||||
await refreshImportedSkills(results);
|
||||
|
||||
// Seed query cache for each successfully imported skill
|
||||
for (const r of results) {
|
||||
if (r.status === "success" && r.skill) {
|
||||
qc.setQueryData(
|
||||
skillDetailOptions(wsId, r.skill.id).queryKey,
|
||||
r.skill,
|
||||
);
|
||||
}
|
||||
const conflicts = results.filter((r) => r.status === "conflict");
|
||||
if (!cancelRef.current && conflicts.length > 0) {
|
||||
seedConflictResolutions(results);
|
||||
setBulkState((prev) => ({
|
||||
...prev,
|
||||
phase: "resolving",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setBulkState((prev) => ({
|
||||
@@ -445,13 +717,120 @@ export function RuntimeLocalSkillImportPanel({
|
||||
}));
|
||||
};
|
||||
|
||||
const handleApplyConflictResolutions = async () => {
|
||||
if (!selectedRuntimeId || pendingConflicts.length === 0) return;
|
||||
|
||||
const conflicts = [...pendingConflicts];
|
||||
let nextResults = bulkState.results;
|
||||
const applyResult = (key: string, next: Partial<BulkImportResult>) => {
|
||||
nextResults = nextResults.map((r) =>
|
||||
r.key === key ? { ...r, ...next } : r,
|
||||
);
|
||||
setBulkState((prev) => ({
|
||||
...prev,
|
||||
results: prev.results.map((r) =>
|
||||
r.key === key ? { ...r, ...next } : r,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
setBulkState((prev) => ({
|
||||
...prev,
|
||||
phase: "importing",
|
||||
total: conflicts.length,
|
||||
completed: 0,
|
||||
}));
|
||||
|
||||
for (const r of conflicts) {
|
||||
const resolution = conflictResolutions[r.key];
|
||||
if (!resolution) {
|
||||
applyResult(r.key, {
|
||||
status: "failed",
|
||||
error: t(($) => $.runtime_import.conflict_missing_resolution),
|
||||
});
|
||||
setBulkState((prev) => ({ ...prev, completed: prev.completed + 1 }));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolution.action === "skip") {
|
||||
applyResult(r.key, { status: "skipped", error: undefined });
|
||||
setBulkState((prev) => ({ ...prev, completed: prev.completed + 1 }));
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await resolveRuntimeLocalSkillImport(selectedRuntimeId, {
|
||||
skill_key: r.key,
|
||||
name:
|
||||
resolution.action === "rename"
|
||||
? resolution.renameName.trim()
|
||||
: r.name,
|
||||
description: r.description,
|
||||
supports_conflict: true,
|
||||
...(resolution.action === "overwrite" && r.conflict
|
||||
? {
|
||||
action: "overwrite" as const,
|
||||
target_skill_id: r.conflict.existing_skill_id,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (result.status === "conflict") {
|
||||
applyResult(r.key, {
|
||||
name:
|
||||
resolution.action === "rename"
|
||||
? resolution.renameName.trim()
|
||||
: r.name,
|
||||
status: "conflict",
|
||||
conflict: result.conflict,
|
||||
error: t(($) => $.runtime_import.conflict_name_still_exists),
|
||||
});
|
||||
if (result.conflict) {
|
||||
setConflictResolution(r.key, {
|
||||
action: result.conflict.can_overwrite ? "overwrite" : "rename",
|
||||
renameName: defaultRenameName(
|
||||
resolution.action === "rename"
|
||||
? resolution.renameName.trim()
|
||||
: r.name,
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
applyResult(r.key, {
|
||||
name: result.skill?.name ?? r.name,
|
||||
status: result.status,
|
||||
skill: result.skill,
|
||||
conflict: undefined,
|
||||
error: undefined,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "";
|
||||
applyResult(r.key, {
|
||||
status: "failed",
|
||||
error: msg || t(($) => $.runtime_import.toast_import_failed),
|
||||
});
|
||||
}
|
||||
|
||||
setBulkState((prev) => ({ ...prev, completed: prev.completed + 1 }));
|
||||
}
|
||||
|
||||
await refreshImportedSkills(nextResults);
|
||||
const unresolved = nextResults.some((r) => r.status === "conflict");
|
||||
setBulkState((prev) => ({
|
||||
...prev,
|
||||
results: nextResults,
|
||||
phase: unresolved ? "resolving" : "done",
|
||||
}));
|
||||
};
|
||||
|
||||
const canImport =
|
||||
!!selectedRuntime &&
|
||||
selectedRuntime.status === "online" &&
|
||||
selectedKeys.size > 0 &&
|
||||
// Single-select requires a non-empty name (user may be renaming)
|
||||
(selectedKeys.size > 1 || !!editName.trim()) &&
|
||||
!importing;
|
||||
!busy;
|
||||
|
||||
const handleCancel = () => {
|
||||
cancelRef.current = true;
|
||||
@@ -488,15 +867,7 @@ export function RuntimeLocalSkillImportPanel({
|
||||
key={r.key}
|
||||
className="flex items-center gap-2 rounded px-2 py-1 text-xs"
|
||||
>
|
||||
{r.status === "success" && (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-green-600" />
|
||||
)}
|
||||
{r.status === "skipped" && (
|
||||
<SkipForward className="h-3.5 w-3.5 shrink-0 text-yellow-600" />
|
||||
)}
|
||||
{r.status === "failed" && (
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-destructive" />
|
||||
)}
|
||||
<ResultIcon status={r.status} />
|
||||
<span className="truncate">{r.name}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -510,6 +881,41 @@ export function RuntimeLocalSkillImportPanel({
|
||||
return <BulkImportSummary results={bulkState.results} />;
|
||||
}
|
||||
|
||||
if (bulkState.phase === "resolving") {
|
||||
return (
|
||||
<ConflictResolutionPanel
|
||||
conflicts={pendingConflicts}
|
||||
resolutions={conflictResolutions}
|
||||
onChange={setConflictResolution}
|
||||
onOverwriteAll={() => {
|
||||
setConflictResolutions((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const r of pendingConflicts) {
|
||||
if (!r.conflict?.can_overwrite) continue;
|
||||
next[r.key] = {
|
||||
action: "overwrite",
|
||||
renameName: prev[r.key]?.renameName ?? defaultRenameName(r.name),
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onSkipAll={() => {
|
||||
setConflictResolutions((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const r of pendingConflicts) {
|
||||
next[r.key] = {
|
||||
action: "skip",
|
||||
renameName: prev[r.key]?.renameName ?? defaultRenameName(r.name),
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Idle phase: skill selection list ---
|
||||
|
||||
if (localRuntimes.length === 0) {
|
||||
@@ -626,10 +1032,16 @@ export function RuntimeLocalSkillImportPanel({
|
||||
// -- Handle "Done" button after import --
|
||||
|
||||
const handleDone = () => {
|
||||
const succeeded = bulkState.results.filter((r) => r.status === "success");
|
||||
const succeeded = bulkState.results.filter(
|
||||
(r) => r.status === "created" || r.status === "updated",
|
||||
);
|
||||
// Single-import flow: navigate to the imported skill detail page.
|
||||
// Multi-import flow: close the dialog even if only one succeeded.
|
||||
if (bulkState.total === 1 && succeeded.length === 1 && succeeded[0]!.skill) {
|
||||
if (
|
||||
bulkState.selectedCount === 1 &&
|
||||
succeeded.length === 1 &&
|
||||
succeeded[0]!.skill
|
||||
) {
|
||||
onImported?.(succeeded[0]!.skill);
|
||||
} else {
|
||||
onBulkDone?.();
|
||||
@@ -640,9 +1052,9 @@ export function RuntimeLocalSkillImportPanel({
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* Sticky top: runtime picker + status */}
|
||||
<div
|
||||
aria-disabled={importing || undefined}
|
||||
aria-disabled={busy || undefined}
|
||||
className={`shrink-0 space-y-2 border-b px-5 py-3 ${
|
||||
importing ? "pointer-events-none opacity-60" : ""
|
||||
busy ? "pointer-events-none opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
@@ -691,9 +1103,7 @@ export function RuntimeLocalSkillImportPanel({
|
||||
style={fadeStyle}
|
||||
aria-disabled={importing || undefined}
|
||||
className={`min-h-0 flex-1 overflow-y-auto px-5 py-3 ${
|
||||
importing && bulkState.phase !== "importing"
|
||||
? "pointer-events-none opacity-60"
|
||||
: ""
|
||||
importing && bulkState.phase !== "importing" ? "pointer-events-none opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
{middle}
|
||||
@@ -717,6 +1127,22 @@ export function RuntimeLocalSkillImportPanel({
|
||||
{t(($) => $.runtime_import.bulk_done_button)}
|
||||
</Button>
|
||||
</>
|
||||
) : resolvingConflicts ? (
|
||||
<>
|
||||
<div className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||
{t(($) => $.runtime_import.conflict_footer, {
|
||||
count: pendingConflicts.length,
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleApplyConflictResolutions}
|
||||
disabled={!canApplyConflictResolutions}
|
||||
>
|
||||
{t(($) => $.runtime_import.conflict_apply_button)}
|
||||
</Button>
|
||||
</>
|
||||
) : importing ? (
|
||||
<>
|
||||
<div className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -141,6 +140,7 @@ func init() {
|
||||
|
||||
// skill import
|
||||
skillImportCmd.Flags().String("url", "", "URL to import from (required)")
|
||||
skillImportCmd.Flags().String("on-conflict", "fail", "Conflict strategy when a skill with the same name exists: fail, overwrite, rename, or skip")
|
||||
skillImportCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
// skill search
|
||||
@@ -419,9 +419,14 @@ func runSkillImport(cmd *cobra.Command, _ []string) error {
|
||||
if importURL == "" {
|
||||
return fmt.Errorf("--url is required")
|
||||
}
|
||||
onConflict, _ := cmd.Flags().GetString("on-conflict")
|
||||
if !validSkillImportConflictStrategy(onConflict) {
|
||||
return fmt.Errorf("--on-conflict must be one of: fail, overwrite, rename, skip")
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"url": importURL,
|
||||
"url": importURL,
|
||||
"on_conflict": onConflict,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cli.AtLeastAPITimeout(60*time.Second))
|
||||
@@ -429,44 +434,107 @@ func runSkillImport(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
var result map[string]any
|
||||
if err := client.PostJSON(ctx, "/api/skills/import", body, &result); err != nil {
|
||||
if handleSkillImportConflict(cmd, err) {
|
||||
return nil
|
||||
if handledErr := handleSkillImportError(cmd, err); handledErr != nil {
|
||||
return handledErr
|
||||
}
|
||||
return fmt.Errorf("import skill: %w", err)
|
||||
}
|
||||
|
||||
return printSkillImportResult(cmd, result)
|
||||
}
|
||||
|
||||
func validSkillImportConflictStrategy(strategy string) bool {
|
||||
switch strategy {
|
||||
case "fail", "overwrite", "rename", "skip":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func handleSkillImportError(cmd *cobra.Command, err error) error {
|
||||
var httpErr *cli.HTTPError
|
||||
if !errors.As(err, &httpErr) || strings.TrimSpace(httpErr.Body) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if json.Unmarshal([]byte(httpErr.Body), &body) != nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := body["status"]; !ok {
|
||||
if _, hasExisting := body["existing_skill"]; !hasExisting {
|
||||
return nil
|
||||
}
|
||||
body = normalizeLegacySkillImportConflict(body)
|
||||
}
|
||||
|
||||
if err := printSkillImportResult(cmd, body); err != nil {
|
||||
return err
|
||||
}
|
||||
reason := strVal(body, "reason")
|
||||
if reason == "" {
|
||||
reason = strVal(body, "error")
|
||||
}
|
||||
if reason == "" {
|
||||
reason = "skill import conflict"
|
||||
}
|
||||
return errors.New(reason)
|
||||
}
|
||||
|
||||
func normalizeLegacySkillImportConflict(body map[string]any) map[string]any {
|
||||
reason := strVal(body, "error")
|
||||
if reason == "" {
|
||||
reason = "a skill with this name already exists"
|
||||
}
|
||||
reason += "; use --on-conflict overwrite to replace it or --on-conflict rename to import a copy"
|
||||
return map[string]any{
|
||||
"status": "conflict",
|
||||
"reason": reason,
|
||||
"existing_skill": body["existing_skill"],
|
||||
}
|
||||
}
|
||||
|
||||
func printSkillImportResult(cmd *cobra.Command, result map[string]any) error {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
return cli.PrintJSON(os.Stdout, result)
|
||||
}
|
||||
|
||||
fmt.Printf("Skill imported: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
|
||||
status := strVal(result, "status")
|
||||
if status == "" {
|
||||
fmt.Printf("Skill imported: %s (%s)\n", strVal(result, "name"), strVal(result, "id"))
|
||||
return nil
|
||||
}
|
||||
|
||||
skill := nestedMap(result, "skill")
|
||||
existing := nestedMap(result, "existing_skill")
|
||||
reason := strVal(result, "reason")
|
||||
switch status {
|
||||
case "created":
|
||||
fmt.Printf("Skill imported: %s (%s)\n", strVal(skill, "name"), strVal(skill, "id"))
|
||||
case "updated":
|
||||
fmt.Printf("Skill updated: %s (%s)\n", strVal(skill, "name"), strVal(skill, "id"))
|
||||
case "skipped":
|
||||
fmt.Printf("Skill skipped: %s (%s)\n", strVal(existing, "name"), strVal(existing, "id"))
|
||||
case "conflict":
|
||||
fmt.Printf("Skill import conflict: %s (%s)\n", strVal(existing, "name"), strVal(existing, "id"))
|
||||
case "failed":
|
||||
fmt.Printf("Skill import failed: %s\n", reason)
|
||||
default:
|
||||
fmt.Printf("Skill import %s\n", status)
|
||||
}
|
||||
if reason != "" && status != "failed" {
|
||||
fmt.Printf("Reason: %s\n", reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleSkillImportConflict(cmd *cobra.Command, err error) bool {
|
||||
var httpErr *cli.HTTPError
|
||||
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusConflict || strings.TrimSpace(httpErr.Body) == "" {
|
||||
return false
|
||||
func nestedMap(m map[string]any, key string) map[string]any {
|
||||
nested, _ := m[key].(map[string]any)
|
||||
if nested == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if json.Unmarshal([]byte(httpErr.Body), &body) != nil {
|
||||
return false
|
||||
}
|
||||
if _, ok := body["existing_skill"]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
if output == "json" {
|
||||
_ = cli.PrintJSON(os.Stdout, body)
|
||||
return true
|
||||
}
|
||||
|
||||
existing, _ := body["existing_skill"].(map[string]any)
|
||||
fmt.Printf("Skill already exists: %s (%s)\n", strVal(existing, "name"), strVal(existing, "id"))
|
||||
return true
|
||||
return nested
|
||||
}
|
||||
|
||||
func runSkillSearch(cmd *cobra.Command, args []string) error {
|
||||
|
||||
@@ -18,6 +18,7 @@ func newSkillImportTestCmd() *cobra.Command {
|
||||
cmd.Flags().String("workspace-id", "", "")
|
||||
cmd.Flags().String("profile", "", "")
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("on-conflict", "fail", "")
|
||||
cmd.Flags().String("output", "json", "")
|
||||
return cmd
|
||||
}
|
||||
@@ -43,7 +44,7 @@ func captureStdout(t *testing.T, fn func() error) (string, error) {
|
||||
return string(out), runErr
|
||||
}
|
||||
|
||||
func TestRunSkillImportJsonTreatsDuplicateAsStructuredResult(t *testing.T) {
|
||||
func TestRunSkillImportJsonTreatsDuplicateAsConflictResult(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("MULTICA_TOKEN", "test-token")
|
||||
t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123")
|
||||
@@ -58,10 +59,21 @@ func TestRunSkillImportJsonTreatsDuplicateAsStructuredResult(t *testing.T) {
|
||||
if r.Header.Get("X-Workspace-ID") != "workspace-123" {
|
||||
t.Fatalf("X-Workspace-ID = %q, want workspace-123", r.Header.Get("X-Workspace-ID"))
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if body["url"] != "https://skills.sh/acme/review-helper" {
|
||||
t.Fatalf("url = %v", body["url"])
|
||||
}
|
||||
if body["on_conflict"] != "fail" {
|
||||
t.Fatalf("on_conflict = %v, want fail", body["on_conflict"])
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": "a skill with this name already exists",
|
||||
"status": "conflict",
|
||||
"reason": "a skill with this name already exists; use --on-conflict overwrite to replace it or --on-conflict rename to import a copy",
|
||||
"existing_skill": map[string]any{
|
||||
"id": "skill-123",
|
||||
"name": "review-helper",
|
||||
@@ -78,16 +90,19 @@ func TestRunSkillImportJsonTreatsDuplicateAsStructuredResult(t *testing.T) {
|
||||
out, err := captureStdout(t, func() error {
|
||||
return runSkillImport(cmd, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runSkillImport returned error for duplicate import: %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate import to return an error")
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
||||
t.Fatalf("decode stdout JSON %q: %v", out, err)
|
||||
}
|
||||
if got["error"] != "a skill with this name already exists" {
|
||||
t.Fatalf("error = %v", got["error"])
|
||||
if got["status"] != "conflict" {
|
||||
t.Fatalf("status = %v", got["status"])
|
||||
}
|
||||
if !strings.Contains(strVal(got, "reason"), "--on-conflict overwrite") {
|
||||
t.Fatalf("reason = %v", got["reason"])
|
||||
}
|
||||
existing, ok := got["existing_skill"].(map[string]any)
|
||||
if !ok {
|
||||
@@ -98,6 +113,52 @@ func TestRunSkillImportJsonTreatsDuplicateAsStructuredResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillImportSendsOnConflictAndPrintsStructuredResult(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("MULTICA_TOKEN", "test-token")
|
||||
t.Setenv("MULTICA_WORKSPACE_ID", "workspace-123")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if body["on_conflict"] != "overwrite" {
|
||||
t.Fatalf("on_conflict = %v, want overwrite", body["on_conflict"])
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "updated",
|
||||
"skill": map[string]any{
|
||||
"id": "skill-123",
|
||||
"name": "review-helper",
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("MULTICA_SERVER_URL", srv.URL)
|
||||
|
||||
cmd := newSkillImportTestCmd()
|
||||
_ = cmd.Flags().Set("url", "https://skills.sh/acme/review-helper")
|
||||
_ = cmd.Flags().Set("on-conflict", "overwrite")
|
||||
_ = cmd.Flags().Set("output", "json")
|
||||
|
||||
out, err := captureStdout(t, func() error {
|
||||
return runSkillImport(cmd, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runSkillImport returned error: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &got); err != nil {
|
||||
t.Fatalf("decode stdout JSON %q: %v", out, err)
|
||||
}
|
||||
if got["status"] != "updated" {
|
||||
t.Fatalf("status = %v", got["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillSearchRequestsSearchEndpoint(t *testing.T) {
|
||||
var gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
@@ -11,6 +12,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
@@ -24,8 +27,35 @@ const (
|
||||
RuntimeLocalSkillCompleted RuntimeLocalSkillRequestStatus = "completed"
|
||||
RuntimeLocalSkillFailed RuntimeLocalSkillRequestStatus = "failed"
|
||||
RuntimeLocalSkillTimeout RuntimeLocalSkillRequestStatus = "timeout"
|
||||
// RuntimeLocalSkillConflict is a terminal state set when a fresh import
|
||||
// hits an existing same-name skill. It is not an error: the request carries
|
||||
// structured Conflict metadata so the caller (Desktop UI / CLI) can offer
|
||||
// overwrite / rename / skip instead of silently failing. See MUL-2800.
|
||||
RuntimeLocalSkillConflict RuntimeLocalSkillRequestStatus = "conflict"
|
||||
)
|
||||
|
||||
// LocalSkillImportAction selects how a runtime-local-skill import resolves when
|
||||
// a skill with the same name already exists in the workspace.
|
||||
type LocalSkillImportAction string
|
||||
|
||||
const (
|
||||
// LocalSkillImportActionCreate is the default: create a new skill, and
|
||||
// surface a structured `conflict` if the name is already taken.
|
||||
LocalSkillImportActionCreate LocalSkillImportAction = ""
|
||||
// LocalSkillImportActionOverwrite re-imports onto an existing skill,
|
||||
// identified by TargetSkillID. Only the skill's creator may overwrite.
|
||||
LocalSkillImportActionOverwrite LocalSkillImportAction = "overwrite"
|
||||
)
|
||||
|
||||
// LocalSkillImportConflict is the structured result attached to a request that
|
||||
// terminated in RuntimeLocalSkillConflict. CanOverwrite reflects the
|
||||
// creator-only re-import policy (canOverwriteSkillByLocalImport).
|
||||
type LocalSkillImportConflict struct {
|
||||
ExistingSkillID string `json:"existing_skill_id"`
|
||||
ExistingCreatedBy string `json:"existing_created_by,omitempty"`
|
||||
CanOverwrite bool `json:"can_overwrite"`
|
||||
}
|
||||
|
||||
const (
|
||||
// runtimeLocalSkillPendingTimeout bounds how long a request can sit in
|
||||
// pending before the server marks it timed out. The value must accommodate
|
||||
@@ -62,11 +92,28 @@ type LocalSkillListStore interface {
|
||||
Fail(ctx context.Context, id string, errMsg string) error
|
||||
}
|
||||
|
||||
// LocalSkillImportRequestInput carries the fields needed to enqueue a
|
||||
// runtime-local-skill import. SupportsConflict gates the structured-conflict
|
||||
// contract: only clients that opt in receive the `conflict` terminal status;
|
||||
// older clients keep the legacy `failed` ("a skill with this name already
|
||||
// exists") behavior so an already-installed Desktop build doesn't regress when
|
||||
// it talks to an upgraded backend. See MUL-2800.
|
||||
type LocalSkillImportRequestInput struct {
|
||||
RuntimeID string
|
||||
CreatorID string
|
||||
SkillKey string
|
||||
Name *string
|
||||
Description *string
|
||||
Action LocalSkillImportAction
|
||||
TargetSkillID string
|
||||
SupportsConflict bool
|
||||
}
|
||||
|
||||
// LocalSkillImportStore is the same contract as LocalSkillListStore but for
|
||||
// runtime-local-skill import requests. Kept as a separate interface because the
|
||||
// Create signature carries import-specific fields (skill_key, optional rename).
|
||||
type LocalSkillImportStore interface {
|
||||
Create(ctx context.Context, runtimeID, creatorID, skillKey string, name, description *string) (*RuntimeLocalSkillImportRequest, error)
|
||||
Create(ctx context.Context, input LocalSkillImportRequestInput) (*RuntimeLocalSkillImportRequest, error)
|
||||
Get(ctx context.Context, id string) (*RuntimeLocalSkillImportRequest, error)
|
||||
HasPending(ctx context.Context, runtimeID string) (bool, error)
|
||||
PopPending(ctx context.Context, runtimeID string) (*RuntimeLocalSkillImportRequest, error)
|
||||
@@ -75,6 +122,9 @@ type LocalSkillImportStore interface {
|
||||
// multiple imports per heartbeat cycle.
|
||||
PopPendingBatch(ctx context.Context, runtimeID string, limit int) ([]*RuntimeLocalSkillImportRequest, error)
|
||||
Complete(ctx context.Context, id string, skill SkillResponse) error
|
||||
// Conflict transitions a request to the terminal RuntimeLocalSkillConflict
|
||||
// state, attaching structured conflict metadata for the caller to act on.
|
||||
Conflict(ctx context.Context, id string, info LocalSkillImportConflict) error
|
||||
Fail(ctx context.Context, id string, errMsg string) error
|
||||
}
|
||||
|
||||
@@ -143,18 +193,25 @@ type RuntimeLocalSkillListRequest struct {
|
||||
}
|
||||
|
||||
type RuntimeLocalSkillImportRequest struct {
|
||||
ID string `json:"id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
SkillKey string `json:"skill_key"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Status RuntimeLocalSkillRequestStatus `json:"status"`
|
||||
Skill *SkillResponse `json:"skill,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatorID string `json:"-"`
|
||||
RunStartedAt *time.Time `json:"-"`
|
||||
ID string `json:"id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
SkillKey string `json:"skill_key"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Action LocalSkillImportAction `json:"action,omitempty"`
|
||||
TargetSkillID string `json:"target_skill_id,omitempty"`
|
||||
// SupportsConflict records whether the initiating client opted into the
|
||||
// structured-conflict contract; consulted at report time to decide between
|
||||
// the new `conflict` status and the legacy `failed` behavior.
|
||||
SupportsConflict bool `json:"supports_conflict,omitempty"`
|
||||
Status RuntimeLocalSkillRequestStatus `json:"status"`
|
||||
Skill *SkillResponse `json:"skill,omitempty"`
|
||||
Conflict *LocalSkillImportConflict `json:"conflict,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatorID string `json:"-"`
|
||||
RunStartedAt *time.Time `json:"-"`
|
||||
}
|
||||
|
||||
// InMemoryLocalSkillListStore is the single-node implementation — good enough
|
||||
@@ -277,7 +334,7 @@ func NewInMemoryLocalSkillImportStore() *InMemoryLocalSkillImportStore {
|
||||
return &InMemoryLocalSkillImportStore{requests: make(map[string]*RuntimeLocalSkillImportRequest)}
|
||||
}
|
||||
|
||||
func (s *InMemoryLocalSkillImportStore) Create(_ context.Context, runtimeID, creatorID, skillKey string, name, description *string) (*RuntimeLocalSkillImportRequest, error) {
|
||||
func (s *InMemoryLocalSkillImportStore) Create(_ context.Context, input LocalSkillImportRequestInput) (*RuntimeLocalSkillImportRequest, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -288,15 +345,18 @@ func (s *InMemoryLocalSkillImportStore) Create(_ context.Context, runtimeID, cre
|
||||
}
|
||||
|
||||
req := &RuntimeLocalSkillImportRequest{
|
||||
ID: randomID(),
|
||||
RuntimeID: runtimeID,
|
||||
SkillKey: skillKey,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Status: RuntimeLocalSkillPending,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
CreatorID: creatorID,
|
||||
ID: randomID(),
|
||||
RuntimeID: input.RuntimeID,
|
||||
SkillKey: input.SkillKey,
|
||||
Name: input.Name,
|
||||
Description: input.Description,
|
||||
Action: input.Action,
|
||||
TargetSkillID: input.TargetSkillID,
|
||||
SupportsConflict: input.SupportsConflict,
|
||||
Status: RuntimeLocalSkillPending,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
CreatorID: input.CreatorID,
|
||||
}
|
||||
s.requests[req.ID] = req
|
||||
return req, nil
|
||||
@@ -396,6 +456,19 @@ func (s *InMemoryLocalSkillImportStore) Complete(_ context.Context, id string, s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *InMemoryLocalSkillImportStore) Conflict(_ context.Context, id string, info LocalSkillImportConflict) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if req, ok := s.requests[id]; ok {
|
||||
req.Status = RuntimeLocalSkillConflict
|
||||
conflict := info
|
||||
req.Conflict = &conflict
|
||||
req.UpdatedAt = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *InMemoryLocalSkillImportStore) Fail(_ context.Context, id string, errMsg string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -412,6 +485,14 @@ type CreateRuntimeLocalSkillImportRequest struct {
|
||||
SkillKey string `json:"skill_key"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
// Action selects create (default) vs overwrite. When overwrite,
|
||||
// TargetSkillID must reference the existing same-name skill.
|
||||
Action LocalSkillImportAction `json:"action,omitempty"`
|
||||
TargetSkillID string `json:"target_skill_id,omitempty"`
|
||||
// SupportsConflict opts the client into the structured-conflict contract.
|
||||
// Omit it (older clients) to keep the legacy `failed` behavior on a
|
||||
// same-name collision. An overwrite request implies the new contract.
|
||||
SupportsConflict bool `json:"supports_conflict,omitempty"`
|
||||
}
|
||||
|
||||
type reportedRuntimeLocalSkill struct {
|
||||
@@ -435,7 +516,8 @@ func cleanOptionalString(value *string) *string {
|
||||
}
|
||||
|
||||
func runtimeLocalSkillRequestTerminal(status RuntimeLocalSkillRequestStatus) bool {
|
||||
return status == RuntimeLocalSkillCompleted || status == RuntimeLocalSkillFailed || status == RuntimeLocalSkillTimeout
|
||||
return status == RuntimeLocalSkillCompleted || status == RuntimeLocalSkillFailed ||
|
||||
status == RuntimeLocalSkillTimeout || status == RuntimeLocalSkillConflict
|
||||
}
|
||||
|
||||
func (h *Handler) requireRuntimeLocalSkillAccess(w http.ResponseWriter, r *http.Request, runtimeID string) (runtimeIDAndWorkspace, bool) {
|
||||
@@ -542,14 +624,36 @@ func (h *Handler) InitiateImportLocalSkill(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
importReq, err := h.LocalSkillImportStore.Create(
|
||||
r.Context(),
|
||||
rt.runtimeID,
|
||||
creatorID,
|
||||
strings.TrimSpace(req.SkillKey),
|
||||
cleanOptionalString(req.Name),
|
||||
cleanOptionalString(req.Description),
|
||||
)
|
||||
targetSkillID := ""
|
||||
switch req.Action {
|
||||
case LocalSkillImportActionCreate:
|
||||
// nothing extra
|
||||
case LocalSkillImportActionOverwrite:
|
||||
// Existence + creator permission are re-verified authoritatively at
|
||||
// report time (the skill may change between confirm and write); here we
|
||||
// only require a well-formed target so we never enqueue a doomed write.
|
||||
uuid, ok := parseUUIDOrBadRequest(w, strings.TrimSpace(req.TargetSkillID), "target_skill_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetSkillID = uuidToString(uuid)
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "invalid action")
|
||||
return
|
||||
}
|
||||
|
||||
importReq, err := h.LocalSkillImportStore.Create(r.Context(), LocalSkillImportRequestInput{
|
||||
RuntimeID: rt.runtimeID,
|
||||
CreatorID: creatorID,
|
||||
SkillKey: strings.TrimSpace(req.SkillKey),
|
||||
Name: cleanOptionalString(req.Name),
|
||||
Description: cleanOptionalString(req.Description),
|
||||
Action: req.Action,
|
||||
TargetSkillID: targetSkillID,
|
||||
// An overwrite request is inherently a new-client action, so it implies
|
||||
// the structured-conflict contract even if the flag is omitted.
|
||||
SupportsConflict: req.SupportsConflict || req.Action == LocalSkillImportActionOverwrite,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to enqueue local skill import: "+err.Error())
|
||||
return
|
||||
@@ -671,21 +775,11 @@ func (h *Handler) ReportLocalSkillImportResult(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
if body.Status != "completed" {
|
||||
if err := h.LocalSkillImportStore.Fail(r.Context(), requestID, body.Error); err != nil {
|
||||
slog.Error("local skill import Fail failed", "error", err, "request_id", requestID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to persist failure")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
h.failLocalSkillImport(w, r, requestID, body.Error)
|
||||
return
|
||||
}
|
||||
if body.Skill == nil {
|
||||
if err := h.LocalSkillImportStore.Fail(r.Context(), requestID, "daemon returned an empty skill bundle"); err != nil {
|
||||
slog.Error("local skill import Fail failed", "error", err, "request_id", requestID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to persist failure")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
h.failLocalSkillImport(w, r, requestID, "daemon returned an empty skill bundle")
|
||||
return
|
||||
}
|
||||
creatorUUID, err := util.ParseUUID(req.CreatorID)
|
||||
@@ -715,33 +809,103 @@ func (h *Handler) ReportLocalSkillImportResult(w http.ResponseWriter, r *http.Re
|
||||
files = append(files, f)
|
||||
}
|
||||
|
||||
config := map[string]any{
|
||||
"origin": map[string]any{
|
||||
"type": "runtime_local",
|
||||
"runtime_id": runtimeID,
|
||||
"provider": body.Skill.Provider,
|
||||
"source_path": body.Skill.SourcePath,
|
||||
},
|
||||
}
|
||||
|
||||
// Overwrite path: re-import onto an existing skill. Existence and creator
|
||||
// permission are re-verified inside overwriteSkillWithFiles, in the same tx
|
||||
// as the write, so a target deleted (or a creator change) between the user's
|
||||
// confirm and this report fails cleanly without falling back to create.
|
||||
if req.Action == LocalSkillImportActionOverwrite {
|
||||
targetUUID, perr := util.ParseUUID(req.TargetSkillID)
|
||||
if perr != nil {
|
||||
failMsg := "stored target_skill_id is invalid"
|
||||
if ferr := h.LocalSkillImportStore.Fail(r.Context(), requestID, failMsg); ferr != nil {
|
||||
slog.Error("local skill import Fail failed", "error", ferr, "request_id", requestID)
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, failMsg)
|
||||
return
|
||||
}
|
||||
resp, oerr := h.overwriteSkillWithFiles(r.Context(), skillOverwriteInput{
|
||||
WorkspaceID: rt.WorkspaceID,
|
||||
TargetSkillID: targetUUID,
|
||||
UserID: req.CreatorID,
|
||||
ExpectedName: sanitizeNullBytes(name),
|
||||
Description: description,
|
||||
Content: body.Skill.Content,
|
||||
Config: config,
|
||||
Files: files,
|
||||
})
|
||||
if oerr != nil {
|
||||
failMsg := oerr.Error()
|
||||
switch {
|
||||
case errors.Is(oerr, errSkillOverwriteNotFound):
|
||||
failMsg = "target skill no longer exists"
|
||||
case errors.Is(oerr, errSkillOverwriteForbidden):
|
||||
failMsg = "you no longer have permission to overwrite this skill"
|
||||
case errors.Is(oerr, errSkillOverwriteNameMismatch):
|
||||
failMsg = "target skill name no longer matches the imported skill"
|
||||
}
|
||||
h.failLocalSkillImport(w, r, requestID, failMsg)
|
||||
return
|
||||
}
|
||||
if err := h.LocalSkillImportStore.Complete(r.Context(), requestID, resp.SkillResponse); err != nil {
|
||||
// The overwrite already committed; unlike the create path we must
|
||||
// NOT delete the skill to "roll back" (that would destroy a
|
||||
// pre-existing skill and its agent bindings). Surface 5xx so the
|
||||
// daemon retries — the retry re-applies the same UPDATE idempotently.
|
||||
slog.Error("local skill import overwrite Complete failed",
|
||||
"error", err, "request_id", requestID, "skill_id", resp.ID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to persist import completion")
|
||||
return
|
||||
}
|
||||
h.publish(protocol.EventSkillUpdated, uuidToString(rt.WorkspaceID), "member", req.CreatorID, map[string]any{"skill": resp})
|
||||
slog.Debug("runtime local skill overwritten", "runtime_id", runtimeID, "request_id", requestID, "skill_id", resp.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create path: detect a same-name conflict before writing. For opted-in
|
||||
// clients this is a structured terminal state (not a failure) so the caller
|
||||
// can offer overwrite / rename / skip; older clients keep the legacy
|
||||
// `failed` behavior (see resolveLocalSkillConflict).
|
||||
if existing, found, lerr := h.lookupSkillByName(r.Context(), rt.WorkspaceID, sanitizeNullBytes(name)); lerr != nil {
|
||||
h.failLocalSkillImport(w, r, requestID, "failed to check for existing skill: "+lerr.Error())
|
||||
return
|
||||
} else if found {
|
||||
h.resolveLocalSkillConflict(w, r, req, existing)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.createSkillWithFiles(r.Context(), skillCreateInput{
|
||||
WorkspaceID: rt.WorkspaceID,
|
||||
CreatorID: creatorUUID,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Content: body.Skill.Content,
|
||||
Config: map[string]any{
|
||||
"origin": map[string]any{
|
||||
"type": "runtime_local",
|
||||
"runtime_id": runtimeID,
|
||||
"provider": body.Skill.Provider,
|
||||
"source_path": body.Skill.SourcePath,
|
||||
},
|
||||
},
|
||||
Files: files,
|
||||
Config: config,
|
||||
Files: files,
|
||||
})
|
||||
if err != nil {
|
||||
failMsg := err.Error()
|
||||
// A unique-violation here means another import won the race between our
|
||||
// lookup and the insert — surface it as a conflict, not a hard failure.
|
||||
if isUniqueViolation(err) {
|
||||
failMsg = "a skill with this name already exists"
|
||||
}
|
||||
if ferr := h.LocalSkillImportStore.Fail(r.Context(), requestID, failMsg); ferr != nil {
|
||||
slog.Error("local skill import Fail failed", "error", ferr, "request_id", requestID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to persist failure")
|
||||
if existing, found, lerr := h.lookupSkillByName(r.Context(), rt.WorkspaceID, sanitizeNullBytes(name)); lerr == nil && found {
|
||||
h.resolveLocalSkillConflict(w, r, req, existing)
|
||||
return
|
||||
}
|
||||
// Lost the row again (deleted between insert-fail and re-lookup):
|
||||
// fall through to the legacy unique-violation message.
|
||||
h.failLocalSkillImport(w, r, requestID, "a skill with this name already exists")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
h.failLocalSkillImport(w, r, requestID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -766,3 +930,64 @@ func (h *Handler) ReportLocalSkillImportResult(w http.ResponseWriter, r *http.Re
|
||||
slog.Debug("runtime local skill imported", "runtime_id", runtimeID, "request_id", requestID, "skill_id", resp.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// failLocalSkillImport marks the request failed and writes the standard daemon
|
||||
// response (200 ok). If the store write itself fails it returns 500 so the
|
||||
// daemon retries.
|
||||
func (h *Handler) failLocalSkillImport(w http.ResponseWriter, r *http.Request, requestID, failMsg string) {
|
||||
if err := h.LocalSkillImportStore.Fail(r.Context(), requestID, failMsg); err != nil {
|
||||
slog.Error("local skill import Fail failed", "error", err, "request_id", requestID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to persist failure")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// resolveLocalSkillConflict terminates a same-name create import. Clients that
|
||||
// opted into the structured-conflict contract (SupportsConflict) receive the
|
||||
// `conflict` status plus metadata so they can offer overwrite / rename / skip;
|
||||
// older clients keep the legacy `failed` ("a skill with this name already
|
||||
// exists") behavior so an installed Desktop build that predates the contract
|
||||
// doesn't regress when it hits an upgraded backend.
|
||||
func (h *Handler) resolveLocalSkillConflict(w http.ResponseWriter, r *http.Request, req *RuntimeLocalSkillImportRequest, existing db.Skill) {
|
||||
if req.SupportsConflict {
|
||||
h.reportLocalSkillConflict(w, r, req.ID, req.CreatorID, existing)
|
||||
return
|
||||
}
|
||||
h.failLocalSkillImport(w, r, req.ID, "a skill with this name already exists")
|
||||
}
|
||||
|
||||
// reportLocalSkillConflict records a same-name conflict as the terminal
|
||||
// RuntimeLocalSkillConflict state with structured metadata the caller uses to
|
||||
// offer overwrite / rename / skip.
|
||||
func (h *Handler) reportLocalSkillConflict(w http.ResponseWriter, r *http.Request, requestID, creatorID string, existing db.Skill) {
|
||||
info := LocalSkillImportConflict{
|
||||
ExistingSkillID: uuidToString(existing.ID),
|
||||
CanOverwrite: canOverwriteSkillByLocalImport(creatorID, existing),
|
||||
}
|
||||
if existing.CreatedBy.Valid {
|
||||
info.ExistingCreatedBy = uuidToString(existing.CreatedBy)
|
||||
}
|
||||
if err := h.LocalSkillImportStore.Conflict(r.Context(), requestID, info); err != nil {
|
||||
slog.Error("local skill import Conflict failed", "error", err, "request_id", requestID)
|
||||
writeError(w, http.StatusInternalServerError, "failed to persist conflict")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// lookupSkillByName resolves a skill by (workspace, name). found=false with a
|
||||
// nil error means there is no such skill — i.e. no conflict.
|
||||
func (h *Handler) lookupSkillByName(ctx context.Context, workspaceID pgtype.UUID, name string) (db.Skill, bool, error) {
|
||||
skill, err := h.Queries.GetSkillByWorkspaceAndName(ctx, db.GetSkillByWorkspaceAndNameParams{
|
||||
WorkspaceID: workspaceID,
|
||||
Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return db.Skill{}, false, nil
|
||||
}
|
||||
return db.Skill{}, false, err
|
||||
}
|
||||
return skill, true, nil
|
||||
}
|
||||
|
||||
434
server/internal/handler/runtime_local_skills_overwrite_test.go
Normal file
434
server/internal/handler/runtime_local_skills_overwrite_test.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// createImportTargetSkill inserts a skill (owned by ownerID) plus the given
|
||||
// path->content files directly into the DB, returning its id. Used as the
|
||||
// pre-existing skill that conflict / overwrite imports collide with.
|
||||
func createImportTargetSkill(t *testing.T, name, ownerID string, files map[string]string) string {
|
||||
t.Helper()
|
||||
|
||||
var skillID string
|
||||
if err := testPool.QueryRow(context.Background(), `
|
||||
INSERT INTO skill (workspace_id, name, description, content, config, created_by)
|
||||
VALUES ($1, $2, 'original description', '# original', '{}'::jsonb, $3)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, name, ownerID).Scan(&skillID); err != nil {
|
||||
t.Fatalf("create target skill: %v", err)
|
||||
}
|
||||
for path, content := range files {
|
||||
if _, err := testPool.Exec(context.Background(), `
|
||||
INSERT INTO skill_file (skill_id, path, content) VALUES ($1, $2, $3)
|
||||
`, skillID, path, content); err != nil {
|
||||
t.Fatalf("create skill file: %v", err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM skill WHERE id = $1`, skillID)
|
||||
})
|
||||
return skillID
|
||||
}
|
||||
|
||||
// bindAgentToSkill creates a workspace agent and binds it to skillID via
|
||||
// agent_skill, returning the agent id. Lets overwrite tests assert the binding
|
||||
// survives the re-import.
|
||||
func bindAgentToSkill(t *testing.T, skillID string) string {
|
||||
t.Helper()
|
||||
|
||||
agentName := fmt.Sprintf("overwrite-test-agent-%d", time.Now().UnixNano())
|
||||
var agentID string
|
||||
if err := testPool.QueryRow(context.Background(), `
|
||||
INSERT INTO agent (
|
||||
workspace_id, name, description, runtime_mode, runtime_config,
|
||||
runtime_id, visibility, max_concurrent_tasks, owner_id
|
||||
)
|
||||
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4)
|
||||
RETURNING id
|
||||
`, testWorkspaceID, agentName, testRuntimeID, testUserID).Scan(&agentID); err != nil {
|
||||
t.Fatalf("create agent: %v", err)
|
||||
}
|
||||
if _, err := testPool.Exec(context.Background(), `
|
||||
INSERT INTO agent_skill (agent_id, skill_id) VALUES ($1, $2)
|
||||
`, agentID, skillID); err != nil {
|
||||
t.Fatalf("bind agent skill: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID)
|
||||
})
|
||||
return agentID
|
||||
}
|
||||
|
||||
func countAgentSkillBindings(t *testing.T, skillID string) int {
|
||||
t.Helper()
|
||||
|
||||
var count int
|
||||
if err := testPool.QueryRow(context.Background(), `
|
||||
SELECT count(*) FROM agent_skill WHERE skill_id = $1
|
||||
`, skillID).Scan(&count); err != nil {
|
||||
t.Fatalf("count agent_skill: %v", err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func getSkillRow(t *testing.T, skillID string) (name, description, content, createdBy string) {
|
||||
t.Helper()
|
||||
|
||||
if err := testPool.QueryRow(context.Background(), `
|
||||
SELECT name, description, content, COALESCE(created_by::text, '')
|
||||
FROM skill WHERE id = $1
|
||||
`, skillID).Scan(&name, &description, &content, &createdBy); err != nil {
|
||||
t.Fatalf("get skill row: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// reportBundleBody builds the daemon "completed" report body for an import.
|
||||
func reportBundleBody(name, description, content string, files map[string]string) map[string]any {
|
||||
fileList := make([]map[string]any, 0, len(files))
|
||||
for p, c := range files {
|
||||
fileList = append(fileList, map[string]any{"path": p, "content": c})
|
||||
}
|
||||
return map[string]any{
|
||||
"status": "completed",
|
||||
"skill": map[string]any{
|
||||
"name": name,
|
||||
"description": description,
|
||||
"content": content,
|
||||
"source_path": "~/.claude/skills/review-helper",
|
||||
"provider": "claude",
|
||||
"files": fileList,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func initiateLocalSkillImport(t *testing.T, runtimeID string, body map[string]any) string {
|
||||
t.Helper()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := withURLParams(
|
||||
newRequestAsUser(testUserID, http.MethodPost, "/api/runtimes/"+runtimeID+"/local-skills/import", body),
|
||||
"runtimeId", runtimeID,
|
||||
)
|
||||
testHandler.InitiateImportLocalSkill(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("InitiateImportLocalSkill: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var importReq RuntimeLocalSkillImportRequest
|
||||
if err := json.NewDecoder(w.Body).Decode(&importReq); err != nil {
|
||||
t.Fatalf("decode import request: %v", err)
|
||||
}
|
||||
return importReq.ID
|
||||
}
|
||||
|
||||
func reportLocalSkillImport(t *testing.T, runtimeID, requestID string, body map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := withURLParams(
|
||||
newDaemonTokenRequest(http.MethodPost, "/api/daemon/runtimes/"+runtimeID+"/local-skills/import/"+requestID+"/result", body, testWorkspaceID, "overwrite-test-daemon"),
|
||||
"runtimeId", runtimeID,
|
||||
"requestId", requestID,
|
||||
)
|
||||
testHandler.ReportLocalSkillImportResult(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ReportLocalSkillImportResult: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func pollLocalSkillImport(t *testing.T, runtimeID, requestID string) RuntimeLocalSkillImportRequest {
|
||||
t.Helper()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := withURLParams(
|
||||
newRequestAsUser(testUserID, http.MethodGet, "/api/runtimes/"+runtimeID+"/local-skills/import/"+requestID, nil),
|
||||
"runtimeId", runtimeID,
|
||||
"requestId", requestID,
|
||||
)
|
||||
testHandler.GetLocalSkillImportRequest(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GetLocalSkillImportRequest: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var got RuntimeLocalSkillImportRequest
|
||||
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode poll response: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// runLocalSkillImport drives initiate -> report -> poll and returns the
|
||||
// terminal request.
|
||||
func runLocalSkillImport(t *testing.T, runtimeID string, initBody, reportBody map[string]any) RuntimeLocalSkillImportRequest {
|
||||
t.Helper()
|
||||
requestID := initiateLocalSkillImport(t, runtimeID, initBody)
|
||||
reportLocalSkillImport(t, runtimeID, requestID, reportBody)
|
||||
return pollLocalSkillImport(t, runtimeID, requestID)
|
||||
}
|
||||
|
||||
func TestRuntimeLocalSkillImport_ConflictCreatorCanOverwrite(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
||||
name := fmt.Sprintf("conflict-creator-%d", time.Now().UnixNano())
|
||||
existingID := createImportTargetSkill(t, name, testUserID, nil)
|
||||
|
||||
got := runLocalSkillImport(t, runtimeID,
|
||||
map[string]any{"skill_key": "review-helper", "supports_conflict": true},
|
||||
reportBundleBody(name, "incoming description", "# incoming", map[string]string{"a.md": "A"}),
|
||||
)
|
||||
|
||||
if got.Status != RuntimeLocalSkillConflict {
|
||||
t.Fatalf("status = %s, want conflict", got.Status)
|
||||
}
|
||||
if got.Conflict == nil {
|
||||
t.Fatal("expected conflict metadata")
|
||||
}
|
||||
if got.Conflict.ExistingSkillID != existingID {
|
||||
t.Fatalf("existing_skill_id = %q, want %q", got.Conflict.ExistingSkillID, existingID)
|
||||
}
|
||||
if got.Conflict.ExistingCreatedBy != testUserID {
|
||||
t.Fatalf("existing_created_by = %q, want %q", got.Conflict.ExistingCreatedBy, testUserID)
|
||||
}
|
||||
if !got.Conflict.CanOverwrite {
|
||||
t.Fatal("creator should be allowed to overwrite")
|
||||
}
|
||||
// A conflict must neither create a second skill nor mutate the original.
|
||||
if n := countSkillsByName(t, name); n != 1 {
|
||||
t.Fatalf("expected exactly 1 skill named %q, got %d", name, n)
|
||||
}
|
||||
if _, desc, _, _ := getSkillRow(t, existingID); desc != "original description" {
|
||||
t.Fatalf("conflict must not modify the existing skill, description = %q", desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeLocalSkillImport_ConflictNonCreatorCannotOverwrite(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
||||
otherUserID := createRuntimeLocalSkillTestMember(t, "member")
|
||||
name := fmt.Sprintf("conflict-noncreator-%d", time.Now().UnixNano())
|
||||
existingID := createImportTargetSkill(t, name, otherUserID, nil)
|
||||
|
||||
got := runLocalSkillImport(t, runtimeID,
|
||||
map[string]any{"skill_key": "review-helper", "supports_conflict": true},
|
||||
reportBundleBody(name, "incoming description", "# incoming", nil),
|
||||
)
|
||||
|
||||
if got.Status != RuntimeLocalSkillConflict {
|
||||
t.Fatalf("status = %s, want conflict", got.Status)
|
||||
}
|
||||
if got.Conflict == nil {
|
||||
t.Fatal("expected conflict metadata")
|
||||
}
|
||||
if got.Conflict.ExistingSkillID != existingID {
|
||||
t.Fatalf("existing_skill_id = %q, want %q", got.Conflict.ExistingSkillID, existingID)
|
||||
}
|
||||
if got.Conflict.ExistingCreatedBy != otherUserID {
|
||||
t.Fatalf("existing_created_by = %q, want %q", got.Conflict.ExistingCreatedBy, otherUserID)
|
||||
}
|
||||
if got.Conflict.CanOverwrite {
|
||||
t.Fatal("a non-creator must not be allowed to overwrite")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeLocalSkillImport_OverwritePreservesIdentityAndBindings(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
||||
name := fmt.Sprintf("overwrite-keep-%d", time.Now().UnixNano())
|
||||
existingID := createImportTargetSkill(t, name, testUserID, map[string]string{
|
||||
"keep.md": "old keep",
|
||||
"prune.md": "should be removed",
|
||||
})
|
||||
bindAgentToSkill(t, existingID)
|
||||
|
||||
got := runLocalSkillImport(t, runtimeID,
|
||||
map[string]any{"skill_key": "review-helper", "action": "overwrite", "target_skill_id": existingID},
|
||||
reportBundleBody(name, "overwritten description", "# overwritten", map[string]string{"keep.md": "new keep"}),
|
||||
)
|
||||
|
||||
if got.Status != RuntimeLocalSkillCompleted {
|
||||
t.Fatalf("status = %s, want completed (error=%q)", got.Status, got.Error)
|
||||
}
|
||||
if got.Skill == nil {
|
||||
t.Fatal("expected overwritten skill in response")
|
||||
}
|
||||
// Same row: UUID and creator preserved.
|
||||
if got.Skill.ID != existingID {
|
||||
t.Fatalf("overwrite must preserve UUID: got %q, want %q", got.Skill.ID, existingID)
|
||||
}
|
||||
if got.Skill.CreatedBy == nil || *got.Skill.CreatedBy != testUserID {
|
||||
t.Fatalf("created_by not preserved: %v", got.Skill.CreatedBy)
|
||||
}
|
||||
if got.Skill.Description != "overwritten description" {
|
||||
t.Fatalf("description not replaced: %q", got.Skill.Description)
|
||||
}
|
||||
// Files fully replaced: prune.md (absent from the new bundle) is gone.
|
||||
if n := countSkillFiles(t, existingID); n != 1 {
|
||||
t.Fatalf("expected 1 file after overwrite, got %d", n)
|
||||
}
|
||||
// Agent binding preserved — the agent must NOT need to re-add the skill.
|
||||
if n := countAgentSkillBindings(t, existingID); n != 1 {
|
||||
t.Fatalf("expected agent binding to survive overwrite, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeLocalSkillImport_OverwriteNonCreatorFails(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
||||
otherUserID := createRuntimeLocalSkillTestMember(t, "member")
|
||||
name := fmt.Sprintf("overwrite-forbidden-%d", time.Now().UnixNano())
|
||||
existingID := createImportTargetSkill(t, name, otherUserID, nil)
|
||||
|
||||
got := runLocalSkillImport(t, runtimeID,
|
||||
map[string]any{"skill_key": "review-helper", "action": "overwrite", "target_skill_id": existingID},
|
||||
reportBundleBody(name, "incoming description", "# incoming", nil),
|
||||
)
|
||||
|
||||
if got.Status != RuntimeLocalSkillFailed {
|
||||
t.Fatalf("status = %s, want failed", got.Status)
|
||||
}
|
||||
// Original skill (owned by someone else) must be untouched.
|
||||
if _, desc, _, _ := getSkillRow(t, existingID); desc != "original description" {
|
||||
t.Fatalf("forbidden overwrite must not mutate the skill, description = %q", desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeLocalSkillImport_OverwriteTargetDeletedFails(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
||||
name := fmt.Sprintf("overwrite-deleted-%d", time.Now().UnixNano())
|
||||
deletedID := createImportTargetSkill(t, name, testUserID, nil)
|
||||
// Simulate the target being deleted between the user's confirm and the
|
||||
// daemon report.
|
||||
if _, err := testPool.Exec(context.Background(), `DELETE FROM skill WHERE id = $1`, deletedID); err != nil {
|
||||
t.Fatalf("delete target skill: %v", err)
|
||||
}
|
||||
|
||||
got := runLocalSkillImport(t, runtimeID,
|
||||
map[string]any{"skill_key": "review-helper", "action": "overwrite", "target_skill_id": deletedID},
|
||||
reportBundleBody(name, "incoming description", "# incoming", map[string]string{"a.md": "A"}),
|
||||
)
|
||||
|
||||
if got.Status != RuntimeLocalSkillFailed {
|
||||
t.Fatalf("status = %s, want failed", got.Status)
|
||||
}
|
||||
// Must NOT fall back to creating a new skill by name.
|
||||
if n := countSkillsByName(t, name); n != 0 {
|
||||
t.Fatalf("deleted-target overwrite must not create a skill, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeLocalSkillImport_OverwriteRetryIsIdempotent(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
||||
name := fmt.Sprintf("overwrite-idempotent-%d", time.Now().UnixNano())
|
||||
existingID := createImportTargetSkill(t, name, testUserID, map[string]string{"old.md": "old"})
|
||||
|
||||
requestID := initiateLocalSkillImport(t, runtimeID, map[string]any{
|
||||
"skill_key": "review-helper",
|
||||
"action": "overwrite",
|
||||
"target_skill_id": existingID,
|
||||
})
|
||||
|
||||
// First report wins and overwrites the skill.
|
||||
reportLocalSkillImport(t, runtimeID, requestID,
|
||||
reportBundleBody(name, "first overwrite", "# first", map[string]string{"first.md": "1"}))
|
||||
|
||||
// A retry of the SAME request id with a different bundle must be ignored
|
||||
// (the request is already terminal) — no second write.
|
||||
reportLocalSkillImport(t, runtimeID, requestID,
|
||||
reportBundleBody(name, "second overwrite", "# second", map[string]string{"second.md": "2", "extra.md": "3"}))
|
||||
|
||||
got := pollLocalSkillImport(t, runtimeID, requestID)
|
||||
if got.Status != RuntimeLocalSkillCompleted {
|
||||
t.Fatalf("status = %s, want completed", got.Status)
|
||||
}
|
||||
if _, desc, _, _ := getSkillRow(t, existingID); desc != "first overwrite" {
|
||||
t.Fatalf("retry must not re-apply, description = %q", desc)
|
||||
}
|
||||
if n := countSkillFiles(t, existingID); n != 1 {
|
||||
t.Fatalf("retry must not re-write files, got %d files", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeLocalSkillImport_LegacyClientGetsFailedOnConflict verifies the
|
||||
// installed-app compatibility gate: a client that does NOT opt into the
|
||||
// structured-conflict contract keeps the legacy `failed` + "already exists"
|
||||
// behavior on a same-name collision, instead of the new `conflict` status its
|
||||
// older poll loop wouldn't understand.
|
||||
func TestRuntimeLocalSkillImport_LegacyClientGetsFailedOnConflict(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
||||
name := fmt.Sprintf("legacy-conflict-%d", time.Now().UnixNano())
|
||||
createImportTargetSkill(t, name, testUserID, nil)
|
||||
|
||||
got := runLocalSkillImport(t, runtimeID,
|
||||
// No supports_conflict (and no action) — an old client.
|
||||
map[string]any{"skill_key": "review-helper"},
|
||||
reportBundleBody(name, "incoming description", "# incoming", nil),
|
||||
)
|
||||
|
||||
if got.Status != RuntimeLocalSkillFailed {
|
||||
t.Fatalf("status = %s, want failed (legacy contract)", got.Status)
|
||||
}
|
||||
if got.Conflict != nil {
|
||||
t.Fatalf("legacy client must not receive structured conflict metadata: %+v", got.Conflict)
|
||||
}
|
||||
if got.Error != "a skill with this name already exists" {
|
||||
t.Fatalf("error = %q, want legacy already-exists message", got.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeLocalSkillImport_OverwriteNameMismatchFails verifies the guard
|
||||
// against a stale / wrong target_skill_id: if the target's name no longer
|
||||
// matches the imported skill, the overwrite fails instead of writing one
|
||||
// skill's content onto another.
|
||||
func TestRuntimeLocalSkillImport_OverwriteNameMismatchFails(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
||||
targetName := fmt.Sprintf("overwrite-target-%d", time.Now().UnixNano())
|
||||
otherName := fmt.Sprintf("overwrite-other-%d", time.Now().UnixNano())
|
||||
targetID := createImportTargetSkill(t, targetName, testUserID, nil)
|
||||
|
||||
// Overwrite targets targetID but the imported bundle is named otherName.
|
||||
got := runLocalSkillImport(t, runtimeID,
|
||||
map[string]any{"skill_key": "review-helper", "action": "overwrite", "target_skill_id": targetID},
|
||||
reportBundleBody(otherName, "incoming description", "# incoming", map[string]string{"a.md": "A"}),
|
||||
)
|
||||
|
||||
if got.Status != RuntimeLocalSkillFailed {
|
||||
t.Fatalf("status = %s, want failed (name mismatch)", got.Status)
|
||||
}
|
||||
if _, desc, _, _ := getSkillRow(t, targetID); desc != "original description" {
|
||||
t.Fatalf("name-mismatch overwrite must not mutate the target, description = %q", desc)
|
||||
}
|
||||
}
|
||||
@@ -262,18 +262,21 @@ func NewRedisLocalSkillImportStore(rdb *redis.Client) *RedisLocalSkillImportStor
|
||||
return &RedisLocalSkillImportStore{rdb: rdb}
|
||||
}
|
||||
|
||||
func (s *RedisLocalSkillImportStore) Create(ctx context.Context, runtimeID, creatorID, skillKey string, name, description *string) (*RuntimeLocalSkillImportRequest, error) {
|
||||
func (s *RedisLocalSkillImportStore) Create(ctx context.Context, input LocalSkillImportRequestInput) (*RuntimeLocalSkillImportRequest, error) {
|
||||
now := time.Now()
|
||||
req := &RuntimeLocalSkillImportRequest{
|
||||
ID: randomID(),
|
||||
RuntimeID: runtimeID,
|
||||
SkillKey: skillKey,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Status: RuntimeLocalSkillPending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
CreatorID: creatorID,
|
||||
ID: randomID(),
|
||||
RuntimeID: input.RuntimeID,
|
||||
SkillKey: input.SkillKey,
|
||||
Name: input.Name,
|
||||
Description: input.Description,
|
||||
Action: input.Action,
|
||||
TargetSkillID: input.TargetSkillID,
|
||||
SupportsConflict: input.SupportsConflict,
|
||||
Status: RuntimeLocalSkillPending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
CreatorID: input.CreatorID,
|
||||
}
|
||||
data, err := s.marshalImport(req)
|
||||
if err != nil {
|
||||
@@ -282,11 +285,11 @@ func (s *RedisLocalSkillImportStore) Create(ctx context.Context, runtimeID, crea
|
||||
|
||||
pipe := s.rdb.TxPipeline()
|
||||
pipe.Set(ctx, localSkillImportKey(req.ID), data, runtimeLocalSkillStoreRetention)
|
||||
pipe.ZAdd(ctx, localSkillImportPendingKey(runtimeID), redis.Z{
|
||||
pipe.ZAdd(ctx, localSkillImportPendingKey(input.RuntimeID), redis.Z{
|
||||
Score: float64(now.UnixNano()),
|
||||
Member: req.ID,
|
||||
})
|
||||
pipe.Expire(ctx, localSkillImportPendingKey(runtimeID), runtimeLocalSkillStoreRetention*2)
|
||||
pipe.Expire(ctx, localSkillImportPendingKey(input.RuntimeID), runtimeLocalSkillStoreRetention*2)
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return nil, fmt.Errorf("persist import request: %w", err)
|
||||
}
|
||||
@@ -494,6 +497,21 @@ func (s *RedisLocalSkillImportStore) Complete(ctx context.Context, id string, sk
|
||||
return s.persistImportRequest(ctx, req)
|
||||
}
|
||||
|
||||
func (s *RedisLocalSkillImportStore) Conflict(ctx context.Context, id string, info LocalSkillImportConflict) error {
|
||||
req, err := s.loadImportRequest(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
req.Status = RuntimeLocalSkillConflict
|
||||
conflict := info
|
||||
req.Conflict = &conflict
|
||||
req.UpdatedAt = time.Now()
|
||||
return s.persistImportRequest(ctx, req)
|
||||
}
|
||||
|
||||
func (s *RedisLocalSkillImportStore) Fail(ctx context.Context, id string, errMsg string) error {
|
||||
req, err := s.loadImportRequest(ctx, id)
|
||||
if err != nil {
|
||||
|
||||
@@ -225,7 +225,15 @@ func TestRedisLocalSkillImportStore_PreservesCreatorID(t *testing.T) {
|
||||
|
||||
name := "Review Helper"
|
||||
desc := "Desc"
|
||||
req, err := store.Create(ctx, "runtime-1", "user-42", "review-helper", &name, &desc)
|
||||
req, err := store.Create(ctx, LocalSkillImportRequestInput{
|
||||
RuntimeID: "runtime-1",
|
||||
CreatorID: "user-42",
|
||||
SkillKey: "review-helper",
|
||||
Name: &name,
|
||||
Description: &desc,
|
||||
Action: LocalSkillImportActionOverwrite,
|
||||
TargetSkillID: "target-skill-99",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
@@ -249,6 +257,47 @@ func TestRedisLocalSkillImportStore_PreservesCreatorID(t *testing.T) {
|
||||
if got.Description == nil || *got.Description != desc {
|
||||
t.Fatalf("description lost: %v", got.Description)
|
||||
}
|
||||
// The overwrite intent must survive the round trip — it is consumed at
|
||||
// report time, not delivered to the daemon.
|
||||
if got.Action != LocalSkillImportActionOverwrite {
|
||||
t.Fatalf("action lost round trip: %q", got.Action)
|
||||
}
|
||||
if got.TargetSkillID != "target-skill-99" {
|
||||
t.Fatalf("target_skill_id lost round trip: %q", got.TargetSkillID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisLocalSkillImportStore_PreservesConflict(t *testing.T) {
|
||||
rdb := newRedisTestClient(t)
|
||||
ctx := context.Background()
|
||||
store := NewRedisLocalSkillImportStore(rdb)
|
||||
|
||||
req, err := store.Create(ctx, LocalSkillImportRequestInput{
|
||||
RuntimeID: "runtime-1",
|
||||
CreatorID: "user-1",
|
||||
SkillKey: "review-helper",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
info := LocalSkillImportConflict{ExistingSkillID: "skill-7", ExistingCreatedBy: "user-2", CanOverwrite: false}
|
||||
if err := store.Conflict(ctx, req.ID, info); err != nil {
|
||||
t.Fatalf("conflict: %v", err)
|
||||
}
|
||||
|
||||
got, err := store.Get(ctx, req.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Status != RuntimeLocalSkillConflict {
|
||||
t.Fatalf("status = %s, want conflict", got.Status)
|
||||
}
|
||||
if got.Conflict == nil {
|
||||
t.Fatalf("conflict metadata lost round trip")
|
||||
}
|
||||
if got.Conflict.ExistingSkillID != "skill-7" || got.Conflict.ExistingCreatedBy != "user-2" || got.Conflict.CanOverwrite {
|
||||
t.Fatalf("conflict metadata corrupted: %+v", got.Conflict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisLocalSkillImportStore_PopPendingAcrossInstances(t *testing.T) {
|
||||
@@ -258,7 +307,11 @@ func TestRedisLocalSkillImportStore_PopPendingAcrossInstances(t *testing.T) {
|
||||
nodeA := NewRedisLocalSkillImportStore(rdb)
|
||||
nodeB := NewRedisLocalSkillImportStore(rdb)
|
||||
|
||||
req, err := nodeA.Create(ctx, "runtime-import", "user-1", "review-helper", nil, nil)
|
||||
req, err := nodeA.Create(ctx, LocalSkillImportRequestInput{
|
||||
RuntimeID: "runtime-import",
|
||||
CreatorID: "user-1",
|
||||
SkillKey: "review-helper",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
@@ -365,7 +418,11 @@ func TestRedisLocalSkillImportStore_PopPendingBatch(t *testing.T) {
|
||||
// Create 5 pending imports.
|
||||
ids := make([]string, 5)
|
||||
for i := range ids {
|
||||
req, err := store.Create(ctx, "runtime-batch", "user-1", fmt.Sprintf("skill-%d", i), nil, nil)
|
||||
req, err := store.Create(ctx, LocalSkillImportRequestInput{
|
||||
RuntimeID: "runtime-batch",
|
||||
CreatorID: "user-1",
|
||||
SkillKey: fmt.Sprintf("skill-%d", i),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create %d: %v", i, err)
|
||||
}
|
||||
|
||||
@@ -187,7 +187,11 @@ func TestInMemoryLocalSkillListStore_TimesOutRunningRequests(t *testing.T) {
|
||||
func TestInMemoryLocalSkillImportStore_TimesOutRunningRequests(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewInMemoryLocalSkillImportStore()
|
||||
req, err := store.Create(ctx, "runtime-xyz", "user-1", "review-helper", nil, nil)
|
||||
req, err := store.Create(ctx, LocalSkillImportRequestInput{
|
||||
RuntimeID: "runtime-xyz",
|
||||
CreatorID: "user-1",
|
||||
SkillKey: "review-helper",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
@@ -237,7 +241,11 @@ func TestGetLocalSkillImportRequest_RequiresRuntimeOwner(t *testing.T) {
|
||||
|
||||
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
||||
adminUserID := createRuntimeLocalSkillTestMember(t, "admin")
|
||||
importReq, err := testHandler.LocalSkillImportStore.Create(context.Background(), runtimeID, testUserID, "review-helper", nil, nil)
|
||||
importReq, err := testHandler.LocalSkillImportStore.Create(context.Background(), LocalSkillImportRequestInput{
|
||||
RuntimeID: runtimeID,
|
||||
CreatorID: testUserID,
|
||||
SkillKey: "review-helper",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create import request: %v", err)
|
||||
}
|
||||
@@ -477,14 +485,13 @@ func TestReportLocalSkillImportResult_IgnoresTimedOutRequests(t *testing.T) {
|
||||
|
||||
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
||||
ctx := context.Background()
|
||||
importReq, err := testHandler.LocalSkillImportStore.Create(
|
||||
ctx,
|
||||
runtimeID,
|
||||
testUserID,
|
||||
"review-helper",
|
||||
cleanOptionalString(ptr("Timed Out Import")),
|
||||
cleanOptionalString(ptr("Should not be created")),
|
||||
)
|
||||
importReq, err := testHandler.LocalSkillImportStore.Create(ctx, LocalSkillImportRequestInput{
|
||||
RuntimeID: runtimeID,
|
||||
CreatorID: testUserID,
|
||||
SkillKey: "review-helper",
|
||||
Name: cleanOptionalString(ptr("Timed Out Import")),
|
||||
Description: cleanOptionalString(ptr("Should not be created")),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create import request: %v", err)
|
||||
}
|
||||
@@ -534,7 +541,11 @@ func TestReportLocalSkillImportResult_RejectsCrossWorkspaceDaemonToken(t *testin
|
||||
}
|
||||
|
||||
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
||||
importReq, err := testHandler.LocalSkillImportStore.Create(context.Background(), runtimeID, testUserID, "review-helper", nil, nil)
|
||||
importReq, err := testHandler.LocalSkillImportStore.Create(context.Background(), LocalSkillImportRequestInput{
|
||||
RuntimeID: runtimeID,
|
||||
CreatorID: testUserID,
|
||||
SkillKey: "review-helper",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create import request: %v", err)
|
||||
}
|
||||
|
||||
@@ -101,9 +101,18 @@ type SkillWithFilesResponse struct {
|
||||
Files []SkillFileResponse `json:"files"`
|
||||
}
|
||||
|
||||
type SkillImportResult struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Skill *SkillWithFilesResponse `json:"skill,omitempty"`
|
||||
ExistingSkill *ExistingSkillIdentity `json:"existing_skill,omitempty"`
|
||||
}
|
||||
|
||||
type ExistingSkillIdentity struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
CanOverwrite bool `json:"can_overwrite,omitempty"`
|
||||
}
|
||||
|
||||
func writeSkillImportDuplicateConflict(w http.ResponseWriter, existing ExistingSkillIdentity) {
|
||||
@@ -138,7 +147,19 @@ func (h *Handler) existingSkillIdentityByName(ctx context.Context, workspaceID p
|
||||
}
|
||||
return ExistingSkillIdentity{}, false, err
|
||||
}
|
||||
return ExistingSkillIdentity{ID: uuidToString(skill.ID), Name: skill.Name}, true, nil
|
||||
return existingSkillIdentity(skill, ""), true, nil
|
||||
}
|
||||
|
||||
func existingSkillIdentity(skill db.Skill, userID string) ExistingSkillIdentity {
|
||||
identity := ExistingSkillIdentity{
|
||||
ID: uuidToString(skill.ID),
|
||||
Name: skill.Name,
|
||||
CanOverwrite: canOverwriteSkillByLocalImport(userID, skill),
|
||||
}
|
||||
if skill.CreatedBy.Valid {
|
||||
identity.CreatedBy = uuidToString(skill.CreatedBy)
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
// decodeSkillConfig decodes a JSONB skill.config blob, defaulting to {} when
|
||||
@@ -390,6 +411,15 @@ func (h *Handler) canManageSkill(w http.ResponseWriter, r *http.Request, skill d
|
||||
return true
|
||||
}
|
||||
|
||||
// canOverwriteSkillByLocalImport reports whether userID may overwrite skill via
|
||||
// a runtime-local-skill re-import. This is intentionally NARROWER than
|
||||
// canManageSkill: only the original creator may overwrite by re-importing.
|
||||
// Workspace owners/admins who want to change a skill they did not create must
|
||||
// edit it in-app instead. See MUL-2701 / MUL-2800.
|
||||
func canOverwriteSkillByLocalImport(userID string, skill db.Skill) bool {
|
||||
return skill.CreatedBy.Valid && uuidToString(skill.CreatedBy) == userID
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateSkill(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
skill, ok := h.loadSkillForUser(w, r, id)
|
||||
@@ -521,7 +551,25 @@ func (h *Handler) DeleteSkill(w http.ResponseWriter, r *http.Request) {
|
||||
// --- Skill import ---
|
||||
|
||||
type ImportSkillRequest struct {
|
||||
URL string `json:"url"`
|
||||
URL string `json:"url"`
|
||||
OnConflict string `json:"on_conflict,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
importOnConflictFail = "fail"
|
||||
importOnConflictOverwrite = "overwrite"
|
||||
importOnConflictRename = "rename"
|
||||
importOnConflictSkip = "skip"
|
||||
)
|
||||
|
||||
const maxImportRenameAttempts = 50
|
||||
|
||||
func validImportOnConflict(strategy string) bool {
|
||||
switch strategy {
|
||||
case "", importOnConflictFail, importOnConflictOverwrite, importOnConflictRename, importOnConflictSkip:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Per-import bundle limits. These mirror the local-runtime importer so that
|
||||
@@ -1719,6 +1767,116 @@ func skillMdNotFoundError(owner, repo, skillName string) error {
|
||||
return fmt.Errorf("SKILL.md not found in repository %s/%s for skill %s", owner, repo, skillName)
|
||||
}
|
||||
|
||||
func skillImportConflictReason() string {
|
||||
return "a skill with this name already exists; use --on-conflict overwrite to replace it or --on-conflict rename to import a copy"
|
||||
}
|
||||
|
||||
func (h *Handler) createImportedSkillWithName(ctx context.Context, workspaceID, creatorID pgtype.UUID, name string, imported *importedSkill, config map[string]any, files []CreateSkillFileRequest) (SkillWithFilesResponse, error) {
|
||||
return h.createSkillWithFiles(ctx, skillCreateInput{
|
||||
WorkspaceID: workspaceID,
|
||||
CreatorID: creatorID,
|
||||
Name: name,
|
||||
Description: imported.description,
|
||||
Content: imported.content,
|
||||
Config: config,
|
||||
Files: files,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) createRenamedImportedSkill(ctx context.Context, workspaceID, creatorID pgtype.UUID, baseName string, imported *importedSkill, config map[string]any, files []CreateSkillFileRequest) (SkillWithFilesResponse, error) {
|
||||
for suffix := 2; suffix < maxImportRenameAttempts+2; suffix++ {
|
||||
candidate := fmt.Sprintf("%s-%d", baseName, suffix)
|
||||
resp, err := h.createImportedSkillWithName(ctx, workspaceID, creatorID, candidate, imported, config, files)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
if !isUniqueViolation(err) {
|
||||
return SkillWithFilesResponse{}, err
|
||||
}
|
||||
}
|
||||
return SkillWithFilesResponse{}, fmt.Errorf("failed to find an available renamed skill name after %d attempts", maxImportRenameAttempts)
|
||||
}
|
||||
|
||||
func skillImportOverwriteFailure(err error) (int, string) {
|
||||
switch {
|
||||
case errors.Is(err, errSkillOverwriteNotFound):
|
||||
return http.StatusConflict, "target skill no longer exists"
|
||||
case errors.Is(err, errSkillOverwriteForbidden):
|
||||
return http.StatusForbidden, "only the skill creator can overwrite this skill"
|
||||
case errors.Is(err, errSkillOverwriteNameMismatch):
|
||||
return http.StatusConflict, "target skill name no longer matches the imported skill"
|
||||
default:
|
||||
return http.StatusInternalServerError, "failed to overwrite skill: " + err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) resolveImportSkillConflict(w http.ResponseWriter, r *http.Request, strategy string, workspaceID string, workspaceUUID, creatorUUID pgtype.UUID, creatorID string, name string, imported *importedSkill, config map[string]any, files []CreateSkillFileRequest, existing db.Skill) {
|
||||
existingInfo := existingSkillIdentity(existing, creatorID)
|
||||
switch strategy {
|
||||
case importOnConflictSkip:
|
||||
writeJSON(w, http.StatusOK, SkillImportResult{
|
||||
Status: "skipped",
|
||||
Reason: "a skill with this name already exists",
|
||||
ExistingSkill: &existingInfo,
|
||||
})
|
||||
case importOnConflictOverwrite:
|
||||
if !canOverwriteSkillByLocalImport(creatorID, existing) {
|
||||
writeJSON(w, http.StatusForbidden, SkillImportResult{
|
||||
Status: "failed",
|
||||
Reason: "only the skill creator can overwrite this skill",
|
||||
ExistingSkill: &existingInfo,
|
||||
})
|
||||
return
|
||||
}
|
||||
resp, err := h.overwriteSkillWithFiles(r.Context(), skillOverwriteInput{
|
||||
WorkspaceID: workspaceUUID,
|
||||
TargetSkillID: existing.ID,
|
||||
UserID: creatorID,
|
||||
ExpectedName: name,
|
||||
Description: imported.description,
|
||||
Content: imported.content,
|
||||
Config: config,
|
||||
Files: files,
|
||||
})
|
||||
if err != nil {
|
||||
status, reason := skillImportOverwriteFailure(err)
|
||||
writeJSON(w, status, SkillImportResult{
|
||||
Status: "failed",
|
||||
Reason: reason,
|
||||
ExistingSkill: &existingInfo,
|
||||
})
|
||||
return
|
||||
}
|
||||
actorType, actorID := h.resolveActor(r, creatorID, workspaceID)
|
||||
h.publish(protocol.EventSkillUpdated, workspaceID, actorType, actorID, map[string]any{"skill": resp})
|
||||
writeJSON(w, http.StatusOK, SkillImportResult{Status: "updated", Skill: &resp})
|
||||
case importOnConflictRename:
|
||||
resp, err := h.createRenamedImportedSkill(r.Context(), workspaceUUID, creatorUUID, name, imported, config, files)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, SkillImportResult{
|
||||
Status: "failed",
|
||||
Reason: "failed to create renamed skill: " + err.Error(),
|
||||
ExistingSkill: &existingInfo,
|
||||
})
|
||||
return
|
||||
}
|
||||
actorType, actorID := h.resolveActor(r, creatorID, workspaceID)
|
||||
h.publish(protocol.EventSkillCreated, workspaceID, actorType, actorID, map[string]any{"skill": resp})
|
||||
writeJSON(w, http.StatusCreated, SkillImportResult{
|
||||
Status: "created",
|
||||
Reason: "renamed to avoid an existing skill",
|
||||
Skill: &resp,
|
||||
ExistingSkill: &existingInfo,
|
||||
})
|
||||
default:
|
||||
writeJSON(w, http.StatusConflict, SkillImportResult{
|
||||
Status: "conflict",
|
||||
Reason: skillImportConflictReason(),
|
||||
ExistingSkill: &existingInfo,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Import handler ---
|
||||
|
||||
func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1739,6 +1897,15 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if !validImportOnConflict(req.OnConflict) {
|
||||
writeError(w, http.StatusBadRequest, "on_conflict must be one of: fail, overwrite, rename, skip")
|
||||
return
|
||||
}
|
||||
structuredResult := req.OnConflict != ""
|
||||
strategy := req.OnConflict
|
||||
if strategy == "" {
|
||||
strategy = importOnConflictFail
|
||||
}
|
||||
|
||||
source, normalized, err := detectImportSource(req.URL)
|
||||
if err != nil {
|
||||
@@ -1779,19 +1946,31 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
|
||||
if imported.origin != nil {
|
||||
config["origin"] = imported.origin
|
||||
}
|
||||
name := sanitizeNullBytes(imported.name)
|
||||
|
||||
resp, err := h.createSkillWithFiles(r.Context(), skillCreateInput{
|
||||
WorkspaceID: workspaceUUID,
|
||||
CreatorID: creatorUUID,
|
||||
Name: imported.name,
|
||||
Description: imported.description,
|
||||
Content: imported.content,
|
||||
Config: config,
|
||||
Files: files,
|
||||
})
|
||||
if structuredResult {
|
||||
if existing, found, lerr := h.lookupSkillByName(r.Context(), workspaceUUID, name); lerr != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, SkillImportResult{
|
||||
Status: "failed",
|
||||
Reason: "failed to check for existing skill: " + lerr.Error(),
|
||||
})
|
||||
return
|
||||
} else if found {
|
||||
h.resolveImportSkillConflict(w, r, strategy, workspaceID, workspaceUUID, creatorUUID, creatorID, name, imported, config, files, existing)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := h.createImportedSkillWithName(r.Context(), workspaceUUID, creatorUUID, name, imported, config, files)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
if existing, found, findErr := h.existingSkillIdentityByName(r.Context(), workspaceUUID, imported.name); findErr == nil && found {
|
||||
if structuredResult {
|
||||
if existing, found, lerr := h.lookupSkillByName(r.Context(), workspaceUUID, name); lerr == nil && found {
|
||||
h.resolveImportSkillConflict(w, r, strategy, workspaceID, workspaceUUID, creatorUUID, creatorID, name, imported, config, files, existing)
|
||||
return
|
||||
}
|
||||
}
|
||||
if existing, found, findErr := h.existingSkillIdentityByName(r.Context(), workspaceUUID, name); findErr == nil && found {
|
||||
writeSkillImportDuplicateConflict(w, existing)
|
||||
} else {
|
||||
writeError(w, http.StatusConflict, "a skill with this name already exists")
|
||||
@@ -1803,6 +1982,10 @@ func (h *Handler) ImportSkill(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
actorType, actorID := h.resolveActor(r, creatorID, workspaceID)
|
||||
h.publish(protocol.EventSkillCreated, workspaceID, actorType, actorID, map[string]any{"skill": resp})
|
||||
if structuredResult {
|
||||
writeJSON(w, http.StatusCreated, SkillImportResult{Status: "created", Skill: &resp})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
skillpkg "github.com/multica-ai/multica/server/internal/skill"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
@@ -89,3 +91,126 @@ func (h *Handler) createSkillWithFiles(ctx context.Context, input skillCreateInp
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// errSkillOverwriteNotFound / errSkillOverwriteForbidden are the terminal
|
||||
// boundary cases of overwriteSkillWithFiles: the target was deleted (or moved
|
||||
// out of the workspace) or the caller lost overwrite permission between the
|
||||
// user's confirm and this write. Callers map them to a failed import and must
|
||||
// NOT fall back to creating a new skill.
|
||||
var (
|
||||
errSkillOverwriteNotFound = errors.New("target skill not found")
|
||||
errSkillOverwriteForbidden = errors.New("not permitted to overwrite target skill")
|
||||
errSkillOverwriteNameMismatch = errors.New("target skill name does not match the imported skill")
|
||||
)
|
||||
|
||||
type skillOverwriteInput struct {
|
||||
WorkspaceID pgtype.UUID
|
||||
TargetSkillID pgtype.UUID
|
||||
UserID string // re-checked against the skill creator inside the tx
|
||||
// ExpectedName, when non-empty, must equal the target's current name. Guards
|
||||
// against a client sending the wrong target_skill_id and overwriting a
|
||||
// different skill than the one the conflict dialog showed the user. The
|
||||
// caller passes the sanitized effective import name.
|
||||
ExpectedName string
|
||||
Description string
|
||||
Content string
|
||||
Config any
|
||||
Files []CreateSkillFileRequest
|
||||
}
|
||||
|
||||
// overwriteSkillWithFiles re-imports a bundle onto an existing skill in a single
|
||||
// transaction. It re-verifies, inside that tx, that the target still exists in
|
||||
// the workspace and that UserID may overwrite it (creator-only — see
|
||||
// canOverwriteSkillByLocalImport). A target deleted or a creator change between
|
||||
// the user's confirm and this write fails cleanly via errSkillOverwriteNotFound
|
||||
// / errSkillOverwriteForbidden rather than falling back to create.
|
||||
//
|
||||
// Preserved: id, created_by, created_at, name, and agent_skill bindings (the
|
||||
// row identity and the binding table are never touched). Replaced: description,
|
||||
// content, config (origin), and the full file set — files absent from the new
|
||||
// bundle are pruned via DeleteSkillFilesBySkill. On any error the tx rolls back,
|
||||
// leaving the original skill unchanged.
|
||||
func (h *Handler) overwriteSkillWithFiles(ctx context.Context, input skillOverwriteInput) (SkillWithFilesResponse, error) {
|
||||
config, err := json.Marshal(input.Config)
|
||||
if err != nil {
|
||||
return SkillWithFilesResponse{}, err
|
||||
}
|
||||
if input.Config == nil {
|
||||
config = []byte("{}")
|
||||
}
|
||||
|
||||
tx, err := h.TxStarter.Begin(ctx)
|
||||
if err != nil {
|
||||
return SkillWithFilesResponse{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
|
||||
existing, err := qtx.GetSkillInWorkspace(ctx, db.GetSkillInWorkspaceParams{
|
||||
ID: input.TargetSkillID,
|
||||
WorkspaceID: input.WorkspaceID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return SkillWithFilesResponse{}, errSkillOverwriteNotFound
|
||||
}
|
||||
return SkillWithFilesResponse{}, err
|
||||
}
|
||||
if !canOverwriteSkillByLocalImport(input.UserID, existing) {
|
||||
return SkillWithFilesResponse{}, errSkillOverwriteForbidden
|
||||
}
|
||||
// The overwrite is keyed on target_skill_id, but the conflict the user
|
||||
// confirmed was a same-name collision; reject if the target's name no longer
|
||||
// matches the imported skill so a stale/wrong target_skill_id can't write
|
||||
// one skill's content onto another.
|
||||
if input.ExpectedName != "" && existing.Name != input.ExpectedName {
|
||||
return SkillWithFilesResponse{}, errSkillOverwriteNameMismatch
|
||||
}
|
||||
|
||||
// Name is intentionally left unset (COALESCE keeps the existing name): the
|
||||
// overwrite targets the same-name skill, so preserving it avoids any
|
||||
// unique-name churn.
|
||||
skill, err := qtx.UpdateSkill(ctx, db.UpdateSkillParams{
|
||||
ID: existing.ID,
|
||||
Description: pgtype.Text{String: sanitizeNullBytes(input.Description), Valid: true},
|
||||
Content: pgtype.Text{String: sanitizeNullBytes(input.Content), Valid: true},
|
||||
Config: config,
|
||||
})
|
||||
if err != nil {
|
||||
// A committed concurrent DELETE can land between the read above and this
|
||||
// UPDATE (READ COMMITTED), so UpdateSkill matches 0 rows. Classify it as
|
||||
// the same "target gone" terminal case rather than a generic failure.
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return SkillWithFilesResponse{}, errSkillOverwriteNotFound
|
||||
}
|
||||
return SkillWithFilesResponse{}, err
|
||||
}
|
||||
|
||||
// Full replace: drop every existing file, then re-insert the new set so
|
||||
// files no longer present in the local source are removed.
|
||||
if err := qtx.DeleteSkillFilesBySkill(ctx, skill.ID); err != nil {
|
||||
return SkillWithFilesResponse{}, err
|
||||
}
|
||||
fileResps := make([]SkillFileResponse, 0, len(input.Files))
|
||||
for _, f := range input.Files {
|
||||
sf, err := qtx.UpsertSkillFile(ctx, db.UpsertSkillFileParams{
|
||||
SkillID: skill.ID,
|
||||
Path: sanitizeNullBytes(f.Path),
|
||||
Content: sanitizeNullBytes(f.Content),
|
||||
})
|
||||
if err != nil {
|
||||
return SkillWithFilesResponse{}, err
|
||||
}
|
||||
fileResps = append(fileResps, skillFileToResponse(sf))
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return SkillWithFilesResponse{}, err
|
||||
}
|
||||
|
||||
return SkillWithFilesResponse{
|
||||
SkillResponse: skillToResponse(skill),
|
||||
Files: fileResps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
@@ -46,3 +47,103 @@ func TestWriteSkillImportDuplicateConflictIncludesExistingSkill(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: multica-skill-importing
|
||||
description: "Use when a user provides a skill URL, slug, or clear intent to import/install a specific skill into the current Multica workspace. Teaches the workspace import API/CLI path (POST /api/skills/import), the supported URL source families, the SkillWithFilesResponse shape returned on success, duplicate 409 handling with the existing_skill body, additive agent binding vs replace-all, and the reserved SKILL.md supporting-file rule. Do not use it to decide which skill the user needs, and never treat an external local installer like npx skills add as the final Multica install."
|
||||
description: "Use when a user provides a skill URL, slug, or clear intent to import/install a specific skill into the current Multica workspace. Teaches the workspace import API/CLI path (POST /api/skills/import), the supported URL source families, --on-conflict fail|overwrite|rename|skip behavior and structured import results, additive agent binding vs replace-all, and the reserved SKILL.md supporting-file rule. Do not use it to decide which skill the user needs, and never treat an external local installer like npx skills add as the final Multica install."
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(multica *)
|
||||
---
|
||||
@@ -28,11 +28,11 @@ import endpoint, driven by this CLI:
|
||||
multica skill import --url <url> --output json
|
||||
```
|
||||
|
||||
That CLI sends:
|
||||
The CLI defaults to `--on-conflict fail`. Current CLIs send:
|
||||
|
||||
```text
|
||||
POST /api/skills/import
|
||||
body: { "url": "<url>" }
|
||||
body: { "url": "<url>", "on_conflict": "fail" }
|
||||
```
|
||||
|
||||
Do not finish with `npx skills add`. That installs into an external/local skill
|
||||
@@ -66,17 +66,30 @@ directly; search is not required by the API:
|
||||
multica skill import --url <url> --output json
|
||||
```
|
||||
|
||||
2. Treat a successful response as the source of truth. The body is a workspace
|
||||
`SkillWithFilesResponse` — it embeds the standard `SkillResponse` and adds the
|
||||
supporting `files` array. Report the relevant fields:
|
||||
2. Treat the response as the source of truth. Current CLI imports use the
|
||||
structured import result envelope:
|
||||
|
||||
- `id`
|
||||
- `name`
|
||||
- `description`
|
||||
- `config.origin` (provenance: which source the skill was imported from — set
|
||||
only when the source supplied an origin, so treat it as possibly absent)
|
||||
- `files` / files count
|
||||
- `created_at` / `updated_at`
|
||||
```json
|
||||
{
|
||||
"status": "created|updated|conflict|skipped|failed",
|
||||
"reason": "...",
|
||||
"skill": { "...": "SkillWithFilesResponse when created/updated" },
|
||||
"existing_skill": { "id": "...", "name": "...", "can_overwrite": true }
|
||||
}
|
||||
```
|
||||
|
||||
For `created` / `updated`, `skill` is a workspace `SkillWithFilesResponse`: it
|
||||
embeds the standard `SkillResponse` and adds the supporting `files` array. Report
|
||||
the relevant fields:
|
||||
|
||||
- `status` and `reason` when present.
|
||||
- `skill.id` / `skill.name` / `skill.description`.
|
||||
- `skill.config.origin` (provenance: which source the skill was imported from —
|
||||
set only when the source supplied an origin, so treat it as possibly absent).
|
||||
- `skill.files` / files count.
|
||||
- `skill.created_at` / `skill.updated_at`.
|
||||
- `existing_skill.id` / `existing_skill.name` when status is `conflict`,
|
||||
`skipped`, or `failed` due to an existing skill.
|
||||
|
||||
Because the response is structured, read these returned fields instead of guessing
|
||||
whether the import succeeded.
|
||||
@@ -118,9 +131,46 @@ rename it to a non-reserved path. (The hard `400` rejection — "SKILL.md is res
|
||||
for the primary skill content" — only fires on the dedicated single-file endpoint
|
||||
`PUT /api/skills/{id}/files`, not on import.)
|
||||
|
||||
## Duplicate imports (409)
|
||||
## Same-name conflicts: `--on-conflict`
|
||||
|
||||
A duplicate import returns `409`. On current servers the body carries the existing
|
||||
Default behavior is safe: `multica skill import --url <url>` is equivalent to
|
||||
`--on-conflict fail`. If the imported skill name already exists, the command
|
||||
prints a structured `conflict` result and exits non-zero; no skill is created or
|
||||
updated.
|
||||
|
||||
Choose an explicit strategy only when the user asked for it or the intent is
|
||||
clear:
|
||||
|
||||
- `--on-conflict fail` (default): do nothing on conflict; report `status:
|
||||
conflict` with a reason that suggests overwrite or rename.
|
||||
- `--on-conflict overwrite`: update the existing same-name skill in place, but
|
||||
only if the current user is the skill's original creator. This preserves the
|
||||
skill ID, `created_by`, `created_at`, and agent-skill bindings; it replaces
|
||||
description, content, provenance config, and supporting files. Non-creators get
|
||||
`status: failed`.
|
||||
- `--on-conflict rename`: create a new skill with an automatic suffix such as
|
||||
`-2` / `-3`; the existing skill is untouched.
|
||||
- `--on-conflict skip`: leave the existing skill untouched and report `status:
|
||||
skipped`.
|
||||
|
||||
Concrete examples:
|
||||
|
||||
```bash
|
||||
# Safe default. Fails with status=conflict if review-helper already exists.
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --output json
|
||||
|
||||
# Replace the existing same-name skill, preserving its ID and agent bindings.
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite --output json
|
||||
|
||||
# Keep the existing skill and import a copy such as review-helper-2.
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename --output json
|
||||
|
||||
# Batch-friendly behavior: leave the existing skill alone and mark it skipped.
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip --output json
|
||||
```
|
||||
|
||||
Legacy compatibility: clients that do not send `on_conflict` keep the old
|
||||
contract. A duplicate import returns `409` and the body carries the existing
|
||||
workspace skill identity:
|
||||
|
||||
```json
|
||||
@@ -133,19 +183,17 @@ workspace skill identity:
|
||||
}
|
||||
```
|
||||
|
||||
`multica skill import --url <url> --output json` prints that structured conflict
|
||||
body and exits successfully (exit 0) for this duplicate case. Treat
|
||||
`existing_skill.id` and `existing_skill.name` as the source of truth, then fetch
|
||||
details if needed:
|
||||
Current CLI normalizes that legacy shape into `status: conflict` and exits
|
||||
non-zero for the default `fail` strategy. Treat `existing_skill.id` and
|
||||
`existing_skill.name` as the source of truth, then fetch details if needed:
|
||||
|
||||
```bash
|
||||
multica skill get <skill-id> --output json
|
||||
```
|
||||
|
||||
Legacy fallback: a legacy server or old CLI may return a `409` whose body is only a
|
||||
string like `a skill with this name already exists`, with no `existing_skill` key.
|
||||
The CLI cannot recognize that as the duplicate case, so it exits non-zero. Recover
|
||||
by finding the existing workspace skill yourself:
|
||||
Older servers may return a `409` whose body is only a string like `a skill with
|
||||
this name already exists`, with no `existing_skill` key. Recover by finding the
|
||||
existing workspace skill yourself:
|
||||
|
||||
```bash
|
||||
multica skill list --output json
|
||||
|
||||
@@ -17,44 +17,48 @@ grep -n "func IsReservedContentPath" server/internal/skill/reserved.go
|
||||
|
||||
| Behavior | File:line |
|
||||
|---|---|
|
||||
| `ImportSkill` handler (`POST /api/skills/import`) | `server/internal/handler/skill.go:1724` |
|
||||
| Decodes `ImportSkillRequest` (`{ "url": ... }`) | `server/internal/handler/skill.go:1737-1741`, struct at `:523` |
|
||||
| Detects source family + normalizes URL | `server/internal/handler/skill.go:1743` (calls `detectImportSource`) |
|
||||
| Persists provenance into `config.origin` | `server/internal/handler/skill.go:1776-1781` — set at `:1780` **only when** `imported.origin != nil` (`:1779`); otherwise `config` stays `{}` and `origin` is absent |
|
||||
| Builds skill + files via `createSkillWithFiles` (def `server/internal/handler/skill_create.go:72`, tx body `:27`) | call site `server/internal/handler/skill.go:1783` |
|
||||
| Success: `201 Created` with the response body | `server/internal/handler/skill.go:1806` |
|
||||
| Route registration `r.Post("/import", h.ImportSkill)` | `server/cmd/server/router.go:621` (under `/api/skills`, `:617`) |
|
||||
| `ImportSkill` handler (`POST /api/skills/import`) | `server/internal/handler/skill.go:1882` |
|
||||
| Decodes `ImportSkillRequest` (`{ "url": ..., "on_conflict": ... }`) | `server/internal/handler/skill.go:1895-1899`, struct at `:553` |
|
||||
| Validates `on_conflict` (`fail`, `overwrite`, `rename`, `skip`) | `server/internal/handler/skill.go:1900-1908`, helper `validImportOnConflict` at `:566` |
|
||||
| Detects source family + normalizes URL | `server/internal/handler/skill.go:1910` (calls `detectImportSource`) |
|
||||
| Persists provenance into `config.origin` | `server/internal/handler/skill.go:1944-1948` — set only when `imported.origin != nil`; otherwise `config` stays `{}` and `origin` is absent |
|
||||
| Structured conflict dispatcher | `server/internal/handler/skill.go:1813-1878` |
|
||||
| Builds skill + files via `createSkillWithFiles` (def `server/internal/handler/skill_create.go:77`, tx body `:29`) | wrapped by `createImportedSkillWithName` at `server/internal/handler/skill.go:1774` |
|
||||
| Structured success: `201 Created` with `{status:"created", skill}` when `on_conflict` was sent | `server/internal/handler/skill.go:1985-1988` |
|
||||
| Legacy success: `201 Created` with bare `SkillWithFilesResponse` when `on_conflict` was omitted | `server/internal/handler/skill.go:1990` |
|
||||
| Route registration `r.Post("/import", h.ImportSkill)` | `server/cmd/server/router.go:874` |
|
||||
|
||||
## CLI: `multica skill import --url`
|
||||
|
||||
| Behavior | File:line |
|
||||
|---|---|
|
||||
| `skill import` command def | `server/cmd/multica/cmd_skill.go:60-64` |
|
||||
| `--url` flag | `server/cmd/multica/cmd_skill.go:143` |
|
||||
| `--url` flag | `server/cmd/multica/cmd_skill.go:142` |
|
||||
| `--on-conflict` flag (default `fail`) | `server/cmd/multica/cmd_skill.go:143` |
|
||||
| `--output` flag (default `json`) | `server/cmd/multica/cmd_skill.go:144` |
|
||||
| `runSkillImport` | `server/cmd/multica/cmd_skill.go:412` |
|
||||
| Requires `--url` | `server/cmd/multica/cmd_skill.go:418-421` |
|
||||
| `POST /api/skills/import` | `server/cmd/multica/cmd_skill.go:431` |
|
||||
| On error, tries conflict handler; returns `nil` (exit 0) when handled | `server/cmd/multica/cmd_skill.go:432-434` |
|
||||
| Reads and validates `--on-conflict` | `server/cmd/multica/cmd_skill.go:422-425` |
|
||||
| Sends `on_conflict` in the request body | `server/cmd/multica/cmd_skill.go:428-431` |
|
||||
| `POST /api/skills/import` | `server/cmd/multica/cmd_skill.go:436` |
|
||||
| Structured HTTP error body handling | `server/cmd/multica/cmd_skill.go:437-440`, `handleSkillImportError` at `:454` |
|
||||
| Prints structured result (`json` or table) | `server/cmd/multica/cmd_skill.go:443`, helper at `:497` |
|
||||
|
||||
## Duplicate 409 handling
|
||||
## Same-name conflict handling
|
||||
|
||||
| Behavior | File:line |
|
||||
|---|---|
|
||||
| `ImportSkill` unique-violation branch | `server/internal/handler/skill.go:1793-1800` |
|
||||
| Structured 409 via `writeSkillImportDuplicateConflict` (looks up existing skill) | `server/internal/handler/skill.go:1794-1795` |
|
||||
| `writeSkillImportDuplicateConflict` writes `{error, existing_skill}` at status 409 | `server/internal/handler/skill.go:109-114` |
|
||||
| `ExistingSkillIdentity` (`{id,name}`) | `server/internal/handler/skill.go:104-107` |
|
||||
| `existingSkillIdentityByName` (GetSkillByWorkspaceAndName) | `server/internal/handler/skill.go:130-142` |
|
||||
| Defensive fallback: plain-string 409 when lookup misses (delete-race) | `server/internal/handler/skill.go:1796-1798` |
|
||||
| CLI `handleSkillImportConflict` | `server/cmd/multica/cmd_skill.go:447` |
|
||||
| Requires HTTP 409 + non-empty body | `server/cmd/multica/cmd_skill.go:449-451` |
|
||||
| Requires `existing_skill` key (else `false` → non-zero exit) | `server/cmd/multica/cmd_skill.go:457-459` |
|
||||
| Under `--output json`: prints body, returns `true` (caller exits 0) | `server/cmd/multica/cmd_skill.go:461-465` |
|
||||
|
||||
A legacy plain-string 409 (no `existing_skill` key) fails the `:457-459` guard,
|
||||
so `handleSkillImportConflict` returns `false`, `runSkillImport` falls through to
|
||||
`return fmt.Errorf("import skill: ...")` at `:435` → non-zero exit.
|
||||
| `SkillImportResult` (`status`, `reason`, `skill`, `existing_skill`) | `server/internal/handler/skill.go:104-109` |
|
||||
| `ExistingSkillIdentity` (`id`, `name`, `created_by`, `can_overwrite`) | `server/internal/handler/skill.go:112-117` |
|
||||
| Pre-create lookup for structured conflict flow | `server/internal/handler/skill.go:1951-1962` |
|
||||
| Race-safe unique-violation fallback into structured conflict flow | `server/internal/handler/skill.go:1966-1971` |
|
||||
| Default `fail`: `status:"conflict"` and HTTP 409 | `server/internal/handler/skill.go:1872-1877` |
|
||||
| `overwrite`: creator-only update, preserves skill identity/bindings via `overwriteSkillWithFiles` | `server/internal/handler/skill.go:1823-1852`, tx helper at `server/internal/handler/skill_create.go:133` |
|
||||
| `rename`: creates suffixed name with bounded attempts | `server/internal/handler/skill.go:1854-1870`, helper at `:1786` |
|
||||
| `skip`: returns `status:"skipped"` and leaves existing skill untouched | `server/internal/handler/skill.go:1816-1821` |
|
||||
| Legacy duplicate branch when `on_conflict` was omitted | `server/internal/handler/skill.go:1973-1978` |
|
||||
| Legacy duplicate response `{error, existing_skill}` | `server/internal/handler/skill.go:118-123` |
|
||||
| CLI normalizes legacy `{existing_skill}` body into `status:"conflict"` | `server/cmd/multica/cmd_skill.go:454-482`, helper at `:484` |
|
||||
|
||||
## Response shape: `SkillWithFilesResponse`
|
||||
|
||||
@@ -64,35 +68,36 @@ so `handleSkillImportConflict` returns `false`, `runSkillImport` falls through t
|
||||
| `SkillResponse` fields (`id, workspace_id, name, description, content, config, created_by, created_at, updated_at`) | `server/internal/handler/skill.go:41-51` |
|
||||
| `SkillFileResponse` fields | `server/internal/handler/skill.go:80-87` |
|
||||
| `createSkillWithFilesInTx` returns `SkillWithFilesResponse{SkillResponse, Files}` | `server/internal/handler/skill_create.go:66-69` |
|
||||
| `config.origin` set on import | `server/internal/handler/skill.go:1780` |
|
||||
| `config.origin` set on import | `server/internal/handler/skill.go:1947` |
|
||||
|
||||
The import response is a `SkillWithFilesResponse` (not a bare `SkillResponse`):
|
||||
it carries every `SkillResponse` field plus the `files` array.
|
||||
For current CLI imports, `SkillWithFilesResponse` appears under
|
||||
`SkillImportResult.skill` when status is `created` or `updated`. Legacy clients
|
||||
that omit `on_conflict` still receive a bare `SkillWithFilesResponse`.
|
||||
|
||||
## URL source families (`detectImportSource`)
|
||||
|
||||
| Behavior | File:line |
|
||||
|---|---|
|
||||
| `detectImportSource` | `server/internal/handler/skill.go:723-756` |
|
||||
| `skills.sh` / `www.skills.sh` | `server/internal/handler/skill.go:743-744` |
|
||||
| `clawhub.ai` / `www.clawhub.ai` | `server/internal/handler/skill.go:745-746` |
|
||||
| `github.com` / `www.github.com` | `server/internal/handler/skill.go:747-748` |
|
||||
| Bare slug (no host) defaults to ClawHub | `server/internal/handler/skill.go:750-753` |
|
||||
| `parseGitHubURL` handles `/tree/{ref}/...` and `/blob/{ref}/.../SKILL.md` | `server/internal/handler/skill.go:1402-1455` (tree/blob check `:1415-1432`) |
|
||||
| `detectImportSource` | `server/internal/handler/skill.go:773-804` |
|
||||
| `skills.sh` / `www.skills.sh` | `server/internal/handler/skill.go:791-792` |
|
||||
| `clawhub.ai` / `www.clawhub.ai` | `server/internal/handler/skill.go:793-794` |
|
||||
| `github.com` / `www.github.com` | `server/internal/handler/skill.go:795-796` |
|
||||
| Bare slug (no host) defaults to ClawHub | `server/internal/handler/skill.go:798-800` |
|
||||
| `parseGitHubURL` handles `/tree/{ref}/...` and `/blob/{ref}/.../SKILL.md` | `server/internal/handler/skill.go:1450-1503` (tree/blob check `:1463-1480`) |
|
||||
|
||||
## Additive add vs replace-all set
|
||||
|
||||
| Behavior | File:line |
|
||||
|---|---|
|
||||
| `AddAgentSkills` (additive: AddAgentSkill loop, no RemoveAll) | `server/internal/handler/skill.go:1978`; loop `:2009-2017` |
|
||||
| Route `POST /api/agents/{id}/skills/add` | `server/cmd/server/router.go:598` |
|
||||
| `SetAgentSkills` (replace-all: RemoveAllAgentSkills then re-add) | `server/internal/handler/skill.go:1923`; `RemoveAllAgentSkills` `:1955`; re-add `:1960-1968` |
|
||||
| Route `PUT /api/agents/{id}/skills` | `server/cmd/server/router.go:597` |
|
||||
| `AddAgentSkills` (additive: AddAgentSkill loop, no RemoveAll) | `server/internal/handler/skill.go:2161`; loop `:2192-2200` |
|
||||
| Route `POST /api/agents/{id}/skills/add` | `server/cmd/server/router.go:851` |
|
||||
| `SetAgentSkills` (replace-all: RemoveAllAgentSkills then re-add) | `server/internal/handler/skill.go:2106`; `RemoveAllAgentSkills` `:2138`; re-add `:2143-2151` |
|
||||
| Route `PUT /api/agents/{id}/skills` | `server/cmd/server/router.go:850` |
|
||||
| CLI `agent skills add` def ("without replacing existing assignments") | `server/cmd/multica/cmd_agent.go:125-130` |
|
||||
| `runAgentSkillsAdd` → `POST .../skills/add` | `server/cmd/multica/cmd_agent.go:839`; POST `:860` |
|
||||
| `runAgentSkillsAdd` → `POST .../skills/add` | `server/cmd/multica/cmd_agent.go:797`; POST `:818` |
|
||||
| CLI `agent skills set` def ("replaces all current assignments") | `server/cmd/multica/cmd_agent.go:118-123` |
|
||||
| `runAgentSkillsSet` → `PUT .../skills` | `server/cmd/multica/cmd_agent.go:814`; PUT `:832` |
|
||||
| CLI `agent skills list` | `server/cmd/multica/cmd_agent.go:782`; GET `:792` |
|
||||
| `runAgentSkillsSet` → `PUT .../skills` | `server/cmd/multica/cmd_agent.go:772`; PUT `:790` |
|
||||
| CLI `agent skills list` | `server/cmd/multica/cmd_agent.go:740`; GET `:750` |
|
||||
|
||||
## Reserved primary-content filename (`SKILL.md`)
|
||||
|
||||
@@ -101,8 +106,8 @@ it carries every `SkillResponse` field plus the `files` array.
|
||||
| `ContentFilename = "SKILL.md"` | `server/internal/skill/reserved.go:12` |
|
||||
| `IsReservedContentPath` (cleans path, case-insensitive compare) | `server/internal/skill/reserved.go:25-27` |
|
||||
| Import/create path: reserved supporting file is **silently skipped** (`continue`) | `server/internal/handler/skill_create.go:50-54` |
|
||||
| `UpdateSkill` (PUT `/api/skills/{id}`) replace-files path: also silently skips | `server/internal/handler/skill.go:461-464` |
|
||||
| `UpsertSkillFile` (PUT `/api/skills/{id}/files`): **rejects 400** "SKILL.md is reserved for the primary skill content" | `server/internal/handler/skill.go:1851-1854` |
|
||||
| `UpdateSkill` (PUT `/api/skills/{id}`) replace-files path: also silently skips | `server/internal/handler/skill.go:490-494` |
|
||||
| `UpsertSkillFile` (PUT `/api/skills/{id}/files`): **rejects 400** "SKILL.md is reserved for the primary skill content" | `server/internal/handler/skill.go:2014`; reserved check `:2034-2036` |
|
||||
|
||||
Reason `SKILL.md` is reserved: the daemon writes the skill's `Content` to that path
|
||||
itself when preparing the execution environment, so a supporting file may not also
|
||||
|
||||
@@ -276,6 +276,13 @@ func TestSkillImportingSkillCoversWorkspaceImportContracts(t *testing.T) {
|
||||
"skills.sh",
|
||||
"github.com",
|
||||
"config.origin",
|
||||
"--on-conflict fail",
|
||||
"--on-conflict overwrite",
|
||||
"--on-conflict rename",
|
||||
"--on-conflict skip",
|
||||
"status",
|
||||
"conflict",
|
||||
"skipped",
|
||||
"409",
|
||||
"existing_skill",
|
||||
"id",
|
||||
|
||||
Reference in New Issue
Block a user