@fieldwangai/agentflow 0.1.156 → 0.1.159

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.
@@ -53,7 +53,7 @@ function interpolatedLiteral(text, folds) {
53
53
  }
54
54
 
55
55
  function displayFileExt(kind) {
56
- return { html: "html", chart: "json", table: "json", mermaid: "mmd", ascii: "txt", react: "json" }[kind] || "md";
56
+ return { html: "html", code: "txt", chart: "json", table: "json", mermaid: "mmd", ascii: "txt", react: "json" }[kind] || "md";
57
57
  }
58
58
 
59
59
  /**
@@ -70,6 +70,25 @@ export function generateFlowSource(ir, opts = {}) {
70
70
  return rel;
71
71
  };
72
72
 
73
+ // 节点 id 就是顶层变量名,可能刚好叫 display / flow / file。节点 id 不能为了代码生成
74
+ // 偷偷改掉(那会变成另一张图),所以冲突时给 DSL API import 起别名:
75
+ // `import { display as displayApi } ...; const display = displayApi.markdown(...)`。
76
+ // 所有顶层标识符共用 taken;先给内置 API 占位,后面的输出解构和包 import 也会避开它们。
77
+ const taken = new Set(Object.keys(N));
78
+ const uniqueName = (base, fallback) => {
79
+ let name = isIdentifier(base) ? base : fallback;
80
+ if (taken.has(name)) name = fallback;
81
+ while (taken.has(name)) name += "_";
82
+ taken.add(name);
83
+ return name;
84
+ };
85
+ const flowApiRoots = ["agent", "control", "display", "file", "flow", "provide", "tool", "workspace"];
86
+ const flowApiBinding = new Map(flowApiRoots.map((root) => [root, uniqueName(root, `${root}Api`)]));
87
+ const apiCall = (name) => {
88
+ const [root, ...tail] = String(name || "").split(".");
89
+ return [flowApiBinding.get(root) || root, ...tail].join(".");
90
+ };
91
+
73
92
  // 同一个节点的 body 和某个引脚值可能都超阈值,文件名必须带槽名区分:都叫
74
93
  // `prompts/<id>.md` 的话两段不同的文本会写进同一个文件,往返时后写的覆盖先写的。
75
94
  const externalize = (id, slot, text) => {
@@ -83,7 +102,7 @@ export function generateFlowSource(ir, opts = {}) {
83
102
  };
84
103
  const textArg = (id, slot, text) => {
85
104
  const rel = externalize(id, slot, text);
86
- return rel ? `file(${JSON.stringify(rel)})` : literal(text);
105
+ return rel ? `${apiCall("file")}(${JSON.stringify(rel)})` : literal(text);
87
106
  };
88
107
 
89
108
  const controlNext = new Map();
@@ -114,18 +133,6 @@ export function generateFlowSource(ir, opts = {}) {
114
133
  }),
115
134
  );
116
135
 
117
- // 文件里所有顶层标识符共用一个命名空间:节点 id、解构出来的输出变量、import 绑定名。
118
- // 分开发号就会撞——两个节点各有一个叫 ok 的自定义输出槽,是完全正常的图,但会生成两条
119
- // `const { ok } = ...`,文件根本解析不回来。
120
- const taken = new Set(Object.keys(N));
121
- const uniqueName = (base, fallback) => {
122
- let name = isIdentifier(base) ? base : fallback;
123
- if (taken.has(name)) name = fallback;
124
- while (taken.has(name)) name += "_";
125
- taken.add(name);
126
- return name;
127
- };
128
-
129
136
  // 自定义输出槽通过解构暴露成变量:`const { storyId } = node;`
130
137
  const outVar = new Map();
131
138
  for (const [id, slots] of destructured) {
@@ -143,7 +150,7 @@ export function generateFlowSource(ir, opts = {}) {
143
150
  const shared = [...bindingOf.values()].find((v) => v.spec === pkg.specifier);
144
151
  bindingOf.set(id, shared || { name: uniqueName(base, `${base}Node`), spec: pkg.specifier });
145
152
  }
146
- const callee = (id) => bindingOf.get(id)?.name || apiName(N[id].definitionId);
153
+ const callee = (id) => bindingOf.get(id)?.name || apiCall(apiName(N[id].definitionId));
147
154
 
148
155
  const bodyTextOf = (id) => String(
149
156
  (N[id].definitionId === "tool_nodejs" && N[id].script) ? N[id].script : (N[id].body || ""),
@@ -256,7 +263,7 @@ export function generateFlowSource(ir, opts = {}) {
256
263
  };
257
264
  const printItem = (item) => (
258
265
  Array.isArray(item)
259
- ? `flow.fork(${item.map((s) => `flow(${s.map(printItem).join(", ")})`).join(", ")})`
266
+ ? `${apiCall("flow.fork")}(${item.map((s) => `${apiCall("flow")}(${s.map(printItem).join(", ")})`).join(", ")})`
260
267
  : item
261
268
  );
262
269
 
@@ -286,8 +293,8 @@ export function generateFlowSource(ir, opts = {}) {
286
293
  if (isIf(id)) {
287
294
  const thenIds = (controlNext.get(id) || []).filter((x) => x.slot === "next1").map((x) => x.to);
288
295
  const elseIds = (controlNext.get(id) || []).filter((x) => x.slot === "next2").map((x) => x.to);
289
- args.push(`flow(${chainFrom(thenIds).map(printItem).join(", ")})`);
290
- args.push(`flow(${chainFrom(elseIds).map(printItem).join(", ")})`);
296
+ args.push(`${apiCall("flow")}(${chainFrom(thenIds).map(printItem).join(", ")})`);
297
+ args.push(`${apiCall("flow")}(${chainFrom(elseIds).map(printItem).join(", ")})`);
291
298
  } else {
292
299
  const usesScript = node.definitionId === "tool_nodejs" && node.script;
293
300
  const body = usesScript ? node.script : node.body;
@@ -323,14 +330,14 @@ export function generateFlowSource(ir, opts = {}) {
323
330
  if (N[runId].label) head.push(literal(N[runId].label));
324
331
  // 排程配置存在 run 节点的 body 里,是 JSON 字符串
325
332
  if (definitionId === "workspace_scheduled_run") head.push(N[runId].body ? literal(N[runId].body) : "null");
326
- const fn = definitionId === "workspace_scheduled_run" ? "flow.schedule" : "flow";
333
+ const fn = apiCall(definitionId === "workspace_scheduled_run" ? "flow.schedule" : "flow");
327
334
  out.push(`export const ${runId} = ${fn}(${[...head, ...seq].join(", ")});\n`);
328
335
  }
329
336
 
330
337
  // 一个 run 直接连到另一个 run:接力,不是子图
331
338
  for (const [src, list] of [...controlNext].sort()) {
332
339
  for (const x of list) {
333
- if (RUN_DEFINITIONS.has(N[x.to].definitionId)) out.push(`flow.resume(${src}, ${x.to});\n`);
340
+ if (RUN_DEFINITIONS.has(N[x.to].definitionId)) out.push(`${apiCall("flow.resume")}(${src}, ${x.to});\n`);
334
341
  }
335
342
  }
336
343
 
@@ -339,13 +346,17 @@ export function generateFlowSource(ir, opts = {}) {
339
346
  for (const id of ids) {
340
347
  if (declared.has(id) || RUN_DEFINITIONS.has(N[id].definitionId) || controlTargets.has(id)) continue;
341
348
  if (!(controlNext.get(id) || []).length) continue;
342
- out.push(`flow.detached(${chainFrom([id]).map(printItem).join(", ")});\n`);
349
+ out.push(`${apiCall("flow.detached")}(${chainFrom([id]).map(printItem).join(", ")});\n`);
343
350
  }
344
351
  for (const id of ids) {
345
352
  if (!declared.has(id) && !RUN_DEFINITIONS.has(N[id].definitionId)) declare(id, true);
346
353
  }
347
354
 
348
- const imports = [`import { agent, control, display, file, flow, provide, tool, workspace } from "agentflow/flow";`];
355
+ const flowImports = flowApiRoots.map((root) => {
356
+ const local = flowApiBinding.get(root);
357
+ return local === root ? root : `${root} as ${local}`;
358
+ });
359
+ const imports = [`import { ${flowImports.join(", ")} } from "agentflow/flow";`];
349
360
  const seenBinding = new Set();
350
361
  for (const id of ids) {
351
362
  const binding = bindingOf.get(id);
@@ -59,6 +59,26 @@ export function parseFlowSource(source, opts = {}) {
59
59
  unresolved.push({ line: node?.loc?.start?.line || 0, message });
60
60
  };
61
61
 
62
+ // codegen 在节点 id 与 DSL API 名冲突时会生成
63
+ // `import { display as displayApi } from "agentflow/flow"`。静态解析时先把 local 名还原成
64
+ // canonical API 名,后面的节点类型、flow 控制语句和 file() 才仍走同一套规则。
65
+ const flowApiOf = new Map();
66
+ for (const stmt of ast.body) {
67
+ if (stmt.type !== "ImportDeclaration" || String(stmt.source.value) !== "agentflow/flow") continue;
68
+ for (const specifier of stmt.specifiers || []) {
69
+ if (specifier.type !== "ImportSpecifier") continue;
70
+ const imported = specifier.imported?.name ?? specifier.imported?.value;
71
+ const local = specifier.local?.name;
72
+ if (imported && local) flowApiOf.set(local, String(imported));
73
+ }
74
+ }
75
+ const apiCalleePath = (node) => {
76
+ const raw = calleePath(node);
77
+ if (!raw) return raw;
78
+ const [root, ...tail] = raw.split(".");
79
+ return [flowApiOf.get(root) || root, ...tail].join(".");
80
+ };
81
+
62
82
  function stringOf(node) {
63
83
  if (!node) return null;
64
84
  if (node.type === "Literal" && typeof node.value === "string") return node.value;
@@ -72,7 +92,7 @@ export function parseFlowSource(source, opts = {}) {
72
92
  return String(node.operator === "-" ? -node.argument.value : node.argument.value);
73
93
  }
74
94
  if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0].value.cooked;
75
- if (node.type === "CallExpression" && calleePath(node.callee) === "file") {
95
+ if (node.type === "CallExpression" && apiCalleePath(node.callee) === "file") {
76
96
  const rel = stringOf(node.arguments[0]);
77
97
  if (rel == null) return null;
78
98
  if (!(rel in files)) throw new Error(`file(${JSON.stringify(rel)}) 找不到对应文件`);
@@ -229,7 +249,7 @@ export function parseFlowSource(source, opts = {}) {
229
249
  const out = [];
230
250
  for (const arg of call.arguments) {
231
251
  if (arg.type === "Identifier") out.push(arg.name);
232
- else if (arg.type === "CallExpression" && calleePath(arg.callee) === "flow.fork") {
252
+ else if (arg.type === "CallExpression" && apiCalleePath(arg.callee) === "flow.fork") {
233
253
  out.push({ fork: arg.arguments.map(itemsOf) });
234
254
  } else {
235
255
  unresolvedAt(arg, "控制流参数只能是节点变量名或 flow.fork(...)");
@@ -277,7 +297,7 @@ export function parseFlowSource(source, opts = {}) {
277
297
  unresolvedAt(d, `${id}: 节点声明右边必须是一次节点调用`);
278
298
  continue;
279
299
  }
280
- const path = calleePath(init.callee);
300
+ const path = apiCalleePath(init.callee);
281
301
  const args = [...init.arguments];
282
302
 
283
303
  const first = args[0];
@@ -384,7 +404,7 @@ export function parseFlowSource(source, opts = {}) {
384
404
  }
385
405
 
386
406
  if (decl?.type === "ExpressionStatement" && decl.expression.type === "CallExpression") {
387
- const path = calleePath(decl.expression.callee);
407
+ const path = apiCalleePath(decl.expression.callee);
388
408
  if (path === "flow.resume") {
389
409
  const [a, b] = decl.expression.arguments.map((x) => x.name);
390
410
  if (a && b) edges.push(`${a}|next|${b}|prev`);
@@ -0,0 +1,368 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const PACKAGE_RE = /(?:\.apk|\.aab|\.ipa)(?:[?#]|$)|apk-dir|apk-file/i;
6
+ const QR_RE = /(?:^|[^a-z])(?:qr|qrcode)(?:[^a-z]|$)|二维码/i;
7
+ const URL_RE = /https?:\/\/[^\s<>"']+/gi;
8
+
9
+ export const JENKINS_BUILD_STATE_VERSION = 1;
10
+ export const DEFAULT_JENKINS_POLL_INTERVAL_MS = 30_000;
11
+ export const DEFAULT_JENKINS_TIMEOUT_MS = 2 * 60 * 60 * 1000;
12
+
13
+ function safeText(value, max = 800) {
14
+ return String(value ?? "").replace(/[\r\n]+/g, " ").trim().slice(0, max);
15
+ }
16
+
17
+ function safeCredentialKey(ref) {
18
+ return String(ref || "")
19
+ .trim()
20
+ .toUpperCase()
21
+ .replace(/[^A-Z0-9]+/g, "_")
22
+ .replace(/^_+|_+$/g, "");
23
+ }
24
+
25
+ function parseDurationMs(raw, fallback) {
26
+ const text = String(raw ?? "").trim().toLowerCase();
27
+ if (!text) return fallback;
28
+ const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/);
29
+ if (!match) throw new Error(`invalid duration: ${raw}`);
30
+ const value = Number(match[1]);
31
+ const unit = match[2] || "s";
32
+ const factor = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000;
33
+ return Math.round(value * factor);
34
+ }
35
+
36
+ export function normalizeJenkinsBuildConfig(inputs = {}) {
37
+ const job = String(inputs.job || inputs.jobName || "").trim();
38
+ if (!job) throw new Error("Jenkins job is required");
39
+ let parameters = {};
40
+ const rawParameters = inputs.parameters ?? inputs.parametersJson ?? "{}";
41
+ if (rawParameters && typeof rawParameters === "object" && !Array.isArray(rawParameters)) {
42
+ parameters = { ...rawParameters };
43
+ } else if (String(rawParameters || "").trim()) {
44
+ const parsed = JSON.parse(String(rawParameters));
45
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
46
+ throw new Error("Jenkins parameters must be a JSON object");
47
+ }
48
+ parameters = parsed;
49
+ }
50
+ const pollIntervalMs = Math.max(5_000, parseDurationMs(inputs.pollInterval || inputs.pollIntervalSec, DEFAULT_JENKINS_POLL_INTERVAL_MS));
51
+ const timeoutMs = Math.max(pollIntervalMs, parseDurationMs(inputs.timeout || inputs.timeoutSec, DEFAULT_JENKINS_TIMEOUT_MS));
52
+ return {
53
+ job,
54
+ parameters,
55
+ credentialRef: String(inputs.credentialRef || "").trim(),
56
+ pollIntervalMs,
57
+ timeoutMs,
58
+ };
59
+ }
60
+
61
+ export function jenkinsCredentialEnv(env = process.env, credentialRef = "") {
62
+ const key = safeCredentialKey(credentialRef);
63
+ const prefix = key ? `JENKINS_${key}_` : "";
64
+ const pick = (name) => (prefix && env[`${prefix}${name}`]) || env[`JENKINS_${name}`] || "";
65
+ return {
66
+ baseUrl: String(pick("BASE_URL") || "").trim().replace(/\/+$/, ""),
67
+ username: String(pick("USERNAME") || "").trim(),
68
+ token: String(pick("TOKEN") || "").trim(),
69
+ };
70
+ }
71
+
72
+ function trimUrl(url) {
73
+ return String(url || "").trim().replace(/[.,;:!?\])}]+$/, "");
74
+ }
75
+
76
+ function jobPath(job) {
77
+ const parts = String(job || "").split("/").map((part) => part.trim()).filter(Boolean);
78
+ if (!parts.length) throw new Error("Jenkins job is required");
79
+ return parts.map((part) => `job/${encodeURIComponent(part)}`).join("/");
80
+ }
81
+
82
+ function absoluteUrl(baseUrl, candidate) {
83
+ const value = String(candidate || "").trim();
84
+ if (!value) return "";
85
+ return new URL(value, `${baseUrl}/`).toString();
86
+ }
87
+
88
+ async function responseBody(response) {
89
+ const text = await response.text();
90
+ if (!text.trim()) return {};
91
+ try {
92
+ return JSON.parse(text);
93
+ } catch {
94
+ return { raw: text };
95
+ }
96
+ }
97
+
98
+ /** Native Jenkins Remote API client. Credentials never leave the request headers. */
99
+ export function createJenkinsHttpInvoker({ credentialRef = "", env = process.env, fetchImpl = globalThis.fetch, signal = null, requestTimeoutMs = 30_000 } = {}) {
100
+ if (typeof fetchImpl !== "function") throw new Error("global fetch is not available in this Node.js runtime");
101
+ const credentials = jenkinsCredentialEnv(env, credentialRef);
102
+ if (!credentials.baseUrl) {
103
+ const suffix = credentialRef ? ` for credentialRef ${credentialRef}` : "";
104
+ throw new Error(`JENKINS_BASE_URL is required${suffix}`);
105
+ }
106
+ const headers = { Accept: "application/json" };
107
+ if (credentials.username || credentials.token) {
108
+ headers.Authorization = `Basic ${Buffer.from(`${credentials.username}:${credentials.token}`).toString("base64")}`;
109
+ }
110
+
111
+ const request = async (url, options = {}) => {
112
+ const controller = new AbortController();
113
+ const onAbort = () => controller.abort(signal?.reason);
114
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
115
+ const timer = setTimeout(() => controller.abort(new Error("Jenkins request timed out")), Math.max(1_000, Number(requestTimeoutMs) || 30_000));
116
+ try {
117
+ const response = await fetchImpl(url, {
118
+ ...options,
119
+ headers: { ...headers, ...(options.headers || {}) },
120
+ signal: controller.signal,
121
+ });
122
+ const body = await responseBody(response);
123
+ if (!response.ok) {
124
+ return { ok: false, status: response.status, safe_summary: `Jenkins HTTP ${response.status}: ${safeText(body?.message || body?.raw || response.statusText)}` };
125
+ }
126
+ return { ok: true, response, body };
127
+ } finally {
128
+ clearTimeout(timer);
129
+ if (signal) signal.removeEventListener("abort", onAbort);
130
+ }
131
+ };
132
+
133
+ return async (operation, args = {}) => {
134
+ if (operation === "trigger") {
135
+ const configuredParameters = args.parameters || {};
136
+ let parameterized = Object.keys(configuredParameters).length > 0;
137
+ if (!parameterized) {
138
+ const metadataUrl = `${credentials.baseUrl}/${jobPath(args.job)}/api/json?tree=actions[parameterDefinitions[name]]`;
139
+ const metadata = await request(metadataUrl);
140
+ if (!metadata.ok) return metadata;
141
+ parameterized = (Array.isArray(metadata.body?.actions) ? metadata.body.actions : [])
142
+ .some((action) => Array.isArray(action?.parameterDefinitions) && action.parameterDefinitions.length > 0);
143
+ }
144
+ const endpoint = `${credentials.baseUrl}/${jobPath(args.job)}/${parameterized ? "buildWithParameters" : "build"}`;
145
+ const form = new URLSearchParams();
146
+ for (const [key, value] of Object.entries(configuredParameters)) {
147
+ form.set(key, value == null ? "" : typeof value === "object" ? JSON.stringify(value) : String(value));
148
+ }
149
+ const result = await request(endpoint, {
150
+ method: "POST",
151
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
152
+ body: form.toString(),
153
+ redirect: "manual",
154
+ });
155
+ if (!result.ok) return result;
156
+ const location = result.response.headers.get("location") || "";
157
+ const queueUrl = absoluteUrl(credentials.baseUrl, location);
158
+ const queueId = queueUrl.match(/\/queue\/item\/([^/?#]+)/)?.[1] || "";
159
+ return queueId
160
+ ? { ok: true, resource: { queue_id: decodeURIComponent(queueId), queue_url: queueUrl } }
161
+ : { ok: false, safe_summary: "Jenkins trigger did not return a queue Location header" };
162
+ }
163
+ if (operation === "queue") {
164
+ const url = `${credentials.baseUrl}/queue/item/${encodeURIComponent(String(args.queueId))}/api/json`;
165
+ const result = await request(url);
166
+ return result.ok ? { ok: true, resource: { item: result.body } } : result;
167
+ }
168
+ if (operation === "build") {
169
+ const url = `${credentials.baseUrl}/${jobPath(args.job)}/${encodeURIComponent(String(args.buildNumber))}/api/json`;
170
+ const result = await request(url);
171
+ return result.ok ? { ok: true, resource: { build: result.body } } : result;
172
+ }
173
+ throw new Error(`unsupported Jenkins operation: ${operation}`);
174
+ };
175
+ }
176
+
177
+ function hashParameters(parameters) {
178
+ return crypto.createHash("sha256").update(JSON.stringify(parameters || {})).digest("hex").slice(0, 16);
179
+ }
180
+
181
+ function artifactUrl(buildUrl, artifact) {
182
+ const direct = trimUrl(artifact?.url);
183
+ if (direct) return direct;
184
+ const relative = String(artifact?.relativePath || "").trim();
185
+ if (!relative || !buildUrl) return "";
186
+ return `${String(buildUrl).replace(/\/+$/, "")}/artifact/${relative.split("/").map(encodeURIComponent).join("/")}`;
187
+ }
188
+
189
+ function collectBuildLinks(build = {}) {
190
+ const buildUrl = trimUrl(build.url);
191
+ const packageUrls = [];
192
+ const qrUrls = [];
193
+ const add = (context, rawUrl) => {
194
+ const url = trimUrl(rawUrl);
195
+ if (!/^https?:\/\//i.test(url)) return;
196
+ const haystack = `${context || ""} ${url}`;
197
+ if (QR_RE.test(haystack)) {
198
+ if (!qrUrls.includes(url)) qrUrls.push(url);
199
+ } else if (PACKAGE_RE.test(haystack)) {
200
+ if (!packageUrls.includes(url)) packageUrls.push(url);
201
+ }
202
+ };
203
+ for (const artifact of Array.isArray(build.artifacts) ? build.artifacts : []) {
204
+ add(artifact?.relativePath || artifact?.fileName || "", artifactUrl(buildUrl, artifact));
205
+ }
206
+ const scan = (value, context = "", depth = 0) => {
207
+ if (depth > 5 || value == null) return;
208
+ if (typeof value === "string") {
209
+ for (const url of value.match(URL_RE) || []) add(context, url);
210
+ } else if (Array.isArray(value)) {
211
+ for (const item of value) scan(item, context, depth + 1);
212
+ } else if (typeof value === "object") {
213
+ for (const [key, item] of Object.entries(value)) scan(item, `${context} ${key}`, depth + 1);
214
+ }
215
+ };
216
+ scan(build.description || "", "description");
217
+ scan(build.actions || [], "actions");
218
+ return { buildUrl, url: packageUrls[0] || buildUrl, qrUrl: qrUrls[0] || "" };
219
+ }
220
+
221
+ function waitResult(state, nowMs, pollIntervalMs, message) {
222
+ const wakeAt = new Date(nowMs + pollIntervalMs).toISOString();
223
+ return { kind: "waiting", message, wakeAt, state: { ...state, wakeAt, message, updatedAt: new Date(nowMs).toISOString() } };
224
+ }
225
+
226
+ function completeResult(state, nowMs, status, message, links = {}) {
227
+ const completedAt = new Date(nowMs).toISOString();
228
+ const next = {
229
+ ...state,
230
+ phase: "complete",
231
+ status,
232
+ message,
233
+ url: links.url || state.url || state.buildUrl || "",
234
+ qrUrl: links.qrUrl || state.qrUrl || "",
235
+ buildUrl: links.buildUrl || state.buildUrl || "",
236
+ wakeAt: "",
237
+ completedAt,
238
+ updatedAt: completedAt,
239
+ };
240
+ return { kind: "complete", message, state: next, outputs: { status, url: next.url, qrUrl: next.qrUrl } };
241
+ }
242
+
243
+ function failedResult(state, nowMs, message) {
244
+ return { ...completeResult(state, nowMs, "ERROR", message), kind: "failed" };
245
+ }
246
+
247
+ function blockedTriggerResult(state, nowMs, message) {
248
+ const updatedAt = new Date(nowMs).toISOString();
249
+ return {
250
+ kind: "failed",
251
+ message,
252
+ state: { ...state, phase: "triggering", status: "ERROR", message, wakeAt: "", updatedAt },
253
+ outputs: { status: "ERROR", url: state.url || state.buildUrl || "", qrUrl: state.qrUrl || "" },
254
+ };
255
+ }
256
+
257
+ function invokeFailure(state, nowMs, config, response, fallback) {
258
+ const errors = Number(state.consecutiveErrors || 0) + 1;
259
+ const message = safeText(response?.safe_summary || response?.blocked_reason || fallback || "Jenkins request failed");
260
+ const next = { ...state, consecutiveErrors: errors, lastError: message };
261
+ if ([401, 403].includes(Number(response?.status)) || errors >= 3) return failedResult(next, nowMs, message);
262
+ return waitResult(next, nowMs, config.pollIntervalMs, `Jenkins 暂时不可用,稍后重试 (${errors}/3)`);
263
+ }
264
+
265
+ /** Advances exactly one remote operation and returns a durable checkpoint. */
266
+ export async function advanceJenkinsBuild({ state, config, invoke, persistState = () => {}, nowMs = Date.now(), cancelled = false, runId = "" }) {
267
+ const nowIso = new Date(nowMs).toISOString();
268
+ let current = state && typeof state === "object" ? { ...state } : null;
269
+ if (cancelled) return completeResult(current || { version: 1, job: config.job, createdAt: nowIso }, nowMs, "CANCELLED", "Jenkins 构建监控已取消");
270
+ if (current?.phase === "complete") return completeResult(current, nowMs, current.status || "ERROR", current.message || "Jenkins 构建已结束", current);
271
+
272
+ if (!current) {
273
+ current = {
274
+ version: JENKINS_BUILD_STATE_VERSION,
275
+ runId: String(runId || ""),
276
+ job: config.job,
277
+ parametersHash: hashParameters(config.parameters),
278
+ phase: "triggering",
279
+ status: "QUEUED",
280
+ createdAt: nowIso,
281
+ startedAt: nowIso,
282
+ deadlineAt: new Date(nowMs + config.timeoutMs).toISOString(),
283
+ pollCount: 0,
284
+ consecutiveErrors: 0,
285
+ updatedAt: nowIso,
286
+ };
287
+ // Persist before the non-idempotent trigger. A crash must never trigger a duplicate build.
288
+ persistState(current);
289
+ let response;
290
+ try {
291
+ response = await invoke("trigger", { job: config.job, parameters: config.parameters });
292
+ } catch (error) {
293
+ return blockedTriggerResult(current, nowMs, `Jenkins 触发结果未知,为避免重复构建未自动重试:${safeText(error?.message || error)}`);
294
+ }
295
+ if (!response?.ok) return failedResult(current, nowMs, safeText(response?.safe_summary || response?.blocked_reason || "Jenkins trigger failed"));
296
+ const queueId = String(response?.resource?.queue_id || response?.resource?.queueId || "").trim();
297
+ if (!queueId) return failedResult(current, nowMs, "Jenkins trigger did not return queueId");
298
+ current = { ...current, phase: "queued", status: "QUEUED", queueId, queueUrl: response?.resource?.queue_url || "", message: `已进入 Jenkins 队列 ${queueId}`, updatedAt: nowIso };
299
+ return waitResult(current, nowMs, config.pollIntervalMs, current.message);
300
+ }
301
+
302
+ if (current.job !== config.job || current.parametersHash !== hashParameters(config.parameters)) {
303
+ return failedResult(current, nowMs, "Jenkins checkpoint does not match the current job or parameters");
304
+ }
305
+ if (current.phase === "triggering") return blockedTriggerResult(current, nowMs, "Jenkins 触发结果未知,为避免重复构建未自动重试");
306
+ if (current.deadlineAt && Date.parse(current.deadlineAt) <= nowMs) return completeResult(current, nowMs, "TIMEOUT", "等待 Jenkins 构建超时");
307
+
308
+ if (current.phase === "queued") {
309
+ let response;
310
+ try {
311
+ response = await invoke("queue", { queueId: current.queueId });
312
+ } catch (error) {
313
+ response = { ok: false, safe_summary: error?.message || String(error) };
314
+ }
315
+ if (!response?.ok) return invokeFailure(current, nowMs, config, response, "Jenkins queue lookup failed");
316
+ const item = response?.resource?.item || {};
317
+ const executable = item.executable || {};
318
+ if (item.cancelled) return completeResult(current, nowMs, "ABORTED", "Jenkins 队列任务已取消");
319
+ if (executable.number != null) {
320
+ const buildNumber = String(executable.number);
321
+ const next = { ...current, phase: "running", status: "RUNNING", buildNumber, buildUrl: trimUrl(executable.url), pollCount: Number(current.pollCount || 0) + 1, consecutiveErrors: 0 };
322
+ return waitResult(next, nowMs, config.pollIntervalMs, `Jenkins 构建中 · #${buildNumber}`);
323
+ }
324
+ const next = { ...current, pollCount: Number(current.pollCount || 0) + 1, consecutiveErrors: 0, queueReason: safeText(item.why || "") };
325
+ return waitResult(next, nowMs, config.pollIntervalMs, next.queueReason || `Jenkins 排队中 · ${current.queueId}`);
326
+ }
327
+
328
+ if (current.phase === "running") {
329
+ let response;
330
+ try {
331
+ response = await invoke("build", { job: config.job, buildNumber: current.buildNumber });
332
+ } catch (error) {
333
+ response = { ok: false, safe_summary: error?.message || String(error) };
334
+ }
335
+ if (!response?.ok) return invokeFailure(current, nowMs, config, response, "Jenkins build lookup failed");
336
+ const build = response?.resource?.build || {};
337
+ const links = collectBuildLinks(build);
338
+ if (build.building || !build.result) {
339
+ const next = { ...current, status: "RUNNING", buildUrl: links.buildUrl || current.buildUrl || "", pollCount: Number(current.pollCount || 0) + 1, consecutiveErrors: 0 };
340
+ return waitResult(next, nowMs, config.pollIntervalMs, `Jenkins 构建中 · #${current.buildNumber}`);
341
+ }
342
+ const status = String(build.result || "ERROR").trim().toUpperCase();
343
+ return completeResult({ ...current, buildNumber: String(build.number ?? current.buildNumber ?? "") }, nowMs, status, `Jenkins ${status}${current.buildNumber ? ` · #${current.buildNumber}` : ""}`, links);
344
+ }
345
+
346
+ return failedResult(current, nowMs, `Unknown Jenkins phase: ${safeText(current.phase)}`);
347
+ }
348
+
349
+ export function readJenkinsBuildState(filePath) {
350
+ try {
351
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
352
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
353
+ } catch {
354
+ return null;
355
+ }
356
+ }
357
+
358
+ export function writeJenkinsBuildState(filePath, state) {
359
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
360
+ const temp = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
361
+ fs.writeFileSync(temp, JSON.stringify(state, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
362
+ fs.renameSync(temp, filePath);
363
+ }
364
+
365
+ export function jenkinsBuildStatePath(workspaceRoot, nodeId) {
366
+ const safeNodeId = String(nodeId || "jenkins").replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 120) || "jenkins";
367
+ return path.join(path.resolve(workspaceRoot), ".workspace", "agentflow", "jenkins", `${safeNodeId}.json`);
368
+ }
@@ -33,7 +33,6 @@ export const WORKSPACE_UNSUPPORTED_NODE_IDS = new Set([
33
33
  "tool_get_env",
34
34
  // 其它
35
35
  "tool_print",
36
- "tool_jenkins_build",
37
36
  ]);
38
37
 
39
38
  /** 节点面板与目录需要隐藏的全部 definitionId */
@@ -351,6 +351,10 @@
351
351
  "displayName": "Markdown Display",
352
352
  "description": "Render Markdown content on the Workspace canvas and pass the text downstream"
353
353
  },
354
+ "display_code": {
355
+ "displayName": "Code Display",
356
+ "description": "Render highlighted source code with line numbers, copy, wrap, and download controls, then pass the code downstream"
357
+ },
354
358
  "display_mermaid": {
355
359
  "displayName": "Mermaid Display",
356
360
  "description": "Render Mermaid diagram source on the Workspace canvas and pass the source downstream"
@@ -351,6 +351,10 @@
351
351
  "displayName": "Markdown 展示",
352
352
  "description": "在 Workspace 画布中渲染 Markdown 内容,并将文本继续传给下游"
353
353
  },
354
+ "display_code": {
355
+ "displayName": "代码展示",
356
+ "description": "在 Workspace 画布中高亮展示代码,支持行号、复制、换行和下载,并将代码继续传给下游"
357
+ },
354
358
  "display_mermaid": {
355
359
  "displayName": "Mermaid 展示",
356
360
  "description": "在 Workspace 画布中渲染 Mermaid 图,并将源码继续传给下游"
@@ -70,6 +70,7 @@ function query(params = {}) {
70
70
  function displayKind(definitionId) {
71
71
  const id = String(definitionId || "");
72
72
  if (id === "display_markdown") return "markdown";
73
+ if (id === "display_code") return "code";
73
74
  if (id === "display_mermaid") return "mermaid";
74
75
  if (id === "display_ascii") return "ascii";
75
76
  if (id === "display_html") return "html";