@fieldwangai/agentflow 0.1.154 → 0.1.157
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/flow-dsl/cli.mjs +2 -1
- package/bin/lib/flow-dsl/codegen.mjs +32 -21
- package/bin/lib/flow-dsl/legacy-yaml.mjs +47 -12
- package/bin/lib/flow-dsl/parser.mjs +24 -4
- package/bin/lib/jenkins.mjs +368 -0
- package/bin/lib/legacy-flow-execution.mjs +0 -1
- package/bin/lib/ui-server.mjs +79 -3
- package/bin/lib/workspace-preview.mjs +12 -2
- package/bin/lib/workspace-routes.mjs +117 -13
- package/bin/lib/workspace-server.mjs +407 -5
- package/builtin/nodes/tool_jenkins_build.md +5 -4
- package/builtin/web-ui/dist/assets/{WorkflowAssistantThread-CKClwj96.js → WorkflowAssistantThread-g_ljSViJ.js} +1 -1
- package/builtin/web-ui/dist/assets/index-C0iq6zHl.js +870 -0
- package/builtin/web-ui/dist/assets/index-mmXs3H9P.css +1 -0
- package/builtin/web-ui/dist/index.html +2 -2
- package/package.json +2 -1
- package/skills/agentflow-flow-dsl/SKILL.md +13 -0
- package/skills/agentflow-flow-dsl/references/node-calls.md +1 -0
- package/skills/agentflow-node-dsl/SKILL.md +27 -3
- package/skills/agentflow-node-reference/references/builtin-nodes.md +8 -0
- package/builtin/web-ui/dist/assets/index-BZ5KqLur.js +0 -870
- package/builtin/web-ui/dist/assets/index-CEXmmwM2.css +0 -1
|
@@ -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
|
+
}
|
package/bin/lib/ui-server.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { t } from "./i18n.mjs";
|
|
|
29
29
|
import {
|
|
30
30
|
PACKAGE_ROOT,
|
|
31
31
|
ARCHIVED_PIPELINES_DIR_NAME,
|
|
32
|
+
PIPELINES_DIR,
|
|
32
33
|
getAgentflowDataRoot,
|
|
33
34
|
getAgentflowSkillsRoot,
|
|
34
35
|
getAgentflowUserConfigAbs,
|
|
@@ -120,6 +121,7 @@ import { json, readBody } from "./http-util.mjs";
|
|
|
120
121
|
// 从 ui-server 拆出去的 Workspace 子系统;路由仍在下面的 startUiServer 里
|
|
121
122
|
import {
|
|
122
123
|
USER_WORKSPACES_FILENAME,
|
|
124
|
+
WORKSPACE_DEFERRED_RUN_POLL_MS,
|
|
123
125
|
WORKSPACE_SCHEDULE_POLL_MS,
|
|
124
126
|
activeWorkspaceRunUsageRecords,
|
|
125
127
|
adminWorkspaceOwnerSummary,
|
|
@@ -135,6 +137,7 @@ import {
|
|
|
135
137
|
normalizeMcpServerConfig,
|
|
136
138
|
normalizeWorkspaceScheduledRunConfig,
|
|
137
139
|
parseJsonText,
|
|
140
|
+
pollWorkspaceDeferredRuns,
|
|
138
141
|
readCursorMcpConfig,
|
|
139
142
|
readCursorMcpServers,
|
|
140
143
|
readDisplayShares,
|
|
@@ -1497,10 +1500,60 @@ function publicDisplayPayloadFromShare(root, share) {
|
|
|
1497
1500
|
body,
|
|
1498
1501
|
inputs: Array.isArray(instance.input) ? instance.input : [],
|
|
1499
1502
|
outputs: Array.isArray(instance.output) ? instance.output : [],
|
|
1503
|
+
hasConnections: (Array.isArray(graph.edges) ? graph.edges : []).some((edge) => edge?.source === id || edge?.target === id),
|
|
1500
1504
|
size: displayPageSizes[id] || workspaceSizes[id] || null,
|
|
1501
1505
|
position: displayPagePositions[id] || workspacePositions[id] || null,
|
|
1502
1506
|
};
|
|
1503
1507
|
});
|
|
1508
|
+
const groups = (Array.isArray(graph.ui?.groups) ? graph.ui.groups : [])
|
|
1509
|
+
.map((group, index) => {
|
|
1510
|
+
const declaredMemberIds = Array.from(new Set((Array.isArray(group?.nodeIds) ? group.nodeIds : [])
|
|
1511
|
+
.map((id) => String(id || "").trim())
|
|
1512
|
+
.filter((id) => nodeIds.includes(id))));
|
|
1513
|
+
const groupX = Number(group?.x);
|
|
1514
|
+
const groupY = Number(group?.y);
|
|
1515
|
+
const groupWidth = Number(group?.width);
|
|
1516
|
+
const groupHeight = Number(group?.height);
|
|
1517
|
+
const inferredMemberIds = declaredMemberIds.length > 0 || ![groupX, groupY, groupWidth, groupHeight].every(Number.isFinite)
|
|
1518
|
+
? []
|
|
1519
|
+
: nodeIds.filter((id) => {
|
|
1520
|
+
const position = workspacePositions[id];
|
|
1521
|
+
if (!position) return false;
|
|
1522
|
+
const size = workspaceSizes[id] || { width: 320, height: 96 };
|
|
1523
|
+
const centerX = Number(position.x || 0) + Math.max(1, Number(size.width) || 320) / 2;
|
|
1524
|
+
const centerY = Number(position.y || 0) + Math.max(1, Number(size.height) || 96) / 2;
|
|
1525
|
+
return centerX >= groupX && centerX <= groupX + groupWidth && centerY >= groupY && centerY <= groupY + groupHeight;
|
|
1526
|
+
});
|
|
1527
|
+
const memberIds = declaredMemberIds.length > 0 ? declaredMemberIds : inferredMemberIds;
|
|
1528
|
+
if (memberIds.length === 0) return null;
|
|
1529
|
+
const bounds = memberIds.reduce((acc, id) => {
|
|
1530
|
+
const position = displayPagePositions[id] || workspacePositions[id] || { x: 0, y: 0 };
|
|
1531
|
+
const size = displayPageSizes[id] || workspaceSizes[id] || { width: 520, height: 320 };
|
|
1532
|
+
const x = Number(position.x) || 0;
|
|
1533
|
+
const y = Number(position.y) || 0;
|
|
1534
|
+
const width = Math.max(1, Number(size.width) || 520);
|
|
1535
|
+
const height = Math.max(1, Number(size.height) || 320);
|
|
1536
|
+
return {
|
|
1537
|
+
minX: Math.min(acc.minX, x),
|
|
1538
|
+
minY: Math.min(acc.minY, y),
|
|
1539
|
+
maxX: Math.max(acc.maxX, x + width),
|
|
1540
|
+
maxY: Math.max(acc.maxY, y + height),
|
|
1541
|
+
};
|
|
1542
|
+
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
|
|
1543
|
+
const padding = 52;
|
|
1544
|
+
return {
|
|
1545
|
+
id: String(group?.id || `group_${index + 1}`),
|
|
1546
|
+
title: String(group?.title || `Group ${index + 1}`),
|
|
1547
|
+
color: String(group?.color || "purple"),
|
|
1548
|
+
nodeIds: memberIds,
|
|
1549
|
+
position: { x: bounds.minX - padding, y: bounds.minY - padding },
|
|
1550
|
+
size: {
|
|
1551
|
+
width: Math.max(240, bounds.maxX - bounds.minX + padding * 2),
|
|
1552
|
+
height: Math.max(160, bounds.maxY - bounds.minY + padding * 2),
|
|
1553
|
+
},
|
|
1554
|
+
};
|
|
1555
|
+
})
|
|
1556
|
+
.filter(Boolean);
|
|
1504
1557
|
return {
|
|
1505
1558
|
ok: true,
|
|
1506
1559
|
share: {
|
|
@@ -1524,6 +1577,7 @@ function publicDisplayPayloadFromShare(root, share) {
|
|
|
1524
1577
|
expiresInDays: share.expiresInDays == null ? null : Number(share.expiresInDays),
|
|
1525
1578
|
},
|
|
1526
1579
|
nodes,
|
|
1580
|
+
groups,
|
|
1527
1581
|
};
|
|
1528
1582
|
}
|
|
1529
1583
|
|
|
@@ -1686,9 +1740,12 @@ function isValidFlowSourceWrite(s) {
|
|
|
1686
1740
|
return s === "user" || s === "workspace";
|
|
1687
1741
|
}
|
|
1688
1742
|
|
|
1689
|
-
function cleanupExpiredWorkspacePreviews() {
|
|
1743
|
+
function cleanupExpiredWorkspacePreviews(workspaceRoot = "") {
|
|
1690
1744
|
const roots = new Set(listAgentflowUserIds().map((id) => getUserPipelinesRoot(id)));
|
|
1691
1745
|
roots.add(getUserPipelinesRoot(""));
|
|
1746
|
+
if (workspaceRoot) {
|
|
1747
|
+
roots.add(path.join(path.resolve(workspaceRoot), PIPELINES_DIR, ARCHIVED_PIPELINES_DIR_NAME));
|
|
1748
|
+
}
|
|
1692
1749
|
let removed = 0;
|
|
1693
1750
|
for (const pipelinesRoot of roots) {
|
|
1694
1751
|
for (const item of listExpiredWorkspacePreviews(pipelinesRoot)) {
|
|
@@ -4286,11 +4343,30 @@ finishedAt: "${new Date().toISOString()}"
|
|
|
4286
4343
|
log.debug(`[workspace-scheduler] initial poll failed: ${(e && e.message) || String(e)}`);
|
|
4287
4344
|
}
|
|
4288
4345
|
}, 1000).unref?.();
|
|
4346
|
+
|
|
4347
|
+
const workspaceDeferredRunTimer = setInterval(() => {
|
|
4348
|
+
try {
|
|
4349
|
+
pollWorkspaceDeferredRuns(root);
|
|
4350
|
+
} catch (e) {
|
|
4351
|
+
log.debug(`[workspace-deferred] poll failed: ${(e && e.message) || String(e)}`);
|
|
4352
|
+
}
|
|
4353
|
+
}, WORKSPACE_DEFERRED_RUN_POLL_MS);
|
|
4354
|
+
try {
|
|
4355
|
+
workspaceDeferredRunTimer.unref?.();
|
|
4356
|
+
} catch (_) {}
|
|
4357
|
+
server.on("close", () => clearInterval(workspaceDeferredRunTimer));
|
|
4358
|
+
setTimeout(() => {
|
|
4359
|
+
try {
|
|
4360
|
+
pollWorkspaceDeferredRuns(root);
|
|
4361
|
+
} catch (e) {
|
|
4362
|
+
log.debug(`[workspace-deferred] initial poll failed: ${(e && e.message) || String(e)}`);
|
|
4363
|
+
}
|
|
4364
|
+
}, 500).unref?.();
|
|
4289
4365
|
}
|
|
4290
4366
|
|
|
4291
4367
|
const workspacePreviewCleanupTimer = setInterval(() => {
|
|
4292
4368
|
try {
|
|
4293
|
-
const removed = cleanupExpiredWorkspacePreviews();
|
|
4369
|
+
const removed = cleanupExpiredWorkspacePreviews(root);
|
|
4294
4370
|
if (removed > 0) log.debug(`[workspace-preview] removed ${removed} expired preview project(s)`);
|
|
4295
4371
|
} catch (e) {
|
|
4296
4372
|
log.debug(`[workspace-preview] cleanup poll failed: ${(e && e.message) || String(e)}`);
|
|
@@ -4301,7 +4377,7 @@ finishedAt: "${new Date().toISOString()}"
|
|
|
4301
4377
|
} catch (_) {}
|
|
4302
4378
|
server.on("close", () => clearInterval(workspacePreviewCleanupTimer));
|
|
4303
4379
|
try {
|
|
4304
|
-
cleanupExpiredWorkspacePreviews();
|
|
4380
|
+
cleanupExpiredWorkspacePreviews(root);
|
|
4305
4381
|
} catch (_) {}
|
|
4306
4382
|
|
|
4307
4383
|
return new Promise((resolve, reject) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import crypto from "crypto";
|
|
4
|
-
import { getUserPipelinesRoot } from "./paths.mjs";
|
|
4
|
+
import { ARCHIVED_PIPELINES_DIR_NAME, PIPELINES_DIR, getUserPipelinesRoot } from "./paths.mjs";
|
|
5
5
|
|
|
6
6
|
export const WORKSPACE_PREVIEW_METADATA_FILENAME = ".agentflow-workspace-preview.json";
|
|
7
7
|
export const DEFAULT_WORKSPACE_PREVIEW_TTL_MS = 2 * 60 * 60 * 1000;
|
|
@@ -42,6 +42,17 @@ export function workspacePreviewFlowDir(flowId, userId = "") {
|
|
|
42
42
|
return path.join(getUserPipelinesRoot(userId), safeId);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* CLI 生成的预览要由另一个 Web 登录用户打开,所以不能放在创建者的 personal 目录。
|
|
47
|
+
* 放到 Workspace 的 archived 区域同时得到两条性质:随机链接跨账号可读,所有现有写入/
|
|
48
|
+
* 运行路由又都会按 archived 拒绝操作。
|
|
49
|
+
*/
|
|
50
|
+
export function workspaceSharedPreviewFlowDir(workspaceRoot, flowId) {
|
|
51
|
+
const safeId = safePreviewId(flowId);
|
|
52
|
+
if (!safeId) return "";
|
|
53
|
+
return path.join(path.resolve(workspaceRoot), PIPELINES_DIR, ARCHIVED_PIPELINES_DIR_NAME, safeId);
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
export function createWorkspacePreviewId() {
|
|
46
57
|
return `preview_${crypto.randomBytes(10).toString("hex")}`;
|
|
47
58
|
}
|
|
@@ -71,4 +82,3 @@ export function listExpiredWorkspacePreviews(userPipelinesRoot, now = Date.now()
|
|
|
71
82
|
}
|
|
72
83
|
return expired;
|
|
73
84
|
}
|
|
74
|
-
|