<!-- 文档分类: DESIGN-ARCH-009 | 阶段: 设计 | 原路径: docs/架构/可视化拖拽工作流编辑器-2026-06-09.md -->
# 可视化拖拽工作流编辑器（MVP / Phase 1）

> 2026-06-09 ｜ 分支 `restructure/full-forest`
> 学习参考：kirara-ai（块引擎 + `/api/block/types`）、cc-wf-studio（React Flow → Markdown 代码生成）、coze-studio（Canvas{nodes,edges} + 字面量/引用绑定）。

## 1. 目标与范围

让 KHY 具备**可视化拖拽工作流**能力。经四项范围决策落地为本期 MVP：

| 维度 | 决策 |
| --- | --- |
| 执行模型 | **分阶段**。Phase 1（本期）把画布图导出为 Markdown，交由**既有 Agent harness** 运行；画布 JSON 是唯一真源，Markdown 单向派生。原生 DAG 执行器为 Phase 2（仅设计）。 |
| 编辑器位置 | **用户页**（per-user 隔离，`req.user.id`），镜像 MyGateway 多租户模式，非 admin。 |
| 节点目录 | 四类全覆盖：基础控制流 / Agent / 数据集成 / 人机交互，共 11 类节点。 |
| 首交付 | **端到端可用 MVP**：拖拽画布 + 节点面板 + 属性面板 + REST 存取 + 导出 Markdown。 |

## 2. 唯一契约：画布 JSON

存于 `user_workflows.graph_json`（TEXT，get/set 自动 JSON 解析）。前端画布、后端存储、导出器三方共用。

```jsonc
{
  "id": "...", "name": "...", "description": "", "version": 1,   // 每次保存 +1
  "nodes": [{ "id":"n_...", "type":"start", "name":"Start",
              "position":{"x":120,"y":80}, "data":{ /* 按类型 */ } }],
  "connections": [{ "id":"e_...", "from":"n_a","fromPort":"default",
                    "to":"n_b","toPort":"input", "condition":null }]
}
```

端口约定：非分支节点用 `input`（target）/ `default`（source）；分支节点声明多 source 端口——`ifElse` 用 `branch-true`/`branch-false`，`loop` 用 `loop-body`/`loop-done`。

## 3. 节点目录（单一真源）

`platform/packages/shared/src/workflow/nodeCatalog.js`（CJS），`package.json` exports 暴露 `./workflow/nodeCatalog`。后端 `/api/workflow/node-types` 端点、图校验、导出器三方共用；**前端运行时经 HTTP 获取**，杜绝重复定义漂移。

- control：start(单例) / end / prompt / ifElse / loop
- agent：subAgent / toolCall / skill
- data：code / http
- human：askUserQuestion

字段控件 `widget` ∈ `text | textarea | number | select | code | keyvalue-list | string-list | var-ref`，驱动属性面板渲染。**扩展 = 加 1 条目录项 + 1 条导出映射 + 1 个节点视觉（BaseNode 已通用兜底）**。

## 4. 后端 REST（services/ai-backend）

- 模型 `platform/packages/shared/src/models/UserWorkflow.js`（镜像 UserProvider），表 `user_workflows`，`user_id` 索引；`models/index.js` 注册 + `User.hasMany`/`belongsTo` 关联。
- 服务 `workflowService.js`：CRUD（全部 `where:{userId,id}`），表用**模型级 `UserWorkflow.sync()`** 惰性创建（ai-backend 不跑全局 sync，仅建 `user_workflows`，不碰其他表）。
- 校验 `workflowService.validateGraph(graph, {strict})`：
  - **完整性**（每次保存）：节点 ≤100、边 ≤300；节点 id 唯一 + 类型已知；连接 id 唯一、端点存在、端口与目录规范一致。
  - **完备性**（仅导出 `strict`）：恰好 1 个 start（无入边）+ ≥1 个 end（无出边）。半成品图可正常保存，但不可导出/运行。
