@fieldwangai/agentflow 0.1.68 → 0.1.70

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,12 @@ import {
106
109
  readAdminBuiltinPipelineConfig,
107
110
  updateAdminBuiltinPipelineConfig,
108
111
  } from "./admin-builtin-pipelines.mjs";
112
+ import { readAdminStorageConfig, writeAdminStorageConfig } from "./admin-storage-config.mjs";
113
+ import {
114
+ appendRunLedgerEvent,
115
+ readRunLedgerEvents,
116
+ runLedgerId,
117
+ } from "./run-ledger.mjs";
109
118
 
110
119
  const MIME = {
111
120
  ".html": "text/html; charset=utf-8",
@@ -1367,31 +1376,40 @@ function shouldSkipWorkspaceFileRelPath(relPath) {
1367
1376
  ));
1368
1377
  }
1369
1378
 
1379
+ const WORKSPACE_FILES_MAX_ITEMS = 500;
1380
+
1370
1381
  function readWorkspaceFilesRecursive(dir, root, depth = 0, maxDepth = 3, budget = { count: 0 }) {
1371
- if (depth > maxDepth || budget.count > 500) return [];
1382
+ if (depth > maxDepth) return [];
1383
+ if (depth > 0 && budget.count > WORKSPACE_FILES_MAX_ITEMS) return [];
1372
1384
  let entries;
1373
1385
  try {
1374
1386
  entries = fs.readdirSync(dir, { withFileTypes: true });
1375
1387
  } catch {
1376
1388
  return [];
1377
1389
  }
1390
+ entries.sort((a, b) => {
1391
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
1392
+ return a.name.localeCompare(b.name);
1393
+ });
1378
1394
  const out = [];
1379
1395
  for (const entry of entries) {
1380
- if (budget.count > 500) break;
1396
+ if (depth > 0 && budget.count > WORKSPACE_FILES_MAX_ITEMS) break;
1381
1397
  if (entry.name.startsWith(".") && entry.name !== ".agents" && entry.name !== ".codex") continue;
1382
1398
  const abs = path.join(dir, entry.name);
1383
1399
  const rel = path.relative(root, abs).replace(/\\/g, "/");
1384
1400
  if (shouldSkipWorkspaceFileRelPath(rel)) continue;
1385
1401
  if (entry.isDirectory()) {
1386
1402
  if (WORKSPACE_FILE_SKIP_DIRS.has(entry.name)) continue;
1387
- budget.count++;
1388
1403
  out.push({
1389
1404
  type: "directory",
1390
1405
  name: entry.name,
1391
1406
  path: rel,
1392
1407
  icon: workspaceFileIcon(entry.name, true),
1393
- children: readWorkspaceFilesRecursive(abs, root, depth + 1, maxDepth, budget),
1408
+ children: budget.count > WORKSPACE_FILES_MAX_ITEMS
1409
+ ? []
1410
+ : readWorkspaceFilesRecursive(abs, root, depth + 1, maxDepth, budget),
1394
1411
  });
1412
+ budget.count++;
1395
1413
  } else if (entry.isFile()) {
1396
1414
  if (WORKSPACE_FILE_SKIP_FILES.has(entry.name)) continue;
1397
1415
  const ext = path.extname(entry.name).toLowerCase();
@@ -1402,10 +1420,6 @@ function readWorkspaceFilesRecursive(dir, root, depth = 0, maxDepth = 3, budget
1402
1420
  out.push({ type: "file", name: entry.name, path: rel, icon: workspaceFileIcon(entry.name), size });
1403
1421
  }
1404
1422
  }
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
1423
  return out;
1410
1424
  }
1411
1425
 
@@ -1464,6 +1478,431 @@ function writeDisplayShares(shares) {
1464
1478
  fs.writeFileSync(file, JSON.stringify(shares && typeof shares === "object" ? shares : {}, null, 2) + "\n", "utf-8");
1465
1479
  }
1466
1480
 
1481
+ function countFlowYamlDirs(root) {
1482
+ try {
1483
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) return 0;
1484
+ return fs.readdirSync(root, { withFileTypes: true })
1485
+ .filter((entry) => entry.isDirectory())
1486
+ .filter((entry) => entry.name !== ARCHIVED_PIPELINES_DIR_NAME)
1487
+ .filter((entry) => fs.existsSync(path.join(root, entry.name, FLOW_YAML_FILENAME)))
1488
+ .length;
1489
+ } catch {
1490
+ return 0;
1491
+ }
1492
+ }
1493
+
1494
+ function pipelineCountsForUser(userId) {
1495
+ const pipelinesRoot = getUserPipelinesRoot(userId);
1496
+ const active = countFlowYamlDirs(pipelinesRoot);
1497
+ const archived = countFlowYamlDirs(path.join(pipelinesRoot, ARCHIVED_PIPELINES_DIR_NAME));
1498
+ return {
1499
+ active,
1500
+ archived,
1501
+ total: active + archived,
1502
+ };
1503
+ }
1504
+
1505
+ const USAGE_DAY_MS = 24 * 60 * 60 * 1000;
1506
+ const RUN_LEDGER_STALE_MS = 6 * 60 * 60 * 1000;
1507
+
1508
+ function startOfLocalDayMs(timeMs) {
1509
+ const d = new Date(Number(timeMs) || Date.now());
1510
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
1511
+ }
1512
+
1513
+ function localDayKey(timeMs) {
1514
+ const d = new Date(Number(timeMs) || Date.now());
1515
+ const y = d.getFullYear();
1516
+ const m = String(d.getMonth() + 1).padStart(2, "0");
1517
+ const day = String(d.getDate()).padStart(2, "0");
1518
+ return `${y}-${m}-${day}`;
1519
+ }
1520
+
1521
+ function runStatusBucket(status) {
1522
+ const s = String(status || "unknown");
1523
+ if (s === "success" || s === "failed" || s === "running" || s === "stopped" || s === "interrupted") return s;
1524
+ return "unknown";
1525
+ }
1526
+
1527
+ function buildUsageDailyTrend(runs, days = 14, nowMs = Date.now()) {
1528
+ const startMs = startOfLocalDayMs(nowMs) - (Math.max(1, days) - 1) * USAGE_DAY_MS;
1529
+ const rows = [];
1530
+ const byDate = new Map();
1531
+ for (let i = 0; i < days; i += 1) {
1532
+ const dateMs = startMs + i * USAGE_DAY_MS;
1533
+ const date = localDayKey(dateMs);
1534
+ const row = {
1535
+ date,
1536
+ runs: 0,
1537
+ success: 0,
1538
+ failed: 0,
1539
+ running: 0,
1540
+ stopped: 0,
1541
+ interrupted: 0,
1542
+ unknown: 0,
1543
+ users: 0,
1544
+ pipelines: 0,
1545
+ totalDurationMs: 0,
1546
+ avgDurationMs: 0,
1547
+ _userIds: new Set(),
1548
+ _pipelineKeys: new Set(),
1549
+ };
1550
+ rows.push(row);
1551
+ byDate.set(date, row);
1552
+ }
1553
+ for (const run of runs) {
1554
+ const at = Number(run?.at || 0);
1555
+ if (!Number.isFinite(at) || at < startMs) continue;
1556
+ const row = byDate.get(localDayKey(at));
1557
+ if (!row) continue;
1558
+ const bucket = runStatusBucket(run.status);
1559
+ row.runs += 1;
1560
+ row[bucket] += 1;
1561
+ row.totalDurationMs += Math.max(0, Number(run.durationMs || 0));
1562
+ row._userIds.add(String(run.userId || ""));
1563
+ {
1564
+ const flowSource = String(run.flowSource || "user");
1565
+ const flowId = String(run.flowId || "");
1566
+ const userId = String(run.userId || "");
1567
+ row._pipelineKeys.add(flowSource === "workspace" ? `workspace:${flowId}` : `${userId}:${flowSource}:${flowId}`);
1568
+ }
1569
+ }
1570
+ return rows.map((row) => {
1571
+ row.users = row._userIds.size;
1572
+ row.pipelines = row._pipelineKeys.size;
1573
+ row.avgDurationMs = row.runs > 0 ? Math.round(row.totalDurationMs / row.runs) : 0;
1574
+ delete row._userIds;
1575
+ delete row._pipelineKeys;
1576
+ return row;
1577
+ });
1578
+ }
1579
+
1580
+ function buildUsageRates(users, runs, nowMs = Date.now(), workspacePipelineCount = 0) {
1581
+ const windowDays = 7;
1582
+ const sinceMs = startOfLocalDayMs(nowMs) - (windowDays - 1) * USAGE_DAY_MS;
1583
+ const recentRuns = runs.filter((run) => Number(run?.at || 0) >= sinceMs);
1584
+ const activeUsers = new Set();
1585
+ const activePipelines = new Set();
1586
+ const statusCounts = {};
1587
+ let recentDurationMs = 0;
1588
+ for (const run of recentRuns) {
1589
+ const userId = String(run.userId || "");
1590
+ if (userId) activeUsers.add(userId);
1591
+ const flowSource = String(run.flowSource || "user");
1592
+ const flowId = String(run.flowId || "");
1593
+ activePipelines.add(flowSource === "workspace" ? `workspace:${flowId}` : `${userId}:${flowSource}:${flowId}`);
1594
+ const bucket = runStatusBucket(run.status);
1595
+ statusCounts[bucket] = (statusCounts[bucket] || 0) + 1;
1596
+ recentDurationMs += Math.max(0, Number(run.durationMs || 0));
1597
+ }
1598
+ const totalUsers = users.length;
1599
+ const totalActivePipelines = users.reduce((sum, user) => sum + Math.max(0, Number(user?.pipelines?.active || 0)), 0)
1600
+ + Math.max(0, Number(workspacePipelineCount || 0));
1601
+ const completedRuns = recentRuns.length - (statusCounts.running || 0);
1602
+ const badRuns = (statusCounts.failed || 0) + (statusCounts.stopped || 0) + (statusCounts.interrupted || 0) + (statusCounts.unknown || 0);
1603
+ return {
1604
+ windowDays,
1605
+ activeUsers: activeUsers.size,
1606
+ activeUserRate: totalUsers > 0 ? activeUsers.size / totalUsers : 0,
1607
+ activePipelines: activePipelines.size,
1608
+ activePipelineRate: totalActivePipelines > 0 ? activePipelines.size / totalActivePipelines : 0,
1609
+ runs: recentRuns.length,
1610
+ avgRunsPerDay: recentRuns.length / windowDays,
1611
+ successRuns: statusCounts.success || 0,
1612
+ badRuns,
1613
+ runningRuns: statusCounts.running || 0,
1614
+ successRate: completedRuns > 0 ? (statusCounts.success || 0) / completedRuns : 0,
1615
+ failureRate: completedRuns > 0 ? badRuns / completedRuns : 0,
1616
+ avgDurationMs: recentRuns.length > 0 ? Math.round(recentDurationMs / recentRuns.length) : 0,
1617
+ };
1618
+ }
1619
+
1620
+ function appendWorkspaceRunStarted(record) {
1621
+ appendRunLedgerEvent({
1622
+ ...record,
1623
+ type: "run_started",
1624
+ kind: "workspace",
1625
+ at: Number(record.startedAt || record.at || Date.now()),
1626
+ });
1627
+ }
1628
+
1629
+ function appendWorkspaceRunFinished(record, status) {
1630
+ appendRunLedgerEvent({
1631
+ ...record,
1632
+ type: "run_finished",
1633
+ kind: "workspace",
1634
+ at: Number(record.startedAt || record.at || Date.now()),
1635
+ endedAt: Number(record.endedAt || Date.now()),
1636
+ durationMs: Math.max(0, Number(record.durationMs || (Number(record.endedAt || Date.now()) - Number(record.startedAt || record.at || Date.now())))),
1637
+ status,
1638
+ });
1639
+ }
1640
+
1641
+ function normalizeWorkspaceUsageRecord(parsed, source = "workspace-run") {
1642
+ const userId = String(parsed?.userId || "").trim();
1643
+ const flowId = String(parsed?.flowId || "").trim();
1644
+ const at = Number(parsed?.at || parsed?.startedAt || 0);
1645
+ if (!userId || !flowId || !Number.isFinite(at) || at <= 0) return null;
1646
+ return {
1647
+ userId,
1648
+ username: String(parsed?.username || userId),
1649
+ flowId,
1650
+ flowSource: String(parsed?.flowSource || "user"),
1651
+ runId: String(parsed?.runId || ""),
1652
+ at,
1653
+ endedAt: parsed?.endedAt == null ? null : Number(parsed.endedAt),
1654
+ durationMs: Math.max(0, Number(parsed?.durationMs || 0)),
1655
+ status: runStatusBucket(parsed?.status),
1656
+ source,
1657
+ };
1658
+ }
1659
+
1660
+ function readLegacyWorkspaceRunUsageRecords() {
1661
+ const filePath = path.join(getAgentflowDataRoot(), "admin", "workspace-run-usage.jsonl");
1662
+ if (!fs.existsSync(filePath)) return [];
1663
+ let items = [];
1664
+ try {
1665
+ items = fs.readFileSync(filePath, "utf-8")
1666
+ .split(/\r?\n/)
1667
+ .filter((line) => line.trim())
1668
+ .map((line) => {
1669
+ try { return JSON.parse(line); } catch { return null; }
1670
+ })
1671
+ .filter(Boolean);
1672
+ } catch {
1673
+ items = [];
1674
+ }
1675
+ return items
1676
+ .map((item) => normalizeWorkspaceUsageRecord(item, "workspace-run-legacy"))
1677
+ .filter(Boolean);
1678
+ }
1679
+
1680
+ function readWorkspaceRunLedgerRecords(options = {}) {
1681
+ const byRunId = new Map();
1682
+ for (const event of readRunLedgerEvents(options)) {
1683
+ if (String(event?.kind || "") !== "workspace") continue;
1684
+ const runId = String(event?.runId || "").trim();
1685
+ if (!runId) continue;
1686
+ const existing = byRunId.get(runId) || {};
1687
+ if (event.type === "run_started") {
1688
+ byRunId.set(runId, {
1689
+ ...existing,
1690
+ ...event,
1691
+ runId,
1692
+ at: Number(event.at || existing.at || Date.now()),
1693
+ status: existing.status || "running",
1694
+ });
1695
+ } else if (event.type === "run_finished") {
1696
+ byRunId.set(runId, {
1697
+ ...existing,
1698
+ ...event,
1699
+ runId,
1700
+ at: Number(existing.at || event.at || Date.now()),
1701
+ endedAt: event.endedAt == null ? null : Number(event.endedAt),
1702
+ durationMs: Math.max(0, Number(event.durationMs || 0)),
1703
+ status: runStatusBucket(event.status),
1704
+ });
1705
+ }
1706
+ }
1707
+ const now = Date.now();
1708
+ return Array.from(byRunId.values())
1709
+ .map((item) => {
1710
+ const at = Number(item?.at || 0);
1711
+ const status = runStatusBucket(item?.status);
1712
+ if (status === "running" && at > 0 && now - at > RUN_LEDGER_STALE_MS) {
1713
+ return {
1714
+ ...item,
1715
+ endedAt: Number(item?.endedAt || at + RUN_LEDGER_STALE_MS),
1716
+ durationMs: Math.max(0, Number(item?.durationMs || Math.min(now - at, RUN_LEDGER_STALE_MS))),
1717
+ status: "interrupted",
1718
+ };
1719
+ }
1720
+ return item;
1721
+ })
1722
+ .map((item) => normalizeWorkspaceUsageRecord(item, "workspace-run-ledger"))
1723
+ .filter(Boolean);
1724
+ }
1725
+
1726
+ function readWorkspaceRunUsageRecords(options = {}) {
1727
+ const sinceMs = Number(options?.sinceMs || 0);
1728
+ return [
1729
+ ...readLegacyWorkspaceRunUsageRecords(),
1730
+ ...readWorkspaceRunLedgerRecords(options),
1731
+ ].filter((run) => !Number.isFinite(sinceMs) || sinceMs <= 0 || Number(run?.at || 0) >= sinceMs);
1732
+ }
1733
+
1734
+ function activeWorkspaceRunUsageRecords() {
1735
+ const out = [];
1736
+ for (const entry of activeWorkspaceRuns.values()) {
1737
+ const userId = String(entry?.userId || "").trim();
1738
+ const flowId = String(entry?.flowId || "").trim();
1739
+ const at = Number(entry?.startedAt || 0);
1740
+ if (!userId || !flowId || !Number.isFinite(at) || at <= 0) continue;
1741
+ out.push({
1742
+ userId,
1743
+ username: String(entry?.username || userId),
1744
+ flowId,
1745
+ flowSource: String(entry?.flowSource || "user"),
1746
+ runId: String(entry?.runId || ""),
1747
+ at,
1748
+ endedAt: null,
1749
+ durationMs: Math.max(0, Date.now() - at),
1750
+ status: "running",
1751
+ source: "workspace-run-active",
1752
+ });
1753
+ }
1754
+ return out;
1755
+ }
1756
+
1757
+ function dedupeWorkspaceUsageRuns(runs = []) {
1758
+ const byKey = new Map();
1759
+ for (const run of runs) {
1760
+ const runId = String(run?.runId || "").trim();
1761
+ const key = runId || `${run?.userId || ""}:${run?.flowSource || ""}:${run?.flowId || ""}:${run?.at || ""}:${run?.status || ""}`;
1762
+ if (!key) continue;
1763
+ const existing = byKey.get(key);
1764
+ if (!existing) {
1765
+ byKey.set(key, run);
1766
+ continue;
1767
+ }
1768
+ const runScore = (item) => {
1769
+ if (item?.source === "workspace-run-active") return 3;
1770
+ if (item?.status && item.status !== "running") return 2;
1771
+ return 1;
1772
+ };
1773
+ if (runScore(run) >= runScore(existing)) byKey.set(key, run);
1774
+ }
1775
+ return Array.from(byKey.values());
1776
+ }
1777
+
1778
+ function buildAdminUsageDashboard(workspaceRoot) {
1779
+ const authUsers = readAuthUsers();
1780
+ const usageSinceMs = startOfLocalDayMs(Date.now()) - 13 * USAGE_DAY_MS;
1781
+ const workspacePipelineCount = listFlowsJson(workspaceRoot, { includeWorkspaceFlows: true })
1782
+ .filter((flow) => flow?.source === "workspace" && !flow?.archived)
1783
+ .length;
1784
+ const workspaceUsageRuns = dedupeWorkspaceUsageRuns([
1785
+ ...readWorkspaceRunUsageRecords({ sinceMs: usageSinceMs }),
1786
+ ...activeWorkspaceRunUsageRecords(),
1787
+ ]);
1788
+ const userIds = Array.from(new Set([
1789
+ ...Object.keys(authUsers || {}),
1790
+ ...listAgentflowUserIds(),
1791
+ ...workspaceUsageRuns.map((run) => run.userId),
1792
+ ].map((id) => String(id || "").trim()).filter(Boolean))).sort((a, b) => a.localeCompare(b));
1793
+ const allRuns = [];
1794
+ const workspaceUsageByUser = new Map();
1795
+ for (const run of workspaceUsageRuns) {
1796
+ const userId = String(run.userId || "");
1797
+ if (!workspaceUsageByUser.has(userId)) workspaceUsageByUser.set(userId, []);
1798
+ workspaceUsageByUser.get(userId).push(run);
1799
+ }
1800
+ const users = userIds.map((userId) => {
1801
+ const user = authUsers[userId] || {};
1802
+ const pipelineCounts = pipelineCountsForUser(userId);
1803
+ const pipelineRuns = listRecentRunsFromDisk(workspaceRoot, {
1804
+ userId,
1805
+ includeWorkspaceRuns: false,
1806
+ includeLegacyUserRuns: false,
1807
+ });
1808
+ const runs = [...pipelineRuns, ...(workspaceUsageByUser.get(userId) || [])]
1809
+ .sort((a, b) => Number(b.at || 0) - Number(a.at || 0));
1810
+ const statusCounts = {};
1811
+ let totalDurationMs = 0;
1812
+ for (const run of runs) {
1813
+ allRuns.push({ ...run, userId, username: user.username || userId });
1814
+ const status = String(run.status || "unknown");
1815
+ statusCounts[status] = (statusCounts[status] || 0) + 1;
1816
+ totalDurationMs += Math.max(0, Number(run.durationMs || 0));
1817
+ }
1818
+ const lastRun = runs[0] || null;
1819
+ return {
1820
+ userId,
1821
+ username: user.username || userId,
1822
+ isAdmin: Boolean(user.isAdmin),
1823
+ pipelines: pipelineCounts,
1824
+ runs: {
1825
+ total: runs.length,
1826
+ running: statusCounts.running || 0,
1827
+ success: statusCounts.success || 0,
1828
+ failed: statusCounts.failed || 0,
1829
+ stopped: statusCounts.stopped || 0,
1830
+ interrupted: statusCounts.interrupted || 0,
1831
+ unknown: statusCounts.unknown || 0,
1832
+ totalDurationMs,
1833
+ avgDurationMs: runs.length > 0 ? Math.round(totalDurationMs / runs.length) : 0,
1834
+ lastRunAt: lastRun?.at || null,
1835
+ lastRunFlowId: lastRun?.flowId || "",
1836
+ lastRunStatus: lastRun?.status || "",
1837
+ recent: runs.slice(0, 5).map((run) => ({
1838
+ flowId: run.flowId,
1839
+ flowSource: run.flowSource,
1840
+ runId: run.runId,
1841
+ at: run.at,
1842
+ endedAt: run.endedAt,
1843
+ durationMs: run.durationMs,
1844
+ status: run.status,
1845
+ })),
1846
+ },
1847
+ };
1848
+ });
1849
+ const totals = users.reduce((acc, user) => {
1850
+ acc.users += 1;
1851
+ acc.admins += user.isAdmin ? 1 : 0;
1852
+ acc.pipelines += user.pipelines.total;
1853
+ acc.activePipelines += user.pipelines.active;
1854
+ acc.archivedPipelines += user.pipelines.archived;
1855
+ acc.runs += user.runs.total;
1856
+ acc.runningRuns += user.runs.running;
1857
+ acc.successRuns += user.runs.success;
1858
+ acc.failedRuns += user.runs.failed;
1859
+ acc.stoppedRuns += user.runs.stopped;
1860
+ acc.interruptedRuns += user.runs.interrupted;
1861
+ acc.unknownRuns += user.runs.unknown;
1862
+ acc.totalDurationMs += user.runs.totalDurationMs;
1863
+ return acc;
1864
+ }, {
1865
+ users: 0,
1866
+ admins: 0,
1867
+ pipelines: 0,
1868
+ activePipelines: 0,
1869
+ archivedPipelines: 0,
1870
+ runs: 0,
1871
+ runningRuns: 0,
1872
+ successRuns: 0,
1873
+ failedRuns: 0,
1874
+ stoppedRuns: 0,
1875
+ interruptedRuns: 0,
1876
+ unknownRuns: 0,
1877
+ totalDurationMs: 0,
1878
+ });
1879
+ totals.avgDurationMs = totals.runs > 0 ? Math.round(totals.totalDurationMs / totals.runs) : 0;
1880
+ const recentRuns = allRuns
1881
+ .slice()
1882
+ .sort((a, b) => Number(b.at || 0) - Number(a.at || 0))
1883
+ .slice(0, 50)
1884
+ .map((run) => ({
1885
+ userId: String(run.userId || ""),
1886
+ username: String(run.username || run.userId || ""),
1887
+ flowId: String(run.flowId || ""),
1888
+ flowSource: String(run.flowSource || "user"),
1889
+ runId: String(run.runId || ""),
1890
+ at: Number(run.at || 0),
1891
+ endedAt: run.endedAt == null ? null : Number(run.endedAt),
1892
+ durationMs: Math.max(0, Number(run.durationMs || 0)),
1893
+ status: runStatusBucket(run.status),
1894
+ runType: String(run.source || "").startsWith("workspace-run") ? "workspace" : "pipeline",
1895
+ }));
1896
+ return {
1897
+ generatedAt: new Date().toISOString(),
1898
+ totals,
1899
+ usage: buildUsageRates(users, allRuns, Date.now(), workspacePipelineCount),
1900
+ dailyTrend: buildUsageDailyTrend(allRuns, 14),
1901
+ recentRuns,
1902
+ users,
1903
+ };
1904
+ }
1905
+
1467
1906
  function createDisplayShareId() {
1468
1907
  return crypto.randomBytes(12).toString("base64url");
1469
1908
  }
