Merge branch 'feat/skill-import-conflict-backend' into agent/niko/mul-2802-skill-import-conflict-cli

This commit is contained in:
J
2026-06-10 16:04:18 +08:00
14 changed files with 1673 additions and 156 deletions

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -44,6 +44,8 @@ export type {
RuntimeModelListStatus,
RuntimeModelsResult,
RuntimeLocalSkillStatus,
RuntimeLocalSkillImportAction,
RuntimeLocalSkillImportConflict,
RuntimeLocalSkillSummary,
RuntimeLocalSkillListRequest,
CreateRuntimeLocalSkillImportRequest,

View File

@@ -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"

View File

@@ -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": "无文件"

View File

@@ -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();
});
});

View File

@@ -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">

View File

@@ -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
}

View 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)
}
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -390,6 +390,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)

View File

@@ -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
}