- 路由 `routes/workflow.js`（`authenticateToken` only）：`GET /`、`GET /node-types`（在 `/:id` 前声明）、`GET /:id`、`POST /`、`PUT /:id`、`POST /:id/export`、`DELETE /:id`。`{success,data}` 包络。
- `server.js` 挂载 `app.use('/api/workflow', ...)`。

## 5. Markdown 导出管线（Phase 1 收益）

`workflowExportService.js`，落点为 harness 始终扫描的 home 根目录（与 cwd 无关，**无需改 harness**）：

- 主工作流 → `~/.khyquant/skills/wf-<userId>-<kebab(name)>/SKILL.md`（slug 编码归属 + 逻辑隔离）。
- 每个 `subAgent` 节点 → `~/.khy/agents/<agentName>.md`。

`SKILL.md` = 可被 `skillLoader.parseSkillContent` 解析的 frontmatter（name/version/description/layer/lifecycle/tags/platforms）+ 正文 `## Flowchart`（Mermaid `flowchart TD`，分支边带 `|真|`/`|假|` 标签）+ `## Execution Steps`（从 start BFS 线性化、显式 "→ step N" 跳转标注）。agent `.md` frontmatter 匹配 `loadAgents.parseFrontmatter`（name/description/model/tools/maxTurns），正文 = 节点 `instructions`。

导出端点返回写入的绝对路径清单 + `goal: run <slug>` 运行提示。**已用真实 harness loader 验证生成产物可解析**。

## 6. 前端（apps/ai-frontend）

新增 `@vue-flow/core`（+ background/controls/minimap）。Vue Flow **受控模式**：Pinia store（`stores/workflowEditor.js`）为唯一真源，`applyNodeChanges`/`applyEdgeChanges`/`addEdge` 在事件回调中改写 store。

- `composables/useWorkflow.js` — REST 客户端（镜像 useUserGateway）。
- `views/Workflows.vue` — 列表（建/改名/删）。
- `views/WorkflowEditor.vue` — 工具栏（保存/导出+结果弹窗）+ 面板/画布/属性三栏。
- `components/workflow/`：`WorkflowCanvas.vue`（受控 VueFlow + 拖拽落点 `screenToFlowCoordinate`）、`NodePalette.vue`（HTML5 drag）、`NodePropertiesPanel.vue` + `NodeField.vue`（目录驱动 8 种控件）、`nodes/BaseNode.vue`（目录驱动端口/分支标签的通用节点）、`graphAdapters.js`（canvas ⇄ canonical graph）。
- 路由：Layout 子路由 `workflows` / `workflows/:id`（**无** requiresAdmin）；`Layout.vue` USER_MENU 加「工作流」。

## 7. 验证

- 后端：`test/workflow.routes.test.js`（20）+ `test/workflowExport.test.js`（4），SQLite-before-require + tmp HOME，覆盖 CRUD / 图 round-trip / 多租户隔离 / 完整性 400 / 完备性 strict / 导出落盘 + 真 loader 解析。**24/24 绿**。
- 前端：`vite build` 绿，`WorkflowEditor` 懒加载块 ~234kB（500kB 警告为既有 index 块，非本期引入）。

## 8. Phase 2：原生 DAG 执行器（已实现，2026-06-09）

同一画布 JSON 直接执行（**非** Markdown 派生），与导出管线并存。两项架构决策：执行器落 `services/backend`（Agent 引擎，复用真原语而非降级）；本期范围=**可跑通的最小闭环**（顺序执行 + 状态回写 + 前端运行面板，askUserQuestion 人机暂停/恢复留下一刀）。

### 8.1 跨进程队列（唯一桥）

新表 `workflow_runs`（`platform/packages/shared/src/models/WorkflowRun.js`，Active-Record 工作队列）：`user_id`/`workflow_id`(索引、FK)、`status`(queued|running|succeeded|failed，索引)、`graph_json`(**入队时快照**，事后改源工作流不影响在途运行)、`vars_json`、`log_json`、`error`、`started_at`/`finished_at`。`models/index.js` 注册 + `User`/`UserWorkflow` 关联。

ai-backend（生产者）与 backend（消费者）**永不互调**，仅经此表通信。

### 8.2 解释器（services/backend/src/services/workflow/workflowExecutor.js）

