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() {
)} 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