@fieldwangai/agentflow 0.1.143 → 0.1.145

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/README.md CHANGED
@@ -58,6 +58,9 @@ npm install -g @fieldwangai/agentflow
58
58
  # Launch Web UI (port 8765)
59
59
  agentflow ui
60
60
 
61
+ # Generate and open a single-file static preview using the platform canvas (no local server)
62
+ agentflow flow preview ./my-flow/flow.yaml
63
+
61
64
  # Or run a flow directly
62
65
  agentflow apply <FlowName>
63
66
  ```
@@ -125,6 +128,8 @@ AgentFlow provides specialized skills for common operations:
125
128
 
126
129
  | Skill | Description |
127
130
  |-------|-------------|
131
+ | `agentflow-author-flow` | Generate a Flow from natural language in Codex/Cursor, validate it, open a static preview, and publish it to personal, workspace, or team scope after confirmation |
132
+ | `agentflow-cli` | Query, publish, and run platform Flows directly with a token and no MCP |
128
133
  | `agentflow-flow-add-instances` | Add new nodes to flow.yaml with proper YAML structure, connection design, and positioning |
129
134
  | `agentflow-flow-edit-node-fields` | Edit allowed fields in existing nodes (label, body, role, input/output values) without breaking topology |
130
135
  | `agentflow-flow-sync-ui` | Sync flow.yaml changes to Web UI canvas after saving to disk |
@@ -132,6 +137,8 @@ AgentFlow provides specialized skills for common operations:
132
137
 
133
138
  Skills are automatically loaded when relevant tasks are detected, providing domain-specific instructions and workflows.
134
139
 
140
+ For example, tell Codex/Cursor: “Use `agentflow-author-flow` to generate a Flow that sends a WeCom notification after a Jenkins build, open the local preview first, and publish it to my team after I confirm.” The agent handles the local files, validation, preview, and publish command; the user only confirms the result.
141
+
135
142
  ## Tutorials
136
143
 
137
144
  - [Quickstart: PR Workflow Automation](docs/wiki/quickstart-pr-workflow.en.md)
@@ -144,6 +151,7 @@ Skills are automatically loaded when relevant tasks are detected, providing doma
144
151
  |---------|-------------|
145
152
  | `list` | List all pipelines |
146
153
  | `ui` | Start Web UI |
154
+ | `flow preview <FlowName\|flow.yaml>` | Generate a single-file static Flow preview; use `--output` to choose the HTML path |
147
155
  | `apply` | Execute flow |
148
156
  | `validate` | Validate flow structure |
149
157
  | `resume` | Resume from breakpoint |
package/README.zh-CN.md CHANGED
@@ -58,6 +58,9 @@ npm install -g @fieldwangai/agentflow
58
58
  # 启动 Web UI(端口 8765)
59
59
  agentflow ui
60
60
 
61
+ # 生成并打开平台同款画布的单文件静态预览(无需本地服务)
62
+ agentflow flow preview ./my-flow/flow.yaml
63
+
61
64
  # 或直接运行流程
62
65
  agentflow apply <FlowName>
63
66
  ```
@@ -125,6 +128,8 @@ AgentFlow 提供专用技能用于常见操作:
125
128
 
126
129
  | 技能 | 说明 |
127
130
  |------|------|
131
+ | `agentflow-author-flow` | 在 Codex/Cursor 中根据自然语言生成 Flow,自动校验并打开静态预览;确认后发布到个人、Workspace 或团队 |
132
+ | `agentflow-cli` | 通过 token 直接查询、发布和运行平台 Flow,无需 MCP |
128
133
  | `agentflow-flow-add-instances` | 向 flow.yaml 添加新节点,包括正确的 YAML 结构、连线设计和位置定位 |
129
134
  | `agentflow-flow-edit-node-fields` | 编辑已有节点的允许字段(label、body、role、input/output 值)而不破坏拓扑 |
130
135
  | `agentflow-flow-sync-ui` | 保存 flow.yaml 到磁盘后同步变更到 Web UI 画布 |
@@ -132,6 +137,8 @@ AgentFlow 提供专用技能用于常见操作:
132
137
 
133
138
  技能在检测到相关任务时自动加载,提供领域特定的指令和工作流。
134
139
 