**单游标**（非拓扑排序）——图含环（loop 回边）：执行当前节点 → 跟随节点选定的输出端口（`default`/`branch-true|false`/`loop-body|done`）到下一节点，直至 end 或悬空端口；`MAX_STEPS=1000` 兜底防死循环。

- 副作用经**注入式 `primitives`**：worker 供真后端基础设施（`chat`/`executeTool`/`executeSkill`/`runSubAgent`/`runCode`/`http`），测试供 mock → 解释器纯、可单测不启引擎。
- `ifElse` 用**受约束比较器**（`a OP b`，`resolveOperand` 处理 `{{var}}`/引号串/数字/布尔/裸变量），**绝不 eval/Function** 执行存储图。
- `interpolate({{var.path}})` + `resolveArgs`（精确 `{{ref}}` 保留类型，否则插值入串）。
- `askUserQuestion` 为 **Phase-2 占位**：自动答 `options[0]`、状态 `skipped`，真中断/恢复（`workflowStatus.js`）留下一刀。

### 8.3 消费者（workflowRunWorker.js）

镜像 `credentialWatcherService` 轮询模式。安全护栏（与实盘 backend 同进程）：

- env `KHY_WORKFLOW_WORKER`（默认开，`0`/`false`/`off` 关）；`KHY_WORKFLOW_POLL_MS` 默认 2500。
- **表存在探针**：ai-backend 惰性建表，worker 建表前 `showAllTables` 探测，未就绪静默空转。
- **原子认领**：`UPDATE status queued→running WHERE id=? AND status='queued'`，校验受影响行数==0 则让出（两 tick/未来多 worker 永不重复执行）。
- 每 tick 全 try/catch 包裹；`_ticking` 防重入；`timer.unref()` 不阻塞退出；串行单运行/ tick 限载。
- `onLog` 流式回写 `log_json`，前端轮询可见分步进度。挂载于 `server.js`（sync 后、env 守卫）+ 双 shutdown hook。

### 8.4 触发 REST（services/ai-backend）

`workflowRunService.js`（队列生产者，惰性 `WorkflowRun.sync()`）：`enqueue`（`workflowService.get` 租户隔离取图 → **strict 校验**（不可运行半成品图）→ 快照入队 `queued`）、`getRun`、`listRuns`，全部 `where:{userId}`。路由：`POST /:id/run`、`GET /runs/:runId`（声明于 `/:id` 前）、`GET /:id/runs`。

### 8.5 前端运行面板（WorkflowEditor.vue）

「运行」按钮（先保存脏图 → enqueue → 抽屉 → **1.2–1.5s 轮询** `getRun` → 终态停轮询，`onBeforeUnmount` 清理）。抽屉显示：运行状态标签、错误告警、逐节点日志（状态/名/类型/摘要/错误）、最终变量。`useWorkflow.js` 增 `runWorkflow`/`getRun`/`listRuns`。

### 8.6 验证

后端 `tests/workflowExecutor.test.js`(13：纯助手 + 线性/ifElse 真假/count 循环/占位 skip/各类型派发/失败包裹/缺 start/MAX_STEPS) + `tests/workflowRunWorker.test.js`(3：认领→succeeded、失败→failed、原子认领)。ai-backend `test/workflowRun.routes.test.js`(8：入队/带 vars/不完整图 400/跨租户 404/无鉴权 401/读取/租户隔离/列表)。**Phase 2 共 24 测试绿，叠加 Phase 1 共 48 绿，前端 build 绿，零回归。**

### 8.7 人机交互真暂停 / 恢复（askUserQuestion，2026-06-09）

§8.2 占位（自动答 `options[0]`）升级为**跨进程真暂停 / 恢复**。关键判断：暂停跨越 ai-backend↔backend 进程边界，恢复检查点**必须落 DB**（`workflow_runs` 两列），不可用单进程内存的 `workflowStatus.js`（刻舟求剑）。

