@fieldwangai/agentflow 0.1.35 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 本地 HTTP:静态 UI + /api/flows(GET/POST/HEAD)、/api/flows/import(POST multipart 导入 .yaml/.zip)、/api/flow/archive(POST)、/api/flow/delete(POST 永久删除)、/api/model-lists、/api/ui-context、/api/pipeline-recent-runs、/api/run-node-statuses(GET 某次 run 各节点磁盘状态)、/api/workspace-tree(GET 工作区目录树)、/api/nodes、/api/flow(GET/POST)、
3
- * /api/flow-editor-sync(POST 通知画布刷新)、/api/flow-editor-sync-events(GET SSE)、/api/flow/run(POST NDJSON 流式执行 agentflow apply --machine-readable)、/api/flow/run/stop(POST 终止运行)、
3
+ * /api/flow-editor-sync(POST 通知画布刷新)、/api/flow-editor-sync-events(GET SSE)、/api/flow/run(POST NDJSON 流式执行 agentflow apply --machine-readable)、/api/flow/run/stop(POST 终止运行)、/api/workspace/run/stop(POST 终止 Workspace 临时运行)、
4
4
  * /api/composer-agent(POST NDJSON;有 flow 时结束后 validate-flow,失败则自动 agent 修复至多 5 次)、
5
5
  * /api/agentflow-config(GET/POST 读写 ~/agentflow/config.json 的 opencodeProvider;POST 后执行 update-model-lists)、/api/update-model-lists(POST 可选 JSON body.opencodeProvider 覆盖本次拉取用的 Provider,未保存 config 也可用);
6
6
  * listen 后后台 updateModelLists
@@ -89,8 +89,10 @@ import {
89
89
  buildClearSessionCookie,
90
90
  buildSessionCookie,
91
91
  getAuthUserFromRequest,
92
+ isAuthUserAllowed,
92
93
  loginOrCreateUser,
93
94
  logoutRequest,
95
+ readUserAllowlist,
94
96
  } from "./auth.mjs";
95
97
  import { readUserEnvObject, readUserEnvRows, writeUserEnvRows } from "./user-env.mjs";
96
98
 
@@ -415,6 +417,21 @@ function omitObjectKeys(obj, keys) {
415
417
  return out;
416
418
  }
417
419
 
