@fieldwangai/agentflow 0.1.68 → 0.1.69

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.
@@ -43,9 +43,12 @@ import {
43
43
  import { t } from "./i18n.mjs";
44
44
  import {
45
45
  PACKAGE_ROOT,
46
+ ARCHIVED_PIPELINES_DIR_NAME,
46
47
  getAgentflowDataRoot,
47
48
  getAgentflowUserConfigAbs,
48
49
  getAgentflowUserDataRoot,
50
+ getUserPipelinesRoot,
51
+ listAgentflowUserIds,
49
52
  getModelListsAbs,
50
53
  getRunDir,
51
54
  } from "./paths.mjs";
@@ -87,7 +90,7 @@ import {
87
90
  publishFlowSnippet,
88
91
  publishNodeFromInstance,
89
92
  } from "./marketplace.mjs";
90
- import { buildGitContext, inferGitRepoRootFromWorktree, loadGitWorktree, normalizeGitContext, runGit, unloadGitWorktree } from "./git-worktree.mjs";
93
+ import { buildGitContext, inferGitRepoRootFromWorktree, loadGitWorktree, normalizeGitContext, runGit, sanitizeWorktreeName, unloadGitWorktree } from "./git-worktree.mjs";
91
94
  import { createGitLabMergeRequest } from "./gitlab-mr.mjs";
92
95
  import {
93
96
  authSetupRequired,
@@ -106,6 +109,7 @@ import {
106
109
  readAdminBuiltinPipelineConfig,
107
110
  updateAdminBuiltinPipelineConfig,
108
111
  } from "./admin-builtin-pipelines.mjs";
112
+ import { readAdminStorageConfig, writeAdminStorageConfig } from "./admin-storage-config.mjs";
109
113
 
110
114
  const MIME = {
111
115
  ".html": "text/html; charset=utf-8",
@@ -1367,31 +1371,40 @@ function shouldSkipWorkspaceFileRelPath(relPath) {
1367
1371
  ));
1368
1372
  }
1369
1373
 
1374
+ const WORKSPACE_FILES_MAX_ITEMS = 500;
1375
+
1370
1376
  function readWorkspaceFilesRecursive(dir, root, depth = 0, maxDepth = 3, budget = { count: 0 }) {
1371
- if (depth > maxDepth || budget.count > 500) return [];
1377
+ if (depth > maxDepth) return [];
1378
+ if (depth > 0 && budget.count > WORKSPACE_FILES_MAX_ITEMS) return [];
1372
1379
  let entries;
1373
1380
  try {
1374
1381
  entries = fs.readdirSync(dir, { withFileTypes: true });
1375
1382
  } catch {
1376
1383
  return [];
1377
1384
  }
1385
+ entries.sort((a, b) => {
1386
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
1387
+ return a.name.localeCompare(b.name);
1388
+ });
1378
1389
  const out = [];
1379
1390
  for (const entry of entries) {
1380
- if (budget.count > 500) break;
1391
+ if (depth > 0 && budget.count > WORKSPACE_FILES_MAX_ITEMS) break;
1381
1392
  if (entry.name.startsWith(".") && entry.name !== ".agents" && entry.name !== ".codex") continue;
1382
1393
  const abs = path.join(dir, entry.name);
1383
1394
  const rel = path.relative(root, abs).replace(/\\/g, "/");
1384
1395
  if (shouldSkipWorkspaceFileRelPath(rel)) continue;
1385
1396
  if (entry.isDirectory()) {
1386
1397
  if (WORKSPACE_FILE_SKIP_DIRS.has(entry.name)) continue;
1387
- budget.count++;
1388
1398
  out.push({
1389
1399
  type: "directory",
1390
1400
  name: entry.name,
1391
1401
  path: rel,
1392
1402
  icon: workspaceFileIcon(entry.name, true),
1393
- children: readWorkspaceFilesRecursive(abs, root, depth + 1, maxDepth, budget),
1403
+ children: budget.count > WORKSPACE_FILES_MAX_ITEMS
1404
+ ? []
1405
+ : readWorkspaceFilesRecursive(abs, root, depth + 1, maxDepth, budget),
1394
1406
  });
1407
+ budget.count++;
1395
1408
  } else if (entry.isFile()) {
1396
1409
  if (WORKSPACE_FILE_SKIP_FILES.has(entry.name)) continue;
1397
1410
  const ext = path.extname(entry.name).toLowerCase();
@@ -1402,10 +1415,6 @@ function readWorkspaceFilesRecursive(dir, root, depth = 0, maxDepth = 3, budget
1402
1415
  out.push({ type: "file", name: entry.name, path: rel, icon: workspaceFileIcon(entry.name), size });
1403
1416
  }
1404
1417
  }
1405
- out.sort((a, b) => {
1406
- if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
1407
- return a.name.localeCompare(b.name);
1408
- });
1409
1418
  return out;
1410
1419
  }
1411
1420
 
@@ -1464,6 +1473,230 @@ function writeDisplayShares(shares) {
1464
1473
  fs.writeFileSync(file, JSON.stringify(shares && typeof shares === "object" ? shares : {}, null, 2) + "\n", "utf-8");
1465
1474
  }
1466
1475
 
1476
+ function countFlowYamlDirs(root) {
1477
+ try {
1478
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) return 0;
1479
+ return fs.readdirSync(root, { withFileTypes: true })
1480
+ .filter((entry) => entry.isDirectory())
1481
+ .filter((entry) => entry.name !== ARCHIVED_PIPELINES_DIR_NAME)
1482
+ .filter((entry) => fs.existsSync(path.join(root, entry.name, FLOW_YAML_FILENAME)))
1483
+ .length;
1484
+ } catch {
1485
+ return 0;
1486
+ }
1487
+ }
1488
+
1489
+ function pipelineCountsForUser(userId) {
1490
+ const pipelinesRoot = getUserPipelinesRoot(userId);
1491
+ const active = countFlowYamlDirs(pipelinesRoot);
1492
+ const archived = countFlowYamlDirs(path.join(pipelinesRoot, ARCHIVED_PIPELINES_DIR_NAME));
1493
+ return {
1494
+ active,
1495
+ archived,
1496
+ total: active + archived,
1497
+ };
1498
+ }
1499
+
1500
+ const USAGE_DAY_MS = 24 * 60 * 60 * 1000;
1501
+
1502
+ function startOfLocalDayMs(timeMs) {
1503
+ const d = new Date(Number(timeMs) || Date.now());
1504
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
1505
+ }
1506
+
1507
+ function localDayKey(timeMs) {
1508
+ const d = new Date(Number(timeMs) || Date.now());
1509
+ const y = d.getFullYear();
1510
+ const m = String(d.getMonth() + 1).padStart(2, "0");
1511
+ const day = String(d.getDate()).padStart(2, "0");
1512
+ return `${y}-${m}-${day}`;
1513
+ }
1514
+
1515
+ function runStatusBucket(status) {
1516
+ const s = String(status || "unknown");
1517
+ if (s === "success" || s === "failed" || s === "running" || s === "stopped" || s === "interrupted") return s;
1518
+ return "unknown";
1519
+ }
1520
+
1521
+ function buildUsageDailyTrend(runs, days = 14, nowMs = Date.now()) {
1522
+ const startMs = startOfLocalDayMs(nowMs) - (Math.max(1, days) - 1) * USAGE_DAY_MS;
1523
+ const rows = [];
1524
+ const byDate = new Map();
1525
+ for (let i = 0; i < days; i += 1) {
1526
+ const dateMs = startMs + i * USAGE_DAY_MS;
1527
+ const date = localDayKey(dateMs);
1528
+ const row = {
1529
+ date,
1530
+ runs: 0,
1531
+ success: 0,
1532
+ failed: 0,
1533
+ running: 0,
1534
+ stopped: 0,
1535
+ interrupted: 0,
1536
+ unknown: 0,
1537
+ users: 0,
1538
+ pipelines: 0,
1539
+ totalDurationMs: 0,
1540
+ avgDurationMs: 0,
1541
+ _userIds: new Set(),
1542
+ _pipelineKeys: new Set(),
1543
+ };
1544
+ rows.push(row);
1545
+ byDate.set(date, row);
1546
+ }
1547
+ for (const run of runs) {
1548
+ const at = Number(run?.at || 0);
1549
+ if (!Number.isFinite(at) || at < startMs) continue;
1550
+ const row = byDate.get(localDayKey(at));
1551
+ if (!row) continue;
1552
+ const bucket = runStatusBucket(run.status);
1553
+ row.runs += 1;
1554
+ row[bucket] += 1;
1555
+ row.totalDurationMs += Math.max(0, Number(run.durationMs || 0));
1556
+ row._userIds.add(String(run.userId || ""));
1557
+ row._pipelineKeys.add(`${run.userId || ""}:${run.flowSource || ""}:${run.flowId || ""}`);
1558
+ }
1559
+ return rows.map((row) => {
1560
+ row.users = row._userIds.size;
1561
+ row.pipelines = row._pipelineKeys.size;
1562
+ row.avgDurationMs = row.runs > 0 ? Math.round(row.totalDurationMs / row.runs) : 0;
1563
+ delete row._userIds;
1564
+ delete row._pipelineKeys;
1565
+ return row;
1566
+ });
1567
+ }
1568
+
1569
+ function buildUsageRates(users, runs, nowMs = Date.now()) {
1570
+ const windowDays = 7;
1571
+ const sinceMs = startOfLocalDayMs(nowMs) - (windowDays - 1) * USAGE_DAY_MS;
1572
+ const recentRuns = runs.filter((run) => Number(run?.at || 0) >= sinceMs);
1573
+ const activeUsers = new Set();
1574
+ const activePipelines = new Set();
1575
+ const statusCounts = {};
1576
+ let recentDurationMs = 0;
1577
+ for (const run of recentRuns) {
1578
+ const userId = String(run.userId || "");
1579
+ if (userId) activeUsers.add(userId);
1580
+ activePipelines.add(`${userId}:${run.flowSource || ""}:${run.flowId || ""}`);
1581
+ const bucket = runStatusBucket(run.status);
1582
+ statusCounts[bucket] = (statusCounts[bucket] || 0) + 1;
1583
+ recentDurationMs += Math.max(0, Number(run.durationMs || 0));
1584
+ }
1585
+ const totalUsers = users.length;
1586
+ const totalActivePipelines = users.reduce((sum, user) => sum + Math.max(0, Number(user?.pipelines?.active || 0)), 0);
1587
+ const completedRuns = recentRuns.length - (statusCounts.running || 0);
1588
+ const badRuns = (statusCounts.failed || 0) + (statusCounts.stopped || 0) + (statusCounts.interrupted || 0) + (statusCounts.unknown || 0);
1589
+ return {
1590
+ windowDays,
1591
+ activeUsers: activeUsers.size,
1592
+ activeUserRate: totalUsers > 0 ? activeUsers.size / totalUsers : 0,
1593
+ activePipelines: activePipelines.size,
1594
+ activePipelineRate: totalActivePipelines > 0 ? activePipelines.size / totalActivePipelines : 0,
1595
+ runs: recentRuns.length,
1596
+ avgRunsPerDay: recentRuns.length / windowDays,
1597
+ successRuns: statusCounts.success || 0,
1598
+ badRuns,
1599
+ runningRuns: statusCounts.running || 0,
1600
+ successRate: completedRuns > 0 ? (statusCounts.success || 0) / completedRuns : 0,
1601
+ failureRate: completedRuns > 0 ? badRuns / completedRuns : 0,
1602
+ avgDurationMs: recentRuns.length > 0 ? Math.round(recentDurationMs / recentRuns.length) : 0,
1603
+ };
1604
+ }
1605
+
1606
+ function buildAdminUsageDashboard(workspaceRoot) {
1607
+ const authUsers = readAuthUsers();
1608
+ const userIds = Array.from(new Set([
1609
+ ...Object.keys(authUsers || {}),
1610
+ ...listAgentflowUserIds(),
1611
+ ].map((id) => String(id || "").trim()).filter(Boolean))).sort((a, b) => a.localeCompare(b));
1612
+ const allRuns = [];
1613
+ const users = userIds.map((userId) => {
1614
+ const user = authUsers[userId] || {};
1615
+ const pipelineCounts = pipelineCountsForUser(userId);
1616
+ const runs = listRecentRunsFromDisk(workspaceRoot, {
1617
+ userId,
1618
+ includeWorkspaceRuns: false,
1619
+ includeLegacyUserRuns: false,
1620
+ });
1621
+ const statusCounts = {};
1622
+ let totalDurationMs = 0;
1623
+ for (const run of runs) {
1624
+ allRuns.push({ ...run, userId, username: user.username || userId });
1625
+ const status = String(run.status || "unknown");
1626
+ statusCounts[status] = (statusCounts[status] || 0) + 1;
1627
+ totalDurationMs += Math.max(0, Number(run.durationMs || 0));
1628
+ }
1629
+ const lastRun = runs[0] || null;
1630
+ return {
1631
+ userId,
1632
+ username: user.username || userId,
1633
+ isAdmin: Boolean(user.isAdmin),
1634
+ pipelines: pipelineCounts,
1635
+ runs: {
1636
+ total: runs.length,
1637
+ running: statusCounts.running || 0,
1638
+ success: statusCounts.success || 0,
1639
+ failed: statusCounts.failed || 0,
1640
+ stopped: statusCounts.stopped || 0,
1641
+ interrupted: statusCounts.interrupted || 0,
1642
+ unknown: statusCounts.unknown || 0,
1643
+ totalDurationMs,
1644
+ avgDurationMs: runs.length > 0 ? Math.round(totalDurationMs / runs.length) : 0,
1645
+ lastRunAt: lastRun?.at || null,
1646
+ lastRunFlowId: lastRun?.flowId || "",
1647
+ lastRunStatus: lastRun?.status || "",
1648
+ recent: runs.slice(0, 5).map((run) => ({
1649
+ flowId: run.flowId,
1650
+ flowSource: run.flowSource,
1651
+ runId: run.runId,
1652
+ at: run.at,
1653
+ endedAt: run.endedAt,
1654
+ durationMs: run.durationMs,
1655
+ status: run.status,
1656
+ })),
1657
+ },
1658
+ };
1659
+ });
1660
+ const totals = users.reduce((acc, user) => {
1661
+ acc.users += 1;
1662
+ acc.admins += user.isAdmin ? 1 : 0;
1663
+ acc.pipelines += user.pipelines.total;
1664
+ acc.activePipelines += user.pipelines.active;
1665
+ acc.archivedPipelines += user.pipelines.archived;
1666
+ acc.runs += user.runs.total;
1667
+ acc.runningRuns += user.runs.running;
1668
+ acc.successRuns += user.runs.success;
1669
+ acc.failedRuns += user.runs.failed;
1670
+ acc.stoppedRuns += user.runs.stopped;
1671
+ acc.interruptedRuns += user.runs.interrupted;
1672
+ acc.unknownRuns += user.runs.unknown;
1673
+ acc.totalDurationMs += user.runs.totalDurationMs;
1674
+ return acc;
1675
+ }, {
1676
+ users: 0,
1677
+ admins: 0,
1678
+ pipelines: 0,
1679
+ activePipelines: 0,
1680
+ archivedPipelines: 0,
1681
+ runs: 0,
1682
+ runningRuns: 0,
1683
+ successRuns: 0,
1684
+ failedRuns: 0,
1685
+ stoppedRuns: 0,
1686
+ interruptedRuns: 0,
1687
+ unknownRuns: 0,
1688
+ totalDurationMs: 0,
1689
+ });
1690
+ totals.avgDurationMs = totals.runs > 0 ? Math.round(totals.totalDurationMs / totals.runs) : 0;
1691
+ return {
1692
+ generatedAt: new Date().toISOString(),
1693
+ totals,
1694
+ usage: buildUsageRates(users, allRuns),
1695
+ dailyTrend: buildUsageDailyTrend(allRuns, 14),
1696
+ users,
1697
+ };
1698
+ }
1699
+
1467
1700
  function createDisplayShareId() {
1468
1701
  return crypto.randomBytes(12).toString("base64url");
1469
1702
  }
@@ -1629,6 +1862,44 @@ function mergeWorkspaceRunGraph(currentGraph, runGraph, touchedIds) {
1629
1862
  };
1630
1863
  }
