@fieldwangai/agentflow 0.1.153 → 0.1.154

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.
Files changed (192) hide show
  1. package/README.md +16 -34
  2. package/README.zh-CN.md +14 -34
  3. package/agents/agentflow-node-executor-code.md +13 -3
  4. package/agents/agentflow-node-executor-planning.md +13 -3
  5. package/agents/agentflow-node-executor-requirement.md +13 -3
  6. package/agents/agentflow-node-executor-test.md +13 -3
  7. package/agents/agentflow-node-executor-ui.md +13 -3
  8. package/agents/agentflow-node-executor.md +13 -3
  9. package/agents/en/agentflow-node-executor.md +13 -3
  10. package/agents/zh/agentflow-node-executor.md +13 -3
  11. package/bin/agentflow.mjs +3 -3
  12. package/bin/lib/admin-builtin-pipelines.mjs +3 -2
  13. package/bin/lib/agent-runners.mjs +0 -929
  14. package/bin/lib/auth.mjs +2 -1
  15. package/bin/lib/catalog-agents.mjs +2 -1
  16. package/bin/lib/catalog-flows.mjs +165 -90
  17. package/bin/lib/composer-agent.mjs +3 -793
  18. package/bin/lib/composer-skill-router.mjs +3 -323
  19. package/bin/lib/exec-buffered.mjs +32 -0
  20. package/bin/lib/flow-dsl/cli.mjs +276 -0
  21. package/bin/lib/flow-dsl/codegen.mjs +358 -0
  22. package/bin/lib/flow-dsl/defs.mjs +103 -0
  23. package/bin/lib/flow-dsl/index.mjs +77 -0
  24. package/bin/lib/flow-dsl/ir.mjs +305 -0
  25. package/bin/lib/flow-dsl/layout.mjs +115 -0
  26. package/bin/lib/flow-dsl/legacy-yaml.mjs +268 -0
  27. package/bin/lib/flow-dsl/lint.mjs +241 -0
  28. package/bin/lib/flow-dsl/packages.mjs +251 -0
  29. package/bin/lib/flow-dsl/parser.mjs +444 -0
  30. package/bin/lib/flow-import.mjs +96 -26
  31. package/bin/lib/flow-write.mjs +48 -12
  32. package/bin/lib/help.mjs +8 -28
  33. package/bin/lib/html-escape.mjs +19 -0
  34. package/bin/lib/http-util.mjs +49 -0
  35. package/bin/lib/legacy-flow-execution.mjs +37 -0
  36. package/bin/lib/locales/en.json +1 -1
  37. package/bin/lib/locales/zh.json +1 -1
  38. package/bin/lib/main.mjs +152 -286
  39. package/bin/lib/marketplace.mjs +206 -61
  40. package/bin/lib/model-config.mjs +10 -0
  41. package/bin/lib/node-package-archive.mjs +240 -0
  42. package/bin/lib/node-package-bootstrap.mjs +72 -0
  43. package/bin/lib/node-package-manifest.mjs +174 -0
  44. package/bin/lib/paths.mjs +42 -19
  45. package/bin/lib/prd-workflow-routes.mjs +2949 -0
  46. package/bin/lib/prd-workflow-server.mjs +4884 -0
  47. package/bin/lib/run-events.mjs +0 -66
  48. package/bin/lib/schedule-config.mjs +4 -4
  49. package/bin/lib/skill-runtime.mjs +35 -0
  50. package/bin/lib/startup-storage-migrations.mjs +274 -0
  51. package/bin/lib/ui-server.mjs +1087 -18122
  52. package/bin/lib/user-env.mjs +16 -0
  53. package/bin/lib/workspace-auto-layout.mjs +234 -0
  54. package/bin/lib/workspace-flow-store.mjs +478 -0
  55. package/bin/lib/workspace-graph-merge.mjs +41 -34
  56. package/bin/lib/workspace-routes.mjs +3088 -0
  57. package/bin/lib/workspace-server.mjs +6199 -0
  58. package/bin/lib/workspace-state.mjs +331 -0
  59. package/bin/lib/workspace.mjs +2 -1
  60. package/builtin/nodes/agent_subAgent.md +1 -0
  61. package/builtin/nodes/control_agent_toBool.md +2 -0
  62. package/builtin/nodes/control_anyOne.md +2 -0
  63. package/builtin/nodes/control_cancelled.md +2 -0
  64. package/builtin/nodes/control_cd_workspace.md +5 -11
  65. package/builtin/nodes/control_delay.md +2 -0
  66. package/builtin/nodes/control_end.md +2 -0
  67. package/builtin/nodes/control_if.md +1 -0
  68. package/builtin/nodes/control_interval_loop.md +2 -0
  69. package/builtin/nodes/control_load_mcp.md +24 -0
  70. package/builtin/nodes/control_load_skills.md +8 -23
  71. package/builtin/nodes/control_start.md +2 -0
  72. package/builtin/nodes/control_toBool.md +2 -0
  73. package/builtin/nodes/control_user_workspace.md +2 -0
  74. package/builtin/nodes/control_wait_until.md +2 -0
  75. package/builtin/nodes/display_ascii.md +1 -0
  76. package/builtin/nodes/display_chart.md +1 -0
  77. package/builtin/nodes/display_html.md +1 -0
  78. package/builtin/nodes/display_image.md +1 -0
  79. package/builtin/nodes/display_markdown.md +1 -0
  80. package/builtin/nodes/display_mermaid.md +1 -0
  81. package/builtin/nodes/display_react_app.md +1 -0
  82. package/builtin/nodes/display_table.md +1 -0
  83. package/builtin/nodes/provide_bool.md +1 -0
  84. package/builtin/nodes/provide_file.md +1 -0
  85. package/builtin/nodes/provide_password.md +1 -0
  86. package/builtin/nodes/provide_str.md +1 -0
  87. package/builtin/nodes/tool_display_share_link.md +1 -0
  88. package/builtin/nodes/tool_get_env.md +2 -0
  89. package/builtin/nodes/tool_git_checkout.md +1 -0
  90. package/builtin/nodes/tool_git_worktree_load.md +1 -0
  91. package/builtin/nodes/tool_git_worktree_unload.md +1 -0
  92. package/builtin/nodes/tool_gitlab_create_mr.md +1 -0
  93. package/builtin/nodes/tool_jenkins_build.md +2 -0
  94. package/builtin/nodes/tool_load_key.md +2 -0
  95. package/builtin/nodes/tool_nodejs.md +17 -19
  96. package/builtin/nodes/tool_print.md +2 -0
  97. package/builtin/nodes/tool_save_key.md +2 -0
  98. package/builtin/nodes/tool_set_run_env.md +1 -0
  99. package/builtin/nodes/tool_user_ask.md +2 -0
  100. package/builtin/nodes/tool_user_check.md +2 -0
  101. package/builtin/nodes/tool_wecom_send_app_markdown.md +1 -0
  102. package/builtin/nodes/tool_wecom_send_group_markdown.md +1 -0
  103. package/builtin/nodes/workspace_one_click_task.md +44 -0
  104. package/builtin/nodes/workspace_run.md +16 -0
  105. package/builtin/nodes/workspace_scheduled_run.md +16 -0
  106. package/builtin/pipelines/module-migrate/scripts/gate.mjs +37 -0
  107. package/builtin/pipelines/module-migrate/scripts/static-check.mjs +82 -0
  108. package/builtin/pipelines/module-migrate/workspace.flow.js +172 -0
  109. package/builtin/pipelines/module-migrate/workspace.layout.json +134 -0
  110. package/builtin/pipelines/new/scripts/lint-flow.mjs +38 -0
  111. package/builtin/pipelines/new/workspace.flow.js +91 -0
  112. package/builtin/pipelines/new/workspace.layout.json +70 -0
  113. package/builtin/web-ui/dist/assets/{WorkflowAssistantThread-B9xslwI0.js → WorkflowAssistantThread-CKClwj96.js} +1 -1
  114. package/builtin/web-ui/dist/assets/index-BZ5KqLur.js +870 -0
  115. package/builtin/web-ui/dist/assets/index-CEXmmwM2.css +1 -0
  116. package/builtin/web-ui/dist/index.html +2 -2
  117. package/package.json +2 -1
  118. package/reference/flow-control-capabilities.md +77 -158
  119. package/reference/flow-layout.md +1 -1
  120. package/reference/flow-prompt-handler-check.md +2 -2
  121. package/skills/agentflow-author-flow/SKILL.md +1 -1
  122. package/skills/agentflow-cli/SKILL.md +139 -24
  123. package/skills/agentflow-cli/agents/openai.yaml +2 -2
  124. package/skills/agentflow-cli/scripts/agentflow-cli.mjs +654 -20
  125. package/skills/agentflow-cli/scripts/agentflow-runtime.mjs +97 -0
  126. package/skills/agentflow-flow-add-instances/SKILL.md +1 -1
  127. package/skills/agentflow-flow-dsl/SKILL.md +206 -0
  128. package/skills/agentflow-flow-dsl/agents/openai.yaml +4 -0
  129. package/skills/agentflow-flow-dsl/references/node-calls.md +39 -0
  130. package/skills/agentflow-flow-edit-node-fields/SKILL.md +1 -1
  131. package/skills/agentflow-flow-recipes/SKILL.md +7 -4
  132. package/skills/agentflow-flow-recipes/references/recipes.md +97 -43
  133. package/skills/agentflow-flow-sync-ui/SKILL.md +1 -1
  134. package/skills/agentflow-node-dsl/SKILL.md +212 -0
  135. package/skills/agentflow-node-dsl/agents/openai.yaml +4 -0
  136. package/skills/agentflow-node-reference/SKILL.md +2 -2
  137. package/skills/agentflow-node-reference/references/builtin-nodes.md +166 -115
  138. package/skills/agentflow-runtime-reference/references/runtime.md +1 -1
  139. package/skills/agentflow-workspace-ascii/SKILL.md +9 -16
  140. package/skills/agentflow-workspace-graph/SKILL.md +62 -48
  141. package/skills/agentflow-workspace-html/SKILL.md +7 -2
  142. package/skills/agentflow-workspace-image/SKILL.md +6 -2
  143. package/skills/agentflow-workspace-markdown/SKILL.md +14 -21
  144. package/skills/agentflow-workspace-mermaid/SKILL.md +8 -16
  145. package/bin/lib/api-runner.mjs +0 -387
  146. package/bin/lib/apply.mjs +0 -903
  147. package/bin/lib/composer-flow-instances.mjs +0 -68
  148. package/bin/lib/composer-flow-skeleton.mjs +0 -334
  149. package/bin/lib/composer-flow-validate.mjs +0 -47
  150. package/bin/lib/composer-model-router.mjs +0 -185
  151. package/bin/lib/composer-node-schema.mjs +0 -303
  152. package/bin/lib/composer-planner.mjs +0 -751
  153. package/bin/lib/composer-script-ops.mjs +0 -233
  154. package/bin/lib/flow-static-preview.mjs +0 -104
  155. package/bin/lib/hub-login.mjs +0 -54
  156. package/bin/lib/hub-publish.mjs +0 -159
  157. package/bin/lib/hub-remote.mjs +0 -189
  158. package/bin/lib/hub.mjs +0 -299
  159. package/bin/lib/jenkins.mjs +0 -380
  160. package/bin/lib/node-execute.mjs +0 -539
  161. package/bin/lib/normalize-node-tool-command.mjs +0 -97
  162. package/bin/lib/runtime-context.mjs +0 -243
  163. package/bin/lib/scheduler.mjs +0 -601
  164. package/bin/lib/ui-print.mjs +0 -94
  165. package/bin/pipeline/build-node-prompt.mjs +0 -271
  166. package/bin/pipeline/check-cache.mjs +0 -191
  167. package/bin/pipeline/check-flow.mjs +0 -543
  168. package/bin/pipeline/collect-nodes.mjs +0 -212
  169. package/bin/pipeline/compute-cache-md5.mjs +0 -177
  170. package/bin/pipeline/ensure-run-dir.mjs +0 -71
  171. package/bin/pipeline/gc.mjs +0 -129
  172. package/bin/pipeline/get-env.mjs +0 -59
  173. package/bin/pipeline/get-resolved-values.mjs +0 -344
  174. package/bin/pipeline/load-key.mjs +0 -62
  175. package/bin/pipeline/parse-flow.mjs +0 -708
  176. package/bin/pipeline/post-process-control-if.mjs +0 -23
  177. package/bin/pipeline/post-process-node.mjs +0 -490
  178. package/bin/pipeline/pre-process-node.mjs +0 -1430
  179. package/bin/pipeline/resolve-inputs.mjs +0 -201
  180. package/bin/pipeline/run-tool-nodejs.mjs +0 -167
  181. package/bin/pipeline/save-key.mjs +0 -93
  182. package/bin/pipeline/snapshot-prior-round.mjs +0 -70
  183. package/bin/pipeline/validate-for-ui.mjs +0 -234
  184. package/bin/pipeline/validate-script-output.mjs +0 -130
  185. package/bin/pipeline/write-result.mjs +0 -182
  186. package/builtin/pipelines/module-migrate/flow.yaml +0 -819
  187. package/builtin/pipelines/new/flow.yaml +0 -545
  188. package/builtin/pipelines/new/scripts/check-flow.mjs +0 -9
  189. package/builtin/pipelines/new/scripts/collect-nodes.mjs +0 -211
  190. package/builtin/web-ui/dist/assets/index-DZ328oSo.css +0 -1
  191. package/builtin/web-ui/dist/assets/index-DmUV7ZCL.js +0 -888
  192. package/skills/agentflow-node-authoring/SKILL.md +0 -57
