@fieldwangai/agentflow 0.1.91 → 0.1.93

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