@fieldwangai/agentflow 0.1.92 → 0.1.94

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.
@@ -134,6 +134,8 @@ const MIME = {
134
134
  ".js": "text/javascript; charset=utf-8",
135
135
  ".css": "text/css; charset=utf-8",
136
136
  ".json": "application/json; charset=utf-8",
137
+ ".md": "text/markdown; charset=utf-8",
138
+ ".markdown": "text/markdown; charset=utf-8",
137
139
  ".png": "image/png",
138
140
  ".jpg": "image/jpeg",
139
141
  ".jpeg": "image/jpeg",
@@ -1072,9 +1074,10 @@ async function checkCursorMcpServers(name = "", userCtx = {}) {
1072
1074
  return { results };
1073
1075
  }
1074
1076
 
1075
- function readModelListsFromDisk(workspaceRoot) {
1076
- const p = getModelListsAbs();
1077
- const empty = {
1077
+ const MODEL_LIST_KEYS = ["cursor", "opencode", "claudeCode", "codex"];
1078
+
1079
+ function emptyModelLists() {
1080
+ return {
1078
1081
  cursor: [],
1079
1082
  opencode: [],
1080
1083
  claudeCode: [],
@@ -1084,6 +1087,62 @@ function readModelListsFromDisk(workspaceRoot) {
1084
1087
  claudeCodeFetchedAt: null,
1085
1088
  codexFetchedAt: null,
1086
1089
  };
1090
+ }
1091
+
1092
+ function modelListEntryId(entry) {
1093
+ const text = String(entry || "").trim();
1094
+ const idx = text.indexOf(" - ");
1095
+ return idx >= 0 ? text.slice(0, idx).trim() : text;
1096
+ }
1097
+
1098
+ function normalizeHiddenModelConfig(raw) {
1099
+ const src = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
1100
+ const out = {};
1101
+ for (const key of MODEL_LIST_KEYS) {
1102
+ out[key] = Array.isArray(src[key])
1103
+ ? [...new Set(src[key].map(modelListEntryId).filter(Boolean))]
1104
+ : [];
1105
+ }
1106
+ return out;
1107
+ }
1108
+
1109
+ function readHiddenModelConfig() {
1110
+ const cfg = readAgentflowUserConfigObject();
1111
+ const visibility = cfg.modelVisibility && typeof cfg.modelVisibility === "object" && !Array.isArray(cfg.modelVisibility)
1112
+ ? cfg.modelVisibility
1113
+ : {};
1114
+ return normalizeHiddenModelConfig(visibility.hiddenModels);
1115
+ }
1116
+
1117
+ function writeHiddenModelConfig(hiddenModels) {
1118
+ const cfgPath = getAgentflowUserConfigAbs();
1119
+ const prev = readAgentflowUserConfigObject();
1120
+ const next = {
1121
+ ...prev,
1122
+ modelVisibility: {
1123
+ ...(prev.modelVisibility && typeof prev.modelVisibility === "object" && !Array.isArray(prev.modelVisibility) ? prev.modelVisibility : {}),
1124
+ hiddenModels: normalizeHiddenModelConfig(hiddenModels),
1125
+ },
1126
+ };
1127
+ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
1128
+ fs.writeFileSync(cfgPath, JSON.stringify(next, null, 2) + "\n", "utf-8");
1129
+ return next.modelVisibility.hiddenModels;
1130
+ }
1131
+
1132
+ function applyModelVisibility(modelLists, hiddenModels) {
1133
+ const hidden = normalizeHiddenModelConfig(hiddenModels);
1134
+ const out = { ...modelLists };
1135
+ for (const key of MODEL_LIST_KEYS) {
1136
+ const hiddenIds = new Set(hidden[key]);
1137
+ out[key] = (Array.isArray(modelLists[key]) ? modelLists[key] : [])
1138
+ .filter((entry) => !hiddenIds.has(modelListEntryId(entry)));
1139
+ }
1140
+ return out;
1141
+ }
1142
+
1143
+ function readRawModelListsFromDisk() {
1144
+ const p = getModelListsAbs();
1145
+ const empty = emptyModelLists();
1087
1146
  try {
1088
1147
  if (!fs.existsSync(p)) return empty;
1089
1148
  const data = JSON.parse(fs.readFileSync(p, "utf-8"));
@@ -1102,6 +1161,12 @@ function readModelListsFromDisk(workspaceRoot) {
1102
1161
  }
1103
1162
  }
1104
1163
 
1164
+ function readModelListsFromDisk(_workspaceRoot, opts = {}) {
1165
+ const raw = readRawModelListsFromDisk();
1166
+ if (opts.raw) return raw;
1167
+ return applyModelVisibility(raw, readHiddenModelConfig());
1168
+ }
1169
+
1105
1170
  const SKILLHUB_TIMEOUT_MS = 60_000;
1106
1171
  const SKILLHUB_API_BASE = String(process.env.SKILLHUB_API_BASE || "https://skillhub.bigo.sg/api/v1").replace(/\/+$/, "");
1107
1172
  const skillhubCollectionInfoCache = new Map();
@@ -1414,6 +1479,19 @@ function htmlEscapeAttribute(value) {
1414
1479
  .replace(/>/g, ">");
1415
1480
  }
1416
1481
 
1482
+ function prdWorkflowReviewNormalizeText(value) {
1483
+ return String(value || "")
1484
+ .replace(/"/g, '"')
1485
+ .replace(/"/g, '"')
1486
+ .replace(/"/gi, '"')
1487
+ .replace(/'/g, "'")
1488
+ .replace(/'/gi, "'")
1489
+ .replace(/'/g, "'")
1490
+ .replace(/&lt;/g, "<")
1491
+ .replace(/&gt;/g, ">")
1492
+ .replace(/&amp;/g, "&");
1493
+ }
1494
+
1417
1495
  function fileUrlFromPath(absPath) {
1418
1496
  return pathToFileURL(path.resolve(absPath)).href;
1419
1497
  }
@@ -2635,15 +2713,42 @@ function displayShareOutputUrl(shareId, baseUrl = "") {
2635
2713
  }
2636
2714
  }
2637
2715
 
2716
+ function normalizePublicBaseUrl(baseUrl = "") {
2717
+ const raw = String(baseUrl || "").trim();
2718
+ if (!raw) return "";
2719
+ try {
2720
+ const url = new URL(raw);
2721
+ if (url.hostname === "0.0.0.0" || url.hostname === "::" || url.hostname === "[::]") {
2722
+ url.hostname = "127.0.0.1";
2723
+ }
2724
+ return url.origin;
2725
+ } catch {
2726
+ return "";
2727
+ }
2728
+ }
2729
+
2638
2730
  function requestPublicBaseUrl(req) {
2639
2731
  const origin = String(req?.headers?.origin || "").trim();
2640
- if (/^https?:\/\//i.test(origin)) return origin;
2732
+ if (/^https?:\/\//i.test(origin)) return normalizePublicBaseUrl(origin);
2641
2733
  const forwardedHost = String(req?.headers?.["x-forwarded-host"] || "").split(",")[0].trim();
2642
2734
  const host = forwardedHost || String(req?.headers?.host || "").trim();
2643
2735
  if (!host) return "";
2644
2736
  const forwardedProto = String(req?.headers?.["x-forwarded-proto"] || "").split(",")[0].trim();
2645
2737
  const proto = /^https?$/i.test(forwardedProto) ? forwardedProto.toLowerCase() : "http";
2646
- return `${proto}://${host}`;
2738
+ return normalizePublicBaseUrl(`${proto}://${host}`);
2739
+ }
2740
+
2741
+ function configuredPublicBaseUrl(payload = null) {
2742
+ const envBase = normalizePublicBaseUrl(process.env.AGENTFLOW_PUBLIC_BASE_URL || process.env.AGENTFLOW_SHARE_BASE_URL || "");
2743
+ if (envBase) return envBase;
2744
+ const payloadBase = payload && typeof payload === "object" && !Array.isArray(payload)
2745
+ ? normalizePublicBaseUrl(payload.publicBaseUrl || payload.public_base_url || payload.reviewBaseUrl || payload.review_base_url || "")
2746
+ : "";
2747
+ return payloadBase;
2748
+ }
2749
+
2750
+ function serverPublicBaseUrl(req, host, port, payload = null) {
2751
+ return configuredPublicBaseUrl(payload) || requestPublicBaseUrl(req) || normalizePublicBaseUrl(`http://${host}:${port}`);
2647
2752
  }
2648
2753
 
2649
2754
  function normalizeRunEnvKey(key) {
@@ -3831,7 +3936,11 @@ function workspacePublishNodeOutputFile(runPackage, relPath) {
3831
3936
  throw new Error(`Invalid node output path: ${clean}`);
3832
3937
  }
3833
3938
  if (!fs.existsSync(src) || !fs.statSync(src).isFile()) {
3834
- throw new Error(`Agent returned resultFile but did not create it under node outputs: ${clean}`);
3939
+ throw new Error(
3940
+ `Agent returned resultFile but did not create it under this node's outputs: ${clean}\n` +
3941
+ `Expected file: ${src}\n` +
3942
+ `Write outputs using a relative path from the node cwd, or use AGENTFLOW_OUTPUTS_DIR.`
3943
+ );
3835
3944
  }
3836
3945
  const nodePart = workspaceSanitizeTmpSegment(runPackage?.nodeId || "node", "node");
3837
3946
  const destRel = clean.slice("outputs/".length).replace(/^\/+/, "");
@@ -4050,6 +4159,7 @@ function workspaceOutputProtocolRequirements(graph, nodeId) {
4050
4159
  "## 输出",
4051
4160
  "",
4052
4161
  `请把${resultKindText}结果写入 \`${resultFile}\`。${resultGuidance}`,
4162
+ `必须先在当前执行目录下创建 \`${resultFile}\`,不要写到绝对路径或其它 run 目录;然后再返回下面的 agentflow envelope。`,
4053
4163
  downstreamInputRequirements ? `\n${downstreamInputRequirements}` : "",
4054
4164
  ...(slots.length ? [`额外输出:${slots.map((name) => `\`${name}\``).join("、")}。短值可写在 \`outParams\`,文件值写成 \`outParams.<name>File\`。`] : []),
4055
4165
  "最终只输出下面的 agentflow envelope,不要输出解释、进度或其它文字:",
@@ -4291,6 +4401,7 @@ function workspaceNodeFileBoundaryBlock(runPackage = {}) {
4291
4401
  nodeTmpDir ? `- 临时文件只能写入:\`${nodeTmpDir}\`,也可通过环境变量 \`AGENTFLOW_NODE_TMP_DIR\` 获取。` : "",
4292
4402
  Object.keys(runPackage?.inputMounts || {}).length ? "- 已挂载的输入文件位于本任务 `inputs/`;`inputs/` 只用于读取,正式产物仍写入 `outputs/`。" : "",
4293
4403
  `- 正式产物写入本任务 \`${outputsRel}/\`,例如 \`${outputsRel}/result.html\`、\`${outputsRel}/result.md\`;返回时仍使用 \`${outputsRel}/...\` 相对路径。`,
4404
+ `- 写正式产物时不要手写或复制上面的绝对路径;请在当前执行目录下使用相对路径 \`${outputsRel}/...\`,或使用环境变量 \`AGENTFLOW_OUTPUTS_DIR\`。`,
4294
4405
  "- 不要在执行目录根部创建 `temp_*`、`_out.json`、`tmp.html` 等临时产物。",
4295
4406
  "- 不要自行删除 run package;AgentFlow 会在运行结束后统一清理。",
4296
4407
  ].filter(Boolean).join("\n");
@@ -5164,11 +5275,70 @@ function workspaceKnowledgeContextBlockFromSources(sources = []) {
5164
5275
  return lines.join("\n");
5165
5276
  }
5166
5277
 
5278
+ function workspaceDedupeKnowledgeSources(sources = []) {
5279
+ const seen = new Set();
5280
+ const out = [];
5281
+ for (const source of Array.isArray(sources) ? sources : []) {
5282
+ if (!source || typeof source !== "object") continue;
5283
+ const key = [
5284
+ path.resolve(String(source.path || source.repoPath || "")),
5285
+ String(source.mountPath || ""),
5286
+ String(source.repoUrl || ""),
5287
+ String(source.branch || ""),
5288
+ ].join("\n");
5289
+ if (seen.has(key)) continue;
5290
+ seen.add(key);
5291
+ out.push(source);
5292
+ }
5293
+ return out;
5294
+ }
5295
+
5296
+ function workspaceGlobalKnowledgeNodeIds(graph) {
5297
+ const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
5298
+ return Object.entries(instances)
5299
+ .filter(([id, instance]) => {
5300
+ if (String(instance?.definitionId || "") !== "control_cd_workspace") return false;
5301
+ if (String(id || "") === "background_knowledge") return true;
5302
+ if (instance?.globalContext === true || instance?.globalKnowledge === true) return true;
5303
+ const scope = String(instance?.scope || workspaceSlotValue(workspaceSlotByName(instance, "scope")) || "").trim().toLowerCase();
5304
+ return scope === "global" || scope === "workspace";
5305
+ })
5306
+ .map(([id]) => id);
5307
+ }
5308
+
5309
+ function workspaceKnowledgeSourcesFromInstance(instance, baseCwd = "", scopedRoot = "") {
5310
+ const knowledgeText = workspaceSlotValue(workspaceSlotByName(instance, "knowledgeContext"));
5311
+ let sources = workspaceKnowledgeSourcesFromText(knowledgeText, baseCwd || scopedRoot, scopedRoot);
5312
+ if (sources.length) return sources;
5313
+ const pathText = workspaceSlotValue(workspaceSlotByName(instance, "path")) ||
5314
+ workspaceSlotValue(workspaceSlotByName(instance, "target")) ||
5315
+ workspaceInstanceText(instance);
5316
+ sources = workspaceKnowledgeSourcesFromText(pathText, baseCwd || scopedRoot, scopedRoot);
5317
+ const label = workspaceSlotValue(workspaceSlotByName(instance, "label"));
5318
+ if (!label) return sources;
5319
+ return sources.map((source) => ({ ...source, label }));
5320
+ }
5321
+
5322
+ function workspaceGlobalKnowledgeSources(graph, scopedRoot = "", logicalCwd = "", excludeNodeId = "") {
5323
+ const root = scopedRoot ? path.resolve(scopedRoot) : "";
5324
+ const cwd = logicalCwd ? path.resolve(logicalCwd) : root;
5325
+ const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
5326
+ return workspaceDedupeKnowledgeSources(
5327
+ workspaceGlobalKnowledgeNodeIds(graph)
5328
+ .filter((id) => String(id) !== String(excludeNodeId || ""))
5329
+ .flatMap((id) => workspaceKnowledgeSourcesFromInstance(instances[id], cwd || root, root))
5330
+ );
5331
+ }
5332
+
5167
5333
  function workspaceNodeWorkspaceContextBlock(graph, nodeId, outputs, scopedRoot = "", logicalCwd = "") {
5168
5334
  const root = scopedRoot ? path.resolve(scopedRoot) : "";
5169
5335
  const cwd = logicalCwd ? path.resolve(logicalCwd) : root;
5170
5336
  const knowledgeText = workspaceSemanticInputText(graph, nodeId, outputs, "knowledgeContext", scopedRoot);
5171
5337
  let knowledgeSources = workspaceKnowledgeSourcesFromText(knowledgeText, cwd || root, scopedRoot);
5338
+ knowledgeSources = workspaceDedupeKnowledgeSources([
5339
+ ...workspaceGlobalKnowledgeSources(graph, scopedRoot, logicalCwd, nodeId),
5340
+ ...knowledgeSources,
5341
+ ]);
5172
5342
  const workspaceText = workspaceSemanticInputText(graph, nodeId, outputs, "workspaceContext", scopedRoot);
5173
5343
  let workspaceContext = workspaceContextObjectFromText(workspaceText, cwd || root, scopedRoot);
5174
5344
  if (!knowledgeSources.length && workspaceContext?.cwd) {
@@ -6414,8 +6584,13 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
6414
6584
  }
6415
6585
  const historyBlock = workspaceNodeHistoryBlock(nodeId, scopedRoot, runPackage);
6416
6586
  let workspaceContextBlock = workspaceNodeWorkspaceContextBlock(graph, nodeId, outputs, scopedRoot, cwd);
6417
- if (!isContextRunNode && workspaceBoolSlot(instance, "includeWorkspaceContext", true) && !workspaceContextBlock) {
6418
- workspaceContextBlock = workspaceDefaultWorkspaceContextBlock(scopedRoot, cwd);
6587
+ if (!isContextRunNode && workspaceBoolSlot(instance, "includeWorkspaceContext", true)) {
6588
+ const defaultWorkspaceBlock = workspaceDefaultWorkspaceContextBlock(scopedRoot, cwd);
6589
+ if (!workspaceContextBlock) {
6590
+ workspaceContextBlock = defaultWorkspaceBlock;
6591
+ } else if (defaultWorkspaceBlock && !workspaceContextBlock.includes("## Workspace 上下文")) {
6592
+ workspaceContextBlock = [workspaceContextBlock, defaultWorkspaceBlock].filter(Boolean).join("\n\n");
6593
+ }
6419
6594
  }
6420
6595
  const prompt = workspaceNodePrompt(graph, nodeId, promptUpstreamText, promptSkillsBlock, promptMcpBlock, runtimeInputValues, runPackage, historyBlock, workspaceContextBlock);
6421
6596
  try {
@@ -6725,177 +6900,1921 @@ function broadcastFlowEditorSync(flowId, flowSource, flowArchived = false, userI
6725
6900
  const activeFlowRuns = new Map();
6726
6901
  /** 正在执行的 Workspace 临时 run(runId/sessionId → { controller, child, runNodeId, startedAt, plannedNodeIds }) */
6727
6902
  const activeWorkspaceRuns = new Map();
6903
+ const prdWorkflowSubscribers = new Map();
6904
+ const prdWorkflowIdempotency = new Map();
6905
+ const prdWorkflowActionLocks = new Map();
6906
+ const PRD_WORKFLOW_IDEMPOTENCY_MAX = 1000;
6907
+ const PRD_WORKFLOW_RUNTIME_EVENTS_MAX = 1000;
6728
6908
  const WORKSPACE_SCHEDULES_FILENAME = "workspace-schedules.json";
6729
6909
  const WORKSPACE_SCHEDULE_POLL_MS = 30_000;
6730
6910
  const WORKSPACE_IMPLEMENTATION_REFERENCE_ENABLED = true;
6731
6911
  const WORKSPACE_IMPLEMENTATION_SUMMARY_ENABLED = false;
6732
6912
  const WORKSPACE_NODE_HISTORY_MAX_CHARS = 80000;
6733
6913
 
6734
- function workspaceIntervalMinutesToCron(intervalMinutes) {
6735
- const n = Number(intervalMinutes);
6736
- if (!Number.isFinite(n) || n <= 0) return "0 9 * * *";
6737
- const minutes = Math.max(1, Math.min(1440, Math.round(n)));
6738
- if (minutes < 60) return `*/${minutes} * * * *`;
6739
- if (minutes === 60) return "0 * * * *";
6740
- if (minutes < 1440 && minutes % 60 === 0) return `0 */${minutes / 60} * * *`;
6741
- return "0 9 * * *";
6914
+ function prdWorkflowKey(userCtx = {}, flowSource = "user", flowId = "", tapdId = "") {
6915
+ return [
6916
+ String(userCtx?.userId || ""),
6917
+ String(flowSource || "user"),
6918
+ String(flowId || ""),
6919
+ String(tapdId || ""),
6920
+ ].join("\t");
6742
6921
  }
6743
6922
 
6744
- function workspaceRunKey(userCtx, flowSource, flowId) {
6745
- return `${userCtx?.userId || ""}:${flowSource || "user"}:${flowId}`;
6923
+ function prdWorkflowBroadcast(key, event = {}) {
6924
+ const set = prdWorkflowSubscribers.get(String(key || ""));
6925
+ if (!set || set.size === 0) return;
6926
+ const chunk = `data: ${JSON.stringify({ ...event, ts: Date.now() })}\n\n`;
6927
+ for (const clientRes of set) {
6928
+ try {
6929
+ clientRes.write(chunk);
6930
+ } catch (_) {}
6931
+ }
6746
6932
  }
6747
6933
 
6748
- function workspaceRunEntryKey(scopeKey, runId) {
6749
- return `${scopeKey}:${String(runId || "").trim() || runLedgerId("workspace")}`;
6934
+ function prdWorkflowCollaborationState(userCtx = {}, flowSource = "user", flowId = "", tapdId = "") {
6935
+ const key = prdWorkflowKey(userCtx, flowSource, flowId, tapdId);
6936
+ const active = prdWorkflowActionLocks.get(key) || null;
6937
+ const subscribers = prdWorkflowSubscribers.get(key);
6938
+ return {
6939
+ subscribers: subscribers ? subscribers.size : 0,
6940
+ activeAction: active ? {
6941
+ action: String(active.action || ""),
6942
+ tapdId: String(active.tapdId || tapdId || ""),
6943
+ title: String(active.title || active.action || ""),
6944
+ stage: String(active.stage || ""),
6945
+ issueKey: String(active.issueKey || ""),
6946
+ startedAt: active.startedAt || 0,
6947
+ startedAtIso: active.startedAt ? new Date(active.startedAt).toISOString() : "",
6948
+ id: String(active.id || ""),
6949
+ userId: String(active.userId || userCtx?.userId || ""),
6950
+ } : null,
6951
+ };
6750
6952
  }
6751
6953
 
6752
- function workspaceRuntimeNodeLabel(graph, nodeId, fallback = "Workspace Run") {
6753
- const id = String(nodeId || "").trim();
6754
- const instance = graph?.instances && typeof graph.instances === "object" ? graph.instances[id] : null;
6755
- const label = String(instance?.label || "").trim();
6756
- return label || id || fallback;
6954
+ function prdWorkflowCliCandidates(root, scopedRoot) {
6955
+ const fromEnv = String(process.env.PRD_FLOW_CLI || "").trim();
6956
+ return [
6957
+ fromEnv,
6958
+ scopedRoot ? path.join(scopedRoot, ".workspace", "prd-flow", "bin", "prd-flow") : "",
6959
+ root ? path.join(root, ".workspace", "prd-flow", "bin", "prd-flow") : "",
6960
+ scopedRoot ? path.join(scopedRoot, ".agents", "skills", "prd-flow", "bin", "prd-flow") : "",
6961
+ root ? path.join(root, ".agents", "skills", "prd-flow", "bin", "prd-flow") : "",
6962
+ "prd-flow",
6963
+ ].filter(Boolean);
6757
6964
  }
6758
6965
 
6759
- function workspaceActiveRunsForScope(scopeKey) {
6760
- const key = String(scopeKey || "");
6761
- return Array.from(activeWorkspaceRuns.entries())
6762
- .filter(([, entry]) => String(entry?.scopeKey || "") === key);
6966
+ function prdWorkflowResolveCli(root, scopedRoot) {
6967
+ const candidates = prdWorkflowCliCandidates(root, scopedRoot);
6968
+ for (const candidate of candidates) {
6969
+ if (candidate === "prd-flow") return { command: candidate, source: "PATH" };
6970
+ try {
6971
+ if (fs.existsSync(candidate)) return { command: candidate, source: candidate };
6972
+ } catch (_) {}
6973
+ }
6974
+ return { command: "prd-flow", source: "PATH" };
6763
6975
  }
6764
6976
 
6765
- function workspaceRunPlanNodeIds(runNodeId, plan) {
6766
- return Array.from(new Set([
6767
- String(runNodeId || "").trim(),
6768
- ...(Array.isArray(plan?.order) ? plan.order : []),
6769
- ...(Array.isArray(plan?.pauseNodeIds) ? plan.pauseNodeIds : []),
6770
- ].map((id) => String(id || "").trim()).filter(Boolean)));
6977
+ function prdWorkflowFallbackSnapshot(tapdId, phase, message, patch = {}) {
6978
+ const now = new Date().toISOString();
6979
+ return {
6980
+ tapdId: String(tapdId || ""),
6981
+ phase: String(phase || "unavailable"),
6982
+ pointer: String(message || "PRD workflow unavailable"),
6983
+ revision: "",
6984
+ nextAction: null,
6985
+ actions: [],
6986
+ milestones: [],
6987
+ issues: [],
6988
+ artifacts: [],
6989
+ optionalGaps: [{ severity: "warn", text: String(message || "PRD workflow unavailable") }],
6990
+ sources: { checkedAt: now },
6991
+ ...patch,
6992
+ };
6771
6993
  }
6772
6994
 
6773
- function workspaceFindActiveRunConflict(scopeKey, plannedNodeIds) {
6774
- const planned = new Set((plannedNodeIds || []).map((id) => String(id || "").trim()).filter(Boolean));
6775
- for (const [key, entry] of workspaceActiveRunsForScope(scopeKey)) {
6776
- const activeIds = Array.isArray(entry?.plannedNodeIds) ? entry.plannedNodeIds : [];
6777
- if (!activeIds.length) {
6778
- return { key, entry, conflictNodeIds: [] };
6995
+ function prdWorkflowStableValue(value) {
6996
+ if (Array.isArray(value)) return value.map((item) => prdWorkflowStableValue(item));
6997
+ if (value && typeof value === "object") {
6998
+ const out = {};
6999
+ for (const key of Object.keys(value).sort()) {
7000
+ if ([
7001
+ "checkedAt",
7002
+ "updatedAt",
7003
+ "createdAt",
7004
+ "clientReportedAt",
7005
+ "cacheUpdatedAt",
7006
+ "runtimeEventsUpdatedAt",
7007
+ "collaboration",
7008
+ "sources",
7009
+ "rawOutput",
7010
+ ].includes(key)) continue;
7011
+ out[key] = prdWorkflowStableValue(value[key]);
6779
7012
  }
6780
- const conflictNodeIds = activeIds
6781
- .map((id) => String(id || "").trim())
6782
- .filter((id) => id && planned.has(id));
6783
- if (conflictNodeIds.length) return { key, entry, conflictNodeIds };
7013
+ return out;
6784
7014
  }
6785
- return null;
7015
+ return value;
6786
7016
  }
6787
7017
 
6788
- function normalizeWorkspaceScheduledRunConfig(raw) {
6789
- let parsed = {};
6790
- const text = String(raw || "").trim();
6791
- if (text) {
6792
- try {
6793
- parsed = JSON.parse(text);
6794
- } catch {
6795
- parsed = {};
6796
- }
6797
- }
6798
- const intervalMinutes = Number(parsed.intervalMinutes);
6799
- const migratedCron = workspaceIntervalMinutesToCron(intervalMinutes);
6800
- const cron = typeof parsed.cron === "string" && parsed.cron.trim()
6801
- ? parsed.cron.trim()
6802
- : migratedCron;
6803
- const timezone = typeof parsed.timezone === "string" && parsed.timezone.trim()
6804
- ? parsed.timezone.trim()
6805
- : "Asia/Shanghai";
6806
- const targetRunNodeId = typeof parsed.targetRunNodeId === "string" ? parsed.targetRunNodeId.trim() : "";
6807
- const overlapPolicy = parsed.overlapPolicy === "skip" ? "skip" : "skip";
6808
- return {
6809
- enabled: parsed.enabled === true,
6810
- cron,
6811
- timezone,
6812
- targetRunNodeId,
6813
- overlapPolicy,
6814
- };
7018
+ function prdWorkflowRevisionHash(value) {
7019
+ return crypto
7020
+ .createHash("sha256")
7021
+ .update(JSON.stringify(prdWorkflowStableValue(value)))
7022
+ .digest("hex")
7023
+ .slice(0, 24);
6815
7024
  }
6816
7025
 
6817
- function workspaceScheduleNextRunAt(config, fromDate = new Date()) {
6818
- if (!config?.enabled || !config?.cron) return null;
6819
- return Date.parse(computeNextRunAt(config.cron, config.timezone || "Asia/Shanghai", fromDate));
7026
+ function prdWorkflowSnapshotRevision(snapshot = {}) {
7027
+ const explicit = String(snapshot?.revision || snapshot?.prd?.revision || snapshot?.next?.revision || "").trim();
7028
+ if (explicit) return explicit;
7029
+ return `snap:${prdWorkflowRevisionHash({
7030
+ tapdId: snapshot?.tapdId || snapshot?.tapd_id || snapshot?.prd?.tapd_id || "",
7031
+ phase: snapshot?.phase || snapshot?.workflow_stage || snapshot?.next?.code || "",
7032
+ pointer: snapshot?.pointer || snapshot?.current || snapshot?.status || snapshot?.next?.title || "",
7033
+ next: snapshot?.nextAction || snapshot?.next || null,
7034
+ actions: snapshot?.actions || snapshot?.workflowActions || snapshot?.workflow_actions || [],
7035
+ milestones: snapshot?.milestones || [],
7036
+ epics: snapshot?.epics || snapshot?.epicGroups || snapshot?.prd?.epics || [],
7037
+ issues: snapshot?.issues || snapshot?.issueGroups || snapshot?.prd?.issues || [],
7038
+ artifacts: snapshot?.artifacts || snapshot?.outputs || [],
7039
+ prd: snapshot?.prd || null,
7040
+ })}`;
7041
+ }
7042
+
7043
+ function prdWorkflowSafeStateId(value) {
7044
+ return String(value || "")
7045
+ .trim()
7046
+ .replace(/[^a-zA-Z0-9._-]+/g, "_")
7047
+ .replace(/^_+|_+$/g, "")
7048
+ .slice(0, 128) || "unknown";
6820
7049
  }
6821
7050
 
6822
- function workspaceSchedulesPath() {
6823
- return path.join(getAgentflowDataRoot(), WORKSPACE_SCHEDULES_FILENAME);
7051
+ function prdWorkflowStatePath(scopedRoot, tapdId) {
7052
+ const rootDir = scopedRoot || process.cwd();
7053
+ return path.join(rootDir, ".workspace", "prd-flow", "workflow-state", `${prdWorkflowSafeStateId(tapdId)}.json`);
6824
7054
  }
6825
7055
 
6826
- function readWorkspaceScheduleRegistry() {
6827
- const filePath = workspaceSchedulesPath();
6828
- if (!fs.existsSync(filePath)) return { version: 1, schedules: {} };
7056
+ function prdWorkflowCachePath(scopedRoot, tapdId) {
7057
+ const rootDir = scopedRoot || process.cwd();
7058
+ return path.join(rootDir, ".workspace", "prd-flow", "workflow-state", `${prdWorkflowSafeStateId(tapdId)}.cache.json`);
7059
+ }
7060
+
7061
+ function prdWorkflowProjectPath(scopedRoot, tapdId) {
7062
+ const rootDir = scopedRoot || process.cwd();
7063
+ return path.join(rootDir, ".workspace", "prd-flow", "workflow-state", `${prdWorkflowSafeStateId(tapdId)}.project.json`);
7064
+ }
7065
+
7066
+ function prdWorkflowClientsPath(scopedRoot, tapdId) {
7067
+ const rootDir = scopedRoot || process.cwd();
7068
+ return path.join(rootDir, ".workspace", "prd-flow", "workflow-state", `${prdWorkflowSafeStateId(tapdId)}.clients.json`);
7069
+ }
7070
+
7071
+ function prdWorkflowEventsPath(scopedRoot, tapdId) {
7072
+ const rootDir = scopedRoot || process.cwd();
7073
+ return path.join(rootDir, ".workspace", "prd-flow", "workflow-state", `${prdWorkflowSafeStateId(tapdId)}.events.json`);
7074
+ }
7075
+
7076
+ function prdWorkflowReviewDir(scopedRoot, tapdId) {
7077
+ const rootDir = path.resolve(scopedRoot || process.cwd());
7078
+ return path.join(rootDir, ".workspace", "prd-flow", "reviews", prdWorkflowSafeStateId(tapdId));
7079
+ }
7080
+
7081
+ function prdWorkflowReviewPaths(scopedRoot, tapdId, reviewId) {
7082
+ const dir = prdWorkflowReviewDir(scopedRoot, tapdId);
7083
+ const safeId = prdWorkflowSafeStateId(reviewId);
7084
+ return {
7085
+ dir,
7086
+ id: safeId,
7087
+ markdownPath: path.join(dir, `${safeId}.md`),
7088
+ metaPath: path.join(dir, `${safeId}.json`),
7089
+ };
7090
+ }
7091
+
7092
+ function prdWorkflowPruneReviews(scopedRoot, tapdId, maxReviews = 200) {
6829
7093
  try {
6830
- const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
6831
- return {
6832
- version: 1,
6833
- schedules: parsed?.schedules && typeof parsed.schedules === "object" && !Array.isArray(parsed.schedules)
6834
- ? parsed.schedules
6835
- : {},
6836
- };
6837
- } catch {
6838
- return { version: 1, schedules: {} };
6839
- }
7094
+ const dir = prdWorkflowReviewDir(scopedRoot, tapdId);
7095
+ if (!fs.existsSync(dir)) return;
7096
+ const now = Date.now();
7097
+ const entries = fs.readdirSync(dir)
7098
+ .filter((name) => name.endsWith(".json"))
7099
+ .map((name) => {
7100
+ const abs = path.join(dir, name);
7101
+ let mtimeMs = 0;
7102
+ try { mtimeMs = fs.statSync(abs).mtimeMs; } catch (_) {}
7103
+ let meta = {};
7104
+ try { meta = JSON.parse(fs.readFileSync(abs, "utf-8")); } catch (_) {}
7105
+ const expiresMs = Date.parse(meta?.expiresAt || "");
7106
+ return { name, abs, id: name.replace(/\.json$/i, ""), mtimeMs, expiresMs };
7107
+ })
7108
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
7109
+ for (const entry of entries.filter((item) => Number.isFinite(item.expiresMs) && item.expiresMs < now)) {
7110
+ try { fs.unlinkSync(entry.abs); } catch (_) {}
7111
+ try { fs.unlinkSync(path.join(dir, `${entry.id}.md`)); } catch (_) {}
7112
+ }
7113
+ for (const entry of entries.filter((item) => !(Number.isFinite(item.expiresMs) && item.expiresMs < now)).slice(maxReviews)) {
7114
+ try { fs.unlinkSync(entry.abs); } catch (_) {}
7115
+ try { fs.unlinkSync(path.join(dir, `${entry.id}.md`)); } catch (_) {}
7116
+ }
7117
+ } catch (_) {}
6840
7118
  }
6841
7119
 
6842
- function writeWorkspaceScheduleRegistry(registry) {
6843
- const filePath = workspaceSchedulesPath();
6844
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
6845
- fs.writeFileSync(filePath, JSON.stringify({
6846
- version: 1,
6847
- updatedAt: new Date().toISOString(),
6848
- schedules: registry?.schedules && typeof registry.schedules === "object" ? registry.schedules : {},
6849
- }, null, 2) + "\n", "utf-8");
7120
+ function prdWorkflowReviewInlineMarkdown(text) {
7121
+ const codeSpans = [];
7122
+ let escaped = htmlEscapeAttribute(prdWorkflowReviewNormalizeText(text || "")).replace(/`([^`]+)`/g, (_m, code) => {
7123
+ const token = `@@CODE${codeSpans.length}@@`;
7124
+ codeSpans.push(`<code>${htmlEscapeAttribute(prdWorkflowReviewNormalizeText(code))}</code>`);
7125
+ return token;
7126
+ });
7127
+ escaped = escaped
7128
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+|file:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>')
7129
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
7130
+ .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
7131
+ codeSpans.forEach((html, index) => {
7132
+ escaped = escaped.replaceAll(`@@CODE${index}@@`, html);
7133
+ });
7134
+ return escaped;
6850
7135
  }
6851
7136
 
6852
- function workspaceScheduleKey(userId, flowSource, flowId, scheduleNodeId) {
6853
- return [
6854
- String(userId || ""),
6855
- String(flowSource || "user"),
6856
- String(flowId || ""),
6857
- String(scheduleNodeId || ""),
6858
- ].join(":");
7137
+ function prdWorkflowReviewSplitFrontmatter(markdown) {
7138
+ const text = String(markdown || "");
7139
+ const match = text.match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/);
7140
+ if (!match) return { frontmatter: "", body: text };
7141
+ return { frontmatter: match[1].trim(), body: text.slice(match[0].length) };
6859
7142
  }
6860
7143
 
6861
- function listWorkspaceScheduleStatusesForFlow(userCtx = {}, flowSource = "user", flowId = "") {
6862
- const registry = readWorkspaceScheduleRegistry();
6863
- const userId = String(userCtx.userId || "");
6864
- return Object.values(registry.schedules || {})
6865
- .filter((entry) => (
6866
- String(entry?.userId || "") === userId &&
6867
- String(entry?.flowSource || "user") === String(flowSource || "user") &&
6868
- String(entry?.flowId || "") === String(flowId || "")
6869
- ))
6870
- .sort((a, b) => String(a.scheduleNodeId || a.runNodeId || "").localeCompare(String(b.scheduleNodeId || b.runNodeId || "")));
7144
+ function prdWorkflowReviewRenderFrontmatter(frontmatter) {
7145
+ if (!String(frontmatter || "").trim()) return "";
7146
+ const rows = String(frontmatter || "").split(/\r?\n/)
7147
+ .map((line) => {
7148
+ const match = line.match(/^([^:]+):\s*(.*)$/);
7149
+ if (!match) return `<tr><td colspan="2">${prdWorkflowReviewInlineMarkdown(line)}</td></tr>`;
7150
+ return `<tr><th>${htmlEscapeAttribute(match[1].trim())}</th><td>${prdWorkflowReviewInlineMarkdown(match[2].trim())}</td></tr>`;
7151
+ })
7152
+ .join("");
7153
+ return `<details class="frontmatter" open><summary>文档元数据</summary><table>${rows}</table></details>`;
6871
7154
  }
6872
7155
 
6873
- function listWorkspaceScheduleStatuses(root, userCtx = {}) {
6874
- const registry = readWorkspaceScheduleRegistry();
6875
- const userId = String(userCtx.userId || "");
6876
- const flows = listFlowsJson(root, { ...userCtx, includeWorkspaceFlows: true })
6877
- .filter((flow) => !flow.archived && !isReadonlyBuiltinFlowSource(flow.source || "user"));
6878
- const rows = [];
6879
- for (const flow of flows) {
6880
- const flowId = String(flow.id || "");
6881
- const flowSource = String(flow.source || "user");
6882
- const scoped = resolveWorkspaceScopeRoot(root, { flowId, flowSource }, userCtx);
6883
- if (scoped.error || !scoped.root) continue;
6884
- let graph;
6885
- try {
6886
- graph = readWorkspaceGraph(scoped.root).graph;
6887
- } catch {
7156
+ function prdWorkflowReviewRenderTable(lines) {
7157
+ const splitRow = (line) => String(line || "")
7158
+ .trim()
7159
+ .replace(/^\|/, "")
7160
+ .replace(/\|$/, "")
7161
+ .split("|")
7162
+ .map((cell) => cell.trim());
7163
+ const headers = splitRow(lines[0]);
7164
+ const body = lines.slice(2).map(splitRow);
7165
+ return [
7166
+ '<div class="table-wrap"><table>',
7167
+ `<thead><tr>${headers.map((cell) => `<th>${prdWorkflowReviewInlineMarkdown(cell)}</th>`).join("")}</tr></thead>`,
7168
+ `<tbody>${body.map((row) => `<tr>${row.map((cell) => `<td>${prdWorkflowReviewInlineMarkdown(cell)}</td>`).join("")}</tr>`).join("")}</tbody>`,
7169
+ "</table></div>",
7170
+ ].join("");
7171
+ }
7172
+
7173
+ function prdWorkflowReviewMarkdownToHtml(markdown) {
7174
+ const { frontmatter, body } = prdWorkflowReviewSplitFrontmatter(markdown);
7175
+ const lines = String(body || "").replace(/\r\n/g, "\n").split("\n");
7176
+ const html = [];
7177
+ let paragraph = [];
7178
+ let list = [];
7179
+ let code = null;
7180
+ const flushParagraph = () => {
7181
+ if (!paragraph.length) return;
7182
+ html.push(`<p>${prdWorkflowReviewInlineMarkdown(paragraph.join(" "))}</p>`);
7183
+ paragraph = [];
7184
+ };
7185
+ const flushList = () => {
7186
+ if (!list.length) return;
7187
+ html.push(`<ul>${list.map((item) => `<li>${item}</li>`).join("")}</ul>`);
7188
+ list = [];
7189
+ };
7190
+ const flushBlocks = () => {
7191
+ flushParagraph();
7192
+ flushList();
7193
+ };
7194
+ for (let i = 0; i < lines.length; i += 1) {
7195
+ const line = lines[i];
7196
+ const trimmed = line.trim();
7197
+ const fence = trimmed.match(/^```(\w+)?\s*$/);
7198
+ if (code) {
7199
+ if (fence) {
7200
+ html.push(`<pre><code>${htmlEscapeAttribute(prdWorkflowReviewNormalizeText(code.lines.join("\n")))}</code></pre>`);
7201
+ code = null;
7202
+ } else {
7203
+ code.lines.push(line);
7204
+ }
6888
7205
  continue;
6889
7206
  }
6890
- const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
6891
- for (const [scheduleNodeId, instance] of Object.entries(instances)) {
6892
- if (String(instance?.definitionId || "") !== "workspace_scheduled_run") continue;
6893
- const config = normalizeWorkspaceScheduledRunConfig(instance.body || "");
6894
- const key = workspaceScheduleKey(userId, flowSource, flowId, scheduleNodeId);
6895
- const current = registry.schedules?.[key] && typeof registry.schedules[key] === "object" ? registry.schedules[key] : {};
6896
- const targetRunNodeId = workspaceScheduleInferTargetRunNodeId(graph, scheduleNodeId, config);
6897
- const scopeKey = workspaceRunKey(userCtx, flowSource, flowId);
6898
- const running = workspaceActiveRunsForScope(scopeKey).some(([, active]) => (
7207
+ if (fence) {
7208
+ flushBlocks();
7209
+ code = { lang: fence[1] || "", lines: [] };
7210
+ continue;
7211
+ }
7212
+ if (!trimmed) {
7213
+ flushBlocks();
7214
+ continue;
7215
+ }
7216
+ if (/^\|.+\|\s*$/.test(trimmed) && i + 1 < lines.length && /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[i + 1].trim())) {
7217
+ flushBlocks();
7218
+ const tableLines = [line, lines[i + 1]];
7219
+ i += 2;
7220
+ while (i < lines.length && /^\|.+\|\s*$/.test(lines[i].trim())) {
7221
+ tableLines.push(lines[i]);
7222
+ i += 1;
7223
+ }
7224
+ i -= 1;
7225
+ html.push(prdWorkflowReviewRenderTable(tableLines));
7226
+ continue;
7227
+ }
7228
+ const heading = trimmed.match(/^(#{1,6})\s+(.+)$/);
7229
+ if (heading) {
7230
+ flushBlocks();
7231
+ const level = Math.min(6, heading[1].length);
7232
+ html.push(`<h${level}>${prdWorkflowReviewInlineMarkdown(heading[2].trim())}</h${level}>`);
7233
+ continue;
7234
+ }
7235
+ const quote = trimmed.match(/^>\s+(.+)$/);
7236
+ if (quote) {
7237
+ flushBlocks();
7238
+ html.push(`<blockquote>${prdWorkflowReviewInlineMarkdown(quote[1])}</blockquote>`);
7239
+ continue;
7240
+ }
7241
+ const bullet = trimmed.match(/^[-*]\s+(?:\[( |x|X)\]\s+)?(.+)$/);
7242
+ if (bullet) {
7243
+ flushParagraph();
7244
+ const checked = bullet[1] ? `<input type="checkbox" disabled${bullet[1].toLowerCase() === "x" ? " checked" : ""}> ` : "";
7245
+ list.push(`${checked}${prdWorkflowReviewInlineMarkdown(bullet[2])}`);
7246
+ continue;
7247
+ }
7248
+ paragraph.push(trimmed);
7249
+ }
7250
+ if (code) html.push(`<pre><code>${htmlEscapeAttribute(prdWorkflowReviewNormalizeText(code.lines.join("\n")))}</code></pre>`);
7251
+ flushBlocks();
7252
+ return [prdWorkflowReviewRenderFrontmatter(frontmatter), ...html].filter(Boolean).join("\n");
7253
+ }
7254
+
7255
+ function prdWorkflowReviewHtml(title, markdown, meta = {}) {
7256
+ const escapedTitle = htmlEscapeAttribute(title || "PRD Workflow Review");
7257
+ const escapedMeta = htmlEscapeAttribute([
7258
+ meta.tapdId ? `TAPD ${meta.tapdId}` : "",
7259
+ meta.stage ? `stage ${meta.stage}` : "",
7260
+ meta.issueKey ? `issue ${meta.issueKey}` : "",
7261
+ meta.createdAt || "",
7262
+ ].filter(Boolean).join(" · "));
7263
+ const renderedMarkdown = prdWorkflowReviewMarkdownToHtml(markdown || "");
7264
+ const rawHref = htmlEscapeAttribute(meta.rawHref || "?raw=1");
7265
+ return `<!doctype html>
7266
+ <html lang="zh-CN" data-theme="dark">
7267
+ <head>
7268
+ <meta charset="utf-8" />
7269
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7270
+ <title>${escapedTitle}</title>
7271
+ <style>
7272
+ :root {
7273
+ color-scheme: dark;
7274
+ font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
7275
+ --bg: #0b1020;
7276
+ --bg-radial: rgba(124,58,237,.18);
7277
+ --panel: rgba(15,23,42,.68);
7278
+ --panel-strong: rgba(15,23,42,.88);
7279
+ --panel-soft: rgba(30,41,59,.74);
7280
+ --border: rgba(148,163,184,.20);
7281
+ --border-soft: rgba(148,163,184,.14);
7282
+ --text: #e5e7eb;
7283
+ --heading: #f8fafc;
7284
+ --muted: #94a3b8;
7285
+ --body: #dbe4f0;
7286
+ --link: #c4b5fd;
7287
+ --button: rgba(124,58,237,.16);
7288
+ --button-text: #ddd6fe;
7289
+ --code-bg: rgba(2,6,23,.58);
7290
+ --code-block: rgba(2,6,23,.72);
7291
+ --shadow: rgba(0,0,0,.28);
7292
+ background: var(--bg);
7293
+ }
7294
+ :root[data-theme="light"] {
7295
+ color-scheme: light;
7296
+ --bg: #f8fafc;
7297
+ --bg-radial: rgba(124,58,237,.12);
7298
+ --panel: rgba(255,255,255,.92);
7299
+ --panel-strong: rgba(255,255,255,.98);
7300
+ --panel-soft: rgba(241,245,249,.96);
7301
+ --border: rgba(100,116,139,.24);
7302
+ --border-soft: rgba(100,116,139,.18);
7303
+ --text: #0f172a;
7304
+ --heading: #020617;
7305
+ --muted: #64748b;
7306
+ --body: #1e293b;
7307
+ --link: #6d28d9;
7308
+ --button: rgba(124,58,237,.10);
7309
+ --button-text: #4c1d95;
7310
+ --code-bg: rgba(226,232,240,.76);
7311
+ --code-block: rgba(241,245,249,.92);
7312
+ --shadow: rgba(15,23,42,.10);
7313
+ }
7314
+ *, *::before, *::after { box-sizing: border-box; }
7315
+ html, body { overflow-x: hidden; }
7316
+ body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top left, var(--bg-radial), transparent 34rem), var(--bg); color: var(--text); }
7317
+ main { width: min(100%, 1120px); margin: 0 auto; padding: 40px 24px 72px; min-width: 0; }
7318
+ header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 22px; }
7319
+ h1 { margin: 0 0 10px; font-size: clamp(28px, 5vw, 56px); line-height: 1.05; letter-spacing: 0; }
7320
+ .meta { margin: 0; color: var(--muted); font-size: 14px; }
7321
+ .toolbar { flex: 0 0 auto; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
7322
+ .raw, .theme-toggle { border: 1px solid rgba(124,58,237,.34); border-radius: 999px; color: var(--button-text); background: var(--button); padding: 9px 14px; text-decoration: none; font-size: 13px; font-weight: 800; line-height: 1.2; }
7323
+ .theme-toggle { cursor: pointer; font-family: inherit; }
7324
+ article { min-width: 0; border: 1px solid var(--border); border-radius: 14px; background: var(--panel); box-shadow: 0 28px 80px var(--shadow); padding: clamp(20px, 4vw, 34px); }
7325
+ article > *:first-child { margin-top: 0; }
7326
+ article > *:last-child { margin-bottom: 0; }
7327
+ h2, h3, h4, h5, h6 { margin: 1.7em 0 .65em; line-height: 1.25; letter-spacing: 0; color: var(--heading); overflow-wrap: anywhere; }
7328
+ h2 { padding-bottom: .4rem; border-bottom: 1px solid var(--border-soft); font-size: 1.5rem; }
7329
+ h3 { font-size: 1.2rem; }
7330
+ p, li, td, th, blockquote { font-size: 15px; line-height: 1.8; overflow-wrap: anywhere; word-break: break-word; }
7331
+ p { margin: .75rem 0; color: var(--body); }
7332
+ ul { margin: .65rem 0 1rem; padding-left: 1.35rem; }
7333
+ li { margin: .28rem 0; color: var(--body); }
7334
+ li input { margin-right: .38rem; transform: translateY(1px); }
7335
+ code { display: inline; max-width: 100%; border: 1px solid var(--border-soft); border-radius: 6px; background: var(--code-bg); color: var(--heading); padding: .1rem .34rem; font-family: "SFMono-Regular", Consolas, monospace; font-size: .92em; white-space: normal; overflow-wrap: anywhere; word-break: break-word; }
7336
+ pre { max-width: 100%; overflow: auto; border: 1px solid var(--border); border-radius: 10px; background: var(--code-block); padding: 16px; line-height: 1.65; }
7337
+ pre code { border: 0; background: transparent; padding: 0; white-space: pre; overflow-wrap: normal; word-break: normal; }
7338
+ blockquote { margin: 1rem 0; border-left: 3px solid rgba(124,58,237,.72); background: rgba(124,58,237,.10); padding: .75rem 1rem; color: var(--body); }
7339
+ a { color: var(--link); text-decoration-thickness: .08em; text-underline-offset: .16em; overflow-wrap: anywhere; }
7340
+ .table-wrap { max-width: 100%; overflow-x: auto; margin: 1rem 0 1.25rem; border: 1px solid var(--border-soft); border-radius: 10px; background: var(--panel-strong); }
7341
+ table { width: 100%; max-width: 100%; border-collapse: collapse; table-layout: fixed; }
7342
+ th, td { min-width: 0; border-bottom: 1px solid var(--border-soft); padding: .65rem .8rem; text-align: left; vertical-align: top; }
7343
+ th { background: var(--panel-soft); color: var(--heading); font-weight: 800; }
7344
+ tr:last-child td { border-bottom: 0; }
7345
+ .frontmatter { margin: 0 0 1.35rem; border: 1px solid var(--border-soft); border-radius: 10px; background: var(--panel-strong); padding: .75rem .9rem; }
7346
+ .frontmatter summary { cursor: pointer; color: var(--body); font-weight: 800; }
7347
+ .frontmatter table { min-width: 0; margin-top: .7rem; }
7348
+ .frontmatter th { width: min(34%, 12rem); background: var(--panel-soft); color: var(--body); }
7349
+ @media (max-width: 720px) { main { padding: 28px 14px 48px; } header { display: block; } .toolbar { justify-content: flex-start; margin-top: 14px; } article { padding: 18px; } th, td { padding: .58rem .65rem; } }
7350
+ </style>
7351
+ </head>
7352
+ <body>
7353
+ <main>
7354
+ <header>
7355
+ <div>
7356
+ <h1>${escapedTitle}</h1>
7357
+ ${escapedMeta ? `<p class="meta">${escapedMeta}</p>` : ""}
7358
+ </div>
7359
+ <div class="toolbar">
7360
+ <button class="theme-toggle" type="button" data-theme-toggle>明亮模式</button>
7361
+ <a class="raw" href="${rawHref}">Raw Markdown</a>
7362
+ </div>
7363
+ </header>
7364
+ <article>${renderedMarkdown}</article>
7365
+ </main>
7366
+ <script>
7367
+ (() => {
7368
+ const key = "prd-workflow-review-theme";
7369
+ const root = document.documentElement;
7370
+ const button = document.querySelector("[data-theme-toggle]");
7371
+ const apply = (theme) => {
7372
+ root.dataset.theme = theme;
7373
+ if (button) button.textContent = theme === "light" ? "暗黑模式" : "明亮模式";
7374
+ try { window.localStorage.setItem(key, theme); } catch (_) {}
7375
+ };
7376
+ let saved = "dark";
7377
+ try { saved = window.localStorage.getItem(key) || "dark"; } catch (_) {}
7378
+ apply(saved === "light" ? "light" : "dark");
7379
+ if (button) button.addEventListener("click", () => apply(root.dataset.theme === "light" ? "dark" : "light"));
7380
+ })();
7381
+ </script>
7382
+ </body>
7383
+ </html>`;
7384
+ }
7385
+
7386
+ function prdWorkflowCreateReview(scopedRoot, tapdId, payload = {}, urlBase = "") {
7387
+ const content = String(payload.markdown || payload.content || payload.rawOutput || "").slice(0, 500000);
7388
+ if (!content.trim()) throw new Error("Missing review markdown");
7389
+ const requestedReviewId = String(payload.reviewId || payload.review_id || "").trim();
7390
+ const reviewId = requestedReviewId
7391
+ ? prdWorkflowSafeStateId(requestedReviewId)
7392
+ : `review_${Date.now().toString(36)}_${crypto.randomBytes(4).toString("hex")}`;
7393
+ const paths = prdWorkflowReviewPaths(scopedRoot, tapdId, reviewId);
7394
+ const title = String(payload.title || payload.label || "PRD Workflow Review").trim().slice(0, 160) || "PRD Workflow Review";
7395
+ const durability = String(payload.durability || (payload.durable === true || payload.permanent === true ? "durable" : "temporary")).trim().toLowerCase() || "temporary";
7396
+ const ttlDaysRaw = Number(payload.ttlDays || payload.ttl_days || (durability === "temporary" ? 7 : 0));
7397
+ const ttlDays = Number.isFinite(ttlDaysRaw) && ttlDaysRaw > 0 ? ttlDaysRaw : 0;
7398
+ const createdAt = new Date();
7399
+ const explicitExpiresAt = String(payload.expiresAt || payload.expires_at || "").trim();
7400
+ const expiresAt = durability === "temporary"
7401
+ ? (explicitExpiresAt || new Date(createdAt.getTime() + ttlDays * 86400000).toISOString())
7402
+ : "";
7403
+ const meta = {
7404
+ id: paths.id,
7405
+ tapdId: String(tapdId || ""),
7406
+ title,
7407
+ stage: String(payload.stage || payload.stageKey || payload.stage_key || "").trim(),
7408
+ action: String(payload.action || payload.actionId || payload.action_id || "").trim(),
7409
+ issueKey: String(payload.issueKey || payload.issue_key || payload.issue || "").trim(),
7410
+ durability,
7411
+ persistence: "runtime",
7412
+ source: payload.source && typeof payload.source === "object" && !Array.isArray(payload.source)
7413
+ ? payload.source
7414
+ : {
7415
+ kind: durability === "durable" ? "ai-doc" : "local-draft",
7416
+ durability,
7417
+ },
7418
+ ttlDays,
7419
+ expiresAt,
7420
+ createdAt: createdAt.toISOString(),
7421
+ };
7422
+ fs.mkdirSync(paths.dir, { recursive: true });
7423
+ fs.writeFileSync(paths.markdownPath, content.trimEnd() + "\n", "utf-8");
7424
+ fs.writeFileSync(paths.metaPath, JSON.stringify(meta, null, 2) + "\n", "utf-8");
7425
+ prdWorkflowPruneReviews(scopedRoot, tapdId);
7426
+ const url = `${String(urlBase || "").replace(/\/+$/, "")}/api/prd-workflow/review/${encodeURIComponent(prdWorkflowSafeStateId(tapdId))}/${encodeURIComponent(paths.id)}`;
7427
+ return { ...meta, url, markdownPath: paths.markdownPath };
7428
+ }
7429
+
7430
+ function prdWorkflowReadCachedSnapshotFile(filePath) {
7431
+ try {
7432
+ if (!fs.existsSync(filePath)) return null;
7433
+ const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
7434
+ return data && typeof data === "object" && !Array.isArray(data) ? data : null;
7435
+ } catch {
7436
+ return null;
7437
+ }
7438
+ }
7439
+
7440
+ function prdWorkflowReadCachedSnapshot(scopedRoot, tapdId) {
7441
+ const cache = prdWorkflowReadCachedSnapshotFile(prdWorkflowCachePath(scopedRoot, tapdId));
7442
+ if (cache) return { ...cache, cacheKind: "projection-cache" };
7443
+ const legacy = prdWorkflowReadCachedSnapshotFile(prdWorkflowStatePath(scopedRoot, tapdId));
7444
+ return legacy ? { ...legacy, cacheKind: "legacy-projection-cache" } : null;
7445
+ }
7446
+
7447
+ function prdWorkflowReadCachedSnapshotWithFallback(root, scopedRoot, tapdId) {
7448
+ const scoped = prdWorkflowReadCachedSnapshot(scopedRoot, tapdId);
7449
+ if (scoped) return { ...scoped, cacheScope: "scoped", cacheRoot: scopedRoot };
7450
+ if (root && path.resolve(root) !== path.resolve(scopedRoot || root)) {
7451
+ const global = prdWorkflowReadCachedSnapshot(root, tapdId);
7452
+ if (global) return { ...global, cacheScope: "global", cacheRoot: root };
7453
+ }
7454
+ return null;
7455
+ }
7456
+
7457
+ function prdWorkflowReadJsonFile(filePath, fallback = null) {
7458
+ try {
7459
+ if (!fs.existsSync(filePath)) return fallback;
7460
+ const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
7461
+ return data && typeof data === "object" && !Array.isArray(data) ? data : fallback;
7462
+ } catch {
7463
+ return fallback;
7464
+ }
7465
+ }
7466
+
7467
+ function prdWorkflowWriteJsonFile(filePath, data) {
7468
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
7469
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
7470
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8");
7471
+ fs.renameSync(tmp, filePath);
7472
+ return data;
7473
+ }
7474
+
7475
+ function prdWorkflowReadProjectState(scopedRoot, tapdId) {
7476
+ return prdWorkflowReadJsonFile(prdWorkflowProjectPath(scopedRoot, tapdId), {
7477
+ version: 1,
7478
+ tapdId: String(tapdId || ""),
7479
+ updatedAt: "",
7480
+ snapshot: null,
7481
+ conflicts: [],
7482
+ });
7483
+ }
7484
+
7485
+ function prdWorkflowReadProjectStateWithFallback(root, scopedRoot, tapdId) {
7486
+ const scoped = prdWorkflowReadProjectState(scopedRoot, tapdId);
7487
+ if (scoped?.snapshot) return { ...scoped, cacheScope: "scoped", cacheRoot: scopedRoot };
7488
+ if (root && path.resolve(root) !== path.resolve(scopedRoot || root)) {
7489
+ const global = prdWorkflowReadProjectState(root, tapdId);
7490
+ if (global?.snapshot) return { ...global, cacheScope: "global", cacheRoot: root };
7491
+ }
7492
+ return scoped?.snapshot ? scoped : null;
7493
+ }
7494
+
7495
+ function prdWorkflowWriteProjectState(scopedRoot, tapdId, snapshot, patch = {}) {
7496
+ const prev = prdWorkflowReadProjectState(scopedRoot, tapdId);
7497
+ return prdWorkflowWriteJsonFile(prdWorkflowProjectPath(scopedRoot, tapdId), {
7498
+ version: 1,
7499
+ tapdId: String(tapdId || ""),
7500
+ updatedAt: new Date().toISOString(),
7501
+ snapshot,
7502
+ conflicts: Array.isArray(patch.conflicts) ? patch.conflicts : Array.isArray(prev.conflicts) ? prev.conflicts : [],
7503
+ sources: patch.sources && typeof patch.sources === "object" ? patch.sources : prev.sources || {},
7504
+ });
7505
+ }
7506
+
7507
+ function prdWorkflowReadClientState(scopedRoot, tapdId) {
7508
+ const data = prdWorkflowReadJsonFile(prdWorkflowClientsPath(scopedRoot, tapdId), null);
7509
+ if (!data) return { version: 1, tapdId: String(tapdId || ""), updatedAt: "", clients: {} };
7510
+ const clients = data.clients && typeof data.clients === "object" && !Array.isArray(data.clients)
7511
+ ? data.clients
7512
+ : {};
7513
+ return { ...data, clients };
7514
+ }
7515
+
7516
+ function prdWorkflowReadClientStateWithFallback(root, scopedRoot, tapdId) {
7517
+ const merged = { version: 1, tapdId: String(tapdId || ""), updatedAt: "", clients: {} };
7518
+ const add = (state, cacheScope) => {
7519
+ if (!state?.clients) return;
7520
+ if (state.updatedAt && (!merged.updatedAt || Date.parse(state.updatedAt) > Date.parse(merged.updatedAt))) {
7521
+ merged.updatedAt = state.updatedAt;
7522
+ }
7523
+ for (const [clientId, item] of Object.entries(state.clients)) {
7524
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
7525
+ const prev = merged.clients[clientId];
7526
+ const itemTime = Date.parse(item.reportedAt || item.observedAt || "");
7527
+ const prevTime = Date.parse(prev?.reportedAt || prev?.observedAt || "");
7528
+ if (!prev || !Number.isFinite(prevTime) || (Number.isFinite(itemTime) && itemTime >= prevTime)) {
7529
+ merged.clients[clientId] = { ...item, cacheScope };
7530
+ }
7531
+ }
7532
+ };
7533
+ if (root && path.resolve(root) !== path.resolve(scopedRoot || root)) {
7534
+ add(prdWorkflowReadClientState(root, tapdId), "global");
7535
+ }
7536
+ add(prdWorkflowReadClientState(scopedRoot, tapdId), "scoped");
7537
+ return merged;
7538
+ }
7539
+
7540
+ function prdWorkflowWriteClientObservation(scopedRoot, tapdId, meta, snapshot) {
7541
+ const state = prdWorkflowReadClientState(scopedRoot, tapdId);
7542
+ const clientId = prdWorkflowSafeStateId(meta.clientId || "anonymous");
7543
+ const nextClients = {
7544
+ ...state.clients,
7545
+ [clientId]: {
7546
+ clientId: String(meta.clientId || clientId),
7547
+ userId: String(meta.userId || ""),
7548
+ observedAt: String(meta.observedAt || ""),
7549
+ reportedAt: String(meta.reportedAt || new Date().toISOString()),
7550
+ revision: String(snapshot?.revision || ""),
7551
+ phase: String(snapshot?.phase || ""),
7552
+ pointer: String(snapshot?.pointer || ""),
7553
+ nextAction: snapshot?.nextAction || null,
7554
+ issues: Array.isArray(snapshot?.issues) ? snapshot.issues : [],
7555
+ artifacts: Array.isArray(snapshot?.artifacts) ? snapshot.artifacts : [],
7556
+ snapshot,
7557
+ },
7558
+ };
7559
+ return prdWorkflowWriteJsonFile(prdWorkflowClientsPath(scopedRoot, tapdId), {
7560
+ version: 1,
7561
+ tapdId: String(tapdId || ""),
7562
+ updatedAt: new Date().toISOString(),
7563
+ clients: nextClients,
7564
+ });
7565
+ }
7566
+
7567
+ const PRD_WORKFLOW_PROJECTION_SOURCE_KEYS = new Set([
7568
+ "projectionMode",
7569
+ "projectCacheScope",
7570
+ "legacyCacheScope",
7571
+ "clientsUpdatedAt",
7572
+ "runtimeEventsUpdatedAt",
7573
+ ]);
7574
+
7575
+ function prdWorkflowStoredObservationSources(...sourcesList) {
7576
+ const out = {};
7577
+ for (const sources of sourcesList) {
7578
+ if (!sources || typeof sources !== "object" || Array.isArray(sources)) continue;
7579
+ for (const [key, value] of Object.entries(sources)) {
7580
+ if (PRD_WORKFLOW_PROJECTION_SOURCE_KEYS.has(key)) continue;
7581
+ out[key] = value;
7582
+ }
7583
+ }
7584
+ return out;
7585
+ }
7586
+
7587
+ function prdWorkflowStoredObservationSnapshot(snapshot, sourcePatch = {}) {
7588
+ if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return snapshot;
7589
+ const clean = { ...snapshot };
7590
+ delete clean.events;
7591
+ delete clean.runtimeEvents;
7592
+ delete clean.runtime_events;
7593
+ delete clean.collaboration;
7594
+ delete clean.clientObservations;
7595
+ delete clean.clients;
7596
+ clean.sources = prdWorkflowStoredObservationSources(snapshot.sources, sourcePatch);
7597
+ return clean;
7598
+ }
7599
+
7600
+ function prdWorkflowProjectFactSource(payload = {}, rawSnapshot = {}) {
7601
+ const sources = rawSnapshot?.sources && typeof rawSnapshot.sources === "object" && !Array.isArray(rawSnapshot.sources)
7602
+ ? rawSnapshot.sources
7603
+ : {};
7604
+ const truth = String(payload.truth || rawSnapshot.truth || sources.truth || "").trim().toLowerCase();
7605
+ if (!["durable_fact", "project_fact"].includes(truth)) return null;
7606
+ return {
7607
+ truth,
7608
+ authority: String(payload.authority || rawSnapshot.authority || sources.authority || "ai-doc").trim() || "ai-doc",
7609
+ persistence: String(payload.persistence || rawSnapshot.persistence || sources.persistence || "ai-doc").trim() || "ai-doc",
7610
+ };
7611
+ }
7612
+
7613
+ function prdWorkflowSnapshotMetaFromReport(payload = {}, rawSnapshot = {}, req = null, userCtx = {}) {
7614
+ const sources = rawSnapshot.sources && typeof rawSnapshot.sources === "object" && !Array.isArray(rawSnapshot.sources)
7615
+ ? rawSnapshot.sources
7616
+ : {};
7617
+ const headerClientId = req?.headers?.["x-agentflow-client-id"];
7618
+ const headerObservedAt = req?.headers?.["x-agentflow-observed-at"];
7619
+ const reportedAt = new Date().toISOString();
7620
+ return {
7621
+ reportedAt,
7622
+ observedAt: String(payload.observedAt || payload.observed_at || rawSnapshot.observedAt || rawSnapshot.observed_at || sources.observedAt || sources.checkedAt || headerObservedAt || reportedAt),
7623
+ clientId: String(payload.clientId || payload.client_id || sources.clientId || headerClientId || userCtx?.userId || "anonymous").slice(0, 160),
7624
+ userId: String(userCtx?.userId || payload.userId || payload.user_id || "").slice(0, 160),
7625
+ baseRevision: String(payload.baseRevision || payload.base_revision || payload.expectedRevision || payload.expected_revision || rawSnapshot.baseRevision || sources.baseRevision || "").trim(),
7626
+ scope: String(payload.scope || rawSnapshot.scope || rawSnapshot.next?.scope || sources.scope || "client").trim().toLowerCase() || "client",
7627
+ platform: String(payload.platform || rawSnapshot.platform || rawSnapshot.next?.platform || sources.platform || "").trim(),
7628
+ issueKey: String(payload.issueKey || payload.issue_key || rawSnapshot.issueKey || rawSnapshot.issue_key || rawSnapshot.next?.issue || rawSnapshot.next?.issueKey || "").trim(),
7629
+ stageKey: String(payload.stageKey || payload.stage_key || rawSnapshot.stageKey || rawSnapshot.stage_key || rawSnapshot.next?.code || "").trim(),
7630
+ force: payload.force === true || payload.force === "1",
7631
+ };
7632
+ }
7633
+
7634
+ function prdWorkflowSnapshotReportConflict(existingRecord, incomingSnapshot, meta) {
7635
+ if (!existingRecord?.snapshot || meta.force) return null;
7636
+ const current = existingRecord.snapshot;
7637
+ const currentRevision = String(current.revision || "").trim();
7638
+ const incomingRevision = String(incomingSnapshot?.revision || "").trim();
7639
+ if (meta.baseRevision && currentRevision && meta.baseRevision !== currentRevision) {
7640
+ return {
7641
+ reason: "base-revision-mismatch",
7642
+ message: `snapshot base revision ${meta.baseRevision} is stale; current revision is ${currentRevision}`,
7643
+ expectedRevision: meta.baseRevision,
7644
+ currentRevision,
7645
+ incomingRevision,
7646
+ };
7647
+ }
7648
+ const incomingObservedAt = Date.parse(meta.observedAt || "");
7649
+ const currentObservedAt = Date.parse(
7650
+ current?.sources?.clientObservedAt ||
7651
+ current?.sources?.clientReportedAt ||
7652
+ existingRecord.updatedAt ||
7653
+ "",
7654
+ );
7655
+ if (
7656
+ Number.isFinite(incomingObservedAt) &&
7657
+ Number.isFinite(currentObservedAt) &&
7658
+ incomingObservedAt + 1000 < currentObservedAt &&
7659
+ incomingRevision !== currentRevision
7660
+ ) {
7661
+ return {
7662
+ reason: "older-observation",
7663
+ message: "snapshot was observed before the current cached workflow state",
7664
+ currentRevision,
7665
+ incomingRevision,
7666
+ currentObservedAt: new Date(currentObservedAt).toISOString(),
7667
+ incomingObservedAt: new Date(incomingObservedAt).toISOString(),
7668
+ };
7669
+ }
7670
+ return null;
7671
+ }
7672
+
7673
+ function prdWorkflowClientObservationRows(root, scopedRoot, tapdId) {
7674
+ const state = prdWorkflowReadClientStateWithFallback(root, scopedRoot, tapdId);
7675
+ return Object.values(state.clients || {})
7676
+ .filter((item) => item && typeof item === "object" && !Array.isArray(item))
7677
+ .sort((a, b) => Date.parse(b.reportedAt || b.observedAt || "") - Date.parse(a.reportedAt || a.observedAt || ""));
7678
+ }
7679
+
7680
+ function prdWorkflowLatestClientSnapshot(root, scopedRoot, tapdId) {
7681
+ const latest = prdWorkflowClientObservationRows(root, scopedRoot, tapdId)[0];
7682
+ return latest?.snapshot && typeof latest.snapshot === "object" && !Array.isArray(latest.snapshot)
7683
+ ? latest.snapshot
7684
+ : null;
7685
+ }
7686
+
7687
+ function prdWorkflowMergedClientIssues(clientRows = []) {
7688
+ const out = [];
7689
+ const seen = new Map();
7690
+ for (const client of clientRows) {
7691
+ const issues = Array.isArray(client.snapshot?.issues) ? client.snapshot.issues : Array.isArray(client.issues) ? client.issues : [];
7692
+ for (const issue of issues) {
7693
+ if (!issue || typeof issue !== "object" || Array.isArray(issue)) continue;
7694
+ const key = String(issue.key || issue.issueKey || issue.issue_key || issue.id || issue.title || "").trim();
7695
+ const platform = String(issue.platform || "").trim();
7696
+ const mergeKey = [key || "issue", platform || "all"].join("|");
7697
+ const prevIndex = seen.get(mergeKey);
7698
+ const next = {
7699
+ ...issue,
7700
+ key: key || issue.key,
7701
+ platform: platform || issue.platform,
7702
+ observedBy: client.clientId || "",
7703
+ observedAt: client.observedAt || client.reportedAt || "",
7704
+ };
7705
+ if (prevIndex != null) out[prevIndex] = { ...out[prevIndex], ...next };
7706
+ else {
7707
+ seen.set(mergeKey, out.length);
7708
+ out.push(next);
7709
+ }
7710
+ }
7711
+ }
7712
+ return out;
7713
+ }
7714
+
7715
+ function prdWorkflowMaterializeSnapshot(root, scopedRoot, tapdId, userCtx = {}, opts = {}) {
7716
+ const flowSource = String(opts.flowSource || "user").trim() || "user";
7717
+ const flowId = String(opts.flowId || "").trim();
7718
+ const project = prdWorkflowReadProjectStateWithFallback(root, scopedRoot, tapdId);
7719
+ const legacy = prdWorkflowReadCachedSnapshotWithFallback(root, scopedRoot, tapdId);
7720
+ const latestClient = prdWorkflowLatestClientSnapshot(root, scopedRoot, tapdId);
7721
+ const base = (
7722
+ project?.snapshot ||
7723
+ latestClient ||
7724
+ legacy?.snapshot ||
7725
+ prdWorkflowFallbackSnapshot(tapdId, "client_snapshot_required", "等待客户端 prd-flow skill 上报 Workflow snapshot", {
7726
+ optionalGaps: [{
7727
+ severity: "warn",
7728
+ text: "Workflow 服务端不会执行客户端 workspace 里的 prd-flow。请在客户端运行 prd-flow current <tapd_id>,并用 AGENTFLOW_BASE_URL + AGENTFLOW_TOKEN 上报到 /api/prd-workflow/snapshot。",
7729
+ }],
7730
+ sources: { executionMode: "client-report" },
7731
+ })
7732
+ );
7733
+ const clientObservations = prdWorkflowClientObservationRows(root, scopedRoot, tapdId).map((item) => ({
7734
+ clientId: item.clientId,
7735
+ userId: item.userId || "",
7736
+ phase: item.phase || "",
7737
+ pointer: item.pointer || "",
7738
+ revision: item.revision || "",
7739
+ observedAt: item.observedAt || "",
7740
+ reportedAt: item.reportedAt || "",
7741
+ nextAction: item.nextAction || null,
7742
+ cacheScope: item.cacheScope || "",
7743
+ }));
7744
+ const clientRows = prdWorkflowClientObservationRows(root, scopedRoot, tapdId);
7745
+ const clientIssues = prdWorkflowMergedClientIssues(clientRows);
7746
+ const materialized = prdWorkflowMergeRuntimeEvents(scopedRoot, tapdId, {
7747
+ ...base,
7748
+ issues: project?.snapshot ? base.issues : clientIssues.length ? clientIssues : base.issues,
7749
+ issueGroups: project?.snapshot ? base.issueGroups : clientIssues.length ? clientIssues : base.issueGroups,
7750
+ collaboration: prdWorkflowCollaborationState(userCtx, flowSource, flowId, tapdId),
7751
+ clientObservations,
7752
+ clients: clientObservations,
7753
+ sources: {
7754
+ ...(base.sources && typeof base.sources === "object" ? base.sources : {}),
7755
+ projectionMode: project?.snapshot ? "project" : latestClient ? "client-observations" : legacy?.snapshot ? "legacy-cache" : "empty",
7756
+ projectCacheScope: project?.cacheScope || "",
7757
+ legacyCacheScope: legacy?.cacheScope || "",
7758
+ clientsUpdatedAt: prdWorkflowReadClientStateWithFallback(root, scopedRoot, tapdId).updatedAt || "",
7759
+ checkedAt: new Date().toISOString(),
7760
+ },
7761
+ });
7762
+ if (!project?.snapshot && latestClient) {
7763
+ materialized.optionalGaps = [
7764
+ ...(Array.isArray(materialized.optionalGaps) ? materialized.optionalGaps : []),
7765
+ { severity: "info", text: "当前展示来自最新客户端观察;尚未形成独立 project materialized view。" },
7766
+ ];
7767
+ }
7768
+ return materialized;
7769
+ }
7770
+
7771
+ function prdWorkflowNormalizeStoredRuntimeEvent(tapdId, event = {}) {
7772
+ if (!event || typeof event !== "object" || Array.isArray(event)) return null;
7773
+ const out = { ...event };
7774
+ const type = String(out.type || out.kind || "").trim();
7775
+ const source = String(out.source || "").trim();
7776
+ const idem = String(out.idempotencyKey || out.idempotency_key || "").trim();
7777
+ const status = String(out.status || "").trim().toLowerCase();
7778
+ const isSnapshotObservation =
7779
+ idem.startsWith("snapshot-action:") ||
7780
+ (!out.truth && source === "prd-flow-client" && type === "workflow-action");
7781
+ if (isSnapshotObservation) {
7782
+ out.truth = out.truth || "observation";
7783
+ out.persistence = out.persistence || "runtime";
7784
+ out.authority = out.authority || "client";
7785
+ if (["done", "success", "completed", "passed"].includes(status)) out.status = "observed";
7786
+ } else {
7787
+ out.truth = out.truth || (type === "review-link" ? "runtime_event" : "runtime_event");
7788
+ out.persistence = out.persistence || "runtime";
7789
+ out.authority = out.authority || source || "agentflow";
7790
+ }
7791
+ if (type === "review-link") {
7792
+ const durability = String(out.durability || "").trim().toLowerCase();
7793
+ const sourceArtifact = out.sourceArtifact && typeof out.sourceArtifact === "object" && !Array.isArray(out.sourceArtifact)
7794
+ ? out.sourceArtifact
7795
+ : {
7796
+ kind: durability === "durable" ? "ai-doc" : "local-draft",
7797
+ durability: durability || "temporary",
7798
+ };
7799
+ out.truth = out.truth || "runtime_event";
7800
+ out.persistence = "runtime";
7801
+ out.sourceArtifact = sourceArtifact;
7802
+ const normalizeReviewRef = (item) => {
7803
+ if (!item || typeof item !== "object" || Array.isArray(item)) return item;
7804
+ const kind = String(item.kind || "").trim();
7805
+ const hasReviewUrl = /\/api\/prd-workflow\/review\//.test(String(item.url || item.href || ""));
7806
+ if (!kind && !hasReviewUrl) return item;
7807
+ return {
7808
+ ...item,
7809
+ persistence: item.persistence || "runtime",
7810
+ source: item.source && typeof item.source === "object" && !Array.isArray(item.source) ? item.source : sourceArtifact,
7811
+ };
7812
+ };
7813
+ if (Array.isArray(out.artifacts)) out.artifacts = out.artifacts.map(normalizeReviewRef);
7814
+ if (Array.isArray(out.links)) out.links = out.links.map(normalizeReviewRef);
7815
+ }
7816
+ out.tapdId = String(out.tapdId || out.tapd_id || tapdId || "");
7817
+ return out;
7818
+ }
7819
+
7820
+ function prdWorkflowReadRuntimeEvents(scopedRoot, tapdId) {
7821
+ try {
7822
+ const p = prdWorkflowEventsPath(scopedRoot, tapdId);
7823
+ if (!fs.existsSync(p)) return { version: 1, tapdId: String(tapdId || ""), events: [] };
7824
+ const data = JSON.parse(fs.readFileSync(p, "utf-8"));
7825
+ const events = Array.isArray(data?.events)
7826
+ ? data.events
7827
+ .map((item) => prdWorkflowNormalizeStoredRuntimeEvent(data?.tapdId || tapdId, item))
7828
+ .filter((item) => item && typeof item === "object")
7829
+ : [];
7830
+ return {
7831
+ version: 1,
7832
+ tapdId: String(data?.tapdId || tapdId || ""),
7833
+ updatedAt: data?.updatedAt || "",
7834
+ events,
7835
+ };
7836
+ } catch {
7837
+ return { version: 1, tapdId: String(tapdId || ""), events: [] };
7838
+ }
7839
+ }
7840
+
7841
+ function prdWorkflowWriteRuntimeEvents(scopedRoot, tapdId, events) {
7842
+ const p = prdWorkflowEventsPath(scopedRoot, tapdId);
7843
+ fs.mkdirSync(path.dirname(p), { recursive: true });
7844
+ const data = {
7845
+ version: 1,
7846
+ tapdId: String(tapdId || ""),
7847
+ updatedAt: new Date().toISOString(),
7848
+ events: Array.isArray(events) ? events.slice(-PRD_WORKFLOW_RUNTIME_EVENTS_MAX) : [],
7849
+ };
7850
+ const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
7851
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8");
7852
+ fs.renameSync(tmp, p);
7853
+ return data;
7854
+ }
7855
+
7856
+ function prdWorkflowRuntimeEventStatus(type, status) {
7857
+ const s = String(status || "").trim().toLowerCase();
7858
+ if (s) return s;
7859
+ const t = String(type || "").trim().toLowerCase();
7860
+ if (t.includes("done") || t.includes("success") || t.includes("completed")) return "done";
7861
+ if (t.includes("error") || t.includes("failed") || t.includes("conflict")) return "error";
7862
+ if (t.includes("start") || t.includes("running")) return "running";
7863
+ return "pending";
7864
+ }
7865
+
7866
+ function prdWorkflowRuntimeEventCanonicalStage(event = {}) {
7867
+ const issue = String(event.issueKey || event.issue_key || event.issue || "").trim();
7868
+ const action = String(event.action || event.actionId || event.action_id || "").trim();
7869
+ const rawStage = String(event.stage || event.stageKey || event.stage_key || event.phase || event.code || action || "").trim();
7870
+ const text = [rawStage, action, event.code, event.type, event.title].map((value) => String(value || "")).join(" ").toLowerCase();
7871
+ if (issue) {
7872
+ if (/plan_draft_local|submit-plan|plan-doc/.test(text)) return `issue-plan:${issue}`;
7873
+ if (/gitlab_issue_missing|ensure-gitlab-issue/.test(text)) return `issue-gitlab:${issue}`;
7874
+ }
7875
+ return rawStage || action;
7876
+ }
7877
+
7878
+ function prdWorkflowRuntimeEventId(event = {}) {
7879
+ const stage = prdWorkflowRuntimeEventCanonicalStage(event);
7880
+ const action = String(event.action || event.actionId || event.action_id || "").trim();
7881
+ const scope = String(event.scope || "").trim();
7882
+ const platform = String(event.platform || "").trim();
7883
+ const aggregateByStage = event.aggregateByStage !== false && event.aggregate_by_stage !== false;
7884
+ if ((stage || action) && aggregateByStage) {
7885
+ const issue = String(event.issueKey || event.issue_key || event.issue || "").trim();
7886
+ const key = [scope, issue, platform, stage || action].filter(Boolean).join(":");
7887
+ return `stage_${prdWorkflowSafeStateId(key)}`;
7888
+ }
7889
+ const existing = String(event.id || event.eventId || event.event_id || "").trim();
7890
+ if (existing) return existing.slice(0, 160);
7891
+ if (stage || action) {
7892
+ const issue = String(event.issueKey || event.issue_key || event.issue || "").trim();
7893
+ const key = [scope, issue, platform, stage || action].filter(Boolean).join(":");
7894
+ return `stage_${prdWorkflowSafeStateId(key)}`;
7895
+ }
7896
+ return `evt_${Date.now().toString(36)}_${crypto.randomBytes(4).toString("hex")}`;
7897
+ }
7898
+
7899
+ function prdWorkflowCompactRuntimeValue(value, maxChars = 24000) {
7900
+ if (value == null) return value;
7901
+ try {
7902
+ const text = JSON.stringify(value);
7903
+ if (!text || text.length <= maxChars) return value;
7904
+ return { truncated: true, preview: text.slice(0, maxChars) };
7905
+ } catch {
7906
+ const text = String(value || "");
7907
+ return text.length <= maxChars ? text : { truncated: true, preview: text.slice(0, maxChars) };
7908
+ }
7909
+ }
7910
+
7911
+ function prdWorkflowNormalizeRuntimeEvent(tapdId, event = {}) {
7912
+ const now = new Date().toISOString();
7913
+ const type = String(event.type || event.kind || "workflow-event").trim().slice(0, 120);
7914
+ const action = String(event.action || event.actionId || event.action_id || "").trim().slice(0, 160);
7915
+ const stage = String(event.stage || event.stageKey || event.stage_key || event.phase || action || "").trim().slice(0, 160);
7916
+ const scope = String(event.scope || "").trim().slice(0, 80);
7917
+ const platform = String(event.platform || "").trim().slice(0, 80);
7918
+ const status = prdWorkflowRuntimeEventStatus(type, event.status);
7919
+ const source = String(event.source || "agentflow").trim().slice(0, 80) || "agentflow";
7920
+ const truth = String(
7921
+ event.truth ||
7922
+ event.stateTruth ||
7923
+ event.state_truth ||
7924
+ (type === "review-link" ? "runtime_event" : source === "prd-flow-client" && type === "workflow-action" ? "observation" : "runtime_event"),
7925
+ ).trim();
7926
+ const persistence = String(event.persistence || event.storage || "runtime").trim();
7927
+ const authority = String(event.authority || (truth === "observation" ? "client" : source)).trim();
7928
+ const entry = {
7929
+ ...event,
7930
+ id: prdWorkflowRuntimeEventId(event),
7931
+ source,
7932
+ runtime: event.runtime !== false,
7933
+ type,
7934
+ tapdId: String(event.tapdId || event.tapd_id || tapdId || ""),
7935
+ status,
7936
+ truth,
7937
+ persistence,
7938
+ authority,
7939
+ updatedAt: now,
7940
+ };
7941
+ const sourceEventId = String(event.id || event.eventId || event.event_id || "").trim();
7942
+ if (sourceEventId && sourceEventId !== entry.id) entry.sourceEventId = sourceEventId.slice(0, 160);
7943
+ if (action) {
7944
+ entry.action = action;
7945
+ entry.actionId = String(event.actionId || event.action_id || action);
7946
+ }
7947
+ if (stage) {
7948
+ entry.stage = stage;
7949
+ entry.stageKey = String(event.stageKey || event.stage_key || stage);
7950
+ }
7951
+ if (scope) entry.scope = scope;
7952
+ if (platform) entry.platform = platform;
7953
+ if (!entry.createdAt) entry.createdAt = event.startedAt || now;
7954
+ if (!entry.title && (stage || action)) entry.title = stage || action;
7955
+ if (!entry.detail && event.message) entry.detail = String(event.message || "").slice(0, 4000);
7956
+ if (entry.rawOutput) entry.rawOutput = String(entry.rawOutput).slice(0, 12000);
7957
+ if (entry.error) entry.error = String(entry.error).slice(0, 4000);
7958
+ if (entry.output != null) entry.output = prdWorkflowCompactRuntimeValue(entry.output);
7959
+ if (entry.result != null) entry.result = prdWorkflowCompactRuntimeValue(entry.result);
7960
+ const history = Array.isArray(event.idempotencyHistory) ? event.idempotencyHistory : [];
7961
+ const idem = String(event.idempotencyKey || "").trim();
7962
+ if (idem && !history.includes(idem)) entry.idempotencyHistory = [...history, idem].slice(-50);
7963
+ return entry;
7964
+ }
7965
+
7966
+ function prdWorkflowMergeRuntimeEventArrays(left, right) {
7967
+ const out = [];
7968
+ const seen = new Set();
7969
+ for (const value of [...(Array.isArray(left) ? left : []), ...(Array.isArray(right) ? right : [])]) {
7970
+ if (value == null) continue;
7971
+ let key = "";
7972
+ try {
7973
+ key = typeof value === "string" ? value : JSON.stringify(value);
7974
+ } catch {
7975
+ key = String(value);
7976
+ }
7977
+ if (seen.has(key)) continue;
7978
+ seen.add(key);
7979
+ out.push(value);
7980
+ }
7981
+ return out;
7982
+ }
7983
+
7984
+ function prdWorkflowRuntimeEventArtifactSignature(event = {}) {
7985
+ const explicit = String(event.artifactHash || event.artifact_hash || event.hash || "").trim();
7986
+ if (explicit) return explicit;
7987
+ const parts = [
7988
+ event.artifact,
7989
+ event.artifactUrl || event.artifact_url,
7990
+ event.url,
7991
+ event.reviewUrl || event.review_url,
7992
+ event.planDoc || event.plan_doc,
7993
+ ].map((value) => String(value || "").trim()).filter(Boolean);
7994
+ if (parts.length) return parts.join("|");
7995
+ const artifacts = Array.isArray(event.artifacts) ? event.artifacts : [];
7996
+ const urls = artifacts
7997
+ .map((item) => String(item?.url || item?.href || item?.path || item?.label || "").trim())
7998
+ .filter(Boolean);
7999
+ return urls.length ? urls.join("|") : "";
8000
+ }
8001
+
8002
+ function prdWorkflowRuntimeEventShouldConflictOnArtifact(event = {}) {
8003
+ if (event.conflictOnArtifact === true || event.conflict_on_artifact === true) return true;
8004
+ if (event.conflictOnArtifact === false || event.conflict_on_artifact === false) return false;
8005
+ if (String(event.type || "") === "review-link") return false;
8006
+ if ((Array.isArray(event.artifacts) ? event.artifacts : []).some((item) => String(item?.kind || "") === "temporary-review")) return false;
8007
+ const stage = String(event.stage || event.stageKey || event.stage_key || event.action || "").toLowerCase();
8008
+ return /submit-plan|project-plan|tech-design|plan/.test(stage);
8009
+ }
8010
+
8011
+ function prdWorkflowAppendRuntimeEvent(scopedRoot, tapdId, event = {}) {
8012
+ try {
8013
+ const current = prdWorkflowReadRuntimeEvents(scopedRoot, tapdId);
8014
+ const entry = prdWorkflowNormalizeRuntimeEvent(tapdId, event);
8015
+ const entryIdem = String(entry.idempotencyKey || "").trim();
8016
+ const index = current.events.findIndex((item) => {
8017
+ if (String(item?.id || "") === entry.id) return true;
8018
+ if (!entryIdem) return false;
8019
+ if (String(item?.idempotencyKey || "").trim() === entryIdem) return true;
8020
+ return Array.isArray(item?.idempotencyHistory) && item.idempotencyHistory.includes(entryIdem);
8021
+ });
8022
+ const events = [...current.events];
8023
+ if (index >= 0) {
8024
+ const prevArtifact = prdWorkflowRuntimeEventArtifactSignature(events[index]);
8025
+ const nextArtifact = prdWorkflowRuntimeEventArtifactSignature(entry);
8026
+ const artifactConflict = prevArtifact && nextArtifact && prevArtifact !== nextArtifact &&
8027
+ prdWorkflowRuntimeEventShouldConflictOnArtifact(events[index]) &&
8028
+ prdWorkflowRuntimeEventShouldConflictOnArtifact(entry);
8029
+ const idempotencyHistory = [
8030
+ ...(events[index].idempotencyKey ? [events[index].idempotencyKey] : []),
8031
+ ...(entry.idempotencyKey ? [entry.idempotencyKey] : []),
8032
+ ...(Array.isArray(events[index].idempotencyHistory) ? events[index].idempotencyHistory : []),
8033
+ ...(Array.isArray(entry.idempotencyHistory) ? entry.idempotencyHistory : []),
8034
+ ];
8035
+ events[index] = {
8036
+ ...events[index],
8037
+ ...entry,
8038
+ links: prdWorkflowMergeRuntimeEventArrays(events[index].links, entry.links),
8039
+ artifacts: prdWorkflowMergeRuntimeEventArrays(events[index].artifacts, entry.artifacts),
8040
+ outputs: prdWorkflowMergeRuntimeEventArrays(events[index].outputs, entry.outputs),
8041
+ results: prdWorkflowMergeRuntimeEventArrays(events[index].results, entry.results),
8042
+ createdAt: events[index].createdAt || entry.createdAt,
8043
+ startedAt: events[index].startedAt || entry.startedAt,
8044
+ idempotencyHistory: [...new Set(idempotencyHistory)].slice(-50),
8045
+ };
8046
+ if (artifactConflict) {
8047
+ events[index] = {
8048
+ ...events[index],
8049
+ type: "same-platform-stage-conflict",
8050
+ status: "conflict",
8051
+ conflict: {
8052
+ type: "same-platform-stage-conflict",
8053
+ previousArtifact: prevArtifact,
8054
+ incomingArtifact: nextArtifact,
8055
+ message: "同一 issue/platform/stage 上报了不同产物,需要本地 agent 拉取后 review。",
8056
+ },
8057
+ };
8058
+ }
8059
+ } else {
8060
+ events.push(entry);
8061
+ }
8062
+ prdWorkflowWriteRuntimeEvents(scopedRoot, tapdId, events);
8063
+ return entry;
8064
+ } catch {
8065
+ return null;
8066
+ }
8067
+ }
8068
+
8069
+ function prdWorkflowFindCompletedIdempotencyEvent(scopedRoot, tapdId, idempotencyKey) {
8070
+ const key = String(idempotencyKey || "").trim();
8071
+ if (!key) return null;
8072
+ const events = prdWorkflowReadRuntimeEvents(scopedRoot, tapdId).events;
8073
+ return [...events].reverse().find((event) => (
8074
+ (String(event?.idempotencyKey || "") === key || (Array.isArray(event?.idempotencyHistory) && event.idempotencyHistory.includes(key))) &&
8075
+ ["done", "success", "completed"].includes(String(event?.status || "").toLowerCase())
8076
+ )) || null;
8077
+ }
8078
+
8079
+ function prdWorkflowRuntimeEventDedupeKey(event = {}, index = 0) {
8080
+ const stage = prdWorkflowRuntimeEventCanonicalStage(event);
8081
+ const aggregateByStage = event.aggregateByStage !== false && event.aggregate_by_stage !== false;
8082
+ if (stage) {
8083
+ const issue = event?.issueKey || event?.issue_key || event?.issue;
8084
+ const platform = event?.platform;
8085
+ if (aggregateByStage || issue || platform) {
8086
+ return ["stage", event?.scope, issue, platform, stage]
8087
+ .map((value) => String(value || "").trim())
8088
+ .join(":");
8089
+ }
8090
+ }
8091
+ const id = String(event?.id || event?.eventId || event?.event_id || "").trim();
8092
+ if (id) return `id:${id}`;
8093
+ if (stage) {
8094
+ return ["stage", event?.scope, event?.issueKey || event?.issue_key || event?.issue, event?.platform, stage]
8095
+ .map((value) => String(value || "").trim())
8096
+ .join(":");
8097
+ }
8098
+ return `idx:${index}`;
8099
+ }
8100
+
8101
+ function prdWorkflowMergeRuntimeEventList(snapshotEvents = [], runtimeEvents = []) {
8102
+ const out = [];
8103
+ const seen = new Map();
8104
+ for (const event of [...(Array.isArray(snapshotEvents) ? snapshotEvents : []), ...(Array.isArray(runtimeEvents) ? runtimeEvents : [])]) {
8105
+ if (!event || typeof event !== "object" || Array.isArray(event)) continue;
8106
+ const key = prdWorkflowRuntimeEventDedupeKey(event, out.length);
8107
+ const index = seen.get(key);
8108
+ if (index == null) {
8109
+ seen.set(key, out.length);
8110
+ out.push(event);
8111
+ } else {
8112
+ out[index] = {
8113
+ ...out[index],
8114
+ ...event,
8115
+ links: prdWorkflowMergeRuntimeEventArrays(out[index].links, event.links),
8116
+ artifacts: prdWorkflowMergeRuntimeEventArrays(out[index].artifacts, event.artifacts),
8117
+ outputs: prdWorkflowMergeRuntimeEventArrays(out[index].outputs, event.outputs),
8118
+ results: prdWorkflowMergeRuntimeEventArrays(out[index].results, event.results),
8119
+ };
8120
+ }
8121
+ }
8122
+ return out;
8123
+ }
8124
+
8125
+ function prdWorkflowMergeRuntimeEvents(scopedRoot, tapdId, snapshot) {
8126
+ const runtime = prdWorkflowReadRuntimeEvents(scopedRoot, tapdId);
8127
+ const runtimeEvents = runtime.events;
8128
+ if (!runtimeEvents.length) return snapshot;
8129
+ const events = prdWorkflowMergeRuntimeEventList(snapshot?.events, runtimeEvents);
8130
+ return {
8131
+ ...snapshot,
8132
+ runtimeEvents,
8133
+ events,
8134
+ sources: {
8135
+ ...(snapshot?.sources && typeof snapshot.sources === "object" ? snapshot.sources : {}),
8136
+ runtimeEventsUpdatedAt: runtime.updatedAt || "",
8137
+ },
8138
+ };
8139
+ }
8140
+
8141
+ function prdWorkflowMockSnapshot(scopedRoot, tapdId = "mock-prd") {
8142
+ const id = String(tapdId || "mock-prd");
8143
+ const now = new Date().toISOString();
8144
+ return prdWorkflowMergeRuntimeEvents(scopedRoot, id, {
8145
+ tapdId: id,
8146
+ phase: "implementing",
8147
+ pointer: "实现中:Tunnel ConfigV3 支持",
8148
+ revision: "mock:tapd-ai-doc-gitlab-runtime",
8149
+ actions: [
8150
+ {
8151
+ id: "stage_tech_design",
8152
+ stage: "tech_design",
8153
+ action: "submit-tech-design",
8154
+ title: "确认技术方案",
8155
+ detail: "tech_design.md 与 TAPD baseline 已归档",
8156
+ status: "done",
8157
+ updatedAt: now,
8158
+ artifacts: [{ label: "技术方案", url: "https://example.com/ai-doc/stories/mock/tech_design.md" }],
8159
+ },
8160
+ {
8161
+ id: "stage_plan_android",
8162
+ stage: "submit-plan",
8163
+ action: "submit-plan",
8164
+ issueKey: "tunnel-config-v3",
8165
+ title: "提交 Android 实施计划",
8166
+ detail: "计划已确认,GitLab Issue 已绑定",
8167
+ status: "done",
8168
+ updatedAt: now,
8169
+ artifacts: [{ label: "Android Plan", url: "https://example.com/ai-doc/stories/mock/android-plan.md" }],
8170
+ },
8171
+ {
8172
+ id: "stage_implementation_tunnel-config-v3",
8173
+ stage: "implementation",
8174
+ action: "mark",
8175
+ issueKey: "tunnel-config-v3",
8176
+ title: "实现 Tunnel ConfigV3",
8177
+ detail: "Android MR 已创建,iOS 待处理",
8178
+ status: "current",
8179
+ updatedAt: now,
8180
+ dryRunSupported: true,
8181
+ },
8182
+ ],
8183
+ nextAction: {
8184
+ action: "mark",
8185
+ actionId: "mark-impl-mr",
8186
+ stage: "implementation",
8187
+ issueKey: "tunnel-config-v3",
8188
+ title: "记录实现 MR",
8189
+ detail: "验证 GitLab Issue/MR 关联后写入 Workflow runtime",
8190
+ dryRunSupported: true,
8191
+ },
8192
+ epics: [
8193
+ {
8194
+ key: "network-governance",
8195
+ title: "网络治理",
8196
+ issues: [
8197
+ {
8198
+ key: "tunnel-config-v3",
8199
+ title: "Tunnel ConfigV3 URI 云控支持",
8200
+ platform: "all",
8201
+ status: "implementing",
8202
+ issueUrl: "https://example.com/gitlab/issues/101",
8203
+ planDocUrl: "https://example.com/ai-doc/stories/mock/android-plan.md",
8204
+ mrs: [
8205
+ { label: "Android MR", platform: "android", url: "https://example.com/gitlab/mr/1", status: "opened" },
8206
+ { label: "iOS MR", platform: "ios", url: "https://example.com/gitlab/mr/2", status: "todo" },
8207
+ ],
8208
+ },
8209
+ {
8210
+ key: "self-test-package",
8211
+ parentKey: "tunnel-config-v3",
8212
+ title: "自测包与验证记录",
8213
+ platform: "all",
8214
+ status: "pending",
8215
+ links: [{ label: "临时 review", url: "https://example.com/review/mock" }],
8216
+ },
8217
+ ],
8218
+ },
8219
+ ],
8220
+ issues: [
8221
+ {
8222
+ key: "tunnel-config-v3",
8223
+ epicKey: "network-governance",
8224
+ title: "Tunnel ConfigV3 URI 云控支持",
8225
+ platform: "all",
8226
+ status: "implementing",
8227
+ },
8228
+ ],
8229
+ artifacts: [
8230
+ { label: "TAPD 需求", kind: "tapd", url: "https://example.com/tapd/mock" },
8231
+ { label: "技术方案", kind: "ai-doc", url: "https://example.com/ai-doc/stories/mock/tech_design.md" },
8232
+ ],
8233
+ optionalGaps: [{ severity: "info", text: "Mock snapshot: 用于验证 Workflow 阶段、Epic/Issue 层级和 MR 归类。" }],
8234
+ sources: { checkedAt: now, mock: true },
8235
+ });
8236
+ }
8237
+
8238
+ function prdWorkflowWriteCachedSnapshot(scopedRoot, tapdId, snapshot) {
8239
+ try {
8240
+ const p = prdWorkflowCachePath(scopedRoot, tapdId);
8241
+ fs.mkdirSync(path.dirname(p), { recursive: true });
8242
+ const data = {
8243
+ version: 1,
8244
+ tapdId: String(tapdId || ""),
8245
+ updatedAt: new Date().toISOString(),
8246
+ snapshot,
8247
+ sources: {
8248
+ truth: "projection",
8249
+ authority: "agentflow-runtime",
8250
+ persistence: "cache",
8251
+ },
8252
+ };
8253
+ const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
8254
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8");
8255
+ fs.renameSync(tmp, p);
8256
+ return data;
8257
+ } catch {
8258
+ return null;
8259
+ }
8260
+ }
8261
+
8262
+ function prdWorkflowFallbackWithCache(scopedRoot, tapdId, fallback, userCtx = {}, flowSource = "user", flowId = "") {
8263
+ const cached = prdWorkflowReadCachedSnapshot(scopedRoot, tapdId);
8264
+ const snapshot = cached?.snapshot && typeof cached.snapshot === "object" && !Array.isArray(cached.snapshot)
8265
+ ? cached.snapshot
8266
+ : null;
8267
+ if (!snapshot) {
8268
+ return {
8269
+ ...fallback,
8270
+ collaboration: prdWorkflowCollaborationState(userCtx, flowSource, flowId, tapdId),
8271
+ };
8272
+ }
8273
+ const optionalGaps = [
8274
+ ...(Array.isArray(fallback?.optionalGaps) ? fallback.optionalGaps : []),
8275
+ ...(Array.isArray(snapshot.optionalGaps) ? snapshot.optionalGaps : []),
8276
+ ];
8277
+ return prdWorkflowMergeRuntimeEvents(scopedRoot, tapdId, {
8278
+ ...snapshot,
8279
+ stale: true,
8280
+ runtimeStatus: fallback?.phase || "unavailable",
8281
+ runtimeMessage: fallback?.pointer || fallback?.error || "",
8282
+ optionalGaps,
8283
+ collaboration: prdWorkflowCollaborationState(userCtx, flowSource, flowId, tapdId),
8284
+ sources: {
8285
+ ...(snapshot.sources && typeof snapshot.sources === "object" ? snapshot.sources : {}),
8286
+ cacheUpdatedAt: cached.updatedAt || "",
8287
+ checkedAt: new Date().toISOString(),
8288
+ },
8289
+ });
8290
+ }
8291
+
8292
+ function prdWorkflowWithAgentflowTokenDiagnostic(snapshot, sessionToken = "") {
8293
+ const current = snapshot && typeof snapshot === "object" && !Array.isArray(snapshot) ? snapshot : {};
8294
+ const tokenPresent = Boolean(String(sessionToken || "").trim());
8295
+ const optionalGaps = Array.isArray(current.optionalGaps) ? [...current.optionalGaps] : [];
8296
+ const hasTokenHint = optionalGaps.some((gap) => /AgentFlow runtime token|AGENTFLOW_BASE_URL|AGENTFLOW_TOKEN|session token/i.test(String(gap?.text || gap || "")));
8297
+ if (!hasTokenHint) {
8298
+ optionalGaps.push({
8299
+ severity: "info",
8300
+ text: tokenPresent
8301
+ ? "当前浏览器会话已有 AgentFlow token。客户端 prd-flow skill/CLI 同步 Workflow 时还需要 AGENTFLOW_BASE_URL,并使用 AGENTFLOW_TOKEN 或 PRD_FLOW_RUNTIME_EVENT_TOKEN 上报 snapshot/event;不要把 token 写入 ai-doc 或 prd-flow config。"
8302
+ : "当前请求没有 AgentFlow session token。客户端 prd-flow skill/CLI 要同步 Workflow 时,需要在客户端 .env 配置 AGENTFLOW_BASE_URL 和 AGENTFLOW_TOKEN,或配置 PRD_FLOW_RUNTIME_BASE_URL 和 PRD_FLOW_RUNTIME_EVENT_TOKEN。",
8303
+ });
8304
+ }
8305
+ return {
8306
+ ...current,
8307
+ optionalGaps,
8308
+ sources: {
8309
+ ...(current.sources && typeof current.sources === "object" ? current.sources : {}),
8310
+ agentflowRuntimeToken: tokenPresent ? "session" : "not-required-for-local-ui",
8311
+ },
8312
+ };
8313
+ }
8314
+
8315
+ function prdWorkflowAllowServerExec() {
8316
+ return parseBool(process.env.AGENTFLOW_PRD_WORKFLOW_SERVER_EXEC, false);
8317
+ }
8318
+
8319
+ function prdWorkflowParseJson(stdout) {
8320
+ const text = String(stdout || "").trim();
8321
+ if (!text) return null;
8322
+ try {
8323
+ return JSON.parse(text);
8324
+ } catch (_) {}
8325
+ const first = text.indexOf("{");
8326
+ const last = text.lastIndexOf("}");
8327
+ if (first >= 0 && last > first) {
8328
+ try {
8329
+ return JSON.parse(text.slice(first, last + 1));
8330
+ } catch (_) {}
8331
+ }
8332
+ return null;
8333
+ }
8334
+
8335
+ function prdWorkflowFirstArray(...values) {
8336
+ const value = values.find((item) => Array.isArray(item));
8337
+ return value ? [...value] : [];
8338
+ }
8339
+
8340
+ function prdWorkflowSplitCommand(command) {
8341
+ const text = String(command || "").trim();
8342
+ if (!text || /[\0\r\n]/.test(text) || text.length > 4000) return [];
8343
+ const tokens = [];
8344
+ let current = "";
8345
+ let quote = "";
8346
+ let escaping = false;
8347
+ for (const ch of text) {
8348
+ if (escaping) {
8349
+ current += ch;
8350
+ escaping = false;
8351
+ continue;
8352
+ }
8353
+ if (ch === "\\") {
8354
+ escaping = true;
8355
+ continue;
8356
+ }
8357
+ if (quote) {
8358
+ if (ch === quote) quote = "";
8359
+ else current += ch;
8360
+ continue;
8361
+ }
8362
+ if (ch === "'" || ch === '"') {
8363
+ quote = ch;
8364
+ continue;
8365
+ }
8366
+ if (/\s/.test(ch)) {
8367
+ if (current) {
8368
+ tokens.push(current);
8369
+ current = "";
8370
+ }
8371
+ continue;
8372
+ }
8373
+ current += ch;
8374
+ }
8375
+ if (escaping) current += "\\";
8376
+ if (quote) return [];
8377
+ if (current) tokens.push(current);
8378
+ return tokens.filter(Boolean);
8379
+ }
8380
+
8381
+ function prdWorkflowCommandArgs(command) {
8382
+ const tokens = prdWorkflowSplitCommand(command);
8383
+ if (!tokens.length) return [];
8384
+ const first = tokens[0] || "";
8385
+ const start = path.basename(first) === "prd-flow" ? 1 : 0;
8386
+ const args = tokens.slice(start);
8387
+ const action = String(args[0] || "");
8388
+ if (!action || action.startsWith("-") || /[\/\\]/.test(action)) return [];
8389
+ return args;
8390
+ }
8391
+
8392
+ function prdWorkflowCommandTapdId(args = []) {
8393
+ for (const arg of args.slice(1)) {
8394
+ const value = String(arg || "").trim();
8395
+ if (!value || value.startsWith("-")) continue;
8396
+ return value;
8397
+ }
8398
+ return "";
8399
+ }
8400
+
8401
+ function prdWorkflowNormalizeNextAction(parsed = {}, fallbackTapdId = "") {
8402
+ const raw = parsed.nextAction || parsed.next_action || parsed.currentAction || parsed.current_action || parsed.next || null;
8403
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
8404
+ const command = String(raw.command || raw.nextCommand || raw.next_command || "").trim();
8405
+ const args = prdWorkflowCommandArgs(command);
8406
+ const action = String(raw.action || raw.actionId || raw.action_id || args[0] || raw.code || raw.id || "").trim();
8407
+ const issue = raw.issue && typeof raw.issue === "object" && !Array.isArray(raw.issue)
8408
+ ? String(raw.issue.key || raw.issue.id || raw.issue.title || "").trim()
8409
+ : String(raw.issue || raw.issueKey || raw.issue_key || "").trim();
8410
+ const title = String(raw.title || raw.label || raw.name || raw.code || action || "下一步").trim();
8411
+ return {
8412
+ ...raw,
8413
+ id: String(raw.id || raw.code || action || "next"),
8414
+ action,
8415
+ actionId: String(raw.actionId || raw.action_id || action),
8416
+ command,
8417
+ label: String(raw.label || title),
8418
+ title,
8419
+ detail: String(raw.detail || raw.description || raw.user_hint || raw.userHint || ""),
8420
+ issueKey: issue,
8421
+ tapdId: String(raw.tapdId || raw.tapd_id || prdWorkflowCommandTapdId(args) || fallbackTapdId || ""),
8422
+ dryRunSupported: raw.dryRunSupported !== false && raw.dry_run_supported !== false,
8423
+ dryRunMode: command ? "preview" : raw.dryRunMode || raw.dry_run_mode || "",
8424
+ };
8425
+ }
8426
+
8427
+ async function runPrdWorkflowCommand(root, scopedRoot, args = [], userCtx = {}, options = {}) {
8428
+ const cli = prdWorkflowResolveCli(root, scopedRoot);
8429
+ const cwd = scopedRoot || root || process.cwd();
8430
+ const env = runtimeEnvForUser(userCtx, {
8431
+ PRD_FLOW_WORKSPACE: path.join(cwd, ".workspace", "prd-flow"),
8432
+ ...(options.env && typeof options.env === "object" && !Array.isArray(options.env) ? options.env : {}),
8433
+ });
8434
+ const result = await execFileBuffered(cli.command, args, {
8435
+ cwd,
8436
+ env,
8437
+ timeout: Number(options.timeout || 120000),
8438
+ maxBuffer: Number(options.maxBuffer || 4 * 1024 * 1024),
8439
+ });
8440
+ return { ...result, cli };
8441
+ }
8442
+
8443
+ function prdWorkflowSnapshotFromParsed(scopedRoot, tapdId, parsed = {}, userCtx = {}, opts = {}) {
8444
+ const id = String(tapdId || parsed?.tapdId || parsed?.tapd_id || parsed?.prd?.tapd_id || parsed?.prd?.tapdId || "").trim();
8445
+ const flowSource = String(opts.flowSource || "user").trim() || "user";
8446
+ const flowId = String(opts.flowId || "").trim();
8447
+ const nextAction = prdWorkflowNormalizeNextAction(parsed, id);
8448
+ const prd = parsed.prd && typeof parsed.prd === "object" && !Array.isArray(parsed.prd) ? parsed.prd : {};
8449
+ const actions = prdWorkflowFirstArray(parsed.actions, parsed.workflowActions, parsed.workflow_actions);
8450
+ const epics = prdWorkflowFirstArray(parsed.epics, parsed.epicGroups, parsed.epic_groups, prd.epics, prd.epicGroups, prd.epic_groups);
8451
+ const issues = prdWorkflowFirstArray(parsed.issues, parsed.issueGroups, parsed.issue_groups, prd.issues, prd.issueGroups, prd.issue_groups);
8452
+ const optionalGaps = prdWorkflowFirstArray(parsed.optionalGaps, parsed.optional_gaps);
8453
+ if (!actions.length && !nextAction) {
8454
+ optionalGaps.push({ severity: "warn", text: "prd-flow current --json 未返回 actions/nextAction,Workflow 时间线只能显示 runtime 或空状态。" });
8455
+ }
8456
+ if (!epics.length && !issues.length) {
8457
+ optionalGaps.push({ severity: "info", text: "prd-flow current --json 未返回 epics/issues,Issue 整合区暂为空。" });
8458
+ }
8459
+ const snapshot = {
8460
+ tapdId: String(parsed.tapdId || parsed.tapd_id || prd.tapd_id || prd.tapdId || id),
8461
+ phase: String(parsed.phase || parsed.workflow_stage || nextAction?.id || parsed.next?.code || "unknown"),
8462
+ pointer: String(parsed.pointer || parsed.current || parsed.status || nextAction?.title || ""),
8463
+ revision: String(parsed.revision || prd.revision || parsed.next?.revision || ""),
8464
+ nextAction,
8465
+ actions,
8466
+ workflowActions: actions,
8467
+ timeline: prdWorkflowFirstArray(parsed.timeline),
8468
+ events: prdWorkflowFirstArray(parsed.events),
8469
+ history: prdWorkflowFirstArray(parsed.history),
8470
+ milestones: prdWorkflowFirstArray(parsed.milestones),
8471
+ epics,
8472
+ epicGroups: epics,
8473
+ issues,
8474
+ issueGroups: issues,
8475
+ artifacts: prdWorkflowFirstArray(parsed.artifacts, parsed.outputs),
8476
+ dependencies: prdWorkflowFirstArray(parsed.dependencies),
8477
+ optionalGaps,
8478
+ sources: parsed.sources && typeof parsed.sources === "object" ? parsed.sources : {},
8479
+ raw: parsed,
8480
+ collaboration: prdWorkflowCollaborationState(userCtx, flowSource, flowId, id),
8481
+ };
8482
+ snapshot.revision = prdWorkflowSnapshotRevision(snapshot);
8483
+ return snapshot;
8484
+ }
8485
+
8486
+ async function prdWorkflowSnapshot(root, scopedRoot, tapdId, userCtx = {}, opts = {}) {
8487
+ const id = String(tapdId || "").trim();
8488
+ const flowSource = String(opts.flowSource || "user").trim() || "user";
8489
+ const flowId = String(opts.flowId || "").trim();
8490
+ if (!id) {
8491
+ return {
8492
+ ...prdWorkflowFallbackSnapshot("", "unselected", "输入 TAPD ID 后读取 Workflow 状态"),
8493
+ collaboration: prdWorkflowCollaborationState(userCtx, flowSource, flowId, ""),
8494
+ };
8495
+ }
8496
+ if (!prdWorkflowAllowServerExec()) {
8497
+ return prdWorkflowMaterializeSnapshot(root, scopedRoot, id, userCtx, { flowSource, flowId });
8498
+ }
8499
+ try {
8500
+ const result = await runPrdWorkflowCommand(root, scopedRoot, ["current", id, "--json"], userCtx, { timeout: 120000 });
8501
+ const parsed = prdWorkflowParseJson(result.stdout);
8502
+ if (!parsed) {
8503
+ return prdWorkflowFallbackWithCache(scopedRoot, id, prdWorkflowFallbackSnapshot(id, "json_unsupported", "prd-flow current --json 暂不可用或未返回 JSON", {
8504
+ rawOutput: String(result.stdout || result.stderr || "").slice(0, 8000),
8505
+ cli: result.cli,
8506
+ }), userCtx, flowSource, flowId);
8507
+ }
8508
+ const snapshot = {
8509
+ ...prdWorkflowSnapshotFromParsed(scopedRoot, id, parsed, userCtx, opts),
8510
+ cli: result.cli,
8511
+ };
8512
+ const mergedSnapshot = prdWorkflowMergeRuntimeEvents(scopedRoot, id, snapshot);
8513
+ prdWorkflowWriteCachedSnapshot(scopedRoot, id, mergedSnapshot);
8514
+ return mergedSnapshot;
8515
+ } catch (e) {
8516
+ const code = e?.code || "";
8517
+ const stdout = String(e?.stdout || "");
8518
+ const stderr = String(e?.stderr || "");
8519
+ const missing = code === "ENOENT";
8520
+ return prdWorkflowFallbackWithCache(scopedRoot, id, prdWorkflowFallbackSnapshot(
8521
+ id,
8522
+ missing ? "unavailable" : "command_failed",
8523
+ missing ? "未找到 prd-flow CLI,请先在 workspace 配置 .workspace/prd-flow/bin/prd-flow 或设置 PRD_FLOW_CLI" : `prd-flow current --json 执行失败:${String(e?.message || e)}`,
8524
+ {
8525
+ rawOutput: `${stdout}${stdout && stderr ? "\n" : ""}${stderr}`.slice(0, 8000),
8526
+ error: String(e?.message || e),
8527
+ },
8528
+ ), userCtx, flowSource, flowId);
8529
+ }
8530
+ }
8531
+
8532
+ function normalizePrdWorkflowActionArgs(payload = {}) {
8533
+ const command = String(payload.command || payload.nextCommand || payload.next_command || "").trim();
8534
+ const commandArgs = prdWorkflowCommandArgs(command);
8535
+ const action = String(payload.action || payload.actionId || commandArgs[0] || "").trim();
8536
+ if (!action || action.startsWith("-") || /[\0\r\n]/.test(action) || action.length > 160) {
8537
+ return { error: "Invalid prd-flow action" };
8538
+ }
8539
+ const tapdId = String(payload.tapdId || payload.tapd_id || prdWorkflowCommandTapdId(commandArgs)).trim();
8540
+ if (!tapdId) return { error: "Missing tapdId" };
8541
+ if (commandArgs.length) {
8542
+ const expectedRevision = String(payload.expectedRevision || "").trim();
8543
+ const idempotencyKey = String(payload.idempotencyKey || "").trim();
8544
+ const args = [...commandArgs];
8545
+ if (expectedRevision && !args.includes("--expected-revision")) args.push("--expected-revision", expectedRevision);
8546
+ if (idempotencyKey && !args.includes("--idempotency-key")) args.push("--idempotency-key", idempotencyKey);
8547
+ return {
8548
+ action,
8549
+ tapdId,
8550
+ args,
8551
+ idempotencyKey,
8552
+ command,
8553
+ previewOnly: payload.dryRun === true || payload.dry_run === true,
8554
+ fromCommand: true,
8555
+ };
8556
+ }
8557
+ const args = [action, tapdId];
8558
+ const issue = String(payload.issueKey || payload.issue || "").trim();
8559
+ if (issue) args.push("--issue", issue);
8560
+ const summary = String(payload.summary || "").trim();
8561
+ if (summary && action === "start-fix") args.push("--summary", summary);
8562
+ const testEnv = String(payload.testEnv || payload.test_environment || "").trim();
8563
+ if (testEnv && action === "submit-test") args.push("--test-env", testEnv);
8564
+ const mr = String(payload.mr || payload.url || "").trim();
8565
+ if (mr && action === "submit-test") args.push("--mr", mr);
8566
+ if (payload.confirm === true) args.push("--confirm");
8567
+ if (payload.dryRun === true || payload.dry_run === true) args.push("--dry-run");
8568
+ if (payload.allowMissingImplementation === true) args.push("--allow-missing-implementation");
8569
+ const expectedRevision = String(payload.expectedRevision || "").trim();
8570
+ if (expectedRevision) args.push("--expected-revision", expectedRevision);
8571
+ const idempotencyKey = String(payload.idempotencyKey || "").trim();
8572
+ if (idempotencyKey) args.push("--idempotency-key", idempotencyKey);
8573
+ args.push("--json");
8574
+ return { action, tapdId, args, idempotencyKey };
8575
+ }
8576
+
8577
+ function prdWorkflowActionText(payload = {}, normalized = {}) {
8578
+ return [
8579
+ normalized?.action,
8580
+ payload.action,
8581
+ payload.actionId,
8582
+ payload.action_id,
8583
+ payload.command,
8584
+ payload.marker,
8585
+ payload.flag,
8586
+ payload.stage,
8587
+ payload.stageKey,
8588
+ payload.stage_key,
8589
+ payload.title,
8590
+ payload.label,
8591
+ ].map((value) => String(value || "").trim()).filter(Boolean).join(" ");
8592
+ }
8593
+
8594
+ function prdWorkflowFirstString(...values) {
8595
+ for (const value of values) {
8596
+ const text = String(value || "").trim();
8597
+ if (text) return text;
8598
+ }
8599
+ return "";
8600
+ }
8601
+
8602
+ function prdWorkflowMarkerEventSpec(payload = {}, normalized = {}) {
8603
+ const text = prdWorkflowActionText(payload, normalized).toLowerCase().replace(/[_\s]+/g, "-");
8604
+ const explicitRuntimeOnly = payload.runtimeOnly === true || payload.runtime_only === true ||
8605
+ payload.markerOnly === true || payload.marker_only === true;
8606
+ const specs = [
8607
+ { re: /(^|-)mark-impl-mr($|-)|(^|-)record-impl-mr($|-)|--impl-mr\b/, stage: "implementation", title: "记录实现 MR", kind: "impl-mr" },
8608
+ { re: /(^|-)mark-fix-mr($|-)|(^|-)record-fix-mr($|-)|--fix-mr\b/, stage: "bugfix", title: "记录修复 MR", kind: "fix-mr" },
8609
+ { re: /(^|-)mark-impl-done($|-)|--impl-done\b/, stage: "implementation", title: "实现完成标记", kind: "impl-done" },
8610
+ { re: /(^|-)mark-impl-merged($|-)|--impl-merged\b/, stage: "implementation", title: "实现 MR 合并标记", kind: "impl-merged" },
8611
+ { re: /(^|-)mark-integration-mr($|-)|(^|-)record-integration-mr($|-)|--integration-mr\b/, stage: "submit-test", title: "记录集成 MR", kind: "integration-mr" },
8612
+ { re: /(^|-)mark-integrated($|-)|--integrated\b/, stage: "submit-test", title: "集成完成标记", kind: "integrated" },
8613
+ { re: /(^|-)retry-jenkins($|-)|(^|-)retry-jenkins-build($|-)|(^|-)record-jenkins($|-)/, stage: "self-test", title: "Jenkins 构建记录", kind: "jenkins" },
8614
+ { re: /(^|-)record-test-mr($|-)|(^|-)test-mr($|-)/, stage: "submit-test", title: "记录提测 MR", kind: "test-mr" },
8615
+ ];
8616
+ let matched = specs.find((spec) => spec.re.test(text));
8617
+ if (!matched && explicitRuntimeOnly) {
8618
+ matched = {
8619
+ stage: prdWorkflowFirstString(payload.stageKey, payload.stage_key, payload.stage, normalized.action, payload.action, "workflow"),
8620
+ title: prdWorkflowFirstString(payload.title, payload.label, normalized.action, payload.action, "Workflow runtime action"),
8621
+ kind: "runtime",
8622
+ };
8623
+ }
8624
+ if (!matched) return null;
8625
+ const issueKey = prdWorkflowFirstString(payload.issueKey, payload.issue_key, payload.issue);
8626
+ const url = prdWorkflowFirstString(payload.url, payload.href, payload.mr, payload.mrUrl, payload.mr_url, payload.mergeRequestUrl, payload.merge_request_url);
8627
+ const links = Array.isArray(payload.links) ? payload.links : [];
8628
+ const artifacts = Array.isArray(payload.artifacts) ? payload.artifacts : [];
8629
+ const title = prdWorkflowFirstString(payload.title, payload.label, matched.title);
8630
+ const stage = prdWorkflowFirstString(payload.stageKey, payload.stage_key, payload.stage, matched.stage);
8631
+ const linkArtifacts = url ? [{ kind: matched.kind, label: title, url }] : [];
8632
+ return {
8633
+ runtimeOnly: true,
8634
+ kind: matched.kind,
8635
+ stage,
8636
+ title,
8637
+ issueKey,
8638
+ detail: prdWorkflowFirstString(payload.detail, payload.description, payload.summary, url ? "记录外部系统事实,不写 ai-doc marker" : "记录运行态事实,不写 ai-doc marker"),
8639
+ links,
8640
+ artifacts: [...artifacts, ...linkArtifacts],
8641
+ };
8642
+ }
8643
+
8644
+ function prunePrdWorkflowIdempotency() {
8645
+ if (prdWorkflowIdempotency.size <= PRD_WORKFLOW_IDEMPOTENCY_MAX) return;
8646
+ const entries = Array.from(prdWorkflowIdempotency.entries())
8647
+ .sort((a, b) => Number(a[1]?.at || 0) - Number(b[1]?.at || 0));
8648
+ for (const [key] of entries.slice(0, Math.max(1, entries.length - PRD_WORKFLOW_IDEMPOTENCY_MAX))) {
8649
+ prdWorkflowIdempotency.delete(key);
8650
+ }
8651
+ }
8652
+
8653
+ function workspaceIntervalMinutesToCron(intervalMinutes) {
8654
+ const n = Number(intervalMinutes);
8655
+ if (!Number.isFinite(n) || n <= 0) return "0 9 * * *";
8656
+ const minutes = Math.max(1, Math.min(1440, Math.round(n)));
8657
+ if (minutes < 60) return `*/${minutes} * * * *`;
8658
+ if (minutes === 60) return "0 * * * *";
8659
+ if (minutes < 1440 && minutes % 60 === 0) return `0 */${minutes / 60} * * *`;
8660
+ return "0 9 * * *";
8661
+ }
8662
+
8663
+ function workspaceRunKey(userCtx, flowSource, flowId) {
8664
+ return `${userCtx?.userId || ""}:${flowSource || "user"}:${flowId}`;
8665
+ }
8666
+
8667
+ function workspaceRunEntryKey(scopeKey, runId) {
8668
+ return `${scopeKey}:${String(runId || "").trim() || runLedgerId("workspace")}`;
8669
+ }
8670
+
8671
+ function workspaceRuntimeNodeLabel(graph, nodeId, fallback = "Workspace Run") {
8672
+ const id = String(nodeId || "").trim();
8673
+ const instance = graph?.instances && typeof graph.instances === "object" ? graph.instances[id] : null;
8674
+ const label = String(instance?.label || "").trim();
8675
+ return label || id || fallback;
8676
+ }
8677
+
8678
+ function workspaceActiveRunsForScope(scopeKey) {
8679
+ const key = String(scopeKey || "");
8680
+ return Array.from(activeWorkspaceRuns.entries())
8681
+ .filter(([, entry]) => String(entry?.scopeKey || "") === key);
8682
+ }
8683
+
8684
+ function workspaceRunPlanNodeIds(runNodeId, plan) {
8685
+ return Array.from(new Set([
8686
+ String(runNodeId || "").trim(),
8687
+ ...(Array.isArray(plan?.order) ? plan.order : []),
8688
+ ...(Array.isArray(plan?.pauseNodeIds) ? plan.pauseNodeIds : []),
8689
+ ].map((id) => String(id || "").trim()).filter(Boolean)));
8690
+ }
8691
+
8692
+ function workspaceFindActiveRunConflict(scopeKey, plannedNodeIds) {
8693
+ const planned = new Set((plannedNodeIds || []).map((id) => String(id || "").trim()).filter(Boolean));
8694
+ for (const [key, entry] of workspaceActiveRunsForScope(scopeKey)) {
8695
+ const activeIds = Array.isArray(entry?.plannedNodeIds) ? entry.plannedNodeIds : [];
8696
+ if (!activeIds.length) {
8697
+ return { key, entry, conflictNodeIds: [] };
8698
+ }
8699
+ const conflictNodeIds = activeIds
8700
+ .map((id) => String(id || "").trim())
8701
+ .filter((id) => id && planned.has(id));
8702
+ if (conflictNodeIds.length) return { key, entry, conflictNodeIds };
8703
+ }
8704
+ return null;
8705
+ }
8706
+
8707
+ function normalizeWorkspaceScheduledRunConfig(raw) {
8708
+ let parsed = {};
8709
+ const text = String(raw || "").trim();
8710
+ if (text) {
8711
+ try {
8712
+ parsed = JSON.parse(text);
8713
+ } catch {
8714
+ parsed = {};
8715
+ }
8716
+ }
8717
+ const intervalMinutes = Number(parsed.intervalMinutes);
8718
+ const migratedCron = workspaceIntervalMinutesToCron(intervalMinutes);
8719
+ const cron = typeof parsed.cron === "string" && parsed.cron.trim()
8720
+ ? parsed.cron.trim()
8721
+ : migratedCron;
8722
+ const timezone = typeof parsed.timezone === "string" && parsed.timezone.trim()
8723
+ ? parsed.timezone.trim()
8724
+ : "Asia/Shanghai";
8725
+ const targetRunNodeId = typeof parsed.targetRunNodeId === "string" ? parsed.targetRunNodeId.trim() : "";
8726
+ const overlapPolicy = parsed.overlapPolicy === "skip" ? "skip" : "skip";
8727
+ return {
8728
+ enabled: parsed.enabled === true,
8729
+ cron,
8730
+ timezone,
8731
+ targetRunNodeId,
8732
+ overlapPolicy,
8733
+ };
8734
+ }
8735
+
8736
+ function workspaceScheduleNextRunAt(config, fromDate = new Date()) {
8737
+ if (!config?.enabled || !config?.cron) return null;
8738
+ return Date.parse(computeNextRunAt(config.cron, config.timezone || "Asia/Shanghai", fromDate));
8739
+ }
8740
+
8741
+ function workspaceSchedulesPath() {
8742
+ return path.join(getAgentflowDataRoot(), WORKSPACE_SCHEDULES_FILENAME);
8743
+ }
8744
+
8745
+ function readWorkspaceScheduleRegistry() {
8746
+ const filePath = workspaceSchedulesPath();
8747
+ if (!fs.existsSync(filePath)) return { version: 1, schedules: {} };
8748
+ try {
8749
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
8750
+ return {
8751
+ version: 1,
8752
+ schedules: parsed?.schedules && typeof parsed.schedules === "object" && !Array.isArray(parsed.schedules)
8753
+ ? parsed.schedules
8754
+ : {},
8755
+ };
8756
+ } catch {
8757
+ return { version: 1, schedules: {} };
8758
+ }
8759
+ }
8760
+
8761
+ function writeWorkspaceScheduleRegistry(registry) {
8762
+ const filePath = workspaceSchedulesPath();
8763
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
8764
+ fs.writeFileSync(filePath, JSON.stringify({
8765
+ version: 1,
8766
+ updatedAt: new Date().toISOString(),
8767
+ schedules: registry?.schedules && typeof registry.schedules === "object" ? registry.schedules : {},
8768
+ }, null, 2) + "\n", "utf-8");
8769
+ }
8770
+
8771
+ function workspaceScheduleKey(userId, flowSource, flowId, scheduleNodeId) {
8772
+ return [
8773
+ String(userId || ""),
8774
+ String(flowSource || "user"),
8775
+ String(flowId || ""),
8776
+ String(scheduleNodeId || ""),
8777
+ ].join(":");
8778
+ }
8779
+
8780
+ function listWorkspaceScheduleStatusesForFlow(userCtx = {}, flowSource = "user", flowId = "") {
8781
+ const registry = readWorkspaceScheduleRegistry();
8782
+ const userId = String(userCtx.userId || "");
8783
+ return Object.values(registry.schedules || {})
8784
+ .filter((entry) => (
8785
+ String(entry?.userId || "") === userId &&
8786
+ String(entry?.flowSource || "user") === String(flowSource || "user") &&
8787
+ String(entry?.flowId || "") === String(flowId || "")
8788
+ ))
8789
+ .sort((a, b) => String(a.scheduleNodeId || a.runNodeId || "").localeCompare(String(b.scheduleNodeId || b.runNodeId || "")));
8790
+ }
8791
+
8792
+ function listWorkspaceScheduleStatuses(root, userCtx = {}) {
8793
+ const registry = readWorkspaceScheduleRegistry();
8794
+ const userId = String(userCtx.userId || "");
8795
+ const flows = listFlowsJson(root, { ...userCtx, includeWorkspaceFlows: true })
8796
+ .filter((flow) => !flow.archived && !isReadonlyBuiltinFlowSource(flow.source || "user"));
8797
+ const rows = [];
8798
+ for (const flow of flows) {
8799
+ const flowId = String(flow.id || "");
8800
+ const flowSource = String(flow.source || "user");
8801
+ const scoped = resolveWorkspaceScopeRoot(root, { flowId, flowSource }, userCtx);
8802
+ if (scoped.error || !scoped.root) continue;
8803
+ let graph;
8804
+ try {
8805
+ graph = readWorkspaceGraph(scoped.root).graph;
8806
+ } catch {
8807
+ continue;
8808
+ }
8809
+ const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
8810
+ for (const [scheduleNodeId, instance] of Object.entries(instances)) {
8811
+ if (String(instance?.definitionId || "") !== "workspace_scheduled_run") continue;
8812
+ const config = normalizeWorkspaceScheduledRunConfig(instance.body || "");
8813
+ const key = workspaceScheduleKey(userId, flowSource, flowId, scheduleNodeId);
8814
+ const current = registry.schedules?.[key] && typeof registry.schedules[key] === "object" ? registry.schedules[key] : {};
8815
+ const targetRunNodeId = workspaceScheduleInferTargetRunNodeId(graph, scheduleNodeId, config);
8816
+ const scopeKey = workspaceRunKey(userCtx, flowSource, flowId);
8817
+ const running = workspaceActiveRunsForScope(scopeKey).some(([, active]) => (
6899
8818
  active?.scheduled === true &&
6900
8819
  String(active?.runNodeId || "") === String(targetRunNodeId || scheduleNodeId)
6901
8820
  ));
@@ -7430,33 +9349,718 @@ export function startUiServer({
7430
9349
  const uiPort = port;
7431
9350
  const uiConfig = { hideCommunityLinks: Boolean(hideCommunityLinks) };
7432
9351
 
7433
- const server = http.createServer(async (req, res) => {
7434
- const url = new URL(req.url || "/", "http://127.0.0.1");
7435
- const reqStart = Date.now();
7436
- log.debug(`[ui] ${req.method} ${url.pathname}${url.search || ""}`);
9352
+ const server = http.createServer(async (req, res) => {
9353
+ const url = new URL(req.url || "/", "http://127.0.0.1");
9354
+ const reqStart = Date.now();
9355
+ log.debug(`[ui] ${req.method} ${url.pathname}${url.search || ""}`);
9356
+
9357
+ const origEnd = res.end.bind(res);
9358
+ res.end = function (...args) {
9359
+ log.debug(`[ui] ${req.method} ${url.pathname} → ${res.statusCode} (${Date.now() - reqStart}ms)`);
9360
+ return origEnd(...args);
9361
+ };
9362
+
9363
+ if (url.pathname === "/api/auth/me" && req.method === "GET") {
9364
+ const user = getAuthUserFromRequest(req);
9365
+ const allowed = user ? isAuthUserAllowed(user) : true;
9366
+ const allowlist = readUserAllowlist();
9367
+ json(res, 200, {
9368
+ authenticated: Boolean(user && allowed),
9369
+ user: user && allowed ? user : null,
9370
+ setupRequired: authSetupRequired(),
9371
+ allowlistEnabled: allowlist.enabled,
9372
+ forbidden: Boolean(user && !allowed),
9373
+ error: user && !allowed ? "用户不在白名单中,请联系管理员开通访问权限" : "",
9374
+ });
9375
+ return;
9376
+ }
9377
+
9378
+ if (url.pathname === "/api/auth/login" && req.method === "POST") {
9379
+ let payload;
9380
+ try {
9381
+ payload = JSON.parse(await readBody(req));
9382
+ } catch {
9383
+ json(res, 400, { error: "Invalid JSON body" });
9384
+ return;
9385
+ }
9386
+ const result = loginOrCreateUser(payload?.username, payload?.password);
9387
+ if (!result.ok) {
9388
+ json(res, result.forbidden ? 403 : 401, { error: result.error || "Login failed", setupRequired: authSetupRequired() });
9389
+ return;
9390
+ }
9391
+ const body = JSON.stringify({ authenticated: true, user: result.user, setupRequired: false, migration: result.migration || null });
9392
+ res.writeHead(200, {
9393
+ "Content-Type": "application/json; charset=utf-8",
9394
+ "Content-Length": Buffer.byteLength(body),
9395
+ "Set-Cookie": buildSessionCookie(result.token),
9396
+ });
9397
+ res.end(body);
9398
+ return;
9399
+ }
9400
+
9401
+ if (url.pathname === "/api/auth/logout" && req.method === "POST") {
9402
+ logoutRequest(req);
9403
+ const body = JSON.stringify({ ok: true });
9404
+ res.writeHead(200, {
9405
+ "Content-Type": "application/json; charset=utf-8",
9406
+ "Content-Length": Buffer.byteLength(body),
9407
+ "Set-Cookie": buildClearSessionCookie(),
9408
+ });
9409
+ res.end(body);
9410
+ return;
9411
+ }
9412
+
9413
+ const authUser = getAuthUserFromRequest(req);
9414
+ const userCtx = authUser ? { userId: authUser.userId, isAdmin: Boolean(authUser.isAdmin) } : {};
9415
+ if (req.method === "GET" && url.pathname === "/api/auth/session-token") {
9416
+ if (!authUser?.userId) {
9417
+ json(res, 401, { error: "Unauthorized" });
9418
+ return;
9419
+ }
9420
+ json(res, 200, { token: getSessionTokenFromRequest(req) || "" });
9421
+ return;
9422
+ }
9423
+ if (req.method === "GET" && url.pathname === "/api/prd-workflow/snapshot") {
9424
+ try {
9425
+ const tapdId = String(url.searchParams.get("tapdId") || "").trim();
9426
+ const flowId = String(url.searchParams.get("flowId") || "").trim();
9427
+ const flowSource = String(url.searchParams.get("flowSource") || "user").trim() || "user";
9428
+ const archived = url.searchParams.get("archived") === "1";
9429
+ let scopedRoot = root;
9430
+ if (flowId) {
9431
+ const scoped = resolveWorkspaceScopeRoot(root, { flowId, flowSource, archived }, userCtx);
9432
+ if (scoped.error) {
9433
+ json(res, 400, { error: scoped.error });
9434
+ return;
9435
+ }
9436
+ scopedRoot = scoped.root;
9437
+ }
9438
+ const useMock = url.searchParams.get("mock") === "1" || parseBool(process.env.AGENTFLOW_PRD_WORKFLOW_MOCK, false);
9439
+ const runtimeOnly = url.searchParams.get("runtimeOnly") === "1" ||
9440
+ url.searchParams.get("runtime_only") === "1" ||
9441
+ url.searchParams.get("cached") === "1";
9442
+ const baseSnapshot = useMock
9443
+ ? prdWorkflowMockSnapshot(scopedRoot, tapdId || "mock-prd")
9444
+ : runtimeOnly
9445
+ ? prdWorkflowMaterializeSnapshot(root, scopedRoot, tapdId, userCtx, { flowSource, flowId })
9446
+ : await prdWorkflowSnapshot(root, scopedRoot, tapdId, userCtx, { flowSource, flowId });
9447
+ const snapshot = prdWorkflowWithAgentflowTokenDiagnostic(
9448
+ baseSnapshot,
9449
+ getSessionTokenFromRequest(req) || "",
9450
+ );
9451
+ json(res, 200, { ok: true, snapshot });
9452
+ } catch (e) {
9453
+ json(res, 500, { error: (e && e.message) || String(e) });
9454
+ }
9455
+ return;
9456
+ }
9457
+
9458
+ if (req.method === "POST" && url.pathname === "/api/prd-workflow/snapshot") {
9459
+ let payload;
9460
+ try {
9461
+ payload = JSON.parse(await readBody(req));
9462
+ } catch {
9463
+ json(res, 400, { error: "Invalid JSON body" });
9464
+ return;
9465
+ }
9466
+ try {
9467
+ const tapdId = String(payload.tapdId || payload.tapd_id || payload?.snapshot?.tapdId || payload?.snapshot?.tapd_id || payload?.snapshot?.prd?.tapd_id || "").trim();
9468
+ if (!tapdId) {
9469
+ json(res, 400, { error: "Missing tapdId" });
9470
+ return;
9471
+ }
9472
+ const flowId = String(payload.flowId || "").trim();
9473
+ const flowSource = String(payload.flowSource || "user").trim() || "user";
9474
+ const archived = payload.archived === true || payload.flowArchived === true;
9475
+ let scopedRoot = root;
9476
+ if (flowId) {
9477
+ const scoped = resolveWorkspaceScopeRoot(root, { flowId, flowSource, archived }, userCtx);
9478
+ if (scoped.error) {
9479
+ json(res, 400, { error: scoped.error });
9480
+ return;
9481
+ }
9482
+ scopedRoot = scoped.root;
9483
+ }
9484
+ const rawSnapshot = payload.snapshot && typeof payload.snapshot === "object" && !Array.isArray(payload.snapshot)
9485
+ ? payload.snapshot
9486
+ : payload.prd || payload.next ? payload : null;
9487
+ if (!rawSnapshot) {
9488
+ json(res, 400, { error: "Missing snapshot" });
9489
+ return;
9490
+ }
9491
+ const normalizedSnapshot = {
9492
+ ...prdWorkflowSnapshotFromParsed(scopedRoot, tapdId, rawSnapshot, userCtx, { flowSource, flowId }),
9493
+ clientReportedAt: new Date().toISOString(),
9494
+ sources: {
9495
+ ...(rawSnapshot.sources && typeof rawSnapshot.sources === "object" ? rawSnapshot.sources : {}),
9496
+ executionMode: "client-report",
9497
+ },
9498
+ };
9499
+ const reportMeta = prdWorkflowSnapshotMetaFromReport(payload, rawSnapshot, req, userCtx);
9500
+ const reportSource = {
9501
+ ...(normalizedSnapshot.sources && typeof normalizedSnapshot.sources === "object" ? normalizedSnapshot.sources : {}),
9502
+ executionMode: "client-report",
9503
+ truth: "observation",
9504
+ authority: "client",
9505
+ persistence: "runtime",
9506
+ clientId: reportMeta.clientId,
9507
+ clientUserId: reportMeta.userId,
9508
+ clientReportedAt: reportMeta.reportedAt,
9509
+ clientObservedAt: reportMeta.observedAt,
9510
+ baseRevision: reportMeta.baseRevision,
9511
+ scope: reportMeta.scope,
9512
+ platform: reportMeta.platform,
9513
+ issueKey: reportMeta.issueKey,
9514
+ stageKey: reportMeta.stageKey,
9515
+ };
9516
+ const storedObservationSnapshot = prdWorkflowStoredObservationSnapshot(normalizedSnapshot, reportSource);
9517
+ prdWorkflowWriteClientObservation(scopedRoot, tapdId, reportMeta, storedObservationSnapshot);
9518
+
9519
+ const projectFactSource = reportMeta.scope === "project"
9520
+ ? prdWorkflowProjectFactSource(payload, rawSnapshot)
9521
+ : null;
9522
+ const projectFactSnapshot = projectFactSource
9523
+ ? prdWorkflowStoredObservationSnapshot(normalizedSnapshot, {
9524
+ ...reportSource,
9525
+ ...projectFactSource,
9526
+ })
9527
+ : null;
9528
+ const projectRecord = prdWorkflowReadProjectStateWithFallback(root, scopedRoot, tapdId);
9529
+ const projectConflict = projectFactSnapshot
9530
+ ? prdWorkflowSnapshotReportConflict(projectRecord, projectFactSnapshot, reportMeta)
9531
+ : null;
9532
+ if (projectConflict) {
9533
+ prdWorkflowAppendRuntimeEvent(scopedRoot, tapdId, {
9534
+ id: "stage_project_plan_conflict",
9535
+ type: "project-plan-conflict",
9536
+ scope: "project",
9537
+ stage: reportMeta.stageKey || "project-plan",
9538
+ title: "主 Project Plan 冲突",
9539
+ detail: projectConflict.message,
9540
+ status: "conflict",
9541
+ source: "agentflow",
9542
+ expectedRevision: projectConflict.expectedRevision || reportMeta.baseRevision || "",
9543
+ currentRevision: projectConflict.currentRevision || "",
9544
+ incomingRevision: projectConflict.incomingRevision || projectFactSnapshot.revision || "",
9545
+ clientId: reportMeta.clientId,
9546
+ observedAt: reportMeta.observedAt,
9547
+ currentSnapshot: prdWorkflowCompactRuntimeValue(projectRecord?.snapshot || null, 12000),
9548
+ incomingSnapshot: prdWorkflowCompactRuntimeValue(projectFactSnapshot, 12000),
9549
+ });
9550
+ const currentSnapshot = prdWorkflowWithAgentflowTokenDiagnostic(
9551
+ prdWorkflowMaterializeSnapshot(root, scopedRoot, tapdId, userCtx, { flowSource, flowId }),
9552
+ getSessionTokenFromRequest(req) || "",
9553
+ );
9554
+ json(res, 409, {
9555
+ ok: false,
9556
+ error: projectConflict.message,
9557
+ conflict: {
9558
+ ...projectConflict,
9559
+ tapdId,
9560
+ type: "project-plan-conflict",
9561
+ currentPhase: String(currentSnapshot?.phase || ""),
9562
+ currentPointer: String(currentSnapshot?.pointer || ""),
9563
+ },
9564
+ snapshot: currentSnapshot,
9565
+ });
9566
+ return;
9567
+ }
9568
+ if (projectFactSnapshot) {
9569
+ prdWorkflowWriteProjectState(scopedRoot, tapdId, projectFactSnapshot, {
9570
+ sources: {
9571
+ ...projectFactSource,
9572
+ clientId: reportMeta.clientId,
9573
+ observedAt: reportMeta.observedAt,
9574
+ },
9575
+ });
9576
+ }
9577
+ const materialized = prdWorkflowMaterializeSnapshot(root, scopedRoot, tapdId, userCtx, { flowSource, flowId });
9578
+ const withDiagnostic = prdWorkflowWithAgentflowTokenDiagnostic(materialized, getSessionTokenFromRequest(req) || "");
9579
+ prdWorkflowBroadcast(prdWorkflowKey(userCtx, flowSource, flowId, tapdId), { type: "snapshot-report", tapdId, snapshot: withDiagnostic });
9580
+ json(res, 200, { ok: true, snapshot: withDiagnostic });
9581
+ } catch (e) {
9582
+ json(res, 500, { error: (e && e.message) || String(e) });
9583
+ }
9584
+ return;
9585
+ }
9586
+
9587
+ if (req.method === "POST" && url.pathname === "/api/prd-workflow/action") {
9588
+ let payload;
9589
+ try {
9590
+ payload = JSON.parse(await readBody(req));
9591
+ } catch {
9592
+ json(res, 400, { error: "Invalid JSON body" });
9593
+ return;
9594
+ }
9595
+ let actionScopedRoot = root;
9596
+ let normalizedForCatch = null;
9597
+ let actionRunId = "";
9598
+ try {
9599
+ const flowId = String(payload.flowId || "").trim();
9600
+ const flowSource = String(payload.flowSource || "user").trim() || "user";
9601
+ const archived = payload.archived === true || payload.flowArchived === true;
9602
+ let scopedRoot = root;
9603
+ if (flowId) {
9604
+ const scoped = resolveWorkspaceScopeRoot(root, { flowId, flowSource, archived }, userCtx);
9605
+ if (scoped.error) {
9606
+ json(res, 400, { error: scoped.error });
9607
+ return;
9608
+ }
9609
+ scopedRoot = scoped.root;
9610
+ }
9611
+ actionScopedRoot = scopedRoot;
9612
+ const normalized = normalizePrdWorkflowActionArgs(payload);
9613
+ normalizedForCatch = normalized;
9614
+ if (normalized.error) {
9615
+ json(res, 400, { error: normalized.error });
9616
+ return;
9617
+ }
9618
+ const idem = String(normalized.idempotencyKey || "").trim();
9619
+ const idemKey = idem ? prdWorkflowKey(userCtx, flowSource, flowId, `${normalized.tapdId}:${idem}`) : "";
9620
+ if (idemKey && prdWorkflowIdempotency.has(idemKey)) {
9621
+ json(res, 200, { ok: true, alreadyApplied: true, ...prdWorkflowIdempotency.get(idemKey)?.result });
9622
+ return;
9623
+ }
9624
+ const completedEvent = prdWorkflowFindCompletedIdempotencyEvent(scopedRoot, normalized.tapdId, idem);
9625
+ if (completedEvent) {
9626
+ const snapshot = prdWorkflowWithAgentflowTokenDiagnostic(
9627
+ await prdWorkflowSnapshot(root, scopedRoot, normalized.tapdId, userCtx, { flowSource, flowId }),
9628
+ getSessionTokenFromRequest(req) || "",
9629
+ );
9630
+ const result = {
9631
+ ok: true,
9632
+ alreadyApplied: true,
9633
+ action: normalized.action,
9634
+ tapdId: normalized.tapdId,
9635
+ output: completedEvent.output || null,
9636
+ rawOutput: completedEvent.rawOutput || "",
9637
+ snapshot,
9638
+ };
9639
+ if (idemKey) {
9640
+ prdWorkflowIdempotency.set(idemKey, { at: Date.now(), result });
9641
+ prunePrdWorkflowIdempotency();
9642
+ }
9643
+ json(res, 200, result);
9644
+ return;
9645
+ }
9646
+ const eventKey = prdWorkflowKey(userCtx, flowSource, flowId, normalized.tapdId);
9647
+ if (prdWorkflowActionLocks.has(eventKey)) {
9648
+ json(res, 409, { error: "Another workflow action is already running for this TAPD ID" });
9649
+ return;
9650
+ }
9651
+ const startedAtMs = Date.now();
9652
+ const startedAt = new Date(startedAtMs).toISOString();
9653
+ const issueKey = String(payload?.issueKey || payload?.issue_key || payload?.issue || "").trim();
9654
+ const dryRun = payload?.dryRun === true || payload?.dry_run === true;
9655
+ const stageKey = String(payload?.stageKey || payload?.stage_key || payload?.stage || payload?.phase || normalized.action || "").trim();
9656
+ const actionTitle = String(payload?.title || payload?.label || payload?.actionLabel || payload?.action_label || stageKey || normalized.action).trim();
9657
+ actionRunId = `stage_${prdWorkflowSafeStateId([stageKey || normalized.action, issueKey].filter(Boolean).join(":"))}`;
9658
+ prdWorkflowActionLocks.set(eventKey, {
9659
+ action: normalized.action,
9660
+ tapdId: normalized.tapdId,
9661
+ title: actionTitle,
9662
+ stage: stageKey || normalized.action,
9663
+ issueKey,
9664
+ startedAt: startedAtMs,
9665
+ id: actionRunId,
9666
+ userId: String(userCtx?.userId || ""),
9667
+ });
9668
+ let result;
9669
+ try {
9670
+ const forceRuntimeMarker = payload?.runtimeOnly === true || payload?.runtime_only === true ||
9671
+ payload?.markerOnly === true || payload?.marker_only === true;
9672
+ const markerEvent = (!normalized.fromCommand || forceRuntimeMarker)
9673
+ ? prdWorkflowMarkerEventSpec(payload, normalized)
9674
+ : null;
9675
+ if (!dryRun && (markerEvent || normalized.fromCommand)) {
9676
+ const expectedRevision = String(payload?.expectedRevision || "").trim();
9677
+ if (expectedRevision) {
9678
+ const latestForMarker = prdWorkflowWithAgentflowTokenDiagnostic(
9679
+ await prdWorkflowSnapshot(root, scopedRoot, normalized.tapdId, userCtx, { flowSource, flowId }),
9680
+ getSessionTokenFromRequest(req) || "",
9681
+ );
9682
+ const latestRevision = String(latestForMarker?.revision || "").trim();
9683
+ if (latestRevision && latestRevision !== expectedRevision) {
9684
+ const err = new Error(`expected revision ${expectedRevision} but current revision is ${latestRevision}`);
9685
+ err.latestSnapshot = latestForMarker;
9686
+ throw err;
9687
+ }
9688
+ }
9689
+ }
9690
+ const startEvent = prdWorkflowAppendRuntimeEvent(scopedRoot, normalized.tapdId, {
9691
+ id: actionRunId,
9692
+ type: "action-start",
9693
+ action: normalized.action,
9694
+ stage: markerEvent?.stage || stageKey || normalized.action,
9695
+ title: markerEvent?.title || actionTitle,
9696
+ detail: dryRun ? "预演中" : "执行中",
9697
+ status: "running",
9698
+ startedAt,
9699
+ dryRun,
9700
+ issueKey: markerEvent?.issueKey || issueKey,
9701
+ expectedRevision: payload?.expectedRevision || "",
9702
+ idempotencyKey: idem,
9703
+ });
9704
+ prdWorkflowBroadcast(eventKey, startEvent || { type: "action-start", action: normalized.action, tapdId: normalized.tapdId });
9705
+ if (markerEvent) {
9706
+ const output = {
9707
+ runtimeOnly: true,
9708
+ kind: markerEvent.kind,
9709
+ message: dryRun
9710
+ ? "该动作将记录为 Workflow runtime event,不会写 ai-doc marker commit。"
9711
+ : "已记录为 Workflow runtime event,未写 ai-doc marker commit。",
9712
+ stage: markerEvent.stage,
9713
+ issueKey: markerEvent.issueKey || issueKey,
9714
+ artifacts: markerEvent.artifacts,
9715
+ links: markerEvent.links,
9716
+ };
9717
+ prdWorkflowAppendRuntimeEvent(scopedRoot, normalized.tapdId, {
9718
+ id: actionRunId,
9719
+ type: dryRun ? "action-preview" : "action-done",
9720
+ source: "agentflow",
9721
+ action: normalized.action,
9722
+ stage: markerEvent.stage || stageKey || normalized.action,
9723
+ title: markerEvent.title || actionTitle,
9724
+ detail: markerEvent.detail,
9725
+ status: dryRun ? "current" : "done",
9726
+ startedAt,
9727
+ completedAt: new Date().toISOString(),
9728
+ dryRun,
9729
+ issueKey: markerEvent.issueKey || issueKey,
9730
+ expectedRevision: payload?.expectedRevision || "",
9731
+ idempotencyKey: idem,
9732
+ output,
9733
+ artifacts: markerEvent.artifacts,
9734
+ links: markerEvent.links,
9735
+ });
9736
+ const snapshot = prdWorkflowWithAgentflowTokenDiagnostic(
9737
+ await prdWorkflowSnapshot(root, scopedRoot, normalized.tapdId, userCtx, { flowSource, flowId }),
9738
+ getSessionTokenFromRequest(req) || "",
9739
+ );
9740
+ result = {
9741
+ ok: true,
9742
+ runtimeOnly: true,
9743
+ action: normalized.action,
9744
+ tapdId: normalized.tapdId,
9745
+ output,
9746
+ rawOutput: "",
9747
+ snapshot,
9748
+ };
9749
+ if (idemKey) {
9750
+ prdWorkflowIdempotency.set(idemKey, { at: Date.now(), result });
9751
+ prunePrdWorkflowIdempotency();
9752
+ }
9753
+ prdWorkflowBroadcast(eventKey, { type: "action-done", action: normalized.action, tapdId: normalized.tapdId, snapshot });
9754
+ json(res, 200, result);
9755
+ return;
9756
+ }
9757
+ if (normalized.fromCommand && dryRun) {
9758
+ const output = {
9759
+ preview: true,
9760
+ command: normalized.command || `prd-flow ${normalized.args.join(" ")}`,
9761
+ message: "预演模式只展示将执行的客户端 prd-flow 命令;确认后会登记 action request,等待客户端 skill 执行并上报结果。",
9762
+ args: normalized.args,
9763
+ };
9764
+ prdWorkflowAppendRuntimeEvent(scopedRoot, normalized.tapdId, {
9765
+ id: actionRunId,
9766
+ type: "action-preview",
9767
+ source: "agentflow",
9768
+ action: normalized.action,
9769
+ stage: stageKey || normalized.action,
9770
+ title: actionTitle,
9771
+ detail: output.message,
9772
+ status: "current",
9773
+ startedAt,
9774
+ completedAt: new Date().toISOString(),
9775
+ dryRun,
9776
+ issueKey,
9777
+ expectedRevision: payload?.expectedRevision || "",
9778
+ idempotencyKey: idem,
9779
+ output,
9780
+ });
9781
+ const snapshot = prdWorkflowWithAgentflowTokenDiagnostic(
9782
+ await prdWorkflowSnapshot(root, scopedRoot, normalized.tapdId, userCtx, { flowSource, flowId }),
9783
+ getSessionTokenFromRequest(req) || "",
9784
+ );
9785
+ result = {
9786
+ ok: true,
9787
+ preview: true,
9788
+ action: normalized.action,
9789
+ tapdId: normalized.tapdId,
9790
+ output,
9791
+ rawOutput: "",
9792
+ snapshot,
9793
+ };
9794
+ prdWorkflowBroadcast(eventKey, { type: "action-preview", action: normalized.action, tapdId: normalized.tapdId, snapshot });
9795
+ json(res, 200, result);
9796
+ return;
9797
+ }
9798
+ if (normalized.fromCommand && !prdWorkflowAllowServerExec()) {
9799
+ const output = {
9800
+ clientExecutionRequired: true,
9801
+ command: normalized.command || `prd-flow ${normalized.args.join(" ")}`,
9802
+ message: "已登记 Workflow action request;服务端不会执行客户端 prd-flow。请客户端 skill 使用 AGENTFLOW_BASE_URL + AGENTFLOW_TOKEN 执行该命令并上报 snapshot/event。",
9803
+ args: normalized.args,
9804
+ };
9805
+ prdWorkflowAppendRuntimeEvent(scopedRoot, normalized.tapdId, {
9806
+ id: actionRunId,
9807
+ type: "action-request",
9808
+ source: "agentflow",
9809
+ action: normalized.action,
9810
+ stage: stageKey || normalized.action,
9811
+ title: actionTitle,
9812
+ detail: output.message,
9813
+ status: "current",
9814
+ startedAt,
9815
+ completedAt: new Date().toISOString(),
9816
+ dryRun: false,
9817
+ issueKey,
9818
+ expectedRevision: payload?.expectedRevision || "",
9819
+ idempotencyKey: idem,
9820
+ output,
9821
+ command: output.command,
9822
+ });
9823
+ const snapshot = prdWorkflowWithAgentflowTokenDiagnostic(
9824
+ await prdWorkflowSnapshot(root, scopedRoot, normalized.tapdId, userCtx, { flowSource, flowId }),
9825
+ getSessionTokenFromRequest(req) || "",
9826
+ );
9827
+ result = {
9828
+ ok: true,
9829
+ actionRequested: true,
9830
+ action: normalized.action,
9831
+ tapdId: normalized.tapdId,
9832
+ output,
9833
+ rawOutput: "",
9834
+ snapshot,
9835
+ };
9836
+ if (idemKey) {
9837
+ prdWorkflowIdempotency.set(idemKey, { at: Date.now(), result });
9838
+ prunePrdWorkflowIdempotency();
9839
+ }
9840
+ prdWorkflowBroadcast(eventKey, { type: "action-request", action: normalized.action, tapdId: normalized.tapdId, snapshot });
9841
+ json(res, 200, result);
9842
+ return;
9843
+ }
9844
+ const runtimeEventUrl = `${serverPublicBaseUrl(req, host, uiPort)}/api/prd-workflow/event`;
9845
+ const commandResult = await runPrdWorkflowCommand(root, scopedRoot, normalized.args, userCtx, {
9846
+ timeout: 300000,
9847
+ env: {
9848
+ PRD_FLOW_RUNTIME_EVENT_URL: runtimeEventUrl,
9849
+ PRD_FLOW_RUNTIME_EVENT_TOKEN: getSessionTokenFromRequest(req) || "",
9850
+ PRD_FLOW_RUNTIME_TAPD_ID: normalized.tapdId,
9851
+ PRD_FLOW_RUNTIME_STAGE_KEY: stageKey || normalized.action,
9852
+ PRD_FLOW_RUNTIME_ISSUE_KEY: issueKey,
9853
+ PRD_FLOW_RUNTIME_FLOW_ID: flowId,
9854
+ PRD_FLOW_RUNTIME_FLOW_SOURCE: flowSource,
9855
+ PRD_FLOW_MARKER_POLICY: "runtime-only",
9856
+ PRD_FLOW_SUPPRESS_AI_DOC_MARKERS: "1",
9857
+ },
9858
+ });
9859
+ const parsed = prdWorkflowParseJson(commandResult.stdout);
9860
+ const rawOutput = parsed ? "" : String(commandResult.stdout || commandResult.stderr || "").slice(0, 12000);
9861
+ prdWorkflowAppendRuntimeEvent(scopedRoot, normalized.tapdId, {
9862
+ id: actionRunId,
9863
+ type: "action-done",
9864
+ action: normalized.action,
9865
+ stage: stageKey || normalized.action,
9866
+ title: actionTitle,
9867
+ detail: parsed?.message || parsed?.summary || (dryRun ? "预演完成,等待确认" : "阶段完成"),
9868
+ status: dryRun ? "current" : "done",
9869
+ startedAt,
9870
+ completedAt: new Date().toISOString(),
9871
+ dryRun,
9872
+ issueKey,
9873
+ expectedRevision: payload?.expectedRevision || "",
9874
+ idempotencyKey: idem,
9875
+ output: parsed || null,
9876
+ rawOutput,
9877
+ artifacts: Array.isArray(parsed?.artifacts) ? parsed.artifacts : [],
9878
+ links: Array.isArray(parsed?.links) ? parsed.links : [],
9879
+ });
9880
+ const snapshot = prdWorkflowWithAgentflowTokenDiagnostic(
9881
+ await prdWorkflowSnapshot(root, scopedRoot, normalized.tapdId, userCtx, { flowSource, flowId }),
9882
+ getSessionTokenFromRequest(req) || "",
9883
+ );
9884
+ result = {
9885
+ ok: true,
9886
+ action: normalized.action,
9887
+ tapdId: normalized.tapdId,
9888
+ output: parsed || null,
9889
+ rawOutput,
9890
+ snapshot,
9891
+ };
9892
+ if (idemKey) {
9893
+ prdWorkflowIdempotency.set(idemKey, { at: Date.now(), result });
9894
+ prunePrdWorkflowIdempotency();
9895
+ }
9896
+ prdWorkflowBroadcast(eventKey, { type: "action-done", action: normalized.action, tapdId: normalized.tapdId, snapshot });
9897
+ } finally {
9898
+ prdWorkflowActionLocks.delete(eventKey);
9899
+ }
9900
+ json(res, 200, result);
9901
+ } catch (e) {
9902
+ const status = /expected revision|stale|conflict|not allow|precondition/i.test(String(e?.message || e)) ? 409 : 500;
9903
+ const tapdId = String(normalizedForCatch?.tapdId || payload?.tapdId || payload?.tapd_id || "").trim();
9904
+ const flowId = String(payload?.flowId || "").trim();
9905
+ const flowSource = String(payload?.flowSource || "user").trim() || "user";
9906
+ const errorText = (e && e.message) || String(e);
9907
+ if (tapdId) {
9908
+ const issueKey = String(payload?.issueKey || payload?.issue_key || payload?.issue || "").trim();
9909
+ const stageKey = String(payload?.stageKey || payload?.stage_key || payload?.stage || payload?.phase || normalizedForCatch?.action || payload?.action || payload?.actionId || "").trim();
9910
+ prdWorkflowAppendRuntimeEvent(actionScopedRoot, tapdId, {
9911
+ id: actionRunId || `stage_${prdWorkflowSafeStateId([stageKey || payload?.action || payload?.actionId || "workflow-action", issueKey].filter(Boolean).join(":"))}`,
9912
+ type: status === 409 ? "action-conflict" : "action-error",
9913
+ action: String(normalizedForCatch?.action || payload?.action || payload?.actionId || ""),
9914
+ stage: stageKey || String(normalizedForCatch?.action || payload?.action || payload?.actionId || ""),
9915
+ title: String(payload?.title || payload?.label || normalizedForCatch?.action || payload?.action || payload?.actionId || "workflow action"),
9916
+ detail: errorText,
9917
+ status: status === 409 ? "conflict" : "error",
9918
+ completedAt: new Date().toISOString(),
9919
+ dryRun: payload?.dryRun === true || payload?.dry_run === true,
9920
+ issueKey,
9921
+ expectedRevision: payload?.expectedRevision || "",
9922
+ idempotencyKey: payload?.idempotencyKey || "",
9923
+ error: errorText,
9924
+ rawOutput: `${String(e?.stdout || "")}${String(e?.stderr || "")}`.slice(0, 12000),
9925
+ });
9926
+ }
9927
+ prdWorkflowBroadcast(prdWorkflowKey(userCtx, flowSource, flowId, tapdId), {
9928
+ type: "action-error",
9929
+ action: String(payload?.action || payload?.actionId || ""),
9930
+ tapdId,
9931
+ error: errorText,
9932
+ });
9933
+ let latestSnapshot = null;
9934
+ if (status === 409 && tapdId) {
9935
+ try {
9936
+ latestSnapshot = prdWorkflowWithAgentflowTokenDiagnostic(
9937
+ await prdWorkflowSnapshot(root, actionScopedRoot, tapdId, userCtx, { flowSource, flowId }),
9938
+ getSessionTokenFromRequest(req) || "",
9939
+ );
9940
+ } catch (_) {}
9941
+ }
9942
+ const conflict = status === 409 ? {
9943
+ action: String(normalizedForCatch?.action || payload?.action || payload?.actionId || ""),
9944
+ tapdId,
9945
+ expectedRevision: String(payload?.expectedRevision || ""),
9946
+ currentRevision: String(latestSnapshot?.revision || ""),
9947
+ currentPhase: String(latestSnapshot?.phase || ""),
9948
+ currentPointer: String(latestSnapshot?.pointer || ""),
9949
+ message: errorText,
9950
+ } : null;
9951
+ json(res, status, {
9952
+ error: errorText,
9953
+ rawOutput: `${String(e?.stdout || "")}${String(e?.stderr || "")}`.slice(0, 12000),
9954
+ snapshot: latestSnapshot,
9955
+ conflict,
9956
+ });
9957
+ }
9958
+ return;
9959
+ }
7437
9960
 
7438
- const origEnd = res.end.bind(res);
7439
- res.end = function (...args) {
7440
- log.debug(`[ui] ${req.method} ${url.pathname} ${res.statusCode} (${Date.now() - reqStart}ms)`);
7441
- return origEnd(...args);
7442
- };
9961
+ if (req.method === "GET" && url.pathname === "/api/prd-workflow/idempotency") {
9962
+ try {
9963
+ const tapdId = String(url.searchParams.get("tapdId") || url.searchParams.get("tapd_id") || "").trim();
9964
+ const idempotencyKey = String(url.searchParams.get("key") || url.searchParams.get("idempotencyKey") || "").trim();
9965
+ if (!tapdId) {
9966
+ json(res, 400, { error: "Missing tapdId" });
9967
+ return;
9968
+ }
9969
+ if (!idempotencyKey) {
9970
+ json(res, 400, { error: "Missing idempotency key" });
9971
+ return;
9972
+ }
9973
+ const flowId = String(url.searchParams.get("flowId") || "").trim();
9974
+ const flowSource = String(url.searchParams.get("flowSource") || "user").trim() || "user";
9975
+ const archived = url.searchParams.get("archived") === "1";
9976
+ let scopedRoot = root;
9977
+ if (flowId) {
9978
+ const scoped = resolveWorkspaceScopeRoot(root, { flowId, flowSource, archived }, userCtx);
9979
+ if (scoped.error) {
9980
+ json(res, 400, { error: scoped.error });
9981
+ return;
9982
+ }
9983
+ scopedRoot = scoped.root;
9984
+ }
9985
+ const event = prdWorkflowFindCompletedIdempotencyEvent(scopedRoot, tapdId, idempotencyKey);
9986
+ json(res, 200, {
9987
+ ok: true,
9988
+ found: !!event,
9989
+ result: event?.output || event?.result || null,
9990
+ rawOutput: event?.rawOutput || "",
9991
+ event: event || null,
9992
+ });
9993
+ } catch (e) {
9994
+ json(res, 500, { error: (e && e.message) || String(e) });
9995
+ }
9996
+ return;
9997
+ }
7443
9998
 
7444
- if (url.pathname === "/api/auth/me" && req.method === "GET") {
7445
- const user = getAuthUserFromRequest(req);
7446
- const allowed = user ? isAuthUserAllowed(user) : true;
7447
- const allowlist = readUserAllowlist();
7448
- json(res, 200, {
7449
- authenticated: Boolean(user && allowed),
7450
- user: user && allowed ? user : null,
7451
- setupRequired: authSetupRequired(),
7452
- allowlistEnabled: allowlist.enabled,
7453
- forbidden: Boolean(user && !allowed),
7454
- error: user && !allowed ? "用户不在白名单中,请联系管理员开通访问权限" : "",
7455
- });
9999
+ if (req.method === "POST" && url.pathname === "/api/prd-workflow/idempotency") {
10000
+ let payload;
10001
+ try {
10002
+ payload = JSON.parse(await readBody(req));
10003
+ } catch {
10004
+ json(res, 400, { error: "Invalid JSON body" });
10005
+ return;
10006
+ }
10007
+ try {
10008
+ const tapdId = String(payload.tapdId || payload.tapd_id || "").trim();
10009
+ const idempotencyKey = String(payload.key || payload.idempotencyKey || payload.idempotency_key || "").trim();
10010
+ if (!tapdId) {
10011
+ json(res, 400, { error: "Missing tapdId" });
10012
+ return;
10013
+ }
10014
+ if (!idempotencyKey) {
10015
+ json(res, 400, { error: "Missing idempotency key" });
10016
+ return;
10017
+ }
10018
+ const flowId = String(payload.flowId || "").trim();
10019
+ const flowSource = String(payload.flowSource || "user").trim() || "user";
10020
+ const archived = payload.archived === true || payload.flowArchived === true;
10021
+ let scopedRoot = root;
10022
+ if (flowId) {
10023
+ const scoped = resolveWorkspaceScopeRoot(root, { flowId, flowSource, archived }, userCtx);
10024
+ if (scoped.error) {
10025
+ json(res, 400, { error: scoped.error });
10026
+ return;
10027
+ }
10028
+ scopedRoot = scoped.root;
10029
+ }
10030
+ const existing = prdWorkflowFindCompletedIdempotencyEvent(scopedRoot, tapdId, idempotencyKey);
10031
+ if (existing) {
10032
+ json(res, 200, { ok: true, found: true, event: existing, result: existing.output || existing.result || null });
10033
+ return;
10034
+ }
10035
+ const command = String(payload.command || "").slice(0, 1000);
10036
+ const result = payload.result && typeof payload.result === "object" && !Array.isArray(payload.result)
10037
+ ? payload.result
10038
+ : { message: String(payload.message || "already completed") };
10039
+ const event = prdWorkflowAppendRuntimeEvent(scopedRoot, tapdId, {
10040
+ type: "idempotent-command-completed",
10041
+ source: "prd-flow-client",
10042
+ auxiliary: true,
10043
+ aggregateByStage: false,
10044
+ conflictOnArtifact: false,
10045
+ action: payload.action || "",
10046
+ stage: payload.stage || payload.stageKey || payload.stage_key || "idempotency",
10047
+ title: payload.title || "prd-flow command completed",
10048
+ detail: command ? `Command completed: ${command}` : "Command completed",
10049
+ status: "done",
10050
+ completedAt: new Date().toISOString(),
10051
+ idempotencyKey,
10052
+ command,
10053
+ output: result,
10054
+ result,
10055
+ });
10056
+ json(res, 200, { ok: true, found: true, event, result });
10057
+ } catch (e) {
10058
+ json(res, 500, { error: (e && e.message) || String(e) });
10059
+ }
7456
10060
  return;
7457
10061
  }
7458
10062
 
7459
- if (url.pathname === "/api/auth/login" && req.method === "POST") {
10063
+ if (req.method === "POST" && url.pathname === "/api/prd-workflow/event") {
7460
10064
  let payload;
7461
10065
  try {
7462
10066
  payload = JSON.parse(await readBody(req));
@@ -7464,43 +10068,209 @@ export function startUiServer({
7464
10068
  json(res, 400, { error: "Invalid JSON body" });
7465
10069
  return;
7466
10070
  }
7467
- const result = loginOrCreateUser(payload?.username, payload?.password);
7468
- if (!result.ok) {
7469
- json(res, result.forbidden ? 403 : 401, { error: result.error || "Login failed", setupRequired: authSetupRequired() });
10071
+ try {
10072
+ const tapdId = String(payload.tapdId || payload.tapd_id || "").trim();
10073
+ if (!tapdId) {
10074
+ json(res, 400, { error: "Missing tapdId" });
10075
+ return;
10076
+ }
10077
+ const flowId = String(payload.flowId || "").trim();
10078
+ const flowSource = String(payload.flowSource || "user").trim() || "user";
10079
+ const archived = payload.archived === true || payload.flowArchived === true;
10080
+ let scopedRoot = root;
10081
+ if (flowId) {
10082
+ const scoped = resolveWorkspaceScopeRoot(root, { flowId, flowSource, archived }, userCtx);
10083
+ if (scoped.error) {
10084
+ json(res, 400, { error: scoped.error });
10085
+ return;
10086
+ }
10087
+ scopedRoot = scoped.root;
10088
+ }
10089
+ const eventPayload = payload.event && typeof payload.event === "object" && !Array.isArray(payload.event)
10090
+ ? payload.event
10091
+ : payload;
10092
+ const event = prdWorkflowAppendRuntimeEvent(scopedRoot, tapdId, {
10093
+ ...eventPayload,
10094
+ tapdId,
10095
+ type: eventPayload.type || "workflow-event",
10096
+ });
10097
+ const snapshot = prdWorkflowWithAgentflowTokenDiagnostic(
10098
+ await prdWorkflowSnapshot(root, scopedRoot, tapdId, userCtx, { flowSource, flowId }),
10099
+ getSessionTokenFromRequest(req) || "",
10100
+ );
10101
+ prdWorkflowBroadcast(prdWorkflowKey(userCtx, flowSource, flowId, tapdId), { type: "runtime-event", tapdId, event, snapshot });
10102
+ json(res, 200, { ok: true, event, snapshot });
10103
+ } catch (e) {
10104
+ json(res, 500, { error: (e && e.message) || String(e) });
10105
+ }
10106
+ return;
10107
+ }
10108
+
10109
+ if (req.method === "POST" && url.pathname === "/api/prd-workflow/review-link") {
10110
+ let payload;
10111
+ try {
10112
+ payload = JSON.parse(await readBody(req));
10113
+ } catch {
10114
+ json(res, 400, { error: "Invalid JSON body" });
7470
10115
  return;
7471
10116
  }
7472
- const body = JSON.stringify({ authenticated: true, user: result.user, setupRequired: false, migration: result.migration || null });
7473
- res.writeHead(200, {
7474
- "Content-Type": "application/json; charset=utf-8",
7475
- "Content-Length": Buffer.byteLength(body),
7476
- "Set-Cookie": buildSessionCookie(result.token),
7477
- });
7478
- res.end(body);
10117
+ try {
10118
+ const tapdId = String(payload.tapdId || payload.tapd_id || "").trim();
10119
+ if (!tapdId) {
10120
+ json(res, 400, { error: "Missing tapdId" });
10121
+ return;
10122
+ }
10123
+ const flowId = String(payload.flowId || "").trim();
10124
+ const flowSource = String(payload.flowSource || "user").trim() || "user";
10125
+ const archived = payload.archived === true || payload.flowArchived === true;
10126
+ let scopedRoot = root;
10127
+ if (flowId) {
10128
+ const scoped = resolveWorkspaceScopeRoot(root, { flowId, flowSource, archived }, userCtx);
10129
+ if (scoped.error) {
10130
+ json(res, 400, { error: scoped.error });
10131
+ return;
10132
+ }
10133
+ scopedRoot = scoped.root;
10134
+ }
10135
+ const review = prdWorkflowCreateReview(scopedRoot, tapdId, payload, serverPublicBaseUrl(req, host, uiPort, payload));
10136
+ const query = new URLSearchParams();
10137
+ if (flowId) query.set("flowId", flowId);
10138
+ if (flowSource) query.set("flowSource", flowSource);
10139
+ if (archived) query.set("archived", "1");
10140
+ const reviewUrl = query.toString() ? `${review.url}?${query.toString()}` : review.url;
10141
+ const durability = review.durability || "temporary";
10142
+ const reviewSource = review.source && typeof review.source === "object" && !Array.isArray(review.source)
10143
+ ? review.source
10144
+ : { kind: durability === "durable" ? "ai-doc" : "local-draft", durability };
10145
+ const artifact = {
10146
+ label: payload.artifactLabel || "Markdown Review",
10147
+ kind: durability === "temporary" ? "temporary-review" : "review",
10148
+ persistence: "runtime",
10149
+ durability,
10150
+ source: reviewSource,
10151
+ confirmed: payload.confirmed === true || payload.confirmed === "1",
10152
+ url: reviewUrl,
10153
+ expiresAt: review.expiresAt || "",
10154
+ };
10155
+ const event = prdWorkflowAppendRuntimeEvent(scopedRoot, tapdId, {
10156
+ type: "review-link",
10157
+ auxiliary: true,
10158
+ conflictOnArtifact: false,
10159
+ truth: "runtime_event",
10160
+ persistence: "runtime",
10161
+ action: payload.action || payload.actionId || "",
10162
+ stage: payload.stage || payload.stageKey || payload.stage_key || "review",
10163
+ title: payload.title || "临时 Markdown Review",
10164
+ detail: "已生成临时 Markdown review 链接",
10165
+ status: "current",
10166
+ issueKey: payload.issueKey || payload.issue_key || "",
10167
+ durability,
10168
+ sourceArtifact: reviewSource,
10169
+ expiresAt: review.expiresAt || "",
10170
+ artifacts: [artifact],
10171
+ links: [{ label: "Markdown Review", url: reviewUrl, persistence: "runtime", durability, source: reviewSource, expiresAt: review.expiresAt || "" }],
10172
+ reviewId: review.id,
10173
+ });
10174
+ const snapshot = prdWorkflowWithAgentflowTokenDiagnostic(
10175
+ await prdWorkflowSnapshot(root, scopedRoot, tapdId, userCtx, { flowSource, flowId }),
10176
+ getSessionTokenFromRequest(req) || "",
10177
+ );
10178
+ prdWorkflowBroadcast(prdWorkflowKey(userCtx, flowSource, flowId, tapdId), { type: "review-link", tapdId, event, snapshot });
10179
+ json(res, 200, { ok: true, review: { ...review, url: reviewUrl }, event, snapshot });
10180
+ } catch (e) {
10181
+ json(res, 500, { error: (e && e.message) || String(e) });
10182
+ }
7479
10183
  return;
7480
10184
  }
7481
10185
 
7482
- if (url.pathname === "/api/auth/logout" && req.method === "POST") {
7483
- logoutRequest(req);
7484
- const body = JSON.stringify({ ok: true });
10186
+ if (req.method === "GET" && url.pathname === "/api/prd-workflow/events") {
10187
+ const tapdId = String(url.searchParams.get("tapdId") || "").trim();
10188
+ const flowId = String(url.searchParams.get("flowId") || "").trim();
10189
+ const flowSource = String(url.searchParams.get("flowSource") || "user").trim() || "user";
10190
+ const key = prdWorkflowKey(userCtx, flowSource, flowId, tapdId);
10191
+ let set = prdWorkflowSubscribers.get(key);
10192
+ if (!set) {
10193
+ set = new Set();
10194
+ prdWorkflowSubscribers.set(key, set);
10195
+ }
7485
10196
  res.writeHead(200, {
7486
- "Content-Type": "application/json; charset=utf-8",
7487
- "Content-Length": Buffer.byteLength(body),
7488
- "Set-Cookie": buildClearSessionCookie(),
10197
+ "Content-Type": "text/event-stream; charset=utf-8",
10198
+ "Cache-Control": "no-cache, no-transform",
10199
+ Connection: "keep-alive",
10200
+ "X-Content-Type-Options": "nosniff",
7489
10201
  });
7490
- res.end(body);
10202
+ res.write(": connected\n\n");
10203
+ set.add(res);
10204
+ const detach = () => {
10205
+ try {
10206
+ set.delete(res);
10207
+ if (set.size === 0) prdWorkflowSubscribers.delete(key);
10208
+ } catch (_) {}
10209
+ };
10210
+ req.on("close", detach);
10211
+ res.on("close", detach);
7491
10212
  return;
7492
10213
  }
7493
10214
 
7494
- const authUser = getAuthUserFromRequest(req);
7495
- const userCtx = authUser ? { userId: authUser.userId, isAdmin: Boolean(authUser.isAdmin) } : {};
7496
- if (req.method === "GET" && url.pathname === "/api/auth/session-token") {
7497
- if (!authUser?.userId) {
7498
- json(res, 401, { error: "Unauthorized" });
7499
- return;
10215
+ if (req.method === "GET" && url.pathname.startsWith("/api/prd-workflow/review/")) {
10216
+ try {
10217
+ const parts = url.pathname.split("/").filter(Boolean);
10218
+ const tapdId = decodeURIComponent(parts[3] || "");
10219
+ const reviewId = decodeURIComponent(parts[4] || "");
10220
+ if (!tapdId || !reviewId) {
10221
+ res.writeHead(404);
10222
+ res.end("Not found");
10223
+ return;
10224
+ }
10225
+ const flowId = String(url.searchParams.get("flowId") || "").trim();
10226
+ const flowSource = String(url.searchParams.get("flowSource") || "user").trim() || "user";
10227
+ const archived = url.searchParams.get("archived") === "1";
10228
+ let scopedRoot = root;
10229
+ if (flowId) {
10230
+ const scoped = resolveWorkspaceScopeRoot(root, { flowId, flowSource, archived }, userCtx);
10231
+ if (scoped.error) {
10232
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
10233
+ res.end(scoped.error);
10234
+ return;
10235
+ }
10236
+ scopedRoot = scoped.root;
10237
+ }
10238
+ const paths = prdWorkflowReviewPaths(scopedRoot, tapdId, reviewId);
10239
+ if (!fs.existsSync(paths.markdownPath) || !fs.statSync(paths.markdownPath).isFile()) {
10240
+ res.writeHead(404);
10241
+ res.end("Not found");
10242
+ return;
10243
+ }
10244
+ const markdown = fs.readFileSync(paths.markdownPath, "utf-8");
10245
+ let meta = {};
10246
+ try {
10247
+ if (fs.existsSync(paths.metaPath)) meta = JSON.parse(fs.readFileSync(paths.metaPath, "utf-8"));
10248
+ } catch (_) {}
10249
+ const expiresMs = Date.parse(meta?.expiresAt || "");
10250
+ if (Number.isFinite(expiresMs) && expiresMs < Date.now()) {
10251
+ res.writeHead(410, { "Content-Type": "text/plain; charset=utf-8" });
10252
+ res.end("Review link expired");
10253
+ return;
10254
+ }
10255
+ const rawParams = new URLSearchParams(url.searchParams);
10256
+ rawParams.set("raw", "1");
10257
+ meta = { ...(meta && typeof meta === "object" && !Array.isArray(meta) ? meta : {}), rawHref: `${url.pathname}?${rawParams.toString()}` };
10258
+ if (url.searchParams.get("raw") === "1") {
10259
+ const data = Buffer.from(markdown, "utf-8");
10260
+ res.writeHead(200, { "Content-Type": "text/markdown; charset=utf-8", "Content-Length": data.length });
10261
+ res.end(data);
10262
+ return;
10263
+ }
10264
+ const html = Buffer.from(prdWorkflowReviewHtml(meta.title || "PRD Workflow Review", markdown, meta), "utf-8");
10265
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Content-Length": html.length });
10266
+ res.end(html);
10267
+ } catch (e) {
10268
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
10269
+ res.end((e && e.message) || String(e));
7500
10270
  }
7501
- json(res, 200, { token: getSessionTokenFromRequest(req) || "" });
7502
10271
  return;
7503
10272
  }
10273
+
7504
10274
  if (req.method === "GET" && url.pathname === "/api/display/shares") {
7505
10275
  try {
7506
10276
  if (!authUser?.userId) {
@@ -9236,6 +12006,52 @@ export function startUiServer({
9236
12006
  return;
9237
12007
  }
9238
12008
 
12009
+ if (req.method === "GET" && url.pathname === "/api/model-visibility") {
12010
+ if (!authUser?.isAdmin) {
12011
+ json(res, 403, { error: "Admin permission required" });
12012
+ return;
12013
+ }
12014
+ try {
12015
+ const allModelLists = readModelListsFromDisk(root, { raw: true });
12016
+ const hiddenModels = readHiddenModelConfig();
12017
+ json(res, 200, {
12018
+ allModelLists,
12019
+ modelLists: applyModelVisibility(allModelLists, hiddenModels),
12020
+ hiddenModels,
12021
+ });
12022
+ } catch (e) {
12023
+ json(res, 500, { error: (e && e.message) || String(e) });
12024
+ }
12025
+ return;
12026
+ }
12027
+
12028
+ if (req.method === "POST" && url.pathname === "/api/model-visibility") {
12029
+ if (!authUser?.isAdmin) {
12030
+ json(res, 403, { error: "Admin permission required" });
12031
+ return;
12032
+ }
12033
+ let payload;
12034
+ try {
12035
+ payload = JSON.parse(await readBody(req));
12036
+ } catch {
12037
+ json(res, 400, { error: "Invalid JSON body" });
12038
+ return;
12039
+ }
12040
+ try {
12041
+ const hiddenModels = writeHiddenModelConfig(payload?.hiddenModels || {});
12042
+ const allModelLists = readModelListsFromDisk(root, { raw: true });
12043
+ json(res, 200, {
12044
+ success: true,
12045
+ allModelLists,
12046
+ modelLists: applyModelVisibility(allModelLists, hiddenModels),
12047
+ hiddenModels,
12048
+ });
12049
+ } catch (e) {
12050
+ json(res, 500, { error: (e && e.message) || String(e) });
12051
+ }
12052
+ return;
12053
+ }
12054
+
9239
12055
  if (req.method === "GET" && url.pathname === "/api/ui-context") {
9240
12056
  try {
9241
12057
  json(res, 200, { workspaceRoot: root, ...uiConfig });