diff --git a/apps/desktop/electron/api.ts b/apps/desktop/electron/api.ts
index 3de3cfe..a6812b9 100644
--- a/apps/desktop/electron/api.ts
+++ b/apps/desktop/electron/api.ts
@@ -8,8 +8,11 @@ import {
getBaseDir,
getWorkflowDir,
readAiBackendOverride,
+ readAiApiProfilesForUi,
readMobileAccessEnabled,
saveAiBackendOverride,
+ saveAiApiProfile,
+ deleteAiApiProfile,
saveMobileAccessEnabled,
} from "../../../packages/core-models/config";
import {
@@ -56,13 +59,18 @@ export async function getWorkflowConfig() {
const workflow = getWorkflow();
const mobileAccessEnabled = await readMobileAccessEnabled();
const aiBackendOverride = await readAiBackendOverride();
+ const aiApiProfiles = await readAiApiProfilesForUi();
if (!workflow) {
- return buildEmptyWorkflowConfig(mobileAccessEnabled, aiBackendOverride);
+ return {
+ ...buildEmptyWorkflowConfig(mobileAccessEnabled, aiBackendOverride),
+ aiApiProfiles,
+ };
}
return {
...getWorkflowConfigShape(workflow),
mobileAccessEnabled,
aiBackendOverride,
+ aiApiProfiles,
};
}
@@ -76,6 +84,20 @@ export async function setAiBackendOverride(backend) {
return getWorkflowConfig();
}
+export async function listAiApiProfiles() {
+ return { profiles: await readAiApiProfilesForUi() };
+}
+
+export async function saveAiApi(profile) {
+ const profiles = await saveAiApiProfile(profile);
+ return { profiles };
+}
+
+export async function deleteAiApi(id) {
+ const profiles = await deleteAiApiProfile(id);
+ return { profiles };
+}
+
export async function listWorkflows() {
const workflowDir = await ensureWorkflowDir();
try {
diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts
index fb5e994..f92142e 100644
--- a/apps/desktop/electron/main.ts
+++ b/apps/desktop/electron/main.ts
@@ -4,12 +4,15 @@ import { mkdirSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { killAllChildren } from "../../../packages/core-lib/claude";
-import { setRuntimeBaseDir, setRuntimeStorageDir } from "../../../packages/core-models/config";
+import { getDefaultDesktopUserDataDir, setRuntimeBaseDir, setRuntimeStorageDir } from "../../../packages/core-models/config";
import {
pickFolder,
getWorkflowConfig,
setMobileAccessEnabled,
setAiBackendOverride,
+ listAiApiProfiles,
+ saveAiApi,
+ deleteAiApi,
listWorkflows,
getWorkflowByFilename,
setWorkflowVisible,
@@ -38,20 +41,25 @@ import {
approveWorkflow,
rejectWorkflow,
sendWorkflowMessage,
- restartWorkflowPhase,
+ resumeWorkflowPhase,
+ retryWorkflowPhase,
pauseWorkflowPhase,
detachWorkflowSender,
} from "./workflow-runtime";
-let mainWindow;
+let mainWindow: BrowserWindow | null = null;
const __dirname = dirname(fileURLToPath(import.meta.url));
const rendererDevServerUrl = process.env.ELECTRON_RENDERER_URL;
const isDev = Boolean(rendererDevServerUrl);
-const userDataDirOverride = process.env.DEV_WORKFLOW_USER_DATA_DIR;
+const userDataDir = getDefaultDesktopUserDataDir();
-if (userDataDirOverride) {
- mkdirSync(userDataDirOverride, { recursive: true });
- app.setPath("userData", userDataDirOverride);
+mkdirSync(userDataDir, { recursive: true });
+app.setPath("userData", userDataDir);
+
+const gotSingleInstanceLock = app.requestSingleInstanceLock();
+
+if (!gotSingleInstanceLock) {
+ app.quit();
}
function spawnDetached(command, args) {
@@ -68,17 +76,39 @@ function spawnDetached(command, args) {
});
}
-async function openInCode(targetPath) {
+const EDITOR_OPENERS = {
+ code: {
+ command: "code",
+ args: (targetPath) => [targetPath],
+ darwinApp: "Visual Studio Code",
+ label: "VS Code",
+ },
+ sublime: {
+ command: "subl",
+ args: (targetPath) => [targetPath],
+ darwinApp: "Sublime Text",
+ label: "Sublime Text",
+ },
+ zed: {
+ command: "zed",
+ args: (targetPath) => [targetPath],
+ darwinApp: "Zed",
+ label: "Zed",
+ },
+};
+
+async function openInEditor(targetPath, editor = "code") {
if (!targetPath) throw new Error("path required");
+ const opener = EDITOR_OPENERS[editor] || EDITOR_OPENERS.code;
try {
- await spawnDetached("code", [targetPath]);
+ await spawnDetached(opener.command, opener.args(targetPath));
return { ok: true };
} catch (error) {
if (process.platform === "darwin") {
- await spawnDetached("open", ["-a", "Visual Studio Code", targetPath]);
+ await spawnDetached("open", ["-a", opener.darwinApp, targetPath]);
return { ok: true };
}
- throw new Error(error?.message || "failed to open VS Code");
+ throw new Error(error?.message || `failed to open ${opener.label}`);
}
}
@@ -132,6 +162,10 @@ async function createWindow() {
} else {
await mainWindow.loadFile(join(__dirname, "..", "renderer", "dist", "index.html"));
}
+
+ mainWindow.on("closed", () => {
+ mainWindow = null;
+ });
}
function registerIpcHandlers() {
@@ -139,6 +173,9 @@ function registerIpcHandlers() {
ipcMain.handle("app:get-workflow-config", () => getWorkflowConfig());
ipcMain.handle("app:set-mobile-access-enabled", (_event, enabled) => setMobileAccessEnabled(enabled));
ipcMain.handle("app:set-ai-backend-override", (_event, backend) => setAiBackendOverride(backend));
+ ipcMain.handle("app:list-ai-api-profiles", () => listAiApiProfiles());
+ ipcMain.handle("app:save-ai-api-profile", (_event, profile) => saveAiApi(profile));
+ ipcMain.handle("app:delete-ai-api-profile", (_event, id) => deleteAiApi(id));
ipcMain.handle("app:list-workflows", () => listWorkflows());
ipcMain.handle("app:get-workflow", (_event, filename) => getWorkflowByFilename(filename));
ipcMain.handle("app:set-workflow-visible", (_event, filename, visible) => setWorkflowVisible(filename, visible));
@@ -157,9 +194,9 @@ function registerIpcHandlers() {
ipcMain.handle("app:add-workfolder", (_event, path) => addWorkFolder(path));
ipcMain.handle("app:remove-workfolder", (_event, path) => removeWorkFolder(path));
ipcMain.handle("app:get-task-state", (_event, taskId, runId) => getTaskState(taskId, runId));
- ipcMain.handle("app:open-in-code", (_event, targetPath) => openInCode(targetPath));
+ ipcMain.handle("app:open-in-code", (_event, targetPath, editor) => openInEditor(targetPath, editor));
ipcMain.handle("app:open-task-output-in-code", async (_event, taskId, runId, phaseId, outputKey) =>
- openInCode(await getTaskOutputPath(taskId, runId, phaseId, outputKey))
+ openInEditor(await getTaskOutputPath(taskId, runId, phaseId, outputKey))
);
ipcMain.handle("app:remove-task", (_event, taskId, runId, options) => removeTask(taskId, runId, options));
ipcMain.handle("app:remove-task-worktree", (_event, taskId, runId) => removeTaskWorktreeOnly(taskId, runId));
@@ -184,8 +221,11 @@ function registerIpcHandlers() {
ipcMain.handle("app:send-workflow-message", (event, taskId, text, images, runId) =>
sendWorkflowMessage(taskId, text, images, (message) => event.sender.send("workflow:event", message), runId)
);
- ipcMain.handle("app:restart-workflow-phase", (event, taskId, phase, runId) =>
- restartWorkflowPhase(taskId, phase, (message) => event.sender.send("workflow:event", message), runId)
+ ipcMain.handle("app:resume-workflow-phase", (event, taskId, phase, runId) =>
+ resumeWorkflowPhase(taskId, phase, (message) => event.sender.send("workflow:event", message), runId)
+ );
+ ipcMain.handle("app:retry-workflow-phase", (event, taskId, phase, runId) =>
+ retryWorkflowPhase(taskId, phase, (message) => event.sender.send("workflow:event", message), runId)
);
ipcMain.handle("app:pause-workflow-phase", (event, taskId, phase, runId) =>
pauseWorkflowPhase(taskId, phase, (message) => event.sender.send("workflow:event", message), runId)
@@ -195,18 +235,26 @@ function registerIpcHandlers() {
});
}
-app.whenReady().then(async () => {
- const userDataDir = app.getPath("userData");
- setRuntimeStorageDir(userDataDir);
- setRuntimeBaseDir(join(userDataDir, "tasks"));
- registerIpcHandlers();
- try {
- await createWindow();
- } catch (err) {
- console.error(err);
- app.quit();
- }
-});
+if (gotSingleInstanceLock) {
+ app.on("second-instance", () => {
+ if (!mainWindow) return;
+ if (mainWindow.isMinimized()) mainWindow.restore();
+ mainWindow.focus();
+ });
+
+ app.whenReady().then(async () => {
+ const userDataDir = app.getPath("userData");
+ setRuntimeStorageDir(userDataDir);
+ setRuntimeBaseDir(join(userDataDir, "tasks"));
+ registerIpcHandlers();
+ try {
+ await createWindow();
+ } catch (err) {
+ console.error(err);
+ app.quit();
+ }
+ });
+}
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
diff --git a/apps/desktop/electron/preload.cts b/apps/desktop/electron/preload.cts
index 80ca3aa..d3ec7db 100644
--- a/apps/desktop/electron/preload.cts
+++ b/apps/desktop/electron/preload.cts
@@ -11,6 +11,9 @@ contextBridge.exposeInMainWorld("desktopApi", {
getWorkflowConfig: () => ipcRenderer.invoke("app:get-workflow-config"),
setMobileAccessEnabled: (enabled) => ipcRenderer.invoke("app:set-mobile-access-enabled", enabled),
setAiBackendOverride: (backend) => ipcRenderer.invoke("app:set-ai-backend-override", backend),
+ listAiApiProfiles: () => ipcRenderer.invoke("app:list-ai-api-profiles"),
+ saveAiApiProfile: (profile) => ipcRenderer.invoke("app:save-ai-api-profile", profile),
+ deleteAiApiProfile: (id) => ipcRenderer.invoke("app:delete-ai-api-profile", id),
listWorkflows: () => ipcRenderer.invoke("app:list-workflows"),
getWorkflow: (filename) => ipcRenderer.invoke("app:get-workflow", filename),
setWorkflowVisible: (filename, visible) => ipcRenderer.invoke("app:set-workflow-visible", filename, visible),
@@ -29,7 +32,7 @@ contextBridge.exposeInMainWorld("desktopApi", {
addWorkFolder: (path) => ipcRenderer.invoke("app:add-workfolder", path),
removeWorkFolder: (path) => ipcRenderer.invoke("app:remove-workfolder", path),
getTaskState: (taskId, runId) => ipcRenderer.invoke("app:get-task-state", taskId, runId),
- openInCode: (targetPath) => ipcRenderer.invoke("app:open-in-code", targetPath),
+ openInCode: (targetPath, editor) => ipcRenderer.invoke("app:open-in-code", targetPath, editor),
openTaskOutputInCode: (taskId, runId, phaseId, outputKey) => ipcRenderer.invoke("app:open-task-output-in-code", taskId, runId, phaseId, outputKey),
removeTask: (taskId, runId, options) => ipcRenderer.invoke("app:remove-task", taskId, runId, options),
removeTaskWorktree: (taskId, runId) => ipcRenderer.invoke("app:remove-task-worktree", taskId, runId),
@@ -38,7 +41,8 @@ contextBridge.exposeInMainWorld("desktopApi", {
approveWorkflow: (taskId, runId) => ipcRenderer.invoke("app:approve-workflow", taskId, runId),
rejectWorkflow: (taskId, rejectTo, reason, runId) => ipcRenderer.invoke("app:reject-workflow", taskId, rejectTo, reason, runId),
sendWorkflowMessage: (taskId, text, images, runId) => ipcRenderer.invoke("app:send-workflow-message", taskId, text, images, runId),
- restartWorkflowPhase: (taskId, phase, runId) => ipcRenderer.invoke("app:restart-workflow-phase", taskId, phase, runId),
+ resumeWorkflowPhase: (taskId, phase, runId) => ipcRenderer.invoke("app:resume-workflow-phase", taskId, phase, runId),
+ retryWorkflowPhase: (taskId, phase, runId) => ipcRenderer.invoke("app:retry-workflow-phase", taskId, phase, runId),
pauseWorkflowPhase: (taskId, phase, runId) => ipcRenderer.invoke("app:pause-workflow-phase", taskId, phase, runId),
onWorkflowEvent: subscribeWorkflowEvents,
detachWorkflow: (taskId, runId) => ipcRenderer.send("workflow:detach", taskId, runId),
diff --git a/apps/desktop/electron/workflow-runtime.ts b/apps/desktop/electron/workflow-runtime.ts
index d9a1a8d..2852cc1 100644
--- a/apps/desktop/electron/workflow-runtime.ts
+++ b/apps/desktop/electron/workflow-runtime.ts
@@ -1,7 +1,8 @@
import { Command, INTERRUPT, isInterrupted } from "@langchain/langgraph";
+import OpenAI from "openai";
import { realpath, stat } from "fs/promises";
import { join, relative, resolve } from "path";
-import { getBaseDir, readAiBackendOverrideSync } from "../../../packages/core-models/config";
+import { getBaseDir, readAiApiProfileSync, readAiApiProfilesSync, readAiBackendOverrideSync } from "../../../packages/core-models/config";
import { readManagedSkillContentSync } from "../../../packages/core-models/skills";
import {
assertSafeWorkflowFilename,
@@ -25,8 +26,9 @@ import {
writeState,
taskDir,
} from "../../../packages/core-models/state";
+import { WORKFLOW_DEBUG_EVENT_TYPES } from "../../../packages/core-models/debug-events";
import { upsertTask } from "../../../packages/core-models/workfolders";
-import { activeWorkflows, getPhaseContent, normalizeAiBackend, readPhaseOutputArtifacts, stopActiveWorkflow } from "../../../packages/core-lib/claude";
+import { activeWorkflows, getPhaseContent, normalizeAiBackend, normalizeWorktreeNamingProvider, readPhaseOutputArtifacts, stopActiveWorkflow, WORKTREE_NAMING_PROVIDERS } from "../../../packages/core-lib/claude";
import { prepareWorktree, removeWorktree } from "../../../packages/core-lib/worktree";
import { buildWorkflowGraphFromDsl, createAppSdkAgentAdapter, createSdkAgentAdapter, FileCheckpointSaver } from "../../../packages/core-lib/langgraph-runtime/index";
import { nanoid } from "nanoid";
@@ -44,6 +46,12 @@ Rules:
const activeGraphs = new Map();
+function isAbortError(error) {
+ return error?.name === "AbortError"
+ || error?.code === "ABORT_ERR"
+ || /abort|aborted|cancelled|canceled/i.test(String(error?.message || ""));
+}
+
function isPathInside(parent, child) {
const rel = relative(resolve(parent), resolve(child));
return rel === "" || (rel && !rel.startsWith("..") && !rel.startsWith("/"));
@@ -136,6 +144,29 @@ function isGenericWorktreeName(value) {
return !suffix || /^(task|todo|work|change|update)(-\d+)?$/.test(suffix);
}
+async function generateWorktreeNameWithAiApi({ prompt, profileId }) {
+ const profile = profileId ? readAiApiProfileSync(profileId) : readAiApiProfilesSync()[0] || null;
+ if (!profile?.apiKey || !profile?.model) return "";
+ const client = new OpenAI({
+ apiKey: profile.apiKey,
+ baseURL: profile.baseUrl || undefined,
+ });
+ const completion = await client.chat.completions.create({
+ model: profile.model,
+ messages: [
+ {
+ role: "system",
+ content: "Return exactly one concise Git branch name and no explanation.",
+ },
+ {
+ role: "user",
+ content: prompt,
+ },
+ ],
+ });
+ return completion.choices?.[0]?.message?.content || "";
+}
+
function getThreadId(taskId, runId) {
return `${taskId}:${runId}`;
}
@@ -235,7 +266,7 @@ async function persistLangGraphState(taskId, runId, langState, workflow) {
async function generateWorktreeName({ taskId, workFolder, taskInputs, workflow }) {
const skill = readManagedSkillContentSync("worktree-naming") || DEFAULT_WORKTREE_NAMING_SKILL;
- const backend = normalizeAiBackend(readAiBackendOverrideSync());
+ const provider = normalizeWorktreeNamingProvider(workflow?.worktree?.namingProvider);
const localFallback = buildLocalWorktreeNameFallback(taskId, taskInputs);
const context = [
`Task ID: ${taskId}`,
@@ -254,6 +285,13 @@ Additional rules:
${context}`;
try {
+ if (provider === WORKTREE_NAMING_PROVIDERS.AI_API) {
+ const content = await generateWorktreeNameWithAiApi({ prompt, profileId: workflow?.worktree?.namingAiApiProfileId || "" });
+ const name = cleanGeneratedWorktreeName(content);
+ return isGenericWorktreeName(name) ? localFallback : name || localFallback;
+ }
+
+ const backend = normalizeAiBackend(readAiBackendOverrideSync());
const adapter = createSdkAgentAdapter({
workFolder,
taskDir: workFolder,
@@ -332,13 +370,25 @@ function getOrCreateGraph(taskId, runId, wf) {
return graph;
}
+function resetGraph(taskId, runId) {
+ activeGraphs.delete(getThreadId(taskId, runId));
+}
+
async function invokeGraph(taskId, runId, input) {
const wf = activeWorkflows.get(taskId);
if (!wf) return null;
const graph = getOrCreateGraph(taskId, runId, wf);
-
- const result = await graph.invoke(input, createGraphConfig(taskId, runId));
+ let result;
+ try {
+ result = await graph.invoke(input, createGraphConfig(taskId, runId));
+ } catch (error) {
+ if (isAbortError(error)) {
+ const state = await readState(taskId, runId).catch(() => null);
+ if (state?.overallStatus === "paused") return state;
+ }
+ throw error;
+ }
const state = await persistLangGraphState(taskId, runId, result, wf.workflow);
wf.send({ type: "state", state });
@@ -421,14 +471,16 @@ export async function startWorkflowSession(taskId, workFolder, taskInputs, image
const activeWorkflowFile = workflowFilename || getActiveWorkflowFile();
const send = createEmitter(sender, taskId, finalRunId);
- send({ type: "workflow_starting" });
+ send({ type: WORKFLOW_DEBUG_EVENT_TYPES.WORKFLOW_STARTING });
const requestedWorktreeName = String(options?.worktreeName || "").trim();
- if (workflow.worktree?.enabled && !requestedWorktreeName) send({ type: "worktree_naming_started" });
+ if (workflow.worktree?.enabled && !requestedWorktreeName) send({ type: WORKFLOW_DEBUG_EVENT_TYPES.WORKTREE_NAMING_STARTED });
const worktreeName = workflow.worktree?.enabled
? requestedWorktreeName || await generateWorktreeName({ taskId, workFolder, taskInputs, workflow })
: "";
- if (workflow.worktree?.enabled && !requestedWorktreeName) send({ type: "worktree_naming_completed", name: worktreeName });
- if (workflow.worktree?.enabled) send({ type: "worktree_preparing", name: worktreeName });
+ if (workflow.worktree?.enabled && !requestedWorktreeName) {
+ send({ type: WORKFLOW_DEBUG_EVENT_TYPES.WORKTREE_NAMING_COMPLETED, name: worktreeName });
+ }
+ if (workflow.worktree?.enabled) send({ type: WORKFLOW_DEBUG_EVENT_TYPES.WORKTREE_PREPARING, name: worktreeName });
const preparedWorktree = await prepareWorktree({
repoRoot: workFolder,
taskId,
@@ -437,7 +489,7 @@ export async function startWorkflowSession(taskId, workFolder, taskInputs, image
});
if (preparedWorktree.enabled) {
send({
- type: "worktree_ready",
+ type: WORKFLOW_DEBUG_EVENT_TYPES.WORKTREE_READY,
branchName: preparedWorktree.branchName,
rootPath: preparedWorktree.rootPath,
});
@@ -505,6 +557,7 @@ export async function approveWorkflow(taskId, sender, runId = "") {
const stateRunId = state.runId || runId || wf.runId || "";
wf.send = createEmitter(sender, taskId, stateRunId);
await ensureCheckpointReady(taskId, stateRunId, state);
+ wf.send({ type: "phase_approved", phase: state.currentPhase, requestedBy: "user", trigger: "checkpoint" });
try {
await invokeGraph(taskId, stateRunId, new Command({
resume: {
@@ -532,6 +585,7 @@ export async function rejectWorkflow(taskId, rejectTo, reason = "", sender, runI
await ensureCheckpointReady(taskId, stateRunId, state);
const notes = String(reason || "").trim();
if (!notes) throw new Error("reject reason is required");
+ send({ type: "phase_rejected", phase: cur, rejectTo, requestedBy: "user", trigger: "checkpoint" });
await appendToPhaseFile(taskId, rejectTo, `\n\n---\n\n**You:** ${notes}\n\n`, stateRunId);
const interaction = await appendPhaseInteraction(taskId, rejectTo, {
role: "user",
@@ -596,24 +650,63 @@ export async function sendWorkflowMessage(taskId, text, images, sender, runId =
send({ type: "user_message", phase, text });
}
-export async function restartWorkflowPhase(taskId, phase, sender, runId = "") {
+export async function resumeWorkflowPhase(taskId, phase, sender, runId = "") {
const wf = activeWorkflows.get(taskId);
if (!wf) throw new Error("workflow not found");
const state = await readState(taskId, runId || wf.runId || "");
const workflow = getRuntimeWorkflow(wf, state);
- if (!isWorkflowAutoStep(workflow, phase)) throw new Error(`cannot restart manual phase ${phase}`);
- if (state.currentPhase !== phase) throw new Error(`cannot restart ${phase} while current phase is ${state.currentPhase}`);
+ if (!isWorkflowAutoStep(workflow, phase)) throw new Error(`cannot resume manual phase ${phase}`);
+ if (state.currentPhase !== phase) throw new Error(`cannot resume ${phase} while current phase is ${state.currentPhase}`);
+ if (state.overallStatus !== "paused") throw new Error(`cannot resume ${phase} while workflow status is ${state.overallStatus}`);
const stateRunId = state.runId || runId || wf.runId || "";
wf.send = createEmitter(sender, taskId, stateRunId);
- updatePhaseStatus(state, phase, "in_progress", null);
+ resetGraph(taskId, stateRunId);
+ updatePhaseStatus(state, phase, "in_progress");
state.overallStatus = "in_progress";
await writeState(taskId, state);
- wf.send({ type: "phase_restarted", phase });
+ wf.send({ type: "phase_resumed", phase, requestedBy: "user", trigger: "toolbar" });
+ wf.send({ type: "state", state });
+ try {
+ await invokeGraph(taskId, stateRunId, new Command({
+ resume: null,
+ update: {
+ ...state,
+ currentStep: phase,
+ overallStatus: "in_progress",
+ },
+ }));
+ } catch (err) {
+ await markWorkflowFailed(taskId, stateRunId, phase, err, wf.send);
+ throw err;
+ }
+}
+
+export async function retryWorkflowPhase(taskId, phase, sender, runId = "") {
+ const wf = activeWorkflows.get(taskId);
+ if (!wf) throw new Error("workflow not found");
+ const state = await readState(taskId, runId || wf.runId || "");
+ const workflow = getRuntimeWorkflow(wf, state);
+ if (!isWorkflowAutoStep(workflow, phase)) throw new Error(`cannot retry manual phase ${phase}`);
+ if (state.currentPhase !== phase) throw new Error(`cannot retry ${phase} while current phase is ${state.currentPhase}`);
+ if (state.overallStatus !== "failed") throw new Error(`cannot retry ${phase} while workflow status is ${state.overallStatus}`);
+ const stateRunId = state.runId || runId || wf.runId || "";
+ wf.send = createEmitter(sender, taskId, stateRunId);
+ if (wf.abortController) {
+ try { wf.abortController.abort(); } catch {}
+ wf.abortController = null;
+ }
+ resetGraph(taskId, stateRunId);
+ updatePhaseStatus(state, phase, "in_progress");
+ state.overallStatus = "in_progress";
+ await writeState(taskId, state);
+ await upsertTask(state.originalWorkFolder || state.workFolder, taskId, "in_progress", stateRunId, { create: false }).catch(() => {});
+ wf.send({ type: "phase_retried", phase, requestedBy: "user", trigger: "toolbar" });
wf.send({ type: "state", state });
try {
await invokeGraph(taskId, stateRunId, {
...state,
currentStep: phase,
+ overallStatus: "in_progress",
});
} catch (err) {
await markWorkflowFailed(taskId, stateRunId, phase, err, wf.send);
@@ -627,15 +720,19 @@ export async function pauseWorkflowPhase(taskId, phase, sender, runId = "") {
const stateRunId = state.runId || runId || wf?.runId || "";
const send = createEmitter(sender, taskId, stateRunId);
if (state.currentPhase !== phase) throw new Error(`cannot pause ${phase} while current phase is ${state.currentPhase}`);
+ const workflow = getRuntimeWorkflow(wf, state);
+ if (!isWorkflowAutoStep(workflow, phase)) throw new Error(`cannot pause manual phase ${phase}`);
+ if (state.overallStatus !== "in_progress") throw new Error(`cannot pause workflow while status is ${state.overallStatus}`);
if (wf?.abortController) {
try { wf.abortController.abort(); } catch {}
wf.abortController = null;
}
- updatePhaseStatus(state, phase, "awaiting_input");
- state.overallStatus = "awaiting_input";
+ resetGraph(taskId, stateRunId);
+ updatePhaseStatus(state, phase, "paused");
+ state.overallStatus = "paused";
await writeState(taskId, state);
- await upsertTask(state.originalWorkFolder || state.workFolder, taskId, "awaiting_input", stateRunId, { create: false }).catch(() => {});
- send({ type: "phase_paused", phase });
+ await upsertTask(state.originalWorkFolder || state.workFolder, taskId, "paused", stateRunId, { create: false }).catch(() => {});
+ send({ type: "phase_paused", phase, requestedBy: "user", trigger: "toolbar" });
send({ type: "state", state });
}
diff --git a/apps/desktop/local-server-controllers/workflowController.ts b/apps/desktop/local-server-controllers/workflowController.ts
index 58ff471..6e1272f 100644
--- a/apps/desktop/local-server-controllers/workflowController.ts
+++ b/apps/desktop/local-server-controllers/workflowController.ts
@@ -7,8 +7,11 @@ import {
saveConfig,
getWorkflowDir,
readAiBackendOverride,
+ readAiApiProfilesForUi,
readMobileAccessEnabled,
saveAiBackendOverride,
+ saveAiApiProfile,
+ deleteAiApiProfile,
saveMobileAccessEnabled,
} from "../../../packages/core-models/config";
import { deleteManagedSkill, importManagedSkills, listManagedSkills, saveManagedSkill } from "../../../packages/core-models/skills";
@@ -44,11 +47,12 @@ function getWorkflowFilename(workflow) {
async function writeWorkflowFile(filename, workflow, isDraft) {
if (!String(workflow?.name || "").trim()) throw new Error("workflow name is required");
const workflowDir = await ensureWorkflowDir();
- await writeFile(join(workflowDir, filename), JSON.stringify(workflow, null, 2));
+ const data = isDraft ? workflow : validateWorkflowDsl(workflow);
+ await writeFile(join(workflowDir, filename), JSON.stringify(data, null, 2));
if (!isDraft && filename === getActiveWorkflowFile()) {
loadWorkflow(join(workflowDir, filename));
}
- return { filename, name: workflow.name };
+ return { filename, name: data.name };
}
// --- AI skill generation ---
@@ -147,14 +151,19 @@ router.get("/workflow", async (req, res) => {
const WORKFLOW = getWorkflow();
const mobileAccessEnabled = await readMobileAccessEnabled();
const aiBackendOverride = await readAiBackendOverride();
+ const aiApiProfiles = await readAiApiProfilesForUi();
if (!WORKFLOW) {
- return res.json(buildEmptyWorkflowConfig(mobileAccessEnabled, aiBackendOverride));
+ return res.json({
+ ...buildEmptyWorkflowConfig(mobileAccessEnabled, aiBackendOverride),
+ aiApiProfiles,
+ });
}
res.json({
...getWorkflowConfigShape(WORKFLOW),
mobileAccessEnabled,
aiBackendOverride,
+ aiApiProfiles,
});
});
@@ -190,6 +199,26 @@ router.put("/settings/ai-backend", async (req, res) => {
}
});
+router.get("/settings/ai-api-profiles", async (_req, res) => {
+ res.json({ profiles: await readAiApiProfilesForUi() });
+});
+
+router.post("/settings/ai-api-profiles", async (req, res) => {
+ try {
+ res.json({ profiles: await saveAiApiProfile(req.body) });
+ } catch (err) {
+ res.status(400).json({ error: err.message || "failed to save AI API profile" });
+ }
+});
+
+router.delete("/settings/ai-api-profiles/:id", async (req, res) => {
+ try {
+ res.json({ profiles: await deleteAiApiProfile(req.params.id) });
+ } catch (err) {
+ res.status(400).json({ error: err.message || "failed to delete AI API profile" });
+ }
+});
+
// --- Workflow CRUD ---
router.get("/workflows", async (req, res) => {
diff --git a/apps/desktop/local-server-controllers/wsController.ts b/apps/desktop/local-server-controllers/wsController.ts
index 84e7040..cc92ea7 100644
--- a/apps/desktop/local-server-controllers/wsController.ts
+++ b/apps/desktop/local-server-controllers/wsController.ts
@@ -4,7 +4,8 @@ import {
approveWorkflow,
pauseWorkflowPhase,
rejectWorkflow,
- restartWorkflowPhase,
+ resumeWorkflowPhase,
+ retryWorkflowPhase,
sendWorkflowMessage,
startWorkflowSession,
} from "../electron/workflow-runtime";
@@ -31,8 +32,10 @@ export function setupWebSocket(server) {
await rejectWorkflow(msg.taskId, msg.rejectTo, msg.reason, send, msg.runId);
} else if (msg.type === "message") {
await sendWorkflowMessage(msg.taskId, msg.text, msg.images, send, msg.runId);
- } else if (msg.type === "restart_phase") {
- await restartWorkflowPhase(msg.taskId, msg.phase, send, msg.runId);
+ } else if (msg.type === "resume_phase") {
+ await resumeWorkflowPhase(msg.taskId, msg.phase, send, msg.runId);
+ } else if (msg.type === "retry_phase") {
+ await retryWorkflowPhase(msg.taskId, msg.phase, send, msg.runId);
} else if (msg.type === "pause_phase") {
await pauseWorkflowPhase(msg.taskId, msg.phase, send, msg.runId);
}
diff --git a/apps/desktop/renderer/src/App.css b/apps/desktop/renderer/src/App.css
index 830500c..484df43 100644
--- a/apps/desktop/renderer/src/App.css
+++ b/apps/desktop/renderer/src/App.css
@@ -627,6 +627,11 @@ body {
color: #f2c94c;
}
+.task-status-paused {
+ background: #4a3a2d;
+ color: #f2c94c;
+}
+
.task-status-completed {
background: #2d4a3e;
color: #6fcf97;
diff --git a/apps/desktop/renderer/src/App.tsx b/apps/desktop/renderer/src/App.tsx
index 3400a1b..e8c4687 100644
--- a/apps/desktop/renderer/src/App.tsx
+++ b/apps/desktop/renderer/src/App.tsx
@@ -4,7 +4,7 @@ import { useConfigStore } from "./stores/configStore";
import { useWorkflowStore } from "./stores/workflowStore";
import HomePage from "./pages/HomePage";
import SettingsPage from "./pages/SettingsPage";
-import TicketPage from "./pages/TicketPage";
+import TaskPage from "./pages/TaskPage";
export default function App() {
const toast = useWorkflowStore((s) => s.toast);
@@ -20,7 +20,7 @@ export default function App() {
} />
} />
} />
- } />
+ } />
{toast && (
diff --git a/apps/desktop/renderer/src/StepDetail.tsx b/apps/desktop/renderer/src/StepDetail.tsx
index fb65e49..1cde56d 100644
--- a/apps/desktop/renderer/src/StepDetail.tsx
+++ b/apps/desktop/renderer/src/StepDetail.tsx
@@ -81,6 +81,8 @@ function normalizeConversation(interactions) {
}
function getBackendLabel(backend) {
+ if (String(backend || "").startsWith("ai-api:")) return `AI API: ${String(backend).slice("ai-api:".length)}`;
+ if (backend === "ai-api") return "AI API";
if (backend === "codex") return "Codex";
if (backend === "claude") return "Claude Code";
return "AI Conversation";
@@ -225,7 +227,7 @@ export default function StepDetail({ phase, content, artifact, interactions = []
const wasStreamingRef = useRef(false);
const fileInputRef = useRef(null);
const imagesRef = useRef([]);
- const activeTicket = useWorkflowStore((s) => s.activeTicket);
+ const activeTask = useWorkflowStore((s) => s.activeTask);
const workflowState = useWorkflowStore((s) => s.workflowState);
const conversation = normalizeConversation(interactions);
@@ -346,7 +348,7 @@ export default function StepDetail({ phase, content, artifact, interactions = []
try {
let uploadedPaths = [];
const runId = workflowState?.runId || "";
- if (images.length > 0 && activeTicket && runId) {
+ if (images.length > 0 && activeTask && runId) {
const payload = await Promise.all(images.map(async (img) => ({
name: img.file.name,
data: Array.from(new Uint8Array(await img.file.arrayBuffer())),
diff --git a/apps/desktop/renderer/src/WorkflowEditor.tsx b/apps/desktop/renderer/src/WorkflowEditor.tsx
index d237739..a084b3c 100644
--- a/apps/desktop/renderer/src/WorkflowEditor.tsx
+++ b/apps/desktop/renderer/src/WorkflowEditor.tsx
@@ -227,6 +227,8 @@ function createDefaultWorkflow({ includeStartStep = true } = {}) {
files: [...COMMON_WORKTREE_FILES],
customFiles: [],
removeOnComplete: false,
+ namingProvider: "ai_api",
+ namingAiApiProfileId: "",
useCustomSetupScript: false,
setupScript: "",
},
@@ -373,6 +375,8 @@ function normalizeWorkflow(raw) {
...(raw?.worktree || {}),
files: Array.from(new Set(worktreeFiles)),
customFiles,
+ namingProvider: raw?.worktree?.namingProvider === "ai_backend" ? "ai_backend" : "ai_api",
+ namingAiApiProfileId: raw?.worktree?.namingAiApiProfileId || "",
useCustomSetupScript: raw?.worktree?.useCustomSetupScript === true,
},
};
@@ -583,6 +587,8 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) {
const [modelRuntimeTarget, setModelRuntimeTarget] = useState(null);
const [showWorkflowSetup, setShowWorkflowSetup] = useState(false);
const [showRuntimeSettings, setShowRuntimeSettings] = useState(false);
+ const aiApiProfiles = useConfigStore((s) => s.aiApiProfiles);
+ const workflowConfig = useConfigStore((s) => s.workflowConfig);
const loadWorkflowConfig = useConfigStore((s) => s.loadWorkflowConfig);
const loadWorkflows = useConfigStore((s) => s.loadWorkflows);
const showToast = useWorkflowStore((s) => s.showToast);
@@ -605,6 +611,11 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) {
const selectedNodePanelRef = useRef(null);
const [flowInstance, setFlowInstance] = useState(null);
+ useEffect(() => {
+ if (workflowConfig) return;
+ loadWorkflowConfig();
+ }, [workflowConfig, loadWorkflowConfig]);
+
function closeEditor() {
resetEditor();
onClose();
@@ -1255,6 +1266,36 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) {
onChange={(event) => updateWorktree({ enabled: event.target.checked })}
/>
+ {workflow.worktree.enabled && (
+
+
+
+
+
+ {(workflow.worktree.namingProvider || "ai_api") === "ai_api" && (
+
+
+
+
+ )}
+
+ )}
{workflow.worktree.enabled && (
@@ -778,7 +890,7 @@ export default function TicketPage() {
setShowDeleteConfirm(false)}>{t("common.cancel")}
- {hasWorktree && deleteWorktree ? t("ticket.deleteTaskAndWorktree") : t("common.delete")}
+ {hasWorktree && deleteWorktree ? t("task.deleteTaskAndWorktree") : t("common.delete")}
)}
diff --git a/apps/desktop/renderer/src/stores/configStore.ts b/apps/desktop/renderer/src/stores/configStore.ts
index e24e6bf..9bb4c87 100644
--- a/apps/desktop/renderer/src/stores/configStore.ts
+++ b/apps/desktop/renderer/src/stores/configStore.ts
@@ -7,6 +7,7 @@ export const useConfigStore = create((set, get) => ({
workflowConfig: null,
workflows: [],
skills: [],
+ aiApiProfiles: [],
activeWorkflowFile: "",
workFolders: [],
selectedFolder: "",
@@ -21,7 +22,7 @@ export const useConfigStore = create((set, get) => ({
async loadWorkflowConfig() {
try {
const workflowConfig = await desktopApi.getWorkflowConfig();
- set({ workflowConfig });
+ set({ workflowConfig, aiApiProfiles: workflowConfig?.aiApiProfiles || [] });
} catch {}
},
@@ -35,7 +36,7 @@ export const useConfigStore = create((set, get) => ({
});
try {
const workflowConfig = await desktopApi.setMobileAccessEnabled(enabled);
- set({ workflowConfig });
+ set({ workflowConfig, aiApiProfiles: workflowConfig?.aiApiProfiles || get().aiApiProfiles });
return workflowConfig;
} catch (error) {
set({ workflowConfig: previousConfig });
@@ -53,7 +54,7 @@ export const useConfigStore = create((set, get) => ({
});
try {
const workflowConfig = await desktopApi.setAiBackendOverride(backend);
- set({ workflowConfig });
+ set({ workflowConfig, aiApiProfiles: workflowConfig?.aiApiProfiles || get().aiApiProfiles });
return workflowConfig;
} catch (error) {
set({ workflowConfig: previousConfig });
@@ -61,6 +62,20 @@ export const useConfigStore = create((set, get) => ({
}
},
+ async saveAiApiProfile(profile) {
+ const data = await desktopApi.saveAiApiProfile(profile);
+ set({ aiApiProfiles: data.profiles || [] });
+ get().loadWorkflowConfig();
+ return data;
+ },
+
+ async deleteAiApiProfile(id) {
+ const data = await desktopApi.deleteAiApiProfile(id);
+ set({ aiApiProfiles: data.profiles || [] });
+ get().loadWorkflowConfig();
+ return data;
+ },
+
async loadWorkflows() {
try {
const data = await desktopApi.listWorkflows();
diff --git a/apps/desktop/renderer/src/stores/workflowStore.ts b/apps/desktop/renderer/src/stores/workflowStore.ts
index 2e87ffe..9df1f59 100644
--- a/apps/desktop/renderer/src/stores/workflowStore.ts
+++ b/apps/desktop/renderer/src/stores/workflowStore.ts
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { useConfigStore } from "./configStore";
import { getAppApi } from "../lib/api-client";
+import { buildClientDebugPayload, DEBUG_EVENT_TRIGGERS, DEBUG_EVENT_TYPES } from "../lib/debug-events";
const desktopApi = getAppApi();
const MAX_DEBUG_EVENTS = 200;
@@ -33,13 +34,13 @@ function getWorkflowStateKey(taskId, runId = "") {
function getCurrentPhaseFromTask(task) {
if (task.status === "completed") return "completed";
const activePhase = (task.phases || []).find((phase) =>
- phase.status === "in_progress" || phase.status === "awaiting_input" || phase.status === "failed"
+ phase.status === "in_progress" || phase.status === "paused" || phase.status === "awaiting_input" || phase.status === "failed"
);
return activePhase?.id || activePhase?.name || task.phases?.[0]?.id || null;
}
function getWorkflowStateFromTask(task) {
- const taskId = task.taskId || task.ticketId || "";
+ const taskId = task.taskId || "";
if (!taskId) return null;
const runId = task.runId || taskId;
return {
@@ -111,7 +112,7 @@ function appendDebugEvent(set, payload, source = "workflow") {
connectionState: source === "workflow" ? "connected" : state.connectionState,
};
- if (payload?.type === "error" || payload?.type === "phase_failed") {
+ if (payload?.type === DEBUG_EVENT_TYPES.ERROR || payload?.type === DEBUG_EVENT_TYPES.PHASE_FAILED) {
nextState.lastError = {
at: event.at,
phase: payload.phase || null,
@@ -126,6 +127,34 @@ function appendDebugEvent(set, payload, source = "workflow") {
});
}
+export function pushClientDebugEvent(payload) {
+ useWorkflowStore.setState((state) => {
+ const event = createDebugEvent(payload, "client");
+ return {
+ debugEvents: [...state.debugEvents, event].slice(-MAX_DEBUG_EVENTS),
+ lastEventAt: event.at,
+ };
+ });
+}
+
+export function pushClientErrorEvent(payload) {
+ useWorkflowStore.setState((state) => {
+ const event = createDebugEvent(payload, "client");
+ return {
+ debugEvents: [...state.debugEvents, event].slice(-MAX_DEBUG_EVENTS),
+ lastEventAt: event.at,
+ lastError: {
+ at: event.at,
+ phase: payload.phase || null,
+ message: payload.message || "Unknown error",
+ payload,
+ },
+ isStreaming: false,
+ streamingPhase: null,
+ };
+ });
+}
+
function matchesWorkflowEvent(msg, taskId, runId = "") {
const eventTaskId = msg?.taskId || msg?.state?.taskId || "";
const eventRunId = msg?.runId || msg?.state?.runId || "";
@@ -178,10 +207,8 @@ function applyDisconnectedState(set, get) {
if (state) {
const updated = {
...state,
- overallStatus: state.overallStatus === "in_progress" ? "awaiting_input" : state.overallStatus,
- phases: state.phases.map((phase) =>
- phase.status === "in_progress" ? { ...phase, status: "awaiting_input" } : phase
- ),
+ overallStatus: state.overallStatus,
+ phases: state.phases.map((phase) => ({ ...phase })),
};
set({ workflowState: updated, isStreaming: false, streamingPhase: null, connectionState: "disconnected" });
} else {
@@ -253,6 +280,9 @@ function attachWorkflowEvents(set, get, taskId, runId = "") {
playNotificationSound();
set({ selectedPhase: phase.id });
}
+ if (phase.status === "paused" && prevStatus !== "paused") {
+ set({ selectedPhase: phase.id });
+ }
if (phase.status === "in_progress") streaming = true;
}
if (!streaming) {
@@ -269,7 +299,7 @@ function attachWorkflowEvents(set, get, taskId, runId = "") {
}
} else if (msg.type === "phase_done") {
set({ isStreaming: false, streamingPhase: null });
- } else if (msg.type === "phase_paused") {
+ } else if (msg.type === DEBUG_EVENT_TYPES.PHASE_PAUSED) {
set({ isStreaming: false, streamingPhase: null });
} else if (msg.type === "phase_content") {
set((state) => ({ phaseMessages: { ...state.phaseMessages, [msg.phase]: msg.content } }));
@@ -317,7 +347,7 @@ function attachWorkflowEvents(set, get, taskId, runId = "") {
});
return () => {
- appendDebugEvent(set, { type: "client_detach", taskId, runId }, "client");
+ pushClientDebugEvent(buildClientDebugPayload(DEBUG_EVENT_TYPES.CLIENT_DETACH, { taskId, runId }));
if (taskId) desktopApi.detachWorkflow(taskId, runId);
if (unsubscribeWorkflowEvents) {
unsubscribeWorkflowEvents();
@@ -328,7 +358,7 @@ function attachWorkflowEvents(set, get, taskId, runId = "") {
}
export const useWorkflowStore = create((set, get) => ({
- activeTicket: null,
+ activeTask: null,
workflowState: null,
workflowStatesByRun: {},
selectedPhase: null,
@@ -378,7 +408,7 @@ export const useWorkflowStore = create((set, get) => ({
});
},
- async loadTicket(taskId, runId) {
+ async loadTask(taskId, runId) {
try {
const stateKey = getWorkflowStateKey(taskId, runId);
const cachedState = get().workflowStatesByRun[stateKey];
@@ -387,7 +417,7 @@ export const useWorkflowStore = create((set, get) => ({
? cachedState.currentPhase
: cachedState.phases?.[0]?.id || null;
set({
- activeTicket: taskId,
+ activeTask: taskId,
workflowState: cachedState,
selectedPhase,
connectionState: cachedState.overallStatus === "completed" ? "disconnected" : get().connectionState,
@@ -401,7 +431,7 @@ export const useWorkflowStore = create((set, get) => ({
? displayState.currentPhase
: displayState.phases?.[0]?.id || null;
set({
- activeTicket: taskId,
+ activeTask: taskId,
workflowState: displayState,
phaseMessages: messages || {},
phaseOutputArtifacts: outputArtifacts || {},
@@ -426,13 +456,13 @@ export const useWorkflowStore = create((set, get) => ({
},
async connectWorkflow(taskId, workFolder, taskInputs, images, runId, worktreeName, workflowFilename) {
- appendDebugEvent(set, { type: "client_connect", taskId, workFolder }, "client");
+ pushClientDebugEvent(buildClientDebugPayload(DEBUG_EVENT_TYPES.CLIENT_CONNECT, { taskId, workFolder }));
set({ connectionState: "connecting" });
const detach = attachWorkflowEvents(set, get, taskId, runId);
try {
await desktopApi.startWorkflow({ taskId, workFolder, taskInputs, images, runId, worktreeName, workflowFilename });
} catch (err) {
- appendDebugEvent(set, { type: "error", message: err?.message || "Failed to start workflow" }, "client");
+ pushClientErrorEvent({ type: DEBUG_EVENT_TYPES.ERROR, message: err?.message || "Failed to start workflow" });
detach();
applyDisconnectedState(set, get);
}
@@ -466,7 +496,7 @@ export const useWorkflowStore = create((set, get) => ({
const stateKey = getWorkflowStateKey(id, workflowState.runId);
set({
- activeTicket: id,
+ activeTask: id,
selectedPhase: firstPhase,
phaseMessages: {},
phaseOutputArtifacts: {},
@@ -487,39 +517,111 @@ export const useWorkflowStore = create((set, get) => ({
},
async approve() {
- const { activeTicket, workflowState } = get();
- if (!activeTicket) return;
+ const { activeTask, workflowState } = get();
+ if (!activeTask) return;
+ pushClientDebugEvent(buildClientDebugPayload(DEBUG_EVENT_TYPES.PHASE_APPROVE_REQUESTED, {
+ phase: workflowState?.currentPhase || null,
+ trigger: DEBUG_EVENT_TRIGGERS.CHECKPOINT,
+ }));
try {
- await desktopApi.approveWorkflow(activeTicket, workflowState?.runId || "");
+ await desktopApi.approveWorkflow(activeTask, workflowState?.runId || "");
} catch (err) {
- appendDebugEvent(set, { type: "error", message: err?.message || "Approve failed" }, "client");
+ pushClientErrorEvent({ type: DEBUG_EVENT_TYPES.ERROR, message: err?.message || "Approve failed" });
}
},
async reject(rejectTo, reason) {
- const { activeTicket, workflowState } = get();
- if (!activeTicket) return;
+ const { activeTask, workflowState } = get();
+ if (!activeTask) return;
+ pushClientDebugEvent(buildClientDebugPayload(DEBUG_EVENT_TYPES.PHASE_REJECT_REQUESTED, {
+ phase: workflowState?.currentPhase || null,
+ rejectTo,
+ message: reason,
+ trigger: DEBUG_EVENT_TRIGGERS.CHECKPOINT,
+ }));
try {
- await desktopApi.rejectWorkflow(activeTicket, rejectTo, reason, workflowState?.runId || "");
+ await desktopApi.rejectWorkflow(activeTask, rejectTo, reason, workflowState?.runId || "");
} catch (err) {
- appendDebugEvent(set, { type: "error", message: err?.message || "Reject failed", rejectTo }, "client");
+ pushClientErrorEvent({ type: DEBUG_EVENT_TYPES.ERROR, message: err?.message || "Reject failed", rejectTo });
}
},
async sendMessage(text, images) {
- const { activeTicket, workflowState } = get();
- if (!activeTicket) return;
+ const { activeTask, workflowState } = get();
+ if (!activeTask) return;
+ pushClientDebugEvent(buildClientDebugPayload(DEBUG_EVENT_TYPES.PHASE_MESSAGE_REQUESTED, {
+ phase: workflowState?.currentPhase || null,
+ text,
+ imageCount: Array.isArray(images) ? images.length : 0,
+ trigger: DEBUG_EVENT_TRIGGERS.CHAT,
+ }));
try {
- await desktopApi.sendWorkflowMessage(activeTicket, text, images, workflowState?.runId || "");
+ await desktopApi.sendWorkflowMessage(activeTask, text, images, workflowState?.runId || "");
} catch (err) {
- appendDebugEvent(set, { type: "error", message: err?.message || "Send message failed" }, "client");
+ pushClientErrorEvent({ type: DEBUG_EVENT_TYPES.ERROR, message: err?.message || "Send message failed" });
}
},
- async restartPhase(phase) {
- const { activeTicket, workflowState } = get();
- if (!activeTicket || !phase) return;
- appendDebugEvent(set, { type: "phase_restart_requested", phase }, "client");
+ async resumePhase(phase) {
+ const { activeTask, workflowState } = get();
+ if (!activeTask || !phase) return;
+ pushClientDebugEvent(buildClientDebugPayload(DEBUG_EVENT_TYPES.PHASE_RESUME_REQUESTED, { phase, trigger: DEBUG_EVENT_TRIGGERS.TOOLBAR }));
+ set((state) => ({
+ isStreaming: true,
+ streamingPhase: phase,
+ lastError: null,
+ workflowState: state.workflowState
+ ? {
+ ...state.workflowState,
+ overallStatus: "in_progress",
+ currentPhase: phase,
+ phases: state.workflowState.phases.map((item) =>
+ item.id === phase ? { ...item, status: "in_progress" } : item
+ ),
+ }
+ : state.workflowState,
+ }));
+ const nextState = get().workflowState;
+ if (nextState) {
+ set((state) => ({
+ workflowStatesByRun: {
+ ...state.workflowStatesByRun,
+ [getWorkflowStateKey(nextState.taskId, nextState.runId)]: nextState,
+ },
+ }));
+ }
+ try {
+ await desktopApi.resumeWorkflowPhase(activeTask, phase, workflowState?.runId || "");
+ } catch (err) {
+ const currentState = get().workflowState;
+ if (currentState) {
+ const reverted = {
+ ...currentState,
+ overallStatus: "paused",
+ phases: currentState.phases.map((item) =>
+ item.id === phase ? { ...item, status: "paused" } : item
+ ),
+ };
+ set((state) => ({
+ workflowState: reverted,
+ workflowStatesByRun: {
+ ...state.workflowStatesByRun,
+ [getWorkflowStateKey(reverted.taskId, reverted.runId)]: reverted,
+ },
+ isStreaming: false,
+ streamingPhase: null,
+ }));
+ } else {
+ set({ isStreaming: false, streamingPhase: null });
+ }
+ pushClientErrorEvent({ type: DEBUG_EVENT_TYPES.ERROR, message: err?.message || "Resume failed", phase });
+ }
+ },
+
+ async retryPhase(phase) {
+ const { activeTask, workflowState } = get();
+ if (!activeTask || !phase) return;
+ pushClientDebugEvent(buildClientDebugPayload(DEBUG_EVENT_TYPES.PHASE_RETRY_REQUESTED, { phase, trigger: DEBUG_EVENT_TRIGGERS.TOOLBAR }));
set((state) => ({
isStreaming: true,
streamingPhase: phase,
@@ -545,26 +647,46 @@ export const useWorkflowStore = create((set, get) => ({
}));
}
try {
- await desktopApi.restartWorkflowPhase(activeTicket, phase, workflowState?.runId || "");
+ await desktopApi.retryWorkflowPhase(activeTask, phase, workflowState?.runId || "");
} catch (err) {
- set({ isStreaming: false, streamingPhase: null });
- appendDebugEvent(set, { type: "error", message: err?.message || "Restart failed", phase }, "client");
+ const currentState = get().workflowState;
+ if (currentState) {
+ const reverted = {
+ ...currentState,
+ overallStatus: "failed",
+ phases: currentState.phases.map((item) =>
+ item.id === phase ? { ...item, status: "failed" } : item
+ ),
+ };
+ set((state) => ({
+ workflowState: reverted,
+ workflowStatesByRun: {
+ ...state.workflowStatesByRun,
+ [getWorkflowStateKey(reverted.taskId, reverted.runId)]: reverted,
+ },
+ isStreaming: false,
+ streamingPhase: null,
+ }));
+ } else {
+ set({ isStreaming: false, streamingPhase: null });
+ }
+ pushClientErrorEvent({ type: DEBUG_EVENT_TYPES.ERROR, message: err?.message || "Retry failed", phase });
}
},
async pausePhase(phase) {
- const { activeTicket, workflowState } = get();
- if (!activeTicket || !phase) return;
- appendDebugEvent(set, { type: "phase_pause_requested", phase }, "client");
+ const { activeTask, workflowState } = get();
+ if (!activeTask || !phase) return;
+ pushClientDebugEvent(buildClientDebugPayload(DEBUG_EVENT_TYPES.PHASE_PAUSE_REQUESTED, { phase, trigger: DEBUG_EVENT_TRIGGERS.TOOLBAR }));
set((state) => ({
isStreaming: false,
streamingPhase: null,
workflowState: state.workflowState
? {
...state.workflowState,
- overallStatus: "awaiting_input",
+ overallStatus: "paused",
phases: state.workflowState.phases.map((item) =>
- item.id === phase ? { ...item, status: "awaiting_input" } : item
+ item.id === phase ? { ...item, status: "paused" } : item
),
}
: state.workflowState,
@@ -579,17 +701,40 @@ export const useWorkflowStore = create((set, get) => ({
}));
}
try {
- await desktopApi.pauseWorkflowPhase(activeTicket, phase, workflowState?.runId || "");
+ await desktopApi.pauseWorkflowPhase(activeTask, phase, workflowState?.runId || "");
} catch (err) {
- appendDebugEvent(set, { type: "error", message: err?.message || "Pause failed", phase }, "client");
+ const currentState = get().workflowState;
+ if (currentState) {
+ const reverted = {
+ ...currentState,
+ overallStatus: "in_progress",
+ phases: currentState.phases.map((item) =>
+ item.id === phase ? { ...item, status: "in_progress" } : item
+ ),
+ };
+ set((state) => ({
+ workflowState: reverted,
+ workflowStatesByRun: {
+ ...state.workflowStatesByRun,
+ [getWorkflowStateKey(reverted.taskId, reverted.runId)]: reverted,
+ },
+ }));
+ }
+ pushClientErrorEvent({ type: DEBUG_EVENT_TYPES.ERROR, message: err?.message || "Pause failed", phase });
}
},
async deleteTask(taskId, runId, options = {}) {
- const { activeTicket, workflowState } = get();
- const targetTaskId = taskId || activeTicket;
+ const { activeTask, workflowState } = get();
+ const targetTaskId = taskId || activeTask;
const targetRunId = runId || workflowState?.runId || "";
if (!targetTaskId) return false;
+ pushClientDebugEvent(buildClientDebugPayload(DEBUG_EVENT_TYPES.TASK_DELETE_REQUESTED, {
+ taskId: targetTaskId,
+ runId: targetRunId,
+ removeWorktree: Boolean(options?.removeWorktree),
+ trigger: DEBUG_EVENT_TRIGGERS.TOOLBAR,
+ }));
try {
if (unsubscribeWorkflowEvents) {
unsubscribeWorkflowEvents();
@@ -597,10 +742,16 @@ export const useWorkflowStore = create((set, get) => ({
}
desktopApi.detachWorkflow(targetTaskId, targetRunId);
await desktopApi.removeTask(targetTaskId, targetRunId, options);
+ pushClientDebugEvent(buildClientDebugPayload(DEBUG_EVENT_TYPES.TASK_DELETED, {
+ taskId: targetTaskId,
+ runId: targetRunId,
+ removeWorktree: Boolean(options?.removeWorktree),
+ trigger: DEBUG_EVENT_TRIGGERS.TOOLBAR,
+ }));
const stateKey = getWorkflowStateKey(targetTaskId, targetRunId);
const { [stateKey]: _removed, ...workflowStatesByRun } = get().workflowStatesByRun;
set({
- activeTicket: null,
+ activeTask: null,
workflowState: null,
workflowStatesByRun,
phaseMessages: {},
@@ -616,18 +767,33 @@ export const useWorkflowStore = create((set, get) => ({
await useConfigStore.getState().loadWorkFolders();
return true;
} catch (err) {
- appendDebugEvent(set, { type: "error", message: err?.message || "Delete task failed" }, "client");
+ pushClientErrorEvent(buildClientDebugPayload(DEBUG_EVENT_TYPES.TASK_DELETE_FAILED, {
+ taskId: targetTaskId,
+ runId: targetRunId,
+ message: err?.message || "Delete task failed",
+ trigger: DEBUG_EVENT_TRIGGERS.TOOLBAR,
+ }));
return false;
}
},
async removeTaskWorktree(taskId, runId) {
- const { activeTicket, workflowState } = get();
- const targetTaskId = taskId || activeTicket;
+ const { activeTask, workflowState } = get();
+ const targetTaskId = taskId || activeTask;
const targetRunId = runId || workflowState?.runId || "";
if (!targetTaskId) return false;
+ pushClientDebugEvent(buildClientDebugPayload(DEBUG_EVENT_TYPES.WORKTREE_REMOVE_REQUESTED, {
+ taskId: targetTaskId,
+ runId: targetRunId,
+ trigger: DEBUG_EVENT_TRIGGERS.TOOLBAR,
+ }));
try {
const result = await desktopApi.removeTaskWorktree(targetTaskId, targetRunId);
+ pushClientDebugEvent(buildClientDebugPayload(DEBUG_EVENT_TYPES.WORKTREE_REMOVED, {
+ taskId: targetTaskId,
+ runId: targetRunId,
+ trigger: DEBUG_EVENT_TRIGGERS.TOOLBAR,
+ }));
const nextState = result?.state || (workflowState ? {
...workflowState,
worktree: {
@@ -649,7 +815,12 @@ export const useWorkflowStore = create((set, get) => ({
await useConfigStore.getState().loadWorkFolders();
return true;
} catch (err) {
- appendDebugEvent(set, { type: "error", message: err?.message || "Remove worktree failed" }, "client");
+ pushClientErrorEvent(buildClientDebugPayload(DEBUG_EVENT_TYPES.WORKTREE_REMOVE_FAILED, {
+ taskId: targetTaskId,
+ runId: targetRunId,
+ message: err?.message || "Remove worktree failed",
+ trigger: DEBUG_EVENT_TRIGGERS.TOOLBAR,
+ }));
return false;
}
},
diff --git a/apps/mobile/lib/main.dart b/apps/mobile/lib/main.dart
index 6b636d0..86bc61b 100644
--- a/apps/mobile/lib/main.dart
+++ b/apps/mobile/lib/main.dart
@@ -1008,6 +1008,8 @@ class _TaskCard extends StatelessWidget {
switch (status) {
case "completed":
return const Color(0xFF15803D);
+ case "paused":
+ return const Color(0xFFD97706);
case "awaiting_input":
return const Color(0xFFD97706);
case "in_progress":
diff --git a/docs/current-mobile-backend-desktop-communication.zh.md b/docs/current-mobile-backend-desktop-communication.zh.md
index 0ea650f..45c6c68 100644
--- a/docs/current-mobile-backend-desktop-communication.zh.md
+++ b/docs/current-mobile-backend-desktop-communication.zh.md
@@ -21,7 +21,7 @@ flowchart LR
B[Cloud Backend]
D[Desktop Connector]
- M -- "HTTP\nGET /api/tasks\nGET /api/devices\nPOST /api/tasks/:deviceId/:ticketId/commands" --> B
+ M -- "HTTP\nGET /api/tasks\nGET /api/devices\nPOST /api/tasks/:deviceId/:taskId/commands" --> B
B -- "HTTP Response" --> M
M -- "WebSocket\n/ws/mobile" --> B
@@ -44,7 +44,7 @@ flowchart LR
M -->|HTTP + WebSocket| B
B -->|WebSocket| D
- D -->|HTTP\n/api/workfolders\n/api/workflow\n/api/workflows\n/api/tasks/:ticketId/state| S
+ D -->|HTTP\n/api/workfolders\n/api/workflow\n/api/workflows\n/api/tasks/:taskId/state| S
D -->|WebSocket\n/ws| S
E -. "本地 IPC" .-> R
S --> R
@@ -81,7 +81,7 @@ sequenceDiagram
D->>B: WebSocket connect /ws/desktop
D->>B: device.hello
- D->>S: HTTP GET /api/tasks/:ticketId/state
+ D->>S: HTTP GET /api/tasks/:taskId/state
D->>B: task.snapshot / task.event
M->>B: HTTP GET /api/tasks
@@ -100,7 +100,7 @@ sequenceDiagram
participant D as Desktop Connector
participant S as Local Workflow Server
- M->>B: HTTP POST /api/tasks/:deviceId/:ticketId/commands
+ M->>B: HTTP POST /api/tasks/:deviceId/:taskId/commands
B-->>M: command created
B-->>D: command.request
@@ -108,7 +108,7 @@ sequenceDiagram
D->>S: WebSocket /ws
D->>S: approve / reject / message
else sync_task
- D->>S: HTTP GET /api/tasks/:ticketId/state
+ D->>S: HTTP GET /api/tasks/:taskId/state
else start_workflow
D->>S: WebSocket /ws
D->>S: start
diff --git a/docs/langgraph-example.md b/docs/langgraph-example.md
new file mode 100644
index 0000000..5d49746
--- /dev/null
+++ b/docs/langgraph-example.md
@@ -0,0 +1,159 @@
+主角:小明,今天接到一个小功能
+需求:“在用户头像旁边加一个在线状态小绿点”
+
+我们用 LangGraph 的 11 个核心概念,把小明这趟开发旅程串起来。
+① State(状态)—— 小明的“开发进度板”
+整个开发过程中,所有信息都记在一块小黑板上,这就是 State:
+
+python
+class DevState(TypedDict):
+ requirement: str # 需求描述
+ plan: str # 实施方案
+ code: str # 代码
+ cr_comments: list # Code Review 意见
+ status: str # 当前阶段
+小明每做完一步,就擦掉旧信息、写上新的。
+
+② Nodes(节点)—— 每个开发步骤
+每个步骤就是一个 Node,也就是小明要做的一件事:
+
+需求分析:搞清楚到底加在哪个位置,是否区分在线/离线。
+
+技术方案:决定用 CSS 伪类还是 websocket 推送,写个简单计划。
+
+编码实现:吭哧吭哧写代码。
+
+自测:本地跑起来看看绿点亮不亮。
+
+提交 CR:把代码推到分支,创建 Pull Request。
+
+根据 CR 修改:别人提了意见,再改一版。
+
+这些都是独立的函数节点,接收 State,返回更新。
+
+③ Edges(普通边)—— 固定流程
+有些步骤是固定顺序,就像流水线:
+
+需求分析 → 技术方案:想清楚才能干。
+
+技术方案 → 编码实现:计划好了才动手。
+
+编码实现 → 自测:写完当然先自己测。
+
+根据 CR 修改 → 自测:改完再跑一遍。
+
+这就是 普通边,小明必须按这个顺序走,没有分支。
+
+④ Conditional Edges(条件边)—— 关键时刻的决策
+但有些地方需要判断,不能傻走。比如自测完了:
+
+如果自测发现 bug → 回到 编码实现(循环)。
+
+如果自测通过 → 进入 提交 CR。
+
+CR 结果回来以后:
+
+如果 reviewer 说“OK,可以合” → 走向 合并(结束)。
+
+如果 reviewer 留了修改意见 → 走向 根据 CR 修改。
+
+这个动态决策就是 条件边,它让开发流程不是死板的,bug 多了会循环,CR 不过会返工。
+
+⑤ Graph(图)—— 小明的一整套开发 SOP
+把上面所有节点和边(固定+条件)画成一张流程图,就是 Graph。它定义了小明处理一个需求的完整标准化流程:
+
+python
+builder = StateGraph(DevState)
+builder.add_node("需求分析", analyze_requirement)
+builder.add_node("技术方案", make_plan)
+builder.add_node("编码实现", write_code)
+builder.add_node("自测", self_test)
+builder.add_node("提交CR", create_pr)
+builder.add_node("根据CR修改", revise_by_comments)
+builder.add_node("合并", merge_pr)
+
+builder.set_entry_point("需求分析")
+builder.add_edge("需求分析", "技术方案")
+builder.add_edge("技术方案", "编码实现")
+builder.add_edge("编码实现", "自测")
+
+# 自测后的分支
+builder.add_conditional_edges("自测", decide_after_test, {
+ "bug": "编码实现",
+ "pass": "提交CR"
+})
+
+# CR 后的分支
+builder.add_conditional_edges("提交CR", decide_after_cr, {
+ "need_revise": "根据CR修改",
+ "approved": "合并"
+})
+
+builder.add_edge("根据CR修改", "自测")
+builder.add_edge("合并", END)
+这就是小明的“开发大脑图”。
+
+⑥ Compile(编译)—— 开启一天的干活模式
+图画好了只是张纸,Compile 就是小明早上坐到工位上,打开电脑,把这张 SOP 变成可以真正跑起来的工作流引擎。同时他还会挂载几个重要插件:记忆棒(checkpointer)和暂停开关(interrupt)。
+
+⑦ Checkpointer(检查点)—— 停电能接着干
+小明在写代码时,突然公司断电了,或者电脑蓝屏了。因为有 Checkpointer,小黑板上的所有状态(做到哪一步、方案是什么、代码写了一部分)都被自动保存了。电一来,他输入同一个 thread_id(比如“需求#351”),工作流直接恢复到断电前的那一步,不用重新分析需求。
+
+⑧ Streaming(流式输出)—— 项目经理的“实时看板”
+项目经理想看小明干到哪儿了,不用站他身后,有个看板实时显示当前节点:
+
+text
+✅ 需求分析 完成
+✅ 技术方案 完成
+⏳ 编码实现 进行中...
+❌ 自测 发现 bug(已返回编码实现)
+✅ 编码实现 完成(二次)
+✅ 自测 通过
+⏳ 等待 Code Review...
+这就是 Streaming,每完成一个节点就推一条状态,所有人心里有数。
+
+⑨ Human-in-the-loop(人机协同)—— Code Review 环节
+开发流程中最经典的“人机协同”就是 Code Review。小明把代码推上去后,工作流在 提交CR 节点自动暂停了,因为 graph 编译时设了 interrupt_before=["提交CR"]。这时必须由 reviewer(人类)介入,看完代码,填写意见,然后才能决定下一步走向。在 LangGraph 里,这个“等待并获取外部输入”就是 Human-in-the-loop。
+
+⑩ Command(命令)—— Reviewer 的“遥控器”
+Reviewer 看完代码,留下两条意见:“1. 颜色用 #42b72a 别用 #00ff00;2. 加个动画”。他在 Review 系统里点“需要修改”,这个动作会被包装成一个 Command:
+
+python
+Command(update={"cr_comments": ["颜色用#42b72a", "加动画"]})
+这个 Command 直接喂给工作流引擎,引擎一看:哦,该走 根据CR修改 分支了。于是小明继续吭哧改代码。如果 Reviewer 点的是“Approve”,Command 里就会标记 approved=True,工作流直接走到 合并。
+
+⑪ Send(并行调度)—— 复杂需求时的并行开发
+如果需求被拆成“前端加绿点”和“后端推送在线状态”两个独立子任务,小明一个人干太慢。这时候 Team Lead 可以用 Send 把任务分发出去:
+
+python
+def fanout(state):
+ return [
+ Send("前端编码", {"component": "avatar"}),
+ Send("后端编码", {"api": "/status"})
+ ]
+两个同事同时开工,各自更新自己的小黑板,最后汇总。虽然这个小功能用不上,但流程里是支持这种并行模式的。
+
+大结局:从接需求到 PR 合并的 LangGraph 之旅
+小明上午接到需求,状态机启动:
+
+需求分析(Node)→ 更新 State 中的 requirement 字段
+
+技术方案(Node)→ 填好 plan
+
+编码实现(Node)→ 写出 code
+
+自测(Node)→ 发现绿点不亮,条件边判定有 bug,回到编码实现
+
+再次自测通过,走到 提交CR(被 Human-in-the-loop 暂停)
+
+Reviewer 通过 Command 发来修改意见
+
+小明走 根据CR修改 节点,改完又自测,再次提 CR
+
+这次 Reviewer 发了“Approve”的 Command,条件边导向 合并
+
+工作流结束,小黑板上 status='done'
+
+整个过程,Checkpointer 保平安,Streaming 让进度透明,Human-in-the-loop 和 Command 让协作无缝,条件边 控制返工循环。
+
+你看,一个开发者的日常,就是活生生的一个 LangGraph 有状态工作流。
\ No newline at end of file
diff --git a/docs/langgraph-workflow-comparison.zh.md b/docs/langgraph-workflow-comparison.zh.md
new file mode 100644
index 0000000..5b5303d
--- /dev/null
+++ b/docs/langgraph-workflow-comparison.zh.md
@@ -0,0 +1,365 @@
+# LangGraph long graph 示例与当前 Workflow 设计对比
+
+## 结论
+
+当前 APP 不是完全按照 LangGraph 原生概念直接建模的。
+
+更准确的描述是:
+
+```text
+自定义 Workflow JSON DSL
+ -> 校验和归一化
+ -> 编译成 LangGraph StateGraph
+ -> 由桌面端 runtime 执行
+ -> 状态、产物、消息和 checkpoint 落到本地任务目录
+ -> UI 用 React Flow 展示和编辑 workflow
+```
+
+也就是说,当前 APP 已经使用 LangGraph 作为 workflow runtime,但产品层仍然是自己的 DSL、自己的状态文件、自己的桌面任务模型和自己的 UI 交互。
+
+## 对比对象
+
+本文对比两个东西:
+
+- `docs/langgraph-example.md`:用“小明开发一个功能”的故事解释 LangGraph 的 11 个核心概念。
+- 当前 APP 的 Workflow 实现:主要代码在 `packages/core-lib/langgraph-runtime/`、`packages/core-models/`、`apps/desktop/electron/workflow-runtime.ts` 和 `apps/desktop/renderer/src/WorkflowEditor.tsx`。
+
+## 概念映射
+
+| LangGraph 概念 | 当前 APP 是否有 | 当前实现 |
+|---|---:|---|
+| State | 有 | `WorkflowRuntimeState` + `workflow-state.json` |
+| Nodes | 有 | DSL 中的 `steps` |
+| 普通 Edges | 有 | `next` 字段 |
+| Conditional Edges | 有 | `condition.passTo/failTo`、`checkpoint.approve/rejectTargets` |
+| Graph | 有 | `buildWorkflowGraphFromDsl()` 创建 `StateGraph` |
+| Compile | 有 | `builder.compile({ checkpointer })` |
+| Checkpointer | 有 | `FileCheckpointSaver` 保存 LangGraph checkpoint |
+| Streaming | 部分有 | APP 自己通过事件推送状态和 phase 内容 |
+| Human-in-the-loop | 有 | `checkpoint` 节点调用 `interrupt()` |
+| Command | 有 | approve/reject 用 `new Command({ resume })` |
+| Send | 没有 | 当前没有 fan-out/fan-in 并行任务调度 |
+
+## 当前 APP 已经符合 LangGraph 的部分
+
+### 1. 有真实的 LangGraph runtime
+
+当前 APP 不是只借用了 LangGraph 这个名字。`packages/core-lib/langgraph-runtime/builder.ts` 里确实使用了:
+
+- `StateGraph`
+- `Annotation`
+- `START`
+- `END`
+- `interrupt`
+- `Command`
+- `MemorySaver`
+
+`buildWorkflowGraphFromDsl()` 会把 DSL 中的 steps 编译成真正的 LangGraph graph。
+
+### 2. 有 State
+
+当前 runtime state 包含:
+
+- `taskId`
+- `runId`
+- `workFolder`
+- `taskDir`
+- `currentStep`
+- `overallStatus`
+- `taskInputs`
+- `sessionMap`
+- `stepOutputs`
+- `stepArtifacts`
+- `stepDecisions`
+- `pendingMessages`
+- `pendingImagePaths`
+- `logs`
+
+同时,APP 还有一份产品层状态文件 `workflow-state.json`。这份状态不仅保存 LangGraph 运行信息,也保存 UI 和任务运行需要的信息,例如:
+
+- `currentPhase`
+- `phases`
+- `steps`
+- `workflowDefinition`
+- `workflowConfig`
+- `worktree`
+- `artifacts`
+
+这和 LangGraph 示例里的简单 `DevState` 不一样。示例里的 state 是纯业务状态;当前 APP 的 state 是“运行状态 + 产品状态 + agent session + artifact + worktree”的混合体。
+
+### 3. 有 Node
+
+示例里的 node 是任意函数,例如:
+
+- 需求分析
+- 技术方案
+- 编码实现
+- 自测
+- 提交 CR
+- 根据 CR 修改
+
+当前 APP 也有 node,但被产品 DSL 收敛成固定类型:
+
+- `agent`
+- `condition`
+- `checkpoint`
+- `end`
+
+这是一个重要差异:LangGraph 原生 node 可以是任意函数;当前 APP 的 node 是为了产品可配置性做过限制的业务节点。
+
+### 4. 有普通边和条件边
+
+当前 APP 的普通边来自 `next`。
+
+当前 APP 的条件边有两类:
+
+- `condition` 节点根据 agent 输出解析出的 `passed` 结果走 `passTo` 或 `failTo`。
+- `checkpoint` 节点根据人工 approve/reject 走 `approve` 或 `rejectTargets`。
+
+这和 `docs/langgraph-example.md` 中“自测失败回到编码实现”“CR 不通过回到修改”的概念是一致的。
+
+### 5. 有 Human-in-the-loop 和 Command
+
+当前 APP 的 `checkpoint` 节点会调用 `interrupt()` 暂停 workflow。
+
+用户在 UI 中 approve 或 reject 后,Electron runtime 会用 `Command({ resume: ... })` 恢复 graph:
+
+- approve:继续到 `approve` 指向的步骤
+- reject:带上 reject reason,回到指定步骤
+
+这和 LangGraph 示例里 reviewer 通过 Command 控制下一步的概念基本一致。
+
+### 6. 有 Checkpointer
+
+当前 APP 使用自定义 `FileCheckpointSaver`,并把 checkpoint 存到任务运行目录下的 `checkpoints`。
+
+这说明 LangGraph checkpoint 是存在的,不只是 APP 自己写 `workflow-state.json`。
+
+## 当前 APP 和 LangGraph 示例的主要差异
+
+### 1. 当前 APP 是 DSL 驱动,不是直接手写 StateGraph
+
+LangGraph 示例是这样的思路:
+
+```python
+builder = StateGraph(DevState)
+builder.add_node(...)
+builder.add_edge(...)
+builder.add_conditional_edges(...)
+```
+
+当前 APP 是这样的思路:
+
+```text
+workflow JSON
+ -> validateWorkflowDsl()
+ -> buildWorkflowGraphFromDsl()
+ -> StateGraph
+```
+
+这意味着用户面对的是产品 DSL,而不是 LangGraph 原生 API。
+
+这个选择是合理的,因为 APP 需要让 workflow 可编辑、可保存、可切换、可通过 UI 展示,而不是让用户直接写 TypeScript/Python graph。
+
+### 2. 当前 DSL 比 LangGraph 原生能力窄
+
+当前 DSL 只允许这些 step 类型:
+
+- `agent`
+- `condition`
+- `checkpoint`
+- `end`
+
+它没有暴露 LangGraph 的完整表达能力,例如:
+
+- 任意自定义 node 函数
+- 任意 state schema
+- 自定义 reducer
+- subgraph
+- dynamic Send
+- map-reduce 风格的并行调度
+
+这不是 runtime 做不到,而是当前产品 DSL 没有设计这些能力。
+
+### 3. 当前 Streaming 是产品事件流,不是直接暴露 LangGraph stream
+
+`docs/langgraph-example.md` 里的 Streaming 更接近 LangGraph 执行事件流。
+
+当前 APP 的 UI 主要消费自己定义的事件,例如:
+
+- `state`
+- `phase_content`
+- `phase_artifact`
+- `phase_interaction`
+- `phase_failed`
+- `workflow_starting`
+- `worktree_ready`
+
+所以当前有“实时看板效果”,但它不是把 LangGraph 原始 stream 原样暴露给前端。
+
+### 4. 当前没有 Send 并行调度
+
+示例里的 `Send` 用来把一个任务拆成多个并行分支,例如前端和后端同时执行。
+
+当前 APP 没有对应能力:
+
+- DSL 没有 `send` / `fanout` / `parallel` 节点。
+- runtime builder 没有使用 LangGraph `Send`。
+- state 也没有为多个并行子任务设计聚合结构。
+
+所以当前 workflow 更适合:
+
+- 线性执行
+- 条件分支
+- 条件循环
+- 人工 checkpoint
+- reject 回跳
+
+不适合原生表达复杂并行工作流。
+
+### 5. 当前状态模型更偏产品运行,而不是纯图状态
+
+LangGraph 示例里的 state 是工作流内部状态。
+
+当前 APP 还要处理桌面产品自己的概念:
+
+- task run 目录
+- worktree
+- artifact 文件
+- phase markdown
+- agent session id
+- UI graph shape
+- Electron 和本地 server 通信
+- task 列表状态
+
+所以当前 state 不可能只是一个简单的 LangGraph state。它同时承担了本地桌面工作流产品的数据持久化职责。
+
+## 当前默认 Workflow 的实际形态
+
+以 `docs/workflows/default-codex.json` 和 `docs/workflows/default-claude-code.json` 为例,默认 workflow 大致是:
+
+```text
+implement
+ -> code_review
+ -> risk_gate
+ -> pass: review
+ -> fail: implement
+ -> review
+ -> approve: commit_and_push
+ -> reject: implement
+ -> commit_and_push
+ -> END
+```
+
+这个结构和 `docs/langgraph-example.md` 中的小明开发流程非常接近:
+
+- `implement` 对应编码实现
+- `code_review` 对应代码审查
+- `risk_gate` 对应自测/质量门禁
+- `review` 对应人工 CR 审批
+- reject 回到 `implement` 对应根据 CR 修改
+- approve 后进入 `commit_and_push` 对应提交和合并流程
+
+差异是,当前 APP 把这些流程产品化成了可编辑 JSON,并且把 agent backend、model、workspace access、worktree、输出文件都放进 workflow 配置里。
+
+## 是否“完全按照 LangGraph 概念设计”
+
+答案:不是完全按照。
+
+更准确的判断:
+
+1. Runtime 层已经比较接近 LangGraph。
+2. 产品配置层不是 LangGraph 原生概念,而是自定义 Workflow DSL。
+3. UI 层使用 React Flow 表达图,但它展示的是产品 DSL 的 graph shape,不是 LangGraph 内部 graph 对象。
+4. 状态持久化是双层的:LangGraph checkpoint + APP 自己的 `workflow-state.json`。
+5. 当前没有实现 LangGraph 的 `Send` 并行调度能力。
+
+所以这不是“纯 LangGraph APP”,而是“LangGraph-backed workflow app”。
+
+## 这个设计的优点
+
+### 1. 更适合产品化
+
+直接暴露 LangGraph 原生 API 会很灵活,但不适合普通 UI 编辑。
+
+当前 DSL 把复杂能力收敛成几个产品节点,用户更容易理解:
+
+- agent step
+- condition step
+- checkpoint step
+- end step
+
+### 2. 更容易接入桌面工作流能力
+
+当前 APP 的核心不是单纯跑 graph,而是帮用户完成真实开发任务。
+
+所以它需要 LangGraph 之外的能力:
+
+- 创建 worktree
+- 调用 Claude/Codex SDK
+- 写 markdown artifact
+- 管理任务目录
+- 暂停、继续、拒绝、追加反馈
+- commit、push、创建 PR
+
+这些能力放在自定义 runtime 适配层里,比硬塞进 LangGraph 原生概念里更清晰。
+
+### 3. 更可控
+
+限制 DSL 能力可以避免用户构造过于复杂或不可控的 graph。
+
+对于当前产品阶段,线性流程、条件循环和人工审批已经覆盖主要开发工作流。
+
+## 这个设计的限制
+
+### 1. 并行工作流能力弱
+
+如果未来要支持“前端实现”和“后端实现”同时跑,再汇总结果,当前 DSL 需要扩展。
+
+可能需要增加:
+
+- `parallel` step
+- `fanout` / `fanin`
+- 子任务 state
+- 多分支 artifact 汇总
+- 多 agent 并发执行 UI
+
+### 2. State schema 不够通用
+
+当前 state 是为开发工作流设计的,不是一个通用的可配置 state schema。
+
+如果未来要支持更通用的业务 workflow,可能需要让 DSL 能声明:
+
+- state fields
+- reducer
+- input/output schema
+- artifact schema
+
+### 3. UI graph 和 runtime graph 不是完全同一个对象
+
+UI 使用的是 `workflowConfig.graph` 这种产品化 graph shape。
+
+它和 LangGraph 内部编译后的 graph 不是同一个对象。正常情况下这没问题,但如果未来加入更复杂的 LangGraph 特性,UI 表达能力也要同步升级。
+
+## 建议
+
+短期不需要追求“完全 LangGraph 原生化”。
+
+当前设计更适合这个 APP:
+
+- 产品层保留自定义 DSL
+- runtime 层继续用 LangGraph
+- 状态层保留 `workflow-state.json` 做桌面产品持久化
+- checkpoint 继续用 LangGraph checkpoint
+- UI 继续展示产品 DSL graph
+
+如果下一步要更接近 LangGraph,优先级建议是:
+
+1. 增加 `parallel` / `Send` 能力,支持真正的并行 agent 分支。
+2. 明确 LangGraph checkpoint 和 `workflow-state.json` 的职责边界。
+3. 把 Streaming 事件定义整理成稳定协议,而不是直接暴露 LangGraph 原始事件。
+4. 只有在需要通用 workflow 平台时,再考虑开放 state schema、reducer 和 subgraph。
+
+## 一句话总结
+
+当前 APP 没有完全照搬 LangGraph 的所有概念,但已经把 LangGraph 用在了最关键的执行层。它的架构本质是:用自定义 DSL 做产品表达,用 LangGraph 做状态图执行,用桌面 runtime 处理真实开发工作流所需的文件、agent、worktree 和人工审批。
diff --git a/docs/mobile-workflow-cloud-backend-plan.zh.md b/docs/mobile-workflow-cloud-backend-plan.zh.md
index 359fcfe..5a50180 100644
--- a/docs/mobile-workflow-cloud-backend-plan.zh.md
+++ b/docs/mobile-workflow-cloud-backend-plan.zh.md
@@ -125,7 +125,7 @@ Local Workflow Runtime
"event": {
"type": "state",
"state": {
- "ticketId": "TASK-123",
+ "taskId": "TASK-123",
"overallStatus": "awaiting_input",
"currentPhase": "review"
}
@@ -265,7 +265,7 @@ heartbeat
- `id`
- `user_id`
- `device_id`
-- `ticket_id`
+- `task_id`
- `work_folder`
- `overall_status`
- `current_phase`
diff --git a/docs/todo/todo-agent-activity-terminal-mode.zh.md b/docs/todo/todo-agent-activity-terminal-mode.zh.md
index 3a00e54..6c9319d 100644
--- a/docs/todo/todo-agent-activity-terminal-mode.zh.md
+++ b/docs/todo/todo-agent-activity-terminal-mode.zh.md
@@ -43,7 +43,7 @@
- `normalizeConversation()` 会把 `assistant_delta`、`tool_use` 等 interaction 组合成可渲染消息
- 当前已经有 loading row、tool 折叠块、用户输入框
-- `apps/desktop/renderer/src/pages/TicketPage.jsx`
+- `apps/desktop/renderer/src/pages/TaskPage.jsx`
- 任务详情页入口
- 负责选择当前 workflow step,并把 step 的 content、artifact、interactions 传给 `StepDetail`
diff --git a/docs/todo/todo-task-git-diff-history.zh.md b/docs/todo/todo-task-git-diff-history.zh.md
index 7182702..523f8a9 100644
--- a/docs/todo/todo-task-git-diff-history.zh.md
+++ b/docs/todo/todo-task-git-diff-history.zh.md
@@ -35,14 +35,14 @@
当前相关链路大致是:
-- `apps/desktop/renderer/src/pages/TicketPage.jsx`
+- `apps/desktop/renderer/src/pages/TaskPage.jsx`
- 任务详情页入口
- 已经展示 task id、phase、worktree badge、调试面板
- worktree badge 可以打开关联目录
- `apps/desktop/renderer/src/stores/workflowStore.js`
- 负责加载任务状态
- - `loadTicket()` 会读取 `state`、messages、artifacts、interactions
+ - `loadTask()` 会读取 `state`、messages、artifacts、interactions
- `apps/desktop/electron/api.mjs`
- `getTaskState()` 读取任务状态
@@ -69,7 +69,7 @@
- Files changed
- History
-第一阶段建议放在 `TicketPage.jsx` / `StepDetail.jsx` 附近,不要先引入复杂路由。
+第一阶段建议放在 `TaskPage.jsx` / `StepDetail.jsx` 附近,不要先引入复杂路由。
如果当前任务没有可用 Git 仓库,需要展示空状态:
diff --git a/package.json b/package.json
index 65ff955..fea1be1 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,7 @@
"fastify": "^5.6.1",
"multer": "^2.1.1",
"nanoid": "^5.1.6",
+ "openai": "^6.38.0",
"ws": "^8.20.0",
"zod": "^4.4.2"
},
diff --git a/packages/core-lib/claude.ts b/packages/core-lib/claude.ts
index 539abcc..324f9eb 100644
--- a/packages/core-lib/claude.ts
+++ b/packages/core-lib/claude.ts
@@ -8,6 +8,11 @@ export const AI_BACKENDS = {
CODEX: "codex",
};
+export const WORKTREE_NAMING_PROVIDERS = {
+ AI_API: "ai_api",
+ AI_BACKEND: "ai_backend",
+};
+
export const SUPPORTED_AI_BACKENDS = [AI_BACKENDS.CLAUDE, AI_BACKENDS.CODEX];
export const DEFAULT_AI_BACKEND = AI_BACKENDS.CLAUDE;
export const activeWorkflows = new Map();
@@ -16,6 +21,10 @@ export function normalizeAiBackend(value) {
return SUPPORTED_AI_BACKENDS.includes(value) ? value : DEFAULT_AI_BACKEND;
}
+export function normalizeWorktreeNamingProvider(value) {
+ return value === WORKTREE_NAMING_PROVIDERS.AI_BACKEND ? WORKTREE_NAMING_PROVIDERS.AI_BACKEND : WORKTREE_NAMING_PROVIDERS.AI_API;
+}
+
export function sendWorkflowEvent(send, data) {
if (!send) return;
try {
diff --git a/packages/core-lib/langgraph-runtime/app-adapter.ts b/packages/core-lib/langgraph-runtime/app-adapter.ts
index e67ee4b..fc1004d 100644
--- a/packages/core-lib/langgraph-runtime/app-adapter.ts
+++ b/packages/core-lib/langgraph-runtime/app-adapter.ts
@@ -1,8 +1,10 @@
+import OpenAI from "openai";
import { mkdir, readFile, writeFile } from "fs/promises";
import { dirname, relative, resolve } from "path";
+import { readAiApiProfileSync, readAiApiProfilesSync } from "../../core-models/config";
import { appendPhaseInteraction, appendToPhaseFile, readState, taskDir, updatePhaseStatus, writeState } from "../../core-models/state";
import { createSdkAgentAdapter } from "./sdk-agent-adapter";
-import { createContentPreview, createContentSummary, createStepOutputMetadata } from "./artifacts";
+import { createContentPreview, createContentSummary, createStepOutputMetadata, formatStepOutputForPrompt } from "./artifacts";
function getOutputByPath(step, artifactPath) {
const normalizedArtifactPath = resolve(artifactPath);
@@ -32,6 +34,44 @@ async function ensureOutputArtifact(taskId, runId, step, content, taskInputs) {
return artifactPath;
}
+function renderTemplate(template, state) {
+ const vars = {
+ taskId: state.taskId || "",
+ runId: state.runId || "",
+ workFolder: state.workFolder || "",
+ taskDir: state.taskDir || "",
+ ...(state.taskInputs || {}),
+ };
+ return String(template || "").replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
+}
+
+function renderAiApiPrompt(step, state, inputSections = []) {
+ const parts = [];
+ if (step.instructions) parts.push(renderTemplate(step.instructions, state));
+ if (step.prompt) parts.push(renderTemplate(step.prompt, state));
+ if (inputSections.length > 0) parts.push(["# Declared inputs", "", inputSections.join("\n\n")].join("\n"));
+
+ const previousOutputs = Object.entries(state.stepOutputs || {})
+ .map(([stepId, output]) => formatStepOutputForPrompt(stepId, output))
+ .filter(Boolean)
+ .join("\n\n");
+
+ parts.push([
+ "# Runtime context",
+ "",
+ `Task ID: ${state.taskId || ""}`,
+ `Run ID: ${state.runId || ""}`,
+ `Current step: ${step.id}`,
+ "",
+ "## Task inputs",
+ "",
+ JSON.stringify(state.taskInputs || {}, null, 2),
+ previousOutputs ? `\n## Previous step outputs\n\n${previousOutputs}` : "",
+ ].filter(Boolean).join("\n"));
+
+ return parts.filter(Boolean).join("\n\n");
+}
+
async function readInputContent(input, state) {
if (!input) return "";
if (input.sourceType === "task_input") return state.taskInputs?.[input.name] || "";
@@ -119,6 +159,78 @@ export function createAppSdkAgentAdapter({ taskId, runId, send, workFolder, task
},
});
+ async function runAiApiStep({ step, state }) {
+ const profile = step.aiApiProfileId ? readAiApiProfileSync(step.aiApiProfileId) : readAiApiProfilesSync()[0] || null;
+ if (!profile) throw new Error(`AI API profile not found: ${step.aiApiProfileId || "none"}`);
+ if (!profile.apiKey) throw new Error(`AI API profile ${profile.name} is missing an API key`);
+ if (!profile.model) throw new Error(`AI API profile ${profile.name} is missing a model`);
+
+ const backend = `ai-api:${profile.name || profile.id}`;
+ send({ type: "backend_selected", phase: step.id, backend, mode: "workflow" });
+ const inputSections = [];
+ for (const input of step.inputs || []) {
+ const content = await readInputContent(input, state);
+ if (content) inputSections.push(`## ${input.name}\n\n${content}`);
+ }
+ const prompt = renderAiApiPrompt(step, state, inputSections);
+ const client = new OpenAI({
+ apiKey: profile.apiKey,
+ baseURL: profile.baseUrl || undefined,
+ });
+ const completionOptions = abortController?.signal ? { signal: abortController.signal } : {};
+ const completion = await client.chat.completions.create({
+ model: profile.model,
+ messages: [
+ {
+ role: "system",
+ content: step.type === "condition"
+ ? "You are a workflow routing node. Return concise output that follows the user's requested format."
+ : "You are a workflow agent. Return concise output that follows the user's requested format.",
+ },
+ {
+ role: "user",
+ content: prompt,
+ },
+ ],
+ }, completionOptions);
+ const content = completion.choices?.[0]?.message?.content || "";
+ send({ type: "text_delta", phase: step.id, backend, text: content });
+ await appendPhaseInteraction(taskId, step.id, {
+ role: "assistant",
+ type: "assistant_delta",
+ text: content,
+ backend,
+ }, runId);
+ await appendToPhaseFile(taskId, step.id, content, runId);
+
+ const artifactPath = await ensureOutputArtifact(taskId, runId, step, content, state.taskInputs);
+ if (artifactPath) {
+ const output = getOutputByPath(step, artifactPath) || step.outputs?.[0];
+ if (output?.key) send({ type: "phase_artifact", phase: step.id, outputKey: output.key, content });
+ }
+ const summary = createContentSummary(content);
+ const contentPreview = createContentPreview(content);
+ const outputs = {};
+ for (const output of step.outputs || []) {
+ if (!output?.key) continue;
+ outputs[output.key] = {
+ kind: output.kind || "markdown",
+ summary,
+ contentPreview,
+ artifactPath,
+ status: artifactPath ? "ready" : "pending",
+ };
+ }
+
+ return {
+ content,
+ summary,
+ contentPreview,
+ artifactPath,
+ outputs,
+ };
+ }
+
return {
async publishCheckpoint({ step, state, action }) {
const rules = (step.publish || []).filter((rule) => rule.action === action);
@@ -161,8 +273,11 @@ export function createAppSdkAgentAdapter({ taskId, runId, send, workFolder, task
async runAgent({ step, agent, state, sessionId, sessionKey }) {
const runtimeSessionKey = sessionKey || step.id;
- const runtimeAgent = aiBackendOverride ? { ...agent, backend: aiBackendOverride, model: "" } : agent;
- send({ type: "backend_selected", phase: step.id, backend: runtimeAgent.backend, mode: aiBackendOverride ? "app" : "workflow" });
+ const hasStepBackend = Boolean(String(step.backend || "").trim());
+ const useBackendOverride = Boolean(aiBackendOverride && !hasStepBackend && agent.backend !== "ai_api");
+ const runtimeAgent = useBackendOverride ? { ...agent, backend: aiBackendOverride, model: "" } : agent;
+ if (runtimeAgent.backend === "ai_api") return runAiApiStep({ step, state });
+ send({ type: "backend_selected", phase: step.id, backend: runtimeAgent.backend, mode: useBackendOverride ? "app" : "workflow" });
const result = await adapter.runAgent({
step,
agent: runtimeAgent,
@@ -170,16 +285,15 @@ export function createAppSdkAgentAdapter({ taskId, runId, send, workFolder, task
sessionId,
sessionKey: runtimeSessionKey,
});
- const artifactPath = result.artifactPath || await ensureOutputArtifact(taskId, runId, step, result.content, state.taskInputs);
+ const artifactPath = result.artifactPath || "";
if (artifactPath) {
const output = getOutputByPath(step, artifactPath) || step.outputs?.[0];
if (output?.key) {
- let content = result.content || "";
- if (!content) {
- try {
- content = await readFile(artifactPath, "utf-8");
- } catch {}
- }
+ let content = "";
+ try {
+ content = await readFile(artifactPath, "utf-8");
+ } catch {}
+ if (!content) content = result.content || "";
send({ type: "phase_artifact", phase: step.id, outputKey: output.key, content });
}
}
@@ -189,5 +303,9 @@ export function createAppSdkAgentAdapter({ taskId, runId, send, workFolder, task
artifactPath,
};
},
+
+ async runAiApi({ step, state }) {
+ return runAiApiStep({ step, state });
+ },
};
}
diff --git a/packages/core-lib/langgraph-runtime/dsl.ts b/packages/core-lib/langgraph-runtime/dsl.ts
index f0e8cdd..4461ba7 100644
--- a/packages/core-lib/langgraph-runtime/dsl.ts
+++ b/packages/core-lib/langgraph-runtime/dsl.ts
@@ -99,16 +99,24 @@ function normalizeWorktree(rawWorktree) {
files: [],
customFiles: [],
removeOnComplete: false,
+ namingProvider: "ai_api",
+ namingAiApiProfileId: "",
useCustomSetupScript: false,
setupScript: "",
};
}
if (!isObject(rawWorktree)) throw new Error("worktree must be an object");
+ const namingProvider = normalizeOptionalString(rawWorktree.namingProvider) || "ai_api";
+ if (namingProvider !== "ai_api" && namingProvider !== "ai_backend") {
+ throw new Error("worktree.namingProvider must be ai_api or ai_backend");
+ }
return {
enabled: Boolean(rawWorktree.enabled),
files: normalizeOptionalStringArray(rawWorktree.files, "worktree.files"),
customFiles: normalizeOptionalStringArray(rawWorktree.customFiles, "worktree.customFiles"),
removeOnComplete: rawWorktree.removeOnComplete === true,
+ namingProvider,
+ namingAiApiProfileId: normalizeOptionalString(rawWorktree.namingAiApiProfileId),
useCustomSetupScript: rawWorktree.useCustomSetupScript === true,
setupScript: normalizeOptionalString(rawWorktree.setupScript),
};
@@ -171,10 +179,6 @@ function normalizeStep(rawStep, index) {
if (type === "agent" || type === "condition") {
step.instructions = normalizeOptionalString(rawStep.instructions);
step.prompt = normalizeOptionalString(rawStep.prompt);
- step.backend = normalizeOptionalString(rawStep.backend);
- step.model = normalizeOptionalString(rawStep.model);
- step.workspaceAccess = normalizeWorkspaceAccess(rawStep.workspaceAccess, `steps.${id}.workspaceAccess`);
- step.options = normalizeOptions(rawStep.options, `steps.${id}.options`);
const primaryOutput = isObject(rawStep.output)
? normalizeOutput({
key: rawStep.output.key || rawStep.output.filename || "output",
@@ -190,6 +194,14 @@ function normalizeStep(rawStep, index) {
: null;
}
+ if (type === "agent" || type === "condition") {
+ step.backend = normalizeOptionalString(rawStep.backend);
+ step.model = normalizeOptionalString(rawStep.model);
+ step.workspaceAccess = normalizeWorkspaceAccess(rawStep.workspaceAccess, `steps.${id}.workspaceAccess`);
+ step.options = normalizeOptions(rawStep.options, `steps.${id}.options`);
+ step.aiApiProfileId = normalizeOptionalString(rawStep.aiApiProfileId);
+ }
+
if (type === "condition") {
step.passTo = normalizeOptionalString(rawStep.passTo);
step.failTo = normalizeOptionalString(rawStep.failTo);
diff --git a/packages/core-lib/langgraph-runtime/sdk-agent-adapter.ts b/packages/core-lib/langgraph-runtime/sdk-agent-adapter.ts
index d6b4f9a..8d84f11 100644
--- a/packages/core-lib/langgraph-runtime/sdk-agent-adapter.ts
+++ b/packages/core-lib/langgraph-runtime/sdk-agent-adapter.ts
@@ -336,7 +336,7 @@ async function streamCodexSdk({ prompt, workFolder, sessionId, imagePaths, abort
}
function resolveOutputPath(taskDir, step, state) {
- const filename = renderTemplate(step.output?.filename || "", state);
+ const filename = renderTemplate(step.outputs?.[0]?.filename || "", state);
if (!filename) return "";
const outputPath = resolve(taskDir, filename);
if (!isPathInside(taskDir, outputPath)) throw new Error(`step ${step.id} output filename escapes taskDir`);
@@ -353,10 +353,12 @@ function resolveDeclaredOutputPath(taskDir, output, state) {
async function writeOutputArtifact(taskDir, step, state, content) {
const outputPath = resolveOutputPath(taskDir, step, state);
- if (!outputPath || content === undefined) return "";
- await mkdir(dirname(outputPath), { recursive: true });
- await writeFile(outputPath, content, "utf-8");
- return outputPath;
+ if (!outputPath) return "";
+ try {
+ await readFile(outputPath, "utf-8");
+ return outputPath;
+ } catch {}
+ return "";
}
export function createSdkAgentAdapter(options = {}) {
@@ -423,8 +425,14 @@ export function createSdkAgentAdapter(options = {}) {
}
const artifactPath = await writeOutputArtifact(taskDir, step, state, content);
- const summary = createContentSummary(content);
- const contentPreview = createContentPreview(content);
+ let artifactContent = content;
+ if (artifactPath) {
+ try {
+ artifactContent = await readFile(artifactPath, "utf-8");
+ } catch {}
+ }
+ const summary = createContentSummary(artifactContent);
+ const contentPreview = createContentPreview(artifactContent);
const outputs = {};
for (const output of step.outputs || []) {
const outputPath = resolveDeclaredOutputPath(taskDir, output, state);
diff --git a/packages/core-lib/worktree.ts b/packages/core-lib/worktree.ts
index 0ce9f18..5a42e98 100644
--- a/packages/core-lib/worktree.ts
+++ b/packages/core-lib/worktree.ts
@@ -108,8 +108,8 @@ async function branchExists(repoRoot, branchName) {
}
async function findAvailableWorktree(repoRoot, taskId, preferredName) {
- const ticketSlug = sanitizeNamePart(taskId, "task");
- const fallbackBranch = `chore/${ticketSlug}`;
+ const taskSlug = sanitizeNamePart(taskId, "task");
+ const fallbackBranch = `chore/${taskSlug}`;
const baseBranchName = normalizeBranchName(preferredName, fallbackBranch);
const baseDirName = branchToWorktreeName(baseBranchName);
const parentDir = dirname(repoRoot);
diff --git a/packages/core-models/config.ts b/packages/core-models/config.ts
index c9b7457..5cf8161 100644
--- a/packages/core-models/config.ts
+++ b/packages/core-models/config.ts
@@ -1,7 +1,8 @@
-import { join, dirname } from "path";
+import { basename, join, dirname } from "path";
import { readFile, writeFile, mkdir } from "fs/promises";
-import { readFileSync } from "fs";
+import { existsSync, readFileSync } from "fs";
import { fileURLToPath } from "url";
+import { createHash } from "crypto";
import os from "os";
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -10,11 +11,7 @@ let runtimeBaseDir = "";
let runtimeStorageDir = "";
export const LEGACY_WORKFLOW_DIR = join(PROJECT_ROOT, "workflows");
-function getDefaultDesktopUserDataDir() {
- if (process.env.DEV_WORKFLOW_USER_DATA_DIR) {
- return process.env.DEV_WORKFLOW_USER_DATA_DIR;
- }
-
+function getPlatformUserDataDir() {
switch (process.platform) {
case "darwin":
return join(os.homedir(), "Library", "Application Support", "dev-Workflow");
@@ -28,6 +25,31 @@ function getDefaultDesktopUserDataDir() {
}
}
+export function getSharedDesktopUserDataDir() {
+ return getPlatformUserDataDir();
+}
+
+function isDevelopmentCheckout() {
+ return (
+ existsSync(join(PROJECT_ROOT, "package.json")) &&
+ existsSync(join(PROJECT_ROOT, "apps", "desktop", "electron", "main.ts"))
+ );
+}
+
+function getWorktreeScopedUserDataDir(baseDir) {
+ const hash = createHash("sha256").update(PROJECT_ROOT).digest("hex").slice(0, 8);
+ return join(baseDir, "worktrees", `${basename(PROJECT_ROOT)}-${hash}`);
+}
+
+export function getDefaultDesktopUserDataDir() {
+ if (process.env.DEV_WORKFLOW_USER_DATA_DIR) {
+ return process.env.DEV_WORKFLOW_USER_DATA_DIR;
+ }
+
+ const baseDir = getPlatformUserDataDir();
+ return isDevelopmentCheckout() ? getWorktreeScopedUserDataDir(baseDir) : baseDir;
+}
+
export const CONFIG_FILE = join(getDefaultDesktopUserDataDir(), "config.json");
function parseJson(raw) {
@@ -105,6 +127,98 @@ export async function saveAiBackendOverride(backend) {
return config.aiBackendOverride;
}
+const DEFAULT_AI_API_BASE_URL = "https://api.openai.com/v1";
+
+function slugifyConfigId(value, fallback = "ai-api") {
+ const slug = String(value || "")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "");
+ return slug || fallback;
+}
+
+function normalizeAiApiProfile(profile, existingProfile = null) {
+ const name = String(profile?.name || "").trim();
+ const id = slugifyConfigId(profile?.id || name || existingProfile?.id);
+ const apiKey = String(profile?.apiKey || "").trim() || existingProfile?.apiKey || "";
+ return {
+ id,
+ name: name || existingProfile?.name || id,
+ baseUrl: String(profile?.baseUrl || existingProfile?.baseUrl || DEFAULT_AI_API_BASE_URL).trim() || DEFAULT_AI_API_BASE_URL,
+ apiKey,
+ model: String(profile?.model || existingProfile?.model || "").trim(),
+ };
+}
+
+function normalizeAiApiProfiles(profiles = []) {
+ if (!Array.isArray(profiles)) return [];
+ const seen = new Set();
+ const result = [];
+ for (const profile of profiles) {
+ const normalized = normalizeAiApiProfile(profile);
+ if (!normalized.id || seen.has(normalized.id)) continue;
+ seen.add(normalized.id);
+ result.push(normalized);
+ }
+ return result;
+}
+
+function redactAiApiProfile(profile) {
+ return {
+ id: profile.id,
+ name: profile.name,
+ baseUrl: profile.baseUrl,
+ model: profile.model,
+ hasApiKey: Boolean(profile.apiKey),
+ };
+}
+
+export function readAiApiProfilesSync() {
+ const config = readConfigSync();
+ return normalizeAiApiProfiles(config.aiApiProfiles);
+}
+
+export function readAiApiProfileSync(id) {
+ const profileId = slugifyConfigId(id, "");
+ if (!profileId) return null;
+ return readAiApiProfilesSync().find((profile) => profile.id === profileId) || null;
+}
+
+export async function readAiApiProfiles() {
+ const config = await readConfig();
+ return normalizeAiApiProfiles(config.aiApiProfiles);
+}
+
+export async function readAiApiProfilesForUi() {
+ return (await readAiApiProfiles()).map(redactAiApiProfile);
+}
+
+export async function saveAiApiProfile(profile) {
+ const config = await readConfig();
+ const profiles = normalizeAiApiProfiles(config.aiApiProfiles);
+ const id = slugifyConfigId(profile?.id || profile?.name, "");
+ const existing = profiles.find((item) => item.id === id) || null;
+ if (!String(profile?.name || existing?.name || "").trim()) throw new Error("AI API name is required");
+ const normalized = normalizeAiApiProfile(profile, existing);
+ if (!normalized.model) throw new Error("AI API model is required");
+ if (!normalized.apiKey) throw new Error("AI API key is required");
+
+ const nextProfiles = profiles.filter((item) => item.id !== normalized.id);
+ nextProfiles.push(normalized);
+ config.aiApiProfiles = nextProfiles;
+ await saveConfig(config);
+ return nextProfiles.map(redactAiApiProfile);
+}
+
+export async function deleteAiApiProfile(id) {
+ const profileId = slugifyConfigId(id, "");
+ if (!profileId) throw new Error("AI API profile id is required");
+ const config = await readConfig();
+ config.aiApiProfiles = normalizeAiApiProfiles(config.aiApiProfiles).filter((profile) => profile.id !== profileId);
+ await saveConfig(config);
+ return config.aiApiProfiles.map(redactAiApiProfile);
+}
+
export function setRuntimeBaseDir(baseDir) {
runtimeBaseDir = baseDir || "";
}
@@ -126,7 +240,7 @@ export function getSkillsDir() {
}
export function getWorkflowDir() {
- return join(getStorageDir(), "workflows");
+ return join(getSharedDesktopUserDataDir(), "workflows");
}
export function getWorkfoldersFile(baseDir) {
diff --git a/packages/core-models/debug-events.ts b/packages/core-models/debug-events.ts
new file mode 100644
index 0000000..4126dbb
--- /dev/null
+++ b/packages/core-models/debug-events.ts
@@ -0,0 +1,18 @@
+export const WORKFLOW_DEBUG_EVENT_TYPES = {
+ STATE: "state",
+ TEXT_DELTA: "text_delta",
+ TOOL_USE: "tool_use",
+ BACKEND_SELECTED: "backend_selected",
+ SESSION_ATTACHED: "session_attached",
+ USER_MESSAGE: "user_message",
+ PHASE_ARTIFACT: "phase_artifact",
+ PHASE_CONTENT: "phase_content",
+ PHASE_INTERACTION: "phase_interaction",
+ PHASE_DONE: "phase_done",
+ PHASE_COMPLETED: "phase_completed",
+ WORKFLOW_STARTING: "workflow_starting",
+ WORKTREE_NAMING_STARTED: "worktree_naming_started",
+ WORKTREE_NAMING_COMPLETED: "worktree_naming_completed",
+ WORKTREE_PREPARING: "worktree_preparing",
+ WORKTREE_READY: "worktree_ready",
+} as const;
diff --git a/packages/core-models/state.ts b/packages/core-models/state.ts
index 8de0a5b..3a60c55 100644
--- a/packages/core-models/state.ts
+++ b/packages/core-models/state.ts
@@ -22,7 +22,7 @@ async function resolveTaskRunId(taskId, requestedRunId = "") {
const folders = JSON.parse(await readFile(file, "utf-8"));
for (const folder of folders || []) {
for (const task of folder.tasks || []) {
- const storedTaskId = task.taskId || task.ticketId;
+ const storedTaskId = task.taskId;
if (storedTaskId === taskId) {
return assertSafeRunId(task.runId || storedTaskId);
}
diff --git a/packages/core-models/workflow.ts b/packages/core-models/workflow.ts
index 3d37fa6..a3050e8 100644
--- a/packages/core-models/workflow.ts
+++ b/packages/core-models/workflow.ts
@@ -62,6 +62,7 @@ function migrateLegacyWorkflowsSync() {
function getStepBackend(workflow, step) {
if (step.type !== "agent" && step.type !== "condition") return "";
+ if (step.backend === "ai_api") return step.aiApiProfileId ? `ai-api:${step.aiApiProfileId}` : "ai-api";
return step.backend || workflow?.runtime?.backend || "";
}
@@ -171,7 +172,7 @@ export function getWorkflowConfigShape(workflow = WORKFLOW) {
rejectTargets: {},
conditionRoutes: {},
taskInputFields: [],
- worktree: { enabled: false, files: [], customFiles: [], removeOnComplete: false, useCustomSetupScript: false, setupScript: "" },
+ worktree: { enabled: false, files: [], customFiles: [], removeOnComplete: false, namingProvider: "ai_api", namingAiApiProfileId: "", useCustomSetupScript: false, setupScript: "" },
};
}
@@ -228,7 +229,7 @@ export function getWorkflowConfigShape(workflow = WORKFLOW) {
rejectTargets,
conditionRoutes,
taskInputFields: deriveTaskInputFields(workflow),
- worktree: workflow.worktree || { enabled: false, files: [], customFiles: [], removeOnComplete: false, useCustomSetupScript: false, setupScript: "" },
+ worktree: workflow.worktree || { enabled: false, files: [], customFiles: [], removeOnComplete: false, namingProvider: "ai_api", namingAiApiProfileId: "", useCustomSetupScript: false, setupScript: "" },
};
}
diff --git a/packages/core-models/workfolders.ts b/packages/core-models/workfolders.ts
index 3fba340..7e4d538 100644
--- a/packages/core-models/workfolders.ts
+++ b/packages/core-models/workfolders.ts
@@ -5,7 +5,7 @@ import { assertSafeRunId, readState, getTaskRunId, writeState } from "./state";
import { removeWorktree } from "../core-lib/worktree";
function getStoredTaskId(task) {
- return task?.taskId || task?.ticketId || "";
+ return task?.taskId || "";
}
function normalizeTask(task) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index de710e2..b84fddf 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -19,7 +19,7 @@ importers:
version: 11.2.0
'@langchain/langgraph':
specifier: ^1.3.0
- version: 1.3.0(@langchain/core@1.1.45(ws@8.20.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(ws@8.20.0)(zod-to-json-schema@3.25.2(zod@4.4.2))(zod@4.4.2)
+ version: 1.3.0(@langchain/core@1.1.45(openai@6.38.0(ws@8.20.0)(zod@4.4.2))(ws@8.20.0))(openai@6.38.0(ws@8.20.0)(zod@4.4.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(ws@8.20.0)(zod-to-json-schema@3.25.2(zod@4.4.2))(zod@4.4.2)
'@openai/codex':
specifier: ^0.128.0
version: 0.128.0
@@ -41,6 +41,9 @@ importers:
nanoid:
specifier: ^5.1.6
version: 5.1.11
+ openai:
+ specifier: ^6.38.0
+ version: 6.38.0(ws@8.20.0)(zod@4.4.2)
ws:
specifier: ^8.20.0
version: 8.20.0
@@ -3477,6 +3480,18 @@ packages:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
+ openai@6.38.0:
+ resolution: {integrity: sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g==}
+ hasBin: true
+ peerDependencies:
+ ws: ^8.18.0
+ zod: ^3.25 || ^4.0
+ peerDependenciesMeta:
+ ws:
+ optional: true
+ zod:
+ optional: true
+
ora@5.4.1:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'}
@@ -5647,7 +5662,7 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
- '@langchain/core@1.1.45(ws@8.20.0)':
+ '@langchain/core@1.1.45(openai@6.38.0(ws@8.20.0)(zod@4.4.2))(ws@8.20.0)':
dependencies:
'@cfworker/json-schema': 4.1.1
'@standard-schema/spec': 1.1.0
@@ -5655,7 +5670,7 @@ snapshots:
camelcase: 6.3.0
decamelize: 1.2.0
js-tiktoken: 1.0.21
- langsmith: 0.6.3(ws@8.20.0)
+ langsmith: 0.6.3(openai@6.38.0(ws@8.20.0)(zod@4.4.2))(ws@8.20.0)
mustache: 4.2.0
p-queue: 6.6.2
zod: 4.4.2
@@ -5666,14 +5681,14 @@ snapshots:
- openai
- ws
- '@langchain/langgraph-checkpoint@1.0.2(@langchain/core@1.1.45(ws@8.20.0))':
+ '@langchain/langgraph-checkpoint@1.0.2(@langchain/core@1.1.45(openai@6.38.0(ws@8.20.0)(zod@4.4.2))(ws@8.20.0))':
dependencies:
- '@langchain/core': 1.1.45(ws@8.20.0)
+ '@langchain/core': 1.1.45(openai@6.38.0(ws@8.20.0)(zod@4.4.2))(ws@8.20.0)
uuid: 10.0.0
- '@langchain/langgraph-sdk@1.9.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(ws@8.20.0)':
+ '@langchain/langgraph-sdk@1.9.1(openai@6.38.0(ws@8.20.0)(zod@4.4.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(ws@8.20.0)':
dependencies:
- '@langchain/core': 1.1.45(ws@8.20.0)
+ '@langchain/core': 1.1.45(openai@6.38.0(ws@8.20.0)(zod@4.4.2))(ws@8.20.0)
'@langchain/protocol': 0.0.15
'@types/json-schema': 7.0.15
p-queue: 9.2.0
@@ -5689,11 +5704,11 @@ snapshots:
- openai
- ws
- '@langchain/langgraph@1.3.0(@langchain/core@1.1.45(ws@8.20.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(ws@8.20.0)(zod-to-json-schema@3.25.2(zod@4.4.2))(zod@4.4.2)':
+ '@langchain/langgraph@1.3.0(@langchain/core@1.1.45(openai@6.38.0(ws@8.20.0)(zod@4.4.2))(ws@8.20.0))(openai@6.38.0(ws@8.20.0)(zod@4.4.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(ws@8.20.0)(zod-to-json-schema@3.25.2(zod@4.4.2))(zod@4.4.2)':
dependencies:
- '@langchain/core': 1.1.45(ws@8.20.0)
- '@langchain/langgraph-checkpoint': 1.0.2(@langchain/core@1.1.45(ws@8.20.0))
- '@langchain/langgraph-sdk': 1.9.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(ws@8.20.0)
+ '@langchain/core': 1.1.45(openai@6.38.0(ws@8.20.0)(zod@4.4.2))(ws@8.20.0)
+ '@langchain/langgraph-checkpoint': 1.0.2(@langchain/core@1.1.45(openai@6.38.0(ws@8.20.0)(zod@4.4.2))(ws@8.20.0))
+ '@langchain/langgraph-sdk': 1.9.1(openai@6.38.0(ws@8.20.0)(zod@4.4.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(ws@8.20.0)
'@langchain/protocol': 0.0.15
'@standard-schema/spec': 1.1.0
uuid: 10.0.0
@@ -7895,10 +7910,11 @@ snapshots:
dependencies:
json-buffer: 3.0.1
- langsmith@0.6.3(ws@8.20.0):
+ langsmith@0.6.3(openai@6.38.0(ws@8.20.0)(zod@4.4.2))(ws@8.20.0):
dependencies:
p-queue: 6.6.2
optionalDependencies:
+ openai: 6.38.0(ws@8.20.0)(zod@4.4.2)
ws: 8.20.0
light-my-request@6.6.0:
@@ -8481,6 +8497,11 @@ snapshots:
dependencies:
mimic-fn: 2.1.0
+ openai@6.38.0(ws@8.20.0)(zod@4.4.2):
+ optionalDependencies:
+ ws: 8.20.0
+ zod: 4.4.2
+
ora@5.4.1:
dependencies:
bl: 4.1.0