1631
1864
 
1865
+ function mergeWorkspacePersistentNodeRefs(incomingGraph, currentGraph) {
1866
+ const incoming = normalizeWorkspaceGraphPayload(incomingGraph || {});
1867
+ const current = normalizeWorkspaceGraphPayload(currentGraph || {});
1868
+ const instances = { ...(incoming.instances || {}) };
1869
+ for (const [id, currentInstance] of Object.entries(current.instances || {})) {
1870
+ const nextInstance = instances[id];
1871
+ if (!nextInstance || typeof nextInstance !== "object") continue;
1872
+ for (const key of ["scriptRef", "implementationRef", "implementationMode"]) {
1873
+ const currentValue = currentInstance?.[key];
1874
+ const nextValue = nextInstance?.[key];
1875
+ if (currentValue != null && String(currentValue).trim() && (nextValue == null || !String(nextValue).trim())) {
1876
+ nextInstance[key] = currentValue;
1877
+ }
1878
+ }
1879
+ }
1880
+ return { ...incoming, instances };
1881
+ }
1882
+
1883
+ function hydrateWorkspaceNodeRefsFromFiles(scopedRoot, graph) {
1884
+ const next = normalizeWorkspaceGraphPayload(graph || {});
1885
+ const instances = { ...(next.instances || {}) };
1886
+ let changed = false;
1887
+ for (const [nodeId, instance] of Object.entries(instances)) {
1888
+ if (!instance || typeof instance !== "object") continue;
1889
+ const defId = String(instance.definitionId || "");
1890
+ if (defId === "workspace_run" || defId === "workspace_scheduled_run") continue;
1891
+ if (!String(instance.implementationRef || "").trim()) {
1892
+ const implementationRef = workspaceDefaultImplementationRef(nodeId);
1893
+ const abs = workspaceResolveFlowFile(scopedRoot, implementationRef, "implementationRef");
1894
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
1895
+ instances[nodeId] = { ...instance, implementationRef };
1896
+ changed = true;
1897
+ }
1898
+ }
1899
+ }
1900
+ return changed ? { ...next, instances } : next;
1901
+ }
1902
+
1632
1903
  function resolveWorkspaceScopeRoot(workspaceRoot, params = {}, opts = {}) {
1633
1904
  const flowId = params.flowId != null ? String(params.flowId).trim() : "";
1634
1905
  if (!flowId) return { root: path.resolve(workspaceRoot), flowId: "", flowSource: "", archived: false };
@@ -2214,6 +2485,45 @@ function workspaceResolvePath(baseCwd, raw) {
2214
2485
  return path.isAbsolute(text) ? path.resolve(text) : path.resolve(baseCwd, text);
2215
2486
  }
2216
2487
 
2488
+ function workspaceShellQuote(value) {
2489
+ return "'" + String(value ?? "").replace(/'/g, "'\\''") + "'";
2490
+ }
2491
+
2492
+ function workspaceSafeFlowRelPath(raw, fieldName = "path") {
2493
+ const text = String(raw || "").trim().replace(/^["']|["']$/g, "");
2494
+ if (!text) return "";
2495
+ if (text.length > 260) throw new Error(`${fieldName} is too long`);
2496
+ if (/[\r\n<>]/.test(text)) throw new Error(`${fieldName} contains invalid characters`);
2497
+ if (/^(?:https?:|data:|blob:|file:|javascript:|mailto:|tel:)/i.test(text)) {
2498
+ throw new Error(`${fieldName} must be a relative file path`);
2499
+ }
2500
+ if (path.isAbsolute(text)) throw new Error(`${fieldName} must be relative`);
2501
+ const normalized = path.posix.normalize(text.replace(/\\/g, "/")).replace(/^\/+/, "");
2502
+ if (!normalized || normalized === "." || normalized.startsWith("../") || normalized.includes("/../")) {
2503
+ throw new Error(`${fieldName} escapes workspace root`);
2504
+ }
2505
+ return normalized;
2506
+ }
2507
+
2508
+ function workspaceResolveFlowFile(scopedRoot, relPath, fieldName = "path") {
2509
+ const clean = workspaceSafeFlowRelPath(relPath, fieldName);
2510
+ if (!clean) return "";
2511
+ const root = path.resolve(scopedRoot);
2512
+ const abs = path.resolve(root, ...clean.split("/"));
2513
+ const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
2514
+ if (abs !== root && !abs.startsWith(rootWithSep)) {
2515
+ throw new Error(`${fieldName} escapes workspace root`);
2516
+ }
2517
+ return abs;
2518
+ }
2519
+
2520
+ function workspaceReadTextFileIfExists(absPath, maxChars = 60000) {
2521
+ const file = String(absPath || "").trim();
2522
+ if (!file || !fs.existsSync(file) || !fs.statSync(file).isFile()) return "";
2523
+ const raw = fs.readFileSync(file, "utf-8");
2524
+ return raw.length > maxChars ? `${raw.slice(0, maxChars)}\n...[truncated ${raw.length - maxChars} chars]` : raw;
2525
+ }
2526
+
2217
2527
  function workspaceSanitizeRepoDirName(repoUrl) {
2218
2528
  const raw = String(repoUrl || "").trim().replace(/\.git$/i, "");
2219
2529
  const last = raw.split(/[/:]/).filter(Boolean).pop() || "repo";
@@ -2500,7 +2810,7 @@ function workspaceRunPlan(graph, runNodeId, scopedRoot = "") {
2500
2810
  const addNeeded = (id) => {
2501
2811
  if (!id || needed.has(id)) return;
2502
2812
  const defId = String(instances[id]?.definitionId || "");
2503
- if (id !== target && defId === "workspace_run") {
2813
+ if (id !== target && (defId === "workspace_run" || defId === "workspace_scheduled_run")) {
2504
2814
  pauseNodeIds.add(id);
2505
2815
  return;
2506
2816
  }
@@ -2726,6 +3036,296 @@ function workspacePromptUpstreamText(upstreamText, runPackage = {}) {
2726
3036
  return upstreamText;
2727
3037
  }
2728
3038
 
3039
+ function workspaceImplementationInlineText(instance) {
3040
+ const candidates = [instance?.implementation, instance?.implementationPlan];
3041
+ for (const item of candidates) {
3042
+ if (item == null) continue;
3043
+ if (typeof item === "string" && item.trim()) return item.trim();
3044
+ if (typeof item === "object" && !Array.isArray(item)) {
3045
+ const content = item.content ?? item.body ?? item.notes ?? "";
3046
+ if (String(content || "").trim()) return String(content).trim();
3047
+ }
3048
+ }
3049
+ return "";
3050
+ }
3051
+
3052
+ function workspaceImplementationBlock(instance, scopedRoot) {
3053
+ const parts = [];
3054
+ const implementationRef = String(instance?.implementationRef || "").trim();
3055
+ if (implementationRef) {
3056
+ const abs = workspaceResolveFlowFile(scopedRoot, implementationRef, "implementationRef");
3057
+ const body = workspaceReadTextFileIfExists(abs, 60000);
3058
+ if (body.trim()) {
3059
+ parts.push(`### ${implementationRef}\n\n${body.trim()}`);
3060
+ }
3061
+ }
3062
+ const inline = workspaceImplementationInlineText(instance);
3063
+ if (inline) parts.push(`### Inline implementation\n\n${inline}`);
3064
+ if (!parts.length) return "";
3065
+ const mode = String(instance?.implementationMode || "").trim();
3066
+ return [
3067
+ "## 参考实现方案",
3068
+ "",
3069
+ mode ? `mode: ${mode}` : "",
3070
+ "",
3071
+ ...parts,
3072
+ "",
3073
+ "以上方案用于加速本次执行。若发现过期,可以按当前任务修正执行,但最终结果必须以当前输入为准。",
3074
+ ].filter((line) => line !== "").join("\n");
3075
+ }
3076
+
3077
+ function workspaceSafeNodeFileName(nodeId) {
3078
+ const text = String(nodeId || "").trim().replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "");
3079
+ return text || "node";
3080
+ }
3081
+
3082
+ function workspaceDefaultImplementationRef(nodeId) {
3083
+ return `nodes/${workspaceSafeNodeFileName(nodeId)}/implementation.md`;
3084
+ }
3085
+
3086
+ function workspaceImplementationModeForInstance(instance) {
3087
+ const explicit = String(instance?.implementationMode || "").trim();
3088
+ if (explicit) return explicit;
3089
+ return String(instance?.definitionId || "") === "tool_nodejs" ? "script" : "steps";
3090
+ }
3091
+
3092
+ function workspaceClipImplementationText(value, maxChars = 2400) {
3093
+ const text = String(value ?? "").trim();
3094
+ if (!text) return "";
3095
+ return text.length > maxChars ? `${text.slice(0, maxChars)}\n...[truncated ${text.length - maxChars} chars]` : text;
3096
+ }
3097
+
3098
+ function workspaceUniqueImplementationList(items = [], maxItems = 12) {
3099
+ const out = [];
3100
+ const seen = new Set();
3101
+ for (const item of items) {
3102
+ const text = String(item || "").trim();
3103
+ if (!text) continue;
3104
+ const key = text.toLowerCase();
3105
+ if (seen.has(key)) continue;
3106
+ seen.add(key);
3107
+ out.push(text);
3108
+ if (out.length >= maxItems) break;
3109
+ }
3110
+ return out;
3111
+ }
3112
+
3113
+ function workspaceImplementationArtifactCandidates(structured = {}, result = "") {
3114
+ const candidates = [
3115
+ structured.resultFile,
3116
+ structured.result,
3117
+ result,
3118
+ ...Object.values(structured.outParams || {}),
3119
+ ];
3120
+ return workspaceUniqueImplementationList(candidates, 10)
3121
+ .filter((item) => /^(?:outputs|nodes|artifacts)\//.test(item) && !/[\r\n]/.test(item));
3122
+ }
3123
+
3124
+ function workspaceReadImplementationArtifact(scopedRoot, structured = {}, result = "") {
3125
+ const root = String(scopedRoot || "").trim();
3126
+ if (!root) return null;
3127
+ for (const rel of workspaceImplementationArtifactCandidates(structured, result)) {
3128
+ let abs = "";
3129
+ try {
3130
+ abs = workspaceResolveFlowFile(root, rel, "resultFile");
3131
+ } catch {
3132
+ continue;
3133
+ }
3134
+ if (!abs || !fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
3135
+ const ext = path.extname(abs).toLowerCase();
3136
+ const content = workspaceReadTextFileIfExists(abs, 120000);
3137
+ if (!content.trim()) continue;
3138
+ return { rel, abs, ext, content };
3139
+ }
3140
+ return null;
3141
+ }
3142
+
3143
+ function workspaceImplementationArtifactContext(scopedRoot, structured = {}, result = "") {
3144
+ const artifact = workspaceReadImplementationArtifact(scopedRoot, structured, result);
3145
+ if (!artifact) return "无可读取产物文件。";
3146
+ return [
3147
+ `产物路径:${artifact.rel}`,
3148
+ `文件类型:${artifact.ext || "(unknown)"}`,
3149
+ "",
3150
+ "产物内容:",
3151
+ "```",
3152
+ workspaceClipImplementationText(artifact.content, 50000),
3153
+ "```",
3154
+ ].join("\n");
3155
+ }
3156
+
3157
+ function workspaceBuildImplementationPrompt(instance, nodeId, opts = {}) {
3158
+ const defId = String(instance?.definitionId || "").trim();
3159
+ const label = String(instance?.label || nodeId || "node").trim();
3160
+ const mode = workspaceImplementationModeForInstance(instance);
3161
+ const inputValues = opts.inputValues || {};
3162
+ const structured = opts.structured && typeof opts.structured === "object" ? opts.structured : {};
3163
+ const result = String(opts.resultContent || structured.result || "").trim();
3164
+ const task = workspaceResolveBodyPlaceholders(instance?.body || "", inputValues).trim();
3165
+ const scriptRef = String(instance?.scriptRef || "").trim();
3166
+ const inlineScript = String(instance?.script || "").trim();
3167
+ const previousImplementation = opts.previousImplementation
3168
+ ? workspaceClipImplementationText(opts.previousImplementation, 12000)
3169
+ : "";
3170
+ return [
3171
+ "你要为 AgentFlow 的一个节点写“实现方案”Markdown。这个文件会在下次运行同一个节点前作为上下文给模型参考。",
3172
+ "",
3173
+ "要求:",
3174
+ "- 必须基于实际任务、输入和产物内容自己总结,不要写流水账,不要写“使用 Agent 生成输出”这类空话。",
3175
+ "- 写清楚这次结果到底是如何实现的:核心思路、产物结构、关键文件/路径、关键样式/函数/数据结构、可复用约定。",
3176
+ "- 写清楚下次如果要继续迭代,应该从哪里改、哪些约定不能破坏。",
3177
+ "- 如果产物是 HTML/UI,必须总结页面模块、视觉风格、关键 class/token、交互点和下游展示契约。",
3178
+ "- 如果产物是脚本,必须总结脚本入口、环境变量、输入输出协议和错误处理方式。",
3179
+ "- 只输出 Markdown 正文,不要输出代码围栏包裹整篇,不要解释你在总结。",
3180
+ "",
3181
+ "## 节点信息",
3182
+ "",
3183
+ `nodeId: ${nodeId}`,
3184
+ `label: ${label}`,
3185
+ `definitionId: ${defId || "(unknown)"}`,
3186
+ `mode: ${mode}`,
3187
+ scriptRef ? `scriptRef: ${scriptRef}` : "",
3188
+ inlineScript ? `inlineScript: ${workspaceClipImplementationText(inlineScript, 1200)}` : "",
3189
+ "",
3190
+ "## 当前任务",
3191
+ "",
3192
+ workspaceClipImplementationText(task || instance?.body || scriptRef || inlineScript || "(无显式任务)", 6000),
3193
+ "",
3194
+ "## 输入",
3195
+ "",
3196
+ JSON.stringify(inputValues || {}, null, 2),
3197
+ "",
3198
+ "## 输出",
3199
+ "",
3200
+ JSON.stringify({
3201
+ result: structured.result || result,
3202
+ resultFile: structured.resultFile || "",
3203
+ outParams: structured.outParams || {},
3204
+ }, null, 2),
3205
+ "",
3206
+ previousImplementation ? "## 上一版实现方案" : "",
3207
+ previousImplementation || "",
3208
+ previousImplementation ? "" : "",
3209
+ "## 实际产物上下文",
3210
+ "",
3211
+ workspaceImplementationArtifactContext(opts.scopedRoot, structured, result),
3212
+ ].filter((line) => line !== "").join("\n");
3213
+ }
3214
+
3215
+ async function workspaceGenerateImplementationMarkdown({
3216
+ scopedRoot,
3217
+ nodeId,
3218
+ instance,
3219
+ inputValues,
3220
+ resultContent,
3221
+ structured,
3222
+ implementationPath,
3223
+ previousImplementation,
3224
+ runPackage,
3225
+ modelKey,
3226
+ userCtx,
3227
+ emit,
3228
+ onActiveChild,
3229
+ }) {
3230
+ const prompt = workspaceBuildImplementationPrompt(instance, nodeId, {
3231
+ scopedRoot,
3232
+ inputValues,
3233
+ resultContent,
3234
+ structured,
3235
+ previousImplementation,
3236
+ });
3237
+ let content = "";
3238
+ let lastAssistant = "";
3239
+ let resultText = "";
3240
+ emit?.({ type: "status", line: "Summarize implementation plan with model" });
3241
+ const handle = startComposerAgent({
3242
+ uiWorkspaceRoot: scopedRoot,
3243
+ cliWorkspace: runPackage?.nodeRunDir || scopedRoot,
3244
+ prompt,
3245
+ modelKey,
3246
+ agentflowUserId: userCtx?.userId || "",
3247
+ extraEnv: runtimeEnvForUser(userCtx, {
3248
+ AGENTFLOW_IMPLEMENTATION_REF: implementationPath || "",
3249
+ AGENTFLOW_NODE_RUN_DIR: runPackage?.nodeRunDir || "",
3250
+ AGENTFLOW_NODE_TMP_DIR: runPackage?.nodeTmpDir || "",
3251
+ AGENTFLOW_OUTPUTS_DIR: runPackage?.outputsDir || "",
3252
+ }),
3253
+ onStreamEvent: (ev) => {
3254
+ if (ev?.type === "natural" && ev.kind === "assistant" && typeof ev.text === "string") {
3255
+ lastAssistant = ev.text;
3256
+ content += (content ? "\n" : "") + ev.text;
3257
+ } else if (ev?.type === "natural" && ev.kind === "result" && typeof ev.text === "string") {
3258
+ resultText = ev.text;
3259
+ }
3260
+ },
3261
+ onToolCall: (subtype, toolName) => {
3262
+ const sub = subtype ? String(subtype) : "";
3263
+ const tool = toolName ? String(toolName) : "";
3264
+ emit?.({ type: "status", line: `总结方案工具 ${tool || "thinking"}${sub ? ` (${sub})` : ""}` });
3265
+ },
3266
+ });
3267
+ if (typeof onActiveChild === "function") onActiveChild(handle.child || null);
3268
+ try {
3269
+ await handle.finished;
3270
+ } finally {
3271
+ if (typeof onActiveChild === "function") onActiveChild(null);
3272
+ }
3273
+ const markdown = String(resultText || lastAssistant || content || "").trim();
3274
+ return markdown.replace(/^```(?:markdown|md)?\s*/i, "").replace(/```\s*$/i, "").trim();
3275
+ }
3276
+
3277
+ async function workspacePersistNodeImplementation(scopedRoot, graph, nodeId, opts = {}) {
3278
+ const current = graph?.instances?.[nodeId];
3279
+ if (!current || String(current.definitionId || "") === "workspace_run" || String(current.definitionId || "") === "workspace_scheduled_run") {
3280
+ return { changed: false, wrote: false, instance: current };
3281
+ }
3282
+ const existingRef = String(current.implementationRef || "").trim();
3283
+ const implementationRef = existingRef || workspaceDefaultImplementationRef(nodeId);
3284
+ const abs = workspaceResolveFlowFile(scopedRoot, implementationRef, "implementationRef");
3285
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
3286
+ const previousImplementation = fs.existsSync(abs) && fs.statSync(abs).isFile()
3287
+ ? workspaceReadTextFileIfExists(abs, 60000)
3288
+ : "";
3289
+ const markdown = await workspaceGenerateImplementationMarkdown({
3290
+ scopedRoot,
3291
+ nodeId,
3292
+ instance: current,
3293
+ inputValues: opts.inputValues || {},
3294
+ resultContent: opts.resultContent || "",
3295
+ structured: opts.structured || {},
3296
+ implementationPath: implementationRef,
3297
+ previousImplementation,
3298
+ runPackage: opts.runPackage,
3299
+ modelKey: opts.modelKey || "",
3300
+ userCtx: opts.userCtx || {},
3301
+ emit: opts.emit,
3302
+ onActiveChild: opts.onActiveChild,
3303
+ });
3304
+ if (!markdown.trim()) throw new Error(`Implementation summary is empty for node ${nodeId}`);
3305
+ fs.writeFileSync(abs, markdown.trimEnd() + "\n", "utf-8");
3306
+ const explicitMode = String(current.implementationMode || "").trim();
3307
+ const next = {
3308
+ ...current,
3309
+ implementationRef,
3310
+ ...(explicitMode ? { implementationMode: explicitMode } : {}),
3311
+ };
3312
+ const changed = String(current.implementationRef || "") !== implementationRef;
3313
+ return { changed, wrote: true, instance: next, implementationRef };
3314
+ }
3315
+
3316
+ async function workspaceTryPersistNodeImplementation(scopedRoot, graph, nodeId, opts = {}) {
3317
+ try {
3318
+ return await workspacePersistNodeImplementation(scopedRoot, graph, nodeId, opts);
3319
+ } catch (e) {
3320
+ opts.emit?.({
3321
+ type: "natural",
3322
+ kind: "warning",
3323
+ text: `实现方案未更新:${e?.message || String(e)}`,
3324
+ });
3325
+ return { changed: false, wrote: false, instance: graph?.instances?.[nodeId] };
3326
+ }
3327
+ }
3328
+
2729
3329
  function parseWorkspaceSkillKeys(raw) {
2730
3330
  const text = String(raw || "").trim();
2731
3331
  if (!text) return [];
@@ -2859,6 +3459,7 @@ function workspaceWriteDisplayContent(instance, content) {
2859
3459
  const unwrapped = workspaceUnwrapOutputEnvelopeForDisplay(content);
2860
3460
  const text = kind === "html" ? normalizeHtmlDisplayContent(unwrapped) : String(unwrapped || "");
2861
3461
  const primaryName = kind === "image" ? "src" : "content";
3462
+ next.displayReloadKey = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
2862
3463
  next.body = text;
2863
3464
  next.input = (Array.isArray(next.input) ? next.input : []).map((slot) => (
2864
3465
  String(slot?.name || "") === primaryName || String(slot?.type || "") === "text"
@@ -2898,7 +3499,7 @@ function workspaceUpdateDirectDisplays(graph, sourceId, content, outputs = null)
2898
3499
  return updated;
2899
3500
  }
2900
3501
 
2901
- function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock, mcpBlock = "", inputValues = {}, nodeTmpDir = "") {
3502
+ function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock, mcpBlock = "", inputValues = {}, nodeTmpDir = "", implementationBlock = "") {
2902
3503
  const instance = graph.instances[nodeId] || {};
2903
3504
  const body = workspaceResolveBodyPlaceholders(instance.body || "", inputValues).trim();
2904
3505
  const { values: relevantInputValues, placeholders } = workspaceRelevantInputValues(instance.body || "", inputValues);
@@ -2911,6 +3512,7 @@ function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock, mcpBlock
2911
3512
  fileBoundary ? `\n${fileBoundary}` : "",
2912
3513
  inputBlock ? `\n${inputBlock}` : "",
2913
3514
  placeholders.size ? "\n任务只显式引用了上面的输入槽;其它未被 `${...}` 引用的已连接业务输入不要作为分析依据。" : "",
3515
+ implementationBlock ? `\n${implementationBlock}` : "",
2914
3516
  skillsBlock ? `\n## 可用能力\n\n${skillsBlock}` : "",
2915
3517
  mcpBlock ? `\n## 可用 MCP\n\n${mcpBlock}` : "",
2916
3518
  upstreamText ? `\n## 上游正文\n\n${upstreamText}` : "",
@@ -2919,13 +3521,38 @@ function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock, mcpBlock
2919
3521
  ].filter(Boolean).join("\n");
2920
3522
  }
2921
3523
 
2922
- function workspaceDefaultWorktreeRoot(scopedRoot) {
2923
- return path.join(path.resolve(scopedRoot), ".workspace", "agentflow", "worktrees");
3524
+ function workspaceDefaultGitRepoRoot(scopedRoot, _userCtx = {}) {
3525
+ return path.join(path.resolve(scopedRoot), ".workspace", "agentflow", "git-repos");
2924
3526
  }
2925
3527
 
2926
- function workspaceShouldAutoCleanupWorktree(scopedRoot, worktreePath, hasExplicitWorktreePath) {
2927
- if (hasExplicitWorktreePath || !worktreePath) return false;
2928
- return workspacePathInside(workspaceDefaultWorktreeRoot(scopedRoot), worktreePath);
3528
+ function workspaceDefaultWorktreePath(runTmpRoot, nodeId, repoPath, branch = "") {
3529
+ const repoRoot = path.resolve(repoPath);
3530
+ const repoName = sanitizeWorktreeName(path.basename(repoRoot));
3531
+ const branchName = String(branch || "").trim();
3532
+ let refLabel = branchName;
3533
+ if (!refLabel) {
3534
+ const currentBranch = runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot);
3535
+ if (currentBranch.status === 0 && currentBranch.stdout.trim() && currentBranch.stdout.trim() !== "HEAD") {
3536
+ refLabel = currentBranch.stdout.trim();
3537
+ }
3538
+ }
3539
+ if (!refLabel) {
3540
+ const currentCommit = runGit(["rev-parse", "HEAD"], repoRoot);
3541
+ refLabel = currentCommit.status === 0 && currentCommit.stdout.trim()
3542
+ ? currentCommit.stdout.trim().slice(0, 12)
3543
+ : "HEAD";
3544
+ }
3545
+ return path.join(
3546
+ path.resolve(runTmpRoot),
3547
+ "worktrees",
3548
+ workspaceSanitizeTmpSegment(nodeId, "node"),
3549
+ repoName,
3550
+ sanitizeWorktreeName(refLabel),
3551
+ );
3552
+ }
3553
+
3554
+ function workspaceShouldAutoCleanupWorktree(worktreePath, hasExplicitWorktreePath) {
3555
+ return Boolean(worktreePath) && !hasExplicitWorktreePath;
2929
3556
  }
2930
3557
 
2931
3558
  function workspaceTrackAutoCleanupWorktree(list, item) {
@@ -2961,7 +3588,7 @@ function workspaceCleanupAutoWorktrees(list, graph, emit) {
2961
3588
  const result = unloadGitWorktree({
2962
3589
  repoPath: entry.repoPath,
2963
3590
  worktreePath: entry.worktreePath,
2964
- force: false,
3591
+ force: true,
2965
3592
  prune: true,
2966
3593
  });
2967
3594
  emit({
@@ -3118,6 +3745,146 @@ function workspaceCleanupTmpRoot(runTmpRoot, userCtx = {}, emit = () => {}) {
3118
3745
  }
3119
3746
  }
3120
3747
 
3748
+ function workspaceOutputFileRefsForNode(instance) {
3749
+ const refs = {};
3750
+ const slots = Array.isArray(instance?.output) ? instance.output : [];
3751
+ for (let index = 0; index < slots.length; index += 1) {
3752
+ const slot = slots[index];
3753
+ const name = String(slot?.name || "").trim();
3754
+ const type = String(slot?.type || "");
3755
+ if (!name || type === "node" || name === "next" || name === "prev") continue;
3756
+ const key = name === "content" || index === 0 ? "result" : name;
3757
+ const safe = workspaceSanitizeTmpSegment(key, "result");
3758
+ refs[key] = `outputs/${safe}.txt`;
3759
+ }
3760
+ if (!refs.result) refs.result = "outputs/result.txt";
3761
+ return refs;
3762
+ }
3763
+
3764
+ function workspaceResolveScriptCommandText(script, values = {}) {
3765
+ return String(script || "").replace(/\$\{([^}]+)\}/g, (_, key) => {
3766
+ const name = String(key || "").trim();
3767
+ return workspaceShellQuote(Object.prototype.hasOwnProperty.call(values, name) ? values[name] : "");
3768
+ });
3769
+ }
3770
+
3771
+ function workspaceDefaultScriptCommand(scriptAbs) {
3772
+ const ext = path.extname(scriptAbs).toLowerCase();
3773
+ if (ext === ".js" || ext === ".mjs" || ext === ".cjs") return `node ${workspaceShellQuote(scriptAbs)}`;
3774
+ if (ext === ".sh" || ext === ".bash") return `bash ${workspaceShellQuote(scriptAbs)}`;
3775
+ if (ext === ".py") return `python3 ${workspaceShellQuote(scriptAbs)}`;
3776
+ return workspaceShellQuote(scriptAbs);
3777
+ }
3778
+
3779
+ function workspaceEnvelopeFromOutputFiles(outputRefs, nodeRunDir) {
3780
+ const entries = Object.entries(outputRefs || {})
3781
+ .map(([name, rel]) => {
3782
+ const abs = path.resolve(nodeRunDir, rel);
3783
+ const nodeRootWithSep = nodeRunDir.endsWith(path.sep) ? nodeRunDir : `${nodeRunDir}${path.sep}`;
3784
+ if (abs !== nodeRunDir && !abs.startsWith(nodeRootWithSep)) return null;
3785
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null;
3786
+ return { name, rel };
3787
+ })
3788
+ .filter(Boolean);
3789
+ if (entries.length === 0) return "";
3790
+ const result = entries.find((entry) => entry.name === "result") || entries[0];
3791
+ const outParams = entries.filter((entry) => entry !== result);
3792
+ return [
3793
+ "---agentflow",
3794
+ `resultFile: ${result.rel}`,
3795
+ outParams.length ? "outParams:" : "",
3796
+ ...outParams.map((entry) => ` ${entry.name}File: ${entry.rel}`),
3797
+ "---end",
3798
+ ].filter(Boolean).join("\n");
3799
+ }
3800
+
3801
+ async function workspaceRunToolNodejsScript({
3802
+ scopedRoot,
3803
+ cwd,
3804
+ instance,
3805
+ inputValues,
3806
+ runPackage,
3807
+ userCtx,
3808
+ emit,
3809
+ }) {
3810
+ const scriptRef = String(instance?.scriptRef || "").trim();
3811
+ const scriptAbs = scriptRef ? workspaceResolveFlowFile(scopedRoot, scriptRef, "scriptRef") : "";
3812
+ if (scriptAbs && (!fs.existsSync(scriptAbs) || !fs.statSync(scriptAbs).isFile())) {
3813
+ throw new Error(`scriptRef not found: ${scriptRef}`);
3814
+ }
3815
+ const outputRefs = workspaceOutputFileRefsForNode(instance);
3816
+ const outputAbs = Object.fromEntries(
3817
+ Object.entries(outputRefs).map(([key, rel]) => [key, path.resolve(runPackage.nodeRunDir, rel)]),
3818
+ );
3819
+ for (const abs of Object.values(outputAbs)) fs.mkdirSync(path.dirname(abs), { recursive: true });
3820
+ const constants = {
3821
+ workspaceRoot: path.resolve(scopedRoot),
3822
+ pipelineWorkspace: path.resolve(scopedRoot),
3823
+ flowDir: path.resolve(scopedRoot),
3824
+ cwd: path.resolve(cwd || scopedRoot),
3825
+ nodeRunDir: runPackage.nodeRunDir,
3826
+ nodeTmpDir: runPackage.nodeTmpDir,
3827
+ outputsDir: runPackage.outputsDir,
3828
+ scriptRef: scriptAbs,
3829
+ ...inputValues,
3830
+ ...outputRefs,
3831
+ };
3832
+ const inlineScript = String(instance?.script || "").trim();
3833
+ const command = inlineScript
3834
+ ? workspaceResolveScriptCommandText(inlineScript, constants)
3835
+ : scriptAbs
3836
+ ? workspaceDefaultScriptCommand(scriptAbs)
3837
+ : "";
3838
+ if (!command) throw new Error("tool_nodejs requires script or scriptRef");
3839
+
3840
+ emit?.({ type: "status", line: `Run script: ${scriptRef || command.slice(0, 120)}` });
3841
+
3842
+ const env = runtimeEnvForUser(userCtx, {
3843
+ AGENTFLOW_WORKSPACE_ROOT: path.resolve(scopedRoot),
3844
+ AGENTFLOW_NODE_RUN_DIR: runPackage.nodeRunDir,
3845
+ AGENTFLOW_NODE_TMP_DIR: runPackage.nodeTmpDir,
3846
+ AGENTFLOW_OUTPUTS_DIR: runPackage.outputsDir,
3847
+ AGENTFLOW_SCRIPT_REF: scriptAbs,
3848
+ AGENTFLOW_INPUTS_JSON: JSON.stringify(inputValues || {}),
3849
+ AGENTFLOW_OUTPUTS_JSON: JSON.stringify(outputRefs),
3850
+ AGENTFLOW_OUTPUTS_ABS_JSON: JSON.stringify(outputAbs),
3851
+ });
3852
+
3853
+ const started = Date.now();
3854
+ return await new Promise((resolve, reject) => {
3855
+ const child = spawn(command, [], {
3856
+ cwd: runPackage.nodeRunDir,
3857
+ shell: true,
3858
+ stdio: ["ignore", "pipe", "pipe"],
3859
+ env,
3860
+ });
3861
+ let stdout = "";
3862
+ let stderr = "";
3863
+ child.stdout.setEncoding("utf-8");
3864
+ child.stderr.setEncoding("utf-8");
3865
+ child.stdout.on("data", (chunk) => {
3866
+ stdout += String(chunk);
3867
+ });
3868
+ child.stderr.on("data", (chunk) => {
3869
+ stderr += String(chunk);
3870
+ });
3871
+ child.on("error", reject);
3872
+ child.on("close", (code) => {
3873
+ if (stderr.trim()) {
3874
+ emit?.({ type: "natural", kind: "warning", text: `[script stderr]\n${stderr.trim().slice(-4000)}` });
3875
+ }
3876
+ if (code !== 0) {
3877
+ reject(new Error(`tool_nodejs script exited ${code}${stderr.trim() ? `: ${stderr.trim().slice(-800)}` : ""}`));
3878
+ return;
3879
+ }
3880
+ const elapsedMs = Math.max(0, Date.now() - started);
3881
+ emit?.({ type: "status", line: `Timing script: ${elapsedMs}ms`, timing: { label: "script", elapsedMs } });
3882
+ const content = stdout.trim() || workspaceEnvelopeFromOutputFiles(outputRefs, runPackage.nodeRunDir);
3883
+ resolve(content);
3884
+ });
3885
+ });
3886
+ }
3887
+
3121
3888
  async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts = {}) {
3122
3889
  const graph = normalizeWorkspaceGraphPayload(payload.graph || {});
3123
3890
  const runNodeId = String(payload?.runNodeId || "").trim();
@@ -3190,7 +3957,7 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3190
3957
  const defId = String(instance.definitionId || "");
3191
3958
  emit({ type: "node-start", nodeId, definitionId: defId });
3192
3959
 
3193
- if (defId === "workspace_run") {
3960
+ if (defId === "workspace_run" || defId === "workspace_scheduled_run") {
3194
3961
  continue;
3195
3962
  }
3196
3963
 
@@ -3297,7 +4064,7 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3297
4064
  const targetRaw = workspaceSlotValue(workspaceSlotByName(instance, "targetDir")).trim();
3298
4065
  const targetDir = targetRaw
3299
4066
  ? workspaceResolvePath(cwd, targetRaw)
3300
- : path.join(scopedRoot, ".workspace", "agentflow", "git-repos", workspaceSanitizeRepoDirName(repoUrl));
4067
+ : path.join(workspaceDefaultGitRepoRoot(scopedRoot, userCtx), workspaceSanitizeRepoDirName(repoUrl));
3301
4068
  const pullIfExists = workspaceBoolSlot(instance, "pullIfExists", true);
3302
4069
  const includeSubmodules = workspaceBoolSlot(instance, "includeSubmodules", false);
3303
4070
  fs.mkdirSync(path.dirname(targetDir), { recursive: true });
@@ -3369,14 +4136,16 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3369
4136
  const worktreeInputSlot = (Array.isArray(instance.input) ? instance.input : [])
3370
4137
  .find((slot) => String(slot?.name || "") === "worktreePath") || null;
3371
4138
  const rawWorktreePath = workspaceSlotValue(worktreeInputSlot || workspaceSlotByName(instance, "worktreePath")).trim();
3372
- const worktreePath = rawWorktreePath ? workspaceResolvePath(cwd, rawWorktreePath) : (gitContext?.worktreePath ? path.resolve(gitContext.worktreePath) : "");
4139
+ const worktreePath = rawWorktreePath
4140
+ ? workspaceResolvePath(cwd, rawWorktreePath)
4141
+ : (gitContext?.worktreePath ? path.resolve(gitContext.worktreePath) : workspaceDefaultWorktreePath(runTmpRoot, nodeId, repoPath, branch));
3373
4142
  const hasExplicitWorktreePath = Boolean(rawWorktreePath) || Boolean(gitContext?.worktreePath);
3374
4143
  const previousCwd = cwd;
3375
4144
  const force = ["true", "1", "yes", "on"].includes(workspaceSlotValue(workspaceSlotByName(instance, "force")).trim().toLowerCase());
3376
4145
  const pruneMissingRaw = workspaceSlotValue(workspaceSlotByName(instance, "pruneMissing")).trim().toLowerCase();
3377
4146
  const pruneMissing = pruneMissingRaw !== "false";
3378
4147
  const result = loadGitWorktree({ repoPath, branch, worktreePath, pipelineWorkspace: scopedRoot, force, pruneMissing });
3379
- if (workspaceShouldAutoCleanupWorktree(scopedRoot, result.worktreePath, hasExplicitWorktreePath)) {
4148
+ if (workspaceShouldAutoCleanupWorktree(result.worktreePath, hasExplicitWorktreePath)) {
3380
4149
  workspaceTrackAutoCleanupWorktree(autoCleanupWorktrees, {
3381
4150
  nodeId,
3382
4151
  repoPath: result.repoRoot,
@@ -3480,6 +4249,51 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3480
4249
  continue;
3481
4250
  }
3482
4251
 
4252
+ if (defId === "tool_nodejs") {
4253
+ const prepareStartedAt = Date.now();
4254
+ const inputValues = workspaceInputValues(graph, nodeId, outputs);
4255
+ const runPackage = workspaceCreateNodeRunPackage(runTmpRoot, nodeId, {
4256
+ scopedRoot,
4257
+ cwd,
4258
+ task: String(instance.script || instance.scriptRef || instance.body || "").trim(),
4259
+ inputValues,
4260
+ });
4261
+ const runtimeInputValues = { ...inputValues, ...(runPackage.inputValues || {}) };
4262
+ emitTiming(nodeId, "prepare-script", prepareStartedAt, {
4263
+ inputCount: Object.keys(runtimeInputValues || {}).length,
4264
+ nodeRunDir: runPackage.nodeRunDir,
4265
+ });
4266
+ const content = await workspaceRunToolNodejsScript({
4267
+ scopedRoot,
4268
+ cwd,
4269
+ instance,
4270
+ inputValues: runtimeInputValues,
4271
+ runPackage,
4272
+ userCtx,
4273
+ emit: (event) => emit({ ...event, nodeId }),
4274
+ });
4275
+ const normalizedAgentOutput = workspacePublishAgentOutputFiles(workspaceStructuredAgentOutput(content), runPackage);
4276
+ const resultContent = normalizedAgentOutput.result || content;
4277
+ outputs.set(nodeId, resultContent);
4278
+ const slotUpdate = workspaceApplyAgentOutputSlots(instance, normalizedAgentOutput);
4279
+ if (slotUpdate.changed) graph.instances[nodeId] = slotUpdate.instance;
4280
+ const implementationUpdate = await workspaceTryPersistNodeImplementation(scopedRoot, graph, nodeId, {
4281
+ inputValues: runtimeInputValues,
4282
+ resultContent,
4283
+ structured: normalizedAgentOutput,
4284
+ runPackage,
4285
+ modelKey,
4286
+ userCtx,
4287
+ emit: (event) => emit({ ...event, nodeId }),
4288
+ onActiveChild: opts.onActiveChild,
4289
+ });
4290
+ if (implementationUpdate.changed) graph.instances[nodeId] = implementationUpdate.instance;
4291
+ const updatedDisplays = workspaceUpdateDirectDisplays(graph, nodeId, resultContent, outputs);
4292
+ if (slotUpdate.changed || implementationUpdate.changed || updatedDisplays.length) emit({ type: "graph", nodeId, displayNodeIds: updatedDisplays, graph });
4293
+ emit({ type: "node-done", nodeId, definitionId: defId });
4294
+ continue;
4295
+ }
4296
+
3483
4297
  const prepareStartedAt = Date.now();
3484
4298
  const inputValues = workspaceInputValues(graph, nodeId, outputs);
3485
4299
  const relevantInputs = workspaceRelevantInputValues(instance.body || "", inputValues);
@@ -3506,7 +4320,8 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3506
4320
  } catch {
3507
4321
  // Best-effort debug artifact only.
3508
4322
  }
3509
- const prompt = workspaceNodePrompt(graph, nodeId, promptUpstreamText, promptSkillsBlock, promptMcpBlock, runtimeInputValues, runPackage);
4323
+ const implementationBlock = workspaceImplementationBlock(instance, scopedRoot);
4324
+ const prompt = workspaceNodePrompt(graph, nodeId, promptUpstreamText, promptSkillsBlock, promptMcpBlock, runtimeInputValues, runPackage, implementationBlock);
3510
4325
  try {
3511
4326
  fs.writeFileSync(path.join(runPackage.nodeRunDir, "prompt.md"), prompt.trimEnd() + "\n", "utf-8");
3512
4327
  } catch {
@@ -3592,8 +4407,19 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3592
4407
  outputs.set(nodeId, resultContent);
3593
4408
  const slotUpdate = workspaceApplyAgentOutputSlots(instance, normalizedAgentOutput);
3594
4409
  if (slotUpdate.changed) graph.instances[nodeId] = slotUpdate.instance;
4410
+ const implementationUpdate = await workspaceTryPersistNodeImplementation(scopedRoot, graph, nodeId, {
4411
+ inputValues: runtimeInputValues,
4412
+ resultContent,
4413
+ structured: normalizedAgentOutput,
4414
+ runPackage,
4415
+ modelKey,
4416
+ userCtx,
4417
+ emit: (event) => emit({ ...event, nodeId }),
4418
+ onActiveChild: opts.onActiveChild,
4419
+ });
4420
+ if (implementationUpdate.changed) graph.instances[nodeId] = implementationUpdate.instance;
3595
4421
  const updatedDisplays = workspaceUpdateDirectDisplays(graph, nodeId, resultContent, outputs);
3596
- if (slotUpdate.changed || updatedDisplays.length) emit({ type: "graph", nodeId, displayNodeIds: updatedDisplays, graph });
4422
+ if (slotUpdate.changed || implementationUpdate.changed || updatedDisplays.length) emit({ type: "graph", nodeId, displayNodeIds: updatedDisplays, graph });
3597
4423
  emit({ type: "node-done", nodeId, definitionId: defId });
3598
4424
  }
3599
4425
  } finally {
@@ -4112,6 +4938,46 @@ export function startUiServer({
4112
4938
  }
4113
4939
  }
4114
4940
 
4941
+ if (url.pathname === "/api/admin/storage-config") {
4942
+ if (!authUser?.isAdmin) {
4943
+ json(res, 403, { error: "Admin permission required" });
4944
+ return;
4945
+ }
4946
+ if (req.method === "GET") {
4947
+ json(res, 200, { config: readAdminStorageConfig() });
4948
+ return;
4949
+ }
4950
+ if (req.method === "POST") {
4951
+ let payload;
4952
+ try {
4953
+ payload = JSON.parse(await readBody(req));
4954
+ } catch {
4955
+ json(res, 400, { error: "Invalid JSON body" });
4956
+ return;
4957
+ }
4958
+ try {
4959
+ const config = writeAdminStorageConfig(payload?.config || payload || {});
4960
+ json(res, 200, { ok: true, config });
4961
+ } catch (e) {
4962
+ json(res, 400, { error: (e && e.message) || String(e) });
4963
+ }
4964
+ return;
4965
+ }
4966
+ }
4967
+
4968
+ if (req.method === "GET" && url.pathname === "/api/admin/usage-dashboard") {
4969
+ if (!authUser?.isAdmin) {
4970
+ json(res, 403, { error: "Admin permission required" });
4971
+ return;
4972
+ }
4973
+ try {
4974
+ json(res, 200, buildAdminUsageDashboard(root));
4975
+ } catch (e) {
4976
+ json(res, 500, { error: (e && e.message) || String(e) });
4977
+ }
4978
+ return;
4979
+ }
4980
+
4115
4981
  if (url.pathname === "/api/admin/user-allowlist") {
4116
4982
  if (!authUser?.isAdmin) {
4117
4983
  json(res, 403, { error: "Admin permission required" });
@@ -4408,9 +5274,10 @@ export function startUiServer({
4408
5274
  return;
4409
5275
  }
4410
5276
  const { path: graphPath, graph } = readWorkspaceGraph(scoped.root);
5277
+ const hydratedGraph = hydrateWorkspaceNodeRefsFromFiles(scoped.root, graph);
4411
5278
  json(res, 200, {
4412
5279
  ok: true,
4413
- graph,
5280
+ graph: hydratedGraph,
4414
5281
  path: graphPath,
4415
5282
  root: scoped.root,
4416
5283
  flowId: scoped.flowId,
@@ -4497,8 +5364,10 @@ export function startUiServer({
4497
5364
  json(res, 400, { error: "Cannot write workspace graph for builtin or archived pipeline" });
4498
5365
  return;
4499
5366
  }
4500
- const graph = normalizeWorkspaceGraphPayload(payload.graph || payload);
5367
+ const submittedGraph = normalizeWorkspaceGraphPayload(payload.graph || payload);
4501
5368
  const graphPath = workspaceGraphPath(scoped.root);
5369
+ const currentGraph = hydrateWorkspaceNodeRefsFromFiles(scoped.root, readWorkspaceGraph(scoped.root).graph);
5370
+ const graph = mergeWorkspacePersistentNodeRefs(submittedGraph, currentGraph);
4502
5371
  fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2) + "\n", "utf-8");
4503
5372
  json(res, 200, { ok: true, path: graphPath, graph });
4504
5373
  } catch (e) {