FN-5853: persist skills toggle state in dashboard
Make skills enabled and disabled toggles persist across dashboard refreshes. - normalize stored skill paths before matching or writing skill toggle entries - preserve enabled and disabled state for both top-level and package-scoped skills - add round-trip adapter tests covering settings persistence and rediscovery Files changed: .changeset/fn-5853-skills-persistence.md | 5 + .../dashboard/src/__tests__/skills-adapter.test.ts | 116 ++++++++++++++++++++- packages/dashboard/src/skills-adapter.ts | 29 ++++-- 3 files changed, 140 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-5853 Fusion-Task-Lineage: 7b9df0ae-7db7-4c3b-93c6-3ceedfad4caa
This commit is contained in:
5
.changeset/fn-5853-skills-persistence.md
Normal file
5
.changeset/fn-5853-skills-persistence.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix the dashboard skills interface so enabled and disabled skill toggles persist across refreshes for both top-level and package-scoped skills. The adapter now normalizes stored skill paths consistently when writing settings and when rediscovering installed skills.
|
||||
@@ -1,9 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createSkillsAdapter, extractSkillName } from "../skills-adapter.js";
|
||||
import { writeFile, mkdir, access } from "node:fs/promises";
|
||||
import { createSkillsAdapter, extractSkillName, computeSkillId } from "../skills-adapter.js";
|
||||
import { writeFile, mkdir, access, readFile, rm } from "node:fs/promises";
|
||||
import { join, dirname } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { rm } from "node:fs/promises";
|
||||
|
||||
describe("createSkillsAdapter - fetchCatalog fallback behavior", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -532,6 +531,117 @@ describe("createSkillsAdapter - readSkillContent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSkillsAdapter - toggleExecutionSkill persistence", () => {
|
||||
async function createRoundTripFixture(source: string) {
|
||||
const rootDir = join(tmpdir(), `skills-adapter-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
const baseDir = source === "*" ? rootDir : join(rootDir, "packages", "example-package");
|
||||
const relativePath = "skills/test-skill/SKILL.md";
|
||||
const absolutePath = join(baseDir, relativePath);
|
||||
const settingsPath = join(rootDir, ".fusion", "settings.json");
|
||||
|
||||
await mkdir(dirname(absolutePath), { recursive: true });
|
||||
await writeFile(absolutePath, "# Test Skill\n", "utf-8");
|
||||
|
||||
const adapter = createSkillsAdapter({
|
||||
packageManager: {
|
||||
resolve: vi.fn().mockResolvedValue({
|
||||
skills: [
|
||||
{
|
||||
path: absolutePath,
|
||||
enabled: false,
|
||||
metadata: {
|
||||
source,
|
||||
scope: "project" as const,
|
||||
origin: source === "*" ? "top-level" as const : "package" as const,
|
||||
baseDir,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
getSettingsPath: vi.fn().mockReturnValue(settingsPath),
|
||||
});
|
||||
|
||||
return {
|
||||
adapter,
|
||||
rootDir,
|
||||
settingsPath,
|
||||
skillId: computeSkillId(source, relativePath),
|
||||
cleanup: () => rm(rootDir, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
it.each([
|
||||
{ source: "*", settingsKey: "skills", expectedPattern: "+test-skill/SKILL.md" },
|
||||
{ source: "@scope/pkg", settingsKey: "packages[].skills", expectedPattern: "+test-skill/SKILL.md" },
|
||||
])("round-trips enabled skills for source $source", async ({ source, settingsKey, expectedPattern }) => {
|
||||
const fixture = await createRoundTripFixture(source);
|
||||
|
||||
try {
|
||||
const result = await fixture.adapter.toggleExecutionSkill(fixture.rootDir, {
|
||||
skillId: fixture.skillId,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(result.pattern).toBe(expectedPattern);
|
||||
expect(result.settingsPath).toBe(settingsKey);
|
||||
|
||||
const settings = JSON.parse(await readFile(fixture.settingsPath, "utf-8")) as {
|
||||
skills?: string[];
|
||||
packages?: Array<{ source: string; skills?: string[] }>;
|
||||
};
|
||||
|
||||
if (source === "*") {
|
||||
expect(settings.skills).toContain(expectedPattern);
|
||||
} else {
|
||||
expect(settings.packages).toContainEqual({ source, skills: [expectedPattern] });
|
||||
}
|
||||
|
||||
const discovered = await fixture.adapter.discoverSkills(fixture.rootDir);
|
||||
expect(discovered).toContainEqual(
|
||||
expect.objectContaining({ id: fixture.skillId, enabled: true }),
|
||||
);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ source: "*", settingsKey: "skills", expectedPattern: "-test-skill/SKILL.md" },
|
||||
{ source: "@scope/pkg", settingsKey: "packages[].skills", expectedPattern: "-test-skill/SKILL.md" },
|
||||
])("round-trips disabled skills for source $source", async ({ source, settingsKey, expectedPattern }) => {
|
||||
const fixture = await createRoundTripFixture(source);
|
||||
|
||||
try {
|
||||
const result = await fixture.adapter.toggleExecutionSkill(fixture.rootDir, {
|
||||
skillId: fixture.skillId,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
expect(result.pattern).toBe(expectedPattern);
|
||||
expect(result.settingsPath).toBe(settingsKey);
|
||||
|
||||
const settings = JSON.parse(await readFile(fixture.settingsPath, "utf-8")) as {
|
||||
skills?: string[];
|
||||
packages?: Array<{ source: string; skills?: string[] }>;
|
||||
};
|
||||
|
||||
if (source === "*") {
|
||||
expect(settings.skills).toContain(expectedPattern);
|
||||
} else {
|
||||
expect(settings.packages).toContainEqual({ source, skills: [expectedPattern] });
|
||||
}
|
||||
|
||||
const discovered = await fixture.adapter.discoverSkills(fixture.rootDir);
|
||||
expect(discovered).toContainEqual(
|
||||
expect.objectContaining({ id: fixture.skillId, enabled: false }),
|
||||
);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSkillName", () => {
|
||||
it("normalizes Windows separators before deriving the display name", () => {
|
||||
expect(extractSkillName("skills\\tooling\\windows-fix", "npm")).toBe("tooling/windows-fix");
|
||||
|
||||
@@ -189,6 +189,10 @@ export function parseSkillId(skillId: string): { source: string; relativePath: s
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStoredSkillPath(path: string): string {
|
||||
return path.replaceAll("\\", "/").replace(/^skills\//, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a skill path is enabled in the settings.
|
||||
* Checks both top-level skills and package-scoped skills.
|
||||
@@ -198,11 +202,20 @@ function isSkillEnabled(
|
||||
settings: { skills?: string[]; packages?: Array<{ source: string; skills?: string[] }> },
|
||||
): boolean {
|
||||
// Check top-level skills
|
||||
const parsedSkillId = parseSkillId(skillId);
|
||||
if (!parsedSkillId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedSkillPath = normalizeStoredSkillPath(parsedSkillId.relativePath);
|
||||
|
||||
const skills = settings.skills ?? [];
|
||||
for (const entry of skills) {
|
||||
const entryPath = entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry;
|
||||
const entryId = computeSkillId("*", entryPath);
|
||||
if (entryId === skillId) {
|
||||
const entryPath = normalizeStoredSkillPath(
|
||||
entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry,
|
||||
);
|
||||
const entryId = computeSkillId("*", `skills/${entryPath}`);
|
||||
if (entryId === skillId || entryPath === normalizedSkillPath) {
|
||||
return entry.startsWith("+");
|
||||
}
|
||||
}
|
||||
@@ -215,9 +228,11 @@ function isSkillEnabled(
|
||||
if (!pkgSkills) continue;
|
||||
|
||||
for (const entry of pkgSkills) {
|
||||
const entryPath = entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry;
|
||||
const entryId = computeSkillId(source, entryPath);
|
||||
if (entryId === skillId) {
|
||||
const entryPath = normalizeStoredSkillPath(
|
||||
entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry,
|
||||
);
|
||||
const entryId = computeSkillId(source, `skills/${entryPath}`);
|
||||
if (entryId === skillId || entryPath === normalizedSkillPath) {
|
||||
return entry.startsWith("+");
|
||||
}
|
||||
}
|
||||
@@ -333,7 +348,7 @@ export function createSkillsAdapter(options: {
|
||||
}
|
||||
|
||||
const isTopLevel = source === "*";
|
||||
const skillPath = relativePath.replace(/^skills\//, "");
|
||||
const skillPath = normalizeStoredSkillPath(relativePath);
|
||||
|
||||
if (isTopLevel) {
|
||||
// Toggle in top-level skills
|
||||
|
||||
Reference in New Issue
Block a user