diff --git a/apps/desktop/electron/api.ts b/apps/desktop/electron/api.ts index a6812b9..6b63d83 100644 --- a/apps/desktop/electron/api.ts +++ b/apps/desktop/electron/api.ts @@ -7,6 +7,7 @@ import { saveConfig, getBaseDir, getWorkflowDir, + getWorkflowDirs, readAiBackendOverride, readAiApiProfilesForUi, readMobileAccessEnabled, @@ -22,11 +23,12 @@ import { setActiveWorkflowFile, deriveTaskInputFields, getWorkflowConfigShape, + getWorkflowFilePath, loadWorkflow, unloadWorkflow, } from "../../../packages/core-models/workflow"; import { validateWorkflowDsl } from "../../../packages/core-lib/langgraph-runtime/index"; -import { readWorkfolders, saveWorkfolders, deleteTask, removeTaskWorktree } from "../../../packages/core-models/workfolders"; +import { readWorkfolders, saveWorkfolders, deleteTask, removeTaskWorktree, removeWorkfolder } from "../../../packages/core-models/workfolders"; import { assertSafeRunId, readState, getTaskRunId, readPhaseInteractions } from "../../../packages/core-models/state"; import { deleteManagedSkill, importManagedSkills, listManagedSkills, saveManagedSkill } from "../../../packages/core-models/skills"; import { getPhaseContent, getPhaseOutputArtifactPath, readPhaseOutputArtifacts, stopActiveWorkflow } from "../../../packages/core-lib/claude"; @@ -99,14 +101,18 @@ export async function deleteAiApi(id) { } export async function listWorkflows() { - const workflowDir = await ensureWorkflowDir(); + await ensureWorkflowDir(); try { - const files = await readdir(workflowDir); + const config = await readConfig(); + const deletedWorkflowFiles = new Set(Array.isArray(config.deletedWorkflowFiles) ? config.deletedWorkflowFiles : []); + const files = Array.from(new Set((await Promise.all( + getWorkflowDirs().map((dir) => readdir(dir).catch(() => [])) + )).flat())).filter((file) => !deletedWorkflowFiles.has(file)).sort(); const workflows = []; for (const file of files) { if (!file.endsWith(".json")) continue; try { - const raw = JSON.parse(await readFile(join(workflowDir, file), "utf-8")); + const raw = JSON.parse(await readFile(getWorkflowFilePath(file), "utf-8")); const workflowConfig = getWorkflowConfigShape(raw); workflows.push({ filename: file, @@ -129,7 +135,7 @@ export async function listWorkflows() { export async function getWorkflowByFilename(filename) { const safeFilename = assertSafeWorkflowFilename(filename); - const raw = await readFile(join(await ensureWorkflowDir(), safeFilename), "utf-8"); + const raw = await readFile(getWorkflowFilePath(safeFilename), "utf-8"); return JSON.parse(raw); } @@ -137,7 +143,7 @@ export async function setWorkflowVisible(filename, visible) { const safeFilename = assertSafeWorkflowFilename(filename); const workflowDir = await ensureWorkflowDir(); const filepath = join(workflowDir, safeFilename); - const raw = JSON.parse(await readFile(filepath, "utf-8")); + const raw = JSON.parse(await readFile(getWorkflowFilePath(safeFilename), "utf-8")); raw.visible = visible === true; await writeFile(filepath, JSON.stringify(raw, null, 2)); if (safeFilename === getActiveWorkflowFile()) loadWorkflow(filepath); @@ -158,16 +164,18 @@ async function createWorkflowFile(workflow, isDraft) { const filename = data.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + ".json"; const workflowDir = await ensureWorkflowDir(); const filepath = join(workflowDir, filename); - const existing = await stat(filepath).catch(() => null); - if (existing) throw new Error("workflow with this name already exists"); + const config = await readConfig(); + const deletedWorkflowFiles = Array.isArray(config.deletedWorkflowFiles) ? config.deletedWorkflowFiles : []; + const existing = await stat(getWorkflowFilePath(filename)).catch(() => null); + if (existing && !deletedWorkflowFiles.includes(filename)) throw new Error("workflow with this name already exists"); await writeFile(filepath, JSON.stringify(data, null, 2)); + config.deletedWorkflowFiles = deletedWorkflowFiles.filter((file) => file !== filename); if (!isDraft && !getWorkflow()) { loadWorkflow(filepath); setActiveWorkflowFile(filename); - const config = await readConfig(); config.activeWorkflow = filename; - await saveConfig(config); } + await saveConfig(config); return { filename, name: data.name }; } @@ -262,6 +270,9 @@ async function updateWorkflowFile(filename, workflow, isDraft) { if (!String(data.name || "").trim()) throw new Error("workflow name is required"); const workflowDir = await ensureWorkflowDir(); await writeFile(join(workflowDir, safeFilename), JSON.stringify(data, null, 2)); + const config = await readConfig(); + config.deletedWorkflowFiles = (config.deletedWorkflowFiles || []).filter((file) => file !== safeFilename); + await saveConfig(config); if (!isDraft && safeFilename === getActiveWorkflowFile()) { loadWorkflow(join(workflowDir, safeFilename)); } @@ -272,30 +283,40 @@ export async function removeWorkflow(filename) { const safeFilename = assertSafeWorkflowFilename(filename); const workflowDir = await ensureWorkflowDir(); const files = (await readdir(workflowDir)).filter((file) => file.endsWith(".json")); - if (!files.includes(safeFilename)) throw new Error("workflow not found"); - - await unlink(join(workflowDir, safeFilename)); + const userFileExists = files.includes(safeFilename); + const targetExists = await stat(getWorkflowFilePath(safeFilename)).catch(() => null); + const systemFileExists = await Promise.all( + getWorkflowDirs().slice(1).map((dir) => stat(join(dir, safeFilename)).catch(() => null)) + ).then((stats) => stats.some(Boolean)); + if (!targetExists) throw new Error("workflow not found"); + + if (userFileExists) { + await unlink(join(workflowDir, safeFilename)); + } + const config = await readConfig(); + if (!userFileExists || systemFileExists) { + config.deletedWorkflowFiles = Array.from(new Set([...(config.deletedWorkflowFiles || []), safeFilename])); + } if (getActiveWorkflowFile() === safeFilename) { - const remaining = files.filter((file) => file !== safeFilename).sort(); - const config = await readConfig(); + const remaining = (await listWorkflows()).workflows.map((workflow) => workflow.filename).filter((file) => file !== safeFilename).sort(); if (remaining.length > 0) { const nextWorkflow = remaining[0]; setActiveWorkflowFile(nextWorkflow); - loadWorkflow(join(workflowDir, nextWorkflow)); + loadWorkflow(getWorkflowFilePath(nextWorkflow)); config.activeWorkflow = nextWorkflow; } else { unloadWorkflow(); delete config.activeWorkflow; } - await saveConfig(config); } + await saveConfig(config); return { ok: true }; } export async function activateWorkflow(filename) { const safeFilename = assertSafeWorkflowFilename(filename); - const workflowDir = await ensureWorkflowDir(); - loadWorkflow(join(workflowDir, safeFilename)); + await ensureWorkflowDir(); + loadWorkflow(getWorkflowFilePath(safeFilename)); setActiveWorkflowFile(safeFilename); const config = await readConfig(); config.activeWorkflow = safeFilename; @@ -339,10 +360,7 @@ export async function addWorkFolder(folderPath) { export async function removeWorkFolder(folderPath) { if (!folderPath) throw new Error("path required"); - let folders = await readWorkfolders(); - folders = folders.filter((folder) => folder.path !== folderPath); - await saveWorkfolders(folders); - return folders; + return removeWorkfolder(folderPath); } export async function getTaskState(taskId, runId = "") { diff --git a/apps/desktop/local-server-controllers/workflowController.ts b/apps/desktop/local-server-controllers/workflowController.ts index 6e1272f..067d777 100644 --- a/apps/desktop/local-server-controllers/workflowController.ts +++ b/apps/desktop/local-server-controllers/workflowController.ts @@ -6,6 +6,7 @@ import { readConfig, saveConfig, getWorkflowDir, + getWorkflowDirs, readAiBackendOverride, readAiApiProfilesForUi, readMobileAccessEnabled, @@ -18,7 +19,7 @@ import { deleteManagedSkill, importManagedSkills, listManagedSkills, saveManaged import { assertSafeWorkflowFilename, getWorkflow, getActiveWorkflowFile, setActiveWorkflowFile, - deriveTaskInputFields, getWorkflowConfigShape, loadWorkflow, unloadWorkflow, + deriveTaskInputFields, getWorkflowConfigShape, getWorkflowFilePath, loadWorkflow, unloadWorkflow, } from "../../../packages/core-models/workflow"; import { validateWorkflowDsl } from "../../../packages/core-lib/langgraph-runtime/index"; @@ -49,6 +50,9 @@ async function writeWorkflowFile(filename, workflow, isDraft) { const workflowDir = await ensureWorkflowDir(); const data = isDraft ? workflow : validateWorkflowDsl(workflow); await writeFile(join(workflowDir, filename), JSON.stringify(data, null, 2)); + const config = await readConfig(); + config.deletedWorkflowFiles = (config.deletedWorkflowFiles || []).filter((file) => file !== filename); + await saveConfig(config); if (!isDraft && filename === getActiveWorkflowFile()) { loadWorkflow(join(workflowDir, filename)); } @@ -222,14 +226,18 @@ router.delete("/settings/ai-api-profiles/:id", async (req, res) => { // --- Workflow CRUD --- router.get("/workflows", async (req, res) => { - const workflowDir = await ensureWorkflowDir(); + await ensureWorkflowDir(); try { - const files = await readdir(workflowDir); + const config = await readConfig(); + const deletedWorkflowFiles = new Set(Array.isArray(config.deletedWorkflowFiles) ? config.deletedWorkflowFiles : []); + const files = Array.from(new Set((await Promise.all( + getWorkflowDirs().map((dir) => readdir(dir).catch(() => [])) + )).flat())).filter((file) => !deletedWorkflowFiles.has(file)).sort(); const workflows = []; for (const f of files) { if (!f.endsWith(".json")) continue; try { - const raw = JSON.parse(await readFile(join(workflowDir, f), "utf-8")); + const raw = JSON.parse(await readFile(getWorkflowFilePath(f), "utf-8")); const workflowConfig = getWorkflowConfigShape(raw); workflows.push({ filename: f, @@ -253,7 +261,7 @@ router.get("/workflows", async (req, res) => { router.get("/workflows/:filename", async (req, res) => { try { const filename = assertSafeWorkflowFilename(req.params.filename); - const raw = await readFile(join(await ensureWorkflowDir(), filename), "utf-8"); + const raw = await readFile(getWorkflowFilePath(filename), "utf-8"); res.json(JSON.parse(raw)); } catch { res.status(400).json({ error: "workflow not found" }); @@ -265,7 +273,7 @@ router.put("/workflows/:filename/visibility", async (req, res) => { const filename = assertSafeWorkflowFilename(req.params.filename); const workflowDir = await ensureWorkflowDir(); const filepath = join(workflowDir, filename); - const workflow = JSON.parse(await readFile(filepath, "utf-8")); + const workflow = JSON.parse(await readFile(getWorkflowFilePath(filename), "utf-8")); workflow.visible = req.body?.visible === true; await writeFile(filepath, JSON.stringify(workflow, null, 2)); if (filename === getActiveWorkflowFile()) loadWorkflow(filepath); @@ -291,18 +299,22 @@ router.post("/workflows", async (req, res) => { } const workflowDir = await ensureWorkflowDir(); const filepath = join(workflowDir, filename); + const config = await readConfig(); + const deletedWorkflowFiles = Array.isArray(config.deletedWorkflowFiles) ? config.deletedWorkflowFiles : []; try { - await stat(filepath); - return res.status(409).json({ error: "workflow with this name already exists" }); + await stat(getWorkflowFilePath(filename)); + if (!deletedWorkflowFiles.includes(filename)) { + return res.status(409).json({ error: "workflow with this name already exists" }); + } } catch {} await writeFile(filepath, JSON.stringify(workflow, null, 2)); + config.deletedWorkflowFiles = deletedWorkflowFiles.filter((file) => file !== filename); if (!isDraft && !getWorkflow()) { loadWorkflow(filepath); setActiveWorkflowFile(filename); - const config = await readConfig(); config.activeWorkflow = filename; - await saveConfig(config); } + await saveConfig(config); res.json({ filename, name: workflow.name }); }); @@ -332,23 +344,37 @@ router.delete("/workflows/:filename", async (req, res) => { const filename = assertSafeWorkflowFilename(req.params.filename); const workflowDir = await ensureWorkflowDir(); const files = (await readdir(workflowDir)).filter((file) => file.endsWith(".json")); - if (!files.includes(filename)) return res.status(404).json({ error: "workflow not found" }); - - await unlink(join(workflowDir, filename)); + const userFileExists = files.includes(filename); + const targetExists = await stat(getWorkflowFilePath(filename)).catch(() => null); + const systemFileExists = await Promise.all( + getWorkflowDirs().slice(1).map((dir) => stat(join(dir, filename)).catch(() => null)) + ).then((stats) => stats.some(Boolean)); + if (!targetExists) return res.status(404).json({ error: "workflow not found" }); + + if (userFileExists) { + await unlink(join(workflowDir, filename)); + } + const config = await readConfig(); + if (!userFileExists || systemFileExists) { + config.deletedWorkflowFiles = Array.from(new Set([...(config.deletedWorkflowFiles || []), filename])); + } if (getActiveWorkflowFile() === filename) { - const remaining = files.filter((file) => file !== filename).sort(); - const config = await readConfig(); + const remaining = Array.from(new Set((await Promise.all( + getWorkflowDirs().map((dir) => readdir(dir).catch(() => [])) + )).flat())) + .filter((file) => file.endsWith(".json") && file !== filename && !(config.deletedWorkflowFiles || []).includes(file)) + .sort(); if (remaining.length > 0) { const nextWorkflow = remaining[0]; setActiveWorkflowFile(nextWorkflow); - loadWorkflow(join(workflowDir, nextWorkflow)); + loadWorkflow(getWorkflowFilePath(nextWorkflow)); config.activeWorkflow = nextWorkflow; } else { unloadWorkflow(); delete config.activeWorkflow; } - await saveConfig(config); } + await saveConfig(config); res.json({ ok: true }); } catch { res.status(404).json({ error: "workflow not found" }); @@ -358,8 +384,8 @@ router.delete("/workflows/:filename", async (req, res) => { router.put("/workflows/:filename/activate", async (req, res) => { try { const filename = assertSafeWorkflowFilename(req.params.filename); - const workflowDir = await ensureWorkflowDir(); - loadWorkflow(join(workflowDir, filename)); + await ensureWorkflowDir(); + loadWorkflow(getWorkflowFilePath(filename)); setActiveWorkflowFile(filename); const config = await readConfig(); config.activeWorkflow = filename; diff --git a/apps/desktop/local-server-controllers/workfoldersController.ts b/apps/desktop/local-server-controllers/workfoldersController.ts index b5233ac..3ac82c1 100644 --- a/apps/desktop/local-server-controllers/workfoldersController.ts +++ b/apps/desktop/local-server-controllers/workfoldersController.ts @@ -3,7 +3,7 @@ import { basename, resolve, join } from "path"; import { stat, mkdir } from "fs/promises"; import { spawn } from "child_process"; import multer from "multer"; -import { readWorkfolders, saveWorkfolders, deleteTask } from "../../../packages/core-models/workfolders"; +import { readWorkfolders, saveWorkfolders, deleteTask, removeWorkfolder } from "../../../packages/core-models/workfolders"; import { getBaseDir } from "../../../packages/core-models/config"; import { assertSafeRunId, readState, getTaskRunId, readPhaseInteractions } from "../../../packages/core-models/state"; import { getPhaseContent, readPhaseOutputArtifacts, stopActiveWorkflow } from "../../../packages/core-lib/claude"; @@ -69,10 +69,7 @@ router.post("/workfolders", async (req, res) => { router.delete("/workfolders", async (req, res) => { const { path: folderPath } = req.body; if (!folderPath) return res.status(400).json({ error: "path required" }); - let folders = await readWorkfolders(); - folders = folders.filter((f) => f.path !== folderPath); - await saveWorkfolders(folders); - res.json(folders); + res.json(await removeWorkfolder(folderPath)); }); router.get("/tasks/:taskId/state", async (req, res) => { diff --git a/apps/desktop/renderer/src/lib/ai-api-config.test.ts b/apps/desktop/renderer/src/lib/ai-api-config.test.ts index cfc637a..cc83164 100644 --- a/apps/desktop/renderer/src/lib/ai-api-config.test.ts +++ b/apps/desktop/renderer/src/lib/ai-api-config.test.ts @@ -1,21 +1,42 @@ -import { mkdtemp, rm } from "fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; let configDir = ""; +let testHomeDir = ""; let originalUserDataDir; +let originalHome; +let originalAppData; +let originalXdgConfigHome; async function loadConfigModule() { vi.resetModules(); return import("../../../../../packages/core-models/config"); } +function getTestAppDataDir(homeDir) { + if (process.platform === "darwin") { + return join(homeDir, "Library", "Application Support", "dev-Workflow"); + } + if (process.platform === "win32") { + return join(homeDir, "AppData", "Roaming", "dev-Workflow"); + } + return join(process.env.XDG_CONFIG_HOME || join(homeDir, ".config"), "dev-Workflow"); +} + describe("AI API config", () => { beforeEach(async () => { originalUserDataDir = process.env.DEV_WORKFLOW_USER_DATA_DIR; + originalHome = process.env.HOME; + originalAppData = process.env.APPDATA; + originalXdgConfigHome = process.env.XDG_CONFIG_HOME; configDir = await mkdtemp(join(tmpdir(), "dev-workflow-config-")); + testHomeDir = await mkdtemp(join(tmpdir(), "dev-workflow-home-")); process.env.DEV_WORKFLOW_USER_DATA_DIR = configDir; + process.env.HOME = testHomeDir; + process.env.APPDATA = join(testHomeDir, "AppData", "Roaming"); + process.env.XDG_CONFIG_HOME = join(testHomeDir, ".config"); }); afterEach(async () => { @@ -24,8 +45,25 @@ describe("AI API config", () => { } else { process.env.DEV_WORKFLOW_USER_DATA_DIR = originalUserDataDir; } + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = originalAppData; + } + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } if (configDir) await rm(configDir, { recursive: true, force: true }); + if (testHomeDir) await rm(testHomeDir, { recursive: true, force: true }); configDir = ""; + testHomeDir = ""; }); test("saves AI API profiles with a default OpenAI-compatible base URL and redacts keys for UI", async () => { @@ -92,4 +130,93 @@ describe("AI API config", () => { expect(remainingProfiles.map((profile) => profile.id)).toEqual(["openai"]); expect(await readAiApiProfiles()).toHaveLength(1); }); + + test("merges system AI API profiles with user overrides", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "dev-workflow-home-")); + process.env.HOME = homeDir; + process.env.APPDATA = join(homeDir, "AppData", "Roaming"); + delete process.env.DEV_WORKFLOW_USER_DATA_DIR; + const appDataDir = getTestAppDataDir(homeDir); + await mkdir(appDataDir, { recursive: true }); + await writeFile( + join(appDataDir, "config.json"), + JSON.stringify({ + aiBackendOverride: "codex", + aiApiProfiles: [ + { + id: "deepseek", + name: "deepseek", + baseUrl: "https://api.deepseek.com", + apiKey: "sk-deepseek", + model: "deepseek-v4-flash", + }, + ], + }, null, 2) + ); + + const { CONFIG_FILE, readAiApiProfilesForUi, readAiBackendOverride, saveAiApiProfile } = await loadConfigModule(); + + expect(await readAiBackendOverride()).toBe("codex"); + expect(await readAiApiProfilesForUi()).toEqual([ + { + id: "deepseek", + name: "deepseek", + baseUrl: "https://api.deepseek.com", + model: "deepseek-v4-flash", + hasApiKey: true, + }, + ]); + + await saveAiApiProfile({ + id: "deepseek", + name: "deepseek", + baseUrl: "https://gateway.example/v1", + apiKey: "", + model: "deepseek-chat", + }); + + expect(await readAiApiProfilesForUi()).toEqual([ + { + id: "deepseek", + name: "deepseek", + baseUrl: "https://gateway.example/v1", + model: "deepseek-chat", + hasApiKey: true, + }, + ]); + expect(JSON.parse(await readFile(CONFIG_FILE, "utf-8")).aiApiProfiles).toHaveLength(1); + + await rm(homeDir, { recursive: true, force: true }); + }); + + test("stores a user deletion marker for system AI API profiles", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "dev-workflow-home-")); + process.env.HOME = homeDir; + process.env.APPDATA = join(homeDir, "AppData", "Roaming"); + delete process.env.DEV_WORKFLOW_USER_DATA_DIR; + const appDataDir = getTestAppDataDir(homeDir); + await mkdir(appDataDir, { recursive: true }); + await writeFile( + join(appDataDir, "config.json"), + JSON.stringify({ + aiApiProfiles: [ + { + id: "deepseek", + name: "deepseek", + baseUrl: "https://api.deepseek.com", + apiKey: "sk-deepseek", + model: "deepseek-v4-flash", + }, + ], + }, null, 2) + ); + + const { CONFIG_FILE, deleteAiApiProfile, readAiApiProfilesForUi } = await loadConfigModule(); + + expect(await deleteAiApiProfile("deepseek")).toEqual([]); + expect(await readAiApiProfilesForUi()).toEqual([]); + expect(JSON.parse(await readFile(CONFIG_FILE, "utf-8")).deletedAiApiProfileIds).toEqual(["deepseek"]); + + await rm(homeDir, { recursive: true, force: true }); + }); }); diff --git a/apps/desktop/renderer/src/lib/skills-storage.test.ts b/apps/desktop/renderer/src/lib/skills-storage.test.ts new file mode 100644 index 0000000..62eeaf0 --- /dev/null +++ b/apps/desktop/renderer/src/lib/skills-storage.test.ts @@ -0,0 +1,112 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +let userDataDir = ""; +let homeDir = ""; +let originalUserDataDir; +let originalHome; +let originalAppData; +let originalXdgConfigHome; + +async function loadModules() { + vi.resetModules(); + const config = await import("../../../../../packages/core-models/config"); + const skills = await import("../../../../../packages/core-models/skills"); + return { config, skills }; +} + +async function writeSkill(root, slug, name, description, body) { + const dir = join(root, slug); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, "SKILL.md"), + [ + "---", + `name: "${name}"`, + `description: "${description}"`, + "---", + "", + body, + "", + ].join("\n") + ); +} + +describe("managed skills storage", () => { + beforeEach(async () => { + originalUserDataDir = process.env.DEV_WORKFLOW_USER_DATA_DIR; + originalHome = process.env.HOME; + originalAppData = process.env.APPDATA; + originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + userDataDir = await mkdtemp(join(tmpdir(), "dev-workflow-user-data-")); + homeDir = await mkdtemp(join(tmpdir(), "dev-workflow-home-")); + process.env.DEV_WORKFLOW_USER_DATA_DIR = userDataDir; + process.env.HOME = homeDir; + process.env.APPDATA = join(homeDir, "AppData", "Roaming"); + process.env.XDG_CONFIG_HOME = join(homeDir, ".config"); + }); + + afterEach(async () => { + if (originalUserDataDir === undefined) { + delete process.env.DEV_WORKFLOW_USER_DATA_DIR; + } else { + process.env.DEV_WORKFLOW_USER_DATA_DIR = originalUserDataDir; + } + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = originalAppData; + } + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + if (userDataDir) await rm(userDataDir, { recursive: true, force: true }); + if (homeDir) await rm(homeDir, { recursive: true, force: true }); + userDataDir = ""; + homeDir = ""; + }); + + test("merges system skills with user overrides", async () => { + const { config, skills } = await loadModules(); + await writeSkill(config.getSystemSkillsDir(), "review", "review", "System review", "system body"); + await writeSkill(config.getSkillsDir(), "review", "review", "User review", "user body"); + await writeSkill(config.getSkillsDir(), "local", "local", "Local only", "local body"); + + const managedSkills = await skills.listManagedSkills(); + const review = managedSkills.find((skill) => skill.slug === "review"); + const local = managedSkills.find((skill) => skill.slug === "local"); + + expect(review).toMatchObject({ name: "review", description: "User review", body: "user body" }); + expect(local).toMatchObject({ name: "local", description: "Local only", body: "local body" }); + expect(skills.readManagedSkillContentSync("review")).toContain("user body"); + }); + + test("stores user deletion markers for system skills", async () => { + const { config, skills } = await loadModules(); + await writeSkill(config.getSystemSkillsDir(), "review", "review", "System review", "system body"); + + await skills.deleteManagedSkill("review"); + + expect((await skills.listManagedSkills()).some((skill) => skill.slug === "review")).toBe(false); + await expect(stat(join(config.getSystemSkillsDir(), "review", "SKILL.md"))).resolves.toBeTruthy(); + expect(await readFile(join(config.getSkillsDir(), "review", ".deleted"), "utf-8")).toBe(""); + + await skills.saveManagedSkill({ + name: "review", + description: "Restored", + body: "restored body", + }); + + expect(skills.readManagedSkillContentSync("review")).toContain("restored body"); + await expect(stat(join(config.getSkillsDir(), "review", ".deleted"))).rejects.toBeTruthy(); + }); +}); diff --git a/apps/desktop/renderer/src/lib/workflow-storage.test.ts b/apps/desktop/renderer/src/lib/workflow-storage.test.ts new file mode 100644 index 0000000..bf833d9 --- /dev/null +++ b/apps/desktop/renderer/src/lib/workflow-storage.test.ts @@ -0,0 +1,97 @@ +import { mkdir, mkdtemp, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +let userDataDir = ""; +let homeDir = ""; +let originalUserDataDir; +let originalHome; +let originalAppData; +let originalXdgConfigHome; + +async function loadModules() { + vi.resetModules(); + const config = await import("../../../../../packages/core-models/config"); + const workflow = await import("../../../../../packages/core-models/workflow"); + return { config, workflow }; +} + +function buildWorkflow(name) { + return { + id: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"), + name, + version: 1, + runtime: { + engine: "langgraph", + backend: "claude", + workspaceAccess: "read", + options: {}, + }, + steps: [ + { + id: "done", + type: "end", + label: "Done", + }, + ], + }; +} + +async function writeWorkflow(dir, filename, name) { + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, filename), JSON.stringify(buildWorkflow(name), null, 2)); +} + +describe("workflow storage", () => { + beforeEach(async () => { + originalUserDataDir = process.env.DEV_WORKFLOW_USER_DATA_DIR; + originalHome = process.env.HOME; + originalAppData = process.env.APPDATA; + originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + userDataDir = await mkdtemp(join(tmpdir(), "dev-workflow-user-data-")); + homeDir = await mkdtemp(join(tmpdir(), "dev-workflow-home-")); + process.env.DEV_WORKFLOW_USER_DATA_DIR = userDataDir; + process.env.HOME = homeDir; + process.env.APPDATA = join(homeDir, "AppData", "Roaming"); + process.env.XDG_CONFIG_HOME = join(homeDir, ".config"); + }); + + afterEach(async () => { + if (originalUserDataDir === undefined) { + delete process.env.DEV_WORKFLOW_USER_DATA_DIR; + } else { + process.env.DEV_WORKFLOW_USER_DATA_DIR = originalUserDataDir; + } + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = originalAppData; + } + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + if (userDataDir) await rm(userDataDir, { recursive: true, force: true }); + if (homeDir) await rm(homeDir, { recursive: true, force: true }); + userDataDir = ""; + homeDir = ""; + }); + + test("uses user workflows before system workflows", async () => { + const { config, workflow } = await loadModules(); + await writeWorkflow(config.getSystemWorkflowDir(), "default.json", "System Default"); + await writeWorkflow(config.getWorkflowDir(), "default.json", "User Default"); + await writeWorkflow(config.getSystemWorkflowDir(), "system-only.json", "System Only"); + + expect(workflow.getWorkflowFilePath("default.json")).toBe(join(config.getWorkflowDir(), "default.json")); + expect(workflow.getWorkflowFilePath("system-only.json")).toBe(join(config.getSystemWorkflowDir(), "system-only.json")); + expect(workflow.readWorkflowFileSync("default.json").name).toBe("User Default"); + }); +}); diff --git a/apps/desktop/renderer/src/lib/workfolders-storage.test.ts b/apps/desktop/renderer/src/lib/workfolders-storage.test.ts new file mode 100644 index 0000000..c0cedde --- /dev/null +++ b/apps/desktop/renderer/src/lib/workfolders-storage.test.ts @@ -0,0 +1,111 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { dirname, join } from "path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +let userDataDir = ""; +let homeDir = ""; +let originalUserDataDir; +let originalHome; +let originalAppData; +let originalXdgConfigHome; + +async function loadModules() { + vi.resetModules(); + const config = await import("../../../../../packages/core-models/config"); + const workfolders = await import("../../../../../packages/core-models/workfolders"); + return { config, workfolders }; +} + +async function writeWorkfolders(file, folders) { + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, JSON.stringify(folders, null, 2)); +} + +describe("workfolders storage", () => { + beforeEach(async () => { + originalUserDataDir = process.env.DEV_WORKFLOW_USER_DATA_DIR; + originalHome = process.env.HOME; + originalAppData = process.env.APPDATA; + originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + userDataDir = await mkdtemp(join(tmpdir(), "dev-workflow-user-data-")); + homeDir = await mkdtemp(join(tmpdir(), "dev-workflow-home-")); + process.env.DEV_WORKFLOW_USER_DATA_DIR = userDataDir; + process.env.HOME = homeDir; + process.env.APPDATA = join(homeDir, "AppData", "Roaming"); + process.env.XDG_CONFIG_HOME = join(homeDir, ".config"); + }); + + afterEach(async () => { + if (originalUserDataDir === undefined) { + delete process.env.DEV_WORKFLOW_USER_DATA_DIR; + } else { + process.env.DEV_WORKFLOW_USER_DATA_DIR = originalUserDataDir; + } + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = originalAppData; + } + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + if (userDataDir) await rm(userDataDir, { recursive: true, force: true }); + if (homeDir) await rm(homeDir, { recursive: true, force: true }); + userDataDir = ""; + homeDir = ""; + }); + + test("merges system workfolders with user folders", async () => { + const { config, workfolders } = await loadModules(); + const userBaseDir = await config.getBaseDir(); + const systemBaseDir = config.getSystemBaseDir(); + await writeWorkfolders(config.getWorkfoldersFile(systemBaseDir), [ + { name: "System", path: "/tmp/system", tasks: [{ taskId: "system-task", status: "running" }] }, + { name: "Shared", path: "/tmp/shared", tasks: [] }, + ]); + await writeWorkfolders(config.getWorkfoldersFile(userBaseDir), [ + { name: "Shared User", path: "/tmp/shared", tasks: [{ taskId: "user-task", status: "done" }] }, + { name: "User", path: "/tmp/user", tasks: [] }, + ]); + + expect(await workfolders.readWorkfolders()).toEqual([ + { name: "System", path: "/tmp/system", tasks: [] }, + { name: "Shared User", path: "/tmp/shared", tasks: [{ taskId: "user-task", runId: "user-task", status: "done" }] }, + { name: "User", path: "/tmp/user", tasks: [] }, + ]); + }); + + test("stores user deletion markers for system workfolders", async () => { + const { config, workfolders } = await loadModules(); + const userBaseDir = await config.getBaseDir(); + const systemBaseDir = config.getSystemBaseDir(); + await writeWorkfolders(config.getWorkfoldersFile(systemBaseDir), [ + { name: "System", path: "/tmp/system", tasks: [] }, + ]); + + expect(await workfolders.removeWorkfolder("/tmp/system")).toEqual([]); + expect(JSON.parse(await readFile(config.getWorkfoldersFile(userBaseDir), "utf-8"))).toEqual([ + { name: "System", path: "/tmp/system", tasks: [], deleted: true }, + ]); + + await workfolders.saveWorkfolders([ + { name: "User", path: "/tmp/user", tasks: [] }, + ]); + + expect(await workfolders.readWorkfolders()).toEqual([ + { name: "User", path: "/tmp/user", tasks: [] }, + ]); + expect(JSON.parse(await readFile(config.getWorkfoldersFile(userBaseDir), "utf-8"))).toEqual([ + { name: "System", path: "/tmp/system", tasks: [], deleted: true }, + { name: "User", path: "/tmp/user", tasks: [] }, + ]); + }); +}); diff --git a/packages/core-models/config.ts b/packages/core-models/config.ts index 5cf8161..9cbc66b 100644 --- a/packages/core-models/config.ts +++ b/packages/core-models/config.ts @@ -9,7 +9,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = join(__dirname, "..", ".."); let runtimeBaseDir = ""; let runtimeStorageDir = ""; -export const LEGACY_WORKFLOW_DIR = join(PROJECT_ROOT, "workflows"); function getPlatformUserDataDir() { switch (process.platform) { @@ -50,6 +49,11 @@ export function getDefaultDesktopUserDataDir() { return isDevelopmentCheckout() ? getWorktreeScopedUserDataDir(baseDir) : baseDir; } +export function getSystemDesktopUserDataDir() { + return getSharedDesktopUserDataDir(); +} + +export const SYSTEM_CONFIG_FILE = join(getSystemDesktopUserDataDir(), "config.json"); export const CONFIG_FILE = join(getDefaultDesktopUserDataDir(), "config.json"); function parseJson(raw) { @@ -76,21 +80,111 @@ async function readJsonFile(path) { } } +function hasOwn(object, key) { + return Object.prototype.hasOwnProperty.call(object, key); +} + +function mergeAiApiProfiles(systemProfiles = [], userProfiles = [], deletedProfileIds = []) { + const deleted = new Set((Array.isArray(deletedProfileIds) ? deletedProfileIds : []).map((id) => slugifyConfigId(id, ""))); + const merged = normalizeAiApiProfiles(systemProfiles).filter((profile) => !deleted.has(profile.id)); + const byId = new Map(merged.map((profile, index) => [profile.id, index])); + + for (const profile of normalizeAiApiProfiles(userProfiles)) { + const index = byId.get(profile.id); + if (index === undefined) { + byId.set(profile.id, merged.length); + merged.push(profile); + } else { + merged[index] = profile; + } + } + + return merged; +} + +function mergeConfigLayers(systemConfig = {}, userConfig = {}) { + const merged = { + ...systemConfig, + ...userConfig, + }; + merged.aiApiProfiles = mergeAiApiProfiles( + systemConfig.aiApiProfiles, + userConfig.aiApiProfiles, + userConfig.deletedAiApiProfileIds + ); + return merged; +} + +function sameAiApiProfile(a, b) { + return ( + a.id === b.id && + a.name === b.name && + a.baseUrl === b.baseUrl && + a.apiKey === b.apiKey && + a.model === b.model + ); +} + +function buildUserConfig(config, systemConfig) { + const userConfig = {}; + const systemProfiles = normalizeAiApiProfiles(systemConfig.aiApiProfiles); + const nextProfiles = normalizeAiApiProfiles(config.aiApiProfiles); + const nextProfileIds = new Set(nextProfiles.map((profile) => profile.id)); + const systemProfileById = new Map(systemProfiles.map((profile) => [profile.id, profile])); + const userProfiles = nextProfiles.filter((profile) => { + const systemProfile = systemProfileById.get(profile.id); + return !systemProfile || !sameAiApiProfile(profile, systemProfile); + }); + const deletedAiApiProfileIds = systemProfiles + .filter((profile) => !nextProfileIds.has(profile.id)) + .map((profile) => profile.id); + + for (const key of ["activeWorkflow", "mobileAccessEnabled", "aiBackendOverride"]) { + if (hasOwn(config, key) && config[key] !== systemConfig[key]) { + userConfig[key] = config[key]; + } + } + if (Array.isArray(config.deletedWorkflowFiles) && config.deletedWorkflowFiles.length > 0) { + userConfig.deletedWorkflowFiles = config.deletedWorkflowFiles; + } + if (userProfiles.length > 0) userConfig.aiApiProfiles = userProfiles; + if (deletedAiApiProfileIds.length > 0) userConfig.deletedAiApiProfileIds = deletedAiApiProfileIds; + return userConfig; +} + async function ensureConfigDir() { await mkdir(dirname(CONFIG_FILE), { recursive: true }); } -export function readConfigSync() { +export function readSystemConfigSync() { + return readJsonFileSync(SYSTEM_CONFIG_FILE) || {}; +} + +export async function readSystemConfig() { + return (await readJsonFile(SYSTEM_CONFIG_FILE)) || {}; +} + +export function readUserConfigSync() { return readJsonFileSync(CONFIG_FILE) || {}; } -export async function readConfig() { +export async function readUserConfig() { return (await readJsonFile(CONFIG_FILE)) || {}; } +export function readConfigSync() { + return mergeConfigLayers(readSystemConfigSync(), readUserConfigSync()); +} + +export async function readConfig() { + return mergeConfigLayers(await readSystemConfig(), await readUserConfig()); +} + export async function saveConfig(config) { + const systemConfig = await readSystemConfig(); + const userConfig = buildUserConfig(config || {}, systemConfig); await ensureConfigDir(); - await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2)); + await writeFile(CONFIG_FILE, JSON.stringify(userConfig, null, 2)); } export async function readMobileAccessEnabled() { @@ -207,7 +301,7 @@ export async function saveAiApiProfile(profile) { nextProfiles.push(normalized); config.aiApiProfiles = nextProfiles; await saveConfig(config); - return nextProfiles.map(redactAiApiProfile); + return readAiApiProfilesForUi(); } export async function deleteAiApiProfile(id) { @@ -216,7 +310,7 @@ export async function deleteAiApiProfile(id) { const config = await readConfig(); config.aiApiProfiles = normalizeAiApiProfiles(config.aiApiProfiles).filter((profile) => profile.id !== profileId); await saveConfig(config); - return config.aiApiProfiles.map(redactAiApiProfile); + return readAiApiProfilesForUi(); } export function setRuntimeBaseDir(baseDir) { @@ -235,12 +329,36 @@ export async function getBaseDir() { return runtimeBaseDir || join(getStorageDir(), "tasks"); } +export function getSystemBaseDir() { + return join(getSystemDesktopUserDataDir(), "tasks"); +} + export function getSkillsDir() { return join(getStorageDir(), "skills"); } +export function getSystemSkillsDir() { + return join(getSystemDesktopUserDataDir(), "skills"); +} + +export function getSkillsDirs() { + const userDir = getSkillsDir(); + const systemDir = getSystemSkillsDir(); + return userDir === systemDir ? [userDir] : [userDir, systemDir]; +} + export function getWorkflowDir() { - return join(getSharedDesktopUserDataDir(), "workflows"); + return join(getStorageDir(), "workflows"); +} + +export function getSystemWorkflowDir() { + return join(getSystemDesktopUserDataDir(), "workflows"); +} + +export function getWorkflowDirs() { + const userDir = getWorkflowDir(); + const systemDir = getSystemWorkflowDir(); + return userDir === systemDir ? [userDir] : [userDir, systemDir]; } export function getWorkfoldersFile(baseDir) { diff --git a/packages/core-models/skills.ts b/packages/core-models/skills.ts index 9558a5f..c455954 100644 --- a/packages/core-models/skills.ts +++ b/packages/core-models/skills.ts @@ -1,7 +1,7 @@ import { basename, join } from "path"; import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "fs/promises"; import { existsSync, readFileSync } from "fs"; -import { getSkillsDir } from "./config"; +import { getSkillsDir, getSkillsDirs, getSystemSkillsDir } from "./config"; function slugify(name) { return String(name || "") @@ -17,14 +17,33 @@ function normalizeContent(content) { .replace(/\r\n/g, "\n"); } -function skillDir(slug) { +const DELETED_MARKER = ".deleted"; + +function skillDir(slug, root = getSkillsDir()) { const cleanSlug = slugify(slug); if (!cleanSlug) throw new Error("skill name is required"); - return join(getSkillsDir(), cleanSlug); + return join(root, cleanSlug); +} + +function skillFile(slug, root = getSkillsDir()) { + return join(skillDir(slug, root), "SKILL.md"); +} + +function deletedSkillMarker(slug) { + return join(skillDir(slug), DELETED_MARKER); } -function skillFile(slug) { - return join(skillDir(slug), "SKILL.md"); +function isSkillDeleted(slug) { + return existsSync(deletedSkillMarker(slug)); +} + +function findSkillFile(slug) { + if (isSkillDeleted(slug)) return ""; + for (const root of getSkillsDirs()) { + const file = skillFile(slug, root); + if (existsSync(file)) return file; + } + return ""; } function parseSkillContent(content) { @@ -86,17 +105,17 @@ const DEFAULT_MANAGED_SKILLS = [ ]; async function ensureDefaultManagedSkills() { - await mkdir(getSkillsDir(), { recursive: true }); + await mkdir(getSystemSkillsDir(), { recursive: true }); for (const skill of DEFAULT_MANAGED_SKILLS) { - const targetFile = skillFile(skill.slug); + const targetFile = skillFile(skill.slug, getSystemSkillsDir()); if (existsSync(targetFile)) continue; - await mkdir(skillDir(skill.slug), { recursive: true }); + await mkdir(skillDir(skill.slug, getSystemSkillsDir()), { recursive: true }); await writeFile(targetFile, buildSkillContent(skill)); } } -async function readSkill(slug) { - const content = await readFile(skillFile(slug), "utf-8"); +async function readSkill(slug, file = findSkillFile(slug)) { + const content = await readFile(file, "utf-8"); const parsed = parseSkillContent(content); return { @@ -137,7 +156,7 @@ async function copySkillDir(sourceDir) { let slug = baseSlug; let suffix = 2; - while (existsSync(skillDir(slug))) { + while (existsSync(skillDir(slug)) || findSkillFile(slug)) { slug = `${baseSlug}-${suffix}`; suffix += 1; } @@ -149,25 +168,36 @@ async function copySkillDir(sourceDir) { export async function listManagedSkills() { await ensureDefaultManagedSkills(); - const root = getSkillsDir(); - const entries = await readdir(root, { withFileTypes: true }).catch(() => []); - const skills = []; - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - try { - skills.push(await readSkill(entry.name)); - } catch {} + const skillsBySlug = new Map(); + + for (const root of getSkillsDirs().slice().reverse()) { + const entries = await readdir(root, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const slug = slugify(entry.name); + if (!slug) continue; + if (isSkillDeleted(slug)) { + skillsBySlug.delete(slug); + continue; + } + const file = skillFile(slug, root); + if (!existsSync(file)) continue; + try { + skillsBySlug.set(slug, await readSkill(slug, file)); + } catch {} + } } - return skills.sort((a, b) => a.name.localeCompare(b.name)); + return Array.from(skillsBySlug.values()).sort((a, b) => a.name.localeCompare(b.name)); } export function readManagedSkillContentSync(ref) { const slug = slugify(ref); if (!slug) return ""; + const file = findSkillFile(slug); + if (!file) return ""; try { - const content = readFileSync(skillFile(slug), "utf-8"); + const content = readFileSync(file, "utf-8"); const parsed = parseSkillContent(content); return buildSkillContent({ name: parsed.name || slug, @@ -196,14 +226,21 @@ export async function saveManagedSkill(input) { await mkdir(getSkillsDir(), { recursive: true }); - if (oldDir && oldDir !== targetDir && existsSync(oldDir)) { - if (existsSync(targetDir)) { + if (oldSlug && oldSlug !== slug) { + if (findSkillFile(slug)) { throw new Error("skill with this name already exists"); } - await rm(oldDir, { recursive: true, force: true }); + if (existsSync(oldDir)) { + await rm(oldDir, { recursive: true, force: true }); + } + if (existsSync(skillDir(oldSlug, getSystemSkillsDir()))) { + await mkdir(oldDir, { recursive: true }); + await writeFile(deletedSkillMarker(oldSlug), ""); + } } await mkdir(targetDir, { recursive: true }); + await rm(deletedSkillMarker(slug), { force: true }); await writeFile(targetFile, content); return readSkill(slug); } @@ -212,6 +249,10 @@ export async function deleteManagedSkill(slug) { const cleanSlug = slugify(slug); if (!cleanSlug) throw new Error("skill name is required"); await rm(skillDir(cleanSlug), { recursive: true, force: true }); + if (existsSync(skillDir(cleanSlug, getSystemSkillsDir()))) { + await mkdir(skillDir(cleanSlug), { recursive: true }); + await writeFile(deletedSkillMarker(cleanSlug), ""); + } } export async function importManagedSkills(sourcePath) { diff --git a/packages/core-models/workflow.ts b/packages/core-models/workflow.ts index a3050e8..6004f77 100644 --- a/packages/core-models/workflow.ts +++ b/packages/core-models/workflow.ts @@ -1,6 +1,6 @@ import { join } from "path"; -import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync } from "fs"; -import { readConfigSync, LEGACY_WORKFLOW_DIR, getWorkflowDir } from "./config"; +import { existsSync, readFileSync, readdirSync } from "fs"; +import { readConfigSync, getWorkflowDir, getWorkflowDirs } from "./config"; import { validateWorkflowDsl } from "../core-lib/langgraph-runtime/index"; let WORKFLOW = null; @@ -40,24 +40,17 @@ export function assertSafeWorkflowFilename(filename) { } export function readWorkflowFileSync(filename) { - const safeFilename = assertSafeWorkflowFilename(filename); - const raw = JSON.parse(readFileSync(join(getWorkflowDir(), safeFilename), "utf-8")); + const raw = JSON.parse(readFileSync(getWorkflowFilePath(filename), "utf-8")); return validateWorkflowDsl(raw); } -function migrateLegacyWorkflowsSync() { - const workflowDir = getWorkflowDir(); - mkdirSync(workflowDir, { recursive: true }); - - const legacyFiles = listWorkflowFilesSync(LEGACY_WORKFLOW_DIR); - for (const file of legacyFiles) { - const sourcePath = join(LEGACY_WORKFLOW_DIR, file); - const targetPath = join(workflowDir, file); - if (existsSync(targetPath)) continue; - try { - renameSync(sourcePath, targetPath); - } catch {} +export function getWorkflowFilePath(filename) { + const safeFilename = assertSafeWorkflowFilename(filename); + for (const dir of getWorkflowDirs()) { + const filepath = join(dir, safeFilename); + if (existsSync(filepath)) return filepath; } + return join(getWorkflowDir(), safeFilename); } function getStepBackend(workflow, step) { @@ -282,14 +275,16 @@ try { } catch {} try { - migrateLegacyWorkflowsSync(); - const workflowDir = getWorkflowDir(); - const files = listWorkflowFilesSync(workflowDir); + const startupConfig = readConfigSync(); + const deletedWorkflowFiles = new Set(Array.isArray(startupConfig.deletedWorkflowFiles) ? startupConfig.deletedWorkflowFiles : []); + const files = Array.from(new Set(getWorkflowDirs().flatMap(listWorkflowFilesSync))) + .filter((file) => !deletedWorkflowFiles.has(file)) + .sort(); if (!files.includes(ACTIVE_WORKFLOW_FILE)) { ACTIVE_WORKFLOW_FILE = files[0] || ""; } if (ACTIVE_WORKFLOW_FILE) { - loadWorkflow(join(workflowDir, ACTIVE_WORKFLOW_FILE)); + loadWorkflow(getWorkflowFilePath(ACTIVE_WORKFLOW_FILE)); } else { clearWorkflowState(); } diff --git a/packages/core-models/workfolders.ts b/packages/core-models/workfolders.ts index 7e4d538..ff53c43 100644 --- a/packages/core-models/workfolders.ts +++ b/packages/core-models/workfolders.ts @@ -1,6 +1,6 @@ import { dirname, join } from "path"; import { readFile, writeFile, mkdir, rm } from "fs/promises"; -import { getBaseDir, getWorkfoldersFile } from "./config"; +import { getBaseDir, getSystemBaseDir, getWorkfoldersFile } from "./config"; import { assertSafeRunId, readState, getTaskRunId, writeState } from "./state"; import { removeWorktree } from "../core-lib/worktree"; @@ -25,17 +25,114 @@ function normalizeWorkfolders(folders) { })); } +async function readWorkfoldersFile(file) { + try { + return normalizeWorkfolders(JSON.parse(await readFile(file, "utf-8"))); + } catch { + return null; + } +} + +function stripTasks(folders) { + return folders.map((folder) => ({ + ...folder, + tasks: [], + })); +} + +function mergeWorkfolders(systemFolders, userFolders) { + const deletedPaths = new Set( + userFolders + .filter((folder) => folder?.deleted === true && folder.path) + .map((folder) => folder.path) + ); + const merged = []; + const seen = new Set(); + + for (const folder of stripTasks(systemFolders).filter((folder) => !deletedPaths.has(folder.path))) { + if (!folder.path || seen.has(folder.path)) continue; + seen.add(folder.path); + merged.push(folder); + } + + for (const folder of userFolders.filter((folder) => folder?.deleted !== true)) { + if (!folder.path) continue; + const index = merged.findIndex((item) => item.path === folder.path); + if (index >= 0) { + merged[index] = folder; + } else { + merged.push(folder); + } + } + + return merged; +} + +function sameWorkfolder(a, b) { + return JSON.stringify(a) === JSON.stringify(b); +} + +async function readUserWorkfolders() { + const baseDir = await getBaseDir(); + return await readWorkfoldersFile(getWorkfoldersFile(baseDir)) || []; +} + +async function readSystemWorkfolders() { + const userFile = getWorkfoldersFile(await getBaseDir()); + const systemFile = getWorkfoldersFile(getSystemBaseDir()); + if (userFile === systemFile) return []; + return await readWorkfoldersFile(systemFile) || []; +} + export async function readWorkfolders() { const baseDir = await getBaseDir(); - const file = getWorkfoldersFile(baseDir); - try { return normalizeWorkfolders(JSON.parse(await readFile(file, "utf-8"))); } catch { return []; } + const userFile = getWorkfoldersFile(baseDir); + const systemFile = getWorkfoldersFile(getSystemBaseDir()); + const userFolders = await readUserWorkfolders(); + if (userFile === systemFile) return userFolders.filter((folder) => folder?.deleted !== true); + + const systemFolders = await readSystemWorkfolders(); + return mergeWorkfolders(systemFolders, userFolders); } export async function saveWorkfolders(folders) { const baseDir = await getBaseDir(); const file = getWorkfoldersFile(baseDir); + const existingUserFolders = await readUserWorkfolders(); + const systemFolders = stripTasks(await readSystemWorkfolders()); + const normalizedFolders = normalizeWorkfolders(folders); + const deletedFolders = [ + ...existingUserFolders.filter((folder) => folder?.deleted === true && folder.path), + ...normalizedFolders.filter((folder) => folder?.deleted === true && folder.path), + ]; + const visibleFolders = normalizedFolders.filter((folder) => folder?.deleted !== true); + const userFolders = visibleFolders.filter((folder) => { + const systemFolder = systemFolders.find((item) => item.path === folder.path); + return !systemFolder || !sameWorkfolder(folder, systemFolder); + }); + const deletedByPath = new Map(deletedFolders.map((folder) => [folder.path, folder])); + const nextFolders = [ + ...Array.from(deletedByPath.values()).filter((deleted) => !visibleFolders.some((folder) => folder.path === deleted.path)), + ...userFolders, + ]; await mkdir(dirname(file), { recursive: true }); - await writeFile(file, JSON.stringify(folders, null, 2)); + await writeFile(file, JSON.stringify(nextFolders, null, 2)); +} + +export async function removeWorkfolder(folderPath) { + const userFolders = await readUserWorkfolders(); + const systemFolder = (await readSystemWorkfolders()).find((folder) => folder.path === folderPath); + const nextFolders = userFolders.filter((folder) => folder.path !== folderPath); + if (systemFolder) { + nextFolders.push({ + name: systemFolder.name, + path: folderPath, + tasks: [], + deleted: true, + }); + } + await saveWorkfolders(nextFolders); + return readWorkfolders(); } export async function upsertTask(workFolder, taskId, status, runId = "", options = {}) {