@@ -0,0 +1,3088 @@
1
+ /**
2
+ * Workspace 的 HTTP 路由。
3
+ *
4
+ * 31 条路由从 `startUiServer` 那个七千行的请求回调里原样搬出来——**路由体一行没改**。
5
+ * 两个约定让这件事成立:
6
+ *
7
+ * 1. **命中与否看 `res.headersSent`。** 路由体里的 `return;` 保持原样(它们在原地就是
8
+ * 「已经回过响应,别再往下走」的意思),外层据此判断要不要继续 ui-server 的后续路由。
9
+ * 改成 `return true` 就得逐个甄别哪些 `return` 在嵌套回调里,那正是搬运出错的地方。
10
+ * 2. **闭包变量在函数头部从 ctx 解构回同名标识符。** `url` / `userCtx` / `root` 这些原本
11
+ * 是请求回调的闭包,解构之后路由体里的写法完全不变。
12
+ */
13
+
14
+ import { listNodesJson, readNodeDetailJson, readNodeFilePreview } from "./catalog-flows.mjs";
15
+ import { startComposerAgent } from "./composer-agent.mjs";
16
+ import { buildSkillCompactInjectionBlock, loadResourcesForSkillKeys } from "./composer-skill-router.mjs";
17
+ import { execFileBuffered } from "./exec-buffered.mjs";
18
+ import { runGit } from "./git-worktree.mjs";
19
+ import {
20
+ listMarketplacePackages,
21
+ nodePackageArchive,
22
+ publishNodePackage,
23
+ publishNodePackageArchive,
24
+ resolveMarketplaceNodePackage,
25
+ } from "./marketplace.mjs";
26
+ import { NODE_PACKAGE_ENTRY, nodePackageExportsRun, readNodePackageManifest } from "./node-package-manifest.mjs";
27
+ import { inspectNodePackageDirectory } from "./node-package-archive.mjs";
28
+ import { json, readBody } from "./http-util.mjs";
29
+ import { log } from "./log.mjs";
30
+ import { PACKAGE_ROOT, getAgentflowUserDataRoot } from "./paths.mjs";
31
+ import { runLedgerId } from "./run-ledger.mjs";
32
+ import { getTeamById, getTeamForUser } from "./teams.mjs";
33
+ import { readMergedEnvObject, runtimeEnvForUser } from "./user-env.mjs";
34
+ import { acceptWorkspaceCollaborationInvite, addWorkspaceCollaborationMember, ensureWorkspaceCollaboration, getWorkspaceCollaborationForProject, listWorkspaceCollaborationsForUser, removeWorkspaceCollaborationMember, removeWorkspaceCollaborationTeamShare, setWorkspaceCollaborationTeamShare, workspaceCollaborationAccess } from "./workspace-collaboration.mjs";
35
+ import { WorkspaceFlowParseError } from "./workspace-flow-store.mjs";
36
+ import { mergeWorkspaceGraphs, workspaceDesignRevision, workspaceRuntimeRevision } from "./workspace-graph-merge.mjs";
37
+ import { DEFAULT_WORKSPACE_PREVIEW_TTL_MS, createWorkspacePreviewId, normalizeWorkspacePreviewTtlMs, readWorkspacePreviewMetadata, workspacePreviewFlowDir, writeWorkspacePreviewMetadata } from "./workspace-preview.mjs";
38
+ import { appendWorkspaceRunLogEvent, createWorkspaceRunLogSession, finishWorkspaceRunLogSession, listWorkspaceRunLogs, readWorkspaceRunLogEvents } from "./workspace-run-logs.mjs";
39
+ import { activeWorkspaceRuns, appendWorkspaceRunFinished, appendWorkspaceRunStarted, hydrateWorkspaceGraphForRuntime, isReadonlyBuiltinFlowSource, isTransientAgentNetworkError, isValidFlowSourceRead, isWorkspaceRunAbortError, listWorkspaceScheduleStatusesForFlow, mergeWorkspacePersistentNodeRefs, mergeWorkspaceRunGraph, normalizeWorkspaceEntry, readWorkspaceConversations, readWorkspaceFiles, readWorkspaceGraph, resolveWorkspaceFilePath, resolveWorkspaceScopeRoot, runWorkspaceGraph, sleepMs, syncWorkspaceSchedulesForGraph, workspaceActiveRunsForScope, workspaceCollaborationEventKey, workspaceCollaborationSequences, workspaceCollaborationSubscribers, workspaceCollaborationSummaryWithUsers, workspaceDesignPath, workspaceDownloadContentDisposition, workspaceFindActiveRunConflict, workspaceGraphAsSource, workspaceOptimizeRunImplementations, workspaceRepoUrlWithCredential, workspaceRunControl, workspaceRunEntryKey, workspaceRunKey, workspaceRunPlan, workspaceRunPlanNodeIds, workspaceRunTouchedNodeIds, workspaceRuntimeNodeLabel, workspaceScopedUserContext, workspaceSearchGuardrailsBlock, workspaceUnwrapOutputEnvelopeForDisplay, workspacesPath, writeWorkspaceConversations, writeWorkspaceGraph } from "./workspace-server.mjs";
40
+ import { getWorkspaceTree } from "./workspace-tree.mjs";
41
+ import busboy from "busboy";
42
+ import crypto from "crypto";
43
+ import fs from "fs";
44
+ import os from "os";
45
+ import path from "path";
46
+ import sharp from "sharp";
47
+ import { pathToFileURL } from "url";
48
+
49
+ function sanitizeWorkspaceUploadName(filename) {
50
+ const parsed = path.parse(String(filename || "image").replace(/\\/g, "/").split("/").pop() || "image");
51
+ const stem = (parsed.name || "image")
52
+ .trim()
53
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
54
+ .replace(/^-+|-+$/g, "")
55
+ .slice(0, 80) || "image";
56
+ const ext = String(parsed.ext || "")
57
+ .toLowerCase()
58
+ .replace(/[^a-z0-9.]+/g, "")
59
+ .slice(0, 24);
60
+ return `${stem}${ext}`;
61
+ }
62
+
63
+ function uniqueWorkspaceRelPath(workspaceRoot, relPath) {
64
+ let { abs, rel } = resolveWorkspaceFilePath(workspaceRoot, relPath);
65
+ if (!fs.existsSync(abs)) return { abs, rel };
66
+ const parsed = path.parse(rel);
67
+ for (let i = 1; i < 1000; i += 1) {
68
+ const candidate = path.posix.join(parsed.dir, `${parsed.name}-${i}${parsed.ext}`);
69
+ const resolved = resolveWorkspaceFilePath(workspaceRoot, candidate);
70
+ if (!fs.existsSync(resolved.abs)) return resolved;
71
+ }
72
+ return { abs, rel };
73
+ }
74
+
75
+ function fileUrlFromPath(absPath) {
76
+ return pathToFileURL(path.resolve(absPath)).href;
77
+ }
78
+
79
+ function injectHtmlBaseHref(html, baseHref) {
80
+ const raw = String(html || "");
81
+ const base = `<base href="${htmlEscapeAttribute(baseHref)}">`;
82
+ if (/<base\b/i.test(raw)) return raw;
83
+ if (/<head\b[^>]*>/i.test(raw)) return raw.replace(/<head\b([^>]*)>/i, `<head$1>${base}`);
84
+ if (/<html\b[^>]*>/i.test(raw)) return raw.replace(/<html\b([^>]*)>/i, `<html$1><head>${base}</head>`);
85
+ return `<!doctype html><html><head>${base}</head><body>${raw}</body></html>`;
86
+ }
87
+
88
+ function chromeScreenshotCandidates() {
89
+ const candidates = [];
90
+ if (process.env.AGENTFLOW_CHROME_PATH) candidates.push(process.env.AGENTFLOW_CHROME_PATH);
91
+ if (process.platform === "darwin") {
92
+ candidates.push(
93
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
94
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
95
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
96
+ );
97
+ } else if (process.platform === "win32") {
98
+ candidates.push(
99
+ path.join(process.env.PROGRAMFILES || "C:\\Program Files", "Google", "Chrome", "Application", "chrome.exe"),
100
+ path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google", "Chrome", "Application", "chrome.exe"),
101
+ path.join(process.env.LOCALAPPDATA || "", "Google", "Chrome", "Application", "chrome.exe"),
102
+ );
103
+ }
104
+ candidates.push("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome", "msedge");
105
+ return candidates.filter(Boolean);
106
+ }
107
+
108
+ async function renderHtmlScreenshotWithChrome({ html, workspaceRoot, baseDir, width, height }) {
109
+ const w = Math.max(240, Math.min(4096, Math.round(Number(width) || 390)));
110
+ const h = Math.max(240, Math.min(12000, Math.round(Number(height) || 844)));
111
+ const debug = {
112
+ requestedWidth: Number(width) || null,
113
+ requestedHeight: Number(height) || null,
114
+ viewportWidth: w,
115
+ viewportHeight: h,
116
+ htmlChars: String(html || "").length,
117
+ baseDir: path.resolve(baseDir || workspaceRoot),
118
+ tried: [],
119
+ usedCommand: "",
120
+ pngBytes: 0,
121
+ pngWidth: null,
122
+ pngHeight: null,
123
+ };
124
+ const tmpDir = path.join(path.resolve(workspaceRoot), ".workspace", "agentflow", "tmp", `html-screenshot-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`);
125
+ fs.mkdirSync(tmpDir, { recursive: true });
126
+ const htmlPath = path.join(tmpDir, "snapshot.html");
127
+ const pngPath = path.join(tmpDir, "snapshot.png");
128
+ const baseHref = `${fileUrlFromPath(baseDir || workspaceRoot).replace(/\/?$/, "/")}`;
129
+ fs.writeFileSync(htmlPath, injectHtmlBaseHref(html, baseHref), "utf-8");
130
+ const args = [
131
+ "--headless=new",
132
+ "--disable-gpu",
133
+ "--no-sandbox",
134
+ "--disable-dev-shm-usage",
135
+ "--no-first-run",
136
+ "--no-default-browser-check",
137
+ "--allow-file-access-from-files",
138
+ "--hide-scrollbars",
139
+ "--force-device-scale-factor=1",
140
+ `--window-size=${w},${h}`,
141
+ `--screenshot=${pngPath}`,
142
+ fileUrlFromPath(htmlPath),
143
+ ];
144
+ let lastError = null;
145
+ try {
146
+ for (const command of chromeScreenshotCandidates()) {
147
+ if (path.isAbsolute(command) && !fs.existsSync(command)) continue;
148
+ debug.tried.push(command);
149
+ try {
150
+ await execFileBuffered(command, args, { timeout: 45000, cwd: workspaceRoot });
151
+ if (fs.existsSync(pngPath) && fs.statSync(pngPath).size > 0) {
152
+ const png = fs.readFileSync(pngPath);
153
+ debug.usedCommand = command;
154
+ debug.pngBytes = png.length;
155
+ try {
156
+ const meta = await sharp(png).metadata();
157
+ debug.pngWidth = meta.width || null;
158
+ debug.pngHeight = meta.height || null;
159
+ } catch (_) {}
160
+ return { png, debug };
161
+ }
162
+ lastError = new Error(`${command} did not produce a screenshot`);
163
+ } catch (error) {
164
+ lastError = error;
165
+ debug.lastError = String(error?.message || error);
166
+ }
167
+ }
168
+ throw new Error(`无法使用 Chrome 生成截图${lastError?.message ? `:${lastError.message}` : ""}`);
169
+ } finally {
170
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
171
+ }
172
+ }
173
+
174
+ /**
175
+ * 落盘并给出**磁盘上那张图**的版本号。
176
+ *
177
+ * 存图必须走这里,不能自己 `writeWorkspaceGraph` 完拿手里那张图去算 revision:代码化会
178
+ * 做规范化,两者对不上,客户端就会攥着一个磁盘上不存在的版本号,下一次保存直接被判成
179
+ * 「基线不匹配」。协作场景里这意味着谁都存不进去。
180
+ *
181
+ * @returns {{ graph: object, path: string, revision: string, runtimeRevision: string, result: object }}
182
+ */
183
+ function commitWorkspaceGraph(workspaceRoot, scoped, graph, userCtx) {
184
+ const result = writeWorkspaceGraph(scoped.root, graph, workspaceRoot);
185
+ const persisted = hydrateWorkspaceGraphForRuntime(workspaceRoot, scoped, result.graph, userCtx);
186
+ return {
187
+ graph: persisted,
188
+ path: workspaceDesignPath(scoped.root),
189
+ revision: workspaceDesignRevision(persisted),
190
+ runtimeRevision: workspaceRuntimeRevision(persisted),
191
+ result,
192
+ };
193
+ }
194
+
195
+ function missingWorkspaceGraphNodePackages(workspaceRoot, scoped, graph, userCtx) {
196
+ const missing = new Set();
197
+ for (const instance of Object.values(graph?.instances || {})) {
198
+ const ref = String(instance?.marketplaceRef || instance?.definitionId || "").trim();
199
+ if (!ref.startsWith("marketplace:")) continue;
200
+ const resolved = resolveMarketplaceNodePackage(
201
+ workspaceRoot,
202
+ scoped.root,
203
+ ref,
204
+ null,
205
+ { ...userCtx, marketplaceScope: "all" },
206
+ );
207
+ if (!resolved) missing.add(ref);
208
+ }
209
+ return [...missing].sort();
210
+ }
211
+
212
+ const NODE_STUDIO_DRAFTS_DIRNAME = "node-studio/drafts";
213
+
214
+ function writeUserWorkspaces(userCtx = {}, entries = []) {
215
+ const seen = new Set();
216
+ const workspaces = (Array.isArray(entries) ? entries : [])
217
+ .map((entry, index) => normalizeWorkspaceEntry(entry, index, userCtx))
218
+ .filter(Boolean)
219
+ .filter((entry) => {
220
+ const key = entry.id || entry.path;
221
+ if (seen.has(key)) return false;
222
+ seen.add(key);
223
+ return true;
224
+ });
225
+ const p = workspacesPath();
226
+ fs.mkdirSync(path.dirname(p), { recursive: true });
227
+ fs.writeFileSync(p, JSON.stringify({ version: 1, workspaces }, null, 2) + "\n", "utf-8");
228
+ return workspaces;
229
+ }
230
+
231
+ function redactWorkspaceSecret(text = "", secret = "") {
232
+ let out = String(text || "");
233
+ const raw = String(secret || "");
234
+ if (!raw) return out;
235
+ out = out.split(raw).join("<redacted>");
236
+ try {
237
+ out = out.split(encodeURIComponent(raw)).join("<redacted>");
238
+ } catch {
239
+ // ignore invalid encoding edge cases
240
+ }
241
+ return out;
242
+ }
243
+
244
+ function gitWorkspaceCommandOrThrow(args, cwd, label, secret = "") {
245
+ const result = runGit(args, cwd);
246
+ if (result.status !== 0) {
247
+ const message = redactWorkspaceSecret(result.stderr || result.stdout || result.error?.message || "unknown error", secret);
248
+ throw new Error(`${label} failed: ${message}`);
249
+ }
250
+ return {
251
+ stdout: redactWorkspaceSecret(result.stdout || "", secret),
252
+ stderr: redactWorkspaceSecret(result.stderr || "", secret),
253
+ };
254
+ }
255
+
256
+ function syncGitWorkspace(entry = {}, userCtx = {}) {
257
+ const workspace = normalizeWorkspaceEntry(entry, 0, userCtx);
258
+ if (!workspace || workspace.kind !== "git") throw new Error("只能拉取 Git 工作区");
259
+ if (!workspace.repoUrl) throw new Error("Git 工作区缺少 repoUrl");
260
+ const env = readMergedEnvObject(userCtx.userId || "");
261
+ const token = workspace.credentialRef ? String(env[workspace.credentialRef] || "").trim() : "";
262
+ const repoUrl = workspaceRepoUrlWithCredential(workspace.repoUrl, token);
263
+ const targetDir = path.resolve(workspace.path);
264
+ const parentDir = path.dirname(targetDir);
265
+ fs.mkdirSync(parentDir, { recursive: true });
266
+
267
+ const lines = [];
268
+ let changed = false;
269
+ if (fs.existsSync(path.join(targetDir, ".git"))) {
270
+ const originalRemote = runGit(["remote", "get-url", "origin"], targetDir).stdout.trim();
271
+ try {
272
+ if (token) gitWorkspaceCommandOrThrow(["remote", "set-url", "origin", repoUrl], targetDir, "git remote set-url", token);
273
+ const before = runGit(["rev-parse", "HEAD"], targetDir).stdout.trim();
274
+ gitWorkspaceCommandOrThrow(["fetch", "origin", "--prune"], targetDir, "git fetch", token);
275
+ if (workspace.branch) {
276
+ const checkout = runGit(["checkout", workspace.branch], targetDir);
277
+ if (checkout.status !== 0) {
278
+ gitWorkspaceCommandOrThrow(["checkout", "-b", workspace.branch, `origin/${workspace.branch}`], targetDir, "git checkout", token);
279
+ }
280
+ gitWorkspaceCommandOrThrow(["pull", "--ff-only", "origin", workspace.branch], targetDir, "git pull", token);
281
+ } else {
282
+ gitWorkspaceCommandOrThrow(["pull", "--ff-only"], targetDir, "git pull", token);
283
+ }
284
+ const after = runGit(["rev-parse", "HEAD"], targetDir).stdout.trim();
285
+ changed = before !== after;
286
+ lines.push(changed ? `updated ${before.slice(0, 8)} -> ${after.slice(0, 8)}` : `already up to date ${after.slice(0, 8)}`);
287
+ } finally {
288
+ if (token && originalRemote) runGit(["remote", "set-url", "origin", originalRemote], targetDir);
289
+ }
290
+ } else {
291
+ if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) {
292
+ throw new Error(`目标路径已存在但不是 Git 仓库:${targetDir}`);
293
+ }
294
+ const args = ["clone"];
295
+ if (workspace.branch) args.push("--branch", workspace.branch);
296
+ args.push(repoUrl, targetDir);
297
+ gitWorkspaceCommandOrThrow(args, parentDir, "git clone", token);
298
+ const commit = runGit(["rev-parse", "HEAD"], targetDir).stdout.trim();
299
+ changed = true;
300
+ lines.push(`cloned ${commit.slice(0, 8)}`);
301
+ if (token) runGit(["remote", "set-url", "origin", workspace.repoUrl], targetDir);
302
+ }
303
+ const branch = runGit(["rev-parse", "--abbrev-ref", "HEAD"], targetDir).stdout.trim();
304
+ const commit = runGit(["rev-parse", "HEAD"], targetDir).stdout.trim();
305
+ return {
306
+ workspace: normalizeWorkspaceEntry({ ...workspace, path: targetDir }, 0, userCtx),
307
+ changed,
308
+ branch,
309
+ commit,
310
+ message: lines.join("\n"),
311
+ };
312
+ }
313
+
314
+ function nodeStudioDraftsRoot(userCtx = {}) {
315
+ return path.join(getAgentflowUserDataRoot(userCtx.userId || ""), NODE_STUDIO_DRAFTS_DIRNAME);
316
+ }
317
+
318
+ function normalizeNodeStudioDraftId(value) {
319
+ const raw = String(value || "").trim().toLowerCase();
320
+ const safe = raw.replace(/[^a-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64);
321
+ return safe || `draft_${Date.now().toString(36)}_${crypto.randomBytes(3).toString("hex")}`;
322
+ }
323
+
324
+ function nodeStudioDraftPath(userCtx = {}, draftId = "") {
325
+ return path.join(nodeStudioDraftsRoot(userCtx), normalizeNodeStudioDraftId(draftId), "draft.json");
326
+ }
327
+
328
+ /**
329
+ * 草稿里那个**真的包目录**。
330
+ *
331
+ * 单独一层 `package/` 而不是和 `draft.json` 同级:`publishNodePackage` 是整目录 `cpSync`,
332
+ * 同级的话草稿元数据会被一起发布出去。
333
+ */
334
+ function nodeStudioPackageDir(userCtx = {}, draftId = "") {
335
+ return path.join(nodeStudioDraftsRoot(userCtx), normalizeNodeStudioDraftId(draftId), "package");
336
+ }
337
+
338
+ /**
339
+ * 把包目录静态解析回草稿的 manifest。
340
+ *
341
+ * 草稿里的 manifest **不是**另一份真相,而是 `index.mjs` 声明的投影——面板、画布、运行时
342
+ * 读的都是那份声明,草稿再存一份手写的只会两边对不上。解析不出来就把错误留在草稿里,
343
+ * 让用户看见,而不是留一个上一次的旧清单假装没事。
344
+ */
345
+ function nodeStudioReadPackage(userCtx = {}, draftId = "") {
346
+ const dir = nodeStudioPackageDir(userCtx, draftId);
347
+ const entry = path.join(dir, NODE_PACKAGE_ENTRY);
348
+ const files = {};
349
+ const collectFiles = (current) => {
350
+ let entries = [];
351
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; }
352
+ for (const item of entries) {
353
+ const abs = path.join(current, item.name);
354
+ if (item.isDirectory()) collectFiles(abs);
355
+ else if (item.isFile()) {
356
+ const rel = path.relative(dir, abs).replace(/\\/g, "/");
357
+ const stat = fs.statSync(abs);
358
+ files[rel] = stat.size <= 256 * 1024 ? fs.readFileSync(abs, "utf-8") : `[binary ${stat.size} bytes]`;
359
+ }
360
+ }
361
+ };
362
+ collectFiles(dir);
363
+ if (!fs.existsSync(entry)) return { source: "", manifest: null, error: "", files, packageDigest: "" };
364
+ const source = fs.readFileSync(entry, "utf-8");
365
+ try {
366
+ const manifest = readNodePackageManifest(dir, () => null);
367
+ if (!manifest) return { source, manifest: null, error: `${NODE_PACKAGE_ENTRY} 里没有可解析的 export default 声明`, files, packageDigest: "" };
368
+ if (!nodePackageExportsRun(entry)) return { source, manifest, error: "缺少 `export function run`,节点无法执行", files, packageDigest: "" };
369
+ const inspected = inspectNodePackageDirectory(dir);
370
+ if (!inspected.ok) return { source, manifest, error: inspected.error, files, packageDigest: "" };
371
+ return { source, manifest, error: "", files, packageDigest: inspected.contentSha256 };
372
+ } catch (e) {
373
+ return { source, manifest: null, error: (e && e.message) || String(e), files, packageDigest: "" };
374
+ }
375
+ }
376
+
377
+ /** 草稿里由包声明决定的那几个字段。手写的 title/config 不在这里,不会被覆盖。 */
378
+ function nodeStudioDraftFromPackage(pkg, draftId) {
379
+ const manifest = pkg.manifest;
380
+ if (!manifest) {
381
+ return { files: pkg.files || { [NODE_PACKAGE_ENTRY]: pkg.source || "" }, packageDigest: "", parseError: pkg.error || "" };
382
+ }
383
+ return {
384
+ title: manifest.displayName || manifest.id || draftId,
385
+ definitionId: manifest.definitionId || `marketplace:${manifest.id}@${manifest.version}`,
386
+ manifest,
387
+ files: pkg.files || { [NODE_PACKAGE_ENTRY]: pkg.source || "" },
388
+ packageDigest: pkg.packageDigest || "",
389
+ parseError: "",
390
+ };
391
+ }
392
+
393
+ /**
394
+ * 在包目录里跑一次 Agent,让它改写 `index.mjs`。
395
+ *
396
+ * `cliWorkspace` 就是包目录:Agent 的工作目录即它要写的地方,不用在提示里报绝对路径,
397
+ * 也就写不到别的地方去。
398
+ */
399
+ async function runNodeStudioAgent({ packageDir, userCtx, modelKey, prompt }) {
400
+ const segments = [];
401
+ let result = "";
402
+ const handle = startComposerAgent({
403
+ uiWorkspaceRoot: packageDir,
404
+ cliWorkspace: packageDir,
405
+ writableDirs: [packageDir],
406
+ prompt,
407
+ modelKey,
408
+ agentflowUserId: userCtx.userId || "",
409
+ onStreamEvent: (ev) => {
410
+ if (ev?.type !== "natural" || typeof ev.text !== "string") return;
411
+ const text = ev.text.trim();
412
+ if (!text) return;
413
+ if (ev.kind === "assistant") segments.push(text);
414
+ else if (ev.kind === "result") result = text;
415
+ },
416
+ });
417
+ await handle.finished;
418
+ return result || segments.at(-1) || "";
419
+ }
420
+
421
+ /**
422
+ * 用运行时那套 bootstrap 真跑一次包,而不是另写一个测试执行器。
423
+ *
424
+ * 走同一条路才有意义:Node Studio 里跑得过、画布上跑不过,这种测试不如没有。输出槽落在
425
+ * 临时目录,测完连目录一起删。
426
+ */
427
+ async function runNodeStudioPackageTest({ packageDir, manifest, inputs, userCtx }) {
428
+ const runDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentflow-node-test-"));
429
+ const outputsDir = path.join(runDir, "outputs");
430
+ fs.mkdirSync(outputsDir, { recursive: true });
431
+ const outputSlots = (Array.isArray(manifest.output) ? manifest.output : [])
432
+ .filter((slot) => String(slot?.type || "") !== "node" && slot?.name !== "next");
433
+ const outputAbs = Object.fromEntries(outputSlots.map((slot) => [slot.name, path.join(outputsDir, `${slot.name}`)]));
434
+ const startedAt = Date.now();
435
+ try {
436
+ const child = await execFileBuffered(
437
+ process.execPath,
438
+ [path.join(PACKAGE_ROOT, "bin", "lib", "node-package-bootstrap.mjs"), path.join(packageDir, NODE_PACKAGE_ENTRY)],
439
+ {
440
+ cwd: runDir,
441
+ env: runtimeEnvForUser(userCtx, {
442
+ AGENTFLOW_WORKSPACE_ROOT: runDir,
443
+ AGENTFLOW_NODE_RUN_DIR: runDir,
444
+ AGENTFLOW_NODE_TMP_DIR: runDir,
445
+ AGENTFLOW_OUTPUTS_DIR: outputsDir,
446
+ AGENTFLOW_INPUTS_JSON: JSON.stringify(inputs || {}),
447
+ AGENTFLOW_OUTPUTS_ABS_JSON: JSON.stringify(outputAbs),
448
+ AGENTFLOW_OUTPUTS_JSON: JSON.stringify(
449
+ Object.fromEntries(outputSlots.map((slot) => [slot.name, `outputs/${slot.name}`])),
450
+ ),
451
+ }),
452
+ maxBuffer: 4 * 1024 * 1024,
453
+ },
454
+ );
455
+ const log = [];
456
+ for (const line of String(child.stdout || "").split("\n")) if (line.trim()) log.push(line);
457
+ for (const line of String(child.stderr || "").split("\n")) if (line.trim()) log.push(`[stderr] ${line}`);
458
+ const outputs = {};
459
+ for (const [name, abs] of Object.entries(outputAbs)) {
460
+ if (!fs.existsSync(abs)) continue;
461
+ const text = fs.readFileSync(abs, "utf-8");
462
+ outputs[name] = text.length > 4000 ? `${text.slice(0, 4000)}…` : text;
463
+ log.push(`[output] ${name} = ${outputs[name].split("\n")[0].slice(0, 120)}`);
464
+ }
465
+ const missing = outputSlots.map((s) => s.name).filter((name) => !(name in outputs));
466
+ if (missing.length) log.push(`[error] 这些输出槽没有写文件:${missing.join(", ")}`);
467
+ // 把「文件写到别处、只把路径写进槽」这种写法在测试阶段就点出来。它在这里看着能过——
468
+ // 路径确实存在——但真实运行时那个位置是会被清理的临时目录,产物就丢了。
469
+ const invalidFileSlots = [];
470
+ for (const slot of outputSlots) {
471
+ const value = String(outputs[slot.name] || "").trim();
472
+ if (String(slot.type || "") !== "file" || !value || value.includes("\n")) continue;
473
+ if (!path.isAbsolute(value)) continue;
474
+ invalidFileSlots.push(slot.name);
475
+ log.push(`[error] ${slot.name} 是 file 槽,但里面写的是一个路径而不是文件内容——请直接把内容写到 outputs.${slot.name}`);
476
+ }
477
+ return {
478
+ status: missing.length || invalidFileSlots.length ? "failed" : "passed",
479
+ durationMs: Date.now() - startedAt,
480
+ log,
481
+ outputs,
482
+ };
483
+ } catch (e) {
484
+ const log = [];
485
+ for (const line of String(e?.stdout || "").split("\n")) if (line.trim()) log.push(line);
486
+ for (const line of String(e?.stderr || "").split("\n")) if (line.trim()) log.push(`[stderr] ${line}`);
487
+ log.push(`[error] ${(e && e.message) || String(e)}`);
488
+ return { status: "failed", durationMs: Date.now() - startedAt, log, outputs: {} };
489
+ } finally {
490
+ fs.rmSync(runDir, { recursive: true, force: true });
491
+ }
492
+ }
493
+
494
+ function buildNodeStudioPrompt({ requirement, currentSource, parseError, history }) {
495
+ const historyBlock = (Array.isArray(history) ? history : [])
496
+ .slice(-8)
497
+ .map((msg) => {
498
+ const text = String(msg?.text || "").trim();
499
+ return text ? `${msg?.role === "user" ? "user" : "assistant"}: ${text}` : "";
500
+ })
501
+ .filter(Boolean)
502
+ .join("\n\n");
503
+ return [
504
+ "你在为 AgentFlow 编写一个**代码节点包**。工作目录就是这个包的目录。",
505
+ "",
506
+ `把节点声明和统一入口写进 \`${NODE_PACKAGE_ENTRY}\`,然后回复一句话说明这次改了什么。`,
507
+ "这是一个完整节点包目录:复杂实现可以拆到 scripts/,也可以创建 templates/、assets/ 等包内文件,并由 index.mjs 使用相对路径引用。",
508
+ "不要写 node.yaml,不要创建 node_modules、.env、密钥、符号链接或引用包外绝对路径。",
509
+ "",
510
+ "## 格式",
511
+ "",
512
+ "```js",
513
+ 'import fs from "node:fs/promises";',
514
+ "",
515
+ "export default {",
516
+ ' id: "count_lines", // 必填,小写字母数字下划线短横',
517
+ ' version: "1.0.0", // 必填,完整 semver',
518
+ ' name: "统计行数",',
519
+ ' description: "读一个文本文件,统计行数",',
520
+ ' inputs: { filePath: { type: "text", description: "文件路径", required: true } },',
521
+ ' outputs: { total: { type: "text" } },',
522
+ "};",
523
+ "",
524
+ "export async function run(inputs, outputs, dirs) {",
525
+ ' const text = await fs.readFile(inputs.filePath, "utf-8");',
526
+ " await fs.writeFile(outputs.total, String(text.split(\"\\n\").length));",
527
+ "}",
528
+ "```",
529
+ "",
530
+ "## 三条硬约束",
531
+ "",
532
+ "1. `export default` 由 acorn **静态解析,永不执行**,所以它必须是纯字面量——任何变量",
533
+ " 引用、函数调用、展开运算都会被拒绝。`run` 里则是普通 Node 模块,随便写。",
534
+ "2. `outputs.<name>` 是**要写入的绝对路径,不是值**。`await fs.writeFile(outputs.x, 值)`。",
535
+ " 声明了几个输出槽就各写各的文件;第一个非控制输出槽承载结果正文。",
536
+ " `file` 类型的槽同理——把**文件内容本身**写到 `outputs.<name>` 上。不要另找一个地方",
537
+ " 写完文件、再把那个路径当字符串写进槽里:槽文件才是下游拿到的产物,你自选的路径在",
538
+ " 真实运行时位于会被清理的临时目录里。",
539
+ "3. 槽位类型只能是 `text` `file` `bool` `node` `image` `json`。声明顺序 = 画布上的引脚顺序。",
540
+ "",
541
+ "失败用抛异常或非零退出表示,不要把 stdout 包成 JSON。",
542
+ currentSource ? `\n## 当前 ${NODE_PACKAGE_ENTRY}\n\n\`\`\`js\n${currentSource}\n\`\`\`` : "",
543
+ parseError ? `\n## 上一版解析失败,必须修掉\n\n${parseError}` : "",
544
+ historyBlock ? `\n## 对话历史\n\n${historyBlock}` : "",
545
+ `\n## 本次需求\n\n${String(requirement || "").trim()}`,
546
+ ].filter((line) => line !== "").join("\n");
547
+ }
548
+
549
+ function emptyNodeStudioDraft(userCtx = {}, draftId = "") {
550
+ const now = new Date().toISOString();
551
+ const id = normalizeNodeStudioDraftId(draftId || "untitled_node");
552
+ return {
553
+ id,
554
+ title: "Untitled Node",
555
+ definitionId: "",
556
+ createdAt: now,
557
+ updatedAt: now,
558
+ ownerUserId: String(userCtx.userId || ""),
559
+ agentMessages: [],
560
+ promptDraft: "",
561
+ manifest: {
562
+ id,
563
+ version: "1.0.0",
564
+ name: "Untitled Node",
565
+ description: "",
566
+ baseDefinitionId: "agent_subAgent",
567
+ runtime: { type: "agent_subAgent" },
568
+ inputs: [],
569
+ outputs: [],
570
+ configSchema: { fields: [] },
571
+ ui: { card: { icon: "extension", variant: "default", actions: [] } },
572
+ },
573
+ config: {},
574
+ test: { inputs: {}, log: [], status: "not run" },
575
+ files: {},
576
+ };
577
+ }
578
+
579
+ function isLegacyNodeStudioDemoDraft(draft) {
580
+ return (
581
+ String(draft?.id || "") === "daily_report_demo" &&
582
+ String(draft?.definitionId || "") === "marketplace:daily_report@1.0.0"
583
+ );
584
+ }
585
+
586
+ function readNodeStudioDraft(userCtx = {}, draftId = "") {
587
+ const id = normalizeNodeStudioDraftId(draftId || "");
588
+ const filePath = nodeStudioDraftPath(userCtx, id);
589
+ if (!fs.existsSync(filePath)) return null;
590
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
591
+ return parsed && typeof parsed === "object" ? parsed : null;
592
+ }
593
+
594
+ function writeNodeStudioDraft(userCtx = {}, draft = {}) {
595
+ const id = normalizeNodeStudioDraftId(draft.id || "untitled_node");
596
+ const filePath = nodeStudioDraftPath(userCtx, id);
597
+ const previous = fs.existsSync(filePath)
598
+ ? JSON.parse(fs.readFileSync(filePath, "utf-8"))
599
+ : {};
600
+ const now = new Date().toISOString();
601
+ const next = {
602
+ ...previous,
603
+ ...draft,
604
+ id,
605
+ createdAt: previous.createdAt || draft.createdAt || now,
606
+ updatedAt: now,
607
+ ownerUserId: String(userCtx.userId || draft.ownerUserId || ""),
608
+ };
609
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
610
+ fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + "\n", "utf-8");
611
+ return next;
612
+ }
613
+
614
+ function listNodeStudioDrafts(userCtx = {}) {
615
+ const rootDir = nodeStudioDraftsRoot(userCtx);
616
+ if (!fs.existsSync(rootDir)) return [];
617
+ const rows = [];
618
+ for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
619
+ if (!entry.isDirectory()) continue;
620
+ const filePath = path.join(rootDir, entry.name, "draft.json");
621
+ if (!fs.existsSync(filePath)) continue;
622
+ try {
623
+ const draft = JSON.parse(fs.readFileSync(filePath, "utf-8"));
624
+ if (isLegacyNodeStudioDemoDraft(draft)) continue;
625
+ rows.push({
626
+ id: String(draft.id || entry.name),
627
+ title: String(draft.title || draft.manifest?.name || entry.name),
628
+ definitionId: String(draft.definitionId || `marketplace:${draft.manifest?.id || entry.name}@${draft.manifest?.version || "1.0.0"}`),
629
+ updatedAt: String(draft.updatedAt || ""),
630
+ });
631
+ } catch {
632
+ /* ignore corrupt drafts */
633
+ }
634
+ }
635
+ rows.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")) || a.id.localeCompare(b.id));
636
+ return rows;
637
+ }
638
+
639
+ function buildWorkspaceGeneratePrompt(payload) {
640
+ const userPrompt = String(payload?.prompt || "").trim();
641
+ const outputKind = String(payload?.outputKind || payload?.kind || "markdown").trim().toLowerCase();
642
+ const allowFlowYaml = payload?.allowFlowYaml === true || payload?.allowFlowYaml === "1";
643
+ const workspaceGraph = payload?.workspaceGraph && typeof payload.workspaceGraph === "object" ? payload.workspaceGraph : null;
644
+ // 图以代码形态给模型看:同一张图 JSON 要几万 token,代码几千,而且 `output-1 -> input-2`
645
+ // 这种下标边模型根本读不出连的是什么槽。生成失败就退回 JSON——上下文缺失比报错更糟。
646
+ const workspaceGraphBlock = workspaceGraph ? workspaceGraphAsSource(workspaceGraph) : "";
647
+ const selectedNodeIds = Array.isArray(payload?.selectedNodeIds)
648
+ ? payload.selectedNodeIds.map((id) => String(id || "").trim()).filter(Boolean)
649
+ : [];
650
+ const skillsBlock = typeof payload?.skillsBlock === "string" ? payload.skillsBlock.trim() : "";
651
+ const nodeCatalogBlock = typeof payload?.nodeCatalogBlock === "string" ? payload.nodeCatalogBlock.trim() : "";
652
+ const history = Array.isArray(payload?.messages) ? payload.messages : [];
653
+ const historyBlock = history
654
+ .slice(-16)
655
+ .map((msg) => {
656
+ const text = String(msg?.text || "").trim();
657
+ if (!text) return "";
658
+ const kind = String(msg?.kind || "").trim();
659
+ if (kind === "raw" || kind === "prompt" || kind === "thinking") return "";
660
+ const role = msg?.role === "user" ? "user" : (msg?.error ? "error" : "assistant");
661
+ if (kind === "run-summary" || kind === "activity") return `context: ${text}`;
662
+ return `${role}: ${text}`;
663
+ })
664
+ .filter(Boolean)
665
+ .join("\n\n");
666
+ const contexts = Array.isArray(payload?.contexts) ? payload.contexts : [];
667
+ const contextBlocks = contexts
668
+ .map((ctx, idx) => {
669
+ const title = String(ctx?.title || ctx?.path || `context-${idx + 1}`).trim();
670
+ const kind = String(ctx?.kind || "text").trim();
671
+ const content = String(ctx?.content || "").trim();
672
+ if (!content) return "";
673
+ return `### ${title} (${kind})\n\n${content}`;
674
+ })
675
+ .filter(Boolean)
676
+ .join("\n\n---\n\n");
677
+ const kindInstruction =
678
+ outputKind === "mermaid"
679
+ ? [
680
+ "你是 workspace Mermaid 图节点的内容生成器。",
681
+ "请根据用户 prompt 和上游节点/文件上下文生成 Mermaid flowchart 源码。",
682
+ "只输出 Mermaid 源码,不要解释,不要包裹 Markdown 代码围栏。",
683
+ "优先使用 `flowchart TD` 或 `graph TD`,节点 ID 使用简单英文/数字/下划线,节点 label 使用清晰短文本。",
684
+ ].join("\n")
685
+ : outputKind === "ascii"
686
+ ? [
687
+ "你是 workspace ASCII 图节点的内容生成器。",
688
+ "请根据用户 prompt 和上游节点/文件上下文生成等宽字体下可读的 ASCII 图。",
689
+ "只输出 ASCII 图正文,不要解释,不要包裹 Markdown 代码围栏。",
690
+ "使用 +-|/\\<> 等字符表达结构,尽量保持对齐。",
691
+ ].join("\n")
692
+ : outputKind === "react"
693
+ ? [
694
+ "你是 workspace React 工程节点的内容生成器。",
695
+ "请根据用户 prompt 和上游节点/文件上下文生成一个可预览的小型 React 工程 JSON。",
696
+ "只输出 JSON,不要解释,不要包裹 Markdown 代码围栏。",
697
+ "JSON 必须包含 title、entry、files;files 至少包含 src/App.jsx,可包含 src/styles.css。",
698
+ "src/App.jsx 里定义或 export default 一个 App 组件;不要依赖未声明的外部包。",
699
+ ].join("\n")
700
+ : [
701
+ "你是 AgentFlow Workspace Composer。",
702
+ "默认以用户当前选择的 workspace 节点作为上下文范围;选中节点不是让你重建整张画布的授权。",
703
+ "默认不要修改 workspace.flow.js,不要新增/删除/重连画布节点;只有当用户明确要求“更新画布、加节点、改连线、展示成节点、生成流程”时,才编辑 workspace.flow.js。",
704
+ "如果用户请求生成或恢复文档/文件,可以直接在 workspace 文件系统中完成,最终只输出简短结果:改了什么、路径在哪里、是否需要下一步。",
705
+ "不要在最终回答中列出过程性步骤,例如“先查看结构”“继续检索”“正在生成”;这些属于执行过程,不属于最终结果。",
706
+ ].join("\n");
707
+ return [
708
+ "你正在 AgentFlow 的 Workspace 工作画布中执行任务。",
709
+ "Workspace 是当前 pipeline 的临时工作区,用于分析、试验、生成中间文件和展示结果。",
710
+ "Workspace 与 Pipeline 各自有独立的 Skill collection;此处只使用当前 Workspace Composer 选择的 collections / skills 作为本次行为规则与编辑依据。",
711
+ "当 Skills 提到修改 flow.yaml / instances / edges / ui 时,在 Workspace 视图下应映射为修改当前工作区的 workspace.flow.js,除非用户显式勾选并要求修改正式 flow.yaml。",
712
+ "画布就是代码:workspace.flow.js 是受限 ESM——`flow()` 是入口,一个节点是一次 `const 变量名 = 类型(\"显示名\", { 引脚 }, 正文)`,引用上游变量的引脚就是一条数据线。它永不执行,只被静态解析,所以里面禁止一切控制流(if / for / await / .map / 箭头函数 / 动态属性)。要写逻辑就建代码节点 nodes/<name>/index.mjs,那里是普通 JS。",
713
+ "workspace.layout.json(坐标)、workspace.nodes.json(图片等机器属性)、workspace.state.json(运行产出)都由平台维护,不要手改。",
714
+ "改完必须跑 `agentflow flow dsl lint <flowDir>` 自查;语法或引脚写错会让整张画布打不开。完整语法见 agentflow-flow-dsl skill。",
715
+ allowFlowYaml
716
+ ? "用户已允许你考虑正式 flow.yaml;如需修改仍必须明确说明影响。"
717
+ : "默认不要修改正式 flow.yaml;优先在 workspace 文件、workspace.flow.js 或回复内容中完成任务。",
718
+ workspaceSearchGuardrailsBlock(),
719
+ workspaceGraphBlock,
720
+ selectedNodeIds.length > 0 ? `\n## 当前用户选中的 workspace 节点\n\n${selectedNodeIds.map((id) => `- ${id}`).join("\n")}` : "",
721
+ skillsBlock ? `\n## Selected Skills\n\n${skillsBlock}` : "",
722
+ nodeCatalogBlock ? `\n## 当前已安装的节点包\n\n${nodeCatalogBlock}` : "",
723
+ kindInstruction,
724
+ contextBlocks ? `\n## 上下文\n\n${contextBlocks}` : "",
725
+ historyBlock ? `\n## 对话历史\n\n${historyBlock}` : "",
726
+ `\n## 用户 prompt\n\n${userPrompt}`,
727
+ ].filter(Boolean).join("\n");
728
+ }
729
+
730
+ function workspaceNodePackageCatalogBlock(workspaceRoot, scoped, userCtx = {}) {
731
+ const catalog = listNodesJson(workspaceRoot, scoped.flowId || "", scoped.flowSource || "user", {
732
+ archived: scoped.archived,
733
+ ...userCtx,
734
+ marketplaceScope: "all",
735
+ });
736
+ // flow/project 本地包虽然也有 marketplaceDefinitionId,但另一个端并没有安装它,不能教 AI
737
+ // 用 marketplace: 引用。这里只暴露已经进入 marketplace/collection 的可移植版本。
738
+ const rows = (Array.isArray(catalog?.nodes) ? catalog.nodes : []).filter((node) =>
739
+ ["marketplace", "collection"].includes(String(node?.source || ""))
740
+ && String(node?.marketplaceDefinitionId || node?.id || "").startsWith("marketplace:"));
741
+ if (!rows.length) return "";
742
+ return rows.slice(0, 100).map((node, index) => {
743
+ const ref = String(node.marketplaceDefinitionId || node.id);
744
+ const binding = String(node.packageId || `nodePackage${index + 1}`)
745
+ .replace(/[^A-Za-z0-9_$]+(.)?/g, (_, ch) => ch ? ch.toUpperCase() : "")
746
+ .replace(/^[^A-Za-z_$]+/, "") || `nodePackage${index + 1}`;
747
+ const slots = (kind) => (Array.isArray(node[kind]) ? node[kind] : [])
748
+ .filter((slot) => slot?.name && !["prev", "next"].includes(slot.name))
749
+ .map((slot) => `${slot.name}:${slot.type || "text"}${slot.required ? "!" : ""}`)
750
+ .join(", ") || "无";
751
+ return [
752
+ `- ${ref} · ${node.displayName || node.label || node.packageId || ref}`,
753
+ ` 用途:${String(node.description || "未提供说明").replace(/\s+/g, " ").slice(0, 300)}`,
754
+ ` 输入:${slots("inputs")};输出:${slots("outputs")}`,
755
+ ` DSL:import ${binding} from ${JSON.stringify(ref)};`,
756
+ ].join("\n");
757
+ }).join("\n");
758
+ }
759
+
760
+ function buildWorkspaceNodeChatPrompt(payload) {
761
+ const node = payload?.node && typeof payload.node === "object" ? payload.node : {};
762
+ const userMessage = String(payload?.message || "").trim();
763
+ const currentContent = String(payload?.currentContent || "").trim();
764
+ const nodeKind = String(payload?.nodeKind || payload?.kind || "markdown").trim().toLowerCase();
765
+ const sourceContext = String(payload?.sourceContext || "").trim();
766
+ const targetFilePath = String(payload?.targetFilePath || "").trim();
767
+ const directFileEdit = Boolean(targetFilePath);
768
+ const history = Array.isArray(payload?.messages) ? payload.messages : [];
769
+ const historyBlock = history
770
+ .slice(-8)
771
+ .map((msg) => {
772
+ const role = String(msg?.role || "user").trim() === "assistant" ? "assistant" : "user";
773
+ const text = String(msg?.text || "").trim();
774
+ return text ? `${role}: ${text}` : "";
775
+ })
776
+ .filter(Boolean)
777
+ .join("\n\n");
778
+ const outputRule = directFileEdit
779
+ ? [
780
+ `直接修改当前 workspace 内的文件:${targetFilePath}`,
781
+ "必须使用可用的文件编辑工具实际写入该文件;不要只描述改法。",
782
+ "不要把完整文件内容输出到聊天回复。",
783
+ "完成后只输出一句简短中文确认;如果无法完成,只输出原因,且说明文件未修改。",
784
+ ].join("\n")
785
+ : nodeKind === "html"
786
+ ? "只输出完整或片段 HTML,不要解释,不要包裹 Markdown 代码围栏。"
787
+ : nodeKind === "react"
788
+ ? "只输出 React 工程 JSON,不要解释,不要包裹 Markdown 代码围栏。JSON 必须包含 title、entry、files;files 至少包含 src/App.jsx。"
789
+ : nodeKind === "image"
790
+ ? "只输出新的图片 src,可以是 URL、data URL 或文件路径,不要解释。"
791
+ : nodeKind === "mermaid"
792
+ ? "只输出 Mermaid 源码,不要解释,不要包裹 Markdown 代码围栏。"
793
+ : nodeKind === "ascii"
794
+ ? "只输出 ASCII 正文,不要解释,不要包裹 Markdown 代码围栏。"
795
+ : "只输出新的 Markdown 正文,不要解释,不要包裹 Markdown 代码围栏。";
796
+ return [
797
+ "你正在微调 AgentFlow Workspace 画布中的单个展示节点。",
798
+ directFileEdit
799
+ ? "根据用户 follow-up 直接编辑该展示节点引用的 artifact 文件。"
800
+ : "根据用户 follow-up 和当前节点内容,生成一个可直接替换当前节点展示内容的候选版本。",
801
+ "上下文只来自当前展示内容、直接上游节点任务和本节点对话历史;不要引用或复述 thinking、运行日志、下游展示内容。",
802
+ outputRule,
803
+ "",
804
+ "## 当前节点",
805
+ `- id: ${String(node.id || "").trim() || "(unknown)"}`,
806
+ `- label: ${String(node.label || "").trim() || "(unnamed)"}`,
807
+ `- definitionId: ${String(node.definitionId || "").trim() || "(unknown)"}`,
808
+ `- kind: ${nodeKind}`,
809
+ targetFilePath ? `- artifactFile: ${targetFilePath}` : "",
810
+ sourceContext ? `\n## 生成该展示的直接上游上下文(不含 thinking/log)\n\n${sourceContext}` : "",
811
+ !directFileEdit && currentContent ? `\n## 当前展示内容\n\n${currentContent}` : "",
812
+ historyBlock ? `\n## 本节点对话历史\n\n${historyBlock}` : "",
813
+ `\n## 用户 follow-up\n\n${userMessage}`,
814
+ ].filter(Boolean).join("\n");
815
+ }
816
+
817
+ function parseWorkspaceUploadForm(req) {
818
+ return new Promise((resolve, reject) => {
819
+ const bb = busboy({
820
+ headers: req.headers,
821
+ limits: { files: 1, fileSize: 10 * 1024 * 1024, parts: 32 },
822
+ });
823
+ const fields = {};
824
+ const chunks = [];
825
+ let filename = "";
826
+ let mimeType = "";
827
+ let gotFile = false;
828
+ bb.on("field", (name, val) => {
829
+ fields[String(name || "")] = String(val || "");
830
+ });
831
+ bb.on("file", (name, file, info) => {
832
+ if (name !== "file") {
833
+ file.resume();
834
+ return;
835
+ }
836
+ gotFile = true;
837
+ filename = info.filename || "";
838
+ mimeType = info.mimeType || "";
839
+ file.on("data", (d) => chunks.push(d));
840
+ file.on("limit", () => {
841
+ reject(new Error("FILE_TOO_LARGE"));
842
+ });
843
+ });
844
+ bb.on("finish", () => {
845
+ resolve({
846
+ fields,
847
+ file: Buffer.concat(chunks),
848
+ filename,
849
+ mimeType,
850
+ gotFile,
851
+ });
852
+ });
853
+ bb.on("error", reject);
854
+ req.pipe(bb);
855
+ });
856
+ }
857
+
858
+ /** ZIP 本地头:PK\x03\x04 / \x05\x06 / \x07\x08 */
859
+ function workspaceBufferLooksLikeZip(buf) {
860
+ return (
861
+ buf.length >= 4
862
+ && buf[0] === 0x50
863
+ && buf[1] === 0x4b
864
+ && (buf[2] === 0x03 || buf[2] === 0x05 || buf[2] === 0x07)
865
+ && (buf[3] === 0x04 || buf[3] === 0x06 || buf[3] === 0x08)
866
+ );
867
+ }
868
+
869
+ /**
870
+ * @param {import('http').IncomingMessage} req
871
+ * @param {import('http').ServerResponse} res
872
+ * @param {object} ctx 请求上下文 + ui-server 侧的几个依赖
873
+ */
874
+ async function workspaceRoutes(req, res, ctx) {
875
+ const { url, authUser, userCtx, root, host, MIME, adminWorkspaceRequestedUserContext, broadcastWorkspaceCollaborationEvent, findWorkspaceShareUser, isValidFlowSourceRead, readUserWorkspaces, requestPublicBaseUrl, resolveWorkspaceScopeRoot, teamSummaryWithUsers, listConfiguredWorkspaces } = ctx;
876
+
877
+ if (req.method === "GET" && url.pathname === "/api/node-packages") {
878
+ try {
879
+ const packages = listMarketplacePackages(root, { ...userCtx, marketplaceScope: "all" });
880
+ json(res, 200, {
881
+ nodes: packages.nodes.map((node) => ({
882
+ id: node.id,
883
+ version: node.version,
884
+ definitionId: node.definitionId,
885
+ displayName: node.displayName,
886
+ description: node.description,
887
+ baseDefinitionId: node.baseDefinitionId,
888
+ inputs: node.inputs,
889
+ outputs: node.outputs,
890
+ ownerUserId: node.ownerUserId || node.createdBy || "",
891
+ fileList: node.fileList || [],
892
+ fileCount: node.fileCount || 0,
893
+ totalBytes: node.totalBytes || 0,
894
+ contentSha256: node.contentSha256 || "",
895
+ archiveSha256: node.archiveSha256 || "",
896
+ downloadPath: `/api/node-packages/${encodeURIComponent(node.id)}/${encodeURIComponent(node.version)}/archive`,
897
+ })),
898
+ });
899
+ } catch (e) {
900
+ json(res, 500, { error: (e && e.message) || String(e) });
901
+ }
902
+ return;
903
+ }
904
+
905
+ if (req.method === "POST" && url.pathname === "/api/node-packages") {
906
+ const ct = req.headers["content-type"] || "";
907
+ if (!ct.toLowerCase().startsWith("multipart/form-data")) {
908
+ json(res, 415, { error: "需要 multipart/form-data" });
909
+ return;
910
+ }
911
+ let parsed;
912
+ try {
913
+ parsed = await parseWorkspaceUploadForm(req);
914
+ } catch (e) {
915
+ json(res, e?.message === "FILE_TOO_LARGE" ? 413 : 400, {
916
+ error: e?.message === "FILE_TOO_LARGE" ? "节点包 ZIP 过大(最大 10MB)" : ((e && e.message) || String(e)),
917
+ });
918
+ return;
919
+ }
920
+ if (!parsed.gotFile || !parsed.file.length || !workspaceBufferLooksLikeZip(parsed.file)) {
921
+ json(res, 400, { error: "请上传 ZIP 节点包(字段名 file)" });
922
+ return;
923
+ }
924
+ try {
925
+ const result = publishNodePackageArchive(root, parsed.file, { ownerUserId: userCtx.userId });
926
+ json(res, result.ok ? (result.alreadyExists ? 200 : 201) : (result.conflict ? 409 : 400), result);
927
+ } catch (e) {
928
+ json(res, 500, { ok: false, error: (e && e.message) || String(e) });
929
+ }
930
+ return;
931
+ }
932
+
933
+ const nodePackageDownload = url.pathname.match(/^\/api\/node-packages\/([^/]+)\/([^/]+)\/archive$/);
934
+ if (req.method === "GET" && nodePackageDownload) {
935
+ let id = "";
936
+ let version = "";
937
+ try {
938
+ id = decodeURIComponent(nodePackageDownload[1]);
939
+ version = decodeURIComponent(nodePackageDownload[2]);
940
+ } catch {
941
+ json(res, 400, { error: "Invalid node package id or version" });
942
+ return;
943
+ }
944
+ try {
945
+ const result = nodePackageArchive(root, id, version, { ...userCtx, marketplaceScope: "all" });
946
+ if (!result.ok) {
947
+ json(res, 404, { error: result.error || "Node package not found" });
948
+ return;
949
+ }
950
+ res.writeHead(200, {
951
+ "Content-Type": "application/zip",
952
+ "Content-Length": result.archive.length,
953
+ "Content-Disposition": `attachment; filename="${String(id).replace(/[^A-Za-z0-9_.-]/g, "-")}-${String(version).replace(/[^A-Za-z0-9_.-]/g, "-")}.zip"`,
954
+ ETag: `"sha256-${result.archiveSha256}"`,
955
+ "X-AgentFlow-Content-SHA256": result.contentSha256,
956
+ "X-AgentFlow-Archive-SHA256": result.archiveSha256,
957
+ });
958
+ res.end(result.archive);
959
+ } catch (e) {
960
+ json(res, 500, { error: (e && e.message) || String(e) });
961
+ }
962
+ return;
963
+ }
964
+
965
+ if (req.method === "GET" && url.pathname === "/api/workspace-tree") {
966
+ try {
967
+ json(res, 200, getWorkspaceTree(root));
968
+ } catch (e) {
969
+ json(res, 500, { error: (e && e.message) || String(e) });
970
+ }
971
+ return;
972
+ }
973
+
974
+ if (req.method === "POST" && url.pathname === "/api/workspace/collaboration/accept") {
975
+ try {
976
+ const payload = JSON.parse(await readBody(req));
977
+ const accepted = acceptWorkspaceCollaborationInvite({
978
+ token: payload?.token,
979
+ userId: userCtx.userId,
980
+ });
981
+ if (accepted.error) {
982
+ json(res, accepted.status || 400, { error: accepted.error });
983
+ return;
984
+ }
985
+ json(res, 200, { ok: true, workspace: accepted.workspace });
986
+ } catch (e) {
987
+ json(res, 400, { error: (e && e.message) || String(e) });
988
+ }
989
+ return;
990
+ }
991
+
992
+ if (req.method === "POST" && url.pathname === "/api/workspace/collaboration/share") {
993
+ try {
994
+ const payload = JSON.parse(await readBody(req));
995
+ const flowId = String(payload?.flowId || "").trim();
996
+ const flowSource = String(payload?.flowSource || "user").trim();
997
+ const archived = payload?.archived === true || payload?.flowArchived === true;
998
+ if (flowSource !== "workspace" && flowSource !== "user") {
999
+ json(res, 400, { error: "当前 Project 不支持协作分享" });
1000
+ return;
1001
+ }
1002
+ const scoped = resolveWorkspaceScopeRoot(root, {
1003
+ flowId,
1004
+ flowSource,
1005
+ workspaceId: payload.workspaceId || "",
1006
+ adminOwnerId: payload.adminOwnerId || "",
1007
+ archived,
1008
+ }, userCtx);
1009
+ if (scoped.error) {
1010
+ json(res, scoped.status || 400, { error: scoped.error });
1011
+ return;
1012
+ }
1013
+ if (scoped.adminReadonly) {
1014
+ json(res, 403, { error: "Admin Workspace review is read-only" });
1015
+ return;
1016
+ }
1017
+ const ensured = ensureWorkspaceCollaboration({
1018
+ flowId,
1019
+ flowSource,
1020
+ archived,
1021
+ userId: userCtx.userId,
1022
+ });
1023
+ if (ensured.error) {
1024
+ json(res, ensured.status || 400, { error: ensured.error });
1025
+ return;
1026
+ }
1027
+ const targetUser = findWorkspaceShareUser(payload?.username || payload?.userId);
1028
+ if (!targetUser) {
1029
+ json(res, 404, { error: "未找到该用户名,请确认对方已经登录或注册 AgentFlow" });
1030
+ return;
1031
+ }
1032
+ const added = addWorkspaceCollaborationMember({
1033
+ workspaceId: ensured.workspace.id,
1034
+ userId: userCtx.userId,
1035
+ memberUserId: targetUser.userId,
1036
+ role: payload?.role,
1037
+ });
1038
+ if (added.error) {
1039
+ json(res, added.status || 400, { error: added.error });
1040
+ return;
1041
+ }
1042
+ const record = getWorkspaceCollaborationForProject({ workspaceId: ensured.workspace.id });
1043
+ broadcastWorkspaceCollaborationEvent(userCtx, flowSource, flowId, archived, {
1044
+ type: "member.added",
1045
+ actorId: userCtx.userId || "",
1046
+ memberUserId: targetUser.userId,
1047
+ });
1048
+ json(res, 200, {
1049
+ ok: true,
1050
+ workspace: workspaceCollaborationSummaryWithUsers(record, userCtx.userId),
1051
+ member: { userId: targetUser.userId, username: targetUser.username, role: "editor" },
1052
+ });
1053
+ } catch (e) {
1054
+ json(res, 400, { error: (e && e.message) || String(e) });
1055
+ }
1056
+ return;
1057
+ }
1058
+
1059
+ if (url.pathname === "/api/workspace/collaboration/team-share" && (req.method === "POST" || req.method === "DELETE")) {
1060
+ try {
1061
+ const payload = JSON.parse(await readBody(req));
1062
+ const flowId = String(payload?.flowId || "").trim();
1063
+ const flowSource = String(payload?.flowSource || "user").trim();
1064
+ const archived = payload?.archived === true || payload?.flowArchived === true;
1065
+ if (!flowId || (flowSource !== "workspace" && flowSource !== "user")) {
1066
+ json(res, 400, { error: "当前 Project 不支持团队分享" });
1067
+ return;
1068
+ }
1069
+ const targetTeam = getTeamById(payload?.teamId);
1070
+ const actorTeam = getTeamForUser(userCtx.userId);
1071
+ if (!targetTeam || targetTeam.status !== "active") {
1072
+ json(res, 404, { error: "团队不存在或已停用" });
1073
+ return;
1074
+ }
1075
+ if (!authUser?.isAdmin && actorTeam?.id !== targetTeam.id) {
1076
+ json(res, 403, { error: "只能分享给自己所在的团队" });
1077
+ return;
1078
+ }
1079
+ const scoped = resolveWorkspaceScopeRoot(root, {
1080
+ flowId,
1081
+ flowSource,
1082
+ workspaceId: payload.workspaceId || "",
1083
+ archived,
1084
+ }, userCtx);
1085
+ if (scoped.error) {
1086
+ json(res, scoped.status || 400, { error: scoped.error });
1087
+ return;
1088
+ }
1089
+ const ensured = ensureWorkspaceCollaboration({
1090
+ flowId,
1091
+ flowSource,
1092
+ archived,
1093
+ userId: userCtx.userId,
1094
+ });
1095
+ if (ensured.error) {
1096
+ json(res, ensured.status || 400, { error: ensured.error });
1097
+ return;
1098
+ }
1099
+ const result = req.method === "POST"
1100
+ ? setWorkspaceCollaborationTeamShare({
1101
+ workspaceId: ensured.workspace.id,
1102
+ userId: userCtx.userId,
1103
+ teamId: targetTeam.id,
1104
+ role: payload?.role,
1105
+ })
1106
+ : removeWorkspaceCollaborationTeamShare({
1107
+ workspaceId: ensured.workspace.id,
1108
+ userId: userCtx.userId,
1109
+ teamId: targetTeam.id,
1110
+ });
1111
+ if (result.error) {
1112
+ json(res, result.status || 400, { error: result.error });
1113
+ return;
1114
+ }
1115
+ const record = getWorkspaceCollaborationForProject({ workspaceId: ensured.workspace.id });
1116
+ json(res, 200, {
1117
+ ok: true,
1118
+ workspace: workspaceCollaborationSummaryWithUsers(record, userCtx.userId),
1119
+ team: teamSummaryWithUsers(targetTeam),
1120
+ });
1121
+ } catch (e) {
1122
+ json(res, 400, { error: (e && e.message) || String(e) });
1123
+ }
1124
+ return;
1125
+ }
1126
+
1127
+ if (req.method === "DELETE" && url.pathname === "/api/workspace/collaboration/share") {
1128
+ try {
1129
+ const payload = JSON.parse(await readBody(req));
1130
+ const flowId = String(payload?.flowId || "").trim();
1131
+ const flowSource = String(payload?.flowSource || "user").trim();
1132
+ const archived = payload?.archived === true || payload?.flowArchived === true;
1133
+ if (!flowId || (flowSource !== "workspace" && flowSource !== "user")) {
1134
+ json(res, 400, { error: "Missing shared project" });
1135
+ return;
1136
+ }
1137
+ const record = getWorkspaceCollaborationForProject({
1138
+ workspaceId: payload.workspaceId || "",
1139
+ flowId,
1140
+ flowSource,
1141
+ archived,
1142
+ ownerId: userCtx.userId,
1143
+ }) || listWorkspaceCollaborationsForUser(userCtx.userId).find((item) => (
1144
+ item.flowId === flowId
1145
+ && (item.projectSource || item.flowSource || "workspace") === flowSource
1146
+ && item.archived === archived
1147
+ ));
1148
+ if (!record) {
1149
+ json(res, 404, { error: "Workspace collaboration not found" });
1150
+ return;
1151
+ }
1152
+ const requestedUser = String(payload?.username || payload?.memberUserId || "").trim();
1153
+ const targetUser = requestedUser ? findWorkspaceShareUser(requestedUser) : null;
1154
+ if (requestedUser && !targetUser) {
1155
+ json(res, 404, { error: "未找到该用户" });
1156
+ return;
1157
+ }
1158
+ const removed = removeWorkspaceCollaborationMember({
1159
+ workspaceId: record.id,
1160
+ userId: userCtx.userId,
1161
+ memberUserId: targetUser?.userId || userCtx.userId,
1162
+ });
1163
+ if (removed.error) {
1164
+ json(res, removed.status || 400, { error: removed.error });
1165
+ return;
1166
+ }
1167
+ broadcastWorkspaceCollaborationEvent(userCtx, flowSource, flowId, archived, {
1168
+ type: removed.left ? "member.left" : "member.removed",
1169
+ actorId: userCtx.userId || "",
1170
+ memberUserId: removed.removedUserId || "",
1171
+ });
1172
+ const nextRecord = getWorkspaceCollaborationForProject({ workspaceId: record.id });
1173
+ json(res, 200, {
1174
+ ok: true,
1175
+ left: removed.left === true,
1176
+ removedUserId: removed.removedUserId || "",
1177
+ workspace: removed.left ? null : workspaceCollaborationSummaryWithUsers(nextRecord, userCtx.userId),
1178
+ });
1179
+ } catch (e) {
1180
+ json(res, 400, { error: (e && e.message) || String(e) });
1181
+ }
1182
+ return;
1183
+ }
1184
+
1185
+ if (req.method === "GET" && url.pathname === "/api/workspace/events") {
1186
+ const scoped = resolveWorkspaceScopeRoot(root, {
1187
+ flowId: url.searchParams.get("flowId") || "",
1188
+ flowSource: url.searchParams.get("flowSource") || "user",
1189
+ workspaceId: url.searchParams.get("workspaceId") || "",
1190
+ archived: url.searchParams.get("archived") === "1",
1191
+ }, userCtx);
1192
+ if (scoped.error) {
1193
+ json(res, scoped.status || 400, { error: scoped.error });
1194
+ return;
1195
+ }
1196
+ const key = workspaceCollaborationEventKey(
1197
+ workspaceScopedUserContext(scoped, userCtx),
1198
+ scoped.flowSource,
1199
+ scoped.flowId,
1200
+ scoped.archived,
1201
+ );
1202
+ let subscribers = workspaceCollaborationSubscribers.get(key);
1203
+ if (!subscribers) {
1204
+ subscribers = new Set();
1205
+ workspaceCollaborationSubscribers.set(key, subscribers);
1206
+ }
1207
+ res.writeHead(200, {
1208
+ "Content-Type": "text/event-stream; charset=utf-8",
1209
+ "Cache-Control": "no-cache, no-transform",
1210
+ Connection: "keep-alive",
1211
+ "X-Accel-Buffering": "no",
1212
+ });
1213
+ res.write(`event: connected\ndata: ${JSON.stringify({ seq: workspaceCollaborationSequences.get(key) || 0 })}\n\n`);
1214
+ subscribers.add(res);
1215
+ const heartbeat = setInterval(() => {
1216
+ try { res.write(`: heartbeat ${Date.now()}\n\n`); } catch (_) {}
1217
+ }, 15_000);
1218
+ const detach = () => {
1219
+ clearInterval(heartbeat);
1220
+ subscribers.delete(res);
1221
+ if (subscribers.size === 0) workspaceCollaborationSubscribers.delete(key);
1222
+ };
1223
+ req.on("close", detach);
1224
+ res.on("close", detach);
1225
+ return;
1226
+ }
1227
+
1228
+ if (req.method === "GET" && url.pathname === "/api/workspace/files") {
1229
+ try {
1230
+ const scoped = resolveWorkspaceScopeRoot(root, {
1231
+ flowId: url.searchParams.get("flowId") || "",
1232
+ flowSource: url.searchParams.get("flowSource") || "user",
1233
+ workspaceId: url.searchParams.get("workspaceId") || "",
1234
+ archived: url.searchParams.get("archived") === "1",
1235
+ }, userCtx);
1236
+ if (scoped.error) {
1237
+ json(res, 400, { error: scoped.error });
1238
+ return;
1239
+ }
1240
+ json(res, 200, { ...readWorkspaceFiles(scoped.root), flowId: scoped.flowId, flowSource: scoped.flowSource, archived: scoped.archived });
1241
+ } catch (e) {
1242
+ json(res, 500, { error: (e && e.message) || String(e) });
1243
+ }
1244
+ return;
1245
+ }
1246
+
1247
+ if (req.method === "GET" && url.pathname === "/api/workspaces") {
1248
+ try {
1249
+ const scoped = resolveWorkspaceScopeRoot(root, {
1250
+ flowId: url.searchParams.get("flowId") || "",
1251
+ flowSource: url.searchParams.get("flowSource") || "user",
1252
+ workspaceId: url.searchParams.get("workspaceId") || "",
1253
+ archived: url.searchParams.get("archived") === "1",
1254
+ }, userCtx);
1255
+ const scopedRoot = scoped.error ? root : scoped.root;
1256
+ json(res, 200, {
1257
+ path: workspacesPath(),
1258
+ workspaces: listConfiguredWorkspaces(root, scopedRoot, userCtx),
1259
+ customWorkspaces: readUserWorkspaces(userCtx),
1260
+ });
1261
+ } catch (e) {
1262
+ json(res, 500, { error: (e && e.message) || String(e) });
1263
+ }
1264
+ return;
1265
+ }
1266
+
1267
+ if (req.method === "POST" && url.pathname === "/api/workspaces") {
1268
+ try {
1269
+ const payload = JSON.parse(await readBody(req));
1270
+ const customWorkspaces = writeUserWorkspaces(userCtx, payload?.workspaces || payload?.customWorkspaces || []);
1271
+ json(res, 200, {
1272
+ path: workspacesPath(),
1273
+ workspaces: listConfiguredWorkspaces(root, root, userCtx),
1274
+ customWorkspaces,
1275
+ });
1276
+ } catch (e) {
1277
+ json(res, 500, { error: (e && e.message) || String(e) });
1278
+ }
1279
+ return;
1280
+ }
1281
+
1282
+ if (req.method === "POST" && url.pathname === "/api/workspaces/sync") {
1283
+ try {
1284
+ const payload = JSON.parse(await readBody(req));
1285
+ const id = String(payload?.id || "").trim();
1286
+ const workspaces = readUserWorkspaces(userCtx);
1287
+ const workspace = workspaces.find((entry) => String(entry.id || "") === id);
1288
+ if (!workspace) {
1289
+ json(res, 404, { error: "工作区不存在" });
1290
+ return;
1291
+ }
1292
+ const result = syncGitWorkspace(workspace, userCtx);
1293
+ json(res, 200, {
1294
+ ok: true,
1295
+ ...result,
1296
+ workspaces: listConfiguredWorkspaces(root, root, userCtx),
1297
+ customWorkspaces: readUserWorkspaces(userCtx),
1298
+ });
1299
+ } catch (e) {
1300
+ json(res, 500, { error: (e && e.message) || String(e) });
1301
+ }
1302
+ return;
1303
+ }
1304
+
1305
+ if (req.method === "POST" && url.pathname === "/api/workspace/preview") {
1306
+ if (!authUser?.userId) {
1307
+ json(res, 401, { error: "Authentication required" });
1308
+ return;
1309
+ }
1310
+ let payload;
1311
+ try {
1312
+ payload = JSON.parse(await readBody(req, 4 * 1024 * 1024));
1313
+ } catch {
1314
+ json(res, 400, { error: "Invalid JSON" });
1315
+ return;
1316
+ }
1317
+ const graph = payload?.graph;
1318
+ if (!graph || typeof graph !== "object" || Array.isArray(graph)) {
1319
+ json(res, 400, { error: "graph must be an object" });
1320
+ return;
1321
+ }
1322
+ const instances = graph.instances && typeof graph.instances === "object" && !Array.isArray(graph.instances)
1323
+ ? graph.instances
1324
+ : {};
1325
+ if (Object.values(instances).some((item) => String(item?.definitionId || "") === "workspace_scheduled_run")) {
1326
+ json(res, 400, { error: "Temporary Workspace preview cannot contain scheduled-run nodes" });
1327
+ return;
1328
+ }
1329
+ const rawRequestedId = String(payload.previewId || "").trim();
1330
+ const flowId = rawRequestedId || createWorkspacePreviewId();
1331
+ const flowDir = workspacePreviewFlowDir(flowId, authUser.userId);
1332
+ if (!flowDir) {
1333
+ json(res, 400, { error: "Invalid previewId" });
1334
+ return;
1335
+ }
1336
+ const existing = readWorkspacePreviewMetadata(flowDir);
1337
+ if (existing && existing.ownerId !== authUser.userId) {
1338
+ json(res, 403, { error: "Preview ownership denied" });
1339
+ return;
1340
+ }
1341
+ if (rawRequestedId && !existing && fs.existsSync(flowDir)) {
1342
+ json(res, 409, { error: "Preview project already exists but is not a preview" });
1343
+ return;
1344
+ }
1345
+ const now = Date.now();
1346
+ const ttlInput = payload.ttlMs != null
1347
+ ? Number(payload.ttlMs)
1348
+ : payload.ttlSeconds != null
1349
+ ? Number(payload.ttlSeconds) * 1000
1350
+ : DEFAULT_WORKSPACE_PREVIEW_TTL_MS;
1351
+ const ttlMs = normalizeWorkspacePreviewTtlMs(ttlInput);
1352
+ const metadata = {
1353
+ version: 1,
1354
+ flowId,
1355
+ ownerId: authUser.userId,
1356
+ title: String(payload.title || "Workspace Preview").trim().slice(0, 200),
1357
+ createdAt: existing?.createdAt || new Date(now).toISOString(),
1358
+ updatedAt: new Date(now).toISOString(),
1359
+ expiresAt: new Date(now + ttlMs).toISOString(),
1360
+ };
1361
+ try {
1362
+ fs.mkdirSync(flowDir, { recursive: true });
1363
+ writeWorkspaceGraph(flowDir, graph, root);
1364
+ writeWorkspacePreviewMetadata(flowDir, metadata);
1365
+ } catch (e) {
1366
+ json(res, 500, { error: (e && e.message) || String(e) });
1367
+ return;
1368
+ }
1369
+ const baseUrl = `${url.protocol}//${url.host}`;
1370
+ const workspaceUrl = `${baseUrl}/workspace?flowId=${encodeURIComponent(flowId)}&flowSource=user`;
1371
+ json(res, 200, { ok: true, flowId, flowSource: "user", preview: true, expiresAt: metadata.expiresAt, url: workspaceUrl });
1372
+ return;
1373
+ }
1374
+
1375
+ /**
1376
+ * 把一个还停在 `flow.yaml` 的老流程迁进 Workspace。
1377
+ *
1378
+ * 平台上这类流程处在「列在列表里、点开是空图」的状态:目录哨兵认 yaml,读图那条路
1379
+ * 不认,所以既跑不了也编辑不了,里面的 body / prompt / script 只能干看着。这条路由
1380
+ * 是它们唯一的出口,也是日后能把哨兵摘掉的前提。
1381
+ *
1382
+ * 默认拒绝有损迁移,把清单原样回给调用方;`allowLoss` 才落盘。yaml 原文不删。
1383
+ */
1384
+ if (req.method === "POST" && url.pathname === "/api/workspace/migrate") {
1385
+ let payload;
1386
+ try {
1387
+ payload = JSON.parse(await readBody(req));
1388
+ } catch {
1389
+ json(res, 400, { error: "Invalid JSON body" });
1390
+ return;
1391
+ }
1392
+ try {
1393
+ const scoped = resolveWorkspaceScopeRoot(root, {
1394
+ flowId: payload.flowId || "",
1395
+ flowSource: payload.flowSource || "user",
1396
+ workspaceId: payload.workspaceId || "",
1397
+ adminOwnerId: payload.adminOwnerId || "",
1398
+ archived: payload.archived === true || payload.flowArchived === true,
1399
+ }, userCtx);
1400
+ if (scoped.error) {
1401
+ json(res, scoped.status || 400, { error: scoped.error });
1402
+ return;
1403
+ }
1404
+ // 归档流程默认不写。但它们恰恰是最需要迁的一批——没人会再打开保存,所以只会
1405
+ // 一直停在老格式上;而「归档 + 仅 yaml」的流程正是摘掉 flow.yaml 哨兵时会凭空
1406
+ // 消失的那种。迁移换的是存储格式不是内容(往返比对闸门保证图等价),所以给一个
1407
+ // 显式豁免,而不是把归档流程永远锁死在死格式里。
1408
+ if (scoped.archived && payload.allowArchived !== true) {
1409
+ json(res, 400, { error: "Archived pipeline: pass allowArchived to migrate it anyway" });
1410
+ return;
1411
+ }
1412
+ if (isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
1413
+ json(res, 400, { error: "Cannot migrate a builtin or read-only pipeline" });
1414
+ return;
1415
+ }
1416
+ const { migrateFlowDirToDsl } = await import("./flow-dsl/cli.mjs");
1417
+ const result = migrateFlowDirToDsl(scoped.root, {
1418
+ force: payload.allowLoss === true,
1419
+ marketplaceRoot: root,
1420
+ });
1421
+ if (result.format === "empty") {
1422
+ json(res, 404, { error: "这个流程目录里既没有 Workspace 图,也没有 flow.yaml", ...result });
1423
+ return;
1424
+ }
1425
+ // 迁移过的图立刻广播给正在看这张画布的人——否则他们手里还是空图,
1426
+ // 下一次保存会把刚迁好的内容覆盖回去
1427
+ if (result.migrated || result.leftYaml) {
1428
+ const { graph } = readWorkspaceGraph(scoped.root, root);
1429
+ broadcastWorkspaceCollaborationEvent(
1430
+ userCtx,
1431
+ scoped.flowSource,
1432
+ scoped.flowId,
1433
+ scoped.archived,
1434
+ { type: "graph.committed", revision: workspaceDesignRevision(graph), actorId: userCtx.userId || "" },
1435
+ );
1436
+ }
1437
+ json(res, 200, { ok: true, ...result });
1438
+ } catch (e) {
1439
+ json(res, 500, { error: (e && e.message) || String(e) });
1440
+ }
1441
+ return;
1442
+ }
1443
+
1444
+ if (req.method === "GET" && url.pathname === "/api/workspace/graph") {
1445
+ try {
1446
+ const scoped = resolveWorkspaceScopeRoot(root, {
1447
+ flowId: url.searchParams.get("flowId") || "",
1448
+ flowSource: url.searchParams.get("flowSource") || "user",
1449
+ workspaceId: url.searchParams.get("workspaceId") || "",
1450
+ archived: url.searchParams.get("archived") === "1",
1451
+ }, userCtx);
1452
+ if (scoped.error) {
1453
+ json(res, scoped.status || 400, { error: scoped.error });
1454
+ return;
1455
+ }
1456
+ const { path: graphPath, graph } = readWorkspaceGraph(scoped.root, root);
1457
+ const scopedUserCtx = workspaceScopedUserContext(scoped, userCtx);
1458
+ const hydratedGraph = hydrateWorkspaceGraphForRuntime(root, scoped, graph, scopedUserCtx);
1459
+ const collaborationAccess = scoped.collaborationAccess || workspaceCollaborationAccess(null, userCtx.userId);
1460
+ json(res, 200, {
1461
+ ok: true,
1462
+ graph: hydratedGraph,
1463
+ revision: workspaceDesignRevision(hydratedGraph),
1464
+ designRevision: workspaceDesignRevision(hydratedGraph),
1465
+ runtimeRevision: workspaceRuntimeRevision(hydratedGraph),
1466
+ path: graphPath,
1467
+ root: scoped.root,
1468
+ flowId: scoped.flowId,
1469
+ flowSource: scoped.flowSource,
1470
+ archived: scoped.archived,
1471
+ writable: !(scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource))
1472
+ && scoped.adminReadonly !== true
1473
+ && collaborationAccess.writable !== false,
1474
+ collaboration: workspaceCollaborationSummaryWithUsers(scoped.collaboration, userCtx.userId),
1475
+ adminReview: scoped.adminReadonly ? {
1476
+ readonly: true,
1477
+ ownerUserId: scoped.ownerUserId,
1478
+ ownerUsername: scoped.ownerUsername,
1479
+ } : null,
1480
+ workspaceSchedules: listWorkspaceScheduleStatusesForFlow(scopedUserCtx, scoped.flowSource || "user", scoped.flowId || ""),
1481
+ });
1482
+ } catch (e) {
1483
+ // 流程文件语法错时给出可修的定位,而不是一句 500——这条路径就是手改 / AI 改
1484
+ // workspace.flow.js 之后最常撞上的
1485
+ if (e instanceof WorkspaceFlowParseError) {
1486
+ json(res, 422, { error: e.message, path: e.filePath, kind: "flow_source_parse_error" });
1487
+ return;
1488
+ }
1489
+ json(res, 500, { error: (e && e.message) || String(e) });
1490
+ }
1491
+ return;
1492
+ }
1493
+
1494
+ if (req.method === "POST" && url.pathname === "/api/workspace/graph") {
1495
+ let payload;
1496
+ try {
1497
+ payload = JSON.parse(await readBody(req));
1498
+ } catch {
1499
+ json(res, 400, { error: "Invalid JSON body" });
1500
+ return;
1501
+ }
1502
+ try {
1503
+ const scoped = resolveWorkspaceScopeRoot(root, {
1504
+ flowId: payload.flowId || "",
1505
+ flowSource: payload.flowSource || "user",
1506
+ workspaceId: payload.workspaceId || "",
1507
+ adminOwnerId: payload.adminOwnerId || "",
1508
+ archived: payload.archived === true || payload.flowArchived === true,
1509
+ }, userCtx);
1510
+ if (scoped.error) {
1511
+ json(res, scoped.status || 400, { error: scoped.error });
1512
+ return;
1513
+ }
1514
+ if (
1515
+ scoped.archived
1516
+ || isReadonlyBuiltinFlowSource(scoped.flowSource)
1517
+ || scoped.collaborationAccess?.writable === false
1518
+ ) {
1519
+ json(res, 400, { error: "Cannot write workspace graph for builtin or archived pipeline" });
1520
+ return;
1521
+ }
1522
+ const submittedDesign = payload.graph || payload;
1523
+ const submittedMissingPackages = missingWorkspaceGraphNodePackages(root, scoped, submittedDesign, userCtx);
1524
+ if (submittedMissingPackages.length) {
1525
+ json(res, 422, {
1526
+ error: `服务端缺少节点包:${submittedMissingPackages.join(", ")};请先上传这些精确版本`,
1527
+ kind: "node_packages_missing",
1528
+ missingNodePackages: submittedMissingPackages,
1529
+ });
1530
+ return;
1531
+ }
1532
+ const submittedGraph = hydrateWorkspaceGraphForRuntime(root, scoped, submittedDesign, userCtx);
1533
+ const currentStoredGraph = readWorkspaceGraph(scoped.root, root).graph;
1534
+ const currentGraph = hydrateWorkspaceGraphForRuntime(root, scoped, currentStoredGraph, userCtx);
1535
+ const currentRevision = workspaceDesignRevision(currentGraph);
1536
+ const baseRevision = String(payload.baseRevision || "").trim();
1537
+ if (scoped.collaboration && !baseRevision) {
1538
+ json(res, 428, {
1539
+ error: "Shared workspace save requires baseRevision",
1540
+ currentRevision,
1541
+ });
1542
+ return;
1543
+ }
1544
+ let nextGraph = submittedGraph;
1545
+ let merged = false;
1546
+ const baseGraph = payload.baseGraph;
1547
+ if (baseRevision && baseGraph && typeof baseGraph === "object") {
1548
+ const actualBaseRevision = workspaceDesignRevision(baseGraph);
1549
+ if (actualBaseRevision !== baseRevision) {
1550
+ json(res, 400, {
1551
+ error: "Workspace 合并基线与 baseRevision 不匹配",
1552
+ conflict: "invalid-merge-base",
1553
+ expectedRevision: baseRevision,
1554
+ actualBaseRevision,
1555
+ currentRevision,
1556
+ });
1557
+ return;
1558
+ }
1559
+ const mergeResult = mergeWorkspaceGraphs({
1560
+ baseGraph,
1561
+ currentGraph,
1562
+ incomingGraph: submittedGraph,
1563
+ });
1564
+ if (mergeResult.conflicts.length) {
1565
+ json(res, 409, {
1566
+ error: `Workspace 存在 ${mergeResult.conflicts.length} 处同字段冲突`,
1567
+ conflict: "field-conflict",
1568
+ expectedRevision: baseRevision,
1569
+ currentRevision,
1570
+ conflictPaths: mergeResult.conflicts.map((item) => item.path),
1571
+ conflictItems: mergeResult.conflicts,
1572
+ mergeGraph: mergeResult.graph,
1573
+ currentGraph,
1574
+ });
1575
+ return;
1576
+ }
1577
+ nextGraph = mergeResult.graph;
1578
+ merged = baseRevision !== currentRevision
1579
+ || workspaceRuntimeRevision(baseGraph) !== workspaceRuntimeRevision(currentGraph);
1580
+ } else if (baseRevision && baseRevision !== currentRevision) {
1581
+ json(res, 409, {
1582
+ error: "Workspace 已被其他成员更新,当前客户端缺少合并基线,请刷新后重试",
1583
+ conflict: "missing-merge-base",
1584
+ expectedRevision: baseRevision,
1585
+ currentRevision,
1586
+ });
1587
+ return;
1588
+ }
1589
+ const graph = mergeWorkspacePersistentNodeRefs(nextGraph, currentGraph);
1590
+ const committed = commitWorkspaceGraph(root, scoped, graph, userCtx);
1591
+ const { path: graphPath, revision, runtimeRevision } = committed;
1592
+ const workspaceSchedules = syncWorkspaceSchedulesForGraph(root, scoped, committed.graph, authUser, userCtx);
1593
+ broadcastWorkspaceCollaborationEvent(
1594
+ userCtx,
1595
+ scoped.flowSource,
1596
+ scoped.flowId,
1597
+ scoped.archived,
1598
+ {
1599
+ type: "graph.committed",
1600
+ revision,
1601
+ actorId: userCtx.userId || "",
1602
+ clientId: String(payload.clientId || ""),
1603
+ },
1604
+ );
1605
+ json(res, 200, {
1606
+ ok: true,
1607
+ path: graphPath,
1608
+ graph: committed.graph,
1609
+ revision,
1610
+ designRevision: revision,
1611
+ runtimeRevision,
1612
+ merged,
1613
+ workspaceSchedules,
1614
+ });
1615
+ } catch (e) {
1616
+ json(res, 500, { error: (e && e.message) || String(e) });
1617
+ }
1618
+ return;
1619
+ }
1620
+
1621
+ if (req.method === "GET" && url.pathname === "/api/workspace/schedules") {
1622
+ try {
1623
+ const flowId = url.searchParams.get("flowId") || "";
1624
+ const flowSource = url.searchParams.get("flowSource") || "user";
1625
+ if (!flowId) {
1626
+ json(res, 400, { error: "Missing flowId" });
1627
+ return;
1628
+ }
1629
+ const scoped = resolveWorkspaceScopeRoot(root, {
1630
+ flowId,
1631
+ flowSource,
1632
+ archived: url.searchParams.get("archived") === "1",
1633
+ }, userCtx);
1634
+ if (scoped.error) {
1635
+ json(res, scoped.status || 400, { error: scoped.error });
1636
+ return;
1637
+ }
1638
+ json(res, 200, {
1639
+ schedules: listWorkspaceScheduleStatusesForFlow(
1640
+ workspaceScopedUserContext(scoped, userCtx),
1641
+ flowSource,
1642
+ flowId,
1643
+ ),
1644
+ });
1645
+ } catch (e) {
1646
+ json(res, 500, { error: (e && e.message) || String(e) });
1647
+ }
1648
+ return;
1649
+ }
1650
+
1651
+ if (req.method === "POST" && url.pathname === "/api/workspace/run/plan") {
1652
+ let payload;
1653
+ try {
1654
+ payload = JSON.parse(await readBody(req));
1655
+ } catch {
1656
+ json(res, 400, { error: "Invalid JSON body" });
1657
+ return;
1658
+ }
1659
+ try {
1660
+ const scoped = resolveWorkspaceScopeRoot(root, {
1661
+ flowId: payload.flowId || "",
1662
+ flowSource: payload.flowSource || "user",
1663
+ workspaceId: payload.workspaceId || "",
1664
+ adminOwnerId: payload.adminOwnerId || "",
1665
+ archived: payload.archived === true || payload.flowArchived === true,
1666
+ }, userCtx);
1667
+ if (scoped.error) {
1668
+ json(res, 400, { error: scoped.error });
1669
+ return;
1670
+ }
1671
+ if (scoped.collaborationAccess?.runnable === false) {
1672
+ json(res, 403, { error: "Workspace run permission denied" });
1673
+ return;
1674
+ }
1675
+ const flowId = String(payload.flowId || "").trim();
1676
+ if (!flowId) {
1677
+ json(res, 400, { error: "Missing flowId" });
1678
+ return;
1679
+ }
1680
+ const graph = hydrateWorkspaceGraphForRuntime(root, scoped, payload.graph || {}, userCtx);
1681
+ const runNodeId = String(payload.runNodeId || "").trim();
1682
+ const plan = workspaceRunPlan(graph, runNodeId, scoped.root, {
1683
+ forceNodeIds: Array.isArray(payload.forceNodeIds) ? payload.forceNodeIds : [],
1684
+ ignoreCache: payload.ignoreCache === true,
1685
+ });
1686
+ const plannedNodeIds = workspaceRunPlanNodeIds(runNodeId, plan);
1687
+ const scopeKey = workspaceRunKey(userCtx, scoped.flowSource || payload.flowSource || "user", flowId);
1688
+ const conflict = workspaceFindActiveRunConflict(scopeKey, plannedNodeIds);
1689
+ json(res, 200, {
1690
+ ok: true,
1691
+ runNodeId,
1692
+ order: plan.order,
1693
+ pauseNodeIds: plan.pauseNodeIds,
1694
+ plannedNodeIds,
1695
+ conflict: conflict ? {
1696
+ runId: conflict.entry?.runId || "",
1697
+ runNodeId: conflict.entry?.runNodeId || "",
1698
+ conflictNodeIds: conflict.conflictNodeIds,
1699
+ } : null,
1700
+ });
1701
+ } catch (e) {
1702
+ json(res, 500, { error: (e && e.message) || String(e) });
1703
+ }
1704
+ return;
1705
+ }
1706
+
1707
+ if (req.method === "POST" && url.pathname === "/api/workspace/run/optimize") {
1708
+ let payload;
1709
+ try {
1710
+ payload = JSON.parse(await readBody(req));
1711
+ } catch {
1712
+ json(res, 400, { error: "Invalid JSON body" });
1713
+ return;
1714
+ }
1715
+ try {
1716
+ const scoped = resolveWorkspaceScopeRoot(root, {
1717
+ flowId: payload.flowId || "",
1718
+ flowSource: payload.flowSource || "user",
1719
+ workspaceId: payload.workspaceId || "",
1720
+ adminOwnerId: payload.adminOwnerId || "",
1721
+ archived: payload.archived === true || payload.flowArchived === true,
1722
+ }, userCtx);
1723
+ if (scoped.error) {
1724
+ json(res, 400, { error: scoped.error });
1725
+ return;
1726
+ }
1727
+ if (
1728
+ scoped.archived
1729
+ || isReadonlyBuiltinFlowSource(scoped.flowSource)
1730
+ || scoped.collaborationAccess?.writable === false
1731
+ ) {
1732
+ json(res, 400, { error: "Cannot optimize workspace graph for builtin or archived pipeline" });
1733
+ return;
1734
+ }
1735
+ const flowId = String(payload.flowId || "").trim();
1736
+ if (!flowId) {
1737
+ json(res, 400, { error: "Missing flowId" });
1738
+ return;
1739
+ }
1740
+ const result = await workspaceOptimizeRunImplementations(root, scoped.root, payload, userCtx, {
1741
+ emit: () => {},
1742
+ });
1743
+ const currentGraph = readWorkspaceGraph(scoped.root, root).graph;
1744
+ const touchedIds = new Set((result.optimized || []).map((item) => item.nodeId).filter(Boolean));
1745
+ const mergedGraph = mergeWorkspaceRunGraph(currentGraph, result.graph, touchedIds);
1746
+ const committed = commitWorkspaceGraph(root, scoped, mergedGraph, userCtx);
1747
+ const { path: graphPath, revision } = committed;
1748
+ const workspaceSchedules = syncWorkspaceSchedulesForGraph(root, scoped, committed.graph, authUser, userCtx);
1749
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
1750
+ type: "graph.committed",
1751
+ revision,
1752
+ actorId: userCtx.userId || "",
1753
+ clientId: String(payload.clientId || ""),
1754
+ });
1755
+ json(res, 200, {
1756
+ ok: true,
1757
+ path: graphPath,
1758
+ graph: committed.graph,
1759
+ revision,
1760
+ order: result.order,
1761
+ optimized: result.optimized,
1762
+ skipped: result.skipped,
1763
+ workspaceSchedules,
1764
+ });
1765
+ } catch (e) {
1766
+ json(res, 500, { error: (e && e.message) || String(e) });
1767
+ }
1768
+ return;
1769
+ }
1770
+
1771
+ if (req.method === "POST" && url.pathname === "/api/workspace/run") {
1772
+ let payload;
1773
+ try {
1774
+ payload = JSON.parse(await readBody(req));
1775
+ } catch {
1776
+ json(res, 400, { error: "Invalid JSON body" });
1777
+ return;
1778
+ }
1779
+ try {
1780
+ const scoped = resolveWorkspaceScopeRoot(root, {
1781
+ flowId: payload.flowId || "",
1782
+ flowSource: payload.flowSource || "user",
1783
+ workspaceId: payload.workspaceId || "",
1784
+ adminOwnerId: payload.adminOwnerId || "",
1785
+ archived: payload.archived === true || payload.flowArchived === true,
1786
+ }, userCtx);
1787
+ if (scoped.error) {
1788
+ json(res, 400, { error: scoped.error });
1789
+ return;
1790
+ }
1791
+ if (
1792
+ scoped.archived
1793
+ || isReadonlyBuiltinFlowSource(scoped.flowSource)
1794
+ || scoped.collaborationAccess?.runnable === false
1795
+ ) {
1796
+ json(res, 400, { error: "Cannot run workspace graph for builtin or archived pipeline" });
1797
+ return;
1798
+ }
1799
+ const wantsStream = /\bapplication\/x-ndjson\b/i.test(req.headers.accept || "") || payload.stream === true;
1800
+ const flowId = String(payload.flowId || "").trim();
1801
+ if (!flowId) {
1802
+ json(res, 400, { error: "Missing flowId" });
1803
+ return;
1804
+ }
1805
+ const canonicalStoredGraph = readWorkspaceGraph(scoped.root, root).graph;
1806
+ const canonicalGraph = hydrateWorkspaceGraphForRuntime(
1807
+ root,
1808
+ scoped,
1809
+ canonicalStoredGraph,
1810
+ userCtx,
1811
+ );
1812
+ const canonicalRevision = workspaceDesignRevision(canonicalGraph);
1813
+ const expectedRevision = String(payload.expectedRevision || payload.baseRevision || "").trim();
1814
+ if (scoped.collaboration && expectedRevision && expectedRevision !== canonicalRevision) {
1815
+ json(res, 409, {
1816
+ error: "Workspace 已更新,请刷新后再运行",
1817
+ conflict: "revision-mismatch",
1818
+ expectedRevision,
1819
+ currentRevision: canonicalRevision,
1820
+ });
1821
+ return;
1822
+ }
1823
+ const runtimeGraph = scoped.collaboration
1824
+ ? canonicalGraph
1825
+ : hydrateWorkspaceGraphForRuntime(root, scoped, payload.graph || canonicalGraph, userCtx);
1826
+ const runNodeId = String(payload.runNodeId || "").trim();
1827
+ const plan = workspaceRunPlan(runtimeGraph, runNodeId, scoped.root, {
1828
+ forceNodeIds: Array.isArray(payload.forceNodeIds) ? payload.forceNodeIds : [],
1829
+ ignoreCache: payload.ignoreCache === true,
1830
+ });
1831
+ const plannedNodeIds = workspaceRunPlanNodeIds(runNodeId, plan);
1832
+ const scopeKey = workspaceRunKey(userCtx, scoped.flowSource || payload.flowSource || "user", flowId);
1833
+ const conflict = workspaceFindActiveRunConflict(scopeKey, plannedNodeIds);
1834
+ if (conflict) {
1835
+ json(res, 409, {
1836
+ error: "该 Run 与正在执行的 Run 共享节点",
1837
+ runNodeId: conflict.entry?.runNodeId || "",
1838
+ runId: conflict.entry?.runId || "",
1839
+ conflictNodeIds: conflict.conflictNodeIds,
1840
+ });
1841
+ return;
1842
+ }
1843
+ const controller = new AbortController();
1844
+ const runControl = workspaceRunControl(controller);
1845
+ const runId = String(payload.runSessionId || payload.runId || "").trim() || runLedgerId("workspace");
1846
+ const runKey = workspaceRunEntryKey(scopeKey, runId);
1847
+ const runAlias = String(payload.runAlias || "").trim() || workspaceRuntimeNodeLabel(runtimeGraph, runNodeId, "Workspace Run");
1848
+ const runEntry = {
1849
+ scopeKey,
1850
+ controller,
1851
+ runControl,
1852
+ runId,
1853
+ userId: String(userCtx.userId || ""),
1854
+ username: String(authUser?.username || userCtx.userId || ""),
1855
+ runNodeId,
1856
+ label: runAlias,
1857
+ flowId,
1858
+ flowSource: scoped.flowSource || payload.flowSource || "user",
1859
+ plannedNodeIds,
1860
+ startedAt: Date.now(),
1861
+ };
1862
+ const runLog = createWorkspaceRunLogSession({
1863
+ runId,
1864
+ userId: runEntry.userId,
1865
+ username: runEntry.username,
1866
+ flowId: runEntry.flowId,
1867
+ flowSource: runEntry.flowSource,
1868
+ scheduleNodeId: String(runtimeGraph.instances?.[runNodeId]?.definitionId || "") === "workspace_scheduled_run" ? runNodeId : "",
1869
+ runNodeId,
1870
+ scheduled: false,
1871
+ trigger: "manual",
1872
+ label: runAlias,
1873
+ startedAt: runEntry.startedAt,
1874
+ });
1875
+ activeWorkspaceRuns.set(runKey, runEntry);
1876
+ appendWorkspaceRunStarted(runEntry);
1877
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
1878
+ type: "run.started",
1879
+ runId,
1880
+ runNodeId,
1881
+ plannedNodeIds,
1882
+ revision: canonicalRevision,
1883
+ actorId: userCtx.userId || "",
1884
+ });
1885
+ const setActiveChild = (child, childOptions = {}) => {
1886
+ runControl.setChild(child, childOptions);
1887
+ };
1888
+ const clearActiveRun = (status = "finished") => {
1889
+ runControl.finish(status);
1890
+ if (activeWorkspaceRuns.get(runKey) === runEntry) activeWorkspaceRuns.delete(runKey);
1891
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
1892
+ type: "run.finished",
1893
+ status,
1894
+ runId,
1895
+ runNodeId,
1896
+ actorId: userCtx.userId || "",
1897
+ });
1898
+ };
1899
+ if (wantsStream) {
1900
+ const runPayload = { ...payload, requestBaseUrl: requestPublicBaseUrl(req) };
1901
+ res.writeHead(200, {
1902
+ "Content-Type": "application/x-ndjson; charset=utf-8",
1903
+ "Cache-Control": "no-cache",
1904
+ "X-Accel-Buffering": "no",
1905
+ });
1906
+ const writeEvent = (event) => {
1907
+ appendWorkspaceRunLogEvent(runLog.runId, event);
1908
+ try { res.write(JSON.stringify(event) + "\n"); } catch (_) {}
1909
+ };
1910
+ try {
1911
+ const result = await runWorkspaceGraph(root, scoped.root, runPayload, userCtx, {
1912
+ onEvent: writeEvent,
1913
+ signal: controller.signal,
1914
+ onActiveChild: setActiveChild,
1915
+ });
1916
+ const currentGraph = readWorkspaceGraph(scoped.root, root).graph;
1917
+ const touchedIds = workspaceRunTouchedNodeIds(result);
1918
+ const mergedGraph = mergeWorkspaceRunGraph(currentGraph, result.graph, touchedIds);
1919
+ const committed = commitWorkspaceGraph(root, scoped, mergedGraph, userCtx);
1920
+ const { path: graphPath, revision, runtimeRevision } = committed;
1921
+ const collaborationEventType = revision === workspaceDesignRevision(currentGraph)
1922
+ ? "runtime.committed"
1923
+ : "graph.committed";
1924
+ const endedAt = Date.now();
1925
+ appendWorkspaceRunFinished({
1926
+ ...runEntry,
1927
+ endedAt,
1928
+ durationMs: endedAt - runEntry.startedAt,
1929
+ }, "success");
1930
+ finishWorkspaceRunLogSession(runLog.runId, "success", {
1931
+ endedAt,
1932
+ durationMs: endedAt - runEntry.startedAt,
1933
+ runNodeId,
1934
+ });
1935
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
1936
+ type: collaborationEventType,
1937
+ revision,
1938
+ runtimeRevision,
1939
+ actorId: userCtx.userId || "",
1940
+ source: "run",
1941
+ });
1942
+ writeEvent({ type: "done", ok: true, path: graphPath, graph: committed.graph, revision, runtimeRevision, order: result.order, touchedNodeIds: Array.from(touchedIds), pauseNodeIds: result.pauseNodeIds || [] });
1943
+ res.end();
1944
+ } catch (e) {
1945
+ const endedAt = Date.now();
1946
+ if (isWorkspaceRunAbortError(e) || controller.signal.aborted) {
1947
+ appendWorkspaceRunFinished({
1948
+ ...runEntry,
1949
+ endedAt,
1950
+ durationMs: endedAt - runEntry.startedAt,
1951
+ }, "stopped");
1952
+ finishWorkspaceRunLogSession(runLog.runId, "stopped", {
1953
+ endedAt,
1954
+ durationMs: endedAt - runEntry.startedAt,
1955
+ runNodeId,
1956
+ });
1957
+ writeEvent({ type: "stopped", ok: false, stopped: true, message: "Workspace run stopped" });
1958
+ } else {
1959
+ const error = (e && e.message) || String(e);
1960
+ appendWorkspaceRunFinished({
1961
+ ...runEntry,
1962
+ endedAt,
1963
+ durationMs: endedAt - runEntry.startedAt,
1964
+ }, "failed");
1965
+ finishWorkspaceRunLogSession(runLog.runId, "failed", {
1966
+ endedAt,
1967
+ durationMs: endedAt - runEntry.startedAt,
1968
+ runNodeId,
1969
+ error,
1970
+ });
1971
+ writeEvent({ type: "error", error });
1972
+ }
1973
+ res.end();
1974
+ } finally {
1975
+ clearActiveRun(controller.signal.aborted ? "stopped" : "finished");
1976
+ }
1977
+ return;
1978
+ }
1979
+ try {
1980
+ const result = await runWorkspaceGraph(root, scoped.root, { ...payload, requestBaseUrl: requestPublicBaseUrl(req) }, userCtx, {
1981
+ signal: controller.signal,
1982
+ onActiveChild: setActiveChild,
1983
+ onEvent: (event) => appendWorkspaceRunLogEvent(runLog.runId, event),
1984
+ });
1985
+ const currentGraph = readWorkspaceGraph(scoped.root, root).graph;
1986
+ const touchedIds = workspaceRunTouchedNodeIds(result);
1987
+ const mergedGraph = mergeWorkspaceRunGraph(currentGraph, result.graph, touchedIds);
1988
+ const committed = commitWorkspaceGraph(root, scoped, mergedGraph, userCtx);
1989
+ const { path: graphPath, revision, runtimeRevision } = committed;
1990
+ const collaborationEventType = revision === workspaceDesignRevision(currentGraph)
1991
+ ? "runtime.committed"
1992
+ : "graph.committed";
1993
+ const endedAt = Date.now();
1994
+ appendWorkspaceRunFinished({
1995
+ ...runEntry,
1996
+ endedAt,
1997
+ durationMs: endedAt - runEntry.startedAt,
1998
+ }, "success");
1999
+ finishWorkspaceRunLogSession(runLog.runId, "success", {
2000
+ endedAt,
2001
+ durationMs: endedAt - runEntry.startedAt,
2002
+ runNodeId,
2003
+ });
2004
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
2005
+ type: collaborationEventType,
2006
+ revision,
2007
+ runtimeRevision,
2008
+ actorId: userCtx.userId || "",
2009
+ source: "run",
2010
+ });
2011
+ json(res, 200, { ok: true, path: graphPath, ...result, graph: committed.graph, revision, runtimeRevision, touchedNodeIds: Array.from(touchedIds) });
2012
+ } catch (e) {
2013
+ const endedAt = Date.now();
2014
+ if (isWorkspaceRunAbortError(e) || controller.signal.aborted) {
2015
+ appendWorkspaceRunFinished({
2016
+ ...runEntry,
2017
+ endedAt,
2018
+ durationMs: endedAt - runEntry.startedAt,
2019
+ }, "stopped");
2020
+ finishWorkspaceRunLogSession(runLog.runId, "stopped", {
2021
+ endedAt,
2022
+ durationMs: endedAt - runEntry.startedAt,
2023
+ runNodeId,
2024
+ });
2025
+ json(res, 200, { ok: false, stopped: true, message: "Workspace run stopped" });
2026
+ } else {
2027
+ const error = (e && e.message) || String(e);
2028
+ appendWorkspaceRunFinished({
2029
+ ...runEntry,
2030
+ endedAt,
2031
+ durationMs: endedAt - runEntry.startedAt,
2032
+ }, "failed");
2033
+ appendWorkspaceRunLogEvent(runLog.runId, { type: "error", error, ts: endedAt });
2034
+ finishWorkspaceRunLogSession(runLog.runId, "failed", {
2035
+ endedAt,
2036
+ durationMs: endedAt - runEntry.startedAt,
2037
+ runNodeId,
2038
+ error,
2039
+ });
2040
+ throw e;
2041
+ }
2042
+ } finally {
2043
+ clearActiveRun(controller.signal.aborted ? "stopped" : "finished");
2044
+ }
2045
+ } catch (e) {
2046
+ json(res, 500, { error: (e && e.message) || String(e) });
2047
+ }
2048
+ return;
2049
+ }
2050
+
2051
+ if (req.method === "GET" && url.pathname === "/api/workspace/run-logs") {
2052
+ try {
2053
+ const flowId = url.searchParams.get("flowId") || "";
2054
+ const flowSource = url.searchParams.get("flowSource") || "";
2055
+ const scheduleNodeId = url.searchParams.get("scheduleNodeId") || "";
2056
+ const runNodeId = url.searchParams.get("runNodeId") || "";
2057
+ const limit = Number(url.searchParams.get("limit") || 50);
2058
+ const scoped = resolveWorkspaceScopeRoot(root, {
2059
+ flowId,
2060
+ flowSource: flowSource || "user",
2061
+ archived: url.searchParams.get("archived") === "1",
2062
+ }, userCtx);
2063
+ if (scoped.error) {
2064
+ json(res, scoped.status || 400, { error: scoped.error });
2065
+ return;
2066
+ }
2067
+ const scopedUserCtx = workspaceScopedUserContext(scoped, userCtx);
2068
+ json(res, 200, {
2069
+ runs: listWorkspaceRunLogs({
2070
+ userId: flowSource === "workspace" ? "" : scopedUserCtx.userId || "",
2071
+ flowId,
2072
+ flowSource,
2073
+ scheduleNodeId,
2074
+ runNodeId,
2075
+ limit,
2076
+ }),
2077
+ });
2078
+ } catch (e) {
2079
+ json(res, 500, { error: (e && e.message) || String(e) });
2080
+ }
2081
+ return;
2082
+ }
2083
+
2084
+ if (req.method === "GET" && url.pathname.startsWith("/api/workspace/run-logs/")) {
2085
+ try {
2086
+ const runId = decodeURIComponent(url.pathname.slice("/api/workspace/run-logs/".length));
2087
+ if (!runId) {
2088
+ json(res, 400, { error: "Missing runId" });
2089
+ return;
2090
+ }
2091
+ const flowId = url.searchParams.get("flowId") || "";
2092
+ const flowSource = url.searchParams.get("flowSource") || "user";
2093
+ const scoped = resolveWorkspaceScopeRoot(root, {
2094
+ flowId,
2095
+ flowSource,
2096
+ archived: url.searchParams.get("archived") === "1",
2097
+ }, userCtx);
2098
+ if (scoped.error) {
2099
+ json(res, scoped.status || 400, { error: scoped.error });
2100
+ return;
2101
+ }
2102
+ const scopedUserCtx = workspaceScopedUserContext(scoped, userCtx);
2103
+ const run = listWorkspaceRunLogs({
2104
+ userId: flowSource === "workspace" ? "" : scopedUserCtx.userId || "",
2105
+ flowId,
2106
+ flowSource,
2107
+ limit: 200,
2108
+ })
2109
+ .find((item) => String(item.runId || "") === runId);
2110
+ if (!run) {
2111
+ json(res, 404, { error: "Run log not found" });
2112
+ return;
2113
+ }
2114
+ json(res, 200, {
2115
+ run,
2116
+ events: readWorkspaceRunLogEvents(runId),
2117
+ });
2118
+ } catch (e) {
2119
+ json(res, 500, { error: (e && e.message) || String(e) });
2120
+ }
2121
+ return;
2122
+ }
2123
+
2124
+ if (req.method === "GET" && url.pathname === "/api/workspace/run/status") {
2125
+ const flowId = typeof url.searchParams.get("flowId") === "string" ? url.searchParams.get("flowId").trim() : "";
2126
+ if (!flowId) {
2127
+ json(res, 400, { error: "Missing flowId" });
2128
+ return;
2129
+ }
2130
+ const flowSource = url.searchParams.get("flowSource") || "user";
2131
+ const scoped = resolveWorkspaceScopeRoot(root, {
2132
+ flowId,
2133
+ flowSource,
2134
+ archived: url.searchParams.get("archived") === "1",
2135
+ }, userCtx);
2136
+ if (scoped.error) {
2137
+ json(res, scoped.status || 400, { error: scoped.error });
2138
+ return;
2139
+ }
2140
+ const scopeKey = workspaceRunKey(workspaceScopedUserContext(scoped, userCtx), flowSource, flowId);
2141
+ const entries = workspaceActiveRunsForScope(scopeKey).map(([, entry]) => entry);
2142
+ const entry = entries[0] || null;
2143
+ json(res, 200, {
2144
+ running: entries.length > 0,
2145
+ state: entry?.runControl?.state || (entries.length > 0 ? "running" : "idle"),
2146
+ flowId,
2147
+ flowSource,
2148
+ runNodeId: entry?.runNodeId || "",
2149
+ label: entry?.label || "",
2150
+ startedAt: entry?.startedAt || null,
2151
+ runs: entries.map((item) => ({
2152
+ runId: item?.runId || "",
2153
+ runNodeId: item?.runNodeId || "",
2154
+ label: item?.label || "",
2155
+ startedAt: item?.startedAt || null,
2156
+ plannedNodeIds: Array.isArray(item?.plannedNodeIds) ? item.plannedNodeIds : [],
2157
+ scheduled: item?.scheduled === true,
2158
+ state: item?.runControl?.state || "running",
2159
+ })),
2160
+ });
2161
+ return;
2162
+ }
2163
+
2164
+ if (req.method === "POST" && url.pathname === "/api/workspace/run/stop") {
2165
+ let payload;
2166
+ try {
2167
+ payload = JSON.parse(await readBody(req));
2168
+ } catch {
2169
+ json(res, 400, { error: "Invalid JSON body" });
2170
+ return;
2171
+ }
2172
+ const flowId = typeof payload.flowId === "string" ? payload.flowId.trim() : "";
2173
+ if (!flowId) {
2174
+ json(res, 400, { error: "Missing flowId" });
2175
+ return;
2176
+ }
2177
+ const flowSource = payload.flowSource || "user";
2178
+ const scoped = resolveWorkspaceScopeRoot(root, {
2179
+ flowId,
2180
+ flowSource,
2181
+ adminOwnerId: payload.adminOwnerId || "",
2182
+ archived: payload.archived === true || payload.flowArchived === true,
2183
+ }, userCtx);
2184
+ if (scoped.error) {
2185
+ json(res, scoped.status || 400, { error: scoped.error });
2186
+ return;
2187
+ }
2188
+ if (scoped.collaborationAccess?.runnable === false) {
2189
+ json(res, 403, { error: "Workspace collaboration run permission denied" });
2190
+ return;
2191
+ }
2192
+ const scopeKey = workspaceRunKey(userCtx, flowSource, flowId);
2193
+ const runId = String(payload.runId || payload.runSessionId || "").trim();
2194
+ const runNodeId = String(payload.runNodeId || "").trim();
2195
+ const entries = workspaceActiveRunsForScope(scopeKey);
2196
+ const match = entries.find(([, item]) => runId && String(item?.runId || "") === runId)
2197
+ || entries.find(([, item]) => runNodeId && String(item?.runNodeId || "") === runNodeId)
2198
+ || (!runId && !runNodeId && entries.length === 1 ? entries[0] : null);
2199
+ const entry = match?.[1] || null;
2200
+ if (!entry) {
2201
+ json(res, 404, { error: "该 Workspace 未在运行" });
2202
+ return;
2203
+ }
2204
+ appendWorkspaceRunLogEvent(entry.runId, {
2205
+ type: "stop-requested",
2206
+ runNodeId: entry.runNodeId || "",
2207
+ ts: Date.now(),
2208
+ });
2209
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
2210
+ type: "run.stop-requested",
2211
+ runId: entry.runId,
2212
+ runNodeId: entry.runNodeId || "",
2213
+ actorId: userCtx.userId || "",
2214
+ });
2215
+ const result = await entry.runControl.stop();
2216
+ if (!result.stopped) {
2217
+ appendWorkspaceRunLogEvent(entry.runId, {
2218
+ type: "stop-failed",
2219
+ runNodeId: entry.runNodeId || "",
2220
+ reason: result.timedOut ? "timeout" : "unknown",
2221
+ ts: Date.now(),
2222
+ });
2223
+ json(res, 409, {
2224
+ error: "停止请求已发送,但运行进程未能退出",
2225
+ ok: false,
2226
+ stopped: false,
2227
+ state: entry.runControl.state,
2228
+ });
2229
+ return;
2230
+ }
2231
+ appendWorkspaceRunLogEvent(entry.runId, {
2232
+ type: "stop-completed",
2233
+ runNodeId: entry.runNodeId || "",
2234
+ forced: result.forced === true,
2235
+ ts: Date.now(),
2236
+ });
2237
+ json(res, 200, {
2238
+ ok: true,
2239
+ stopped: true,
2240
+ forced: result.forced === true,
2241
+ });
2242
+ return;
2243
+ }
2244
+
2245
+ if (req.method === "GET" && url.pathname === "/api/workspace/file") {
2246
+ try {
2247
+ const scoped = resolveWorkspaceScopeRoot(root, {
2248
+ flowId: url.searchParams.get("flowId") || "",
2249
+ flowSource: url.searchParams.get("flowSource") || "user",
2250
+ archived: url.searchParams.get("archived") === "1",
2251
+ }, userCtx);
2252
+ if (scoped.error) {
2253
+ json(res, 400, { error: scoped.error });
2254
+ return;
2255
+ }
2256
+ const { abs, rel } = resolveWorkspaceFilePath(scoped.root, url.searchParams.get("path") || "");
2257
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
2258
+ json(res, 404, { error: "File not found" });
2259
+ return;
2260
+ }
2261
+ const stat = fs.statSync(abs);
2262
+ if (stat.size > 2 * 1024 * 1024) {
2263
+ json(res, 413, { error: "File too large" });
2264
+ return;
2265
+ }
2266
+ const content = fs.readFileSync(abs, "utf-8");
2267
+ json(res, 200, {
2268
+ path: rel,
2269
+ content,
2270
+ size: stat.size,
2271
+ revision: crypto.createHash("sha256").update(content).digest("hex"),
2272
+ });
2273
+ } catch (e) {
2274
+ json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
2275
+ }
2276
+ return;
2277
+ }
2278
+
2279
+ if (req.method === "GET" && url.pathname === "/api/workspace/file/raw") {
2280
+ try {
2281
+ const scoped = resolveWorkspaceScopeRoot(root, {
2282
+ flowId: url.searchParams.get("flowId") || "",
2283
+ flowSource: url.searchParams.get("flowSource") || "user",
2284
+ archived: url.searchParams.get("archived") === "1",
2285
+ }, userCtx);
2286
+ if (scoped.error) {
2287
+ json(res, 400, { error: scoped.error });
2288
+ return;
2289
+ }
2290
+ const { abs, rel } = resolveWorkspaceFilePath(scoped.root, url.searchParams.get("path") || "");
2291
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
2292
+ json(res, 404, { error: "File not found" });
2293
+ return;
2294
+ }
2295
+ const ext = path.extname(abs).toLowerCase();
2296
+ const type = MIME[ext] || "application/octet-stream";
2297
+ const data = fs.readFileSync(abs);
2298
+ const headers = {
2299
+ "Content-Type": type,
2300
+ "Content-Length": data.length,
2301
+ "Cache-Control": "no-store",
2302
+ };
2303
+ if (url.searchParams.get("download") === "1") {
2304
+ headers["Content-Disposition"] = workspaceDownloadContentDisposition(rel);
2305
+ }
2306
+ res.writeHead(200, headers);
2307
+ res.end(data);
2308
+ } catch (e) {
2309
+ json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
2310
+ }
2311
+ return;
2312
+ }
2313
+
2314
+ if (req.method === "POST" && url.pathname === "/api/workspace/html-screenshot") {
2315
+ let payload;
2316
+ try {
2317
+ payload = JSON.parse(await readBody(req));
2318
+ } catch {
2319
+ json(res, 400, { error: "Invalid JSON body" });
2320
+ return;
2321
+ }
2322
+ try {
2323
+ const scoped = resolveWorkspaceScopeRoot(root, {
2324
+ flowId: payload.flowId || "",
2325
+ flowSource: payload.flowSource || "user",
2326
+ adminOwnerId: payload.adminOwnerId || "",
2327
+ archived: payload.archived === true || payload.flowArchived === true,
2328
+ }, userCtx);
2329
+ if (scoped.error) {
2330
+ json(res, 400, { error: scoped.error });
2331
+ return;
2332
+ }
2333
+ const sourceFilePath = String(payload.sourceFilePath || payload.path || "").trim();
2334
+ let html = String(payload.content || "");
2335
+ let baseDir = scoped.root;
2336
+ if (sourceFilePath) {
2337
+ const { abs } = resolveWorkspaceFilePath(scoped.root, sourceFilePath);
2338
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
2339
+ json(res, 404, { error: "HTML file not found" });
2340
+ return;
2341
+ }
2342
+ const stat = fs.statSync(abs);
2343
+ if (stat.size > 5 * 1024 * 1024) {
2344
+ json(res, 413, { error: "HTML file too large" });
2345
+ return;
2346
+ }
2347
+ if (!html.trim()) html = fs.readFileSync(abs, "utf-8");
2348
+ baseDir = path.dirname(abs);
2349
+ }
2350
+ if (!html.trim()) {
2351
+ json(res, 400, { error: "Missing HTML content" });
2352
+ return;
2353
+ }
2354
+ const screenshot = await renderHtmlScreenshotWithChrome({
2355
+ html,
2356
+ workspaceRoot: scoped.root,
2357
+ baseDir,
2358
+ width: payload.width,
2359
+ height: payload.height,
2360
+ });
2361
+ const png = screenshot.png;
2362
+ const filename = sanitizeWorkspaceUploadName(payload.filename || "html-render.png").replace(/\.[^.]+$/i, ".png");
2363
+ res.writeHead(200, {
2364
+ "Content-Type": "image/png",
2365
+ "Content-Length": png.length,
2366
+ "Cache-Control": "no-store",
2367
+ "Content-Disposition": workspaceDownloadContentDisposition(filename),
2368
+ });
2369
+ res.end(png);
2370
+ } catch (e) {
2371
+ json(res, 500, { error: (e && e.message) || String(e) });
2372
+ }
2373
+ return;
2374
+ }
2375
+
2376
+ if (req.method === "POST" && url.pathname === "/api/workspace/file") {
2377
+ let payload;
2378
+ try {
2379
+ payload = JSON.parse(await readBody(req));
2380
+ } catch {
2381
+ json(res, 400, { error: "Invalid JSON body" });
2382
+ return;
2383
+ }
2384
+ try {
2385
+ const scoped = resolveWorkspaceScopeRoot(root, {
2386
+ flowId: payload.flowId || "",
2387
+ flowSource: payload.flowSource || "user",
2388
+ adminOwnerId: payload.adminOwnerId || "",
2389
+ archived: payload.archived === true || payload.flowArchived === true,
2390
+ }, userCtx);
2391
+ if (scoped.error) {
2392
+ json(res, 400, { error: scoped.error });
2393
+ return;
2394
+ }
2395
+ if (
2396
+ scoped.archived
2397
+ || isReadonlyBuiltinFlowSource(scoped.flowSource)
2398
+ || scoped.collaborationAccess?.writable === false
2399
+ ) {
2400
+ json(res, 400, { error: "Cannot write to builtin or archived pipeline workspace" });
2401
+ return;
2402
+ }
2403
+ const { abs, rel } = resolveWorkspaceFilePath(scoped.root, payload.path || "");
2404
+ if (!rel) {
2405
+ json(res, 400, { error: "Missing path" });
2406
+ return;
2407
+ }
2408
+ const content = String(payload.content ?? "");
2409
+ const baseRevision = String(payload.baseRevision || "").trim();
2410
+ if (baseRevision && fs.existsSync(abs) && fs.statSync(abs).isFile()) {
2411
+ const currentContent = fs.readFileSync(abs, "utf-8");
2412
+ const currentRevision = crypto.createHash("sha256").update(currentContent).digest("hex");
2413
+ if (currentRevision !== baseRevision) {
2414
+ json(res, 409, {
2415
+ error: "文件已被其他成员更新,请处理冲突后重试",
2416
+ conflict: "revision-mismatch",
2417
+ currentRevision,
2418
+ });
2419
+ return;
2420
+ }
2421
+ }
2422
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
2423
+ const tmp = `${abs}.${process.pid}.${Date.now()}.tmp`;
2424
+ fs.writeFileSync(tmp, content, "utf-8");
2425
+ fs.renameSync(tmp, abs);
2426
+ const revision = crypto.createHash("sha256").update(content).digest("hex");
2427
+ broadcastWorkspaceCollaborationEvent(
2428
+ userCtx,
2429
+ scoped.flowSource,
2430
+ scoped.flowId,
2431
+ scoped.archived,
2432
+ {
2433
+ type: "file.committed",
2434
+ path: rel,
2435
+ revision,
2436
+ actorId: userCtx.userId || "",
2437
+ clientId: String(payload.clientId || ""),
2438
+ },
2439
+ );
2440
+ json(res, 200, { ok: true, path: rel, revision });
2441
+ } catch (e) {
2442
+ json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
2443
+ }
2444
+ return;
2445
+ }
2446
+
2447
+ if (req.method === "POST" && url.pathname === "/api/workspace/upload") {
2448
+ let parsed;
2449
+ try {
2450
+ parsed = await parseWorkspaceUploadForm(req);
2451
+ } catch (e) {
2452
+ json(res, /FILE_TOO_LARGE/.test(String(e.message || e)) ? 413 : 400, { error: (e && e.message) || String(e) });
2453
+ return;
2454
+ }
2455
+ try {
2456
+ if (!parsed.gotFile || !parsed.file.length) {
2457
+ json(res, 400, { error: "Missing upload file" });
2458
+ return;
2459
+ }
2460
+ const scoped = resolveWorkspaceScopeRoot(root, {
2461
+ flowId: parsed.fields.flowId || "",
2462
+ flowSource: parsed.fields.flowSource || "user",
2463
+ adminOwnerId: parsed.fields.adminOwnerId || "",
2464
+ archived: parsed.fields.archived === "1" || parsed.fields.archived === "true" || parsed.fields.flowArchived === "true",
2465
+ }, userCtx);
2466
+ if (scoped.error) {
2467
+ json(res, 400, { error: scoped.error });
2468
+ return;
2469
+ }
2470
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
2471
+ json(res, 400, { error: "Cannot write to builtin or archived pipeline workspace" });
2472
+ return;
2473
+ }
2474
+ const safeName = sanitizeWorkspaceUploadName(parsed.filename);
2475
+ const targetDir = String(parsed.fields.dir ?? "").trim().replace(/^[/\\]+/, "").replace(/\\/g, "/");
2476
+ const targetRel = targetDir ? path.posix.join(targetDir, safeName) : safeName;
2477
+ const target = uniqueWorkspaceRelPath(scoped.root, targetRel);
2478
+ fs.mkdirSync(path.dirname(target.abs), { recursive: true });
2479
+ fs.writeFileSync(target.abs, parsed.file);
2480
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
2481
+ type: "file.committed",
2482
+ path: target.rel,
2483
+ actorId: userCtx.userId || "",
2484
+ });
2485
+ json(res, 200, {
2486
+ ok: true,
2487
+ path: target.rel,
2488
+ size: parsed.file.length,
2489
+ mimeType: parsed.mimeType,
2490
+ });
2491
+ } catch (e) {
2492
+ json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
2493
+ }
2494
+ return;
2495
+ }
2496
+
2497
+ if (req.method === "POST" && url.pathname === "/api/workspace/folder") {
2498
+ let payload;
2499
+ try {
2500
+ payload = JSON.parse(await readBody(req));
2501
+ } catch {
2502
+ json(res, 400, { error: "Invalid JSON body" });
2503
+ return;
2504
+ }
2505
+ try {
2506
+ const scoped = resolveWorkspaceScopeRoot(root, {
2507
+ flowId: payload.flowId || "",
2508
+ flowSource: payload.flowSource || "user",
2509
+ adminOwnerId: payload.adminOwnerId || "",
2510
+ archived: payload.archived === true || payload.flowArchived === true,
2511
+ }, userCtx);
2512
+ if (scoped.error) {
2513
+ json(res, 400, { error: scoped.error });
2514
+ return;
2515
+ }
2516
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
2517
+ json(res, 400, { error: "Cannot write to builtin or archived pipeline workspace" });
2518
+ return;
2519
+ }
2520
+ const { abs, rel } = resolveWorkspaceFilePath(scoped.root, payload.path || "");
2521
+ if (!rel) {
2522
+ json(res, 400, { error: "Missing path" });
2523
+ return;
2524
+ }
2525
+ fs.mkdirSync(abs, { recursive: true });
2526
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
2527
+ type: "file.tree-changed",
2528
+ path: rel,
2529
+ actorId: userCtx.userId || "",
2530
+ });
2531
+ json(res, 200, { ok: true, path: rel });
2532
+ } catch (e) {
2533
+ json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
2534
+ }
2535
+ return;
2536
+ }
2537
+
2538
+ if (req.method === "POST" && url.pathname === "/api/workspace/delete") {
2539
+ let payload;
2540
+ try {
2541
+ payload = JSON.parse(await readBody(req));
2542
+ } catch {
2543
+ json(res, 400, { error: "Invalid JSON body" });
2544
+ return;
2545
+ }
2546
+ try {
2547
+ const scoped = resolveWorkspaceScopeRoot(root, {
2548
+ flowId: payload.flowId || "",
2549
+ flowSource: payload.flowSource || "user",
2550
+ adminOwnerId: payload.adminOwnerId || "",
2551
+ archived: payload.archived === true || payload.flowArchived === true,
2552
+ }, userCtx);
2553
+ if (scoped.error) {
2554
+ json(res, 400, { error: scoped.error });
2555
+ return;
2556
+ }
2557
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
2558
+ json(res, 400, { error: "Cannot write to builtin or archived pipeline workspace" });
2559
+ return;
2560
+ }
2561
+ const { abs, rel } = resolveWorkspaceFilePath(scoped.root, payload.path || "");
2562
+ if (!rel) {
2563
+ json(res, 400, { error: "Missing path" });
2564
+ return;
2565
+ }
2566
+ if (!fs.existsSync(abs)) {
2567
+ json(res, 404, { error: "Path not found" });
2568
+ return;
2569
+ }
2570
+ fs.rmSync(abs, { recursive: true, force: true });
2571
+ broadcastWorkspaceCollaborationEvent(userCtx, scoped.flowSource, scoped.flowId, scoped.archived, {
2572
+ type: "file.tree-changed",
2573
+ path: rel,
2574
+ deleted: true,
2575
+ actorId: userCtx.userId || "",
2576
+ });
2577
+ json(res, 200, { ok: true, path: rel });
2578
+ } catch (e) {
2579
+ json(res, /traversal/i.test(String(e.message || e)) ? 403 : 500, { error: (e && e.message) || String(e) });
2580
+ }
2581
+ return;
2582
+ }
2583
+
2584
+ if (url.pathname === "/api/workspace/conversations") {
2585
+ let payload = {};
2586
+ if (req.method === "POST") {
2587
+ try {
2588
+ payload = JSON.parse(await readBody(req));
2589
+ } catch {
2590
+ json(res, 400, { error: "Invalid JSON body" });
2591
+ return;
2592
+ }
2593
+ } else if (req.method !== "GET") {
2594
+ json(res, 405, { error: "Method not allowed" });
2595
+ return;
2596
+ }
2597
+ try {
2598
+ const scoped = resolveWorkspaceScopeRoot(root, {
2599
+ flowId: req.method === "POST" ? (payload.flowId || "") : (url.searchParams.get("flowId") || ""),
2600
+ flowSource: req.method === "POST" ? (payload.flowSource || "user") : (url.searchParams.get("flowSource") || "user"),
2601
+ adminOwnerId: req.method === "POST" ? (payload.adminOwnerId || "") : "",
2602
+ archived: req.method === "POST"
2603
+ ? (payload.archived === true || payload.flowArchived === true)
2604
+ : (url.searchParams.get("archived") === "1" || url.searchParams.get("flowArchived") === "1"),
2605
+ }, userCtx);
2606
+ if (scoped.error) {
2607
+ json(res, 400, { error: scoped.error });
2608
+ return;
2609
+ }
2610
+ if (req.method === "GET") {
2611
+ json(res, 200, { ok: true, conversations: readWorkspaceConversations(scoped.root) });
2612
+ return;
2613
+ }
2614
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
2615
+ json(res, 400, { error: "Cannot write conversations for builtin or archived pipeline workspace" });
2616
+ return;
2617
+ }
2618
+ const conversations = writeWorkspaceConversations(scoped.root, payload.conversations || payload);
2619
+ json(res, 200, { ok: true, conversations });
2620
+ } catch (e) {
2621
+ json(res, 500, { error: (e && e.message) || String(e) });
2622
+ }
2623
+ return;
2624
+ }
2625
+
2626
+ if (req.method === "POST" && url.pathname === "/api/workspace/generate") {
2627
+ let payload;
2628
+ try {
2629
+ payload = JSON.parse(await readBody(req));
2630
+ } catch {
2631
+ json(res, 400, { error: "Invalid JSON body" });
2632
+ return;
2633
+ }
2634
+ const prompt = String(payload?.prompt || "").trim();
2635
+ if (!prompt) {
2636
+ json(res, 400, { error: "Missing prompt" });
2637
+ return;
2638
+ }
2639
+ try {
2640
+ const scoped = resolveWorkspaceScopeRoot(root, {
2641
+ flowId: payload.flowId || "",
2642
+ flowSource: payload.flowSource || "user",
2643
+ adminOwnerId: payload.adminOwnerId || "",
2644
+ archived: payload.archived === true || payload.flowArchived === true,
2645
+ }, userCtx);
2646
+ if (scoped.error) {
2647
+ json(res, 400, { error: scoped.error });
2648
+ return;
2649
+ }
2650
+ if (scoped.collaborationAccess?.writable === false) {
2651
+ json(res, 403, { error: "Workspace collaboration edit permission denied" });
2652
+ return;
2653
+ }
2654
+ const selectedSkillKeys = Array.isArray(payload?.selectedSkills)
2655
+ ? payload.selectedSkills.map((x) => String(x || "").trim()).filter(Boolean)
2656
+ : [];
2657
+ const selectedSkillResources = selectedSkillKeys.length > 0
2658
+ ? loadResourcesForSkillKeys(selectedSkillKeys, PACKAGE_ROOT, scoped.root)
2659
+ : { skills: [], references: [] };
2660
+ const skillsBlock = selectedSkillKeys.length > 0
2661
+ ? buildSkillCompactInjectionBlock(selectedSkillResources.skills, selectedSkillResources.references)
2662
+ : "";
2663
+ let content = "";
2664
+ const events = [];
2665
+ const maxAttempts = 3;
2666
+ const nodeCatalogBlock = workspaceNodePackageCatalogBlock(root, scoped, userCtx);
2667
+ const promptText = buildWorkspaceGeneratePrompt({ ...payload, skillsBlock, nodeCatalogBlock });
2668
+ const modelKey = typeof payload?.model === "string" ? payload.model.trim() : "";
2669
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
2670
+ let attemptResult = "";
2671
+ const assistantSegments = [];
2672
+ try {
2673
+ if (attempt > 1) {
2674
+ events.push({
2675
+ type: "status",
2676
+ line: `Workspace agent retry ${attempt}/${maxAttempts} after transient network failure...`,
2677
+ });
2678
+ await sleepMs(Math.min(1500 * attempt, 5000));
2679
+ }
2680
+ const handle = startComposerAgent({
2681
+ uiWorkspaceRoot: scoped.root,
2682
+ cliWorkspace: scoped.root,
2683
+ prompt: promptText,
2684
+ modelKey,
2685
+ agentflowUserId: userCtx.userId || "",
2686
+ onStreamEvent: (ev) => {
2687
+ events.push(ev);
2688
+ if (ev?.type === "natural" && ev.kind === "assistant" && typeof ev.text === "string") {
2689
+ const text = ev.text.trim();
2690
+ if (text) assistantSegments.push(text);
2691
+ } else if (ev?.type === "natural" && ev.kind === "result" && typeof ev.text === "string") {
2692
+ const text = ev.text.trim();
2693
+ if (text) attemptResult = text;
2694
+ }
2695
+ },
2696
+ });
2697
+ await handle.finished;
2698
+ content = attemptResult || assistantSegments.at(-1) || "";
2699
+ break;
2700
+ } catch (e) {
2701
+ if (attempt < maxAttempts && isTransientAgentNetworkError(e)) {
2702
+ events.push({
2703
+ type: "status",
2704
+ line: `Workspace agent transient network error: ${String(e.message || e).slice(0, 220)}`,
2705
+ });
2706
+ continue;
2707
+ }
2708
+ throw e;
2709
+ }
2710
+ }
2711
+ json(res, 200, { ok: true, content: content.trim(), events });
2712
+ } catch (e) {
2713
+ json(res, 500, { error: (e && e.message) || String(e) });
2714
+ }
2715
+ return;
2716
+ }
2717
+
2718
+ if (req.method === "POST" && url.pathname === "/api/workspace/node-chat") {
2719
+ let payload;
2720
+ try {
2721
+ payload = JSON.parse(await readBody(req));
2722
+ } catch {
2723
+ json(res, 400, { error: "Invalid JSON body" });
2724
+ return;
2725
+ }
2726
+ const message = String(payload?.message || "").trim();
2727
+ if (!message) {
2728
+ json(res, 400, { error: "Missing message" });
2729
+ return;
2730
+ }
2731
+ try {
2732
+ const scoped = resolveWorkspaceScopeRoot(root, {
2733
+ flowId: payload.flowId || "",
2734
+ flowSource: payload.flowSource || "user",
2735
+ adminOwnerId: payload.adminOwnerId || "",
2736
+ archived: payload.archived === true || payload.flowArchived === true,
2737
+ }, userCtx);
2738
+ if (scoped.error) {
2739
+ json(res, 400, { error: scoped.error });
2740
+ return;
2741
+ }
2742
+ if (scoped.collaborationAccess?.writable === false) {
2743
+ json(res, 403, { error: "Workspace collaboration edit permission denied" });
2744
+ return;
2745
+ }
2746
+ const targetFilePath = String(payload?.targetFilePath || "").trim();
2747
+ let targetFile = null;
2748
+ if (targetFilePath) {
2749
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource) || scoped.collaborationAccess?.writable === false) {
2750
+ json(res, 400, { error: "Cannot edit builtin or archived pipeline workspace" });
2751
+ return;
2752
+ }
2753
+ targetFile = resolveWorkspaceFilePath(scoped.root, targetFilePath);
2754
+ if (!targetFile.rel) {
2755
+ json(res, 400, { error: "Missing artifact file path" });
2756
+ return;
2757
+ }
2758
+ }
2759
+ const beforeTargetContent = targetFile && fs.existsSync(targetFile.abs) && fs.statSync(targetFile.abs).isFile()
2760
+ ? fs.readFileSync(targetFile.abs, "utf-8")
2761
+ : null;
2762
+ const promptText = buildWorkspaceNodeChatPrompt(payload);
2763
+ const modelKey = typeof payload?.model === "string" ? payload.model.trim() : "";
2764
+ let content = "";
2765
+ const events = [];
2766
+ const handle = startComposerAgent({
2767
+ uiWorkspaceRoot: scoped.root,
2768
+ cliWorkspace: scoped.root,
2769
+ prompt: promptText,
2770
+ modelKey,
2771
+ agentflowUserId: userCtx.userId || "",
2772
+ onStreamEvent: (ev) => {
2773
+ events.push(ev);
2774
+ if (ev?.type === "natural" && ev.kind === "assistant" && typeof ev.text === "string") {
2775
+ content += (content ? "\n" : "") + ev.text;
2776
+ }
2777
+ },
2778
+ });
2779
+ await handle.finished;
2780
+ let candidateContent = targetFile
2781
+ ? (fs.existsSync(targetFile.abs) && fs.statSync(targetFile.abs).isFile()
2782
+ ? fs.readFileSync(targetFile.abs, "utf-8")
2783
+ : "")
2784
+ : content.trim();
2785
+ if (targetFile) {
2786
+ const unwrappedTargetContent = workspaceUnwrapOutputEnvelopeForDisplay(candidateContent);
2787
+ if (unwrappedTargetContent && unwrappedTargetContent !== candidateContent) {
2788
+ fs.writeFileSync(targetFile.abs, unwrappedTargetContent, "utf-8");
2789
+ candidateContent = unwrappedTargetContent;
2790
+ }
2791
+ }
2792
+ if (targetFile && beforeTargetContent != null && candidateContent === beforeTargetContent) {
2793
+ json(res, 500, { error: "Agent 未修改目标展示文件,请换一种更明确的描述后重试。" });
2794
+ return;
2795
+ }
2796
+ json(res, 200, {
2797
+ ok: true,
2798
+ sessionId: String(payload?.sessionId || "") || `nodechat_${Date.now()}`,
2799
+ reply: targetFile ? content.trim() : candidateContent,
2800
+ candidateContent,
2801
+ directFileEdit: Boolean(targetFile),
2802
+ artifactPath: targetFile?.rel || "",
2803
+ events,
2804
+ });
2805
+ } catch (e) {
2806
+ json(res, 500, { error: (e && e.message) || String(e) });
2807
+ }
2808
+ return;
2809
+ }
2810
+
2811
+ if (req.method === "GET" && url.pathname === "/api/nodes") {
2812
+ const flowId = url.searchParams.get("flowId");
2813
+ const flowSource = url.searchParams.get("flowSource") || "user";
2814
+ const lang = url.searchParams.get("lang") || "en";
2815
+ const marketplaceScope = url.searchParams.get("scope") === "owned" ? "owned" : "all";
2816
+ if (flowId && !isValidFlowSourceRead(flowSource)) {
2817
+ json(res, 400, { error: "Invalid flowSource" });
2818
+ return;
2819
+ }
2820
+ const nodesArchived = url.searchParams.get("archived") === "1";
2821
+ try {
2822
+ const requestedContext = adminWorkspaceRequestedUserContext(userCtx);
2823
+ if (requestedContext.error) {
2824
+ json(res, requestedContext.status || 403, { error: requestedContext.error });
2825
+ return;
2826
+ }
2827
+ const { setLanguage } = await import("./i18n.mjs");
2828
+ setLanguage(lang);
2829
+ json(res, 200, listNodesJson(root, flowId || "", flowId ? flowSource : "", {
2830
+ archived: nodesArchived,
2831
+ ...requestedContext.userCtx,
2832
+ marketplaceScope,
2833
+ }));
2834
+ } catch (e) {
2835
+ json(res, 500, { error: (e && e.message) || String(e) });
2836
+ }
2837
+ return;
2838
+ }
2839
+
2840
+ if (req.method === "GET" && url.pathname === "/api/nodes/detail") {
2841
+ const nodeId = url.searchParams.get("id") || "";
2842
+ const flowId = url.searchParams.get("flowId") || "";
2843
+ const flowSource = url.searchParams.get("flowSource") || "";
2844
+ if (!nodeId) {
2845
+ json(res, 400, { error: "Missing node id" });
2846
+ return;
2847
+ }
2848
+ if (flowId && !isValidFlowSourceRead(flowSource || "user")) {
2849
+ json(res, 400, { error: "Invalid flowSource" });
2850
+ return;
2851
+ }
2852
+ const archived = url.searchParams.get("archived") === "1";
2853
+ try {
2854
+ const requestedContext = adminWorkspaceRequestedUserContext(userCtx);
2855
+ if (requestedContext.error) {
2856
+ json(res, requestedContext.status || 403, { error: requestedContext.error });
2857
+ return;
2858
+ }
2859
+ const detail = readNodeDetailJson(root, nodeId, flowId, flowId ? (flowSource || "user") : "", {
2860
+ archived,
2861
+ ...requestedContext.userCtx,
2862
+ });
2863
+ if (detail.error) {
2864
+ json(res, 404, { error: detail.error });
2865
+ return;
2866
+ }
2867
+ json(res, 200, detail);
2868
+ } catch (e) {
2869
+ json(res, 500, { error: (e && e.message) || String(e) });
2870
+ }
2871
+ return;
2872
+ }
2873
+
2874
+ if (req.method === "GET" && url.pathname === "/api/nodes/file") {
2875
+ const nodeId = url.searchParams.get("id") || "";
2876
+ const relPath = url.searchParams.get("path") || "";
2877
+ const flowId = url.searchParams.get("flowId") || "";
2878
+ const flowSource = url.searchParams.get("flowSource") || "";
2879
+ if (!nodeId || !relPath) {
2880
+ json(res, 400, { error: "Missing node id or path" });
2881
+ return;
2882
+ }
2883
+ if (flowId && !isValidFlowSourceRead(flowSource || "user")) {
2884
+ json(res, 400, { error: "Invalid flowSource" });
2885
+ return;
2886
+ }
2887
+ const archived = url.searchParams.get("archived") === "1";
2888
+ try {
2889
+ const file = readNodeFilePreview(root, nodeId, relPath, flowId, flowId ? (flowSource || "user") : "", { archived, ...userCtx });
2890
+ if (file.error) {
2891
+ json(res, 404, { error: file.error });
2892
+ return;
2893
+ }
2894
+ json(res, 200, file);
2895
+ } catch (e) {
2896
+ json(res, 500, { error: (e && e.message) || String(e) });
2897
+ }
2898
+ return;
2899
+ }
2900
+
2901
+ if (req.method === "GET" && url.pathname === "/api/node-studio/drafts") {
2902
+ try {
2903
+ json(res, 200, { drafts: listNodeStudioDrafts(userCtx) });
2904
+ } catch (e) {
2905
+ json(res, 500, { error: (e && e.message) || String(e) });
2906
+ }
2907
+ return;
2908
+ }
2909
+
2910
+ if (req.method === "GET" && url.pathname === "/api/node-studio/draft") {
2911
+ try {
2912
+ const id = url.searchParams.get("id") || "";
2913
+ if (!id) {
2914
+ json(res, 200, { draft: null });
2915
+ return;
2916
+ }
2917
+ const draft = readNodeStudioDraft(userCtx, id);
2918
+ json(res, 200, { draft: draft && !isLegacyNodeStudioDemoDraft(draft) ? draft : null });
2919
+ } catch (e) {
2920
+ json(res, 500, { error: (e && e.message) || String(e) });
2921
+ }
2922
+ return;
2923
+ }
2924
+
2925
+ if (req.method === "POST" && url.pathname === "/api/node-studio/draft") {
2926
+ let payload;
2927
+ try {
2928
+ payload = JSON.parse(await readBody(req));
2929
+ } catch {
2930
+ json(res, 400, { error: "Invalid JSON body" });
2931
+ return;
2932
+ }
2933
+ try {
2934
+ const draftId = normalizeNodeStudioDraftId(payload.id || "untitled_node");
2935
+ const current = readNodeStudioDraft(userCtx, draftId) || emptyNodeStudioDraft(userCtx, draftId);
2936
+ const promptDraft = payload.promptDraft != null ? String(payload.promptDraft) : current.promptDraft || "";
2937
+ const agentMessages = Array.isArray(current.agentMessages) ? [...current.agentMessages] : [];
2938
+ const generate = payload.appendUserMessage === true && promptDraft.trim();
2939
+
2940
+ if (generate) {
2941
+ const at = new Date().toISOString();
2942
+ agentMessages.push({ role: "user", text: promptDraft.trim(), at });
2943
+ const packageDir = nodeStudioPackageDir(userCtx, draftId);
2944
+ fs.mkdirSync(packageDir, { recursive: true });
2945
+ const before = nodeStudioReadPackage(userCtx, draftId);
2946
+ const reply = await runNodeStudioAgent({
2947
+ packageDir,
2948
+ userCtx,
2949
+ modelKey: typeof payload.model === "string" ? payload.model.trim() : "",
2950
+ prompt: buildNodeStudioPrompt({
2951
+ requirement: promptDraft,
2952
+ currentSource: before.source,
2953
+ parseError: before.error,
2954
+ history: current.agentMessages,
2955
+ }),
2956
+ });
2957
+ const after = nodeStudioReadPackage(userCtx, draftId);
2958
+ agentMessages.push({
2959
+ role: "assistant",
2960
+ at: new Date().toISOString(),
2961
+ text: after.error
2962
+ ? `${reply || "已改写 index.mjs"}\n\n⚠️ 解析失败:${after.error}`
2963
+ : (reply || `已写出 ${after.manifest?.id}@${after.manifest?.version}`),
2964
+ error: Boolean(after.error),
2965
+ });
2966
+ const draft = writeNodeStudioDraft(userCtx, {
2967
+ ...current,
2968
+ ...nodeStudioDraftFromPackage(after, draftId),
2969
+ promptDraft: "",
2970
+ agentMessages,
2971
+ test: { inputs: current?.test?.inputs || {}, log: [], status: "not run", packageDigest: "" },
2972
+ });
2973
+ json(res, 200, { ok: true, draft });
2974
+ return;
2975
+ }
2976
+
2977
+ const draft = writeNodeStudioDraft(userCtx, {
2978
+ ...current,
2979
+ ...(payload.config && typeof payload.config === "object" ? { config: { ...(current.config || {}), ...payload.config } } : {}),
2980
+ promptDraft,
2981
+ agentMessages,
2982
+ });
2983
+ json(res, 200, { ok: true, draft });
2984
+ } catch (e) {
2985
+ json(res, 500, { error: (e && e.message) || String(e) });
2986
+ }
2987
+ return;
2988
+ }
2989
+
2990
+ if (req.method === "POST" && url.pathname === "/api/node-studio/publish") {
2991
+ let payload;
2992
+ try {
2993
+ payload = JSON.parse(await readBody(req));
2994
+ } catch {
2995
+ json(res, 400, { error: "Invalid JSON body" });
2996
+ return;
2997
+ }
2998
+ try {
2999
+ const draftId = normalizeNodeStudioDraftId(payload.id || "");
3000
+ if (!readNodeStudioDraft(userCtx, draftId)) {
3001
+ json(res, 404, { error: "草稿不存在" });
3002
+ return;
3003
+ }
3004
+ // 发布前必须解析得过。发布一个读不出声明的包,等于往 marketplace 里放一个在面板上
3005
+ // 根本不出现的条目——问题会在别人安装它的时候才暴露。
3006
+ const pkg = nodeStudioReadPackage(userCtx, draftId);
3007
+ if (!pkg.source) {
3008
+ json(res, 400, { error: `还没有 ${NODE_PACKAGE_ENTRY},先让 Agent 生成` });
3009
+ return;
3010
+ }
3011
+ if (pkg.error) {
3012
+ json(res, 400, { error: pkg.error });
3013
+ return;
3014
+ }
3015
+ const current = readNodeStudioDraft(userCtx, draftId);
3016
+ if (current?.test?.status !== "passed" || !pkg.packageDigest || current?.test?.packageDigest !== pkg.packageDigest) {
3017
+ json(res, 400, { error: "发布前必须对当前节点包运行并通过 Test" });
3018
+ return;
3019
+ }
3020
+ const result = publishNodePackage(root, nodeStudioPackageDir(userCtx, draftId), {
3021
+ immutable: true,
3022
+ ownerUserId: userCtx.userId,
3023
+ });
3024
+ if (!result.ok) {
3025
+ json(res, result.conflict ? 409 : 400, { error: result.error || "发布失败" });
3026
+ return;
3027
+ }
3028
+ json(res, 200, { ok: true, ...result });
3029
+ } catch (e) {
3030
+ json(res, 500, { error: (e && e.message) || String(e) });
3031
+ }
3032
+ return;
3033
+ }
3034
+
3035
+ if (req.method === "POST" && url.pathname === "/api/node-studio/test") {
3036
+ let payload;
3037
+ try {
3038
+ payload = JSON.parse(await readBody(req));
3039
+ } catch {
3040
+ json(res, 400, { error: "Invalid JSON body" });
3041
+ return;
3042
+ }
3043
+ try {
3044
+ const draftId = normalizeNodeStudioDraftId(payload.id || "");
3045
+ const current = readNodeStudioDraft(userCtx, draftId);
3046
+ if (!current) {
3047
+ json(res, 404, { error: "草稿不存在" });
3048
+ return;
3049
+ }
3050
+ const pkg = nodeStudioReadPackage(userCtx, draftId);
3051
+ if (pkg.error || !pkg.manifest) {
3052
+ json(res, 400, { error: pkg.error || `还没有 ${NODE_PACKAGE_ENTRY}` });
3053
+ return;
3054
+ }
3055
+ const inputs = payload.inputs && typeof payload.inputs === "object" ? payload.inputs : {};
3056
+ const result = await runNodeStudioPackageTest({
3057
+ packageDir: nodeStudioPackageDir(userCtx, draftId),
3058
+ manifest: pkg.manifest,
3059
+ inputs,
3060
+ userCtx,
3061
+ });
3062
+ const draft = writeNodeStudioDraft(userCtx, {
3063
+ ...current,
3064
+ ...nodeStudioDraftFromPackage(pkg, draftId),
3065
+ test: {
3066
+ inputs,
3067
+ log: result.log,
3068
+ status: result.status,
3069
+ durationMs: result.durationMs,
3070
+ packageDigest: pkg.packageDigest || "",
3071
+ },
3072
+ });
3073
+ json(res, 200, { ok: true, draft, ...result });
3074
+ } catch (e) {
3075
+ json(res, 500, { error: (e && e.message) || String(e) });
3076
+ }
3077
+ return;
3078
+ }
3079
+
3080
+ }
3081
+
3082
+ /**
3083
+ * @returns {Promise<boolean>} 是否已经由 PRD workflow 路由处理掉
3084
+ */
3085
+ export async function handleWorkspaceRoutes(req, res, ctx) {
3086
+ await workspaceRoutes(req, res, ctx);
3087
+ return res.headersSent;
3088
+ }