@@ -1629,6 +2068,44 @@ function mergeWorkspaceRunGraph(currentGraph, runGraph, touchedIds) {
1629
2068
  };
1630
2069
  }
1631
2070
 
2071
+ function mergeWorkspacePersistentNodeRefs(incomingGraph, currentGraph) {
2072
+ const incoming = normalizeWorkspaceGraphPayload(incomingGraph || {});
2073
+ const current = normalizeWorkspaceGraphPayload(currentGraph || {});
2074
+ const instances = { ...(incoming.instances || {}) };
2075
+ for (const [id, currentInstance] of Object.entries(current.instances || {})) {
2076
+ const nextInstance = instances[id];
2077
+ if (!nextInstance || typeof nextInstance !== "object") continue;
2078
+ for (const key of ["scriptRef", "implementationRef", "implementationMode"]) {
2079
+ const currentValue = currentInstance?.[key];
2080
+ const nextValue = nextInstance?.[key];
2081
+ if (currentValue != null && String(currentValue).trim() && (nextValue == null || !String(nextValue).trim())) {
2082
+ nextInstance[key] = currentValue;
2083
+ }
2084
+ }
2085
+ }
2086
+ return { ...incoming, instances };
2087
+ }
2088
+
2089
+ function hydrateWorkspaceNodeRefsFromFiles(scopedRoot, graph) {
2090
+ const next = normalizeWorkspaceGraphPayload(graph || {});
2091
+ const instances = { ...(next.instances || {}) };
2092
+ let changed = false;
2093
+ for (const [nodeId, instance] of Object.entries(instances)) {
2094
+ if (!instance || typeof instance !== "object") continue;
2095
+ const defId = String(instance.definitionId || "");
2096
+ if (defId === "workspace_run" || defId === "workspace_scheduled_run") continue;
2097
+ if (!String(instance.implementationRef || "").trim()) {
2098
+ const implementationRef = workspaceDefaultImplementationRef(nodeId);
2099
+ const abs = workspaceResolveFlowFile(scopedRoot, implementationRef, "implementationRef");
2100
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
2101
+ instances[nodeId] = { ...instance, implementationRef };
2102
+ changed = true;
2103
+ }
2104
+ }
2105
+ }
2106
+ return changed ? { ...next, instances } : next;
2107
+ }
2108
+
1632
2109
  function resolveWorkspaceScopeRoot(workspaceRoot, params = {}, opts = {}) {
1633
2110
  const flowId = params.flowId != null ? String(params.flowId).trim() : "";
1634
2111
  if (!flowId) return { root: path.resolve(workspaceRoot), flowId: "", flowSource: "", archived: false };
@@ -2214,6 +2691,45 @@ function workspaceResolvePath(baseCwd, raw) {
2214
2691
  return path.isAbsolute(text) ? path.resolve(text) : path.resolve(baseCwd, text);
2215
2692
  }
2216
2693
 
2694
+ function workspaceShellQuote(value) {
2695
+ return "'" + String(value ?? "").replace(/'/g, "'\\''") + "'";
2696
+ }
2697
+
2698
+ function workspaceSafeFlowRelPath(raw, fieldName = "path") {
2699
+ const text = String(raw || "").trim().replace(/^["']|["']$/g, "");
2700
+ if (!text) return "";
2701
+ if (text.length > 260) throw new Error(`${fieldName} is too long`);
2702
+ if (/[\r\n<>]/.test(text)) throw new Error(`${fieldName} contains invalid characters`);
2703
+ if (/^(?:https?:|data:|blob:|file:|javascript:|mailto:|tel:)/i.test(text)) {
2704
+ throw new Error(`${fieldName} must be a relative file path`);
2705
+ }
2706
+ if (path.isAbsolute(text)) throw new Error(`${fieldName} must be relative`);
2707
+ const normalized = path.posix.normalize(text.replace(/\\/g, "/")).replace(/^\/+/, "");
2708
+ if (!normalized || normalized === "." || normalized.startsWith("../") || normalized.includes("/../")) {
2709
+ throw new Error(`${fieldName} escapes workspace root`);
2710
+ }
2711
+ return normalized;
2712
+ }
2713
+
2714
+ function workspaceResolveFlowFile(scopedRoot, relPath, fieldName = "path") {
2715
+ const clean = workspaceSafeFlowRelPath(relPath, fieldName);
2716
+ if (!clean) return "";
2717
+ const root = path.resolve(scopedRoot);
2718
+ const abs = path.resolve(root, ...clean.split("/"));
2719
+ const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
2720
+ if (abs !== root && !abs.startsWith(rootWithSep)) {
2721
+ throw new Error(`${fieldName} escapes workspace root`);
2722
+ }
2723
+ return abs;
2724
+ }
2725
+
2726
+ function workspaceReadTextFileIfExists(absPath, maxChars = 60000) {
2727
+ const file = String(absPath || "").trim();
2728
+ if (!file || !fs.existsSync(file) || !fs.statSync(file).isFile()) return "";
2729
+ const raw = fs.readFileSync(file, "utf-8");
2730
+ return raw.length > maxChars ? `${raw.slice(0, maxChars)}\n...[truncated ${raw.length - maxChars} chars]` : raw;
2731
+ }
2732
+
2217
2733
  function workspaceSanitizeRepoDirName(repoUrl) {
2218
2734
  const raw = String(repoUrl || "").trim().replace(/\.git$/i, "");
2219
2735
  const last = raw.split(/[/:]/).filter(Boolean).pop() || "repo";
@@ -2500,7 +3016,7 @@ function workspaceRunPlan(graph, runNodeId, scopedRoot = "") {
2500
3016
  const addNeeded = (id) => {
2501
3017
  if (!id || needed.has(id)) return;
2502
3018
  const defId = String(instances[id]?.definitionId || "");
2503
- if (id !== target && defId === "workspace_run") {
3019
+ if (id !== target && (defId === "workspace_run" || defId === "workspace_scheduled_run")) {
2504
3020
  pauseNodeIds.add(id);
2505
3021
  return;
2506
3022
  }
@@ -2726,6 +3242,314 @@ function workspacePromptUpstreamText(upstreamText, runPackage = {}) {
2726
3242
  return upstreamText;
2727
3243
  }
2728
3244
 
3245
+ function workspaceImplementationInlineText(instance) {
3246
+ const candidates = [instance?.implementation, instance?.implementationPlan];
3247
+ for (const item of candidates) {
3248
+ if (item == null) continue;
3249
+ if (typeof item === "string" && item.trim()) return item.trim();
3250
+ if (typeof item === "object" && !Array.isArray(item)) {
3251
+ const content = item.content ?? item.body ?? item.notes ?? "";
3252
+ if (String(content || "").trim()) return String(content).trim();
3253
+ }
3254
+ }
3255
+ return "";
3256
+ }
3257
+
3258
+ function workspaceMaterializeImplementationReference(instance, scopedRoot, runPackage = {}) {
3259
+ const implementationRef = String(instance?.implementationRef || "").trim();
3260
+ if (!implementationRef) return null;
3261
+ const abs = workspaceResolveFlowFile(scopedRoot, implementationRef, "implementationRef");
3262
+ const exists = fs.existsSync(abs) && fs.statSync(abs).isFile();
3263
+ const nodeRunDir = String(runPackage?.nodeRunDir || "").trim();
3264
+ let mounted = "";
3265
+ if (exists && nodeRunDir) {
3266
+ try {
3267
+ const mountedRel = path.join("references", "implementation.md");
3268
+ const dest = path.resolve(nodeRunDir, mountedRel);
3269
+ const nodeRunWithSep = nodeRunDir.endsWith(path.sep) ? nodeRunDir : `${nodeRunDir}${path.sep}`;
3270
+ if (dest === nodeRunDir || dest.startsWith(nodeRunWithSep)) {
3271
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
3272
+ fs.copyFileSync(abs, dest);
3273
+ mounted = mountedRel.split(path.sep).join(path.posix.sep);
3274
+ }
3275
+ } catch {
3276
+ mounted = "";
3277
+ }
3278
+ }
3279
+ return { implementationRef, exists, mounted };
3280
+ }
3281
+
3282
+ function workspaceImplementationBlock(instance, scopedRoot, runPackage = {}) {
3283
+ const ref = workspaceMaterializeImplementationReference(instance, scopedRoot, runPackage);
3284
+ const inline = workspaceImplementationInlineText(instance);
3285
+ if (!ref && !inline) return "";
3286
+ const mode = String(instance?.implementationMode || "").trim();
3287
+ return [
3288
+ "## 参考实现方案",
3289
+ "",
3290
+ mode ? `mode: ${mode}` : "",
3291
+ ref ? `- 实现方案文件:\`${ref.mounted || ref.implementationRef}\`` : "",
3292
+ ref?.mounted ? `- 原始流水线路径:\`${ref.implementationRef}\`` : "",
3293
+ ref && !ref.exists ? "- 当前实现方案文件不存在,本次不要依赖旧方案。" : "",
3294
+ inline ? "- 节点存在内联实现方案字段,但本提示不会内联其内容;如需复用,请优先参考实现方案文件。" : "",
3295
+ "",
3296
+ "该文件只作为可选参考,用于了解上次执行的实现路径。不要把旧方案当成硬约束;如果与当前任务、输入或输出要求冲突,以当前任务为准。",
3297
+ "只有在需要复用细节或确认历史约定时才读取该文件;不要在最终回复中复述参考方案内容。",
3298
+ ].filter((line) => line !== "").join("\n");
3299
+ }
3300
+
3301
+ function workspaceSafeNodeFileName(nodeId) {
3302
+ const text = String(nodeId || "").trim().replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "");
3303
+ return text || "node";
3304
+ }
3305
+
3306
+ function workspaceDefaultImplementationRef(nodeId) {
3307
+ return `nodes/${workspaceSafeNodeFileName(nodeId)}/implementation.md`;
3308
+ }
3309
+
3310
+ function workspaceImplementationModeForInstance(instance) {
3311
+ const explicit = String(instance?.implementationMode || "").trim();
3312
+ if (explicit) return explicit;
3313
+ return String(instance?.definitionId || "") === "tool_nodejs" ? "script" : "steps";
3314
+ }
3315
+
3316
+ function workspaceClipImplementationText(value, maxChars = 2400) {
3317
+ const text = String(value ?? "").trim();
3318
+ if (!text) return "";
3319
+ return text.length > maxChars ? `${text.slice(0, maxChars)}\n...[truncated ${text.length - maxChars} chars]` : text;
3320
+ }
3321
+
3322
+ function workspaceUniqueImplementationList(items = [], maxItems = 12) {
3323
+ const out = [];
3324
+ const seen = new Set();
3325
+ for (const item of items) {
3326
+ const text = String(item || "").trim();
3327
+ if (!text) continue;
3328
+ const key = text.toLowerCase();
3329
+ if (seen.has(key)) continue;
3330
+ seen.add(key);
3331
+ out.push(text);
3332
+ if (out.length >= maxItems) break;
3333
+ }
3334
+ return out;
3335
+ }
3336
+
3337
+ function workspaceImplementationArtifactCandidates(structured = {}, result = "") {
3338
+ const candidates = [
3339
+ structured.resultFile,
3340
+ structured.result,
3341
+ result,
3342
+ ...Object.values(structured.outParams || {}),
3343
+ ];
3344
+ return workspaceUniqueImplementationList(candidates, 10)
3345
+ .filter((item) => /^(?:outputs|nodes|artifacts)\//.test(item) && !/[\r\n]/.test(item));
3346
+ }
3347
+
3348
+ function workspaceReadImplementationArtifact(scopedRoot, structured = {}, result = "") {
3349
+ const root = String(scopedRoot || "").trim();
3350
+ if (!root) return null;
3351
+ for (const rel of workspaceImplementationArtifactCandidates(structured, result)) {
3352
+ let abs = "";
3353
+ try {
3354
+ abs = workspaceResolveFlowFile(root, rel, "resultFile");
3355
+ } catch {
3356
+ continue;
3357
+ }
3358
+ if (!abs || !fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
3359
+ const ext = path.extname(abs).toLowerCase();
3360
+ const content = workspaceReadTextFileIfExists(abs, 120000);
3361
+ if (!content.trim()) continue;
3362
+ return { rel, abs, ext, content };
3363
+ }
3364
+ return null;
3365
+ }
3366
+
3367
+ function workspaceImplementationArtifactContext(scopedRoot, structured = {}, result = "") {
3368
+ const artifact = workspaceReadImplementationArtifact(scopedRoot, structured, result);
3369
+ if (!artifact) return "无可读取产物文件。";
3370
+ return [
3371
+ `产物路径:${artifact.rel}`,
3372
+ `文件类型:${artifact.ext || "(unknown)"}`,
3373
+ "",
3374
+ "产物内容:",
3375
+ "```",
3376
+ workspaceClipImplementationText(artifact.content, 50000),
3377
+ "```",
3378
+ ].join("\n");
3379
+ }
3380
+
3381
+ function workspaceBuildImplementationPrompt(instance, nodeId, opts = {}) {
3382
+ const defId = String(instance?.definitionId || "").trim();
3383
+ const label = String(instance?.label || nodeId || "node").trim();
3384
+ const mode = workspaceImplementationModeForInstance(instance);
3385
+ const inputValues = opts.inputValues || {};
3386
+ const structured = opts.structured && typeof opts.structured === "object" ? opts.structured : {};
3387
+ const result = String(opts.resultContent || structured.result || "").trim();
3388
+ const task = workspaceResolveBodyPlaceholders(instance?.body || "", inputValues).trim();
3389
+ const scriptRef = String(instance?.scriptRef || "").trim();
3390
+ const inlineScript = String(instance?.script || "").trim();
3391
+ const previousImplementation = opts.previousImplementation
3392
+ ? workspaceClipImplementationText(opts.previousImplementation, 12000)
3393
+ : "";
3394
+ return [
3395
+ "你要为 AgentFlow 的一个节点写“实现方案”Markdown。这个文件会在下次运行同一个节点前作为上下文给模型参考。",
3396
+ "",
3397
+ "要求:",
3398
+ "- 必须基于实际任务、输入和产物内容自己总结,不要写流水账,不要写“使用 Agent 生成输出”这类空话。",
3399
+ "- 写清楚这次结果到底是如何实现的:核心思路、产物结构、关键文件/路径、关键样式/函数/数据结构、可复用约定。",
3400
+ "- 写清楚下次如果要继续迭代,应该从哪里改、哪些约定不能破坏。",
3401
+ "- 如果产物是 HTML/UI,必须总结页面模块、视觉风格、关键 class/token、交互点和下游展示契约。",
3402
+ "- 如果产物是脚本,必须总结脚本入口、环境变量、输入输出协议和错误处理方式。",
3403
+ "- 只输出 Markdown 正文,不要输出代码围栏包裹整篇,不要解释你在总结。",
3404
+ "",
3405
+ "## 节点信息",
3406
+ "",
3407
+ `nodeId: ${nodeId}`,
3408
+ `label: ${label}`,
3409
+ `definitionId: ${defId || "(unknown)"}`,
3410
+ `mode: ${mode}`,
3411
+ scriptRef ? `scriptRef: ${scriptRef}` : "",
3412
+ inlineScript ? `inlineScript: ${workspaceClipImplementationText(inlineScript, 1200)}` : "",
3413
+ "",
3414
+ "## 当前任务",
3415
+ "",
3416
+ workspaceClipImplementationText(task || instance?.body || scriptRef || inlineScript || "(无显式任务)", 6000),
3417
+ "",
3418
+ "## 输入",
3419
+ "",
3420
+ JSON.stringify(inputValues || {}, null, 2),
3421
+ "",
3422
+ "## 输出",
3423
+ "",
3424
+ JSON.stringify({
3425
+ result: structured.result || result,
3426
+ resultFile: structured.resultFile || "",
3427
+ outParams: structured.outParams || {},
3428
+ }, null, 2),
3429
+ "",
3430
+ previousImplementation ? "## 上一版实现方案" : "",
3431
+ previousImplementation || "",
3432
+ previousImplementation ? "" : "",
3433
+ "## 实际产物上下文",
3434
+ "",
3435
+ workspaceImplementationArtifactContext(opts.scopedRoot, structured, result),
3436
+ ].filter((line) => line !== "").join("\n");
3437
+ }
3438
+
3439
+ async function workspaceGenerateImplementationMarkdown({
3440
+ scopedRoot,
3441
+ nodeId,
3442
+ instance,
3443
+ inputValues,
3444
+ resultContent,
3445
+ structured,
3446
+ implementationPath,
3447
+ previousImplementation,
3448
+ runPackage,
3449
+ modelKey,
3450
+ userCtx,
3451
+ emit,
3452
+ onActiveChild,
3453
+ }) {
3454
+ const prompt = workspaceBuildImplementationPrompt(instance, nodeId, {
3455
+ scopedRoot,
3456
+ inputValues,
3457
+ resultContent,
3458
+ structured,
3459
+ previousImplementation,
3460
+ });
3461
+ let content = "";
3462
+ let lastAssistant = "";
3463
+ let resultText = "";
3464
+ emit?.({ type: "status", line: "Summarize implementation plan with model" });
3465
+ const handle = startComposerAgent({
3466
+ uiWorkspaceRoot: scopedRoot,
3467
+ cliWorkspace: runPackage?.nodeRunDir || scopedRoot,
3468
+ prompt,
3469
+ modelKey,
3470
+ agentflowUserId: userCtx?.userId || "",
3471
+ extraEnv: runtimeEnvForUser(userCtx, {
3472
+ AGENTFLOW_IMPLEMENTATION_REF: implementationPath || "",
3473
+ AGENTFLOW_NODE_RUN_DIR: runPackage?.nodeRunDir || "",
3474
+ AGENTFLOW_NODE_TMP_DIR: runPackage?.nodeTmpDir || "",
3475
+ AGENTFLOW_OUTPUTS_DIR: runPackage?.outputsDir || "",
3476
+ }),
3477
+ onStreamEvent: (ev) => {
3478
+ if (ev?.type === "natural" && ev.kind === "assistant" && typeof ev.text === "string") {
3479
+ lastAssistant = ev.text;
3480
+ content += (content ? "\n" : "") + ev.text;
3481
+ } else if (ev?.type === "natural" && ev.kind === "result" && typeof ev.text === "string") {
3482
+ resultText = ev.text;
3483
+ }
3484
+ },
3485
+ onToolCall: (subtype, toolName) => {
3486
+ const sub = subtype ? String(subtype) : "";
3487
+ const tool = toolName ? String(toolName) : "";
3488
+ emit?.({ type: "status", line: `总结方案工具 ${tool || "thinking"}${sub ? ` (${sub})` : ""}` });
3489
+ },
3490
+ });
3491
+ if (typeof onActiveChild === "function") onActiveChild(handle.child || null);
3492
+ try {
3493
+ await handle.finished;
3494
+ } finally {
3495
+ if (typeof onActiveChild === "function") onActiveChild(null);
3496
+ }
3497
+ const markdown = String(resultText || lastAssistant || content || "").trim();
3498
+ return markdown.replace(/^```(?:markdown|md)?\s*/i, "").replace(/```\s*$/i, "").trim();
3499
+ }
3500
+
3501
+ async function workspacePersistNodeImplementation(scopedRoot, graph, nodeId, opts = {}) {
3502
+ const current = graph?.instances?.[nodeId];
3503
+ if (!current || String(current.definitionId || "") === "workspace_run" || String(current.definitionId || "") === "workspace_scheduled_run") {
3504
+ return { changed: false, wrote: false, instance: current };
3505
+ }
3506
+ const existingRef = String(current.implementationRef || "").trim();
3507
+ const implementationRef = existingRef || workspaceDefaultImplementationRef(nodeId);
3508
+ const abs = workspaceResolveFlowFile(scopedRoot, implementationRef, "implementationRef");
3509
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
3510
+ const previousImplementation = fs.existsSync(abs) && fs.statSync(abs).isFile()
3511
+ ? workspaceReadTextFileIfExists(abs, 60000)
3512
+ : "";
3513
+ const markdown = await workspaceGenerateImplementationMarkdown({
3514
+ scopedRoot,
3515
+ nodeId,
3516
+ instance: current,
3517
+ inputValues: opts.inputValues || {},
3518
+ resultContent: opts.resultContent || "",
3519
+ structured: opts.structured || {},
3520
+ implementationPath: implementationRef,
3521
+ previousImplementation,
3522
+ runPackage: opts.runPackage,
3523
+ modelKey: opts.modelKey || "",
3524
+ userCtx: opts.userCtx || {},
3525
+ emit: opts.emit,
3526
+ onActiveChild: opts.onActiveChild,
3527
+ });
3528
+ if (!markdown.trim()) throw new Error(`Implementation summary is empty for node ${nodeId}`);
3529
+ fs.writeFileSync(abs, markdown.trimEnd() + "\n", "utf-8");
3530
+ const explicitMode = String(current.implementationMode || "").trim();
3531
+ const next = {
3532
+ ...current,
3533
+ implementationRef,
3534
+ ...(explicitMode ? { implementationMode: explicitMode } : {}),
3535
+ };
3536
+ const changed = String(current.implementationRef || "") !== implementationRef;
3537
+ return { changed, wrote: true, instance: next, implementationRef };
3538
+ }
3539
+
3540
+ async function workspaceTryPersistNodeImplementation(scopedRoot, graph, nodeId, opts = {}) {
3541
+ try {
3542
+ return await workspacePersistNodeImplementation(scopedRoot, graph, nodeId, opts);
3543
+ } catch (e) {
3544
+ opts.emit?.({
3545
+ type: "natural",
3546
+ kind: "warning",
3547
+ text: `实现方案未更新:${e?.message || String(e)}`,
3548
+ });
3549
+ return { changed: false, wrote: false, instance: graph?.instances?.[nodeId] };
3550
+ }
3551
+ }
3552
+
2729
3553
  function parseWorkspaceSkillKeys(raw) {
2730
3554
  const text = String(raw || "").trim();
2731
3555
  if (!text) return [];
@@ -2859,6 +3683,7 @@ function workspaceWriteDisplayContent(instance, content) {
2859
3683
  const unwrapped = workspaceUnwrapOutputEnvelopeForDisplay(content);
2860
3684
  const text = kind === "html" ? normalizeHtmlDisplayContent(unwrapped) : String(unwrapped || "");
2861
3685
  const primaryName = kind === "image" ? "src" : "content";
3686
+ next.displayReloadKey = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
2862
3687
  next.body = text;
2863
3688
  next.input = (Array.isArray(next.input) ? next.input : []).map((slot) => (
2864
3689
  String(slot?.name || "") === primaryName || String(slot?.type || "") === "text"
@@ -2898,7 +3723,7 @@ function workspaceUpdateDirectDisplays(graph, sourceId, content, outputs = null)
2898
3723
  return updated;
2899
3724
  }
2900
3725
 
2901
- function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock, mcpBlock = "", inputValues = {}, nodeTmpDir = "") {
3726
+ function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock, mcpBlock = "", inputValues = {}, nodeTmpDir = "", implementationBlock = "") {
2902
3727
  const instance = graph.instances[nodeId] || {};
2903
3728
  const body = workspaceResolveBodyPlaceholders(instance.body || "", inputValues).trim();
2904
3729
  const { values: relevantInputValues, placeholders } = workspaceRelevantInputValues(instance.body || "", inputValues);
@@ -2911,6 +3736,7 @@ function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock, mcpBlock
2911
3736
  fileBoundary ? `\n${fileBoundary}` : "",
2912
3737
  inputBlock ? `\n${inputBlock}` : "",
2913
3738
  placeholders.size ? "\n任务只显式引用了上面的输入槽;其它未被 `${...}` 引用的已连接业务输入不要作为分析依据。" : "",
3739
+ implementationBlock ? `\n${implementationBlock}` : "",
2914
3740
  skillsBlock ? `\n## 可用能力\n\n${skillsBlock}` : "",
2915
3741
  mcpBlock ? `\n## 可用 MCP\n\n${mcpBlock}` : "",
2916
3742
  upstreamText ? `\n## 上游正文\n\n${upstreamText}` : "",
@@ -2919,13 +3745,38 @@ function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock, mcpBlock
2919
3745
  ].filter(Boolean).join("\n");
2920
3746
  }
2921
3747
 
2922
- function workspaceDefaultWorktreeRoot(scopedRoot) {
2923
- return path.join(path.resolve(scopedRoot), ".workspace", "agentflow", "worktrees");
3748
+ function workspaceDefaultGitRepoRoot(scopedRoot, _userCtx = {}) {
3749
+ return path.join(path.resolve(scopedRoot), ".workspace", "agentflow", "git-repos");
3750
+ }
3751
+
3752
+ function workspaceDefaultWorktreePath(runTmpRoot, nodeId, repoPath, branch = "") {
3753
+ const repoRoot = path.resolve(repoPath);
3754
+ const repoName = sanitizeWorktreeName(path.basename(repoRoot));
3755
+ const branchName = String(branch || "").trim();
3756
+ let refLabel = branchName;
3757
+ if (!refLabel) {
3758
+ const currentBranch = runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot);
3759
+ if (currentBranch.status === 0 && currentBranch.stdout.trim() && currentBranch.stdout.trim() !== "HEAD") {
3760
+ refLabel = currentBranch.stdout.trim();
3761
+ }
3762
+ }
3763
+ if (!refLabel) {
3764
+ const currentCommit = runGit(["rev-parse", "HEAD"], repoRoot);
3765
+ refLabel = currentCommit.status === 0 && currentCommit.stdout.trim()
3766
+ ? currentCommit.stdout.trim().slice(0, 12)
3767
+ : "HEAD";
3768
+ }
3769
+ return path.join(
3770
+ path.resolve(runTmpRoot),
3771
+ "worktrees",
3772
+ workspaceSanitizeTmpSegment(nodeId, "node"),
3773
+ repoName,
3774
+ sanitizeWorktreeName(refLabel),
3775
+ );
2924
3776
  }
2925
3777
 
2926
- function workspaceShouldAutoCleanupWorktree(scopedRoot, worktreePath, hasExplicitWorktreePath) {
2927
- if (hasExplicitWorktreePath || !worktreePath) return false;
2928
- return workspacePathInside(workspaceDefaultWorktreeRoot(scopedRoot), worktreePath);
3778
+ function workspaceShouldAutoCleanupWorktree(worktreePath, hasExplicitWorktreePath) {
3779
+ return Boolean(worktreePath) && !hasExplicitWorktreePath;
2929
3780
  }
2930
3781
 
2931
3782
  function workspaceTrackAutoCleanupWorktree(list, item) {
@@ -2961,7 +3812,7 @@ function workspaceCleanupAutoWorktrees(list, graph, emit) {
2961
3812
  const result = unloadGitWorktree({
2962
3813
  repoPath: entry.repoPath,
2963
3814
  worktreePath: entry.worktreePath,
2964
- force: false,
3815
+ force: true,
2965
3816
  prune: true,
2966
3817
  });
2967
3818
  emit({
@@ -3118,6 +3969,146 @@ function workspaceCleanupTmpRoot(runTmpRoot, userCtx = {}, emit = () => {}) {
3118
3969
  }
3119
3970
  }
3120
3971
 
3972
+ function workspaceOutputFileRefsForNode(instance) {
3973
+ const refs = {};
3974
+ const slots = Array.isArray(instance?.output) ? instance.output : [];
3975
+ for (let index = 0; index < slots.length; index += 1) {
3976
+ const slot = slots[index];
3977
+ const name = String(slot?.name || "").trim();
3978
+ const type = String(slot?.type || "");
3979
+ if (!name || type === "node" || name === "next" || name === "prev") continue;
3980
+ const key = name === "content" || index === 0 ? "result" : name;
3981
+ const safe = workspaceSanitizeTmpSegment(key, "result");
3982
+ refs[key] = `outputs/${safe}.txt`;
3983
+ }
3984
+ if (!refs.result) refs.result = "outputs/result.txt";
3985
+ return refs;
3986
+ }
3987
+
3988
+ function workspaceResolveScriptCommandText(script, values = {}) {
3989
+ return String(script || "").replace(/\$\{([^}]+)\}/g, (_, key) => {
3990
+ const name = String(key || "").trim();
3991
+ return workspaceShellQuote(Object.prototype.hasOwnProperty.call(values, name) ? values[name] : "");
3992
+ });
3993
+ }
3994
+
3995
+ function workspaceDefaultScriptCommand(scriptAbs) {
3996
+ const ext = path.extname(scriptAbs).toLowerCase();
3997
+ if (ext === ".js" || ext === ".mjs" || ext === ".cjs") return `node ${workspaceShellQuote(scriptAbs)}`;
3998
+ if (ext === ".sh" || ext === ".bash") return `bash ${workspaceShellQuote(scriptAbs)}`;
3999
+ if (ext === ".py") return `python3 ${workspaceShellQuote(scriptAbs)}`;
4000
+ return workspaceShellQuote(scriptAbs);
4001
+ }
4002
+
4003
+ function workspaceEnvelopeFromOutputFiles(outputRefs, nodeRunDir) {
4004
+ const entries = Object.entries(outputRefs || {})
4005
+ .map(([name, rel]) => {
4006
+ const abs = path.resolve(nodeRunDir, rel);
4007
+ const nodeRootWithSep = nodeRunDir.endsWith(path.sep) ? nodeRunDir : `${nodeRunDir}${path.sep}`;
4008
+ if (abs !== nodeRunDir && !abs.startsWith(nodeRootWithSep)) return null;
4009
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null;
4010
+ return { name, rel };
4011
+ })
4012
+ .filter(Boolean);
4013
+ if (entries.length === 0) return "";
4014
+ const result = entries.find((entry) => entry.name === "result") || entries[0];
4015
+ const outParams = entries.filter((entry) => entry !== result);
4016
+ return [
4017
+ "---agentflow",
4018
+ `resultFile: ${result.rel}`,
4019
+ outParams.length ? "outParams:" : "",
4020
+ ...outParams.map((entry) => ` ${entry.name}File: ${entry.rel}`),
4021
+ "---end",
4022
+ ].filter(Boolean).join("\n");
4023
+ }
4024
+
4025
+ async function workspaceRunToolNodejsScript({
4026
+ scopedRoot,
4027
+ cwd,
4028
+ instance,
4029
+ inputValues,
4030
+ runPackage,
4031
+ userCtx,
4032
+ emit,
4033
+ }) {
4034
+ const scriptRef = String(instance?.scriptRef || "").trim();
4035
+ const scriptAbs = scriptRef ? workspaceResolveFlowFile(scopedRoot, scriptRef, "scriptRef") : "";
4036
+ if (scriptAbs && (!fs.existsSync(scriptAbs) || !fs.statSync(scriptAbs).isFile())) {
4037
+ throw new Error(`scriptRef not found: ${scriptRef}`);
4038
+ }
4039
+ const outputRefs = workspaceOutputFileRefsForNode(instance);
4040
+ const outputAbs = Object.fromEntries(
4041
+ Object.entries(outputRefs).map(([key, rel]) => [key, path.resolve(runPackage.nodeRunDir, rel)]),
4042
+ );
4043
+ for (const abs of Object.values(outputAbs)) fs.mkdirSync(path.dirname(abs), { recursive: true });
4044
+ const constants = {
4045
+ workspaceRoot: path.resolve(scopedRoot),
4046
+ pipelineWorkspace: path.resolve(scopedRoot),
4047
+ flowDir: path.resolve(scopedRoot),
4048
+ cwd: path.resolve(cwd || scopedRoot),
4049
+ nodeRunDir: runPackage.nodeRunDir,
4050
+ nodeTmpDir: runPackage.nodeTmpDir,
4051
+ outputsDir: runPackage.outputsDir,
4052
+ scriptRef: scriptAbs,
4053
+ ...inputValues,
4054
+ ...outputRefs,
4055
+ };
4056
+ const inlineScript = String(instance?.script || "").trim();
4057
+ const command = inlineScript
4058
+ ? workspaceResolveScriptCommandText(inlineScript, constants)
4059
+ : scriptAbs
4060
+ ? workspaceDefaultScriptCommand(scriptAbs)
4061
+ : "";
4062
+ if (!command) throw new Error("tool_nodejs requires script or scriptRef");
4063
+
4064
+ emit?.({ type: "status", line: `Run script: ${scriptRef || command.slice(0, 120)}` });
4065
+
4066
+ const env = runtimeEnvForUser(userCtx, {
4067
+ AGENTFLOW_WORKSPACE_ROOT: path.resolve(scopedRoot),
4068
+ AGENTFLOW_NODE_RUN_DIR: runPackage.nodeRunDir,
4069
+ AGENTFLOW_NODE_TMP_DIR: runPackage.nodeTmpDir,
4070
+ AGENTFLOW_OUTPUTS_DIR: runPackage.outputsDir,
4071
+ AGENTFLOW_SCRIPT_REF: scriptAbs,
4072
+ AGENTFLOW_INPUTS_JSON: JSON.stringify(inputValues || {}),
4073
+ AGENTFLOW_OUTPUTS_JSON: JSON.stringify(outputRefs),
4074
+ AGENTFLOW_OUTPUTS_ABS_JSON: JSON.stringify(outputAbs),
4075
+ });
4076
+
4077
+ const started = Date.now();
4078
+ return await new Promise((resolve, reject) => {
4079
+ const child = spawn(command, [], {
4080
+ cwd: runPackage.nodeRunDir,
4081
+ shell: true,
4082
+ stdio: ["ignore", "pipe", "pipe"],
4083
+ env,
4084
+ });
4085
+ let stdout = "";
4086
+ let stderr = "";
4087
+ child.stdout.setEncoding("utf-8");
4088
+ child.stderr.setEncoding("utf-8");
4089
+ child.stdout.on("data", (chunk) => {
4090
+ stdout += String(chunk);
4091
+ });
4092
+ child.stderr.on("data", (chunk) => {
4093
+ stderr += String(chunk);
4094
+ });
4095
+ child.on("error", reject);
4096
+ child.on("close", (code) => {
4097
+ if (stderr.trim()) {
4098
+ emit?.({ type: "natural", kind: "warning", text: `[script stderr]\n${stderr.trim().slice(-4000)}` });
4099
+ }
4100
+ if (code !== 0) {
4101
+ reject(new Error(`tool_nodejs script exited ${code}${stderr.trim() ? `: ${stderr.trim().slice(-800)}` : ""}`));
4102
+ return;
4103
+ }
4104
+ const elapsedMs = Math.max(0, Date.now() - started);
4105
+ emit?.({ type: "status", line: `Timing script: ${elapsedMs}ms`, timing: { label: "script", elapsedMs } });
4106
+ const content = stdout.trim() || workspaceEnvelopeFromOutputFiles(outputRefs, runPackage.nodeRunDir);
4107
+ resolve(content);
4108
+ });
4109
+ });
4110
+ }
4111
+
3121
4112
  async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts = {}) {
3122
4113
  const graph = normalizeWorkspaceGraphPayload(payload.graph || {});
3123
4114
  const runNodeId = String(payload?.runNodeId || "").trim();
@@ -3190,7 +4181,7 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3190
4181
  const defId = String(instance.definitionId || "");
3191
4182
  emit({ type: "node-start", nodeId, definitionId: defId });
3192
4183
 
3193
- if (defId === "workspace_run") {
4184
+ if (defId === "workspace_run" || defId === "workspace_scheduled_run") {
3194
4185
  continue;
3195
4186
  }
3196
4187
 
@@ -3297,7 +4288,7 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3297
4288
  const targetRaw = workspaceSlotValue(workspaceSlotByName(instance, "targetDir")).trim();
3298
4289
  const targetDir = targetRaw
3299
4290
  ? workspaceResolvePath(cwd, targetRaw)
3300
- : path.join(scopedRoot, ".workspace", "agentflow", "git-repos", workspaceSanitizeRepoDirName(repoUrl));
4291
+ : path.join(workspaceDefaultGitRepoRoot(scopedRoot, userCtx), workspaceSanitizeRepoDirName(repoUrl));
3301
4292
  const pullIfExists = workspaceBoolSlot(instance, "pullIfExists", true);
3302
4293
  const includeSubmodules = workspaceBoolSlot(instance, "includeSubmodules", false);
3303
4294
  fs.mkdirSync(path.dirname(targetDir), { recursive: true });
@@ -3369,14 +4360,16 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3369
4360
  const worktreeInputSlot = (Array.isArray(instance.input) ? instance.input : [])
3370
4361
  .find((slot) => String(slot?.name || "") === "worktreePath") || null;
3371
4362
  const rawWorktreePath = workspaceSlotValue(worktreeInputSlot || workspaceSlotByName(instance, "worktreePath")).trim();
3372
- const worktreePath = rawWorktreePath ? workspaceResolvePath(cwd, rawWorktreePath) : (gitContext?.worktreePath ? path.resolve(gitContext.worktreePath) : "");
4363
+ const worktreePath = rawWorktreePath
4364
+ ? workspaceResolvePath(cwd, rawWorktreePath)
4365
+ : (gitContext?.worktreePath ? path.resolve(gitContext.worktreePath) : workspaceDefaultWorktreePath(runTmpRoot, nodeId, repoPath, branch));
3373
4366
  const hasExplicitWorktreePath = Boolean(rawWorktreePath) || Boolean(gitContext?.worktreePath);
3374
4367
  const previousCwd = cwd;
3375
4368
  const force = ["true", "1", "yes", "on"].includes(workspaceSlotValue(workspaceSlotByName(instance, "force")).trim().toLowerCase());
3376
4369
  const pruneMissingRaw = workspaceSlotValue(workspaceSlotByName(instance, "pruneMissing")).trim().toLowerCase();
3377
4370
  const pruneMissing = pruneMissingRaw !== "false";
3378
4371
  const result = loadGitWorktree({ repoPath, branch, worktreePath, pipelineWorkspace: scopedRoot, force, pruneMissing });
3379
- if (workspaceShouldAutoCleanupWorktree(scopedRoot, result.worktreePath, hasExplicitWorktreePath)) {
4372
+ if (workspaceShouldAutoCleanupWorktree(result.worktreePath, hasExplicitWorktreePath)) {
3380
4373
  workspaceTrackAutoCleanupWorktree(autoCleanupWorktrees, {
3381
4374
  nodeId,
3382
4375
  repoPath: result.repoRoot,
@@ -3480,6 +4473,51 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3480
4473
  continue;
3481
4474
  }
3482
4475
 
4476
+ if (defId === "tool_nodejs") {
4477
+ const prepareStartedAt = Date.now();
4478
+ const inputValues = workspaceInputValues(graph, nodeId, outputs);
4479
+ const runPackage = workspaceCreateNodeRunPackage(runTmpRoot, nodeId, {
4480
+ scopedRoot,
4481
+ cwd,
4482
+ task: String(instance.script || instance.scriptRef || instance.body || "").trim(),
4483
+ inputValues,
4484
+ });
4485
+ const runtimeInputValues = { ...inputValues, ...(runPackage.inputValues || {}) };
4486
+ emitTiming(nodeId, "prepare-script", prepareStartedAt, {
4487
+ inputCount: Object.keys(runtimeInputValues || {}).length,
4488
+ nodeRunDir: runPackage.nodeRunDir,
4489
+ });
4490
+ const content = await workspaceRunToolNodejsScript({
4491
+ scopedRoot,
4492
+ cwd,
4493
+ instance,
4494
+ inputValues: runtimeInputValues,
4495
+ runPackage,
4496
+ userCtx,
4497
+ emit: (event) => emit({ ...event, nodeId }),
4498
+ });
4499
+ const normalizedAgentOutput = workspacePublishAgentOutputFiles(workspaceStructuredAgentOutput(content), runPackage);
4500
+ const resultContent = normalizedAgentOutput.result || content;
4501
+ outputs.set(nodeId, resultContent);
4502
+ const slotUpdate = workspaceApplyAgentOutputSlots(instance, normalizedAgentOutput);
4503
+ if (slotUpdate.changed) graph.instances[nodeId] = slotUpdate.instance;
4504
+ const implementationUpdate = await workspaceTryPersistNodeImplementation(scopedRoot, graph, nodeId, {
4505
+ inputValues: runtimeInputValues,
4506
+ resultContent,
4507
+ structured: normalizedAgentOutput,
4508
+ runPackage,
4509
+ modelKey,
4510
+ userCtx,
4511
+ emit: (event) => emit({ ...event, nodeId }),
4512
+ onActiveChild: opts.onActiveChild,
4513
+ });
4514
+ if (implementationUpdate.changed) graph.instances[nodeId] = implementationUpdate.instance;
4515
+ const updatedDisplays = workspaceUpdateDirectDisplays(graph, nodeId, resultContent, outputs);
4516
+ if (slotUpdate.changed || implementationUpdate.changed || updatedDisplays.length) emit({ type: "graph", nodeId, displayNodeIds: updatedDisplays, graph });
4517
+ emit({ type: "node-done", nodeId, definitionId: defId });
4518
+ continue;
4519
+ }
4520
+
3483
4521
  const prepareStartedAt = Date.now();
3484
4522
  const inputValues = workspaceInputValues(graph, nodeId, outputs);
3485
4523
  const relevantInputs = workspaceRelevantInputValues(instance.body || "", inputValues);
@@ -3506,7 +4544,8 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3506
4544
  } catch {
3507
4545
  // Best-effort debug artifact only.
3508
4546
  }
3509
- const prompt = workspaceNodePrompt(graph, nodeId, promptUpstreamText, promptSkillsBlock, promptMcpBlock, runtimeInputValues, runPackage);
4547
+ const implementationBlock = workspaceImplementationBlock(instance, scopedRoot, runPackage);
4548
+ const prompt = workspaceNodePrompt(graph, nodeId, promptUpstreamText, promptSkillsBlock, promptMcpBlock, runtimeInputValues, runPackage, implementationBlock);
3510
4549
  try {
3511
4550
  fs.writeFileSync(path.join(runPackage.nodeRunDir, "prompt.md"), prompt.trimEnd() + "\n", "utf-8");
3512
4551
  } catch {
@@ -3592,8 +4631,19 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
3592
4631
  outputs.set(nodeId, resultContent);
3593
4632
  const slotUpdate = workspaceApplyAgentOutputSlots(instance, normalizedAgentOutput);
3594
4633
  if (slotUpdate.changed) graph.instances[nodeId] = slotUpdate.instance;
4634
+ const implementationUpdate = await workspaceTryPersistNodeImplementation(scopedRoot, graph, nodeId, {
4635
+ inputValues: runtimeInputValues,
4636
+ resultContent,
4637
+ structured: normalizedAgentOutput,
4638
+ runPackage,
4639
+ modelKey,
4640
+ userCtx,
4641
+ emit: (event) => emit({ ...event, nodeId }),
4642
+ onActiveChild: opts.onActiveChild,
4643
+ });
4644
+ if (implementationUpdate.changed) graph.instances[nodeId] = implementationUpdate.instance;
3595
4645
  const updatedDisplays = workspaceUpdateDirectDisplays(graph, nodeId, resultContent, outputs);
3596
- if (slotUpdate.changed || updatedDisplays.length) emit({ type: "graph", nodeId, displayNodeIds: updatedDisplays, graph });
4646
+ if (slotUpdate.changed || implementationUpdate.changed || updatedDisplays.length) emit({ type: "graph", nodeId, displayNodeIds: updatedDisplays, graph });
3597
4647
  emit({ type: "node-done", nodeId, definitionId: defId });
3598
4648
  }
3599
4649
  } finally {
@@ -4112,6 +5162,46 @@ export function startUiServer({
4112
5162
  }
4113
5163
  }
4114
5164
 
5165
+ if (url.pathname === "/api/admin/storage-config") {
5166
+ if (!authUser?.isAdmin) {
5167
+ json(res, 403, { error: "Admin permission required" });
5168
+ return;
5169
+ }
5170
+ if (req.method === "GET") {
5171
+ json(res, 200, { config: readAdminStorageConfig() });
5172
+ return;
5173
+ }
5174
+ if (req.method === "POST") {
5175
+ let payload;
5176
+ try {
5177
+ payload = JSON.parse(await readBody(req));
5178
+ } catch {
5179
+ json(res, 400, { error: "Invalid JSON body" });
5180
+ return;
5181
+ }
5182
+ try {
5183
+ const config = writeAdminStorageConfig(payload?.config || payload || {});
5184
+ json(res, 200, { ok: true, config });
5185
+ } catch (e) {
5186
+ json(res, 400, { error: (e && e.message) || String(e) });
5187
+ }
5188
+ return;
5189
+ }
5190
+ }
5191
+
5192
+ if (req.method === "GET" && url.pathname === "/api/admin/usage-dashboard") {
5193
+ if (!authUser?.isAdmin) {
5194
+ json(res, 403, { error: "Admin permission required" });
5195
+ return;
5196
+ }
5197
+ try {
5198
+ json(res, 200, buildAdminUsageDashboard(root));
5199
+ } catch (e) {
5200
+ json(res, 500, { error: (e && e.message) || String(e) });
5201
+ }
5202
+ return;
5203
+ }
5204
+
4115
5205
  if (url.pathname === "/api/admin/user-allowlist") {
4116
5206
  if (!authUser?.isAdmin) {
4117
5207
  json(res, 403, { error: "Admin permission required" });
@@ -4408,9 +5498,10 @@ export function startUiServer({
4408
5498
  return;
4409
5499
  }
4410
5500
  const { path: graphPath, graph } = readWorkspaceGraph(scoped.root);
5501
+ const hydratedGraph = hydrateWorkspaceNodeRefsFromFiles(scoped.root, graph);
4411
5502
  json(res, 200, {
4412
5503
  ok: true,
4413
- graph,
5504
+ graph: hydratedGraph,
4414
5505
  path: graphPath,
4415
5506
  root: scoped.root,
4416
5507
  flowId: scoped.flowId,
@@ -4497,8 +5588,10 @@ export function startUiServer({
4497
5588
  json(res, 400, { error: "Cannot write workspace graph for builtin or archived pipeline" });
4498
5589
  return;
4499
5590
  }
4500
- const graph = normalizeWorkspaceGraphPayload(payload.graph || payload);
5591
+ const submittedGraph = normalizeWorkspaceGraphPayload(payload.graph || payload);
4501
5592
  const graphPath = workspaceGraphPath(scoped.root);
5593
+ const currentGraph = hydrateWorkspaceNodeRefsFromFiles(scoped.root, readWorkspaceGraph(scoped.root).graph);
5594
+ const graph = mergeWorkspacePersistentNodeRefs(submittedGraph, currentGraph);
4502
5595
  fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2) + "\n", "utf-8");
4503
5596
  json(res, 200, { ok: true, path: graphPath, graph });
4504
5597
  } catch (e) {
@@ -4544,6 +5637,9 @@ export function startUiServer({
4544
5637
  const runEntry = {
4545
5638
  controller,
4546
5639
  child: null,
5640
+ runId: runLedgerId("workspace"),
5641
+ userId: String(userCtx.userId || ""),
5642
+ username: String(authUser?.username || userCtx.userId || ""),
4547
5643
  runNodeId: String(payload.runNodeId || "").trim(),
4548
5644
  flowId,
4549
5645
  flowSource: scoped.flowSource || payload.flowSource || "user",
@@ -4555,6 +5651,7 @@ export function startUiServer({
4555
5651
  },
4556
5652
  };
4557
5653
  activeWorkspaceRuns.set(runKey, runEntry);
5654
+ appendWorkspaceRunStarted(runEntry);
4558
5655
  const setActiveChild = (child) => {
4559
5656
  runEntry.child = child || null;
4560
5657
  if (controller.signal.aborted) runEntry.stopChild();
@@ -4582,12 +5679,27 @@ export function startUiServer({
4582
5679
  const touchedIds = workspaceRunTouchedNodeIds(result);
4583
5680
  const mergedGraph = mergeWorkspaceRunGraph(currentGraph, result.graph, touchedIds);
4584
5681
  fs.writeFileSync(graphPath, JSON.stringify(mergedGraph, null, 2) + "\n", "utf-8");
5682
+ appendWorkspaceRunFinished({
5683
+ ...runEntry,
5684
+ endedAt: Date.now(),
5685
+ durationMs: Date.now() - runEntry.startedAt,
5686
+ }, "success");
4585
5687
  writeEvent({ type: "done", ok: true, path: graphPath, graph: mergedGraph, order: result.order, touchedNodeIds: Array.from(touchedIds), pauseNodeIds: result.pauseNodeIds || [] });
4586
5688
  res.end();
4587
5689
  } catch (e) {
4588
5690
  if (isWorkspaceRunAbortError(e) || controller.signal.aborted) {
5691
+ appendWorkspaceRunFinished({
5692
+ ...runEntry,
5693
+ endedAt: Date.now(),
5694
+ durationMs: Date.now() - runEntry.startedAt,
5695
+ }, "stopped");
4589
5696
  writeEvent({ type: "stopped", ok: false, stopped: true, message: "Workspace run stopped" });
4590
5697
  } else {
5698
+ appendWorkspaceRunFinished({
5699
+ ...runEntry,
5700
+ endedAt: Date.now(),
5701
+ durationMs: Date.now() - runEntry.startedAt,
5702
+ }, "failed");
4591
5703
  writeEvent({ type: "error", error: (e && e.message) || String(e) });
4592
5704
  }
4593
5705
  res.end();
@@ -4606,11 +5718,26 @@ export function startUiServer({
4606
5718
  const touchedIds = workspaceRunTouchedNodeIds(result);
4607
5719
  const mergedGraph = mergeWorkspaceRunGraph(currentGraph, result.graph, touchedIds);
4608
5720
  fs.writeFileSync(graphPath, JSON.stringify(mergedGraph, null, 2) + "\n", "utf-8");
5721
+ appendWorkspaceRunFinished({
5722
+ ...runEntry,
5723
+ endedAt: Date.now(),
5724
+ durationMs: Date.now() - runEntry.startedAt,
5725
+ }, "success");
4609
5726
  json(res, 200, { ok: true, path: graphPath, ...result, graph: mergedGraph, touchedNodeIds: Array.from(touchedIds) });
4610
5727
  } catch (e) {
4611
5728
  if (isWorkspaceRunAbortError(e) || controller.signal.aborted) {
5729
+ appendWorkspaceRunFinished({
5730
+ ...runEntry,
5731
+ endedAt: Date.now(),
5732
+ durationMs: Date.now() - runEntry.startedAt,
5733
+ }, "stopped");
4612
5734
  json(res, 200, { ok: false, stopped: true, message: "Workspace run stopped" });
4613
5735
  } else {
5736
+ appendWorkspaceRunFinished({
5737
+ ...runEntry,
5738
+ endedAt: Date.now(),
5739
+ durationMs: Date.now() - runEntry.startedAt,
5740
+ }, "failed");
4614
5741
  throw e;
4615
5742
  }
4616
5743
  } finally {