- **模型新增**：状态枚举加 `awaiting_input`；两列 JSON-as-TEXT 检查点——`pending_json`（`{ nodeId, question, options, answerVar, loopState }`，暂停现场）、`resume_json`（`{ answer }`，用户回答）。
- **解释器**：`runGraph` 新增 `options.pauseOnAsk` 与 `options.resume({nodeId,answer,loopState})`，返回 `{ status:'completed'|'paused', vars, log, pause? }`。命中 `askUserQuestion`：若 `pauseOnAsk` 则记 `awaiting_input` 日志 + 返回 `paused` 检查点（含**插值后**问题 + `loopState` 快照）；若 `resume.nodeId` 命中本节点则注入 `answer` 入 `answerVar`、标 `succeeded`、清检查点续跑；无 `pauseOnAsk` 时保留旧占位 skip（向后兼容）。游标从 `resume.nodeId` 起步，`loopState` 从 Map 还原 → 循环内的 ask 不会重置计数。
- **消费者**：`executeRun` 检测 `pending_json + resume_json` 即恢复模式——构造 `resume` 选项、丢弃尾部 `awaiting_input` 占位日志（恢复节点自重记 succeeded）；`paused` 结果写 `awaiting_input` + `pending_json`；恢复成功写 `succeeded`。重新入队（`status→queued`）后既有认领循环自然重拾。
- **触发 REST**：`workflowRunService.answer(userId, runId, {answer})`——租户隔离取行、校验 `status==='awaiting_input'`（否则 409）、校验 `answer ∈ options`（否则 400，空 options 放行自由文本）、写 `resume_json` + `status→queued`；路由 `POST /runs/:runId/answer`；`toRunView` 增 `pending`。
- **前端**：抽屉在 `awaiting_input` 时渲染回答表单（有 options 用单选，否则文本域）→ `answerRun` → 回 `queued` 后恢复轮询；`PARKED` 态停轮询等待用户。
- **验证**：解释器 +6（暂停检查点 / 恢复注入续跑 / loopState 往返 / 向后兼容占位 / 恢复缺节点抛错）、worker +1（暂停→`awaiting_input` 持久化→重入队恢复→succeeded）、ai-backend +5（重入队写 resume / 非选项 400 / 非 awaiting 409 / 跨租户 404 / 自由文本）。**本刀共 +12，Phase 2 累计 36，叠加 Phase 1 共 60 测试绿，前端 build 绿，零回归。**

### 8.8 画布节点状态实时叠加（2026-06-09）

运行时把逐节点状态叠加到画布本身（此前仅在抽屉日志可见）。**关键约束**：不得污染保存图 / dirty 标记——状态经 provide/inject 旁路传入，不写 `store.nodes[].data`。

- **WorkflowEditor.vue**：computed `runNodeStatus`（遍历 `run.log`，**同节点取最新**条目→循环体显示最近一遍；抽屉关闭即空，画布回中性编辑态），以 `:run-status` 传入画布。
- **WorkflowCanvas.vue**：新 prop `runStatus`，`provide('wfRunStatus', computed(...))`（与既有 `wfCatalog` 同模式）。
- **BaseNode.vue**：`inject('wfRunStatus')` → 按本节点 `id` 取状态 → 加 `run-<status>` 类 + 头部字形徽标（▶/⏸/✓/✕/–）。running 脉冲动画、awaiting 琥珀、succeeded 绿、failed 红、skipped 淡化。节点 id 经 `graphAdapters` 往返不变 → 日志 `nodeId` 直接命中画布节点。
- **反应式**：provide 的 computed 随轮询刷新自动驱动 BaseNode 重算（复用已验证的 catalog inject 模式，无需触碰 VueFlow 节点标识）。
- **验证**：纯前端，build 绿，零回归（60 后端/ai-backend 测试不受影响）。

### 8.9 运行态 SSE 实时推送替代轮询（2026-06-09）

抽屉/画布原先靠前端 N 次 `GET /runs/:id` 轮询刷新；改为**单条长连接服务端推送**。**关键约束**：worker（services/backend）与 ai-backend 无 IPC，唯一共享是 `workflow_runs` 行——所以 ai-backend 端**服务端轮询 DB 行**、变化即推浏览器，是一座诚实的「DB-poll→push」桥，而非真事件总线（状态透明）。