420
+ function privateKeyMetadataFromConfig(configValue = {}) {
421
+ const meta = configValue?.__agentflowPrivateKeys;
422
+ const env = Array.isArray(meta?.env) ? meta.env.map((key) => String(key || "").trim()).filter(Boolean) : [];
423
+ const headers = Array.isArray(meta?.headers) ? meta.headers.map((key) => String(key || "").trim()).filter(Boolean) : [];
424
+ return { env: Array.from(new Set(env)), headers: Array.from(new Set(headers)) };
425
+ }
426
+
427
+ function withPrivatePlaceholders(obj, keys) {
428
+ const out = { ...(obj && typeof obj === "object" && !Array.isArray(obj) ? obj : {}) };
429
+ for (const key of keys) {
430
+ if (key && !Object.prototype.hasOwnProperty.call(out, key)) out[key] = "";
431
+ }
432
+ return out;
433
+ }
434
+
418
435
  function normalizeMcpServerConfig(value) {
419
436
  const raw = value && typeof value === "object" && !Array.isArray(value) ? value : {};
420
437
  const next = {};
@@ -459,10 +476,13 @@ function readCursorMcpServers(userCtx = {}) {
459
476
  const privateValue = privateConfig.servers?.[name] && typeof privateConfig.servers[name] === "object" ? privateConfig.servers[name] : {};
460
477
  const privateEnv = privateValue.env && typeof privateValue.env === "object" && !Array.isArray(privateValue.env) ? privateValue.env : {};
461
478
  const privateHeaders = privateValue.headers && typeof privateValue.headers === "object" && !Array.isArray(privateValue.headers) ? privateValue.headers : {};
479
+ const privateMeta = privateKeyMetadataFromConfig(publicValue);
480
+ const privateEnvKeys = Array.from(new Set([...privateMeta.env, ...Object.keys(privateEnv)]));
481
+ const privateHeaderKeys = Array.from(new Set([...privateMeta.headers, ...Object.keys(privateHeaders)]));
462
482
  const configValue = {
463
483
  ...publicValue,
464
- env: { ...(publicValue.env || {}), ...privateEnv },
465
- headers: { ...(publicValue.headers || {}), ...privateHeaders },
484
+ env: { ...withPrivatePlaceholders(publicValue.env || {}, privateEnvKeys), ...privateEnv },
485
+ headers: { ...withPrivatePlaceholders(publicValue.headers || {}, privateHeaderKeys), ...privateHeaders },
466
486
  };
467
487
  return {
468
488
  name,
@@ -474,8 +494,8 @@ function readCursorMcpServers(userCtx = {}) {
474
494
  headers: configValue.headers && typeof configValue.headers === "object" ? configValue.headers : {},
475
495
  description: typeof configValue.description === "string" ? configValue.description : "",
476
496
  raw: configValue,
477
- privateEnvKeys: Object.keys(privateEnv),
478
- privateHeaderKeys: Object.keys(privateHeaders),
497
+ privateEnvKeys,
498
+ privateHeaderKeys,
479
499
  };
480
500
  }).sort((a, b) => a.name.localeCompare(b.name));
481
501
  return { path: cursorMcpConfigPath(), servers };
@@ -496,6 +516,16 @@ function writeCursorMcpServer(payload = {}, userCtx = {}) {
496
516
  env: omitObjectKeys(server.env || {}, privateEnvKeys),
497
517
  headers: omitObjectKeys(server.headers || {}, privateHeaderKeys),
498
518
  };
519
+ publicServer.env = withPrivatePlaceholders(publicServer.env, privateEnvKeys);
520
+ publicServer.headers = withPrivatePlaceholders(publicServer.headers, privateHeaderKeys);
521
+ if (privateEnvKeys.size || privateHeaderKeys.size) {
522
+ publicServer.__agentflowPrivateKeys = {
523
+ ...(privateEnvKeys.size ? { env: Array.from(privateEnvKeys) } : {}),
524
+ ...(privateHeaderKeys.size ? { headers: Array.from(privateHeaderKeys) } : {}),
525
+ };
526
+ } else {
527
+ delete publicServer.__agentflowPrivateKeys;
528
+ }
499
529
  if (!Object.keys(publicServer.env).length) delete publicServer.env;
500
530
  if (!Object.keys(publicServer.headers).length) delete publicServer.headers;
501
531
  const p = cursorMcpConfigPath();
@@ -1125,6 +1155,21 @@ function resolveWorkspaceScopeRoot(workspaceRoot, params = {}, opts = {}) {
1125
1155
  return { root: path.resolve(result.path), flowId, flowSource, archived };
1126
1156
  }
1127
1157
 
1158
+ function workspaceSearchGuardrailsBlock() {
1159
+ return [
1160
+ "## 检索约束",
1161
+ "",
1162
+ "默认不要读取、搜索或 Glob 历史运行产物;除非用户明确要求分析历史 run/log,否则必须排除:",
1163
+ "- `**/runBuild/**`",
1164
+ "- `**/logs/**`",
1165
+ "- `.workspace/agentflow/**/runBuild/**`",
1166
+ "- `~/agentflow/runBuild/**`",
1167
+ "- `node_modules/**`、`dist/**` 等依赖或构建产物",
1168
+ "",
1169
+ "使用 grep/rg/find/Glob 等工具时,应把上述路径作为 exclude/glob ignore;不要从历史 runBuild/logs 中推断业务事实、指标资产或 skill 文档。",
1170
+ ].join("\n");
1171
+ }
1172
+
1128
1173
  function buildWorkspaceGeneratePrompt(payload) {
1129
1174
  const userPrompt = String(payload?.prompt || "").trim();
1130
1175
  const outputKind = String(payload?.outputKind || payload?.kind || "markdown").trim().toLowerCase();
@@ -1175,6 +1220,7 @@ function buildWorkspaceGeneratePrompt(payload) {
1175
1220
  allowFlowYaml
1176
1221
  ? "用户已允许你考虑正式 flow.yaml;如需修改仍必须明确说明影响。"
1177
1222
  : "默认不要修改正式 flow.yaml;优先在 workspace 文件、workspace.graph.json 或回复内容中完成任务。",
1223
+ workspaceSearchGuardrailsBlock(),
1178
1224
  workspaceGraph ? `\n## 当前 workspace graph\n\n${JSON.stringify(workspaceGraph, null, 2)}` : "",
1179
1225
  selectedNodeIds.length > 0 ? `\n## 当前用户选中的 workspace 节点\n\n${selectedNodeIds.map((id) => `- ${id}`).join("\n")}` : "",
1180
1226
  skillsBlock ? `\n## Selected Skills\n\n${skillsBlock}` : "",
@@ -1281,6 +1327,7 @@ function workspaceDisplayKind(definitionId) {
1281
1327
  if (id === "display_ascii") return "ascii";
1282
1328
  if (id === "display_html") return "html";
1283
1329
  if (id === "display_image") return "image";
1330
+ if (id === "display_chart") return "chart";
1284
1331
  return "";
1285
1332
  }
1286
1333
 
@@ -1344,6 +1391,9 @@ function workspaceDownstreamDisplayRequirements(graph, nodeId) {
1344
1391
  if (kinds.has("image")) {
1345
1392
  rules.push("- 下游连接了图片展示节点:输出可作为 img src 使用的图片地址、data URL 或 base64 data URL;不要输出 Markdown 图片语法或解释文字。");
1346
1393
  }
1394
+ if (kinds.has("chart")) {
1395
+ rules.push('- 下游连接了 Chart 展示节点:只输出 ChartSpec JSON 对象,不要 Markdown 代码围栏,不要解释文字。格式必须包含 `"type":"chart"`、`"version":"1.0"`、`"renderer":"echarts"`、`"option"`;`option.series[].type` 只使用 line/bar/pie/scatter/radar/heatmap/tree/treemap/sunburst/sankey/graph/gauge/funnel;不要输出 HTML、script、iframe 或 JS 函数。');
1396
+ }
1347
1397
  return [
1348
1398
  "## 下游输出要求",
1349
1399
  "",
@@ -1434,7 +1484,7 @@ function workspaceTargetSlotForEdge(graph, edge) {
1434
1484
  function isWorkspaceSemanticInputSlot(slot) {
1435
1485
  const name = String(slot?.name || "");
1436
1486
  const type = String(slot?.type || "");
1437
- return type === "node" || name === "prev" || name === "next" || name === "skillsContext" || name === "workspaceContext" || name === "gitContext";
1487
+ return type === "node" || name === "prev" || name === "next" || name === "skillsContext" || name === "mcpContext" || name === "workspaceContext" || name === "gitContext";
1438
1488
  }
1439
1489
 
1440
1490
  function workspaceTaskUpstreamText(graph, nodeId, outputs) {
@@ -1470,6 +1520,14 @@ function selectedSkillKeysFromInstance(instance) {
1470
1520
  return parseWorkspaceSkillKeys(workspaceSlotValue(slot) || "");
1471
1521
  }
1472
1522
 
1523
+ function selectedMcpServerNamesFromInstance(instance) {
1524
+ const bodyNames = parseWorkspaceSkillKeys(instance?.body || "");
1525
+ if (bodyNames.length > 0) return bodyNames;
1526
+ const slots = [...(Array.isArray(instance?.input) ? instance.input : []), ...(Array.isArray(instance?.output) ? instance.output : [])];
1527
+ const slot = slots.find((item) => item?.name === "mcpContext") || slots.find((item) => item?.name === "serverNames");
1528
+ return parseWorkspaceSkillKeys(workspaceSlotValue(slot) || "");
1529
+ }
1530
+
1473
1531
  function workspaceUpstreamSkillBlocks(graph, nodeId, outputs) {
1474
1532
  const edges = Array.isArray(graph?.edges) ? graph.edges : [];
1475
1533
  const blocks = edges
@@ -1486,6 +1544,22 @@ function workspaceUpstreamSkillBlocks(graph, nodeId, outputs) {
1486
1544
  return Array.from(new Set(blocks)).join("\n\n---\n\n");
1487
1545
  }
1488
1546
 
1547
+ function workspaceUpstreamMcpBlocks(graph, nodeId, outputs) {
1548
+ const edges = Array.isArray(graph?.edges) ? graph.edges : [];
1549
+ const blocks = edges
1550
+ .filter((edge) => String(edge?.target || "") === String(nodeId))
1551
+ .filter((edge) => {
1552
+ const slot = workspaceTargetSlotForEdge(graph, edge);
1553
+ return String(slot?.name || "") === "mcpContext";
1554
+ })
1555
+ .map((edge) => String(outputs.get(String(edge.source || "")) || ""))
1556
+ .filter((text) => text.includes("MCP") || text.includes("mcp"))
1557
+ .flatMap((text) => text.split(/\n\s*---\s*\n/g))
1558
+ .map((text) => text.trim())
1559
+ .filter(Boolean);
1560
+ return Array.from(new Set(blocks)).join("\n\n---\n\n");
1561
+ }
1562
+
1489
1563
  function mergeWorkspaceSkillBlocks(...values) {
1490
1564
  const blocks = values
1491
1565
  .map((value) => String(value || ""))
@@ -1518,6 +1592,43 @@ function buildWorkspaceSkillManifestBlock(skills, selectedKeys = []) {
1518
1592
  ].join("\n");
1519
1593
  }
1520
1594
 
1595
+ function buildWorkspaceMcpManifestBlock(results, servers = [], selectedNames = []) {
1596
+ const serverByName = new Map((Array.isArray(servers) ? servers : []).map((server) => [String(server?.name || ""), server]));
1597
+ const normalizedNames = Array.from(new Set((selectedNames || []).map((x) => String(x || "").trim()).filter(Boolean)));
1598
+ const targets = (Array.isArray(results) ? results : []).filter((item) => !normalizedNames.length || normalizedNames.includes(String(item?.name || "")));
1599
+ const rows = [];
1600
+ for (const result of targets) {
1601
+ const name = String(result?.name || "").trim();
1602
+ if (!name) continue;
1603
+ const server = serverByName.get(name) || {};
1604
+ const description = String(server?.description || "").trim();
1605
+ if (!result?.ok) {
1606
+ rows.push(`- MCP server \`${name}\`: unavailable${result?.error ? ` (${String(result.error)})` : ""}`);
1607
+ continue;
1608
+ }
1609
+ rows.push(`- MCP server \`${name}\`${description ? `: ${description}` : ""}`);
1610
+ const tools = Array.isArray(result?.tools) ? result.tools : [];
1611
+ if (!tools.length) {
1612
+ rows.push(" - no tools reported");
1613
+ continue;
1614
+ }
1615
+ for (const tool of tools.slice(0, 80)) {
1616
+ const toolName = String(tool?.name || "").trim();
1617
+ if (!toolName) continue;
1618
+ const toolDescription = String(tool?.description || "").trim();
1619
+ rows.push(` - tool \`${toolName}\`${toolDescription ? `: ${toolDescription}` : ""}`);
1620
+ }
1621
+ }
1622
+ if (!rows.length && !normalizedNames.length) return "";
1623
+ return [
1624
+ "### Workspace MCP Manifest",
1625
+ "",
1626
+ "这些 MCP servers/tools 已在当前 Agent 运行器中可用。需要外部工具能力时,优先使用下列 MCP 工具;不要声称调用了工具,除非实际工具调用成功。",
1627
+ "",
1628
+ ...(rows.length ? rows : normalizedNames.map((name) => `- MCP server \`${name}\``)),
1629
+ ].join("\n");
1630
+ }
1631
+
1521
1632
  function workspaceWriteDisplayContent(instance, content) {
1522
1633
  const next = { ...(instance || {}) };
1523
1634
  const kind = workspaceDisplayKind(next.definitionId);
@@ -1552,7 +1663,7 @@ function workspaceUpdateDirectDisplays(graph, sourceId, content) {
1552
1663
  return updated;
1553
1664
  }
1554
1665
 
1555
- function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock) {
1666
+ function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock, mcpBlock = "") {
1556
1667
  const instance = graph.instances[nodeId] || {};
1557
1668
  const body = String(instance.body || "").trim();
1558
1669
  const label = String(instance.label || nodeId).trim();
@@ -1560,7 +1671,9 @@ function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock) {
1560
1671
  return [
1561
1672
  "你正在执行 AgentFlow Workspace 画布中的一个临时节点。",
1562
1673
  "只输出该节点要传给下游展示/后续节点的正文,不要解释运行过程。",
1674
+ workspaceSearchGuardrailsBlock(),
1563
1675
  skillsBlock ? `\n## Available Skills\n\n${skillsBlock}` : "",
1676
+ mcpBlock ? `\n## Available MCP\n\n${mcpBlock}` : "",
1564
1677
  upstreamText ? `\n## 上游上下文\n\n${upstreamText}` : "",
1565
1678
  downstreamRequirements ? `\n${downstreamRequirements}` : "",
1566
1679
  `\n## 当前节点\n\n- id: ${nodeId}\n- label: ${label}\n- definitionId: ${instance.definitionId || ""}`,
@@ -1572,6 +1685,14 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
1572
1685
  const graph = normalizeWorkspaceGraphPayload(payload.graph || {});
1573
1686
  const runNodeId = String(payload?.runNodeId || "").trim();
1574
1687
  const { order, pauseNodeIds } = workspaceRunPlan(graph, runNodeId);
1688
+ const signal = opts.signal || null;
1689
+ const throwIfAborted = () => {
1690
+ if (signal?.aborted) {
1691
+ const err = new Error("Workspace run stopped");
1692
+ err.code = "WORKSPACE_RUN_ABORTED";
1693
+ throw err;
1694
+ }
1695
+ };
1575
1696
  const fallbackSelectedSkillKeys = Array.isArray(payload?.selectedSkills)
1576
1697
  ? payload.selectedSkills.map((x) => String(x || "").trim()).filter(Boolean)
1577
1698
  : [];
@@ -1589,6 +1710,22 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
1589
1710
  skillsBlockCache.set(cacheKey, block);
1590
1711
  return block;
1591
1712
  };
1713
+ const mcpBlockCache = new Map();
1714
+ const loadMcpBlockForNames = async (names) => {
1715
+ const normalized = Array.from(new Set((names || []).map((x) => String(x || "").trim()).filter(Boolean)));
1716
+ if (!normalized.length) return "";
1717
+ const cacheKey = normalized.join("\n");
1718
+ if (mcpBlockCache.has(cacheKey)) return mcpBlockCache.get(cacheKey);
1719
+ const { servers } = readCursorMcpServers(userCtx);
1720
+ const results = [];
1721
+ for (const name of normalized) {
1722
+ const checked = await checkCursorMcpServers(name, userCtx);
1723
+ results.push(...(Array.isArray(checked.results) ? checked.results : []));
1724
+ }
1725
+ const block = buildWorkspaceMcpManifestBlock(results, servers, normalized);
1726
+ mcpBlockCache.set(cacheKey, block);
1727
+ return block;
1728
+ };
1592
1729
  const outputs = new Map();
1593
1730
  const events = [];
1594
1731
  const emit = (event) => {
@@ -1603,6 +1740,7 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
1603
1740
  const modelKey = typeof payload?.model === "string" ? payload.model.trim() : "";
1604
1741
 
1605
1742
  for (const nodeId of order) {
1743
+ throwIfAborted();
1606
1744
  const instance = graph.instances[nodeId];
1607
1745
  if (!instance) continue;
1608
1746
  const defId = String(instance.definitionId || "");
@@ -1633,6 +1771,26 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
1633
1771
  continue;
1634
1772
  }
1635
1773
 
1774
+ if (defId === "control_load_mcp") {
1775
+ const mcpStartedAt = Date.now();
1776
+ const serverNames = selectedMcpServerNamesFromInstance(instance);
1777
+ const mcpBlock = await loadMcpBlockForNames(serverNames);
1778
+ emitTiming(nodeId, "load-mcp", mcpStartedAt, { serverCount: serverNames.length, charCount: mcpBlock.length });
1779
+ graph.instances[nodeId] = {
1780
+ ...instance,
1781
+ output: (Array.isArray(instance.output) ? instance.output : []).map((slot) => (
1782
+ String(slot?.name || "") === "mcpContext" || String(slot?.type || "") === "text"
1783
+ ? { ...slot, default: mcpBlock, value: mcpBlock }
1784
+ : slot
1785
+ )),
1786
+ };
1787
+ outputs.set(nodeId, mcpBlock);
1788
+ workspaceUpdateDirectDisplays(graph, nodeId, mcpBlock);
1789
+ emit({ type: "graph", nodeId, graph });
1790
+ emit({ type: "node-done", nodeId, definitionId: defId });
1791
+ continue;
1792
+ }
1793
+
1636
1794
  if (workspaceDisplayKind(defId)) {
1637
1795
  const content = workspaceUpstreamText(graph, nodeId, outputs);
1638
1796
  graph.instances[nodeId] = workspaceWriteDisplayContent(instance, content);
@@ -1768,7 +1926,10 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
1768
1926
  const rawWorktreePath = workspaceSlotValue(workspaceSlotByName(instance, "worktreePath")).trim();
1769
1927
  const worktreePath = rawWorktreePath ? workspaceResolvePath(cwd, rawWorktreePath) : (gitContext?.worktreePath ? path.resolve(gitContext.worktreePath) : "");
1770
1928
  const previousCwd = cwd;
1771
- const result = loadGitWorktree({ repoPath, branch, worktreePath, pipelineWorkspace: scopedRoot });
1929
+ const force = ["true", "1", "yes", "on"].includes(workspaceSlotValue(workspaceSlotByName(instance, "force")).trim().toLowerCase());
1930
+ const pruneMissingRaw = workspaceSlotValue(workspaceSlotByName(instance, "pruneMissing")).trim().toLowerCase();
1931
+ const pruneMissing = pruneMissingRaw !== "false";
1932
+ const result = loadGitWorktree({ repoPath, branch, worktreePath, pipelineWorkspace: scopedRoot, force, pruneMissing });
1772
1933
  const outGitContext = buildGitContext({
1773
1934
  repoPath: result.repoRoot,
1774
1935
  worktreePath: result.worktreePath,
@@ -1873,8 +2034,9 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
1873
2034
  }
1874
2035
  const upstreamSkillBlocks = workspaceUpstreamSkillBlocks(graph, nodeId, outputs);
1875
2036
  const promptSkillsBlock = mergeWorkspaceSkillBlocks(upstreamSkillBlocks, upstreamSkillBlocks ? "" : loadSkillsBlockForKeys(fallbackSelectedSkillKeys));
1876
- const prompt = workspaceNodePrompt(graph, nodeId, upstreamText, promptSkillsBlock);
1877
- emitTiming(nodeId, "prepare-agent-prompt", prepareStartedAt, { promptChars: prompt.length, upstreamChars: String(upstreamText || "").length, skillsChars: promptSkillsBlock.length });
2037
+ const promptMcpBlock = workspaceUpstreamMcpBlocks(graph, nodeId, outputs);
2038
+ const prompt = workspaceNodePrompt(graph, nodeId, upstreamText, promptSkillsBlock, promptMcpBlock);
2039
+ emitTiming(nodeId, "prepare-agent-prompt", prepareStartedAt, { promptChars: prompt.length, upstreamChars: String(upstreamText || "").length, skillsChars: promptSkillsBlock.length, mcpChars: promptMcpBlock.length });
1878
2040
  emit({ type: "natural", kind: "prompt", nodeId, text: prompt });
1879
2041
  let content = "";
1880
2042
  const maxAttempts = 3;
@@ -1905,14 +2067,21 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
1905
2067
  emit({ type: "status", nodeId, line: `工具 ${tool || "thinking"}${sub ? ` (${sub})` : ""}` });
1906
2068
  },
1907
2069
  });
2070
+ if (typeof opts.onActiveChild === "function") opts.onActiveChild(handle.child || null);
1908
2071
  emitTiming(nodeId, "spawn-agent", spawnStartedAt, { attempt });
1909
- await handle.finished;
2072
+ try {
2073
+ await handle.finished;
2074
+ } finally {
2075
+ if (typeof opts.onActiveChild === "function") opts.onActiveChild(null);
2076
+ }
2077
+ throwIfAborted();
1910
2078
  content = attemptContent.trim();
1911
2079
  break;
1912
2080
  } catch (e) {
2081
+ if (signal?.aborted || e?.code === "WORKSPACE_RUN_ABORTED") throwIfAborted();
1913
2082
  if (attempt < maxAttempts && isTransientAgentNetworkError(e)) {
1914
2083
  emit({ type: "status", nodeId, line: `Workspace node retry ${attempt + 1}/${maxAttempts} after network error` });
1915
- await sleepMs(Math.min(1500 * attempt, 5000));
2084
+ await sleepMs(Math.min(1500 * attempt, 5000), signal);
1916
2085
  continue;
1917
2086
  }
1918
2087
  throw e;
@@ -1930,6 +2099,10 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
1930
2099
  return { graph, events, order, pauseNodeIds };
1931
2100
  }
1932
2101
 
2102
+ function isWorkspaceRunAbortError(err) {
2103
+ return err?.code === "WORKSPACE_RUN_ABORTED" || /Workspace run stopped/i.test(String(err?.message || ""));
2104
+ }
2105
+
1933
2106
  function isTransientAgentNetworkError(err) {
1934
2107
  const text = [
1935
2108
  err?.message,
@@ -1946,8 +2119,17 @@ function isTransientAgentNetworkError(err) {
1946
2119
  /socket hang up/i.test(text);
1947
2120
  }
1948
2121
 
1949
- function sleepMs(ms) {
1950
- return new Promise((resolve) => setTimeout(resolve, ms));
2122
+ function sleepMs(ms, signal = null) {
2123
+ if (signal?.aborted) return Promise.resolve();
2124
+ return new Promise((resolve) => {
2125
+ const timer = setTimeout(resolve, ms);
2126
+ if (signal) {
2127
+ signal.addEventListener("abort", () => {
2128
+ clearTimeout(timer);
2129
+ resolve();
2130
+ }, { once: true });
2131
+ }
2132
+ });
1951
2133
  }
1952
2134
 
1953
2135
  /** ZIP 本地头:PK\x03\x04 / \x05\x06 / \x07\x08 */
@@ -2052,6 +2234,12 @@ function broadcastFlowEditorSync(flowId, flowSource, flowArchived = false, userI
2052
2234
 
2053
2235
  /** 正在执行的 flow run(flowId → { child, runUuid });同一 flow 只允许一个 run */
2054
2236
  const activeFlowRuns = new Map();
2237
+ /** 正在执行的 Workspace 临时 run(flowId → { controller, child });同一 flow 只允许一个 run */
2238
+ const activeWorkspaceRuns = new Map();
2239
+
2240
+ function workspaceRunKey(userCtx, flowSource, flowId) {
2241
+ return `${userCtx?.userId || ""}:${flowSource || "user"}:${flowId}`;
2242
+ }
2055
2243
 
2056
2244
  /** Cursor/OpenCode 执行目录统一使用当前 UI 启动 workspace。 */
2057
2245
  function composerCliWorkspaceForFlowDir(workspaceRoot, _flowDir) {
@@ -2186,7 +2374,16 @@ export function startUiServer({
2186
2374
 
2187
2375
  if (url.pathname === "/api/auth/me" && req.method === "GET") {
2188
2376
  const user = getAuthUserFromRequest(req);
2189
- json(res, 200, { authenticated: Boolean(user), user: user || null, setupRequired: authSetupRequired() });
2377
+ const allowed = user ? isAuthUserAllowed(user) : true;
2378
+ const allowlist = readUserAllowlist();
2379
+ json(res, 200, {
2380
+ authenticated: Boolean(user && allowed),
2381
+ user: user && allowed ? user : null,
2382
+ setupRequired: authSetupRequired(),
2383
+ allowlistEnabled: allowlist.enabled,
2384
+ forbidden: Boolean(user && !allowed),
2385
+ error: user && !allowed ? "用户不在白名单中,请联系管理员开通访问权限" : "",
2386
+ });
2190
2387
  return;
2191
2388
  }
2192
2389
 
@@ -2200,7 +2397,7 @@ export function startUiServer({
2200
2397
  }
2201
2398
  const result = loginOrCreateUser(payload?.username, payload?.password);
2202
2399
  if (!result.ok) {
2203
- json(res, 401, { error: result.error || "Login failed", setupRequired: authSetupRequired() });
2400
+ json(res, result.forbidden ? 403 : 401, { error: result.error || "Login failed", setupRequired: authSetupRequired() });
2204
2401
  return;
2205
2402
  }
2206
2403
  const body = JSON.stringify({ authenticated: true, user: result.user, setupRequired: false, migration: result.migration || null });
@@ -2231,6 +2428,10 @@ export function startUiServer({
2231
2428
  json(res, 401, { error: "Authentication required", setupRequired: authSetupRequired() });
2232
2429
  return;
2233
2430
  }
2431
+ if (url.pathname.startsWith("/api/") && authUser && !isAuthUserAllowed(authUser)) {
2432
+ json(res, 403, { error: "用户不在白名单中,请联系管理员开通访问权限" });
2433
+ return;
2434
+ }
2234
2435
 
2235
2436
  if (url.pathname === "/api/flows") {
2236
2437
  if (req.method === "GET") {
@@ -2566,6 +2767,34 @@ export function startUiServer({
2566
2767
  return;
2567
2768
  }
2568
2769
  const wantsStream = /\bapplication\/x-ndjson\b/i.test(req.headers.accept || "") || payload.stream === true;
2770
+ const flowId = String(payload.flowId || "").trim();
2771
+ if (!flowId) {
2772
+ json(res, 400, { error: "Missing flowId" });
2773
+ return;
2774
+ }
2775
+ const runKey = workspaceRunKey(userCtx, scoped.flowSource || payload.flowSource || "user", flowId);
2776
+ if (activeWorkspaceRuns.has(runKey)) {
2777
+ json(res, 409, { error: "该 Workspace 正在运行" });
2778
+ return;
2779
+ }
2780
+ const controller = new AbortController();
2781
+ const runEntry = {
2782
+ controller,
2783
+ child: null,
2784
+ stopChild() {
2785
+ if (this.child && !this.child.killed) {
2786
+ try { this.child.kill("SIGTERM"); } catch (_) {}
2787
+ }
2788
+ },
2789
+ };
2790
+ activeWorkspaceRuns.set(runKey, runEntry);
2791
+ const setActiveChild = (child) => {
2792
+ runEntry.child = child || null;
2793
+ if (controller.signal.aborted) runEntry.stopChild();
2794
+ };
2795
+ const clearActiveRun = () => {
2796
+ if (activeWorkspaceRuns.get(runKey) === runEntry) activeWorkspaceRuns.delete(runKey);
2797
+ };
2569
2798
  if (wantsStream) {
2570
2799
  const graphPath = workspaceGraphPath(scoped.root);
2571
2800
  res.writeHead(200, {
@@ -2574,29 +2803,77 @@ export function startUiServer({
2574
2803
  "X-Accel-Buffering": "no",
2575
2804
  });
2576
2805
  const writeEvent = (event) => {
2577
- res.write(JSON.stringify(event) + "\n");
2806
+ try { res.write(JSON.stringify(event) + "\n"); } catch (_) {}
2578
2807
  };
2579
2808
  try {
2580
- const result = await runWorkspaceGraph(root, scoped.root, payload, userCtx, { onEvent: writeEvent });
2809
+ const result = await runWorkspaceGraph(root, scoped.root, payload, userCtx, {
2810
+ onEvent: writeEvent,
2811
+ signal: controller.signal,
2812
+ onActiveChild: setActiveChild,
2813
+ });
2581
2814
  fs.writeFileSync(graphPath, JSON.stringify(result.graph, null, 2) + "\n", "utf-8");
2582
2815
  writeEvent({ type: "done", ok: true, path: graphPath, graph: result.graph, order: result.order, pauseNodeIds: result.pauseNodeIds || [] });
2583
2816
  res.end();
2584
2817
  } catch (e) {
2585
- writeEvent({ type: "error", error: (e && e.message) || String(e) });
2818
+ if (isWorkspaceRunAbortError(e) || controller.signal.aborted) {
2819
+ writeEvent({ type: "stopped", ok: false, stopped: true, message: "Workspace run stopped" });
2820
+ } else {
2821
+ writeEvent({ type: "error", error: (e && e.message) || String(e) });
2822
+ }
2586
2823
  res.end();
2824
+ } finally {
2825
+ clearActiveRun();
2587
2826
  }
2588
2827
  return;
2589
2828
  }
2590
- const result = await runWorkspaceGraph(root, scoped.root, payload, userCtx);
2591
- const graphPath = workspaceGraphPath(scoped.root);
2592
- fs.writeFileSync(graphPath, JSON.stringify(result.graph, null, 2) + "\n", "utf-8");
2593
- json(res, 200, { ok: true, path: graphPath, ...result });
2829
+ try {
2830
+ const result = await runWorkspaceGraph(root, scoped.root, payload, userCtx, {
2831
+ signal: controller.signal,
2832
+ onActiveChild: setActiveChild,
2833
+ });
2834
+ const graphPath = workspaceGraphPath(scoped.root);
2835
+ fs.writeFileSync(graphPath, JSON.stringify(result.graph, null, 2) + "\n", "utf-8");
2836
+ json(res, 200, { ok: true, path: graphPath, ...result });
2837
+ } catch (e) {
2838
+ if (isWorkspaceRunAbortError(e) || controller.signal.aborted) {
2839
+ json(res, 200, { ok: false, stopped: true, message: "Workspace run stopped" });
2840
+ } else {
2841
+ throw e;
2842
+ }
2843
+ } finally {
2844
+ clearActiveRun();
2845
+ }
2594
2846
  } catch (e) {
2595
2847
  json(res, 500, { error: (e && e.message) || String(e) });
2596
2848
  }
2597
2849
  return;
2598
2850
  }
2599
2851
 
2852
+ if (req.method === "POST" && url.pathname === "/api/workspace/run/stop") {
2853
+ let payload;
2854
+ try {
2855
+ payload = JSON.parse(await readBody(req));
2856
+ } catch {
2857
+ json(res, 400, { error: "Invalid JSON body" });
2858
+ return;
2859
+ }
2860
+ const flowId = typeof payload.flowId === "string" ? payload.flowId.trim() : "";
2861
+ if (!flowId) {
2862
+ json(res, 400, { error: "Missing flowId" });
2863
+ return;
2864
+ }
2865
+ const runKey = workspaceRunKey(userCtx, payload.flowSource || "user", flowId);
2866
+ const entry = activeWorkspaceRuns.get(runKey);
2867
+ if (!entry) {
2868
+ json(res, 404, { error: "该 Workspace 未在运行" });
2869
+ return;
2870
+ }
2871
+ try { entry.controller?.abort(); } catch (_) {}
2872
+ try { entry.stopChild?.(); } catch (_) {}
2873
+ json(res, 200, { ok: true, stopped: true });
2874
+ return;
2875
+ }
2876
+
2600
2877
  if (req.method === "GET" && url.pathname === "/api/workspace/file") {
2601
2878
  try {
2602
2879
  const scoped = resolveWorkspaceScopeRoot(root, {
@@ -363,11 +363,15 @@ function emitGitWorktreeLoadNode(workspaceRoot, flowName, uuid, instanceId, exec
363
363
  const branch = String(inputs.branch || "").trim();
364
364
  const worktreePath = resolveMaybeWorkspacePath(inputs.worktreePath, workspaceContext, { branch }) ||
365
365
  (gitContext?.worktreePath ? path.resolve(gitContext.worktreePath) : "");
366
+ const force = isTruthyInput(inputs.force);
367
+ const pruneMissing = String(inputs.pruneMissing ?? "true").trim().toLowerCase() !== "false";
366
368
  const result = loadGitWorktree({
367
369
  repoPath,
368
370
  branch,
369
371
  worktreePath,
370
372
  pipelineWorkspace: workspaceContext.pipelineWorkspace || path.resolve(workspaceRoot),
373
+ force,
374
+ pruneMissing,
371
375
  });
372
376
  const outWorkspaceContext = {
373
377
  version: 1,
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  # 内置节点:子 Agent
3
- description: 利用子 Agent 执行任务;可接收 workspaceContext 切换执行工作区,并接收 skillsContext 注入已加载 skills
3
+ description: 利用子 Agent 执行任务;可接收 workspaceContext 切换执行工作区,并接收 skillsContext / mcpContext 注入已加载 skills 与 MCP 工具清单。
4
4
  displayName: 子 Agent
5
5
  input:
6
6
  - type: node
@@ -12,6 +12,9 @@ input:
12
12
  - type: text
13
13
  name: skillsContext
14
14
  default: ""
15
+ - type: text
16
+ name: mcpContext
17
+ default: ""
15
18
  output:
16
19
  - type: node
17
20
  name: next
@@ -0,0 +1,31 @@
1
+ ---
2
+ # Built-in node: Chart Display
3
+ description: Display a JSON ChartSpec with ECharts in workspace canvas; passes the JSON downstream as text
4
+ displayName: Chart Display
5
+ input:
6
+ - type: node
7
+ name: prev
8
+ default: ""
9
+ - type: text
10
+ name: content
11
+ default: ""
12
+ required: true
13
+ showOnNode: true
14
+ - type: file
15
+ name: filePath
16
+ default: ""
17
+ showOnNode: false
18
+ - type: text
19
+ name: workspaceContext
20
+ default: ""
21
+ showOnNode: false
22
+ output:
23
+ - type: text
24
+ name: content
25
+ default: ""
26
+ showOnNode: false
27
+ - type: node
28
+ name: next
29
+ default: ""
30
+ ---
31
+ ${content}
@@ -8,6 +8,8 @@ description: |
8
8
  - `branch` is optional. When empty, AgentFlow creates a detached worktree at the current HEAD.
9
9
  - `worktreePath` is optional. When empty, AgentFlow creates a path under `${pipelineWorkspace}/.workspace/agentflow/worktrees`.
10
10
  - Existing worktree paths are reused only when they are registered by `git worktree list` for the given repo.
11
+ - `pruneMissing` defaults to true. When Git has a registered worktree whose directory is missing, AgentFlow runs `git worktree prune` before adding it again.
12
+ - `force` defaults to false. When true, AgentFlow passes `--force` to `git worktree add`.
11
13
  displayName: Load Worktree
12
14
  input:
13
15
  - type: node
@@ -22,6 +24,14 @@ input:
22
24
  - type: file
23
25
  name: worktreePath
24
26
  default: ""
27
+ - type: bool
28
+ name: pruneMissing
29
+ default: "true"
30
+ showOnNode: false
31
+ - type: bool
32
+ name: force
33
+ default: "false"
34
+ showOnNode: false
25
35
  - type: text
26
36
  name: gitContext
27
37
  default: ""
@@ -38,6 +38,9 @@ input:
38
38
  - type: text
39
39
  name: skillsContext
40
40
  default: ""
41
+ - type: text
42
+ name: mcpContext
43
+ default: ""
41
44
  output:
42
45
  - type: node
43
46
  name: next