@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.
@@ -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 */
@@ -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
-