@fieldwangai/agentflow 0.1.67 → 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,
@@ -97,6 +100,7 @@ import {
97
100
  isAuthUserAllowed,
98
101
  loginOrCreateUser,
99
102
  logoutRequest,
103
+ readAuthUsers,
100
104
  readUserAllowlist,
101
105
  writeUserAllowlist,
102
106
  } from "./auth.mjs";
@@ -105,6 +109,7 @@ import {
105
109
  readAdminBuiltinPipelineConfig,
106
110
  updateAdminBuiltinPipelineConfig,
107
111
  } from "./admin-builtin-pipelines.mjs";
112
+ import { readAdminStorageConfig, writeAdminStorageConfig } from "./admin-storage-config.mjs";
108
113
 
109
114
  const MIME = {
110
115
  ".html": "text/html; charset=utf-8",
@@ -257,8 +262,38 @@ function createFeedbackItem(payload, user) {
257
262
  };
258
263
  }
259
264
 
260
- function skillCollectionsAbs(userCtx = {}) {
261
- return path.join(getAgentflowUserDataRoot(userCtx.userId), SKILL_COLLECTIONS_FILENAME);
265
+ function skillCollectionsAbs() {
266
+ return path.join(getAgentflowDataRoot(), "admin", SKILL_COLLECTIONS_FILENAME);
267
+ }
268
+
269
+ function legacyAdminSkillCollectionPaths() {
270
+ const users = readAuthUsers();
271
+ return Object.entries(users || {})
272
+ .filter(([, user]) => user?.isAdmin)
273
+ .map(([userId, user]) => path.join(getAgentflowUserDataRoot(user.userId || userId), SKILL_COLLECTIONS_FILENAME));
274
+ }
275
+
276
+ function readSkillCollectionFile(filePath) {
277
+ try {
278
+ if (!fs.existsSync(filePath)) return null;
279
+ const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
280
+ return data && typeof data === "object" && !Array.isArray(data) ? data : null;
281
+ } catch {
282
+ return null;
283
+ }
284
+ }
285
+
286
+ function mergeSkillCollectionConfigs(configs = []) {
287
+ const merged = [];
288
+ const seen = new Set();
289
+ for (const config of configs) {
290
+ for (const collection of normalizeSkillCollectionConfig(config).collections) {
291
+ if (!collection.id || seen.has(collection.id)) continue;
292
+ seen.add(collection.id);
293
+ merged.push(collection);
294
+ }
295
+ }
296
+ return { version: 1, collections: merged };
262
297
  }
263
298
 
264
299
  function slugifySkillCollectionId(name, fallback = "collection") {
@@ -382,8 +417,12 @@ function withBuiltinSkillCollections(config, availableSkills = []) {
382
417
  function readSkillCollectionConfig(userCtx = {}, availableSkills = []) {
383
418
  const p = skillCollectionsAbs(userCtx);
384
419
  try {
385
- if (!fs.existsSync(p)) return withBuiltinSkillCollections({}, availableSkills);
386
- return withBuiltinSkillCollections(JSON.parse(fs.readFileSync(p, "utf-8")), availableSkills);
420
+ const globalConfig = readSkillCollectionFile(p);
421
+ if (globalConfig) return withBuiltinSkillCollections(globalConfig, availableSkills);
422
+ const legacyConfigs = legacyAdminSkillCollectionPaths()
423
+ .map((legacyPath) => readSkillCollectionFile(legacyPath))
424
+ .filter(Boolean);
425
+ return withBuiltinSkillCollections(mergeSkillCollectionConfigs(legacyConfigs), availableSkills);
387
426
  } catch {
388
427
  return withBuiltinSkillCollections({}, availableSkills);
389
428
  }
@@ -1332,31 +1371,40 @@ function shouldSkipWorkspaceFileRelPath(relPath) {
1332
1371
  ));
1333
1372
  }
1334
1373
 
1374
+ const WORKSPACE_FILES_MAX_ITEMS = 500;
1375
+
1335
1376
  function readWorkspaceFilesRecursive(dir, root, depth = 0, maxDepth = 3, budget = { count: 0 }) {
1336
- if (depth > maxDepth || budget.count > 500) return [];
1377
+ if (depth > maxDepth) return [];
1378
+ if (depth > 0 && budget.count > WORKSPACE_FILES_MAX_ITEMS) return [];
1337
1379
  let entries;
1338
1380
  try {
1339
1381
  entries = fs.readdirSync(dir, { withFileTypes: true });
1340
1382
  } catch {
1341
1383
  return [];
1342
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
+ });
1343
1389
  const out = [];
1344
1390
  for (const entry of entries) {
1345
- if (budget.count > 500) break;
1391
+ if (depth > 0 && budget.count > WORKSPACE_FILES_MAX_ITEMS) break;
1346
1392
  if (entry.name.startsWith(".") && entry.name !== ".agents" && entry.name !== ".codex") continue;
1347
1393
  const abs = path.join(dir, entry.name);
1348
1394
  const rel = path.relative(root, abs).replace(/\\/g, "/");
1349
1395
  if (shouldSkipWorkspaceFileRelPath(rel)) continue;
1350
1396
  if (entry.isDirectory()) {
1351
1397
  if (WORKSPACE_FILE_SKIP_DIRS.has(entry.name)) continue;
1352
- budget.count++;
1353
1398
  out.push({
1354
1399
  type: "directory",
1355
1400
  name: entry.name,
1356
1401
  path: rel,
1357
1402
  icon: workspaceFileIcon(entry.name, true),
1358
- 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),
1359
1406
  });
1407
+ budget.count++;
1360
1408
  } else if (entry.isFile()) {
1361
1409
  if (WORKSPACE_FILE_SKIP_FILES.has(entry.name)) continue;
1362
1410
  const ext = path.extname(entry.name).toLowerCase();
@@ -1367,10 +1415,6 @@ function readWorkspaceFilesRecursive(dir, root, depth = 0, maxDepth = 3, budget
1367
1415
  out.push({ type: "file", name: entry.name, path: rel, icon: workspaceFileIcon(entry.name), size });
1368
1416
  }
1369
1417
  }
1370
- out.sort((a, b) => {
1371
- if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
1372
- return a.name.localeCompare(b.name);
1373
- });
1374
1418
  return out;
1375
1419
  }
1376
1420
 
@@ -1408,6 +1452,7 @@ function readWorkspaceGraph(workspaceRoot) {
1408
1452
  }
1409
1453
 
1410
1454
  const DISPLAY_SHARE_FILENAME = "display-shares.json";
1455
+ const DISPLAY_SHARE_TTL_MS = 24 * 60 * 60 * 1000;
1411
1456
 
1412
1457
  function displaySharesPath() {
1413
1458
  return path.join(getAgentflowDataRoot(), DISPLAY_SHARE_FILENAME);
@@ -1428,10 +1473,254 @@ function writeDisplayShares(shares) {
1428
1473
  fs.writeFileSync(file, JSON.stringify(shares && typeof shares === "object" ? shares : {}, null, 2) + "\n", "utf-8");
1429
1474
  }
1430
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
+
1431
1700
  function createDisplayShareId() {
1432
1701
  return crypto.randomBytes(12).toString("base64url");
1433
1702
  }
1434
1703
 
1704
+ function displayShareExpiresAt(now = new Date()) {
1705
+ const time = now instanceof Date ? now.getTime() : Date.now();
1706
+ return new Date(time + DISPLAY_SHARE_TTL_MS).toISOString();
1707
+ }
1708
+
1709
+ function isDisplayShareExpired(share) {
1710
+ const expiresAt = Date.parse(String(share?.expiresAt || ""));
1711
+ return Number.isFinite(expiresAt) && expiresAt <= Date.now();
1712
+ }
1713
+
1714
+ function getDisplayShareOrExpired(id) {
1715
+ const shares = readDisplayShares();
1716
+ const share = shares[id];
1717
+ if (!share) return { shares, share: null, expired: false };
1718
+ if (!isDisplayShareExpired(share)) return { shares, share, expired: false };
1719
+ delete shares[id];
1720
+ writeDisplayShares(shares);
1721
+ return { shares, share: null, expired: true };
1722
+ }
1723
+
1435
1724
  function normalizeDisplayShareNodeIds(ids, graph) {
1436
1725
  const out = [];
1437
1726
  const seen = new Set();
@@ -1519,6 +1808,7 @@ function publicDisplayPayloadFromShare(root, share) {
1519
1808
  : null,
1520
1809
  createdAt: share.createdAt || "",
1521
1810
  updatedAt: share.updatedAt || "",
1811
+ expiresAt: share.expiresAt || "",
1522
1812
  },
1523
1813
  nodes,
1524
1814
  };
@@ -1572,6 +1862,44 @@ function mergeWorkspaceRunGraph(currentGraph, runGraph, touchedIds) {
1572
1862
  };
1573
1863
  }
1574
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
+
1575
1903
  function resolveWorkspaceScopeRoot(workspaceRoot, params = {}, opts = {}) {
1576
1904
  const flowId = params.flowId != null ? String(params.flowId).trim() : "";
1577
1905
  if (!flowId) return { root: path.resolve(workspaceRoot), flowId: "", flowSource: "", archived: false };
@@ -2157,6 +2485,45 @@ function workspaceResolvePath(baseCwd, raw) {
2157
2485
  return path.isAbsolute(text) ? path.resolve(text) : path.resolve(baseCwd, text);
2158
2486
  }
2159
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
+
2160
2527
  function workspaceSanitizeRepoDirName(repoUrl) {
2161
2528
  const raw = String(repoUrl || "").trim().replace(/\.git$/i, "");
2162
2529
  const last = raw.split(/[/:]/).filter(Boolean).pop() || "repo";
@@ -2443,7 +2810,7 @@ function workspaceRunPlan(graph, runNodeId, scopedRoot = "") {
2443
2810
  const addNeeded = (id) => {
2444
2811
  if (!id || needed.has(id)) return;
2445
2812
  const defId = String(instances[id]?.definitionId || "");
2446
- if (id !== target && defId === "workspace_run") {
2813
+ if (id !== target && (defId === "workspace_run" || defId === "workspace_scheduled_run")) {
2447
2814
  pauseNodeIds.add(id);
2448
2815
  return;
2449
2816
  }
@@ -2669,6 +3036,296 @@ function workspacePromptUpstreamText(upstreamText, runPackage = {}) {
2669
3036
  return upstreamText;
2670
3037
  }
2671
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
+
2672
3329
  function parseWorkspaceSkillKeys(raw) {
2673
3330
  const text = String(raw || "").trim();
2674
3331
  if (!text) return [];
@@ -2802,6 +3459,7 @@ function workspaceWriteDisplayContent(instance, content) {
2802
3459
  const unwrapped = workspaceUnwrapOutputEnvelopeForDisplay(content);
2803
3460
  const text = kind === "html" ? normalizeHtmlDisplayContent(unwrapped) : String(unwrapped || "");
2804
3461
  const primaryName = kind === "image" ? "src" : "content";
3462
+ next.displayReloadKey = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
2805
3463
  next.body = text;
2806
3464
  next.input = (Array.isArray(next.input) ? next.input : []).map((slot) => (
2807
3465
  String(slot?.name || "") === primaryName || String(slot?.type || "") === "text"
@@ -2841,7 +3499,7 @@ function workspaceUpdateDirectDisplays(graph, sourceId, content, outputs = null)
2841
3499
  return updated;
2842
3500
  }
2843
3501
 
2844
- function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock, mcpBlock = "", inputValues = {}, nodeTmpDir = "") {
3502
+ function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock, mcpBlock = "", inputValues = {}, nodeTmpDir = "", implementationBlock = "") {
2845
3503
  const instance = graph.instances[nodeId] || {};
2846
3504
  const body = workspaceResolveBodyPlaceholders(instance.body || "", inputValues).trim();
2847
3505
  const { values: relevantInputValues, placeholders } = workspaceRelevantInputValues(instance.body || "", inputValues);
@@ -2854,6 +3512,7 @@ function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock, mcpBlock
2854
3512
  fileBoundary ? `\n${fileBoundary}` : "",
2855
3513
  inputBlock ? `\n${inputBlock}` : "",
2856
3514
  placeholders.size ? "\n任务只显式引用了上面的输入槽;其它未被 `${...}` 引用的已连接业务输入不要作为分析依据。" : "",
3515
+ implementationBlock ? `\n${implementationBlock}` : "",
2857
3516
  skillsBlock ? `\n## 可用能力\n\n${skillsBlock}` : "",
2858
3517
  mcpBlock ? `\n## 可用 MCP\n\n${mcpBlock}` : "",
2859
3518
  upstreamText ? `\n## 上游正文\n\n${upstreamText}` : "",
@@ -2862,13 +3521,38 @@ function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock, mcpBlock
2862
3521
  ].filter(Boolean).join("\n");
2863
3522
  }
2864
3523
 
2865
- function workspaceDefaultWorktreeRoot(scopedRoot) {
2866
- 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");
2867
3526
  }
2868
3527
 
2869
- function workspaceShouldAutoCleanupWorktree(scopedRoot, worktreePath, hasExplicitWorktreePath) {
2870
- if (hasExplicitWorktreePath || !worktreePath) return false;
2871
- 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;
2872
3556
  }
2873
3557
 
2874
3558
  function workspaceTrackAutoCleanupWorktree(list, item) {
@@ -2904,7 +3588,7 @@ function workspaceCleanupAutoWorktrees(list, graph, emit) {
2904
3588
  const result = unloadGitWorktree({
2905
3589
  repoPath: entry.repoPath,
2906
3590
  worktreePath: entry.worktreePath,
2907
- force: false,
3591
+ force: true,
2908
3592
  prune: true,
2909
3593
  });
2910
3594
  emit({
@@ -3061,6 +3745,146 @@ function workspaceCleanupTmpRoot(runTmpRoot, userCtx = {}, emit = () => {}) {
3061
3745
  }
3062
3746
  }
3063
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
+
3064
3888
  async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts = {}) {
3065
3889
  const graph = normalizeWorkspaceGraphPayload(payload.graph || {});
3066
3890
  const runNodeId = String(payload?.runNodeId || "").trim();
@@ -3133,7 +3957,7 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3133
3957
  const defId = String(instance.definitionId || "");
3134
3958
  emit({ type: "node-start", nodeId, definitionId: defId });
3135
3959
 
3136
- if (defId === "workspace_run") {
3960
+ if (defId === "workspace_run" || defId === "workspace_scheduled_run") {
3137
3961
  continue;
3138
3962
  }
3139
3963
 
@@ -3240,7 +4064,7 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3240
4064
  const targetRaw = workspaceSlotValue(workspaceSlotByName(instance, "targetDir")).trim();
3241
4065
  const targetDir = targetRaw
3242
4066
  ? workspaceResolvePath(cwd, targetRaw)
3243
- : path.join(scopedRoot, ".workspace", "agentflow", "git-repos", workspaceSanitizeRepoDirName(repoUrl));
4067
+ : path.join(workspaceDefaultGitRepoRoot(scopedRoot, userCtx), workspaceSanitizeRepoDirName(repoUrl));
3244
4068
  const pullIfExists = workspaceBoolSlot(instance, "pullIfExists", true);
3245
4069
  const includeSubmodules = workspaceBoolSlot(instance, "includeSubmodules", false);
3246
4070
  fs.mkdirSync(path.dirname(targetDir), { recursive: true });
@@ -3312,14 +4136,16 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3312
4136
  const worktreeInputSlot = (Array.isArray(instance.input) ? instance.input : [])
3313
4137
  .find((slot) => String(slot?.name || "") === "worktreePath") || null;
3314
4138
  const rawWorktreePath = workspaceSlotValue(worktreeInputSlot || workspaceSlotByName(instance, "worktreePath")).trim();
3315
- 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));
3316
4142
  const hasExplicitWorktreePath = Boolean(rawWorktreePath) || Boolean(gitContext?.worktreePath);
3317
4143
  const previousCwd = cwd;
3318
4144
  const force = ["true", "1", "yes", "on"].includes(workspaceSlotValue(workspaceSlotByName(instance, "force")).trim().toLowerCase());
3319
4145
  const pruneMissingRaw = workspaceSlotValue(workspaceSlotByName(instance, "pruneMissing")).trim().toLowerCase();
3320
4146
  const pruneMissing = pruneMissingRaw !== "false";
3321
4147
  const result = loadGitWorktree({ repoPath, branch, worktreePath, pipelineWorkspace: scopedRoot, force, pruneMissing });
3322
- if (workspaceShouldAutoCleanupWorktree(scopedRoot, result.worktreePath, hasExplicitWorktreePath)) {
4148
+ if (workspaceShouldAutoCleanupWorktree(result.worktreePath, hasExplicitWorktreePath)) {
3323
4149
  workspaceTrackAutoCleanupWorktree(autoCleanupWorktrees, {
3324
4150
  nodeId,
3325
4151
  repoPath: result.repoRoot,
@@ -3423,6 +4249,51 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3423
4249
  continue;
3424
4250
  }
3425
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
+
3426
4297
  const prepareStartedAt = Date.now();
3427
4298
  const inputValues = workspaceInputValues(graph, nodeId, outputs);
3428
4299
  const relevantInputs = workspaceRelevantInputValues(instance.body || "", inputValues);
@@ -3449,7 +4320,8 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3449
4320
  } catch {
3450
4321
  // Best-effort debug artifact only.
3451
4322
  }
3452
- 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);
3453
4325
  try {
3454
4326
  fs.writeFileSync(path.join(runPackage.nodeRunDir, "prompt.md"), prompt.trimEnd() + "\n", "utf-8");
3455
4327
  } catch {
@@ -3535,8 +4407,19 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3535
4407
  outputs.set(nodeId, resultContent);
3536
4408
  const slotUpdate = workspaceApplyAgentOutputSlots(instance, normalizedAgentOutput);
3537
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;
3538
4421
  const updatedDisplays = workspaceUpdateDirectDisplays(graph, nodeId, resultContent, outputs);
3539
- 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 });
3540
4423
  emit({ type: "node-done", nodeId, definitionId: defId });
3541
4424
  }
3542
4425
  } finally {
@@ -3927,9 +4810,9 @@ export function startUiServer({
3927
4810
  json(res, 400, { error: "Missing display share id" });
3928
4811
  return;
3929
4812
  }
3930
- const share = readDisplayShares()[id];
4813
+ const { share, expired } = getDisplayShareOrExpired(id);
3931
4814
  if (!share) {
3932
- json(res, 404, { error: "Display share not found" });
4815
+ json(res, expired ? 410 : 404, { error: expired ? "Display share has expired" : "Display share not found" });
3933
4816
  return;
3934
4817
  }
3935
4818
  const payload = publicDisplayPayloadFromShare(root, share);
@@ -3951,9 +4834,9 @@ export function startUiServer({
3951
4834
  json(res, 400, { error: "Missing display share id" });
3952
4835
  return;
3953
4836
  }
3954
- const share = readDisplayShares()[id];
4837
+ const { share, expired } = getDisplayShareOrExpired(id);
3955
4838
  if (!share) {
3956
- json(res, 404, { error: "Display share not found" });
4839
+ json(res, expired ? 410 : 404, { error: expired ? "Display share has expired" : "Display share not found" });
3957
4840
  return;
3958
4841
  }
3959
4842
  const scoped = resolveWorkspaceScopeRoot(root, {
@@ -4055,6 +4938,46 @@ export function startUiServer({
4055
4938
  }
4056
4939
  }
4057
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
+
4058
4981
  if (url.pathname === "/api/admin/user-allowlist") {
4059
4982
  if (!authUser?.isAdmin) {
4060
4983
  json(res, 403, { error: "Admin permission required" });
@@ -4351,9 +5274,10 @@ export function startUiServer({
4351
5274
  return;
4352
5275
  }
4353
5276
  const { path: graphPath, graph } = readWorkspaceGraph(scoped.root);
5277
+ const hydratedGraph = hydrateWorkspaceNodeRefsFromFiles(scoped.root, graph);
4354
5278
  json(res, 200, {
4355
5279
  ok: true,
4356
- graph,
5280
+ graph: hydratedGraph,
4357
5281
  path: graphPath,
4358
5282
  root: scoped.root,
4359
5283
  flowId: scoped.flowId,
@@ -4394,7 +5318,8 @@ export function startUiServer({
4394
5318
  const shares = readDisplayShares();
4395
5319
  let id = createDisplayShareId();
4396
5320
  while (shares[id]) id = createDisplayShareId();
4397
- const now = new Date().toISOString();
5321
+ const nowDate = new Date();
5322
+ const now = nowDate.toISOString();
4398
5323
  const share = {
4399
5324
  id,
4400
5325
  userId: authUser.userId,
@@ -4402,10 +5327,11 @@ export function startUiServer({
4402
5327
  flowSource: scoped.flowSource || "user",
4403
5328
  archived: scoped.archived === true,
4404
5329
  title: String(payload.title || "").trim() || "AgentFlow Display",
4405
- layout: ["canvas", "gallery", "slides", "document"].includes(String(payload.layout || "")) ? String(payload.layout) : "canvas",
5330
+ layout: ["canvas", "gallery", "slides", "document", "single"].includes(String(payload.layout || "")) ? String(payload.layout) : "canvas",
4406
5331
  nodeIds,
4407
5332
  createdAt: now,
4408
5333
  updatedAt: now,
5334
+ expiresAt: displayShareExpiresAt(nowDate),
4409
5335
  };
4410
5336
  shares[id] = share;
4411
5337
  writeDisplayShares(shares);
@@ -4438,8 +5364,10 @@ export function startUiServer({
4438
5364
  json(res, 400, { error: "Cannot write workspace graph for builtin or archived pipeline" });
4439
5365
  return;
4440
5366
  }
4441
- const graph = normalizeWorkspaceGraphPayload(payload.graph || payload);
5367
+ const submittedGraph = normalizeWorkspaceGraphPayload(payload.graph || payload);
4442
5368
  const graphPath = workspaceGraphPath(scoped.root);
5369
+ const currentGraph = hydrateWorkspaceNodeRefsFromFiles(scoped.root, readWorkspaceGraph(scoped.root).graph);
5370
+ const graph = mergeWorkspacePersistentNodeRefs(submittedGraph, currentGraph);
4443
5371
  fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2) + "\n", "utf-8");
4444
5372
  json(res, 200, { ok: true, path: graphPath, graph });
4445
5373
  } catch (e) {
@@ -5481,6 +6409,10 @@ export function startUiServer({
5481
6409
  json(res, 400, { error: "Missing skill slug or collection" });
5482
6410
  return;
5483
6411
  }
6412
+ if (payload?.collection && !authUser?.isAdmin) {
6413
+ json(res, 403, { error: "Admin required" });
6414
+ return;
6415
+ }
5484
6416
  const beforeSkills = payload?.collection ? listComposerSkills(PACKAGE_ROOT, root) : [];
5485
6417
  const result = await runSkillhub(args, { cwd: root, timeoutMs: 180_000, maxBuffer: 4 * 1024 * 1024 });
5486
6418
  if (!result.ok) {
@@ -5510,6 +6442,10 @@ export function startUiServer({
5510
6442
  json(res, 400, { error: "Missing skill slug or collection" });
5511
6443
  return;
5512
6444
  }
6445
+ if (payload?.collection && !authUser?.isAdmin) {
6446
+ json(res, 403, { error: "Admin required" });
6447
+ return;
6448
+ }
5513
6449
  const result = await runSkillhub(args, { cwd: root, timeoutMs: 120_000, maxBuffer: 4 * 1024 * 1024 });
5514
6450
  if (!result.ok) {
5515
6451
  json(res, 500, { error: result.error, stdout: result.stdout });
@@ -6499,6 +7435,10 @@ finishedAt: "${new Date().toISOString()}"
6499
7435
  }
6500
7436
 
6501
7437
  if (req.method === "POST" && url.pathname === "/api/skill-collections") {
7438
+ if (!authUser?.isAdmin) {
7439
+ json(res, 403, { error: "Admin required" });
7440
+ return;
7441
+ }
6502
7442
  let payload;
6503
7443
  try {
6504
7444
  payload = JSON.parse(await readBody(req));