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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { createSkillsAdapter, extractSkillName } from "../skills-adapter.js";
|
import { createSkillsAdapter, extractSkillName, computeSkillId } from "../skills-adapter.js";
|
||||||
import { writeFile, mkdir, access } from "node:fs/promises";
|
import { writeFile, mkdir, access, readFile, rm } from "node:fs/promises";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { rm } from "node:fs/promises";
|
|
||||||
|
|
||||||
describe("createSkillsAdapter - fetchCatalog fallback behavior", () => {
|
describe("createSkillsAdapter - fetchCatalog fallback behavior", () => {
|
||||||
const originalFetch = globalThis.fetch;
|
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", () => {
|
describe("extractSkillName", () => {
|
||||||
it("normalizes Windows separators before deriving the display name", () => {
|
it("normalizes Windows separators before deriving the display name", () => {
|
||||||
expect(extractSkillName("skills\\tooling\\windows-fix", "npm")).toBe("tooling/windows-fix");
|
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.
|
* Check if a skill path is enabled in the settings.
|
||||||
* Checks both top-level skills and package-scoped skills.
|
* Checks both top-level skills and package-scoped skills.
|
||||||
@@ -198,11 +202,20 @@ function isSkillEnabled(
|
|||||||
settings: { skills?: string[]; packages?: Array<{ source: string; skills?: string[] }> },
|
settings: { skills?: string[]; packages?: Array<{ source: string; skills?: string[] }> },
|
||||||
): boolean {
|
): boolean {
|
||||||
// Check top-level skills
|
// Check top-level skills
|
||||||
|
const parsedSkillId = parseSkillId(skillId);
|
||||||
|
if (!parsedSkillId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSkillPath = normalizeStoredSkillPath(parsedSkillId.relativePath);
|
||||||
|
|
||||||
const skills = settings.skills ?? [];
|
const skills = settings.skills ?? [];
|
||||||
for (const entry of skills) {
|
for (const entry of skills) {
|
||||||
const entryPath = entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry;
|
const entryPath = normalizeStoredSkillPath(
|
||||||
const entryId = computeSkillId("*", entryPath);
|
entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry,
|
||||||
if (entryId === skillId) {
|
);
|
||||||
|
const entryId = computeSkillId("*", `skills/${entryPath}`);
|
||||||
|
if (entryId === skillId || entryPath === normalizedSkillPath) {
|
||||||
return entry.startsWith("+");
|
return entry.startsWith("+");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,9 +228,11 @@ function isSkillEnabled(
|
|||||||
if (!pkgSkills) continue;
|
if (!pkgSkills) continue;
|
||||||
|
|
||||||
for (const entry of pkgSkills) {
|
for (const entry of pkgSkills) {
|
||||||
const entryPath = entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry;
|
const entryPath = normalizeStoredSkillPath(
|
||||||
const entryId = computeSkillId(source, entryPath);
|
entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry,
|
||||||
if (entryId === skillId) {
|
);
|
||||||
|
const entryId = computeSkillId(source, `skills/${entryPath}`);
|
||||||
|
if (entryId === skillId || entryPath === normalizedSkillPath) {
|
||||||
return entry.startsWith("+");
|
return entry.startsWith("+");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,7 +348,7 @@ export function createSkillsAdapter(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isTopLevel = source === "*";
|
const isTopLevel = source === "*";
|
||||||
const skillPath = relativePath.replace(/^skills\//, "");
|
const skillPath = normalizeStoredSkillPath(relativePath);
|
||||||
|
|
||||||
if (isTopLevel) {
|
if (isTopLevel) {
|
||||||
// Toggle in top-level skills
|
// Toggle in top-level skills
|
||||||
|
|||||||
Reference in New Issue
Block a user