140
+ 例如直接对 Codex/Cursor 说:“用 `agentflow-author-flow` 生成一个 Jenkins 构建完成后通知企业微信的 Flow,先打开本地预览,我确认后发布到团队。” Agent 会处理本地文件、校验、预览和发布命令;用户只负责确认效果。
141
+
135
142
  ## 教程
136
143
 
137
144
  - [快速上手:PR 流程自动化](docs/wiki/quickstart-pr-workflow.zh-CN.md)
@@ -144,6 +151,7 @@ AgentFlow 提供专用技能用于常见操作:
144
151
  |------|------|
145
152
  | `list` | 列出所有流水线 |
146
153
  | `ui` | 启动 Web UI |
154
+ | `flow preview <FlowName\|flow.yaml>` | 生成平台同款画布的单文件静态 Flow 预览;可用 `--output` 指定 HTML |
147
155
  | `apply` | 执行流程 |
148
156
  | `validate` | 校验流程结构 |
149
157
  | `resume` | 断点续跑 |
package/bin/lib/auth.mjs CHANGED
@@ -88,6 +88,58 @@ export function readAuthUsers() {
88
88
  return readJsonObject(usersPath());
89
89
  }
90
90
 
91
+ export function listAuthUsers() {
92
+ return Object.entries(readAuthUsers())
93
+ .map(([userId, user]) => ({
94
+ userId,
95
+ username: String(user?.username || userId),
96
+ isAdmin: Boolean(user?.isAdmin),
97
+ createdAt: String(user?.createdAt || ""),
98
+ updatedAt: String(user?.updatedAt || user?.createdAt || ""),
99
+ }))
100
+ .sort((left, right) => left.username.localeCompare(right.username));
101
+ }
102
+
103
+ export function resetAuthUserPassword(userId, password) {
104
+ const normalizedUserId = sanitizeAgentflowUserId(userId);
105
+ if (!normalizedUserId) return { ok: false, status: 400, error: "用户名无效" };
106
+ const nextPassword = String(password || "");
107
+ if (nextPassword.length < 4) return { ok: false, status: 400, error: "密码至少 4 位" };
108
+
109
+ const users = readAuthUsers();
110
+ const user = users[normalizedUserId];
111
+ if (!user) return { ok: false, status: 404, error: "用户不存在" };
112
+
113
+ const nextCredential = hashPassword(nextPassword);
114
+ users[normalizedUserId] = {
115
+ ...user,
116
+ salt: nextCredential.salt,
117
+ hash: nextCredential.hash,
118
+ updatedAt: new Date().toISOString(),
119
+ };
120
+ writeJsonObject(usersPath(), users);
121
+
122
+ const sessions = readJsonObject(sessionsPath());
123
+ let revokedSessions = 0;
124
+ for (const [sessionKey, session] of Object.entries(sessions)) {
125
+ if (session?.userId !== normalizedUserId) continue;
126
+ delete sessions[sessionKey];
127
+ revokedSessions += 1;
128
+ }
129
+ writeJsonObject(sessionsPath(), sessions);
130
+
131
+ return {
132
+ ok: true,
133
+ user: {
134
+ userId: normalizedUserId,
135
+ username: String(user.username || normalizedUserId),
136
+ isAdmin: Boolean(user.isAdmin),
137
+ updatedAt: users[normalizedUserId].updatedAt,
138
+ },
139
+ revokedSessions,
140
+ };
141
+ }
142
+
91
143
  export function authSetupRequired() {
92
144
  return Object.keys(readAuthUsers()).length === 0;
93
145
  }
