@fieldwangai/agentflow 0.1.69 → 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.
package/bin/lib/apply.mjs CHANGED
@@ -24,6 +24,7 @@ import { printEntryAndFlowFiles, printNodeStatusTable, runValidateFlowAndExitIfI
24
24
  import { clearApplyActiveLock, writeApplyActiveLock } from "./run-apply-active-lock.mjs";
25
25
  import { ensureReference, findFlowNameByUuid, getFlowDir, getRunDir } from "./workspace.mjs";
26
26
  import { readMergedEnvObject } from "./user-env.mjs";
27
+ import { appendRunLedgerEvent } from "./run-ledger.mjs";
27
28
 
28
29
  const PARALLEL_PREFIX_COLORS = [
29
30
  (s) => chalk.cyan(s),
@@ -72,6 +73,31 @@ export async function apply(workspaceRoot, flowName, uuidArg, dryRun, agentModel
72
73
  // 提前 ensure:apply-start 携带原始 run 起始时间,UI 计时器可按「这个 uuid 从头到现在的总时长」显示,
73
74
  // 避免每次 resume 仅从 totalExecutedMs 累加看起来像从 resume 才开始计时。
74
75
  const priorRunStartTime = ensureRunStartTime(workspaceRoot, flowName, uuid);
76
+ let runStartTime = priorRunStartTime;
77
+ let totalExecutedMs = priorTotalExecutedMs;
78
+ const writeRunLedger = !dryRun;
79
+ let runLedgerFinished = false;
80
+ const runLedgerBase = {
81
+ kind: "pipeline",
82
+ runId: uuid,
83
+ userId: String(process.env.AGENTFLOW_USER_ID || ""),
84
+ username: String(process.env.AGENTFLOW_USER_ID || ""),
85
+ flowId: flowName,
86
+ flowSource: "user",
87
+ at: priorRunStartTime,
88
+ };
89
+ if (writeRunLedger) appendRunLedgerEvent({ ...runLedgerBase, type: "run_started" });
90
+ const finishRunLedger = (status) => {
91
+ if (!writeRunLedger || runLedgerFinished) return;
92
+ runLedgerFinished = true;
93
+ appendRunLedgerEvent({
94
+ ...runLedgerBase,
95
+ type: "run_finished",
96
+ endedAt: Date.now(),
97
+ durationMs: totalExecutedMs,
98
+ status,
99
+ });
100
+ };
75
101
  emitEvent(workspaceRoot, flowName, uuid, {
76
102
  event: "apply-start",
77
103
  flowName,
@@ -85,8 +111,6 @@ export async function apply(workspaceRoot, flowName, uuidArg, dryRun, agentModel
85
111
  writeApplyActiveLock(workspaceRoot, flowName, uuid);
86
112
 
87
113
  try {
88
- let runStartTime = priorRunStartTime;
89
- let totalExecutedMs = priorTotalExecutedMs;
90
114
  let round = 0;
91
115
  while (round < MAX_LOOP_ROUNDS) {
92
116
  round++;
@@ -100,6 +124,7 @@ export async function apply(workspaceRoot, flowName, uuidArg, dryRun, agentModel
100
124
  if (readyNodes.length === 0) {
101
125
  if (allDone) {
102
126
  saveTotalExecutedMs(workspaceRoot, flowName, uuid, totalExecutedMs);
127
+ finishRunLedger("success");
103
128
  const totalElapsed = formatDuration(totalExecutedMs);
104
129
  emitEvent(workspaceRoot, flowName, uuid, {
105
130
  event: "apply-done",
@@ -163,6 +188,7 @@ export async function apply(workspaceRoot, flowName, uuidArg, dryRun, agentModel
163
188
  if (options.length === 0) {
164
189
  log.info(chalk.yellow(`节点 ${pendId} 未配置任何选项(output 槽位),无法选择。请先在编辑器中添加 output 槽位。`));
165
190
  log.info(chalk.bold.yellow("→ " + t("flow.resume_hint") + " ") + resumeExample);
191
+ finishRunLedger("interrupted");
166
192
  return;
167
193
  }
168
194
 
@@ -177,6 +203,7 @@ export async function apply(workspaceRoot, flowName, uuidArg, dryRun, agentModel
177
203
  }
178
204
  process.stderr.write(chalk.bold.cyan("━━━━━━━━━━━━━━━━━━━━━━━━━\n"));
179
205
  log.info(chalk.bold.yellow("→ " + t("flow.resume_hint") + " ") + resumeExample);
206
+ finishRunLedger("interrupted");
180
207
  return;
181
208
  }
182
209
 
@@ -209,6 +236,7 @@ export async function apply(workspaceRoot, flowName, uuidArg, dryRun, agentModel
209
236
  if (trimmed === "q") {
210
237
  rl.close();
211
238
  log.info(chalk.yellow("用户取消,流程暂停。") + chalk.dim(` 恢复命令: ${resumeExample}`));
239
+ finishRunLedger("interrupted");
212
240
  return;
213
241
  }
214
242
  const idx = parseInt(trimmed, 10);
@@ -250,6 +278,7 @@ export async function apply(workspaceRoot, flowName, uuidArg, dryRun, agentModel
250
278
  }
251
279
  process.stderr.write(chalk.bold.cyan("━━━━━━━━━━━━━━━━━━━━━━━━━\n"));
252
280
  log.info(chalk.bold.yellow("→ " + t("flow.resume_hint") + " ") + resumeExample);
281
+ finishRunLedger("interrupted");
253
282
  return;
254
283
  }
255
284
 
@@ -380,6 +409,7 @@ ${currentContent}
380
409
  } else if (answer.trim().toLowerCase() === "q") {
381
410
  rl.close();
382
411
  log.info(chalk.yellow("用户取消,流程暂停。") + chalk.dim(` 恢复命令: ${resumeExample}`));
412
+ finishRunLedger("interrupted");
383
413
  return;
384
414
  }
385
415
  }
@@ -393,6 +423,7 @@ ${currentContent}
393
423
  }
394
424
 
395
425
  log.info(chalk.bold.yellow("→ " + t("flow.resume_hint") + " ") + resumeExample);
426
+ finishRunLedger("interrupted");
396
427
  return;
397
428
  }
398
429
  const endNodeIds = Array.isArray(parseOut.nodes)
@@ -401,6 +432,7 @@ ${currentContent}
401
432
  const endReached = endNodeIds.some((id) => instanceStatus[id] === "success");
402
433
  if (endReached) {
403
434
  saveTotalExecutedMs(workspaceRoot, flowName, uuid, totalExecutedMs);
435
+ finishRunLedger("success");
404
436
  const totalElapsed = formatDuration(totalExecutedMs);
405
437
  emitEvent(workspaceRoot, flowName, uuid, {
406
438
  event: "apply-done",
@@ -763,6 +795,7 @@ ${currentContent}
763
795
  maxErr.uuid = uuid;
764
796
  throw maxErr;
765
797
  } finally {
798
+ finishRunLedger("failed");
766
799
  clearApplyActiveLock(workspaceRoot, flowName, uuid);
767
800
  }
768
801
  }
@@ -0,0 +1,96 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import crypto from "crypto";
4
+
5
+ import { getAgentflowDataRoot } from "./paths.mjs";
6
+
7
+ function localDayKey(timeMs) {
8
+ const d = new Date(Number(timeMs) || Date.now());
9
+ const y = d.getFullYear();
10
+ const m = String(d.getMonth() + 1).padStart(2, "0");
11
+ const day = String(d.getDate()).padStart(2, "0");
12
+ return `${y}-${m}-${day}`;
13
+ }
14
+
15
+ function safeSegment(value, fallback = "run") {
16
+ return String(value || fallback)
17
+ .trim()
18
+ .replace(/[^a-zA-Z0-9._-]+/g, "_")
19
+ .replace(/^_+|_+$/g, "")
20
+ .slice(0, 120) || fallback;
21
+ }
22
+
23
+ export function runLedgerId(prefix = "run") {
24
+ return `${safeSegment(prefix, "run")}-${Date.now().toString(36)}-${crypto.randomBytes(4).toString("hex")}`;
25
+ }
26
+
27
+ export function runLedgerDir() {
28
+ return path.join(getAgentflowDataRoot(), "admin", "run-ledger");
29
+ }
30
+
31
+ export function runLedgerPath(timeMs = Date.now()) {
32
+ return path.join(runLedgerDir(), `${localDayKey(timeMs)}.jsonl`);
33
+ }
34
+
35
+ export function appendRunLedgerEvent(event = {}) {
36
+ try {
37
+ const item = {
38
+ version: 1,
39
+ type: String(event.type || ""),
40
+ kind: String(event.kind || ""),
41
+ runId: String(event.runId || runLedgerId()),
42
+ userId: String(event.userId || ""),
43
+ username: String(event.username || event.userId || ""),
44
+ flowId: String(event.flowId || ""),
45
+ flowSource: String(event.flowSource || "user"),
46
+ runNodeId: String(event.runNodeId || ""),
47
+ at: Number(event.at || event.startedAt || Date.now()),
48
+ endedAt: event.endedAt == null ? null : Number(event.endedAt),
49
+ durationMs: Math.max(0, Number(event.durationMs || 0)),
50
+ status: event.status ? String(event.status || "") : "",
51
+ };
52
+ if (!item.type || !item.kind || !item.runId || !item.flowId) return;
53
+ const filePath = runLedgerPath(item.at);
54
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
55
+ fs.appendFileSync(filePath, JSON.stringify(item) + "\n", "utf-8");
56
+ } catch {
57
+ // Usage telemetry must never affect the run itself.
58
+ }
59
+ }
60
+
61
+ function readJsonlObjects(filePath) {
62
+ if (!fs.existsSync(filePath)) return [];
63
+ try {
64
+ const out = [];
65
+ const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/);
66
+ for (const line of lines) {
67
+ if (!line.trim()) continue;
68
+ try {
69
+ const parsed = JSON.parse(line);
70
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) out.push(parsed);
71
+ } catch {
72
+ /* ignore malformed line */
73
+ }
74
+ }
75
+ return out;
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+
81
+ export function readRunLedgerEvents(options = {}) {
82
+ const dir = runLedgerDir();
83
+ if (!fs.existsSync(dir)) return [];
84
+ try {
85
+ const sinceMs = Number(options?.sinceMs || 0);
86
+ const sinceKey = Number.isFinite(sinceMs) && sinceMs > 0 ? localDayKey(sinceMs) : "";
87
+ const files = fs.readdirSync(dir, { withFileTypes: true })
88
+ .filter((entry) => entry.isFile() && /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(entry.name))
89
+ .filter((entry) => !sinceKey || entry.name.slice(0, 10) >= sinceKey)
90
+ .map((entry) => path.join(dir, entry.name))
91
+ .sort();
92
+ return files.flatMap((filePath) => readJsonlObjects(filePath));
93
+ } catch {
94
+ return [];
95
+ }
96
+ }
@@ -110,6 +110,11 @@ import {
110
110
  updateAdminBuiltinPipelineConfig,
111
111
  } from "./admin-builtin-pipelines.mjs";
112
112
  import { readAdminStorageConfig, writeAdminStorageConfig } from "./admin-storage-config.mjs";
113
+ import {
114
+ appendRunLedgerEvent,
115
+ readRunLedgerEvents,
116
+ runLedgerId,
117
+ } from "./run-ledger.mjs";
113
118
 
114
119
  const MIME = {
115
120
  ".html": "text/html; charset=utf-8",
@@ -1498,6 +1503,7 @@ function pipelineCountsForUser(userId) {
1498
1503
  }
1499
1504
 
1500
1505
  const USAGE_DAY_MS = 24 * 60 * 60 * 1000;
1506
+ const RUN_LEDGER_STALE_MS = 6 * 60 * 60 * 1000;
1501
1507
 
1502
1508
  function startOfLocalDayMs(timeMs) {
1503
1509
  const d = new Date(Number(timeMs) || Date.now());
@@ -1554,7 +1560,12 @@ function buildUsageDailyTrend(runs, days = 14, nowMs = Date.now()) {
1554
1560
  row[bucket] += 1;
1555
1561
  row.totalDurationMs += Math.max(0, Number(run.durationMs || 0));
1556
1562
  row._userIds.add(String(run.userId || ""));
1557
- row._pipelineKeys.add(`${run.userId || ""}:${run.flowSource || ""}:${run.flowId || ""}`);
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
+ }
1558
1569
  }
1559
1570
  return rows.map((row) => {
1560
1571
  row.users = row._userIds.size;
@@ -1566,7 +1577,7 @@ function buildUsageDailyTrend(runs, days = 14, nowMs = Date.now()) {
1566
1577
  });
1567
1578
  }
1568
1579
 
1569
- function buildUsageRates(users, runs, nowMs = Date.now()) {
1580
+ function buildUsageRates(users, runs, nowMs = Date.now(), workspacePipelineCount = 0) {
1570
1581
  const windowDays = 7;
1571
1582
  const sinceMs = startOfLocalDayMs(nowMs) - (windowDays - 1) * USAGE_DAY_MS;
1572
1583
  const recentRuns = runs.filter((run) => Number(run?.at || 0) >= sinceMs);
@@ -1577,13 +1588,16 @@ function buildUsageRates(users, runs, nowMs = Date.now()) {
1577
1588
  for (const run of recentRuns) {
1578
1589
  const userId = String(run.userId || "");
1579
1590
  if (userId) activeUsers.add(userId);
1580
- activePipelines.add(`${userId}:${run.flowSource || ""}:${run.flowId || ""}`);
1591
+ const flowSource = String(run.flowSource || "user");
1592
+ const flowId = String(run.flowId || "");
1593
+ activePipelines.add(flowSource === "workspace" ? `workspace:${flowId}` : `${userId}:${flowSource}:${flowId}`);
1581
1594
  const bucket = runStatusBucket(run.status);
1582
1595
  statusCounts[bucket] = (statusCounts[bucket] || 0) + 1;
1583
1596
  recentDurationMs += Math.max(0, Number(run.durationMs || 0));
1584
1597
  }
1585
1598
  const totalUsers = users.length;
1586
- const totalActivePipelines = users.reduce((sum, user) => sum + Math.max(0, Number(user?.pipelines?.active || 0)), 0);
1599
+ const totalActivePipelines = users.reduce((sum, user) => sum + Math.max(0, Number(user?.pipelines?.active || 0)), 0)
1600
+ + Math.max(0, Number(workspacePipelineCount || 0));
1587
1601
  const completedRuns = recentRuns.length - (statusCounts.running || 0);
1588
1602
  const badRuns = (statusCounts.failed || 0) + (statusCounts.stopped || 0) + (statusCounts.interrupted || 0) + (statusCounts.unknown || 0);
1589
1603
  return {
@@ -1603,21 +1617,196 @@ function buildUsageRates(users, runs, nowMs = Date.now()) {
1603
1617
  };
1604
1618
  }
1605
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
+
1606
1778
  function buildAdminUsageDashboard(workspaceRoot) {
1607
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
+ ]);
1608
1788
  const userIds = Array.from(new Set([
1609
1789
  ...Object.keys(authUsers || {}),
1610
1790
  ...listAgentflowUserIds(),
1791
+ ...workspaceUsageRuns.map((run) => run.userId),
1611
1792
  ].map((id) => String(id || "").trim()).filter(Boolean))).sort((a, b) => a.localeCompare(b));
1612
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
+ }
1613
1800
  const users = userIds.map((userId) => {
1614
1801
  const user = authUsers[userId] || {};
1615
1802
  const pipelineCounts = pipelineCountsForUser(userId);
1616
- const runs = listRecentRunsFromDisk(workspaceRoot, {
1803
+ const pipelineRuns = listRecentRunsFromDisk(workspaceRoot, {
1617
1804
  userId,
1618
1805
  includeWorkspaceRuns: false,
1619
1806
  includeLegacyUserRuns: false,
1620
1807
  });
1808
+ const runs = [...pipelineRuns, ...(workspaceUsageByUser.get(userId) || [])]
1809
+ .sort((a, b) => Number(b.at || 0) - Number(a.at || 0));
1621
1810
  const statusCounts = {};
1622
1811
  let totalDurationMs = 0;
1623
1812
  for (const run of runs) {
@@ -1688,11 +1877,28 @@ function buildAdminUsageDashboard(workspaceRoot) {
1688
1877
  totalDurationMs: 0,
1689
1878
  });
1690
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
+ }));
1691
1896
  return {
1692
1897
  generatedAt: new Date().toISOString(),
1693
1898
  totals,
1694
- usage: buildUsageRates(users, allRuns),
1899
+ usage: buildUsageRates(users, allRuns, Date.now(), workspacePipelineCount),
1695
1900
  dailyTrend: buildUsageDailyTrend(allRuns, 14),
1901
+ recentRuns,
1696
1902
  users,
1697
1903
  };
1698
1904
  }
@@ -3049,28 +3255,46 @@ function workspaceImplementationInlineText(instance) {
3049
3255
  return "";
3050
3256
  }
3051
3257
 
3052
- function workspaceImplementationBlock(instance, scopedRoot) {
3053
- const parts = [];
3258
+ function workspaceMaterializeImplementationReference(instance, scopedRoot, runPackage = {}) {
3054
3259
  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()}`);
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 = "";
3060
3277
  }
3061
3278
  }
3279
+ return { implementationRef, exists, mounted };
3280
+ }
3281
+
3282
+ function workspaceImplementationBlock(instance, scopedRoot, runPackage = {}) {
3283
+ const ref = workspaceMaterializeImplementationReference(instance, scopedRoot, runPackage);
3062
3284
  const inline = workspaceImplementationInlineText(instance);
3063
- if (inline) parts.push(`### Inline implementation\n\n${inline}`);
3064
- if (!parts.length) return "";
3285
+ if (!ref && !inline) return "";
3065
3286
  const mode = String(instance?.implementationMode || "").trim();
3066
3287
  return [
3067
3288
  "## 参考实现方案",
3068
3289
  "",
3069
3290
  mode ? `mode: ${mode}` : "",
3291
+ ref ? `- 实现方案文件:\`${ref.mounted || ref.implementationRef}\`` : "",
3292
+ ref?.mounted ? `- 原始流水线路径:\`${ref.implementationRef}\`` : "",
3293
+ ref && !ref.exists ? "- 当前实现方案文件不存在,本次不要依赖旧方案。" : "",
3294
+ inline ? "- 节点存在内联实现方案字段,但本提示不会内联其内容;如需复用,请优先参考实现方案文件。" : "",
3070
3295
  "",
3071
- ...parts,
3072
- "",
3073
- "以上方案用于加速本次执行。若发现过期,可以按当前任务修正执行,但最终结果必须以当前输入为准。",
3296
+ "该文件只作为可选参考,用于了解上次执行的实现路径。不要把旧方案当成硬约束;如果与当前任务、输入或输出要求冲突,以当前任务为准。",
3297
+ "只有在需要复用细节或确认历史约定时才读取该文件;不要在最终回复中复述参考方案内容。",
3074
3298
  ].filter((line) => line !== "").join("\n");
3075
3299
  }
3076
3300
 
@@ -4320,7 +4544,7 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
4320
4544
  } catch {
4321
4545
  // Best-effort debug artifact only.
4322
4546
  }
4323
- const implementationBlock = workspaceImplementationBlock(instance, scopedRoot);
4547
+ const implementationBlock = workspaceImplementationBlock(instance, scopedRoot, runPackage);
4324
4548
  const prompt = workspaceNodePrompt(graph, nodeId, promptUpstreamText, promptSkillsBlock, promptMcpBlock, runtimeInputValues, runPackage, implementationBlock);
4325
4549
  try {
4326
4550
  fs.writeFileSync(path.join(runPackage.nodeRunDir, "prompt.md"), prompt.trimEnd() + "\n", "utf-8");
@@ -5413,6 +5637,9 @@ export function startUiServer({
5413
5637
  const runEntry = {
5414
5638
  controller,
5415
5639
  child: null,
5640
+ runId: runLedgerId("workspace"),
5641
+ userId: String(userCtx.userId || ""),
5642
+ username: String(authUser?.username || userCtx.userId || ""),
5416
5643
  runNodeId: String(payload.runNodeId || "").trim(),
5417
5644
  flowId,
5418
5645
  flowSource: scoped.flowSource || payload.flowSource || "user",
@@ -5424,6 +5651,7 @@ export function startUiServer({
5424
5651
  },
5425
5652
  };
5426
5653
  activeWorkspaceRuns.set(runKey, runEntry);
5654
+ appendWorkspaceRunStarted(runEntry);
5427
5655
  const setActiveChild = (child) => {
5428
5656
  runEntry.child = child || null;
5429
5657
  if (controller.signal.aborted) runEntry.stopChild();
@@ -5451,12 +5679,27 @@ export function startUiServer({
5451
5679
  const touchedIds = workspaceRunTouchedNodeIds(result);
5452
5680
  const mergedGraph = mergeWorkspaceRunGraph(currentGraph, result.graph, touchedIds);
5453
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");
5454
5687
  writeEvent({ type: "done", ok: true, path: graphPath, graph: mergedGraph, order: result.order, touchedNodeIds: Array.from(touchedIds), pauseNodeIds: result.pauseNodeIds || [] });
5455
5688
  res.end();
5456
5689
  } catch (e) {
5457
5690
  if (isWorkspaceRunAbortError(e) || controller.signal.aborted) {
5691
+ appendWorkspaceRunFinished({
5692
+ ...runEntry,
5693
+ endedAt: Date.now(),
5694
+ durationMs: Date.now() - runEntry.startedAt,
5695
+ }, "stopped");
5458
5696
  writeEvent({ type: "stopped", ok: false, stopped: true, message: "Workspace run stopped" });
5459
5697
  } else {
5698
+ appendWorkspaceRunFinished({
5699
+ ...runEntry,
5700
+ endedAt: Date.now(),
5701
+ durationMs: Date.now() - runEntry.startedAt,
5702
+ }, "failed");
5460
5703
  writeEvent({ type: "error", error: (e && e.message) || String(e) });
5461
5704
  }
5462
5705
  res.end();
@@ -5475,11 +5718,26 @@ export function startUiServer({
5475
5718
  const touchedIds = workspaceRunTouchedNodeIds(result);
5476
5719
  const mergedGraph = mergeWorkspaceRunGraph(currentGraph, result.graph, touchedIds);
5477
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");
5478
5726
  json(res, 200, { ok: true, path: graphPath, ...result, graph: mergedGraph, touchedNodeIds: Array.from(touchedIds) });
5479
5727
  } catch (e) {
5480
5728
  if (isWorkspaceRunAbortError(e) || controller.signal.aborted) {
5729
+ appendWorkspaceRunFinished({
5730
+ ...runEntry,
5731
+ endedAt: Date.now(),
5732
+ durationMs: Date.now() - runEntry.startedAt,
5733
+ }, "stopped");
5481
5734
  json(res, 200, { ok: false, stopped: true, message: "Workspace run stopped" });
5482
5735
  } else {
5736
+ appendWorkspaceRunFinished({
5737
+ ...runEntry,
5738
+ endedAt: Date.now(),
5739
+ durationMs: Date.now() - runEntry.startedAt,
5740
+ }, "failed");
5483
5741
  throw e;
5484
5742
  }
5485
5743
  } finally {