@fieldwangai/agentflow 0.1.137 → 0.1.141
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.
- package/bin/lib/catalog-flows.mjs +17 -3
- package/bin/lib/composer-node-schema.mjs +4 -0
- package/bin/lib/i18n.mjs +2 -2
- package/bin/lib/jenkins.mjs +380 -0
- package/bin/lib/locales/en.json +22 -0
- package/bin/lib/locales/zh.json +22 -0
- package/bin/lib/paths.mjs +1 -0
- package/bin/lib/prd-workflow-collaboration.mjs +139 -1
- package/bin/lib/recent-runs.mjs +16 -0
- package/bin/lib/run-node-statuses-from-disk.mjs +41 -3
- package/bin/lib/scheduler.mjs +59 -25
- package/bin/lib/ui-server.mjs +721 -63
- package/bin/pipeline/pre-process-node.mjs +122 -11
- package/bin/pipeline/run-log.mjs +2 -2
- package/bin/pipeline/write-result.mjs +4 -4
- package/builtin/nodes/tool_jenkins_build.md +64 -0
- package/builtin/pipelines/jenkins-build-notify/flow.yaml +217 -0
- package/builtin/web-ui/dist/assets/WorkflowAssistantThread-ubxHcM7p.js +6 -0
- package/builtin/web-ui/dist/assets/index-B6TWUomI.css +1 -0
- package/builtin/web-ui/dist/assets/index-DQzcZp7S.js +590 -0
- package/builtin/web-ui/dist/index.html +2 -2
- package/package.json +1 -1
- package/skills/agentflow-workflow-report/SKILL.md +3 -0
- package/skills/agentflow-workflow-report/references/protocol.md +8 -5
- package/builtin/web-ui/dist/assets/index-CQsrSc3u.css +0 -1
- package/builtin/web-ui/dist/assets/index-DQvqqAeQ.js +0 -590
|
@@ -171,7 +171,7 @@ function normalizeFrontmatterSlots(arr) {
|
|
|
171
171
|
*/
|
|
172
172
|
export function parseNodeFrontmatter(raw) {
|
|
173
173
|
const m = raw.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/);
|
|
174
|
-
const data = { input: [], output: [], displayName: undefined, description: undefined };
|
|
174
|
+
const data = { input: [], output: [], displayName: undefined, description: undefined, guide: undefined };
|
|
175
175
|
if (!m) return data;
|
|
176
176
|
const fm = m[1];
|
|
177
177
|
try {
|
|
@@ -183,6 +183,9 @@ export function parseNodeFrontmatter(raw) {
|
|
|
183
183
|
if (parsed.displayName != null && String(parsed.displayName).trim() !== "") {
|
|
184
184
|
data.displayName = String(parsed.displayName).trim();
|
|
185
185
|
}
|
|
186
|
+
if (parsed.guide && typeof parsed.guide === "object" && !Array.isArray(parsed.guide)) {
|
|
187
|
+
data.guide = parsed.guide;
|
|
188
|
+
}
|
|
186
189
|
data.input = normalizeFrontmatterSlots(parsed.input);
|
|
187
190
|
data.output = normalizeFrontmatterSlots(parsed.output);
|
|
188
191
|
return data;
|
|
@@ -259,14 +262,24 @@ export function listNodesJson(workspaceRoot, flowId, flowSource, opts = {}) {
|
|
|
259
262
|
const strippedId =
|
|
260
263
|
id.replace(/^agent_?/i, "").replace(/^control_?/i, "").replace(/^provide_?/i, "").replace(/^tool_?/i, "") || id;
|
|
261
264
|
const label = data.displayName ?? strippedId;
|
|
262
|
-
const
|
|
263
|
-
const
|
|
265
|
+
const displayNameKey = `nodeDef.${id}.displayName`;
|
|
266
|
+
const descriptionKey = `nodeDef.${id}.description`;
|
|
267
|
+
const guideKey = `nodeDef.${id}.guide`;
|
|
268
|
+
const translatedDisplayNameRaw = translateNodeDef(id, "displayName");
|
|
269
|
+
const translatedDescriptionRaw = translateNodeDef(id, "description");
|
|
270
|
+
const translatedGuideRaw = translateNodeDef(id, "guide");
|
|
271
|
+
const translatedDisplayName = translatedDisplayNameRaw === displayNameKey ? "" : translatedDisplayNameRaw;
|
|
272
|
+
const translatedDescription = translatedDescriptionRaw === descriptionKey ? "" : translatedDescriptionRaw;
|
|
273
|
+
const translatedGuide = translatedGuideRaw && typeof translatedGuideRaw === "object" && !Array.isArray(translatedGuideRaw)
|
|
274
|
+
? translatedGuideRaw
|
|
275
|
+
: undefined;
|
|
264
276
|
byId.set(id, {
|
|
265
277
|
id,
|
|
266
278
|
type,
|
|
267
279
|
label: translatedDisplayName || label,
|
|
268
280
|
displayName: translatedDisplayName || data.displayName,
|
|
269
281
|
description: translatedDescription || data.description,
|
|
282
|
+
guide: translatedGuide || data.guide,
|
|
270
283
|
inputs: data.input,
|
|
271
284
|
outputs: data.output,
|
|
272
285
|
source: flowIdOpt ? "flow" : "project",
|
|
@@ -606,6 +619,7 @@ export function readNodeJson(workspaceRoot, nodeId, flowId, flowSource, opts = {
|
|
|
606
619
|
outputs: data.output,
|
|
607
620
|
executionLogic: content || undefined,
|
|
608
621
|
description: data.description,
|
|
622
|
+
guide: data.guide,
|
|
609
623
|
};
|
|
610
624
|
} catch (_) {}
|
|
611
625
|
}
|
|
@@ -139,6 +139,7 @@ export function buildNodeSchemaCompactSection() {
|
|
|
139
139
|
lines.push("3. `provide_*` 节点不得连入控制链(node→node 边),仅作数据源向下游 text/file/bool 槽供值。");
|
|
140
140
|
lines.push("4. 边连接时 `sourceHandle: output-N` 与 `targetHandle: input-N` 的索引必须对应**同一 type**;type 不一致禁止连线(text 不能接 file,node 不能接 text)。");
|
|
141
141
|
lines.push("5. **YAML 多行字符串必须用 `|` 块标量。** 写 `script` / `body` / `value` 等字符串字段时,只要内容含 `: `、`\"`、`'`、`#`、换行、shell 操作符,**强制**使用 `|` 块。");
|
|
142
|
+
lines.push("6. 用户要求 Jenkins 构建或等待 Jenkins 结果时,必须使用固定槽位节点 `tool_jenkins_build`;不要生成 `tool_nodejs` 轮询脚本、`control_interval_loop` 或阻塞式 sleep。该节点内部负责持久化等待,输出 `status/url/qrUrl`。");
|
|
142
143
|
cachedCompact = lines.join("\n");
|
|
143
144
|
return cachedCompact;
|
|
144
145
|
}
|
|
@@ -194,6 +195,9 @@ export function buildNodeSchemaPromptSection() {
|
|
|
194
195
|
lines.push(
|
|
195
196
|
"5. **YAML 多行字符串必须用 `|` 块标量。** 写 `script` / `body` / `value` 等字符串字段时,只要内容含 `: `(冒号+空格)、`\"`、`'`、`#`、换行、shell 操作符(`|`/`&`/`>`/`<`),**强制**使用 `|` 块。**默认全部用 `|`**——比裸写安全且易读。"
|
|
196
197
|
);
|
|
198
|
+
lines.push(
|
|
199
|
+
"6. 用户要求 Jenkins 构建或等待 Jenkins 结果时,必须使用固定槽位节点 `tool_jenkins_build`;不要生成 `tool_nodejs` 轮询脚本、`control_interval_loop` 或阻塞式 sleep。该节点内部负责持久化等待,输出 `status/url/qrUrl`。"
|
|
200
|
+
);
|
|
197
201
|
lines.push(" ✅ 正确:");
|
|
198
202
|
lines.push(" ```yaml");
|
|
199
203
|
lines.push(" script: |");
|
package/bin/lib/i18n.mjs
CHANGED
|
@@ -225,8 +225,8 @@ export function normalizeRoleToKey(roleOrKey) {
|
|
|
225
225
|
/**
|
|
226
226
|
* 翻译节点定义字段
|
|
227
227
|
* @param {string} definitionId - 节点定义 ID
|
|
228
|
-
* @param {"displayName" | "description"} field - 字段名
|
|
229
|
-
* @returns {string}
|
|
228
|
+
* @param {"displayName" | "description" | "guide"} field - 字段名
|
|
229
|
+
* @returns {string | object}
|
|
230
230
|
*/
|
|
231
231
|
export function translateNodeDef(definitionId, field) {
|
|
232
232
|
return t(`nodeDef.${definitionId}.${field}`);
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { spawnSync } from "child_process";
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import path from "path";
|
|
6
|
+
|
|
7
|
+
import { getAgentflowSkillsRoot } from "./paths.mjs";
|
|
8
|
+
|
|
9
|
+
const PACKAGE_RE = /(?:\.apk|\.aab|\.ipa)(?:[?#]|$)|apk-dir|apk-file/i;
|
|
10
|
+
const QR_RE = /(?:^|[^a-z])(?:qr|qrcode)(?:[^a-z]|$)|二维码/i;
|
|
11
|
+
const URL_RE = /https?:\/\/[^\s<>"']+/gi;
|
|
12
|
+
|
|
13
|
+
export const JENKINS_BUILD_STATE_VERSION = 1;
|
|
14
|
+
export const DEFAULT_JENKINS_POLL_INTERVAL_MS = 30_000;
|
|
15
|
+
export const DEFAULT_JENKINS_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
function safeText(value, max = 800) {
|
|
18
|
+
return String(value ?? "").replace(/[\r\n]+/g, " ").trim().slice(0, max);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function safeCredentialKey(ref) {
|
|
22
|
+
return String(ref || "")
|
|
23
|
+
.trim()
|
|
24
|
+
.toUpperCase()
|
|
25
|
+
.replace(/[^A-Z0-9]+/g, "_")
|
|
26
|
+
.replace(/^_+|_+$/g, "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseDurationMs(raw, fallback) {
|
|
30
|
+
const text = String(raw ?? "").trim().toLowerCase();
|
|
31
|
+
if (!text) return fallback;
|
|
32
|
+
const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/);
|
|
33
|
+
if (!match) throw new Error(`invalid duration: ${raw}`);
|
|
34
|
+
const value = Number(match[1]);
|
|
35
|
+
const unit = match[2] || "s";
|
|
36
|
+
const factor = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000;
|
|
37
|
+
return Math.round(value * factor);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function normalizeJenkinsBuildConfig(inputs = {}) {
|
|
41
|
+
const job = String(inputs.job || inputs.jobName || "").trim();
|
|
42
|
+
if (!job) throw new Error("Jenkins job is required");
|
|
43
|
+
let parameters = {};
|
|
44
|
+
const rawParameters = inputs.parameters ?? inputs.parametersJson ?? "{}";
|
|
45
|
+
if (rawParameters && typeof rawParameters === "object" && !Array.isArray(rawParameters)) {
|
|
46
|
+
parameters = { ...rawParameters };
|
|
47
|
+
} else if (String(rawParameters || "").trim()) {
|
|
48
|
+
const parsed = JSON.parse(String(rawParameters));
|
|
49
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Jenkins parameters must be a JSON object");
|
|
50
|
+
parameters = parsed;
|
|
51
|
+
}
|
|
52
|
+
const pollIntervalMs = Math.max(5_000, parseDurationMs(inputs.pollInterval || inputs.pollIntervalSec, DEFAULT_JENKINS_POLL_INTERVAL_MS));
|
|
53
|
+
const timeoutMs = Math.max(pollIntervalMs, parseDurationMs(inputs.timeout || inputs.timeoutSec, DEFAULT_JENKINS_TIMEOUT_MS));
|
|
54
|
+
return {
|
|
55
|
+
job,
|
|
56
|
+
parameters,
|
|
57
|
+
credentialRef: String(inputs.credentialRef || "").trim(),
|
|
58
|
+
pollIntervalMs,
|
|
59
|
+
timeoutMs,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function jenkinsCredentialEnv(env = process.env, credentialRef = "") {
|
|
64
|
+
const key = safeCredentialKey(credentialRef);
|
|
65
|
+
const prefix = key ? `JENKINS_${key}_` : "";
|
|
66
|
+
const pick = (name) => (prefix && env[`${prefix}${name}`]) || env[`JENKINS_${name}`] || "";
|
|
67
|
+
return {
|
|
68
|
+
baseUrl: String(pick("BASE_URL") || "").trim(),
|
|
69
|
+
username: String(pick("USERNAME") || "").trim(),
|
|
70
|
+
token: String(pick("TOKEN") || "").trim(),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function candidateSkillDirs(workspaceRoot, env) {
|
|
75
|
+
return [
|
|
76
|
+
env.AGENTFLOW_JENKINS_SKILL_DIR,
|
|
77
|
+
workspaceRoot ? path.join(workspaceRoot, ".agents", "skills", "jenkins") : "",
|
|
78
|
+
workspaceRoot ? path.join(workspaceRoot, ".codex", "skills", "jenkins") : "",
|
|
79
|
+
path.join(getAgentflowSkillsRoot(), "jenkins"),
|
|
80
|
+
path.join(os.homedir(), ".agents", "skills", "jenkins"),
|
|
81
|
+
path.join(os.homedir(), ".codex", "skills", "jenkins"),
|
|
82
|
+
path.join(os.homedir(), ".@nova", "skillhub", "jenkins"),
|
|
83
|
+
path.join(os.homedir(), ".@nova", "skillhub", "jenkins", "latest", "content"),
|
|
84
|
+
].filter(Boolean);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function resolveJenkinsSkillRuntime(workspaceRoot, env = process.env) {
|
|
88
|
+
for (const dir of candidateSkillDirs(workspaceRoot, env)) {
|
|
89
|
+
const skillDir = path.resolve(String(dir));
|
|
90
|
+
const cli = ["jenkins_cli.py", "jenkins.py"]
|
|
91
|
+
.map((name) => path.join(skillDir, "scripts", name))
|
|
92
|
+
.find((candidate) => fs.existsSync(candidate));
|
|
93
|
+
if (!cli) continue;
|
|
94
|
+
const configuredPython = String(env.AGENTFLOW_JENKINS_PYTHON || "").trim();
|
|
95
|
+
const venvPython = path.join(skillDir, ".venv", "bin", "python");
|
|
96
|
+
if (configuredPython) return { skillDir, cli, command: configuredPython, prefixArgs: [] };
|
|
97
|
+
if (fs.existsSync(venvPython)) return { skillDir, cli, command: venvPython, prefixArgs: [] };
|
|
98
|
+
const systemPython = spawnSync("python3", ["-c", "import jenkins"], { encoding: "utf-8", timeout: 5_000 });
|
|
99
|
+
if (systemPython.status === 0) return { skillDir, cli, command: "python3", prefixArgs: [] };
|
|
100
|
+
const uv = spawnSync("uv", ["--version"], { encoding: "utf-8", timeout: 5_000 });
|
|
101
|
+
if (uv.status === 0) {
|
|
102
|
+
return {
|
|
103
|
+
skillDir,
|
|
104
|
+
cli,
|
|
105
|
+
command: "uv",
|
|
106
|
+
prefixArgs: ["run", "--quiet", "--with", "python-jenkins", "python"],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return { skillDir, cli, command: "python3", prefixArgs: [] };
|
|
110
|
+
}
|
|
111
|
+
throw new Error("Jenkins skill is not installed; install the `jenkins` skill or set AGENTFLOW_JENKINS_SKILL_DIR");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function operationArgs(operation, args) {
|
|
115
|
+
if (operation === "trigger") {
|
|
116
|
+
return ["trigger", "--job-name", String(args.job), "--parameters-json", JSON.stringify(args.parameters || {})];
|
|
117
|
+
}
|
|
118
|
+
if (operation === "queue") return ["queue", "--queue-id", String(args.queueId)];
|
|
119
|
+
if (operation === "build") return ["build", "--job-name", String(args.job), "--build-number", String(args.buildNumber)];
|
|
120
|
+
throw new Error(`unsupported Jenkins operation: ${operation}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function createJenkinsSkillInvoker({ workspaceRoot, credentialRef = "", env = process.env, timeoutMs = 30_000 } = {}) {
|
|
124
|
+
const runtime = resolveJenkinsSkillRuntime(workspaceRoot, env);
|
|
125
|
+
const credentials = jenkinsCredentialEnv(env, credentialRef);
|
|
126
|
+
if (!credentials.baseUrl) {
|
|
127
|
+
const suffix = credentialRef ? ` for credentialRef ${credentialRef}` : "";
|
|
128
|
+
throw new Error(`JENKINS_BASE_URL is required${suffix}`);
|
|
129
|
+
}
|
|
130
|
+
return (operation, args = {}) => {
|
|
131
|
+
const child = spawnSync(runtime.command, [...runtime.prefixArgs, runtime.cli, ...operationArgs(operation, args)], {
|
|
132
|
+
cwd: workspaceRoot ? path.resolve(workspaceRoot) : process.cwd(),
|
|
133
|
+
env: {
|
|
134
|
+
...env,
|
|
135
|
+
JENKINS_BASE_URL: credentials.baseUrl,
|
|
136
|
+
JENKINS_USERNAME: credentials.username,
|
|
137
|
+
JENKINS_TOKEN: credentials.token,
|
|
138
|
+
},
|
|
139
|
+
encoding: "utf-8",
|
|
140
|
+
timeout: Math.max(1_000, Number(timeoutMs) || 30_000),
|
|
141
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
142
|
+
});
|
|
143
|
+
if (child.error) throw new Error(`Jenkins skill failed: ${safeText(child.error.message)}`);
|
|
144
|
+
let payload;
|
|
145
|
+
try {
|
|
146
|
+
payload = JSON.parse(String(child.stdout || "").trim());
|
|
147
|
+
} catch {
|
|
148
|
+
throw new Error(`Jenkins skill returned invalid JSON: ${safeText(child.stderr || child.stdout || "empty output")}`);
|
|
149
|
+
}
|
|
150
|
+
return payload;
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function hashParameters(parameters) {
|
|
155
|
+
return crypto.createHash("sha256").update(JSON.stringify(parameters || {})).digest("hex").slice(0, 16);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function trimUrl(url) {
|
|
159
|
+
return String(url || "").trim().replace(/[.,;:!?\])}]+$/, "");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function artifactUrl(buildUrl, artifact) {
|
|
163
|
+
const direct = trimUrl(artifact?.url);
|
|
164
|
+
if (direct) return direct;
|
|
165
|
+
const relative = String(artifact?.relativePath || "").trim();
|
|
166
|
+
if (!relative || !buildUrl) return "";
|
|
167
|
+
return `${String(buildUrl).replace(/\/+$/, "")}/artifact/${relative.split("/").map(encodeURIComponent).join("/")}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function collectBuildLinks(build = {}) {
|
|
171
|
+
const buildUrl = trimUrl(build.url);
|
|
172
|
+
const packageUrls = [];
|
|
173
|
+
const qrUrls = [];
|
|
174
|
+
const add = (context, rawUrl) => {
|
|
175
|
+
const url = trimUrl(rawUrl);
|
|
176
|
+
if (!/^https?:\/\//i.test(url)) return;
|
|
177
|
+
const haystack = `${context || ""} ${url}`;
|
|
178
|
+
if (QR_RE.test(haystack)) {
|
|
179
|
+
if (!qrUrls.includes(url)) qrUrls.push(url);
|
|
180
|
+
} else if (PACKAGE_RE.test(haystack)) {
|
|
181
|
+
if (!packageUrls.includes(url)) packageUrls.push(url);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
for (const artifact of Array.isArray(build.artifacts) ? build.artifacts : []) {
|
|
185
|
+
add(artifact?.relativePath || artifact?.fileName || "", artifactUrl(buildUrl, artifact));
|
|
186
|
+
}
|
|
187
|
+
const scan = (value, context = "", depth = 0) => {
|
|
188
|
+
if (depth > 5 || value == null) return;
|
|
189
|
+
if (typeof value === "string") {
|
|
190
|
+
for (const url of value.match(URL_RE) || []) add(context, url);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (Array.isArray(value)) {
|
|
194
|
+
for (const item of value) scan(item, context, depth + 1);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (typeof value === "object") {
|
|
198
|
+
for (const [key, item] of Object.entries(value)) scan(item, `${context} ${key}`, depth + 1);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
scan(build.description || "", "description");
|
|
202
|
+
scan(build.actions || [], "actions");
|
|
203
|
+
return {
|
|
204
|
+
buildUrl,
|
|
205
|
+
url: packageUrls[0] || buildUrl,
|
|
206
|
+
qrUrl: qrUrls[0] || "",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function waitResult(state, nowMs, pollIntervalMs, message) {
|
|
211
|
+
const wakeAt = new Date(nowMs + pollIntervalMs).toISOString();
|
|
212
|
+
return {
|
|
213
|
+
kind: "waiting",
|
|
214
|
+
message,
|
|
215
|
+
wakeAt,
|
|
216
|
+
state: { ...state, wakeAt, message, updatedAt: new Date(nowMs).toISOString() },
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function completeResult(state, nowMs, status, message, links = {}) {
|
|
221
|
+
const completedAt = new Date(nowMs).toISOString();
|
|
222
|
+
const next = {
|
|
223
|
+
...state,
|
|
224
|
+
phase: "complete",
|
|
225
|
+
status,
|
|
226
|
+
message,
|
|
227
|
+
url: links.url || state.url || state.buildUrl || "",
|
|
228
|
+
qrUrl: links.qrUrl || state.qrUrl || "",
|
|
229
|
+
buildUrl: links.buildUrl || state.buildUrl || "",
|
|
230
|
+
wakeAt: "",
|
|
231
|
+
completedAt,
|
|
232
|
+
updatedAt: completedAt,
|
|
233
|
+
};
|
|
234
|
+
return { kind: "complete", message, state: next, outputs: { status, url: next.url, qrUrl: next.qrUrl } };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function failedResult(state, nowMs, message) {
|
|
238
|
+
const completed = completeResult(state, nowMs, "ERROR", message);
|
|
239
|
+
return { ...completed, kind: "failed" };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function invokeFailure(state, nowMs, config, response, fallback) {
|
|
243
|
+
const errors = Number(state.consecutiveErrors || 0) + 1;
|
|
244
|
+
const message = safeText(response?.safe_summary || response?.blocked_reason || fallback || "Jenkins request failed");
|
|
245
|
+
const next = { ...state, consecutiveErrors: errors, lastError: message };
|
|
246
|
+
if (errors >= 3) return failedResult(next, nowMs, message);
|
|
247
|
+
return waitResult(next, nowMs, config.pollIntervalMs, `Jenkins 暂时不可用,稍后重试 (${errors}/3)`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Advances exactly one Jenkins remote operation. Long waits are represented as
|
|
252
|
+
* a wakeAt checkpoint; callers persist the returned state and let Scheduler
|
|
253
|
+
* re-enter the same node.
|
|
254
|
+
*/
|
|
255
|
+
export function advanceJenkinsBuild({ state, config, invoke, persistState = () => {}, nowMs = Date.now(), cancelled = false }) {
|
|
256
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
257
|
+
let current = state && typeof state === "object" ? { ...state } : null;
|
|
258
|
+
if (cancelled) return completeResult(current || { version: 1, job: config.job, createdAt: nowIso }, nowMs, "CANCELLED", "Jenkins 构建监控已取消");
|
|
259
|
+
if (current?.phase === "complete") return completeResult(current, nowMs, current.status || "ERROR", current.message || "Jenkins 构建已结束", current);
|
|
260
|
+
|
|
261
|
+
if (!current) {
|
|
262
|
+
current = {
|
|
263
|
+
version: JENKINS_BUILD_STATE_VERSION,
|
|
264
|
+
job: config.job,
|
|
265
|
+
parametersHash: hashParameters(config.parameters),
|
|
266
|
+
phase: "triggering",
|
|
267
|
+
status: "QUEUED",
|
|
268
|
+
createdAt: nowIso,
|
|
269
|
+
startedAt: nowIso,
|
|
270
|
+
deadlineAt: new Date(nowMs + config.timeoutMs).toISOString(),
|
|
271
|
+
pollCount: 0,
|
|
272
|
+
consecutiveErrors: 0,
|
|
273
|
+
updatedAt: nowIso,
|
|
274
|
+
};
|
|
275
|
+
// Persist before the non-idempotent trigger. If the process dies after the
|
|
276
|
+
// Jenkins request, a later resume reports an unknown outcome instead of
|
|
277
|
+
// accidentally triggering a duplicate build.
|
|
278
|
+
persistState(current);
|
|
279
|
+
let response;
|
|
280
|
+
try {
|
|
281
|
+
response = invoke("trigger", { job: config.job, parameters: config.parameters });
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return failedResult(current, nowMs, safeText(error?.message || error));
|
|
284
|
+
}
|
|
285
|
+
if (!response?.ok) return failedResult(current, nowMs, safeText(response?.safe_summary || response?.blocked_reason || "Jenkins trigger failed"));
|
|
286
|
+
const queueId = String(response?.resource?.queue_id || response?.resource?.queueId || "").trim();
|
|
287
|
+
if (!queueId) return failedResult(current, nowMs, "Jenkins trigger did not return queueId");
|
|
288
|
+
current = { ...current, phase: "queued", status: "QUEUED", queueId, message: `已进入 Jenkins 队列 ${queueId}`, updatedAt: nowIso };
|
|
289
|
+
return waitResult(current, nowMs, config.pollIntervalMs, current.message);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (current.phase === "triggering") {
|
|
293
|
+
return failedResult(current, nowMs, "Jenkins 触发结果未知,为避免重复构建未自动重试");
|
|
294
|
+
}
|
|
295
|
+
if (current.deadlineAt && Date.parse(current.deadlineAt) <= nowMs) {
|
|
296
|
+
return completeResult(current, nowMs, "TIMEOUT", "等待 Jenkins 构建超时");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (current.phase === "queued") {
|
|
300
|
+
let response;
|
|
301
|
+
try {
|
|
302
|
+
response = invoke("queue", { queueId: current.queueId });
|
|
303
|
+
} catch (error) {
|
|
304
|
+
response = { ok: false, safe_summary: error?.message || String(error) };
|
|
305
|
+
}
|
|
306
|
+
if (!response?.ok) return invokeFailure(current, nowMs, config, response, "Jenkins queue lookup failed");
|
|
307
|
+
const item = response?.resource?.item || {};
|
|
308
|
+
const executable = item.executable || {};
|
|
309
|
+
if (item.cancelled) return completeResult(current, nowMs, "ABORTED", "Jenkins 队列任务已取消");
|
|
310
|
+
if (executable.number != null) {
|
|
311
|
+
const buildNumber = String(executable.number);
|
|
312
|
+
const buildUrl = trimUrl(executable.url);
|
|
313
|
+
const next = {
|
|
314
|
+
...current,
|
|
315
|
+
phase: "running",
|
|
316
|
+
status: "RUNNING",
|
|
317
|
+
buildNumber,
|
|
318
|
+
buildUrl,
|
|
319
|
+
pollCount: Number(current.pollCount || 0) + 1,
|
|
320
|
+
consecutiveErrors: 0,
|
|
321
|
+
};
|
|
322
|
+
return waitResult(next, nowMs, config.pollIntervalMs, `Jenkins 构建中 · #${buildNumber}`);
|
|
323
|
+
}
|
|
324
|
+
const next = {
|
|
325
|
+
...current,
|
|
326
|
+
pollCount: Number(current.pollCount || 0) + 1,
|
|
327
|
+
consecutiveErrors: 0,
|
|
328
|
+
queueReason: safeText(item.why || ""),
|
|
329
|
+
};
|
|
330
|
+
return waitResult(next, nowMs, config.pollIntervalMs, next.queueReason || `Jenkins 排队中 · ${current.queueId}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (current.phase === "running") {
|
|
334
|
+
let response;
|
|
335
|
+
try {
|
|
336
|
+
response = invoke("build", { job: config.job, buildNumber: current.buildNumber });
|
|
337
|
+
} catch (error) {
|
|
338
|
+
response = { ok: false, safe_summary: error?.message || String(error) };
|
|
339
|
+
}
|
|
340
|
+
if (!response?.ok) return invokeFailure(current, nowMs, config, response, "Jenkins build lookup failed");
|
|
341
|
+
const build = response?.resource?.build || {};
|
|
342
|
+
const links = collectBuildLinks(build);
|
|
343
|
+
if (build.building || !build.result) {
|
|
344
|
+
const next = {
|
|
345
|
+
...current,
|
|
346
|
+
status: "RUNNING",
|
|
347
|
+
buildUrl: links.buildUrl || current.buildUrl || "",
|
|
348
|
+
pollCount: Number(current.pollCount || 0) + 1,
|
|
349
|
+
consecutiveErrors: 0,
|
|
350
|
+
};
|
|
351
|
+
return waitResult(next, nowMs, config.pollIntervalMs, `Jenkins 构建中 · #${current.buildNumber}`);
|
|
352
|
+
}
|
|
353
|
+
const status = String(build.result || "ERROR").trim().toUpperCase();
|
|
354
|
+
return completeResult(
|
|
355
|
+
{ ...current, buildNumber: String(build.number ?? current.buildNumber ?? "") },
|
|
356
|
+
nowMs,
|
|
357
|
+
status,
|
|
358
|
+
`Jenkins ${status}${current.buildNumber ? ` · #${current.buildNumber}` : ""}`,
|
|
359
|
+
links,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return failedResult(current, nowMs, `Unknown Jenkins phase: ${safeText(current.phase)}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function readJenkinsBuildState(filePath) {
|
|
367
|
+
try {
|
|
368
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
369
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
370
|
+
} catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function writeJenkinsBuildState(filePath, state) {
|
|
376
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
377
|
+
const temp = `${filePath}.tmp-${process.pid}`;
|
|
378
|
+
fs.writeFileSync(temp, JSON.stringify(state, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
379
|
+
fs.renameSync(temp, filePath);
|
|
380
|
+
}
|
package/bin/lib/locales/en.json
CHANGED
|
@@ -289,6 +289,28 @@
|
|
|
289
289
|
"displayName": "Create GitLab MR",
|
|
290
290
|
"description": "Create or reuse a GitLab merge request for the current branch and output its URL"
|
|
291
291
|
},
|
|
292
|
+
"tool_jenkins_build": {
|
|
293
|
+
"displayName": "Jenkins Build",
|
|
294
|
+
"description": "Trigger a Jenkins build and wait for completion, then output its status, artifact URL, and optional QR code",
|
|
295
|
+
"guide": {
|
|
296
|
+
"summary": "This node triggers one Jenkins job and durably checks its queue and build status. It does not keep an execution process blocked while waiting, and the flow continues automatically when the build finishes.",
|
|
297
|
+
"prerequisites": [
|
|
298
|
+
"Configure the Jenkins URL in Settings → Environment Variables; add a username and API token when authentication is required.",
|
|
299
|
+
"Confirm the exact Jenkins job name and parameter names; parameter names are case-sensitive."
|
|
300
|
+
],
|
|
301
|
+
"steps": [
|
|
302
|
+
"Set job to the Jenkins job name, such as like-android; use folder/job for jobs inside folders.",
|
|
303
|
+
"Set parameters to a JSON object; use {} for a non-parameterized build.",
|
|
304
|
+
"Leave credentialRef empty to use the default JENKINS_BASE_URL variables, or set it to mobile to use JENKINS_MOBILE_* variables.",
|
|
305
|
+
"pollInterval and timeout default to 30s and 2h and normally do not need changes."
|
|
306
|
+
],
|
|
307
|
+
"example": "JENKINS_MOBILE_BASE_URL=http://jenkinsmobile.bigo.sg:8080/\nJENKINS_MOBILE_USERNAME=your-name\nJENKINS_MOBILE_TOKEN=your-api-token\n\njob: like-android\nparameters: {}\ncredentialRef: mobile",
|
|
308
|
+
"notes": [
|
|
309
|
+
"Never put a Jenkins token in flow.yaml or parameters.",
|
|
310
|
+
"status returns SUCCESS, FAILURE, ABORTED, TIMEOUT, or CANCELLED; downstream notification nodes continue even when the build itself fails."
|
|
311
|
+
]
|
|
312
|
+
}
|
|
313
|
+
},
|
|
292
314
|
"tool_wecom_send_group_markdown": {
|
|
293
315
|
"displayName": "WeCom Group Chat Markdown",
|
|
294
316
|
"description": "Send a Markdown message to a group chat through a WeCom group robot webhook"
|
package/bin/lib/locales/zh.json
CHANGED
|
@@ -289,6 +289,28 @@
|
|
|
289
289
|
"displayName": "提交 GitLab MR",
|
|
290
290
|
"description": "为当前分支创建或复用 GitLab Merge Request,并输出 MR 链接"
|
|
291
291
|
},
|
|
292
|
+
"tool_jenkins_build": {
|
|
293
|
+
"displayName": "Jenkins 构建",
|
|
294
|
+
"description": "触发 Jenkins 构建并持续等待结果,完成后输出构建状态、产物链接和可选二维码",
|
|
295
|
+
"guide": {
|
|
296
|
+
"summary": "这个节点会触发一次 Jenkins Job,并在后台持续检查排队和构建状态。等待期间不会占用执行进程,完成后流程会自动继续。",
|
|
297
|
+
"prerequisites": [
|
|
298
|
+
"在「设置 → 环境变量」中配置 Jenkins 地址;需要登录时同时配置用户名和 API Token。",
|
|
299
|
+
"确认 Jenkins Job 名称及其参数名;参数名区分大小写。"
|
|
300
|
+
],
|
|
301
|
+
"steps": [
|
|
302
|
+
"job 填 Jenkins Job 名称,例如 like-android;如果 Job 位于文件夹中,填写 folder/job。",
|
|
303
|
+
"parameters 填 JSON object;非参数化构建填写 {}。",
|
|
304
|
+
"credentialRef 留空时使用 JENKINS_BASE_URL 等默认变量;填写 mobile 时使用 JENKINS_MOBILE_* 变量。",
|
|
305
|
+
"pollInterval 和 timeout 默认分别为 30s、2h,通常不需要修改。"
|
|
306
|
+
],
|
|
307
|
+
"example": "JENKINS_MOBILE_BASE_URL=http://jenkinsmobile.bigo.sg:8080/\nJENKINS_MOBILE_USERNAME=your-name\nJENKINS_MOBILE_TOKEN=your-api-token\n\njob: like-android\nparameters: {}\ncredentialRef: mobile",
|
|
308
|
+
"notes": [
|
|
309
|
+
"不要把 Jenkins Token 写入 flow.yaml 或 parameters。",
|
|
310
|
+
"status 会返回 SUCCESS、FAILURE、ABORTED、TIMEOUT 或 CANCELLED;即使构建失败,下游通知节点仍会继续执行。"
|
|
311
|
+
]
|
|
312
|
+
}
|
|
313
|
+
},
|
|
292
314
|
"tool_wecom_send_group_markdown": {
|
|
293
315
|
"displayName": "企业微信群聊 Markdown",
|
|
294
316
|
"description": "通过企业微信群机器人 webhook 发送 Markdown 到群聊"
|
package/bin/lib/paths.mjs
CHANGED
|
@@ -330,6 +330,7 @@ export const LOCAL_ONLY_DEFINITION_IDS = new Set([
|
|
|
330
330
|
"tool_git_worktree_load",
|
|
331
331
|
"tool_git_worktree_unload",
|
|
332
332
|
"tool_gitlab_create_mr",
|
|
333
|
+
"tool_jenkins_build",
|
|
333
334
|
"tool_wecom_send_group_markdown",
|
|
334
335
|
"tool_wecom_send_app_markdown",
|
|
335
336
|
"tool_display_share_link",
|