- **后端 SSE 路由**：`GET /api/workflow/runs/:runId/events`。先 `runSvc.getRun` 做租户/存在校验（失败走 `fail()` 返常规 JSON 404，**不升级为死流**）；再 `writeHead(text/event-stream)`。`send(view)` 写 `data: <json>\n\n` 并记 `lastSig=status:logLen`，`finish()` 写 `event: done` 后 `res.end()`。初始快照后若已终态/驻留（`succeeded/failed/awaiting_input`）立即 `finish`；否则起 `poll`（`KHY_WORKFLOW_SSE_MS`，默认 800ms，签名变化才推、命中 halt 即收）+ `keepalive`（25s 注释帧）。`req.on('close')` 清理双 interval。**零硬编码**：轮询周期走 env。
- **SSE 鉴权约束**：共享 `authenticateToken` 只读 `Authorization` 头，原生 `EventSource` 无法设头 → 前端 `useWorkflow.streamRun(runId,{onUpdate,onDone,onError})` 改用 `fetch + ReadableStream` reader，显式带 `Bearer <token>`（取自 `useUserStore().token`，baseURL 取自 `request.defaults.baseURL`），自解析 SSE 帧（`\n\n` 分帧、跳 `:` keepalive、识 `event: done`）；返回 `abort()` 闭包。
- **前端接线（WorkflowEditor.vue）**：新 `track(runId)`——**SSE 优先**（`streamRun` 更新 `run.value`），`onError` 时**降级到 `pollRun`**（代理/旧后端/网络异常仍可用）；`stopWatch()` 统一停流+停定时器；`doRun`/`submitAnswer` 改调 `track`，`onBeforeUnmount` 调 `stopWatch`。
- **TDZ 陷阱（已修）**：终态运行在 interval 声明前即 `finish()→cleanup()`，原 `const poll/keepalive` 触发暂时性死区 `ReferenceError`，致 `res.end()` 不执行、supertest 挂死。改为 `let poll=null/keepalive=null` 上提 + `cleanup` 内空值守卫。
- **验证**：ai-backend +3（终态运行推快照+`event: done`、驻留运行同样单帧即收、跨租户 404 不升级流）；测试用终态/驻留种子使流即开即收，supertest 可断言无挂连接。**前端 build 绿，ai-backend 16 测试全绿且无 open-handle 警告。**

### 8.10 forEach / 嵌套 loop 恢复深测 + 嵌套修复（2026-06-09）

深测循环跨暂停/恢复边界，**发现并修复嵌套循环 bug**。

- **嵌套 bug**：单游标解释器原把 `loopState` 保活整个运行周期，重入的内层循环看到陈旧终态计数 → 不再重跑。**修复**：`case 'loop'` 的 loop-done 分支加 `loopState.delete(node.id)`——抽干即清计数，重入从 0 起；仅 ACTIVE（迭代中）循环留 Map，故暂停快照恰好捕获在跑的循环。
- **深测（executor +4）**：forEach 遍历数组暴露 `itemVar`；嵌套 count `bodyRuns===6`（2×3，内层每趟外层重置）；forEach 暂停/恢复（vars + loopState 跨 2 item 往返）；嵌套暂停/恢复**双计数器还原**（`bodyRuns===4`、`answers===4`，首暂停 `{outer:{i:1},inner:{i:1}}`）。
- **验证**：executor 22 + worker 4 全绿，零回归。

---

## 9. Phase 2 原始设计（已被 §8 实现取代，存档）

同一 JSON 驱动真执行器：`services/backend` 内 `Map<type, async(node,ctx)=>outputs>` + 邻接表拓扑序 + kirara 式 readiness-gating；复用现有孤儿原语 `workflowStatus.js`（中断/恢复）承接 askUserQuestion；跨进程桥经共享 DB 表 `workflow_runs` 轮询或内部 IPC，图 JSON 即载荷。

> 实现差异：最终用**单游标解释器**（非拓扑排序，因图含 loop 回边）；中断/恢复留下一刀（本刀占位）。
