Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 41 additions & 23 deletions apps/desktop/electron/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
saveConfig,
getBaseDir,
getWorkflowDir,
getWorkflowDirs,
readAiBackendOverride,
readAiApiProfilesForUi,
readMobileAccessEnabled,
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -129,15 +135,15 @@ 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);
}

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

Expand Down Expand Up @@ -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));
}
Expand All @@ -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;
Expand Down Expand Up @@ -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 = "") {
Expand Down
64 changes: 45 additions & 19 deletions apps/desktop/local-server-controllers/workflowController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
readConfig,
saveConfig,
getWorkflowDir,
getWorkflowDirs,
readAiBackendOverride,
readAiApiProfilesForUi,
readMobileAccessEnabled,
Expand All @@ -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";

Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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,
Expand All @@ -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" });
Expand All @@ -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);
Expand All @@ -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 });
});

Expand Down Expand Up @@ -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" });
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down
Loading
Loading