@@ -229,7 +229,7 @@ export function parseNodeFrontmatter(raw) {
229
229
  * @param {string} workspaceRoot
230
230
  * @param {string} flowId
231
231
  * @param {string} flowSource
232
- * @param {{ archived?: boolean }} [opts]
232
+ * @param {{ archived?: boolean, staticFlowPath?: string }} [opts]
233
233
  */
234
234
  export function listNodesJson(workspaceRoot, flowId, flowSource, opts = {}) {
235
235
  const root = path.resolve(workspaceRoot);
@@ -238,7 +238,14 @@ export function listNodesJson(workspaceRoot, flowId, flowSource, opts = {}) {
238
238
  const byId = new Map();
239
239
  const pipelineTranslations = {};
240
240
  let marketplaceFlowData = null;
241
- if (flowId && flowSource) {
241
+ const staticFlowPath = opts.staticFlowPath ? path.resolve(String(opts.staticFlowPath)) : "";
242
+ const staticFlowDir = staticFlowPath ? path.dirname(staticFlowPath) : "";
243
+ if (staticFlowPath && fs.existsSync(staticFlowPath) && fs.statSync(staticFlowPath).isFile()) {
244
+ try {
245
+ const parsed = yaml.load(fs.readFileSync(staticFlowPath, "utf-8"));
246
+ if (parsed && typeof parsed === "object") marketplaceFlowData = parsed;
247
+ } catch (_) {}
248
+ } else if (flowId && flowSource) {
242
249
  const flowPath = getFlowYamlAbs(workspaceRoot, flowId, flowSource, opts);
243
250
  if (flowPath.path && fs.existsSync(flowPath.path)) {
244
251
  try {
@@ -315,6 +322,24 @@ export function listNodesJson(workspaceRoot, flowId, flowSource, opts = {}) {
315
322
  runtime: manifest.runtime,
316
323
  });
317
324
  }
325
+ if (staticFlowDir) {
326
+ addFromDir(path.join(staticFlowDir, "nodes"), "flow", flowId);
327
+ const flowData = marketplaceFlowData;
328
+ if (flowData?.instances) {
329
+ pipelineTranslations[flowId] = pipelineTranslations[flowId] || {};
330
+ for (const [nodeId, inst] of Object.entries(flowData.instances)) {
331
+ pipelineTranslations[flowId][nodeId] = {
332
+ label: inst?.label,
333
+ body: inst?.body,
334
+ description: inst?.description || inst?.userDescription,
335
+ };
336
+ }
337
+ }
338
+ if (flowData?.ui?.description) {
339
+ pipelineTranslations[flowId] = pipelineTranslations[flowId] || {};
340
+ pipelineTranslations[flowId].__flowDescription = flowData.ui.description;
341
+ }
342
+ }
318
343
  if (flowId && flowSource) {
319
344
  if (flowSource === "builtin") {
320
345
  addFromDir(path.join(PACKAGE_BUILTIN_PIPELINES_DIR, flowId, "nodes"), "flow", flowId);
@@ -0,0 +1,104 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ function assetPathFromHtml(indexHtml, pattern, label, distDir) {
6
+ const match = indexHtml.match(pattern);
7
+ if (!match?.[1]) throw new Error(`AgentFlow Web UI ${label} asset is missing; run npm run build:web-ui first`);
8
+ const assetPath = path.join(distDir, match[1].replace(/^\/+/, ""));
9
+ if (!fs.existsSync(assetPath) || !fs.statSync(assetPath).isFile()) {
10
+ throw new Error(`AgentFlow Web UI ${label} asset not found: ${assetPath}`);
11
+ }
12
+ return assetPath;
13
+ }
14
+
15
+ function safeInlineJson(value) {
16
+ return JSON.stringify(value)
17
+ .replace(/</g, "\\u003c")
18
+ .replace(/\u2028/g, "\\u2028")
19
+ .replace(/\u2029/g, "\\u2029");
20
+ }
21
+
22
+ function safeInlineScript(source) {
23
+ return String(source).replace(/<\/script/gi, "<\\/script");
24
+ }
25
+
26
+ function safeInlineStyle(source) {
27
+ return String(source).replace(/<\/style/gi, "<\\/style");
28
+ }
29
+
30
+ function escapeHtml(value) {
31
+ return String(value)
32
+ .replace(/&/g, "&amp;")
33
+ .replace(/</g, "&lt;")
34
+ .replace(/>/g, "&gt;")
35
+ .replace(/"/g, "&quot;");
36
+ }
37
+
38
+ /**
39
+ * Build one self-contained, read-only HTML snapshot using the production Web UI bundle.
40
+ * No local HTTP server or AgentFlow process is required after this function returns.
41
+ */
42
+ export function writeStaticFlowPreview({
43
+ flowId,
44
+ flowPath,
45
+ nodeCatalog,
46
+ outputPath,
47
+ distDir,
48
+ }) {
49
+ const indexPath = path.join(distDir, "index.html");
50
+ if (!fs.existsSync(indexPath)) {
51
+ throw new Error(`AgentFlow Web UI build not found: ${indexPath}; run npm run build:web-ui first`);
52
+ }
53
+ const indexHtml = fs.readFileSync(indexPath, "utf-8");
54
+ const scriptPath = assetPathFromHtml(
55
+ indexHtml,
56
+ /<script[^>]+src=["']([^"']+\.js)["'][^>]*><\/script>/i,
57
+ "JavaScript",
58
+ distDir,
59
+ );
60
+ const stylePath = assetPathFromHtml(
61
+ indexHtml,
62
+ /<link[^>]+href=["']([^"']+\.css)["'][^>]*>/i,
63
+ "CSS",
64
+ distDir,
65
+ );
66
+ const flowYaml = fs.readFileSync(flowPath, "utf-8");
67
+ const payload = {
68
+ format: "agentflow-static-preview-v1",
69
+ flow: { id: String(flowId || "local-preview"), source: "preview", archived: false },
70
+ flowYaml,
71
+ nodeCatalog: nodeCatalog && typeof nodeCatalog === "object" ? nodeCatalog : { nodes: [] },
72
+ revision: crypto.createHash("sha256").update(flowYaml).digest("hex").slice(0, 24),
73
+ };
74
+ const appScript = safeInlineScript(fs.readFileSync(scriptPath, "utf-8"));
75
+ const appStyle = safeInlineStyle(fs.readFileSync(stylePath, "utf-8"));
76
+ const title = `${escapeHtml(flowId || "Flow")} · AgentFlow Preview`;
77
+ const html = `<!doctype html>
78
+ <html class="af-dark" lang="zh-CN">
79
+ <head>
80
+ <meta charset="UTF-8" />
81
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
82
+ <meta name="generator" content="AgentFlow static Flow preview" />
83
+ <title>${title}</title>
84
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
85
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
86
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet" />
87
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" rel="stylesheet" />
88
+ <style>${appStyle}</style>
89
+ <script>window.__AGENTFLOW_STATIC_FLOW_PREVIEW__=${safeInlineJson(payload)};</script>
90
+ </head>
91
+ <body>
92
+ <div id="root"></div>
93
+ <script type="module">${appScript}</script>
94
+ </body>
95
+ </html>
96
+ `;
97
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
98
+ fs.writeFileSync(outputPath, html, "utf-8");
99
+ return {
100
+ outputPath,
101
+ bytes: Buffer.byteLength(html),
102
+ revision: payload.revision,
103
+ };
104
+ }
package/bin/lib/help.mjs CHANGED
@@ -18,6 +18,7 @@ AgentFlow CLI — 使用 Cursor / OpenCode / Claude Code / Codex CLI 流式输
18
18
  agentflow download <slug|title> [--user|--workspace] [--as <id>] [--raw [--output <dir>]] 从 Hub 下载流程(默认 --user 安装到 ~/agentflow/pipelines/<id>;--workspace 安装到当前工程 .workspace/agentflow/pipelines/<id>;--raw 仅保留压缩包)
19
19
  agentflow list 列出所有流水线
20
20
  agentflow ui [--host <addr>] [--port <n>] [--scheduler] [--no-open] [--hide-community-links] 本地 HTTP:流水线列表 + React Flow 节点流程图编辑保存(默认 127.0.0.1:8765;可用 AGENTFLOW_UI_HOST)
21
+ agentflow flow preview <FlowName|flow.yaml> [--output <preview.html>] [--no-open] 生成平台同款画布的单文件静态 Flow 预览
21
22
  agentflow mcp 启动 AgentFlow MCP stdio server,供 Cursor/Codex 运行流程并读取 display 输出
22
23
  agentflow scheduler start [--poll-ms <ms>] 启动定时执行调度器(读取各流水线 schedule.json)
23
24
  agentflow scheduler status [--json] 查看定时执行配置与状态
@@ -88,6 +89,7 @@ Usage:
88
89
  agentflow download <slug|title> [--user|--workspace] [--as <id>] [--raw [--output <dir>]] Download flow (default --user → ~/agentflow/pipelines/<id>; --workspace → current project's .workspace/agentflow/pipelines/<id>; --raw keeps the archive)
89
90
  agentflow list List all pipelines
90
91
  agentflow ui [--host <addr>] [--port <n>] [--scheduler] [--no-open] [--hide-community-links] Local HTTP: pipeline list + React Flow node diagram editor (default 127.0.0.1:8765; AGENTFLOW_UI_HOST supported)
92
+ agentflow flow preview <FlowName|flow.yaml> [--output <preview.html>] [--no-open] Generate a single-file static Flow preview using the platform canvas
91
93
  agentflow mcp Start the AgentFlow MCP stdio server for Cursor/Codex to run flows and read display outputs
92
94
  agentflow scheduler start [--poll-ms <ms>] Start the scheduled-run scheduler (reads each pipeline schedule.json)
93
95
  agentflow scheduler status [--json] Show scheduled-run configuration and state
package/bin/lib/main.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { spawn } from "child_process";
2
+ import fs from "fs";
2
3
  import path from "path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
3
5
  import chalk from "chalk";
4
6
  import { apply, replay, resume } from "./apply.mjs";
5
7
  import {
@@ -33,6 +35,7 @@ import { hubListRemote, hubDownload } from "./hub-remote.mjs";
33
35
  import { cancelScheduledRun, listScheduleStatuses, startScheduler } from "./scheduler.mjs";
34
36
  import { installFlowDependency, listMarketplacePackages, publishNodePackage } from "./marketplace.mjs";
35
37
  import { startMcpServer } from "./mcp-server.mjs";
38
+ import { writeStaticFlowPreview } from "./flow-static-preview.mjs";
36
39
 
37
40
  async function readStdin() {
38
41
  const chunks = [];
@@ -346,6 +349,67 @@ export async function main() {
346
349
  process.stdout.write(JSON.stringify(result.success ? { success: true } : result) + "\n");
347
350
  process.exit(result.success ? 0 : 1);
348
351
  }
352
+ if (sub === "flow" && argv[0] === "preview") {
353
+ shift();
354
+ const target = shift();
355
+ if (!target) throw new Error("Usage: agentflow flow preview <FlowName|flow.yaml> [--output <preview.html>] [--no-open]");
356
+ let outputPath = "";
357
+ const outputIdx = argv.indexOf("--output");
358
+ if (outputIdx >= 0 && argv[outputIdx + 1]) {
359
+ outputPath = path.resolve(workspaceRoot, argv[outputIdx + 1]);
360
+ argv.splice(outputIdx, 2);
361
+ }
362
+ const noOpen = argv.includes("--no-open");
363
+ if (noOpen) argv.splice(argv.indexOf("--no-open"), 1);
364
+ if (argv.length > 0) throw new Error(`Unknown flow preview option: ${argv[0]}`);
365
+
366
+ const targetPath = path.resolve(workspaceRoot, target);
367
+ let previewFlowPath = "";
368
+ if (fs.existsSync(targetPath)) {
369
+ previewFlowPath = fs.statSync(targetPath).isDirectory()
370
+ ? path.join(targetPath, "flow.yaml")
371
+ : targetPath;
372
+ } else {
373
+ const flowDir = getFlowDir(workspaceRoot, target);
374
+ if (flowDir) previewFlowPath = path.join(flowDir, "flow.yaml");
375
+ }
376
+ if (!previewFlowPath || !fs.existsSync(previewFlowPath) || !fs.statSync(previewFlowPath).isFile()) {
377
+ throw new Error(`Flow not found: ${target}`);
378
+ }
379
+ previewFlowPath = fs.realpathSync(previewFlowPath);
380
+ const flowId = path.basename(path.dirname(previewFlowPath)) || "local-preview";
381
+ const safeFlowId = flowId.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "flow-preview";
382
+ if (!outputPath) {
383
+ outputPath = path.join(workspaceRoot, ".workspace", "agentflow", "previews", `${safeFlowId}.html`);
384
+ }
385
+ if (!/\.html?$/i.test(outputPath)) throw new Error("Preview output must be an HTML file");
386
+ const nodeCatalog = listNodesJson(workspaceRoot, flowId, "", {
387
+ staticFlowPath: previewFlowPath,
388
+ marketplaceScope: "all",
389
+ });
390
+ const result = writeStaticFlowPreview({
391
+ flowId,
392
+ flowPath: previewFlowPath,
393
+ nodeCatalog,
394
+ outputPath,
395
+ distDir: path.join(path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url)))), "builtin", "web-ui", "dist"),
396
+ });
397
+ const url = pathToFileURL(result.outputPath).href;
398
+ process.stderr.write(`AgentFlow static preview: ${result.outputPath}\nSource: ${previewFlowPath}\n`);
399
+ if (!noOpen) {
400
+ if (process.platform === "win32") {
401
+ const child = spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" });
402
+ child.unref();
403
+ } else if (process.platform === "darwin") {
404
+ const child = spawn("open", [url], { detached: true, stdio: "ignore" });
405
+ child.unref();
406
+ } else {
407
+ const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
408
+ child.unref();
409
+ }
410
+ }
411
+ return;
412
+ }
349
413
  if (sub === "ui") {
350
414
  let port = 8765;
351
415
  let host = process.env.AGENTFLOW_UI_HOST || "127.0.0.1";
@@ -109,10 +109,12 @@ import {
109
109
  getAuthUserFromRequest,
110
110
  getSessionTokenFromRequest,
111
111
  isAuthUserAllowed,
112
+ listAuthUsers,
112
113
  loginOrCreateUser,
113
114
  logoutRequest,
114
115
  readAuthUsers,
115
116
  readUserAllowlist,
117
+ resetAuthUserPassword,
116
118
  writeUserAllowlist,
117
119
  } from "./auth.mjs";
118
120
  import { readGlobalEnvRows, readMergedEnvObject, readUserEnvRows, writeGlobalEnvRows, writeUserEnvRows } from "./user-env.mjs";
@@ -3905,6 +3907,8 @@ export function prdWorkflowDashboardTimeline(workflows = []) {
3905
3907
  for (const entry of Array.isArray(workflow?.timeline) ? workflow.timeline : []) {
3906
3908
  const memberKey = String(entry?.key || [entry?.source, entry?.kind, entry?.id].filter(Boolean).join(":"));
3907
3909
  const identity = prdWorkflowDashboardTimelineIdentity(entry);
3910
+ const rawId = String(entry?.id || "").trim();
3911
+ const projectionId = identity && identity !== rawId.toLowerCase() ? identity : rawId;
3908
3912
  const groupKey = prdWorkflowDashboardTimelineGroupKey(entry);
3909
3913
  if (!memberKey || !groupKey) continue;
3910
3914
  assignedWorkflowIds.add(workflowId);
@@ -3912,6 +3916,7 @@ export function prdWorkflowDashboardTimeline(workflows = []) {
3912
3916
  key: memberKey,
3913
3917
  kind: String(entry.kind || ""),
3914
3918
  id: identity || String(entry.id || ""),
3919
+ projectionId,
3915
3920
  title: String(entry.title || entry.id || ""),
3916
3921
  date: String(entry.date || ""),
3917
3922
  startDate: String(entry.startDate || ""),
@@ -3928,6 +3933,7 @@ export function prdWorkflowDashboardTimeline(workflows = []) {
3928
3933
  memberKeys: [],
3929
3934
  };
3930
3935
  current.kind = String(entry.kind || current.kind);
3936
+ current.projectionId = projectionId || current.projectionId;
3931
3937
  current.title = String(entry.title || current.title);
3932
3938
  current.date = String(entry.date || current.date);
3933
3939
  current.startDate = String(entry.startDate || current.startDate);
@@ -11721,10 +11727,17 @@ function prdWorkflowAdminVersionRepairIntent(payload = {}, report = {}, userCtx
11721
11727
  if (!report.expectedRevision) {
11722
11728
  return { requested: true, status: 400, error: "Admin version repair requires expectedRevision" };
11723
11729
  }
11724
- return intent;
11730
+ const rawReason = String(payload.adminReason || payload.admin_reason || "");
11731
+ if (rawReason.length > 500) {
11732
+ return { requested: true, status: 400, error: "Admin version repair reason exceeds 500 characters" };
11733
+ }
11734
+ if (/[\0\r\n]/.test(rawReason)) {
11735
+ return { requested: true, status: 400, error: "Admin version repair reason contains control characters" };
11736
+ }
11737
+ return { ...intent, reason: rawReason.trim() };
11725
11738
  }
11726
11739
 
11727
- function prdWorkflowMergeAdminVersionTimeline(report, currentSnapshot = {}) {
11740
+ function prdWorkflowMergeAdminVersionTimeline(report, currentSnapshot = {}, adminIntent = {}) {
11728
11741
  const source = prdWorkflowRuntimeEventProducer(report?.event || {});
11729
11742
  const current = Array.isArray(currentSnapshot?.projections?.timeline) ? currentSnapshot.projections.timeline : [];
11730
11743
  const incoming = Array.isArray(report?.projections?.timeline) ? report.projections.timeline : [];
@@ -11736,6 +11749,7 @@ function prdWorkflowMergeAdminVersionTimeline(report, currentSnapshot = {}) {
11736
11749
  const administrativeRepair = {
11737
11750
  kind: "version-attribution",
11738
11751
  operation: "repair-version-membership",
11752
+ ...(adminIntent.reason ? { reason: adminIntent.reason } : {}),
11739
11753
  };
11740
11754
  return {
11741
11755
  ...report,
@@ -13485,7 +13499,6 @@ export function startUiServer({
13485
13499
  const root = path.resolve(workspaceRoot);
13486
13500
  const uiPort = port;
13487
13501
  const uiConfig = { hideCommunityLinks: Boolean(hideCommunityLinks) };
13488
-
13489
13502
  const server = http.createServer(async (req, res) => {
13490
13503
  const url = new URL(req.url || "/", "http://127.0.0.1");
13491
13504
  const reqStart = Date.now();
@@ -15622,7 +15635,7 @@ export function startUiServer({
15622
15635
  return;
15623
15636
  }
15624
15637
  report = adminVersionRepair.requested
15625
- ? prdWorkflowMergeAdminVersionTimeline(report, currentSnapshot)
15638
+ ? prdWorkflowMergeAdminVersionTimeline(report, currentSnapshot, adminVersionRepair)
15626
15639
  : prdWorkflowMergeProducerTimeline(report, currentSnapshot);
15627
15640
  if (report.error) {
15628
15641
  json(res, 400, { error: report.error });
@@ -16082,7 +16095,7 @@ export function startUiServer({
16082
16095
  artifacts: [artifact],
16083
16096
  links: [{
16084
16097
  key: artifactKey,
16085
- label: "Markdown Review",
16098
+ label: artifact.label,
16086
16099
  kind: artifact.kind,
16087
16100
  url: displayUrl,
16088
16101
  canonicalUrl: reviewUrl,
@@ -16668,6 +16681,40 @@ export function startUiServer({
16668
16681
  }
16669
16682
  }
16670
16683
 
16684
+ if (url.pathname === "/api/admin/users" || url.pathname === "/api/admin/users/reset-password") {
16685
+ if (!authUser?.isAdmin) {
16686
+ json(res, 403, { error: "Admin permission required" });
16687
+ return;
16688
+ }
16689
+ if (req.method === "GET" && url.pathname === "/api/admin/users") {
16690
+ json(res, 200, { users: listAuthUsers() });
16691
+ return;
16692
+ }
16693
+ if (req.method === "POST" && url.pathname === "/api/admin/users/reset-password") {
16694
+ let payload;
16695
+ try {
16696
+ payload = JSON.parse(await readBody(req));
16697
+ } catch {
16698
+ json(res, 400, { error: "Invalid JSON body" });
16699
+ return;
16700
+ }
16701
+ const targetUserId = String(payload?.userId || "").trim();
16702
+ if (targetUserId === userCtx.userId) {
16703
+ json(res, 400, { error: "不能在当前会话中重置自己的密码" });
16704
+ return;
16705
+ }
16706
+ const result = resetAuthUserPassword(targetUserId, payload?.password);
16707
+ if (!result.ok) {
16708
+ json(res, result.status || 400, { error: result.error || "Password reset failed" });
16709
+ return;
16710
+ }
16711
+ json(res, 200, result);
16712
+ return;
16713
+ }
16714
+ json(res, 405, { error: "Method not allowed" });
16715
+ return;
16716
+ }
16717
+
16671
16718
  if (url.pathname === "/api/flows") {
16672
16719
  if (req.method === "GET") {
16673
16720
  try {